# -*- coding: utf-8 -*- """ Tests fuer das Audio-Ducking Plugin. Prueft Lautstaerke-Reduktion bei Wakeword-Erkennung und Wiederherstellung nach Conversation-Ende. """ from __future__ import annotations from pathlib import Path from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch import pytest from plugins.audio_ducking.main import AudioDuckingPlugin # ============================================================================ # Fixtures # ============================================================================ @pytest.fixture def mock_volume_handler() -> MagicMock: """Erstellt einen Mock-VolumeHandler mit allen benoetigten Methoden.""" handler = MagicMock() handler.available = True handler.set_output_volume = AsyncMock() handler.restore_from_config = AsyncMock() return handler @pytest.fixture def mock_app_with_volume(mock_application, mock_volume_handler) -> MagicMock: """Erstellt eine Mock-Application mit VolumeHandler.""" mock_application._volume_handler = mock_volume_handler return mock_application @pytest.fixture def mock_app_without_volume(mock_application) -> MagicMock: """Erstellt eine Mock-Application ohne VolumeHandler.""" # Sicherstellen, dass kein _volume_handler existiert if hasattr(mock_application, "_volume_handler"): delattr(mock_application, "_volume_handler") return mock_application @pytest.fixture def plugin(mock_app_with_volume, mock_plugin_path) -> AudioDuckingPlugin: """Erstellt eine Plugin-Instanz mit VolumeHandler.""" config = {"duck_volume_percent": 20} return AudioDuckingPlugin(mock_app_with_volume, mock_plugin_path, config) @pytest.fixture def plugin_no_handler(mock_app_without_volume, mock_plugin_path) -> AudioDuckingPlugin: """Erstellt eine Plugin-Instanz ohne VolumeHandler.""" config = {"duck_volume_percent": 20} return AudioDuckingPlugin(mock_app_without_volume, mock_plugin_path, config) @pytest.fixture def plugin_custom_volume(mock_app_with_volume, mock_plugin_path) -> AudioDuckingPlugin: """Erstellt eine Plugin-Instanz mit benutzerdefiniertem duck_volume_percent.""" config = {"duck_volume_percent": 35} return AudioDuckingPlugin(mock_app_with_volume, mock_plugin_path, config) # ============================================================================ # Plugin-Erstellung und Attribute # ============================================================================ class TestPluginAttributes: """Prueft Plugin-Attribute und Initialisierung.""" def test_plugin_name(self, plugin: AudioDuckingPlugin) -> None: """NAME ist 'audio_ducking'.""" assert AudioDuckingPlugin.NAME == "audio_ducking" def test_plugin_version(self, plugin: AudioDuckingPlugin) -> None: """VERSION ist '2.0.0'.""" assert AudioDuckingPlugin.VERSION == "2.0.0" def test_plugin_description(self, plugin: AudioDuckingPlugin) -> None: """DESCRIPTION ist gesetzt.""" assert AudioDuckingPlugin.DESCRIPTION != "" def test_plugin_author(self, plugin: AudioDuckingPlugin) -> None: """AUTHOR ist 'Trixy'.""" assert AudioDuckingPlugin.AUTHOR == "Trixy" def test_initial_ducked_state(self, plugin: AudioDuckingPlugin) -> None: """_ducked ist nach Erstellung False.""" assert plugin._ducked is False def test_plugin_config(self, plugin: AudioDuckingPlugin) -> None: """Plugin hat die uebergebene Konfiguration.""" assert plugin.get_config_value("duck_volume_percent") == 20 # ============================================================================ # on_load / on_unload # ============================================================================ class TestLifecycle: """Prueft Plugin-Lebenszyklus (on_load, on_unload).""" @pytest.mark.asyncio async def test_on_load_does_not_crash(self, plugin: AudioDuckingPlugin) -> None: """on_load() laeuft ohne Fehler durch.""" await plugin.on_load() @pytest.mark.asyncio async def test_on_unload_restores_if_ducked( self, plugin: AudioDuckingPlugin, mock_volume_handler: MagicMock ) -> None: """on_unload() stellt Lautstaerke wieder her, wenn geduckt.""" # Erst ducken await plugin._duck_volume() assert plugin._ducked is True # Dann entladen await plugin.on_unload() mock_volume_handler.restore_from_config.assert_called_once_with("output") assert plugin._ducked is False @pytest.mark.asyncio async def test_on_unload_does_nothing_if_not_ducked( self, plugin: AudioDuckingPlugin, mock_volume_handler: MagicMock ) -> None: """on_unload() macht nichts, wenn nicht geduckt.""" assert plugin._ducked is False await plugin.on_unload() mock_volume_handler.restore_from_config.assert_not_called() # ============================================================================ # _duck_volume # ============================================================================ class TestDuckVolume: """Prueft die Lautstaerke-Reduktion.""" @pytest.mark.asyncio async def test_duck_volume_sets_volume( self, plugin: AudioDuckingPlugin, mock_volume_handler: MagicMock ) -> None: """_duck_volume() setzt Lautstaerke mit source='duck'.""" await plugin._duck_volume() mock_volume_handler.set_output_volume.assert_called_once_with(20, source="duck") @pytest.mark.asyncio async def test_duck_volume_sets_ducked_flag( self, plugin: AudioDuckingPlugin ) -> None: """_duck_volume() setzt _ducked auf True.""" await plugin._duck_volume() assert plugin._ducked is True @pytest.mark.asyncio async def test_duck_volume_idempotent( self, plugin: AudioDuckingPlugin, mock_volume_handler: MagicMock ) -> None: """Zweiter Aufruf von _duck_volume() aendert nichts.""" await plugin._duck_volume() await plugin._duck_volume() # Nur einmal aufgerufen mock_volume_handler.set_output_volume.assert_called_once() @pytest.mark.asyncio async def test_duck_volume_no_handler( self, plugin_no_handler: AudioDuckingPlugin ) -> None: """_duck_volume() ohne VolumeHandler setzt _ducked nicht.""" await plugin_no_handler._duck_volume() assert plugin_no_handler._ducked is False @pytest.mark.asyncio async def test_duck_volume_handler_not_available( self, plugin: AudioDuckingPlugin, mock_volume_handler: MagicMock ) -> None: """_duck_volume() mit unavailable Handler setzt _ducked nicht.""" mock_volume_handler.available = False await plugin._duck_volume() assert plugin._ducked is False mock_volume_handler.set_output_volume.assert_not_called() @pytest.mark.asyncio async def test_duck_volume_reads_config_value( self, plugin_custom_volume: AudioDuckingPlugin, mock_volume_handler: MagicMock ) -> None: """_duck_volume() liest duck_volume_percent aus der Config.""" await plugin_custom_volume._duck_volume() mock_volume_handler.set_output_volume.assert_called_once_with(35, source="duck") @pytest.mark.asyncio async def test_duck_volume_default_percent( self, mock_app_with_volume: MagicMock, mock_plugin_path: Path, mock_volume_handler: MagicMock, ) -> None: """Ohne Config-Wert wird Default 20 verwendet.""" # Plugin ohne duck_volume_percent in Config p = AudioDuckingPlugin(mock_app_with_volume, mock_plugin_path, config={}) await p._duck_volume() mock_volume_handler.set_output_volume.assert_called_once_with(20, source="duck") # ============================================================================ # _restore_volume # ============================================================================ class TestRestoreVolume: """Prueft die Lautstaerke-Wiederherstellung.""" @pytest.mark.asyncio async def test_restore_volume_calls_handler( self, plugin: AudioDuckingPlugin, mock_volume_handler: MagicMock ) -> None: """_restore_volume() ruft restore_from_config('output') auf.""" await plugin._duck_volume() await plugin._restore_volume() mock_volume_handler.restore_from_config.assert_called_once_with("output") @pytest.mark.asyncio async def test_restore_volume_clears_ducked_flag( self, plugin: AudioDuckingPlugin ) -> None: """_restore_volume() setzt _ducked auf False.""" await plugin._duck_volume() assert plugin._ducked is True await plugin._restore_volume() assert plugin._ducked is False @pytest.mark.asyncio async def test_restore_volume_idempotent( self, plugin: AudioDuckingPlugin, mock_volume_handler: MagicMock ) -> None: """Zweiter Aufruf von _restore_volume() aendert nichts.""" await plugin._duck_volume() await plugin._restore_volume() await plugin._restore_volume() mock_volume_handler.restore_from_config.assert_called_once() @pytest.mark.asyncio async def test_restore_volume_no_handler( self, plugin_no_handler: AudioDuckingPlugin ) -> None: """_restore_volume() ohne Handler setzt _ducked trotzdem auf False.""" plugin_no_handler._ducked = True await plugin_no_handler._restore_volume() assert plugin_no_handler._ducked is False @pytest.mark.asyncio async def test_restore_volume_handler_not_available( self, plugin: AudioDuckingPlugin, mock_volume_handler: MagicMock ) -> None: """_restore_volume() mit unavailable Handler ruft nichts auf, setzt aber Flag.""" plugin._ducked = True mock_volume_handler.available = False await plugin._restore_volume() assert plugin._ducked is False mock_volume_handler.restore_from_config.assert_not_called() # ============================================================================ # Event-Handler # ============================================================================ class TestEventHandlers: """Prueft die Event-Handler-Methoden.""" @pytest.mark.asyncio async def test_on_wakeword_ducks( self, plugin: AudioDuckingPlugin, mock_volume_handler: MagicMock ) -> None: """on_wakeword() reduziert die Lautstaerke.""" await plugin.on_wakeword("wakeword_detected", {}) assert plugin._ducked is True mock_volume_handler.set_output_volume.assert_called_once() @pytest.mark.asyncio async def test_on_manual_trigger_ducks( self, plugin: AudioDuckingPlugin, mock_volume_handler: MagicMock ) -> None: """on_manual_trigger() reduziert die Lautstaerke.""" await plugin.on_manual_trigger("wakeword_manual_trigger", {}) assert plugin._ducked is True @pytest.mark.asyncio async def test_on_recording_complete_restores( self, plugin: AudioDuckingPlugin, mock_volume_handler: MagicMock ) -> None: """on_recording_complete() stellt Lautstaerke wieder her.""" await plugin._duck_volume() await plugin.on_recording_complete("recording_complete", {}) assert plugin._ducked is False mock_volume_handler.restore_from_config.assert_called_once_with("output") @pytest.mark.asyncio async def test_on_conversation_started_ducks( self, plugin: AudioDuckingPlugin, mock_volume_handler: MagicMock ) -> None: """on_conversation_started() reduziert die Lautstaerke.""" await plugin.on_conversation_started("conversation_started", {}) assert plugin._ducked is True @pytest.mark.asyncio async def test_on_conversation_ended_restores( self, plugin: AudioDuckingPlugin, mock_volume_handler: MagicMock ) -> None: """on_conversation_ended() stellt Lautstaerke wieder her.""" await plugin._duck_volume() await plugin.on_conversation_ended("conversation_ended", {}) assert plugin._ducked is False # ============================================================================ # Komplexe Ablaeufe # ============================================================================ class TestComplexFlows: """Prueft vollstaendige Ablaeufe ueber mehrere Events.""" @pytest.mark.asyncio async def test_full_wakeword_flow( self, plugin: AudioDuckingPlugin, mock_volume_handler: MagicMock ) -> None: """Wakeword → Duck → Conversation ended → Restore.""" # Wakeword erkannt await plugin.on_wakeword("wakeword_detected", {}) assert plugin._ducked is True # Conversation endet await plugin.on_conversation_ended("conversation_ended", {}) assert plugin._ducked is False # Handler-Aufrufe pruefen mock_volume_handler.set_output_volume.assert_called_once_with(20, source="duck") mock_volume_handler.restore_from_config.assert_called_once_with("output") @pytest.mark.asyncio async def test_multiple_ducks_dont_stack( self, plugin: AudioDuckingPlugin, mock_volume_handler: MagicMock ) -> None: """Mehrfaches Ducken (Wakeword + Conversation) ruft Handler nur einmal auf.""" # Wakeword erkannt → duckt await plugin.on_wakeword("wakeword_detected", {}) # Conversation gestartet → bereits geduckt, kein zweiter Aufruf await plugin.on_conversation_started("conversation_started", {}) # set_output_volume nur einmal mock_volume_handler.set_output_volume.assert_called_once() # Ein Restore reicht await plugin.on_conversation_ended("conversation_ended", {}) assert plugin._ducked is False @pytest.mark.asyncio async def test_restore_after_unload( self, plugin: AudioDuckingPlugin, mock_volume_handler: MagicMock ) -> None: """Unload stellt Lautstaerke wieder her, wenn Plugin geduckt ist.""" await plugin.on_wakeword("wakeword_detected", {}) assert plugin._ducked is True # Plugin wird entladen → Restore await plugin.on_unload() assert plugin._ducked is False mock_volume_handler.restore_from_config.assert_called_once_with("output") @pytest.mark.asyncio async def test_duck_restore_duck_again( self, plugin: AudioDuckingPlugin, mock_volume_handler: MagicMock ) -> None: """Nach Restore kann erneut geduckt werden.""" # Erster Zyklus await plugin.on_wakeword("wakeword_detected", {}) await plugin.on_conversation_ended("conversation_ended", {}) assert plugin._ducked is False # Zweiter Zyklus await plugin.on_manual_trigger("wakeword_manual_trigger", {}) assert plugin._ducked is True await plugin.on_recording_complete("recording_complete", {}) assert plugin._ducked is False # Handler wurde zweimal aufgerufen assert mock_volume_handler.set_output_volume.call_count == 2 assert mock_volume_handler.restore_from_config.call_count == 2 @pytest.mark.asyncio async def test_restore_without_duck_is_noop( self, plugin: AudioDuckingPlugin, mock_volume_handler: MagicMock ) -> None: """Restore ohne vorheriges Duck macht nichts.""" await plugin.on_conversation_ended("conversation_ended", {}) assert plugin._ducked is False mock_volume_handler.restore_from_config.assert_not_called() # ============================================================================ # _get_volume_handler # ============================================================================ class TestGetVolumeHandler: """Prueft den Zugriff auf den VolumeHandler.""" def test_returns_handler_if_present( self, plugin: AudioDuckingPlugin, mock_volume_handler: MagicMock ) -> None: """_get_volume_handler() gibt den Handler zurueck.""" handler = plugin._get_volume_handler() assert handler is mock_volume_handler def test_returns_none_if_absent( self, plugin_no_handler: AudioDuckingPlugin ) -> None: """_get_volume_handler() gibt None zurueck ohne Handler.""" handler = plugin_no_handler._get_volume_handler() assert handler is None