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 }; } const NEW_PRIMITIVES = [ 'src/components/ui/DateInput.tsx', 'src/components/ui/EnumSelect.tsx', 'src/components/ui/MultiSelect.tsx', ]; // Display-only primitives (no input contract): same theme rules, own describe. const MODERN_KIT = [ 'src/components/ui/Skeleton.tsx', 'src/components/ui/EmptyState.tsx', 'src/components/ui/Badge.tsx', 'src/components/ui/StatCard.tsx', ]; function fileByPath(path: string): { path: string; content: string; strategy: string } { const files = generate(fixture()); const f = files.find((x) => x.path === path); if (!f) throw new Error(`missing generated file ${path}`); return f; } describe('scaffold-ui-primitives / new primitives — file shape', () => { it.each(NEW_PRIMITIVES)('emits %s with overwrite strategy + AUTO-GENERATED header', (path) => { const f = fileByPath(path); expect(f.strategy).toBe('overwrite'); expect(f.content).toMatch(/AUTO-GENERATED/); expect(f.content).toMatch(/scaffold-ui-primitives/); }); }); describe('scaffold-ui-primitives / new primitives — theme compliance (non-negotiable)', () => { 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.each(NEW_PRIMITIVES)('%s uses zero hardcoded Tailwind color classes', (path) => { const content = fileByPath(path).content; 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 = content.match(re); expect(matches, `Hardcoded Tailwind color (${palette}): ${matches?.join(', ')}`).toBeNull(); } }); it.each(NEW_PRIMITIVES)('%s uses zero dark: prefix (theme swap handles dark mode at the token layer)', (path) => { expect(fileByPath(path).content).not.toMatch(/\bdark:/); }); it.each(NEW_PRIMITIVES)('%s uses zero hex literals in className', (path) => { const content = fileByPath(path).content; const classNameBlocks = 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.each(NEW_PRIMITIVES)('%s references the SmartStack input tokens (radius-input, border-color, bg-card, accent-500)', (path) => { const content = fileByPath(path).content; expect(content).toMatch(/var\(--radius-input/); expect(content).toMatch(/var\(--border-color\)/); expect(content).toMatch(/var\(--bg-card\)/); expect(content).toMatch(/var\(--color-accent-500\)/); }); }); describe('scaffold-ui-primitives / new primitives — component contracts', () => { it('DateInput exports the component with an ISO value contract + calendar popover', () => { const c = fileByPath('src/components/ui/DateInput.tsx').content; expect(c).toMatch(/export function DateInput\(/); expect(c).toMatch(/value: string \| null/); expect(c).toMatch(/onChange: \(value: string \| null\) => void/); expect(c).toMatch(/label\?: string/); // Real calendar, not native input: month grid + Intl formatting. expect(c).toMatch(/Intl\.DateTimeFormat/); expect(c).toMatch(/grid-cols-7/); expect(c).toMatch(/role="dialog"/); expect(c).not.toMatch(/type="date"/); }); it('EnumSelect exports a single-select listbox with options', () => { const c = fileByPath('src/components/ui/EnumSelect.tsx').content; expect(c).toMatch(/export function EnumSelect\(/); expect(c).toMatch(/options: SelectOption\[\]/); expect(c).toMatch(/value: string \| null/); expect(c).toMatch(/onChange: \(value: string \| null\) => void/); expect(c).toMatch(/role="listbox"/); expect(c).toMatch(/role="option"/); // Themed dropdown, not the native { const c = fileByPath('src/components/ui/MultiSelect.tsx').content; expect(c).toMatch(/export function MultiSelect\(/); expect(c).toMatch(/value: string\[\]/); expect(c).toMatch(/onChange: \(value: string\[\]\) => void/); expect(c).toMatch(/aria-multiselectable="true"/); expect(c).not.toMatch(/