import { describe, it, expect } from 'vitest'; import { flowEdges } from '@beehexa/hexasync-template-report-render'; /** * `flowEdges` — routing as DATA, shared by every renderer (2026-08-06). * * Exported so a second view of a flow cannot grow a second idea of what routes where. That is not * hypothetical: the mermaid chart and the FLOW-6 rule each had their own, and both were wrong about * `data`-branching IF steps in different ways, in the same week. The extension's preview is the second * consumer. * * Semantics are the runtime's — `NextStepBuilder.ResolveNext` and `IfStepExecutor`. */ const step = (over: Record) => ({ key: 'K', label: 'L', rootStep: false, ...over, }); const edges = (steps: unknown[]) => flowEdges(steps as never); describe('the three route forms', () => { it('a plain string is a route to that step', () => { expect(edges([step({ key: 'A', next: 'B' }), step({ key: 'B' })])).toEqual([ { from: 'A', to: 'B' }, ]); }); it('an IF step routes through its `data`, labelled YES and NO', () => { // `IfStepExecutor` passes `StepData` to the builder, so `data` IS the conditional next. expect( edges([ step({ key: 'D', stepType: 'IF', data: { if: '{{x}}', then: 'A', else: 'B' }, }), step({ key: 'A' }), step({ key: 'B' }), ]), ).toEqual([ { from: 'D', to: 'A', label: 'YES' }, { from: 'D', to: 'B', label: 'NO' }, ]); }); it('an EMPTY branch is the end of the flow, not an edge', () => { // `else: ""` resolves to the empty string — no next step. The corpus writes it deliberately. expect( edges([ step({ key: 'D', stepType: 'IF', data: { if: '{{x}}', then: 'A', else: '' }, }), step({ key: 'A' }), ]), ).toEqual([{ from: 'D', to: 'A', label: 'YES' }]); }); it('a scriban expression routes to every quoted key it names', () => { // Which one it takes is a run-time fact, so a static view shows all of them and keeps the expression. const found = edges([ step({ key: 'A', next: '{{ if x ; "B" ; else ; "C" ; end }}' }), step({ key: 'B' }), step({ key: 'C' }), ]); expect(found.map((e) => e.to)).toEqual(['B', 'C']); expect(found[0]!.raw).toContain('{{'); }); it('nests, because `then` and `else` are themselves routes', () => { const found = edges([ step({ key: 'A', next: { if: '{{a}}', then: { if: '{{b}}', then: 'B', else: 'C' }, else: 'D', }, }), step({ key: 'B' }), step({ key: 'C' }), step({ key: 'D' }), ]); expect(found.map((e) => e.to).sort()).toEqual(['B', 'C', 'D']); }); it('keeps a route that names NO step, so a dead end is visible', () => { const found = edges([step({ key: 'A', next: 'NOT_A_STEP' })]); expect(found).toEqual([{ from: 'A', raw: 'NOT_A_STEP' }]); }); it('adds the onError route, labelled', () => { expect( edges([ step({ key: 'A', next: 'B', onError: 'RECOVER' }), step({ key: 'B' }), step({ key: 'RECOVER' }), ]), ).toEqual([ { from: 'A', to: 'B' }, { from: 'A', to: 'RECOVER', label: 'onError' }, ]); }); it('reads a SWITCH’s branches from `data`', () => { expect( edges([ step({ key: 'S', stepType: 'SWITCH', data: { A: {}, B: {} } }), step({ key: 'A' }), step({ key: 'B' }), ]).map((e) => `${e.label}->${e.to}`), ).toEqual(['A->A', 'B->B']); }); });