Files
stack/apps/web/src/components/knowledge/KnowledgeGraphViewer.test.tsx
Jason Woltje 37cf813b88
All checks were successful
ci/woodpecker/push/web Pipeline was successful
fix(web): update calendar and knowledge tests for real API integration (#483)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-23 05:04:55 +00:00

400 lines
12 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { KnowledgeGraphViewer } from "./KnowledgeGraphViewer";
import * as knowledgeApi from "@/lib/api/knowledge";
// Mock the knowledge API
vi.mock("@/lib/api/knowledge");
// Mock MosaicSpinner to expose a test ID
vi.mock("@/components/ui/MosaicSpinner", () => ({
MosaicSpinner: ({ label }: { label?: string }): React.JSX.Element => (
<div data-testid="loading-spinner">{label ?? "Loading..."}</div>
),
}));
// Mock elkjs since it requires APIs not available in test environment
vi.mock("elkjs/lib/elk.bundled.js", () => ({
default: class ELK {
layout(graph: {
children?: { id: string }[];
}): Promise<{ children: { id: string; x: number; y: number }[] }> {
return Promise.resolve({
children: (graph.children ?? []).map((child: { id: string }, i: number) => ({
id: child.id,
x: i * 100,
y: i * 100,
})),
});
}
},
}));
// Mock Next.js router
const mockPush = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: (): {
push: typeof mockPush;
replace: () => void;
prefetch: () => void;
back: () => void;
forward: () => void;
refresh: () => void;
} => ({
push: mockPush,
replace: vi.fn(),
prefetch: vi.fn(),
back: vi.fn(),
forward: vi.fn(),
refresh: vi.fn(),
}),
}));
// Mock ReactFlow since it requires DOM APIs not available in test environment
vi.mock("@xyflow/react", () => ({
ReactFlow: ({
nodes,
edges,
children,
}: {
nodes: unknown[];
edges: unknown[];
children: React.ReactNode;
}): React.JSX.Element => (
<div data-testid="react-flow">
<div data-testid="node-count">{nodes.length}</div>
<div data-testid="edge-count">{edges.length}</div>
{children}
</div>
),
Background: (): React.JSX.Element => <div data-testid="background" />,
Controls: (): React.JSX.Element => <div data-testid="controls" />,
MiniMap: (): React.JSX.Element => <div data-testid="minimap" />,
Panel: ({ children }: { children: React.ReactNode }): React.JSX.Element => (
<div data-testid="panel">{children}</div>
),
useNodesState: (initial: unknown[]): [unknown[], () => void, () => void] => [
initial,
vi.fn(),
vi.fn(),
],
useEdgesState: (initial: unknown[]): [unknown[], () => void, () => void] => [
initial,
vi.fn(),
vi.fn(),
],
MarkerType: { ArrowClosed: "arrowclosed" },
BackgroundVariant: { Dots: "dots" },
}));
const mockGraphData = {
nodes: [
{
id: "node-1",
slug: "test-entry-1",
title: "Test Entry 1",
summary: "Test summary 1",
status: "PUBLISHED",
tags: [{ id: "tag-1", name: "Tag 1", slug: "tag-1", color: "#3B82F6" }],
depth: 0,
isOrphan: false,
},
{
id: "node-2",
slug: "test-entry-2",
title: "Test Entry 2",
summary: "Test summary 2",
status: "DRAFT",
tags: [],
depth: 1,
isOrphan: false,
},
{
id: "node-3",
slug: "test-entry-3",
title: "Orphan Entry",
summary: "No connections",
status: "PUBLISHED",
tags: [],
depth: 0,
isOrphan: true,
},
],
edges: [
{
id: "edge-1",
sourceId: "node-1",
targetId: "node-2",
linkText: "link text",
},
],
stats: {
totalNodes: 3,
totalEdges: 1,
orphanCount: 1,
},
};
describe("KnowledgeGraphViewer", (): void => {
beforeEach((): void => {
vi.clearAllMocks();
});
describe("rendering", (): void => {
it("should render loading state initially", (): void => {
vi.mocked(knowledgeApi.fetchKnowledgeGraph).mockImplementation(
// eslint-disable-next-line @typescript-eslint/no-empty-function
() => new Promise(() => {}) // Never resolves
);
render(<KnowledgeGraphViewer />);
expect(screen.getByTestId("loading-spinner")).toBeInTheDocument();
});
it("should render error state when fetch fails", async (): Promise<void> => {
vi.mocked(knowledgeApi.fetchKnowledgeGraph).mockRejectedValue(
new Error("Failed to fetch graph")
);
render(<KnowledgeGraphViewer />);
await waitFor(() => {
expect(screen.getByText(/error loading graph/i)).toBeInTheDocument();
expect(screen.getByText(/failed to fetch graph/i)).toBeInTheDocument();
});
});
it("should render graph when data loads successfully", async (): Promise<void> => {
vi.mocked(knowledgeApi.fetchKnowledgeGraph).mockResolvedValue(mockGraphData);
render(<KnowledgeGraphViewer />);
await waitFor(() => {
expect(screen.getByTestId("react-flow")).toBeInTheDocument();
expect(screen.getByTestId("node-count")).toHaveTextContent("3");
expect(screen.getByTestId("edge-count")).toHaveTextContent("1");
});
});
it("should render graph controls", async (): Promise<void> => {
vi.mocked(knowledgeApi.fetchKnowledgeGraph).mockResolvedValue(mockGraphData);
render(<KnowledgeGraphViewer />);
await waitFor(() => {
expect(screen.getByTestId("controls")).toBeInTheDocument();
expect(screen.getByTestId("minimap")).toBeInTheDocument();
});
});
it("should display statistics in panel", async (): Promise<void> => {
vi.mocked(knowledgeApi.fetchKnowledgeGraph).mockResolvedValue(mockGraphData);
render(<KnowledgeGraphViewer />);
await waitFor(() => {
expect(screen.getByText(/3 entries/i)).toBeInTheDocument();
expect(screen.getByText(/1 connection/i)).toBeInTheDocument();
});
});
});
describe("node sizing", (): void => {
it("should size nodes based on number of connections", async (): Promise<void> => {
vi.mocked(knowledgeApi.fetchKnowledgeGraph).mockResolvedValue(mockGraphData);
render(<KnowledgeGraphViewer />);
await waitFor(() => {
const reactFlow = screen.getByTestId("react-flow");
expect(reactFlow).toBeInTheDocument();
});
// Node sizes should be calculated based on connection count
// node-1 has 1 connection, node-2 has 1 connection, node-3 has 0 connections
});
});
describe("node coloring", (): void => {
it("should color nodes based on entry status", async (): Promise<void> => {
vi.mocked(knowledgeApi.fetchKnowledgeGraph).mockResolvedValue(mockGraphData);
render(<KnowledgeGraphViewer />);
await waitFor(() => {
expect(screen.getByTestId("react-flow")).toBeInTheDocument();
});
// Colors should follow PDA-friendly guidelines:
// PUBLISHED -> green (active)
// DRAFT -> blue (scheduled/upcoming)
// ARCHIVED -> gray (dormant)
});
it("should highlight orphan nodes differently", async (): Promise<void> => {
vi.mocked(knowledgeApi.fetchKnowledgeGraph).mockResolvedValue(mockGraphData);
render(<KnowledgeGraphViewer />);
await waitFor(() => {
expect(screen.getByTestId("react-flow")).toBeInTheDocument();
});
// Orphan nodes should have visual indication
});
});
describe("layout controls", (): void => {
it("should render layout toggle buttons", async (): Promise<void> => {
vi.mocked(knowledgeApi.fetchKnowledgeGraph).mockResolvedValue(mockGraphData);
render(<KnowledgeGraphViewer />);
await waitFor(() => {
expect(screen.getByRole("button", { name: /force/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /hierarchical/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /circular/i })).toBeInTheDocument();
});
});
it("should switch layout when button is clicked", async (): Promise<void> => {
const user = userEvent.setup();
vi.mocked(knowledgeApi.fetchKnowledgeGraph).mockResolvedValue(mockGraphData);
render(<KnowledgeGraphViewer />);
await waitFor(() => {
expect(screen.getByRole("button", { name: /force/i })).toBeInTheDocument();
});
const hierarchicalButton = screen.getByRole("button", { name: /hierarchical/i });
await user.click(hierarchicalButton);
// Should apply hierarchical layout
expect(hierarchicalButton).toHaveClass("bg-blue-500");
});
});
describe("filtering", (): void => {
it("should allow filtering by tags", async (): Promise<void> => {
const user = userEvent.setup();
vi.mocked(knowledgeApi.fetchKnowledgeGraph).mockResolvedValue(mockGraphData);
render(<KnowledgeGraphViewer />);
await waitFor(() => {
expect(screen.getByTestId("react-flow")).toBeInTheDocument();
});
const filterInput = screen.getByPlaceholderText(/filter by tags/i);
await user.type(filterInput, "Tag 1");
// Should filter nodes
});
it("should allow filtering by status", async (): Promise<void> => {
const user = userEvent.setup();
vi.mocked(knowledgeApi.fetchKnowledgeGraph).mockResolvedValue(mockGraphData);
render(<KnowledgeGraphViewer />);
await waitFor(() => {
expect(screen.getByTestId("react-flow")).toBeInTheDocument();
});
const statusFilter = screen.getByRole("combobox", { name: /status/i });
await user.selectOptions(statusFilter, "PUBLISHED");
// Should filter nodes by status
});
it("should show orphan nodes when filter is enabled", async (): Promise<void> => {
const user = userEvent.setup();
vi.mocked(knowledgeApi.fetchKnowledgeGraph).mockResolvedValue(mockGraphData);
render(<KnowledgeGraphViewer />);
await waitFor(() => {
expect(screen.getByTestId("react-flow")).toBeInTheDocument();
});
const orphanToggle = screen.getByRole("switch", { name: /show orphans/i });
await user.click(orphanToggle);
// Should toggle orphan visibility
});
});
describe("node interaction", (): void => {
it("should navigate to entry when node is clicked", async (): Promise<void> => {
vi.mocked(knowledgeApi.fetchKnowledgeGraph).mockResolvedValue(mockGraphData);
render(<KnowledgeGraphViewer />);
await waitFor(() => {
expect(screen.getByTestId("react-flow")).toBeInTheDocument();
});
// Click functionality will be tested in integration tests
// Unit test verifies the handler is set up correctly
});
it("should show node details on hover", async (): Promise<void> => {
vi.mocked(knowledgeApi.fetchKnowledgeGraph).mockResolvedValue(mockGraphData);
render(<KnowledgeGraphViewer />);
await waitFor(() => {
expect(screen.getByTestId("react-flow")).toBeInTheDocument();
});
// Hover behavior will be verified in the actual component
});
});
describe("performance", (): void => {
it("should handle large graphs with 500+ nodes", async (): Promise<void> => {
const largeGraph = {
nodes: Array.from({ length: 500 }, (_, i) => ({
id: `node-${String(i)}`,
slug: `entry-${String(i)}`,
title: `Entry ${String(i)}`,
summary: `Summary ${String(i)}`,
status: "PUBLISHED",
tags: [],
depth: 0,
isOrphan: false,
})),
edges: Array.from({ length: 600 }, (_, i) => ({
id: `edge-${String(i)}`,
sourceId: `node-${String(i % 500)}`,
targetId: `node-${String((i + 1) % 500)}`,
linkText: "link",
})),
stats: {
totalNodes: 500,
totalEdges: 600,
orphanCount: 0,
},
};
vi.mocked(knowledgeApi.fetchKnowledgeGraph).mockResolvedValue(largeGraph);
const startTime = performance.now();
render(<KnowledgeGraphViewer />);
await waitFor(() => {
expect(screen.getByTestId("react-flow")).toBeInTheDocument();
});
const endTime = performance.now();
const renderTime = endTime - startTime;
// Should render in reasonable time (< 3 seconds)
expect(renderTime).toBeLessThan(3000);
});
});
});