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:
2026-06-08 13:48:43 +03:00
parent ca59546711
commit f71e10ee06
27 changed files with 2739 additions and 6 deletions
+317
View File
@@ -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) == []