* fix(config): share one ConfigManager across the process main(), the tray, and the sound-effects check each constructed their own ConfigManager. Every instance loads the whole config into memory at construction time and save_config() writes that whole cache back, so any save from a stale instance silently reverts what another instance saved in the meantime — no race needed, the second save just wins with old values. The disk-read workarounds in main (_should_append_trailing_space) already existed to dodge exactly this. Add get_shared_config_manager() and route the three call sites through it. It keeps ConfigManager constructible for tests, and the tray/dialog pair already shared one instance, so this extends that arrangement to the rest of the process. Tests: a regression test runs the two-instance scenario through the accessor, and a source guard fails if application code constructs ConfigManager directly again. conftest drops the shared instance between tests, since it would otherwise be created under whichever mocks the first caller had active and leak them into later tests. The two single-instance tests now patch acquire_lock itself: they used patch.dict(sys.modules, ...), which main()'s "from . import" ignores once an earlier test has imported the real module — they only passed through an unrelated mock leaking into the tray's shortcut setup, which this change stops. * fix(config): lock the shared accessor and prove the revert it prevents Review follow-ups on #691: - guard the lazy create with a lock, so two threads racing the first call cannot each build a ConfigManager and reintroduce #689 - take both handles before the first save in the accessor test, and assert identity there — the old order let a fresh ConfigManager pass - add a characterization test: two direct ConfigManager instances still revert each other, which is the footgun the accessor exists to hide - patch get_shared_config_manager in test_main and test_audio_feedback instead of the class, so a leftover singleton cannot skip the mock - close the ExitStack in _boot_under_patches when main() raises; the caller only closes a stack it received, so a failure there leaked those patches into every later test * fix(audio): route the tone lookup through the shared ConfigManager The tone picker merged on main (#707) constructs its own ConfigManager, which is exactly the stale-cache overwrite this branch removes; the new source guard in test_shared_config_manager.py flags it.
470 lines
18 KiB
Python
470 lines
18 KiB
Python
"""
|
|
Additional coverage tests for main.py module.
|
|
|
|
Tests for uncovered lines in parse_arguments(), check_display_available(), and main().
|
|
Covers edge cases and error paths.
|
|
"""
|
|
|
|
import logging
|
|
import sys
|
|
from unittest.mock import MagicMock, call, patch
|
|
|
|
import pytest
|
|
|
|
|
|
# Autouse fixture to prevent sys.modules pollution
|
|
@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]
|
|
for k, v in saved.items():
|
|
if k not in sys.modules or sys.modules[k] is not v:
|
|
sys.modules[k] = v
|
|
|
|
|
|
class TestCheckDisplayAvailableExtra:
|
|
"""Additional tests for check_display_available() error handling."""
|
|
|
|
@patch("vocalinux.main.logging")
|
|
def test_check_display_available_exception_handling(self, mock_logging):
|
|
"""Test check_display_available when an exception is raised."""
|
|
from vocalinux.main import check_display_available
|
|
|
|
# Mock gi so we don't call real Gdk.Display.get_default() (hangs in headless CI)
|
|
mock_gi = MagicMock()
|
|
mock_gi.require_version.side_effect = ValueError("Cannot find Gdk")
|
|
with patch.dict(sys.modules, {"gi": mock_gi, "gi.repository": MagicMock()}):
|
|
result = check_display_available()
|
|
# Should handle the exception gracefully and return False
|
|
assert result is False
|
|
|
|
@patch("vocalinux.main.logging")
|
|
def test_check_display_available_no_display_none(self, mock_logging):
|
|
"""Test check_display_available when Gdk.Display.get_default() returns None."""
|
|
from vocalinux.main import check_display_available
|
|
|
|
mock_gdk = MagicMock()
|
|
mock_gdk.Display.get_default.return_value = None
|
|
|
|
with patch.dict(sys.modules, {"gi": MagicMock(), "gi.repository": MagicMock()}):
|
|
with patch("builtins.__import__") as mock_import:
|
|
|
|
def side_effect(name, *args, **kwargs):
|
|
if name == "gi":
|
|
mock_gi = MagicMock()
|
|
mock_gi.require_version = MagicMock()
|
|
return mock_gi
|
|
elif name == "gi.repository.Gdk" or (
|
|
len(args) > 2 and args[2] and "Gdk" in str(args[2])
|
|
):
|
|
return mock_gdk
|
|
return MagicMock()
|
|
|
|
mock_import.side_effect = side_effect
|
|
|
|
result = check_display_available()
|
|
# Function should handle this gracefully
|
|
assert isinstance(result, bool)
|
|
|
|
|
|
class TestMainFunctionExtra:
|
|
"""Additional tests for main() function edge cases."""
|
|
|
|
@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_notification_on_second_instance(
|
|
self, mock_atexit, mock_parse_args, mock_check_display, mock_check_deps, mock_logging
|
|
):
|
|
"""Test that notification is attempted when another instance is 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
|
|
|
|
@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_first_run_dialog_yes(
|
|
self, mock_atexit, mock_parse_args, mock_check_display, mock_check_deps, mock_logging
|
|
):
|
|
"""Test main() when first run dialog returns 'yes'."""
|
|
from vocalinux.main import main
|
|
|
|
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 = MagicMock()
|
|
mock_single_instance.acquire_lock.return_value = True
|
|
|
|
mock_config_manager = MagicMock()
|
|
mock_config_manager.return_value.get_settings.return_value = {
|
|
"speech_recognition": {
|
|
"engine": "whisper_cpp",
|
|
"model_size": "small",
|
|
"language": "en-us",
|
|
},
|
|
"audio": {},
|
|
"general": {"first_run": True},
|
|
}
|
|
mock_config_manager.return_value.set = MagicMock()
|
|
mock_config_manager.return_value.save_settings = MagicMock()
|
|
|
|
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 exit"))
|
|
)
|
|
),
|
|
"vocalinux.text_injection": MagicMock(),
|
|
"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.ui.first_run_dialog": MagicMock(
|
|
show_first_run_dialog=MagicMock(return_value="yes")
|
|
),
|
|
"vocalinux.ui.autostart_manager": MagicMock(),
|
|
"vocalinux.text_injection.start_ibus_daemon": MagicMock(return_value=False),
|
|
},
|
|
):
|
|
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_first_run_dialog_no(
|
|
self, mock_atexit, mock_parse_args, mock_check_display, mock_check_deps, mock_logging
|
|
):
|
|
"""Test main() when first run dialog returns 'no'."""
|
|
from vocalinux.main import main
|
|
|
|
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 = MagicMock()
|
|
mock_single_instance.acquire_lock.return_value = True
|
|
|
|
mock_config_manager = MagicMock()
|
|
mock_config_manager.return_value.get_settings.return_value = {
|
|
"speech_recognition": {
|
|
"engine": "whisper_cpp",
|
|
"model_size": "small",
|
|
"language": "en-us",
|
|
},
|
|
"audio": {},
|
|
"general": {"first_run": True},
|
|
}
|
|
mock_config_manager.return_value.set = MagicMock()
|
|
mock_config_manager.return_value.save_settings = MagicMock()
|
|
|
|
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 exit"))
|
|
)
|
|
),
|
|
"vocalinux.text_injection": MagicMock(),
|
|
"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.ui.first_run_dialog": MagicMock(
|
|
show_first_run_dialog=MagicMock(return_value="no")
|
|
),
|
|
"vocalinux.ui.autostart_manager": MagicMock(),
|
|
"vocalinux.text_injection.start_ibus_daemon": MagicMock(return_value=False),
|
|
},
|
|
):
|
|
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_first_run_dialog_cancel(
|
|
self, mock_atexit, mock_parse_args, mock_check_display, mock_check_deps, mock_logging
|
|
):
|
|
"""Test main() when first run dialog is cancelled."""
|
|
from vocalinux.main import main
|
|
|
|
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 = MagicMock()
|
|
mock_single_instance.acquire_lock.return_value = True
|
|
|
|
mock_config_manager = MagicMock()
|
|
mock_config_manager.return_value.get_settings.return_value = {
|
|
"speech_recognition": {
|
|
"engine": "whisper_cpp",
|
|
"model_size": "small",
|
|
"language": "en-us",
|
|
},
|
|
"audio": {},
|
|
"general": {"first_run": True},
|
|
}
|
|
|
|
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 exit"))
|
|
)
|
|
),
|
|
"vocalinux.text_injection": MagicMock(),
|
|
"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.ui.first_run_dialog": MagicMock(
|
|
show_first_run_dialog=MagicMock(return_value="cancel")
|
|
),
|
|
"vocalinux.ui.autostart_manager": MagicMock(),
|
|
"vocalinux.text_injection.start_ibus_daemon": MagicMock(return_value=False),
|
|
},
|
|
):
|
|
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_cli_args_override_config(
|
|
self, mock_atexit, mock_parse_args, mock_check_display, mock_check_deps, mock_logging
|
|
):
|
|
"""Test that CLI arguments override config settings."""
|
|
from vocalinux.main import main
|
|
|
|
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_args.engine = "vosk"
|
|
mock_args.model = "medium"
|
|
mock_args.language = "es"
|
|
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": {
|
|
"engine": "whisper_cpp",
|
|
"model_size": "small",
|
|
"language": "en-us",
|
|
},
|
|
"audio": {},
|
|
"general": {"first_run": False},
|
|
}
|
|
|
|
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 exit"))
|
|
)
|
|
),
|
|
"vocalinux.text_injection": MagicMock(),
|
|
"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.ui.first_run_dialog": MagicMock(),
|
|
"vocalinux.ui.autostart_manager": MagicMock(),
|
|
"vocalinux.text_injection.start_ibus_daemon": MagicMock(return_value=False),
|
|
},
|
|
):
|
|
# Mock sys.argv to have CLI arguments
|
|
with patch.object(
|
|
sys,
|
|
"argv",
|
|
["vocalinux", "--engine", "vosk", "--model", "medium", "--language", "es"],
|
|
):
|
|
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_ibus_daemon_startup(
|
|
self, mock_atexit, mock_parse_args, mock_check_display, mock_check_deps, mock_logging
|
|
):
|
|
"""Test that IBus daemon is started successfully."""
|
|
from vocalinux.main import main
|
|
|
|
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 = MagicMock()
|
|
mock_single_instance.acquire_lock.return_value = True
|
|
|
|
mock_config_manager = MagicMock()
|
|
mock_config_manager.return_value.get_settings.return_value = {
|
|
"speech_recognition": {
|
|
"engine": "whisper_cpp",
|
|
"model_size": "small",
|
|
"language": "en-us",
|
|
},
|
|
"audio": {},
|
|
"general": {"first_run": False},
|
|
}
|
|
|
|
mock_ibus_daemon = MagicMock(return_value=True)
|
|
|
|
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 exit"))
|
|
)
|
|
),
|
|
"vocalinux.text_injection": MagicMock(start_ibus_daemon=mock_ibus_daemon),
|
|
"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.ui.first_run_dialog": MagicMock(),
|
|
"vocalinux.ui.autostart_manager": MagicMock(),
|
|
},
|
|
):
|
|
with pytest.raises(SystemExit) as exc_info:
|
|
main()
|
|
assert exc_info.value.code == 1
|
|
mock_ibus_daemon.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_ibus_daemon_error(
|
|
self, mock_atexit, mock_parse_args, mock_check_display, mock_check_deps, mock_logging
|
|
):
|
|
"""Test main() when IBus daemon startup fails."""
|
|
from vocalinux.main import main
|
|
|
|
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 = MagicMock()
|
|
mock_single_instance.acquire_lock.return_value = True
|
|
|
|
mock_config_manager = MagicMock()
|
|
mock_config_manager.return_value.get_settings.return_value = {
|
|
"speech_recognition": {
|
|
"engine": "whisper_cpp",
|
|
"model_size": "small",
|
|
"language": "en-us",
|
|
},
|
|
"audio": {},
|
|
"general": {"first_run": False},
|
|
}
|
|
|
|
mock_ibus_daemon = MagicMock(side_effect=Exception("IBus error"))
|
|
|
|
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 exit"))
|
|
)
|
|
),
|
|
"vocalinux.text_injection": MagicMock(start_ibus_daemon=mock_ibus_daemon),
|
|
"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.ui.first_run_dialog": MagicMock(),
|
|
"vocalinux.ui.autostart_manager": MagicMock(),
|
|
},
|
|
):
|
|
with pytest.raises(SystemExit) as exc_info:
|
|
main()
|
|
assert exc_info.value.code == 1
|