diff --git a/booth/app.py b/booth/app.py index d054e9f..1e6d841 100644 --- a/booth/app.py +++ b/booth/app.py @@ -41,6 +41,7 @@ import os import re import secrets import shutil +import tempfile import time import zipfile from contextlib import asynccontextmanager @@ -87,6 +88,10 @@ from booth.items import ( # noqa: E402,F401 doc_kind, find_item, image_chain, + review_chain, + REVIEW_KINDS, + SEEN_FILE, + read_seen, read_blurred, render_doc, render_doc_body, @@ -319,6 +324,37 @@ def record_view(booth: Path) -> None: pass +def record_seen(booth: Path, rel: str, items: Sequence[Item]) -> None: + """Note that `rel` was looked at full size (R2 C2). + + Rewrites the whole marker — the previous set plus `rel`, pruned to rels that + are still items, sorted — so it is deduplicated and never outgrows the + booth. Atomic replace (CLAUDE.md invariant 5) through a temp file created + with O_EXCL: a planted `.seen.tmp` symlink cannot redirect the write, and + `os.replace` swaps a planted `.seen` symlink out rather than writing + through it. + + NEVER RAISES, for `record_view`'s reason: not recording a look is a cost + this service can absorb, not answering the request is not. + """ + try: + live = {it.rel for it in items} + seen = (read_seen(booth) | {rel}) & live + fd, tmp = tempfile.mkstemp(prefix=".seen.", suffix=".tmp", dir=booth) + try: + with os.fdopen(fd, "w") as fh: + fh.write("".join(f"{r}\n" for r in sorted(seen))) + os.replace(tmp, booth / SEEN_FILE) + except BaseException: + try: + os.unlink(tmp) + except OSError: + pass + raise + except OSError: + pass + + HOLD_UNREADABLE = "unreadable" HOLD_OPEN = "open" @@ -522,6 +558,8 @@ def build_gallery(child: Path) -> list[dict]: "section": it.section, # U7. Derived in the resolver (INV-1); this only carries it. "group": it.group, + # R2 C1. Same rule: the resolver numbers, this carries. + "ordinal": it.ordinal, "caption": it.caption, "rendered": rendered, "rendered_html": rendered_html, @@ -1485,6 +1523,10 @@ def create_app( # of a thing that is not an item is not a view of the booth. if item is not None: record_view(booth) + # R2 C2: WHICH item was looked at — media only, the ring the tape + # draws. Same gate as the view above, and it never raises either. + if item.kind in REVIEW_KINDS: + record_seen(booth, item.rel, items) marks = marks_for(booth) item_marks = marks_for_target(marks, f) common = { @@ -1505,8 +1547,10 @@ def create_app( } if item is not None and item.kind == "image": - # prev/next ring (wraps; only when there is more than one image) - names = image_chain(items) + # prev/next ring (wraps; only when there is more than one item in + # it). R2 C2: the ring is `review_chain` — the item order filtered to + # MEDIA — so a set that mixes pictures and sound steps through both. + names = review_chain(items) prev_url = next_url = None if f in names and len(names) > 1: i = names.index(f) diff --git a/booth/items.py b/booth/items.py index 4f33621..8b93a10 100644 --- a/booth/items.py +++ b/booth/items.py @@ -95,6 +95,27 @@ class Item: blurred: bool doc: str | None size: int + # R2 C1: the 1-based position in `booth_items` order over ALL items — the + # number the operator means by "the third one". Set in the resolver loop + # and nowhere else (INV-1). APPENDED, never inserted: a mid-dataclass field + # is a positional-construction break. + ordinal: int + + +# R2 C2: which items have been looked at full size. UI state, not judgment — +# never exposed to sessions, holds nothing. One viewer: this records WHAT was +# seen, never who saw it. +SEEN_FILE = ".seen" + + +def read_seen(booth: Path) -> set[str]: + """Rels seen at full size. Missing or unreadable file -> empty set; a + damaged marker costs the tape its memory, never the page.""" + try: + text = (booth / SEEN_FILE).read_text() + except (OSError, UnicodeDecodeError): + return set() + return {ln.strip() for ln in text.splitlines() if ln.strip()} def read_blurred(booth: Path) -> set[str]: @@ -273,6 +294,10 @@ def booth_items(booth: Path) -> list[Item]: blurred=rel in blurred, doc=doc_kind(p.name), size=size, + # Counted over items that RENDER: a caption sidecar or a name + # the quote() guard skipped takes no number, so the numbers + # stay contiguous over what the operator can see. + ordinal=len(items) + 1, ) ) return items @@ -287,6 +312,18 @@ def image_chain(items: Sequence[Item]) -> list[str]: return [it.rel for it in items if it.kind == "image"] +# R2 C2: what the review route steps through. ONE LINE: the item order +# filtered to media. It is a declared change to the zoom-ring rule, which was +# images only: a listening set is reviewed the same way a picture set is. +REVIEW_KINDS = ("image", "video", "audio") + + +def review_chain(items: Sequence[Item]) -> list[str]: + """The rels of the media items, in item order — the review's prev/next ring, + its filmstrip and its tape.""" + return [it.rel for it in items if it.kind in REVIEW_KINDS] + + def find_item(items: Sequence[Item], rel: str) -> Item | None: """The record for one rel, or None — the zoom/doc route's entry point.""" for it in items: diff --git a/booth/templates/booth.html b/booth/templates/booth.html index 58a5e65..c4e57cd 100644 --- a/booth/templates/booth.html +++ b/booth/templates/booth.html @@ -53,6 +53,13 @@ {%- endmacro %} +{# R2 C1: an item's number in the WHOLE set, zero-padded to the set's width so + a column of them lines up. Width reads `all_items`, never the filtered list: + a filter must not change how a number is written any more than which. #} +{% macro ordinal(it) -%} + #{{ "%0*d"|format((all_items|length|string|length), it.ordinal) }} +{%- endmacro %} + {% block title %}{{ name }} · The Booth{% endblock %} {% block content %}
@@ -310,6 +317,7 @@
+ {{ ordinal(it) }} {{ it.name }} ⤢ @@ -333,6 +341,7 @@ {% else %}
+ {{ ordinal(it) }} {% if it.blurred %} {# Click-to-reveal is per-viewer and client-side: nothing is persisted, so a reload re-hides it. No-JS degrades to STAYS BLURRED, which is the diff --git a/tests/test_flow.py b/tests/test_flow.py new file mode 100644 index 0000000..ce616eb --- /dev/null +++ b/tests/test_flow.py @@ -0,0 +1,153 @@ +"""R2 — the review flow: the Desk, the lightbox, the review. + +Contract: docs/contracts/r2_flow.contract.md. Tests are grouped by the +contract's components (C1-C7) and named for the behaviour they pin. +""" + +from __future__ import annotations + +import pathlib +import re +import sys + +import pytest +from fastapi.testclient import TestClient + +sys.path.insert(0, str(pathlib.Path(__file__).parent.parent)) + +from booth.app import create_app # noqa: E402 +from booth.items import booth_items # noqa: E402 +from booth.marks import set_flag # noqa: E402 + +PNG = b"\x89PNG\r\n\x1a\n" + + +def _booth(root: pathlib.Path, name: str, files: dict[str, bytes]) -> pathlib.Path: + b = root / name + b.mkdir() + for rel, data in files.items(): + p = b / rel + p.parent.mkdir(parents=True, exist_ok=True) + p.write_bytes(data) + return b + + +def _client(root: pathlib.Path) -> TestClient: + return TestClient(create_app(root, ttl_hours=24, start_sweeper=False)) + + +def _ordinals(body: str) -> dict[str, str]: + """rel -> the ordinal text its tile prints, in render order.""" + out = {} + for fig in re.findall(r'
]*>.*?
', body, re.S): + rel = re.search(r'data-item="([^"]+)"', fig).group(1) + m = re.search(r'class="ord"[^>]*>#(\d+)<', fig) + out[rel] = m.group(1) if m else None + return out + + +# ---- C1: Item.ordinal ------------------------------------------------------ + +def test_a_filtered_tile_keeps_its_number_in_the_whole_set(tmp_path): + """The tracer. b.png is the second item of three; under ?filter=flagged it + is the ONLY tile rendered and must still print #2, because the number is a + property of the item, not of the view.""" + b = _booth(tmp_path, "g", {"a.png": PNG, "b.png": PNG, "c.png": PNG}) + set_flag(b, "b.png", True) + body = _client(tmp_path).get("/b/g/?filter=flagged").text + assert _ordinals(body) == {"b.png": "2"} + + +def test_ordinals_count_rendered_items_only(tmp_path): + """A caption sidecar is not an item and takes no number, so the numbers + stay contiguous over what the operator can see. And an undecodable name + that the quote() guard skips takes none either — it is not rendered.""" + b = _booth(tmp_path, "g", {"a.png": PNG, "a.png.txt": b"cap", "b.png": PNG, + "c.png": PNG}) + import os + os.close(os.open(bytes(b) + b"/m\xff.png", os.O_CREAT | os.O_WRONLY, 0o644)) + items = booth_items(b) + assert [(it.rel, it.ordinal) for it in items] == [("a.png", 1), ("b.png", 2), ("c.png", 3)] + + +def test_ordinals_pad_to_the_width_of_the_whole_set(tmp_path): + """Twelve items: numbers are written two wide, so a column of them lines + up — and a filter showing only the first does not narrow it to #1.""" + b = _booth(tmp_path, "g", {f"{n:02d}.png": PNG for n in range(1, 13)}) + set_flag(b, "01.png", True) + c = _client(tmp_path) + assert _ordinals(c.get("/b/g/").text)["01.png"] == "01" + assert _ordinals(c.get("/b/g/").text)["12.png"] == "12" + assert _ordinals(c.get("/b/g/?filter=flagged").text) == {"01.png": "01"} + + +# ---- C2: review_chain and .seen ------------------------------------------- + +def test_the_review_ring_is_the_item_order_filtered_to_media(tmp_path): + """Images, video and audio, in set order. A doc is not in the ring: it + keeps its reading page.""" + from booth.items import review_chain + b = _booth(tmp_path, "g", {"a.png": PNG, "b.mp3": b"ID3", "c.md": b"# c", + "d.webm": b"\x1aE", "e.zip": b"PK"}) + assert review_chain(booth_items(b)) == ["a.png", "b.mp3", "d.webm"] + + +def test_the_review_route_rings_through_audio_in_set_order(tmp_path): + """From the only image, "next" is the audio track that follows it in the + set — today's image-only ring had nowhere to go.""" + _booth(tmp_path, "g", {"a.png": PNG, "b.mp3": b"ID3", "c.md": b"# c"}) + body = _client(tmp_path).get("/b/g/view?f=a.png").text + assert 'class="vnav vnext" href="?f=b.mp3"' in body + assert 'class="vnav vprev" href="?f=b.mp3"' in body # a two-item ring wraps + + +def test_a_full_size_look_is_recorded_as_seen_and_a_non_item_is_not(tmp_path): + """`.seen` answers WHICH items were looked at full size. Gated on the item + record like `record_view`: pointing `f` at a dotfile the service itself + wrote is not a look at anything.""" + from booth.items import read_seen + b = _booth(tmp_path, "g", {"a.png": PNG, "b.png": PNG, "c.png": PNG}) + (b / ".marks.lock").write_bytes(b"") + c = _client(tmp_path) + for f in ("a.png", "c.png", "a.png", ".marks.lock"): + c.get(f"/b/g/view?f={f}") + assert read_seen(b) == {"a.png", "c.png"} + assert (b / ".seen").read_text() == "a.png\nc.png\n" # sorted, deduplicated + + +def test_seen_is_pruned_to_live_items_at_the_next_write(tmp_path): + """A deleted file drops out of `.seen` the next time anything is seen, so + the marker never outgrows the booth and never counts a ghost.""" + from booth.items import read_seen + b = _booth(tmp_path, "g", {"a.png": PNG, "b.png": PNG}) + c = _client(tmp_path) + c.get("/b/g/view?f=a.png") + (b / "a.png").unlink() + c.get("/b/g/view?f=b.png") + assert read_seen(b) == {"b.png"} + + +def test_a_planted_seen_symlink_is_replaced_not_written_through(tmp_path): + """Any fleet session can write into a booth. A `.seen` symlink aimed at a + file outside must not turn a page view into a write at that path.""" + outside = tmp_path / "victim.txt" + outside.write_text("untouched") + b = _booth(tmp_path, "g", {"a.png": PNG}) + (b / ".seen").symlink_to(outside) + r = _client(tmp_path).get("/b/g/view?f=a.png") + assert r.status_code == 200 + assert outside.read_text() == "untouched" + assert not (b / ".seen").is_symlink() + + +def test_a_look_that_cannot_be_recorded_still_serves_the_page(tmp_path): + """NEVER RAISES: a booth the service cannot write to costs the marker, not + the page.""" + b = _booth(tmp_path, "g", {"a.png": PNG}) + b.chmod(0o555) + try: + r = _client(tmp_path).get("/b/g/view?f=a.png") + finally: + b.chmod(0o755) + assert r.status_code == 200 + assert not (b / ".seen").exists()