- Add a project storage utility with scoped key helpers and key inventories for global vs project state - Scope dashboard/task view, list preferences, quick-entry drafts, agent/tree state, terminal tabs, usage view, and modal draft persistence to projectId - Reload persisted UI state when project context changes so per-project settings and drafts do not leak across projects - Update and expand dashboard tests, including new projectStorage coverage and scoped persistence regression fixes
206 lines
7.4 KiB
TypeScript
206 lines
7.4 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import { renderHook, act } from "@testing-library/react";
|
|
import { useAgentHierarchy } from "../useAgentHierarchy";
|
|
import type { Agent, AgentCapability, AgentState } from "../../api";
|
|
import { scopedKey } from "../../utils/projectStorage";
|
|
|
|
// Mock localStorage
|
|
const localStorageStore: Record<string, string> = {};
|
|
const localStorageMock = {
|
|
getItem: vi.fn((key: string) => localStorageStore[key] ?? null),
|
|
setItem: vi.fn((key: string, value: string) => {
|
|
localStorageStore[key] = value;
|
|
}),
|
|
removeItem: vi.fn((key: string) => {
|
|
delete localStorageStore[key];
|
|
}),
|
|
clear: vi.fn(() => {
|
|
Object.keys(localStorageStore).forEach((k) => delete localStorageStore[k]);
|
|
}),
|
|
};
|
|
vi.stubGlobal("localStorage", localStorageMock);
|
|
|
|
const PROJECT_ID = "proj-123";
|
|
const EXPANDED_STORAGE_KEY = scopedKey("kb-agent-tree-expanded", PROJECT_ID);
|
|
|
|
function createMockAgent(overrides: Partial<Agent> = {}): Agent {
|
|
return {
|
|
id: "agent-001",
|
|
name: "Test Agent",
|
|
role: "executor" as AgentCapability,
|
|
state: "idle" as AgentState,
|
|
metadata: {},
|
|
createdAt: "2026-01-01T00:00:00Z",
|
|
updatedAt: "2026-01-01T00:00:00Z",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function renderHierarchy(agents: Agent[], projectId: string | undefined = PROJECT_ID) {
|
|
return renderHook(() => useAgentHierarchy(agents, projectId));
|
|
}
|
|
|
|
beforeEach(() => {
|
|
localStorageMock.getItem.mockImplementation((key: string) => localStorageStore[key] ?? null);
|
|
localStorageMock.setItem.mockImplementation((key: string, value: string) => {
|
|
localStorageStore[key] = value;
|
|
});
|
|
localStorageMock.clear.mockImplementation(() => {
|
|
Object.keys(localStorageStore).forEach((k) => delete localStorageStore[k]);
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
localStorageMock.clear();
|
|
});
|
|
|
|
describe("useAgentHierarchy", () => {
|
|
it("builds tree from flat agents array", () => {
|
|
const parent = createMockAgent({ id: "parent-1", name: "Parent" });
|
|
const child = createMockAgent({ id: "child-1", name: "Child", reportsTo: "parent-1" });
|
|
|
|
const { result } = renderHierarchy([parent, child]);
|
|
|
|
expect(result.current.rootNodes).toHaveLength(1);
|
|
expect(result.current.rootNodes[0].agent.id).toBe("parent-1");
|
|
expect(result.current.rootNodes[0].depth).toBe(0);
|
|
});
|
|
|
|
it("handles empty agents array", () => {
|
|
const { result } = renderHierarchy([]);
|
|
|
|
expect(result.current.rootNodes).toHaveLength(0);
|
|
expect(result.current.isLoading).toBe(false);
|
|
});
|
|
|
|
it("handles agents with no parent-child relationships (all root nodes)", () => {
|
|
const agent1 = createMockAgent({ id: "agent-1" });
|
|
const agent2 = createMockAgent({ id: "agent-2" });
|
|
const agent3 = createMockAgent({ id: "agent-3" });
|
|
|
|
const { result } = renderHierarchy([agent1, agent2, agent3]);
|
|
|
|
expect(result.current.rootNodes).toHaveLength(3);
|
|
expect(result.current.rootNodes.map((n) => n.agent.id)).toEqual(["agent-1", "agent-2", "agent-3"]);
|
|
});
|
|
|
|
it("handles deeply nested hierarchies (parent -> child -> grandchild)", () => {
|
|
const parent = createMockAgent({ id: "parent", name: "Parent" });
|
|
const child = createMockAgent({ id: "child", name: "Child", reportsTo: "parent" });
|
|
const grandchild = createMockAgent({ id: "grandchild", name: "Grandchild", reportsTo: "child" });
|
|
|
|
// Pre-expand parent and child so grandchild shows up
|
|
localStorageMock.getItem.mockImplementation((key: string) => {
|
|
if (key === EXPANDED_STORAGE_KEY) return JSON.stringify(["parent", "child"]);
|
|
return localStorageStore[key] ?? null;
|
|
});
|
|
|
|
const { result } = renderHierarchy([parent, child, grandchild]);
|
|
|
|
expect(result.current.rootNodes).toHaveLength(1);
|
|
const rootNode = result.current.rootNodes[0];
|
|
expect(rootNode.agent.id).toBe("parent");
|
|
expect(rootNode.depth).toBe(0);
|
|
expect(rootNode.children).toHaveLength(1);
|
|
expect(rootNode.children[0].agent.id).toBe("child");
|
|
expect(rootNode.children[0].depth).toBe(1);
|
|
expect(rootNode.children[0].children).toHaveLength(1);
|
|
expect(rootNode.children[0].children[0].agent.id).toBe("grandchild");
|
|
expect(rootNode.children[0].children[0].depth).toBe(2);
|
|
});
|
|
|
|
it("toggles expand state for a node", () => {
|
|
const parent = createMockAgent({ id: "parent-1", name: "Parent" });
|
|
const child = createMockAgent({ id: "child-1", name: "Child", reportsTo: "parent-1" });
|
|
|
|
const { result } = renderHierarchy([parent, child]);
|
|
|
|
// Initially not expanded
|
|
expect(result.current.isExpanded("parent-1")).toBe(false);
|
|
|
|
// Expand
|
|
act(() => {
|
|
result.current.toggleExpand("parent-1");
|
|
});
|
|
|
|
expect(result.current.isExpanded("parent-1")).toBe(true);
|
|
|
|
// Collapse
|
|
act(() => {
|
|
result.current.toggleExpand("parent-1");
|
|
});
|
|
|
|
expect(result.current.isExpanded("parent-1")).toBe(false);
|
|
});
|
|
|
|
it("persists expand state to localStorage", () => {
|
|
const parent = createMockAgent({ id: "parent-1", name: "Parent" });
|
|
|
|
const { result } = renderHierarchy([parent]);
|
|
|
|
act(() => {
|
|
result.current.toggleExpand("parent-1");
|
|
});
|
|
|
|
expect(localStorageMock.setItem).toHaveBeenCalledWith(
|
|
EXPANDED_STORAGE_KEY,
|
|
JSON.stringify(["parent-1"]),
|
|
);
|
|
});
|
|
|
|
it("restores expand state from localStorage on mount", () => {
|
|
localStorageMock.getItem.mockImplementation((key: string) => {
|
|
if (key === EXPANDED_STORAGE_KEY) return JSON.stringify(["parent-1"]);
|
|
return localStorageStore[key] ?? null;
|
|
});
|
|
|
|
const parent = createMockAgent({ id: "parent-1", name: "Parent" });
|
|
|
|
const { result } = renderHierarchy([parent]);
|
|
|
|
expect(result.current.isExpanded("parent-1")).toBe(true);
|
|
});
|
|
|
|
it("isExpanded returns correct state", () => {
|
|
const agent1 = createMockAgent({ id: "agent-1" });
|
|
const agent2 = createMockAgent({ id: "agent-2" });
|
|
|
|
const { result } = renderHierarchy([agent1, agent2]);
|
|
|
|
expect(result.current.isExpanded("agent-1")).toBe(false);
|
|
expect(result.current.isExpanded("agent-2")).toBe(false);
|
|
|
|
act(() => {
|
|
result.current.toggleExpand("agent-1");
|
|
});
|
|
|
|
expect(result.current.isExpanded("agent-1")).toBe(true);
|
|
expect(result.current.isExpanded("agent-2")).toBe(false);
|
|
});
|
|
|
|
it("getChildren returns direct children for an agent", () => {
|
|
const parent = createMockAgent({ id: "parent-1", name: "Parent" });
|
|
const child1 = createMockAgent({ id: "child-1", name: "Child 1", reportsTo: "parent-1" });
|
|
const child2 = createMockAgent({ id: "child-2", name: "Child 2", reportsTo: "parent-1" });
|
|
const unrelated = createMockAgent({ id: "unrelated", name: "Unrelated" });
|
|
|
|
const { result } = renderHierarchy([parent, child1, child2, unrelated]);
|
|
|
|
const children = result.current.getChildren("parent-1");
|
|
expect(children).toHaveLength(2);
|
|
expect(children.map((c) => c.id)).toEqual(["child-1", "child-2"]);
|
|
});
|
|
|
|
it("handles agents with reportsTo pointing to non-existent parent", () => {
|
|
const orphan = createMockAgent({ id: "orphan-1", name: "Orphan", reportsTo: "missing-parent" });
|
|
const normal = createMockAgent({ id: "normal-1", name: "Normal" });
|
|
|
|
const { result } = renderHierarchy([orphan, normal]);
|
|
|
|
// Orphan should be treated as a root node since parent doesn't exist
|
|
expect(result.current.rootNodes).toHaveLength(2);
|
|
expect(result.current.rootNodes.map((n) => n.agent.id)).toContain("orphan-1");
|
|
expect(result.current.rootNodes.map((n) => n.agent.id)).toContain("normal-1");
|
|
});
|
|
});
|