Files
LEDMatrix/test/test_vegas_static_mode.py
T
ChuckandClaude Opus 5.5 37d2280a15 fix(vegas): a static pause redraws its plugin for the whole pause
The pause called display(force_clear=True) once and slept out the
duration, so a plugin that rotates its screens or animates (Fantasy
Blitz's playlist) was frozen on its first frame. The pause now calls
display() again at the rotation's rate: once a second, or the high-FPS
interval for a plugin that declares needs_high_fps (or scrolls). Only
the first call clears. A redraw that finds update() holding the plugin
lock is skipped; the pause carries on. Interrupt checks and the
watchdog beat still run every 0.1 s; a raise still ends the pause.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 18:06:48 -04:00

347 lines
13 KiB
Python

"""STATIC Vegas plugins pause the scroll when the scroll reaches their turn.
The coordinator used to decide by peeking at the front of the stream
manager's segment buffer. Continuous scrolling (the default) never advances
that buffer -- it extends the strip with take_next_group() -- so the same
first segment was examined on every frame: a STATIC plugin paused the scroll
only if it happened to be first, once, at startup, and otherwise scrolled past
as ordinary content. The render pipeline now marks where each STATIC plugin's
turn falls in the strip, and the coordinator pauses when the scroll gets there.
"""
import threading
from collections import deque
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from PIL import Image
from src.plugin_system.base_plugin import VegasDisplayMode
from src.vegas_mode.config import VegasModeConfig
from src.vegas_mode.render_pipeline import RenderPipeline
W, H = 64, 32
SEP = 32
def block(width):
return Image.new('RGB', (width, H), (255, 255, 255))
class DM:
width = W
height = H
def set_scrolling_state(self, *a):
pass
class FakeStream:
"""Stands in for StreamManager: a composed buffer, then extension groups."""
def __init__(self, layout, statics=(), groups=()):
# layout: [(plugin_id, width or None for STATIC)]
self.layout = layout
self.statics = set(statics) | {pid for pid, w in layout if w is None}
self.groups = deque(groups)
self.plugin_manager = SimpleNamespace(plugins={})
def get_grouped_content_for_composition(self):
return [(pid, [block(w)]) for pid, w in self.layout if w is not None]
def get_static_layout(self):
return [(pid, w is None) for pid, w in self.layout]
def is_static_plugin(self, plugin_id):
return plugin_id in self.statics
def take_next_group(self, count=None, offscreen_only=False):
return self.groups.popleft() if self.groups else []
def pipeline(stream, **cfg):
cfg.setdefault('separator_width', SEP)
cfg.setdefault('lead_in_width', 0)
p = RenderPipeline(VegasModeConfig(**cfg), DM(), stream)
p.start_prefetch = lambda: None # keep extension on this thread
return p
def scroll_to(p, x):
p.scroll_helper.scroll_position = float(x)
class TestComposedStrip:
def test_a_static_plugin_mid_strip_triggers_when_the_scroll_reaches_it(self):
p = pipeline(FakeStream([('a', 100), ('s', None), ('b', 50)]), lead_in_width=10)
assert p.compose_scroll_content()
# 's' follows 'a', which ends at 10 + 100 = 110.
scroll_to(p, 110 - W - 1)
assert p.next_static_trigger() is None
scroll_to(p, 110 - W)
assert p.next_static_trigger() == 's'
# Consumed: one pause per place in the strip.
assert p.next_static_trigger() is None
def test_static_content_is_not_in_the_strip(self):
p = pipeline(FakeStream([('a', 100), ('s', None), ('b', 50)]))
assert p.compose_scroll_content()
assert p.scroll_helper.cached_image.width == 100 + SEP + 50
def test_a_static_plugin_first_triggers_at_once(self):
p = pipeline(FakeStream([('s', None), ('a', 100)]))
assert p.compose_scroll_content()
assert p.next_static_trigger() == 's'
def test_no_static_plugins_no_triggers(self):
p = pipeline(FakeStream([('a', 100), ('b', 50)]))
assert p.compose_scroll_content()
scroll_to(p, 1000)
assert p.next_static_trigger() is None
class TestContinuousExtension:
def test_a_static_plugin_in_a_later_group_triggers_at_its_place(self):
# The reported bug: in continuous mode, a STATIC plugin anywhere but
# first never paused the scroll at all.
stream = FakeStream([('a', 200)], statics={'s'},
groups=[[('c', [block(40)]), ('s', []), ('d', [block(30)])]])
p = pipeline(stream)
assert p.compose_scroll_content()
assert p.extend_scroll_content()
# The strip had 200 columns; 'c' is appended after a separator and
# 's' follows it: 200 + 32 + 40 = 272. 's' adds no columns.
assert p.scroll_helper.cached_image.width == 200 + SEP + 40 + SEP + 30
scroll_to(p, 272 - W - 1)
assert p.next_static_trigger() is None
scroll_to(p, 272 - W)
assert p.next_static_trigger() == 's'
def test_markers_follow_the_strip_when_its_head_is_trimmed(self):
stream = FakeStream([('a', 400)], statics={'s'},
groups=[[('c', [block(40)]), ('s', [])]])
p = pipeline(stream)
assert p.compose_scroll_content()
# Far enough along that extension trims the head of the strip.
scroll_to(p, 300)
assert p.extend_scroll_content()
cut = 300 - int(p.scroll_helper.scroll_position)
assert cut > 0
marker = 400 + SEP + 40 - cut
scroll_to(p, marker - W - 1)
assert p.next_static_trigger() is None
scroll_to(p, marker - W)
assert p.next_static_trigger() == 's'
def test_a_group_of_only_static_plugins_marks_the_strip_end(self):
stream = FakeStream([('a', 200)], statics={'s'}, groups=[[('s', [])]])
p = pipeline(stream)
assert p.compose_scroll_content()
p.extend_scroll_content()
scroll_to(p, 200 - W)
assert p.next_static_trigger() == 's'
def test_reset_forgets_markers(self):
p = pipeline(FakeStream([('a', 100), ('s', None), ('b', 50)]))
assert p.compose_scroll_content()
p.reset()
scroll_to(p, 1000)
assert p.next_static_trigger() is None
class TestStreamManagerSkipsStaticContent:
def _stream(self, plugins):
from src.vegas_mode.stream_manager import StreamManager
sm = StreamManager.__new__(StreamManager)
sm._buffer_lock = threading.RLock()
sm._ordered_plugins = list(plugins)
sm._prefetch_index = 0
sm.config = SimpleNamespace(plugins_per_cycle=len(plugins), offscreen_prefetch=True)
sm.plugin_manager = SimpleNamespace(plugins=plugins)
sm.plugin_adapter = MagicMock()
sm.plugin_adapter.get_content.return_value = [block(10)]
sm.refresh = lambda: None
return sm
def _plugin(self, mode):
return SimpleNamespace(get_vegas_display_mode=lambda: mode)
def test_static_plugins_keep_their_place_but_are_not_rendered(self):
sm = self._stream({
'a': self._plugin(VegasDisplayMode.SCROLL),
's': self._plugin(VegasDisplayMode.STATIC),
})
group = sm.take_next_group()
assert [pid for pid, _ in group] == ['a', 's']
assert dict(group)['s'] == []
fetched = [c.args[1] for c in sm.plugin_adapter.get_content.call_args_list]
assert fetched == ['a']
def test_static_layout_lines_up_with_composition(self):
from src.vegas_mode.stream_manager import ContentSegment, StreamManager
sm = StreamManager.__new__(StreamManager)
sm._buffer_lock = threading.RLock()
sm._active_buffer = deque([
ContentSegment('a', [block(10)], VegasDisplayMode.SCROLL),
ContentSegment('empty', [], VegasDisplayMode.SCROLL),
ContentSegment('s', [], VegasDisplayMode.STATIC),
ContentSegment('b', [block(10)], VegasDisplayMode.SCROLL),
])
assert sm.get_static_layout() == [('a', False), ('s', True), ('b', False)]
assert [pid for pid, _ in sm.get_grouped_content_for_composition()] == ['a', 'b']
class TestCoordinatorStaticPause:
def _coord(self, plugin, lock=None):
from src.vegas_mode.coordinator import VegasModeCoordinator
coord = VegasModeCoordinator.__new__(VegasModeCoordinator)
coord.render_pipeline = MagicMock()
coord.render_pipeline.get_scroll_position.return_value = 0
coord.display_manager = MagicMock()
locks = {plugin.plugin_id: lock or threading.Lock()}
coord.plugin_manager = SimpleNamespace(
get_plugin={plugin.plugin_id: plugin}.get,
get_plugin_lock=locks.__getitem__)
coord._state_lock = threading.Lock()
coord._static_pause_active = False
coord._saved_scroll_position = None
coord._should_stop = False
coord._live_priority_active = False
coord._live_priority_check = None
coord._interrupt_check = None
return coord
def _plugin(self):
plugin = MagicMock()
plugin.plugin_id = 'clock'
# A moment: zero would pause 15 s, as the rotation shows it.
plugin.get_display_duration.return_value = 0.01
return plugin
def test_trigger_comes_from_the_pipeline(self):
plugin = self._plugin()
coord = self._coord(plugin)
coord.render_pipeline.next_static_trigger.return_value = 'clock'
assert coord._check_static_plugin_trigger() is plugin
coord.render_pipeline.next_static_trigger.return_value = None
assert coord._check_static_plugin_trigger() is None
def test_pause_displays_under_the_plugin_lock(self):
plugin = self._plugin()
lock = threading.Lock()
seen = []
plugin.display.side_effect = lambda **k: seen.append(lock.locked())
coord = self._coord(plugin, lock)
assert coord._handle_static_pause(plugin) is True
assert seen and all(seen)
assert not lock.locked()
def test_pause_is_skipped_while_update_holds_the_lock(self):
plugin = self._plugin()
lock = threading.Lock()
lock.acquire()
coord = self._coord(plugin, lock)
coord.STATIC_LOCK_TIMEOUT = 0.01
try:
assert coord._handle_static_pause(plugin) is True
finally:
lock.release()
plugin.display.assert_not_called()
class FakeClock:
"""time.monotonic/time.sleep for the pause loop: sleeping moves the clock."""
def __init__(self):
self.now = 0.0
def monotonic(self):
return self.now
def sleep(self, seconds):
assert seconds >= 0
self.now += seconds
if self.now > 3600:
raise RuntimeError("the static pause never ended")
class TestStaticPauseRedraws:
"""A pause redraws its plugin for as long as it lasts.
It used to call display() once and sleep out the duration, so a plugin
that rotates its screens or animates (Fantasy Blitz's playlist, say) was
frozen on its first frame for the whole pause.
"""
def _run(self, monkeypatch, duration=10.0, high_fps=False, interrupt_at=None,
lock=None):
from src.vegas_mode import coordinator
clock = FakeClock()
monkeypatch.setattr(coordinator, 'time', clock)
plugin = MagicMock()
plugin.plugin_id = 'clock'
plugin.needs_high_fps = high_fps
plugin.get_display_duration.return_value = duration
calls = []
plugin.display.side_effect = lambda **k: calls.append((clock.now, k))
coord = TestCoordinatorStaticPause()._coord(plugin, lock)
if interrupt_at is not None:
coord._interrupt_check = lambda: clock.now >= interrupt_at
return coord._handle_static_pause(plugin), calls, clock
def test_redraws_once_a_second_for_the_whole_pause(self, monkeypatch):
done, calls, clock = self._run(monkeypatch, duration=10.0)
assert done is True
assert len(calls) == 10
assert [round(t, 6) for t, _ in calls] == [float(i) for i in range(10)]
assert 10.0 <= clock.now < 10.2
def test_only_the_first_draw_clears(self, monkeypatch):
_, calls, _ = self._run(monkeypatch, duration=3.0)
assert calls[0][1] == {'force_clear': True}
assert all(kwargs == {} for _, kwargs in calls[1:])
def test_a_high_fps_plugin_redraws_at_the_high_fps_rate(self, monkeypatch):
from src.screen_runner import HIGH_FPS_INTERVAL
_, calls, _ = self._run(monkeypatch, duration=1.0, high_fps=True)
assert len(calls) == pytest.approx(1.0 / HIGH_FPS_INTERVAL, abs=2)
def test_an_interrupt_still_ends_the_pause(self, monkeypatch):
done, calls, clock = self._run(monkeypatch, duration=30.0, interrupt_at=2.5)
assert done is False
assert clock.now < 2.7
assert len(calls) == 3
def test_a_held_lock_skips_one_redraw_not_the_pause(self, monkeypatch):
lock = threading.Lock()
real_acquire = lock.acquire
held = {'count': 0}
class FlakyLock:
def acquire(self, timeout=-1):
held['count'] += 1
# update() holds the lock at the third draw only.
return False if held['count'] == 3 else real_acquire(timeout=timeout)
def release(self):
lock.release()
done, calls, _ = self._run(monkeypatch, duration=5.0, lock=FlakyLock())
assert done is True
assert [round(t) for t, _ in calls] == [0, 1, 3, 4]
def test_a_raise_from_a_redraw_ends_the_pause(self, monkeypatch):
from src.vegas_mode import coordinator
clock = FakeClock()
monkeypatch.setattr(coordinator, 'time', clock)
plugin = MagicMock()
plugin.plugin_id = 'clock'
plugin.needs_high_fps = False
plugin.get_display_duration.return_value = 30.0
plugin.display.side_effect = [None, RuntimeError('boom')]
coord = TestCoordinatorStaticPause()._coord(plugin)
assert coord._handle_static_pause(plugin) is False
assert clock.now < 1.2
assert coord._static_pause_active is False