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

346 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
后端架构治理工作流的静态契约测试。
"""
import configparser
from pathlib import Path
from ruamel.yaml import YAML
from tests.run import ARCHITECTURE_GATE_TESTS
PROJECT_ROOT = Path(__file__).parents[1]
WORKFLOW_ROOT = PROJECT_ROOT / ".github" / "workflows"
def _load_workflow(name: str) -> dict:
"""
以 YAML 1.2 解析 GitHub Actions 工作流。
"""
yaml = YAML(typ="safe")
return yaml.load((WORKFLOW_ROOT / name).read_text(encoding="utf-8"))
def _step_commands(workflow: dict, job_name: str) -> str:
"""
拼接指定 job 的命令,便于断言关键门禁没有被移除。
"""
return "\n".join(
step.get("run", "")
for step in workflow["jobs"][job_name]["steps"]
)
def test_unit_test_workflow_has_independent_host_architecture_gate():
"""
主仓 PR 与推送必须在全量分片外快速执行宿主架构门禁。
"""
workflow = _load_workflow("test.yml")
commands = "\n".join(
_step_commands(workflow, job_name) for job_name in ("architecture", "architecture-ratchets")
)
steps = workflow["jobs"]["architecture"]["steps"]
semantic_step = next(
step for step in steps if step.get("name") == "Check event semantic policy"
)
snapshot_step = next(
step for step in steps if step.get("name") == "Check host architecture snapshot"
)
assert "pull_request" in workflow["on"]
assert "push" in workflow["on"]
assert "tests/test_architecture_dependencies.py" in commands
assert "tests/test_architecture_adapter_imports.py" in commands
assert "tests/test_architecture_egress.py" in commands
assert "tests/test_architecture_event_facts.py" in semantic_step["run"]
assert "tests/test_architecture_event_policy.py" in semantic_step["run"]
assert "tests/test_architecture_dependencies.py" in semantic_step["run"]
assert "tests/test_architecture_adapter_imports.py" in semantic_step["run"]
assert "tests/test_architecture_egress.py" in semantic_step["run"]
assert "scripts/architecture/baseline.py" not in semantic_step["run"]
assert not {
"tests/test_architecture_dependencies.py",
"tests/test_architecture_adapter_imports.py",
"tests/test_architecture_egress.py",
"tests/test_architecture_event_facts.py",
"tests/test_architecture_event_policy.py",
} & set(snapshot_step["run"].split())
assert steps.index(semantic_step) < steps.index(snapshot_step)
assert "tests/test_architecture_contract_baseline.py" in commands
assert "scripts/architecture/baseline.py --check-host" in commands
assert commands.count("scripts/architecture/baseline.py --check-host") == 1
assert "scripts/architecture/ruff_ratchet.py" in commands
assert "scripts/architecture/mypy_ratchet.py" in commands
assert "scripts/architecture/ruff_ratchet.py --write" not in commands
assert "scripts/architecture/mypy_ratchet.py --write" not in commands
assert "scripts/architecture/service_locator.py" in commands
assert "scripts/architecture/complexity.py" in commands
assert "complexity.py --v2" not in commands
assert "--report complexity-report.json" in commands
complexity_step = next(
step for step in workflow["jobs"]["architecture-ratchets"]["steps"]
if step.get("name") == "Check structural complexity ratchet"
)
assert "--write" not in complexity_step["run"]
assert "|| true" not in complexity_step["run"]
assert not complexity_step.get("continue-on-error")
report_step = next(
step for step in workflow["jobs"]["architecture-ratchets"]["steps"]
if step.get("name") == "Upload complexity and source size report"
)
assert report_step["if"] == "always()"
assert report_step["with"]["path"] == "complexity-report.json"
assert "scripts/startup/performance.py --check --repeat 3" in commands
# 覆盖率分片跳过的文件必须恰好由门禁运行,否则会有测试在 CI 中无人执行
gate_test_files = {
token.removeprefix("tests/")
for token in commands.split()
if token.startswith("tests/test_") and token.endswith(".py")
}
assert gate_test_files == set(ARCHITECTURE_GATE_TESTS)
def test_official_plugin_observation_is_scheduled_and_never_writes_fixture():
"""
跨仓观察应定时或手工运行,只上传语义报告而不刷新 fixture。
"""
workflow = _load_workflow("architecture-observe.yml")
commands = _step_commands(workflow, "observe")
steps = workflow["jobs"]["observe"]["steps"]
assert "schedule" in workflow["on"]
assert "workflow_dispatch" in workflow["on"]
assert "--check-plugins" in commands
assert "--report official-plugin-architecture-report.json" in commands
assert "--write" not in commands
assert any(
step.get("uses", "").startswith("actions/upload-artifact@")
for step in steps
)
def test_coverage_jobs_parallelize_data_and_keep_one_global_ratchet() -> None:
"""
Coverage 分片并行采集数据,再由单一报告 job 合并并检查全局低水位。
"""
workflow = _load_workflow("test.yml")
assert "pytest" not in workflow["jobs"]
shard_job = workflow["jobs"]["coverage-shard"]
shard_steps = shard_job["steps"]
report_job = workflow["jobs"]["coverage-report"]
report_steps = report_job["steps"]
assert workflow["on"]["pull_request"]["branches"] == ["v3"]
assert workflow["on"]["push"]["branches"] == ["v3"]
assert "workflow_dispatch" in workflow["on"]
assert shard_job["runs-on"] == "ubuntu-latest"
assert shard_job["name"] == "Unit Tests with Coverage (${{ matrix.shard }})"
assert shard_job["timeout-minutes"] == 15
shard_matrix = shard_job["strategy"]["matrix"]["include"]
assert [item["shard"] for item in shard_matrix] == [f"{index}/6" for index in range(1, 7)]
assert [item["index"] for item in shard_matrix] == [str(index) for index in range(1, 7)]
assert report_job["needs"] == "coverage-shard"
assert report_job["runs-on"] == "ubuntu-latest"
assert report_job["timeout-minutes"] == 10
setup_step = next(step for step in shard_steps if step.get("name") == "Set up uv")
install_step = next(
step for step in shard_steps if step.get("name") == "Install dependencies"
)
generate_step = next(
step for step in shard_steps if step.get("name") == "Generate coverage data"
)
assert generate_step["timeout-minutes"] == 10
assert (
"coverage run --parallel-mode tests/run.py --exclude-architecture-gate --shard"
in generate_step["run"]
)
upload_data_step = next(
step for step in shard_steps if step.get("name") == "Upload coverage data"
)
assert upload_data_step["with"]["path"] == ".coverage.*"
assert upload_data_step["with"]["name"] == "coverage-data-${{ matrix.index }}"
assert upload_data_step["with"]["include-hidden-files"] is True
download_step = next(
step for step in report_steps if step.get("name") == "Download coverage data"
)
assert download_step["uses"] == "actions/download-artifact@v8"
assert download_step["with"]["merge-multiple"] is True
report_step = next(
step
for step in report_steps
if step.get("name") == "Combine coverage data and check ratchet"
)
assert setup_step["with"]["python-version"] == "3.14"
assert install_step["run"] == "uv sync --locked"
commands = report_step["run"]
expected_commands = [
"python -m coverage combine coverage-data",
"python -m coverage report",
"python -m coverage json",
"python -m coverage xml",
"scripts/architecture/coverage_ratchet.py",
]
positions = [commands.index(command) for command in expected_commands]
assert positions == sorted(positions)
all_commands = _step_commands(workflow, "coverage-shard") + _step_commands(
workflow, "coverage-report"
)
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 shard_steps + report_steps
)
assert all(
"always()" not in str(step.get("if", ""))
for step in shard_steps + report_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()
assert "app/testing/*" not in coverage_config["run"]["omit"].splitlines()
def test_ci_reuse_requires_successful_proof_for_every_gate() -> None:
"""
证明必须依赖全部硬门禁,推送只能通过保守判定跳过昂贵任务。
"""
for filename, gates in {
"test.yml": ["architecture", "architecture-ratchets", "coverage-shard", "coverage-report"],
"pylint.yml": ["pylint"],
}.items():
jobs = _load_workflow(filename)["jobs"]
reuse = jobs["reuse"]
assert reuse["permissions"] == {
"contents": "read", "actions": "read", "pull-requests": "read"
}
assert reuse["outputs"]["reused"] == "${{ steps.check.outputs.reuse }}"
assert "node --test .github/scripts/reuse.test.mjs" in _step_commands(
_load_workflow(filename), "reuse"
)
for gate in gates:
if gate == "coverage-report":
continue
assert jobs[gate]["needs"] == "reuse"
assert jobs[gate]["if"] == "needs.reuse.outputs.reused != 'true'"
proof = jobs["proof"]
assert proof["name"] == "CI proof (${{ github.sha }})"
assert proof["needs"] == gates
assert proof["if"] == "github.event_name == 'pull_request' && " + " && ".join(
f"needs.{gate}.result == 'success'" for gate in gates
)
def test_pylint_workflow_runs_for_v3_pull_requests_and_pushes():
"""
改动文件应硬门禁,而全仓存量问题只能生成建议性报告。
"""
workflow = _load_workflow("pylint.yml")
commands = _step_commands(workflow, "pylint")
assert workflow["on"]["pull_request"]["branches"] == ["v3"]
assert workflow["on"]["push"]["branches"] == ["v3"]
assert "changed-python-files.txt" in commands
assert "xargs uv run --locked --no-sync pylint" in commands
assert "pylint app/" in commands
assert "--output-format=json > pylint-report.json || true" in commands
full_report_step = next(
step
for step in workflow["jobs"]["pylint"]["steps"]
if step.get("name") == "Generate full advisory report"
)
assert "|| true" in full_report_step["run"]
def test_upload_artifact_actions_share_node24_major():
"""
主仓工件上传统一为 v7;采集器工作流已迁出,不得重新混入主仓发布。
"""
actions_by_workflow: dict[str, list[str]] = {}
for workflow_path in WORKFLOW_ROOT.glob("*.yml"):
workflow = _load_workflow(workflow_path.name)
upload_actions = [
step["uses"]
for job in workflow.get("jobs", {}).values()
for step in job.get("steps", [])
if step.get("uses", "").startswith("actions/upload-artifact@")
]
if upload_actions:
actions_by_workflow[workflow_path.name] = upload_actions
assert actions_by_workflow == {
"architecture-observe.yml": ["actions/upload-artifact@v7"],
"native-dependency-update.yml": ["actions/upload-artifact@v7"],
"pylint.yml": ["actions/upload-artifact@v7"],
"test.yml": [
"actions/upload-artifact@v7",
"actions/upload-artifact@v7",
"actions/upload-artifact@v7",
],
}
def test_native_dependency_update_probe_has_narrow_automatic_triggers():
"""
昂贵的三平台探针只跟随生产安装边界变化,并保留手工验收入口。
"""
workflow = _load_workflow("native-dependency-update.yml")
expected_paths = [
"app/adapters/external/market.py",
"app/adapters/system/host.py",
"app/adapters/system/package.py",
"app/adapters/system/plugin/package.py",
"app/application/plugin/install.py",
"app/runtime/dependencies/native.py",
"app/runtime/dependencies/profile.py",
"scripts/probe_native_dependency_update.py",
".github/workflows/native-dependency-update.yml",
]
assert workflow["on"]["pull_request"]["paths"] == expected_paths
assert workflow["on"]["push"]["paths"] == expected_paths
assert "workflow_dispatch" in workflow["on"]
assert set(workflow["jobs"]) == {"probe"}
def test_dependency_compatibility_covers_windows_free_threaded_profile():
"""
依赖门禁必须真实安装并检查 Windows free-threaded profile。
"""
workflow = _load_workflow("dependency-compat.yml")
matrix = workflow["jobs"]["install"]["strategy"]["matrix"]["include"]
free_threaded = next(item for item in matrix if item["expected-profile"] == "free-threaded")
assert free_threaded == {
"name": "Windows x64 free-threaded",
"runner": "windows-2025",
"python-version": "3.14t",
"runtime-group": "runtime-free-threaded",
"expected-profile": "free-threaded",
"expected-system": "Windows",
"expected-machine": "AMD64",
}
install = _step_commands(workflow, "install")
assert "--group ${{ matrix.runtime-group }}" in install
assert "python -m scripts.verify_runtime_profile" in install
assert "MOVIEPILOT_SAFE_MODE" in str(workflow["jobs"]["install"]["steps"])
expected_paths = {
"app/__init__.py",
"app/runtime/dependencies/profile.py",
"scripts/verify_runtime_profile.py",
}
assert expected_paths <= set(workflow["on"]["pull_request"]["paths"])
assert expected_paths <= set(workflow["on"]["push"]["paths"])
postgres_step = next(
step
for step in workflow["jobs"]["install"]["steps"]
if step.get("name") == "Expose PostgreSQL build tools"
)
assert postgres_step["if"] == "matrix.expected-profile == 'free-threaded'"
assert "Test-Path -LiteralPath $env:PGBIN -PathType Container" in postgres_step["run"]