Files
voculinux/tests/test_main.py
T
Jatin K MalikandGitHub 6fc1401ae0 feat(commands): make voice commands optional with auto-enable for VOSK (#274)
* feat(commands): make voice commands optional with auto-enable for VOSK

This implements a new voice commands feature that:
- Adds 'voice_commands_enabled' config option (None=auto, True, False)
- Auto-enables voice commands for VOSK engine (which needs them for punctuation)
- Auto-disables for Whisper engines (which handle punctuation natively)
- Adds a toggle in Settings > Recognition tab to override the default

The toggle shows the current auto-detected state and allows users to
enable/disable voice commands regardless of engine choice.

This addresses issue #147 discussion about voice commands utility with
modern Whisper models that handle punctuation well.

Closes #270 (by providing an alternative solution - disabling commands)

* fix(tests): add voice_commands_enabled parameter to test expectations

Update test assertions to include the new voice_commands_enabled
parameter that was added to SpeechRecognitionManager.

* style(tests): fix black formatting in test_speech_recognition.py

* fix(commands): keep voice command mode in sync on reconfigure
2026-02-28 13:06:05 -08:00

653 lines
23 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",
language="en-us",
model_size="medium",
vad_sensitivity=3,
silence_timeout=2.0,
audio_device_index=None,
voice_commands_enabled=None,
)
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
)
# 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)
if __name__ == "__main__":
unittest.main()