Files
code-review-graph/tests/test_progress_bridge.py
T

224 lines
6.7 KiB
Python

"""Tests for the MCP progress bridge (_run_with_progress) and engine progress_cb.
Covers:
- Engine functions accept and invoke ``progress_cb`` (compute_coverage /
deep_read_plan / score_review / compute_file_churn).
- The event-loop heartbeat helper ``_run_with_progress`` emits MCP progress
notifications and relays real progress from the worker thread.
- ``CRG_TOOL_TIMEOUT`` server-side backstop returns a readable error dict.
"""
from __future__ import annotations
import asyncio
import sys
import time
from pathlib import Path
from typing import Any, Callable, Optional
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent))
from code_review_graph.scoring import ( # noqa: E402
compute_coverage,
deep_read_plan,
score_review,
)
class _FakeContext:
"""Minimal stand-in for fastmcp Context with a recording report_progress."""
def __init__(self) -> None:
self.calls: list[tuple[float, Optional[float], Optional[str]]] = []
async def report_progress(
self, progress: float, total: Optional[float] = None, message: Optional[str] = None
) -> None:
self.calls.append((progress, total, message))
class _FakeStore:
"""Minimal GraphStore stub covering what coverage/score use."""
def __init__(self, files: list[str]) -> None:
self._files = list(files)
def get_all_files(self) -> list[str]:
return list(self._files)
def get_nodes_by_file(self, file_path: str):
return []
def get_edges_by_target(self, qualified_name: str):
return []
def get_community_ids_by_qualified_names(self, qualified_names):
return {}
def get_edges_by_source(self, qualified_name: str):
return []
def get_communities(self, limit=None):
return []
def get_edges(self, kind=None, limit=None):
return []
@pytest.fixture
def risk_repo(tmp_path: Path) -> Path:
"""a.py carries an SQL-risk signal (fail w1), the rest are clean."""
Path(tmp_path, "src").mkdir(exist_ok=True)
Path(tmp_path, "src", "a.py").write_text(
"def a():\n"
" sql = 'SELECT * FROM users WHERE id=' + str(uid)\n"
" return sql\n",
encoding="utf-8",
)
for name in ("b", "c", "d"):
Path(tmp_path, "src", f"{name}.py").write_text(
f"def {name}():\n return 2\n",
encoding="utf-8",
)
return tmp_path
def _record_progress(records: list[tuple[float, Optional[str]]]) -> Callable[[float, Optional[str]], None]:
def cb(fraction: float, message: Optional[str]) -> None:
records.append((fraction, message))
return cb
# ---------------------------------------------------------------------------
# Engine progress_cb
# ---------------------------------------------------------------------------
def test_compute_coverage_invokes_progress_cb(risk_repo):
files = ["src/a.py", "src/b.py", "src/c.py", "src/d.py"]
store = _FakeStore(files)
records: list[tuple[float, Optional[str]]] = []
result = compute_coverage(
store, risk_repo,
deep_read_files=["src/a.py"],
include_churn=False,
progress_cb=_record_progress(records),
)
assert result["status"] == "ok"
# Weights report every 50 files (>=1 call) plus a final "done".
assert len(records) >= 1
assert records[-1][0] == 1.0
assert "done" in (records[-1][1] or "").lower()
def test_deep_read_plan_invokes_progress_cb(risk_repo):
files = ["src/a.py", "src/b.py", "src/c.py", "src/d.py"]
store = _FakeStore(files)
records: list[tuple[float, Optional[str]]] = []
plan = deep_read_plan(
store, risk_repo,
deep_read_files=["src/a.py"],
target_coverage=85.0,
include_churn=False,
progress_cb=_record_progress(records),
)
assert plan["status"] == "ok"
assert len(records) >= 1
assert records[-1][0] == 1.0
def test_score_review_invokes_progress_cb(risk_repo):
store = _FakeStore([])
records: list[tuple[float, Optional[str]]] = []
result = score_review(
store, risk_repo,
changed_files=["src/a.py", "src/b.py", "src/c.py", "src/d.py"],
include_churn=False,
progress_cb=_record_progress(records),
)
assert result["status"] == "ok"
# One report per metric (5) + final done.
assert len(records) >= 5
assert records[-1][0] == 1.0
# ---------------------------------------------------------------------------
# _run_with_progress heartbeat helper
# ---------------------------------------------------------------------------
def test_run_with_progress_emits_heartbeat_and_real_progress(risk_repo):
from code_review_graph.main import _run_with_progress
ctx = _FakeContext()
def slow_coverage(deep_read_files, repo_root, progress_cb=None, **kw):
for i in range(3):
time.sleep(0.05)
if progress_cb:
progress_cb(i / 3.0, f"step {i}")
return compute_coverage(
_FakeStore(["src/a.py", "src/b.py"]),
repo_root,
deep_read_files=deep_read_files,
include_churn=False,
progress_cb=progress_cb,
)
result = asyncio.run(
_run_with_progress(
ctx, slow_coverage,
deep_read_files=["src/a.py"], repo_root=risk_repo,
heartbeat=0.02, tool_timeout=0,
)
)
assert result["status"] == "ok"
# Heartbeat + engine progress: at least one notification, and a real
# (non-"processing...") message from the worker surfaced through.
assert len(ctx.calls) >= 1
messages = [m for (_, _, m) in ctx.calls if m]
assert any("step" in m for m in messages), f"real progress not relayed: {messages}"
def test_run_with_progress_timeout_returns_error(risk_repo):
from code_review_graph.main import _run_with_progress
ctx = _FakeContext()
def forever(**kw):
time.sleep(5)
return {"status": "ok"}
result = asyncio.run(
_run_with_progress(
ctx, forever,
heartbeat=0.01, tool_timeout=1,
)
)
assert result["status"] == "error"
assert "timeout" in (result.get("error") or "").lower()
def test_run_with_progress_relays_when_no_progress_cb_param():
from code_review_graph.main import _run_with_progress
ctx = _FakeContext()
def plain(**kw):
time.sleep(0.2)
return {"status": "ok", "value": 42}
result = asyncio.run(
_run_with_progress(
ctx, plain,
heartbeat=0.02, tool_timeout=0,
)
)
assert result["status"] == "ok" and result["value"] == 42
# Pure heartbeat notifications (no real progress) still fire to keep the
# client timeout reset.
assert len(ctx.calls) >= 1