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 TABLE_PRIMITIVES = [ 'src/components/ui/TruncatedText.tsx', 'src/components/ui/DataTable.tsx', 'src/components/ui/ResponsiveDataTable.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 / table primitives — file shape', () => { it.each(TABLE_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 / DataTable — UAT contract', () => { it('tags each data row with data-testid="ss-list-row" for automated walking', () => { const dt = fileByPath('src/components/ui/DataTable.tsx'); expect(dt.content).toContain('data-testid="ss-list-row"'); }); }); describe('scaffold-ui-primitives / table 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(TABLE_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(TABLE_PRIMITIVES)('%s uses zero dark: prefix', (path) => { expect(fileByPath(path).content).not.toMatch(/\bdark:/); }); it.each(TABLE_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}/); } }); }); describe('scaffold-ui-primitives / TruncatedText — contract', () => { const c = () => fileByPath('src/components/ui/TruncatedText.tsx').content; it('exports the component and a default export', () => { expect(c()).toMatch(/export function TruncatedText\(/); expect(c()).toMatch(/export default TruncatedText/); }); it('detects real truncation (scrollWidth > clientWidth) and clips with truncate', () => { expect(c()).toMatch(/scrollWidth\s*>\s*el\.clientWidth/); expect(c()).toMatch(/\btruncate\b/); }); it('shows the full text via the package Tooltip, gated by disabled when not truncated', () => { expect(c()).toMatch(/import \{ Tooltip \} from '@atlashub\/smartstack'/); expect(c()).toMatch(/ { expect(c()).toMatch(/maxWidth/); }); }); describe('scaffold-ui-primitives / ResponsiveDataTable — contract', () => { const c = () => fileByPath('src/components/ui/ResponsiveDataTable.tsx').content; it('exports the component, the ResponsiveColumn type and ColumnBreakpoint', () => { expect(c()).toMatch(/export function ResponsiveDataTable/); // ColumnBreakpoint is DEFINED in tableRepresentation (the single owner of // width knowledge) and re-exported here, so existing imports keep resolving. expect(c()).toMatch(/export type \{ ColumnBreakpoint \}/); }); it('wraps the LOCAL DataTable (not the package one)', () => { expect(c()).toMatch(/from '@\/components\/ui\/DataTable'/); expect(c()).toMatch(/ columns=\{visible\}/); }); it('delegates column filtering to survivingColumns (breakpoints owned by tableRepresentation)', () => { expect(c()).toMatch(/survivingColumns\(columns, width, hiddenColumnKeys\)/); const r = fileByPath('src/components/ui/tableRepresentation.ts').content; expect(r).toMatch(/BREAKPOINTS[\s\S]*sm:\s*640[\s\S]*md:\s*768[\s\S]*lg:\s*1024[\s\S]*xl:\s*1280/); expect(r).toMatch(/c\.key === ALWAYS_KEY \|\| !c\.minBreakpoint/); expect(r).toMatch(/ALWAYS_KEY = '__actions'/); }); it('clips truncate columns through TruncatedText (resolving nested keys)', () => { expect(c()).toMatch(/import \{ TruncatedText \} from '@\/components\/ui\/TruncatedText'/); expect(c()).toMatch(/if \(!c\.truncate \|\| c\.render\) return c/); expect(c()).toMatch(/\{formatCellValue\(getNestedValue\(item, key\), key\)\}/); }); it('routes the truncate fallback through DataTable formatCellValue (ISO dates → display settings)', () => { expect(c()).toMatch(/import \{ DataTable, formatCellValue,/); }); // Measuring the WINDOW kept columns that did not fit: at a 768px window the // content pane is ~720px, yet every `md` column was retained because // window.innerWidth >= 768. The pane can be 256-320px narrower than the window // (pinned sidebar, resizable doc panel), so the container is the only honest // ruler. it('measures its own container, never the window', () => { expect(c()).not.toMatch(/window\.innerWidth/); expect(c()).not.toMatch(/matchMedia\s*\(/); expect(c()).toMatch(/useContainerWidth\(hostRef\)/); expect(c()).toMatch(/
/); }); it('passes the surviving columns width budget so overflow-x-auto can fire', () => { expect(c()).toMatch(/minWidth=\{tableMinWidth\(visible, rest\.selectable\)\}/); }); it('drops user-hidden columns (column picker) BEFORE the breakpoint filter, actions column exempt', () => { expect(c()).toMatch(/hiddenColumnKeys\?: ReadonlySet/); const r = () => fileByPath('src/components/ui/tableRepresentation.ts').content; const hiddenIdx = r().indexOf('!hiddenColumnKeys?.has(c.key)'); const breakpointIdx = r().indexOf('!c.minBreakpoint || width >= BREAKPOINTS'); expect(hiddenIdx).toBeGreaterThan(-1); expect(breakpointIdx).toBeGreaterThan(-1); expect(hiddenIdx).toBeLessThan(breakpointIdx); expect(r()).toMatch(/c\.key === ALWAYS_KEY \|\| !hiddenColumnKeys\?\.has\(c\.key\)/); }); }); describe('scaffold-ui-primitives / DataTable — owned base table (regression guard)', () => { const c = () => fileByPath('src/components/ui/DataTable.tsx').content; it('is emitted (so ResponsiveDataTable + every *ListPage import resolves) and exports the contract', () => { const f = fileByPath('src/components/ui/DataTable.tsx'); expect(f.strategy).toBe('overwrite'); expect(c()).toMatch(/export function DataTable/); expect(c()).toMatch(/export interface DataTableProps/); }); it('IS the base table — depends only on react + lucide + the package datetime helpers, never re-imports a DataTable', () => { expect(c()).not.toMatch(/from '@\/components\/ui\/DataTable'/); expect(c()).toMatch(/from 'lucide-react'/); // Platform display-settings door for the no-render cell fallback. expect(c()).toMatch(/import \{ isIsoDateLike, formatIsoSmart \} from '@atlashub\/smartstack'/); }); it('formats full-ISO cell values through the display settings in the no-render fallback', () => { expect(c()).toMatch(/export function formatCellValue\(value: unknown, columnKey: string\): string/); expect(c()).toMatch(/isIsoDateLike\(value\) \? formatIsoSmart\(value, columnKey\) : String\(value \?\? ''\)/); expect(c()).toMatch(/formatCellValue\(getNestedValue\(item, column\.key\), column\.key\)/); }); it('styles itself through the --table-* design tokens scaffold-theme emits (no bare/un-themed table)', () => { for (const tok of [ '--table-header-bg', '--table-header-text', '--table-zebra-bg', '--table-row-hover-bg', '--table-border-color', '--table-radius', '--table-cell-px', '--table-cell-py', ]) { expect(c(), `DataTable must consume ${tok}`).toContain(tok); } }); it('actually implements column sorting (not a dead `sortable` flag like the legacy shim)', () => { expect(c()).toMatch(/const handleSort = \(key: string\)/); expect(c()).toMatch(/column\.sortable && handleSort\(/); expect(c()).toMatch(/renderSortIcon/); expect(c()).toMatch(/onSortChange/); // controlled (server-side) sort path }); it('supports search, pagination and row selection (full parity)', () => { expect(c()).toMatch(/searchable/); expect(c()).toMatch(/handlePageChange/); expect(c()).toMatch(/selectable/); expect(c()).toMatch(/handleSelectAll/); }); it('supports controlled search and hides its own input in that mode (mirrors controlled sort)', () => { expect(c()).toMatch(/searchTerm\?: string/); expect(c()).toMatch(/onSearchChange\?: \(value: string\) => void/); expect(c()).toMatch(/const isSearchControlled = onSearchChange != null/); expect(c()).toMatch(/effectiveSearchTerm/); // The built-in input only renders in uncontrolled mode. expect(c()).toMatch(/searchable && !isSearchControlled/); }); it('supports serverMode: renders the fetched page as-is (no client filter/sort/slice) and drives the pager from props', () => { // Server-pagination contract on the props. expect(c()).toMatch(/serverMode\?: boolean/); expect(c()).toMatch(/onPageChange\?: \(page: number\) => void/); expect(c()).toMatch(/totalCount\?: number/); expect(c()).toMatch(/const isServer = serverMode === true/); // The three client transforms each short-circuit in server mode. expect(c()).toMatch(/if \(isServer \|\| !effectiveSearchTerm\) return safeData/); // no client filter expect(c()).toMatch(/if \(isServer \|\| isSortControlled \|\| !sortKey\) return filteredData/); // no client sort expect(c()).toMatch(/if \(isServer \|\| !pagination\) return sortedData/); // no client slice // The pager reads page/total from props; page changes call onPageChange, not local state. expect(c()).toMatch(/const effectiveCurrentPage = isServer \? \(page \?\? 1\) : currentPage/); expect(c()).toMatch(/if \(isServer\) \{ onPageChange\?\.\(clamped\); return; \}/); }); it('loading renders SKELETON rows (no spinner), sized to the page and responsive-aware', () => { // The spinner era is over: loading shows the table anatomy immediately. expect(c()).not.toMatch(/Loader2/); expect(c()).toMatch(/import \{ Skeleton \} from '@\/components\/ui\/Skeleton'/); expect(c()).toMatch(/Math\.min\(pagination\?\.pageSize \?\? 8, 8\)/); expect(c()).toMatch(/data-testid="ss-list-skeleton-row"/); // Skeleton cells respect the responsive hide + sticky classes of their column. expect(c()).toMatch(/ss-list-skeleton-row[\s\S]*?getResponsiveHideClass\(column\)/); expect(c()).not.toMatch(/--color-primary-500/); }); it('empty state delegates to the EmptyState primitive (icon + title + description + CTA)', () => { expect(c()).toMatch(/import \{ EmptyState \} from '@\/components\/ui\/EmptyState'/); expect(c()).toMatch(//); expect(c()).toMatch(/emptyDescription\?: string/); expect(c()).toMatch(/emptyAction\?: ReactNode/); }); it('header is sticky and pinned columns consume the sticky prop (dead prop no more)', () => { expect(c()).toMatch(/