test_arbitration.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846
  1. # -*- coding: utf-8 -*-
  2. """
  3. Tests für das Arbitration-Modul.
  4. Testet Kandidaten, Zeitfenster, Strategien und den Arbitrator-Service.
  5. """
  6. import pytest
  7. import asyncio
  8. from datetime import datetime, timedelta
  9. from trixy_core.arbitration import (
  10. # Candidate
  11. ArbitrationCandidate,
  12. CandidateState,
  13. # Window
  14. ArbitrationWindow,
  15. WindowConfig,
  16. WindowState,
  17. # Strategies
  18. ArbitrationStrategy,
  19. LoudestStrategy,
  20. ConfidenceStrategy,
  21. CombinedStrategy,
  22. ProximityStrategy,
  23. # Arbitrator
  24. WakewordArbitrator,
  25. ArbitrationConfig,
  26. ArbitrationResult,
  27. )
  28. from trixy_core.arbitration.strategies.combined import CombinedWeights
  29. from trixy_core.arbitration.strategies.proximity import ProximityWeights, RoomRelevance
  30. # =============================================================================
  31. # ArbitrationCandidate Tests
  32. # =============================================================================
  33. class TestArbitrationCandidate:
  34. """Tests für ArbitrationCandidate."""
  35. def test_create_candidate(self):
  36. """Test Kandidaten-Erstellung."""
  37. candidate = ArbitrationCandidate(
  38. satellite_id="living_room",
  39. room_id="wohnzimmer",
  40. alias="Wohnzimmer",
  41. audio_level=0.85,
  42. confidence=0.92,
  43. )
  44. assert candidate.satellite_id == "living_room"
  45. assert candidate.room_id == "wohnzimmer"
  46. assert candidate.audio_level == 0.85
  47. assert candidate.confidence == 0.92
  48. assert candidate.state == CandidateState.PENDING
  49. def test_candidate_requires_satellite_id(self):
  50. """Test dass satellite_id erforderlich ist."""
  51. with pytest.raises(ValueError):
  52. ArbitrationCandidate(satellite_id="")
  53. def test_audio_level_normalization(self):
  54. """Test dass audio_level auf 0-1 normalisiert wird."""
  55. candidate = ArbitrationCandidate(
  56. satellite_id="test",
  57. audio_level=1.5, # Über 1.0
  58. )
  59. assert candidate.audio_level == 1.0
  60. candidate2 = ArbitrationCandidate(
  61. satellite_id="test",
  62. audio_level=-0.5, # Unter 0.0
  63. )
  64. assert candidate2.audio_level == 0.0
  65. def test_candidate_state_transitions(self):
  66. """Test Zustandsübergänge."""
  67. candidate = ArbitrationCandidate(satellite_id="test")
  68. assert candidate.is_pending
  69. assert not candidate.is_selected
  70. assert not candidate.is_rejected
  71. candidate.select()
  72. assert candidate.is_selected
  73. assert not candidate.is_pending
  74. candidate2 = ArbitrationCandidate(satellite_id="test2")
  75. candidate2.reject(reason="Anderer gewählt")
  76. assert candidate2.is_rejected
  77. assert candidate2.metadata.get("reject_reason") == "Anderer gewählt"
  78. def test_candidate_age(self):
  79. """Test Kandidaten-Alter."""
  80. candidate = ArbitrationCandidate(satellite_id="test")
  81. assert candidate.age_ms >= 0
  82. def test_candidate_to_dict(self):
  83. """Test Dictionary-Konvertierung."""
  84. candidate = ArbitrationCandidate(
  85. satellite_id="test",
  86. room_id="room1",
  87. audio_level=0.75,
  88. confidence=0.9,
  89. )
  90. data = candidate.to_dict()
  91. assert data["satellite_id"] == "test"
  92. assert data["room_id"] == "room1"
  93. assert data["audio_level"] == 0.75
  94. assert data["confidence"] == 0.9
  95. # =============================================================================
  96. # ArbitrationWindow Tests
  97. # =============================================================================
  98. class TestArbitrationWindow:
  99. """Tests für ArbitrationWindow."""
  100. @pytest.mark.asyncio
  101. async def test_window_open_close(self):
  102. """Test Fenster öffnen und schließen."""
  103. window = ArbitrationWindow()
  104. assert window.is_closed
  105. assert not window.is_open
  106. await window.open()
  107. assert window.is_open
  108. assert not window.is_closed
  109. candidates = await window.close(reason="test")
  110. assert len(candidates) == 0
  111. @pytest.mark.asyncio
  112. async def test_window_add_candidate(self):
  113. """Test Kandidaten hinzufügen."""
  114. window = ArbitrationWindow()
  115. await window.open()
  116. candidate = ArbitrationCandidate(
  117. satellite_id="test",
  118. audio_level=0.8,
  119. )
  120. success = await window.add_candidate(candidate)
  121. assert success
  122. assert window.candidate_count == 1
  123. # Nochmal denselben Satellite - sollte aktualisieren
  124. candidate2 = ArbitrationCandidate(
  125. satellite_id="test",
  126. audio_level=0.9, # Höher
  127. )
  128. success = await window.add_candidate(candidate2)
  129. assert success
  130. assert window.candidate_count == 1 # Immer noch 1
  131. # Der audio_level sollte aktualisiert sein
  132. stored = window.get_candidate("test")
  133. assert stored.audio_level == 0.9
  134. await window.reset()
  135. @pytest.mark.asyncio
  136. async def test_window_cannot_add_when_closed(self):
  137. """Test dass Kandidaten nicht bei geschlossenem Fenster hinzugefügt werden können."""
  138. window = ArbitrationWindow()
  139. candidate = ArbitrationCandidate(satellite_id="test")
  140. success = await window.add_candidate(candidate)
  141. assert not success
  142. assert window.candidate_count == 0
  143. @pytest.mark.asyncio
  144. async def test_window_max_candidates(self):
  145. """Test maximale Kandidaten-Anzahl."""
  146. config = WindowConfig(max_candidates=2)
  147. window = ArbitrationWindow(config=config)
  148. await window.open()
  149. await window.add_candidate(ArbitrationCandidate(satellite_id="s1"))
  150. await window.add_candidate(ArbitrationCandidate(satellite_id="s2"))
  151. # Dritter sollte abgelehnt werden
  152. success = await window.add_candidate(
  153. ArbitrationCandidate(satellite_id="s3")
  154. )
  155. assert not success
  156. assert window.candidate_count == 2
  157. await window.reset()
  158. @pytest.mark.asyncio
  159. async def test_window_has_candidate(self):
  160. """Test has_candidate Methode."""
  161. window = ArbitrationWindow()
  162. await window.open()
  163. await window.add_candidate(ArbitrationCandidate(satellite_id="test"))
  164. assert window.has_candidate("test")
  165. assert not window.has_candidate("other")
  166. await window.reset()
  167. @pytest.mark.asyncio
  168. async def test_window_duration(self):
  169. """Test Fenster-Dauer."""
  170. window = ArbitrationWindow()
  171. await window.open()
  172. # Kurz warten
  173. await asyncio.sleep(0.05)
  174. assert window.duration_ms >= 50
  175. assert window.remaining_ms < window.config.window_duration_ms
  176. await window.reset()
  177. @pytest.mark.asyncio
  178. async def test_window_auto_close(self):
  179. """Test automatisches Schließen nach Timeout."""
  180. callback_called = []
  181. async def on_complete(candidates):
  182. callback_called.append(len(candidates))
  183. config = WindowConfig(
  184. window_duration_ms=100,
  185. min_wait_ms=50,
  186. )
  187. window = ArbitrationWindow(config=config, on_complete=on_complete)
  188. await window.open()
  189. await window.add_candidate(ArbitrationCandidate(satellite_id="test"))
  190. # Warten auf auto-close
  191. await asyncio.sleep(0.15)
  192. assert len(callback_called) == 1
  193. assert callback_called[0] == 1
  194. @pytest.mark.asyncio
  195. async def test_window_reset(self):
  196. """Test Fenster zurücksetzen."""
  197. window = ArbitrationWindow()
  198. await window.open()
  199. await window.add_candidate(ArbitrationCandidate(satellite_id="test"))
  200. await window.reset()
  201. assert window.is_closed
  202. assert window.candidate_count == 0
  203. # =============================================================================
  204. # Strategy Tests
  205. # =============================================================================
  206. class TestLoudestStrategy:
  207. """Tests für LoudestStrategy."""
  208. def test_select_loudest(self):
  209. """Test dass lautester Kandidat gewählt wird."""
  210. strategy = LoudestStrategy()
  211. candidates = [
  212. ArbitrationCandidate(satellite_id="quiet", audio_level=0.5),
  213. ArbitrationCandidate(satellite_id="loud", audio_level=0.9),
  214. ArbitrationCandidate(satellite_id="medium", audio_level=0.7),
  215. ]
  216. winner = strategy.select(candidates)
  217. assert winner is not None
  218. assert winner.satellite_id == "loud"
  219. assert winner.score == 0.9
  220. def test_select_empty_list(self):
  221. """Test mit leerer Liste."""
  222. strategy = LoudestStrategy()
  223. winner = strategy.select([])
  224. assert winner is None
  225. def test_min_audio_level(self):
  226. """Test Minimum Audio-Level Filter."""
  227. strategy = LoudestStrategy(min_audio_level=0.5)
  228. candidates = [
  229. ArbitrationCandidate(satellite_id="too_quiet", audio_level=0.3),
  230. ArbitrationCandidate(satellite_id="ok", audio_level=0.6),
  231. ]
  232. winner = strategy.select(candidates)
  233. assert winner is not None
  234. assert winner.satellite_id == "ok"
  235. def test_all_below_minimum(self):
  236. """Test wenn alle unter Minimum."""
  237. strategy = LoudestStrategy(min_audio_level=0.8)
  238. candidates = [
  239. ArbitrationCandidate(satellite_id="s1", audio_level=0.5),
  240. ArbitrationCandidate(satellite_id="s2", audio_level=0.6),
  241. ]
  242. winner = strategy.select(candidates)
  243. assert winner is None
  244. def test_rank_candidates(self):
  245. """Test Kandidaten-Ranking."""
  246. strategy = LoudestStrategy()
  247. candidates = [
  248. ArbitrationCandidate(satellite_id="c", audio_level=0.3),
  249. ArbitrationCandidate(satellite_id="a", audio_level=0.9),
  250. ArbitrationCandidate(satellite_id="b", audio_level=0.6),
  251. ]
  252. ranked = strategy.rank(candidates)
  253. assert ranked[0].satellite_id == "a"
  254. assert ranked[1].satellite_id == "b"
  255. assert ranked[2].satellite_id == "c"
  256. class TestConfidenceStrategy:
  257. """Tests für ConfidenceStrategy."""
  258. def test_select_highest_confidence(self):
  259. """Test dass höchste Confidence gewählt wird."""
  260. strategy = ConfidenceStrategy()
  261. candidates = [
  262. ArbitrationCandidate(satellite_id="low", confidence=0.6),
  263. ArbitrationCandidate(satellite_id="high", confidence=0.95),
  264. ArbitrationCandidate(satellite_id="mid", confidence=0.8),
  265. ]
  266. winner = strategy.select(candidates)
  267. assert winner is not None
  268. assert winner.satellite_id == "high"
  269. def test_min_confidence(self):
  270. """Test Minimum Confidence Filter."""
  271. strategy = ConfidenceStrategy(min_confidence=0.7)
  272. candidates = [
  273. ArbitrationCandidate(satellite_id="too_low", confidence=0.5),
  274. ArbitrationCandidate(satellite_id="ok", confidence=0.8),
  275. ]
  276. winner = strategy.select(candidates)
  277. assert winner is not None
  278. assert winner.satellite_id == "ok"
  279. class TestCombinedStrategy:
  280. """Tests für CombinedStrategy."""
  281. def test_combined_score(self):
  282. """Test kombinierte Bewertung."""
  283. weights = CombinedWeights(
  284. audio_level=0.5,
  285. confidence=0.5,
  286. recency=0.0,
  287. )
  288. strategy = CombinedStrategy(weights=weights)
  289. candidates = [
  290. ArbitrationCandidate(
  291. satellite_id="balanced",
  292. audio_level=0.8,
  293. confidence=0.8,
  294. ),
  295. ArbitrationCandidate(
  296. satellite_id="loud_low_conf",
  297. audio_level=0.95,
  298. confidence=0.5,
  299. ),
  300. ]
  301. winner = strategy.select(candidates)
  302. assert winner is not None
  303. assert winner.satellite_id == "balanced"
  304. # balanced: 0.8*0.5 + 0.8*0.5 = 0.8
  305. # loud: 0.95*0.5 + 0.5*0.5 = 0.725
  306. def test_weights_normalization(self):
  307. """Test dass Gewichte normalisiert werden."""
  308. weights = CombinedWeights(
  309. audio_level=2.0,
  310. confidence=2.0,
  311. recency=1.0,
  312. )
  313. # Sollte auf 0.4, 0.4, 0.2 normalisiert werden
  314. assert abs(weights.audio_level - 0.4) < 0.01
  315. assert abs(weights.confidence - 0.4) < 0.01
  316. assert abs(weights.recency - 0.2) < 0.01
  317. def test_score_details(self):
  318. """Test dass Score-Details gespeichert werden."""
  319. strategy = CombinedStrategy()
  320. candidate = ArbitrationCandidate(
  321. satellite_id="test",
  322. audio_level=0.8,
  323. confidence=0.9,
  324. )
  325. score = strategy.calculate_score(candidate)
  326. assert "audio_level" in candidate.score_details
  327. assert "confidence" in candidate.score_details
  328. assert "audio_weighted" in candidate.score_details
  329. class TestProximityStrategy:
  330. """Tests für ProximityStrategy."""
  331. def test_last_active_bonus(self):
  332. """Test Bonus für letzten aktiven Raum."""
  333. room_relevance = RoomRelevance(last_active_bonus=0.3)
  334. strategy = ProximityStrategy(room_relevance=room_relevance)
  335. candidates = [
  336. ArbitrationCandidate(
  337. satellite_id="kitchen",
  338. room_id="kitchen",
  339. audio_level=0.8,
  340. confidence=0.8,
  341. ),
  342. ArbitrationCandidate(
  343. satellite_id="living",
  344. room_id="living_room",
  345. audio_level=0.75,
  346. confidence=0.8,
  347. ),
  348. ]
  349. context = {"last_active_room": "living_room"}
  350. winner = strategy.select(candidates, context)
  351. # Living room sollte wegen Bonus gewinnen trotz niedrigerem audio_level
  352. assert winner is not None
  353. # Der Bonus könnte ausreichen um das niedrigere audio_level auszugleichen
  354. assert "history_bonus" in winner.score_details
  355. def test_adjacent_rooms_bonus(self):
  356. """Test Bonus für benachbarte Räume."""
  357. room_relevance = RoomRelevance(
  358. adjacent_rooms={"living_room": ["hallway", "kitchen"]},
  359. adjacent_bonus=0.2,
  360. )
  361. strategy = ProximityStrategy(room_relevance=room_relevance)
  362. candidates = [
  363. ArbitrationCandidate(
  364. satellite_id="hallway",
  365. room_id="hallway",
  366. audio_level=0.7,
  367. confidence=0.8,
  368. ),
  369. ]
  370. context = {"last_active_room": "living_room"}
  371. strategy.calculate_score(candidates[0], context)
  372. # Hallway sollte room_relevance Bonus bekommen
  373. assert candidates[0].score_details["room_relevance"] > 0.5
  374. # =============================================================================
  375. # WakewordArbitrator Tests
  376. # =============================================================================
  377. class TestWakewordArbitrator:
  378. """Tests für WakewordArbitrator Service."""
  379. def test_create_arbitrator(self):
  380. """Test Arbitrator-Erstellung."""
  381. config = ArbitrationConfig(
  382. strategy="loudest",
  383. window_duration_ms=500,
  384. )
  385. arbitrator = WakewordArbitrator(config)
  386. assert arbitrator.config.strategy == "loudest"
  387. assert arbitrator.config.window_duration_ms == 500
  388. assert arbitrator.strategy.name == "loudest"
  389. def test_create_with_combined_strategy(self):
  390. """Test Arbitrator mit Combined-Strategie."""
  391. config = ArbitrationConfig(
  392. strategy="combined",
  393. audio_weight=0.7,
  394. confidence_weight=0.2,
  395. recency_weight=0.1,
  396. )
  397. arbitrator = WakewordArbitrator(config)
  398. assert arbitrator.strategy.name == "combined"
  399. def test_is_arbitrating_initial(self):
  400. """Test dass initial nicht arbitriert wird."""
  401. arbitrator = WakewordArbitrator()
  402. assert not arbitrator.is_arbitrating
  403. @pytest.mark.asyncio
  404. async def test_add_candidate_opens_window(self):
  405. """Test dass add_candidate Fenster öffnet."""
  406. # Verwende kurzes Fenster und warte nicht auf cancel
  407. config = ArbitrationConfig(
  408. window_duration_ms=50,
  409. min_wait_ms=10,
  410. auto_start_conversation=False,
  411. notify_rejected=False,
  412. )
  413. arbitrator = WakewordArbitrator(config)
  414. candidate = ArbitrationCandidate(
  415. satellite_id="test",
  416. audio_level=0.8,
  417. )
  418. await arbitrator.add_candidate(candidate)
  419. # Prüfe sofort (bevor Timer abläuft)
  420. assert arbitrator.is_arbitrating
  421. assert len(arbitrator.current_candidates) == 1
  422. # Warte auf natürliches Ende statt cancel
  423. await asyncio.sleep(0.1)
  424. @pytest.mark.asyncio
  425. async def test_multiple_candidates(self):
  426. """Test mehrere Kandidaten hinzufügen."""
  427. config = ArbitrationConfig(
  428. window_duration_ms=100,
  429. min_wait_ms=20,
  430. auto_start_conversation=False,
  431. notify_rejected=False,
  432. )
  433. arbitrator = WakewordArbitrator(config)
  434. await arbitrator.add_candidate(
  435. ArbitrationCandidate(satellite_id="s1", audio_level=0.7)
  436. )
  437. await arbitrator.add_candidate(
  438. ArbitrationCandidate(satellite_id="s2", audio_level=0.9)
  439. )
  440. await arbitrator.add_candidate(
  441. ArbitrationCandidate(satellite_id="s3", audio_level=0.6)
  442. )
  443. assert len(arbitrator.current_candidates) == 3
  444. # Warte auf natürliches Ende
  445. await asyncio.sleep(0.15)
  446. @pytest.mark.asyncio
  447. async def test_decision_callback(self):
  448. """Test Decision-Callback."""
  449. results = []
  450. async def on_decision(result: ArbitrationResult):
  451. results.append(result)
  452. config = ArbitrationConfig(
  453. window_duration_ms=100,
  454. min_wait_ms=50,
  455. auto_start_conversation=False,
  456. notify_rejected=False,
  457. )
  458. arbitrator = WakewordArbitrator(config)
  459. arbitrator.on_decision(on_decision)
  460. await arbitrator.add_candidate(
  461. ArbitrationCandidate(satellite_id="s1", audio_level=0.7, confidence=0.8)
  462. )
  463. await arbitrator.add_candidate(
  464. ArbitrationCandidate(satellite_id="s2", audio_level=0.9, confidence=0.85)
  465. )
  466. # Warten auf Entscheidung
  467. await asyncio.sleep(0.2)
  468. assert len(results) == 1
  469. assert results[0].success
  470. assert results[0].winner.satellite_id == "s2"
  471. assert len(results[0].rejected) == 1
  472. @pytest.mark.asyncio
  473. async def test_cancel_current(self):
  474. """Test aktuelle Arbitration abbrechen."""
  475. config = ArbitrationConfig(
  476. window_duration_ms=100,
  477. min_wait_ms=20,
  478. auto_start_conversation=False,
  479. notify_rejected=False,
  480. )
  481. arbitrator = WakewordArbitrator(config)
  482. await arbitrator.add_candidate(
  483. ArbitrationCandidate(satellite_id="test", audio_level=0.8)
  484. )
  485. assert arbitrator.is_arbitrating
  486. # Cancel sollte sofort funktionieren
  487. cancelled = await arbitrator.cancel_current()
  488. assert cancelled
  489. # Nach cancel sollte nicht mehr arbitrierend sein
  490. # (oder warten auf natürliches Ende)
  491. await asyncio.sleep(0.05)
  492. # is_arbitrating prüft ob window noch offen ist
  493. # Nach cancel ist es geschlossen
  494. def test_stats_initial(self):
  495. """Test initiale Statistiken."""
  496. arbitrator = WakewordArbitrator()
  497. stats = arbitrator.stats
  498. assert stats["total_arbitrations"] == 0
  499. assert stats["successful"] == 0
  500. def test_get_window_status_closed(self):
  501. """Test Fenster-Status wenn geschlossen."""
  502. arbitrator = WakewordArbitrator()
  503. status = arbitrator.get_window_status()
  504. assert status["state"] == "closed"
  505. assert status["candidates"] == 0
  506. @pytest.mark.asyncio
  507. async def test_get_window_status_open(self):
  508. """Test Fenster-Status wenn offen."""
  509. config = ArbitrationConfig(
  510. window_duration_ms=100,
  511. min_wait_ms=20,
  512. auto_start_conversation=False,
  513. notify_rejected=False,
  514. )
  515. arbitrator = WakewordArbitrator(config)
  516. await arbitrator.add_candidate(
  517. ArbitrationCandidate(satellite_id="test", audio_level=0.8)
  518. )
  519. status = arbitrator.get_window_status()
  520. assert status["state"] == "open"
  521. assert status["candidates"] == 1
  522. assert "duration_ms" in status
  523. assert "remaining_ms" in status
  524. assert "arbitration_id" in status
  525. # Warte auf natürliches Ende
  526. await asyncio.sleep(0.15)
  527. def test_change_strategy(self):
  528. """Test Strategie wechseln."""
  529. arbitrator = WakewordArbitrator()
  530. assert arbitrator.strategy.name == "combined" # Default
  531. arbitrator.strategy = LoudestStrategy()
  532. assert arbitrator.strategy.name == "loudest"
  533. class TestArbitrationResult:
  534. """Tests für ArbitrationResult."""
  535. def test_create_result(self):
  536. """Test Result-Erstellung."""
  537. winner = ArbitrationCandidate(satellite_id="winner", audio_level=0.9)
  538. rejected = [
  539. ArbitrationCandidate(satellite_id="loser", audio_level=0.5)
  540. ]
  541. result = ArbitrationResult(
  542. success=True,
  543. winner=winner,
  544. rejected=rejected,
  545. conversation_id="conv_123",
  546. arbitration_id="arb_456",
  547. candidate_count=2,
  548. strategy_used="loudest",
  549. )
  550. assert result.success
  551. assert result.winner.satellite_id == "winner"
  552. assert len(result.rejected) == 1
  553. assert result.conversation_id == "conv_123"
  554. def test_result_to_dict(self):
  555. """Test Dictionary-Konvertierung."""
  556. winner = ArbitrationCandidate(satellite_id="winner")
  557. result = ArbitrationResult(
  558. success=True,
  559. winner=winner,
  560. candidate_count=1,
  561. strategy_used="loudest",
  562. )
  563. data = result.to_dict()
  564. assert data["success"] is True
  565. assert data["winner"]["satellite_id"] == "winner"
  566. assert data["strategy_used"] == "loudest"
  567. # =============================================================================
  568. # Integration Tests
  569. # =============================================================================
  570. class TestArbitrationIntegration:
  571. """Integration-Tests für das Arbitration-System."""
  572. @pytest.mark.asyncio
  573. async def test_full_arbitration_flow(self):
  574. """Test kompletter Arbitration-Ablauf."""
  575. results = []
  576. async def capture_result(result):
  577. results.append(result)
  578. config = ArbitrationConfig(
  579. strategy="combined",
  580. window_duration_ms=100,
  581. min_wait_ms=20,
  582. audio_weight=0.6,
  583. confidence_weight=0.3,
  584. recency_weight=0.1,
  585. auto_start_conversation=False,
  586. notify_rejected=False,
  587. )
  588. arbitrator = WakewordArbitrator(config)
  589. arbitrator.on_decision(capture_result)
  590. # Simuliere Wakeword-Erkennungen von verschiedenen Satellites
  591. await arbitrator.add_candidate(ArbitrationCandidate(
  592. satellite_id="kitchen",
  593. room_id="kitchen",
  594. audio_level=0.7,
  595. confidence=0.85,
  596. ))
  597. await arbitrator.add_candidate(ArbitrationCandidate(
  598. satellite_id="living_room",
  599. room_id="living_room",
  600. audio_level=0.85,
  601. confidence=0.9,
  602. ))
  603. await arbitrator.add_candidate(ArbitrationCandidate(
  604. satellite_id="hallway",
  605. room_id="hallway",
  606. audio_level=0.6,
  607. confidence=0.8,
  608. ))
  609. # Warten auf Entscheidung
  610. await asyncio.sleep(0.2)
  611. assert len(results) == 1
  612. result = results[0]
  613. assert result.success
  614. assert result.winner is not None
  615. assert result.candidate_count == 3
  616. assert len(result.rejected) == 2
  617. # Living room sollte gewinnen (höchstes audio_level und confidence)
  618. assert result.winner.satellite_id == "living_room"
  619. @pytest.mark.asyncio
  620. async def test_single_candidate_wins(self):
  621. """Test dass einzelner Kandidat gewinnt."""
  622. results = []
  623. async def capture_result(result):
  624. results.append(result)
  625. config = ArbitrationConfig(
  626. window_duration_ms=80,
  627. min_wait_ms=20,
  628. auto_start_conversation=False,
  629. notify_rejected=False,
  630. )
  631. arbitrator = WakewordArbitrator(config)
  632. arbitrator.on_decision(capture_result)
  633. await arbitrator.add_candidate(ArbitrationCandidate(
  634. satellite_id="only_one",
  635. audio_level=0.8,
  636. confidence=0.9,
  637. ))
  638. await asyncio.sleep(0.15)
  639. assert len(results) == 1
  640. assert results[0].success
  641. assert results[0].winner.satellite_id == "only_one"
  642. assert len(results[0].rejected) == 0
  643. @pytest.mark.asyncio
  644. async def test_strategy_comparison(self):
  645. """Test verschiedene Strategien mit gleichen Kandidaten."""
  646. candidates = [
  647. ArbitrationCandidate(
  648. satellite_id="loud_low_conf",
  649. audio_level=0.95,
  650. confidence=0.6,
  651. ),
  652. ArbitrationCandidate(
  653. satellite_id="quiet_high_conf",
  654. audio_level=0.6,
  655. confidence=0.95,
  656. ),
  657. ]
  658. # Loudest sollte loud_low_conf wählen
  659. loudest = LoudestStrategy()
  660. winner1 = loudest.select(candidates.copy())
  661. assert winner1.satellite_id == "loud_low_conf"
  662. # Confidence sollte quiet_high_conf wählen
  663. confidence = ConfidenceStrategy()
  664. winner2 = confidence.select(candidates.copy())
  665. assert winner2.satellite_id == "quiet_high_conf"