= ({ fieldPath, value }) => {
+ return Hook: {fieldPath} = {String(value)}
+ }
+
+ 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()
+
+ 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 = ({ fieldPath, children }) => {
+ return (
+
+
Wrapper for: {fieldPath}
+ {children}
+
+ )
+ }
+
+ 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()
+
+ 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 = ({ fieldPath, value, onChange }) => {
+ return (
+
+
{fieldPath}
+
{String(value)}
+
+
+ )
+ }
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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 = ({ onChange }) => {
+ return
+ }
+
+ 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()
+
+ 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()
+
+ 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()
+
+ expect(screen.getByText('Test Field')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/dashboard/src/components/dynamic-form/__tests__/DynamicField.test.tsx b/dashboard/src/components/dynamic-form/__tests__/DynamicField.test.tsx
new file mode 100644
index 00000000..ba72b7e5
--- /dev/null
+++ b/dashboard/src/components/dynamic-form/__tests__/DynamicField.test.tsx
@@ -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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ expect(screen.getByText('No options available for select')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/dashboard/src/lib/__tests__/field-hooks.test.ts b/dashboard/src/lib/__tests__/field-hooks.test.ts
new file mode 100644
index 00000000..4a4fd7f1
--- /dev/null
+++ b/dashboard/src/lib/__tests__/field-hooks.test.ts
@@ -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)
+ })
+ })
+})
diff --git a/dashboard/src/test/setup.ts b/dashboard/src/test/setup.ts
new file mode 100644
index 00000000..106b74cd
--- /dev/null
+++ b/dashboard/src/test/setup.ts
@@ -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: () => {},
+ }),
+})
+
diff --git a/dashboard/tsconfig.json b/dashboard/tsconfig.json
index 1ffef600..08c8a904 100644
--- a/dashboard/tsconfig.json
+++ b/dashboard/tsconfig.json
@@ -2,6 +2,7 @@
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
- { "path": "./tsconfig.node.json" }
+ { "path": "./tsconfig.node.json" },
+ { "path": "./tsconfig.vitest.json" }
]
}
diff --git a/dashboard/tsconfig.vitest.json b/dashboard/tsconfig.vitest.json
new file mode 100644
index 00000000..9bf41c52
--- /dev/null
+++ b/dashboard/tsconfig.vitest.json
@@ -0,0 +1,7 @@
+{
+ "extends": "./tsconfig.app.json",
+ "compilerOptions": {
+ "types": ["vitest/globals", "@testing-library/jest-dom"]
+ },
+ "include": ["src"]
+}
diff --git a/dashboard/vite.config.ts b/dashboard/vite.config.ts
index 7f76c96f..08dd9e59 100644
--- a/dashboard/vite.config.ts
+++ b/dashboard/vite.config.ts
@@ -1,3 +1,4 @@
+///
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: {
diff --git a/dashboard/vitest.config.ts b/dashboard/vitest.config.ts
new file mode 100644
index 00000000..5770520a
--- /dev/null
+++ b/dashboard/vitest.config.ts
@@ -0,0 +1,18 @@
+///
+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'),
+ },
+ },
+})