import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import path from 'node:path' import { deriveRelatedTabsData } from '../derive.js' // The § the client measured: 18/18 shipped related tabs fell back to a single // createdAt column because deriving the target list pagespec's columns was an // LLM step nobody ran. This CLI is the deterministic replacement. let root: string let conducteurs: string let parc: string beforeEach(() => { root = mkdtempSync(path.join(tmpdir(), 'ssrtd-')) conducteurs = path.join(root, 'FLOTTE', 'CONDUCTEURS') parc = path.join(root, 'FLOTTE', 'PARC') mkdirSync(path.join(conducteurs, 'pagespecs'), { recursive: true }) mkdirSync(path.join(parc, 'pagespecs'), { recursive: true }) }) afterEach(() => rmSync(root, { recursive: true, force: true })) function writeSpec(moduleRoot: string, file: string, spec: object): void { writeFileSync( path.join(moduleRoot, 'pagespecs', file), '# Pagespec\n\n```json\n' + JSON.stringify(spec, null, 2) + '\n```\n', ) } function driverDetail(relatedTabs: object[]): void { writeSpec(conducteurs, 'Driver.detail.md', { screenCode: 'SCR-DRIVER-DETAIL', entity: 'Driver', view: 'detail', relatedTabs, }) } const LICENCE_LIST = { screenCode: 'SCR-LICENCE-LIST', entity: 'DrivingLicence', view: 'list', columns: [ { key: 'number' }, { key: 'driverId' }, { key: 'status' }, { key: 'issuedOn', formatHint: 'date' }, { key: 'maxGrossWeightKg', formatHint: 'number' }, { key: 'nextMedicalCheckDate', formatHint: 'date' }, { key: 'isCreditCardFormat', formatHint: 'boolean' }, ], actions: [{ code: 'create', scope: 'header', permission: 'conducteurs.permis.create' }], i18nKeys: { fr: { 'list.columns.number': 'Numéro', 'list.columns.status': 'Statut' }, en: { 'list.columns.number': 'Number', 'list.columns.status': 'Status' }, it: { 'list.columns.number': 'Numero' }, de: { 'list.columns.number': 'Nummer' }, }, } const PERMIS_TAB = { key: 'permis', displayMode: 'table', relatedEntity: 'DrivingLicence', relationFk: 'driverId', relatedModule: 'conducteurs', relatedSection: 'permis', permission: 'conducteurs.permis.read', labelKey: 'detail.related.permis.label', } describe('derive-related-tabs-data', () => { it('derives ≤5 columns from the target list pagespec, relation FK EXCLUDED, labels resolved per locale', () => { driverDetail([PERMIS_TAB]) writeSpec(conducteurs, 'DrivingLicence.list.md', LICENCE_LIST) const r = deriveRelatedTabsData({ moduleRoot: conducteurs, entity: 'Driver' }) expect(r.errors).toEqual([]) expect(r.relatedTabsData).toHaveLength(1) const entry = r.relatedTabsData[0] expect(entry.key).toBe('permis') // driverId (the relation FK) is dropped; the cap keeps the first 5 others. expect(entry.columns.map(c => c.key)).toEqual(['number', 'status', 'issuedOn', 'maxGrossWeightKg', 'nextMedicalCheckDate']) expect(entry.columns[0].labels).toEqual({ fr: 'Numéro', en: 'Number', it: 'Numero', de: 'Nummer' }) expect(entry.columns[2].formatHint).toBe('date') expect(entry.displayField).toBe('number') expect(entry.createPermission).toBe('conducteurs.permis.create') }) it('derives hasDetail / hasCreateForm from the target pagespec files on disk', () => { driverDetail([PERMIS_TAB]) writeSpec(conducteurs, 'DrivingLicence.list.md', LICENCE_LIST) writeSpec(conducteurs, 'DrivingLicence.detail.md', { entity: 'DrivingLicence', view: 'detail' }) const r = deriveRelatedTabsData({ moduleRoot: conducteurs, entity: 'Driver' }) expect(r.relatedTabsData[0].hasDetail).toBe(true) expect(r.relatedTabsData[0].hasCreateForm).toBe(false) }) it('cross-module tab resolves the sibling module directory case-insensitively', () => { driverDetail([{ ...PERMIS_TAB, key: 'affectations', relatedEntity: 'Assignment', relatedModule: 'parc', relatedSection: 'affectations', permission: 'parc.affectations.read', labelKey: 'detail.related.affectations.label', }]) writeSpec(parc, 'Assignment.list.md', { entity: 'Assignment', view: 'list', columns: [{ key: 'startDate', formatHint: 'date' }, { key: 'driverId' }, { key: 'vehicleId' }], i18nKeys: { fr: { 'list.columns.startDate': 'Début' } }, }) const r = deriveRelatedTabsData({ moduleRoot: conducteurs, entity: 'Driver' }) expect(r.errors).toEqual([]) const entry = r.relatedTabsData[0] // The relation FK (driverId) is excluded; the OTHER FK column stays. expect(entry.columns.map(c => c.key)).toEqual(['startDate', 'vehicleId']) }) it('cross-module target NOT on disk → entry omitted (fail-open), never an error', () => { driverDetail([{ ...PERMIS_TAB, key: 'missions', relatedEntity: 'Mission', relatedModule: 'missions' }]) const r = deriveRelatedTabsData({ moduleRoot: conducteurs, entity: 'Driver' }) expect(r.errors).toEqual([]) expect(r.relatedTabsData).toEqual([]) expect(r.omitted).toHaveLength(1) expect(r.omitted[0].key).toBe('missions') }) it('same-module tab without a list pagespec falls back to entité.md attributes', () => { driverDetail([{ ...PERMIS_TAB, key: 'documents', relatedEntity: 'DriverDocument' }]) writeFileSync(path.join(conducteurs, 'entité.md'), `# Entités ### ENT-003 — DriverDocument (document) | Attribut | Type | Contraintes | Description | |---|---|---|---| | Id | guid | PK | — | | Label | string(120) | requis | — | | DriverId | guid | FK | — | | UploadedOn | date | requis | — | | MimeType | string(80) | — | — | | SizeKb | int | — | — | `) const r = deriveRelatedTabsData({ moduleRoot: conducteurs, entity: 'Driver' }) expect(r.errors).toEqual([]) const entry = r.relatedTabsData[0] // Id (system) and DriverId (relation FK) drop; first 4 remaining derive. expect(entry.columns.map(c => c.key)).toEqual(['label', 'uploadedOn', 'mimeType', 'sizeKb']) }) it('a target pagespec that EXISTS but derives nothing is a hard error (never ship the empty tab)', () => { driverDetail([PERMIS_TAB]) writeSpec(conducteurs, 'DrivingLicence.list.md', { entity: 'DrivingLicence', view: 'list', columns: [{ key: 'driverId' }] }) const r = deriveRelatedTabsData({ moduleRoot: conducteurs, entity: 'Driver' }) expect(r.errors).toHaveLength(1) expect(r.errors[0]).toContain("tab 'permis'") expect(r.relatedTabsData).toEqual([]) }) it('summary tabs need no entry', () => { driverDetail([{ ...PERMIS_TAB, key: 'compteur', displayMode: 'summary' }]) const r = deriveRelatedTabsData({ moduleRoot: conducteurs, entity: 'Driver' }) expect(r.errors).toEqual([]) expect(r.relatedTabsData).toEqual([]) expect(r.omitted).toEqual([]) }) }) describe('cross-APPLICATION targets', () => { // The billing application, a sibling of FLOTTE under the same BA root. let factures: string beforeEach(() => { factures = path.join(root, 'FACTURATION', 'FACTURES') mkdirSync(path.join(factures, 'pagespecs'), { recursive: true }) writeSpec(factures, 'Invoice.list.md', { screenCode: 'SCR-INVOICE-LIST', entity: 'Invoice', view: 'list', columns: [{ key: 'number' }, { key: 'total' }], }) }) const crossAppTab = { key: 'factures', displayMode: 'table', relatedEntity: 'Invoice', relationFk: 'driverId', relatedApp: 'facturation', relatedModule: 'factures', relatedSection: 'factures', } it('derives the columns of a target living in ANOTHER application', () => { driverDetail([crossAppTab]) const report = deriveRelatedTabsData({ moduleRoot: conducteurs, entity: 'Driver' }) expect(report.omitted).toEqual([]) const entry = report.relatedTabsData.find(e => e.key === 'factures') expect(entry?.columns.map(c => c.key)).toEqual(['number', 'total']) }) it('omits — and says where it looked — when that application has no such module', () => { driverDetail([{ ...crossAppTab, relatedModule: 'inexistant' }]) const report = deriveRelatedTabsData({ moduleRoot: conducteurs, entity: 'Driver' }) expect(report.relatedTabsData.find(e => e.key === 'factures')).toBeUndefined() expect(report.omitted[0].reason).toContain('facturation/inexistant') }) it('does not confuse two applications sharing a module code', () => { // FLOTTE also has a FACTURES module, with a DIFFERENT Invoice list. const flotteFactures = path.join(root, 'FLOTTE', 'FACTURES') mkdirSync(path.join(flotteFactures, 'pagespecs'), { recursive: true }) writeSpec(flotteFactures, 'Invoice.list.md', { screenCode: 'SCR-WRONG-LIST', entity: 'Invoice', view: 'list', columns: [{ key: 'wrong' }], }) driverDetail([crossAppTab]) const report = deriveRelatedTabsData({ moduleRoot: conducteurs, entity: 'Driver' }) const entry = report.relatedTabsData.find(e => e.key === 'factures') expect(entry?.columns.map(c => c.key)).toEqual(['number', 'total']) }) it('a tab naming its OWN application still resolves as a sibling module', () => { driverDetail([{ ...crossAppTab, relatedApp: 'flotte', relatedModule: 'parc', relatedEntity: 'Vehicle' }]) writeSpec(parc, 'Vehicle.list.md', { screenCode: 'SCR-VEHICLE-LIST', entity: 'Vehicle', view: 'list', columns: [{ key: 'plate' }], }) const report = deriveRelatedTabsData({ moduleRoot: conducteurs, entity: 'Driver' }) expect(report.omitted).toEqual([]) expect(report.relatedTabsData.find(e => e.key === 'factures')?.columns.map(c => c.key)).toEqual(['plate']) }) })