Files
booth/tests/test_items.py
T
vh 397ea89795 fix(u7): six defects from the heid bug-hunt panel, and five vacuous falsifiers
Cross-frontier panel (Gróa/Hulda/Regin/Kimi) on U7's diff, thread
01M368G2Y0JMTJ2T7M3JMTXV5Z. Four of the six fixes are for defects no test in
this repo could have caught, and the panel's guard-strength passes found five of
my own falsifiers green under the exact change they forbade.

THE 4-OF-4 FINDING — the group anchor could land on the WRONG artifact.
The anchor was the raw rel spliced into an href fragment while the tile id was
equally raw. A browser matches a fragment against ids RAW FIRST and only then
percent-decoded, so raw-on-both-sides is not merely unencoded, it is AMBIGUOUS:
with `a b.png` and `a%20b.png` in one booth, the first's href resolves to the
fragment `item-a%20b.png` and the raw pass matches the SECOND file's id. That is
the misfiled-judgment failure invariant 6 exists to prevent, arriving through a
path invariant 6 never looked at. Both sides now use `Item.url`
(`quote(rel, safe="/")`), which is injective here and is the convention
booth_flag has always used. The original test asserted the href occurred as SOME
id on the page — true while pointing at the wrong one.

GRÓA'S STRONGEST SOLO — a zero-hit filter removed the way back.
The rail was gated on the FILTERED list, so a valid filter with no matches
removed the rail, the filter links and the route back to `all`, while the
empty-booth branch announced the booth was empty with rail.total still holding
the real count. No recovery without editing the address bar, and it degraded the
same way with JavaScript off, on the surface the operator actually reviews on.
Gated on all_items now, with an explicit no-match row.

HULDA — one unrepresentable filename took out the INDEX, not just its booth.
A non-UTF-8 filename reaches CPython as a surrogate and quote() raises on it,
outside any per-item handler. booth_items feeds list_booths, so one 0xff byte in
one booth's filename 500s every booth's card. Such a file cannot be linked,
served or zipped, so it is skipped like a dotfile.

HULDA — the `f` shortcut has never worked. The selector named `.flagbtn`, which
nothing in this repo emits, so it fell through to the hidden target input;
clicking a hidden input does not submit its form, and the handler called
preventDefault anyway. Now clicks the flag form's real button, verified end to
end in a real browser.

GRÓA — a group jump was undone by the next keypress. The jump scrolls, the
cursor stayed at -1, and the next arrow focused tile 0 and scrolled back. The
cursor now picks up from the viewport, which also fixes the general
scroll-then-arrow case. Asserted on real scroll geometry in Chromium.

HULDA — the caption sidecar was read whole before being truncated, so a
pathological file was a MemoryError the OSError handler does not catch. Bounded
at the read, and deliberately NOT by st_size: a FIFO reports 0.

ACCEPTED KNOWN RISKS, both now documented rather than implied: no cap on rail
row count (1,000 groups of two would render 1,000 rows; the largest live booth
is 66 items and picking a cap without a booth that needs one is invented work),
and Item.group sits mid-dataclass (one construction site, keyword-only, grepped).
The docstring now names the UPPER median explicitly — two arms flagged that
"the middle group" admits both readings for an even count.

FIVE VACUOUS FALSIFIERS, found by the arms and not by me: the anchor test
survived v[0]->v[-1]; the informativeness guard survived sizes[-1]; the group
count survived len(v)+1; the zero-hit filter test used a fixture that HAD hits;
and the escaping test asserted over the whole page, so it went red on a code
comment. All rewritten, all mutation-proved. The table is up to 20 rows and one
drifted when I changed the line under it — reported by the harness, not silently
skipped, which is the behaviour tests/test_mutation_check.py exists to hold.

660 green; 20/20 proved. Deployed; 21/21 booths 200.

Held for design-dev, not fixed here: Gróa's finding that the sticky rail has no
scroll-margin, so a fragment jump tucks the target under it. It is one line in
base.html, the file he is rewriting from scratch.
2026-09-23 00:04:59 -07:00

359 lines
12 KiB
Python

"""U1 — the item record.
The headline here is `zoom_carries_the_caption`. The operator reported that
zoomed-in images lose their annotations; the cause was not a rendering bug but
three independent readers of one truth, of which the zoom route was the one that
never resolved a caption at all. These tests pin the record and pin the bug.
See docs/contracts/u1_item_record.contract.md.
"""
import pytest
from fastapi.testclient import TestClient
from booth.app import build_gallery, create_app, list_booths
from booth.items import (
CAPTION_MAX,
Item,
booth_items,
find_item,
image_chain,
render_doc_body,
)
def _touch(path, data=b"x"):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
@pytest.fixture
def client(tmp_path):
app = create_app(tmp_path, ttl_hours=24, start_sweeper=False)
return TestClient(app), tmp_path
# ---- the record -------------------------------------------------------------
def test_section_is_the_parent_dir(tmp_path):
_touch(tmp_path / "root.png")
_touch(tmp_path / "v3" / "x.png")
_touch(tmp_path / "v3" / "deep" / "y.png")
by_rel = {it.rel: it for it in booth_items(tmp_path)}
assert by_rel["root.png"].section is None
assert by_rel["v3/x.png"].section == "v3"
assert by_rel["v3/deep/y.png"].section == "v3/deep"
def test_caption_sidecars_are_not_items(tmp_path):
_touch(tmp_path / "a.png")
(tmp_path / "a.txt").write_text("variant A")
_touch(tmp_path / "b.png")
(tmp_path / "b.png.txt").write_text("variant B")
(tmp_path / "loose.txt").write_text("captions nothing")
by_rel = {it.rel: it for it in booth_items(tmp_path)}
assert by_rel["a.png"].caption == "variant A"
assert by_rel["b.png"].caption == "variant B"
assert "a.txt" not in by_rel and "b.png.txt" not in by_rel
assert "loose.txt" in by_rel # a caption with nothing to caption stays visible
def test_a_txt_beside_a_bin_is_its_own_item(tmp_path):
"""The `classify != "other"` guard: a .txt only captions a MEDIA sibling."""
_touch(tmp_path / "data.bin")
(tmp_path / "data.txt").write_text("not a caption for a blob")
by_rel = {it.rel: it for it in booth_items(tmp_path)}
assert "data.txt" in by_rel
assert by_rel["data.bin"].caption is None
def test_ask_sidecars_are_not_items(tmp_path):
_touch(tmp_path / "a.png")
(tmp_path / "pick.ask.json").write_text("{}")
(tmp_path / "pick.answer.json").write_text("{}")
rels = {it.rel for it in booth_items(tmp_path)}
assert rels == {"a.png"}
def test_dotfiles_are_not_items(tmp_path):
_touch(tmp_path / "a.png")
_touch(tmp_path / ".forever")
_touch(tmp_path / ".blurred")
assert {it.rel for it in booth_items(tmp_path)} == {"a.png"}
def test_blur_state_rides_on_the_item(tmp_path):
_touch(tmp_path / "a.png")
_touch(tmp_path / "b.png")
(tmp_path / ".blurred").write_text("a.png\n")
by_rel = {it.rel: it for it in booth_items(tmp_path)}
assert by_rel["a.png"].blurred is True
assert by_rel["b.png"].blurred is False
def test_order_matches_todays_gallery(tmp_path):
"""INV-3: this unit reorganises who computes what. It must not move a tile."""
_touch(tmp_path / "z.png")
_touch(tmp_path / "a.png")
(tmp_path / "a.txt").write_text("cap")
_touch(tmp_path / "v3" / "b.png")
_touch(tmp_path / "v4" / "b.png")
(tmp_path / "notes.md").write_text("# hi")
_touch(tmp_path / "blob.bin")
assert [it.rel for it in booth_items(tmp_path)] == [
it["name"] for it in build_gallery(tmp_path)
]
# ---- helpers ----------------------------------------------------------------
def test_image_chain_is_the_images_in_order(tmp_path):
_touch(tmp_path / "b.png")
_touch(tmp_path / "a.png")
_touch(tmp_path / "clip.webm")
(tmp_path / "notes.md").write_text("# hi")
assert image_chain(booth_items(tmp_path)) == ["a.png", "b.png"]
def test_find_item(tmp_path):
_touch(tmp_path / "a.png")
items = booth_items(tmp_path)
assert find_item(items, "a.png").rel == "a.png"
assert find_item(items, "nope.png") is None
def test_render_doc_body_markdown_and_text(tmp_path):
(tmp_path / "r.md").write_text("# Title\n\n- a\n- b\n")
(tmp_path / "n.txt").write_text("plain\ntext")
items = {it.rel: it for it in booth_items(tmp_path)}
html, is_html = render_doc_body(tmp_path, items["r.md"])
assert is_html and "<h1>" in html
body, is_html = render_doc_body(tmp_path, items["n.txt"])
assert not is_html and body == "plain\ntext"
def test_render_doc_body_is_none_for_a_huge_doc(tmp_path):
from booth.items import DOC_MAX_BYTES
(tmp_path / "huge.log").write_text("x" * (DOC_MAX_BYTES + 1))
items = {it.rel: it for it in booth_items(tmp_path)}
assert render_doc_body(tmp_path, items["huge.log"]) is None
def test_render_doc_body_is_none_for_a_non_doc(tmp_path):
_touch(tmp_path / "a.png")
items = {it.rel: it for it in booth_items(tmp_path)}
assert render_doc_body(tmp_path, items["a.png"]) is None
# ---- the bug this unit exists to close --------------------------------------
def test_zoom_carries_the_caption(client):
"""THE operator-reported defect, as a regression test.
`a.png.txt` captions `a.png` in the gallery. Before U1 the zoom route
re-derived the item from scratch and never resolved a caption, so the
annotation vanished at exactly the size where it is most readable.
"""
c, data = client
b = data / "bo"
_touch(b / "a.png")
(b / "a.png.txt").write_text("the annotation that used to vanish")
r = c.get("/b/bo/view", params={"f": "a.png"})
assert r.status_code == 200
assert "the annotation that used to vanish" in r.text
def test_zoom_carries_the_stem_caption(client):
"""The other caption form -- `a.txt` beside `a.png`."""
c, data = client
b = data / "bo"
_touch(b / "a.png")
(b / "a.txt").write_text("stem-form annotation")
r = c.get("/b/bo/view", params={"f": "a.png"})
assert "stem-form annotation" in r.text
def test_doc_view_carries_the_caption(client):
c, data = client
b = data / "bo"
b.mkdir()
(b / "notes.md").write_text("# body")
(b / "notes.md.txt").write_text("what this doc is")
r = c.get("/b/bo/view", params={"f": "notes.md"})
assert r.status_code == 200
assert "what this doc is" in r.text
def test_zoom_prev_next_still_works(client):
"""image_chain replaces booth_image_names; the ring must be unchanged."""
c, data = client
b = data / "bo"
_touch(b / "a.png")
_touch(b / "b.png")
r = c.get("/b/bo/view", params={"f": "a.png"})
assert "b.png" in r.text # prev and next both wrap to the only other image
# ---- invariants -------------------------------------------------------------
def test_index_renders_no_doc_bodies(client, monkeypatch):
"""INV-4: the index calls the resolver once per booth. If it also rendered
every doc, a page load would markdown-render every doc in every booth."""
c, data = client
b = data / "bo"
b.mkdir()
(b / "big.md").write_text("# hi")
_touch(b / "a.png")
import booth.items as items_mod
def boom(*a, **k):
raise AssertionError("the index must not render doc bodies")
monkeypatch.setattr(items_mod, "render_doc_body", boom)
assert c.get("/").status_code == 200
def test_app_still_exports_the_moved_names():
"""INV-5: 22 existing test sites import these from booth.app by name."""
import booth.app as app_mod
for name in (
"classify",
"doc_kind",
"render_doc",
"CAPTION_MAX",
"DOC_MAX_BYTES",
"IMAGE_EXTS",
"VIDEO_EXTS",
"AUDIO_EXTS",
"MARKDOWN_EXTS",
"TEXT_EXTS",
):
assert hasattr(app_mod, name), f"booth.app must still export {name}"
def test_list_booths_counts_match_the_resolver(tmp_path):
b = tmp_path / "bo"
_touch(b / "a.png")
_touch(b / "clip.webm")
(b / "a.txt").write_text("cap") # captions a.png # a sidecar is not an item
got = list_booths(tmp_path, ttl_seconds=86400)[0]
assert got["count"] == len(booth_items(b)) == 2
# --- U7: the group, derived here and nowhere else -------------------------
#
# ⚠ THE RULE IS NOT THE ONE THE CONTRACT FIRST STATED, and the change is
# measured rather than preferred. The contract's `strip ONE trailing run of
# digits` yields 24 groups for sindra-bakeoff's 40 images and 27 for sindra's
# 30 — a rail with one row per tile, which is a second copy of the grid rather
# than a way through it. Measured against all 17 live booths on 2026-09-22;
# the numbers are in the contract's rewritten table.
def test_group_of_takes_the_first_segment(tmp_path):
from booth.items import _group_of
assert _group_of("00-sheet-c1-market-noon.png") == "00"
assert _group_of("m-c1-market-noon-9401.png") == "m"
assert _group_of("flag-rear.png") == "flag"
assert _group_of("v30-seed8302-HELD.png") == "v30"
def test_group_of_destems_only_a_flat_name(tmp_path):
"""`ac01.png` has no separator, so the digits ARE the separator and the
group is `ac`. `v30-seed8302` HAS one, so `v30` survives intact — stripping
there would merge v30 with v35, which is the axis that booth is about."""
from booth.items import _group_of
assert _group_of("ac01.png") == "ac"
assert _group_of("DSC0001.jpg") == "DSC"
assert _group_of("v30-seed8302.png") == "v30"
assert _group_of("v35-seed8302.png") == "v35"
def test_group_of_is_none_when_there_is_no_prefix(tmp_path):
"""A stem that is entirely digits has nothing to group on. Inventing one
would file every numbered render under the empty string."""
from booth.items import _group_of
assert _group_of("01.png") is None
assert _group_of("0042.jpg") is None
assert _group_of("-leading.png") is None
def test_group_is_derived_from_the_basename_not_the_path(tmp_path):
"""A booth WITH subdirectories still groups on the filename. Sections and
groups are different questions; `Item.section` still carries the path."""
from booth.items import _group_of
assert _group_of("sub/dir/ac01.png") == "ac"
def test_booth_items_carries_the_group(tmp_path):
b = tmp_path / "g"
_touch(b / "ac01.png")
_touch(b / "ac02.png")
_touch(b / "99.png")
got = {it.rel: it.group for it in booth_items(b)}
assert got == {"ac01.png": "ac", "ac02.png": "ac", "99.png": None}
def test_one_unrepresentable_filename_costs_its_own_tile_not_the_booth(tmp_path):
"""HULDA, and it is worse than the bundle could see: `quote()` raises
UnicodeEncodeError on a surrogate from a non-UTF-8 filename, and
`booth_items` feeds `list_booths` — so ONE 0xff byte in ONE booth's
filename took out the INDEX for every booth, not just its own page.
The repo's standing posture is that a damaged file costs its own tile and
never the page. A file whose name cannot be percent-encoded cannot be
linked or served either, so it cannot be an item.
Defeating change: dropping the guard — this raises before it renders."""
import os
b = tmp_path / "b"
b.mkdir()
(b / "ok.png").write_bytes(b"\x89PNG")
(b / os.fsdecode(b"bad\xff.png")).write_bytes(b"\x89PNG")
got = booth_items(b)
assert [it.rel for it in got] == ["ok.png"]
def test_a_huge_caption_sidecar_is_not_read_whole(tmp_path):
"""HULDA: `read_text()` pulled the entire sidecar into memory before
`[:CAPTION_MAX]` trimmed it, and the handler catches only OSError — so a
pathological sidecar is a MemoryError, not a missing caption.
Bounded at the READ. Deliberately NOT bounded by st_size: a FIFO reports
st_size 0 and a bound that trusts it inherits what it does not mean —
persistent-memory.d/2026-09-22-size-cap-opened-a-hang.md."""
b = tmp_path / "b"
b.mkdir()
(b / "a.png").write_bytes(b"\x89PNG")
(b / "a.txt").write_text("x" * (CAPTION_MAX * 50))
cap = {it.rel: it.caption for it in booth_items(b)}["a.png"]
assert cap is not None and len(cap) <= CAPTION_MAX