aboutsummaryrefslogtreecommitdiff
path: root/languages/python/tests/test_coverage_summary.py
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-31 12:31:35 -0500
committerCraig Jennings <c@cjennings.net>2026-05-31 12:31:35 -0500
commitaf478a42b18c4d5e0712c4cb43036126d36c56b5 (patch)
tree5ef37e4f4c0e1f749e1d3506dde4147036660854 /languages/python/tests/test_coverage_summary.py
parentddf48dc7ac780da1aacdff4e03f1d7da255b8f39 (diff)
downloadrulesets-af478a42b18c4d5e0712c4cb43036126d36c56b5.tar.gz
rulesets-af478a42b18c4d5e0712c4cb43036126d36c56b5.zip
feat(python): add coverage-summary to the Python bundle
Second language in the coverage-summary fan-out, after the Elisp pilot. Same kernel: a module no test imports never appears in coverage.py's report, so a line-weighted total skips it silently and the suite looks healthier than it is. This counts every source file on disk that's absent from the report as 0% and weights the project number by file, so untested modules stay visible. The script at languages/python/claude/scripts/coverage-summary.py parses coverage.py's JSON (files[path].summary.covered_lines / num_statements), resolves report paths against the report's directory since coverage records them relative to where it ran, and recurses the source dir for *.py. Unlike the Elisp version it doesn't print a per-file table, because coverage.py's own coverage report already does. The script adds the missing-file accounting that report lacks. It uses only the standard library, parsing the report rather than importing coverage. The Python run confirmed the plumbing from the pilot is genuinely generic. install-lang and sync deliver the script and the project-owned coverage-makefile.txt with no Python-specific code. The one gap I had to close: the Python bundle shipped without a gitignore-add.txt, so the .claude/ footprint wasn't ignored and the script would have been committable. Added one mirroring the Elisp footprint plus Python artifacts (__pycache__, .coverage, coverage.json). make test gained a languages/*/tests/test_*.py discovery path alongside the existing Elisp ERT one. Tests: 12 pytest covering the parser, the file-weighted number, and the missing-file detection including subpackage recursion, plus an install-lang check that the script lands in the gitignored footprint. I proved it against a report matching coverage.py's documented schema and the CLI end to end, but not against a live coverage json run, because coverage.py isn't installed in this repo's env. The first project to adopt it should sanity-check against a real report.
Diffstat (limited to 'languages/python/tests/test_coverage_summary.py')
-rw-r--r--languages/python/tests/test_coverage_summary.py156
1 files changed, 156 insertions, 0 deletions
diff --git a/languages/python/tests/test_coverage_summary.py b/languages/python/tests/test_coverage_summary.py
new file mode 100644
index 0000000..b65effd
--- /dev/null
+++ b/languages/python/tests/test_coverage_summary.py
@@ -0,0 +1,156 @@
+"""Tests for the Python bundle coverage-summary kernel.
+
+The script under test lives at languages/python/claude/scripts/coverage-summary.py
+and is loaded by path (hyphenated CLI name, not importable as a module).
+
+The kernel is the missing-file detection and the file-weighted project number;
+coverage.py's own `coverage report` prints the per-file table, so this script
+does not reimplement that. Tests build a throwaway package tree plus a faithful
+coverage.py JSON report and assert the kernel's numbers against hand-computed
+values, with Normal / Boundary / Error coverage per function.
+"""
+import importlib.util
+import json
+from pathlib import Path
+
+import pytest
+
+# Load the hyphenated CLI script by path.
+_SCRIPT = Path(__file__).resolve().parents[1] / "claude" / "scripts" / "coverage-summary.py"
+_spec = importlib.util.spec_from_file_location("coverage_summary", _SCRIPT)
+cs = importlib.util.module_from_spec(_spec)
+_spec.loader.exec_module(cs)
+
+
+def write_report(root: Path, files: dict) -> Path:
+ """Write a coverage.py-shaped JSON report.
+
+ files maps a project-relative path to (covered, total). Mirrors the real
+ schema: files[path] = {"summary": {"covered_lines", "num_statements", ...}}.
+ """
+ payload = {"meta": {"version": "7.0"}, "files": {}, "totals": {}}
+ for rel, (cov, tot) in files.items():
+ payload["files"][rel] = {
+ "executed_lines": list(range(1, cov + 1)),
+ "summary": {
+ "covered_lines": cov,
+ "num_statements": tot,
+ "percent_covered": (100.0 * cov / tot) if tot else 100.0,
+ "missing_lines": tot - cov,
+ "excluded_lines": 0,
+ },
+ "missing_lines": list(range(cov + 1, tot + 1)),
+ "excluded_lines": [],
+ }
+ p = root / "coverage.json"
+ p.write_text(json.dumps(payload))
+ return p
+
+
+@pytest.fixture
+def project(tmp_path):
+ """A package tree under src/ plus a place for the report. Returns (root, src)."""
+ src = tmp_path / "src"
+ (src / "sub").mkdir(parents=True)
+ return tmp_path, src
+
+
+# --- parse: covered / total ------------------------------------------------
+
+def test_parse_counts_covered_and_statements(project):
+ root, src = project
+ report = write_report(root, {"src/a.py": (3, 5)})
+ table = cs.parse_report(report)
+ key = str((root / "src/a.py").resolve())
+ assert table[key] == (3, 5)
+
+
+def test_parse_missing_report_raises(project):
+ root, _ = project
+ with pytest.raises(SystemExit):
+ cs.parse_report(root / "nope.json")
+
+
+def test_parse_malformed_json_raises(project):
+ root, _ = project
+ bad = root / "coverage.json"
+ bad.write_text("{not json")
+ with pytest.raises(SystemExit):
+ cs.parse_report(bad)
+
+
+# --- file percentage -------------------------------------------------------
+
+def test_file_pct_normal():
+ assert cs.file_pct(1, 2) == 50.0
+
+
+def test_file_pct_no_statements_is_100():
+ assert cs.file_pct(0, 0) == 100.0
+
+
+def test_file_pct_fully_covered():
+ assert cs.file_pct(4, 4) == 100.0
+
+
+# --- missing-file detection (the kernel) -----------------------------------
+
+def test_missing_finds_ondisk_file_absent_from_report(project):
+ root, src = project
+ (src / "tracked.py").write_text("x = 1\n")
+ (src / "untested.py").write_text("y = 2\n")
+ report = write_report(root, {"src/tracked.py": (1, 1)})
+ table = cs.under_dir(cs.parse_report(report), src, root)
+ missing = cs.missing(list(table), src, root)
+ assert missing == ["src/untested.py"]
+
+
+def test_missing_recurses_into_subpackages(project):
+ root, src = project
+ (src / "top.py").write_text("a = 1\n")
+ (src / "sub" / "deep.py").write_text("b = 2\n")
+ report = write_report(root, {"src/top.py": (1, 1)})
+ table = cs.under_dir(cs.parse_report(report), src, root)
+ missing = cs.missing(list(table), src, root)
+ assert "src/sub/deep.py" in missing
+
+
+def test_missing_empty_when_all_tracked(project):
+ root, src = project
+ (src / "a.py").write_text("x = 1\n")
+ report = write_report(root, {"src/a.py": (1, 1)})
+ table = cs.under_dir(cs.parse_report(report), src, root)
+ assert cs.missing(list(table), src, root) == []
+
+
+# --- project number (file-weighted, missing as 0%) -------------------------
+
+def test_project_pct_unit_weighted_with_missing_as_zero(project):
+ root, src = project
+ for name in ("full.py", "half.py", "untested.py"):
+ (src / name).write_text("x = 1\n")
+ report = write_report(root, {"src/full.py": (2, 2), "src/half.py": (1, 2)})
+ # full=100, half=50, untested missing=0 -> (100+50+0)/3 = 50.0
+ assert cs.project_pct(report, src, root) == 50.0
+
+
+def test_project_pct_no_missing(project):
+ root, src = project
+ for name in ("full.py", "half.py"):
+ (src / name).write_text("x = 1\n")
+ report = write_report(root, {"src/full.py": (2, 2), "src/half.py": (1, 2)})
+ assert cs.project_pct(report, src, root) == 75.0
+
+
+# --- end-to-end text -------------------------------------------------------
+
+def test_summary_text_reports_missing_and_project_number(project):
+ root, src = project
+ (src / "a.py").write_text("x = 1\n")
+ (src / "orphan.py").write_text("y = 2\n")
+ report = write_report(root, {"src/a.py": (1, 2)})
+ text = cs.summary_text(report, src, root)
+ assert "orphan.py" in text
+ assert "0%" in text
+ # a.py = 50%, orphan missing = 0% -> 25.0%
+ assert "25.0%" in text