"""Tests for the V2.1 line/unit coverage gate (gate="both+line"). Covers: per-file line coverage thresholds, unit-completeness (gap-free), giant-file exemption, the four-state gate enum, and the v2 coverage index round-trip (nodes.file_hash based, ranges persisted, v1 fallback). """ from __future__ import annotations import json import sys from pathlib import Path import pytest sys.path.insert(0, str(Path(__file__).parent.parent)) from code_review_graph.scoring import compute_coverage # noqa: E402 from code_review_graph.tools.scoring_tools import ( # noqa: E402 _coverage_index_path, save_coverage_index_func, ) class _Row: def __init__(self, d): self._d = d def __getitem__(self, k): return self._d[k] class _Cursor: def __init__(self, rows): self._rows = rows def fetchall(self): return [_Row(r) for r in self._rows] def fetchone(self): return _Row(self._rows[0]) if self._rows else None class _Conn: def __init__(self, units): self.units = units def execute(self, sql, params=None): if "kind IN ('Function','Class','Test')" in sql: # returns dict-like rows with .keys() for dict(row) conversion return _Cursor([dict(u) for u in self.units]) if "kind='File'" in sql: return _Cursor([]) return _Cursor([]) class _Node: def __init__(self, qualified_name, file_path, is_test=False, kind="Function"): self.qualified_name = qualified_name self.file_path = file_path self.is_test = is_test self.kind = kind class _Store: def __init__(self, files, units=None): self._files = list(files) self._conn = _Conn(units or []) self._nodes = [ _Node("D:/repo/" + f, f) for f in self._files ] def get_all_files(self): return list(self._files) def get_nodes_by_file(self, file_path): return [n for n in self._nodes if n.file_path == file_path] def get_edges_by_target(self, qualified_name): return [] @pytest.fixture def repo(tmp_path: Path) -> Path: Path(tmp_path, "src").mkdir(exist_ok=True) Path(tmp_path, ".git").mkdir(exist_ok=True) # satisfy _get_store repo-root check Path(tmp_path, "src", "a.py").write_text( "\n".join(f"# line {i}" for i in range(1, 41)), encoding="utf-8", ) Path(tmp_path, "src", "b.py").write_text( "def b():\n return 2\n", encoding="utf-8", ) return tmp_path def _mk_store_units(rel, units): """Graph semantic units for a file: {name: [s,e]}.""" return [ {"kind": "Function", "name": name, "line_start": s, "line_end": e} for name, (s, e) in units.items() ] def test_line_coverage_four_tiers(repo): files = ["src/a.py", "src/b.py"] a_units = _mk_store_units("src/a.py", {"a": [1, 40]}) store = _Store(files, a_units) a_rel = "src/a.py" def cov(ranges): return compute_coverage( store, repo, deep_read_files=["src/a.py"], include_churn=False, gate="both+line", file_read_ranges={a_rel: ranges}, file_semantic_units={a_rel: [{"range": [1, 40], "kind": "Function", "name": "a"}]}, ) r = cov([[1, 20]]) # 50% of 40 lines assert r["line_gap_files"][0]["coverage_pct"] == 50.0 assert r["target_reached"] is False r = cov([[1, 40]]) # 100% assert r["line_gap_files"] == [] assert r["line_coverage_pct"] == 100.0 def test_unit_gap_detected(repo): files = ["src/a.py"] units = _mk_store_units("src/a.py", {"a": [1, 20], "b": [21, 40]}) store = _Store(files, units) a_rel = "src/a.py" # sub-agents report relative paths # report only unit a, missing unit b r = compute_coverage( store, repo, deep_read_files=["src/a.py"], include_churn=False, gate="both+line", file_read_ranges={a_rel: [[1, 40]]}, file_semantic_units={a_rel: [{"range": [1, 20], "kind": "Function", "name": "a"}]}, ) assert len(r["unit_gap_files"]) == 1 assert r["unit_gap_files"][0]["covered_units"] == 1 assert r["unit_gap_files"][0]["total_units"] == 2 def test_unit_exempt_giant_file(repo): files = ["src/a.py"] units = _mk_store_units("src/a.py", {"big": [1, 40]}) store = _Store(files, units) a_rel = "src/a.py" # relative path as sub-agents report r = compute_coverage( store, repo, deep_read_files=["src/a.py"], include_churn=False, gate="both+line", file_read_ranges={a_rel: [[1, 2]]}, file_semantic_units={a_rel: [{"range": [1, 2], "kind": "Function", "name": "big"}]}, ) assert len(r["unit_exempt_files"]) == 1 # single unit, span>80% -> exempt assert r["unit_gap_files"] == [] def test_gate_both_line_requires_quality(repo): files = ["src/a.py", "src/b.py"] units = _mk_store_units("src/a.py", {"a": [1, 40]}) store = _Store(files, units) a_rel = "src/a.py" b_rel = "src/b.py" # full file-count coverage but only 50% line coverage on a.py r = compute_coverage( store, repo, deep_read_files=["src/a.py", "src/b.py"], include_churn=False, gate="both+line", file_read_ranges={a_rel: [[1, 20]], b_rel: [[1, 2]]}, file_semantic_units={ a_rel: [{"range": [1, 40], "kind": "Function", "name": "a"}], b_rel: [], }, ) assert r["coverage_pct"] == 100.0 assert r["line_coverage_pct"] < 100.0 assert r["target_reached"] is False # quality gate fails despite 100% file coverage def test_gate_both_unchanged_back_compat(repo): files = ["src/a.py", "src/b.py"] store = _Store(files) r = compute_coverage( store, repo, deep_read_files=["src/a.py", "src/b.py"], include_churn=False, gate="both", ) assert r["gate"] == "both" assert "line_gap_files" in r # present but empty (only filled for both+line) assert r["line_gap_files"] == [] def test_fail_closed_missing_read_ranges(repo): """Deep-read file with NO read_ranges must fail (not silently pass). Guards the v2.5.2 fail-closed fix: previously a deep-read file without file_read_ranges was neither counted as covered nor recorded as a gap, so gate="both+line" returned target_reached=true while line_coverage_pct stayed 0 (silent green). Now it must be a line gap + missing_data. """ files = ["src/a.py"] units = _mk_store_units("src/a.py", {"a": [1, 40]}) store = _Store(files, units) r = compute_coverage( store, repo, deep_read_files=["src/a.py"], include_churn=False, gate="both+line", # NO file_read_ranges / file_semantic_units passed at all ) assert r["line_coverage_pct"] == 0.0 assert len(r["line_gap_files"]) == 1 assert r["line_gap_files"][0]["reason"] == "missing read_ranges" assert any(m["field"] == "file_read_ranges" for m in r["missing_data_files"]) assert r["target_reached"] is False def test_fail_closed_missing_semantic_units(repo): """Graph has units but file_semantic_units is absent -> unit gap.""" files = ["src/a.py"] units = _mk_store_units("src/a.py", {"a": [1, 40]}) store = _Store(files, units) r = compute_coverage( store, repo, deep_read_files=["src/a.py"], include_churn=False, gate="both+line", file_read_ranges={"src/a.py": [[1, 40]]}, # file_semantic_units omitted ) assert r["unit_coverage_pct"] == 0.0 assert len(r["unit_gap_files"]) == 1 assert r["unit_gap_files"][0]["reason"] == "missing semantic_units" assert any(m["field"] == "file_semantic_units" for m in r["missing_data_files"]) assert r["target_reached"] is False def test_fail_closed_full_data_passes(repo): """With complete three-piece data the file is verified (no false gap).""" files = ["src/a.py"] units = _mk_store_units("src/a.py", {"a": [1, 40]}) store = _Store(files, units) r = compute_coverage( store, repo, deep_read_files=["src/a.py"], include_churn=False, gate="both+line", file_read_ranges={"src/a.py": [[1, 40]]}, file_semantic_units={"src/a.py": [{"range": [1, 40], "kind": "Function", "name": "a"}]}, ) assert r["line_coverage_pct"] == 100.0 assert r["line_gap_files"] == [] assert r["unit_gap_files"] == [] assert r["missing_data_files"] == [] def test_gate_line_unit_only_checks_line_and_unit(repo): """gate=\"line+unit\" (feature) checks ONLY line + unit completeness. File-count coverage (1 of 2 files = 50% < 85%) and high-risk coverage are NOT part of the gate: coverage_pct/high_risk_coverage_pct are None and target_reached reflects the line/unit quality gate alone. """ files = ["src/a.py", "src/b.py"] units = _mk_store_units("src/a.py", {"a": [1, 40]}) store = _Store(files, units) a_rel = "src/a.py" r = compute_coverage( store, repo, deep_read_files=["src/a.py"], include_churn=False, gate="line+unit", file_read_ranges={a_rel: [[1, 40]]}, file_semantic_units={a_rel: [{"range": [1, 40], "kind": "Function", "name": "a"}]}, ) assert r["gate"] == "line+unit" assert r["coverage_pct"] is None assert r["high_risk_coverage_pct"] is None assert r["line_coverage_pct"] == 100.0 assert r["unit_coverage_pct"] == 100.0 assert r["target_reached"] is True assert r["grade"] == "good" def test_gate_line_unit_fails_on_line_gap(repo): """A line gap below target still fails gate=\"line+unit\".""" files = ["src/a.py"] units = _mk_store_units("src/a.py", {"a": [1, 40]}) store = _Store(files, units) a_rel = "src/a.py" r = compute_coverage( store, repo, deep_read_files=["src/a.py"], include_churn=False, gate="line+unit", file_read_ranges={a_rel: [[1, 20]]}, # 50% of 40 lines file_semantic_units={a_rel: [{"range": [1, 40], "kind": "Function", "name": "a"}]}, ) assert r["line_coverage_pct"] == 0.0 # 0 of 1 files pass the >=95% line gate assert r["line_gap_files"][0]["coverage_pct"] == 50.0 assert r["target_reached"] is False assert r["grade"] == "fail" def test_gate_line_unit_fails_on_unit_gap(repo): """A missing semantic unit fails gate=\"line+unit\".""" files = ["src/a.py"] units = _mk_store_units("src/a.py", {"a": [1, 20], "b": [21, 40]}) store = _Store(files, units) a_rel = "src/a.py" r = compute_coverage( store, repo, deep_read_files=["src/a.py"], include_churn=False, gate="line+unit", file_read_ranges={a_rel: [[1, 40]]}, file_semantic_units={a_rel: [{"range": [1, 20], "kind": "Function", "name": "a"}]}, ) assert len(r["unit_gap_files"]) == 1 assert r["target_reached"] is False def test_coverage_index_v2_roundtrip(repo, monkeypatch): files = ["src/a.py"] a_rel = "src/a.py" r = save_coverage_index_func( deep_read_files=[a_rel], repo_root=str(repo), file_read_ranges={a_rel: [[1, 2]]}, file_semantic_units={a_rel: [{"range": [1, 2], "kind": "Function", "name": "a"}]}, ) assert r["status"] == "ok" index_path = _coverage_index_path(repo) assert index_path.is_file() payload = json.loads(index_path.read_text(encoding="utf-8")) assert payload["version"] == 2 entry = payload["entries"][a_rel] assert entry["ranges"] == [[1, 2]] assert entry["units"]