diff --git a/graph-ui/src/components/GraphTab.selection.test.tsx b/graph-ui/src/components/GraphTab.selection.test.tsx new file mode 100644 index 000000000..2e1444311 --- /dev/null +++ b/graph-ui/src/components/GraphTab.selection.test.tsx @@ -0,0 +1,168 @@ +/* @vitest-environment jsdom */ +import "@testing-library/jest-dom/vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { GraphTab } from "./GraphTab"; +import type { GraphData } from "../lib/types"; + +/* GraphScene renders a WebGL which jsdom can't run — stub it out. */ +vi.mock("./GraphScene", () => ({ + GraphScene: () => null, + computeCameraTarget: () => null, +})); + +const SELECTION_DATA: GraphData = { + nodes: [ + /* Multi-node directory: src/ has foo.ts + bar.ts */ + { + id: 1, + x: 0, y: 0, z: 0, + label: "Function", + name: "foo", + file_path: "src/foo.ts", + size: 1, + color: "#ff0000", + }, + { + id: 2, + x: 1, y: 0, z: 0, + label: "Class", + name: "bar", + file_path: "src/bar.ts", + size: 1, + color: "#00ff00", + }, + /* One-node directory: utils/ has only helper.ts */ + { + id: 3, + x: 2, y: 0, z: 0, + label: "Function", + name: "helper", + file_path: "utils/helper.ts", + size: 1, + color: "#0000ff", + }, + /* Node without file_path — reachable only via search */ + { + id: 4, + x: 3, y: 0, z: 0, + label: "Class", + name: "orphanFn", + size: 1, + color: "#ffff00", + }, + ], + edges: [ + { source: 1, target: 2, type: "CALLS" }, + { source: 1, target: 3, type: "CALLS" }, + ], + total_nodes: 4, +}; + +function mockFetch(data: GraphData) { + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.startsWith("/api/layout")) { + return new Response(JSON.stringify(data), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + /* /api/ui-config, /api/repo-info → default empty */ + return new Response("{}", { status: 200 }); + }); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +describe("GraphTab selection", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("opens the detail panel when a leaf node is clicked in the file tree", async () => { + mockFetch(SELECTION_DATA); + render(); + + /* Wait for the layout to load. */ + expect(await screen.findByText("Filters")).toBeInTheDocument(); + + /* Expand the "src" directory. */ + fireEvent.click(screen.getByRole("button", { name: /src/ })); + + /* Click the "foo" file node inside. */ + fireEvent.click(screen.getByRole("button", { name: /^foo/ })); + + /* The detail panel must render the node name as a heading. */ + expect(screen.getByRole("heading", { name: "foo" })).toBeInTheDocument(); + }); + + it("does NOT open the detail panel for a directory containing exactly one node", async () => { + mockFetch(SELECTION_DATA); + render(); + + expect(await screen.findByText("Filters")).toBeInTheDocument(); + + /* Click the "utils" directory — it contains exactly 1 node (helper), + * but Sidebar passes no GraphNode arg for directories. */ + fireEvent.click(screen.getByRole("button", { name: /utils/ })); + + /* The detail panel must NOT open just because nodeIds.size === 1. */ + expect(screen.queryByRole("heading", { name: "helper" })).not.toBeInTheDocument(); + + /* The directory IS selected though — HUD shows count. */ + expect(screen.getByText("1 selected")).toBeInTheDocument(); + }); + + it("shows the detail panel for a search result that has no file_path", async () => { + mockFetch(SELECTION_DATA); + render(); + + expect(await screen.findByText("Filters")).toBeInTheDocument(); + + /* Search for the orphan node. */ + const searchInput = screen.getByPlaceholderText("Search..."); + fireEvent.change(searchInput, { target: { value: "orphan" } }); + + /* Click the search result — Sidebar passes "" for path (no file_path) + * but still passes the GraphNode as the third argument. */ + fireEvent.click(screen.getByRole("button", { name: /orphanFn/ })); + + /* The detail panel must still appear. */ + expect(screen.getByRole("heading", { name: "orphanFn" })).toBeInTheDocument(); + }); + + it("clears selection via the Sidebar 'Clear selection' button", async () => { + mockFetch(SELECTION_DATA); + render(); + + expect(await screen.findByText("Filters")).toBeInTheDocument(); + + /* Select a leaf node first. */ + fireEvent.click(screen.getByRole("button", { name: /src/ })); + fireEvent.click(screen.getByRole("button", { name: /^foo/ })); + expect(screen.getByRole("heading", { name: "foo" })).toBeInTheDocument(); + + /* Click "Clear selection" — there are two buttons with this label + * (Sidebar footer + HUD). Both clear, so clicking either is fine. */ + const clearButtons = screen.getAllByRole("button", { name: "Clear selection" }); + fireEvent.click(clearButtons[0]); + + /* Detail panel must disappear. */ + expect(screen.queryByRole("heading", { name: "foo" })).not.toBeInTheDocument(); + }); + + it("highlights the selected node and its direct neighbors", async () => { + mockFetch(SELECTION_DATA); + render(); + + expect(await screen.findByText("Filters")).toBeInTheDocument(); + + /* Select foo (id:1), which has edges to bar (id:2) and helper (id:3). */ + fireEvent.click(screen.getByRole("button", { name: /src/ })); + fireEvent.click(screen.getByRole("button", { name: /^foo/ })); + + /* foo + bar + helper = 3 nodes highlighted. */ + expect(screen.getByText("3 selected")).toBeInTheDocument(); + }); +}); diff --git a/graph-ui/src/components/GraphTab.tsx b/graph-ui/src/components/GraphTab.tsx index bc05d5c14..2f3a17e14 100644 --- a/graph-ui/src/components/GraphTab.tsx +++ b/graph-ui/src/components/GraphTab.tsx @@ -257,21 +257,6 @@ export function GraphTab({ project }: GraphTabProps) { }; }, [project]); - const handleSelectPath = useCallback( - (path: string, nodeIds: Set) => { - if (!filteredData || !path || nodeIds.size === 0) { - setHighlightedIds(null); - setSelectedPath(null); - setCameraTarget(null); - return; - } - setSelectedPath(path); - setHighlightedIds(nodeIds); - setCameraTarget(computeCameraTarget(filteredData.nodes, nodeIds)); - }, - [filteredData], - ); - const handleNodeClick = useCallback( (node: GraphNode) => { if (!filteredData) return; @@ -310,6 +295,29 @@ export function GraphTab({ project }: GraphTabProps) { [handleNodeClick], ); + const handleSelectPath = useCallback( + (path: string, nodeIds: Set, node?: GraphNode) => { + if (node) { + handleNodeClick(node); + return; + } + + if (!filteredData || !path || nodeIds.size === 0) { + setHighlightedIds(null); + setSelectedPath(null); + setSelectedNode(null); + setCameraTarget(null); + return; + } + + setSelectedNode(null); + setSelectedPath(path); + setHighlightedIds(nodeIds); + setCameraTarget(computeCameraTarget(filteredData.nodes, nodeIds)); + }, + [filteredData, handleNodeClick], + ); + const toggleLabel = useCallback((label: string) => { setEnabledLabels((prev) => { const next = new Set(prev); diff --git a/graph-ui/src/components/Sidebar.tsx b/graph-ui/src/components/Sidebar.tsx index 0343940a4..4dab26467 100644 --- a/graph-ui/src/components/Sidebar.tsx +++ b/graph-ui/src/components/Sidebar.tsx @@ -5,7 +5,7 @@ import { useUiMessages } from "../lib/i18n"; interface SidebarProps { nodes: GraphNode[]; - onSelectPath: (path: string, nodeIds: Set) => void; + onSelectPath: (path: string, nodeIds: Set, node?: GraphNode) => void; selectedPath: string | null; } @@ -61,7 +61,7 @@ function flattenSingleChild(dir: DirNode): DirNode { function TreeItem({ dir, depth, onSelect, selectedPath }: { dir: DirNode; depth: number; - onSelect: (path: string, ids: Set) => void; + onSelect: (path: string, ids: Set, node?: GraphNode) => void; selectedPath: string | null; }) { const [expanded, setExpanded] = useState(false); @@ -72,7 +72,7 @@ function TreeItem({ dir, depth, onSelect, selectedPath }: { return (