Files
LEDMatrix/test/test_display_manager_logging.py
T
ChuckandClaude Opus 5.5 6f45ff5e63 fix(display): thread-safety for deferred updates, BDF faces and follower image; one refresh default (#652)
- DisplayManager.defer_update()/process_deferred_updates(): one lock around
  every queue mutation (appends from the update thread were lost to the
  render thread's filter/slice reassignments); callables run outside it.
- FontManager and element_style no longer cache BDF freetype.Face objects
  process-wide (load_bdf_face caches them per thread); element_style's LRU
  is locked against get/move_to_end vs eviction races.
- limit_refresh_rate_hz default is one constant, DEFAULT_REFRESH_LIMIT_HZ =
  100 (the template's), for the library options, refresh_hz, the matrix
  guard, Vegas and scroll_config. Previously a missing key capped the panel
  at 90 while pacing assumed 100.
- Sync follower: the TCP thread queues the leader's scroll image; the render
  thread swaps image/array/width in between frames.
- update_display() error log rate-limited (traceback first, then once a
  minute with a count); swallowed DisplayController exceptions log at DEBUG.
- Root display_controller.py runs run.py via runpy.
- stream_manager: correct the RLock release comments; merge duplicate if.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:39:40 -04:00

75 lines
2.8 KiB
Python

"""display_manager's logger must follow the level run.py configures.
The module pinned its own logger to INFO at import, which overrides the root
level, so ``run.py -d`` never showed a single DEBUG line from the display
manager -- the dirty-tracking, fallback-mode and scrolling-state traces were
unreachable without editing the source.
"""
import logging
import os
os.environ.setdefault("EMULATOR", "true")
import src.display_manager as dm
def test_the_module_sets_no_level_of_its_own():
assert dm.logger.level == logging.NOTSET
def test_scrolling_state_logs_only_when_it_changes(caplog):
# Vegas and every scrolling plugin set the state on each frame, so a line
# per call was ~120 DEBUG lines a second once debug output was visible.
dm_obj = object.__new__(dm.DisplayManager)
dm_obj._frame_hold = 1
dm_obj._scrolling_state = {'is_scrolling': False, 'last_scroll_activity': 0}
with caplog.at_level(logging.DEBUG, logger='src.display_manager'):
for _ in range(5):
dm_obj.set_scrolling_state(True, frame_hold=2)
dm_obj.set_scrolling_state(False)
dm_obj.set_scrolling_state(False)
lines = [r.getMessage() for r in caplog.records
if r.getMessage().startswith('Scrolling state set to')]
assert lines == ['Scrolling state set to: True', 'Scrolling state set to: False']
# The state itself still updates on every call.
assert dm_obj._scrolling_state['is_scrolling'] is False
assert dm_obj._frame_hold == 1
def test_debug_output_appears_when_the_root_is_at_debug():
root = logging.getLogger()
previous = root.level
root.setLevel(logging.DEBUG)
try:
assert dm.logger.isEnabledFor(logging.DEBUG)
finally:
root.setLevel(previous)
def test_update_display_errors_are_rate_limited(caplog):
# update_display() runs every frame; a persistent fault logged an ERROR
# line per frame (~100 a second) and never a traceback.
from unittest.mock import MagicMock
dm_obj = object.__new__(dm.DisplayManager)
dm_obj._writes_suppressed = MagicMock(side_effect=RuntimeError("boom"))
with caplog.at_level(logging.ERROR, logger='src.display_manager'):
for _ in range(50):
dm_obj.update_display() # must not raise
errors = [r for r in caplog.records
if r.getMessage().startswith('Error updating display')]
assert len(errors) == 1
assert errors[0].exc_info is not None
# Once the interval has passed, one more line reports what was skipped.
dm_obj._update_error_logged_at -= dm._UPDATE_ERROR_LOG_INTERVAL + 1
dm_obj.update_display()
errors = [r for r in caplog.records
if r.getMessage().startswith('Error updating display')]
assert len(errors) == 2
assert '49 more' in errors[1].getMessage()