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 3575b5e445
commit 9057421112
10 changed files with 480 additions and 0 deletions

View File

@@ -1730,6 +1730,44 @@ export default plugin;
"views-b:timeline",
]);
expect(loader.getPluginUiSlots()).toHaveLength(1);
expect(loader.getPluginTools()).toEqual([]);
expect(loader.getPluginRoutes()).toEqual([]);
});
it("returns pluginId and complete view payload for each dashboard view entry", async () => {
await pluginStore.init();
const loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
(loader as any).plugins.set("views-shape", {
manifest: makeManifest({ id: "views-shape" }),
state: "started",
hooks: {},
dashboardViews: [
{
viewId: "graph",
label: "Graph",
componentPath: "./graph.js",
icon: "Network",
placement: "more",
description: "Task dependency graph",
order: 40,
},
],
} as FusionPlugin);
expect(loader.getPluginDashboardViews()).toEqual([
{
pluginId: "views-shape",
view: {
viewId: "graph",
label: "Graph",
componentPath: "./graph.js",
icon: "Network",
placement: "more",
description: "Task dependency graph",
order: 40,
},
},
]);
});
});

View File

@@ -1784,6 +1784,48 @@ describe("App view switching", () => {
localStorage.removeItem("kb-dashboard-view-mode");
});
it("restores board and plugin routes when persisted taskView changes across remounts", async () => {
localStorage.setItem("kb-dashboard-view-mode", "project");
localStorage.setItem(taskViewStorageKey(), "plugin:fusion-plugin-dependency-graph:graph");
(fetchPluginDashboardViews as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
{
pluginId: "fusion-plugin-dependency-graph",
view: { viewId: "graph", label: "Graph", componentPath: "./GraphView", placement: "more" },
},
]);
const first = render(<App />);
await waitFor(() => {
expect(screen.getByText("Plugin view unavailable")).toBeInTheDocument();
});
first.unmount();
localStorage.setItem(taskViewStorageKey(), "board");
const second = render(<App />);
await waitFor(() => {
expect(screen.getByTitle("Board view").className).toContain("active");
});
second.unmount();
localStorage.setItem(taskViewStorageKey(), "plugin:fusion-plugin-dependency-graph:graph");
(fetchPluginDashboardViews as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
{
pluginId: "fusion-plugin-dependency-graph",
view: { viewId: "graph", label: "Graph", componentPath: "./GraphView", placement: "more" },
},
]);
render(<App />);
await waitFor(() => {
expect(screen.getByText("Plugin view unavailable")).toBeInTheDocument();
});
localStorage.removeItem(taskViewStorageKey());
localStorage.removeItem("kb-dashboard-view-mode");
});
it("opens planning mode when TodoView triggers planning from todo item", async () => {
localStorage.setItem("kb-dashboard-view-mode", "project");
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({

View File

@@ -101,4 +101,19 @@ describe("usePluginDashboardViews", () => {
await waitFor(() => expect(second.result.current.loading).toBe(false));
expect(mockFetch).toHaveBeenCalledWith("project-b");
});
it("supports filtering view entries by pluginId in consumers", async () => {
mockFetch.mockResolvedValueOnce([
{ pluginId: "fusion-plugin-dependency-graph", view: { viewId: "graph", label: "Graph", componentPath: "./Graph.js" } },
{ pluginId: "fusion-plugin-queue", view: { viewId: "queue", label: "Queue", componentPath: "./Queue.js" } },
]);
const { result } = renderHook(() => usePluginDashboardViews("project-a"));
await waitFor(() => expect(result.current.loading).toBe(false));
const graphViews = result.current.views.filter((entry) => entry.pluginId === "fusion-plugin-dependency-graph");
expect(graphViews).toEqual([
{ pluginId: "fusion-plugin-dependency-graph", view: { viewId: "graph", label: "Graph", componentPath: "./Graph.js" } },
]);
});
});

View File

@@ -367,6 +367,34 @@ describe("useViewState", () => {
});
});
it("round-trips between built-in and plugin task views", async () => {
localStorage.setItem("kb:proj_123:kb-dashboard-task-view", "board");
const { result } = renderHook(() =>
useViewState(
createOptions({
currentProject: PROJECT,
}),
),
);
await waitFor(() => {
expect(result.current.taskView).toBe("board");
});
await act(async () => {
result.current.handleChangeTaskView("plugin:fusion-plugin-dependency-graph:graph");
});
expect(result.current.taskView).toBe("plugin:fusion-plugin-dependency-graph:graph");
expect(localStorage.getItem("kb:proj_123:kb-dashboard-task-view")).toBe("plugin:fusion-plugin-dependency-graph:graph");
await act(async () => {
result.current.handleChangeTaskView("board");
});
expect(result.current.taskView).toBe("board");
expect(localStorage.getItem("kb:proj_123:kb-dashboard-task-view")).toBe("board");
});
it("restores legacy views (board/list/agents/missions/chat) from scoped storage", async () => {
const legacyViews = ["board", "list", "agents", "missions", "chat"] as const;

View File

@@ -34,6 +34,22 @@ describe("pluginViewRegistry", () => {
expect(getPluginViewComponent("plugin-b", "missing")).toBeNull();
});
it("keeps all registered plugin views discoverable by key iteration", () => {
const ViewA = lazy(async () => ({ default: () => <div>A</div> }));
const ViewB = lazy(async () => ({ default: () => <div>B</div> }));
registerPluginView("plugin-a", "main", ViewA);
registerPluginView("plugin-b", "graph", ViewB);
const knownIds = [getPluginViewId("plugin-a", "main"), getPluginViewId("plugin-b", "graph")];
const resolved = knownIds.map((id) => {
const parsed = parsePluginViewId(id);
if (!parsed) return null;
return getPluginViewComponent(parsed.pluginId, parsed.viewId);
});
expect(resolved).toEqual([ViewA, ViewB]);
});
it("renders registered components", async () => {
const View = lazy(async () => ({ default: () => <div>Rendered Plugin View</div> }));
registerPluginView("plugin-a", "main", View);

View File

@@ -784,6 +784,8 @@ describe("GET /api/plugins/dashboard-views", () => {
componentPath: "./views/Graph.js",
icon: "Network",
placement: "more",
order: 40,
description: "Dependency graph",
},
},
];
@@ -794,6 +796,28 @@ describe("GET /api/plugins/dashboard-views", () => {
expect(res.status).toBe(200);
expect(res.body).toEqual(mockViews);
});
it("returns exactly pluginLoader dashboard-view entries (no synthesized plugin rows)", async () => {
(pluginLoader.getPluginDashboardViews as ReturnType<typeof vi.fn>).mockReturnValue([
{
pluginId: "with-view",
view: {
viewId: "graph",
label: "Graph",
componentPath: "./Graph.js",
},
},
]);
const res = await performGet(buildApp(), "/api/plugins/dashboard-views");
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
expect(res.body[0]).toMatchObject({
pluginId: "with-view",
view: { viewId: "graph", label: "Graph", componentPath: "./Graph.js" },
});
});
});
describe("GET /api/plugins/ui-slots", () => {

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();
});
});