Ctrl K
ComunidadGuías…

tests/test_export.py

Rama v8 · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import json
import math
import re
import tempfile
from pathlib import Path
from graphify.build import build_from_json
from graphify.cluster import cluster
from graphify.export import to_json, to_cypher, to_graphml, to_html, to_canvas, to_obsidian

FIXTURES = Path(__file__).parent / "fixtures"

def make_graph():
    return build_from_json(json.loads((FIXTURES / "extraction.json").read_text()))

def test_to_json_creates_file():
    G = make_graph()
    communities = cluster(G)
    with tempfile.TemporaryDirectory() as tmp:
        out = Path(tmp) / "graph.json"
        to_json(G, communities, str(out))
        assert out.exists()

def test_to_json_valid_json():
    G = make_graph()
    communities = cluster(G)
    with tempfile.TemporaryDirectory() as tmp:
        out = Path(tmp) / "graph.json"
        to_json(G, communities, str(out))
        data = json.loads(out.read_text())
        assert "nodes" in data
        assert "links" in data


def test_to_json_includes_graph_schema_metadata(tmp_path):
    G = make_graph()
    out = tmp_path / "graph.json"

    assert to_json(G, {}, str(out))

    graph_metadata = json.loads(out.read_text())["graph"]
    assert graph_metadata["schema_version"] == 1
    assert graph_metadata["graphify_version"]


def test_to_json_nodes_have_community():
    G = make_graph()
    communities = cluster(G)
    with tempfile.TemporaryDirectory() as tmp:
        out = Path(tmp) / "graph.json"
        to_json(G, communities, str(out))
        data = json.loads(out.read_text())
        for node in data["nodes"]:
            assert "community" in node


def test_to_json_sorts_graph_collections_across_insertion_order(tmp_path):
    import networkx as nx

    nodes = [("b", {"label": "Beta"}), ("a", {"label": "Alpha"}), ("c", {"label": "Gamma"})]
    links = [
        ("b", "c", {"relation": "uses", "_src": "b", "_tgt": "c"}),
        ("a", "b", {"relation": "calls", "_src": "a", "_tgt": "b"}),
    ]
    hyperedges = [
        {"id": "h2", "nodes": ["b", "c"]},
        {"id": "h1", "nodes": ["a", "b"]},
    ]

    def make_graph(reverse=False):
        graph = nx.Graph()
        graph.add_nodes_from(reversed(nodes) if reverse else nodes)
        graph.add_edges_from(reversed(links) if reverse else links)
        graph.graph["hyperedges"] = list(reversed(hyperedges)) if reverse else hyperedges
        return graph

    outputs = [tmp_path / "first.json", tmp_path / "second.json"]
    for output, reverse in zip(outputs, (False, True)):
        assert to_json(
            make_graph(reverse),
            {0: ["a", "b"], 1: ["c"]},
            str(output),
            built_at_commit="fixed",
        )

    assert outputs[0].read_bytes() == outputs[1].read_bytes()


def test_to_json_field_order_stable_across_read_rebuild(tmp_path):
    """graph.json survives a build -> write -> read-back -> write round-trip
    byte-for-byte. node_link_data always appends the node key (`id`) last, so a
    node whose `id` was an inline attribute on a cold build lands mid-dict, while
    the same node after build_from_json consumes `id` as the pure node key lands
    last — identical values, churned field order. Emitting a canonical key order
    keeps the two serializations identical. Regression guard: the earlier
    determinism test only varied insertion order within one build and missed
    this."""
    extraction = {
        "nodes": [
            {"id": "a_foo", "label": "foo", "file_type": "code", "source_file": "a.py"},
            {"id": "b_bar", "label": "bar", "file_type": "code", "source_file": "b.py"},
            {"id": "c_baz", "label": "baz", "file_type": "code", "source_file": "c.py"},
        ],
        "edges": [
            {"source": "a_foo", "target": "b_bar", "relation": "calls",
             "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "a.py"},
            {"source": "b_bar", "target": "c_baz", "relation": "references",
             "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "b.py"},
        ],
        "hyperedges": [],
    }
    communities = {0: ["a_foo", "b_bar", "c_baz"]}

    first = tmp_path / "first.json"
    to_json(build_from_json(extraction), communities, str(first),
            built_at_commit="fixed", force=True)
    reread = json.loads(first.read_text())

    second = tmp_path / "second.json"
    to_json(build_from_json(reread), communities, str(second),
            built_at_commit="fixed", force=True)

    # Byte-identity is the strongest statement of "no cosmetic churn".
    assert first.read_bytes() == second.read_bytes()

    data = json.loads(first.read_text())
    # `id` leads every node; source/target lead every link — the identity-first
    # order node_link_data does not guarantee on its own.
    for node in data["nodes"]:
        assert list(node.keys())[0] == "id"
    for link in data["links"]:
        assert list(link.keys())[:2] == ["source", "target"]
    # Endpoints are never swapped by the reordering.
    endpoints = sorted((e["source"], e["target"]) for e in data["links"])
    assert endpoints == [("a_foo", "b_bar"), ("b_bar", "c_baz")]


def test_to_json_field_order_stable_with_non_ascii_labels(tmp_path):
    """The byte-identity guarantee must hold with non-ASCII labels — the fix's
    round-trip stability implicitly relies on the ensure_ascii write path, and a
    reordered dict with escaped-unicode values must still serialize identically."""
    extraction = {
        "nodes": [
            {"id": "a_cafe", "label": "café", "file_type": "code", "source_file": "a.py"},
            {"id": "b_ja", "label": "日本語クラス", "file_type": "code", "source_file": "b.py"},
        ],
        "edges": [
            {"source": "a_cafe", "target": "b_ja", "relation": "references",
             "confidence": "INFERRED", "confidence_score": 0.55, "source_file": "a.py"},
        ],
        "hyperedges": [],
    }
    communities = {0: ["a_cafe", "b_ja"]}
    first = tmp_path / "first.json"
    to_json(build_from_json(extraction), communities, str(first),
            built_at_commit="fixed", force=True)
    reread = json.loads(first.read_text())
    second = tmp_path / "second.json"
    to_json(build_from_json(reread), communities, str(second),
            built_at_commit="fixed", force=True)
    assert first.read_bytes() == second.read_bytes(), "non-ASCII round-trip churned field order"
    # the reorder preserves the non-ASCII value
    labels = {n["id"]: n.get("label") for n in json.loads(first.read_text())["nodes"]}
    assert labels.get("b_ja") == "日本語クラス"


def test_to_json_commit_fallback_uses_output_repo_not_cwd(tmp_path, monkeypatch):
    # Without an explicit built_at_commit, provenance must come from the repo
    # the graph is written into, not from whatever repo the shell happens to
    # be in — running `graphify extract <target>` from another repo's root
    # used to stamp the invoker's HEAD into the target's graph.json.
    import subprocess
    import networkx as nx

    def git(cwd, *args):
        subprocess.run(
            ["git", "-c", "user.email=t@t", "-c", "user.name=t", *args],
            cwd=cwd, check=True, capture_output=True,
        )

    target = tmp_path / "target"
    (target / "graphify-out").mkdir(parents=True)
    git(target, "init")
    git(target, "commit", "--allow-empty", "-m", "target")
    target_head = subprocess.run(
        ["git", "rev-parse", "HEAD"], cwd=target, check=True,
        capture_output=True, text=True,
    ).stdout.strip()

    invoker = tmp_path / "invoker"
    invoker.mkdir()
    git(invoker, "init")
    git(invoker, "commit", "--allow-empty", "-m", "invoker")
    monkeypatch.chdir(invoker)

    G = nx.Graph()
    G.add_node("n1", label="n1")
    out = target / "graphify-out" / "graph.json"
    assert to_json(G, {0: ["n1"]}, str(out), force=True)
    assert json.loads(out.read_text())["built_at_commit"] == target_head


def test_to_cypher_creates_file():
    G = make_graph()
    with tempfile.TemporaryDirectory() as tmp:
        out = Path(tmp) / "cypher.txt"
        to_cypher(G, str(out))
        assert out.exists()

def test_to_cypher_contains_merge_statements():
    G = make_graph()
    with tempfile.TemporaryDirectory() as tmp:
        out = Path(tmp) / "cypher.txt"
        to_cypher(G, str(out))
        content = out.read_text()
        assert "MERGE (n:Code {id: 'n_transformer'}) SET n.label = 'Transformer';" in content
        assert "{id: 'n_transformer', label:" not in content

        G.nodes["n_transformer"]["label"] = "Renamed Transformer"
        to_cypher(G, str(out))
        refreshed = out.read_text()
        assert "MERGE (n:Code {id: 'n_transformer'}) SET n.label = 'Renamed Transformer';" in refreshed
        assert "{id: 'n_transformer', label:" not in refreshed
        assert "SET n.label = 'Transformer';" not in refreshed

def test_to_graphml_creates_file():
    G = make_graph()
    communities = cluster(G)
    with tempfile.TemporaryDirectory() as tmp:
        out = Path(tmp) / "graph.graphml"
        to_graphml(G, communities, str(out))
        assert out.exists()

def test_to_graphml_valid_xml():
    G = make_graph()
    communities = cluster(G)
    with tempfile.TemporaryDirectory() as tmp:
        out = Path(tmp) / "graph.graphml"
        to_graphml(G, communities, str(out))
        content = out.read_text()
        assert "<graphml" in content
        assert "<node" in content

def test_to_graphml_has_community_attribute():
    G = make_graph()
    communities = cluster(G)
    with tempfile.TemporaryDirectory() as tmp:
        out = Path(tmp) / "graph.graphml"
        to_graphml(G, communities, str(out))
        content = out.read_text()
        assert "community" in content

def test_to_graphml_tolerates_none_attribute_values():
    """nx.write_graphml raises ValueError on a None attribute value; to_graphml
    must coerce None -> "" so a node/edge with a null field still exports (#1502)."""
    G = make_graph()
    communities = cluster(G)
    # Inject a None-valued attribute on one node and one edge.
    a_node = next(iter(G.nodes()))
    G.nodes[a_node]["nullable_field"] = None
    if G.number_of_edges():
        u, v = next(iter(G.edges()))
        G.edges[u, v]["nullable_field"] = None
    with tempfile.TemporaryDirectory() as tmp:
        out = Path(tmp) / "graph.graphml"
        to_graphml(G, communities, str(out))  # must not raise
        content = out.read_text()
        assert "<graphml" in content

def test_to_graphml_tolerates_dict_and_list_attribute_values():
    """nx.write_graphml only accepts scalars; a dict/list attribute (per-node
    metadata, or the graph-level hyperedges list) used to crash the whole export.
    to_graphml must JSON-serialize them across graph/node/edge scopes (#1831)."""
    import networkx as nx
    G = make_graph()
    communities = cluster(G)
    a_node = next(iter(G.nodes()))
    G.nodes[a_node]["metadata"] = {"kind": "file", "size": 12}
    G.nodes[a_node]["tags"] = ["x", "y"]
    if G.number_of_edges():
        u, v = next(iter(G.edges()))
        G.edges[u, v]["ctx"] = {"k": "v"}
    G.graph["hyperedges"] = [{"nodes": [a_node], "label": "h"}]
    with tempfile.TemporaryDirectory() as tmp:
        out = Path(tmp) / "graph.graphml"
        to_graphml(G, communities, str(out))  # must not raise
        H = nx.read_graphml(str(out))
        assert json.loads(H.nodes[a_node]["metadata"]) == {"kind": "file", "size": 12}
        assert json.loads(H.nodes[a_node]["tags"]) == ["x", "y"]
        assert json.loads(H.graph["hyperedges"]) == [{"nodes": [a_node], "label": "h"}]
        assert not (Path(tmp) / "graph.graphml.tmp").exists()


def test_to_graphml_exports_multigraph_parallel_edges():
    """graph.json is loaded as a MultiGraph when it carries parallel edges
    (#4500), e.g. `imports` and `calls` between the same two nodes. The edge
    coercion loop indexed H.edges[u, v], which needs a key on a MultiGraph, so
    the export crashed; every parallel edge must reach the file, coerced."""
    import networkx as nx
    G = nx.MultiGraph()
    G.add_node("a", label="A")
    G.add_node("b", label="B")
    G.add_edge("a", "b", relation="imports", confidence="EXTRACTED", note=None)
    G.add_edge("a", "b", relation="calls", confidence="INFERRED", ctx={"k": "v"})
    with tempfile.TemporaryDirectory() as tmp:
        out = Path(tmp) / "g.graphml"
        to_graphml(G, {0: ["a", "b"]}, str(out))
        H = nx.read_graphml(str(out))
        assert H.number_of_edges() == 2
        edges = [d for _, _, d in H.edges(data=True)]
        assert sorted(d["relation"] for d in edges) == ["calls", "imports"]
        calls = next(d for d in edges if d["relation"] == "calls")
        imports = next(d for d in edges if d["relation"] == "imports")
        assert json.loads(calls["ctx"]) == {"k": "v"}
        assert imports["note"] == ""


def test_to_graphml_preserves_native_scalar_types():
    """Coercion must leave GraphML-native scalars (int/float/bool/str) untouched,
    only stringifying non-scalars (#1831)."""
    import networkx as nx
    G = nx.Graph()
    G.add_node("a", count=3, ratio=0.5, flag=True, name="x")
    G.add_node("b")
    G.add_edge("a", "b")
    with tempfile.TemporaryDirectory() as tmp:
        out = Path(tmp) / "g.graphml"
        to_graphml(G, {0: ["a", "b"]}, str(out))
        H = nx.read_graphml(str(out))
        assert H.nodes["a"]["count"] == 3
        assert H.nodes["a"]["ratio"] == 0.5
        assert H.nodes["a"]["flag"] is True
        assert H.nodes["a"]["name"] == "x"


def test_to_html_creates_file():
    G = make_graph()
    communities = cluster(G)
    with tempfile.TemporaryDirectory() as tmp:
        out = Path(tmp) / "graph.html"
        to_html(G, communities, str(out))
        assert out.exists()

def test_to_html_contains_visjs():
    G = make_graph()
    communities = cluster(G)
    with tempfile.TemporaryDirectory() as tmp:
        out = Path(tmp) / "graph.html"
        to_html(G, communities, str(out))
        content = out.read_text()
        assert "vis-network" in content



def test_to_html_title_uses_portable_path_not_host_absolute():
    """#2598 / #433: <title> must not embed the generator host absolute path."""
    import re

    G = make_graph()
    communities = cluster(G)
    with tempfile.TemporaryDirectory() as tmp:
        userish = Path(tmp) / "Users" / "mike" / "proj" / "graphify-out" / "graph.html"
        userish.parent.mkdir(parents=True)
        to_html(G, communities, str(userish))
        html = userish.read_text(encoding="utf-8")
    m = re.search(r"<title>(.*?)</title>", html)
    assert m, "expected a <title> tag"
    title = m.group(1)
    assert title.startswith("graphify - ")
    label = title[len("graphify - "):]
    assert "mike" not in label
    assert "Users" not in label
    assert not label.startswith("/")
    assert "graphify-out/graph.html" in label or label == "graph.html"


def test_html_document_title_helper_windows_and_relative():
    from graphify.exporters.html import _html_document_title

    assert _html_document_title(r"C:\Users\mike\proj\graphify-out\graph.html") == "graphify-out/graph.html"
    assert _html_document_title("/home/u/proj/graphify-out/graph.html") == "graphify-out/graph.html"
    assert _html_document_title("graphify-out/graph.html") == "graphify-out/graph.html"
    assert _html_document_title("/tmp/only/graph.html") == "graph.html"

def test_to_html_neighbor_links_have_no_inline_onclick_xss():
    """#1838: neighbor links dropped an unescaped JSON.stringify(nid) into a
    quoted inline onclick — which broke every link (the value's own quotes
    truncated the attribute) and let a node id/label containing a double-quote
    (from a document or a scraped `graphify add` URL) inject a live event handler
    into the local report (stored XSS). The template must instead carry the id in
    an escaped data attribute and dispatch via one delegated listener."""
    G = make_graph()
    communities = cluster(G)
    with tempfile.TemporaryDirectory() as tmp:
        out = Path(tmp) / "graph.html"
        to_html(G, communities, str(out))
        html = out.read_text()
    # The vulnerable inline handler is gone entirely...
    assert 'onclick="focusNode(' not in html
    assert "JSON.stringify(nid)" not in html
    # ...replaced by an escaped data attribute + a single delegated listener.
    assert 'data-nid="${esc(nid)}"' in html
    assert "closest('.neighbor-link')" in html


def test_to_html_pins_visjs_version_with_sri():
    """vis-network script tag must use a pinned versioned URL with a sha384
    Subresource Integrity hash and crossorigin=anonymous. Without this,
    a compromised CDN could ship arbitrary JavaScript into every rendered
    graph viewer. The hash was verified against the upstream file at
    https://unpkg.com/vis-network@9.1.6/standalone/umd/vis-network.min.js
    (sha384-Ux6phic9PEHJ38YtrijhkzyJ8yQlH8i/+buBR8s3mAZOJrP1gwyvAcIYl3GWtpX1).
    Bumping the vis-network version MUST update both the URL and the hash.
    """
    G = make_graph()
    communities = cluster(G)
    with tempfile.TemporaryDirectory() as tmp:
        out = Path(tmp) / "graph.html"
        to_html(G, communities, str(out))
        content = out.read_text()

    # Versioned URL — unversioned `vis-network/standalone/...` is rejected.
    assert "vis-network@9.1.6/standalone/umd/vis-network.min.js" in content
    assert "https://unpkg.com/vis-network/standalone" not in content

    # SRI integrity attribute pinning the known-good hash.
    assert 'integrity="sha384-Ux6phic9PEHJ38YtrijhkzyJ8yQlH8i/+buBR8s3mAZOJrP1gwyvAcIYl3GWtpX1"' in content

    # crossorigin="anonymous" is required for SRI on cross-origin scripts.
    assert 'crossorigin="anonymous"' in content

def test_to_html_contains_search():
    G = make_graph()
    communities = cluster(G)
    with tempfile.TemporaryDirectory() as tmp:
        out = Path(tmp) / "graph.html"
        to_html(G, communities, str(out))
        content = out.read_text()
        assert "search" in content.lower()

def test_to_html_contains_legend_with_labels():
    G = make_graph()
    communities = cluster(G)
    labels = {cid: f"Group {cid}" for cid in communities}
    with tempfile.TemporaryDirectory() as tmp:
        out = Path(tmp) / "graph.html"
        to_html(G, communities, str(out), community_labels=labels)
        content = out.read_text()
        assert "Group 0" in content

def test_to_html_contains_nodes_and_edges():
    G = make_graph()
    communities = cluster(G)
    with tempfile.TemporaryDirectory() as tmp:
        out = Path(tmp) / "graph.html"
        to_html(G, communities, str(out))
        content = out.read_text()
        assert "RAW_NODES" in content
        assert "RAW_EDGES" in content


def test_to_html_member_counts_accepted():
    """to_html accepts member_counts without raising."""
    G = make_graph()
    communities = cluster(G)
    member_counts = {cid: len(members) for cid, members in communities.items()}
    with tempfile.TemporaryDirectory() as tmp:
        out = Path(tmp) / "graph.html"
        to_html(G, communities, str(out), member_counts=member_counts)
        assert out.exists()


def _vis_nodes_from_html(content: str) -> list:
    """Extract the RAW_NODES JSON array embedded in the generated HTML."""
    m = re.search(r"const RAW_NODES = (\[.*?\]);", content, re.DOTALL)
    assert m, "RAW_NODES not found in HTML"
    return json.loads(m.group(1).replace("<\\/", "</"))


def _vis_edges_from_html(content: str) -> list:
    """Extract the RAW_EDGES JSON array embedded in the generated HTML."""
    m = re.search(r"const RAW_EDGES = (\[.*?\]);", content, re.DOTALL)
    assert m, "RAW_EDGES not found in HTML"
    return json.loads(m.group(1).replace("<\\/", "</"))


def test_to_html_annotated_node_gets_learning_status_and_ring():
    """A node with an overlay entry gets learning_status + learning_stale fields,
    a status-colored ring (border), and a Lesson line in its hover title."""
    G = make_graph()
    communities = cluster(G)
    overlay = {
        "n_transformer": {"status": "preferred", "uses": 3, "score": 2.4,
                          "stale": False, "neg": 0},
    }
    with tempfile.TemporaryDirectory() as tmp:
        out = Path(tmp) / "graph.html"
        to_html(G, communities, str(out), learning_overlay=overlay)
        content = out.read_text()
    nodes = {n["id"]: n for n in _vis_nodes_from_html(content)}
    ann = nodes["n_transformer"]
    assert ann["learning_status"] == "preferred"
    assert ann["learning_stale"] is False
    assert ann["color"]["border"] == "#22c55e"  # green ring for preferred
    assert ann.get("borderWidth") == 3
    assert "Lesson: preferred source" in ann["title"]
    # An un-annotated node carries no learning fields.
    other = next(n for nid, n in nodes.items() if nid != "n_transformer")
    assert "learning_status" not in other
    assert "learning_stale" not in other


def test_to_html_contested_stale_node_gets_dashed_desaturated_ring():
    G = make_graph()
    communities = cluster(G)
    overlay = {
        "n_transformer": {"status": "contested", "uses": 2, "neg": 1,
                          "verdict": "dead end", "stale": True},
    }
    with tempfile.TemporaryDirectory() as tmp:
        out = Path(tmp) / "graph.html"
        to_html(G, communities, str(out), learning_overlay=overlay)
        content = out.read_text()
    ann = {n["id"]: n for n in _vis_nodes_from_html(content)}["n_transformer"]
    assert ann["learning_status"] == "contested"
    assert ann["learning_stale"] is True
    assert ann["color"]["border"] == "#9ca3af"  # desaturated when stale
    assert ann["shapeProperties"]["borderDashes"] == [4, 4]
    assert "code changed" in ann["title"]


def test_to_html_unannotated_identical_to_pre_feature():
    """With no overlay, the HTML is byte-identical whether learning_overlay is
    omitted or passed empty — no learning fields leak into the un-annotated render."""
    G = make_graph()
    communities = cluster(G)
    with tempfile.TemporaryDirectory() as tmp:
        a = Path(tmp) / "a.html"
        b = Path(tmp) / "b.html"
        to_html(G, communities, str(a))
        to_html(G, communities, str(b), learning_overlay={})
        # Output path appears in the title, so compare with paths normalized out.
        ca = a.read_text().replace("a.html", "X.html")
        cb = b.read_text().replace("b.html", "X.html")
    assert ca == cb
    assert "learning_status" not in ca


def test_to_html_tooltips_preserve_special_characters_without_html_entities(tmp_path):
    """Issue #3664: Node and edge hover tooltips (vis-network title) must preserve
    raw characters (&, ', ", <, >) without HTML entity escaping (&amp;, &#x27;, etc.)
    because vis-network renders title via innerText rather than innerHTML."""
    import networkx as nx
    G = nx.Graph()
    G.add_node("n1", label="B-Roll & Filming", source_file="src/a.py", community=0)
    G.add_node("n2", label="Anna's content batching schedule", source_file="src/b.py", community=0)
    G.add_node("n3", label='<Widget prop="value" & count > 0>', source_file="src/c.py", community=0)
    G.add_edge("n1", "n2", relation="reviews & approves", confidence="EXTRACTED")
    G.add_edge("n2", "n3", relation="calls <indirect>", confidence="INFERRED")

    labels = {0: "Team A & Team B"}
    out = tmp_path / "graph.html"
    to_html(G, {0: ["n1", "n2", "n3"]}, str(out), community_labels=labels)
    content = out.read_text(encoding="utf-8")

    nodes = {n["id"]: n for n in _vis_nodes_from_html(content)}
    assert nodes["n1"]["title"] == "B-Roll & Filming"
    assert "&amp;" not in nodes["n1"]["title"]

    assert nodes["n2"]["title"] == "Anna's content batching schedule"
    assert "&#x27;" not in nodes["n2"]["title"]
    assert "&#39;" not in nodes["n2"]["title"]

    assert nodes["n3"]["title"] == '<Widget prop="value" & count > 0>'
    for entity in ("&lt;", "&gt;", "&quot;", "&amp;"):
        assert entity not in nodes["n3"]["title"]

    edges = _vis_edges_from_html(content)
    edge_map = {(e["from"], e["to"]): e for e in edges}
    e1 = edge_map[("n1", "n2")]
    assert e1["title"] == "reviews & approves [EXTRACTED]"
    assert "&amp;" not in e1["title"]

    e2 = edge_map[("n2", "n3")]
    assert e2["title"] == "calls <indirect> [INFERRED]"
    assert "&lt;" not in e2["title"]
    assert "&gt;" not in e2["title"]

    # In contrast, legend items are injected into innerHTML and MUST remain HTML-escaped.
    assert "Team A &amp; Team B" in content


def test_to_html_learning_overlay_tooltip_preserves_special_characters(tmp_path):
    """Issue #3664: Node with learning overlay preserves special characters in both
    the label and lesson text without HTML entity escaping."""
    import networkx as nx
    G = nx.Graph()
    G.add_node("n1", label="Anna's & Bob's <Pipeline>", source_file="src/a.py", community=0)
    overlay = {
        "n1": {"status": "contested", "uses": 2, "neg": 1, "stale": True}
    }

    out = tmp_path / "graph.html"
    to_html(G, {0: ["n1"]}, str(out), learning_overlay=overlay)
    content = out.read_text(encoding="utf-8")

    nodes = {n["id"]: n for n in _vis_nodes_from_html(content)}
    title = nodes["n1"]["title"]
    assert "Anna's & Bob's <Pipeline>\n" in title
    assert "Lesson: contested (useful 2 / dead-end 1) [code changed — re-verify]" in title
    for entity in ("&amp;", "&#x27;", "&#39;", "&lt;", "&gt;"):
        assert entity not in title


def test_to_canvas_file_paths_relative_to_vault():
    """Node file paths in canvas must be vault-root-relative (just fname.md), not hardcoded."""
    G = make_graph()
    communities = cluster(G)
    with tempfile.TemporaryDirectory() as tmp:
        out = Path(tmp) / "graph.canvas"
        to_canvas(G, communities, str(out))
        data = json.loads(out.read_text())
        file_nodes = [n for n in data["nodes"] if n.get("type") == "file"]
        assert file_nodes, "canvas should contain file nodes"
        for node in file_nodes:
            assert "/" not in node["file"], f"file path should not contain '/': {node['file']}"
            assert node["file"].endswith(".md")


def test_to_canvas_no_communities_still_populates():
    """#1324: empty communities (e.g. --no-cluster builds) on a populated graph
    must NOT produce the 32-byte empty `{"nodes": [], "edges": []}` shell."""
    G = make_graph()
    with tempfile.TemporaryDirectory() as tmp:
        out = Path(tmp) / "graph.canvas"
        to_canvas(G, {}, str(out))  # no community data — the bug condition
        data = json.loads(out.read_text())
        assert len(data["nodes"]) >= G.number_of_nodes()
        assert len(data["edges"]) >= 1
        assert out.stat().st_size > 32


def test_to_canvas_node_grid_matches_box_columns():
    """#1452: a community's node cards are laid out in the same ceil(sqrt(n))-column
    grid the group box is sized for. Previously the box width assumed sqrt(n)
    columns while the placement loop hardcoded 3, so any community bigger than ~9
    rendered as a cramped 3-wide strip filling only part of an over-wide box.
    Covers a perfect square (25 -> 5x5) and a non-square count (10 -> 4 cols, a
    partial last row) so both the column count and the row count are pinned."""
    for n in (10, 25):
        G = build_from_json({
            "nodes": [
                {"id": f"n{i}", "label": f"sym_{i:02d}", "file_type": "code", "source_file": "a.py"}
                for i in range(n)
            ],
            "edges": [],
        })
        communities = {0: [f"n{i}" for i in range(n)]}
        with tempfile.TemporaryDirectory() as tmp:
            out = Path(tmp) / "graph.canvas"
            to_canvas(G, communities, str(out))
            data = json.loads(out.read_text())

        group = next(g for g in data["nodes"] if g.get("type") == "group")
        cards = [c for c in data["nodes"] if c.get("type") == "file"]
        assert len(cards) == n, f"n={n}"

        # Cards occupy the ceil(sqrt(n))-column / ceil(n/cols)-row grid the box is
        # sized for — not the old fixed 3 columns, which spread cards across far
        # more rows (the load-bearing checks: distinct column/row positions).
        expected_cols = math.ceil(math.sqrt(n))
        expected_rows = math.ceil(n / expected_cols)
        distinct_x = len({c["x"] for c in cards})
        distinct_y = len({c["y"] for c in cards})
        assert distinct_x == expected_cols, f"n={n}: expected {expected_cols} cols, got {distinct_x}"
        assert distinct_y == expected_rows, f"n={n}: expected {expected_rows} rows, got {distinct_y}"

        # And every card sits fully inside its group box on both axes.
        gx, gy, gw, gh = group["x"], group["y"], group["width"], group["height"]
        for c in cards:
            assert gx <= c["x"] and c["x"] + c["width"] <= gx + gw, (n, c)
            assert gy <= c["y"] and c["y"] + c["height"] <= gy + gh, (n, c)


# ── Issue #1409: punctuation-only Obsidian/Canvas filenames ───────────────────

def _punct_graph(label: str):
    """A 2-node graph where one node's label is all-punctuation (e.g. a `@/*`
    tsconfig paths key) and the other is a normal symbol."""
    return build_from_json({
        "nodes": [
            {"id": "n1", "label": label, "file_type": "code", "source_file": "tsconfig.json"},
            {"id": "n2", "label": "AuthHandler", "file_type": "code", "source_file": "auth.ts"},
        ],
        "edges": [],
    })


def test_to_obsidian_never_emits_punctuation_only_filenames():
    """#1409: an all-punctuation label (e.g. `@/*`) must not produce a `@.md`-style
    filename — valid on disk but empty once a downstream tool re-slugs on word chars
    (crashes `qmd update`). It falls back to `unnamed`."""
    G = _punct_graph("@/*")
    communities = cluster(G)
    with tempfile.TemporaryDirectory() as tmp:
        to_obsidian(G, communities, tmp)
        stems = [p.stem for p in Path(tmp).rglob("*.md")]
        assert stems, "to_obsidian wrote no notes"
        bad = [s for s in stems if not re.search(r"\w", s, flags=re.UNICODE)]
        assert not bad, f"punctuation-only filenames emitted: {bad}"
        assert any(s == "unnamed" or s.startswith("unnamed") for s in stems), stems


def test_to_canvas_never_emits_punctuation_only_filenames():
    """#1409: same guard on the canvas exporter's file-node names."""
    G = _punct_graph("@")
    communities = cluster(G)
    with tempfile.TemporaryDirectory() as tmp:
        out = Path(tmp) / "graph.canvas"
        to_canvas(G, communities, str(out))
        data = json.loads(out.read_text())
        file_nodes = [n for n in data["nodes"] if n.get("type") == "file"]
        assert file_nodes, "canvas has no file nodes"
        bad = [n["file"] for n in file_nodes if not re.search(r"\w", Path(n["file"]).stem, flags=re.UNICODE)]
        assert not bad, f"punctuation-only canvas filenames: {bad}"


def test_to_obsidian_leading_dot_labels_are_not_hidden_filenames():
    """#2205: Obsidian hides notes whose names start with `.` — `.env` must
    become `dot-env.md` (and canvas must point at the same stem)."""
    import networkx as nx
    G = nx.Graph()
    G.add_node("n_env", label=".env", source_file=".env", type="document")
    G.add_node("n_gi", label=".gitignore", source_file=".gitignore", type="document")
    G.add_node("n_readme", label="README", source_file="README.md", type="document")
    G.add_edge("n_readme", "n_env", relation="references")
    communities = {0: ["n_env", "n_gi", "n_readme"]}
    with tempfile.TemporaryDirectory() as tmp:
        to_obsidian(G, communities, tmp)
        stems = {p.stem for p in Path(tmp).rglob("*.md") if not p.name.startswith("_")}
        assert "dot-env" in stems, stems
        assert "dot-gitignore" in stems, stems
        assert not any(s.startswith(".") for s in stems), stems

        canvas = Path(tmp) / "graph.canvas"
        to_canvas(G, communities, str(canvas))
        data = json.loads(canvas.read_text(encoding="utf-8"))
        file_stems = {
            Path(n["file"]).stem
            for n in data["nodes"]
            if n.get("type") == "file"
        }
        assert "dot-env" in file_stems, file_stems
        assert "dot-gitignore" in file_stems, file_stems
        assert not any(s.startswith(".") for s in file_stems), file_stems


def test_obsidian_safe_stem_all_dots_label_falls_back_to_unnamed():
    """#2205 follow-up: the `dot-` prefix only applies when a word char survives
    the dot strip. An all-dots label like "..." must hit the #1409 "unnamed"
    fallback, not produce the meaningless stem "dot-"."""
    from graphify.export import _obsidian_safe_stem
    assert _obsidian_safe_stem(".env") == "dot-env"        # #2205 fix unchanged
    assert _obsidian_safe_stem("...") == "unnamed"         # not "dot-"
    assert _obsidian_safe_stem("Database") == "Database"   # normal labels untouched


# ── Existing-vault safety: graphify must not clobber user notes / .obsidian (#1506) ──

def _two_node_graph():
    import networkx as nx
    G = nx.Graph()
    G.add_node("n1", label="Database", community=0, source_file="app/db.py", type="code")
    G.add_node("n2", label="Server", community=0, source_file="app/srv.py", type="code")
    G.add_edge("n1", "n2")
    return G, {0: ["n1", "n2"]}


def test_to_obsidian_preserves_existing_user_notes_and_obsidian_config():
    """#1506: exporting into an existing vault must not overwrite a user's note that
    collides with a graphify node name, nor their .obsidian/ graph settings."""
    G, communities = _two_node_graph()
    with tempfile.TemporaryDirectory() as tmp:
        vault = Path(tmp)
        (vault / "Database.md").write_text("# MY NOTES\nkeep me\n", encoding="utf-8")
        (vault / ".obsidian").mkdir()
        (vault / ".obsidian" / "graph.json").write_text('{"USER":"settings"}', encoding="utf-8")
        to_obsidian(G, communities, str(vault), community_labels={0: "Backend"})
        # user content untouched
        assert "MY NOTES" in (vault / "Database.md").read_text()
        assert json.loads((vault / ".obsidian" / "graph.json").read_text()) == {"USER": "settings"}
        # non-colliding graphify note still written
        assert (vault / "Server.md").exists()


def test_to_obsidian_empty_dir_writes_full_vault():
    """No regression: a fresh/empty dir still gets every note + .obsidian/graph.json."""
    G, communities = _two_node_graph()
    with tempfile.TemporaryDirectory() as tmp:
        out = Path(tmp) / "obsidian"
        n = to_obsidian(G, communities, str(out), community_labels={0: "Backend"})
        assert (out / "Database.md").exists() and (out / "Server.md").exists()
        assert (out / ".obsidian" / "graph.json").exists()
        assert n == 3  # 2 nodes + 1 community note


def test_to_obsidian_rerun_updates_own_notes_but_not_user_files():
    """A re-run overwrites graphify's own prior notes (via the manifest) but leaves a
    user-added note in the same dir alone."""
    G, communities = _two_node_graph()
    with tempfile.TemporaryDirectory() as tmp:
        out = Path(tmp) / "obsidian"
        to_obsidian(G, communities, str(out), community_labels={0: "Backend"})
        (out / "UserNote.md").write_text("mine\n", encoding="utf-8")
        to_obsidian(G, communities, str(out), community_labels={0: "Backend2"})
        assert (out / "Database.md").exists()  # graphify re-wrote its own
        assert (out / "UserNote.md").read_text().strip() == "mine"  # user's untouched


def _four_node_two_community_graph():
    import networkx as nx
    G = nx.Graph()
    G.add_node("n1", label="Database", community=0, source_file="app/db.py", type="code")
    G.add_node("n2", label="Server", community=0, source_file="app/srv.py", type="code")
    G.add_node("n3", label="Cache", community=1, source_file="infra/cache.py", type="code")
    G.add_node("n4", label="Queue", community=1, source_file="infra/queue.py", type="code")
    G.add_edge("n1", "n2")
    G.add_edge("n3", "n4")
    return G, {0: ["n1", "n2"], 1: ["n3", "n4"]}


def test_to_obsidian_rerun_prunes_removed_nodes():
    """#1896: re-exporting into the same vault must delete graphify's own notes for
    nodes (and communities) that dropped out of the graph, so the vault mirrors the
    current graph rather than old-union-new. User files are never touched."""
    G4, comm4 = _four_node_two_community_graph()
    G2, comm2 = _two_node_graph()
    with tempfile.TemporaryDirectory() as tmp:
        out = Path(tmp) / "obsidian"
        to_obsidian(G4, comm4, str(out), community_labels={0: "Backend", 1: "Infra"})
        assert (out / "Cache.md").exists() and (out / "_COMMUNITY_Infra.md").exists()
        (out / "MyOwnNote.md").write_text("mine\n", encoding="utf-8")
        to_obsidian(G2, comm2, str(out), community_labels={0: "Backend"})
        # notes for removed nodes and the stale community overview are pruned
        assert not (out / "Cache.md").exists()
        assert not (out / "Queue.md").exists()
        assert not (out / "_COMMUNITY_Infra.md").exists()
        # surviving graphify notes and the user's own note remain
        assert (out / "Database.md").exists() and (out / "Server.md").exists()
        assert (out / "_COMMUNITY_Backend.md").exists()
        assert (out / "MyOwnNote.md").read_text().strip() == "mine"


def test_to_obsidian_removed_node_returning_is_writable_again(capsys):
    """#1896 follow-on: a node that disappears and later returns must be writable
    again. Before the fix, the manifest was rewritten to only this run's files, so
    the orphaned note was disowned and the returning node's write was skipped as a
    'pre-existing user file' forever."""
    import networkx as nx
    GA, commA = _two_node_graph()
    GB = nx.Graph()
    GB.add_node("n1", label="Database", community=0, source_file="app/db.py", type="code")
    commB = {0: ["n1"]}
    with tempfile.TemporaryDirectory() as tmp:
        out = Path(tmp) / "obsidian"
        to_obsidian(GA, commA, str(out), community_labels={0: "Backend"})
        to_obsidian(GB, commB, str(out), community_labels={0: "Backend"})
        assert not (out / "Server.md").exists()  # pruned while absent
        capsys.readouterr()
        to_obsidian(GA, commA, str(out), community_labels={0: "Backend"})
        # returned node's note exists with current content, written this run
        assert (out / "Server.md").exists()
        assert "# Server" in (out / "Server.md").read_text()
        captured = capsys.readouterr()
        assert "skipped" not in captured.err.lower()


# ── Case-only-distinct labels must not collide on case-insensitive filesystems ──

def _case_collision_graph():
    """Two nodes whose labels differ only by case - on macOS/APFS and Windows/NTFS
    their notes resolve to the same path unless the dedup map folds case."""
    return build_from_json({
        "nodes": [
            {"id": "n1", "label": "References", "file_type": "code", "source_file": "a.py"},
            {"id": "n2", "label": "references", "file_type": "document", "source_file": "b.md"},
        ],
        "edges": [],
    })


def test_to_obsidian_case_only_distinct_labels_dont_overwrite():
    """Both notes must survive as separate files. On a case-insensitive filesystem
    a missing suffix silently overwrites the first note (fewer files than nodes);
    on a case-sensitive one it writes two stems equal under .lower(). Assert both:
    every node note is on disk, and no two stems collide case-insensitively."""
    G = _case_collision_graph()
    communities = cluster(G)
    with tempfile.TemporaryDirectory() as tmp:
        to_obsidian(G, communities, tmp)
        notes = [p for p in Path(tmp).rglob("*.md") if not p.name.startswith("_COMMUNITY")]
        assert len(notes) == G.number_of_nodes(), [p.name for p in notes]
        lowered = [p.stem.lower() for p in notes]
        assert len(set(lowered)) == len(lowered), [p.name for p in notes]
        # the suffixed name must be the expected one, not merely distinct
        assert sorted(p.stem for p in notes) == ["References", "references_1"], [p.name for p in notes]


def test_to_obsidian_generated_suffix_doesnt_overwrite_literal():
    """A generated `_1` suffix must not collide with a node whose literal label is
    already that suffixed name. With labels [dup, dup, dup_1] the second `dup`
    becomes `dup_1`, which would clobber the third node unless the candidate is
    re-checked. This collides on case-sensitive filesystems too, so it guards the
    dedup loop independently of case-folding."""
    G = build_from_json({
        "nodes": [
            {"id": "a", "label": "dup", "file_type": "code", "source_file": "a.py"},
            {"id": "b", "label": "dup", "file_type": "code", "source_file": "b.py"},
            {"id": "c", "label": "dup_1", "file_type": "code", "source_file": "c.py"},
        ],
        "edges": [],
    })
    communities = cluster(G)
    with tempfile.TemporaryDirectory() as tmp:
        to_obsidian(G, communities, tmp)
        notes = [p for p in Path(tmp).rglob("*.md") if not p.name.startswith("_COMMUNITY")]
        assert len(notes) == 3, [p.name for p in notes]
        assert len({p.stem.lower() for p in notes}) == 3, [p.name for p in notes]


def test_to_canvas_case_only_distinct_labels_get_distinct_files():
    """Canvas file-node references for case-only-distinct labels must be distinct
    case-insensitively, else both cards point at one overwritten note."""
    G = _case_collision_graph()
    communities = cluster(G)
    with tempfile.TemporaryDirectory() as tmp:
        out = Path(tmp) / "graph.canvas"
        to_canvas(G, communities, str(out))
        data = json.loads(out.read_text())
        files = [n["file"] for n in data["nodes"] if n.get("type") == "file"]
        lowered = [f.lower() for f in files]
        assert len(set(lowered)) == len(lowered), files


def test_obsidian_canvas_filenames_agree():
    """The CLI calls to_obsidian and to_canvas separately with no shared map, so
    they must independently produce the same node->filename mapping - otherwise a
    canvas card points at a note file that doesn't exist on disk."""
    G = _case_collision_graph()
    communities = cluster(G)
    with tempfile.TemporaryDirectory() as tmp:
        to_obsidian(G, communities, tmp)
        note_stems = {p.stem for p in Path(tmp).rglob("*.md") if not p.name.startswith("_COMMUNITY")}
        out = Path(tmp) / "graph.canvas"
        to_canvas(G, communities, str(out))
        data = json.loads(out.read_text())
        canvas_stems = {Path(n["file"]).stem for n in data["nodes"] if n.get("type") == "file"}
        assert canvas_stems <= note_stems, (sorted(canvas_stems), sorted(note_stems))


def test_to_obsidian_community_notes_case_collision():
    """Two community labels differing only by case must each get their own
    `_COMMUNITY_*.md` overview note. This path had no dedup at all, so even
    same-case duplicate labels previously overwrote silently."""
    G = build_from_json({
        "nodes": [
            {"id": "n1", "label": "alpha", "file_type": "code", "source_file": "a.py"},
            {"id": "n2", "label": "beta", "file_type": "code", "source_file": "b.py"},
        ],
        "edges": [],
    })
    communities = {0: ["n1"], 1: ["n2"]}
    labels = {0: "API", 1: "Api"}
    with tempfile.TemporaryDirectory() as tmp:
        to_obsidian(G, communities, tmp, community_labels=labels)
        comm = [p for p in Path(tmp).rglob("_COMMUNITY_*.md")]
        assert len(comm) == 2, [p.name for p in comm]
        lowered = [p.stem.lower() for p in comm]
        assert len(set(lowered)) == len(lowered), [p.name for p in comm]


# ── Issue #834: backup_if_protected ──────────────────────────────────────────

def test_backup_no_graph_json(tmp_path):
    """No graph.json → no backup."""
    from graphify.export import backup_if_protected
    assert backup_if_protected(tmp_path) is None


def test_backup_no_markers(tmp_path):
    """graph.json present but no sentinel and no curated labels → no backup."""
    from graphify.export import backup_if_protected
    (tmp_path / "graph.json").write_text('{"nodes":[],"links":[]}')
    assert backup_if_protected(tmp_path) is None


def test_backup_semantic_marker(tmp_path):
    """graph.json + .graphify_semantic_marker → backup taken."""
    from graphify.export import backup_if_protected
    (tmp_path / "graph.json").write_text('{"nodes":[],"links":[]}')
    (tmp_path / "GRAPH_REPORT.md").write_text("# Report")
    (tmp_path / ".graphify_semantic_marker").write_text('{"output_tokens": 1234}')
    result = backup_if_protected(tmp_path)
    assert result is not None
    assert result.is_dir()
    assert (result / "graph.json").exists()
    assert (result / "GRAPH_REPORT.md").exists()
    assert (result / ".graphify_semantic_marker").exists()


def test_backup_curated_labels(tmp_path):
    """graph.json + non-default label in .graphify_labels.json → backup taken."""
    import json
    from graphify.export import backup_if_protected
    (tmp_path / "graph.json").write_text('{"nodes":[],"links":[]}')
    (tmp_path / ".graphify_labels.json").write_text(json.dumps({"0": "Auth Pipeline", "1": "Community 1"}))
    result = backup_if_protected(tmp_path)
    assert result is not None


def test_backup_default_labels_only(tmp_path):
    """All-default labels → no backup (not curated)."""
    import json
    from graphify.export import backup_if_protected
    (tmp_path / "graph.json").write_text('{"nodes":[],"links":[]}')
    (tmp_path / ".graphify_labels.json").write_text(json.dumps({"0": "Community 0", "1": "Community 1"}))
    assert backup_if_protected(tmp_path) is None


def test_backup_same_day_no_accumulation(tmp_path):
    """Same content on same day returns existing backup dir without re-copying."""
    from graphify.export import backup_if_protected
    from datetime import date
    (tmp_path / "graph.json").write_text('{"nodes":[],"links":[]}')
    (tmp_path / ".graphify_semantic_marker").write_text("{}")
    b1 = backup_if_protected(tmp_path)
    b2 = backup_if_protected(tmp_path)
    assert b1 is not None and b2 is not None
    assert b1 == b2  # same dir, no _2 accumulation
    assert b1.name == date.today().isoformat()


def test_backup_same_day_changed_content(tmp_path):
    """Changed graph.json on same day overwrites the existing backup in place."""
    from graphify.export import backup_if_protected
    from datetime import date
    (tmp_path / "graph.json").write_text('{"nodes":[],"links":[]}')
    (tmp_path / ".graphify_semantic_marker").write_text("{}")
    b1 = backup_if_protected(tmp_path)
    (tmp_path / "graph.json").write_text('{"nodes":[{"id":"x"}],"links":[]}')
    b2 = backup_if_protected(tmp_path)
    assert b1 == b2  # still one folder per day
    assert (b2 / "graph.json").read_text() == '{"nodes":[{"id":"x"}],"links":[]}'


def test_backup_env_disable(tmp_path, monkeypatch):
    """GRAPHIFY_NO_BACKUP=1 disables backup entirely."""
    from graphify.export import backup_if_protected
    monkeypatch.setenv("GRAPHIFY_NO_BACKUP", "1")
    (tmp_path / "graph.json").write_text('{"nodes":[],"links":[]}')
    (tmp_path / ".graphify_semantic_marker").write_text("{}")
    assert backup_if_protected(tmp_path) is None


# ── Issue #3769: incomplete backups reported as successful ──────────────────

def test_backup_failed_copy_warns_and_returns_none(tmp_path, monkeypatch, capsys):
    """A copy that fails part-way leaves a stale snapshot; it must be warned
    and reported as no backup, not printed as a success (#3769)."""
    import shutil
    from datetime import date
    from graphify.export import backup_if_protected
    (tmp_path / "graph.json").write_text('{"nodes":[{"id":"new"}],"links":[]}')
    (tmp_path / ".graphify_semantic_marker").write_text("{}")
    # A same-day snapshot holding the pre-overwrite graph: its graph.json is
    # stale but readable, so the copy attempt fails rather than the hash check.
    backup_dir = tmp_path / date.today().isoformat()
    backup_dir.mkdir()
    stale = backup_dir / "graph.json"
    stale.write_text('{"nodes":[{"id":"old"}],"links":[]}')

    real_copy2 = shutil.copy2

    def failing_copy2(src, dst):
        if Path(src).name == "graph.json":
            raise PermissionError("denied")
        return real_copy2(src, dst)

    monkeypatch.setattr(shutil, "copy2", failing_copy2)
    result = backup_if_protected(tmp_path)
    assert result is None
    assert "graph.json" in capsys.readouterr().err
    # The stale snapshot must not have been silently kept.
    assert stale.read_text() == '{"nodes":[{"id":"old"}],"links":[]}'


def test_backup_same_day_retries_missing_sidecar(tmp_path, monkeypatch, capsys):
    """A snapshot missing a sidecar is retried on the next call, not treated
    as complete because graph.json already matches (#3769)."""
    import shutil
    from graphify.export import backup_if_protected
    (tmp_path / "graph.json").write_text('{"nodes":[],"links":[]}')
    (tmp_path / ".graphify_semantic_marker").write_text("{}")
    (tmp_path / "cost.json").write_text('{"input_tokens": 5}')

    real_copy2 = shutil.copy2

    def failing_copy2(src, dst):
        if Path(src).name == "cost.json":
            raise PermissionError("denied")
        return real_copy2(src, dst)

    monkeypatch.setattr(shutil, "copy2", failing_copy2)
    assert backup_if_protected(tmp_path) is None
    assert "cost.json" in capsys.readouterr().err

    monkeypatch.setattr(shutil, "copy2", real_copy2)
    result = backup_if_protected(tmp_path)
    assert result is not None
    assert (result / "graph.json").exists()
    assert (result / "cost.json").read_text() == '{"input_tokens": 5}'


def _mkG(n):
    import networkx as nx
    G = nx.Graph()
    for i in range(n):
        G.add_node(f"n{i}", label=f"n{i}", community=0)
    return G


def test_to_json_refuses_shrink(tmp_path):
    """#479: refuse to silently overwrite an existing graph with fewer nodes."""
    p = tmp_path / "graph.json"
    json.dump({"nodes": [{"id": f"n{i}"} for i in range(5)]}, p.open("w"))
    assert to_json(_mkG(2), {}, str(p), force=False) is False
    assert to_json(_mkG(2), {}, str(p), force=True) is True  # force overrides


def test_to_json_refuses_lost_semantic_id_when_total_grows(tmp_path, capsys):
    """#2229: AST growth and a new stub must not replace a saved rationale id."""
    import networkx as nx

    saved_nodes = [
        {"id": "app_ts", "label": "app.ts", "source_file": "src/app.ts", "_origin": "ast", "source_location": "L1", "file_type": "code"},
        {"id": "app_ts_main", "label": "main", "source_file": "src/app.ts", "_origin": "ast", "source_location": "L2", "file_type": "code"},
        {"id": "rationale_kept", "label": "kept", "source_file": "src/app.ts", "_origin": "semantic", "source_location": None, "file_type": "rationale"},
        {"id": "rationale_lost", "label": "lost", "source_file": "src/app.ts", "_origin": "semantic", "source_location": None, "file_type": "rationale"},
    ]
    new_nodes = [
        {"id": "app_ts", "label": "app.ts", "source_file": "src/app.ts", "_origin": "ast", "source_location": "L1", "file_type": "code"},
        {"id": "app_ts_main", "label": "main", "source_file": "src/app.ts", "_origin": "ast", "source_location": "L2", "file_type": "code"},
        {"id": "app_ts_hook", "label": "hook", "source_file": "src/app.ts", "_origin": "ast", "source_location": "L10", "file_type": "code"},
        {"id": "app_ts_route", "label": "route", "source_file": "src/app.ts", "_origin": "ast", "source_location": "L20", "file_type": "code"},
        {"id": "app_ts_util", "label": "util", "source_file": "src/app.ts", "_origin": "ast", "source_location": "L30", "file_type": "code"},
        {"id": "rationale_kept", "label": "kept", "source_file": "src/app.ts", "_origin": "semantic", "source_location": None, "file_type": "rationale"},
        {"id": "pathlib_Path", "label": "Path", "source_file": "", "source_location": None, "file_type": "concept"},
    ]
    p = tmp_path / "graph.json"
    p.write_text(json.dumps({"nodes": saved_nodes, "links": []}), encoding="utf-8")
    G = nx.Graph()
    for node in new_nodes:
        G.add_node(node["id"], **{k: v for k, v in node.items() if k != "id"})
    assert G.number_of_nodes() > len(saved_nodes)
    assert to_json(G, {}, str(p), force=False) is False
    written = json.loads(p.read_text(encoding="utf-8"))
    assert "rationale_lost" in {n["id"] for n in written["nodes"]}
    assert "rationale_lost" in capsys.readouterr().err


def test_to_json_allows_ast_growth_that_keeps_semantic_ids(tmp_path):
    """AST nodes may be added when every saved rationale id is still present."""
    import networkx as nx

    saved_nodes = [
        {"id": "app_ts", "label": "app.ts", "source_file": "src/app.ts", "_origin": "ast", "source_location": "L1", "file_type": "code"},
        {"id": "rationale_kept", "label": "kept", "source_file": "src/app.ts", "_origin": "semantic", "source_location": None, "file_type": "rationale"},
        {"id": "rationale_lost", "label": "lost", "source_file": "src/app.ts", "_origin": "semantic", "source_location": None, "file_type": "rationale"},
    ]
    new_nodes = saved_nodes + [
        {"id": "app_ts_hook", "label": "hook", "source_file": "src/app.ts", "_origin": "ast", "source_location": "L10", "file_type": "code"},
    ]
    p = tmp_path / "graph.json"
    p.write_text(json.dumps({"nodes": saved_nodes, "links": []}), encoding="utf-8")
    G = nx.Graph()
    for node in new_nodes:
        G.add_node(node["id"], **{k: v for k, v in node.items() if k != "id"})
    assert to_json(G, {}, str(p), force=False) is True
    written = json.loads(p.read_text(encoding="utf-8"))
    assert {"rationale_kept", "rationale_lost"} <= {n["id"] for n in written["nodes"]}


def test_to_json_rewrites_the_same_integer_node_ids(tmp_path):
    """An unstamped integer id that is still in the graph is not a lost semantic node."""
    import networkx as nx

    G = nx.Graph()
    G.add_nodes_from([(1, {}), (2, {}), (3, {})])
    G.add_edges_from([(1, 2, {}), (1, 3, {}), (2, 3, {})])
    p = tmp_path / "racecar"
    assert to_json(G, {}, str(p), force=False) is True
    assert to_json(G, {}, str(p), force=False) is True
    assert {n["id"] for n in json.loads(p.read_text(encoding="utf-8"))["nodes"]} == {1, 2, 3}


def test_to_json_refuses_a_missing_integer_semantic_id(tmp_path):
    """A saved integer id that the new graph dropped is still refused when the total grows."""
    import networkx as nx

    p = tmp_path / "graph.json"
    p.write_text(json.dumps({"nodes": [{"id": 4}], "links": []}), encoding="utf-8")
    G = nx.Graph()
    G.add_nodes_from([(1, {}), (2, {}), (3, {})])
    assert to_json(G, {}, str(p), force=False) is False
    assert json.loads(p.read_text(encoding="utf-8"))["nodes"] == [{"id": 4}]


def test_to_json_fails_safe_on_corrupt_existing(tmp_path):
    """A non-empty but unparseable existing graph.json (corrupt or mid-write)
    must NOT be silently overwritten — we can't verify the new graph isn't a
    partial shrink, so fail safe (refuse) unless force is given."""
    p = tmp_path / "graph.json"
    p.write_text("{ this has content but is not valid json")
    assert to_json(_mkG(10), {}, str(p), force=False) is False
    assert to_json(_mkG(10), {}, str(p), force=True) is True


def test_to_json_proceeds_on_empty_existing(tmp_path):
    """An empty/whitespace existing file has no nodes to lose, so it is not a
    shrink risk — the write proceeds."""
    p = tmp_path / "graph.json"
    p.write_text("")
    assert to_json(_mkG(3), {}, str(p), force=False) is True
    data = json.loads(p.read_text())
    assert len(data["nodes"]) == 3


def test_to_html_handles_null_source_file_and_label(tmp_path):
    """#1775: a node with source_file=None or label=None must not crash to_html
    (synthetic/aggregate nodes legitimately carry null source_file; JSON `null`
    survives .get()'s default). Regression guard — fixed via sanitize_label's
    None-coercion + the str(source_file or "") call-site guard."""
    import networkx as nx
    G = nx.Graph()
    G.add_node("n1", label="Foo", source_file=None, community=0)
    G.add_node("n2", label=None, source_file="a.py", community=0)
    G.add_node("n3", label=None, source_file=None, community=0)
    out = tmp_path / "graph.html"
    to_html(G, {0: ["n1", "n2", "n3"]}, str(out))
    assert out.exists() and out.stat().st_size > 0


def test_existing_graph_node_count(tmp_path):
    from graphify.export import existing_graph_node_count, MALFORMED_GRAPH
    p = tmp_path / "graph.json"
    assert existing_graph_node_count(p) is None            # absent -> nothing to protect
    p.write_text("", encoding="utf-8")
    assert existing_graph_node_count(p) is None            # empty -> nothing to protect
    # Non-empty but unparseable must fail CLOSED (sentinel), matching to_json's
    # #479 guard — a corrupt/mid-write file could be hiding a complete graph.
    p.write_text("{not json", encoding="utf-8")
    assert existing_graph_node_count(p) is MALFORMED_GRAPH  # malformed -> fail closed
    p.write_text('{"nodes": "notalist"}', encoding="utf-8")
    assert existing_graph_node_count(p) is MALFORMED_GRAPH  # structurally wrong -> fail closed
    p.write_text('{"nodes": [{"id": "a"}, {"id": "b"}], "links": []}', encoding="utf-8")
    assert existing_graph_node_count(p) == 2               # valid


def test_hyperedge_perimeter_uses_convex_hull_not_member_order():
    """The hyperedge polygon must be traced in hull order. Tracing `h.nodes`
    array order self-intersects whenever the layout does not place members in
    angular order, so `fill()` paints crossed wedges instead of one region."""
    from graphify.exporters.html import _hyperedge_script
    script = _hyperedge_script("[]")
    assert "function convexHull(pts)" in script
    assert "const hull = convexHull(positions);" in script
    # the traced ring must derive from the hull, never from raw member order
    assert "const expanded = hull.map(" in script
    assert "const expanded = positions.map(" not in script


def test_hyperedge_convex_hull_js_is_geometrically_sound():
    """Execute the emitted convexHull in node: the perimeter must be simple
    (no self-intersection), convex, and contain every member point."""
    import shutil
    import subprocess
    node = shutil.which("node")
    if node is None:
        import pytest
        pytest.skip("node not available")
    from graphify.exporters.html import _hyperedge_script
    m = re.search(r"function convexHull\(pts\) \{.*?\n\}", _hyperedge_script("[]"), re.S)
    assert m, "convexHull not found in emitted script"
    harness = m.group(0) + r"""
const cross = (p,q,r) => (q.x-p.x)*(r.y-p.y) - (q.y-p.y)*(r.x-p.x);
const proper = (a,b,c,d) => {
  const s = (p,q,r) => Math.sign(cross(p,q,r));
  return s(a,b,c)*s(a,b,d) < 0 && s(c,d,a)*s(c,d,b) < 0;
};
function selfIntersects(poly){
  const n = poly.length;
  if (n < 4) return false;
  for (let i=0;i<n;i++) for (let j=i+1;j<n;j++){
    if ((i+1)%n===j || (j+1)%n===i) continue;
    if (proper(poly[i],poly[(i+1)%n],poly[j],poly[(j+1)%n])) return true;
  }
  return false;
}
let rng = 12345;
const rnd = () => (rng = (rng*1103515245+12345) & 0x7fffffff) / 0x7fffffff;
let bad = 0;
for (let t=0;t<2000;t++){
  const n = 4 + Math.floor(rnd()*4);            // real hyperedges carry 4-7 members
  const pts = Array.from({length:n}, () => ({x: rnd()*1000-500, y: rnd()*1000-500}));
  const h = convexHull(pts);
  if (selfIntersects(h)) bad++;
  for (let i=0;i<h.length;i++)                  // convex + counter-clockwise
    if (cross(h[i], h[(i+1)%h.length], h[(i+2)%h.length]) < -1e-9) bad++;
  for (const p of pts)                          // every member enclosed
    for (let i=0;i<h.length;i++)
      if (cross(h[i], h[(i+1)%h.length], p) < -1e-6) { bad++; break; }
}
// degenerate member sets must not throw or produce a crossed ring
for (const pts of [
  [{x:-2,y:0},{x:-1,y:0},{x:1,y:0},{x:2,y:0}],
  [{x:0,y:0},{x:0,y:0},{x:5,y:0},{x:0,y:5}],
  [{x:3,y:3},{x:3,y:3},{x:3,y:3},{x:3,y:3}],
  [{x:0,y:0},{x:1,y:1}],
]) {
  const h = convexHull(pts);
  if (!Array.isArray(h) || h.length < 1 || selfIntersects(h)) bad++;
  if (!h.every(p => Number.isFinite(p.x) && Number.isFinite(p.y))) bad++;
}
// the bow-tie ordering this fix exists for
if (!selfIntersects([{x:-1,y:-1},{x:1,y:1},{x:-1,y:1},{x:1,y:-1}])) bad++;
if (selfIntersects(convexHull([{x:-1,y:-1},{x:1,y:1},{x:-1,y:1},{x:1,y:-1}]))) bad++;
console.log(bad);
"""
    with tempfile.TemporaryDirectory() as tmp:
        js = Path(tmp) / "hull_check.js"
        js.write_text(harness, encoding="utf-8")
        proc = subprocess.run([node, str(js)], capture_output=True, text=True, timeout=60)
    assert proc.returncode == 0, proc.stderr
    assert proc.stdout.strip() == "0", f"geometry violations: {proc.stdout.strip()}"


def test_hyperedge_regions_skip_hidden_members():
    """Legend toggles set `hidden` on nodes, but getPositions() still returns
    them. The hull must skip hidden members so a region disappears once fewer
    than two members remain visible (#5121)."""
    import shutil
    import subprocess
    node = shutil.which("node")
    if node is None:
        import pytest
        pytest.skip("node not available")
    from graphify.exporters.html import _hyperedge_script
    hyperedges = json.dumps([{"nodes": ["a", "b", "c"], "label": "h"}])
    script = _hyperedge_script(hyperedges)
    body = script.split("<script>", 1)[1].rsplit("</script>", 1)[0]
    harness = r"""
let handler = null;
const pos = {a: {x: 0, y: 0}, b: {x: 10, y: 0}, c: {x: 0, y: 10}};
const network = {
  on: (ev, fn) => { if (ev === 'afterDrawing') handler = fn; },
  getPositions: ids => ({[ids[0]]: pos[ids[0]]}),
};
const store = {a: {id: 'a'}, b: {id: 'b'}, c: {id: 'c'}};
const nodesDS = {get: id => store[id]};
let fills = 0;
const ctx = new Proxy({}, {
  get: (t, k) => k === 'fill' ? (() => { fills++; })
    : k === 'measureText' ? (() => ({width: 1})) : (() => {}),
  set: () => true,
});
""" + body + r"""
const out = [];
handler(ctx); out.push(fills);
fills = 0; store.a.hidden = true; handler(ctx); out.push(fills);
fills = 0; store.b.hidden = true; handler(ctx); out.push(fills);
console.log(JSON.stringify(out));
"""
    with tempfile.TemporaryDirectory() as tmp:
        js = Path(tmp) / "hyperedge_hidden.js"
        js.write_text(harness, encoding="utf-8")
        proc = subprocess.run([node, str(js)], capture_output=True, text=True, timeout=60)
    assert proc.returncode == 0, proc.stderr
    visible, one_hidden, two_hidden = json.loads(proc.stdout.strip())
    assert visible >= 1
    assert one_hidden >= 1
    assert two_hidden == 0, "hull still drawn with only one visible member"


def test_to_html_pins_vis_network_version_for_tooltip_xss_boundary():
    """Tooltip `title` is passed to vis-network as a STRING and rendered via
    Popup.setText -> innerText (verified in the 9.1.6 bundle), so tooltips are
    intentionally NOT html-escaped (#3664/#3686). That safety rests on the pin:
    if vis-network is bumped, the innerText rendering path (the #1838 stored-XSS
    boundary) must be re-verified. This guard fails CI on a silent bump."""
    G = make_graph()
    communities = cluster(G)
    with tempfile.TemporaryDirectory() as tmp:
        out = Path(tmp) / "graph.html"
        to_html(G, communities, str(out))
        content = out.read_text()
        assert "vis-network@9.1.6" in content, (
            "vis-network pin changed — re-verify Popup.setText renders a string "
            "title via innerText (not innerHTML) before updating this pin, or the "
            "un-escaped tooltip (#3686) reopens the #1838 stored-XSS boundary"
        )


def test_to_html_spiral_seed_uses_a_real_map_index():
    """#3699: the Fermat-spiral seed positions reference `i`, so the node map
    MUST bind an index (`RAW_NODES.map((n, i) => ...)`). Without it the emitted
    JS throws `ReferenceError: i is not defined` and graph.html fails to render
    at every graph size. Guards against the free-`i` regression."""
    G = make_graph()
    communities = cluster(G)
    with tempfile.TemporaryDirectory() as tmp:
        out = Path(tmp) / "graph.html"
        to_html(G, communities, str(out))
        content = out.read_text()
        if "Math.sqrt(i)" in content:  # spiral seed present
            assert "RAW_NODES.map((n, i)" in content, (
                "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"<script>(.*?)</script>\s*<script>", html_content, re.DOTALL)
    assert m, "vis script block not found in html"
    script = m.group(1)

    harness = f"""
const mockElem = () => ({{
  innerHTML: '',
  style: {{}},
  appendChild: () => {{}},
  addEventListener: () => {{}},
  classList: {{ add: () => {{}}, remove: () => {{}} }},
  prepend: () => {{}},
}});

const doc = {{
  body: mockElem(),
  elements: {{
    'info-content': mockElem(),
    'graph': mockElem(),
    'search': mockElem(),
    'search-results': mockElem(),
    'select-all-cb': Object.assign(mockElem(), {{ checked: true }}),
    'legend': mockElem()
  }},
  getElementById(id) {{
    return this.elements[id] || (this.elements[id] = mockElem());
  }},
  createElement() {{ return mockElem(); }},
  addEventListener() {{}},
  querySelectorAll() {{ return []; }}
}};

class MockDataSet {{
  constructor(items) {{
    this.map = new Map();
    items.forEach(it => this.map.set(it.id, it));
  }}
  get(id) {{ return this.map.get(id); }}
  update() {{}}
}}

const vis = {{
  DataSet: MockDataSet,
  Network: class {{
    constructor() {{}}
    once() {{}}
    on() {{}}
    getConnectedNodes() {{ return []; }}
    focus() {{}}
    selectNodes() {{}}
  }}
}};

const document = doc;
const window = {{}};

{script}

showInfo('{target_node_id}');
console.log('OUTPUT:' + doc.getElementById('info-content').innerHTML);
"""
    proc = subprocess.run([node, "-e", harness], capture_output=True, text=True, timeout=30)
    assert proc.returncode == 0, f"Node script failed ({proc.returncode}): {proc.stderr}"
    m_out = re.search(r"OUTPUT:(.*)", proc.stdout)
    assert m_out, f"OUTPUT not found in stdout: {proc.stdout}"
    return m_out.group(1)


def test_to_html_show_info_runtime_renders_node_fields(tmp_path):
    """#3914: Execute showInfo in Node.js for a normal node.
    Type: code and Source: src/sample.py must be rendered into info-content,
    not Type: unknown or Source: -."""
    import networkx as nx
    G = nx.Graph()
    G.add_node("n_sample", label="SampleNode", file_type="code", source_file="src/sample.py")
    G.add_node("n_other", label="OtherNode", file_type="document", source_file="doc/readme.md")
    G.add_edge("n_sample", "n_other", relation="references", confidence="EXTRACTED")

    out = tmp_path / "graph.html"
    to_html(G, {0: ["n_sample", "n_other"]}, str(out), community_labels={0: "Core"})
    content = out.read_text(encoding="utf-8")

    info_html = _run_node_info_harness(content, "n_sample")
    assert '<div class="field"><b>SampleNode</b></div>' in info_html
    assert '<div class="field">Type: code</div>' in info_html
    assert '<div class="field">Community: Core</div>' in info_html
    assert '<div class="field">Source: src/sample.py</div>' in info_html
    assert '<div class="field">Degree: 1</div>' in info_html
    assert "Type: unknown" not in info_html
    assert "Source: -" not in info_html


def test_to_html_aggregated_community_nodes_runtime(tmp_path):
    """#3914: Execute showInfo in Node.js for an aggregated community node.
    Community meta-nodes have no file_type/source_file and must not display
    misleading Type: unknown or Source: -; they must display community name
    and member count."""
    import networkx as nx
    meta = nx.Graph()
    meta.add_node("0", label="Core Systems")
    meta.add_node("1", label="UI Layer")
    meta.add_edge("0", "1", relation="cross-community", confidence="AGGREGATED")

    out = tmp_path / "graph.html"
    to_html(
        meta,
        {0: ["0"], 1: ["1"]},
        str(out),
        community_labels={0: "Core Systems", 1: "UI Layer"},
        member_counts={0: 25, 1: 10},
    )
    content = out.read_text(encoding="utf-8")

    nodes = _vis_nodes_from_html(content)
    assert any(n.get("member_count") == 25 for n in nodes)

    info_html = _run_node_info_harness(content, "0")
    assert '<div class="field"><b>Core Systems</b></div>' in info_html
    assert '<div class="field">Community: Core Systems</div>' in info_html
    assert '<div class="field">Members: 25</div>' in info_html
    assert '<div class="field">Degree: 1</div>' in info_html
    assert "Type: unknown" not in info_html
    assert "Source: -" not in info_html


def _run_sidebar_harness(html_content: str, epilogue: str):
    """Run graph.html's client script in Node.js with DOM mocks that record
    children and listeners, then run `epilogue` and return what it prints as JSON."""
    import shutil
    import subprocess
    node = shutil.which("node")
    if node is None:
        import pytest
        pytest.skip("node not available")
    m = re.search(r"<script>(.*?)</script>\s*<script>", html_content, re.DOTALL)
    assert m, "vis script block not found in html"
    harness = """
const mockElem = () => ({ innerHTML: '', style: {}, classList: { add() {}, remove() {} }, children: [], listeners: {},
  offsetWidth: 200, offsetHeight: 30,
  appendChild(c) { this.children.push(c); }, addEventListener(t, f) { this.listeners[t] = f; }, prepend() {} });
const els = {};
const document = { body: mockElem(), listeners: {}, getElementById: id => els[id] || (els[id] = mockElem()),
  createElement: mockElem, addEventListener(t, f) { this.listeners[t] = f; }, querySelectorAll: () => [] };
const window = { innerWidth: 1280, innerHeight: 800 };
class MockDataSet { constructor(items) { this.map = new Map(items.map(it => [it.id, it])); }
  get(id) { return this.map.get(id); } update() {} }
const vis = { DataSet: MockDataSet, Network: class { once() {} on() {} focus() {} selectNodes() {}
  getConnectedNodes(id) { return RAW_NODES.map(n => n.id).filter(x => x !== id); } } };
""" + m.group(1) + "\n" + epilogue
    proc = subprocess.run([node, "-e", harness], capture_output=True, text=True, encoding="utf-8", timeout=30)
    assert proc.returncode == 0, f"Node script failed ({proc.returncode}): {proc.stderr}"
    return json.loads(proc.stdout)


def _parse_html_fragment(fragment: str) -> list:
    """Return (tag, attrs dict, text) for each start tag; HTMLParser decodes entities."""
    from html.parser import HTMLParser
    tags: list = []
    open_: list = []

    class _P(HTMLParser):
        def handle_starttag(self, tag, attrs):
            tags.append([tag, dict(attrs), ""])
            open_.append(tags[-1])

        def handle_endtag(self, tag):
            if open_:
                open_.pop()

        def handle_data(self, data):
            if open_:
                open_[-1][2] += data

    _P(convert_charrefs=True).feed(fragment)
    return tags


def test_to_html_legend_label_escaped_once_without_title(tmp_path):
    """The legend label is HTML-escaped once for the innerHTML sink, so its DOM
    text (what the sidebar tooltip shows) is the raw label; no title attributes."""
    import networkx as nx
    G = nx.Graph()
    for nid in ("n1", "n2", "n3", "n4"):
        G.add_node(nid, label=nid, file_type="code", source_file=f"{nid}.py")
    label = "Team \"A\" & <B> 'c'"
    out = tmp_path / "graph.html"
    to_html(G, {0: ["n1", "n2", "n3"], 1: ["n4"]}, str(out), community_labels={0: label, 1: "Solo"})
    items = _run_sidebar_harness(out.read_text(encoding="utf-8"),
                                 "console.log(JSON.stringify(legendEl.children.map(c => c.innerHTML)));")
    assert "&amp;amp;" not in items[0]
    tags = [t for frag in items for t in _parse_html_fragment(frag)]
    assert {t[0] for t in tags} == {"div", "span"}
    assert not any("title" in t[1] for t in tags)
    assert [t[2] for t in tags if t[1].get("class") == "legend-label"] == [label, "Solo"]
    assert [t[2] for t in tags if t[1].get("class") == "legend-count"] == ["3", "1"]


def test_to_html_sidebar_tooltip_shows_truncated_text_and_node_counts(tmp_path):
    """One shared .vis-tooltip: names only when truncated, counts always as
    'N node(s)'; text set via textContent so & " ' < > show literally."""
    import networkx as nx
    G = nx.Graph()
    G.add_node("a", label="a", file_type="code", source_file="a.py")
    label = "Team \"A\" & <b>B</b> 'c' <img src=x onerror=alert(1)>"
    out = tmp_path / "graph.html"
    to_html(G, {0: ["a"]}, str(out), community_labels={0: "Core"})
    content = out.read_text(encoding="utf-8")
    assert "sidebarTip.innerHTML" not in content
    assert "max-width: 360px; overflow-wrap: break-word" in content
    result = _run_sidebar_harness(content, _SIDEBAR_DOM_JS + """
const hover = (cls, text, truncated) => {
  const el = cls.startsWith('legend-') ? legendRow(cls === 'legend-label' ? text : 'x', truncated, text)[cls]
    : domNode(cls, text, null, truncated);
  document.listeners.mouseover({ target: el, clientX: 100, clientY: 100 });
  const shown = tip.style.visibility === 'visible' ? tip.textContent : null;
  const pos = [tip.style.left, tip.style.top];
  document.listeners.mouseout({ target: el, relatedTarget: null });
  return [shown, tip.style.visibility, tip.innerHTML, tip.textContent, shown === null ? null : pos];
};
const L = %s;
console.log(JSON.stringify({ count: tips.length, hovers: [
  hover('legend-label', L, true), hover('search-item', L, true), hover('neighbor-link', L, true),
  hover('legend-label', 'short', false), hover('search-item', 'short', false), hover('neighbor-link', 'short', false),
  hover('legend-count', '3', false), hover('legend-count', '1', false)], styles: tip.styleLog }));""" % json.dumps(label))
    assert result["count"] == 1
    # Shown 12px right of and 16px below the cursor at (100, 100); hidden and
    # emptied again on mouseout.
    at = ["112px", "116px"]
    assert result["hovers"] == [[label, "hidden", "", "", at]] * 3 + [[None, "hidden", "", "", None]] * 3 + [
        ["3 nodes", "hidden", "", "", at], ["1 node", "hidden", "", "", at]]
    assert result["styles"] and not any("NaN" in v for v in result["styles"])


# Parent-linked DOM nodes for the sidebar tooltip tests: enough of closest(),
# contains() and querySelector() to hover any part of a real legend row, which
# the client builds as checkbox + dot + label + count inside a .legend-item.
# `tip` is the shared tooltip; tip.styleLog records every left/top it is given.
_SIDEBAR_DOM_JS = """
globalThis.setTimeout = f => f();
const tips = document.body.children.filter(c => c.className === 'vis-tooltip');
const tip = tips[0];
tip.styleLog = [];
tip.style = new Proxy(tip.style, { set(s, k, v) { if (k === 'left' || k === 'top') tip.styleLog.push(String(v));
  s[k] = v; return true; } });
const domNode = (cls, text, parent, truncated) => {
  const el = { textContent: text, parent, kids: [], scrollWidth: truncated ? 300 : 100, clientWidth: 100,
    classList: { contains: c => c === cls },
    contains(o) { for (let x = o; x; x = x.parent) if (x === el) return true; return false; },
    closest(sel) { const want = sel.split(', ').map(s => s.slice(1));
      for (let x = el; x; x = x.parent) if (want.some(w => x.classList.contains(w))) return x; return null; },
    querySelector(sel) { const q = [...el.kids];
      while (q.length) { const k = q.shift(); if (k.classList.contains(sel.slice(1))) return k; q.push(...k.kids); }
      return null; } };
  if (parent) parent.kids.push(el);
  return el;
};
const legendRow = (label, truncated, count = '3') => {
  const row = domNode('legend-item', '', null, false);
  return { 'legend-item': row, 'legend-cb': domNode('legend-cb', '', row, false),
    'legend-dot': domNode('legend-dot', '', row, false), 'legend-label': domNode('legend-label', label, row, truncated),
    'legend-count': domNode('legend-count', count, row, false) };
};
const move = (from, to, x = 100, y = 100) => {
  if (from) document.listeners.mouseout({ target: from, relatedTarget: to });
  const afterOut = tip.style.visibility;
  if (to) document.listeners.mouseover({ target: to, clientX: x, clientY: y });
  return [afterOut, tip.style.visibility, tip.textContent];
};
"""


def _sidebar_tip_graph_html(tmp_path, label: str = "Core") -> str:
    import networkx as nx
    G = nx.Graph()
    G.add_node("a", label="a", file_type="code", source_file="a.py")
    out = tmp_path / "graph.html"
    to_html(G, {0: ["a"]}, str(out), community_labels={0: label})
    return out.read_text(encoding="utf-8")


def test_to_html_sidebar_tooltip_stays_inside_viewport_near_edges(tmp_path):
    """Near the right/bottom edge of the 1280x800 window the 200x30 tooltip is
    clamped left and flipped above the cursor instead of overflowing."""
    result = _run_sidebar_harness(_sidebar_tip_graph_html(tmp_path), _SIDEBAR_DOM_JS + """
const at = (x, y) => { const c = legendRow('x', false)['legend-count']; move(null, c, x, y);
  const r = [tip.style.left, tip.style.top]; move(c, null); return r; };
console.log(JSON.stringify({ size: [tip.offsetWidth, tip.offsetHeight],
  edges: [at(1270, 790), at(1270, 100), at(100, 790), at(0, 0)], styles: tip.styleLog }));""")
    w, h = result["size"]
    assert result["edges"] == [["1076px", "752px"], ["1076px", "116px"], ["112px", "752px"], ["12px", "16px"]]
    for left, top in result["edges"]:
        assert 0 <= int(left[:-2]) and int(left[:-2]) + w <= 1280
        assert 0 <= int(top[:-2]) and int(top[:-2]) + h <= 800
    assert not any("NaN" in v for v in result["styles"])


def test_to_html_legend_row_hover_shows_label_anywhere_but_count(tmp_path):
    """The whole legend row (checkbox, dot, gaps/padding) shows the truncated
    name; the count still says 'N nodes'; moving inside the row never hides it."""
    result = _run_sidebar_harness(_sidebar_tip_graph_html(tmp_path), _SIDEBAR_DOM_JS + """
const long = legendRow('A very long community name', true);
const short = legendRow('Short', false, '1');
console.log(JSON.stringify({
  padding: move(null, long['legend-item']),
  toDot: move(long['legend-item'], long['legend-dot']),
  toCheckbox: move(long['legend-dot'], long['legend-cb']),
  toLabel: move(long['legend-cb'], long['legend-label']),
  toCount: move(long['legend-label'], long['legend-count']),
  backToDot: move(long['legend-count'], long['legend-dot']),
  leave: move(long['legend-dot'], null),
  shortPadding: move(null, short['legend-item']),
  shortDot: move(short['legend-item'], short['legend-dot']),
  shortCount: move(short['legend-dot'], short['legend-count']),
  shortLeave: move(short['legend-count'], null) }));""")
    name = "A very long community name"
    assert result == {
        "padding": [None, "visible", name],  # never shown before: no visibility set yet
        # Same row, same text: the tooltip is not hidden between children.
        "toDot": ["visible", "visible", name],
        "toCheckbox": ["visible", "visible", name],
        "toLabel": ["visible", "visible", name],
        "toCount": ["visible", "visible", "3 nodes"],
        "backToDot": ["hidden", "visible", name],
        "leave": ["hidden", "hidden", ""],
        # A name that fits gets no tooltip from anywhere on its row.
        "shortPadding": ["hidden", "hidden", ""],
        "shortDot": ["hidden", "hidden", ""],
        "shortCount": ["hidden", "visible", "1 node"],
        "shortLeave": ["hidden", "hidden", ""],
    }