import { describe, it, expect } from 'vitest' import { generate } from '../generate.js' import type { ScaffoldComponentInput, PageSpecMin } from '../types.js' /** * Default-visibility budget + column picker contract (the "22 colonnes à plat" * fix). Every pagespec column stays in the columns literal (and the DTO); the * budget only decides which are VISIBLE BY DEFAULT: * - inactive (≤ 7 columns, no medium/low priority) → today's rendering, * - explicit medium/low → hidden by default even under the budget, * - always/high → never truncated, even beyond the budget (the BA wins), * - no priorities + > 7 columns → first-7 fill by declaration order, * - hidden-by-default columns get NO auto responsive tier (a re-enabled * column must not re-drop on a narrow screen), * - the page wires useColumnVisibility + + hiddenColumnKeys. */ const I18N: Record = { 'list.title': 'Budgets', 'list.subtitle': 'x', 'list.create': 'x', 'list.search': 'x', 'list.loading': 'x', 'list.empty': 'x', } function pageSpec(columns: PageSpecMin['columns']): PageSpecMin { return { screenCode: 'SCR-BUDGET-LIST', module: 'budgets', appCode: 'testv2', section: 'budgets', entity: 'Budget', view: 'list', filePath: 'src/pages/testv2/budgets/budgets/BudgetsListPage.tsx', permission: 'budgets.budgets.read', actions: [], columns, i18nKeys: { fr: I18N, en: I18N, it: I18N, de: I18N }, needsRefinement: false, specHash: 'a'.repeat(64), } } function gen(columns: PageSpecMin['columns'], fields?: ScaffoldComponentInput['fields']): string { const input: ScaffoldComponentInput = { module: 'budgets', appCode: 'testv2', entity: 'Budget', section: 'budgets', views: ['list'], fields: fields ?? [ { name: 'code', type: 'string', required: true }, { name: 'label', type: 'string', required: true }, { name: 'amount', type: 'number', required: true }, { name: 'category', type: 'string', required: false }, { name: 'region', type: 'string', required: false }, { name: 'notes', type: 'string', required: false }, { name: 'owner', type: 'string', required: false }, { name: 'source', type: 'string', required: false }, { name: 'reference', type: 'string', required: false }, ], projectPath: '/web', pageSpec: pageSpec(columns), } const files = generate(input) const list = files.find((f) => /ListPage\.tsx$/.test(f.path)) if (!list) throw new Error('no list page emitted') return list.content } /** Extracts the visibility-defaults literal entry for a given key. */ function visibilityEntryFor(content: string, key: string): string { const re = new RegExp(`\\{ key: '${key}', label: [^}]*defaultVisible: (?:true|false)[^}]*\\}`) const m = content.match(re) if (!m) throw new Error(`no columnVisibilityDefaults entry for key '${key}'`) return m[0] } /** Extracts the emitted table-column object literal for a given key. The * `sortable:` guard keeps the match off the columnVisibilityDefaults literal * (same key + label shape, no sortable). */ function columnEntryFor(content: string, key: string): string { const re = new RegExp(`\\{ key: '${key}',(?=[^}]*sortable:)[^}]*\\}`) const m = content.match(re) if (!m) throw new Error(`no column entry for key '${key}'`) return m[0] } function col(key: string, extra: Record = {}) { return { key, labelKey: `list.columns.${key}`, sortable: false, ...extra } } const NINE_COLUMNS = [ col('code'), col('label'), col('amount'), col('category'), col('region'), col('notes'), col('owner'), col('source'), col('reference'), ] as PageSpecMin['columns'] describe('scaffold-component / column budget — inactive path (≤ 7 columns, no medium/low)', () => { const FIVE = [col('code'), col('label'), col('amount'), col('category'), col('region')] as PageSpecMin['columns'] it('keeps every column visible by default', () => { const c = gen(FIVE) expect(c).not.toMatch(/defaultVisible: false/) }) it('keeps the md → lg → xl tiering of the extra columns (byte-stable legacy rendering)', () => { const c = gen(FIVE) expect(columnEntryFor(c, 'amount')).toMatch(/minBreakpoint: 'md'/) expect(columnEntryFor(c, 'category')).toMatch(/minBreakpoint: 'lg'/) expect(columnEntryFor(c, 'region')).toMatch(/minBreakpoint: 'xl'/) }) }) describe('scaffold-component / column budget — legacy > 7 columns without priorities', () => { it('fills to 7 visible in declaration order, hides the rest by default', () => { const c = gen(NINE_COLUMNS) // code + label (always, locked) + amount..notes fill to 7. for (const key of ['code', 'label', 'amount', 'category', 'region', 'notes', 'owner']) { expect(visibilityEntryFor(c, key), key).toMatch(/defaultVisible: true/) } for (const key of ['source', 'reference']) { expect(visibilityEntryFor(c, key), key).toMatch(/defaultVisible: false/) } }) it('gives default-hidden columns NO auto responsive tier (deliberate re-show must not re-drop)', () => { const c = gen(NINE_COLUMNS) expect(columnEntryFor(c, 'source')).not.toMatch(/minBreakpoint/) expect(columnEntryFor(c, 'reference')).not.toMatch(/minBreakpoint/) // The tiering only advances on default-VISIBLE extras. expect(columnEntryFor(c, 'amount')).toMatch(/minBreakpoint: 'md'/) expect(columnEntryFor(c, 'category')).toMatch(/minBreakpoint: 'lg'/) expect(columnEntryFor(c, 'region')).toMatch(/minBreakpoint: 'xl'/) }) it('still honours an explicit minBreakpoint on a default-hidden column', () => { const c = gen([ ...NINE_COLUMNS!.slice(0, 8), col('reference', { minBreakpoint: 'lg' }), ] as PageSpecMin['columns']) expect(columnEntryFor(c, 'reference')).toMatch(/minBreakpoint: 'lg'/) }) }) describe('scaffold-component / column budget — authored priorities', () => { it('hides an explicit medium/low even under the 7-column budget', () => { const c = gen([ col('code'), col('label'), col('amount'), col('notes', { priority: 'low' }), col('category', { priority: 'medium' }), ] as PageSpecMin['columns']) expect(visibilityEntryFor(c, 'notes')).toMatch(/defaultVisible: false/) expect(visibilityEntryFor(c, 'category')).toMatch(/defaultVisible: false/) expect(visibilityEntryFor(c, 'amount')).toMatch(/defaultVisible: true/) }) it('never truncates always/high, even beyond the budget (the BA wins — PRD-109 warns upstream)', () => { const c = gen([ col('code'), col('label'), col('amount', { priority: 'high' }), col('category', { priority: 'high' }), col('region', { priority: 'high' }), col('notes', { priority: 'high' }), col('owner', { priority: 'high' }), col('source', { priority: 'high' }), col('reference', { priority: 'high' }), ] as PageSpecMin['columns']) expect(c).not.toMatch(/defaultVisible: false/) }) it('hides unprioritized columns when always/high already fill the budget', () => { const c = gen([ col('code'), col('label'), col('amount', { priority: 'high' }), col('category', { priority: 'high' }), col('region', { priority: 'high' }), col('notes', { priority: 'high' }), col('owner', { priority: 'high' }), col('source'), col('reference'), ] as PageSpecMin['columns']) expect(visibilityEntryFor(c, 'source')).toMatch(/defaultVisible: false/) expect(visibilityEntryFor(c, 'reference')).toMatch(/defaultVisible: false/) }) }) describe('scaffold-component / column picker — page wiring', () => { it('emits the defaults literal + persisted hook with the per-page storage key', () => { const c = gen(NINE_COLUMNS) expect(c).toMatch(/const columnVisibilityDefaults = \[/) expect(c).toMatch(/useColumnVisibility\('ss\.cols\.v1\.testv2\.budgets\.budgets\.budget', columnVisibilityDefaults\)/) expect(c).toMatch(/import \{ useColumnVisibility \} from '@\/components\/ui\/useColumnVisibility'/) }) it('locks the identity (always) columns and only them', () => { const c = gen(NINE_COLUMNS) expect(visibilityEntryFor(c, 'code')).toMatch(/locked: true/) expect(visibilityEntryFor(c, 'label')).toMatch(/locked: true/) expect(visibilityEntryFor(c, 'amount')).toMatch(/locked: false/) }) it('renders the fed from the defaults + hook, with the floor i18n keys', () => { const c = gen(NINE_COLUMNS) expect(c).toMatch(/import \{ ColumnPicker \} from '@\/components\/ui\/ColumnPicker'/) expect(c).toMatch(/