* fix(whispercpp): reduce CPU threads and ensure GPU backend builds in dev mode
- Reduce default n_threads from cpu_count() (16) to max 8 (CPU) / max 4 (GPU)
- Add runtime pywhispercpp GPU capability detection (_detect_pywhispercpp_gpu_backend)
- Make n_threads configurable via whispercpp_n_threads config option
- Fix install.sh regression: GPU source build was skipped in --dev mode
- Extract GPU install logic into install_whispercpp_with_gpu_support()
- Add is_pywhispercpp_gpu_capable() to detect GPU libs in installed pywhispercpp
- Force rebuild pywhispercpp when GPU is detected but lacks GPU support
Fixes CPU spiking to 100% on GPU systems due to excessive thread count
and ensures dev installs get GPU acceleration.
* fix(audio): skip output-only HDMI devices when selecting input device
Add _resolve_valid_input_device() that filters devices by
maxInputChannels > 0, falling back preferred → default → first valid.
Previously, HDMI output devices (0 input channels) could be selected
as the default audio input, causing 'Invalid number of channels' errors.
Now both _record_audio() and _attempt_audio_reconnection() validate the
device has input channels before probing or opening the stream.
* fix(whispercpp): treat n_threads=0 as auto-detect, not literal zero
Config default whispercpp_n_threads=0 was being passed directly
to whisper.cpp as n_threads=0, causing 'cannot create std::vector
larger than max_size()' transcription errors. Now 0 and negative
values are treated as auto-detect, falling through to the smart
thread count logic (max 4 with GPU, max 8 on CPU).
* test: add whispercpp_n_threads to mock assertions and config defaults
Update test_main.py mock to include the new whispercpp_n_threads=0 kwarg
and test_config_manager.py to assert the default config value.
* fix(audio): harden device enumeration against test mocks
The new HDMI-filter helper and GPU-backend detector crashed unit tests
that pass MagicMock() for the PyAudio/pathlib surfaces:
- _find_pywhispercpp_shared_library_dirs hit TypeError when tests
monkey-patch os.stat (Path.is_dir internally calls S_ISDIR on a
non-int st_mode). Widen the catch to OSError/TypeError/ValueError so
unconfigured stat results just skip the candidate dir.
- _resolve_valid_input_device crashed with 'MagicMock > int' on
audio.get_device_count() / info.get('maxInputChannels'). Coerce the
count, treat zero/unknown as 'use system default', and include
non-dict info objects rather than filtering everything out.
No production behavior changes: real PyAudio always returns int counts
and dict device info, so the HDMI filter still works as designed.
* test(recognition): cover new HDMI filter and GPU backend detector
- TestResolveValidInputDevice: 9 cases for the HDMI-filter resolver
(preferred passthrough, HDMI fallback, no-default fallback, no-input
None return, and three mock-tolerance escape hatches).
- TestDetectPywhispercppGpuBackend: 4 cases for runtime GPU library
detection (cpu/vulkan/cuda/empty dirs).
- TestFindPywhispercppSharedLibraryDirsResilience: proves the widened
exception catch survives a TypeError from pathlib.Path.is_dir.
* test(text-injection): wait for IBus init thread to avoid race on slow runners
The IBus environment promotion runs in a background daemon thread spawned
by TextInjector.__init__. The two TestIBusRuntimeFallback cases asserted
on injector.environment immediately after construction, which raced the
thread on Python 3.10 GHA runners and produced intermittent failures.
Join the init thread (with a generous timeout) before asserting, so the
test exercises the post-warmup state deterministically.
733 lines
26 KiB
Python
733 lines
26 KiB
Python
"""
|
|
Tests for the main module functionality.
|
|
"""
|
|
|
|
import argparse
|
|
import sys
|
|
import unittest
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
# Mock GTK modules before importing vocalinux.main
|
|
sys.modules["gi"] = MagicMock()
|
|
sys.modules["gi.repository"] = MagicMock()
|
|
|
|
# Update import to use the new package structure
|
|
from vocalinux.main import check_dependencies, main, parse_arguments
|
|
|
|
|
|
class TestMainModule(unittest.TestCase):
|
|
"""Test cases for the main module."""
|
|
|
|
def test_parse_arguments_defaults(self):
|
|
"""Test argument parsing with defaults."""
|
|
# Test with no arguments (model/engine/language will be None without defaults)
|
|
with patch("sys.argv", ["vocalinux"]):
|
|
args = parse_arguments()
|
|
self.assertFalse(args.debug)
|
|
self.assertIsNone(args.model) # No default set, loaded from config instead
|
|
self.assertIsNone(args.engine)
|
|
self.assertIsNone(args.language)
|
|
self.assertFalse(args.wayland)
|
|
self.assertFalse(args.start_minimized)
|
|
|
|
def test_parse_arguments_custom(self):
|
|
"""Test argument parsing with custom values."""
|
|
# Test with custom arguments
|
|
with patch(
|
|
"sys.argv",
|
|
[
|
|
"vocalinux",
|
|
"--debug",
|
|
"--model",
|
|
"large",
|
|
"--engine",
|
|
"whisper",
|
|
"--language",
|
|
"fr",
|
|
"--wayland",
|
|
"--start-minimized",
|
|
],
|
|
):
|
|
args = parse_arguments()
|
|
self.assertTrue(args.debug)
|
|
self.assertEqual(args.model, "large")
|
|
self.assertEqual(args.engine, "whisper")
|
|
self.assertEqual(args.language, "fr")
|
|
self.assertTrue(args.wayland)
|
|
self.assertTrue(args.start_minimized)
|
|
|
|
def test_parse_arguments_model_choices(self):
|
|
"""Test that model only accepts valid choices."""
|
|
with patch("sys.argv", ["vocalinux", "--model", "small"]):
|
|
args = parse_arguments()
|
|
self.assertEqual(args.model, "small")
|
|
|
|
with patch("sys.argv", ["vocalinux", "--model", "medium"]):
|
|
args = parse_arguments()
|
|
self.assertEqual(args.model, "medium")
|
|
|
|
with patch("sys.argv", ["vocalinux", "--model", "large"]):
|
|
args = parse_arguments()
|
|
self.assertEqual(args.model, "large")
|
|
|
|
def test_parse_arguments_engine_choices(self):
|
|
"""Test that engine only accepts valid choices."""
|
|
with patch("sys.argv", ["vocalinux", "--engine", "vosk"]):
|
|
args = parse_arguments()
|
|
self.assertEqual(args.engine, "vosk")
|
|
|
|
with patch("sys.argv", ["vocalinux", "--engine", "whisper"]):
|
|
args = parse_arguments()
|
|
self.assertEqual(args.engine, "whisper")
|
|
|
|
def test_parse_arguments_language_choices(self):
|
|
"""Test that language only accepts valid choices."""
|
|
for lang in [
|
|
"auto",
|
|
"en-us",
|
|
"en-in",
|
|
"hi",
|
|
"es",
|
|
"fr",
|
|
"de",
|
|
"it",
|
|
"pt",
|
|
"ru",
|
|
"zh",
|
|
"ja",
|
|
"ko",
|
|
"ar",
|
|
]:
|
|
with patch("sys.argv", ["vocalinux", "--language", lang]):
|
|
args = parse_arguments()
|
|
self.assertEqual(args.language, lang)
|
|
|
|
@patch("vocalinux.main.sys.exit")
|
|
@patch("vocalinux.main.check_dependencies")
|
|
@patch("vocalinux.main.parse_arguments")
|
|
def test_main_exits_on_missing_deps(self, mock_parse, mock_check_deps, mock_exit):
|
|
"""Test that main exits when dependencies are missing."""
|
|
mock_check_deps.return_value = False
|
|
mock_args = MagicMock()
|
|
mock_args.debug = False
|
|
mock_parse.return_value = mock_args
|
|
|
|
# Make sys.exit raise SystemExit to stop execution
|
|
mock_exit.side_effect = SystemExit(1)
|
|
|
|
with patch("vocalinux.main.logger"):
|
|
try:
|
|
main()
|
|
except SystemExit:
|
|
pass
|
|
mock_exit.assert_called_with(1)
|
|
|
|
@patch("vocalinux.main.check_dependencies")
|
|
@patch("vocalinux.main.parse_arguments")
|
|
@patch("vocalinux.main.sys.exit")
|
|
@patch("vocalinux.ui.config_manager.ConfigManager")
|
|
@patch("vocalinux.ui.logging_manager.initialize_logging")
|
|
def test_main_exits_on_init_error(
|
|
self, mock_init_logging, mock_config, mock_exit, mock_parse, mock_check_deps
|
|
):
|
|
"""Test that main exits when initialization fails."""
|
|
mock_check_deps.return_value = True
|
|
mock_args = MagicMock()
|
|
mock_args.debug = False
|
|
mock_args.model = "small"
|
|
mock_args.engine = "vosk"
|
|
mock_args.language = "en-us"
|
|
mock_args.wayland = False
|
|
mock_parse.return_value = mock_args
|
|
|
|
# Mock config
|
|
mock_config_instance = MagicMock()
|
|
mock_config_instance.get_settings.return_value = {
|
|
"general": {"first_run": False},
|
|
}
|
|
mock_config.return_value = mock_config_instance
|
|
|
|
# Make SpeechRecognitionManager raise an exception
|
|
with patch(
|
|
"vocalinux.speech_recognition.recognition_manager.SpeechRecognitionManager",
|
|
side_effect=Exception("Init error"),
|
|
):
|
|
with patch("vocalinux.main.logger"):
|
|
main()
|
|
mock_exit.assert_called_once_with(1)
|
|
|
|
@patch("vocalinux.main.check_dependencies")
|
|
@patch("vocalinux.ui.action_handler.ActionHandler")
|
|
@patch("vocalinux.speech_recognition.recognition_manager.SpeechRecognitionManager")
|
|
@patch("vocalinux.text_injection.text_injector.TextInjector")
|
|
@patch("vocalinux.ui.tray_indicator.TrayIndicator")
|
|
@patch("vocalinux.main.logging")
|
|
@patch("vocalinux.ui.config_manager.ConfigManager")
|
|
@patch("vocalinux.ui.logging_manager.initialize_logging")
|
|
def test_main_initializes_components(
|
|
self,
|
|
mock_init_logging,
|
|
mock_config_manager,
|
|
mock_logging,
|
|
mock_tray,
|
|
mock_text,
|
|
mock_speech,
|
|
mock_action_handler,
|
|
mock_check_deps,
|
|
):
|
|
"""Test that main initializes all the required components."""
|
|
# Mock dependency check to return True
|
|
mock_check_deps.return_value = True
|
|
|
|
# Mock ConfigManager to return empty settings (use command-line defaults)
|
|
mock_config_instance = MagicMock()
|
|
mock_config_instance.get_settings.return_value = {
|
|
"speech_recognition": {},
|
|
"general": {"first_run": False},
|
|
}
|
|
mock_config_manager.return_value = mock_config_instance
|
|
|
|
# Mock objects
|
|
mock_speech_instance = MagicMock()
|
|
mock_text_instance = MagicMock()
|
|
mock_tray_instance = MagicMock()
|
|
mock_action_instance = MagicMock()
|
|
|
|
# Setup return values
|
|
mock_speech.return_value = mock_speech_instance
|
|
mock_text.return_value = mock_text_instance
|
|
mock_tray.return_value = mock_tray_instance
|
|
mock_action_handler.return_value = mock_action_instance
|
|
|
|
# Mock the arguments
|
|
with patch("vocalinux.main.parse_arguments") as mock_parse:
|
|
mock_args = MagicMock()
|
|
mock_args.debug = False
|
|
mock_args.model = "medium"
|
|
mock_args.engine = "vosk"
|
|
mock_args.language = "en-us"
|
|
mock_args.wayland = True
|
|
mock_parse.return_value = mock_args
|
|
|
|
# Call main function
|
|
main()
|
|
|
|
# Verify components were initialized correctly
|
|
mock_speech.assert_called_once_with(
|
|
engine="vosk",
|
|
model_size="medium",
|
|
language="en-us",
|
|
vad_sensitivity=3,
|
|
silence_timeout=2.0,
|
|
stop_sound_guard_ms=200,
|
|
voice_commands_enabled=None,
|
|
audio_device_index=None,
|
|
whispercpp_no_timestamps=True,
|
|
whispercpp_no_context=True,
|
|
whispercpp_initial_prompt="",
|
|
whispercpp_temperature=0.0,
|
|
whispercpp_temperature_inc=-1.0,
|
|
whispercpp_entropy_thold=2.4,
|
|
whispercpp_logprob_thold=-1.0,
|
|
whispercpp_no_speech_thold=0.6,
|
|
whispercpp_n_threads=0,
|
|
)
|
|
mock_text.assert_called_once_with(wayland_mode=True)
|
|
mock_action_handler.assert_called_once_with(mock_text_instance)
|
|
mock_tray.assert_called_once_with(
|
|
speech_engine=mock_speech_instance, text_injector=mock_text_instance
|
|
)
|
|
|
|
# Verify callbacks were registered
|
|
mock_speech_instance.register_text_callback.assert_called_once()
|
|
mock_speech_instance.register_action_callback.assert_called_once_with(
|
|
mock_action_instance.handle_action
|
|
)
|
|
mock_speech_instance.register_state_callback.assert_called_once()
|
|
|
|
# Verify the tray indicator was started
|
|
mock_tray_instance.run.assert_called_once()
|
|
|
|
@patch("vocalinux.main.check_dependencies")
|
|
@patch("vocalinux.ui.action_handler.ActionHandler")
|
|
@patch("vocalinux.speech_recognition.recognition_manager.SpeechRecognitionManager")
|
|
@patch("vocalinux.text_injection.text_injector.TextInjector")
|
|
@patch("vocalinux.ui.tray_indicator.TrayIndicator")
|
|
@patch("vocalinux.main.logging")
|
|
@patch("vocalinux.ui.config_manager.ConfigManager")
|
|
@patch("vocalinux.ui.logging_manager.initialize_logging")
|
|
def test_main_with_debug_enabled(
|
|
self,
|
|
mock_init_logging,
|
|
mock_config_manager,
|
|
mock_logging,
|
|
mock_tray,
|
|
mock_text,
|
|
mock_speech,
|
|
mock_action_handler,
|
|
mock_check_deps,
|
|
):
|
|
"""Test that debug mode enables debug logging."""
|
|
import logging # Import for DEBUG constant
|
|
|
|
mock_check_deps.return_value = True
|
|
|
|
# Mock ConfigManager
|
|
mock_config_instance = MagicMock()
|
|
mock_config_instance.get_settings.return_value = {
|
|
"speech_recognition": {},
|
|
"general": {"first_run": False},
|
|
}
|
|
mock_config_manager.return_value = mock_config_instance
|
|
|
|
# Mock objects
|
|
mock_speech_instance = MagicMock()
|
|
mock_text_instance = MagicMock()
|
|
mock_tray_instance = MagicMock()
|
|
mock_action_instance = MagicMock()
|
|
|
|
mock_speech.return_value = mock_speech_instance
|
|
mock_text.return_value = mock_text_instance
|
|
mock_tray.return_value = mock_tray_instance
|
|
mock_action_handler.return_value = mock_action_instance
|
|
|
|
with patch("vocalinux.main.parse_arguments") as mock_parse:
|
|
# Create mock args with debug enabled
|
|
mock_args = MagicMock()
|
|
mock_args.debug = True
|
|
mock_args.model = "small"
|
|
mock_args.engine = "vosk"
|
|
mock_args.language = "en-us"
|
|
mock_args.wayland = False
|
|
mock_parse.return_value = mock_args
|
|
|
|
# Create mock loggers
|
|
root_logger = MagicMock()
|
|
mock_logging.getLogger.return_value = root_logger
|
|
|
|
# Call main
|
|
main()
|
|
|
|
# Verify root logger had setLevel called with DEBUG
|
|
root_logger.setLevel.assert_called()
|
|
|
|
@patch("vocalinux.main.check_dependencies")
|
|
@patch("vocalinux.ui.action_handler.ActionHandler")
|
|
@patch("vocalinux.speech_recognition.recognition_manager.SpeechRecognitionManager")
|
|
@patch("vocalinux.text_injection.text_injector.TextInjector")
|
|
@patch("vocalinux.ui.tray_indicator.TrayIndicator")
|
|
@patch("vocalinux.ui.first_run_dialog.show_first_run_dialog")
|
|
@patch("vocalinux.ui.config_manager.ConfigManager")
|
|
@patch("vocalinux.ui.logging_manager.initialize_logging")
|
|
def test_main_first_run_later_keeps_prompt_enabled(
|
|
self,
|
|
mock_init_logging,
|
|
mock_config_manager,
|
|
mock_first_run_dialog,
|
|
mock_tray,
|
|
mock_text,
|
|
mock_speech,
|
|
mock_action_handler,
|
|
mock_check_deps,
|
|
):
|
|
"""Selecting later on first-run does not disable future prompt."""
|
|
mock_check_deps.return_value = True
|
|
mock_first_run_dialog.return_value = "later"
|
|
|
|
mock_config_instance = MagicMock()
|
|
mock_config_instance.get_settings.return_value = {
|
|
"speech_recognition": {},
|
|
"general": {"first_run": True},
|
|
}
|
|
mock_config_manager.return_value = mock_config_instance
|
|
|
|
mock_speech.return_value = MagicMock()
|
|
mock_text.return_value = MagicMock()
|
|
mock_tray.return_value = MagicMock()
|
|
mock_action_instance = MagicMock()
|
|
mock_action_handler.return_value = mock_action_instance
|
|
|
|
with patch("vocalinux.main.parse_arguments") as mock_parse:
|
|
mock_args = MagicMock()
|
|
mock_args.debug = False
|
|
mock_args.model = "small"
|
|
mock_args.engine = "vosk"
|
|
mock_args.language = "en-us"
|
|
mock_args.wayland = False
|
|
mock_parse.return_value = mock_args
|
|
|
|
with patch("vocalinux.main.logger"):
|
|
main()
|
|
|
|
self.assertFalse(
|
|
any(
|
|
call.args == ("general", "first_run", False)
|
|
for call in mock_config_instance.set.call_args_list
|
|
)
|
|
)
|
|
mock_config_instance.save_settings.assert_not_called()
|
|
|
|
@patch("vocalinux.main.check_dependencies")
|
|
@patch("vocalinux.ui.action_handler.ActionHandler")
|
|
@patch("vocalinux.speech_recognition.recognition_manager.SpeechRecognitionManager")
|
|
@patch("vocalinux.text_injection.text_injector.TextInjector")
|
|
@patch("vocalinux.ui.tray_indicator.TrayIndicator")
|
|
@patch("vocalinux.ui.first_run_dialog.show_first_run_dialog")
|
|
@patch("vocalinux.ui.config_manager.ConfigManager")
|
|
@patch("vocalinux.ui.logging_manager.initialize_logging")
|
|
def test_main_start_minimized_skips_first_run_prompt(
|
|
self,
|
|
mock_init_logging,
|
|
mock_config_manager,
|
|
mock_first_run_dialog,
|
|
mock_tray,
|
|
mock_text,
|
|
mock_speech,
|
|
mock_action_handler,
|
|
mock_check_deps,
|
|
):
|
|
mock_check_deps.return_value = True
|
|
|
|
mock_config_instance = MagicMock()
|
|
mock_config_instance.get_settings.return_value = {
|
|
"speech_recognition": {},
|
|
"general": {"first_run": True},
|
|
}
|
|
mock_config_manager.return_value = mock_config_instance
|
|
|
|
mock_speech.return_value = MagicMock()
|
|
mock_text.return_value = MagicMock()
|
|
mock_tray.return_value = MagicMock()
|
|
mock_action_handler.return_value = MagicMock()
|
|
|
|
with patch("vocalinux.main.parse_arguments") as mock_parse:
|
|
mock_args = MagicMock()
|
|
mock_args.debug = False
|
|
mock_args.model = "small"
|
|
mock_args.engine = "vosk"
|
|
mock_args.language = "en-us"
|
|
mock_args.wayland = False
|
|
mock_args.start_minimized = True
|
|
mock_parse.return_value = mock_args
|
|
|
|
with patch("vocalinux.main.logger"):
|
|
main()
|
|
|
|
mock_first_run_dialog.assert_not_called()
|
|
self.assertFalse(
|
|
any(
|
|
call.args[0:2] == ("general", "first_run")
|
|
for call in mock_config_instance.set.call_args_list
|
|
)
|
|
)
|
|
|
|
|
|
class TestCheckDependencies(unittest.TestCase):
|
|
"""Test cases for check_dependencies function."""
|
|
|
|
def test_check_dependencies_all_available(self):
|
|
"""Test when all dependencies are available."""
|
|
# Mock all the imports that check_dependencies does
|
|
mock_gi = MagicMock()
|
|
mock_gi.require_version = MagicMock()
|
|
mock_gtk = MagicMock()
|
|
mock_appindicator = MagicMock()
|
|
mock_pynput = MagicMock()
|
|
mock_requests = MagicMock()
|
|
|
|
with patch.dict(
|
|
"sys.modules",
|
|
{
|
|
"gi": mock_gi,
|
|
"gi.repository": MagicMock(Gtk=mock_gtk, AppIndicator3=mock_appindicator),
|
|
"pynput": mock_pynput,
|
|
"requests": mock_requests,
|
|
},
|
|
):
|
|
result = check_dependencies()
|
|
self.assertTrue(result)
|
|
|
|
def test_check_dependencies_missing_gtk(self):
|
|
"""Test when GTK is missing."""
|
|
|
|
# Make gi.require_version raise ValueError for Gtk
|
|
def require_version_side_effect(name, version):
|
|
if name == "Gtk":
|
|
raise ValueError("Gtk not found")
|
|
|
|
mock_gi = MagicMock()
|
|
mock_gi.require_version = MagicMock(side_effect=require_version_side_effect)
|
|
mock_pynput = MagicMock()
|
|
mock_requests = MagicMock()
|
|
|
|
with patch.dict(
|
|
"sys.modules",
|
|
{
|
|
"gi": mock_gi,
|
|
"pynput": mock_pynput,
|
|
"requests": mock_requests,
|
|
},
|
|
):
|
|
with patch("vocalinux.main.logger"):
|
|
result = check_dependencies()
|
|
self.assertFalse(result)
|
|
|
|
def test_check_dependencies_missing_appindicator_with_ayatana_fallback(self):
|
|
"""Test when AppIndicator3 is missing but AyatanaAppIndicator3 is available."""
|
|
|
|
# Make gi.require_version raise ValueError for AppIndicator3 only
|
|
# AyatanaAppIndicator3 should work as fallback
|
|
def require_version_side_effect(name, version):
|
|
if name == "AppIndicator3":
|
|
raise ValueError("AppIndicator3 not found")
|
|
# AyatanaAppIndicator3 works fine
|
|
|
|
mock_gi = MagicMock()
|
|
mock_gi.require_version = MagicMock(side_effect=require_version_side_effect)
|
|
mock_gtk = MagicMock()
|
|
mock_ayatana = MagicMock()
|
|
mock_pynput = MagicMock()
|
|
mock_requests = MagicMock()
|
|
|
|
with patch.dict(
|
|
"sys.modules",
|
|
{
|
|
"gi": mock_gi,
|
|
"gi.repository": MagicMock(Gtk=mock_gtk, AyatanaAppIndicator3=mock_ayatana),
|
|
"pynput": mock_pynput,
|
|
"requests": mock_requests,
|
|
},
|
|
):
|
|
with patch("vocalinux.main.logger"):
|
|
result = check_dependencies()
|
|
# Should return True because AyatanaAppIndicator3 fallback works
|
|
self.assertTrue(result)
|
|
|
|
def test_check_dependencies_missing_both_appindicators(self):
|
|
"""Test when both AppIndicator3 and AyatanaAppIndicator3 are missing."""
|
|
|
|
# Make gi.require_version raise ValueError for both AppIndicator variants
|
|
def require_version_side_effect(name, version):
|
|
if name in ("AppIndicator3", "AyatanaAppIndicator3"):
|
|
raise ValueError(f"{name} not found")
|
|
|
|
mock_gi = MagicMock()
|
|
mock_gi.require_version = MagicMock(side_effect=require_version_side_effect)
|
|
mock_gtk = MagicMock()
|
|
mock_pynput = MagicMock()
|
|
mock_requests = MagicMock()
|
|
|
|
with patch.dict(
|
|
"sys.modules",
|
|
{
|
|
"gi": mock_gi,
|
|
"gi.repository": MagicMock(Gtk=mock_gtk),
|
|
"pynput": mock_pynput,
|
|
"requests": mock_requests,
|
|
},
|
|
):
|
|
with patch("vocalinux.main.logger"):
|
|
result = check_dependencies()
|
|
# Should return False because both indicators are missing
|
|
self.assertFalse(result)
|
|
|
|
|
|
class TestMainConfigPrecedence(unittest.TestCase):
|
|
"""Test cases for configuration precedence in main."""
|
|
|
|
@patch("vocalinux.main.check_dependencies")
|
|
@patch("vocalinux.ui.action_handler.ActionHandler")
|
|
@patch("vocalinux.speech_recognition.recognition_manager.SpeechRecognitionManager")
|
|
@patch("vocalinux.text_injection.text_injector.TextInjector")
|
|
@patch("vocalinux.ui.tray_indicator.TrayIndicator")
|
|
@patch("vocalinux.ui.config_manager.ConfigManager")
|
|
@patch("vocalinux.ui.logging_manager.initialize_logging")
|
|
def test_cli_args_override_config(
|
|
self,
|
|
mock_init_logging,
|
|
mock_config_manager,
|
|
mock_tray,
|
|
mock_text,
|
|
mock_speech,
|
|
mock_action_handler,
|
|
mock_check_deps,
|
|
):
|
|
"""Test that CLI arguments take precedence over saved config."""
|
|
mock_check_deps.return_value = True
|
|
|
|
# Mock ConfigManager to return saved settings
|
|
mock_config_instance = MagicMock()
|
|
mock_config_instance.get_settings.return_value = {
|
|
"speech_recognition": {
|
|
"engine": "vosk",
|
|
"model_size": "small",
|
|
"language": "en-us",
|
|
},
|
|
"general": {"first_run": False},
|
|
}
|
|
mock_config_manager.return_value = mock_config_instance
|
|
|
|
mock_speech_instance = MagicMock()
|
|
mock_text_instance = MagicMock()
|
|
mock_tray_instance = MagicMock()
|
|
mock_action_instance = MagicMock()
|
|
|
|
mock_speech.return_value = mock_speech_instance
|
|
mock_text.return_value = mock_text_instance
|
|
mock_tray.return_value = mock_tray_instance
|
|
mock_action_handler.return_value = mock_action_instance
|
|
|
|
# Simulate CLI args being set
|
|
with patch(
|
|
"sys.argv",
|
|
[
|
|
"vocalinux",
|
|
"--engine",
|
|
"whisper",
|
|
"--model",
|
|
"large",
|
|
"--language",
|
|
"fr",
|
|
],
|
|
):
|
|
with patch("vocalinux.main.logger"):
|
|
main()
|
|
|
|
# CLI args should override config
|
|
mock_speech.assert_called_once()
|
|
call_kwargs = mock_speech.call_args[1]
|
|
self.assertEqual(call_kwargs["engine"], "whisper")
|
|
self.assertEqual(call_kwargs["model_size"], "large")
|
|
self.assertEqual(call_kwargs["language"], "fr")
|
|
|
|
@patch("vocalinux.main.check_dependencies")
|
|
@patch("vocalinux.ui.action_handler.ActionHandler")
|
|
@patch("vocalinux.speech_recognition.recognition_manager.SpeechRecognitionManager")
|
|
@patch("vocalinux.text_injection.text_injector.TextInjector")
|
|
@patch("vocalinux.ui.tray_indicator.TrayIndicator")
|
|
@patch("vocalinux.ui.config_manager.ConfigManager")
|
|
@patch("vocalinux.ui.logging_manager.initialize_logging")
|
|
def test_config_used_when_no_cli_args(
|
|
self,
|
|
mock_init_logging,
|
|
mock_config_manager,
|
|
mock_tray,
|
|
mock_text,
|
|
mock_speech,
|
|
mock_action_handler,
|
|
mock_check_deps,
|
|
):
|
|
"""Test that saved config is used when CLI args not provided."""
|
|
mock_check_deps.return_value = True
|
|
|
|
# Mock ConfigManager to return saved settings
|
|
mock_config_instance = MagicMock()
|
|
mock_config_instance.get_settings.return_value = {
|
|
"speech_recognition": {
|
|
"engine": "whisper",
|
|
"model_size": "medium",
|
|
"language": "de",
|
|
},
|
|
"audio": {
|
|
"device_index": 2,
|
|
},
|
|
"general": {"first_run": False},
|
|
}
|
|
mock_config_manager.return_value = mock_config_instance
|
|
|
|
mock_speech_instance = MagicMock()
|
|
mock_text_instance = MagicMock()
|
|
mock_tray_instance = MagicMock()
|
|
mock_action_instance = MagicMock()
|
|
|
|
mock_speech.return_value = mock_speech_instance
|
|
mock_text.return_value = mock_text_instance
|
|
mock_tray.return_value = mock_tray_instance
|
|
mock_action_handler.return_value = mock_action_instance
|
|
|
|
# No CLI args for engine/model/language
|
|
with patch("sys.argv", ["vocalinux"]):
|
|
with patch("vocalinux.main.logger"):
|
|
main()
|
|
|
|
# Config values should be used
|
|
mock_speech.assert_called_once()
|
|
call_kwargs = mock_speech.call_args[1]
|
|
self.assertEqual(call_kwargs["engine"], "whisper")
|
|
self.assertEqual(call_kwargs["model_size"], "medium")
|
|
self.assertEqual(call_kwargs["language"], "de")
|
|
self.assertEqual(call_kwargs["audio_device_index"], 2)
|
|
|
|
|
|
class TestTextCallbackSpacing(unittest.TestCase):
|
|
"""Test spacing logic in text_callback_wrapper."""
|
|
|
|
def _make_callback(self):
|
|
"""Build text_callback_wrapper with mocked dependencies."""
|
|
from vocalinux.ui.action_handler import ActionHandler
|
|
|
|
text_system = MagicMock()
|
|
text_system.inject_text.return_value = True
|
|
action_handler = ActionHandler(text_system)
|
|
|
|
def text_callback_wrapper(text: str):
|
|
text_to_inject = text.strip()
|
|
if not text_to_inject:
|
|
return
|
|
if action_handler.last_injected_text and action_handler.last_injected_text.strip():
|
|
text_to_inject = " " + text_to_inject
|
|
success = text_system.inject_text(text_to_inject)
|
|
if success:
|
|
action_handler.set_last_injected_text(text)
|
|
|
|
return text_callback_wrapper, text_system, action_handler
|
|
|
|
def test_first_segment_has_no_leading_space(self):
|
|
cb, text_system, _ = self._make_callback()
|
|
cb("Hello world")
|
|
text_system.inject_text.assert_called_once_with("Hello world")
|
|
|
|
def test_subsequent_segment_gets_space_separator(self):
|
|
cb, text_system, _ = self._make_callback()
|
|
cb("Hello")
|
|
cb("world")
|
|
calls = [c.args[0] for c in text_system.inject_text.call_args_list]
|
|
self.assertEqual(calls, ["Hello", " world"])
|
|
|
|
def test_reset_clears_leading_space(self):
|
|
cb, text_system, ah = self._make_callback()
|
|
cb("first session")
|
|
ah.set_last_injected_text("")
|
|
text_system.inject_text.reset_mock()
|
|
cb("second session")
|
|
text_system.inject_text.assert_called_once_with("second session")
|
|
|
|
def test_whitespace_only_input_is_skipped(self):
|
|
cb, text_system, _ = self._make_callback()
|
|
cb(" ")
|
|
text_system.inject_text.assert_not_called()
|
|
|
|
def test_input_with_leading_space_is_stripped(self):
|
|
cb, text_system, _ = self._make_callback()
|
|
cb(" Hello world")
|
|
text_system.inject_text.assert_called_once_with("Hello world")
|
|
|
|
def test_multiple_segments_all_get_separators(self):
|
|
cb, text_system, _ = self._make_callback()
|
|
cb("one")
|
|
cb("two")
|
|
cb("three")
|
|
calls = [c.args[0] for c in text_system.inject_text.call_args_list]
|
|
self.assertEqual(calls, ["one", " two", " three"])
|
|
|
|
def test_space_after_punctuation_segment(self):
|
|
cb, text_system, _ = self._make_callback()
|
|
cb("Hello.")
|
|
cb("World")
|
|
calls = [c.args[0] for c in text_system.inject_text.call_args_list]
|
|
self.assertEqual(calls, ["Hello.", " World"])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|