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,296 @@
|
||||
"""Tests for scene-playlist CRUD + cycling-control routes.
|
||||
|
||||
The PlaylistEngine is replaced with a lightweight fake so the route layer is
|
||||
tested without driving the real asyncio cycling loop.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from ledgrab.api import dependencies as deps
|
||||
from ledgrab.api.routes.scene_playlists import router
|
||||
from ledgrab.core.scenes.playlist_engine import PlaylistError
|
||||
from ledgrab.storage.scene_playlist_store import ScenePlaylistStore
|
||||
from ledgrab.storage.scene_preset import ScenePreset
|
||||
from ledgrab.storage.scene_preset_store import ScenePresetStore
|
||||
|
||||
|
||||
class FakePlaylistEngine:
|
||||
"""Minimal stand-in matching the methods the routes call."""
|
||||
|
||||
def __init__(self, store):
|
||||
self._store = store
|
||||
self._running_id = None
|
||||
self.start_calls = []
|
||||
self.stop_calls = 0
|
||||
|
||||
def get_running_playlist_id(self):
|
||||
return self._running_id
|
||||
|
||||
def get_state(self):
|
||||
if self._running_id is None:
|
||||
return {
|
||||
"is_running": False,
|
||||
"playlist_id": None,
|
||||
"playlist_name": None,
|
||||
"current_index": 0,
|
||||
"item_count": 0,
|
||||
"current_preset_id": None,
|
||||
"started_at": None,
|
||||
"step_started_at": None,
|
||||
"step_duration": 0.0,
|
||||
}
|
||||
return {
|
||||
"is_running": True,
|
||||
"playlist_id": self._running_id,
|
||||
"playlist_name": "running",
|
||||
"current_index": 0,
|
||||
"item_count": 1,
|
||||
"current_preset_id": "scene_a",
|
||||
"started_at": datetime.now(timezone.utc).isoformat(),
|
||||
"step_started_at": datetime.now(timezone.utc).isoformat(),
|
||||
"step_duration": 30.0,
|
||||
}
|
||||
|
||||
async def start_playlist(self, playlist_id):
|
||||
self.start_calls.append(playlist_id)
|
||||
pl = self._store.get_playlist(playlist_id)
|
||||
if not pl.items:
|
||||
raise PlaylistError("empty")
|
||||
self._running_id = playlist_id
|
||||
|
||||
async def stop(self):
|
||||
self.stop_calls += 1
|
||||
self._running_id = None
|
||||
|
||||
async def stop_if_running(self, playlist_id):
|
||||
if self._running_id == playlist_id:
|
||||
self._running_id = None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _route_db(tmp_path):
|
||||
from ledgrab.storage.database import Database
|
||||
|
||||
db = Database(tmp_path / "test.db")
|
||||
yield db
|
||||
db.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def preset_store(_route_db) -> ScenePresetStore:
|
||||
store = ScenePresetStore(_route_db)
|
||||
now = datetime.now(timezone.utc)
|
||||
for sid, name in [("scene_a", "A"), ("scene_b", "B")]:
|
||||
store.create_preset(
|
||||
ScenePreset(id=sid, name=name, targets=[], created_at=now, updated_at=now)
|
||||
)
|
||||
return store
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def playlist_store(_route_db) -> ScenePlaylistStore:
|
||||
return ScenePlaylistStore(_route_db)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_engine(playlist_store):
|
||||
return FakePlaylistEngine(playlist_store)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(playlist_store, preset_store, fake_engine):
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
|
||||
from ledgrab.api.auth import verify_api_key
|
||||
|
||||
app.dependency_overrides[verify_api_key] = lambda: "test-user"
|
||||
app.dependency_overrides[deps.get_scene_playlist_store] = lambda: playlist_store
|
||||
app.dependency_overrides[deps.get_scene_preset_store] = lambda: preset_store
|
||||
app.dependency_overrides[deps.get_playlist_engine] = lambda: fake_engine
|
||||
# Routes fire entity events through the processor manager; give it a stub.
|
||||
deps._deps["processor_manager"] = None
|
||||
|
||||
return TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
|
||||
def _create(client, name="P1", items=None, **extra):
|
||||
body = {"name": name, "items": items if items is not None else [], **extra}
|
||||
return client.post("/api/v1/scene-playlists", json=body)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCreate:
|
||||
def test_create_minimal(self, client):
|
||||
resp = _create(client, "Empty OK")
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["name"] == "Empty OK"
|
||||
assert data["loop"] is True
|
||||
assert data["is_running"] is False
|
||||
assert data["id"].startswith("playlist_")
|
||||
|
||||
def test_create_with_items(self, client):
|
||||
resp = _create(
|
||||
client,
|
||||
"With items",
|
||||
items=[
|
||||
{"scene_preset_id": "scene_a", "duration_seconds": 15},
|
||||
{"scene_preset_id": "scene_b", "duration_seconds": 45},
|
||||
],
|
||||
loop=False,
|
||||
shuffle=True,
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert len(data["items"]) == 2
|
||||
assert data["loop"] is False
|
||||
assert data["shuffle"] is True
|
||||
|
||||
def test_create_rejects_unknown_preset(self, client):
|
||||
resp = _create(
|
||||
client, "Bad ref", items=[{"scene_preset_id": "ghost", "duration_seconds": 10}]
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "ghost" in resp.json()["detail"]
|
||||
|
||||
def test_create_rejects_below_min_duration(self, client):
|
||||
resp = _create(
|
||||
client, "Too fast", items=[{"scene_preset_id": "scene_a", "duration_seconds": 0}]
|
||||
)
|
||||
# Pydantic ge=MIN validation → 422
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_create_duplicate_name(self, client):
|
||||
_create(client, "Dup")
|
||||
resp = _create(client, "Dup")
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
class TestList:
|
||||
def test_list_empty_includes_idle_state(self, client):
|
||||
resp = client.get("/api/v1/scene-playlists")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["count"] == 0
|
||||
assert data["state"]["is_running"] is False
|
||||
|
||||
def test_list_marks_running(self, client, fake_engine):
|
||||
pid = _create(
|
||||
client, "Runner", items=[{"scene_preset_id": "scene_a", "duration_seconds": 10}]
|
||||
).json()["id"]
|
||||
fake_engine._running_id = pid
|
||||
data = client.get("/api/v1/scene-playlists").json()
|
||||
assert data["count"] == 1
|
||||
assert data["playlists"][0]["is_running"] is True
|
||||
assert data["state"]["is_running"] is True
|
||||
|
||||
|
||||
class TestGet:
|
||||
def test_get_existing(self, client):
|
||||
pid = _create(client, "Find me").json()["id"]
|
||||
resp = client.get(f"/api/v1/scene-playlists/{pid}")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["name"] == "Find me"
|
||||
|
||||
def test_get_missing(self, client):
|
||||
resp = client.get("/api/v1/scene-playlists/nope")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_state_route_not_shadowed_by_id(self, client):
|
||||
# The literal /state path must resolve to the state endpoint.
|
||||
resp = client.get("/api/v1/scene-playlists/state")
|
||||
assert resp.status_code == 200
|
||||
assert "is_running" in resp.json()
|
||||
|
||||
|
||||
class TestUpdate:
|
||||
def test_update_fields(self, client):
|
||||
pid = _create(client, "Edit").json()["id"]
|
||||
resp = client.put(
|
||||
f"/api/v1/scene-playlists/{pid}",
|
||||
json={
|
||||
"name": "Edited",
|
||||
"loop": False,
|
||||
"items": [{"scene_preset_id": "scene_b", "duration_seconds": 20}],
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["name"] == "Edited"
|
||||
assert data["loop"] is False
|
||||
assert data["items"][0]["scene_preset_id"] == "scene_b"
|
||||
|
||||
def test_update_rejects_unknown_preset(self, client):
|
||||
pid = _create(client, "Edit2").json()["id"]
|
||||
resp = client.put(
|
||||
f"/api/v1/scene-playlists/{pid}",
|
||||
json={"items": [{"scene_preset_id": "ghost", "duration_seconds": 10}]},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_update_missing(self, client):
|
||||
resp = client.put("/api/v1/scene-playlists/nope", json={"name": "x"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
class TestDelete:
|
||||
def test_delete(self, client):
|
||||
pid = _create(client, "Goner").json()["id"]
|
||||
resp = client.delete(f"/api/v1/scene-playlists/{pid}")
|
||||
assert resp.status_code == 204
|
||||
assert client.get(f"/api/v1/scene-playlists/{pid}").status_code == 404
|
||||
|
||||
def test_delete_stops_if_running(self, client, fake_engine):
|
||||
pid = _create(
|
||||
client, "Running goner", items=[{"scene_preset_id": "scene_a", "duration_seconds": 10}]
|
||||
).json()["id"]
|
||||
fake_engine._running_id = pid
|
||||
client.delete(f"/api/v1/scene-playlists/{pid}")
|
||||
assert fake_engine.get_running_playlist_id() is None
|
||||
|
||||
def test_delete_missing(self, client):
|
||||
assert client.delete("/api/v1/scene-playlists/nope").status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cycling control
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestControl:
|
||||
def test_start(self, client, fake_engine):
|
||||
pid = _create(
|
||||
client, "Go", items=[{"scene_preset_id": "scene_a", "duration_seconds": 10}]
|
||||
).json()["id"]
|
||||
resp = client.post(f"/api/v1/scene-playlists/{pid}/start")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["is_running"] is True
|
||||
assert fake_engine.start_calls == [pid]
|
||||
|
||||
def test_start_missing_playlist(self, client):
|
||||
resp = client.post("/api/v1/scene-playlists/nope/start")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_start_empty_playlist_400(self, client):
|
||||
pid = _create(client, "EmptyGo").json()["id"]
|
||||
resp = client.post(f"/api/v1/scene-playlists/{pid}/start")
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_stop(self, client, fake_engine):
|
||||
pid = _create(
|
||||
client, "Stoppable", items=[{"scene_preset_id": "scene_a", "duration_seconds": 10}]
|
||||
).json()["id"]
|
||||
fake_engine._running_id = pid
|
||||
resp = client.post("/api/v1/scene-playlists/stop")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["is_running"] is False
|
||||
assert fake_engine.stop_calls == 1
|
||||
Reference in New Issue
Block a user