feat(FN-3090): add graph position persistence to dependency graph plugin

Merges graph position persistence into the dependency graph plugin (FN-3090) via a scoped storage layer, a dedicated position storage utility, and a React hook that preserves pan/zoom state across sessions. Also includes a responsive CSS fix for ScriptsModal and supporting test coverage in both the

Fusion-Task-Id: FN-3090
This commit is contained in:
Fusion
2026-05-07 05:56:44 -07:00
committed by gsxdsm
parent 957b8b4efe
commit 70007a1d24
11 changed files with 535 additions and 23 deletions

View File

@@ -0,0 +1,115 @@
import { afterEach, beforeEach, describe, expect, it, vi } 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: (task: Task) => void }) => (
<button data-testid={`task-${task.id}`} onClick={() => onOpenDetail(task)}>{task.id}</button>
),
}));
vi.mock("../useGraphInteraction", () => ({
useGraphInteraction: () => ({
transform: "translate(0px, 0px) scale(1)",
zoom: 1,
transitioning: false,
zoomIn: vi.fn(),
zoomOut: vi.fn(),
resetView: vi.fn(),
fitToGraph,
onPointerDown: vi.fn(),
onPointerMove: vi.fn(),
onPointerUp: vi.fn(),
onWheelZoom: vi.fn(),
handleKeyDown: vi.fn(),
}),
}));
vi.mock("../layout", () => ({
computeAutoLayout: ({ nodes }: { nodes: Array<{ task: { id: string } }> }) => {
const map = new Map<string, { x: number; y: number }>();
for (const node of nodes) {
if (node.task.id === "A") map.set("A", { x: 0, y: 0 });
if (node.task.id === "B") map.set("B", { x: 200, y: 0 });
}
return map;
},
}));
function createStorage() {
const store = new Map<string, string>();
return {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => {
store.set(key, value);
},
removeItem: (key: string) => {
store.delete(key);
},
};
}
function createTask(id: string, column: Task["column"] = "todo"): Task {
return { id, description: id, column, dependencies: [], steps: [], currentStep: 0, log: [] } as Task;
}
describe("DependencyGraph persistence", () => {
afterEach(() => {
cleanup();
});
beforeEach(() => {
Object.defineProperty(window, "localStorage", { value: createStorage(), configurable: true });
fitToGraph.mockReset();
});
it("persists dragged node position across remount", () => {
const { unmount } = render(<DependencyGraph tasks={[createTask("A")]} projectId="p1" />);
const node = screen.getByTestId("graph-task-node-A");
fireEvent.pointerDown(node, { pointerId: 1, isPrimary: true, clientX: 10, clientY: 10 });
fireEvent.pointerMove(node, { pointerId: 1, isPrimary: true, clientX: 30, clientY: 40 });
fireEvent.pointerUp(node, { pointerId: 1, isPrimary: true, clientX: 30, clientY: 40 });
expect(window.localStorage.getItem("kb:p1:dependency-graph-positions")).toContain('"A":{"x":20,"y":30}');
unmount();
render(<DependencyGraph tasks={[createTask("A")]} projectId="p1" />);
expect(screen.getByTestId("graph-task-node-A").getAttribute("style")).toContain("left: 20px");
expect(screen.getByTestId("graph-task-node-A").getAttribute("style")).toContain("top: 30px");
});
it("merges saved positions with auto-layout for new tasks", () => {
window.localStorage.setItem("kb:p1:dependency-graph-positions", JSON.stringify({ A: { x: 25, y: 35 } }));
render(<DependencyGraph tasks={[createTask("A"), createTask("B")]} projectId="p1" />);
expect(screen.getByTestId("graph-task-node-A").getAttribute("style")).toContain("left: 25px");
expect(screen.getByTestId("graph-task-node-B").getAttribute("style")).toContain("left: 200px");
});
it("fit to graph clears saved positions and reapplies auto-layout", () => {
window.localStorage.setItem("kb:p1:dependency-graph-positions", JSON.stringify({ A: { x: 25, y: 35 } }));
render(<DependencyGraph tasks={[createTask("A")]} projectId="p1" />);
fireEvent.click(screen.getByRole("button", { name: "Fit to graph" }));
expect(window.localStorage.getItem("kb:p1:dependency-graph-positions")).toBeNull();
expect(screen.getByTestId("graph-task-node-A").getAttribute("style")).toContain("left: 0px");
});
it("switching projects loads project-scoped positions", () => {
window.localStorage.setItem("kb:p1:dependency-graph-positions", JSON.stringify({ A: { x: 11, y: 22 } }));
window.localStorage.setItem("kb:p2:dependency-graph-positions", JSON.stringify({ A: { x: 33, y: 44 } }));
const { rerender } = render(<DependencyGraph tasks={[createTask("A")]} projectId="p1" />);
expect(screen.getByTestId("graph-task-node-A").getAttribute("style")).toContain("left: 11px");
rerender(<DependencyGraph tasks={[createTask("A")]} projectId="p2" />);
expect(screen.getByTestId("graph-task-node-A").getAttribute("style")).toContain("left: 33px");
});
});