mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 22:35:08 +00:00
While a plugin's update() runs it holds the plugin's lock and its frames are skipped -- on a scroller, a frozen strip -- with nothing logged. The high-FPS loop now times each run of skipped frames (report_hold=True); one of 250 ms or more logs 'Display of X held N ms by its update()' (rate-limited per plugin) and is recorded as a 'display hold' busy skip, which never touches the circuit breaker. The 1 Hz loop is left out: one skipped frame there measures the loop interval on a screen that did not visibly freeze (seen on ledpi as ~1000 ms reports on clock-simple and switch-mode football). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
143 lines
5.0 KiB
Python
143 lines
5.0 KiB
Python
"""The report of a scrolling screen held by its plugin's update().
|
|
|
|
While a plugin's update() runs it holds the plugin's lock, and its screen's
|
|
frames are skipped -- on a scroller, a frozen strip -- with nothing logged.
|
|
_note_display_hold times each such run and reports one of
|
|
DISPLAY_HOLD_REPORT_SECONDS or more.
|
|
"""
|
|
|
|
import threading
|
|
import types
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
|
|
class _Clock:
|
|
"""display_controller's clock: moves only when run() sleeps or a test says."""
|
|
|
|
def __init__(self, start=10_000.0):
|
|
self.t = start
|
|
|
|
def now(self):
|
|
return self.t
|
|
|
|
def sleep(self, seconds):
|
|
self.t += max(seconds, 0.0005)
|
|
|
|
def module(self):
|
|
return types.SimpleNamespace(time=self.now, monotonic=self.now,
|
|
perf_counter=self.now, sleep=self.sleep)
|
|
|
|
|
|
@pytest.fixture
|
|
def clock(monkeypatch):
|
|
c = _Clock()
|
|
monkeypatch.setattr("src.display_controller.time", c.module())
|
|
return c
|
|
|
|
|
|
class _Locks:
|
|
"""get_plugin_lock for one plugin, whose lock the test can hold."""
|
|
|
|
def __init__(self):
|
|
self.lock = threading.Lock()
|
|
|
|
def __call__(self, plugin_id):
|
|
return self.lock
|
|
|
|
|
|
@pytest.fixture
|
|
def held(test_display_controller):
|
|
c = test_display_controller
|
|
locks = _Locks()
|
|
c.plugin_manager.get_plugin_lock = locks
|
|
c.plugin_manager._warn_rate_limited = MagicMock()
|
|
c.plugin_manager.health_tracker = MagicMock()
|
|
c._display_hold = None
|
|
return c, locks.lock
|
|
|
|
|
|
def _plugin(plugin_id):
|
|
p = MagicMock()
|
|
p.plugin_id = plugin_id
|
|
p.display.return_value = True
|
|
return p
|
|
|
|
|
|
class TestTheDisplayHoldReport:
|
|
def test_a_long_hold_is_reported_when_it_ends(self, held, clock):
|
|
c, lock = held
|
|
ticker = _plugin("ticker")
|
|
lock.acquire() # update() running
|
|
assert c._display_once(ticker, "ticker", False, report_hold=True) is True
|
|
clock.t += 0.2
|
|
assert c._display_once(ticker, "ticker", False, report_hold=True) is True
|
|
assert ticker.display.call_count == 0
|
|
c.plugin_manager._warn_rate_limited.assert_not_called()
|
|
clock.t += 0.2
|
|
lock.release() # update() done
|
|
c._display_once(ticker, "ticker", False, report_hold=True)
|
|
assert ticker.display.call_count == 1
|
|
key, message, plugin_id, ms = c.plugin_manager._warn_rate_limited.call_args[0]
|
|
assert key == "display-hold:ticker" and plugin_id == "ticker"
|
|
assert "held" in message and ms == pytest.approx(400.0)
|
|
c.plugin_manager.health_tracker.record_busy_skip.assert_called_once_with(
|
|
"ticker", "display hold", pytest.approx(0.4))
|
|
|
|
def test_a_short_hold_is_not(self, held, clock):
|
|
c, lock = held
|
|
ticker = _plugin("ticker")
|
|
lock.acquire()
|
|
c._display_once(ticker, "ticker", False, report_hold=True)
|
|
clock.t += 0.1
|
|
lock.release()
|
|
c._display_once(ticker, "ticker", False, report_hold=True)
|
|
c.plugin_manager._warn_rate_limited.assert_not_called()
|
|
c.plugin_manager.health_tracker.record_busy_skip.assert_not_called()
|
|
|
|
def test_a_hold_that_ends_on_another_plugins_screen_is_not_blamed_on_it(
|
|
self, held, clock):
|
|
c, lock = held
|
|
lock.acquire()
|
|
c._display_once(_plugin("ticker"), "ticker", False, report_hold=True)
|
|
clock.t += 1.0
|
|
lock.release()
|
|
c._display_once(_plugin("clock"), "clock", False, report_hold=True)
|
|
c.plugin_manager._warn_rate_limited.assert_not_called()
|
|
assert c._display_hold is None
|
|
|
|
def test_frames_that_draw_report_nothing(self, held, clock):
|
|
c, _lock = held
|
|
ticker = _plugin("ticker")
|
|
for _ in range(5):
|
|
c._display_once(ticker, "ticker", False, report_hold=True)
|
|
clock.t += 0.5
|
|
c.plugin_manager._warn_rate_limited.assert_not_called()
|
|
|
|
def test_the_1hz_loop_reports_no_holds(self, held, clock):
|
|
# A static screen's frames are a second apart: one skipped frame is
|
|
# not a measured hold, and nothing on the panel froze. (On ledpi the
|
|
# first version reported every such skip as "held 1000 ms".)
|
|
c, lock = held
|
|
board = _plugin("board")
|
|
lock.acquire()
|
|
c._display_once(board, "board", False)
|
|
clock.t += 1.0
|
|
lock.release()
|
|
c._display_once(board, "board", False)
|
|
c.plugin_manager._warn_rate_limited.assert_not_called()
|
|
c.plugin_manager.health_tracker.record_busy_skip.assert_not_called()
|
|
|
|
def test_a_run_left_open_is_dropped_by_a_1hz_frame(self, held, clock):
|
|
c, lock = held
|
|
ticker = _plugin("ticker")
|
|
lock.acquire()
|
|
c._display_once(ticker, "ticker", False, report_hold=True)
|
|
lock.release()
|
|
c._display_once(ticker, "ticker", False) # the 1 Hz loop draws
|
|
assert c._display_hold is None
|
|
clock.t += 5.0
|
|
c._display_once(ticker, "ticker", False, report_hold=True)
|
|
c.plugin_manager._warn_rate_limited.assert_not_called()
|