| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722 |
- # -*- coding: utf-8 -*-
- """
- Tests fuer PulseMicrophoneMix.
- Testet:
- - PulseSource NamedTuple Konstruktion
- - _parse_sources() Parser mit realistischen pactl-Ausgaben
- - "Monitor of Sink: n/a" Sonderbehandlung
- - available() Lazy-Detection
- - list_sources() mit Monitor-Filterung
- - find_existing_null_sink() / _find_our_loopbacks()
- - setup() / teardown() Lifecycle
- - Default-Source-Verwaltung
- - Properties (monitor_source_name, active)
- """
- import asyncio
- import pytest
- from unittest.mock import AsyncMock, MagicMock, patch
- from trixy_core.audio.pulse_mic_mix import PulseSource, PulseMicrophoneMix
- # =============================================================================
- # Hilfskonstanten — Realistische pactl-Ausgaben
- # =============================================================================
- PACTL_LIST_SOURCES_MULTI = """\
- Source #0
- \tState: SUSPENDED
- \tName: alsa_output.pci-0000_00_1f.3.analog-stereo.monitor
- \tDescription: Monitor of Built-in Audio Analog Stereo
- \tDriver: PipeWireAudioImpl
- \tSample Specification: float32le 2ch 48000Hz
- \tMonitor of Sink: alsa_output.pci-0000_00_1f.3.analog-stereo
- Source #1
- \tState: IDLE
- \tName: alsa_input.usb-Blue_Yeti-00.analog-stereo
- \tDescription: Blue Yeti Analog Stereo
- \tDriver: PipeWireAudioImpl
- \tSample Specification: s16le 1ch 16000Hz
- \tMonitor of Sink: n/a
- Source #2
- \tState: RUNNING
- \tName: alsa_input.usb-Rode_NT_USB-00.analog-stereo
- \tDescription: Rode NT-USB Analog Stereo
- \tDriver: PipeWireAudioImpl
- \tSample Specification: s16le 1ch 48000Hz
- \tMonitor of Sink: n/a
- Source #128
- \tState: SUSPENDED
- \tName: trixy_mics_combined.monitor
- \tDescription: Monitor of Trixy_Multi_Mic_Mix
- \tDriver: PipeWireAudioImpl
- \tSample Specification: float32le 2ch 48000Hz
- \tMonitor of Sink: trixy_mics_combined
- """
- PACTL_LIST_SOURCES_NUR_MONITOR = """\
- Source #0
- \tState: SUSPENDED
- \tName: alsa_output.pci.monitor
- \tDescription: Monitor of Built-in Audio
- \tMonitor of Sink: alsa_output.pci
- """
- PACTL_LIST_SOURCES_EMPTY = ""
- PACTL_LIST_SOURCES_NA_VARIANTEN = """\
- Source #1
- \tName: mic1
- \tState: IDLE
- \tMonitor of Sink: n/a
- Source #2
- \tName: mic2
- \tState: IDLE
- \tMonitor of Sink: N/A
- Source #3
- \tName: mic3
- \tState: IDLE
- \tMonitor of Sink:
- """
- PACTL_LIST_SOURCES_PARTIAL = """\
- Source #99
- \tName: headset_mic
- \tState: RUNNING
- """
- PACTL_MODULES_SHORT_NULL_SINK = """\
- 0\tmodule-always-sink\t
- 1\tmodule-switch-on-port-available\t
- 55\tmodule-null-sink\tsink_name=trixy_mics_combined sink_properties=device.description=Trixy_Multi_Mic_Mix
- 60\tmodule-loopback\tsource=alsa_input.usb-Blue_Yeti-00.analog-stereo sink=trixy_mics_combined latency_msec=30
- 61\tmodule-loopback\tsource=alsa_input.usb-Rode_NT_USB-00.analog-stereo sink=trixy_mics_combined latency_msec=30
- 70\tmodule-loopback\tsource=some_other sink=other_sink latency_msec=30
- """
- PACTL_MODULES_SHORT_KEIN_NULL_SINK = """\
- 0\tmodule-always-sink\t
- 1\tmodule-switch-on-port-available\t
- 70\tmodule-loopback\tsource=some_other sink=other_sink latency_msec=30
- """
- # =============================================================================
- # Fixtures
- # =============================================================================
- @pytest.fixture
- def mix() -> PulseMicrophoneMix:
- """Erstellt einen PulseMicrophoneMix mit Default-Name."""
- return PulseMicrophoneMix()
- @pytest.fixture
- def mix_custom() -> PulseMicrophoneMix:
- """Erstellt einen PulseMicrophoneMix mit benutzerdefiniertem Namen."""
- return PulseMicrophoneMix(sink_name="mein_mic_mix")
- @pytest.fixture
- def mix_verfuegbar(mix: PulseMicrophoneMix) -> PulseMicrophoneMix:
- """PulseMicrophoneMix der als verfuegbar markiert ist."""
- mix._available = True
- return mix
- # =============================================================================
- # Tests — PulseSource NamedTuple
- # =============================================================================
- class TestPulseSource:
- """Tests fuer die PulseSource NamedTuple Struktur."""
- def test_erstellung_mit_allen_feldern(self) -> None:
- """PulseSource mit allen Feldern korrekt erstellen."""
- s = PulseSource(
- index=1,
- name="alsa_input.usb",
- description="Blue Yeti",
- state="RUNNING",
- monitor_of="",
- )
- assert s.index == 1
- assert s.name == "alsa_input.usb"
- assert s.description == "Blue Yeti"
- assert s.state == "RUNNING"
- assert s.monitor_of == ""
- def test_monitor_source_mit_sink_referenz(self) -> None:
- """Monitor-Source mit Sink-Referenz erkennen."""
- s = PulseSource(0, "output.monitor", "Monitor", "IDLE", "output_sink")
- assert s.monitor_of == "output_sink"
- def test_named_tuple_unveraenderlich(self) -> None:
- """PulseSource-Felder koennen nicht geaendert werden."""
- s = PulseSource(0, "test", "Test", "IDLE", "")
- with pytest.raises(AttributeError):
- s.name = "neu" # type: ignore[misc]
- # =============================================================================
- # Tests — _parse_sources (Statische Methode, kein Mock noetig)
- # =============================================================================
- class TestParseSources:
- """Tests fuer den pactl-list-sources Parser."""
- def test_leere_eingabe(self) -> None:
- """Leerer String ergibt leere Liste."""
- assert PulseMicrophoneMix._parse_sources("") == []
- def test_mehrere_sources_korrekt_parsen(self) -> None:
- """Mehrere Sources inklusive Monitors korrekt parsen."""
- sources = PulseMicrophoneMix._parse_sources(PACTL_LIST_SOURCES_MULTI)
- assert len(sources) == 4
- namen = [s.name for s in sources]
- assert "alsa_input.usb-Blue_Yeti-00.analog-stereo" in namen
- assert "alsa_input.usb-Rode_NT_USB-00.analog-stereo" in namen
- assert "alsa_output.pci-0000_00_1f.3.analog-stereo.monitor" in namen
- assert "trixy_mics_combined.monitor" in namen
- def test_monitor_of_sink_korrekt_gesetzt(self) -> None:
- """Monitor-Sources haben den Sink-Namen in monitor_of."""
- sources = PulseMicrophoneMix._parse_sources(PACTL_LIST_SOURCES_MULTI)
- monitor = next(s for s in sources if s.name == "alsa_output.pci-0000_00_1f.3.analog-stereo.monitor")
- assert monitor.monitor_of == "alsa_output.pci-0000_00_1f.3.analog-stereo"
- def test_monitor_of_sink_na_wird_zu_leerem_string(self) -> None:
- """'Monitor of Sink: n/a' muss als leerer String behandelt werden."""
- sources = PulseMicrophoneMix._parse_sources(PACTL_LIST_SOURCES_MULTI)
- yeti = next(s for s in sources if "Yeti" in s.name)
- assert yeti.monitor_of == ""
- def test_na_case_insensitive(self) -> None:
- """'N/A' (Grossbuchstaben) wird ebenfalls als leer behandelt."""
- sources = PulseMicrophoneMix._parse_sources(PACTL_LIST_SOURCES_NA_VARIANTEN)
- assert len(sources) == 3
- # Alle drei sollten monitor_of="" haben
- for s in sources:
- assert s.monitor_of == "", f"{s.name} hat monitor_of='{s.monitor_of}'"
- def test_monitor_of_sink_leer(self) -> None:
- """'Monitor of Sink:' mit leerem Wert -> leerer String."""
- sources = PulseMicrophoneMix._parse_sources(PACTL_LIST_SOURCES_NA_VARIANTEN)
- mic3 = next(s for s in sources if s.name == "mic3")
- assert mic3.monitor_of == ""
- def test_state_werte(self) -> None:
- """Verschiedene State-Werte korrekt parsen."""
- sources = PulseMicrophoneMix._parse_sources(PACTL_LIST_SOURCES_MULTI)
- states = {s.name: s.state for s in sources}
- assert states["alsa_input.usb-Blue_Yeti-00.analog-stereo"] == "IDLE"
- assert states["alsa_input.usb-Rode_NT_USB-00.analog-stereo"] == "RUNNING"
- def test_partieller_source_ohne_description(self) -> None:
- """Source ohne Description faellt auf Name zurueck."""
- sources = PulseMicrophoneMix._parse_sources(PACTL_LIST_SOURCES_PARTIAL)
- assert len(sources) == 1
- assert sources[0].description == "headset_mic" # Fallback auf Name
- def test_partieller_source_ohne_monitor_of(self) -> None:
- """Source ohne 'Monitor of Sink' hat leeren monitor_of."""
- sources = PulseMicrophoneMix._parse_sources(PACTL_LIST_SOURCES_PARTIAL)
- assert sources[0].monitor_of == ""
- def test_nur_whitespace(self) -> None:
- """Nur Leerzeichen/Tabs ergeben keine Sources."""
- assert PulseMicrophoneMix._parse_sources(" \n\t\n ") == []
- def test_ungueltiger_index(self) -> None:
- """Ungueltiger Index wird als -1 behandelt."""
- text = "Source #xyz\n\tName: defekt\n\tState: IDLE\n"
- sources = PulseMicrophoneMix._parse_sources(text)
- assert len(sources) == 1
- assert sources[0].index == -1
- def test_reihenfolge_bleibt_erhalten(self) -> None:
- """Sources werden in der Reihenfolge der Ausgabe zurueckgegeben."""
- sources = PulseMicrophoneMix._parse_sources(PACTL_LIST_SOURCES_MULTI)
- assert sources[0].index == 0
- assert sources[1].index == 1
- assert sources[2].index == 2
- assert sources[3].index == 128
- def test_nur_monitor_sources(self) -> None:
- """Ausgabe mit nur Monitor-Sources korrekt parsen."""
- sources = PulseMicrophoneMix._parse_sources(PACTL_LIST_SOURCES_NUR_MONITOR)
- assert len(sources) == 1
- assert sources[0].monitor_of == "alsa_output.pci"
- def test_sonderzeichen_in_description(self) -> None:
- """Sonderzeichen in der Beschreibung bleiben erhalten."""
- text = 'Source #5\n\tName: mic\n\tDescription: Headset (USB) & "Mikro"\n\tState: IDLE\n'
- sources = PulseMicrophoneMix._parse_sources(text)
- assert sources[0].description == 'Headset (USB) & "Mikro"'
- # =============================================================================
- # Tests — available() Lazy-Detection
- # =============================================================================
- class TestAvailable:
- """Tests fuer die Verfuegbarkeitserkennung."""
- @patch("trixy_core.audio.pulse_mic_mix.shutil.which", return_value=None)
- async def test_pactl_nicht_im_path(self, mock_which: MagicMock, mix: PulseMicrophoneMix) -> None:
- """Kein pactl im PATH -> nicht verfuegbar."""
- assert await mix.available() is False
- @patch("trixy_core.audio.pulse_mic_mix.shutil.which", return_value="/usr/bin/pactl")
- async def test_pactl_vorhanden_server_antwortet(self, mock_which: MagicMock, mix: PulseMicrophoneMix) -> None:
- """pactl + Server -> verfuegbar."""
- mix._run = AsyncMock(return_value=(0, "Server: PipeWire\n"))
- assert await mix.available() is True
- @patch("trixy_core.audio.pulse_mic_mix.shutil.which", return_value="/usr/bin/pactl")
- async def test_pactl_vorhanden_server_nicht_erreichbar(self, mock_which: MagicMock, mix: PulseMicrophoneMix) -> None:
- """pactl vorhanden, aber kein Server -> nicht verfuegbar."""
- mix._run = AsyncMock(return_value=(1, "Connection refused\n"))
- assert await mix.available() is False
- async def test_lazy_cache_true(self, mix: PulseMicrophoneMix) -> None:
- """Gecachter Wert True wird sofort zurueckgegeben."""
- mix._available = True
- assert await mix.available() is True
- async def test_lazy_cache_false(self, mix: PulseMicrophoneMix) -> None:
- """Gecachter Wert False wird sofort zurueckgegeben."""
- mix._available = False
- assert await mix.available() is False
- # =============================================================================
- # Tests — Properties
- # =============================================================================
- class TestProperties:
- """Tests fuer Properties und Initialisierung."""
- def test_default_sink_name(self, mix: PulseMicrophoneMix) -> None:
- """Standard Sink-Name ist 'trixy_mics_combined'."""
- assert mix.sink_name == "trixy_mics_combined"
- def test_custom_sink_name(self, mix_custom: PulseMicrophoneMix) -> None:
- """Benutzerdefinierter Sink-Name."""
- assert mix_custom.sink_name == "mein_mic_mix"
- def test_monitor_source_name(self, mix: PulseMicrophoneMix) -> None:
- """Monitor-Source-Name ist '{sink_name}.monitor'."""
- assert mix.monitor_source_name == "trixy_mics_combined.monitor"
- def test_monitor_source_name_custom(self, mix_custom: PulseMicrophoneMix) -> None:
- """Monitor-Source-Name mit benutzerdefiniertem Sink-Namen."""
- assert mix_custom.monitor_source_name == "mein_mic_mix.monitor"
- def test_active_ohne_null_sink(self, mix: PulseMicrophoneMix) -> None:
- """active ist False wenn kein Null-Sink-Modul geladen."""
- assert mix.active is False
- def test_active_mit_null_sink(self, mix: PulseMicrophoneMix) -> None:
- """active ist True wenn Null-Sink-Modul geladen."""
- mix._null_sink_module = 55
- assert mix.active is True
- def test_sources_ist_kopie(self, mix: PulseMicrophoneMix) -> None:
- """sources Property gibt eine Kopie zurueck."""
- mix._sources = ["a", "b"]
- kopie = mix.sources
- kopie.append("c")
- assert mix.sources == ["a", "b"]
- # =============================================================================
- # Tests — list_sources()
- # =============================================================================
- class TestListSources:
- """Tests fuer die Source-Auflistung mit Monitor-Filterung."""
- async def test_nicht_verfuegbar_ergibt_leere_liste(self, mix: PulseMicrophoneMix) -> None:
- """Nicht verfuegbar -> leere Liste."""
- mix._available = False
- result = await mix.list_sources()
- assert result == []
- async def test_pactl_fehler_ergibt_leere_liste(self, mix_verfuegbar: PulseMicrophoneMix) -> None:
- """pactl-Fehler -> leere Liste."""
- mix_verfuegbar._run = AsyncMock(return_value=(1, "error"))
- result = await mix_verfuegbar.list_sources()
- assert result == []
- async def test_ohne_monitors_nur_echte_mikrofone(self, mix_verfuegbar: PulseMicrophoneMix) -> None:
- """Standard: nur echte Mikrofone (keine Monitors)."""
- mix_verfuegbar._run = AsyncMock(return_value=(0, PACTL_LIST_SOURCES_MULTI))
- result = await mix_verfuegbar.list_sources(include_monitors=False)
- namen = [s.name for s in result]
- # Nur echte Mics (n/a monitor_of)
- assert "alsa_input.usb-Blue_Yeti-00.analog-stereo" in namen
- assert "alsa_input.usb-Rode_NT_USB-00.analog-stereo" in namen
- # Monitors sind weg
- assert "alsa_output.pci-0000_00_1f.3.analog-stereo.monitor" not in namen
- # Eigener Monitor ist ebenfalls weg
- assert "trixy_mics_combined.monitor" not in namen
- async def test_mit_monitors_alle_ausser_eigener(self, mix_verfuegbar: PulseMicrophoneMix) -> None:
- """include_monitors=True zeigt auch Monitor-Sources, aber nicht unseren eigenen."""
- mix_verfuegbar._run = AsyncMock(return_value=(0, PACTL_LIST_SOURCES_MULTI))
- result = await mix_verfuegbar.list_sources(include_monitors=True)
- namen = [s.name for s in result]
- # Fremder Monitor sichtbar
- assert "alsa_output.pci-0000_00_1f.3.analog-stereo.monitor" in namen
- # Eigener Monitor trotzdem nicht
- assert "trixy_mics_combined.monitor" not in namen
- async def test_eigener_monitor_mit_custom_name(self, mix_custom: PulseMicrophoneMix) -> None:
- """Eigener Monitor-Name haengt vom Sink-Namen ab."""
- mix_custom._available = True
- mix_custom._run = AsyncMock(return_value=(0, PACTL_LIST_SOURCES_MULTI))
- result = await mix_custom.list_sources(include_monitors=False)
- # "trixy_mics_combined.monitor" ist NICHT unser eigener (Name ist anders)
- # -> aber wegen monitor_of != "" auch gefiltert (wenn include_monitors=False)
- namen = [s.name for s in result]
- assert "trixy_mics_combined.monitor" not in namen
- # =============================================================================
- # Tests — find_existing_null_sink()
- # =============================================================================
- class TestFindExistingNullSink:
- """Tests fuer die Null-Sink-Modul-Suche."""
- async def test_null_sink_gefunden(self, mix_verfuegbar: PulseMicrophoneMix) -> None:
- """Bestehendes module-null-sink mit unserem Namen finden."""
- mix_verfuegbar._run = AsyncMock(return_value=(0, PACTL_MODULES_SHORT_NULL_SINK))
- result = await mix_verfuegbar.find_existing_null_sink()
- assert result == 55
- async def test_kein_null_sink_vorhanden(self, mix_verfuegbar: PulseMicrophoneMix) -> None:
- """Kein passendes Modul -> None."""
- mix_verfuegbar._run = AsyncMock(return_value=(0, PACTL_MODULES_SHORT_KEIN_NULL_SINK))
- result = await mix_verfuegbar.find_existing_null_sink()
- assert result is None
- async def test_pactl_fehler(self, mix_verfuegbar: PulseMicrophoneMix) -> None:
- """pactl-Fehler -> None."""
- mix_verfuegbar._run = AsyncMock(return_value=(1, "error"))
- result = await mix_verfuegbar.find_existing_null_sink()
- assert result is None
- async def test_anderer_sink_name_ignoriert(self, mix_custom: PulseMicrophoneMix) -> None:
- """Null-Sink mit anderem sink_name wird ignoriert."""
- mix_custom._available = True
- mix_custom._run = AsyncMock(return_value=(0, PACTL_MODULES_SHORT_NULL_SINK))
- result = await mix_custom.find_existing_null_sink()
- # sink_name=trixy_mics_combined != mein_mic_mix
- assert result is None
- # =============================================================================
- # Tests — _find_our_loopbacks()
- # =============================================================================
- class TestFindOurLoopbacks:
- """Tests fuer die Loopback-Modul-Suche."""
- async def test_loopbacks_gefunden(self, mix_verfuegbar: PulseMicrophoneMix) -> None:
- """Unsere Loopback-Module finden."""
- mix_verfuegbar._run = AsyncMock(return_value=(0, PACTL_MODULES_SHORT_NULL_SINK))
- result = await mix_verfuegbar._find_our_loopbacks()
- assert result == [60, 61]
- async def test_fremde_loopbacks_ignoriert(self, mix_verfuegbar: PulseMicrophoneMix) -> None:
- """Loopback mit anderem Sink wird nicht zurueckgegeben."""
- mix_verfuegbar._run = AsyncMock(return_value=(0, PACTL_MODULES_SHORT_NULL_SINK))
- result = await mix_verfuegbar._find_our_loopbacks()
- # Index 70 (sink=other_sink) darf nicht enthalten sein
- assert 70 not in result
- async def test_keine_loopbacks(self, mix_verfuegbar: PulseMicrophoneMix) -> None:
- """Keine Loopbacks vorhanden -> leere Liste."""
- mix_verfuegbar._run = AsyncMock(return_value=(0, PACTL_MODULES_SHORT_KEIN_NULL_SINK))
- result = await mix_verfuegbar._find_our_loopbacks()
- assert result == []
- async def test_pactl_fehler(self, mix_verfuegbar: PulseMicrophoneMix) -> None:
- """pactl-Fehler -> leere Liste."""
- mix_verfuegbar._run = AsyncMock(return_value=(1, "error"))
- result = await mix_verfuegbar._find_our_loopbacks()
- assert result == []
- # =============================================================================
- # Tests — setup()
- # =============================================================================
- class TestSetup:
- """Tests fuer den Mic-Mix-Aufbau."""
- async def test_setup_mit_zwei_sources(self, mix_verfuegbar: PulseMicrophoneMix) -> None:
- """Setup mit zwei Mikrofonen sollte erfolgreich sein."""
- mix_verfuegbar._run = AsyncMock(side_effect=[
- # teardown: restore_default_source -> nichts (kein previous)
- # teardown: _find_our_loopbacks
- (0, PACTL_MODULES_SHORT_KEIN_NULL_SINK),
- # teardown: find_existing_null_sink
- (0, PACTL_MODULES_SHORT_KEIN_NULL_SINK),
- # load-module null-sink
- (0, "55\n"),
- # load-module loopback 1
- (0, "60\n"),
- # load-module loopback 2
- (0, "61\n"),
- ])
- result = await mix_verfuegbar.setup(["mic_a", "mic_b"])
- assert result is True
- assert mix_verfuegbar._null_sink_module == 55
- assert mix_verfuegbar.sources == ["mic_a", "mic_b"]
- assert mix_verfuegbar._loopback_modules == [60, 61]
- async def test_setup_leere_sources(self, mix_verfuegbar: PulseMicrophoneMix) -> None:
- """Leere Source-Liste -> teardown + False."""
- mix_verfuegbar._run = AsyncMock(return_value=(0, ""))
- result = await mix_verfuegbar.setup([])
- assert result is False
- async def test_setup_eine_source(self, mix_verfuegbar: PulseMicrophoneMix) -> None:
- """Nur eine Source -> teardown + False (kein Mix noetig)."""
- mix_verfuegbar._run = AsyncMock(return_value=(0, ""))
- result = await mix_verfuegbar.setup(["nur_ein_mic"])
- assert result is False
- async def test_setup_nicht_verfuegbar(self, mix: PulseMicrophoneMix) -> None:
- """Setup ohne verfuegbares pactl -> False."""
- mix._available = False
- result = await mix.setup(["a", "b"])
- assert result is False
- async def test_setup_null_sink_fehler(self, mix_verfuegbar: PulseMicrophoneMix) -> None:
- """Null-Sink-Erstellung schlaegt fehl -> False."""
- mix_verfuegbar._run = AsyncMock(side_effect=[
- (0, PACTL_MODULES_SHORT_KEIN_NULL_SINK), # teardown: loopbacks
- (0, PACTL_MODULES_SHORT_KEIN_NULL_SINK), # teardown: null-sink
- (1, "Fehler!"), # load-module null-sink
- ])
- result = await mix_verfuegbar.setup(["a", "b"])
- assert result is False
- async def test_setup_loopback_teilweise_fehlgeschlagen(self, mix_verfuegbar: PulseMicrophoneMix) -> None:
- """Wenn ein Loopback fehlschlaegt, die anderen trotzdem nutzen."""
- mix_verfuegbar._run = AsyncMock(side_effect=[
- (0, PACTL_MODULES_SHORT_KEIN_NULL_SINK), # teardown: loopbacks
- (0, PACTL_MODULES_SHORT_KEIN_NULL_SINK), # teardown: null-sink
- (0, "55\n"), # load-module null-sink
- (1, "Fehler"), # loopback 1 fehlgeschlagen
- (0, "61\n"), # loopback 2 erfolgreich
- ])
- result = await mix_verfuegbar.setup(["mic_a", "mic_b"])
- assert result is True
- assert len(mix_verfuegbar._loopback_modules) == 1
- assert mix_verfuegbar.sources == ["mic_b"]
- async def test_setup_alle_loopbacks_fehlgeschlagen(self, mix_verfuegbar: PulseMicrophoneMix) -> None:
- """Wenn alle Loopbacks fehlschlagen -> teardown + False."""
- mix_verfuegbar._run = AsyncMock(side_effect=[
- (0, PACTL_MODULES_SHORT_KEIN_NULL_SINK), # teardown: loopbacks
- (0, PACTL_MODULES_SHORT_KEIN_NULL_SINK), # teardown: null-sink
- (0, "55\n"), # load-module null-sink
- (1, "Fehler"), # loopback 1 fehl
- (1, "Fehler"), # loopback 2 fehl
- # teardown nach Fehlschlag:
- (0, PACTL_MODULES_SHORT_KEIN_NULL_SINK), # loopbacks
- (0, PACTL_MODULES_SHORT_NULL_SINK), # find null-sink
- (0, ""), # unload null-sink
- ])
- result = await mix_verfuegbar.setup(["mic_a", "mic_b"])
- assert result is False
- async def test_setup_null_sink_fallback_find(self, mix_verfuegbar: PulseMicrophoneMix) -> None:
- """Wenn Null-Sink-Index nicht parsbar -> find_existing_null_sink nutzen."""
- mix_verfuegbar._run = AsyncMock(side_effect=[
- (0, PACTL_MODULES_SHORT_KEIN_NULL_SINK), # teardown: loopbacks
- (0, PACTL_MODULES_SHORT_KEIN_NULL_SINK), # teardown: null-sink
- (0, "nicht_eine_zahl\n"), # load-module (unparsbarer Output)
- (0, PACTL_MODULES_SHORT_NULL_SINK), # find_existing_null_sink
- (0, "60\n"), # loopback 1
- (0, "61\n"), # loopback 2
- ])
- result = await mix_verfuegbar.setup(["mic_a", "mic_b"])
- assert result is True
- assert mix_verfuegbar._null_sink_module == 55
- # =============================================================================
- # Tests — teardown()
- # =============================================================================
- class TestTeardown:
- """Tests fuer den Mic-Mix-Abbau."""
- async def test_teardown_mit_bekannten_modulen(self, mix_verfuegbar: PulseMicrophoneMix) -> None:
- """Teardown mit bekannten Modul-Indizes entlaedt alles."""
- mix_verfuegbar._null_sink_module = 55
- mix_verfuegbar._loopback_modules = [60, 61]
- mix_verfuegbar._sources = ["a", "b"]
- mix_verfuegbar._run = AsyncMock(return_value=(0, ""))
- await mix_verfuegbar.teardown()
- assert mix_verfuegbar._null_sink_module is None
- assert mix_verfuegbar._loopback_modules == []
- assert mix_verfuegbar.sources == []
- async def test_teardown_sucht_loopbacks_wenn_cache_leer(self, mix_verfuegbar: PulseMicrophoneMix) -> None:
- """Teardown ohne gecachte Loopbacks sucht per _find_our_loopbacks."""
- mix_verfuegbar._null_sink_module = 55
- mix_verfuegbar._loopback_modules = []
- mix_verfuegbar._run = AsyncMock(side_effect=[
- (0, PACTL_MODULES_SHORT_NULL_SINK), # _find_our_loopbacks
- (0, ""), # unload loopback 60
- (0, ""), # unload loopback 61
- (0, ""), # unload null-sink 55
- ])
- await mix_verfuegbar.teardown()
- async def test_teardown_sucht_null_sink_wenn_unbekannt(self, mix_verfuegbar: PulseMicrophoneMix) -> None:
- """Teardown ohne Null-Sink-Index sucht per find_existing_null_sink."""
- mix_verfuegbar._null_sink_module = None
- mix_verfuegbar._loopback_modules = []
- mix_verfuegbar._run = AsyncMock(side_effect=[
- (0, PACTL_MODULES_SHORT_KEIN_NULL_SINK), # _find_our_loopbacks
- (0, PACTL_MODULES_SHORT_NULL_SINK), # find_existing_null_sink
- (0, ""), # unload null-sink
- ])
- await mix_verfuegbar.teardown()
- async def test_teardown_nicht_verfuegbar(self, mix: PulseMicrophoneMix) -> None:
- """Teardown ohne verfuegbares pactl -> sofortiger Return."""
- mix._available = False
- mix._run = AsyncMock()
- await mix.teardown()
- mix._run.assert_not_called()
- async def test_teardown_stellt_default_source_wieder_her(self, mix_verfuegbar: PulseMicrophoneMix) -> None:
- """Teardown ruft restore_default_source vor dem Entladen auf."""
- mix_verfuegbar._null_sink_module = 55
- mix_verfuegbar._loopback_modules = []
- mix_verfuegbar._previous_default = "alter_mic"
- mix_verfuegbar._run = AsyncMock(side_effect=[
- # restore_default_source: get_default_source
- (0, "trixy_mics_combined.monitor\n"),
- # restore_default_source: set_default_source
- (0, ""),
- # _find_our_loopbacks
- (0, PACTL_MODULES_SHORT_KEIN_NULL_SINK),
- # unload null-sink
- (0, ""),
- ])
- await mix_verfuegbar.teardown()
- assert mix_verfuegbar._previous_default is None
- # =============================================================================
- # Tests — Default-Source-Verwaltung
- # =============================================================================
- class TestDefaultSource:
- """Tests fuer get/set/make/restore Default-Source."""
- async def test_get_default_source(self, mix_verfuegbar: PulseMicrophoneMix) -> None:
- """Default-Source-Name lesen."""
- mix_verfuegbar._run = AsyncMock(return_value=(0, "alsa_input.usb\n"))
- result = await mix_verfuegbar.get_default_source()
- assert result == "alsa_input.usb"
- async def test_get_default_source_nicht_verfuegbar(self, mix: PulseMicrophoneMix) -> None:
- """Nicht verfuegbar -> leerer String."""
- mix._available = False
- result = await mix.get_default_source()
- assert result == ""
- async def test_get_default_source_fehler(self, mix_verfuegbar: PulseMicrophoneMix) -> None:
- """pactl-Fehler -> leerer String."""
- mix_verfuegbar._run = AsyncMock(return_value=(1, ""))
- result = await mix_verfuegbar.get_default_source()
- assert result == ""
- async def test_get_default_source_leere_ausgabe(self, mix_verfuegbar: PulseMicrophoneMix) -> None:
- """Leere Ausgabe -> leerer String."""
- mix_verfuegbar._run = AsyncMock(return_value=(0, ""))
- result = await mix_verfuegbar.get_default_source()
- assert result == ""
- async def test_set_default_source_erfolg(self, mix_verfuegbar: PulseMicrophoneMix) -> None:
- """Default-Source setzen mit Erfolg."""
- mix_verfuegbar._run = AsyncMock(return_value=(0, ""))
- result = await mix_verfuegbar.set_default_source("my_mic")
- assert result is True
- async def test_set_default_source_fehler(self, mix_verfuegbar: PulseMicrophoneMix) -> None:
- """Default-Source setzen schlaegt fehl."""
- mix_verfuegbar._run = AsyncMock(return_value=(1, "error"))
- result = await mix_verfuegbar.set_default_source("my_mic")
- assert result is False
- async def test_make_default_ohne_null_sink(self, mix_verfuegbar: PulseMicrophoneMix) -> None:
- """make_default ohne Null-Sink-Modul -> False."""
- mix_verfuegbar._null_sink_module = None
- result = await mix_verfuegbar.make_default()
- assert result is False
- async def test_make_default_merkt_vorherigen(self, mix_verfuegbar: PulseMicrophoneMix) -> None:
- """make_default speichert den vorherigen Default-Source."""
- mix_verfuegbar._null_sink_module = 55
- mix_verfuegbar._run = AsyncMock(side_effect=[
- (0, "alter_mic\n"), # get_default_source
- (0, ""), # set_default_source
- ])
- result = await mix_verfuegbar.make_default()
- assert result is True
- assert mix_verfuegbar._previous_default == "alter_mic"
- async def test_make_default_ueberschreibt_nicht_bei_reload(self, mix_verfuegbar: PulseMicrophoneMix) -> None:
- """Wenn aktuell schon unser Monitor -> previous nicht ueberschreiben."""
- mix_verfuegbar._null_sink_module = 55
- mix_verfuegbar._previous_default = "alter_mic"
- mix_verfuegbar._run = AsyncMock(side_effect=[
- (0, "trixy_mics_combined.monitor\n"), # get_default_source -> schon unser
- (0, ""), # set_default_source
- ])
- await mix_verfuegbar.make_default()
- assert mix_verfuegbar._previous_default == "alter_mic"
- async def test_restore_default_source_erfolgreich(self, mix_verfuegbar: PulseMicrophoneMix) -> None:
- """Vorherigen Default-Source erfolgreich wiederherstellen."""
- mix_verfuegbar._previous_default = "alter_mic"
- mix_verfuegbar._run = AsyncMock(side_effect=[
- (0, "trixy_mics_combined.monitor\n"), # get_default_source
- (0, ""), # set_default_source
- ])
- await mix_verfuegbar.restore_default_source()
- assert mix_verfuegbar._previous_default is None
- async def test_restore_default_source_kein_vorheriger(self, mix_verfuegbar: PulseMicrophoneMix) -> None:
- """Kein vorheriger Default -> nichts tun."""
- mix_verfuegbar._previous_default = None
- mix_verfuegbar._run = AsyncMock()
- await mix_verfuegbar.restore_default_source()
- mix_verfuegbar._run.assert_not_called()
- async def test_restore_default_source_anderer_ist_default(self, mix_verfuegbar: PulseMicrophoneMix) -> None:
- """Wenn aktuell nicht unser Monitor -> nicht wiederherstellen."""
- mix_verfuegbar._previous_default = "alter_mic"
- mix_verfuegbar._run = AsyncMock(return_value=(0, "anderer_mic\n"))
- await mix_verfuegbar.restore_default_source()
- # previous_default wird trotzdem geloescht
- assert mix_verfuegbar._previous_default is None
|