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>
67 lines
4.9 KiB
TypeScript
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");
|
|
});
|
|
});
|