224 lines
6.7 KiB
Python
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
|