|
1 | | -import { describe, it, expect, vi } from "vitest"; |
2 | | -import { render, screen, fireEvent } from "@testing-library/react"; |
| 1 | +import { describe, it, expect, vi, afterEach } from "vitest"; |
| 2 | +import { render, screen, fireEvent, act } from "@testing-library/react"; |
| 3 | +import { forwardRef, useImperativeHandle } from "react"; |
3 | 4 | import { GraphFlow } from "../../src/graph/graph-flow.js"; |
4 | 5 |
|
| 6 | +// Stub the Sigma canvas: it dynamically imports "sigma", which needs WebGL2 |
| 7 | +// and rejects under jsdom. These tests assert toolbar / notice / picker |
| 8 | +// behavior, none of which lives inside the canvas. |
| 9 | +const { focusNodeSpy } = vi.hoisted(() => ({ focusNodeSpy: vi.fn() })); |
| 10 | +vi.mock("../../src/graph/sigma/sigma-canvas.js", () => ({ |
| 11 | + SigmaCanvas: forwardRef(function MockSigmaCanvas(_props, ref) { |
| 12 | + useImperativeHandle(ref, () => ({ |
| 13 | + focusNode: focusNodeSpy, |
| 14 | + fitView: () => {}, |
| 15 | + zoomIn: () => {}, |
| 16 | + zoomOut: () => {}, |
| 17 | + })); |
| 18 | + return <div data-testid="sigma-canvas" />; |
| 19 | + }), |
| 20 | +})); |
| 21 | + |
| 22 | +afterEach(() => { |
| 23 | + vi.useRealTimers(); |
| 24 | + focusNodeSpy.mockClear(); |
| 25 | +}); |
| 26 | + |
5 | 27 | // Minimal prop set — no graphs supplied, so the canvas renders its empty state |
6 | 28 | // while the toolbar (and its color-mode control) still mounts. |
7 | 29 | const baseProps = { |
@@ -102,4 +124,121 @@ describe("GraphFlow shell", () => { |
102 | 124 | fireEvent.click(screen.getByRole("radio", { name: "Dead" })); |
103 | 125 | expect(screen.getByText("No dead files in this repo")).toBeTruthy(); |
104 | 126 | }); |
| 127 | + |
| 128 | + it("opens the flow picker on the module overview and jumps to the full graph on selection", () => { |
| 129 | + const onViewModeChange = vi.fn(); |
| 130 | + render( |
| 131 | + <GraphFlow |
| 132 | + {...baseProps} |
| 133 | + initialViewMode="module" |
| 134 | + onViewModeChange={onViewModeChange} |
| 135 | + executionFlows={{ |
| 136 | + total_entry_points: 1, |
| 137 | + flows: [ |
| 138 | + { |
| 139 | + entry_point: "app.py::main", |
| 140 | + entry_point_name: "main", |
| 141 | + entry_point_score: 1, |
| 142 | + trace: ["app.py::main", "core.py::run"], |
| 143 | + depth: 1, |
| 144 | + crosses_community: false, |
| 145 | + communities_visited: [0], |
| 146 | + }, |
| 147 | + ], |
| 148 | + }} |
| 149 | + />, |
| 150 | + ); |
| 151 | + |
| 152 | + fireEvent.click(screen.getByRole("button", { name: "Execution flows" })); |
| 153 | + expect(screen.getByText("Execution Flows")).toBeTruthy(); |
| 154 | + |
| 155 | + // Selecting a flow from the module overview switches to the file-level |
| 156 | + // graph so the trace can actually be highlighted. |
| 157 | + fireEvent.click(screen.getByText("main")); |
| 158 | + expect(onViewModeChange).toHaveBeenCalledWith("full"); |
| 159 | + }); |
| 160 | + |
| 161 | + it("focuses the flow trace head once the graph gains it, exactly once", () => { |
| 162 | + vi.useFakeTimers(); |
| 163 | + const flows = { |
| 164 | + total_entry_points: 1, |
| 165 | + flows: [ |
| 166 | + { |
| 167 | + entry_point: "app.py::main", |
| 168 | + entry_point_name: "main", |
| 169 | + entry_point_score: 1, |
| 170 | + trace: ["app.py::main", "core.py::run"], |
| 171 | + depth: 1, |
| 172 | + crosses_community: false, |
| 173 | + communities_visited: [0], |
| 174 | + }, |
| 175 | + ], |
| 176 | + }; |
| 177 | + const { rerender } = render( |
| 178 | + <GraphFlow {...baseProps} initialViewMode="full" executionFlows={flows} />, |
| 179 | + ); |
| 180 | + |
| 181 | + fireEvent.click(screen.getByRole("button", { name: "Execution flows" })); |
| 182 | + fireEvent.click(screen.getByText("main")); |
| 183 | + |
| 184 | + // The focus timer fires while the full graph is still loading — the |
| 185 | + // trace head isn't in the (empty) graph yet, so nothing is focused. |
| 186 | + act(() => { |
| 187 | + vi.advanceTimersByTime(800); |
| 188 | + }); |
| 189 | + expect(focusNodeSpy).not.toHaveBeenCalled(); |
| 190 | + |
| 191 | + // The full graph lands: the deferred focus fires once for the trace head. |
| 192 | + const nodes = flows.flows[0]!.trace.map((id) => ({ |
| 193 | + node_id: id, |
| 194 | + label: id, |
| 195 | + language: "python", |
| 196 | + })); |
| 197 | + rerender( |
| 198 | + <GraphFlow |
| 199 | + {...baseProps} |
| 200 | + initialViewMode="full" |
| 201 | + executionFlows={flows} |
| 202 | + fullGraph={{ nodes, links: [] }} |
| 203 | + />, |
| 204 | + ); |
| 205 | + expect(focusNodeSpy).toHaveBeenCalledWith("app.py::main"); |
| 206 | + expect(focusNodeSpy).toHaveBeenCalledTimes(1); |
| 207 | + |
| 208 | + // Later graph changes must not re-steer the camera for the same flow. |
| 209 | + rerender( |
| 210 | + <GraphFlow |
| 211 | + {...baseProps} |
| 212 | + initialViewMode="full" |
| 213 | + executionFlows={flows} |
| 214 | + fullGraph={{ nodes: [...nodes], links: [] }} |
| 215 | + />, |
| 216 | + ); |
| 217 | + expect(focusNodeSpy).toHaveBeenCalledTimes(1); |
| 218 | + }); |
| 219 | + |
| 220 | + it("refuses hierarchical layout above the ELK cap and explains why", () => { |
| 221 | + const nodes = Array.from({ length: 501 }, (_, i) => ({ |
| 222 | + node_id: `f${i}.ts`, |
| 223 | + label: `f${i}.ts`, |
| 224 | + language: "typescript", |
| 225 | + })); |
| 226 | + render( |
| 227 | + <GraphFlow |
| 228 | + {...baseProps} |
| 229 | + initialViewMode="full" |
| 230 | + fullGraph={{ nodes, links: [] }} |
| 231 | + />, |
| 232 | + ); |
| 233 | + |
| 234 | + fireEvent.click(screen.getByRole("button", { name: "Hierarchical" })); |
| 235 | + |
| 236 | + // The mode must not activate — force stays on — and the notice says why. |
| 237 | + expect( |
| 238 | + screen.getByRole("button", { name: "Hierarchical" }).getAttribute("aria-pressed"), |
| 239 | + ).toBe("false"); |
| 240 | + expect(screen.getByRole("status").textContent).toContain( |
| 241 | + "Hierarchical layout is limited to 500 nodes", |
| 242 | + ); |
| 243 | + }); |
105 | 244 | }); |
0 commit comments