/** * derive-detail-summary.test.ts — pure cores + FS wrapper. */ import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import path from 'node:path' import { checkDetailSummaryOfPagespec, deriveDetailSummaryForPagespec, execute, parseEntityAttributes, } from '../execute.js' // --------------------------------------------------------------------------- // Fixtures // --------------------------------------------------------------------------- const ENTITE_MD = ` ### ENT-001 — Vehicle (agrégat racine) | Attribut | Type | Contraintes | Calculé | |----------|------|-------------|---------| | Id | Guid | PK | — | | Plate | string(20) | requis | — | | Status | enum | ACTIF/IMMOBILISE/SORTI | — | | SiteId | Guid | requis | — | | Mileage | int | requis | — | | EntryDate | date | requis | — | | Notes | string(2000) | — | — | | TotalCost | decimal(18,2) | requis | \`sum(costs)\` | | Brand | string(50) | requis | — | | Model | string(50) | requis | — | | Color | string(30) | requis | — | - **Affichage** : Plate ### ENT-002 — Assignment (transaction) | Attribut | Type | Contraintes | Calculé | |----------|------|-------------|---------| | Id | Guid | PK | — | | StartDate | date | requis | — | - **Affichage** : Id ### ENT-003 — DueDate (transaction) | Attribut | Type | Contraintes | Calculé | |----------|------|-------------|---------| | Id | Guid | PK | — | | Phone | string(50) | requis | — | ### ENT-004 — AlertRule (paramétrage) | Attribut | Type | Contraintes | Calculé | |----------|------|-------------|---------| | Id | Guid | PK | — | | Name | string(100) | requis | — | | DraftStatus | enum | DRAFT/LIVE | — | | RunState | enum | IDLE/RUNNING | — | ` const spec = (entity: string, extra: Record = {}) => { const block = { entity, view: 'detail', ...extra } return `# ${entity} — detail\n\nIntro prose.\n\n\`\`\`json\n${JSON.stringify(block, null, 2)}\n\`\`\`\n\nTrailing prose.\n` } const relTab = (key: string, extra: Record = {}) => ({ key, displayMode: 'table', relatedEntity: 'Assignment', relationFk: 'vehicleId', relatedModule: 'parc', relatedSection: 'affectations', ...extra, }) const summaryOf = (md: string) => { const json = /```json\s*\n([\s\S]*?)\n```/.exec(md)![1] return (JSON.parse(json) as { summary?: Record }).summary } // --------------------------------------------------------------------------- // parseEntityAttributes // --------------------------------------------------------------------------- describe('parseEntityAttributes', () => { it('reads the entity block rows in declaration order, header skipped', () => { const attrs = parseEntityAttributes(ENTITE_MD, 'Vehicle') expect(attrs.map((a) => a.name).slice(0, 3)).toEqual(['Id', 'Plate', 'Status']) expect(attrs.find((a) => a.name === 'TotalCost')!.computed).toContain('sum') }) it("a sibling entity's rows never leak", () => { expect(parseEntityAttributes(ENTITE_MD, 'Assignment').map((a) => a.name)).toEqual(['Id', 'StartDate']) }) }) // --------------------------------------------------------------------------- // derive core // --------------------------------------------------------------------------- describe('deriveDetailSummaryForPagespec', () => { it('derives title from **Affichage**, status from the single state enum, ≤4 meta fields in declaration order', () => { const { md, outcome } = deriveDetailSummaryForPagespec(spec('Vehicle'), 'Vehicle.detail.md', ENTITE_MD) expect(outcome.status).toBe('derived') const summary = summaryOf(md)! expect(summary.titleField).toBe('plate') expect(summary.statusField).toBe('status') // Declaration order; SiteId (Guid), Notes (not requis), TotalCost // (computed), Plate/Status (title/status), audit columns all excluded; // capped at 4 (Color falls off). expect(summary.fields).toEqual(['mileage', 'entryDate', 'brand', 'model']) }) it("**Affichage** : Id → opt-out, nothing written", () => { const src = spec('Assignment') const { md, outcome } = deriveDetailSummaryForPagespec(src, 'Assignment.detail.md', ENTITE_MD) expect(outcome.status).toBe('opt-out') expect(md).toBe(src) }) it('no Affichage line → the lib display cascade over stored string attributes (Name wins)', () => { const { md, outcome } = deriveDetailSummaryForPagespec(spec('AlertRule'), 'AlertRule.detail.md', ENTITE_MD) expect(outcome.status).toBe('derived') expect(summaryOf(md)!.titleField).toBe('name') }) it('two state-semantics enums → statusField OMITTED with the reason (never a guess)', () => { const { md, outcome } = deriveDetailSummaryForPagespec(spec('AlertRule'), 'AlertRule.detail.md', ENTITE_MD) expect(summaryOf(md)!.statusField).toBeUndefined() expect(outcome.reason).toContain('statusField omitted') }) it('no title anchor at all → needs-judgment, file byte-identical', () => { // DueDate: no Affichage, no display-family attribute (Phone is not in the cascade). const src = spec('DueDate') const { md, outcome } = deriveDetailSummaryForPagespec(src, 'DueDate.detail.md', ENTITE_MD) expect(outcome.status).toBe('needs-judgment') expect(outcome.reason).toContain('no **Affichage** line') expect(md).toBe(src) }) it('idempotent: an existing summary or uiDesign.detail.summary is never touched', () => { const withSummary = spec('Vehicle', { summary: { titleField: 'plate' } }) expect(deriveDetailSummaryForPagespec(withSummary, 'x', ENTITE_MD).outcome.status).toBe('already') const withOverlay = spec('Vehicle', { uiDesign: { detail: { summary: { titleField: 'plate' } } } }) expect(deriveDetailSummaryForPagespec(withOverlay, 'x', ENTITE_MD).outcome.status).toBe('already') }) it('preserves the markdown around the machine block', () => { const { md } = deriveDetailSummaryForPagespec(spec('Vehicle'), 'Vehicle.detail.md', ENTITE_MD) expect(md.startsWith('# Vehicle — detail\n\nIntro prose.')).toBe(true) expect(md.endsWith('Trailing prose.\n')).toBe(true) }) it('skips non-detail views and malformed blocks', () => { const form = `\`\`\`json\n{"entity":"Vehicle","view":"form"}\n\`\`\`\n` expect(deriveDetailSummaryForPagespec(form, 'x', ENTITE_MD).outcome.status).toBe('skipped') expect(deriveDetailSummaryForPagespec('no block here', 'x', ENTITE_MD).outcome.status).toBe('skipped') }) }) // --------------------------------------------------------------------------- // check core (PRD-134) // --------------------------------------------------------------------------- describe('checkDetailSummaryOfPagespec — the PRD-134 engine', () => { it('flags ≥ 4 rendered strip tabs with no summary; silent with one, silent below', () => { const crowded = spec('Vehicle', { tabs: [{ key: 'a' }, { key: 'b' }], relatedTabs: [relTab('r1', { relatedSection: 's1' }), relTab('r2', { relatedSection: 's2' })], }) expect(checkDetailSummaryOfPagespec(crowded, 'p', new Set(['detail']))).toHaveLength(1) expect(checkDetailSummaryOfPagespec(crowded, 'p', new Set(['detail']))[0].message).toContain('identity and state vanish') const withSummary = spec('Vehicle', { summary: { titleField: 'plate' }, tabs: [{ key: 'a' }, { key: 'b' }], relatedTabs: [relTab('r1'), relTab('r2')], }) expect(checkDetailSummaryOfPagespec(withSummary, 'p', new Set(['detail']))).toHaveLength(0) const three = spec('Vehicle', { tabs: [{ key: 'a' }], relatedTabs: [relTab('r1'), relTab('r2')] }) expect(checkDetailSummaryOfPagespec(three, 'p', new Set(['detail']))).toHaveLength(0) }) it('honours the unified fiche (field tabs melt) and counts the synthetic info tab', () => { const unified = spec('Vehicle', { tabs: [{ key: 'a' }, { key: 'b' }, { key: 'c' }], relatedTabs: [relTab('r1'), relTab('r2')], }) // detail+form → unified → 0 + 2 = 2 → silent (would be 5 legacy). expect(checkDetailSummaryOfPagespec(unified, 'p', new Set(['detail', 'form']))).toHaveLength(0) // 3 related alone → info + 3 = 4 → finding. const infoPush = spec('Vehicle', { relatedTabs: [relTab('r1'), relTab('r2'), relTab('r3')] }) expect(checkDetailSummaryOfPagespec(infoPush, 'p', new Set(['detail']))).toHaveLength(1) }) }) // --------------------------------------------------------------------------- // FS wrapper // --------------------------------------------------------------------------- describe('execute — filesystem wrapper', () => { let root: string beforeEach(() => { root = mkdtempSync(path.join(tmpdir(), 'derive-detail-summary-')) mkdirSync(path.join(root, 'pagespecs')) writeFileSync(path.join(root, 'entité.md'), ENTITE_MD, 'utf-8') writeFileSync(path.join(root, 'pagespecs', 'Vehicle.detail.md'), spec('Vehicle'), 'utf-8') writeFileSync(path.join(root, 'pagespecs', 'Vehicle.form.md'), `\`\`\`json\n{"entity":"Vehicle","view":"form"}\n\`\`\`\n`, 'utf-8') }) afterEach(() => rmSync(root, { recursive: true, force: true })) it('derive writes once, then reports already (idempotent)', () => { const s = { pagespecDir: path.join(root, 'pagespecs'), moduleRoot: root, mode: 'derive' as const } const first = execute(s, root) expect(first.written).toBe(1) expect(readFileSync(path.join(root, 'pagespecs', 'Vehicle.detail.md'), 'utf-8')).toContain('"summary"') const second = execute(s, root) expect(second.written).toBe(0) expect(second.outcomes.find((o) => o.entity === 'Vehicle')!.status).toBe('already') }) it('check resolves the view set from the pagespec filenames (unified honoured)', () => { // Vehicle has detail+form on disk → unified → 3 field tabs melt; 3 related // strip tabs alone stay under the threshold → no finding. writeFileSync( path.join(root, 'pagespecs', 'Vehicle.detail.md'), spec('Vehicle', { tabs: [{ key: 'a' }, { key: 'b' }, { key: 'c' }], relatedTabs: [relTab('r1'), relTab('r2'), relTab('r3')] }), 'utf-8', ) const report = execute({ pagespecDir: path.join(root, 'pagespecs'), moduleRoot: root, mode: 'check' }, root) expect(report.findings).toHaveLength(0) }) })