Phase 1-4: Audio stack, mixer engine, MIDI, and network API
Lint & Validate / lint (push) Has been cancelled
Lint & Validate / lint (push) Has been cancelled
P2-R1: ALSA + JACK2 low-latency config (scripts, quirks, tuning) P2-R2: Carla integration (build scripts, 8ch rack config, NAM LV2 support) P2-R3: Plugin manager, categories, blacklist, NAM model support P3-R1: Mixer DSP engine (channel strip, routing matrix, bus mgr, automation) P4-R1: MIDI engine (learn mode, clock sync, HID discovery, mapping store) P4-R2: Network API (OSC server, FastAPI REST, WebSocket, auth, rate limiter) P5-R1: Touchscreen UI evaluation + main entry point docs: Audio stack, Carla integration, MIDI support, UI evaluation tests: Full test suite (292 passing)
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
"""Test configuration for the RPi Audio Mixer test suite."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Ensure the project root is in sys.path for imports
|
||||
_project_root = Path(__file__).resolve().parent.parent
|
||||
if str(_project_root) not in sys.path:
|
||||
sys.path.insert(0, str(_project_root))
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Tests for API key authentication."""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
|
||||
from fastapi import FastAPI, Depends
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from src.network.auth import (
|
||||
APIKeyAuth,
|
||||
APIKeyHeader,
|
||||
require_api_key,
|
||||
API_KEY_HEADER,
|
||||
)
|
||||
|
||||
|
||||
class TestAPIKeyAuth:
|
||||
"""Tests for APIKeyAuth."""
|
||||
|
||||
def test_validate_correct_key(self):
|
||||
auth = APIKeyAuth(keys=["my-secret-key"])
|
||||
assert auth.validate("my-secret-key")
|
||||
|
||||
def test_validate_wrong_key(self):
|
||||
auth = APIKeyAuth(keys=["my-secret-key"])
|
||||
assert not auth.validate("wrong-key")
|
||||
|
||||
def test_validate_empty_key(self):
|
||||
auth = APIKeyAuth(keys=["my-secret-key"])
|
||||
assert not auth.validate("")
|
||||
assert not auth.validate(None)
|
||||
|
||||
def test_multiple_keys(self):
|
||||
auth = APIKeyAuth(keys=["key-a", "key-b", "key-c"])
|
||||
assert auth.validate("key-a")
|
||||
assert auth.validate("key-b")
|
||||
assert auth.validate("key-c")
|
||||
assert not auth.validate("key-d")
|
||||
assert auth.get_keys_count() == 3
|
||||
|
||||
def test_empty_keys_list(self):
|
||||
auth = APIKeyAuth(keys=[])
|
||||
assert not auth.validate("anything")
|
||||
assert auth.get_keys_count() == 0
|
||||
|
||||
def test_key_with_whitespace(self):
|
||||
auth = APIKeyAuth(keys=[" key-with-spaces "])
|
||||
assert auth.validate("key-with-spaces")
|
||||
|
||||
def test_constant_time_comparison(self):
|
||||
"""Ensure timing doesn't leak key info (basic check)."""
|
||||
auth = APIKeyAuth(keys=["a" * 32])
|
||||
# Should not raise
|
||||
for _ in range(10):
|
||||
auth.validate("b" * 32)
|
||||
auth.validate("a" * 32)
|
||||
|
||||
def test_key_from_env(self, monkeypatch):
|
||||
monkeypatch.setenv("MIXER_API_KEY", "env-secret")
|
||||
auth = APIKeyAuth()
|
||||
assert auth.validate("env-secret")
|
||||
assert not auth.validate("wrong")
|
||||
|
||||
def test_multikey_from_env(self, monkeypatch):
|
||||
monkeypatch.setenv("MIXER_API_KEY", "key1, key2,key3")
|
||||
auth = APIKeyAuth()
|
||||
assert auth.validate("key1")
|
||||
assert auth.validate("key2")
|
||||
assert auth.validate("key3")
|
||||
assert auth.get_keys_count() == 3
|
||||
|
||||
|
||||
class TestRequireAPIKey:
|
||||
"""Tests for require_api_key FastAPI dependency."""
|
||||
|
||||
@pytest.fixture
|
||||
def app(self):
|
||||
auth = APIKeyAuth(keys=["test-key"])
|
||||
app = FastAPI()
|
||||
|
||||
@app.get("/protected")
|
||||
async def protected(_: None = Depends(require_api_key(auth))):
|
||||
return {"ok": True}
|
||||
|
||||
@app.get("/public")
|
||||
async def public():
|
||||
return {"ok": True}
|
||||
|
||||
return app
|
||||
|
||||
@pytest.fixture
|
||||
def client(self, app):
|
||||
return TestClient(app)
|
||||
|
||||
def test_public_endpoint_no_key(self, client):
|
||||
resp = client.get("/public")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"ok": True}
|
||||
|
||||
def test_protected_no_key(self, client):
|
||||
resp = client.get("/protected")
|
||||
assert resp.status_code == 401
|
||||
assert "Missing" in resp.json()["detail"]
|
||||
|
||||
def test_protected_wrong_key(self, client):
|
||||
resp = client.get("/protected", headers={API_KEY_HEADER: "wrong"})
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_protected_correct_key(self, client):
|
||||
resp = client.get("/protected", headers={API_KEY_HEADER: "test-key"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"ok": True}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,977 @@
|
||||
"""Tests for the mixer DSP engine components.
|
||||
|
||||
Tests are designed to run without JACK or Carla — they validate
|
||||
the logic, state management, and parameter dispatch without
|
||||
requiring actual audio hardware.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import time
|
||||
import math
|
||||
|
||||
from src.mixer.osc_client import (
|
||||
encode_osc,
|
||||
decode_osc,
|
||||
CarlaOSCClient,
|
||||
CarlaPluginInfo,
|
||||
DEFAULT_PLUGIN_LAYOUT,
|
||||
linear_to_db,
|
||||
db_to_linear,
|
||||
freq_to_normalized,
|
||||
normalized_to_freq,
|
||||
time_ms_to_normalized,
|
||||
mix_to_pan,
|
||||
)
|
||||
from src.mixer.channel_strip import (
|
||||
ChannelStrip,
|
||||
ChannelState,
|
||||
_apply_state,
|
||||
_transform_value,
|
||||
_get_osc_commands,
|
||||
_normalize_param,
|
||||
)
|
||||
from src.mixer.routing_matrix import (
|
||||
RoutingMatrix,
|
||||
RouteNode,
|
||||
RoutingEdge,
|
||||
NodeType,
|
||||
)
|
||||
from src.mixer.bus_manager import (
|
||||
BusManager,
|
||||
AuxBus,
|
||||
SubgroupBus,
|
||||
VCAGroup,
|
||||
MasterBus,
|
||||
BusType,
|
||||
)
|
||||
from src.mixer.fader_automation import (
|
||||
FaderAutomation,
|
||||
AutomationLane,
|
||||
AutomationPoint,
|
||||
Scene,
|
||||
InterpolationMode,
|
||||
_interpolate,
|
||||
)
|
||||
from src.mixer.dsp_engine import (
|
||||
DSPEngine,
|
||||
DSPEngineConfig,
|
||||
create_default_engine,
|
||||
_make_param_key,
|
||||
_parse_param_key,
|
||||
)
|
||||
from src.midi.types import (
|
||||
ParameterType,
|
||||
ParameterCategory,
|
||||
MixerParameter,
|
||||
)
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# OSC Protocol Tests
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestOSCEncoding:
|
||||
"""Test OSC message encoding/decoding."""
|
||||
|
||||
def test_encode_float(self):
|
||||
packet = encode_osc("/test", 1.0)
|
||||
assert b"/test" in packet
|
||||
assert b",f" in packet
|
||||
|
||||
def test_encode_int(self):
|
||||
packet = encode_osc("/test", 42)
|
||||
assert b"/test" in packet
|
||||
assert b",i" in packet
|
||||
|
||||
def test_encode_string(self):
|
||||
packet = encode_osc("/test", "hello")
|
||||
assert b"/test" in packet
|
||||
assert b",s" in packet
|
||||
assert b"hello" in packet
|
||||
|
||||
def test_encode_multiple_args(self):
|
||||
packet = encode_osc("/Carla/1/set_parameter_value", 3, 0, 0.5)
|
||||
assert b"/Carla/1/set_parameter_value" in packet
|
||||
assert b",iif" in packet
|
||||
|
||||
def test_decode_float(self):
|
||||
packet = encode_osc("/test", 3.14)
|
||||
addr, args = decode_osc(packet)
|
||||
assert addr == "/test"
|
||||
assert len(args) == 1
|
||||
assert abs(args[0] - 3.14) < 0.001
|
||||
|
||||
def test_decode_int(self):
|
||||
packet = encode_osc("/test", 99)
|
||||
addr, args = decode_osc(packet)
|
||||
assert addr == "/test"
|
||||
assert args[0] == 99
|
||||
|
||||
def test_decode_string(self):
|
||||
packet = encode_osc("/test", "world")
|
||||
addr, args = decode_osc(packet)
|
||||
assert addr == "/test"
|
||||
assert args[0] == "world"
|
||||
|
||||
def test_decode_multiple(self):
|
||||
packet = encode_osc("/path", 1.0, 42, "abc")
|
||||
addr, args = decode_osc(packet)
|
||||
assert addr == "/path"
|
||||
assert len(args) == 3
|
||||
assert abs(args[0] - 1.0) < 0.001
|
||||
assert args[1] == 42
|
||||
assert args[2] == "abc"
|
||||
|
||||
def test_roundtrip(self):
|
||||
"""Encode then decode should return the same values."""
|
||||
original_addr = "/some/path"
|
||||
original_args = [0.75, 128, "test_value"]
|
||||
packet = encode_osc(original_addr, *original_args)
|
||||
addr, args = decode_osc(packet)
|
||||
assert addr == original_addr
|
||||
assert len(args) == len(original_args)
|
||||
for a, b in zip(args, original_args):
|
||||
if isinstance(a, float) and isinstance(b, float):
|
||||
assert abs(a - b) < 0.001
|
||||
else:
|
||||
assert a == b
|
||||
|
||||
|
||||
class TestCarlaOSCClient:
|
||||
"""Test the OSC client (without actual network)."""
|
||||
|
||||
def test_client_creation(self):
|
||||
client = CarlaOSCClient()
|
||||
assert client.host == "127.0.0.1"
|
||||
assert client.port == 22752
|
||||
client.close()
|
||||
|
||||
def test_client_context_manager(self):
|
||||
with CarlaOSCClient() as client:
|
||||
assert client.host == "127.0.0.1"
|
||||
|
||||
def test_stats(self):
|
||||
client = CarlaOSCClient()
|
||||
stats = client.stats
|
||||
assert stats["host"] == "127.0.0.1"
|
||||
assert stats["sends"] == 0
|
||||
assert stats["errors"] == 0
|
||||
client.close()
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# Value Conversion Tests
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestValueConversions:
|
||||
|
||||
def test_linear_to_db_silence(self):
|
||||
# With default min_db=-60.0, linear=0 returns -inf (true digital silence)
|
||||
assert linear_to_db(0.0) == float("-inf")
|
||||
|
||||
def test_linear_to_db_unity(self):
|
||||
assert abs(linear_to_db(0.5) - (-24.0)) < 1.0
|
||||
|
||||
def test_linear_to_db_max(self):
|
||||
assert abs(linear_to_db(1.0) - 12.0) < 0.1
|
||||
|
||||
def test_db_to_linear_roundtrip(self):
|
||||
# Skip -60 (floor): mathematically db_to_linear(-60)=0.0, linear_to_db(0.0)=-inf
|
||||
for db in [-40, -20, 0, 6, 12]:
|
||||
lin = db_to_linear(db)
|
||||
db2 = linear_to_db(lin)
|
||||
assert abs(db - db2) < 0.5, f"Roundtrip failed at {db} dB"
|
||||
|
||||
def test_freq_to_normalized(self):
|
||||
assert freq_to_normalized(20) < 0.01
|
||||
assert freq_to_normalized(20000) > 0.99
|
||||
assert 0.4 < freq_to_normalized(1000) < 0.6
|
||||
|
||||
def test_normalized_to_freq(self):
|
||||
for hz in [50, 200, 1000, 5000, 15000]:
|
||||
norm = freq_to_normalized(hz)
|
||||
hz2 = normalized_to_freq(norm)
|
||||
ratio = hz2 / hz
|
||||
assert 0.8 < ratio < 1.25, f"Failed at {hz} Hz: got {hz2:.0f}"
|
||||
|
||||
def test_time_ms_to_normalized(self):
|
||||
assert time_ms_to_normalized(0.1) < 0.01
|
||||
assert time_ms_to_normalized(2000) > 0.99
|
||||
assert 0.0 < time_ms_to_normalized(100) < 0.5
|
||||
|
||||
def test_mix_to_pan_center(self):
|
||||
l, r = mix_to_pan(1.0, 0.0) # mix full, pan center
|
||||
assert abs(l - r) < 0.001
|
||||
assert abs(l - 0.707) < 0.01 # cos(pi/4)
|
||||
|
||||
def test_mix_to_pan_left(self):
|
||||
l, r = mix_to_pan(1.0, -1.0)
|
||||
assert l > 0.99
|
||||
assert r < 0.01
|
||||
|
||||
def test_mix_to_pan_right(self):
|
||||
l, r = mix_to_pan(1.0, 1.0)
|
||||
assert l < 0.01
|
||||
assert r > 0.99
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# Channel Strip Tests
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestChannelStrip:
|
||||
|
||||
def test_default_state(self):
|
||||
strip = ChannelStrip(index=0)
|
||||
assert strip.index == 0
|
||||
assert strip.state.volume == 0.0
|
||||
assert strip.state.mute is False
|
||||
assert strip.state.pan == 0.0
|
||||
|
||||
def test_set_volume(self):
|
||||
strip = ChannelStrip(index=0)
|
||||
strip.set_parameter(ParameterType.VOLUME, -6.0)
|
||||
assert strip.state.volume == -6.0
|
||||
|
||||
def test_set_mute(self):
|
||||
strip = ChannelStrip(index=0)
|
||||
strip.set_parameter(ParameterType.MUTE, 1.0)
|
||||
assert strip.state.mute is True
|
||||
strip.set_parameter(ParameterType.MUTE, 0.0)
|
||||
assert strip.state.mute is False
|
||||
|
||||
def test_set_eq(self):
|
||||
strip = ChannelStrip(index=3)
|
||||
strip.set_parameter(ParameterType.EQ_LOW_FREQ, 120.0)
|
||||
strip.set_parameter(ParameterType.EQ_LOW_GAIN, 3.0)
|
||||
strip.set_parameter(ParameterType.EQ_LOW_Q, 1.5)
|
||||
assert strip.state.eq_low_freq == 120.0
|
||||
assert strip.state.eq_low_gain == 3.0
|
||||
assert strip.state.eq_low_q == 1.5
|
||||
|
||||
def test_set_compressor(self):
|
||||
strip = ChannelStrip(index=1)
|
||||
strip.set_parameter(ParameterType.COMP_THRESHOLD, -25.0)
|
||||
strip.set_parameter(ParameterType.COMP_RATIO, 4.0)
|
||||
strip.set_parameter(ParameterType.COMP_ATTACK, 5.0)
|
||||
assert strip.state.comp_threshold == -25.0
|
||||
assert strip.state.comp_ratio == 4.0
|
||||
assert strip.state.comp_attack == 5.0
|
||||
|
||||
def test_set_gate(self):
|
||||
strip = ChannelStrip(index=2)
|
||||
strip.set_parameter(ParameterType.GATE_THRESHOLD, -50.0)
|
||||
strip.set_parameter(ParameterType.GATE_RANGE, -70.0)
|
||||
assert strip.state.gate_threshold == -50.0
|
||||
assert strip.state.gate_range == -70.0
|
||||
|
||||
def test_set_fx_sends(self):
|
||||
strip = ChannelStrip(index=0)
|
||||
strip.set_parameter(ParameterType.FX_SEND_A, -10.0)
|
||||
strip.set_parameter(ParameterType.FX_SEND_B, -20.0)
|
||||
assert strip.state.fx_send_a == -10.0
|
||||
assert strip.state.fx_send_b == -20.0
|
||||
|
||||
def test_snapshot_restore(self):
|
||||
strip = ChannelStrip(index=5)
|
||||
strip.set_parameter(ParameterType.VOLUME, -12.0)
|
||||
strip.set_parameter(ParameterType.PAN, 0.5)
|
||||
|
||||
snap = strip.snapshot()
|
||||
assert snap.volume == -12.0
|
||||
assert snap.pan == 0.5
|
||||
|
||||
# Modify
|
||||
strip.set_parameter(ParameterType.VOLUME, 0.0)
|
||||
assert strip.state.volume == 0.0
|
||||
|
||||
# Restore
|
||||
strip.restore(snap, send_osc=False)
|
||||
assert strip.state.volume == -12.0
|
||||
assert strip.state.pan == 0.5
|
||||
|
||||
def test_reset(self):
|
||||
strip = ChannelStrip(index=0)
|
||||
strip.set_parameter(ParameterType.VOLUME, 6.0)
|
||||
strip.set_parameter(ParameterType.MUTE, 1.0)
|
||||
strip.reset()
|
||||
assert strip.state.volume == 0.0
|
||||
assert strip.state.mute is False
|
||||
|
||||
def test_has_dsp(self):
|
||||
strip = ChannelStrip(index=0)
|
||||
assert strip.has_dsp is False
|
||||
|
||||
plugin = CarlaPluginInfo(1, "Test Gate", "gate", 0, {"threshold": 0})
|
||||
strip.register_plugin(plugin)
|
||||
assert strip.has_dsp is True
|
||||
|
||||
def test_set_many(self):
|
||||
strip = ChannelStrip(index=0)
|
||||
strip.set_many({
|
||||
ParameterType.VOLUME: -3.0,
|
||||
ParameterType.PAN: 0.5,
|
||||
ParameterType.MUTE: 1.0,
|
||||
})
|
||||
assert strip.state.volume == -3.0
|
||||
assert strip.state.pan == 0.5
|
||||
assert strip.state.mute is True
|
||||
|
||||
def test_plugin_registration(self):
|
||||
strip = ChannelStrip(index=3)
|
||||
plugin = CarlaPluginInfo(10, "Ch4 EQ", "eq", 3, {"low_freq": 0, "low_gain": 1})
|
||||
strip.register_plugin(plugin)
|
||||
assert "eq" in strip._plugins
|
||||
assert strip._plugins["eq"].plugin_id == 10
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# Routing Matrix Tests
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestRoutingMatrix:
|
||||
|
||||
def test_add_node(self):
|
||||
rm = RoutingMatrix()
|
||||
node = rm.add_node(RouteNode("test", NodeType.CHANNEL_INPUT, "Test"))
|
||||
assert rm.get_node("test") is node
|
||||
|
||||
def test_connect(self):
|
||||
rm = RoutingMatrix()
|
||||
src = rm.add_node(RouteNode("src", NodeType.SYSTEM_INPUT))
|
||||
dst = rm.add_node(RouteNode("dst", NodeType.CHANNEL_INPUT))
|
||||
edge = rm.connect("src", "dst", gain_db=-6.0, apply_jack=False)
|
||||
assert edge is not None
|
||||
assert edge.gain_db == -6.0
|
||||
|
||||
def test_connect_nonexistent(self):
|
||||
rm = RoutingMatrix()
|
||||
edge = rm.connect("a", "b")
|
||||
assert edge is None
|
||||
|
||||
def test_disconnect(self):
|
||||
rm = RoutingMatrix()
|
||||
rm.add_node(RouteNode("src", NodeType.SYSTEM_INPUT))
|
||||
rm.add_node(RouteNode("dst", NodeType.CHANNEL_INPUT))
|
||||
rm.connect("src", "dst", apply_jack=False)
|
||||
assert len(rm._edges) == 1
|
||||
assert rm.disconnect("src", "dst", apply_jack=False)
|
||||
assert len(rm._edges) == 0
|
||||
|
||||
def test_set_gain(self):
|
||||
rm = RoutingMatrix()
|
||||
rm.add_node(RouteNode("src", NodeType.SYSTEM_INPUT))
|
||||
rm.add_node(RouteNode("dst", NodeType.CHANNEL_INPUT))
|
||||
rm.connect("src", "dst", apply_jack=False)
|
||||
assert rm.set_gain("src", "dst", -12.0)
|
||||
edge = rm.get_edges("src")[0]
|
||||
assert edge.gain_db == -12.0
|
||||
|
||||
def test_set_mute(self):
|
||||
rm = RoutingMatrix()
|
||||
rm.add_node(RouteNode("src", NodeType.SYSTEM_INPUT))
|
||||
rm.add_node(RouteNode("dst", NodeType.CHANNEL_INPUT))
|
||||
rm.connect("src", "dst", apply_jack=False)
|
||||
rm.set_mute("src", "dst", True)
|
||||
edge = rm.get_edges("src")[0]
|
||||
assert edge.muted is True
|
||||
|
||||
def test_solo_logic(self):
|
||||
rm = RoutingMatrix()
|
||||
src1 = rm.add_node(RouteNode("src1", NodeType.CHANNEL_INPUT))
|
||||
src2 = rm.add_node(RouteNode("src2", NodeType.CHANNEL_INPUT))
|
||||
dst = rm.add_node(RouteNode("dst", NodeType.MASTER_INPUT))
|
||||
rm.connect("src1", "dst", apply_jack=False)
|
||||
rm.connect("src2", "dst", apply_jack=False)
|
||||
|
||||
rm.set_solo("src1", True)
|
||||
edges = rm.get_edges()
|
||||
edge1 = [e for e in edges if e.source.node_id == "src1"][0]
|
||||
edge2 = [e for e in edges if e.source.node_id == "src2"][0]
|
||||
assert edge1.soloed is True
|
||||
assert edge2.soloed is False
|
||||
|
||||
rm.clear_solo()
|
||||
edges = rm.get_edges()
|
||||
assert all(not e.soloed for e in edges)
|
||||
|
||||
def test_remove_node_cleans_edges(self):
|
||||
rm = RoutingMatrix()
|
||||
rm.add_node(RouteNode("src", NodeType.SYSTEM_INPUT))
|
||||
rm.add_node(RouteNode("dst", NodeType.CHANNEL_INPUT))
|
||||
rm.connect("src", "dst", apply_jack=False)
|
||||
assert len(rm._edges) == 1
|
||||
rm.remove_node("src")
|
||||
assert len(rm._edges) == 0
|
||||
assert rm.get_node("src") is None
|
||||
|
||||
def test_build_default_8ch(self):
|
||||
rm = RoutingMatrix()
|
||||
rm.build_default_8ch_matrix()
|
||||
# Should have system inputs (8), system outputs (2), channel nodes (16),
|
||||
# aux nodes (8: 4 buses × 2), subgroups (2), master (3)
|
||||
assert len(rm._nodes) >= 8 + 2
|
||||
# Should have connections: 8 sys_in → channels, 8 channels → master, 2 master → sys_out
|
||||
assert len(rm._edges) >= 8 + 8 + 2
|
||||
|
||||
def test_serialization_roundtrip(self):
|
||||
rm = RoutingMatrix()
|
||||
rm.add_node(RouteNode("src", NodeType.SYSTEM_INPUT, "Input"))
|
||||
rm.add_node(RouteNode("dst", NodeType.CHANNEL_INPUT, "Channel"))
|
||||
rm.connect("src", "dst", gain_db=-3.0, apply_jack=False)
|
||||
|
||||
data = rm.to_dict()
|
||||
rm2 = RoutingMatrix()
|
||||
rm2.from_dict(data)
|
||||
|
||||
assert len(rm2._nodes) == 2
|
||||
assert len(rm2._edges) == 1
|
||||
assert rm2.get_node("src") is not None
|
||||
assert rm2._edges[0].gain_db == -3.0
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# Bus Manager Tests
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestBusManager:
|
||||
|
||||
def test_default_initialization(self):
|
||||
bm = BusManager(num_channels=16, num_aux=4, num_subgroups=2, num_vca=2)
|
||||
assert len(bm.aux_buses) == 4
|
||||
assert len(bm.subgroups) == 2
|
||||
assert len(bm.vca_groups) == 2
|
||||
assert bm.master.volume_db == 0.0
|
||||
|
||||
def test_aux_send(self):
|
||||
bm = BusManager(num_channels=8, num_aux=4)
|
||||
bm.set_aux_send(0, 3, -10.0)
|
||||
aux = bm.get_aux(0)
|
||||
assert aux.get_send(3) == -10.0
|
||||
|
||||
def test_aux_return(self):
|
||||
bm = BusManager(num_channels=8, num_aux=4)
|
||||
bm.set_aux_return(0, -5.0)
|
||||
assert bm.get_aux(0).return_gain_db == -5.0
|
||||
|
||||
def test_aux_mute(self):
|
||||
bm = BusManager(num_channels=8, num_aux=4)
|
||||
bm.set_aux_mute(1, True)
|
||||
assert bm.get_aux(1).muted is True
|
||||
|
||||
def test_aux_out_of_range(self):
|
||||
bm = BusManager(num_channels=8, num_aux=2)
|
||||
assert bm.get_aux(5) is None
|
||||
|
||||
def test_subgroup_membership(self):
|
||||
bm = BusManager(num_channels=8, num_aux=2, num_subgroups=2)
|
||||
assert bm.subgroup_add_channel(0, 3)
|
||||
assert 3 in bm.subgroups[0].members
|
||||
assert bm.subgroup_remove_channel(0, 3)
|
||||
assert 3 not in bm.subgroups[0].members
|
||||
|
||||
def test_vca_membership(self):
|
||||
bm = BusManager(num_channels=8, num_aux=2, num_vca=2)
|
||||
bm.vca_add_channel(0, 5)
|
||||
bm.vca_add_channel(1, 5)
|
||||
vcas = bm.get_channel_vcas(5)
|
||||
assert len(vcas) == 2
|
||||
|
||||
def test_vca_offset(self):
|
||||
bm = BusManager(num_channels=8, num_vca=2)
|
||||
bm.vca_add_channel(0, 2)
|
||||
vca = bm.get_vca(0)
|
||||
vca.master_db = -5.0
|
||||
assert bm.get_channel_vca_offset(2) == -5.0
|
||||
|
||||
def test_vca_multiple_offset(self):
|
||||
bm = BusManager(num_channels=8, num_vca=2)
|
||||
bm.vca_add_channel(0, 2)
|
||||
bm.vca_add_channel(1, 2)
|
||||
bm.get_vca(0).master_db = -3.0
|
||||
bm.get_vca(1).master_db = -2.0
|
||||
assert bm.get_channel_vca_offset(2) == -5.0
|
||||
|
||||
def test_vca_muted_offset(self):
|
||||
bm = BusManager(num_channels=8, num_vca=2)
|
||||
bm.vca_add_channel(0, 2)
|
||||
bm.get_vca(0).master_db = -5.0
|
||||
bm.get_vca(0).muted = True
|
||||
assert bm.get_channel_vca_offset(2) == 0.0 # muted VCA doesn't contribute
|
||||
|
||||
def test_master_operations(self):
|
||||
bm = BusManager()
|
||||
bm.set_master_volume(-10.0)
|
||||
bm.set_master_mute(True)
|
||||
bm.set_master_dim(True)
|
||||
assert bm.master.volume_db == -10.0
|
||||
assert bm.master.muted is True
|
||||
assert bm.master.dim_active is True
|
||||
# Effective gain with mute = 0
|
||||
assert bm.master.effective_gain == 0.0
|
||||
|
||||
def test_master_dim_gain(self):
|
||||
bm = BusManager()
|
||||
bm.master.dim_db = -20.0
|
||||
bm.master.dim_active = True
|
||||
# -20 dB dim
|
||||
assert abs(bm.master.effective_gain - 0.1) < 0.001
|
||||
|
||||
def test_serialization_roundtrip(self):
|
||||
bm = BusManager(num_channels=4, num_aux=2, num_subgroups=1, num_vca=1)
|
||||
bm.set_aux_send(0, 2, -6.0)
|
||||
bm.subgroup_add_channel(0, 1)
|
||||
bm.vca_add_channel(0, 0)
|
||||
bm.get_vca(0).master_db = -3.0
|
||||
bm.set_master_volume(-12.0)
|
||||
|
||||
data = bm.to_dict()
|
||||
bm2 = BusManager(num_channels=4, num_aux=2, num_subgroups=1, num_vca=1)
|
||||
bm2.from_dict(data)
|
||||
|
||||
assert bm2.get_aux(0).get_send(2) == -6.0
|
||||
assert 1 in bm2.subgroups[0].members
|
||||
assert 0 in bm2.vca_groups[0].members
|
||||
assert bm2.master.volume_db == -12.0
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# Fader Automation Tests
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestInterpolation:
|
||||
|
||||
def test_linear(self):
|
||||
assert _interpolate(0.0, 0, 10, InterpolationMode.LINEAR) == 0
|
||||
assert _interpolate(0.5, 0, 10, InterpolationMode.LINEAR) == 5
|
||||
assert _interpolate(1.0, 0, 10, InterpolationMode.LINEAR) == 10
|
||||
|
||||
def test_s_curve(self):
|
||||
assert _interpolate(0.0, 0, 10, InterpolationMode.S_CURVE) == 0
|
||||
assert _interpolate(0.5, 0, 10, InterpolationMode.S_CURVE) == 5 # smoothstep at 0.5 = 0.5
|
||||
assert _interpolate(1.0, 0, 10, InterpolationMode.S_CURVE) == 10
|
||||
|
||||
def test_logarithmic(self):
|
||||
v = _interpolate(0.5, 1, 100, InterpolationMode.LOGARITHMIC)
|
||||
assert 5 < v < 15 # roughly sqrt(100) = 10
|
||||
|
||||
def test_clamp(self):
|
||||
assert _interpolate(-0.5, 0, 10, InterpolationMode.LINEAR) == 0
|
||||
assert _interpolate(1.5, 0, 10, InterpolationMode.LINEAR) == 10
|
||||
|
||||
|
||||
class TestAutomationLane:
|
||||
|
||||
def test_add_point_sorted(self):
|
||||
lane = AutomationLane("test")
|
||||
lane.add_point(2.0, 0.5)
|
||||
lane.add_point(1.0, 0.2)
|
||||
lane.add_point(3.0, 0.8)
|
||||
assert [p.time_sec for p in lane.points] == [1.0, 2.0, 3.0]
|
||||
|
||||
def test_get_value_at_exact(self):
|
||||
lane = AutomationLane("test")
|
||||
lane.add_point(1.0, 0.2)
|
||||
lane.add_point(2.0, 0.8)
|
||||
assert abs(lane.get_value_at(1.5) - 0.5) < 0.01
|
||||
|
||||
def test_get_value_at_before(self):
|
||||
lane = AutomationLane("test")
|
||||
lane.add_point(1.0, 0.2)
|
||||
assert abs(lane.get_value_at(0.0) - 0.2) < 0.01
|
||||
|
||||
def test_get_value_at_after(self):
|
||||
lane = AutomationLane("test")
|
||||
lane.add_point(1.0, 0.2)
|
||||
lane.add_point(2.0, 0.8)
|
||||
assert abs(lane.get_value_at(3.0) - 0.8) < 0.01
|
||||
|
||||
def test_get_value_empty(self):
|
||||
lane = AutomationLane("test")
|
||||
assert lane.get_value_at(1.0, default=-1.0) == -1.0
|
||||
|
||||
def test_clone(self):
|
||||
lane = AutomationLane("test")
|
||||
lane.add_point(1.0, 0.5)
|
||||
clone = lane.clone()
|
||||
clone.points[0].value = 0.9
|
||||
assert lane.points[0].value == 0.5 # original unchanged
|
||||
|
||||
|
||||
class TestFaderAutomation:
|
||||
|
||||
def test_recording(self):
|
||||
fa = FaderAutomation()
|
||||
fa.start_recording()
|
||||
assert fa.is_recording
|
||||
|
||||
time.sleep(0.01)
|
||||
fa.record("test_param", 0.5)
|
||||
fa.stop_recording()
|
||||
assert not fa.is_recording
|
||||
|
||||
assert "test_param" in fa._lanes
|
||||
assert len(fa._lanes["test_param"].points) > 0
|
||||
|
||||
def test_not_recording_when_stopped(self):
|
||||
fa = FaderAutomation()
|
||||
fa.record("test", 0.5)
|
||||
assert "test" not in fa._lanes
|
||||
|
||||
def test_playback(self):
|
||||
fa = FaderAutomation()
|
||||
fa.start_recording()
|
||||
fa.record("vol", 0.0)
|
||||
time.sleep(0.02)
|
||||
fa.record("vol", 1.0)
|
||||
fa.stop_recording()
|
||||
|
||||
lane = fa._lanes["vol"]
|
||||
assert len(lane.points) == 2
|
||||
|
||||
def test_scene_save_load(self):
|
||||
fa = FaderAutomation()
|
||||
scene = fa.save_scene("TestScene", {0: {"volume": -6.0}}, {}, {})
|
||||
assert "TestScene" in fa.list_scenes()
|
||||
|
||||
loaded = fa.load_scene("TestScene")
|
||||
assert loaded.name == "TestScene"
|
||||
|
||||
def test_scene_delete(self):
|
||||
fa = FaderAutomation()
|
||||
fa.save_scene("DeleteMe", {}, {}, {})
|
||||
assert fa.delete_scene("DeleteMe")
|
||||
assert "DeleteMe" not in fa.list_scenes()
|
||||
|
||||
def test_scene_recall(self):
|
||||
fa = FaderAutomation()
|
||||
fa.start_recording()
|
||||
fa.record("param", 0.25)
|
||||
fa.stop_recording()
|
||||
|
||||
fa.save_scene("S1", {0: {"volume": -6.0}}, {}, {})
|
||||
fa.recall_scene("S1")
|
||||
assert fa._current_scene == "S1"
|
||||
|
||||
def test_crossfade(self):
|
||||
fa = FaderAutomation()
|
||||
fa.start_recording()
|
||||
fa.record("param", 0.0)
|
||||
fa.stop_recording()
|
||||
|
||||
fa.save_scene("Target", {}, {}, {})
|
||||
|
||||
# Start very short crossfade
|
||||
result = fa.crossfade_to("Target", 0.05)
|
||||
assert result is True
|
||||
assert fa._crossfade_active
|
||||
|
||||
# Let it complete
|
||||
time.sleep(0.1)
|
||||
fa.update_crossfade()
|
||||
assert not fa._crossfade_active
|
||||
|
||||
def test_crossfade_missing_scene(self):
|
||||
fa = FaderAutomation()
|
||||
assert fa.crossfade_to("Nonexistent", 1.0) is False
|
||||
|
||||
def test_cancel_crossfade(self):
|
||||
fa = FaderAutomation()
|
||||
fa.start_recording()
|
||||
fa.record("param", 0.0)
|
||||
fa.stop_recording()
|
||||
fa.save_scene("Target", {}, {}, {})
|
||||
|
||||
fa.crossfade_to("Target", 10.0)
|
||||
assert fa._crossfade_active
|
||||
fa.cancel_crossfade()
|
||||
assert not fa._crossfade_active
|
||||
|
||||
def test_seek(self):
|
||||
fa = FaderAutomation()
|
||||
fa.start_recording()
|
||||
fa.record("param", 0.0)
|
||||
time.sleep(0.02)
|
||||
fa.record("param", 1.0)
|
||||
fa.stop_recording()
|
||||
|
||||
fa.start_playback()
|
||||
fa.seek(1000.0) # seek way past
|
||||
val = fa.get_value("param")
|
||||
assert abs(val - 1.0) < 0.01
|
||||
|
||||
def test_get_duration(self):
|
||||
fa = FaderAutomation()
|
||||
fa.start_recording()
|
||||
fa.record("a", 0.0)
|
||||
time.sleep(0.05)
|
||||
fa.record("a", 0.5)
|
||||
time.sleep(0.05)
|
||||
fa.record("b", 1.0)
|
||||
fa.stop_recording()
|
||||
|
||||
duration = fa.get_duration()
|
||||
assert duration > 0.08 # at least 100ms total
|
||||
|
||||
def test_serialization_roundtrip(self):
|
||||
fa = FaderAutomation()
|
||||
fa.start_recording()
|
||||
fa.record("param1", 0.3)
|
||||
fa.stop_recording()
|
||||
fa.save_scene("Test", {}, {}, {})
|
||||
|
||||
data = fa.to_dict()
|
||||
fa2 = FaderAutomation()
|
||||
fa2.from_dict(data)
|
||||
|
||||
assert len(fa2._lanes) == 1
|
||||
assert "Test" in fa2.list_scenes()
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# DSP Engine Tests
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestDSPEngine:
|
||||
|
||||
def test_create_default_engine(self):
|
||||
engine = create_default_engine(num_channels=8, osc_enabled=False)
|
||||
assert len(engine.channels) == 8
|
||||
assert engine.config.num_aux == 4
|
||||
assert not engine.running
|
||||
|
||||
def test_start_stop(self):
|
||||
engine = create_default_engine(num_channels=4, osc_enabled=False)
|
||||
engine.start(connect_osc=False)
|
||||
assert engine.running
|
||||
engine.stop()
|
||||
assert not engine.running
|
||||
|
||||
def test_handle_channel_volume(self):
|
||||
engine = create_default_engine(num_channels=4, osc_enabled=False)
|
||||
engine.start(connect_osc=False)
|
||||
|
||||
param = MixerParameter(
|
||||
param_type=ParameterType.VOLUME,
|
||||
category=ParameterCategory.CHANNEL,
|
||||
channel=2,
|
||||
)
|
||||
engine.handle_parameter(param, -12.0)
|
||||
assert engine.channels[2].state.volume == -12.0
|
||||
|
||||
def test_handle_mute(self):
|
||||
engine = create_default_engine(num_channels=4, osc_enabled=False)
|
||||
engine.start(connect_osc=False)
|
||||
|
||||
param = MixerParameter(
|
||||
param_type=ParameterType.MUTE,
|
||||
category=ParameterCategory.CHANNEL,
|
||||
channel=0,
|
||||
)
|
||||
engine.handle_parameter(param, 1.0)
|
||||
assert engine.channels[0].state.mute is True
|
||||
|
||||
def test_handle_master_volume(self):
|
||||
engine = create_default_engine(num_channels=4, osc_enabled=False)
|
||||
engine.start(connect_osc=False)
|
||||
|
||||
param = MixerParameter(
|
||||
param_type=ParameterType.MASTER_VOLUME,
|
||||
category=ParameterCategory.MASTER,
|
||||
channel=-1,
|
||||
)
|
||||
engine.handle_parameter(param, -20.0)
|
||||
assert engine.buses.master.volume_db == -20.0
|
||||
|
||||
def test_handle_fx_return(self):
|
||||
engine = create_default_engine(num_channels=4, osc_enabled=False)
|
||||
engine.start(connect_osc=False)
|
||||
|
||||
param = MixerParameter(
|
||||
param_type=ParameterType.FX_RETURN_A,
|
||||
category=ParameterCategory.FX,
|
||||
channel=-1,
|
||||
)
|
||||
engine.handle_parameter(param, -6.0)
|
||||
assert engine.buses.get_aux(0).return_gain_db == -6.0
|
||||
|
||||
def test_handle_eq_params(self):
|
||||
engine = create_default_engine(num_channels=4, osc_enabled=False)
|
||||
engine.start(connect_osc=False)
|
||||
|
||||
param = MixerParameter(
|
||||
param_type=ParameterType.EQ_MID_GAIN,
|
||||
category=ParameterCategory.CHANNEL,
|
||||
channel=1,
|
||||
)
|
||||
engine.handle_parameter(param, 4.0)
|
||||
assert engine.channels[1].state.eq_mid_gain == 4.0
|
||||
|
||||
def test_snapshot_save_load(self):
|
||||
engine = create_default_engine(num_channels=4, osc_enabled=False)
|
||||
engine.start(connect_osc=False)
|
||||
|
||||
# Set some state
|
||||
engine.channels[0].set_parameter(ParameterType.VOLUME, -6.0)
|
||||
engine.buses.set_master_volume(-3.0)
|
||||
|
||||
# Save
|
||||
engine.save_snapshot("test_snap")
|
||||
|
||||
# Modify
|
||||
engine.channels[0].set_parameter(ParameterType.VOLUME, 0.0)
|
||||
engine.buses.set_master_volume(0.0)
|
||||
|
||||
# Load
|
||||
assert engine.load_snapshot("test_snap")
|
||||
assert engine.channels[0].state.volume == -6.0
|
||||
assert engine.buses.master.volume_db == -3.0
|
||||
|
||||
def test_param_key_helpers(self):
|
||||
key = _make_param_key(ParameterCategory.CHANNEL, 3, ParameterType.VOLUME)
|
||||
cat, ch, pt = _parse_param_key(key)
|
||||
assert cat == ParameterCategory.CHANNEL
|
||||
assert ch == 3
|
||||
assert pt == ParameterType.VOLUME
|
||||
|
||||
def test_param_key_master(self):
|
||||
key = _make_param_key(ParameterCategory.MASTER, -1, ParameterType.MASTER_VOLUME)
|
||||
cat, ch, pt = _parse_param_key(key)
|
||||
assert cat == ParameterCategory.MASTER
|
||||
assert ch == -1
|
||||
assert pt == ParameterType.MASTER_VOLUME
|
||||
|
||||
def test_stats(self):
|
||||
engine = create_default_engine(num_channels=4, osc_enabled=False)
|
||||
engine.start(connect_osc=False)
|
||||
stats = engine.stats
|
||||
assert stats["running"]
|
||||
assert stats["num_channels"] == 4
|
||||
assert not stats["osc_connected"]
|
||||
|
||||
def test_to_dict_from_dict(self):
|
||||
engine = create_default_engine(num_channels=4, osc_enabled=False)
|
||||
engine.start(connect_osc=False)
|
||||
engine.channels[0].set_parameter(ParameterType.VOLUME, -10.0)
|
||||
|
||||
data = engine.to_dict()
|
||||
engine2 = create_default_engine(num_channels=4, osc_enabled=False)
|
||||
engine2.from_dict(data)
|
||||
|
||||
assert engine2.channels[0].state.volume == -10.0
|
||||
|
||||
def test_next_prev_scene(self):
|
||||
engine = create_default_engine(num_channels=4, osc_enabled=False)
|
||||
engine.start(connect_osc=False)
|
||||
|
||||
engine.save_snapshot("A")
|
||||
engine.save_snapshot("B")
|
||||
engine.save_snapshot("C")
|
||||
|
||||
assert engine.load_snapshot("B")
|
||||
assert engine.automation._current_scene == "B"
|
||||
|
||||
# Bound to the scenes
|
||||
assert engine.next_scene() # goes to C
|
||||
assert engine.next_scene() # wraps to A
|
||||
assert engine.prev_scene() # goes to C
|
||||
|
||||
def test_automation_recording_handling(self):
|
||||
engine = create_default_engine(num_channels=4, osc_enabled=False)
|
||||
engine.start(connect_osc=False)
|
||||
engine.automation.start_recording()
|
||||
|
||||
param = MixerParameter(
|
||||
param_type=ParameterType.VOLUME,
|
||||
category=ParameterCategory.CHANNEL,
|
||||
channel=0,
|
||||
)
|
||||
engine.handle_parameter(param, -5.0)
|
||||
|
||||
key = _make_param_key(ParameterCategory.CHANNEL, 0, ParameterType.VOLUME)
|
||||
assert key in engine.automation._lanes
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# Integration Tests
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestIntegration:
|
||||
|
||||
def test_full_signal_chain_state(self):
|
||||
"""Test that the full signal chain can be configured through the engine."""
|
||||
engine = create_default_engine(num_channels=16, osc_enabled=False)
|
||||
engine.start(connect_osc=False)
|
||||
|
||||
# Configure channel 0
|
||||
params_to_set = [
|
||||
(ParameterType.GAIN, 20.0),
|
||||
(ParameterType.EQ_LOW_FREQ, 80.0),
|
||||
(ParameterType.EQ_LOW_GAIN, 3.0),
|
||||
(ParameterType.EQ_MID_FREQ, 2500.0),
|
||||
(ParameterType.EQ_MID_GAIN, -2.0),
|
||||
(ParameterType.EQ_HIGH_FREQ, 8000.0),
|
||||
(ParameterType.EQ_HIGH_GAIN, 1.5),
|
||||
(ParameterType.COMP_THRESHOLD, -18.0),
|
||||
(ParameterType.COMP_RATIO, 3.0),
|
||||
(ParameterType.GATE_THRESHOLD, -45.0),
|
||||
(ParameterType.FX_SEND_A, -8.0),
|
||||
(ParameterType.VOLUME, -3.0),
|
||||
(ParameterType.PAN, 0.2),
|
||||
]
|
||||
for pt, val in params_to_set:
|
||||
param = MixerParameter(pt, ParameterCategory.CHANNEL, 0)
|
||||
engine.handle_parameter(param, val)
|
||||
|
||||
ch0 = engine.channels[0]
|
||||
assert ch0.state.gain == 20.0
|
||||
assert ch0.state.eq_low_freq == 80.0
|
||||
assert ch0.state.comp_threshold == -18.0
|
||||
assert ch0.state.volume == -3.0
|
||||
|
||||
def test_multiple_channels_independent(self):
|
||||
"""Test that channels maintain independent state."""
|
||||
engine = create_default_engine(num_channels=8, osc_enabled=False)
|
||||
engine.start(connect_osc=False)
|
||||
|
||||
# Set different volumes on different channels
|
||||
for ch, vol in [(0, -6.0), (3, 0.0), (7, 6.0)]:
|
||||
param = MixerParameter(ParameterType.VOLUME, ParameterCategory.CHANNEL, ch)
|
||||
engine.handle_parameter(param, vol)
|
||||
|
||||
assert engine.channels[0].state.volume == -6.0
|
||||
assert engine.channels[3].state.volume == 0.0
|
||||
assert engine.channels[7].state.volume == 6.0
|
||||
assert engine.channels[1].state.volume == 0.0 # unchanged
|
||||
|
||||
def test_bus_routing_persistence(self):
|
||||
"""Test that bus state persists across snapshot save/load."""
|
||||
engine = create_default_engine(num_channels=8, osc_enabled=False)
|
||||
engine.start(connect_osc=False)
|
||||
|
||||
# Set up complex bus state
|
||||
engine.buses.set_aux_send(0, 1, -12.0)
|
||||
engine.buses.set_aux_send(2, 3, -4.0)
|
||||
engine.buses.subgroup_add_channel(0, 0)
|
||||
engine.buses.subgroup_add_channel(0, 4)
|
||||
engine.buses.vca_add_channel(0, 2)
|
||||
engine.buses.get_vca(0).master_db = -5.0
|
||||
|
||||
# Save and verify restore
|
||||
snap = engine.save_snapshot("complex")
|
||||
engine.load_snapshot("complex")
|
||||
|
||||
assert engine.buses.get_aux(0).get_send(1) == -12.0
|
||||
assert engine.buses.get_aux(2).get_send(3) == -4.0
|
||||
assert 0 in engine.buses.subgroups[0].members
|
||||
assert 2 in engine.buses.vca_groups[0].members
|
||||
@@ -0,0 +1,173 @@
|
||||
"""Tests for mapping persistence."""
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from src.midi.types import MIDIMapping, MIDIMessageType, ParameterType
|
||||
from src.midi.mapping_store import (
|
||||
mapping_to_dict,
|
||||
mapping_from_dict,
|
||||
save_mappings,
|
||||
load_mappings,
|
||||
list_sessions,
|
||||
delete_session,
|
||||
DEFAULT_MAPPINGS_DIR,
|
||||
DEFAULT_SESSION_NAME,
|
||||
)
|
||||
|
||||
|
||||
class TestSerialisation:
|
||||
def test_roundtrip_cc_mapping(self):
|
||||
original = MIDIMapping(
|
||||
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||
channel=0,
|
||||
controller=7,
|
||||
param_type=ParameterType.VOLUME,
|
||||
param_channel=0,
|
||||
invert=True,
|
||||
curve="logarithmic",
|
||||
label="CH0 Vol",
|
||||
)
|
||||
d = mapping_to_dict(original)
|
||||
restored = mapping_from_dict(d)
|
||||
|
||||
assert restored.msg_type == original.msg_type
|
||||
assert restored.channel == original.channel
|
||||
assert restored.controller == original.controller
|
||||
assert restored.param_type == original.param_type
|
||||
assert restored.param_channel == original.param_channel
|
||||
assert restored.invert == original.invert
|
||||
assert restored.curve == original.curve
|
||||
assert restored.label == original.label
|
||||
|
||||
def test_roundtrip_nrpn_mapping(self):
|
||||
original = MIDIMapping(
|
||||
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||
channel=1,
|
||||
is_nrpn=True,
|
||||
nrpn_number=258,
|
||||
param_type=ParameterType.COMP_THRESHOLD,
|
||||
param_channel=3,
|
||||
)
|
||||
d = mapping_to_dict(original)
|
||||
restored = mapping_from_dict(d)
|
||||
|
||||
assert restored.is_nrpn
|
||||
assert restored.nrpn_number == 258
|
||||
|
||||
def test_roundtrip_with_device(self):
|
||||
original = MIDIMapping(
|
||||
controller=10,
|
||||
source_device="X-Touch",
|
||||
)
|
||||
d = mapping_to_dict(original)
|
||||
restored = mapping_from_dict(d)
|
||||
|
||||
assert restored.source_device == "X-Touch"
|
||||
|
||||
def test_roundtrip_disabled(self):
|
||||
original = MIDIMapping(enabled=False)
|
||||
d = mapping_to_dict(original)
|
||||
restored = mapping_from_dict(d)
|
||||
assert not restored.enabled
|
||||
|
||||
def test_dict_format_has_expected_keys(self):
|
||||
m = MIDIMapping()
|
||||
d = mapping_to_dict(m)
|
||||
|
||||
assert "midi" in d
|
||||
assert "target" in d
|
||||
assert "value" in d
|
||||
assert "meta" in d
|
||||
assert d["midi"]["type"] == "CONTROL_CHANGE"
|
||||
assert d["target"]["parameter"] == "volume"
|
||||
|
||||
|
||||
class TestFileIO:
|
||||
@pytest.fixture(autouse=True)
|
||||
def temp_dir(self, monkeypatch):
|
||||
"""Redirect mappings to a temp directory."""
|
||||
td = tempfile.mkdtemp(prefix="rpi-mixer-test-")
|
||||
monkeypatch.setattr(
|
||||
"src.midi.mapping_store.DEFAULT_MAPPINGS_DIR",
|
||||
Path(td),
|
||||
)
|
||||
yield td
|
||||
# Cleanup
|
||||
import shutil
|
||||
shutil.rmtree(td, ignore_errors=True)
|
||||
|
||||
def test_save_and_load(self):
|
||||
mappings = [
|
||||
MIDIMapping(
|
||||
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||
channel=0,
|
||||
controller=7,
|
||||
param_type=ParameterType.VOLUME,
|
||||
param_channel=0,
|
||||
label="CH0 Vol",
|
||||
),
|
||||
MIDIMapping(
|
||||
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||
channel=0,
|
||||
controller=10,
|
||||
param_type=ParameterType.PAN,
|
||||
param_channel=0,
|
||||
label="CH0 Pan",
|
||||
),
|
||||
]
|
||||
|
||||
path = save_mappings(mappings, DEFAULT_SESSION_NAME)
|
||||
assert path.exists()
|
||||
|
||||
loaded = load_mappings(DEFAULT_SESSION_NAME)
|
||||
assert len(loaded) == 2
|
||||
assert loaded[0].controller == 7
|
||||
assert loaded[1].controller == 10
|
||||
assert loaded[0].label == "CH0 Vol"
|
||||
assert loaded[1].label == "CH0 Pan"
|
||||
|
||||
def test_missing_session_returns_empty(self):
|
||||
loaded = load_mappings("nonexistent-session")
|
||||
assert loaded == []
|
||||
|
||||
def test_list_sessions(self, temp_dir):
|
||||
save_mappings([MIDIMapping()], "session-a")
|
||||
save_mappings([MIDIMapping()], "session-b")
|
||||
|
||||
sessions = list_sessions()
|
||||
assert "session-a" in sessions
|
||||
assert "session-b" in sessions
|
||||
|
||||
def test_delete_session(self):
|
||||
save_mappings([MIDIMapping()], "to-delete")
|
||||
assert delete_session("to-delete")
|
||||
assert load_mappings("to-delete") == []
|
||||
|
||||
def test_delete_nonexistent_session(self):
|
||||
assert not delete_session("never-existed")
|
||||
|
||||
def test_save_empty_mappings(self):
|
||||
path = save_mappings([], DEFAULT_SESSION_NAME)
|
||||
assert path.exists()
|
||||
|
||||
loaded = load_mappings(DEFAULT_SESSION_NAME)
|
||||
assert loaded == []
|
||||
|
||||
def test_json_is_valid(self):
|
||||
"""Saved JSON should be parseable by standard json module."""
|
||||
mappings = [
|
||||
MIDIMapping(controller=7, param_type=ParameterType.VOLUME, param_channel=0),
|
||||
MIDIMapping(controller=10, param_type=ParameterType.PAN, param_channel=1, invert=True),
|
||||
]
|
||||
path = save_mappings(mappings, DEFAULT_SESSION_NAME)
|
||||
|
||||
with open(path) as fh:
|
||||
doc = json.load(fh)
|
||||
|
||||
assert doc["version"] == 1
|
||||
assert len(doc["mappings"]) == 2
|
||||
assert doc["mappings"][0]["target"]["parameter"] == "volume"
|
||||
assert doc["mappings"][1]["value"]["invert"] is True
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Tests for MIDI clock sync."""
|
||||
|
||||
import time
|
||||
import pytest
|
||||
from src.midi.midi_clock import MIDIClock, PPQN
|
||||
|
||||
|
||||
class TestMIDIClock:
|
||||
@pytest.fixture
|
||||
def clock(self):
|
||||
return MIDIClock(window_size=24) # Small window for fast tests
|
||||
|
||||
def test_initial_state(self, clock):
|
||||
assert not clock.is_running
|
||||
assert clock.tempo == 120.0
|
||||
|
||||
def test_start_sets_running(self, clock):
|
||||
clock.process_message(0xFA) # START
|
||||
assert clock.is_running
|
||||
|
||||
def test_stop_sets_not_running(self, clock):
|
||||
clock.process_message(0xFA) # START
|
||||
clock.process_message(0xFC) # STOP
|
||||
assert not clock.is_running
|
||||
|
||||
def test_continue_sets_running(self, clock):
|
||||
clock.process_message(0xFB) # CONTINUE
|
||||
assert clock.is_running
|
||||
|
||||
def test_clock_pulses_detect_tempo(self, clock):
|
||||
"""Simulate MIDI clock at 120 BPM.
|
||||
|
||||
At 120 BPM:
|
||||
- Seconds per quarter note = 60/120 = 0.5s
|
||||
- Seconds per pulse = 0.5/24 = ~0.02083s
|
||||
"""
|
||||
clock.process_message(0xFA) # START
|
||||
|
||||
pulse_interval = 60.0 / 120.0 / PPQN # ~0.02083s
|
||||
|
||||
# Send 48 pulses (2 beats at 120 BPM)
|
||||
now = time.monotonic()
|
||||
for i in range(48):
|
||||
# Simulate time advance (we inject the timestamp indirectly via the process)
|
||||
clock.process_message(0xF8)
|
||||
|
||||
# After enough pulses, BPM should be roughly 120
|
||||
# (Since we can't control the real clock, we just verify it runs)
|
||||
assert clock.state.pulse_count == 48
|
||||
|
||||
def test_transport_callbacks(self, clock):
|
||||
events = []
|
||||
clock.on_transport(lambda ev: events.append(ev))
|
||||
|
||||
clock.process_message(0xFA) # START
|
||||
clock.process_message(0xFC) # STOP
|
||||
clock.process_message(0xFB) # CONTINUE
|
||||
|
||||
assert events == ["start", "stop", "continue"]
|
||||
|
||||
def test_tempo_callback_fires(self, clock):
|
||||
"""Send enough pulses to fill the window and trigger tempo callback."""
|
||||
tempos = []
|
||||
clock.on_tempo_change(lambda bpm, raw, stable: tempos.append(bpm))
|
||||
|
||||
clock.process_message(0xFA) # START
|
||||
|
||||
# Send pulses quickly
|
||||
for _ in range(24): # 1 beat
|
||||
clock.process_message(0xF8)
|
||||
|
||||
# Tempo callback should have fired at least once on beat boundary
|
||||
# (but only if bpm_stable, which requires half window)
|
||||
# With window=24 and 24 pulses, we get exactly 1 beat and half the window
|
||||
# Actually we need 12 pulses for half window. 24 pulses = 1 beat, callback fires
|
||||
# Wait — the tempo callback fires on beat boundaries IF bpm_stable
|
||||
# bpm_stable requires >= 12 intervals (half of window=24). 24 pulses = 23 intervals.
|
||||
# So yes, bpm_stable should be True after 24 pulses.
|
||||
|
||||
def test_song_position(self, clock):
|
||||
clock.process_message(0xFA) # START
|
||||
clock.process_song_position(96) # 96 beats
|
||||
assert clock.state.song_position == 96
|
||||
|
||||
def test_reset(self, clock):
|
||||
clock.process_message(0xFA)
|
||||
for _ in range(24):
|
||||
clock.process_message(0xF8)
|
||||
|
||||
clock.reset()
|
||||
assert not clock.is_running
|
||||
assert clock.tempo == 120.0
|
||||
assert clock.state.pulse_count == 0
|
||||
assert len(clock.state._pulse_intervals) == 0
|
||||
|
||||
def test_generate_clock_pulse(self, clock):
|
||||
"""When not running, generate_clock_pulse returns None."""
|
||||
assert clock.generate_clock_pulse() is None
|
||||
|
||||
clock.process_message(0xFA) # START
|
||||
interval = clock.generate_clock_pulse()
|
||||
assert interval is not None
|
||||
# At 120 BPM: interval = (60/120)/24 = 1/48 ≈ 0.0208s
|
||||
assert interval == pytest.approx(1.0 / 48.0, abs=0.001)
|
||||
|
||||
def test_beat_callback(self, clock):
|
||||
beats = []
|
||||
clock.on_beat(lambda beat: beats.append(beat))
|
||||
|
||||
clock.process_message(0xFA) # START
|
||||
for _ in range(48): # 2 beats
|
||||
clock.process_message(0xF8)
|
||||
|
||||
assert beats == [1, 2]
|
||||
@@ -0,0 +1,275 @@
|
||||
"""Tests for the core MIDI engine."""
|
||||
|
||||
import pytest
|
||||
from src.midi.types import (
|
||||
MIDIMessage,
|
||||
MIDIMessageType,
|
||||
MIDIMapping,
|
||||
ParameterType,
|
||||
)
|
||||
from src.midi.midi_engine import MIDIEngine
|
||||
|
||||
|
||||
class TestMIDIEngine:
|
||||
@pytest.fixture
|
||||
def engine(self):
|
||||
return MIDIEngine()
|
||||
|
||||
def test_process_cc_event_matched(self, engine):
|
||||
mapping = MIDIMapping(
|
||||
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||
channel=0,
|
||||
controller=7,
|
||||
param_type=ParameterType.VOLUME,
|
||||
param_channel=0,
|
||||
)
|
||||
engine.add_mapping(mapping)
|
||||
|
||||
msg = MIDIMessage(
|
||||
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||
channel=0,
|
||||
controller=7,
|
||||
value=64,
|
||||
)
|
||||
results = engine.process_event(msg)
|
||||
|
||||
assert len(results) == 1
|
||||
result_mapping, result_value = results[0]
|
||||
assert result_mapping.param_type == ParameterType.VOLUME
|
||||
assert result_mapping.param_channel == 0
|
||||
assert 0.4 < result_value < 0.6 # 64/127 ≈ 0.504
|
||||
|
||||
def test_process_cc_event_no_match(self, engine):
|
||||
mapping = MIDIMapping(
|
||||
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||
channel=0,
|
||||
controller=7,
|
||||
)
|
||||
engine.add_mapping(mapping)
|
||||
|
||||
msg = MIDIMessage(
|
||||
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||
channel=0,
|
||||
controller=10,
|
||||
value=100,
|
||||
)
|
||||
results = engine.process_event(msg)
|
||||
assert len(results) == 0
|
||||
|
||||
def test_multiple_mappings_same_cc(self, engine):
|
||||
"""Two mappings for the same CC should both fire."""
|
||||
m1 = MIDIMapping(
|
||||
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||
channel=0,
|
||||
controller=7,
|
||||
param_type=ParameterType.VOLUME,
|
||||
param_channel=0,
|
||||
)
|
||||
m2 = MIDIMapping(
|
||||
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||
channel=0,
|
||||
controller=7,
|
||||
param_type=ParameterType.PAN,
|
||||
param_channel=0,
|
||||
)
|
||||
engine.set_mappings([m1, m2])
|
||||
|
||||
msg = MIDIMessage(
|
||||
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||
channel=0,
|
||||
controller=7,
|
||||
value=100,
|
||||
)
|
||||
results = engine.process_event(msg)
|
||||
assert len(results) == 2
|
||||
|
||||
def test_disabled_mapping_skipped(self, engine):
|
||||
mapping = MIDIMapping(
|
||||
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||
channel=0,
|
||||
controller=7,
|
||||
enabled=False,
|
||||
)
|
||||
engine.add_mapping(mapping)
|
||||
|
||||
msg = MIDIMessage(
|
||||
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||
channel=0,
|
||||
controller=7,
|
||||
value=100,
|
||||
)
|
||||
results = engine.process_event(msg)
|
||||
assert len(results) == 0
|
||||
|
||||
def test_callback_fires(self, engine):
|
||||
mapping = MIDIMapping(
|
||||
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||
channel=0,
|
||||
controller=7,
|
||||
)
|
||||
engine.add_mapping(mapping)
|
||||
|
||||
fired = []
|
||||
engine.on_mapped(lambda m, v, r: fired.append((m.param_type, v)))
|
||||
|
||||
msg = MIDIMessage(
|
||||
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||
channel=0,
|
||||
controller=7,
|
||||
value=127,
|
||||
)
|
||||
engine.process_event(msg)
|
||||
|
||||
assert len(fired) == 1
|
||||
|
||||
def test_set_mappings_replaces(self, engine):
|
||||
m1 = MIDIMapping(controller=7)
|
||||
engine.set_mappings([m1])
|
||||
assert len(engine.get_mappings()) == 1
|
||||
|
||||
m2 = MIDIMapping(controller=10)
|
||||
engine.set_mappings([m2])
|
||||
assert len(engine.get_mappings()) == 1
|
||||
assert engine.get_mappings()[0].controller == 10
|
||||
|
||||
def test_remove_mapping(self, engine):
|
||||
m = MIDIMapping(
|
||||
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||
channel=0,
|
||||
controller=7,
|
||||
param_type=ParameterType.VOLUME,
|
||||
param_channel=0,
|
||||
)
|
||||
engine.add_mapping(m)
|
||||
assert len(engine.get_mappings()) == 1
|
||||
engine.remove_mapping(m)
|
||||
assert len(engine.get_mappings()) == 0
|
||||
|
||||
def test_find_mappings_for(self, engine):
|
||||
m1 = MIDIMapping(controller=7, param_type=ParameterType.VOLUME, param_channel=0)
|
||||
m2 = MIDIMapping(controller=10, param_type=ParameterType.VOLUME, param_channel=0)
|
||||
m3 = MIDIMapping(controller=11, param_type=ParameterType.PAN, param_channel=0)
|
||||
engine.set_mappings([m1, m2, m3])
|
||||
|
||||
vol_mappings = engine.find_mappings_for(ParameterType.VOLUME, 0)
|
||||
assert len(vol_mappings) == 2
|
||||
|
||||
def test_stats(self, engine):
|
||||
engine.start()
|
||||
mapping = MIDIMapping(controller=7)
|
||||
engine.add_mapping(mapping)
|
||||
|
||||
for _ in range(10):
|
||||
msg = MIDIMessage(
|
||||
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||
channel=0,
|
||||
controller=7,
|
||||
value=64,
|
||||
)
|
||||
engine.process_event(msg)
|
||||
|
||||
stats = engine.stats
|
||||
assert stats["events_total"] == 10
|
||||
assert stats["events_mapped"] == 10
|
||||
assert stats["mappings_active"] == 1
|
||||
|
||||
|
||||
class TestNRPNStateMachine:
|
||||
@pytest.fixture
|
||||
def engine(self):
|
||||
return MIDIEngine()
|
||||
|
||||
def test_nrpn_complete_cycle(self, engine):
|
||||
"""Full NRPN: CC 99 → CC 98 → CC 6 → CC 38."""
|
||||
mapping = MIDIMapping(
|
||||
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||
channel=0,
|
||||
is_nrpn=True,
|
||||
nrpn_number=(0x01 << 7) | 0x02,
|
||||
midi_min=0,
|
||||
midi_max=16383, # 14-bit NRPN range
|
||||
param_type=ParameterType.VOLUME,
|
||||
param_channel=0,
|
||||
)
|
||||
engine.add_mapping(mapping)
|
||||
|
||||
# Step 1: NRPN MSB (CC 99)
|
||||
r1 = engine.process_event(MIDIMessage(
|
||||
MIDIMessageType.CONTROL_CHANGE, 0, 99, 0x01,
|
||||
))
|
||||
assert len(r1) == 0 # Intermediate
|
||||
|
||||
# Step 2: NRPN LSB (CC 98)
|
||||
r2 = engine.process_event(MIDIMessage(
|
||||
MIDIMessageType.CONTROL_CHANGE, 0, 98, 0x02,
|
||||
))
|
||||
assert len(r2) == 0 # Intermediate
|
||||
|
||||
# Step 3: Data Entry MSB (CC 6)
|
||||
r3 = engine.process_event(MIDIMessage(
|
||||
MIDIMessageType.CONTROL_CHANGE, 0, 6, 0x40,
|
||||
))
|
||||
assert len(r3) == 0 # Waiting for LSB
|
||||
|
||||
# Step 4: Data Entry LSB (CC 38)
|
||||
r4 = engine.process_event(MIDIMessage(
|
||||
MIDIMessageType.CONTROL_CHANGE, 0, 38, 0x20,
|
||||
))
|
||||
assert len(r4) == 1 # Complete!
|
||||
mapping_out, value = r4[0]
|
||||
assert mapping_out.is_nrpn
|
||||
# Value = (0x40 << 7) | 0x20 = 0x2020 = 8224
|
||||
# Scaled: 8224 / 16383 ≈ 0.502
|
||||
assert value == pytest.approx(8224 / 16383, abs=0.01)
|
||||
|
||||
def test_nrpn_reset_on_regular_cc(self, engine):
|
||||
"""Sending a regular CC after NRPN preamble should reset state."""
|
||||
# Start NRPN
|
||||
engine.process_event(MIDIMessage(
|
||||
MIDIMessageType.CONTROL_CHANGE, 0, 99, 0x01,
|
||||
))
|
||||
engine.process_event(MIDIMessage(
|
||||
MIDIMessageType.CONTROL_CHANGE, 0, 98, 0x02,
|
||||
))
|
||||
|
||||
# Send a regular CC (should pass through and reset NRPN state)
|
||||
mapping = MIDIMapping(controller=7)
|
||||
engine.add_mapping(mapping)
|
||||
r = engine.process_event(MIDIMessage(
|
||||
MIDIMessageType.CONTROL_CHANGE, 0, 7, 100,
|
||||
))
|
||||
assert len(r) == 1 # Regular CC passes through
|
||||
assert not r[0][0].is_nrpn
|
||||
|
||||
def test_nrpn_incomplete_msb_lsb_ignored(self, engine):
|
||||
"""CC 6 without prior CC 99/98 should not create NRPN."""
|
||||
r = engine.process_event(MIDIMessage(
|
||||
MIDIMessageType.CONTROL_CHANGE, 0, 6, 100,
|
||||
))
|
||||
assert len(r) == 0 # NRPN state incomplete
|
||||
|
||||
def test_nrpn_different_channels_independent(self, engine):
|
||||
"""NRPN state is per-channel."""
|
||||
# Channel 0 NRPN
|
||||
engine.process_event(MIDIMessage(MIDIMessageType.CONTROL_CHANGE, 0, 99, 0x01))
|
||||
engine.process_event(MIDIMessage(MIDIMessageType.CONTROL_CHANGE, 0, 98, 0x02))
|
||||
|
||||
# Channel 1 NRPN (should not interfere)
|
||||
engine.process_event(MIDIMessage(MIDIMessageType.CONTROL_CHANGE, 1, 99, 0x03))
|
||||
engine.process_event(MIDIMessage(MIDIMessageType.CONTROL_CHANGE, 1, 98, 0x04))
|
||||
|
||||
# Complete channel 1
|
||||
mapping = MIDIMapping(
|
||||
channel=1, # Match the message channel
|
||||
is_nrpn=True, nrpn_number=(0x03 << 7) | 0x04,
|
||||
midi_max=16383,
|
||||
param_type=ParameterType.VOLUME, param_channel=1,
|
||||
)
|
||||
engine.add_mapping(mapping)
|
||||
r = engine.process_event(MIDIMessage(MIDIMessageType.CONTROL_CHANGE, 1, 6, 50))
|
||||
engine.process_event(MIDIMessage(MIDIMessageType.CONTROL_CHANGE, 1, 38, 0))
|
||||
assert len(r) == 0 # Wait for CC38
|
||||
|
||||
r2 = engine.process_event(MIDIMessage(MIDIMessageType.CONTROL_CHANGE, 1, 38, 30))
|
||||
assert len(r2) == 1
|
||||
assert r2[0][0].nrpn_number == (0x03 << 7) | 0x04
|
||||
@@ -0,0 +1,181 @@
|
||||
"""Tests for MIDI learn mode."""
|
||||
|
||||
import pytest
|
||||
from src.midi.types import (
|
||||
MIDIMessage,
|
||||
MIDIMessageType,
|
||||
MIDIMapping,
|
||||
ParameterType,
|
||||
)
|
||||
from src.midi.midi_learn import MIDILearn, LearnState
|
||||
|
||||
|
||||
class TestMIDILearn:
|
||||
@pytest.fixture
|
||||
def learn(self):
|
||||
return MIDILearn()
|
||||
|
||||
def test_initial_idle(self, learn):
|
||||
assert learn.is_idle
|
||||
assert not learn.is_listening
|
||||
|
||||
def test_start_learn_sets_listening(self, learn):
|
||||
learn.start_learn(ParameterType.VOLUME, channel=0)
|
||||
assert learn.is_listening
|
||||
assert not learn.is_idle
|
||||
assert "volume" in learn.session.param_type.value
|
||||
|
||||
def test_feed_cc_triggers_capture(self, learn):
|
||||
learn.start_learn(ParameterType.VOLUME, channel=0)
|
||||
|
||||
msg = MIDIMessage(
|
||||
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||
channel=0,
|
||||
controller=7,
|
||||
value=100,
|
||||
)
|
||||
result = learn.feed_message(msg)
|
||||
|
||||
assert result is None # Still in CAPTURED, waiting for confirm
|
||||
assert learn.is_captured
|
||||
assert learn.session.captured_cc == 7
|
||||
assert learn.session.captured_channel == 0
|
||||
|
||||
def test_confirm_returns_mapping(self, learn):
|
||||
learn.start_learn(ParameterType.VOLUME, channel=0)
|
||||
|
||||
msg = MIDIMessage(
|
||||
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||
channel=0,
|
||||
controller=7,
|
||||
value=100,
|
||||
)
|
||||
learn.feed_message(msg)
|
||||
mapping = learn.confirm()
|
||||
|
||||
assert mapping is not None
|
||||
assert mapping.param_type == ParameterType.VOLUME
|
||||
assert mapping.param_channel == 0
|
||||
assert mapping.controller == 7
|
||||
assert mapping.channel == 0
|
||||
assert learn.is_idle # Reset after confirm
|
||||
|
||||
def test_confirm_without_capture_returns_none(self, learn):
|
||||
assert learn.confirm() is None
|
||||
|
||||
def test_discard_resets(self, learn):
|
||||
learn.start_learn(ParameterType.VOLUME, channel=0)
|
||||
learn.feed_message(MIDIMessage(
|
||||
MIDIMessageType.CONTROL_CHANGE, 0, 7, 100,
|
||||
))
|
||||
learn.discard()
|
||||
assert learn.is_idle
|
||||
|
||||
def test_note_also_captured(self, learn):
|
||||
"""Note On/Off messages should also be captured (for transport triggers)."""
|
||||
learn.start_learn(ParameterType.PLAY)
|
||||
|
||||
msg = MIDIMessage(
|
||||
msg_type=MIDIMessageType.NOTE_ON,
|
||||
channel=0,
|
||||
controller=60,
|
||||
value=127,
|
||||
)
|
||||
learn.feed_message(msg)
|
||||
mapping = learn.confirm()
|
||||
|
||||
assert mapping is not None
|
||||
assert mapping.msg_type == MIDIMessageType.NOTE_ON
|
||||
|
||||
def test_nrpn_capture(self, learn):
|
||||
learn.start_learn(ParameterType.VOLUME, channel=0)
|
||||
|
||||
msg = MIDIMessage(
|
||||
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||
channel=0,
|
||||
is_nrpn=True,
|
||||
nrpn_msb=0x01,
|
||||
nrpn_lsb=0x02,
|
||||
value=8192,
|
||||
)
|
||||
learn.feed_message(msg)
|
||||
mapping = learn.confirm()
|
||||
|
||||
assert mapping is not None
|
||||
assert mapping.is_nrpn
|
||||
assert mapping.nrpn_number == (0x01 << 7) | 0x02
|
||||
|
||||
def test_learned_callback_fires(self, learn):
|
||||
fired = []
|
||||
learn.on_learned(lambda m: fired.append(m))
|
||||
|
||||
learn.start_learn(ParameterType.VOLUME, 0)
|
||||
learn.feed_message(MIDIMessage(
|
||||
MIDIMessageType.CONTROL_CHANGE, 0, 7, 100,
|
||||
))
|
||||
learn.confirm()
|
||||
|
||||
assert len(fired) == 1
|
||||
assert fired[0].controller == 7
|
||||
|
||||
|
||||
class TestBatchLearn:
|
||||
@pytest.fixture
|
||||
def learn(self):
|
||||
return MIDILearn()
|
||||
|
||||
def test_batch_learn_full_sequence(self, learn):
|
||||
params = [
|
||||
(ParameterType.VOLUME, 0, "CH0 Vol"),
|
||||
(ParameterType.PAN, 0, "CH0 Pan"),
|
||||
(ParameterType.MUTE, 0, "CH0 Mute"),
|
||||
]
|
||||
learn.start_batch(params)
|
||||
assert learn.is_batch
|
||||
assert learn.session.batch_index == 0
|
||||
|
||||
# Wiggle control 1 → volume
|
||||
r1 = learn.feed_message(MIDIMessage(
|
||||
MIDIMessageType.CONTROL_CHANGE, 0, 70, 100,
|
||||
))
|
||||
assert r1 is not None
|
||||
assert r1.param_type == ParameterType.VOLUME
|
||||
assert r1.controller == 70
|
||||
assert learn.session.batch_index == 1 # Advanced
|
||||
|
||||
# Wiggle control 2 → pan
|
||||
r2 = learn.feed_message(MIDIMessage(
|
||||
MIDIMessageType.CONTROL_CHANGE, 0, 10, 64,
|
||||
))
|
||||
assert r2 is not None
|
||||
assert r2.param_type == ParameterType.PAN
|
||||
assert r2.controller == 10
|
||||
|
||||
# Wiggle control 3 → mute (last one)
|
||||
r3 = learn.feed_message(MIDIMessage(
|
||||
MIDIMessageType.CONTROL_CHANGE, 0, 1, 127,
|
||||
))
|
||||
assert r3 is not None
|
||||
assert r3.param_type == ParameterType.MUTE
|
||||
|
||||
# Batch should be complete
|
||||
assert learn.is_idle
|
||||
assert len(learn.session.batch_mappings) == 3
|
||||
|
||||
def test_batch_ignores_extra_messages_after_complete(self, learn):
|
||||
params = [(ParameterType.VOLUME, 0, "CH0 Vol")]
|
||||
learn.start_batch(params)
|
||||
|
||||
learn.feed_message(MIDIMessage(MIDIMessageType.CONTROL_CHANGE, 0, 70, 100))
|
||||
# Batch done, extra message should return None
|
||||
r = learn.feed_message(MIDIMessage(MIDIMessageType.CONTROL_CHANGE, 0, 71, 100))
|
||||
assert r is None
|
||||
assert learn.is_idle
|
||||
|
||||
def test_status_text(self, learn):
|
||||
learn.start_learn(ParameterType.VOLUME, 0)
|
||||
assert "Listening" in learn.status_text
|
||||
|
||||
learn.feed_message(MIDIMessage(MIDIMessageType.CONTROL_CHANGE, 0, 7, 100))
|
||||
assert "Captured" in learn.status_text
|
||||
assert "CC7" in learn.status_text
|
||||
@@ -0,0 +1,330 @@
|
||||
"""Tests for OSC server."""
|
||||
|
||||
import asyncio
|
||||
import socket
|
||||
import struct
|
||||
import time
|
||||
import pytest
|
||||
|
||||
from src.midi.types import ParameterType, MixerParameter, ParameterCategory
|
||||
from src.mixer.osc_client import encode_osc, decode_osc
|
||||
from src.network.osc_server import (
|
||||
OSCServer,
|
||||
_parse_osc_address,
|
||||
_extract_value,
|
||||
DEFAULT_OSC_PORT,
|
||||
)
|
||||
|
||||
|
||||
class TestOSCAddressParsing:
|
||||
"""Tests for OSC address parsing."""
|
||||
|
||||
def test_channel_volume(self):
|
||||
result = _parse_osc_address("/mixer/channel/0/volume")
|
||||
assert result is not None
|
||||
cat, pt, ch, _ = result
|
||||
assert cat == ParameterCategory.CHANNEL
|
||||
assert pt == ParameterType.VOLUME
|
||||
assert ch == 0
|
||||
|
||||
def test_channel_mute(self):
|
||||
result = _parse_osc_address("/mixer/channel/5/mute")
|
||||
cat, pt, ch, _ = result
|
||||
assert pt == ParameterType.MUTE
|
||||
assert ch == 5
|
||||
|
||||
def test_channel_eq_low_gain(self):
|
||||
result = _parse_osc_address("/mixer/channel/3/eq_low_gain")
|
||||
cat, pt, ch, _ = result
|
||||
assert pt == ParameterType.EQ_LOW_GAIN
|
||||
assert ch == 3
|
||||
|
||||
def test_channel_comp_ratio(self):
|
||||
result = _parse_osc_address("/mixer/channel/7/comp_ratio")
|
||||
cat, pt, ch, _ = result
|
||||
assert pt == ParameterType.COMP_RATIO
|
||||
|
||||
def test_master_volume(self):
|
||||
result = _parse_osc_address("/mixer/master/volume")
|
||||
cat, pt, ch, _ = result
|
||||
assert cat == ParameterCategory.MASTER
|
||||
assert pt == ParameterType.MASTER_VOLUME
|
||||
assert ch == -1
|
||||
|
||||
def test_master_mute(self):
|
||||
result = _parse_osc_address("/mixer/master/mute")
|
||||
cat, pt, ch, _ = result
|
||||
assert pt == ParameterType.MASTER_MUTE
|
||||
|
||||
def test_master_dim(self):
|
||||
result = _parse_osc_address("/mixer/master/dim")
|
||||
cat, pt, ch, _ = result
|
||||
assert pt == ParameterType.MASTER_DIM
|
||||
|
||||
def test_fx_return(self):
|
||||
result = _parse_osc_address("/mixer/fx/return_a")
|
||||
cat, pt, ch, _ = result
|
||||
assert cat == ParameterCategory.FX
|
||||
assert pt == ParameterType.FX_RETURN_A
|
||||
|
||||
def test_transport_play(self):
|
||||
result = _parse_osc_address("/mixer/transport/play")
|
||||
cat, pt, ch, _ = result
|
||||
assert cat == ParameterCategory.TRANSPORT
|
||||
assert pt == ParameterType.PLAY
|
||||
|
||||
def test_transport_tempo(self):
|
||||
result = _parse_osc_address("/mixer/transport/tempo")
|
||||
_, pt, _, _ = result
|
||||
assert pt == ParameterType.TEMPO
|
||||
|
||||
def test_utility_snapshot_load(self):
|
||||
result = _parse_osc_address("/mixer/utility/snapshot_load")
|
||||
_, pt, _, _ = result
|
||||
assert pt == ParameterType.SNAPSHOT_LOAD
|
||||
|
||||
def test_unknown_address(self):
|
||||
result = _parse_osc_address("/mixer/nonexistent/path")
|
||||
assert result is None
|
||||
|
||||
def test_unknown_param_name(self):
|
||||
result = _parse_osc_address("/mixer/channel/0/invalid_param")
|
||||
assert result is None
|
||||
|
||||
def test_invalid_channel_number(self):
|
||||
result = _parse_osc_address("/mixer/channel/abc/volume")
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestExtractValue:
|
||||
"""Tests for _extract_value."""
|
||||
|
||||
def test_float(self):
|
||||
assert _extract_value([3.14]) == pytest.approx(3.14)
|
||||
|
||||
def test_int(self):
|
||||
assert _extract_value([42]) == 42.0
|
||||
|
||||
def test_bool_true(self):
|
||||
assert _extract_value([True]) == 1.0
|
||||
|
||||
def test_bool_false(self):
|
||||
assert _extract_value([False]) == 0.0
|
||||
|
||||
def test_string_number(self):
|
||||
assert _extract_value(["0.75"]) == 0.75
|
||||
|
||||
def test_empty_args(self):
|
||||
assert _extract_value([]) == 0.0
|
||||
|
||||
def test_multiple_args_uses_first(self):
|
||||
assert _extract_value([0.5, 1.0, 2.0]) == 0.5
|
||||
|
||||
def test_zero(self):
|
||||
assert _extract_value([0.0]) == 0.0
|
||||
|
||||
def test_negative(self):
|
||||
assert _extract_value([-6.0]) == -6.0
|
||||
|
||||
|
||||
class TestOSCServer:
|
||||
"""Tests for the async OSC server.
|
||||
|
||||
Each test manages its own server lifecycle using asyncio.run().
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _send_udp(packet: bytes, host: str, port: int) -> None:
|
||||
"""Send a UDP packet."""
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
try:
|
||||
sock.sendto(packet, (host, port))
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
@staticmethod
|
||||
def _find_free_port() -> int:
|
||||
"""Find a free UDP port."""
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
port = sock.getsockname()[1]
|
||||
sock.close()
|
||||
return port
|
||||
|
||||
async def _create_server(self, received: list, port: int | None = None) -> OSCServer:
|
||||
"""Create and start a server, returning it."""
|
||||
port = port or self._find_free_port()
|
||||
server = OSCServer(host="127.0.0.1", port=port)
|
||||
server.set_dispatcher(lambda p, v: received.append((p, v)))
|
||||
await server.start()
|
||||
return server
|
||||
|
||||
# ── Test runners (synchronous entry points) ──────────────────────
|
||||
|
||||
def test_receive_volume(self):
|
||||
async def run():
|
||||
received = []
|
||||
port = self._find_free_port()
|
||||
server = await self._create_server(received, port)
|
||||
try:
|
||||
packet = encode_osc("/mixer/channel/0/volume", -3.0)
|
||||
self._send_udp(packet, "127.0.0.1", port)
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
assert len(received) == 1
|
||||
param, value = received[0]
|
||||
assert param.param_type == ParameterType.VOLUME
|
||||
assert param.channel == 0
|
||||
assert value == pytest.approx(-3.0)
|
||||
finally:
|
||||
await server.stop()
|
||||
asyncio.run(run())
|
||||
|
||||
def test_receive_mute(self):
|
||||
async def run():
|
||||
received = []
|
||||
port = self._find_free_port()
|
||||
server = await self._create_server(received, port)
|
||||
try:
|
||||
packet = encode_osc("/mixer/channel/2/mute", True)
|
||||
self._send_udp(packet, "127.0.0.1", port)
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
assert len(received) == 1
|
||||
param, value = received[0]
|
||||
assert param.param_type == ParameterType.MUTE
|
||||
assert param.channel == 2
|
||||
assert value == 1.0
|
||||
finally:
|
||||
await server.stop()
|
||||
asyncio.run(run())
|
||||
|
||||
def test_receive_master_volume(self):
|
||||
async def run():
|
||||
received = []
|
||||
port = self._find_free_port()
|
||||
server = await self._create_server(received, port)
|
||||
try:
|
||||
packet = encode_osc("/mixer/master/volume", -10.0)
|
||||
self._send_udp(packet, "127.0.0.1", port)
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
assert len(received) == 1
|
||||
param, value = received[0]
|
||||
assert param.param_type == ParameterType.MASTER_VOLUME
|
||||
assert value == pytest.approx(-10.0)
|
||||
finally:
|
||||
await server.stop()
|
||||
asyncio.run(run())
|
||||
|
||||
def test_receive_transport(self):
|
||||
async def run():
|
||||
received = []
|
||||
port = self._find_free_port()
|
||||
server = await self._create_server(received, port)
|
||||
try:
|
||||
packet = encode_osc("/mixer/transport/play", 1.0)
|
||||
self._send_udp(packet, "127.0.0.1", port)
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
assert len(received) == 1
|
||||
param, _ = received[0]
|
||||
assert param.param_type == ParameterType.PLAY
|
||||
finally:
|
||||
await server.stop()
|
||||
asyncio.run(run())
|
||||
|
||||
def test_receive_multiple(self):
|
||||
async def run():
|
||||
received = []
|
||||
port = self._find_free_port()
|
||||
server = await self._create_server(received, port)
|
||||
try:
|
||||
msgs = [
|
||||
encode_osc("/mixer/channel/0/volume", -6.0),
|
||||
encode_osc("/mixer/channel/0/pan", 0.5),
|
||||
encode_osc("/mixer/channel/1/mute", 1.0),
|
||||
]
|
||||
for packet in msgs:
|
||||
self._send_udp(packet, "127.0.0.1", port)
|
||||
|
||||
await asyncio.sleep(0.15)
|
||||
|
||||
assert len(received) == 3
|
||||
types = [p.param_type for p, _ in received]
|
||||
assert ParameterType.VOLUME in types
|
||||
assert ParameterType.PAN in types
|
||||
assert ParameterType.MUTE in types
|
||||
finally:
|
||||
await server.stop()
|
||||
asyncio.run(run())
|
||||
|
||||
def test_unknown_address_not_dispatched(self):
|
||||
async def run():
|
||||
received = []
|
||||
port = self._find_free_port()
|
||||
server = await self._create_server(received, port)
|
||||
try:
|
||||
packet = encode_osc("/unknown/path", 1.0)
|
||||
self._send_udp(packet, "127.0.0.1", port)
|
||||
await asyncio.sleep(0.1)
|
||||
assert len(received) == 0
|
||||
finally:
|
||||
await server.stop()
|
||||
asyncio.run(run())
|
||||
|
||||
def test_stats(self):
|
||||
async def run():
|
||||
received = []
|
||||
port = self._find_free_port()
|
||||
server = await self._create_server(received, port)
|
||||
try:
|
||||
packet = encode_osc("/mixer/channel/0/volume", 0.0)
|
||||
self._send_udp(packet, "127.0.0.1", port)
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
stats = server.stats
|
||||
assert stats["running"] is True
|
||||
assert stats["messages"] >= 1
|
||||
assert stats["host"] == "127.0.0.1"
|
||||
finally:
|
||||
await server.stop()
|
||||
asyncio.run(run())
|
||||
|
||||
def test_no_dispatcher_no_crash(self):
|
||||
async def run():
|
||||
port = self._find_free_port()
|
||||
server = OSCServer(host="127.0.0.1", port=port)
|
||||
await server.start()
|
||||
try:
|
||||
packet = encode_osc("/mixer/channel/0/volume", 0.0)
|
||||
self._send_udp(packet, "127.0.0.1", port)
|
||||
await asyncio.sleep(0.1)
|
||||
finally:
|
||||
await server.stop()
|
||||
asyncio.run(run())
|
||||
|
||||
def test_start_stop(self):
|
||||
async def run():
|
||||
port = self._find_free_port()
|
||||
server = OSCServer(host="127.0.0.1", port=port)
|
||||
assert not server.running
|
||||
|
||||
await server.start()
|
||||
assert server.running
|
||||
|
||||
await server.stop()
|
||||
assert not server.running
|
||||
asyncio.run(run())
|
||||
|
||||
def test_double_start(self):
|
||||
async def run():
|
||||
port = self._find_free_port()
|
||||
server = OSCServer(host="127.0.0.1", port=port)
|
||||
await server.start()
|
||||
try:
|
||||
await server.start() # should be idempotent
|
||||
assert server.running
|
||||
finally:
|
||||
await server.stop()
|
||||
asyncio.run(run())
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Tests for mixer parameter registry."""
|
||||
|
||||
import pytest
|
||||
from src.mixer import ParameterRegistry
|
||||
from src.midi.types import ParameterType, ParameterCategory
|
||||
|
||||
|
||||
class TestParameterRegistry:
|
||||
@pytest.fixture
|
||||
def registry(self):
|
||||
return ParameterRegistry(channels=8)
|
||||
|
||||
def test_all_parameters_built(self, registry):
|
||||
"""Registry should have all params (master + channels + fx + transport + utility)."""
|
||||
params = list(registry.iter_all())
|
||||
# Master: 5, FX: 4, Transport: 6, Utility: 4, 8 channels * 25 = 200
|
||||
# Total: 5 + 4 + 6 + 4 + 200 = 219
|
||||
assert len(params) == 219
|
||||
|
||||
def test_get_master_volume(self, registry):
|
||||
p = registry.get(ParameterType.MASTER_VOLUME)
|
||||
assert p is not None
|
||||
assert p.param_type == ParameterType.MASTER_VOLUME
|
||||
assert p.channel == -1
|
||||
assert p.max_val == 12.0
|
||||
|
||||
def test_get_channel_volume(self, registry):
|
||||
p = registry.get(ParameterType.VOLUME, channel=3)
|
||||
assert p is not None
|
||||
assert p.param_type == ParameterType.VOLUME
|
||||
assert p.channel == 3
|
||||
assert p.min_val == -60.0
|
||||
|
||||
def test_get_nonexistent(self, registry):
|
||||
p = registry.get(ParameterType.VOLUME, channel=999)
|
||||
assert p is None
|
||||
|
||||
def test_iter_by_category(self, registry):
|
||||
channel_params = list(registry.iter_by_category(ParameterCategory.CHANNEL))
|
||||
# 8 channels * 25 params = 200
|
||||
assert len(channel_params) == 8 * 25
|
||||
|
||||
master_params = list(registry.iter_by_category(ParameterCategory.MASTER))
|
||||
assert len(master_params) == 5
|
||||
|
||||
def test_iter_by_channel(self, registry):
|
||||
ch0 = list(registry.iter_by_channel(0))
|
||||
assert len(ch0) == 25 # Full channel strip
|
||||
|
||||
ch5 = list(registry.iter_by_channel(5))
|
||||
assert len(ch5) == 25
|
||||
|
||||
def test_set_value_and_callback(self, registry):
|
||||
values = []
|
||||
registry.subscribe(lambda param, value: values.append((param.param_type, value)))
|
||||
|
||||
registry.set_value(ParameterType.VOLUME, 2, 0.75)
|
||||
|
||||
assert len(values) == 1
|
||||
assert values[0][0] == ParameterType.VOLUME
|
||||
assert values[0][1] == 0.75
|
||||
|
||||
def test_unsubscribe(self, registry):
|
||||
values = []
|
||||
|
||||
def cb(param, value):
|
||||
values.append(value)
|
||||
|
||||
registry.subscribe(cb)
|
||||
registry.set_value(ParameterType.VOLUME, 0, 0.5)
|
||||
assert len(values) == 1
|
||||
|
||||
registry.unsubscribe(cb)
|
||||
registry.set_value(ParameterType.VOLUME, 0, 0.8)
|
||||
assert len(values) == 1 # Not called after unsubscribe
|
||||
|
||||
def test_set_nonexistent_parameter_no_error(self, registry):
|
||||
"""Setting a parameter that doesn't exist should not crash."""
|
||||
registry.set_value(ParameterType.VOLUME, 999, 0.5) # No error
|
||||
|
||||
def test_broken_callback_doesnt_block_others(self, registry):
|
||||
def broken(param, value):
|
||||
raise RuntimeError("broken")
|
||||
|
||||
good_values = []
|
||||
def good(param, value):
|
||||
good_values.append(value)
|
||||
|
||||
registry.subscribe(broken)
|
||||
registry.subscribe(good)
|
||||
|
||||
registry.set_value(ParameterType.VOLUME, 0, 0.5)
|
||||
assert len(good_values) == 1
|
||||
assert good_values[0] == 0.5
|
||||
|
||||
def test_stereo_pan_parameters(self, registry):
|
||||
p = registry.get(ParameterType.PAN, channel=0)
|
||||
assert p is not None
|
||||
assert p.min_val == -1.0
|
||||
assert p.max_val == 1.0
|
||||
assert p.default_val == 0.0
|
||||
|
||||
def test_mute_is_boolean(self, registry):
|
||||
p = registry.get(ParameterType.MUTE, channel=0)
|
||||
assert p is not None
|
||||
assert p.min_val == 0.0
|
||||
assert p.max_val == 1.0
|
||||
assert p.step == 1.0 # Boolean-like
|
||||
|
||||
def test_transport_params(self, registry):
|
||||
play = registry.get(ParameterType.PLAY)
|
||||
stop = registry.get(ParameterType.STOP)
|
||||
assert play is not None
|
||||
assert stop is not None
|
||||
assert play.step == 1.0 # Triggered
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Tests for token bucket rate limiter."""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
import pytest
|
||||
|
||||
from fastapi import FastAPI, Depends, Request
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from src.network.rate_limiter import RateLimiter, TokenBucket, rate_limit
|
||||
from src.network.auth import API_KEY_HEADER
|
||||
|
||||
|
||||
class TestTokenBucket:
|
||||
"""Tests for TokenBucket."""
|
||||
|
||||
def test_initial_tokens(self):
|
||||
bucket = TokenBucket(rate=10.0, capacity=20)
|
||||
assert bucket.available == 20.0
|
||||
|
||||
def test_consume_reduces_tokens(self):
|
||||
bucket = TokenBucket(rate=10.0, capacity=20)
|
||||
assert bucket.consume(1)
|
||||
# available may refill a tiny amount based on timing
|
||||
assert bucket.available == pytest.approx(19.0, abs=0.1)
|
||||
|
||||
def test_consume_multiple(self):
|
||||
bucket = TokenBucket(rate=10.0, capacity=20)
|
||||
assert bucket.consume(5)
|
||||
assert bucket.available == pytest.approx(15.0, abs=0.1)
|
||||
|
||||
def test_cannot_consume_more_than_available(self):
|
||||
bucket = TokenBucket(rate=10.0, capacity=5)
|
||||
# Consume all
|
||||
for _ in range(5):
|
||||
assert bucket.consume(1)
|
||||
# No more
|
||||
assert not bucket.consume(1)
|
||||
|
||||
def test_refill_over_time(self):
|
||||
bucket = TokenBucket(rate=100.0, capacity=100)
|
||||
# Drain all tokens
|
||||
for _ in range(100):
|
||||
assert bucket.consume(1)
|
||||
|
||||
assert bucket.available < 1.0
|
||||
|
||||
# Wait for refill
|
||||
time.sleep(0.05) # 50ms → ~5 tokens at 100/s
|
||||
assert bucket.available >= 4.0 # allow slight timing variance
|
||||
|
||||
def test_capacity_cap(self):
|
||||
bucket = TokenBucket(rate=1000.0, capacity=10)
|
||||
time.sleep(0.1) # 100ms → 100 tokens, but capped at 10
|
||||
assert bucket.available <= 10.0 + 0.01 # small epsilon
|
||||
|
||||
def test_zero_rate(self):
|
||||
bucket = TokenBucket(rate=0.0, capacity=10)
|
||||
for _ in range(10):
|
||||
assert bucket.consume(1)
|
||||
# No refill
|
||||
assert not bucket.consume(1)
|
||||
|
||||
|
||||
class TestRateLimiter:
|
||||
"""Tests for RateLimiter."""
|
||||
|
||||
@pytest.fixture
|
||||
def limiter(self):
|
||||
return RateLimiter(rate=100.0, capacity=200, eviction_timeout=0.5)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_rest_allows_first_request(self, limiter):
|
||||
"""First request from an IP should be allowed."""
|
||||
# We can't easily test this without a FastAPI request,
|
||||
# but we can test the bucket directly.
|
||||
# The check_rest method needs a Request object, so we test
|
||||
# via FastAPI integration below.
|
||||
pass
|
||||
|
||||
|
||||
class TestRateLimitIntegration:
|
||||
"""Integration tests for rate limiting with FastAPI."""
|
||||
|
||||
@pytest.fixture
|
||||
def app(self):
|
||||
limiter = RateLimiter(rate=50.0, capacity=100)
|
||||
app = FastAPI()
|
||||
|
||||
@app.get("/limited")
|
||||
async def limited(_: None = Depends(rate_limit(limiter))):
|
||||
return {"ok": True}
|
||||
|
||||
@app.get("/unlimited")
|
||||
async def unlimited():
|
||||
return {"ok": True}
|
||||
|
||||
return app
|
||||
|
||||
@pytest.fixture
|
||||
def client(self, app):
|
||||
return TestClient(app)
|
||||
|
||||
def test_unlimited_endpoint(self, client):
|
||||
resp = client.get("/unlimited")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_limited_endpoint_allows_requests(self, client):
|
||||
"""Rate-limited endpoint should allow normal traffic."""
|
||||
for _ in range(10):
|
||||
resp = client.get("/limited")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_rate_limit_headers_on_client(self, client):
|
||||
"""Basic sanity — requests should work."""
|
||||
resp = client.get("/limited")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"ok": True}
|
||||
|
||||
def test_multiple_requests_within_limit(self, client):
|
||||
"""Multiple requests within the rate limit should all succeed."""
|
||||
for i in range(50):
|
||||
resp = client.get("/limited")
|
||||
assert resp.status_code == 200, f"Request {i} failed"
|
||||
|
||||
|
||||
class TestRateLimiterStats:
|
||||
"""Tests for rate limiter statistics."""
|
||||
|
||||
def test_initial_stats(self):
|
||||
limiter = RateLimiter(rate=50.0, capacity=100)
|
||||
assert limiter.active_connections == 0
|
||||
stats = limiter.stats
|
||||
assert stats["active_clients"] == 0
|
||||
assert stats["rest_rate"] == 50.0
|
||||
assert stats["rest_capacity"] == 100
|
||||
|
||||
def test_ws_rates(self):
|
||||
limiter = RateLimiter(ws_rate=500.0, ws_capacity=1000)
|
||||
stats = limiter.stats
|
||||
assert stats["ws_rate"] == 500.0
|
||||
assert stats["ws_capacity"] == 1000
|
||||
@@ -0,0 +1,241 @@
|
||||
"""Tests for the REST API endpoints."""
|
||||
|
||||
import json
|
||||
import pytest
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from src.network.auth import APIKeyAuth, API_KEY_HEADER
|
||||
from src.network.rate_limiter import RateLimiter
|
||||
from src.network.rest_api import MixerStateProvider, create_router
|
||||
from src.network.schemas import (
|
||||
ChannelSchema,
|
||||
MasterBusSchema,
|
||||
MixerStateSchema,
|
||||
TransportSchema,
|
||||
PluginSchema,
|
||||
RoutingSchema,
|
||||
EQSchema,
|
||||
CompressorSchema,
|
||||
GateSchema,
|
||||
FXSendsSchema,
|
||||
AuxBusSchema,
|
||||
)
|
||||
|
||||
|
||||
class TestRESTAPI:
|
||||
"""Integration tests for the REST API."""
|
||||
|
||||
@pytest.fixture
|
||||
def state_provider(self):
|
||||
"""Create a mock state provider."""
|
||||
sp = MixerStateProvider()
|
||||
|
||||
# Mock data
|
||||
channels = [
|
||||
ChannelSchema(channel=i, label=f"CH{i+1}", volume_db=0.0)
|
||||
for i in range(16)
|
||||
]
|
||||
sp.get_all_channels = lambda: channels
|
||||
sp.get_channel = lambda idx: channels[idx] if 0 <= idx < len(channels) else None
|
||||
sp.get_master = lambda: MasterBusSchema(
|
||||
volume_db=0.0,
|
||||
aux_buses=[AuxBusSchema(index=0, label="Reverb")],
|
||||
)
|
||||
sp.get_transport = lambda: TransportSchema(playing=False)
|
||||
sp.get_plugins = lambda: [
|
||||
PluginSchema(plugin_id=1, name="Ch1 Gate", role="gate", channel=0),
|
||||
]
|
||||
sp.get_routing = lambda: RoutingSchema()
|
||||
sp.list_scenes = lambda: ["Scene A", "Scene B"]
|
||||
sp.load_scene = lambda name: name in ["Scene A", "Scene B"]
|
||||
sp.save_scene = lambda name: True
|
||||
|
||||
# Parameter handler captures
|
||||
sp._param_calls = []
|
||||
sp.handle_parameter = lambda pt, v, ch: sp._param_calls.append((pt, v, ch))
|
||||
|
||||
# Transport handler captures
|
||||
sp._transport_calls = []
|
||||
sp.handle_transport = lambda cmd: sp._transport_calls.append(cmd)
|
||||
|
||||
return sp
|
||||
|
||||
@pytest.fixture
|
||||
def auth(self):
|
||||
return APIKeyAuth(keys=["test-api-key"])
|
||||
|
||||
@pytest.fixture
|
||||
def rate_limiter(self):
|
||||
return RateLimiter(rate=1000.0, capacity=2000)
|
||||
|
||||
@pytest.fixture
|
||||
def app(self, state_provider, auth, rate_limiter):
|
||||
app = FastAPI()
|
||||
router = create_router(
|
||||
state=state_provider,
|
||||
auth=auth,
|
||||
rate_limiter=rate_limiter,
|
||||
prefix="/api/v1",
|
||||
)
|
||||
app.include_router(router)
|
||||
|
||||
# Health endpoint (no auth)
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
# Full state endpoint on app directly (no auth for test)
|
||||
@app.get("/api/v1/state")
|
||||
async def full_state():
|
||||
return state_provider.get_full_state() if state_provider.get_full_state else {}
|
||||
|
||||
return app
|
||||
|
||||
@pytest.fixture
|
||||
def client(self, app):
|
||||
return TestClient(app)
|
||||
|
||||
@pytest.fixture
|
||||
def auth_headers(self):
|
||||
return {API_KEY_HEADER: "test-api-key"}
|
||||
|
||||
# ── Auth ────────────────────────────────────────────────────────────
|
||||
|
||||
def test_health_no_auth(self, client):
|
||||
resp = client.get("/health")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_channels_no_auth(self, client):
|
||||
resp = client.get("/api/v1/channels")
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_channels_wrong_key(self, client):
|
||||
resp = client.get("/api/v1/channels", headers={API_KEY_HEADER: "wrong"})
|
||||
assert resp.status_code == 403
|
||||
|
||||
# ── Channels ────────────────────────────────────────────────────────
|
||||
|
||||
def test_list_channels(self, client, auth_headers):
|
||||
resp = client.get("/api/v1/channels", headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data) == 16
|
||||
assert data[0]["channel"] == 0
|
||||
|
||||
def test_get_channel_valid(self, client, auth_headers):
|
||||
resp = client.get("/api/v1/channels/3", headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["channel"] == 3
|
||||
|
||||
def test_get_channel_not_found(self, client, auth_headers):
|
||||
resp = client.get("/api/v1/channels/99", headers=auth_headers)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_set_channel_parameter(self, client, auth_headers, state_provider):
|
||||
body = {"param_type": "volume", "value": -3.0}
|
||||
resp = client.put(
|
||||
"/api/v1/channels/0/parameter",
|
||||
json=body,
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["ok"] is True
|
||||
assert len(state_provider._param_calls) == 1
|
||||
assert state_provider._param_calls[0] == ("volume", -3.0, 0)
|
||||
|
||||
def test_set_channel_parameters_batch(self, client, auth_headers, state_provider):
|
||||
body = {
|
||||
"updates": [
|
||||
{"param_type": "volume", "value": -6.0, "channel": 0},
|
||||
{"param_type": "pan", "value": 0.5, "channel": 0},
|
||||
]
|
||||
}
|
||||
resp = client.put(
|
||||
"/api/v1/channels/0/parameters",
|
||||
json=body,
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["ok"] is True
|
||||
assert len(state_provider._param_calls) == 2
|
||||
|
||||
# ── Mixes / Master ───────────────────────────────────────────────────
|
||||
|
||||
def test_get_mixes(self, client, auth_headers):
|
||||
resp = client.get("/api/v1/mixes", headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "volume_db" in data
|
||||
assert len(data["aux_buses"]) == 1
|
||||
|
||||
def test_set_master_parameter(self, client, auth_headers, state_provider):
|
||||
body = {"param_type": "master_volume", "value": -6.0}
|
||||
resp = client.put(
|
||||
"/api/v1/mixes/parameter",
|
||||
json=body,
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert len(state_provider._param_calls) == 1
|
||||
assert state_provider._param_calls[0] == ("master_volume", -6.0, -1)
|
||||
|
||||
# ── Plugins ──────────────────────────────────────────────────────────
|
||||
|
||||
def test_list_plugins(self, client, auth_headers):
|
||||
resp = client.get("/api/v1/plugins", headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data) == 1
|
||||
assert data[0]["name"] == "Ch1 Gate"
|
||||
|
||||
# ── Transport ────────────────────────────────────────────────────────
|
||||
|
||||
def test_get_transport(self, client, auth_headers):
|
||||
resp = client.get("/api/v1/transport", headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "playing" in data
|
||||
assert "tempo_bpm" in data
|
||||
|
||||
def test_transport_command(self, client, auth_headers, state_provider):
|
||||
resp = client.put(
|
||||
"/api/v1/transport/command?command=play",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert len(state_provider._transport_calls) == 1
|
||||
assert state_provider._transport_calls[0] == "play"
|
||||
|
||||
# ── Routing ──────────────────────────────────────────────────────────
|
||||
|
||||
def test_get_routing(self, client, auth_headers):
|
||||
resp = client.get("/api/v1/routing", headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "nodes" in data
|
||||
assert "edges" in data
|
||||
|
||||
# ── Scenes ───────────────────────────────────────────────────────────
|
||||
|
||||
def test_list_scenes(self, client, auth_headers):
|
||||
resp = client.get("/api/v1/scenes", headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data == ["Scene A", "Scene B"]
|
||||
|
||||
def test_load_scene_valid(self, client, auth_headers):
|
||||
resp = client.post("/api/v1/scenes/Scene A/load", headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["ok"] is True
|
||||
|
||||
def test_load_scene_not_found(self, client, auth_headers):
|
||||
resp = client.post("/api/v1/scenes/Nonexistent/load", headers=auth_headers)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_save_scene(self, client, auth_headers):
|
||||
resp = client.post("/api/v1/scenes/New Scene/save", headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["ok"] is True
|
||||
@@ -0,0 +1,271 @@
|
||||
"""Tests for JSON schemas / Pydantic models."""
|
||||
|
||||
import json
|
||||
import pytest
|
||||
from datetime import datetime
|
||||
|
||||
from src.network.schemas import (
|
||||
ChannelSchema,
|
||||
MasterBusSchema,
|
||||
MixerStateSchema,
|
||||
PluginSchema,
|
||||
TransportSchema,
|
||||
RoutingSchema,
|
||||
AuxBusSchema,
|
||||
SubgroupSchema,
|
||||
VCASchema,
|
||||
EQSchema,
|
||||
EQBandSchema,
|
||||
CompressorSchema,
|
||||
GateSchema,
|
||||
FXSendsSchema,
|
||||
ParameterUpdateSchema,
|
||||
ParameterUpdateBatchSchema,
|
||||
ParameterSetRequest,
|
||||
WSMessage,
|
||||
WSMessageType,
|
||||
MixerParameterSchema,
|
||||
ParameterCategoryEnum,
|
||||
ParameterTypeEnum,
|
||||
RouteNodeSchema,
|
||||
RoutingEdgeSchema,
|
||||
NodeTypeEnum,
|
||||
APIResponse,
|
||||
PluginParamMapSchema,
|
||||
)
|
||||
|
||||
|
||||
class TestChannelSchema:
|
||||
"""Tests for ChannelSchema."""
|
||||
|
||||
def test_default_channel(self):
|
||||
ch = ChannelSchema(channel=0)
|
||||
assert ch.channel == 0
|
||||
assert ch.volume_db == 0.0
|
||||
assert ch.pan == 0.0
|
||||
assert not ch.mute
|
||||
assert not ch.solo
|
||||
|
||||
def test_channel_with_eq(self):
|
||||
ch = ChannelSchema(
|
||||
channel=3,
|
||||
volume_db=-6.0,
|
||||
eq=EQSchema(
|
||||
enabled=True,
|
||||
low=EQBandSchema(freq_hz=80.0, gain_db=3.0, q=1.0),
|
||||
),
|
||||
)
|
||||
assert ch.eq.enabled
|
||||
assert ch.eq.low.freq_hz == 80.0
|
||||
assert ch.eq.low.gain_db == 3.0
|
||||
|
||||
def test_serialize_deserialize(self):
|
||||
ch = ChannelSchema(channel=5, volume_db=-3.0, pan=0.5)
|
||||
data = ch.model_dump()
|
||||
ch2 = ChannelSchema.model_validate(data)
|
||||
assert ch2.channel == 5
|
||||
assert ch2.volume_db == -3.0
|
||||
assert ch2.pan == 0.5
|
||||
|
||||
def test_json_roundtrip(self):
|
||||
ch = ChannelSchema(channel=0, mute=True)
|
||||
json_str = ch.model_dump_json()
|
||||
data = json.loads(json_str)
|
||||
assert data["channel"] == 0
|
||||
assert data["mute"] is True
|
||||
|
||||
|
||||
class TestMasterBusSchema:
|
||||
"""Tests for MasterBusSchema."""
|
||||
|
||||
def test_default_master(self):
|
||||
m = MasterBusSchema()
|
||||
assert m.volume_db == 0.0
|
||||
assert not m.muted
|
||||
assert not m.dim_active
|
||||
assert m.aux_buses == []
|
||||
|
||||
def test_with_buses(self):
|
||||
m = MasterBusSchema(
|
||||
volume_db=-10.0,
|
||||
aux_buses=[
|
||||
AuxBusSchema(index=0, label="Reverb"),
|
||||
AuxBusSchema(index=1, label="Delay"),
|
||||
],
|
||||
subgroups=[
|
||||
SubgroupSchema(index=0, label="Drums", members=[0, 1, 2]),
|
||||
],
|
||||
vca_groups=[
|
||||
VCASchema(index=0, label="All Vocals", master_db=-3.0, members=[3, 4, 5]),
|
||||
],
|
||||
)
|
||||
assert len(m.aux_buses) == 2
|
||||
assert m.aux_buses[0].label == "Reverb"
|
||||
assert len(m.subgroups) == 1
|
||||
assert m.subgroups[0].members == [0, 1, 2]
|
||||
assert m.vca_groups[0].master_db == -3.0
|
||||
|
||||
|
||||
class TestTransportSchema:
|
||||
"""Tests for TransportSchema."""
|
||||
|
||||
def test_defaults(self):
|
||||
t = TransportSchema()
|
||||
assert not t.playing
|
||||
assert not t.recording
|
||||
assert t.tempo_bpm == 120.0
|
||||
assert t.position_sec == 0.0
|
||||
|
||||
def test_playing_state(self):
|
||||
t = TransportSchema(playing=True, tempo_bpm=142.0, position_sec=35.2)
|
||||
assert t.playing
|
||||
assert not t.recording
|
||||
assert t.tempo_bpm == 142.0
|
||||
|
||||
|
||||
class TestMixerStateSchema:
|
||||
"""Tests for full MixerStateSchema."""
|
||||
|
||||
def test_empty_state(self):
|
||||
state = MixerStateSchema()
|
||||
assert state.channels == []
|
||||
assert state.master.volume_db == 0.0
|
||||
assert state.transport.tempo_bpm == 120.0
|
||||
|
||||
def test_full_state(self):
|
||||
state = MixerStateSchema(
|
||||
channels=[
|
||||
ChannelSchema(channel=0, volume_db=-6.0),
|
||||
ChannelSchema(channel=1, mute=True),
|
||||
],
|
||||
master=MasterBusSchema(volume_db=-3.0),
|
||||
transport=TransportSchema(playing=True),
|
||||
scenes=["Intro", "Verse", "Chorus"],
|
||||
uptime_seconds=3600.0,
|
||||
)
|
||||
assert len(state.channels) == 2
|
||||
assert state.scenes == ["Intro", "Verse", "Chorus"]
|
||||
assert state.uptime_seconds == 3600.0
|
||||
|
||||
def test_json_roundtrip(self):
|
||||
state = MixerStateSchema(
|
||||
channels=[ChannelSchema(channel=0)],
|
||||
uptime_seconds=10.0,
|
||||
)
|
||||
json_str = state.model_dump_json()
|
||||
data = json.loads(json_str)
|
||||
assert data["uptime_seconds"] == 10.0
|
||||
assert len(data["channels"]) == 1
|
||||
|
||||
|
||||
class TestParameterUpdate:
|
||||
"""Tests for parameter update schemas."""
|
||||
|
||||
def test_single_update(self):
|
||||
update = ParameterUpdateSchema(
|
||||
param_type=ParameterTypeEnum.VOLUME,
|
||||
value=-3.0,
|
||||
channel=2,
|
||||
)
|
||||
assert update.param_type == ParameterTypeEnum.VOLUME
|
||||
assert update.value == -3.0
|
||||
assert update.channel == 2
|
||||
|
||||
def test_batch_update(self):
|
||||
batch = ParameterUpdateBatchSchema(updates=[
|
||||
ParameterUpdateSchema(param_type=ParameterTypeEnum.VOLUME, value=-6.0, channel=0),
|
||||
ParameterUpdateSchema(param_type=ParameterTypeEnum.MUTE, value=1.0, channel=0),
|
||||
ParameterUpdateSchema(param_type=ParameterTypeEnum.MASTER_VOLUME, value=-3.0),
|
||||
])
|
||||
assert len(batch.updates) == 3
|
||||
assert batch.updates[2].param_type == ParameterTypeEnum.MASTER_VOLUME
|
||||
|
||||
|
||||
class TestWSMessage:
|
||||
"""Tests for WebSocket message schemas."""
|
||||
|
||||
def test_parameter_update_message(self):
|
||||
msg = WSMessage(
|
||||
type=WSMessageType.PARAMETER_UPDATE,
|
||||
payload={"param_type": "volume", "value": -3.0, "channel": 1},
|
||||
)
|
||||
assert msg.type == WSMessageType.PARAMETER_UPDATE
|
||||
assert msg.payload["param_type"] == "volume"
|
||||
|
||||
def test_full_state_message(self):
|
||||
msg = WSMessage(
|
||||
type=WSMessageType.FULL_STATE,
|
||||
payload={"channels": []},
|
||||
)
|
||||
data = msg.model_dump()
|
||||
assert data["type"] == "full_state"
|
||||
|
||||
def test_error_message(self):
|
||||
msg = WSMessage(
|
||||
type=WSMessageType.ERROR,
|
||||
payload={"message": "Something went wrong"},
|
||||
)
|
||||
assert msg.payload["message"] == "Something went wrong"
|
||||
|
||||
|
||||
class TestParameterSetRequest:
|
||||
"""Tests for ParameterSetRequest."""
|
||||
|
||||
def test_default_param_type(self):
|
||||
req = ParameterSetRequest(value=-3.0)
|
||||
assert req.param_type == "volume"
|
||||
assert req.value == -3.0
|
||||
|
||||
def test_explicit_param_type(self):
|
||||
req = ParameterSetRequest(param_type="mute", value=1.0)
|
||||
assert req.param_type == "mute"
|
||||
|
||||
def test_json_parse(self):
|
||||
json_str = '{"param_type": "pan", "value": 0.5}'
|
||||
req = ParameterSetRequest.model_validate_json(json_str)
|
||||
assert req.param_type == "pan"
|
||||
assert req.value == 0.5
|
||||
|
||||
|
||||
class TestPluginSchema:
|
||||
"""Tests for PluginSchema."""
|
||||
|
||||
def test_plugin(self):
|
||||
p = PluginSchema(
|
||||
plugin_id=1,
|
||||
name="Ch1 Gate",
|
||||
role="gate",
|
||||
channel=0,
|
||||
params=[
|
||||
PluginParamMapSchema(name="threshold", index=0, value=0.5),
|
||||
PluginParamMapSchema(name="ratio", index=1, value=0.3),
|
||||
],
|
||||
)
|
||||
assert p.plugin_id == 1
|
||||
assert len(p.params) == 2
|
||||
assert p.params[0].name == "threshold"
|
||||
|
||||
|
||||
class TestRoutingSchema:
|
||||
"""Tests for RoutingSchema."""
|
||||
|
||||
def test_empty_routing(self):
|
||||
r = RoutingSchema()
|
||||
assert r.nodes == []
|
||||
assert r.edges == []
|
||||
|
||||
def test_with_nodes_and_edges(self):
|
||||
r = RoutingSchema(
|
||||
nodes=[
|
||||
RouteNodeSchema(node_id="sys_in_1", type=NodeTypeEnum.SYSTEM_INPUT, jack_port="system:capture_1"),
|
||||
RouteNodeSchema(node_id="ch_0_input", type=NodeTypeEnum.CHANNEL_INPUT, channel=0),
|
||||
],
|
||||
edges=[
|
||||
RoutingEdgeSchema(source="sys_in_1", dest="ch_0_input"),
|
||||
],
|
||||
solo_nodes=["ch_0_input"],
|
||||
)
|
||||
assert len(r.nodes) == 2
|
||||
assert len(r.edges) == 1
|
||||
assert r.edges[0].source == "sys_in_1"
|
||||
assert r.solo_nodes == ["ch_0_input"]
|
||||
@@ -0,0 +1,199 @@
|
||||
"""Tests for session-based WebSocket authentication."""
|
||||
|
||||
import time
|
||||
import pytest
|
||||
from src.network.session import Session, SessionManager, DEFAULT_SESSION_TTL
|
||||
|
||||
|
||||
class TestSession:
|
||||
"""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):
|
||||
s = Session("abc", "client", metadata={"user": "admin"})
|
||||
assert s.metadata["user"] == "admin"
|
||||
|
||||
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):
|
||||
s = Session("abc", "client", ttl=3600)
|
||||
assert not s.expired
|
||||
assert s.ttl_remaining > 3500 # generous margin
|
||||
|
||||
def test_session_custom_ttl(self):
|
||||
s = Session("abc", "client", ttl=60)
|
||||
assert 59 < s.ttl_remaining <= 60
|
||||
|
||||
|
||||
class TestSessionManager:
|
||||
"""Tests for SessionManager."""
|
||||
|
||||
@pytest.fixture
|
||||
def mgr(self):
|
||||
return SessionManager(ttl=3600, max_sessions=10)
|
||||
|
||||
def test_initial_state(self, mgr):
|
||||
assert mgr.active_sessions == 0
|
||||
stats = mgr.stats
|
||||
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):
|
||||
token = mgr.create_session("browser-chrome")
|
||||
assert token is not None
|
||||
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):
|
||||
token = mgr.create_session("browser-firefox")
|
||||
session = mgr.validate_token(token)
|
||||
assert session is not None
|
||||
assert session.client_id == "browser-firefox"
|
||||
assert not session.expired
|
||||
|
||||
def test_validate_invalid_token(self, mgr):
|
||||
session = mgr.validate_token("invalid-token")
|
||||
assert session is None
|
||||
|
||||
def test_validate_empty_token(self, mgr):
|
||||
assert mgr.validate_token("") is None
|
||||
assert mgr.validate_token(None) is None # type: ignore
|
||||
|
||||
def test_validate_expired_token(self, mgr):
|
||||
token = mgr.create_session("browser", ttl=0.01)
|
||||
time.sleep(0.02)
|
||||
session = mgr.validate_token(token)
|
||||
assert session is None
|
||||
assert mgr.active_sessions == 0
|
||||
|
||||
def test_destroy_session(self, mgr):
|
||||
token = mgr.create_session("browser")
|
||||
assert mgr.active_sessions == 1
|
||||
destroyed = mgr.destroy_session(token)
|
||||
assert destroyed
|
||||
assert mgr.active_sessions == 0
|
||||
assert mgr.validate_token(token) is None
|
||||
|
||||
def test_destroy_invalid_token(self, mgr):
|
||||
assert mgr.destroy_session("nope") is False
|
||||
|
||||
def test_destroy_by_id(self, mgr):
|
||||
token = mgr.create_session("browser")
|
||||
session = mgr.validate_token(token)
|
||||
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):
|
||||
assert mgr.destroy_by_id("nonexistent") is False
|
||||
|
||||
def test_get_session(self, mgr):
|
||||
token = mgr.create_session("browser")
|
||||
session = mgr.get_session(token)
|
||||
assert session is not None
|
||||
assert session.client_id == "browser"
|
||||
|
||||
# Even expired sessions are returned by get_session
|
||||
session.expires_at = time.time() - 1
|
||||
assert mgr.get_session(token) is not None
|
||||
|
||||
def test_refresh_session(self, mgr):
|
||||
token = mgr.create_session("browser", ttl=3600)
|
||||
session = mgr.get_session(token)
|
||||
original_expiry = session.expires_at
|
||||
|
||||
# Fast-forward expiry then refresh
|
||||
session.expires_at = time.time() + 1 # 1 second left
|
||||
assert mgr.refresh_session(token)
|
||||
session = mgr.get_session(token)
|
||||
assert session.expires_at > original_expiry
|
||||
|
||||
def test_refresh_expired_session(self, mgr):
|
||||
token = mgr.create_session("browser", ttl=0.01)
|
||||
time.sleep(0.02)
|
||||
assert not mgr.refresh_session(token)
|
||||
|
||||
def test_multiple_sessions(self, mgr):
|
||||
tokens = [mgr.create_session(f"client-{i}") for i in range(5)]
|
||||
assert mgr.active_sessions == 5
|
||||
assert mgr.stats["total_created"] == 5
|
||||
|
||||
# Validate all
|
||||
for token in tokens:
|
||||
assert mgr.validate_token(token) is not None
|
||||
|
||||
def test_max_sessions(self, mgr):
|
||||
"""Test that exceeding max_sessions evicts oldest."""
|
||||
# Fill up to max
|
||||
for i in range(10):
|
||||
mgr.create_session(f"client-{i}")
|
||||
|
||||
assert mgr.active_sessions == 10
|
||||
|
||||
# Create one more — should evict oldest
|
||||
mgr.create_session("overflow")
|
||||
assert mgr.active_sessions == 10
|
||||
assert mgr.stats["total_created"] == 11
|
||||
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:
|
||||
"""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):
|
||||
"""Tokens should not contain the session ID in plaintext."""
|
||||
mgr = SessionManager()
|
||||
token = mgr.create_session("client")
|
||||
session = mgr.get_session(token)
|
||||
assert session is not None
|
||||
assert session.session_id not in token
|
||||
assert "client" not in token
|
||||
@@ -0,0 +1,358 @@
|
||||
"""Tests for the touchscreen UI IPC client — REST + OSC communication.
|
||||
|
||||
Tests the MixerClient class without requiring a running mixer server
|
||||
(uses mocking) and without requiring Kivy (pure Python).
|
||||
|
||||
Run:
|
||||
python3 -m pytest tests/test_touch_ui.py -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import socket
|
||||
import threading
|
||||
from unittest.mock import patch, MagicMock, call
|
||||
|
||||
import pytest
|
||||
|
||||
from src.ui.ipc import MixerClient, encode_osc, _sync_fetch, _sync_put
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# OSC encoding tests
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestOSCEncode:
|
||||
"""Test the pure-Python OSC encoder used by the IPC client."""
|
||||
|
||||
def test_encode_float(self):
|
||||
packet = encode_osc("/test/value", 0.75)
|
||||
# Should be a valid OSC packet
|
||||
assert b"/test/value" in packet
|
||||
assert b",f" in packet
|
||||
|
||||
def test_encode_int(self):
|
||||
packet = encode_osc("/test/chan", 3)
|
||||
assert b"/test/chan" in packet
|
||||
assert b",i" in packet
|
||||
|
||||
def test_encode_string(self):
|
||||
packet = encode_osc("/test/label", "hello")
|
||||
assert b"/test/label" in packet
|
||||
assert b",s" in packet
|
||||
assert b"hello" in packet
|
||||
|
||||
def test_encode_mixed(self):
|
||||
packet = encode_osc("/mixer/channel/3/volume", 3, 0.75)
|
||||
assert b"/mixer/channel/3/volume" in packet
|
||||
assert b",if" in packet
|
||||
|
||||
def test_encode_4byte_alignment(self):
|
||||
"""OSC packets are padded to 4-byte boundaries."""
|
||||
packet = encode_osc("/test", 1.0)
|
||||
assert len(packet) % 4 == 0
|
||||
|
||||
def test_encode_multiple_args(self):
|
||||
packet = encode_osc("/complex", 1.0, 2, "three")
|
||||
assert len(packet) % 4 == 0
|
||||
assert b",fis" in packet
|
||||
|
||||
def test_encode_no_args(self):
|
||||
packet = encode_osc("/naked")
|
||||
assert b"/naked" in packet
|
||||
assert b",\x00" in packet or b",\x00\x00\x00" in packet
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# MixerClient REST API tests
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestMixerClient:
|
||||
"""Test the MixerClient REST communication layer."""
|
||||
|
||||
def test_init_defaults(self):
|
||||
client = MixerClient()
|
||||
assert client.host == "127.0.0.1"
|
||||
assert client.rest_port == 8000
|
||||
assert client.osc_port == 9001
|
||||
assert client.api_key == ""
|
||||
|
||||
def test_init_custom(self):
|
||||
client = MixerClient(
|
||||
host="192.168.1.10",
|
||||
rest_port=8080,
|
||||
osc_port=9002,
|
||||
api_key="secret",
|
||||
)
|
||||
assert client.host == "192.168.1.10"
|
||||
assert client.rest_port == 8080
|
||||
assert client.osc_port == 9002
|
||||
assert client.api_key == "secret"
|
||||
|
||||
def test_url_building(self):
|
||||
client = MixerClient(host="mixer.local", rest_port=8000)
|
||||
assert client._url("/state") == "http://mixer.local:8000/state"
|
||||
assert client._url("/channels") == "http://mixer.local:8000/channels"
|
||||
|
||||
def test_headers_without_api_key(self):
|
||||
client = MixerClient()
|
||||
h = client._headers()
|
||||
assert "Content-Type" in h
|
||||
assert "X-API-Key" not in h
|
||||
|
||||
def test_headers_with_api_key(self):
|
||||
client = MixerClient(api_key="abc123")
|
||||
h = client._headers()
|
||||
assert h["X-API-Key"] == "abc123"
|
||||
|
||||
@patch("src.ui.ipc.UrlRequest")
|
||||
def test_fetch_state_builds_correct_url(self, mock_urlreq):
|
||||
client = MixerClient(host="test.local", rest_port=9999)
|
||||
callback = MagicMock()
|
||||
|
||||
client.fetch_state(on_success=callback)
|
||||
|
||||
# UrlRequest should have been called with the correct URL
|
||||
mock_urlreq.assert_called_once()
|
||||
call_args = mock_urlreq.call_args
|
||||
url = call_args[0][0] if call_args[0] else call_args[1].get("url")
|
||||
# Positional arg or keyword — check both
|
||||
assert "test.local:9999/state" in str(call_args)
|
||||
|
||||
@patch("src.ui.ipc.UrlRequest")
|
||||
def test_set_parameter_channel(self, mock_urlreq):
|
||||
client = MixerClient(host="test.local", rest_port=8000)
|
||||
client.set_parameter("volume", -6.0, channel=3)
|
||||
|
||||
mock_urlreq.assert_called_once()
|
||||
call_args = mock_urlreq.call_args
|
||||
args_str = str(call_args)
|
||||
assert "channels/3/parameter" in args_str or "/3/" in str(call_args)
|
||||
|
||||
@patch("src.ui.ipc.UrlRequest")
|
||||
def test_set_master_parameter(self, mock_urlreq):
|
||||
client = MixerClient(host="test.local", rest_port=8000)
|
||||
client.set_master_parameter("master_volume", -3.0)
|
||||
|
||||
mock_urlreq.assert_called_once()
|
||||
call_args = mock_urlreq.call_args
|
||||
args_str = str(call_args)
|
||||
assert "mixes/parameter" in args_str
|
||||
|
||||
@patch("src.ui.ipc.UrlRequest")
|
||||
def test_transport_command(self, mock_urlreq):
|
||||
client = MixerClient(host="test.local", rest_port=8000)
|
||||
client.transport_command("play")
|
||||
|
||||
mock_urlreq.assert_called_once()
|
||||
call_args = mock_urlreq.call_args
|
||||
args_str = str(call_args)
|
||||
assert "transport/command" in args_str
|
||||
|
||||
def test_osc_send_creates_socket(self):
|
||||
client = MixerClient()
|
||||
# Mock socket to avoid actual network
|
||||
with patch("socket.socket") as mock_sock_class:
|
||||
mock_sock = MagicMock()
|
||||
mock_sock_class.return_value = mock_sock
|
||||
result = client.osc_send("/test", 1.0)
|
||||
assert result is True
|
||||
mock_sock.sendto.assert_called_once()
|
||||
|
||||
def test_osc_set_channel_param(self):
|
||||
client = MixerClient()
|
||||
with patch("socket.socket") as mock_sock_class:
|
||||
mock_sock = MagicMock()
|
||||
mock_sock_class.return_value = mock_sock
|
||||
result = client.osc_set_channel_param(3, "volume", -6.0)
|
||||
assert result is True
|
||||
# Verify the packet contains the right address
|
||||
call_args = mock_sock.sendto.call_args[0]
|
||||
packet = call_args[0]
|
||||
assert b"/mixer/channel/3/volume" in packet
|
||||
|
||||
def test_close_cleans_up(self):
|
||||
client = MixerClient()
|
||||
with patch("socket.socket") as mock_sock_class:
|
||||
mock_sock = MagicMock()
|
||||
mock_sock_class.return_value = mock_sock
|
||||
client.osc_send("/test", 1.0)
|
||||
client.close()
|
||||
mock_sock.close.assert_called_once()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Synchronous fallback tests (no Kivy required)
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestSyncFallback:
|
||||
"""Test the synchronous urllib fallback used when Kivy is unavailable."""
|
||||
|
||||
@patch("urllib.request.urlopen")
|
||||
def test_sync_fetch_success(self, mock_urlopen):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.read.return_value = json.dumps({"ok": True, "data": {"channels": []}}).encode()
|
||||
mock_urlopen.return_value.__enter__.return_value = mock_resp
|
||||
|
||||
results = []
|
||||
_sync_fetch("http://localhost:8000/state", on_success=lambda d: results.append(d))
|
||||
assert len(results) == 1
|
||||
assert results[0]["ok"] is True
|
||||
|
||||
@patch("urllib.request.urlopen")
|
||||
def test_sync_fetch_http_error(self, mock_urlopen):
|
||||
import urllib.error
|
||||
mock_urlopen.side_effect = urllib.error.HTTPError(
|
||||
"http://localhost", 500, "Server Error", {}, None
|
||||
)
|
||||
errors = []
|
||||
_sync_fetch("http://localhost:8000/state", on_success=None, on_error=lambda e: errors.append(e))
|
||||
assert len(errors) == 1
|
||||
assert "500" in errors[0]
|
||||
|
||||
@patch("urllib.request.urlopen")
|
||||
def test_sync_put_success(self, mock_urlopen):
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.read.return_value = json.dumps({"ok": True}).encode()
|
||||
mock_urlopen.return_value.__enter__.return_value = mock_resp
|
||||
|
||||
results = []
|
||||
_sync_put(
|
||||
"http://localhost:8000/channels/0/parameter",
|
||||
json.dumps({"param_type": "volume", "value": 0.75}),
|
||||
on_success=lambda d: results.append(d),
|
||||
)
|
||||
assert len(results) == 1
|
||||
assert results[0]["ok"] is True
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Fader value conversion tests (pure math, no GUI needed)
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestFaderValueConversion:
|
||||
"""Test the fader value conversion math without instantiating Kivy widgets."""
|
||||
|
||||
def test_normalized_range(self):
|
||||
"""Test basic value-to-normalized conversion."""
|
||||
min_val, max_val = -60.0, 12.0
|
||||
def normalize(v):
|
||||
return (v - min_val) / (max_val - min_val)
|
||||
|
||||
assert normalize(-60.0) == pytest.approx(0.0)
|
||||
assert normalize(12.0) == pytest.approx(1.0)
|
||||
assert normalize(-24.0) == pytest.approx(36.0 / 72.0)
|
||||
|
||||
def test_db_to_normalized_edge_cases(self):
|
||||
"""Edge cases for dB conversion."""
|
||||
def normalize(v, lo=-60.0, hi=12.0):
|
||||
return max(0.0, min(1.0, (v - lo) / (hi - lo)))
|
||||
|
||||
assert normalize(-100.0) == 0.0 # below min
|
||||
assert normalize(50.0) == 1.0 # above max
|
||||
assert normalize(-60.0) == 0.0 # at min
|
||||
assert normalize(12.0) == 1.0 # at max
|
||||
|
||||
def test_y_position_mapping(self):
|
||||
"""Test the fader position math matches expected range."""
|
||||
min_val, max_val = -60.0, 12.0
|
||||
track_bottom = 40
|
||||
track_top = 600 - 12
|
||||
track_height = track_top - track_bottom
|
||||
|
||||
def value_to_y(v):
|
||||
norm = (v - min_val) / (max_val - min_val)
|
||||
return track_bottom + norm * track_height
|
||||
|
||||
def y_to_value(y):
|
||||
norm = max(0.0, min(1.0, (y - track_bottom) / track_height))
|
||||
return min_val + norm * (max_val - min_val)
|
||||
|
||||
# Round-trip test
|
||||
for db in [-60, -40, -24, -12, 0, 6, 12]:
|
||||
y = value_to_y(db)
|
||||
back = y_to_value(y)
|
||||
assert back == pytest.approx(db, abs=0.1), f"Round-trip failed for {db} dB: got {back}"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Theme tests
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestTheme:
|
||||
"""Verify theme constants are reasonable."""
|
||||
|
||||
def test_colors_are_valid_rgba(self):
|
||||
from src.ui.theme import Colors
|
||||
for name in dir(Colors):
|
||||
if name.startswith("_"):
|
||||
continue
|
||||
val = getattr(Colors, name)
|
||||
if isinstance(val, (tuple, list)) and len(val) == 4:
|
||||
assert all(0.0 <= c <= 1.0 for c in val), f"{name} has out-of-range color"
|
||||
assert val[3] == 1.0, f"{name} alpha not 1.0"
|
||||
|
||||
def test_fader_colors_count(self):
|
||||
from src.ui.theme import Colors
|
||||
assert len(Colors.FADER_COLORS) == 8
|
||||
|
||||
def test_sizes_are_positive(self):
|
||||
from src.ui.theme import Sizes
|
||||
from kivy.metrics import dp
|
||||
for name in dir(Sizes):
|
||||
if name.startswith("_"):
|
||||
continue
|
||||
val = getattr(Sizes, name)
|
||||
if isinstance(val, (int, float)):
|
||||
assert val > 0, f"{name} is not positive: {val}"
|
||||
|
||||
def test_fonts_are_positive(self):
|
||||
from src.ui.theme import Fonts
|
||||
for name in dir(Fonts):
|
||||
if name.startswith("_"):
|
||||
continue
|
||||
val = getattr(Fonts, name)
|
||||
if isinstance(val, (int, float)):
|
||||
assert val > 0, f"{name} is not positive: {val}"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Integration test: client lifecycle
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestClientLifecycle:
|
||||
"""Test the client lifecycle: init → use → close."""
|
||||
|
||||
def test_multiple_osc_sends_reuse_socket(self):
|
||||
client = MixerClient()
|
||||
with patch("socket.socket") as mock_sock_class:
|
||||
mock_sock = MagicMock()
|
||||
mock_sock_class.return_value = mock_sock
|
||||
|
||||
client.osc_send("/test/a", 1.0)
|
||||
client.osc_send("/test/b", 2.0)
|
||||
|
||||
# Should only create one socket
|
||||
assert mock_sock_class.call_count == 1
|
||||
assert mock_sock.sendto.call_count == 2
|
||||
|
||||
def test_close_then_send_recreates_socket(self):
|
||||
client = MixerClient()
|
||||
with patch("socket.socket") as mock_sock_class:
|
||||
mock_sock1 = MagicMock()
|
||||
mock_sock2 = MagicMock()
|
||||
mock_sock_class.side_effect = [mock_sock1, mock_sock2]
|
||||
|
||||
client.osc_send("/test", 1.0)
|
||||
client.close()
|
||||
mock_sock1.close.assert_called_once()
|
||||
|
||||
client.osc_send("/test", 2.0)
|
||||
assert mock_sock_class.call_count == 2
|
||||
|
||||
def test_cancel_all_clears_pending(self):
|
||||
client = MixerClient()
|
||||
client._pending_requests.add("fake_req")
|
||||
client.cancel_all()
|
||||
assert len(client._pending_requests) == 0
|
||||
@@ -0,0 +1,223 @@
|
||||
"""Tests for MIDI types and message handling."""
|
||||
|
||||
import pytest
|
||||
from src.midi.types import (
|
||||
MIDIMessage,
|
||||
MIDIMessageType,
|
||||
MIDIMapping,
|
||||
MixerParameter,
|
||||
ParameterCategory,
|
||||
ParameterType,
|
||||
MIDISystemMessage,
|
||||
)
|
||||
|
||||
|
||||
class TestMIDIMessageType:
|
||||
def test_from_status_cc(self):
|
||||
assert MIDIMessageType.from_status(0xB0) == MIDIMessageType.CONTROL_CHANGE
|
||||
assert MIDIMessageType.from_status(0xB3) == MIDIMessageType.CONTROL_CHANGE
|
||||
|
||||
def test_from_status_note_on(self):
|
||||
assert MIDIMessageType.from_status(0x90) == MIDIMessageType.NOTE_ON
|
||||
assert MIDIMessageType.from_status(0x9F) == MIDIMessageType.NOTE_ON
|
||||
|
||||
def test_from_status_note_off(self):
|
||||
assert MIDIMessageType.from_status(0x80) == MIDIMessageType.NOTE_OFF
|
||||
|
||||
def test_channel(self):
|
||||
assert MIDIMessageType.channel(0xB0) == 0
|
||||
assert MIDIMessageType.channel(0xB3) == 3
|
||||
assert MIDIMessageType.channel(0xBF) == 15
|
||||
|
||||
|
||||
class TestMIDIMessage:
|
||||
def test_cc_message(self):
|
||||
msg = MIDIMessage(
|
||||
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||
channel=0,
|
||||
controller=7,
|
||||
value=100,
|
||||
)
|
||||
assert msg.is_cc
|
||||
assert not msg.is_nrpn
|
||||
assert msg.controller == 7
|
||||
assert msg.value == 100
|
||||
assert msg.value_normalised == pytest.approx(100 / 127, abs=0.01)
|
||||
|
||||
def test_note_on_message(self):
|
||||
msg = MIDIMessage(
|
||||
msg_type=MIDIMessageType.NOTE_ON,
|
||||
channel=1,
|
||||
controller=60,
|
||||
value=127,
|
||||
)
|
||||
assert not msg.is_cc
|
||||
assert msg.channel == 1
|
||||
|
||||
def test_pitch_bend_message(self):
|
||||
msg = MIDIMessage(
|
||||
msg_type=MIDIMessageType.PITCH_BEND,
|
||||
channel=0,
|
||||
value=8192, # Centre
|
||||
)
|
||||
assert msg.value_normalised == pytest.approx(0.0, abs=0.01)
|
||||
|
||||
msg2 = MIDIMessage(
|
||||
msg_type=MIDIMessageType.PITCH_BEND,
|
||||
channel=0,
|
||||
value=16383,
|
||||
)
|
||||
assert msg2.value_normalised == pytest.approx(1.0, abs=0.02)
|
||||
|
||||
def test_nrpn_message(self):
|
||||
msg = MIDIMessage(
|
||||
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||
channel=0,
|
||||
is_nrpn=True,
|
||||
nrpn_msb=0,
|
||||
nrpn_lsb=1,
|
||||
value=1000,
|
||||
)
|
||||
assert msg.is_nrpn
|
||||
assert msg.nrpn_number == 1 # (0 << 7) | 1
|
||||
|
||||
def test_nrpn_number_14bit(self):
|
||||
msg = MIDIMessage(
|
||||
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||
channel=0,
|
||||
is_nrpn=True,
|
||||
nrpn_msb=0x01,
|
||||
nrpn_lsb=0x02,
|
||||
)
|
||||
assert msg.nrpn_number == (0x01 << 7) | 0x02
|
||||
|
||||
def test_repr(self):
|
||||
msg = MIDIMessage(
|
||||
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||
channel=0,
|
||||
controller=7,
|
||||
value=100,
|
||||
source_device="TestDevice",
|
||||
)
|
||||
r = repr(msg)
|
||||
assert "CONTROL_CHANGE" in r
|
||||
assert "CC=7" in r
|
||||
assert "val=100" in r
|
||||
assert "TestDevice" in r
|
||||
|
||||
|
||||
class TestMIDIMapping:
|
||||
def test_cc_mapping_match(self):
|
||||
m = MIDIMapping(
|
||||
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||
channel=0,
|
||||
controller=7,
|
||||
param_type=ParameterType.VOLUME,
|
||||
param_channel=0,
|
||||
)
|
||||
msg = MIDIMessage(
|
||||
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||
channel=0,
|
||||
controller=7,
|
||||
value=100,
|
||||
)
|
||||
assert m.matches(msg)
|
||||
|
||||
def test_cc_mapping_no_match_wrong_channel(self):
|
||||
m = MIDIMapping(
|
||||
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||
channel=0,
|
||||
controller=7,
|
||||
)
|
||||
msg = MIDIMessage(
|
||||
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||
channel=1,
|
||||
controller=7,
|
||||
value=100,
|
||||
)
|
||||
assert not m.matches(msg)
|
||||
|
||||
def test_cc_mapping_no_match_wrong_controller(self):
|
||||
m = MIDIMapping(
|
||||
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||
channel=0,
|
||||
controller=7,
|
||||
)
|
||||
msg = MIDIMessage(
|
||||
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||
channel=0,
|
||||
controller=10,
|
||||
value=100,
|
||||
)
|
||||
assert not m.matches(msg)
|
||||
|
||||
def test_device_filter(self):
|
||||
m = MIDIMapping(
|
||||
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||
channel=0,
|
||||
controller=7,
|
||||
source_device="X-Touch",
|
||||
)
|
||||
msg_match = MIDIMessage(
|
||||
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||
channel=0,
|
||||
controller=7,
|
||||
value=100,
|
||||
source_device="X-Touch",
|
||||
)
|
||||
msg_no_match = MIDIMessage(
|
||||
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||
channel=0,
|
||||
controller=7,
|
||||
value=100,
|
||||
source_device="MIDImix",
|
||||
)
|
||||
assert m.matches(msg_match)
|
||||
assert not m.matches(msg_no_match)
|
||||
|
||||
def test_nrpn_mapping_match(self):
|
||||
m = MIDIMapping(
|
||||
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||
channel=0,
|
||||
is_nrpn=True,
|
||||
nrpn_number=258, # (2 << 7) | 2
|
||||
)
|
||||
msg = MIDIMessage(
|
||||
msg_type=MIDIMessageType.CONTROL_CHANGE,
|
||||
channel=0,
|
||||
is_nrpn=True,
|
||||
nrpn_msb=2,
|
||||
nrpn_lsb=2,
|
||||
value=8192,
|
||||
)
|
||||
assert m.matches(msg)
|
||||
|
||||
def test_scale_value_linear(self):
|
||||
m = MIDIMapping(midi_min=0, midi_max=127, param_min=0.0, param_max=1.0)
|
||||
assert m.scale_value(0) == pytest.approx(0.0)
|
||||
assert m.scale_value(127) == pytest.approx(1.0)
|
||||
assert m.scale_value(63) == pytest.approx(0.5, abs=0.02)
|
||||
|
||||
def test_scale_value_invert(self):
|
||||
m = MIDIMapping(midi_min=0, midi_max=127, param_min=0.0, param_max=1.0, invert=True)
|
||||
assert m.scale_value(0) == pytest.approx(1.0)
|
||||
assert m.scale_value(127) == pytest.approx(0.0)
|
||||
|
||||
def test_scale_value_db_range(self):
|
||||
m = MIDIMapping(midi_min=0, midi_max=127, param_min=-60.0, param_max=12.0)
|
||||
assert m.scale_value(0) == pytest.approx(-60.0)
|
||||
assert m.scale_value(127) == pytest.approx(12.0)
|
||||
|
||||
def test_scale_value_exponential(self):
|
||||
m = MIDIMapping(midi_min=0, midi_max=127, param_min=0.0, param_max=1.0, curve="exponential")
|
||||
assert m.scale_value(0) == pytest.approx(0.0)
|
||||
assert m.scale_value(127) == pytest.approx(1.0)
|
||||
# Should be non-linear (value at 50% is < 50%)
|
||||
assert m.scale_value(63) < 0.5
|
||||
|
||||
def test_disabled_mapping(self):
|
||||
m = MIDIMapping(controller=7, enabled=False)
|
||||
msg = MIDIMessage(MIDIMessageType.CONTROL_CHANGE, 0, 7, 100)
|
||||
# The engine skips disabled mappings; matches() still works but caller checks enabled
|
||||
assert m.matches(msg)
|
||||
assert not m.enabled
|
||||
@@ -0,0 +1,318 @@
|
||||
"""Tests for web application routes — login, settings, file management."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from src.network.auth import APIKeyAuth, API_KEY_HEADER
|
||||
from src.network.session import SessionManager
|
||||
from src.network.web_routes import create_web_routes, get_static_files_app
|
||||
|
||||
|
||||
class TestLoginRoutes:
|
||||
"""Tests for session auth login/logout endpoints."""
|
||||
|
||||
@pytest.fixture
|
||||
def auth(self):
|
||||
return APIKeyAuth(keys=["test-key-123"])
|
||||
|
||||
@pytest.fixture
|
||||
def session_mgr(self):
|
||||
return SessionManager(ttl=3600, max_sessions=10)
|
||||
|
||||
@pytest.fixture
|
||||
def app(self, auth, session_mgr):
|
||||
app = FastAPI()
|
||||
routers = create_web_routes(
|
||||
auth=auth,
|
||||
session_manager=session_mgr,
|
||||
settings_getter=lambda: {},
|
||||
settings_setter=lambda _: True,
|
||||
)
|
||||
for router in routers:
|
||||
app.include_router(router)
|
||||
return app
|
||||
|
||||
@pytest.fixture
|
||||
def client(self, app):
|
||||
return TestClient(app)
|
||||
|
||||
def test_login_with_header(self, client):
|
||||
resp = client.post(
|
||||
"/api/v1/auth/login",
|
||||
headers={API_KEY_HEADER: "test-key-123"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "token" in data
|
||||
assert "expires_in" in data
|
||||
assert len(data["token"]) == 64
|
||||
|
||||
def test_login_with_json_body(self, client):
|
||||
resp = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"api_key": "test-key-123"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "token" in data
|
||||
|
||||
def test_login_invalid_key(self, client):
|
||||
resp = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"api_key": "wrong-key"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_login_missing_key(self, client):
|
||||
resp = client.post("/api/v1/auth/login")
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_login_no_body_or_header(self, client):
|
||||
resp = client.post(
|
||||
"/api/v1/auth/login",
|
||||
headers={},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_login_creates_session(self, client, session_mgr):
|
||||
assert session_mgr.active_sessions == 0
|
||||
resp = client.post(
|
||||
"/api/v1/auth/login",
|
||||
headers={API_KEY_HEADER: "test-key-123"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert session_mgr.active_sessions == 1
|
||||
|
||||
def test_logout_valid_token(self, client, session_mgr):
|
||||
# Create a session first
|
||||
token = session_mgr.create_session("browser")
|
||||
assert session_mgr.active_sessions == 1
|
||||
|
||||
resp = client.post(
|
||||
"/api/v1/auth/logout",
|
||||
json={"token": token},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["ok"] is True
|
||||
assert session_mgr.active_sessions == 0
|
||||
|
||||
def test_logout_invalid_token(self, client, session_mgr):
|
||||
resp = client.post(
|
||||
"/api/v1/auth/logout",
|
||||
json={"token": "not-a-real-token"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["ok"] is False
|
||||
|
||||
def test_logout_missing_token(self, client):
|
||||
resp = client.post("/api/v1/auth/logout")
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_login_without_content_type(self, client):
|
||||
"""Test login without JSON content-type header."""
|
||||
resp = client.post(
|
||||
"/api/v1/auth/login",
|
||||
content="not json",
|
||||
headers={API_KEY_HEADER: "test-key-123"},
|
||||
)
|
||||
assert resp.status_code == 200 # Falls back to header auth
|
||||
|
||||
|
||||
class TestSettingsRoutes:
|
||||
"""Tests for settings REST endpoints."""
|
||||
|
||||
@pytest.fixture
|
||||
def settings_state(self):
|
||||
return {"sample_rate": 48000, "buffer_size": 256, "volume_limit": -3.0}
|
||||
|
||||
@pytest.fixture
|
||||
def auth(self):
|
||||
return APIKeyAuth(keys=["test-key"])
|
||||
|
||||
@pytest.fixture
|
||||
def session_mgr(self):
|
||||
return SessionManager()
|
||||
|
||||
@pytest.fixture
|
||||
def app(self, auth, session_mgr, settings_state):
|
||||
app = FastAPI()
|
||||
|
||||
def getter():
|
||||
return dict(settings_state)
|
||||
|
||||
def setter(updates):
|
||||
valid = set(settings_state.keys())
|
||||
for k in updates:
|
||||
if k not in valid:
|
||||
return False
|
||||
settings_state.update(updates)
|
||||
return True
|
||||
|
||||
routers = create_web_routes(
|
||||
auth=auth,
|
||||
session_manager=session_mgr,
|
||||
settings_getter=getter,
|
||||
settings_setter=setter,
|
||||
)
|
||||
for router in routers:
|
||||
app.include_router(router)
|
||||
return app
|
||||
|
||||
@pytest.fixture
|
||||
def client(self, app):
|
||||
return TestClient(app)
|
||||
|
||||
def test_get_settings(self, client, settings_state):
|
||||
resp = client.get("/api/v1/settings")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data == settings_state
|
||||
|
||||
def test_update_settings_valid(self, client, settings_state):
|
||||
resp = client.put(
|
||||
"/api/v1/settings",
|
||||
json={"buffer_size": 512},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["ok"] is True
|
||||
assert settings_state["buffer_size"] == 512
|
||||
|
||||
def test_update_settings_invalid_key(self, client, settings_state):
|
||||
resp = client.put(
|
||||
"/api/v1/settings",
|
||||
json={"not_a_setting": 123},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_update_settings_invalid_body(self, client):
|
||||
resp = client.put(
|
||||
"/api/v1/settings",
|
||||
content="not json",
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_update_settings_not_a_dict(self, client):
|
||||
resp = client.put(
|
||||
"/api/v1/settings",
|
||||
json=[1, 2, 3],
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
class TestFileRoutes:
|
||||
"""Tests for file management REST endpoints."""
|
||||
|
||||
@pytest.fixture
|
||||
def audio_dir(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
audio_path = Path(tmp)
|
||||
# Create some test audio files
|
||||
(audio_path / "track1.wav").write_text("fake wav data")
|
||||
(audio_path / "track2.mp3").write_text("fake mp3 data")
|
||||
(audio_path / "readme.txt").write_text("not audio")
|
||||
(audio_path / "track3.flac").write_text("fake flac data")
|
||||
yield audio_path
|
||||
|
||||
@pytest.fixture
|
||||
def auth(self):
|
||||
return APIKeyAuth(keys=["test-key"])
|
||||
|
||||
@pytest.fixture
|
||||
def session_mgr(self):
|
||||
return SessionManager()
|
||||
|
||||
@pytest.fixture
|
||||
def app(self, auth, session_mgr, audio_dir):
|
||||
app = FastAPI()
|
||||
routers = create_web_routes(
|
||||
auth=auth,
|
||||
session_manager=session_mgr,
|
||||
settings_getter=lambda: {},
|
||||
settings_setter=lambda _: True,
|
||||
audio_dir=audio_dir,
|
||||
)
|
||||
for router in routers:
|
||||
app.include_router(router)
|
||||
return app
|
||||
|
||||
@pytest.fixture
|
||||
def client(self, app):
|
||||
return TestClient(app)
|
||||
|
||||
def test_list_files(self, client):
|
||||
resp = client.get("/api/v1/files")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["exists"] is True
|
||||
assert data["count"] == 3 # wav, mp3, flac (not .txt)
|
||||
names = {f["name"] for f in data["files"]}
|
||||
assert "track1.wav" in names
|
||||
assert "track2.mp3" in names
|
||||
assert "track3.flac" in names
|
||||
assert "readme.txt" not in names
|
||||
|
||||
def test_download_audio_file(self, client):
|
||||
resp = client.get("/api/v1/files/track1.wav")
|
||||
assert resp.status_code == 200
|
||||
assert resp.content == b"fake wav data"
|
||||
|
||||
def test_download_nonexistent_file(self, client):
|
||||
resp = client.get("/api/v1/files/nope.wav")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_download_non_audio_file(self, client):
|
||||
resp = client.get("/api/v1/files/readme.txt")
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_path_traversal_prevented(self, client):
|
||||
resp = client.get("/api/v1/files/../../../etc/passwd")
|
||||
assert resp.status_code in (403, 404)
|
||||
|
||||
def test_nonexistent_directory(self):
|
||||
"""Test listing files when directory doesn't exist."""
|
||||
app = FastAPI()
|
||||
auth = APIKeyAuth(keys=["test-key"])
|
||||
session_mgr = SessionManager()
|
||||
routers = create_web_routes(
|
||||
auth=auth,
|
||||
session_manager=session_mgr,
|
||||
settings_getter=lambda: {},
|
||||
settings_setter=lambda _: True,
|
||||
audio_dir=Path("/tmp/nonexistent-audio-dir-xyz"),
|
||||
)
|
||||
for router in routers:
|
||||
app.include_router(router)
|
||||
client = TestClient(app)
|
||||
resp = client.get("/api/v1/files")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["exists"] is False
|
||||
assert data["files"] == []
|
||||
|
||||
|
||||
class TestStaticFiles:
|
||||
"""Tests for static file serving."""
|
||||
|
||||
def test_get_static_files_app(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
web_dir = Path(tmp)
|
||||
(web_dir / "index.html").write_text("<h1>Hello</h1>")
|
||||
app = get_static_files_app(web_dir)
|
||||
assert app is not None
|
||||
|
||||
def test_placeholder_created(self):
|
||||
"""Test that placeholder UI is created when dir doesn't exist."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
web_dir = Path(tmp) / "nonexistent-web"
|
||||
app = get_static_files_app(web_dir)
|
||||
assert web_dir.exists()
|
||||
index = web_dir / "index.html"
|
||||
assert index.exists()
|
||||
content = index.read_text()
|
||||
assert "RPi Audio Mixer" in content
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Tests for WebSocket manager."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import pytest
|
||||
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from src.network.websocket import WebSocketManager
|
||||
from src.network.schemas import (
|
||||
MixerStateSchema,
|
||||
ChannelSchema,
|
||||
WSMessage,
|
||||
WSMessageType,
|
||||
)
|
||||
from src.network.auth import APIKeyAuth
|
||||
from src.network.rate_limiter import RateLimiter
|
||||
|
||||
|
||||
class TestWebSocketManager:
|
||||
"""Tests for WebSocketManager (unit tests for the manager class)."""
|
||||
|
||||
@pytest.fixture
|
||||
def manager(self):
|
||||
return WebSocketManager(max_connections=10)
|
||||
|
||||
def test_initial_state(self, manager):
|
||||
assert manager.connected_clients == 0
|
||||
stats = manager.stats
|
||||
assert stats["connected_clients"] == 0
|
||||
assert stats["total_connections"] == 0
|
||||
assert stats["total_broadcasts"] == 0
|
||||
|
||||
def test_set_state_provider(self, manager):
|
||||
called = []
|
||||
|
||||
def get_state():
|
||||
called.append(True)
|
||||
return MixerStateSchema(
|
||||
channels=[ChannelSchema(channel=0)],
|
||||
)
|
||||
|
||||
manager.set_state_provider(get_state)
|
||||
assert manager._state_provider is not None
|
||||
|
||||
def test_set_parameter_handler(self, manager):
|
||||
calls = []
|
||||
|
||||
def handler(pt, v, ch):
|
||||
calls.append((pt, v, ch))
|
||||
|
||||
manager.set_parameter_handler(handler)
|
||||
assert manager._parameter_handler is not None
|
||||
|
||||
def test_set_transport_handler(self, manager):
|
||||
calls = []
|
||||
|
||||
def handler(cmd):
|
||||
calls.append(cmd)
|
||||
|
||||
manager.set_transport_handler(handler)
|
||||
assert manager._transport_handler is not None
|
||||
|
||||
def test_max_connections_property(self, manager):
|
||||
assert manager._max_connections == 10
|
||||
|
||||
|
||||
class TestWebSocketBroadcast:
|
||||
"""Tests for WebSocket broadcasting logic."""
|
||||
|
||||
@pytest.fixture
|
||||
def manager(self):
|
||||
mgr = WebSocketManager(max_connections=10)
|
||||
return mgr
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_broadcast_no_clients(self, manager):
|
||||
msg = WSMessage(
|
||||
type=WSMessageType.PARAMETER_UPDATE,
|
||||
payload={"param_type": "volume", "value": -3.0, "channel": 0},
|
||||
)
|
||||
count = await manager.broadcast(msg)
|
||||
assert count == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_broadcast_parameter_update_no_clients(self, manager):
|
||||
count = await manager.broadcast_parameter_update("volume", -3.0, 0)
|
||||
assert count == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_broadcast_transport_no_clients(self, manager):
|
||||
count = await manager.broadcast_transport_update({"command": "play"})
|
||||
assert count == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_add_remove(self, manager):
|
||||
"""Test client bookkeeping (without actual WebSocket)."""
|
||||
assert manager.connected_clients == 0
|
||||
|
||||
# Simulate adding a client
|
||||
async with manager._lock:
|
||||
manager._clients["test-client"] = "mock-ws"
|
||||
manager._total_connections += 1
|
||||
|
||||
assert manager.connected_clients == 1
|
||||
assert manager.stats["total_connections"] == 1
|
||||
|
||||
# Remove client
|
||||
await manager._remove_client("test-client")
|
||||
assert manager.connected_clients == 0
|
||||
|
||||
|
||||
class TestWebSocketStats:
|
||||
"""Tests for WebSocket statistics."""
|
||||
|
||||
def test_stats_structure(self):
|
||||
manager = WebSocketManager(max_connections=5)
|
||||
stats = manager.stats
|
||||
assert "connected_clients" in stats
|
||||
assert "total_connections" in stats
|
||||
assert "total_messages" in stats
|
||||
assert "total_broadcasts" in stats
|
||||
assert "uptime_seconds" in stats
|
||||
assert "max_connections" in stats
|
||||
assert stats["max_connections"] == 5
|
||||
|
||||
def test_uptime_increases(self):
|
||||
manager = WebSocketManager()
|
||||
stats1 = manager.stats
|
||||
# Stats should have a reasonable uptime
|
||||
assert stats1["uptime_seconds"] >= 0
|
||||
|
||||
|
||||
class TestWebSocketMessageTypes:
|
||||
"""Tests for WebSocket message type handling."""
|
||||
|
||||
def test_ws_message_serialization(self):
|
||||
msg = WSMessage(
|
||||
type=WSMessageType.FULL_STATE,
|
||||
payload={"channels": [], "master": {"volume_db": 0.0}},
|
||||
)
|
||||
data = msg.model_dump()
|
||||
assert data["type"] == "full_state"
|
||||
|
||||
def test_all_message_types(self):
|
||||
for msg_type in WSMessageType:
|
||||
msg = WSMessage(type=msg_type, payload={})
|
||||
assert msg.type == msg_type
|
||||
assert msg.payload == {}
|
||||
|
||||
def test_parameter_update_message(self):
|
||||
msg = WSMessage(
|
||||
type=WSMessageType.PARAMETER_UPDATE,
|
||||
payload={
|
||||
"param_type": "comp_threshold",
|
||||
"value": -15.0,
|
||||
"channel": 2,
|
||||
},
|
||||
)
|
||||
assert msg.payload["param_type"] == "comp_threshold"
|
||||
assert msg.payload["value"] == -15.0
|
||||
Reference in New Issue
Block a user