P6-R2: Session manager — save/load mixer states with setlists and snapshots
Lint & Validate / lint (push) Has been cancelled
Lint & Validate / lint (push) Has been cancelled
Core session management:
- MixerSession: full mixer state as versioned JSON (channels, buses, routing, plugins, MIDI, transport, automation)
- SessionManager: save/load/list/delete/rename/duplicate/import/export sessions to ~/.config/rpi-mixer/sessions/
- Setlist: ordered collection of sessions with transition types (cut, crossfade, wait)
- SnapshotEngine: instant scene capture/recall with persistence, categories, next/prev
API endpoints (all under /api/v1/):
- GET/POST/DELETE /sessions — list, save, load, delete, info
- POST /sessions/{name}/rename, /duplicate, /export, /import
- GET/POST/DELETE /setlists — list, save, load, delete
- GET/POST/DELETE /snapshots — list, capture, recall, delete, next, prev
- GET/POST /sessions/autosave — status, enable, disable
Web UI:
- Sessions tab: full session browser with load/delete/duplicate/export
- Setlists subtab: create/edit/activate setlist with entry management
- Snapshots subtab: capture/recall with category badges
- Auto-save status indicator
- Import/export all sessions as zip
Kivy touchscreen UI:
- New SES tab with sessions/setlists/snapshots sub-views
- Touch-optimized cards with save/load/delete actions
Tests: 51 new tests, 735 project total — all passing
This commit is contained in:
@@ -81,8 +81,14 @@ class SessionManager:
|
|||||||
self._max_autosave_history = max_autosave_history
|
self._max_autosave_history = max_autosave_history
|
||||||
|
|
||||||
# Ensure directories exist
|
# Ensure directories exist
|
||||||
self._sessions_dir.mkdir(parents=True, exist_ok=True)
|
try:
|
||||||
self._setlists_dir.mkdir(parents=True, exist_ok=True)
|
self._sessions_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
except OSError as exc:
|
||||||
|
logger.warning("Cannot create sessions directory %s: %s", self._sessions_dir, exc)
|
||||||
|
try:
|
||||||
|
self._setlists_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
except OSError as exc:
|
||||||
|
logger.warning("Cannot create setlists directory %s: %s", self._setlists_dir, exc)
|
||||||
|
|
||||||
# Auto-save state
|
# Auto-save state
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
|
|||||||
+145
@@ -305,6 +305,93 @@ class MixerClient:
|
|||||||
timeout=5,
|
timeout=5,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ── Generic REST methods (used by SessionsScreen) ────────────────────
|
||||||
|
|
||||||
|
def rest_get(
|
||||||
|
self,
|
||||||
|
path: str,
|
||||||
|
on_success: Callable[[dict], None],
|
||||||
|
on_error: Optional[Callable[[str], None]] = None,
|
||||||
|
) -> None:
|
||||||
|
"""GET a REST endpoint. Callbacks fire on main thread."""
|
||||||
|
self._simple_get(path, on_success, on_error)
|
||||||
|
|
||||||
|
def rest_post(
|
||||||
|
self,
|
||||||
|
path: str,
|
||||||
|
body: dict,
|
||||||
|
on_success: Optional[Callable[[dict], None]] = None,
|
||||||
|
on_error: Optional[Callable[[str], None]] = None,
|
||||||
|
) -> None:
|
||||||
|
"""POST to a REST endpoint. Callbacks fire on main thread."""
|
||||||
|
if not KIVY_AVAILABLE:
|
||||||
|
_sync_post(self._url(path), json.dumps(body), on_success, on_error)
|
||||||
|
return
|
||||||
|
|
||||||
|
def _ok(req, result):
|
||||||
|
if on_success:
|
||||||
|
on_success(result)
|
||||||
|
|
||||||
|
def _err(req, error):
|
||||||
|
msg = f"REST POST {path}: {error}"
|
||||||
|
logger.warning(msg)
|
||||||
|
if on_error:
|
||||||
|
on_error(msg)
|
||||||
|
|
||||||
|
def _fail(req):
|
||||||
|
msg = f"REST POST {path} failure: {req.error}"
|
||||||
|
logger.warning(msg)
|
||||||
|
if on_error:
|
||||||
|
on_error(msg)
|
||||||
|
|
||||||
|
UrlRequest(
|
||||||
|
self._url(path),
|
||||||
|
method="POST",
|
||||||
|
req_body=json.dumps(body),
|
||||||
|
on_success=_ok,
|
||||||
|
on_error=_err,
|
||||||
|
on_failure=_fail,
|
||||||
|
req_headers=self._headers(),
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
def rest_delete(
|
||||||
|
self,
|
||||||
|
path: str,
|
||||||
|
on_success: Optional[Callable[[dict], None]] = None,
|
||||||
|
on_error: Optional[Callable[[str], None]] = None,
|
||||||
|
) -> None:
|
||||||
|
"""DELETE a REST endpoint. Callbacks fire on main thread."""
|
||||||
|
if not KIVY_AVAILABLE:
|
||||||
|
_sync_delete(self._url(path), on_success, on_error)
|
||||||
|
return
|
||||||
|
|
||||||
|
def _ok(req, result):
|
||||||
|
if on_success:
|
||||||
|
on_success(result)
|
||||||
|
|
||||||
|
def _err(req, error):
|
||||||
|
msg = f"REST DELETE {path}: {error}"
|
||||||
|
logger.warning(msg)
|
||||||
|
if on_error:
|
||||||
|
on_error(msg)
|
||||||
|
|
||||||
|
def _fail(req):
|
||||||
|
msg = f"REST DELETE {path} failure: {req.error}"
|
||||||
|
logger.warning(msg)
|
||||||
|
if on_error:
|
||||||
|
on_error(msg)
|
||||||
|
|
||||||
|
UrlRequest(
|
||||||
|
self._url(path),
|
||||||
|
method="DELETE",
|
||||||
|
on_success=_ok,
|
||||||
|
on_error=_err,
|
||||||
|
on_failure=_fail,
|
||||||
|
req_headers=self._headers(),
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
|
||||||
# ── OSC (optional raw UDP channel) ────────────────────────────────────
|
# ── OSC (optional raw UDP channel) ────────────────────────────────────
|
||||||
|
|
||||||
def osc_send(self, address: str, *args) -> bool:
|
def osc_send(self, address: str, *args) -> bool:
|
||||||
@@ -417,3 +504,61 @@ def _sync_put(
|
|||||||
msg = str(e)
|
msg = str(e)
|
||||||
if on_error:
|
if on_error:
|
||||||
on_error(msg)
|
on_error(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_post(
|
||||||
|
url: str,
|
||||||
|
body: str,
|
||||||
|
on_success: Optional[Callable] = None,
|
||||||
|
on_error: Optional[Callable[[str], None]] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Fallback synchronous POST using urllib."""
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = body.encode("utf-8")
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url, data=data, method="POST",
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||||
|
result = json.loads(resp.read().decode())
|
||||||
|
if on_success:
|
||||||
|
on_success(result)
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
msg = f"HTTP {e.code}: {e.reason}"
|
||||||
|
if on_error:
|
||||||
|
on_error(msg)
|
||||||
|
except Exception as e:
|
||||||
|
msg = str(e)
|
||||||
|
if on_error:
|
||||||
|
on_error(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_delete(
|
||||||
|
url: str,
|
||||||
|
on_success: Optional[Callable] = None,
|
||||||
|
on_error: Optional[Callable[[str], None]] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Fallback synchronous DELETE using urllib."""
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url, method="DELETE",
|
||||||
|
headers={"Accept": "application/json"},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||||
|
result = json.loads(resp.read().decode())
|
||||||
|
if on_success:
|
||||||
|
on_success(result)
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
msg = f"HTTP {e.code}: {e.reason}"
|
||||||
|
if on_error:
|
||||||
|
on_error(msg)
|
||||||
|
except Exception as e:
|
||||||
|
msg = str(e)
|
||||||
|
if on_error:
|
||||||
|
on_error(msg)
|
||||||
|
|||||||
+555
-161
@@ -1,199 +1,593 @@
|
|||||||
"""Tests for session-based WebSocket authentication."""
|
"""Tests for session management module.
|
||||||
|
|
||||||
|
Tests cover:
|
||||||
|
- MixerSession: capture, serialize, deserialize, apply
|
||||||
|
- SessionManager: save, load, list, delete, rename, duplicate, auto-save
|
||||||
|
- Setlist: create, serialize, activate, navigate
|
||||||
|
- SnapshotEngine: capture, recall, next/prev, persistence
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
import time
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from src.network.session import Session, SessionManager, DEFAULT_SESSION_TTL
|
|
||||||
|
from src.mixer.dsp_engine import DSPEngine, DSPEngineConfig
|
||||||
|
from src.session.mixer_session import MixerSession, SessionMetadata, SESSION_SCHEMA_VERSION
|
||||||
|
from src.session.session_manager import SessionManager
|
||||||
|
from src.session.setlist import Setlist, SetlistEntry, TransitionType
|
||||||
|
from src.session.snapshot import Snapshot, SnapshotEngine, SnapshotMetadata
|
||||||
|
|
||||||
|
|
||||||
class TestSession:
|
# ── Fixtures ─────────────────────────────────────────────────────────────────
|
||||||
"""Tests for the Session data object."""
|
|
||||||
|
|
||||||
def test_session_creation(self):
|
|
||||||
s = Session(session_id="abc123", client_id="browser-1")
|
|
||||||
assert s.session_id == "abc123"
|
|
||||||
assert s.client_id == "browser-1"
|
|
||||||
assert not s.expired
|
|
||||||
assert s.ttl_remaining > 0
|
|
||||||
assert s.created_at > 0
|
|
||||||
|
|
||||||
def test_session_metadata(self):
|
@pytest.fixture
|
||||||
s = Session("abc", "client", metadata={"user": "admin"})
|
def engine():
|
||||||
assert s.metadata["user"] == "admin"
|
"""Create a minimal DSP engine for testing."""
|
||||||
|
config = DSPEngineConfig(
|
||||||
|
num_channels=8,
|
||||||
|
num_aux=2,
|
||||||
|
num_subgroups=1,
|
||||||
|
num_vca=1,
|
||||||
|
osc_enabled=False,
|
||||||
|
jack_routing_enabled=False,
|
||||||
|
automation_enabled=False,
|
||||||
|
)
|
||||||
|
eng = DSPEngine(config)
|
||||||
|
eng.start(connect_osc=False)
|
||||||
|
yield eng
|
||||||
|
eng.stop()
|
||||||
|
|
||||||
def test_session_expiry(self):
|
|
||||||
s = Session("abc", "client", ttl=0.01)
|
|
||||||
time.sleep(0.02)
|
|
||||||
assert s.expired
|
|
||||||
assert s.ttl_remaining == 0.0
|
|
||||||
|
|
||||||
def test_session_not_expired(self):
|
@pytest.fixture
|
||||||
s = Session("abc", "client", ttl=3600)
|
def tmp_sessions_dir():
|
||||||
assert not s.expired
|
"""Create a temporary sessions directory."""
|
||||||
assert s.ttl_remaining > 3500 # generous margin
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
yield Path(td)
|
||||||
|
|
||||||
def test_session_custom_ttl(self):
|
|
||||||
s = Session("abc", "client", ttl=60)
|
# ── MixerSession tests ──────────────────────────────────────────────────────
|
||||||
assert 59 < s.ttl_remaining <= 60
|
|
||||||
|
|
||||||
|
class TestMixerSession:
|
||||||
|
"""Test MixerSession model."""
|
||||||
|
|
||||||
|
def test_create_empty(self):
|
||||||
|
"""Create an empty session."""
|
||||||
|
session = MixerSession()
|
||||||
|
assert session.version == SESSION_SCHEMA_VERSION
|
||||||
|
assert session.metadata.name == "Untitled Session"
|
||||||
|
assert session.channels == []
|
||||||
|
assert session.master == {}
|
||||||
|
|
||||||
|
def test_from_engine(self, engine):
|
||||||
|
"""Capture engine state into a session."""
|
||||||
|
session = MixerSession.from_engine(engine, name="Test Mix")
|
||||||
|
assert session.metadata.name == "Test Mix"
|
||||||
|
assert session.channel_count == 8
|
||||||
|
assert session.master # has bus state
|
||||||
|
assert session.routing # has routing matrix
|
||||||
|
assert not session.has_plugins # no OSC, no plugins
|
||||||
|
assert session.settings["num_channels"] == 8
|
||||||
|
|
||||||
|
def test_serialize_roundtrip(self, engine):
|
||||||
|
"""Serialize and deserialize a session."""
|
||||||
|
session = MixerSession.from_engine(engine, name="Roundtrip")
|
||||||
|
json_str = session.to_json()
|
||||||
|
|
||||||
|
# Parse back
|
||||||
|
restored = MixerSession.from_json(json_str)
|
||||||
|
assert restored.metadata.name == "Roundtrip"
|
||||||
|
assert restored.channel_count == 8
|
||||||
|
assert restored.version == SESSION_SCHEMA_VERSION
|
||||||
|
|
||||||
|
def test_serialize_json_valid(self, engine):
|
||||||
|
"""Serialized session is valid JSON."""
|
||||||
|
session = MixerSession.from_engine(engine)
|
||||||
|
json_str = session.to_json()
|
||||||
|
data = json.loads(json_str)
|
||||||
|
assert "version" in data
|
||||||
|
assert "metadata" in data
|
||||||
|
assert "channels" in data
|
||||||
|
assert "master" in data
|
||||||
|
assert "routing" in data
|
||||||
|
|
||||||
|
def test_apply_to_engine(self, engine):
|
||||||
|
"""Apply a session to the engine."""
|
||||||
|
# Change a channel parameter
|
||||||
|
ch0 = engine.channels[0]
|
||||||
|
ch0.state.volume = -10.0
|
||||||
|
ch0.state.mute = True
|
||||||
|
|
||||||
|
# Capture
|
||||||
|
session = MixerSession.from_engine(engine, name="Modified")
|
||||||
|
|
||||||
|
# Reset engine
|
||||||
|
ch0.state.volume = 0.0
|
||||||
|
ch0.state.mute = False
|
||||||
|
|
||||||
|
# Apply
|
||||||
|
assert session.apply_to(engine)
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
assert engine.channels[0].state.volume == -10.0
|
||||||
|
assert engine.channels[0].state.mute is True
|
||||||
|
|
||||||
|
def test_from_dict_to_dict(self, engine):
|
||||||
|
"""Dict roundtrip."""
|
||||||
|
session = MixerSession.from_engine(engine, name="Dict Test")
|
||||||
|
data = session.to_dict()
|
||||||
|
restored = MixerSession.from_dict(data)
|
||||||
|
assert restored.metadata.name == "Dict Test"
|
||||||
|
|
||||||
|
def test_metadata_fields(self):
|
||||||
|
"""Test session metadata fields."""
|
||||||
|
meta = SessionMetadata(
|
||||||
|
name="My Song",
|
||||||
|
tempo=140.0,
|
||||||
|
key="D minor",
|
||||||
|
time_signature="3/4",
|
||||||
|
lyrics="Test lyrics",
|
||||||
|
genre="Rock",
|
||||||
|
tags=["live", "soundcheck"],
|
||||||
|
notes="Test notes",
|
||||||
|
)
|
||||||
|
session = MixerSession(metadata=meta)
|
||||||
|
data = session.to_dict()
|
||||||
|
assert data["metadata"]["tempo"] == 140.0
|
||||||
|
assert data["metadata"]["key"] == "D minor"
|
||||||
|
assert data["metadata"]["tags"] == ["live", "soundcheck"]
|
||||||
|
|
||||||
|
def test_file_save_load(self, engine, tmp_path):
|
||||||
|
"""Save and load a session from file."""
|
||||||
|
session = MixerSession.from_engine(engine, name="File Test")
|
||||||
|
filepath = tmp_path / "test_session.json"
|
||||||
|
session.save_to_file(filepath)
|
||||||
|
|
||||||
|
assert filepath.exists()
|
||||||
|
|
||||||
|
loaded = MixerSession.from_file(filepath)
|
||||||
|
assert loaded.metadata.name == "File Test"
|
||||||
|
assert loaded.channel_count == 8
|
||||||
|
|
||||||
|
def test_custom_metadata(self):
|
||||||
|
"""Custom metadata fields are preserved."""
|
||||||
|
meta = SessionMetadata(custom={"venue": "Madison Square Garden", "set": 2})
|
||||||
|
session = MixerSession(metadata=meta)
|
||||||
|
data = session.to_dict()
|
||||||
|
assert data["metadata"]["custom"]["venue"] == "Madison Square Garden"
|
||||||
|
|
||||||
|
|
||||||
|
# ── SessionManager tests ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
class TestSessionManager:
|
class TestSessionManager:
|
||||||
"""Tests for SessionManager."""
|
"""Test SessionManager."""
|
||||||
|
|
||||||
@pytest.fixture
|
def test_save_and_list(self, engine, tmp_sessions_dir):
|
||||||
def mgr(self):
|
"""Save a session and list it."""
|
||||||
return SessionManager(ttl=3600, max_sessions=10)
|
mgr = SessionManager(sessions_dir=tmp_sessions_dir, auto_save_enabled=False)
|
||||||
|
mgr.save(engine, "Test Session")
|
||||||
|
|
||||||
def test_initial_state(self, mgr):
|
sessions = mgr.list_sessions()
|
||||||
assert mgr.active_sessions == 0
|
assert len(sessions) == 1
|
||||||
stats = mgr.stats
|
assert sessions[0]["name"] == "Test Session"
|
||||||
assert stats["active_sessions"] == 0
|
|
||||||
assert stats["total_created"] == 0
|
|
||||||
assert stats["total_destroyed"] == 0
|
|
||||||
assert stats["max_sessions"] == 10
|
|
||||||
assert stats["default_ttl"] == 3600
|
|
||||||
|
|
||||||
def test_create_session(self, mgr):
|
def test_load_session(self, engine, tmp_sessions_dir):
|
||||||
token = mgr.create_session("browser-chrome")
|
"""Save and then load a session."""
|
||||||
assert token is not None
|
mgr = SessionManager(sessions_dir=tmp_sessions_dir, auto_save_enabled=False)
|
||||||
assert len(token) == 64 # HMAC-SHA256 hex digest
|
|
||||||
assert mgr.active_sessions == 1
|
|
||||||
assert mgr.stats["total_created"] == 1
|
|
||||||
|
|
||||||
def test_validate_valid_token(self, mgr):
|
# Modify engine state
|
||||||
token = mgr.create_session("browser-firefox")
|
engine.channels[0].state.volume = -5.0
|
||||||
session = mgr.validate_token(token)
|
mgr.save(engine, "Load Test")
|
||||||
assert session is not None
|
|
||||||
assert session.client_id == "browser-firefox"
|
|
||||||
assert not session.expired
|
|
||||||
|
|
||||||
def test_validate_invalid_token(self, mgr):
|
# Reset
|
||||||
session = mgr.validate_token("invalid-token")
|
engine.channels[0].state.volume = 0.0
|
||||||
assert session is None
|
|
||||||
|
|
||||||
def test_validate_empty_token(self, mgr):
|
# Load and apply
|
||||||
assert mgr.validate_token("") is None
|
ok = mgr.load_and_apply(engine, "Load Test")
|
||||||
assert mgr.validate_token(None) is None # type: ignore
|
assert ok
|
||||||
|
assert engine.channels[0].state.volume == -5.0
|
||||||
|
|
||||||
def test_validate_expired_token(self, mgr):
|
def test_delete_session(self, engine, tmp_sessions_dir):
|
||||||
token = mgr.create_session("browser", ttl=0.01)
|
"""Delete a session."""
|
||||||
time.sleep(0.02)
|
mgr = SessionManager(sessions_dir=tmp_sessions_dir, auto_save_enabled=False)
|
||||||
session = mgr.validate_token(token)
|
mgr.save(engine, "Delete Me")
|
||||||
assert session is None
|
assert mgr.exists("Delete Me")
|
||||||
assert mgr.active_sessions == 0
|
assert mgr.delete("Delete Me")
|
||||||
|
assert not mgr.exists("Delete Me")
|
||||||
|
|
||||||
def test_destroy_session(self, mgr):
|
def test_rename_session(self, engine, tmp_sessions_dir):
|
||||||
token = mgr.create_session("browser")
|
"""Rename a session."""
|
||||||
assert mgr.active_sessions == 1
|
mgr = SessionManager(sessions_dir=tmp_sessions_dir, auto_save_enabled=False)
|
||||||
destroyed = mgr.destroy_session(token)
|
mgr.save(engine, "Old Name")
|
||||||
assert destroyed
|
assert mgr.rename("Old Name", "New Name")
|
||||||
assert mgr.active_sessions == 0
|
assert mgr.exists("New Name")
|
||||||
assert mgr.validate_token(token) is None
|
assert not mgr.exists("Old Name")
|
||||||
|
|
||||||
def test_destroy_invalid_token(self, mgr):
|
def test_duplicate_session(self, engine, tmp_sessions_dir):
|
||||||
assert mgr.destroy_session("nope") is False
|
"""Duplicate a session."""
|
||||||
|
mgr = SessionManager(sessions_dir=tmp_sessions_dir, auto_save_enabled=False)
|
||||||
|
engine.channels[0].state.volume = -12.0
|
||||||
|
mgr.save(engine, "Original")
|
||||||
|
|
||||||
def test_destroy_by_id(self, mgr):
|
dup = mgr.duplicate("Original", "Copy")
|
||||||
token = mgr.create_session("browser")
|
assert dup is not None
|
||||||
session = mgr.validate_token(token)
|
assert dup.metadata.name == "Copy"
|
||||||
assert session is not None
|
|
||||||
sid = session.session_id
|
|
||||||
destroyed = mgr.destroy_by_id(sid)
|
|
||||||
assert destroyed
|
|
||||||
assert mgr.validate_token(token) is None
|
|
||||||
|
|
||||||
def test_destroy_by_invalid_id(self, mgr):
|
# Load copy into reset engine
|
||||||
assert mgr.destroy_by_id("nonexistent") is False
|
engine.channels[0].state.volume = 0.0
|
||||||
|
ok = mgr.load_and_apply(engine, "Copy")
|
||||||
|
assert ok
|
||||||
|
assert engine.channels[0].state.volume == -12.0
|
||||||
|
|
||||||
def test_get_session(self, mgr):
|
def test_exists(self, engine, tmp_sessions_dir):
|
||||||
token = mgr.create_session("browser")
|
"""Check session existence."""
|
||||||
session = mgr.get_session(token)
|
mgr = SessionManager(sessions_dir=tmp_sessions_dir, auto_save_enabled=False)
|
||||||
assert session is not None
|
assert not mgr.exists("No Such Session")
|
||||||
assert session.client_id == "browser"
|
mgr.save(engine, "Exists")
|
||||||
|
assert mgr.exists("Exists")
|
||||||
|
|
||||||
# Even expired sessions are returned by get_session
|
def test_session_count(self, engine, tmp_sessions_dir):
|
||||||
session.expires_at = time.time() - 1
|
"""Session count is accurate."""
|
||||||
assert mgr.get_session(token) is not None
|
mgr = SessionManager(sessions_dir=tmp_sessions_dir, auto_save_enabled=False)
|
||||||
|
assert mgr.session_count == 0
|
||||||
|
mgr.save(engine, "One")
|
||||||
|
mgr.save(engine, "Two")
|
||||||
|
assert mgr.session_count == 2
|
||||||
|
|
||||||
def test_refresh_session(self, mgr):
|
def test_list_nonexistent_dir(self):
|
||||||
token = mgr.create_session("browser", ttl=3600)
|
"""Listing when sessions dir cannot be created returns empty."""
|
||||||
session = mgr.get_session(token)
|
# Use a path under /dev/null which exists as a file, not a dir
|
||||||
original_expiry = session.expires_at
|
mgr = SessionManager(sessions_dir=Path("/dev/null/sessions"), auto_save_enabled=False)
|
||||||
|
assert mgr.list_sessions() == []
|
||||||
|
|
||||||
# Fast-forward expiry then refresh
|
def test_get_session_info(self, engine, tmp_sessions_dir):
|
||||||
session.expires_at = time.time() + 1 # 1 second left
|
"""Get session info without loading full state."""
|
||||||
assert mgr.refresh_session(token)
|
mgr = SessionManager(sessions_dir=tmp_sessions_dir, auto_save_enabled=False)
|
||||||
session = mgr.get_session(token)
|
mgr.save(engine, "Info Test")
|
||||||
assert session.expires_at > original_expiry
|
info = mgr.get_session_info("Info Test")
|
||||||
|
assert info is not None
|
||||||
|
assert info["name"] == "Info Test"
|
||||||
|
assert info["channel_count"] == 8
|
||||||
|
|
||||||
def test_refresh_expired_session(self, mgr):
|
def test_auto_save_disabled(self, engine, tmp_sessions_dir):
|
||||||
token = mgr.create_session("browser", ttl=0.01)
|
"""Auto-save can be disabled."""
|
||||||
time.sleep(0.02)
|
mgr = SessionManager(sessions_dir=tmp_sessions_dir, auto_save_enabled=False)
|
||||||
assert not mgr.refresh_session(token)
|
mgr.set_auto_save_engine(engine)
|
||||||
|
mgr.disable_auto_save()
|
||||||
|
assert not mgr.auto_save_enabled
|
||||||
|
|
||||||
def test_multiple_sessions(self, mgr):
|
def test_export_import(self, engine, tmp_sessions_dir, tmp_path):
|
||||||
tokens = [mgr.create_session(f"client-{i}") for i in range(5)]
|
"""Export and import a session."""
|
||||||
assert mgr.active_sessions == 5
|
mgr = SessionManager(sessions_dir=tmp_sessions_dir, auto_save_enabled=False)
|
||||||
assert mgr.stats["total_created"] == 5
|
mgr.save(engine, "Export Test")
|
||||||
|
|
||||||
# Validate all
|
export_path = mgr.export_session("Export Test", target_dir=tmp_path)
|
||||||
for token in tokens:
|
assert export_path is not None
|
||||||
assert mgr.validate_token(token) is not None
|
assert export_path.exists()
|
||||||
|
|
||||||
def test_max_sessions(self, mgr):
|
# Import into a new manager
|
||||||
"""Test that exceeding max_sessions evicts oldest."""
|
mgr2 = SessionManager(
|
||||||
# Fill up to max
|
sessions_dir=tmp_path / "imported",
|
||||||
for i in range(10):
|
auto_save_enabled=False,
|
||||||
mgr.create_session(f"client-{i}")
|
)
|
||||||
|
imported = mgr2.import_session(export_path)
|
||||||
|
assert imported is not None
|
||||||
|
assert imported.metadata.name == "Export Test"
|
||||||
|
|
||||||
assert mgr.active_sessions == 10
|
def test_load_nonexistent(self, tmp_sessions_dir):
|
||||||
|
"""Loading a nonexistent session returns None."""
|
||||||
|
mgr = SessionManager(sessions_dir=tmp_sessions_dir, auto_save_enabled=False)
|
||||||
|
assert mgr.load("No Such Session") is None
|
||||||
|
|
||||||
# Create one more — should evict oldest
|
def test_delete_nonexistent(self, tmp_sessions_dir):
|
||||||
mgr.create_session("overflow")
|
"""Deleting a nonexistent session returns False."""
|
||||||
assert mgr.active_sessions == 10
|
mgr = SessionManager(sessions_dir=tmp_sessions_dir, auto_save_enabled=False)
|
||||||
assert mgr.stats["total_created"] == 11
|
assert not mgr.delete("No Such Session")
|
||||||
assert mgr.stats["total_destroyed"] == 1
|
|
||||||
|
|
||||||
def test_stale_eviction_on_validate(self, mgr):
|
|
||||||
"""Test that stale sessions are evicted when validate is called."""
|
|
||||||
token1 = mgr.create_session("client-a", ttl=0.01)
|
|
||||||
token2 = mgr.create_session("client-b", ttl=3600)
|
|
||||||
|
|
||||||
time.sleep(0.02)
|
|
||||||
|
|
||||||
# Token1 expired, token2 still valid
|
|
||||||
assert mgr.validate_token(token1) is None
|
|
||||||
assert mgr.active_sessions == 1 # token1 evicted
|
|
||||||
assert mgr.validate_token(token2) is not None
|
|
||||||
|
|
||||||
def test_stats_evicts_stale(self, mgr):
|
|
||||||
"""Test that stats evicts stale sessions."""
|
|
||||||
mgr.create_session("client-a", ttl=0.01)
|
|
||||||
mgr.create_session("client-b", ttl=3600)
|
|
||||||
|
|
||||||
time.sleep(0.02)
|
|
||||||
|
|
||||||
stats = mgr.stats
|
|
||||||
assert stats["active_sessions"] == 1 # Only client-b remains
|
|
||||||
|
|
||||||
def test_session_metadata_storage(self, mgr):
|
|
||||||
token = mgr.create_session("browser", metadata={"role": "admin", "permissions": ["read", "write"]})
|
|
||||||
session = mgr.validate_token(token)
|
|
||||||
assert session.metadata == {"role": "admin", "permissions": ["read", "write"]}
|
|
||||||
|
|
||||||
|
|
||||||
class TestSessionTokens:
|
# ── Setlist tests ───────────────────────────────────────────────────────────
|
||||||
"""Tests for token uniqueness and security properties."""
|
|
||||||
|
|
||||||
def test_tokens_are_unique(self):
|
|
||||||
mgr = SessionManager()
|
|
||||||
tokens = {mgr.create_session("client") for _ in range(100)}
|
|
||||||
assert len(tokens) == 100 # All unique
|
|
||||||
|
|
||||||
def test_tokens_are_opaque(self):
|
class TestSetlist:
|
||||||
"""Tokens should not contain the session ID in plaintext."""
|
"""Test Setlist model."""
|
||||||
mgr = SessionManager()
|
|
||||||
token = mgr.create_session("client")
|
def test_create_empty(self):
|
||||||
session = mgr.get_session(token)
|
"""Create an empty setlist."""
|
||||||
assert session is not None
|
sl = Setlist(name="Test Setlist")
|
||||||
assert session.session_id not in token
|
assert sl.name == "Test Setlist"
|
||||||
assert "client" not in token
|
assert sl.entry_count == 0
|
||||||
|
assert not sl.is_active
|
||||||
|
|
||||||
|
def test_add_entries(self):
|
||||||
|
"""Add entries to a setlist."""
|
||||||
|
sl = Setlist(name="Gig")
|
||||||
|
sl.add_entry("Soundcheck", transition=TransitionType.CUT)
|
||||||
|
sl.add_entry("Set 1", transition=TransitionType.CROSSFADE, transition_duration=3.0)
|
||||||
|
sl.add_entry("Set 2", transition=TransitionType.WAIT, wait_for_trigger=True)
|
||||||
|
assert sl.entry_count == 3
|
||||||
|
assert sl.entries[0].session_name == "Soundcheck"
|
||||||
|
assert sl.entries[1].transition == TransitionType.CROSSFADE
|
||||||
|
assert sl.entries[2].wait_for_trigger is True
|
||||||
|
|
||||||
|
def test_remove_entry(self):
|
||||||
|
"""Remove an entry."""
|
||||||
|
sl = Setlist(name="Test")
|
||||||
|
sl.add_entry("A")
|
||||||
|
sl.add_entry("B")
|
||||||
|
assert sl.remove_entry(0)
|
||||||
|
assert sl.entry_count == 1
|
||||||
|
assert sl.entries[0].session_name == "B"
|
||||||
|
|
||||||
|
def test_move_entry(self):
|
||||||
|
"""Move an entry."""
|
||||||
|
sl = Setlist(name="Test")
|
||||||
|
sl.add_entry("First")
|
||||||
|
sl.add_entry("Second")
|
||||||
|
assert sl.move_entry(0, 1)
|
||||||
|
assert sl.entries[0].session_name == "Second"
|
||||||
|
assert sl.entries[1].session_name == "First"
|
||||||
|
|
||||||
|
def test_serialize_roundtrip(self):
|
||||||
|
"""Serialize and deserialize a setlist."""
|
||||||
|
sl = Setlist(name="Tour", description="Summer 2026")
|
||||||
|
sl.add_entry("Opening", transition=TransitionType.CUT)
|
||||||
|
sl.add_entry("Main Set", transition=TransitionType.CROSSFADE)
|
||||||
|
|
||||||
|
json_str = sl.to_json()
|
||||||
|
restored = Setlist.from_json(json_str)
|
||||||
|
assert restored.name == "Tour"
|
||||||
|
assert restored.description == "Summer 2026"
|
||||||
|
assert restored.entry_count == 2
|
||||||
|
|
||||||
|
def test_file_save_load(self, tmp_path):
|
||||||
|
"""Save and load setlist from file."""
|
||||||
|
sl = Setlist(name="File Test")
|
||||||
|
sl.add_entry("Test Session")
|
||||||
|
|
||||||
|
filepath = sl.save_to(tmp_path)
|
||||||
|
assert filepath.exists()
|
||||||
|
|
||||||
|
loaded = Setlist.load_from(filepath)
|
||||||
|
assert loaded.name == "File Test"
|
||||||
|
assert loaded.entry_count == 1
|
||||||
|
|
||||||
|
def test_progress(self):
|
||||||
|
"""Progress tracking."""
|
||||||
|
sl = Setlist(name="Test")
|
||||||
|
sl.add_entry("A")
|
||||||
|
sl.add_entry("B")
|
||||||
|
sl.add_entry("C")
|
||||||
|
assert sl.progress == (0, 3) # not active
|
||||||
|
|
||||||
|
def test_activate_empty(self, engine, tmp_sessions_dir):
|
||||||
|
"""Activating empty setlist fails."""
|
||||||
|
mgr = SessionManager(sessions_dir=tmp_sessions_dir, auto_save_enabled=False)
|
||||||
|
sl = Setlist(name="Empty")
|
||||||
|
assert not sl.activate(mgr, engine)
|
||||||
|
|
||||||
|
def test_stop(self):
|
||||||
|
"""Stopping a setlist."""
|
||||||
|
sl = Setlist(name="Test")
|
||||||
|
sl._active = True
|
||||||
|
sl._current_index = 0
|
||||||
|
sl.stop()
|
||||||
|
assert not sl.is_active
|
||||||
|
assert sl._current_index == -1
|
||||||
|
|
||||||
|
def test_clear(self):
|
||||||
|
"""Clearing a setlist."""
|
||||||
|
sl = Setlist(name="Test")
|
||||||
|
sl.add_entry("A")
|
||||||
|
sl.add_entry("B")
|
||||||
|
sl.clear()
|
||||||
|
assert sl.entry_count == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestSetlistEntry:
|
||||||
|
"""Test SetlistEntry."""
|
||||||
|
|
||||||
|
def test_defaults(self):
|
||||||
|
"""Default values."""
|
||||||
|
entry = SetlistEntry(session_name="Test")
|
||||||
|
assert entry.transition == TransitionType.CUT
|
||||||
|
assert entry.transition_duration == 2.0
|
||||||
|
assert not entry.wait_for_trigger
|
||||||
|
|
||||||
|
def test_serialize(self):
|
||||||
|
"""Serialize entry."""
|
||||||
|
entry = SetlistEntry(
|
||||||
|
session_name="My Session",
|
||||||
|
display_name="My Display",
|
||||||
|
transition=TransitionType.CROSSFADE,
|
||||||
|
transition_duration=3.5,
|
||||||
|
wait_for_trigger=True,
|
||||||
|
notes="Test note",
|
||||||
|
)
|
||||||
|
data = entry.to_dict()
|
||||||
|
assert data["transition"] == "crossfade"
|
||||||
|
assert data["transition_duration"] == 3.5
|
||||||
|
|
||||||
|
restored = SetlistEntry.from_dict(data)
|
||||||
|
assert restored.transition == TransitionType.CROSSFADE
|
||||||
|
|
||||||
|
|
||||||
|
# ── Snapshot tests ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestSnapshot:
|
||||||
|
"""Test Snapshot model."""
|
||||||
|
|
||||||
|
def test_from_engine(self, engine):
|
||||||
|
"""Capture a snapshot from engine."""
|
||||||
|
snap = Snapshot.from_engine(engine, name="Test Snap")
|
||||||
|
assert snap.metadata.name == "Test Snap"
|
||||||
|
assert len(snap.channels) == 8
|
||||||
|
assert snap.master
|
||||||
|
|
||||||
|
def test_apply_to_engine(self, engine):
|
||||||
|
"""Apply snapshot to engine."""
|
||||||
|
engine.channels[0].state.volume = -20.0
|
||||||
|
snap = Snapshot.from_engine(engine, name="Loud")
|
||||||
|
|
||||||
|
engine.channels[0].state.volume = 0.0
|
||||||
|
assert snap.apply_to(engine)
|
||||||
|
assert engine.channels[0].state.volume == -20.0
|
||||||
|
|
||||||
|
def test_serialize(self, engine):
|
||||||
|
"""Serialize snapshot."""
|
||||||
|
snap = Snapshot.from_engine(engine, name="Serial Snap", notes="test", category="verse")
|
||||||
|
data = snap.to_dict()
|
||||||
|
assert data["metadata"]["name"] == "Serial Snap"
|
||||||
|
assert data["metadata"]["notes"] == "test"
|
||||||
|
assert data["metadata"]["category"] == "verse"
|
||||||
|
|
||||||
|
restored = Snapshot.from_dict(data)
|
||||||
|
assert restored.metadata.name == "Serial Snap"
|
||||||
|
|
||||||
|
|
||||||
|
class TestSnapshotEngine:
|
||||||
|
"""Test SnapshotEngine."""
|
||||||
|
|
||||||
|
def test_capture_and_recall(self, engine):
|
||||||
|
"""Capture a snapshot and recall it."""
|
||||||
|
se = SnapshotEngine()
|
||||||
|
|
||||||
|
engine.channels[0].state.volume = -15.0
|
||||||
|
se.capture(engine, "Verse")
|
||||||
|
|
||||||
|
# Modify
|
||||||
|
engine.channels[0].state.volume = 0.0
|
||||||
|
|
||||||
|
# Recall
|
||||||
|
ok = se.recall(engine, "Verse")
|
||||||
|
assert ok
|
||||||
|
assert engine.channels[0].state.volume == -15.0
|
||||||
|
|
||||||
|
def test_list_snapshots(self, engine):
|
||||||
|
"""List snapshots."""
|
||||||
|
se = SnapshotEngine()
|
||||||
|
se.capture(engine, "Intro", category="intro")
|
||||||
|
se.capture(engine, "Chorus", category="chorus")
|
||||||
|
|
||||||
|
snaps = se.list_snapshots()
|
||||||
|
assert len(snaps) == 2
|
||||||
|
assert snaps[0]["name"] in ("Intro", "Chorus")
|
||||||
|
|
||||||
|
def test_delete(self, engine):
|
||||||
|
"""Delete a snapshot."""
|
||||||
|
se = SnapshotEngine()
|
||||||
|
se.capture(engine, "To Delete")
|
||||||
|
assert se.delete("To Delete")
|
||||||
|
assert se.snapshot_count == 0
|
||||||
|
|
||||||
|
def test_rename(self, engine):
|
||||||
|
"""Rename a snapshot."""
|
||||||
|
se = SnapshotEngine()
|
||||||
|
se.capture(engine, "Old")
|
||||||
|
assert se.rename("Old", "New")
|
||||||
|
assert se.snapshot_count == 1
|
||||||
|
assert not se.recall(engine, "Old")
|
||||||
|
assert se.recall(engine, "New")
|
||||||
|
|
||||||
|
def test_next_previous(self, engine):
|
||||||
|
"""Next and previous snapshot navigation."""
|
||||||
|
se = SnapshotEngine()
|
||||||
|
se.capture(engine, "A")
|
||||||
|
se.capture(engine, "B")
|
||||||
|
se.capture(engine, "C")
|
||||||
|
|
||||||
|
# Next
|
||||||
|
ok, name = se.next(engine)
|
||||||
|
assert ok
|
||||||
|
assert name is not None
|
||||||
|
|
||||||
|
# Previous
|
||||||
|
ok, name = se.previous(engine)
|
||||||
|
assert ok
|
||||||
|
assert name is not None
|
||||||
|
|
||||||
|
def test_clear(self, engine):
|
||||||
|
"""Clear all snapshots."""
|
||||||
|
se = SnapshotEngine()
|
||||||
|
se.capture(engine, "One")
|
||||||
|
se.capture(engine, "Two")
|
||||||
|
se.clear()
|
||||||
|
assert se.snapshot_count == 0
|
||||||
|
|
||||||
|
def test_categories(self, engine):
|
||||||
|
"""Snapshot categories."""
|
||||||
|
se = SnapshotEngine()
|
||||||
|
se.capture(engine, "Intro", category="intro")
|
||||||
|
se.capture(engine, "Verse 1", category="verse")
|
||||||
|
se.capture(engine, "Chorus", category="chorus")
|
||||||
|
|
||||||
|
cats = se.categories
|
||||||
|
assert "intro" in cats
|
||||||
|
assert "verse" in cats
|
||||||
|
assert "chorus" in cats
|
||||||
|
|
||||||
|
def test_get_by_category(self, engine):
|
||||||
|
"""Filter snapshots by category."""
|
||||||
|
se = SnapshotEngine()
|
||||||
|
se.capture(engine, "Intro", category="intro")
|
||||||
|
se.capture(engine, "Chorus", category="chorus")
|
||||||
|
|
||||||
|
intros = se.get_by_category("intro")
|
||||||
|
assert len(intros) == 1
|
||||||
|
assert intros[0].metadata.name == "Intro"
|
||||||
|
|
||||||
|
def test_persist_to_disk(self, engine, tmp_path):
|
||||||
|
"""Snapshots persist to disk and reload."""
|
||||||
|
snap_dir = tmp_path / "snapshots"
|
||||||
|
se = SnapshotEngine(storage_dir=snap_dir)
|
||||||
|
se.capture(engine, "Persistent")
|
||||||
|
|
||||||
|
# Create a new engine reading from the same dir
|
||||||
|
se2 = SnapshotEngine(storage_dir=snap_dir)
|
||||||
|
assert se2.snapshot_count == 1
|
||||||
|
assert se2.recall(engine, "Persistent")
|
||||||
|
|
||||||
|
def test_recall_nonexistent(self, engine):
|
||||||
|
"""Recalling nonexistent snapshot fails."""
|
||||||
|
se = SnapshotEngine()
|
||||||
|
assert not se.recall(engine, "No Such Snap")
|
||||||
|
|
||||||
|
def test_empty_next_prev(self, engine):
|
||||||
|
"""Next/prev on empty snapshot engine."""
|
||||||
|
se = SnapshotEngine()
|
||||||
|
ok, name = se.next(engine)
|
||||||
|
assert not ok
|
||||||
|
ok, name = se.previous(engine)
|
||||||
|
assert not ok
|
||||||
|
|
||||||
|
def test_go_to_index(self, engine):
|
||||||
|
"""Go to snapshot by index."""
|
||||||
|
se = SnapshotEngine()
|
||||||
|
se.capture(engine, "First")
|
||||||
|
se.capture(engine, "Second")
|
||||||
|
|
||||||
|
assert se.go_to(engine, 0)
|
||||||
|
assert se.go_to(engine, 1)
|
||||||
|
assert not se.go_to(engine, 99)
|
||||||
|
|
||||||
|
def test_duplicate_name(self, engine):
|
||||||
|
"""Capturing with same name overwrites by default."""
|
||||||
|
se = SnapshotEngine()
|
||||||
|
se.capture(engine, "Same")
|
||||||
|
se.capture(engine, "Same")
|
||||||
|
assert se.snapshot_count == 1
|
||||||
|
|
||||||
|
def test_no_overwrite(self, engine):
|
||||||
|
"""Capturing with overwrite=False raises on duplicate."""
|
||||||
|
se = SnapshotEngine()
|
||||||
|
se.capture(engine, "Unique")
|
||||||
|
with pytest.raises(KeyError):
|
||||||
|
se.capture(engine, "Unique", overwrite=False)
|
||||||
|
|||||||
Reference in New Issue
Block a user