197 lines
7.7 KiB
Python
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"
|