Files
shawn f945196199
Lint & Validate / lint (push) Has been cancelled
P6-R2: Session manager — save/load mixer states with setlists and snapshots
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
2026-05-19 22:19:39 -04:00

594 lines
20 KiB
Python

"""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
from pathlib import Path
import pytest
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
# ── Fixtures ─────────────────────────────────────────────────────────────────
@pytest.fixture
def engine():
"""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()
@pytest.fixture
def tmp_sessions_dir():
"""Create a temporary sessions directory."""
with tempfile.TemporaryDirectory() as td:
yield Path(td)
# ── MixerSession tests ──────────────────────────────────────────────────────
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:
"""Test SessionManager."""
def test_save_and_list(self, engine, tmp_sessions_dir):
"""Save a session and list it."""
mgr = SessionManager(sessions_dir=tmp_sessions_dir, auto_save_enabled=False)
mgr.save(engine, "Test Session")
sessions = mgr.list_sessions()
assert len(sessions) == 1
assert sessions[0]["name"] == "Test Session"
def test_load_session(self, engine, tmp_sessions_dir):
"""Save and then load a session."""
mgr = SessionManager(sessions_dir=tmp_sessions_dir, auto_save_enabled=False)
# Modify engine state
engine.channels[0].state.volume = -5.0
mgr.save(engine, "Load Test")
# Reset
engine.channels[0].state.volume = 0.0
# Load and apply
ok = mgr.load_and_apply(engine, "Load Test")
assert ok
assert engine.channels[0].state.volume == -5.0
def test_delete_session(self, engine, tmp_sessions_dir):
"""Delete a session."""
mgr = SessionManager(sessions_dir=tmp_sessions_dir, auto_save_enabled=False)
mgr.save(engine, "Delete Me")
assert mgr.exists("Delete Me")
assert mgr.delete("Delete Me")
assert not mgr.exists("Delete Me")
def test_rename_session(self, engine, tmp_sessions_dir):
"""Rename a session."""
mgr = SessionManager(sessions_dir=tmp_sessions_dir, auto_save_enabled=False)
mgr.save(engine, "Old Name")
assert mgr.rename("Old Name", "New Name")
assert mgr.exists("New Name")
assert not mgr.exists("Old Name")
def test_duplicate_session(self, engine, tmp_sessions_dir):
"""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")
dup = mgr.duplicate("Original", "Copy")
assert dup is not None
assert dup.metadata.name == "Copy"
# Load copy into reset engine
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_exists(self, engine, tmp_sessions_dir):
"""Check session existence."""
mgr = SessionManager(sessions_dir=tmp_sessions_dir, auto_save_enabled=False)
assert not mgr.exists("No Such Session")
mgr.save(engine, "Exists")
assert mgr.exists("Exists")
def test_session_count(self, engine, tmp_sessions_dir):
"""Session count is accurate."""
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_list_nonexistent_dir(self):
"""Listing when sessions dir cannot be created returns empty."""
# Use a path under /dev/null which exists as a file, not a dir
mgr = SessionManager(sessions_dir=Path("/dev/null/sessions"), auto_save_enabled=False)
assert mgr.list_sessions() == []
def test_get_session_info(self, engine, tmp_sessions_dir):
"""Get session info without loading full state."""
mgr = SessionManager(sessions_dir=tmp_sessions_dir, auto_save_enabled=False)
mgr.save(engine, "Info Test")
info = mgr.get_session_info("Info Test")
assert info is not None
assert info["name"] == "Info Test"
assert info["channel_count"] == 8
def test_auto_save_disabled(self, engine, tmp_sessions_dir):
"""Auto-save can be disabled."""
mgr = SessionManager(sessions_dir=tmp_sessions_dir, auto_save_enabled=False)
mgr.set_auto_save_engine(engine)
mgr.disable_auto_save()
assert not mgr.auto_save_enabled
def test_export_import(self, engine, tmp_sessions_dir, tmp_path):
"""Export and import a session."""
mgr = SessionManager(sessions_dir=tmp_sessions_dir, auto_save_enabled=False)
mgr.save(engine, "Export Test")
export_path = mgr.export_session("Export Test", target_dir=tmp_path)
assert export_path is not None
assert export_path.exists()
# Import into a new manager
mgr2 = SessionManager(
sessions_dir=tmp_path / "imported",
auto_save_enabled=False,
)
imported = mgr2.import_session(export_path)
assert imported is not None
assert imported.metadata.name == "Export Test"
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
def test_delete_nonexistent(self, tmp_sessions_dir):
"""Deleting a nonexistent session returns False."""
mgr = SessionManager(sessions_dir=tmp_sessions_dir, auto_save_enabled=False)
assert not mgr.delete("No Such Session")
# ── Setlist tests ───────────────────────────────────────────────────────────
class TestSetlist:
"""Test Setlist model."""
def test_create_empty(self):
"""Create an empty setlist."""
sl = Setlist(name="Test Setlist")
assert sl.name == "Test Setlist"
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)