mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 08:48:05 +00:00
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
This commit is contained in:
@@ -1509,3 +1509,131 @@ class TestDeferredDraining:
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user