/** * derive-test-data — a two-module project on disk (CLIENT/ANNUAIRE cites * CLIENT/CONFIGURATION's reference table and dataset, and a Core User by * actor). The load-bearing assertions: a FK is resolved WHERE THE RELATION SAYS * (same-module dataset, cross-module dataset, Valeurs initiales, actor), never * copied and never a Guid; an unresolved citation names the owner; a cycle is * refused; an absent file is a result; every issue class fires on the corpus. */ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { loadModuleEntities } from '../../../../../lib/ba-entities.js' import { loadEntityContents, normalizeModulePath, parseEntities } from '../../../../../lib/ba-relations.js' import { loadModuleRules } from '../../../../../lib/ba-rules-rows.js' import { loadAppActors } from '../../../../../lib/ba-actors.js' import { loadModuleTestData } from '../../../../../lib/ba-test-data.js' import { csTypeOf, deriveTestData, type ModuleInputs } from '../execute.js' import { validateSpec } from '../validate.js' import type { DeriveTestDataReport, RowRef } from '../types.js' const ACTEUR = [ '', '### BA-001-AC-001 — Gestionnaire clients', '- **Type** : internal', '- **Catégorie** : Manager', '### BA-001-AC-002 — Commercial', '- **Type** : internal', '- **Catégorie** : Contributor', '', ].join('\n') const CONFIG_ENTITE = [ '', '- **Portée** : strict — données isolées par tenant', '### ENT-001 — TypeClient (lookup)', '- **Préfixe table** : `cfg_`', '- **Affichage** : Label', '', '| Attribut | Type | Contraintes | Calculé |', '|---|---|---|---|', '| Id | Guid | PK | — |', '| Label | string(100) | requis, unique | — |', '', '- **Valeurs initiales** : clé `Label` — les types fixés par le métier :', '', '| Label |', '|---|', '| Grand compte |', '| PME |', '', '### ENT-002 — Segment (agrégat racine)', '- **Préfixe table** : `cfg_`', '- **Affichage** : Nom', '', '| Attribut | Type | Contraintes | Calculé |', '|---|---|---|---|', '| Id | Guid | PK | — |', '| Nom | string(100) | requis | — |', '| Poids | int | | — |', '', ].join('\n') const CONFIG_TEST_DATA = [ '', '- **Date de référence** : 2026-09-15', '', '### JT-001 — Segment (ENT-002)', '- **Clé** : `Nom`', '', '| Nom | Poids |', '|---|---|', '| Industrie | 10 |', '| Services | 20 |', '| Public | 30 |', '', ].join('\n') const ANNUAIRE_ENTITE = [ '', '- **Portée** : strict — données isolées par tenant', '### ENT-001 — Client (agrégat racine)', '- **Préfixe table** : `cli_`', '- **Affichage** : Nom', '', '| Attribut | Type | Contraintes | Calculé |', '|---|---|---|---|', '| Id | Guid | PK | — |', '| Nom | string(200) | requis | — |', '| Statut | enum | Actif/Archivé, requis | — |', '| DateEntree | date | | — |', '| Actif | bool | | — |', '| Total | decimal(18,2) | | `0` |', '', '- **Relations** :', ' - Client *→1 TenantOrganisation — FK OrganisationId, scope core (tenant_TenantOrganisations), onDelete restrict', ' - Client *→1 TypeClient — FK TypeClientId, scope cross-module (CLIENT/CONFIGURATION), onDelete restrict', ' - Client *→1 Segment — FK SegmentId, scope cross-module (CLIENT/CONFIGURATION), onDelete restrict', ' - Client *→1 User — FK ResponsableId, scope core (auth_Users), onDelete restrict', ' - Client *→1 Group — FK GroupeId, scope core (auth_Groups), onDelete restrict', '- **Index** : (Statut).', '', '### ENT-002 — Contact (agrégat racine)', '- **Préfixe table** : `cli_`', '- **Affichage** : Email', '', '| Attribut | Type | Contraintes | Calculé |', '|---|---|---|---|', '| Id | Guid | PK | — |', '| Email | string(200) | requis, unique | — |', '', '- **Relations** :', ' - Contact *→1 Client — FK ClientId, scope same-module, onDelete cascade', '', ].join('\n') const ANNUAIRE_RULES = [ '', '### BR-004 — Archivage réversible', '- **Type** : state-transition', '- **Sévérité** : err', '- **Flow** :', ' - Actif → Archivé (by: BA-001-AC-001)', ' - Archivé → Actif (by: BA-001-AC-001)', '- **Code d\'erreur** : `client-archive`', '', ].join('\n') const ANNUAIRE_TEST_DATA = [ '', '> Personnes et sociétés FICTIVES — aucune donnée réelle.', '- **Date de référence** : 2026-09-15', '', '### JT-001 — Client (ENT-001)', '- **Clé** : `Nom`', '', '| Nom | Organisation | TypeClient | Segment | Statut | DateEntree | Actif | Responsable | Total | Note |', '|---|---|---|---|---|---|---|---|---|---|', '| Direction Marketing | ACME SA | Grand compte | Industrie | Actif | 2025-01-10 | oui | Commercial | 12 | BR-003 |', '| Atelier Lausanne | Garage du Léman Sàrl | PME | Services | Archivé | 2024-06-01 | non | BA-001-AC-001 | 0 | BR-004 |', '| Bureau Sion | Alpes SA | Artisan | Montagne | actif | 2024-13-01 | peut-être | Inconnu | abc | |', '', '### JT-002 — Contact (ENT-002)', '- **Clé** : `Email`', '', '| Email | Client |', '|---|---|', '| claire.rochat@exemple.ch | Direction Marketing |', '| marc.favre@exemple.ch | Atelier Lausanne |', '| dup@exemple.ch | Direction Marketing |', '| dup@exemple.ch | Nulle part |', '', ].join('\n') let root: string function write(rel: string, content: string): void { const p = join(root, rel) mkdirSync(join(p, '..'), { recursive: true }) writeFileSync(p, content, 'utf8') } function loadModules(): Map { const modules = new Map() for (const [app, mod] of [['CLIENT', 'CONFIGURATION'], ['CLIENT', 'ANNUAIRE']] as const) { const entities = loadModuleEntities(root, app, mod) if (!entities) continue modules.set(normalizeModulePath(`${app}/${mod}`), { modulePath: `${app}/${mod}`, entities: entities.entities, testData: loadModuleTestData(root, app, mod).doc, }) } return modules } function run(app: string, module: string, mode: 'check' | 'derive'): DeriveTestDataReport { return deriveTestData({ app, module, mode, modules: loadModules(), graph: parseEntities(loadEntityContents(root)), rules: loadModuleRules(root, app, module).rules, actors: loadAppActors(root, app).actors, file: null, warnings: [], }) } beforeAll(() => { root = mkdtempSync(join(tmpdir(), 'derive-test-data-')) write('CLIENT/acteur.md', ACTEUR) write('CLIENT/CONFIGURATION/entité.md', CONFIG_ENTITE) write('CLIENT/CONFIGURATION/jeu-de-test.md', CONFIG_TEST_DATA) write('CLIENT/ANNUAIRE/entité.md', ANNUAIRE_ENTITE) write('CLIENT/ANNUAIRE/règles-métier.md', ANNUAIRE_RULES) write('CLIENT/ANNUAIRE/jeu-de-test.md', ANNUAIRE_TEST_DATA) }) afterAll(() => rmSync(root, { recursive: true, force: true })) describe('derive-test-data — references resolve WHERE THE RELATION SAYS', () => { it('cross-module FK → the owner dataset (key), a Valeurs initiales row, a Core User → the actor, another Core → a literal', () => { const r = run('CLIENT', 'ANNUAIRE', 'derive') const client = r.derived!.sets.find((s) => s.entity === 'Client')! const row1 = client.rows[0]! expect(row1['SegmentId']).toEqual({ ref: 'CLIENT/CONFIGURATION/Segment', entity: 'Segment', keyField: 'Nom', key: 'Industrie', tenantMode: 'tenant' }) expect(row1['TypeClientId']).toEqual({ ref: 'CLIENT/CONFIGURATION/TypeClient', entity: 'TypeClient', keyField: 'Label', key: 'Grand compte', tenantMode: 'tenant' }) expect(row1['ResponsableId']).toEqual({ actor: 'BA-001-AC-002', label: 'Commercial' }) expect(client.rows[1]!['ResponsableId']).toEqual({ actor: 'BA-001-AC-001', label: 'Gestionnaire clients' }) expect(row1['OrganisationId']).toEqual({ core: 'TenantOrganisation', by: 'Name', value: 'ACME SA' }) expect(client.dependsOn).toEqual(['CLIENT/CONFIGURATION/Segment', 'CLIENT/CONFIGURATION/TypeClient']) expect(r.derived!.moduleDependencies).toEqual(['CLIENT/CONFIGURATION']) expect(r.derived!.rank).toBe(1) expect(r.scopeNotes.some((n) => n.includes('TenantOrganisation'))).toBe(true) }) it('same-module FK → the citing block depends on the cited one and comes AFTER it; no Guid anywhere', () => { const r = run('CLIENT', 'ANNUAIRE', 'derive') const codes = r.derived!.sets.map((s) => s.code) expect(codes).toEqual(['JT-001', 'JT-002']) const contact = r.derived!.sets[1]! expect(contact.order).toBeGreaterThan(r.derived!.sets[0]!.order) expect((contact.rows[0]!['ClientId'] as RowRef).key).toBe('Direction Marketing') expect(contact.dependsOn).toEqual(['CLIENT/ANNUAIRE/Client']) expect(JSON.stringify(r.derived)).not.toMatch(/[0-9a-f]{8}-[0-9a-f]{4}/i) }) it('types the scalars and lists the enum for Phase 1', () => { const r = run('CLIENT', 'ANNUAIRE', 'derive') const client = r.derived!.sets[0]! expect(client.types).toEqual({ Nom: 'string', DateEntree: 'DateOnly', Actif: 'bool' }) expect(client.needsTypes).toEqual(['Statut']) expect(client.rows[0]!['Actif']).toBe(true) expect(client.rows[1]!['Actif']).toBe(false) expect(client.rows[0]!['DateEntree']).toBe('2025-01-10') // Total is computed → ignored (a warning, no value). expect(client.rows[0]!['Total']).toBeUndefined() expect(client.tenantMode).toBe('tenant') expect(client.keyField).toBe('Nom') expect(csTypeOf('decimal(18,2)')).toBe('decimal') expect(csTypeOf('enum')).toBeNull() }) }) describe('derive-test-data — every incoherence is said, with its owner', () => { it('row 3 fires enum (verbatim), date, boolean, actor and both FK owners; row count warns', () => { const r = run('CLIENT', 'ANNUAIRE', 'check') const codes = r.issues.map((i) => i.code) expect(codes).toContain('enum-value') expect(r.issues.find((i) => i.code === 'enum-value')!.message).toContain('did you mean « Actif »') expect(codes).toContain('date-format') expect(codes).toContain('boolean-format') expect(codes).toContain('actor-unknown') const fk = r.issues.filter((i) => i.code === 'fk-unresolved') expect(fk.some((i) => i.message.includes('CLIENT/CONFIGURATION/TypeClient « Artisan »'))).toBe(true) expect(fk.some((i) => i.message.includes('CLIENT/CONFIGURATION/Segment « Montagne »'))).toBe(true) expect(fk.some((i) => i.set === 'JT-002' && i.message.includes('CLIENT/ANNUAIRE/Client « Nulle part »'))).toBe(true) expect(codes).toContain('computed-column') expect(codes).toContain('duplicate-key') expect(codes).toContain('unique-duplicate') expect(r.status).toBe('issues') expect(r.totals.unresolvedRefs).toBe(4) expect(r.derived).toBeNull() }) it('a Flow status no row carries is a warning (DM-032 leg)', () => { write('CLIENT/ANNUAIRE/jeu-de-test.md', ANNUAIRE_TEST_DATA.replace('| Atelier Lausanne | Garage du Léman Sàrl | PME | Services | Archivé |', '| Atelier Lausanne | Garage du Léman Sàrl | PME | Services | Actif |')) try { const r = run('CLIENT', 'ANNUAIRE', 'check') const w = r.issues.find((i) => i.code === 'flow-status-missing') expect(w?.severity).toBe('warn') expect(w?.message).toContain('« Archivé »') } finally { write('CLIENT/ANNUAIRE/jeu-de-test.md', ANNUAIRE_TEST_DATA) } }) it('a block on a reference table that carries Valeurs initiales is refused; an unknown entity too', () => { write('CLIENT/CONFIGURATION/jeu-de-test.md', CONFIG_TEST_DATA + ['### JT-002 — TypeClient (ENT-001)', '- **Clé** : `Label`', '| Label |', '|---|', '| Autre |', '', '### JT-003 — Fantome', '- **Clé** : `X`', '| X |', '|---|', '| 1 |', ''].join('\n')) try { const r = run('CLIENT', 'CONFIGURATION', 'check') expect(r.issues.some((i) => i.code === 'duplicate-initial-values' && i.set === 'JT-002')).toBe(true) expect(r.issues.some((i) => i.code === 'unknown-entity' && i.set === 'JT-003')).toBe(true) } finally { write('CLIENT/CONFIGURATION/jeu-de-test.md', CONFIG_TEST_DATA) } }) it('a same-module cycle is refused: no insertion order exists', () => { const cyclic = ANNUAIRE_ENTITE.replace( '- **Index** : (Statut).', ' - Client *→1 Contact — FK ContactPrincipalId, scope same-module, onDelete restrict\n- **Index** : (Statut).', ) write('CLIENT/ANNUAIRE/entité.md', cyclic) write('CLIENT/ANNUAIRE/jeu-de-test.md', ANNUAIRE_TEST_DATA.replace('| Nom | Organisation |', '| Nom | ContactPrincipal | Organisation |').replace('| Direction Marketing | ACME SA |', '| Direction Marketing | claire.rochat@exemple.ch | ACME SA |').replace('| Atelier Lausanne | Garage', '| Atelier Lausanne | | Garage').replace('| Bureau Sion | Alpes', '| Bureau Sion | | Alpes').replace('|---|---|---|---|---|---|---|---|---|---|', '|---|---|---|---|---|---|---|---|---|---|---|')) try { const r = run('CLIENT', 'ANNUAIRE', 'derive') const cycle = r.issues.find((i) => i.code === 'cycle') expect(cycle?.severity).toBe('err') expect(cycle?.message).toContain('JT-001') expect(cycle?.message).toContain('JT-002') } finally { write('CLIENT/ANNUAIRE/entité.md', ANNUAIRE_ENTITE) write('CLIENT/ANNUAIRE/jeu-de-test.md', ANNUAIRE_TEST_DATA) } }) it('a Core target the seed cannot resolve (neither User nor TenantOrganisation) is a warning, the cell still travels', () => { write('CLIENT/ANNUAIRE/jeu-de-test.md', ANNUAIRE_TEST_DATA .replace('| Nom | Organisation |', '| Nom | Groupe | Organisation |') .replace('|---|---|---|---|---|---|---|---|---|---|', '|---|---|---|---|---|---|---|---|---|---|---|') .replace('| Direction Marketing | ACME SA |', '| Direction Marketing | Équipe Nord | ACME SA |') .replace('| Atelier Lausanne | Garage', '| Atelier Lausanne | | Garage') .replace('| Bureau Sion | Alpes', '| Bureau Sion | | Alpes')) try { const r = run('CLIENT', 'ANNUAIRE', 'derive') const w = r.issues.find((i) => i.code === 'core-unsupported') expect(w?.severity).toBe('warn') expect(w?.message).toContain('Core Group') expect(r.derived!.sets[0]!.rows[0]!['GroupeId']).toEqual({ core: 'Group', by: 'Name', value: 'Équipe Nord' }) expect(r.derived!.sets[0]!.rows[1]!['GroupeId']).toBeNull() } finally { write('CLIENT/ANNUAIRE/jeu-de-test.md', ANNUAIRE_TEST_DATA) } }) it('a reference-date mismatch with a cited module is said', () => { write('CLIENT/CONFIGURATION/jeu-de-test.md', CONFIG_TEST_DATA.replace('2026-09-15', '2026-01-01')) try { const r = run('CLIENT', 'ANNUAIRE', 'check') expect(r.issues.some((i) => i.code === 'reference-date-mismatch' && i.message.includes('CLIENT/CONFIGURATION'))).toBe(true) } finally { write('CLIENT/CONFIGURATION/jeu-de-test.md', CONFIG_TEST_DATA) } }) }) describe('derive-test-data — absence and prerequisites', () => { it('an absent jeu-de-test.md is a RESULT, not an error', () => { rmSync(join(root, 'CLIENT', 'CONFIGURATION', 'jeu-de-test.md')) try { const r = run('CLIENT', 'CONFIGURATION', 'derive') expect(r.status).toBe('absent') expect(r.derived).toBeNull() expect(r.scopeNotes[0]).toContain('/ba-create-test-data') // And the CITING module now says the owner provides no such row (the dataset half is gone; Valeurs initiales still resolve). const citing = run('CLIENT', 'ANNUAIRE', 'check') expect(citing.issues.some((i) => i.code === 'fk-unresolved' && i.message.includes('Segment « Industrie »'))).toBe(true) expect(citing.issues.some((i) => i.code === 'fk-unresolved' && i.message.includes('TypeClient « Grand compte »'))).toBe(false) } finally { write('CLIENT/CONFIGURATION/jeu-de-test.md', CONFIG_TEST_DATA) } }) it('validateSpec gates the module folder and its entité.md, never the dataset', () => { expect(validateSpec({ baRoot: root, app: 'CLIENT', module: 'ANNUAIRE' }).valid).toBe(true) const missing = validateSpec({ baRoot: root, app: 'CLIENT', module: 'NOPE' }) expect(missing.valid).toBe(false) expect(missing.errors[0]).toContain('Module folder not found') mkdirSync(join(root, 'CLIENT', 'VIDE'), { recursive: true }) const noModel = validateSpec({ baRoot: root, app: 'CLIENT', module: 'VIDE' }) expect(noModel.valid).toBe(false) expect(noModel.errors[0]).toContain('/ba-create-data-model') expect(validateSpec({ baRoot: root, app: 'CLIENT', module: 'ANNUAIRE', mode: 'backfill' }).valid).toBe(false) }) })