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,189 @@
|
||||
"""Tests for ScenePlaylist model + ScenePlaylistStore."""
|
||||
|
||||
import pytest
|
||||
|
||||
from ledgrab.storage.base_store import EntityNotFoundError
|
||||
from ledgrab.storage.scene_playlist import (
|
||||
DEFAULT_DURATION_SECONDS,
|
||||
MAX_DURATION_SECONDS,
|
||||
MIN_DURATION_SECONDS,
|
||||
PlaylistItem,
|
||||
ScenePlaylist,
|
||||
clamp_duration,
|
||||
)
|
||||
from ledgrab.storage.scene_playlist_store import ScenePlaylistStore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(tmp_db) -> ScenePlaylistStore:
|
||||
return ScenePlaylistStore(tmp_db)
|
||||
|
||||
|
||||
def _make_playlist(store: ScenePlaylistStore, name="My Playlist", **kwargs) -> ScenePlaylist:
|
||||
import uuid
|
||||
|
||||
items = kwargs.pop("items", [PlaylistItem("scene_aaa", 10.0)])
|
||||
pl = ScenePlaylist(
|
||||
id=f"playlist_{uuid.uuid4().hex[:8]}",
|
||||
name=name,
|
||||
items=items,
|
||||
order=store.count(),
|
||||
**kwargs,
|
||||
)
|
||||
return store.create_playlist(pl)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# clamp_duration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestClampDuration:
|
||||
def test_within_range_unchanged(self):
|
||||
assert clamp_duration(42.5) == 42.5
|
||||
|
||||
def test_below_floor_clamped(self):
|
||||
assert clamp_duration(0) == MIN_DURATION_SECONDS
|
||||
assert clamp_duration(-5) == MIN_DURATION_SECONDS
|
||||
|
||||
def test_above_ceiling_clamped(self):
|
||||
assert clamp_duration(MAX_DURATION_SECONDS + 1000) == MAX_DURATION_SECONDS
|
||||
|
||||
def test_non_numeric_returns_default(self):
|
||||
assert clamp_duration("oops") == DEFAULT_DURATION_SECONDS
|
||||
assert clamp_duration(None) == DEFAULT_DURATION_SECONDS
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Model serialization round-trip
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestModelSerialization:
|
||||
def test_round_trip_preserves_fields(self):
|
||||
pl = ScenePlaylist(
|
||||
id="playlist_1",
|
||||
name="Cycle",
|
||||
description="desc",
|
||||
items=[PlaylistItem("a", 5.0), PlaylistItem("b", 15.0)],
|
||||
loop=False,
|
||||
shuffle=True,
|
||||
tags=["movie"],
|
||||
icon="play",
|
||||
icon_color="#fff",
|
||||
order=3,
|
||||
)
|
||||
restored = ScenePlaylist.from_dict(pl.to_dict())
|
||||
assert restored.id == "playlist_1"
|
||||
assert restored.name == "Cycle"
|
||||
assert restored.description == "desc"
|
||||
assert restored.loop is False
|
||||
assert restored.shuffle is True
|
||||
assert restored.tags == ["movie"]
|
||||
assert restored.icon == "play"
|
||||
assert restored.icon_color == "#fff"
|
||||
assert restored.order == 3
|
||||
assert [(i.scene_preset_id, i.duration_seconds) for i in restored.items] == [
|
||||
("a", 5.0),
|
||||
("b", 15.0),
|
||||
]
|
||||
|
||||
def test_from_dict_clamps_bad_duration(self):
|
||||
data = {
|
||||
"id": "playlist_x",
|
||||
"name": "X",
|
||||
"items": [{"scene_preset_id": "a", "duration_seconds": 0}],
|
||||
}
|
||||
restored = ScenePlaylist.from_dict(data)
|
||||
assert restored.items[0].duration_seconds == MIN_DURATION_SECONDS
|
||||
|
||||
def test_to_dict_omits_empty_icon(self):
|
||||
pl = ScenePlaylist(id="p", name="n")
|
||||
d = pl.to_dict()
|
||||
assert "icon" not in d
|
||||
assert "icon_color" not in d
|
||||
assert d["loop"] is True
|
||||
assert d["shuffle"] is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Store CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestStoreCrud:
|
||||
def test_create_and_get(self, store):
|
||||
pl = _make_playlist(store, "First")
|
||||
fetched = store.get_playlist(pl.id)
|
||||
assert fetched.name == "First"
|
||||
|
||||
def test_create_duplicate_name_rejected(self, store):
|
||||
_make_playlist(store, "Dup")
|
||||
with pytest.raises(ValueError):
|
||||
_make_playlist(store, "Dup")
|
||||
|
||||
def test_create_empty_name_rejected(self, store):
|
||||
with pytest.raises(ValueError):
|
||||
_make_playlist(store, " ")
|
||||
|
||||
def test_get_missing_raises(self, store):
|
||||
with pytest.raises(EntityNotFoundError):
|
||||
store.get_playlist("nope")
|
||||
|
||||
def test_get_all_sorted_by_order(self, store):
|
||||
_make_playlist(store, "A") # order 0
|
||||
_make_playlist(store, "B") # order 1
|
||||
_make_playlist(store, "C") # order 2
|
||||
names = [p.name for p in store.get_all_playlists()]
|
||||
assert names == ["A", "B", "C"]
|
||||
|
||||
def test_update_fields(self, store):
|
||||
pl = _make_playlist(store, "Edit me")
|
||||
updated = store.update_playlist(
|
||||
pl.id,
|
||||
name="Edited",
|
||||
loop=False,
|
||||
shuffle=True,
|
||||
items=[PlaylistItem("x", 20.0)],
|
||||
tags=["t"],
|
||||
)
|
||||
assert updated.name == "Edited"
|
||||
assert updated.loop is False
|
||||
assert updated.shuffle is True
|
||||
assert updated.items[0].scene_preset_id == "x"
|
||||
assert updated.tags == ["t"]
|
||||
|
||||
def test_update_duplicate_name_rejected(self, store):
|
||||
_make_playlist(store, "Taken")
|
||||
pl = _make_playlist(store, "Other")
|
||||
with pytest.raises(ValueError):
|
||||
store.update_playlist(pl.id, name="Taken")
|
||||
|
||||
def test_update_missing_raises(self, store):
|
||||
with pytest.raises(EntityNotFoundError):
|
||||
store.update_playlist("nope", name="x")
|
||||
|
||||
def test_delete(self, store):
|
||||
pl = _make_playlist(store, "Goner")
|
||||
store.delete_playlist(pl.id)
|
||||
assert store.count() == 0
|
||||
with pytest.raises(EntityNotFoundError):
|
||||
store.get_playlist(pl.id)
|
||||
|
||||
def test_persistence_across_reload(self, tmp_db):
|
||||
store1 = ScenePlaylistStore(tmp_db)
|
||||
_make_playlist(store1, "Persisted", items=[PlaylistItem("s1", 12.0)])
|
||||
# New store instance over the same DB reloads from SQLite.
|
||||
store2 = ScenePlaylistStore(tmp_db)
|
||||
all_pls = store2.get_all_playlists()
|
||||
assert len(all_pls) == 1
|
||||
assert all_pls[0].name == "Persisted"
|
||||
assert all_pls[0].items[0].duration_seconds == 12.0
|
||||
|
||||
def test_clone_supported(self, store):
|
||||
pl = _make_playlist(store, "Original")
|
||||
clone = store.clone(pl.id, "Copy")
|
||||
assert clone.name == "Copy"
|
||||
assert clone.id != pl.id
|
||||
assert clone.id.startswith("playlist_")
|
||||
assert store.count() == 2
|
||||
Reference in New Issue
Block a user