diff --git a/dashboard/package.json b/dashboard/package.json index bf98b1e5..e57cd1bf 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -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" } } diff --git a/dashboard/src/components/dynamic-form/__tests__/DynamicConfigForm.test.tsx b/dashboard/src/components/dynamic-form/__tests__/DynamicConfigForm.test.tsx new file mode 100644 index 00000000..e628a308 --- /dev/null +++ b/dashboard/src/components/dynamic-form/__tests__/DynamicConfigForm.test.tsx @@ -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() + + 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() + + 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 = ({ 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'), + }, + }, +})