test_network_transport.py 54 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553
  1. # -*- coding: utf-8 -*-
  2. """
  3. Unit-Tests für die Netzwerk-Transport-Schicht.
  4. Testet NetworkService, ClientConnection und deren Zusammenspiel.
  5. """
  6. import asyncio
  7. import pytest
  8. from unittest.mock import MagicMock, AsyncMock, patch
  9. from pathlib import Path
  10. from trixy_core.network.service import NetworkService, ClientHandler, AudioStreamHandler
  11. from trixy_core.network.client_connection import ClientConnection, ConnectionConfig, get_mac_address
  12. from trixy_core.network.message_handler import MessageHandler, EventBridge, setup_default_event_bridge
  13. from trixy_core.network.protocol import TrixyProtocol, ProtocolFlags, ProtocolMessage, COMMAND_PING, COMMAND_PONG
  14. from trixy_core.network.encryption import TrixyEncryption
  15. from trixy_core.network.cmd import (
  16. SatelliteConnect,
  17. SatelliteAccepted,
  18. SatelliteDenied,
  19. SatelliteDisconnect,
  20. )
  21. from trixy_core.network.cmd.wakeword import WakewordDetected
  22. from trixy_core.satellite.satellite import Satellite, ConnectionState
  23. from trixy_core.satellite.satellite_manager import SatelliteManager
  24. from trixy_core.satellite.registration_manager import RegistrationManager
  25. from trixy_core.config.datasets.server import ServerConfig, NetworkConfig, SecurityConfig
  26. # ============================================================================
  27. # Fixtures
  28. # ============================================================================
  29. @pytest.fixture
  30. def mock_server_application(temp_dir):
  31. """Erstellt eine Mock-Server-Anwendung."""
  32. app = MagicMock()
  33. # Server-Konfiguration
  34. network_config = NetworkConfig(
  35. command_port=0, # 0 = automatische Port-Zuweisung
  36. audio_input_port=0,
  37. audio_output_port=0,
  38. music_port=0,
  39. bind_address="127.0.0.1",
  40. )
  41. security_config = SecurityConfig(
  42. encryption_enabled=False,
  43. encryption_key_path=str(temp_dir / "test.key"),
  44. )
  45. server_config = ServerConfig(
  46. network=network_config,
  47. security=security_config,
  48. )
  49. app.server_config = server_config
  50. app.satellites = SatelliteManager(app)
  51. app.registration = RegistrationManager(app, directory=temp_dir / "satellites")
  52. app.events = MagicMock()
  53. app.events.trigger = AsyncMock()
  54. return app
  55. @pytest.fixture
  56. def mock_client_config():
  57. """Erstellt eine Mock-Client-Konfiguration."""
  58. return ConnectionConfig(
  59. connect_timeout=5.0,
  60. reconnect_interval=1.0,
  61. ping_interval=30.0,
  62. pong_timeout=5.0,
  63. )
  64. @pytest.fixture
  65. def test_mac_address():
  66. """Test-MAC-Adresse."""
  67. return "AA:BB:CC:DD:EE:FF"
  68. @pytest.fixture
  69. def protocol():
  70. """Erstellt ein TrixyProtocol."""
  71. return TrixyProtocol()
  72. # ============================================================================
  73. # Helper-Klassen für Tests
  74. # ============================================================================
  75. class MockStreamReader:
  76. """Mock für asyncio.StreamReader."""
  77. def __init__(self):
  78. self._data = b""
  79. self._pos = 0
  80. self._closed = False
  81. def feed_data(self, data: bytes):
  82. """Fügt Daten zum Stream hinzu."""
  83. self._data += data
  84. async def read(self, n: int) -> bytes:
  85. """Liest n Bytes."""
  86. if self._closed:
  87. return b""
  88. data = self._data[self._pos:self._pos + n]
  89. self._pos += len(data)
  90. return data
  91. async def readexactly(self, n: int) -> bytes:
  92. """Liest exakt n Bytes."""
  93. return await self.read(n)
  94. def close(self):
  95. self._closed = True
  96. class MockStreamWriter:
  97. """Mock für asyncio.StreamWriter."""
  98. def __init__(self):
  99. self._data = b""
  100. self._closed = False
  101. self._extra_info = {"peername": ("127.0.0.1", 12345)}
  102. def write(self, data: bytes):
  103. """Schreibt Daten."""
  104. self._data += data
  105. async def drain(self):
  106. """Wartet auf Schreiben."""
  107. pass
  108. def close(self):
  109. self._closed = True
  110. async def wait_closed(self):
  111. """Wartet auf Schließen."""
  112. pass
  113. def get_extra_info(self, key: str):
  114. """Gibt Extra-Info zurück."""
  115. return self._extra_info.get(key)
  116. def get_written_data(self) -> bytes:
  117. """Gibt geschriebene Daten zurück."""
  118. return self._data
  119. def clear(self):
  120. """Löscht geschriebene Daten."""
  121. self._data = b""
  122. # ============================================================================
  123. # Tests für get_mac_address
  124. # ============================================================================
  125. class TestGetMacAddress:
  126. """Tests für get_mac_address Hilfsfunktion."""
  127. def test_returns_valid_mac(self):
  128. """Testet, dass eine gültige MAC-Adresse zurückgegeben wird."""
  129. mac = get_mac_address()
  130. # Format prüfen: AA:BB:CC:DD:EE:FF
  131. assert len(mac) == 17
  132. parts = mac.split(":")
  133. assert len(parts) == 6
  134. for part in parts:
  135. assert len(part) == 2
  136. int(part, 16) # Muss hexadezimal sein
  137. # ============================================================================
  138. # Tests für ConnectionConfig
  139. # ============================================================================
  140. class TestConnectionConfig:
  141. """Tests für ConnectionConfig."""
  142. def test_default_values(self):
  143. """Testet die Standardwerte."""
  144. config = ConnectionConfig()
  145. assert config.connect_timeout == 10.0
  146. assert config.reconnect_interval == 5.0
  147. assert config.max_reconnect_attempts == 0
  148. assert config.ping_interval == 30.0
  149. assert config.pong_timeout == 10.0
  150. def test_custom_values(self):
  151. """Testet benutzerdefinierte Werte."""
  152. config = ConnectionConfig(
  153. connect_timeout=5.0,
  154. reconnect_interval=2.0,
  155. ping_interval=15.0,
  156. )
  157. assert config.connect_timeout == 5.0
  158. assert config.reconnect_interval == 2.0
  159. assert config.ping_interval == 15.0
  160. # ============================================================================
  161. # Tests für MessageHandler
  162. # ============================================================================
  163. class TestMessageHandler:
  164. """Tests für MessageHandler."""
  165. @pytest.fixture
  166. def handler(self):
  167. """Erstellt einen MessageHandler."""
  168. return MessageHandler()
  169. @pytest.fixture
  170. def test_satellite(self):
  171. """Erstellt einen Test-Satellite."""
  172. return Satellite(
  173. satellite_id="test-sat",
  174. room_id="wohnzimmer",
  175. mac_address="AA:BB:CC:DD:EE:FF",
  176. alias="Test Echo"
  177. )
  178. @pytest.mark.asyncio
  179. async def test_register_handler(self, handler):
  180. """Testet die Handler-Registrierung."""
  181. async def my_handler(satellite, message):
  182. pass
  183. handler.register(my_handler, ["TestMessage"])
  184. assert len(handler._handlers) == 1
  185. @pytest.mark.asyncio
  186. async def test_unregister_handler(self, handler):
  187. """Testet das Entfernen eines Handlers."""
  188. async def my_handler(satellite, message):
  189. pass
  190. handler.register(my_handler)
  191. result = handler.unregister(my_handler)
  192. assert result is True
  193. assert len(handler._handlers) == 0
  194. @pytest.mark.asyncio
  195. async def test_dispatch_calls_handler(self, handler, test_satellite):
  196. """Testet, dass dispatch Handler aufruft."""
  197. called = []
  198. async def my_handler(satellite, message):
  199. called.append((satellite.id, message.class_name))
  200. handler.register(my_handler, ["WakewordDetected"])
  201. message = ProtocolMessage(
  202. class_name="WakewordDetected",
  203. data={"confidence": 0.9}
  204. )
  205. count = await handler.dispatch(test_satellite, message)
  206. assert count == 1
  207. assert len(called) == 1
  208. assert called[0] == ("test-sat", "WakewordDetected")
  209. @pytest.mark.asyncio
  210. async def test_dispatch_filters_by_class_name(self, handler, test_satellite):
  211. """Testet, dass dispatch nach Klassennamen filtert."""
  212. called = []
  213. async def my_handler(satellite, message):
  214. called.append(message.class_name)
  215. handler.register(my_handler, ["WakewordDetected"])
  216. # Diese sollte aufgerufen werden
  217. msg1 = ProtocolMessage(class_name="WakewordDetected", data={})
  218. await handler.dispatch(test_satellite, msg1)
  219. # Diese sollte NICHT aufgerufen werden
  220. msg2 = ProtocolMessage(class_name="OtherMessage", data={})
  221. await handler.dispatch(test_satellite, msg2)
  222. assert len(called) == 1
  223. assert called[0] == "WakewordDetected"
  224. @pytest.mark.asyncio
  225. async def test_dispatch_with_filter_func(self, handler, test_satellite):
  226. """Testet dispatch mit Filterfunktion."""
  227. called = []
  228. async def my_handler(satellite, message):
  229. called.append(message.data)
  230. # Nur Nachrichten mit hoher Confidence
  231. handler.register(
  232. my_handler,
  233. filter_func=lambda m: m.data.get("confidence", 0) > 0.8
  234. )
  235. # Hohe Confidence - sollte aufgerufen werden
  236. msg1 = ProtocolMessage(class_name="Test", data={"confidence": 0.95})
  237. await handler.dispatch(test_satellite, msg1)
  238. # Niedrige Confidence - sollte NICHT aufgerufen werden
  239. msg2 = ProtocolMessage(class_name="Test", data={"confidence": 0.5})
  240. await handler.dispatch(test_satellite, msg2)
  241. assert len(called) == 1
  242. assert called[0]["confidence"] == 0.95
  243. @pytest.mark.asyncio
  244. async def test_dispatch_priority(self, handler, test_satellite):
  245. """Testet, dass Handler nach Priorität aufgerufen werden."""
  246. order = []
  247. async def low_priority(satellite, message):
  248. order.append("low")
  249. async def high_priority(satellite, message):
  250. order.append("high")
  251. handler.register(low_priority, priority=0)
  252. handler.register(high_priority, priority=10)
  253. message = ProtocolMessage(class_name="Test", data={})
  254. await handler.dispatch(test_satellite, message)
  255. assert order == ["high", "low"]
  256. # ============================================================================
  257. # Tests für EventBridge
  258. # ============================================================================
  259. class TestEventBridge:
  260. """Tests für EventBridge."""
  261. @pytest.fixture
  262. def mock_event_manager(self):
  263. """Erstellt einen Mock-EventManager."""
  264. manager = MagicMock()
  265. manager.trigger = AsyncMock()
  266. return manager
  267. @pytest.fixture
  268. def bridge(self, mock_event_manager):
  269. """Erstellt eine EventBridge."""
  270. return EventBridge(mock_event_manager)
  271. @pytest.fixture
  272. def test_satellite(self):
  273. """Erstellt einen Test-Satellite."""
  274. return Satellite(
  275. satellite_id="test-sat",
  276. room_id="wohnzimmer",
  277. alias="Test Echo"
  278. )
  279. @pytest.mark.asyncio
  280. async def test_map_and_trigger(self, bridge, mock_event_manager, test_satellite):
  281. """Testet Mapping und Event-Auslösung."""
  282. bridge.map("WakewordDetected", "wakeword_detected")
  283. message = ProtocolMessage(
  284. class_name="WakewordDetected",
  285. data={"confidence": 0.95, "model": "custom"}
  286. )
  287. result = await bridge.on_message(test_satellite, message)
  288. assert result is True
  289. mock_event_manager.trigger.assert_called_once()
  290. call_args = mock_event_manager.trigger.call_args
  291. assert call_args[0][0] == "wakeword_detected"
  292. @pytest.mark.asyncio
  293. async def test_unmapped_message_returns_false(self, bridge, test_satellite):
  294. """Testet, dass unmapped Nachrichten False zurückgeben."""
  295. message = ProtocolMessage(class_name="UnknownMessage", data={})
  296. result = await bridge.on_message(test_satellite, message)
  297. assert result is False
  298. @pytest.mark.asyncio
  299. async def test_satellite_info_added(self, bridge, mock_event_manager, test_satellite):
  300. """Testet, dass Satellite-Info zu Event-Daten hinzugefügt wird."""
  301. bridge.map("TestMessage", "test_event")
  302. message = ProtocolMessage(class_name="TestMessage", data={"value": 42})
  303. await bridge.on_message(test_satellite, message)
  304. call_args = mock_event_manager.trigger.call_args
  305. event_data = call_args[0][1]
  306. assert event_data["satellite_id"] == "test-sat"
  307. assert event_data["satellite_alias"] == "Test Echo"
  308. assert event_data["satellite_room"] == "wohnzimmer"
  309. def test_setup_default_event_bridge(self, mock_event_manager):
  310. """Testet setup_default_event_bridge."""
  311. bridge = setup_default_event_bridge(mock_event_manager)
  312. assert "WakewordDetected" in bridge._mappings
  313. assert "RecordingComplete" in bridge._mappings
  314. assert bridge._mappings["WakewordDetected"] == "wakeword_detected"
  315. # ============================================================================
  316. # Tests für TrixyProtocol Serialisierung
  317. # ============================================================================
  318. class TestProtocolSerialization:
  319. """Tests für Protokoll-Serialisierung im Transport-Kontext."""
  320. @pytest.fixture
  321. def protocol(self):
  322. """Erstellt ein TrixyProtocol."""
  323. return TrixyProtocol()
  324. def test_serialize_satellite_connect(self, protocol):
  325. """Testet Serialisierung von SatelliteConnect."""
  326. msg = SatelliteConnect(
  327. mac_address="AA:BB:CC:DD:EE:FF",
  328. room="wohnzimmer",
  329. alias="Test Echo",
  330. version="1.0.0"
  331. )
  332. serialized = protocol.serialize(msg)
  333. deserialized = protocol.deserialize(serialized)
  334. assert deserialized.class_name == "SatelliteConnect"
  335. assert deserialized.data.mac_address == "AA:BB:CC:DD:EE:FF"
  336. assert deserialized.data.room == "wohnzimmer"
  337. def test_serialize_satellite_accepted(self, protocol):
  338. """Testet Serialisierung von SatelliteAccepted."""
  339. session_key = TrixyEncryption.generate_key()
  340. msg = SatelliteAccepted(
  341. satellite_id="sat-123",
  342. session_key=session_key,
  343. audio_input_port=2102,
  344. audio_output_port=2103,
  345. music_port=2104
  346. )
  347. serialized = protocol.serialize(msg)
  348. deserialized = protocol.deserialize(serialized)
  349. assert deserialized.class_name == "SatelliteAccepted"
  350. assert deserialized.data.satellite_id == "sat-123"
  351. assert deserialized.data.audio_input_port == 2102
  352. def test_serialize_wakeword_detected(self, protocol):
  353. """Testet Serialisierung von WakewordDetected."""
  354. msg = WakewordDetected(
  355. satellite_id="sat-123",
  356. wakeword_model="custom",
  357. confidence=0.95,
  358. audio_level=0.7
  359. )
  360. serialized = protocol.serialize(msg)
  361. deserialized = protocol.deserialize(serialized)
  362. assert deserialized.class_name == "WakewordDetected"
  363. assert deserialized.data.confidence == 0.95
  364. # ============================================================================
  365. # Integration Tests - Server und Client zusammen
  366. # ============================================================================
  367. class TestServerClientIntegration:
  368. """Integrationstests für Server und Client."""
  369. @pytest.fixture
  370. async def running_server(self, mock_server_application):
  371. """Startet einen NetworkService auf zufälligen Ports."""
  372. service = NetworkService(mock_server_application)
  373. # Registrierungsmodus aktivieren für Tests
  374. mock_server_application.registration.enable_registration_mode(timeout_seconds=300)
  375. await service.start()
  376. # Tatsächliche Ports ermitteln
  377. command_server = service._servers["command"]
  378. audio_in_server = service._servers["audio_in"]
  379. audio_out_server = service._servers["audio_out"]
  380. music_server = service._servers["music"]
  381. ports = {
  382. "command": command_server.sockets[0].getsockname()[1],
  383. "audio_in": audio_in_server.sockets[0].getsockname()[1],
  384. "audio_out": audio_out_server.sockets[0].getsockname()[1],
  385. "music": music_server.sockets[0].getsockname()[1],
  386. }
  387. # Konfiguration aktualisieren mit tatsächlichen Ports
  388. mock_server_application.server_config.network.command_port = ports["command"]
  389. mock_server_application.server_config.network.audio_input_port = ports["audio_in"]
  390. mock_server_application.server_config.network.audio_output_port = ports["audio_out"]
  391. mock_server_application.server_config.network.music_port = ports["music"]
  392. yield service, ports
  393. await service.stop()
  394. @pytest.mark.asyncio
  395. async def test_client_connects_to_server(self, running_server, test_mac_address):
  396. """Testet, dass Client sich mit Server verbinden kann."""
  397. service, ports = running_server
  398. # Client erstellen
  399. client = ClientConnection(
  400. host="127.0.0.1",
  401. port=ports["command"],
  402. room="wohnzimmer",
  403. alias="Test Echo",
  404. mac_address=test_mac_address,
  405. config=ConnectionConfig(connect_timeout=5.0),
  406. )
  407. # Verbinden
  408. success = await client.connect()
  409. assert success is True
  410. assert client.is_connected is True
  411. assert client.satellite_id != ""
  412. # Aufräumen
  413. await client.disconnect()
  414. @pytest.mark.asyncio
  415. async def test_unregistered_client_is_denied(self, running_server, mock_server_application, test_mac_address):
  416. """Testet, dass unregistrierter Client abgelehnt wird."""
  417. service, ports = running_server
  418. # Registrierungsmodus deaktivieren
  419. mock_server_application.registration.disable_registration_mode()
  420. # Client erstellen
  421. client = ClientConnection(
  422. host="127.0.0.1",
  423. port=ports["command"],
  424. room="wohnzimmer",
  425. alias="Test Echo",
  426. mac_address=test_mac_address,
  427. config=ConnectionConfig(connect_timeout=5.0),
  428. )
  429. # Verbinden sollte fehlschlagen
  430. success = await client.connect()
  431. assert success is False
  432. assert client.is_connected is False
  433. @pytest.mark.asyncio
  434. async def test_registered_client_is_accepted(self, running_server, mock_server_application, test_mac_address):
  435. """Testet, dass registrierter Client akzeptiert wird."""
  436. service, ports = running_server
  437. # Deaktiviere Auto-Registrierung
  438. mock_server_application.registration.disable_registration_mode()
  439. # Manuell registrieren
  440. mock_server_application.registration.enable_registration_mode()
  441. mock_server_application.registration.begin_registration(
  442. test_mac_address, "wohnzimmer", "Test Echo"
  443. )
  444. satellite = mock_server_application.registration.complete_registration(test_mac_address)
  445. mock_server_application.satellites.add(satellite)
  446. mock_server_application.registration.disable_registration_mode()
  447. # Client erstellen
  448. client = ClientConnection(
  449. host="127.0.0.1",
  450. port=ports["command"],
  451. room="wohnzimmer",
  452. alias="Test Echo",
  453. mac_address=test_mac_address,
  454. config=ConnectionConfig(connect_timeout=5.0),
  455. )
  456. # Verbinden sollte funktionieren
  457. success = await client.connect()
  458. assert success is True
  459. assert client.is_connected is True
  460. await client.disconnect()
  461. @pytest.mark.asyncio
  462. async def test_server_sends_message_to_client(self, running_server, test_mac_address):
  463. """Testet, dass Server Nachrichten an Client senden kann."""
  464. service, ports = running_server
  465. received_messages = []
  466. # Client erstellen
  467. client = ClientConnection(
  468. host="127.0.0.1",
  469. port=ports["command"],
  470. room="wohnzimmer",
  471. alias="Test Echo",
  472. mac_address=test_mac_address,
  473. )
  474. async def on_message(message):
  475. received_messages.append(message)
  476. client.on_message(on_message)
  477. # Verbinden
  478. await client.connect()
  479. # Warte kurz auf Verbindungsaufbau
  480. await asyncio.sleep(0.2)
  481. # Server sendet Nachricht
  482. from trixy_core.network.cmd.system import SystemStatus
  483. status_msg = SystemStatus(status="running", uptime_seconds=100.0)
  484. sent = await service.send_to_satellite(client.satellite_id, status_msg)
  485. assert sent is True
  486. # Warte auf Empfang
  487. await asyncio.sleep(0.2)
  488. assert len(received_messages) >= 1
  489. await client.disconnect()
  490. @pytest.mark.asyncio
  491. async def test_client_sends_command_to_server(self, running_server, test_mac_address, mock_server_application):
  492. """Testet, dass Client Commands an Server senden kann."""
  493. service, ports = running_server
  494. received_messages = []
  495. # Message-Handler registrieren
  496. async def message_handler(satellite, message):
  497. received_messages.append((satellite.id, message))
  498. service.register_message_handler(message_handler)
  499. # Client erstellen und verbinden
  500. client = ClientConnection(
  501. host="127.0.0.1",
  502. port=ports["command"],
  503. room="wohnzimmer",
  504. alias="Test Echo",
  505. mac_address=test_mac_address,
  506. )
  507. await client.connect()
  508. await asyncio.sleep(0.1)
  509. # Wakeword senden
  510. wakeword_msg = WakewordDetected(
  511. satellite_id=client.satellite_id,
  512. wakeword_model="custom",
  513. confidence=0.95,
  514. audio_level=0.7
  515. )
  516. success = await client.send_command(wakeword_msg)
  517. assert success is True
  518. # Warte auf Verarbeitung
  519. await asyncio.sleep(0.2)
  520. assert len(received_messages) >= 1
  521. _, msg = received_messages[0]
  522. assert msg.class_name == "WakewordDetected"
  523. await client.disconnect()
  524. @pytest.mark.asyncio
  525. async def test_keepalive_ping_pong(self, running_server, test_mac_address):
  526. """Testet KeepAlive Ping/Pong."""
  527. service, ports = running_server
  528. # Client mit normalem Ping-Intervall (Test prüft nur dass Verbindung stabil ist)
  529. client = ClientConnection(
  530. host="127.0.0.1",
  531. port=ports["command"],
  532. room="wohnzimmer",
  533. alias="Test Echo",
  534. mac_address=test_mac_address,
  535. config=ConnectionConfig(ping_interval=30.0), # Normales Intervall
  536. )
  537. await client.connect()
  538. assert client.is_connected is True
  539. # Server-seitige KeepAlive prüfen
  540. satellite = service.application.satellites.get(client.satellite_id)
  541. assert satellite is not None
  542. # KeepAlive-Session sollte existieren
  543. session = service.keepalive.get_session(client.satellite_id)
  544. assert session is not None
  545. assert session.is_running is True
  546. await client.disconnect()
  547. @pytest.mark.asyncio
  548. async def test_client_disconnect_event(self, running_server, test_mac_address, mock_server_application):
  549. """Testet, dass Disconnect-Event ausgelöst wird."""
  550. service, ports = running_server
  551. client = ClientConnection(
  552. host="127.0.0.1",
  553. port=ports["command"],
  554. room="wohnzimmer",
  555. alias="Test Echo",
  556. mac_address=test_mac_address,
  557. )
  558. await client.connect()
  559. satellite_id = client.satellite_id
  560. await asyncio.sleep(0.1)
  561. # Client trennen
  562. await client.disconnect("Test disconnect")
  563. await asyncio.sleep(0.2)
  564. # Event sollte ausgelöst worden sein
  565. calls = mock_server_application.events.trigger.call_args_list
  566. event_names = [call[0][0] for call in calls]
  567. assert "satellite_disconnected" in event_names
  568. @pytest.mark.asyncio
  569. async def test_multiple_clients(self, running_server):
  570. """Testet mehrere gleichzeitige Clients."""
  571. service, ports = running_server
  572. clients = []
  573. for i in range(3):
  574. mac = f"AA:BB:CC:DD:EE:{i:02X}"
  575. client = ClientConnection(
  576. host="127.0.0.1",
  577. port=ports["command"],
  578. room=f"raum{i}",
  579. alias=f"Echo {i}",
  580. mac_address=mac,
  581. )
  582. success = await client.connect()
  583. assert success is True
  584. clients.append(client)
  585. # Alle sollten verbunden sein
  586. await asyncio.sleep(0.2)
  587. assert all(c.is_connected for c in clients)
  588. # Alle trennen
  589. for client in clients:
  590. await client.disconnect()
  591. @pytest.mark.asyncio
  592. async def test_broadcast_to_all_clients(self, running_server, mock_server_application):
  593. """Testet Broadcast an alle Clients."""
  594. service, ports = running_server
  595. received_counts = {}
  596. clients = []
  597. for i in range(2):
  598. mac = f"BB:BB:BB:BB:BB:{i:02X}"
  599. client = ClientConnection(
  600. host="127.0.0.1",
  601. port=ports["command"],
  602. room="wohnzimmer",
  603. alias=f"Echo {i}",
  604. mac_address=mac,
  605. )
  606. async def make_handler(client_id):
  607. async def handler(message):
  608. received_counts[client_id] = received_counts.get(client_id, 0) + 1
  609. return handler
  610. client.on_message(await make_handler(i))
  611. await client.connect()
  612. clients.append(client)
  613. await asyncio.sleep(0.2)
  614. # Broadcast senden
  615. from trixy_core.network.cmd.system import SystemStatus
  616. status_msg = SystemStatus(status="broadcast_test", uptime_seconds=0.0)
  617. count = await service.broadcast(status_msg)
  618. assert count == 2
  619. await asyncio.sleep(0.3)
  620. # Beide Clients sollten empfangen haben
  621. assert sum(received_counts.values()) >= 2
  622. for client in clients:
  623. await client.disconnect()
  624. # ============================================================================
  625. # Tests für Audio-Streaming
  626. # ============================================================================
  627. class TestAudioStreaming:
  628. """Tests für Audio-Streaming."""
  629. @pytest.fixture
  630. async def connected_client_and_server(self, mock_server_application, temp_dir):
  631. """Startet Server und verbindet Client."""
  632. # Server mit zufälligen Ports
  633. mock_server_application.server_config.network.command_port = 0
  634. mock_server_application.server_config.network.audio_input_port = 0
  635. mock_server_application.server_config.network.audio_output_port = 0
  636. mock_server_application.server_config.network.music_port = 0
  637. mock_server_application.registration.enable_registration_mode(timeout_seconds=300)
  638. service = NetworkService(mock_server_application)
  639. await service.start()
  640. # Ports ermitteln
  641. ports = {
  642. "command": service._servers["command"].sockets[0].getsockname()[1],
  643. "audio_in": service._servers["audio_in"].sockets[0].getsockname()[1],
  644. "audio_out": service._servers["audio_out"].sockets[0].getsockname()[1],
  645. "music": service._servers["music"].sockets[0].getsockname()[1],
  646. }
  647. mock_server_application.server_config.network.command_port = ports["command"]
  648. mock_server_application.server_config.network.audio_input_port = ports["audio_in"]
  649. mock_server_application.server_config.network.audio_output_port = ports["audio_out"]
  650. mock_server_application.server_config.network.music_port = ports["music"]
  651. # Client verbinden
  652. client = ClientConnection(
  653. host="127.0.0.1",
  654. port=ports["command"],
  655. room="wohnzimmer",
  656. alias="Test Echo",
  657. mac_address="CC:CC:CC:CC:CC:CC",
  658. )
  659. await client.connect()
  660. await asyncio.sleep(0.2)
  661. yield service, client, mock_server_application
  662. await client.disconnect()
  663. await service.stop()
  664. @pytest.mark.asyncio
  665. async def test_client_sends_audio(self, connected_client_and_server):
  666. """Testet, dass Client Audio-Daten senden kann."""
  667. service, client, app = connected_client_and_server
  668. # Audio-Daten erstellen (100ms bei 16kHz mono)
  669. audio_data = b"\x00\x00" * 1600
  670. # Audio senden
  671. success = await client.send_audio(audio_data)
  672. # Hinweis: Ohne Audio-In-Socket verbunden, wird das fehlschlagen
  673. # Aber wir testen, dass die Methode existiert und aufgerufen werden kann
  674. # In einem echten Test würde man den Audio-Socket prüfen
  675. # ============================================================================
  676. # Tests für Fehlerbehandlung
  677. # ============================================================================
  678. class TestErrorHandling:
  679. """Tests für Fehlerbehandlung."""
  680. @pytest.mark.asyncio
  681. async def test_connection_timeout(self):
  682. """Testet Verbindungs-Timeout."""
  683. client = ClientConnection(
  684. host="192.0.2.1", # TEST-NET-1, sollte nicht erreichbar sein
  685. port=2101,
  686. room="test",
  687. alias="Test",
  688. mac_address="DD:DD:DD:DD:DD:DD",
  689. config=ConnectionConfig(connect_timeout=0.5),
  690. )
  691. success = await client.connect()
  692. assert success is False
  693. assert client.is_connected is False
  694. @pytest.mark.asyncio
  695. async def test_disconnect_callback_called(self, mock_server_application, temp_dir):
  696. """Testet, dass Disconnect-Callback aufgerufen wird."""
  697. mock_server_application.server_config.network.command_port = 0
  698. mock_server_application.server_config.network.audio_input_port = 0
  699. mock_server_application.server_config.network.audio_output_port = 0
  700. mock_server_application.server_config.network.music_port = 0
  701. mock_server_application.registration.enable_registration_mode()
  702. service = NetworkService(mock_server_application)
  703. await service.start()
  704. ports = {
  705. "command": service._servers["command"].sockets[0].getsockname()[1],
  706. }
  707. mock_server_application.server_config.network.command_port = ports["command"]
  708. mock_server_application.server_config.network.audio_input_port = service._servers["audio_in"].sockets[0].getsockname()[1]
  709. mock_server_application.server_config.network.audio_output_port = service._servers["audio_out"].sockets[0].getsockname()[1]
  710. mock_server_application.server_config.network.music_port = service._servers["music"].sockets[0].getsockname()[1]
  711. disconnect_reason = None
  712. async def on_disconnect(reason):
  713. nonlocal disconnect_reason
  714. disconnect_reason = reason
  715. client = ClientConnection(
  716. host="127.0.0.1",
  717. port=ports["command"],
  718. room="test",
  719. alias="Test",
  720. mac_address="EE:EE:EE:EE:EE:EE",
  721. )
  722. client.on_disconnected(on_disconnect)
  723. await client.connect()
  724. await client.disconnect("Test reason")
  725. assert disconnect_reason == "Test reason"
  726. await service.stop()
  727. # ============================================================================
  728. # Tests für NetworkService Health Check
  729. # ============================================================================
  730. class TestNetworkServiceHealth:
  731. """Tests für NetworkService Health-Checks."""
  732. @pytest.mark.asyncio
  733. async def test_health_check_returns_true_when_running(self, mock_server_application):
  734. """Testet, dass health_check True zurückgibt wenn Service läuft."""
  735. mock_server_application.server_config.network.command_port = 0
  736. mock_server_application.server_config.network.audio_input_port = 0
  737. mock_server_application.server_config.network.audio_output_port = 0
  738. mock_server_application.server_config.network.music_port = 0
  739. service = NetworkService(mock_server_application)
  740. await service.start()
  741. result = await service.health_check()
  742. assert result is True
  743. await service.stop()
  744. @pytest.mark.asyncio
  745. async def test_health_check_returns_false_when_stopped(self, mock_server_application):
  746. """Testet, dass health_check False zurückgibt wenn Server gestoppt."""
  747. mock_server_application.server_config.network.command_port = 0
  748. mock_server_application.server_config.network.audio_input_port = 0
  749. mock_server_application.server_config.network.audio_output_port = 0
  750. mock_server_application.server_config.network.music_port = 0
  751. service = NetworkService(mock_server_application)
  752. await service.start()
  753. await service.stop()
  754. result = await service.health_check()
  755. assert result is False
  756. # ============================================================================
  757. # Tests für Audio/Musik Transfer
  758. # ============================================================================
  759. class TestAudioTransfer:
  760. """Tests für Audio-Transfer über das Netzwerk."""
  761. @pytest.fixture
  762. async def connected_setup(self, mock_server_application, temp_dir):
  763. """Startet Server und Client mit allen Ports verbunden."""
  764. mock_server_application.server_config.network.command_port = 0
  765. mock_server_application.server_config.network.audio_input_port = 0
  766. mock_server_application.server_config.network.audio_output_port = 0
  767. mock_server_application.server_config.network.music_port = 0
  768. mock_server_application.registration.enable_registration_mode(timeout_seconds=300)
  769. service = NetworkService(mock_server_application)
  770. await service.start()
  771. # Ports ermitteln
  772. ports = {
  773. "command": service._servers["command"].sockets[0].getsockname()[1],
  774. "audio_in": service._servers["audio_in"].sockets[0].getsockname()[1],
  775. "audio_out": service._servers["audio_out"].sockets[0].getsockname()[1],
  776. "music": service._servers["music"].sockets[0].getsockname()[1],
  777. }
  778. mock_server_application.server_config.network.command_port = ports["command"]
  779. mock_server_application.server_config.network.audio_input_port = ports["audio_in"]
  780. mock_server_application.server_config.network.audio_output_port = ports["audio_out"]
  781. mock_server_application.server_config.network.music_port = ports["music"]
  782. # Client verbinden
  783. client = ClientConnection(
  784. host="127.0.0.1",
  785. port=ports["command"],
  786. room="wohnzimmer",
  787. alias="Test Echo",
  788. mac_address="FF:FF:FF:FF:FF:01",
  789. )
  790. await client.connect()
  791. await asyncio.sleep(0.3) # Warte auf Audio-Ports
  792. yield service, client, mock_server_application, ports
  793. await client.disconnect()
  794. await service.stop()
  795. @pytest.mark.asyncio
  796. async def test_audio_out_transfer(self, connected_setup):
  797. """Testet Audio-Output Transfer vom Server zum Client."""
  798. service, client, app, ports = connected_setup
  799. received_audio = []
  800. async def on_audio(data: bytes):
  801. received_audio.append(data)
  802. client.on_audio_out(on_audio)
  803. # Hole den Satellite
  804. satellite = app.satellites.get(client.satellite_id)
  805. assert satellite is not None
  806. # Warte auf Audio-Socket-Verbindung
  807. await asyncio.sleep(0.3)
  808. # Erstelle Test-Audio (100ms bei 16kHz mono 16-bit)
  809. # 16000 samples/s * 0.1s * 2 bytes/sample = 3200 bytes
  810. test_audio = bytes([i % 256 for i in range(3200)])
  811. # Sende Audio direkt über Satellite
  812. if satellite.sockets.audio_out:
  813. success = await satellite.say(test_audio)
  814. await asyncio.sleep(0.2)
  815. # Client sollte Audio empfangen haben
  816. if received_audio:
  817. total_received = b"".join(received_audio)
  818. assert len(total_received) > 0
  819. @pytest.mark.asyncio
  820. async def test_music_transfer(self, connected_setup):
  821. """Testet Music Transfer vom Server zum Client."""
  822. service, client, app, ports = connected_setup
  823. received_music = []
  824. async def on_music(data: bytes):
  825. received_music.append(data)
  826. client.on_music(on_music)
  827. # Hole den Satellite
  828. satellite = app.satellites.get(client.satellite_id)
  829. assert satellite is not None
  830. # Warte auf Music-Socket-Verbindung
  831. await asyncio.sleep(0.3)
  832. # Erstelle Test-Musik (100ms bei 48kHz stereo 16-bit)
  833. # 48000 samples/s * 0.1s * 2 channels * 2 bytes/sample = 19200 bytes
  834. test_music = bytes([i % 256 for i in range(19200)])
  835. # Sende Musik direkt über Socket
  836. if satellite.sockets.music_out:
  837. try:
  838. satellite.sockets.music_out.write(test_music)
  839. await satellite.sockets.music_out.drain()
  840. await asyncio.sleep(0.2)
  841. # Client sollte Musik empfangen haben
  842. if received_music:
  843. total_received = b"".join(received_music)
  844. assert len(total_received) > 0
  845. except Exception:
  846. pass # Socket möglicherweise nicht verbunden
  847. @pytest.mark.asyncio
  848. async def test_audio_input_from_client(self, connected_setup):
  849. """Testet Audio-Input vom Client zum Server."""
  850. service, client, app, ports = connected_setup
  851. received_events = []
  852. # Event-Handler für Audio
  853. async def audio_handler(event_name, data):
  854. received_events.append(data)
  855. app.events.trigger = audio_handler
  856. # Test-Audio erstellen
  857. test_audio = b"\x00\x00" * 1600 # 100ms Stille
  858. # Audio senden
  859. success = await client.send_audio(test_audio)
  860. await asyncio.sleep(0.2)
  861. # Event sollte ausgelöst worden sein (oder Audio empfangen)
  862. # Hinweis: Hängt davon ab, ob Audio-In Socket verbunden ist
  863. # ============================================================================
  864. # Tests für TTS-Integration
  865. # ============================================================================
  866. class TestTTSIntegration:
  867. """Tests für Text-to-Speech Integration über das Netzwerk."""
  868. @pytest.fixture
  869. def mock_tts_provider(self):
  870. """Erstellt einen Mock-TTS-Provider."""
  871. from trixy_core.audio.tts import TTSProvider, TTSConfig, TTSResult, TTSState, Voice
  872. class MockTTSProvider(TTSProvider):
  873. """Mock TTS für Tests."""
  874. def __init__(self):
  875. super().__init__(TTSConfig())
  876. self._state = TTSState.READY
  877. @property
  878. def name(self) -> str:
  879. return "mock"
  880. @property
  881. def supported_languages(self) -> list[str]:
  882. return ["de-DE", "en-US"]
  883. async def initialize(self) -> None:
  884. self._state = TTSState.READY
  885. async def shutdown(self) -> None:
  886. self._state = TTSState.UNINITIALIZED
  887. async def get_voices(self, language: str | None = None) -> list[Voice]:
  888. return [
  889. Voice(id="mock-de", name="Mock Deutsch", language="de-DE", gender="neutral"),
  890. Voice(id="mock-en", name="Mock English", language="en-US", gender="neutral"),
  891. ]
  892. async def synthesize(
  893. self,
  894. text: str,
  895. voice_id: str | None = None,
  896. language: str | None = None,
  897. ) -> TTSResult:
  898. """Generiert einfache Test-Audio-Daten."""
  899. # Erstelle Sinus-ähnliche Wellenform als Test
  900. # 16kHz, 16-bit mono, 0.5 Sekunden pro Wort
  901. words = len(text.split())
  902. duration = max(0.5, words * 0.3)
  903. sample_rate = 16000
  904. num_samples = int(duration * sample_rate)
  905. # Einfache Wellenform (Rechteck für Testzwecke)
  906. audio_data = b""
  907. for i in range(num_samples):
  908. # Abwechselnd +/- Werte für hörbare Wellenform
  909. value = 10000 if (i // 100) % 2 == 0 else -10000
  910. audio_data += value.to_bytes(2, 'little', signed=True)
  911. return TTSResult(
  912. audio_data=audio_data,
  913. sample_rate=sample_rate,
  914. channels=1,
  915. text=text,
  916. duration_seconds=duration,
  917. processing_time_ms=10.0,
  918. voice_id=voice_id or "mock-de",
  919. voice_name="Mock Voice",
  920. language=language or "de-DE",
  921. provider="mock",
  922. model="mock-v1",
  923. )
  924. return MockTTSProvider()
  925. @pytest.mark.asyncio
  926. async def test_tts_synthesize_generates_audio(self, mock_tts_provider):
  927. """Testet, dass Mock-TTS Audio generiert."""
  928. result = await mock_tts_provider.synthesize("Test")
  929. assert result.audio_data is not None
  930. assert len(result.audio_data) > 0
  931. assert result.sample_rate == 16000
  932. assert result.text == "Test"
  933. assert result.duration_seconds > 0
  934. @pytest.mark.asyncio
  935. async def test_tts_longer_text_generates_more_audio(self, mock_tts_provider):
  936. """Testet, dass längerer Text mehr Audio generiert."""
  937. short_result = await mock_tts_provider.synthesize("Hi")
  938. long_result = await mock_tts_provider.synthesize("Dies ist ein längerer Testsatz mit mehreren Wörtern")
  939. assert len(long_result.audio_data) > len(short_result.audio_data)
  940. @pytest.mark.asyncio
  941. async def test_tts_to_satellite_workflow(self, mock_server_application, temp_dir, mock_tts_provider):
  942. """Testet den kompletten TTS-zu-Satellite Workflow."""
  943. # Server starten
  944. mock_server_application.server_config.network.command_port = 0
  945. mock_server_application.server_config.network.audio_input_port = 0
  946. mock_server_application.server_config.network.audio_output_port = 0
  947. mock_server_application.server_config.network.music_port = 0
  948. mock_server_application.registration.enable_registration_mode()
  949. service = NetworkService(mock_server_application)
  950. await service.start()
  951. ports = {
  952. "command": service._servers["command"].sockets[0].getsockname()[1],
  953. "audio_in": service._servers["audio_in"].sockets[0].getsockname()[1],
  954. "audio_out": service._servers["audio_out"].sockets[0].getsockname()[1],
  955. "music": service._servers["music"].sockets[0].getsockname()[1],
  956. }
  957. mock_server_application.server_config.network.command_port = ports["command"]
  958. mock_server_application.server_config.network.audio_input_port = ports["audio_in"]
  959. mock_server_application.server_config.network.audio_output_port = ports["audio_out"]
  960. mock_server_application.server_config.network.music_port = ports["music"]
  961. received_audio = []
  962. async def on_audio(data: bytes):
  963. received_audio.append(data)
  964. # Client verbinden
  965. client = ClientConnection(
  966. host="127.0.0.1",
  967. port=ports["command"],
  968. room="wohnzimmer",
  969. alias="Test Echo",
  970. mac_address="FF:FF:FF:FF:FF:02",
  971. )
  972. client.on_audio_out(on_audio)
  973. await client.connect()
  974. await asyncio.sleep(0.3)
  975. # TTS durchführen
  976. tts_result = await mock_tts_provider.synthesize("Test Audio Ausgabe")
  977. assert tts_result.audio_data is not None
  978. assert len(tts_result.audio_data) > 0
  979. # Audio an Satellite senden
  980. satellite = mock_server_application.satellites.get(client.satellite_id)
  981. if satellite and satellite.sockets.audio_out:
  982. await satellite.say(tts_result.audio_data)
  983. await asyncio.sleep(0.2)
  984. # Prüfe ob Client Audio empfangen hat
  985. if received_audio:
  986. total = b"".join(received_audio)
  987. assert len(total) > 0
  988. await client.disconnect()
  989. await service.stop()
  990. @pytest.mark.asyncio
  991. async def test_tts_event_workflow(self, mock_server_application, temp_dir, mock_tts_provider):
  992. """Testet TTS-Workflow über Events."""
  993. # Server starten
  994. mock_server_application.server_config.network.command_port = 0
  995. mock_server_application.server_config.network.audio_input_port = 0
  996. mock_server_application.server_config.network.audio_output_port = 0
  997. mock_server_application.server_config.network.music_port = 0
  998. mock_server_application.registration.enable_registration_mode()
  999. service = NetworkService(mock_server_application)
  1000. await service.start()
  1001. ports = {
  1002. "command": service._servers["command"].sockets[0].getsockname()[1],
  1003. "audio_in": service._servers["audio_in"].sockets[0].getsockname()[1],
  1004. "audio_out": service._servers["audio_out"].sockets[0].getsockname()[1],
  1005. "music": service._servers["music"].sockets[0].getsockname()[1],
  1006. }
  1007. mock_server_application.server_config.network.command_port = ports["command"]
  1008. mock_server_application.server_config.network.audio_input_port = ports["audio_in"]
  1009. mock_server_application.server_config.network.audio_output_port = ports["audio_out"]
  1010. mock_server_application.server_config.network.music_port = ports["music"]
  1011. tts_completed_events = []
  1012. # Simuliere TTS-Request-Handler
  1013. async def handle_tts_request(text: str, satellite_id: str):
  1014. """Simuliert einen TTS-Request wie ein Plugin ihn verarbeiten würde."""
  1015. result = await mock_tts_provider.synthesize(text)
  1016. # Sende Audio an Satellite
  1017. satellite = mock_server_application.satellites.get(satellite_id)
  1018. if satellite and satellite.sockets.audio_out:
  1019. await satellite.say(result.audio_data)
  1020. tts_completed_events.append({
  1021. "text": text,
  1022. "satellite_id": satellite_id,
  1023. "duration": result.duration_seconds,
  1024. "audio_size": len(result.audio_data),
  1025. })
  1026. received_audio = []
  1027. async def on_audio(data: bytes):
  1028. received_audio.append(data)
  1029. # Client verbinden
  1030. client = ClientConnection(
  1031. host="127.0.0.1",
  1032. port=ports["command"],
  1033. room="wohnzimmer",
  1034. alias="Test Echo",
  1035. mac_address="FF:FF:FF:FF:FF:03",
  1036. )
  1037. client.on_audio_out(on_audio)
  1038. await client.connect()
  1039. await asyncio.sleep(0.3)
  1040. # TTS-Request simulieren
  1041. await handle_tts_request("Hallo Welt", client.satellite_id)
  1042. await asyncio.sleep(0.2)
  1043. # Prüfungen
  1044. assert len(tts_completed_events) == 1
  1045. assert tts_completed_events[0]["text"] == "Hallo Welt"
  1046. assert tts_completed_events[0]["audio_size"] > 0
  1047. await client.disconnect()
  1048. await service.stop()
  1049. # ============================================================================
  1050. # Tests für Bidirektionale Kommunikation
  1051. # ============================================================================
  1052. class TestBidirectionalCommunication:
  1053. """Tests für bidirektionale Kommunikation zwischen Server und Client."""
  1054. @pytest.mark.asyncio
  1055. async def test_request_response_pattern(self, mock_server_application, temp_dir):
  1056. """Testet Request-Response Muster."""
  1057. mock_server_application.server_config.network.command_port = 0
  1058. mock_server_application.server_config.network.audio_input_port = 0
  1059. mock_server_application.server_config.network.audio_output_port = 0
  1060. mock_server_application.server_config.network.music_port = 0
  1061. mock_server_application.registration.enable_registration_mode()
  1062. service = NetworkService(mock_server_application)
  1063. await service.start()
  1064. ports = {"command": service._servers["command"].sockets[0].getsockname()[1]}
  1065. mock_server_application.server_config.network.command_port = ports["command"]
  1066. mock_server_application.server_config.network.audio_input_port = service._servers["audio_in"].sockets[0].getsockname()[1]
  1067. mock_server_application.server_config.network.audio_output_port = service._servers["audio_out"].sockets[0].getsockname()[1]
  1068. mock_server_application.server_config.network.music_port = service._servers["music"].sockets[0].getsockname()[1]
  1069. # Server-seitiger Handler für Wakeword
  1070. responses_sent = []
  1071. async def handle_wakeword(satellite, message):
  1072. """Antwortet auf WakewordDetected."""
  1073. if message.class_name == "WakewordDetected":
  1074. from trixy_core.network.cmd.wakeword import WakewordSelected
  1075. response = WakewordSelected(
  1076. conversation_id="conv-123",
  1077. start_streaming=True
  1078. )
  1079. await service.send_to_satellite(satellite.id, response)
  1080. responses_sent.append(satellite.id)
  1081. service.register_message_handler(handle_wakeword)
  1082. # Client-seitige Empfangsliste
  1083. received_responses = []
  1084. async def on_message(message):
  1085. received_responses.append(message.class_name)
  1086. client = ClientConnection(
  1087. host="127.0.0.1",
  1088. port=ports["command"],
  1089. room="wohnzimmer",
  1090. alias="Test Echo",
  1091. mac_address="FF:FF:FF:FF:FF:04",
  1092. )
  1093. client.on_message(on_message)
  1094. await client.connect()
  1095. await asyncio.sleep(0.1)
  1096. # Client sendet WakewordDetected
  1097. wakeword_msg = WakewordDetected(
  1098. satellite_id=client.satellite_id,
  1099. wakeword_model="custom",
  1100. confidence=0.95,
  1101. audio_level=0.7
  1102. )
  1103. await client.send_command(wakeword_msg)
  1104. # Warte auf Antwort
  1105. await asyncio.sleep(0.3)
  1106. # Prüfe dass Server geantwortet hat
  1107. assert len(responses_sent) >= 1
  1108. # Prüfe dass Client Antwort empfangen hat
  1109. assert "WakewordSelected" in received_responses
  1110. await client.disconnect()
  1111. await service.stop()
  1112. @pytest.mark.asyncio
  1113. async def test_conversation_flow(self, mock_server_application, temp_dir):
  1114. """Testet einen kompletten Konversationsablauf."""
  1115. mock_server_application.server_config.network.command_port = 0
  1116. mock_server_application.server_config.network.audio_input_port = 0
  1117. mock_server_application.server_config.network.audio_output_port = 0
  1118. mock_server_application.server_config.network.music_port = 0
  1119. mock_server_application.registration.enable_registration_mode()
  1120. service = NetworkService(mock_server_application)
  1121. await service.start()
  1122. ports = {"command": service._servers["command"].sockets[0].getsockname()[1]}
  1123. mock_server_application.server_config.network.command_port = ports["command"]
  1124. mock_server_application.server_config.network.audio_input_port = service._servers["audio_in"].sockets[0].getsockname()[1]
  1125. mock_server_application.server_config.network.audio_output_port = service._servers["audio_out"].sockets[0].getsockname()[1]
  1126. mock_server_application.server_config.network.music_port = service._servers["music"].sockets[0].getsockname()[1]
  1127. conversation_state = {"step": 0}
  1128. # Konversations-Handler
  1129. async def conversation_handler(satellite, message):
  1130. if message.class_name == "WakewordDetected":
  1131. conversation_state["step"] = 1
  1132. from trixy_core.network.cmd.wakeword import WakewordSelected
  1133. await service.send_to_satellite(satellite.id, WakewordSelected(
  1134. session_id="conv-test",
  1135. conversation_id="conv-test",
  1136. start_streaming=True
  1137. ))
  1138. elif message.class_name == "RecordingComplete":
  1139. conversation_state["step"] = 2
  1140. # Simuliere Transkription
  1141. from trixy_core.network.cmd.wakeword import TranscriptionResult
  1142. await service.send_to_satellite(satellite.id, TranscriptionResult(
  1143. session_id="conv-test",
  1144. text="Wie ist das Wetter?",
  1145. confidence=0.95
  1146. ))
  1147. # Simuliere Intent
  1148. from trixy_core.network.cmd.wakeword import IntentResult
  1149. await service.send_to_satellite(satellite.id, IntentResult(
  1150. session_id="conv-test",
  1151. intent="weather.query",
  1152. confidence=0.9,
  1153. entities={"location": "Berlin"}
  1154. ))
  1155. # Simuliere Antwort
  1156. from trixy_core.network.cmd.wakeword import AssistantResponse
  1157. await service.send_to_satellite(satellite.id, AssistantResponse(
  1158. session_id="conv-test",
  1159. text="In Berlin ist es sonnig bei 20 Grad.",
  1160. is_final=True
  1161. ))
  1162. conversation_state["step"] = 3
  1163. service.register_message_handler(conversation_handler)
  1164. # Client
  1165. client_messages = []
  1166. async def on_message(message):
  1167. client_messages.append(message.class_name)
  1168. client = ClientConnection(
  1169. host="127.0.0.1",
  1170. port=ports["command"],
  1171. room="wohnzimmer",
  1172. alias="Test Echo",
  1173. mac_address="FF:FF:FF:FF:FF:05",
  1174. )
  1175. client.on_message(on_message)
  1176. await client.connect()
  1177. await asyncio.sleep(0.1)
  1178. # 1. Wakeword senden
  1179. await client.send_command(WakewordDetected(
  1180. satellite_id=client.satellite_id,
  1181. wakeword_model="custom",
  1182. confidence=0.95,
  1183. audio_level=0.7
  1184. ))
  1185. await asyncio.sleep(0.2)
  1186. assert conversation_state["step"] >= 1
  1187. assert "WakewordSelected" in client_messages
  1188. # 2. Recording senden
  1189. from trixy_core.network.cmd.wakeword import RecordingComplete
  1190. await client.send_command(RecordingComplete(
  1191. session_id="conv-test",
  1192. audio_data="", # Hex-encodierte Audio-Daten (leer für Test)
  1193. duration_seconds=2.5,
  1194. ended_by="silence",
  1195. speech_detected=True,
  1196. peak_level=0.5
  1197. ))
  1198. await asyncio.sleep(0.3)
  1199. # Prüfe Konversationsablauf
  1200. assert conversation_state["step"] >= 2
  1201. assert "TranscriptionResult" in client_messages or "AssistantResponse" in client_messages
  1202. await client.disconnect()
  1203. await service.stop()