feat(FN-3091): add plugin view remount routing test coverage

Adds test coverage for plugin view remount routing behavior in the App component.

Fusion-Task-Id: FN-3091
This commit is contained in:
Fusion
2026-05-07 07:08:11 -07:00
committed by gsxdsm
parent 5ffd1485c3
commit 4338b590c6
10 changed files with 480 additions and 0 deletions

View File

@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import { render, renderHook } from "@testing-library/react";
import type { Task } from "@fusion/core";
import { filterGraphTasks } from "../filters";
import { useGraphData } from "../useGraphData";
import { DependencyGraph } from "../DependencyGraph";
function createTask(id: string, column: Task["column"], dependencies: string[] = [], status?: Task["status"]): Task {
return {
id,
description: id,
column,
status,
dependencies,
steps: [],
currentStep: 0,
log: [],
} as Task;
}
describe("dependency graph filtering", () => {
it("includes triage/todo/in-progress/in-review and excludes done/archived by column", () => {
const tasks = [
createTask("T", "triage", [], "done"),
createTask("TD", "todo", [], "done"),
createTask("P", "in-progress", [], "done"),
createTask("R", "in-review", [], "done"),
createTask("D", "done", [], "in-progress"),
createTask("A", "archived", [], "in-progress"),
];
expect(filterGraphTasks(tasks).map((task) => task.id)).toEqual(["T", "TD", "P", "R"]);
});
it("keeps standalone tasks without dependencies as nodes", () => {
const tasks = [createTask("A", "todo")];
const { result } = renderHook(() => useGraphData(tasks));
expect(result.current.nodes.map((node) => node.task.id)).toEqual(["A"]);
expect(result.current.edges).toEqual([]);
});
it("keeps only edges to included dependency tasks for mixed-status dependencies", () => {
const filteredTasks = filterGraphTasks([
createTask("A", "in-progress", ["B", "DONE", "ARCH"]),
createTask("B", "todo"),
createTask("DONE", "done"),
createTask("ARCH", "archived"),
]);
const { result } = renderHook(() => useGraphData(filteredTasks));
expect(result.current.edges).toEqual([{ source: "A", target: "B" }]);
});
it("renders empty state when all tasks are done/archived", () => {
const { container } = render(
<DependencyGraph tasks={[createTask("D", "done"), createTask("A", "archived")]} onOpenTaskDetail={() => {}} />,
);
expect(container.textContent).toContain("No active tasks");
});
});

View File

@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { definePlugin } from "@fusion/plugin-sdk";
import { validatePluginManifest } from "@fusion/core";
import plugin from "../index";
import { getPluginViewId } from "../../../../packages/dashboard/app/plugins/pluginViewRegistry";
describe("dependency graph plugin host integration contract", () => {
it("declares dashboard view manifest shape", () => {
expect(plugin.manifest.id).toBe("fusion-plugin-dependency-graph");
expect(plugin.dashboardViews).toEqual([
expect.objectContaining({
viewId: "graph",
label: "Graph",
componentPath: "./src/DependencyGraph.tsx",
placement: "more",
}),
]);
});
it("is valid for definePlugin + manifest validation", () => {
const defined = definePlugin(plugin);
const validation = validatePluginManifest(defined.manifest);
expect(validation.valid).toBe(true);
expect(validation.errors).toEqual([]);
});
it("produces loader-compatible pluginId/view entries", () => {
const entries = (plugin.dashboardViews ?? []).map((view) => ({ pluginId: plugin.manifest.id, view }));
expect(entries).toHaveLength(1);
expect(entries[0]).toEqual(
expect.objectContaining({
pluginId: "fusion-plugin-dependency-graph",
view: expect.objectContaining({ viewId: "graph" }),
}),
);
});
it("matches host registry lookup key format plugin:{pluginId}:{viewId}", () => {
const view = plugin.dashboardViews?.[0];
if (!view) throw new Error("missing dashboard view");
expect(getPluginViewId(plugin.manifest.id, view.viewId)).toBe("plugin:fusion-plugin-dependency-graph:graph");
});
});

View File

@@ -0,0 +1,120 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, renderHook, screen } from "@testing-library/react";
import type { Task } from "@fusion/core";
import { DependencyGraph } from "../DependencyGraph";
import { GraphTaskNode } from "../GraphTaskNode";
import { useGraphInteraction } from "../useGraphInteraction";
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} {task.column === "in-progress" ? "Executing" : "Idle"}
</button>
),
}));
function createTask(id: string, column: Task["column"] = "todo", dependencies: string[] = []): Task {
return {
id,
description: id,
column,
dependencies,
steps: [{ name: "one", status: "in-progress" }],
currentStep: 0,
status: column === "in-progress" ? "executing" : "queued",
log: [],
} as Task;
}
afterEach(() => {
cleanup();
});
describe("dependency graph interactions", () => {
it("supports pan and zoom via interaction hook", () => {
const { result } = renderHook(() => useGraphInteraction());
act(() => {
result.current.onPointerDown(1, { x: 10, y: 10 });
result.current.onPointerMove(1, { x: 110, y: 60 }, 800, 600);
result.current.zoomIn();
});
expect(result.current.pan).toEqual({ x: 100, y: 50 });
expect(result.current.zoom).toBeGreaterThan(1);
});
it("fit-to-graph computes bounds from actual node positions", () => {
const { result } = renderHook(() => useGraphInteraction());
const positions = new Map([
["A", { x: 0, y: 0 }],
["B", { x: 1000, y: 400 }],
]);
act(() => {
result.current.fitToGraph(positions, 800, 600, { nodeWidth: 200, nodeHeight: 100, xGap: 40, yGap: 40 });
});
expect(result.current.zoom).toBeCloseTo(0.6, 3);
expect(result.current.pan.x).toBeCloseTo(40, 3);
expect(result.current.pan.y).toBeCloseTo(150, 3);
});
it("clicking a node opens task detail", () => {
const onOpenDetail = vi.fn();
render(<DependencyGraph tasks={[createTask("A", "in-progress")]} onOpenDetail={onOpenDetail} />);
fireEvent.click(screen.getByTestId("task-A"));
expect(onOpenDetail).toHaveBeenCalledWith(expect.objectContaining({ id: "A" }));
expect(screen.getAllByText(/Executing/).length).toBeGreaterThan(0);
});
it("dragging a node updates its position", () => {
const onNodePositionChange = vi.fn();
render(
<GraphTaskNode
task={createTask("A")}
position={{ x: 0, y: 0 }}
scale={1}
isHighlighted={false}
isDimmed={false}
onNodePositionChange={onNodePositionChange}
onNodeDragStateChange={vi.fn()}
projectId="p1"
onOpenDetail={vi.fn()}
addToast={vi.fn()}
onUpdateTask={vi.fn()}
onArchiveTask={vi.fn()}
onUnarchiveTask={vi.fn()}
onDeleteTask={vi.fn()}
onRetryTask={vi.fn()}
onOpenDetailWithTab={vi.fn()}
onMoveTask={vi.fn()}
onOpenMission={vi.fn()}
taskStuckTimeoutMs={1_000}
lastFetchTimeMs={Date.now()}
workflowStepNameLookup={new Map()}
/>,
);
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: 25, clientY: 30, isPrimary: true });
expect(onNodePositionChange).toHaveBeenCalled();
});
it("highlights upstream/downstream chain on hover", () => {
render(
<DependencyGraph
tasks={[createTask("A"), createTask("B", "todo", ["A"]), createTask("C", "todo", ["B"]), createTask("D")]}
onOpenDetail={vi.fn()}
/>,
);
fireEvent.mouseEnter(screen.getByTestId("graph-task-node-C"));
expect(screen.getByTestId("graph-task-node-A").className).toContain("graph-task-node--highlighted");
expect(screen.getByTestId("graph-task-node-D").className).toContain("graph-task-node--dimmed");
});
});

View File

@@ -0,0 +1,89 @@
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";
import { loadPositions, savePositions } from "../utils/graphPositionStorage";
vi.mock("@fusion/dashboard/app/components/TaskCard", () => ({
TaskCard: ({ task }: { task: Task }) => <div>{task.id}</div>,
}));
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("dependency graph position persistence", () => {
beforeEach(() => {
Object.defineProperty(window, "localStorage", { value: createStorage(), configurable: true });
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
it("saves/restores project-scoped position shape", () => {
savePositions({ A: { x: 10, y: 20 }, B: { x: 30, y: 40 } }, new Set(["A", "B"]), "p1");
expect(window.localStorage.getItem("kb:p1:dependency-graph-positions")).toBe(
JSON.stringify({ A: { x: 10, y: 20 }, B: { x: 30, y: 40 } }),
);
expect(loadPositions("p1")).toEqual({ A: { x: 10, y: 20 }, B: { x: 30, y: 40 } });
});
it("keeps positions isolated across projects", () => {
savePositions({ A: { x: 1, y: 2 } }, new Set(["A"]), "p1");
savePositions({ A: { x: 99, y: 88 } }, new Set(["A"]), "p2");
expect(loadPositions("p1")).toEqual({ A: { x: 1, y: 2 } });
expect(loadPositions("p2")).toEqual({ A: { x: 99, y: 88 } });
});
it("falls back to auto-layout with corrupt storage and does not crash", () => {
window.localStorage.setItem("kb:p1:dependency-graph-positions", "{broken");
render(<DependencyGraph tasks={[createTask("A")]} projectId="p1" />);
expect(screen.getByTestId("graph-task-node-A")).toBeTruthy();
});
it("drag persistence only writes localStorage and performs no network writes", () => {
const fetchSpy = vi.spyOn(globalThis, "fetch");
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"');
expect(fetchSpy).not.toHaveBeenCalled();
});
it("clearing localStorage causes fresh auto-layout on 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: 40, clientY: 50 });
fireEvent.pointerUp(node, { pointerId: 1, isPrimary: true, clientX: 40, clientY: 50 });
window.localStorage.removeItem("kb:p1:dependency-graph-positions");
unmount();
render(<DependencyGraph tasks={[createTask("A")]} projectId="p1" />);
expect(window.localStorage.getItem("kb:p1:dependency-graph-positions")).toBeNull();
expect(screen.getByTestId("graph-task-node-A")).toBeTruthy();
});
});