mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 08:48:05 +00:00
A cycle used to be a discrete strip that got replaced: motion stopped, every pixel was substituted at once, and the next group started with the viewport already full. That is the freeze, the flash and the jump. The strip is now extended rather than replaced. ScrollHelper gains append_content(), which adds items on the right without touching scroll_position or total_distance_scrolled, so motion continues and the next group simply arrives from the right. Because completion is measured against total_scroll_width, extending also defers completion — there is no longer a cycle boundary to see. drop_scrolled_prefix() reclaims what has gone past, keeping the strip bounded however long Vegas runs (observed 5,000-11,000px against an unbounded strip otherwise). It shifts total_distance_scrolled and total_scroll_width together so the completion arithmetic is unchanged, and refuses to run while the viewport is wrapping: wrapping reads the head of the strip into the right of the frame, so trimming the head there would visibly change the picture. A test caught that. Groups are prepared off the render thread. The constraint is that the canvas and the matrix proxy are process-wide mutable state, so narrowing or capturing through them from another thread would corrupt the frame the render loop is pushing. get_content() therefore takes offscreen_only: the background thread uses only paths that avoid the canvas, and anything needing it is marked and picked up on the render thread. That puts the expensive work (native renders of leaderboard and baseball cards, seconds each) in the background and leaves the cheap work (display capture, 40-600ms) in the foreground. DisplayManager's capture flag is now thread-local. As a shared flag, a background capture would have suppressed the render loop's own frame pushes for its duration, freezing the panel precisely when the point was to avoid a freeze. Canvas-bound plugins are drained one at a time rather than as a batch: six at once held the render thread for 1.75s. Drains are also spaced by two seconds while the lookahead is healthy, since taking them back to back turns one long stall into a run of short ones. When the strip is genuinely running short the throttle is ignored, because content matters more than smoothness there. Measured on hardware: zero cycle-complete swaps, drains landing 2-4s apart, lookahead holding at 1,200-3,500px, no errors. Set continuous_scroll false to restore the swap behaviour; the old path is intact. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
246 lines
9.8 KiB
Python
246 lines
9.8 KiB
Python
"""
|
|
Tests for ScrollHelper's continuous-strip primitives.
|
|
|
|
append_content extends the strip to the right without disturbing motion, and
|
|
drop_scrolled_prefix reclaims what has already gone past. Together they let a
|
|
caller keep one endless strip instead of swapping a new one in, which is what
|
|
shows as a flash and a hard cut to already-full-screen content.
|
|
"""
|
|
|
|
import numpy as np
|
|
import pytest
|
|
from PIL import Image
|
|
|
|
from src.common.scroll_helper import ScrollHelper
|
|
from src.vegas_mode.geometry import column_has_ink
|
|
|
|
W, H = 128, 32
|
|
|
|
|
|
def helper():
|
|
return ScrollHelper(W, H)
|
|
|
|
|
|
def block(width, colour=(255, 255, 255), height=H):
|
|
return Image.new('RGB', (width, height), colour)
|
|
|
|
|
|
class TestAppendContent:
|
|
def test_first_append_builds_the_strip(self):
|
|
sh = helper()
|
|
assert sh.append_content([block(100)], item_gap=0)
|
|
assert sh.cached_image is not None
|
|
assert sh.total_scroll_width == sh.cached_image.width
|
|
|
|
def test_strip_grows_by_content_plus_gaps(self):
|
|
sh = helper()
|
|
sh.create_scrolling_image([block(100)], item_gap=0, element_gap=0, lead_gap=0)
|
|
assert sh.cached_image.width == 100
|
|
|
|
sh.append_content([block(50)], item_gap=10, element_gap=0)
|
|
# one leading gap of 10 then the 50px block
|
|
assert sh.cached_image.width == 160
|
|
assert sh.total_scroll_width == 160
|
|
|
|
def test_scroll_position_is_preserved(self):
|
|
sh = helper()
|
|
sh.create_scrolling_image([block(400)], item_gap=0, element_gap=0, lead_gap=0)
|
|
sh.scroll_position = 137.0
|
|
sh.total_distance_scrolled = 137.0
|
|
|
|
sh.append_content([block(200)], item_gap=16)
|
|
assert sh.scroll_position == 137.0
|
|
assert sh.total_distance_scrolled == 137.0
|
|
|
|
def test_appending_defers_completion(self):
|
|
sh = helper()
|
|
sh.create_scrolling_image([block(200)], item_gap=0, element_gap=0, lead_gap=0)
|
|
sh.scroll_complete = True
|
|
|
|
sh.append_content([block(200)], item_gap=0)
|
|
assert not sh.scroll_complete
|
|
assert sh.total_distance_scrolled < sh.total_scroll_width
|
|
|
|
def test_existing_pixels_are_untouched(self):
|
|
sh = helper()
|
|
original = block(80, (10, 200, 10))
|
|
sh.create_scrolling_image([original], item_gap=0, element_gap=0, lead_gap=0)
|
|
before = sh.cached_image.crop((0, 0, 80, H)).tobytes()
|
|
|
|
sh.append_content([block(40, (200, 10, 10))], item_gap=8)
|
|
assert sh.cached_image.crop((0, 0, 80, H)).tobytes() == before
|
|
|
|
def test_appended_content_sits_after_the_gap(self):
|
|
sh = helper()
|
|
sh.create_scrolling_image([block(50)], item_gap=0, element_gap=0, lead_gap=0)
|
|
sh.append_content([block(30)], item_gap=12)
|
|
|
|
ink = column_has_ink(sh.cached_image)
|
|
assert ink[:50].all()
|
|
assert not ink[50:62].any() # the 12px gap
|
|
assert ink[62:92].all()
|
|
|
|
def test_array_and_image_stay_consistent(self):
|
|
# get_visible_portion slices cached_array but bounds-checks against
|
|
# cached_image.width, so a mismatch corrupts frames.
|
|
sh = helper()
|
|
sh.create_scrolling_image([block(200)], item_gap=0, element_gap=0, lead_gap=0)
|
|
sh.append_content([block(100)], item_gap=8)
|
|
assert sh.cached_array.shape[1] == sh.cached_image.width
|
|
assert sh.cached_array.shape[0] == sh.cached_image.height
|
|
|
|
def test_visible_portion_still_renders_after_append(self):
|
|
sh = helper()
|
|
sh.create_scrolling_image([block(300)], item_gap=0, element_gap=0, lead_gap=0)
|
|
sh.append_content([block(300)], item_gap=8)
|
|
sh.scroll_position = 250.0
|
|
frame = sh.get_visible_portion()
|
|
assert frame is not None and frame.size == (W, H)
|
|
|
|
def test_empty_append_is_a_no_op(self):
|
|
sh = helper()
|
|
sh.create_scrolling_image([block(100)], item_gap=0, element_gap=0, lead_gap=0)
|
|
assert sh.append_content([]) is False
|
|
assert sh.cached_image.width == 100
|
|
|
|
def test_repeated_appends_accumulate(self):
|
|
sh = helper()
|
|
sh.append_content([block(100)], item_gap=0)
|
|
for _ in range(5):
|
|
sh.append_content([block(100)], item_gap=0)
|
|
assert sh.cached_image.width == 600
|
|
|
|
|
|
class TestDropScrolledPrefix:
|
|
def test_removes_consumed_columns(self):
|
|
sh = helper()
|
|
sh.create_scrolling_image([block(1000)], item_gap=0, element_gap=0, lead_gap=0)
|
|
sh.scroll_position = 500.0
|
|
sh.total_distance_scrolled = 500.0
|
|
|
|
removed = sh.drop_scrolled_prefix(keep_before=0)
|
|
assert removed == 500
|
|
assert sh.cached_image.width == 500
|
|
assert sh.scroll_position == 0.0
|
|
|
|
def test_keeps_the_requested_margin(self):
|
|
sh = helper()
|
|
sh.create_scrolling_image([block(1000)], item_gap=0, element_gap=0, lead_gap=0)
|
|
sh.scroll_position = 500.0
|
|
sh.drop_scrolled_prefix(keep_before=100)
|
|
assert sh.scroll_position == 100.0
|
|
assert sh.cached_image.width == 600
|
|
|
|
def test_completion_difference_is_preserved(self):
|
|
# total_distance_scrolled and total_scroll_width must shift together, or
|
|
# trimming would spuriously complete or un-complete the cycle.
|
|
sh = helper()
|
|
sh.create_scrolling_image([block(1000)], item_gap=0, element_gap=0, lead_gap=0)
|
|
sh.scroll_position = 600.0
|
|
sh.total_distance_scrolled = 600.0
|
|
before = sh.total_scroll_width - sh.total_distance_scrolled
|
|
|
|
sh.drop_scrolled_prefix(keep_before=0)
|
|
assert sh.total_scroll_width - sh.total_distance_scrolled == before
|
|
|
|
def test_never_trims_below_the_viewport(self):
|
|
sh = helper()
|
|
sh.create_scrolling_image([block(200)], item_gap=0, element_gap=0, lead_gap=0)
|
|
sh.scroll_position = 190.0
|
|
sh.drop_scrolled_prefix(keep_before=0)
|
|
assert sh.cached_image.width >= W
|
|
|
|
def test_no_op_before_anything_has_scrolled(self):
|
|
sh = helper()
|
|
sh.create_scrolling_image([block(500)], item_gap=0, element_gap=0, lead_gap=0)
|
|
assert sh.drop_scrolled_prefix(keep_before=0) == 0
|
|
assert sh.cached_image.width == 500
|
|
|
|
def test_no_op_with_no_strip(self):
|
|
assert helper().drop_scrolled_prefix() == 0
|
|
|
|
def test_visible_frame_is_unchanged_by_trimming(self):
|
|
# The whole point: trimming is invisible. Same pixels on screen before
|
|
# and after. Position chosen so the viewport is well clear of the end,
|
|
# i.e. not wrapping.
|
|
sh = helper()
|
|
items = [block(200, (255, 0, 0)), block(200, (0, 255, 0)),
|
|
block(200, (0, 0, 255))]
|
|
sh.create_scrolling_image(items, item_gap=20, element_gap=0, lead_gap=0)
|
|
sh.scroll_position = 300.0
|
|
before = sh.get_visible_portion().tobytes()
|
|
|
|
assert sh.drop_scrolled_prefix(keep_before=0) > 0, "trim should have run"
|
|
after = sh.get_visible_portion().tobytes()
|
|
assert after == before
|
|
|
|
def test_refuses_to_trim_while_the_viewport_wraps(self):
|
|
# Wrapping reads the head of the strip into the right of the frame, so
|
|
# trimming the head there would visibly change the picture.
|
|
sh = helper()
|
|
sh.create_scrolling_image([block(200)], item_gap=0, element_gap=0, lead_gap=0)
|
|
sh.scroll_position = 150.0 # 150 + 128 > 200, so wrapping
|
|
before = sh.get_visible_portion().tobytes()
|
|
assert sh.drop_scrolled_prefix(keep_before=0) == 0
|
|
assert sh.get_visible_portion().tobytes() == before
|
|
|
|
def test_array_and_image_stay_consistent_after_trim(self):
|
|
sh = helper()
|
|
sh.create_scrolling_image([block(900)], item_gap=0, element_gap=0, lead_gap=0)
|
|
sh.scroll_position = 400.0
|
|
sh.drop_scrolled_prefix(keep_before=0)
|
|
assert sh.cached_array.shape[1] == sh.cached_image.width
|
|
|
|
|
|
class TestRemainingUnscrolled:
|
|
def test_counts_content_right_of_the_viewport(self):
|
|
sh = helper()
|
|
sh.create_scrolling_image([block(500)], item_gap=0, element_gap=0, lead_gap=0)
|
|
assert sh.remaining_unscrolled() == 500 - W
|
|
|
|
def test_shrinks_as_the_strip_scrolls(self):
|
|
sh = helper()
|
|
sh.create_scrolling_image([block(500)], item_gap=0, element_gap=0, lead_gap=0)
|
|
sh.scroll_position = 200.0
|
|
assert sh.remaining_unscrolled() == 500 - 200 - W
|
|
|
|
def test_never_negative(self):
|
|
sh = helper()
|
|
sh.create_scrolling_image([block(200)], item_gap=0, element_gap=0, lead_gap=0)
|
|
sh.scroll_position = 500.0
|
|
assert sh.remaining_unscrolled() == 0
|
|
|
|
def test_zero_with_no_strip(self):
|
|
assert helper().remaining_unscrolled() == 0
|
|
|
|
def test_grows_when_content_is_appended(self):
|
|
sh = helper()
|
|
sh.create_scrolling_image([block(600)], item_gap=0, element_gap=0, lead_gap=0)
|
|
sh.scroll_position = 100.0
|
|
before = sh.remaining_unscrolled()
|
|
assert before > 0, "fixture should leave content ahead of the viewport"
|
|
sh.append_content([block(400)], item_gap=0)
|
|
assert sh.remaining_unscrolled() == before + 400
|
|
|
|
|
|
class TestContinuousScrollingEndToEnd:
|
|
def test_strip_can_be_extended_indefinitely_at_bounded_size(self):
|
|
"""The invariant that makes this viable: extend + trim keeps the strip
|
|
bounded while motion never stops."""
|
|
sh = helper()
|
|
sh.create_scrolling_image([block(600)], item_gap=0, element_gap=0, lead_gap=0)
|
|
|
|
widths = []
|
|
for _ in range(20):
|
|
sh.scroll_position += 200
|
|
sh.total_distance_scrolled += 200
|
|
if sh.remaining_unscrolled() < 2 * W:
|
|
sh.append_content([block(600)], item_gap=16)
|
|
sh.drop_scrolled_prefix(keep_before=W)
|
|
widths.append(sh.cached_image.width)
|
|
# A frame must always be renderable.
|
|
assert sh.get_visible_portion() is not None
|
|
|
|
assert max(widths) < 3000, f"strip grew unbounded: max {max(widths)}"
|
|
assert not sh.scroll_complete, "continuous strip should never complete"
|