diff --git a/graphify/exporters/html.py b/graphify/exporters/html.py index e4be7f1a3..14f781249 100644 --- a/graphify/exporters/html.py +++ b/graphify/exporters/html.py @@ -157,8 +157,9 @@ const nodesDS = new vis.DataSet(RAW_NODES.map((n, i) => ({{ // graphs. `i` is the map index — physics still settles small graphs identically. x: 30 * Math.sqrt(i) * Math.cos(i * 2.4), y: 30 * Math.sqrt(i) * Math.sin(i * 2.4), - _community: n.community, _community_name: n.community_name, - _source_file: n.source_file, _file_type: n.file_type, _degree: n.degree, + community: n.community, community_name: n.community_name, + source_file: n.source_file, file_type: n.file_type, degree: n.degree, + member_count: n.member_count, }}))); const edgesDS = new vis.DataSet(RAW_EDGES.map((e, i) => ({{ @@ -210,14 +211,22 @@ function showInfo(nodeId) {{ const color = nb ? nb.color.background : '#555'; return `${{esc(nb ? nb.label : nid)}}`; }}).join(''); - document.getElementById('info-content').innerHTML = ` -
${{esc(n.label)}}
-
Type: ${{esc(n._file_type || 'unknown')}}
-
Community: ${{esc(n._community_name)}}
-
Source: ${{esc(n._source_file || '-')}}
-
Degree: ${{n._degree}}
- ${{neighborIds.length ? `
Neighbors (${{neighborIds.length}})
${{neighborItems}}
` : ''}} - `; + const isMetaNode = !n.file_type && !n.source_file; + let html = `
${{esc(n.label)}}
`; + if (!isMetaNode) {{ + html += `
Type: ${{esc(n.file_type || 'unknown')}}
`; + }} + html += `
Community: ${{esc(n.community_name || '')}}
`; + if (!isMetaNode) {{ + html += `
Source: ${{esc(n.source_file || '-')}}
`; + }} else if (n.member_count !== undefined && n.member_count !== null) {{ + html += `
Members: ${{esc(n.member_count)}}
`; + }} + html += `
Degree: ${{n.degree}}
`; + if (neighborIds.length) {{ + html += `
Neighbors (${{neighborIds.length}})
${{neighborItems}}
`; + }} + document.getElementById('info-content').innerHTML = html; }} function focusNode(nodeId) {{ @@ -541,6 +550,8 @@ def to_html( "file_type": data.get("file_type", ""), "degree": deg, } + if member_counts: + node["member_count"] = member_counts.get(cid, len(communities.get(cid, []))) # Conditional learning fields — only present for annotated nodes, so # un-annotated output keeps the exact pre-feature node dict shape. entry = learning_overlay.get(str(node_id)) if learning_overlay else None diff --git a/tests/test_export.py b/tests/test_export.py index 725f45843..d05bdbb28 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -1197,3 +1197,179 @@ def test_to_html_spiral_seed_uses_a_real_map_index(): "spiral seed references `i` but the node map has no index param " "-> ReferenceError: i is not defined (#3699)" ) + + +def test_to_html_canonical_node_schema_mapping(tmp_path): + """#3914: nodesDS and showInfo must use canonical un-prefixed field names + matching RAW_NODES and Graphify's node schema (file_type, source_file, + community_name, degree), eliminating the underscore-prefixed mapping.""" + import networkx as nx + G = nx.Graph() + G.add_node("n_sample", label="SampleNode", file_type="code", source_file="src/sample.py") + out = tmp_path / "graph.html" + to_html(G, {0: ["n_sample"]}, str(out), community_labels={0: "Core"}) + content = out.read_text(encoding="utf-8") + + # RAW_NODES carries canonical un-prefixed fields + nodes = {n["id"]: n for n in _vis_nodes_from_html(content)} + assert nodes["n_sample"]["file_type"] == "code" + assert nodes["n_sample"]["source_file"] == "src/sample.py" + assert nodes["n_sample"]["community_name"] == "Core" + assert "degree" in nodes["n_sample"] + + # nodesDS dataset mapping must preserve canonical field names + assert "file_type: n.file_type" in content + assert "source_file: n.source_file" in content + assert "community_name: n.community_name" in content + assert "degree: n.degree" in content + + # nodesDS must not contain old underscore-prefixed mappings + assert "_file_type: n.file_type" not in content + assert "_source_file: n.source_file" not in content + assert "_community_name: n.community_name" not in content + assert "_degree: n.degree" not in content + assert "_community: n.community" not in content + + # showInfo must consume canonical fields directly + assert "n.file_type" in content + assert "n.source_file" in content + assert "n.community_name" in content + assert "n.degree" in content + assert "n._file_type" not in content + assert "n._source_file" not in content + assert "n._community_name" not in content + assert "n._degree" not in content + + +def _run_node_info_harness(html_content: str, target_node_id: str) -> str: + """Helper to execute graph.html's client script in Node.js and return #info-content innerHTML.""" + import re + import shutil + import subprocess + node = shutil.which("node") + if node is None: + import pytest + pytest.skip("node not available") + + m = re.search(r"\s*