Files
photoanalyzer/tests/e2e/test_user_manual_screenshots.py
domverse f1442527a2
Some checks failed
Test / suites (push) Failing after 2m56s
Test / container (push) Failing after 5m27s
US09-04: Write the User Manual with Generated Screenshots (#110)
2026-08-23 23:37:31 +02:00

145 lines
5.7 KiB
Python

"""US09-04: the user manual's screenshots, produced from the running application.
A screenshot nobody can regenerate is a screenshot that silently stops being true.
So every image in the manual is captured here, from a real server and a real worker
driving a temporary fixture library, and never pasted in by hand.
Regenerate the committed images with one command:
PHOTO_PIPELINE_WRITE_SCREENSHOTS=1 work_item/scripts/python -m pytest \
tests/e2e/test_user_manual_screenshots.py -q
Without that variable the capture still runs on every suite — the generator has to
keep working, and a view that stops rendering must fail here rather than in a
reader's browser — but the images go to a temporary directory. Regenerating on every
run would leave the working tree permanently dirty, because PNG output is not
byte-stable.
"""
from __future__ import annotations
import os
import re
from contextlib import closing
from pathlib import Path
import pytest
from tests.conftest import session_client
from tests.e2e._pipeline_harness import (
Server,
seed_album,
start_worker,
wait_until,
)
REPO = Path(__file__).resolve().parents[2]
DOCS = REPO / "docs"
IMAGES = DOCS / "images"
ALBUM = "rome"
VIEWPORT = {"width": 1280, "height": 900}
# One per stage page of the manual: the route to visit, and the element whose
# presence means the view has actually finished rendering.
SHOTS = (
("workflow", "#/workflow", "stage-safety"),
("inventory", "#/inventory", "asset-row"),
("duplicates", "#/duplicates", None),
("safety", "#/safety", None),
("analysis", "#/analyze", "analyze-counts"),
("albums", f"#/albums?album={ALBUM}", "suggested-name"),
("renames", "#/renames", None),
("uploads", "#/uploads", "upload-scope"),
("archive", "#/archive", "archive-locations"),
("statistics", "#/stats", None),
)
def writing() -> bool:
return os.environ.get("PHOTO_PIPELINE_WRITE_SCREENSHOTS") == "1"
@pytest.fixture(scope="module")
def stack(tmp_path_factory):
"""A library in the state the manual describes: one analysed album, a duplicate
to review, an archive destination, and a worker that claims jobs."""
tmp_path = tmp_path_factory.mktemp("manual")
seeded = seed_album(tmp_path, ALBUM, ("forum.jpg", "colosseum.jpg"))
worker = start_worker(seeded, fake_vision_log=tmp_path / "vision.log")
server = Server(seeded).start()
try:
_prepare(server.base, seeded)
yield server
finally:
server.stop()
worker.terminate()
worker.wait(timeout=10)
def _prepare(base: str, seeded) -> None:
"""Everything the views need, established over the public API — the same calls a
person would make, so the screenshots show reachable states."""
with closing(session_client(base, timeout=30)) as client:
client.post("/api/v1/duplicates/detect").raise_for_status()
client.post("/api/v1/albums/proposals", json={}).raise_for_status()
destination = seeded.data / "archive"
destination.mkdir(exist_ok=True)
client.post(
"/api/v1/archive-locations",
json={"name": "external disk", "root": str(destination)},
).raise_for_status()
wait_until(lambda: client.get("/api/v1/workflow").status_code == 200)
def _capture(page, server, target: Path) -> list[str]:
target.mkdir(parents=True, exist_ok=True)
page.set_viewport_size(VIEWPORT)
written = []
for name, route, ready in SHOTS:
page.goto(f"{server.base}/app/{route}")
if ready:
page.get_by_test_id(ready).first.wait_for(timeout=30_000)
else:
page.locator("main h1").first.wait_for(timeout=30_000)
page.screenshot(path=str(target / f"{name}.png"))
written.append(name)
return written
def test_the_generator_produces_every_screenshot_the_manual_references(page, stack, tmp_path):
destination = IMAGES if writing() else tmp_path / "images"
written = _capture(page, stack, destination)
assert sorted(written) == sorted(name for name, _, _ in SHOTS)
for name in written:
produced = destination / f"{name}.png"
assert produced.stat().st_size > 5_000, f"{name}.png is too small to be a view"
referenced = set(re.findall(r"images/([a-z-]+)\.png", "\n".join(
path.read_text() for path in DOCS.rglob("*.md")
)))
assert referenced <= set(written), f"the manual references images nobody generates: {referenced - set(written)}"
def test_no_screenshot_shows_a_real_path_or_a_secret(page, stack, tmp_path):
"""The fixture library is synthetic and lives in a temporary directory, so the
pixels cannot carry someone's photographs — but the view can still print a path,
and that path must be the fixture's, never the operator's."""
with closing(session_client(stack.base, timeout=30)) as client:
rendered = client.get("/api/v1/inventory/assets", params={"limit": 200}).json()["items"]
paths = [item["current_path"] for item in rendered if item["current_path"]]
assert paths, "nothing was rendered, so nothing was proven"
assert all("/manual" in path or "pytest" in path or "/tmp" in path or "/var" in path for path in paths), paths
assert all(ALBUM in path or "images" in path for path in paths)
def test_every_committed_screenshot_is_referenced_by_a_page():
"""An image nobody shows is an image nobody updates."""
if not IMAGES.is_dir():
pytest.skip("no screenshots have been generated into the repository yet")
text = "\n".join(path.read_text() for path in DOCS.rglob("*.md"))
orphans = sorted(
image.name for image in IMAGES.glob("*.png") if f"images/{image.name}" not in text
)
assert orphans == [], f"committed but unreferenced: {orphans}"