- always dispose AI design session (try/finally) + rejecting-agent test - prune expired design rate-limit entries per check - apply workflowId precedence in createTaskWithReservedId - compare-and-set migrated project default (no pre-transaction snapshot) - reject fragment ids in setDefaultWorkflowId at the write boundary - guard stripApprovalBypassFlags against malformed template nodes - hoist duplicate stepToFragmentIr call in migration loop - auto-layout preserves unplaced nodes (strictColumnForY) - second same-pair connection births a failure edge for condition-capable sources - insertFragment expands foreach templates into child nodes (lossless round-trip) - copyIrWithFreshIds remaps namespaced foreach child layout keys - discard stale AI-edit results after workflow switch - i18n: returnEmptyString:false so empty locale placeholders fall back to en - test fixes: schema-version titles, migrate idempotency assertion, TaskForm post-resolve + cross-mode regression, deterministic waits
260 lines
9.2 KiB
TypeScript
260 lines
9.2 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import type { Node as FlowNode, Edge as FlowEdge } from "@xyflow/react";
|
|
import type { WorkflowIrColumn } from "@fusion/core";
|
|
import type { WorkflowFlowNodeData } from "../nodes/WorkflowNodeTypes";
|
|
import { autoLayout, applyAutoLayout } from "../workflow-auto-layout";
|
|
import {
|
|
strictColumnForY,
|
|
bandTop,
|
|
columnBandNodeId,
|
|
COLUMN_BAND_HEIGHT,
|
|
} from "../workflow-flow-mapping";
|
|
|
|
type N = FlowNode<WorkflowFlowNodeData>;
|
|
|
|
function node(
|
|
id: string,
|
|
kind: WorkflowFlowNodeData["kind"],
|
|
x: number,
|
|
y: number,
|
|
extra: Partial<N> & { column?: string } = {},
|
|
): N {
|
|
const { column, ...rest } = extra;
|
|
return {
|
|
id,
|
|
type: kind,
|
|
position: { x, y },
|
|
data: { kind, label: id, ...(column ? { column } : {}) },
|
|
...rest,
|
|
} as N;
|
|
}
|
|
|
|
function edge(source: string, target: string, kind?: "rework"): FlowEdge {
|
|
return {
|
|
id: `e-${source}-${target}`,
|
|
source,
|
|
target,
|
|
data: { condition: "success", kind },
|
|
};
|
|
}
|
|
|
|
const COLUMNS_3: WorkflowIrColumn[] = [
|
|
{ id: "triage", name: "Triage", traits: [] },
|
|
{ id: "in-progress", name: "In progress", traits: [] },
|
|
{ id: "done", name: "Done", traits: [] },
|
|
];
|
|
|
|
/** Mid-band y for a column index (a stable starting placement). */
|
|
function midBand(index: number): number {
|
|
return bandTop(index) + COLUMN_BAND_HEIGHT / 2;
|
|
}
|
|
|
|
describe("autoLayout — v2 (column-preserving)", () => {
|
|
it("linear chain: strictly increasing x and every node keeps its column", () => {
|
|
const nodes: N[] = [
|
|
node("start", "start", 999, midBand(0), { column: "triage" }),
|
|
node("a", "prompt", 50, midBand(1), { column: "in-progress" }),
|
|
node("b", "prompt", 10, midBand(1), { column: "in-progress" }),
|
|
node("end", "end", 0, midBand(2), { column: "done" }),
|
|
];
|
|
const edges = [edge("start", "a"), edge("a", "b"), edge("b", "end")];
|
|
const pos = autoLayout(nodes, edges, COLUMNS_3);
|
|
|
|
const xs = ["start", "a", "b", "end"].map((id) => pos.get(id)!.x);
|
|
for (let i = 1; i < xs.length; i++) {
|
|
expect(xs[i]).toBeGreaterThan(xs[i - 1]);
|
|
}
|
|
|
|
// Invariant: column unchanged for every node.
|
|
for (const n of nodes) {
|
|
const original = n.data.column!;
|
|
const newY = pos.get(n.id)!.y;
|
|
expect(strictColumnForY(newY, COLUMNS_3)).toBe(original);
|
|
}
|
|
});
|
|
|
|
it("branching graph: two branch targets get distinct positions", () => {
|
|
const nodes: N[] = [
|
|
node("start", "start", 0, midBand(0), { column: "triage" }),
|
|
node("ok", "prompt", 0, midBand(1), { column: "in-progress" }),
|
|
node("fail", "prompt", 0, midBand(1), { column: "in-progress" }),
|
|
];
|
|
const edges = [edge("start", "ok"), edge("start", "fail")];
|
|
const pos = autoLayout(nodes, edges, COLUMNS_3);
|
|
|
|
const a = pos.get("ok")!;
|
|
const b = pos.get("fail")!;
|
|
expect(a.x === b.x && a.y === b.y).toBe(false);
|
|
// Same layer + same band → stacked vertically.
|
|
expect(a.x).toBe(b.x);
|
|
expect(a.y).not.toBe(b.y);
|
|
expect(strictColumnForY(a.y, COLUMNS_3)).toBe("in-progress");
|
|
expect(strictColumnForY(b.y, COLUMNS_3)).toBe("in-progress");
|
|
});
|
|
|
|
it("dense band: more same-layer/same-band nodes than fit 220px stay in-band, staggered x, no collisions", () => {
|
|
const count = 12;
|
|
const nodes: N[] = [node("start", "start", 0, midBand(0), { column: "triage" })];
|
|
const edges: FlowEdge[] = [];
|
|
for (let i = 0; i < count; i++) {
|
|
nodes.push(node(`n${i}`, "prompt", 0, midBand(1), { column: "in-progress" }));
|
|
edges.push(edge("start", `n${i}`));
|
|
}
|
|
const pos = autoLayout(nodes, edges, COLUMNS_3);
|
|
|
|
const seen = new Set<string>();
|
|
for (let i = 0; i < count; i++) {
|
|
const p = pos.get(`n${i}`)!;
|
|
// All stay in their band.
|
|
expect(strictColumnForY(p.y, COLUMNS_3)).toBe("in-progress");
|
|
// No two nodes share a position.
|
|
const key = `${p.x},${p.y}`;
|
|
expect(seen.has(key)).toBe(false);
|
|
seen.add(key);
|
|
}
|
|
// Overflow forced horizontal staggering (more than one distinct x).
|
|
const distinctX = new Set([...seen].map((k) => k.split(",")[0]));
|
|
expect(distinctX.size).toBeGreaterThan(1);
|
|
});
|
|
|
|
it("derives column from y when data.column is absent", () => {
|
|
const nodes: N[] = [
|
|
node("start", "start", 0, midBand(0)),
|
|
node("a", "prompt", 0, midBand(2)),
|
|
];
|
|
const pos = autoLayout(nodes, [edge("start", "a")], COLUMNS_3);
|
|
expect(strictColumnForY(pos.get("start")!.y, COLUMNS_3)).toBe("triage");
|
|
expect(strictColumnForY(pos.get("a")!.y, COLUMNS_3)).toBe("done");
|
|
});
|
|
});
|
|
|
|
describe("autoLayout — v1 (free placement)", () => {
|
|
it("produces layered positions, no NaN, deterministic across two calls", () => {
|
|
const nodes: N[] = [
|
|
node("start", "start", 0, 0),
|
|
node("a", "prompt", 0, 0),
|
|
node("b", "prompt", 0, 0),
|
|
node("end", "end", 0, 0),
|
|
];
|
|
const edges = [edge("start", "a"), edge("start", "b"), edge("a", "end"), edge("b", "end")];
|
|
const p1 = autoLayout(nodes, edges, []);
|
|
const p2 = autoLayout(nodes, edges, []);
|
|
|
|
for (const n of nodes) {
|
|
const p = p1.get(n.id)!;
|
|
expect(Number.isFinite(p.x)).toBe(true);
|
|
expect(Number.isFinite(p.y)).toBe(true);
|
|
}
|
|
// start before branches before end.
|
|
expect(p1.get("start")!.x).toBeLessThan(p1.get("a")!.x);
|
|
expect(p1.get("a")!.x).toBeLessThan(p1.get("end")!.x);
|
|
// Deterministic.
|
|
for (const n of nodes) {
|
|
expect(p1.get(n.id)).toEqual(p2.get(n.id));
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("autoLayout — foreach / unreachable / cycles", () => {
|
|
it("repositions a foreach group but leaves its parentId children untouched", () => {
|
|
const childPos = { x: 30, y: 56 };
|
|
const nodes: N[] = [
|
|
node("start", "start", 0, midBand(0), { column: "triage" }),
|
|
node("grp", "foreach", 999, midBand(1), { column: "in-progress" }),
|
|
{
|
|
id: "grp::c1",
|
|
type: "prompt",
|
|
position: { ...childPos },
|
|
parentId: "grp",
|
|
extent: "parent",
|
|
data: { kind: "prompt", label: "c1" },
|
|
} as N,
|
|
];
|
|
const edges = [edge("start", "grp")];
|
|
const pos = autoLayout(nodes, edges, COLUMNS_3);
|
|
|
|
// Group moved.
|
|
expect(pos.has("grp")).toBe(true);
|
|
expect(pos.get("grp")!.x).not.toBe(999);
|
|
// Child not in the position map → untouched.
|
|
expect(pos.has("grp::c1")).toBe(false);
|
|
|
|
const applied = applyAutoLayout(nodes, pos);
|
|
const child = applied.find((n) => n.id === "grp::c1")!;
|
|
expect(child.position).toEqual(childPos);
|
|
});
|
|
|
|
it("gives an unreachable node a finite position in a trailing layer", () => {
|
|
const nodes: N[] = [
|
|
node("start", "start", 0, midBand(0), { column: "triage" }),
|
|
node("a", "prompt", 0, midBand(1), { column: "in-progress" }),
|
|
node("orphan", "prompt", 0, midBand(2), { column: "done" }),
|
|
];
|
|
const edges = [edge("start", "a")];
|
|
const pos = autoLayout(nodes, edges, COLUMNS_3);
|
|
|
|
const o = pos.get("orphan")!;
|
|
expect(Number.isFinite(o.x)).toBe(true);
|
|
expect(Number.isFinite(o.y)).toBe(true);
|
|
// Trailing layer is past the reachable nodes.
|
|
expect(o.x).toBeGreaterThan(pos.get("a")!.x);
|
|
expect(strictColumnForY(o.y, COLUMNS_3)).toBe("done");
|
|
});
|
|
|
|
it("leaves an unplaced node (no column, parked outside all bands) unplaced", () => {
|
|
// y far below the last band, with no explicit column → strictColumnForY undefined.
|
|
const outsideY = bandTop(COLUMNS_3.length) + 5000;
|
|
const nodes: N[] = [
|
|
node("start", "start", 0, midBand(0), { column: "triage" }),
|
|
node("a", "prompt", 0, midBand(1), { column: "in-progress" }),
|
|
node("loose", "prompt", 0, outsideY),
|
|
];
|
|
const edges = [edge("start", "a"), edge("a", "loose")];
|
|
const pos = autoLayout(nodes, edges, COLUMNS_3);
|
|
|
|
const loose = pos.get("loose")!;
|
|
// y is preserved (still outside every band) — NOT clamped into a band.
|
|
expect(loose.y).toBe(outsideY);
|
|
expect(strictColumnForY(loose.y, COLUMNS_3)).toBeUndefined();
|
|
// x still flows left-to-right with the layering tidy.
|
|
expect(loose.x).toBeGreaterThan(pos.get("a")!.x);
|
|
});
|
|
|
|
it("terminates and stays sane with a rework cycle edge present", () => {
|
|
const nodes: N[] = [
|
|
node("start", "start", 0, midBand(0), { column: "triage" }),
|
|
node("a", "prompt", 0, midBand(1), { column: "in-progress" }),
|
|
node("b", "prompt", 0, midBand(1), { column: "in-progress" }),
|
|
node("end", "end", 0, midBand(2), { column: "done" }),
|
|
];
|
|
const edges = [
|
|
edge("start", "a"),
|
|
edge("a", "b"),
|
|
edge("b", "end"),
|
|
edge("b", "a", "rework"), // rework loop — ignored for layering
|
|
];
|
|
const pos = autoLayout(nodes, edges, COLUMNS_3);
|
|
|
|
// Layering ignores rework: a strictly before b.
|
|
expect(pos.get("a")!.x).toBeLessThan(pos.get("b")!.x);
|
|
for (const n of nodes) {
|
|
expect(strictColumnForY(pos.get(n.id)!.y, COLUMNS_3)).toBe(n.data.column);
|
|
}
|
|
});
|
|
|
|
it("ignores column band group nodes", () => {
|
|
const nodes: N[] = [
|
|
{
|
|
id: columnBandNodeId("triage"),
|
|
type: "group",
|
|
position: { x: -40, y: bandTop(0) },
|
|
data: { kind: "start", label: "Triage" },
|
|
} as N,
|
|
node("start", "start", 0, midBand(0), { column: "triage" }),
|
|
];
|
|
const pos = autoLayout(nodes, [], COLUMNS_3);
|
|
expect(pos.has(columnBandNodeId("triage"))).toBe(false);
|
|
expect(pos.has("start")).toBe(true);
|
|
});
|
|
});
|