test_pulse_mic_mix.py 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722
  1. # -*- coding: utf-8 -*-
  2. """
  3. Tests fuer PulseMicrophoneMix.
  4. Testet:
  5. - PulseSource NamedTuple Konstruktion
  6. - _parse_sources() Parser mit realistischen pactl-Ausgaben
  7. - "Monitor of Sink: n/a" Sonderbehandlung
  8. - available() Lazy-Detection
  9. - list_sources() mit Monitor-Filterung
  10. - find_existing_null_sink() / _find_our_loopbacks()
  11. - setup() / teardown() Lifecycle
  12. - Default-Source-Verwaltung
  13. - Properties (monitor_source_name, active)
  14. """
  15. import asyncio
  16. import pytest
  17. from unittest.mock import AsyncMock, MagicMock, patch
  18. from trixy_core.audio.pulse_mic_mix import PulseSource, PulseMicrophoneMix
  19. # =============================================================================
  20. # Hilfskonstanten — Realistische pactl-Ausgaben
  21. # =============================================================================
  22. PACTL_LIST_SOURCES_MULTI = """\
  23. Source #0
  24. \tState: SUSPENDED
  25. \tName: alsa_output.pci-0000_00_1f.3.analog-stereo.monitor
  26. \tDescription: Monitor of Built-in Audio Analog Stereo
  27. \tDriver: PipeWireAudioImpl
  28. \tSample Specification: float32le 2ch 48000Hz
  29. \tMonitor of Sink: alsa_output.pci-0000_00_1f.3.analog-stereo
  30. Source #1
  31. \tState: IDLE
  32. \tName: alsa_input.usb-Blue_Yeti-00.analog-stereo
  33. \tDescription: Blue Yeti Analog Stereo
  34. \tDriver: PipeWireAudioImpl
  35. \tSample Specification: s16le 1ch 16000Hz
  36. \tMonitor of Sink: n/a
  37. Source #2
  38. \tState: RUNNING
  39. \tName: alsa_input.usb-Rode_NT_USB-00.analog-stereo
  40. \tDescription: Rode NT-USB Analog Stereo
  41. \tDriver: PipeWireAudioImpl
  42. \tSample Specification: s16le 1ch 48000Hz
  43. \tMonitor of Sink: n/a
  44. Source #128
  45. \tState: SUSPENDED
  46. \tName: trixy_mics_combined.monitor
  47. \tDescription: Monitor of Trixy_Multi_Mic_Mix
  48. \tDriver: PipeWireAudioImpl
  49. \tSample Specification: float32le 2ch 48000Hz
  50. \tMonitor of Sink: trixy_mics_combined
  51. """
  52. PACTL_LIST_SOURCES_NUR_MONITOR = """\
  53. Source #0
  54. \tState: SUSPENDED
  55. \tName: alsa_output.pci.monitor
  56. \tDescription: Monitor of Built-in Audio
  57. \tMonitor of Sink: alsa_output.pci
  58. """
  59. PACTL_LIST_SOURCES_EMPTY = ""
  60. PACTL_LIST_SOURCES_NA_VARIANTEN = """\
  61. Source #1
  62. \tName: mic1
  63. \tState: IDLE
  64. \tMonitor of Sink: n/a
  65. Source #2
  66. \tName: mic2
  67. \tState: IDLE
  68. \tMonitor of Sink: N/A
  69. Source #3
  70. \tName: mic3
  71. \tState: IDLE
  72. \tMonitor of Sink:
  73. """
  74. PACTL_LIST_SOURCES_PARTIAL = """\
  75. Source #99
  76. \tName: headset_mic
  77. \tState: RUNNING
  78. """
  79. PACTL_MODULES_SHORT_NULL_SINK = """\
  80. 0\tmodule-always-sink\t
  81. 1\tmodule-switch-on-port-available\t
  82. 55\tmodule-null-sink\tsink_name=trixy_mics_combined sink_properties=device.description=Trixy_Multi_Mic_Mix
  83. 60\tmodule-loopback\tsource=alsa_input.usb-Blue_Yeti-00.analog-stereo sink=trixy_mics_combined latency_msec=30
  84. 61\tmodule-loopback\tsource=alsa_input.usb-Rode_NT_USB-00.analog-stereo sink=trixy_mics_combined latency_msec=30
  85. 70\tmodule-loopback\tsource=some_other sink=other_sink latency_msec=30
  86. """
  87. PACTL_MODULES_SHORT_KEIN_NULL_SINK = """\
  88. 0\tmodule-always-sink\t
  89. 1\tmodule-switch-on-port-available\t
  90. 70\tmodule-loopback\tsource=some_other sink=other_sink latency_msec=30
  91. """
  92. # =============================================================================
  93. # Fixtures
  94. # =============================================================================
  95. @pytest.fixture
  96. def mix() -> PulseMicrophoneMix:
  97. """Erstellt einen PulseMicrophoneMix mit Default-Name."""
  98. return PulseMicrophoneMix()
  99. @pytest.fixture
  100. def mix_custom() -> PulseMicrophoneMix:
  101. """Erstellt einen PulseMicrophoneMix mit benutzerdefiniertem Namen."""
  102. return PulseMicrophoneMix(sink_name="mein_mic_mix")
  103. @pytest.fixture
  104. def mix_verfuegbar(mix: PulseMicrophoneMix) -> PulseMicrophoneMix:
  105. """PulseMicrophoneMix der als verfuegbar markiert ist."""
  106. mix._available = True
  107. return mix
  108. # =============================================================================
  109. # Tests — PulseSource NamedTuple
  110. # =============================================================================
  111. class TestPulseSource:
  112. """Tests fuer die PulseSource NamedTuple Struktur."""
  113. def test_erstellung_mit_allen_feldern(self) -> None:
  114. """PulseSource mit allen Feldern korrekt erstellen."""
  115. s = PulseSource(
  116. index=1,
  117. name="alsa_input.usb",
  118. description="Blue Yeti",
  119. state="RUNNING",
  120. monitor_of="",
  121. )
  122. assert s.index == 1
  123. assert s.name == "alsa_input.usb"
  124. assert s.description == "Blue Yeti"
  125. assert s.state == "RUNNING"
  126. assert s.monitor_of == ""
  127. def test_monitor_source_mit_sink_referenz(self) -> None:
  128. """Monitor-Source mit Sink-Referenz erkennen."""
  129. s = PulseSource(0, "output.monitor", "Monitor", "IDLE", "output_sink")
  130. assert s.monitor_of == "output_sink"
  131. def test_named_tuple_unveraenderlich(self) -> None:
  132. """PulseSource-Felder koennen nicht geaendert werden."""
  133. s = PulseSource(0, "test", "Test", "IDLE", "")
  134. with pytest.raises(AttributeError):
  135. s.name = "neu" # type: ignore[misc]
  136. # =============================================================================
  137. # Tests — _parse_sources (Statische Methode, kein Mock noetig)
  138. # =============================================================================
  139. class TestParseSources:
  140. """Tests fuer den pactl-list-sources Parser."""
  141. def test_leere_eingabe(self) -> None:
  142. """Leerer String ergibt leere Liste."""
  143. assert PulseMicrophoneMix._parse_sources("") == []
  144. def test_mehrere_sources_korrekt_parsen(self) -> None:
  145. """Mehrere Sources inklusive Monitors korrekt parsen."""
  146. sources = PulseMicrophoneMix._parse_sources(PACTL_LIST_SOURCES_MULTI)
  147. assert len(sources) == 4
  148. namen = [s.name for s in sources]
  149. assert "alsa_input.usb-Blue_Yeti-00.analog-stereo" in namen
  150. assert "alsa_input.usb-Rode_NT_USB-00.analog-stereo" in namen
  151. assert "alsa_output.pci-0000_00_1f.3.analog-stereo.monitor" in namen
  152. assert "trixy_mics_combined.monitor" in namen
  153. def test_monitor_of_sink_korrekt_gesetzt(self) -> None:
  154. """Monitor-Sources haben den Sink-Namen in monitor_of."""
  155. sources = PulseMicrophoneMix._parse_sources(PACTL_LIST_SOURCES_MULTI)
  156. monitor = next(s for s in sources if s.name == "alsa_output.pci-0000_00_1f.3.analog-stereo.monitor")
  157. assert monitor.monitor_of == "alsa_output.pci-0000_00_1f.3.analog-stereo"
  158. def test_monitor_of_sink_na_wird_zu_leerem_string(self) -> None:
  159. """'Monitor of Sink: n/a' muss als leerer String behandelt werden."""
  160. sources = PulseMicrophoneMix._parse_sources(PACTL_LIST_SOURCES_MULTI)
  161. yeti = next(s for s in sources if "Yeti" in s.name)
  162. assert yeti.monitor_of == ""
  163. def test_na_case_insensitive(self) -> None:
  164. """'N/A' (Grossbuchstaben) wird ebenfalls als leer behandelt."""
  165. sources = PulseMicrophoneMix._parse_sources(PACTL_LIST_SOURCES_NA_VARIANTEN)
  166. assert len(sources) == 3
  167. # Alle drei sollten monitor_of="" haben
  168. for s in sources:
  169. assert s.monitor_of == "", f"{s.name} hat monitor_of='{s.monitor_of}'"
  170. def test_monitor_of_sink_leer(self) -> None:
  171. """'Monitor of Sink:' mit leerem Wert -> leerer String."""
  172. sources = PulseMicrophoneMix._parse_sources(PACTL_LIST_SOURCES_NA_VARIANTEN)
  173. mic3 = next(s for s in sources if s.name == "mic3")
  174. assert mic3.monitor_of == ""
  175. def test_state_werte(self) -> None:
  176. """Verschiedene State-Werte korrekt parsen."""
  177. sources = PulseMicrophoneMix._parse_sources(PACTL_LIST_SOURCES_MULTI)
  178. states = {s.name: s.state for s in sources}
  179. assert states["alsa_input.usb-Blue_Yeti-00.analog-stereo"] == "IDLE"
  180. assert states["alsa_input.usb-Rode_NT_USB-00.analog-stereo"] == "RUNNING"
  181. def test_partieller_source_ohne_description(self) -> None:
  182. """Source ohne Description faellt auf Name zurueck."""
  183. sources = PulseMicrophoneMix._parse_sources(PACTL_LIST_SOURCES_PARTIAL)
  184. assert len(sources) == 1
  185. assert sources[0].description == "headset_mic" # Fallback auf Name
  186. def test_partieller_source_ohne_monitor_of(self) -> None:
  187. """Source ohne 'Monitor of Sink' hat leeren monitor_of."""
  188. sources = PulseMicrophoneMix._parse_sources(PACTL_LIST_SOURCES_PARTIAL)
  189. assert sources[0].monitor_of == ""
  190. def test_nur_whitespace(self) -> None:
  191. """Nur Leerzeichen/Tabs ergeben keine Sources."""
  192. assert PulseMicrophoneMix._parse_sources(" \n\t\n ") == []
  193. def test_ungueltiger_index(self) -> None:
  194. """Ungueltiger Index wird als -1 behandelt."""
  195. text = "Source #xyz\n\tName: defekt\n\tState: IDLE\n"
  196. sources = PulseMicrophoneMix._parse_sources(text)
  197. assert len(sources) == 1
  198. assert sources[0].index == -1
  199. def test_reihenfolge_bleibt_erhalten(self) -> None:
  200. """Sources werden in der Reihenfolge der Ausgabe zurueckgegeben."""
  201. sources = PulseMicrophoneMix._parse_sources(PACTL_LIST_SOURCES_MULTI)
  202. assert sources[0].index == 0
  203. assert sources[1].index == 1
  204. assert sources[2].index == 2
  205. assert sources[3].index == 128
  206. def test_nur_monitor_sources(self) -> None:
  207. """Ausgabe mit nur Monitor-Sources korrekt parsen."""
  208. sources = PulseMicrophoneMix._parse_sources(PACTL_LIST_SOURCES_NUR_MONITOR)
  209. assert len(sources) == 1
  210. assert sources[0].monitor_of == "alsa_output.pci"
  211. def test_sonderzeichen_in_description(self) -> None:
  212. """Sonderzeichen in der Beschreibung bleiben erhalten."""
  213. text = 'Source #5\n\tName: mic\n\tDescription: Headset (USB) & "Mikro"\n\tState: IDLE\n'
  214. sources = PulseMicrophoneMix._parse_sources(text)
  215. assert sources[0].description == 'Headset (USB) & "Mikro"'
  216. # =============================================================================
  217. # Tests — available() Lazy-Detection
  218. # =============================================================================
  219. class TestAvailable:
  220. """Tests fuer die Verfuegbarkeitserkennung."""
  221. @patch("trixy_core.audio.pulse_mic_mix.shutil.which", return_value=None)
  222. async def test_pactl_nicht_im_path(self, mock_which: MagicMock, mix: PulseMicrophoneMix) -> None:
  223. """Kein pactl im PATH -> nicht verfuegbar."""
  224. assert await mix.available() is False
  225. @patch("trixy_core.audio.pulse_mic_mix.shutil.which", return_value="/usr/bin/pactl")
  226. async def test_pactl_vorhanden_server_antwortet(self, mock_which: MagicMock, mix: PulseMicrophoneMix) -> None:
  227. """pactl + Server -> verfuegbar."""
  228. mix._run = AsyncMock(return_value=(0, "Server: PipeWire\n"))
  229. assert await mix.available() is True
  230. @patch("trixy_core.audio.pulse_mic_mix.shutil.which", return_value="/usr/bin/pactl")
  231. async def test_pactl_vorhanden_server_nicht_erreichbar(self, mock_which: MagicMock, mix: PulseMicrophoneMix) -> None:
  232. """pactl vorhanden, aber kein Server -> nicht verfuegbar."""
  233. mix._run = AsyncMock(return_value=(1, "Connection refused\n"))
  234. assert await mix.available() is False
  235. async def test_lazy_cache_true(self, mix: PulseMicrophoneMix) -> None:
  236. """Gecachter Wert True wird sofort zurueckgegeben."""
  237. mix._available = True
  238. assert await mix.available() is True
  239. async def test_lazy_cache_false(self, mix: PulseMicrophoneMix) -> None:
  240. """Gecachter Wert False wird sofort zurueckgegeben."""
  241. mix._available = False
  242. assert await mix.available() is False
  243. # =============================================================================
  244. # Tests — Properties
  245. # =============================================================================
  246. class TestProperties:
  247. """Tests fuer Properties und Initialisierung."""
  248. def test_default_sink_name(self, mix: PulseMicrophoneMix) -> None:
  249. """Standard Sink-Name ist 'trixy_mics_combined'."""
  250. assert mix.sink_name == "trixy_mics_combined"
  251. def test_custom_sink_name(self, mix_custom: PulseMicrophoneMix) -> None:
  252. """Benutzerdefinierter Sink-Name."""
  253. assert mix_custom.sink_name == "mein_mic_mix"
  254. def test_monitor_source_name(self, mix: PulseMicrophoneMix) -> None:
  255. """Monitor-Source-Name ist '{sink_name}.monitor'."""
  256. assert mix.monitor_source_name == "trixy_mics_combined.monitor"
  257. def test_monitor_source_name_custom(self, mix_custom: PulseMicrophoneMix) -> None:
  258. """Monitor-Source-Name mit benutzerdefiniertem Sink-Namen."""
  259. assert mix_custom.monitor_source_name == "mein_mic_mix.monitor"
  260. def test_active_ohne_null_sink(self, mix: PulseMicrophoneMix) -> None:
  261. """active ist False wenn kein Null-Sink-Modul geladen."""
  262. assert mix.active is False
  263. def test_active_mit_null_sink(self, mix: PulseMicrophoneMix) -> None:
  264. """active ist True wenn Null-Sink-Modul geladen."""
  265. mix._null_sink_module = 55
  266. assert mix.active is True
  267. def test_sources_ist_kopie(self, mix: PulseMicrophoneMix) -> None:
  268. """sources Property gibt eine Kopie zurueck."""
  269. mix._sources = ["a", "b"]
  270. kopie = mix.sources
  271. kopie.append("c")
  272. assert mix.sources == ["a", "b"]
  273. # =============================================================================
  274. # Tests — list_sources()
  275. # =============================================================================
  276. class TestListSources:
  277. """Tests fuer die Source-Auflistung mit Monitor-Filterung."""
  278. async def test_nicht_verfuegbar_ergibt_leere_liste(self, mix: PulseMicrophoneMix) -> None:
  279. """Nicht verfuegbar -> leere Liste."""
  280. mix._available = False
  281. result = await mix.list_sources()
  282. assert result == []
  283. async def test_pactl_fehler_ergibt_leere_liste(self, mix_verfuegbar: PulseMicrophoneMix) -> None:
  284. """pactl-Fehler -> leere Liste."""
  285. mix_verfuegbar._run = AsyncMock(return_value=(1, "error"))
  286. result = await mix_verfuegbar.list_sources()
  287. assert result == []
  288. async def test_ohne_monitors_nur_echte_mikrofone(self, mix_verfuegbar: PulseMicrophoneMix) -> None:
  289. """Standard: nur echte Mikrofone (keine Monitors)."""
  290. mix_verfuegbar._run = AsyncMock(return_value=(0, PACTL_LIST_SOURCES_MULTI))
  291. result = await mix_verfuegbar.list_sources(include_monitors=False)
  292. namen = [s.name for s in result]
  293. # Nur echte Mics (n/a monitor_of)
  294. assert "alsa_input.usb-Blue_Yeti-00.analog-stereo" in namen
  295. assert "alsa_input.usb-Rode_NT_USB-00.analog-stereo" in namen
  296. # Monitors sind weg
  297. assert "alsa_output.pci-0000_00_1f.3.analog-stereo.monitor" not in namen
  298. # Eigener Monitor ist ebenfalls weg
  299. assert "trixy_mics_combined.monitor" not in namen
  300. async def test_mit_monitors_alle_ausser_eigener(self, mix_verfuegbar: PulseMicrophoneMix) -> None:
  301. """include_monitors=True zeigt auch Monitor-Sources, aber nicht unseren eigenen."""
  302. mix_verfuegbar._run = AsyncMock(return_value=(0, PACTL_LIST_SOURCES_MULTI))
  303. result = await mix_verfuegbar.list_sources(include_monitors=True)
  304. namen = [s.name for s in result]
  305. # Fremder Monitor sichtbar
  306. assert "alsa_output.pci-0000_00_1f.3.analog-stereo.monitor" in namen
  307. # Eigener Monitor trotzdem nicht
  308. assert "trixy_mics_combined.monitor" not in namen
  309. async def test_eigener_monitor_mit_custom_name(self, mix_custom: PulseMicrophoneMix) -> None:
  310. """Eigener Monitor-Name haengt vom Sink-Namen ab."""
  311. mix_custom._available = True
  312. mix_custom._run = AsyncMock(return_value=(0, PACTL_LIST_SOURCES_MULTI))
  313. result = await mix_custom.list_sources(include_monitors=False)
  314. # "trixy_mics_combined.monitor" ist NICHT unser eigener (Name ist anders)
  315. # -> aber wegen monitor_of != "" auch gefiltert (wenn include_monitors=False)
  316. namen = [s.name for s in result]
  317. assert "trixy_mics_combined.monitor" not in namen
  318. # =============================================================================
  319. # Tests — find_existing_null_sink()
  320. # =============================================================================
  321. class TestFindExistingNullSink:
  322. """Tests fuer die Null-Sink-Modul-Suche."""
  323. async def test_null_sink_gefunden(self, mix_verfuegbar: PulseMicrophoneMix) -> None:
  324. """Bestehendes module-null-sink mit unserem Namen finden."""
  325. mix_verfuegbar._run = AsyncMock(return_value=(0, PACTL_MODULES_SHORT_NULL_SINK))
  326. result = await mix_verfuegbar.find_existing_null_sink()
  327. assert result == 55
  328. async def test_kein_null_sink_vorhanden(self, mix_verfuegbar: PulseMicrophoneMix) -> None:
  329. """Kein passendes Modul -> None."""
  330. mix_verfuegbar._run = AsyncMock(return_value=(0, PACTL_MODULES_SHORT_KEIN_NULL_SINK))
  331. result = await mix_verfuegbar.find_existing_null_sink()
  332. assert result is None
  333. async def test_pactl_fehler(self, mix_verfuegbar: PulseMicrophoneMix) -> None:
  334. """pactl-Fehler -> None."""
  335. mix_verfuegbar._run = AsyncMock(return_value=(1, "error"))
  336. result = await mix_verfuegbar.find_existing_null_sink()
  337. assert result is None
  338. async def test_anderer_sink_name_ignoriert(self, mix_custom: PulseMicrophoneMix) -> None:
  339. """Null-Sink mit anderem sink_name wird ignoriert."""
  340. mix_custom._available = True
  341. mix_custom._run = AsyncMock(return_value=(0, PACTL_MODULES_SHORT_NULL_SINK))
  342. result = await mix_custom.find_existing_null_sink()
  343. # sink_name=trixy_mics_combined != mein_mic_mix
  344. assert result is None
  345. # =============================================================================
  346. # Tests — _find_our_loopbacks()
  347. # =============================================================================
  348. class TestFindOurLoopbacks:
  349. """Tests fuer die Loopback-Modul-Suche."""
  350. async def test_loopbacks_gefunden(self, mix_verfuegbar: PulseMicrophoneMix) -> None:
  351. """Unsere Loopback-Module finden."""
  352. mix_verfuegbar._run = AsyncMock(return_value=(0, PACTL_MODULES_SHORT_NULL_SINK))
  353. result = await mix_verfuegbar._find_our_loopbacks()
  354. assert result == [60, 61]
  355. async def test_fremde_loopbacks_ignoriert(self, mix_verfuegbar: PulseMicrophoneMix) -> None:
  356. """Loopback mit anderem Sink wird nicht zurueckgegeben."""
  357. mix_verfuegbar._run = AsyncMock(return_value=(0, PACTL_MODULES_SHORT_NULL_SINK))
  358. result = await mix_verfuegbar._find_our_loopbacks()
  359. # Index 70 (sink=other_sink) darf nicht enthalten sein
  360. assert 70 not in result
  361. async def test_keine_loopbacks(self, mix_verfuegbar: PulseMicrophoneMix) -> None:
  362. """Keine Loopbacks vorhanden -> leere Liste."""
  363. mix_verfuegbar._run = AsyncMock(return_value=(0, PACTL_MODULES_SHORT_KEIN_NULL_SINK))
  364. result = await mix_verfuegbar._find_our_loopbacks()
  365. assert result == []
  366. async def test_pactl_fehler(self, mix_verfuegbar: PulseMicrophoneMix) -> None:
  367. """pactl-Fehler -> leere Liste."""
  368. mix_verfuegbar._run = AsyncMock(return_value=(1, "error"))
  369. result = await mix_verfuegbar._find_our_loopbacks()
  370. assert result == []
  371. # =============================================================================
  372. # Tests — setup()
  373. # =============================================================================
  374. class TestSetup:
  375. """Tests fuer den Mic-Mix-Aufbau."""
  376. async def test_setup_mit_zwei_sources(self, mix_verfuegbar: PulseMicrophoneMix) -> None:
  377. """Setup mit zwei Mikrofonen sollte erfolgreich sein."""
  378. mix_verfuegbar._run = AsyncMock(side_effect=[
  379. # teardown: restore_default_source -> nichts (kein previous)
  380. # teardown: _find_our_loopbacks
  381. (0, PACTL_MODULES_SHORT_KEIN_NULL_SINK),
  382. # teardown: find_existing_null_sink
  383. (0, PACTL_MODULES_SHORT_KEIN_NULL_SINK),
  384. # load-module null-sink
  385. (0, "55\n"),
  386. # load-module loopback 1
  387. (0, "60\n"),
  388. # load-module loopback 2
  389. (0, "61\n"),
  390. ])
  391. result = await mix_verfuegbar.setup(["mic_a", "mic_b"])
  392. assert result is True
  393. assert mix_verfuegbar._null_sink_module == 55
  394. assert mix_verfuegbar.sources == ["mic_a", "mic_b"]
  395. assert mix_verfuegbar._loopback_modules == [60, 61]
  396. async def test_setup_leere_sources(self, mix_verfuegbar: PulseMicrophoneMix) -> None:
  397. """Leere Source-Liste -> teardown + False."""
  398. mix_verfuegbar._run = AsyncMock(return_value=(0, ""))
  399. result = await mix_verfuegbar.setup([])
  400. assert result is False
  401. async def test_setup_eine_source(self, mix_verfuegbar: PulseMicrophoneMix) -> None:
  402. """Nur eine Source -> teardown + False (kein Mix noetig)."""
  403. mix_verfuegbar._run = AsyncMock(return_value=(0, ""))
  404. result = await mix_verfuegbar.setup(["nur_ein_mic"])
  405. assert result is False
  406. async def test_setup_nicht_verfuegbar(self, mix: PulseMicrophoneMix) -> None:
  407. """Setup ohne verfuegbares pactl -> False."""
  408. mix._available = False
  409. result = await mix.setup(["a", "b"])
  410. assert result is False
  411. async def test_setup_null_sink_fehler(self, mix_verfuegbar: PulseMicrophoneMix) -> None:
  412. """Null-Sink-Erstellung schlaegt fehl -> False."""
  413. mix_verfuegbar._run = AsyncMock(side_effect=[
  414. (0, PACTL_MODULES_SHORT_KEIN_NULL_SINK), # teardown: loopbacks
  415. (0, PACTL_MODULES_SHORT_KEIN_NULL_SINK), # teardown: null-sink
  416. (1, "Fehler!"), # load-module null-sink
  417. ])
  418. result = await mix_verfuegbar.setup(["a", "b"])
  419. assert result is False
  420. async def test_setup_loopback_teilweise_fehlgeschlagen(self, mix_verfuegbar: PulseMicrophoneMix) -> None:
  421. """Wenn ein Loopback fehlschlaegt, die anderen trotzdem nutzen."""
  422. mix_verfuegbar._run = AsyncMock(side_effect=[
  423. (0, PACTL_MODULES_SHORT_KEIN_NULL_SINK), # teardown: loopbacks
  424. (0, PACTL_MODULES_SHORT_KEIN_NULL_SINK), # teardown: null-sink
  425. (0, "55\n"), # load-module null-sink
  426. (1, "Fehler"), # loopback 1 fehlgeschlagen
  427. (0, "61\n"), # loopback 2 erfolgreich
  428. ])
  429. result = await mix_verfuegbar.setup(["mic_a", "mic_b"])
  430. assert result is True
  431. assert len(mix_verfuegbar._loopback_modules) == 1
  432. assert mix_verfuegbar.sources == ["mic_b"]
  433. async def test_setup_alle_loopbacks_fehlgeschlagen(self, mix_verfuegbar: PulseMicrophoneMix) -> None:
  434. """Wenn alle Loopbacks fehlschlagen -> teardown + False."""
  435. mix_verfuegbar._run = AsyncMock(side_effect=[
  436. (0, PACTL_MODULES_SHORT_KEIN_NULL_SINK), # teardown: loopbacks
  437. (0, PACTL_MODULES_SHORT_KEIN_NULL_SINK), # teardown: null-sink
  438. (0, "55\n"), # load-module null-sink
  439. (1, "Fehler"), # loopback 1 fehl
  440. (1, "Fehler"), # loopback 2 fehl
  441. # teardown nach Fehlschlag:
  442. (0, PACTL_MODULES_SHORT_KEIN_NULL_SINK), # loopbacks
  443. (0, PACTL_MODULES_SHORT_NULL_SINK), # find null-sink
  444. (0, ""), # unload null-sink
  445. ])
  446. result = await mix_verfuegbar.setup(["mic_a", "mic_b"])
  447. assert result is False
  448. async def test_setup_null_sink_fallback_find(self, mix_verfuegbar: PulseMicrophoneMix) -> None:
  449. """Wenn Null-Sink-Index nicht parsbar -> find_existing_null_sink nutzen."""
  450. mix_verfuegbar._run = AsyncMock(side_effect=[
  451. (0, PACTL_MODULES_SHORT_KEIN_NULL_SINK), # teardown: loopbacks
  452. (0, PACTL_MODULES_SHORT_KEIN_NULL_SINK), # teardown: null-sink
  453. (0, "nicht_eine_zahl\n"), # load-module (unparsbarer Output)
  454. (0, PACTL_MODULES_SHORT_NULL_SINK), # find_existing_null_sink
  455. (0, "60\n"), # loopback 1
  456. (0, "61\n"), # loopback 2
  457. ])
  458. result = await mix_verfuegbar.setup(["mic_a", "mic_b"])
  459. assert result is True
  460. assert mix_verfuegbar._null_sink_module == 55
  461. # =============================================================================
  462. # Tests — teardown()
  463. # =============================================================================
  464. class TestTeardown:
  465. """Tests fuer den Mic-Mix-Abbau."""
  466. async def test_teardown_mit_bekannten_modulen(self, mix_verfuegbar: PulseMicrophoneMix) -> None:
  467. """Teardown mit bekannten Modul-Indizes entlaedt alles."""
  468. mix_verfuegbar._null_sink_module = 55
  469. mix_verfuegbar._loopback_modules = [60, 61]
  470. mix_verfuegbar._sources = ["a", "b"]
  471. mix_verfuegbar._run = AsyncMock(return_value=(0, ""))
  472. await mix_verfuegbar.teardown()
  473. assert mix_verfuegbar._null_sink_module is None
  474. assert mix_verfuegbar._loopback_modules == []
  475. assert mix_verfuegbar.sources == []
  476. async def test_teardown_sucht_loopbacks_wenn_cache_leer(self, mix_verfuegbar: PulseMicrophoneMix) -> None:
  477. """Teardown ohne gecachte Loopbacks sucht per _find_our_loopbacks."""
  478. mix_verfuegbar._null_sink_module = 55
  479. mix_verfuegbar._loopback_modules = []
  480. mix_verfuegbar._run = AsyncMock(side_effect=[
  481. (0, PACTL_MODULES_SHORT_NULL_SINK), # _find_our_loopbacks
  482. (0, ""), # unload loopback 60
  483. (0, ""), # unload loopback 61
  484. (0, ""), # unload null-sink 55
  485. ])
  486. await mix_verfuegbar.teardown()
  487. async def test_teardown_sucht_null_sink_wenn_unbekannt(self, mix_verfuegbar: PulseMicrophoneMix) -> None:
  488. """Teardown ohne Null-Sink-Index sucht per find_existing_null_sink."""
  489. mix_verfuegbar._null_sink_module = None
  490. mix_verfuegbar._loopback_modules = []
  491. mix_verfuegbar._run = AsyncMock(side_effect=[
  492. (0, PACTL_MODULES_SHORT_KEIN_NULL_SINK), # _find_our_loopbacks
  493. (0, PACTL_MODULES_SHORT_NULL_SINK), # find_existing_null_sink
  494. (0, ""), # unload null-sink
  495. ])
  496. await mix_verfuegbar.teardown()
  497. async def test_teardown_nicht_verfuegbar(self, mix: PulseMicrophoneMix) -> None:
  498. """Teardown ohne verfuegbares pactl -> sofortiger Return."""
  499. mix._available = False
  500. mix._run = AsyncMock()
  501. await mix.teardown()
  502. mix._run.assert_not_called()
  503. async def test_teardown_stellt_default_source_wieder_her(self, mix_verfuegbar: PulseMicrophoneMix) -> None:
  504. """Teardown ruft restore_default_source vor dem Entladen auf."""
  505. mix_verfuegbar._null_sink_module = 55
  506. mix_verfuegbar._loopback_modules = []
  507. mix_verfuegbar._previous_default = "alter_mic"
  508. mix_verfuegbar._run = AsyncMock(side_effect=[
  509. # restore_default_source: get_default_source
  510. (0, "trixy_mics_combined.monitor\n"),
  511. # restore_default_source: set_default_source
  512. (0, ""),
  513. # _find_our_loopbacks
  514. (0, PACTL_MODULES_SHORT_KEIN_NULL_SINK),
  515. # unload null-sink
  516. (0, ""),
  517. ])
  518. await mix_verfuegbar.teardown()
  519. assert mix_verfuegbar._previous_default is None
  520. # =============================================================================
  521. # Tests — Default-Source-Verwaltung
  522. # =============================================================================
  523. class TestDefaultSource:
  524. """Tests fuer get/set/make/restore Default-Source."""
  525. async def test_get_default_source(self, mix_verfuegbar: PulseMicrophoneMix) -> None:
  526. """Default-Source-Name lesen."""
  527. mix_verfuegbar._run = AsyncMock(return_value=(0, "alsa_input.usb\n"))
  528. result = await mix_verfuegbar.get_default_source()
  529. assert result == "alsa_input.usb"
  530. async def test_get_default_source_nicht_verfuegbar(self, mix: PulseMicrophoneMix) -> None:
  531. """Nicht verfuegbar -> leerer String."""
  532. mix._available = False
  533. result = await mix.get_default_source()
  534. assert result == ""
  535. async def test_get_default_source_fehler(self, mix_verfuegbar: PulseMicrophoneMix) -> None:
  536. """pactl-Fehler -> leerer String."""
  537. mix_verfuegbar._run = AsyncMock(return_value=(1, ""))
  538. result = await mix_verfuegbar.get_default_source()
  539. assert result == ""
  540. async def test_get_default_source_leere_ausgabe(self, mix_verfuegbar: PulseMicrophoneMix) -> None:
  541. """Leere Ausgabe -> leerer String."""
  542. mix_verfuegbar._run = AsyncMock(return_value=(0, ""))
  543. result = await mix_verfuegbar.get_default_source()
  544. assert result == ""
  545. async def test_set_default_source_erfolg(self, mix_verfuegbar: PulseMicrophoneMix) -> None:
  546. """Default-Source setzen mit Erfolg."""
  547. mix_verfuegbar._run = AsyncMock(return_value=(0, ""))
  548. result = await mix_verfuegbar.set_default_source("my_mic")
  549. assert result is True
  550. async def test_set_default_source_fehler(self, mix_verfuegbar: PulseMicrophoneMix) -> None:
  551. """Default-Source setzen schlaegt fehl."""
  552. mix_verfuegbar._run = AsyncMock(return_value=(1, "error"))
  553. result = await mix_verfuegbar.set_default_source("my_mic")
  554. assert result is False
  555. async def test_make_default_ohne_null_sink(self, mix_verfuegbar: PulseMicrophoneMix) -> None:
  556. """make_default ohne Null-Sink-Modul -> False."""
  557. mix_verfuegbar._null_sink_module = None
  558. result = await mix_verfuegbar.make_default()
  559. assert result is False
  560. async def test_make_default_merkt_vorherigen(self, mix_verfuegbar: PulseMicrophoneMix) -> None:
  561. """make_default speichert den vorherigen Default-Source."""
  562. mix_verfuegbar._null_sink_module = 55
  563. mix_verfuegbar._run = AsyncMock(side_effect=[
  564. (0, "alter_mic\n"), # get_default_source
  565. (0, ""), # set_default_source
  566. ])
  567. result = await mix_verfuegbar.make_default()
  568. assert result is True
  569. assert mix_verfuegbar._previous_default == "alter_mic"
  570. async def test_make_default_ueberschreibt_nicht_bei_reload(self, mix_verfuegbar: PulseMicrophoneMix) -> None:
  571. """Wenn aktuell schon unser Monitor -> previous nicht ueberschreiben."""
  572. mix_verfuegbar._null_sink_module = 55
  573. mix_verfuegbar._previous_default = "alter_mic"
  574. mix_verfuegbar._run = AsyncMock(side_effect=[
  575. (0, "trixy_mics_combined.monitor\n"), # get_default_source -> schon unser
  576. (0, ""), # set_default_source
  577. ])
  578. await mix_verfuegbar.make_default()
  579. assert mix_verfuegbar._previous_default == "alter_mic"
  580. async def test_restore_default_source_erfolgreich(self, mix_verfuegbar: PulseMicrophoneMix) -> None:
  581. """Vorherigen Default-Source erfolgreich wiederherstellen."""
  582. mix_verfuegbar._previous_default = "alter_mic"
  583. mix_verfuegbar._run = AsyncMock(side_effect=[
  584. (0, "trixy_mics_combined.monitor\n"), # get_default_source
  585. (0, ""), # set_default_source
  586. ])
  587. await mix_verfuegbar.restore_default_source()
  588. assert mix_verfuegbar._previous_default is None
  589. async def test_restore_default_source_kein_vorheriger(self, mix_verfuegbar: PulseMicrophoneMix) -> None:
  590. """Kein vorheriger Default -> nichts tun."""
  591. mix_verfuegbar._previous_default = None
  592. mix_verfuegbar._run = AsyncMock()
  593. await mix_verfuegbar.restore_default_source()
  594. mix_verfuegbar._run.assert_not_called()
  595. async def test_restore_default_source_anderer_ist_default(self, mix_verfuegbar: PulseMicrophoneMix) -> None:
  596. """Wenn aktuell nicht unser Monitor -> nicht wiederherstellen."""
  597. mix_verfuegbar._previous_default = "alter_mic"
  598. mix_verfuegbar._run = AsyncMock(return_value=(0, "anderer_mic\n"))
  599. await mix_verfuegbar.restore_default_source()
  600. # previous_default wird trotzdem geloescht
  601. assert mix_verfuegbar._previous_default is None