import { describe, it, expect } from 'vitest' import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { deriveResources, deriveNavResources, humanizeSlug } from '../derive.js' import type { PagespecRouteIdentity } from '../types.js' function spec(over: Partial): PagespecRouteIdentity { return { key: 'X.list', appCode: 'flotte', module: 'parametrage', section: 'referentiels', view: 'list', ...over, } } describe('deriveResources', () => { it('emits one resource per distinct (routeParent, routeFamily) with route + componentKey on the scaffold-routes shape', () => { const report = deriveResources([ spec({ key: 'VehicleType.list', entity: 'VehicleType', routeFamily: 'vehicle-types', routeParent: 'referentiels', frLeaves: { 'list.title': 'Types de véhicule' } }), spec({ key: 'EnergyType.list', entity: 'EnergyType', routeFamily: 'energy-types', routeParent: 'referentiels', frLeaves: { 'list.title': 'Types d’énergie' } }), ]) expect(report.resources).toHaveLength(2) const vt = report.resources.find((r) => r.code === 'vehicle-types')! expect(vt).toMatchObject({ code: 'vehicle-types', label: 'Types de véhicule', sectionCode: 'referentiels', route: '/flotte/parametrage/referentiels/vehicle-types', componentKey: 'flotte.parametrage.referentiels.vehicle-types', }) // displayOrder is alphabetical per section, 1-based. expect(report.resources.map((r) => [r.code, r.displayOrder])).toEqual([ ['energy-types', 1], ['vehicle-types', 2], ]) }) it('folds every view of the same family into ONE resource, label from the list view', () => { const report = deriveResources([ spec({ key: 'VehicleType.list', routeFamily: 'vehicle-types', routeParent: 'referentiels', frLeaves: { 'list.title': 'Types de véhicule' } }), spec({ key: 'VehicleType.detail', view: 'detail', routeFamily: 'vehicle-types', routeParent: 'referentiels', frLeaves: { 'detail.title': 'Type de véhicule' } }), spec({ key: 'VehicleType.form', view: 'form', routeFamily: 'vehicle-types', routeParent: 'referentiels' }), ]) expect(report.resources).toHaveLength(1) expect(report.resources[0].label).toBe('Types de véhicule') expect(report.totals.satellites).toBe(3) }) it('the porteur (no routeFamily) never becomes a resource', () => { const report = deriveResources([ spec({ key: 'Vehicle.list', section: 'vehicules', entity: 'Vehicle' }), spec({ key: 'VehicleDocument.list', section: 'vehicules', routeFamily: 'documents', routeParent: 'vehicules', frLeaves: { 'list.title': 'Documents' } }), ]) expect(report.resources.map((r) => r.code)).toEqual(['documents']) }) it('half-declared route identity (routeFamily without routeParent) is skipped with a PRD-108 warning', () => { const report = deriveResources([ spec({ key: 'Broken.list', routeFamily: 'broken' }), ]) expect(report.resources).toHaveLength(0) expect(report.warnings.some((w) => w.includes('Broken.list') && w.includes('PRD-108'))).toBe(true) }) it('a family without a list view is seeded anyway (views must stay reachable) with a warning', () => { const report = deriveResources([ spec({ key: 'LifecycleSummary.form', view: 'form', section: 'vehicules', routeFamily: 'lifecycle-summary', routeParent: 'vehicules' }), ]) expect(report.resources).toHaveLength(1) expect(report.resources[0].label).toBe('Lifecycle summary') expect(report.warnings.some((w) => w.includes('no list pagespec'))).toBe(true) }) it('warns when section ≠ routeParent (routeParent must name the hosting section)', () => { const report = deriveResources([ spec({ key: 'X.list', section: 'permis', routeFamily: 'categories', routeParent: 'referentiels' }), ]) expect(report.warnings.some((w) => w.includes('routeParent'))).toBe(true) }) }) describe('deriveNavResources (disk scan)', () => { it('reads fenced json machine blocks from pagespecs/*.md', () => { const root = mkdtempSync(join(tmpdir(), 'ssnavres-')) mkdirSync(join(root, 'pagespecs')) const block = { appCode: 'flotte', module: 'parametrage', section: 'referentiels', entity: 'VehicleType', view: 'list', routeFamily: 'vehicle-types', routeParent: 'referentiels', i18nKeys: { fr: { 'list.title': 'Types de véhicule' } }, } writeFileSync( join(root, 'pagespecs', 'VehicleType.list.md'), `# VehicleType — list\n\n\`\`\`json\n${JSON.stringify(block, null, 2)}\n\`\`\`\n`, 'utf8', ) const report = deriveNavResources({ moduleRoot: root }) expect(report.totals).toEqual({ pagespecs: 1, satellites: 1, resources: 1, collisions: 0 }) expect(report.resources[0].componentKey).toBe('flotte.parametrage.referentiels.vehicle-types') }) it('missing pagespecs/ dir → empty result with a warning, never a throw', () => { const root = mkdtempSync(join(tmpdir(), 'ssnavres-')) const report = deriveNavResources({ moduleRoot: root }) expect(report.resources).toHaveLength(0) expect(report.warnings.some((w) => w.includes('no pagespecs/'))).toBe(true) }) }) describe('humanizeSlug', () => { it('kebab → sentence case', () => { expect(humanizeSlug('vehicle-types')).toBe('Vehicle types') expect(humanizeSlug('handover-reports')).toBe('Handover reports') }) }) describe('deriveResources — duplicate route family (review fix)', () => { it('BLOCKS when two entities claim the same (routeParent, routeFamily)', () => { const report = deriveResources([ spec({ key: 'VehicleType.list', entity: 'VehicleType', routeFamily: 'types', routeParent: 'referentiels' }), spec({ key: 'EnergyType.list', entity: 'EnergyType', routeFamily: 'types', routeParent: 'referentiels' }), ]) // One resource is still emitted (the fold), but the collision is LOUD: // one [NavRoute] serves one controller. expect(report.resources).toHaveLength(1) // STRUCTURED, not just a warning — index.ts exits 1 on a non-empty // collisions[] instead of telling the orchestrator to seed them. expect(report.collisions).toEqual([ { sectionCode: 'referentiels', family: 'types', entities: ['EnergyType', 'VehicleType'] }, ]) expect(report.totals.collisions).toBe(1) const blocking = report.warnings.find((w) => w.startsWith('BLOCKING')) expect(blocking).toBeDefined() expect(blocking).toContain('EnergyType, VehicleType') expect(blocking).toContain('PRD-108') }) it('the same entity across its views is NOT a collision', () => { const report = deriveResources([ spec({ key: 'VehicleType.list', entity: 'VehicleType', routeFamily: 'types', routeParent: 'referentiels' }), spec({ key: 'VehicleType.detail', entity: 'VehicleType', view: 'detail', routeFamily: 'types', routeParent: 'referentiels' }), ]) expect(report.warnings.filter((w) => w.startsWith('BLOCKING'))).toEqual([]) expect(report.collisions).toEqual([]) }) })