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.
359 lines
12 KiB
Python
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
|