/** * relations.test.ts — entité.md → relation graph parsing. * Inline-markdown fixtures, same idiom as create-plan-development/parse.test.ts. */ import { describe, it, expect } from 'vitest' import { parseEntities, findEntity, incomingOf, outgoingOf, isJunction, } from '../relations.js' const CRM_CLIENTS = ` # Modèle de données — CRM / CLIENTS ### ENT-001 — Client (agrégat racine) - **Préfixe table** : \`clients_\` | Attribut | Type | Contraintes | Calculé | |----------|------|-------------|---------| | Id | Guid | PK | — | | Code | string(20) | requis | — | | Name | string(200) | requis | — | - **Relations** : Client *→1 Sector — FK SectorId, scope same-module, onDelete restrict. ### ENT-002 — Invoice (agrégat racine) | Attribut | Type | Contraintes | Calculé | |----------|------|-------------|---------| | Id | Guid | PK | — | | Number | string(20) | requis | — | | Status | enum | DRAFT/SENT/PAID | — | - **Relations** : Invoice *→1 Client — FK ClientId, scope same-module, onDelete restrict ; Invoice *→1 Currency — FK CurrencyId, scope same-module, onDelete restrict. ### ENT-003 — Sector (lookup) | Attribut | Type | Contraintes | Calculé | |----------|------|-------------|---------| | Id | Guid | PK | — | | Label | string(100) | requis | — | ` const SALES_ORDERS = ` # Modèle de données — SALES / ORDERS ### ENT-001 — Order (agrégat racine) | Attribut | Type | Contraintes | Calculé | |----------|------|-------------|---------| | Id | Guid | PK | — | - **Relations** : Order *→1 Client — FK ClientId, scope cross-module (CRM/CLIENTS), onDelete restrict. ` const HR_STAFF = ` # Modèle de données — HR / STAFF ### ENT-001 — Employee (agrégat racine) | Attribut | Type | Contraintes | Calculé | |----------|------|-------------|---------| | Id | Guid | PK | — | - **Relations** : Employee *→1 User — FK UserId, scope core (auth_Users), onDelete restrict. ` function graphOf(contents: Record) { return parseEntities(new Map(Object.entries(contents))) } describe('parseEntities', () => { it('parses a single Relations entry with a trailing period', () => { const graph = graphOf({ 'CRM/CLIENTS': CRM_CLIENTS }) const client = findEntity(graph, 'Client')! expect(client.relations).toHaveLength(1) expect(client.relations[0]).toMatchObject({ sourceEntity: 'Client', cardinality: '*→1', targetEntity: 'Sector', fk: 'SectorId', scope: 'same-module', onDelete: 'restrict', module: 'CRM/CLIENTS', }) }) it('parses several Relations entries on one line', () => { const graph = graphOf({ 'CRM/CLIENTS': CRM_CLIENTS }) const invoice = findEntity(graph, 'Invoice')! expect(invoice.relations).toHaveLength(2) expect(invoice.relations.map((r) => r.targetEntity)).toEqual(['Client', 'Currency']) }) it('parses the cross-module (APP/MOD) scope detail', () => { const graph = graphOf({ 'SALES/ORDERS': SALES_ORDERS }) const order = findEntity(graph, 'Order')! expect(order.relations[0]).toMatchObject({ scope: 'cross-module', scopeDetail: 'CRM/CLIENTS', targetEntity: 'Client', }) }) it('keeps scope core relations in the graph', () => { const graph = graphOf({ 'HR/STAFF': HR_STAFF }) const coreRel = graph.relations.find((r) => r.scope === 'core') expect(coreRel).toMatchObject({ targetEntity: 'User', scopeDetail: 'auth_Users' }) }) it('parses attributes (header row skipped) and heading classification', () => { const graph = graphOf({ 'CRM/CLIENTS': CRM_CLIENTS }) const client = findEntity(graph, 'Client')! expect(client.classification).toBe('agrégat racine') expect(client.attributes.map((a) => a.name)).toEqual(['Id', 'Code', 'Name']) expect(client.attributes[1].type).toBe('string(20)') }) it('prefers the Classification bullet over the heading parens', () => { const graph = graphOf({ 'X/Y': `### ENT-001 — Thing (agrégat racine) - **Classification** : composant `, }) expect(findEntity(graph, 'Thing')!.classification).toBe('composant') }) it('ignores placeholder entité.md content', () => { const graph = graphOf({ 'X/Y': ` _À définir lors de la phase « modèle de données »._ `, }) expect(graph.entities).toHaveLength(0) }) // --- Fail-closed: a leftover scaffolding line must not discard a model --- // // The placeholder marker used to be tested against the WHOLE file. Since // /ba-create-menu writes it under every not-yet-authored section, a fully // authored entité.md routinely keeps one — and the whole document was // dropped: every entity, and above all every relation, i.e. every foreign // key. The cascade was the treacherous part: DM-009/DM-014 warned that // everything was isolated while DM-004/DM-013/DM-005 went GREEN on an empty // graph, and the control counts saw nothing (they reconcile `### ENT-` // headings, which ba-entities keeps parsing, never relations). it('keeps an AUTHORED model that still carries a leftover placeholder line', () => { const graph = graphOf({ 'CRM/CLIENTS': `${CRM_CLIENTS} ## Écrans _À définir lors de la phase « écrans »._ `, }) expect(graph.entities.length).toBeGreaterThan(0) expect(graph.relations.length).toBeGreaterThan(0) expect(findEntity(graph, 'Client')!.relations[0]!.fk).toBe('SectorId') }) // ENT_HEADING_RE (ba-entities) has always accepted the ASCII dash; this // parser required the em-dash. The entity therefore existed on one side and // its relations vanished on the other — silently, since nothing compares the // two parsers. it('reads an entity heading written with the ASCII dash', () => { const graph = graphOf({ 'X/Y': `### ENT-001 - Order (agrégat racine) - **Relations** : Order *→1 Client — FK ClientId, scope same-module. `, }) expect(graph.entities).toHaveLength(1) expect(graph.relations).toHaveLength(1) }) // Same hardening on the entry that CARRIES the foreign key. SCREEN_HEADING_RE // was widened after the 2026-08-30 incident ("the em-dash-only form silently // dropped those screens"); the fix had never reached this grammar. it('reads a relation written with the ASCII dash and a lowercase fk', () => { const graph = graphOf({ 'X/Y': `### ENT-001 — Order (agrégat racine) - **Relations** : Order *→1 Client - fk ClientId, scope Same-Module, onDelete restrict. `, }) expect(graph.relations).toHaveLength(1) expect(graph.relations[0]!.fk).toBe('ClientId') // `i` lets the scope come back capitalised; consumers compare it to the // lowercase literal, so it is normalised at the source. expect(graph.relations[0]!.scope).toBe('same-module') }) // The grammar's owner also counts what it could NOT read — and every direct // consumer (this CLI, derive-lookup-grants, derive-related-tabs-data) sees it. it('reports a LOSS on graph.warnings when a block carries more cardinality tokens than parsed entries', () => { const graph = graphOf({ 'X/Y': `### ENT-001 — Order (agrégat racine) - **Relations** : - Order *→1 Client — FK ClientId, scope same-module. - Order *→1 Owner FK OwnerId `, }) expect(graph.relations).toHaveLength(1) expect(graph.warnings).toHaveLength(1) expect(graph.warnings[0]).toContain('ENT-001 — **Relations** carries 2 cardinality token(s) but only 1 entry(ies) parse') }) it('accepts the ASCII arrow (*->1) and normalises the cardinality — a probe that only knew → counted 0 tokens on it', () => { const graph = graphOf({ 'X/Y': `### ENT-001 — Order (agrégat racine) - **Relations** : Order *->1 Client — FK ClientId, scope same-module. `, }) expect(graph.relations).toHaveLength(1) expect(graph.relations[0]!.cardinality).toBe('*→1') expect(graph.warnings).toEqual([]) }) }) describe('incomingOf', () => { it('returns same-module *→1 relations targeting the entity', () => { const graph = graphOf({ 'CRM/CLIENTS': CRM_CLIENTS }) const incoming = incomingOf(graph, 'Client', 'CRM/CLIENTS', true) expect(incoming).toHaveLength(1) expect(incoming[0].sourceEntity).toBe('Invoice') }) it('spans two modules when includeCrossModule is true', () => { const graph = graphOf({ 'CRM/CLIENTS': CRM_CLIENTS, 'SALES/ORDERS': SALES_ORDERS }) const incoming = incomingOf(graph, 'Client', 'CRM/CLIENTS', true) expect(incoming.map((r) => r.sourceEntity).sort()).toEqual(['Invoice', 'Order']) }) it('filters cross-module relations when includeCrossModule is false', () => { const graph = graphOf({ 'CRM/CLIENTS': CRM_CLIENTS, 'SALES/ORDERS': SALES_ORDERS }) const incoming = incomingOf(graph, 'Client', 'CRM/CLIENTS', false) expect(incoming.map((r) => r.sourceEntity)).toEqual(['Invoice']) }) it('never returns scope core relations', () => { const graph = graphOf({ 'HR/STAFF': HR_STAFF }) expect(incomingOf(graph, 'User', 'HR/STAFF', true)).toHaveLength(0) }) it('does not leak a sibling module same-module relation into another module', () => { // Same entity NAME in two modules: only the entity's own module matches. const graph = graphOf({ 'A/M1': `### ENT-001 — Note (composant) - **Relations** : Note *→1 Client — FK ClientId, scope same-module, onDelete cascade. `, 'CRM/CLIENTS': CRM_CLIENTS, }) const incoming = incomingOf(graph, 'Client', 'CRM/CLIENTS', true) expect(incoming.map((r) => r.sourceEntity)).toEqual(['Invoice']) }) }) describe('outgoingOf', () => { it('returns the relations written ON the entity', () => { const graph = graphOf({ 'CRM/CLIENTS': CRM_CLIENTS }) const outgoing = outgoingOf(graph, 'Invoice', 'CRM/CLIENTS') expect(outgoing.map((r) => r.targetEntity)).toEqual(['Client', 'Currency']) }) }) describe('isJunction', () => { it('detects a junction from the classification', () => { const graph = graphOf({ 'X/Y': `### ENT-001 — ClientTag (jonction) - **Relations** : ClientTag *→1 Client — FK ClientId, scope same-module, onDelete cascade. `, }) expect(isJunction(findEntity(graph, 'ClientTag')!)).toBe(true) }) it('detects a junction from exactly two *→1 relations', () => { const graph = graphOf({ 'X/Y': `### ENT-001 — ProjectMember (composant) - **Relations** : ProjectMember *→1 Project — FK ProjectId, scope same-module, onDelete cascade. ProjectMember *→1 Employee — FK EmployeeId, scope same-module, onDelete cascade. `, }) expect(isJunction(findEntity(graph, 'ProjectMember')!)).toBe(true) }) it('does not flag an entity with mixed cardinalities', () => { const graph = graphOf({ 'X/Y': `### ENT-001 — Profile (composant) - **Relations** : Profile *→1 Client — FK ClientId, scope same-module, onDelete cascade. Profile 1→1 Avatar — FK AvatarId, scope same-module, onDelete cascade. `, }) expect(isJunction(findEntity(graph, 'Profile')!)).toBe(false) }) it('does not flag an aggregate with a single FK', () => { const graph = graphOf({ 'CRM/CLIENTS': CRM_CLIENTS }) expect(isJunction(findEntity(graph, 'Client')!)).toBe(false) }) }) describe('findEntity', () => { it('prefers the entity in the requested module', () => { const graph = graphOf({ 'A/M1': `### ENT-001 — Client (agrégat racine) `, 'CRM/CLIENTS': CRM_CLIENTS, }) expect(findEntity(graph, 'Client', 'CRM/CLIENTS')!.module).toBe('CRM/CLIENTS') expect(findEntity(graph, 'Client', 'A/M1')!.module).toBe('A/M1') }) })