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:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user