Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions frontend/src/components/NetworkGraph.map-lookup.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";

import { describe, expect, it } from "vitest";

const networkGraphSource = readFileSync(
fileURLToPath(new URL("./NetworkGraph.tsx", import.meta.url)),
"utf8",
);

function sourceBetween(startMarker: string, endMarker: string): string {
const startIndex = networkGraphSource.indexOf(startMarker);
const endIndex = networkGraphSource.indexOf(endMarker, startIndex);

expect(startIndex).toBeGreaterThanOrEqual(0);
expect(endIndex).toBeGreaterThan(startIndex);

return networkGraphSource.slice(startIndex, endIndex);
}

describe("NetworkGraph constant-time selection lookup contract", () => {
it("keeps graph event selection on memoized maps without linear fallback scans", () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 — this contract never mounts the graph. readFileSync + .toContain('edgeMap.get') will stay green if selectNode / selectEdge stop updating the Korean detail or the <select> value.

Keep the source scan if useful as a lint, but add a jsdom case that registers the vis-network handlers and fires mixed numeric/string ids (101, "recipient-1", 7). #1382 adds that coverage.

const edgeSelection = sourceBetween("const selectEdge =", "const selectNode =");
const nodeSelection = sourceBetween("const selectNode =", "const handleEdgeSelection =");

expect(edgeSelection).toContain("edgeMap.get(String(edgeId))");
expect(edgeSelection).not.toContain(".find(");

expect(nodeSelection).toContain("nodeMap.get(String(nodeId))");
expect(nodeSelection).toContain("?? String(nodeId)");
expect(nodeSelection).not.toContain("findNodeLabel(");
expect(nodeSelection).not.toContain(".find(");
});

it("keeps select controls on memoized maps without rescanning nodes or edges", () => {
const graphNodeSelection = sourceBetween(
"const selectGraphNode =",
"const handleSelectFirstRelationship =",
);
const relationshipControl = sourceBetween(
"const handleRelationshipOptionChange =",
"const handleNodeOptionChange =",
);
const nodeControl = sourceBetween(
"const handleNodeOptionChange =",
"const handleZoomGraph =",
);

expect(graphNodeSelection).toContain("nodeMap.get(String(node.id))");
expect(graphNodeSelection).toContain("?? String(node.id)");
expect(graphNodeSelection).not.toContain("findNodeLabel(");
expect(graphNodeSelection).not.toContain(".find(");

expect(relationshipControl).toContain("edgeMap.get(value)");
expect(relationshipControl).not.toContain(".find(");

expect(nodeControl).toContain("nodeInstanceMap.get(value)");
expect(nodeControl).not.toContain(".find(");
});
});
14 changes: 8 additions & 6 deletions frontend/src/components/NetworkGraph.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,8 @@ export default function NetworkGraph() {
const [graphActionStatus, setGraphActionStatus] = useState('그래프 준비 완료');
const [relationshipOptionId, setRelationshipOptionId] = useState('');
const [nodeOptionId, setNodeOptionId] = useState('');
const edgeMap = useMemo(() => new Map(edges.map((e) => [String(e.id), e])), [edges]);
const nodeInstanceMap = useMemo(() => new Map(nodes.map((n) => [String(n.id), n])), [nodes]);
Comment on lines +162 to +163

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 — last-wins Map construction. new Map(edges.map((e) => [String(e.id), e])) and the same pattern for nodeInstanceMap keep the last colliding id. nodeMap above is first-wins (if (!map.has(key))), and the replaced .find() path was also first-wins.

A repeated relationship id therefore opens the later edge while labels still describe the first node pair. Build both maps with first-set-wins (see firstGraphEntryById on #1382) and add a rendered case that repeats rel-shared and asserts 선택된 관계: 김지현 -> 사용자 (메일 2건).

const nodeMap = useMemo(() => {
const map = new Map<string, string>();
for (const node of nodes) {
Expand Down Expand Up @@ -202,7 +204,7 @@ export default function NetworkGraph() {
};

const selectEdge = (edgeId: number | string) => {
const edge = edges.find((candidate) => graphIdEquals(candidate.id, edgeId));
const edge = edgeMap.get(String(edgeId));
if (!edge) return;
setRelationshipOptionId(String(edge.id));
setNodeOptionId('');
Expand All @@ -213,7 +215,7 @@ export default function NetworkGraph() {
const selectNode = (nodeId: number | string) => {
setRelationshipOptionId('');
setNodeOptionId(String(nodeId));
setSelectedGraphDetail(`선택된 노드: ${findNodeLabel(nodes, nodeId)}`);
setSelectedGraphDetail(`선택된 노드: ${nodeMap.get(String(nodeId)) ?? String(nodeId)}`);
setGraphActionStatus('그래프에서 노드를 선택했습니다.');
};

Expand Down Expand Up @@ -262,7 +264,7 @@ export default function NetworkGraph() {
network.destroy();
};
}
}, [nodes, edges, nodeMap]);
}, [nodes, edges, nodeMap, edgeMap, nodeInstanceMap]);

const nodeLabels = useMemo(() => {
return nodes
Expand Down Expand Up @@ -303,7 +305,7 @@ export default function NetworkGraph() {
if (!isGraphId(node.id)) return;
setRelationshipOptionId('');
setNodeOptionId(String(node.id));
setSelectedGraphDetail(`선택된 노드: ${findNodeLabel(nodes, node.id)}`);
setSelectedGraphDetail(`선택된 노드: ${nodeMap.get(String(node.id)) ?? String(node.id)}`);
setGraphActionStatus(status);
networkRef.current?.selectNodes?.([node.id]);
networkRef.current?.fit?.({ nodes: [node.id], animation: false });
Expand All @@ -315,13 +317,13 @@ export default function NetworkGraph() {
};

const handleRelationshipOptionChange = (value: string) => {
const edge = edges.find((candidate) => String(candidate.id) === value);
const edge = edgeMap.get(value);
if (!edge) return;
selectRelationship(edge, '선택한 관계를 열었습니다.');
};

const handleNodeOptionChange = (value: string) => {
const node = nodes.find((candidate) => String(candidate.id) === value);
const node = nodeInstanceMap.get(value);
if (!node) return;
selectGraphNode(node, '선택한 노드를 열었습니다.');
};
Expand Down
20 changes: 20 additions & 0 deletions plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# NetworkGraph constant-time lookup plan

1. Pre-compute `edgeMap` and `nodeInstanceMap` with `useMemo`, and keep the existing `nodeMap` as the authoritative node-label lookup for rendered selections.
- `selectEdge` uses `edgeMap.get(String(edgeId))`.
- `selectNode` uses `nodeMap.get(String(nodeId))` with the node identifier as the no-entry fallback.
- `selectGraphNode` uses `nodeMap.get(String(node.id))` with the node identifier as the no-entry fallback.
- `handleRelationshipOptionChange` uses `edgeMap.get(value)`.
- `handleNodeOptionChange` uses `nodeInstanceMap.get(value)`.
- Selection handlers must not fall back to `Array.prototype.find()` or `findNodeLabel()` scans.

2. Verify the exact branch head from `frontend/` with these commands:

```bash
pnpm test -- src/components/NetworkGraph.test.tsx src/components/NetworkGraph.map-lookup.test.ts
pnpm exec eslint src/components/NetworkGraph.tsx src/components/NetworkGraph.test.tsx src/components/NetworkGraph.map-lookup.test.ts
pnpm typecheck
pnpm build
```

3. Keep the pull request open until the unchanged exact head has terminal-success required checks, all addressed review threads are resolved, and protected-branch review requirements are satisfied without bypass.
Loading