Files
LEDMatrix/test/test_vegas_prepared_blocks.py
T
ChuckandClaude Opus 5.5 7e066174d9 chore: remove dead code, deprecate unused plugin APIs (over-engineering audit)
Whole-tree audit. Every symbol was checked against core, the plugin
monorepo and all eight third-party plugins in plugins.json first.

- Deprecate (removal 3.10.0) plugin-facing methods nothing calls:
  LogoDownloader bulk download, ConfigManager backup/secret wrappers,
  APIHelper extras, BackgroundDataService poll API, PluginManager /
  PluginStateManager info readers, and a few CacheManager, FontManager,
  BaseOddsManager, DynamicTeamResolver methods and PluginTestCase.
  plugin_api_usage.py learns their receiver names; DEPRECATIONS doc
  regenerated.
- Remove core-internal dead code: CacheMetrics, Vegas status/stats
  plumbing, sync "new cycle" message (followers ignore unknown types),
  unused operation types, test-only PluginCatalog readers, IPC to_dict
  and ping, _parse_form_value, CacheStrategyProtocol, ErrorAggregator
  callbacks, duplicate web response helpers.
- Web UI: drop never-mounted json-file-manager.js, the example widget,
  utils/error_handler.js, four uncalled PluginAPI methods, and 29
  escapeHtml shims (call window.LEDEscape directly). Public globals,
  BaseWidget and widget names unchanged.
- Remove six one-off scripts (owner decision) and the unused markupsafe
  and pytest-mock pins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 18:27:40 -04:00

247 lines
9.6 KiB
Python

"""A group's blocks are laid out by the thread that fetched it, not at the extension.
Joining a plugin's rows (measuring the separation between them) and turning
the block into pixels cost the frame after every extension ~35 ms on a Pi 4,
against ~3.75 ms of slack. The prefetch thread and the live-element worker now
do it as each member arrives (RenderPipeline.prepare_group_member), and the
extension only writes those pixels into the strip (ScrollHelper.append_content
takes arrays). The strip a viewer sees must be exactly what it was.
"""
import random
import sys
from pathlib import Path
import numpy as np
import pytest
from PIL import Image, ImageDraw
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from src.common.scroll_helper import ScrollHelper # noqa: E402
from src.vegas_mode import elements # noqa: E402
from src.vegas_mode import render_pipeline # noqa: E402
from src.vegas_mode.config import VegasModeConfig # noqa: E402
from src.vegas_mode.elements import ElementMeta # noqa: E402
from src.vegas_mode.render_pipeline import RenderPipeline # noqa: E402
W, H = 96, 24
def _row(width, seed, live_key=None):
rng = np.random.default_rng(seed)
pixels = np.zeros((H, width, 3), dtype=np.uint8)
# Margins of different sizes, so the measured separation matters.
left, right = seed % 5, width - 1 - (seed % 7)
pixels[2:H - 2, left:right] = rng.integers(20, 255, (H - 4, right - left, 3), dtype=np.uint8)
image = Image.frombytes("RGB", (width, H), pixels.tobytes())
if live_key is None:
return image
pinned, array = elements.pin_element(image, 8)
return elements.tag(pinned, ElementMeta("p", live_key, 0, elements.pixel_digest(array), 0.0, 0.0))
def _groups(seed, n=8):
rng = random.Random(seed)
groups = []
for g in range(n):
group = []
for p in range(rng.randint(1, 3)):
rows = [_row(rng.randint(12, 60), rng.randint(0, 10**6),
f"g{g}p{p}r{r}" if rng.random() < 0.5 else None)
for r in range(rng.randint(1, 4))]
group.append((f"pl{g}{p}", rows))
groups.append(group)
return groups
class _Stream:
def __init__(self, groups, statics=()):
self.groups = groups
self.statics = set(statics)
self.plugin_manager = type("PM", (), {"plugins": {}})()
self.plugin_adapter = None
self.i = 0
def get_grouped_content_for_composition(self):
return self.groups[0]
def take_next_group(self, count=None, offscreen_only=False):
self.i += 1
return self.groups[self.i] if self.i < len(self.groups) else []
def is_static_plugin(self, plugin_id):
return plugin_id in self.statics
class _DM:
width, height = W, H
def __init__(self):
self.image = Image.new("RGB", (W, H))
def set_scrolling_state(self, *a):
pass
def update_display(self):
pass
def _pipeline(groups, prepared, statics=()):
p = RenderPipeline(VegasModeConfig(continuous_scroll=True, lead_in_width=0,
separator_width=12), _DM(), _Stream(groups, statics))
if prepared:
# The prefetch thread, run to completion before each extension.
def prefetch_now():
p._prepared_group = p.stream_manager.take_next_group(offscreen_only=True)
for member in p._prepared_group:
p.prepare_group_member(member)
p.start_prefetch = prefetch_now
else:
p.start_prefetch = lambda: None # every extension fetches inline
assert p.compose_scroll_content()
return p
def _run(p, steps=6):
for _ in range(steps):
p.scroll_helper.scroll_position = max(
p.scroll_helper.scroll_position, p.scroll_helper.total_scroll_width - 2 * W)
if not p.extend_scroll_content():
break
return p
@pytest.mark.parametrize("seed", range(6))
def test_the_strip_is_the_same_prepared_ahead_or_joined_at_the_extension(seed):
inline = _run(_pipeline(_groups(seed), prepared=False))
ahead = _run(_pipeline(_groups(seed), prepared=True))
assert np.array_equal(ahead.scroll_helper.cached_array, inline.scroll_helper.cached_array)
assert ahead._strip_origin == inline._strip_origin
assert [(r.key, r.abs_x, r.width) for r in ahead.live_records()] == \
[(r.key, r.abs_x, r.width) for r in inline.live_records()]
assert ahead.live_records()
def test_a_prepared_extension_joins_nothing_on_the_render_thread(monkeypatch):
p = _pipeline(_groups(1), prepared=True)
p.start_prefetch() # the group, prepared ahead
p.start_prefetch = lambda: None # and not the one after it
def joined_here(*_a, **_k):
raise AssertionError("the extension laid a block out itself")
monkeypatch.setattr(render_pipeline, "join_plugin_rows", joined_here)
monkeypatch.setattr(render_pipeline, "separation_gap", joined_here)
monkeypatch.setattr(Image.Image, "paste", joined_here)
assert p.extend_scroll_content()
assert not p._prepared_blocks # each block used once, then let go
def test_a_static_plugins_block_is_let_go_unused():
groups = _groups(2)
static = groups[1][0][0]
p = _pipeline(groups, prepared=True, statics=[static])
p.start_prefetch()
p.start_prefetch = lambda: None
assert p.extend_scroll_content()
assert not p._prepared_blocks
assert any(pid == static for _x, pid in p._static_markers)
def test_blocks_prepared_for_other_images_are_not_used():
groups = _groups(3)
p = _pipeline(groups, prepared=False)
images = groups[1][0][1]
p.prepare_group_member(("x", list(images))) # equal, not the same list
assert p._take_prepared_block(images) is None
# Filed under these images' id() -- as when a list dies and its id is
# reused -- but made from others.
p._prepared_blocks[id(images)] = render_pipeline.PreparedBlock(
[_row(20, 99)], p.config)
assert p._take_prepared_block(images) is None
def test_a_reset_drops_prepared_blocks():
p = _pipeline(_groups(4), prepared=True)
p.start_prefetch()
assert p._prepared_blocks
p.reset()
assert not p._prepared_blocks
def test_preparing_never_raises(caplog):
p = _pipeline(_groups(5), prepared=False)
p.prepare_group_member(("broken", [object()]))
p.prepare_group_member(("empty", []))
p.prepare_group_member(("deferred", None))
assert not p._prepared_blocks
def test_the_waiting_blocks_are_bounded():
p = _pipeline(_groups(6), prepared=False)
for n in range(RenderPipeline.PREPARED_BLOCKS_MAX * 2):
p.prepare_group_member((f"p{n}", [_row(20, n)]))
assert len(p._prepared_blocks) <= RenderPipeline.PREPARED_BLOCKS_MAX
# -- ScrollHelper.append_content with arrays ----------------------------------------
def _strip():
helper = ScrollHelper(W, H)
helper.create_scrolling_image([_row(40, k) for k in range(4)], item_gap=3, lead_gap=0)
return helper
@pytest.mark.parametrize("in_place", [False, True])
def test_appending_pixels_draws_exactly_what_appending_images_does(in_place):
items = [_row(30, 7),
_row(25, 8).convert("RGBA"), # converted, as paste does
Image.new("RGB", (20, H - 6), (200, 40, 40)), # short: black beneath
Image.new("RGB", (15, H + 6), (40, 200, 40))] # tall: cut at the strip
as_images, as_pixels = _strip(), _strip()
if in_place:
for helper in (as_images, as_pixels):
helper.append_content([_row(10, 1)], item_gap=2) # a buffer with room
as_images.append_content(items, item_gap=5, element_gap=2)
as_pixels.append_content([np.asarray(i.convert("RGB")) for i in items],
item_gap=5, element_gap=2)
assert np.array_equal(as_images.cached_array, as_pixels.cached_array)
assert as_images.total_scroll_width == as_pixels.total_scroll_width
def test_reference_paste_layout_is_unchanged():
"""Gaps black, short items padded below, tall ones cut: what paste did."""
items = [Image.new("RGB", (20, H - 6), (200, 40, 40)),
Image.new("RGBA", (15, H + 6), (40, 200, 40, 9))]
helper = _strip()
before = helper.cached_array.copy()
helper.append_content(items, item_gap=5, element_gap=2)
addition = Image.new("RGB", (5 + 20 + 2 + 5 + 15 + 2, H))
addition.paste(items[0], (5, 0))
addition.paste(items[1], (5 + 20 + 2 + 5, 0))
expected = np.concatenate((before, np.asarray(addition)), axis=1)
assert np.array_equal(helper.cached_array, expected)
def test_a_first_build_from_pixels_matches_one_from_images():
items = [_row(30, 3), _row(40, 4)]
a, b = ScrollHelper(W, H), ScrollHelper(W, H)
a.append_content(items, item_gap=4)
b.append_content([np.asarray(i) for i in items], item_gap=4)
assert np.array_equal(a.cached_array, b.cached_array)
def test_gaps_and_short_rows_are_black_whatever_the_spare_room_held():
"""Only uncovered columns are blanked, so they must all be."""
items = [Image.new("RGB", (20, H - 6), (200, 40, 40)), _row(30, 5)]
clean, dirty = _strip(), _strip()
for helper in (clean, dirty):
helper.append_content([_row(10, 1)], item_gap=2) # a buffer with room
end = dirty._strip_start + dirty.cached_array.shape[1]
dirty._strip_buffer[:, end:] = 255 # what an old strip left
for helper in (clean, dirty):
helper.append_content([np.asarray(i) for i in items], item_gap=5, element_gap=3)
assert np.array_equal(dirty.cached_array, clean.cached_array)
assert not dirty.cached_array[:, -3:].any() # the trailing element gap