import { afterEach, describe, expect, it } from 'vitest' import { cleanup, fireEvent, render, within } from '@testing-library/react' import { useState } from 'react' import { SchemaForm } from '../src/form/Form' import { validate } from '../src/form/validate' import { defaultFor, pickVariant, resolveSchema } from '../src/form/resolve' import { schemas } from '../mock/fixtures' afterEach(cleanup) /** Controlled harness; `onValue` sees every change so tests can assert the exact data written. */ function Harness({ schema, initial, onValue }: { schema: any; initial: any; onValue(v: any): void }) { const [v, setV] = useState(initial) return (setV(n), onValue(n))} node={{ id: 'n', dataType: 't', data: initial }} ctrl={null as never} /> } function mount(schema: any, initial: any = {}) { let last = initial const r = render( (last = v)} />) return { ...r, value: () => last, field: (path: string) => r.container.querySelector(`[data-path="${path}"]`)! } } const type = (el: Element, v: string) => fireEvent.change(el, { target: { value: v } }) const footer = schemas.footer const data = () => structuredClone({ companyName: 'Mock AB', links: [{ text: 'About', href: 'https://x.test/a' }, { text: 'Careers', href: 'https://x.test/b' }], usps: ['Fast', 'Friendly'], social: { github: 'https://g.test' }, badges: ['gdpr'], cta: { kind: 'link', href: '/c' } }) describe('array of objects ($ref items)', () => { it('shows summaries, edits nested fields, and writes them back', () => { const m = mount(footer, data()) const links = m.field('links') expect(links.textContent).toContain('About') expect(links.textContent).toContain('Careers') type(within(links).getAllByRole('textbox')[0], 'About us') // items <= 2 start expanded expect(m.value().links[0]).toMatchObject({ text: 'About us', href: 'https://x.test/a' }) }) it('adds an item with schema defaults, respecting maxItems', () => { const m = mount(footer, data()) for (let i = 0; i < 6; i++) fireEvent.click(within(m.field('links')).getByLabelText('Add item')) expect(m.value().links).toHaveLength(6) expect(m.value().links[5]).toEqual({ text: '' }) // required props seeded expect((within(m.field('links')).getByLabelText('Add item') as HTMLButtonElement).disabled).toBe(true) }) it('reorders (buttons), duplicates and removes', () => { const m = mount(footer, data()) const l = () => within(m.field('links')) fireEvent.click(l().getByLabelText('Move item 1 down')) expect(m.value().links.map((x: any) => x.text)).toEqual(['Careers', 'About']) fireEvent.click(l().getByLabelText('Duplicate item 1')) expect(m.value().links.map((x: any) => x.text)).toEqual(['Careers', 'Careers', 'About']) fireEvent.click(l().getByLabelText('Remove item 2')) expect(m.value().links.map((x: any) => x.text)).toEqual(['Careers', 'About']) }) it('keeps open/closed state with the item when reordering', () => { const d = data() d.links.push({ text: 'Third', href: 'https://x.test/c' }) // 3 items -> all start collapsed const m = mount(footer, d) const l = () => within(m.field('links')) fireEvent.click(l().getByText('Third')) // expand it expect(l().queryAllByRole('textbox')).toHaveLength(2) // text + href of the one open card fireEvent.click(l().getByLabelText('Move item 3 up')) expect(m.value().links.map((x: any) => x.text)).toEqual(['About', 'Third', 'Careers']) expect(l().getAllByRole('textbox')[0].getAttribute('value')).toBe('Third') // still the open one }) it('an optional array emptied is removed from the data', () => { const m = mount(footer, { companyName: 'x', links: [{ text: 'Only' }] }) fireEvent.click(within(m.field('links')).getByLabelText('Remove item 1')) expect('links' in m.value()).toBe(false) }) }) describe('primitive arrays', () => { it('edit in place, add and remove rows', () => { const m = mount(footer, data()) const u = () => within(m.field('usps')) type(u().getAllByRole('textbox')[1], 'Very friendly') expect(m.value().usps).toEqual(['Fast', 'Very friendly']) fireEvent.click(u().getByLabelText('Add item')) expect(m.value().usps).toEqual(['Fast', 'Very friendly', '']) fireEvent.click(u().getByLabelText('Remove item 1')) expect(m.value().usps).toEqual(['Very friendly', '']) }) it('uniqueItems + enum renders a checkbox set', () => { const m = mount(footer, data()) const boxes = within(m.field('badges')).getAllByRole('checkbox') as HTMLInputElement[] expect(boxes.map((b) => b.checked)).toEqual([false, true, false]) fireEvent.click(boxes[0]) expect(m.value().badges).toEqual(['gdpr', 'iso9001']) fireEvent.click(boxes[1]) expect(m.value().badges).toEqual(['iso9001']) }) }) describe('object maps (additionalProperties)', () => { it('adds, renames (keeping order), edits and removes keys; blocks duplicates', () => { const m = mount(footer, data()) const s = () => within(m.field('social')) type(s().getByLabelText('New key'), 'mastodon') fireEvent.click(s().getByLabelText('Add entry')) expect(Object.keys(m.value().social)).toEqual(['github', 'mastodon']) const key = s().getByLabelText('Key github') type(key, 'gh') fireEvent.blur(key) expect(Object.keys(m.value().social)).toEqual(['gh', 'mastodon']) type(s().getByLabelText('New key'), 'gh') fireEvent.click(s().getByLabelText('Add entry')) expect(s().getByText('That key already exists')).toBeTruthy() fireEvent.click(s().getByLabelText('Remove gh')) expect(Object.keys(m.value().social)).toEqual(['mastodon']) }) it('x-dict-keys is a fixed vocabulary: one row per key, blank removes', () => { const schema = { type: 'object', properties: { m: { type: 'object', 'x-editor': 'dictionary', 'x-dict-keys': ['höjd', 'bredd'], additionalProperties: { type: 'integer' } } } } const m = mount(schema, { m: { höjd: 5 } }) const inputs = within(m.field('m')).getAllByRole('spinbutton') expect(inputs).toHaveLength(2) type(inputs[1], '7') expect(m.value().m).toEqual({ höjd: 5, bredd: 7 }) type(inputs[0], '') expect(m.value().m).toEqual({ bredd: 7 }) }) }) describe('variants and refs', () => { it('oneOf: picks the branch from the data and switching sets the discriminator', () => { const m = mount(footer, data()) const sel = within(m.field('cta')).getByLabelText('Variant') as HTMLSelectElement expect(sel.options[sel.selectedIndex].text).toBe('Link') fireEvent.change(sel, { target: { value: '1' } }) expect(m.value().cta).toEqual({ kind: 'phone', number: '' }) type(within(m.field('cta')).getAllByRole('textbox')[0], '08-1234') expect(m.value().cta).toEqual({ kind: 'phone', number: '08-1234' }) }) it('nullable unions unwrap to the real type', () => { const root = { $defs: { s: { type: 'string' } } } expect(resolveSchema(root, { anyOf: [{ $ref: '#/$defs/s' }, { type: 'null' }] })).toMatchObject({ nullable: true, schema: { type: 'string' } }) expect(resolveSchema(root, { type: ['integer', 'null'] })).toMatchObject({ nullable: true, schema: { type: 'integer' } }) }) it('resolves $ref chains and merges allOf', () => { const root = { $defs: { a: { properties: { x: { type: 'string' } }, required: ['x'] }, b: { allOf: [{ $ref: '#/$defs/a' }, { properties: { y: { type: 'number' } } }] } } } const { schema } = resolveSchema(root, { $ref: '#/$defs/b', title: 'B' }) expect(Object.keys(schema.properties)).toEqual(['x', 'y']) expect(schema.required).toEqual(['x']) expect(schema.title).toBe('B') }) it('pickVariant scores by discriminator, type and required keys', () => { const b = footer.properties.cta.oneOf expect(pickVariant(footer, b, { kind: 'phone', number: '1' })).toBe(1) expect(pickVariant(footer, b, undefined)).toBe(-1) expect(pickVariant({}, [{ type: 'string' }, { type: 'number' }], 3)).toBe(1) }) it('defaultFor seeds required props, consts and enums', () => { expect(defaultFor(footer, footer.properties.links.items)).toEqual({ text: '' }) expect(defaultFor(footer, footer.properties.cta.oneOf[1])).toEqual({ kind: 'phone', number: '' }) expect(defaultFor({}, { type: 'string', enum: ['a', 'b'] })).toBe('a') }) }) describe('field behaviour', () => { it('hidden fields are not rendered; readOnly are shown but not editable', () => { const m = mount(footer, { companyName: 'x', internal: 'abc', secret: 's' }) expect(m.field('secret')).toBeNull() expect(m.field('internal').querySelector('input,textarea')).toBeNull() expect(m.field('internal').textContent).toContain('abc') }) it('x-group becomes a collapsible section', () => { const m = mount(footer, data()) const groups = [...m.container.querySelectorAll('details.cms-group')] as HTMLDetailsElement[] expect(groups.map((g) => g.querySelector('summary')!.textContent!.replace(/\s*\(\d+\)/, ''))).toEqual(['Contact', 'Social']) expect(groups.map((g) => g.open)).toEqual([true, false]) // collapsedByDefault }) it('clearing an optional string removes the key; a required one keeps ""', () => { const m = mount(footer, { companyName: 'Acme', tagline: 'hi' }) type(m.field('tagline').querySelector('input')!, '') expect('tagline' in m.value()).toBe(false) type(m.field('companyName').querySelector('input')!, '') expect(m.value().companyName).toBe('') }) it('data not in the schema stays editable under "not in the schema"', () => { const m = mount({ type: 'object', properties: { a: { type: 'string' } } }, { a: '1', legacy: 'old' }) expect(m.container.querySelector('.cms-extra')!.textContent).toContain('1 field') type(m.container.querySelector('.cms-extra input')!, 'new') expect(m.value()).toEqual({ a: '1', legacy: 'new' }) }) }) describe('validation', () => { it('reports required/minLength/format inside array items with form paths', () => { const e = validate(footer, footer, { companyName: '', links: [{ text: 'x' }, { text: 'ok' }], email: 'nope' }) expect(e).toMatchObject({ companyName: 'Required', 'links.0.text': 'At least 2 characters', email: 'Not a valid email address' }) expect(e['links.1.text']).toBeUndefined() }) it('checks minItems/maxItems/number bounds and validates the chosen variant only', () => { const s = { type: 'object', properties: { l: { type: 'array', minItems: 2, items: { type: 'integer', maximum: 5 } }, v: footer.properties.cta } } expect(validate(s, s, { l: [9], v: { kind: 'phone' } })).toMatchObject({ l: 'Add at least 2 items', 'l.0': 'Must be at most 5', 'v.number': 'Required' }) }) it('shows messages inline and flags the field', () => { const m = mount(footer, { companyName: 'ok', links: [{ text: 'x' }] }) const row = m.container.querySelector('[data-path="links.0.text"]')! expect(row.className).toContain('has-error') expect(row.querySelector('[role=alert]')!.textContent).toBe('At least 2 characters') }) })