"""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 "

" 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_folder_that_lists_but_cannot_be_searched_costs_its_files_not_the_index(tmp_path): """Found folding R2's bug-hunt: `Path.is_file()` swallows a missing entry but PROPAGATES EACCES. A directory with read and no execute permission lists its names, and every stat under it raises — so one such folder in one booth took out the index for every booth, the same blast radius as the unrepresentable filename above. Its files are not items. Defeating change: calling `is_file()` outside the OSError guard.""" b = tmp_path / "b" b.mkdir() (b / "ok.png").write_bytes(b"\x89PNG") sub = b / "d" sub.mkdir() (sub / "x.png").write_bytes(b"\x89PNG") sub.chmod(0o644) # r--: listable, nothing inside stat-able try: with pytest.raises(PermissionError): (sub / "x.png").stat() # the fixture is live, not assumed assert [it.rel for it in booth_items(b)] == ["ok.png"] [row] = list_booths(tmp_path, ttl_seconds=86400) assert row["name"] == "b" and row["count"] == 1 finally: sub.chmod(0o755) 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