mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-05 06:45:09 +00:00
feat(vegas): live elements -- a plugin API for content that changes while it scrolls
Vegas bakes each plugin's pictures into one strip, so a card already on its way across the panel keeps what it showed when it was drawn. This adds the API and bookkeeping for content that can be updated in place; the worker that redraws and swaps it follows separately. No shipped plugin implements the hook yet, so nothing changes for users. Plugin API (core 3.8.0), all no-ops by default: - BasePlugin.get_vegas_elements() -> [VegasElement(key, image, version, live, refresh_hz)]: named, fixed-width pieces of Vegas content. - BasePlugin.redraw_vegas_element(key, width, height, at): a lock-free redraw for content that changes with time. - BasePlugin.notify_vegas_data_changed(): data that lands outside update(). - src/plugin_system/vegas_elements.py (VegasElement, re-exported from base_plugin). Core: - PluginAdapter asks a plugin that implements the hook for elements on the background fetch only (under its lock, on its own canvas); every other path keeps get_vegas_content(). Live elements are pinned (padded with content_padding, never trimmed), tagged with their key, digest and data epoch in Image.info so the existing cache and group plumbing carry them unchanged, and untagged if a width budget crops them. - RenderPipeline records where each live element lands (ElementRecord), in absolute strip columns a trim does not move; the block-start arithmetic is shared with the STATIC markers. - PluginManager update listeners (add/remove_update_listener, notify_data_changed): told the moment update() completes, not at the next ~4s Vegas poll. The coordinator uses one to move each plugin's data epoch on. - vegas_scroll.live_refresh (kill switch), live_max_hz, live_min_interval, live_lead_screens; per-plugin core-owned vegas_live. Live elements are off under multi-display sync, in swap mode and with offscreen_prefetch off. - scripts/check_plugin.py checks the element contract (src/plugin_system/testing/vegas.py); test/fixtures/plugins/vegas-live-stub is a working example. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Enable the stub (test fixture only)."
|
||||
},
|
||||
"cards": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 32,
|
||||
"default": 6,
|
||||
"description": "How many keyed cards to return."
|
||||
},
|
||||
"card_width": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 512,
|
||||
"default": 0,
|
||||
"description": "Card width in px; 0 sizes cards from the render width."
|
||||
},
|
||||
"map_hz": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 30,
|
||||
"default": 4,
|
||||
"description": "refresh_hz of the full-width animated element; 0 leaves it out."
|
||||
},
|
||||
"dot_speed": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 200,
|
||||
"default": 20,
|
||||
"description": "How fast the animated element's dot moves, in px per second."
|
||||
}
|
||||
}
|
||||
}
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
"""
|
||||
Vegas live-element stub.
|
||||
|
||||
A fixture, not a product: it exercises every part of the live-element contract
|
||||
(src/plugin_system/vegas_elements.py) with content whose changes are easy to
|
||||
see and to assert on, and nothing else -- no fonts, no network.
|
||||
|
||||
- ``card:<n>`` -- fixed-width cards. Each draws the bits of ``tick + n`` as
|
||||
lit bars, so every update() changes every card's pixels but never its width.
|
||||
- ``sep`` -- a separator, ``live=False``: placed and trimmed like plain content.
|
||||
- ``map`` -- one full-render-width element with ``refresh_hz``: a dot that
|
||||
moves across it with time, drawn by redraw_vegas_element() from state
|
||||
published in a single attribute store, so it is safe to call without the
|
||||
plugin's lock.
|
||||
|
||||
update() only advances the tick. display() draws the tick's bars full screen
|
||||
so the plugin also passes the ordinary rendering harness.
|
||||
"""
|
||||
|
||||
import time
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from src.plugin_system.base_plugin import BasePlugin
|
||||
|
||||
try:
|
||||
from src.plugin_system.vegas_elements import VegasElement
|
||||
except ImportError: # core older than 3.8.0: the hooks are never called
|
||||
VegasElement = None
|
||||
|
||||
_COLOURS = [(255, 64, 64), (64, 255, 64), (64, 128, 255), (255, 200, 0),
|
||||
(255, 64, 255), (0, 220, 220)]
|
||||
|
||||
|
||||
class VegasLiveStub(BasePlugin):
|
||||
"""Keyed cards, a separator and an animated element for the Vegas ticker."""
|
||||
|
||||
def __init__(self, plugin_id, config, display_manager, cache_manager, plugin_manager):
|
||||
super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager)
|
||||
self.tick = 0
|
||||
# Everything the lock-free redraw reads, published in one store.
|
||||
self._snapshot: Tuple[int, float] = (0, time.monotonic())
|
||||
|
||||
# -- data -------------------------------------------------------------
|
||||
|
||||
def update(self) -> None:
|
||||
self.tick += 1
|
||||
self._snapshot = (self.tick, time.monotonic())
|
||||
|
||||
# -- drawing ----------------------------------------------------------
|
||||
|
||||
def _bars(self, image: Image.Image, value: int, colour, box) -> None:
|
||||
x0, y0, x1, y1 = box
|
||||
draw = ImageDraw.Draw(image)
|
||||
draw.rectangle([x0, y0, x1, y1], outline=colour)
|
||||
bits = 8
|
||||
span = max(1, (x1 - x0 - 2) // bits)
|
||||
for bit in range(bits):
|
||||
if value >> bit & 1:
|
||||
left = x0 + 1 + bit * span
|
||||
draw.rectangle([left, y0 + 2, left + max(0, span - 2), y1 - 2],
|
||||
fill=colour)
|
||||
|
||||
def _card_width(self) -> int:
|
||||
configured = int(self.config.get('card_width', 0) or 0)
|
||||
if configured > 0:
|
||||
return configured
|
||||
return max(24, min(64, self.get_vegas_render_width() // 4))
|
||||
|
||||
def _card(self, index: int, tick: int) -> Image.Image:
|
||||
width, height = self._card_width(), self.display_manager.height
|
||||
image = Image.new('RGB', (width, height), (0, 0, 0))
|
||||
self._bars(image, tick + index, _COLOURS[index % len(_COLOURS)],
|
||||
(0, 0, width - 1, height - 1))
|
||||
return image
|
||||
|
||||
def _map(self, width: int, height: int, at: float) -> Image.Image:
|
||||
tick, _published = self._snapshot
|
||||
image = Image.new('RGB', (width, height), (0, 0, 16))
|
||||
draw = ImageDraw.Draw(image)
|
||||
draw.rectangle([0, 0, width - 1, height - 1], outline=(40, 40, 80))
|
||||
speed = float(self.config.get('dot_speed', 20) or 0)
|
||||
x = int(at * speed) % max(1, width - 4) + 2
|
||||
y = 2 + tick % max(1, height - 4)
|
||||
draw.rectangle([x - 1, y - 1, x + 1, y + 1], fill=(255, 255, 255))
|
||||
return image
|
||||
|
||||
def _dot_column(self, width: int, at: float) -> int:
|
||||
speed = float(self.config.get('dot_speed', 20) or 0)
|
||||
return int(at * speed) % max(1, width - 4) + 2
|
||||
|
||||
def display(self, force_clear: bool = False) -> bool:
|
||||
width, height = self.display_manager.width, self.display_manager.height
|
||||
self.display_manager.clear()
|
||||
self._bars(self.display_manager.image, self.tick, _COLOURS[0],
|
||||
(0, 0, width - 1, height - 1))
|
||||
self.display_manager.update_display()
|
||||
return True
|
||||
|
||||
# -- Vegas ------------------------------------------------------------
|
||||
|
||||
def get_vegas_content(self) -> Optional[List[Image.Image]]:
|
||||
cards = int(self.config.get('cards', 6))
|
||||
return [self._card(i, self.tick) for i in range(cards)] or None
|
||||
|
||||
def get_vegas_elements(self):
|
||||
if VegasElement is None:
|
||||
return None
|
||||
tick = self.tick
|
||||
elements = []
|
||||
for i in range(int(self.config.get('cards', 6))):
|
||||
elements.append(VegasElement(
|
||||
key=f"card:{i}", image=self._card(i, tick),
|
||||
version=(tick, i, self._card_width())))
|
||||
if i == 0:
|
||||
separator = Image.new('RGB', (4, self.display_manager.height), (0, 0, 0))
|
||||
ImageDraw.Draw(separator).rectangle(
|
||||
[1, 0, 2, self.display_manager.height - 1], fill=(90, 90, 90))
|
||||
elements.append(VegasElement(key="sep", image=separator, live=False))
|
||||
hz = float(self.config.get('map_hz', 4) or 0)
|
||||
if hz > 0:
|
||||
width, height = self.get_vegas_render_width(), self.display_manager.height
|
||||
now = time.monotonic()
|
||||
elements.append(VegasElement(
|
||||
key="map", image=self._map(width, height, now),
|
||||
version=(tick, width, self._dot_column(width, now)),
|
||||
refresh_hz=hz))
|
||||
return elements
|
||||
|
||||
def redraw_vegas_element(self, key, width, height, at):
|
||||
if key != "map":
|
||||
return None
|
||||
return self._map(width, height, at)
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"id": "vegas-live-stub",
|
||||
"name": "Vegas Live Stub",
|
||||
"version": "1.0.0",
|
||||
"description": "Test fixture for live Vegas elements: keyed cards whose content changes on every update, a separator, and a full-width element that animates with time. Drives the live-element tests and the hardware soaks. Not installable from the store and never shipped to devices.",
|
||||
"author": "LEDMatrix",
|
||||
"entry_point": "manager.py",
|
||||
"class_name": "VegasLiveStub",
|
||||
"display_modes": ["vegas-live-stub"],
|
||||
"update_interval": 2,
|
||||
"min_ledmatrix_version": "2.0.0",
|
||||
"compatible_versions": [">=2.0.0"]
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
"""The offline live-element checks (src/plugin_system/testing/vegas.py).
|
||||
|
||||
Run against the stub fixture plugin, which honours the contract, and against
|
||||
small broken plugins, each breaking one clause of it.
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from src.plugin_system.base_plugin import BasePlugin # noqa: E402
|
||||
from src.plugin_system.testing.harness import _instantiate # noqa: E402
|
||||
from src.plugin_system.testing.loading import build_full_config, load_harness_spec, load_manifest # noqa: E402
|
||||
from src.plugin_system.testing.vegas import ( # noqa: E402
|
||||
check_vegas_elements, implements_vegas_elements, render_vegas_elements,
|
||||
)
|
||||
from src.plugin_system.testing.visual_display_manager import VisualTestDisplayManager # noqa: E402
|
||||
from src.plugin_system.vegas_elements import VegasElement # noqa: E402
|
||||
|
||||
STUB = Path(__file__).resolve().parent / "fixtures" / "plugins" / "vegas-live-stub"
|
||||
W, H = 192, 48
|
||||
|
||||
|
||||
def _stub(**config):
|
||||
dm = VisualTestDisplayManager(width=W, height=H)
|
||||
full = {**build_full_config(STUB, load_harness_spec(STUB), {}), **config}
|
||||
plugin = _instantiate("vegas-live-stub", load_manifest(STUB), STUB, full, {}, dm)
|
||||
return plugin, dm
|
||||
|
||||
|
||||
class _Broken(BasePlugin):
|
||||
"""A plugin whose get_vegas_elements returns whatever it is given."""
|
||||
|
||||
def __init__(self, dm, result, redraw=None):
|
||||
self.plugin_id = "broken"
|
||||
self.config = {}
|
||||
self.display_manager = dm
|
||||
self.plugin_manager = None
|
||||
self._result = result
|
||||
self._redraw = redraw
|
||||
|
||||
def update(self):
|
||||
pass
|
||||
|
||||
def display(self, force_clear=False):
|
||||
pass
|
||||
|
||||
def get_vegas_elements(self):
|
||||
return self._result() if callable(self._result) else self._result
|
||||
|
||||
def redraw_vegas_element(self, key, width, height, at):
|
||||
return self._redraw(width, height) if self._redraw else None
|
||||
|
||||
|
||||
def _img(w, h=H):
|
||||
return Image.new("RGB", (w, h), (255, 0, 0))
|
||||
|
||||
|
||||
def test_the_stub_passes_every_check():
|
||||
plugin, dm = _stub()
|
||||
report = check_vegas_elements(plugin, dm)
|
||||
assert report.implemented and report.ok, report.errors
|
||||
assert report.elements == 8 and report.live == 7
|
||||
assert not report.warnings, report.warnings
|
||||
|
||||
|
||||
def test_the_stub_renders_at_the_width_it_is_given():
|
||||
plugin, dm = _stub()
|
||||
elements = render_vegas_elements(plugin, dm, width=96)
|
||||
by_key = {e.key: e for e in elements}
|
||||
assert by_key["map"].image.size == (96, H)
|
||||
assert by_key["card:0"].image.width == 24
|
||||
assert plugin.get_vegas_render_width() == W # restored afterwards
|
||||
|
||||
|
||||
def test_the_stubs_cards_change_on_update_but_keep_their_width():
|
||||
plugin, dm = _stub()
|
||||
before = {e.key: e for e in render_vegas_elements(plugin, dm)}
|
||||
plugin.update()
|
||||
after = {e.key: e for e in render_vegas_elements(plugin, dm)}
|
||||
for key in ("card:0", "card:3"):
|
||||
assert after[key].version != before[key].version
|
||||
assert after[key].image.size == before[key].image.size
|
||||
assert after[key].image.tobytes() != before[key].image.tobytes()
|
||||
|
||||
|
||||
def test_a_plugin_without_the_hook_is_not_checked():
|
||||
class Plain(BasePlugin):
|
||||
def update(self):
|
||||
pass
|
||||
|
||||
def display(self, force_clear=False):
|
||||
pass
|
||||
|
||||
plugin = Plain.__new__(Plain)
|
||||
dm = VisualTestDisplayManager(width=W, height=H)
|
||||
report = check_vegas_elements(plugin, dm)
|
||||
assert not implements_vegas_elements(plugin)
|
||||
assert not report.implemented and report.ok
|
||||
|
||||
|
||||
def _errors(result, redraw=None):
|
||||
dm = VisualTestDisplayManager(width=W, height=H)
|
||||
return check_vegas_elements(_Broken(dm, result, redraw), dm)
|
||||
|
||||
|
||||
def test_each_broken_clause_is_an_error():
|
||||
assert "expected a list" in _errors("x").errors[0]
|
||||
assert "not a VegasElement" in _errors([object()]).errors[0]
|
||||
assert "twice" in _errors([VegasElement("k", _img(10)),
|
||||
VegasElement("k", _img(10))]).errors[0]
|
||||
assert "tall" in _errors([VegasElement("k", _img(10, H + 1))]).errors[0]
|
||||
assert "no key" in _errors([VegasElement("", _img(10))]).errors[0]
|
||||
|
||||
|
||||
def test_a_width_that_changes_with_nothing_new_is_an_error():
|
||||
widths = iter([10, 12, 10, 10])
|
||||
report = _errors(lambda: [VegasElement("k", _img(next(widths)))])
|
||||
assert any("changed width" in e for e in report.errors)
|
||||
|
||||
|
||||
def test_a_redraw_of_the_wrong_size_is_an_error():
|
||||
report = _errors([VegasElement("m", _img(40), refresh_hz=2)],
|
||||
redraw=lambda w, h: _img(w + 1, h))
|
||||
assert any("asked for 40x48" in e for e in report.errors)
|
||||
|
||||
|
||||
def test_animation_without_a_redraw_is_a_warning():
|
||||
dm = VisualTestDisplayManager(width=W, height=H)
|
||||
|
||||
class NoRedraw(_Broken):
|
||||
redraw_vegas_element = BasePlugin.redraw_vegas_element
|
||||
|
||||
report = check_vegas_elements(
|
||||
NoRedraw(dm, [VegasElement("m", _img(40), refresh_hz=2)]), dm)
|
||||
assert report.ok
|
||||
assert any("not implemented" in w for w in report.warnings)
|
||||
|
||||
|
||||
def test_none_means_legacy_content_and_is_only_a_warning():
|
||||
report = _errors(None)
|
||||
assert report.ok and "get_vegas_content" in report.warnings[0]
|
||||
|
||||
|
||||
def test_a_refresh_rate_that_is_not_a_number_is_an_error_not_a_crash():
|
||||
report = _errors([VegasElement("m", _img(40), refresh_hz="fast")])
|
||||
assert any("not a number" in e for e in report.errors)
|
||||
# None is what a plugin passing the dataclass default through gets.
|
||||
assert _errors([VegasElement("m", _img(40), refresh_hz=None)]).ok
|
||||
|
||||
|
||||
def test_an_empty_image_is_an_error():
|
||||
assert any("empty" in e for e in _errors([VegasElement("k", _img(0))]).errors)
|
||||
|
||||
|
||||
def test_a_second_call_that_breaks_the_contract_is_an_error_not_a_crash():
|
||||
answers = iter([[VegasElement("k", _img(10))], "x", "x", "x"])
|
||||
report = _errors(lambda: next(answers))
|
||||
assert any("second call" in e for e in report.errors)
|
||||
answers = iter([[VegasElement("k", _img(10))], [VegasElement("k", "not an image")],
|
||||
[], []])
|
||||
report = _errors(lambda: next(answers))
|
||||
assert any("disappeared" in e for e in report.errors)
|
||||
|
||||
|
||||
def test_check_plugin_reports_a_failing_element_check_and_carries_on(monkeypatch):
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
|
||||
import check_plugin
|
||||
|
||||
def boom(*args, **kwargs):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
monkeypatch.setattr(check_plugin, "check_plugin_vegas_elements", boom)
|
||||
results = check_plugin.check_one(
|
||||
"vegas-live-stub", [str(STUB.parent)], [(W, H)], {}, {}, False, None,
|
||||
False, None, None)
|
||||
vegas = [r for r in results if r.mode == "vegas elements"]
|
||||
assert len(vegas) == 1 and "boom" in vegas[0].error
|
||||
@@ -0,0 +1,143 @@
|
||||
"""PluginManager's update listeners: told the moment a plugin's data may have changed.
|
||||
|
||||
Vegas live elements redraw a plugin when its update() completes. Before these
|
||||
listeners the only signal was a set drained by the Vegas tick every ~4s; the
|
||||
listener hears it at once. It is called on the update worker with the
|
||||
plugin's lock still held, which is why a listener may only hand off.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
from src.plugin_system.base_plugin import BasePlugin # noqa: E402
|
||||
from src.plugin_system.plugin_manager import PluginManager # noqa: E402
|
||||
from src.plugin_system.plugin_state import PluginState # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pm(tmp_path):
|
||||
manager = PluginManager(plugins_dir=str(tmp_path), config_manager=None,
|
||||
display_manager=None, cache_manager=None)
|
||||
yield manager
|
||||
manager.stop_update_worker()
|
||||
|
||||
|
||||
class _Plugin:
|
||||
def __init__(self, fail=False):
|
||||
self.enabled = True
|
||||
self.fail = fail
|
||||
self.updates = 0
|
||||
|
||||
def update(self):
|
||||
self.updates += 1
|
||||
if self.fail:
|
||||
raise RuntimeError("no data")
|
||||
|
||||
def display(self, force_clear=False):
|
||||
return True
|
||||
|
||||
|
||||
def _install(pm, plugin, plugin_id="p"):
|
||||
pm.plugins[plugin_id] = plugin
|
||||
pm._update_interval_cache[plugin_id] = 0.01
|
||||
pm.state_manager.set_state(plugin_id, PluginState.ENABLED)
|
||||
return plugin_id
|
||||
|
||||
|
||||
def _wait(predicate, timeout=5.0):
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if predicate():
|
||||
return True
|
||||
time.sleep(0.01)
|
||||
return False
|
||||
|
||||
|
||||
def test_a_completed_update_calls_the_listener_with_the_lock_held(pm):
|
||||
plugin_id = _install(pm, _Plugin())
|
||||
heard = []
|
||||
|
||||
def listener(pid):
|
||||
heard.append((pid, pm.get_plugin_lock(pid).locked()))
|
||||
|
||||
pm.add_update_listener(listener)
|
||||
pm.run_scheduled_updates()
|
||||
assert _wait(lambda: heard)
|
||||
assert heard[0] == (plugin_id, True)
|
||||
# The poll still sees it too: the set is filled before listeners run.
|
||||
assert plugin_id in pm.drain_completed_updates()
|
||||
|
||||
|
||||
def test_a_failed_update_is_not_reported(pm):
|
||||
_install(pm, _Plugin(fail=True))
|
||||
heard = []
|
||||
pm.add_update_listener(heard.append)
|
||||
pm.run_scheduled_updates()
|
||||
assert _wait(lambda: pm.plugins["p"].updates == 1)
|
||||
time.sleep(0.1)
|
||||
assert heard == []
|
||||
|
||||
|
||||
def test_a_listener_that_raises_does_not_stop_the_others(pm):
|
||||
heard = []
|
||||
|
||||
def broken(_pid):
|
||||
raise ValueError("listener bug")
|
||||
|
||||
pm.add_update_listener(broken)
|
||||
pm.add_update_listener(heard.append)
|
||||
pm._note_update_completed("p") # must not raise
|
||||
assert heard == ["p"]
|
||||
assert "p" in pm.drain_completed_updates()
|
||||
|
||||
|
||||
def test_adding_twice_calls_once_and_removing_stops_it(pm):
|
||||
heard = []
|
||||
pm.add_update_listener(heard.append)
|
||||
pm.add_update_listener(heard.append)
|
||||
pm.notify_data_changed("x")
|
||||
assert heard == ["x"]
|
||||
pm.remove_update_listener(heard.append)
|
||||
pm.notify_data_changed("y")
|
||||
assert heard == ["x"]
|
||||
|
||||
|
||||
def test_notify_data_changed_reaches_listeners_but_not_the_poll(pm):
|
||||
heard = []
|
||||
pm.add_update_listener(heard.append)
|
||||
pm.notify_data_changed("q")
|
||||
assert heard == ["q"]
|
||||
assert pm.drain_completed_updates() == []
|
||||
|
||||
|
||||
def test_a_plugin_can_report_data_that_arrived_on_its_own_thread(pm):
|
||||
class Pushed(BasePlugin):
|
||||
def update(self):
|
||||
pass
|
||||
|
||||
def display(self, force_clear=False):
|
||||
pass
|
||||
|
||||
plugin = Pushed.__new__(Pushed)
|
||||
plugin.plugin_id = "pushed"
|
||||
plugin.plugin_manager = pm
|
||||
heard = []
|
||||
pm.add_update_listener(heard.append)
|
||||
thread = threading.Thread(target=plugin.notify_vegas_data_changed)
|
||||
thread.start()
|
||||
thread.join()
|
||||
assert heard == ["pushed"]
|
||||
|
||||
|
||||
def test_a_bare_manager_has_no_listeners_to_call():
|
||||
manager = PluginManager.__new__(PluginManager)
|
||||
manager._completed_updates = set()
|
||||
manager._completed_updates_lock = threading.Lock()
|
||||
manager._note_update_completed("p")
|
||||
manager.add_update_listener(lambda pid: None)
|
||||
manager.remove_update_listener(lambda pid: None)
|
||||
@@ -0,0 +1,331 @@
|
||||
"""Live Vegas elements: the plugin API and how the adapter fetches them.
|
||||
|
||||
A plugin opts in by implementing get_vegas_elements(); the adapter then asks
|
||||
for elements instead of pictures, but only where that is safe (the background
|
||||
fetch, under the plugin's lock, with live elements switched on) and falls back
|
||||
to get_vegas_content() everywhere else. What makes an element live rides in
|
||||
its image's ``info`` so it survives the adapter's cache and the pipeline's
|
||||
join unchanged. These tests pin all of that.
|
||||
"""
|
||||
import dataclasses
|
||||
import sys
|
||||
import threading
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from src.plugin_system.base_plugin import BasePlugin # noqa: E402
|
||||
from src.plugin_system.vegas_elements import VegasElement # noqa: E402
|
||||
from src.vegas_mode import elements # noqa: E402
|
||||
from src.vegas_mode.config import VegasModeConfig # noqa: E402
|
||||
from src.vegas_mode.elements import ElementMeta, LiveEpochs # noqa: E402
|
||||
from src.vegas_mode.plugin_adapter import PluginAdapter # noqa: E402
|
||||
|
||||
W, H = 128, 32
|
||||
|
||||
|
||||
def _card(width=40, colour=(255, 0, 0), height=H):
|
||||
image = Image.new("RGB", (width, height), (0, 0, 0))
|
||||
ImageDraw.Draw(image).rectangle([0, 0, width - 1, height - 1], outline=colour)
|
||||
return image
|
||||
|
||||
|
||||
class _DM:
|
||||
"""A display manager with the per-thread canvas Vegas renders on."""
|
||||
|
||||
def __init__(self):
|
||||
self.width, self.height = W, H
|
||||
self.image = Image.new("RGB", (W, H))
|
||||
self.offscreen_widths = []
|
||||
|
||||
@contextmanager
|
||||
def offscreen(self, width=None, height=None):
|
||||
self.offscreen_widths.append(width)
|
||||
yield SimpleNamespace(image=Image.new("RGB", (width or W, H)))
|
||||
|
||||
|
||||
class _Plugin(BasePlugin):
|
||||
"""A BasePlugin with both the legacy and the element hooks."""
|
||||
|
||||
def __init__(self, elements_result=None, config=None):
|
||||
self.plugin_id = "p"
|
||||
self.config = config or {}
|
||||
self.elements_result = elements_result
|
||||
self.element_calls = 0
|
||||
self.content_calls = 0
|
||||
self.render_widths = []
|
||||
self.plugin_manager = None
|
||||
|
||||
def update(self):
|
||||
pass
|
||||
|
||||
def display(self, force_clear=False):
|
||||
pass
|
||||
|
||||
def get_vegas_content(self):
|
||||
self.content_calls += 1
|
||||
return [_card(40, (0, 0, 255))]
|
||||
|
||||
def get_vegas_elements(self):
|
||||
self.element_calls += 1
|
||||
self.render_widths.append(self.get_vegas_render_width())
|
||||
result = self.elements_result
|
||||
if isinstance(result, Exception):
|
||||
raise result
|
||||
return result() if callable(result) else result
|
||||
|
||||
|
||||
class _Legacy(_Plugin):
|
||||
get_vegas_elements = BasePlugin.get_vegas_elements
|
||||
|
||||
|
||||
def _adapter(**cfg):
|
||||
lock = threading.Lock()
|
||||
pm = SimpleNamespace(get_plugin_lock=lambda pid: lock)
|
||||
adapter = PluginAdapter(_DM(), VegasModeConfig(**cfg), plugin_manager=pm)
|
||||
adapter.live_elements_enabled = True
|
||||
adapter.live_epochs = LiveEpochs()
|
||||
return adapter
|
||||
|
||||
|
||||
def _elements():
|
||||
return [VegasElement("card:a", _card(40, (255, 0, 0)), version=1),
|
||||
VegasElement("sep", _card(10, (90, 90, 90)), live=False),
|
||||
VegasElement("card:b", _card(40, (0, 255, 0)), version=2)]
|
||||
|
||||
|
||||
# -- the type and the tag -----------------------------------------------------
|
||||
|
||||
|
||||
def test_element_defaults():
|
||||
element = VegasElement("k", _card())
|
||||
assert element.live and element.refresh_hz == 0 and element.version is None
|
||||
with pytest.raises(dataclasses.FrozenInstanceError):
|
||||
element.key = "other"
|
||||
|
||||
|
||||
def test_a_tag_survives_what_the_plumbing_does_to_an_image():
|
||||
meta = ElementMeta("p", "k", 3, ((H, 10, 3), 1), 0.0, 0.0)
|
||||
image = elements.tag(_card(), meta)
|
||||
for derived in (image.copy(), image.crop((0, 0, 10, H)), image.convert("RGB"),
|
||||
image.resize((20, H))):
|
||||
assert elements.meta_of(derived) == meta
|
||||
assert elements.meta_of(elements.untag(image.copy())) is None
|
||||
assert elements.meta_of(_card()) is None
|
||||
assert elements.meta_of(object()) is None
|
||||
|
||||
|
||||
def test_pinning_pads_with_black_and_freezes_the_pixels():
|
||||
image, array = elements.pin_element(_card(20), 8)
|
||||
assert image.size == (36, H)
|
||||
assert array.shape == (H, 36, 3)
|
||||
assert not array.flags.writeable
|
||||
assert not array[:, :8].any() and not array[:, -8:].any()
|
||||
assert np.array_equal(array[:, 8:28], np.asarray(_card(20)))
|
||||
|
||||
|
||||
def test_the_digest_sees_a_one_pixel_change():
|
||||
_, a = elements.pin_element(_card(20), 0)
|
||||
b = np.array(a)
|
||||
b[5, 5, 0] ^= 1
|
||||
assert elements.pixel_digest(a) != elements.pixel_digest(b)
|
||||
assert elements.pixel_digest(a) == elements.pixel_digest(np.array(a))
|
||||
|
||||
|
||||
def test_epochs_move_on_per_plugin_and_never_repeat():
|
||||
epochs = LiveEpochs()
|
||||
assert epochs.get("a") == 0
|
||||
first = epochs.bump("a")
|
||||
second = epochs.bump("b")
|
||||
assert second > first and epochs.get("a") == first
|
||||
assert epochs.bump("a") > second
|
||||
|
||||
|
||||
# -- BasePlugin ---------------------------------------------------------------
|
||||
|
||||
|
||||
def test_the_base_hooks_do_nothing():
|
||||
plugin = _Legacy()
|
||||
assert plugin.get_vegas_elements() is None
|
||||
assert plugin.redraw_vegas_element("k", 10, H, 0.0) is None
|
||||
|
||||
|
||||
def test_notify_vegas_data_changed_reaches_the_plugin_manager():
|
||||
plugin = _Legacy()
|
||||
plugin.plugin_manager = MagicMock()
|
||||
plugin.notify_vegas_data_changed()
|
||||
plugin.plugin_manager.notify_data_changed.assert_called_once_with("p")
|
||||
plugin.plugin_manager = None
|
||||
plugin.notify_vegas_data_changed() # no manager: nothing to do
|
||||
|
||||
|
||||
# -- the adapter's keyed path --------------------------------------------------
|
||||
|
||||
|
||||
def test_the_background_fetch_asks_for_elements():
|
||||
adapter = _adapter()
|
||||
adapter.live_epochs.bump("p")
|
||||
plugin = _Plugin(_elements)
|
||||
images = adapter.get_content(plugin, "p", offscreen_only=True)
|
||||
assert plugin.element_calls == 1 and plugin.content_calls == 0
|
||||
metas = [elements.meta_of(img) for img in images]
|
||||
assert [m.key if m else None for m in metas] == ["card:a", None, "card:b"]
|
||||
assert metas[0].epoch == adapter.live_epochs.get("p")
|
||||
assert metas[0].version == 1
|
||||
|
||||
|
||||
def test_live_elements_are_pinned_not_trimmed():
|
||||
adapter = _adapter(content_padding=8)
|
||||
plugin = _Plugin(_elements)
|
||||
images = adapter.get_content(plugin, "p", offscreen_only=True)
|
||||
live = [img for img in images if elements.meta_of(img)]
|
||||
assert all(img.width == 40 + 16 for img in live)
|
||||
# The plain separator is trimmed as always: drawn to its edges, it keeps
|
||||
# its width (trimming never widens an image).
|
||||
separator = images[1]
|
||||
assert elements.meta_of(separator) is None
|
||||
assert separator.width == 10
|
||||
|
||||
|
||||
def test_an_elements_digest_matches_its_pixels():
|
||||
adapter = _adapter()
|
||||
images = adapter.get_content(_Plugin(_elements), "p", offscreen_only=True)
|
||||
meta = elements.meta_of(images[0])
|
||||
assert meta.digest == elements.pixel_digest(np.asarray(images[0]))
|
||||
|
||||
|
||||
def test_elements_render_on_a_canvas_of_their_own_at_the_render_width():
|
||||
adapter = _adapter(render_width_pct=50)
|
||||
plugin = _Plugin(_elements)
|
||||
adapter.get_content(plugin, "p", offscreen_only=True)
|
||||
assert plugin.render_widths == [W // 2]
|
||||
assert adapter.display_manager.offscreen_widths == [W // 2]
|
||||
assert plugin.get_vegas_render_width() == W # restored afterwards
|
||||
|
||||
|
||||
@pytest.mark.parametrize("why", ["disabled", "render thread", "restricted",
|
||||
"plugin opted out", "legacy plugin"])
|
||||
def test_everywhere_else_the_legacy_content_is_used(why):
|
||||
cfg = {"offscreen_prefetch": False} if why == "restricted" else {}
|
||||
adapter = _adapter(**cfg)
|
||||
plugin_cls = _Legacy if why == "legacy plugin" else _Plugin
|
||||
plugin = plugin_cls(_elements, config={"vegas_live": False}
|
||||
if why == "plugin opted out" else None)
|
||||
if why == "disabled":
|
||||
adapter.live_elements_enabled = False
|
||||
images = adapter.get_content(plugin, "p", offscreen_only=(why != "render thread"))
|
||||
assert plugin.element_calls == 0
|
||||
if why != "restricted":
|
||||
assert plugin.content_calls == 1
|
||||
assert all(elements.meta_of(img) is None for img in images)
|
||||
|
||||
|
||||
def test_a_mock_plugin_is_never_asked_for_elements():
|
||||
adapter = _adapter()
|
||||
plugin = MagicMock()
|
||||
plugin.config = {}
|
||||
plugin.get_vegas_content.return_value = [_card()]
|
||||
adapter.get_content(plugin, "p", offscreen_only=True)
|
||||
plugin.get_vegas_elements.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("result", [None, RuntimeError("boom"), "nonsense",
|
||||
[], [object()]])
|
||||
def test_a_broken_or_empty_answer_falls_back_to_legacy_content(result):
|
||||
adapter = _adapter()
|
||||
plugin = _Plugin(result)
|
||||
images = adapter.get_content(plugin, "p", offscreen_only=True)
|
||||
assert plugin.content_calls == 1
|
||||
assert images and all(elements.meta_of(img) is None for img in images)
|
||||
|
||||
|
||||
def test_duplicate_keys_keep_the_first():
|
||||
adapter = _adapter()
|
||||
plugin = _Plugin(lambda: [VegasElement("k", _card(40)),
|
||||
VegasElement("k", _card(30))])
|
||||
images = adapter.get_content(plugin, "p", offscreen_only=True)
|
||||
assert len(images) == 1 and images[0].width == 40 + 16
|
||||
|
||||
|
||||
def test_elements_are_brought_to_the_display_height_and_rgb():
|
||||
adapter = _adapter()
|
||||
plugin = _Plugin(lambda: [VegasElement("k", _card(40, height=H * 2).convert("RGBA"))])
|
||||
image = adapter.get_content(plugin, "p", offscreen_only=True)[0]
|
||||
assert image.mode == "RGB" and image.height == H
|
||||
assert elements.meta_of(image) is not None
|
||||
|
||||
|
||||
def test_a_keyed_fetch_ignores_legacy_content_in_the_cache():
|
||||
# The first compose runs on the render thread and caches legacy content;
|
||||
# the first background fetch after it must still ask for elements.
|
||||
adapter = _adapter()
|
||||
plugin = _Plugin(_elements)
|
||||
adapter.get_content(plugin, "p", offscreen_only=False)
|
||||
assert plugin.content_calls == 1
|
||||
images = adapter.get_content(plugin, "p", offscreen_only=True)
|
||||
assert plugin.element_calls == 1
|
||||
assert elements.meta_of(images[0]) is not None
|
||||
|
||||
|
||||
def test_keyed_content_is_cached_with_its_tags():
|
||||
adapter = _adapter()
|
||||
plugin = _Plugin(_elements)
|
||||
adapter.get_content(plugin, "p", offscreen_only=True)
|
||||
again = adapter.get_content(plugin, "p", offscreen_only=True)
|
||||
assert plugin.element_calls == 1
|
||||
assert elements.meta_of(again[0]).key == "card:a"
|
||||
|
||||
|
||||
def test_an_element_cropped_to_a_width_budget_is_no_longer_live():
|
||||
adapter = _adapter(max_plugin_width_ratio=0.5)
|
||||
wide = Image.new("RGB", (400, H), (255, 255, 255))
|
||||
plugin = _Plugin(lambda: [VegasElement("map", wide)])
|
||||
image = adapter.get_content(plugin, "p", offscreen_only=True)[0]
|
||||
budget = adapter._width_budget(plugin, "p")
|
||||
assert budget // 2 <= image.width < 400
|
||||
# A real window of the element, not a cut in the middle of its padding.
|
||||
assert np.asarray(image).any()
|
||||
assert elements.meta_of(image) is None
|
||||
|
||||
|
||||
def test_an_element_whose_drawing_fits_the_budget_stays_live():
|
||||
adapter = _adapter(max_plugin_width_ratio=0.5)
|
||||
budget = adapter._width_budget(None, "p")
|
||||
# Over the budget only by the black padding pinned on either side.
|
||||
plugin = _Plugin(lambda: [VegasElement("card", _card(budget - 4))])
|
||||
image = adapter.get_content(plugin, "p", offscreen_only=True)[0]
|
||||
assert image.width > budget
|
||||
assert elements.meta_of(image).key == "card"
|
||||
|
||||
|
||||
def test_a_padded_solid_image_over_the_budget_is_cropped_not_slivered():
|
||||
# Legacy content has the same margins after trimming, and the same cut
|
||||
# used to land mid-margin.
|
||||
adapter = _adapter(max_plugin_width_ratio=0.5)
|
||||
budget = adapter._width_budget(None, "p")
|
||||
solid = Image.new("RGB", (budget * 3, H), (255, 255, 255))
|
||||
cropped = adapter._apply_width_budget(
|
||||
[_padded(solid, adapter.config.content_padding)], "p", None)
|
||||
assert budget // 2 <= cropped[0].width
|
||||
assert np.asarray(cropped[0]).any()
|
||||
|
||||
|
||||
def _padded(image, pad):
|
||||
out = Image.new("RGB", (image.width + 2 * pad, image.height))
|
||||
out.paste(image, (pad, 0))
|
||||
return out
|
||||
|
||||
|
||||
def test_rows_under_a_width_budget_keep_their_tags():
|
||||
adapter = _adapter(max_plugin_width_ratio=1.0)
|
||||
plugin = _Plugin(lambda: [VegasElement(f"c{i}", _card(40)) for i in range(6)])
|
||||
images = adapter.get_content(plugin, "p", offscreen_only=True)
|
||||
assert 0 < len(images) < 6
|
||||
assert all(elements.meta_of(img) is not None for img in images)
|
||||
@@ -0,0 +1,199 @@
|
||||
"""Where live elements land in the Vegas strip, and that the records stay true.
|
||||
|
||||
The pipeline records each live element's place (ElementRecord) as it builds
|
||||
the strip, in absolute columns that a trim never moves: the strip column is
|
||||
``abs_x - _strip_origin``. Everything a live update will do depends on these
|
||||
being exact, so the check here is the strongest one available -- after any
|
||||
sequence of compose, extend and trim, the strip's pixels at every record are
|
||||
the element's own pixels.
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from src.vegas_mode import elements # 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 = 128, 32
|
||||
|
||||
|
||||
def _live(pid, key, width, seed):
|
||||
"""A tagged, pinned element as the adapter hands it over."""
|
||||
rng = np.random.default_rng(seed)
|
||||
pixels = rng.integers(20, 255, (H, width, 3), dtype=np.uint8)
|
||||
image = Image.frombytes("RGB", (width, H), pixels.tobytes())
|
||||
pinned, array = elements.pin_element(image, 8)
|
||||
return elements.tag(pinned, ElementMeta(
|
||||
pid, key, 1, elements.pixel_digest(array), 0.0, 0.0))
|
||||
|
||||
|
||||
def _plain(width, colour=(200, 200, 200)):
|
||||
image = Image.new("RGB", (width, H), (0, 0, 0))
|
||||
ImageDraw.Draw(image).rectangle([0, 0, width - 1, H - 1], outline=colour)
|
||||
return image
|
||||
|
||||
|
||||
class _Stream:
|
||||
def __init__(self, groups):
|
||||
self.groups = groups
|
||||
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 get_active_plugin_ids(self):
|
||||
return [pid for pid, _ in 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 []
|
||||
|
||||
|
||||
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, **cfg):
|
||||
cfg.setdefault("continuous_scroll", True)
|
||||
return RenderPipeline(VegasModeConfig(**cfg), _DM(), _Stream(groups))
|
||||
|
||||
|
||||
def _assert_records_match(p, images_by_key):
|
||||
strip = p.scroll_helper.cached_array
|
||||
for record in p.live_records():
|
||||
x = record.abs_x - p._strip_origin
|
||||
expected = np.asarray(images_by_key[record.key])
|
||||
assert expected.shape[1] == record.width
|
||||
lo = max(0, x)
|
||||
got = strip[:, lo:x + record.width]
|
||||
assert np.array_equal(got, expected[:, lo - x:]), record.key
|
||||
|
||||
|
||||
def _group_images(groups):
|
||||
found = {}
|
||||
for group in groups:
|
||||
for _pid, images in group:
|
||||
for image in images:
|
||||
meta = elements.meta_of(image)
|
||||
if meta:
|
||||
found[meta.key] = image
|
||||
return found
|
||||
|
||||
|
||||
def test_a_compose_records_every_live_element_exactly():
|
||||
groups = [[("a", [_live("a", "a1", 40, 1), _plain(20), _live("a", "a2", 50, 2)]),
|
||||
("b", [_plain(60)]),
|
||||
("c", [_live("c", "c1", 70, 3)])]]
|
||||
p = _pipeline(groups, lead_in_width=10)
|
||||
assert p.compose_scroll_content()
|
||||
keys = [r.key for r in p.live_records()]
|
||||
assert keys == ["a1", "a2", "c1"]
|
||||
assert all(r.width == 16 + {"a1": 40, "a2": 50, "c1": 70}[r.key]
|
||||
for r in p.live_records())
|
||||
_assert_records_match(p, _group_images(groups))
|
||||
|
||||
|
||||
def test_extensions_and_trims_keep_every_record_true():
|
||||
# A strip long enough that the viewport never runs off its end (trimming
|
||||
# is refused while it wraps), advanced by less than each extension adds.
|
||||
groups = [[("a", [_live("a", "a1", 40, 1), _plain(500)])]]
|
||||
for n in range(12):
|
||||
groups.append([(f"p{n}", [_live(f"p{n}", f"k{n}", 30 + n, 10 + n),
|
||||
_plain(25)]),
|
||||
(f"q{n}", [_plain(45)])])
|
||||
p = _pipeline(groups, lead_in_width=0)
|
||||
assert p.compose_scroll_content()
|
||||
images = _group_images(groups)
|
||||
cuts = 0
|
||||
for _ in range(11):
|
||||
width_before = p.scroll_helper.cached_array.shape[1]
|
||||
p.scroll_helper.scroll_position += 150
|
||||
assert p.scroll_helper.scroll_position + W <= width_before
|
||||
position = p.scroll_helper.scroll_position
|
||||
assert p.extend_scroll_content()
|
||||
cut = int(position - p.scroll_helper.scroll_position)
|
||||
assert 0 <= cut <= width_before
|
||||
cuts += cut
|
||||
assert p._strip_origin == cuts
|
||||
_assert_records_match(p, images)
|
||||
# Records wholly trimmed away are forgotten, and only those.
|
||||
for record in p.live_records():
|
||||
assert record.abs_x + record.width > p._strip_origin
|
||||
assert set(p._record_by_seq) == {r.seq for r in p.live_records()}
|
||||
assert cuts > 0
|
||||
|
||||
|
||||
def test_the_first_extension_of_an_empty_strip_starts_at_zero():
|
||||
groups = [[], [("a", [_live("a", "a1", 40, 1)]), ("b", [_live("b", "b1", 20, 2)])]]
|
||||
p = _pipeline(groups)
|
||||
assert p.extend_scroll_content()
|
||||
first = p.live_records()[0]
|
||||
assert first.abs_x == 0 and p._strip_origin == 0
|
||||
_assert_records_match(p, _group_images(groups))
|
||||
|
||||
|
||||
def test_plain_content_is_not_recorded():
|
||||
p = _pipeline([[("a", [_plain(80)])], [("b", [_plain(90)])]])
|
||||
assert p.compose_scroll_content()
|
||||
assert p.extend_scroll_content()
|
||||
assert p.live_records() == ()
|
||||
|
||||
|
||||
def test_a_new_strip_starts_a_new_generation_with_no_records():
|
||||
groups = [[("a", [_live("a", "a1", 40, 1)])]]
|
||||
p = _pipeline(groups)
|
||||
assert p.compose_scroll_content()
|
||||
gen = p._strip_gen
|
||||
assert p.live_records()
|
||||
assert p.compose_scroll_content()
|
||||
assert p._strip_gen == gen + 1
|
||||
assert [r.key for r in p.live_records()] == ["a1"]
|
||||
p.reset()
|
||||
assert p._strip_gen == gen + 2
|
||||
assert p.live_records() == () and p._strip_origin == 0
|
||||
|
||||
|
||||
def test_record_numbers_are_never_reused():
|
||||
groups = [[("a", [_live("a", "a1", 40, 1)])]]
|
||||
p = _pipeline(groups)
|
||||
seen = set()
|
||||
for _ in range(3):
|
||||
assert p.compose_scroll_content()
|
||||
for record in p.live_records():
|
||||
assert record.seq not in seen
|
||||
seen.add(record.seq)
|
||||
|
||||
|
||||
def test_static_markers_still_land_where_they_did():
|
||||
# The marker arithmetic now shares _block_starts with the records.
|
||||
class Stream(_Stream):
|
||||
def is_static_plugin(self, pid):
|
||||
return pid == "pause"
|
||||
|
||||
groups = [[("a", [_plain(100)])],
|
||||
[("b", [_plain(60)]), ("pause", []), ("c", [_plain(70)])]]
|
||||
p = RenderPipeline(VegasModeConfig(continuous_scroll=True, lead_in_width=0,
|
||||
separator_width=32),
|
||||
_DM(), Stream(groups))
|
||||
assert p.compose_scroll_content()
|
||||
strip_end = p.scroll_helper.total_scroll_width
|
||||
assert p.extend_scroll_content()
|
||||
# b starts after one separator and ends 60 later; the pause follows b.
|
||||
assert p._static_markers == ((strip_end + 32 + 60, "pause"),)
|
||||
@@ -0,0 +1,137 @@
|
||||
"""When Vegas live elements are on, and that everywhere else nothing changes.
|
||||
|
||||
Live elements run only when the config allows them and nothing rules them
|
||||
out: multi-display sync (the follower mirrors whole strips only), swap mode,
|
||||
the offscreen kill switch, or a display manager with no off-screen canvas.
|
||||
While they are on, the coordinator listens for plugin updates and moves each
|
||||
plugin's data epoch on; while off, the adapter never asks for elements.
|
||||
"""
|
||||
import sys
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from src.vegas_mode.coordinator import VegasModeCoordinator # noqa: E402
|
||||
|
||||
|
||||
class _DM:
|
||||
width, height = 128, 32
|
||||
|
||||
def __init__(self, offscreen=True):
|
||||
self.image = Image.new("RGB", (128, 32))
|
||||
if offscreen:
|
||||
self.offscreen = self._offscreen
|
||||
|
||||
@contextmanager
|
||||
def _offscreen(self, width=None, height=None):
|
||||
yield SimpleNamespace(image=Image.new("RGB", (width or 128, 32)))
|
||||
|
||||
def set_scrolling_state(self, *a, **k):
|
||||
pass
|
||||
|
||||
def update_display(self):
|
||||
pass
|
||||
|
||||
|
||||
class _PM:
|
||||
def __init__(self):
|
||||
self.plugins = {}
|
||||
self.listeners = []
|
||||
|
||||
def add_update_listener(self, fn):
|
||||
if fn not in self.listeners:
|
||||
self.listeners.append(fn)
|
||||
|
||||
def remove_update_listener(self, fn):
|
||||
self.listeners = [f for f in self.listeners if f != fn]
|
||||
|
||||
|
||||
def _coordinator(dm=None, **vegas):
|
||||
vegas.setdefault("enabled", True)
|
||||
config = {"display": {"vegas_scroll": vegas}}
|
||||
return VegasModeCoordinator(config, dm or _DM(), _PM())
|
||||
|
||||
|
||||
def test_on_by_default():
|
||||
c = _coordinator()
|
||||
c._apply_live_state()
|
||||
assert c.live_active
|
||||
assert c.plugin_adapter.live_elements_enabled
|
||||
assert c._on_plugin_data_changed in c.plugin_manager.listeners
|
||||
|
||||
|
||||
@pytest.mark.parametrize("why,vegas,dm", [
|
||||
("switched off", {"live_refresh": False}, None),
|
||||
("swap mode", {"continuous_scroll": False}, None),
|
||||
("offscreen kill switch", {"offscreen_prefetch": False}, None),
|
||||
("no offscreen canvas", {}, _DM(offscreen=False)),
|
||||
])
|
||||
def test_off_when_ruled_out(why, vegas, dm):
|
||||
c = _coordinator(dm, **vegas)
|
||||
c._apply_live_state()
|
||||
assert not c.live_active
|
||||
assert not c.plugin_adapter.live_elements_enabled
|
||||
assert c.plugin_manager.listeners == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", ["leader", "follower"])
|
||||
def test_off_whenever_sync_is_configured(role):
|
||||
c = _coordinator()
|
||||
c.set_sync_manager(SimpleNamespace(role=role))
|
||||
c._apply_live_state()
|
||||
assert not c.live_active
|
||||
assert not c.plugin_adapter.live_elements_enabled
|
||||
|
||||
|
||||
def test_a_standalone_sync_manager_does_not_count():
|
||||
from src.common.sync_manager import SyncRole
|
||||
c = _coordinator()
|
||||
c.set_sync_manager(SimpleNamespace(role=SyncRole.STANDALONE))
|
||||
c._apply_live_state()
|
||||
assert c.live_active
|
||||
|
||||
|
||||
def test_stopping_switches_it_off_and_stops_listening():
|
||||
c = _coordinator()
|
||||
c._apply_live_state()
|
||||
c._is_active = True
|
||||
c.stop()
|
||||
assert not c.live_active and not c.plugin_adapter.live_elements_enabled
|
||||
assert c.plugin_manager.listeners == []
|
||||
|
||||
|
||||
def test_a_config_change_can_switch_it_off_mid_run():
|
||||
c = _coordinator()
|
||||
c._apply_live_state()
|
||||
c._is_active = True
|
||||
c.stream_manager.refresh = MagicMock()
|
||||
c.update_config({"display": {"vegas_scroll": {"enabled": True,
|
||||
"live_refresh": False}}})
|
||||
c._apply_pending_config()
|
||||
assert not c.live_active
|
||||
assert c.plugin_manager.listeners == []
|
||||
|
||||
|
||||
def test_an_update_moves_the_plugins_epoch_on():
|
||||
c = _coordinator()
|
||||
c._apply_live_state()
|
||||
before = c.live_epochs.get("p")
|
||||
for listener in c.plugin_manager.listeners:
|
||||
listener("p")
|
||||
assert c.live_epochs.get("p") > before
|
||||
assert c.plugin_adapter.live_epochs is c.live_epochs
|
||||
|
||||
|
||||
def test_the_state_change_is_logged_once(caplog):
|
||||
c = _coordinator(live_refresh=False)
|
||||
with caplog.at_level("INFO"):
|
||||
c._apply_live_state()
|
||||
c._apply_live_state()
|
||||
lines = [r.message for r in caplog.records if "live elements" in r.message]
|
||||
assert lines == ["Vegas live elements off: switched off (vegas_scroll.live_refresh)"]
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Live Vegas elements end to end: a real plugin through the real ticker.
|
||||
|
||||
The stub fixture plugin (test/fixtures/plugins/vegas-live-stub) is loaded by
|
||||
the real plugin loader onto a real DisplayManager (RGBMatrixEmulator) and a
|
||||
real PluginManager, and the real coordinator runs it: the first compose uses
|
||||
its ordinary Vegas content, the background prefetch asks it for elements, and
|
||||
the strip records where each one landed.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
os.environ["EMULATOR"] = "true"
|
||||
|
||||
import numpy as np # noqa: E402
|
||||
import pytest # noqa: E402
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from src.plugin_system.plugin_state import PluginState # noqa: E402
|
||||
from src.plugin_system.testing.harness import _instantiate # noqa: E402
|
||||
from src.plugin_system.testing.loading import build_full_config, load_harness_spec, load_manifest # noqa: E402
|
||||
from src.vegas_mode import elements # noqa: E402
|
||||
|
||||
STUB = Path(__file__).resolve().parent / "fixtures" / "plugins" / "vegas-live-stub"
|
||||
PID = "vegas-live-stub"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def dm(tmp_path_factory):
|
||||
from src.display_manager import DisplayManager
|
||||
DisplayManager._instance = None
|
||||
DisplayManager._initialized = False
|
||||
manager = DisplayManager({
|
||||
"display": {
|
||||
"hardware": {"rows": 32, "cols": 64, "chain_length": 2,
|
||||
"parallel": 1, "brightness": 90},
|
||||
"runtime": {"gpio_slowdown": 0},
|
||||
},
|
||||
}, suppress_test_pattern=True)
|
||||
manager._snapshot_path = str(
|
||||
tmp_path_factory.mktemp("live") / "led_matrix_preview.png")
|
||||
if manager.matrix is None:
|
||||
pytest.fail("DisplayManager fell back to matrix=None")
|
||||
yield manager
|
||||
DisplayManager._instance = None
|
||||
DisplayManager._initialized = False
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ticker(dm, tmp_path):
|
||||
from src.plugin_system.plugin_manager import PluginManager
|
||||
from src.vegas_mode.coordinator import VegasModeCoordinator
|
||||
|
||||
pm = PluginManager(plugins_dir=str(tmp_path), config_manager=None,
|
||||
display_manager=dm, cache_manager=None)
|
||||
config = {**build_full_config(STUB, load_harness_spec(STUB), {}),
|
||||
"enabled": True, "map_hz": 4}
|
||||
plugin = _instantiate(PID, load_manifest(STUB), STUB, config, {}, dm)
|
||||
plugin.plugin_manager = pm
|
||||
pm.plugins[PID] = plugin
|
||||
pm.state_manager.set_state(PID, PluginState.ENABLED)
|
||||
|
||||
coordinator = VegasModeCoordinator({"display": {"vegas_scroll": {
|
||||
"enabled": True, "continuous_scroll": True, "plugins_per_cycle": 1,
|
||||
"scroll_speed": 100, "lead_in_width": 0,
|
||||
}}}, dm, pm)
|
||||
yield coordinator, plugin, pm
|
||||
coordinator.stop()
|
||||
pm.stop_update_worker()
|
||||
|
||||
|
||||
def _run_until(coordinator, predicate, seconds=10.0):
|
||||
deadline = time.monotonic() + seconds
|
||||
while time.monotonic() < deadline:
|
||||
coordinator.run_frame()
|
||||
if predicate():
|
||||
return True
|
||||
time.sleep(0.002)
|
||||
return False
|
||||
|
||||
|
||||
def test_the_prefetched_stub_is_placed_as_live_elements(ticker):
|
||||
coordinator, plugin, _pm = ticker
|
||||
assert coordinator.start()
|
||||
assert coordinator.live_active
|
||||
pipeline = coordinator.render_pipeline
|
||||
# The first compose ran on this thread without the plugin's lock, so it
|
||||
# is plain content.
|
||||
assert pipeline.live_records() == ()
|
||||
|
||||
assert _run_until(coordinator, lambda: pipeline.live_records())
|
||||
keys = [r.key for r in pipeline.live_records()]
|
||||
assert {"card:0", "card:5", "map"} <= set(keys)
|
||||
assert "sep" not in keys
|
||||
assert all(r.plugin_id == PID for r in pipeline.live_records())
|
||||
|
||||
# Each record points at the element's pixels: a card is bordered in its
|
||||
# colour, with content_padding black columns either side.
|
||||
strip = pipeline.scroll_helper.cached_array
|
||||
pad = coordinator.vegas_config.content_padding
|
||||
for record in pipeline.live_records():
|
||||
x = record.abs_x - pipeline._strip_origin
|
||||
if x < 0:
|
||||
continue
|
||||
columns = strip[:, x:x + record.width]
|
||||
assert not columns[:, :pad].any() and not columns[:, -pad:].any()
|
||||
assert columns[:, pad:record.width - pad].any()
|
||||
|
||||
|
||||
def test_an_update_moves_the_plugins_epoch_and_new_elements_carry_it(ticker):
|
||||
coordinator, plugin, pm = ticker
|
||||
assert coordinator.start()
|
||||
before = coordinator.live_epochs.get(PID)
|
||||
pm._note_update_completed(PID)
|
||||
epoch = coordinator.live_epochs.get(PID)
|
||||
assert epoch > before
|
||||
coordinator.plugin_adapter.invalidate_cache(PID)
|
||||
images = coordinator.plugin_adapter.get_content(plugin, PID, offscreen_only=True)
|
||||
metas = [elements.meta_of(img) for img in images if elements.meta_of(img)]
|
||||
assert metas and all(m.epoch == epoch for m in metas)
|
||||
|
||||
|
||||
def test_with_live_refresh_off_the_same_run_is_plain_content(dm, tmp_path, ticker):
|
||||
coordinator, _plugin, _pm = ticker
|
||||
coordinator.vegas_config.live_refresh = False
|
||||
assert coordinator.start()
|
||||
assert not coordinator.live_active
|
||||
pipeline = coordinator.render_pipeline
|
||||
start_width = pipeline.scroll_helper.total_scroll_width
|
||||
assert _run_until(
|
||||
coordinator,
|
||||
lambda: pipeline.stats.get('extensions', 0) >= 1, seconds=10.0)
|
||||
assert pipeline.live_records() == ()
|
||||
assert np.asarray(pipeline.scroll_helper.cached_array).shape[1] > 0
|
||||
assert start_width > 0
|
||||
Reference in New Issue
Block a user