Files
voculinux/tests/test_recognition_manager_init.py
T
Jatin K MalikandGitHub be0614010d refactor: systematic code quality improvements across 20 dimensions (#377)
* refactor: improve code quality across multiple dimensions

Code quality improvements identified and driven by desloppify analysis:

**action_handler.py**
- Replace 9 repetitive shortcut forwarding methods with data-driven
  _SHORTCUT_ACTIONS class-level dispatch table
- Add _make_shortcut_handler() factory for shortcut closures
- Add explicit type annotation for action_handlers dict

**config_manager.py**
- Move 'import copy' from __init__ to module level (stdlib best practice)
- Add _VALID_SECTIONS frozenset for section name validation in set()
- Narrow broad Exception catch to (KeyError, TypeError) in set()
- Add deprecation warning to save_settings() guiding toward save_config()

**tray_indicator.py**
- Consolidate all imports at module top level
- Remove noqa E402 suppressions for mid-file imports
- Move ResourceManager import alongside other local imports

**recognition_manager.py**
- Narrow broad 'except Exception' to 'except OSError' for audio device
  enumeration errors
- Narrow 'except Exception' to 'except (FileNotFoundError, OSError)' for
  notification subprocess errors

**Tests**
- Update test assertions to use specific exception types matching narrowed
  catches (OSError, FileNotFoundError instead of Exception/RuntimeError)

* refactor: improve type safety, callback contracts, and method decomposition

**main.py** — Mid-level elegance
- Document callback wiring contract: text_callback, action_callback,
  state_callback with threading context, argument types, and behavior
- Add return type annotations to nested callback functions
- Reorder: define callbacks before registration for readability

**config_manager.py** — Type safety, error consistency
- Add typed accessors: get_str(), get_bool(), get_int(), get_float(),
  get_optional_int() eliminating Any returns for common access patterns
- Narrow load_config exception to (json.JSONDecodeError, OSError)
- Narrow save_config exception to (OSError, TypeError)

**recognition_manager.py** — Low-level elegance
- Decompose _init_whispercpp (141 lines) into three focused methods:
  - _init_whispercpp: validation and download gating (35 lines)
  - _load_whispercpp_model: backend detection and model loading (55 lines)
  - _handle_gpu_fallback: GPU incompatibility recovery (40 lines)
- Narrow exception catch from broad Exception to specific types

**tray_indicator.py, settings_dialog.py** — Type safety
- Migrate config access from untyped get() to typed get_str(),
  get_bool(), get_optional_int() across 8 call sites

* refactor: narrow 11 more broad Exception catches to specific types

Systematically replaced broad 'except Exception' with specific exception
types across recognition_manager.py:

- test_audio_input: (OSError, ValueError) for audio read errors
- test_audio_input outer: (OSError, ValueError, RuntimeError) for unexpected
- _init_whisper: (RuntimeError, OSError) for engine init failures
- _transcribe_with_whisper: (RuntimeError, OSError, ValueError) for transcription
- _download_whispercpp_model: (OSError, RuntimeError, ValueError) for downloads
- _download_vosk_model: (OSError, RuntimeError, ValueError) for downloads
- _download_whisper_model: (OSError, RuntimeError, ValueError) for downloads

This brings the total narrowed exception catches to 18+ across the codebase,
improving error consistency and making failure modes more predictable.

* style: fix black formatting issues

Auto-format with black to fix CI lint failures in recognition_manager.py,
settings_dialog.py, and test_settings_shortcuts.py.

* test: add coverage for typed config accessors

Add TestTypedAccessors class with 15 tests covering:
- get_str(): string values, defaults, non-string conversion, None handling
- get_bool(): boolean values, defaults
- get_int(): integer values, invalid input fallback, missing key defaults
- get_float(): float values, invalid input fallback
- get_optional_int(): int values, None values, missing keys, invalid input

Fixes codecov/patch coverage requirement for new typed accessor methods.
2026-04-02 14:53:29 -07:00

358 lines
14 KiB
Python

"""
Additional coverage tests for recognition_manager.py module.
Tests for uncovered initialization paths, error handling, and edge cases.
"""
import os
import sys
from unittest.mock import MagicMock, Mock, mock_open, patch
import pytest
# Autouse fixture to prevent sys.modules pollution
@pytest.fixture(autouse=True)
def _restore_sys_modules():
saved = dict(sys.modules)
yield
added = set(sys.modules.keys()) - set(saved.keys())
for k in added:
del sys.modules[k]
for k, v in saved.items():
if k not in sys.modules or sys.modules[k] is not v:
sys.modules[k] = v
def _make_manager(engine="whisper_cpp", **kw):
"""Helper to create SpeechRecognitionManager with init methods patched."""
from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager
with patch.object(SpeechRecognitionManager, "_init_vosk"):
with patch.object(SpeechRecognitionManager, "_init_whisper"):
with patch.object(SpeechRecognitionManager, "_init_whispercpp"):
return SpeechRecognitionManager(
engine=engine, model_size="small", language="en-us", defer_download=True, **kw
)
class TestSpeechRecognitionManagerInit:
"""Tests for SpeechRecognitionManager initialization."""
def test_manager_init_whisper_cpp_engine(self):
"""Test manager initialization with whisper_cpp engine."""
manager = _make_manager(engine="whisper_cpp")
assert manager.engine == "whisper_cpp"
assert manager.model_size == "small"
assert manager.language == "en-us"
def test_manager_init_vosk_engine(self):
"""Test manager initialization with vosk engine."""
manager = _make_manager(engine="vosk")
assert manager.engine == "vosk"
def test_manager_init_whisper_engine(self):
"""Test manager initialization with whisper engine."""
manager = _make_manager(engine="whisper")
assert manager.engine == "whisper"
def test_manager_init_with_audio_device_index(self):
"""Test manager initialization with audio device index."""
manager = _make_manager(audio_device_index=2)
assert manager.audio_device_index == 2
def test_manager_init_with_vad_sensitivity(self):
"""Test manager initialization with VAD sensitivity."""
manager = _make_manager(vad_sensitivity=5)
assert manager.vad_sensitivity == 5
def test_manager_init_with_silence_timeout(self):
"""Test manager initialization with silence timeout."""
manager = _make_manager(silence_timeout=3.0)
assert manager.silence_timeout == 3.0
def test_manager_init_with_voice_commands_enabled(self):
"""Test manager initialization with voice commands enabled."""
manager = _make_manager(voice_commands_enabled=True)
# Just verify manager initializes without error
assert manager is not None
def test_manager_init_with_download_progress_callback(self):
"""Test manager initialization with download progress callback."""
callback = MagicMock()
manager = _make_manager(download_progress_callback=callback)
# Just verify manager initializes without error
assert manager is not None
class TestGetAudioInputDevices:
"""Tests for get_audio_input_devices function."""
def test_get_audio_input_devices_success(self):
"""Test getting audio input devices successfully."""
from vocalinux.speech_recognition.recognition_manager import get_audio_input_devices
mock_audio = MagicMock()
mock_audio.get_device_count.return_value = 2
mock_audio.get_default_input_device_info.return_value = {"index": 0}
mock_audio.get_device_info_by_index.side_effect = [
{"name": "Device 0", "maxInputChannels": 2, "index": 0},
{"name": "Device 1", "maxInputChannels": 2, "index": 1},
]
with patch.dict(
"sys.modules", {"pyaudio": MagicMock(PyAudio=MagicMock(return_value=mock_audio))}
):
devices = get_audio_input_devices()
assert len(devices) == 2
assert devices[0][0] == 0 # device index
assert devices[0][2] is True # is_default
assert devices[1][2] is False
def test_get_audio_input_devices_no_default(self):
"""Test getting devices when no default input device exists."""
from vocalinux.speech_recognition.recognition_manager import get_audio_input_devices
mock_audio = MagicMock()
mock_audio.get_device_count.return_value = 1
mock_audio.get_default_input_device_info.side_effect = IOError("No default device")
mock_audio.get_device_info_by_index.return_value = {
"name": "Device 0",
"maxInputChannels": 2,
"index": 0,
}
with patch.dict(
"sys.modules", {"pyaudio": MagicMock(PyAudio=MagicMock(return_value=mock_audio))}
):
devices = get_audio_input_devices()
assert len(devices) == 1
assert devices[0][2] is False # not default
def test_get_audio_input_devices_skip_input_only(self):
"""Test that devices with 0 input channels are skipped."""
from vocalinux.speech_recognition.recognition_manager import get_audio_input_devices
mock_audio = MagicMock()
mock_audio.get_device_count.return_value = 2
mock_audio.get_default_input_device_info.return_value = {"index": 0}
mock_audio.get_device_info_by_index.side_effect = [
{"name": "Output Device", "maxInputChannels": 0, "index": 0}, # output only
{"name": "Input Device", "maxInputChannels": 2, "index": 1}, # input capable
]
with patch.dict(
"sys.modules", {"pyaudio": MagicMock(PyAudio=MagicMock(return_value=mock_audio))}
):
devices = get_audio_input_devices()
assert len(devices) == 1
assert devices[0][1] == "Input Device"
def test_get_audio_input_devices_device_error(self):
"""Test handling of device enumeration errors."""
from vocalinux.speech_recognition.recognition_manager import get_audio_input_devices
mock_audio = MagicMock()
mock_audio.get_device_count.return_value = 2
mock_audio.get_default_input_device_info.return_value = {"index": 0}
mock_audio.get_device_info_by_index.side_effect = [
IOError("Device error"), # First device has error
{"name": "Device 1", "maxInputChannels": 2, "index": 1},
]
with patch.dict(
"sys.modules", {"pyaudio": MagicMock(PyAudio=MagicMock(return_value=mock_audio))}
):
devices = get_audio_input_devices()
assert len(devices) == 1
assert devices[0][1] == "Device 1"
def test_get_audio_input_devices_pyaudio_not_installed(self):
"""Test handling when pyaudio is not installed."""
from vocalinux.speech_recognition.recognition_manager import get_audio_input_devices
with patch.dict("sys.modules", {"pyaudio": None}):
with patch("builtins.__import__", side_effect=ImportError("No module named 'pyaudio'")):
devices = get_audio_input_devices()
assert devices == []
def test_get_audio_input_devices_generic_exception(self):
"""Test handling of OS-level audio errors during device enumeration."""
from vocalinux.speech_recognition.recognition_manager import get_audio_input_devices
mock_pyaudio = MagicMock()
mock_pyaudio.PyAudio.side_effect = OSError("Audio subsystem error")
with patch.dict("sys.modules", {"pyaudio": mock_pyaudio}):
devices = get_audio_input_devices()
assert devices == []
class TestGetSupportedChannels:
"""Tests for _get_supported_channels function."""
def test_get_supported_channels_mono(self):
"""Test that mono (1 channel) is detected as supported."""
from vocalinux.speech_recognition.recognition_manager import _get_supported_channels
mock_audio = MagicMock()
mock_stream = MagicMock()
mock_audio.open.return_value = mock_stream
# First call (mono) succeeds
mock_audio.open.side_effect = [mock_stream, IOError("2 channels not supported")]
with patch.dict(
"sys.modules",
{"pyaudio": MagicMock(paInt16=16, PyAudio=MagicMock(return_value=mock_audio))},
):
channels = _get_supported_channels(mock_audio, None)
assert channels == 1
def test_get_supported_channels_stereo_fallback(self):
"""Test fallback to stereo if mono fails."""
from vocalinux.speech_recognition.recognition_manager import _get_supported_channels
mock_audio = MagicMock()
mock_stream = MagicMock()
def open_side_effect(**kwargs):
if kwargs.get("channels") == 1:
raise IOError("-9998 invalid number of channels")
return mock_stream
mock_audio.open.side_effect = open_side_effect
mock_audio.get_default_input_device_info.return_value = {"defaultSampleRate": 48000}
with patch.dict(
"sys.modules",
{"pyaudio": MagicMock(paInt16=16, PyAudio=MagicMock(return_value=mock_audio))},
):
channels = _get_supported_channels(mock_audio, None)
assert channels == 2
def test_get_supported_channels_both_fail(self):
"""Test default to 1 channel if both mono and stereo fail."""
from vocalinux.speech_recognition.recognition_manager import _get_supported_channels
mock_audio = MagicMock()
mock_audio.open.side_effect = IOError("All channel/rate probes failed")
mock_audio.get_default_input_device_info.return_value = {"defaultSampleRate": 48000}
with patch.dict(
"sys.modules",
{"pyaudio": MagicMock(paInt16=16, PyAudio=MagicMock(return_value=mock_audio))},
):
channels = _get_supported_channels(mock_audio, None)
assert channels == 1
class TestGetSupportedSampleRate:
"""Tests for _get_supported_sample_rate function."""
def test_get_supported_sample_rate_device_default(self):
"""Test that device's default sample rate is preferred."""
from vocalinux.speech_recognition.recognition_manager import _get_supported_sample_rate
mock_audio = MagicMock()
mock_stream = MagicMock()
mock_audio.get_device_info_by_index.return_value = {"defaultSampleRate": 48000}
mock_audio.open.return_value = mock_stream
with patch.dict(
"sys.modules",
{"pyaudio": MagicMock(paInt16=16, PyAudio=MagicMock(return_value=mock_audio))},
):
rate = _get_supported_sample_rate(mock_audio, 0, 1)
assert rate == 48000
def test_get_supported_sample_rate_fallback_to_common(self):
"""Test fallback to common sample rates."""
from vocalinux.speech_recognition.recognition_manager import _get_supported_sample_rate
mock_audio = MagicMock()
mock_stream = MagicMock()
# Device default fails, but 44100 works
mock_audio.get_device_info_by_index.return_value = {"defaultSampleRate": 0}
mock_audio.open.side_effect = [
IOError("Device default failed"),
mock_stream, # 44100 works
]
with patch.dict(
"sys.modules",
{"pyaudio": MagicMock(paInt16=16, PyAudio=MagicMock(return_value=mock_audio))},
):
rate = _get_supported_sample_rate(mock_audio, 0, 1)
assert rate == 44100
def test_get_supported_sample_rate_all_fail_default(self):
"""Test default to 16000 if all rates fail."""
from vocalinux.speech_recognition.recognition_manager import _get_supported_sample_rate
mock_audio = MagicMock()
mock_audio.get_device_info_by_index.return_value = {"defaultSampleRate": 0}
# All rates fail
mock_audio.open.side_effect = IOError("All rates failed")
with patch.dict(
"sys.modules",
{"pyaudio": MagicMock(paInt16=16, PyAudio=MagicMock(return_value=mock_audio))},
):
rate = _get_supported_sample_rate(mock_audio, 0, 1)
assert rate == 16000
def test_get_supported_sample_rate_no_device_info(self):
"""Test handling when device info cannot be retrieved."""
from vocalinux.speech_recognition.recognition_manager import _get_supported_sample_rate
mock_audio = MagicMock()
mock_stream = MagicMock()
mock_audio.get_device_info_by_index.side_effect = IOError("Cannot get device info")
mock_audio.open.side_effect = [
mock_stream, # First common rate works
]
with patch.dict(
"sys.modules",
{"pyaudio": MagicMock(paInt16=16, PyAudio=MagicMock(return_value=mock_audio))},
):
rate = _get_supported_sample_rate(mock_audio, 0, 1)
# Should try common rates and return first working one
assert rate in [48000, 44100, 32000, 22050, 16000, 8000]
class TestManagerStateCallbacks:
"""Tests for callback registration and state management."""
def test_register_text_callback(self):
"""Test registering text callback."""
manager = _make_manager()
callback = MagicMock()
# Just verify method exists and doesn't error
manager.register_text_callback(callback)
assert manager is not None
def test_register_action_callback(self):
"""Test registering action callback."""
manager = _make_manager()
callback = MagicMock()
# Just verify method exists and doesn't error
manager.register_action_callback(callback)
assert manager is not None
def test_register_state_callback(self):
"""Test registering state callback."""
manager = _make_manager()
callback = MagicMock()
# Just verify method exists and doesn't error
manager.register_state_callback(callback)
assert manager is not None
def test_manager_state_property(self):
"""Test manager state property."""
manager = _make_manager()
# Just verify manager has state property
state = manager.state
assert state is not None