173 lines
6.1 KiB
Python
173 lines
6.1 KiB
Python
"""US09-04: the user manual, checked against the application it describes.
|
|
|
|
The manual's most perishable claim is its error catalogue: a code renamed in a route
|
|
leaves a page confidently explaining something that can no longer happen, and the
|
|
operator meeting the new code finds nothing. So the catalogue is compared with the
|
|
codes the application can actually emit, in both directions.
|
|
|
|
The screenshots are generated and checked by
|
|
``tests/e2e/test_user_manual_screenshots.py``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from pathlib import Path
|
|
|
|
REPO = Path(__file__).resolve().parents[2]
|
|
DOCS = REPO / "docs"
|
|
STAGES = DOCS / "stages"
|
|
PACKAGE = REPO / "photo_pipeline"
|
|
ERRORS = (DOCS / "errors.md").read_text()
|
|
ALL_DOCS = "\n".join(path.read_text() for path in DOCS.rglob("*.md"))
|
|
|
|
# The shapes an error code is emitted in. Everything the API returns goes through one
|
|
# of these, so the set they find is the set an operator can meet.
|
|
EMITTERS = (
|
|
re.compile(r'_error\(\s*\d+,\s*"([a-z_]+)"'),
|
|
# The status may be a literal or an exception's own code, so match either.
|
|
re.compile(r'_envelope\(\s*[\w.]+,\s*"([a-z_]+)"'),
|
|
re.compile(r'"code":\s*"([a-z_]+)"'),
|
|
re.compile(r'code="([a-z_]+)"'),
|
|
re.compile(r'Refusal\(\s*\d+,\s*"([a-z_]+)"'),
|
|
)
|
|
|
|
# Codes raised deep in a service and mapped to a response by its route; they are what
|
|
# the browser shows, so the manual owes the reader an entry.
|
|
SERVICE_CODES = {
|
|
"lock_held",
|
|
"path_not_allowed",
|
|
"rename_recovery_required",
|
|
"stale_preflight",
|
|
"access_denied",
|
|
"too_many_attempts",
|
|
}
|
|
|
|
STAGE_PAGES = (
|
|
"inventory",
|
|
"duplicates",
|
|
"safety",
|
|
"analysis",
|
|
"albums",
|
|
"renames",
|
|
"uploads",
|
|
"archive",
|
|
"diagnostics",
|
|
)
|
|
|
|
|
|
def emitted_codes() -> set[str]:
|
|
found: set[str] = set()
|
|
for path in PACKAGE.rglob("*.py"):
|
|
text = path.read_text()
|
|
for pattern in EMITTERS:
|
|
found |= set(pattern.findall(text))
|
|
for code in SERVICE_CODES:
|
|
assert re.search(rf'"{code}"', "\n".join(p.read_text() for p in PACKAGE.rglob("*.py"))), (
|
|
f"{code} is documented as a service code but no longer exists"
|
|
)
|
|
return (found | SERVICE_CODES) - {"code"}
|
|
|
|
|
|
def documented_codes() -> set[str]:
|
|
return set(re.findall(r"`([a-z][a-z_]{3,})`", ERRORS))
|
|
|
|
|
|
# ── the error catalogue ──────────────────────────────────────────────────────
|
|
|
|
|
|
def test_every_error_the_application_can_emit_is_documented():
|
|
missing = sorted(emitted_codes() - documented_codes())
|
|
assert missing == [], f"undocumented error codes: {missing}"
|
|
|
|
|
|
def test_the_catalogue_documents_no_code_that_cannot_happen():
|
|
"""Backticked snake_case in the catalogue is either a code, a setting, or one of
|
|
the handful of prose terms below."""
|
|
prose = {
|
|
"diagnostics",
|
|
"dry_run",
|
|
"approve_dry_run",
|
|
"current_path",
|
|
"archived_online",
|
|
"archived_offline",
|
|
}
|
|
settings = set(re.findall(r"PHOTO_PIPELINE_[A-Z_]+", ERRORS))
|
|
invented = sorted(
|
|
code
|
|
for code in documented_codes()
|
|
if code not in emitted_codes()
|
|
and code not in prose
|
|
and f"PHOTO_PIPELINE_{code.upper()}" not in settings
|
|
)
|
|
assert invented == [], f"documented but not emitted anywhere: {invented}"
|
|
|
|
|
|
def test_the_protective_refusals_are_explained_as_intentional():
|
|
"""These are the ones a reader is most likely to mistake for a broken
|
|
application, which is exactly when they start working around them."""
|
|
for code in ("lock_held", "rename_recovery_required", "stale_preflight", "not_runnable"):
|
|
row = next(line for line in ERRORS.splitlines() if line.startswith(f"| `{code}`"))
|
|
assert len(row.split("|")) >= 4, f"{code} has no cause and remedy"
|
|
|
|
|
|
# ── the stage pages ──────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_there_is_one_page_per_workflow_stage():
|
|
missing = [name for name in STAGE_PAGES if not (STAGES / f"{name}.md").is_file()]
|
|
assert missing == [], f"stages without a page: {missing}"
|
|
|
|
|
|
def test_every_stage_page_answers_the_four_questions():
|
|
"""The shape is the point: a page that skips 'what it changes' is the page
|
|
somebody needed."""
|
|
for name in STAGE_PAGES:
|
|
text = (STAGES / f"{name}.md").read_text()
|
|
for question in ("What it is for.", "What you decide.", "What it changes.", "What it refuses."):
|
|
assert question in text, f"{name}.md does not answer: {question}"
|
|
|
|
|
|
def test_every_stage_page_shows_a_screenshot():
|
|
for name in STAGE_PAGES:
|
|
text = (STAGES / f"{name}.md").read_text()
|
|
assert re.search(r"!\[[^\]]+\]\(\.\./images/[a-z-]+\.png\)", text), (
|
|
f"{name}.md carries no screenshot"
|
|
)
|
|
|
|
|
|
def test_the_guided_pass_names_the_irreversible_stages():
|
|
first_pass = (DOCS / "first-pass.md").read_text()
|
|
for stage in ("renames", "uploads", "archive"):
|
|
assert f"stages/{stage}.md" in first_pass
|
|
assert "point" in first_pass.lower() and "no return" in first_pass.lower()
|
|
|
|
|
|
def test_recovery_covers_each_interruption_the_application_can_survive():
|
|
recovery = (DOCS / "recovery.md").read_text()
|
|
for topic in (
|
|
"interrupted rename",
|
|
"uncertain upload",
|
|
"failed migration",
|
|
"restored backup",
|
|
):
|
|
assert topic in recovery.lower(), f"recovery does not cover: {topic}"
|
|
|
|
|
|
def test_the_manual_is_reachable_from_the_index():
|
|
index = (DOCS / "index.md").read_text()
|
|
for page in [f"stages/{name}.md" for name in STAGE_PAGES] + [
|
|
"first-pass.md",
|
|
"errors.md",
|
|
"recovery.md",
|
|
]:
|
|
assert page in index, f"{page} is not linked from the index"
|
|
|
|
|
|
def test_the_screenshots_are_declared_as_generated():
|
|
"""The allow list had to be widened for them, so the reason has to be visible
|
|
where the widening is."""
|
|
config = (REPO / "work_item" / ".work-item.yml").read_text()
|
|
assert "docs/images/**" in config
|
|
assert "test_user_manual_screenshots.py" in config, "no note saying who generates them"
|