- 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.
154 lines
6.1 KiB
Python
154 lines
6.1 KiB
Python
"""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()
|