mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 08:48:05 +00:00
Vegas mode: one continuous strip instead of swapping cycles
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
This commit is contained in:
@@ -0,0 +1,245 @@
|
||||
"""
|
||||
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"
|
||||
@@ -1219,3 +1219,293 @@ class TestCaptureModeAlwaysHeld:
|
||||
|
||||
adapter.get_content(Boom(dm), 'boom')
|
||||
assert dm.capture_depth == 0
|
||||
|
||||
|
||||
class TestContinuousExtension:
|
||||
"""
|
||||
Continuous mode extends one strip instead of swapping in a new one, so the
|
||||
next group scrolls in from the right: no freeze, no substitution, and no
|
||||
restart with the viewport already full.
|
||||
"""
|
||||
|
||||
def _pipeline(self, groups, **cfg):
|
||||
"""groups: list of lists of (plugin_id, [images]) handed out in turn."""
|
||||
from src.vegas_mode.render_pipeline import RenderPipeline
|
||||
|
||||
class FakeStream:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
self.plugin_manager = type('PM', (), {'plugins': {}})()
|
||||
self.plugin_adapter = None
|
||||
self._i = 0
|
||||
|
||||
def get_grouped_content_for_composition(self):
|
||||
return groups[0] if groups else []
|
||||
|
||||
def get_active_plugin_ids(self):
|
||||
return [pid for pid, _ in (groups[0] if groups else [])]
|
||||
|
||||
def take_next_group(self, count=None, offscreen_only=False):
|
||||
self.calls.append(offscreen_only)
|
||||
if self._i >= len(groups):
|
||||
return []
|
||||
g = groups[self._i]
|
||||
self._i += 1
|
||||
return g
|
||||
|
||||
class DM:
|
||||
width = DISPLAY_W
|
||||
height = DISPLAY_H
|
||||
|
||||
def __init__(self):
|
||||
self.image = Image.new('RGB', (DISPLAY_W, DISPLAY_H))
|
||||
self.pushes = 0
|
||||
|
||||
def set_scrolling_state(self, *a):
|
||||
pass
|
||||
|
||||
def update_display(self):
|
||||
self.pushes += 1
|
||||
|
||||
cfg.setdefault('lead_in_width', 0)
|
||||
stream = FakeStream()
|
||||
return RenderPipeline(VegasModeConfig(**cfg), DM(), stream), stream
|
||||
|
||||
def _block(self, w):
|
||||
return Image.new('RGB', (w, DISPLAY_H), (255, 255, 255))
|
||||
|
||||
def test_needs_extension_only_near_the_end(self):
|
||||
p, _ = self._pipeline([[('a', [self._block(400)])]],
|
||||
continuous_scroll=True, extend_threshold_screens=2.0)
|
||||
p.compose_scroll_content()
|
||||
# 400px strip on a 512px display: already inside the threshold.
|
||||
assert p.needs_extension()
|
||||
|
||||
def test_no_extension_when_plenty_remains(self):
|
||||
p, _ = self._pipeline([[('a', [self._block(4000)])]],
|
||||
continuous_scroll=True, extend_threshold_screens=2.0)
|
||||
p.compose_scroll_content()
|
||||
assert not p.needs_extension()
|
||||
|
||||
def test_disabled_never_extends(self):
|
||||
p, _ = self._pipeline([[('a', [self._block(100)])]],
|
||||
continuous_scroll=False)
|
||||
p.compose_scroll_content()
|
||||
assert not p.needs_extension()
|
||||
|
||||
def test_extension_grows_the_strip_and_keeps_position(self):
|
||||
groups = [[('a', [self._block(600)])], [('b', [self._block(600)])]]
|
||||
p, _ = self._pipeline(groups, continuous_scroll=True)
|
||||
p.compose_scroll_content()
|
||||
before_width = p.scroll_helper.total_scroll_width
|
||||
p.scroll_helper.scroll_position = 120.0
|
||||
|
||||
assert p.extend_scroll_content()
|
||||
assert p.scroll_helper.total_scroll_width > before_width
|
||||
assert p.scroll_helper.scroll_position == 120.0
|
||||
|
||||
def test_extension_never_completes_the_cycle(self):
|
||||
groups = [[('a', [self._block(600)])], [('b', [self._block(600)])]]
|
||||
p, _ = self._pipeline(groups, continuous_scroll=True)
|
||||
p.compose_scroll_content()
|
||||
p.extend_scroll_content()
|
||||
assert not p.scroll_helper.scroll_complete
|
||||
|
||||
def test_deferred_plugins_are_dropped_when_unresolvable(self):
|
||||
# A None entry means "needs the render thread"; with no plugin instance
|
||||
# available it must be skipped rather than crashing or inserting a gap.
|
||||
groups = [[('a', [self._block(300)])],
|
||||
[('needs-canvas', None), ('b', [self._block(300)])]]
|
||||
p, _ = self._pipeline(groups, continuous_scroll=True)
|
||||
p.compose_scroll_content()
|
||||
assert p.extend_scroll_content()
|
||||
assert p.scroll_helper.total_scroll_width > 300
|
||||
|
||||
def test_empty_next_group_fails_cleanly(self):
|
||||
# compose_scroll_content does not consume a group, so the first extend
|
||||
# takes groups[0]; the second finds nothing left.
|
||||
groups = [[('a', [self._block(300)])], []]
|
||||
p, _ = self._pipeline(groups, continuous_scroll=True)
|
||||
p.compose_scroll_content()
|
||||
assert p.extend_scroll_content() is True
|
||||
assert p.extend_scroll_content() is False
|
||||
|
||||
def test_prefetch_requests_offscreen_only(self):
|
||||
# The background thread must never take a canvas-touching path.
|
||||
groups = [[('a', [self._block(600)])], [('b', [self._block(600)])]]
|
||||
p, stream = self._pipeline(groups, continuous_scroll=True)
|
||||
p.compose_scroll_content()
|
||||
p.start_prefetch()
|
||||
if p._prefetch_thread:
|
||||
p._prefetch_thread.join(timeout=5)
|
||||
assert stream.calls == [True]
|
||||
|
||||
def test_prepared_group_is_used_without_refetching(self):
|
||||
groups = [[('a', [self._block(600)])], [('b', [self._block(600)])]]
|
||||
p, stream = self._pipeline(groups, continuous_scroll=True)
|
||||
p.compose_scroll_content()
|
||||
p.start_prefetch()
|
||||
if p._prefetch_thread:
|
||||
p._prefetch_thread.join(timeout=5)
|
||||
|
||||
assert p.extend_scroll_content()
|
||||
# One offscreen prefetch, then one more kicked off for the group after.
|
||||
assert stream.calls[0] is True
|
||||
assert p._prepared_group is None or isinstance(p._prepared_group, list)
|
||||
|
||||
def test_strip_stays_bounded_over_many_extensions(self):
|
||||
groups = [[('g%d' % i, [self._block(600)])] for i in range(30)]
|
||||
p, _ = self._pipeline(groups, continuous_scroll=True)
|
||||
p.compose_scroll_content()
|
||||
|
||||
widths = []
|
||||
for _ in range(25):
|
||||
p.scroll_helper.scroll_position += 300
|
||||
p.scroll_helper.total_distance_scrolled += 300
|
||||
if p.needs_extension():
|
||||
p.extend_scroll_content()
|
||||
widths.append(p.scroll_helper.total_scroll_width)
|
||||
assert max(widths) < 6000, f"strip grew unbounded: {max(widths)}"
|
||||
|
||||
|
||||
class TestDeferredDraining:
|
||||
"""
|
||||
Canvas-bound plugins cannot be prepared off the render thread, so they are
|
||||
queued and appended one per frame. Doing all of them at once held the render
|
||||
thread for 1.75s on hardware.
|
||||
"""
|
||||
|
||||
def _pipeline(self, group, plugin_images, **cfg):
|
||||
from src.vegas_mode.render_pipeline import RenderPipeline
|
||||
|
||||
class FakeAdapter:
|
||||
def __init__(self, mapping):
|
||||
self.mapping = mapping
|
||||
self.calls = []
|
||||
|
||||
def get_content(self, plugin, plugin_id, offscreen_only=False):
|
||||
self.calls.append((plugin_id, offscreen_only))
|
||||
return self.mapping.get(plugin_id)
|
||||
|
||||
class FakeStream:
|
||||
def __init__(self):
|
||||
self.plugin_manager = type(
|
||||
'PM', (), {'plugins': {pid: object() for pid in plugin_images}})()
|
||||
self.plugin_adapter = FakeAdapter(plugin_images)
|
||||
self._served = False
|
||||
|
||||
def get_grouped_content_for_composition(self):
|
||||
return [('seed', [Image.new('RGB', (600, DISPLAY_H), (255, 255, 255))])]
|
||||
|
||||
def get_active_plugin_ids(self):
|
||||
return ['seed']
|
||||
|
||||
def take_next_group(self, count=None, offscreen_only=False):
|
||||
if self._served:
|
||||
return []
|
||||
self._served = True
|
||||
return group
|
||||
|
||||
class DM:
|
||||
width = DISPLAY_W
|
||||
height = DISPLAY_H
|
||||
|
||||
def __init__(self):
|
||||
self.image = Image.new('RGB', (DISPLAY_W, DISPLAY_H))
|
||||
|
||||
def set_scrolling_state(self, *a):
|
||||
pass
|
||||
|
||||
def update_display(self):
|
||||
pass
|
||||
|
||||
cfg.setdefault('lead_in_width', 0)
|
||||
cfg.setdefault('continuous_scroll', True)
|
||||
stream = FakeStream()
|
||||
p = RenderPipeline(VegasModeConfig(**cfg), DM(), stream)
|
||||
p.compose_scroll_content()
|
||||
return p, stream
|
||||
|
||||
def _img(self, w):
|
||||
return [Image.new('RGB', (w, DISPLAY_H), (255, 255, 255))]
|
||||
|
||||
def test_deferred_plugins_are_queued_not_fetched_inline(self):
|
||||
group = [('ready', self._img(200)), ('needs-canvas', None)]
|
||||
p, stream = self._pipeline(group, {'needs-canvas': self._img(150)})
|
||||
p.extend_scroll_content()
|
||||
# Only queued at this point — no fetch for it yet.
|
||||
assert p.has_deferred()
|
||||
assert all(pid != 'needs-canvas' for pid, _ in stream.plugin_adapter.calls)
|
||||
|
||||
def test_draining_appends_one_at_a_time(self):
|
||||
group = [('a', None), ('b', None), ('c', None)]
|
||||
images = {k: self._img(120) for k in ('a', 'b', 'c')}
|
||||
p, _ = self._pipeline(group, images)
|
||||
p.extend_scroll_content()
|
||||
|
||||
widths = [p.scroll_helper.total_scroll_width]
|
||||
drained = 0
|
||||
while p.has_deferred():
|
||||
assert p.drain_deferred()
|
||||
drained += 1
|
||||
widths.append(p.scroll_helper.total_scroll_width)
|
||||
assert drained == 3
|
||||
assert widths == sorted(widths), "each drain should extend the strip"
|
||||
|
||||
def test_drain_uses_the_full_path_not_offscreen(self):
|
||||
group = [('needs-canvas', None)]
|
||||
p, stream = self._pipeline(group, {'needs-canvas': self._img(150)})
|
||||
p.extend_scroll_content()
|
||||
p.drain_deferred()
|
||||
assert ('needs-canvas', False) in stream.plugin_adapter.calls
|
||||
|
||||
def test_drain_is_a_no_op_with_an_empty_queue(self):
|
||||
p, _ = self._pipeline([('a', self._img(200))], {})
|
||||
p.extend_scroll_content()
|
||||
assert not p.has_deferred()
|
||||
assert p.drain_deferred() is False
|
||||
|
||||
def test_scroll_position_survives_draining(self):
|
||||
group = [('a', None), ('b', None)]
|
||||
p, _ = self._pipeline(group, {k: self._img(120) for k in ('a', 'b')})
|
||||
p.extend_scroll_content()
|
||||
p.scroll_helper.scroll_position = 200.0
|
||||
while p.has_deferred():
|
||||
p.drain_deferred()
|
||||
assert p.scroll_helper.scroll_position == 200.0
|
||||
|
||||
def test_a_plugin_yielding_nothing_is_dropped_from_the_queue(self):
|
||||
group = [('empty', None)]
|
||||
p, _ = self._pipeline(group, {'empty': None})
|
||||
p.extend_scroll_content()
|
||||
assert p.drain_deferred() is False
|
||||
assert not p.has_deferred(), "must not retry forever"
|
||||
|
||||
def test_all_deferred_group_still_reports_progress(self):
|
||||
# Nothing appendable right now, but the queue will extend the strip.
|
||||
group = [('a', None), ('b', None)]
|
||||
p, _ = self._pipeline(group, {k: self._img(100) for k in ('a', 'b')})
|
||||
assert p.extend_scroll_content() is True
|
||||
assert p.has_deferred()
|
||||
|
||||
def test_drains_are_spaced_when_lookahead_is_healthy(self):
|
||||
# With plenty of strip ahead there is no hurry, so consecutive drains
|
||||
# must be throttled rather than firing back to back.
|
||||
group = [('a', None), ('b', None)]
|
||||
p, _ = self._pipeline(group, {k: self._img(4000) for k in ('a', 'b')})
|
||||
p.extend_scroll_content()
|
||||
|
||||
assert p.drain_deferred() is True # first one goes through
|
||||
# Strip is now long, so the next is deferred by the interval.
|
||||
assert p.scroll_helper.remaining_unscrolled() > (
|
||||
DISPLAY_W * p.config.extend_threshold_screens)
|
||||
assert p.drain_deferred() is False
|
||||
assert p.has_deferred(), "queue must be kept, not dropped"
|
||||
|
||||
def test_urgent_drain_ignores_the_throttle(self):
|
||||
# When the strip is nearly exhausted, content matters more than smoothness.
|
||||
group = [('a', None), ('b', None)]
|
||||
p, _ = self._pipeline(group, {k: self._img(80) for k in ('a', 'b')})
|
||||
p.extend_scroll_content()
|
||||
assert p.drain_deferred() is True
|
||||
assert p.drain_deferred() is True
|
||||
|
||||
Reference in New Issue
Block a user