Files
voculinux/tests/test_main.py
T
Jatin K MalikandGitHub 9e5b632710 test: Improve test coverage to 80%+ (#122)
* test: Improve test coverage to 80%

Increased test coverage from ~33% to 80.15% statement coverage.

New test files added:
- test_action_handler.py (100% coverage)
- test_version.py (100% coverage)
- test_logging_manager.py (100% coverage)
- test_common_types.py
- test_speech_recognition_init.py (100% coverage)
- test_resource_manager.py (93% coverage)

Significantly expanded existing tests:
- test_tray_indicator.py: 76% -> 98%
- test_main.py: 50% -> 92%
- test_text_injector.py: 60% -> 94%
- test_recognition_manager.py: 30% -> 48%
- test_command_processor.py: 85% -> 98%
- test_keyboard_shortcuts.py: 88% -> 95%
- test_config_manager.py: improved to 98%
- test_audio_feedback.py: improved to 92%

Configuration changes:
- Updated pyproject.toml to exclude untestable GTK dialog files
  (settings_dialog.py, logging_dialog.py) from coverage metrics

Key testing improvements:
- Added comprehensive GTK mocking for UI components
- Added edge case tests for text injection on X11/Wayland
- Added keyboard shortcut injection tests
- Added error handling and callback tests
- Added configuration management tests

* fix: Use runtime CI mode detection to fix Python 3.8/3.10 tests

Change CI_MODE from module-level constant to _is_ci_mode() function
that is evaluated at runtime. This ensures pytest detection works
correctly on all Python versions, since pytest modules may not be
loaded at import time.

* fix: Add PYTEST_RUNNING env var for reliable pytest detection in CI

The previous approach to detect pytest at runtime wasn't working
reliably on Python 3.8/3.10 in CI environments. This adds a
PYTEST_RUNNING=1 environment variable set early in conftest.py
that _is_ci_mode() can check to reliably determine if tests are
running.

* fix: Use patch.object for reliable mocking in audio_feedback tests

The previous approach using patch() with module paths didn't work
reliably because the module reimport caused the patched objects
to not be the same as the ones the functions referenced.

Using patch.object() on the imported module's attributes ensures
the patches are applied to the actual objects the functions use.

* fix: Use patch.object for reliable mocking in test_settings_callback

The test was failing on Python 3.8/3.10 because the module-level patch
for SettingsDialog wasn't being applied reliably across all Python
versions due to import timing differences.

Using patch.object() on the imported module ensures the mock is applied
to the actual reference that _on_settings_clicked() uses, regardless of
Python version or module caching behavior.
2026-02-04 21:01:34 -08:00

488 lines
17 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)
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",
],
):
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)
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 = {}
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": {}}
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,
)
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": {}}
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()
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(self):
"""Test when AppIndicator3 is missing."""
# Make gi.require_version raise ValueError for AppIndicator3
def require_version_side_effect(name, version):
if name == "AppIndicator3":
raise ValueError("AppIndicator3 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()
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",
}
}
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,
},
}
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()