chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,344 @@
|
||||
"""Tests for CLI helpers and MCP serve command wiring."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from importlib.metadata import PackageNotFoundError
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from code_review_graph import cli
|
||||
|
||||
|
||||
def test_get_version_falls_back_to_package_attr_when_metadata_missing(
|
||||
monkeypatch, caplog,
|
||||
):
|
||||
"""When importlib.metadata can't find the dist, fall back to __version__.
|
||||
|
||||
This matters on filesystems where iCloud / OneDrive leave orphan
|
||||
dist-info dirs that confuse the metadata lookup. Before v2.3.5 the
|
||||
fallback returned the literal string "dev", which produced confusing
|
||||
output for installed users whose lookup happened to fail.
|
||||
"""
|
||||
def _raise_package_not_found(_dist_name: str) -> str:
|
||||
raise PackageNotFoundError("code-review-graph")
|
||||
|
||||
monkeypatch.setattr(cli, "pkg_version", _raise_package_not_found)
|
||||
|
||||
with caplog.at_level(logging.DEBUG, logger="code_review_graph.cli"):
|
||||
version = cli._get_version()
|
||||
|
||||
# Falls back to the package's __version__, not "dev"
|
||||
from code_review_graph import __version__ as expected
|
||||
assert version == expected
|
||||
assert "Package metadata unavailable" in caplog.text
|
||||
|
||||
|
||||
def test_get_version_returns_dev_when_both_sources_fail(monkeypatch, caplog):
|
||||
"""The literal "dev" fallback still fires when __version__ also fails."""
|
||||
def _raise_package_not_found(_dist_name: str) -> str:
|
||||
raise PackageNotFoundError("code-review-graph")
|
||||
|
||||
monkeypatch.setattr(cli, "pkg_version", _raise_package_not_found)
|
||||
|
||||
import code_review_graph
|
||||
monkeypatch.delattr(code_review_graph, "__version__", raising=False)
|
||||
|
||||
with caplog.at_level(logging.DEBUG, logger="code_review_graph.cli"):
|
||||
version = cli._get_version()
|
||||
|
||||
assert version == "dev"
|
||||
|
||||
|
||||
class TestServeCommand:
|
||||
def test_serve_passes_auto_watch_flag(self):
|
||||
argv = [
|
||||
"code-review-graph",
|
||||
"serve",
|
||||
"--repo",
|
||||
"repo-root",
|
||||
"--auto-watch",
|
||||
]
|
||||
with patch.object(sys, "argv", argv):
|
||||
with patch("code_review_graph.main.main") as mock_serve:
|
||||
cli.main()
|
||||
|
||||
mock_serve.assert_called_once_with(
|
||||
repo_root="repo-root",
|
||||
auto_watch=True,
|
||||
tools=None,
|
||||
)
|
||||
|
||||
def test_mcp_alias_maps_to_serve(self):
|
||||
argv = [
|
||||
"code-review-graph",
|
||||
"mcp",
|
||||
"--repo",
|
||||
"repo-root",
|
||||
]
|
||||
with patch.object(sys, "argv", argv):
|
||||
with patch("code_review_graph.main.main") as mock_serve:
|
||||
cli.main()
|
||||
|
||||
mock_serve.assert_called_once_with(
|
||||
repo_root="repo-root",
|
||||
auto_watch=False,
|
||||
)
|
||||
|
||||
|
||||
class TestWatchInteraction:
|
||||
def test_watch_exits_when_lock_is_held(self):
|
||||
argv = ["code-review-graph", "watch", "--repo", "repo-root"]
|
||||
with patch.object(sys, "argv", argv):
|
||||
with patch("code_review_graph.graph.GraphStore") as mock_store:
|
||||
mock_store.return_value = MagicMock()
|
||||
with patch("code_review_graph.incremental.get_db_path") as mock_db:
|
||||
mock_db.return_value = MagicMock()
|
||||
with patch("code_review_graph.incremental.watch") as mock_watch:
|
||||
mock_watch.side_effect = RuntimeError("watcher already running")
|
||||
try:
|
||||
cli.main()
|
||||
assert False, "Expected SystemExit"
|
||||
except SystemExit as exc:
|
||||
assert exc.code == 1
|
||||
|
||||
|
||||
class TestBuildUpdateCommands:
|
||||
def test_build_skip_postprocess_does_not_run_extra_cli_postprocess(self):
|
||||
argv = [
|
||||
"code-review-graph",
|
||||
"build",
|
||||
"--skip-postprocess",
|
||||
"--repo",
|
||||
"repo-root",
|
||||
]
|
||||
result = {
|
||||
"files_parsed": 1,
|
||||
"total_nodes": 2,
|
||||
"total_edges": 1,
|
||||
"postprocess_level": "none",
|
||||
}
|
||||
|
||||
with patch.object(sys, "argv", argv):
|
||||
with patch("code_review_graph.graph.GraphStore") as mock_store:
|
||||
mock_store.return_value = MagicMock()
|
||||
with patch("code_review_graph.incremental.get_db_path") as mock_db:
|
||||
mock_db.return_value = MagicMock()
|
||||
with patch(
|
||||
"code_review_graph.tools.build.build_or_update_graph",
|
||||
return_value=result,
|
||||
) as mock_build:
|
||||
with patch(
|
||||
"code_review_graph.postprocessing.run_post_processing",
|
||||
) as mock_postprocess:
|
||||
cli.main()
|
||||
|
||||
mock_build.assert_called_once_with(
|
||||
full_rebuild=True,
|
||||
repo_root="repo-root",
|
||||
postprocess="none",
|
||||
)
|
||||
mock_postprocess.assert_not_called()
|
||||
|
||||
def test_update_skip_flows_does_not_run_extra_cli_postprocess(self):
|
||||
argv = [
|
||||
"code-review-graph",
|
||||
"update",
|
||||
"--skip-flows",
|
||||
"--repo",
|
||||
"repo-root",
|
||||
]
|
||||
result = {
|
||||
"files_updated": 1,
|
||||
"total_nodes": 2,
|
||||
"total_edges": 1,
|
||||
"postprocess_level": "minimal",
|
||||
}
|
||||
|
||||
with patch.object(sys, "argv", argv):
|
||||
with patch("code_review_graph.graph.GraphStore") as mock_store:
|
||||
mock_store.return_value = MagicMock()
|
||||
with patch("code_review_graph.incremental.get_db_path") as mock_db:
|
||||
mock_db.return_value = MagicMock()
|
||||
with patch(
|
||||
"code_review_graph.tools.build.build_or_update_graph",
|
||||
return_value=result,
|
||||
) as mock_build:
|
||||
with patch(
|
||||
"code_review_graph.postprocessing.run_post_processing",
|
||||
) as mock_postprocess:
|
||||
cli.main()
|
||||
|
||||
mock_build.assert_called_once_with(
|
||||
full_rebuild=False,
|
||||
repo_root="repo-root",
|
||||
base="HEAD~1",
|
||||
postprocess="minimal",
|
||||
)
|
||||
mock_postprocess.assert_not_called()
|
||||
|
||||
|
||||
class TestDetectChangesCommand:
|
||||
def test_brief_output_includes_token_savings_panel(self, tmp_path, capsys):
|
||||
"""v2.3.5: --brief output renders a boxed Token Savings panel.
|
||||
|
||||
Replaces the v2.3.4 one-line `Estimated context saved: …` format.
|
||||
The panel must include the title, the saved-tokens line, the
|
||||
percent suffix, and box borders.
|
||||
"""
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
(repo / ".git").mkdir()
|
||||
(repo / "app.py").write_text("x" * 2000, encoding="utf-8")
|
||||
argv = [
|
||||
"code-review-graph",
|
||||
"detect-changes",
|
||||
"--repo",
|
||||
str(repo),
|
||||
"--brief",
|
||||
]
|
||||
|
||||
with patch.object(sys, "argv", argv):
|
||||
with patch("code_review_graph.graph.GraphStore") as mock_store:
|
||||
mock_store.return_value = MagicMock()
|
||||
with patch("code_review_graph.incremental.get_db_path") as mock_db:
|
||||
mock_db.return_value = MagicMock()
|
||||
with patch(
|
||||
"code_review_graph.incremental.get_changed_files",
|
||||
return_value=["app.py"],
|
||||
):
|
||||
with patch(
|
||||
"code_review_graph.changes.analyze_changes",
|
||||
return_value={"summary": "summary only"},
|
||||
):
|
||||
cli.main()
|
||||
|
||||
output = capsys.readouterr().out
|
||||
assert "summary only" in output
|
||||
# Panel structure: title, the three core rows, and box borders.
|
||||
assert "Token Savings" in output
|
||||
assert "Full context would be:" in output
|
||||
assert "Graph context used:" in output
|
||||
assert "Saved:" in output
|
||||
# Box drawing characters from format_context_savings_panel
|
||||
assert "┌" in output and "┘" in output
|
||||
|
||||
def test_json_output_includes_compact_savings_metadata(self, tmp_path, capsys):
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
(repo / ".git").mkdir()
|
||||
(repo / "app.py").write_text("x" * 2000, encoding="utf-8")
|
||||
argv = [
|
||||
"code-review-graph",
|
||||
"detect-changes",
|
||||
"--repo",
|
||||
str(repo),
|
||||
]
|
||||
|
||||
with patch.object(sys, "argv", argv):
|
||||
with patch("code_review_graph.graph.GraphStore") as mock_store:
|
||||
mock_store.return_value = MagicMock()
|
||||
with patch("code_review_graph.incremental.get_db_path") as mock_db:
|
||||
mock_db.return_value = MagicMock()
|
||||
with patch(
|
||||
"code_review_graph.incremental.get_changed_files",
|
||||
return_value=["app.py"],
|
||||
):
|
||||
with patch(
|
||||
"code_review_graph.changes.analyze_changes",
|
||||
return_value={"summary": "json summary"},
|
||||
):
|
||||
cli.main()
|
||||
|
||||
result = json.loads(capsys.readouterr().out)
|
||||
assert set(result["context_savings"]) == {
|
||||
"estimated",
|
||||
"saved_tokens",
|
||||
"saved_percent",
|
||||
}
|
||||
|
||||
|
||||
class TestDetectChangesEndToEnd:
|
||||
"""Regression test for #528: CLI detect-changes mapped 0 functions.
|
||||
|
||||
The graph stores absolute native paths, but the CLI path let
|
||||
analyze_changes parse the diff internally, producing forward-slash
|
||||
relative keys that never matched on Windows. This exercises the full
|
||||
pipeline on a real tmp git repo with a committed change.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _git(repo, *args):
|
||||
import subprocess
|
||||
|
||||
subprocess.run(
|
||||
["git", "-C", str(repo), "-c", "user.email=t@example.com",
|
||||
"-c", "user.name=Test", "-c", "commit.gpgsign=false", *args],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
stdin=subprocess.DEVNULL,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
def test_detect_changes_maps_committed_change_to_functions(
|
||||
self, tmp_path, capsys, monkeypatch,
|
||||
):
|
||||
monkeypatch.delenv("CRG_DATA_DIR", raising=False)
|
||||
monkeypatch.delenv("CRG_REPO_ROOT", raising=False)
|
||||
|
||||
repo = tmp_path / "repo"
|
||||
src = repo / "src"
|
||||
src.mkdir(parents=True)
|
||||
app = src / "app.py"
|
||||
app.write_text(
|
||||
"def greet(name):\n"
|
||||
" message = 'hello ' + name\n"
|
||||
" return message\n"
|
||||
"\n"
|
||||
"def farewell(name):\n"
|
||||
" return 'bye ' + name\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
self._git(repo, "init", "-q")
|
||||
self._git(repo, "add", ".")
|
||||
self._git(repo, "commit", "-q", "-m", "initial")
|
||||
|
||||
# Commit a change inside greet() so HEAD~1..HEAD touches lines 1-3.
|
||||
app.write_text(
|
||||
"def greet(name):\n"
|
||||
" message = 'hi there ' + name\n"
|
||||
" return message.upper()\n"
|
||||
"\n"
|
||||
"def farewell(name):\n"
|
||||
" return 'bye ' + name\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
self._git(repo, "add", ".")
|
||||
self._git(repo, "commit", "-q", "-m", "change greet")
|
||||
|
||||
# Build the graph after the change (stores absolute native paths).
|
||||
from code_review_graph.graph import GraphStore
|
||||
from code_review_graph.incremental import full_build, get_db_path
|
||||
|
||||
store = GraphStore(get_db_path(repo))
|
||||
try:
|
||||
full_build(repo, store)
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
argv = ["code-review-graph", "detect-changes", "--repo", str(repo)]
|
||||
with patch.object(sys, "argv", argv):
|
||||
cli.main()
|
||||
|
||||
out = capsys.readouterr().out
|
||||
result = json.loads(out[out.index("{"):])
|
||||
|
||||
# The diff must map to >0 functions — not silently come up empty.
|
||||
names = {f["name"] for f in result["changed_functions"]}
|
||||
assert "greet" in names
|
||||
|
||||
# The token-savings metadata must not be the misleading
|
||||
# 100%-on-empty case (tiny response because nothing was mapped).
|
||||
savings = result["context_savings"]
|
||||
assert result["changed_functions"]
|
||||
assert savings["saved_percent"] < 100
|
||||
Reference in New Issue
Block a user