diff --git a/scripts/sports_scroll_check.py b/scripts/sports_scroll_check.py new file mode 100644 index 00000000..b5d37e54 --- /dev/null +++ b/scripts/sports_scroll_check.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +"""Drive a sports scoreboard scroll on the panel and report what it did. + +The eight sports scoreboards scroll through ``src/common/sports_scroll.py``, +and that path is per-league opt-in: a rig showing static game cards never +constructs a SportsScrollDisplay at all, so nothing about its pacing can be +observed from a normal run. This drives it directly, with synthetic games, so +the pacing can be measured without changing anyone's configuration. + +What it checks is what the shared resolver is supposed to buy: + +* the requested speed lands on a whole number of pixels per refresh +* the frame hold that makes that true is published to the display manager +* frames actually arrive at the interval the hold implies + + sudo systemctl stop ledmatrix + sudo python3 scripts/sports_scroll_check.py --seconds 20 + sudo systemctl start ledmatrix + +Like scripts/scroll_speeds.py, this never starts or stops the display service +itself -- that is left to the caller, so a crash here cannot leave the panel +dark. +""" +from __future__ import annotations + +import argparse +import json +import statistics +import subprocess # nosec B404 - list-form argv only, no shell # nosemgrep +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from PIL import Image # noqa: E402 + +from src.common.sports_scroll import SportsScrollDisplay # noqa: E402 +from src.display_manager import DisplayManager # noqa: E402 + + +class _Check(SportsScrollDisplay): + """A scoreboard whose cards are plain blocks -- pacing is what matters.""" + + SCROLL_LEAGUE_KEYS = ("nfl",) + + def prepare_scroll_content(self, games, game_type, leagues, rankings_cache=None): + width = self.display_height * 2 + cards = [] + for i, _ in enumerate(games): + card = Image.new("RGB", (width, self.display_height), (0, 0, 0)) + shade = 40 + (i * 37) % 180 + for x in range(2, width - 2): + for y in range(2, self.display_height - 2): + card.putpixel((x, y), (shade, 90, 220 - shade // 2)) + cards.append(card) + self._current_games = list(games) + self._current_game_type = game_type + self._current_leagues = list(leagues) + self.scroll_helper.create_scrolling_image(content_items=cards, item_gap=24) + return bool(cards) + + +class _HoldSpy: + """Records what the scroll publishes, without changing what it does.""" + + def __init__(self, display_manager): + self.dm = display_manager + self.calls = [] + self._real = display_manager.set_scrolling_state + + def __enter__(self): + def spy(is_scrolling, frame_hold=1): + self.calls.append((is_scrolling, frame_hold)) + return self._real(is_scrolling, frame_hold=frame_hold) + self.dm.set_scrolling_state = spy + return self + + def __exit__(self, *exc): + self.dm.set_scrolling_state = self._real + return False + + +MESSAGE = """ledmatrix is running and owns the panel's GPIO. + +Stop it first, or this run can leave the display dark: + + sudo systemctl stop ledmatrix + sudo python3 scripts/sports_scroll_check.py + sudo systemctl start ledmatrix + +Use --fallback to check the pacing logic without the panel, or --force if +you really mean it.""" + + +def _refuse_if_the_service_is_running(force): + """Refuse to touch the panel while ledmatrix has it. + + rpi-rgb-led-matrix configures GPIO directions and the hardware PWM inside + RGBMatrix(), and on the root check it calls exit() from C -- no cleanup. + Do that while the service is driving those same pins and the panel goes + dark while the service carries on rendering happily: fresh framebuffer, + every pixel lit, "RGB Matrix initialized successfully", nothing in the log. + A restart brings it back, but only once you work out that is what happened. + + The module docstring says to stop the service first. This makes it true. + """ + if force: + return + try: + active = subprocess.run( # nosec B603 B607 - hardcoded systemctl args # nosemgrep + ["systemctl", "is-active", "ledmatrix"], + capture_output=True, text=True).stdout.strip() + except OSError: + return # not a systemd box; nothing to protect + if active == "active": + sys.exit(MESSAGE) + + +def main(): + ap = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--seconds", type=float, default=20.0) + ap.add_argument("--speed", type=float, default=None, + help="px/s to request; default is the module's own") + ap.add_argument("--games", type=int, default=6) + ap.add_argument("--force", action="store_true", + help="run even though the display service is up. It owns " + "the GPIO; expect a dark panel until you restart it.") + ap.add_argument("--fallback", action="store_true", + help="run without the panel. Driving the real matrix needs " + "root; this checks everything except the vsync pacing " + "-- what speed resolves to, that the hold is published, " + "and that it is released afterwards.") + args = ap.parse_args() + + if not args.fallback: + _refuse_if_the_service_is_running(args.force) + + root = Path(__file__).resolve().parent.parent + config = json.loads((root / "config" / "config.json").read_text(encoding="utf-8")) + + display_manager = DisplayManager(config, force_fallback=args.fallback) + settings = {} if args.speed is None else { + "nfl": {"scroll_settings": {"scroll_speed": args.speed}}} + + display = _Check(display_manager, settings, global_config=config) + resolved = display._scroll_settings + print("resolved: %s" % resolved.describe()) + print("frame hold: %d refresh(es) per frame" % resolved.frame_hold) + if resolved.warning: + print("warning: %s" % resolved.warning) + + display.prepare_scroll_content( + [{"id": "g%d" % i} for i in range(args.games)], "live", ["nfl"]) + + gaps, drawn = [], 0 + last = None + with _HoldSpy(display_manager) as spy: + started = time.perf_counter() + while time.perf_counter() - started < args.seconds: + if not display.display_scroll_frame(): + break + now = time.perf_counter() + if last is not None: + gaps.append((now - last) * 1000.0) + last = now + drawn += 1 + display.clear() + + if not gaps: + sys.exit("no frames were drawn -- the scroll never started") + + gaps.sort() + expected = 1000.0 * resolved.frame_hold / (resolved.crisp.refresh_hz + if resolved.crisp else 100.0) + print("\n%d frames in %.1fs -> %.1f fps" % ( + drawn, args.seconds, drawn / args.seconds)) + print("frame gap median %.2fms p95 %.2fms max %.2fms (hold implies %.2fms)" + % (statistics.median(gaps), gaps[int(len(gaps) * 0.95)], gaps[-1], expected)) + + holds = {h for on, h in spy.calls if on} + print("published while scrolling: frame_hold=%s" % (sorted(holds) or "NOTHING")) + print("released on clear: %s" % any(not on for on, _ in spy.calls)) + print("display manager hold now: %d (1 means released)" + % getattr(display_manager, "_frame_hold", -1)) + + if not holds: + sys.exit("FAIL: the scroll never told the core it was scrolling") + if holds != {resolved.frame_hold}: + sys.exit("FAIL: published %s but resolved %d" % (holds, resolved.frame_hold)) + print("\nOK: the resolved hold reached the panel and was released after") + + +if __name__ == "__main__": + main() diff --git a/src/common/sports_scroll.py b/src/common/sports_scroll.py index f9a32e37..0278a1d3 100644 --- a/src/common/sports_scroll.py +++ b/src/common/sports_scroll.py @@ -43,6 +43,7 @@ class HockeyScrollDisplayManager(SportsScrollDisplayManager): from PIL import Image +from src.common import scroll_config from src.common.scroll_helper import ScrollHelper logger = logging.getLogger(__name__) @@ -58,13 +59,9 @@ class HockeyScrollDisplayManager(SportsScrollDisplayManager): "dynamic_duration": True, } -#: Bounds on the px/second -> px/frame conversion, applied before the helper -#: sees the value. FPS is *not* clamped here — ScrollHelper.set_target_fps -#: already does that, and a second copy of the range would drift from it. -MIN_PIXELS_PER_FRAME = 0.1 -MAX_PIXELS_PER_FRAME = 5.0 - #: Pacing to assume when scroll_delay is 0, i.e. the plugin has not set one. +#: Only used to interpret this module's own px/frame config shape; the speed +#: bounds and the px/s -> px/frame conversion belong to scroll_config now. ASSUMED_FPS_WHEN_UNPACED = 100.0 @@ -236,51 +233,75 @@ def _configure_scroll_helper(self) -> None: """Apply config to the scroll helper. Safe to call again after a change.""" settings = self._get_scroll_settings() - scroll_speed = self._coerce_float(settings.get("scroll_speed"), 50.0) - scroll_delay = self._coerce_float(settings.get("scroll_delay"), 0.01) dynamic_duration = bool(settings.get("dynamic_duration", True)) - - self.scroll_helper.set_scroll_delay(scroll_delay) self.scroll_helper.set_dynamic_duration_settings( enabled=dynamic_duration, min_duration=settings.get("min_duration", 30), max_duration=settings.get("max_duration", 600), buffer=0.2, # ensure the strip clears the panel completely ) - # Frame-based scrolling: motion advances per rendered frame rather than - # per wall-clock second, which is what makes the pacing stable. - self.scroll_helper.set_frame_based_scrolling(True) - # Config states speed in px/second; frame-based mode wants px/frame. - if scroll_delay > 0: - pixels_per_frame = scroll_speed * scroll_delay - else: - pixels_per_frame = scroll_speed / ASSUMED_FPS_WHEN_UNPACED - pixels_per_frame = max( - MIN_PIXELS_PER_FRAME, min(MAX_PIXELS_PER_FRAME, pixels_per_frame) - ) - self.scroll_helper.set_scroll_speed(pixels_per_frame) - - effective_pps = ( - pixels_per_frame / scroll_delay - if scroll_delay > 0 - else pixels_per_frame * ASSUMED_FPS_WHEN_UNPACED + # Speed goes through scroll_config, which every other scrolling plugin + # already uses. What must NOT happen is handing it this module's + # settings dict: the two read the same key names with different + # meanings, and the collision is a factor of 1/scroll_delay. + # + # sports_scroll: scroll_speed is px/SECOND; scroll_delay is only the + # frame period used to reach px/frame. + # scroll_config: scroll_speed is px per STEP, so px/s = speed/delay. + # + # Passing {"scroll_speed": 50.0, "scroll_delay": 0.01} straight through + # resolves to 5000 px/s (clamped to 500) instead of 50. So this module + # keeps ownership of reading its own config -- _get_scroll_settings + # merges the league overrides -- and hands the resolver a plain px/s. + pixels_per_second = self._resolve_pixels_per_second(settings) + + resolved = scroll_config.configure( + self.scroll_helper, + plugin_config=None, + global_config=self.global_config, + default_pixels_per_second=pixels_per_second, + display_manager=self.display_manager, + plugin_logger=self.logger, + refresh_hz=self._resolve_refresh_hz(), ) + self._scroll_settings = resolved self.logger.info( - f"ScrollHelper configured: {pixels_per_frame:.2f} px/frame, " - f"delay={scroll_delay}s (effective {effective_pps:.1f} px/s from " - f"{scroll_speed} px/s config), dynamic_duration={dynamic_duration}" + "ScrollHelper configured: %s (requested %.1f px/s), " + "dynamic_duration=%s", + resolved.describe(), + pixels_per_second, dynamic_duration, ) - # The reason this module exists upstream: the bundled copies hardcode - # ~100 FPS via scroll_delay and never consult the global target. - # No hasattr guard here, unlike the plugin copies: they probe because - # they may run against an older core, whereas this module ships in the - # same release as the ScrollHelper it calls. The helper clamps. - target_fps = self._resolve_target_fps() - if target_fps: - self.scroll_helper.set_target_fps(target_fps) - self.logger.info(f"Target FPS set to {target_fps}") + def _resolve_pixels_per_second(self, settings: Dict[str, Any]) -> float: + """This module's config shape, expressed as plain pixels per second. + + ``scroll_speed`` is already px/s here. ``scroll_delay`` only matters + when a caller supplied px/frame instead, which the 0 case covers. + """ + scroll_speed = self._coerce_float(settings.get("scroll_speed"), 50.0) + scroll_delay = self._coerce_float(settings.get("scroll_delay"), 0.01) + if scroll_delay <= 0: + return scroll_speed * ASSUMED_FPS_WHEN_UNPACED + return scroll_speed + + def _resolve_refresh_hz(self) -> Optional[float]: + """The panel refresh the crisp ladder should be computed against. + + Prefers the configured hardware refresh. Falls back to the global + ``target_fps``/``scroll_target_fps`` this module has always honoured: + under the old model that key *was* the rate frames were presented at, + so it is the faithful translation for anyone who set it. Returning + None lets scroll_config apply its own default. + """ + hardware = scroll_config.refresh_hz_from_config(self.global_config) + if hardware and hardware != scroll_config.DEFAULT_REFRESH_HZ: + return hardware + return self._resolve_target_fps() or hardware or None + + def _scroll_frame_hold(self) -> int: + """Refreshes to hold each frame for, from the resolved settings.""" + return getattr(getattr(self, "_scroll_settings", None), "frame_hold", 1) # ------------------------------------------------------------------ # Frame pumping @@ -305,6 +326,16 @@ def display_scroll_frame(self) -> bool: if not visible: return False + # Tell the core the panel is scrolling, and for how many + # refreshes to hold each frame. Without this the frame hold is + # never applied -- so a speed the ladder made crisp still presents + # a new frame every refresh and judders -- and, because deferred + # updates only run while nothing is scrolling, core would run + # blocking work in the middle of this scroll. + if hasattr(self.display_manager, "set_scrolling_state"): + self.display_manager.set_scrolling_state( + True, frame_hold=self._scroll_frame_hold()) + self.display_manager.image = visible self.display_manager.update_display() self._frame_count += 1 @@ -331,7 +362,19 @@ def _log_scroll_progress(self) -> None: def is_scroll_complete(self) -> bool: """True when the strip has scrolled fully past the panel.""" - return self.scroll_helper.is_scroll_complete() + complete = self.scroll_helper.is_scroll_complete() + if complete: + self._release_scrolling_state() + return complete + + def _release_scrolling_state(self) -> None: + """Tell the core this display is no longer scrolling. + + The scrolling flag and the frame hold are global to the display + manager, so leaving them set holds every other plugin's frames too. + """ + if hasattr(self.display_manager, "set_scrolling_state"): + self.display_manager.set_scrolling_state(False) def reset_scroll(self) -> None: """Return the strip to its starting position, keeping the content.""" @@ -349,6 +392,7 @@ def clear(self) -> None: self._vegas_content_items = [] self._is_scrolling = False self._scroll_start_time = None + self._release_scrolling_state() self.logger.debug("Scroll display cleared") # ------------------------------------------------------------------ diff --git a/src/display_manager.py b/src/display_manager.py index 9649cd1f..5d042489 100644 --- a/src/display_manager.py +++ b/src/display_manager.py @@ -1436,6 +1436,14 @@ def is_currently_scrolling(self) -> bool: # If we've been inactive for the threshold period, consider it not scrolling if current_time - self._scrolling_state['last_scroll_activity'] > self._scrolling_state['scroll_inactivity_threshold']: self._scrolling_state['is_scrolling'] = False + # Drop the hold with the state, exactly as set_scrolling_state(False) + # does. This path is the one a scroll takes when it ends without + # saying so -- the rotation moves on mid-scroll, or the plugin is + # torn down -- and leaving the hold set there means every later + # plugin, scrolling or static, is presented at refresh/N until + # somebody calls set_scrolling_state(False). The hold must not + # outlive the scroll that asked for it, however that scroll ends. + self._frame_hold = 1 return False return True diff --git a/test/test_display_dirty_tracking.py b/test/test_display_dirty_tracking.py index cf33d12b..eb2a97c3 100644 --- a/test/test_display_dirty_tracking.py +++ b/test/test_display_dirty_tracking.py @@ -293,6 +293,29 @@ def test_scrolling_state_carries_the_hold(self, dm): finally: dm.set_scrolling_state(False) + def test_a_scroll_that_times_out_does_not_strand_a_hold(self, dm): + """The hold must go when the state does, however the scroll ended. + + set_scrolling_state(False) is the polite exit. The other one is + is_currently_scrolling() deciding, after scroll_inactivity_threshold + of silence, that the scroll is over -- which is what happens when the + rotation moves on mid-scroll or a plugin is torn down. That path used + to clear the flag and keep the hold, so every later plugin, scrolling + or static, was presented at refresh/N by whoever scrolled last. + """ + dm.set_scrolling_state(True, frame_hold=5) + # Age the scroll past the inactivity threshold rather than sleeping. + dm._scrolling_state['last_scroll_activity'] -= ( + dm._scrolling_state['scroll_inactivity_threshold'] + 1.0) + + assert dm.is_currently_scrolling() is False + dm.draw.rectangle([0, 0, 5, 5], fill=(10, 10, 200)) + with _SwapSpy(dm.matrix) as spy: + dm.update_display() + assert spy.last_frame_hold == 1, ( + "a timed-out scroll left its frame hold behind; the next plugin " + "is being presented at a fraction of the refresh rate") + def test_another_plugin_stopping_does_not_strand_a_hold(self, dm): dm.set_scrolling_state(True, frame_hold=3) dm.set_scrolling_state(False) # some other plugin finishes diff --git a/test/test_sports_scroll.py b/test/test_sports_scroll.py index 9f6f72e8..893dfa34 100644 --- a/test/test_sports_scroll.py +++ b/test/test_sports_scroll.py @@ -21,8 +21,6 @@ from src.common.sports_scroll import ( # noqa: E402 DEFAULT_SCROLL_SETTINGS, - MAX_PIXELS_PER_FRAME, - MIN_PIXELS_PER_FRAME, SportsScrollDisplay, SportsScrollDisplayManager, ) @@ -187,29 +185,48 @@ def test_a_null_league_block_is_tolerated(self, build): # --------------------------------------------------------------------------- class TestConfigureScrollHelper: - def test_speed_is_converted_to_pixels_per_frame(self, build): + """Pacing now comes from scroll_config, the same resolver every other + scrolling plugin uses. What this class guards is the translation into it, + because the two modules read identically-named keys differently.""" + + def test_config_speed_is_pixels_per_second_not_pixels_per_step(self, build): + """The collision that makes a naive hand-off wrong by 1/scroll_delay. + + sports config states scroll_speed in px/SECOND and uses scroll_delay + only to reach px/frame. scroll_config states it in px per STEP, so it + computes px/s as speed/delay. Handing this module's settings dict + straight to the resolver turns 50 px/s into 5000 px/s, which its own + bounds then clamp to 500 -- a tenfold speed-up on every scoreboard. + """ display = build({"nhl": {"scroll_settings": { - "scroll_speed": 100.0, "scroll_delay": 0.02}}}) - # 100 px/s * 0.02 s/frame = 2 px/frame - display.scroll_helper.set_scroll_speed.assert_called_with(2.0) - - def test_conversion_is_clamped_low(self, build): - display = build({"nhl": {"scroll_settings": { - "scroll_speed": 0.001, "scroll_delay": 0.001}}}) - display.scroll_helper.set_scroll_speed.assert_called_with(MIN_PIXELS_PER_FRAME) + "scroll_speed": 50.0, "scroll_delay": 0.01}}}) + applied = display.scroll_helper.set_scroll_speed.call_args[0][0] + assert applied == pytest.approx(50.0, abs=1.0), ( + f"applied {applied} px/s; 500 means the settings dict was passed " + "through to the resolver instead of a plain px/s") + + def test_speed_is_snapped_to_whole_pixel_motion(self, build): + """50 px/s on a 100Hz panel is half a pixel per refresh, which cannot + render as motion -- it alternates 0 and 1 px steps and judders. The + ladder keeps the speed and holds each frame for two refreshes.""" + display = build() + assert display._scroll_settings.frame_hold == 2 + assert display._scroll_settings.pixels_per_second == pytest.approx(50.0) - def test_conversion_is_clamped_high(self, build): - display = build({"nhl": {"scroll_settings": { - "scroll_speed": 5000.0, "scroll_delay": 0.5}}}) - display.scroll_helper.set_scroll_speed.assert_called_with(MAX_PIXELS_PER_FRAME) + def test_the_resolved_hold_is_what_gets_published(self, build): + display = build() + assert display._scroll_frame_hold() == 2 - def test_zero_delay_assumes_a_pacing_instead_of_dividing_by_zero(self, build): - display = build({"nhl": {"scroll_settings": { - "scroll_speed": 100.0, "scroll_delay": 0}}}) - display.scroll_helper.set_scroll_speed.assert_called_with(1.0) + def test_hold_defaults_to_one_before_anything_is_resolved(self, display_manager): + bare = SportsScrollDisplay.__new__(SportsScrollDisplay) + assert SportsScrollDisplay._scroll_frame_hold(bare) == 1 - def test_frame_based_scrolling_is_enabled(self, build): - build().scroll_helper.set_frame_based_scrolling.assert_called_once_with(True) + def test_stepping_is_time_based(self, build): + """Frame-based mode gated motion on a wall clock at 1/scroll_delay + steps, with scroll_delay set to the frame period -- putting the + comparison exactly on its own threshold, so it flipped on sub- + millisecond jitter.""" + build().scroll_helper.set_frame_based_scrolling.assert_called_once_with(False) def test_dynamic_duration_settings_are_applied(self, build): display = build({"nhl": {"scroll_settings": { @@ -219,32 +236,38 @@ def test_dynamic_duration_settings_are_applied(self, build): assert kwargs["min_duration"] == 5 assert kwargs["max_duration"] == 50 - def test_target_fps_reaches_the_helper(self, build): - """The whole point of upstreaming: the bundled copies hardcode ~100 FPS - via scroll_delay and never consult the global target.""" - display = build(global_config={"target_fps": 120}) - display.scroll_helper.set_target_fps.assert_called_once_with(120.0) + def test_zero_delay_still_means_pixels_per_frame(self, build): + """The one case where scroll_speed is not already px/s.""" + display = build({"nhl": {"scroll_settings": { + "scroll_speed": 1.0, "scroll_delay": 0}}}) + applied = display.scroll_helper.set_scroll_speed.call_args[0][0] + assert applied == pytest.approx(100.0, abs=1.0) + + def test_global_target_fps_sets_the_refresh_the_ladder_uses(self, build): + """Under the old model this key was the rate frames were presented at, + so it is the faithful translation of it into a panel refresh.""" + display = build(global_config={"target_fps": 60}) + assert display._resolve_refresh_hz() == 60.0 def test_legacy_key_is_honored(self, build): display = build(global_config={"scroll_target_fps": 90}) - display.scroll_helper.set_target_fps.assert_called_once_with(90.0) + assert display._resolve_refresh_hz() == 90.0 def test_modern_key_wins_over_legacy(self, build): display = build(global_config={"target_fps": 120, "scroll_target_fps": 90}) - display.scroll_helper.set_target_fps.assert_called_once_with(120.0) + assert display._resolve_refresh_hz() == 120.0 - def test_absent_target_fps_leaves_config_pacing_alone(self, build): - build().scroll_helper.set_target_fps.assert_not_called() + def test_configured_hardware_refresh_wins_over_the_fps_target(self, build): + display = build(global_config={ + "target_fps": 60, + "display": {"hardware": {"limit_refresh_rate_hz": 120}}}) + assert display._resolve_refresh_hz() == 120.0 @pytest.mark.parametrize("bad", ["fast", None, {}, [], "", 0]) def test_unusable_target_fps_degrades_instead_of_raising(self, build, bad): """A malformed global config must cost the FPS target, not the display.""" display = build(global_config={"target_fps": bad}) - display.scroll_helper.set_target_fps.assert_not_called() - - def test_string_digits_are_accepted(self, build): - display = build(global_config={"target_fps": "120"}) - display.scroll_helper.set_target_fps.assert_called_once_with(120.0) + assert display._scroll_settings.pixels_per_second > 0 @pytest.mark.parametrize("bad", [None, "fast", {}, []]) def test_unusable_scroll_speed_degrades_instead_of_crashing(self, build, bad): @@ -252,24 +275,63 @@ def test_unusable_scroll_speed_degrades_instead_of_crashing(self, build, bad): present with null reaches the arithmetic and raises inside __init__, taking the whole display down before it renders anything.""" display = build({"nhl": {"scroll_settings": {"scroll_speed": bad}}}) - # 50.0 px/s * 0.01 s/frame == 0.5 px/frame, i.e. the default speed. - display.scroll_helper.set_scroll_speed.assert_called_with(0.5) + applied = display.scroll_helper.set_scroll_speed.call_args[0][0] + assert applied == pytest.approx(50.0, abs=1.0) @pytest.mark.parametrize("bad", [None, "slow", {}]) def test_unusable_scroll_delay_degrades_instead_of_crashing(self, build, bad): display = build({"nhl": {"scroll_settings": {"scroll_delay": bad}}}) - display.scroll_helper.set_scroll_delay.assert_called_with(0.01) + applied = display.scroll_helper.set_scroll_speed.call_args[0][0] + assert applied == pytest.approx(50.0, abs=1.0) def test_numeric_strings_are_accepted(self, build): display = build({"nhl": {"scroll_settings": { "scroll_speed": "100", "scroll_delay": "0.02"}}}) - display.scroll_helper.set_scroll_speed.assert_called_with(2.0) + applied = display.scroll_helper.set_scroll_speed.call_args[0][0] + assert applied == pytest.approx(100.0, abs=1.0) + + +class TestScrollingStateIsPublished: + """The core has to be told, or the frame hold is never applied and + deferred work runs in the middle of the scroll.""" + + def _drawable(self, display): + display.scroll_helper.cached_image = Image.new("RGB", (400, 32)) + display.scroll_helper.get_visible_portion.return_value = Image.new( + "RGB", (128, 32)) + + def test_a_drawn_frame_declares_the_hold(self, build, display_manager): + display = build() + self._drawable(display) + assert display.display_scroll_frame() is True + display_manager.set_scrolling_state.assert_called_with( + True, frame_hold=display._scroll_frame_hold()) + + def test_completion_releases_the_state(self, build, display_manager): + display = build() + display.scroll_helper.is_scroll_complete.return_value = True + assert display.is_scroll_complete() is True + display_manager.set_scrolling_state.assert_called_with(False) + + def test_an_incomplete_scroll_does_not_release(self, build, display_manager): + display = build() + display.scroll_helper.is_scroll_complete.return_value = False + display.is_scroll_complete() + assert (False,) not in [c.args for c in + display_manager.set_scrolling_state.call_args_list] + + def test_clearing_releases_the_state(self, build, display_manager): + display = build() + display.clear() + display_manager.set_scrolling_state.assert_called_with(False) + + def test_an_older_core_without_the_call_is_tolerated(self, build): + """Plugins ship independently of the core they run against.""" + display = build() + del display.display_manager.set_scrolling_state + self._drawable(display) + assert display.display_scroll_frame() is True - def test_fps_clamping_is_left_to_the_helper(self, build): - """Deliberately not clamped here — a second copy of the range would - drift from ScrollHelper.set_target_fps.""" - display = build(global_config={"target_fps": 5000}) - display.scroll_helper.set_target_fps.assert_called_once_with(5000.0) # --------------------------------------------------------------------------- @@ -429,7 +491,11 @@ def test_global_config_is_threaded_to_children(self, manager): never honoring target_fps.""" child = manager.get_scroll_display("live") assert child.global_config == {"target_fps": 120} - child.scroll_helper.set_target_fps.assert_called_once_with(120.0) + # The target is now the refresh the crisp ladder is computed against, + # so what proves the hand-off is that the child reasons about 120Hz -- + # not the presentation rate, which the chosen hold divides down. + assert child._resolve_refresh_hz() == 120.0 + child.scroll_helper.set_target_fps.assert_called_once() def test_prepare_sets_the_active_type(self, manager): assert manager.prepare_and_display([{"id": "g1"}], "live", ["nhl"]) is True @@ -551,10 +617,17 @@ def real(self, display_manager, monkeypatch): ) def test_configuration_lands_on_the_real_helper(self, real): - helper = real.get_scroll_display("live").scroll_helper - assert helper.target_fps == 120.0 - assert helper.frame_based_scrolling is True - assert helper.scroll_speed == pytest.approx(0.5) # 500 px/s * 0.001 s + display = real.get_scroll_display("live") + helper = display.scroll_helper + # 500 px/s on a 120Hz panel snaps to a whole number of pixels per + # refresh, and the helper is driven in px/s in time-based mode. + assert helper.frame_based_scrolling is False + assert helper.scroll_speed == pytest.approx( + display._scroll_settings.pixels_per_second) + assert helper.scroll_speed == pytest.approx(500.0, abs=25.0) + # target_fps is the presentation rate the chosen hold produces. + assert helper.target_fps == pytest.approx( + 120.0 / display._scroll_settings.frame_hold, abs=1.0) def test_a_strip_is_built_and_scrolls_to_completion(self, real): import time