9cd8292acc
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)
359 lines
15 KiB
Python
359 lines
15 KiB
Python
"""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
|