Files
tirth8205--code-review-graph/tests/test_incremental.py
T
2026-07-13 12:42:18 +08:00

871 lines
33 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Tests for the incremental graph update module."""
import subprocess
from unittest.mock import MagicMock, patch # noqa: F401 patch used in tests
from code_review_graph.graph import GraphStore
from code_review_graph.incremental import (
_is_binary,
_load_ignore_patterns,
_parse_single_file,
_should_ignore,
_single_hop_dependents,
ensure_repo_gitignore_excludes_crg,
find_dependents,
find_project_root,
find_repo_root,
full_build,
get_all_tracked_files,
get_changed_files,
get_db_path,
get_staged_and_unstaged,
incremental_update,
start_watch_thread,
)
class TestFindRepoRoot:
def test_finds_git_dir(self, tmp_path):
(tmp_path / ".git").mkdir()
assert find_repo_root(tmp_path) == tmp_path
def test_finds_parent_git_dir(self, tmp_path):
(tmp_path / ".git").mkdir()
sub = tmp_path / "a" / "b"
sub.mkdir(parents=True)
assert find_repo_root(sub) == tmp_path
def test_returns_none_without_git(self, tmp_path):
"""No .git between ``sub`` and ``tmp_path`` -> None.
Bounded with ``stop_at=tmp_path`` so the walk does not climb into
ancestors outside the test sandbox. On Windows in particular,
``tmp_path`` lives under ``C:/Users/<user>/AppData/Local/Temp/...``
and if the user has ``git init`` anywhere under their home (dotfiles,
chezmoi, etc.) the unbounded walk would find that ancestor .git and
the test would fail for reasons unrelated to the product. See #241.
"""
sub = tmp_path / "no_git"
sub.mkdir()
assert find_repo_root(sub, stop_at=tmp_path) is None
def test_stop_at_prevents_escape_to_outer_git(self, tmp_path):
"""Positive regression test for #241: ``stop_at`` must halt the
walk even when an ancestor *does* contain ``.git``.
Without ``stop_at`` the walk correctly finds the outer .git; with
``stop_at=inner`` the walk is bounded and returns None.
"""
outer = tmp_path / "outer"
outer.mkdir()
(outer / ".git").mkdir()
inner = outer / "inner"
inner.mkdir()
# Unbounded walk finds the ancestor .git (existing behavior).
assert find_repo_root(inner) == outer
# Bounded walk stops at ``inner`` and never climbs to ``outer``.
assert find_repo_root(inner, stop_at=inner) is None
def test_stop_at_finds_git_at_boundary(self, tmp_path):
"""stop_at does not suppress a .git that lives *at* the boundary."""
boundary = tmp_path / "boundary"
boundary.mkdir()
(boundary / ".git").mkdir()
inner = boundary / "inner"
inner.mkdir()
# The walk examines ``boundary`` and finds the .git before stopping.
assert find_repo_root(inner, stop_at=boundary) == boundary
class TestFindProjectRoot:
def test_returns_git_root(self, tmp_path):
(tmp_path / ".git").mkdir()
assert find_project_root(tmp_path) == tmp_path
def test_falls_back_to_start(self, tmp_path, monkeypatch):
"""With no .git and no env override, find_project_root returns ``sub``.
Bounded with ``stop_at=tmp_path`` to prevent the ancestor walk from
escaping the test sandbox (see #241), and ``CRG_REPO_ROOT`` is
cleared so a developer env var cannot shadow the test expectation.
"""
monkeypatch.delenv("CRG_REPO_ROOT", raising=False)
sub = tmp_path / "no_git"
sub.mkdir()
assert find_project_root(sub, stop_at=tmp_path) == sub
def test_stop_at_forwarded_to_find_repo_root(self, tmp_path, monkeypatch):
"""Positive regression test for #241: find_project_root must forward
stop_at to find_repo_root, not silently drop it."""
monkeypatch.delenv("CRG_REPO_ROOT", raising=False)
outer = tmp_path / "outer"
outer.mkdir()
(outer / ".git").mkdir()
inner = outer / "inner"
inner.mkdir()
# Without stop_at, find_project_root climbs to outer (existing behavior).
assert find_project_root(inner) == outer
# With stop_at=inner, the walk is bounded and find_project_root falls
# back to its third resolution rule (the start path itself).
assert find_project_root(inner, stop_at=inner) == inner
class TestGetDbPath:
def test_creates_directory_and_db_path(self, tmp_path):
db_path = get_db_path(tmp_path)
assert db_path == tmp_path / ".code-review-graph" / "graph.db"
assert (tmp_path / ".code-review-graph").is_dir()
def test_creates_gitignore(self, tmp_path):
get_db_path(tmp_path)
gi = tmp_path / ".code-review-graph" / ".gitignore"
assert gi.exists()
assert "*\n" in gi.read_text()
def test_migrates_legacy_db(self, tmp_path):
legacy = tmp_path / ".code-review-graph.db"
legacy.write_text("legacy data")
db_path = get_db_path(tmp_path)
assert db_path.exists()
assert not legacy.exists()
assert db_path.read_text() == "legacy data"
def test_cleans_legacy_side_files(self, tmp_path):
legacy = tmp_path / ".code-review-graph.db"
legacy.write_text("data")
for suffix in ("-wal", "-shm", "-journal"):
(tmp_path / f".code-review-graph.db{suffix}").write_text("side")
get_db_path(tmp_path)
for suffix in ("-wal", "-shm", "-journal"):
assert not (tmp_path / f".code-review-graph.db{suffix}").exists()
class TestEnsureRepoGitignoreExcludesCrg:
def test_creates_gitignore_when_missing(self, tmp_path):
state = ensure_repo_gitignore_excludes_crg(tmp_path)
assert state == "created"
gitignore = tmp_path / ".gitignore"
assert gitignore.exists()
assert gitignore.read_text() == (
"# Added by code-review-graph\n"
".code-review-graph/\n"
)
def test_appends_rule_when_missing(self, tmp_path):
gitignore = tmp_path / ".gitignore"
gitignore.write_text("node_modules/\n")
state = ensure_repo_gitignore_excludes_crg(tmp_path)
assert state == "updated"
assert gitignore.read_text() == (
"node_modules/\n"
"# Added by code-review-graph\n"
".code-review-graph/\n"
)
def test_idempotent_when_present(self, tmp_path):
gitignore = tmp_path / ".gitignore"
gitignore.write_text(".code-review-graph/\n")
state = ensure_repo_gitignore_excludes_crg(tmp_path)
assert state == "already-present"
assert gitignore.read_text() == ".code-review-graph/\n"
def test_treats_wildcard_ignore_as_present(self, tmp_path):
gitignore = tmp_path / ".gitignore"
gitignore.write_text(".code-review-graph/**\n")
state = ensure_repo_gitignore_excludes_crg(tmp_path)
assert state == "already-present"
class TestIgnorePatterns:
def test_default_patterns_loaded(self, tmp_path):
patterns = _load_ignore_patterns(tmp_path)
assert "node_modules/**" in patterns
assert ".git/**" in patterns
assert "__pycache__/**" in patterns
def test_custom_ignore_file(self, tmp_path):
ignore = tmp_path / ".code-review-graphignore"
ignore.write_text("custom/**\n# comment\n\nvendor/**\n")
patterns = _load_ignore_patterns(tmp_path)
assert "custom/**" in patterns
assert "vendor/**" in patterns
# Comments and blanks should be skipped
assert "# comment" not in patterns
assert "" not in patterns
def test_should_ignore_matches(self):
patterns = ["node_modules/**", "*.pyc", ".git/**"]
assert _should_ignore("node_modules/foo/bar.js", patterns)
assert _should_ignore("test.pyc", patterns)
assert _should_ignore(".git/HEAD", patterns)
assert not _should_ignore("src/main.py", patterns)
def test_should_ignore_nested_dependency_dirs(self):
"""Nested node_modules / vendor / .gradle should be ignored (#91)."""
patterns = [
"node_modules/**", "vendor/**", ".gradle/**", ".venv/**",
]
# Monorepo: nested node_modules
assert _should_ignore("packages/app/node_modules/react/index.js", patterns)
assert _should_ignore("apps/web/node_modules/lodash/index.js", patterns)
# PHP/Laravel: vendor at any depth
assert _should_ignore("backend/vendor/autoload.php", patterns)
# Gradle at any depth
assert _should_ignore("android/app/.gradle/cache/metadata.bin", patterns)
# Negative: similarly-named dirs that aren't a match
assert not _should_ignore("src/node_modules_helper/foo.py", patterns)
assert not _should_ignore("src/venv_tools/bar.py", patterns)
def test_should_ignore_framework_defaults(self):
"""Default patterns should cover Laravel, Gradle, Flutter, and caches."""
from code_review_graph.incremental import DEFAULT_IGNORE_PATTERNS
patterns = DEFAULT_IGNORE_PATTERNS
# Laravel/PHP
assert _should_ignore("vendor/autoload.php", patterns)
assert _should_ignore("bootstrap/cache/packages.php", patterns)
# Gradle/Java
assert _should_ignore(".gradle/caches/jars.bin", patterns)
assert _should_ignore("build/libs/app.jar", patterns)
# Flutter/Dart
assert _should_ignore(".dart_tool/package_config.json", patterns)
# Coverage/cache
assert _should_ignore("coverage/lcov.info", patterns)
assert _should_ignore(".cache/webpack/index.pack", patterns)
class TestDataDir:
"""Tests for get_data_dir / CRG_DATA_DIR / CRG_REPO_ROOT (#155)."""
def test_default_uses_repo_subdir(self, tmp_path, monkeypatch):
"""Without CRG_DATA_DIR, graphs live at <repo>/.code-review-graph."""
monkeypatch.delenv("CRG_DATA_DIR", raising=False)
from code_review_graph.incremental import get_data_dir
result = get_data_dir(tmp_path)
assert result == tmp_path / ".code-review-graph"
assert result.is_dir()
# Auto-generated gitignore must exist
assert (result / ".gitignore").is_file()
content = (result / ".gitignore").read_text(encoding="utf-8")
assert content.strip().endswith("*")
def test_auto_gitignore_is_valid_utf8(self, tmp_path, monkeypatch):
"""Regression guard for #239 bug 1: the auto-generated .gitignore
must be written as UTF-8 on every platform.
Before the fix, ``write_text()`` was called without an encoding
argument. The header contains an em-dash (U+2014) which Python
writes using the system default codepage on Windows (cp1252 →
byte 0x97), producing a file that cannot be decoded as UTF-8.
"""
monkeypatch.delenv("CRG_DATA_DIR", raising=False)
from code_review_graph.incremental import get_data_dir
data_dir = get_data_dir(tmp_path)
gi = data_dir / ".gitignore"
assert gi.is_file()
# The file must be valid UTF-8 — this is what actually broke.
raw = gi.read_bytes()
# The em-dash must be stored as the proper UTF-8 sequence (0xE2 0x80 0x94),
# not as the cp1252 single byte 0x97.
assert b"\xe2\x80\x94" in raw, (
"auto-generated .gitignore is missing the UTF-8 em-dash; it was "
"probably written using the platform default codepage"
)
assert b"\x97" not in raw, (
"auto-generated .gitignore contains cp1252 byte 0x97 — indicates "
"write_text was called without encoding='utf-8'"
)
# And it must round-trip cleanly under strict UTF-8 decoding.
decoded = raw.decode("utf-8", errors="strict")
assert "—" in decoded, "em-dash missing from decoded gitignore"
def test_env_override_replaces_repo_subdir(self, tmp_path, monkeypatch):
"""CRG_DATA_DIR replaces the default <repo>/.code-review-graph."""
external = tmp_path / "external-graphs"
repo = tmp_path / "project"
repo.mkdir()
monkeypatch.setenv("CRG_DATA_DIR", str(external))
from code_review_graph.incremental import get_data_dir
result = get_data_dir(repo)
assert result == external.resolve()
assert result.is_dir()
# The repo itself should NOT have a .code-review-graph dir now
assert not (repo / ".code-review-graph").exists()
def test_get_db_path_uses_data_dir(self, tmp_path, monkeypatch):
"""get_db_path should honor CRG_DATA_DIR too."""
external = tmp_path / "external"
repo = tmp_path / "project"
repo.mkdir()
monkeypatch.setenv("CRG_DATA_DIR", str(external))
from code_review_graph.incremental import get_db_path
db_path = get_db_path(repo)
assert db_path == external.resolve() / "graph.db"
assert db_path.parent.is_dir()
def test_find_project_root_env_override(self, tmp_path, monkeypatch):
"""CRG_REPO_ROOT should override normal git-root resolution."""
from pathlib import Path as PathType
external_repo = tmp_path / "elsewhere"
external_repo.mkdir()
monkeypatch.setenv("CRG_REPO_ROOT", str(external_repo))
from code_review_graph.incremental import find_project_root
result = find_project_root(PathType.cwd())
assert result == external_repo.resolve()
def test_find_project_root_env_override_missing_dir_falls_through(
self, tmp_path, monkeypatch,
):
"""CRG_REPO_ROOT pointing at a non-existent path falls back to
the usual resolution rather than crashing."""
monkeypatch.setenv(
"CRG_REPO_ROOT", str(tmp_path / "does-not-exist-123"),
)
from code_review_graph.incremental import find_project_root
result = find_project_root(tmp_path)
# Should NOT equal the bogus env value
assert result != tmp_path / "does-not-exist-123"
class TestDataDirRegistry:
"""Tests for registry-based data_dir resolution."""
def test_registry_data_dir_overrides_default(self, tmp_path, monkeypatch):
"""Registry data_dir should override default .code-review-graph."""
from code_review_graph.incremental import get_data_dir
from code_review_graph.registry import Registry
repo = tmp_path / "project"
repo.mkdir()
external = tmp_path / "external"
monkeypatch.delenv("CRG_DATA_DIR", raising=False)
# Set in registry
registry = Registry()
registry.set_data_dir(str(repo), str(external))
result = get_data_dir(repo)
assert result == external.resolve()
assert result.is_dir()
assert not (repo / ".code-review-graph").exists()
def test_registry_data_dir_overrides_env_var(self, tmp_path, monkeypatch):
"""Registry data_dir should override CRG_DATA_DIR."""
from code_review_graph.incremental import get_data_dir
from code_review_graph.registry import Registry
repo = tmp_path / "project"
repo.mkdir()
registry_dir = tmp_path / "registry-data"
env_dir = tmp_path / "env-data"
monkeypatch.setenv("CRG_DATA_DIR", str(env_dir))
# Set in registry
registry = Registry()
registry.set_data_dir(str(repo), str(registry_dir))
result = get_data_dir(repo)
# Registry should win over env var
assert result == registry_dir.resolve()
assert not env_dir.exists()
def test_registry_fallback_to_env_var(self, tmp_path, monkeypatch):
"""Fall back to CRG_DATA_DIR when registry has no entry."""
from code_review_graph.incremental import get_data_dir
from code_review_graph.registry import Registry
repo = tmp_path / "project"
repo.mkdir()
env_dir = tmp_path / "env-data"
monkeypatch.setenv("CRG_DATA_DIR", str(env_dir))
# Don't set in registry
result = get_data_dir(repo)
assert result == env_dir.resolve()
assert result.is_dir()
def test_registry_fallback_to_default(self, tmp_path, monkeypatch):
"""Fall back to default when neither registry nor env var is set."""
from code_review_graph.incremental import get_data_dir
from code_review_graph.registry import Registry
repo = tmp_path / "project"
repo.mkdir()
monkeypatch.delenv("CRG_DATA_DIR", raising=False)
# Don't set in registry
result = get_data_dir(repo)
assert result == repo / ".code-review-graph"
assert result.is_dir()
def test_data_dir_auto_creates_directory(self, tmp_path, monkeypatch):
"""get_data_dir should auto-create the data directory."""
from code_review_graph.incremental import get_data_dir
from code_review_graph.registry import Registry
repo = tmp_path / "project"
repo.mkdir()
data_dir = tmp_path / "nonexistent" / "nested" / "path"
monkeypatch.delenv("CRG_DATA_DIR", raising=False)
registry = Registry()
registry.set_data_dir(str(repo), str(data_dir))
result = get_data_dir(repo)
assert result.exists()
assert result.is_dir()
assert result == data_dir.resolve()
class TestIsBinary:
def test_text_file_is_not_binary(self, tmp_path):
f = tmp_path / "text.py"
f.write_text("print('hello')\n")
assert not _is_binary(f)
def test_binary_file_is_binary(self, tmp_path):
f = tmp_path / "binary.bin"
f.write_bytes(b"header\x00binary data")
assert _is_binary(f)
def test_missing_file_is_binary(self, tmp_path):
f = tmp_path / "missing.txt"
assert _is_binary(f)
class TestGitOperations:
@patch("code_review_graph.incremental.subprocess.run")
def test_get_changed_files(self, mock_run, tmp_path):
mock_run.return_value = MagicMock(
returncode=0,
stdout="src/a.py\nsrc/b.py\n",
)
result = get_changed_files(tmp_path)
assert result == ["src/a.py", "src/b.py"]
mock_run.assert_called_once()
call_args = mock_run.call_args
assert "git" in call_args[0][0]
assert call_args[1].get("timeout") == 30
@patch("code_review_graph.incremental.subprocess.run")
def test_get_changed_files_fallback(self, mock_run, tmp_path):
# First call fails, second succeeds
mock_run.side_effect = [
MagicMock(returncode=1, stdout=""),
MagicMock(returncode=0, stdout="staged.py\n"),
]
result = get_changed_files(tmp_path)
assert result == ["staged.py"]
assert mock_run.call_count == 2
@patch("code_review_graph.incremental.subprocess.run")
def test_get_changed_files_timeout(self, mock_run, tmp_path):
mock_run.side_effect = subprocess.TimeoutExpired("git", 30)
result = get_changed_files(tmp_path)
assert result == []
@patch("code_review_graph.incremental.subprocess.run")
def test_get_staged_and_unstaged(self, mock_run, tmp_path):
mock_run.return_value = MagicMock(
returncode=0,
stdout=" M src/a.py\n?? new.py\nR old.py -> new_name.py\n",
)
result = get_staged_and_unstaged(tmp_path)
assert "src/a.py" in result
assert "new.py" in result
assert "new_name.py" in result
# old.py should NOT be in results (renamed away)
assert "old.py" not in result
@patch("code_review_graph.incremental.subprocess.run")
def test_get_all_tracked_files(self, mock_run, tmp_path):
mock_run.return_value = MagicMock(
returncode=0,
stdout="a.py\nb.py\nc.go\n",
)
result = get_all_tracked_files(tmp_path)
assert result == ["a.py", "b.py", "c.go"]
@patch("code_review_graph.incremental.subprocess.run")
def test_get_all_tracked_files_recurse_submodules_param(
self, mock_run, tmp_path
):
mock_run.return_value = MagicMock(
returncode=0,
stdout="a.py\nsub/b.py\n",
)
result = get_all_tracked_files(tmp_path, recurse_submodules=True)
assert result == ["a.py", "sub/b.py"]
cmd = mock_run.call_args[0][0]
assert "--recurse-submodules" in cmd
@patch("code_review_graph.incremental.subprocess.run")
def test_get_all_tracked_files_no_recurse_by_default(
self, mock_run, tmp_path
):
mock_run.return_value = MagicMock(
returncode=0,
stdout="a.py\n",
)
result = get_all_tracked_files(tmp_path)
assert result == ["a.py"]
cmd = mock_run.call_args[0][0]
assert "--recurse-submodules" not in cmd
@patch("code_review_graph.incremental.subprocess.run")
@patch("code_review_graph.incremental._RECURSE_SUBMODULES", True)
def test_get_all_tracked_files_env_var_fallback(
self, mock_run, tmp_path
):
mock_run.return_value = MagicMock(
returncode=0,
stdout="a.py\nsub/c.py\n",
)
# None -> falls back to env var (_RECURSE_SUBMODULES=True)
result = get_all_tracked_files(tmp_path, recurse_submodules=None)
assert result == ["a.py", "sub/c.py"]
cmd = mock_run.call_args[0][0]
assert "--recurse-submodules" in cmd
@patch("code_review_graph.incremental.subprocess.run")
@patch("code_review_graph.incremental._RECURSE_SUBMODULES", True)
def test_get_all_tracked_files_param_overrides_env(
self, mock_run, tmp_path
):
mock_run.return_value = MagicMock(
returncode=0,
stdout="a.py\n",
)
# Explicit False overrides env var
result = get_all_tracked_files(tmp_path, recurse_submodules=False)
assert result == ["a.py"]
cmd = mock_run.call_args[0][0]
assert "--recurse-submodules" not in cmd
class TestFullBuild:
def test_full_build_parses_files(self, tmp_path):
# Create a simple Python file
py_file = tmp_path / "sample.py"
py_file.write_text("def hello():\n pass\n")
(tmp_path / ".git").mkdir()
db_path = tmp_path / "test.db"
store = GraphStore(db_path)
try:
mock_target = "code_review_graph.incremental.get_all_tracked_files"
with patch(mock_target, return_value=["sample.py"]):
result = full_build(tmp_path, store)
assert result["files_parsed"] == 1
assert result["total_nodes"] > 0
assert result["errors"] == []
assert store.get_metadata("last_build_type") == "full"
finally:
store.close()
class TestIncrementalUpdate:
def test_incremental_with_no_changes(self, tmp_path):
db_path = tmp_path / "test.db"
store = GraphStore(db_path)
try:
result = incremental_update(tmp_path, store, changed_files=[])
assert result["files_updated"] == 0
finally:
store.close()
def test_incremental_with_changed_file(self, tmp_path):
py_file = tmp_path / "mod.py"
py_file.write_text("def greet():\n return 'hi'\n")
db_path = tmp_path / "test.db"
store = GraphStore(db_path)
try:
result = incremental_update(
tmp_path, store, changed_files=["mod.py"]
)
assert result["files_updated"] >= 1
assert result["total_nodes"] > 0
finally:
store.close()
def test_incremental_deleted_file(self, tmp_path):
db_path = tmp_path / "test.db"
store = GraphStore(db_path)
try:
# Pre-populate with a file
py_file = tmp_path / "old.py"
py_file.write_text("x = 1\n")
result = incremental_update(tmp_path, store, changed_files=["old.py"])
assert result["total_nodes"] > 0
# Now delete the file and run incremental
py_file.unlink()
incremental_update(tmp_path, store, changed_files=["old.py"])
# File should have been removed from graph
nodes = store.get_nodes_by_file(str(tmp_path / "old.py"))
assert len(nodes) == 0
finally:
store.close()
class TestParallelParsing:
def test_parse_single_file(self, tmp_path):
py_file = tmp_path / "single.py"
py_file.write_text("def foo():\n pass\n")
rel_path, nodes, edges, error, fhash = _parse_single_file(
("single.py", str(tmp_path))
)
assert rel_path == "single.py"
assert error is None
assert len(nodes) > 0
assert fhash != ""
def test_parse_single_file_missing(self, tmp_path):
rel_path, nodes, edges, error, fhash = _parse_single_file(
("missing.py", str(tmp_path))
)
assert error is not None
assert nodes == []
assert edges == []
def test_parallel_build_produces_same_results(self, tmp_path):
"""Serial and parallel builds produce identical node/edge counts."""
(tmp_path / ".git").mkdir()
# Create several Python files
for i in range(10):
(tmp_path / f"mod{i}.py").write_text(
f"def func_{i}():\n return {i}\n\n"
f"class Cls{i}:\n pass\n"
)
tracked = [f"mod{i}.py" for i in range(10)]
mock_target = "code_review_graph.incremental.get_all_tracked_files"
# Serial build
db_serial = tmp_path / "serial.db"
store_serial = GraphStore(db_serial)
try:
with patch(mock_target, return_value=tracked):
with patch.dict("os.environ", {"CRG_SERIAL_PARSE": "1"}):
result_serial = full_build(tmp_path, store_serial)
serial_nodes = result_serial["total_nodes"]
serial_edges = result_serial["total_edges"]
serial_files = result_serial["files_parsed"]
finally:
store_serial.close()
# Parallel build
db_parallel = tmp_path / "parallel.db"
store_parallel = GraphStore(db_parallel)
try:
with patch(mock_target, return_value=tracked):
with patch.dict("os.environ", {"CRG_SERIAL_PARSE": ""}):
result_parallel = full_build(tmp_path, store_parallel)
parallel_nodes = result_parallel["total_nodes"]
parallel_edges = result_parallel["total_edges"]
parallel_files = result_parallel["files_parsed"]
finally:
store_parallel.close()
assert serial_files == parallel_files
assert serial_nodes == parallel_nodes
assert serial_edges == parallel_edges
class TestMultiHopDependents:
"""Tests for N-hop dependent discovery."""
def _make_chain_store(self, tmp_path):
"""Build A -> B -> C chain in the graph."""
from code_review_graph.parser import EdgeInfo, NodeInfo
db_path = tmp_path / "chain.db"
store = GraphStore(db_path)
for name, path in [("a", "/a.py"), ("b", "/b.py"), ("c", "/c.py")]:
store.upsert_node(NodeInfo(
kind="File", name=path, file_path=path,
line_start=1, line_end=10, language="python",
))
store.upsert_node(NodeInfo(
kind="Function", name=f"func_{name}", file_path=path,
line_start=2, line_end=8, language="python",
))
# A imports B, B imports C
store.upsert_edge(EdgeInfo(
kind="IMPORTS_FROM", source="/a.py::func_a",
target="/b.py::func_b", file_path="/a.py", line=1,
))
store.upsert_edge(EdgeInfo(
kind="IMPORTS_FROM", source="/b.py::func_b",
target="/c.py::func_c", file_path="/b.py", line=1,
))
store.commit()
return store
def test_single_hop_finds_direct_only(self, tmp_path):
store = self._make_chain_store(tmp_path)
try:
deps = _single_hop_dependents(store, "/c.py")
assert "/b.py" in deps
assert "/a.py" not in deps
finally:
store.close()
def test_one_hop_finds_b_not_a(self, tmp_path):
store = self._make_chain_store(tmp_path)
try:
deps = find_dependents(store, "/c.py", max_hops=1)
assert "/b.py" in deps
assert "/a.py" not in deps
finally:
store.close()
def test_two_hops_finds_b_and_a(self, tmp_path):
store = self._make_chain_store(tmp_path)
try:
deps = find_dependents(store, "/c.py", max_hops=2)
assert "/b.py" in deps
assert "/a.py" in deps
finally:
store.close()
def test_cap_triggers_on_many_files(self, tmp_path):
"""The 500-file cap prevents runaway expansion."""
from code_review_graph.parser import EdgeInfo, NodeInfo
db_path = tmp_path / "big.db"
store = GraphStore(db_path)
try:
# Hub node that many files depend on
store.upsert_node(NodeInfo(
kind="File", name="/hub.py", file_path="/hub.py",
line_start=1, line_end=10, language="python",
))
store.upsert_node(NodeInfo(
kind="Function", name="hub_func", file_path="/hub.py",
line_start=2, line_end=8, language="python",
))
for i in range(600):
path = f"/dep{i}.py"
store.upsert_node(NodeInfo(
kind="File", name=path, file_path=path,
line_start=1, line_end=10, language="python",
))
store.upsert_node(NodeInfo(
kind="Function", name=f"func_{i}", file_path=path,
line_start=2, line_end=8, language="python",
))
store.upsert_edge(EdgeInfo(
kind="IMPORTS_FROM", source=f"{path}::func_{i}",
target="/hub.py::hub_func", file_path=path, line=1,
))
store.commit()
# Even with high max_hops, cap should limit results
deps = find_dependents(store, "/hub.py", max_hops=5)
assert len(deps) <= 500
finally:
store.close()
def test_truncated_flag_set_when_capped(self, tmp_path):
"""Regression test for #261: find_dependents must set
DependentList.truncated = True when the result is capped."""
from code_review_graph.parser import EdgeInfo, NodeInfo
db_path = tmp_path / "trunc.db"
store = GraphStore(db_path)
try:
store.upsert_node(NodeInfo(
kind="File", name="/hub.py", file_path="/hub.py",
line_start=1, line_end=10, language="python",
))
store.upsert_node(NodeInfo(
kind="Function", name="hub_func", file_path="/hub.py",
line_start=2, line_end=8, language="python",
))
for i in range(600):
path = f"/dep{i}.py"
store.upsert_node(NodeInfo(
kind="File", name=path, file_path=path,
line_start=1, line_end=10, language="python",
))
store.upsert_node(NodeInfo(
kind="Function", name=f"func_{i}", file_path=path,
line_start=2, line_end=8, language="python",
))
store.upsert_edge(EdgeInfo(
kind="IMPORTS_FROM", source=f"{path}::func_{i}",
target="/hub.py::hub_func", file_path=path, line=1,
))
store.commit()
deps = find_dependents(store, "/hub.py", max_hops=5)
assert len(deps) <= 500
# The key assertion: truncated flag must be set.
assert deps.truncated is True, (
"DependentList.truncated should be True when capped at "
"_MAX_DEPENDENT_FILES, but it was False"
)
finally:
store.close()
def test_truncated_flag_false_when_not_capped(self, tmp_path):
"""Regression test for #261: find_dependents must set
DependentList.truncated = False when the result is complete."""
store = self._make_chain_store(tmp_path)
try:
deps = find_dependents(store, "/c.py", max_hops=2)
assert deps.truncated is False, (
"DependentList.truncated should be False when the "
"expansion completed without hitting the cap"
)
finally:
store.close()
class TestStartWatchThread:
@patch("code_review_graph.incremental.watch")
def test_starts_background_thread(self, mock_watch, tmp_path):
"""start_watch_thread returns a running thread when watchdog is available."""
import threading
barrier = threading.Event()
mock_watch.side_effect = lambda *a, **kw: barrier.wait(timeout=5)
db_path = tmp_path / "graph.db"
store = GraphStore(db_path)
try:
thread = start_watch_thread(tmp_path, store, daemon=True)
assert thread is not None
assert thread.daemon is True
assert thread.is_alive()
finally:
barrier.set()
store.close()
def test_returns_none_when_watchdog_unavailable(self, tmp_path):
"""start_watch_thread returns None when watchdog is not installed."""
db_path = tmp_path / "graph.db"
store = GraphStore(db_path)
try:
with patch.dict("sys.modules", {"watchdog": None}):
thread = start_watch_thread(tmp_path, store, daemon=True)
assert thread is None
finally:
store.close()