test(dashboard): add unit tests for dynamic form components

- Create comprehensive test suite for DynamicField (21 tests)
- Create comprehensive test suite for DynamicConfigForm (10 tests)
- Create comprehensive test suite for FieldHookRegistry (21 tests)
- Configure Vitest 4.0.18 with jsdom environment
- Add test setup with ResizeObserver and matchMedia polyfills
- 52 tests total covering all core functionality
pull/1496/head
DrSmoothl 2026-02-17 18:18:32 +08:00
parent c58ad64352
commit 69dfd0cac6
No known key found for this signature in database
9 changed files with 1077 additions and 3 deletions

View File

@ -8,7 +8,9 @@
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"format": "prettier --write \"src/**/*.{ts,tsx,css}\""
"format": "prettier --write \"src/**/*.{ts,tsx,css}\"",
"test": "vitest",
"test:ui": "vitest --ui"
},
"dependencies": {
"@codemirror/lang-javascript": "^6.2.4",
@ -75,21 +77,27 @@
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.10.2",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.2",
"@vitest/ui": "^4.0.18",
"autoprefixer": "^10.4.22",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"jsdom": "^28.1.0",
"postcss": "^8.5.6",
"prettier": "^3.7.4",
"prettier-plugin-tailwindcss": "^0.7.2",
"tailwindcss": "^3",
"typescript": "~5.9.3",
"typescript-eslint": "^8.49.0",
"vite": "^7.2.7"
"vite": "^7.2.7",
"vitest": "^4.0.18"
}
}

View File

@ -0,0 +1,362 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { DynamicConfigForm } from '../DynamicConfigForm'
import { FieldHookRegistry } from '@/lib/field-hooks'
import type { ConfigSchema } from '@/types/config-schema'
import type { FieldHookComponentProps } from '@/lib/field-hooks'
describe('DynamicConfigForm', () => {
describe('basic rendering', () => {
it('renders simple fields', () => {
const schema: ConfigSchema = {
className: 'TestConfig',
classDoc: 'Test configuration',
fields: [
{
name: 'field1',
type: 'string',
label: 'Field 1',
description: 'First field',
required: false,
default: 'value1',
},
{
name: 'field2',
type: 'boolean',
label: 'Field 2',
description: 'Second field',
required: false,
default: false,
},
],
}
const values = { field1: 'value1', field2: false }
const onChange = vi.fn()
render(<DynamicConfigForm schema={schema} values={values} onChange={onChange} />)
expect(screen.getByText('Field 1')).toBeInTheDocument()
expect(screen.getByText('Field 2')).toBeInTheDocument()
expect(screen.getByText('First field')).toBeInTheDocument()
expect(screen.getByText('Second field')).toBeInTheDocument()
})
it('renders nested schema', () => {
const schema: ConfigSchema = {
className: 'MainConfig',
classDoc: 'Main configuration',
fields: [
{
name: 'top_field',
type: 'string',
label: 'Top Field',
description: 'Top level field',
required: false,
},
],
nested: {
sub_config: {
className: 'SubConfig',
classDoc: 'Sub configuration',
fields: [
{
name: 'nested_field',
type: 'number',
label: 'Nested Field',
description: 'Nested field',
required: false,
default: 42,
},
],
},
},
}
const values = {
top_field: 'top',
sub_config: {
nested_field: 42,
},
}
const onChange = vi.fn()
render(<DynamicConfigForm schema={schema} values={values} onChange={onChange} />)
expect(screen.getByText('Top Field')).toBeInTheDocument()
expect(screen.getByText('SubConfig')).toBeInTheDocument()
expect(screen.getByText('Sub configuration')).toBeInTheDocument()
expect(screen.getByText('Nested Field')).toBeInTheDocument()
})
})
describe('Hook system', () => {
it('renders Hook component in replace mode', () => {
const TestHookComponent: React.FC<FieldHookComponentProps> = ({ fieldPath, value }) => {
return <div data-testid="hook-component">Hook: {fieldPath} = {String(value)}</div>
}
const hooks = new FieldHookRegistry()
hooks.register('hooked_field', TestHookComponent, 'replace')
const schema: ConfigSchema = {
className: 'TestConfig',
classDoc: 'Test configuration',
fields: [
{
name: 'hooked_field',
type: 'string',
label: 'Hooked Field',
description: 'A field with hook',
required: false,
},
{
name: 'normal_field',
type: 'string',
label: 'Normal Field',
description: 'A normal field',
required: false,
},
],
}
const values = { hooked_field: 'test', normal_field: 'normal' }
const onChange = vi.fn()
render(<DynamicConfigForm schema={schema} values={values} onChange={onChange} hooks={hooks} />)
expect(screen.getByTestId('hook-component')).toBeInTheDocument()
expect(screen.getByText('Hook: hooked_field = test')).toBeInTheDocument()
expect(screen.queryByText('Hooked Field')).not.toBeInTheDocument()
expect(screen.getByText('Normal Field')).toBeInTheDocument()
})
it('renders Hook component in wrapper mode', () => {
const WrapperHookComponent: React.FC<FieldHookComponentProps> = ({ fieldPath, children }) => {
return (
<div data-testid="wrapper-hook">
<div>Wrapper for: {fieldPath}</div>
{children}
</div>
)
}
const hooks = new FieldHookRegistry()
hooks.register('wrapped_field', WrapperHookComponent, 'wrapper')
const schema: ConfigSchema = {
className: 'TestConfig',
classDoc: 'Test configuration',
fields: [
{
name: 'wrapped_field',
type: 'string',
label: 'Wrapped Field',
description: 'A wrapped field',
required: false,
},
],
}
const values = { wrapped_field: 'test' }
const onChange = vi.fn()
render(<DynamicConfigForm schema={schema} values={values} onChange={onChange} hooks={hooks} />)
expect(screen.getByTestId('wrapper-hook')).toBeInTheDocument()
expect(screen.getByText('Wrapper for: wrapped_field')).toBeInTheDocument()
expect(screen.getByText('Wrapped Field')).toBeInTheDocument()
})
it('passes correct props to Hook component', () => {
const TestHookComponent: React.FC<FieldHookComponentProps> = ({ fieldPath, value, onChange }) => {
return (
<div>
<div data-testid="field-path">{fieldPath}</div>
<div data-testid="field-value">{String(value)}</div>
<button onClick={() => onChange?.('new_value')}>Change</button>
</div>
)
}
const hooks = new FieldHookRegistry()
hooks.register('test_field', TestHookComponent, 'replace')
const schema: ConfigSchema = {
className: 'TestConfig',
classDoc: 'Test configuration',
fields: [
{
name: 'test_field',
type: 'string',
label: 'Test Field',
description: 'A test field',
required: false,
},
],
}
const values = { test_field: 'original' }
const onChange = vi.fn()
render(<DynamicConfigForm schema={schema} values={values} onChange={onChange} hooks={hooks} />)
expect(screen.getByTestId('field-path')).toHaveTextContent('test_field')
expect(screen.getByTestId('field-value')).toHaveTextContent('original')
})
})
describe('onChange propagation', () => {
it('propagates onChange from simple field', async () => {
const schema: ConfigSchema = {
className: 'TestConfig',
classDoc: 'Test configuration',
fields: [
{
name: 'test_field',
type: 'string',
label: 'Test Field',
description: 'A test field',
required: false,
},
],
}
const values = { test_field: '' }
const onChange = vi.fn()
render(<DynamicConfigForm schema={schema} values={values} onChange={onChange} />)
const input = screen.getByRole('textbox')
input.focus()
await userEvent.keyboard('Hello')
expect(onChange).toHaveBeenCalledTimes(5)
expect(onChange.mock.calls.every(call => call[0] === 'test_field')).toBe(true)
expect(onChange).toHaveBeenNthCalledWith(1, 'test_field', 'H')
expect(onChange).toHaveBeenNthCalledWith(5, 'test_field', 'o')
})
it('propagates onChange from nested field with correct path', async () => {
const schema: ConfigSchema = {
className: 'MainConfig',
classDoc: 'Main configuration',
fields: [],
nested: {
sub_config: {
className: 'SubConfig',
classDoc: 'Sub configuration',
fields: [
{
name: 'nested_field',
type: 'string',
label: 'Nested Field',
description: 'Nested field',
required: false,
},
],
},
},
}
const values = {
sub_config: {
nested_field: '',
},
}
const onChange = vi.fn()
render(<DynamicConfigForm schema={schema} values={values} onChange={onChange} />)
const input = screen.getByRole('textbox')
input.focus()
await userEvent.keyboard('Test')
expect(onChange).toHaveBeenCalledTimes(4)
expect(onChange.mock.calls.every(call => call[0] === 'sub_config.nested_field')).toBe(true)
expect(onChange).toHaveBeenNthCalledWith(1, 'sub_config.nested_field', 'T')
expect(onChange).toHaveBeenNthCalledWith(4, 'sub_config.nested_field', 't')
})
it('propagates onChange from Hook component', async () => {
const TestHookComponent: React.FC<FieldHookComponentProps> = ({ onChange }) => {
return <button onClick={() => onChange?.('hook_value')}>Set Value</button>
}
const hooks = new FieldHookRegistry()
hooks.register('hooked_field', TestHookComponent, 'replace')
const schema: ConfigSchema = {
className: 'TestConfig',
classDoc: 'Test configuration',
fields: [
{
name: 'hooked_field',
type: 'string',
label: 'Hooked Field',
description: 'A hooked field',
required: false,
},
],
}
const values = { hooked_field: '' }
const onChange = vi.fn()
const user = userEvent.setup()
render(<DynamicConfigForm schema={schema} values={values} onChange={onChange} hooks={hooks} />)
await user.click(screen.getByRole('button'))
expect(onChange).toHaveBeenCalledWith('hooked_field', 'hook_value')
})
})
describe('edge cases', () => {
it('renders with empty nested values', () => {
const schema: ConfigSchema = {
className: 'MainConfig',
classDoc: 'Main configuration',
fields: [],
nested: {
sub_config: {
className: 'SubConfig',
classDoc: 'Sub configuration',
fields: [
{
name: 'nested_field',
type: 'string',
label: 'Nested Field',
description: 'Nested field',
required: false,
},
],
},
},
}
const values = {}
const onChange = vi.fn()
render(<DynamicConfigForm schema={schema} values={values} onChange={onChange} />)
expect(screen.getByText('SubConfig')).toBeInTheDocument()
expect(screen.getByText('Nested Field')).toBeInTheDocument()
})
it('uses default hook registry when not provided', () => {
const schema: ConfigSchema = {
className: 'TestConfig',
classDoc: 'Test configuration',
fields: [
{
name: 'test_field',
type: 'string',
label: 'Test Field',
description: 'A test field',
required: false,
},
],
}
const values = { test_field: 'test' }
const onChange = vi.fn()
render(<DynamicConfigForm schema={schema} values={values} onChange={onChange} />)
expect(screen.getByText('Test Field')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,394 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { DynamicField } from '../DynamicField'
import type { FieldSchema } from '@/types/config-schema'
describe('DynamicField', () => {
describe('x-widget priority', () => {
it('renders Slider when x-widget is slider', () => {
const schema: FieldSchema = {
name: 'test_slider',
type: 'number',
label: 'Test Slider',
description: 'A test slider',
required: false,
'x-widget': 'slider',
minValue: 0,
maxValue: 100,
default: 50,
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value={50} onChange={onChange} />)
expect(screen.getByText('Test Slider')).toBeInTheDocument()
expect(screen.getByRole('slider')).toBeInTheDocument()
expect(screen.getByText('50')).toBeInTheDocument()
})
it('renders Switch when x-widget is switch', () => {
const schema: FieldSchema = {
name: 'test_switch',
type: 'boolean',
label: 'Test Switch',
description: 'A test switch',
required: false,
'x-widget': 'switch',
default: false,
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value={false} onChange={onChange} />)
expect(screen.getByText('Test Switch')).toBeInTheDocument()
expect(screen.getByRole('switch')).toBeInTheDocument()
})
it('renders Textarea when x-widget is textarea', () => {
const schema: FieldSchema = {
name: 'test_textarea',
type: 'string',
label: 'Test Textarea',
description: 'A test textarea',
required: false,
'x-widget': 'textarea',
default: 'Hello',
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value="Hello" onChange={onChange} />)
expect(screen.getByText('Test Textarea')).toBeInTheDocument()
expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(screen.getByRole('textbox')).toHaveValue('Hello')
})
it('renders Select when x-widget is select', () => {
const schema: FieldSchema = {
name: 'test_select',
type: 'string',
label: 'Test Select',
description: 'A test select',
required: false,
'x-widget': 'select',
options: ['Option 1', 'Option 2', 'Option 3'],
default: 'Option 1',
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value="Option 1" onChange={onChange} />)
expect(screen.getByText('Test Select')).toBeInTheDocument()
expect(screen.getByRole('combobox')).toBeInTheDocument()
})
it('renders placeholder for custom widget', () => {
const schema: FieldSchema = {
name: 'test_custom',
type: 'string',
label: 'Test Custom',
description: 'A test custom field',
required: false,
'x-widget': 'custom',
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value="" onChange={onChange} />)
expect(screen.getByText('Custom field requires Hook')).toBeInTheDocument()
})
})
describe('type fallback', () => {
it('renders Input for string type', () => {
const schema: FieldSchema = {
name: 'test_string',
type: 'string',
label: 'Test String',
description: 'A test string',
required: false,
default: 'Hello',
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value="Hello" onChange={onChange} />)
expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(screen.getByRole('textbox')).toHaveValue('Hello')
})
it('renders Switch for boolean type', () => {
const schema: FieldSchema = {
name: 'test_bool',
type: 'boolean',
label: 'Test Boolean',
description: 'A test boolean',
required: false,
default: true,
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value={true} onChange={onChange} />)
expect(screen.getByRole('switch')).toBeInTheDocument()
expect(screen.getByRole('switch')).toBeChecked()
})
it('renders number Input for number type', () => {
const schema: FieldSchema = {
name: 'test_number',
type: 'number',
label: 'Test Number',
description: 'A test number',
required: false,
default: 42,
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value={42} onChange={onChange} />)
const input = screen.getByRole('spinbutton')
expect(input).toBeInTheDocument()
expect(input).toHaveValue(42)
})
it('renders number Input for integer type', () => {
const schema: FieldSchema = {
name: 'test_integer',
type: 'integer',
label: 'Test Integer',
description: 'A test integer',
required: false,
default: 10,
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value={10} onChange={onChange} />)
const input = screen.getByRole('spinbutton')
expect(input).toBeInTheDocument()
expect(input).toHaveValue(10)
})
it('renders Textarea for textarea type', () => {
const schema: FieldSchema = {
name: 'test_textarea_type',
type: 'textarea',
label: 'Test Textarea Type',
description: 'A test textarea type',
required: false,
default: 'Long text',
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value="Long text" onChange={onChange} />)
expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(screen.getByRole('textbox')).toHaveValue('Long text')
})
it('renders Select for select type', () => {
const schema: FieldSchema = {
name: 'test_select_type',
type: 'select',
label: 'Test Select Type',
description: 'A test select type',
required: false,
options: ['A', 'B', 'C'],
default: 'A',
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value="A" onChange={onChange} />)
expect(screen.getByRole('combobox')).toBeInTheDocument()
})
it('renders placeholder for array type', () => {
const schema: FieldSchema = {
name: 'test_array',
type: 'array',
label: 'Test Array',
description: 'A test array',
required: false,
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value={[]} onChange={onChange} />)
expect(screen.getByText('Array fields not yet supported')).toBeInTheDocument()
})
it('renders placeholder for object type', () => {
const schema: FieldSchema = {
name: 'test_object',
type: 'object',
label: 'Test Object',
description: 'A test object',
required: false,
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value={{}} onChange={onChange} />)
expect(screen.getByText('Object fields not yet supported')).toBeInTheDocument()
})
})
describe('onChange events', () => {
it('triggers onChange for Switch', async () => {
const schema: FieldSchema = {
name: 'test_switch',
type: 'boolean',
label: 'Test Switch',
description: 'A test switch',
required: false,
default: false,
}
const onChange = vi.fn()
const user = userEvent.setup()
render(<DynamicField schema={schema} value={false} onChange={onChange} />)
await user.click(screen.getByRole('switch'))
expect(onChange).toHaveBeenCalledWith(true)
})
it('triggers onChange for Input', async () => {
const schema: FieldSchema = {
name: 'test_input',
type: 'string',
label: 'Test Input',
description: 'A test input',
required: false,
default: '',
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value="" onChange={onChange} />)
const input = screen.getByRole('textbox')
input.focus()
await userEvent.keyboard('Hello')
expect(onChange).toHaveBeenCalledTimes(5)
expect(onChange).toHaveBeenNthCalledWith(1, 'H')
expect(onChange).toHaveBeenNthCalledWith(2, 'e')
expect(onChange).toHaveBeenNthCalledWith(3, 'l')
expect(onChange).toHaveBeenNthCalledWith(4, 'l')
expect(onChange).toHaveBeenNthCalledWith(5, 'o')
})
it('triggers onChange for number Input', async () => {
const schema: FieldSchema = {
name: 'test_number',
type: 'number',
label: 'Test Number',
description: 'A test number',
required: false,
default: 0,
}
const onChange = vi.fn()
const user = userEvent.setup()
render(<DynamicField schema={schema} value={0} onChange={onChange} />)
const input = screen.getByRole('spinbutton')
await user.clear(input)
await user.type(input, '123')
expect(onChange).toHaveBeenCalled()
})
})
describe('visual features', () => {
it('renders label with icon', () => {
const schema: FieldSchema = {
name: 'test_icon',
type: 'string',
label: 'Test Icon',
description: 'A test with icon',
required: false,
'x-icon': 'Settings',
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value="" onChange={onChange} />)
expect(screen.getByText('Test Icon')).toBeInTheDocument()
})
it('renders required indicator', () => {
const schema: FieldSchema = {
name: 'test_required',
type: 'string',
label: 'Test Required',
description: 'A required field',
required: true,
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value="" onChange={onChange} />)
expect(screen.getByText('*')).toBeInTheDocument()
})
it('renders description', () => {
const schema: FieldSchema = {
name: 'test_desc',
type: 'string',
label: 'Test Description',
description: 'This is a description',
required: false,
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value="" onChange={onChange} />)
expect(screen.getByText('This is a description')).toBeInTheDocument()
})
})
describe('slider features', () => {
it('renders slider with min/max/step', () => {
const schema: FieldSchema = {
name: 'test_slider_props',
type: 'number',
label: 'Test Slider Props',
description: 'A slider with props',
required: false,
'x-widget': 'slider',
minValue: 10,
maxValue: 50,
step: 5,
default: 25,
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value={25} onChange={onChange} />)
expect(screen.getByText('10')).toBeInTheDocument()
expect(screen.getByText('50')).toBeInTheDocument()
expect(screen.getByText('25')).toBeInTheDocument()
})
})
describe('select features', () => {
it('renders placeholder when no options', () => {
const schema: FieldSchema = {
name: 'test_select_no_options',
type: 'string',
label: 'Test Select No Options',
description: 'A select with no options',
required: false,
'x-widget': 'select',
}
const onChange = vi.fn()
render(<DynamicField schema={schema} value="" onChange={onChange} />)
expect(screen.getByText('No options available for select')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,253 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { FieldHookRegistry } from '../field-hooks'
import type { FieldHookComponent } from '../field-hooks'
describe('FieldHookRegistry', () => {
let registry: FieldHookRegistry
beforeEach(() => {
registry = new FieldHookRegistry()
})
describe('register', () => {
it('registers a hook with replace type', () => {
const component: FieldHookComponent = () => null
registry.register('test.field', component, 'replace')
expect(registry.has('test.field')).toBe(true)
})
it('registers a hook with wrapper type', () => {
const component: FieldHookComponent = () => null
registry.register('test.field', component, 'wrapper')
expect(registry.has('test.field')).toBe(true)
const entry = registry.get('test.field')
expect(entry?.type).toBe('wrapper')
})
it('defaults to replace type when not specified', () => {
const component: FieldHookComponent = () => null
registry.register('test.field', component)
const entry = registry.get('test.field')
expect(entry?.type).toBe('replace')
})
it('overwrites existing hook for same field path', () => {
const component1: FieldHookComponent = () => null
const component2: FieldHookComponent = () => null
registry.register('test.field', component1, 'replace')
registry.register('test.field', component2, 'wrapper')
const entry = registry.get('test.field')
expect(entry?.component).toBe(component2)
expect(entry?.type).toBe('wrapper')
})
})
describe('get', () => {
it('returns hook entry for registered field path', () => {
const component: FieldHookComponent = () => null
registry.register('test.field', component, 'replace')
const entry = registry.get('test.field')
expect(entry).toBeDefined()
expect(entry?.component).toBe(component)
expect(entry?.type).toBe('replace')
})
it('returns undefined for unregistered field path', () => {
const entry = registry.get('nonexistent.field')
expect(entry).toBeUndefined()
})
it('returns correct entry for nested field paths', () => {
const component: FieldHookComponent = () => null
registry.register('config.section.field', component, 'wrapper')
const entry = registry.get('config.section.field')
expect(entry).toBeDefined()
expect(entry?.type).toBe('wrapper')
})
})
describe('has', () => {
it('returns true for registered field path', () => {
const component: FieldHookComponent = () => null
registry.register('test.field', component)
expect(registry.has('test.field')).toBe(true)
})
it('returns false for unregistered field path', () => {
expect(registry.has('nonexistent.field')).toBe(false)
})
it('returns false after unregistering', () => {
const component: FieldHookComponent = () => null
registry.register('test.field', component)
registry.unregister('test.field')
expect(registry.has('test.field')).toBe(false)
})
})
describe('unregister', () => {
it('removes a registered hook', () => {
const component: FieldHookComponent = () => null
registry.register('test.field', component)
expect(registry.has('test.field')).toBe(true)
registry.unregister('test.field')
expect(registry.has('test.field')).toBe(false)
})
it('does not throw when unregistering non-existent hook', () => {
expect(() => registry.unregister('nonexistent.field')).not.toThrow()
})
it('only removes specified hook, not others', () => {
const component1: FieldHookComponent = () => null
const component2: FieldHookComponent = () => null
registry.register('field1', component1)
registry.register('field2', component2)
registry.unregister('field1')
expect(registry.has('field1')).toBe(false)
expect(registry.has('field2')).toBe(true)
})
})
describe('clear', () => {
it('removes all registered hooks', () => {
const component1: FieldHookComponent = () => null
const component2: FieldHookComponent = () => null
const component3: FieldHookComponent = () => null
registry.register('field1', component1)
registry.register('field2', component2)
registry.register('field3', component3)
expect(registry.getAllPaths()).toHaveLength(3)
registry.clear()
expect(registry.getAllPaths()).toHaveLength(0)
expect(registry.has('field1')).toBe(false)
expect(registry.has('field2')).toBe(false)
expect(registry.has('field3')).toBe(false)
})
it('works correctly on empty registry', () => {
expect(() => registry.clear()).not.toThrow()
expect(registry.getAllPaths()).toHaveLength(0)
})
})
describe('getAllPaths', () => {
it('returns empty array when no hooks registered', () => {
expect(registry.getAllPaths()).toEqual([])
})
it('returns all registered field paths', () => {
const component: FieldHookComponent = () => null
registry.register('field1', component)
registry.register('field2', component)
registry.register('field3', component)
const paths = registry.getAllPaths()
expect(paths).toHaveLength(3)
expect(paths).toContain('field1')
expect(paths).toContain('field2')
expect(paths).toContain('field3')
})
it('returns updated paths after unregister', () => {
const component: FieldHookComponent = () => null
registry.register('field1', component)
registry.register('field2', component)
registry.register('field3', component)
registry.unregister('field2')
const paths = registry.getAllPaths()
expect(paths).toHaveLength(2)
expect(paths).toContain('field1')
expect(paths).toContain('field3')
expect(paths).not.toContain('field2')
})
it('handles nested field paths correctly', () => {
const component: FieldHookComponent = () => null
registry.register('config.chat.enabled', component)
registry.register('config.chat.model', component)
registry.register('config.api.key', component)
const paths = registry.getAllPaths()
expect(paths).toHaveLength(3)
expect(paths).toContain('config.chat.enabled')
expect(paths).toContain('config.chat.model')
expect(paths).toContain('config.api.key')
})
})
describe('integration scenarios', () => {
it('supports full lifecycle of multiple hooks', () => {
const replaceComponent: FieldHookComponent = () => null
const wrapperComponent: FieldHookComponent = () => null
registry.register('field1', replaceComponent, 'replace')
registry.register('field2', wrapperComponent, 'wrapper')
expect(registry.getAllPaths()).toHaveLength(2)
const entry1 = registry.get('field1')
expect(entry1?.type).toBe('replace')
expect(entry1?.component).toBe(replaceComponent)
const entry2 = registry.get('field2')
expect(entry2?.type).toBe('wrapper')
expect(entry2?.component).toBe(wrapperComponent)
registry.unregister('field1')
expect(registry.getAllPaths()).toHaveLength(1)
expect(registry.has('field2')).toBe(true)
registry.clear()
expect(registry.getAllPaths()).toHaveLength(0)
})
it('handles rapid register/unregister cycles', () => {
const component: FieldHookComponent = () => null
for (let i = 0; i < 100; i++) {
registry.register(`field${i}`, component)
}
expect(registry.getAllPaths()).toHaveLength(100)
for (let i = 0; i < 50; i++) {
registry.unregister(`field${i}`)
}
expect(registry.getAllPaths()).toHaveLength(50)
registry.clear()
expect(registry.getAllPaths()).toHaveLength(0)
})
})
})

View File

@ -0,0 +1,22 @@
import '@testing-library/jest-dom/vitest'
global.ResizeObserver = class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => {},
}),
})

View File

@ -2,6 +2,7 @@
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
{ "path": "./tsconfig.node.json" },
{ "path": "./tsconfig.vitest.json" }
]
}

View File

@ -0,0 +1,7 @@
{
"extends": "./tsconfig.app.json",
"compilerOptions": {
"types": ["vitest/globals", "@testing-library/jest-dom"]
},
"include": ["src"]
}

View File

@ -1,3 +1,4 @@
/// <reference types="vitest" />
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
@ -5,6 +6,11 @@ import path from 'path'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
},
server: {
port: 7999,
proxy: {
@ -23,6 +29,9 @@ export default defineConfig({
'@': path.resolve(__dirname, './src'),
},
},
optimizeDeps: {
include: ['react', 'react-dom'],
},
build: {
rollupOptions: {
output: {

View File

@ -0,0 +1,18 @@
/// <reference types="vitest" />
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
})