/** * lib/ba-menu-tree — the shared contract of the BA menu tree as a machine object. * * What each block pins: * - anchors: a rewrite preserves every attribute it does not name (`depends=`, * `previousCodes=`, unknown ones) and their ORDER — the silent-loss class * the full re-Write of a parent index.md used to cause; * - long codes: the module / section / resource segment is renamed or removed * on 3-segment AND 4-segment codes, siblings untouched (`CLIENTS` never * matches `CLIENTS_VIP`); * - permission paths / pagespec refs: exact bounded tokens, machine blocks and * foreign applications byte-identical, and whatever the lists miss is SAID * by `residualTokenScan`; * - `## Enfants` / `## Dépendances`: line-level splices, the rest of the file * byte-identical. */ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { AMBIGUOUS_SECTION_NAMES, CONFIG_APP_RE, EMPTY_HP_MARKER, addPreviousCodeToAnchor, countCrossModuleScopes, countPermissionPaths, countScopeCodes, findNode, labelOfIndex, loadMenuTree, navFormOf, parseNodeAnchor, placeholderDocs, removeCodesFromText, removePermissionPaths, renameCodeSegment, renameCodesInText, renameCrossModuleScopes, renamePagespecRefs, renamePermissionPaths, renderIndexMd, residualTokenScan, sectionCodeToFolder, sectionFolderToCode, spliceDependances, spliceEnfants, updateNodeAnchor, } from '../ba-menu-tree.js' // --------------------------------------------------------------------------- // Anchors // --------------------------------------------------------------------------- describe('node anchor', () => { const content = '\n# BILLING — Facturation\n' it('parses every attribute, lists included', () => { const a = parseNodeAnchor(content)! expect(a.level).toBe('module') expect(a.code).toBe('BILLING') expect(a.depends).toEqual(['ERP/REFERENCES', 'ERP/CUSTOMERS']) expect(a.previousCodes).toEqual(['invoicing']) expect(a.attrs.map((x) => x.key)).toEqual(['kind', 'level', 'code', 'depends', 'previousCodes', 'custom']) }) it('updateNodeAnchor renames the code and keeps every other attribute in order', () => { const out = updateNodeAnchor(content, { code: 'INVOICES', addPreviousCode: 'billing' }) expect(out.split('\n')[0]).toBe('') expect(out.split('\n').slice(1).join('\n')).toBe('# BILLING — Facturation\n') }) it('updateNodeAnchor replaces or removes depends=', () => { expect(updateNodeAnchor(content, { depends: ['ERP/REFERENCES'] })).toContain('depends=ERP/REFERENCES previousCodes') expect(updateNodeAnchor(content, { depends: [] })).not.toContain('depends=') }) it('addPreviousCode never adds a self-alias nor a duplicate', () => { expect(updateNodeAnchor(content, { addPreviousCode: 'billing' })).toBe(content) expect(updateNodeAnchor(content, { addPreviousCode: 'INVOICING' })).toBe(content) }) it('content without an anchor is returned untouched', () => { expect(updateNodeAnchor('# Nothing\n', { code: 'X' })).toBe('# Nothing\n') expect(parseNodeAnchor('# Nothing\n')).toBeNull() }) it('addPreviousCodeToAnchor (reconcile historical form) creates the attribute when absent', () => { const out = addPreviousCodeToAnchor('\n', 'clients') expect(out).toContain('previousCodes=clients') expect(out).toContain('code=prospects') }) }) // --------------------------------------------------------------------------- // Code spellings // --------------------------------------------------------------------------- describe('code spellings', () => { it('folder ↔ long-code ↔ nav form', () => { expect(sectionFolderToCode('exchange-history')).toBe('EXCHANGE_HISTORY') expect(sectionCodeToFolder('EXCHANGE_HISTORY')).toBe('exchange-history') expect(navFormOf('OLD_MOD')).toBe('old-mod') expect(navFormOf('opportunites')).toBe('opportunites') }) it('mechanical vocabularies are the audit ones', () => { expect(AMBIGUOUS_SECTION_NAMES.has('divers')).toBe(true) expect(CONFIG_APP_RE.test('paramètres')).toBe(true) expect(EMPTY_HP_MARKER).toBe('_Aucune exclusion connue à ce stade._') }) }) // --------------------------------------------------------------------------- // Long codes — rename // --------------------------------------------------------------------------- const DOC = [ '### UC-CRM-PIPELINE-CLIENTS-001 — Créer un client', '- Écrans liés : SCR-CRM-PIPELINE-CLIENTS-002, SCR-CRM-PIPELINE-CLIENTS_VIP-001', '### UC-CRM-PIPELINE-CLIENTS-VIP-003 — Ressource vip sous clients', '### BR-CRM-PIPELINE-DEVIS-007 — Autre section', '### UC-CRM-BILLING-CLIENTS-001 — Même section dans un autre module', '', ].join('\n') describe('renameCodeSegment', () => { it('section: renames the section codes AND its 4-segment resource codes, never the look-alike sibling', () => { const out = renameCodeSegment(DOC, { app: 'CRM', mod: 'PIPELINE', sec: 'CLIENTS' }, 'section', 'TIERS') expect(out).toContain('UC-CRM-PIPELINE-TIERS-001') expect(out).toContain('SCR-CRM-PIPELINE-TIERS-002') expect(out).toContain('UC-CRM-PIPELINE-TIERS-VIP-003') expect(out).toContain('SCR-CRM-PIPELINE-CLIENTS_VIP-001') // a different section expect(out).toContain('BR-CRM-PIPELINE-DEVIS-007') expect(out).toContain('UC-CRM-BILLING-CLIENTS-001') // another module }) it('module: every section and resource code of the module follows', () => { const out = renameCodeSegment(DOC, { app: 'CRM', mod: 'PIPELINE' }, 'module', 'SALES') expect(out).toContain('UC-CRM-SALES-CLIENTS-001') expect(out).toContain('SCR-CRM-SALES-CLIENTS_VIP-001') expect(out).toContain('UC-CRM-SALES-CLIENTS-VIP-003') expect(out).toContain('BR-CRM-SALES-DEVIS-007') expect(out).toContain('UC-CRM-BILLING-CLIENTS-001') expect(out).not.toContain('PIPELINE') }) it('resource: only the 4-segment codes of that resource', () => { const out = renameCodeSegment(DOC, { app: 'CRM', mod: 'PIPELINE', sec: 'CLIENTS', res: 'VIP' }, 'resource', 'PREMIUM') expect(out).toContain('UC-CRM-PIPELINE-CLIENTS-PREMIUM-003') expect(out).toContain('UC-CRM-PIPELINE-CLIENTS-001') expect(out).toContain('SCR-CRM-PIPELINE-CLIENTS_VIP-001') }) it('is idempotent and the historical wrapper keeps its meaning', () => { const once = renameCodesInText(DOC, 'CRM', 'PIPELINE', 'CLIENTS', 'TIERS') expect(renameCodesInText(once, 'CRM', 'PIPELINE', 'CLIENTS', 'TIERS')).toBe(once) expect(renameCodesInText('### UC-CRM-PIPELINE-OPPORTUNITES-001 — Créer\n', 'CRM', 'PIPELINE', 'OPPORTUNITES', 'PROSPECTS')).toBe('### UC-CRM-PIPELINE-PROSPECTS-001 — Créer\n') }) it('countScopeCodes counts the node codes (module wildcard, section, resource)', () => { expect(countScopeCodes(DOC, { app: 'CRM', mod: 'PIPELINE' })).toBe(5) expect(countScopeCodes(DOC, { app: 'CRM', mod: 'PIPELINE', sec: 'CLIENTS' })).toBe(3) expect(countScopeCodes(DOC, { app: 'CRM', mod: 'PIPELINE', sec: 'CLIENTS', res: 'VIP' })).toBe(1) }) }) // --------------------------------------------------------------------------- // Long codes — delete // --------------------------------------------------------------------------- describe('removeCodesFromText', () => { const doc = [ '## Cas d\'usage', '### UC-CRM-PIPELINE-CLIENTS-001 — Créer', 'Corps A', '### UC-CRM-PIPELINE-CLIENTS-VIP-002 — Ressource', 'Corps B', '### UC-CRM-PIPELINE-DEVIS-001 — Devis', '- **Règles liées** : BR-CRM-PIPELINE-CLIENTS-001, BR-CRM-PIPELINE-DEVIS-002', '', ].join('\n') it('section: drops the section blocks (resources included) and scrubs body cites, sibling section kept', () => { const out = removeCodesFromText(doc, 'CRM', 'PIPELINE', 'CLIENTS') expect(out).not.toContain('CLIENTS') expect(out).toContain('### UC-CRM-PIPELINE-DEVIS-001 — Devis') expect(out).toContain('**Règles liées** : BR-CRM-PIPELINE-DEVIS-002') }) it('module wildcard: every block of every section goes', () => { const out = removeCodesFromText(doc, 'CRM', 'PIPELINE') expect(out).not.toMatch(/UC-CRM-PIPELINE|BR-CRM-PIPELINE/) expect(out).toContain("## Cas d'usage") }) it('resource only: the 4-segment blocks of that resource', () => { const out = removeCodesFromText(doc, 'CRM', 'PIPELINE', 'CLIENTS', 'VIP') expect(out).not.toContain('CLIENTS-VIP-002') expect(out).toContain('### UC-CRM-PIPELINE-CLIENTS-001 — Créer') }) }) // --------------------------------------------------------------------------- // rbac.md permission paths // --------------------------------------------------------------------------- const RBAC = [ '', '# RBAC — CRM / PIPELINE', '', '| Acteur | Permission | Portée |', '|---|---|---|', '| BA-001-AC-001 (Commercial) | `pipeline.clients.read` | toutes |', '| BA-001-AC-001 (Commercial) | `pipeline.clients.vip.export` | toutes |', '| BA-001-AC-002 (Manager) | `pipeline.clients-vip.read` | équipe |', '| BA-001-AC-002 (Manager) | `pipeline.devis.approve` | équipe |', '', '', '| `crm.pipeline.clients.read` | floor |', '', '', '', '| BA-001-AC-002 | `crm.pipeline.clients.lookup` | derived |', '', '', ].join('\n') describe('permission paths', () => { it('section rename rewrites the human rows (resource paths follow), machine blocks byte-identical', () => { const out = renamePermissionPaths(RBAC, { module: 'PIPELINE', section: 'clients' }, 'tiers') expect(out).toContain('`pipeline.tiers.read`') expect(out).toContain('`pipeline.tiers.vip.export`') expect(out).toContain('`pipeline.clients-vip.read`') // look-alike sibling untouched expect(out).toContain('`pipeline.devis.approve`') expect(out).toContain('| `crm.pipeline.clients.read` | floor |') expect(out).toContain('| BA-001-AC-002 | `crm.pipeline.clients.lookup` | derived |') }) it('module rename rewrites the module segment of every human row', () => { const out = renamePermissionPaths(RBAC, { module: 'PIPELINE' }, 'SALES') expect(out).toContain('`sales.clients.read`') expect(out).toContain('`sales.devis.approve`') expect(out).toContain('`crm.pipeline.clients.read` | floor') // locked }) it('counts and removes the rows of a scope, machine blocks intact', () => { expect(countPermissionPaths(RBAC, { module: 'PIPELINE', section: 'clients' })).toBe(2) const out = removePermissionPaths(RBAC, { module: 'PIPELINE', section: 'clients' }) expect(out).not.toContain('`pipeline.clients.read`') expect(out).not.toContain('`pipeline.clients.vip.export`') expect(out).toContain('`pipeline.clients-vip.read`') expect(out).toContain('`pipeline.devis.approve`') expect(out).toContain('\n| `crm.pipeline.clients.read` | floor |\n') }) }) // --------------------------------------------------------------------------- // Pagespecs // --------------------------------------------------------------------------- const PAGESPEC = (json: Record): string => `# Page\n\n\`\`\`json\n${JSON.stringify(json, null, 2)}\n\`\`\`\n\nProse below.\n` const LIST_PAGESPEC = PAGESPEC({ screenCode: 'SCR-CRM-PIPELINE-CLIENTS-001', appCode: 'crm', module: 'pipeline', section: 'clients', entity: 'Client', view: 'list', permission: 'pipeline.clients.read', actions: [ { code: 'create', permission: 'pipeline.clients.create' }, { code: 'open', kind: 'navigate', targetRoute: 'routes.clients.detail(item.id)', permission: 'pipeline.clients.read' }, ], filters: [{ field: 'ownerId', fkTo: { entity: 'User', module: 'core', apiEndpoint: '/api/core/users/lookup' } }], relatedTabs: [ { key: 'devis', relatedModule: 'pipeline', relatedSection: 'devis', permission: 'pipeline.devis.read', createPermission: 'pipeline.devis.create' }, { key: 'factures', relatedApp: 'facturation', relatedModule: 'pipeline', relatedSection: 'clients', permission: 'pipeline.clients.read' }, ], i18nKeys: { fr: { 'list.title': 'Clients', 'list.note': 'e.g' } }, }) const OTHER_MODULE_PAGESPEC = PAGESPEC({ screenCode: 'SCR-CRM-BILLING-INVOICES-001', appCode: 'crm', module: 'billing', section: 'invoices', permission: 'billing.invoices.read', filters: [{ field: 'clientId', fkTo: { entity: 'Client', app: 'crm', module: 'pipeline', navRoute: 'pipeline.clients', apiEndpoint: '/api/pipeline/clients/lookup' } }], relatedTabs: [{ key: 'clients', relatedModule: 'pipeline', relatedSection: 'clients', permission: 'pipeline.clients.read' }], }) describe('renamePagespecRefs', () => { it('section rename in the OWN module: identity, permissions, targetRoute, related tabs — foreign app untouched', () => { const r = renamePagespecRefs(LIST_PAGESPEC, { app: 'CRM', mod: 'PIPELINE', oldSec: 'clients', newSec: 'tiers', ownModule: true }) expect(r.error).toBeUndefined() const block = JSON.parse(/```json\n([\s\S]*?)\n```/.exec(r.content)![1]!) expect(block.section).toBe('tiers') expect(block.permission).toBe('pipeline.tiers.read') expect(block.actions[0].permission).toBe('pipeline.tiers.create') expect(block.actions[1].targetRoute).toBe('routes.tiers.detail(item.id)') expect(block.relatedTabs[0].relatedSection).toBe('devis') expect(block.relatedTabs[1].relatedSection).toBe('clients') // relatedApp facturation ≠ crm expect(block.relatedTabs[1].permission).toBe('pipeline.clients.read') expect(block.filters[0].fkTo.apiEndpoint).toBe('/api/core/users/lookup') expect(block.i18nKeys.fr['list.note']).toBe('e.g') expect(r.content).toContain('Prose below.') expect(r.count).toBeGreaterThanOrEqual(4) }) it('section rename seen from ANOTHER module: cross references only, root identity untouched', () => { const r = renamePagespecRefs(OTHER_MODULE_PAGESPEC, { app: 'CRM', mod: 'PIPELINE', oldSec: 'clients', newSec: 'tiers', ownModule: false }) const block = JSON.parse(/```json\n([\s\S]*?)\n```/.exec(r.content)![1]!) expect(block.section).toBe('invoices') expect(block.filters[0].fkTo.navRoute).toBe('pipeline.tiers') expect(block.filters[0].fkTo.apiEndpoint).toBe('/api/pipeline/tiers/lookup') expect(block.relatedTabs[0].relatedSection).toBe('tiers') expect(block.relatedTabs[0].permission).toBe('pipeline.tiers.read') }) it('module rename: module fields, permission prefixes, fkTo.module and endpoints', () => { const own = renamePagespecRefs(LIST_PAGESPEC, { app: 'CRM', oldMod: 'PIPELINE', newMod: 'SALES', ownModule: true }) const b1 = JSON.parse(/```json\n([\s\S]*?)\n```/.exec(own.content)![1]!) expect(b1.module).toBe('sales') expect(b1.permission).toBe('sales.clients.read') expect(b1.relatedTabs[0].relatedModule).toBe('sales') expect(b1.relatedTabs[1].relatedModule).toBe('pipeline') // other application const other = renamePagespecRefs(OTHER_MODULE_PAGESPEC, { app: 'CRM', oldMod: 'PIPELINE', newMod: 'SALES', ownModule: false }) const b2 = JSON.parse(/```json\n([\s\S]*?)\n```/.exec(other.content)![1]!) expect(b2.module).toBe('billing') expect(b2.filters[0].fkTo.module).toBe('sales') expect(b2.filters[0].fkTo.navRoute).toBe('sales.clients') expect(b2.filters[0].fkTo.apiEndpoint).toBe('/api/sales/clients/lookup') expect(b2.relatedTabs[0].relatedModule).toBe('sales') }) it('no block or unparseable block → untouched, said', () => { expect(renamePagespecRefs('# no block\n', { app: 'CRM', mod: 'PIPELINE', oldSec: 'a', newSec: 'b', ownModule: true })).toEqual({ content: '# no block\n', count: 0 }) const broken = '```json\n{ not json\n```\n' expect(renamePagespecRefs(broken, { app: 'CRM', mod: 'PIPELINE', oldSec: 'a', newSec: 'b', ownModule: true }).error).toBe('parse-error') }) it('a rename with nothing to rewrite leaves the file byte-identical', () => { const r = renamePagespecRefs(LIST_PAGESPEC, { app: 'CRM', mod: 'PIPELINE', oldSec: 'nothing', newSec: 'else', ownModule: true }) expect(r.count).toBe(0) expect(r.content).toBe(LIST_PAGESPEC) }) }) describe('residualTokenScan', () => { it('reports leftover whole tokens on code-ish lines only, every spelling', () => { const text = ['> Voir [use-case](./clients/use-case.md)', 'la section clients est décrite plus bas', 'code `CLIENTS` en dur', 'clients-vip reste', '"route": "/crm/pipeline/clients"'].join('\n') const hits = residualTokenScan(text, 'clients') expect(hits).toHaveLength(3) expect(hits[0]).toMatch(/^1: /) expect(hits[1]).toMatch(/^3: /) expect(hits[2]).toMatch(/^5: /) }) it('skips machine-owned blocks — their leftovers belong to the owner CLI, not to the residual', () => { const text = ['| `pipeline.tiers.read` |', '', '| `crm.pipeline.clients.read` | floor |', '', '`pipeline.clients.read` left over'].join('\n') expect(residualTokenScan(text, 'clients')).toEqual(['5: `pipeline.clients.read` left over']) }) }) // --------------------------------------------------------------------------- // entité.md cross-module scopes // --------------------------------------------------------------------------- describe('renameCrossModuleScopes', () => { const doc = '- **Relations** : Invoice *→1 Client — FK ClientId, scope cross-module (CRM/PIPELINE), onDelete restrict.\n- **Relations** : Invoice *→1 Site — FK SiteId, scope cross-module (CRM/SITES), onDelete restrict.\n' it('rewrites the exact APP/MOD token only', () => { const r = renameCrossModuleScopes(doc, 'CRM', 'PIPELINE', 'SALES') expect(r.count).toBe(1) expect(r.content).toContain('scope cross-module (CRM/SALES)') expect(r.content).toContain('scope cross-module (CRM/SITES)') expect(countCrossModuleScopes(doc, 'CRM', 'SITES')).toBe(1) }) }) // --------------------------------------------------------------------------- // `## Enfants` / `## Dépendances` // --------------------------------------------------------------------------- const PARENT = [ '', '# PIPELINE — Pipeline', '', '## Contexte', 'Suivi des ventes.', '- **Sources** : SRC-001 §2', '', '## Hors-périmètre', '- Pas de facturation.', '', '## Enfants', '- [opportunites](./opportunites/index.md) — Opportunités', '- [clients](./clients/index.md) — Clients', '', ].join('\n') describe('spliceEnfants', () => { it('add appends a child line and leaves everything else byte-identical', () => { const r = spliceEnfants(PARENT, { op: 'add', code: 'devis', label: 'Devis' }) expect(r.changed).toBe(true) const [before, after] = [PARENT.split('\n'), r.content.split('\n')] expect(after.slice(0, 13)).toEqual(before.slice(0, 13)) expect(after[13]).toBe('- [devis](./devis/index.md) — Devis') expect(after.slice(14)).toEqual(before.slice(13)) }) it('add is idempotent', () => { expect(spliceEnfants(PARENT, { op: 'add', code: 'clients', label: 'Clients' }).changed).toBe(false) }) it('add creates the heading when missing', () => { const r = spliceEnfants('\n# CRM — CRM\n\n## Contexte\nx\n', { op: 'add', code: 'PIPELINE', label: 'Pipeline' }) expect(r.content.endsWith('## Contexte\nx\n\n## Enfants\n- [PIPELINE](./PIPELINE/index.md) — Pipeline\n')).toBe(true) }) it('rename rewrites the link and keeps the label unless given', () => { const r = spliceEnfants(PARENT, { op: 'rename', code: 'clients', newCode: 'tiers' }) expect(r.content).toContain('- [tiers](./tiers/index.md) — Clients') expect(r.content).not.toContain('[clients]') const r2 = spliceEnfants(PARENT, { op: 'rename', code: 'clients', newCode: 'tiers', label: 'Tiers' }) expect(r2.content).toContain('- [tiers](./tiers/index.md) — Tiers') }) it('remove drops the line; the anchor with depends= and previousCodes= survives', () => { const r = spliceEnfants(PARENT, { op: 'remove', code: 'clients' }) expect(r.content).not.toContain('[clients]') expect(r.content.split('\n')[0]).toBe(PARENT.split('\n')[0]) expect(r.content).toContain('- **Sources** : SRC-001 §2') expect(spliceEnfants(PARENT, { op: 'remove', code: 'ghost' }).changed).toBe(false) }) it('preserves CRLF endings', () => { const crlf = PARENT.replace(/\n/g, '\r\n') const r = spliceEnfants(crlf, { op: 'add', code: 'devis', label: 'Devis' }) expect(r.content).toContain('\r\n- [devis](./devis/index.md) — Devis\r\n') expect(r.content).not.toMatch(/[^\r]\n/) }) }) describe('spliceDependances', () => { const doc = '## Dépendances\n- [REFERENCES](../REFERENCES/index.md) — devises\n- [CUSTOMERS](../CUSTOMERS/index.md) — clients\n' it('renames and removes a dependency line', () => { expect(spliceDependances(doc, { op: 'rename', code: 'CUSTOMERS', newCode: 'CLIENTS' }).content).toContain('- [CLIENTS](../CLIENTS/index.md) — clients') const r = spliceDependances(doc, { op: 'remove', code: 'REFERENCES' }) expect(r.content).toBe('## Dépendances\n- [CUSTOMERS](../CUSTOMERS/index.md) — clients\n') expect(spliceDependances(doc, { op: 'remove', code: 'GHOST' }).changed).toBe(false) }) }) // --------------------------------------------------------------------------- // Tree + node documents // --------------------------------------------------------------------------- describe('loadMenuTree / documents', () => { const dirs: string[] = [] afterEach(() => { for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }) }) it('loads apps → modules → sections → resources, skipping _audit / pagespecs / dotfiles', () => { const root = mkdtempSync(join(tmpdir(), 'ba-menu-tree-')) dirs.push(root) const w = (rel: string, content: string): void => { mkdirSync(join(root, rel, '..'), { recursive: true }) writeFileSync(join(root, rel), content, 'utf8') } w('index.md', '\n# Demo\n') w('CRM/index.md', '\n# CRM — Gestion commerciale\n') w('CRM/PIPELINE/index.md', '\n# PIPELINE — Pipeline\n') w('CRM/PIPELINE/clients/index.md', '\n# clients — Clients\n') w('CRM/PIPELINE/clients/vip/index.md', '\n# vip — VIP\n') w('CRM/PIPELINE/_audit/menu.md', 'verdict') w('CRM/PIPELINE/pagespecs/Client.list.md', 'spec') w('CRM/PIPELINE/.run-snapshot.json', '{}') mkdirSync(join(root, 'CRM', 'PIPELINE', 'devis')) const tree = loadMenuTree(root) expect(tree.rootIndexRaw).toContain('level=project') expect(tree.apps.map((a) => a.code)).toEqual(['CRM']) const mod = findNode(tree, ['crm', 'pipeline'])! expect(mod.level).toBe('module') expect(mod.anchor?.depends).toEqual(['ERP/REF']) expect(mod.children.map((c) => c.code)).toEqual(['clients', 'devis']) expect(mod.children[1]!.indexRaw).toBeNull() expect(findNode(tree, ['CRM', 'PIPELINE', 'clients', 'vip'])?.level).toBe('resource') expect(findNode(tree, ['CRM', 'GHOST'])).toBeUndefined() expect(tree.all.map((n) => n.path.join('/'))).toEqual(['CRM', 'CRM/PIPELINE', 'CRM/PIPELINE/clients', 'CRM/PIPELINE/clients/vip', 'CRM/PIPELINE/devis']) expect(loadMenuTree(join(root, 'nowhere')).apps).toEqual([]) }) it('labelOfIndex reads the label after the em-dash, falls back to title then code', () => { expect(labelOfIndex('# CRM — Gestion commerciale\n', 'CRM')).toBe('Gestion commerciale') expect(labelOfIndex('# Pipeline\n', 'PIPELINE')).toBe('Pipeline') expect(labelOfIndex(null, 'X')).toBe('X') }) it('placeholderDocs emits the six concept docs with their anchors', () => { const docs = placeholderDocs('section', 'clients') expect(docs.map((d) => d.file)).toEqual(['acteur.md', 'entité.md', 'use-case.md', 'règles-métier.md', 'rbac.md', 'screen.md']) expect(docs[0]!.content).toBe('\n_À définir lors de la phase « acteurs » (/ba-create-actors)._\n') expect(docs[3]!.content).toContain('') expect(docs[1]!.content).toContain('') }) it('renderIndexMd — canonical shape, empty marker, sources, depends', () => { const md = renderIndexMd({ level: 'module', code: 'BILLING', label: 'Facturation', contexte: 'Qui / quoi / limites.', horsPerimetre: [], sources: ['SRC-001 §2'], depends: ['ERP/REF'] }) expect(md).toBe(['', '# BILLING — Facturation', '', '## Contexte', 'Qui / quoi / limites.', '- **Sources** : SRC-001 §2', '', '## Hors-périmètre', EMPTY_HP_MARKER, '', '## Enfants', ''].join('\n')) const sec = renderIndexMd({ level: 'section', code: 'clients', label: 'Clients', contexte: 'Liste des clients.' }) expect(sec).not.toContain('## Hors-périmètre') const bullets = renderIndexMd({ level: 'application', code: 'CRM', label: 'CRM', contexte: 'x', horsPerimetre: ['Pas de paie.'] }) expect(bullets).toContain('## Hors-périmètre\n- Pas de paie.\n') }) })