mirror of https://github.com/Mai-with-u/MaiBot.git
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 functionalitypull/1496/head
parent
c58ad64352
commit
69dfd0cac6
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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: () => {},
|
||||
}),
|
||||
})
|
||||
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
{ "path": "./tsconfig.node.json" },
|
||||
{ "path": "./tsconfig.vitest.json" }
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"extends": "./tsconfig.app.json",
|
||||
"compilerOptions": {
|
||||
"types": ["vitest/globals", "@testing-library/jest-dom"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
},
|
||||
},
|
||||
})
|
||||
Loading…
Reference in New Issue