Phase 1-4: Audio stack, mixer engine, MIDI, and network API
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:
2026-05-19 20:39:17 -04:00
parent 7b123762b5
commit 9cd8292acc
97 changed files with 26545 additions and 5 deletions
+9
View File
@@ -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))
+112
View File
@@ -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
+977
View File
@@ -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
+173
View File
@@ -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
+114
View File
@@ -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]
+275
View File
@@ -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
+181
View File
@@ -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
+330
View File
@@ -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())
+115
View File
@@ -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
+142
View File
@@ -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
+241
View File
@@ -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
+271
View File
@@ -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"]
+199
View File
@@ -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
+358
View File
@@ -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
+223
View File
@@ -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
+318
View File
@@ -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
+162
View File
@@ -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