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

333 lines
11 KiB
Python

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