test_audio_ducking.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429
  1. # -*- coding: utf-8 -*-
  2. """
  3. Tests fuer das Audio-Ducking Plugin.
  4. Prueft Lautstaerke-Reduktion bei Wakeword-Erkennung und
  5. Wiederherstellung nach Conversation-Ende.
  6. """
  7. from __future__ import annotations
  8. from pathlib import Path
  9. from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch
  10. import pytest
  11. from plugins.audio_ducking.main import AudioDuckingPlugin
  12. # ============================================================================
  13. # Fixtures
  14. # ============================================================================
  15. @pytest.fixture
  16. def mock_volume_handler() -> MagicMock:
  17. """Erstellt einen Mock-VolumeHandler mit allen benoetigten Methoden."""
  18. handler = MagicMock()
  19. handler.available = True
  20. handler.set_output_volume = AsyncMock()
  21. handler.restore_from_config = AsyncMock()
  22. return handler
  23. @pytest.fixture
  24. def mock_app_with_volume(mock_application, mock_volume_handler) -> MagicMock:
  25. """Erstellt eine Mock-Application mit VolumeHandler."""
  26. mock_application._volume_handler = mock_volume_handler
  27. return mock_application
  28. @pytest.fixture
  29. def mock_app_without_volume(mock_application) -> MagicMock:
  30. """Erstellt eine Mock-Application ohne VolumeHandler."""
  31. # Sicherstellen, dass kein _volume_handler existiert
  32. if hasattr(mock_application, "_volume_handler"):
  33. delattr(mock_application, "_volume_handler")
  34. return mock_application
  35. @pytest.fixture
  36. def plugin(mock_app_with_volume, mock_plugin_path) -> AudioDuckingPlugin:
  37. """Erstellt eine Plugin-Instanz mit VolumeHandler."""
  38. config = {"duck_volume_percent": 20}
  39. return AudioDuckingPlugin(mock_app_with_volume, mock_plugin_path, config)
  40. @pytest.fixture
  41. def plugin_no_handler(mock_app_without_volume, mock_plugin_path) -> AudioDuckingPlugin:
  42. """Erstellt eine Plugin-Instanz ohne VolumeHandler."""
  43. config = {"duck_volume_percent": 20}
  44. return AudioDuckingPlugin(mock_app_without_volume, mock_plugin_path, config)
  45. @pytest.fixture
  46. def plugin_custom_volume(mock_app_with_volume, mock_plugin_path) -> AudioDuckingPlugin:
  47. """Erstellt eine Plugin-Instanz mit benutzerdefiniertem duck_volume_percent."""
  48. config = {"duck_volume_percent": 35}
  49. return AudioDuckingPlugin(mock_app_with_volume, mock_plugin_path, config)
  50. # ============================================================================
  51. # Plugin-Erstellung und Attribute
  52. # ============================================================================
  53. class TestPluginAttributes:
  54. """Prueft Plugin-Attribute und Initialisierung."""
  55. def test_plugin_name(self, plugin: AudioDuckingPlugin) -> None:
  56. """NAME ist 'audio_ducking'."""
  57. assert AudioDuckingPlugin.NAME == "audio_ducking"
  58. def test_plugin_version(self, plugin: AudioDuckingPlugin) -> None:
  59. """VERSION ist '2.0.0'."""
  60. assert AudioDuckingPlugin.VERSION == "2.0.0"
  61. def test_plugin_description(self, plugin: AudioDuckingPlugin) -> None:
  62. """DESCRIPTION ist gesetzt."""
  63. assert AudioDuckingPlugin.DESCRIPTION != ""
  64. def test_plugin_author(self, plugin: AudioDuckingPlugin) -> None:
  65. """AUTHOR ist 'Trixy'."""
  66. assert AudioDuckingPlugin.AUTHOR == "Trixy"
  67. def test_initial_ducked_state(self, plugin: AudioDuckingPlugin) -> None:
  68. """_ducked ist nach Erstellung False."""
  69. assert plugin._ducked is False
  70. def test_plugin_config(self, plugin: AudioDuckingPlugin) -> None:
  71. """Plugin hat die uebergebene Konfiguration."""
  72. assert plugin.get_config_value("duck_volume_percent") == 20
  73. # ============================================================================
  74. # on_load / on_unload
  75. # ============================================================================
  76. class TestLifecycle:
  77. """Prueft Plugin-Lebenszyklus (on_load, on_unload)."""
  78. @pytest.mark.asyncio
  79. async def test_on_load_does_not_crash(self, plugin: AudioDuckingPlugin) -> None:
  80. """on_load() laeuft ohne Fehler durch."""
  81. await plugin.on_load()
  82. @pytest.mark.asyncio
  83. async def test_on_unload_restores_if_ducked(
  84. self, plugin: AudioDuckingPlugin, mock_volume_handler: MagicMock
  85. ) -> None:
  86. """on_unload() stellt Lautstaerke wieder her, wenn geduckt."""
  87. # Erst ducken
  88. await plugin._duck_volume()
  89. assert plugin._ducked is True
  90. # Dann entladen
  91. await plugin.on_unload()
  92. mock_volume_handler.restore_from_config.assert_called_once_with("output")
  93. assert plugin._ducked is False
  94. @pytest.mark.asyncio
  95. async def test_on_unload_does_nothing_if_not_ducked(
  96. self, plugin: AudioDuckingPlugin, mock_volume_handler: MagicMock
  97. ) -> None:
  98. """on_unload() macht nichts, wenn nicht geduckt."""
  99. assert plugin._ducked is False
  100. await plugin.on_unload()
  101. mock_volume_handler.restore_from_config.assert_not_called()
  102. # ============================================================================
  103. # _duck_volume
  104. # ============================================================================
  105. class TestDuckVolume:
  106. """Prueft die Lautstaerke-Reduktion."""
  107. @pytest.mark.asyncio
  108. async def test_duck_volume_sets_volume(
  109. self, plugin: AudioDuckingPlugin, mock_volume_handler: MagicMock
  110. ) -> None:
  111. """_duck_volume() setzt Lautstaerke mit source='duck'."""
  112. await plugin._duck_volume()
  113. mock_volume_handler.set_output_volume.assert_called_once_with(20, source="duck")
  114. @pytest.mark.asyncio
  115. async def test_duck_volume_sets_ducked_flag(
  116. self, plugin: AudioDuckingPlugin
  117. ) -> None:
  118. """_duck_volume() setzt _ducked auf True."""
  119. await plugin._duck_volume()
  120. assert plugin._ducked is True
  121. @pytest.mark.asyncio
  122. async def test_duck_volume_idempotent(
  123. self, plugin: AudioDuckingPlugin, mock_volume_handler: MagicMock
  124. ) -> None:
  125. """Zweiter Aufruf von _duck_volume() aendert nichts."""
  126. await plugin._duck_volume()
  127. await plugin._duck_volume()
  128. # Nur einmal aufgerufen
  129. mock_volume_handler.set_output_volume.assert_called_once()
  130. @pytest.mark.asyncio
  131. async def test_duck_volume_no_handler(
  132. self, plugin_no_handler: AudioDuckingPlugin
  133. ) -> None:
  134. """_duck_volume() ohne VolumeHandler setzt _ducked nicht."""
  135. await plugin_no_handler._duck_volume()
  136. assert plugin_no_handler._ducked is False
  137. @pytest.mark.asyncio
  138. async def test_duck_volume_handler_not_available(
  139. self, plugin: AudioDuckingPlugin, mock_volume_handler: MagicMock
  140. ) -> None:
  141. """_duck_volume() mit unavailable Handler setzt _ducked nicht."""
  142. mock_volume_handler.available = False
  143. await plugin._duck_volume()
  144. assert plugin._ducked is False
  145. mock_volume_handler.set_output_volume.assert_not_called()
  146. @pytest.mark.asyncio
  147. async def test_duck_volume_reads_config_value(
  148. self, plugin_custom_volume: AudioDuckingPlugin, mock_volume_handler: MagicMock
  149. ) -> None:
  150. """_duck_volume() liest duck_volume_percent aus der Config."""
  151. await plugin_custom_volume._duck_volume()
  152. mock_volume_handler.set_output_volume.assert_called_once_with(35, source="duck")
  153. @pytest.mark.asyncio
  154. async def test_duck_volume_default_percent(
  155. self, mock_app_with_volume: MagicMock, mock_plugin_path: Path,
  156. mock_volume_handler: MagicMock,
  157. ) -> None:
  158. """Ohne Config-Wert wird Default 20 verwendet."""
  159. # Plugin ohne duck_volume_percent in Config
  160. p = AudioDuckingPlugin(mock_app_with_volume, mock_plugin_path, config={})
  161. await p._duck_volume()
  162. mock_volume_handler.set_output_volume.assert_called_once_with(20, source="duck")
  163. # ============================================================================
  164. # _restore_volume
  165. # ============================================================================
  166. class TestRestoreVolume:
  167. """Prueft die Lautstaerke-Wiederherstellung."""
  168. @pytest.mark.asyncio
  169. async def test_restore_volume_calls_handler(
  170. self, plugin: AudioDuckingPlugin, mock_volume_handler: MagicMock
  171. ) -> None:
  172. """_restore_volume() ruft restore_from_config('output') auf."""
  173. await plugin._duck_volume()
  174. await plugin._restore_volume()
  175. mock_volume_handler.restore_from_config.assert_called_once_with("output")
  176. @pytest.mark.asyncio
  177. async def test_restore_volume_clears_ducked_flag(
  178. self, plugin: AudioDuckingPlugin
  179. ) -> None:
  180. """_restore_volume() setzt _ducked auf False."""
  181. await plugin._duck_volume()
  182. assert plugin._ducked is True
  183. await plugin._restore_volume()
  184. assert plugin._ducked is False
  185. @pytest.mark.asyncio
  186. async def test_restore_volume_idempotent(
  187. self, plugin: AudioDuckingPlugin, mock_volume_handler: MagicMock
  188. ) -> None:
  189. """Zweiter Aufruf von _restore_volume() aendert nichts."""
  190. await plugin._duck_volume()
  191. await plugin._restore_volume()
  192. await plugin._restore_volume()
  193. mock_volume_handler.restore_from_config.assert_called_once()
  194. @pytest.mark.asyncio
  195. async def test_restore_volume_no_handler(
  196. self, plugin_no_handler: AudioDuckingPlugin
  197. ) -> None:
  198. """_restore_volume() ohne Handler setzt _ducked trotzdem auf False."""
  199. plugin_no_handler._ducked = True
  200. await plugin_no_handler._restore_volume()
  201. assert plugin_no_handler._ducked is False
  202. @pytest.mark.asyncio
  203. async def test_restore_volume_handler_not_available(
  204. self, plugin: AudioDuckingPlugin, mock_volume_handler: MagicMock
  205. ) -> None:
  206. """_restore_volume() mit unavailable Handler ruft nichts auf, setzt aber Flag."""
  207. plugin._ducked = True
  208. mock_volume_handler.available = False
  209. await plugin._restore_volume()
  210. assert plugin._ducked is False
  211. mock_volume_handler.restore_from_config.assert_not_called()
  212. # ============================================================================
  213. # Event-Handler
  214. # ============================================================================
  215. class TestEventHandlers:
  216. """Prueft die Event-Handler-Methoden."""
  217. @pytest.mark.asyncio
  218. async def test_on_wakeword_ducks(
  219. self, plugin: AudioDuckingPlugin, mock_volume_handler: MagicMock
  220. ) -> None:
  221. """on_wakeword() reduziert die Lautstaerke."""
  222. await plugin.on_wakeword("wakeword_detected", {})
  223. assert plugin._ducked is True
  224. mock_volume_handler.set_output_volume.assert_called_once()
  225. @pytest.mark.asyncio
  226. async def test_on_manual_trigger_ducks(
  227. self, plugin: AudioDuckingPlugin, mock_volume_handler: MagicMock
  228. ) -> None:
  229. """on_manual_trigger() reduziert die Lautstaerke."""
  230. await plugin.on_manual_trigger("wakeword_manual_trigger", {})
  231. assert plugin._ducked is True
  232. @pytest.mark.asyncio
  233. async def test_on_recording_complete_restores(
  234. self, plugin: AudioDuckingPlugin, mock_volume_handler: MagicMock
  235. ) -> None:
  236. """on_recording_complete() stellt Lautstaerke wieder her."""
  237. await plugin._duck_volume()
  238. await plugin.on_recording_complete("recording_complete", {})
  239. assert plugin._ducked is False
  240. mock_volume_handler.restore_from_config.assert_called_once_with("output")
  241. @pytest.mark.asyncio
  242. async def test_on_conversation_started_ducks(
  243. self, plugin: AudioDuckingPlugin, mock_volume_handler: MagicMock
  244. ) -> None:
  245. """on_conversation_started() reduziert die Lautstaerke."""
  246. await plugin.on_conversation_started("conversation_started", {})
  247. assert plugin._ducked is True
  248. @pytest.mark.asyncio
  249. async def test_on_conversation_ended_restores(
  250. self, plugin: AudioDuckingPlugin, mock_volume_handler: MagicMock
  251. ) -> None:
  252. """on_conversation_ended() stellt Lautstaerke wieder her."""
  253. await plugin._duck_volume()
  254. await plugin.on_conversation_ended("conversation_ended", {})
  255. assert plugin._ducked is False
  256. # ============================================================================
  257. # Komplexe Ablaeufe
  258. # ============================================================================
  259. class TestComplexFlows:
  260. """Prueft vollstaendige Ablaeufe ueber mehrere Events."""
  261. @pytest.mark.asyncio
  262. async def test_full_wakeword_flow(
  263. self, plugin: AudioDuckingPlugin, mock_volume_handler: MagicMock
  264. ) -> None:
  265. """Wakeword → Duck → Conversation ended → Restore."""
  266. # Wakeword erkannt
  267. await plugin.on_wakeword("wakeword_detected", {})
  268. assert plugin._ducked is True
  269. # Conversation endet
  270. await plugin.on_conversation_ended("conversation_ended", {})
  271. assert plugin._ducked is False
  272. # Handler-Aufrufe pruefen
  273. mock_volume_handler.set_output_volume.assert_called_once_with(20, source="duck")
  274. mock_volume_handler.restore_from_config.assert_called_once_with("output")
  275. @pytest.mark.asyncio
  276. async def test_multiple_ducks_dont_stack(
  277. self, plugin: AudioDuckingPlugin, mock_volume_handler: MagicMock
  278. ) -> None:
  279. """Mehrfaches Ducken (Wakeword + Conversation) ruft Handler nur einmal auf."""
  280. # Wakeword erkannt → duckt
  281. await plugin.on_wakeword("wakeword_detected", {})
  282. # Conversation gestartet → bereits geduckt, kein zweiter Aufruf
  283. await plugin.on_conversation_started("conversation_started", {})
  284. # set_output_volume nur einmal
  285. mock_volume_handler.set_output_volume.assert_called_once()
  286. # Ein Restore reicht
  287. await plugin.on_conversation_ended("conversation_ended", {})
  288. assert plugin._ducked is False
  289. @pytest.mark.asyncio
  290. async def test_restore_after_unload(
  291. self, plugin: AudioDuckingPlugin, mock_volume_handler: MagicMock
  292. ) -> None:
  293. """Unload stellt Lautstaerke wieder her, wenn Plugin geduckt ist."""
  294. await plugin.on_wakeword("wakeword_detected", {})
  295. assert plugin._ducked is True
  296. # Plugin wird entladen → Restore
  297. await plugin.on_unload()
  298. assert plugin._ducked is False
  299. mock_volume_handler.restore_from_config.assert_called_once_with("output")
  300. @pytest.mark.asyncio
  301. async def test_duck_restore_duck_again(
  302. self, plugin: AudioDuckingPlugin, mock_volume_handler: MagicMock
  303. ) -> None:
  304. """Nach Restore kann erneut geduckt werden."""
  305. # Erster Zyklus
  306. await plugin.on_wakeword("wakeword_detected", {})
  307. await plugin.on_conversation_ended("conversation_ended", {})
  308. assert plugin._ducked is False
  309. # Zweiter Zyklus
  310. await plugin.on_manual_trigger("wakeword_manual_trigger", {})
  311. assert plugin._ducked is True
  312. await plugin.on_recording_complete("recording_complete", {})
  313. assert plugin._ducked is False
  314. # Handler wurde zweimal aufgerufen
  315. assert mock_volume_handler.set_output_volume.call_count == 2
  316. assert mock_volume_handler.restore_from_config.call_count == 2
  317. @pytest.mark.asyncio
  318. async def test_restore_without_duck_is_noop(
  319. self, plugin: AudioDuckingPlugin, mock_volume_handler: MagicMock
  320. ) -> None:
  321. """Restore ohne vorheriges Duck macht nichts."""
  322. await plugin.on_conversation_ended("conversation_ended", {})
  323. assert plugin._ducked is False
  324. mock_volume_handler.restore_from_config.assert_not_called()
  325. # ============================================================================
  326. # _get_volume_handler
  327. # ============================================================================
  328. class TestGetVolumeHandler:
  329. """Prueft den Zugriff auf den VolumeHandler."""
  330. def test_returns_handler_if_present(
  331. self, plugin: AudioDuckingPlugin, mock_volume_handler: MagicMock
  332. ) -> None:
  333. """_get_volume_handler() gibt den Handler zurueck."""
  334. handler = plugin._get_volume_handler()
  335. assert handler is mock_volume_handler
  336. def test_returns_none_if_absent(
  337. self, plugin_no_handler: AudioDuckingPlugin
  338. ) -> None:
  339. """_get_volume_handler() gibt None zurueck ohne Handler."""
  340. handler = plugin_no_handler._get_volume_handler()
  341. assert handler is None