mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-18 04:49:13 +00:00
Whole-pixel motion was previously only available at multiples of the refresh rate -- 100, 200, 300 px/s on a 100Hz panel. 100 px/s crosses a 256px panel in 2.6s, which is brisk for reading, and everything slower had to blend (blur) or repeat frames unevenly (judder). There was no way to ask for 50 px/s and get clean motion. SwapOnVSync takes a framerate_fraction the display manager never passed. It holds each frame for N panel refreshes; the panel keeps refreshing at its full rate throughout, so holding costs nothing in flicker and only changes how often a NEW image is presented. That turns 50 px/s into one whole pixel every second refresh instead of half a pixel every refresh. The crisp speeds are therefore refresh_hz / hold * pixels_per_frame, and that ladder depends on the panel: a Pi Zero on a long chain has a different set of good speeds from a Pi 4 on a short one. crisp_ladder() enumerates them and solve_crisp() picks the best match for a requested speed. solve_crisp weights motion quality rather than picking the numerically nearest entry, which matters more than it sounds. Asked for 30 px/s, nearest-by-value answers 28.6 -- 2px jumps at 14fps -- over 33.3, which is single-pixel motion at 33fps and obviously better on the panel. The target is also clamped into the ladder's range first, because relative error saturates near 1.0 for a target far outside it and the quality penalty would otherwise answer "10000 px/s" with the slowest entry. configure() snaps to the ladder and applies the hold when given a display manager. Without one the hold silently cannot happen and motion falls back to fractional pixels, so it warns rather than failing quietly. set_frame_hold() resets to 1 when scrolling stops, so one plugin's pacing cannot leak into whatever is on screen next. scripts/scroll_speeds.py is the user-facing part: it prints the ladder for the configured rate, measures what the panel ACTUALLY manages (--measure, for hardware that cannot reach its configured limit), highlights the nearest option to a wanted speed, and demos one live. It never starts or stops the display service itself -- doing that inside a script stranded the panel twice today. Speeds below ~20 px/s remain stepped regardless. That is the pixel pitch, not a software limit. Also fixes the dirty-tracking test spy, which stubbed SwapOnVSync with a single-argument function and would have masked the new call as a failed push, and rewrites a configure() test that had started passing for the wrong reason: it asserted a judder warning, which snapping now prevents, and was matching the unrelated "hold could not be applied" warning instead. 183 tests pass on the Pi. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
353 lines
14 KiB
Python
353 lines
14 KiB
Python
"""Tests for the shared scroll configuration resolver."""
|
|
|
|
import logging
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
|
|
from src.common.scroll_config import ( # noqa: E402
|
|
DEFAULT_PIXELS_PER_SECOND,
|
|
MAX_PIXELS_PER_FRAME,
|
|
crisp_ladder,
|
|
solve_crisp,
|
|
MAX_PIXELS_PER_SECOND,
|
|
MIN_PIXELS_PER_SECOND,
|
|
ScrollSettings,
|
|
configure,
|
|
refresh_hz_from_config,
|
|
resolve,
|
|
)
|
|
|
|
|
|
class FakeHelper:
|
|
"""Records what configure() applied."""
|
|
|
|
def __init__(self, with_optional=True):
|
|
self.speed = None
|
|
self.frame_based = None
|
|
self.target_fps = None
|
|
if not with_optional:
|
|
del FakeHelper.set_frame_based_scrolling
|
|
del FakeHelper.set_target_fps
|
|
|
|
def set_scroll_speed(self, speed):
|
|
self.speed = speed
|
|
|
|
def set_frame_based_scrolling(self, enabled):
|
|
self.frame_based = enabled
|
|
|
|
def set_target_fps(self, fps):
|
|
self.target_fps = fps
|
|
|
|
|
|
class FakeDisplayManager:
|
|
"""Records the frame hold configure() applies."""
|
|
|
|
def __init__(self):
|
|
self.hold = None
|
|
|
|
def set_frame_hold(self, refreshes):
|
|
self.hold = refreshes
|
|
|
|
|
|
class MinimalHelper:
|
|
"""An older helper exposing only set_scroll_speed."""
|
|
|
|
def __init__(self):
|
|
self.speed = None
|
|
|
|
def set_scroll_speed(self, speed):
|
|
self.speed = speed
|
|
|
|
|
|
class TestPrecedence:
|
|
def test_display_options_pair_wins(self):
|
|
s = resolve({"display_options": {"scroll_speed": 1.0, "scroll_delay": 0.01}})
|
|
assert s.pixels_per_second == 100.0
|
|
assert s.source == "display_options.scroll_speed/delay"
|
|
|
|
def test_display_block_used_when_options_absent(self):
|
|
s = resolve({"display": {"scroll_speed": 2.0, "scroll_delay": 0.01}})
|
|
assert s.pixels_per_second == 200.0
|
|
assert s.source == "display.scroll_speed/delay"
|
|
|
|
def test_root_pair_used_when_both_blocks_absent(self):
|
|
s = resolve({"scroll_speed": 1.0, "scroll_delay": 0.02})
|
|
assert s.pixels_per_second == 50.0
|
|
|
|
def test_pixels_per_second_used_when_no_pair_given(self):
|
|
s = resolve({"display_options": {"scroll_pixels_per_second": 120.0}})
|
|
assert s.pixels_per_second == 120.0
|
|
assert s.source == "display_options.scroll_pixels_per_second"
|
|
|
|
def test_global_display_is_the_last_resort_before_default(self):
|
|
s = resolve({}, {"display": {"scroll_speed": 1.0, "scroll_delay": 0.005}})
|
|
assert s.pixels_per_second == 200.0
|
|
|
|
def test_default_when_nothing_configured(self):
|
|
s = resolve({}, {})
|
|
assert s.pixels_per_second == DEFAULT_PIXELS_PER_SECOND
|
|
assert s.source == "default"
|
|
|
|
|
|
class TestDeprecatedKeyCannotOverrideExplicitPair:
|
|
"""Regression for ChuckBuilds/ledmatrix-plugins#408.
|
|
|
|
odds-ticker ranked scroll_pixels_per_second above the documented
|
|
scroll_speed/scroll_delay pair. Because that key carries a schema default,
|
|
the documented settings became unreachable for every user and the ticker
|
|
silently ran at the default speed. The pair must win.
|
|
"""
|
|
|
|
def test_pair_beats_pixels_per_second_in_the_same_block(self):
|
|
s = resolve(
|
|
{
|
|
"display_options": {
|
|
"scroll_speed": 1.0,
|
|
"scroll_delay": 0.01,
|
|
"scroll_pixels_per_second": 50.0, # schema default
|
|
}
|
|
}
|
|
)
|
|
assert s.pixels_per_second == 100.0
|
|
assert "scroll_speed/delay" in s.source
|
|
|
|
def test_pair_beats_pixels_per_second_in_an_outer_block(self):
|
|
s = resolve(
|
|
{
|
|
"display_options": {"scroll_speed": 1.0, "scroll_delay": 0.01},
|
|
"scroll_pixels_per_second": 50.0,
|
|
}
|
|
)
|
|
assert s.pixels_per_second == 100.0
|
|
|
|
|
|
class TestMalformedValues:
|
|
@pytest.mark.parametrize("bad", [None, "fast", "", {}, [], float("nan")])
|
|
def test_unusable_speed_falls_through(self, bad):
|
|
s = resolve({"display_options": {"scroll_speed": bad, "scroll_delay": 0.01}})
|
|
assert s.pixels_per_second == DEFAULT_PIXELS_PER_SECOND
|
|
|
|
@pytest.mark.parametrize("bad", [0, -5, 0.0])
|
|
def test_non_positive_values_fall_through(self, bad):
|
|
s = resolve({"display_options": {"scroll_pixels_per_second": bad}})
|
|
assert s.pixels_per_second == DEFAULT_PIXELS_PER_SECOND
|
|
|
|
def test_booleans_are_not_treated_as_numbers(self):
|
|
s = resolve({"display_options": {"scroll_pixels_per_second": True}})
|
|
assert s.pixels_per_second == DEFAULT_PIXELS_PER_SECOND
|
|
|
|
def test_zero_delay_does_not_divide_by_zero(self):
|
|
s = resolve({"display_options": {"scroll_speed": 1.0, "scroll_delay": 0}})
|
|
assert s.pixels_per_second == DEFAULT_PIXELS_PER_SECOND
|
|
|
|
def test_non_dict_blocks_are_ignored(self):
|
|
s = resolve({"display_options": "nonsense", "display": 5})
|
|
assert s.pixels_per_second == DEFAULT_PIXELS_PER_SECOND
|
|
|
|
|
|
class TestClamping:
|
|
def test_absurdly_fast_is_clamped(self):
|
|
s = resolve({"display_options": {"scroll_pixels_per_second": 100000.0}})
|
|
assert s.pixels_per_second == MAX_PIXELS_PER_SECOND
|
|
assert s.warning and "clamped" in s.warning
|
|
|
|
def test_absurdly_slow_is_clamped(self):
|
|
s = resolve({"display_options": {"scroll_pixels_per_second": 0.01}})
|
|
assert s.pixels_per_second == MIN_PIXELS_PER_SECOND
|
|
|
|
|
|
class TestWholePixelWarning:
|
|
"""The display rule: whole pixels per refresh, or it judders."""
|
|
|
|
def test_no_warning_when_speed_divides_evenly(self):
|
|
s = resolve({"display_options": {"scroll_pixels_per_second": 100.0}}, refresh_hz=100)
|
|
assert s.warning is None
|
|
assert s.pixels_per_frame == pytest.approx(1.0)
|
|
|
|
def test_no_warning_at_an_integer_multiple(self):
|
|
s = resolve({"display_options": {"scroll_pixels_per_second": 200.0}}, refresh_hz=100)
|
|
assert s.warning is None
|
|
|
|
def test_warns_on_a_half_pixel_per_frame(self):
|
|
s = resolve({"display_options": {"scroll_pixels_per_second": 50.0}}, refresh_hz=100)
|
|
assert s.warning is not None
|
|
assert "judder" in s.warning
|
|
assert "100 px/s" in s.warning
|
|
|
|
def test_warns_on_a_fractional_multiple(self):
|
|
s = resolve({"display_options": {"scroll_pixels_per_second": 130.0}}, refresh_hz=100)
|
|
assert s.warning is not None
|
|
|
|
def test_respects_a_non_default_refresh_rate(self):
|
|
s = resolve({"display_options": {"scroll_pixels_per_second": 150.0}}, refresh_hz=150)
|
|
assert s.warning is None
|
|
assert s.pixels_per_frame == pytest.approx(1.0)
|
|
|
|
|
|
class TestConfigure:
|
|
def test_applies_time_based_mode_and_speed(self):
|
|
helper = FakeHelper()
|
|
s = configure(helper, {"display_options": {"scroll_speed": 1.0, "scroll_delay": 0.01}})
|
|
assert helper.speed == 100.0
|
|
assert helper.frame_based is False, "must not use the wall-clock step gate"
|
|
assert helper.target_fps == 100.0
|
|
assert s.pixels_per_second == 100.0
|
|
|
|
def test_works_against_a_helper_without_optional_methods(self):
|
|
helper = MinimalHelper()
|
|
configure(helper, {"display_options": {"scroll_pixels_per_second": 100.0}})
|
|
assert helper.speed == 100.0
|
|
|
|
def test_snapping_removes_the_judder_warning(self, caplog):
|
|
"""50px/s cannot be shown in whole pixels at 100Hz *per refresh*, but it
|
|
can as 1px every 2nd refresh -- so once snapped there is nothing to warn
|
|
about. resolve() alone still warns; configure() resolves it."""
|
|
assert "judder" in resolve(
|
|
{"display_options": {"scroll_pixels_per_second": 50.0}},
|
|
refresh_hz=100).warning
|
|
helper, dm = FakeHelper(), FakeDisplayManager()
|
|
with caplog.at_level(logging.WARNING):
|
|
settings = configure(
|
|
helper, {"display_options": {"scroll_pixels_per_second": 50.0}},
|
|
display_manager=dm)
|
|
assert settings.warning is None
|
|
assert not [r for r in caplog.records if "judder" in r.getMessage()]
|
|
|
|
def test_warns_when_a_hold_is_needed_but_cannot_be_applied(self, caplog):
|
|
"""Without a display manager the hold silently does not happen, and the
|
|
motion falls back to fractional pixels. That must not pass quietly."""
|
|
with caplog.at_level(logging.WARNING):
|
|
configure(FakeHelper(), {"display_options": {"scroll_pixels_per_second": 50.0}})
|
|
assert any("frame hold" in r.getMessage() for r in caplog.records)
|
|
|
|
def test_applies_the_frame_hold_to_the_display_manager(self):
|
|
dm = FakeDisplayManager()
|
|
configure(FakeHelper(), {"display_options": {"scroll_pixels_per_second": 25.0}},
|
|
display_manager=dm)
|
|
assert dm.hold == 4, "25px/s at 100Hz is 1px every 4th refresh"
|
|
|
|
def test_full_speed_needs_no_hold(self):
|
|
dm = FakeDisplayManager()
|
|
configure(FakeHelper(), {"display_options": {"scroll_pixels_per_second": 100.0}},
|
|
display_manager=dm)
|
|
assert dm.hold == 1
|
|
|
|
def test_snaps_to_the_nearest_crisp_speed_and_reports_both(self):
|
|
s = configure(FakeHelper(), {"display_options": {"scroll_pixels_per_second": 45.0}},
|
|
display_manager=FakeDisplayManager())
|
|
assert s.requested_pixels_per_second == 45.0
|
|
assert s.pixels_per_second == 50.0
|
|
assert s.crisp is not None and s.crisp.pixels_per_frame == 1
|
|
|
|
def test_snapping_can_be_turned_off(self):
|
|
s = configure(FakeHelper(), {"display_options": {"scroll_pixels_per_second": 45.0}},
|
|
snap_to_crisp=False)
|
|
assert s.pixels_per_second == 45.0
|
|
assert s.crisp is None
|
|
|
|
def test_helper_target_fps_matches_the_presentation_rate(self):
|
|
"""At a hold of 4 the panel still refreshes at 100Hz, but frames are
|
|
presented at 25/s -- that is the rate the helper should pace to."""
|
|
helper = FakeHelper()
|
|
configure(helper, {"display_options": {"scroll_pixels_per_second": 25.0}},
|
|
display_manager=FakeDisplayManager())
|
|
assert helper.target_fps == pytest.approx(25.0)
|
|
|
|
def test_returns_settings_describing_the_source(self):
|
|
s = configure(FakeHelper(), {"display_options": {"scroll_speed": 2.0, "scroll_delay": 0.01}})
|
|
assert isinstance(s, ScrollSettings)
|
|
assert "200.0 px/s" in s.describe()
|
|
|
|
|
|
class TestRefreshFromConfig:
|
|
def test_reads_the_hardware_limit(self):
|
|
assert refresh_hz_from_config(
|
|
{"display": {"hardware": {"limit_refresh_rate_hz": 150}}}
|
|
) == 150.0
|
|
|
|
@pytest.mark.parametrize("cfg", [None, {}, {"display": {}}, {"display": {"hardware": {}}},
|
|
"nonsense", {"display": {"hardware": {"limit_refresh_rate_hz": None}}}])
|
|
def test_falls_back_to_the_default(self, cfg):
|
|
assert refresh_hz_from_config(cfg) == 100.0
|
|
|
|
|
|
class TestCrispLadder:
|
|
"""Whole-pixel speeds available on a given panel."""
|
|
|
|
def test_every_entry_is_exactly_reachable(self):
|
|
for c in crisp_ladder(100):
|
|
assert c.pixels_per_second == pytest.approx(
|
|
c.refresh_hz / c.frame_hold * c.pixels_per_frame)
|
|
|
|
def test_sorted_slowest_first(self):
|
|
speeds = [c.pixels_per_second for c in crisp_ladder(100)]
|
|
assert speeds == sorted(speeds)
|
|
|
|
def test_no_duplicate_speeds(self):
|
|
speeds = [round(c.pixels_per_second, 3) for c in crisp_ladder(100)]
|
|
assert len(speeds) == len(set(speeds))
|
|
|
|
def test_duplicates_resolve_to_the_smaller_step(self):
|
|
"""100px/s is 1px every refresh or 2px every 2nd; prefer the former."""
|
|
entry = next(c for c in crisp_ladder(100)
|
|
if c.pixels_per_second == pytest.approx(100.0))
|
|
assert entry.pixels_per_frame == 1
|
|
assert entry.frame_hold == 1
|
|
|
|
def test_ladder_scales_with_the_panel(self):
|
|
assert any(c.pixels_per_second == pytest.approx(60.0)
|
|
for c in crisp_ladder(60))
|
|
assert any(c.pixels_per_second == pytest.approx(30.0)
|
|
for c in crisp_ladder(60))
|
|
|
|
def test_full_refresh_speed_is_present(self):
|
|
for hz in (60, 75, 100, 120):
|
|
assert any(c.pixels_per_second == pytest.approx(float(hz))
|
|
for c in crisp_ladder(hz))
|
|
|
|
|
|
class TestSolveCrisp:
|
|
def test_exact_targets_are_matched_exactly(self):
|
|
for target in (100, 50, 25, 20):
|
|
assert solve_crisp(target, 100).pixels_per_second == pytest.approx(target)
|
|
|
|
def test_prefers_smooth_motion_over_raw_proximity(self):
|
|
"""30 -> 33.3 (1px, 33fps), not 28.6 (2px at 14fps) which is nearer."""
|
|
got = solve_crisp(30, 100)
|
|
assert got.pixels_per_second == pytest.approx(33.333, abs=0.01)
|
|
assert got.pixels_per_frame == 1
|
|
|
|
def test_does_not_chase_a_jumpy_exact_match(self):
|
|
"""45 -> 50 (1px, smooth) beats 42.9 (3px at 14fps)."""
|
|
assert solve_crisp(45, 100).pixels_per_frame == 1
|
|
|
|
def test_allows_a_two_pixel_step_when_it_is_clearly_closer(self):
|
|
"""60 -> 66.7 (2px at 33fps) rather than 50 (1px) which is 17% slow."""
|
|
got = solve_crisp(60, 100)
|
|
assert got.pixels_per_second == pytest.approx(66.667, abs=0.01)
|
|
assert got.pixels_per_frame == 2
|
|
|
|
def test_result_is_always_on_the_ladder(self):
|
|
ladder = {round(c.pixels_per_second, 3) for c in crisp_ladder(100)}
|
|
for target in range(5, 205, 5):
|
|
assert round(solve_crisp(target, 100).pixels_per_second, 3) in ladder
|
|
|
|
def test_adapts_to_a_slower_panel(self):
|
|
got = solve_crisp(30, 60)
|
|
assert got.pixels_per_second == pytest.approx(30.0)
|
|
assert got.pixels_per_frame == 1
|
|
assert got.frame_hold == 2
|
|
|
|
def test_speeds_beyond_the_panel_clamp_to_the_fastest_available(self):
|
|
got = solve_crisp(10_000, 100)
|
|
assert got.frame_hold == 1
|
|
assert got.pixels_per_frame == MAX_PIXELS_PER_FRAME
|
|
|
|
def test_steppiness_labels_are_sane(self):
|
|
assert solve_crisp(100, 100).steppiness == "smooth"
|
|
assert crisp_ladder(100)[0].steppiness == "stepped"
|