Files
fusion/plugins/fusion-plugin-dependency-graph/src/__tests__/DependencyGraph.test.tsx

377 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { readFileSync } from "node:fs";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import type { Task } from "@fusion/core";
import type { NodePositions } from "../utils/graphPositionStorage";
import { DependencyGraph } from "../DependencyGraph";
const fitToGraph = vi.fn();
const zoomIn = vi.fn();
const zoomOut = vi.fn();
const resetView = vi.fn();
const handleKeyDown = vi.fn();
const onPointerDown = vi.fn();
const onPointerMove = vi.fn();
const onPointerUp = vi.fn();
const setGraphBounds = vi.fn();
const clearSavedPositions = vi.fn();
let mockSavedPositions: NodePositions | null = null;
let resizeObserverCallbacks: ResizeObserverCallback[] = [];
let cssStyleElement: HTMLStyleElement | null = null;
const dependencyGraphCss = readFileSync("src/DependencyGraph.css", "utf8");
vi.mock("@fusion/dashboard/app/components/TaskCard", () => ({
TaskCard: ({ task, onOpenDetail, disableDrag }: { task: Task; onOpenDetail: (task: Task) => void; disableDrag?: boolean }) => (
<button data-testid={`task-${task.id}`} draggable={!disableDrag} onClick={() => onOpenDetail(task)}>{task.id}</button>
),
}));
vi.mock("../useGraphInteraction", () => ({
useGraphInteraction: () => ({
transform: "translate(0px, 0px) scale(1)",
zoom: 1,
transitioning: false,
zoomIn,
zoomOut,
resetView,
fitToGraph,
onPointerDown,
onPointerMove,
onPointerUp,
onWheelZoom: vi.fn(),
handleKeyDown,
setGraphBounds,
}),
}));
vi.mock("../hooks/useGraphPositions", () => ({
useGraphPositions: () => ({
savedPositions: mockSavedPositions,
persistPositions: vi.fn(),
clearSavedPositions,
}),
}));
function createTask(id: string, column: Task["column"], dependencies: string[] = []): Task {
return { id, description: id, column, dependencies, steps: [], currentStep: 0, log: [] } as Task;
}
function setViewportSize(width: number, height: number): void {
const viewport = document.querySelector(".dependency-graph__viewport") as HTMLDivElement | null;
if (!viewport) throw new Error("missing viewport");
Object.defineProperty(viewport, "clientWidth", { value: width, configurable: true });
Object.defineProperty(viewport, "clientHeight", { value: height, configurable: true });
resizeObserverCallbacks.forEach((callback) => {
callback([{ contentRect: { width, height } } as ResizeObserverEntry], {} as ResizeObserver);
});
}
function readNodePosition(taskId: string): { left: number; top: number } {
const node = screen.getByTestId(`graph-task-node-${taskId}`) as HTMLElement;
return {
left: Number.parseFloat(node.style.left),
top: Number.parseFloat(node.style.top),
};
}
function renderInProjectContent(tasks: Task[], width = "100%"): HTMLElement {
const { container } = render(
<div className="project-content" style={{ display: "flex", width }}>
<DependencyGraph tasks={tasks} onOpenTaskDetail={vi.fn()} />
</div>,
);
const graph = container.querySelector(".dependency-graph") as HTMLElement | null;
if (!graph) throw new Error("missing dependency graph");
return graph;
}
function expectGraphToFillFlexParent(graph: HTMLElement): void {
const style = getComputedStyle(graph);
expect(style.flexGrow).toBe("1");
expect(style.flexShrink).toBe("1");
expect(style.flexBasis).toBe("auto");
expect(style.minWidth).toBe("0px");
expect(style.boxSizing).toBe("border-box");
}
describe("DependencyGraph", () => {
beforeEach(() => {
fitToGraph.mockReset();
zoomIn.mockReset();
zoomOut.mockReset();
resetView.mockReset();
handleKeyDown.mockReset();
onPointerDown.mockReset();
onPointerMove.mockReset();
onPointerUp.mockReset();
setGraphBounds.mockReset();
clearSavedPositions.mockReset();
mockSavedPositions = null;
resizeObserverCallbacks = [];
cssStyleElement = document.createElement("style");
cssStyleElement.textContent = dependencyGraphCss;
document.head.appendChild(cssStyleElement);
vi.stubGlobal(
"ResizeObserver",
class {
constructor(callback: ResizeObserverCallback) {
resizeObserverCallbacks.push(callback);
}
observe() {}
disconnect() {}
},
);
});
afterEach(() => {
vi.unstubAllGlobals();
cssStyleElement?.remove();
cssStyleElement = null;
cleanup();
});
it("renders empty state for empty list", () => {
render(<DependencyGraph tasks={[]} onOpenTaskDetail={vi.fn()} />);
expect(screen.getByText(/No active tasks/i)).toBeTruthy();
});
it.each([
{ label: "empty desktop-width surface", tasks: [], width: "100%", expectedNodeIds: [] },
{ label: "empty mobile-width surface", tasks: [], width: "375px", expectedNodeIds: [] },
{
label: "populated desktop-width surface",
tasks: [createTask("A", "todo"), createTask("B", "todo", ["A"])],
width: "100%",
expectedNodeIds: ["A", "B"],
},
{
label: "populated mobile-width surface",
tasks: [createTask("A", "todo"), createTask("B", "todo", ["A"])],
width: "375px",
expectedNodeIds: ["A", "B"],
},
])("fills its flex parent in the $label", ({ tasks, width, expectedNodeIds }) => {
const graph = renderInProjectContent(tasks, width);
if (expectedNodeIds.length === 0) {
expect(screen.getByText(/No active tasks/i)).toBeTruthy();
} else {
for (const nodeId of expectedNodeIds) {
expect(screen.getByTestId(`graph-task-node-${nodeId}`)).toBeTruthy();
}
}
expectGraphToFillFlexParent(graph);
});
it("renders only triage/todo/in-progress/in-review nodes from mixed columns", () => {
render(
<DependencyGraph
tasks={[
createTask("A", "triage"),
createTask("B", "todo"),
createTask("C", "in-progress"),
createTask("D", "in-review"),
createTask("E", "done"),
createTask("F", "archived"),
]}
onOpenTaskDetail={vi.fn()}
/>,
);
expect(screen.getByTestId("graph-task-node-A")).toBeTruthy();
expect(screen.getByTestId("graph-task-node-B")).toBeTruthy();
expect(screen.getByTestId("graph-task-node-C")).toBeTruthy();
expect(screen.getByTestId("graph-task-node-D")).toBeTruthy();
expect(screen.queryByTestId("graph-task-node-E")).toBeNull();
expect(screen.queryByTestId("graph-task-node-F")).toBeNull();
});
it("auto-fits on initial load with active tasks", async () => {
render(<DependencyGraph tasks={[createTask("A", "todo")]} onOpenTaskDetail={vi.fn()} />);
// FNXC:Graph 2026-06-23-00:10: auto-fit only fires once the viewport is measured
// (width/height > 0); a 0-sized fit would mis-center, so the guard waits for layout.
setViewportSize(1200, 800);
await waitFor(() => expect(fitToGraph).toHaveBeenCalled());
expect(setGraphBounds).toHaveBeenCalled();
});
// FNXC:Graph 2026-06-23-02:45: the component can mount at 0×0 (hidden behind a tab / before the
// container is laid out) and only later report a real size via ResizeObserver. The first real paint
// must center WITHOUT any user click/drag. This guards the zero-size-at-mount → RO-driven fit path.
it("does not fit while the viewport is zero-sized, then fits once ResizeObserver reports a real size", async () => {
render(<DependencyGraph tasks={[createTask("A", "todo"), createTask("B", "todo", ["A"])]} onOpenTaskDetail={vi.fn()} />);
// Mount-time measured size is 0×0 (jsdom default). A 0-sized fit would mis-center, so none fires.
setViewportSize(0, 0);
await Promise.resolve();
expect(fitToGraph).not.toHaveBeenCalled();
// Hidden→visible transition: ResizeObserver now reports a real non-zero size → fit must run.
setViewportSize(1200, 800);
await waitFor(() => expect(fitToGraph).toHaveBeenCalled());
// FNXC:Graph 2026-06-23-02:45: fit must run on the normalized (origin-shifted) positions and the
// real measured size so clampPan clamps against the committed normalized bounds — never the 0-box.
const [fittedPositions, fittedWidth, fittedHeight] = fitToGraph.mock.calls.at(-1)!;
expect(fittedWidth).toBe(1200);
expect(fittedHeight).toBe(800);
const minLeft = Math.min(...Array.from((fittedPositions as Map<string, { x: number; y: number }>).values()).map((p) => p.x));
const minTop = Math.min(...Array.from((fittedPositions as Map<string, { x: number; y: number }>).values()).map((p) => p.y));
expect(minLeft).toBe(0);
expect(minTop).toBe(0);
expect(setGraphBounds).toHaveBeenCalled();
});
it("auto-fits saved/manual positions into the viewport after layout settles", async () => {
mockSavedPositions = { A: { x: 10, y: 10 }, B: { x: 300, y: 10 } };
render(<DependencyGraph tasks={[createTask("A", "todo"), createTask("B", "todo", ["A"])]} projectId="p1" onOpenTaskDetail={vi.fn()} />);
setViewportSize(1200, 800);
await waitFor(() => expect(fitToGraph).toHaveBeenCalled());
const [fittedPositions, fittedWidth, fittedHeight] = fitToGraph.mock.calls.at(-1)!;
expect(fittedWidth).toBe(1200);
expect(fittedHeight).toBe(800);
expect((fittedPositions as Map<string, { x: number; y: number }>).get("A")).toEqual({ x: 0, y: 0 });
expect((fittedPositions as Map<string, { x: number; y: number }>).get("B")).toEqual({ x: 290, y: 0 });
});
it("forwards keyboard events to interaction hook", () => {
render(<DependencyGraph tasks={[createTask("A", "todo")]} onOpenTaskDetail={vi.fn()} />);
const viewport = document.querySelector(".dependency-graph__viewport");
if (!viewport) throw new Error("missing viewport");
fireEvent.keyDown(viewport, { key: "=", ctrlKey: true });
expect(handleKeyDown).toHaveBeenCalled();
});
it("sets viewport tabIndex for keyboard focus", () => {
render(<DependencyGraph tasks={[createTask("A", "todo")]} onOpenTaskDetail={vi.fn()} />);
const viewport = document.querySelector(".dependency-graph__viewport");
expect(viewport?.getAttribute("tabindex")).toBe("0");
});
it("renders toolbar controls", () => {
render(<DependencyGraph tasks={[createTask("A", "todo")]} onOpenTaskDetail={vi.fn()} />);
expect(screen.getByRole("button", { name: "Zoom in" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Zoom out" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Fit to graph" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Reset view" })).toBeTruthy();
expect(screen.getByText("100%")).toBeTruthy();
});
it("fit-to-graph button triggers fitToGraph", () => {
render(<DependencyGraph tasks={[createTask("A", "todo")]} onOpenTaskDetail={vi.fn()} />);
fireEvent.click(screen.getByRole("button", { name: "Fit to graph" }));
expect(fitToGraph).toHaveBeenCalled();
});
it("single-clicking a node selects it without opening detail", () => {
const onOpenDetail = vi.fn();
render(<DependencyGraph tasks={[createTask("A", "in-progress")]} onOpenDetail={onOpenDetail} />);
const node = screen.getByTestId("graph-task-node-A");
fireEvent.click(node);
expect(node.className).toContain("graph-node--draggable");
expect(onOpenDetail).not.toHaveBeenCalled();
});
it("double-clicking a node opens detail", () => {
const onOpenDetail = vi.fn();
render(<DependencyGraph tasks={[createTask("A", "in-progress")]} onOpenDetail={onOpenDetail} />);
fireEvent.doubleClick(screen.getByTestId("graph-task-node-A"));
expect(onOpenDetail).toHaveBeenCalledTimes(1);
expect(onOpenDetail).toHaveBeenCalledWith(expect.objectContaining({ id: "A" }));
});
it("double-click falls back to onOpenTaskDetail when onOpenDetail is not provided", () => {
const onOpenTaskDetail = vi.fn();
render(<DependencyGraph tasks={[createTask("A", "in-progress")]} onOpenTaskDetail={onOpenTaskDetail} />);
fireEvent.doubleClick(screen.getByTestId("graph-task-node-A"));
expect(onOpenTaskDetail).toHaveBeenCalledWith("A");
});
it("requires selection before node drag class is enabled", () => {
render(<DependencyGraph tasks={[createTask("A", "todo")]} onOpenTaskDetail={vi.fn()} />);
const node = screen.getByTestId("graph-task-node-A");
expect(node.className).not.toContain("graph-node--draggable");
fireEvent.click(node);
expect(node.className).toContain("graph-node--draggable");
});
it("allows pane panning from an unselected node surface", () => {
render(<DependencyGraph tasks={[createTask("A", "todo")]} onOpenTaskDetail={vi.fn()} />);
const node = screen.getByTestId("graph-task-node-A");
fireEvent.pointerDown(node, { pointerId: 1, clientX: 10, clientY: 10, isPrimary: true });
fireEvent.pointerMove(node, { pointerId: 1, clientX: 30, clientY: 20, isPrimary: true });
fireEvent.pointerUp(node, { pointerId: 1, clientX: 30, clientY: 20, isPrimary: true });
expect(onPointerDown).toHaveBeenCalled();
expect(onPointerMove).toHaveBeenCalled();
expect(onPointerUp).toHaveBeenCalled();
});
it("uses vertical-depth auto-layout in wide viewports and flips to horizontal-depth in tall viewports", async () => {
render(
<DependencyGraph
tasks={[createTask("A", "todo", ["B"]), createTask("B", "todo", ["C"]), createTask("C", "todo")]}
onOpenTaskDetail={vi.fn()}
/>,
);
setViewportSize(1400, 900);
await waitFor(() => {
const wideA = readNodePosition("A");
const wideB = readNodePosition("B");
const wideC = readNodePosition("C");
expect(wideC.top).toBeLessThan(wideB.top);
expect(wideB.top).toBeLessThan(wideA.top);
expect(wideA.left).toBe(wideB.left);
expect(wideB.left).toBe(wideC.left);
});
setViewportSize(400, 900);
await waitFor(() => {
const tallA = readNodePosition("A");
const tallB = readNodePosition("B");
const tallC = readNodePosition("C");
expect(tallC.left).toBeLessThan(tallB.left);
expect(tallB.left).toBeLessThan(tallA.left);
expect(tallA.top).toBe(tallB.top);
expect(tallB.top).toBe(tallC.top);
});
});
it("preserves saved node positions across viewport orientation changes", async () => {
mockSavedPositions = {
A: { x: 500, y: 200 },
B: { x: 100, y: 100 },
};
render(<DependencyGraph tasks={[createTask("A", "todo", ["B"]), createTask("B", "todo")]} projectId="project-1" onOpenTaskDetail={vi.fn()} />);
setViewportSize(400, 900);
await waitFor(() => {
expect(readNodePosition("B")).toEqual({ left: 0, top: 0 });
expect(readNodePosition("A")).toEqual({ left: 400, top: 100 });
});
setViewportSize(1400, 900);
await waitFor(() => {
expect(readNodePosition("B")).toEqual({ left: 0, top: 0 });
expect(readNodePosition("A")).toEqual({ left: 400, top: 100 });
});
});
});