import { describe, expect, it } from 'vitest' import { parseEntityDoc, type BaEntity } from '../ba-entities.js' import { findDecidedCodeNearMisses, parseDecidedCodeLine, } from '../code-pattern-grammar.js' import { citationsFor, classifyReferentialCode, initialValuesSources, isReferentialCodeError, normalizeLabelKey, seededCodeValues, type CitationSource, } from '../ba-referential-codes.js' // The fixtures run through the PRODUCTION parser — never hand-built objects. const DOC = ` # Modèle de données — SALES / CATALOG ### ENT-001 — MotifRetrait (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 motifs fixés par le métier : | Code | Label | IsActive | |------|-------|----------| | FIN_COMMERCIALISATION | Fin de commercialisation | true | | REMPLACEMENT | Remplacement | true | ### ENT-002 — NatureArticle (lookup) - **Préfixe table** : \`catalog_\` | Attribut | Type | Contraintes | Calculé | |----------|------|-------------|---------| | Id | Guid | PK | — | | Code | string(50) | unique | — | | Label | string(150) | requis | — | - **Index** : (Code) unique. - **Valeurs initiales** : clé \`Code\` — les natures : | Code | Label | |------|-------| | PIECE | Pièce détachée | | SERVICE | Prestation | ### ENT-003 — UniteVente (lookup) - **Code décidé** : l'unité imprimée sur le bon de livraison — décision utilisateur du 2026-09-01 | Attribut | Type | Contraintes | Calculé | |----------|------|-------------|---------| | Id | Guid | PK | — | | Code | string(10) | unique | — | | Label | string(80) | requis | — | ### ENT-004 — TypeExpression (lookup) - **Code décidé** : le code du type, sans date | Attribut | Type | Contraintes | Calculé | |----------|------|-------------|---------| | Id | Guid | PK | — | | Code | string(50) | unique | — | | Label | string(150) | requis | — | ### ENT-005 — StatutArticle (lookup) - **Code pattern** : \`ST-{SEQ:3}\` — scope tenant, reset none, gapless. | Attribut | Type | Contraintes | Calculé | |----------|------|-------------|---------| | Id | Guid | PK | — | | Code | string(50) | unique | — | | Label | string(150) | requis | — | ### ENT-006 — NatureGrille (lookup) | Attribut | Type | Contraintes | Calculé | |----------|------|-------------|---------| | Id | Guid | PK | — | | Label | string(150) | requis | — | | IsActive | bool | défaut true | — | ### ENT-007 — ObjetTracable (lookup) | Attribut | Type | Contraintes | Calculé | |----------|------|-------------|---------| | Id | Guid | PK | — | | Code | string(50) | unique | — | | Label | string(150) | requis | — | - **Index** : (TenantId, Code) unique. - **Valeurs initiales** : clé \`Code\` — les valeurs : | Code | Seuil | |------|-------| | TRACE_LOT | 1 | ### ENT-008 — UnitePalier (lookup) | Attribut | Type | Contraintes | Calculé | |----------|------|-------------|---------| | Id | Guid | PK | — | | Code | string(50) | unique | — | | Seuil | int | ≥ 0 | — | - **Valeurs initiales** : clé \`Code\` — les valeurs : | Code | Seuil | |------|-------| | PALIER_Q | 1 | ### ENT-009 — MotifSuspension (lookup) | Attribut | Type | Contraintes | Calculé | |----------|------|-------------|---------| | Id | Guid | PK | — | | Code | string(50) | unique | — | | Seuil | int | ≥ 0 | — | - **Affichage** : Code — le champ qui nomme la ligne. - **Valeurs initiales** : clé \`Code\` — les valeurs : | Code | Seuil | |------|-------| | SUSP_RUPTURE | 1 | ### ENT-012 — LibelleAmbigu (lookup) | Attribut | Type | Contraintes | Calculé | |----------|------|-------------|---------| | Id | Guid | PK | — | | Code | string(50) | unique | — | | Label | string(150) | requis | — | - **Valeurs initiales** : clé \`Code\` — deux libellés qui se confondent : | Code | Label | |------|-------| | AMB_A | Fin de commercialisation | | AMB_B | fin de commercialisation | ### ENT-011 — NatureGrilleCodee (lookup) | Attribut | Type | Contraintes | Calculé | |----------|------|-------------|---------| | Id | Guid | PK | — | | Code | string(50) | unique | — | | Label | string(150) | requis | — | ### ENT-010 — Article (agrégat racine) | Attribut | Type | Contraintes | Calculé | |----------|------|-------------|---------| | Id | Guid | PK | — | | Code | string(50) | unique | — | | Label | string(150) | requis | — | ` const { entities } = parseEntityDoc(DOC, 'SALES/CATALOG') const byName = new Map(entities.map((e) => [e.name, e])) const ent = (name: string): BaEntity => { const e = byName.get(name) if (!e) throw new Error(`fixture entity ${name} missing`) return e } const verdict = (name: string, sources: CitationSource[] = []) => { const e = ent(name) return classifyReferentialCode(e, citationsFor(e, sources)) } describe('the decided-code line — a decision carries a DATE', () => { it('parses the canonical FR bullet', () => { expect(parseDecidedCodeLine("l'unité imprimée — décision utilisateur du 2026-09-01")).toEqual({ names: "l'unité imprimée", date: '2026-09-01', }) }) it('parses the EN alias', () => { expect(parseDecidedCodeLine('the printed unit — user decision of 2026-09-01')).toEqual({ names: 'the printed unit', date: '2026-09-01', }) }) it('refuses a bullet with no date — a « decision » without one is a habit', () => { expect(parseDecidedCodeLine('le code du type, sans date')).toBeNull() }) it('refuses a date-SHAPED string that is not a real day', () => { expect(parseDecidedCodeLine('le code — décision utilisateur du 2026-13-45')).toBeNull() }) it('refuses a decision that names nothing', () => { expect(parseDecidedCodeLine('— décision utilisateur du 2026-09-01')).toBeNull() }) }) describe('near-misses — a line that MENTIONS the decision without parsing is an err', () => { it('flags the table-cell shape (the incident shape)', () => { const hits = findDecidedCodeNearMisses( '### ENT-001 — Motif (lookup)\n| Code décidé | oui, le 2026-09-01 |\n', ) expect(hits).toHaveLength(1) expect(hits[0]).toMatchObject({ entity: 'Motif', shape: 'table-cell' }) }) it('flags a well-formed bullet whose date is missing', () => { const hits = findDecidedCodeNearMisses( '### ENT-004 — TypeExpression (lookup)\n- **Code décidé** : le code du type\n', ) expect(hits).toHaveLength(1) expect(hits[0]).toMatchObject({ entity: 'TypeExpression', shape: 'missing-date' }) }) it('flags an unbolded bullet', () => { const hits = findDecidedCodeNearMisses('- Code décidé : le code — décision utilisateur du 2026-09-01\n') expect(hits[0]?.shape).toBe('unbolded-bullet') }) it('never flags the canonical bullet', () => { expect( findDecidedCodeNearMisses('- **Code décidé** : le code — décision utilisateur du 2026-09-01\n'), ).toEqual([]) }) it('never flags a stated ABSENCE — precision over recall', () => { expect(findDecidedCodeNearMisses('- **Note** : pas de code décidé sur ce référentiel.\n')).toEqual([]) }) }) describe('the verdict — the ONE definition DM-022, DM-012 and the backfill share', () => { it('a lookup with a code, no decision and no citation is a reprise', () => { const v = verdict('NatureArticle') expect(v.status).toBe('reprise') expect(v.labelAttribute).toBe('Label') }) it('a lookup with no code at all is clean', () => { expect(verdict('NatureGrille').status).toBe('clean') }) it('a non-lookup is out of scope, code or not', () => { expect(verdict('Article').status).toBe('not-reference') }) it('a DATED decision is untouchable', () => { const v = verdict('UniteVente') expect(v.status).toBe('decided') expect(v.decision?.date).toBe('2026-09-01') expect(isReferentialCodeError(v.status)).toBe(false) }) it('a decision line that does not parse is a near-miss, never a silence', () => { expect(verdict('TypeExpression').status).toBe('near-miss') }) it('an ALLOCATED code on a reference table is an err nothing caught before', () => { // DM-017 check 1 only asks for a numbering RULE and never tests the // classification — a lookup with a Code pattern passed every gate. expect(verdict('StatutArticle').status).toBe('allocated') }) it('a composite index on `code` blocks — a mutilated index is worse', () => { const v = verdict('ObjetTracable') expect(v.status).toBe('blocked') expect(v.blockedBy).toBe('composite-index') }) it('no label-family attribute blocks — the row would be left unnamed', () => { const v = verdict('UnitePalier') expect(v.status).toBe('blocked') expect(v.blockedBy).toBe('no-label-attribute') }) it('`**Affichage** : Code` with nothing to move it to blocks, and says so', () => { const v = verdict('MotifSuspension') expect(v.status).toBe('blocked') expect(v.blockedBy).toBe('display-anchored-on-code') }) }) describe('an empty citation list only means something when something WAS searched', () => { it('a code with NO seeded value blocks — the inventory concluded nothing', () => { // The trap: reporting « no citation, nothing depends on this code » on a // table whose rows the BA left in prose. Nothing was searched, so nothing // was cleared, and the backfill must not strip on that basis. const v = verdict('NatureGrilleCodee') expect(v.status).toBe('blocked') expect(v.blockedBy).toBe('no-seeded-codes') expect(v.searchableCodes).toBe(0) }) it('a label that does not distinguish the rows blocks — scaffold-seed refuses a duplicate key', () => { // Moving the seed key onto the label is the whole point of the reprise; // doing it when two labels collapse to one key hands the next generation // a spec scaffold-seed rejects outright. const v = verdict('LibelleAmbigu') expect(v.status).toBe('blocked') expect(v.blockedBy).toBe('ambiguous-label-key') expect(v.detail).toContain('fin de commercialisation') }) it('a reprise states its BASIS — how many values were searched', () => { const v = verdict('NatureArticle') expect(v.status).toBe('reprise') expect(v.searchableCodes).toBe(2) expect(v.detail).toContain('2 valeur(s) de code cherchée(s)') }) }) describe('citations — what breaks if the code goes', () => { const rules: CitationSource = { kind: 'rules', where: 'BR-003', text: 'Le motif doit appartenir à {FIN_COMMERCIALISATION, REMPLACEMENT, PRESTATION_ABANDONNEE}.', } it('a cited code blocks, and the verdict names where', () => { const v = verdict('MotifRetrait', [rules]) expect(v.status).toBe('blocked') expect(v.blockedBy).toBe('cited') expect(v.citations.map((c) => c.value).sort()).toEqual(['FIN_COMMERCIALISATION', 'REMPLACEMENT']) expect(v.detail).toContain('BR-003') }) it('the excerpt is VERBATIM, accents included', () => { const v = verdict('MotifRetrait', [rules]) expect(v.citations[0]?.excerpt).toContain('Le motif doit appartenir à') }) it('the PRD is a source in its own right — an API payload cites by code with no rule behind it', () => { // The load-bearing case: without this source the inventory under-declares // and the backfill removes a code the contract depends on. const prd: CitationSource = { kind: 'prd', where: 'prd.api.md', text: 'POST /articles/{id}/retirer — payload { ReasonCode: "REMPLACEMENT" }', } const v = verdict('MotifRetrait', [prd]) expect(v.status).toBe('blocked') expect(v.citations).toHaveLength(1) expect(v.citations[0]?.kind).toBe('prd') }) it('a LABEL citation does not block — only a code citation binds the contract', () => { const labelOnly: CitationSource = { kind: 'rules', where: 'BR-009', text: 'Afficher « Fin de commercialisation » dans la liste.', } expect(verdict('MotifRetrait', [labelOnly]).status).toBe('reprise') }) it('matches whole words only — PIECE never matches PIECES_DETACHEES', () => { const near: CitationSource = { kind: 'rules', where: 'BR-011', text: 'voir PIECES_DETACHEES' } expect(verdict('NatureArticle', [near]).status).toBe('reprise') }) it('an entity never cites itself through its own Valeurs initiales', () => { const sources = initialValuesSources(entities, 'MotifRetrait') expect(sources.every((s) => !s.where.startsWith('MotifRetrait'))).toBe(true) expect(verdict('MotifRetrait', sources).status).toBe('reprise') }) it('a sibling table referencing another by its key IS a citation', () => { const sources = initialValuesSources(entities, 'NatureArticle') const withRef: CitationSource[] = [ ...sources, { kind: 'initial-values', where: 'GrilleTarif', text: 'standard | PIECE | 10' }, ] expect(verdict('NatureArticle', withRef).status).toBe('blocked') }) it('reads the code column of the seeded rows, not the key column name', () => { expect(seededCodeValues(ent('MotifRetrait'))).toEqual(['FIN_COMMERCIALISATION', 'REMPLACEMENT']) expect(seededCodeValues(ent('NatureGrille'))).toEqual([]) }) }) describe('the label key — the price of dropping the code, said out loud', () => { it('normalises trim, inner spaces, case and accents', () => { expect(normalizeLabelKey(' Fin de commercialisation ')).toBe('fin de commercialisation') expect(normalizeLabelKey('Fin de commercialisation')).toBe( normalizeLabelKey('FIN DE COMMERCIALISATION'), ) expect(normalizeLabelKey('Pièce détachée')).toBe('piece detachee') }) })