import { describe, it, expect } from 'vitest'; import { generate } from '../generate.js'; import type { ScaffoldUiPrimitivesInput } from '../types.js'; function fixture(overrides: Partial = {}): ScaffoldUiPrimitivesInput { return { projectPath: '/tmp/web', appCode: 'crm', force: false, ...overrides, }; } describe('scaffold-ui-primitives / generate — file shape', () => { it('emits EntityLookup.tsx with overwrite strategy', () => { const files = generate(fixture()); const lookup = files.find((f) => f.path === 'src/components/ui/EntityLookup.tsx'); expect(lookup).toBeDefined(); expect(lookup!.strategy).toBe('overwrite'); }); it('emits one locale file per supported language with deep-merge-json strategy', () => { const files = generate(fixture()); for (const locale of ['fr', 'en', 'it', 'de']) { const f = files.find((x) => x.path === `src/i18n/locales/${locale}/common.json`); expect(f, `missing locale ${locale}`).toBeDefined(); expect(f!.strategy).toBe('deep-merge-json'); const parsed = JSON.parse(f!.content); expect(parsed.entityLookup).toBeDefined(); expect(parsed.entityLookup.placeholder).toBeTruthy(); expect(parsed.entityLookup.empty).toBeTruthy(); expect(parsed.entityLookup.error).toBeTruthy(); } }); it('includes the AUTO-GENERATED header so re-runs do not surprise the dev', () => { const files = generate(fixture()); const lookup = files.find((f) => f.path === 'src/components/ui/EntityLookup.tsx')!; expect(lookup.content).toMatch(/AUTO-GENERATED/); expect(lookup.content).toMatch(/scaffold-ui-primitives/); }); it('exposes a selectItems adapter for non-standard (combined-DTO) lookup endpoints', () => { const files = generate(fixture()); const lookup = files.find((f) => f.path === 'src/components/ui/EntityLookup.tsx')!; // The prop exists on the public interface… expect(lookup.content).toMatch(/selectItems\?:\s*\(raw: unknown\)\s*=>\s*TItem\[\]/); // …and the fetch branches on it (raw fetch + client-side filter) instead of the // standard `{ items }` paginated contract. expect(lookup.content).toContain('if (selectItems)'); expect(lookup.content).toContain('const all = selectItems(raw)'); }); it('emits CustomActionDialog.tsx (the custom-action payload modal) with its param contract', () => { const files = generate(fixture()); const dlg = files.find((f) => f.path === 'src/components/ui/CustomActionDialog.tsx'); expect(dlg).toBeDefined(); expect(dlg!.content).toMatch(/export function CustomActionDialog/); expect(dlg!.content).toMatch(/export interface CustomActionParam/); expect(dlg!.content).toMatch(/onSubmit: \(payload: Record\) =>/); // Reuses EntityLookup for lookup params + a native file input for file params. expect(dlg!.content).toMatch(/import \{ EntityLookup \}/); expect(dlg!.content).toMatch(/type="file"/); // Required-field gate before submit. expect(dlg!.content).toMatch(/const missing = params\.filter/); }); }); describe('scaffold-ui-primitives / generate — theme compliance (non-negotiable)', () => { // Catalog of Tailwind palette tokens. Any of these in the EntityLookup // would break the theme swap contract — the audit lives here so a future // hand-edit can't silently regress. const FORBIDDEN_PALETTES = [ 'slate', 'gray', 'zinc', 'neutral', 'stone', 'red', 'orange', 'amber', 'yellow', 'lime', 'green', 'emerald', 'teal', 'cyan', 'sky', 'blue', 'indigo', 'violet', 'purple', 'fuchsia', 'pink', 'rose', ]; it('uses zero hardcoded Tailwind color classes (bg-*-XXX / text-*-XXX / border-*-XXX)', () => { const files = generate(fixture()); const lookup = files.find((f) => f.path === 'src/components/ui/EntityLookup.tsx')!; for (const palette of FORBIDDEN_PALETTES) { const re = new RegExp(`\\b(?:bg|text|border|ring|placeholder|from|to|via|divide|outline)-${palette}-\\d{2,3}\\b`); const matches = lookup.content.match(re); expect(matches, `Hardcoded Tailwind color found (${palette}): ${matches?.join(', ')}`).toBeNull(); } }); it('uses zero dark: prefix (theme swap handles dark mode at the token layer)', () => { const files = generate(fixture()); const lookup = files.find((f) => f.path === 'src/components/ui/EntityLookup.tsx')!; expect(lookup.content).not.toMatch(/\bdark:/); }); it('uses zero hex literals in className (only via tokens)', () => { const files = generate(fixture()); const lookup = files.find((f) => f.path === 'src/components/ui/EntityLookup.tsx')!; // Spot any `className="…#abc123…"` style hex literals — there must be none. const classNameBlocks = lookup.content.match(/className=[`"']([^`"']*)[`"']/g) ?? []; for (const block of classNameBlocks) { expect(block, `Hex literal in className: ${block}`).not.toMatch(/#[0-9a-fA-F]{3,8}/); } }); it('references the SmartStack tokens it depends on (bg-card, border-color, accent-500, text-primary, text-muted, bg-hover)', () => { const files = generate(fixture()); const lookup = files.find((f) => f.path === 'src/components/ui/EntityLookup.tsx')!; expect(lookup.content).toMatch(/var\(--bg-card\)/); expect(lookup.content).toMatch(/var\(--border-color\)/); expect(lookup.content).toMatch(/var\(--color-accent-500\)/); expect(lookup.content).toMatch(/var\(--text-primary\)/); expect(lookup.content).toMatch(/var\(--text-muted\)/); expect(lookup.content).toMatch(/var\(--bg-hover\)/); }); }); describe('scaffold-ui-primitives / generate — EntityLookup contract', () => { it('exports an EntityLookup component with the documented props shape', () => { const files = generate(fixture()); const lookup = files.find((f) => f.path === 'src/components/ui/EntityLookup.tsx')!; expect(lookup.content).toMatch(/export function EntityLookup/); // Required props per the spec expect(lookup.content).toMatch(/apiEndpoint: string/); expect(lookup.content).toMatch(/value: string \| null/); expect(lookup.content).toMatch(/onChange: \(id: string \| null, item\?: TItem\) => void/); expect(lookup.content).toMatch(/label: string/); // Optional props that must be present in the interface expect(lookup.content).toMatch(/mapOption\?:/); expect(lookup.content).toMatch(/required\?: boolean/); expect(lookup.content).toMatch(/disabled\?: boolean/); expect(lookup.content).toMatch(/error\?: string/); expect(lookup.content).toMatch(/multi\?: boolean/); }); it('fetches lookup results from apiEndpoint using api.get with debouncedSearch', () => { const files = generate(fixture()); const lookup = files.find((f) => f.path === 'src/components/ui/EntityLookup.tsx')!; expect(lookup.content).toMatch(/api\.get>\(apiEndpoint/); expect(lookup.content).toMatch(/search: debouncedSearch/); }); it('emits 300ms debounce constant', () => { const files = generate(fixture()); const lookup = files.find((f) => f.path === 'src/components/ui/EntityLookup.tsx')!; expect(lookup.content).toMatch(/DEBOUNCE_MS = 300/); }); it('renders an ARIA combobox with role=combobox + role=listbox + role=option', () => { const files = generate(fixture()); const lookup = files.find((f) => f.path === 'src/components/ui/EntityLookup.tsx')!; expect(lookup.content).toMatch(/role="combobox"/); expect(lookup.content).toMatch(/role="listbox"/); expect(lookup.content).toMatch(/role="option"/); expect(lookup.content).toMatch(/aria-expanded=\{open\}/); expect(lookup.content).toMatch(/aria-autocomplete="list"/); }); it('handles keyboard navigation (ArrowDown/Up/Enter/Escape)', () => { const files = generate(fixture()); const lookup = files.find((f) => f.path === 'src/components/ui/EntityLookup.tsx')!; expect(lookup.content).toMatch(/e\.key === 'ArrowDown'/); expect(lookup.content).toMatch(/e\.key === 'ArrowUp'/); expect(lookup.content).toMatch(/e\.key === 'Enter'/); expect(lookup.content).toMatch(/e\.key === 'Escape'/); }); it('throws not-implemented when multi=true (reserved API)', () => { const files = generate(fixture()); const lookup = files.find((f) => f.path === 'src/components/ui/EntityLookup.tsx')!; expect(lookup.content).toMatch(/multi-select is not implemented yet/); }); it('uses i18n via useTranslation("common") with entityLookup.* namespace', () => { const files = generate(fixture()); const lookup = files.find((f) => f.path === 'src/components/ui/EntityLookup.tsx')!; expect(lookup.content).toMatch(/useTranslation\('common'\)/); expect(lookup.content).toMatch(/entityLookup\.placeholder/); expect(lookup.content).toMatch(/entityLookup\.empty/); expect(lookup.content).toMatch(/entityLookup\.error/); expect(lookup.content).toMatch(/entityLookup\.loading/); expect(lookup.content).toMatch(/entityLookup\.clear/); }); });