333 lines
11 KiB
Python
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"]
|