Files
photoanalyzer/tests/integration/test_docs_gate.py
domverse 516f81c71e
Some checks failed
Test / suites (push) Failing after 2m30s
Test / container (push) Failing after 5m38s
Test / documentation (push) Failing after 1m54s
US09-05: Automate Documentation Acceptance (#111)
2026-08-24 00:22:59 +02:00

197 lines
7.7 KiB
Python

"""US09-05: the documentation gate's own contract, checked without a browser.
The gate itself needs a browser and a server and takes minutes; what is checkable
offline is what makes it a *gate* rather than a long test run:
* it selects every documentation check by marker, so adding one is enough to put it
in front of a release;
* it accepts no skip at all — a check that did not run is a page nobody compared
with the code;
* it retains checksummed evidence per run;
* CI runs it on pull requests, which is when a fix is still cheap.
The checks it runs are the four suites written by US09-01 through US09-04.
"""
from __future__ import annotations
import json
import re
from pathlib import Path
import pytest
import yaml
from photo_pipeline.config import Config
from photo_pipeline.services import release
REPO = Path(__file__).resolve().parents[2]
DOCS = REPO / "docs"
TEST_WORKFLOW = yaml.safe_load((REPO / ".gitea" / "workflows" / "test.yml").read_text())
pytestmark = pytest.mark.phase_i
# The suites the gate exists to run. Each is mapped to a story in the traceability
# matrix, so this list is also what stops one being quietly dropped.
DOCUMENTATION_SUITES = (
"tests/integration/test_documentation.py",
"tests/integration/test_installation_manual.py",
"tests/integration/test_architecture_overview.py",
"tests/integration/test_user_manual.py",
"tests/integration/test_docs_gate.py",
"tests/e2e/test_docs_ui.py",
"tests/e2e/test_user_manual_screenshots.py",
)
def _config(tmp_path) -> Config:
(tmp_path / "data").mkdir(exist_ok=True)
return Config.from_env({"PHOTO_PIPELINE_DATA_DIR": str(tmp_path / "data")})
# ── what the gate runs ───────────────────────────────────────────────────────
def test_the_gate_selects_every_documentation_check_by_marker():
assert release.DOCS_STAGES == (("documentation", ("tests", "-m", "phase_i")),)
declared = [
line
for line in (REPO / "pyproject.toml").read_text().splitlines()
if line.strip().startswith('"phase_i:')
]
assert declared, "an unregistered marker selects nothing and fails no gate"
def test_every_documentation_suite_carries_the_marker():
"""A documentation test without the marker is a check the gate never runs."""
unmarked = [
suite
for suite in DOCUMENTATION_SUITES
if "pytest.mark.phase_i" not in (REPO / suite).read_text()
]
assert unmarked == [], f"suites missing the phase_i marker: {unmarked}"
def test_the_marker_selects_nothing_outside_the_documentation_suites():
"""Scope matters: the gate's promise is that it ran *the documentation checks*."""
marked = sorted(
str(path.relative_to(REPO))
for path in (REPO / "tests").rglob("test_*.py")
if "pytest.mark.phase_i" in path.read_text()
)
assert marked == sorted(DOCUMENTATION_SUITES)
# ── no skip is an environment limit here ─────────────────────────────────────
def test_the_gate_accepts_no_skipped_check_at_all():
assert release.DOCS_ALLOWED_SKIP_REASONS == ()
skipped = release.StageResult(
"documentation", [], 0, 0.1, "1 skipped", ["SKIPPED [1] x.py:1: exiftool not installed"]
)
# The release gate tolerates this one because it describes the machine. The
# documentation gate cannot: nothing here depends on the machine.
assert release.unexpected_skips([skipped]) == []
assert release.unexpected_skips([skipped], release.DOCS_ALLOWED_SKIP_REASONS) == [
"SKIPPED [1] x.py:1: exiftool not installed"
]
def test_a_skipped_check_fails_the_run_and_the_evidence_says_so(tmp_path):
skipping = tmp_path / "test_skipping.py"
skipping.write_text(
"import pytest\n\ndef test_x():\n pytest.skip('exiftool not installed')\n"
)
evidence = tmp_path / "evidence"
report = release.run_gate(
_config(tmp_path),
output=evidence,
stages=(("documentation", (str(skipping),)),),
allowed_skips=release.DOCS_ALLOWED_SKIP_REASONS,
)
assert report["ok"] is False
assert report["failures"] == [], "the stage passed; the skip is what fails the gate"
assert report["unexpected_skips"], report
written = json.loads((evidence / release.REPORT_NAME).read_text())
assert written["ok"] is False
assert (evidence / "logs" / "documentation.log").exists()
for line in (evidence / release.CHECKSUMS_NAME).read_text().splitlines():
digest, name = line.split(" ", 1)
assert release.sha256_file(evidence / name) == digest
def test_a_broken_documentation_check_fails_the_gate(tmp_path):
"""The failure mode that matters: a page and the code disagreeing."""
failing = tmp_path / "test_disagreement.py"
failing.write_text(
"def test_the_manual_matches_the_code():\n"
" documented = {'lock_held'}\n"
" emitted = {'lock_held', 'renamed_since'}\n"
" assert emitted <= documented\n"
)
report = release.run_gate(
_config(tmp_path),
output=tmp_path / "evidence",
stages=(("documentation", (str(failing),)),),
allowed_skips=release.DOCS_ALLOWED_SKIP_REASONS,
)
assert report["ok"] is False
assert report["failures"], report
def test_a_clean_tree_passes(tmp_path):
passing = tmp_path / "test_passing.py"
passing.write_text("def test_x():\n assert True\n")
report = release.run_gate(
_config(tmp_path),
output=tmp_path / "evidence",
stages=(("documentation", (str(passing),)),),
allowed_skips=release.DOCS_ALLOWED_SKIP_REASONS,
)
assert report["ok"] is True and report["unexpected_skips"] == []
assert report["revision"], "the evidence must say which commit it covers"
# ── the command, and CI ──────────────────────────────────────────────────────
def test_the_command_is_documented_and_wired():
assert "docs-gate" in (REPO / "photo_pipeline" / "__main__.py").read_text()
assert "docs-gate" in (DOCS / "index.md").read_text()
def test_ci_runs_the_gate_on_pull_requests_and_keeps_its_evidence():
job = TEST_WORKFLOW["jobs"]["documentation"]
# No `if:` — documentation drifts on the same commits that change behaviour, and
# the pull request is when saying so is still cheap.
assert "if" not in job
assert "pull_request" in TEST_WORKFLOW[True] or "pull_request" in TEST_WORKFLOW.get("on", {})
script = "\n".join(step["run"] for step in job["steps"] if "run" in step)
assert "photo_pipeline docs-gate" in script
evidence = next(step for step in job["steps"] if "upload-artifact" in str(step.get("uses")))
assert evidence["if"] == "always()", "a failed gate's evidence is the one worth keeping"
# ── the pages and the repository point at each other ─────────────────────────
def test_the_readme_and_the_documentation_index_link_to_each_other():
"""Neither may become the forgotten copy."""
readme = (REPO / "README.md").read_text()
index = (DOCS / "index.md").read_text()
assert "docs/index.md" in readme
assert re.search(r"\.\./README\.md", index), "the index does not link back to the README"
def test_every_documentation_story_is_mapped_in_the_traceability_matrix():
matrix = json.loads((REPO / "tests" / "story_traceability.json").read_text())["stories"]
for story in ("US09-01", "US09-02", "US09-03", "US09-04", "US09-05"):
assert matrix.get(story), f"{story} is not mapped to any test"