/** * scaffold-routes / generate.test.ts (2026-05-27) * * Covers the three regressions reported alongside the GAF/REFERENTIELS * post-mortem: * - Bug D: parentSection nests the componentKey (and the URL helper) so the * navigation DB's 4-segment key for sub-resources actually resolves. * - Bug E: `create` + `edit` views share the same `${E}FormPage` lazy const * — emit it exactly once so the registry compiles. * - Bug F: `module-home` view emits the 2-segment `{app}.{module}` key, not * the redundant `{app}.{module}.module-home.module-home`. * * Plus a couple of guard-rail tests that lock in the baseline contract so a * future change can't silently regress the basic 3-segment list/detail/create * key shape. */ import { describe, it, expect } from 'vitest' import { generate } from '../generate.js' import { validateComponentKey } from '../types.js' import type { ScaffoldRoutesInput } from '../types.js' function fixture(overrides: Partial = {}): ScaffoldRoutesInput { return { module: 'crm', appCode: 'app', entities: [ { name: 'Contact', section: 'contacts', views: ['list', 'detail', 'create', 'edit'], }, ], projectPath: '/test', ...overrides, } as ScaffoldRoutesInput } describe('scaffold-routes / generate — baseline contract', () => { it('emits a 3-segment componentKey for the list view', () => { const files = generate(fixture()) const registry = files.find(f => f.path.includes('Registry'))! expect(registry.content).toContain("PageRegistry.register('app.crm.contacts', ContactsListPage)") }) it('emits 4-segment keys for detail/edit/create', () => { const files = generate(fixture()) const registry = files.find(f => f.path.includes('Registry'))! expect(registry.content).toContain("PageRegistry.register('app.crm.contacts.detail',") expect(registry.content).toContain("PageRegistry.register('app.crm.contacts.edit',") expect(registry.content).toContain("PageRegistry.register('app.crm.contacts.create',") }) it('uses the BA-supplied pluralName instead of `${name}s`', () => { const files = generate(fixture({ entities: [{ name: 'Rue', pluralName: 'Rues', section: 'rues', views: ['list'], }], })) const registry = files.find(f => f.path.includes('Registry'))! expect(registry.content).toMatch(/const RuesListPage = lazyWithRetry/) expect(registry.content).not.toMatch(/const RusListPage/) // naive fallback would be wrong }) }) describe('scaffold-routes / generate — Bug D (parentSection nesting)', () => { it('nests sub-resource componentKeys under parentSection', () => { const files = generate({ module: 'referentiels', appCode: 'gaf', entities: [{ name: 'QualificatifTypeAudit', pluralName: 'QualificatifTypeAudits', section: 'types-audit', parentSection: 'types-affaire', views: ['list', 'detail', 'create', 'edit'], }], projectPath: '/test', }) const registry = files.find(f => f.path.includes('Registry'))! // List view: 4-segment key matches the navigation DB expect(registry.content).toContain("'gaf.referentiels.types-affaire.types-audit'") // Detail/edit/create: 5-segment keys expect(registry.content).toContain("'gaf.referentiels.types-affaire.types-audit.detail'") expect(registry.content).toContain("'gaf.referentiels.types-affaire.types-audit.edit'") expect(registry.content).toContain("'gaf.referentiels.types-affaire.types-audit.create'") // The flat (broken) 3-segment key must NOT appear expect(registry.content).not.toContain("'gaf.referentiels.types-audit'") }) it('nests sub-resource default import paths under parentSection', () => { const files = generate({ module: 'referentiels', appCode: 'gaf', entities: [{ name: 'Rue', pluralName: 'Rues', section: 'rues', parentSection: 'sites', views: ['list', 'detail'], }], projectPath: '/test', }) const registry = files.find(f => f.path.includes('Registry'))! expect(registry.content).toContain("import('@/pages/gaf/referentiels/sites/rues/RuesListPage')") expect(registry.content).toContain("import('@/pages/gaf/referentiels/sites/rues/RueDetailPage')") }) it('nests sub-resource URL helpers under parentSection', () => { const files = generate({ module: 'referentiels', appCode: 'gaf', entities: [{ name: 'Rue', pluralName: 'Rues', section: 'rues', parentSection: 'sites', views: ['list', 'detail', 'edit', 'create'], }], projectPath: '/test', }) const routes = files.find(f => f.path.includes('Routes'))! expect(routes.content).toContain("list: () => '/gaf/referentiels/sites/rues'") expect(routes.content).toContain("detail: (id: string) => `/gaf/referentiels/sites/rues/${id}`") expect(routes.content).toContain("edit: (id: string) => `/gaf/referentiels/sites/rues/${id}/edit`") expect(routes.content).toContain("create: () => '/gaf/referentiels/sites/rues/create'") }) it('flat entities (no parentSection) still emit 3-segment URLs — backward compat', () => { const files = generate(fixture()) const routes = files.find(f => f.path.includes('Routes'))! expect(routes.content).toContain("list: () => '/app/crm/contacts'") // No spurious empty segment in the URL literals themselves (grepping the // whole file would catch the `//` in the header banner). expect(routes.content).not.toMatch(/'\/[^']*\/\/[^']*'/) // empty path segment in a single-quoted URL expect(routes.content).not.toMatch(/`\/[^`]*\/\/[^`]*`/) // empty path segment in a template URL }) it('accepts 5-segment sub-resource keys (validateComponentKey upper limit bumped to 5)', () => { expect(validateComponentKey('gaf.referentiels.types-affaire.types-audit')).toBeNull() expect(validateComponentKey('gaf.referentiels.types-affaire.types-audit.detail')).toBeNull() // 6 segments still rejected expect(validateComponentKey('a.b.c.d.e.f')).toMatch(/must be between 2 and 5/) }) }) describe('scaffold-routes / generate — Bug E (deduplicate FormPage const for create+edit)', () => { it('emits one FormPage const for create; .edit registers the DetailPage (unified fiche)', () => { const files = generate(fixture()) const registry = files.find(f => f.path.includes('Registry'))! const matches = registry.content.match(/const ContactFormPage = lazyWithRetry/g) ?? [] expect(matches).toHaveLength(1) expect(registry.content).toContain("PageRegistry.register('app.crm.contacts.create', ContactFormPage)") // detail + form without opt-out → the fiche IS the edit surface // (lib/edit-surface): /edit mounts the DetailPage, sections opened. expect(registry.content).toContain("PageRegistry.register('app.crm.contacts.edit', ContactDetailPage)") }) it('directEdit: true keeps the legacy FormPage on .edit (the opt-out)', () => { const files = generate(fixture({ entities: [{ name: 'Contact', section: 'contacts', views: ['list', 'detail', 'form'], directEdit: true }], } as Parameters[0])) const registry = files.find(f => f.path.includes('Registry'))! expect(registry.content).toContain("PageRegistry.register('app.crm.contacts.edit', ContactFormPage)") }) it('form without a detail sibling keeps the legacy FormPage on .edit', () => { const files = generate(fixture({ entities: [{ name: 'Contact', section: 'contacts', views: ['list', 'form'] }], } as Parameters[0])) const registry = files.find(f => f.path.includes('Registry'))! expect(registry.content).toContain("PageRegistry.register('app.crm.contacts.edit', ContactFormPage)") }) it('still emits a single const when only one of create/edit is present', () => { const files = generate(fixture({ entities: [{ name: 'Contact', section: 'contacts', views: ['list', 'edit'], // edit only }], })) const registry = files.find(f => f.path.includes('Registry'))! const matches = registry.content.match(/const ContactFormPage = lazyWithRetry/g) ?? [] expect(matches).toHaveLength(1) }) it('does not collapse distinct page names — list and detail remain separate consts', () => { const files = generate(fixture()) const registry = files.find(f => f.path.includes('Registry'))! expect(registry.content).toMatch(/const ContactsListPage = lazyWithRetry/) expect(registry.content).toMatch(/const ContactDetailPage = lazyWithRetry/) expect(registry.content).toMatch(/const ContactFormPage = lazyWithRetry/) }) }) describe('scaffold-routes / generate — Fix #4 (React.ComponentType matches lazyWithRetry constraint)', () => { it('emits React.ComponentType in the module-cast (NOT bare React.ComponentType)', () => { // The SmartStack `lazyWithRetry>` constraint // rejects `ComponentType<{}>` (the implicit shape of bare // `React.ComponentType`). Without the explicit generic, every registration // failed TS compilation — `audit-dev-frontend` had to auto-heal every run. const files = generate(fixture()) const registry = files.find(f => f.path.includes('Registry'))! // Positive: every cast carries ``. expect(registry.content).toMatch(/default\?: React\.ComponentType;/) expect(registry.content).toMatch(/ContactsListPage\?: React\.ComponentType/) // Negative: the bare form (without `<…>`) MUST NOT leak back. expect(registry.content).not.toMatch(/default\?: React\.ComponentType;/) }) }) describe('scaffold-routes / generate — Bug F (module-home is the 2-segment app.module root)', () => { it('generates a 2-segment key for the module-home view (no redundant suffix)', () => { const files = generate({ module: 'referentiels', appCode: 'gaf', entities: [{ name: 'ReferentielsHome', pluralName: 'ReferentielsHome', section: 'module-home', views: ['module-home'], }], projectPath: '/test', }) const registry = files.find(f => f.path.includes('Registry'))! expect(registry.content).toContain("PageRegistry.register('gaf.referentiels', ") expect(registry.content).not.toContain("'gaf.referentiels.module-home.module-home'") expect(registry.content).not.toContain("'gaf.referentiels.module-home'") }) it('renders a valid TypeScript identifier for hub-view page names (no hyphens)', () => { // Regression: the legacy `view.charAt(0).toUpperCase() + view.slice(1)` // produced `Module-homePage` — invalid identifier, breaks the build the // moment a module-home entity is in the spec. const files = generate({ module: 'referentiels', appCode: 'gaf', entities: [{ name: 'ReferentielsHome', pluralName: 'ReferentielsHome', section: 'module-home', views: ['module-home'], }], projectPath: '/test', }) const registry = files.find(f => f.path.includes('Registry'))! expect(registry.content).toMatch(/const ReferentielsHomeModuleHomePage = lazyWithRetry/) expect(registry.content).not.toMatch(/Module-home/) }) it('section-home view registers under the section root (same key as list)', () => { const files = generate({ module: 'referentiels', appCode: 'gaf', entities: [{ name: 'ReferentielsHome', pluralName: 'ReferentielsHome', section: 'home', views: ['section-home'], }], projectPath: '/test', }) const registry = files.find(f => f.path.includes('Registry'))! // section-home shares its key with the list view by design (3-segment) expect(registry.content).toContain("PageRegistry.register('gaf.referentiels.home', ") }) // The board is a viewMode of the LIST page (?view=kanban) — the legacy // 'kanban' view token must emit NOTHING (no key, no import, no helper), // while the list registration stays intact. it("legacy 'kanban' view token emits no registry key, no page import, no URL helper", () => { const files = generate({ module: 'affaires', appCode: 'gaf', entities: [{ name: 'Demande', pluralName: 'Demandes', section: 'demandes', views: ['list', 'kanban'], }], projectPath: '/test', }) const registry = files.find(f => f.path.includes('Registry'))! expect(registry.content).toContain("PageRegistry.register('gaf.affaires.demandes', ") expect(registry.content).not.toContain('.demandes.kanban') expect(registry.content).not.toMatch(/DemandeKanbanPage/) const routesFile = files.find(f => f.path.includes('Routes'))! expect(routesFile.content).toMatch(/list: \(\) => '\/gaf\/affaires\/demandes'/) expect(routesFile.content).not.toMatch(/kanban:/) }) it('reconduction view registers with key {app}.{module}.{section}.reconduction + URL helper', () => { const files = generate({ module: 'affaires', appCode: 'gaf', entities: [{ name: 'Demande', pluralName: 'Demandes', section: 'demandes', views: ['list', 'reconduction'], }], projectPath: '/test', }) const registry = files.find(f => f.path.includes('Registry'))! expect(registry.content).toContain("PageRegistry.register('gaf.affaires.demandes.reconduction', ") expect(registry.content).toMatch(/const DemandeReconductionPage = lazyWithRetry/) const routesFile = files.find(f => f.path.includes('Routes'))! expect(routesFile.content).toMatch(/reconduction: \(\) => '\/gaf\/affaires\/demandes\/reconduction'/) }) }) describe('scaffold-routes / generate — BUG A (app-scoped extension filenames)', () => { it('names the registry + routes files by - so two apps never collide', () => { const files = generate(fixture({ appCode: 'clients', module: 'configuration' })) const registry = files.find(f => f.path.includes('Registry'))! const routes = files.find(f => f.path.includes('Routes'))! expect(registry.path).toBe('src/extensions/clients-configurationRegistry.ts') expect(routes.path).toBe('src/extensions/clients-configurationRoutes.ts') // the un-prefixed flat name (that made the 2nd app overwrite the 1st) is gone expect(registry.path).not.toBe('src/extensions/configurationRegistry.ts') }) it('two apps sharing a module code emit DISTINCT files and DISTINCT componentKeys', () => { const rh = generate(fixture({ appCode: 'rh', module: 'configuration', entities: [{ name: 'TypeAbsence', pluralName: 'TypesAbsence', section: 'types-absence', views: ['list'] }], })) const clients = generate(fixture({ appCode: 'clients', module: 'configuration', entities: [{ name: 'Parametre', pluralName: 'Parametres', section: 'parametres', views: ['list'] }], })) const rhReg = rh.find(f => f.path.includes('Registry'))! const clReg = clients.find(f => f.path.includes('Registry'))! // Distinct filenames — the second app no longer overwrites the first on disk. expect(rhReg.path).toBe('src/extensions/rh-configurationRegistry.ts') expect(clReg.path).toBe('src/extensions/clients-configurationRegistry.ts') expect(rhReg.path).not.toBe(clReg.path) // Distinct, app-prefixed componentKeys (the aggregator sees no collision). expect(rhReg.content).toContain("PageRegistry.register('rh.configuration.types-absence',") expect(clReg.content).toContain("PageRegistry.register('clients.configuration.parametres',") }) it('the registry import-example comment points at the app-scoped basename', () => { // Contract with scaffold-component: it imports `@/extensions/-Routes` // via the SAME lib helper, so the emitted file basename must match. const files = generate(fixture({ appCode: 'clients', module: 'configuration' })) const registry = files.find(f => f.path.includes('Registry'))! expect(registry.content).toContain("import './extensions/clients-configurationRegistry';") const routes = files.find(f => f.path.includes('Routes'))! expect(routes.content).toContain("import { routes } from '@/extensions/clients-configurationRoutes';") }) }) describe('scaffold-routes / form view alias', () => { // The BA pagespec view set is list/detail/form — passing it verbatim must // yield BOTH create() and edit() helpers. Routes-native views used to be // required and the orchestration mapping was implicit; `edit` was silently // dropped (the schema default has no edit either) while scaffold-component // emitted routes.{x}.edit(...) → TS2339 on every list page. it("views ['list','detail','form'] emits list + detail + create + edit helpers and ONE FormPage registration pair", () => { const files = generate({ module: 'referentiels', appCode: 'gaf', entities: [{ name: 'TypeAffaire', pluralName: 'TypesAffaire', section: 'types-affaire', views: ['list', 'detail', 'form'] }], projectPath: '/test', }) const routes = files.find(f => f.path.includes('Routes'))! expect(routes.content).toContain("list: () => '/gaf/referentiels/types-affaire'") expect(routes.content).toContain('detail: (id: string) => `/gaf/referentiels/types-affaire/${id}`') expect(routes.content).toContain('edit: (id: string) => `/gaf/referentiels/types-affaire/${id}/edit`') expect(routes.content).toContain("create: () => '/gaf/referentiels/types-affaire/create'") const registry = files.find(f => f.path.includes('Registry'))! // create + edit share one FormPage import (dedup guard still holds) expect(registry.content.match(/TypeAffaireFormPage'\)/g)?.length).toBe(1) }) })