Files
raspberry-pi-mixer/tests/test_touch_ui.py
T
shawn 9cd8292acc
Lint & Validate / lint (push) Has been cancelled
Phase 1-4: Audio stack, mixer engine, MIDI, and network API
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)
2026-05-19 20:39:17 -04:00

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