Compare commits

..
Author SHA1 Message Date
ChuckBuildsandClaude Opus 5 cad279a7d2 test(harness): flag a mode that draws nothing without reporting it
The 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 is therefore never skipped, and
because a mode switch clears the panel first, it sits on a blank screen
for its whole display duration. Two sports plugins shipped exactly that.

The harness rendered those modes and passed them, because it called
display() and discarded the result. Capture it, and warn when a render
produced no lit pixels while claiming content.

Warn-only by default, and deliberately so: a scroll mode's first frame
is legitimately its blank scroll-in buffer, which is 42 of these on the
F1 scoreboard alone. Plugins whose modes are known to draw on their
fixture data can opt into failing via harness.json {"empty_check":
"strict"}, matching how the fill check is staged.

Worth being clear about the limit: this only sees what the fixtures
render. It would not have caught the sports bug, whose fixture seeds
games so the empty path never renders -- that needs the source-level
gate in the plugins repo. What it does catch is the same mistake in any
plugin whose empty state the harness does happen to reach, which is
coverage there was none of before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5
2026-08-09 17:22:15 -04:00
8 changed files with 182 additions and 41 deletions
+1 -1
View File
@@ -149,7 +149,7 @@
"min_plugin_width": 8, "min_plugin_width": 8,
"lead_in_width": 0, "lead_in_width": 0,
"plugins_per_cycle": 6, "plugins_per_cycle": 6,
"max_plugin_width_ratio": 0.0, "max_plugin_width_ratio": 3.0,
"overflow_mode": "rotate", "overflow_mode": "rotate",
"dynamic_duration_enabled": true, "dynamic_duration_enabled": true,
"min_cycle_duration": 60, "min_cycle_duration": 60,
+1 -1
View File
@@ -127,7 +127,7 @@ Read by `src/vegas_mode/config.py` (`VegasScrollConfig.from_config`). See
| `min_plugin_width` | int, `8` | | `min_plugin_width` | int, `8` |
| `lead_in_width` | int, `0` | | `lead_in_width` | int, `0` |
| `plugins_per_cycle` | int, `6` | | `plugins_per_cycle` | int, `6` |
| `max_plugin_width_ratio` | float, `0.0` | | `max_plugin_width_ratio` | float, `3.0` |
| `overflow_mode` | string, `"rotate"` | | `overflow_mode` | string, `"rotate"` |
| `dynamic_duration_enabled` | bool, `true` | | `dynamic_duration_enabled` | bool, `true` |
| `min_cycle_duration` | int, `60` | | `min_cycle_duration` | int, `60` |
+15
View File
@@ -41,6 +41,7 @@ from src.plugin_system.testing.loading import ( # noqa: E402
) )
from src.plugin_system.testing.harness import ( # noqa: E402 from src.plugin_system.testing.harness import ( # noqa: E402
RenderResult, render_plugin_matrix, compare_to_goldens, write_goldens, RenderResult, render_plugin_matrix, compare_to_goldens, write_goldens,
check_empty_claimed,
check_scale_up, check_scale_up,
) )
from src.plugin_system.testing.sizes import ( # noqa: E402 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", {}) declared = load_manifest(plugin_dir).get("display", {}).get("design_size", {})
design_size = (int(declared.get("width", 128)), int(declared.get("height", 32))) design_size = (int(declared.get("width", 128)), int(declared.get("height", 32)))
fill_strict = spec.get("fill_check") == "strict" 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" — # Every run: the base config, plus one per harness.json "variant" —
# a config overlay with its own golden dir (e.g. adaptive layout mode # 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) compare_to_goldens(results, golden_dir)
check_scale_up(results, design_size=design_size, strict=fill_strict) 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. # Tag variant runs so the report and PNG dumps stay distinguishable.
if variant_name: 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 # warn-only underfill: big panel left mostly empty
ex, ey = r.fill_extent ex, ey = r.fill_extent
detail += f" (fill warn: extent {ex:.0%}x{ey:.0%})" 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: else:
everything_ok = False everything_ok = False
if r.error is not None: 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) ex, ey = r.fill_extent or (0.0, 0.0)
status = "FAIL" status = "FAIL"
detail = f" fill: extent {ex:.0%}x{ey:.0%} below required coverage" 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: else:
status, detail = "FAIL", "" status, detail = "FAIL", ""
print(f" [{status}] {r.size_label:>7} {r.mode}{detail}") print(f" [{status}] {r.size_label:>7} {r.mode}{detail}")
+55 -6
View File
@@ -73,6 +73,11 @@ class RenderResult:
golden_ok: Optional[bool] = None golden_ok: Optional[bool] = None
golden_diff_pixels: int = 0 golden_diff_pixels: int = 0
golden_max_delta: 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 / scale-up check (populated only for sizes >= 2x the design size)
fill_checked: bool = False fill_checked: bool = False
fill_ok: Optional[bool] = None # False only in strict mode fill_ok: Optional[bool] = None # False only in strict mode
@@ -92,6 +97,8 @@ class RenderResult:
return False return False
if self.fill_ok is False: if self.fill_ok is False:
return False return False
if self.empty_ok is False:
return False
return True return True
@@ -132,21 +139,25 @@ def _instantiate(plugin_id: str, manifest: Dict[str, Any], plugin_dir: Path,
return plugin_instance 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 """Render a specific screen. Prefer an explicit display_mode kwarg; otherwise
drive the plugin's internal mode state machine (first display() call renders 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) sig = inspect.signature(plugin_instance.display)
if "display_mode" in sig.parameters: if "display_mode" in sig.parameters:
plugin_instance.display(force_clear=True, display_mode=mode) return plugin_instance.display(force_clear=True, display_mode=mode)
return
modes = getattr(plugin_instance, "modes", None) modes = getattr(plugin_instance, "modes", None)
if modes and mode in modes: if modes and mode in modes:
plugin_instance.current_mode_index = list(modes).index(mode) plugin_instance.current_mode_index = list(modes).index(mode)
if hasattr(plugin_instance, "current_display_mode"): if hasattr(plugin_instance, "current_display_mode"):
plugin_instance.current_display_mode = None 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]): 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", logger.warning("update() raised a non-connectivity error for %s [%s]: %s",
plugin_id, mode, e) plugin_id, mode, e)
if result.error is None: if result.error is None:
_render_mode(inst, mode) result.display_returned = _render_mode(inst, mode)
result.image = dm.get_image() result.image = dm.get_image()
result.overflow = dm.check_overflow() result.overflow = dm.check_overflow()
except Exception as e: # noqa: BLE001 — a display crash is a real failure 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) 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], def check_scale_up(results: List[RenderResult],
design_size: Tuple[int, int] = (128, 32), design_size: Tuple[int, int] = (128, 32),
min_extent: float = _MIN_FILL_EXTENT, min_extent: float = _MIN_FILL_EXTENT,
+5 -17
View File
@@ -104,22 +104,10 @@ class VegasModeConfig:
overflow_mode: str = "rotate" overflow_mode: str = "rotate"
# Cap on one plugin's share of a cycle, as a multiple of display width. # 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 # A single ticker returning 7,000px would otherwise hold the panel for over
# content and is always entered at its beginning. # two minutes. Overflow is deferred to later cycles rather than discarded.
# # 0 disables the cap.
# Capping was the default until it proved to cost more than it bought. max_plugin_width_ratio: float = 3.0
# 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
# Plugin management # Plugin management
plugin_order: List[str] = field(default_factory=list) 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)), lead_in_width=int(vegas_config.get('lead_in_width', 0)),
plugins_per_cycle=int(vegas_config.get('plugins_per_cycle', 6)), plugins_per_cycle=int(vegas_config.get('plugins_per_cycle', 6)),
max_plugin_width_ratio=float( 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')), overflow_mode=str(vegas_config.get('overflow_mode', 'rotate')),
plugin_order=list(vegas_config.get('plugin_order', [])), plugin_order=list(vegas_config.get('plugin_order', [])),
excluded_plugins=set(vegas_config.get('excluded_plugins', [])), excluded_plugins=set(vegas_config.get('excluded_plugins', [])),
+103
View File
@@ -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
-14
View File
@@ -781,20 +781,6 @@ class TestNewConfigKeys:
assert cfg.render_width_pct == 100 assert cfg.render_width_pct == 100
assert cfg.min_content_separation == 24 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', [ @pytest.mark.parametrize('overrides,bad_key', [
({'render_width_pct': 5}, 'render_width_pct'), ({'render_width_pct': 5}, 'render_width_pct'),
({'render_width_pct': 101}, 'render_width_pct'), ({'render_width_pct': 101}, 'render_width_pct'),
@@ -556,11 +556,11 @@
</div> </div>
<div class="form-group" id="setting-display-vegas_max_plugin_width_ratio" data-setting-key="display.vegas_scroll.max_plugin_width_ratio"> <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 (020).\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 (020).\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" <input type="number"
id="vegas_max_plugin_width_ratio" id="vegas_max_plugin_width_ratio"
name="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" min="0"
max="20" max="20"
step="0.5" step="0.5"