mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 08:48:05 +00:00
feat: adaptive layout system — size-aware regions, crisp font ladders, image fitting (#393)
* feat(layout): adaptive layout & font scaling system for plugins Add src/adaptive_layout.py — opt-in core helpers so plugins render legibly on any panel size without hand-tuned per-display layouts: - Region: integer rect algebra (bands/columns/weighted splits/centering) that partitions space so text bands can't overlap by construction - Font ladders: ordered (family, size) steps known to render crisply (LADDER_GRID: X11 BDFs at native sizes; LADDER_ARCADE: PressStart2P at 8px multiples) — fitting walks the ladder instead of scaling pixel fonts fractionally - LayoutContext: breakpoint tiers, geometry scale vs. a declared design size, and cached fit_text/fit_lines/font_for_rows queries Generalizes the three patterns proven in the field: f1-scoreboard's scale factor, masters-tournament's tiers, baseball-scoreboard's font fallback ladder. Wiring: BasePlugin gains a lazy .layout property and draw_fit(); FontManager gains get_native_bdf_size() and a cache_generation counter; manifest schema gains display.design_size and requires.display_size max_width/max_height; 96x48 joins DEFAULT_TEST_SIZES; the bounds-check harness records negative-coordinate draws; TextHelper's broken measurement helpers are fixed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(layout): adaptive image fitting + composite region helpers Add src/adaptive_images.py — the image counterpart to fit_text: - fit_image(img, box, mode=contain|cover|fill_height|stretch, crop_to_ink, anchor, resample, upscale) promoting the proven plugin patterns (football's crop-to-ink fill-height logos, masters' cover crop + NEAREST flags, static-image's letterbox). Upscales by default — thumbnail()'s downscale-only behavior is why imagery stays tiny on big panels. - draw_fitted_image() pastes aligned within a Region with alpha mask. - One central Pillow>=9.1 RESAMPLE shim replacing ~15 plugin copies. LayoutContext.fit_image() caches results per (identity, box size, options) with a 64-entry LRU; id()-keyed entries pin the source image. BasePlugin.draw_image() is the one-liner adoption path beside draw_fit. Composites in adaptive_layout.py: Region.offset() (user x/y-offset passthrough), scoreboard_regions() (the two-logos-plus-score card math duplicated across six sports plugins, logo_slot = min(H, W//2)), and media_row() (art-left/text-right). Fix LogoHelper's size-blind cache key (stale sizes on panel change); deprecation note on dead image_utils.py. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(harness): scale-up fill check, config variants, multi-size dev gallery Quality gates for adaptive layout: - fill_metrics()/check_scale_up() in the safety harness: overflow catches content too big for a panel, but nothing caught content that stays tiny on panels >= 2x the plugin's declared design size. The check measures lit-content extents and warns (or fails, when a plugin opts into "fill_check": "strict" in test/harness.json) below 50% coverage on the doubled axis. Warn-only by default so no existing plugin breaks. - harness.json "variants": extra runs with config overlays and their own golden dirs, so an opt-in mode (e.g. layout_mode: adaptive) is golden- tested beside the classic default. check_plugin.py loops base + variants and labels variant results mode@name. - Dev preview server: GET /api/sizes (harness size sample), POST /api/render-matrix (render at up to 12 sizes in one call), size-preset dropdown, and an "All Sizes" side-by-side gallery in the preview UI. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(plugins): adaptive-lib discoverability + advisory version compat warning Discoverability: re-export the adaptive layout/image API from src.common (the blessed-helpers package plugin authors already know) — canonical paths stay src.adaptive_layout / src.adaptive_images so nothing breaks. Document it in src/common/README.md and cross-link ADAPTIVE_LAYOUT.md from the developer docs authors actually read (quick reference, API reference, advanced dev, font manager, dev preview, plugin dev guide); ADAPTIVE_LAYOUT.md gains adaptive-images, composite-layouts and preserving-user-customization sections. Compat: PluginLoader now logs one advisory warning (never raises) when a plugin's manifest declares a min LEDMatrix version newer than the running core, checking the min_ledmatrix_version / requires.* / versions[] spellings found in the wild. Guarded against stale core version numbers. src/__init__.py __version__ bumped 1.0.0 -> 3.1.0 to match the latest release tag (v3.1.0) — it had never been updated and the compat check needs a truthful number. NOTE: verify this matches the intended release numbering before the next tag. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(layout): add measure_font_crispness — verify a ladder rung isn't blurry PIL antialiases TTF outlines by default; a 'pixel-style' font only rasterizes without antialiasing at specific sizes (for PressStart2P: exact multiples of its 8px design grid). A ladder rung at an unverified size silently renders blurry on an LED panel — this exact bug shipped in both text-display's and football-scoreboard's custom TTF ladders (non-8-multiple PressStart2P sizes, and '5by7.regular'/'4x6-font' at sizes that were never actually crisp). measure_font_crispness(font, sample_text) renders the sample and reports the fraction of ink-bbox pixels that are neither pure black nor pure white. BDF fonts (real bitmaps) always score 0.0; TTF ladders should be verified against this before shipping — see the new TestFontFitting::test_ladder_arcade_is_crisp pattern. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(layout): add fit_text_proportional — proportional sizing vs. always-maximize fit_text always picks the largest ladder rung that fits its box. That's right when an element owns dedicated space, but wrong when several independently-fitted elements need to stay visually harmonious as the panel grows: a score's box might have generous room while a neighboring logo scales by a fixed geometry factor via px() — fit_text lets the score balloon out of proportion (even overlapping the logo) even though its individual pick is technically correct. fit_text_proportional(text, box, base_size_px, ladder) instead targets base_size_px * self.scale (the same scale factor px() already uses), picking the nearest ladder rung at or below that target, still capped to what fits the box, floored at the smallest rung when the target is below every rung. Refactored the shared largest-that-fits/ellipsize walk into _walk_ladder() so fit_text and fit_text_proportional don't duplicate it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(layout): fit_text_proportional gains an axis-specific scale override self.scale (min(width_ratio, height_ratio)) is the right conservative default for anything whose aspect ratio matters, but a caller whose surrounding composition already scales along a single axis — e.g. football-scoreboard's logo_slot = min(height, width // 2), which tracks height alone — needs text sized the same way, or it reads as under-scaled next to logos that grew on a panel that only got taller (128x32 -> 128x64: self.scale stays 1.0 since width didn't grow, but logos still double). fit_text_proportional(..., scale=None) now accepts an explicit override; None keeps the existing self.scale default. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(layout): scoreboard_regions reserves real center space at 2:1 aspect ratios logo_slot = min(height, width // 2) has a blind spot: at exactly 2:1 aspect ratio (width == 2 * height -- a very common shape: two, four, or more square modules stacked into a taller panel) width // 2 and height are equal, so the two logo slots claim the ENTIRE width and leave zero pixels for a center column, no matter how large the panel gets. Not a 'small panel' problem -- 96x48, 128x64, and 256x128 (all exactly 2:1) hit it identically, while the 128x32 design baseline and panels like 192x48 or 256x32 never do, because height is already the tighter constraint there. Two new parameters fix it in the one shared helper every scoreboard-style plugin composes through: - min_center_fraction / min_center_design_px reserve at least max(width * fraction, design_px * ctx.scale) for the center column, capping logo_slot further when needed. The scaled design-px term matters on small panels where a flat fraction alone reserves too little absolute space. - score_bleed_fraction extends the score's own fit box (not the logo slots themselves) a controlled amount into each side -- the same way real broadcast scoreboards let a big score number's edges cross into the team marks flanking it. Without this the reserve alone can still be too narrow for a short score to render without truncating. score_area is now genuinely narrower than the full card width (previously identical to status_band/detail_band, which still span the full width and overlay the logos -- short text there was never the problem). Verified against the full harness size spread: a real game score like '17-21' never needs ellipsis at any tested 2:1-or-tighter aspect ratio (test_score_never_needs_ellipsis_for_a_short_score), and wide panels (128x32/192x48/256x32-style) are provably unaffected. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: document scoreboard_regions' center-reserve and score-bleed params Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: address CodeRabbit review on PR #393 - docs: scope the self.layout note to BasePlugin subclasses (others build a LayoutContext directly) and make explicit that adaptive layout is opt-in — classic rendering stays unless a plugin adopts the APIs. - dev_server: broaden the render-request catch (a bad manifest.json now returns a clean 400 instead of an unhandled 500) and stop echoing raw exception text in the loader-failure responses — full tracebacks go to the dev server's console log instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam * fix(dev-server): allowlist plugin_id before any path lookup CodeQL (py/path-injection): plugin_id arrives in request input and flows into filesystem paths via find_plugin_dir. Gate it with the same ^[a-zA-Z0-9_-]{1,64}$ allowlist the web UI's pages_v3 uses, at the single choke point every route resolves through. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam * fix(dev-server): lexical containment check on resolved plugin dirs CodeQL doesn't recognize the interprocedural allowlist as a path-injection barrier; add the canonical one — normalize (without following symlinks, since dev plugins are commonly symlinked into plugins/) and require the result to stay inside the search dir. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam * fix(dev-server): inline normpath containment barrier before render CodeQL doesn't credit the sanitization inside find_plugin_dir along this flow; apply its documented barrier (normpath + startswith against the allowed roots) inline in _parse_render_request, on the exact path that reaches the render/load sinks. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam * fix(dev-server): derive plugin dir from trusted directory listings CodeQL's barrier-guard recognition doesn't see a startswith check inside an any() comprehension, so the normalize-and-prefix approach still flagged. Break the taint outright instead: after lookup, re-derive the directory by enumerating the search dirs (iterdir) and matching by path equality — the Path used for all downstream file access is built solely from trusted listings, never from request input. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam * fix(dev-server): use os.scandir for path-injection barrier, redact stack traces from render responses CodeQL doesn't model Path.iterdir() as a taint-clearing enumeration the way it does os.scandir() -- _trusted_plugin_dir's iterdir-based rebuild still traced plugin_id through to the manifest.json open(). Switched to scandir, matching the pattern already verified clean on PR #396. Also stops surfacing raw exception text (update()/display() failures) in the JSON render response -- logs full detail server-side via exc_info instead, returning only the exception class name to the client. And drops path values from three plugin_loader debug/error logs that CodeQL flags as clear-text-logging of externally-influenced data, keeping plugin_id (not flagged) for context. * fix(dev-server): remove conditional-reassignment ambiguity in plugin_dir resolution CodeQL's path-injection flow still traced through _parse_render_request after the scandir fix -- the tainted find_plugin_dir() result and the scandir-derived _trusted_plugin_dir() result shared the same variable name (plugin_dir), reassigned only on the truthy branch. That merge point apparently isn't treated as a barrier by the flow analysis, so it kept tracing the pre-reassignment value through to the manifest open(). Split into two distinct names -- candidate_dir (tainted, used only to call _trusted_plugin_dir) and trusted_dir (the only name used for any downstream file access) -- so there's no reassigned variable for the flow to walk through. * fix: remove unused imports flagged by Codacy Union in adaptive_images.py and field in adaptive_layout.py are both imported but never used -- the last two Codacy findings on this PR, matching the same fix already applied on PR #396. * fix(layout): bound the fit cache; never alias the source image in fits 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 --------- Co-authored-by: Chuck <chuck@example.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
Chuck
parent
05e7c43b27
commit
7f7f0d6464
@@ -0,0 +1,208 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,454 @@
|
||||
"""Tests for the adaptive layout system (src/adaptive_layout.py)."""
|
||||
|
||||
import pytest
|
||||
|
||||
from src.adaptive_layout import (
|
||||
DEFAULT_DESIGN_SIZE,
|
||||
LADDER_ARCADE,
|
||||
LADDER_GRID,
|
||||
LayoutContext,
|
||||
Region,
|
||||
draw_fitted_text,
|
||||
measure_font_crispness,
|
||||
measure_ink,
|
||||
media_row,
|
||||
scoreboard_regions,
|
||||
)
|
||||
from src.plugin_system.testing.sizes import DEFAULT_TEST_SIZES
|
||||
from src.font_manager import FontManager
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def font_manager():
|
||||
"""Real FontManager over assets/fonts — the ladders depend on it."""
|
||||
return FontManager({})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ctx(font_manager):
|
||||
return LayoutContext(128, 32, font_manager)
|
||||
|
||||
|
||||
class TestRegion:
|
||||
"""Pure integer rect algebra."""
|
||||
|
||||
def test_bands_partition_without_overlap(self):
|
||||
r = Region(0, 0, 128, 32)
|
||||
top = r.top_band(7)
|
||||
bottom = r.bottom_band(7)
|
||||
middle = r.middle(7, 7)
|
||||
assert top.bottom == middle.y
|
||||
assert middle.bottom == bottom.y
|
||||
assert top.h + middle.h + bottom.h == r.h
|
||||
|
||||
def test_bands_clamp_on_short_panel(self):
|
||||
# The classic failure: y=1 top band and y=height-7 bottom band
|
||||
# overlapping on a short panel. Bands can't exceed the region.
|
||||
r = Region(0, 0, 32, 8)
|
||||
assert r.top_band(16).h == 8
|
||||
assert r.bottom_band(16).h == 8
|
||||
assert r.middle(8, 8).h == 0 # degenerate, never negative
|
||||
|
||||
def test_split_v_weights_sum_to_height(self):
|
||||
r = Region(0, 0, 64, 33)
|
||||
rows = r.split_v(3, 1, 1, gap=1)
|
||||
assert len(rows) == 3
|
||||
assert sum(row.h for row in rows) == 33 - 2 # two 1px gaps
|
||||
assert rows[0].h > rows[1].h
|
||||
assert rows[-1].bottom == r.bottom
|
||||
|
||||
def test_split_h_columns_advance(self):
|
||||
r = Region(0, 0, 100, 32)
|
||||
cols = r.split_h(1, 1, gap=2)
|
||||
assert cols[0].right + 2 == cols[1].x
|
||||
assert cols[1].right == r.right
|
||||
|
||||
def test_degenerate_sizes_never_negative(self):
|
||||
for w, h in [(8, 8), (32, 16), (1, 1)]:
|
||||
r = Region(0, 0, w, h).inset(4)
|
||||
assert r.w >= 0 and r.h >= 0
|
||||
for sub in r.split_v(1, 1) + r.split_h(1, 1, gap=3):
|
||||
assert sub.w >= 0 and sub.h >= 0
|
||||
|
||||
def test_align_xy(self):
|
||||
r = Region(10, 10, 100, 20)
|
||||
assert r.align_xy(20, 10, "left", "top") == (10, 10)
|
||||
assert r.align_xy(20, 10, "right", "bottom") == (90, 20)
|
||||
assert r.align_xy(20, 10) == (50, 15)
|
||||
|
||||
def test_left_right_cols(self):
|
||||
r = Region(0, 0, 128, 32)
|
||||
assert r.left_col(32) == Region(0, 0, 32, 32)
|
||||
assert r.right_col(32) == Region(96, 0, 32, 32)
|
||||
|
||||
def test_offset_translates_without_resizing(self):
|
||||
r = Region(5, 5, 20, 10).offset(3, -2)
|
||||
assert r == Region(8, 3, 20, 10)
|
||||
|
||||
|
||||
class TestScoreboardRegions:
|
||||
@pytest.mark.parametrize("w,h", DEFAULT_TEST_SIZES + [(8, 8)])
|
||||
def test_invariants_at_all_sizes(self, w, h):
|
||||
regs = scoreboard_regions(Region(0, 0, w, h))
|
||||
assert regs.logo_slot <= min(h, w // 2)
|
||||
# slots hug the edges and never overlap the center column
|
||||
assert regs.away_slot.x == 0 and regs.home_slot.right == w
|
||||
assert regs.away_slot.right <= regs.center_col.x or regs.center_col.w == 0
|
||||
assert regs.center_col.right <= regs.home_slot.x or regs.center_col.w == 0
|
||||
# bands stack inside the center column without overlap
|
||||
assert regs.status_band.bottom <= regs.score_area.y or regs.score_area.h == 0
|
||||
assert regs.score_area.bottom <= regs.detail_band.y or regs.score_area.h == 0
|
||||
# everything within bounds, nothing negative
|
||||
for reg in (regs.away_slot, regs.home_slot, regs.center_col,
|
||||
regs.status_band, regs.score_area, regs.detail_band,
|
||||
regs.bottom_left, regs.bottom_right):
|
||||
assert reg.w >= 0 and reg.h >= 0
|
||||
assert reg.x >= 0 and reg.y >= 0
|
||||
assert reg.right <= w and reg.bottom <= h
|
||||
|
||||
@pytest.mark.parametrize("w,h", [(96, 48), (128, 64), (256, 128), (64, 32), (128, 96)])
|
||||
def test_2to1_aspect_gets_a_center_reserve(self, w, h):
|
||||
"""These sizes are all <= 2:1 aspect, where the raw min(h, w//2)
|
||||
formula claims the entire width for logos and leaves zero pixels
|
||||
for a center column — the bug this reserve exists to fix."""
|
||||
regs = scoreboard_regions(Region(0, 0, w, h))
|
||||
assert regs.center_col.w >= int(w * 0.15) - 1 # -1 for int() rounding
|
||||
|
||||
@pytest.mark.parametrize("w,h", [(128, 32), (192, 48), (256, 32)])
|
||||
def test_wide_panels_unaffected_by_center_reserve(self, w, h):
|
||||
"""Wide (>= ~4:1) panels already have height as the tighter
|
||||
constraint, so the center reserve must be a no-op there — the
|
||||
design-size baseline's proportions shouldn't shift."""
|
||||
regs = scoreboard_regions(Region(0, 0, w, h))
|
||||
assert regs.logo_slot == min(h, w // 2)
|
||||
|
||||
def test_center_reserve_fraction_is_configurable(self):
|
||||
# min_center_design_px=0 isolates the fraction term (otherwise the
|
||||
# scaled absolute floor can dominate and mask a fraction change).
|
||||
regs_default = scoreboard_regions(Region(0, 0, 128, 64), min_center_design_px=0)
|
||||
regs_wider = scoreboard_regions(Region(0, 0, 128, 64), min_center_fraction=0.5,
|
||||
min_center_design_px=0)
|
||||
assert regs_wider.center_col.w > regs_default.center_col.w
|
||||
assert regs_wider.logo_slot < regs_default.logo_slot
|
||||
|
||||
def test_score_bleed_extends_past_center_col(self):
|
||||
regs = scoreboard_regions(Region(0, 0, 128, 64), score_bleed_fraction=0.5)
|
||||
assert regs.score_area.w > regs.center_col.w
|
||||
assert regs.score_area.x < regs.center_col.x
|
||||
assert regs.score_area.right > regs.center_col.right
|
||||
|
||||
def test_score_bleed_zero_matches_center_col(self):
|
||||
regs = scoreboard_regions(Region(0, 0, 128, 64), score_bleed_fraction=0.0)
|
||||
assert regs.score_area.w == regs.center_col.w
|
||||
assert regs.score_area.x == regs.center_col.x
|
||||
|
||||
@pytest.mark.parametrize("w,h", [(64, 32), (96, 48), (128, 64), (256, 128), (128, 96)])
|
||||
def test_score_never_needs_ellipsis_for_a_short_score(self, w, h, font_manager):
|
||||
"""The concrete regression this whole reserve/bleed system exists to
|
||||
prevent: a real game score like '17-21' must always render in full,
|
||||
never truncated, at every 2:1-or-tighter aspect ratio in the sample."""
|
||||
ctx = LayoutContext(w, h, font_manager)
|
||||
regs = scoreboard_regions(Region(0, 0, w, h), ctx=ctx)
|
||||
height_scale = h / 32.0
|
||||
fit = ctx.fit_text_proportional("17-21", regs.score_area, base_size_px=10,
|
||||
ladder=LADDER_ARCADE, scale=height_scale)
|
||||
assert fit.text == "17-21"
|
||||
assert fit.fits
|
||||
|
||||
def test_ctx_scales_band_heights(self, font_manager):
|
||||
small = scoreboard_regions(Region(0, 0, 128, 32),
|
||||
ctx=LayoutContext(128, 32, font_manager))
|
||||
big = scoreboard_regions(Region(0, 0, 256, 64),
|
||||
ctx=LayoutContext(256, 64, font_manager))
|
||||
assert big.status_band.h > small.status_band.h
|
||||
|
||||
def test_works_on_offset_card_region(self):
|
||||
card = Region(10, 4, 100, 24)
|
||||
regs = scoreboard_regions(card)
|
||||
assert regs.away_slot.x == 10
|
||||
assert regs.home_slot.right == card.right
|
||||
|
||||
|
||||
class TestMediaRow:
|
||||
def test_square_art_plus_body(self):
|
||||
row = media_row(Region(0, 0, 128, 32))
|
||||
assert row.art == Region(0, 0, 32, 32)
|
||||
assert row.body.x == 32 + 2 and row.body.right == 128
|
||||
|
||||
def test_non_square(self):
|
||||
row = media_row(Region(0, 0, 100, 20), square=False, gap=4)
|
||||
assert row.art.w == 50
|
||||
assert row.body.x == 54
|
||||
|
||||
def test_narrow_panel_clamps(self):
|
||||
row = media_row(Region(0, 0, 16, 32))
|
||||
assert row.art.w == 16 and row.body.w == 0
|
||||
|
||||
|
||||
class TestLayoutContext:
|
||||
def test_tiers(self, font_manager):
|
||||
assert LayoutContext(128, 32, font_manager).tier == "sm"
|
||||
assert LayoutContext(96, 48, font_manager).tier == "md"
|
||||
assert LayoutContext(128, 64, font_manager).tier == "lg"
|
||||
assert LayoutContext(64, 16, font_manager).tier == "xs"
|
||||
assert LayoutContext(256, 128, font_manager).tier == "xl"
|
||||
|
||||
def test_wide_short_flag(self, font_manager):
|
||||
assert LayoutContext(128, 32, font_manager).is_wide_short
|
||||
assert not LayoutContext(128, 64, font_manager).is_wide_short
|
||||
|
||||
def test_scale_against_design_size(self, font_manager):
|
||||
assert LayoutContext(128, 32, font_manager).scale == 1.0
|
||||
assert LayoutContext(256, 64, font_manager).scale == 2.0
|
||||
# min() of the two axes: don't overscale the constrained one
|
||||
assert LayoutContext(256, 32, font_manager).scale == 1.0
|
||||
assert DEFAULT_DESIGN_SIZE == (128, 32)
|
||||
|
||||
def test_px_scales_and_clamps(self, font_manager):
|
||||
big = LayoutContext(256, 64, font_manager)
|
||||
assert big.px(4) == 8
|
||||
assert big.px(4, maximum=6) == 6
|
||||
tiny = LayoutContext(32, 16, font_manager)
|
||||
assert tiny.px(4, minimum=2) == 2
|
||||
|
||||
def test_by_tier_nearest_at_or_below(self, font_manager):
|
||||
mapping = {"sm": 10, "lg": 18}
|
||||
assert LayoutContext(128, 32, font_manager).by_tier(mapping) == 10
|
||||
assert LayoutContext(96, 48, font_manager).by_tier(mapping) == 10 # md -> sm
|
||||
assert LayoutContext(128, 64, font_manager).by_tier(mapping) == 18
|
||||
assert LayoutContext(256, 128, font_manager).by_tier(mapping) == 18 # xl -> lg
|
||||
# nothing at-or-below: fall forward to smallest defined above
|
||||
assert LayoutContext(64, 16, font_manager).by_tier(mapping) == 10
|
||||
|
||||
|
||||
class TestFontFitting:
|
||||
def test_ladder_monotonic(self, font_manager):
|
||||
"""Each ladder rung must render no taller than the one before it."""
|
||||
for ladder in (LADDER_GRID, LADDER_ARCADE):
|
||||
heights = []
|
||||
for step in ladder:
|
||||
font = font_manager.get_font(step.family, step.size_px)
|
||||
heights.append(measure_ink("Ay0", font)[1])
|
||||
assert heights == sorted(heights, reverse=True), (
|
||||
f"ladder not monotonically shrinking: {heights}")
|
||||
|
||||
def test_ladder_grid_is_crisp(self, font_manager):
|
||||
"""LADDER_GRID's BDF fonts are real bitmaps — always 0% antialiased."""
|
||||
for step in LADDER_GRID:
|
||||
font = font_manager.get_font(step.family, step.size_px)
|
||||
assert measure_font_crispness(font, "Ay0") == 0.0
|
||||
|
||||
def test_ladder_arcade_is_crisp(self, font_manager):
|
||||
"""PressStart2P only rasterizes without antialiasing at exact
|
||||
multiples of its 8px design grid — every LADDER_ARCADE rung must
|
||||
land on one."""
|
||||
for step in LADDER_ARCADE:
|
||||
assert step.size_px % 8 == 0, f"{step} is not a multiple of 8"
|
||||
font = font_manager.get_font(step.family, step.size_px)
|
||||
assert measure_font_crispness(font, "17-21") == 0.0
|
||||
|
||||
def test_crispness_catches_a_bad_size(self, font_manager):
|
||||
"""Sanity check the measurement itself: a known-bad size for a
|
||||
pixel-grid font must NOT read as crisp."""
|
||||
font = font_manager.get_font("press_start", 10) # not a multiple of 8
|
||||
assert measure_font_crispness(font, "17-21") > 0.1
|
||||
|
||||
def test_fit_text_grows_on_taller_panel(self, font_manager):
|
||||
small = LayoutContext(64, 32, font_manager)
|
||||
large = LayoutContext(128, 64, font_manager)
|
||||
text = "12:34"
|
||||
fit_small = small.fit_text(text, small.bounds, ladder=LADDER_ARCADE)
|
||||
fit_large = large.fit_text(text, large.bounds, ladder=LADDER_ARCADE)
|
||||
assert fit_small.fits and fit_large.fits
|
||||
assert fit_large.size_px > fit_small.size_px
|
||||
|
||||
def test_fit_text_fits_the_box(self, ctx):
|
||||
box = ctx.bounds.inset(1)
|
||||
fit = ctx.fit_text("HELLO WORLD", box)
|
||||
assert fit.fits
|
||||
assert fit.width <= box.w and fit.height <= box.h
|
||||
|
||||
def test_fit_text_ellipsizes_overlong_text(self, font_manager):
|
||||
tiny = LayoutContext(32, 16, font_manager)
|
||||
fit = tiny.fit_text("SUPERCALIFRAGILISTIC", tiny.bounds)
|
||||
assert fit.text != "SUPERCALIFRAGILISTIC"
|
||||
assert fit.text.endswith("…")
|
||||
assert fit.width <= tiny.bounds.w
|
||||
|
||||
def test_fit_text_cached(self, ctx):
|
||||
first = ctx.fit_text("CACHED", ctx.bounds)
|
||||
second = ctx.fit_text("CACHED", ctx.bounds)
|
||||
assert first is second
|
||||
ctx.clear_cache()
|
||||
assert ctx.fit_text("CACHED", ctx.bounds) is not first
|
||||
|
||||
def test_fit_text_proportional_tracks_design_scale(self, font_manager):
|
||||
# design size 128x32, base_size_px=10 (a typical classic score size):
|
||||
# at 2x scale the target is 20px -> nearest LADDER_ARCADE rung <= 20
|
||||
# is 16px, not the largest that merely fits the box (32).
|
||||
ctx = LayoutContext(256, 64, font_manager) # scale = min(2,2) = 2
|
||||
fit = ctx.fit_text_proportional("17-21", ctx.bounds, base_size_px=10,
|
||||
ladder=LADDER_ARCADE)
|
||||
assert fit.size_px == 16
|
||||
|
||||
def test_fit_text_proportional_does_not_exceed_max_fit(self, ctx):
|
||||
# at scale=1 (128x32, the design size itself) the target equals
|
||||
# base_size_px, so proportional should never pick something LARGER
|
||||
# than plain fit_text would for the same box.
|
||||
prop = ctx.fit_text_proportional("17-21", ctx.bounds, base_size_px=10,
|
||||
ladder=LADDER_ARCADE)
|
||||
maxed = ctx.fit_text("17-21", ctx.bounds, ladder=LADDER_ARCADE)
|
||||
assert prop.size_px <= maxed.size_px
|
||||
|
||||
def test_fit_text_proportional_floors_at_smallest_rung(self, font_manager):
|
||||
# scale so small the target is below every rung -> use the smallest
|
||||
# rung as a floor rather than refusing to render anything.
|
||||
ctx = LayoutContext(32, 8, font_manager) # scale = min(32/128, 8/32) = 0.25
|
||||
fit = ctx.fit_text_proportional("HI", ctx.bounds, base_size_px=10,
|
||||
ladder=LADDER_ARCADE)
|
||||
assert fit.size_px == min(s.size_px for s in LADDER_ARCADE)
|
||||
|
||||
def test_fit_text_proportional_falls_through_when_target_rung_overflows(self, font_manager):
|
||||
# a long string at the target rung might not fit a narrow box even
|
||||
# though the target size is "correct" -- must fall through to a
|
||||
# smaller rung exactly like fit_text does, not just refuse to fit.
|
||||
ctx = LayoutContext(256, 64, font_manager)
|
||||
narrow_box = Region(0, 0, 40, 64)
|
||||
fit = ctx.fit_text_proportional("A REALLY LONG STRING HERE", narrow_box,
|
||||
base_size_px=10, ladder=LADDER_ARCADE)
|
||||
assert fit.fits or fit.text.endswith("…")
|
||||
|
||||
def test_fit_text_proportional_cached(self, ctx):
|
||||
first = ctx.fit_text_proportional("X", ctx.bounds, base_size_px=10)
|
||||
second = ctx.fit_text_proportional("X", ctx.bounds, base_size_px=10)
|
||||
assert first is second
|
||||
|
||||
def test_fit_text_proportional_scale_override(self, font_manager):
|
||||
# 128x64 vs design 128x32: self.scale (min of both axes) is 1.0
|
||||
# since width didn't grow, but a caller whose composition scales by
|
||||
# HEIGHT alone (e.g. logo_slot = min(h, w//2)) should be able to
|
||||
# override the reference scale so text grows with it too.
|
||||
ctx = LayoutContext(128, 64, font_manager)
|
||||
assert ctx.scale == 1.0
|
||||
default_fit = ctx.fit_text_proportional("17-21", ctx.bounds, base_size_px=10,
|
||||
ladder=LADDER_ARCADE)
|
||||
height_scale = 64 / 32 # matches design height
|
||||
scaled_fit = ctx.fit_text_proportional("17-21", ctx.bounds, base_size_px=10,
|
||||
ladder=LADDER_ARCADE, scale=height_scale)
|
||||
assert scaled_fit.size_px > default_fit.size_px
|
||||
|
||||
def test_fit_lines_stacks_within_height(self, ctx):
|
||||
box = ctx.bounds
|
||||
lines = ["LINE ONE", "LINE TWO", "LINE THREE"]
|
||||
fit = ctx.fit_lines(lines, box, spacing=1)
|
||||
assert fit.fits
|
||||
assert 3 * fit.line_height + 2 <= box.h
|
||||
|
||||
def test_font_for_rows(self, ctx):
|
||||
fit = ctx.font_for_rows(4, 32)
|
||||
assert fit.fits
|
||||
assert 4 * fit.line_height <= 32
|
||||
|
||||
def test_ellipsize_returns_original_when_it_fits(self, ctx):
|
||||
font = ctx.font_manager.get_font("4x6", 6)
|
||||
assert ctx.ellipsize("HI", font, 1000) == "HI"
|
||||
|
||||
|
||||
class TestDrawFittedText:
|
||||
def test_draws_within_region(self, ctx):
|
||||
calls = []
|
||||
|
||||
class _DM:
|
||||
def draw_text(self, text, x=None, y=None, color=None, font=None):
|
||||
calls.append((text, x, y))
|
||||
|
||||
box = Region(10, 4, 100, 24)
|
||||
fit = ctx.fit_text("SCORE", box)
|
||||
draw_fitted_text(_DM(), fit, box)
|
||||
text, x, y = calls[0]
|
||||
assert text == "SCORE"
|
||||
assert box.x <= x <= box.right - fit.width
|
||||
# the ink (y + y_offset .. + height) must land inside the box
|
||||
assert box.y <= y + fit.y_offset
|
||||
assert y + fit.y_offset + fit.height <= box.bottom
|
||||
|
||||
|
||||
class TestBasePluginIntegration:
|
||||
def test_layout_property_and_draw_fit(self):
|
||||
from src.plugin_system.base_plugin import BasePlugin
|
||||
from src.plugin_system.testing.mocks import (
|
||||
MockCacheManager, MockDisplayManager, MockPluginManager,
|
||||
)
|
||||
|
||||
class _Plugin(BasePlugin):
|
||||
def update(self):
|
||||
pass
|
||||
|
||||
def display(self, force_clear=False):
|
||||
pass
|
||||
|
||||
plugin = _Plugin("test-plugin", {}, MockDisplayManager(96, 48),
|
||||
MockCacheManager(), MockPluginManager())
|
||||
assert (plugin.layout.width, plugin.layout.height) == (96, 48)
|
||||
assert plugin.layout is plugin.layout # cached
|
||||
fit = plugin.draw_fit("HELLO", plugin.layout.bounds.inset(1))
|
||||
assert fit.fits
|
||||
assert plugin.display_manager.draw_calls # actually drew
|
||||
|
||||
def test_layout_rebuilds_on_size_change(self):
|
||||
from src.plugin_system.base_plugin import BasePlugin
|
||||
from src.plugin_system.testing.mocks import (
|
||||
MockCacheManager, MockDisplayManager, MockPluginManager,
|
||||
)
|
||||
|
||||
class _Plugin(BasePlugin):
|
||||
def update(self):
|
||||
pass
|
||||
|
||||
def display(self, force_clear=False):
|
||||
pass
|
||||
|
||||
dm = MockDisplayManager(128, 32)
|
||||
plugin = _Plugin("test-plugin", {}, dm,
|
||||
MockCacheManager(), MockPluginManager())
|
||||
assert plugin.layout.tier == "sm"
|
||||
dm.width, dm.height = 128, 64
|
||||
assert plugin.layout.tier == "lg"
|
||||
|
||||
def test_design_size_from_manifest(self):
|
||||
from src.plugin_system.base_plugin import BasePlugin
|
||||
from src.plugin_system.testing.mocks import (
|
||||
MockCacheManager, MockDisplayManager, MockPluginManager,
|
||||
)
|
||||
|
||||
class _Plugin(BasePlugin):
|
||||
def update(self):
|
||||
pass
|
||||
|
||||
def display(self, force_clear=False):
|
||||
pass
|
||||
|
||||
pm = MockPluginManager()
|
||||
pm.plugin_manifests["test-plugin"] = {
|
||||
"display": {"design_size": {"width": 64, "height": 32}}
|
||||
}
|
||||
plugin = _Plugin("test-plugin", {}, MockDisplayManager(128, 64),
|
||||
MockCacheManager(), pm)
|
||||
assert plugin.layout.design_size == (64, 32)
|
||||
assert plugin.layout.scale == 2.0
|
||||
|
||||
|
||||
class TestFitCacheBound:
|
||||
def test_fit_cache_is_lru_bounded(self, ctx):
|
||||
"""A plugin fitting changing text (live game clock, ticker) on a
|
||||
24/7 service must not grow the fit cache without bound."""
|
||||
for i in range(ctx._FIT_CACHE_MAX + 100):
|
||||
ctx.fit_text(f"tick {i}", Region(0, 0, 100, 20))
|
||||
assert len(ctx._fit_cache) <= ctx._FIT_CACHE_MAX
|
||||
|
||||
def test_lru_keeps_recent_entries_hot(self, ctx):
|
||||
hot = ctx.fit_text("stay hot", Region(0, 0, 100, 20))
|
||||
for i in range(ctx._FIT_CACHE_MAX - 1):
|
||||
ctx.fit_text(f"cold {i}", Region(0, 0, 100, 20))
|
||||
ctx.fit_text("stay hot", Region(0, 0, 100, 20)) # keep touching it
|
||||
assert ctx.fit_text("stay hot", Region(0, 0, 100, 20)) is hot
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Tests for the harness fill / scale-up check (src/plugin_system/testing/harness.py)."""
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from src.plugin_system.testing.harness import (
|
||||
RenderResult,
|
||||
check_scale_up,
|
||||
fill_metrics,
|
||||
)
|
||||
|
||||
|
||||
def _canvas(w, h):
|
||||
return Image.new("RGB", (w, h), (0, 0, 0))
|
||||
|
||||
|
||||
def _with_block(w, h, bx, by, bw, bh, color=(255, 255, 255)):
|
||||
img = _canvas(w, h)
|
||||
img.paste(Image.new("RGB", (bw, bh), color), (bx, by))
|
||||
return img
|
||||
|
||||
|
||||
def _result(w, h, image):
|
||||
return RenderResult("p", w, h, "mode", image=image)
|
||||
|
||||
|
||||
class TestFillMetrics:
|
||||
def test_full_white(self):
|
||||
ex, ey, ink = fill_metrics(Image.new("RGB", (64, 32), (255, 255, 255)))
|
||||
assert (ex, ey, ink) == (1.0, 1.0, 1.0)
|
||||
|
||||
def test_black_is_empty(self):
|
||||
assert fill_metrics(_canvas(64, 32)) == (0.0, 0.0, 0.0)
|
||||
|
||||
def test_corner_dot(self):
|
||||
ex, ey, ink = fill_metrics(_with_block(100, 100, 0, 0, 10, 10))
|
||||
assert ex == 0.1 and ey == 0.1
|
||||
assert ink == 0.01
|
||||
|
||||
def test_centered_half(self):
|
||||
ex, ey, _ = fill_metrics(_with_block(100, 100, 25, 25, 50, 50))
|
||||
assert ex == 0.5 and ey == 0.5
|
||||
|
||||
def test_dim_pixels_ignored(self):
|
||||
img = _canvas(10, 10)
|
||||
img.putpixel((5, 5), (10, 10, 10)) # below the lit threshold
|
||||
assert fill_metrics(img) == (0.0, 0.0, 0.0)
|
||||
|
||||
|
||||
class TestCheckScaleUp:
|
||||
def test_not_checked_below_2x(self):
|
||||
# 128x64 vs design 128x32: only height is 2x -> checked on y only;
|
||||
# 128x32 itself: not checked at all
|
||||
r = _result(128, 32, _with_block(128, 32, 0, 0, 10, 10))
|
||||
check_scale_up([r], design_size=(128, 32))
|
||||
assert not r.fill_checked
|
||||
|
||||
def test_warn_mode_records_but_passes(self):
|
||||
# tiny corner content on a 256x128 (2x both axes)
|
||||
r = _result(256, 128, _with_block(256, 128, 0, 0, 20, 20))
|
||||
check_scale_up([r], design_size=(128, 32), strict=False)
|
||||
assert r.fill_checked
|
||||
assert r.fill_ok is None # warn-only: not a failure
|
||||
assert r.ok # still passes
|
||||
assert r.fill_extent[0] < 0.5
|
||||
|
||||
def test_strict_mode_fails_underfill(self):
|
||||
r = _result(256, 128, _with_block(256, 128, 0, 0, 20, 20))
|
||||
check_scale_up([r], design_size=(128, 32), strict=True)
|
||||
assert r.fill_ok is False
|
||||
assert not r.ok
|
||||
|
||||
def test_well_filled_passes_strict(self):
|
||||
r = _result(256, 128, _with_block(256, 128, 10, 10, 200, 100))
|
||||
check_scale_up([r], design_size=(128, 32), strict=True)
|
||||
assert r.fill_ok is True and r.ok
|
||||
|
||||
def test_axis_selection_wide_only(self):
|
||||
# 256x32 vs design 128x32: width is 2x, height is not -> only the
|
||||
# x-extent matters; content spanning full width but few rows passes
|
||||
r = _result(256, 32, _with_block(256, 32, 0, 12, 250, 8))
|
||||
check_scale_up([r], design_size=(128, 32), strict=True)
|
||||
assert r.fill_ok is True
|
||||
|
||||
def test_axis_selection_wide_only_underfill(self):
|
||||
r = _result(256, 32, _with_block(256, 32, 0, 12, 60, 8))
|
||||
check_scale_up([r], design_size=(128, 32), strict=True)
|
||||
assert r.fill_ok is False
|
||||
|
||||
def test_errored_render_skipped(self):
|
||||
r = RenderResult("p", 256, 128, "m", error="boom")
|
||||
check_scale_up([r], design_size=(128, 32), strict=True)
|
||||
assert not r.fill_checked
|
||||
|
||||
def test_custom_design_size(self):
|
||||
# 128x64 with design 64x32 IS 2x both axes
|
||||
r = _result(128, 64, _with_block(128, 64, 0, 0, 10, 10))
|
||||
check_scale_up([r], design_size=(64, 32), strict=False)
|
||||
assert r.fill_checked
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Tests for the plugin loader's advisory version-compatibility warning."""
|
||||
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
from src.plugin_system.plugin_loader import PluginLoader
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def loader():
|
||||
return PluginLoader(logger=logging.getLogger("test-loader"))
|
||||
|
||||
|
||||
def _warnings(caplog):
|
||||
return [r for r in caplog.records if r.levelno == logging.WARNING]
|
||||
|
||||
|
||||
class TestParseSemver:
|
||||
def test_basic(self, loader):
|
||||
assert loader._parse_semver("3.1.0") == (3, 1, 0)
|
||||
assert loader._parse_semver("v2.0") == (2, 0, 0)
|
||||
assert loader._parse_semver("2.0.0-beta.1") == (2, 0, 0)
|
||||
|
||||
def test_unparseable(self, loader):
|
||||
assert loader._parse_semver(None) is None
|
||||
assert loader._parse_semver(123) is None
|
||||
|
||||
|
||||
class TestWarnIfIncompatible:
|
||||
def test_warns_when_plugin_needs_newer_core(self, loader, caplog, monkeypatch):
|
||||
import src
|
||||
monkeypatch.setattr(src, "__version__", "3.1.0")
|
||||
with caplog.at_level(logging.WARNING, logger="test-loader"):
|
||||
loader._warn_if_incompatible("p", {"min_ledmatrix_version": "9.0.0"})
|
||||
assert len(_warnings(caplog)) == 1
|
||||
assert "9.0.0" in _warnings(caplog)[0].message
|
||||
|
||||
def test_silent_when_compatible(self, loader, caplog, monkeypatch):
|
||||
import src
|
||||
monkeypatch.setattr(src, "__version__", "3.1.0")
|
||||
with caplog.at_level(logging.WARNING, logger="test-loader"):
|
||||
loader._warn_if_incompatible("p", {"min_ledmatrix_version": "2.0.0"})
|
||||
assert not _warnings(caplog)
|
||||
|
||||
def test_silent_when_field_absent(self, loader, caplog):
|
||||
with caplog.at_level(logging.WARNING, logger="test-loader"):
|
||||
loader._warn_if_incompatible("p", {"name": "no version fields"})
|
||||
assert not _warnings(caplog)
|
||||
|
||||
def test_reads_requires_and_versions_spellings(self, loader, caplog, monkeypatch):
|
||||
import src
|
||||
monkeypatch.setattr(src, "__version__", "3.1.0")
|
||||
with caplog.at_level(logging.WARNING, logger="test-loader"):
|
||||
loader._warn_if_incompatible(
|
||||
"a", {"requires": {"min_ledmatrix_version": "9.0.0"}})
|
||||
loader._warn_if_incompatible(
|
||||
"b", {"versions": [{"ledmatrix_min_version": "9.0.0"}]})
|
||||
loader._warn_if_incompatible(
|
||||
"c", {"versions": [{"ledmatrix_min": "9.0.0"}]})
|
||||
assert len(_warnings(caplog)) == 3
|
||||
|
||||
def test_stale_core_version_skips_comparison(self, loader, caplog, monkeypatch):
|
||||
# Anti-spam guard: a core whose __version__ is below the ecosystem
|
||||
# floor must not warn about every plugin.
|
||||
import src
|
||||
monkeypatch.setattr(src, "__version__", "1.0.0")
|
||||
with caplog.at_level(logging.WARNING, logger="test-loader"):
|
||||
loader._warn_if_incompatible("p", {"min_ledmatrix_version": "2.0.0"})
|
||||
assert not _warnings(caplog)
|
||||
Reference in New Issue
Block a user