mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 16:58:06 +00:00
Two latent issues found in a self-review pass: - LayoutContext._fit_cache was an unbounded dict (the image cache got an LRU cap, the text-fit cache didn't). Cache keys embed the fitted TEXT, so a plugin fitting changing strings — a live game clock, a ticker — on a 24/7 service grows it forever. Now LRU-bounded at 512 entries via the same pattern as the image cache. - fit_image returned the caller's ORIGINAL image object when the source was already RGBA at target size (contain/fill_height, no ink crop). ImageFitResult is documented as an independent copy, and LayoutContext caches results — an aliased image lets later mutations of the source corrupt cached fits (or vice versa). Copy in that branch. Both covered by new regression tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam
209 lines
7.7 KiB
Python
209 lines
7.7 KiB
Python
"""Tests for adaptive image fitting (src/adaptive_images.py) and the
|
|
LayoutContext image cache."""
|
|
|
|
import pytest
|
|
from PIL import Image
|
|
|
|
from src.adaptive_images import (
|
|
RESAMPLE_LANCZOS,
|
|
RESAMPLE_NEAREST,
|
|
ImageFitResult,
|
|
draw_fitted_image,
|
|
fit_image,
|
|
)
|
|
from src.adaptive_layout import LayoutContext, Region
|
|
from src.font_manager import FontManager
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def font_manager():
|
|
return FontManager({})
|
|
|
|
|
|
@pytest.fixture
|
|
def ctx(font_manager):
|
|
return LayoutContext(128, 32, font_manager)
|
|
|
|
|
|
def _solid(w, h, color=(255, 0, 0, 255)):
|
|
return Image.new("RGBA", (w, h), color)
|
|
|
|
|
|
def _padded_logo(ink_w=10, ink_h=10, pad=10):
|
|
"""Transparent canvas with a solid ink block in the middle — models a
|
|
logo shipped with generous transparent padding."""
|
|
img = Image.new("RGBA", (ink_w + 2 * pad, ink_h + 2 * pad), (0, 0, 0, 0))
|
|
img.paste(_solid(ink_w, ink_h), (pad, pad))
|
|
return img
|
|
|
|
|
|
class TestFitModes:
|
|
def test_contain_letterboxes_and_upscales(self):
|
|
fit = fit_image(_solid(10, 5), (40, 40))
|
|
assert (fit.width, fit.height) == (40, 20) # aspect preserved
|
|
assert fit.scale == 4.0
|
|
|
|
def test_contain_no_upscale(self):
|
|
fit = fit_image(_solid(10, 5), (40, 40), upscale=False)
|
|
assert (fit.width, fit.height) == (10, 5)
|
|
assert fit.scale == 1.0
|
|
|
|
def test_cover_fills_and_crops(self):
|
|
fit = fit_image(_solid(10, 20), (40, 40), mode="cover")
|
|
assert (fit.width, fit.height) == (40, 40)
|
|
|
|
def test_cover_top_anchor(self):
|
|
# top half red, bottom half blue; cover-crop a wide box with top anchor
|
|
img = Image.new("RGBA", (20, 40), (0, 0, 255, 255))
|
|
img.paste(_solid(20, 20, (255, 0, 0, 255)), (0, 0))
|
|
fit = fit_image(img, (20, 20), mode="cover", anchor="top")
|
|
assert fit.image.getpixel((10, 5))[:3] == (255, 0, 0) # kept the top
|
|
|
|
def test_fill_height_matches_box_height(self):
|
|
fit = fit_image(_solid(10, 10), (64, 32), mode="fill_height")
|
|
assert fit.height == 32 and fit.width == 32
|
|
|
|
def test_fill_height_capped_by_width(self):
|
|
# very wide source: height-fill would overflow the box width
|
|
fit = fit_image(_solid(100, 10), (40, 32), mode="fill_height")
|
|
assert fit.width <= 40
|
|
|
|
def test_stretch_exact(self):
|
|
fit = fit_image(_solid(3, 7), (25, 13), mode="stretch")
|
|
assert (fit.width, fit.height) == (25, 13)
|
|
|
|
def test_crop_to_ink(self):
|
|
fit = fit_image(_padded_logo(), (30, 30), crop_to_ink=True)
|
|
# 10x10 ink upscaled to fill 30x30 (padding would have kept it small)
|
|
assert (fit.width, fit.height) == (30, 30)
|
|
no_crop = fit_image(_padded_logo(), (30, 30), crop_to_ink=False)
|
|
assert no_crop.width == 30 # whole padded canvas scaled instead
|
|
|
|
def test_fully_transparent_source(self):
|
|
img = Image.new("RGBA", (10, 10), (0, 0, 0, 0))
|
|
fit = fit_image(img, (20, 20), crop_to_ink=True)
|
|
assert fit.is_empty
|
|
|
|
def test_degenerate_box(self):
|
|
assert fit_image(_solid(10, 10), (0, 20)).is_empty
|
|
assert fit_image(_solid(10, 10), Region(0, 0, 20, 0)).is_empty
|
|
|
|
def test_output_always_rgba(self):
|
|
rgb = Image.new("RGB", (10, 10), (1, 2, 3))
|
|
assert fit_image(rgb, (20, 20)).image.mode == "RGBA"
|
|
|
|
def test_nearest_keeps_hard_edges(self):
|
|
# 2x2 checker scaled 8x: NEAREST keeps pure colors, LANCZOS blends
|
|
img = Image.new("RGBA", (2, 2), (0, 0, 0, 255))
|
|
img.putpixel((0, 0), (255, 255, 255, 255))
|
|
near = fit_image(img, (16, 16), mode="stretch", resample=RESAMPLE_NEAREST)
|
|
colors = {near.image.getpixel((x, y))[:3] for x in range(16) for y in range(16)}
|
|
assert colors == {(255, 255, 255), (0, 0, 0)}
|
|
|
|
def test_unknown_mode_raises(self):
|
|
with pytest.raises(ValueError):
|
|
fit_image(_solid(4, 4), (8, 8), mode="tile")
|
|
|
|
|
|
class TestDrawFittedImage:
|
|
class _DM:
|
|
def __init__(self, w=64, h=32):
|
|
self.image = Image.new("RGB", (w, h), (0, 0, 0))
|
|
|
|
def test_pastes_aligned_in_region(self):
|
|
dm = self._DM()
|
|
box = Region(10, 4, 20, 20)
|
|
fit = fit_image(_solid(10, 10), box)
|
|
xy = draw_fitted_image(dm, fit, box)
|
|
assert xy == box.align_xy(fit.width, fit.height)
|
|
assert dm.image.getpixel((xy[0] + 1, xy[1] + 1)) == (255, 0, 0)
|
|
|
|
def test_offset_translates(self):
|
|
dm = self._DM()
|
|
box = Region(0, 0, 20, 20)
|
|
fit = fit_image(_solid(10, 10), box)
|
|
x, y = draw_fitted_image(dm, fit, box, align="left", valign="top",
|
|
offset=(3, 5))
|
|
assert (x, y) == (3, 5)
|
|
|
|
def test_empty_fit_noops(self):
|
|
dm = self._DM()
|
|
fit = fit_image(_solid(10, 10), (0, 0))
|
|
assert draw_fitted_image(dm, fit, Region(0, 0, 10, 10)) is None
|
|
|
|
|
|
class TestContextImageCache:
|
|
def test_size_keyed_hit_and_miss(self, ctx):
|
|
img = _solid(10, 10)
|
|
a = ctx.fit_image(img, (20, 20), cache_key="logo:A")
|
|
assert ctx.fit_image(img, (20, 20), cache_key="logo:A") is a
|
|
b = ctx.fit_image(img, (30, 30), cache_key="logo:A")
|
|
assert b is not a and b.width == 30 # different box size = new entry
|
|
|
|
def test_id_keyed_default(self, ctx):
|
|
img = _solid(10, 10)
|
|
a = ctx.fit_image(img, (20, 20))
|
|
assert ctx.fit_image(img, (20, 20)) is a
|
|
|
|
def test_id_safety_pins_source(self, ctx):
|
|
# id()-keyed entries must pin the source image so a recycled id
|
|
# can't alias a dead image's cache entry.
|
|
img = _solid(10, 10)
|
|
ctx.fit_image(img, (20, 20))
|
|
pinned = [entry[1] for entry in ctx._image_cache.values()]
|
|
assert img in pinned
|
|
|
|
def test_cache_key_entries_do_not_pin(self, ctx):
|
|
img = _solid(10, 10)
|
|
ctx.fit_image(img, (20, 20), cache_key="logo:X")
|
|
key = next(k for k in ctx._image_cache if k[1] == "logo:X")
|
|
assert ctx._image_cache[key][1] is None
|
|
|
|
def test_lru_eviction(self, ctx):
|
|
for i in range(ctx._IMAGE_CACHE_MAX + 5):
|
|
ctx.fit_image(_solid(4, 4), (8, 8), cache_key=f"k{i}")
|
|
assert len(ctx._image_cache) == ctx._IMAGE_CACHE_MAX
|
|
assert not any(k[1] == "k0" for k in ctx._image_cache) # oldest evicted
|
|
|
|
def test_clear_cache_clears_images(self, ctx):
|
|
ctx.fit_image(_solid(4, 4), (8, 8), cache_key="k")
|
|
ctx.clear_cache()
|
|
assert len(ctx._image_cache) == 0
|
|
|
|
|
|
class TestBasePluginDrawImage:
|
|
def test_draw_image_end_to_end(self):
|
|
from src.plugin_system.base_plugin import BasePlugin
|
|
from src.plugin_system.testing.mocks import (
|
|
MockCacheManager, MockDisplayManager, MockPluginManager,
|
|
)
|
|
|
|
class _P(BasePlugin):
|
|
def update(self):
|
|
pass
|
|
|
|
def display(self, force_clear=False):
|
|
pass
|
|
|
|
plugin = _P("t", {}, MockDisplayManager(64, 32),
|
|
MockCacheManager(), MockPluginManager())
|
|
logo = _padded_logo()
|
|
box = plugin.layout.bounds.left_col(32)
|
|
ifit = plugin.draw_image(logo, box, mode="fill_height",
|
|
crop_to_ink=True, cache_key="logo:T")
|
|
assert ifit.height == 32
|
|
# pasted onto the mock's canvas
|
|
assert plugin.display_manager.image.getpixel((16, 16)) != (0, 0, 0)
|
|
|
|
|
|
class TestResultIndependence:
|
|
def test_same_size_fit_never_aliases_the_source(self):
|
|
"""LayoutContext caches ImageFitResults — an aliased image would let
|
|
later mutations of the source corrupt cached fits (or vice versa)."""
|
|
from PIL import ImageDraw
|
|
src = Image.new("RGBA", (20, 20), (255, 0, 0, 255))
|
|
fit = fit_image(src, (20, 20))
|
|
assert fit.image is not src
|
|
ImageDraw.Draw(src).rectangle([0, 0, 19, 19], fill=(0, 255, 0, 255))
|
|
assert fit.image.getpixel((5, 5)) == (255, 0, 0, 255)
|