Files
LEDMatrix/test/test_follower_scroll_image_handoff.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

79 lines
3.0 KiB
Python

"""Follower adoption of the leader's scroll image (src/display_controller.py).
The leader's image arrives on the sync TCP thread. That callback used to set
scroll_helper.cached_image, cached_array and total_scroll_width one after
another while the render thread sliced frames out of them, so a frame could
pair the new array with the old width. The callback now only queues the
image; the render thread swaps all three in at the start of a follower frame.
"""
import os
from collections import deque
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
os.environ.setdefault("EMULATOR", "true")
from PIL import Image # noqa: E402
from src.display_controller import DisplayController # noqa: E402
def _wired_controller():
dc = object.__new__(DisplayController)
dc.config = {"display": {"vegas_scroll": {"enabled": True}}, "sync": {}}
dc.display_manager = MagicMock()
dc.plugin_manager = MagicMock()
dc.sync_manager = MagicMock()
dc._check_live_priority = MagicMock()
dc._check_vegas_interrupt = MagicMock(return_value=False)
dc._follower_pending_new_image = True
dc._follower_incoming_image = deque(maxlen=1)
old = Image.new("RGB", (100, 16))
helper = SimpleNamespace(cached_image=old, cached_array="old-array",
total_scroll_width=100)
coordinator = MagicMock()
coordinator.render_pipeline = SimpleNamespace(scroll_helper=helper)
with patch('src.vegas_mode.VegasModeCoordinator',
MagicMock(return_value=coordinator)):
dc._initialize_vegas_mode()
on_image = dc.sync_manager.set_on_scroll_image.call_args[0][0]
return dc, coordinator.render_pipeline, helper, old, on_image
def test_tcp_callback_does_not_touch_the_render_state():
dc, _rp, helper, old, on_image = _wired_controller()
on_image(Image.new("RGB", (300, 16)))
# Nothing the render thread reads has changed yet.
assert helper.cached_image is old
assert helper.cached_array == "old-array"
assert helper.total_scroll_width == 100
assert dc._follower_pending_new_image is True
def test_render_thread_adopts_all_three_together():
dc, rp, helper, _old, on_image = _wired_controller()
new = Image.new("RGB", (300, 16), (255, 0, 0))
on_image(new)
dc._adopt_follower_scroll_image(rp)
assert helper.cached_image is new
assert helper.cached_array.shape == (16, 300, 3)
assert helper.cached_array[0, 0].tolist() == [255, 0, 0]
assert helper.total_scroll_width == 300
assert dc._follower_pending_new_image is False
# Consumed: a second frame changes nothing.
helper.total_scroll_width = 7
dc._adopt_follower_scroll_image(rp)
assert helper.total_scroll_width == 7
def test_only_the_latest_image_is_adopted():
dc, rp, helper, _old, on_image = _wired_controller()
on_image(Image.new("RGB", (200, 16)))
latest = Image.new("RGB", (400, 16))
on_image(latest)
dc._adopt_follower_scroll_image(rp)
assert helper.cached_image is latest
assert helper.total_scroll_width == 400