Files
anki/tools/coverage/check-coverage-regression.py
Fernando Lins 02210ec450 test(scheduler): add unit tests for learning and relearning state transitions (#4963)
## Linked issue

Closes #4928

## Summary / motivation

`learning.rs` and `relearning.rs` had 0% test coverage. They implement
the core scheduling logic for new and lapsed cards — a regression here
affects every user's review queue silently. This PR adds unit tests to
establish a safety net before future migrations or FSRS changes touch
this code.

## How to test

```bash
cargo test -p anki --lib "states::learning::tests"
cargo test -p anki --lib "states::relearning::tests"
```

## Details

`learning.rs` — tests covering all four buttons under SM-2: again (reset
to first step, no-steps graduation), hard (same step, no-steps
graduation), good (advance or graduate), easy (always graduates,
interval ordering invariant), plus FSRS routing stubs.

`relearning.rs` — tests covering SM-2 transitions (lapse penalty, step
keep/reset/graduate), FSRS routing (long interval → Review, short-term →
Relearning for all buttons), and multi-step behavior (again resets, hard
keeps, good advances through intermediate steps).
2026-06-08 11:59:54 -03:00

123 lines
3.8 KiB
Python
Executable File

#!/usr/bin/env python3
# Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
"""
Compare line coverage percentages between a baseline and the current run.
Usage:
check-coverage-regression <baseline-dir> <current-dir>
baseline-dir Directory containing coverage-summary.json files from main
(e.g. out/coverage-baseline)
current-dir Directory containing coverage-summary.json files from the
current PR run (e.g. out/coverage)
Exits with code 1 if any stack's line coverage is below the baseline.
Tolerance: 0.20% — small decreases within this margin are ignored to absorb
instrumentation noise across runs. Anything beyond this is treated as a
regression. If a decrease is acceptable (e.g. dead code removed), update
the baseline by merging to main.
"""
import json
import sys
from pathlib import Path
from typing import Any, Callable, TypedDict
TOLERANCE = 0.2 # percentage points; absorbs instrumentation noise across runs
# (0.1 produced false positives: llvm-cov/coverage.py fluctuate up to ~0.15pp
# between identical runs due to non-deterministic macro/generic instrumentation)
class Stack(TypedDict):
name: str
path: str
extract: Callable[[Any], float]
STACKS: list[Stack] = [
{
"name": "rust",
"path": "rust/coverage-summary.json",
"extract": lambda d: d["data"][0]["totals"]["lines"]["percent"],
},
{
"name": "python-pylib",
"path": "python-pylib/coverage-summary.json",
"extract": lambda d: d["totals"]["percent_covered"],
},
{
"name": "python-qt",
"path": "python-qt/coverage-summary.json",
"extract": lambda d: d["totals"]["percent_covered"],
},
{
"name": "typescript",
"path": "typescript/coverage-summary.json",
"extract": lambda d: d["total"]["lines"]["pct"],
},
]
def load_pct(directory: Path, stack: Stack) -> float | None:
path = directory / stack["path"]
if not path.exists():
return None
return stack["extract"](json.loads(path.read_text()))
def main() -> int:
if len(sys.argv) != 3:
print(f"Usage: {sys.argv[0]} <baseline-dir> <current-dir>", file=sys.stderr)
return 2
baseline_dir = Path(sys.argv[1])
current_dir = Path(sys.argv[2])
regressions: list[tuple[str, float, float]] = []
for stack in STACKS:
name = stack["name"]
baseline_pct = load_pct(baseline_dir, stack)
current_pct = load_pct(current_dir, stack)
if baseline_pct is None:
print(f"[{name}] no baseline — skipping")
continue
if current_pct is None:
print(
f"[{name}] coverage-summary.json not found in {current_dir}",
file=sys.stderr,
)
return 2
delta = current_pct - baseline_pct
if delta < -TOLERANCE:
regressions.append((name, baseline_pct, current_pct))
print(
f"[{name}] REGRESSION: {baseline_pct:.2f}% -> {current_pct:.2f}%"
f" (delta: {delta:+.2f}%, tolerance: {TOLERANCE:.2f}%)"
)
else:
print(
f"[{name}] ok: {current_pct:.2f}%"
f" (baseline: {baseline_pct:.2f}%, delta: {delta:+.2f}%)"
)
if regressions:
names = ", ".join(r[0] for r in regressions)
print(
f"\n{len(regressions)} stack(s) with coverage regression: {names}\n"
f"Configured tolerance: {TOLERANCE:.2f}%\n"
"To accept a legitimate decrease (e.g. dead code removed),\n"
"merge to main — this will update the baseline automatically."
)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())