feat(FN-3082): restructure dependency graph plugin with modular architectur
Implements a modular dependency graph feature for the Fusion dashboard plugin, replacing the monolithic `DependencyGraphView` component with a factored architecture: graph types and filtering (Step 1), a data hook (Step 2), auto-layout engine (Step 3), SVG edge rendering (Step 4), an interaction hoo Fusion-Task-Id: FN-3082
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
import { afterEach, describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { Task } from "@fusion/core";
|
||||
import { DependencyGraph } from "../DependencyGraph";
|
||||
|
||||
const fitToGraph = vi.fn();
|
||||
|
||||
vi.mock("@fusion/dashboard/app/components/TaskCard", () => ({
|
||||
TaskCard: ({ task, onOpenDetail }: { task: Task; onOpenDetail: () => void }) => (
|
||||
<button data-testid={`task-${task.id}`} onClick={onOpenDetail}>{task.id}</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../useGraphInteraction", () => ({
|
||||
useGraphInteraction: () => ({
|
||||
transform: "translate(0px, 0px) scale(1)",
|
||||
zoomIn: vi.fn(),
|
||||
zoomOut: vi.fn(),
|
||||
fitToGraph,
|
||||
onPointerDown: vi.fn(),
|
||||
onPointerMove: vi.fn(),
|
||||
onPointerUp: vi.fn(),
|
||||
onWheelZoom: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
function createTask(id: string, column: Task["column"], dependencies: string[] = []): Task {
|
||||
return { id, description: id, column, dependencies, steps: [], currentStep: 0, log: [] } as Task;
|
||||
}
|
||||
|
||||
describe("DependencyGraph", () => {
|
||||
beforeEach(() => {
|
||||
fitToGraph.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("renders empty state for empty list", () => {
|
||||
render(<DependencyGraph tasks={[]} onOpenTaskDetail={vi.fn()} />);
|
||||
expect(screen.getByText(/No active tasks/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders positioned nodes and edges for included tasks", () => {
|
||||
render(<DependencyGraph tasks={[
|
||||
createTask("A", "todo"),
|
||||
createTask("B", "in-progress", ["A"]),
|
||||
]} onOpenTaskDetail={vi.fn()} />);
|
||||
|
||||
expect(screen.getByTestId("task-A")).toBeTruthy();
|
||||
expect(screen.getByTestId("task-B")).toBeTruthy();
|
||||
expect(screen.getAllByTestId("dependency-edge")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("excludes done and archived nodes", () => {
|
||||
render(<DependencyGraph tasks={[
|
||||
createTask("A", "todo"),
|
||||
createTask("B", "done"),
|
||||
createTask("C", "archived"),
|
||||
]} onOpenTaskDetail={vi.fn()} />);
|
||||
|
||||
expect(screen.getByTestId("task-A")).toBeTruthy();
|
||||
expect(screen.queryByTestId("task-B")).toBeNull();
|
||||
expect(screen.queryByTestId("task-C")).toBeNull();
|
||||
});
|
||||
|
||||
it("fit-to-screen button triggers fitToGraph", () => {
|
||||
render(<DependencyGraph tasks={[createTask("A", "todo")]} onOpenTaskDetail={vi.fn()} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Fit to screen" }));
|
||||
expect(fitToGraph).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,261 +0,0 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, fireEvent, screen, act, cleanup } from "@testing-library/react";
|
||||
import * as React from "react";
|
||||
import { DependencyGraphView } from "../DependencyGraphView";
|
||||
import type { DependencyGraphHostContext, PluginDashboardViewComponentProps } from "../DependencyGraphView";
|
||||
import type { Task } from "@fusion/core";
|
||||
|
||||
// Mock storage to avoid localStorage dependency
|
||||
vi.mock("../storage", () => ({
|
||||
loadPositions: () => ({}),
|
||||
savePositions: () => {},
|
||||
}));
|
||||
|
||||
// Helper to create a minimal Task
|
||||
function createTask(overrides: Partial<Task> & { id: string; column: Task["column"] }): Task {
|
||||
return {
|
||||
description: overrides.description ?? `Task ${overrides.id}`,
|
||||
column: overrides.column,
|
||||
dependencies: overrides.dependencies ?? [],
|
||||
steps: overrides.steps ?? [],
|
||||
currentStep: overrides.currentStep ?? 0,
|
||||
log: overrides.log ?? [],
|
||||
...overrides,
|
||||
} as Task;
|
||||
}
|
||||
|
||||
function createMockContext(tasks: Task[] = []): DependencyGraphHostContext {
|
||||
return {
|
||||
projectId: "test-project",
|
||||
tasks,
|
||||
openTaskDetail: vi.fn(),
|
||||
renderTaskCard: (task: Task) => React.createElement("div", { "data-testid": "task-card" }, task.id),
|
||||
};
|
||||
}
|
||||
|
||||
function renderView(context?: DependencyGraphHostContext) {
|
||||
const props: PluginDashboardViewComponentProps = {
|
||||
context: context ?? createMockContext(),
|
||||
};
|
||||
return render(React.createElement(DependencyGraphView, props));
|
||||
}
|
||||
|
||||
describe("DependencyGraphView", () => {
|
||||
beforeEach(() => {
|
||||
// Mock getComputedStyle for rem calculations
|
||||
vi.spyOn(window, "getComputedStyle").mockImplementation((() => {
|
||||
return { fontSize: "16px" } as CSSStyleDeclaration;
|
||||
}) as typeof window.getComputedStyle);
|
||||
|
||||
// Mock matchMedia to default to desktop
|
||||
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("renders empty state when no active-column tasks provided", () => {
|
||||
const { container } = renderView(createMockContext([]));
|
||||
const empty = container.querySelector(".dependency-graph-empty");
|
||||
expect(empty).toBeTruthy();
|
||||
expect(empty?.textContent).toContain("No tasks to display");
|
||||
});
|
||||
|
||||
it("renders nodes for active-column tasks", () => {
|
||||
const tasks = [
|
||||
createTask({ id: "FN-001", column: "todo" }),
|
||||
createTask({ id: "FN-002", column: "in-progress" }),
|
||||
];
|
||||
const { container } = renderView(createMockContext(tasks));
|
||||
const nodes = container.querySelectorAll(".dependency-graph-node");
|
||||
expect(nodes.length).toBe(2);
|
||||
});
|
||||
|
||||
it("filters out tasks not in active columns", () => {
|
||||
const tasks = [
|
||||
createTask({ id: "FN-001", column: "todo" }),
|
||||
createTask({ id: "FN-002", column: "done" }),
|
||||
createTask({ id: "FN-003", column: "archived" }),
|
||||
];
|
||||
const { container } = renderView(createMockContext(tasks));
|
||||
const nodes = container.querySelectorAll(".dependency-graph-node");
|
||||
expect(nodes.length).toBe(1);
|
||||
});
|
||||
|
||||
it("renders edges for tasks with dependencies in the active set", () => {
|
||||
const tasks = [
|
||||
createTask({ id: "FN-001", column: "todo" }),
|
||||
createTask({ id: "FN-002", column: "todo", dependencies: ["FN-001"] }),
|
||||
];
|
||||
const { container } = renderView(createMockContext(tasks));
|
||||
const edges = container.querySelectorAll(".dependency-graph-edge");
|
||||
expect(edges.length).toBe(1);
|
||||
});
|
||||
|
||||
it("does not render edges for dependencies not in the active set", () => {
|
||||
const tasks = [
|
||||
createTask({ id: "FN-002", column: "todo", dependencies: ["FN-999"] }),
|
||||
];
|
||||
const { container } = renderView(createMockContext(tasks));
|
||||
const edges = container.querySelectorAll(".dependency-graph-edge");
|
||||
expect(edges.length).toBe(0);
|
||||
});
|
||||
|
||||
it("zoom-in button increases scale", () => {
|
||||
const tasks = [createTask({ id: "FN-001", column: "todo" })];
|
||||
const { container, unmount } = renderView(createMockContext(tasks));
|
||||
const zoomInBtn = Array.from(container.querySelectorAll("button")).find((b) => b.textContent === "Zoom In")!;
|
||||
fireEvent.click(zoomInBtn);
|
||||
const scene = container.querySelector(".dependency-graph-scene") as HTMLElement;
|
||||
expect(scene).toBeTruthy();
|
||||
const transform = scene.style.transform;
|
||||
expect(transform).toMatch(/scale\([1-9]/);
|
||||
});
|
||||
|
||||
it("zoom-out button decreases scale", () => {
|
||||
const tasks = [createTask({ id: "FN-001", column: "todo" })];
|
||||
const { container, unmount } = renderView(createMockContext(tasks));
|
||||
const zoomOutBtn = Array.from(container.querySelectorAll("button")).find((b) => b.textContent === "Zoom Out")!;
|
||||
fireEvent.click(zoomOutBtn);
|
||||
const scene = container.querySelector(".dependency-graph-scene") as HTMLElement;
|
||||
expect(scene).toBeTruthy();
|
||||
const transform = scene.style.transform;
|
||||
expect(transform).toMatch(/scale\(0\./);
|
||||
});
|
||||
|
||||
it("fit-to-graph button produces a valid scale", () => {
|
||||
const tasks = [createTask({ id: "FN-001", column: "todo" })];
|
||||
const { container, unmount } = renderView(createMockContext(tasks));
|
||||
|
||||
// Mock canvas dimensions for fitToGraph
|
||||
const canvas = container.querySelector(".dependency-graph-canvas") as HTMLElement;
|
||||
if (canvas) {
|
||||
Object.defineProperty(canvas, "clientWidth", { value: 800, configurable: true });
|
||||
Object.defineProperty(canvas, "clientHeight", { value: 600, configurable: true });
|
||||
}
|
||||
|
||||
const fitBtn = Array.from(container.querySelectorAll("button")).find((b) => b.textContent === "Fit")!;
|
||||
fireEvent.click(fitBtn);
|
||||
|
||||
const scene = container.querySelector(".dependency-graph-scene") as HTMLElement;
|
||||
expect(scene).toBeTruthy();
|
||||
const transform = scene.style.transform;
|
||||
const scaleMatch = transform.match(/scale\(([\d.]+)\)/);
|
||||
expect(scaleMatch).toBeTruthy();
|
||||
const scaleValue = parseFloat(scaleMatch![1]);
|
||||
expect(scaleValue).toBeGreaterThanOrEqual(0.4);
|
||||
expect(scaleValue).toBeLessThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("wheel event zooms the graph", () => {
|
||||
const tasks = [createTask({ id: "FN-001", column: "todo" })];
|
||||
const { container } = renderView(createMockContext(tasks));
|
||||
const canvas = container.querySelector(".dependency-graph-canvas") as HTMLElement;
|
||||
|
||||
expect(canvas).toBeTruthy();
|
||||
|
||||
// Mock getBoundingClientRect on the canvas
|
||||
canvas.getBoundingClientRect = vi.fn().mockReturnValue({
|
||||
left: 0, top: 0, right: 800, bottom: 600, width: 800, height: 600, x: 0, y: 0,
|
||||
});
|
||||
|
||||
const initialScene = container.querySelector(".dependency-graph-scene") as HTMLElement;
|
||||
const initialTransform = initialScene.style.transform;
|
||||
|
||||
// Negative deltaY = zoom in
|
||||
fireEvent.wheel(canvas, { deltaY: -100, clientX: 400, clientY: 300 });
|
||||
|
||||
const updatedScene = container.querySelector(".dependency-graph-scene") as HTMLElement;
|
||||
const updatedTransform = updatedScene.style.transform;
|
||||
|
||||
expect(updatedTransform).not.toBe(initialTransform);
|
||||
const scaleMatch = updatedTransform.match(/scale\(([\d.]+)\)/);
|
||||
expect(scaleMatch).toBeTruthy();
|
||||
expect(parseFloat(scaleMatch![1])).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("pinch gesture changes scale", () => {
|
||||
const tasks = [createTask({ id: "FN-001", column: "todo" })];
|
||||
const { container } = renderView(createMockContext(tasks));
|
||||
const canvas = container.querySelector(".dependency-graph-canvas") as HTMLElement;
|
||||
|
||||
expect(canvas).toBeTruthy();
|
||||
|
||||
// First pointer down (finger 1)
|
||||
fireEvent.pointerDown(canvas, {
|
||||
pointerId: 1,
|
||||
pointerType: "touch",
|
||||
button: 0,
|
||||
clientX: 200,
|
||||
clientY: 300,
|
||||
});
|
||||
|
||||
// Second pointer down (finger 2) — starts pinch
|
||||
fireEvent.pointerDown(canvas, {
|
||||
pointerId: 2,
|
||||
pointerType: "touch",
|
||||
button: 0,
|
||||
clientX: 600,
|
||||
clientY: 300,
|
||||
});
|
||||
|
||||
// Move fingers apart (increasing distance)
|
||||
fireEvent.pointerMove(canvas, {
|
||||
pointerId: 1,
|
||||
pointerType: "touch",
|
||||
clientX: 100,
|
||||
clientY: 300,
|
||||
});
|
||||
|
||||
fireEvent.pointerMove(canvas, {
|
||||
pointerId: 2,
|
||||
pointerType: "touch",
|
||||
clientX: 700,
|
||||
clientY: 300,
|
||||
});
|
||||
|
||||
const scene = container.querySelector(".dependency-graph-scene") as HTMLElement;
|
||||
const transform = scene.style.transform;
|
||||
const scaleMatch = transform.match(/scale\(([\d.]+)\)/);
|
||||
expect(scaleMatch).toBeTruthy();
|
||||
expect(parseFloat(scaleMatch![1])).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("scale is clamped between MIN_SCALE and MAX_SCALE", () => {
|
||||
const tasks = [createTask({ id: "FN-001", column: "todo" })];
|
||||
const { container, unmount } = renderView(createMockContext(tasks));
|
||||
|
||||
// Try to zoom in beyond MAX_SCALE (2.0)
|
||||
const zoomInBtn = Array.from(container.querySelectorAll("button")).find((b) => b.textContent === "Zoom In")!;
|
||||
for (let i = 0; i < 50; i++) {
|
||||
fireEvent.click(zoomInBtn);
|
||||
}
|
||||
|
||||
const scene = container.querySelector(".dependency-graph-scene") as HTMLElement;
|
||||
let scaleMatch = scene.style.transform.match(/scale\(([\d.]+)\)/);
|
||||
expect(scaleMatch).toBeTruthy();
|
||||
expect(parseFloat(scaleMatch![1])).toBeLessThanOrEqual(2);
|
||||
|
||||
// Try to zoom out beyond MIN_SCALE (0.4)
|
||||
const zoomOutBtn = Array.from(container.querySelectorAll("button")).find((b) => b.textContent === "Zoom Out")!;
|
||||
for (let i = 0; i < 100; i++) {
|
||||
fireEvent.click(zoomOutBtn);
|
||||
}
|
||||
|
||||
const updatedScene = container.querySelector(".dependency-graph-scene") as HTMLElement;
|
||||
scaleMatch = updatedScene.style.transform.match(/scale\(([\d.]+)\)/);
|
||||
expect(scaleMatch).toBeTruthy();
|
||||
expect(parseFloat(scaleMatch![1])).toBeGreaterThanOrEqual(0.4);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { GraphEdges } from "../edges";
|
||||
import type { GraphEdge } from "../types";
|
||||
|
||||
function renderEdges(edges: GraphEdge[], highlightedEdgeIds?: Set<string>) {
|
||||
const positions = new Map([
|
||||
["A", { x: 0, y: 0 }],
|
||||
["B", { x: 320, y: 180 }],
|
||||
["C", { x: 640, y: 180 }],
|
||||
]);
|
||||
|
||||
return render(
|
||||
<GraphEdges
|
||||
edges={edges}
|
||||
positions={positions}
|
||||
highlightedEdgeIds={highlightedEdgeIds}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("GraphEdges", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
it("renders single edge", () => {
|
||||
renderEdges([{ source: "A", target: "B" }]);
|
||||
const edge = screen.getAllByTestId("dependency-edge")[0];
|
||||
expect(edge.getAttribute("opacity")).toBe("1");
|
||||
expect(edge.getAttribute("stroke")).toBe("var(--border)");
|
||||
});
|
||||
|
||||
it("renders multiple edges", () => {
|
||||
renderEdges([
|
||||
{ source: "A", target: "B" },
|
||||
{ source: "A", target: "C" },
|
||||
]);
|
||||
expect(screen.getAllByTestId("dependency-edge")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("supports edges with same source", () => {
|
||||
renderEdges([
|
||||
{ source: "A", target: "B" },
|
||||
{ source: "A", target: "C" },
|
||||
]);
|
||||
expect(screen.getAllByTestId("dependency-edge")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("supports edges with same target", () => {
|
||||
renderEdges([
|
||||
{ source: "B", target: "A" },
|
||||
{ source: "C", target: "A" },
|
||||
]);
|
||||
expect(screen.getAllByTestId("dependency-edge")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("dims non-highlighted edges when highlight set provided", () => {
|
||||
renderEdges(
|
||||
[
|
||||
{ source: "A", target: "B" },
|
||||
{ source: "A", target: "C" },
|
||||
],
|
||||
new Set(["A->B"]),
|
||||
);
|
||||
|
||||
const all = screen.getAllByTestId("dependency-edge");
|
||||
const highlighted = all.find((edge) => edge.getAttribute("data-edge-id") === "A->B");
|
||||
const dimmed = all.find((edge) => edge.getAttribute("data-edge-id") === "A->C");
|
||||
|
||||
expect(highlighted?.getAttribute("opacity")).toBe("1");
|
||||
expect(dimmed?.getAttribute("opacity")).toBe("0.2");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Task } from "@fusion/core";
|
||||
import { filterGraphTasks } from "../filters";
|
||||
|
||||
function createTask(id: string, column: Task["column"], dependencies: string[] = []): Task {
|
||||
return {
|
||||
id,
|
||||
description: `Task ${id}`,
|
||||
column,
|
||||
dependencies,
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
} as Task;
|
||||
}
|
||||
|
||||
describe("filterGraphTasks", () => {
|
||||
it("returns empty for empty input", () => {
|
||||
expect(filterGraphTasks([])).toEqual([]);
|
||||
});
|
||||
|
||||
it("includes triage/todo/in-progress/in-review and excludes done/archived", () => {
|
||||
const tasks = [
|
||||
createTask("FN-1", "triage"),
|
||||
createTask("FN-2", "todo"),
|
||||
createTask("FN-3", "in-progress"),
|
||||
createTask("FN-4", "in-review"),
|
||||
createTask("FN-5", "done"),
|
||||
createTask("FN-6", "archived"),
|
||||
];
|
||||
|
||||
expect(filterGraphTasks(tasks).map((task) => task.id)).toEqual(["FN-1", "FN-2", "FN-3", "FN-4"]);
|
||||
});
|
||||
|
||||
it("returns empty when only excluded columns are present", () => {
|
||||
const tasks = [createTask("FN-1", "done"), createTask("FN-2", "archived")];
|
||||
|
||||
expect(filterGraphTasks(tasks)).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps included tasks even when dependencies reference excluded tasks", () => {
|
||||
const tasks = [
|
||||
createTask("FN-1", "done"),
|
||||
createTask("FN-2", "todo", ["FN-1"]),
|
||||
createTask("FN-3", "in-review", ["FN-2", "FN-1"]),
|
||||
createTask("FN-4", "archived", ["FN-2"]),
|
||||
];
|
||||
|
||||
expect(filterGraphTasks(tasks).map((task) => task.id)).toEqual(["FN-2", "FN-3"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
import { describe, expect, it, beforeEach } from "vitest";
|
||||
import { projectScopedKey, loadPositions, savePositions } from "../storage";
|
||||
|
||||
const createMemoryStorage = () => {
|
||||
const map = new Map<string, string>();
|
||||
return {
|
||||
getItem: (key: string) => map.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
map.set(key, value);
|
||||
},
|
||||
clear: () => map.clear(),
|
||||
};
|
||||
};
|
||||
|
||||
describe("storage", () => {
|
||||
const localStorage = createMemoryStorage();
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as { window?: { localStorage?: typeof localStorage } }).window = { localStorage };
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("builds project-scoped key with canonical base key", () => {
|
||||
expect(projectScopedKey("proj_123")).toBe("kb:proj_123:fusion-plugin-dependency-graph:positions");
|
||||
});
|
||||
|
||||
it("falls back to unscoped key when projectId is missing or empty", () => {
|
||||
expect(projectScopedKey()).toBe("fusion-plugin-dependency-graph:positions");
|
||||
expect(projectScopedKey("")).toBe("fusion-plugin-dependency-graph:positions");
|
||||
});
|
||||
|
||||
it("persists and restores positions", () => {
|
||||
savePositions("proj_123", { "FN-1": { x: 10, y: 20 } });
|
||||
expect(loadPositions("proj_123")).toEqual({ "FN-1": { x: 10, y: 20 } });
|
||||
});
|
||||
|
||||
it("returns empty object for invalid JSON", () => {
|
||||
localStorage.setItem("kb:proj_123:fusion-plugin-dependency-graph:positions", "not-json");
|
||||
expect(loadPositions("proj_123")).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import type { Task } from "@fusion/core";
|
||||
import { useGraphData } from "../useGraphData";
|
||||
|
||||
function createTask(id: string, dependencies: string[] = []): Task {
|
||||
return {
|
||||
id,
|
||||
description: id,
|
||||
column: "todo",
|
||||
dependencies,
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
} as Task;
|
||||
}
|
||||
|
||||
describe("useGraphData", () => {
|
||||
it("returns empty graph for empty tasks", () => {
|
||||
const { result } = renderHook(() => useGraphData([]));
|
||||
expect(result.current).toEqual({ nodes: [], edges: [] });
|
||||
});
|
||||
|
||||
it("creates node for single task with no deps", () => {
|
||||
const { result } = renderHook(() => useGraphData([createTask("A")]));
|
||||
expect(result.current.nodes.map((node) => node.task.id)).toEqual(["A"]);
|
||||
expect(result.current.edges).toEqual([]);
|
||||
});
|
||||
|
||||
it("creates edges in dependent-to-dependency direction for chain", () => {
|
||||
const tasks = [createTask("A", ["B"]), createTask("B", ["C"]), createTask("C")];
|
||||
const { result } = renderHook(() => useGraphData(tasks));
|
||||
expect(result.current.edges).toEqual([
|
||||
{ source: "A", target: "B" },
|
||||
{ source: "B", target: "C" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("creates diamond dependency edges", () => {
|
||||
const tasks = [
|
||||
createTask("A", ["B", "C"]),
|
||||
createTask("B", ["D"]),
|
||||
createTask("C", ["D"]),
|
||||
createTask("D"),
|
||||
];
|
||||
const { result } = renderHook(() => useGraphData(tasks));
|
||||
expect(result.current.edges).toEqual([
|
||||
{ source: "A", target: "B" },
|
||||
{ source: "A", target: "C" },
|
||||
{ source: "B", target: "D" },
|
||||
{ source: "C", target: "D" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops orphan dependency references", () => {
|
||||
const { result } = renderHook(() => useGraphData([createTask("A", ["Z"]), createTask("B", ["A"])]));
|
||||
expect(result.current.edges).toEqual([{ source: "B", target: "A" }]);
|
||||
});
|
||||
|
||||
it("supports disconnected subgraphs", () => {
|
||||
const tasks = [createTask("A", ["B"]), createTask("B"), createTask("X", ["Y"]), createTask("Y")];
|
||||
const { result } = renderHook(() => useGraphData(tasks));
|
||||
expect(result.current.edges).toEqual([
|
||||
{ source: "A", target: "B" },
|
||||
{ source: "X", target: "Y" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { useGraphInteraction } from "../useGraphInteraction";
|
||||
|
||||
describe("useGraphInteraction", () => {
|
||||
it("starts with default pan/zoom", () => {
|
||||
const { result } = renderHook(() => useGraphInteraction());
|
||||
expect(result.current.zoom).toBe(1);
|
||||
expect(result.current.pan).toEqual({ x: 0, y: 0 });
|
||||
});
|
||||
|
||||
it("clamps zoom between 0.1 and 3", () => {
|
||||
const { result } = renderHook(() => useGraphInteraction());
|
||||
|
||||
act(() => {
|
||||
for (let i = 0; i < 100; i += 1) result.current.zoomOut();
|
||||
});
|
||||
expect(result.current.zoom).toBe(0.1);
|
||||
|
||||
act(() => {
|
||||
for (let i = 0; i < 100; i += 1) result.current.zoomIn();
|
||||
});
|
||||
expect(result.current.zoom).toBe(3);
|
||||
});
|
||||
|
||||
it("fits single node", () => {
|
||||
const { result } = renderHook(() => useGraphInteraction());
|
||||
act(() => {
|
||||
result.current.fitToGraph(new Map([["A", { x: 0, y: 0 }]]), 800, 600);
|
||||
});
|
||||
|
||||
expect(result.current.zoom).toBeGreaterThan(0.1);
|
||||
expect(result.current.zoom).toBeLessThanOrEqual(3);
|
||||
});
|
||||
|
||||
it("fits wide graph", () => {
|
||||
const { result } = renderHook(() => useGraphInteraction());
|
||||
act(() => {
|
||||
result.current.fitToGraph(new Map([
|
||||
["A", { x: 0, y: 0 }],
|
||||
["B", { x: 2000, y: 0 }],
|
||||
]), 800, 600);
|
||||
});
|
||||
|
||||
expect(result.current.zoom).toBeLessThan(1);
|
||||
});
|
||||
|
||||
it("fits tall graph", () => {
|
||||
const { result } = renderHook(() => useGraphInteraction());
|
||||
act(() => {
|
||||
result.current.fitToGraph(new Map([
|
||||
["A", { x: 0, y: 0 }],
|
||||
["B", { x: 0, y: 2000 }],
|
||||
]), 800, 600);
|
||||
});
|
||||
|
||||
expect(result.current.zoom).toBeLessThan(1);
|
||||
});
|
||||
|
||||
it("resets when positions are empty", () => {
|
||||
const { result } = renderHook(() => useGraphInteraction());
|
||||
|
||||
act(() => {
|
||||
result.current.zoomIn();
|
||||
result.current.onPointerDown(1, { x: 10, y: 10 });
|
||||
result.current.onPointerMove(1, { x: 110, y: 60 }, 800, 600);
|
||||
result.current.onPointerUp(1);
|
||||
result.current.fitToGraph(new Map(), 800, 600);
|
||||
});
|
||||
|
||||
expect(result.current.zoom).toBe(1);
|
||||
expect(result.current.pan).toEqual({ x: 0, y: 0 });
|
||||
});
|
||||
|
||||
it("resetView restores defaults", () => {
|
||||
const { result } = renderHook(() => useGraphInteraction());
|
||||
|
||||
act(() => {
|
||||
result.current.zoomIn();
|
||||
result.current.onPointerDown(1, { x: 0, y: 0 });
|
||||
result.current.onPointerMove(1, { x: 200, y: 200 }, 800, 600);
|
||||
result.current.onPointerUp(1);
|
||||
result.current.resetView();
|
||||
});
|
||||
|
||||
expect(result.current.zoom).toBe(1);
|
||||
expect(result.current.pan).toEqual({ x: 0, y: 0 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user