Files
booth/tests/test_items.py
T
vh ce598b3cf6 feat(items): one item record, so an annotation survives the zoom
The operator reported that zoomed-in images lose their annotations. That was
never a rendering bug. Three functions independently walked a booth and derived
overlapping subsets of the same facts -- build_gallery (kind, caption, blur,
doc), booth_view_file (kind, doc, image ring) and list_booths (kind counts,
cover) -- and the zoom route's subset was the smallest. Caption resolution lived
inside build_gallery's loop and nowhere else, so there was no code path by which
a caption could reach the zoom template. It was never sent.

booth/items.py is now the one truth: booth_items() returns the full record --
rel, kind, section, caption, blur, doc kind, size -- and the gallery, the zoom
view, the doc view and the index all read it. Patching view.html would have
fixed the symptom for images and left the next surface starting from the same
missing truth.

Two things fall out of the consolidation:
  - the index and the booth page now agree on what an item IS. list_booths
    counted every non-dot file, so an A/B pair with two caption sidecars read
    as 4 items on the index and showed 2 tiles when you opened it.
  - "section" (the item's subfolder) is computed and carried but nothing renders
    it yet. That is deliberate: it is U7's whole input, and shipping the field
    now makes U7 a template change rather than a resolver change.

Doc bodies are NOT rendered by the resolver -- the index touches every booth on
every page load, and rendering every markdown file in every booth would be the
price of that convenience. render_doc_body is a separate step for the one
surface that inlines them; an invariant test monkeypatches it to raise and
loads the index.

Verified beyond the suite, because this repo has shipped two dead controls that
every test passed: the caption was measured in a real browser at 1280x41 px,
visible, with elementFromPoint at its centre returning the caption itself.
layout-probe reports all controls hittable across index, gallery, zoom and doc.

192 tests pass (173 before, 19 new).

Contract: docs/contracts/u1_item_record.contract.md
2026-09-21 22:04:07 -07:00

261 lines
8.0 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 (
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