diff --git a/frontend/src/components/NetworkGraph.map-lookup.test.ts b/frontend/src/components/NetworkGraph.map-lookup.test.ts index 3ba76c75c..71f718a31 100644 --- a/frontend/src/components/NetworkGraph.map-lookup.test.ts +++ b/frontend/src/components/NetworkGraph.map-lookup.test.ts @@ -18,7 +18,7 @@ function sourceBetween(startMarker: string, endMarker: string): string { return networkGraphSource.slice(startIndex, endIndex); } -describe("NetworkGraph constant-time selection lookup contract", () => { +describe("NetworkGraph indexed lookup architecture", () => { it("keeps graph event selection on memoized maps without linear fallback scans", () => { const edgeSelection = sourceBetween("const selectEdge =", "const selectNode ="); const nodeSelection = sourceBetween("const selectNode =", "const handleEdgeSelection ="); diff --git a/frontend/src/components/NetworkGraph.option-limits.test.tsx b/frontend/src/components/NetworkGraph.option-limits.test.tsx new file mode 100644 index 000000000..e69a4089c --- /dev/null +++ b/frontend/src/components/NetworkGraph.option-limits.test.tsx @@ -0,0 +1,129 @@ +/* @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(); + +vi.mock("vis-network", () => ({ + Network: vi.fn(function MockNetwork() { + return { + destroy: destroyMock, + fit: vi.fn(), + moveTo: vi.fn(), + off: vi.fn(), + on: vi.fn(), + selectEdges: vi.fn(), + selectNodes: vi.fn(), + }; + }), +})); + +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 display limits", () => { + 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 bounded options while preserving the first five non-empty node labels", async () => { + const nodes = [ + { id: "blank-1", label: "" }, + { id: "node-1", label: "노드 1" }, + { id: "blank-2", label: "" }, + { id: "node-2", label: "노드 2" }, + { id: "node-3", label: "노드 3" }, + { id: "node-4", label: "노드 4" }, + { id: "node-5", label: "노드 5" }, + { id: "node-6", label: "노드 6" }, + { id: "node-7", label: "노드 7" }, + { id: "node-8", label: "노드 8" }, + ]; + const edges = Array.from({ length: 7 }, (_, index) => ({ + id: `edge-${index + 1}`, + from: "node-1", + to: "node-2", + title: `관계 ${index + 1}`, + })); + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.resolve(jsonResponse({ nodes, edges }))), + ); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render(); + }); + await flushAsyncWork(); + + const relationshipSelect = container.querySelector( + 'select[aria-label="관계 선택"]', + ); + const nodeSelect = container.querySelector( + 'select[aria-label="노드 선택"]', + ); + const relationshipValues = Array.from(relationshipSelect?.options ?? []).map( + (option) => option.value, + ); + const nodeValues = Array.from(nodeSelect?.options ?? []).map( + (option) => option.value, + ); + + expect(relationshipValues).toEqual([ + "", + "edge-1", + "edge-2", + "edge-3", + "edge-4", + "edge-5", + ]); + expect(nodeValues).toEqual([ + "", + "blank-1", + "node-1", + "blank-2", + "node-2", + "node-3", + "node-4", + "node-5", + "node-6", + ]); + + const summary = Array.from(container.querySelectorAll("p")).find((element) => + element.textContent?.includes("관련 노드:"), + ); + expect(summary?.textContent).toContain( + "관련 노드: 노드 1, 노드 2, 노드 3, 노드 4, 노드 5", + ); + expect(summary?.textContent).not.toContain("노드 6"); + }); +}); diff --git a/frontend/src/components/NetworkGraph.tsx b/frontend/src/components/NetworkGraph.tsx index f9eb61c71..77bafeb3c 100644 --- a/frontend/src/components/NetworkGraph.tsx +++ b/frontend/src/components/NetworkGraph.tsx @@ -278,27 +278,44 @@ 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 = []; + for (const node of nodes) { + if (labels.length >= 5) break; + const label = String(node.label ?? node.id); + if (label) labels.push(label); + } + return labels; }, [nodes]); const firstEdge = edges[0] ?? null; const relationshipOptions = useMemo(() => { - return Array.from(edgeMap.values()).slice(0, 5).map((edge, index) => ({ - edge, - id: String(edge.id), - label: `관계 ${index + 1}: ${describeEdge(edge, nodeMap)}`, - })); + const options = []; + let index = 0; + for (const edge of edgeMap.values()) { + if (index >= 5) break; + options.push({ + edge, + id: String(edge.id), + label: `관계 ${index + 1}: ${describeEdge(edge, nodeMap)}`, + }); + index++; + } + return options; }, [edgeMap, nodeMap]); const nodeOptions = useMemo(() => { - return Array.from(nodeInstanceMap.values()).slice(0, 8).map((node) => ({ - id: String(node.id), - label: `노드: ${String(node.label ?? node.id)}`, - node, - })); + const options = []; + let count = 0; + for (const node of nodeInstanceMap.values()) { + if (count >= 8) break; + options.push({ + id: String(node.id), + label: `노드: ${String(node.label ?? node.id)}`, + node, + }); + count++; + } + return options; }, [nodeInstanceMap]); const selectRelationship = (edge: Edge, status: string) => { diff --git a/requirements-strix-ci-hashes.txt b/requirements-strix-ci-hashes.txt index eaa2ad04f..268d89992 100644 --- a/requirements-strix-ci-hashes.txt +++ b/requirements-strix-ci-hashes.txt @@ -142,6 +142,7 @@ anyio==4.14.2 \ # google-genai # gql # httpx + # httpx2 # mcp # openai # sse-starlette @@ -849,6 +850,7 @@ h11==0.16.0 \ --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86 # via # httpcore + # httpcore2 # uvicorn hf-xet==1.5.1 \ --hash=sha256:0c97106032ef70467b4f6bc2d0ccc266d7613ee076afc56516c502f87ce1c4a6 \ @@ -881,6 +883,10 @@ httpcore==1.0.9 \ --hash=sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 \ --hash=sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8 # via httpx +httpcore2==2.12.0 \ + --hash=sha256:7e04258ce01013d7d615e5b910a3b27fac937d7a95038227e79652b4ba3b4ceb \ + --hash=sha256:9293522bba0aa7c4c8e9e3f040c16575bd8868e155a77fa30c7a9085a5eae648 + # via httpx2 httpx==0.28.1 \ --hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \ --hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad @@ -894,6 +900,10 @@ httpx-sse==0.4.3 \ --hash=sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc \ --hash=sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d # via mcp +httpx2==2.12.0 \ + --hash=sha256:7631fe9887a8a2275f4a2540e053aa670fcc50742864a9ae7c66e609fdcf12cf \ + --hash=sha256:cc8b6eecb8661c146b8f89a60e97456ee086e91a784ed31ac450c3a9e613dd36 + # via -r requirements-strix-ci.txt huggingface-hub==1.23.0 \ --hash=sha256:b1d604788f5adc7f0eb246e03e0ec19011ca06e38400218c347dccc3dffa64a2 \ --hash=sha256:c04997fb8bbdace1e57b7703d30ed7678af51f70d00d241819ff411b92ae9a88 @@ -904,6 +914,7 @@ idna==3.18 \ # via # anyio # httpx + # httpx2 # requests # yarl importlib-metadata==8.9.0 \ @@ -2103,6 +2114,12 @@ tqdm==4.68.4 \ # via # huggingface-hub # openai +truststore==0.10.4 \ + --hash=sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301 \ + --hash=sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981 + # via + # httpcore2 + # httpx2 types-requests==2.33.0.20260712 \ --hash=sha256:2141b67ab534a5c5cd2dac5034f2a35f42e699c5bf185eee608c5246a069d7fb \ --hash=sha256:de027e28c171d3da529689cbfa023b0b4eab188c8dfa22fd834eebd2cee6e7bb diff --git a/requirements-strix-ci.txt b/requirements-strix-ci.txt index a1faaf6a4..58506695d 100644 --- a/requirements-strix-ci.txt +++ b/requirements-strix-ci.txt @@ -3,3 +3,4 @@ google-cloud-aiplatform==1.160.0 cryptography==50.0.0 protobuf==6.33.6 python-multipart==0.0.32 +httpx2