Files
fusion/packages/dashboard/app/hooks/__tests__/useKnowledgeGraph.test.ts
gsxdsm dad726c777 FN-9038: add Knowledge Graph tab to Memory view
Expose knowledge graph data in the Memory view through a localized, resilient dashboard panel.

- Add protected knowledge graph API routes and access helpers.
- Add a Memory view tab, graph panel, loading/error states, and localized labels.
- Document graph navigation and cover access, route, hook, and component behavior.

Files changed:
 .changeset/fn-9038-knowledge-graph-tab.md          |   7 +
 docs/dashboard-guide.md                            |   2 +
 docs/knowledge-graph.md                            |  10 ++
 packages/dashboard/app/api/legacy.ts               |  17 ++-
 packages/dashboard/app/api/system/index.ts         |   1 +
 .../dashboard/app/api/system/knowledge-graph.ts    |  23 +++
 .../app/components/KnowledgeGraphPanel.css         |  35 +++++
 .../app/components/KnowledgeGraphPanel.tsx         |  68 +++++++++
 packages/dashboard/app/components/MemoryView.tsx   |  15 +-
 .../__tests__/KnowledgeGraphPanel.test.tsx         |  49 +++++++
 .../app/components/__tests__/MemoryView.test.tsx   |  13 ++
 .../app/hooks/__tests__/useKnowledgeGraph.test.ts  |  66 +++++++++
 packages/dashboard/app/hooks/useKnowledgeGraph.ts  |  94 ++++++++++++
 .../src/__tests__/knowledge-graph-access.test.ts   |  88 ++++++++++++
 packages/dashboard/src/knowledge-graph-access.ts   | 160 +++++++++++++++++++++
 .../register-knowledge-graph-routes.test.ts        |  75 ++++++++++
 .../src/routes/register-knowledge-routes.ts        | 120 ++++++++++++++++
 packages/i18n/locales/en/app.json                  |  15 +-
 packages/i18n/locales/es/app.json                  |  15 +-
 packages/i18n/locales/fr/app.json                  |  15 +-
 packages/i18n/locales/ko/app.json                  |  15 +-
 packages/i18n/locales/pt-BR/app.json               |  15 +-
 packages/i18n/locales/zh-CN/app.json               |  15 +-
 packages/i18n/locales/zh-TW/app.json               |  15 +-
 24 files changed, 939 insertions(+), 9 deletions(-)

Fusion-Task-Id: FN-9038

Fusion-Task-Lineage: 4200e214-ad22-4f92-b980-c9bcb8ffd3b9

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-08-13 16:48:01 -07:00

67 lines
4.9 KiB
TypeScript

import { act, renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useKnowledgeGraph } from "../useKnowledgeGraph";
const api = vi.hoisted(() => ({ status: vi.fn(), nodes: vi.fn(), node: vi.fn(), neighbors: vi.fn(), path: vi.fn(), build: vi.fn() }));
vi.mock("../../api/system/knowledge-graph.js", () => ({ fetchKnowledgeGraphStatus: api.status, queryKnowledgeGraphNodes: api.nodes, fetchKnowledgeGraphNode: api.node, fetchKnowledgeGraphNeighbors: api.neighbors, fetchKnowledgeGraphPath: api.path, buildKnowledgeGraphArtifacts: api.build }));
const status = { available: true, pathLimits: { defaultMaxHops: 6, maxHops: 10, maxExpansions: 20_000 } };
const emptyNodes = { nodes: [], total: 0, limit: 50, offset: 0 };
function defaults() { api.status.mockResolvedValue(status); api.nodes.mockResolvedValue(emptyNodes); api.node.mockResolvedValue({ node: { id: "a" }, outgoing: [], incoming: [], outgoingTotal: 0, incomingTotal: 0 }); api.neighbors.mockResolvedValue({ neighbors: [], total: 0 }); }
beforeEach(() => { vi.clearAllMocks(); defaults(); });
afterEach(() => { vi.useRealTimers(); });
describe("useKnowledgeGraph", () => {
it("loads status lazily and debounces one search for a filter update", async () => {
renderHook(() => useKnowledgeGraph({ projectId: "project", enabled: false }));
expect(api.status).not.toHaveBeenCalled();
// Mounting an enabled instance makes the lazy boundary explicit.
const enabled = renderHook(() => useKnowledgeGraph({ projectId: "project", enabled: true }));
await waitFor(() => { expect(api.status).toHaveBeenCalledWith("project"); expect(api.nodes).toHaveBeenCalledTimes(1); });
act(() => { enabled.result.current.setFilters({ pathPrefix: "src", limit: 50, offset: 0 }); });
await waitFor(() => expect(api.nodes).toHaveBeenLastCalledWith("project", expect.objectContaining({ pathPrefix: "src" })));
});
it("fences stale searches and loads node details with neighbors", async () => {
let resolveFirst: ((value: typeof emptyNodes) => void) | undefined;
api.nodes.mockImplementationOnce(() => new Promise((resolve) => { resolveFirst = resolve; })).mockResolvedValueOnce({ ...emptyNodes, total: 2 });
const { result } = renderHook(() => useKnowledgeGraph({ enabled: true }));
await waitFor(() => expect(resolveFirst).toBeTypeOf("function"));
act(() => result.current.setFilters({ pathPrefix: "new", limit: 50, offset: 0 }));
await waitFor(() => expect(result.current.nodes?.total).toBe(2));
await act(async () => { resolveFirst?.(emptyNodes); });
expect(result.current.nodes?.total).toBe(2);
await act(async () => { await result.current.selectNode("a"); });
expect(api.node).toHaveBeenCalledWith(undefined, "a"); expect(api.neighbors).toHaveBeenCalledWith(undefined, expect.objectContaining({ nodeId: "a" }));
expect(result.current.detail?.node.id).toBe("a");
});
it("preserves every path outcome, rejects unknown ids distinctly, and reapplies raised hop bounds", async () => {
api.path.mockResolvedValueOnce({ outcome: "found", path: { nodes: [], edges: [] }, hops: 0, maxHops: 6, expansions: 0, truncated: false })
.mockResolvedValueOnce({ outcome: "not-found", path: null, maxHops: 6, expansions: 1, truncated: false })
.mockResolvedValueOnce({ outcome: "limit-reached", path: null, maxHops: 8, expansions: 1, truncated: true, limit: "max-hops" })
.mockRejectedValueOnce(new Error("Unknown graph node"));
const { result } = renderHook(() => useKnowledgeGraph({ enabled: true }));
await waitFor(() => expect(result.current.status).toEqual(status));
for (const outcome of ["found", "not-found", "limit-reached"]) {
await act(async () => { await result.current.findPath(); });
expect(result.current.pathResult?.outcome).toBe(outcome);
}
act(() => result.current.setPathInput({ fromId: "a", toId: "b", maxHops: 8 }));
await act(async () => { await result.current.findPath(); });
expect(result.current.pathResult).toBeNull(); expect(result.current.error).toBe("Unknown graph node");
expect(api.path).toHaveBeenLastCalledWith(undefined, { fromId: "a", toId: "b", maxHops: 8 });
});
it("rebuilds once, refreshes status, and returns rejected calls as state", async () => {
api.build.mockResolvedValue({ changed: true });
const { result } = renderHook(() => useKnowledgeGraph({ projectId: "project", enabled: true }));
await waitFor(() => expect(result.current.status).toEqual(status));
await act(async () => { await result.current.rebuild(true); });
expect(api.build).toHaveBeenCalledWith("project", true); expect(api.status).toHaveBeenCalledTimes(2); expect(result.current.rebuilding).toBe(false);
api.path.mockRejectedValueOnce(new Error("network failure"));
await act(async () => { await result.current.findPath(); });
expect(result.current.error).toBe("network failure");
});
});