feat(r2): C1 ordinals and C2 the review ring and .seen

- Item.ordinal: the 1-based position in booth_items over the items that
  render. It is appended, and set in the resolver. Tiles print it padded to
  the whole set's width, and a filter never renumbers.
- review_chain: the item order filtered to media. It replaces image_chain as
  the zoom route's ring, so a set of pictures and sound steps through both.
  image_chain stays importable.
- .seen: which media items were looked at full size, written by the review
  route under record_view's gate. It is rewritten whole: deduplicated, pruned
  to live items, sorted. The temp file is created with O_EXCL and swapped in
  with os.replace, so a planted symlink is replaced, never written through.
  It never raises.

Nine new tests. The contiguity and symlink tests are mutation-checked.
669 passed.
This commit is contained in:
vh
2026-09-23 08:32:38 -07:00
parent 7a4d3fcbf8
commit 277554a3f7
4 changed files with 245 additions and 2 deletions
+46 -2
View File
@@ -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)
+37
View File
@@ -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:
+9
View File
@@ -53,6 +53,13 @@
</details>
{%- 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) -%}
<span class="ord" data-ordinal="{{ it.ordinal }}">#{{ "%0*d"|format((all_items|length|string|length), it.ordinal) }}</span>
{%- endmacro %}
{% block title %}{{ name }} · The Booth{% endblock %}
{% block content %}
<div class="boothhead">
@@ -310,6 +317,7 @@
<details class="doc-inline" open>
<summary class="doc-bar">
<span class="doc-chevron" aria-hidden="true">▸</span>
{{ ordinal(it) }}
<span class="doc-name">{{ it.name }}</span>
<span class="doc-spacer"></span>
<a class="doc-act" href="view?f={{ it.url }}" title="open full page">⤢</a>
@@ -333,6 +341,7 @@
</figure>
{% else %}
<figure class="item item-{{ it.kind }}{% if it.blurred %} blurred{% endif %}{% if item_marks.get(it.name, []) | selectattr('shape', 'equalto', 'flag') | list %} is-flagged{% endif %}" data-item="{{ it.name }}" id="item-{{ it.url }}">
{{ 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
+153
View File
@@ -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'<figure class="item[^"]*"[^>]*>.*?</figure>', 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()