Files
MoviePilot/tests/test_complexity_gate.py
DDSRem 2de976f6b3 Merge pull request #7050 from DDSRem-Dev/codex-comment-migration
docs: 统一全项目中文注释和结构化契约说明
2026-10-17 19:26:29 +08:00

271 lines
12 KiB
Python

"""
结构复杂度门禁、行数观察与基线写保护的回归测试。
"""
import json
import subprocess
import sys
from pathlib import Path
from textwrap import indent
import pytest
from scripts.architecture import complexity
def _write_source(root: Path, relative: str, source: str) -> Path:
"""
写入隔离源码树,避免对真实宿主文件做变异。
"""
path = root / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(source, encoding="utf-8")
return path
def _branches(count: int, name: str = "dispatch") -> str:
"""
构造分支数可控的函数,以真实 Ruff 验证阈值边界。
"""
return f"def {name}(value):\n" + "".join(
f" if value == {index}:\n return {index}\n" for index in range(count)
)
def _nested(depth: int) -> str:
"""
构造独立于物理行数的深层条件控制流。
"""
return "def nested(value):\n" + "".join(
f"{' ' * level}if value:\n" for level in range(1, depth + 1)
) + f"{' ' * (depth + 1)}return value\n"
def test_structural_metrics_cover_all_host_packages_and_ignore_only_plugins(tmp_path: Path) -> None:
"""
原来未受治的宿主目录同样拦截短而多分支的函数;插件副本除外。
"""
packages = (
"adapters", "agent", "api/endpoints", "application", "chain", "db", "domain",
"foundation", "modules", "runtime", "scheduler", "startup", "workflow", "sdk",
"runtime/compat", "testing",
)
for package in packages:
_write_source(tmp_path, f"app/{package}/sample.py", _branches(15, "__private"))
_write_source(tmp_path, "app/plugins/sample/__init__.py", "invalid syntax !")
metrics = complexity.collect_complexity(tmp_path)
assert metrics["C901"] == {
f"app/{package}/sample.py:__private": 16 for package in packages
}
assert metrics["PLR1702"] == {}
def test_complexity_limits_are_inclusive_and_nested_depth_is_independent(tmp_path: Path) -> None:
"""
圈复杂度 15、嵌套 5 层允许,跨过任一阈值必须报告。
"""
path = _write_source(tmp_path, "app/sample.py", _branches(14) + _nested(5))
assert complexity.collect_complexity(tmp_path) == {"C901": {}, "PLR1702": {}}
path.write_text(_branches(15) + _nested(6), encoding="utf-8")
assert complexity.collect_complexity(tmp_path) == {
"C901": {"app/sample.py:dispatch": 16},
"PLR1702": {"app/sample.py:nested": 6},
}
def test_formatting_comments_and_docstrings_only_affect_advisory_size(tmp_path: Path) -> None:
"""
增加文档、空行或换行不消耗结构预算,但仍可观察源码规模变化。
"""
path = _write_source(tmp_path, "app/sample.py", _branches(15))
before = complexity.collect_complexity(tmp_path)
formatted = _branches(15).replace("(value):", "(\n value,\n):").replace(
" if value == 0:",
' """文档\n' + " 说明\n" * 160 + ' """\n\n # 分支语义未变\n if value == 0:',
)
path.write_text("\n\n" + formatted, encoding="utf-8")
assert complexity.collect_complexity(tmp_path) == before
assert complexity.collect_source_size(tmp_path)["method"]["app/sample.py:dispatch"] > 150
path.write_text("def linear():\n" + " value = 1\n" * 1100, encoding="utf-8")
assert complexity.collect_complexity(tmp_path) == {"C901": {}, "PLR1702": {}}
assert complexity.collect_source_size(tmp_path)["file"] == {"app/sample.py": 1101}
def test_nested_owners_and_redefinitions_do_not_share_budgets(tmp_path: Path) -> None:
"""
Match、TryStar 内嵌套函数与同名重定义必须保留独立身份。
"""
source = (
"class Runner:\n" + indent(_branches(15, "__hidden"), " ")
+ "\ndef outer(value):\n match value:\n case 1:\n"
+ indent(_branches(15, "matched"), " ")
+ "\ndef guarded():\n try:\n pass\n except* ValueError:\n"
+ indent(_branches(15, "recovered"), " ")
+ "\ndef local():\n class Inner:\n"
+ indent(_branches(15, "method"), " ")
+ "\nif enabled:\n" + indent(_branches(15, "repeated"), " ")
+ "else:\n" + indent(_branches(16, "repeated"), " ")
)
_write_source(tmp_path, "app/sample.py", source)
metrics = complexity.collect_complexity(tmp_path)["C901"]
for owner in ("Runner.__hidden", "outer.matched", "guarded.recovered", "local.Inner.method"):
assert metrics[f"app/sample.py:{owner}"] == 16
assert metrics["app/sample.py:repeated#1"] == 16
assert metrics["app/sample.py:repeated#2"] == 17
def test_noqa_gitignore_and_local_ruff_config_cannot_hide_metrics(tmp_path: Path) -> None:
"""
noqa、忽略文件或局部 Ruff 排除项不能绕过宿主复杂度门禁。
"""
_write_source(tmp_path, "app/sample.py", _branches(15).replace(
"def dispatch(value):", "def dispatch(value): # noqa: C901",
))
(tmp_path / ".git").mkdir()
(tmp_path / ".gitignore").write_text("app/\n", encoding="utf-8")
(tmp_path / "ruff.toml").write_text('exclude = ["app"]\n', encoding="utf-8")
assert complexity.collect_complexity(tmp_path)["C901"] == {"app/sample.py:dispatch": 16}
def test_multiple_deep_blocks_use_the_functions_maximum_depth(tmp_path: Path) -> None:
"""
同函数多个深层块取最大深度,嵌套函数的指标归属自己。
"""
source = _nested(6) + _nested(7).split("\n", 1)[1]
source += "\ndef outer():\n" + indent(_nested(8), " ")
_write_source(tmp_path, "app/sample.py", source)
assert complexity.collect_complexity(tmp_path)["PLR1702"] == {
"app/sample.py:nested": 7, "app/sample.py:outer.nested": 8,
}
def test_growth_cannot_be_offset_by_reduction_or_renaming() -> None:
"""
同文件中删除旧热点不能抵消另一个函数变复杂或搬走后的新热点。
"""
baseline = {"C901": {"app/a.py:old": 20, "app/a.py:growing": 18}, "PLR1702": {}}
current = {"C901": {"app/b.py:moved": 20, "app/a.py:growing": 19}, "PLR1702": {}}
regressions, stale = complexity.classify_complexity(baseline, current)
assert len(regressions) == 2
assert any("growing 18->19" in item for item in regressions)
assert any("moved 15->20" in item for item in regressions)
assert len(stale) == 1 and "old" in stale[0]
@pytest.mark.parametrize("current", [{"C901": {"app/a.py:f": 16}, "PLR1702": {}}, {"C901": {}, "PLR1702": {}}])
def test_cli_requires_and_writes_lower_watermark(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, current: dict,
) -> None:
"""
降低到阈值内或删除热点后必须收紧基线,后续检查才能通过。
"""
baseline = tmp_path / "baseline.json"
baseline.write_text(json.dumps(complexity.baseline_payload({"C901": {"app/a.py:f": 20}, "PLR1702": {}})))
monkeypatch.setattr(complexity, "collect_complexity", lambda: current)
monkeypatch.setattr(sys, "argv", ["complexity.py", "--baseline", str(baseline)])
assert complexity.main() == 1
monkeypatch.setattr(sys, "argv", [*sys.argv, "--write"])
assert complexity.main() == 0
assert complexity.read_baseline(baseline) == current
monkeypatch.setattr(sys, "argv", sys.argv[:-1])
assert complexity.main() == 0
def test_cli_refuses_to_overwrite_growth_and_keeps_diagnostic_report(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
失败时保存审查报告,但 write 绝不能把回退洗入已有基线。
"""
baseline, report = tmp_path / "baseline.json", tmp_path / "report.json"
original = json.dumps(complexity.baseline_payload({"C901": {}, "PLR1702": {}}))
baseline.write_text(original, encoding="utf-8")
current = {"C901": {"app/a.py:f": 16}, "PLR1702": {}}
monkeypatch.setattr(complexity, "collect_complexity", lambda: current)
monkeypatch.setattr(complexity, "collect_source_size", lambda: {"file": {"app/a.py": 2000}})
monkeypatch.setattr(sys, "argv", ["complexity.py", "--write", "--baseline", str(baseline), "--report", str(report)])
assert complexity.main() == 1
assert baseline.read_text(encoding="utf-8") == original
assert json.loads(report.read_text(encoding="utf-8"))["metrics"] == current
monkeypatch.setattr(sys, "argv", ["complexity.py", "--baseline", str(baseline), "--report", str(baseline)])
assert complexity.main() == 1
assert baseline.read_text(encoding="utf-8") == original
def test_cli_missing_baseline_requires_explicit_initialization(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""
缺失基线不能默认为成功,首次建账必须显式执行 write。
"""
baseline = tmp_path / "new.json"
monkeypatch.setattr(complexity, "collect_complexity", lambda: {"C901": {}, "PLR1702": {}})
monkeypatch.setattr(sys, "argv", ["complexity.py", "--baseline", str(baseline)])
assert complexity.main() == 1
monkeypatch.setattr(sys, "argv", [*sys.argv, "--write"])
assert complexity.main() == 0
assert complexity.read_baseline(baseline) == {"C901": {}, "PLR1702": {}}
@pytest.mark.parametrize("payload", [
{"api_endpoint": {}, "application_public": {}, "chain_public": {}},
{"schema_version": 3, "thresholds": {"C901": 20, "PLR1702": 5}, "metrics": {}},
complexity.baseline_payload({"C901": {"app/a.py:f": True}, "PLR1702": {}}),
complexity.baseline_payload({"C901": {"app/plugins/a.py:f": 16}, "PLR1702": {}}),
complexity.baseline_payload({"C901": {"app/a.py:f": 15}, "PLR1702": {}}),
complexity.baseline_payload({"C901": {}}),
])
def test_baseline_rejects_legacy_malformed_or_changed_policy(tmp_path: Path, payload: dict) -> None:
"""
格式、范围和阈值异常必须失败,不能静默接受旧口径。
"""
path = tmp_path / "baseline.json"
path.write_text(json.dumps(payload), encoding="utf-8")
with pytest.raises(ValueError):
complexity.read_baseline(path)
@pytest.mark.parametrize(("status", "stdout", "stderr"), [
(2, "", "tool failure"), (0, "", ""), (0, "{}", ""),
(1, "[]", ""), (0, '[{"code": "C901"}]', ""), (0, "[]", "warning"),
])
def test_ruff_failures_never_become_zero_debt(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, status: int, stdout: str, stderr: str,
) -> None:
"""
工具异常、输出丢失和退出状态不一致均必须阻断。
"""
result = subprocess.CompletedProcess([], status, stdout, stderr)
monkeypatch.setattr(complexity.subprocess, "run", lambda *args, **kwargs: result)
with pytest.raises(ValueError):
complexity.run_ruff(tmp_path)
@pytest.mark.parametrize("changes", [
{"code": "invalid-syntax"}, {"message": "unrecognized metric"},
{"message": "metric (16 > 10)"}, {"location": {"row": 999}},
])
def test_unrecognized_diagnostics_fail_closed(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, changes: dict,
) -> None:
"""
Ruff 升级改变输出或无法定位 owner 时不能漏记债务。
"""
path = _write_source(tmp_path, "app/sample.py", _branches(15))
diagnostic = {
"code": "C901", "message": "metric (16 > 15)",
"filename": str(path), "location": {"row": 1}, **changes,
}
monkeypatch.setattr(complexity, "run_ruff", lambda root: [diagnostic])
with pytest.raises(ValueError):
complexity.collect_complexity(tmp_path)
def test_missing_or_invalid_source_cannot_shrink_baseline(tmp_path: Path) -> None:
"""
扫描根消失或源码损坏不能被当成复杂度下降。
"""
with pytest.raises(ValueError, match="未找到"):
complexity.collect_complexity(tmp_path)
_write_source(tmp_path, "app/sample.py", "invalid syntax !")
with pytest.raises(SyntaxError):
complexity.collect_complexity(tmp_path)