Files
voculinux/tests/test_pulse_sources.py
T
xavierk 71b97fe633
CI / security (pull_request) Successful in 1m10s
CI / lint (pull_request) Successful in 23s
CI / ai-review (pull_request) Successful in 25s
fix(audio): never record from another microphone when one is configured
A configured device that was virtual, output-only, renamed or unplugged
was replaced by the hardware default input, with only a log warning. The
capture source now refuses an explicit selection that cannot be used as
configured: it raises AudioDeviceUnavailableError, skips reconnection
(retrying cannot help), and reports it through the desktop notification
path. System Default (unset, or -1) keeps its implicit default.

Behaviour change: an unplugged or renamed configured microphone now fails
visibly instead of recording from the default input. Pulse source
selection is unchanged.

Fixes #42
2026-11-06 23:58:15 +05:30

743 lines
30 KiB
Python

"""
Tests for snap microphone selection through PulseAudio sources (issue #1011)
and the snap's CPU-only whisper.cpp reporting.
"""
import importlib
import json
import os
import subprocess
import sys
import threading
from types import ModuleType
from typing import Any, Callable, Dict, List, Optional, Tuple
from unittest.mock import MagicMock, patch
import pytest
from vocalinux.audio.capture import PortAudioCaptureSource, get_audio_input_devices
from vocalinux.audio.capture import test_audio_input as _run_test_audio_input
from vocalinux.audio.pipewire import (
PIPEWIRE_INDEX_BASE,
PIPEWIRE_INDEX_FLOOR,
is_pipewire_device_index,
)
from vocalinux.audio.pulse_sources import (
PULSE_INDEX_BASE,
PULSE_SOURCE_ENV,
PulseSource,
_parse_pactl_json,
_parse_pactl_text,
_run_pactl,
get_pulse_sources,
is_pulse_device_index,
pulse_label_for_name,
pulse_source_env,
resolve_pulse_source,
)
from vocalinux.utils import whispercpp_model_info
from vocalinux.utils.snap import SNAP_GPU_NOTE, running_in_snap
SNAP_ENV = {"SNAP": "/snap/voculinux/42", "SNAP_NAME": "voculinux"}
PACTL_JSON = [
{
"index": 52,
"name": "alsa_input.pci-0000_00_1f.3.analog-stereo",
"description": "Built-in Audio Analog Stereo",
"monitor_of_sink": None,
"properties": {"device.class": "sound"},
},
{
"index": 51,
"name": "alsa_output.pci-0000_00_1f.3.analog-stereo.monitor",
"description": "Monitor of Built-in Audio Analog Stereo",
"monitor_of_sink": "alsa_output.pci-0000_00_1f.3.analog-stereo",
"properties": {"device.class": "monitor"},
},
{
"index": 60,
"name": "alsa_input.usb-Blue_Yeti-00.analog-stereo",
"description": "Yeti Stereo Microphone",
"monitor_of_sink": "n/a",
"properties": {"device.class": "sound"},
},
{
"index": 70,
"name": "bluez_output.AA_BB.1.monitor",
"description": "Headset monitor",
"properties": {},
},
]
PACTL_TEXT = """Source #51
\tState: SUSPENDED
\tName: alsa_output.pci-0000_00_1f.3.analog-stereo.monitor
\tDescription: Monitor of Built-in Audio Analog Stereo
\tDriver: PipeWire
\tMonitor of Sink: alsa_output.pci-0000_00_1f.3.analog-stereo
\tProperties:
\t\tdevice.class = "monitor"
Source #52
\tState: RUNNING
\tName: alsa_input.pci-0000_00_1f.3.analog-stereo
\tDescription: Built-in Audio Analog Stereo
\tDriver: PipeWire
\tMonitor of Sink: n/a
\tProperties:
\t\tdevice.description = "Built-in Audio Analog Stereo"
\t\tdevice.class = "sound"
"""
CommandResult = Tuple[int, str, str]
def _runner(
responses: Dict[str, CommandResult], calls: Optional[List[List[str]]] = None
) -> Callable[[List[str]], CommandResult]:
"""Return a command runner answering by joined argv."""
def run(args: List[str]) -> CommandResult:
if calls is not None:
calls.append(args)
return responses[" ".join(args)]
return run
def _have_pactl(name: str) -> Optional[str]:
return "/snap/voculinux/42/usr/bin/pactl" if name == "pactl" else None
class TestPactlParsing:
def test_json_skips_monitors_by_sink_class_and_suffix(self) -> None:
sources = _parse_pactl_json(json.dumps(PACTL_JSON))
assert sources is not None
assert [s.name for s in sources] == [
"alsa_input.pci-0000_00_1f.3.analog-stereo",
"alsa_input.usb-Blue_Yeti-00.analog-stereo",
]
def test_json_rejects_non_json(self) -> None:
assert _parse_pactl_json("Source #1\n") is None
assert _parse_pactl_json('{"not": "a list"}') is None
def test_text_skips_monitors(self) -> None:
sources = _parse_pactl_text(PACTL_TEXT)
assert sources == [
PulseSource(
name="alsa_input.pci-0000_00_1f.3.analog-stereo",
description="Built-in Audio Analog Stereo",
index=52,
)
]
class TestPactlEdgeCases:
def test_json_tolerates_malformed_entries(self) -> None:
entries = [
"not a dict",
{"description": "no name"},
{"index": "x", "name": "mic_a", "description": "Mic", "monitor_of_sink": ""},
{"index": 3, "name": "mic_b", "monitor_of_sink": "speakers"},
]
assert _parse_pactl_json(json.dumps({"not": "a list"})) is None
sources = _parse_pactl_json(json.dumps(entries))
assert sources == [PulseSource(name="mic_a", description="Mic", index=0)]
def test_text_tolerates_preamble_nameless_and_bad_index_blocks(self) -> None:
text = (
"stray line before any source\n"
"Source #x\n\tName: mic_a\n\tDescription: Mic\n\tMonitor of Sink: n/a\n"
"Source #7\n\tDescription: no name\n"
)
assert _parse_pactl_text(text) == [PulseSource(name="mic_a", description="Mic", index=0)]
def test_default_lookup_failure_still_lists_sources(self) -> None:
def runner(args: List[str]) -> CommandResult:
if args[-1] == "get-default-source":
raise subprocess.TimeoutExpired(args, 3)
return 0, json.dumps(PACTL_JSON), ""
sources = get_pulse_sources(runner=runner, which=_have_pactl, environ=SNAP_ENV)
assert len(sources) == 2
assert not any(source.is_default for source in sources)
def test_run_pactl_uses_the_c_locale_and_host_env(self) -> None:
completed = subprocess.CompletedProcess(["pactl"], 0, stdout="ok", stderr=None)
with patch("vocalinux.audio.pulse_sources.subprocess.run", return_value=completed) as run:
assert _run_pactl(["pactl", "info"]) == (0, "ok", "")
assert run.call_args.kwargs["env"]["LC_ALL"] == "C"
class TestGetPulseSources:
def test_inert_outside_snap(self) -> None:
runner = MagicMock()
assert get_pulse_sources(runner=runner, which=_have_pactl, environ={}) == []
runner.assert_not_called()
def test_missing_pactl_returns_nothing(self) -> None:
runner = MagicMock()
assert get_pulse_sources(runner=runner, which=lambda _n: None, environ=SNAP_ENV) == []
runner.assert_not_called()
def test_lists_sources_in_server_order_and_flags_default(self) -> None:
runner = _runner(
{
"pactl -f json list sources": (0, json.dumps(PACTL_JSON), ""),
"pactl get-default-source": (0, "alsa_input.usb-Blue_Yeti-00.analog-stereo\n", ""),
}
)
sources = get_pulse_sources(runner=runner, which=_have_pactl, environ=SNAP_ENV)
assert [(s.display_name, s.is_default) for s in sources] == [
("Built-in Audio Analog Stereo", False),
("Yeti Stereo Microphone", True),
]
def test_falls_back_to_text_when_json_is_unsupported(self) -> None:
calls: List[List[str]] = []
runner = _runner(
{
"pactl -f json list sources": (1, "", "pactl: invalid option -- 'f'"),
"pactl list sources": (0, PACTL_TEXT, ""),
"pactl get-default-source": (1, "", ""),
},
calls,
)
sources = get_pulse_sources(runner=runner, which=_have_pactl, environ=SNAP_ENV)
assert [s.name for s in sources] == ["alsa_input.pci-0000_00_1f.3.analog-stereo"]
assert ["pactl", "list", "sources"] in calls
def test_shared_descriptions_are_disambiguated(self) -> None:
duplicate = [
{"index": 2, "name": "mic_b", "description": "USB Audio"},
{"index": 1, "name": "mic_a", "description": "USB Audio"},
]
runner = _runner(
{
"pactl -f json list sources": (0, json.dumps(duplicate), ""),
"pactl get-default-source": (0, "mic_a", ""),
}
)
sources = get_pulse_sources(runner=runner, which=_have_pactl, environ=SNAP_ENV)
assert [s.display_name for s in sources] == ["USB Audio (mic_a)", "USB Audio (mic_b)"]
def test_server_errors_return_nothing(self) -> None:
def broken(_args: List[str]) -> CommandResult:
raise OSError("connection refused")
assert get_pulse_sources(runner=broken, which=_have_pactl, environ=SNAP_ENV) == []
class TestIndexRanges:
def test_pulse_range_sits_below_pipewire(self) -> None:
assert PULSE_INDEX_BASE < PIPEWIRE_INDEX_FLOOR <= PIPEWIRE_INDEX_BASE
assert is_pulse_device_index(PULSE_INDEX_BASE)
assert is_pulse_device_index(PULSE_INDEX_BASE - 3)
assert not is_pipewire_device_index(PULSE_INDEX_BASE)
assert is_pipewire_device_index(PIPEWIRE_INDEX_FLOOR)
assert not is_pulse_device_index(PIPEWIRE_INDEX_FLOOR)
@pytest.mark.parametrize("value", [None, True, -1, 0, 3, PIPEWIRE_INDEX_BASE])
def test_other_indices_are_not_pulse(self, value: Optional[int]) -> None:
assert not is_pulse_device_index(value)
class TestResolvePulseSource:
sources = [
PulseSource(name="mic_a", description="Built-in", index=1, label="Built-in"),
PulseSource(name="mic_b", description="Yeti", index=2, label="Yeti"),
]
def test_resolves_by_name_even_when_the_position_moved(self) -> None:
assert resolve_pulse_source(PULSE_INDEX_BASE, "Yeti", self.sources) is self.sources[1]
def test_resolves_by_position_without_a_name(self) -> None:
resolved = resolve_pulse_source(PULSE_INDEX_BASE - 1, None, self.sources)
assert resolved is self.sources[1]
def test_gone_device_does_not_guess(self) -> None:
assert resolve_pulse_source(PULSE_INDEX_BASE, "Unplugged", self.sources) is None
assert resolve_pulse_source(PULSE_INDEX_BASE - 9, None, self.sources) is None
def test_non_pulse_index_is_ignored(self) -> None:
assert resolve_pulse_source(0, "Yeti", self.sources) is None
def test_resolves_by_server_name_before_the_label(self) -> None:
assert resolve_pulse_source(PULSE_INDEX_BASE, "mic_b", self.sources) is self.sources[1]
def _usb_listing(*names: str) -> List[PulseSource]:
"""List same-description microphones the way pactl reports them."""
entries = [
{"index": position, "name": name, "description": "USB Audio"}
for position, name in enumerate(names, start=1)
]
runner = _runner(
{
"pactl -f json list sources": (0, json.dumps(entries), ""),
"pactl get-default-source": (0, names[0], ""),
}
)
return get_pulse_sources(runner=runner, which=_have_pactl, environ=SNAP_ENV)
class TestSavedChoiceSurvivesLabelChanges:
"""Unplugging one of two same-named microphones relabels the other (#1018 review)."""
def test_label_changes_when_the_twin_is_unplugged(self) -> None:
before = _usb_listing("mic_a", "mic_b")
after = _usb_listing("mic_a")
assert before[0].display_name == "USB Audio (mic_a)"
assert after[0].display_name == "USB Audio"
def test_saved_server_name_still_resolves_after_unplug(self) -> None:
before = _usb_listing("mic_a", "mic_b")
assert before[0].display_name == "USB Audio (mic_a)"
saved = before[0].name # what Settings stores for that entry
after = _usb_listing("mic_a")
resolved = resolve_pulse_source(PULSE_INDEX_BASE, saved, after)
assert resolved is not None and resolved.name == "mic_a"
assert pulse_label_for_name(saved, after) == "USB Audio"
def test_helpers_return_none_for_unknown_or_empty_input(self) -> None:
sources = _usb_listing("mic_a")
assert pulse_label_for_name(None, sources) is None
assert pulse_label_for_name("mic_z", sources) is None
def test_helpers_query_the_server_when_no_listing_is_given(self) -> None:
sources = _usb_listing("mic_a")
with patch("vocalinux.audio.pulse_sources.get_pulse_sources", return_value=sources):
assert pulse_label_for_name("mic_a") == "USB Audio"
class TestPulseSourceEnv:
def test_sets_and_removes(self) -> None:
with patch.dict(os.environ, {}, clear=False):
os.environ.pop(PULSE_SOURCE_ENV, None)
with pulse_source_env("mic_b"):
assert os.environ[PULSE_SOURCE_ENV] == "mic_b"
assert PULSE_SOURCE_ENV not in os.environ
def test_restores_previous_value(self) -> None:
with patch.dict(os.environ, {PULSE_SOURCE_ENV: "user_choice"}):
with pulse_source_env("mic_b"):
assert os.environ[PULSE_SOURCE_ENV] == "mic_b"
assert os.environ[PULSE_SOURCE_ENV] == "user_choice"
def test_none_leaves_environment_alone(self) -> None:
with patch.dict(os.environ, {PULSE_SOURCE_ENV: "user_choice"}):
with pulse_source_env(None):
assert os.environ[PULSE_SOURCE_ENV] == "user_choice"
def test_snap_opens_are_serialized_across_threads(self) -> None:
"""A System Default open never sees another thread's PULSE_SOURCE (#1018 review)."""
inside_first = threading.Event()
release_first = threading.Event()
seen: Dict[str, Optional[str]] = {}
def choose_mic() -> None:
with pulse_source_env("mic_b"):
inside_first.set()
release_first.wait(timeout=5)
def open_default() -> None:
with pulse_source_env(None):
seen["default"] = os.environ.get(PULSE_SOURCE_ENV)
with patch.dict(os.environ, SNAP_ENV):
os.environ.pop(PULSE_SOURCE_ENV, None)
first = threading.Thread(target=choose_mic)
first.start()
assert inside_first.wait(timeout=5)
second = threading.Thread(target=open_default)
second.start()
second.join(timeout=0.2)
assert second.is_alive(), "System Default open ran inside another open's scope"
release_first.set()
first.join(timeout=5)
second.join(timeout=5)
leaked = os.environ.get(PULSE_SOURCE_ENV)
assert seen == {"default": None}
assert leaked is None
def test_overlapping_scopes_always_restore_the_environment(self) -> None:
barrier = threading.Barrier(8)
wrong: List[str] = []
def open_with(name: str) -> None:
barrier.wait(timeout=5)
for _ in range(50):
with pulse_source_env(name):
if os.environ.get(PULSE_SOURCE_ENV) != name:
wrong.append(name)
with patch.dict(os.environ, SNAP_ENV):
os.environ.pop(PULSE_SOURCE_ENV, None)
threads = [threading.Thread(target=open_with, args=(f"mic_{n % 2}",)) for n in range(8)]
for thread in threads:
thread.start()
for thread in threads:
thread.join(timeout=10)
leaked = os.environ.get(PULSE_SOURCE_ENV)
assert wrong == []
assert leaked is None
def _pyaudio_with_one_mic(name: str = "default") -> Tuple[MagicMock, MagicMock]:
mock_audio = MagicMock()
mock_audio.get_device_count.return_value = 1
mock_audio.get_default_input_device_info.return_value = {"index": 0, "name": name}
mock_audio.get_device_info_by_index.return_value = {
"index": 0,
"name": name,
"maxInputChannels": 32,
}
return mock_audio, MagicMock(PyAudio=MagicMock(return_value=mock_audio))
class TestCaptureIntegration:
sources = [
PulseSource(
name="mic_a", description="Built-in", index=1, is_default=True, label="Built-in"
),
PulseSource(name="mic_b", description="Yeti", index=2, label="Yeti"),
]
def test_device_list_appends_pulse_sources(self) -> None:
_audio, mock_pyaudio = _pyaudio_with_one_mic()
with (
patch.dict(sys.modules, {"pyaudio": mock_pyaudio}),
patch("vocalinux.audio.capture.get_system_audio_sources", return_value=[]),
patch("vocalinux.audio.capture.get_pulse_sources", return_value=self.sources),
):
devices = get_audio_input_devices()
assert devices == [
(PULSE_INDEX_BASE, "Built-in", True),
(PULSE_INDEX_BASE - 1, "Yeti", False),
]
def test_device_list_reuses_a_listing_it_is_given(self) -> None:
_audio, mock_pyaudio = _pyaudio_with_one_mic()
with (
patch.dict(sys.modules, {"pyaudio": mock_pyaudio}),
patch("vocalinux.audio.capture.get_system_audio_sources", return_value=[]),
patch("vocalinux.audio.capture.get_pulse_sources") as listing,
):
devices = get_audio_input_devices(pulse_sources=self.sources[1:])
listing.assert_not_called()
assert devices == [(PULSE_INDEX_BASE, "Yeti", False)]
def _open_recording_env(
self,
device_index: int,
device_name: str,
sources: List[PulseSource],
mic_name: str = "default",
) -> Tuple[Dict[str, object], Optional[str]]:
mock_audio, mock_pyaudio = _pyaudio_with_one_mic(mic_name)
seen: Dict[str, object] = {}
def fake_open(audio: Any, index: Optional[int]) -> Tuple[int, int, MagicMock]:
seen["index"] = index
seen["env"] = os.environ.get(PULSE_SOURCE_ENV)
return 1, 16000, MagicMock()
with (
patch.dict(sys.modules, {"pyaudio": mock_pyaudio}),
patch.dict(os.environ, {}, clear=False),
patch("vocalinux.audio.pulse_sources.get_pulse_sources", return_value=sources),
patch("vocalinux.audio.capture._open_capture_stream", side_effect=fake_open),
):
os.environ.pop(PULSE_SOURCE_ENV, None)
capture = PortAudioCaptureSource(device_index=device_index, device_name=device_name)
capture.open(mock_audio)
leaked = os.environ.get(PULSE_SOURCE_ENV)
return seen, leaked
def test_open_routes_the_default_device_to_the_chosen_source(self) -> None:
seen, leaked = self._open_recording_env(PULSE_INDEX_BASE - 1, "Yeti", self.sources)
assert seen == {"index": None, "env": "mic_b"}
assert leaked is None
def test_open_falls_back_to_default_when_the_source_is_gone(self) -> None:
seen, leaked = self._open_recording_env(PULSE_INDEX_BASE, "Unplugged", self.sources)
assert seen == {"index": None, "env": None}
assert leaked is None
def test_portaudio_selection_is_untouched(self) -> None:
# A real configured microphone; an explicit virtual "default" is now refused (#42).
seen, _leaked = self._open_recording_env(
0, "USB Microphone", self.sources, mic_name="USB Microphone"
)
assert seen["env"] is None
def test_audio_test_uses_the_chosen_source(self) -> None:
mock_audio, mock_pyaudio = _pyaudio_with_one_mic()
stream = MagicMock()
stream.get_read_available.return_value = 1024
stream.read.return_value = b"\x00\x10" * 1024
seen: Dict[str, object] = {}
def fake_open(audio: Any, index: Optional[int]) -> Tuple[int, int, MagicMock]:
seen["index"] = index
seen["env"] = os.environ.get(PULSE_SOURCE_ENV)
return 1, 16000, stream
with (
patch.dict(sys.modules, {"pyaudio": mock_pyaudio}),
patch("vocalinux.audio.pulse_sources.get_pulse_sources", return_value=self.sources),
patch("vocalinux.audio.capture._open_capture_stream", side_effect=fake_open),
):
result = _run_test_audio_input(device_index=PULSE_INDEX_BASE - 1, duration=0.1)
assert seen == {"index": None, "env": "mic_b"}
assert result["success"] is True
assert result["device_name"] == "Yeti"
class TestSnapGpuReporting:
def setup_method(self) -> None:
whispercpp_model_info.detect_compute_backend.cache_clear()
def teardown_method(self) -> None:
whispercpp_model_info.detect_compute_backend.cache_clear()
def test_running_in_snap(self) -> None:
assert running_in_snap(SNAP_ENV)
assert running_in_snap({"SNAP": "/snap/voculinux/42"})
assert not running_in_snap({})
def test_snap_reports_cpu_without_probing_host_gpu_tools(
self, caplog: pytest.LogCaptureFixture
) -> None:
caplog.set_level("INFO", logger="vocalinux.utils.whispercpp_model_info")
with (
patch.dict(os.environ, SNAP_ENV),
patch.object(whispercpp_model_info, "detect_vulkan_support") as vulkan,
patch.object(whispercpp_model_info, "detect_cuda_support") as cuda,
):
backend, _info = whispercpp_model_info.detect_compute_backend()
assert backend == whispercpp_model_info.ComputeBackend.CPU
vulkan.assert_not_called()
cuda.assert_not_called()
assert SNAP_GPU_NOTE in caplog.text
def test_note_points_at_builds_that_have_a_gpu_backend(self) -> None:
assert "CPU" in SNAP_GPU_NOTE
assert "AppImage" in SNAP_GPU_NOTE and "install.sh" in SNAP_GPU_NOTE
def test_settings_subtitle_explains_the_snap(self) -> None:
settings_dialog = _load_settings_dialog()
with (
patch.dict(os.environ, SNAP_ENV),
patch(
"vocalinux.speech_recognition.recognition_manager"
".detect_pywhispercpp_gpu_backend"
) as probe,
):
subtitle = settings_dialog.SettingsDialog._gpu_acceleration_subtitle(MagicMock())
assert subtitle == SNAP_GPU_NOTE
probe.assert_not_called()
class TestSettingsPersistsServerName:
"""Settings saves the stable source name and finds it again after relabels."""
def test_selection_after_unplug_matches_the_relabelled_source(self) -> None:
settings_dialog = _load_settings_dialog()
after = _usb_listing("mic_a")
devices = [(0, "Webcam", False), (PULSE_INDEX_BASE, "USB Audio", True)]
with patch("vocalinux.audio.pulse_sources.get_pulse_sources") as listing:
matched = settings_dialog._resolve_audio_device_selection(
devices, PULSE_INDEX_BASE - 1, "mic_a", after
)
assert matched == PULSE_INDEX_BASE
listing.assert_not_called()
def test_gone_source_is_not_matched_by_its_old_position(self) -> None:
settings_dialog = _load_settings_dialog()
devices = [(PULSE_INDEX_BASE, "USB Audio", True)] # now mic_b
matched = settings_dialog._resolve_audio_device_selection(
devices, PULSE_INDEX_BASE, "mic_a", _usb_listing("mic_b")
)
assert matched is None
def test_non_pulse_selection_keeps_label_matching(self) -> None:
settings_dialog = _load_settings_dialog()
devices = [(0, "Webcam", False), (1, "Yeti", False)]
with patch.object(settings_dialog, "pulse_label_for_name") as lookup:
matched = settings_dialog._resolve_audio_device_selection(devices, 3, "Yeti")
assert matched == 1
lookup.assert_not_called()
def _choose(
self,
settings_dialog: ModuleType,
device_id: str,
label: str,
names: Dict[int, str],
) -> MagicMock:
dialog = MagicMock(_initializing=False, _pulse_source_names=names)
dialog.audio_device_combo.get_active_id.return_value = device_id
dialog.audio_device_combo.get_active_text.return_value = label
# Choosing a microphone runs on the GTK thread: it must not query the server.
with (
patch.object(settings_dialog, "get_pulse_sources") as listing,
patch("vocalinux.audio.pulse_sources.get_pulse_sources") as module_listing,
):
settings_dialog.SettingsDialog._on_audio_device_changed(dialog, None)
listing.assert_not_called()
module_listing.assert_not_called()
return dialog
def test_choosing_a_snap_source_saves_its_server_name(self) -> None:
settings_dialog = _load_settings_dialog()
names = {PULSE_INDEX_BASE: "mic_a", PULSE_INDEX_BASE - 1: "mic_b"}
dialog = self._choose(
settings_dialog, str(PULSE_INDEX_BASE), "USB Audio (mic_a) (default)", names
)
dialog.config_manager.set.assert_any_call("audio", "device_name", "mic_a")
dialog.speech_engine.set_audio_device.assert_called_once_with(PULSE_INDEX_BASE, "mic_a")
def test_choosing_a_portaudio_device_saves_its_label(self) -> None:
settings_dialog = _load_settings_dialog()
dialog = self._choose(settings_dialog, "1", "Yeti", {})
dialog.config_manager.set.assert_any_call("audio", "device_name", "Yeti")
def test_device_list_keeps_each_entry_server_name(self) -> None:
"""Populating lists the server once and maps dropdown entries to names."""
settings_dialog = _load_settings_dialog()
sources = _usb_listing("mic_a", "mic_b")
dialog = MagicMock()
dialog.config_manager.get_optional_int.return_value = PULSE_INDEX_BASE - 1
dialog.config_manager.get.return_value = "mic_b"
devices = [
(PULSE_INDEX_BASE, sources[0].display_name, True),
(PULSE_INDEX_BASE - 1, sources[1].display_name, False),
]
with (
patch.object(settings_dialog, "get_pulse_sources", return_value=sources) as listing,
patch(
"vocalinux.speech_recognition.recognition_manager.get_audio_input_devices",
return_value=devices,
) as get_devices,
patch("vocalinux.audio.pulse_sources.get_pulse_sources") as relisting,
):
settings_dialog.SettingsDialog._populate_audio_devices(dialog)
listing.assert_called_once_with()
get_devices.assert_called_once_with(pulse_sources=sources)
relisting.assert_not_called()
assert dialog._pulse_source_names == {
PULSE_INDEX_BASE: "mic_a",
PULSE_INDEX_BASE - 1: "mic_b",
}
dialog.audio_device_combo.set_active_id.assert_called_with(str(PULSE_INDEX_BASE - 1))
class TestAudioDeviceTooltip:
"""The fixed-width dropdown truncates long names; the tooltip shows them whole (#1029)."""
_SOF = "sof-hda-dsp: - (hw:0,6)"
def _combo(self, device_id: Optional[str], label: Optional[str]) -> MagicMock:
combo = MagicMock()
combo.get_active_id.return_value = device_id
combo.get_active_text.return_value = label
return combo
def test_choosing_a_device_shows_its_full_name_in_the_tooltip(self) -> None:
settings_dialog = _load_settings_dialog()
dialog = MagicMock(_initializing=False, _pulse_source_names={})
dialog.audio_device_combo = self._combo("1", self._SOF)
settings_dialog.SettingsDialog._on_audio_device_changed(dialog, None)
dialog.audio_device_combo.set_tooltip_text.assert_called_with(self._SOF)
def test_system_default_keeps_the_generic_tooltip(self) -> None:
settings_dialog = _load_settings_dialog()
dialog = MagicMock(_initializing=False, _pulse_source_names={})
dialog.audio_device_combo = self._combo("-1", "System Default")
settings_dialog.SettingsDialog._on_audio_device_changed(dialog, None)
dialog.audio_device_combo.set_tooltip_text.assert_called_with(
"Select the microphone to use for voice recognition"
)
def test_populating_shows_the_saved_device_in_the_tooltip(self) -> None:
"""The saved device is selected before the change handler is wired up."""
settings_dialog = _load_settings_dialog()
dialog = MagicMock()
dialog.config_manager.get_optional_int.return_value = 1
dialog.config_manager.get.return_value = self._SOF
dialog.audio_device_combo = self._combo("1", self._SOF)
devices = [(0, "sof-hda-dsp: - (hw:0,0)", False), (1, self._SOF, False)]
with (
patch.object(settings_dialog, "get_pulse_sources", return_value=[]),
patch(
"vocalinux.speech_recognition.recognition_manager.get_audio_input_devices",
return_value=devices,
),
):
settings_dialog.SettingsDialog._populate_audio_devices(dialog)
dialog.audio_device_combo.set_tooltip_text.assert_called_with(self._SOF)
class TestAudioStatusMarkup:
"""Device names and errors are free text; the status label parses Pango markup."""
def _glib(self) -> MagicMock:
from xml.sax.saxutils import escape
return MagicMock(markup_escape_text=MagicMock(side_effect=escape))
def test_selected_device_name_is_escaped(self) -> None:
settings_dialog = _load_settings_dialog()
dialog = MagicMock(_initializing=False, _pulse_source_names={})
dialog.audio_device_combo.get_active_id.return_value = "2"
dialog.audio_device_combo.get_active_text.return_value = "Mic & Line <A2DP>"
with patch.object(settings_dialog, "GLib", self._glib()):
settings_dialog.SettingsDialog._on_audio_device_changed(dialog, None)
dialog.audio_test_status.set_markup.assert_called_with(
"<i>Selected: Mic &amp; Line &lt;A2DP&gt;</i>"
)
def test_test_failure_message_is_escaped(self) -> None:
settings_dialog = _load_settings_dialog()
dialog = MagicMock()
with patch.object(settings_dialog, "GLib", self._glib()):
settings_dialog.SettingsDialog._handle_audio_test_result(
dialog, {"success": False, "error": "Invalid device <hw:0,6> & busy"}
)
dialog.audio_test_status.set_markup.assert_called_with(
"<span foreground='#c01c28'>✗ Test failed:</span> "
"Invalid device &lt;hw:0,6&gt; &amp; busy"
)
def _load_settings_dialog() -> ModuleType:
"""Import settings_dialog with real GTK base classes so its methods are callable.
Mirrors the helper in test_download_error_reporting.py and restores the
module afterwards.
"""
import vocalinux.ui as ui_pkg
repository = sys.modules["gi.repository"]
bases = {name: type(name, (), {}) for name in ("Box", "ListBoxRow", "Dialog")}
saved = sys.modules.pop("vocalinux.ui.settings_dialog", None)
try:
with patch.object(repository, "Gtk", MagicMock(**bases)):
module = importlib.import_module("vocalinux.ui.settings_dialog")
finally:
sys.modules.pop("vocalinux.ui.settings_dialog", None)
if saved is not None:
sys.modules["vocalinux.ui.settings_dialog"] = saved
ui_pkg.settings_dialog = saved
else:
ui_pkg.__dict__.pop("settings_dialog", None)
return module