import { describe, expect, it } from 'vitest' import { parseAcFromUseCaseMd } from '../../../../../development/testing/cli/scaffold-tests-from-ac/parse-ac.js' import { deriveUcCoverage, exceptionParity, pagespecUcRefs, screenUcRefs, type PagespecSource, type ScreenSource, } from '../execute.js' // ── Fixtures ────────────────────────────────────────────────────────────── const UC_DOC = ` # Cas d'usage — CRM / PIPELINE / opportunites ### UC-CRM-PIPELINE-OPPORTUNITES-001 — Créer une opportunité - **Acteur principal** : BA-001-AC-001 (Commercial) - **Flux principal** : 1. Le commercial ouvre le formulaire. - **Acceptance Criteria** : - [ ] AC-01 — POST /api/opportunities renvoie 201. ### UC-CRM-PIPELINE-OPPORTUNITES-002 — Relancer les opportunités dormantes - **Acteur principal** : système - **Exécution** : scheduled[day], priorité high. - **Flux principal** : 1. Le système détecte les opportunités sans activité. ### UC-CRM-PIPELINE-OPPORTUNITES-003 — Valider les entrées d'heures (subfunction) - **Acteur principal** : BA-001-AC-001 (Commercial) - **Flux principal** : 1. Le système valide chaque entrée. ### UC-CRM-PIPELINE-OPPORTUNITES-004 — Analyser l'impact d'un changement - **Acteur principal** : BA-001-AC-002 (Manager) - **Flux principal** : 1. Le manager demande l'analyse. ` function parsed() { return parseAcFromUseCaseMd(UC_DOC, 'opportunites/use-case.md') } const SCREEN_MD = ` # Écrans — CRM / PIPELINE / opportunites ### SCR-CRM-PIPELINE-OPPORTUNITES-001 — Liste des opportunités (SmartListView) - **Entité** : Opportunity (ENT-001) - **Cas d'usage liés** : UC-CRM-PIPELINE-OPPORTUNITES-001 - **Actions** : - \`analyzeImpact\` — kind: api, scope: header, endpoint: impact, httpMethod: GET, permission: pipeline.opportunites.read, UC: UC-CRM-PIPELINE-OPPORTUNITES-004, label: « Analyser » ` // ── screenUcRefs — the two sanctioned channels ──────────────────────────── describe('derive-uc-coverage / screenUcRefs', () => { it('reads the « Cas d\'usage liés » field and the action UC: tag, anchored on the SCR code', () => { const refs = screenUcRefs({ relPath: 'opportunites/screen.md', md: SCREEN_MD }) expect(refs.get('UC-CRM-PIPELINE-OPPORTUNITES-001')).toEqual([ { file: 'opportunites/screen.md', channel: 'cas-d-usage-lies', anchor: 'SCR-CRM-PIPELINE-OPPORTUNITES-001' }, ]) expect(refs.get('UC-CRM-PIPELINE-OPPORTUNITES-004')).toEqual([ { file: 'opportunites/screen.md', channel: 'screen-action-uc', anchor: 'SCR-CRM-PIPELINE-OPPORTUNITES-001' }, ]) }) it('tolerates the typographic apostrophe and decomposed accents in the field name', () => { const md = "### SCR-X-Y-Z-001 — Liste\n- **Cas d’usage liés** : UC-CRM-PIPELINE-OPPORTUNITES-001\n" const refs = screenUcRefs({ relPath: 's/screen.md', md }) expect(refs.has('UC-CRM-PIPELINE-OPPORTUNITES-001')).toBe(true) }) it('NEVER counts loose prose mentions — only the two sanctioned channels', () => { const md = '### SCR-X-Y-Z-001 — Liste\nVoir aussi UC-CRM-PIPELINE-OPPORTUNITES-001 pour le contexte.\n' const refs = screenUcRefs({ relPath: 's/screen.md', md }) expect(refs.size).toBe(0) }) // ── Fix (c) — the field folds over continuation lines ──────────────────── it('(c) reads codes on CONTINUATION sub-bullets of a folded « Cas d\'usage liés » field', () => { const md = [ '### SCR-CRM-PIPELINE-OPPORTUNITES-001 — Liste (SmartListView)', "- **Cas d'usage liés** :", ' - UC-CRM-PIPELINE-OPPORTUNITES-001', ' - UC-CRM-PIPELINE-OPPORTUNITES-004', '- **Permission** : pipeline.opportunites.read', '', ].join('\n') const refs = screenUcRefs({ relPath: 'opportunites/screen.md', md }) expect(refs.get('UC-CRM-PIPELINE-OPPORTUNITES-001')?.[0]?.channel).toBe('cas-d-usage-lies') expect(refs.get('UC-CRM-PIPELINE-OPPORTUNITES-004')?.[0]?.channel).toBe('cas-d-usage-lies') }) it('(c) reads codes on an indented PLAIN continuation line; the next field bullet ends the block', () => { const md = [ '### SCR-A-B-C-001 — Liste (SmartListView)', "- **Cas d'usage liés** : UC-A-B-C-001,", ' UC-A-B-C-002', '- **Entité** : X (ENT-001)', '', ].join('\n') const refs = screenUcRefs({ relPath: 's/screen.md', md }) expect(refs.has('UC-A-B-C-001')).toBe(true) expect(refs.has('UC-A-B-C-002')).toBe(true) }) it('(c) a blank line ends the folded field — codes after it are NOT attributed', () => { const md = [ '### SCR-A-B-C-001 — Liste (SmartListView)', "- **Cas d'usage liés** :", ' - UC-A-B-C-001', '', ' - UC-A-B-C-002', '', ].join('\n') const refs = screenUcRefs({ relPath: 's/screen.md', md }) expect(refs.has('UC-A-B-C-001')).toBe(true) expect(refs.has('UC-A-B-C-002')).toBe(false) }) it('(a) a LOWERCASE section segment is read in both the SCR anchor and the UC token', () => { const md = [ '### SCR-CRM-PIPELINE-opportunites-001 — Liste (SmartListView)', "- **Cas d'usage liés** : UC-CRM-PIPELINE-opportunites-001", '', ].join('\n') const refs = screenUcRefs({ relPath: 'opportunites/screen.md', md }) expect(refs.get('UC-CRM-PIPELINE-opportunites-001')).toEqual([ { file: 'opportunites/screen.md', channel: 'cas-d-usage-lies', anchor: 'SCR-CRM-PIPELINE-opportunites-001' }, ]) }) }) // ── pagespecUcRefs ──────────────────────────────────────────────────────── describe('derive-uc-coverage / pagespecUcRefs', () => { function spec(block: object): PagespecSource { return { name: 'Opportunity.list.md', md: '```json\n' + JSON.stringify(block) + '\n```\n' } } it('reads linkedUseCases[] and actions[].ucReference', () => { const warnings: string[] = [] const refs = pagespecUcRefs( spec({ entity: 'Opportunity', linkedUseCases: ['UC-CRM-PIPELINE-OPPORTUNITES-001'], actions: [{ code: 'analyzeImpact', ucReference: 'UC-CRM-PIPELINE-OPPORTUNITES-004' }], }), warnings, ) expect(refs.get('UC-CRM-PIPELINE-OPPORTUNITES-001')).toEqual([ { file: 'pagespecs/Opportunity.list.md', channel: 'linked-use-cases' }, ]) expect(refs.get('UC-CRM-PIPELINE-OPPORTUNITES-004')).toEqual([ { file: 'pagespecs/Opportunity.list.md', channel: 'uc-reference', anchor: 'analyzeImpact' }, ]) expect(warnings).toEqual([]) }) it('a pagespec without a parseable machine block is skipped WITH a warning (never silently green)', () => { const warnings: string[] = [] const refs = pagespecUcRefs({ name: 'Broken.list.md', md: '# nothing fenced' }, warnings) expect(refs.size).toBe(0) expect(warnings.some((w) => w.includes('Broken.list.md'))).toBe(true) }) }) // ── deriveUcCoverage — the coverage matrix ──────────────────────────────── describe('derive-uc-coverage / deriveUcCoverage', () => { const screens: ScreenSource[] = [{ relPath: 'opportunites/screen.md', md: SCREEN_MD }] const pagespecs: PagespecSource[] = [ { name: 'Opportunity.list.md', md: '```json\n' + JSON.stringify({ entity: 'Opportunity', linkedUseCases: ['UC-CRM-PIPELINE-OPPORTUNITES-001'], actions: [], }) + '\n```\n', }, ] function run(overrides: Partial[0]> = {}) { const p = parsed() return deriveUcCoverage({ app: 'CRM', module: 'PIPELINE', ucs: p.ucs, ucWarnings: [], screens, pagespecs, ...overrides, }) } it('T6 closed — the uncovered user-goal UC is a COUNTED finding, not a silent pass', () => { const r = run() const byCode = new Map(r.ucs.map((u) => [u.ucCode, u])) // 001: screen (cas d'usage liés) + pagespec (linkedUseCases) → covered/covered expect(byCode.get('UC-CRM-PIPELINE-OPPORTUNITES-001')).toMatchObject({ ba: 'covered', prd: 'covered' }) // 002: scheduled → covered by the scheduled surface on both legs expect(byCode.get('UC-CRM-PIPELINE-OPPORTUNITES-002')).toMatchObject({ ba: 'scheduled', prd: 'scheduled' }) // 003: subfunction (heading parenthetical) → exempt expect(byCode.get('UC-CRM-PIPELINE-OPPORTUNITES-003')).toMatchObject({ ba: 'exempt-level', prd: 'exempt-level' }) // 004: screen action UC: tag covers BA — but NO pagespec references it → PRD leg uncovered expect(byCode.get('UC-CRM-PIPELINE-OPPORTUNITES-004')).toMatchObject({ ba: 'covered', prd: 'uncovered' }) // userGoal counts the LEVEL (the scheduled UC is a user-goal too) expect(r.totals).toMatchObject({ ucs: 4, userGoal: 3, scheduled: 1, exemptLevel: 1, uncoveredBa: 0, uncoveredPrd: 1 }) }) it('a user-goal UC referenced NOWHERE is uncovered on both legs (the phantom-UC shape)', () => { const r = run({ screens: [], pagespecs: [] }) const u = r.ucs.find((x) => x.ucCode === 'UC-CRM-PIPELINE-OPPORTUNITES-001')! expect(u.ba).toBe('uncovered') expect(u.prd).toBe('uncovered') expect(r.totals.uncoveredBa).toBe(2) // 001 + 004 expect(r.totals.uncoveredPrd).toBe(2) }) it('no pagespecs/ dir at all → the PRD leg reports no-pagespecs, never a silent verdict', () => { const r = run({ pagespecs: null }) expect(r.pagespecsPresent).toBe(false) const u = r.ucs.find((x) => x.ucCode === 'UC-CRM-PIPELINE-OPPORTUNITES-004')! expect(u.prd).toBe('no-pagespecs') expect(r.totals.uncoveredPrd).toBe(0) }) it('matching is VERBATIM — a typo\'d reference covers nothing', () => { const typo: ScreenSource[] = [ { relPath: 's/screen.md', md: '### SCR-A-B-C-001 — L\n- **Cas d\'usage liés** : UC-CRM-PIPELINE-OPPORTUNITES-999\n' }, ] const r = run({ screens: typo, pagespecs: [] }) expect(r.ucs.find((x) => x.ucCode === 'UC-CRM-PIPELINE-OPPORTUNITES-001')!.ba).toBe('uncovered') }) it('the coverage JOIN is case-INSENSITIVE — a lowercase heading is covered by an UPPERCASE reference', () => { const lowerDoc = [ '### UC-CRM-PIPELINE-opportunites-001 — Créer une opportunité', '- **Flux principal** :', ' 1. X.', '- **Acceptance Criteria** :', ' - [ ] AC-01 — POST renvoie 201.', '', ].join('\n') const p = parseAcFromUseCaseMd(lowerDoc, 'opportunites/use-case.md') const upperScreens: ScreenSource[] = [ { relPath: 'opportunites/screen.md', md: "### SCR-CRM-PIPELINE-OPPORTUNITES-001 — L (SmartListView)\n- **Cas d'usage liés** : UC-CRM-PIPELINE-OPPORTUNITES-001\n", }, ] const r = deriveUcCoverage({ app: 'CRM', module: 'PIPELINE', ucs: p.ucs, ucWarnings: [], screens: upperScreens, pagespecs: null, }) expect(r.ucs[0]!.ba).toBe('covered') }) }) // ── parse-ac extension (level + scheduled) — the fields this CLI reads ──── describe('parse-ac — level & scheduled detection (3.1 extension)', () => { it('level: heading parenthetical wins, **Niveau** bullet fills, default user-goal (fail-closed)', () => { const p = parsed() const byCode = new Map(p.ucs.map((u) => [u.ucCode, u])) expect(byCode.get('UC-CRM-PIPELINE-OPPORTUNITES-001')!.level).toBe('user-goal') // undeclared expect(byCode.get('UC-CRM-PIPELINE-OPPORTUNITES-003')!.level).toBe('subfunction') // (subfunction) const withBullet = parseAcFromUseCaseMd( '### UC-A-B-C-001 — Truc\n- **Niveau** : summary\n- **Flux principal** :\n 1. X.\n', 'c/use-case.md', ) expect(withBullet.ucs[0]!.level).toBe('summary') }) it('ALT-N / EXC-N branch lines are parsed structurally, assigned by their OWN prefix', () => { const p = parseAcFromUseCaseMd( [ '### UC-A-B-C-001 — Créer', '- **Flux principal** :', ' 1. X.', '- **Flux alternatifs** :', ' - ALT-1 : montant inconnu → enregistrement en brouillon.', '- **Exceptions** :', ' - EXC-1 : prospect archivé → refus avec message.', ' - EXC-2 : quota dépassé → 409.', '', ].join('\n'), 'c/use-case.md', ) expect(p.ucs[0]!.alternatives).toEqual([{ id: 'ALT-1', text: 'montant inconnu → enregistrement en brouillon.' }]) expect(p.ucs[0]!.exceptions.map((e) => e.id)).toEqual(['EXC-1', 'EXC-2']) }) it('a duplicated branch id keeps the first line and warns', () => { const p = parseAcFromUseCaseMd( '### UC-A-B-C-001 — Créer\n- **Exceptions** :\n - EXC-1 : a → b.\n - EXC-1 : c → d.\n', 'c/use-case.md', ) expect(p.ucs[0]!.exceptions).toEqual([{ id: 'EXC-1', text: 'a → b.' }]) expect(p.warnings.some((w) => w.includes('EXC-1 twice'))).toBe(true) }) it('scheduled: detected on the UC bullet lines (derive-job-specs lockstep), NOT from an AC text', () => { const p = parsed() const byCode = new Map(p.ucs.map((u) => [u.ucCode, u])) expect(byCode.get('UC-CRM-PIPELINE-OPPORTUNITES-002')!.scheduled).toBe(true) expect(byCode.get('UC-CRM-PIPELINE-OPPORTUNITES-001')!.scheduled).toBe(false) // An AC merely DESCRIBING a scheduled job never schedules the UC. const acOnly = parseAcFromUseCaseMd( '### UC-A-B-C-001 — Truc\n- **Flux principal** :\n 1. X.\n- **Acceptance Criteria** :\n - [ ] AC-01 — the scheduled job emits nothing on replay.\n', 'c/use-case.md', ) expect(acOnly.ucs[0]!.scheduled).toBe(false) }) }) // ── UC-022 — exception ↔ AC parity (the « 5 EXC, 1 happy-path AC » closer) ─ describe('derive-uc-coverage / exceptionParity (UC-022 engine)', () => { function uc(md: string) { return parseAcFromUseCaseMd(md, 'c/use-case.md').ucs } it('T12 closed — a UC with exceptions and only a happy-path AC is a COUNTED finding', () => { const ucs = uc( [ '### UC-A-B-C-001 — Créer', '- **Exceptions** :', ' - EXC-1 : prospect archivé → refus.', ' - EXC-2 : quota dépassé → blocage.', '- **Acceptance Criteria** :', ' - [ ] AC-01 — POST valide renvoie 201.', '', ].join('\n'), ) const f = exceptionParity(ucs) expect(f).toHaveLength(1) expect(f[0]!.uncovered).toEqual(['EXC-1', 'EXC-2']) expect(f[0]!.detail).toContain('2 flow(s) have no corresponding AC') }) it('tier 1 — an AC citing the EXC id verbatim covers that flow', () => { const ucs = uc( [ '### UC-A-B-C-001 — Créer', '- **Exceptions** :', ' - EXC-1 : prospect archivé → refus.', '- **Acceptance Criteria** :', ' - [ ] AC-01 — POST valide renvoie 201.', ' - [ ] AC-02 — POST sur un prospect archivé renvoie 409 (EXC-1).', '', ].join('\n'), ) expect(exceptionParity(ucs)).toEqual([]) }) it('tier 2 — un-cited flows are covered by the NEGATIVE-AC pool (counted, never guessed)', () => { const ucs = uc( [ '### UC-A-B-C-001 — Créer', '- **Exceptions** :', ' - EXC-1 : prospect archivé → refus.', ' - EXC-2 : montant négatif → erreur.', '- **Acceptance Criteria** :', ' - [ ] AC-01 — POST valide renvoie 201.', ' - [ ] AC-02 — POST avec un montant négatif renvoie 400.', ' - [ ] AC-03 — POST sur un prospect archivé est rejeté.', '', ].join('\n'), ) expect(exceptionParity(ucs)).toEqual([]) }) it('a tier-1 citing AC is NOT double-counted into the tier-2 pool', () => { const ucs = uc( [ '### UC-A-B-C-001 — Créer', '- **Exceptions** :', ' - EXC-1 : prospect archivé → refus.', ' - EXC-2 : montant négatif → erreur.', '- **Acceptance Criteria** :', ' - [ ] AC-01 — POST sur un prospect archivé renvoie 409 (EXC-1).', '', ].join('\n'), ) const f = exceptionParity(ucs) expect(f).toHaveLength(1) expect(f[0]!.uncovered).toEqual(['EXC-2']) }) it('subfunction UCs are exempt (UC-012: no AC contract at that level) and 0-exception UCs never appear', () => { const ucs = uc( [ '### UC-A-B-C-001 — Valider (subfunction)', '- **Exceptions** :', ' - EXC-1 : entrée invalide → refus.', '', '### UC-A-B-C-002 — Créer', '- **Flux principal** :', ' 1. X.', '', ].join('\n'), ) expect(exceptionParity(ucs)).toEqual([]) }) })