import { describe, it, expect } from 'vitest'; import { resolve, ResolverIO } from '@beehexa/hexasync-template-compose'; import { mergeKey, overrideVariables, } from '@beehexa/hexasync-template-compose'; import { reconcile, createTracer } from '@beehexa/hexasync-template-compose'; import { renderReport } from '@beehexa/hexasync-template-report-render'; import { renderReportHtml } from '@beehexa/hexasync-template-report-render'; import { makeVirtualIO } from './fixtures'; import { TracerEvent } from '@beehexa/hexasync-template-compose'; const read = (io: ResolverIO, p: string): Promise => io.readFileIfExists(p) as Promise; describe('CR fix: CAP-5 diamond dedupe is replacement-aware', () => { it('same base referenced twice with different replacements yields two instances', async () => { const io = makeVirtualIO({ '/vfs/int/partials/main.yaml': `externals:\n - type: project\n path: ../../misa\n - type: project\n path: ../../sapo\n`, '/vfs/misa/partials/main.yaml': `externals:\n - type: project\n path: ../../base\n replacements:\n - fromKey: '**GEN**'\n toKey: '**MISA**'\n`, '/vfs/sapo/partials/main.yaml': `externals:\n - type: project\n path: ../../base\n replacements:\n - fromKey: '**GEN**'\n toKey: '**SAPO**'\n`, '/vfs/base/partials/main.yaml': `externals: []\n`, '/vfs/base/partials/B.yaml': `pushers:\n - id: '**GEN**'\n`, }); const r = await resolve( '/vfs/int/partials', await read(io, '/vfs/int/partials/main.yaml'), io, ); const baseItems = r.worklist.filter((w) => w.path.includes('base/partials/B.yaml'), ); expect(baseItems).toHaveLength(2); const toKeys = baseItems .flatMap((i) => i.replacements.map((x) => x.toKey)) .sort(); expect(toKeys).toEqual(['**MISA**', '**SAPO**']); }); it('true diamond (identical replacements) still dedupes to one', async () => { const io = makeVirtualIO({ '/vfs/int/partials/main.yaml': `externals:\n - type: project\n path: ../../misa\n - type: project\n path: ../../sapo\n`, '/vfs/misa/partials/main.yaml': `externals:\n - type: project\n path: ../../base\n`, '/vfs/sapo/partials/main.yaml': `externals:\n - type: project\n path: ../../base\n`, '/vfs/base/partials/main.yaml': `externals: []\n`, '/vfs/base/partials/B.yaml': `pushers:\n - id: BASE\n`, }); const r = await resolve( '/vfs/int/partials', await read(io, '/vfs/int/partials/main.yaml'), io, ); expect( r.worklist.filter((w) => w.path.includes('base/partials/B.yaml')), ).toHaveLength(1); }); }); describe('CR fix: override:true merges last (wins)', () => { it('override local sorts after non-override peers', async () => { const io = makeVirtualIO({ '/vfs/p/partials/main.yaml': `externals:\n - type: local\n path: ./A.yaml\n index: 0\n - type: local\n path: ./B.yaml\n override: true\n`, '/vfs/p/partials/A.yaml': `pushers:\n - id: A\n`, '/vfs/p/partials/B.yaml': `pushers:\n - id: B\n`, }); const r = await resolve( '/vfs/p/partials', await read(io, '/vfs/p/partials/main.yaml'), io, ); const ia = r.worklist.findIndex((w) => w.path.includes('A.yaml')); const ib = r.worklist.findIndex((w) => w.path.includes('B.yaml')); expect(ib).toBeGreaterThan(ia); // override B merged after A }); }); describe('CR fix: deterministic tiebreaks', () => { it('equal-weight siblings keep declaration/BFS order', async () => { const io = makeVirtualIO({ '/vfs/int/partials/main.yaml': `externals:\n - type: project\n path: ../../misa\n weight: 5\n - type: project\n path: ../../sapo\n weight: 5\n`, '/vfs/misa/partials/main.yaml': `externals: []\n`, '/vfs/misa/partials/M.yaml': `pushers:\n - id: M\n`, '/vfs/sapo/partials/main.yaml': `externals: []\n`, '/vfs/sapo/partials/S.yaml': `pushers:\n - id: S\n`, }); const r = await resolve( '/vfs/int/partials', await read(io, '/vfs/int/partials/main.yaml'), io, ); const order = r.worklist.map((w) => w.project); expect(order.indexOf('misa')).toBeLessThan(order.indexOf('sapo')); }); it('equal-index globbed files break ties by filename (glob sorted)', async () => { const io = makeVirtualIO({ '/vfs/p/partials/main.yaml': `externals: []\n`, '/vfs/p/partials/B.yaml': `pushers:\n - id: B\n`, '/vfs/p/partials/A.yaml': `pushers:\n - id: A\n`, }); const r = await resolve( '/vfs/p/partials', await read(io, '/vfs/p/partials/main.yaml'), io, ); const ia = r.worklist.findIndex((w) => w.path.endsWith('A.yaml')); const ib = r.worklist.findIndex((w) => w.path.endsWith('B.yaml')); expect(ia).toBeLessThan(ib); // filename order deterministic }); }); describe('CR fix: externals non-array is tolerated', () => { it('does not crash when externals is a mapping', async () => { const io = makeVirtualIO({ '/vfs/p/partials/main.yaml': `externals:\n oops: not-a-list\npushers:\n - id: X\n`, }); await expect( resolve( '/vfs/p/partials', await read(io, '/vfs/p/partials/main.yaml'), io, ), ).resolves.toBeDefined(); }); }); describe('CR fix: mergeKey depth for arrays nested under a map', () => { it('emits nested id/key items at depth > 0', async () => { const tracer = createTracer(); const from = { notification: { channels: [{ id: 'C1' }] } }; const override = { notification: { channels: [{ id: 'C2' }] } }; mergeKey( 'f', 'notification', from.notification, override.notification, tracer, 0, ); const chanEvent = tracer.events().find((e) => e.idOrKey === 'C2')!; expect(chanEvent.depth).toBeGreaterThan(0); // not a top-level component }); }); describe('CR fix: overrideVariables substitutes longest key first', () => { it('a substring token does not corrupt the longer token', async () => { const out = overrideVariables(`k: "**ProductId**"`, { '**Id**': 'AAA', '**ProductId**': 'BBB', }); expect(out.k).toBe('BBB'); }); }); describe('CR fix: reconcile token-form (no false LOST) + no directive noise', () => { it('a component present in the (token-form) output is not LOST', async () => { const events: TracerEvent[] = [ { idOrKey: '**BaseId**', parentKey: 'pushers', operation: 'added', depth: 0, sourceFile: 'f', project: 'base', generation: 1, weight: 10, }, ]; // token-form output (pre-substitution) — matches tracer ids const finalOutput = { pushers: [{ id: '**BaseId**' }] }; const model = reconcile( events, [], finalOutput, [{ project: 'base', generation: 1, weight: 10 }], 'demo', ); expect(model.counts.lost).toBe(0); expect(model.counts.inherited).toBe(1); }); it('finalComponentIndex tolerates a mixed collection (one id-less element)', async () => { const events: TracerEvent[] = [ { idOrKey: 'P', parentKey: 'pushers', operation: 'added', depth: 0, sourceFile: 'f', project: 'base', generation: 1, weight: 1, }, ]; const finalOutput = { pushers: [{ id: 'P' }, { note: 'no-id-here' }] }; const model = reconcile( events, [], finalOutput, [{ project: 'base', generation: 1, weight: 1 }], 'demo', ); expect(model.counts.lost).toBe(0); }); }); describe('CR fix: report escapes markdown-breaking ids', () => { it('escapes pipe characters in component ids', async () => { const events: TracerEvent[] = [ { idOrKey: 'a|b', parentKey: 'pushers', operation: 'added', depth: 0, sourceFile: 'f', project: 'THIS', generation: 0, weight: NaN, }, ]; const model = reconcile( events, [], { pushers: [{ id: 'a|b' }] }, [{ project: 'THIS', generation: 0, weight: NaN }], 'demo', ); const md = renderReport(model); // Provenance/per-project are HTML tables now → pipes HTML-escaped, not \|. expect(md).toContain('a|b'); expect(md).not.toContain('a|b'); // raw pipe never leaks into a cell }); }); describe('CR round-2: wholesale overwrites are recorded (kept, with detail)', () => { it('id-less scalar array overwrite is recorded (hadComponents=false, no excluded)', async () => { const tracer = createTracer(); mergeKey('f', 'indexGroups', [1, 2], [3], tracer, 0); expect(tracer.overwrites()).toHaveLength(1); expect(tracer.overwrites()[0].hadComponents).toBe(false); expect( tracer.events().filter((e) => e.operation === 'excluded'), ).toHaveLength(0); }); it('overwriting a component array records hadComponents + depth, and emits NO excluded events', async () => { const tracer = createTracer(); mergeKey( 'f', 'pushers', [{ id: 'A' }, { id: 'B' }], [{ noId: true }], tracer, 0, ); expect(tracer.overwrites()).toHaveLength(1); expect(tracer.overwrites()[0].hadComponents).toBe(true); expect(tracer.overwrites()[0].depth).toBe(0); // A wholesale clobber is NOT an explicit `!` removal — it must not emit excluded // events (they'd mislabel the drop). The reporter handles it via the record. expect( tracer.events().filter((e) => e.operation === 'excluded'), ).toHaveLength(0); }); }); describe('CR round-2: report gates on any externals (local OR project)', () => { const read = (io: ResolverIO, p: string): Promise => io.readFileIfExists(p) as Promise; it('type: local externals set hasExternals (report will be written) but not hasInheritance', async () => { const io = makeVirtualIO({ '/vfs/p/partials/main.yaml': `externals:\n - type: local\n path: ./A.yaml\n`, '/vfs/p/partials/A.yaml': `pushers:\n - id: A\n`, }); const r = await resolve( '/vfs/p/partials', await read(io, '/vfs/p/partials/main.yaml'), io, ); expect(r.hasExternals).toBe(true); expect(r.hasInheritance).toBe(false); }); it('no externals (globbed-only) => hasExternals false (no report)', async () => { const io = makeVirtualIO({ '/vfs/q/partials/main.yaml': `externals: []\n`, '/vfs/q/partials/A.yaml': `pushers:\n - id: A\n`, }); const r = await resolve( '/vfs/q/partials', await read(io, '/vfs/q/partials/main.yaml'), io, ); expect(r.hasExternals).toBe(false); }); }); describe('CR round-2: root main.yaml excluded from worklist (no self-merge)', () => { const read = (io: ResolverIO, p: string): Promise => io.readFileIfExists(p) as Promise; it('the current project main.yaml is not re-listed as a component', async () => { const io = makeVirtualIO({ '/vfs/int/partials/main.yaml': `externals:\n - type: project\n path: ../../base\npushers:\n - id: ROOT\n`, '/vfs/base/partials/main.yaml': `externals: []\n`, '/vfs/base/partials/B.yaml': `pushers:\n - id: BASE\n`, }); const r = await resolve( '/vfs/int/partials', await read(io, '/vfs/int/partials/main.yaml'), io, ); expect( r.worklist.some((w) => w.path.endsWith('int/partials/main.yaml')), ).toBe(false); }); }); describe('CR round-2: weight null/blank is rejected', () => { const read = (io: ResolverIO, p: string): Promise => io.readFileIfExists(p) as Promise; it('weight: null throws (not silently coerced to 0)', async () => { const io = makeVirtualIO({ '/vfs/p/partials/main.yaml': `externals:\n - type: project\n path: ../../b\n weight: null\n`, '/vfs/b/partials/main.yaml': `externals: []\n`, }); await expect( resolve( '/vfs/p/partials', await read(io, '/vfs/p/partials/main.yaml'), io, ), ).rejects.toThrow(/invalid weight/); }); }); describe('CR round-2: report highlighting', () => { it('status badges are colored and last-wins is highlighted', async () => { const events: TracerEvent[] = [ { idOrKey: 'P', parentKey: 'pushers', operation: 'added', depth: 0, sourceFile: 'f', project: 'base', generation: 1, weight: 10, }, ]; const model = reconcile( events, [], { pushers: [{ id: 'P' }] }, [ { project: 'base', generation: 1, weight: 10 }, { project: 'THIS', generation: 0, weight: NaN }, ], 'demo', ); const md = renderReport(model); expect(md).toContain('color:'); // colored status badges expect(md).toMatch(/last \(wins\)<\/strong>/); // last-wins highlighted }); }); describe('CR round-2: customized diff + linked hierarchy', () => { it('produces a unified diff (inherited → final) and clickable hierarchy links', async () => { const path = await import('path'); const snapshots = new Map(); snapshots.set(JSON.stringify(['pushers', 'P']), { id: 'P', name: 'old', step: 'A', }); const events: TracerEvent[] = [ { idOrKey: 'P', parentKey: 'pushers', operation: 'added', depth: 0, sourceFile: 'f', project: 'base', generation: 1, weight: 10, }, { idOrKey: 'P', parentKey: 'pushers', operation: 'customized', depth: 0, sourceFile: 'g', project: 'THIS', generation: 0, weight: NaN, }, ]; const model = reconcile( events, [], { pushers: [{ id: 'P', name: 'new', step: 'A' }] }, [ { project: 'base', generation: 1, weight: 10 }, { project: 'THIS', generation: 0, weight: NaN }, ], 'demo', { projectPaths: new Map([ ['base', '/vfs/base/partials'], ['THIS', '/vfs/int/partials'], ]), reportDir: '/vfs/int', snapshots, relative: (from, to) => path.posix.relative(from, to), }, ); const p = model.components.find((c) => c.idOrKey === 'P')!; expect(p.status).toBe('customized'); expect(p.diffText).toMatch(/- name: old/); expect(p.diffText).toMatch(/\+ name: new/); expect(p.hierarchy.find((h) => h.label === 'base')?.link).toBeTruthy(); const md = renderReport(model); // Full-width diff row rendered directly beneath the customized row (5 cols // after the Type column moved into the collapsible group header). expect(md).toContain(']*>\+ name: new/); // added, green bg expect(md).toMatch(/background:#ffebe9;color:#b31d28[^>]*>- name: old/); // removed, red bg expect(md).toMatch(/base<\/a>/); // clickable hierarchy link }); }); describe('CR round-2: HTML report', () => { it('renders a styled self-contained HTML doc with pills and inline diff', async () => { const { renderReportHtml } = await import('@beehexa/hexasync-template-report-render'); const snapshots = new Map(); snapshots.set(JSON.stringify(['pushers', 'P']), { id: 'P', name: 'old' }); const events: TracerEvent[] = [ { idOrKey: 'P', parentKey: 'pushers', operation: 'added', depth: 0, sourceFile: 'f', project: 'base', generation: 1, weight: 10, }, { idOrKey: 'P', parentKey: 'pushers', operation: 'customized', depth: 0, sourceFile: 'g', project: 'THIS', generation: 0, weight: NaN, }, ]; const model = reconcile( events, [], { pushers: [{ id: 'P', name: 'new' }] }, [ { project: 'base', generation: 1, weight: 10 }, { project: 'THIS', generation: 0, weight: NaN }, ], 'demo', { snapshots }, ); const html = renderReportHtml(model); expect(html.startsWith('')).toBe(true); expect(html).toContain('