# -*- coding: utf-8 -*- """ Tests für das Arbitration-Modul. Testet Kandidaten, Zeitfenster, Strategien und den Arbitrator-Service. """ import pytest import asyncio from datetime import datetime, timedelta from trixy_core.arbitration import ( # Candidate ArbitrationCandidate, CandidateState, # Window ArbitrationWindow, WindowConfig, WindowState, # Strategies ArbitrationStrategy, LoudestStrategy, ConfidenceStrategy, CombinedStrategy, ProximityStrategy, # Arbitrator WakewordArbitrator, ArbitrationConfig, ArbitrationResult, ) from trixy_core.arbitration.strategies.combined import CombinedWeights from trixy_core.arbitration.strategies.proximity import ProximityWeights, RoomRelevance # ============================================================================= # ArbitrationCandidate Tests # ============================================================================= class TestArbitrationCandidate: """Tests für ArbitrationCandidate.""" def test_create_candidate(self): """Test Kandidaten-Erstellung.""" candidate = ArbitrationCandidate( satellite_id="living_room", room_id="wohnzimmer", alias="Wohnzimmer", audio_level=0.85, confidence=0.92, ) assert candidate.satellite_id == "living_room" assert candidate.room_id == "wohnzimmer" assert candidate.audio_level == 0.85 assert candidate.confidence == 0.92 assert candidate.state == CandidateState.PENDING def test_candidate_requires_satellite_id(self): """Test dass satellite_id erforderlich ist.""" with pytest.raises(ValueError): ArbitrationCandidate(satellite_id="") def test_audio_level_normalization(self): """Test dass audio_level auf 0-1 normalisiert wird.""" candidate = ArbitrationCandidate( satellite_id="test", audio_level=1.5, # Über 1.0 ) assert candidate.audio_level == 1.0 candidate2 = ArbitrationCandidate( satellite_id="test", audio_level=-0.5, # Unter 0.0 ) assert candidate2.audio_level == 0.0 def test_candidate_state_transitions(self): """Test Zustandsübergänge.""" candidate = ArbitrationCandidate(satellite_id="test") assert candidate.is_pending assert not candidate.is_selected assert not candidate.is_rejected candidate.select() assert candidate.is_selected assert not candidate.is_pending candidate2 = ArbitrationCandidate(satellite_id="test2") candidate2.reject(reason="Anderer gewählt") assert candidate2.is_rejected assert candidate2.metadata.get("reject_reason") == "Anderer gewählt" def test_candidate_age(self): """Test Kandidaten-Alter.""" candidate = ArbitrationCandidate(satellite_id="test") assert candidate.age_ms >= 0 def test_candidate_to_dict(self): """Test Dictionary-Konvertierung.""" candidate = ArbitrationCandidate( satellite_id="test", room_id="room1", audio_level=0.75, confidence=0.9, ) data = candidate.to_dict() assert data["satellite_id"] == "test" assert data["room_id"] == "room1" assert data["audio_level"] == 0.75 assert data["confidence"] == 0.9 # ============================================================================= # ArbitrationWindow Tests # ============================================================================= class TestArbitrationWindow: """Tests für ArbitrationWindow.""" @pytest.mark.asyncio async def test_window_open_close(self): """Test Fenster öffnen und schließen.""" window = ArbitrationWindow() assert window.is_closed assert not window.is_open await window.open() assert window.is_open assert not window.is_closed candidates = await window.close(reason="test") assert len(candidates) == 0 @pytest.mark.asyncio async def test_window_add_candidate(self): """Test Kandidaten hinzufügen.""" window = ArbitrationWindow() await window.open() candidate = ArbitrationCandidate( satellite_id="test", audio_level=0.8, ) success = await window.add_candidate(candidate) assert success assert window.candidate_count == 1 # Nochmal denselben Satellite - sollte aktualisieren candidate2 = ArbitrationCandidate( satellite_id="test", audio_level=0.9, # Höher ) success = await window.add_candidate(candidate2) assert success assert window.candidate_count == 1 # Immer noch 1 # Der audio_level sollte aktualisiert sein stored = window.get_candidate("test") assert stored.audio_level == 0.9 await window.reset() @pytest.mark.asyncio async def test_window_cannot_add_when_closed(self): """Test dass Kandidaten nicht bei geschlossenem Fenster hinzugefügt werden können.""" window = ArbitrationWindow() candidate = ArbitrationCandidate(satellite_id="test") success = await window.add_candidate(candidate) assert not success assert window.candidate_count == 0 @pytest.mark.asyncio async def test_window_max_candidates(self): """Test maximale Kandidaten-Anzahl.""" config = WindowConfig(max_candidates=2) window = ArbitrationWindow(config=config) await window.open() await window.add_candidate(ArbitrationCandidate(satellite_id="s1")) await window.add_candidate(ArbitrationCandidate(satellite_id="s2")) # Dritter sollte abgelehnt werden success = await window.add_candidate( ArbitrationCandidate(satellite_id="s3") ) assert not success assert window.candidate_count == 2 await window.reset() @pytest.mark.asyncio async def test_window_has_candidate(self): """Test has_candidate Methode.""" window = ArbitrationWindow() await window.open() await window.add_candidate(ArbitrationCandidate(satellite_id="test")) assert window.has_candidate("test") assert not window.has_candidate("other") await window.reset() @pytest.mark.asyncio async def test_window_duration(self): """Test Fenster-Dauer.""" window = ArbitrationWindow() await window.open() # Kurz warten await asyncio.sleep(0.05) assert window.duration_ms >= 50 assert window.remaining_ms < window.config.window_duration_ms await window.reset() @pytest.mark.asyncio async def test_window_auto_close(self): """Test automatisches Schließen nach Timeout.""" callback_called = [] async def on_complete(candidates): callback_called.append(len(candidates)) config = WindowConfig( window_duration_ms=100, min_wait_ms=50, ) window = ArbitrationWindow(config=config, on_complete=on_complete) await window.open() await window.add_candidate(ArbitrationCandidate(satellite_id="test")) # Warten auf auto-close await asyncio.sleep(0.15) assert len(callback_called) == 1 assert callback_called[0] == 1 @pytest.mark.asyncio async def test_window_reset(self): """Test Fenster zurücksetzen.""" window = ArbitrationWindow() await window.open() await window.add_candidate(ArbitrationCandidate(satellite_id="test")) await window.reset() assert window.is_closed assert window.candidate_count == 0 # ============================================================================= # Strategy Tests # ============================================================================= class TestLoudestStrategy: """Tests für LoudestStrategy.""" def test_select_loudest(self): """Test dass lautester Kandidat gewählt wird.""" strategy = LoudestStrategy() candidates = [ ArbitrationCandidate(satellite_id="quiet", audio_level=0.5), ArbitrationCandidate(satellite_id="loud", audio_level=0.9), ArbitrationCandidate(satellite_id="medium", audio_level=0.7), ] winner = strategy.select(candidates) assert winner is not None assert winner.satellite_id == "loud" assert winner.score == 0.9 def test_select_empty_list(self): """Test mit leerer Liste.""" strategy = LoudestStrategy() winner = strategy.select([]) assert winner is None def test_min_audio_level(self): """Test Minimum Audio-Level Filter.""" strategy = LoudestStrategy(min_audio_level=0.5) candidates = [ ArbitrationCandidate(satellite_id="too_quiet", audio_level=0.3), ArbitrationCandidate(satellite_id="ok", audio_level=0.6), ] winner = strategy.select(candidates) assert winner is not None assert winner.satellite_id == "ok" def test_all_below_minimum(self): """Test wenn alle unter Minimum.""" strategy = LoudestStrategy(min_audio_level=0.8) candidates = [ ArbitrationCandidate(satellite_id="s1", audio_level=0.5), ArbitrationCandidate(satellite_id="s2", audio_level=0.6), ] winner = strategy.select(candidates) assert winner is None def test_rank_candidates(self): """Test Kandidaten-Ranking.""" strategy = LoudestStrategy() candidates = [ ArbitrationCandidate(satellite_id="c", audio_level=0.3), ArbitrationCandidate(satellite_id="a", audio_level=0.9), ArbitrationCandidate(satellite_id="b", audio_level=0.6), ] ranked = strategy.rank(candidates) assert ranked[0].satellite_id == "a" assert ranked[1].satellite_id == "b" assert ranked[2].satellite_id == "c" class TestConfidenceStrategy: """Tests für ConfidenceStrategy.""" def test_select_highest_confidence(self): """Test dass höchste Confidence gewählt wird.""" strategy = ConfidenceStrategy() candidates = [ ArbitrationCandidate(satellite_id="low", confidence=0.6), ArbitrationCandidate(satellite_id="high", confidence=0.95), ArbitrationCandidate(satellite_id="mid", confidence=0.8), ] winner = strategy.select(candidates) assert winner is not None assert winner.satellite_id == "high" def test_min_confidence(self): """Test Minimum Confidence Filter.""" strategy = ConfidenceStrategy(min_confidence=0.7) candidates = [ ArbitrationCandidate(satellite_id="too_low", confidence=0.5), ArbitrationCandidate(satellite_id="ok", confidence=0.8), ] winner = strategy.select(candidates) assert winner is not None assert winner.satellite_id == "ok" class TestCombinedStrategy: """Tests für CombinedStrategy.""" def test_combined_score(self): """Test kombinierte Bewertung.""" weights = CombinedWeights( audio_level=0.5, confidence=0.5, recency=0.0, ) strategy = CombinedStrategy(weights=weights) candidates = [ ArbitrationCandidate( satellite_id="balanced", audio_level=0.8, confidence=0.8, ), ArbitrationCandidate( satellite_id="loud_low_conf", audio_level=0.95, confidence=0.5, ), ] winner = strategy.select(candidates) assert winner is not None assert winner.satellite_id == "balanced" # balanced: 0.8*0.5 + 0.8*0.5 = 0.8 # loud: 0.95*0.5 + 0.5*0.5 = 0.725 def test_weights_normalization(self): """Test dass Gewichte normalisiert werden.""" weights = CombinedWeights( audio_level=2.0, confidence=2.0, recency=1.0, ) # Sollte auf 0.4, 0.4, 0.2 normalisiert werden assert abs(weights.audio_level - 0.4) < 0.01 assert abs(weights.confidence - 0.4) < 0.01 assert abs(weights.recency - 0.2) < 0.01 def test_score_details(self): """Test dass Score-Details gespeichert werden.""" strategy = CombinedStrategy() candidate = ArbitrationCandidate( satellite_id="test", audio_level=0.8, confidence=0.9, ) score = strategy.calculate_score(candidate) assert "audio_level" in candidate.score_details assert "confidence" in candidate.score_details assert "audio_weighted" in candidate.score_details class TestProximityStrategy: """Tests für ProximityStrategy.""" def test_last_active_bonus(self): """Test Bonus für letzten aktiven Raum.""" room_relevance = RoomRelevance(last_active_bonus=0.3) strategy = ProximityStrategy(room_relevance=room_relevance) candidates = [ ArbitrationCandidate( satellite_id="kitchen", room_id="kitchen", audio_level=0.8, confidence=0.8, ), ArbitrationCandidate( satellite_id="living", room_id="living_room", audio_level=0.75, confidence=0.8, ), ] context = {"last_active_room": "living_room"} winner = strategy.select(candidates, context) # Living room sollte wegen Bonus gewinnen trotz niedrigerem audio_level assert winner is not None # Der Bonus könnte ausreichen um das niedrigere audio_level auszugleichen assert "history_bonus" in winner.score_details def test_adjacent_rooms_bonus(self): """Test Bonus für benachbarte Räume.""" room_relevance = RoomRelevance( adjacent_rooms={"living_room": ["hallway", "kitchen"]}, adjacent_bonus=0.2, ) strategy = ProximityStrategy(room_relevance=room_relevance) candidates = [ ArbitrationCandidate( satellite_id="hallway", room_id="hallway", audio_level=0.7, confidence=0.8, ), ] context = {"last_active_room": "living_room"} strategy.calculate_score(candidates[0], context) # Hallway sollte room_relevance Bonus bekommen assert candidates[0].score_details["room_relevance"] > 0.5 # ============================================================================= # WakewordArbitrator Tests # ============================================================================= class TestWakewordArbitrator: """Tests für WakewordArbitrator Service.""" def test_create_arbitrator(self): """Test Arbitrator-Erstellung.""" config = ArbitrationConfig( strategy="loudest", window_duration_ms=500, ) arbitrator = WakewordArbitrator(config) assert arbitrator.config.strategy == "loudest" assert arbitrator.config.window_duration_ms == 500 assert arbitrator.strategy.name == "loudest" def test_create_with_combined_strategy(self): """Test Arbitrator mit Combined-Strategie.""" config = ArbitrationConfig( strategy="combined", audio_weight=0.7, confidence_weight=0.2, recency_weight=0.1, ) arbitrator = WakewordArbitrator(config) assert arbitrator.strategy.name == "combined" def test_is_arbitrating_initial(self): """Test dass initial nicht arbitriert wird.""" arbitrator = WakewordArbitrator() assert not arbitrator.is_arbitrating @pytest.mark.asyncio async def test_add_candidate_opens_window(self): """Test dass add_candidate Fenster öffnet.""" # Verwende kurzes Fenster und warte nicht auf cancel config = ArbitrationConfig( window_duration_ms=50, min_wait_ms=10, auto_start_conversation=False, notify_rejected=False, ) arbitrator = WakewordArbitrator(config) candidate = ArbitrationCandidate( satellite_id="test", audio_level=0.8, ) await arbitrator.add_candidate(candidate) # Prüfe sofort (bevor Timer abläuft) assert arbitrator.is_arbitrating assert len(arbitrator.current_candidates) == 1 # Warte auf natürliches Ende statt cancel await asyncio.sleep(0.1) @pytest.mark.asyncio async def test_multiple_candidates(self): """Test mehrere Kandidaten hinzufügen.""" config = ArbitrationConfig( window_duration_ms=100, min_wait_ms=20, auto_start_conversation=False, notify_rejected=False, ) arbitrator = WakewordArbitrator(config) await arbitrator.add_candidate( ArbitrationCandidate(satellite_id="s1", audio_level=0.7) ) await arbitrator.add_candidate( ArbitrationCandidate(satellite_id="s2", audio_level=0.9) ) await arbitrator.add_candidate( ArbitrationCandidate(satellite_id="s3", audio_level=0.6) ) assert len(arbitrator.current_candidates) == 3 # Warte auf natürliches Ende await asyncio.sleep(0.15) @pytest.mark.asyncio async def test_decision_callback(self): """Test Decision-Callback.""" results = [] async def on_decision(result: ArbitrationResult): results.append(result) config = ArbitrationConfig( window_duration_ms=100, min_wait_ms=50, auto_start_conversation=False, notify_rejected=False, ) arbitrator = WakewordArbitrator(config) arbitrator.on_decision(on_decision) await arbitrator.add_candidate( ArbitrationCandidate(satellite_id="s1", audio_level=0.7, confidence=0.8) ) await arbitrator.add_candidate( ArbitrationCandidate(satellite_id="s2", audio_level=0.9, confidence=0.85) ) # Warten auf Entscheidung await asyncio.sleep(0.2) assert len(results) == 1 assert results[0].success assert results[0].winner.satellite_id == "s2" assert len(results[0].rejected) == 1 @pytest.mark.asyncio async def test_cancel_current(self): """Test aktuelle Arbitration abbrechen.""" config = ArbitrationConfig( window_duration_ms=100, min_wait_ms=20, auto_start_conversation=False, notify_rejected=False, ) arbitrator = WakewordArbitrator(config) await arbitrator.add_candidate( ArbitrationCandidate(satellite_id="test", audio_level=0.8) ) assert arbitrator.is_arbitrating # Cancel sollte sofort funktionieren cancelled = await arbitrator.cancel_current() assert cancelled # Nach cancel sollte nicht mehr arbitrierend sein # (oder warten auf natürliches Ende) await asyncio.sleep(0.05) # is_arbitrating prüft ob window noch offen ist # Nach cancel ist es geschlossen def test_stats_initial(self): """Test initiale Statistiken.""" arbitrator = WakewordArbitrator() stats = arbitrator.stats assert stats["total_arbitrations"] == 0 assert stats["successful"] == 0 def test_get_window_status_closed(self): """Test Fenster-Status wenn geschlossen.""" arbitrator = WakewordArbitrator() status = arbitrator.get_window_status() assert status["state"] == "closed" assert status["candidates"] == 0 @pytest.mark.asyncio async def test_get_window_status_open(self): """Test Fenster-Status wenn offen.""" config = ArbitrationConfig( window_duration_ms=100, min_wait_ms=20, auto_start_conversation=False, notify_rejected=False, ) arbitrator = WakewordArbitrator(config) await arbitrator.add_candidate( ArbitrationCandidate(satellite_id="test", audio_level=0.8) ) status = arbitrator.get_window_status() assert status["state"] == "open" assert status["candidates"] == 1 assert "duration_ms" in status assert "remaining_ms" in status assert "arbitration_id" in status # Warte auf natürliches Ende await asyncio.sleep(0.15) def test_change_strategy(self): """Test Strategie wechseln.""" arbitrator = WakewordArbitrator() assert arbitrator.strategy.name == "combined" # Default arbitrator.strategy = LoudestStrategy() assert arbitrator.strategy.name == "loudest" class TestArbitrationResult: """Tests für ArbitrationResult.""" def test_create_result(self): """Test Result-Erstellung.""" winner = ArbitrationCandidate(satellite_id="winner", audio_level=0.9) rejected = [ ArbitrationCandidate(satellite_id="loser", audio_level=0.5) ] result = ArbitrationResult( success=True, winner=winner, rejected=rejected, conversation_id="conv_123", arbitration_id="arb_456", candidate_count=2, strategy_used="loudest", ) assert result.success assert result.winner.satellite_id == "winner" assert len(result.rejected) == 1 assert result.conversation_id == "conv_123" def test_result_to_dict(self): """Test Dictionary-Konvertierung.""" winner = ArbitrationCandidate(satellite_id="winner") result = ArbitrationResult( success=True, winner=winner, candidate_count=1, strategy_used="loudest", ) data = result.to_dict() assert data["success"] is True assert data["winner"]["satellite_id"] == "winner" assert data["strategy_used"] == "loudest" # ============================================================================= # Integration Tests # ============================================================================= class TestArbitrationIntegration: """Integration-Tests für das Arbitration-System.""" @pytest.mark.asyncio async def test_full_arbitration_flow(self): """Test kompletter Arbitration-Ablauf.""" results = [] async def capture_result(result): results.append(result) config = ArbitrationConfig( strategy="combined", window_duration_ms=100, min_wait_ms=20, audio_weight=0.6, confidence_weight=0.3, recency_weight=0.1, auto_start_conversation=False, notify_rejected=False, ) arbitrator = WakewordArbitrator(config) arbitrator.on_decision(capture_result) # Simuliere Wakeword-Erkennungen von verschiedenen Satellites await arbitrator.add_candidate(ArbitrationCandidate( satellite_id="kitchen", room_id="kitchen", audio_level=0.7, confidence=0.85, )) await arbitrator.add_candidate(ArbitrationCandidate( satellite_id="living_room", room_id="living_room", audio_level=0.85, confidence=0.9, )) await arbitrator.add_candidate(ArbitrationCandidate( satellite_id="hallway", room_id="hallway", audio_level=0.6, confidence=0.8, )) # Warten auf Entscheidung await asyncio.sleep(0.2) assert len(results) == 1 result = results[0] assert result.success assert result.winner is not None assert result.candidate_count == 3 assert len(result.rejected) == 2 # Living room sollte gewinnen (höchstes audio_level und confidence) assert result.winner.satellite_id == "living_room" @pytest.mark.asyncio async def test_single_candidate_wins(self): """Test dass einzelner Kandidat gewinnt.""" results = [] async def capture_result(result): results.append(result) config = ArbitrationConfig( window_duration_ms=80, min_wait_ms=20, auto_start_conversation=False, notify_rejected=False, ) arbitrator = WakewordArbitrator(config) arbitrator.on_decision(capture_result) await arbitrator.add_candidate(ArbitrationCandidate( satellite_id="only_one", audio_level=0.8, confidence=0.9, )) await asyncio.sleep(0.15) assert len(results) == 1 assert results[0].success assert results[0].winner.satellite_id == "only_one" assert len(results[0].rejected) == 0 @pytest.mark.asyncio async def test_strategy_comparison(self): """Test verschiedene Strategien mit gleichen Kandidaten.""" candidates = [ ArbitrationCandidate( satellite_id="loud_low_conf", audio_level=0.95, confidence=0.6, ), ArbitrationCandidate( satellite_id="quiet_high_conf", audio_level=0.6, confidence=0.95, ), ] # Loudest sollte loud_low_conf wählen loudest = LoudestStrategy() winner1 = loudest.select(candidates.copy()) assert winner1.satellite_id == "loud_low_conf" # Confidence sollte quiet_high_conf wählen confidence = ConfidenceStrategy() winner2 = confidence.select(candidates.copy()) assert winner2.satellite_id == "quiet_high_conf"