chore: sync local changes, add Chinese docs and opencode config
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user