Compare commits

..
Author SHA1 Message Date
ChuckBuildsandClaude Sonnet 5 131a913017 fix(testing): clear get_cached_data_with_strategy_calls in MockCacheManager.reset()
reset() cleared get_calls/set_calls/delete_calls but not the newer
get_cached_data_with_strategy_calls tracker, so a reused mock (e.g. across
test cases sharing a fixture) retained stale strategy-call records after
reset().

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
2026-07-14 08:25:27 -04:00
ChuckBuilds ee5df2a321 fix(testing): add MockCacheManager.get_cached_data_with_strategy/save_cache
ledmatrix-leaderboard's data_fetcher.py calls these two real-CacheManager
methods (src/cache_manager.py:313,817), but MockCacheManager had neither --
update() always hit an AttributeError, caught by a broad except and logged,
so the harness rendered an empty-but-green leaderboard on every test run
without ever exercising real standings data.

Both mocks delegate to the existing get()/set() -- a mock doesn't need the
real strategy's per-data-type max_age/market-hours timing, plugins under
test just need the methods to exist and round-trip whatever was cached.
2026-07-12 20:17:18 -04:00
6edd80d9f3 fix(schedule): stop stray 'days' data from overriding Global schedule (#399)
save_schedule_config never persisted the schedule's 'mode' field, and
_check_schedule inferred per-day vs global purely from whether a 'days'
dict was present for the current day. Config migration
(_merge_template_defaults) re-adds the template's 'schedule.days' (all
days disabled by default) whenever it's missing from the user's saved
config - which is exactly the case after saving Global mode, since that
save path intentionally pops 'days'. The result: a user on Global mode
would get their schedule silently reinterpreted as per-day, with today's
day disabled, blanking the display.

Persist 'mode' on save and have _check_schedule honor it explicitly
(mirroring how _check_dim_schedule already does), so a resurrected
'days' dict can't override an explicit Global selection. Falls back to
the old inference behavior only when no 'mode' is recorded.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-12 10:53:33 -04:00
1c7a0cef66 fix(vegas): lock plugin_last_update snapshot/diff against concurrent mutation (#398)
* fix(vegas): restore live plugin-update refresh dropped by sync refactor

Investigating a user report that Vegas scroll mode doesn't update scores
or game status. Root cause: PR #299 (Mar 28) added a mechanism so a live
score change reached the ticker within a few seconds instead of waiting
for a full scroll cycle -- _tick_plugin_updates_for_vegas() diffed
plugin_last_update timestamps to detect which plugins got fresh data and
called coordinator.mark_plugin_updated() for each, and should_recompose()
checked has_pending_updates_for_visible_segments() to trigger an immediate
hot-swap.

PR #330 (May 14, multi-display wireless sync) refactored both call sites
while adding sync support and silently deleted this entire mechanism --
not just gated it behind the new sync-mode deferral it legitimately
needed, but removed it outright. The result: VegasModeCoordinator.
mark_plugin_updated() and StreamManager.has_pending_updates_for_visible_
segments() have been fully implemented but never called from anywhere
since. Vegas mode's only remaining freshness sources are a 5s content
cache TTL (fine) and full recompose at cycle boundaries, which depending
on min/max_cycle_duration can be minutes away -- so live scores/status
can sit stale far longer than a user would expect from a "live" ticker.

Fix:
- Restored _tick_plugin_updates_for_vegas() in display_controller.py,
  wired as the Vegas coordinator's update callback in place of the plain
  _tick_plugin_updates(). Diffs plugin_last_update before/after the tick
  and calls vegas_coordinator.mark_plugin_updated(plugin_id) for each
  plugin that actually got new data (rather than returning the list, since
  the callback interface no longer consumes a return value).
- Restored the has_pending_updates_for_visible_segments() check in
  render_pipeline.should_recompose(), positioned after (not instead of)
  the sync-mode early return PR #330 added, so standalone installations
  regain immediate refresh while synced leader/follower pairs correctly
  keep deferring hot-swaps to cycle boundaries as PR #330 intended.

Test plan:
- Added test_display_controller_vegas_tick.py and
  test_vegas_render_pipeline_recompose.py -- neither area had any prior
  test coverage, which is very likely why this regression went unnoticed
  for ~2.5 months.
- Verified both new test files fail against the pre-fix code (swapped in
  the current main versions of both files) with exactly the expected
  errors -- AttributeError for the deleted method, and the recompose
  assertion returning False instead of True -- then pass against the fix.
- Confirmed the sync-mode deferral this restoration must not break still
  holds: test_sync_active_defers_pending_updates_to_cycle_boundary.
- Full related suite (test_vegas_plugin_adapter, test_vegas_config,
  test_display_controller_plugin_toggle, test_display_controller_
  optimizations, test_plugin_system): 108 passed, 1 pre-existing failure
  unrelated to this change (test_circuit_breaker, stale mock signature).
- Full CI plugin-safety suite (test_harness, test_visual_rendering,
  test_plugin_matrix): 52 passed, 2 pre-existing skips.

* fix(vegas): lock plugin_last_update snapshot/diff against concurrent mutation

_tick_plugin_updates_for_vegas() snapshotted and later re-iterated
plugin_manager.plugin_last_update from the Vegas background update-tick
thread while the main render loop (or other callers) could mutate the
same dict concurrently — a real race (unprotected dict iteration/mutation
across threads), not just a style nit.

Move the snapshot/update/diff into a new locked
PluginManager.run_scheduled_updates_with_changes() so all reads and
mutations of plugin_last_update happen under one lock, and update
DisplayController to use it. The lock is only held around the dict
accesses, not the update pass itself, so slow plugin update() calls don't
serialize against other callers.

Also add a regression test covering that the Vegas coordinator is wired
to the Vegas-aware tick callback rather than the plain one.

Skipped as not worth the change:
- Narrowing the broad `except Exception` around
  vc.mark_plugin_updated(plugin_id) to specific types: it's a deliberate
  per-plugin isolation boundary (matches the same pattern used elsewhere
  in this file for plugin/coordinator calls) and there's no documented,
  stable set of exceptions that call can raise to narrow to.
- Adding an inactive-DisplaySyncManager test to
  test_vegas_render_pipeline_recompose.py: verified
  VegasModeCoordinator.set_sync_manager() already normalizes a
  SyncRole.STANDALONE manager to None before handing it to the render
  pipeline (src/vegas_mode/coordinator.py:152-156), so should_recompose()'s
  `is not None` check is correct in practice; the suggested case is
  already covered by that normalization.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-12 10:52:18 -04:00
ChuckandGitHub 6052a60d22 fix(vegas): restore live plugin-update refresh dropped by sync refactor (#395)
Investigating a user report that Vegas scroll mode doesn't update scores
or game status. Root cause: PR #299 (Mar 28) added a mechanism so a live
score change reached the ticker within a few seconds instead of waiting
for a full scroll cycle -- _tick_plugin_updates_for_vegas() diffed
plugin_last_update timestamps to detect which plugins got fresh data and
called coordinator.mark_plugin_updated() for each, and should_recompose()
checked has_pending_updates_for_visible_segments() to trigger an immediate
hot-swap.

PR #330 (May 14, multi-display wireless sync) refactored both call sites
while adding sync support and silently deleted this entire mechanism --
not just gated it behind the new sync-mode deferral it legitimately
needed, but removed it outright. The result: VegasModeCoordinator.
mark_plugin_updated() and StreamManager.has_pending_updates_for_visible_
segments() have been fully implemented but never called from anywhere
since. Vegas mode's only remaining freshness sources are a 5s content
cache TTL (fine) and full recompose at cycle boundaries, which depending
on min/max_cycle_duration can be minutes away -- so live scores/status
can sit stale far longer than a user would expect from a "live" ticker.

Fix:
- Restored _tick_plugin_updates_for_vegas() in display_controller.py,
  wired as the Vegas coordinator's update callback in place of the plain
  _tick_plugin_updates(). Diffs plugin_last_update before/after the tick
  and calls vegas_coordinator.mark_plugin_updated(plugin_id) for each
  plugin that actually got new data (rather than returning the list, since
  the callback interface no longer consumes a return value).
- Restored the has_pending_updates_for_visible_segments() check in
  render_pipeline.should_recompose(), positioned after (not instead of)
  the sync-mode early return PR #330 added, so standalone installations
  regain immediate refresh while synced leader/follower pairs correctly
  keep deferring hot-swaps to cycle boundaries as PR #330 intended.

Test plan:
- Added test_display_controller_vegas_tick.py and
  test_vegas_render_pipeline_recompose.py -- neither area had any prior
  test coverage, which is very likely why this regression went unnoticed
  for ~2.5 months.
- Verified both new test files fail against the pre-fix code (swapped in
  the current main versions of both files) with exactly the expected
  errors -- AttributeError for the deleted method, and the recompose
  assertion returning False instead of True -- then pass against the fix.
- Confirmed the sync-mode deferral this restoration must not break still
  holds: test_sync_active_defers_pending_updates_to_cycle_boundary.
- Full related suite (test_vegas_plugin_adapter, test_vegas_config,
  test_display_controller_plugin_toggle, test_display_controller_
  optimizations, test_plugin_system): 108 passed, 1 pre-existing failure
  unrelated to this change (test_circuit_breaker, stale mock signature).
- Full CI plugin-safety suite (test_harness, test_visual_rendering,
  test_plugin_matrix): 52 passed, 2 pre-existing skips.
2026-07-12 10:40:34 -04:00
7f7f0d6464 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>
2026-07-12 10:38:52 -04:00
14 changed files with 432 additions and 52 deletions
+28 -18
View File
@@ -236,13 +236,15 @@ def _render_once(plugin_id, plugin_dir, manifest, config, mock_data, width, heig
try: try:
plugin_instance.update() plugin_instance.update()
except Exception as e: except Exception as e:
warnings.append(f"update() raised: {e}") logger.warning("update() raised for plugin %s", plugin_id, exc_info=True)
warnings.append(f"update() raised: {type(e).__name__} — see server log")
# Run display() # Run display()
try: try:
plugin_instance.display(force_clear=True) plugin_instance.display(force_clear=True)
except Exception as e: except Exception as e:
errors.append(f"display() raised: {e}") logger.warning("display() raised for plugin %s", plugin_id, exc_info=True)
errors.append(f"display() raised: {type(e).__name__} — see server log")
render_time_ms = round((time.time() - start_time) * 1000, 1) render_time_ms = round((time.time() - start_time) * 1000, 1)
@@ -259,20 +261,25 @@ def _render_once(plugin_id, plugin_dir, manifest, config, mock_data, width, heig
def _trusted_plugin_dir(plugin_dir: Path) -> Optional[Path]: def _trusted_plugin_dir(plugin_dir: Path) -> Optional[Path]:
"""Re-derive a plugin directory from the search dirs' own listings. """Re-derive a plugin directory from the search dirs' own listings.
Path-injection barrier: the returned Path is constructed purely from Path-injection barrier: unlike ``Path.iterdir()`` (which CodeQL doesn't
trusted directory enumeration (``iterdir``) — request-derived strings recognize as a taint-clearing enumeration), ``os.scandir()`` is. The
returned Path is built from a trusted root plus a name the filesystem
itself produced under that root via scandir — request-derived strings
never enter its construction — so a crafted plugin id can never make never enter its construction — so a crafted plugin id can never make
downstream file access leave the plugin search dirs. Comparison is by downstream file access leave the plugin search dirs. Comparison is by
path equality, deliberately without symlink resolution (dev plugins name, deliberately without symlink resolution (dev plugins are
are commonly symlinked into plugins/). commonly symlinked into plugins/).
""" """
wanted = Path(os.path.normpath(str(plugin_dir))) wanted_name = Path(os.path.normpath(str(plugin_dir))).name
for search_dir in get_search_dirs(): for search_dir in get_search_dirs():
if not search_dir.is_dir(): search_dir_str = str(search_dir)
try:
with os.scandir(search_dir_str) as entries:
for entry in entries:
if entry.name == wanted_name and entry.is_dir():
return Path(search_dir_str) / entry.name
except OSError:
continue continue
for entry in search_dir.iterdir():
if entry.is_dir() and entry == wanted:
return entry
return None return None
@@ -280,22 +287,25 @@ def _parse_render_request(data):
"""Shared /api/render* request prep. Returns (plugin_dir, manifest, config, """Shared /api/render* request prep. Returns (plugin_dir, manifest, config,
mock_data, skip_update) or raises ValueError with a client message.""" mock_data, skip_update) or raises ValueError with a client message."""
plugin_id = data['plugin_id'] plugin_id = data['plugin_id']
plugin_dir = find_plugin_dir(plugin_id) candidate_dir = find_plugin_dir(plugin_id)
if plugin_dir: # Never reuse `candidate_dir` past this point: it's built from
plugin_dir = _trusted_plugin_dir(plugin_dir) # request-derived input, and a variable reassigned only on some paths
if not plugin_dir: # isn't a barrier CodeQL's flow analysis honors. `trusted_dir` is the
# sole name used below, always the scandir-sourced result.
trusted_dir = _trusted_plugin_dir(candidate_dir) if candidate_dir else None
if not trusted_dir:
raise LookupError(f'Plugin not found: {plugin_id}') raise LookupError(f'Plugin not found: {plugin_id}')
manifest_path = plugin_dir / 'manifest.json' manifest_path = trusted_dir / 'manifest.json'
with open(manifest_path, 'r') as f: with open(manifest_path, 'r') as f:
manifest = json.load(f) manifest = json.load(f)
# Build config: schema defaults + user overrides # Build config: schema defaults + user overrides
config = {'enabled': True} config = {'enabled': True}
config.update(load_config_defaults(plugin_dir)) config.update(load_config_defaults(trusted_dir))
config.update(data.get('config', {})) config.update(data.get('config', {}))
return plugin_dir, manifest, config, data.get('mock_data', {}), data.get('skip_update', False) return trusted_dir, manifest, config, data.get('mock_data', {}), data.get('skip_update', False)
@app.route('/api/render', methods=['POST']) @app.route('/api/render', methods=['POST'])
+10 -2
View File
@@ -25,7 +25,7 @@ uncached primitives.
""" """
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Optional, Tuple, Union from typing import Any, Optional, Tuple
from PIL import Image from PIL import Image
@@ -136,7 +136,15 @@ def fit_image(img: Image.Image, box: Any, *, mode: str = "contain",
scale = min(scale, 1.0) scale = min(scale, 1.0)
out_w = max(1, round(src_w * scale)) out_w = max(1, round(src_w * scale))
out_h = max(1, round(src_h * scale)) out_h = max(1, round(src_h * scale))
out = work if (out_w, out_h) == (src_w, src_h) else work.resize((out_w, out_h), resample) if (out_w, out_h) == (src_w, src_h):
# No resize needed — but `work` may still BE the caller's original
# image (RGBA source, no ink crop). The result must always be an
# independent copy: LayoutContext caches ImageFitResults, and an
# aliased image would let later mutations of the source corrupt
# cached fits (or vice versa).
out = work.copy() if work is img else work
else:
out = work.resize((out_w, out_h), resample)
return ImageFitResult(out, out_w, out_h, scale, mode, (src_w, src_h)) return ImageFitResult(out, out_w, out_h, scale, mode, (src_w, src_h))
+28 -13
View File
@@ -29,7 +29,7 @@ freetype.Face, so it drops straight into DisplayManager.draw_text().
import logging import logging
from collections import OrderedDict from collections import OrderedDict
from dataclasses import dataclass, field from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Sequence, Tuple, Union from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
import freetype import freetype
@@ -328,13 +328,28 @@ class LayoutContext:
# fonts, which step between crisp ladder rungs instead. # fonts, which step between crisp ladder rungs instead.
self.scale = min(self.width / max(1, design_w), self.scale = min(self.width / max(1, design_w),
self.height / max(1, design_h)) self.height / max(1, design_h))
self._fit_cache: Dict[Any, FitResult] = {} # LRU-bounded: entries are small, but keys embed the fitted TEXT —
# LRU-bounded (images are big, unlike text fits). Entries hold a # a plugin fitting changing text (a live game clock, a ticker) on a
# strong reference to the source image when keyed by id() so the id # 24/7 service would otherwise grow this without bound.
# can't be recycled out from under the cache. self._fit_cache: "OrderedDict[Any, FitResult]" = OrderedDict()
# LRU-bounded (images are big). Entries hold a strong reference to
# the source image when keyed by id() so the id can't be recycled
# out from under the cache.
self._image_cache: "OrderedDict[Any, Tuple[Any, Any]]" = OrderedDict() self._image_cache: "OrderedDict[Any, Tuple[Any, Any]]" = OrderedDict()
_IMAGE_CACHE_MAX = 64 _IMAGE_CACHE_MAX = 64
_FIT_CACHE_MAX = 512
def _fit_cache_get(self, key: Any) -> Optional["FitResult"]:
cached = self._fit_cache.get(key)
if cached is not None:
self._fit_cache.move_to_end(key)
return cached
def _fit_cache_put(self, key: Any, result: "FitResult") -> None:
self._fit_cache[key] = result
while len(self._fit_cache) > self._FIT_CACHE_MAX:
self._fit_cache.popitem(last=False)
# ---- the three adaptation patterns -------------------------------- # ---- the three adaptation patterns --------------------------------
@@ -373,11 +388,11 @@ class LayoutContext:
acceptable rendering exists.""" acceptable rendering exists."""
box_w, box_h = _box_dims(box) box_w, box_h = _box_dims(box)
key = ("text", text, box_w, box_h, ladder, ellipsis) key = ("text", text, box_w, box_h, ladder, ellipsis)
cached = self._fit_cache.get(key) cached = self._fit_cache_get(key)
if cached is not None: if cached is not None:
return cached return cached
result = self._walk_ladder(text, ladder, box_w, box_h, ellipsis) result = self._walk_ladder(text, ladder, box_w, box_h, ellipsis)
self._fit_cache[key] = result self._fit_cache_put(key, result)
return result return result
def fit_text_proportional(self, text: str, box: Union[Region, Tuple[int, int]], def fit_text_proportional(self, text: str, box: Union[Region, Tuple[int, int]],
@@ -416,14 +431,14 @@ class LayoutContext:
box_w, box_h = _box_dims(box) box_w, box_h = _box_dims(box)
effective_scale = self.scale if scale is None else scale effective_scale = self.scale if scale is None else scale
key = ("text_prop", text, box_w, box_h, ladder, base_size_px, ellipsis, effective_scale) key = ("text_prop", text, box_w, box_h, ladder, base_size_px, ellipsis, effective_scale)
cached = self._fit_cache.get(key) cached = self._fit_cache_get(key)
if cached is not None: if cached is not None:
return cached return cached
target = base_size_px * effective_scale target = base_size_px * effective_scale
eligible = [step for step in ladder if step.size_px <= target] eligible = [step for step in ladder if step.size_px <= target]
candidates = eligible if eligible else (min(ladder, key=lambda s: s.size_px),) candidates = eligible if eligible else (min(ladder, key=lambda s: s.size_px),)
result = self._walk_ladder(text, candidates, box_w, box_h, ellipsis) result = self._walk_ladder(text, candidates, box_w, box_h, ellipsis)
self._fit_cache[key] = result self._fit_cache_put(key, result)
return result return result
def _walk_ladder(self, text: str, ladder: Sequence[FontStep], def _walk_ladder(self, text: str, ladder: Sequence[FontStep],
@@ -460,7 +475,7 @@ class LayoutContext:
one wouldn't (baseball's multiline pattern). Text is the widest line.""" one wouldn't (baseball's multiline pattern). Text is the widest line."""
box_w, box_h = _box_dims(box) box_w, box_h = _box_dims(box)
key = ("lines", tuple(lines), box_w, box_h, ladder, spacing) key = ("lines", tuple(lines), box_w, box_h, ladder, spacing)
cached = self._fit_cache.get(key) cached = self._fit_cache_get(key)
if cached is not None: if cached is not None:
return cached return cached
@@ -482,7 +497,7 @@ class LayoutContext:
if result.fits: if result.fits:
break break
self._fit_cache[key] = result self._fit_cache_put(key, result)
return result return result
def font_for_rows(self, rows: int, box_h: int, def font_for_rows(self, rows: int, box_h: int,
@@ -491,7 +506,7 @@ class LayoutContext:
(baseball's traditional-scoreboard pattern). Measures a digit/cap (baseball's traditional-scoreboard pattern). Measures a digit/cap
sample rather than specific strings.""" sample rather than specific strings."""
key = ("rows", rows, box_h, ladder) key = ("rows", rows, box_h, ladder)
cached = self._fit_cache.get(key) cached = self._fit_cache_get(key)
if cached is not None: if cached is not None:
return cached return cached
@@ -508,7 +523,7 @@ class LayoutContext:
if result.fits: if result.fits:
break break
self._fit_cache[key] = result self._fit_cache_put(key, result)
return result return result
# ---- images --------------------------------------------------------- # ---- images ---------------------------------------------------------
+56 -7
View File
@@ -502,7 +502,10 @@ class DisplayController:
# Run plugin updates inside the Vegas loop so the inter-iteration # Run plugin updates inside the Vegas loop so the inter-iteration
# gap is <1 ms (nothing left for _tick_plugin_updates() to do). # gap is <1 ms (nothing left for _tick_plugin_updates() to do).
self.vegas_coordinator.set_update_callback(self._tick_plugin_updates) # Use the Vegas-aware variant so plugins that got fresh data are
# hot-swapped into the scroll promptly instead of waiting for the
# next full cycle.
self.vegas_coordinator.set_update_callback(self._tick_plugin_updates_for_vegas)
# Wire multi-display sync into Vegas render pipeline # Wire multi-display sync into Vegas render pipeline
follower_pos = self.config.get("sync", {}).get("follower_position", "left") follower_pos = self.config.get("sync", {}).get("follower_position", "left")
@@ -625,14 +628,24 @@ class DisplayController:
# Check if per-day schedule is configured # Check if per-day schedule is configured
days_config = schedule_config.get('days') days_config = schedule_config.get('days')
# Determine which schedule to use # Determine which schedule to use. Respect an explicit 'mode' field
# (like the dim schedule does) so a stray/legacy 'days' dict left over
# from config migration or a prior per-day setup can't silently
# override a user's Global schedule selection.
mode = schedule_config.get('mode')
mode_normalized = mode.replace('_', '-') if mode else None
use_per_day = False use_per_day = False
if days_config: if mode_normalized == 'global':
# Check if days dict is not empty and contains current day use_per_day = False
if days_config and current_day in days_config: elif mode_normalized == 'per-day':
use_per_day = bool(days_config and current_day in days_config)
elif days_config:
# No explicit mode recorded (legacy config) - fall back to
# inferring from presence of a 'days' dict for the current day.
if current_day in days_config:
use_per_day = True use_per_day = True
elif days_config: else:
# Days dict exists but doesn't have current day - fall back to global
logger.debug("Per-day schedule exists but %s not configured, using global schedule", current_day) logger.debug("Per-day schedule exists but %s not configured, using global schedule", current_day)
if use_per_day: if use_per_day:
@@ -828,6 +841,42 @@ class DisplayController:
if hasattr(self.plugin_manager, 'health_tracker') and self.plugin_manager.health_tracker: if hasattr(self.plugin_manager, 'health_tracker') and self.plugin_manager.health_tracker:
self.plugin_manager.health_tracker.record_failure(plugin_id, exc) self.plugin_manager.health_tracker.record_failure(plugin_id, exc)
def _tick_plugin_updates_for_vegas(self) -> None:
"""Run scheduled plugin updates and tell Vegas mode which plugins
actually got fresh data, so it can hot-swap them into the scroll
without waiting for a full cycle to complete.
Used as the Vegas coordinator's update callback instead of the plain
_tick_plugin_updates() so that a live score change is reflected in
the ticker within a few seconds rather than at the next cycle
boundary (which, depending on min/max_cycle_duration, can be
minutes away). Restores wiring that PR #299 added and PR #330's
sync-mode refactor inadvertently dropped: coordinator.mark_plugin_updated()
has been unreachable dead code since.
Delegates the before/after plugin_last_update snapshot to
PluginManager.run_scheduled_updates_with_changes() so the snapshot,
update pass, and diff are lock-protected against this callback's own
background update-tick thread racing the main render loop.
"""
if not self.plugin_manager or not hasattr(self.plugin_manager, "run_scheduled_updates_with_changes"):
self._tick_plugin_updates()
return
updated = self.plugin_manager.run_scheduled_updates_with_changes()
vc = getattr(self, "vegas_coordinator", None)
if vc is None:
return
if updated:
logger.info("Vegas update tick: %d plugin(s) updated: %s", len(updated), updated)
for plugin_id in updated:
try:
vc.mark_plugin_updated(plugin_id)
except Exception: # pylint: disable=broad-except
logger.exception("Error marking plugin %s updated for Vegas", plugin_id)
def _tick_plugin_updates(self): def _tick_plugin_updates(self):
"""Run scheduled plugin updates if the plugin manager supports them.""" """Run scheduled plugin updates if the plugin manager supports them."""
if not self.plugin_manager: if not self.plugin_manager:
+3 -4
View File
@@ -437,8 +437,7 @@ class PluginLoader:
if not Path(existing_file).resolve().is_relative_to(resolved_dir): if not Path(existing_file).resolve().is_relative_to(resolved_dir):
evicted[mod_name] = sys.modules.pop(mod_name) evicted[mod_name] = sys.modules.pop(mod_name)
self.logger.debug( self.logger.debug(
"Evicted stale module '%s' (from %s) before loading plugin in %s", "Evicted stale bare-name module '%s' before loading plugin", mod_name,
mod_name, existing_file, plugin_dir,
) )
except (ValueError, TypeError): except (ValueError, TypeError):
continue continue
@@ -551,7 +550,7 @@ class PluginLoader:
plugin_dir_str = str(plugin_dir) plugin_dir_str = str(plugin_dir)
if plugin_dir_str not in sys.path: if plugin_dir_str not in sys.path:
sys.path.insert(0, plugin_dir_str) sys.path.insert(0, plugin_dir_str)
self.logger.debug("Added plugin directory to sys.path: %s", plugin_dir_str) self.logger.debug("Added plugin %s's directory to sys.path", plugin_id)
# Import the plugin module # Import the plugin module
module_name = f"plugin_{plugin_id.replace('-', '_')}" module_name = f"plugin_{plugin_id.replace('-', '_')}"
@@ -563,8 +562,8 @@ class PluginLoader:
spec = importlib.util.spec_from_file_location(module_name, entry_file) spec = importlib.util.spec_from_file_location(module_name, entry_file)
if spec is None or spec.loader is None: if spec is None or spec.loader is None:
self.logger.error("Could not create module spec for plugin %s", plugin_id)
error_msg = f"Could not create module spec for {entry_file}" error_msg = f"Could not create module spec for {entry_file}"
self.logger.error(error_msg)
raise PluginError(error_msg, plugin_id=plugin_id, context={'entry_file': str(entry_file)}) raise PluginError(error_msg, plugin_id=plugin_id, context={'entry_file': str(entry_file)})
module = importlib.util.module_from_spec(spec) module = importlib.util.module_from_spec(spec)
+43 -6
View File
@@ -76,6 +76,12 @@ class PluginManager:
# concurrent mutation (background reconciliation) and reads (requests). # concurrent mutation (background reconciliation) and reads (requests).
self._discovery_lock = threading.RLock() self._discovery_lock = threading.RLock()
# Lock protecting plugin_last_update from concurrent mutation/iteration.
# It's written from run_scheduled_updates()/update_all_plugins() (main
# loop) and read/diffed by run_scheduled_updates_with_changes(), which
# Vegas mode calls from its own background update-tick thread.
self._plugin_last_update_lock = threading.RLock()
# Active plugins # Active plugins
self.plugins: Dict[str, Any] = {} self.plugins: Dict[str, Any] = {}
self.plugin_manifests: Dict[str, Dict[str, Any]] = {} self.plugin_manifests: Dict[str, Dict[str, Any]] = {}
@@ -317,7 +323,8 @@ class PluginManager:
# Store plugin instance # Store plugin instance
self.plugins[plugin_id] = plugin_instance self.plugins[plugin_id] = plugin_instance
self.plugin_last_update[plugin_id] = 0.0 with self._plugin_last_update_lock:
self.plugin_last_update[plugin_id] = 0.0
# Invalidate cached interval so next tick re-derives it for this plugin # Invalidate cached interval so next tick re-derives it for this plugin
self._update_interval_cache.pop(plugin_id, None) self._update_interval_cache.pop(plugin_id, None)
@@ -429,7 +436,8 @@ class PluginManager:
# Remove from active plugins # Remove from active plugins
del self.plugins[plugin_id] del self.plugins[plugin_id]
self.plugin_last_update.pop(plugin_id, None) with self._plugin_last_update_lock:
self.plugin_last_update.pop(plugin_id, None)
self._update_interval_cache.pop(plugin_id, None) self._update_interval_cache.pop(plugin_id, None)
# Remove main module from sys.modules if present # Remove main module from sys.modules if present
@@ -698,7 +706,8 @@ class PluginManager:
'recoverable': True, 'recoverable': True,
} }
self.logger.warning("Plugin %s update() failed; will retry after interval", plugin_id) self.logger.warning("Plugin %s update() failed; will retry after interval", plugin_id)
self.plugin_last_update[plugin_id] = failure_time with self._plugin_last_update_lock:
self.plugin_last_update[plugin_id] = failure_time
self.state_manager.set_state_with_error(plugin_id, PluginState.ENABLED, error_info, error=err) self.state_manager.set_state_with_error(plugin_id, PluginState.ENABLED, error_info, error=err)
if self.health_tracker: if self.health_tracker:
self.health_tracker.record_failure(plugin_id, err) self.health_tracker.record_failure(plugin_id, err)
@@ -731,7 +740,8 @@ class PluginManager:
if interval is None: if interval is None:
continue continue
last_update = self.plugin_last_update.get(plugin_id, 0.0) with self._plugin_last_update_lock:
last_update = self.plugin_last_update.get(plugin_id, 0.0)
if last_update == 0.0 or (current_time - last_update) >= interval: if last_update == 0.0 or (current_time - last_update) >= interval:
# Update state to RUNNING # Update state to RUNNING
@@ -762,7 +772,8 @@ class PluginManager:
success = self.plugin_executor.execute_update(plugin_instance, plugin_id) success = self.plugin_executor.execute_update(plugin_instance, plugin_id)
if success: if success:
self.plugin_last_update[plugin_id] = current_time with self._plugin_last_update_lock:
self.plugin_last_update[plugin_id] = current_time
self.state_manager.record_update(plugin_id) self.state_manager.record_update(plugin_id)
# Update state back to ENABLED # Update state back to ENABLED
self.state_manager.set_state(plugin_id, PluginState.ENABLED) self.state_manager.set_state(plugin_id, PluginState.ENABLED)
@@ -775,6 +786,31 @@ class PluginManager:
self.logger.exception("Error updating plugin %s: %s", plugin_id, exc) self.logger.exception("Error updating plugin %s: %s", plugin_id, exc)
self._record_update_failure(plugin_id, exc=exc) self._record_update_failure(plugin_id, exc=exc)
def run_scheduled_updates_with_changes(self, current_time: Optional[float] = None) -> List[str]:
"""
Like run_scheduled_updates(), but also returns the plugin_ids whose
plugin_last_update timestamp actually advanced during this call.
The before/after snapshots and the update pass itself are each
individually lock-protected against concurrent plugin_last_update
mutation (Vegas mode calls this from its own background
update-tick thread, racing the main render loop's plugin updates),
so callers get an atomic "who got fresh data" answer without
reaching into plugin_last_update themselves. The lock is not held
across the update pass so slow/blocking plugin update() calls don't
serialize against other plugin_last_update readers.
"""
with self._plugin_last_update_lock:
old_times = dict(self.plugin_last_update)
self.run_scheduled_updates(current_time)
with self._plugin_last_update_lock:
return [
plugin_id for plugin_id, new_time in self.plugin_last_update.items()
if new_time > old_times.get(plugin_id, 0.0)
]
def update_all_plugins(self) -> None: def update_all_plugins(self) -> None:
""" """
Update all enabled plugins. Update all enabled plugins.
@@ -797,7 +833,8 @@ class PluginManager:
try: try:
success = self.plugin_executor.execute_update(plugin_instance, plugin_id) success = self.plugin_executor.execute_update(plugin_instance, plugin_id)
if success: if success:
self.plugin_last_update[plugin_id] = time.time() with self._plugin_last_update_lock:
self.plugin_last_update[plugin_id] = time.time()
self.state_manager.record_update(plugin_id) self.state_manager.record_update(plugin_id)
self.state_manager.set_state(plugin_id, PluginState.ENABLED) self.state_manager.set_state(plugin_id, PluginState.ENABLED)
else: else:
+20
View File
@@ -71,6 +71,7 @@ class MockCacheManager:
self.get_calls = [] self.get_calls = []
self.set_calls = [] self.set_calls = []
self.delete_calls = [] self.delete_calls = []
self.get_cached_data_with_strategy_calls = []
# Real temp dir for plugins that write/read files under cache_dir. # Real temp dir for plugins that write/read files under cache_dir.
# Registered for cleanup so each mock instance doesn't leak a tmp dir. # Registered for cleanup so each mock instance doesn't leak a tmp dir.
self.cache_dir = tempfile.mkdtemp(prefix="ledmatrix-mock-cache-") self.cache_dir = tempfile.mkdtemp(prefix="ledmatrix-mock-cache-")
@@ -108,6 +109,24 @@ class MockCacheManager:
self.delete_calls.append(key) self.delete_calls.append(key)
if key in self._cache: if key in self._cache:
del self._cache[key] del self._cache[key]
def get_cached_data_with_strategy(self, key: str, data_type: str = 'default') -> Optional[Any]:
"""Mock of CacheManager.get_cached_data_with_strategy (src/cache_manager.py).
The real method picks a max_age/memory_ttl strategy per data_type
(and extends it during market-closed hours for market data) before
delegating to get_cached_data(). None of that timing nuance matters
for a mock -- plugins under test just need the method to exist and
return whatever was cached, so this delegates straight to get().
"""
self.get_cached_data_with_strategy_calls.append({'key': key, 'data_type': data_type})
return self.get(key)
def save_cache(self, key: str, data: Any) -> None:
"""Mock of CacheManager.save_cache (src/cache_manager.py) -- the
write-side counterpart to get_cached_data_with_strategy, used by the
same real-CacheManager-oriented plugins. Delegates to set()."""
self.set(key, data)
if key in self._cache_timestamps: if key in self._cache_timestamps:
del self._cache_timestamps[key] del self._cache_timestamps[key]
@@ -118,6 +137,7 @@ class MockCacheManager:
self.get_calls = [] self.get_calls = []
self.set_calls = [] self.set_calls = []
self.delete_calls = [] self.delete_calls = []
self.get_cached_data_with_strategy_calls = []
class MockConfigManager: class MockConfigManager:
+8
View File
@@ -297,6 +297,8 @@ class RenderPipeline:
Returns True when: Returns True when:
- Cycle is complete and we should start fresh - Cycle is complete and we should start fresh
- Staging buffer has new content - Staging buffer has new content
- A plugin currently visible in the scroll has pending updated data
(e.g. a live score changed) standalone (non-sync) mode only
""" """
if self._cycle_complete: if self._cycle_complete:
return True return True
@@ -314,6 +316,12 @@ class RenderPipeline:
if buffer_status['staging_count'] > 0: if buffer_status['staging_count'] > 0:
return True return True
# Trigger recompose when pending updates affect visible segments, so
# live score/status changes reach the display within a few seconds
# instead of waiting for the next full cycle.
if self.stream_manager.has_pending_updates_for_visible_segments():
return True
return False return False
def hot_swap_content(self) -> bool: def hot_swap_content(self) -> bool:
+12
View File
@@ -194,3 +194,15 @@ class TestBasePluginDrawImage:
assert ifit.height == 32 assert ifit.height == 32
# pasted onto the mock's canvas # pasted onto the mock's canvas
assert plugin.display_manager.image.getpixel((16, 16)) != (0, 0, 0) 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)
+16
View File
@@ -436,3 +436,19 @@ class TestBasePluginIntegration:
MockCacheManager(), pm) MockCacheManager(), pm)
assert plugin.layout.design_size == (64, 32) assert plugin.layout.design_size == (64, 32)
assert plugin.layout.scale == 2.0 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,88 @@
"""
Regression tests for DisplayController._tick_plugin_updates_for_vegas().
PR #299 added logic to detect which plugins actually got fresh data on a
scheduled-update tick and notify Vegas mode via
vegas_coordinator.mark_plugin_updated(), so a live score change reaches the
scroll within seconds instead of waiting for a full cycle. PR #330's
multi-display sync refactor deleted this method (folding the callback back
to the plain _tick_plugin_updates(), which reports nothing), silently
orphaning VegasModeCoordinator.mark_plugin_updated() -- it has had zero
callers since.
"""
from typing import Dict, List, Optional
from unittest.mock import MagicMock
from src.display_controller import DisplayController
def _make_controller(updated: Optional[List[str]] = None, vegas_coordinator: Optional[MagicMock] = None) -> DisplayController:
dc = object.__new__(DisplayController)
dc.plugin_manager = MagicMock()
dc.plugin_manager.run_scheduled_updates_with_changes.return_value = list(updated or [])
dc.vegas_coordinator = vegas_coordinator
return dc
class TestTickPluginUpdatesForVegas:
def test_marks_only_plugins_whose_timestamp_advanced(self):
vc = MagicMock()
dc = _make_controller(updated=["stock-news"], vegas_coordinator=vc)
dc._tick_plugin_updates_for_vegas()
vc.mark_plugin_updated.assert_called_once_with("stock-news")
def test_no_advance_marks_nothing(self):
vc = MagicMock()
dc = _make_controller(updated=[], vegas_coordinator=vc)
dc._tick_plugin_updates_for_vegas()
vc.mark_plugin_updated.assert_not_called()
def test_no_vegas_coordinator_does_not_raise(self):
dc = _make_controller(updated=["stock-news"], vegas_coordinator=None)
dc._tick_plugin_updates_for_vegas() # must not raise
def test_mark_plugin_updated_exception_does_not_propagate(self):
"""One plugin's mark_plugin_updated failing must not stop the tick
or crash the update loop it runs in."""
vc = MagicMock()
vc.mark_plugin_updated.side_effect = [RuntimeError("boom"), None]
dc = _make_controller(updated=["a", "b"], vegas_coordinator=vc)
dc._tick_plugin_updates_for_vegas() # must not raise
assert vc.mark_plugin_updated.call_count == 2
class TestVegasCoordinatorCallbackWiring:
def test_initialize_wires_vegas_aware_tick_as_update_callback(self):
"""The Vegas coordinator must be given the Vegas-aware
_tick_plugin_updates_for_vegas as its update callback, not the plain
_tick_plugin_updates() -- that's the exact wiring PR #330 dropped."""
dc = object.__new__(DisplayController)
dc.config = {"display": {"vegas_scroll": {"enabled": True}}, "sync": {}}
dc.display_manager = MagicMock()
dc.plugin_manager = MagicMock()
dc.sync_manager = MagicMock()
dc._check_live_priority = MagicMock()
dc._check_vegas_interrupt = MagicMock(return_value=False)
fake_coordinator = MagicMock()
import src.display_controller as dc_module
original_imported = dc_module._vegas_mode_imported
original_class = dc_module.VegasModeCoordinator
try:
dc_module._vegas_mode_imported = True
dc_module.VegasModeCoordinator = MagicMock(return_value=fake_coordinator)
dc._initialize_vegas_mode()
finally:
dc_module._vegas_mode_imported = original_imported
dc_module.VegasModeCoordinator = original_class
fake_coordinator.set_update_callback.assert_called_once_with(dc._tick_plugin_updates_for_vegas)
+45
View File
@@ -0,0 +1,45 @@
"""
Unit tests for src/plugin_system/testing/mocks.py.
MockCacheManager/MockPluginManager stand in for the real production
managers under the plugin safety harness -- a missing method here isn't a
harness bug in the abstract, it's a plugin silently failing to render
under test (confirmed on ledmatrix-leaderboard, which calls
get_cached_data_with_strategy() and previously hit an AttributeError that
its own broad except swallowed, producing an empty-but-green render).
"""
from src.plugin_system.testing.mocks import MockCacheManager
class TestMockCacheManagerStrategyMethod:
def test_get_cached_data_with_strategy_returns_cached_value(self):
cm = MockCacheManager()
cm.set("standings_nfl", {"teams": ["KC", "BUF"]})
result = cm.get_cached_data_with_strategy("standings_nfl", "sports_live")
assert result == {"teams": ["KC", "BUF"]}
def test_get_cached_data_with_strategy_returns_none_when_missing(self):
cm = MockCacheManager()
assert cm.get_cached_data_with_strategy("missing_key") is None
def test_get_cached_data_with_strategy_defaults_data_type(self):
cm = MockCacheManager()
cm.set("k", "v")
assert cm.get_cached_data_with_strategy("k") == "v"
def test_calls_are_tracked(self):
cm = MockCacheManager()
cm.get_cached_data_with_strategy("k", "sports_live")
assert cm.get_cached_data_with_strategy_calls == [{"key": "k", "data_type": "sports_live"}]
def test_save_cache_is_readable_via_strategy_lookup(self):
cm = MockCacheManager()
cm.save_cache("standings_nfl", {"teams": ["KC", "BUF"]})
assert cm.get_cached_data_with_strategy("standings_nfl") == {"teams": ["KC", "BUF"]}
def test_reset_clears_strategy_call_tracking(self):
cm = MockCacheManager()
cm.get_cached_data_with_strategy("k", "sports_live")
cm.reset()
assert cm.get_cached_data_with_strategy_calls == []
@@ -0,0 +1,72 @@
"""
Regression tests for RenderPipeline.should_recompose()'s pending-updates check.
PR #299 added a check so a plugin's live score/status change (a "pending
update" in StreamManager) triggers a hot-swap within a few seconds instead
of waiting for a full scroll cycle to complete. PR #330 (multi-display sync)
refactored should_recompose() and dropped that check entirely -- not just
gated behind the new sync-mode deferral it added, but removed outright, so
even standalone (non-sync) installations silently lost live-refresh and fell
back to waiting for full cycle boundaries (which, depending on
min/max_cycle_duration, can be minutes).
"""
from unittest.mock import MagicMock
from src.vegas_mode.config import VegasModeConfig
from src.vegas_mode.render_pipeline import RenderPipeline
class FakeDisplayManager:
width = 64
height = 32
def _make_pipeline(sync_manager=None):
stream_manager = MagicMock()
stream_manager.get_buffer_status.return_value = {'staging_count': 0}
pipeline = RenderPipeline(VegasModeConfig(), FakeDisplayManager(), stream_manager)
pipeline.sync_manager = sync_manager
return pipeline, stream_manager
class TestShouldRecompose:
def test_cycle_complete_always_recomposes(self):
pipeline, stream_manager = _make_pipeline()
pipeline._cycle_complete = True
stream_manager.has_pending_updates_for_visible_segments.return_value = False
assert pipeline.should_recompose() is True
def test_no_pending_updates_no_staging_does_not_recompose(self):
pipeline, stream_manager = _make_pipeline()
stream_manager.has_pending_updates_for_visible_segments.return_value = False
assert pipeline.should_recompose() is False
def test_pending_updates_on_visible_segment_triggers_recompose(self):
"""The actual regression: a live-updated plugin currently in view
must trigger a recompose instead of waiting for cycle end."""
pipeline, stream_manager = _make_pipeline()
stream_manager.has_pending_updates_for_visible_segments.return_value = True
assert pipeline.should_recompose() is True
def test_staging_buffer_content_triggers_recompose(self):
pipeline, stream_manager = _make_pipeline()
stream_manager.get_buffer_status.return_value = {'staging_count': 1}
stream_manager.has_pending_updates_for_visible_segments.return_value = False
assert pipeline.should_recompose() is True
def test_sync_active_defers_pending_updates_to_cycle_boundary(self):
"""Sync-mode deferral (PR #330's actual intent) must still hold:
pending updates alone must NOT trigger a mid-cycle hot-swap when a
follower display is attached, since that causes a visible
freeze+jump on the follower. This must keep working after
restoring the non-sync pending-updates check above."""
pipeline, stream_manager = _make_pipeline(sync_manager=MagicMock())
stream_manager.has_pending_updates_for_visible_segments.return_value = True
assert pipeline.should_recompose() is False
def test_sync_active_still_recomposes_on_cycle_complete(self):
pipeline, stream_manager = _make_pipeline(sync_manager=MagicMock())
pipeline._cycle_complete = True
stream_manager.has_pending_updates_for_visible_segments.return_value = True
assert pipeline.should_recompose() is True
+1
View File
@@ -329,6 +329,7 @@ def save_schedule_config():
} }
mode = data.get('mode', 'global') mode = data.get('mode', 'global')
schedule_config['mode'] = mode
if mode == 'global': if mode == 'global':
# Simple global schedule # Simple global schedule