Files
fusion/packages/dashboard/app/hooks/__tests__/useViewState.test.ts
Fusion cba93d0924 feat(FN-3264): add droid plugin, improve agent detail view, and fix researc
This merge adds Agent Detail view improvements including model favoriting, wires in the droid bundled plugin, updates mobile split layout with proper safe-area padding, and adds research task-view persistence with associated test coverage across Header, MobileNavBar, App, and view state hooks.

Fusion-Task-Id: FN-3264
2026-05-04 09:20:07 -07:00

427 lines
12 KiB
TypeScript

import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useViewState } from "../useViewState";
import type { ProjectInfo } from "../../api";
import type { ThemeMode } from "@fusion/core";
const PROJECT: ProjectInfo = {
id: "proj_123",
name: "Demo Project",
path: "/demo",
status: "active",
isolationMode: "in-process",
createdAt: "",
updatedAt: "",
};
function createOptions(overrides: Partial<Parameters<typeof useViewState>[0]> = {}): Parameters<typeof useViewState>[0] {
return {
projectsLoading: false,
projectsError: null,
currentProjectLoading: false,
currentProject: null,
projectsLength: 1,
setupWizardOpen: false,
openSetupWizard: vi.fn(),
themeMode: "dark",
setThemeMode: vi.fn(),
...overrides,
};
}
describe("useViewState", () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
});
it("returns default viewMode and taskView when no localStorage exists", async () => {
const { result } = renderHook(() => useViewState(createOptions()));
await waitFor(() => {
expect(result.current.viewMode).toBe("overview");
expect(result.current.taskView).toBe("board");
});
});
it("reads saved viewMode from localStorage on init", async () => {
localStorage.setItem("kb-dashboard-view-mode", "project");
const { result } = renderHook(() => useViewState(createOptions()));
await waitFor(() => {
expect(result.current.viewMode).toBe("project");
});
});
it("reads saved taskView from localStorage on init", async () => {
localStorage.setItem("kb-dashboard-task-view", "list");
const { result } = renderHook(() => useViewState(createOptions()));
await waitFor(() => {
expect(result.current.taskView).toBe("list");
});
});
it("persists viewMode changes to localStorage", async () => {
const { result } = renderHook(() => useViewState(createOptions()));
await act(async () => {
result.current.setViewMode("project");
});
expect(localStorage.getItem("kb-dashboard-view-mode")).toBe("project");
});
it("persists taskView changes to localStorage", async () => {
const { result } = renderHook(() => useViewState(createOptions()));
await act(async () => {
result.current.setTaskView("list");
});
expect(localStorage.getItem("kb-dashboard-task-view")).toBe("list");
});
it("handleChangeTaskView updates taskView state", async () => {
const { result } = renderHook(() => useViewState(createOptions()));
await act(async () => {
result.current.handleChangeTaskView("agents");
});
expect(result.current.taskView).toBe("agents");
});
it("handleToggleTheme cycles dark → light → system → dark", async () => {
let themeMode: ThemeMode = "dark";
const setThemeMode = vi.fn((mode: ThemeMode) => {
themeMode = mode;
});
const { result, rerender } = renderHook(() =>
useViewState(
createOptions({
themeMode,
setThemeMode,
}),
),
);
await act(async () => {
result.current.handleToggleTheme();
});
expect(setThemeMode).toHaveBeenLastCalledWith("light");
rerender();
await act(async () => {
result.current.handleToggleTheme();
});
expect(setThemeMode).toHaveBeenLastCalledWith("system");
rerender();
await act(async () => {
result.current.handleToggleTheme();
});
expect(setThemeMode).toHaveBeenLastCalledWith("dark");
});
it("syncs viewMode to project when currentProject is restored after loading", async () => {
localStorage.setItem("kb-dashboard-view-mode", "overview");
const { result } = renderHook(() =>
useViewState(
createOptions({
currentProject: PROJECT,
projectsLength: 1,
}),
),
);
await waitFor(() => {
expect(result.current.viewMode).toBe("project");
});
});
it("calls openSetupWizard when no projects and no current project after loading", async () => {
vi.useFakeTimers();
const openSetupWizard = vi.fn();
renderHook(() =>
useViewState(
createOptions({
projectsLength: 0,
currentProject: null,
openSetupWizard,
}),
),
);
await act(async () => {
vi.advanceTimersByTime(500);
});
expect(openSetupWizard).toHaveBeenCalledTimes(1);
vi.useRealTimers();
});
it("does NOT call openSetupWizard when projects exist even if no current project selected", async () => {
vi.useFakeTimers();
const openSetupWizard = vi.fn();
renderHook(() =>
useViewState(
createOptions({
projectsLength: 3, // Projects exist
currentProject: null, // But none selected yet
openSetupWizard,
}),
),
);
await act(async () => {
vi.advanceTimersByTime(500);
});
// Should NOT open setup wizard when projects already exist
// The dashboard should show overview mode to let user pick a project
expect(openSetupWizard).not.toHaveBeenCalled();
vi.useRealTimers();
});
it("does NOT call openSetupWizard when the initial projects fetch failed", async () => {
vi.useFakeTimers();
const openSetupWizard = vi.fn();
renderHook(() =>
useViewState(
createOptions({
projectsLength: 0,
currentProject: null,
projectsError: "Failed to fetch projects",
openSetupWizard,
}),
),
);
await act(async () => {
vi.advanceTimersByTime(500);
});
expect(openSetupWizard).not.toHaveBeenCalled();
vi.useRealTimers();
});
// ── Insights view persistence ─────────────────────────────────────
it("reads saved insights taskView from scoped localStorage on init", async () => {
// Set up scoped storage for project
localStorage.setItem("kb:proj_123:kb-dashboard-task-view", "insights");
const { result } = renderHook(() =>
useViewState(
createOptions({
currentProject: PROJECT,
}),
),
);
await waitFor(() => {
expect(result.current.taskView).toBe("insights");
});
});
it("reads saved research taskView from scoped localStorage on init", async () => {
localStorage.setItem("kb:proj_123:kb-dashboard-task-view", "research");
const { result } = renderHook(() =>
useViewState(
createOptions({
currentProject: PROJECT,
}),
),
);
await waitFor(() => {
expect(result.current.taskView).toBe("research");
});
});
it("persists insights taskView changes to scoped localStorage", async () => {
const { result } = renderHook(() =>
useViewState(
createOptions({
currentProject: PROJECT,
}),
),
);
await act(async () => {
result.current.setTaskView("insights");
});
expect(localStorage.getItem("kb:proj_123:kb-dashboard-task-view")).toBe("insights");
});
it("persists research taskView changes to scoped localStorage", async () => {
const { result } = renderHook(() =>
useViewState(
createOptions({
currentProject: PROJECT,
}),
),
);
await act(async () => {
result.current.setTaskView("research");
});
expect(localStorage.getItem("kb:proj_123:kb-dashboard-task-view")).toBe("research");
});
it("restores dev-server task view and normalizes legacy devserver values", async () => {
localStorage.setItem("kb:proj_123:kb-dashboard-task-view", "dev-server");
const { result, rerender } = renderHook(
({ project }) => useViewState(createOptions({ currentProject: project })),
{ initialProps: { project: PROJECT } },
);
await waitFor(() => {
expect(result.current.taskView).toBe("dev-server");
});
await act(async () => {
result.current.setTaskView("dev-server");
});
expect(localStorage.getItem("kb:proj_123:kb-dashboard-task-view")).toBe("dev-server");
localStorage.setItem("kb:proj_123:kb-dashboard-task-view", "devserver");
rerender({ project: { ...PROJECT, id: "proj_legacy", name: "Legacy" } });
rerender({ project: PROJECT });
await waitFor(() => {
expect(result.current.taskView).toBe("dev-server");
});
});
it("restores and persists plugin task views using the canonical composite key", async () => {
localStorage.setItem("kb:proj_123:kb-dashboard-task-view", "plugin:fusion-plugin-dependency-graph:graph");
const { result } = renderHook(() =>
useViewState(
createOptions({
currentProject: PROJECT,
}),
),
);
await waitFor(() => {
expect(result.current.taskView).toBe("plugin:fusion-plugin-dependency-graph:graph");
});
await act(async () => {
result.current.setTaskView("plugin:fusion-plugin-dependency-graph:graph");
});
expect(localStorage.getItem("kb:proj_123:kb-dashboard-task-view")).toBe("plugin:fusion-plugin-dependency-graph:graph");
});
it("restores legacy views (board/list/agents/missions/chat) from scoped storage", async () => {
const legacyViews = ["board", "list", "agents", "missions", "chat"] as const;
for (const view of legacyViews) {
localStorage.clear();
localStorage.setItem(`kb:proj_123:kb-dashboard-task-view`, view);
const { result } = renderHook(() =>
useViewState(
createOptions({
currentProject: PROJECT,
}),
),
);
await waitFor(() => {
expect(result.current.taskView).toBe(view);
});
}
});
// ── Project-switch scoped rehydration ─────────────────────────────
it("project A reads its own scoped task-view and project B reads its own", async () => {
const projectA: ProjectInfo = { ...PROJECT, id: "proj_a", name: "Project A" };
const projectB: ProjectInfo = { ...PROJECT, id: "proj_b", name: "Project B" };
// Set different views for each project
localStorage.setItem("kb:proj_a:kb-dashboard-task-view", "insights");
localStorage.setItem("kb:proj_b:kb-dashboard-task-view", "agents");
// Start with project A
const { result, rerender } = renderHook(
({ project }) => useViewState(createOptions({ currentProject: project })),
{ initialProps: { project: projectA } },
);
await waitFor(() => {
expect(result.current.taskView).toBe("insights");
});
// Switch to project B
rerender({ project: projectB });
await waitFor(() => {
expect(result.current.taskView).toBe("agents");
});
// Switch back to project A - should restore A's view
rerender({ project: projectA });
await waitFor(() => {
expect(result.current.taskView).toBe("insights");
});
});
it("no cross-project bleed when switching projects", async () => {
const projectA: ProjectInfo = { ...PROJECT, id: "proj_a", name: "Project A" };
const projectB: ProjectInfo = { ...PROJECT, id: "proj_b", name: "Project B" };
// Only set view for project A, project B has no saved view
localStorage.setItem("kb:proj_a:kb-dashboard-task-view", "insights");
// Ensure project B has no scoped storage
localStorage.removeItem("kb:proj_b:kb-dashboard-task-view");
// Load project A
const { result: resultA } = renderHook(() =>
useViewState(
createOptions({
currentProject: projectA,
}),
),
);
await waitFor(() => {
expect(resultA.current.taskView).toBe("insights");
});
// Load project B (no saved view - should default to board)
const { result: resultB } = renderHook(() =>
useViewState(
createOptions({
currentProject: projectB,
}),
),
);
await waitFor(() => {
expect(resultB.current.taskView).toBe("board");
});
// Project A's view should still be insights (not affected by project B load)
expect(resultA.current.taskView).toBe("insights");
});
});