"""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