import { describe, expect, it } from 'vitest' import { findCodeLikeAttributes, parseEntityDoc, typedCodeAttributes } from '../ba-entities.js' const FULL_DOC = ` # Modèle de données — CRM / PIPELINE ### ENT-001 — Opportunity (agrégat racine) - **Préfixe table** : \`pipeline_\` - **Traçabilité** : UC-CRM-PIPELINE-opportunites-001, BR-001, BR-002 | Attribut | Type | Contraintes | Calculé | |----------|------|-------------|---------| | Id | Guid | PK | — | | Amount | decimal(18,2) | ≥ 0 | — | | Stage | enum | NOUVELLE/GAGNEE/PERDUE | — | | WeightedAmount | decimal(18,2) | — | \`Amount * Probability\` | - **Relations** : Opportunity *→1 Contact — FK ContactId, scope same-module, onDelete restrict. - **Index** : (Stage), (Code) unique. - **Code pattern** : \`OPP-{YY}-{SEQ:4}\` — scope tenant, reset annuel, gapless (voir BR-002). - **Affichage** : Code — le champ qui NOMME une ligne partout. - **Valeurs initiales** : clé \`Key\` — les lignes fixées par le métier : | Key | Label | Enabled | |-----|-------|---------| | expertise | Expertise périodique | true | | vignette | Vignette autoroutière | true | ### ENT-002 — AuditTrail (technical) - **Isolation** : by-design — journal d'audit, personne ne le référence. | Attribut | Type | Contraintes | Calculé | |----------|------|-------------|---------| | Id | Guid | PK | — | ### ENT-003 — Employee (agrégat racine) - **Personne** : mandatory — identité via auth_Users (FirstName, LastName, Email) - **Relations** : Employee *→1 User — FK UserId, scope core (auth_Users), onDelete restrict. ` describe('ba-entities — full entity block parsing', () => { const { entities, warnings } = parseEntityDoc(FULL_DOC, 'CRM/PIPELINE') it('parses every entity block with code, name and classification', () => { expect(entities.map((e) => e.code)).toEqual(['ENT-001', 'ENT-002', 'ENT-003']) expect(entities[0]!.name).toBe('Opportunity') expect(entities[0]!.classification).toBe('agrégat racine') expect(entities[1]!.classification).toBe('technical') expect(warnings).toEqual([]) }) it('reads the attribute table INCLUDING the Calculé column', () => { const attrs = entities[0]!.attributes expect(attrs.map((a) => a.name)).toEqual(['Id', 'Amount', 'Stage', 'WeightedAmount']) expect(attrs[1]).toMatchObject({ type: 'decimal(18,2)', constraints: '≥ 0', computed: null }) expect(attrs[3]!.computed).toBe('Amount * Probability') }) it('reads prefix, traceability (lowercase UC codes included) and indexes with unique flags', () => { const e = entities[0]! expect(e.tablePrefix).toBe('pipeline_') expect(e.traceability).toEqual(['UC-CRM-PIPELINE-opportunites-001', 'BR-001', 'BR-002']) expect(e.indexes).toEqual([ { fields: ['Stage'], unique: false, raw: '(Stage)' }, { fields: ['Code'], unique: true, raw: '(Code) unique' }, ]) }) it('delegates relations to ba-relations (single grammar)', () => { expect(entities[0]!.relations).toHaveLength(1) expect(entities[0]!.relations[0]).toMatchObject({ sourceEntity: 'Opportunity', targetEntity: 'Contact', fk: 'ContactId', scope: 'same-module', }) expect(entities[2]!.relations[0]).toMatchObject({ scope: 'core', fk: 'UserId' }) }) it('delegates the Code pattern line to code-pattern-grammar', () => { const cp = entities[0]!.codePattern expect(cp).not.toBeNull() expect(cp!.format).toBe('OPP-{YY}-{SEQ:4}') expect(cp!.scope).toBe('Tenant') }) it('reads Affichage (attribute before the em-dash), Personne and Isolation', () => { expect(entities[0]!.display).toBe('Code') expect(entities[1]!.isolation).toContain('by-design') expect(entities[2]!.person).toContain('auth_Users') }) it('reads the Valeurs initiales natural key and its table WITHOUT polluting attributes', () => { const iv = entities[0]!.initialValues expect(iv).not.toBeNull() expect(iv!.key).toBe('Key') expect(iv!.columns).toEqual(['Key', 'Label', 'Enabled']) expect(iv!.rows).toEqual([ ['expertise', 'Expertise périodique', 'true'], ['vignette', 'Vignette autoroutière', 'true'], ]) // The seeded rows never leak into the attribute list. expect(entities[0]!.attributes.some((a) => a.name === 'expertise')).toBe(false) expect(entities[0]!.attributes.some((a) => a.name === 'Key')).toBe(false) }) it('an entity block owns ONLY its own fields — no bleed across blocks', () => { expect(entities[1]!.tablePrefix).toBeNull() expect(entities[1]!.indexes).toEqual([]) expect(entities[2]!.isolation).toBeNull() }) }) describe('ba-entities — fail-closed shapes', () => { it('a `### ENT-…` heading that does not parse is a NEAR-MISS warning, never silence', () => { const md = '### ENT-XYZ — 123Bad\n| Attribut | Type | Contraintes | Calculé |\n| A | int | — | — |\n' const { entities, warnings } = parseEntityDoc(md, 'CRM/PIPELINE') expect(entities).toEqual([]) expect(warnings.some((w) => w.includes('does not parse'))).toBe(true) }) it('a placeholder/empty doc yields zero entities and zero warnings', () => { const { entities, warnings } = parseEntityDoc('# Modèle de données\n_À définir._\n', 'CRM/X') expect(entities).toEqual([]) expect(warnings).toEqual([]) }) it('folded field labels tolerate NFD accents and case', () => { const nfd = 'Traçabilité'.normalize('NFD') const md = `### ENT-001 — Thing\n- **${nfd}** : BR-001\n` const { entities } = parseEntityDoc(md, 'CRM/X') expect(entities[0]!.traceability).toEqual(['BR-001']) }) it('a parenthesised traceability annotation with a comma stays ONE code', () => { const md = '### ENT-001 — Thing\n- **Traçabilité** : UC-CRM-X-list-001, BR-004 (numérotation, reset annuel)\n' const { entities } = parseEntityDoc(md, 'CRM/X') expect(entities[0]!.traceability).toEqual(['UC-CRM-X-list-001', 'BR-004 (numérotation, reset annuel)']) }) it('a folded multi-line Traçabilité (sub-bullets) is fully read', () => { const md = [ '### ENT-001 — Thing', '- **Traçabilité** :', ' - UC-CRM-X-list-001', ' - BR-004', '', ].join('\n') const { entities } = parseEntityDoc(md, 'CRM/X') expect(entities[0]!.traceability).toEqual(['UC-CRM-X-list-001', 'BR-004']) }) }) describe('ba-entities — code-like classification (DM-021 feed)', () => { const doc = (attrs: string, bullets = ''): string => `### ENT-001 — Dossier | Attribut | Type | Contraintes | Calculé | |----------|------|-------------|---------| ${attrs} ${bullets} ` it('flags a string+unique synonym attribute with no classification', () => { const { entities } = parseEntityDoc(doc('| Reference | string/50 | unique, obligatoire | — |'), 'APP/MOD') expect(findCodeLikeAttributes(entities)).toEqual([ { entity: 'Dossier', attribute: 'Reference', matchedWord: 'reference' }, ]) }) it('a **Code saisi** bullet classifies the attribute — no finding (FR and EN alias)', () => { const fr = parseEntityDoc( doc('| Reference | string/50 | unique | — |', '- **Code saisi** : Reference — clé référentielle tapée.'), 'APP/MOD', ).entities expect(typedCodeAttributes(fr[0]!)).toEqual(['Reference']) expect(findCodeLikeAttributes(fr)).toEqual([]) const en = parseEntityDoc( doc('| Reference | string/50 | unique | — |', '- **Typed code** : Reference'), 'APP/MOD', ).entities expect(findCodeLikeAttributes(en)).toEqual([]) }) it('never flags: non-unique, non-string, computed, FK-shaped, or the code attribute itself', () => { const { entities } = parseEntityDoc( doc( [ '| Reference | string/200 | — | — |', '| NumeroId | Guid | FK | — |', '| Numero | int | unique | — |', '| Matricule | string/20 | unique | `Prenom + Nom` |', '| Code | string/30 | unique | — |', ].join('\n'), ), 'APP/MOD', ) expect(findCodeLikeAttributes(entities)).toEqual([]) }) it('compound names match whole-word (NumeroClient), unrelated names never', () => { // Accented attribute names (NuméroClient) never reach `attributes` — the // table parser only admits ASCII identifiers (BA attributes become C# // properties); the accent-folding of the lexicon itself is covered by the // code-pattern-grammar suite (codeLikeWordOf('Référence')). const { entities } = parseEntityDoc( doc('| NumeroClient | string/20 | unique | — |\n| Preference | string/20 | unique | — |'), 'APP/MOD', ) expect(findCodeLikeAttributes(entities)).toEqual([ { entity: 'Dossier', attribute: 'NumeroClient', matchedWord: 'numero' }, ]) }) }) // --------------------------------------------------------------------------- // Fail-closed BELOW the heading. The near-miss discipline used to cover ONE // line shape (`### ENT-…`); every declaration under it — attribute rows, // `**Index**`, `**Relations**` — could vanish without a word, and audit-ba's // control counts reconcile headings, never rows. Each test here is a shape // that used to be swallowed in silence. // --------------------------------------------------------------------------- describe('parseEntityDoc — fail-closed below the heading', () => { const doc = (body: string): string => `### ENT-001 — Thing (agrégat racine)\n${body}` it('KEEPS an accented attribute name so DM-007 can judge it (it used to be dropped before the rule ran)', () => { const { entities, warnings } = parseEntityDoc( doc('| Attribut | Type | Contraintes | Calculé |\n|---|---|---|---|\n| Libellé | string(100) | requis | — |\n'), 'CRM/X', ) expect(entities[0]!.attributes.map((a) => a.name)).toEqual(['Libellé']) expect(warnings).toEqual([]) }) it('drops a name no rule could safely read — and says so', () => { const { entities, warnings } = parseEntityDoc( doc('| Attribut | Type | Contraintes | Calculé |\n|---|---|---|---|\n| Montant (HT) | decimal(18,2) | ≥ 0 | — |\n| Name | string(100) | requis | — |\n'), 'CRM/X', ) expect(entities[0]!.attributes.map((a) => a.name)).toEqual(['Name']) expect(warnings).toHaveLength(1) expect(warnings[0]).toContain('attribute row « Montant (HT) » dropped') }) it('reads a GFM row without its trailing pipe — and the rows AFTER it (the old regex reset the table there)', () => { const { entities } = parseEntityDoc( doc('| Attribut | Type | Contraintes | Calculé |\n|---|---|---|---|\n| Id | Guid | PK | —\n| Name | string(100) | requis | — |\n| Amount | decimal(18,2) | ≥ 0 | — |\n'), 'CRM/X', ) expect(entities[0]!.attributes.map((a) => a.name)).toEqual(['Id', 'Name', 'Amount']) }) it('an **Index** value that yields no (…) group is a near-miss, never an empty list in silence', () => { const { entities, warnings } = parseEntityDoc(doc('- **Index** : Stage, Code unique\n'), 'CRM/X') expect(entities[0]!.indexes).toEqual([]) expect(warnings).toHaveLength(1) expect(warnings[0]).toContain('**Index** declares « Stage, Code unique »') }) it('a **Relations** bullet whose entries do not ALL parse reports the loss (a lost entry is a lost foreign key)', () => { const { entities, warnings } = parseEntityDoc( doc( '- **Relations** :\n' + ' - Thing *→1 Client — FK ClientId, scope same-module.\n' + ' - Thing *→1 Owner FK OwnerId\n', // no dash, no scope → the grammar cannot read it ), 'CRM/X', ) expect(entities[0]!.relations).toHaveLength(1) expect(warnings).toHaveLength(1) expect(warnings[0]).toContain('carries 2 cardinality token(s) but only 1 entry(ies) parse') }) it('stays silent when every Relations entry parses', () => { const { warnings } = parseEntityDoc( doc('- **Relations** : Thing *→1 Client — FK ClientId, scope same-module.\n'), 'CRM/X', ) expect(warnings).toEqual([]) }) }) describe('parseEntityDoc — **Portée** (tenancy)', () => { it('reads the entity bullet into the closed vocabulary and keeps the raw', () => { const { entities } = parseEntityDoc('### ENT-001 — Thing\n- **Portée** : optional — modèles partagés\n', 'CRM/X') expect(entities[0]!.tenancy).toBe('optional') expect(entities[0]!.tenancyRaw).toBe('optional — modèles partagés') }) it('a document-level bullet (before the first heading) is inherited; an entity bullet overrides it', () => { const md = '- **Portée** : none\n\n### ENT-001 — A\n\n### ENT-002 — B\n- **Portée** : strict\n' const { entities } = parseEntityDoc(md, 'CRM/X') expect(entities.map((e) => e.tenancy)).toEqual(['none', 'strict']) }) it('an unreadable value keeps the raw and yields tenancy null — the near-miss pair', () => { const { entities } = parseEntityDoc('### ENT-001 — Thing\n- **Portée** : partagé\n', 'CRM/X') expect(entities[0]!.tenancyRaw).toBe('partagé') expect(entities[0]!.tenancy).toBeNull() }) it('absent everywhere → both null (the scaffolder default applies, and DM-028 says so)', () => { const { entities } = parseEntityDoc('### ENT-001 — Thing\n', 'CRM/X') expect(entities[0]!.tenancy).toBeNull() expect(entities[0]!.tenancyRaw).toBeNull() }) })