mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
fix(display): a non-numeric plugin duration no longer stops the display; narrow scroll strips no longer raise (#739)
* fix(display): a plugin duration that is not a number no longer stops the display DisplayController._get_display_duration returned whatever the plugin's get_display_duration() gave back. clock-simple, calendar and countdown return their display_duration setting straight from config.json, so a value saved as "20" or null reached _resolve_durations as a string or None, and its `<= 0` check raised a TypeError. Nothing in the loop caught it: run()'s outer handler logged "Unexpected error in display controller" and cleanup() ended the service when that plugin's screen came up, and systemd restarted it into the same crash. The plugin's answer is now read as seconds: a finite number or a numeric string is used (as BasePlugin.get_display_duration already accepts), a number at or below zero still goes to _resolve_durations' 15 s rule, and anything else -- None, a non-numeric string, a bool, NaN, infinity, or a get_display_duration() that raises -- gets the 30 s a mode without a plugin gets. The warning is logged once per plugin, not at every screen. Tests: test/test_display_duration_not_a_number.py, including the real run() on the run-loop harness, which returned at t=30 before the fix. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(scroll): a strip narrower than the panel no longer raises on every frame ScrollHelper._get_visible_portion_integer handled a frame that runs off the end of the strip by copying the strip's tail and then the rest of the frame from its head, which assumed the head was at least that wide. For a strip narrower than the panel that raised "could not broadcast input array" at every position, so get_visible_portion() never returned a frame and the caller logged a traceback each frame. Vegas composes such a strip (lead_in_width defaults to 0) when its content is narrower than the chain. A wrapping frame is now taken column by column modulo the strip's width (np.take, mode='wrap', into the reused frame buffer): the tail then the head, as before, and a narrow strip repeated across the panel. The same path takes a position before the start of the strip, whose [-n:m] slice was empty and made frombytes raise; the integer and sub-pixel fast paths now leave a negative start to it. A zero-width strip is still a black frame. Tests: test/test_scroll_helper_narrow_strip.py. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
"""A plugin duration that is not a number must not stop the display.
|
||||
|
||||
Several plugins return their ``display_duration`` setting as it is in
|
||||
config.json (``return self.config.get('display_duration', 15.0)``), so a
|
||||
value saved as ``"20"`` or ``null`` -- from the raw config editor, or by
|
||||
hand -- reached run() as a string or None. _resolve_durations then compared
|
||||
it with 0, the TypeError went past every handler in the loop, and the
|
||||
display service exited; systemd restarted it into the same screen and the
|
||||
same crash.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
os.environ.setdefault("EMULATOR", "true")
|
||||
|
||||
import pytest
|
||||
|
||||
from src.display_controller import DisplayController
|
||||
from test._run_loop_harness import FakePlugin, RunLoopHarness
|
||||
|
||||
|
||||
def _controller(plugin_modes):
|
||||
dc = object.__new__(DisplayController)
|
||||
dc.config = {}
|
||||
dc.plugin_modes = plugin_modes
|
||||
return dc
|
||||
|
||||
|
||||
def _plugin(duration, plugin_id='clock-simple'):
|
||||
plugin = MagicMock()
|
||||
plugin.plugin_id = plugin_id
|
||||
plugin.get_display_duration.return_value = duration
|
||||
return plugin
|
||||
|
||||
|
||||
class TestPluginDurationIsCoerced:
|
||||
@pytest.mark.parametrize('value, expected', [
|
||||
('20', 20.0), (' 7.5 ', 7.5), (12, 12.0), (12.5, 12.5)])
|
||||
def test_numbers_and_numeric_strings_are_used(self, value, expected):
|
||||
dc = _controller({'clock': _plugin(value)})
|
||||
duration = dc._get_display_duration('clock')
|
||||
assert duration == expected and isinstance(duration, float)
|
||||
|
||||
@pytest.mark.parametrize('value', [
|
||||
None, '', 'twenty', True, False, float('nan'), float('inf'), 'inf',
|
||||
[20], {'seconds': 20}])
|
||||
def test_anything_but_a_finite_number_gets_the_default(self, value):
|
||||
dc = _controller({'clock': _plugin(value)})
|
||||
assert dc._get_display_duration('clock') == 30
|
||||
|
||||
@pytest.mark.parametrize('value', [0, -5, '-5', '0'])
|
||||
def test_a_number_not_above_zero_still_gets_the_15s_rule(self, value):
|
||||
"""Unchanged: _resolve_durations turns it into 15 s, with its warning."""
|
||||
plugin = _plugin(value)
|
||||
dc = _controller({'clock': plugin})
|
||||
base = dc._get_display_duration('clock')
|
||||
assert dc._resolve_durations(plugin, 'clock', base, False)[1] == 15.0
|
||||
|
||||
def test_a_raising_get_display_duration_gets_the_default(self):
|
||||
plugin = _plugin(None)
|
||||
plugin.get_display_duration.side_effect = KeyError('display_duration')
|
||||
assert _controller({'clock': plugin})._get_display_duration('clock') == 30
|
||||
|
||||
def test_the_result_feeds_resolve_durations(self):
|
||||
"""The two calls run() makes back to back, for one screen."""
|
||||
plugin = _plugin('bad')
|
||||
dc = _controller({'clock': plugin})
|
||||
base = dc._get_display_duration('clock')
|
||||
assert dc._resolve_durations(plugin, 'clock', base, False) == (30, 30)
|
||||
|
||||
def test_logged_once_per_plugin(self, caplog):
|
||||
dc = _controller({'clock': _plugin('twenty'),
|
||||
'clock_big': _plugin('twenty'),
|
||||
'calendar': _plugin(None, plugin_id='calendar')})
|
||||
# clock_big is a second mode of the same plugin.
|
||||
dc.plugin_modes['clock_big'].plugin_id = 'clock-simple'
|
||||
with caplog.at_level(logging.WARNING, logger='src.display_controller'):
|
||||
for _ in range(3):
|
||||
for mode in ('clock', 'clock_big', 'calendar'):
|
||||
dc._get_display_duration(mode)
|
||||
warnings = [r for r in caplog.records if 'display duration' in r.getMessage()]
|
||||
assert len(warnings) == 2
|
||||
assert {'clock-simple', 'calendar'} == {
|
||||
next(p for p in ('clock-simple', 'calendar') if p in r.getMessage())
|
||||
for r in warnings}
|
||||
|
||||
def test_a_good_value_after_a_bad_one_is_used(self):
|
||||
plugin = _plugin(None)
|
||||
dc = _controller({'clock': plugin})
|
||||
assert dc._get_display_duration('clock') == 30
|
||||
plugin.get_display_duration.return_value = 45
|
||||
assert dc._get_display_duration('clock') == 45.0
|
||||
|
||||
|
||||
class TestRunLoopSurvives:
|
||||
"""Through the real run() on the harness's fake clock."""
|
||||
|
||||
@pytest.mark.parametrize('duration, shown_for', [('20', 20.0), (None, 30.0),
|
||||
('twenty', 30.0)])
|
||||
def test_the_screen_runs_and_the_rotation_goes_on(self, tmp_path, duration, shown_for):
|
||||
harness = RunLoopHarness(tmp_path, horizon=120)
|
||||
harness.add_plugin(FakePlugin("weather", ["weather"], duration=30))
|
||||
harness.add_plugin(FakePlugin("clock-simple", ["clock"], duration=duration))
|
||||
# Before the fix run() returned at t=30, when the clock came up, and
|
||||
# the harness raised "run() returned ... before the horizon".
|
||||
rows = harness.run()["screens"]
|
||||
clock = next(row for row in rows if row[1] == "clock")
|
||||
assert math.isclose(clock[2], shown_for, abs_tol=1.0)
|
||||
assert [row[1] for row in rows][:3] == ["weather", "clock", "weather"]
|
||||
@@ -0,0 +1,112 @@
|
||||
"""
|
||||
ScrollHelper frames for a strip narrower than the panel, and other wraps.
|
||||
|
||||
A frame that runs past the end of the strip continues from its head: column
|
||||
j of the frame is strip column (position + j) modulo the strip's width. The
|
||||
wrap path sliced the strip's tail and then "the rest of the frame" from its
|
||||
head, which assumed the head was at least that wide. For a strip narrower
|
||||
than the panel it raised ValueError at every position, so a narrow strip
|
||||
(Vegas composes one when its content is narrower than the chain, with its
|
||||
lead-in of 0) logged a traceback every frame instead of drawing.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from src.common.scroll_helper import ScrollHelper
|
||||
|
||||
W, H = 128, 32
|
||||
|
||||
|
||||
def _strip(width, height=H):
|
||||
"""A strip whose every column is distinct: R and B are the column number."""
|
||||
columns = np.arange(width)
|
||||
pixels = np.zeros((height, width, 3), dtype=np.uint8)
|
||||
pixels[:, :, 0] = columns % 256
|
||||
pixels[:, :, 1] = 255 - (columns % 256)
|
||||
pixels[:, :, 2] = columns // 256
|
||||
return Image.fromarray(pixels, 'RGB')
|
||||
|
||||
|
||||
def _helper(strip_width, sub_pixel=False):
|
||||
sh = ScrollHelper(W, H)
|
||||
sh.set_scrolling_image(_strip(strip_width))
|
||||
sh.sub_pixel_scrolling = sub_pixel
|
||||
return sh
|
||||
|
||||
|
||||
def _frame(sh, position):
|
||||
sh.scroll_position = position
|
||||
frame = sh.get_visible_portion()
|
||||
assert frame is not None and frame.size == (W, H) and frame.mode == 'RGB'
|
||||
return np.asarray(frame)
|
||||
|
||||
|
||||
def _wrapped(sh, start):
|
||||
"""What the panel should show from ``start``: the strip, wrapping."""
|
||||
return sh.cached_array[:, np.arange(start, start + W) % sh.cached_array.shape[1]]
|
||||
|
||||
|
||||
class TestNarrowStrip:
|
||||
@pytest.mark.parametrize('strip_width', [1, 40, 50, W - 1])
|
||||
@pytest.mark.parametrize('position', [0, 10, 39])
|
||||
def test_frame_repeats_the_strip_across_the_panel(self, strip_width, position):
|
||||
sh = _helper(strip_width)
|
||||
position %= strip_width
|
||||
assert np.array_equal(_frame(sh, position), _wrapped(sh, position))
|
||||
|
||||
def test_a_composed_strip_without_lead_in(self):
|
||||
# How Vegas builds its strip: lead_gap=0 (vegas_scroll.lead_in_width).
|
||||
sh = ScrollHelper(W, H)
|
||||
sh.create_scrolling_image([_strip(40)], item_gap=0, element_gap=0, lead_gap=0)
|
||||
assert sh.total_scroll_width == 40
|
||||
for position in range(40):
|
||||
assert np.array_equal(_frame(sh, position), _wrapped(sh, position))
|
||||
|
||||
def test_a_whole_pass_scrolls_without_raising(self):
|
||||
sh = _helper(50)
|
||||
sh.set_pixels_per_frame(3)
|
||||
for _ in range(60):
|
||||
sh.update_scroll_position()
|
||||
assert sh.get_visible_portion().size == (W, H)
|
||||
|
||||
@pytest.mark.parametrize('position', [0.5, 10.25, 49.5])
|
||||
def test_sub_pixel_blend_of_a_narrow_strip(self, position):
|
||||
sh = _helper(50, sub_pixel=True)
|
||||
frame = _frame(sh, position)
|
||||
start = int(position)
|
||||
near, far = _wrapped(sh, start), _wrapped(sh, start + 1)
|
||||
lo, hi = np.minimum(near, far), np.maximum(near, far)
|
||||
assert (frame >= lo).all() and (frame <= hi).all()
|
||||
|
||||
|
||||
class TestWrapOfAWideStrip:
|
||||
"""Unchanged: the tail, then the head."""
|
||||
|
||||
@pytest.mark.parametrize('position', [200 - W + 1, 150, 199])
|
||||
def test_tail_then_head(self, position):
|
||||
sh = _helper(200)
|
||||
frame = _frame(sh, position)
|
||||
tail = 200 - position
|
||||
assert np.array_equal(frame[:, :tail], sh.cached_array[:, position:])
|
||||
assert np.array_equal(frame[:, tail:], sh.cached_array[:, :W - tail])
|
||||
|
||||
def test_at_the_end_shows_the_head(self):
|
||||
sh = _helper(200)
|
||||
assert np.array_equal(_frame(sh, 200), sh.cached_array[:, :W])
|
||||
|
||||
def test_sub_pixel_at_the_last_column(self):
|
||||
sh = _helper(200, sub_pixel=True)
|
||||
assert _frame(sh, 199.5).shape == (H, W, 3)
|
||||
|
||||
def test_a_position_before_the_start_wraps_too(self):
|
||||
# Slicing [-10:118] of the array was an empty slice: frombytes raised.
|
||||
sh = _helper(200)
|
||||
assert np.array_equal(_frame(sh, -10), _wrapped(sh, -10))
|
||||
|
||||
|
||||
def test_a_zero_width_strip_is_a_black_frame():
|
||||
sh = ScrollHelper(W, H)
|
||||
sh.set_scrolling_image(Image.new('RGB', (0, H)))
|
||||
assert not _frame(sh, 0).any()
|
||||
Reference in New Issue
Block a user