import { describe, expect, it } from 'vitest' import { parseAcFromUseCaseMd } from '../parse-ac.js' describe('parse-ac — happy path', () => { it('parses one UC with three ACs (em-dash separator)', () => { const md = ` # Cas d'usage — CRM / PIPELINE / opportunites ### UC-CRM-PIPELINE-OPPORTUNITES-001 — Créer une opportunité - **Acteur principal** : BA-001-AC-001 (Commercial) - **Préconditions** : le prospect existe. - **Flux principal** : 1. Le commercial ouvre le formulaire. - **Postconditions** : opportunité visible. - **Acceptance Criteria** : - [ ] AC-01 — POST /api/opportunities avec un body valide renvoie 201 + l'identifiant créé. - [ ] AC-02 — POST avec un montant négatif renvoie 400 et le code d'erreur \`amount-positive\`. - [ ] AC-03 — La nouvelle opportunité apparaît dans GET /api/opportunities. ` const { ucs, warnings } = parseAcFromUseCaseMd(md, 'opportunites/use-case.md') expect(ucs).toHaveLength(1) const uc = ucs[0]! expect(uc.ucCode).toBe('UC-CRM-PIPELINE-OPPORTUNITES-001') expect(uc.title).toBe('Créer une opportunité') expect(uc.sectionCode).toBe('OPPORTUNITES') expect(uc.sectionFolder).toBe('opportunites') expect(uc.acs).toHaveLength(3) expect(uc.acs[0]!.localId).toBe('AC-01') expect(uc.acs[0]!.text).toContain('POST /api/opportunities') expect(uc.acs[1]!.localId).toBe('AC-02') expect(uc.acs[2]!.localId).toBe('AC-03') expect(warnings).toEqual([]) }) it('parses multiple UCs in the same file', () => { const md = `### UC-CRM-PIPELINE-OPPORTUNITES-001 — Créer - **Acteur principal** : X - **Acceptance Criteria** : - [ ] AC-01 — Texte 1. ### UC-CRM-PIPELINE-OPPORTUNITES-002 — Modifier - **Acteur principal** : Y - **Acceptance Criteria** : - [ ] AC-01 — Texte 2. - [ ] AC-02 — Texte 3. ` const { ucs } = parseAcFromUseCaseMd(md, 'opportunites/use-case.md') expect(ucs).toHaveLength(2) expect(ucs[0]!.acs).toHaveLength(1) expect(ucs[1]!.acs).toHaveLength(2) }) it('tolerates ASCII dash as separator (graceful fallback)', () => { const md = `### UC-CRM-PIPELINE-OPP-001 - Plain dash title - **Acceptance Criteria** : - [ ] AC-01 - Plain dash AC text. ` const { ucs } = parseAcFromUseCaseMd(md, 'opp/use-case.md') expect(ucs).toHaveLength(1) expect(ucs[0]!.title).toBe('Plain dash title') expect(ucs[0]!.acs).toHaveLength(1) expect(ucs[0]!.acs[0]!.text).toBe('Plain dash AC text.') }) it('handles tabbed indentation under the AC field', () => { const md = `### UC-CRM-PIPELINE-OPP-001 — Title - **Acceptance Criteria** : \t- [ ] AC-01 — Tab-indented AC. ` const { ucs } = parseAcFromUseCaseMd(md, 'opp/use-case.md') expect(ucs[0]!.acs).toHaveLength(1) expect(ucs[0]!.acs[0]!.text).toBe('Tab-indented AC.') }) it('handles checked boxes ([x] and [X])', () => { const md = `### UC-CRM-PIPELINE-OPP-001 — Title - **Acceptance Criteria** : - [x] AC-01 — Lower-x checkbox. - [X] AC-02 — Upper-X checkbox. - [ ] AC-03 — Empty checkbox. ` const { ucs } = parseAcFromUseCaseMd(md, 'opp/use-case.md') expect(ucs[0]!.acs).toHaveLength(3) }) }) describe('parse-ac — error / warning paths', () => { it('warns when a UC has no Acceptance Criteria field', () => { const md = `### UC-CRM-PIPELINE-OPP-001 — Title - **Acteur principal** : X - **Postconditions** : Y ` const { ucs, warnings } = parseAcFromUseCaseMd(md, 'opp/use-case.md') expect(ucs).toHaveLength(1) expect(ucs[0]!.acs).toEqual([]) expect(warnings.some(w => w.includes('no **Acceptance Criteria**'))).toBe(true) }) it('warns on duplicate AC ids within one UC', () => { const md = `### UC-CRM-PIPELINE-OPP-001 — Title - **Acceptance Criteria** : - [ ] AC-01 — First. - [ ] AC-01 — Duplicate. ` const { ucs, warnings } = parseAcFromUseCaseMd(md, 'opp/use-case.md') expect(ucs[0]!.acs).toHaveLength(1) // duplicate dropped expect(warnings.some(w => w.includes('duplicates AC-01'))).toBe(true) }) it('warns on malformed AC bullet (missing AC-NN id)', () => { const md = `### UC-CRM-PIPELINE-OPP-001 — Title - **Acceptance Criteria** : - [ ] no-id — bad bullet. ` const { ucs, warnings } = parseAcFromUseCaseMd(md, 'opp/use-case.md') expect(ucs[0]!.acs).toEqual([]) expect(warnings.some(w => w.includes('malformed AC bullet'))).toBe(true) }) it('warns when section folder in path does not match UC code', () => { const md = `### UC-CRM-PIPELINE-OPPORTUNITES-001 — Title - **Acceptance Criteria** : - [ ] AC-01 — text. ` const { warnings } = parseAcFromUseCaseMd(md, 'wrong-folder/use-case.md') expect(warnings.some(w => w.includes('section "opportunites"') && w.includes('"wrong-folder"'))).toBe(true) }) it('does NOT bleed ACs across UC headings', () => { const md = `### UC-CRM-PIPELINE-OPP-001 — A - **Acceptance Criteria** : - [ ] AC-01 — Belongs to A. ### UC-CRM-PIPELINE-OPP-002 — B - **Acteur principal** : X ` const { ucs } = parseAcFromUseCaseMd(md, 'opp/use-case.md') expect(ucs).toHaveLength(2) expect(ucs[0]!.acs).toHaveLength(1) expect(ucs[1]!.acs).toHaveLength(0) }) it('does NOT keep AC bullets after the next field of the same UC', () => { const md = `### UC-CRM-PIPELINE-OPP-001 — A - **Acceptance Criteria** : - [ ] AC-01 — Real AC. - **Règles liées** : BR-001 - [ ] AC-02 — Stray bullet under another field. ` const { ucs } = parseAcFromUseCaseMd(md, 'opp/use-case.md') expect(ucs[0]!.acs).toHaveLength(1) expect(ucs[0]!.acs[0]!.localId).toBe('AC-01') }) it('returns empty result on a file with no UC heading', () => { const md = `# Rollup > Voir les cas d'usage par section. ` const { ucs, warnings } = parseAcFromUseCaseMd(md, 'use-case.md') expect(ucs).toEqual([]) expect(warnings).toEqual([]) }) }) describe('parse-ac — robustness', () => { it('rejects an AC id with only 1 digit', () => { const md = `### UC-CRM-PIPELINE-OPP-001 — Title - **Acceptance Criteria** : - [ ] AC-1 — Wrong id (single digit). ` const { ucs, warnings } = parseAcFromUseCaseMd(md, 'opp/use-case.md') expect(ucs[0]!.acs).toEqual([]) expect(warnings.some(w => w.includes('malformed AC bullet'))).toBe(true) }) it('rejects an AC id with 3 digits', () => { const md = `### UC-CRM-PIPELINE-OPP-001 — Title - **Acceptance Criteria** : - [ ] AC-001 — Wrong id (3 digits). ` const { ucs } = parseAcFromUseCaseMd(md, 'opp/use-case.md') expect(ucs[0]!.acs).toEqual([]) }) it('handles CRLF line endings', () => { const md = `### UC-CRM-PIPELINE-OPP-001 — Title\r\n- **Acceptance Criteria** :\r\n - [ ] AC-01 — CRLF text.\r\n` const { ucs } = parseAcFromUseCaseMd(md, 'opp/use-case.md') expect(ucs[0]!.acs).toHaveLength(1) expect(ucs[0]!.acs[0]!.text).toBe('CRLF text.') }) }) // ── Hardening (audit chantier 0.2 — no AC lost in silence) ───────────────── describe('parse-ac hardening — column-0 bullets, 5-segment UCs, the lost channel', () => { it('a column-0 AC bullet is ACCEPTED (formatting note) — it used to vanish with no warning', () => { const md = [ '### UC-CRM-PIPELINE-OPPORTUNITES-001 — Créer', '- **Acceptance Criteria** :', ' - [ ] AC-01 — Indenté.', '- [ ] AC-02 — Colonne zéro.', '', ].join('\n') const { ucs, warnings, lost } = parseAcFromUseCaseMd(md, 'opportunites/use-case.md') expect(ucs[0]!.acs.map(a => a.localId)).toEqual(['AC-01', 'AC-02']) expect(lost).toEqual([]) expect(warnings.some(w => w.includes('column 0'))).toBe(true) }) it('a malformed bullet lands in the LOST channel (subset of warnings) — the scaffolder fails on it', () => { const md = [ '### UC-CRM-PIPELINE-OPPORTUNITES-001 — Créer', '- **Acceptance Criteria** :', ' - [ ] AC-01 — Bon.', ' - AC-02 — Sans case à cocher.', '', ].join('\n') const { ucs, warnings, lost } = parseAcFromUseCaseMd(md, 'opportunites/use-case.md') expect(ucs[0]!.acs).toHaveLength(1) expect(lost).toHaveLength(1) expect(lost[0]).toContain('malformed AC bullet') expect(warnings).toEqual(expect.arrayContaining(lost)) }) it('a duplicate AC id is a LOSS too (the second assertion is dropped from the contract)', () => { const md = [ '### UC-CRM-PIPELINE-OPPORTUNITES-001 — Créer', '- **Acceptance Criteria** :', ' - [ ] AC-01 — Première.', ' - [ ] AC-01 — Seconde, perdue.', '', ].join('\n') const { lost } = parseAcFromUseCaseMd(md, 'opportunites/use-case.md') expect(lost).toHaveLength(1) expect(lost[0]).toContain('duplicates AC-01') }) it('a 5-segment RESOURCE-level UC parses — with its resource folder accepted as parent', () => { const md = [ '### UC-CRM-PIPELINE-OPPORTUNITES-DEVIS-001 — Générer un devis', '- **Acceptance Criteria** :', ' - [ ] AC-01 — Le devis reprend les lignes.', '', ].join('\n') const { ucs, warnings } = parseAcFromUseCaseMd(md, 'opportunites/devis/use-case.md') expect(ucs).toHaveLength(1) expect(ucs[0]!.ucCode).toBe('UC-CRM-PIPELINE-OPPORTUNITES-DEVIS-001') expect(ucs[0]!.sectionCode).toBe('OPPORTUNITES') expect(ucs[0]!.acs).toHaveLength(1) // Resource folder as parent = legitimate, no folder-mismatch warning. expect(warnings.filter(w => w.includes('lives under folder'))).toEqual([]) }) it('a 6-segment code PARSES (multi-word kebab sections span segments — the old cap rejected them)', () => { const md = '### UC-A-B-C-D-E-001 — Profond mais légitime\n- **Acceptance Criteria** :\n - [ ] AC-01 — X.\n' const { ucs, lost } = parseAcFromUseCaseMd(md, 'x/use-case.md') expect(ucs).toHaveLength(1) expect(ucs[0]!.ucCode).toBe('UC-A-B-C-D-E-001') expect(ucs[0]!.acs).toHaveLength(1) expect(lost).toEqual([]) }) }) // ── Corpus lexical forms (fail-open fixes a & b — ImmoHub campaign 2026-08-30) ── describe('parse-ac corpus forms — lowercase sections, multi-word kebab, thematic breaks, near-miss', () => { it('(a) a LOWERCASE section segment parses — 19/20 real modules write it that way', () => { const md = [ '### UC-DIFFUSION-CANAUX-list-001 — Lister les canaux', '- **Acceptance Criteria** :', ' - [ ] AC-01 — GET /api/canaux renvoie 200.', '', ].join('\n') const { ucs, warnings, lost } = parseAcFromUseCaseMd(md, 'list/use-case.md') expect(ucs).toHaveLength(1) expect(ucs[0]!.ucCode).toBe('UC-DIFFUSION-CANAUX-list-001') expect(ucs[0]!.sectionCode).toBe('LIST') expect(ucs[0]!.sectionFolder).toBe('list') expect(ucs[0]!.acs).toHaveLength(1) expect(warnings).toEqual([]) expect(lost).toEqual([]) }) it('(a) a MULTI-WORD kebab section resolves against the owning folder (no parts[3] hard-index)', () => { const md = [ '### UC-CRM-EXCH-exchange-history-001 — Historique des échanges', '- **Acceptance Criteria** :', ' - [ ] AC-01 — La liste est triée.', '', ].join('\n') const { ucs, warnings } = parseAcFromUseCaseMd(md, 'exchange-history/use-case.md') expect(ucs).toHaveLength(1) expect(ucs[0]!.sectionFolder).toBe('exchange-history') expect(ucs[0]!.sectionCode).toBe('EXCHANGE-HISTORY') expect(warnings.filter((w) => w.includes('lives under folder'))).toEqual([]) }) it('(a) multi-word section + resource tail, file under the SECTION folder', () => { const md = [ '### UC-CRM-EXCH-exchange-history-drafts-001 — Brouillons', '- **Acceptance Criteria** :', ' - [ ] AC-01 — X.', '', ].join('\n') const { ucs, warnings } = parseAcFromUseCaseMd(md, 'exchange-history/use-case.md') expect(ucs[0]!.sectionFolder).toBe('exchange-history') expect(warnings.filter((w) => w.includes('lives under folder'))).toEqual([]) }) it('(b) a `---` separator between two UCs is a BLOCK BREAK, never a lost assertion', () => { const md = [ '### UC-CRM-PIPELINE-opportunites-001 — Créer', '- **Acceptance Criteria** :', ' - [ ] AC-01 — Créée.', '', '---', '', '### UC-CRM-PIPELINE-opportunites-002 — Modifier', '- **Acceptance Criteria** :', ' - [ ] AC-01 — Modifiée.', '', ].join('\n') const { ucs, lost } = parseAcFromUseCaseMd(md, 'opportunites/use-case.md') expect(ucs).toHaveLength(2) expect(ucs[0]!.acs).toHaveLength(1) expect(ucs[1]!.acs).toHaveLength(1) expect(lost).toEqual([]) }) it('(b) spaced and starred thematic breaks (`- - -`, `***`, `___`) are tolerated too', () => { const md = [ '### UC-CRM-PIPELINE-opp-001 — A', '- **Acceptance Criteria** :', ' - [ ] AC-01 — Un.', '- - -', '***', '___', '', ].join('\n') const { ucs, lost } = parseAcFromUseCaseMd(md, 'opp/use-case.md') expect(ucs[0]!.acs).toHaveLength(1) expect(lost).toEqual([]) }) it('a NEAR-MISS heading (`### UC-…` that does not parse) is LOUD — loss-class, never silence', () => { const md = [ '### UC-CRM-PIPELINE-OPP — Numéro manquant', '- **Acceptance Criteria** :', ' - [ ] AC-01 — Perdue sans le canal near-miss.', '', ].join('\n') const { ucs, warnings, lost } = parseAcFromUseCaseMd(md, 'opp/use-case.md') expect(ucs).toHaveLength(0) expect(lost).toHaveLength(1) expect(lost[0]).toContain('does not parse') expect(warnings).toEqual(expect.arrayContaining(lost)) }) it('a rejected UC heading TERMINATES the previous UC block — its ACs are never mis-attributed', () => { const md = [ '### UC-CRM-PIPELINE-opp-001 — A', '- **Acceptance Criteria** :', ' - [ ] AC-01 — Appartient à A.', '', '### UC-CRM-PIPELINE-OPP — Cassé', '- **Acceptance Criteria** :', ' - [ ] AC-01 — Aurait fui vers A avant le fix.', '', ].join('\n') const { ucs, lost } = parseAcFromUseCaseMd(md, 'opp/use-case.md') expect(ucs).toHaveLength(1) expect(ucs[0]!.acs).toHaveLength(1) expect(lost).toHaveLength(1) }) })