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 TOOLBAR_PRIMITIVES = [ 'src/components/ui/useColumnVisibility.ts', 'src/components/ui/useKanbanColumnPrefs.ts', 'src/components/ui/ColumnPicker.tsx', 'src/components/ui/FilterBar.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 / toolbar primitives — file shape', () => { it.each(TOOLBAR_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/); }); it.each(TOOLBAR_PRIMITIVES)('%s carries no i18n of its own (labels arrive by props)', (path) => { expect(fileByPath(path).content).not.toMatch(/useTranslation/); }); }); describe('scaffold-ui-primitives / toolbar 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(TOOLBAR_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(TOOLBAR_PRIMITIVES)('%s uses zero dark: prefix', (path) => { expect(fileByPath(path).content).not.toMatch(/\bdark:/); }); }); describe('scaffold-ui-primitives / useColumnVisibility — contract', () => { const c = () => fileByPath('src/components/ui/useColumnVisibility.ts').content; it('exports the hook and its default/state types', () => { expect(c()).toMatch(/export function useColumnVisibility\(/); expect(c()).toMatch(/export interface ColumnVisibilityDefault/); expect(c()).toMatch(/export interface ColumnVisibilityState/); expect(c()).toMatch(/hiddenKeys: ReadonlySet/); }); it('persists { hidden: string[] } under the storage key and clears it on reset', () => { expect(c()).toMatch(/window\.localStorage\.setItem\(storageKey, JSON\.stringify\(\{ hidden: Array\.from\(next\) \}\)\)/); expect(c()).toMatch(/window\.localStorage\.removeItem\(storageKey\)/); }); it('prunes stored keys unknown to the current column set and ignores locked ones (re-scaffold drift)', () => { expect(c()).toMatch(/known\.has\(k\) && !known\.get\(k\)\?\.locked/); }); it('never hides a locked column through toggle', () => { expect(c()).toMatch(/if \(!decl \|\| decl\.locked\) return prev/); }); it('reads storage lazily inside the initializer and survives storage errors (private mode, SSR)', () => { expect(c()).toMatch(/useState>\(\(\) =>/); expect(c()).toMatch(/typeof window === 'undefined'/); expect(c()).toMatch(/catch/); }); it('falls back to the declared defaults (hidden = !defaultVisible && !locked)', () => { expect(c()).toMatch(/!d\.defaultVisible && !d\.locked/); }); }); describe('scaffold-ui-primitives / useKanbanColumnPrefs — contract', () => { const c = () => fileByPath('src/components/ui/useKanbanColumnPrefs.ts').content; it('exports the hook and its default/state types (order + visibility)', () => { expect(c()).toMatch(/export function useKanbanColumnPrefs\(/); expect(c()).toMatch(/export interface KanbanColumnPrefDefault/); expect(c()).toMatch(/export interface KanbanColumnPrefsState/); expect(c()).toMatch(/order: readonly string\[\]/); expect(c()).toMatch(/hiddenKeys: ReadonlySet/); expect(c()).toMatch(/moveColumn: \(sourceKey: string, targetKey: string\) => void/); }); it('persists { order, hidden } under the storage key and clears it on reset', () => { expect(c()).toMatch(/JSON\.stringify\(\{ order, hidden: Array\.from\(hidden\) \}\)/); expect(c()).toMatch(/window\.localStorage\.removeItem\(storageKey\)/); }); it('prunes stored keys unknown to the current column set and splices NEW columns at their declared position (re-scaffold drift)', () => { expect(c()).toMatch(/function reconcileOrder\(/); expect(c()).toMatch(/stored\.filter\(\(k\) => known\.has\(k\)\)/); expect(c()).toMatch(/known\.has\(k\)/); }); it('moveColumn is a no-op on unknown or identical keys', () => { expect(c()).toMatch(/if \(sourceKey === targetKey\) return prev/); expect(c()).toMatch(/if \(from < 0 \|\| to < 0\) return prev/); }); it('reads storage lazily inside the initializer and survives storage errors (private mode, SSR)', () => { expect(c()).toMatch(/useState<\{ order: string\[\]; hidden: ReadonlySet \}>\(\(\) =>/); expect(c()).toMatch(/typeof window === 'undefined'/); expect(c()).toMatch(/catch/); }); it('falls back to the declared defaults (order = declaration, hidden = initiallyHidden)', () => { expect(c()).toMatch(/defaults\.map\(\(d\) => d\.key\)/); expect(c()).toMatch(/d\.initiallyHidden/); }); }); describe('scaffold-ui-primitives / ColumnPicker — contract', () => { const c = () => fileByPath('src/components/ui/ColumnPicker.tsx').content; it('exports the component and its item/props interfaces', () => { expect(c()).toMatch(/export function ColumnPicker\(/); expect(c()).toMatch(/export interface ColumnPickerItem/); expect(c()).toMatch(/export interface ColumnPickerProps/); expect(c()).toMatch(/export default ColumnPicker/); }); it('renders one checkbox per column, disabled when locked', () => { expect(c()).toMatch(/type="checkbox"/); expect(c()).toMatch(/checked=\{it\.visible\}/); expect(c()).toMatch(/disabled=\{it\.locked\}/); expect(c()).toMatch(/onChange=\{\(\) => onToggle\(it\.key\)\}/); }); it('closes on outside click and Escape', () => { expect(c()).toMatch(/addEventListener\('mousedown'/); expect(c()).toMatch(/e\.key === 'Escape'/); }); it('offers a reset action and shows how many columns are hidden', () => { expect(c()).toMatch(/onClick=\{onReset\}/); expect(c()).toMatch(/hiddenCount/); }); it('renders nothing when there is no column to manage', () => { expect(c()).toMatch(/if \(items\.length === 0\) return null/); }); }); describe('scaffold-ui-primitives / FilterBar — contract', () => { const c = () => fileByPath('src/components/ui/FilterBar.tsx').content; it('exports the component and the chip/labels/props interfaces', () => { expect(c()).toMatch(/export function FilterBar\(/); expect(c()).toMatch(/export interface FilterChip/); expect(c()).toMatch(/export interface FilterBarLabels/); expect(c()).toMatch(/export interface FilterBarProps/); expect(c()).toMatch(/export default FilterBar/); }); it('collapses the advanced controls behind a toggle with the active-count badge', () => { expect(c()).toMatch(/aria-expanded=\{open\}/); expect(c()).toMatch(/advancedActiveCount > 0 && \(/); expect(c()).toMatch(/advanced != null && open && \(/); }); it('auto-opens the panel when an advanced filter is already active on mount', () => { expect(c()).toMatch(/useState\(advancedActiveCount > 0\)/); }); it('renders no toggle at all when there is no advanced content', () => { expect(c()).toMatch(/advanced != null && \(/); }); it('echoes active filters as removable chips + a reset action', () => { expect(c()).toMatch(/chips\.map\(\(chip\) =>/); expect(c()).toMatch(/onRemoveChip\(chip\.key\)/); expect(c()).toMatch(/onClick=\{onReset\}/); }); it('hosts the column picker in the trailing toolbar slot', () => { expect(c()).toMatch(/\{columnPicker\}/); }); });