"""Tests for graphify tree / tree_html.build_tree.
#2534 case 3: `tree --root ` that matches NO source_file used to silently
flatten the whole hierarchy (every file attached flat to the root) while the CLI
still printed "wrote ..." and exited 0 — source_file paths are stored
repo-relative, so an absolute --root never matched. A zero-match explicit root
now raises/exits 1; the default computed root and partial matches stay exit 0.
"""
from __future__ import annotations
import json
import subprocess
import sys
from pathlib import Path
import pytest
from graphify.tree_html import build_tree
PYTHON = sys.executable
def _run(args: list[str], cwd: Path) -> subprocess.CompletedProcess:
return subprocess.run(
[PYTHON, "-m", "graphify"] + args,
cwd=cwd,
capture_output=True,
text=True,
)
def _graph(files: dict[str, str]) -> dict:
"""Minimal node-link graph: one symbol node per {source_file: label}."""
return {
"directed": False,
"nodes": [
{"id": f"n{i}", "label": label, "source_file": src}
for i, (src, label) in enumerate(sorted(files.items()))
],
"links": [],
}
def _write_graph(tmp_path: Path, graph: dict) -> Path:
out = tmp_path / "graphify-out"
out.mkdir()
(out / "graph.json").write_text(json.dumps(graph), encoding="utf-8")
return out
# ── build_tree unit tests ─────────────────────────────────────────────────────
def test_build_tree_explicit_root_matching_nothing_raises():
graph = _graph({"pkg/a.py": "func_a", "pkg/sub/b.py": "func_b"})
with pytest.raises(ValueError, match=r"matched 0 of 2 source files"):
build_tree(graph, root="/abs/checkout/elsewhere")
def test_build_tree_error_names_a_repo_relative_sample():
graph = _graph({"pkg/a.py": "func_a"})
with pytest.raises(ValueError, match=r"repo-relative.*'pkg/a\.py'"):
build_tree(graph, root="/abs/nonmatch")
def test_build_tree_partial_match_does_not_raise():
"""Files outside an explicit root legitimately attach flat — only a
ZERO-match explicit root is an error."""
graph = _graph({"pkg/a.py": "func_a", "other/c.py": "func_c"})
tree = build_tree(graph, root="pkg")
assert tree["name"] == "pkg"
assert tree["total_count"] > 0
def test_build_tree_default_root_keeps_hierarchy():
graph = _graph({"pkg/a.py": "func_a", "pkg/sub/b.py": "func_b"})
tree = build_tree(graph) # computed common root — always matches
assert tree["name"] == "pkg"
names = {c["name"] for c in tree["children"]}
assert "a.py" in names and "sub" in names
def _merged_graph(per_repo: dict[str, dict[str, str]]) -> dict:
"""Node-link graph shaped like a merge-graphs result: every node is
repo-tagged and keeps its repo-relative source_file."""
nodes = []
for repo, files in sorted(per_repo.items()):
for src, label in sorted(files.items()):
nodes.append({
"id": f"{repo}::{label}", "label": label,
"source_file": src, "repo": repo,
})
return {"directed": False, "nodes": nodes, "links": []}
def _by_name(node: dict) -> dict:
return {c["name"]: c for c in node["children"]}
def test_build_tree_merged_graph_gets_one_root_per_repo():
graph = _merged_graph({
"repo-a": {"src/engine.py": "EngineA"},
"repo-b": {"lib/util.py": "UtilB"},
})
tree = build_tree(graph)
assert set(_by_name(tree)) == {"repo-a", "repo-b"}
assert tree["total_count"] == 2
def test_build_tree_merged_graph_keeps_shared_relative_paths_separate():
"""Same repo-relative path in two repos must not fuse into one file node."""
graph = _merged_graph({
"repo-a": {"src/engine.py": "EngineA"},
"repo-b": {"src/engine.py": "EngineB"},
})
repos = _by_name(build_tree(graph))
for tag, symbol in (("repo-a", "EngineA"), ("repo-b", "EngineB")):
engine = _by_name(_by_name(repos[tag])["src"])["engine.py"]
assert [c["name"] for c in engine["children"]] == [symbol]
def test_build_tree_single_repo_tag_is_unchanged():
"""One distinct repo tag keeps the pre-existing flat-root behaviour."""
graph = _merged_graph({"repo-a": {"pkg/a.py": "func_a", "pkg/sub/b.py": "func_b"}})
tree = build_tree(graph)
assert tree["name"] == "pkg"
assert {"a.py", "sub"} <= set(_by_name(tree))
def test_build_tree_merged_graph_untagged_nodes_are_kept():
graph = _merged_graph({
"repo-a": {"a.py": "A"},
"repo-b": {"b.py": "B"},
})
graph["nodes"].append({"id": "x", "label": "X", "source_file": "x.py"})
assert set(_by_name(build_tree(graph))) == {"repo-a", "repo-b", "(no repo)"}
def test_build_tree_partially_tagged_single_repo_is_not_a_merged_graph():
"""One real repo tag plus untagged nodes is not a merged graph: it must
take the original single-root path, not grow a "(no repo)" sibling."""
graph = {"nodes": [
{"id": "a", "label": "A", "source_file": "pkg/a.py", "repo": "only-repo"},
{"id": "b", "label": "B", "source_file": "pkg/b.py"},
]}
tree = build_tree(graph)
assert tree["name"] == "pkg"
assert set(_by_name(tree)) == {"a.py", "b.py"}
def test_build_tree_untagged_graph_is_unchanged_by_repo_grouping():
graph = _graph({"pkg/a.py": "func_a", "pkg/sub/b.py": "func_b"})
assert "repo" not in graph["nodes"][0]
tree = build_tree(graph)
assert tree["name"] == "pkg"
assert {"a.py", "sub"} <= set(_by_name(tree))
def test_build_tree_merged_graph_explicit_root_matching_nothing_raises():
graph = _merged_graph({
"repo-a": {"src/a.py": "A"},
"repo-b": {"src/b.py": "B"},
})
with pytest.raises(ValueError, match=r"matched 0 of 2 source files"):
build_tree(graph, root="/abs/checkout/elsewhere")
def test_build_tree_merged_graph_uses_project_label_for_top_root():
graph = _merged_graph({"repo-a": {"a.py": "A"}, "repo-b": {"b.py": "B"}})
assert build_tree(graph, project_label="world")["name"] == "world"
def test_tree_cli_merged_graph_renders_repo_roots(tmp_path):
out = _write_graph(tmp_path, _merged_graph({
"repo-a": {"src/engine.py": "EngineA"},
"repo-b": {"src/engine.py": "EngineB"},
}))
r = _run(["tree", "--graph", str(out / "graph.json")], tmp_path)
assert r.returncode == 0, r.stderr
html = (out / "GRAPH_TREE.html").read_text(encoding="utf-8")
assert "repo-a" in html and "repo-b" in html
# ── CLI integration ───────────────────────────────────────────────────────────
def test_tree_cli_absolute_root_mismatch_exits_1(tmp_path):
_write_graph(tmp_path, _graph({"pkg/a.py": "func_a", "pkg/sub/b.py": "func_b"}))
r = _run(["tree", "--root", "/abs/nonmatch"], tmp_path)
assert r.returncode == 1, r.stdout
assert "matched 0 of" in r.stderr, r.stderr
# the handler's error pattern, not an unhandled traceback
assert "error:" in r.stderr, r.stderr
assert "Traceback" not in r.stderr, r.stderr
assert "wrote" not in r.stdout, r.stdout
assert not (tmp_path / "graphify-out" / "GRAPH_TREE.html").exists()
def test_tree_cli_default_root_succeeds(tmp_path):
_write_graph(tmp_path, _graph({"pkg/a.py": "func_a", "pkg/sub/b.py": "func_b"}))
r = _run(["tree"], tmp_path)
assert r.returncode == 0, r.stderr
assert "wrote" in r.stdout, r.stdout
html = (tmp_path / "graphify-out" / "GRAPH_TREE.html").read_text(encoding="utf-8")
# the hierarchy survives: pkg is the root, sub is an interior node
assert '"name":"pkg"' in html
assert '"name":"sub"' in html
def test_tree_cli_partial_match_root_succeeds(tmp_path):
_write_graph(tmp_path, _graph({"pkg/a.py": "func_a", "other/c.py": "func_c"}))
r = _run(["tree", "--root", "pkg"], tmp_path)
assert r.returncode == 0, r.stderr
assert (tmp_path / "graphify-out" / "GRAPH_TREE.html").exists()