Files
LEDMatrix/test/test_vegas_density.py
T
5b45f35888 Vegas mode: reclaim dead space and pace the rotation (#423)
* Vegas mode: reclaim dead space and pace the rotation

On a wide panel Vegas mode spent much of its time showing black. At 50px/s
on a 512px display, one display width of blank is 10.2 seconds, which makes
several long-standing behaviours expensive:

- ScrollHelper prepended a full display width of black as an "initial gap",
  charged once per cycle — 10.2s of black at the start of every rotation.
- Plugins without get_vegas_content() are captured off a full-display canvas,
  so their blank margins entered the ticker too. Measured: of-the-day drew
  35px of "No Data" on a 512px canvas (92% blank), youtube-stats 142px of
  content with 185px of black either side. Only the scroll_helper path had
  any trimming.
- Cycle transitions deliberately pushed a blank frame and then recomposed
  synchronously: 84ms at best, 4.8s at worst, every millisecond of it black.
- buffer_ahead doubled as the cycle size, so a 21-plugin install showed 3
  plugins per cycle and took ~7 cycles to come around.
- separator_width was applied between every image rather than at plugin
  boundaries, so a per-row ticker like the F1 scoreboard (116 images, which
  it renders 4px apart internally) got a 32px chasm between each row — and
  the width budget didn't count those gaps, so the plugin quietly occupied
  far more of the panel than intended.

Changes:

- src/vegas_mode/geometry.py: numpy column-ink primitives shared by the
  trimmer and the audit tool, so the number reported is the number acted on.
  A Python per-column loop over a 17,000px strip is far too slow for the
  render path.
- PluginAdapter trims every content path, not just scroll_helper. Only outer
  edges are cropped: interior blank columns are the plugin's own layout
  (logo left, score right) and closing them would corrupt the design. A
  plugin on a non-black background is inherently unaffected.
- ScrollHelper.create_scrolling_image takes an explicit lead_gap, still
  defaulting to display_width so the many standalone-ticker callers are
  unchanged. Vegas passes lead_in_width (default 0).
- Cycle end holds the last rendered frame instead of blanking, turning the
  recompose into a brief freeze rather than the panel switching off.
- plugins_per_cycle (default 6) is split from buffer_ahead, which goes back
  to being only a prefetch low-water mark.
- max_plugin_width_ratio (default 3x display width) caps one plugin's share
  of a cycle. Overflow is deferred, not discarded: a rotation offset advances
  each fetch so later rows appear on subsequent cycles. Single oversized
  images are cropped at a blank column so the cut misses glyphs.
- Composition groups images by plugin: rows are joined by intra_plugin_gap
  (default 8) and separator_width applies only between plugins. The width
  budget now counts those gaps.
- Plugin data updates no longer run on the Vegas render path.

All new settings are user-configurable in Display -> Vegas Scroll, including
min/max cycle duration and dynamic duration, which previously existed in code
but were reachable only by hand-editing config.json.

Measured with scripts/dev/vegas_audit.py on a 512x64 panel:

  mean ink coverage    42.7% -> 69.4%
  fully blank           5.9% -> 0%
  reads as empty        13.6% -> 0%
  worst blank stretch    4.8s -> 0s
  full rotation          414s -> 123s
  plugins per cycle         3 -> 6

Note the metric choice: a "fully blank" scan (>=95% black viewport) reported
only 0.4% and badly understated the problem, because two full-width segments
with mid-canvas content never fully blank the viewport — they hold it at ~28%.
window_coverage_stats grades every viewport position by how much ink it
carries, which is what tracks perceived dead time.

Known remaining: cycle transitions still freeze ~3.5s while the next cycle is
fetched. Fixing that needs background prefetch, which is deferred because the
fallback-capture path mutates the shared display_manager.image and racing it
against the render loop risks torn frames.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ

* Drop unused Optional import from the vegas audit script

Flagged by Codacy (F401). Any, Dict and List are all still used.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ

* Align Vegas API bounds with validate(), fix audit config plumbing

Both from review feedback on #423.

The web API's accepted ranges disagreed with VegasModeConfig.validate(),
which is what actually gates Vegas starting:

  scroll_speed      1-100  -> 1-200   (a slider value of 150 returned 400)
  separator_width   0-500  -> 0-128
  target_fps        1-200  -> 30-200
  buffer_ahead      1-20   -> 1-5

The three loose ones were the dangerous direction: the value saved with a
200, then VegasModeCoordinator.start() failed validation with only a log
line, so the ticker silently never ran. The UI already matched validate() in
all four cases, so the API was the odd one out.

test_vegas_api_bounds_match_validate parses the numeric_fields map out of
api_v3 and asserts every bound against validate(), plus that validate()
accepts both endpoints and rejects just outside them, so these cannot drift
apart again. That test immediately caught a missing upper bound on
min_plugin_width, now added — unbounded it would drop every segment and
leave a blank ticker.

Separately, vegas_audit.py constructed PluginAdapter without the config, so
it fell back to VegasModeConfig() defaults and would report trimming and
width-budget behaviour that differed from the user's config.json. It now
passes the loaded config exactly as the coordinator does. This is the same
class of drift the explicit lead_gap and grouping arguments already guard
against. Output is unchanged on a rig whose config matches the defaults.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ

* Vegas mode: render plugins narrower, space rows by measured separation

Trimming reclaims blank margins but cannot compact a layout that genuinely
spans the display — a five-column forecast, a progress bar drawn at 100%
width, a stat block with the panel's whole width between its elements. Those
need the plugin to make different layout decisions, which means telling it the
screen is narrower while it renders.

DisplayManager.render_size() presents a smaller logical canvas for the
duration of a Vegas content fetch, reusing the same _LogicalMatrix
indirection double-sided mode already relies on so plugins see a consistent
size from every accessor. Plugins that size themselves from matrix.width need
no changes at all; one that wants to be explicit can read the new
BasePlugin.get_vegas_render_width().

Width is a percentage so a single setting travels across panel sizes:
vegas_scroll.render_width_pct globally, or vegas_width_pct in an individual
plugin's config. Measured on a 512x64 panel with real data:

  ledmatrix-weather   1536px -> 576px   (forecast becomes narrow cards)
  youtube-stats        353px -> 199px   (2% blank left, so genuinely compact)
  geochron             453px -> 153px   (ink density rises to 100%)
  ledmatrix-flights    950px -> 740px

The youtube-stats figure is the clearest evidence the layout itself changed
rather than being cropped: at full width the content had to be trimmed from
512px to 353px, whereas at 40% it arrives with almost no blank to reclaim.

Row spacing is now measured rather than added. A flat gap gets it wrong in
both directions at once — content drawn flush to its own edges ends up nearly
touching (reported for recent sports scores, which sat 8px apart), while
content already carrying wide margins gets pushed even further out.
separation_gap() measures the blank each pair already has and adds only the
shortfall, up to min_content_separation (default 24). intra_plugin_gap stays
as a floor applied regardless.

Two tests shipped in the previous commit encoded the old flat-gap arithmetic
and are updated to the measured semantics, including one renamed to reflect
that zero intra_plugin_gap alone no longer butts rows together.

Also fixes a real bug found while testing: the harness display manager had no
render_size(), and because the adapter catches broadly that surfaced as "no
content" rather than an error, silently dropping five plugins. Added the
context to VisualTestDisplayManager for parity, and _render_at() now degrades
to a no-op on any display manager lacking it, so a third-party or older
harness loses the narrowing rather than the content.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ

* Vegas mode: end cycles before the wrap, keep the width budget honest

Three fixes, the first a regression from lead_in_width defaulting to 0.

get_visible_portion wraps: once scroll_position + display_width passes the end
of the strip it fills the right of the frame from the *head* of the same strip.
So the final display_width of travel showed the cycle's first plugin re-entering
on the right while its last plugin exited on the left, and the recompose that
followed replaced both at once. On a 512px panel at 50px/s that was 10.2s of
two plugins on screen at once, ending in a hard cut — reported as the ticker
"switching mid-scroll" from F1 to news.

That used to be invisible because the strip began with a full display_width of
blank, so the wrapped-in region was black. Removing that blank (it was 10s of
dead panel per cycle) exposed the wrap. Cycles now end one display width
earlier, before any wrapped content appears, clamped for strips no wider than
the display so they don't complete instantly and spin the recompose loop.

Verified on hardware: a 3936px strip now completes at 68.5s, exactly
(3936 - 512) / 50.

Second, auto_trim=False also skipped the width budget, which is an unrelated
concern — turning off margin cropping should not let one plugin hold the panel
for minutes. Seen in the field: the F1 scoreboard contributed 116 images and
14,848px untouched, giving a 33,821px cycle (11 minutes of content). The budget
now applies regardless of trimming; with it restored that cycle is 6,362px.

Third, the budget accounted for row gaps using the flat intra_plugin_gap while
the compositor had moved to measured separation, so it under-counted by up to
(min_content_separation - intra_plugin_gap) per row and a many-row plugin
overran its cap. Both now use the same separation_gap() rule, and a test
asserts the composed block fits the budget end to end rather than trusting the
two paths to agree.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ

* Fix IndexError in find_blank_cut when the cut lands on the image edge

A cut position after the last column is legitimate — _crop_to_budget asks for
min(start + budget, img.width), which equals the width whenever the remaining
strip is shorter than the budget. find_blank_cut clamped target to width but
then walked leftwards starting at target itself, so ink[width] raised
IndexError.

Caught on hardware: it killed the ledmatrix-stocks fetch, and because
_fetch_plugin_content catches broadly that surfaced as the plugin silently
contributing nothing for the cycle.

Only reachable on the second or later pass of the rotating window over a single
oversized image, which is why the existing tests missed it — they all exercised
the first pass, where start is 0 and start + budget is comfortably inside the
image. Added TestRotationAcrossMultipleCycles, which walks the window round
several times and asserts content is never lost, plus direct coverage of
find_blank_cut at and beyond the image edge.

Both bounds now stop at width - 1 so neither direction can index past the end.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ

* Only cut oversized segments at real gaps between items

The width-budget crop snapped to the nearest blank column, and in rendered text
the gap between two characters is a single column. So a cut routinely landed
inside a word: the cycle showed "Wednesda" and the orphaned "y" turned up as a
lone floating letter in the next cycle, positioned after whatever plugin
happened to precede it.

Measured on the clock-simple segment to confirm: its blank runs are
[1, 1, 1, 1, 1, 8, 8] — five single-column letter gaps, every one of which
find_blank_cut would happily have chosen.

Cuts now only land in a run of at least min_cut_gap blank columns (default 6),
which excludes letter spacing while still finding the gaps plugins put between
items (the stocks ticker uses 32px, baseball 48px). Where no boundary falls
inside the budget the cut waits for the next one and overruns, because
splitting an item is worse than a slightly long segment.

Continuous content is treated differently on purpose: an image with no internal
gaps is a map or a chart, where any column is as good as another, so it is still
cut to the budget exactly. The gap rule protects discrete items; letting a solid
image escape the cap in its name would be wrong.

blank_runs() is vectorised — 48ms for a 17,000px strip, against seconds for a
per-column Python loop.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ

* Hold capture_mode for every plugin render, not just narrowed ones

The native content path only entered capture_mode when it was also narrowing
the canvas, so at full width — which is every plugin without a vegas_width_pct
override, i.e. most of them — a plugin calling update_display() while building
its Vegas content wrote straight to the hardware. That is a visible flash
mid-scroll, and it lines up with the flash reported at cycle transitions, when
several plugins are fetched back to back.

Suppression is now unconditional; the narrowing context stays separate because
it is already a no-op at full width.

Both contexts are reached through helpers that degrade to nullcontext when the
display manager lacks them. That matters more than it looks: the adapter's
handlers are deliberately broad, so an AttributeError from a missing context
does not surface as an error — it surfaces as the plugin contributing nothing.
Making the call unconditional without this turned 44 tests red for exactly that
reason, all of them reporting lost content rather than the real cause.

The test double now provides capture_mode and render_size too, so tests
exercise the real contexts instead of silently taking the degraded path.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ

* 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

* Pace the Vegas frame loop adaptively: 31.5 -> 78.7 fps

The loop slept a fixed frame_interval on top of however long the frame took, so
at a measured 31.6ms per frame a flat 8ms of that was pure idle — a quarter of
the budget spent not rendering. It now sleeps only the remainder of the budget.

Measured on hardware: 31.5 fps to 78.7 fps sustained, with CPU going *down* from
150% to 127%. Scroll speed is unchanged at 49.9px/s against a configured 50,
because motion is derived from elapsed time rather than frame count — this buys
smoothness, not speed.

Worth recording what the bottleneck was not: the per-frame render path measures
0.34ms in total (0.18ms for the numpy slice, 0.17ms for the dirty-tracking
digest), which is a theoretical 2900 fps. Optimising any of that would have been
wasted effort. The frame was idle, not busy.

Also nices the prefetch thread. Its work is PIL and numpy that releases the GIL,
so the scheduler can act on the priority, and without it the prefetch competes
for the same cores as the render loop.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ

* Sub-pixel scrolling: motion at the frame rate, not the pixel rate

With integer positioning the number of distinct frames per second equals the
scroll speed in px/s, however fast the loop renders. Measured at 50px/s and
78.7fps, 36% of frames were byte-identical: the extra frames cost work and
bought no motion, and what was left was 50 discrete 1px steps a second.

Two things were wrong with the pre-existing sub-pixel support. get_visible_portion
never consulted sub_pixel_scrolling — it always took the integer path, so the flag
and _get_visible_portion_subpixel were dead code. And that implementation needed
scipy.ndimage.shift, which is not installed on the target devices (HAS_SCIPY is
False there), so it would not have interpolated even if reached. Verified both:
positions 1000.0 and 1000.5 produced identical frames either way.

Blending is now wired up and implemented with numpy. Two details make it
affordable: slice cached_array directly instead of building two PIL images only
to convert them straight back (the naive version measured 15x the integer path),
and use fixed-point uint16 multiply-add rather than float32, which suits the Pi's
cores and gives finer weighting than the panel can resolve. Result 0.939ms
against 0.237ms — 0.70ms added per frame, a 1065fps ceiling.

Measured on hardware: 81.2 fps with blending on, against 78.7 with it off, so no
cost within noise — and every frame is now a distinct position rather than one in
three being a repeat.

The trade is a slight horizontal softening of text, since each frame blends two
positions. Set smooth_scroll false for maximum crispness.

Also benchmarked and cleared as non-issues: extending the strip costs 9.4ms on an
11,000px strip and trimming 2.5ms, both under one frame at this rate.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ

* Add overflow handling: keep ordered content whole instead of rotating a window

The width budget split any oversized plugin by advancing a window each cycle.
That is right for interchangeable items — news headlines, odds, stock prices —
but wrong for ordered content: a league table showed ranks 1-6, then resumed at
7 two rotations later, which reads as out of order and out of context. Nobody
needs rank 23 in a ticker; they need the top of the table, every time.

overflow_mode chooses between them:

  rotate   — advance a window each cycle so everything is seen eventually
             (unchanged default)
  truncate — always show the start and drop the rest, keeping ordered content
             coherent. Records no window state, so every pass starts at the top.

Per-plugin vegas_overflow overrides the global setting, since one install has
both kinds of plugin. Also adds per-plugin vegas_max_width_screens, so content
that must stay whole can be given more room — or uncapped with 0 — without
lifting the cap on every ticker.

Applied on the test rig: f1-scoreboard and ledmatrix-leaderboard set to
truncate, and baseball given 4.5 screens because it was showing 8 of 9 games
when the whole slate needed only a little more room. Verified: F1 now reports
"the first 10 of 116 ... the rest are not shown", baseball has dropped out of
the budget log entirely, and stocks, odds-ticker and stock-news still rotate.

Also corrects the crop log, which claimed "window advances next cycle"
unconditionally and so misreported truncated crops. A test now pins the
behaviour behind the message: truncate must leave no offset recorded.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ

* Stop Vegas mode showing last night's games as if they were live

A game that was live in the evening was still being drawn as live the next
morning. Two faults combined to freeze plugin visuals indefinitely.

PR #291 added a call to plugin_adapter.invalidate_plugin_scroll_cache() so
a plugin's own cached scroll image would be rebuilt from fresh data. That
method was never implemented. hot_swap_content() wraps the call in a broad
except, so every hot swap has raised AttributeError and been swallowed
silently ever since — which is why the visuals it was meant to keep fresh
never were.

Continuous scrolling then removed the only path that reached it at all:
should_recompose() and hot_swap_content() are called from the
non-continuous branch of run_frame(), and continuous_scroll defaults to
True. So on a default install the pending-update flags were set by the
update tick, never consumed, and grew without bound.

Together these froze content completely, because refetching is not enough
on its own: the sports plugins' get_vegas_content() regenerates only "if
the cache is empty", so take_next_group() kept receiving the same picture
however often it asked.

Fixed by:

- Implementing invalidate_plugin_scroll_cache(). It covers both layouts —
  a helper directly on the plugin (stocks, news, odds-ticker) and one
  owned by a scroll-display manager (the sports scoreboards, which is the
  shape that produced this bug) — and clears cached_image and
  cached_array together, since the array is the image's numpy mirror.

- Adding StreamManager.invalidate_pending_updates() and calling it from
  the continuous branch. It only drops the caches; the plugin recomposes
  when it next comes round in the rotation. process_updates() is wrong
  here: it refetches synchronously and merges into the active buffer that
  continuous mode bypasses, and hot_swap_content() rebuilds and
  repositions the whole strip, which is the freeze-and-jump this mode
  exists to avoid.

Tests assert the fix rather than the implementation: 14 of the 17 new
tests fail without it. Includes the wiring itself, since the regression
was a call that was simply absent, and a check that the scroll position is
untouched so this cannot regress into the swap's visible jump.

All Vegas suites pass (355 tests). test_display_controller_vegas_tick.py
still cannot be collected off-device for want of rgbmatrix, identically
with and without this change.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ

* Fix two CodeRabbit-flagged test assertions in vegas density tests

test_prepared_group_is_used_without_refetching had a tautological final
assertion; now checks stream.calls directly. test_no_partial_letter_at_either_edge
required both crop edges to be blank, but the left edge here is always the
crop's start position with no lead-in gap in word_strip, so it legitimately
carries ink — only the right edge is an actual cut and needs the check.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-31 09:40:38 -04:00

1646 lines
68 KiB
Python

"""
Tests for the Vegas mode density work: dead-space trimming in PluginAdapter
and the configurable lead-in gap in ScrollHelper.
"""
from contextlib import contextmanager
import pytest
from PIL import Image
from src.common.scroll_helper import ScrollHelper
from src.vegas_mode.config import VegasModeConfig
from src.vegas_mode.geometry import column_has_ink
from src.vegas_mode.plugin_adapter import PluginAdapter
DISPLAY_W = 512
DISPLAY_H = 64
class FakeDisplayManager:
"""Stand-in offering the same contexts the real DisplayManager does.
capture_mode and render_size must both be present: the adapter degrades
gracefully when they are missing, so a fake without them would silently
exercise the degraded path instead of the real one.
"""
width = DISPLAY_W
height = DISPLAY_H
def __init__(self):
self.image = Image.new('RGB', (DISPLAY_W, DISPLAY_H))
self.draw = None
self._capture_mode_active = False
@contextmanager
def capture_mode(self):
self._capture_mode_active = True
try:
yield
finally:
self._capture_mode_active = False
@contextmanager
def render_size(self, width, height=None):
prev = self.image
target_w = max(1, min(int(width), DISPLAY_W))
target_h = max(1, min(int(height) if height else DISPLAY_H, DISPLAY_H))
try:
self.image = Image.new('RGB', (target_w, target_h))
yield
finally:
self.image = prev
def clear(self):
self.image = Image.new('RGB', self.image.size)
def update_display(self):
pass
class NativePlugin:
"""Plugin that returns pre-rendered Vegas content."""
def __init__(self, images):
self._images = images
def get_vegas_content(self):
return self._images
def canvas(content_spans, width=DISPLAY_W, height=DISPLAY_H):
"""Full-display canvas with white content in the given [x0, x1) spans."""
img = Image.new('RGB', (width, height), (0, 0, 0))
for x0, x1 in content_spans:
img.paste(Image.new('RGB', (x1 - x0, height), (255, 255, 255)), (x0, 0))
return img
def adapter_with(**overrides):
cfg = VegasModeConfig(**overrides)
return PluginAdapter(FakeDisplayManager(), cfg)
class TestAdapterTrimming:
def test_of_the_day_case_is_reclaimed(self):
# Measured on devpi: "No Data" occupying 35px of a 512px canvas bought
# 9.5s of black at 50px/s.
adapter = adapter_with(content_padding=0)
plugin = NativePlugin([canvas([(4, 39)])])
images = adapter.get_content(plugin, 'of-the-day')
assert len(images) == 1
assert images[0].width == 35
def test_youtube_stats_case_is_reclaimed(self):
# Centred 142px of content on a 512px canvas: 185px black each side.
adapter = adapter_with(content_padding=0)
plugin = NativePlugin([canvas([(185, 327)])])
images = adapter.get_content(plugin, 'youtube-stats')
assert images[0].width == 142
def test_padding_is_applied_within_available_margin(self):
adapter = adapter_with(content_padding=8)
plugin = NativePlugin([canvas([(185, 327)])])
images = adapter.get_content(plugin, 'youtube-stats')
assert images[0].width == 142 + 16
def test_interior_layout_gap_survives(self):
# A logo far left and a score far right is deliberate layout; closing
# the gap would corrupt the design rather than reclaim dead space.
adapter = adapter_with(content_padding=0)
plugin = NativePlugin([canvas([(10, 40), (400, 460)])])
images = adapter.get_content(plugin, 'scoreboard')
assert images[0].width == 450 # 10..459
assert int(column_has_ink(images[0]).sum()) == 90
def test_wide_legitimate_content_is_left_alone(self):
# geochron uses 444 of 512 columns; only the real tail should go.
adapter = adapter_with(content_padding=0)
plugin = NativePlugin([canvas([(0, 444)])])
images = adapter.get_content(plugin, 'geochron')
assert images[0].width == 444
def test_non_black_background_is_untouched(self):
adapter = adapter_with(content_padding=0)
bg = Image.new('RGB', (DISPLAY_W, DISPLAY_H), (0, 0, 40))
plugin = NativePlugin([bg])
images = adapter.get_content(plugin, 'weather')
assert images[0].width == DISPLAY_W
def test_each_image_of_a_multi_item_segment_is_trimmed(self):
# compose_scroll_content treats every image as its own item, so a
# per-image trim is what makes separator_width the real gap.
adapter = adapter_with(content_padding=0)
plugin = NativePlugin([
canvas([(168, 344)]),
canvas([(200, 300)]),
canvas([(0, 512)]),
])
images = adapter.get_content(plugin, 'ledmatrix-flights')
assert [img.width for img in images] == [176, 100, 512]
def test_fully_blank_segment_contributes_nothing(self):
adapter = adapter_with()
plugin = NativePlugin([Image.new('RGB', (DISPLAY_W, DISPLAY_H))])
assert adapter.get_content(plugin, 'empty') is None
def test_blank_images_are_dropped_but_others_kept(self):
adapter = adapter_with(content_padding=0)
plugin = NativePlugin([
canvas([(100, 150)]),
Image.new('RGB', (DISPLAY_W, DISPLAY_H)),
canvas([(200, 260)]),
])
images = adapter.get_content(plugin, 'mixed')
assert [img.width for img in images] == [50, 60]
def test_min_plugin_width_rejects_noise(self):
adapter = adapter_with(content_padding=0, min_plugin_width=32)
plugin = NativePlugin([canvas([(10, 14)])])
assert adapter.get_content(plugin, 'sliver') is None
def test_min_plugin_width_of_zero_keeps_everything(self):
adapter = adapter_with(content_padding=0, min_plugin_width=0)
plugin = NativePlugin([canvas([(10, 14)])])
images = adapter.get_content(plugin, 'sliver')
assert images[0].width == 4
def test_auto_trim_off_preserves_original_behaviour(self):
adapter = adapter_with(auto_trim=False)
plugin = NativePlugin([canvas([(4, 39)])])
images = adapter.get_content(plugin, 'of-the-day')
assert images[0].width == DISPLAY_W
def test_trim_threshold_ignores_near_black_noise(self):
# A very dark band should not be mistaken for content.
img = Image.new('RGB', (DISPLAY_W, DISPLAY_H), (0, 0, 0))
img.paste(Image.new('RGB', (100, DISPLAY_H), (3, 3, 3)), (0, 0))
img.paste(Image.new('RGB', (50, DISPLAY_H), (255, 255, 255)), (200, 0))
adapter = adapter_with(content_padding=0, trim_threshold=10)
images = adapter.get_content(NativePlugin([img]), 'noisy')
assert images[0].width == 50
def test_height_is_preserved_through_trim(self):
adapter = adapter_with(content_padding=0)
plugin = NativePlugin([canvas([(100, 200)])])
images = adapter.get_content(plugin, 'x')
assert images[0].height == DISPLAY_H
def test_trimmed_result_is_cached(self):
adapter = adapter_with(content_padding=0)
plugin = NativePlugin([canvas([(100, 200)])])
first = adapter.get_content(plugin, 'cached')
# Swap the plugin's content; the cache should still serve the old size.
plugin._images = [canvas([(0, 512)])]
second = adapter.get_content(plugin, 'cached')
assert first[0].width == second[0].width == 100
def test_default_adapter_construction_still_works(self):
# Existing callers pass only the display manager.
adapter = PluginAdapter(FakeDisplayManager())
assert adapter.config.auto_trim is True
class TestWidthBudget:
def test_segment_within_budget_is_untouched(self):
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=3.0)
plugin = NativePlugin([canvas([(0, 400)])])
assert adapter.get_content(plugin, 'small')[0].width == 400
def test_oversized_multi_item_segment_is_capped(self):
# 10 items of 400px = 4000px against a 1536px budget (3 x 512).
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=3.0)
items = [canvas([(0, 400)], width=400) for _ in range(10)]
images = adapter.get_content(NativePlugin(items), 'stock-news')
assert sum(i.width for i in images) <= 3 * DISPLAY_W
assert len(images) == 3 # 1200px; a 4th would exceed 1536
def test_deferred_items_appear_on_the_next_cycle(self):
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0)
# Distinguish items by width so the rotation is observable.
items = [canvas([(0, w)], width=w) for w in (200, 210, 220, 230, 240)]
plugin = NativePlugin(items)
first = adapter.get_content(plugin, 'ticker')
adapter.invalidate_cache('ticker')
second = adapter.get_content(plugin, 'ticker')
assert [i.width for i in first] != [i.width for i in second]
def test_rotation_eventually_covers_every_item(self):
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0)
widths = (200, 210, 220, 230, 240)
items = [canvas([(0, w)], width=w) for w in widths]
plugin = NativePlugin(items)
seen = set()
for _ in range(10):
adapter.invalidate_cache('ticker')
for img in adapter.get_content(plugin, 'ticker'):
seen.add(img.width)
assert seen == set(widths)
def test_single_oversized_image_is_cropped_to_budget(self):
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0)
# A 6898px leaderboard strip, as measured on devpi. Solid ink means
# there is no blank column to snap to, so the cut lands on the budget.
plugin = NativePlugin([canvas([(0, 6898)], width=6898)])
images = adapter.get_content(plugin, 'ledmatrix-leaderboard')
assert len(images) == 1
assert images[0].width == DISPLAY_W
def test_single_image_crop_snaps_to_a_blank_column(self):
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0)
# Content blocks with gaps; the cut should land in a gap, not mid-block.
spans = [(x, x + 90) for x in range(0, 2000, 100)]
plugin = NativePlugin([canvas(spans, width=2000)])
images = adapter.get_content(plugin, 'gapped')
assert images[0].width != DISPLAY_W
assert abs(images[0].width - DISPLAY_W) <= DISPLAY_W // 16 + 1
def test_single_image_window_advances_across_cycles(self):
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0)
plugin = NativePlugin([canvas([(0, 3000)], width=3000)])
adapter.get_content(plugin, 'strip')
assert adapter._item_offsets['strip'] == DISPLAY_W
def test_budget_of_zero_disables_the_cap(self):
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=0)
plugin = NativePlugin([canvas([(0, 6898)], width=6898)])
assert adapter.get_content(plugin, 'huge')[0].width == 6898
def test_rotation_resets_when_content_shrinks_to_fit(self):
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0)
big = [canvas([(0, 300)], width=300) for _ in range(5)]
adapter.get_content(NativePlugin(big), 'shrink')
assert 'shrink' in adapter._item_offsets
adapter.invalidate_cache('shrink')
adapter.get_content(NativePlugin([canvas([(0, 100)], width=100)]), 'shrink')
assert 'shrink' not in adapter._item_offsets
def test_one_item_wider_than_budget_is_still_shown(self):
# Never return nothing just because the first whole item overflows.
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0)
items = [canvas([(0, 900)], width=900), canvas([(0, 100)], width=100)]
images = adapter.get_content(NativePlugin(items), 'wide-first')
assert len(images) >= 1
assert images[0].width == 900
class TestPluginBoundaryGaps:
"""separator_width belongs between plugins; intra_plugin_gap within one."""
def _pipeline(self, grouped, **cfg):
from src.vegas_mode.render_pipeline import RenderPipeline
class FakeStream:
def get_grouped_content_for_composition(self):
return grouped
def get_active_plugin_ids(self):
return [pid for pid, _ in grouped]
class DM:
width = DISPLAY_W
height = DISPLAY_H
def set_scrolling_state(self, *a):
pass
return RenderPipeline(VegasModeConfig(**cfg), DM(), FakeStream())
def test_separator_only_at_plugin_boundaries(self):
# Two plugins, two rows each. Expect: row row [sep] row row. These rows
# are drawn flush to their edges, so the intra-plugin gap is the full
# min_content_separation.
rows = [Image.new('RGB', (100, DISPLAY_H), (255, 255, 255)) for _ in range(4)]
pipeline = self._pipeline(
[('a', rows[:2]), ('b', rows[2:])],
separator_width=32, intra_plugin_gap=8, min_content_separation=24,
lead_in_width=0,
)
assert pipeline.compose_scroll_content()
ink = column_has_ink(pipeline.scroll_helper.cached_image)
assert ink[:100].all()
assert not ink[100:124].any() # measured gap inside plugin a
assert ink[124:224].all()
assert not ink[224:256].any() # separator between a and b
assert ink[256:356].all()
assert not ink[356:380].any() # measured gap inside plugin b
assert ink[380:480].all()
def test_total_width_uses_both_gap_sizes(self):
rows = [Image.new('RGB', (100, DISPLAY_H), (255, 255, 255)) for _ in range(4)]
pipeline = self._pipeline(
[('a', rows[:2]), ('b', rows[2:])],
separator_width=32, intra_plugin_gap=8, min_content_separation=24,
lead_in_width=0,
)
pipeline.compose_scroll_content()
# 4 rows + 2 measured intra gaps (24 each) + 1 separator
assert pipeline.scroll_helper.cached_image.width == 400 + 48 + 32
def test_f1_shaped_case_stays_below_the_separator_width(self):
# 12 rows from one plugin. Previously each boundary got the full 32px
# separator (352px of gap); rows are now spaced by measured separation,
# which for flush rows is min_content_separation.
rows = [Image.new('RGB', (128, DISPLAY_H), (255, 255, 255)) for _ in range(12)]
pipeline = self._pipeline(
[('f1-scoreboard', rows)],
separator_width=32, intra_plugin_gap=8, min_content_separation=24,
lead_in_width=0,
)
pipeline.compose_scroll_content()
width = pipeline.scroll_helper.cached_image.width
assert width == 12 * 128 + 11 * 24
assert width < 12 * 128 + 11 * 32 # cheaper than the old flat separator
def test_single_row_plugin_image_is_not_copied(self):
row = Image.new('RGB', (100, DISPLAY_H), (255, 255, 255))
pipeline = self._pipeline([('solo', [row])], lead_in_width=0)
assert pipeline._join_plugin_rows([row]) is row
def test_rows_butt_together_only_when_both_gap_settings_are_zero(self):
# intra_plugin_gap alone no longer decides this: min_content_separation
# would still push flush rows apart, which is the point of it.
rows = [Image.new('RGB', (50, DISPLAY_H), (255, 255, 255)) for _ in range(3)]
pipeline = self._pipeline(
[('a', rows)], separator_width=32, intra_plugin_gap=0,
min_content_separation=0, lead_in_width=0)
pipeline.compose_scroll_content()
assert pipeline.scroll_helper.cached_image.width == 150
assert column_has_ink(pipeline.scroll_helper.cached_image).all()
def test_empty_grouping_fails_composition(self):
pipeline = self._pipeline([])
assert pipeline.compose_scroll_content() is False
class TestWidthBudgetCountsGaps:
def test_budget_accounts_for_intra_plugin_gaps(self):
# 8 rows of 200px = 1600px of pixels, but with 8px gaps the real
# occupancy is 1600 + 56 = 1656px. Against a 512px budget the row count
# must be chosen using the gap-inclusive cost.
adapter = adapter_with(
content_padding=0, max_plugin_width_ratio=1.0, intra_plugin_gap=8)
items = [canvas([(0, 200)], width=200) for _ in range(8)]
images = adapter.get_content(NativePlugin(items), 'rows')
n = len(images)
assert 200 * n + 8 * (n - 1) <= DISPLAY_W
def test_gap_free_config_fits_more_rows(self):
items = [canvas([(0, 200)], width=200) for _ in range(8)]
with_gap = adapter_with(
content_padding=0, max_plugin_width_ratio=1.0, intra_plugin_gap=64)
without = adapter_with(
content_padding=0, max_plugin_width_ratio=1.0, intra_plugin_gap=0)
assert len(without.get_content(NativePlugin(items), 'r')) >= \
len(with_gap.get_content(NativePlugin(items), 'r'))
class TestStreamGrouping:
def _stream(self, segments):
from src.vegas_mode.stream_manager import StreamManager
from collections import deque
sm = StreamManager.__new__(StreamManager)
import threading
sm._buffer_lock = threading.RLock()
sm._active_buffer = deque(segments)
return sm
def _seg(self, plugin_id, count, mode=None):
from src.vegas_mode.stream_manager import ContentSegment
from src.plugin_system.base_plugin import VegasDisplayMode
imgs = [Image.new('RGB', (10, 8)) for _ in range(count)]
return ContentSegment(
plugin_id=plugin_id, images=imgs, total_width=10 * count,
display_mode=mode or VegasDisplayMode.SCROLL)
def test_grouping_preserves_plugin_boundaries(self):
sm = self._stream([self._seg('a', 3), self._seg('b', 1)])
grouped = sm.get_grouped_content_for_composition()
assert [(pid, len(imgs)) for pid, imgs in grouped] == [('a', 3), ('b', 1)]
def test_static_segments_are_skipped(self):
from src.plugin_system.base_plugin import VegasDisplayMode
sm = self._stream([
self._seg('a', 2),
self._seg('paused', 1, VegasDisplayMode.STATIC),
self._seg('b', 1),
])
assert [pid for pid, _ in sm.get_grouped_content_for_composition()] == ['a', 'b']
def test_imageless_segments_are_skipped(self):
sm = self._stream([self._seg('a', 0), self._seg('b', 2)])
assert [pid for pid, _ in sm.get_grouped_content_for_composition()] == ['b']
def test_flat_accessor_still_matches_grouped_total(self):
sm = self._stream([self._seg('a', 3), self._seg('b', 2)])
assert len(sm.get_all_content_for_composition()) == 5
class TestApiBoundsMatchValidate:
"""
The web API's accepted range for each Vegas setting must agree with
VegasModeConfig.validate(), which is what actually gates Vegas starting.
A looser API bound saves a value with a 200 and then makes
VegasModeCoordinator.start() bail out with only a log line, so the ticker
silently never runs. A tighter one rejects a legitimate value with a 400.
Both happened before this test existed.
"""
# (config key, min, max) as validate() enforces them.
EXPECTED = {
'scroll_speed': (1, 200),
'separator_width': (0, 128),
'intra_plugin_gap': (0, 128),
'target_fps': (30, 200),
'buffer_ahead': (1, 5),
'trim_threshold': (0, 254),
'content_padding': (0, 128),
'min_plugin_width': (0, 512),
'plugins_per_cycle': (1, 50),
}
def _api_numeric_fields(self):
"""Extract the numeric_fields map from api_v3 without importing Flask."""
import ast
import pathlib
src = pathlib.Path('web_interface/blueprints/api_v3.py').read_text()
tree = ast.parse(src)
for node in ast.walk(tree):
if not isinstance(node, ast.Assign):
continue
targets = [t.id for t in node.targets if isinstance(t, ast.Name)]
if 'numeric_fields' not in targets:
continue
if not isinstance(node.value, ast.Dict):
continue
found = {}
for key, value in zip(node.value.keys, node.value.values):
if not isinstance(key, ast.Constant):
continue
if not str(key.value).startswith('vegas_'):
break
cfg_key, lo, hi = [ast.literal_eval(e) for e in value.elts]
found[cfg_key] = (lo, hi)
if found:
return found
raise AssertionError("could not locate the vegas numeric_fields map")
def test_every_bound_matches_validate(self):
api = self._api_numeric_fields()
mismatched = {
key: (api[key], expected)
for key, expected in self.EXPECTED.items()
if key in api and api[key] != expected
}
assert not mismatched, f"API bounds disagree with validate(): {mismatched}"
@pytest.mark.parametrize('key,bounds', sorted(EXPECTED.items()))
def test_validate_accepts_both_endpoints(self, key, bounds):
lo, hi = bounds
for value in (lo, hi):
cfg = VegasModeConfig(**{key: value})
errors = [e for e in cfg.validate() if key in e]
assert not errors, f"{key}={value} should be valid, got {errors}"
@pytest.mark.parametrize('key,bounds', sorted(EXPECTED.items()))
def test_validate_rejects_just_outside(self, key, bounds):
lo, hi = bounds
for value in (lo - 1, hi + 1):
cfg = VegasModeConfig(**{key: value})
errors = [e for e in cfg.validate() if key in e]
assert errors, f"{key}={value} should be rejected"
class TestCycleSizing:
def test_plugins_per_cycle_defaults_above_buffer_ahead(self):
cfg = VegasModeConfig()
assert cfg.plugins_per_cycle == 6
assert cfg.plugins_per_cycle > cfg.buffer_ahead + 1
def test_plugins_per_cycle_parses(self):
cfg = VegasModeConfig.from_config(
{'display': {'vegas_scroll': {'plugins_per_cycle': 10}}})
assert cfg.plugins_per_cycle == 10
def test_max_plugin_width_ratio_parses(self):
cfg = VegasModeConfig.from_config(
{'display': {'vegas_scroll': {'max_plugin_width_ratio': 1.5}}})
assert cfg.max_plugin_width_ratio == 1.5
@pytest.mark.parametrize('overrides,bad_key', [
({'plugins_per_cycle': 0}, 'plugins_per_cycle'),
({'plugins_per_cycle': 99}, 'plugins_per_cycle'),
({'max_plugin_width_ratio': -1.0}, 'max_plugin_width_ratio'),
])
def test_validate_rejects_out_of_range(self, overrides, bad_key):
errors = VegasModeConfig(**overrides).validate()
assert any(bad_key in e for e in errors), errors
class TestScrollHelperLeadGap:
def test_default_lead_gap_is_display_width(self):
# Standalone tickers rely on scrolling in from off-screen; that
# behaviour must not change for the many non-Vegas callers.
sh = ScrollHelper(128, 32)
sh.create_scrolling_image([Image.new('RGB', (100, 32), (255, 0, 0))],
item_gap=0, element_gap=0)
assert sh.cached_image.width == 128 + 100
assert not column_has_ink(sh.cached_image)[:128].any()
def test_zero_lead_gap_starts_on_content(self):
sh = ScrollHelper(128, 32)
sh.create_scrolling_image([Image.new('RGB', (100, 32), (255, 0, 0))],
item_gap=0, element_gap=0, lead_gap=0)
assert sh.cached_image.width == 100
assert column_has_ink(sh.cached_image)[0]
def test_explicit_lead_gap_is_honoured(self):
sh = ScrollHelper(128, 32)
sh.create_scrolling_image([Image.new('RGB', (100, 32), (255, 0, 0))],
item_gap=0, element_gap=0, lead_gap=16)
assert sh.cached_image.width == 116
ink = column_has_ink(sh.cached_image)
assert not ink[:16].any()
assert ink[16:].all()
def test_negative_lead_gap_is_clamped(self):
sh = ScrollHelper(128, 32)
sh.create_scrolling_image([Image.new('RGB', (100, 32), (255, 0, 0))],
item_gap=0, element_gap=0, lead_gap=-50)
assert sh.cached_image.width == 100
def test_total_scroll_width_matches_image(self):
# The cycle-complete check compares against total_scroll_width, so a
# mismatch here would cut cycles short or overrun them.
sh = ScrollHelper(128, 32)
items = [Image.new('RGB', (60, 32), (255, 0, 0)) for _ in range(3)]
sh.create_scrolling_image(items, item_gap=32, element_gap=0, lead_gap=0)
assert sh.total_scroll_width == sh.cached_image.width
assert sh.cached_image.width == 60 * 3 + 32 * 2
def test_item_gaps_are_unaffected_by_lead_gap(self):
sh = ScrollHelper(128, 32)
items = [Image.new('RGB', (10, 32), (255, 0, 0)) for _ in range(2)]
sh.create_scrolling_image(items, item_gap=20, element_gap=0, lead_gap=0)
ink = column_has_ink(sh.cached_image)
assert ink[:10].all()
assert not ink[10:30].any()
assert ink[30:40].all()
class TestConfigSurface:
def test_new_keys_parse_from_config(self):
cfg = VegasModeConfig.from_config({'display': {'vegas_scroll': {
'auto_trim': False,
'trim_threshold': 25,
'content_padding': 4,
'min_plugin_width': 64,
'lead_in_width': 32,
}}})
assert cfg.auto_trim is False
assert cfg.trim_threshold == 25
assert cfg.content_padding == 4
assert cfg.min_plugin_width == 64
assert cfg.lead_in_width == 32
def test_defaults_favour_trimming(self):
cfg = VegasModeConfig.from_config({})
assert cfg.auto_trim is True
assert cfg.lead_in_width == 0
assert cfg.content_padding == 8
def test_round_trips_through_to_dict(self):
cfg = VegasModeConfig(trim_threshold=20, lead_in_width=64)
restored = VegasModeConfig.from_config(
{'display': {'vegas_scroll': cfg.to_dict()}})
assert restored.trim_threshold == 20
assert restored.lead_in_width == 64
def test_update_applies_new_keys(self):
cfg = VegasModeConfig()
cfg.update({'display': {'vegas_scroll': {'content_padding': 16}}})
assert cfg.content_padding == 16
@pytest.mark.parametrize('overrides,bad_key', [
({'trim_threshold': 300}, 'trim_threshold'),
({'trim_threshold': -1}, 'trim_threshold'),
({'content_padding': -5}, 'content_padding'),
({'content_padding': 500}, 'content_padding'),
({'min_plugin_width': -1}, 'min_plugin_width'),
({'lead_in_width': -1}, 'lead_in_width'),
])
def test_validate_rejects_out_of_range(self, overrides, bad_key):
errors = VegasModeConfig(**overrides).validate()
assert any(bad_key in e for e in errors), errors
def test_valid_config_has_no_errors(self):
assert VegasModeConfig(
trim_threshold=10, content_padding=8,
min_plugin_width=8, lead_in_width=0,
).validate() == []
class TestRenderWidthResolution:
"""Vegas asks plugins to render narrower so layouts compact, not crop."""
class CfgPlugin:
def __init__(self, cfg=None):
self.config = cfg or {}
def get_vegas_content(self):
return None
def test_defaults_to_full_width(self):
adapter = adapter_with()
assert adapter.resolve_render_width(self.CfgPlugin(), 'p') == DISPLAY_W
def test_global_percentage_applies(self):
adapter = adapter_with(render_width_pct=50)
assert adapter.resolve_render_width(self.CfgPlugin(), 'p') == DISPLAY_W // 2
def test_per_plugin_override_beats_global(self):
adapter = adapter_with(render_width_pct=50)
plugin = self.CfgPlugin({'vegas_width_pct': 30})
assert adapter.resolve_render_width(plugin, 'p') == int(DISPLAY_W * 0.3)
def test_per_plugin_can_opt_back_to_full_width(self):
adapter = adapter_with(render_width_pct=30)
plugin = self.CfgPlugin({'vegas_width_pct': 100})
assert adapter.resolve_render_width(plugin, 'p') == DISPLAY_W
@pytest.mark.parametrize('bad', [0, 5, 150, -10, 'wide', None, ''])
def test_invalid_override_falls_back_to_global(self, bad):
adapter = adapter_with(render_width_pct=50)
plugin = self.CfgPlugin({'vegas_width_pct': bad})
assert adapter.resolve_render_width(plugin, 'p') == DISPLAY_W // 2
def test_plugin_without_config_is_safe(self):
adapter = adapter_with(render_width_pct=50)
class NoCfg:
pass
assert adapter.resolve_render_width(NoCfg(), 'p') == DISPLAY_W // 2
def test_never_exceeds_panel_width(self):
adapter = adapter_with(render_width_pct=100)
assert adapter.resolve_render_width(self.CfgPlugin(), 'p') <= DISPLAY_W
class TestMeasuredSeparation:
"""Rows are spaced by measured blank, not a flat additive gap."""
def _pipeline(self, grouped, **cfg):
from src.vegas_mode.render_pipeline import RenderPipeline
class FakeStream:
def get_grouped_content_for_composition(self):
return grouped
def get_active_plugin_ids(self):
return [pid for pid, _ in grouped]
class DM:
width = DISPLAY_W
height = DISPLAY_H
def set_scrolling_state(self, *a):
pass
return RenderPipeline(VegasModeConfig(**cfg), DM(), FakeStream())
def test_flush_rows_are_pushed_to_the_target(self):
# The reported problem: score cards drawn edge to edge sat 8px apart.
rows = [Image.new('RGB', (100, DISPLAY_H), (255, 255, 255)) for _ in range(3)]
p = self._pipeline([('scores', rows)],
intra_plugin_gap=8, min_content_separation=24,
lead_in_width=0)
block = p._join_plugin_rows(rows)
assert block.width == 300 + 24 * 2
ink = column_has_ink(block)
assert not ink[100:124].any()
assert ink[124:224].all()
def test_rows_with_margins_are_not_pushed_further(self):
# Each row already carries 12px blank per side = 24px facing total,
# which meets the target, so only the floor is added.
rows = [canvas([(12, 88)], width=100) for _ in range(3)]
p = self._pipeline([('padded', rows)],
intra_plugin_gap=0, min_content_separation=24,
lead_in_width=0)
block = p._join_plugin_rows(rows)
assert block.width == 300
def test_floor_still_applies_when_target_is_met(self):
rows = [canvas([(12, 88)], width=100) for _ in range(2)]
p = self._pipeline([('padded', rows)],
intra_plugin_gap=6, min_content_separation=24,
lead_in_width=0)
assert p._join_plugin_rows(rows).width == 200 + 6
def test_gaps_are_per_pair_not_uniform(self):
# Flush row then a padded row: the two gaps must differ.
flush = Image.new('RGB', (100, DISPLAY_H), (255, 255, 255))
padded = canvas([(20, 80)], width=100)
p = self._pipeline([('mixed', [flush, padded, flush])],
intra_plugin_gap=0, min_content_separation=24,
lead_in_width=0)
block = p._join_plugin_rows([flush, padded, flush])
# gap1: flush right(0) + padded left(20) = 20 -> add 4
# gap2: padded right(20) + flush left(0) = 20 -> add 4
assert block.width == 300 + 4 + 4
def test_zero_target_falls_back_to_the_floor(self):
rows = [Image.new('RGB', (50, DISPLAY_H), (255, 255, 255)) for _ in range(2)]
p = self._pipeline([('a', rows)],
intra_plugin_gap=5, min_content_separation=0,
lead_in_width=0)
assert p._join_plugin_rows(rows).width == 100 + 5
class TestNewConfigKeys:
def test_render_width_pct_parses(self):
cfg = VegasModeConfig.from_config(
{'display': {'vegas_scroll': {'render_width_pct': 40}}})
assert cfg.render_width_pct == 40
def test_min_content_separation_parses(self):
cfg = VegasModeConfig.from_config(
{'display': {'vegas_scroll': {'min_content_separation': 16}}})
assert cfg.min_content_separation == 16
def test_defaults(self):
cfg = VegasModeConfig()
assert cfg.render_width_pct == 100
assert cfg.min_content_separation == 24
@pytest.mark.parametrize('overrides,bad_key', [
({'render_width_pct': 5}, 'render_width_pct'),
({'render_width_pct': 101}, 'render_width_pct'),
({'min_content_separation': -1}, 'min_content_separation'),
({'min_content_separation': 300}, 'min_content_separation'),
])
def test_validate_rejects_out_of_range(self, overrides, bad_key):
errors = VegasModeConfig(**overrides).validate()
assert any(bad_key in e for e in errors), errors
class TestCycleEndsBeforeWrap:
"""
get_visible_portion wraps the head of the strip into the right side of the
frame once scroll_position + display_width passes the end. With a leading
blank that was invisible; with lead_in_width=0 it showed the cycle's first
plugin re-entering while the last one exited, then a recompose replaced
both — the reported "switched mid-scroll".
"""
def _pipeline(self, strip_width, **cfg):
from src.vegas_mode.render_pipeline import RenderPipeline
class FakeStream:
def get_grouped_content_for_composition(self):
return [('a', [Image.new('RGB', (strip_width, DISPLAY_H), (255, 255, 255))])]
def get_active_plugin_ids(self):
return ['a']
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
p = RenderPipeline(VegasModeConfig(lead_in_width=0, **cfg), DM(), FakeStream())
assert p.compose_scroll_content()
return p
def _advance_to(self, pipeline, distance):
pipeline.scroll_helper.total_distance_scrolled = distance
pipeline.scroll_helper.scroll_position = float(distance)
def test_cycle_is_not_complete_before_the_wrap_point(self):
p = self._pipeline(2000)
self._advance_to(p, 2000 - DISPLAY_W - 1)
p.render_frame()
assert not p.is_cycle_complete()
def test_cycle_completes_exactly_at_the_wrap_point(self):
p = self._pipeline(2000)
self._advance_to(p, 2000 - DISPLAY_W)
p.render_frame()
assert p.is_cycle_complete()
def test_completes_a_full_display_width_earlier_than_the_strip_end(self):
# The whole point: it must not run to total_scroll_width, which is
# where the wrapped content has already been on screen for 10s at
# 50px/s on a 512px panel.
p = self._pipeline(3000)
self._advance_to(p, 3000 - DISPLAY_W - 1)
p.render_frame()
assert not p.is_cycle_complete()
self._advance_to(p, 3000 - DISPLAY_W)
p.render_frame()
assert p.is_cycle_complete()
def test_strip_narrower_than_the_display_does_not_complete_instantly(self):
# Subtracting the display width would go negative and end the cycle on
# the very first frame, spinning the recompose loop.
p = self._pipeline(200)
self._advance_to(p, 0)
p.render_frame()
assert not p.is_cycle_complete()
def test_strip_narrower_than_the_display_still_completes(self):
p = self._pipeline(200)
self._advance_to(p, 200)
p.render_frame()
assert p.is_cycle_complete()
def test_strip_exactly_the_display_width(self):
p = self._pipeline(DISPLAY_W)
self._advance_to(p, 0)
p.render_frame()
assert not p.is_cycle_complete()
self._advance_to(p, DISPLAY_W)
p.render_frame()
assert p.is_cycle_complete()
class TestBudgetIndependentOfTrim:
"""
Turning off margin trimming must not disable the per-plugin width cap —
they are unrelated concerns. Found in the field: with auto_trim off, the F1
scoreboard contributed 116 images / 14,848px untouched, producing a 33,821px
cycle.
"""
def test_budget_still_applies_with_trim_off(self):
adapter = adapter_with(auto_trim=False, max_plugin_width_ratio=1.0,
intra_plugin_gap=0, min_content_separation=0)
items = [canvas([(0, 400)], width=400) for _ in range(10)]
images = adapter.get_content(NativePlugin(items), 'f1-scoreboard')
assert sum(i.width for i in images) <= DISPLAY_W
def test_trim_off_still_leaves_content_untrimmed(self):
# The margins must survive; only the cap should act.
adapter = adapter_with(auto_trim=False, max_plugin_width_ratio=0)
images = adapter.get_content(NativePlugin([canvas([(4, 39)])]), 'x')
assert images[0].width == DISPLAY_W
def test_single_oversized_image_capped_with_trim_off(self):
adapter = adapter_with(auto_trim=False, max_plugin_width_ratio=1.0)
images = adapter.get_content(
NativePlugin([canvas([(0, 6898)], width=6898)]), 'leaderboard')
assert images[0].width <= DISPLAY_W + DISPLAY_W // 16
class TestBudgetUsesMeasuredGaps:
"""
The budget must count the gaps the compositor actually inserts. Assuming the
flat intra_plugin_gap under-counted by up to
(min_content_separation - intra_plugin_gap) per row, so a many-row plugin
overran its cap.
"""
def test_flush_rows_are_budgeted_with_the_measured_gap(self):
# 6 flush rows of 100px against a 512px budget. With 24px measured gaps
# only 4 fit (400 + 3*24 = 472; a 5th would be 596).
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0,
intra_plugin_gap=8, min_content_separation=24)
rows = [Image.new('RGB', (100, DISPLAY_H), (255, 255, 255)) for _ in range(6)]
images = adapter.get_content(NativePlugin(rows), 'rows')
n = len(images)
assert 100 * n + 24 * (n - 1) <= DISPLAY_W
assert n == 4
def test_larger_separation_fits_fewer_rows(self):
rows = [Image.new('RGB', (100, DISPLAY_H), (255, 255, 255)) for _ in range(8)]
tight = adapter_with(content_padding=0, max_plugin_width_ratio=1.0,
intra_plugin_gap=0, min_content_separation=0)
loose = adapter_with(content_padding=0, max_plugin_width_ratio=1.0,
intra_plugin_gap=0, min_content_separation=48)
assert len(loose.get_content(NativePlugin(rows), 'r')) < \
len(tight.get_content(NativePlugin(rows), 'r'))
def test_composed_block_respects_the_budget_end_to_end(self):
# The real invariant: what the compositor produces must fit the cap.
from src.vegas_mode.render_pipeline import RenderPipeline
cfg = dict(content_padding=0, max_plugin_width_ratio=1.0,
intra_plugin_gap=8, min_content_separation=24)
adapter = adapter_with(**cfg)
rows = [Image.new('RGB', (90, DISPLAY_H), (255, 255, 255)) for _ in range(9)]
selected = adapter.get_content(NativePlugin(rows), 'rows')
class FakeStream:
def get_grouped_content_for_composition(self):
return [('rows', selected)]
def get_active_plugin_ids(self):
return ['rows']
class DM:
width = DISPLAY_W
height = DISPLAY_H
def set_scrolling_state(self, *a):
pass
p = RenderPipeline(VegasModeConfig(lead_in_width=0, **cfg), DM(), FakeStream())
assert p._join_plugin_rows(selected).width <= DISPLAY_W
class TestRotationAcrossMultipleCycles:
"""
The single-image crop advances a window across cycles. The second and later
passes are where start + budget can land exactly on the image width, which
crashed find_blank_cut in the field and lost that plugin's content for the
cycle. First-pass-only tests never reach it.
"""
def test_window_advances_over_many_cycles_without_error(self):
# Mirrors the field case: 1840px stocks strip, 1536px budget, so the
# second pass starts at 1536 and start + budget == 3072 -> clamped to
# the 1840 width, i.e. target == img.width.
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=3.0)
strip = canvas([(0, 1840)], width=1840)
widths = []
for _ in range(8):
adapter.invalidate_cache('ledmatrix-stocks')
images = adapter.get_content(NativePlugin([strip]), 'ledmatrix-stocks')
assert images, "content must never be lost mid-rotation"
widths.append(images[0].width)
assert all(w > 0 for w in widths)
def test_offset_wraps_back_to_zero_at_the_end(self):
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=3.0)
strip = canvas([(0, 1840)], width=1840)
seen_reset = False
for _ in range(6):
adapter.invalidate_cache('s')
adapter.get_content(NativePlugin([strip]), 's')
if adapter._item_offsets.get('s', 0) == 0:
seen_reset = True
assert seen_reset, "window should wrap round rather than stall at the end"
def test_multi_row_rotation_never_returns_empty(self):
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0)
rows = [canvas([(0, 200)], width=200) for _ in range(7)]
for _ in range(10):
adapter.invalidate_cache('rows')
assert adapter.get_content(NativePlugin(rows), 'rows')
def word_strip(words, letter_w=7, letter_gap=1, item_gap=32, height=DISPLAY_H):
"""
Build a ticker-like strip: 'words' of solid blocks separated by 1px, with
a wide gap between words. Mirrors real rendered text, where the measured
gap between characters is a single column and the gap between items is 8px+.
Returns (image, list of (word_start, word_end) column ranges).
"""
spans = []
x = 0
for w_i, letters in enumerate(words):
start = x
for l_i in range(letters):
x += letter_w
if l_i < letters - 1:
x += letter_gap
spans.append((start, x))
if w_i < len(words) - 1:
x += item_gap
img = Image.new('RGB', (x, height), (0, 0, 0))
for w_i, letters in enumerate(words):
sx = spans[w_i][0]
for l_i in range(letters):
lx = sx + l_i * (letter_w + letter_gap)
img.paste(Image.new('RGB', (letter_w, height), (255, 255, 255)), (lx, 0))
return img, spans
class TestCutsNeverSplitWords:
"""
A cut placed in a 1px inter-letter gap orphans the tail of a word into the
next cycle. That is what produced a lone "y" from "Wednesday" floating
between two unrelated plugins.
"""
def test_cut_lands_in_an_item_gap_not_between_letters(self):
from src.vegas_mode.geometry import blank_runs
img, spans = word_strip([9, 9, 9, 9, 9]) # five 9-letter words
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=0.2,
min_cut_gap=6)
# Budget deliberately lands mid-word if cut naively.
images = adapter.get_content(NativePlugin([img]), 'ticker')
cut_width = images[0].width
# Every qualifying gap midpoint is a legal cut; assert we used one.
legal = {0, img.width} | {(a + b) // 2 for a, b in blank_runs(img, 6)}
assert cut_width in {c for c in legal}, \
f"cut at {cut_width} is not an item boundary; legal: {sorted(legal)}"
def test_no_partial_letter_at_either_edge(self):
# A split letter shows as a lit column touching the crop edge with the
# rest of its glyph missing. The crop always starts at 0 here (fresh
# adapter, no prior rotation offset), and word_strip's first word
# begins at column 0 with no lead-in gap, so the left edge is the
# true start of the content rather than a cut and legitimately
# carries ink. Only the right edge is where the width budget actually
# cropped, so that is the one that must land in a gap.
from src.vegas_mode.geometry import column_has_ink
img, _ = word_strip([9, 9, 9, 9, 9, 9])
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=0.25,
min_cut_gap=6)
out = adapter.get_content(NativePlugin([img]), 'ticker')[0]
ink = column_has_ink(out)
assert not ink[-1] or out.width == img.width, \
"crop's right edge lands on ink, so a glyph was cut through"
def test_rotation_never_orphans_a_fragment(self):
# Walk the window across the whole strip and assert no slice is a
# narrow sliver, which is what an orphaned letter looks like.
img, _ = word_strip([9] * 8)
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=0.2,
min_cut_gap=6)
widths = []
for _ in range(12):
adapter.invalidate_cache('ticker')
out = adapter.get_content(NativePlugin([img]), 'ticker')
assert out
widths.append(out[0].width)
# A single 7px letter is the fragment signature; nothing that narrow.
assert min(widths) > 10, f"orphaned fragment in {widths}"
def test_continuous_image_is_still_cut_to_budget(self):
# A map or chart has no item gaps; the gap rule must not let it escape
# the cap, because any column there is as good as another.
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0,
min_cut_gap=6)
solid = canvas([(0, 4000)], width=4000)
out = adapter.get_content(NativePlugin([solid]), 'geochron')[0]
assert out.width == DISPLAY_W
def test_overruns_budget_rather_than_splitting(self):
# One very long item with no internal gap: the cut must wait for the
# next real boundary even though that exceeds the budget.
img, spans = word_strip([80, 9], letter_gap=1, item_gap=32)
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=0.1,
min_cut_gap=6)
out = adapter.get_content(NativePlugin([img]), 'longitem')[0]
assert out.width > int(DISPLAY_W * 0.1), \
"should overrun to the next boundary instead of cutting the item"
def test_min_cut_gap_of_two_still_excludes_letter_spacing(self):
from src.vegas_mode.geometry import blank_runs
img, _ = word_strip([9, 9, 9])
# 1px letter gaps must never qualify, whatever the setting.
assert all(end - start >= 2 for start, end in blank_runs(img, 2))
def test_permissive_gap_rule_would_have_split_a_word(self):
"""
Pins the actual regression. With min_cut_gap=1 the 1px gaps between
letters qualify as cut points, so the crop lands inside a word; with the
default it can only land in the wide gaps between items.
"""
from src.vegas_mode.geometry import blank_runs
img, _ = word_strip([9, 9, 9, 9, 9])
letter_gaps = {(a + b) // 2 for a, b in blank_runs(img, 1)}
item_gaps = {(a + b) // 2 for a, b in blank_runs(img, 6)}
# The permissive rule offers many more cut points, and the extra ones
# are exactly the mid-word positions.
assert len(letter_gaps) > len(item_gaps)
mid_word = letter_gaps - item_gaps
assert mid_word, "expected inter-letter gaps to exist in the fixture"
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=0.2,
min_cut_gap=6)
out = adapter.get_content(NativePlugin([img]), 'ticker')[0]
assert out.width not in mid_word, \
f"cut at {out.width} is a mid-word position"
class TestCaptureModeAlwaysHeld:
"""
Building Vegas content is off-screen work, but plugins are free to call
update_display() while doing it. Outside capture_mode that write reaches the
hardware and flashes the panel mid-scroll, so every path that runs plugin
render code must hold capture_mode — including at full width, where the
narrowing context is a no-op.
"""
class RecordingDM:
width = DISPLAY_W
height = DISPLAY_H
def __init__(self):
self.image = Image.new('RGB', (DISPLAY_W, DISPLAY_H))
self.draw = None
self.capture_depth = 0
self.hardware_writes_while_uncaptured = 0
from contextlib import contextmanager
@contextmanager
def capture_mode(self):
self.capture_depth += 1
try:
yield
finally:
self.capture_depth -= 1
@contextmanager
def render_size(self, width, height=None):
yield
def clear(self):
self.image = Image.new('RGB', (DISPLAY_W, DISPLAY_H))
def update_display(self):
if self.capture_depth == 0:
self.hardware_writes_while_uncaptured += 1
class PushyPlugin:
"""A plugin that pushes to the display while building Vegas content."""
def __init__(self, dm, images):
self.display_manager = dm
self.config = {}
self._images = images
def get_vegas_content(self):
self.display_manager.update_display()
return self._images
def _run(self, **cfg):
dm = self.RecordingDM()
adapter = PluginAdapter(dm, VegasModeConfig(**cfg))
plugin = self.PushyPlugin(dm, [canvas([(100, 300)])])
adapter.get_content(plugin, 'pushy')
return dm
def test_no_hardware_write_escapes_at_full_width(self):
dm = self._run(render_width_pct=100)
assert dm.hardware_writes_while_uncaptured == 0
def test_no_hardware_write_escapes_when_narrowing(self):
dm = self._run(render_width_pct=50)
assert dm.hardware_writes_while_uncaptured == 0
def test_capture_mode_is_released_afterwards(self):
dm = self._run(render_width_pct=100)
assert dm.capture_depth == 0
def test_capture_mode_released_even_when_the_plugin_raises(self):
dm = self.RecordingDM()
adapter = PluginAdapter(dm, VegasModeConfig())
class Boom:
def __init__(self, d):
self.display_manager = d
self.config = {}
def get_vegas_content(self):
raise ValueError("boom")
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
# The extend must consume the prepared group rather than fetching inline,
# so no offscreen_only=False call may appear.
assert False not in stream.calls
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
class TestOverflowMode:
"""
Rotating a window through content only makes sense when the items are
interchangeable. For ordered content it shows the middle of a ranked list —
ranks 1-6, then 7 onwards two rotations later, which reads as out of order.
"""
class Cfg:
def __init__(self, cfg=None):
self.config = cfg or {}
def get_vegas_content(self):
return None
def test_default_is_rotate(self):
adapter = adapter_with()
assert adapter.resolve_overflow_mode(self.Cfg(), 'p') == 'rotate'
def test_global_mode_applies(self):
adapter = adapter_with(overflow_mode='truncate')
assert adapter.resolve_overflow_mode(self.Cfg(), 'p') == 'truncate'
def test_per_plugin_override_wins(self):
adapter = adapter_with(overflow_mode='rotate')
plugin = self.Cfg({'vegas_overflow': 'truncate'})
assert adapter.resolve_overflow_mode(plugin, 'p') == 'truncate'
@pytest.mark.parametrize('bad', ['sideways', '', None, 5])
def test_invalid_override_falls_back(self, bad):
adapter = adapter_with(overflow_mode='rotate')
plugin = self.Cfg({'vegas_overflow': bad})
assert adapter.resolve_overflow_mode(plugin, 'p') == 'rotate'
def test_truncate_always_shows_the_same_opening_rows(self):
# The reported problem: a ranked list should not resume from the middle.
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0,
intra_plugin_gap=0, min_content_separation=0,
overflow_mode='truncate')
rows = [canvas([(0, 200)], width=200) for _ in range(8)]
plugin = NativePlugin(rows)
first = adapter.get_content(plugin, 'ranks')
adapter.invalidate_cache('ranks')
second = adapter.get_content(plugin, 'ranks')
assert len(first) == len(second)
assert 'ranks' not in adapter._item_offsets, "must not advance a window"
def test_rotate_still_advances(self):
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0,
intra_plugin_gap=0, min_content_separation=0,
overflow_mode='rotate')
rows = [canvas([(0, 200)], width=200) for _ in range(8)]
adapter.get_content(NativePlugin(rows), 'ticker')
assert adapter._item_offsets.get('ticker', 0) > 0
def test_truncate_on_a_single_wide_image_starts_at_zero(self):
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0,
overflow_mode='truncate')
strip = canvas([(0, 5000)], width=5000)
plugin = NativePlugin([strip])
first = adapter.get_content(plugin, 'table')[0]
adapter.invalidate_cache('table')
second = adapter.get_content(plugin, 'table')[0]
assert first.tobytes() == second.tobytes(), "window must not advance"
class TestPerPluginWidthBudget:
class Cfg:
def __init__(self, cfg=None):
self.config = cfg or {}
def get_vegas_content(self):
return None
def test_global_ratio_by_default(self):
adapter = adapter_with(max_plugin_width_ratio=3.0)
assert adapter._width_budget(self.Cfg(), 'p') == DISPLAY_W * 3
def test_per_plugin_override_widens(self):
adapter = adapter_with(max_plugin_width_ratio=3.0)
plugin = self.Cfg({'vegas_max_width_screens': 8})
assert adapter._width_budget(plugin, 'p') == DISPLAY_W * 8
def test_per_plugin_zero_means_uncapped(self):
adapter = adapter_with(max_plugin_width_ratio=3.0)
plugin = self.Cfg({'vegas_max_width_screens': 0})
assert adapter._width_budget(plugin, 'p') == 0
def test_uncapped_plugin_keeps_all_its_content(self):
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0)
class Wide(NativePlugin):
def __init__(self, images):
super().__init__(images)
self.config = {'vegas_max_width_screens': 0}
strip = canvas([(0, 6000)], width=6000)
assert adapter.get_content(Wide([strip]), 'whole')[0].width == 6000
@pytest.mark.parametrize('bad', ['wide', -1, None, ''])
def test_invalid_override_falls_back_to_global(self, bad):
adapter = adapter_with(max_plugin_width_ratio=2.0)
plugin = self.Cfg({'vegas_max_width_screens': bad})
assert adapter._width_budget(plugin, 'p') == DISPLAY_W * 2
def test_no_plugin_uses_the_global(self):
adapter = adapter_with(max_plugin_width_ratio=2.0)
assert adapter._width_budget() == DISPLAY_W * 2
def test_truncate_single_image_never_records_an_offset(self):
# The behavioural guarantee behind the log message: no window state is
# kept, so every pass starts at the top.
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0,
overflow_mode='truncate')
strip = canvas([(0, 5000)], width=5000)
for _ in range(4):
adapter.invalidate_cache('table')
adapter.get_content(NativePlugin([strip]), 'table')
assert 'table' not in adapter._item_offsets
def test_rotate_single_image_does_record_an_offset(self):
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0,
overflow_mode='rotate')
strip = canvas([(0, 5000)], width=5000)
adapter.get_content(NativePlugin([strip]), 'ticker')
assert adapter._item_offsets.get('ticker', 0) > 0