/** * __tests__/fixture.ts — a self-contained mini BA corpus with KNOWN maxima. * * CRM/PIPELINE: two sections (`opportunites`, `clients`), lowercase UC section * segments (CONV-001), one near-miss UC heading, module + section rules docs, * a rbac.md with a derived block citing a REMOVED actor, two entities, two * screens, two pagespecs (one citing a REMOVED UC and carrying uiDesign + * lifecycle keys), an optional `previousCodes=` alias, and a second * application (HR) with look-alike titles for the cross-app check. * * Self-contained on purpose — never import another suite's fixture (a test * module carries `describe` side effects). */ import { mkdirSync, writeFileSync } from 'node:fs' import { join } from 'node:path' export interface FixtureOptions { /** `opportunites/index.md` carries `previousCodes=prospects` and a rule cites `UC-CRM-PIPELINE-PROSPECTS-020`. */ withAlias?: boolean /** Default true. */ withPagespecs?: boolean /** Writes `/.run-snapshot.json` (developed state). */ withSnapshot?: boolean /** Writes `/../sources/index.json` (sibling registry) with SRC-001. */ withSources?: boolean } const w = (path: string, content: string): void => { mkdirSync(join(path, '..'), { recursive: true }) writeFileSync(path, content, 'utf8') } export const UC_PREFIX = 'UC-CRM-PIPELINE-opportunites-' export const SCR_LIST = 'SCR-CRM-PIPELINE-OPPORTUNITES-001' export const SCR_DETAIL = 'SCR-CRM-PIPELINE-OPPORTUNITES-002' function useCaseBlock(code: string, title: string, actor: string, exc: boolean): string { return [ `### ${code} — ${title} (user-goal)`, `- **Acteur principal** : ${actor}`, '- **Acteurs secondaires** : —', '- **Préconditions** : le prospect existe.', '- **Flux principal** :', ' 1. Le commercial ouvre le formulaire.', ' 2. Le système enregistre.', ...(exc ? ['- **Exceptions** :', ' - EXC-1 : prospect archivé → refus avec message.'] : []), '- **Postconditions** : visible dans le pipeline.', '- **Acceptance Criteria** :', ' - [ ] AC-01 — POST renvoie 201.', ...(exc ? [' - [ ] AC-02 — EXC-1 renvoie 409.'] : []), '', ].join('\n') } /** The section doc: UC-001..003 (+ a near-miss `### UC-…-004` heading unless `nearMiss` is false) + extra blocks. */ export function useCaseDoc(extraBlocks = '', nearMiss = true): string { return [ '', '# Cas d\'usage — CRM / PIPELINE / opportunites', '', useCaseBlock(`${UC_PREFIX}001`, 'Créer une opportunité', 'BA-001-AC-001 (Commercial)', true), useCaseBlock(`${UC_PREFIX}002`, 'Qualifier une opportunité', 'BA-001-AC-001 (Commercial)', false), useCaseBlock(`${UC_PREFIX}003`, 'Clôturer une opportunité', 'BA-001-AC-002 (Manager commercial)', false), ...(nearMiss ? [`### ${UC_PREFIX}004`, '- **Acteur principal** : BA-001-AC-001 (Commercial)', ''] : []), extraBlocks, ].join('\n') } export function ruleBlock(code: string, title: string, type: string, ucs: string, errorCode = 'pipeline.rule'): string { return [ `### ${code} — ${title}`, `- **Type** : ${type}`, '- **Sévérité** : err', '- **Portée** : CRM / PIPELINE', '- **Condition** : QUAND une remise > 20 % est saisie ALORS exiger l\'aval manager.', '- **Expression** : `Discount <= 0.20 || Approval.ManagerId != null`', `- **Code d'erreur** : \`${errorCode}\``, '- **Cas valides** : remise 15 % sans aval.', '- **Cas invalides** : remise 25 % sans aval.', `- **Cas d'usage liés** : ${ucs}`, '', ].join('\n') } export function moduleRulesDoc(extra = ''): string { return [ '', '# Règles métier — CRM / PIPELINE', '', ruleBlock('BR-001', 'Remise plafonnée', 'validation', `${UC_PREFIX}002 (étape 3)`, 'pipeline.discount.cap'), ruleBlock('BR-002', 'Numéro d\'opportunité', 'numbering', `${UC_PREFIX}001 (postcondition)`, 'pipeline.opportunity.reference.duplicate'), extra, ].join('\n') } export function writeChangeCorpus(root: string, opts: FixtureOptions = {}): void { const withPagespecs = opts.withPagespecs ?? true const mod = join(root, 'CRM', 'PIPELINE') const opp = join(mod, 'opportunites') const cli = join(mod, 'clients') w(join(root, 'CRM', 'index.md'), '\n# CRM\n') w( join(root, 'CRM', 'acteur.md'), [ '', '# Acteurs — CRM', '', '### BA-001-AC-001 — Commercial', '- **Type** : internal', '- **Description** : Gère son portefeuille.', '- **Périmètre** : CRM', '- **Origine** : conversation', '', '### BA-001-AC-002 — Manager commercial', '- **Type** : internal', '- **Description** : Supervise.', '- **Périmètre** : CRM / PIPELINE', '- **Origine** : conversation', '', ].join('\n'), ) w(join(mod, 'index.md'), '\n# PIPELINE\n') w(join(mod, 'règles-métier.md'), moduleRulesDoc()) w( join(mod, 'rbac.md'), [ '', '# RBAC — CRM / PIPELINE', '', '| Acteur | Permission (`module.section[.resource].action`) | Portée |', '|--------|------------------------------------------|--------|', '| BA-001-AC-001 (Commercial) | `pipeline.opportunites.read` | toutes |', '| BA-001-AC-001 (Commercial) | `pipeline.opportunites.create` | les siennes |', '| BA-001-AC-002 (Manager commercial) | `pipeline.opportunites.approve` | équipe |', '', '', '| Acteur | Permission | Portée | Justification |', '|---|---|---|---|', '| BA-001-AC-003 (Assistant) | `crm.pipeline.clients.lookup` | toutes | FK ClientId |', '', '', ].join('\n'), ) w( join(mod, 'entité.md'), [ '', '# Modèle de données — CRM / PIPELINE', '', '### ENT-001 — Opportunity (agrégat racine)', '- **Préfixe table** : `pipeline_`', '- **Portée** : strict — données isolées par tenant', `- **Traçabilité** : ${UC_PREFIX}001, BR-001, BR-002`, '', '| Attribut | Type | Contraintes | Calculé |', '|----------|------|-------------|---------|', '| Id | Guid | PK | — |', '| Amount | decimal(18,2) | ≥ 0 | — |', '| Stage | enum | NOUVELLE/GAGNEE/PERDUE | — |', '| ClientId | Guid | requis | — |', '', '- **Relations** : Opportunity *→1 Client — FK ClientId, scope same-module, onDelete restrict.', '- **Index** : (Stage), (ClientId).', '- **Affichage** : Amount', '', '### ENT-002 — Client (agrégat racine)', '- **Préfixe table** : `pipeline_`', '- **Portée** : strict — données isolées par tenant', `- **Traçabilité** : UC-CRM-PIPELINE-clients-001`, '', '| Attribut | Type | Contraintes | Calculé |', '|----------|------|-------------|---------|', '| Id | Guid | PK | — |', '| Name | string(200) | requis | — |', '', '- **Affichage** : Name', '', ].join('\n'), ) w( join(opp, 'index.md'), `\n# opportunites\n`, ) w(join(opp, 'use-case.md'), useCaseDoc()) w( join(opp, 'règles-métier.md'), [ '', '# Règles métier — CRM / PIPELINE / opportunites', '', ruleBlock( 'BR-003', 'Clôture motivée', 'validation', `${UC_PREFIX}001${opts.withAlias ? ', UC-CRM-PIPELINE-PROSPECTS-020' : ''}`, 'pipeline.close.reason', ), ].join('\n'), ) w( join(opp, 'screen.md'), [ '', '# Écrans — CRM / PIPELINE / opportunites', '', `### ${SCR_LIST} — Liste des opportunités (SmartListView)`, '- **Entité** : Opportunity (ENT-001)', '- **Permission** : `pipeline.opportunites.read`', `- **Cas d'usage liés** : ${UC_PREFIX}001, ${UC_PREFIX}002`, '- **Colonnes** : Amount, Stage', '- **Filtres** : Stage (select)', '- **Actions** : create (header), edit (row)', '', `### ${SCR_DETAIL} — Fiche opportunité (SmartForm)`, '- **Entité** : Opportunity (ENT-001)', '- **Permission** : `pipeline.opportunites.read`', '- **Mode** : detail', `- **Cas d'usage liés** : ${UC_PREFIX}003`, '- **Champs** : Amount, Stage, Client', '', ].join('\n'), ) w(join(cli, 'index.md'), '\n# clients\n') w( join(cli, 'use-case.md'), [ '', '# Cas d\'usage — CRM / PIPELINE / clients', '', useCaseBlock('UC-CRM-PIPELINE-clients-001', 'Créer un client', 'BA-001-AC-001 (Commercial)', false), ].join('\n'), ) w( join(cli, 'screen.md'), [ '', '# Écrans — CRM / PIPELINE / clients', '', '### SCR-CRM-PIPELINE-CLIENTS-001 — Liste des clients (SmartListView)', '- **Entité** : Client (ENT-002)', '- **Permission** : `pipeline.clients.read`', '- **Cas d\'usage liés** : UC-CRM-PIPELINE-clients-001', '- **Colonnes** : Name', '', ].join('\n'), ) if (withPagespecs) { w(join(mod, 'prd.md'), '# PRD — CRM / PIPELINE\n') w( join(mod, 'pagespecs', 'Opportunity.list.md'), [ '# Opportunity — list', '', '```json', JSON.stringify( { screenCode: SCR_LIST, entity: 'Opportunity', view: 'list', permission: 'crm.pipeline.opportunites.read', linkedUseCases: [`${UC_PREFIX}001`, `${UC_PREFIX}009`], linkedBusinessRules: ['BR-001'], uiDesign: { list: { density: 'compact' } }, lifecycle: { statusField: 'Stage', phases: [] }, }, null, 2, ), '```', '', ].join('\n'), ) w( join(mod, 'pagespecs', 'Opportunity.detail.md'), [ '# Opportunity — detail', '', '```json', JSON.stringify({ screenCode: SCR_DETAIL, entity: 'Opportunity', view: 'detail', linkedUseCases: [`${UC_PREFIX}003`] }, null, 2), '```', '', ].join('\n'), ) } if (opts.withSnapshot) { w(join(mod, '.run-snapshot.json'), JSON.stringify({ pages: {} })) } // Second application — look-alike titles, read-only for the change engine. w(join(root, 'HR', 'index.md'), '\n# HR\n') w( join(root, 'HR', 'acteur.md'), ['', '# Acteurs — HR', '', '### BA-001-AC-005 — Responsable RH', '- **Type** : internal', ''].join('\n'), ) w(join(root, 'HR', 'RECRUIT', 'index.md'), '\n# RECRUIT\n') w(join(root, 'HR', 'RECRUIT', 'règles-métier.md'), ['', '# Règles métier — HR / RECRUIT', '', ruleBlock('BR-001', 'Remise plafonnée', 'validation', 'UC-HR-RECRUIT-candidats-001')].join('\n')) w(join(root, 'HR', 'RECRUIT', 'candidats', 'index.md'), '\n# candidats\n') w( join(root, 'HR', 'RECRUIT', 'candidats', 'use-case.md'), ['', '# Cas d\'usage — HR / RECRUIT / candidats', '', useCaseBlock('UC-HR-RECRUIT-candidats-001', 'Créer une opportunité', 'BA-001-AC-005 (Responsable RH)', false)].join('\n'), ) if (opts.withSources) { const sourcesRoot = join(root, '..', 'sources') w( join(sourcesRoot, 'index.json'), JSON.stringify( { version: 1, nextSeq: 2, sources: { 'SRC-001': { code: 'SRC-001', kind: 'file', title: 'Cahier des charges', fingerprint: '0123456789ab', origin: { path: 'cdc.md' }, format: 'md', ingestedAt: '2026-09-01T00:00:00.000Z', updatedAt: '2026-09-01T00:00:00.000Z', status: 'ingested', tags: ['processus-vente'], scopes: ['CRM'], summary: 'Le cahier des charges du CRM.', sections: 1, extracts: 1, }, }, }, null, 2, ), ) w( join(sourcesRoot, 'SRC-001', 'source.md'), [ '', '# SRC-001 — Cahier des charges', '', '## Métadonnées', '- **Origine** : `cdc.md`', '- **Format** : md · **Ingéré le** : 2026-09-01', '- **Tags** : processus-vente', '- **Portée pressentie** : CRM', '', '## Résumé', 'Le cahier des charges du CRM.', '', '## Points saillants', '### §1 — Relance des prospects [processus-vente] — extrait verbatim', '> « Le commercial relance ses prospects sous 7 jours. »', '', ].join('\n'), ) } }