Files
fusion/plugins/fusion-plugin-dependency-graph/src/__tests__/layout.test.ts
Fusion 14d11ff5a6 test(FN-4596): complete Step 2 — add horizontal measured-height regression tests
Fusion-Task-Id: FN-4596
Fusion-Task-Lineage: 0af225ee-ce0a-47ea-95cb-eff3b9f1d5fc
2026-05-15 07:04:04 -07:00

268 lines
9.2 KiB
TypeScript

import { describe, expect, it } from "vitest";
import type { GraphData } from "../types";
import { computeAutoLayout } from "../layout";
function graph(nodeIds: string[], edges: Array<{ source: string; target: string }> = []): GraphData {
return {
nodes: nodeIds.map((id) => ({ task: { id } as never })),
edges,
};
}
describe("computeAutoLayout", () => {
it("returns empty map for empty graph", () => {
expect(computeAutoLayout({ nodes: [], edges: [] }).size).toBe(0);
});
it("positions single node", () => {
const positions = computeAutoLayout(graph(["A"]));
expect(positions.has("A")).toBe(true);
});
it("places linear chain in increasing depth", () => {
const positions = computeAutoLayout(graph(["A", "B", "C"], [
{ source: "A", target: "B" },
{ source: "B", target: "C" },
]));
expect((positions.get("C")?.y ?? 0)).toBeLessThan(positions.get("B")?.y ?? 0);
expect((positions.get("B")?.y ?? 0)).toBeLessThan(positions.get("A")?.y ?? 0);
});
it("spreads wide layer horizontally", () => {
const positions = computeAutoLayout(graph(["A", "B", "C"]));
const xs = [positions.get("A")?.x, positions.get("B")?.x, positions.get("C")?.x].filter((x): x is number => x !== undefined);
expect(new Set(xs).size).toBe(3);
});
it("handles diamond dependencies", () => {
const positions = computeAutoLayout(graph(["A", "B", "C", "D"], [
{ source: "A", target: "B" },
{ source: "A", target: "C" },
{ source: "B", target: "D" },
{ source: "C", target: "D" },
]));
expect((positions.get("D")?.y ?? 0)).toBeLessThan(positions.get("B")?.y ?? 0);
expect((positions.get("D")?.y ?? 0)).toBeLessThan(positions.get("C")?.y ?? 0);
expect((positions.get("B")?.y ?? 0)).toBeLessThan(positions.get("A")?.y ?? 0);
expect((positions.get("C")?.y ?? 0)).toBeLessThan(positions.get("A")?.y ?? 0);
});
it("handles cycles without crashing", () => {
const positions = computeAutoLayout(graph(["A", "B"], [
{ source: "A", target: "B" },
{ source: "B", target: "A" },
]));
expect(positions.size).toBe(2);
});
it("respects custom spacing options", () => {
const positions = computeAutoLayout(graph(["A", "B"]), {
nodeWidth: 200,
nodeHeight: 120,
horizontalGap: 100,
verticalGap: 20,
});
expect(Math.abs((positions.get("A")?.x ?? 0) - (positions.get("B")?.x ?? 0))).toBe(300);
});
describe("vertical orientation with measured heights", () => {
it("adds cumulative row offsets from measured layer heights", () => {
const verticalGap = 80;
const nodeHeight = 100;
const positions = computeAutoLayout(
graph(["A", "B", "C"], [
{ source: "A", target: "B" },
{ source: "B", target: "C" },
]),
{
orientation: "vertical",
nodeHeight,
verticalGap,
measuredHeights: new Map([["C", 300]]),
},
);
const aY = positions.get("A")?.y ?? 0;
const bY = positions.get("B")?.y ?? 0;
const cY = positions.get("C")?.y ?? 0;
expect(bY).toBeGreaterThanOrEqual(cY + 300 + verticalGap);
expect(aY).toBeGreaterThanOrEqual(bY + nodeHeight + verticalGap);
});
it("uses per-layer max measured heights for wide layers", () => {
const verticalGap = 80;
const nodeHeight = 100;
const positions = computeAutoLayout(
graph(["A", "B", "C", "D"], [
{ source: "C", target: "A" },
{ source: "D", target: "A" },
]),
{
orientation: "vertical",
nodeHeight,
verticalGap,
measuredHeights: new Map([
["A", 140],
["B", 220],
]),
},
);
expect(positions.get("A")?.y).toBe(0);
expect(positions.get("B")?.y).toBe(0);
const expectedDepthOneY = 220 + verticalGap;
expect(positions.get("C")?.y).toBe(expectedDepthOneY);
expect(positions.get("D")?.y).toBe(expectedDepthOneY);
});
it.each([
{ nodeHeight: 100, verticalGap: 80 },
{ nodeHeight: 140, verticalGap: 24 },
])("matches fixed-height behavior when no measurements exist (%o)", ({ nodeHeight, verticalGap }) => {
const graphData = graph(["A", "B", "C"], [
{ source: "A", target: "B" },
{ source: "B", target: "C" },
]);
const withoutHeights = computeAutoLayout(graphData, { orientation: "vertical", nodeHeight, verticalGap });
const emptyHeights = computeAutoLayout(graphData, {
orientation: "vertical",
nodeHeight,
verticalGap,
measuredHeights: new Map(),
});
expect(Array.from(withoutHeights.entries())).toEqual(Array.from(emptyHeights.entries()));
});
});
describe("horizontal orientation with measured heights", () => {
it("uses measured heights as the stacking stride for each preceding node", () => {
const nodeHeight = 100;
const verticalGap = 80;
const measuredHeights = new Map<string, number>([
["A", 200],
["B", 300],
]);
const positions = computeAutoLayout(graph(["A", "B", "C"]), {
orientation: "horizontal",
nodeHeight,
verticalGap,
measuredHeights,
});
const ordered = ["A", "B", "C"].map((id) => ({
id,
y: positions.get(id)?.y ?? 0,
height: measuredHeights.get(id) ?? nodeHeight,
}));
expect(ordered[1].y - ordered[0].y).toBe(ordered[0].height + verticalGap);
expect(ordered[2].y - ordered[1].y).toBe(ordered[1].height + verticalGap);
expect(ordered[0].y + ordered[0].height + verticalGap).toBe(ordered[1].y);
expect(ordered[1].y + ordered[1].height + verticalGap).toBe(ordered[2].y);
});
it("keeps adjacent card gaps exactly equal to verticalGap when all nodes are measured", () => {
const nodeHeight = 100;
const verticalGap = 64;
const measuredHeights = new Map<string, number>([
["A", 180],
["B", 220],
["C", 260],
]);
const positions = computeAutoLayout(graph(["A", "B", "C"]), {
orientation: "horizontal",
nodeHeight,
verticalGap,
measuredHeights,
});
const ordered = ["A", "B", "C"].map((id) => ({
y: positions.get(id)?.y ?? 0,
height: measuredHeights.get(id) ?? nodeHeight,
}));
expect(ordered[1].y - (ordered[0].y + ordered[0].height)).toBe(verticalGap);
expect(ordered[2].y - (ordered[1].y + ordered[1].height)).toBe(verticalGap);
});
it.each([
{ nodeHeight: 100, verticalGap: 80 },
{ nodeHeight: 140, verticalGap: 24 },
])("matches fixed-stride behavior when no measurements exist (%o)", ({ nodeHeight, verticalGap }) => {
const graphData = graph(["A", "B", "C"]);
const withoutHeights = computeAutoLayout(graphData, {
orientation: "horizontal",
nodeHeight,
verticalGap,
});
const emptyHeights = computeAutoLayout(graphData, {
orientation: "horizontal",
nodeHeight,
verticalGap,
measuredHeights: new Map(),
});
expect(Array.from(withoutHeights.entries())).toEqual(Array.from(emptyHeights.entries()));
});
});
describe("horizontal orientation", () => {
it("places linear chain in increasing depth along x", () => {
const positions = computeAutoLayout(
graph(["A", "B", "C"], [
{ source: "A", target: "B" },
{ source: "B", target: "C" },
]),
{ orientation: "horizontal" },
);
expect((positions.get("C")?.x ?? 0)).toBeLessThan(positions.get("B")?.x ?? 0);
expect((positions.get("B")?.x ?? 0)).toBeLessThan(positions.get("A")?.x ?? 0);
expect(positions.get("A")?.y).toBe(positions.get("B")?.y);
expect(positions.get("B")?.y).toBe(positions.get("C")?.y);
});
it("spreads wide layer vertically", () => {
const positions = computeAutoLayout(graph(["A", "B", "C"]), { orientation: "horizontal" });
const ys = [positions.get("A")?.y, positions.get("B")?.y, positions.get("C")?.y].filter((y): y is number => y !== undefined);
const xs = [positions.get("A")?.x, positions.get("B")?.x, positions.get("C")?.x].filter((x): x is number => x !== undefined);
expect(new Set(ys).size).toBe(3);
expect(new Set(xs).size).toBe(1);
});
it("handles diamond dependencies mirrored onto x", () => {
const positions = computeAutoLayout(
graph(["A", "B", "C", "D"], [
{ source: "A", target: "B" },
{ source: "A", target: "C" },
{ source: "B", target: "D" },
{ source: "C", target: "D" },
]),
{ orientation: "horizontal" },
);
expect((positions.get("D")?.x ?? 0)).toBeLessThan(positions.get("B")?.x ?? 0);
expect((positions.get("D")?.x ?? 0)).toBeLessThan(positions.get("C")?.x ?? 0);
expect((positions.get("B")?.x ?? 0)).toBeLessThan(positions.get("A")?.x ?? 0);
expect((positions.get("C")?.x ?? 0)).toBeLessThan(positions.get("A")?.x ?? 0);
});
it("handles cycles without crashing", () => {
const positions = computeAutoLayout(
graph(["A", "B"], [
{ source: "A", target: "B" },
{ source: "B", target: "A" },
]),
{ orientation: "horizontal" },
);
expect(positions.size).toBe(2);
});
});
});