| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647 |
- # -*- coding: utf-8 -*-
- """
- Tests fuer PulseCombinedSink.
- Testet:
- - PulseSink NamedTuple Konstruktion
- - _parse_sinks() Parser mit realistischen pactl-Ausgaben
- - available() Lazy-Detection (pactl + Server-Check)
- - list_sinks() mit Filterung des eigenen Combined-Sinks
- - find_existing_module() Modul-Suche
- - setup() / teardown() Lifecycle
- - Default-Sink-Verwaltung (make_default, restore_default)
- - Per-Sink Lautstaerke (set/get)
- - _run() Fehlerfaelle
- """
- import asyncio
- import pytest
- from unittest.mock import AsyncMock, MagicMock, patch
- from trixy_core.audio.pulse_combined import PulseSink, PulseCombinedSink
- # =============================================================================
- # Hilfskonstanten — Realistische pactl-Ausgaben
- # =============================================================================
- PACTL_LIST_SINKS_SINGLE = """\
- Sink #65
- \tState: RUNNING
- \tName: alsa_output.usb-Generic_USB_Audio-00.analog-stereo
- \tDescription: USB Audio CODEC Analog Stereo
- \tDriver: PipeWireAudioImpl
- \tSample Specification: s16le 2ch 44100Hz
- \tChannel Map: front-left,front-right
- \tOwner Module: 4294967295
- \tMute: no
- \tVolume: front-left: 42598 / 65% / -11.24 dB, front-right: 42598 / 65% / -11.24 dB
- \tBalance 0.00
- """
- PACTL_LIST_SINKS_MULTI = """\
- Sink #10
- \tState: IDLE
- \tName: alsa_output.pci-0000_00_1f.3.analog-stereo
- \tDescription: Built-in Audio Analog Stereo
- \tDriver: PipeWireAudioImpl
- \tSample Specification: float32le 2ch 48000Hz
- \tChannel Map: front-left,front-right
- \tMute: no
- \tVolume: front-left: 65536 / 100% / 0.00 dB, front-right: 65536 / 100% / 0.00 dB
- Sink #65
- \tState: RUNNING
- \tName: alsa_output.usb-Generic_USB_Audio-00.analog-stereo
- \tDescription: USB Audio CODEC Analog Stereo
- \tDriver: PipeWireAudioImpl
- \tSample Specification: s16le 2ch 44100Hz
- \tChannel Map: front-left,front-right
- \tMute: no
- \tVolume: front-left: 42598 / 65% / -11.24 dB, front-right: 42598 / 65% / -11.24 dB
- Sink #128
- \tState: SUSPENDED
- \tName: trixy_combined
- \tDescription: Trixy_Multi_Speaker
- \tDriver: PipeWireAudioImpl
- \tSample Specification: s16le 2ch 44100Hz
- \tChannel Map: front-left,front-right
- \tMute: no
- \tVolume: front-left: 65536 / 100% / 0.00 dB, front-right: 65536 / 100% / 0.00 dB
- """
- PACTL_LIST_SINKS_EMPTY = ""
- PACTL_LIST_SINKS_PARTIAL = """\
- Sink #99
- \tName: partial_sink
- \tState: IDLE
- """
- PACTL_MODULES_SHORT = """\
- 0\tmodule-always-sink\t
- 1\tmodule-switch-on-port-available\t
- 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
- 50\tmodule-null-sink\tsink_name=something_else
- """
- PACTL_MODULES_SHORT_NO_COMBINED = """\
- 0\tmodule-always-sink\t
- 1\tmodule-switch-on-port-available\t
- 50\tmodule-null-sink\tsink_name=something_else
- """
- PACTL_VOLUME_OUTPUT = """\
- Volume: front-left: 52428 / 80% / -5.81 dB, front-right: 52428 / 80% / -5.81 dB
- """
- PACTL_VOLUME_OUTPUT_ASYMMETRIC = """\
- Volume: front-left: 32768 / 50% / -18.06 dB, front-right: 65536 / 100% / 0.00 dB
- """
- # =============================================================================
- # Fixtures
- # =============================================================================
- @pytest.fixture
- def sink() -> PulseCombinedSink:
- """Erstellt einen PulseCombinedSink mit Default-Name."""
- return PulseCombinedSink()
- @pytest.fixture
- def sink_custom() -> PulseCombinedSink:
- """Erstellt einen PulseCombinedSink mit benutzerdefiniertem Namen."""
- return PulseCombinedSink(sink_name="mein_sink")
- @pytest.fixture
- def sink_verfuegbar(sink: PulseCombinedSink) -> PulseCombinedSink:
- """PulseCombinedSink der als verfuegbar markiert ist."""
- sink._available = True
- return sink
- # =============================================================================
- # Tests — PulseSink NamedTuple
- # =============================================================================
- class TestPulseSink:
- """Tests fuer die PulseSink NamedTuple Struktur."""
- def test_erstellung_mit_allen_feldern(self) -> None:
- """PulseSink mit allen Feldern korrekt erstellen."""
- s = PulseSink(
- index=10,
- name="alsa_output.pci",
- description="Built-in Audio",
- sample_spec="s16le 2ch 48000Hz",
- state="RUNNING",
- )
- assert s.index == 10
- assert s.name == "alsa_output.pci"
- assert s.description == "Built-in Audio"
- assert s.sample_spec == "s16le 2ch 48000Hz"
- assert s.state == "RUNNING"
- def test_named_tuple_unveraenderlich(self) -> None:
- """PulseSink-Felder koennen nicht geaendert werden."""
- s = PulseSink(0, "test", "Test", "s16le", "IDLE")
- with pytest.raises(AttributeError):
- s.name = "neu" # type: ignore[misc]
- def test_tuple_vergleich(self) -> None:
- """Zwei PulseSinks mit gleichen Werten sind gleich."""
- a = PulseSink(1, "a", "A", "spec", "RUNNING")
- b = PulseSink(1, "a", "A", "spec", "RUNNING")
- assert a == b
- # =============================================================================
- # Tests — _parse_sinks (Statische Methode, kein Mock noetig)
- # =============================================================================
- class TestParseSinks:
- """Tests fuer den pactl-list-sinks Parser."""
- def test_leere_eingabe(self) -> None:
- """Leerer String ergibt leere Liste."""
- assert PulseCombinedSink._parse_sinks("") == []
- def test_einzelner_sink(self) -> None:
- """Einzelnen Sink korrekt parsen."""
- sinks = PulseCombinedSink._parse_sinks(PACTL_LIST_SINKS_SINGLE)
- assert len(sinks) == 1
- s = sinks[0]
- assert s.index == 65
- assert s.name == "alsa_output.usb-Generic_USB_Audio-00.analog-stereo"
- assert s.description == "USB Audio CODEC Analog Stereo"
- assert s.sample_spec == "s16le 2ch 44100Hz"
- assert s.state == "RUNNING"
- def test_mehrere_sinks(self) -> None:
- """Mehrere Sinks inklusive Combined-Sink parsen."""
- sinks = PulseCombinedSink._parse_sinks(PACTL_LIST_SINKS_MULTI)
- assert len(sinks) == 3
- namen = [s.name for s in sinks]
- assert "alsa_output.pci-0000_00_1f.3.analog-stereo" in namen
- assert "alsa_output.usb-Generic_USB_Audio-00.analog-stereo" in namen
- assert "trixy_combined" in namen
- def test_sink_status_werte(self) -> None:
- """Verschiedene State-Werte korrekt parsen."""
- sinks = PulseCombinedSink._parse_sinks(PACTL_LIST_SINKS_MULTI)
- states = {s.name: s.state for s in sinks}
- assert states["alsa_output.pci-0000_00_1f.3.analog-stereo"] == "IDLE"
- assert states["alsa_output.usb-Generic_USB_Audio-00.analog-stereo"] == "RUNNING"
- assert states["trixy_combined"] == "SUSPENDED"
- def test_sample_spec_float32(self) -> None:
- """float32le Sample-Spec korrekt parsen."""
- sinks = PulseCombinedSink._parse_sinks(PACTL_LIST_SINKS_MULTI)
- builtin = next(s for s in sinks if "pci" in s.name)
- assert builtin.sample_spec == "float32le 2ch 48000Hz"
- def test_partieller_sink_ohne_description(self) -> None:
- """Sink ohne Description faellt auf Name zurueck."""
- sinks = PulseCombinedSink._parse_sinks(PACTL_LIST_SINKS_PARTIAL)
- assert len(sinks) == 1
- assert sinks[0].name == "partial_sink"
- # Description fehlt -> Fallback auf Name
- assert sinks[0].description == "partial_sink"
- def test_partieller_sink_ohne_sample_spec(self) -> None:
- """Sink ohne Sample Specification ergibt leeren String."""
- sinks = PulseCombinedSink._parse_sinks(PACTL_LIST_SINKS_PARTIAL)
- assert sinks[0].sample_spec == ""
- def test_nur_whitespace(self) -> None:
- """Nur Leerzeichen/Tabs ergeben keine Sinks."""
- assert PulseCombinedSink._parse_sinks(" \n\t\n ") == []
- def test_ungueltiger_index(self) -> None:
- """Ungueltiger Index (keine Zahl) wird abgefangen."""
- text = "Sink #abc\n\tName: test\n\tState: IDLE\n"
- sinks = PulseCombinedSink._parse_sinks(text)
- # Index-Parse schlaegt fehl, current bleibt ohne "index" -> Fallback -1
- assert len(sinks) == 1
- assert sinks[0].index == -1
- def test_mehrere_sinks_reihenfolge(self) -> None:
- """Sinks werden in der Reihenfolge der Ausgabe zurueckgegeben."""
- sinks = PulseCombinedSink._parse_sinks(PACTL_LIST_SINKS_MULTI)
- assert sinks[0].index == 10
- assert sinks[1].index == 65
- assert sinks[2].index == 128
- def test_sink_mit_sonderzeichen_in_description(self) -> None:
- """Sonderzeichen in der Beschreibung werden beibehalten."""
- text = 'Sink #1\n\tName: test\n\tDescription: Kopfhoerer (USB) & "Lautsprecher"\n\tState: IDLE\n'
- sinks = PulseCombinedSink._parse_sinks(text)
- assert sinks[0].description == 'Kopfhoerer (USB) & "Lautsprecher"'
- def test_zeile_ohne_relevante_felder(self) -> None:
- """Unbekannte Zeilen werden ignoriert."""
- text = "Sink #5\n\tName: ok\n\tDriver: PipeWire\n\tMute: no\n\tState: RUNNING\n"
- sinks = PulseCombinedSink._parse_sinks(text)
- assert len(sinks) == 1
- assert sinks[0].name == "ok"
- def test_sink_index_hohe_nummer(self) -> None:
- """Hohe Sink-Indices (PipeWire) korrekt parsen."""
- text = "Sink #4294967295\n\tName: high_idx\n\tState: IDLE\n"
- sinks = PulseCombinedSink._parse_sinks(text)
- assert sinks[0].index == 4294967295
- # =============================================================================
- # Tests — available() Lazy-Detection
- # =============================================================================
- class TestAvailable:
- """Tests fuer die Verfuegbarkeitserkennung."""
- @patch("trixy_core.audio.pulse_combined.shutil.which", return_value=None)
- async def test_pactl_nicht_im_path(self, mock_which: MagicMock, sink: PulseCombinedSink) -> None:
- """Kein pactl im PATH -> nicht verfuegbar."""
- assert await sink.available() is False
- @patch("trixy_core.audio.pulse_combined.shutil.which", return_value="/usr/bin/pactl")
- async def test_pactl_vorhanden_server_antwortet(self, mock_which: MagicMock, sink: PulseCombinedSink) -> None:
- """pactl vorhanden und Server antwortet -> verfuegbar."""
- sink._run = AsyncMock(return_value=(0, "Server: PipeWire\n"))
- assert await sink.available() is True
- @patch("trixy_core.audio.pulse_combined.shutil.which", return_value="/usr/bin/pactl")
- async def test_pactl_vorhanden_server_antwortet_nicht(self, mock_which: MagicMock, sink: PulseCombinedSink) -> None:
- """pactl vorhanden aber kein Server -> nicht verfuegbar."""
- sink._run = AsyncMock(return_value=(1, "Connection refused\n"))
- assert await sink.available() is False
- async def test_lazy_cache_true(self, sink: PulseCombinedSink) -> None:
- """Einmal als verfuegbar erkannt -> gecacht, kein erneuter Aufruf."""
- sink._available = True
- assert await sink.available() is True
- async def test_lazy_cache_false(self, sink: PulseCombinedSink) -> None:
- """Einmal als nicht verfuegbar erkannt -> gecacht."""
- sink._available = False
- assert await sink.available() is False
- # =============================================================================
- # Tests — list_sinks()
- # =============================================================================
- class TestListSinks:
- """Tests fuer die Sink-Auflistung."""
- async def test_nicht_verfuegbar_ergibt_leere_liste(self, sink: PulseCombinedSink) -> None:
- """Wenn nicht verfuegbar, leere Liste zurueckgeben."""
- sink._available = False
- result = await sink.list_sinks()
- assert result == []
- async def test_pactl_fehler_ergibt_leere_liste(self, sink_verfuegbar: PulseCombinedSink) -> None:
- """pactl-Fehler ergibt leere Liste."""
- sink_verfuegbar._run = AsyncMock(return_value=(1, "error"))
- result = await sink_verfuegbar.list_sinks()
- assert result == []
- async def test_eigener_combined_sink_wird_gefiltert(self, sink_verfuegbar: PulseCombinedSink) -> None:
- """Eigener Combined-Sink (trixy_combined) erscheint nicht in der Liste."""
- sink_verfuegbar._run = AsyncMock(return_value=(0, PACTL_LIST_SINKS_MULTI))
- result = await sink_verfuegbar.list_sinks()
- namen = [s.name for s in result]
- assert "trixy_combined" not in namen
- assert len(result) == 2
- async def test_custom_sink_name_filterung(self, sink_custom: PulseCombinedSink) -> None:
- """Benutzerdefinierter Sink-Name wird korrekt gefiltert."""
- sink_custom._available = True
- sink_custom._run = AsyncMock(return_value=(0, PACTL_LIST_SINKS_MULTI))
- result = await sink_custom.list_sinks()
- # "trixy_combined" ist nicht der eigene Name -> nicht gefiltert
- namen = [s.name for s in result]
- assert "trixy_combined" in namen
- # =============================================================================
- # Tests — find_existing_module()
- # =============================================================================
- class TestFindExistingModule:
- """Tests fuer die Modul-Suche."""
- async def test_modul_gefunden(self, sink_verfuegbar: PulseCombinedSink) -> None:
- """Bestehendes module-combine-sink mit unserem Namen finden."""
- sink_verfuegbar._run = AsyncMock(return_value=(0, PACTL_MODULES_SHORT))
- result = await sink_verfuegbar.find_existing_module()
- assert result == 42
- async def test_kein_modul_vorhanden(self, sink_verfuegbar: PulseCombinedSink) -> None:
- """Kein passendes Modul ergibt None."""
- sink_verfuegbar._run = AsyncMock(return_value=(0, PACTL_MODULES_SHORT_NO_COMBINED))
- result = await sink_verfuegbar.find_existing_module()
- assert result is None
- async def test_pactl_fehler(self, sink_verfuegbar: PulseCombinedSink) -> None:
- """pactl-Fehler ergibt None."""
- sink_verfuegbar._run = AsyncMock(return_value=(1, "error"))
- result = await sink_verfuegbar.find_existing_module()
- assert result is None
- async def test_anderer_sink_name_wird_ignoriert(self, sink_custom: PulseCombinedSink) -> None:
- """Modul mit anderem sink_name wird ignoriert."""
- sink_custom._available = True
- sink_custom._run = AsyncMock(return_value=(0, PACTL_MODULES_SHORT))
- result = await sink_custom.find_existing_module()
- # sink_name=trixy_combined != mein_sink
- assert result is None
- # =============================================================================
- # Tests — setup()
- # =============================================================================
- class TestSetup:
- """Tests fuer den Combined-Sink-Aufbau."""
- async def test_setup_mit_zwei_slaves(self, sink_verfuegbar: PulseCombinedSink) -> None:
- """Setup mit zwei Slaves sollte erfolgreich sein."""
- sink_verfuegbar._run = AsyncMock(side_effect=[
- # teardown: find_existing_module (kein _module_index, kein previous_default)
- (0, PACTL_MODULES_SHORT_NO_COMBINED),
- # load-module -> Modul-Index
- (0, "42\n"),
- ])
- result = await sink_verfuegbar.setup(["sink_a", "sink_b"])
- assert result is True
- assert sink_verfuegbar.module_index == 42
- assert sink_verfuegbar.slaves == ["sink_a", "sink_b"]
- async def test_setup_leere_slaves(self, sink_verfuegbar: PulseCombinedSink) -> None:
- """Leere Slave-Liste -> teardown + False."""
- sink_verfuegbar._run = AsyncMock(return_value=(0, ""))
- result = await sink_verfuegbar.setup([])
- assert result is False
- async def test_setup_ein_slave(self, sink_verfuegbar: PulseCombinedSink) -> None:
- """Nur ein Slave -> teardown + False (kein Combined noetig)."""
- sink_verfuegbar._run = AsyncMock(return_value=(0, ""))
- result = await sink_verfuegbar.setup(["nur_einer"])
- assert result is False
- async def test_setup_nicht_verfuegbar(self, sink: PulseCombinedSink) -> None:
- """Setup ohne verfuegbares pactl -> False."""
- sink._available = False
- result = await sink.setup(["a", "b"])
- assert result is False
- async def test_setup_load_module_fehler(self, sink_verfuegbar: PulseCombinedSink) -> None:
- """load-module schlaegt fehl -> False."""
- sink_verfuegbar._run = AsyncMock(side_effect=[
- (0, PACTL_MODULES_SHORT_NO_COMBINED), # teardown: find_existing_module
- (1, "Fehler!"), # load-module fehlgeschlagen
- ])
- result = await sink_verfuegbar.setup(["a", "b"])
- assert result is False
- async def test_setup_fallback_auf_find_existing(self, sink_verfuegbar: PulseCombinedSink) -> None:
- """Wenn Modul-Index nicht parsbar, suche per find_existing_module."""
- sink_verfuegbar._run = AsyncMock(side_effect=[
- (0, PACTL_MODULES_SHORT_NO_COMBINED), # teardown: find_existing_module
- (0, "nicht_eine_zahl\n"), # load-module mit unparsbarem Output
- (0, PACTL_MODULES_SHORT), # find_existing_module
- ])
- result = await sink_verfuegbar.setup(["a", "b"])
- assert result is True
- assert sink_verfuegbar.module_index == 42
- # =============================================================================
- # Tests — teardown()
- # =============================================================================
- class TestTeardown:
- """Tests fuer den Combined-Sink-Abbau."""
- async def test_teardown_mit_bekanntem_modul(self, sink_verfuegbar: PulseCombinedSink) -> None:
- """Teardown mit bekanntem Modul-Index entlaedt das Modul."""
- sink_verfuegbar._module_index = 42
- sink_verfuegbar._slaves = ["a", "b"]
- sink_verfuegbar._run = AsyncMock(return_value=(0, ""))
- await sink_verfuegbar.teardown()
- assert sink_verfuegbar.module_index is None
- assert sink_verfuegbar.slaves == []
- async def test_teardown_sucht_modul_wenn_unbekannt(self, sink_verfuegbar: PulseCombinedSink) -> None:
- """Teardown ohne Modul-Index sucht per find_existing_module."""
- sink_verfuegbar._module_index = None
- sink_verfuegbar._run = AsyncMock(side_effect=[
- (0, PACTL_MODULES_SHORT), # find_existing_module
- (0, ""), # unload-module
- ])
- await sink_verfuegbar.teardown()
- async def test_teardown_kein_modul_vorhanden(self, sink_verfuegbar: PulseCombinedSink) -> None:
- """Teardown ohne vorhandenes Modul -> nichts tun."""
- sink_verfuegbar._module_index = None
- sink_verfuegbar._run = AsyncMock(return_value=(0, PACTL_MODULES_SHORT_NO_COMBINED))
- await sink_verfuegbar.teardown()
- assert sink_verfuegbar.slaves == []
- async def test_teardown_nicht_verfuegbar(self, sink: PulseCombinedSink) -> None:
- """Teardown ohne verfuegbares pactl -> sofortiger Return."""
- sink._available = False
- sink._run = AsyncMock()
- await sink.teardown()
- sink._run.assert_not_called()
- # =============================================================================
- # Tests — Default-Sink-Verwaltung
- # =============================================================================
- class TestDefaultSink:
- """Tests fuer get/set/make/restore Default-Sink."""
- async def test_get_default_sink(self, sink_verfuegbar: PulseCombinedSink) -> None:
- """Default-Sink-Name lesen."""
- sink_verfuegbar._run = AsyncMock(return_value=(0, "alsa_output.pci\n"))
- result = await sink_verfuegbar.get_default_sink()
- assert result == "alsa_output.pci"
- async def test_get_default_sink_nicht_verfuegbar(self, sink: PulseCombinedSink) -> None:
- """Nicht verfuegbar -> leerer String."""
- sink._available = False
- result = await sink.get_default_sink()
- assert result == ""
- async def test_get_default_sink_fehler(self, sink_verfuegbar: PulseCombinedSink) -> None:
- """pactl-Fehler -> leerer String."""
- sink_verfuegbar._run = AsyncMock(return_value=(1, ""))
- result = await sink_verfuegbar.get_default_sink()
- assert result == ""
- async def test_get_default_sink_leere_ausgabe(self, sink_verfuegbar: PulseCombinedSink) -> None:
- """Leere Ausgabe -> leerer String."""
- sink_verfuegbar._run = AsyncMock(return_value=(0, ""))
- result = await sink_verfuegbar.get_default_sink()
- assert result == ""
- async def test_set_default_sink_erfolg(self, sink_verfuegbar: PulseCombinedSink) -> None:
- """Default-Sink setzen mit Erfolg."""
- sink_verfuegbar._run = AsyncMock(return_value=(0, ""))
- result = await sink_verfuegbar.set_default_sink("my_sink")
- assert result is True
- async def test_set_default_sink_fehler(self, sink_verfuegbar: PulseCombinedSink) -> None:
- """Default-Sink setzen schlaegt fehl."""
- sink_verfuegbar._run = AsyncMock(return_value=(1, "error"))
- result = await sink_verfuegbar.set_default_sink("my_sink")
- assert result is False
- async def test_make_default_ohne_modul(self, sink_verfuegbar: PulseCombinedSink) -> None:
- """make_default ohne geladenes Modul -> False."""
- sink_verfuegbar._module_index = None
- result = await sink_verfuegbar.make_default()
- assert result is False
- async def test_make_default_merkt_vorherigen(self, sink_verfuegbar: PulseCombinedSink) -> None:
- """make_default speichert den vorherigen Default-Sink."""
- sink_verfuegbar._module_index = 42
- sink_verfuegbar._run = AsyncMock(side_effect=[
- (0, "alter_sink\n"), # get_default_sink
- (0, ""), # set_default_sink
- ])
- result = await sink_verfuegbar.make_default()
- assert result is True
- assert sink_verfuegbar._previous_default == "alter_sink"
- async def test_make_default_ueberschreibt_nicht_bei_reload(self, sink_verfuegbar: PulseCombinedSink) -> None:
- """Wenn aktueller Default schon der Combined-Sink ist, nicht ueberschreiben."""
- sink_verfuegbar._module_index = 42
- sink_verfuegbar._previous_default = "alter_sink"
- sink_verfuegbar._run = AsyncMock(side_effect=[
- (0, "trixy_combined\n"), # get_default_sink -> schon unser
- (0, ""), # set_default_sink
- ])
- await sink_verfuegbar.make_default()
- # Vorheriger Default bleibt erhalten
- assert sink_verfuegbar._previous_default == "alter_sink"
- async def test_restore_default_erfolgreich(self, sink_verfuegbar: PulseCombinedSink) -> None:
- """Vorherigen Default erfolgreich wiederherstellen."""
- sink_verfuegbar._previous_default = "alter_sink"
- sink_verfuegbar._run = AsyncMock(side_effect=[
- (0, "trixy_combined\n"), # get_default_sink
- (0, ""), # set_default_sink
- ])
- await sink_verfuegbar.restore_default()
- assert sink_verfuegbar._previous_default is None
- async def test_restore_default_kein_vorheriger(self, sink_verfuegbar: PulseCombinedSink) -> None:
- """Kein vorheriger Default -> nichts tun."""
- sink_verfuegbar._previous_default = None
- sink_verfuegbar._run = AsyncMock()
- await sink_verfuegbar.restore_default()
- sink_verfuegbar._run.assert_not_called()
- async def test_restore_default_anderer_sink_ist_default(self, sink_verfuegbar: PulseCombinedSink) -> None:
- """Wenn aktuell nicht unser Combined -> nicht wiederherstellen."""
- sink_verfuegbar._previous_default = "alter_sink"
- sink_verfuegbar._run = AsyncMock(return_value=(0, "anderer_sink\n"))
- await sink_verfuegbar.restore_default()
- # previous_default wird trotzdem geloescht
- assert sink_verfuegbar._previous_default is None
- # =============================================================================
- # Tests — Lautstaerke
- # =============================================================================
- class TestVolume:
- """Tests fuer Per-Sink-Lautstaerke."""
- async def test_set_sink_volume_erfolg(self, sink_verfuegbar: PulseCombinedSink) -> None:
- """Lautstaerke setzen mit Erfolg."""
- sink_verfuegbar._run = AsyncMock(return_value=(0, ""))
- result = await sink_verfuegbar.set_sink_volume("test_sink", 80)
- assert result is True
- sink_verfuegbar._run.assert_called_with("set-sink-volume", "test_sink", "80%")
- async def test_set_sink_volume_clamping_ueber_100(self, sink_verfuegbar: PulseCombinedSink) -> None:
- """Lautstaerke ueber 100 wird auf 100 begrenzt."""
- sink_verfuegbar._run = AsyncMock(return_value=(0, ""))
- await sink_verfuegbar.set_sink_volume("test_sink", 150)
- sink_verfuegbar._run.assert_called_with("set-sink-volume", "test_sink", "100%")
- async def test_set_sink_volume_clamping_unter_0(self, sink_verfuegbar: PulseCombinedSink) -> None:
- """Negative Lautstaerke wird auf 0 begrenzt."""
- sink_verfuegbar._run = AsyncMock(return_value=(0, ""))
- await sink_verfuegbar.set_sink_volume("test_sink", -10)
- sink_verfuegbar._run.assert_called_with("set-sink-volume", "test_sink", "0%")
- async def test_get_sink_volume_symmetrisch(self, sink_verfuegbar: PulseCombinedSink) -> None:
- """Lautstaerke lesen bei symmetrischen Kanaelen (80%)."""
- sink_verfuegbar._run = AsyncMock(return_value=(0, PACTL_VOLUME_OUTPUT))
- result = await sink_verfuegbar.get_sink_volume("test_sink")
- assert result == 80
- async def test_get_sink_volume_asymmetrisch(self, sink_verfuegbar: PulseCombinedSink) -> None:
- """Lautstaerke lesen bei asymmetrischen Kanaelen -> Mittelwert."""
- sink_verfuegbar._run = AsyncMock(return_value=(0, PACTL_VOLUME_OUTPUT_ASYMMETRIC))
- result = await sink_verfuegbar.get_sink_volume("test_sink")
- assert result == 75 # (50 + 100) / 2
- async def test_get_sink_volume_fehler(self, sink_verfuegbar: PulseCombinedSink) -> None:
- """Fehler bei Lautstaerke-Abfrage -> 0."""
- sink_verfuegbar._run = AsyncMock(return_value=(1, ""))
- result = await sink_verfuegbar.get_sink_volume("test_sink")
- assert result == 0
- async def test_get_sink_volume_nicht_verfuegbar(self, sink: PulseCombinedSink) -> None:
- """Nicht verfuegbar -> 0."""
- sink._available = False
- result = await sink.get_sink_volume("test_sink")
- assert result == 0
- async def test_get_sink_volume_kein_prozentzeichen(self, sink_verfuegbar: PulseCombinedSink) -> None:
- """Ausgabe ohne Prozentzeichen -> 0."""
- sink_verfuegbar._run = AsyncMock(return_value=(0, "Volume: unknown\n"))
- result = await sink_verfuegbar.get_sink_volume("test_sink")
- assert result == 0
- # =============================================================================
- # Tests — Properties und Initialisierung
- # =============================================================================
- class TestProperties:
- """Tests fuer Attribut-Zugriff und Initialisierung."""
- def test_default_sink_name(self, sink: PulseCombinedSink) -> None:
- """Standard Sink-Name ist 'trixy_combined'."""
- assert sink.sink_name == "trixy_combined"
- def test_custom_sink_name(self, sink_custom: PulseCombinedSink) -> None:
- """Benutzerdefinierter Sink-Name."""
- assert sink_custom.sink_name == "mein_sink"
- def test_initial_module_index_none(self, sink: PulseCombinedSink) -> None:
- """Modul-Index ist initial None."""
- assert sink.module_index is None
- def test_initial_slaves_leer(self, sink: PulseCombinedSink) -> None:
- """Slave-Liste ist initial leer."""
- assert sink.slaves == []
- def test_slaves_ist_kopie(self, sink: PulseCombinedSink) -> None:
- """slaves Property gibt eine Kopie zurueck."""
- sink._slaves = ["a", "b"]
- kopie = sink.slaves
- kopie.append("c")
- assert sink.slaves == ["a", "b"]
|