mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-10 13:18:04 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cad279a7d2 |
@@ -149,7 +149,7 @@
|
||||
"min_plugin_width": 8,
|
||||
"lead_in_width": 0,
|
||||
"plugins_per_cycle": 6,
|
||||
"max_plugin_width_ratio": 0.0,
|
||||
"max_plugin_width_ratio": 3.0,
|
||||
"overflow_mode": "rotate",
|
||||
"dynamic_duration_enabled": true,
|
||||
"min_cycle_duration": 60,
|
||||
|
||||
@@ -127,7 +127,7 @@ Read by `src/vegas_mode/config.py` (`VegasScrollConfig.from_config`). See
|
||||
| `min_plugin_width` | int, `8` |
|
||||
| `lead_in_width` | int, `0` |
|
||||
| `plugins_per_cycle` | int, `6` |
|
||||
| `max_plugin_width_ratio` | float, `0.0` |
|
||||
| `max_plugin_width_ratio` | float, `3.0` |
|
||||
| `overflow_mode` | string, `"rotate"` |
|
||||
| `dynamic_duration_enabled` | bool, `true` |
|
||||
| `min_cycle_duration` | int, `60` |
|
||||
|
||||
@@ -41,6 +41,7 @@ from src.plugin_system.testing.loading import ( # noqa: E402
|
||||
)
|
||||
from src.plugin_system.testing.harness import ( # noqa: E402
|
||||
RenderResult, render_plugin_matrix, compare_to_goldens, write_goldens,
|
||||
check_empty_claimed,
|
||||
check_scale_up,
|
||||
)
|
||||
from src.plugin_system.testing.sizes import ( # noqa: E402
|
||||
@@ -115,6 +116,11 @@ def check_one(plugin_id: str, search_dirs: List[str], sizes, mock_data: Dict,
|
||||
declared = load_manifest(plugin_dir).get("display", {}).get("design_size", {})
|
||||
design_size = (int(declared.get("width", 128)), int(declared.get("height", 32)))
|
||||
fill_strict = spec.get("fill_check") == "strict"
|
||||
# A mode that renders nothing without returning False is never skipped by
|
||||
# the display controller, so it holds a blank panel for its whole duration.
|
||||
# Warn-only by default: a scroll mode's first frame is legitimately its
|
||||
# blank scroll-in buffer.
|
||||
empty_strict = spec.get("empty_check") == "strict"
|
||||
|
||||
# Every run: the base config, plus one per harness.json "variant" —
|
||||
# a config overlay with its own golden dir (e.g. adaptive layout mode
|
||||
@@ -142,6 +148,7 @@ def check_one(plugin_id: str, search_dirs: List[str], sizes, mock_data: Dict,
|
||||
compare_to_goldens(results, golden_dir)
|
||||
|
||||
check_scale_up(results, design_size=design_size, strict=fill_strict)
|
||||
check_empty_claimed(results, strict=empty_strict)
|
||||
|
||||
# Tag variant runs so the report and PNG dumps stay distinguishable.
|
||||
if variant_name:
|
||||
@@ -178,6 +185,9 @@ def print_report(all_results: Dict[str, List[RenderResult]]) -> bool:
|
||||
# warn-only underfill: big panel left mostly empty
|
||||
ex, ey = r.fill_extent
|
||||
detail += f" (fill warn: extent {ex:.0%}x{ey:.0%})"
|
||||
if r.empty_claimed and r.empty_ok is None:
|
||||
detail += (f" (empty warn: drew nothing but display() returned"
|
||||
f" {r.display_returned!r}, so the mode is not skipped)")
|
||||
else:
|
||||
everything_ok = False
|
||||
if r.error is not None:
|
||||
@@ -191,6 +201,11 @@ def print_report(all_results: Dict[str, List[RenderResult]]) -> bool:
|
||||
ex, ey = r.fill_extent or (0.0, 0.0)
|
||||
status = "FAIL"
|
||||
detail = f" fill: extent {ex:.0%}x{ey:.0%} below required coverage"
|
||||
elif r.empty_ok is False:
|
||||
status = "FAIL"
|
||||
detail = (f" drew nothing but display() returned"
|
||||
f" {r.display_returned!r}; return False so the"
|
||||
f" controller skips the mode")
|
||||
else:
|
||||
status, detail = "FAIL", ""
|
||||
print(f" [{status}] {r.size_label:>7} {r.mode}{detail}")
|
||||
|
||||
@@ -73,6 +73,11 @@ class RenderResult:
|
||||
golden_ok: Optional[bool] = None
|
||||
golden_diff_pixels: int = 0
|
||||
golden_max_delta: int = 0
|
||||
# what display() handed back; the controller skips a mode only on False
|
||||
display_returned: Any = None
|
||||
# empty-frame check: rendered nothing while not reporting "no content"
|
||||
empty_claimed: Optional[bool] = None # True when that happened
|
||||
empty_ok: Optional[bool] = None # False only in strict mode
|
||||
# fill / scale-up check (populated only for sizes >= 2x the design size)
|
||||
fill_checked: bool = False
|
||||
fill_ok: Optional[bool] = None # False only in strict mode
|
||||
@@ -92,6 +97,8 @@ class RenderResult:
|
||||
return False
|
||||
if self.fill_ok is False:
|
||||
return False
|
||||
if self.empty_ok is False:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@@ -132,21 +139,25 @@ def _instantiate(plugin_id: str, manifest: Dict[str, Any], plugin_dir: Path,
|
||||
return plugin_instance
|
||||
|
||||
|
||||
def _render_mode(plugin_instance: Any, mode: str) -> None:
|
||||
def _render_mode(plugin_instance: Any, mode: str) -> Any:
|
||||
"""Render a specific screen. Prefer an explicit display_mode kwarg; otherwise
|
||||
drive the plugin's internal mode state machine (first display() call renders
|
||||
modes[current_mode_index] when current_display_mode is None)."""
|
||||
modes[current_mode_index] when current_display_mode is None).
|
||||
|
||||
Returns whatever display() returned. The display controller skips a mode
|
||||
whose display() returns False, so that value decides whether an empty mode
|
||||
is rotated past or sat on -- which makes it worth reporting rather than
|
||||
discarding."""
|
||||
sig = inspect.signature(plugin_instance.display)
|
||||
if "display_mode" in sig.parameters:
|
||||
plugin_instance.display(force_clear=True, display_mode=mode)
|
||||
return
|
||||
return plugin_instance.display(force_clear=True, display_mode=mode)
|
||||
|
||||
modes = getattr(plugin_instance, "modes", None)
|
||||
if modes and mode in modes:
|
||||
plugin_instance.current_mode_index = list(modes).index(mode)
|
||||
if hasattr(plugin_instance, "current_display_mode"):
|
||||
plugin_instance.current_display_mode = None
|
||||
plugin_instance.display(force_clear=False)
|
||||
return plugin_instance.display(force_clear=False)
|
||||
|
||||
|
||||
def _freeze(freeze_time: Optional[str]):
|
||||
@@ -234,7 +245,7 @@ def _render_size(plugin_id, manifest, plugin_dir, config, mock_data,
|
||||
logger.warning("update() raised a non-connectivity error for %s [%s]: %s",
|
||||
plugin_id, mode, e)
|
||||
if result.error is None:
|
||||
_render_mode(inst, mode)
|
||||
result.display_returned = _render_mode(inst, mode)
|
||||
result.image = dm.get_image()
|
||||
result.overflow = dm.check_overflow()
|
||||
except Exception as e: # noqa: BLE001 — a display crash is a real failure
|
||||
@@ -341,6 +352,44 @@ def fill_metrics(image: Image.Image) -> Tuple[float, float, float]:
|
||||
return (extent_x, extent_y, ink)
|
||||
|
||||
|
||||
def check_empty_claimed(results: List[RenderResult],
|
||||
strict: bool = False) -> List[RenderResult]:
|
||||
"""Flag a mode that rendered nothing without reporting "no content".
|
||||
|
||||
The display controller skips a mode whose ``display()`` returns False, and
|
||||
treats anything else -- including None -- as "content was shown". A mode
|
||||
that draws nothing and does not return False therefore holds whatever is on
|
||||
the panel for its whole display duration. Since a mode switch clears first,
|
||||
that is a blank screen. Two sports plugins shipped exactly this: their
|
||||
``display()`` returned None on every path, so an out-of-season league sat
|
||||
blank for its full duration rather than being rotated past.
|
||||
|
||||
Warn-only by default, because a blank frame is not automatically wrong: a
|
||||
scroll mode whose first frame is its blank scroll-in buffer renders empty
|
||||
and is behaving correctly. ``strict=True`` sets ``empty_claimed`` such that
|
||||
``RenderResult.ok`` fails -- opt in per plugin via harness.json
|
||||
``{"empty_check": "strict"}`` once its modes are known to draw on the
|
||||
fixture data.
|
||||
|
||||
Note this can only catch what the fixtures actually render. A plugin whose
|
||||
harness fixture seeds content never exercises its empty path here; the
|
||||
source-level gate in the plugins repo covers that case.
|
||||
"""
|
||||
for r in results:
|
||||
if r.image is None or r.error is not None:
|
||||
continue
|
||||
# An explicit False is the plugin correctly saying "nothing to show".
|
||||
if r.display_returned is False:
|
||||
continue
|
||||
if r.image.convert("L").point(
|
||||
lambda p: 255 if p > _LIT_THRESHOLD else 0).getbbox() is not None:
|
||||
continue
|
||||
r.empty_claimed = True
|
||||
if strict:
|
||||
r.empty_ok = False
|
||||
return results
|
||||
|
||||
|
||||
def check_scale_up(results: List[RenderResult],
|
||||
design_size: Tuple[int, int] = (128, 32),
|
||||
min_extent: float = _MIN_FILL_EXTENT,
|
||||
|
||||
@@ -104,22 +104,10 @@ class VegasModeConfig:
|
||||
overflow_mode: str = "rotate"
|
||||
|
||||
# Cap on one plugin's share of a cycle, as a multiple of display width.
|
||||
# 0 (the default) disables the cap, so every plugin contributes all of its
|
||||
# content and is always entered at its beginning.
|
||||
#
|
||||
# Capping was the default until it proved to cost more than it bought.
|
||||
# Measured over a 17-plugin fleet on a 512px panel, only four plugins were
|
||||
# ever wide enough to hit a 3.0 cap; for those four it produced two visible
|
||||
# faults. Content resumed mid-item on each appearance (a news ticker entered
|
||||
# at column 6027 of its own strip), and the final window of a rotation was
|
||||
# whatever happened to be left — 348px of a 1840px stocks ticker, seven
|
||||
# seconds of panel time. Both read as the display being broken rather than
|
||||
# as deferral working.
|
||||
#
|
||||
# A wide plugin does hold the panel for a long time uncapped: set the cap
|
||||
# per plugin with vegas_max_width_screens where that matters, rather than
|
||||
# globally where it mostly hurts plugins that were never the problem.
|
||||
max_plugin_width_ratio: float = 0.0
|
||||
# A single ticker returning 7,000px would otherwise hold the panel for over
|
||||
# two minutes. Overflow is deferred to later cycles rather than discarded.
|
||||
# 0 disables the cap.
|
||||
max_plugin_width_ratio: float = 3.0
|
||||
|
||||
# Plugin management
|
||||
plugin_order: List[str] = field(default_factory=list)
|
||||
@@ -171,7 +159,7 @@ class VegasModeConfig:
|
||||
lead_in_width=int(vegas_config.get('lead_in_width', 0)),
|
||||
plugins_per_cycle=int(vegas_config.get('plugins_per_cycle', 6)),
|
||||
max_plugin_width_ratio=float(
|
||||
vegas_config.get('max_plugin_width_ratio', 0.0)),
|
||||
vegas_config.get('max_plugin_width_ratio', 3.0)),
|
||||
overflow_mode=str(vegas_config.get('overflow_mode', 'rotate')),
|
||||
plugin_order=list(vegas_config.get('plugin_order', [])),
|
||||
excluded_plugins=set(vegas_config.get('excluded_plugins', [])),
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Tests for the harness empty-frame check (src/plugin_system/testing/harness.py).
|
||||
|
||||
The display controller skips a mode whose display() returns False and treats
|
||||
anything else -- including None -- as "content was shown". A mode that draws
|
||||
nothing without returning False is therefore never skipped, and since a mode
|
||||
switch clears the panel first, it sits on a blank screen for its whole display
|
||||
duration.
|
||||
|
||||
Two sports plugins shipped exactly that: their display() returned None on every
|
||||
path, so an out-of-season league held a blank panel instead of being rotated
|
||||
past. The harness rendered those modes and passed them, because it discarded
|
||||
the return value entirely.
|
||||
"""
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from src.plugin_system.testing.harness import RenderResult, check_empty_claimed
|
||||
|
||||
|
||||
def _blank(w=64, h=32):
|
||||
return Image.new("RGB", (w, h), (0, 0, 0))
|
||||
|
||||
|
||||
def _drawn(w=64, h=32):
|
||||
img = _blank(w, h)
|
||||
img.paste(Image.new("RGB", (10, 10), (255, 255, 255)), (5, 5))
|
||||
return img
|
||||
|
||||
|
||||
def _result(image, returned=None, **kw):
|
||||
return RenderResult("p", 64, 32, "mode", image=image,
|
||||
display_returned=returned, **kw)
|
||||
|
||||
|
||||
class TestCheckEmptyClaimed:
|
||||
def test_blank_frame_returning_none_is_flagged(self):
|
||||
# The shape that shipped: nothing drawn, nothing reported.
|
||||
r = _result(_blank(), returned=None)
|
||||
check_empty_claimed([r])
|
||||
assert r.empty_claimed is True
|
||||
|
||||
def test_blank_frame_returning_true_is_flagged(self):
|
||||
# Just as broken, and more explicit about it.
|
||||
r = _result(_blank(), returned=True)
|
||||
check_empty_claimed([r])
|
||||
assert r.empty_claimed is True
|
||||
|
||||
def test_blank_frame_returning_false_is_fine(self):
|
||||
# The plugin correctly said "no content"; the controller will skip it.
|
||||
r = _result(_blank(), returned=False)
|
||||
check_empty_claimed([r])
|
||||
assert r.empty_claimed is None
|
||||
|
||||
def test_a_drawn_frame_is_fine_whatever_it_returns(self):
|
||||
for returned in (None, True, False):
|
||||
r = _result(_drawn(), returned=returned)
|
||||
check_empty_claimed([r])
|
||||
assert r.empty_claimed is None, returned
|
||||
|
||||
def test_near_black_still_counts_as_drawn(self):
|
||||
# Guard the threshold: content dim enough to look black to the eye is
|
||||
# still content, and flagging it would train people to ignore this.
|
||||
img = _blank()
|
||||
img.paste(Image.new("RGB", (4, 4), (60, 60, 60)), (2, 2))
|
||||
r = _result(img, returned=None)
|
||||
check_empty_claimed([r])
|
||||
assert r.empty_claimed is None
|
||||
|
||||
|
||||
class TestWarnVersusStrict:
|
||||
def test_warn_only_by_default(self):
|
||||
# A scroll mode's first frame is legitimately its blank scroll-in
|
||||
# buffer, so this must not fail a run unless opted in.
|
||||
r = _result(_blank(), returned=None)
|
||||
check_empty_claimed([r])
|
||||
assert r.empty_ok is None
|
||||
assert r.ok is True
|
||||
|
||||
def test_strict_fails_the_result(self):
|
||||
r = _result(_blank(), returned=None)
|
||||
check_empty_claimed([r], strict=True)
|
||||
assert r.empty_ok is False
|
||||
assert r.ok is False
|
||||
|
||||
def test_strict_still_allows_an_honest_false(self):
|
||||
r = _result(_blank(), returned=False)
|
||||
check_empty_claimed([r], strict=True)
|
||||
assert r.empty_ok is None
|
||||
assert r.ok is True
|
||||
|
||||
|
||||
class TestSkippedResults:
|
||||
def test_a_crashed_render_is_left_alone(self):
|
||||
# error already fails the result; adding a second reason just muddies
|
||||
# the report.
|
||||
r = _result(None, returned=None, error="boom")
|
||||
check_empty_claimed([r], strict=True)
|
||||
assert r.empty_claimed is None
|
||||
|
||||
def test_a_result_with_no_image_is_left_alone(self):
|
||||
r = _result(None, returned=None)
|
||||
check_empty_claimed([r], strict=True)
|
||||
assert r.empty_claimed is None
|
||||
@@ -781,20 +781,6 @@ class TestNewConfigKeys:
|
||||
assert cfg.render_width_pct == 100
|
||||
assert cfg.min_content_separation == 24
|
||||
|
||||
def test_width_cap_is_off_by_default(self):
|
||||
# Capping made wide plugins resume mid-content on every appearance and
|
||||
# emit runt final windows; it is now opt-in per plugin instead.
|
||||
assert VegasModeConfig().max_plugin_width_ratio == 0.0
|
||||
assert VegasModeConfig.from_config({}).max_plugin_width_ratio == 0.0
|
||||
|
||||
def test_width_cap_is_still_available_when_asked_for(self):
|
||||
# Defaulting the cap off must not remove it: a user who sets a ratio
|
||||
# still gets one, and 0 still means uncapped.
|
||||
cfg = VegasModeConfig.from_config(
|
||||
{'display': {'vegas_scroll': {'max_plugin_width_ratio': 3.0}}})
|
||||
assert cfg.max_plugin_width_ratio == 3.0
|
||||
assert cfg.validate() == []
|
||||
|
||||
@pytest.mark.parametrize('overrides,bad_key', [
|
||||
({'render_width_pct': 5}, 'render_width_pct'),
|
||||
({'render_width_pct': 101}, 'render_width_pct'),
|
||||
|
||||
@@ -556,11 +556,11 @@
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="setting-display-vegas_max_plugin_width_ratio" data-setting-key="display.vegas_scroll.max_plugin_width_ratio">
|
||||
<label for="vegas_max_plugin_width_ratio" class="block text-sm font-medium text-gray-700">Max Plugin Width (screens){{ ui.help_tip('Caps how much of one cycle a single plugin may occupy, measured in screen widths (0–20).\nDefault: 0 (no limit) — every plugin shows all of its content and always starts at the beginning.\nSet a limit to stop one long ticker holding the display for minutes: it is cut to this width and the remainder shown on later cycles. The trade-off is that such a plugin then resumes mid-content on each appearance instead of starting fresh.', 'Max Plugin Width') }}</label>
|
||||
<label for="vegas_max_plugin_width_ratio" class="block text-sm font-medium text-gray-700">Max Plugin Width (screens){{ ui.help_tip('Caps how much of one cycle a single plugin may occupy, measured in screen widths (0–20).\nDefault: 3. A long ticker such as a news feed or leaderboard is trimmed to this and the remainder shown on later cycles, so one plugin cannot hold the display for minutes. Set 0 for no limit.', 'Max Plugin Width') }}</label>
|
||||
<input type="number"
|
||||
id="vegas_max_plugin_width_ratio"
|
||||
name="vegas_max_plugin_width_ratio"
|
||||
value="{{ main_config.display.get('vegas_scroll', {}).get('max_plugin_width_ratio', 0.0) }}"
|
||||
value="{{ main_config.display.get('vegas_scroll', {}).get('max_plugin_width_ratio', 3.0) }}"
|
||||
min="0"
|
||||
max="20"
|
||||
step="0.5"
|
||||
|
||||
Reference in New Issue
Block a user