import { describe, it, expect } from 'vitest' import { generate } from '../generate.js' import type { ScaffoldComponentInput, PageSpecMin } from '../types.js' import { buildNavApiPath } from '../../../../../../lib/url-conventions.js' function fixture(overrides: Partial = {}): ScaffoldComponentInput { return { module: 'crm', appCode: 'TestV2', entity: 'Contact', section: 'directory', views: ['list', 'detail', 'form'], // Full sibling view set — single-view overrides below still emit the // cross-view navs (the 3a orchestrator passes this per invocation). entityViews: ['list', 'detail', 'form'], fields: [ { name: 'firstName', type: 'string', required: true }, { name: 'lastName', type: 'string', required: true }, { name: 'email', type: 'string', required: false }, ], projectPath: '/web', ...overrides, } } describe('scaffold-component / generate', () => { it('emits no local usePermissions stub anywhere in any page', () => { const files = generate(fixture()) for (const file of files.filter((f) => f.path.endsWith('Page.tsx'))) { expect(file.content).not.toMatch(/function\s+usePermissions\s*\(/) expect(file.content).not.toMatch(/const\s+usePermissions\s*=/) } }) it('every page imports PageTemplate from @/components/ui/PageTemplate', () => { const files = generate(fixture()) const pages = files.filter((f) => f.path.endsWith('Page.tsx')) expect(pages.length).toBeGreaterThan(0) for (const page of pages) { expect(page.content).toMatch(/import\s*\{\s*PageTemplate\s*\}\s*from\s*['"]@\/components\/ui\/PageTemplate['"]/) } }) it('every permission="…" literal matches {module}.{section}.{action} (3 lowercase segments, no appCode prefix)', () => { const files = generate(fixture()) const permRe = /permission\s*=\s*['"]([^'"]+)['"]/g const validShape = /^[a-z][a-z0-9-]*\.[a-z][a-z0-9-]*\.[a-z][a-z0-9-]*$/ let matched = 0 for (const page of files.filter((f) => f.path.endsWith('Page.tsx'))) { let m: RegExpExecArray | null while ((m = permRe.exec(page.content)) !== null) { matched++ expect(m[1]).toMatch(validShape) expect(m[1].split('.').length).toBe(3) expect(m[1].startsWith('crm.')).toBe(true) } } expect(matched).toBeGreaterThan(0) }) it('list / detail / form pages wrap content in ', () => { const files = generate(fixture()) // List uses plural ('ContactsListPage'); detail/form stay singular // (matches the SmartStack project convention). const expectedNames: Record = { List: 'ContactsListPage.tsx', Detail: 'ContactDetailPage.tsx', Form: 'ContactFormPage.tsx', } for (const [, fileName] of Object.entries(expectedNames)) { const page = files.find((f) => f.path.endsWith(fileName))! expect(page).toBeDefined() expect(page.content).toMatch(/ { const files = generate(fixture({ views: ['dashboard'], pageSpec: { i18nKeys: {}, widgets: [ { key: 'active', label: 'Active', type: 'kpi', col: 3 }, { key: 'byDept', label: 'By department', type: 'chart-pie', col: 6, permission: 'crm.directory.read' }, { key: 'recent', label: 'Recent', type: 'list', col: 12 }, ], } as unknown as PageSpecMin, })) const page = files.find((f) => f.path.endsWith('ContactDashboardPage.tsx'))! expect(page, 'ContactDashboardPage.tsx').toBeDefined() // local dashboard primitives + themed DateInput (never native date) expect(page.content).toMatch(/import \{ WidgetRenderer \} from '@\/components\/dashboard\/WidgetRenderer'/) expect(page.content).toMatch(/import \{ DashboardGrid \} from '@\/components\/dashboard\/DashboardGrid'/) expect(page.content).toMatch(/import \{ DateInput \} from '@\/components\/ui\/DateInput'/) expect(page.content).not.toMatch(/type="date"/) // typed widgets baked from the pageSpec expect(page.content).toMatch(/const WIDGETS: DashboardWidget\[\]/) expect(page.content).toMatch(/key: "active"/) expect(page.content).toMatch(/type: "chart-pie"/) expect(page.content).toMatch(/ { const files = generate(fixture()) const list = files.find((f) => f.path.endsWith('ContactsListPage.tsx'))! expect(list.content).toMatch(/from\s+['"]@atlashub\/smartstack['"]/) // Create button is wrapped expect(list.content).toMatch(/ { const files = generate(fixture({ views: ['list', 'detail', 'form'] })) const list = files.find((f) => f.path.endsWith('ContactsListPage.tsx'))! // Row action affordances carry a per-id testid the UAT driver keys on. expect(list.content).toContain('data-testid={`row-edit-${item.id}`}') expect(list.content).toContain('data-testid={`row-delete-${item.id}`}') // The page-level read guard renders a permission-denied marker (not a blank page). expect(list.content).toMatch(//) const form = files.find((f) => /FormPage\.tsx$/.test(f.path))! expect(form.content).toContain('data-testid="form-submit"') }) it('derives the guard prefix from the PAGESPEC permission (resource grain honoured — chantier 4.1)', () => { // The generator used to ALWAYS recompute `{module}.{section}` and ignore // spec.permission (H11 — the stale comment even claimed the opposite): a // narrower resource-grain page silently guarded on the wider section key. const files = generate(fixture({ views: ['list'], pageSpec: { view: 'list', permission: 'crm.directory.vip.read', i18nKeys: {}, } as unknown as PageSpecMin, })) const list = files.find((f) => f.path.endsWith('ContactsListPage.tsx'))! expect(list.content).toMatch(//) expect(list.content).toMatch(/ { const files = generate(fixture({ views: ['list'], pageSpec: undefined })) const list = files.find((f) => f.path.endsWith('ContactsListPage.tsx'))! expect(list.content).toMatch(//) }) it('generates hub pages (app-home / module-home / section-home) with PageTemplate + tokens', () => { const files = generate(fixture({ views: ['app-home', 'module-home', 'section-home'] })) const expectedFiles = ['ContactsAppHomePage.tsx', 'ContactsModuleHomePage.tsx', 'ContactsSectionHomePage.tsx'] for (const filename of expectedFiles) { const page = files.find((f) => f.path.endsWith(filename))! expect(page, `missing ${filename}`).toBeDefined() // Wraps content in expect(page.content).toMatch(/ { const files = generate(fixture({ views: ['section-home'], pageSpec: { i18nKeys: {}, widgets: [ { key: 'total', labelKey: 'widgets.total', type: 'kpi', entity: 'Contact', aggregation: 'count' }, { key: 'foreign', labelKey: 'widgets.foreign', type: 'kpi', entity: 'Invoice', aggregation: 'sum', field: 'amount' }, ], quickLinks: [ { key: 'all', labelKey: 'quicklinks.all', icon: 'list', screenTarget: 'SCR-CRM-DIRECTORY-LIST-001' }, { key: 'new', labelKey: 'quicklinks.new', icon: 'plus', screenTarget: 'SCR-CRM-DIRECTORY-FORM-001', permission: 'crm.directory.create' }, { key: 'ghost', labelKey: 'quicklinks.ghost', screenTarget: 'SCR-OTHER-MODULE-LIST-001' }, ], } as unknown as PageSpecMin, })) const page = files.find((f) => f.path.endsWith('ContactsSectionHomePage.tsx'))!.content // Own-entity count widget reads the list hook's totalCount through StatCard. expect(page).toMatch(/import \{ StatCard \} from '@\/components\/ui\/StatCard'/) expect(page).toMatch(/const \{ data: countData, isLoading: countLoading \} = useContacts\(\{ page: 1, pageSize: 1 \}\)/) expect(page).toMatch(/value=\{countData\?\.totalCount \?\? 0\}/) expect(page).toMatch(/loading=\{countLoading\}/) // A non-wirable widget stays visibly unwired — never a fake live figure. expect(page).toMatch(/label=\{t\('contact\.widgets\.foreign'\)\}\n\s+value="—"/) // QuickLinks resolve to real route helpers; the unresolvable one is OMITTED. expect(page).toMatch(/onClick=\{\(\) => navigate\(routes\.directory\.list\(\)\)\}/) expect(page).toMatch(/onClick=\{\(\) => navigate\(routes\.directory\.create\(\)\)\}/) expect(page).toMatch(/import \{ routes \} from '@\/extensions\/testv2-crmRoutes'/) expect(page).not.toMatch(/quicklinks\.ghost/) // The permissioned link is gated. expect(page).toMatch(//) // The dead-mockup era markers are gone. expect(page).not.toMatch(/navigate\('#'\)/) expect(page).not.toMatch(/>— { const files = generate(fixture({ views: ['module-home'] })) const page = files.find((f) => f.path.endsWith('ContactsModuleHomePage.tsx'))!.content expect(page).toMatch(//) expect(page).toMatch(//) // No quickLink resolved → no navigate/routes imports (type-check gate). expect(page).not.toMatch(/useNavigate/) expect(page).not.toMatch(/from '@\/extensions\//) expect(page).not.toMatch(/StatCard/) }) it('emits 4 locale files per entity (fr/en/it/de)', () => { const files = generate(fixture()) const locales = files.filter((f) => /\/i18n\/locales\/(fr|en|it|de)\/crm\.json$/.test(f.path)) expect(locales).toHaveLength(4) for (const loc of locales) { const parsed = JSON.parse(loc.content) expect(parsed).toHaveProperty('contact') expect(parsed.contact).toHaveProperty('list') expect(parsed.contact).toHaveProperty('detail') expect(parsed.contact).toHaveProperty('form') } }) it('isolates i18n keys when two entities share the same module', () => { const filesA = generate(fixture({ entity: 'Contact', module: 'crm' })) const filesB = generate(fixture({ entity: 'Company', module: 'crm', section: 'companies', fields: [ { name: 'name', type: 'string', required: true }, { name: 'industry', type: 'string', required: false }, ], })) const frA = JSON.parse(filesA.find((f) => /fr\/crm\.json$/.test(f.path))!.content) const frB = JSON.parse(filesB.find((f) => /fr\/crm\.json$/.test(f.path))!.content) // Each entity has its own top-level key — no collision expect(frA).toHaveProperty('contact') expect(frB).toHaveProperty('company') expect(frA.contact).toHaveProperty('list') expect(frA.contact).toHaveProperty('detail') expect(frB.company).toHaveProperty('list') expect(frB.company).toHaveProperty('detail') // Titles are entity-specific expect(frA.contact.list.title).not.toBe(frB.company.list.title) }) it('prefixes all t() calls with entity key in generated pages', () => { const files = generate(fixture()) const pages = files.filter((f) => f.path.endsWith('Page.tsx')) for (const page of pages) { // Every t('...') call must start with the entity prefix 'contact.' // (word-gated: useListParamOpt('sort') must not read as t('sort')). const tCalls = page.content.match(/(? { // Regression guard for the REFERENTIELS class of crashes: when the // section is multi-word kebab (e.g. `types-affaire`), the generated // `navigate(routes.types-affaire.detail(id))` is parsed by JS as // `routes.types - affaire.detail(id)` and throws ReferenceError at the // first interaction. The fix mirrors what scaffold-routes already does // (`kebabToCamel(entity.section)`) so both CLIs agree on the property // name: `routes.typesAffaire`. const files = generate(fixture({ section: 'types-affaire' })) const pages = files.filter(f => f.path.endsWith('Page.tsx')) for (const page of pages) { // Camel form MUST appear wherever routes.
.() is referenced. if (page.content.includes('routes.')) { expect(page.content, page.path).toMatch(/routes\.typesAffaire\./) expect(page.content, page.path).not.toMatch(/routes\.types-affaire\./) } } }) it('leaves single-word sections unchanged (camel of `contacts` is `contacts`)', () => { const files = generate(fixture()) // section: 'directory' const pages = files.filter(f => f.path.endsWith('Page.tsx')) for (const page of pages) { if (page.content.includes('routes.')) { expect(page.content, page.path).toMatch(/routes\.directory\./) } } }) }) // ─── Enriched mode (Phase 3a, post-pageSpec) ──────────────────────────── function pageSpecFixture(overrides: Partial = {}): PageSpecMin { return { screenCode: 'SCR-CONTACT-LIST', module: 'crm', appCode: 'testv2', section: 'directory', entity: 'Contact', view: 'list', filePath: 'src/pages/testv2/crm/directory/ContactListPage.tsx', permission: 'crm.directory.read', actions: [ { code: 'create', scope: 'header', labelKey: 'list.create', permission: 'crm.directory.create', variant: 'primary' }, ], columns: [ { key: 'firstName', labelKey: 'list.columns.firstName', sortable: true }, { key: 'lastName', labelKey: 'list.columns.lastName', sortable: true }, ], i18nKeys: { fr: { 'list.title': 'Contacts métier', 'list.subtitle': 'Annuaire CRM personnalisé', 'list.create': 'Nouveau contact', 'list.search': 'Filtrer…', 'list.loading': 'Chargement…', 'list.empty': 'Aucun contact à afficher', 'list.columns.firstName': 'Prénom', 'list.columns.lastName': 'Nom', }, en: { 'list.title': 'Business contacts', 'list.subtitle': 'CRM custom directory', 'list.create': 'New contact', 'list.search': 'Filter…', 'list.loading': 'Loading…', 'list.empty': 'No contact to show', 'list.columns.firstName': 'First name', 'list.columns.lastName': 'Last name', }, it: { 'list.title': 'Contatti business', 'list.subtitle': 'Rubrica CRM', 'list.create': 'Nuovo contatto', 'list.search': 'Filtra…', 'list.loading': 'Caricamento…', 'list.empty': 'Nessun contatto', 'list.columns.firstName': 'Nome', 'list.columns.lastName': 'Cognome', }, de: { 'list.title': 'Geschäftskontakte', 'list.subtitle': 'Benutzerdefiniertes CRM-Verzeichnis', 'list.create': 'Neuer Kontakt', 'list.search': 'Filtern…', 'list.loading': 'Wird geladen…', 'list.empty': 'Kein Kontakt anzuzeigen', 'list.columns.firstName': 'Vorname', 'list.columns.lastName': 'Nachname', }, }, needsRefinement: false, specHash: 'a'.repeat(64), ...overrides, } } describe('scaffold-component / generate — enriched mode', () => { it('layers pageSpec.i18nKeys overrides on top of the buildTranslations floor', () => { const files = generate(fixture({ pageSpec: pageSpecFixture() })) const fr = files.find((f) => /\/i18n\/locales\/fr\/crm\.json$/.test(f.path))! expect(fr).toBeDefined() const parsed = JSON.parse(fr.content) // The pageSpec catalogue overrides the auto-generated floor — these are // project-specific values that buildTranslations() would never produce on // its own (humanize 'firstName' → 'First name', not 'Prénom métier'). expect(parsed.contact.list.title).toBe('Contacts métier') expect(parsed.contact.list.subtitle).toBe('Annuaire CRM personnalisé') expect(parsed.contact.list.create).toBe('Nouveau contact') expect(parsed.contact.list.empty).toBe('Aucun contact à afficher') expect(parsed.contact.list.columns.firstName).toBe('Prénom') expect(parsed.contact.list.columns.lastName).toBe('Nom') }) it('emits a COMPLETE structural floor even when pageSpec.i18nKeys is thin', () => { // Regression for the RH raw-key bug: a thin PRD i18nKeys block (only // list.title) must NOT leave the structural keys the templates render bare // untranslated — the generator seeds the full buildTranslations() floor // underneath, in all 4 locales, then lets the PRD override what it provides. const thin = pageSpecFixture({ i18nKeys: { fr: { 'list.title': 'Employés' }, en: { 'list.title': 'Employees' }, it: { 'list.title': 'Dipendenti' }, de: { 'list.title': 'Mitarbeiter' }, }, }) const files = generate(fixture({ pageSpec: thin })) // Every structural key rendered WITHOUT a defaultValue must resolve in // each locale (these are exactly the keys that showed raw in the RH module). const structural = [ ['breadcrumb', 'section'], ['list', 'actionsColumn'], ['list', 'edit'], ['list', 'delete'], ['list', 'empty'], ['list', 'error'], ['list', 'filters', 'all'], ['detail', 'edit'], ['detail', 'notFound'], ['form', 'submitCreate'], ['form', 'submitUpdate'], ['form', 'cancel'], ['form', 'required'], ['form', 'section', 'essential'], ['form', 'section', 'details'], ['kanban', 'title'], ['kanban', 'unassigned'], ['reconduction', 'action', 'refuse'], ['reconduction', 'columns', 'identifier'], ] for (const locale of ['fr', 'en', 'it', 'de'] as const) { const file = files.find((f) => f.path === `src/i18n/locales/${locale}/crm.json`)! const c = JSON.parse(file.content).contact for (const path of structural) { const leaf = path.reduce((node, seg) => (node as Record)?.[seg], c) expect(leaf, `${locale} ${path.join('.')}`).toBeTypeOf('string') } } // …and the one key the PRD does supply still wins over the floor. const frC = JSON.parse(files.find((f) => /\/fr\/crm\.json$/.test(f.path))!.content).contact expect(frC.list.title).toBe('Employés') }) it('drops "[en]/[it]/[de] …" placeholder values so the floor wins (PRD-089 self-heal)', () => { // Legacy "author FR, defer the rest" pagespecs carry bracket-tagged // placeholders in en/it/de. They must NEVER override the floor: dropping // them here ships the floor's real translation (or humanised label) // instead of a raw "[en] …" on screen. PRD-089 still gates the PRD — // this is the belt-and-suspenders heal at the scaffolder input edge. const ps = pageSpecFixture({ i18nKeys: { fr: { 'list.title': 'Contacts métier', 'list.create': 'Nouveau contact' }, en: { 'list.title': '[en] Contacts métier', 'list.create': 'New contact' }, it: { 'list.title': '[it] Contacts métier', 'list.create': '[it] Nouveau contact' }, de: { 'list.title': '[de] Contacts métier' }, }, }) const files = generate(fixture({ pageSpec: ps })) const read = (locale: string) => JSON.parse(files.find((f) => f.path === `src/i18n/locales/${locale}/crm.json`)!.content).contact // Real translations ride through untouched. expect(read('fr').list.title).toBe('Contacts métier') expect(read('en').list.create).toBe('New contact') // Placeholders are dropped — the buildTranslations() floor wins. expect(read('en').list.title).toBe('Contacts') expect(read('it').list.title).toBe('Contacts') expect(read('it').list.create).toBe('Crea') expect(read('de').list.title).toBe('Contacts') // No bracket-tagged placeholder survives in ANY locale payload. for (const locale of ['fr', 'en', 'it', 'de'] as const) { const content = files.find((f) => f.path === `src/i18n/locales/${locale}/crm.json`)!.content expect(content, `${locale} catalogue must not ship placeholders`).not.toMatch(/\[(en|it|de)\] /) } }) it('normalizes PascalCase field keys from pageSpec.i18nKeys to camelCase', () => { const ps = pageSpecFixture({ i18nKeys: { fr: { 'list.title': 'Contacts', 'list.columns.FirstName': 'Prénom', 'list.columns.LastName': 'Nom', 'form.fields.FirstName': 'Prénom', 'form.fields.EstActif': 'Actif', 'detail.fields.Code': 'Code', 'list.filters.SecteurId': 'Secteur', }, en: { 'list.title': 'Contacts', 'list.columns.FirstName': 'First name', 'form.fields.FirstName': 'First name', 'form.fields.EstActif': 'Active', 'detail.fields.Code': 'Code', 'list.filters.SecteurId': 'Sector' }, it: { 'list.title': 'Contatti', 'list.columns.FirstName': 'Nome', 'form.fields.FirstName': 'Nome', 'form.fields.EstActif': 'Attivo', 'detail.fields.Code': 'Codice', 'list.filters.SecteurId': 'Settore' }, de: { 'list.title': 'Kontakte', 'list.columns.FirstName': 'Vorname', 'form.fields.FirstName': 'Vorname', 'form.fields.EstActif': 'Aktiv', 'detail.fields.Code': 'Code', 'list.filters.SecteurId': 'Sektor' }, }, }) const files = generate(fixture({ pageSpec: ps })) const fr = files.find((f) => /\/i18n\/locales\/fr\/crm\.json$/.test(f.path))! const parsed = JSON.parse(fr.content) // PascalCase field keys normalized to camelCase (matching t() calls) expect(parsed.contact.list.columns.firstName).toBe('Prénom') expect(parsed.contact.list.columns.lastName).toBe('Nom') expect(parsed.contact.form.fields.firstName).toBe('Prénom') expect(parsed.contact.form.fields.estActif).toBe('Actif') expect(parsed.contact.detail.fields.code).toBe('Code') expect(parsed.contact.list.filters.secteurId).toBe('Secteur') // Non-field keys stay as-is expect(parsed.contact.list.title).toBe('Contacts') // PascalCase originals should NOT exist expect(parsed.contact.list.columns.FirstName).toBeUndefined() expect(parsed.contact.form.fields.FirstName).toBeUndefined() }) it('produces parallel locale catalogues from pageSpec.i18nKeys (4 locales nested)', () => { const files = generate(fixture({ pageSpec: pageSpecFixture() })) for (const locale of ['fr', 'en', 'it', 'de'] as const) { const file = files.find((f) => f.path === `src/i18n/locales/${locale}/crm.json`)! expect(file, `missing locale ${locale}`).toBeDefined() const parsed = JSON.parse(file.content) expect(parsed.contact).toBeDefined() expect(parsed.contact.list).toBeDefined() expect(parsed.contact.list.columns).toBeDefined() expect(parsed.contact.list.columns.firstName).toBeDefined() } }) it('emits i18n catalogues under src/i18n relative to the web root (same base as pages)', () => { // projectPath IS the validated web root (assertWebProjectRoot) — a // web/-web prefix here would double-nest into a phantom folder the // runtime never loads (business pages then render raw i18n keys). const files = generate(fixture({ pageSpec: pageSpecFixture() })) const i18nFiles = files.filter((f) => /\.json$/.test(f.path)) expect(i18nFiles.length).toBeGreaterThan(0) for (const f of i18nFiles) { expect(f.path, 'i18n path must be web-root relative, like pages').toMatch(/^src\/i18n\/locales\//) } expect(files.some((f) => f.path.startsWith('web/')), 'no emitted path may start with web/').toBe(false) }) it('falls back to buildTranslations() (legacy) when pageSpec is absent', () => { // No pageSpec → legacy dictionary outputs 'Contacts' (plural humanize) for list.title const files = generate(fixture()) const fr = files.find((f) => /\/i18n\/locales\/fr\/crm\.json$/.test(f.path))! const parsed = JSON.parse(fr.content) expect(parsed.contact.list.title).toBe('Contacts') // No 'subtitle' override from pageSpec — legacy "Gérer les contacts" expect(parsed.contact.list.subtitle).toContain('Gérer') }) it('legacy mode and enriched mode emit the same number of files (no schema drift)', () => { const legacyFiles = generate(fixture()).map((f) => f.path).sort() const enrichedFiles = generate(fixture({ pageSpec: pageSpecFixture() })).map((f) => f.path).sort() // Enriched mode honours pageSpec.filePath as the page output target; // legacy mode uses the derived `src/pages/{appCode}/{module}/{section}/...` path. // The PATHS may differ (e.g. legacy folder name) but the FILE COUNT // must stay identical so no view is silently dropped. expect(enrichedFiles.length).toBe(legacyFiles.length) }) it('honours pageSpec.filePath verbatim — output target matches the legacy folder', () => { // pageSpec.filePath = 'src/pages/testv2/crm/directory/ContactListPage.tsx' (the // fixture path, lifted as-is from the canonical PageSpec contract). Even when // the project's actual folder layout differs from the derived // `src/pages/{module}/{section}/...`, the scaffold MUST write at the path // declared in the pageSpec — that's the contract's "source of truth" rule. const ps = pageSpecFixture({ filePath: 'src/pages/legacy-budgeting/budgets/BudgetsListPage.tsx' }) const files = generate(fixture({ pageSpec: ps, module: 'budgets', entity: 'Budget', section: 'budgets', views: ['list'], })) const listPage = files.find((f) => f.path.endsWith('BudgetsListPage.tsx'))! expect(listPage).toBeDefined() expect(listPage.path).toBe('src/pages/legacy-budgeting/budgets/BudgetsListPage.tsx') }) it('a bare pageSpec.filePath (no directory) never becomes the base directory itself', () => { // A filePath like 'BudgetsListPage.tsx' has no '/' — the directory-strip // regex used to leave base = 'BudgetsListPage.tsx', landing SIBLING files // under 'BudgetsListPage.tsx/…'. The base must fall back to the canonical // src/pages/{app}/{module}/{section}/ directory instead. const ps = pageSpecFixture({ filePath: 'BudgetsListPage.tsx' }) const files = generate(fixture({ pageSpec: ps, module: 'budgets', entity: 'Budget', section: 'budgets', views: ['list'], })) expect(files.some((f) => f.path.includes('BudgetsListPage.tsx/'))).toBe(false) }) it('uses pageSpec.columns verbatim — does NOT pull legacy columns from spec.fields', () => { // The data model can carry attributes the BA did NOT include in the list // view (e.g. legacy `description` column). Without honouring pageSpec.columns // the scaffold renders them as untranslated `LIST.COLUMNS.DESCRIPTION` headers. const ps = pageSpecFixture({ columns: [ { key: 'code', labelKey: 'list.columns.code', sortable: true }, { key: 'label', labelKey: 'list.columns.label', sortable: true }, { key: 'initialAmount', labelKey: 'list.columns.initialAmount', sortable: true }, ], }) const files = generate(fixture({ pageSpec: ps, // Inject a legacy `description` field that the BA did NOT declare for this view. fields: [ { name: 'code', type: 'string', required: true }, { name: 'label', type: 'string', required: true }, { name: 'description', type: 'string', required: false }, { name: 'initialAmount', type: 'number', required: true }, ], module: 'budgets', entity: 'Budget', section: 'budgets', views: ['list'], })) const list = files.find((f) => f.path.endsWith('ContactsListPage.tsx')) ?? files.find((f) => /ListPage\.tsx$/.test(f.path))! // The pageSpec-declared columns appear: expect(list.content).toMatch(/key:\s*'code'/) expect(list.content).toMatch(/key:\s*'label'/) expect(list.content).toMatch(/key:\s*'initialAmount'/) // The legacy `description` column does NOT appear (would surface as // `LIST.COLUMNS.DESCRIPTION` in the rendered page header). expect(list.content).not.toMatch(/key:\s*'description'/) // labelKey from pageSpec is honoured (path verbatim, leaf normalised to // camelCase by normalizeI18nKey — symmetric with the JSON catalogue which // goes through normalizeI18nFieldKeys). camelCase leaves are idempotent // so this test still passes verbatim. expect(list.content).toMatch(/t\('budget\.list\.columns\.code'\)/) expect(list.content).toMatch(/t\('budget\.list\.columns\.initialAmount'\)/) }) it('renders a FilterBar when pageSpec.filters[] is non-empty', () => { const ps = pageSpecFixture({ columns: [{ key: 'label', labelKey: 'list.columns.label', sortable: true }], // Add filters: text + select + date-range — each produces a different control. filters: [ { field: 'label', labelKey: 'list.filters.label', control: 'text' }, { field: 'status', labelKey: 'list.filters.status', control: 'select', options: ['active', 'archived'], }, { field: 'startDate', labelKey: 'list.filters.startDate', control: 'date-range' }, ], } as Parameters[0]) const files = generate(fixture({ pageSpec: ps, views: ['list'] })) const list = files.find((f) => /ListPage\.tsx$/.test(f.path))! // The component imports useState + useMemo (filter state + chips) and // useEffect (search/filter debounces — the list is always server-driven). expect(list.content).toMatch(/import\s*\{\s*useMemo,\s*useState,\s*useEffect\s*\}\s*from\s*'react'/) // The FilterBar primitive hosts the toolbar (search + filters + chips). expect(list.content).toMatch(/import \{ FilterBar \} from '@\/components\/ui\/FilterBar'/) expect(list.content).toMatch(/) with its // option set + "all" default, and imports the primitive on the list page: expect(list.content).toMatch(/): expect(list.content).toMatch(/ setSearch\(event\.target\.value\)\}/) expect(list.content).toMatch(/placeholder=\{t\('\w+\.list\.search'\)\}/) // Search/X icons are hoisted into the base lucide import (no duplicate identifier). expect(list.content).toMatch(/import \{ Plus, Pencil, Trash2, List, Search, X/) // The table runs in controlled-search mode — no built-in `searchable` input. expect(list.content).toMatch(/searchTerm=\{search\}/) expect(list.content).toMatch(/onSearchChange=\{setSearch\}/) expect(list.content).not.toMatch(/\bsearchable\b/) }) it('drives the server (pagination + search + sort) when pageSpec.filters is empty', () => { const ps = pageSpecFixture({ filters: [] } as Parameters[0]) const files = generate(fixture({ pageSpec: ps, views: ['list'] })) const list = files.find((f) => /ListPage\.tsx$/.test(f.path))! // Server state lives on the page (page / size / search / sort) — the debounce // needs useEffect. expect(list.content).toMatch(/const \[page, setPage\] = useListNumberParam\('page', 1\)/) expect(list.content).toMatch(/const \[search, setSearch\] = useListParam\('q', ''\)/) expect(list.content).toMatch(/const \[sortBy, setSortBy\]/) expect(list.content).toMatch(/import\s*\{[^}]*\buseEffect\b[^}]*\}\s*from\s*'react'/) // The list hook is called WITH server params (not the bare, client-only form). expect(list.content).toMatch(/use\w+\(\{ page, pageSize, search: debouncedSearch \|\| undefined, sortBy, sortDir \}\)/) // The table is server-driven: no local filter / sort / slice. expect(list.content).toMatch(/serverMode/) expect(list.content).toMatch(/page=\{page\}/) expect(list.content).toMatch(/totalCount=\{totalCount\}/) expect(list.content).toMatch(/onPageChange=\{setPage\}/) expect(list.content).toMatch(/onSortChange=\{handleSort\}/) expect(list.content).toMatch(/const totalCount = data\?\.totalCount \?\? 0/) // Controlled search box in a toolbar — NOT the DataTable's built-in `searchable`. expect(list.content).toMatch(/searchTerm=\{search\}/) expect(list.content).toMatch(/onSearchChange=\{setSearch\}/) expect(list.content).not.toMatch(/\bsearchable\b/) // The search box lives in the FilterBar toolbar even without filters. expect(list.content).toMatch(/ { // The PRD sometimes emits PascalCase leaves (`list.columns.Code`, // `list.columns.EstActif`). The JSON catalogue normalises them to // camelCase via normalizeI18nFieldKeys (line ~1400). Without the // symmetric normalisation on the TSX side, t('…Code') resolves nothing // and the screen shows the raw key. This is THE reason every table // column header rendered the literal key on REFERENTIELS. const ps = pageSpecFixture({ columns: [ { key: 'code', labelKey: 'list.columns.Code', sortable: true }, { key: 'estActif', labelKey: 'list.columns.EstActif', sortable: true }, ], }) const files = generate(fixture({ pageSpec: ps, views: ['list'] })) const list = files.find((f) => /ListPage\.tsx$/.test(f.path))! // The leaf is camelCased in the t() call: expect(list.content).toMatch(/t\('contact\.list\.columns\.code'\)/) expect(list.content).toMatch(/t\('contact\.list\.columns\.estActif'\)/) // Negative — the PascalCase leaf must NOT leak into the TSX: expect(list.content).not.toMatch(/t\('contact\.list\.columns\.Code'\)/) expect(list.content).not.toMatch(/t\('contact\.list\.columns\.EstActif'\)/) }) it('Fix #9 — normalises pagespec filter labelKey leaf across every control type', () => { // Same normalisation applied at the source — one pass on pageSpecFilters // covers text, select, date-range and boolean labels + placeholders // (6 substitution sites in renderFilterInput) without touching them. const ps = pageSpecFixture({ filters: [ { field: 'code', labelKey: 'list.filters.Code', control: 'text' }, { field: 'status', labelKey: 'list.filters.Status', control: 'select', options: ['a'] }, { field: 'startDate', labelKey: 'list.filters.StartDate', control: 'date-range' }, { field: 'estActif', labelKey: 'list.filters.EstActif', control: 'boolean' }, ], } as Parameters[0]) const files = generate(fixture({ pageSpec: ps, views: ['list'] })) const list = files.find((f) => /ListPage\.tsx$/.test(f.path))! // All four control types resolve the camelCased leaf: expect(list.content).toMatch(/t\('contact\.list\.filters\.code'\)/) expect(list.content).toMatch(/t\('contact\.list\.filters\.status'\)/) expect(list.content).toMatch(/t\('contact\.list\.filters\.startDate'\)/) expect(list.content).toMatch(/t\('contact\.list\.filters\.estActif'\)/) // Negative — no PascalCase leaves leak into any of the 6 render sites: expect(list.content).not.toMatch(/t\('contact\.list\.filters\.Code'\)/) expect(list.content).not.toMatch(/t\('contact\.list\.filters\.Status'\)/) expect(list.content).not.toMatch(/t\('contact\.list\.filters\.StartDate'\)/) expect(list.content).not.toMatch(/t\('contact\.list\.filters\.EstActif'\)/) }) it('Fix #9 — does NOT mangle action labelKey (parent not in I18N_FIELD_PARENTS)', () => { // Action labels live under `list.` / `form.actions.` — // their parent is NOT one of {fields, columns, filters, help}, so // normalizeI18nFieldKeys leaves them alone in the JSON. The TSX must // mirror that and keep them verbatim too, otherwise we would break // kebab-case action codes like `toggle-actif`. const ps = pageSpecFixture({ actions: [ { code: 'create', scope: 'header', labelKey: 'list.create', permission: 'crm.directory.create', variant: 'primary' }, { code: 'toggle-actif', scope: 'row', labelKey: 'list.toggle-actif', permission: 'crm.directory.update', variant: 'secondary' }, ], }) const files = generate(fixture({ pageSpec: ps, views: ['list'] })) const list = files.find((f) => /ListPage\.tsx$/.test(f.path))! // Action labels stay verbatim (2-seg path: parts.length < 3 → skip), // and the kebab-case `toggle-actif` is preserved exactly: expect(list.content).toMatch(/t\('contact\.list\.create'\)/) expect(list.content).toMatch(/t\('contact\.list\.toggle-actif'\)/) }) it('collapses row-scope custom actions into the "…" overflow menu (RowActionsMenu)', () => { const ps = pageSpecFixture({ actions: [ { code: 'create', scope: 'header', labelKey: 'list.create', permission: 'crm.directory.create', variant: 'primary' }, { code: 'archive', scope: 'row', labelKey: 'list.archive', permission: 'crm.directory.update', variant: 'secondary' }, { code: 'duplicate', scope: 'row', labelKey: 'list.duplicate', permission: 'crm.directory.create', variant: 'secondary' }, ], }) const files = generate(fixture({ pageSpec: ps, views: ['list'] })) const list = files.find((f) => /ListPage\.tsx$/.test(f.path))! // Hooks for archive + duplicate imported alongside CRUD hooks expect(list.content).toMatch(/import\s*\{\s*useContacts,\s*useDeleteContact, useArchiveContact, useDuplicateContact\s*\}/) // Mutation declarations expect(list.content).toMatch(/const archiveMutation = useArchiveContact\(\)/) expect(list.content).toMatch(/const duplicateMutation = useDuplicateContact\(\)/) // Handler functions wired to mutations expect(list.content).toMatch(/const handleArchive = async \(item: ContactListDto\) => \{[\s\S]*?archiveMutation\.mutateAsync\(item\.id\)/) expect(list.content).toMatch(/const handleDuplicate = async \(item: ContactListDto\) => \{[\s\S]*?duplicateMutation\.mutateAsync\(item\.id\)/) // Icon imports include Archive + Copy (from the lucide map) expect(list.content).toMatch(/Archive,/) expect(list.content).toMatch(/Copy/) // Row custom actions render inside a single overflow menu, not an inline strip. expect(list.content).toMatch(/import \{ RowActionsMenu \} from '@\/components\/ui\/RowActionsMenu'/) expect(list.content).toMatch(/, permission: 'crm\.directory\.update', onClick: \(\) => \{ void handleArchive\(item\) \} \}/) expect(list.content).toMatch(/\{ key: 'duplicate', label: t\('contact\.list\.duplicate'\), icon: , permission: 'crm\.directory\.create', onClick: \(\) => \{ void handleDuplicate\(item\) \} \}/) // The old per-row icon-button strip (title tooltip) is gone for custom actions. expect(list.content).not.toMatch(/title=\{t\('contact\.list\.archive'\)\}/) }) it('renders header-scope custom actions next to the Create button', () => { const ps = pageSpecFixture({ actions: [ { code: 'create', scope: 'header', labelKey: 'list.create', permission: 'crm.directory.create', variant: 'primary' }, { code: 'export', scope: 'header', labelKey: 'list.export', permission: 'crm.directory.read', variant: 'secondary' }, ], }) const files = generate(fixture({ pageSpec: ps, views: ['list'] })) const list = files.find((f) => /ListPage\.tsx$/.test(f.path))! // The header actions wrapper WRAPS: without flex-wrap the buttons cannot stack // when the row runs out of room, and their labels break in two instead. expect(list.content).toMatch(/actions=\{[\s\S]*?
/) // Export button uses the secondary variant style + Download icon expect(list.content).toMatch(/\{t\('contact\.list\.export'\)\}/) expect(list.content).toMatch(/Download/) // Header action handler does NOT take an item parameter expect(list.content).toMatch(/const handleExport = async \(\) => \{[\s\S]*?exportMutation\.mutateAsync\(\)/) }) it('Priority+ header cluster: ≤ 2 promoted buttons at lg+, every action rides the HeaderActionsMenu', () => { const ps = pageSpecFixture({ actions: [ { code: 'create', scope: 'header', labelKey: 'list.create', permission: 'crm.directory.create', variant: 'primary' }, { code: 'export', scope: 'header', labelKey: 'list.export', permission: 'crm.directory.read', variant: 'secondary' }, { code: 'archive', scope: 'header', labelKey: 'list.archive', permission: 'crm.directory.update', variant: 'secondary' }, { code: 'duplicate', scope: 'header', labelKey: 'list.duplicate', permission: 'crm.directory.create', variant: 'secondary' }, ], }) const files = generate(fixture({ pageSpec: ps, views: ['list'] })) const list = files.find((f) => /ListPage\.tsx$/.test(f.path))! // The menu primitive is imported once header actions exist. expect(list.content).toMatch(/import \{ HeaderActionsMenu \} from '@\/components\/ui\/HeaderActionsMenu'/) // First two authored actions stay visible buttons, hidden below lg. expect(list.content).toMatch(/className="btn btn-secondary hidden lg:inline-flex"[\s\S]{0,200}\{t\('contact\.list\.export'\)\}/) expect(list.content).toMatch(/className="btn btn-secondary hidden lg:inline-flex"[\s\S]{0,200}\{t\('contact\.list\.archive'\)\}/) // The third action never gets a visible button — menu item only (the braced // `{t('…')}` form only exists as button JSX children; the menu carries the // unbraced `label: t('…')`). expect(list.content).not.toMatch(/\{t\('contact\.list\.duplicate'\)\}/) // Every action rides the menu; promoted ones flagged so the menu drops them at lg+. expect(list.content).toMatch(/\{ key: 'export',[\s\S]*?promoted: true,[\s\S]*?onClick: \(\) => \{ void handleExport\(\) \} \},/) expect(list.content).toMatch(/\{ key: 'archive',[\s\S]*?promoted: true,[\s\S]*?onClick: \(\) => \{ void handleArchive\(\) \} \},/) expect(list.content).toMatch(/\{ key: 'duplicate', label: t\('contact\.list\.duplicate'\), icon: , permission: 'crm\.directory\.create', onClick: \(\) => \{ void handleDuplicate\(\) \} \},/) }) it('Priority+ header cluster: primary-variant actions claim the visible budget first', () => { const ps = pageSpecFixture({ actions: [ { code: 'export', scope: 'header', labelKey: 'list.export', permission: 'crm.directory.read', variant: 'secondary' }, { code: 'archive', scope: 'header', labelKey: 'list.archive', permission: 'crm.directory.update', variant: 'secondary' }, { code: 'launchCampaign', scope: 'header', labelKey: 'list.launchCampaign', permission: 'crm.directory.execute', variant: 'primary' }, ], }) const files = generate(fixture({ pageSpec: ps, views: ['list'] })) const list = files.find((f) => /ListPage\.tsx$/.test(f.path))! // The primary action is promoted (visible button, btn-primary) even though // it was authored last; the stable sort keeps export as the second slot and // overflows archive into the menu. expect(list.content).toMatch(/className="btn btn-primary hidden lg:inline-flex"[\s\S]{0,200}\{t\('contact\.list\.launchCampaign'\)\}/) expect(list.content).toMatch(/\{ key: 'launchCampaign',[\s\S]*?promoted: true/) expect(list.content).toMatch(/\{ key: 'export',[\s\S]*?promoted: true/) expect(list.content).not.toMatch(/\{ key: 'archive',[\s\S]{0,200}promoted: true/) expect(list.content).not.toMatch(/\{t\('contact\.list\.archive'\)\}/) }) it('emits no HeaderActionsMenu when the page has no header custom action', () => { const files = generate(fixture({ pageSpec: pageSpecFixture(), views: ['list'] })) const list = files.find((f) => /ListPage\.tsx$/.test(f.path))! expect(list.content).not.toMatch(/HeaderActionsMenu/) }) it('detail Priority+ cluster: promoted anchors on the button, overflowed anchors on the menu item (DEV-UI-032)', () => { const ps = pageSpecFixture({ view: 'detail', filePath: 'src/pages/testv2/crm/directory/ContactDetailPage.tsx', actions: [ { code: 'duplicate', scope: 'header', labelKey: 'detail.duplicate', permission: 'crm.directory.create', variant: 'secondary' }, { code: 'archive', scope: 'header', labelKey: 'detail.archive', permission: 'crm.directory.update', variant: 'secondary' }, { code: 'export', scope: 'header', labelKey: 'detail.export', permission: 'crm.directory.read', variant: 'secondary' }, ], }) const files = generate(fixture({ pageSpec: ps, views: ['detail'], entityViews: ['list', 'detail', 'form'] })) const detail = files.find((f) => /DetailPage\.tsx$/.test(f.path))! expect(detail.content).toMatch(/import \{ HeaderActionsMenu \} from '@\/components\/ui\/HeaderActionsMenu'/) // Promoted (visible budget): the data-testid rides the button. expect(detail.content).toMatch(/data-testid="detail-action-duplicate"/) expect(detail.content).toMatch(/data-testid="detail-action-archive"/) // Overflowed: the anchor rides the menu item's testId prop — never a second // DOM node with the same data-testid. expect(detail.content).not.toMatch(/data-testid="detail-action-export"/) expect(detail.content).toMatch(/\{ key: 'export',[\s\S]*?testId: 'detail-action-export', onClick: \(\) => \{ void handleExport\(\) \} \},/) // Edit + Delete keep their historical anchors untouched. expect(detail.content).toMatch(/data-testid="detail-action-edit"/) expect(detail.content).toMatch(/data-testid="detail-action-delete"/) }) it('renders bulk-scope actions in a selection toolbar wired to DataTable selection', () => { const ps = pageSpecFixture({ actions: [ { code: 'create', scope: 'header', labelKey: 'list.create', permission: 'crm.directory.create', variant: 'primary' }, { code: 'bulkArchive', scope: 'bulk', labelKey: 'list.bulkArchive', permission: 'crm.directory.update', payloadDto: 'BulkArchiveRequest', payloadParameters: [{ name: 'reason', type: 'text', required: true }], }, { code: 'bulkDelete', scope: 'bulk', labelKey: 'list.bulkDelete', permission: 'crm.directory.delete' }, ], }) const files = generate(fixture({ pageSpec: ps, views: ['list'] })) const list = files.find((f) => /ListPage\.tsx$/.test(f.path))! // Controlled DataTable selection: state + selectable + selectedKeys + onSelectionChange expect(list.content).toMatch(/const \[selectedKeys, setSelectedKeys\] = useState>\(new Set\(\)\)/) expect(list.content).toMatch(/selectable/) expect(list.content).toMatch(/selectedKeys=\{selectedKeys\}/) expect(list.content).toMatch(/onSelectionChange=\{setSelectedKeys\}/) // Selection toolbar rendered only when rows are checked expect(list.content).toMatch(/selectedKeys\.size > 0 &&/) // Bulk WITH payload → opens a dialog, fires mutateAsync({ ids, payload }) expect(list.content).toMatch(/const \[bulkArchiveDialogOpen, setBulkArchiveDialogOpen\] = useState\(false\)/) expect(list.content).toMatch(/const handleBulkArchive = \(\) => \{ setBulkArchiveDialogOpen\(true\) \}/) expect(list.content).toMatch(/bulkArchiveMutation\.mutateAsync\(\{ ids: \[\.\.\.selectedKeys\], payload: payload as never \}\)/) expect(list.content).toMatch(/import \{ CustomActionDialog \} from '@\/components\/ui\/CustomActionDialog'/) // Bulk WITHOUT payload → fires against the selection directly then clears it expect(list.content).toMatch(/const handleBulkDelete = async \(\) => \{[\s\S]*?bulkDeleteMutation\.mutateAsync\(\[\.\.\.selectedKeys\]\)[\s\S]*?setSelectedKeys\(new Set\(\)\)/) }) it('renders a CustomActionDialog for a payload header action on detail AND form pages', () => { const ps = pageSpecFixture({ actions: [ { code: 'generateReport', scope: 'header', labelKey: 'detail.generateReport', permission: 'crm.directory.execute', payloadDto: 'GenerateReportRequest', payloadParameters: [{ name: 'year', type: 'number', required: true }], }, ], }) const files = generate(fixture({ pageSpec: ps, views: ['detail', 'form'] })) for (const suffix of ['DetailPage.tsx', 'FormPage.tsx']) { const page = files.find((f) => f.path.endsWith(suffix))! expect(page.content, suffix).toMatch(/import \{ CustomActionDialog \} from '@\/components\/ui\/CustomActionDialog'/) expect(page.content, suffix).toMatch(/const \[generateReportDialogOpen, setGenerateReportDialogOpen\] = useState\(false\)/) expect(page.content, suffix).toMatch(/const handleGenerateReport = \(\) => \{ setGenerateReportDialogOpen\(true\) \}/) expect(page.content, suffix).toMatch(/generateReportMutation\.mutateAsync\(payload as never\)/) // A payload action must NOT fall back to the old mutateAsync(id) shape. expect(page.content, suffix).not.toMatch(/generateReportMutation\.mutateAsync\(id\)/) } }) it('skips standard CRUD codes from custom actions iteration (no double-emit)', () => { const ps = pageSpecFixture({ actions: [ // 'edit' and 'delete' are standard CRUD — already hardcoded; skip { code: 'edit', scope: 'row', labelKey: 'list.edit', permission: 'crm.directory.update', variant: 'secondary' }, { code: 'delete', scope: 'row', labelKey: 'list.delete', permission: 'crm.directory.delete', variant: 'danger' }, // 'archive' is non-CRUD → renders { code: 'archive', scope: 'row', labelKey: 'list.archive', permission: 'crm.directory.update', variant: 'secondary' }, ], }) const files = generate(fixture({ pageSpec: ps, views: ['list'] })) const list = files.find((f) => /ListPage\.tsx$/.test(f.path))! // No useEditContact import (CRUD codes are skipped from custom iteration) expect(list.content).not.toMatch(/useEditContact/) // archive IS imported as a custom action hook expect(list.content).toMatch(/useArchiveContact/) // useDeleteContact is the standard hardcoded import (appears in both // import statement AND in `useDelete${e}()` call — 2 occurrences total). // Custom-action iteration must NOT add a third reference. expect(list.content.match(/useDeleteContact/g)?.length).toBe(2) }) it('flags a danger-variant row action with danger:true in the overflow menu', () => { const ps = pageSpecFixture({ actions: [ { code: 'reject', scope: 'row', labelKey: 'list.reject', permission: 'crm.directory.update', variant: 'danger' }, ], }) const files = generate(fixture({ pageSpec: ps, views: ['list'] })) const list = files.find((f) => /ListPage\.tsx$/.test(f.path))! // The danger variant is carried on the menu item (RowActionsMenu styles it with // the --error-* tokens); no more inline per-button className. expect(list.content).toMatch(/\{ key: 'reject',[\s\S]*?onClick: \(\) => \{ void handleReject\(item\) \}, danger: true \}/) }) it('emits NO custom action artefacts when pageSpec.actions is empty (legacy)', () => { // The fixture pageSpec has no actions[] beyond a default 'create' header // (which is a standard CRUD code skipped from custom iteration). const files = generate(fixture({ pageSpec: pageSpecFixture(), views: ['list'] })) const list = files.find((f) => /ListPage\.tsx$/.test(f.path))! // No extra hook imports beyond the standard CRUD ones expect(list.content).not.toMatch(/useArchive|useDuplicate|useApprove|useReject/) // No mutation declarations beyond `deleteMutation` expect(list.content).not.toMatch(/Mutation = use(?!Delete|Contacts)/) // No overflow menu when there are no custom row actions — the column is just Edit/Delete. expect(list.content).not.toMatch(/RowActionsMenu/) }) }) describe('scaffold-component / generate — list page custom actions kind:navigate', () => { it('emits a navigate(targetRoute) handler for a kind:navigate row action, no hook', () => { const ps = pageSpecFixture({ actions: [ // A kind:navigate row action must emit a navigate() handler (no hook, // no POST → no phantom 405). Target a NON-detail page so it survives the // row-nav dedup: a navigate-to-detail row action is dropped because // onRowClick already opens the detail. { code: 'history', kind: 'navigate', scope: 'row', labelKey: 'list.history', permission: 'crm.directory.read', targetRoute: 'routes.directory.history(item.id)', }, ], }) const files = generate(fixture({ pageSpec: ps, views: ['list'] })) const list = files.find((f) => /ListPage\.tsx$/.test(f.path))! // NO hook import for the navigate action expect(list.content).not.toMatch(/useHistoryContact/) // NO mutation declaration expect(list.content).not.toMatch(/historyMutation/) // The handler navigates via React Router — synchronous, no mutateAsync expect(list.content).toMatch(/const handleHistory = \(item: ContactListDto\) => \{[\s\S]*?navigate\(routes\.directory\.history\(item\.id\)\)/) // The button still renders (PermissionGuard + onClick → handleHistory) expect(list.content).toMatch(/handleHistory\(item\)/) }) it('emits a navigate(targetRoute) handler for a kind:navigate header action', () => { const ps = pageSpecFixture({ actions: [ { code: 'openHistory', kind: 'navigate', scope: 'header', labelKey: 'list.openHistory', permission: 'crm.directory.read', targetRoute: 'routes.directory.history()', }, ], }) const files = generate(fixture({ pageSpec: ps, views: ['list'] })) const list = files.find((f) => /ListPage\.tsx$/.test(f.path))! // Synchronous handler — no `async`, no `await` expect(list.content).toMatch(/const handleOpenHistory = \(\) => \{[\s\S]*?navigate\(routes\.directory\.history\(\)\)/) expect(list.content).not.toMatch(/useOpenHistoryContact/) expect(list.content).not.toMatch(/openHistoryMutation/) }) it('mixes kind:api and kind:navigate actions on the same page', () => { const ps = pageSpecFixture({ actions: [ // CRUD standard — skipped from custom iteration { code: 'create', scope: 'header', labelKey: 'list.create', permission: 'crm.directory.create', variant: 'primary' }, // api-bound custom — needs hook + mutation { code: 'archive', kind: 'api', scope: 'row', labelKey: 'list.archive', permission: 'crm.directory.update', variant: 'secondary' }, // navigate-bound custom — needs router only; non-detail target so it // survives the row-nav dedup (navigate-to-detail rows are dropped). { code: 'history', kind: 'navigate', scope: 'row', labelKey: 'list.history', permission: 'crm.directory.read', targetRoute: 'routes.directory.history(item.id)' }, ], }) const files = generate(fixture({ pageSpec: ps, views: ['list'] })) const list = files.find((f) => /ListPage\.tsx$/.test(f.path))! // api action: hook + mutation expect(list.content).toMatch(/useArchiveContact/) expect(list.content).toMatch(/archiveMutation\.mutateAsync\(item\.id\)/) // navigate action: NO hook, NO mutation, just navigate() expect(list.content).not.toMatch(/useHistoryContact/) expect(list.content).not.toMatch(/historyMutation/) expect(list.content).toMatch(/handleHistory = \(item: ContactListDto\) => \{[\s\S]*?navigate\(routes\.directory\.history\(item\.id\)\)/) // Both buttons reach the actions column expect(list.content).toMatch(/handleArchive\(item\)/) expect(list.content).toMatch(/handleHistory\(item\)/) }) it('hook name follows endpoint when code != endpoint (override pattern)', () => { // The BA wants the UI label "Sync from PCE" but the backend route is the // legacy `sync-from-proconcept`. scaffold-api-client generates the hook // named after the endpoint so the request URL and the React Query key // stay aligned. scaffold-component MUST import the same name. const ps = pageSpecFixture({ actions: [ { code: 'syncFromPce', kind: 'api', scope: 'header', labelKey: 'list.syncFromPce', permission: 'crm.directory.update', endpoint: 'sync-from-proconcept', }, ], }) const files = generate(fixture({ pageSpec: ps, views: ['list'] })) const list = files.find((f) => /ListPage\.tsx$/.test(f.path))! // Hook name derived from endpoint (the URL is the source of truth) expect(list.content).toMatch(/useSyncFromProconceptContact/) expect(list.content).toMatch(/syncFromProconceptMutation = useSyncFromProconceptContact\(\)/) expect(list.content).toMatch(/handleSyncFromPce = async \(\) => \{[\s\S]*?syncFromProconceptMutation\.mutateAsync\(\)/) // The button label still uses the BA labelKey (camelCase) — UI label stays // independent of the URL segment. expect(list.content).toMatch(/\{t\('contact\.list\.syncFromPce'\)\}/) }) }) describe('scaffold-component / generate — FK fields render as ', () => { it('emits for a same-module FK (no dead per-target hook import)', () => { const files = generate(fixture({ entity: 'Employee', module: 'hr', section: 'employees', views: ['form'], entityViews: ['list', 'form'], fields: [ { name: 'firstName', type: 'string', required: true }, { name: 'departmentId', type: 'guid', required: true, fkTo: { entity: 'Department', module: 'hr' }, }, ], })) const form = files.find((f) => /FormPage\.tsx$/.test(f.path))! // Import surface — EntityLookup for the EDIT control, plus the per-target // use{Entity}Lookup hook the READ-FIRST grid consumes to resolve the FK's // displayName (never the raw Guid). The hook import must be USED, not dead: // its LookupData is read by the section's read grid. expect(form.content).toMatch(/import \{ EntityLookup \} from '@\/components\/ui\/EntityLookup'/) expect(form.content).toMatch(/useDepartmentLookup/) expect(form.content).toMatch(/departmentIdLookupData\?\.items/) // FK field is rendered as , not . The default lookup URL is // the target's NavRoute-resolved route /api/{module}/{section}/lookup (here the // fallback {module}.{plural} = hr.departments). The dead /api/v1/integration // literal is never emitted. expect(form.content).toMatch( / expect(form.content).toMatch(/id="firstName"[\s\S]*?type="text"/) // FK field never gets a plain with name/id ending in "Id" expect(form.content).not.toMatch(/]*id="departmentId"/) }) it('emits the required attribute on the EntityLookup when the FK is required', () => { const files = generate(fixture({ entity: 'Employee', module: 'hr', section: 'employees', views: ['form'], entityViews: ['list', 'form'], fields: [ { name: 'mgrId', type: 'guid', required: true, fkTo: { entity: 'Manager', module: 'hr' } }, ], })) const form = files.find((f) => /FormPage\.tsx$/.test(f.path))! // The `required` attribute is present (no value — JSX boolean attribute shorthand) expect(form.content).toMatch(/ { const files = generate(fixture({ entity: 'Client', module: 'crm', section: 'clients', views: ['form'], entityViews: ['list', 'form'], fields: [ { name: 'name', type: 'string', required: true }, { name: 'organisationId', type: 'guid', required: false, fkTo: { entity: 'TenantOrganisation', module: 'core' } }, ], })) const form = files.find((f) => /FormPage\.tsx$/.test(f.path))! // The org picker reads the combined references endpoint — there is no // /api/core/tenant-organisations/lookup (ref_Companies + its /lookup were dropped). expect(form.content).toContain('apiEndpoint="/api/administration/users/organization-references"') expect(form.content).not.toMatch(/\/api\/core\/tenant-organisations\/lookup/) // …and adapts the combined DTO to the {id,displayName} shape via selectItems. expect(form.content).toContain('selectItems={(r) =>') expect(form.content).toContain('.companies') }) it('a list column for a non-standard Core FK (TenantOrganisation) emits NO dangling useLookup import', () => { const files = generate(fixture({ entity: 'Client', module: 'crm', section: 'clients', views: ['list'], fields: [ { name: 'name', type: 'string', required: true }, { name: 'organisationId', type: 'guid', required: false, fkTo: { entity: 'TenantOrganisation', module: 'core' } }, ], })) const list = files.find((f) => /ListPage\.tsx$/.test(f.path))! // No useLookup hook exists for a Core target → the list must neither import nor call it. expect(list.content).not.toMatch(/useTenantOrganisationLookup/) }) it('omits the EntityLookup import when no FK is declared (legacy form unchanged)', () => { const files = generate(fixture()) // no fkTo in fixture fields const form = files.find((f) => /FormPage\.tsx$/.test(f.path))! expect(form.content).not.toMatch(/EntityLookup/) expect(form.content).not.toMatch(/use[A-Z]\w*Lookup/) }) it('honours a custom apiEndpoint override (cross-module / Core)', () => { const files = generate(fixture({ entity: 'Demand', module: 'requests', section: 'demands', views: ['form'], entityViews: ['list', 'form'], fields: [ { name: 'requesterUserId', type: 'guid', required: true, currentUserFk: true, fkTo: { entity: 'User', module: 'core', apiEndpoint: '/api/core/users/lookup' }, }, ], })) const form = files.find((f) => /FormPage\.tsx$/.test(f.path))! expect(form.content).toMatch(/apiEndpoint="\/api\/core\/users\/lookup"/) // A FK flagged currentUserFk gets a one-click "Me" button bound to useAuth; // the read-first grid additionally resolves the label via the per-target // lookup hook (consumed, not dead). expect(form.content).toMatch(/useUserLookup/) expect(form.content).toMatch(/requesterUserIdLookupData\?\.items/) expect(form.content).toMatch(/import \{ useAuth \} from '@\/business\/auth\/useAuth'/) expect(form.content).toMatch(/user\?\.id \?\? ''/) expect(form.content).toMatch(/t\('demand\.form\.me'/) }) it('imports useAuth exactly once even with multiple user FKs (no per-target hook)', () => { const files = generate(fixture({ entity: 'Assignment', module: 'hr', section: 'assignments', views: ['form'], entityViews: ['list', 'form'], fields: [ { name: 'fromEmployeeId', type: 'guid', required: true, currentUserFk: true, fkTo: { entity: 'Employee', module: 'hr' } }, { name: 'toEmployeeId', type: 'guid', required: true, currentUserFk: true, fkTo: { entity: 'Employee', module: 'hr' } }, ], })) const form = files.find((f) => /FormPage\.tsx$/.test(f.path))! // Both FKs are flagged currentUserFk → the "Me" shortcut needs useAuth, imported ONCE. const matches = form.content.match(/import \{ useAuth \}/g) ?? [] expect(matches.length).toBe(1) // Two FKs to the SAME target: the read-grid lookup hook imports dedupe by // entity (one import line) while each field keeps its own LookupData. const lookupImports = form.content.match(/import \{ useEmployeeLookup \}/g) ?? [] expect(lookupImports.length).toBe(1) expect(form.content).toMatch(/fromEmployeeIdLookupData\?\.items/) expect(form.content).toMatch(/toEmployeeIdLookupData\?\.items/) // Both FK fields are rendered. expect(form.content).toMatch(/label=\{t\('assignment\.form\.fields\.fromEmployeeId'\)\}/) expect(form.content).toMatch(/label=\{t\('assignment\.form\.fields\.toEmployeeId'\)\}/) }) it('a FK lookup uses fkTo.navRoute when set (the target\'s NavRoute-resolved route)', () => { const files = generate(fixture({ entity: 'Order', module: 'sales', section: 'orders', views: ['form'], entityViews: ['list', 'form'], fields: [{ name: 'clientId', type: 'guid', required: true, fkTo: { entity: 'Client', module: 'crm', navRoute: 'repertoire.clients' } }], })) const form = files.find((f) => /FormPage\.tsx$/.test(f.path))! // navRoute wins over the heuristic fallback → /api/{targetModule}/{targetSection}/lookup. expect(form.content).toContain('apiEndpoint="/api/repertoire/clients/lookup"') expect(form.content).not.toMatch(/apiEndpoint="\/api\/v1\/integration\//) }) it('default FK lookup URL falls back to the target module\'s NavRoute path /api/{module}/{plural}/lookup', () => { const files = generate(fixture({ entity: 'Employee', module: 'hr', section: 'employees', views: ['form'], entityViews: ['list', 'form'], fields: [{ name: 'departmentId', type: 'guid', required: true, fkTo: { entity: 'Department', module: 'hr' } }], })) const form = files.find((f) => /FormPage\.tsx$/.test(f.path))! // No navRoute/apiEndpoint on fkTo → best-effort fallback `{module}.{plural}` via // buildNavApiPath. When the section == the entity plural (common), it matches the // real route; audit-dev-wire flags it otherwise. const expected = `${buildNavApiPath('hr.departments')}/lookup` expect(expected).toBe('/api/hr/departments/lookup') expect(form.content).toContain(`apiEndpoint="${expected}"`) // The dead /api/v1/integration/... literal (the platform rewrote it away) must never appear. expect(form.content).not.toMatch(/apiEndpoint="\/api\/v1\/integration\//) }) it('a cross-module FK resolves to the TARGET module\'s NavRoute path', () => { const files = generate(fixture({ entity: 'Order', module: 'sales', section: 'orders', views: ['form'], entityViews: ['list', 'form'], fields: [{ name: 'productId', type: 'guid', required: true, fkTo: { entity: 'Product', module: 'catalog' } }], })) const form = files.find((f) => /FormPage\.tsx$/.test(f.path))! // The lookup lives on the TARGET's controller → its module is in the URL. expect(form.content).toContain('apiEndpoint="/api/catalog/products/lookup"') expect(form.content).not.toMatch(/apiEndpoint="\/api\/v1\/integration\//) }) it('a Core FK without an explicit apiEndpoint falls back to /api/core/{plural}/lookup', () => { const files = generate(fixture({ entity: 'Demand', module: 'requests', section: 'demands', views: ['form'], entityViews: ['list', 'form'], fields: [{ name: 'ownerUserId', type: 'guid', required: true, fkTo: { entity: 'User', module: 'core' } }], })) const form = files.find((f) => /FormPage\.tsx$/.test(f.path))! expect(form.content).toContain('apiEndpoint="/api/core/users/lookup"') }) it('uses fkTo.plural for irregular plurals; otherwise pluralises heuristically (Category → categories)', () => { const heuristic = generate(fixture({ entity: 'Task', module: 'todo', section: 'tasks', views: ['form'], entityViews: ['list', 'form'], fields: [{ name: 'categoryId', type: 'guid', required: true, fkTo: { entity: 'Category', module: 'todo' } }], })).find((f) => /FormPage\.tsx$/.test(f.path))! // Category → Categories → categories (NOT the naive `categorys`). expect(heuristic.content).toContain('apiEndpoint="/api/todo/categories/lookup"') const explicit = generate(fixture({ entity: 'Job', module: 'ops', section: 'jobs', views: ['form'], entityViews: ['list', 'form'], fields: [{ name: 'statusId', type: 'guid', required: true, fkTo: { entity: 'Status', module: 'ops', plural: 'Statuses' } }], })).find((f) => /FormPage\.tsx$/.test(f.path))! expect(explicit.content).toContain('apiEndpoint="/api/ops/statuses/lookup"') }) it('never emits a raw or ) + import it', () => { const page = formPage([{ name: 'birthDate', type: 'date', required: true }]) expect(page.content).toMatch(/import \{ DateInput \} from '@\/components\/ui\/DateInput'/) expect(page.content).toMatch(/ { const seg = (content: string) => content.split(' with i18n option labels', () => { const page = formPage([ { name: 'status', type: 'enum', required: true, options: [ { value: 'open', label: 'Open' }, { value: 'closed', label: 'Closed' }, ] }, ]) expect(page.content).toMatch(/import \{ SegmentedControl \} from '@\/components\/ui\/SegmentedControl'/) expect(page.content).toMatch(/. SIBLING branch: nested under // the form.fields. label leaf they could never ship (leaf wins). expect(page.content).toMatch(/value: 'open', label: t\('contact\.form\.options\.status\.open', \{ defaultValue: 'Open' \}\)/) expect(page.content).toMatch(/value: 'closed', label: t\('contact\.form\.options\.status\.closed', \{ defaultValue: 'Closed' \}\)/) // Routed away from the plain text input path. expect(page.content).not.toMatch(/type="text"/) }) it('enum fields with >4 options render (dropdown) with i18n option labels', () => { const page = formPage([ { name: 'priority', type: 'enum', required: true, options: [ { value: 'p1', label: 'P1' }, { value: 'p2', label: 'P2' }, { value: 'p3', label: 'P3' }, { value: 'p4', label: 'P4' }, { value: 'p5', label: 'P5' }, ] }, ]) expect(page.content).toMatch(/import \{ EnumSelect \} from '@\/components\/ui\/EnumSelect'/) expect(page.content).toMatch(/, types the field string[] and seeds []', () => { const page = formPage([ { name: 'tags', type: 'enum', required: false, multiple: true, options: [ { value: 'a', label: 'A' }, { value: 'b', label: 'B' }, ] }, ]) expect(page.content).toMatch(/import \{ MultiSelect \} from '@\/components\/ui\/MultiSelect'/) expect(page.content).toMatch(/ { const page = formPage([ { name: 'kind', type: 'enum', required: true, options: [ { value: "o'brien", label: "O'Brien" }, ] }, ]) expect(page.content).toMatch(/value: 'o\\'brien'/) expect(page.content).toMatch(/defaultValue: 'O\\'Brien'/) }) it('plain (non-enum / non-date) fields still render a , no primitive imports', () => { const page = formPage([{ name: 'firstName', type: 'string', required: true }]) expect(page.content).toMatch(/ { const files = generate(fixture({ views: ['detail'], entityViews: ['list', 'detail'], fields: [ { name: 'status', type: 'enum', required: true, options: [ { value: 'open', label: 'Open' }, { value: 'closed', label: 'Closed' }, ] }, { name: 'tags', type: 'enum', required: false, multiple: true, options: [ { value: 'a', label: 'Alpha' }, ] }, ], })) const detail = files.find((f) => /DetailPage\.tsx$/.test(f.path))! // single enum → label looked up by value expect(detail.content).toMatch(/\{ value: 'open', label: 'Open' \}/) expect(detail.content).toMatch(/find\(\(o\) => o\.value === data\.status\)\?\.label/) // multiselect → values mapped to labels and joined expect(detail.content).toMatch(/\(\(data\.tags as string\[\] \| undefined\) \?\? \[\]\)\.map/) }) }) describe('scaffold-component / generate — form field state (readonly / readonlyOn / visibleWhen)', () => { function formFor(fields: ScaffoldComponentInput['fields']) { const files = generate(fixture({ entity: 'Task', module: 'todo', section: 'tasks', views: ['form'], fields })) return files.find((f) => /FormPage\.tsx$/.test(f.path))! } it('renders a readonly field display-only (no editable input) and drops it from the create payload', () => { const form = formFor([ { name: 'title', type: 'string', required: true }, { name: 'isOverdue', type: 'bool', required: false, readonly: true }, ]) expect(form.content).not.toMatch(/id="isOverdue"/) // Readonly boolean → coloured Oui/Non pill (not the old true). expect(form.content).not.toMatch(/\{String\(formData\.isOverdue\)\}<\/span>/) expect(form.content).toMatch(/formData\.isOverdue \? \{t\('task\.common\.yes'\)\}<\/Badge>/) expect(form.content).toMatch(/import \{ Badge \} from '@\/components\/ui\/Badge'/) // toCreatePayload picks ONLY the editable fields (title), excluding the readonly one. expect(form.content).toMatch(/const toCreatePayload = \(d: TaskFormData\) => \(\{/) expect(form.content).toMatch(/title: d\.title,/) expect(form.content).not.toMatch(/isOverdue: d\.isOverdue/) expect(form.content).toMatch(/createMutation\.mutateAsync\(toCreatePayload\(formData\)\)/) }) it('treats isComputed as an alias of readonly (display-only)', () => { const form = formFor([ { name: 'title', type: 'string', required: true }, { name: 'total', type: 'number', required: false, isComputed: true }, ]) expect(form.content).not.toMatch(/id="total"/) expect(form.content).toMatch(/\{String\(formData\.total \?\? ''\)\}/) }) it('locks a readonlyOn:"create" select in create mode and seeds its first option', () => { const form = formFor([ { name: 'status', type: 'string', required: true, readonlyOn: 'create', options: [{ value: 'Open', label: 'Open' }, { value: 'Done', label: 'Done' }] }, ]) expect(form.content).toMatch(/ { const form = formFor([ { name: 'status', type: 'string', required: true, options: [{ value: 'Open', label: 'Open' }, { value: 'Done', label: 'Done' }] }, { name: 'resolution', type: 'string', required: false, visibleWhen: "status === 'Done'" }, ]) expect(form.content).toMatch(/\{\(formData\.status === 'Done'\) && \(/) // The field stays in the form data type (only its JSX is conditional). expect(form.content).toMatch(/resolution\??: string/) }) it('degrades an unsupported visibleWhen to always-visible with a comment (never crashes)', () => { const form = formFor([ { name: 'note', type: 'string', required: false, visibleWhen: 'status && other' }, ]) expect(form.content).toMatch(/visibleWhen "status && other" is not a supported predicate/) // The field is still rendered (always visible), just not guarded. "note" // matches the long-text heuristic, so it's a