mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 08:48:05 +00:00
163 lines
5.9 KiB
Python
163 lines
5.9 KiB
Python
"""Tests for update_display dirty tracking (src/display_manager.py).
|
|
|
|
Runs against RGBMatrixEmulator (EMULATOR=true), exercising the REAL
|
|
DisplayManager — not a mock — so the skip logic, its invalidation hooks,
|
|
and the kill switch are verified off-Pi.
|
|
|
|
The invariants:
|
|
- identical frames are pushed exactly once (SwapOnVSync not re-called)
|
|
- ANY pixel change pushes
|
|
- clear() and set_brightness() invalidate (the two paths that alter panel
|
|
state outside the digest's view)
|
|
- the kill switch (display.dirty_tracking: false) restores always-push
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import time
|
|
|
|
os.environ["EMULATOR"] = "true"
|
|
|
|
import pytest
|
|
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def dm():
|
|
"""One real DisplayManager on the emulator (it's a process singleton)."""
|
|
from src.display_manager import DisplayManager
|
|
DisplayManager._instance = None
|
|
DisplayManager._initialized = False
|
|
manager = DisplayManager({
|
|
"display": {
|
|
"hardware": {"rows": 32, "cols": 64, "chain_length": 2,
|
|
"parallel": 1, "brightness": 90},
|
|
"runtime": {"gpio_slowdown": 0},
|
|
},
|
|
}, suppress_test_pattern=True)
|
|
yield manager
|
|
DisplayManager._instance = None
|
|
DisplayManager._initialized = False
|
|
|
|
|
|
class _SwapSpy:
|
|
"""Counts SwapOnVSync calls through the real matrix object."""
|
|
|
|
def __init__(self, matrix):
|
|
self.matrix = matrix
|
|
self.count = 0
|
|
self._orig = matrix.SwapOnVSync
|
|
|
|
def __enter__(self):
|
|
def counting(canvas):
|
|
self.count += 1
|
|
return self._orig(canvas)
|
|
self.matrix.SwapOnVSync = counting
|
|
return self
|
|
|
|
def __exit__(self, *exc):
|
|
self.matrix.SwapOnVSync = self._orig
|
|
|
|
|
|
class TestDirtyTracking:
|
|
def test_identical_frames_push_once(self, dm):
|
|
dm.draw.rectangle([0, 0, 10, 10], fill=(255, 0, 0))
|
|
with _SwapSpy(dm.matrix) as spy:
|
|
dm.update_display()
|
|
dm.update_display()
|
|
dm.update_display()
|
|
assert spy.count == 1
|
|
|
|
def test_pixel_change_pushes(self, dm):
|
|
dm.update_display()
|
|
with _SwapSpy(dm.matrix) as spy:
|
|
dm.draw.point((5, 5), fill=(0, 255, 0))
|
|
dm.update_display()
|
|
dm.update_display() # unchanged again
|
|
assert spy.count == 1
|
|
|
|
def test_clear_invalidates(self, dm):
|
|
dm.draw.rectangle([0, 0, 20, 20], fill=(0, 0, 255))
|
|
dm.update_display()
|
|
dm.clear() # writes to the matrix directly; digest must reset
|
|
with _SwapSpy(dm.matrix) as spy:
|
|
dm.update_display() # black frame after clear must still push
|
|
assert spy.count == 1
|
|
|
|
def test_brightness_change_forces_push(self, dm):
|
|
dm.draw.rectangle([0, 0, 20, 20], fill=(200, 200, 200))
|
|
dm.update_display()
|
|
with _SwapSpy(dm.matrix) as spy:
|
|
dm.update_display() # identical -> skipped
|
|
assert spy.count == 0
|
|
dm.set_brightness(40) # dim schedule scenario
|
|
dm.update_display() # same image, new brightness -> push
|
|
assert spy.count == 1
|
|
dm.set_brightness(90)
|
|
|
|
def test_snapshot_still_written_on_skip(self, dm, tmp_path):
|
|
"""The web preview mirror must keep working through skipped panel
|
|
pushes: _write_snapshot_if_due() still runs on the dirty-tracking
|
|
skip path and applies its own write/touch policy rather than being
|
|
bypassed entirely (see src/common/snapshot_policy.py — an unchanged
|
|
frame is touched, not re-encoded, once TOUCH_INTERVAL elapses)."""
|
|
dm._snapshot_path = str(tmp_path / "snap.png")
|
|
dm._last_snapshot_ts = 0.0
|
|
dm._last_snapshot_touch_ts = 0.0
|
|
dm._last_snapshot_digest = None
|
|
dm.draw.rectangle([0, 0, 30, 8], fill=(255, 255, 0))
|
|
dm.update_display() # push + snapshot write (first frame)
|
|
assert os.path.exists(dm._snapshot_path)
|
|
first_mtime = os.path.getmtime(dm._snapshot_path)
|
|
|
|
# Age the write/touch bookkeeping past TOUCH_INTERVAL so the next
|
|
# identical frame is due for a touch, then push it again: dirty
|
|
# tracking must skip the panel write, but the snapshot mirror must
|
|
# still get its mtime bumped so the health check doesn't go stale.
|
|
from src.common import snapshot_policy
|
|
stale_ts = time.time() - snapshot_policy.TOUCH_INTERVAL - 1.0
|
|
dm._last_snapshot_ts = stale_ts
|
|
dm._last_snapshot_touch_ts = stale_ts
|
|
with _SwapSpy(dm.matrix) as spy:
|
|
dm.update_display() # identical frame -> panel push skipped
|
|
assert spy.count == 0
|
|
assert os.path.getmtime(dm._snapshot_path) > first_mtime
|
|
|
|
|
|
class TestKillSwitch:
|
|
def test_dirty_tracking_can_be_disabled(self, dm):
|
|
dm._dirty_tracking_enabled = False
|
|
try:
|
|
dm.draw.rectangle([0, 0, 10, 10], fill=(1, 2, 3))
|
|
with _SwapSpy(dm.matrix) as spy:
|
|
dm.update_display()
|
|
dm.update_display()
|
|
dm.update_display()
|
|
assert spy.count == 3 # always-push, exactly the old behavior
|
|
finally:
|
|
dm._dirty_tracking_enabled = True
|
|
dm._last_pushed_digest = None
|
|
|
|
def test_config_flag_wires_through(self):
|
|
from src.display_manager import DisplayManager
|
|
DisplayManager._instance = None
|
|
DisplayManager._initialized = False
|
|
try:
|
|
manager = DisplayManager({
|
|
"display": {
|
|
"hardware": {"rows": 32, "cols": 64, "chain_length": 1,
|
|
"parallel": 1},
|
|
"runtime": {"gpio_slowdown": 0},
|
|
"dirty_tracking": False,
|
|
},
|
|
}, suppress_test_pattern=True)
|
|
assert manager._dirty_tracking_enabled is False
|
|
finally:
|
|
DisplayManager._instance = None
|
|
DisplayManager._initialized = False
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(pytest.main([__file__, "-v"]))
|