diff --git a/frontend/src/components/NetworkGraph.bounded-label-summary.test.ts b/frontend/src/components/NetworkGraph.bounded-label-summary.test.ts new file mode 100644 index 000000000..2b2b99881 --- /dev/null +++ b/frontend/src/components/NetworkGraph.bounded-label-summary.test.ts @@ -0,0 +1,24 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +function nodeLabelMemoSource(): string { + const source = readFileSync(new URL("./NetworkGraph.tsx", import.meta.url), "utf8"); + const start = source.indexOf(" const nodeLabels = useMemo(() => {"); + const end = source.indexOf(" const firstEdge =", start); + + expect(start).toBeGreaterThanOrEqual(0); + expect(end).toBeGreaterThan(start); + return source.slice(start, end); +} + +describe("NetworkGraph bounded label summary", () => { + it("caps label-summary work at five accepted labels without whole-array transforms", () => { + const memoSource = nodeLabelMemoSource(); + + expect(memoSource).toContain("for (const node of nodes)"); + expect(memoSource).toContain("if (labels.length >= 5) break;"); + expect(memoSource).not.toContain(".map("); + expect(memoSource).not.toContain(".filter("); + expect(memoSource).not.toContain(".slice("); + }); +}); diff --git a/frontend/src/components/NetworkGraph.label-boundary.test.tsx b/frontend/src/components/NetworkGraph.label-boundary.test.tsx new file mode 100644 index 000000000..a7f2593ec --- /dev/null +++ b/frontend/src/components/NetworkGraph.label-boundary.test.tsx @@ -0,0 +1,99 @@ +/* @vitest-environment jsdom */ +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const destroyMock = vi.fn(); +const fitMock = vi.fn(); +const moveToMock = vi.fn(); +const offMock = vi.fn(); +const onMock = vi.fn(); +const selectEdgesMock = vi.fn(); +const selectNodesMock = vi.fn(); + +vi.mock("vis-network", () => ({ + Network: vi.fn(function MockNetwork() { + return { + destroy: destroyMock, + fit: fitMock, + moveTo: moveToMock, + off: offMock, + on: onMock, + selectEdges: selectEdgesMock, + selectNodes: selectNodesMock, + }; + }), +})); + +import NetworkGraph from "./NetworkGraph"; + +function jsonResponse(body: unknown) { + return { + ok: true, + json: async () => body, + }; +} + +async function flushAsyncWork() { + for (let index = 0; index < 5; index += 1) { + await act(async () => { + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + } +} + +describe("NetworkGraph label boundary", () => { + let root: Root | null = null; + let container: HTMLDivElement | null = null; + + afterEach(() => { + if (root) { + act(() => root?.unmount()); + } + root = null; + container?.remove(); + container = null; + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + it("renders only the first five non-empty related-node labels in source order", async () => { + const fetchMock = vi.fn(() => + Promise.resolve( + jsonResponse({ + nodes: [ + { id: "node-a", label: "A" }, + { id: "empty-1", label: "" }, + { id: "node-b", label: "B" }, + { id: "node-c", label: "C" }, + { id: "empty-2", label: "" }, + { id: "node-d", label: "D" }, + { id: "node-e", label: "E" }, + { id: "node-f", label: "F" }, + ], + edges: [], + }), + ), + ); + vi.stubGlobal("fetch", fetchMock); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render(); + }); + await flushAsyncWork(); + + const summary = Array.from(container.querySelectorAll("p")).find((element) => + element.textContent?.includes("관련 노드:"), + ); + + expect(summary?.textContent?.replace(/\s+/g, " ").trim()).toBe( + "관련 노드: A, B, C, D, E", + ); + expect(summary?.textContent).not.toContain("F"); + }); +}); diff --git a/frontend/src/components/NetworkGraph.tsx b/frontend/src/components/NetworkGraph.tsx index 5bdfba9fa..03dad7cf7 100644 --- a/frontend/src/components/NetworkGraph.tsx +++ b/frontend/src/components/NetworkGraph.tsx @@ -278,10 +278,15 @@ export default function NetworkGraph() { }, [nodes, edges, nodeMap, edgeMap]); const nodeLabels = useMemo(() => { - return nodes - .map((node) => String(node.label ?? node.id)) - .filter(Boolean) - .slice(0, 5); + const labels: string[] = []; + for (const node of nodes) { + const label = String(node.label ?? node.id); + if (label) { + labels.push(label); + if (labels.length >= 5) break; + } + } + return labels; }, [nodes]); const firstEdge = edges[0] ?? null;