import { describe, it, expect } from 'vitest'; import { generate } from '../generate.js'; import type { ScaffoldUiPrimitivesInput } from '../types.js'; /** * The width budget — the rule that decides whether a list still READS as a table. * * A table only earns its layout when the reader compares rows column by column. * Below the width where a column's value can be recognised at a glance the * comparison is dead, and of the three ways out only one is honest: hiding * columns amputates the data silently, horizontal scrolling fights the vertical * page scroll on touch and hides the actions column, so the list switches * representation instead and keeps every field. */ function fixture(overrides: Partial = {}): ScaffoldUiPrimitivesInput { return { projectPath: '/tmp/web', appCode: 'crm', force: false, ...overrides }; } function emitted(path: string): { path: string; content: string; strategy: string } { const f = generate(fixture()).find((x) => x.path === path); if (!f) throw new Error(`missing generated file ${path}`); return f; } const module_ = () => emitted('src/components/ui/tableRepresentation.ts').content; describe('scaffold-ui-primitives / tableRepresentation — contract', () => { it('is emitted as an overwritable module', () => { expect(emitted('src/components/ui/tableRepresentation.ts').strategy).toBe('overwrite'); }); it('exports the budget vocabulary the table and the list both consume', () => { const c = module_(); for (const symbol of [ 'COLUMN_MIN_WIDTH', 'SELECTION_COLUMN_WIDTH', 'BREAKPOINTS', 'ALWAYS_KEY', 'inferColumnKind', 'columnMinWidth', 'tableMinWidth', 'decideRepresentation', 'useContainerWidth', 'survivingColumns', 'useListRepresentation', ]) { expect(c, symbol).toMatch(new RegExp(`export (?:const|function|type) ${symbol}\\b`)); } }); it('owns the ONLY copy of the breakpoint table', () => { expect(module_()).toMatch(/BREAKPOINTS[\s\S]*sm:\s*640[\s\S]*md:\s*768[\s\S]*lg:\s*1024[\s\S]*xl:\s*1280/); // ResponsiveDataTable must import them, not redeclare them. const rdt = emitted('src/components/ui/ResponsiveDataTable.tsx').content; expect(rdt).not.toMatch(/const BREAKPOINTS/); expect(rdt).toMatch(/from '@\/components\/ui\/tableRepresentation'/); }); it('classifies column kinds by WORD, not by substring', () => { const c = module_(); // The substring rule read "descripti-on" as a date column and missed // `startDate` outright — hence the tokenisation. expect(c).toMatch(/function keyWords\(/); expect(c).toMatch(/replace\(\/\(\[a-z0-9\]\)\(\[A-Z\]\)\/g, '\$1 \$2'\)/); }); it('measures a container, never the window', () => { expect(module_()).toMatch(/new ResizeObserver\(/); expect(module_()).not.toMatch(/window\.innerWidth/); }); it('stays on the table until the first measurement lands (no cards flash)', () => { expect(module_()).toMatch(/if \(containerWidth === null \|\| containerWidth <= 0\) return 'table'/); }); it('lets a pinned representation win over the measurement', () => { expect(module_()).toMatch(/if \(options\.pinned\) return \{ mode: options\.pinned, hostRef \}/); }); it('computes the budget on the SURVIVING columns, so an authored minBreakpoint acts first', () => { expect(module_()).toMatch(/survivingColumns\(columns, effective, options\.hiddenColumnKeys\)/); }); }); /** * The calibration, pinned in both directions. These two numbers are the reason * COLUMN_MIN_WIDTH holds the values it holds; changing a constant without * re-reading them is how a desktop list silently turns into cards. */ describe('scaffold-ui-primitives / tableRepresentation — calibration', () => { // Re-implemented here from the emitted constants rather than imported: the // module is a STRING until a client app is scaffolded. const MIN = { actions: 88, status: 96, number: 96, code: 104, date: 112, text: 128, label: 144 }; it('emits exactly the calibrated constants', () => { const c = module_(); for (const [kind, px] of Object.entries(MIN)) { expect(c, kind).toMatch(new RegExp(`${kind}:\\s*${px},`)); } }); // The list that prompted this work: 7 columns, 4 of them long free text. const GRILLES_BUDGET = MIN.code + MIN.label + MIN.text * 4 + MIN.actions; // 848 it('a 7-column list becomes cards on a tablet (768px window ≈ 720px of content)', () => { expect(720).toBeLessThan(GRILLES_BUDGET); }); it('the SAME list stays a table on a desktop (1280px window + pinned sidebar ≈ 944px)', () => { expect(944).toBeGreaterThanOrEqual(GRILLES_BUDGET); }); it('a short list stays a table on a tablet', () => { const short = MIN.code + MIN.label + MIN.status + MIN.actions; // 432 expect(720).toBeGreaterThanOrEqual(short); }); });