fix(quality): close coverage ratchet

This commit is contained in:
jxxghp
2026-08-26 22:10:23 +08:00
parent 15ddbbbafe
commit 265d3c6d1a
8 changed files with 645 additions and 46 deletions
+3 -1
View File
@@ -140,7 +140,6 @@ jobs:
uv run --locked --no-sync python -m coverage report
uv run --locked --no-sync python -m coverage json
uv run --locked --no-sync python -m coverage xml
uv run --locked --no-sync python scripts/architecture/coverage_ratchet.py
- name: Upload coverage report
uses: actions/upload-artifact@v7
@@ -150,3 +149,6 @@ jobs:
coverage.xml
coverage.json
retention-days: 7
- name: Check coverage ratchet
run: uv run --locked --no-sync python scripts/architecture/coverage_ratchet.py
+14 -6
View File
@@ -198,8 +198,9 @@ uvx --from pip-audit==2.10.1 pip-audit \
`python tests/run.py` 在本地默认把排序后的测试文件按向上取整的连续区间切成 4 片,
并启动 4 个独立 pytest 进程;GitHub Actions 使用同一入口的 `--shard N/TOTAL`
参数启动对应分片。需要单进程调试时使用 `python tests/run.py --serial`。覆盖率报告
按需通过 `Unit Tests` workflow 的手动触发串行生成,不阻塞常规 PR / push 门禁。
参数启动对应分片。需要单进程调试时使用 `python tests/run.py --serial`。Coverage job
会在 `v3` 的 PR / push 中串行运行同一全量入口,并检查 Application 与 Domain 的
已提交低水位;它不是只在手工触发时运行的建议性报告。
4. **运行架构与静态门禁**:主仓架构检查不依赖独立插件仓;官方插件兼容观察单独运行,
任何检查命令都不会写入 fixture。
@@ -210,17 +211,24 @@ uvx --from pip-audit==2.10.1 pip-audit \
--check-plugins --plugin-repo ../MoviePilot-Plugins \
--report official-plugin-architecture-report.json
uv run --locked --no-sync pylint app/
uv run --locked --no-sync python scripts/architecture/ruff_ratchet.py
uv run --locked --no-sync python scripts/architecture/mypy_ratchet.py
uv run --locked --no-sync python -m coverage erase
uv run --locked --no-sync python -m coverage run tests/run.py --serial
uv run --locked --no-sync python -m coverage json
uv run --locked --no-sync python scripts/architecture/coverage_ratchet.py
```
GitHub Actions 会在 `v3` 的 PR/push 中独立执行宿主架构门禁,并对本次改动的 Python
文件执行 Pylint 硬门禁;`app/` 全量结果作为建议性报告上传。最新官方插件仓通过每周
或手工观察工作流检查,只上传语义差异报告,不会自动更新已提交基线。
mypy 类型错误基线(`tests/fixtures/architecture/mypy-baseline.json`)只降不增:
新增文件、错误码或既有计数增长都会被 `scripts/architecture/mypy_ratchet.py` 拒绝;
修复存量错误后用同脚本 `--write` 收紧基线,禁止为绕过门禁放宽基线。受治文件清单
仍由 `mypy.ini` 的 `files=` 维护并保持零错误。
Ruff/Mypy/Coverage 基线只允许收紧:新增诊断、类型错误增长或覆盖率下降都会被拒绝;
已有债务下降或覆盖率提升但 fixture 尚未同步时,门禁也会要求用对应脚本的 `--write`
显式固化新低水位。存在回退时 `--write` 会拒绝覆盖,不能用于放宽基线。Mypy 完整
ratchet 固定按 Linux/Python 3.14 分析;Coverage fixture 只接受 GitHub Actions 的
Ubuntu/Python 3.14、locked 依赖和串行全量测试工件,本机 macOS 报告仅用于诊断,
不得直接写入并提交。受治零错误文件仍由 `mypy.ini` 的 `files=` 维护。
### 7. 参考资源
+25 -3
View File
@@ -84,8 +84,8 @@
| 批次 | 叶子目标 | 状态 | 当前证据/停止条件 |
|---|---|---|---|
| 0 | 历史任务清账、现行架构图、外部契约核对和宿主基线对齐 | 已推送 | `d234c7132`;远端同 SHAahead/behind `0/0`;架构契约 `71 passed` |
| 1 | Mypy fail-closed,并把 Ruff/Mypy 已下降债务固化为真实低水位 | 已本地验证 | Mypy 完整低水位 11994、Ruff 976;专项 `33 passed`;架构契约 `71 passed`Pylint `10.00/10` |
| 2 | 用全量串行测试初始化非零 Coverage 低水位,并补齐 CI/文档防回退契约 | 待批次 1 | fixture 当前 Application/Domain 均为 0%;最近 CI 仅作参考,必须在最终代码快照本地重建 |
| 1 | Mypy fail-closed,并把 Ruff/Mypy 已下降债务固化为真实低水位 | 已推送 | `6062b0661`;远端同 SHAahead/behind `0/0`Mypy 11994、Ruff 976 |
| 2 | 用全量串行测试初始化非零 Coverage 低水位,并补齐 CI/文档防回退契约 | 已本地验证 | Ubuntu canonicalApplication `9292/11949`77.76%),Domain `3390/4278`79.24%);专项 `26 passed` |
| 3 | 收口阶段 62 遗留的 QQ Gateway heartbeat Timer 所有权 | 待批次 2 | Timer 只 cancel 不 joinGateway 主线程可能在 heartbeat 仍执行时报告停止成功 |
| Final | 全仓回归、插件兼容复核、台账定稿和远端一致性验证 | 待前置批次 | 所有准入项已推送;全量测试和适用门禁通过;本地/远端 0/0 |
@@ -134,7 +134,9 @@
* Ruff fixture 从 1623 收紧到 976Mypy 和 Ruff 默认路径复跑均通过;
* 质量/CI/上下文/严格类型专项 `33 passed`,架构契约 `71 passed`,改动文件 Pylint `10.00/10`
待完成:提交推送并记录远端证据。
交付证据:提交 `6062b0661` 已推送到 `origin/v3``git ls-remote` 返回同一 SHA
`HEAD...origin/v3``0/0`。提交严格包含上述 10 个批次路径;并行任务在提交前推进的
`1f7fac2b2` 是其父提交,不在本批次 diff 中。
### 批次 2Coverage 低水位闭环
@@ -145,6 +147,23 @@
零 statements、工具失败和低水位未固化均会失败;CI 命令存在性有契约测试;文档与 CI 一致;
批次独立提交推送。
本地验收结果:
* 脚本拒绝 malformed/零语句报告、非法或不完整基线、布尔/负数/越界计数和计数不一致的
百分比;唯一旧全零 fixture 只允许初始化一次,后续不能再借 `--write` 绕过回退;
* 回退使用整数交叉相乘比较真实比例,避免四舍五入隐藏下降;任一包下降会整体拒绝写入且
基线原始字节不变,等比例计数变化和覆盖提升都必须显式固化完整快照;
* GitHub Actions run `32977180133``15ddbbbaf` 上完成 Ubuntu/Python 3.14、locked
依赖和串行全量 Coverage,日志精确聚合为 Application `9292/11949`77.76%)、Domain
`3390/4278`(79.24%);本机报告得到相同计数后,旧全零 fixture 已初始化并复验通过;
* CI 在同一 job 内按 erase、serial run、report、JSON、XML 顺序生成报告,先上传 canonical
工件再执行只读 ratchetPR、push、手工触发、Python 版本、锁依赖和禁止绕过均有契约测试;
* 当前应用代码快照的本地串行全量结果为 `6379 passed, 6 skipped`;随后新增的边界测试由
最新专项覆盖,质量/CI 专项 `26 passed`Ruff 通过,Pylint `10.00/10`,宿主、Schema、
Coverage 和锁文件门禁均通过。
待完成:提交推送并记录远端证据。
### 批次 3QQ Gateway heartbeat owner
阶段 62 的既有合同要求消息渠道只有在线程真实终止后才能返回成功,超时时 owner 和句柄
@@ -195,6 +214,9 @@ git rev-list --left-right --count HEAD...origin/v3
| 2026-08-26 | 后台 owner 历史余项审计 | 仅 QQ heartbeat Timer 满足阶段 62 既有合同和两天准入条件,其余候选关闭或排除 |
| 2026-08-26 | 批次 0 交付 | `d234c7132` 已推送;远端同 SHAahead/behind `0/0` |
| 2026-08-26 | 批次 1 本地验收 | Mypy 11994、Ruff 976;专项 `33 passed`;架构契约 `71 passed`Pylint `10.00/10` |
| 2026-08-26 | 批次 1 交付 | `6062b0661` 已推送;远端同 SHAahead/behind `0/0`;显式变更 10 个路径 |
| 2026-08-26 | 批次 2 canonical Coverage | run `32977180133` 与本机计数一致;Application 77.76%Domain 79.24%;零 fixture 已初始化 |
| 2026-08-26 | 批次 2 本地验收 | 串行全量 `6379 passed, 6 skipped`;最新专项 `26 passed`Coverage/宿主/Schema/锁文件门禁通过 |
## 七、本轮停止条件
+1 -1
View File
@@ -149,5 +149,5 @@ def test_recognize_prefers_explicit_identity(sample_meta, monkeypatch):
- **跨仓观察**`.github/workflows/architecture-observe.yml` 每周或手工检出官方插件仓最新 `main`,使用 `--check-plugins` 比较公开导入、Hook 和动态 API 契约。它只上传 `official-plugin-architecture-report.json`,不会自动刷新 fixture;语义变化必须人工审查后显式执行 `--write-plugins`。
- **静态检查**`.github/workflows/pylint.yml` 对指向 `v3` 的 PR、推送和手工触发运行 Pylint。PR/推送改动到的 Python 文件是硬门禁;`app/` 全量扫描保留为建议性 JSON 构建工件,存量告警不会掩盖或阻塞本次增量治理。
- **PR 本地验证**:提交前运行受影响测试和适用的静态检查。涉及依赖或锁文件、共享测试基建、数据库、启动链、跨模块生命周期、兼容层或大范围行为变化时,运行 `uv run --locked --no-sync python tests/run.py` 完成本地全量;需要断点、输出顺序或测试污染诊断时使用 `--serial`。所有测试都应确认受影响路径通过且 socket 探针无真实出站,验证说明准确标注执行范围。若存在无关失败,必须在当前 `upstream/v3` 基线上独立复现并在 PR 中如实说明;不得静默扩大当前 PR 去修复基线问题。纯文档变更执行适用的文本、结构和 diff 检查,CI 继续运行全量门禁。
- 覆盖率不参与常规 PR / push 的合并门禁;需要覆盖率制品时手动触发 `Unit Tests` workflow,独立的 `Coverage Report` job 会通过 `tests/run.py --serial` 跑串行全量上传 JSON / XML 报告
- **覆盖率门禁**`Coverage Report` job 会在 `v3` 的 PR、push 和手工触发中通过 `tests/run.py --serial` 跑串行全量,先上传 JSON / XML 工件,再只读检查 Application 与 Domain 的 Ubuntu/Python 3.14 canonical 低水位。覆盖率下降会阻塞,提升或等比例快照变化也必须显式刷新 fixture;macOS 本地报告只用于诊断,不直接作为可提交基线
- 复现 CI 使用 `uv sync --locked`;主程序运行依赖位于 `[project].dependencies`,pytest 与覆盖率工具位于默认 `dev` 依赖组。
+183 -20
View File
@@ -1,9 +1,11 @@
"""对 Application 与 Domain 维护不可退化的行覆盖率阈值"""
"""对 Application 与 Domain 维护不可退化且及时固化的行覆盖率低水位"""
from __future__ import annotations
import argparse
import json
import math
from collections.abc import Mapping
from pathlib import Path
from typing import Any
@@ -14,22 +16,55 @@ PACKAGE_PREFIXES = {
"application": "app/application/",
"domain": "app/domain/",
}
LEGACY_ZERO_BASELINE = {
name: {"statements": 0, "covered_lines": 0, "percent": 0.0}
for name in PACKAGE_PREFIXES
}
def _non_negative_int(value: object, *, field: str) -> int:
"""读取 coverage 整数计数,拒绝 bool、字符串和负值。"""
if type(value) is not int or value < 0:
raise ValueError(f"{field} 必须是非负整数")
return value
def collect_package_coverage(report: dict[str, Any]) -> dict[str, dict[str, int | float]]:
"""按治理包聚合 coverage.py JSON 中的语句和已覆盖行。"""
if not isinstance(report, Mapping):
raise ValueError("coverage JSON 顶层必须是对象")
files = report.get("files")
if not isinstance(files, Mapping):
raise ValueError("coverage JSON 缺少 files 对象")
result: dict[str, dict[str, int | float]] = {}
files = report.get("files", {})
for name, prefix in PACKAGE_PREFIXES.items():
statements = 0
covered = 0
for path, details in files.items():
if not isinstance(path, str):
raise ValueError("coverage files 键必须是字符串路径")
if not path.replace("\\", "/").startswith(prefix):
continue
summary = details["summary"]
statements += int(summary["num_statements"])
covered += int(summary["covered_lines"])
percent = round(covered * 100 / statements, 2) if statements else 100.0
if not isinstance(details, Mapping):
raise ValueError(f"{path}: coverage 文件详情必须是对象")
summary = details.get("summary")
if not isinstance(summary, Mapping):
raise ValueError(f"{path}: coverage 文件详情缺少 summary 对象")
file_statements = _non_negative_int(
summary.get("num_statements"),
field=f"{path}.num_statements",
)
file_covered = _non_negative_int(
summary.get("covered_lines"),
field=f"{path}.covered_lines",
)
if file_covered > file_statements:
raise ValueError(
f"{path}: 已覆盖行数越界 {file_covered}/{file_statements}"
)
statements += file_statements
covered += file_covered
percent = round(covered * 100 / statements, 2) if statements else 0.0
result[name] = {
"statements": statements,
"covered_lines": covered,
@@ -38,18 +73,110 @@ def collect_package_coverage(report: dict[str, Any]) -> dict[str, dict[str, int
return result
def validate_coverage(
current: object,
) -> list[str]:
"""拒绝缺包、额外包、零快照和不一致的派生百分比。"""
problems: list[str] = []
if not isinstance(current, Mapping):
return ["覆盖率快照必须是对象"]
expected_names = set(PACKAGE_PREFIXES)
actual_names = set(current)
for name in sorted(expected_names - actual_names):
problems.append(f"{name}: 覆盖率报告缺少治理包")
for name in sorted(actual_names - expected_names):
problems.append(f"{name}: 覆盖率快照包含未知治理包")
for name in PACKAGE_PREFIXES:
values = current.get(name)
if values is None:
continue
if not isinstance(values, Mapping):
problems.append(f"{name}: 覆盖率数据必须是对象")
continue
if set(values) != {"statements", "covered_lines", "percent"}:
problems.append(f"{name}: 覆盖率数据字段不完整或包含未知字段")
continue
try:
statements = _non_negative_int(
values.get("statements"),
field=f"{name}.statements",
)
covered = _non_negative_int(
values.get("covered_lines"),
field=f"{name}.covered_lines",
)
except ValueError as error:
problems.append(str(error))
continue
percent = values.get("percent")
if (
isinstance(percent, bool)
or not isinstance(percent, (int, float))
or not math.isfinite(float(percent))
):
problems.append(f"{name}.percent 必须是有限数值")
continue
if statements <= 0:
problems.append(f"{name}: 覆盖率报告语句数必须大于 0")
continue
if not 0 <= covered <= statements:
problems.append(
f"{name}: 已覆盖行数越界 {covered}/{statements}"
)
continue
if covered == 0:
problems.append(f"{name}: 已覆盖行数必须大于 0")
expected_percent = round(covered * 100 / statements, 2)
if float(percent) != expected_percent:
problems.append(
f"{name}: percent 与计数不一致 {float(percent):.2f}!={expected_percent:.2f}"
)
return problems
def is_legacy_zero_baseline(baseline: object) -> bool:
"""识别本批次之前唯一允许被初始化替换的全零 fixture。"""
return baseline == LEGACY_ZERO_BASELINE
def classify_coverage(
baseline: dict[str, dict[str, int | float]],
current: dict[str, dict[str, int | float]],
) -> tuple[list[str], list[str]]:
"""把覆盖率差异分为不可写入的回退和可固化的新低水位。"""
regressions: list[str] = []
stale: list[str] = []
for name in PACKAGE_PREFIXES:
expected_values = baseline.get(name, {})
actual_values = current[name]
expected = float(expected_values.get("percent", 0.0))
actual = float(actual_values["percent"])
expected_statements = int(expected_values.get("statements", 0))
expected_covered = int(expected_values.get("covered_lines", 0))
actual_statements = int(actual_values["statements"])
actual_covered = int(actual_values["covered_lines"])
if (
expected_statements > 0
and actual_covered * expected_statements
< expected_covered * actual_statements
):
regressions.append(
f"{name}: 行覆盖率下降 {expected:.2f}%->{actual:.2f}%"
)
elif actual_values != expected_values:
stale.append(
f"{name}: 覆盖率低水位未固化 {expected:.2f}%->{actual:.2f}%"
)
return regressions, stale
def compare_coverage(
baseline: dict[str, dict[str, int | float]],
current: dict[str, dict[str, int | float]],
) -> list[str]:
"""返回覆盖率低于已提交阈值的问题"""
problems = []
for name in PACKAGE_PREFIXES:
expected = float(baseline[name]["percent"])
actual = float(current[name]["percent"])
if actual < expected:
problems.append(f"{name}: 行覆盖率下降 {expected:.2f}%->{actual:.2f}%")
return problems
"""返回覆盖率回退和尚未固化的新低水位"""
regressions, stale = classify_coverage(baseline, current)
return [*regressions, *stale]
def main() -> int:
@@ -59,25 +186,61 @@ def main() -> int:
parser.add_argument("--report", type=Path, default=DEFAULT_REPORT)
parser.add_argument("--baseline", type=Path, default=DEFAULT_BASELINE)
args = parser.parse_args()
report = json.loads(args.report.read_text(encoding="utf-8"))
current = collect_package_coverage(report)
try:
report = json.loads(args.report.read_text(encoding="utf-8"))
current = collect_package_coverage(report)
except (OSError, ValueError, TypeError) as error:
print(f"Coverage 报告无效:{error}")
return 1
validation_problems = validate_coverage(current)
if validation_problems:
print("\n".join(validation_problems))
return 1
baseline_exists = args.baseline.exists()
if baseline_exists:
try:
baseline = json.loads(args.baseline.read_text(encoding="utf-8"))
except (OSError, ValueError, TypeError) as error:
print(f"Coverage 基线无效:{error}")
return 1
if not is_legacy_zero_baseline(baseline):
baseline_problems = validate_coverage(baseline)
if baseline_problems:
print("Coverage 基线无效:")
print("\n".join(baseline_problems))
return 1
else:
baseline = {}
regressions, stale = classify_coverage(baseline, current)
if args.write:
if baseline_exists and regressions:
print("\n".join(regressions))
print("拒绝写入:当前结果包含覆盖率回退,--write 只能固化持平或提升后的低水位。")
return 1
args.baseline.parent.mkdir(parents=True, exist_ok=True)
args.baseline.write_text(
json.dumps(current, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
print(f"已写入 {args.baseline.relative_to(PROJECT_ROOT)}")
display_path = (
args.baseline.relative_to(PROJECT_ROOT)
if args.baseline.is_relative_to(PROJECT_ROOT)
else args.baseline
)
print(f"已写入 {display_path}")
return 0
baseline = json.loads(args.baseline.read_text(encoding="utf-8"))
problems = compare_coverage(baseline, current)
problems = [*regressions, *stale]
if problems:
print("\n".join(problems))
if regressions:
print("先消除覆盖率回退;存在下降时禁止用 --write 覆盖基线。")
else:
print("提示:当前只有覆盖率持平快照变化或提升,可用 --write 固化新的低水位。")
return 1
summary = ", ".join(
f"{name}={values['percent']:.2f}%" for name, values in current.items()
)
print(f"覆盖率 ratchet 通过({summary}")
print(f"覆盖率 ratchet 通过(低水位已同步:{summary}")
return 0
+6 -6
View File
@@ -1,12 +1,12 @@
{
"application": {
"covered_lines": 0,
"percent": 0.0,
"statements": 0
"covered_lines": 9292,
"percent": 77.76,
"statements": 11949
},
"domain": {
"covered_lines": 0,
"percent": 0.0,
"statements": 0
"covered_lines": 3390,
"percent": 79.24,
"statements": 4278
}
}
+52
View File
@@ -1,5 +1,6 @@
"""后端架构治理工作流的静态契约测试。"""
import configparser
from pathlib import Path
from ruamel.yaml import YAML
@@ -57,6 +58,57 @@ def test_official_plugin_observation_is_scheduled_and_never_writes_fixture():
)
def test_coverage_job_runs_full_suite_and_read_only_ratchet() -> None:
"""PR 与推送的 Coverage job 必须串行采集全量报告并只读检查低水位。"""
workflow = _load_workflow("test.yml")
coverage_job = workflow["jobs"]["coverage"]
steps = coverage_job["steps"]
assert workflow["on"]["pull_request"]["branches"] == ["v3"]
assert workflow["on"]["push"]["branches"] == ["v3"]
assert "workflow_dispatch" in workflow["on"]
assert coverage_job["runs-on"] == "ubuntu-latest"
assert "if" not in coverage_job
setup_step = next(step for step in steps if step.get("name") == "Set up uv")
install_step = next(
step for step in steps if step.get("name") == "Install dependencies"
)
generate_step = next(
step for step in steps if step.get("name") == "Generate coverage reports"
)
upload_step = next(
step for step in steps if step.get("name") == "Upload coverage report"
)
ratchet_step = next(
step for step in steps if step.get("name") == "Check coverage ratchet"
)
assert setup_step["with"]["python-version"] == "3.14"
assert install_step["run"] == "uv sync --locked"
commands = generate_step["run"]
expected_commands = [
"python -m coverage erase",
"python -m coverage run tests/run.py --serial",
"python -m coverage report",
"python -m coverage json",
"python -m coverage xml",
]
positions = [commands.index(command) for command in expected_commands]
assert positions == sorted(positions)
assert ratchet_step["run"].endswith("scripts/architecture/coverage_ratchet.py")
assert steps.index(generate_step) < steps.index(upload_step) < steps.index(ratchet_step)
all_commands = _step_commands(workflow, "coverage")
assert "--write" not in all_commands
assert "|| true" not in all_commands
assert all(step.get("continue-on-error") is not True for step in steps)
assert all("always()" not in str(step.get("if", "")) for step in steps)
coverage_config = configparser.ConfigParser()
coverage_config.read(PROJECT_ROOT / ".coveragerc", encoding="utf-8")
assert coverage_config["run"]["source"].strip() == "app"
assert "app/plugins/*/*" in coverage_config["run"]["omit"].splitlines()
def test_pylint_workflow_runs_for_v3_pull_requests_and_pushes():
"""改动文件应硬门禁,而全仓存量问题只能生成建议性报告。"""
workflow = _load_workflow("pylint.yml")
+361 -9
View File
@@ -3,6 +3,7 @@
import json
import subprocess
import sys
from pathlib import Path
from unittest.mock import patch
import pytest
@@ -10,6 +11,10 @@ import pytest
from scripts.architecture.coverage_ratchet import (
collect_package_coverage,
compare_coverage,
validate_coverage,
)
from scripts.architecture.coverage_ratchet import (
main as coverage_main,
)
from scripts.architecture.ruff_ratchet import (
PROJECT_ROOT,
@@ -94,7 +99,10 @@ def test_run_ruff_rejects_status_payload_mismatch(
run_ruff()
def test_ruff_write_refuses_to_legalize_regression(tmp_path, monkeypatch) -> None:
def test_ruff_write_refuses_to_legalize_regression(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""已有基线出现增长时,--write 不得覆盖原文件。"""
baseline_path = tmp_path / "ruff-baseline.json"
baseline_path.write_text("{}\n", encoding="utf-8")
@@ -114,7 +122,10 @@ def test_ruff_write_refuses_to_legalize_regression(tmp_path, monkeypatch) -> Non
assert json.loads(baseline_path.read_text(encoding="utf-8")) == {}
def test_ruff_write_persists_reduced_low_watermark(tmp_path, monkeypatch) -> None:
def test_ruff_write_persists_reduced_low_watermark(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""没有增长时,--write 应把下降后的 Ruff 快照固化。"""
baseline_path = tmp_path / "ruff-baseline.json"
baseline_path.write_text(
@@ -164,18 +175,359 @@ def test_coverage_ratchet_aggregates_governed_packages() -> None:
}
def test_coverage_ratchet_rejects_only_regressions() -> None:
"""覆盖率达到或超过阈值时通过,任一包下降时失败"""
def test_coverage_ratchet_rejects_regressions_and_stale_improvements() -> None:
"""覆盖率下降必须修复,提升也必须及时固化为新低水位"""
baseline = {
"application": {"percent": 50.0},
"domain": {"percent": 75.0},
"application": {"statements": 10, "covered_lines": 5, "percent": 50.0},
"domain": {"statements": 20, "covered_lines": 15, "percent": 75.0},
}
assert compare_coverage(
baseline,
{"application": {"percent": 50.0}, "domain": {"percent": 76.0}},
) == []
{
"application": {"statements": 10, "covered_lines": 5, "percent": 50.0},
"domain": {"statements": 25, "covered_lines": 19, "percent": 76.0},
},
) == ["domain: 覆盖率低水位未固化 75.00%->76.00%"]
assert compare_coverage(
baseline,
{"application": {"percent": 49.99}, "domain": {"percent": 75.0}},
{
"application": {
"statements": 10000,
"covered_lines": 4999,
"percent": 49.99,
},
"domain": {"statements": 20, "covered_lines": 15, "percent": 75.0},
},
) == ["application: 行覆盖率下降 50.00%->49.99%"]
def test_coverage_ratchet_detects_regression_hidden_by_rounding() -> None:
"""显示百分比相同时,真实覆盖比例下降仍必须失败。"""
baseline = {
"application": {"statements": 10, "covered_lines": 5, "percent": 50.0},
"domain": {"statements": 20, "covered_lines": 15, "percent": 75.0},
}
current = {
"application": {
"statements": 100000,
"covered_lines": 49996,
"percent": 50.0,
},
"domain": {"statements": 20, "covered_lines": 15, "percent": 75.0},
}
assert compare_coverage(baseline, current) == [
"application: 行覆盖率下降 50.00%->50.00%"
]
def test_coverage_ratchet_requires_equal_ratio_snapshot_to_be_persisted() -> None:
"""真实比例持平但计数变化时也必须固化完整快照。"""
baseline = {
"application": {"statements": 10, "covered_lines": 5, "percent": 50.0},
"domain": {"statements": 20, "covered_lines": 15, "percent": 75.0},
}
current = {
"application": {"statements": 20, "covered_lines": 10, "percent": 50.0},
"domain": {"statements": 20, "covered_lines": 15, "percent": 75.0},
}
assert compare_coverage(baseline, current) == [
"application: 覆盖率低水位未固化 50.00%->50.00%"
]
def test_coverage_ratchet_rejects_zero_statement_report() -> None:
"""没有采集到治理包时不得把零语句误判为满覆盖。"""
current = collect_package_coverage({"files": {}})
assert current == {
"application": {"statements": 0, "covered_lines": 0, "percent": 0.0},
"domain": {"statements": 0, "covered_lines": 0, "percent": 0.0},
}
assert validate_coverage(current) == [
"application: 覆盖率报告语句数必须大于 0",
"domain: 覆盖率报告语句数必须大于 0",
]
@pytest.mark.parametrize(
("snapshot", "message"),
[
(
{
"application": {
"statements": 10,
"covered_lines": 11,
"percent": 110.0,
},
"domain": {"statements": 20, "covered_lines": 15, "percent": 75.0},
},
"已覆盖行数越界",
),
(
{
"application": {
"statements": 10,
"covered_lines": 5,
"percent": 49.0,
},
"domain": {"statements": 20, "covered_lines": 15, "percent": 75.0},
},
"percent 与计数不一致",
),
(
{
"application": {
"statements": True,
"covered_lines": 1,
"percent": 100.0,
},
"domain": {"statements": 20, "covered_lines": 15, "percent": 75.0},
},
"必须是非负整数",
),
],
)
def test_coverage_snapshot_validation_is_fail_closed(
snapshot: object,
message: str,
) -> None:
"""非法计数、布尔计数和人工百分比必须被受控拒绝。"""
assert any(message in problem for problem in validate_coverage(snapshot))
def test_coverage_main_rejects_malformed_report(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""缺少 files 对象的 JSON 报告必须返回失败而不是抛出栈。"""
report_path = tmp_path / "coverage.json"
report_path.write_text("{}\n", encoding="utf-8")
monkeypatch.setattr(
sys,
"argv",
["coverage_ratchet.py", "--report", str(report_path)],
)
assert coverage_main() == 1
def test_coverage_write_refuses_to_legalize_regression(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""已有阈值出现下降时,--write 不得覆盖原文件。"""
report_path = tmp_path / "coverage.json"
baseline_path = tmp_path / "coverage-baseline.json"
report_path.write_text(
json.dumps({
"files": {
"app/application/a.py": {
"summary": {"num_statements": 10, "covered_lines": 5}
},
"app/domain/a.py": {
"summary": {"num_statements": 20, "covered_lines": 14}
},
}
}),
encoding="utf-8",
)
original = {
"application": {"statements": 10, "covered_lines": 4, "percent": 40.0},
"domain": {"statements": 20, "covered_lines": 15, "percent": 75.0},
}
original_bytes = json.dumps(original, indent=1) + "\n"
baseline_path.write_text(original_bytes, encoding="utf-8")
monkeypatch.setattr(
sys,
"argv",
[
"coverage_ratchet.py",
"--write",
"--report",
str(report_path),
"--baseline",
str(baseline_path),
],
)
assert coverage_main() == 1
assert baseline_path.read_text(encoding="utf-8") == original_bytes
def test_coverage_write_persists_improved_low_watermark(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""覆盖率持平或提升时,--write 应固化当前完整快照。"""
report_path = tmp_path / "coverage.json"
baseline_path = tmp_path / "coverage-baseline.json"
report_path.write_text(
json.dumps({
"files": {
"app/application/a.py": {
"summary": {"num_statements": 10, "covered_lines": 5}
},
"app/domain/a.py": {
"summary": {"num_statements": 20, "covered_lines": 15}
},
}
}),
encoding="utf-8",
)
baseline_path.write_text(
json.dumps({
"application": {"statements": 10, "covered_lines": 4, "percent": 40.0},
"domain": {"statements": 20, "covered_lines": 14, "percent": 70.0},
}),
encoding="utf-8",
)
monkeypatch.setattr(
sys,
"argv",
[
"coverage_ratchet.py",
"--write",
"--report",
str(report_path),
"--baseline",
str(baseline_path),
],
)
assert coverage_main() == 0
assert json.loads(baseline_path.read_text(encoding="utf-8")) == {
"application": {"statements": 10, "covered_lines": 5, "percent": 50.0},
"domain": {"statements": 20, "covered_lines": 15, "percent": 75.0},
}
def test_coverage_write_persists_equal_ratio_snapshot(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""等比例计数变化不是回退,显式写入后应通过精确快照检查。"""
report_path = tmp_path / "coverage.json"
baseline_path = tmp_path / "coverage-baseline.json"
report_path.write_text(
json.dumps({
"files": {
"app/application/a.py": {
"summary": {"num_statements": 20, "covered_lines": 10}
},
"app/domain/a.py": {
"summary": {"num_statements": 20, "covered_lines": 15}
},
}
}),
encoding="utf-8",
)
baseline_path.write_text(
json.dumps({
"application": {"statements": 10, "covered_lines": 5, "percent": 50.0},
"domain": {"statements": 20, "covered_lines": 15, "percent": 75.0},
}),
encoding="utf-8",
)
command = [
"coverage_ratchet.py",
"--report",
str(report_path),
"--baseline",
str(baseline_path),
]
monkeypatch.setattr(sys, "argv", command)
assert coverage_main() == 1
monkeypatch.setattr(sys, "argv", [*command, "--write"])
assert coverage_main() == 0
monkeypatch.setattr(sys, "argv", command)
assert coverage_main() == 0
def test_coverage_legacy_zero_baseline_can_only_initialize_once(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""历史全零 fixture 只允许初始化,初始化后下降仍必须拒绝写入。"""
report_path = tmp_path / "coverage.json"
baseline_path = tmp_path / "coverage-baseline.json"
baseline_path.write_text(
json.dumps({
"application": {"statements": 0, "covered_lines": 0, "percent": 0.0},
"domain": {"statements": 0, "covered_lines": 0, "percent": 0.0},
}),
encoding="utf-8",
)
report = {
"files": {
"app/application/a.py": {
"summary": {"num_statements": 10, "covered_lines": 5}
},
"app/domain/a.py": {
"summary": {"num_statements": 20, "covered_lines": 15}
},
}
}
report_path.write_text(json.dumps(report), encoding="utf-8")
command = [
"coverage_ratchet.py",
"--report",
str(report_path),
"--baseline",
str(baseline_path),
]
monkeypatch.setattr(sys, "argv", command)
assert coverage_main() == 1
monkeypatch.setattr(sys, "argv", [*command, "--write"])
assert coverage_main() == 0
initialized_bytes = baseline_path.read_bytes()
report["files"]["app/domain/a.py"]["summary"]["covered_lines"] = 14
report_path.write_text(json.dumps(report), encoding="utf-8")
assert coverage_main() == 1
assert baseline_path.read_bytes() == initialized_bytes
def test_coverage_write_rejects_malformed_existing_baseline(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""已有 malformed 基线不能借 --write 被静默洗掉。"""
report_path = tmp_path / "coverage.json"
baseline_path = tmp_path / "coverage-baseline.json"
report_path.write_text(
json.dumps({
"files": {
"app/application/a.py": {
"summary": {"num_statements": 10, "covered_lines": 5}
},
"app/domain/a.py": {
"summary": {"num_statements": 20, "covered_lines": 15}
},
}
}),
encoding="utf-8",
)
original_bytes = json.dumps({
"application": {"statements": 0, "covered_lines": 0, "percent": 0.0},
}).encode()
baseline_path.write_bytes(original_bytes)
monkeypatch.setattr(
sys,
"argv",
[
"coverage_ratchet.py",
"--write",
"--report",
str(report_path),
"--baseline",
str(baseline_path),
],
)
assert coverage_main() == 1
assert baseline_path.read_bytes() == original_bytes