/** * The SALES/CATALOG shape, reproduced on disk. * * Measured on a real module: 11 reference tables, 11 `Code` columns, not one * written decision. Nine of them have their values cited somewhere — and TWO * of those nine are cited by NO rule and NO acceptance criterion: only the API * payload designates them, by `ReasonCode`. That is why the inventory reads * four sources and not two. * * The load-bearing assertion of this file: the backfill takes exactly TWO * entities. If it takes more, the third source (prd*.md / pagespecs) is not * plugged in, and a code the contract depends on is about to be dropped. */ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { deriveReferentialCodes } from '../execute.js' import { loadCitationSources } from '../sources.js' import type { EntityReport } from '../types.js' // --------------------------------------------------------------------------- // Fixture // --------------------------------------------------------------------------- interface Lookup { ent: string name: string codes: [string, string][] } const LOOKUPS: Lookup[] = [ { ent: 'ENT-001', name: 'StatutArticle', codes: [['BROUILLON', 'Brouillon'], ['VENDABLE', 'Vendable']] }, { ent: 'ENT-002', name: 'StatutFamille', codes: [['FAM_ACTIVE', 'Active'], ['FAM_ARCHIVEE', 'Archivée']] }, { ent: 'ENT-003', name: 'StatutGrille', codes: [['GRILLE_PROJET', 'Projet'], ['GRILLE_PUBLIEE', 'Publiée']] }, { ent: 'ENT-004', name: 'NatureGrille', codes: [['TARIF_PUBLIC', 'Tarif public'], ['TARIF_NEGOCIE', 'Tarif négocié']] }, { ent: 'ENT-005', name: 'TypeExpression', codes: [['EXPR_MONTANT', 'Montant'], ['EXPR_TAUX', 'Taux']] }, { ent: 'ENT-006', name: 'ObjetTracable', codes: [['TRACE_LOT', 'Lot'], ['TRACE_SERIE', 'Numéro de série']] }, { ent: 'ENT-007', name: 'UnitePalier', codes: [['PALIER_QUANTITE', 'Quantité'], ['PALIER_MONTANT', 'Montant HT']] }, { ent: 'ENT-008', name: 'MotifRetrait', codes: [['FIN_COMMERCIALISATION', 'Fin de commercialisation'], ['REMPLACEMENT', 'Remplacement']] }, { ent: 'ENT-009', name: 'MotifSuspension', codes: [['RUPTURE_FOURNISSEUR', 'Rupture fournisseur'], ['LITIGE_QUALITE', 'Litige qualité']] }, { ent: 'ENT-010', name: 'NatureArticle', codes: [['NAT_PIECE', 'Pièce détachée'], ['NAT_PRESTATION', 'Prestation']] }, { ent: 'ENT-011', name: 'UniteVente', codes: [['VTE_UNITE', 'Unité'], ['VTE_KILO', 'Kilogramme']] }, ] /** Entities cited by NO source — the only ones a backfill may take. */ const FREE = ['NatureArticle', 'UniteVente'] /** Cited only through the PRD payload — the third source's whole reason to be. */ const PRD_ONLY = ['MotifRetrait', 'MotifSuspension'] function entityBlock(l: Lookup): string { return [ `### ${l.ent} — ${l.name} (lookup)`, '- **Préfixe table** : `catalog_`', '', '| Attribut | Type | Contraintes | Calculé |', '|----------|------|-------------|---------|', '| Id | Guid | PK | — |', '| Code | string(50) | unique | — |', '| Label | string(150) | requis | — |', '| IsActive | bool | défaut true | — |', '', '- **Index** : (Code) unique.', `- **Valeurs initiales** : clé \`Code\` — les valeurs fixées par le métier :`, '', ' | Code | Label | IsActive |', ' |------|-------|----------|', ...l.codes.map(([c, lab]) => ` | ${c} | ${lab} | true |`), '', ].join('\n') } const ENTITE_MD = [ '', '# Modèle de données — SALES / CATALOG', '', ...LOOKUPS.map(entityBlock), ].join('\n') const REGLES_MD = ` # Règles métier — SALES / CATALOG ### BR-001 — Cycle de vie d'une famille - **Type** : workflow - **Sévérité** : err - **Portée** : SALES / CATALOG - **Condition** : QUAND une famille passe de FAM_ACTIVE à FAM_ARCHIVEE ALORS ses articles sont masqués. - **Cas valides** : FAM_ACTIVE puis FAM_ARCHIVEE. - **Cas invalides** : FAM_ARCHIVEE sans motif. ### BR-002 — Publication d'une grille - **Type** : workflow - **Sévérité** : err - **Portée** : SALES / CATALOG - **Condition** : QUAND une grille GRILLE_PROJET est publiée ALORS elle devient GRILLE_PUBLIEE. - **Cas valides** : une grille TARIF_PUBLIC publiée ; une grille TARIF_NEGOCIE publiée. - **Cas invalides** : publication sans palier. ### BR-003 — Palier de remise - **Type** : validation - **Sévérité** : err - **Portée** : SALES / CATALOG - **Condition** : QUAND l'unité de palier est PALIER_QUANTITE ALORS le seuil est entier. - **Cas valides** : seuil 10 en PALIER_QUANTITE. - **Cas invalides** : seuil 10,5. ` const USE_CASE_MD = `### UC-SALES-CATALOG-catalogue-001 — Publier un article - **Acteur principal** : BA-001-AC-001 (Gestionnaire catalogue) - **Préconditions** : l'article existe. - **Flux principal** : 1. Le gestionnaire ouvre la fiche. 2. Il publie l'article. - **Postconditions** : l'article est vendable. - **Acceptance Criteria** : - [ ] AC-01 — un article BROUILLON passe à VENDABLE après publication. - [ ] AC-02 — une expression EXPR_MONTANT accepte deux décimales. - [ ] AC-03 — un objet TRACE_LOT exige un numéro de lot. - [ ] AC-04 — un palier PALIER_MONTANT s'exprime en euros. ` const PRD_API_MD = `# Phase: API — SALES / CATALOG ## Endpoints - POST /api/sales/catalog/articles/{id}/retirer — payload \`{ ReasonCode: "REMPLACEMENT", Comment: string }\` - POST /api/sales/catalog/articles/{id}/suspendre — payload \`{ ReasonCode: "RUPTURE_FOURNISSEUR" }\` ` let baRoot = '' let moduleDir = '' function writeFixture(opts: { withPrd: boolean }): void { mkdirSync(join(moduleDir, 'catalogue'), { recursive: true }) writeFileSync(join(moduleDir, 'entité.md'), ENTITE_MD, 'utf8') writeFileSync(join(moduleDir, 'règles-métier.md'), REGLES_MD, 'utf8') writeFileSync(join(moduleDir, 'catalogue', 'use-case.md'), USE_CASE_MD, 'utf8') if (opts.withPrd) writeFileSync(join(moduleDir, 'prd.api.md'), PRD_API_MD, 'utf8') } function run(mode: 'check' | 'backfill') { const loaded = loadCitationSources(baRoot, 'SALES', 'CATALOG') return deriveReferentialCodes({ app: 'SALES', module: 'CATALOG', mode, entiteMd: readFileSync(join(moduleDir, 'entité.md'), 'utf8'), sources: loaded.sources, coverage: loaded.coverage, warnings: loaded.warnings, }) } const byName = (entities: EntityReport[]): Map => new Map(entities.map((e) => [e.entity, e])) beforeEach(() => { baRoot = mkdtempSync(join(tmpdir(), 'ss-refcodes-')) moduleDir = join(baRoot, 'SALES', 'CATALOG') }) afterEach(() => { rmSync(baRoot, { recursive: true, force: true }) }) // --------------------------------------------------------------------------- describe('SALES/CATALOG — the measured shape', () => { beforeEach(() => writeFixture({ withPrd: true })) it('reads all four sources, and says the PRD was there', () => { const { report } = run('check') expect(report.coverage).toMatchObject({ rulesDocs: 1, useCaseDocs: 1, prdPresent: true, }) expect(report.coverage.prdFiles).toBeGreaterThan(0) }) it('check reports 11 err — 11 reference tables, 11 codes, 0 decision', () => { const { report } = run('check') expect(report.totals.references).toBe(11) expect(report.totals.errors).toBe(11) expect(report.totals.decided).toBe(0) }) it('9 tables have at least one citation, 2 have none', () => { const { report } = run('check') const cited = report.entities.filter((e) => e.citations.length > 0).map((e) => e.entity) expect(cited).toHaveLength(9) expect(cited.sort()).toEqual(LOOKUPS.map((l) => l.name).filter((n) => !FREE.includes(n)).sort()) }) it('the retirement and suspension reasons are cited by the API ALONE', () => { const { report } = run('check') for (const name of PRD_ONLY) { const e = byName(report.entities).get(name)! expect(e.citations.length).toBeGreaterThan(0) expect(e.citations.every((c) => c.kind === 'prd')).toBe(true) } }) it('check writes nothing', () => { const before = readFileSync(join(moduleDir, 'entité.md'), 'utf8') const { rewritten } = run('check') expect(rewritten).toBeNull() expect(readFileSync(join(moduleDir, 'entité.md'), 'utf8')).toBe(before) }) it('backfill takes exactly 2 entities and blocks 9 — with their citations', () => { const { report } = run('backfill') expect(report.totals.reprise).toBe(2) expect(report.totals.blocked).toBe(9) const blocked = report.entities.filter((e) => e.status === 'blocked') expect(blocked.every((e) => e.blockedBy === 'cited')).toBe(true) expect(blocked.every((e) => e.citations.length > 0)).toBe(true) expect(report.entities.filter((e) => e.applied.length > 0).map((e) => e.entity).sort()).toEqual( [...FREE].sort(), ) }) it('a blocked entity is reported and the run CONTINUES — never a global refusal', () => { const { report, rewritten } = run('backfill') expect(rewritten).not.toBeNull() // The two free entities were still taken, though nine others blocked. expect(report.totals.backfilled).toBe(2) }) }) describe('the third source — remove it and the backfill over-reaches', () => { it('without prd.api.md the inventory under-declares and 4 entities become takeable', () => { writeFixture({ withPrd: false }) const { report } = run('backfill') expect(report.coverage.prdPresent).toBe(false) // 2 genuinely free + the 2 the API alone protects. expect(report.totals.reprise).toBe(4) expect(report.entities.filter((e) => e.applied.length > 0).map((e) => e.entity).sort()).toEqual( [...FREE, ...PRD_ONLY].sort(), ) }) it('and the report SAYS the inventory is partial rather than letting it read as clean', () => { writeFixture({ withPrd: false }) const { report } = run('check') expect(report.scopeNotes.some((n) => n.includes('PARTIEL'))).toBe(true) }) }) describe('what the backfill writes', () => { beforeEach(() => writeFixture({ withPrd: true })) it('rewrites entité.md only: attribute, index, seed key, seed column, display anchor', () => { const { rewritten, report } = run('backfill') writeFileSync(join(moduleDir, 'entité.md'), rewritten!, 'utf8') const md = readFileSync(join(moduleDir, 'entité.md'), 'utf8') const block = md.slice(md.indexOf('### ENT-010'), md.indexOf('### ENT-011')) expect(block).not.toContain('| Code | string(50) | unique | — |') expect(block).not.toContain('- **Index** :') expect(block).toContain('clé `Label`') expect(block).toContain('| Label | IsActive |') expect(block).not.toContain('NAT_PIECE') expect(block).toContain('- **Affichage** : Label') const applied = byName(report.entities).get('NatureArticle')!.applied expect(applied.length).toBe(5) }) it('leaves every blocked entity byte-identical', () => { const before = readFileSync(join(moduleDir, 'entité.md'), 'utf8') const { rewritten } = run('backfill') const blockOf = (md: string): string => md.slice(md.indexOf('### ENT-008'), md.indexOf('### ENT-009')) expect(blockOf(rewritten!)).toBe(blockOf(before)) }) it('keeps the separator row tight — the diff the user reviews stays free of churn', () => { const { rewritten } = run('backfill') const block = rewritten!.slice(rewritten!.indexOf('### ENT-010'), rewritten!.indexOf('### ENT-011')) expect(block).toContain('|-------|----------|') }) it('never strips the document trailing newline', () => { const before = readFileSync(join(moduleDir, 'entité.md'), 'utf8') expect(before.endsWith(String.fromCharCode(10))).toBe(true) const { rewritten } = run('backfill') expect(rewritten!.endsWith(String.fromCharCode(10))).toBe(true) }) it('is idempotent — a second run writes nothing', () => { const first = run('backfill') writeFileSync(join(moduleDir, 'entité.md'), first.rewritten!, 'utf8') const second = run('backfill') expect(second.rewritten).toBeNull() expect(second.report.totals.backfilled).toBe(0) // The two taken entities are now simply clean. expect(second.report.totals.clean).toBe(2) }) it('after the backfill the 2 taken entities stop being errors', () => { const first = run('backfill') writeFileSync(join(moduleDir, 'entité.md'), first.rewritten!, 'utf8') expect(run('check').report.totals.errors).toBe(9) }) }) describe('a user decision, and the backfill goes silent on that table', () => { it('a dated decision line takes the entity out of the run entirely', () => { writeFixture({ withPrd: true }) const decided = ENTITE_MD.replace( '### ENT-011 — UniteVente (lookup)\n', "### ENT-011 — UniteVente (lookup)\n- **Code décidé** : l'unité imprimée sur le bon de livraison — décision utilisateur du 2026-09-01\n", ) writeFileSync(join(moduleDir, 'entité.md'), decided, 'utf8') const { report } = run('backfill') const e = byName(report.entities).get('UniteVente')! expect(e.status).toBe('decided') expect(e.applied).toEqual([]) expect(report.totals.reprise).toBe(1) expect(report.totals.errors).toBe(10) }) })