/**
 * Tests for the pure helpers that drive the ComponentBlockField admin
 * form. Splitting these out of the React component lets us run them in
 * node without jsdom.
 */
import { describe, expect, it } from 'vitest';
import { buildClientValidator, defaultForType, getAllowedComponents, parseEnumSelection, parsePerPropErrors, safeJsonStringify, seedPropsForComponent, switchUnionVariant, } from '../../fields/component-block-helpers.js';
import { detectDiscriminator } from '@actuate-media/component-blocks/discriminated-union';
const FIXTURE = {
    version: 1,
    generatedAt: '2026-05-23T12:00:00.000Z',
    rootDir: 'fixtures',
    components: [
        {
            name: 'Hero',
            displayName: 'Hero',
            filePath: 'Hero.tsx',
            props: [
                { name: 'title', type: { kind: 'string' }, required: true },
                {
                    name: 'alignment',
                    type: { kind: 'enum', values: ['left', 'center', 'right'] },
                    required: false,
                    defaultValue: 'center',
                },
                {
                    name: 'features',
                    type: {
                        kind: 'array',
                        itemType: {
                            kind: 'object',
                            fields: [
                                { name: 'label', type: { kind: 'string' }, required: true },
                                { name: 'icon', type: { kind: 'string' }, required: false },
                            ],
                        },
                    },
                    required: false,
                },
            ],
        },
        {
            name: 'Banner',
            displayName: 'Banner',
            filePath: 'Banner.tsx',
            props: [{ name: 'message', type: { kind: 'string' }, required: true }],
        },
    ],
};
describe('defaultForType', () => {
    it('returns empty string for string', () => {
        expect(defaultForType({ kind: 'string' })).toBe('');
    });
    it('returns 0 for number', () => {
        expect(defaultForType({ kind: 'number' })).toBe(0);
    });
    it('returns false for boolean', () => {
        expect(defaultForType({ kind: 'boolean' })).toBe(false);
    });
    it('returns the first enum value', () => {
        expect(defaultForType({ kind: 'enum', values: ['a', 'b'] })).toBe('a');
    });
    it('returns the literal value', () => {
        expect(defaultForType({ kind: 'literal', value: 'fixed' })).toBe('fixed');
    });
    it('returns [] for array', () => {
        expect(defaultForType({ kind: 'array', itemType: { kind: 'string' } })).toEqual([]);
    });
    it('returns an object seeded with required fields only', () => {
        const result = defaultForType({
            kind: 'object',
            fields: [
                { name: 'a', type: { kind: 'string' }, required: true },
                { name: 'b', type: { kind: 'string' }, required: false },
            ],
        });
        expect(result).toEqual({ a: '' });
    });
    it('returns null (not undefined) for non-discriminated union/reference/unknown so JSON round-trips are stable', () => {
        // Regression for Bugbot finding: returning `undefined` from
        // defaultForType inside an array contradicted the JSDoc
        // ("the admin form generator never wants `undefined` in an
        // array") and round-tripped through JSON.stringify as `null`
        // anyway, surfacing as the literal string `null` in the
        // JsonFallback textarea.
        expect(defaultForType({ kind: 'union', types: [] })).toBeNull();
        expect(defaultForType({ kind: 'reference', targetType: 'X' })).toBeNull();
        expect(defaultForType({ kind: 'unknown' })).toBeNull();
    });
    it('seeds a structural default for a discriminated union (picks the first variant)', () => {
        // PR #4: with a discriminated union, defaultForType picks the
        // first variant and seeds its required fields. This is what lets
        // the admin form render a fully-editable variant immediately
        // instead of bouncing the editor through the JSON textarea.
        const cta = defaultForType({
            kind: 'union',
            types: [
                {
                    kind: 'object',
                    fields: [
                        { name: 'kind', type: { kind: 'literal', value: 'link' }, required: true },
                        { name: 'href', type: { kind: 'string' }, required: true },
                        { name: 'label', type: { kind: 'string' }, required: true },
                    ],
                },
                {
                    kind: 'object',
                    fields: [
                        { name: 'kind', type: { kind: 'literal', value: 'modal' }, required: true },
                        { name: 'modalId', type: { kind: 'string' }, required: true },
                        { name: 'label', type: { kind: 'string' }, required: true },
                    ],
                },
            ],
        });
        expect(cta).toEqual({ kind: 'link', href: '', label: '' });
    });
});
describe('seedPropsForComponent', () => {
    const hero = FIXTURE.components[0];
    it('uses each prop defaultValue when present', () => {
        const seeded = seedPropsForComponent(hero);
        expect(seeded.alignment).toBe('center');
    });
    it('seeds required props without a defaultValue using defaultForType', () => {
        const seeded = seedPropsForComponent(hero);
        expect(seeded.title).toBe('');
    });
    it('omits optional props without a defaultValue', () => {
        const seeded = seedPropsForComponent(hero);
        // `features` is optional and has no defaultValue — should be absent.
        expect('features' in seeded).toBe(false);
    });
    it('returns an empty object when the component has zero props', () => {
        const empty = {
            name: 'Empty',
            displayName: 'Empty',
            filePath: 'Empty.tsx',
            props: [],
        };
        expect(seedPropsForComponent(empty)).toEqual({});
    });
    it('does not seed required props of non-discriminated union/reference/unknown kinds (would trip validator immediately)', () => {
        // Regression for Bugbot finding: after defaultForType started
        // returning `null` for these kinds (so arrays survive JSON
        // round-trip), seedPropsForComponent would seed required props
        // with `null` — and buildClientValidator treats `null` as
        // missing, so the editor would see "Missing required prop" the
        // instant they picked the component. Leave those required props
        // absent instead; the validator's message and the data state
        // then agree. Discriminated unions are exempt because they DO
        // have a sensible structural default (first variant).
        const spec = {
            name: 'Card',
            displayName: 'Card',
            filePath: 'Card.tsx',
            props: [
                { name: 'title', type: { kind: 'string' }, required: true },
                { name: 'data', type: { kind: 'union', types: [] }, required: true },
                {
                    name: 'ref',
                    type: { kind: 'reference', targetType: 'SomeType' },
                    required: true,
                },
                { name: 'meta', type: { kind: 'unknown' }, required: true },
            ],
        };
        const seeded = seedPropsForComponent(spec);
        expect(seeded.title).toBe('');
        expect('data' in seeded).toBe(false);
        expect('ref' in seeded).toBe(false);
        expect('meta' in seeded).toBe(false);
    });
    it('seeds required discriminated-union props with the first variant', () => {
        // PR #4: a required cta of `{ kind: 'link', ... } | { kind: 'modal', ... }`
        // should arrive on screen as a fully-editable Link variant — not
        // absent, not null, not a "Missing required prop" error.
        const spec = {
            name: 'CTAHero',
            displayName: 'CTAHero',
            filePath: 'CTAHero.tsx',
            props: [
                {
                    name: 'cta',
                    required: true,
                    type: {
                        kind: 'union',
                        types: [
                            {
                                kind: 'object',
                                fields: [
                                    { name: 'kind', type: { kind: 'literal', value: 'link' }, required: true },
                                    { name: 'href', type: { kind: 'string' }, required: true },
                                    { name: 'label', type: { kind: 'string' }, required: true },
                                ],
                            },
                            {
                                kind: 'object',
                                fields: [
                                    { name: 'kind', type: { kind: 'literal', value: 'modal' }, required: true },
                                    { name: 'modalId', type: { kind: 'string' }, required: true },
                                ],
                            },
                        ],
                    },
                },
            ],
        };
        const seeded = seedPropsForComponent(spec);
        expect(seeded.cta).toEqual({ kind: 'link', href: '', label: '' });
    });
});
describe('switchUnionVariant', () => {
    // PR #4 review: switching between discriminated-union variants
    // needs to (a) honor each field's explicit defaultValue before
    // falling back to defaultForType, and (b) leave required fields
    // ABSENT instead of seeding `null` when defaultForType has no
    // sensible default (reference / unknown / non-discriminated union),
    // so the validator's "missing required prop" message and the
    // stored value agree. The previous implementation called
    // defaultForType blindly and silently dropped declared defaults.
    const ctaType = {
        kind: 'union',
        types: [
            {
                kind: 'object',
                fields: [
                    { name: 'kind', type: { kind: 'literal', value: 'link' }, required: true },
                    { name: 'href', type: { kind: 'string' }, required: true },
                    { name: 'label', type: { kind: 'string' }, required: true, defaultValue: 'Learn more' },
                ],
            },
            {
                kind: 'object',
                fields: [
                    { name: 'kind', type: { kind: 'literal', value: 'modal' }, required: true },
                    { name: 'modalId', type: { kind: 'string' }, required: true },
                    { name: 'label', type: { kind: 'string' }, required: true, defaultValue: 'Open' },
                ],
            },
            {
                kind: 'object',
                fields: [
                    { name: 'kind', type: { kind: 'literal', value: 'page' }, required: true },
                    // Reference: defaultForType returns null. Required fields
                    // of these kinds must be left absent on variant switch.
                    { name: 'target', type: { kind: 'reference', targetType: 'Page' }, required: true },
                    { name: 'label', type: { kind: 'string' }, required: true },
                ],
            },
        ],
    };
    function union() {
        const detected = detectDiscriminator(ctaType);
        if (!detected)
            throw new Error('test fixture failed to detect discriminator');
        return detected;
    }
    function variantFor(value) {
        const v = union().variants.find((v) => v.value === value);
        if (!v)
            throw new Error(`test fixture missing variant '${value}'`);
        return v;
    }
    it('seeds required fields from PropSpec.defaultValue when present', () => {
        const next = switchUnionVariant({}, union(), variantFor('link'));
        // `label` has defaultValue 'Learn more' — should be respected.
        expect(next).toEqual({ kind: 'link', href: '', label: 'Learn more' });
    });
    it('falls back to defaultForType when no PropSpec.defaultValue is declared', () => {
        // `href` has no defaultValue — falls back to '' (string default).
        const next = switchUnionVariant({}, union(), variantFor('link'));
        expect(next.href).toBe('');
    });
    it('does NOT seed required fields whose defaultForType is null (reference / unknown)', () => {
        // `target: reference` has no sensible structural default; the
        // helper must leave it ABSENT (not literal null) so the validator
        // and the data agree about the missing-required state.
        const next = switchUnionVariant({}, union(), variantFor('page'));
        expect('target' in next).toBe(false);
        // But `kind` + `label` (with their structural defaults) are present.
        expect(next.kind).toBe('page');
        expect(next.label).toBe('');
    });
    it('carries an optional/shared field across the switch when both variants declare it', () => {
        // Both `link` and `modal` declare `label`; an existing value
        // should survive the switch instead of being overwritten by the
        // new variant's default.
        const next = switchUnionVariant({ kind: 'link', href: '/x', label: 'Custom label' }, union(), variantFor('modal'));
        expect(next).toEqual({ kind: 'modal', label: 'Custom label', modalId: '' });
    });
    it('drops fields exclusive to the previous variant', () => {
        const next = switchUnionVariant({ kind: 'link', href: '/x', label: 'Custom label' }, union(), variantFor('modal'));
        expect('href' in next).toBe(false);
    });
    it('treats a non-object input as empty when computing the next value', () => {
        const next = switchUnionVariant(null, union(), variantFor('link'));
        expect(next).toEqual({ kind: 'link', href: '', label: 'Learn more' });
    });
    it('does not override a carried defaultValue field that was edited to empty string', () => {
        // The carry path runs before the seed path, so a previously-
        // edited `label = ''` should win over the new variant's default.
        const next = switchUnionVariant({ kind: 'link', href: '/x', label: '' }, union(), variantFor('modal'));
        expect(next.label).toBe('');
    });
});
describe('getAllowedComponents', () => {
    it('returns all components when no allow list is given', () => {
        expect(getAllowedComponents(FIXTURE, undefined)).toHaveLength(2);
    });
    it('returns all components when allow list is empty', () => {
        expect(getAllowedComponents(FIXTURE, [])).toHaveLength(2);
    });
    it('filters by component name', () => {
        const allowed = getAllowedComponents(FIXTURE, ['Hero']);
        expect(allowed).toHaveLength(1);
        expect(allowed[0].name).toBe('Hero');
    });
    it('returns empty array when allow list filters everything out', () => {
        expect(getAllowedComponents(FIXTURE, ['Nonexistent'])).toEqual([]);
    });
    it('treats an empty allow array as "no filter" (matches validator semantics)', () => {
        // Regression for Bugbot finding: UI used to show every component
        // when allow=[] but the validator rejected every selection,
        // creating a deadlock state.
        expect(getAllowedComponents(FIXTURE, [])).toHaveLength(2);
    });
});
describe('buildClientValidator', () => {
    const validate = buildClientValidator(FIXTURE, undefined);
    it('accepts a valid Hero with required props', () => {
        expect(validate({ component: 'Hero', props: { title: 'Welcome' } })).toBe(true);
    });
    it('rejects unknown components', () => {
        const res = validate({ component: 'Nope', props: {} });
        expect(res).not.toBe(true);
        expect(res).toMatch(/Unknown component 'Nope'/);
    });
    it('rejects missing required props', () => {
        const res = validate({ component: 'Hero', props: {} });
        expect(res).not.toBe(true);
        expect(res).toMatch(/Missing required prop 'title'/);
    });
    it('rejects number where string expected', () => {
        const res = validate({ component: 'Hero', props: { title: 42 } });
        expect(res).toMatch(/Prop 'title' must be a string/);
    });
    it('rejects enum values not in the allowed set', () => {
        const res = validate({
            component: 'Hero',
            props: { title: 'ok', alignment: 'diagonal' },
        });
        expect(res).toMatch(/Prop 'alignment' must be one of: left, center, right/);
    });
    it('rejects when value is not in allow-list', () => {
        const restrictive = buildClientValidator(FIXTURE, ['Hero']);
        const res = restrictive({ component: 'Banner', props: { message: 'hi' } });
        expect(res).toMatch(/not in the allow-list/);
    });
    it('skips optional props that are absent', () => {
        // Hero.features is optional and unset — should still pass.
        const res = validate({ component: 'Hero', props: { title: 'ok' } });
        expect(res).toBe(true);
    });
    it('accepts any component when allow=[] (empty filter matches UI)', () => {
        // Regression for Bugbot finding: with the old `allow ? new Set(allow) : null`
        // logic, allow=[] produced an empty Set that rejected every name.
        // The UI showed every component, so saving was impossible.
        const permissive = buildClientValidator(FIXTURE, []);
        expect(permissive({ component: 'Hero', props: { title: 'ok' } })).toBe(true);
        expect(permissive({ component: 'Banner', props: { message: 'hi' } })).toBe(true);
    });
    it('reports an empty-known-components message when manifest is empty', () => {
        const empty = buildClientValidator({ version: 1, generatedAt: '', rootDir: '', components: [] }, undefined);
        const res = empty({ component: 'Foo', props: {} });
        expect(res).toMatch(/Manifest knows: <none>/);
    });
    describe('nested object validation', () => {
        // Regression for Bugbot finding: clientShapeError used to only
        // check whether the top-level value was an object, never
        // recursing into nested required children. Passing `{}` for an
        // object prop with required children sailed through the client
        // validator while the server-side validateComponentBlockValue
        // would reject it — silently broken "live structural validation".
        const nested = {
            version: 1,
            generatedAt: '',
            rootDir: '',
            components: [
                {
                    name: 'Card',
                    displayName: 'Card',
                    filePath: 'Card.tsx',
                    props: [
                        {
                            name: 'cta',
                            type: {
                                kind: 'object',
                                fields: [
                                    { name: 'label', type: { kind: 'string' }, required: true },
                                    { name: 'href', type: { kind: 'string' }, required: true },
                                    { name: 'newTab', type: { kind: 'boolean' }, required: false },
                                ],
                            },
                            required: true,
                        },
                    ],
                },
            ],
        };
        const validateNested = buildClientValidator(nested, undefined);
        it('rejects an empty object for an object prop with required children', () => {
            const res = validateNested({ component: 'Card', props: { cta: {} } });
            expect(res).not.toBe(true);
            expect(res).toMatch(/cta\.label/);
            expect(res).toMatch(/required but missing/i);
        });
        it('rejects a nested field whose value has the wrong type', () => {
            const res = validateNested({
                component: 'Card',
                props: { cta: { label: 'Click me', href: 123 } },
            });
            expect(res).not.toBe(true);
            expect(res).toMatch(/cta\.href.*must be a string/i);
        });
        it('accepts a fully-populated nested object', () => {
            const res = validateNested({
                component: 'Card',
                props: { cta: { label: 'Click me', href: '/x' } },
            });
            expect(res).toBe(true);
        });
        it('allows omitting an optional nested field', () => {
            const res = validateNested({
                component: 'Card',
                props: { cta: { label: 'Click me', href: '/x', newTab: undefined } },
            });
            expect(res).toBe(true);
        });
    });
    describe('discriminated-union validation', () => {
        // PR #4: with a discriminated union, the validator should route
        // the value into the matching variant's shape check rather than
        // accepting any object. Mirrors the picker UX one-for-one.
        const dunion = {
            version: 1,
            generatedAt: '',
            rootDir: '',
            components: [
                {
                    name: 'CTAHero',
                    displayName: 'CTAHero',
                    filePath: 'CTAHero.tsx',
                    props: [
                        {
                            name: 'cta',
                            required: true,
                            type: {
                                kind: 'union',
                                types: [
                                    {
                                        kind: 'object',
                                        fields: [
                                            { name: 'kind', type: { kind: 'literal', value: 'link' }, required: true },
                                            { name: 'href', type: { kind: 'string' }, required: true },
                                            { name: 'label', type: { kind: 'string' }, required: true },
                                        ],
                                    },
                                    {
                                        kind: 'object',
                                        fields: [
                                            { name: 'kind', type: { kind: 'literal', value: 'modal' }, required: true },
                                            { name: 'modalId', type: { kind: 'string' }, required: true },
                                        ],
                                    },
                                ],
                            },
                        },
                    ],
                },
            ],
        };
        const validateDiscrim = buildClientValidator(dunion, undefined);
        it('accepts a fully-populated valid variant', () => {
            expect(validateDiscrim({
                component: 'CTAHero',
                props: { cta: { kind: 'link', href: '/x', label: 'Go' } },
            })).toBe(true);
        });
        it('rejects an unknown discriminator value', () => {
            const res = validateDiscrim({
                component: 'CTAHero',
                props: { cta: { kind: 'mystery' } },
            });
            expect(res).not.toBe(true);
            expect(res).toMatch(/cta\.kind/);
            expect(res).toMatch(/"link", "modal"/);
        });
        it("rejects when the picked variant's required field is missing", () => {
            const res = validateDiscrim({
                component: 'CTAHero',
                props: { cta: { kind: 'modal' } },
            });
            expect(res).not.toBe(true);
            expect(res).toMatch(/cta\.modalId/);
        });
        it('rejects when the picked variant has a wrong-type required field', () => {
            const res = validateDiscrim({
                component: 'CTAHero',
                props: { cta: { kind: 'link', href: 42, label: 'Go' } },
            });
            expect(res).not.toBe(true);
            expect(res).toMatch(/cta\.href.*must be a string/i);
        });
        it('rejects when the discriminated-union value is not an object', () => {
            const res = validateDiscrim({
                component: 'CTAHero',
                props: { cta: 'not-an-object' },
            });
            expect(res).not.toBe(true);
            expect(res).toMatch(/must be an object with a 'kind' discriminator/);
        });
    });
});
describe('safeJsonStringify', () => {
    // Regression for Bugbot finding: the previous implementation
    // short-circuited for strings and returned the raw value without
    // quotes, which made the JsonFallback textarea round-trip lossy.
    it('quotes string values so the textarea round-trips through JSON.parse', () => {
        expect(safeJsonStringify('hello')).toBe('"hello"');
        expect(safeJsonStringify('')).toBe('""');
    });
    it('encodes objects and arrays as pretty-printed JSON', () => {
        expect(safeJsonStringify({ a: 1 })).toBe('{\n  "a": 1\n}');
        expect(safeJsonStringify([1, 2])).toBe('[\n  1,\n  2\n]');
    });
    it('encodes primitives correctly', () => {
        expect(safeJsonStringify(42)).toBe('42');
        expect(safeJsonStringify(true)).toBe('true');
        expect(safeJsonStringify(null)).toBe('null');
    });
    it('falls back to String() for values JSON cannot encode', () => {
        const circular = {};
        circular.self = circular;
        // The exact fallback isn't load-bearing — the contract is just
        // "returns *some* string so the field remains editable".
        expect(typeof safeJsonStringify(circular)).toBe('string');
    });
});
describe('parseEnumSelection', () => {
    it('treats the empty-string placeholder as a clear (undefined)', () => {
        // Regression for Bugbot finding: the original handler ran
        // `Number("")` which evaluates to 0 and silently picked the first
        // option, making optional enums un-clearable.
        expect(parseEnumSelection('', ['left', 'center', 'right'])).toBeUndefined();
    });
    it('returns the option at the parsed index', () => {
        expect(parseEnumSelection('0', ['left', 'center', 'right'])).toBe('left');
        expect(parseEnumSelection('2', ['left', 'center', 'right'])).toBe('right');
    });
    it('returns undefined for malformed or out-of-range values', () => {
        expect(parseEnumSelection('not-a-number', ['a', 'b'])).toBeUndefined();
        expect(parseEnumSelection('5', ['a', 'b'])).toBeUndefined();
        expect(parseEnumSelection('-1', ['a', 'b'])).toBeUndefined();
        expect(parseEnumSelection('1.5', ['a', 'b'])).toBeUndefined();
    });
});
describe('parsePerPropErrors', () => {
    it('returns empty for null/empty input', () => {
        expect(parsePerPropErrors(null)).toEqual({});
        expect(parsePerPropErrors('')).toEqual({});
    });
    it('extracts prop name from a shape error', () => {
        const err = "Prop 'title' must be a string.";
        expect(parsePerPropErrors(err)).toEqual({ title: err });
    });
    it('extracts prop name from a missing-required error', () => {
        const err = "Missing required prop 'title' for component 'Hero'.";
        // 'Prop' matches first since the regex sees `prop 'title'` — but the
        // missing-required path is also distinct. Either keying is fine as
        // long as the editor sees the message under SOMETHING. Assert at
        // least one of the property keys is `title`.
        const parsed = parsePerPropErrors(err);
        expect(Object.keys(parsed)).toContain('title');
        expect(parsed.title).toBe(err);
    });
    it('returns empty for whole-form errors without a prop name', () => {
        const err = "Unknown component 'Nope'. Manifest knows: Hero.";
        expect(parsePerPropErrors(err)).toEqual({});
    });
});
//# sourceMappingURL=component-block-helpers.test.js.map