* feat(asr): add chat audio remote benchmarking * style(asr): apply black formatting * test(main): include remote API model kwarg * test(asr): add coverage for chat-completions error handling and parsing Add 10 new tests to cover uncovered lines in PR #468: - ConnectionError handler in _try_chat_completions_audio_api (lines 1601-1602) - _parse_chat_completion_transcription edge cases: None, list content, empty/whitespace strings, JSON without text field, plain text, and single-line language metadata (lines 1610, 1613-1621, 1625, 1629-1640) Coverage for recognition_manager.py improved from 85% to 86%. * feat(asr): support FunASR remote responses * refactor(asr): scope remote FunASR support to the #469 path Drop chat-completions/Qwen client, evaluation notes, and the remote ASR benchmark harness. Keep model field, tolerant OpenAI response parsing, SenseVoice label stripping, FunASR docs, and tests.
841 lines
31 KiB
Python
841 lines
31 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.common_types import RecognitionState
|
|
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_values(self):
|
|
"""Test model parsing for base and exact whisper.cpp model IDs."""
|
|
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")
|
|
|
|
with patch("sys.argv", ["vocalinux", "--model", "medium.en-q5_0"]):
|
|
args = parse_arguments()
|
|
self.assertEqual(args.model, "medium.en-q5_0")
|
|
|
|
with patch("sys.argv", ["vocalinux", "--model", "large-v3-turbo"]):
|
|
args = parse_arguments()
|
|
self.assertEqual(args.model, "large-v3-turbo")
|
|
|
|
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,
|
|
audio_device_name=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,
|
|
remote_api_url="",
|
|
remote_api_key="",
|
|
remote_api_endpoint="/inference",
|
|
remote_api_model="whisper-1",
|
|
)
|
|
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.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_registered_callbacks_keep_spacing_across_processing_to_listening(
|
|
self,
|
|
mock_init_logging,
|
|
mock_config_manager,
|
|
mock_tray,
|
|
mock_text,
|
|
mock_speech,
|
|
mock_check_deps,
|
|
):
|
|
"""Test main callback wiring preserves spacing between in-session segments."""
|
|
mock_check_deps.return_value = True
|
|
|
|
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_speech_instance = MagicMock()
|
|
mock_text_instance = MagicMock()
|
|
mock_text_instance.inject_text.return_value = True
|
|
mock_tray_instance = MagicMock()
|
|
|
|
mock_speech.return_value = mock_speech_instance
|
|
mock_text.return_value = mock_text_instance
|
|
mock_tray.return_value = mock_tray_instance
|
|
|
|
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 = False
|
|
mock_args.start_minimized = False
|
|
mock_parse.return_value = mock_args
|
|
|
|
main()
|
|
|
|
text_callback = mock_speech_instance.register_text_callback.call_args.args[0]
|
|
state_callback = mock_speech_instance.register_state_callback.call_args.args[0]
|
|
|
|
text_callback("Hello.")
|
|
state_callback(RecognitionState.PROCESSING)
|
|
state_callback(RecognitionState.LISTENING)
|
|
text_callback("World")
|
|
|
|
calls = [call.args[0] for call in mock_text_instance.inject_text.call_args_list]
|
|
self.assertEqual(calls, ["Hello.", " World"])
|
|
|
|
state_callback(RecognitionState.IDLE)
|
|
mock_text_instance.inject_text.reset_mock()
|
|
text_callback("Next session")
|
|
mock_text_instance.inject_text.assert_called_once_with("Next session")
|
|
|
|
@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_does_not_require_pynput(self):
|
|
"""Test startup is allowed when the optional pynput backend is unavailable."""
|
|
mock_gi = MagicMock()
|
|
mock_gi.require_version = MagicMock()
|
|
mock_gtk = MagicMock()
|
|
mock_appindicator = MagicMock()
|
|
mock_requests = MagicMock()
|
|
|
|
with patch.dict(
|
|
"sys.modules",
|
|
{
|
|
"gi": mock_gi,
|
|
"gi.repository": MagicMock(Gtk=mock_gtk, AppIndicator3=mock_appindicator),
|
|
"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_to_inject)
|
|
|
|
def on_state_change(state: RecognitionState):
|
|
if state == RecognitionState.IDLE:
|
|
action_handler.set_last_injected_text("")
|
|
|
|
return text_callback_wrapper, on_state_change, 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, on_state_change, text_system, _ = self._make_callback()
|
|
cb("first session")
|
|
on_state_change(RecognitionState.IDLE)
|
|
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"])
|
|
|
|
def test_processing_to_listening_keeps_segment_spacing(self):
|
|
cb, on_state_change, text_system, _ = self._make_callback()
|
|
cb("Hello.")
|
|
on_state_change(RecognitionState.PROCESSING)
|
|
on_state_change(RecognitionState.LISTENING)
|
|
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()
|