/** * compute-page-diff — unit + guard tests. * * Covers the F4.1 test matrix: * - canonical-hash determinism is tested in lib/__tests__/canonical-hash.test.ts * - tests 4-9 : computeDiff spec-diff scenarios + extractFencedJson * - test 10 : toRegenerate empty when nothing changed (skip signal) * - tests 11-14 : disk-drift guard (silent-skip class) — these pin the * orchestrator's Phase 3a union contract using INJECTED * exists/errCount deps, so the union logic is unit-testable * without a real web project. */ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { canonicalHash } from '../../../../lib/canonical-hash.js' import { computeDiff } from '../compute-diff.js' import { extractFencedJson, scanPagespecs, loadSnapshot, SNAPSHOT_FILENAME } from '../scan-pagespecs.js' import { computeDiskDrift, selectPagesToRegenerate } from '../disk-drift.js' const SPEC_A = { screenCode: 'SCR-A-001', entity: 'TypeAffaire', view: 'list', columns: [{ key: 'code' }] } const SPEC_B = { screenCode: 'SCR-B-001', entity: 'TypeAffaire', view: 'form', fields: ['code', 'libelle'] } describe('extractFencedJson (compute-page-diff copy)', () => { it('extracts the first fenced json block (test 9)', () => { expect(extractFencedJson('# T\n\n```json\n{"x":1}\n```\n')).toBe('{"x":1}') }) it('returns null when the json tag is absent', () => { expect(extractFencedJson('```\n{"x":1}\n```')).toBeNull() }) }) describe('computeDiff', () => { let moduleRoot: string beforeEach(() => { moduleRoot = mkdtempSync(join(tmpdir(), 'compute-page-diff-')) mkdirSync(join(moduleRoot, 'pagespecs'), { recursive: true }) }) afterEach(() => rmSync(moduleRoot, { recursive: true, force: true })) function writePagespec(name: string, spec: object): void { writeFileSync(join(moduleRoot, 'pagespecs', name), '```json\n' + JSON.stringify(spec) + '\n```\n', 'utf8') } function writeSnapshot(pages: Record): void { writeFileSync(join(moduleRoot, SNAPSHOT_FILENAME), JSON.stringify({ lastRun: '2026-05-28T00:00:00Z', pages }), 'utf8') } it('treats every page as added when no snapshot exists (test 4)', () => { writePagespec('TypeAffaire.list.md', SPEC_A) writePagespec('TypeAffaire.form.md', SPEC_B) const r = computeDiff({ moduleRoot }) expect(r.snapshotFound).toBe(false) expect(r.diff.added).toEqual(['TypeAffaire.form', 'TypeAffaire.list']) expect(r.diff.modified).toEqual([]) expect(r.diff.unchanged).toEqual([]) expect(r.toRegenerate).toEqual(['TypeAffaire.form', 'TypeAffaire.list']) }) it('treats every page as unchanged when the snapshot matches (test 5 + test 10)', () => { writePagespec('TypeAffaire.list.md', SPEC_A) writePagespec('TypeAffaire.form.md', SPEC_B) writeSnapshot({ 'TypeAffaire.list': canonicalHash(SPEC_A), 'TypeAffaire.form': canonicalHash(SPEC_B) }) const r = computeDiff({ moduleRoot }) expect(r.snapshotFound).toBe(true) expect(r.diff.unchanged).toEqual(['TypeAffaire.form', 'TypeAffaire.list']) expect(r.diff.added).toEqual([]) // test 10: spec-side regenerate set is empty → the orchestrator's skip signal expect(r.toRegenerate).toEqual([]) }) it('detects a single modified page (test 6)', () => { writePagespec('TypeAffaire.list.md', SPEC_A) writeSnapshot({ 'TypeAffaire.list': 'sha256-STALE' }) const r = computeDiff({ moduleRoot }) expect(r.diff.modified).toHaveLength(1) expect(r.diff.modified[0]).toMatchObject({ key: 'TypeAffaire.list', previousHash: 'sha256-STALE' }) expect(r.diff.modified[0].currentHash).toBe(canonicalHash(SPEC_A)) expect(r.toRegenerate).toEqual(['TypeAffaire.list']) }) it('detects a single added page alongside an unchanged one (test 7)', () => { writePagespec('TypeAffaire.list.md', SPEC_A) writePagespec('TypeAffaire.form.md', SPEC_B) writeSnapshot({ 'TypeAffaire.list': canonicalHash(SPEC_A) }) const r = computeDiff({ moduleRoot }) expect(r.diff.added).toEqual(['TypeAffaire.form']) expect(r.diff.unchanged).toEqual(['TypeAffaire.list']) expect(r.toRegenerate).toEqual(['TypeAffaire.form']) }) it('detects a removed page (in snapshot, no longer on disk) (test 8)', () => { writePagespec('TypeAffaire.list.md', SPEC_A) writeSnapshot({ 'TypeAffaire.list': canonicalHash(SPEC_A), 'TypeAffaire.detail': 'sha256-OLD' }) const r = computeDiff({ moduleRoot }) expect(r.diff.removed).toEqual(['TypeAffaire.detail']) expect(r.diff.unchanged).toEqual(['TypeAffaire.list']) expect(r.toRegenerate).toEqual([]) // removed pages are not regenerated }) it('handles a mixed add/modify/unchanged/remove diff', () => { writePagespec('TypeAffaire.list.md', SPEC_A) // unchanged writePagespec('TypeAffaire.form.md', SPEC_B) // modified writePagespec('TypeAffaire.detail.md', { v: 1 }) // added writeSnapshot({ 'TypeAffaire.list': canonicalHash(SPEC_A), 'TypeAffaire.form': 'sha256-STALE', 'TypeAffaire.kanban': 'sha256-GONE', }) const r = computeDiff({ moduleRoot }) expect(r.diff.added).toEqual(['TypeAffaire.detail']) expect(r.diff.modified.map((m) => m.key)).toEqual(['TypeAffaire.form']) expect(r.diff.unchanged).toEqual(['TypeAffaire.list']) expect(r.diff.removed).toEqual(['TypeAffaire.kanban']) expect(r.toRegenerate).toEqual(['TypeAffaire.detail', 'TypeAffaire.form']) }) it('warns and skips a malformed pagespec without aborting the diff', () => { writePagespec('TypeAffaire.list.md', SPEC_A) writeFileSync(join(moduleRoot, 'pagespecs', 'Broken.list.md'), '# no fenced block', 'utf8') const r = computeDiff({ moduleRoot }) expect(r.diff.added).toEqual(['TypeAffaire.list']) expect(r.warnings.some((w) => w.includes('Broken.list.md'))).toBe(true) }) }) describe('scanPagespecs / loadSnapshot', () => { it('returns empty (no warning) when pagespecs/ is absent', () => { const empty = mkdtempSync(join(tmpdir(), 'cpd-empty-')) try { expect(scanPagespecs(empty)).toEqual({ pages: [], warnings: [] }) expect(loadSnapshot(empty)).toEqual({ snapshot: { pages: {} }, found: false }) } finally { rmSync(empty, { recursive: true, force: true }) } }) }) describe('disk-drift guard (orchestrator Phase 3a union contract)', () => { it('flags an unchanged page whose .tsx is missing (test 11)', () => { const drift = computeDiskDrift(['TypeAffaire.list'], { exists: () => false, errCount: () => 0, }) expect(drift).toEqual(['TypeAffaire.list']) }) it('flags an unchanged page whose .tsx has err violations (test 12)', () => { const drift = computeDiskDrift(['TypeAffaire.list'], { exists: () => true, errCount: (k) => (k === 'TypeAffaire.list' ? 3 : 0), }) expect(drift).toEqual(['TypeAffaire.list']) }) it('does NOT flag an unchanged page that is present and valid (test 13)', () => { const drift = computeDiskDrift(['TypeAffaire.list', 'TypeAffaire.form'], { exists: () => true, errCount: () => 0, }) expect(drift).toEqual([]) }) it('skips Phase 3a when spec-diff AND disk-drift are both empty (test 14)', () => { // all unchanged + all valid on disk → nothing to regenerate → Phase 3a skipped const diskDrift = computeDiskDrift(['A.list', 'B.form'], { exists: () => true, errCount: () => 0 }) expect(selectPagesToRegenerate([], diskDrift)).toEqual([]) }) it('unions spec-diff with disk-drift, de-duplicated and sorted', () => { expect(selectPagesToRegenerate(['B.form', 'A.list'], ['A.list', 'C.detail'])).toEqual([ 'A.list', 'B.form', 'C.detail', ]) }) })