import { describe, expect, it } from "vitest"; import type { WorkflowDefinition } from "@fusion/core"; import { parseWorkflowIr } from "@fusion/core"; import type { Node as FlowNode } from "@xyflow/react"; import { irToFlow, flowToIr, insertFragment, fragmentSeamConflicts, copyIrWithFreshIds, columnsOf, columnForY, bandTop, columnsToBandNodes, isColumnBandNode, validateColumnsClient, unplacedNodeIds, foreachChildFlowId, templateNodeIdFromChild, shortConditionLabel, edgeClassName, edgeConditionEditability, wouldCreateCycle, buildConnectionEdge, cascadeDelete, COLUMN_BAND_HEIGHT, WF_CARD_WIDTH, WF_CARD_MAX_WIDTH, WF_CARD_HEIGHT, FOREACH_GROUP_WIDTH, FOREACH_GROUP_HEIGHT, FOREACH_CHILD_X, FOREACH_CHILD_Y, } from "../workflow-flow-mapping"; import type { WorkflowFlowNodeData } from "../nodes/WorkflowNodeTypes"; import type { TraitCatalogEntry } from "../../api"; function makeDef(ir: WorkflowDefinition["ir"]): WorkflowDefinition { return { id: "WF-001", kind: "workflow", name: ir.name, description: "", ir, layout: {}, createdAt: "2024-01-01T00:00:00.000Z", updatedAt: "2024-01-01T00:00:00.000Z", }; } describe("workflow-flow-mapping name preservation", () => { it("does not inject synthetic names for unnamed start/end/merge nodes on round-trip", () => { const ir: WorkflowDefinition["ir"] = { version: "v1", name: "wf", nodes: [ { id: "start", kind: "start" }, { id: "n1", kind: "prompt", config: { prompt: "do work" } }, { id: "m1", kind: "prompt", config: { seam: "merge" } }, { id: "end", kind: "end" }, ], edges: [ { from: "start", to: "n1", condition: "success" }, { from: "n1", to: "m1", condition: "success" }, { from: "m1", to: "end", condition: "success" }, ], }; const { nodes, edges } = irToFlow(makeDef(ir)); const { ir: out } = flowToIr("wf", nodes, edges); const byId = Object.fromEntries(out.nodes.map((n) => [n.id, n])); // start/end carry no config or a config without an injected name expect(byId.start.config?.name).toBeUndefined(); expect(byId.end.config?.name).toBeUndefined(); // merge boundary keeps its seam but does not gain a synthetic "Merge boundary" name expect(byId.m1.config?.seam).toBe("merge"); expect(byId.m1.config?.name).toBeUndefined(); // an unnamed prompt node keeps no synthetic id-as-name expect(byId.n1.config?.name).toBeUndefined(); expect(byId.n1.config?.prompt).toBe("do work"); }); it("preserves an explicit node name across round-trips", () => { const ir: WorkflowDefinition["ir"] = { version: "v1", name: "wf", nodes: [ { id: "start", kind: "start" }, { id: "n1", kind: "prompt", config: { name: "Implement", prompt: "do work" } }, { id: "end", kind: "end" }, ], edges: [ { from: "start", to: "n1", condition: "success" }, { from: "n1", to: "end", condition: "success" }, ], }; const { nodes, edges } = irToFlow(makeDef(ir)); const { ir: out } = flowToIr("wf", nodes, edges); const n1 = out.nodes.find((n) => n.id === "n1"); expect(n1?.config?.name).toBe("Implement"); }); it("persists a user-entered label as the node name", () => { const ir: WorkflowDefinition["ir"] = { version: "v1", name: "wf", nodes: [ { id: "start", kind: "start" }, { id: "n1", kind: "prompt", config: { prompt: "do work" } }, { id: "end", kind: "end" }, ], edges: [ { from: "start", to: "n1", condition: "success" }, { from: "n1", to: "end", condition: "success" }, ], }; const { nodes, edges } = irToFlow(makeDef(ir)); // Simulate the editor renaming the node via its label input. const renamed = nodes.map((n) => n.id === "n1" ? { ...n, data: { ...n.data, label: "Build feature" } } : n, ); const { ir: out } = flowToIr("wf", renamed, edges); const n1 = out.nodes.find((n) => n.id === "n1"); expect(n1?.config?.name).toBe("Build feature"); }); }); // ── U10: v2 round-trip (columns, placement, hold, split/join) ──────────────── const CATALOG: TraitCatalogEntry[] = [ { id: "intake", name: "Intake", builtin: true, flags: { intake: true } }, { id: "complete", name: "Complete", builtin: true, flags: { complete: true } }, { id: "archived", name: "Archived", builtin: true, flags: { archived: true, hiddenFromBoard: true } }, { id: "wip", name: "WIP", builtin: true, flags: { countsTowardWip: true } }, { id: "hold", name: "Hold", builtin: true, flags: { hold: true } }, ]; function v2Def(ir: WorkflowDefinition["ir"], layout: WorkflowDefinition["layout"] = {}): WorkflowDefinition { return { ...makeDef(ir), layout }; } describe("workflow-flow-mapping v2 round-trip", () => { const ir: WorkflowDefinition["ir"] = { version: "v2", name: "wf2", columns: [ { id: "triage", name: "Triage", traits: [{ trait: "intake" }] }, { id: "in-progress", name: "In progress", traits: [{ trait: "wip", config: { limit: 2 } }] }, { id: "done", name: "Done", traits: [{ trait: "complete" }] }, ], nodes: [ { id: "start", kind: "start", column: "triage" }, { id: "h1", kind: "hold", column: "triage", config: { release: "manual" } }, { id: "s1", kind: "split", column: "in-progress" }, { id: "b1", kind: "prompt", column: "in-progress", config: { prompt: "lint" } }, { id: "b2", kind: "prompt", column: "in-progress", config: { prompt: "test" } }, { id: "j1", kind: "join", column: "in-progress", config: { mode: { quorum: 2 }, onBranchFailure: "fail-fast" } }, { id: "end", kind: "end", column: "done" }, ], edges: [ { from: "start", to: "h1", condition: "success" }, { from: "h1", to: "s1", condition: "success" }, { from: "s1", to: "b1", condition: "success" }, { from: "s1", to: "b2", condition: "success" }, { from: "b1", to: "j1", condition: "success" }, { from: "b2", to: "j1", condition: "success" }, { from: "j1", to: "end", condition: "success" }, ], }; it("round-trips columns, placement, hold, and split/join config losslessly", () => { const { nodes, edges } = irToFlow(v2Def(ir)); const columns = columnsOf(v2Def(ir)); const { ir: out } = flowToIr("wf2", nodes, edges, columns); expect(out.version).toBe("v2"); if (out.version !== "v2") return; // Columns preserved in order with their traits. expect(out.columns.map((c) => c.id)).toEqual(["triage", "in-progress", "done"]); expect(out.columns[1].traits).toEqual([{ trait: "wip", config: { limit: 2 } }]); const byId = Object.fromEntries(out.nodes.map((n) => [n.id, n])); // Placement preserved for every node. expect(byId.h1.column).toBe("triage"); expect(byId.s1.column).toBe("in-progress"); expect(byId.j1.column).toBe("in-progress"); expect(byId.end.column).toBe("done"); // Hold release config preserved. expect(byId.h1.config?.release).toBe("manual"); // Split/join shape preserved. expect(byId.s1.kind).toBe("split"); expect(byId.j1.kind).toBe("join"); expect(byId.j1.config?.mode).toEqual({ quorum: 2 }); expect(byId.j1.config?.onBranchFailure).toBe("fail-fast"); }); it("emits swimlane band group nodes that flowToIr strips back out", () => { const { nodes } = irToFlow(v2Def(ir)); const bands = nodes.filter((n) => isColumnBandNode(n.id)); expect(bands).toHaveLength(3); expect(bands.every((b) => b.type === "group")).toBe(true); // flowToIr must not emit band group nodes as IR nodes. const { ir: out } = flowToIr("wf2", nodes, [], columnsOf(v2Def(ir))); expect(out.nodes.some((n) => isColumnBandNode(n.id))).toBe(false); }); it("derives node.column by position when a node is dropped into a band", () => { const columns = columnsOf(v2Def(ir)); // Band index 2 = "done"; a node dragged to that band's y resolves to it. const yInDone = bandTop(2) + 40; expect(columnForY(yInDone, columns)).toBe("done"); // Simulate a node moved into the "done" band with no explicit data.column. const stepNode: FlowNode = { id: "n9", type: "prompt", position: { x: 100, y: yInDone }, data: { kind: "prompt", label: "ship", config: {} }, }; const bandNodes = columnsToBandNodes(columns); const { ir: out } = flowToIr("wf2", [...bandNodes, stepNode], [], columns); const n9 = out.version === "v2" ? out.nodes.find((n) => n.id === "n9") : undefined; expect(n9?.column).toBe("done"); }); it("v1 definitions map to empty columns (legacy round-trip stays v1)", () => { const v1: WorkflowDefinition["ir"] = { version: "v1", name: "wf", nodes: [ { id: "start", kind: "start" }, { id: "end", kind: "end" }, ], edges: [{ from: "start", to: "end", condition: "success" }], }; const def = makeDef(v1); expect(columnsOf(def)).toEqual([]); const { nodes, edges } = irToFlow(def); const { ir: out } = flowToIr("wf", nodes, edges, columnsOf(def)); expect(out.version).toBe("v1"); }); }); describe("workflow-flow-mapping validation helpers", () => { it("flags a trait conflict on the offending column", () => { const columns = [ { id: "done", name: "Done", traits: [{ trait: "complete" }, { trait: "wip" }] }, ]; const violations = validateColumnsClient(columns, CATALOG); const conflict = violations.find((v) => v.code === "complete-with-wip"); expect(conflict).toBeTruthy(); expect(conflict?.columnId).toBe("done"); expect(conflict?.severity).toBe("error"); }); it("flags more than one intake column workflow-wide", () => { const columns = [ { id: "a", name: "A", traits: [{ trait: "intake" }] }, { id: "b", name: "B", traits: [{ trait: "intake" }] }, ]; const v = validateColumnsClient(columns, CATALOG).find((x) => x.code === "multiple-intake-columns"); expect(v?.columnId).toBeNull(); }); it("reports unplaced step nodes (not start/end, not bands)", () => { const columns = columnsOf( v2Def({ version: "v2", name: "w", columns: [{ id: "c1", name: "C1", traits: [] }], nodes: [ { id: "start", kind: "start" }, { id: "end", kind: "end" }, ], edges: [{ from: "start", to: "end", condition: "success" }], }), ); const placed: FlowNode = { id: "p1", type: "prompt", position: { x: 0, y: bandTop(0) + 20 }, data: { kind: "prompt", label: "x", config: {}, column: "c1" }, }; // A fresh node parked far below the single band (no explicit column) is // strictly outside every band → unplaced. const floating: FlowNode = { id: "float", type: "prompt", position: { x: 0, y: bandTop(0) + COLUMN_BAND_HEIGHT * 5 }, data: { kind: "prompt", label: "y", config: {} }, }; const ids = unplacedNodeIds( [...columnsToBandNodes(columns), placed, floating, { id: "start", type: "start", position: { x: 0, y: 0 }, data: { kind: "start", label: "" } }, { id: "end", type: "end", position: { x: 0, y: 0 }, data: { kind: "end", label: "" } }, ], columns, ); expect(ids).not.toContain("p1"); expect(ids).not.toContain("start"); expect(ids).not.toContain("end"); expect(ids).toContain("float"); }); it("treats a node with an unknown column id as unplaced", () => { const columns = [{ id: "c1", name: "C1", traits: [] }]; const ghost: FlowNode = { id: "ghost", type: "prompt", position: { x: 0, y: bandTop(0) }, data: { kind: "prompt", label: "x", config: {}, column: "no-such-column" }, }; const ids = unplacedNodeIds([ghost], columns); expect(ids).toContain("ghost"); }); it("band height stays positive (geometry sanity)", () => { expect(COLUMN_BAND_HEIGHT).toBeGreaterThan(0); }); }); // ── U8: step-inversion round-trip (foreach template, rework edges) ─────────── describe("workflow-flow-mapping foreach + rework round-trip", () => { const ir: WorkflowDefinition["ir"] = { version: "v2", name: "stepwise", columns: [ { id: "plan", name: "Plan", traits: [] }, { id: "in-progress", name: "In progress", traits: [] }, { id: "done", name: "Done", traits: [] }, ], nodes: [ { id: "start", kind: "start", column: "plan" }, { id: "parse", kind: "parse-steps", column: "plan", config: { artifact: "PROMPT.md", parser: "step-headings" } }, { id: "loop", kind: "foreach", column: "in-progress", config: { source: "task-steps", mode: "sequential", isolation: "shared", maxReworkCycles: 3, template: { nodes: [ { id: "exec", kind: "prompt", config: { seam: "step-execute", prompt: "do step" } }, { id: "review", kind: "step-review", config: { type: "code" } }, ], edges: [ { from: "exec", to: "review", condition: "success" }, { from: "review", to: "exec", condition: "outcome:revise", kind: "rework" }, ], }, }, }, { id: "end", kind: "end", column: "done" }, ], edges: [ { from: "start", to: "parse", condition: "success" }, { from: "parse", to: "loop", condition: "success" }, { from: "loop", to: "end", condition: "success" }, ], }; it("round-trips foreach template (children partitioned by parentId) losslessly", () => { const def = makeDef(ir); const { nodes, edges } = irToFlow(def); const columns = columnsOf(def); // The foreach group + its two template children render as parented nodes. const group = nodes.find((n) => n.id === "loop"); expect(group?.type).toBe("foreach"); const children = nodes.filter((n) => n.parentId === "loop"); expect(children.map((c) => c.id).sort()).toEqual( [foreachChildFlowId("loop", "exec"), foreachChildFlowId("loop", "review")].sort(), ); // Template edges (incl. the rework edge) live inside the group's id-scope. const reworkFlowEdge = edges.find((e) => e.data?.kind === "rework"); expect(reworkFlowEdge).toBeTruthy(); expect(reworkFlowEdge?.source).toBe(foreachChildFlowId("loop", "review")); const { ir: out } = flowToIr("stepwise", nodes, edges, columns); if (out.version !== "v2") throw new Error("expected v2"); const loop = out.nodes.find((n) => n.id === "loop"); expect(loop?.kind).toBe("foreach"); const cfg = loop?.config as Record; expect(cfg.source).toBe("task-steps"); expect(cfg.mode).toBe("sequential"); expect(cfg.maxReworkCycles).toBe(3); const template = cfg.template as { nodes: unknown[]; edges: { from: string; to: string; condition?: string; kind?: string }[] }; // Template node ids are template-local (de-namespaced), not flow ids. expect((template.nodes as { id: string }[]).map((n) => n.id).sort()).toEqual(["exec", "review"]); // The rework edge survives with its kind and outcome condition. const rework = template.edges.find((e) => e.kind === "rework"); expect(rework).toEqual({ from: "review", to: "exec", condition: "outcome:revise", kind: "rework" }); // The plain success edge has no kind. const success = template.edges.find((e) => e.condition === "success"); expect(success?.kind).toBeUndefined(); // Top-level edges exclude the intra-template ones. expect(out.edges.map((e) => `${e.from}->${e.to}`)).toEqual([ "start->parse", "parse->loop", "loop->end", ]); // parse-steps config preserved. const parse = out.nodes.find((n) => n.id === "parse"); expect(parse?.config).toMatchObject({ artifact: "PROMPT.md", parser: "step-headings" }); }); it("round-trips loop templates through parented group children", () => { const loopIr: WorkflowDefinition["ir"] = { version: "v2", name: "bounded-loop", columns: ir.columns, nodes: [ { id: "start", kind: "start", column: "plan" }, { id: "retry", kind: "loop", column: "in-progress", config: { maxIterations: 4, timeoutMs: 60000, exitWhen: { type: "output-matches", nodeId: "check", pattern: "DONE|COMPLETE" }, template: { nodes: [ { id: "try", kind: "prompt", config: { prompt: "try once" } }, { id: "check", kind: "gate", config: { prompt: "done?" } }, ], edges: [{ from: "try", to: "check", condition: "success" }], }, }, }, { id: "end", kind: "end", column: "done" }, ], edges: [ { from: "start", to: "retry", condition: "success" }, { from: "retry", to: "end", condition: "success" }, ], }; const { nodes, edges } = irToFlow(makeDef(loopIr)); const group = nodes.find((n) => n.id === "retry"); expect(group?.type).toBe("loop"); expect(group?.data.kind).toBe("loop"); expect(nodes.filter((n) => n.parentId === "retry").map((n) => templateNodeIdFromChild("retry", n.id))).toEqual([ "try", "check", ]); const { ir: out } = flowToIr("bounded-loop", nodes, edges, columnsOf(makeDef(loopIr))); if (out.version !== "v2") throw new Error("expected v2"); const retry = out.nodes.find((n) => n.id === "retry"); expect(retry?.kind).toBe("loop"); expect(retry?.config).toMatchObject({ maxIterations: 4, timeoutMs: 60000, exitWhen: { type: "output-matches", nodeId: "check", pattern: "DONE|COMPLETE" }, }); const template = retry?.config?.template as { nodes: { id: string }[]; edges: { from: string; to: string }[] }; expect(template.nodes.map((n) => n.id)).toEqual(["try", "check"]); expect(template.edges).toEqual([{ from: "try", to: "check", condition: "success" }]); }); it("inserts loop fragments with their template children intact", () => { const fragment: WorkflowDefinition["ir"] = { version: "v2", name: "fragment", columns: ir.columns, nodes: [ { id: "start", kind: "start" }, { id: "retry", kind: "loop", config: { maxIterations: 2, exitWhen: { type: "output-contains", value: "DONE" }, template: { nodes: [{ id: "try", kind: "prompt", config: { prompt: "again" } }], edges: [], }, }, }, { id: "end", kind: "end" }, ], edges: [ { from: "start", to: "retry" }, { from: "retry", to: "end" }, ], }; const inserted = insertFragment([], [], parseWorkflowIr(fragment), { x: 10, y: 20 }, { [foreachChildFlowId("retry", "try")]: { x: 86, y: 132 }, }); const group = inserted.nodes.find((n) => n.data.kind === "loop"); const child = inserted.nodes.find((n) => n.parentId === group?.id); expect(group).toBeTruthy(); expect(group?.type).toBe("loop"); expect(child?.position).toEqual({ x: 86, y: 132 }); expect(inserted.nodes.filter((n) => n.parentId === group?.id)).toHaveLength(1); expect(inserted.edges).toHaveLength(0); }); it("round-trips a code node config (source + timeoutMs)", () => { const codeIr: WorkflowDefinition["ir"] = { version: "v1", name: "wf", nodes: [ { id: "start", kind: "start" }, { id: "c1", kind: "code", config: { source: "export default async()=>({})", timeoutMs: 5000 } }, { id: "end", kind: "end" }, ], edges: [ { from: "start", to: "c1", condition: "success" }, { from: "c1", to: "end", condition: "success" }, ], }; const { nodes, edges } = irToFlow(makeDef(codeIr)); const { ir: out } = flowToIr("wf", nodes, edges); const c1 = out.nodes.find((n) => n.id === "c1"); expect(c1?.kind).toBe("code"); expect(c1?.config).toMatchObject({ source: "export default async()=>({})", timeoutMs: 5000 }); }); it("child id namespacing helpers are inverse", () => { const fid = foreachChildFlowId("loop", "exec"); expect(templateNodeIdFromChild("loop", fid)).toBe("exec"); // A non-namespaced id passes through unchanged. expect(templateNodeIdFromChild("loop", "other")).toBe("other"); }); it("shortens outcome: edge labels", () => { expect(shortConditionLabel("outcome:approve")).toBe("approve"); expect(shortConditionLabel("success")).toBe("success"); }); it("does not flag foreach template children as unplaced", () => { const def = makeDef(ir); const { nodes } = irToFlow(def); const columns = columnsOf(def); const ids = unplacedNodeIds(nodes, columns); expect(ids).not.toContain(foreachChildFlowId("loop", "exec")); expect(ids).not.toContain(foreachChildFlowId("loop", "review")); }); }); describe("card dimension constants (U1)", () => { it("card max width is at least the nominal card width", () => { expect(WF_CARD_MAX_WIDTH).toBeGreaterThanOrEqual(WF_CARD_WIDTH); }); it("a child card fits inside the foreach group with padding", () => { // Child offset + card max dimensions must stay inside the group box so a // card never overflows or gets clamped by extent:"parent". expect(FOREACH_CHILD_X + WF_CARD_MAX_WIDTH).toBeLessThanOrEqual(FOREACH_GROUP_WIDTH); expect(FOREACH_CHILD_Y + WF_CARD_HEIGHT).toBeLessThanOrEqual(FOREACH_GROUP_HEIGHT); }); }); describe("edge-condition authoring (U2)", () => { it("round-trips a failure condition through flowToIr → irToFlow with class + label", () => { const ir: WorkflowDefinition["ir"] = { version: "v1", name: "wf", nodes: [ { id: "start", kind: "start" }, { id: "n1", kind: "prompt", config: { prompt: "do" } }, { id: "ok", kind: "prompt", config: { prompt: "ok" } }, { id: "bad", kind: "prompt", config: { prompt: "bad" } }, { id: "end", kind: "end" }, ], edges: [ { from: "start", to: "n1", condition: "success" }, { from: "n1", to: "ok", condition: "success" }, { from: "n1", to: "bad", condition: "failure" }, { from: "ok", to: "end", condition: "success" }, { from: "bad", to: "end", condition: "success" }, ], }; const { nodes, edges } = irToFlow(makeDef(ir)); const failFlow = edges.find((e) => e.source === "n1" && e.target === "bad")!; expect(failFlow.data?.condition).toBe("failure"); expect(failFlow.label).toBe("failure"); expect(failFlow.className).toBe("wf-edge-failure"); const { ir: out } = flowToIr("wf", nodes, edges); const irFail = out.edges.find((e) => e.from === "n1" && e.to === "bad"); expect(irFail?.condition).toBe("failure"); }); it("preserves parallel success+failure edges between the same pair through the round-trip", () => { const flowNodes: FlowNode[] = [ { id: "a", type: "prompt", position: { x: 0, y: 0 }, data: { kind: "prompt", label: "a" } }, { id: "b", type: "prompt", position: { x: 100, y: 0 }, data: { kind: "prompt", label: "b" } }, ]; const flowEdges = [ { id: "e-1", source: "a", target: "b", data: { condition: "success" } }, { id: "e-2", source: "a", target: "b", data: { condition: "failure" } }, ]; const { ir } = flowToIr("wf", flowNodes, flowEdges); expect(ir.edges).toHaveLength(2); expect(ir.edges.map((e) => e.condition).sort()).toEqual(["failure", "success"]); const { edges: reFlow } = irToFlow(makeDef(ir)); expect(reFlow).toHaveLength(2); const ids = new Set(reFlow.map((e) => e.id)); expect(ids.size).toBe(2); }); it("edgeClassName: failure → wf-edge-failure, rework precedence, success → undefined", () => { expect(edgeClassName("failure", false)).toBe("wf-edge-failure"); expect(edgeClassName("success", false)).toBeUndefined(); expect(edgeClassName("failure", true)).toBe("wf-edge-rework"); }); it("edgeConditionEditability gates by source kind (KTD-2)", () => { expect(edgeConditionEditability("step-review")).toBe("verdicts"); expect(edgeConditionEditability("prompt")).toBe("conditions"); expect(edgeConditionEditability("script")).toBe("conditions"); expect(edgeConditionEditability("gate")).toBe("conditions"); expect(edgeConditionEditability("code")).toBe("conditions"); expect(edgeConditionEditability("foreach")).toBe("conditions"); expect(edgeConditionEditability("split")).toBe("readonly"); expect(edgeConditionEditability("parse-steps")).toBe("readonly"); expect(edgeConditionEditability("start")).toBe("readonly"); expect(edgeConditionEditability(undefined)).toBe("readonly"); }); it("wouldCreateCycle detects back-edges and ignores forward + rework edges", () => { const chain = [ { id: "1", source: "a", target: "b", data: { condition: "success" } }, { id: "2", source: "b", target: "c", data: { condition: "success" } }, ]; // c → a closes the loop a→b→c→a. expect(wouldCreateCycle(chain, "c", "a")).toBe(true); // a → c is forward, no cycle. expect(wouldCreateCycle(chain, "a", "c")).toBe(false); // self-loop. expect(wouldCreateCycle(chain, "a", "a")).toBe(true); // rework edges are excluded from the reachability walk. const withRework = [ { id: "1", source: "a", target: "b", data: { condition: "success" } }, { id: "2", source: "b", target: "c", data: { condition: "success", kind: "rework" } }, ]; // c only reachable from b via a rework edge, so c→a is NOT a (non-rework) cycle. expect(wouldCreateCycle(withRework, "c", "a")).toBe(false); }); it("buildConnectionEdge: builds a success edge, rejects missing endpoints, duplicates, cycles", () => { const nodes: FlowNode[] = [ { id: "a", type: "prompt", position: { x: 0, y: 0 }, data: { kind: "prompt", label: "a" } }, { id: "b", type: "prompt", position: { x: 100, y: 0 }, data: { kind: "prompt", label: "b" } }, { id: "c", type: "prompt", position: { x: 200, y: 0 }, data: { kind: "prompt", label: "c" } }, ]; const edges = [ { id: "1", source: "a", target: "b", data: { condition: "success" } }, { id: "2", source: "b", target: "c", data: { condition: "success" } }, ]; // happy path: new success edge with a unique id + interactionWidth. const ok = buildConnectionEdge({ source: "a", target: "c" }, edges, nodes); expect("edge" in ok).toBe(true); if ("edge" in ok) { expect(ok.edge.data?.condition).toBe("success"); expect(ok.edge.label).toBe("success"); expect(ok.edge.interactionWidth).toBeGreaterThan(0); expect(typeof ok.edge.id).toBe("string"); } // missing endpoint. expect(buildConnectionEdge({ source: "a", target: null }, edges, nodes)).toEqual({ error: "missing-endpoint", }); // second connect of an existing success pair (prompt source supports // conditions) → births the parallel failure edge rather than rejecting. const failureBirth = buildConnectionEdge({ source: "a", target: "b" }, edges, nodes); expect("edge" in failureBirth).toBe(true); if ("edge" in failureBirth) { expect(failureBirth.edge.data?.condition).toBe("failure"); } // once BOTH success and failure exist, a third connect is a duplicate. const bothConditions = [ ...edges, { id: "3", source: "a", target: "b", data: { condition: "failure" } }, ]; expect(buildConnectionEdge({ source: "a", target: "b" }, bothConditions, nodes)).toEqual({ error: "duplicate", }); // a source kind that does NOT support conditions stays a hard duplicate. const readonlyNodes: FlowNode[] = [ { id: "a", type: "start", position: { x: 0, y: 0 }, data: { kind: "start", label: "a" } }, { id: "b", type: "prompt", position: { x: 100, y: 0 }, data: { kind: "prompt", label: "b" } }, ]; expect( buildConnectionEdge( { source: "a", target: "b" }, [{ id: "1", source: "a", target: "b", data: { condition: "success" } }], readonlyNodes, ), ).toEqual({ error: "duplicate" }); // cycle: c→a closes a→b→c→a. expect(buildConnectionEdge({ source: "c", target: "a" }, edges, nodes)).toEqual({ error: "cycle", }); }); it("buildConnectionEdge exempts intra-foreach-template connections from the cycle guard", () => { const nodes: FlowNode[] = [ { id: "g", type: "foreach", position: { x: 0, y: 0 }, data: { kind: "foreach", label: "g" } }, { id: "g::a", type: "prompt", position: { x: 0, y: 0 }, parentId: "g", data: { kind: "prompt", label: "a" } }, { id: "g::b", type: "prompt", position: { x: 0, y: 0 }, parentId: "g", data: { kind: "prompt", label: "b" } }, ]; const edges = [{ id: "1", source: "g::a", target: "g::b", data: { condition: "success" } }]; // b→a would be a cycle, but both are children of the same group → allowed. const res = buildConnectionEdge({ source: "g::b", target: "g::a" }, edges, nodes); expect("edge" in res).toBe(true); }); }); describe("cascadeDelete (U3, R6)", () => { // start → a → b → c → end (a/b/c are prompt nodes), so deleting a mid-chain // node must drop its two incident edges with no bridge created. const chainDef = (): WorkflowDefinition => makeDef({ version: "v1", name: "chain", nodes: [ { id: "start", kind: "start" }, { id: "a", kind: "prompt", config: { prompt: "a" } }, { id: "b", kind: "prompt", config: { prompt: "b" } }, { id: "c", kind: "prompt", config: { prompt: "c" } }, { id: "end", kind: "end" }, ], edges: [ { from: "start", to: "a", condition: "success" }, { from: "a", to: "b", condition: "success" }, { from: "b", to: "c", condition: "success" }, { from: "c", to: "end", condition: "success" }, ], }); it("deletes a mid-chain node + both incident edges, with NO bridge edge", () => { const { nodes, edges } = irToFlow(chainDef()); const bEdge = edges.find((e) => e.source === "a" && e.target === "b")!; const result = cascadeDelete(nodes, edges, [/* node */ "b"]); expect(result.nodes.find((n) => n.id === "b")).toBeUndefined(); // Both incident edges (a→b and b→c) are gone. expect(result.edges.find((e) => e.source === "a" && e.target === "b")).toBeUndefined(); expect(result.edges.find((e) => e.source === "b" && e.target === "c")).toBeUndefined(); // No auto-bridge a→c. expect(result.edges.find((e) => e.source === "a" && e.target === "c")).toBeUndefined(); // Untouched edges survive. expect(result.edges.find((e) => e.source === "start" && e.target === "a")).toBeTruthy(); expect(result.edges.find((e) => e.source === "c" && e.target === "end")).toBeTruthy(); void bEdge; }); // A foreach group with two template children (exec → review, review → exec // rework), plus top-level edges parse→loop→end. const foreachDef = (): WorkflowDefinition => makeDef({ version: "v1", name: "loopwf", nodes: [ { id: "start", kind: "start" }, { id: "loop", kind: "foreach", config: { source: "task-steps", template: { nodes: [ { id: "exec", kind: "prompt", config: { seam: "step-execute", prompt: "do" } }, { id: "review", kind: "step-review", config: { type: "code" } }, ], edges: [ { from: "exec", to: "review", condition: "success" }, { from: "review", to: "exec", condition: "outcome:revise", kind: "rework" }, ], }, }, }, { id: "end", kind: "end" }, ], edges: [ { from: "start", to: "loop", condition: "success" }, { from: "loop", to: "end", condition: "success" }, ], }); it("deleting a foreach group removes the group + children + template edges + incident edges", () => { const { nodes, edges } = irToFlow(foreachDef()); const execId = foreachChildFlowId("loop", "exec"); const reviewId = foreachChildFlowId("loop", "review"); // Sanity: children + their template edges exist before delete. expect(nodes.find((n) => n.id === execId)).toBeTruthy(); expect(edges.some((e) => e.source === execId && e.target === reviewId)).toBe(true); const result = cascadeDelete(nodes, edges, ["loop"]); // Group + both children gone. expect(result.nodes.find((n) => n.id === "loop")).toBeUndefined(); expect(result.nodes.find((n) => n.id === execId)).toBeUndefined(); expect(result.nodes.find((n) => n.id === reviewId)).toBeUndefined(); // Intra-template edges gone. expect(result.edges.some((e) => e.source === execId || e.target === execId)).toBe(false); expect(result.edges.some((e) => e.source === reviewId || e.target === reviewId)).toBe(false); // The group's own incident edges (start→loop, loop→end) gone. expect(result.edges.some((e) => e.source === "loop" || e.target === "loop")).toBe(false); // start and end nodes survive. expect(result.nodes.find((n) => n.id === "start")).toBeTruthy(); expect(result.nodes.find((n) => n.id === "end")).toBeTruthy(); }); it("deleting only a template child removes the child + its edges, leaving the group", () => { const { nodes, edges } = irToFlow(foreachDef()); const execId = foreachChildFlowId("loop", "exec"); const reviewId = foreachChildFlowId("loop", "review"); const result = cascadeDelete(nodes, edges, [execId]); // The exec child is gone; the group + sibling remain. expect(result.nodes.find((n) => n.id === execId)).toBeUndefined(); expect(result.nodes.find((n) => n.id === "loop")).toBeTruthy(); expect(result.nodes.find((n) => n.id === reviewId)).toBeTruthy(); // Edges touching exec (both directions) are gone. expect(result.edges.some((e) => e.source === execId || e.target === execId)).toBe(false); }); it("never deletes start/end nodes (and preserves their edges)", () => { const { nodes, edges } = irToFlow(chainDef()); const result = cascadeDelete(nodes, edges, ["start", "end"]); expect(result.nodes.find((n) => n.id === "start")).toBeTruthy(); expect(result.nodes.find((n) => n.id === "end")).toBeTruthy(); // Their incident edges survive too (start→a, c→end). expect(result.edges.some((e) => e.source === "start")).toBe(true); expect(result.edges.some((e) => e.target === "end")).toBe(true); // Nothing was removed at all. expect(result.nodes).toHaveLength(nodes.length); expect(result.edges).toHaveLength(edges.length); }); it("never deletes column band nodes", () => { const v2: WorkflowDefinition["ir"] = { version: "v2", name: "wf", columns: [{ id: "col1", name: "Col 1", traits: [] }], nodes: [ { id: "start", kind: "start", column: "col1" }, { id: "end", kind: "end", column: "col1" }, ], edges: [{ from: "start", to: "end", condition: "success" }], }; const { nodes, edges } = irToFlow(makeDef(v2)); const bandId = nodes.find((n) => isColumnBandNode(n.id))!.id; const result = cascadeDelete(nodes, edges, [bandId]); expect(result.nodes.find((n) => n.id === bandId)).toBeTruthy(); }); it("deletes an edge id directly, removing just that edge", () => { const { nodes, edges } = irToFlow(chainDef()); const target = edges.find((e) => e.source === "b" && e.target === "c")!; const result = cascadeDelete(nodes, edges, [target.id]); expect(result.edges.find((e) => e.id === target.id)).toBeUndefined(); // No nodes removed, all other edges intact. expect(result.nodes).toHaveLength(nodes.length); expect(result.edges).toHaveLength(edges.length - 1); }); }); // ── Fragment insertion + graph copy (U8, R7/R8) ────────────────────────────── /** start → a → b → c → end (top-level scope, reused by the U8 suites). */ const u8ChainDef = (): WorkflowDefinition => makeDef({ version: "v1", name: "chain", nodes: [ { id: "start", kind: "start" }, { id: "a", kind: "prompt", config: { prompt: "a" } }, { id: "b", kind: "prompt", config: { prompt: "b" } }, { id: "c", kind: "prompt", config: { prompt: "c" } }, { id: "end", kind: "end" }, ], edges: [ { from: "start", to: "a", condition: "success" }, { from: "a", to: "b", condition: "success" }, { from: "b", to: "c", condition: "success" }, { from: "c", to: "end", condition: "success" }, ], }); /** A small fragment IR: start → a → b → end, b carries a merge seam + name. */ function fragmentIr(): WorkflowDefinition["ir"] { return { version: "v1", name: "frag", nodes: [ { id: "start", kind: "start" }, { id: "a", kind: "prompt", config: { prompt: "do a", name: "Step A" } }, { id: "b", kind: "prompt", config: { seam: "merge", name: "Merge" } }, { id: "end", kind: "end" }, ], edges: [ { from: "start", to: "a", condition: "success" }, { from: "a", to: "b", condition: "failure" }, { from: "b", to: "end", condition: "success" }, ], }; } describe("insertFragment", () => { it("strips start/end, remaps every node id, preserves internal edges/config/conditions", () => { const existing = irToFlow(u8ChainDef()); const before = new Set(existing.nodes.map((n) => n.id)); const { nodes, edges, insertedNodeIds } = insertFragment( existing.nodes, existing.edges, fragmentIr(), { x: 500, y: 200 }, ); // Two body nodes inserted (start/end stripped). expect(insertedNodeIds).toHaveLength(2); expect(nodes).toHaveLength(existing.nodes.length + 2); // Inserted ids are fresh (disjoint from existing) and not the fragment's. for (const id of insertedNodeIds) { expect(before.has(id)).toBe(false); expect(["start", "a", "b", "end"]).not.toContain(id); } const inserted = nodes.filter((n) => insertedNodeIds.includes(n.id)); // No start/end among inserted nodes. expect(inserted.some((n) => n.data.kind === "start" || n.data.kind === "end")).toBe(false); // Config preserved: the merge node keeps its seam; the prompt keeps its prompt. const merge = inserted.find((n) => n.data.kind === "merge")!; expect(merge.data.config?.seam).toBe("merge"); const promptNode = inserted.find((n) => n.data.config?.prompt === "do a")!; expect(promptNode).toBeTruthy(); // Only the single internal a→b edge survives (start→a, b→end stripped). const newEdges = edges.slice(existing.edges.length); expect(newEdges).toHaveLength(1); const e = newEdges[0]; expect(insertedNodeIds).toContain(e.source); expect(insertedNodeIds).toContain(e.target); // Edge condition preserved. expect(e.data?.condition).toBe("failure"); // Inputs not mutated. expect(existing.nodes).toHaveLength(before.size); }); it("positions inserted nodes near the requested position", () => { const existing = irToFlow(u8ChainDef()); const { nodes, insertedNodeIds } = insertFragment( existing.nodes, existing.edges, fragmentIr(), { x: 500, y: 200 }, ); const inserted = nodes.filter((n) => insertedNodeIds.includes(n.id)); for (const n of inserted) { expect(n.position.x).toBeGreaterThanOrEqual(500); expect(n.position.y).toBeGreaterThanOrEqual(200); expect(n.position.y).toBeLessThan(500); } }); it("double-insert of the same fragment yields two disjoint id sets", () => { const existing = irToFlow(u8ChainDef()); const first = insertFragment(existing.nodes, existing.edges, fragmentIr(), { x: 500, y: 200 }); const second = insertFragment(first.nodes, first.edges, fragmentIr(), { x: 800, y: 200 }); const setA = new Set(first.insertedNodeIds); for (const id of second.insertedNodeIds) expect(setA.has(id)).toBe(false); // All ids across the graph are unique. const allIds = second.nodes.map((n) => n.id); expect(new Set(allIds).size).toBe(allIds.length); }); it("expands a foreach fragment's template so flowToIr round-trips the full template", () => { // Fragment: start → loop(foreach with a 2-node template) → end. const foreachFragment: WorkflowDefinition["ir"] = { version: "v1", name: "frag", nodes: [ { id: "start", kind: "start" }, { id: "loop", kind: "foreach", config: { source: "task-steps", template: { nodes: [ { id: "t1", kind: "prompt", config: { prompt: "inner1" } }, { id: "t2", kind: "prompt", config: { prompt: "inner2" } }, ], edges: [{ from: "t1", to: "t2", condition: "success" }], }, }, }, { id: "end", kind: "end" }, ], edges: [ { from: "start", to: "loop", condition: "success" }, { from: "loop", to: "end", condition: "success" }, ], }; const existing = irToFlow(u8ChainDef()); const { nodes, edges, insertedNodeIds } = insertFragment( existing.nodes, existing.edges, foreachFragment, { x: 400, y: 200 }, ); // The foreach group's template children were expanded as parented nodes. const groupId = insertedNodeIds[0]; const children = nodes.filter((n) => n.parentId === groupId); expect(children).toHaveLength(2); // Round-trip the live canvas back to IR — the inserted foreach must carry its // full template (not an empty one) with both inner nodes and the inner edge. const { ir: out } = flowToIr("wf", nodes, edges); const loop = out.nodes.find((n) => n.kind === "foreach")!; expect(loop).toBeTruthy(); const template = (loop.config as { template?: { nodes: unknown[]; edges: unknown[] } }) .template; expect(template?.nodes).toHaveLength(2); expect(template?.edges).toHaveLength(1); }); }); describe("fragmentSeamConflicts", () => { it("flags a merge seam present in both fragment and canvas", () => { // Canvas containing a merge node. const canvas = irToFlow( makeDef({ version: "v1", name: "wf", nodes: [ { id: "start", kind: "start" }, { id: "m", kind: "prompt", config: { seam: "merge" } }, { id: "end", kind: "end" }, ], edges: [ { from: "start", to: "m", condition: "success" }, { from: "m", to: "end", condition: "success" }, ], }), ); expect(fragmentSeamConflicts(fragmentIr(), canvas.nodes)).toEqual(["merge"]); }); it("returns [] when the canvas has no overlapping seam", () => { const canvas = irToFlow(u8ChainDef()); expect(fragmentSeamConflicts(fragmentIr(), canvas.nodes)).toEqual([]); }); it("treats the editor 'merge' node kind as the merge seam on the canvas", () => { // Canvas node has no config.seam but is rendered as a merge node. const canvas: FlowNode[] = [ { id: "x", type: "merge", position: { x: 0, y: 0 }, data: { kind: "merge", label: "Merge boundary" }, }, ]; expect(fragmentSeamConflicts(fragmentIr(), canvas)).toEqual(["merge"]); }); }); describe("copyIrWithFreshIds", () => { function v2WithForeach(): WorkflowDefinition["ir"] { return { version: "v2", name: "wf", columns: [{ id: "in-progress", name: "In Progress", traits: [] }], nodes: [ { id: "start", kind: "start", column: "in-progress" }, { id: "loop", kind: "foreach", column: "in-progress", config: { source: "task-steps", template: { nodes: [ { id: "t1", kind: "prompt", config: { prompt: "inner" } }, { id: "t2", kind: "prompt", config: { prompt: "inner2" } }, ], edges: [{ from: "t1", to: "t2", condition: "success" }], }, }, }, { id: "end", kind: "end", column: "in-progress" }, ], edges: [ { from: "start", to: "loop", condition: "success" }, { from: "loop", to: "end", condition: "success" }, ], }; } it("preserves structure but remaps all node ids; layout keys remapped consistently", () => { const ir = u8ChainDef().ir; const layout = { start: { x: 0, y: 0 }, a: { x: 100, y: 0 }, b: { x: 200, y: 0 }, c: { x: 300, y: 0 }, end: { x: 400, y: 0 } }; const result = copyIrWithFreshIds(ir, layout); // Same counts + kinds. expect(result.ir.nodes).toHaveLength(ir.nodes.length); expect(result.ir.edges).toHaveLength(ir.edges.length); expect(result.ir.nodes.map((n) => n.kind)).toEqual(ir.nodes.map((n) => n.kind)); // All new ids, disjoint from originals. const origIds = new Set(ir.nodes.map((n) => n.id)); for (const n of result.ir.nodes) expect(origIds.has(n.id)).toBe(false); // Edges reference only new ids. const newIds = new Set(result.ir.nodes.map((n) => n.id)); for (const e of result.ir.edges) { expect(newIds.has(e.from)).toBe(true); expect(newIds.has(e.to)).toBe(true); } // Layout keys remapped consistently: same value set, all keys are new ids. expect(Object.keys(result.layout)).toHaveLength(Object.keys(layout).length); for (const key of Object.keys(result.layout)) expect(newIds.has(key)).toBe(true); // Original inputs untouched. expect(ir.nodes[0].id).toBe("start"); expect(layout.start).toEqual({ x: 0, y: 0 }); // The copy is a valid IR (value import works under the test-runner alias). const parsed = parseWorkflowIr(result.ir); expect(parsed.nodes).toHaveLength(ir.nodes.length); }); it("remaps foreach template node ids + edges, preserving columns", () => { const ir = v2WithForeach(); const result = copyIrWithFreshIds(ir, {}); const loop = result.ir.nodes.find((n) => n.kind === "foreach")!; const template = (loop.config as { template: { nodes: { id: string; kind: string }[]; edges: { from: string; to: string }[] } }).template; // Template node ids are remapped (not the originals t1/t2). expect(template.nodes.map((n) => n.id)).not.toEqual(["t1", "t2"]); expect(template.nodes.map((n) => n.kind)).toEqual(["prompt", "prompt"]); // Template edges reference the remapped template ids. const tIds = new Set(template.nodes.map((n) => n.id)); expect(template.edges).toHaveLength(1); expect(tIds.has(template.edges[0].from)).toBe(true); expect(tIds.has(template.edges[0].to)).toBe(true); // Columns preserved untouched. expect(result.ir.version).toBe("v2"); if (result.ir.version === "v2") { expect(result.ir.columns).toEqual(ir.columns); // Node columns carried through. expect(result.ir.nodes.every((n) => n.column === "in-progress")).toBe(true); } }); it("remaps namespaced foreach child layout keys consistently with the template ids", () => { const ir = v2WithForeach(); const layout = { start: { x: 0, y: 0 }, loop: { x: 100, y: 0 }, "loop::t1": { x: 10, y: 20 }, "loop::t2": { x: 270, y: 20 }, end: { x: 400, y: 0 }, }; const result = copyIrWithFreshIds(ir, layout); const loop = result.ir.nodes.find((n) => n.kind === "foreach")!; const template = (loop.config as { template: { nodes: { id: string }[] } }).template; const newGroupId = loop.id; const childKeys = Object.keys(result.layout).filter((k) => k.includes("::")); // Both namespaced child keys survive (count preserved). expect(childKeys).toHaveLength(2); // Each child key is `${newGroupId}::${newTemplateId}` for a real template id. const newInnerIds = new Set(template.nodes.map((n) => n.id)); for (const k of childKeys) { const [g, inner] = k.split("::"); expect(g).toBe(newGroupId); expect(newInnerIds.has(inner)).toBe(true); // No stale original ids leak through. expect(g).not.toBe("loop"); expect(["t1", "t2"]).not.toContain(inner); } // Values preserved by position (t1's offset stays with the remapped t1 key). const t1NewId = result.layout[`${newGroupId}::${template.nodes[0].id}`]; expect(t1NewId).toEqual({ x: 10, y: 20 }); }); });