import { describe, it, expect } from 'vitest'; import { SUB_COMPONENT_COLLECTIONS, stripSubComponents, summarizeSubComponents, reconcile, } from '@beehexa/hexasync-template-compose'; import { renderReport } from '@beehexa/hexasync-template-report-render'; import { renderReportHtml } from '@beehexa/hexasync-template-report-render'; import { buildMermaid } from '@beehexa/hexasync-template-report-render'; import { TracerEvent } from '@beehexa/hexasync-template-compose'; const ev = ( parentKey: string, idOrKey: string, operation: 'added' | 'customized', generation: number, ): TracerEvent => ({ idOrKey, parentKey, operation, depth: 0, sourceFile: 'f', project: generation === 0 ? 'THIS' : 'base', generation, weight: generation === 0 ? NaN : 1, }); describe('sub-component registry + stripSubComponents', () => { it('registers metrics (rollup) and targets (detail)', () => { expect(SUB_COMPONENT_COLLECTIONS.objects).toEqual([ { field: 'metrics', kind: 'array', mode: 'rollup' }, ]); expect(SUB_COMPONENT_COLLECTIONS.webhooks).toEqual([ { field: 'targets', kind: 'map', mode: 'detail' }, ]); }); it('strips registered sub-collection fields (both modes) from a component clone', () => { const obj = { id: 'O', name: 'task', metrics: [{ id: 'M' }] }; const stripped = stripSubComponents('objects', obj) as any; expect(stripped.metrics).toBeUndefined(); expect(stripped.name).toBe('task'); expect(obj.metrics).toBeDefined(); // original not mutated const wh = { id: 'W', targets: { a: {} }, name: 'hook' }; expect((stripSubComponents('webhooks', wh) as any).targets).toBeUndefined(); }); it('is a no-op for unregistered parents, arrays, and non-objects', () => { const arr = [{ id: 'A' }]; expect(stripSubComponents('pushers', { id: 'P', metrics: [] })).toEqual({ id: 'P', metrics: [], }); expect(stripSubComponents('objects', arr)).toBe(arr); expect(stripSubComponents('objects', null)).toBe(null); }); }); describe('parent self-diff excludes sub-components', () => { it('a task whose ONLY change is a metric has an empty parent self-diff', () => { const anc = { objects: [{ id: 'O', name: 'same', metrics: [{ id: 'M', v: 1 }] }], }; const final = { objects: [{ id: 'O', name: 'same', metrics: [{ id: 'M', v: 2 }] }], }; const model = reconcile( [ev('objects', 'O', 'added', 1), ev('objects', 'O', 'customized', 0)], [], final, [ { project: 'base', generation: 1, weight: 1 }, { project: 'THIS', generation: 0, weight: NaN }, ], 'demo', { snapshots: new Map([ [JSON.stringify(['objects', 'O']), anc.objects[0]], ]), projectCheckpoints: [ { label: 'base', output: anc }, { label: 'THIS', output: final }, ], }, ); const o = model.components.find((c) => c.idOrKey === 'O')!; expect(o.diffText ?? '').not.toMatch(/v: 1/); expect(o.diffText ?? '').not.toMatch(/metrics/); }); }); describe('summarizeSubComponents (counts only)', () => { const opts = (anc: any, final: any) => ({ projectCheckpoints: [ { label: 'base', output: anc }, { label: 'THIS', output: final }, ], }); it('counts added / removed / customized for metrics (rollup)', () => { const anc = { objects: [ { id: 'O', metrics: [{ id: 'M1', v: 1 }, { id: 'M2' }, { id: 'M4', v: 9 }], }, ], }; const final = { objects: [ { id: 'O', metrics: [{ id: 'M1', v: 2 }, { id: 'M3' }, { id: 'M4', v: 9 }], }, ], }; const [sum] = summarizeSubComponents( 'objects', 'O', final, opts(anc, final), ); expect(sum).toEqual({ title: 'metrics', added: 1, removed: 1, customized: 1, }); }); it('omits collections with all-zero changes', () => { const anc = { objects: [{ id: 'O', metrics: [{ id: 'M', v: 1 }] }] }; const final = { objects: [{ id: 'O', metrics: [{ id: 'M', v: 1 }] }] }; expect( summarizeSubComponents('objects', 'O', final, opts(anc, final)), ).toEqual([]); }); it('does not count a checkpoint-failure item (in final, held by no checkpoint) as added', () => { const final = { objects: [{ id: 'O', metrics: [{ id: 'M', v: 1 }] }] }; const o = { projectCheckpoints: [ { label: 'base', output: {} }, { label: 'THIS', output: {} }, ], }; expect(summarizeSubComponents('objects', 'O', final, o)).toEqual([]); }); it('returns [] for detail-only or unregistered parents', () => { const final = { webhooks: [{ id: 'W', targets: { a: {} } }] }; expect( summarizeSubComponents('webhooks', 'W', final, opts(final, final)), ).toEqual([]); expect( summarizeSubComponents('pushers', 'P', final, opts(final, final)), ).toEqual([]); }); }); describe('webhook targets keep detailed sub-table + pullSteps chart', () => { it('reconciles targets and renders each target pullSteps as a flow', () => { const anc = { webhooks: [ { id: 'W', targets: { customer: { pullSteps: [{ key: 'A', name: 'Pull', next: null }] }, order: { pullSteps: [] }, }, }, ], }; const final = { webhooks: [ { id: 'W', targets: { customer: { pullSteps: [{ key: 'A', name: 'Pull v2', next: null }], }, invoice: { pullSteps: [{ key: 'B', name: 'New', next: null }] }, }, }, ], }; const model = reconcile( [ev('webhooks', 'W', 'added', 1), ev('webhooks', 'W', 'customized', 0)], [], final, [ { project: 'base', generation: 1, weight: 1 }, { project: 'THIS', generation: 0, weight: NaN }, ], 'demo', { snapshots: new Map([ [JSON.stringify(['webhooks', 'W']), anc.webhooks[0]], ]), projectCheckpoints: [ { label: 'base', output: anc }, { label: 'THIS', output: final }, ], }, ); const w = model.components.find((c) => c.idOrKey === 'W')!; expect(w.subTable?.title).toBe('targets'); const byKey = Object.fromEntries( (w.subTable?.items ?? []).map((t) => [t.idOrKey, t]), ); expect(byKey['customer'].status).toBe('customized'); expect(byKey['customer'].flow?.kind).toBe('puller'); /** * The chart NAMES the target it draws (Story 4.6). `source.componentId` is the target key, so `customer`'s steps and * `invoice`'s get different addresses; without it every target's `pullSteps:key_A` was the same node id. */ expect(byKey['customer'].flow?.source?.componentId).toBe('customer'); expect(byKey['invoice'].flow?.source?.componentId).toBe('invoice'); expect(buildMermaid(byKey['customer'].flow!)).toContain( 'pullers__id_customer__pullSteps__key_A["Pull v2 · A"]', ); expect(buildMermaid(byKey['invoice'].flow!)).toContain( 'pullers__id_invoice__pullSteps__key_B["New · B"]', ); expect(byKey['invoice'].status).toBe('added'); expect(byKey['order'].status).toBe('excluded'); expect(w.subComponents).toBeUndefined(); expect(renderReportHtml(model)).toContain('puller flow —'); }); }); describe('parent status promotion from sub-component changes', () => { const base = (metricsAnc: any[], metricsFinal: any[]) => { const anc = { objects: [{ id: 'O', name: 'same', metrics: metricsAnc }] }; const final = { objects: [{ id: 'O', name: 'same', metrics: metricsFinal }], }; return reconcile( [ev('objects', 'O', 'added', 1), ev('objects', 'O', 'customized', 0)], [], final, [ { project: 'base', generation: 1, weight: 1 }, { project: 'THIS', generation: 0, weight: NaN }, ], 'demo', { snapshots: new Map([ [JSON.stringify(['objects', 'O']), anc.objects[0]], ]), projectCheckpoints: [ { label: 'base', output: anc }, { label: 'THIS', output: final }, ], }, ).components.find((c) => c.idOrKey === 'O')!; }; it('clean self-diff + a changed metric ⇒ customized, with a rollup', () => { const o = base([{ id: 'M', v: 1 }], [{ id: 'M', v: 2 }]); expect(o.status).toBe('customized'); expect(o.subComponents).toEqual([ { title: 'metrics', added: 0, removed: 0, customized: 1 }, ]); expect(o.subTable).toBeUndefined(); }); it('no self change and no metric change ⇒ inherited', () => { const o = base([{ id: 'M', v: 1 }], [{ id: 'M', v: 1 }]); expect(o.status).toBe('inherited'); expect(o.subComponents).toBeUndefined(); }); }); describe('rollup rendering + perf contract', () => { const bigModel = () => { const metricsAnc = Array.from({ length: 500 }, (_, i) => ({ id: `M${i}`, v: 1, })); const metricsFinal = metricsAnc.map((m, i) => i < 3 ? { id: m.id, v: 2 } : m, ); const anc = { objects: [{ id: 'O', name: 'x', metrics: metricsAnc }] }; const final = { objects: [{ id: 'O', name: 'x', metrics: metricsFinal }] }; return reconcile( [ev('objects', 'O', 'added', 1), ev('objects', 'O', 'customized', 0)], [], final, [ { project: 'base', generation: 1, weight: 1 }, { project: 'THIS', generation: 0, weight: NaN }, ], 'demo', { snapshots: new Map([ [JSON.stringify(['objects', 'O']), anc.objects[0]], ]), projectCheckpoints: [ { label: 'base', output: anc }, { label: 'THIS', output: final }, ], }, ); }; it('renders a compact metrics rollup line and NO per-item table', () => { const model = bigModel(); const md = renderReport(model); expect(md).toMatch(/metrics:.*3 customized/); expect(md).not.toContain('metrics —'); expect(renderReportHtml(model)).toContain('metrics:'); }); it('perf contract: rollup carries no per-item subTable', () => { const o = bigModel().components.find((c) => c.idOrKey === 'O')!; expect(o.subTable).toBeUndefined(); expect(o.subComponents).toEqual([ { title: 'metrics', added: 0, removed: 0, customized: 3 }, ]); }); });