"""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