mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
The frame loops and the dwell sleep run PluginManager.run_scheduled_updates() at most every 0.25 s instead of after every frame (the top of each loop pass still ticks unthrottled), and SportsScrollDisplay and the sync follower ask ScrollHelper.has_strip() instead of building cached_image just to see whether a strip exists. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
135 lines
5.3 KiB
Python
135 lines
5.3 KiB
Python
"""The render loop's scheduled-update pass, throttled per frame.
|
|
|
|
The frame loops called _tick_plugin_updates() after every frame, about 125
|
|
times a second on a scroller, and each call ran
|
|
PluginManager.run_scheduled_updates(): a copy of the plugin dict and several
|
|
locks per plugin, to find that nothing was due (no interval is shorter than
|
|
5 s). The frame loops and the dwell sleep now call
|
|
_tick_plugin_updates_if_due(), which runs it at most once per
|
|
PLUGIN_UPDATE_TICK_INTERVAL. The top of each loop pass still calls
|
|
_tick_plugin_updates() unthrottled, because a plugin loaded, reloaded or
|
|
enabled for on-demand there is due at once.
|
|
"""
|
|
|
|
import os
|
|
from types import SimpleNamespace
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
os.environ.setdefault("EMULATOR", "true")
|
|
|
|
from src import display_controller as dc_mod # noqa: E402
|
|
from src.display_controller import DisplayController # noqa: E402
|
|
from test._run_loop_harness import FakePlugin, RunLoopHarness # noqa: E402
|
|
|
|
|
|
@pytest.fixture
|
|
def clock(monkeypatch):
|
|
now = {"t": 1000.0}
|
|
fake = SimpleNamespace(monotonic=lambda: now["t"], time=lambda: now["t"])
|
|
monkeypatch.setattr(dc_mod, "time", fake)
|
|
return now
|
|
|
|
|
|
@pytest.fixture
|
|
def dc():
|
|
controller = object.__new__(DisplayController)
|
|
controller.plugin_manager = MagicMock()
|
|
return controller
|
|
|
|
|
|
def _calls(dc):
|
|
return dc.plugin_manager.run_scheduled_updates.call_count
|
|
|
|
|
|
class TestThrottle:
|
|
def test_the_first_call_runs(self, dc, clock):
|
|
dc._tick_plugin_updates_if_due()
|
|
assert _calls(dc) == 1
|
|
|
|
def test_calls_inside_the_window_are_skipped(self, dc, clock):
|
|
dc._tick_plugin_updates_if_due()
|
|
for _ in range(30): # a scroller's frames, 8 ms apart
|
|
clock["t"] += 0.008
|
|
dc._tick_plugin_updates_if_due()
|
|
assert clock["t"] - 1000.0 < dc.PLUGIN_UPDATE_TICK_INTERVAL
|
|
assert _calls(dc) == 1
|
|
|
|
def test_it_runs_again_once_the_window_has_passed(self, dc, clock):
|
|
dc._tick_plugin_updates_if_due()
|
|
clock["t"] += dc.PLUGIN_UPDATE_TICK_INTERVAL
|
|
dc._tick_plugin_updates_if_due()
|
|
assert _calls(dc) == 2
|
|
|
|
def test_the_unthrottled_tick_always_runs_and_restarts_the_window(self, dc, clock):
|
|
# The top of the loop pass: a plugin just (re)loaded is due now.
|
|
dc._tick_plugin_updates_if_due()
|
|
clock["t"] += 0.01
|
|
dc._tick_plugin_updates()
|
|
assert _calls(dc) == 2
|
|
# ...and the frame right after it does not repeat the pass.
|
|
clock["t"] += 0.01
|
|
dc._tick_plugin_updates_if_due()
|
|
assert _calls(dc) == 2
|
|
|
|
def test_no_plugin_manager(self, clock):
|
|
controller = object.__new__(DisplayController)
|
|
controller.plugin_manager = None
|
|
controller._tick_plugin_updates_if_due() # must not raise
|
|
controller._tick_plugin_updates()
|
|
|
|
def test_a_failing_pass_is_contained_and_still_throttled(self, dc, clock):
|
|
dc.plugin_manager.run_scheduled_updates.side_effect = RuntimeError("boom")
|
|
dc._tick_plugin_updates_if_due()
|
|
dc._tick_plugin_updates_if_due()
|
|
assert _calls(dc) == 1
|
|
|
|
def test_the_vegas_tick_is_not_throttled(self, dc, clock):
|
|
# Vegas's own update thread calls the plugin manager directly.
|
|
dc.vegas_coordinator = None
|
|
dc.plugin_manager.run_scheduled_updates_with_changes.return_value = []
|
|
dc._tick_plugin_updates()
|
|
dc._tick_plugin_updates_for_vegas()
|
|
dc._tick_plugin_updates_for_vegas()
|
|
assert dc.plugin_manager.run_scheduled_updates_with_changes.call_count == 2
|
|
|
|
|
|
class TestInTheRunLoop:
|
|
"""The real run() on the golden-trace harness's fake clock."""
|
|
|
|
@pytest.fixture
|
|
def run(self, tmp_path):
|
|
h = RunLoopHarness(tmp_path, horizon=40)
|
|
h.add_plugin(FakePlugin("ticker", ["ticker"], duration=10, enable_scrolling=True))
|
|
h.add_plugin(FakePlugin("clock", ["clock"], duration=10))
|
|
ticks = []
|
|
h.pm.run_scheduled_updates = lambda: ticks.append(round(h.clock.rel(), 3))
|
|
h.run()
|
|
passes = sorted({t for t, kind, _s, _d in h.events if kind == "pass"})
|
|
frames = [t for t, kind, s, _d in h.events if kind in ("first", "frame") and s == "ticker"]
|
|
return SimpleNamespace(ticks=ticks, passes=passes, frames=frames,
|
|
interval=h.controller.PLUGIN_UPDATE_TICK_INTERVAL)
|
|
|
|
def test_a_scroller_ticks_a_few_times_a_second_not_every_frame(self, run):
|
|
ticker_frames = len(run.frames)
|
|
assert ticker_frames > 500 # 10 s screens at 125 Hz, twice
|
|
# 40 s at four a second, plus one per pass.
|
|
assert len(run.ticks) <= 40 / run.interval + len(run.passes) + 1
|
|
|
|
def test_ticks_are_never_closer_than_the_floor_except_at_a_pass(self, run):
|
|
passes = set(run.passes)
|
|
for prev, cur in zip(run.ticks, run.ticks[1:]):
|
|
if cur - prev < run.interval - 1e-6:
|
|
assert cur in passes, (prev, cur)
|
|
|
|
def test_every_pass_ticks_at_once(self, run):
|
|
# Unthrottled: the frame loop ticked just before the screen ended, and
|
|
# the pass ticks again anyway, so a plugin it just loaded is not kept
|
|
# waiting.
|
|
ticks = set(run.ticks)
|
|
for t in run.passes:
|
|
assert t in ticks, t
|
|
assert any(cur - prev < run.interval
|
|
for prev, cur in zip(run.ticks, run.ticks[1:]))
|