test_pulse_combined.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647
  1. # -*- coding: utf-8 -*-
  2. """
  3. Tests fuer PulseCombinedSink.
  4. Testet:
  5. - PulseSink NamedTuple Konstruktion
  6. - _parse_sinks() Parser mit realistischen pactl-Ausgaben
  7. - available() Lazy-Detection (pactl + Server-Check)
  8. - list_sinks() mit Filterung des eigenen Combined-Sinks
  9. - find_existing_module() Modul-Suche
  10. - setup() / teardown() Lifecycle
  11. - Default-Sink-Verwaltung (make_default, restore_default)
  12. - Per-Sink Lautstaerke (set/get)
  13. - _run() Fehlerfaelle
  14. """
  15. import asyncio
  16. import pytest
  17. from unittest.mock import AsyncMock, MagicMock, patch
  18. from trixy_core.audio.pulse_combined import PulseSink, PulseCombinedSink
  19. # =============================================================================
  20. # Hilfskonstanten — Realistische pactl-Ausgaben
  21. # =============================================================================
  22. PACTL_LIST_SINKS_SINGLE = """\
  23. Sink #65
  24. \tState: RUNNING
  25. \tName: alsa_output.usb-Generic_USB_Audio-00.analog-stereo
  26. \tDescription: USB Audio CODEC Analog Stereo
  27. \tDriver: PipeWireAudioImpl
  28. \tSample Specification: s16le 2ch 44100Hz
  29. \tChannel Map: front-left,front-right
  30. \tOwner Module: 4294967295
  31. \tMute: no
  32. \tVolume: front-left: 42598 / 65% / -11.24 dB, front-right: 42598 / 65% / -11.24 dB
  33. \tBalance 0.00
  34. """
  35. PACTL_LIST_SINKS_MULTI = """\
  36. Sink #10
  37. \tState: IDLE
  38. \tName: alsa_output.pci-0000_00_1f.3.analog-stereo
  39. \tDescription: Built-in Audio Analog Stereo
  40. \tDriver: PipeWireAudioImpl
  41. \tSample Specification: float32le 2ch 48000Hz
  42. \tChannel Map: front-left,front-right
  43. \tMute: no
  44. \tVolume: front-left: 65536 / 100% / 0.00 dB, front-right: 65536 / 100% / 0.00 dB
  45. Sink #65
  46. \tState: RUNNING
  47. \tName: alsa_output.usb-Generic_USB_Audio-00.analog-stereo
  48. \tDescription: USB Audio CODEC Analog Stereo
  49. \tDriver: PipeWireAudioImpl
  50. \tSample Specification: s16le 2ch 44100Hz
  51. \tChannel Map: front-left,front-right
  52. \tMute: no
  53. \tVolume: front-left: 42598 / 65% / -11.24 dB, front-right: 42598 / 65% / -11.24 dB
  54. Sink #128
  55. \tState: SUSPENDED
  56. \tName: trixy_combined
  57. \tDescription: Trixy_Multi_Speaker
  58. \tDriver: PipeWireAudioImpl
  59. \tSample Specification: s16le 2ch 44100Hz
  60. \tChannel Map: front-left,front-right
  61. \tMute: no
  62. \tVolume: front-left: 65536 / 100% / 0.00 dB, front-right: 65536 / 100% / 0.00 dB
  63. """
  64. PACTL_LIST_SINKS_EMPTY = ""
  65. PACTL_LIST_SINKS_PARTIAL = """\
  66. Sink #99
  67. \tName: partial_sink
  68. \tState: IDLE
  69. """
  70. PACTL_MODULES_SHORT = """\
  71. 0\tmodule-always-sink\t
  72. 1\tmodule-switch-on-port-available\t
  73. 42\tmodule-combine-sink\tsink_name=trixy_combined slaves=alsa_output.pci-0000_00_1f.3.analog-stereo,alsa_output.usb-Generic_USB_Audio-00.analog-stereo
  74. 50\tmodule-null-sink\tsink_name=something_else
  75. """
  76. PACTL_MODULES_SHORT_NO_COMBINED = """\
  77. 0\tmodule-always-sink\t
  78. 1\tmodule-switch-on-port-available\t
  79. 50\tmodule-null-sink\tsink_name=something_else
  80. """
  81. PACTL_VOLUME_OUTPUT = """\
  82. Volume: front-left: 52428 / 80% / -5.81 dB, front-right: 52428 / 80% / -5.81 dB
  83. """
  84. PACTL_VOLUME_OUTPUT_ASYMMETRIC = """\
  85. Volume: front-left: 32768 / 50% / -18.06 dB, front-right: 65536 / 100% / 0.00 dB
  86. """
  87. # =============================================================================
  88. # Fixtures
  89. # =============================================================================
  90. @pytest.fixture
  91. def sink() -> PulseCombinedSink:
  92. """Erstellt einen PulseCombinedSink mit Default-Name."""
  93. return PulseCombinedSink()
  94. @pytest.fixture
  95. def sink_custom() -> PulseCombinedSink:
  96. """Erstellt einen PulseCombinedSink mit benutzerdefiniertem Namen."""
  97. return PulseCombinedSink(sink_name="mein_sink")
  98. @pytest.fixture
  99. def sink_verfuegbar(sink: PulseCombinedSink) -> PulseCombinedSink:
  100. """PulseCombinedSink der als verfuegbar markiert ist."""
  101. sink._available = True
  102. return sink
  103. # =============================================================================
  104. # Tests — PulseSink NamedTuple
  105. # =============================================================================
  106. class TestPulseSink:
  107. """Tests fuer die PulseSink NamedTuple Struktur."""
  108. def test_erstellung_mit_allen_feldern(self) -> None:
  109. """PulseSink mit allen Feldern korrekt erstellen."""
  110. s = PulseSink(
  111. index=10,
  112. name="alsa_output.pci",
  113. description="Built-in Audio",
  114. sample_spec="s16le 2ch 48000Hz",
  115. state="RUNNING",
  116. )
  117. assert s.index == 10
  118. assert s.name == "alsa_output.pci"
  119. assert s.description == "Built-in Audio"
  120. assert s.sample_spec == "s16le 2ch 48000Hz"
  121. assert s.state == "RUNNING"
  122. def test_named_tuple_unveraenderlich(self) -> None:
  123. """PulseSink-Felder koennen nicht geaendert werden."""
  124. s = PulseSink(0, "test", "Test", "s16le", "IDLE")
  125. with pytest.raises(AttributeError):
  126. s.name = "neu" # type: ignore[misc]
  127. def test_tuple_vergleich(self) -> None:
  128. """Zwei PulseSinks mit gleichen Werten sind gleich."""
  129. a = PulseSink(1, "a", "A", "spec", "RUNNING")
  130. b = PulseSink(1, "a", "A", "spec", "RUNNING")
  131. assert a == b
  132. # =============================================================================
  133. # Tests — _parse_sinks (Statische Methode, kein Mock noetig)
  134. # =============================================================================
  135. class TestParseSinks:
  136. """Tests fuer den pactl-list-sinks Parser."""
  137. def test_leere_eingabe(self) -> None:
  138. """Leerer String ergibt leere Liste."""
  139. assert PulseCombinedSink._parse_sinks("") == []
  140. def test_einzelner_sink(self) -> None:
  141. """Einzelnen Sink korrekt parsen."""
  142. sinks = PulseCombinedSink._parse_sinks(PACTL_LIST_SINKS_SINGLE)
  143. assert len(sinks) == 1
  144. s = sinks[0]
  145. assert s.index == 65
  146. assert s.name == "alsa_output.usb-Generic_USB_Audio-00.analog-stereo"
  147. assert s.description == "USB Audio CODEC Analog Stereo"
  148. assert s.sample_spec == "s16le 2ch 44100Hz"
  149. assert s.state == "RUNNING"
  150. def test_mehrere_sinks(self) -> None:
  151. """Mehrere Sinks inklusive Combined-Sink parsen."""
  152. sinks = PulseCombinedSink._parse_sinks(PACTL_LIST_SINKS_MULTI)
  153. assert len(sinks) == 3
  154. namen = [s.name for s in sinks]
  155. assert "alsa_output.pci-0000_00_1f.3.analog-stereo" in namen
  156. assert "alsa_output.usb-Generic_USB_Audio-00.analog-stereo" in namen
  157. assert "trixy_combined" in namen
  158. def test_sink_status_werte(self) -> None:
  159. """Verschiedene State-Werte korrekt parsen."""
  160. sinks = PulseCombinedSink._parse_sinks(PACTL_LIST_SINKS_MULTI)
  161. states = {s.name: s.state for s in sinks}
  162. assert states["alsa_output.pci-0000_00_1f.3.analog-stereo"] == "IDLE"
  163. assert states["alsa_output.usb-Generic_USB_Audio-00.analog-stereo"] == "RUNNING"
  164. assert states["trixy_combined"] == "SUSPENDED"
  165. def test_sample_spec_float32(self) -> None:
  166. """float32le Sample-Spec korrekt parsen."""
  167. sinks = PulseCombinedSink._parse_sinks(PACTL_LIST_SINKS_MULTI)
  168. builtin = next(s for s in sinks if "pci" in s.name)
  169. assert builtin.sample_spec == "float32le 2ch 48000Hz"
  170. def test_partieller_sink_ohne_description(self) -> None:
  171. """Sink ohne Description faellt auf Name zurueck."""
  172. sinks = PulseCombinedSink._parse_sinks(PACTL_LIST_SINKS_PARTIAL)
  173. assert len(sinks) == 1
  174. assert sinks[0].name == "partial_sink"
  175. # Description fehlt -> Fallback auf Name
  176. assert sinks[0].description == "partial_sink"
  177. def test_partieller_sink_ohne_sample_spec(self) -> None:
  178. """Sink ohne Sample Specification ergibt leeren String."""
  179. sinks = PulseCombinedSink._parse_sinks(PACTL_LIST_SINKS_PARTIAL)
  180. assert sinks[0].sample_spec == ""
  181. def test_nur_whitespace(self) -> None:
  182. """Nur Leerzeichen/Tabs ergeben keine Sinks."""
  183. assert PulseCombinedSink._parse_sinks(" \n\t\n ") == []
  184. def test_ungueltiger_index(self) -> None:
  185. """Ungueltiger Index (keine Zahl) wird abgefangen."""
  186. text = "Sink #abc\n\tName: test\n\tState: IDLE\n"
  187. sinks = PulseCombinedSink._parse_sinks(text)
  188. # Index-Parse schlaegt fehl, current bleibt ohne "index" -> Fallback -1
  189. assert len(sinks) == 1
  190. assert sinks[0].index == -1
  191. def test_mehrere_sinks_reihenfolge(self) -> None:
  192. """Sinks werden in der Reihenfolge der Ausgabe zurueckgegeben."""
  193. sinks = PulseCombinedSink._parse_sinks(PACTL_LIST_SINKS_MULTI)
  194. assert sinks[0].index == 10
  195. assert sinks[1].index == 65
  196. assert sinks[2].index == 128
  197. def test_sink_mit_sonderzeichen_in_description(self) -> None:
  198. """Sonderzeichen in der Beschreibung werden beibehalten."""
  199. text = 'Sink #1\n\tName: test\n\tDescription: Kopfhoerer (USB) & "Lautsprecher"\n\tState: IDLE\n'
  200. sinks = PulseCombinedSink._parse_sinks(text)
  201. assert sinks[0].description == 'Kopfhoerer (USB) & "Lautsprecher"'
  202. def test_zeile_ohne_relevante_felder(self) -> None:
  203. """Unbekannte Zeilen werden ignoriert."""
  204. text = "Sink #5\n\tName: ok\n\tDriver: PipeWire\n\tMute: no\n\tState: RUNNING\n"
  205. sinks = PulseCombinedSink._parse_sinks(text)
  206. assert len(sinks) == 1
  207. assert sinks[0].name == "ok"
  208. def test_sink_index_hohe_nummer(self) -> None:
  209. """Hohe Sink-Indices (PipeWire) korrekt parsen."""
  210. text = "Sink #4294967295\n\tName: high_idx\n\tState: IDLE\n"
  211. sinks = PulseCombinedSink._parse_sinks(text)
  212. assert sinks[0].index == 4294967295
  213. # =============================================================================
  214. # Tests — available() Lazy-Detection
  215. # =============================================================================
  216. class TestAvailable:
  217. """Tests fuer die Verfuegbarkeitserkennung."""
  218. @patch("trixy_core.audio.pulse_combined.shutil.which", return_value=None)
  219. async def test_pactl_nicht_im_path(self, mock_which: MagicMock, sink: PulseCombinedSink) -> None:
  220. """Kein pactl im PATH -> nicht verfuegbar."""
  221. assert await sink.available() is False
  222. @patch("trixy_core.audio.pulse_combined.shutil.which", return_value="/usr/bin/pactl")
  223. async def test_pactl_vorhanden_server_antwortet(self, mock_which: MagicMock, sink: PulseCombinedSink) -> None:
  224. """pactl vorhanden und Server antwortet -> verfuegbar."""
  225. sink._run = AsyncMock(return_value=(0, "Server: PipeWire\n"))
  226. assert await sink.available() is True
  227. @patch("trixy_core.audio.pulse_combined.shutil.which", return_value="/usr/bin/pactl")
  228. async def test_pactl_vorhanden_server_antwortet_nicht(self, mock_which: MagicMock, sink: PulseCombinedSink) -> None:
  229. """pactl vorhanden aber kein Server -> nicht verfuegbar."""
  230. sink._run = AsyncMock(return_value=(1, "Connection refused\n"))
  231. assert await sink.available() is False
  232. async def test_lazy_cache_true(self, sink: PulseCombinedSink) -> None:
  233. """Einmal als verfuegbar erkannt -> gecacht, kein erneuter Aufruf."""
  234. sink._available = True
  235. assert await sink.available() is True
  236. async def test_lazy_cache_false(self, sink: PulseCombinedSink) -> None:
  237. """Einmal als nicht verfuegbar erkannt -> gecacht."""
  238. sink._available = False
  239. assert await sink.available() is False
  240. # =============================================================================
  241. # Tests — list_sinks()
  242. # =============================================================================
  243. class TestListSinks:
  244. """Tests fuer die Sink-Auflistung."""
  245. async def test_nicht_verfuegbar_ergibt_leere_liste(self, sink: PulseCombinedSink) -> None:
  246. """Wenn nicht verfuegbar, leere Liste zurueckgeben."""
  247. sink._available = False
  248. result = await sink.list_sinks()
  249. assert result == []
  250. async def test_pactl_fehler_ergibt_leere_liste(self, sink_verfuegbar: PulseCombinedSink) -> None:
  251. """pactl-Fehler ergibt leere Liste."""
  252. sink_verfuegbar._run = AsyncMock(return_value=(1, "error"))
  253. result = await sink_verfuegbar.list_sinks()
  254. assert result == []
  255. async def test_eigener_combined_sink_wird_gefiltert(self, sink_verfuegbar: PulseCombinedSink) -> None:
  256. """Eigener Combined-Sink (trixy_combined) erscheint nicht in der Liste."""
  257. sink_verfuegbar._run = AsyncMock(return_value=(0, PACTL_LIST_SINKS_MULTI))
  258. result = await sink_verfuegbar.list_sinks()
  259. namen = [s.name for s in result]
  260. assert "trixy_combined" not in namen
  261. assert len(result) == 2
  262. async def test_custom_sink_name_filterung(self, sink_custom: PulseCombinedSink) -> None:
  263. """Benutzerdefinierter Sink-Name wird korrekt gefiltert."""
  264. sink_custom._available = True
  265. sink_custom._run = AsyncMock(return_value=(0, PACTL_LIST_SINKS_MULTI))
  266. result = await sink_custom.list_sinks()
  267. # "trixy_combined" ist nicht der eigene Name -> nicht gefiltert
  268. namen = [s.name for s in result]
  269. assert "trixy_combined" in namen
  270. # =============================================================================
  271. # Tests — find_existing_module()
  272. # =============================================================================
  273. class TestFindExistingModule:
  274. """Tests fuer die Modul-Suche."""
  275. async def test_modul_gefunden(self, sink_verfuegbar: PulseCombinedSink) -> None:
  276. """Bestehendes module-combine-sink mit unserem Namen finden."""
  277. sink_verfuegbar._run = AsyncMock(return_value=(0, PACTL_MODULES_SHORT))
  278. result = await sink_verfuegbar.find_existing_module()
  279. assert result == 42
  280. async def test_kein_modul_vorhanden(self, sink_verfuegbar: PulseCombinedSink) -> None:
  281. """Kein passendes Modul ergibt None."""
  282. sink_verfuegbar._run = AsyncMock(return_value=(0, PACTL_MODULES_SHORT_NO_COMBINED))
  283. result = await sink_verfuegbar.find_existing_module()
  284. assert result is None
  285. async def test_pactl_fehler(self, sink_verfuegbar: PulseCombinedSink) -> None:
  286. """pactl-Fehler ergibt None."""
  287. sink_verfuegbar._run = AsyncMock(return_value=(1, "error"))
  288. result = await sink_verfuegbar.find_existing_module()
  289. assert result is None
  290. async def test_anderer_sink_name_wird_ignoriert(self, sink_custom: PulseCombinedSink) -> None:
  291. """Modul mit anderem sink_name wird ignoriert."""
  292. sink_custom._available = True
  293. sink_custom._run = AsyncMock(return_value=(0, PACTL_MODULES_SHORT))
  294. result = await sink_custom.find_existing_module()
  295. # sink_name=trixy_combined != mein_sink
  296. assert result is None
  297. # =============================================================================
  298. # Tests — setup()
  299. # =============================================================================
  300. class TestSetup:
  301. """Tests fuer den Combined-Sink-Aufbau."""
  302. async def test_setup_mit_zwei_slaves(self, sink_verfuegbar: PulseCombinedSink) -> None:
  303. """Setup mit zwei Slaves sollte erfolgreich sein."""
  304. sink_verfuegbar._run = AsyncMock(side_effect=[
  305. # teardown: find_existing_module (kein _module_index, kein previous_default)
  306. (0, PACTL_MODULES_SHORT_NO_COMBINED),
  307. # load-module -> Modul-Index
  308. (0, "42\n"),
  309. ])
  310. result = await sink_verfuegbar.setup(["sink_a", "sink_b"])
  311. assert result is True
  312. assert sink_verfuegbar.module_index == 42
  313. assert sink_verfuegbar.slaves == ["sink_a", "sink_b"]
  314. async def test_setup_leere_slaves(self, sink_verfuegbar: PulseCombinedSink) -> None:
  315. """Leere Slave-Liste -> teardown + False."""
  316. sink_verfuegbar._run = AsyncMock(return_value=(0, ""))
  317. result = await sink_verfuegbar.setup([])
  318. assert result is False
  319. async def test_setup_ein_slave(self, sink_verfuegbar: PulseCombinedSink) -> None:
  320. """Nur ein Slave -> teardown + False (kein Combined noetig)."""
  321. sink_verfuegbar._run = AsyncMock(return_value=(0, ""))
  322. result = await sink_verfuegbar.setup(["nur_einer"])
  323. assert result is False
  324. async def test_setup_nicht_verfuegbar(self, sink: PulseCombinedSink) -> None:
  325. """Setup ohne verfuegbares pactl -> False."""
  326. sink._available = False
  327. result = await sink.setup(["a", "b"])
  328. assert result is False
  329. async def test_setup_load_module_fehler(self, sink_verfuegbar: PulseCombinedSink) -> None:
  330. """load-module schlaegt fehl -> False."""
  331. sink_verfuegbar._run = AsyncMock(side_effect=[
  332. (0, PACTL_MODULES_SHORT_NO_COMBINED), # teardown: find_existing_module
  333. (1, "Fehler!"), # load-module fehlgeschlagen
  334. ])
  335. result = await sink_verfuegbar.setup(["a", "b"])
  336. assert result is False
  337. async def test_setup_fallback_auf_find_existing(self, sink_verfuegbar: PulseCombinedSink) -> None:
  338. """Wenn Modul-Index nicht parsbar, suche per find_existing_module."""
  339. sink_verfuegbar._run = AsyncMock(side_effect=[
  340. (0, PACTL_MODULES_SHORT_NO_COMBINED), # teardown: find_existing_module
  341. (0, "nicht_eine_zahl\n"), # load-module mit unparsbarem Output
  342. (0, PACTL_MODULES_SHORT), # find_existing_module
  343. ])
  344. result = await sink_verfuegbar.setup(["a", "b"])
  345. assert result is True
  346. assert sink_verfuegbar.module_index == 42
  347. # =============================================================================
  348. # Tests — teardown()
  349. # =============================================================================
  350. class TestTeardown:
  351. """Tests fuer den Combined-Sink-Abbau."""
  352. async def test_teardown_mit_bekanntem_modul(self, sink_verfuegbar: PulseCombinedSink) -> None:
  353. """Teardown mit bekanntem Modul-Index entlaedt das Modul."""
  354. sink_verfuegbar._module_index = 42
  355. sink_verfuegbar._slaves = ["a", "b"]
  356. sink_verfuegbar._run = AsyncMock(return_value=(0, ""))
  357. await sink_verfuegbar.teardown()
  358. assert sink_verfuegbar.module_index is None
  359. assert sink_verfuegbar.slaves == []
  360. async def test_teardown_sucht_modul_wenn_unbekannt(self, sink_verfuegbar: PulseCombinedSink) -> None:
  361. """Teardown ohne Modul-Index sucht per find_existing_module."""
  362. sink_verfuegbar._module_index = None
  363. sink_verfuegbar._run = AsyncMock(side_effect=[
  364. (0, PACTL_MODULES_SHORT), # find_existing_module
  365. (0, ""), # unload-module
  366. ])
  367. await sink_verfuegbar.teardown()
  368. async def test_teardown_kein_modul_vorhanden(self, sink_verfuegbar: PulseCombinedSink) -> None:
  369. """Teardown ohne vorhandenes Modul -> nichts tun."""
  370. sink_verfuegbar._module_index = None
  371. sink_verfuegbar._run = AsyncMock(return_value=(0, PACTL_MODULES_SHORT_NO_COMBINED))
  372. await sink_verfuegbar.teardown()
  373. assert sink_verfuegbar.slaves == []
  374. async def test_teardown_nicht_verfuegbar(self, sink: PulseCombinedSink) -> None:
  375. """Teardown ohne verfuegbares pactl -> sofortiger Return."""
  376. sink._available = False
  377. sink._run = AsyncMock()
  378. await sink.teardown()
  379. sink._run.assert_not_called()
  380. # =============================================================================
  381. # Tests — Default-Sink-Verwaltung
  382. # =============================================================================
  383. class TestDefaultSink:
  384. """Tests fuer get/set/make/restore Default-Sink."""
  385. async def test_get_default_sink(self, sink_verfuegbar: PulseCombinedSink) -> None:
  386. """Default-Sink-Name lesen."""
  387. sink_verfuegbar._run = AsyncMock(return_value=(0, "alsa_output.pci\n"))
  388. result = await sink_verfuegbar.get_default_sink()
  389. assert result == "alsa_output.pci"
  390. async def test_get_default_sink_nicht_verfuegbar(self, sink: PulseCombinedSink) -> None:
  391. """Nicht verfuegbar -> leerer String."""
  392. sink._available = False
  393. result = await sink.get_default_sink()
  394. assert result == ""
  395. async def test_get_default_sink_fehler(self, sink_verfuegbar: PulseCombinedSink) -> None:
  396. """pactl-Fehler -> leerer String."""
  397. sink_verfuegbar._run = AsyncMock(return_value=(1, ""))
  398. result = await sink_verfuegbar.get_default_sink()
  399. assert result == ""
  400. async def test_get_default_sink_leere_ausgabe(self, sink_verfuegbar: PulseCombinedSink) -> None:
  401. """Leere Ausgabe -> leerer String."""
  402. sink_verfuegbar._run = AsyncMock(return_value=(0, ""))
  403. result = await sink_verfuegbar.get_default_sink()
  404. assert result == ""
  405. async def test_set_default_sink_erfolg(self, sink_verfuegbar: PulseCombinedSink) -> None:
  406. """Default-Sink setzen mit Erfolg."""
  407. sink_verfuegbar._run = AsyncMock(return_value=(0, ""))
  408. result = await sink_verfuegbar.set_default_sink("my_sink")
  409. assert result is True
  410. async def test_set_default_sink_fehler(self, sink_verfuegbar: PulseCombinedSink) -> None:
  411. """Default-Sink setzen schlaegt fehl."""
  412. sink_verfuegbar._run = AsyncMock(return_value=(1, "error"))
  413. result = await sink_verfuegbar.set_default_sink("my_sink")
  414. assert result is False
  415. async def test_make_default_ohne_modul(self, sink_verfuegbar: PulseCombinedSink) -> None:
  416. """make_default ohne geladenes Modul -> False."""
  417. sink_verfuegbar._module_index = None
  418. result = await sink_verfuegbar.make_default()
  419. assert result is False
  420. async def test_make_default_merkt_vorherigen(self, sink_verfuegbar: PulseCombinedSink) -> None:
  421. """make_default speichert den vorherigen Default-Sink."""
  422. sink_verfuegbar._module_index = 42
  423. sink_verfuegbar._run = AsyncMock(side_effect=[
  424. (0, "alter_sink\n"), # get_default_sink
  425. (0, ""), # set_default_sink
  426. ])
  427. result = await sink_verfuegbar.make_default()
  428. assert result is True
  429. assert sink_verfuegbar._previous_default == "alter_sink"
  430. async def test_make_default_ueberschreibt_nicht_bei_reload(self, sink_verfuegbar: PulseCombinedSink) -> None:
  431. """Wenn aktueller Default schon der Combined-Sink ist, nicht ueberschreiben."""
  432. sink_verfuegbar._module_index = 42
  433. sink_verfuegbar._previous_default = "alter_sink"
  434. sink_verfuegbar._run = AsyncMock(side_effect=[
  435. (0, "trixy_combined\n"), # get_default_sink -> schon unser
  436. (0, ""), # set_default_sink
  437. ])
  438. await sink_verfuegbar.make_default()
  439. # Vorheriger Default bleibt erhalten
  440. assert sink_verfuegbar._previous_default == "alter_sink"
  441. async def test_restore_default_erfolgreich(self, sink_verfuegbar: PulseCombinedSink) -> None:
  442. """Vorherigen Default erfolgreich wiederherstellen."""
  443. sink_verfuegbar._previous_default = "alter_sink"
  444. sink_verfuegbar._run = AsyncMock(side_effect=[
  445. (0, "trixy_combined\n"), # get_default_sink
  446. (0, ""), # set_default_sink
  447. ])
  448. await sink_verfuegbar.restore_default()
  449. assert sink_verfuegbar._previous_default is None
  450. async def test_restore_default_kein_vorheriger(self, sink_verfuegbar: PulseCombinedSink) -> None:
  451. """Kein vorheriger Default -> nichts tun."""
  452. sink_verfuegbar._previous_default = None
  453. sink_verfuegbar._run = AsyncMock()
  454. await sink_verfuegbar.restore_default()
  455. sink_verfuegbar._run.assert_not_called()
  456. async def test_restore_default_anderer_sink_ist_default(self, sink_verfuegbar: PulseCombinedSink) -> None:
  457. """Wenn aktuell nicht unser Combined -> nicht wiederherstellen."""
  458. sink_verfuegbar._previous_default = "alter_sink"
  459. sink_verfuegbar._run = AsyncMock(return_value=(0, "anderer_sink\n"))
  460. await sink_verfuegbar.restore_default()
  461. # previous_default wird trotzdem geloescht
  462. assert sink_verfuegbar._previous_default is None
  463. # =============================================================================
  464. # Tests — Lautstaerke
  465. # =============================================================================
  466. class TestVolume:
  467. """Tests fuer Per-Sink-Lautstaerke."""
  468. async def test_set_sink_volume_erfolg(self, sink_verfuegbar: PulseCombinedSink) -> None:
  469. """Lautstaerke setzen mit Erfolg."""
  470. sink_verfuegbar._run = AsyncMock(return_value=(0, ""))
  471. result = await sink_verfuegbar.set_sink_volume("test_sink", 80)
  472. assert result is True
  473. sink_verfuegbar._run.assert_called_with("set-sink-volume", "test_sink", "80%")
  474. async def test_set_sink_volume_clamping_ueber_100(self, sink_verfuegbar: PulseCombinedSink) -> None:
  475. """Lautstaerke ueber 100 wird auf 100 begrenzt."""
  476. sink_verfuegbar._run = AsyncMock(return_value=(0, ""))
  477. await sink_verfuegbar.set_sink_volume("test_sink", 150)
  478. sink_verfuegbar._run.assert_called_with("set-sink-volume", "test_sink", "100%")
  479. async def test_set_sink_volume_clamping_unter_0(self, sink_verfuegbar: PulseCombinedSink) -> None:
  480. """Negative Lautstaerke wird auf 0 begrenzt."""
  481. sink_verfuegbar._run = AsyncMock(return_value=(0, ""))
  482. await sink_verfuegbar.set_sink_volume("test_sink", -10)
  483. sink_verfuegbar._run.assert_called_with("set-sink-volume", "test_sink", "0%")
  484. async def test_get_sink_volume_symmetrisch(self, sink_verfuegbar: PulseCombinedSink) -> None:
  485. """Lautstaerke lesen bei symmetrischen Kanaelen (80%)."""
  486. sink_verfuegbar._run = AsyncMock(return_value=(0, PACTL_VOLUME_OUTPUT))
  487. result = await sink_verfuegbar.get_sink_volume("test_sink")
  488. assert result == 80
  489. async def test_get_sink_volume_asymmetrisch(self, sink_verfuegbar: PulseCombinedSink) -> None:
  490. """Lautstaerke lesen bei asymmetrischen Kanaelen -> Mittelwert."""
  491. sink_verfuegbar._run = AsyncMock(return_value=(0, PACTL_VOLUME_OUTPUT_ASYMMETRIC))
  492. result = await sink_verfuegbar.get_sink_volume("test_sink")
  493. assert result == 75 # (50 + 100) / 2
  494. async def test_get_sink_volume_fehler(self, sink_verfuegbar: PulseCombinedSink) -> None:
  495. """Fehler bei Lautstaerke-Abfrage -> 0."""
  496. sink_verfuegbar._run = AsyncMock(return_value=(1, ""))
  497. result = await sink_verfuegbar.get_sink_volume("test_sink")
  498. assert result == 0
  499. async def test_get_sink_volume_nicht_verfuegbar(self, sink: PulseCombinedSink) -> None:
  500. """Nicht verfuegbar -> 0."""
  501. sink._available = False
  502. result = await sink.get_sink_volume("test_sink")
  503. assert result == 0
  504. async def test_get_sink_volume_kein_prozentzeichen(self, sink_verfuegbar: PulseCombinedSink) -> None:
  505. """Ausgabe ohne Prozentzeichen -> 0."""
  506. sink_verfuegbar._run = AsyncMock(return_value=(0, "Volume: unknown\n"))
  507. result = await sink_verfuegbar.get_sink_volume("test_sink")
  508. assert result == 0
  509. # =============================================================================
  510. # Tests — Properties und Initialisierung
  511. # =============================================================================
  512. class TestProperties:
  513. """Tests fuer Attribut-Zugriff und Initialisierung."""
  514. def test_default_sink_name(self, sink: PulseCombinedSink) -> None:
  515. """Standard Sink-Name ist 'trixy_combined'."""
  516. assert sink.sink_name == "trixy_combined"
  517. def test_custom_sink_name(self, sink_custom: PulseCombinedSink) -> None:
  518. """Benutzerdefinierter Sink-Name."""
  519. assert sink_custom.sink_name == "mein_sink"
  520. def test_initial_module_index_none(self, sink: PulseCombinedSink) -> None:
  521. """Modul-Index ist initial None."""
  522. assert sink.module_index is None
  523. def test_initial_slaves_leer(self, sink: PulseCombinedSink) -> None:
  524. """Slave-Liste ist initial leer."""
  525. assert sink.slaves == []
  526. def test_slaves_ist_kopie(self, sink: PulseCombinedSink) -> None:
  527. """slaves Property gibt eine Kopie zurueck."""
  528. sink._slaves = ["a", "b"]
  529. kopie = sink.slaves
  530. kopie.append("c")
  531. assert sink.slaves == ["a", "b"]