Files
voculinux/tests/test_main_args_deps.py
T
xavierk a4a831c3c6
CI / security (push) Successful in 1m11s
CI / lint (push) Successful in 22s
CI / ai-review (push) Skipped
fix(src): drop remaining upstream install and wiki references (#40)
The missing-dependency error gives the Void xbps instruction instead of piping upstream's installer, the IBus hint points at the fork's docs (niri names its own notes), and the IBus engine homepage is the fork. VocaGateway stays upstream, with the credit stated in code and docs.
2026-11-06 16:32:52 +00:00

669 lines
25 KiB
Python

"""
Additional coverage tests for main.py module.
Tests for parse_args(), main(), and VoculinuxApp class (if present).
Covers normal startup, debug mode, version flag, keyboard interrupt, and exception handling.
"""
import os
import sys
import unittest
from contextlib import redirect_stdout
from io import StringIO
from unittest.mock import MagicMock, call, patch
import pytest
# Autouse fixture to prevent sys.modules pollution between tests
@pytest.fixture(autouse=True)
def _restore_sys_modules():
saved = dict(sys.modules)
yield
added = set(sys.modules.keys()) - set(saved.keys())
for k in added:
del sys.modules[k]
# Also drop the stale attribute from the parent package so that
# mock.patch and a later re-import resolve to the same fresh module
parent, _, child = k.rpartition(".")
if parent and parent in sys.modules:
try:
delattr(sys.modules[parent], child)
except AttributeError:
pass
for k, v in saved.items():
if k not in sys.modules or sys.modules[k] is not v:
sys.modules[k] = v
class TestParseArguments(unittest.TestCase):
"""Tests for parse_arguments() function."""
def test_parse_args_defaults(self):
"""Test parsing arguments with defaults."""
with patch.object(sys, "argv", ["voculinux"]):
from vocalinux.main import parse_arguments
args = parse_arguments()
assert args.debug is False
assert args.model is None
assert args.language is None
assert args.engine is None
assert args.wayland is False
assert args.start_minimized is False
assert args.transcribe_file is None
def test_parse_args_debug_flag(self):
"""Test parsing with debug flag."""
with patch.object(sys, "argv", ["voculinux", "--debug"]):
from vocalinux.main import parse_arguments
args = parse_arguments()
assert args.debug is True
def test_parse_args_version_flag(self):
"""Test that --version prints the package version and exits."""
from vocalinux.version import __version__
output = StringIO()
with patch.object(sys, "argv", ["voculinux", "--version"]):
from vocalinux.main import parse_arguments
with redirect_stdout(output), self.assertRaises(SystemExit) as exit_context:
parse_arguments()
assert exit_context.exception.code == 0
assert output.getvalue() == f"voculinux {__version__}\n"
def test_parse_args_model_argument(self):
"""Test parsing with model argument."""
with patch.object(sys, "argv", ["voculinux", "--model", "small"]):
from vocalinux.main import parse_arguments
args = parse_arguments()
assert args.model == "small"
def test_parse_args_language_argument(self):
"""Test parsing with language argument."""
with patch.object(sys, "argv", ["voculinux", "--language", "en-us"]):
from vocalinux.main import parse_arguments
args = parse_arguments()
assert args.language == "en-us"
def test_parse_args_engine_argument(self):
"""Test parsing with engine argument."""
with patch.object(sys, "argv", ["voculinux", "--engine", "whisper_cpp"]):
from vocalinux.main import parse_arguments
args = parse_arguments()
assert args.engine == "whisper_cpp"
def test_parse_args_wayland_flag(self):
"""Test parsing with wayland flag."""
with patch.object(sys, "argv", ["voculinux", "--wayland"]):
from vocalinux.main import parse_arguments
args = parse_arguments()
assert args.wayland is True
def test_parse_args_start_minimized_flag(self):
"""Test parsing with start-minimized flag."""
with patch.object(sys, "argv", ["voculinux", "--start-minimized"]):
from vocalinux.main import parse_arguments
args = parse_arguments()
assert args.start_minimized is True
def test_parse_args_transcribe_file(self):
"""Test parsing with the headless --transcribe-file flag."""
with patch.object(sys, "argv", ["voculinux", "--transcribe-file", "/tmp/a.wav"]):
from vocalinux.main import parse_arguments
args = parse_arguments()
assert args.transcribe_file == "/tmp/a.wav"
def test_run_file_transcription_prints_transcript(self):
"""The headless path prints formatted blocks and skips GTK entirely."""
from vocalinux.main import _run_file_transcription
from vocalinux.speech_recognition.diarization import TranscriptBlock
blocks = [TranscriptBlock(0.0, 1, "Hello there.")]
with patch(
"vocalinux.speech_recognition.diarization.transcribe_audio_file",
return_value=blocks,
):
buffer = StringIO()
with redirect_stdout(buffer):
assert _run_file_transcription("/tmp/a.wav") == 0
assert "Speaker 1: Hello there." in buffer.getvalue()
def test_run_file_transcription_reports_errors_on_stderr(self):
"""Decode/load failures exit non-zero with the error on stderr."""
from vocalinux.main import _run_file_transcription
stderr_buffer = StringIO()
with patch(
"vocalinux.speech_recognition.diarization.transcribe_audio_file",
side_effect=RuntimeError("ffmpeg missing"),
):
with patch.object(sys, "stderr", stderr_buffer):
assert _run_file_transcription("/tmp/a.wav") == 1
assert "ffmpeg missing" in stderr_buffer.getvalue()
def test_parse_args_multiple_arguments(self):
"""Test parsing with multiple arguments."""
with patch.object(
sys,
"argv",
[
"voculinux",
"--debug",
"--model",
"large",
"--language",
"es",
"--engine",
"vosk",
"--wayland",
],
):
from vocalinux.main import parse_arguments
args = parse_arguments()
assert args.debug is True
assert args.model == "large"
assert args.language == "es"
assert args.engine == "vosk"
assert args.wayland is True
class TestCheckDependencies(unittest.TestCase):
"""Tests for check_dependencies() function."""
def test_check_dependencies_success(self):
"""Test successful dependency check with all deps mocked as available."""
from vocalinux.main import check_dependencies
mock_gi = MagicMock()
mock_gi_repo = MagicMock()
with patch("vocalinux.main.logging"):
with patch.dict(
sys.modules,
{
"gi": mock_gi,
"gi.repository": mock_gi_repo,
"pynput": MagicMock(),
"requests": MagicMock(),
},
):
result = check_dependencies()
assert result is True
def test_check_dependencies_missing_gtk(self):
"""Test dependency check with missing GTK."""
from vocalinux.main import check_dependencies
with patch("vocalinux.main.logging"):
with patch("builtins.__import__", side_effect=ImportError("No module")):
# Just verify the function doesn't crash
result = check_dependencies()
assert result is False
def test_check_dependencies_logs_gnome_extension_guidance(self):
from vocalinux.main import check_dependencies
with patch("vocalinux.main.logger") as mock_logger:
with patch("builtins.__import__", side_effect=ImportError("No module")):
result = check_dependencies()
assert result is False
mock_logger.error.assert_any_call(
" NOTE: On GNOME Shell (default on Debian), you also need:"
)
mock_logger.error.assert_any_call(" sudo apt install gnome-shell-extension-appindicator")
def test_check_dependencies_points_void_users_at_the_xbps_package(self):
"""Voculinux ships only as a Void xbps package: the hint names it, no upstream script."""
from vocalinux.main import check_dependencies
with patch("vocalinux.main.logger") as mock_logger:
with patch("builtins.__import__", side_effect=ImportError("No module")):
result = check_dependencies()
assert result is False
logged = [call.args[0] for call in mock_logger.error.call_args_list if call.args]
assert " sudo xbps-install -Su voculinux" in logged
assert (
" https://git.bongbetic.com/xavierk/voculinux/src/branch/main/docs/INSTALL.md"
in logged
)
assert not any(
marker in line for line in logged for marker in ("curl", "| bash", "pipx", "VocaHQ")
)
class TestCheckDisplayAvailable(unittest.TestCase):
"""Tests for check_display_available() function."""
@patch("vocalinux.main.logging")
def test_check_display_available_success(self, mock_logging):
"""Test successful display check - mocks gi to avoid real Gdk calls."""
from vocalinux.main import check_display_available
mock_gi = MagicMock()
mock_gdk = MagicMock()
mock_gdk.Display.get_default.return_value = MagicMock() # display exists
mock_gi_repo = MagicMock(Gdk=mock_gdk)
with patch.dict(sys.modules, {"gi": mock_gi, "gi.repository": mock_gi_repo}):
result = check_display_available()
assert result is True
@patch("vocalinux.main.logging")
def test_check_display_available_no_display(self, mock_logging):
"""Test display check when no display is available."""
from vocalinux.main import check_display_available
mock_gi = MagicMock()
mock_gdk = MagicMock()
mock_gdk.Display.get_default.return_value = None # no display
mock_gi_repo = MagicMock(Gdk=mock_gdk)
with patch.dict(sys.modules, {"gi": mock_gi, "gi.repository": mock_gi_repo}):
result = check_display_available()
assert result is False
class TestCheckAppIndicatorSupport(unittest.TestCase):
def test_check_appindicator_support_true_when_watcher_present(self):
from vocalinux.main import check_appindicator_support
mock_proxy = MagicMock()
mock_names_variant = MagicMock()
mock_names_variant.unpack.return_value = (
["org.freedesktop.DBus", "org.kde.StatusNotifierWatcher"],
)
mock_proxy.call_sync.return_value = mock_names_variant
mock_gio = MagicMock()
mock_gio.DBusProxy.new_for_bus_sync.return_value = mock_proxy
with patch.dict(
sys.modules,
{
"gi": MagicMock(),
"gi.repository": MagicMock(Gio=mock_gio),
},
):
assert check_appindicator_support() is True
def test_check_appindicator_support_false_when_watcher_missing(self):
from vocalinux.main import check_appindicator_support
mock_proxy = MagicMock()
mock_names_variant = MagicMock()
mock_names_variant.unpack.return_value = (["org.freedesktop.DBus"],)
mock_proxy.call_sync.return_value = mock_names_variant
mock_gio = MagicMock()
mock_gio.DBusProxy.new_for_bus_sync.return_value = mock_proxy
with patch.dict(
sys.modules,
{
"gi": MagicMock(),
"gi.repository": MagicMock(Gio=mock_gio),
},
):
assert check_appindicator_support() is False
def test_check_appindicator_support_true_on_exception(self):
from vocalinux.main import check_appindicator_support
mock_gio = MagicMock()
mock_gio.DBusProxy.new_for_bus_sync.side_effect = RuntimeError("dbus unavailable")
with patch.dict(
sys.modules,
{
"gi": MagicMock(),
"gi.repository": MagicMock(Gio=mock_gio),
},
):
assert check_appindicator_support() is True
class TestMainFunction(unittest.TestCase):
"""Tests for the main() function."""
@patch("vocalinux.main.logging")
@patch("vocalinux.main.check_dependencies")
@patch("vocalinux.main.check_display_available")
@patch("vocalinux.main.parse_arguments")
@patch("vocalinux.main.atexit")
def test_main_single_instance_already_running(
self,
mock_atexit,
mock_parse_args,
mock_check_display,
mock_check_deps,
mock_logging,
):
"""Test main() when another instance is already running."""
from vocalinux.main import main
# Patch the function itself: main() binds the module via the package
# attribute, so a patch.dict(sys.modules, ...) entry is ignored whenever
# an earlier test already imported the real vocalinux.single_instance.
with patch("vocalinux.single_instance.acquire_lock", return_value=False):
with pytest.raises(SystemExit) as exc_info:
main()
assert exc_info.value.code == 1
def test_main_version_flag_with_lock_held(self):
"""Test that --version works even when another instance holds the lock."""
from vocalinux.main import main
from vocalinux.version import __version__
mock_single_instance = MagicMock()
mock_single_instance.acquire_lock.return_value = False
output = StringIO()
with patch.dict(sys.modules, {"vocalinux.single_instance": mock_single_instance}):
with patch.object(sys, "argv", ["voculinux", "--version"]):
with redirect_stdout(output):
with pytest.raises(SystemExit) as exc_info:
main()
assert exc_info.value.code == 0
assert output.getvalue() == f"voculinux {__version__}\n"
mock_single_instance.acquire_lock.assert_not_called()
@patch("vocalinux.main.check_display_available")
@patch("vocalinux.main.logging")
@patch("vocalinux.main.check_dependencies")
@patch("vocalinux.main.parse_arguments")
@patch("vocalinux.main.atexit")
def test_main_missing_dependencies(
self, mock_atexit, mock_parse_args, mock_check_deps, mock_logging, mock_check_display
):
"""Test main() when dependencies are missing."""
from vocalinux.main import main
# Set up mocks
mock_check_deps.return_value = False
mock_parse_args.return_value = MagicMock(debug=False)
# Mock single_instance module since it's imported inside main()
mock_single_instance = MagicMock()
mock_single_instance.acquire_lock.return_value = True
with patch.dict(sys.modules, {"vocalinux.single_instance": mock_single_instance}):
# Should exit with status 1
with pytest.raises(SystemExit) as exc_info:
main()
assert exc_info.value.code == 1
@patch("vocalinux.main.logging")
@patch("vocalinux.main.check_dependencies")
@patch("vocalinux.main.check_display_available")
@patch("vocalinux.main.parse_arguments")
@patch("vocalinux.main.atexit")
def test_main_no_display_available(
self, mock_atexit, mock_parse_args, mock_check_display, mock_check_deps, mock_logging
):
"""Test main() when no display is available."""
from vocalinux.main import main
# Set up mocks
mock_check_deps.return_value = True
mock_check_display.return_value = False
mock_parse_args.return_value = MagicMock(debug=False)
# Mock single_instance module since it's imported inside main()
mock_single_instance = MagicMock()
mock_single_instance.acquire_lock.return_value = True
with patch.dict(sys.modules, {"vocalinux.single_instance": mock_single_instance}):
# Should exit with status 1
with pytest.raises(SystemExit) as exc_info:
main()
assert exc_info.value.code == 1
@patch("vocalinux.main.logging")
@patch("vocalinux.main.check_dependencies")
@patch("vocalinux.main.check_display_available")
@patch("vocalinux.main.parse_arguments")
@patch("vocalinux.main.atexit")
def test_main_debug_mode_enabled(
self,
mock_atexit,
mock_parse_args,
mock_check_display,
mock_check_deps,
mock_logging,
):
"""Test main() with debug mode enabled."""
from vocalinux.main import main
# Set up mocks
mock_check_deps.return_value = True
mock_check_display.return_value = True
mock_args = MagicMock()
mock_args.debug = True
mock_args.wayland = False
mock_args.start_minimized = False
mock_parse_args.return_value = mock_args
# Mock single_instance module since it's imported inside main()
mock_single_instance = MagicMock()
mock_single_instance.acquire_lock.return_value = True
# Create proper mock for ConfigManager with get_settings() returning valid data
mock_config_manager = MagicMock()
mock_config_manager.return_value.get_settings.return_value = {
"speech_recognition": {
"engine": "whisper",
"model_size": "medium",
"language": "en-us",
},
"audio": {},
"general": {"first_run": False},
}
# Mock the imported modules
with patch.dict(
sys.modules,
{
"vocalinux.single_instance": mock_single_instance,
"vocalinux.common_types": MagicMock(),
"vocalinux.speech_recognition": MagicMock(
recognition_manager=MagicMock(
SpeechRecognitionManager=MagicMock(
side_effect=Exception("Test exception to stop execution")
)
)
),
"vocalinux.text_injection.text_injector": MagicMock(),
"vocalinux.ui.tray_indicator": MagicMock(),
"vocalinux.ui.action_handler": MagicMock(),
"vocalinux.ui.config_manager": mock_config_manager,
"vocalinux.ui.logging_manager": MagicMock(),
"vocalinux.text_injection": MagicMock(),
"vocalinux.ui.first_run_dialog": MagicMock(),
"vocalinux.ui.autostart_manager": MagicMock(),
"vocalinux.text_injection.start_ibus_daemon": MagicMock(),
},
):
# Mock the config manager and other components
with patch("vocalinux.main.logging.getLogger") as mock_get_logger:
mock_logger = MagicMock()
mock_get_logger.return_value = mock_logger
# This would normally start the GTK main loop, which we want to avoid
# The initialization should fail as we've set SpeechRecognitionManager to raise an exception
with pytest.raises(SystemExit) as exc_info:
main()
assert exc_info.value.code == 1
@patch("vocalinux.main.logging")
@patch("vocalinux.main.check_dependencies")
@patch("vocalinux.main.check_display_available")
@patch("vocalinux.main.check_appindicator_support")
@patch("vocalinux.main.parse_arguments")
@patch("vocalinux.main.atexit")
def test_main_logs_warning_when_appindicator_support_missing(
self,
mock_atexit,
mock_parse_args,
mock_check_appindicator,
mock_check_display,
mock_check_deps,
mock_logging,
):
from vocalinux.main import main
mock_check_deps.return_value = True
mock_check_display.return_value = True
mock_check_appindicator.return_value = False
mock_args = MagicMock()
mock_args.debug = False
mock_args.wayland = False
mock_args.start_minimized = False
mock_args.engine = None
mock_args.model = None
mock_args.language = None
mock_parse_args.return_value = mock_args
mock_single_instance = MagicMock()
mock_single_instance.acquire_lock.return_value = True
mock_config_manager = MagicMock()
mock_config_manager.return_value.get_settings.return_value = {
"speech_recognition": {},
"audio": {},
"general": {"first_run": False},
}
mock_speech_manager_ctor = MagicMock(side_effect=Exception("stop"))
with patch.dict(
sys.modules,
{
"vocalinux.single_instance": mock_single_instance,
"vocalinux.common_types": MagicMock(),
"vocalinux.speech_recognition": MagicMock(
recognition_manager=MagicMock(SpeechRecognitionManager=mock_speech_manager_ctor)
),
"vocalinux.text_injection.text_injector": MagicMock(),
"vocalinux.ui.tray_indicator": MagicMock(),
"vocalinux.ui.action_handler": MagicMock(),
"vocalinux.ui.config_manager": mock_config_manager,
"vocalinux.ui.logging_manager": MagicMock(),
"vocalinux.text_injection": MagicMock(start_ibus_daemon=MagicMock()),
"vocalinux.ui.first_run_dialog": MagicMock(),
"vocalinux.ui.autostart_manager": MagicMock(),
},
):
with patch("vocalinux.main.logger") as mock_logger:
with pytest.raises(SystemExit):
main()
mock_logger.warning.assert_any_call(
"No StatusNotifierWatcher found on D-Bus session bus."
)
mock_logger.warning.assert_any_call("The system tray icon may not appear.")
mock_speech_manager_ctor.assert_called_once()
@patch("vocalinux.main.logging")
@patch("vocalinux.main.check_dependencies")
@patch("vocalinux.main.check_display_available")
@patch("vocalinux.main.parse_arguments")
@patch("vocalinux.main.atexit")
def test_main_initialization_error(
self,
mock_atexit,
mock_parse_args,
mock_check_display,
mock_check_deps,
mock_logging,
):
"""Test main() when initialization fails."""
from vocalinux.main import main
# Set up mocks
mock_check_deps.return_value = True
mock_check_display.return_value = True
mock_args = MagicMock()
mock_args.debug = False
mock_args.wayland = False
mock_args.start_minimized = False
mock_parse_args.return_value = mock_args
# Mock single_instance module since it's imported inside main()
mock_single_instance = MagicMock()
mock_single_instance.acquire_lock.return_value = True
# Mock components that will raise an exception
with patch("vocalinux.main.logging.getLogger") as mock_get_logger:
mock_logger = MagicMock()
mock_get_logger.return_value = mock_logger
with patch.dict(
sys.modules,
{
"vocalinux.single_instance": mock_single_instance,
"vocalinux.common_types": MagicMock(),
"vocalinux.speech_recognition": MagicMock(
recognition_manager=MagicMock(
SpeechRecognitionManager=MagicMock(side_effect=Exception("Init error"))
)
),
"vocalinux.text_injection.text_injector": MagicMock(),
"vocalinux.ui.tray_indicator": MagicMock(),
"vocalinux.ui.action_handler": MagicMock(),
"vocalinux.ui.config_manager": MagicMock(),
"vocalinux.ui.logging_manager": MagicMock(),
"vocalinux.text_injection": MagicMock(),
"vocalinux.ui.first_run_dialog": MagicMock(),
"vocalinux.ui.autostart_manager": MagicMock(),
"vocalinux.text_injection.start_ibus_daemon": MagicMock(),
},
):
# Should exit with status 1
with pytest.raises(SystemExit) as exc_info:
main()
assert exc_info.value.code == 1
class TestMainEntry(unittest.TestCase):
"""Tests for the main entry point and script execution."""
@patch("vocalinux.main.main")
def test_main_entry_point(self, mock_main):
"""Test that the script entry point calls main()."""
# Import the module and verify the entry point
import vocalinux.main as main_module
# Check that main function exists
assert hasattr(main_module, "main")
assert callable(main_module.main)
def test_module_can_be_imported(self):
"""Test that the main module can be imported successfully."""
try:
import vocalinux.main # noqa: F401
assert True
except Exception as e:
pytest.fail(f"Failed to import vocalinux.main: {e}")
if __name__ == "__main__":
unittest.main()