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
261 lines
8.0 KiB
Python
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
|