import { describe, it, expect } from 'vitest' import type { FlowModule } from '$lib/gen' import { buildNestedRestartPath, findStepPath, parseExpandedSubflowId, type ForloopGraphState, type FlowStatusModuleLite } from './restartFromStepPath' // --- minimal FlowModule fixtures (findStepPath only reads type/modules/branches/parallel) --- const script = (id: string): FlowModule => ({ id, value: { type: 'rawscript', language: 'bun', content: '', input_transforms: {} } }) as any const subflow = (id: string, path = 'f/x'): FlowModule => ({ id, value: { type: 'flow', path } }) as any const forloop = (id: string, modules: FlowModule[], parallel = false): FlowModule => ({ id, value: { type: 'forloopflow', modules, iterator: { type: 'static', value: [] }, parallel } }) as any const whileloop = (id: string, modules: FlowModule[]): FlowModule => ({ id, value: { type: 'whileloopflow', modules, parallel: false } }) as any const branchone = (id: string, def: FlowModule[], branches: FlowModule[][]): FlowModule => ({ id, value: { type: 'branchone', default: def, branches: branches.map((m) => ({ modules: m })) } }) as any const branchall = (id: string, branches: FlowModule[][], parallel = false): FlowModule => ({ id, value: { type: 'branchall', branches: branches.map((m) => ({ modules: m })), parallel } }) as any function build(opts: { selectedJobStep: string rawFlowModules: FlowModule[] flowStatusModules?: FlowStatusModuleLite[] graphModuleStates?: Record expandedSubflows?: Record }) { return buildNestedRestartPath({ flowStatusModules: [], graphModuleStates: {}, expandedSubflows: {}, ...opts }) } describe('parseExpandedSubflowId', () => { it('parses a multi-level subflow id', () => { expect(parseExpandedSubflowId('subflow:a:b:leaf')).toEqual({ subflowSteps: ['a', 'b'], leaf: 'leaf' }) }) it('returns undefined for a bare id or a single segment', () => { expect(parseExpandedSubflowId('leaf')).toBeUndefined() expect(parseExpandedSubflowId('subflow:leaf')).toBeUndefined() }) }) describe('buildNestedRestartPath', () => { it('pure nested subflows: top is the outer subflow, no iterations', () => { const r = build({ selectedJobStep: 'subflow:stop:smid:a', rawFlowModules: [subflow('stop', 'f/mid')], expandedSubflows: { stop: { modules: [subflow('smid', 'f/leaf')] }, 'subflow:stop:smid': { modules: [script('a')] } } }) expect(r).toEqual({ topStepId: 'stop', topBranchOrIterationN: undefined, path: [{ step_id: 'smid' }, { step_id: 'a' }], iterationCounts: {} }) }) it('subflow nested inside a ForLoop: recovers the ForLoop as the top step (the reported bug)', () => { const r = build({ selectedJobStep: 'subflow:s:a', rawFlowModules: [forloop('L', [subflow('s', 'f/leaf')])], expandedSubflows: { s: { modules: [script('a')] } }, graphModuleStates: { L: { selectedForloopIndex: 1, flow_jobs: ['j0', 'j1'] } } }) expect(r).toEqual({ topStepId: 'L', topBranchOrIterationN: 1, path: [{ step_id: 's' }, { step_id: 'a' }], iterationCounts: { top: 2 } }) }) it('ForLoop sitting between two subflow boundaries is recovered as an inner path step', () => { const r = build({ selectedJobStep: 'subflow:sa:sb:a', rawFlowModules: [subflow('sa', 'f/mid')], expandedSubflows: { sa: { modules: [forloop('L', [subflow('sb', 'f/leaf')])] }, 'subflow:sa:sb': { modules: [script('a')] } }, graphModuleStates: { 'subflow:sa:L': { selectedForloopIndex: 0, flow_jobs: ['j0', 'j1'] } } }) expect(r).toEqual({ topStepId: 'sa', topBranchOrIterationN: undefined, path: [{ step_id: 'L', branch_or_iteration_n: 0 }, { step_id: 'sb' }, { step_id: 'a' }], iterationCounts: { 'inner-0': 2 } }) }) it('leaf directly inside a top-level ForLoop (no subflow)', () => { const r = build({ selectedJobStep: 'x', rawFlowModules: [forloop('L', [script('x')])], graphModuleStates: { L: { selectedForloopIndex: 2, flow_jobs: ['a', 'b', 'c'] } } }) expect(r).toEqual({ topStepId: 'L', topBranchOrIterationN: 2, path: [{ step_id: 'x' }], iterationCounts: { top: 3 } }) }) it('the leaf itself is a ForLoop: exposes its own iteration too', () => { const r = build({ selectedJobStep: 'M', rawFlowModules: [forloop('L', [forloop('M', [script('y')])])], graphModuleStates: { L: { selectedForloopIndex: 0, flow_jobs: ['a'] }, M: { selectedForloopIndex: 1, flow_jobs: ['p', 'q'] } } }) expect(r).toEqual({ topStepId: 'L', topBranchOrIterationN: 0, path: [{ step_id: 'M', branch_or_iteration_n: 1 }], iterationCounts: { top: 1, 'inner-0': 2 } }) }) it('BranchOne top step: no iteration sent (backend locks the branch from the run)', () => { const r = build({ selectedJobStep: 'x', rawFlowModules: [branchone('BO', [script('d')], [[script('x')]])], flowStatusModules: [{ id: 'BO', branch_chosen: { type: 'branch', branch: 0 } }] }) expect(r).toEqual({ topStepId: 'BO', topBranchOrIterationN: undefined, path: [{ step_id: 'x' }], iterationCounts: {} }) }) it('BranchOne default branch taken and leaf is in the default branch', () => { const r = build({ selectedJobStep: 'd', rawFlowModules: [branchone('BO', [script('d')], [[script('x')]])], flowStatusModules: [{ id: 'BO', branch_chosen: { type: 'default' } }] }) expect(r?.topStepId).toBe('BO') expect(r?.path).toEqual([{ step_id: 'd' }]) }) it('rejects a leaf inside a BranchOne branch the original run did not take', () => { const r = build({ selectedJobStep: 'x', rawFlowModules: [branchone('BO', [script('d')], [[script('x')]])], // Original run took the default, not branch 0 (which contains x). flowStatusModules: [{ id: 'BO', branch_chosen: { type: 'default' } }] }) expect(r).toBeNull() }) it('rejects unsupported containers: parallel ForLoop, BranchAll, WhileLoop', () => { expect( build({ selectedJobStep: 'x', rawFlowModules: [forloop('L', [script('x')], true)] }) ).toBeNull() expect( build({ selectedJobStep: 'x', rawFlowModules: [branchall('B', [[script('x')]])] }) ).toBeNull() expect( build({ selectedJobStep: 'x', rawFlowModules: [whileloop('W', [script('x')])] }) ).toBeNull() }) it('returns null for a top-level step (handled by the top-level path)', () => { expect(build({ selectedJobStep: 'top', rawFlowModules: [script('top')] })).toBeNull() }) it('falls back to a flat path when a subflow’s modules are not loaded yet', () => { const r = build({ selectedJobStep: 'subflow:stop:smid:a', rawFlowModules: [subflow('stop', 'f/mid')], expandedSubflows: {} // stop not expanded → modules unavailable at level 1 }) expect(r).toEqual({ topStepId: 'stop', topBranchOrIterationN: undefined, path: [{ step_id: 'smid' }, { step_id: 'a' }], iterationCounts: {} }) }) it('flat fallback still ends at the leaf when the deepest modules are loaded but stale', () => { // smid IS expanded but its cached modules don't contain the leaf `a` // (stale cache). The chain must still end at `a`, never at the `smid` // subflow boundary. const r = build({ selectedJobStep: 'subflow:stop:smid:a', rawFlowModules: [subflow('stop', 'f/mid')], expandedSubflows: { stop: { modules: [subflow('smid', 'f/leaf')] }, 'subflow:stop:smid': { modules: [script('z')] } // no `a` } }) expect(r).toEqual({ topStepId: 'stop', topBranchOrIterationN: undefined, path: [{ step_id: 'smid' }, { step_id: 'a' }], iterationCounts: {} }) }) }) describe('findStepPath', () => { it('locates a leaf inside a ForLoop and reports the ancestor chain', () => { const p = findStepPath([forloop('L', [script('x')])], 'x') expect(p?.target.id).toBe('x') expect(p?.ancestors).toEqual([{ stepId: 'L', type: 'forloopflow', parallel: false }]) }) it('does not cross subflow boundaries', () => { expect(findStepPath([subflow('s')], 'a')).toBeUndefined() }) })