# -*- coding: utf-8 -*- """ Unit-Tests für die Netzwerk-Transport-Schicht. Testet NetworkService, ClientConnection und deren Zusammenspiel. """ import asyncio import pytest from unittest.mock import MagicMock, AsyncMock, patch from pathlib import Path from trixy_core.network.service import NetworkService, ClientHandler, AudioStreamHandler from trixy_core.network.client_connection import ClientConnection, ConnectionConfig, get_mac_address from trixy_core.network.message_handler import MessageHandler, EventBridge, setup_default_event_bridge from trixy_core.network.protocol import TrixyProtocol, ProtocolFlags, ProtocolMessage, COMMAND_PING, COMMAND_PONG from trixy_core.network.encryption import TrixyEncryption from trixy_core.network.cmd import ( SatelliteConnect, SatelliteAccepted, SatelliteDenied, SatelliteDisconnect, ) from trixy_core.network.cmd.wakeword import WakewordDetected from trixy_core.satellite.satellite import Satellite, ConnectionState from trixy_core.satellite.satellite_manager import SatelliteManager from trixy_core.satellite.registration_manager import RegistrationManager from trixy_core.config.datasets.server import ServerConfig, NetworkConfig, SecurityConfig # ============================================================================ # Fixtures # ============================================================================ @pytest.fixture def mock_server_application(temp_dir): """Erstellt eine Mock-Server-Anwendung.""" app = MagicMock() # Server-Konfiguration network_config = NetworkConfig( command_port=0, # 0 = automatische Port-Zuweisung audio_input_port=0, audio_output_port=0, music_port=0, bind_address="127.0.0.1", ) security_config = SecurityConfig( encryption_enabled=False, encryption_key_path=str(temp_dir / "test.key"), ) server_config = ServerConfig( network=network_config, security=security_config, ) app.server_config = server_config app.satellites = SatelliteManager(app) app.registration = RegistrationManager(app, directory=temp_dir / "satellites") app.events = MagicMock() app.events.trigger = AsyncMock() return app @pytest.fixture def mock_client_config(): """Erstellt eine Mock-Client-Konfiguration.""" return ConnectionConfig( connect_timeout=5.0, reconnect_interval=1.0, ping_interval=30.0, pong_timeout=5.0, ) @pytest.fixture def test_mac_address(): """Test-MAC-Adresse.""" return "AA:BB:CC:DD:EE:FF" @pytest.fixture def protocol(): """Erstellt ein TrixyProtocol.""" return TrixyProtocol() # ============================================================================ # Helper-Klassen für Tests # ============================================================================ class MockStreamReader: """Mock für asyncio.StreamReader.""" def __init__(self): self._data = b"" self._pos = 0 self._closed = False def feed_data(self, data: bytes): """Fügt Daten zum Stream hinzu.""" self._data += data async def read(self, n: int) -> bytes: """Liest n Bytes.""" if self._closed: return b"" data = self._data[self._pos:self._pos + n] self._pos += len(data) return data async def readexactly(self, n: int) -> bytes: """Liest exakt n Bytes.""" return await self.read(n) def close(self): self._closed = True class MockStreamWriter: """Mock für asyncio.StreamWriter.""" def __init__(self): self._data = b"" self._closed = False self._extra_info = {"peername": ("127.0.0.1", 12345)} def write(self, data: bytes): """Schreibt Daten.""" self._data += data async def drain(self): """Wartet auf Schreiben.""" pass def close(self): self._closed = True async def wait_closed(self): """Wartet auf Schließen.""" pass def get_extra_info(self, key: str): """Gibt Extra-Info zurück.""" return self._extra_info.get(key) def get_written_data(self) -> bytes: """Gibt geschriebene Daten zurück.""" return self._data def clear(self): """Löscht geschriebene Daten.""" self._data = b"" # ============================================================================ # Tests für get_mac_address # ============================================================================ class TestGetMacAddress: """Tests für get_mac_address Hilfsfunktion.""" def test_returns_valid_mac(self): """Testet, dass eine gültige MAC-Adresse zurückgegeben wird.""" mac = get_mac_address() # Format prüfen: AA:BB:CC:DD:EE:FF assert len(mac) == 17 parts = mac.split(":") assert len(parts) == 6 for part in parts: assert len(part) == 2 int(part, 16) # Muss hexadezimal sein # ============================================================================ # Tests für ConnectionConfig # ============================================================================ class TestConnectionConfig: """Tests für ConnectionConfig.""" def test_default_values(self): """Testet die Standardwerte.""" config = ConnectionConfig() assert config.connect_timeout == 10.0 assert config.reconnect_interval == 5.0 assert config.max_reconnect_attempts == 0 assert config.ping_interval == 30.0 assert config.pong_timeout == 10.0 def test_custom_values(self): """Testet benutzerdefinierte Werte.""" config = ConnectionConfig( connect_timeout=5.0, reconnect_interval=2.0, ping_interval=15.0, ) assert config.connect_timeout == 5.0 assert config.reconnect_interval == 2.0 assert config.ping_interval == 15.0 # ============================================================================ # Tests für MessageHandler # ============================================================================ class TestMessageHandler: """Tests für MessageHandler.""" @pytest.fixture def handler(self): """Erstellt einen MessageHandler.""" return MessageHandler() @pytest.fixture def test_satellite(self): """Erstellt einen Test-Satellite.""" return Satellite( satellite_id="test-sat", room_id="wohnzimmer", mac_address="AA:BB:CC:DD:EE:FF", alias="Test Echo" ) @pytest.mark.asyncio async def test_register_handler(self, handler): """Testet die Handler-Registrierung.""" async def my_handler(satellite, message): pass handler.register(my_handler, ["TestMessage"]) assert len(handler._handlers) == 1 @pytest.mark.asyncio async def test_unregister_handler(self, handler): """Testet das Entfernen eines Handlers.""" async def my_handler(satellite, message): pass handler.register(my_handler) result = handler.unregister(my_handler) assert result is True assert len(handler._handlers) == 0 @pytest.mark.asyncio async def test_dispatch_calls_handler(self, handler, test_satellite): """Testet, dass dispatch Handler aufruft.""" called = [] async def my_handler(satellite, message): called.append((satellite.id, message.class_name)) handler.register(my_handler, ["WakewordDetected"]) message = ProtocolMessage( class_name="WakewordDetected", data={"confidence": 0.9} ) count = await handler.dispatch(test_satellite, message) assert count == 1 assert len(called) == 1 assert called[0] == ("test-sat", "WakewordDetected") @pytest.mark.asyncio async def test_dispatch_filters_by_class_name(self, handler, test_satellite): """Testet, dass dispatch nach Klassennamen filtert.""" called = [] async def my_handler(satellite, message): called.append(message.class_name) handler.register(my_handler, ["WakewordDetected"]) # Diese sollte aufgerufen werden msg1 = ProtocolMessage(class_name="WakewordDetected", data={}) await handler.dispatch(test_satellite, msg1) # Diese sollte NICHT aufgerufen werden msg2 = ProtocolMessage(class_name="OtherMessage", data={}) await handler.dispatch(test_satellite, msg2) assert len(called) == 1 assert called[0] == "WakewordDetected" @pytest.mark.asyncio async def test_dispatch_with_filter_func(self, handler, test_satellite): """Testet dispatch mit Filterfunktion.""" called = [] async def my_handler(satellite, message): called.append(message.data) # Nur Nachrichten mit hoher Confidence handler.register( my_handler, filter_func=lambda m: m.data.get("confidence", 0) > 0.8 ) # Hohe Confidence - sollte aufgerufen werden msg1 = ProtocolMessage(class_name="Test", data={"confidence": 0.95}) await handler.dispatch(test_satellite, msg1) # Niedrige Confidence - sollte NICHT aufgerufen werden msg2 = ProtocolMessage(class_name="Test", data={"confidence": 0.5}) await handler.dispatch(test_satellite, msg2) assert len(called) == 1 assert called[0]["confidence"] == 0.95 @pytest.mark.asyncio async def test_dispatch_priority(self, handler, test_satellite): """Testet, dass Handler nach Priorität aufgerufen werden.""" order = [] async def low_priority(satellite, message): order.append("low") async def high_priority(satellite, message): order.append("high") handler.register(low_priority, priority=0) handler.register(high_priority, priority=10) message = ProtocolMessage(class_name="Test", data={}) await handler.dispatch(test_satellite, message) assert order == ["high", "low"] # ============================================================================ # Tests für EventBridge # ============================================================================ class TestEventBridge: """Tests für EventBridge.""" @pytest.fixture def mock_event_manager(self): """Erstellt einen Mock-EventManager.""" manager = MagicMock() manager.trigger = AsyncMock() return manager @pytest.fixture def bridge(self, mock_event_manager): """Erstellt eine EventBridge.""" return EventBridge(mock_event_manager) @pytest.fixture def test_satellite(self): """Erstellt einen Test-Satellite.""" return Satellite( satellite_id="test-sat", room_id="wohnzimmer", alias="Test Echo" ) @pytest.mark.asyncio async def test_map_and_trigger(self, bridge, mock_event_manager, test_satellite): """Testet Mapping und Event-Auslösung.""" bridge.map("WakewordDetected", "wakeword_detected") message = ProtocolMessage( class_name="WakewordDetected", data={"confidence": 0.95, "model": "custom"} ) result = await bridge.on_message(test_satellite, message) assert result is True mock_event_manager.trigger.assert_called_once() call_args = mock_event_manager.trigger.call_args assert call_args[0][0] == "wakeword_detected" @pytest.mark.asyncio async def test_unmapped_message_returns_false(self, bridge, test_satellite): """Testet, dass unmapped Nachrichten False zurückgeben.""" message = ProtocolMessage(class_name="UnknownMessage", data={}) result = await bridge.on_message(test_satellite, message) assert result is False @pytest.mark.asyncio async def test_satellite_info_added(self, bridge, mock_event_manager, test_satellite): """Testet, dass Satellite-Info zu Event-Daten hinzugefügt wird.""" bridge.map("TestMessage", "test_event") message = ProtocolMessage(class_name="TestMessage", data={"value": 42}) await bridge.on_message(test_satellite, message) call_args = mock_event_manager.trigger.call_args event_data = call_args[0][1] assert event_data["satellite_id"] == "test-sat" assert event_data["satellite_alias"] == "Test Echo" assert event_data["satellite_room"] == "wohnzimmer" def test_setup_default_event_bridge(self, mock_event_manager): """Testet setup_default_event_bridge.""" bridge = setup_default_event_bridge(mock_event_manager) assert "WakewordDetected" in bridge._mappings assert "RecordingComplete" in bridge._mappings assert bridge._mappings["WakewordDetected"] == "wakeword_detected" # ============================================================================ # Tests für TrixyProtocol Serialisierung # ============================================================================ class TestProtocolSerialization: """Tests für Protokoll-Serialisierung im Transport-Kontext.""" @pytest.fixture def protocol(self): """Erstellt ein TrixyProtocol.""" return TrixyProtocol() def test_serialize_satellite_connect(self, protocol): """Testet Serialisierung von SatelliteConnect.""" msg = SatelliteConnect( mac_address="AA:BB:CC:DD:EE:FF", room="wohnzimmer", alias="Test Echo", version="1.0.0" ) serialized = protocol.serialize(msg) deserialized = protocol.deserialize(serialized) assert deserialized.class_name == "SatelliteConnect" assert deserialized.data.mac_address == "AA:BB:CC:DD:EE:FF" assert deserialized.data.room == "wohnzimmer" def test_serialize_satellite_accepted(self, protocol): """Testet Serialisierung von SatelliteAccepted.""" session_key = TrixyEncryption.generate_key() msg = SatelliteAccepted( satellite_id="sat-123", session_key=session_key, audio_input_port=2102, audio_output_port=2103, music_port=2104 ) serialized = protocol.serialize(msg) deserialized = protocol.deserialize(serialized) assert deserialized.class_name == "SatelliteAccepted" assert deserialized.data.satellite_id == "sat-123" assert deserialized.data.audio_input_port == 2102 def test_serialize_wakeword_detected(self, protocol): """Testet Serialisierung von WakewordDetected.""" msg = WakewordDetected( satellite_id="sat-123", wakeword_model="custom", confidence=0.95, audio_level=0.7 ) serialized = protocol.serialize(msg) deserialized = protocol.deserialize(serialized) assert deserialized.class_name == "WakewordDetected" assert deserialized.data.confidence == 0.95 # ============================================================================ # Integration Tests - Server und Client zusammen # ============================================================================ class TestServerClientIntegration: """Integrationstests für Server und Client.""" @pytest.fixture async def running_server(self, mock_server_application): """Startet einen NetworkService auf zufälligen Ports.""" service = NetworkService(mock_server_application) # Registrierungsmodus aktivieren für Tests mock_server_application.registration.enable_registration_mode(timeout_seconds=300) await service.start() # Tatsächliche Ports ermitteln command_server = service._servers["command"] audio_in_server = service._servers["audio_in"] audio_out_server = service._servers["audio_out"] music_server = service._servers["music"] ports = { "command": command_server.sockets[0].getsockname()[1], "audio_in": audio_in_server.sockets[0].getsockname()[1], "audio_out": audio_out_server.sockets[0].getsockname()[1], "music": music_server.sockets[0].getsockname()[1], } # Konfiguration aktualisieren mit tatsächlichen Ports mock_server_application.server_config.network.command_port = ports["command"] mock_server_application.server_config.network.audio_input_port = ports["audio_in"] mock_server_application.server_config.network.audio_output_port = ports["audio_out"] mock_server_application.server_config.network.music_port = ports["music"] yield service, ports await service.stop() @pytest.mark.asyncio async def test_client_connects_to_server(self, running_server, test_mac_address): """Testet, dass Client sich mit Server verbinden kann.""" service, ports = running_server # Client erstellen client = ClientConnection( host="127.0.0.1", port=ports["command"], room="wohnzimmer", alias="Test Echo", mac_address=test_mac_address, config=ConnectionConfig(connect_timeout=5.0), ) # Verbinden success = await client.connect() assert success is True assert client.is_connected is True assert client.satellite_id != "" # Aufräumen await client.disconnect() @pytest.mark.asyncio async def test_unregistered_client_is_denied(self, running_server, mock_server_application, test_mac_address): """Testet, dass unregistrierter Client abgelehnt wird.""" service, ports = running_server # Registrierungsmodus deaktivieren mock_server_application.registration.disable_registration_mode() # Client erstellen client = ClientConnection( host="127.0.0.1", port=ports["command"], room="wohnzimmer", alias="Test Echo", mac_address=test_mac_address, config=ConnectionConfig(connect_timeout=5.0), ) # Verbinden sollte fehlschlagen success = await client.connect() assert success is False assert client.is_connected is False @pytest.mark.asyncio async def test_registered_client_is_accepted(self, running_server, mock_server_application, test_mac_address): """Testet, dass registrierter Client akzeptiert wird.""" service, ports = running_server # Deaktiviere Auto-Registrierung mock_server_application.registration.disable_registration_mode() # Manuell registrieren mock_server_application.registration.enable_registration_mode() mock_server_application.registration.begin_registration( test_mac_address, "wohnzimmer", "Test Echo" ) satellite = mock_server_application.registration.complete_registration(test_mac_address) mock_server_application.satellites.add(satellite) mock_server_application.registration.disable_registration_mode() # Client erstellen client = ClientConnection( host="127.0.0.1", port=ports["command"], room="wohnzimmer", alias="Test Echo", mac_address=test_mac_address, config=ConnectionConfig(connect_timeout=5.0), ) # Verbinden sollte funktionieren success = await client.connect() assert success is True assert client.is_connected is True await client.disconnect() @pytest.mark.asyncio async def test_server_sends_message_to_client(self, running_server, test_mac_address): """Testet, dass Server Nachrichten an Client senden kann.""" service, ports = running_server received_messages = [] # Client erstellen client = ClientConnection( host="127.0.0.1", port=ports["command"], room="wohnzimmer", alias="Test Echo", mac_address=test_mac_address, ) async def on_message(message): received_messages.append(message) client.on_message(on_message) # Verbinden await client.connect() # Warte kurz auf Verbindungsaufbau await asyncio.sleep(0.2) # Server sendet Nachricht from trixy_core.network.cmd.system import SystemStatus status_msg = SystemStatus(status="running", uptime_seconds=100.0) sent = await service.send_to_satellite(client.satellite_id, status_msg) assert sent is True # Warte auf Empfang await asyncio.sleep(0.2) assert len(received_messages) >= 1 await client.disconnect() @pytest.mark.asyncio async def test_client_sends_command_to_server(self, running_server, test_mac_address, mock_server_application): """Testet, dass Client Commands an Server senden kann.""" service, ports = running_server received_messages = [] # Message-Handler registrieren async def message_handler(satellite, message): received_messages.append((satellite.id, message)) service.register_message_handler(message_handler) # Client erstellen und verbinden client = ClientConnection( host="127.0.0.1", port=ports["command"], room="wohnzimmer", alias="Test Echo", mac_address=test_mac_address, ) await client.connect() await asyncio.sleep(0.1) # Wakeword senden wakeword_msg = WakewordDetected( satellite_id=client.satellite_id, wakeword_model="custom", confidence=0.95, audio_level=0.7 ) success = await client.send_command(wakeword_msg) assert success is True # Warte auf Verarbeitung await asyncio.sleep(0.2) assert len(received_messages) >= 1 _, msg = received_messages[0] assert msg.class_name == "WakewordDetected" await client.disconnect() @pytest.mark.asyncio async def test_keepalive_ping_pong(self, running_server, test_mac_address): """Testet KeepAlive Ping/Pong.""" service, ports = running_server # Client mit normalem Ping-Intervall (Test prüft nur dass Verbindung stabil ist) client = ClientConnection( host="127.0.0.1", port=ports["command"], room="wohnzimmer", alias="Test Echo", mac_address=test_mac_address, config=ConnectionConfig(ping_interval=30.0), # Normales Intervall ) await client.connect() assert client.is_connected is True # Server-seitige KeepAlive prüfen satellite = service.application.satellites.get(client.satellite_id) assert satellite is not None # KeepAlive-Session sollte existieren session = service.keepalive.get_session(client.satellite_id) assert session is not None assert session.is_running is True await client.disconnect() @pytest.mark.asyncio async def test_client_disconnect_event(self, running_server, test_mac_address, mock_server_application): """Testet, dass Disconnect-Event ausgelöst wird.""" service, ports = running_server client = ClientConnection( host="127.0.0.1", port=ports["command"], room="wohnzimmer", alias="Test Echo", mac_address=test_mac_address, ) await client.connect() satellite_id = client.satellite_id await asyncio.sleep(0.1) # Client trennen await client.disconnect("Test disconnect") await asyncio.sleep(0.2) # Event sollte ausgelöst worden sein calls = mock_server_application.events.trigger.call_args_list event_names = [call[0][0] for call in calls] assert "satellite_disconnected" in event_names @pytest.mark.asyncio async def test_multiple_clients(self, running_server): """Testet mehrere gleichzeitige Clients.""" service, ports = running_server clients = [] for i in range(3): mac = f"AA:BB:CC:DD:EE:{i:02X}" client = ClientConnection( host="127.0.0.1", port=ports["command"], room=f"raum{i}", alias=f"Echo {i}", mac_address=mac, ) success = await client.connect() assert success is True clients.append(client) # Alle sollten verbunden sein await asyncio.sleep(0.2) assert all(c.is_connected for c in clients) # Alle trennen for client in clients: await client.disconnect() @pytest.mark.asyncio async def test_broadcast_to_all_clients(self, running_server, mock_server_application): """Testet Broadcast an alle Clients.""" service, ports = running_server received_counts = {} clients = [] for i in range(2): mac = f"BB:BB:BB:BB:BB:{i:02X}" client = ClientConnection( host="127.0.0.1", port=ports["command"], room="wohnzimmer", alias=f"Echo {i}", mac_address=mac, ) async def make_handler(client_id): async def handler(message): received_counts[client_id] = received_counts.get(client_id, 0) + 1 return handler client.on_message(await make_handler(i)) await client.connect() clients.append(client) await asyncio.sleep(0.2) # Broadcast senden from trixy_core.network.cmd.system import SystemStatus status_msg = SystemStatus(status="broadcast_test", uptime_seconds=0.0) count = await service.broadcast(status_msg) assert count == 2 await asyncio.sleep(0.3) # Beide Clients sollten empfangen haben assert sum(received_counts.values()) >= 2 for client in clients: await client.disconnect() # ============================================================================ # Tests für Audio-Streaming # ============================================================================ class TestAudioStreaming: """Tests für Audio-Streaming.""" @pytest.fixture async def connected_client_and_server(self, mock_server_application, temp_dir): """Startet Server und verbindet Client.""" # Server mit zufälligen Ports mock_server_application.server_config.network.command_port = 0 mock_server_application.server_config.network.audio_input_port = 0 mock_server_application.server_config.network.audio_output_port = 0 mock_server_application.server_config.network.music_port = 0 mock_server_application.registration.enable_registration_mode(timeout_seconds=300) service = NetworkService(mock_server_application) await service.start() # Ports ermitteln ports = { "command": service._servers["command"].sockets[0].getsockname()[1], "audio_in": service._servers["audio_in"].sockets[0].getsockname()[1], "audio_out": service._servers["audio_out"].sockets[0].getsockname()[1], "music": service._servers["music"].sockets[0].getsockname()[1], } mock_server_application.server_config.network.command_port = ports["command"] mock_server_application.server_config.network.audio_input_port = ports["audio_in"] mock_server_application.server_config.network.audio_output_port = ports["audio_out"] mock_server_application.server_config.network.music_port = ports["music"] # Client verbinden client = ClientConnection( host="127.0.0.1", port=ports["command"], room="wohnzimmer", alias="Test Echo", mac_address="CC:CC:CC:CC:CC:CC", ) await client.connect() await asyncio.sleep(0.2) yield service, client, mock_server_application await client.disconnect() await service.stop() @pytest.mark.asyncio async def test_client_sends_audio(self, connected_client_and_server): """Testet, dass Client Audio-Daten senden kann.""" service, client, app = connected_client_and_server # Audio-Daten erstellen (100ms bei 16kHz mono) audio_data = b"\x00\x00" * 1600 # Audio senden success = await client.send_audio(audio_data) # Hinweis: Ohne Audio-In-Socket verbunden, wird das fehlschlagen # Aber wir testen, dass die Methode existiert und aufgerufen werden kann # In einem echten Test würde man den Audio-Socket prüfen # ============================================================================ # Tests für Fehlerbehandlung # ============================================================================ class TestErrorHandling: """Tests für Fehlerbehandlung.""" @pytest.mark.asyncio async def test_connection_timeout(self): """Testet Verbindungs-Timeout.""" client = ClientConnection( host="192.0.2.1", # TEST-NET-1, sollte nicht erreichbar sein port=2101, room="test", alias="Test", mac_address="DD:DD:DD:DD:DD:DD", config=ConnectionConfig(connect_timeout=0.5), ) success = await client.connect() assert success is False assert client.is_connected is False @pytest.mark.asyncio async def test_disconnect_callback_called(self, mock_server_application, temp_dir): """Testet, dass Disconnect-Callback aufgerufen wird.""" mock_server_application.server_config.network.command_port = 0 mock_server_application.server_config.network.audio_input_port = 0 mock_server_application.server_config.network.audio_output_port = 0 mock_server_application.server_config.network.music_port = 0 mock_server_application.registration.enable_registration_mode() service = NetworkService(mock_server_application) await service.start() ports = { "command": service._servers["command"].sockets[0].getsockname()[1], } mock_server_application.server_config.network.command_port = ports["command"] mock_server_application.server_config.network.audio_input_port = service._servers["audio_in"].sockets[0].getsockname()[1] mock_server_application.server_config.network.audio_output_port = service._servers["audio_out"].sockets[0].getsockname()[1] mock_server_application.server_config.network.music_port = service._servers["music"].sockets[0].getsockname()[1] disconnect_reason = None async def on_disconnect(reason): nonlocal disconnect_reason disconnect_reason = reason client = ClientConnection( host="127.0.0.1", port=ports["command"], room="test", alias="Test", mac_address="EE:EE:EE:EE:EE:EE", ) client.on_disconnected(on_disconnect) await client.connect() await client.disconnect("Test reason") assert disconnect_reason == "Test reason" await service.stop() # ============================================================================ # Tests für NetworkService Health Check # ============================================================================ class TestNetworkServiceHealth: """Tests für NetworkService Health-Checks.""" @pytest.mark.asyncio async def test_health_check_returns_true_when_running(self, mock_server_application): """Testet, dass health_check True zurückgibt wenn Service läuft.""" mock_server_application.server_config.network.command_port = 0 mock_server_application.server_config.network.audio_input_port = 0 mock_server_application.server_config.network.audio_output_port = 0 mock_server_application.server_config.network.music_port = 0 service = NetworkService(mock_server_application) await service.start() result = await service.health_check() assert result is True await service.stop() @pytest.mark.asyncio async def test_health_check_returns_false_when_stopped(self, mock_server_application): """Testet, dass health_check False zurückgibt wenn Server gestoppt.""" mock_server_application.server_config.network.command_port = 0 mock_server_application.server_config.network.audio_input_port = 0 mock_server_application.server_config.network.audio_output_port = 0 mock_server_application.server_config.network.music_port = 0 service = NetworkService(mock_server_application) await service.start() await service.stop() result = await service.health_check() assert result is False # ============================================================================ # Tests für Audio/Musik Transfer # ============================================================================ class TestAudioTransfer: """Tests für Audio-Transfer über das Netzwerk.""" @pytest.fixture async def connected_setup(self, mock_server_application, temp_dir): """Startet Server und Client mit allen Ports verbunden.""" mock_server_application.server_config.network.command_port = 0 mock_server_application.server_config.network.audio_input_port = 0 mock_server_application.server_config.network.audio_output_port = 0 mock_server_application.server_config.network.music_port = 0 mock_server_application.registration.enable_registration_mode(timeout_seconds=300) service = NetworkService(mock_server_application) await service.start() # Ports ermitteln ports = { "command": service._servers["command"].sockets[0].getsockname()[1], "audio_in": service._servers["audio_in"].sockets[0].getsockname()[1], "audio_out": service._servers["audio_out"].sockets[0].getsockname()[1], "music": service._servers["music"].sockets[0].getsockname()[1], } mock_server_application.server_config.network.command_port = ports["command"] mock_server_application.server_config.network.audio_input_port = ports["audio_in"] mock_server_application.server_config.network.audio_output_port = ports["audio_out"] mock_server_application.server_config.network.music_port = ports["music"] # Client verbinden client = ClientConnection( host="127.0.0.1", port=ports["command"], room="wohnzimmer", alias="Test Echo", mac_address="FF:FF:FF:FF:FF:01", ) await client.connect() await asyncio.sleep(0.3) # Warte auf Audio-Ports yield service, client, mock_server_application, ports await client.disconnect() await service.stop() @pytest.mark.asyncio async def test_audio_out_transfer(self, connected_setup): """Testet Audio-Output Transfer vom Server zum Client.""" service, client, app, ports = connected_setup received_audio = [] async def on_audio(data: bytes): received_audio.append(data) client.on_audio_out(on_audio) # Hole den Satellite satellite = app.satellites.get(client.satellite_id) assert satellite is not None # Warte auf Audio-Socket-Verbindung await asyncio.sleep(0.3) # Erstelle Test-Audio (100ms bei 16kHz mono 16-bit) # 16000 samples/s * 0.1s * 2 bytes/sample = 3200 bytes test_audio = bytes([i % 256 for i in range(3200)]) # Sende Audio direkt über Satellite if satellite.sockets.audio_out: success = await satellite.say(test_audio) await asyncio.sleep(0.2) # Client sollte Audio empfangen haben if received_audio: total_received = b"".join(received_audio) assert len(total_received) > 0 @pytest.mark.asyncio async def test_music_transfer(self, connected_setup): """Testet Music Transfer vom Server zum Client.""" service, client, app, ports = connected_setup received_music = [] async def on_music(data: bytes): received_music.append(data) client.on_music(on_music) # Hole den Satellite satellite = app.satellites.get(client.satellite_id) assert satellite is not None # Warte auf Music-Socket-Verbindung await asyncio.sleep(0.3) # Erstelle Test-Musik (100ms bei 48kHz stereo 16-bit) # 48000 samples/s * 0.1s * 2 channels * 2 bytes/sample = 19200 bytes test_music = bytes([i % 256 for i in range(19200)]) # Sende Musik direkt über Socket if satellite.sockets.music_out: try: satellite.sockets.music_out.write(test_music) await satellite.sockets.music_out.drain() await asyncio.sleep(0.2) # Client sollte Musik empfangen haben if received_music: total_received = b"".join(received_music) assert len(total_received) > 0 except Exception: pass # Socket möglicherweise nicht verbunden @pytest.mark.asyncio async def test_audio_input_from_client(self, connected_setup): """Testet Audio-Input vom Client zum Server.""" service, client, app, ports = connected_setup received_events = [] # Event-Handler für Audio async def audio_handler(event_name, data): received_events.append(data) app.events.trigger = audio_handler # Test-Audio erstellen test_audio = b"\x00\x00" * 1600 # 100ms Stille # Audio senden success = await client.send_audio(test_audio) await asyncio.sleep(0.2) # Event sollte ausgelöst worden sein (oder Audio empfangen) # Hinweis: Hängt davon ab, ob Audio-In Socket verbunden ist # ============================================================================ # Tests für TTS-Integration # ============================================================================ class TestTTSIntegration: """Tests für Text-to-Speech Integration über das Netzwerk.""" @pytest.fixture def mock_tts_provider(self): """Erstellt einen Mock-TTS-Provider.""" from trixy_core.audio.tts import TTSProvider, TTSConfig, TTSResult, TTSState, Voice class MockTTSProvider(TTSProvider): """Mock TTS für Tests.""" def __init__(self): super().__init__(TTSConfig()) self._state = TTSState.READY @property def name(self) -> str: return "mock" @property def supported_languages(self) -> list[str]: return ["de-DE", "en-US"] async def initialize(self) -> None: self._state = TTSState.READY async def shutdown(self) -> None: self._state = TTSState.UNINITIALIZED async def get_voices(self, language: str | None = None) -> list[Voice]: return [ Voice(id="mock-de", name="Mock Deutsch", language="de-DE", gender="neutral"), Voice(id="mock-en", name="Mock English", language="en-US", gender="neutral"), ] async def synthesize( self, text: str, voice_id: str | None = None, language: str | None = None, ) -> TTSResult: """Generiert einfache Test-Audio-Daten.""" # Erstelle Sinus-ähnliche Wellenform als Test # 16kHz, 16-bit mono, 0.5 Sekunden pro Wort words = len(text.split()) duration = max(0.5, words * 0.3) sample_rate = 16000 num_samples = int(duration * sample_rate) # Einfache Wellenform (Rechteck für Testzwecke) audio_data = b"" for i in range(num_samples): # Abwechselnd +/- Werte für hörbare Wellenform value = 10000 if (i // 100) % 2 == 0 else -10000 audio_data += value.to_bytes(2, 'little', signed=True) return TTSResult( audio_data=audio_data, sample_rate=sample_rate, channels=1, text=text, duration_seconds=duration, processing_time_ms=10.0, voice_id=voice_id or "mock-de", voice_name="Mock Voice", language=language or "de-DE", provider="mock", model="mock-v1", ) return MockTTSProvider() @pytest.mark.asyncio async def test_tts_synthesize_generates_audio(self, mock_tts_provider): """Testet, dass Mock-TTS Audio generiert.""" result = await mock_tts_provider.synthesize("Test") assert result.audio_data is not None assert len(result.audio_data) > 0 assert result.sample_rate == 16000 assert result.text == "Test" assert result.duration_seconds > 0 @pytest.mark.asyncio async def test_tts_longer_text_generates_more_audio(self, mock_tts_provider): """Testet, dass längerer Text mehr Audio generiert.""" short_result = await mock_tts_provider.synthesize("Hi") long_result = await mock_tts_provider.synthesize("Dies ist ein längerer Testsatz mit mehreren Wörtern") assert len(long_result.audio_data) > len(short_result.audio_data) @pytest.mark.asyncio async def test_tts_to_satellite_workflow(self, mock_server_application, temp_dir, mock_tts_provider): """Testet den kompletten TTS-zu-Satellite Workflow.""" # Server starten mock_server_application.server_config.network.command_port = 0 mock_server_application.server_config.network.audio_input_port = 0 mock_server_application.server_config.network.audio_output_port = 0 mock_server_application.server_config.network.music_port = 0 mock_server_application.registration.enable_registration_mode() service = NetworkService(mock_server_application) await service.start() ports = { "command": service._servers["command"].sockets[0].getsockname()[1], "audio_in": service._servers["audio_in"].sockets[0].getsockname()[1], "audio_out": service._servers["audio_out"].sockets[0].getsockname()[1], "music": service._servers["music"].sockets[0].getsockname()[1], } mock_server_application.server_config.network.command_port = ports["command"] mock_server_application.server_config.network.audio_input_port = ports["audio_in"] mock_server_application.server_config.network.audio_output_port = ports["audio_out"] mock_server_application.server_config.network.music_port = ports["music"] received_audio = [] async def on_audio(data: bytes): received_audio.append(data) # Client verbinden client = ClientConnection( host="127.0.0.1", port=ports["command"], room="wohnzimmer", alias="Test Echo", mac_address="FF:FF:FF:FF:FF:02", ) client.on_audio_out(on_audio) await client.connect() await asyncio.sleep(0.3) # TTS durchführen tts_result = await mock_tts_provider.synthesize("Test Audio Ausgabe") assert tts_result.audio_data is not None assert len(tts_result.audio_data) > 0 # Audio an Satellite senden satellite = mock_server_application.satellites.get(client.satellite_id) if satellite and satellite.sockets.audio_out: await satellite.say(tts_result.audio_data) await asyncio.sleep(0.2) # Prüfe ob Client Audio empfangen hat if received_audio: total = b"".join(received_audio) assert len(total) > 0 await client.disconnect() await service.stop() @pytest.mark.asyncio async def test_tts_event_workflow(self, mock_server_application, temp_dir, mock_tts_provider): """Testet TTS-Workflow über Events.""" # Server starten mock_server_application.server_config.network.command_port = 0 mock_server_application.server_config.network.audio_input_port = 0 mock_server_application.server_config.network.audio_output_port = 0 mock_server_application.server_config.network.music_port = 0 mock_server_application.registration.enable_registration_mode() service = NetworkService(mock_server_application) await service.start() ports = { "command": service._servers["command"].sockets[0].getsockname()[1], "audio_in": service._servers["audio_in"].sockets[0].getsockname()[1], "audio_out": service._servers["audio_out"].sockets[0].getsockname()[1], "music": service._servers["music"].sockets[0].getsockname()[1], } mock_server_application.server_config.network.command_port = ports["command"] mock_server_application.server_config.network.audio_input_port = ports["audio_in"] mock_server_application.server_config.network.audio_output_port = ports["audio_out"] mock_server_application.server_config.network.music_port = ports["music"] tts_completed_events = [] # Simuliere TTS-Request-Handler async def handle_tts_request(text: str, satellite_id: str): """Simuliert einen TTS-Request wie ein Plugin ihn verarbeiten würde.""" result = await mock_tts_provider.synthesize(text) # Sende Audio an Satellite satellite = mock_server_application.satellites.get(satellite_id) if satellite and satellite.sockets.audio_out: await satellite.say(result.audio_data) tts_completed_events.append({ "text": text, "satellite_id": satellite_id, "duration": result.duration_seconds, "audio_size": len(result.audio_data), }) received_audio = [] async def on_audio(data: bytes): received_audio.append(data) # Client verbinden client = ClientConnection( host="127.0.0.1", port=ports["command"], room="wohnzimmer", alias="Test Echo", mac_address="FF:FF:FF:FF:FF:03", ) client.on_audio_out(on_audio) await client.connect() await asyncio.sleep(0.3) # TTS-Request simulieren await handle_tts_request("Hallo Welt", client.satellite_id) await asyncio.sleep(0.2) # Prüfungen assert len(tts_completed_events) == 1 assert tts_completed_events[0]["text"] == "Hallo Welt" assert tts_completed_events[0]["audio_size"] > 0 await client.disconnect() await service.stop() # ============================================================================ # Tests für Bidirektionale Kommunikation # ============================================================================ class TestBidirectionalCommunication: """Tests für bidirektionale Kommunikation zwischen Server und Client.""" @pytest.mark.asyncio async def test_request_response_pattern(self, mock_server_application, temp_dir): """Testet Request-Response Muster.""" mock_server_application.server_config.network.command_port = 0 mock_server_application.server_config.network.audio_input_port = 0 mock_server_application.server_config.network.audio_output_port = 0 mock_server_application.server_config.network.music_port = 0 mock_server_application.registration.enable_registration_mode() service = NetworkService(mock_server_application) await service.start() ports = {"command": service._servers["command"].sockets[0].getsockname()[1]} mock_server_application.server_config.network.command_port = ports["command"] mock_server_application.server_config.network.audio_input_port = service._servers["audio_in"].sockets[0].getsockname()[1] mock_server_application.server_config.network.audio_output_port = service._servers["audio_out"].sockets[0].getsockname()[1] mock_server_application.server_config.network.music_port = service._servers["music"].sockets[0].getsockname()[1] # Server-seitiger Handler für Wakeword responses_sent = [] async def handle_wakeword(satellite, message): """Antwortet auf WakewordDetected.""" if message.class_name == "WakewordDetected": from trixy_core.network.cmd.wakeword import WakewordSelected response = WakewordSelected( conversation_id="conv-123", start_streaming=True ) await service.send_to_satellite(satellite.id, response) responses_sent.append(satellite.id) service.register_message_handler(handle_wakeword) # Client-seitige Empfangsliste received_responses = [] async def on_message(message): received_responses.append(message.class_name) client = ClientConnection( host="127.0.0.1", port=ports["command"], room="wohnzimmer", alias="Test Echo", mac_address="FF:FF:FF:FF:FF:04", ) client.on_message(on_message) await client.connect() await asyncio.sleep(0.1) # Client sendet WakewordDetected wakeword_msg = WakewordDetected( satellite_id=client.satellite_id, wakeword_model="custom", confidence=0.95, audio_level=0.7 ) await client.send_command(wakeword_msg) # Warte auf Antwort await asyncio.sleep(0.3) # Prüfe dass Server geantwortet hat assert len(responses_sent) >= 1 # Prüfe dass Client Antwort empfangen hat assert "WakewordSelected" in received_responses await client.disconnect() await service.stop() @pytest.mark.asyncio async def test_conversation_flow(self, mock_server_application, temp_dir): """Testet einen kompletten Konversationsablauf.""" mock_server_application.server_config.network.command_port = 0 mock_server_application.server_config.network.audio_input_port = 0 mock_server_application.server_config.network.audio_output_port = 0 mock_server_application.server_config.network.music_port = 0 mock_server_application.registration.enable_registration_mode() service = NetworkService(mock_server_application) await service.start() ports = {"command": service._servers["command"].sockets[0].getsockname()[1]} mock_server_application.server_config.network.command_port = ports["command"] mock_server_application.server_config.network.audio_input_port = service._servers["audio_in"].sockets[0].getsockname()[1] mock_server_application.server_config.network.audio_output_port = service._servers["audio_out"].sockets[0].getsockname()[1] mock_server_application.server_config.network.music_port = service._servers["music"].sockets[0].getsockname()[1] conversation_state = {"step": 0} # Konversations-Handler async def conversation_handler(satellite, message): if message.class_name == "WakewordDetected": conversation_state["step"] = 1 from trixy_core.network.cmd.wakeword import WakewordSelected await service.send_to_satellite(satellite.id, WakewordSelected( session_id="conv-test", conversation_id="conv-test", start_streaming=True )) elif message.class_name == "RecordingComplete": conversation_state["step"] = 2 # Simuliere Transkription from trixy_core.network.cmd.wakeword import TranscriptionResult await service.send_to_satellite(satellite.id, TranscriptionResult( session_id="conv-test", text="Wie ist das Wetter?", confidence=0.95 )) # Simuliere Intent from trixy_core.network.cmd.wakeword import IntentResult await service.send_to_satellite(satellite.id, IntentResult( session_id="conv-test", intent="weather.query", confidence=0.9, entities={"location": "Berlin"} )) # Simuliere Antwort from trixy_core.network.cmd.wakeword import AssistantResponse await service.send_to_satellite(satellite.id, AssistantResponse( session_id="conv-test", text="In Berlin ist es sonnig bei 20 Grad.", is_final=True )) conversation_state["step"] = 3 service.register_message_handler(conversation_handler) # Client client_messages = [] async def on_message(message): client_messages.append(message.class_name) client = ClientConnection( host="127.0.0.1", port=ports["command"], room="wohnzimmer", alias="Test Echo", mac_address="FF:FF:FF:FF:FF:05", ) client.on_message(on_message) await client.connect() await asyncio.sleep(0.1) # 1. Wakeword senden await client.send_command(WakewordDetected( satellite_id=client.satellite_id, wakeword_model="custom", confidence=0.95, audio_level=0.7 )) await asyncio.sleep(0.2) assert conversation_state["step"] >= 1 assert "WakewordSelected" in client_messages # 2. Recording senden from trixy_core.network.cmd.wakeword import RecordingComplete await client.send_command(RecordingComplete( session_id="conv-test", audio_data="", # Hex-encodierte Audio-Daten (leer für Test) duration_seconds=2.5, ended_by="silence", speech_detected=True, peak_level=0.5 )) await asyncio.sleep(0.3) # Prüfe Konversationsablauf assert conversation_state["step"] >= 2 assert "TranscriptionResult" in client_messages or "AssistantResponse" in client_messages await client.disconnect() await service.stop()