mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
Mid-scroll, update_display() no longer checksums every frame: it asks is_currently_scrolling() once per frame and reuses the answer, hashes only when dirty tracking can skip a static frame, and the preview snapshot asks its policy first and hashes only when a write or touch could follow (decide() is monotone, pinned by a property test). With the preview open the snapshot is written at most once a second (VIEWER_INTERVAL 1.0 s, was 0.2 s; the SSE stream re-read it once a second, so four encodes in five went unread); the stream now polls its mtime every 0.25 s (VIEWER_POLL_INTERVAL), so the preview stays about as fresh. The PNG is written at compress_level=1. --preview soaks are not comparable across this change. Merged with #716: _scan_segments takes the frame's one scrolling answer and #716's static-handover pass-through, as soaked on ledpi (A B B A, 20 min each: main 0.118% / 0.113% late, with #716 and this 0.107% / 0.104%). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
138 lines
5.9 KiB
Python
138 lines
5.9 KiB
Python
"""Tests for the snapshot write policy (src/common/snapshot_policy.py).
|
|
|
|
The invariants that matter:
|
|
- unchanged frames are NEVER re-encoded (the old code PNG-encoded identical
|
|
frames at 5 fps, 24/7)
|
|
- the file mtime never goes stale enough to trip the health check's 60s
|
|
degraded threshold (api_v3 get_hardware_status)
|
|
- a viewer gets the viewer cadence; no viewer drops to the idle keepalive
|
|
- decide() is monotone in frame_changed, which DisplayManager's lazy
|
|
frame hash relies on
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
|
|
import pytest
|
|
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
|
|
|
from src.common.snapshot_policy import ( # noqa: E402
|
|
IDLE_INTERVAL,
|
|
TOUCH_INTERVAL,
|
|
VIEWER_INTERVAL,
|
|
VIEWER_MARKER_FRESH_SEC,
|
|
VIEWER_POLL_INTERVAL,
|
|
SnapshotAction,
|
|
decide,
|
|
)
|
|
|
|
|
|
class TestViewerCadence:
|
|
def test_changed_frame_with_viewer_writes_at_full_rate(self):
|
|
assert decide(now=100.0, last_write_ts=100.0 - VIEWER_INTERVAL,
|
|
last_touch_ts=0, viewer_fresh=True,
|
|
frame_changed=True) is SnapshotAction.WRITE
|
|
|
|
def test_changed_frame_with_viewer_respects_min_interval(self):
|
|
assert decide(now=100.0, last_write_ts=100.0 - VIEWER_INTERVAL / 2,
|
|
last_touch_ts=100.0, viewer_fresh=True,
|
|
frame_changed=True) is SnapshotAction.SKIP
|
|
|
|
def test_unchanged_frame_with_viewer_never_writes(self):
|
|
"""A static screen with a viewer must not burn PNG encodes."""
|
|
assert decide(now=100.0, last_write_ts=90.0, last_touch_ts=90.0,
|
|
viewer_fresh=True,
|
|
frame_changed=False) is SnapshotAction.SKIP
|
|
|
|
|
|
class TestIdleCadence:
|
|
def test_changed_frame_without_viewer_waits_for_idle_interval(self):
|
|
assert decide(now=100.0, last_write_ts=100.0 - IDLE_INTERVAL / 2,
|
|
last_touch_ts=100.0, viewer_fresh=False,
|
|
frame_changed=True) is SnapshotAction.SKIP
|
|
|
|
def test_changed_frame_without_viewer_writes_at_idle_rate(self):
|
|
assert decide(now=100.0, last_write_ts=100.0 - IDLE_INTERVAL,
|
|
last_touch_ts=0, viewer_fresh=False,
|
|
frame_changed=True) is SnapshotAction.WRITE
|
|
|
|
|
|
class TestHealthKeepalive:
|
|
def test_stale_mtime_gets_touched(self):
|
|
"""Whatever else happens, mtime must be bumped within TOUCH_INTERVAL
|
|
so the health check (60s threshold) never reads the display as dead."""
|
|
assert decide(now=100.0, last_write_ts=100.0 - TOUCH_INTERVAL,
|
|
last_touch_ts=100.0 - TOUCH_INTERVAL, viewer_fresh=False,
|
|
frame_changed=False) is SnapshotAction.TOUCH
|
|
|
|
def test_touch_applies_with_viewer_too(self):
|
|
"""Viewer watching a static screen: no writes, but health stays green."""
|
|
assert decide(now=100.0, last_write_ts=100.0 - TOUCH_INTERVAL - 1,
|
|
last_touch_ts=100.0 - TOUCH_INTERVAL - 1, viewer_fresh=True,
|
|
frame_changed=False) is SnapshotAction.TOUCH
|
|
|
|
def test_recent_touch_suppresses_another(self):
|
|
assert decide(now=100.0, last_write_ts=0.0,
|
|
last_touch_ts=100.0 - TOUCH_INTERVAL / 2, viewer_fresh=False,
|
|
frame_changed=False) is SnapshotAction.SKIP
|
|
|
|
def test_touch_interval_stays_under_health_threshold(self):
|
|
"""api_v3's hardware status treats snapshot age >= 60s as degraded.
|
|
Keep a 2x margin so scheduling jitter can't trip it."""
|
|
assert TOUCH_INTERVAL <= 30
|
|
|
|
def test_worst_case_mtime_age_is_bounded(self):
|
|
"""Simulate any interleaving: from any state, within one policy call
|
|
after TOUCH_INTERVAL elapses, mtime gets refreshed (WRITE or TOUCH)."""
|
|
for viewer in (True, False):
|
|
for changed in (True, False):
|
|
action = decide(now=1000.0, last_write_ts=900.0,
|
|
last_touch_ts=900.0, viewer_fresh=viewer,
|
|
frame_changed=changed)
|
|
assert action in (SnapshotAction.WRITE, SnapshotAction.TOUCH)
|
|
|
|
|
|
class TestMonotoneInFrameChanged:
|
|
"""DisplayManager asks decide() as if the frame had changed, and only
|
|
hashes the frame when that answer is not SKIP. That is exact only while
|
|
a SKIP for a changed frame is also a SKIP for an unchanged one."""
|
|
|
|
def test_skip_for_a_changed_frame_is_skip_for_an_unchanged_one(self):
|
|
# Ages that straddle every threshold decide() compares against, plus
|
|
# a timestamp in the future (a wall-clock step back) and "never".
|
|
eps = 1e-6
|
|
ages = sorted({0.0, -1.0, 1000.0, 100.0} | {
|
|
base + delta
|
|
for base in (VIEWER_INTERVAL, IDLE_INTERVAL, TOUCH_INTERVAL)
|
|
for delta in (-eps, 0.0, eps)
|
|
})
|
|
now = 1000.0
|
|
checked = 0
|
|
for write_age in ages:
|
|
for touch_age in ages:
|
|
for viewer in (True, False):
|
|
args = (now, now - write_age, now - touch_age, viewer)
|
|
if decide(*args, frame_changed=True) is SnapshotAction.SKIP:
|
|
checked += 1
|
|
assert decide(*args, frame_changed=False) is SnapshotAction.SKIP, args
|
|
assert checked, "the grid never reached a SKIP; it tests nothing"
|
|
|
|
|
|
class TestPreviewPolling:
|
|
def test_the_reader_polls_faster_than_the_writer_writes(self):
|
|
"""Equal periods alias: two unsynchronised clocks of the same period
|
|
leave the preview up to a period stale, with the odd double gap.
|
|
Polling at least twice per interval keeps each frame at most half an
|
|
interval late."""
|
|
assert VIEWER_POLL_INTERVAL <= VIEWER_INTERVAL / 2
|
|
|
|
def test_polling_keeps_the_viewer_marker_fresh(self):
|
|
# The SSE reader touches the marker about once a second however fast
|
|
# it polls; the display must still count that as a live viewer.
|
|
assert max(VIEWER_POLL_INTERVAL, 1.0) < VIEWER_MARKER_FRESH_SEC
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(pytest.main([__file__, "-v"]))
|