import { describe, it, expect } from 'vitest'; import { createTracer, reconcile } from '@beehexa/hexasync-template-compose'; import { mergeToMain, mergeKey } from '@beehexa/hexasync-template-compose'; import { renderReport } from '@beehexa/hexasync-template-report-render'; import { TracerEvent } from '@beehexa/hexasync-template-compose'; const projects = [ { project: 'sapo', generation: 1, weight: 20 }, { project: 'THIS', generation: 0, weight: NaN }, ]; function ev(p: Partial): TracerEvent { return { idOrKey: 'X', parentKey: 'pushers', operation: 'added', depth: 0, sourceFile: 'f', project: 'sapo', generation: 1, weight: 20, ...p, }; } describe('reconcile — classification (Story 1.8)', () => { it('flags LOST when an ancestor component is absent with no exclude directive', () => { const events = [ev({ idOrKey: 'GONE', operation: 'added', generation: 1 })]; const finalOutput = { pushers: [{ id: 'KEPT' }] }; const model = reconcile(events, [], finalOutput, projects, 'demo'); expect(model.counts.lost).toBe(1); expect(model.warnings.some((w) => w.includes('GONE'))).toBe(true); }); it('classifies excluded (not lost) when a ! directive removed it', () => { const events = [ ev({ idOrKey: 'DROP', operation: 'excluded', generation: 0, project: 'THIS', }), ]; const model = reconcile( events, [], { pushers: [{ id: 'OTHER' }] }, projects, 'demo', ); expect(model.counts.excluded).toBe(1); expect(model.counts.lost).toBe(0); }); it('exclude-then-readd => present wins (OQ-4)', () => { const events = [ ev({ idOrKey: 'R', operation: 'excluded', generation: 1, project: 'sapo', }), ev({ idOrKey: 'R', operation: 'added', generation: 0, project: 'THIS', weight: NaN, }), ]; const model = reconcile( events, [], { pushers: [{ id: 'R' }] }, projects, 'demo', ); const r = model.components.find((c) => c.idOrKey === 'R')!; expect(['added', 'customized']).toContain(r.status); // present, not excluded/lost }); it('nested (depth>0) events never trigger LOST', () => { const events = [ ev({ idOrKey: 'STEP', depth: 1, operation: 'added', generation: 1 }), ]; const model = reconcile( events, [], { pushers: [{ id: 'P' }] }, projects, 'demo', ); expect(model.counts.lost).toBe(0); }); it('customized when touched by more than one generation, with hierarchy path', () => { const events = [ ev({ idOrKey: 'P', operation: 'added', generation: 1, project: 'sapo' }), ev({ idOrKey: 'P', operation: 'customized', generation: 0, project: 'THIS', }), ]; // Provide the ancestor-original snapshot so there IS a real change to diff // (customized now requires an actual change; identical re-includes are inherited). const snapshots = new Map(); snapshots.set(JSON.stringify(['pushers', 'P']), { id: 'P', name: 'old' }); const model = reconcile( events, [], { pushers: [{ id: 'P', name: 'new' }] }, projects, 'demo', { snapshots }, ); const p = model.components.find((c) => c.idOrKey === 'P')!; expect(p.status).toBe('customized'); expect(p.hierarchyPath).toBe('sapo => THIS'); }); }); describe('engine tracer — non-regression (Story 1.7, AD-1)', () => { it('mergeKey output is identical whether or not a tracer is passed', () => { const from = [{ id: 'A', name: 'a' }]; const override = [{ id: 'A', name: 'a2' }, { id: 'B' }]; const withoutTracer = mergeKey( 'f', 'pushers', JSON.parse(JSON.stringify(from)), JSON.parse(JSON.stringify(override)), ); const tracer = createTracer(); const withTracer = mergeKey( 'f', 'pushers', JSON.parse(JSON.stringify(from)), JSON.parse(JSON.stringify(override)), tracer, 0, ); expect(withTracer).toEqual(withoutTracer); // and the tracer observed the operations const ops = tracer .events() .map((e) => `${e.idOrKey}:${e.operation}`) .sort(); expect(ops).toEqual(['A:customized', 'B:added']); }); }); describe('renderReport (Story 1.9)', () => { it('puts warnings at the top and includes provenance + merge order', () => { const model = reconcile( [ev({ idOrKey: 'GONE', operation: 'added', generation: 1 })], [], { pushers: [{ id: 'KEPT' }] }, projects, 'demo', ); const md = renderReport(model); const summaryIdx = md.indexOf('## ⚠ Summary'); const provenanceIdx = md.indexOf('## Global component provenance'); expect(summaryIdx).toBeGreaterThan(-1); expect(summaryIdx).toBeLessThan(provenanceIdx); // warnings/summary first expect(md).toContain('Composed projects (resolved merge order)'); expect(md).toContain('⚠ LOST'); }); });