feat(scenes): scene playlists with timed auto-cycling
Add ordered, timed sequences of scene presets that auto-cycle — activating each preset and holding it for its dwell duration before advancing. Backend: - ScenePlaylist / PlaylistItem models + SQLite store (new scene_playlists table) - PlaylistEngine: cycles ONE playlist at a time (starting one stops any other), loop/shuffle, re-reads the playlist each cycle so edits/deletes apply at the boundary, skips missing presets, guards against busy-loops; reuses the shared apply_scene_state path used by scene presets and automations - REST API: CRUD + /start, /stop, /state with scene-preset reference validation - Constructed in the app lifespan with a bounded stop on shutdown Frontend: - New "Playlists" sub-tab in the Automations tab with start/stop controls and a running indicator; editor modal with ordered scene rows (reorder + per-item duration), loop/shuffle toggles, and tags - Live refresh via the playlist_state_changed WebSocket event - i18n in en/ru/zh Tests: new unit + API coverage for the store/model, engine (cycling, single-active exclusivity, missing-preset skip, shuffle, and the playlist_state_changed event contract), and routes. Full suite green; ruff and tsc clean.
This commit is contained in:
@@ -0,0 +1,317 @@
|
||||
"""Tests for PlaylistEngine — the scene-playlist auto-cycling runtime.
|
||||
|
||||
The engine dwells on each scene for ``MIN_DURATION_SECONDS`` (1s) at minimum,
|
||||
so these tests patch ``asyncio.sleep`` to a tiny real delay (``fast_sleep``)
|
||||
to keep the cycling deterministic and fast. A captured ``_REAL_SLEEP`` is used
|
||||
for the test's own real-time waits so they aren't shortened by the patch.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from ledgrab.core.scenes.playlist_engine import PlaylistEngine, PlaylistError
|
||||
from ledgrab.storage.scene_playlist import PlaylistItem, ScenePlaylist
|
||||
from ledgrab.storage.scene_playlist_store import ScenePlaylistStore
|
||||
from ledgrab.storage.scene_preset import ScenePreset
|
||||
from ledgrab.storage.scene_preset_store import ScenePresetStore
|
||||
|
||||
_REAL_SLEEP = asyncio.sleep
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fast_sleep():
|
||||
"""Patch the engine's dwell sleep to a tiny real delay."""
|
||||
|
||||
async def _fast(_duration):
|
||||
await _REAL_SLEEP(0.005)
|
||||
|
||||
with patch("asyncio.sleep", _fast):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def preset_store(tmp_db) -> ScenePresetStore:
|
||||
store = ScenePresetStore(tmp_db)
|
||||
for sid, name in [("scene_a", "A"), ("scene_b", "B"), ("scene_c", "C")]:
|
||||
now = datetime.now(timezone.utc)
|
||||
store.create_preset(
|
||||
ScenePreset(id=sid, name=name, targets=[], created_at=now, updated_at=now)
|
||||
)
|
||||
return store
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def playlist_store(tmp_db) -> ScenePlaylistStore:
|
||||
return ScenePlaylistStore(tmp_db)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def applied():
|
||||
"""A list that records the preset ids the engine applies, in order."""
|
||||
return []
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def engine(playlist_store, preset_store, applied):
|
||||
manager = MagicMock()
|
||||
manager.fire_event = MagicMock()
|
||||
target_store = MagicMock()
|
||||
|
||||
eng = PlaylistEngine(
|
||||
playlist_store=playlist_store,
|
||||
scene_preset_store=preset_store,
|
||||
target_store=target_store,
|
||||
processor_manager=manager,
|
||||
)
|
||||
|
||||
async def _fake_apply(preset, _ts, _mgr):
|
||||
applied.append(preset.id)
|
||||
return ("activated", [])
|
||||
|
||||
# apply_scene_state is imported lazily inside the engine, so patch it at
|
||||
# its definition site with a plain async function (unambiguous awaiting).
|
||||
patcher = patch(
|
||||
"ledgrab.core.scenes.scene_activator.apply_scene_state",
|
||||
new=_fake_apply,
|
||||
)
|
||||
patcher.start()
|
||||
eng._apply_patcher = patcher # keep a handle for teardown
|
||||
yield eng
|
||||
patcher.stop()
|
||||
|
||||
|
||||
def _make_playlist(store, name, item_specs, **kwargs) -> ScenePlaylist:
|
||||
import uuid
|
||||
|
||||
items = [PlaylistItem(sid, dur) for sid, dur in item_specs]
|
||||
pl = ScenePlaylist(
|
||||
id=f"playlist_{uuid.uuid4().hex[:8]}",
|
||||
name=name,
|
||||
items=items,
|
||||
order=store.count(),
|
||||
**kwargs,
|
||||
)
|
||||
return store.create_playlist(pl)
|
||||
|
||||
|
||||
async def _drain(engine, timeout=2.0):
|
||||
"""Await the engine's current cycling task (for non-loop playlists)."""
|
||||
task = engine._task
|
||||
if task is not None:
|
||||
await asyncio.wait_for(asyncio.shield(task), timeout=timeout)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Start validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestStartValidation:
|
||||
async def test_start_unknown_raises(self, engine):
|
||||
with pytest.raises(PlaylistError):
|
||||
await engine.start_playlist("missing")
|
||||
|
||||
async def test_start_empty_playlist_raises(self, engine, playlist_store):
|
||||
pl = _make_playlist(playlist_store, "Empty", [])
|
||||
with pytest.raises(PlaylistError):
|
||||
await engine.start_playlist(pl.id)
|
||||
assert engine.is_running() is False
|
||||
|
||||
async def test_initial_state_set_on_start(self, engine, playlist_store, fast_sleep):
|
||||
pl = _make_playlist(playlist_store, "Loopy", [("scene_a", 50), ("scene_b", 50)], loop=True)
|
||||
state = await engine.start_playlist(pl.id)
|
||||
try:
|
||||
assert state.current_index == 0
|
||||
assert state.current_preset_id == "scene_a"
|
||||
s = engine.get_state()
|
||||
assert s["is_running"] is True
|
||||
assert s["playlist_id"] == pl.id
|
||||
assert s["item_count"] == 2
|
||||
finally:
|
||||
await engine.stop()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cycling behaviour
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCycling:
|
||||
async def test_non_loop_applies_all_in_order_then_idle(
|
||||
self, engine, playlist_store, applied, fast_sleep
|
||||
):
|
||||
pl = _make_playlist(
|
||||
playlist_store,
|
||||
"Once",
|
||||
[("scene_a", 50), ("scene_b", 50), ("scene_c", 50)],
|
||||
loop=False,
|
||||
)
|
||||
await engine.start_playlist(pl.id)
|
||||
await _drain(engine)
|
||||
assert applied == ["scene_a", "scene_b", "scene_c"]
|
||||
assert engine.is_running() is False
|
||||
assert engine.get_state()["is_running"] is False
|
||||
|
||||
async def test_loop_keeps_cycling_until_stopped(
|
||||
self, engine, playlist_store, applied, fast_sleep
|
||||
):
|
||||
pl = _make_playlist(
|
||||
playlist_store, "Forever", [("scene_a", 50), ("scene_b", 50)], loop=True
|
||||
)
|
||||
await engine.start_playlist(pl.id)
|
||||
await _REAL_SLEEP(0.05)
|
||||
assert engine.is_running() is True
|
||||
await engine.stop()
|
||||
assert engine.is_running() is False
|
||||
# Looped at least past the first pass.
|
||||
assert len(applied) >= 3
|
||||
assert applied[0] == "scene_a"
|
||||
|
||||
async def test_missing_preset_is_skipped(self, engine, playlist_store, applied, fast_sleep):
|
||||
pl = _make_playlist(
|
||||
playlist_store,
|
||||
"Mixed",
|
||||
[("scene_a", 50), ("ghost", 50), ("scene_b", 50)],
|
||||
loop=False,
|
||||
)
|
||||
await engine.start_playlist(pl.id)
|
||||
await _drain(engine)
|
||||
assert applied == ["scene_a", "scene_b"]
|
||||
|
||||
async def test_all_missing_with_loop_stops(self, engine, playlist_store, applied):
|
||||
pl = _make_playlist(playlist_store, "Dead", [("ghost1", 50), ("ghost2", 50)], loop=True)
|
||||
await engine.start_playlist(pl.id)
|
||||
await _drain(engine) # guard should break the loop immediately
|
||||
assert applied == []
|
||||
assert engine.is_running() is False
|
||||
|
||||
async def test_shuffle_uses_random_order(self, engine, playlist_store, applied, fast_sleep):
|
||||
pl = _make_playlist(
|
||||
playlist_store,
|
||||
"Shuffled",
|
||||
[("scene_a", 50), ("scene_b", 50), ("scene_c", 50)],
|
||||
loop=False,
|
||||
shuffle=True,
|
||||
)
|
||||
|
||||
def _reverse(seq):
|
||||
seq.reverse()
|
||||
|
||||
with patch("ledgrab.core.scenes.playlist_engine.random.shuffle", side_effect=_reverse):
|
||||
await engine.start_playlist(pl.id)
|
||||
await _drain(engine)
|
||||
assert applied == ["scene_c", "scene_b", "scene_a"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Single-playlist exclusivity + stop helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestExclusivityAndStop:
|
||||
async def test_starting_second_playlist_replaces_first(
|
||||
self, engine, playlist_store, fast_sleep
|
||||
):
|
||||
a = _make_playlist(playlist_store, "A", [("scene_a", 50)], loop=True)
|
||||
b = _make_playlist(playlist_store, "B", [("scene_b", 50)], loop=True)
|
||||
await engine.start_playlist(a.id)
|
||||
await engine.start_playlist(b.id)
|
||||
try:
|
||||
assert engine.get_running_playlist_id() == b.id
|
||||
finally:
|
||||
await engine.stop()
|
||||
|
||||
async def test_stop_when_idle_is_noop(self, engine):
|
||||
await engine.stop() # should not raise
|
||||
assert engine.is_running() is False
|
||||
|
||||
async def test_stop_if_running_only_matching(self, engine, playlist_store, fast_sleep):
|
||||
a = _make_playlist(playlist_store, "A", [("scene_a", 50)], loop=True)
|
||||
await engine.start_playlist(a.id)
|
||||
await engine.stop_if_running("some-other-id")
|
||||
assert engine.is_running() is True
|
||||
await engine.stop_if_running(a.id)
|
||||
assert engine.is_running() is False
|
||||
|
||||
async def test_get_state_idle_shape(self, engine):
|
||||
s = engine.get_state()
|
||||
assert s["is_running"] is False
|
||||
assert s["playlist_id"] is None
|
||||
assert s["current_index"] == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Event firing — the playlist_state_changed contract the frontend WS layer
|
||||
# (events-ws.ts allowlist + scene-playlists.ts listener) depends on. A dropped
|
||||
# event here would silently freeze the UI's running indicator, yet the
|
||||
# is_running()/ordering assertions above would stay green — so assert the
|
||||
# fire_event payloads directly.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _fired_events(engine) -> list:
|
||||
"""All payloads passed to processor_manager.fire_event, in order."""
|
||||
return [c.args[0] for c in engine._manager.fire_event.call_args_list]
|
||||
|
||||
|
||||
def _fired_actions(engine) -> list:
|
||||
return [e.get("action") for e in _fired_events(engine)]
|
||||
|
||||
|
||||
class TestEvents:
|
||||
async def test_start_fires_started_event(self, engine, playlist_store, fast_sleep):
|
||||
pl = _make_playlist(playlist_store, "Started", [("scene_a", 50)], loop=True)
|
||||
await engine.start_playlist(pl.id)
|
||||
try:
|
||||
started = [e for e in _fired_events(engine) if e.get("action") == "started"]
|
||||
assert len(started) == 1
|
||||
assert started[0]["type"] == "playlist_state_changed"
|
||||
assert started[0]["playlist_id"] == pl.id
|
||||
finally:
|
||||
await engine.stop()
|
||||
|
||||
async def test_non_loop_completion_fires_final_stopped(
|
||||
self, engine, playlist_store, fast_sleep
|
||||
):
|
||||
pl = _make_playlist(
|
||||
playlist_store, "Finishes", [("scene_a", 50), ("scene_b", 50)], loop=False
|
||||
)
|
||||
await engine.start_playlist(pl.id)
|
||||
await _drain(engine)
|
||||
|
||||
actions = _fired_actions(engine)
|
||||
assert actions[0] == "started"
|
||||
assert actions[-1] == "stopped"
|
||||
# The natural-completion 'stopped' must carry the playlist id even
|
||||
# though _run clears _state to None before firing (ended_id capture).
|
||||
stopped = [e for e in _fired_events(engine) if e.get("action") == "stopped"]
|
||||
assert stopped and stopped[-1]["playlist_id"] == pl.id
|
||||
|
||||
async def test_advanced_fires_per_applied_item_only(self, engine, playlist_store, fast_sleep):
|
||||
pl = _make_playlist(
|
||||
playlist_store,
|
||||
"Mixed",
|
||||
[("scene_a", 50), ("ghost", 50), ("scene_b", 50)],
|
||||
loop=False,
|
||||
)
|
||||
await engine.start_playlist(pl.id)
|
||||
await _drain(engine)
|
||||
|
||||
advanced = [e for e in _fired_events(engine) if e.get("action") == "advanced"]
|
||||
# Only the two real presets advance; the missing one fires nothing.
|
||||
assert [e["preset_id"] for e in advanced] == ["scene_a", "scene_b"]
|
||||
assert [e["index"] for e in advanced] == [0, 2]
|
||||
|
||||
async def test_explicit_stop_fires_stopped(self, engine, playlist_store, fast_sleep):
|
||||
pl = _make_playlist(playlist_store, "Stopper", [("scene_a", 50)], loop=True)
|
||||
await engine.start_playlist(pl.id)
|
||||
await engine.stop()
|
||||
stopped = [e for e in _fired_events(engine) if e.get("action") == "stopped"]
|
||||
assert stopped and stopped[-1]["playlist_id"] == pl.id
|
||||
|
||||
async def test_stop_when_idle_fires_nothing(self, engine):
|
||||
await engine.stop()
|
||||
assert _fired_actions(engine) == []
|
||||
Reference in New Issue
Block a user