Files
fusion/packages/dashboard/app/components/__tests__/workflow-mobile-graph.test.ts
gsxdsm ba599a4ea1 FN-7136: preserve workflow edges between adjacent group nodes
Keep workflow graph edges connected when optional-group containers appear consecutively.

- Reject stale saved top-level layout coordinates only when connected container spans overlap in the same visual row.
- Advance auto-layout layers by each consecutive container layer's rendered width.
- Add coverage for desktop flow mapping, auto-layout, mobile graph rendering, and release-note changeset.

Files changed:
 .../fn-7136-consecutive-optional-group-edges.md    |   7 ++
 .../__tests__/workflow-auto-layout.test.ts         | 116 +++++++++++++++++++-
 .../__tests__/workflow-flow-mapping.test.ts        | 122 ++++++++++++++++++++-
 .../__tests__/workflow-mobile-graph.test.ts        |  10 +-
 .../app/components/workflow-auto-layout.ts         |   3 +
 .../app/components/workflow-flow-mapping.ts        |  50 ++++++++-
 6 files changed, 299 insertions(+), 9 deletions(-)

Fusion-Task-Id: FN-7136
Fusion-Task-Lineage: 1fa1e528-a68e-4eee-9018-6752fe62a1f5
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-06-27 15:39:01 -07:00

211 lines
8.4 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { BUILTIN_CODING_WORKFLOW_IR, BUILTIN_STEPWISE_CODING_WORKFLOW_IR } from "@fusion/core";
import type { Edge as FlowEdge, Node as FlowNode } from "@xyflow/react";
import { buildMobileWorkflowGraph, reorderWorkflowNode } from "../workflow-mobile-graph";
import type { WorkflowFlowNodeData } from "../nodes/WorkflowNodeTypes";
import { columnBandNodeId, foreachChildFlowId, irToFlow } from "../workflow-flow-mapping";
function node(
id: string,
kind: WorkflowFlowNodeData["kind"],
x: number,
y: number,
extra: Partial<FlowNode<WorkflowFlowNodeData>> = {},
): FlowNode<WorkflowFlowNodeData> {
return {
id,
type: kind,
position: { x, y },
data: { kind, label: id, ...(extra.data ?? {}) },
...extra,
};
}
function edge(id: string, source: string, target: string, condition = "success"): FlowEdge {
return {
id,
source,
target,
label: condition,
data: { condition },
};
}
function workflowDef(ir: typeof BUILTIN_CODING_WORKFLOW_IR) {
return {
id: ir.name,
kind: "workflow" as const,
name: ir.name,
description: "",
ir,
layout: {},
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-01T00:00:00.000Z",
};
}
function rowOrder(nodes: FlowNode<WorkflowFlowNodeData>[]): string[] {
return buildMobileWorkflowGraph(nodes, []).map((row) => row.id);
}
describe("reorderWorkflowNode", () => {
it("swaps adjacent editable top-level siblings in the same column and re-derives the new order", () => {
const nodes = [
node("a", "prompt", 0, 0, { data: { kind: "prompt", label: "A", column: "todo" } }),
node("b", "script", 0, 80, { data: { kind: "script", label: "B", column: "todo" } }),
node("c", "gate", 0, 160, { data: { kind: "gate", label: "C", column: "todo" } }),
];
const reordered = reorderWorkflowNode(nodes, "b", "up");
expect(rowOrder(reordered)).toEqual(["b", "a", "c"]);
expect(reordered.find((n) => n.id === "b")?.position).toEqual({ x: 0, y: 0 });
expect(reordered.find((n) => n.id === "a")?.position).toEqual({ x: 0, y: 80 });
});
it("does not move past same-group boundaries", () => {
const nodes = [
node("a", "prompt", 0, 0, { data: { kind: "prompt", label: "A", column: "todo" } }),
node("b", "script", 0, 80, { data: { kind: "script", label: "B", column: "todo" } }),
];
expect(reorderWorkflowNode(nodes, "a", "up")).toBe(nodes);
expect(reorderWorkflowNode(nodes, "b", "down")).toBe(nodes);
});
it("does not move top-level nodes across column groups", () => {
const nodes = [
node("todo-a", "prompt", 0, 0, { data: { kind: "prompt", label: "A", column: "todo" } }),
node("doing-a", "script", 0, 80, { data: { kind: "script", label: "B", column: "doing" } }),
];
expect(reorderWorkflowNode(nodes, "todo-a", "down")).toBe(nodes);
expect(rowOrder(reorderWorkflowNode(nodes, "doing-a", "up"))).toEqual(["todo-a", "doing-a"]);
});
it("reorders template children only within the same parent", () => {
const first = foreachChildFlowId("each", "first");
const second = foreachChildFlowId("each", "second");
const other = foreachChildFlowId("other", "first");
const nodes = [
node("each", "foreach", 0, 0, { data: { kind: "foreach", label: "Each" } }),
node(first, "prompt", 20, 60, { parentId: "each", data: { kind: "prompt", label: "First" } }),
node(second, "script", 20, 120, { parentId: "each", data: { kind: "script", label: "Second" } }),
node("other", "loop", 0, 200, { data: { kind: "loop", label: "Other" } }),
node(other, "prompt", 20, 60, { parentId: "other", data: { kind: "prompt", label: "Other child" } }),
];
const rows = buildMobileWorkflowGraph(reorderWorkflowNode(nodes, second, "up"), []);
expect(rows.find((row) => row.id === "each")?.children.map((child) => child.id)).toEqual([second, first]);
expect(rows.find((row) => row.id === "other")?.children.map((child) => child.id)).toEqual([other]);
});
it("refuses to reorder non-editable nodes or swap with a non-editable neighbor", () => {
const nodes = [
node("start", "start", 0, 0, { data: { kind: "start", label: "Start", column: "todo" } }),
node("step", "prompt", 0, 80, { data: { kind: "prompt", label: "Step", column: "todo" } }),
node(columnBandNodeId("todo"), "start", -40, 0, {
type: "group",
data: { kind: "start", label: "Todo", column: "todo" },
}),
];
expect(reorderWorkflowNode(nodes, "start", "down")).toBe(nodes);
expect(reorderWorkflowNode(nodes, "step", "up")).toBe(nodes);
expect(reorderWorkflowNode(nodes, columnBandNodeId("todo"), "down")).toBe(nodes);
});
});
describe("buildMobileWorkflowGraph", () => {
it("returns ordered linear rows with outgoing edge destinations", () => {
const rows = buildMobileWorkflowGraph(
[
node("end", "end", 300, 0),
node("start", "start", 0, 0),
node("lint", "gate", 150, 0, { data: { kind: "gate", label: "Lint", config: { gateMode: "gate" } } }),
],
[edge("e1", "start", "lint"), edge("e2", "lint", "end")],
);
expect(rows.map((row) => row.id)).toEqual(["start", "lint", "end"]);
expect(rows[0].outgoing[0]).toMatchObject({ target: "lint", targetLabel: "Lint" });
expect(rows[1].summary).toBe("Gate (blocks)");
});
it("summarizes consecutive optional-group container connections for built-in mobile outlines", () => {
for (const [name, ir, incoming] of [
["coding", BUILTIN_CODING_WORKFLOW_IR, "execute"],
["stepwise", BUILTIN_STEPWISE_CODING_WORKFLOW_IR, "steps"],
] as const) {
const { nodes, edges } = irToFlow(workflowDef(ir));
const rows = buildMobileWorkflowGraph(nodes, edges, ir.version === "v2" ? ir.columns : []);
const browserVerification = rows.find((row) => row.id === "browser-verification");
const codeReview = rows.find((row) => row.id === "code-review");
const incomingRow = rows.find((row) => row.id === incoming);
expect(browserVerification?.kind, name).toBe("optional-group");
expect(codeReview?.kind, name).toBe("optional-group");
expect(incomingRow?.outgoing.some((out) => out.target === "browser-verification"), name).toBe(true);
expect(browserVerification?.outgoing.map((out) => [out.target, out.label]), name).toEqual(
expect.arrayContaining([
["code-review", "success"],
["end", "failure"],
]),
);
expect(codeReview?.outgoing.map((out) => [out.target, out.label]), name).toEqual(
expect.arrayContaining([
["review", "success"],
["end", "failure"],
]),
);
}
});
it("preserves branch edges and column labels while ignoring column band nodes", () => {
const rows = buildMobileWorkflowGraph(
[
node(columnBandNodeId("todo"), "start", -40, 0, {
type: "group",
data: { kind: "start", label: "Todo", column: "todo" },
}),
node("split", "split", 0, 0, { data: { kind: "split", label: "Split", column: "todo" } }),
node("a", "prompt", 160, 20, { data: { kind: "prompt", label: "A", column: "todo" } }),
node("b", "script", 160, 90, { data: { kind: "script", label: "B", column: "todo" } }),
],
[edge("e1", "split", "a", "success"), edge("e2", "split", "b", "failure")],
[{ id: "todo", name: "Todo", traits: [] }],
);
expect(rows.map((row) => row.id)).toEqual(["split", "a", "b"]);
expect(rows[0].columnName).toBe("Todo");
expect(rows[0].outgoing.map((out) => [out.label, out.targetLabel])).toEqual([
["success", "A"],
["failure", "B"],
]);
});
it("nests foreach template children without exposing local ids as top-level rows", () => {
const childId = foreachChildFlowId("each", "step");
const rows = buildMobileWorkflowGraph(
[
node("each", "foreach", 0, 0, { data: { kind: "foreach", label: "Each step", config: { mode: "parallel" } } }),
node(childId, "prompt", 20, 60, {
parentId: "each",
data: { kind: "prompt", label: "Run step", config: { seam: "step-execute" } },
}),
],
[edge("e-child", childId, childId, "outcome:revise")],
);
expect(rows).toHaveLength(1);
expect(rows[0].id).toBe("each");
expect(rows[0].children).toHaveLength(1);
expect(rows[0].children[0]).toMatchObject({
id: childId,
templateLocalId: "step",
label: "Run step",
});
});
});