Files
booth/tests/test_thumbs.py
T
vh 18d599dd2a fix(thumbs): the cache aged the booth it cached, and two more surfaces
Two corrections to the thumbnail work, the first of them a live bug shipped an
hour ago and caught by design-dev before its worst form landed.

⚠ GENERATING A THUMBNAIL RESET THE BOOTH'S EXPIRY CLOCK. `_newest_mtime`
excludes `.lock` sidecars because machinery is not the operator doing something;
the thumbnail cache is machinery too, and it is written by the SERVER on a mere
view. Excluding the cache's CONTENTS turned out not to be enough — creating
`.thumbs/` touches the BOOTH DIRECTORY's own mtime, which is exactly what
_newest_mtime seeds from. The booth's stamp is now restored across the mkdir,
which cannot hide real activity because any file an agent adds is counted by its
own mtime in the same walk.

The failure this prevents is not small. Once the Desk's preview strip pulls a
thumbnail per booth, ONE INDEX LOAD would have pushed every booth's expiry out
and the TTL would never have fired again — nothing would ever sweep. It was
already live for the gallery, one booth at a time.

TWO MORE SURFACES, because the fix only helped where it was wired:

  Desk preview strip  four small images per booth on the page he opens FIRST.
                      design-dev measured 28 originals / 24.1 MB on a 12-booth
                      copy; live has 28. The heaviest surface in the service,
                      heavier than the gallery it previews.
  flag tray           _marks.html rendered originals as tray thumbnails.

The review stage stays on the original, because that is the full-size review.

754 green plus the new guards.
2026-09-23 10:51:40 -07:00

180 lines
7.1 KiB
Python

"""Thumbnails — the fix for a gallery that shipped 77 MB to render 250px tiles.
The operator found this in about a minute of using the live Desk. ROADMAP had
parked it on "the largest gallery is 66 images", which counted IMAGES and never
weighed BYTES; 66 is a fine count sitting on a terrible payload.
"""
from __future__ import annotations
import io
import pathlib
import sys
import pytest
from fastapi.testclient import TestClient
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
from booth.app import create_app # noqa: E402
from booth.items import booth_items # noqa: E402
from booth.thumbs import THUMB_DIR, THUMB_MAX, ensure_thumb, wants_thumb # noqa: E402
PIL = pytest.importorskip("PIL.Image", reason="Pillow is not installed")
def _img(path: pathlib.Path, w: int, h: int, fmt="PNG"):
path.parent.mkdir(parents=True, exist_ok=True)
PIL.new("RGB", (w, h), (120, 30, 90)).save(path, fmt)
return path
def test_a_big_image_gets_a_much_smaller_thumbnail(tmp_path):
"""The whole point, asserted in BYTES rather than in existence — a thumbnail
that is not dramatically smaller has not fixed anything."""
b = tmp_path / "g"
src = _img(b / "big.png", 1024, 1024)
out = ensure_thumb(b, "big.png")
assert out is not None and out.is_file()
assert max(PIL.open(out).size) <= THUMB_MAX
assert out.stat().st_size * 4 < src.stat().st_size, (
f"thumb {out.stat().st_size}B vs source {src.stat().st_size}B — not worth the cache"
)
def test_an_already_small_image_gets_no_thumbnail(tmp_path):
"""Serving the original is correct when it is already tile-sized. A cache
entry that saves nothing is pure cost.
Defeating change: generating unconditionally."""
b = tmp_path / "g"
_img(b / "small.png", 200, 200)
assert ensure_thumb(b, "small.png") is None
def test_the_cache_lives_inside_the_booth_and_is_invisible(tmp_path):
"""`.thumbs/` is inside the booth so it is swept with it — a cache that
outlives what it describes is a leak. And it must not become gallery items
or zip entries: both skip every dot-prefixed path COMPONENT, which they did
not do until this module needed them to."""
import zipfile
from booth.app import zip_booth
b = tmp_path / "g"
_img(b / "big.png", 1024, 1024)
ensure_thumb(b, "big.png")
assert (b / THUMB_DIR).is_dir(), "the cache is not inside the booth"
assert [i.rel for i in booth_items(b)] == ["big.png"]
assert zipfile.ZipFile(io.BytesIO(zip_booth(b))).namelist() == ["big.png"]
def test_a_damaged_image_costs_its_own_tile_not_the_page(tmp_path):
"""NEVER RAISES. A thumbnail is an optimisation; a page that will not load
is worse than one that loads slowly.
Defeating change: letting the Pillow exception out."""
b = tmp_path / "g"
b.mkdir()
(b / "lies.png").write_bytes(b"\x89PNG\r\n\x1a\n" + b"not an image at all" * 20)
assert ensure_thumb(b, "lies.png") is None
def test_a_stale_thumbnail_is_rebuilt(tmp_path):
"""Editing a file in place must not leave the old thumbnail forever."""
import os
b = tmp_path / "g"
_img(b / "x.png", 1024, 1024)
first = ensure_thumb(b, "x.png")
before = first.stat().st_mtime_ns
_img(b / "x.png", 900, 900)
os.utime(b / "x.png", None)
again = ensure_thumb(b, "x.png")
assert again.stat().st_mtime_ns != before, "the stale thumbnail survived an edit"
def test_only_thumbable_types_are_candidates():
"""SVG is vector and Pillow cannot read it; a video is not an image."""
assert wants_thumb("a.png") and wants_thumb("A.JPG") and wants_thumb("a.webp")
assert not wants_thumb("a.svg") and not wants_thumb("a.webm") and not wants_thumb("a.txt")
def test_the_item_record_carries_the_thumb_url(tmp_path):
"""INV-1: the resolver decides whether an item has a thumbnail. No template
appends `?thumb=1` by reasoning about `kind` itself."""
b = tmp_path / "g"
_img(b / "big.png", 1024, 1024)
_img(b / "vec.svg", 10, 10) if False else (b / "vec.svg").write_text("<svg/>")
by = {i.rel: i for i in booth_items(b)}
assert by["big.png"].thumb == "big.png?thumb=1"
assert by["vec.svg"].thumb is None
def test_the_route_serves_the_thumbnail_and_the_original(tmp_path):
"""?thumb=1 rides the EXISTING file route, so it inherits that route's
traversal guard rather than growing a second one."""
b = tmp_path / "g"
src = _img(b / "big.png", 1024, 1024)
c = TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False))
full = c.get("/b/g/big.png")
thumb = c.get("/b/g/big.png?thumb=1")
assert full.status_code == thumb.status_code == 200
assert len(thumb.content) * 4 < len(full.content), "the route served the full image"
assert len(full.content) == src.stat().st_size
def test_the_gallery_tile_requests_the_thumbnail(tmp_path):
"""The operator's actual complaint: the grid pulled full-resolution files."""
b = tmp_path / "g"
for n in ("a.png", "b.png"):
_img(b / n, 1024, 1024)
c = TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False))
html = c.get("/b/g/").text
assert 'src="a.png?thumb=1"' in html, "the tile still asks for the full image"
def test_a_thumb_request_for_a_traversal_path_is_still_refused(tmp_path):
"""The guard is the file route's, and it must not be weakened by the new
query parameter."""
b = tmp_path / "g"
_img(b / "big.png", 1024, 1024)
(tmp_path / "secret.txt").write_text("nope")
c = TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False))
assert c.get("/b/g/../secret.txt?thumb=1").status_code in (404, 400)
def test_the_filmstrip_and_tray_use_thumbnails_but_the_stage_does_not(tmp_path):
"""The same 77 MB in a different place. The filmstrip shows EVERY ring item
at a few dozen pixels, so full-resolution frames there are worse than the
grid was — while the stage is the full-size review and must stay full size.
Defeating change: `x.url` in the filmstrip, or `it.thumb` on the stage."""
b = tmp_path / "g"
for n in ("a.png", "b.png", "c.png"):
_img(b / n, 1024, 1024)
c = TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False))
html = c.get("/b/g/view?f=a.png").text
assert 'id="vimg" src="a.png"' in html, "the stage must serve the full image"
assert 'src="b.png?thumb=1"' in html, "the filmstrip still pulls full images"
assert 'src="b.png"' not in html.replace('src="b.png?thumb=1"', ""), \
"a full-size frame survived in the strip"
def test_the_desk_preview_strip_uses_thumbnails(tmp_path):
"""The heaviest surface in the service, on the page he opens FIRST: four
small images per booth, across every booth. design-dev measured 28
originals / 24.1 MB on a 12-booth copy; live has 28 booths.
Defeating change: `it.url` in the preview tuple."""
for name in ("one", "two"):
b = tmp_path / name
for n in ("a.png", "b.png"):
_img(b / n, 1024, 1024)
c = TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False))
html = c.get("/").text
assert 'src="/b/one/a.png?thumb=1"' in html, "the Desk strip still pulls full images"
assert 'src="/b/one/a.png"' not in html