"""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 ( # noqa: E402 THUMB_DIR, THUMB_HEIGHT_MAX, THUMB_LIGHT_BYTES, THUMB_WIDTH, ensure_thumb, thumb_path, wants_thumb, ) 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() w, h = PIL.open(out).size assert w <= THUMB_WIDTH and h <= THUMB_HEIGHT_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 AND already light. 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("") 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 # ---- sized for the tile's WIDTH, at 2x density --------------------------------- # # The operator, on sindra-nude-final: "the images look blurry until they're # selected and blown up". The cap was 512 on the LONGEST side, but a tile is sized # by its WIDTH, so a 704x1408 portrait got a 256px-wide thumbnail stretched into # a 361px tile: 1.4x at 1x density, 2.8x on a 2x screen. def _noise(path: pathlib.Path, w: int, h: int): """A photographic-weight image: incompressible, so its bytes are realistic. A flat colour compresses to almost nothing and would take the light path.""" import os path.parent.mkdir(parents=True, exist_ok=True) PIL.frombytes("RGB", (w, h), os.urandom(w * h * 3)).save(path, "PNG") return path def test_a_portrait_keeps_its_full_width(tmp_path): """Defeating change: bounding the longest side, which gave this image 256px of width for a tile that shows 361.""" b = tmp_path / "g" _noise(b / "p.png", 704, 1408) out = ensure_thumb(b, "p.png") assert out is not None assert PIL.open(out).size == (704, 1408) def test_a_wide_image_is_bounded_by_width(tmp_path): b = tmp_path / "g" _noise(b / "w.png", 2048, 1024) out = ensure_thumb(b, "w.png") assert PIL.open(out).size == (THUMB_WIDTH, THUMB_WIDTH // 2) def test_a_tile_width_image_that_is_heavy_still_gets_a_thumbnail(tmp_path): """Fitting the tile in PIXELS is not being light in BYTES: a 704x1408 PNG is about a megabyte, and serving it as its own thumbnail would undo the cache. Defeating change: skipping every image that fits the bounds.""" b = tmp_path / "g" src = _noise(b / "p.png", 704, 1408) assert src.stat().st_size > THUMB_LIGHT_BYTES out = ensure_thumb(b, "p.png") assert out is not None and out.stat().st_size < src.stat().st_size def test_an_extremely_tall_image_is_bounded_by_height_too(tmp_path): """Width alone would let a long screenshot through at full height.""" b = tmp_path / "g" _noise(b / "t.png", 300, THUMB_HEIGHT_MAX * 2) w, h = PIL.open(ensure_thumb(b, "t.png")).size assert h <= THUMB_HEIGHT_MAX and w <= 150 def test_an_animated_gif_that_fits_is_served_as_itself(tmp_path): """A thumbnail is one frame. A heavy GIF that already fits the tile used to be served whole (it was under the old cap) and must stay animated. Defeating change: dropping the animation guard on the fits-but-heavy path.""" import os b = tmp_path / "g" b.mkdir() frames = [PIL.frombytes("RGB", (300, 300), os.urandom(300 * 300 * 3)) for _ in range(3)] frames[0].save(b / "a.gif", save_all=True, append_images=frames[1:]) assert (b / "a.gif").stat().st_size > THUMB_LIGHT_BYTES assert ensure_thumb(b, "a.gif") is None def test_a_thumbnail_cut_to_the_old_rule_is_not_served(tmp_path): """The live booths hold 512-cap thumbnails that are NEWER than their sources, so the mtime check alone would serve them forever. The size rule is in the cache name, so a thumbnail cut to another rule is simply not found. Defeating change: an unversioned cache name.""" import os b = tmp_path / "g" _noise(b / "p.png", 704, 1408) legacy = b / THUMB_DIR / "p.png.webp" legacy.parent.mkdir(parents=True) PIL.new("RGB", (256, 512)).save(legacy, "WEBP") os.utime(legacy, None) out = ensure_thumb(b, "p.png") assert out == thumb_path(b, "p.png") and out != legacy assert PIL.open(out).size == (704, 1408) # ---- the heid bug-hunt on this change (4/4 arms), folded ------------------------ # # The cache sits in a directory any fleet session can write into, so every entry # on the way to it may be planted. The new size rules only governed cache MISSES; # the hit path trusted a name and an mtime. def test_a_planted_directory_at_the_cache_path_is_not_served(tmp_path): """4/4, seat-executed: a directory at the cache path, with a future mtime, was returned AS the thumbnail. Defeating change: a cache hit that checks only the mtime.""" import os b = tmp_path / "g" _noise(b / "p.png", 704, 1408) out = thumb_path(b, "p.png") out.mkdir(parents=True) os.utime(out, (2e9, 2e9)) got = ensure_thumb(b, "p.png") assert got is None or got.is_file() def test_a_source_replaced_with_an_older_mtime_is_rebuilt(tmp_path): """kimi: `cp -p` or an archive extract keeps an OLDER mtime, and a cache newer than its source was served forever. The cache now carries its source's exact mtime, so any change is a miss. Defeating change: `>=`.""" import os b = tmp_path / "g" _noise(b / "p.png", 704, 1408) first = ensure_thumb(b, "p.png") assert PIL.open(first).size == (704, 1408) _noise(b / "p.png", 1536, 768) os.utime(b / "p.png", (1e9, 1e9)) # an older stamp than the cache assert PIL.open(ensure_thumb(b, "p.png")).size == (THUMB_WIDTH, THUMB_WIDTH // 2) def test_a_symlinked_cache_dir_is_never_written_through(tmp_path): """seat P4: `.thumbs` planted as a link to another directory put the cache outside the booth, beyond the sweep. Defeating change: `mkdir(parents=True)`, which follows an existing link.""" b = tmp_path / "g" _noise(b / "p.png", 704, 1408) elsewhere = tmp_path / "elsewhere" elsewhere.mkdir() (b / THUMB_DIR).symlink_to(elsewhere) assert ensure_thumb(b, "p.png") is None assert list(elsewhere.iterdir()) == [] def test_a_planted_link_at_the_old_temp_name_cannot_redirect_the_write(tmp_path): """groa, seat P5: the temp name was `..tmp`, predictable, so a link planted there made the encoder truncate and overwrite its target (600 B -> 316,400 B). Defeating change: any predictable temp name.""" import os b = tmp_path / "g" _noise(b / "p.png", 704, 1408) victim = tmp_path / "victim.txt" victim.write_text("untouched") out = thumb_path(b, "p.png") out.parent.mkdir(parents=True) (out.parent / (out.name + f".{os.getpid()}.tmp")).symlink_to(victim) ensure_thumb(b, "p.png") assert victim.read_text() == "untouched" def test_palette_transparency_survives_the_thumbnail(tmp_path): """3/4, seat-executed, and INTRODUCED by this change: the fits-but-heavy branch newly re-encodes palette PNGs, and `getbands()` of mode P has no A even with a tRNS chunk, so transparency became opaque. Defeating change: choosing RGBA by `getbands()` alone.""" import os b = tmp_path / "g" b.mkdir() im = PIL.frombytes("P", (400, 400), os.urandom(400 * 400)) im.putpalette(os.urandom(768)) im.save(b / "p.png", "PNG", transparency=0) assert (b / "p.png").stat().st_size > THUMB_LIGHT_BYTES t = PIL.open(ensure_thumb(b, "p.png")) assert t.mode == "RGBA" and t.getchannel("A").getextrema()[0] == 0 def test_a_camera_portrait_is_sized_and_saved_upright(tmp_path): """groa, seat-verified: EXIF orientation was ignored, so a portrait shot stored sideways was sized as a landscape and tiled sideways. Defeating change: sizing the raw pixels without `exif_transpose`.""" import os b = tmp_path / "g" b.mkdir() exif = PIL.Exif() exif[0x0112] = 6 # rotate 90 CW to display PIL.frombytes("RGB", (1200, 800), os.urandom(1200 * 800 * 3)).save( b / "cam.jpg", "JPEG", exif=exif, quality=95) assert PIL.open(ensure_thumb(b, "cam.jpg")).size == (THUMB_WIDTH, 1152) def test_an_image_past_the_pixel_budget_is_never_decoded(tmp_path, monkeypatch): """2/4: the header is free to read and `thumbnail()` then decodes whatever it claims, on every request, since a failure is not cached. Over the budget, the original is served instead. Defeating change: no budget check.""" import booth.thumbs as thumbs b = tmp_path / "g" _noise(b / "p.png", 704, 1408) monkeypatch.setattr(thumbs, "THUMB_MAX_PIXELS", 704 * 1408 - 1) assert ensure_thumb(b, "p.png") is None