* feat(dictionary): combined custom dictionary with corrections Rebased onto latest main (conflict-resolution squash for PR #818). * fix(dictionary): stabilize the terms and corrections file contracts - corrections_path() stays at the fixed config_dir() location promised by the file contract instead of following a custom or --dictionary-file terms path, which could strand or duplicate corrections; a leftover file beside the terms file is still read once and copied over. - _atomic_write() preserves the replaced file's permission mode instead of resetting it to the process umask. - build_initial_prompt() streams terms and stops at the configured cap instead of reading and processing the whole file for every segment. * fix(settings): restore the selected dictionary pane after a search Searching can auto-switch the Custom Dictionary management pane to reveal a match, but clearing the query only restored widget visibility. Record the pane selection in the search baseline snapshot and restore it alongside the previous page so users return to the pane they chose. * fix(dictionary): close the remaining custom dictionary file-contract gaps - A --dictionary-file session override no longer imports a corrections file found beside the override into the standard config directory; the override scopes to terms only. - Terms reads validate the whole file before yielding: corrupt UTF-8 mid-file now yields no terms at all, matching the invalid status Settings reports instead of leaking a partial dictionary to recognition. - The terms read is bounded by MAX_TERMS_FILE_BYTES (1 MiB); a larger file is invalid everywhere with a dedicated status instead of being read on every segment. * fix(dictionary): keep usable terms prefix on oversized files and legacy corrections under overrides * fix(dictionary): cap yielded terms and keep added terms visible on oversized files * fix(dictionary): keep added terms visible without dropping scanner-owned lines * fix(dictionary): keep oversized terms manageable and honor mid-search pane switches - terms list gains a Show all / Show first 500 toggle; adding a term beyond the cap auto-expands so the new row renders with a Remove button instead of being invisible until the file is hand-edited - a manual dictionary pane switch during an active search drops the saved pre-search pane so clearing the query no longer rolls back the user's newer selection - annotate the new settings tests and type model_info so the parakeet branch stops adding fresh mypy errors * fix(settings): do not auto-expand terms on add past display cap Rebuilding up to thousands of term rows on the UI thread stalls the dialog; the overflow button already shows the true count, so the added term stays one click away. * test(dictionary): stub segment_callbacks on bare manager for history dispatch * fix(dictionary): stream oversized terms files when building the prompt build_initial_prompt decoded the whole 1 MiB read window before every transcription segment even though it only needs the first max_words terms. _iter_terms_for_prompt now reads oversized files line by line and stops at the prompt limit, keeping the same contract: complete leading lines only, invalid UTF-8 inside the usable window yields nothing. * fix(history): only the ended session's worker may extend its snippet A late segment could extend the newest history entry regardless of which session produced it, so a worker finishing after the next dictation session leaked its text into that session's entry. Track the worker that owned the ended session and require late segments to arrive on the same thread before merging; anything else forms its own snippet. * fix(speech): transcribe queued segments the reload worker never drained The recognition worker can exit during stop_recognition's bounded audio-thread join, so the released key's final buffer lands in the queue after the worker is gone and was silently dropped when the reload teardown replaced the queue. Drain whatever remains before swapping it — except on capture failure or an explicit cancel, which still discard the session's leftovers. * fix(settings): build term rows in idle slices and pin hidden additions Two problems shared one cause. Rebuilding up to 4096 rows synchronously stalled the dialog, and a term appended past the 500-row cap reported success while staying invisible. Rows now build in bounded GLib idle slices guarded by a refresh token, and a saved term outside the visible window is pinned to the top of the capped list until it disappears or becomes visible on its own. Adding under "show all" scrolls to the new term instead. * test(engine): annotate new test method return types * fix(ci): match a candidate row that leads the latest track block snapcraft status normally prints candidate as a continuation line under the latest track (channel in column 1). If candidate ever leads the track block it rides the track line instead (channel in column 3), and the resolver missed it and refused to promote. Match both layouts in the resolve and the pre-release re-verify. * fix(dictionary): bound prompt reads and scroll to landed terms - _iter_terms_for_prompt now reads each line via readline() capped by the remaining byte window, so an overlong line can never pull more than MAX_TERMS_FILE_BYTES into memory - the prompt path keeps decoding every complete line inside the usable window after the term limit is collected, so invalid UTF-8 anywhere in the window still yields no terms and matches the reader's invalid status - under "show all" Settings scrolls to the index where an added term landed (prepended or appended), verified against the row's label, instead of always scrolling to the end * fix(history,reload): keep late-segment ownership and drain stamped tuples - Track session_workers_seen so a worker from an earlier session can never merge into a newer snippet while a session is open - Unpack (segment, language) queue tuples in _transcribe_queued_segments; passing the tuple as audio raised TypeError and lost final speech - Refuse 'delete that' when a manual pad edit blurred segment boundaries - Translate term-row coordinates into the scrolled card's space so the scroll actually reaches rows below the fold - Type _on_dictionary_pane_changed's varargs * style: drop unneeded nonlocal for mutated worker set --------- Co-authored-by: Graham <graham@thegeeky.ninja>
1955 lines
76 KiB
Python
1955 lines
76 KiB
Python
"""
|
|
Tests for the main module functionality.
|
|
"""
|
|
|
|
import argparse
|
|
import queue
|
|
import sys
|
|
import threading
|
|
import time
|
|
import unittest
|
|
from contextlib import ExitStack
|
|
from types import SimpleNamespace
|
|
from typing import Any, Callable, Dict, Optional, Tuple
|
|
from unittest.mock import ANY, 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
|
|
from vocalinux.ui.transcription_history import TranscriptionHistory
|
|
|
|
|
|
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.assertIsNone(args.dictionary_file)
|
|
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 from the catalog."""
|
|
from vocalinux.main import LANGUAGE_CHOICES
|
|
from vocalinux.utils.vosk_model_info import SUPPORTED_LANGUAGES
|
|
|
|
self.assertEqual(LANGUAGE_CHOICES, tuple(SUPPORTED_LANGUAGES.keys()))
|
|
# Spot-check newly exposed languages (issue #565 and catalog expansion).
|
|
for lang in ["auto", "en-us", "hu", "ja", "ko", "ar", "nl", "pl", "uk"]:
|
|
self.assertIn(lang, LANGUAGE_CHOICES)
|
|
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.get_shared_config_manager")
|
|
@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.dictation_pad.DictationPad")
|
|
@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.get_shared_config_manager")
|
|
@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_dictation_pad,
|
|
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_bool.return_value = True
|
|
mock_config_instance.get_settings.return_value = {
|
|
"speech_recognition": {},
|
|
"general": {"first_run": False},
|
|
}
|
|
mock_config_instance.get_model_size_for_engine.return_value = "medium"
|
|
mock_config_instance.is_dictate_to_pad_enabled.return_value = 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_pad_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_dictation_pad.return_value = mock_pad_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,
|
|
buffer_during_reload=True,
|
|
voice_commands_enabled=None,
|
|
audio_device_index=None,
|
|
audio_device_name=None,
|
|
whispercpp_no_timestamps=True,
|
|
whispercpp_no_context=True,
|
|
whispercpp_initial_prompt="",
|
|
whispercpp_language_candidates="",
|
|
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,
|
|
whispercpp_gpu_device=None,
|
|
dictionary_manager=ANY,
|
|
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,
|
|
transcription_history=ANY,
|
|
dictation_pad=mock_pad_instance,
|
|
)
|
|
# The pad starts from the shared manager's live capture flag.
|
|
mock_dictation_pad.assert_called_once_with(
|
|
enabled=False, config_manager=mock_config_instance
|
|
)
|
|
|
|
# Verify callbacks were registered
|
|
mock_speech_instance.register_text_callback.assert_called_once()
|
|
mock_speech_instance.register_segment_callback.assert_called_once()
|
|
mock_speech_instance.register_action_callback.assert_called_once()
|
|
# The registered wrapper delegates non-pad actions to ActionHandler.
|
|
action_callback = mock_speech_instance.register_action_callback.call_args.args[0]
|
|
action_callback("undo")
|
|
mock_action_instance.handle_action.assert_called_once_with("undo")
|
|
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.get_shared_config_manager")
|
|
@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_instance.is_dictate_to_pad_enabled.return_value = 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")
|
|
# Trailing space persists in the previous field; next session starts clean
|
|
# (no leading space) but still gets its own trailing space.
|
|
mock_text_instance.inject_text.assert_called_once_with("Next session ")
|
|
|
|
def _run_main_and_get_text_callback(
|
|
self,
|
|
*,
|
|
engine: str,
|
|
auto_capitalize: bool,
|
|
mock_config_manager,
|
|
mock_tray,
|
|
mock_text,
|
|
mock_speech,
|
|
mock_check_deps,
|
|
):
|
|
"""Boot main() with mocked deps and return the registered text callback."""
|
|
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_instance.get.side_effect = lambda section, key, default=None: (
|
|
auto_capitalize if section == "text_injection" and key == "auto_capitalize" else default
|
|
)
|
|
mock_config_instance.is_dictate_to_pad_enabled.return_value = False
|
|
mock_config_manager.return_value = mock_config_instance
|
|
|
|
mock_speech_instance = MagicMock()
|
|
mock_speech_instance.engine = engine
|
|
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 = engine
|
|
mock_args.language = "en-us"
|
|
mock_args.wayland = False
|
|
mock_args.start_minimized = False
|
|
mock_parse.return_value = mock_args
|
|
|
|
with patch("sys.argv", ["vocalinux", "--engine", engine]):
|
|
main()
|
|
|
|
text_callback = mock_speech_instance.register_text_callback.call_args.args[0]
|
|
return text_callback, mock_text_instance
|
|
|
|
@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.get_shared_config_manager")
|
|
@patch("vocalinux.ui.logging_manager.initialize_logging")
|
|
def test_vosk_auto_capitalize_applies_to_injected_text(
|
|
self,
|
|
mock_init_logging,
|
|
mock_config_manager,
|
|
mock_tray,
|
|
mock_text,
|
|
mock_speech,
|
|
mock_check_deps,
|
|
):
|
|
"""Vosk engine capitalizes sentences when auto_capitalize is enabled."""
|
|
text_callback, mock_text_instance = self._run_main_and_get_text_callback(
|
|
engine="vosk",
|
|
auto_capitalize=True,
|
|
mock_config_manager=mock_config_manager,
|
|
mock_tray=mock_tray,
|
|
mock_text=mock_text,
|
|
mock_speech=mock_speech,
|
|
mock_check_deps=mock_check_deps,
|
|
)
|
|
|
|
text_callback("hello world. goodbye")
|
|
mock_text_instance.inject_text.assert_called_once_with("Hello world. Goodbye ")
|
|
|
|
@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.get_shared_config_manager")
|
|
@patch("vocalinux.ui.logging_manager.initialize_logging")
|
|
def test_vosk_auto_capitalize_can_be_disabled(
|
|
self,
|
|
mock_init_logging,
|
|
mock_config_manager,
|
|
mock_tray,
|
|
mock_text,
|
|
mock_speech,
|
|
mock_check_deps,
|
|
):
|
|
"""Vosk leaves casing unchanged when auto_capitalize is disabled."""
|
|
text_callback, mock_text_instance = self._run_main_and_get_text_callback(
|
|
engine="vosk",
|
|
auto_capitalize=False,
|
|
mock_config_manager=mock_config_manager,
|
|
mock_tray=mock_tray,
|
|
mock_text=mock_text,
|
|
mock_speech=mock_speech,
|
|
mock_check_deps=mock_check_deps,
|
|
)
|
|
|
|
text_callback("hello world. goodbye")
|
|
mock_text_instance.inject_text.assert_called_once_with("hello world. goodbye ")
|
|
|
|
@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.get_shared_config_manager")
|
|
@patch("vocalinux.ui.logging_manager.initialize_logging")
|
|
def test_whisper_skips_auto_capitalize(
|
|
self,
|
|
mock_init_logging,
|
|
mock_config_manager,
|
|
mock_tray,
|
|
mock_text,
|
|
mock_speech,
|
|
mock_check_deps,
|
|
):
|
|
"""Whisper engines keep model casing even when auto_capitalize is on."""
|
|
text_callback, mock_text_instance = self._run_main_and_get_text_callback(
|
|
engine="whisper_cpp",
|
|
auto_capitalize=True,
|
|
mock_config_manager=mock_config_manager,
|
|
mock_tray=mock_tray,
|
|
mock_text=mock_text,
|
|
mock_speech=mock_speech,
|
|
mock_check_deps=mock_check_deps,
|
|
)
|
|
|
|
text_callback("hello world. goodbye")
|
|
mock_text_instance.inject_text.assert_called_once_with("hello world. goodbye ")
|
|
|
|
@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.get_shared_config_manager")
|
|
@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.get_shared_config_manager")
|
|
@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.get_shared_config_manager")
|
|
@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 legacy AppIndicator3 is missing but Ayatana is available."""
|
|
|
|
# Prefer Ayatana; legacy AppIndicator3 is only a 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 works
|
|
self.assertTrue(result)
|
|
|
|
def test_check_dependencies_falls_back_to_legacy_appindicator(self):
|
|
"""Test when Ayatana is missing but legacy AppIndicator3 is available."""
|
|
|
|
def require_version_side_effect(name, version):
|
|
if name in ("AyatanaAppIndicator3", "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_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,
|
|
},
|
|
):
|
|
with patch("vocalinux.main.logger"):
|
|
result = check_dependencies()
|
|
self.assertTrue(result)
|
|
|
|
def test_check_dependencies_falls_back_to_lowercase_ayatana(self):
|
|
"""Test rare lowercase AyatanaAppindicator3 typelib is accepted."""
|
|
|
|
def require_version_side_effect(name, version):
|
|
if name == "AyatanaAppIndicator3":
|
|
raise ValueError("AyatanaAppIndicator3 not found")
|
|
|
|
mock_gi = MagicMock()
|
|
mock_gi.require_version = MagicMock(side_effect=require_version_side_effect)
|
|
mock_gtk = MagicMock()
|
|
mock_ayatana_lower = MagicMock()
|
|
mock_pynput = MagicMock()
|
|
mock_requests = MagicMock()
|
|
|
|
with patch.dict(
|
|
"sys.modules",
|
|
{
|
|
"gi": mock_gi,
|
|
"gi.repository": MagicMock(Gtk=mock_gtk, AyatanaAppindicator3=mock_ayatana_lower),
|
|
"pynput": mock_pynput,
|
|
"requests": mock_requests,
|
|
},
|
|
):
|
|
with patch("vocalinux.main.logger"):
|
|
result = check_dependencies()
|
|
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 all AppIndicator variants
|
|
def require_version_side_effect(name, version):
|
|
if name in (
|
|
"AppIndicator3",
|
|
"AyatanaAppIndicator3",
|
|
"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.get_shared_config_manager")
|
|
@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.get_shared_config_manager")
|
|
@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_instance.get_model_size_for_engine.return_value = "medium"
|
|
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)
|
|
|
|
@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.get_shared_config_manager")
|
|
@patch("vocalinux.ui.logging_manager.initialize_logging")
|
|
def test_cli_language_normalized_for_parakeet(
|
|
self,
|
|
mock_init_logging,
|
|
mock_config_manager,
|
|
mock_tray,
|
|
mock_text,
|
|
mock_speech,
|
|
mock_action_handler,
|
|
mock_check_deps,
|
|
):
|
|
"""CLI --language must not be stored when --engine is parakeet."""
|
|
mock_check_deps.return_value = True
|
|
|
|
mock_config_instance = MagicMock()
|
|
mock_config_instance.get_settings.return_value = {
|
|
"speech_recognition": {
|
|
"engine": "whisper_cpp",
|
|
"model_size": "small",
|
|
"language": "en-us",
|
|
},
|
|
"general": {"first_run": False},
|
|
}
|
|
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(
|
|
"sys.argv",
|
|
[
|
|
"vocalinux",
|
|
"--engine",
|
|
"parakeet",
|
|
"--model",
|
|
"v3-european",
|
|
"--language",
|
|
"fr",
|
|
],
|
|
):
|
|
with patch("vocalinux.main.logger"):
|
|
main()
|
|
|
|
mock_speech.assert_called_once()
|
|
call_kwargs = mock_speech.call_args[1]
|
|
self.assertEqual(call_kwargs["engine"], "parakeet")
|
|
self.assertEqual(call_kwargs["language"], "auto")
|
|
|
|
@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.get_shared_config_manager")
|
|
@patch("vocalinux.ui.logging_manager.initialize_logging")
|
|
def test_saved_language_normalized_for_parakeet(
|
|
self,
|
|
mock_init_logging,
|
|
mock_config_manager,
|
|
mock_tray,
|
|
mock_text,
|
|
mock_speech,
|
|
mock_action_handler,
|
|
mock_check_deps,
|
|
):
|
|
"""A leftover saved language must not be stored when the engine is parakeet."""
|
|
mock_check_deps.return_value = True
|
|
|
|
mock_config_instance = MagicMock()
|
|
mock_config_instance.get_settings.return_value = {
|
|
"speech_recognition": {
|
|
"engine": "parakeet",
|
|
"model_size": "v3-european",
|
|
"language": "de",
|
|
},
|
|
"general": {"first_run": False},
|
|
}
|
|
mock_config_instance.get_model_size_for_engine.return_value = "v3-european"
|
|
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("sys.argv", ["vocalinux"]):
|
|
with patch("vocalinux.main.logger"):
|
|
main()
|
|
|
|
mock_speech.assert_called_once()
|
|
call_kwargs = mock_speech.call_args[1]
|
|
self.assertEqual(call_kwargs["engine"], "parakeet")
|
|
self.assertEqual(call_kwargs["language"], "auto")
|
|
|
|
@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_model_resolved_per_engine_not_from_generic_key(
|
|
self,
|
|
mock_init_logging,
|
|
mock_config_manager,
|
|
mock_tray,
|
|
mock_text,
|
|
mock_speech,
|
|
mock_action_handler,
|
|
mock_check_deps,
|
|
):
|
|
"""Startup must use the engine's own model, not the stale generic key.
|
|
|
|
The generic "model_size" key holds whichever engine was saved last, so
|
|
a config carrying another engine's model must not decide what the
|
|
configured engine loads.
|
|
"""
|
|
mock_check_deps.return_value = True
|
|
|
|
mock_config_instance = MagicMock()
|
|
mock_config_instance.get_settings.return_value = {
|
|
"speech_recognition": {
|
|
"engine": "whisper_cpp",
|
|
# Left behind by the last VOSK save.
|
|
"model_size": "medium",
|
|
"vosk_model_size": "medium",
|
|
"whisper_cpp_model_size": "small",
|
|
"language": "en-us",
|
|
},
|
|
"general": {"first_run": False},
|
|
}
|
|
mock_config_instance.get_model_size_for_engine.side_effect = lambda engine: {
|
|
"vosk": "medium",
|
|
"whisper_cpp": "small",
|
|
}[engine]
|
|
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("sys.argv", ["vocalinux"]):
|
|
with patch("vocalinux.main.logger"):
|
|
main()
|
|
|
|
mock_config_instance.get_model_size_for_engine.assert_called_with("whisper_cpp")
|
|
call_kwargs = mock_speech.call_args[1]
|
|
self.assertEqual(call_kwargs["engine"], "whisper_cpp")
|
|
self.assertEqual(call_kwargs["model_size"], "small")
|
|
|
|
|
|
class TestTextCallbackSpacing(unittest.TestCase):
|
|
"""Test spacing logic in text_callback_wrapper."""
|
|
|
|
def _make_callback(self, append_trailing_space: bool = True):
|
|
"""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.lstrip().rstrip(" \t")
|
|
if not text_to_inject:
|
|
return
|
|
if append_trailing_space:
|
|
if not text_to_inject.endswith((" ", "\t", "\n")):
|
|
text_to_inject += " "
|
|
elif 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_trailing_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_has_trailing_space_not_leading(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_cross_session_keeps_trailing_space_without_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")
|
|
# No leading space (empty-field safe); trailing space still appended.
|
|
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_trailing_spaces(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_newline_segment_does_not_get_trailing_space(self):
|
|
cb, _, text_system, _ = self._make_callback()
|
|
cb("Hello.\n")
|
|
text_system.inject_text.assert_called_once_with("Hello.\n")
|
|
|
|
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 "])
|
|
|
|
def test_legacy_mode_uses_leading_space_in_session(self):
|
|
cb, _, text_system, _ = self._make_callback(append_trailing_space=False)
|
|
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_legacy_mode_clears_leading_space_across_sessions(self):
|
|
cb, on_state_change, text_system, _ = self._make_callback(append_trailing_space=False)
|
|
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")
|
|
|
|
|
|
class TestShouldAppendTrailingSpace(unittest.TestCase):
|
|
"""Test disk-backed trailing-space setting reader."""
|
|
|
|
def test_reads_setting_from_disk_with_true_default(self):
|
|
import json
|
|
import os
|
|
import tempfile
|
|
|
|
from vocalinux.main import _should_append_trailing_space
|
|
|
|
with tempfile.TemporaryDirectory() as d:
|
|
with patch("vocalinux.utils.paths.config_dir", return_value=d):
|
|
# No config file -> default True
|
|
self.assertTrue(_should_append_trailing_space())
|
|
|
|
cfg = os.path.join(d, "config.json")
|
|
with open(cfg, "w") as f:
|
|
json.dump({"text_injection": {}}, f)
|
|
self.assertTrue(_should_append_trailing_space())
|
|
|
|
with open(cfg, "w") as f:
|
|
json.dump({"text_injection": {"append_trailing_space": False}}, f)
|
|
self.assertFalse(_should_append_trailing_space())
|
|
|
|
with open(cfg, "w") as f:
|
|
json.dump({"text_injection": {"append_trailing_space": True}}, f)
|
|
self.assertTrue(_should_append_trailing_space())
|
|
|
|
def test_returns_true_when_config_read_fails(self):
|
|
from vocalinux.main import _should_append_trailing_space
|
|
|
|
with patch("vocalinux.utils.paths.config_dir", side_effect=OSError("boom")):
|
|
self.assertTrue(_should_append_trailing_space())
|
|
|
|
|
|
def _boot_main_callbacks(
|
|
*,
|
|
append_trailing_space: bool = True,
|
|
inject_ok: bool = True,
|
|
dictate_to_pad: bool = False,
|
|
) -> SimpleNamespace:
|
|
"""Boot main() with mocked deps; return the registered callbacks and mocks.
|
|
|
|
The patch stack stays open — the caller owns ``stack`` and must close it
|
|
in a finally block so no patch leaks into later tests.
|
|
"""
|
|
from contextlib import ExitStack
|
|
|
|
stack = ExitStack()
|
|
stack.enter_context(patch("vocalinux.main.check_dependencies", return_value=True))
|
|
mock_config_cls = stack.enter_context(
|
|
patch("vocalinux.ui.config_manager.get_shared_config_manager")
|
|
)
|
|
mock_config = MagicMock()
|
|
mock_config.get_settings.return_value = {
|
|
"speech_recognition": {},
|
|
"general": {"first_run": False},
|
|
}
|
|
mock_config.get.return_value = False # auto_capitalize off
|
|
mock_config.is_dictate_to_pad_enabled.return_value = dictate_to_pad
|
|
mock_config_cls.return_value = mock_config
|
|
|
|
mock_speech_cls = stack.enter_context(
|
|
patch("vocalinux.speech_recognition.recognition_manager.SpeechRecognitionManager")
|
|
)
|
|
mock_speech = MagicMock()
|
|
mock_speech.engine = "whisper_cpp"
|
|
mock_speech_cls.return_value = mock_speech
|
|
|
|
mock_text_cls = stack.enter_context(
|
|
patch("vocalinux.text_injection.text_injector.TextInjector")
|
|
)
|
|
mock_text = MagicMock()
|
|
mock_text.inject_text.return_value = inject_ok
|
|
mock_text_cls.return_value = mock_text
|
|
|
|
mock_pad_cls = stack.enter_context(patch("vocalinux.ui.dictation_pad.DictationPad"))
|
|
mock_pad = mock_pad_cls.return_value
|
|
|
|
mock_tray_cls = stack.enter_context(patch("vocalinux.ui.tray_indicator.TrayIndicator"))
|
|
mock_tray_cls.return_value = MagicMock()
|
|
|
|
stack.enter_context(patch("vocalinux.ui.logging_manager.initialize_logging"))
|
|
stack.enter_context(
|
|
patch(
|
|
"vocalinux.main._should_append_trailing_space",
|
|
return_value=append_trailing_space,
|
|
)
|
|
)
|
|
mock_parse = stack.enter_context(patch("vocalinux.main.parse_arguments"))
|
|
stack.enter_context(patch("sys.argv", ["vocalinux"]))
|
|
|
|
mock_args = MagicMock()
|
|
mock_args.debug = False
|
|
mock_args.model = "tiny"
|
|
mock_args.engine = "whisper_cpp"
|
|
mock_args.language = "en-us"
|
|
mock_args.wayland = False
|
|
mock_args.start_minimized = False
|
|
mock_parse.return_value = mock_args
|
|
try:
|
|
main()
|
|
text_cb = mock_speech.register_text_callback.call_args.args[0]
|
|
action_cb = mock_speech.register_action_callback.call_args.args[0]
|
|
state_cb = mock_speech.register_state_callback.call_args.args[0]
|
|
except BaseException:
|
|
# The caller only closes the stack once it gets one back, so a
|
|
# failure here would leak these patches into every later test.
|
|
stack.close()
|
|
raise
|
|
|
|
return SimpleNamespace(
|
|
stack=stack,
|
|
text_cb=text_cb,
|
|
action_cb=action_cb,
|
|
state_cb=state_cb,
|
|
text_system=mock_text,
|
|
pad=mock_pad,
|
|
pad_cls=mock_pad_cls,
|
|
config=mock_config,
|
|
)
|
|
|
|
|
|
class TestMainCallbackTrailingSpaceEdges(unittest.TestCase):
|
|
"""Exercise trailing-space edge paths through the real main() callback."""
|
|
|
|
def test_whitespace_only_is_skipped_through_main(self) -> None:
|
|
boot = _boot_main_callbacks()
|
|
try:
|
|
boot.text_cb(" \t ")
|
|
boot.text_system.inject_text.assert_not_called()
|
|
finally:
|
|
boot.stack.close()
|
|
|
|
def test_newline_segment_skips_trailing_space_through_main(self) -> None:
|
|
boot = _boot_main_callbacks()
|
|
try:
|
|
boot.text_cb("Hello.\n")
|
|
boot.text_system.inject_text.assert_called_once_with("Hello.\n")
|
|
finally:
|
|
boot.stack.close()
|
|
|
|
def test_legacy_mode_adds_leading_space_in_session(self) -> None:
|
|
boot = _boot_main_callbacks(append_trailing_space=False)
|
|
try:
|
|
boot.text_cb("Hello.")
|
|
boot.text_cb("World")
|
|
calls = [c.args[0] for c in boot.text_system.inject_text.call_args_list]
|
|
self.assertEqual(calls, ["Hello.", " World"])
|
|
finally:
|
|
boot.stack.close()
|
|
|
|
def test_failed_inject_does_not_remember_text(self) -> None:
|
|
boot = _boot_main_callbacks(append_trailing_space=False, inject_ok=False)
|
|
try:
|
|
boot.text_cb("Hello.")
|
|
boot.text_system.inject_text.reset_mock()
|
|
boot.text_system.inject_text.return_value = True
|
|
boot.text_cb("World")
|
|
# Failure means last_injected stays empty; next segment has no leading space
|
|
boot.text_system.inject_text.assert_called_once_with("World")
|
|
finally:
|
|
boot.stack.close()
|
|
|
|
|
|
class TestPadRoutingCallbacks(unittest.TestCase):
|
|
"""Pad capture routing through the real main() callback wrappers (#726).
|
|
|
|
The pad path is the feature's main routing: these exercise the callbacks
|
|
actually registered on the speech engine rather than re-implementations.
|
|
"""
|
|
|
|
def test_capture_routes_transcription_to_pad_not_injector(self) -> None:
|
|
boot = _boot_main_callbacks(dictate_to_pad=True)
|
|
try:
|
|
boot.text_cb("hello")
|
|
boot.pad.append_text.assert_called_once_with("hello ")
|
|
boot.text_system.inject_text.assert_not_called()
|
|
finally:
|
|
boot.stack.close()
|
|
|
|
def test_capture_routes_delete_that_to_pad(self) -> None:
|
|
boot = _boot_main_callbacks(dictate_to_pad=True)
|
|
try:
|
|
boot.pad.last_segment = "hello "
|
|
boot.text_cb("hello")
|
|
boot.action_cb("delete_last")
|
|
boot.pad.delete_last_chars.assert_called_once_with(len("hello "))
|
|
boot.text_system.press_backspace.assert_not_called()
|
|
finally:
|
|
boot.stack.close()
|
|
|
|
def test_capture_routes_editing_commands_to_pad(self) -> None:
|
|
"""undo/select/cut/copy/paste must never reach the focused app."""
|
|
boot = _boot_main_callbacks(dictate_to_pad=True)
|
|
try:
|
|
for action in (
|
|
"undo",
|
|
"redo",
|
|
"select_all",
|
|
"select_line",
|
|
"select_word",
|
|
"select_paragraph",
|
|
"cut",
|
|
"copy",
|
|
"paste",
|
|
):
|
|
boot.pad.handle_action.reset_mock()
|
|
self.assertTrue(boot.action_cb(action))
|
|
boot.pad.handle_action.assert_called_once_with(action)
|
|
boot.text_system._inject_keyboard_shortcut.assert_not_called()
|
|
boot.text_system.press_backspace.assert_not_called()
|
|
finally:
|
|
boot.stack.close()
|
|
|
|
def test_delete_that_follows_destination_across_mode_change(self) -> None:
|
|
"""Deletion targets where the last segment went, not the live toggle."""
|
|
boot = _boot_main_callbacks(dictate_to_pad=False)
|
|
try:
|
|
# Dictated into the app; capture switched on afterwards: the
|
|
# backspaces must still go to the app that received the text.
|
|
boot.text_cb("into app")
|
|
boot.config.is_dictate_to_pad_enabled.return_value = True
|
|
boot.action_cb("delete_last")
|
|
boot.text_system.press_backspace.assert_called_once_with(len("into app "))
|
|
boot.pad.delete_last_chars.assert_not_called()
|
|
|
|
# Dictated into the pad; capture switched off afterwards: the
|
|
# pad's copy is still the one removed.
|
|
boot.config.is_dictate_to_pad_enabled.return_value = True
|
|
boot.pad.last_segment = "into pad "
|
|
boot.pad.delete_last_chars.return_value = len("into pad ")
|
|
boot.text_cb("into pad")
|
|
boot.config.is_dictate_to_pad_enabled.return_value = False
|
|
boot.action_cb("delete_last")
|
|
boot.pad.delete_last_chars.assert_called_once_with(len("into pad "))
|
|
self.assertEqual(boot.text_system.press_backspace.call_count, 1)
|
|
finally:
|
|
boot.stack.close()
|
|
|
|
def test_capture_toggle_flips_routing_without_restart(self) -> None:
|
|
"""Routing follows the shared manager's live value, not a snapshot."""
|
|
boot = _boot_main_callbacks(dictate_to_pad=False)
|
|
try:
|
|
boot.text_cb("to app")
|
|
boot.text_system.inject_text.assert_called_once_with("to app ")
|
|
|
|
boot.config.is_dictate_to_pad_enabled.return_value = True
|
|
boot.text_cb("to pad")
|
|
boot.pad.append_text.assert_called_once_with("to pad ")
|
|
self.assertEqual(boot.text_system.inject_text.call_count, 1)
|
|
finally:
|
|
boot.stack.close()
|
|
|
|
def test_delete_that_with_empty_history_is_swallowed(self) -> None:
|
|
boot = _boot_main_callbacks(dictate_to_pad=True)
|
|
try:
|
|
self.assertTrue(boot.action_cb("delete_last"))
|
|
boot.pad.delete_last_chars.assert_not_called()
|
|
boot.text_system.press_backspace.assert_not_called()
|
|
finally:
|
|
boot.stack.close()
|
|
|
|
def test_idle_state_resets_delete_that_target(self) -> None:
|
|
boot = _boot_main_callbacks(dictate_to_pad=True)
|
|
try:
|
|
boot.text_cb("pad text")
|
|
boot.state_cb(RecognitionState.IDLE)
|
|
boot.action_cb("delete_last")
|
|
boot.pad.delete_last_chars.assert_not_called()
|
|
boot.text_system.press_backspace.assert_not_called()
|
|
finally:
|
|
boot.stack.close()
|
|
|
|
def test_pad_undo_retargets_delete_that(self) -> None:
|
|
"""Undoing the last pad segment must not leave a stale delete length."""
|
|
boot = _boot_main_callbacks(dictate_to_pad=True)
|
|
try:
|
|
boot.text_cb("hello")
|
|
boot.text_cb("world")
|
|
# The pad reports the surviving segment after its own undo pops
|
|
# "world "; the next delete targets it, not the stale segment.
|
|
boot.pad.last_segment = "hello "
|
|
boot.pad.handle_action.return_value = True
|
|
self.assertTrue(boot.action_cb("undo"))
|
|
boot.pad.handle_action.assert_called_once_with("undo")
|
|
|
|
boot.action_cb("delete_last")
|
|
boot.pad.delete_last_chars.assert_called_once_with(len("hello "))
|
|
boot.text_system.press_backspace.assert_not_called()
|
|
finally:
|
|
boot.stack.close()
|
|
|
|
def test_pad_undo_to_empty_clears_delete_target(self) -> None:
|
|
"""When pad undo removes the last segment, "delete that" is a no-op."""
|
|
boot = _boot_main_callbacks(dictate_to_pad=True)
|
|
try:
|
|
boot.text_cb("only")
|
|
boot.pad.last_segment = None
|
|
boot.pad.handle_action.return_value = True
|
|
self.assertTrue(boot.action_cb("undo"))
|
|
|
|
self.assertTrue(boot.action_cb("delete_last"))
|
|
boot.pad.delete_last_chars.assert_not_called()
|
|
boot.text_system.press_backspace.assert_not_called()
|
|
finally:
|
|
boot.stack.close()
|
|
|
|
def test_pad_redo_restores_delete_target(self) -> None:
|
|
"""A redo that brings the segment back must re-arm "delete that"."""
|
|
boot = _boot_main_callbacks(dictate_to_pad=True)
|
|
try:
|
|
boot.text_cb("only")
|
|
boot.pad.handle_action.return_value = True
|
|
|
|
# Undo to an empty pad clears the target but keeps the pad as the
|
|
# last dictation destination.
|
|
boot.pad.last_segment = None
|
|
self.assertTrue(boot.action_cb("undo"))
|
|
self.assertTrue(boot.action_cb("delete_last"))
|
|
boot.pad.delete_last_chars.assert_not_called()
|
|
|
|
# Redo restores the segment; the next delete reaches the pad.
|
|
boot.pad.last_segment = "only"
|
|
self.assertTrue(boot.action_cb("redo"))
|
|
self.assertTrue(boot.action_cb("delete_last"))
|
|
boot.pad.delete_last_chars.assert_called_once_with(len("only"))
|
|
boot.text_system.press_backspace.assert_not_called()
|
|
finally:
|
|
boot.stack.close()
|
|
|
|
|
|
class TestSessionHistoryRecording(unittest.TestCase):
|
|
"""Segments commit to transcription history as one snippet per session."""
|
|
|
|
def _boot(self, *, extra_settings: Optional[Dict[str, Any]] = None) -> Tuple[
|
|
ExitStack,
|
|
Callable[[str], None],
|
|
Callable[[str, float], None],
|
|
Callable[[RecognitionState], None],
|
|
TranscriptionHistory,
|
|
]:
|
|
"""Boot main() under mocks; return (stack, text_cb, segment_cb, state_cb, history).
|
|
|
|
The history is the real TranscriptionHistory instance handed to the
|
|
(mocked) TrayIndicator, so assertions observe actual recording.
|
|
segment_cb(text, started_at) delivers a recognized segment with the
|
|
monotonic time its audio capture began — history recording consumes
|
|
capture time so tests control pre-/post-clear placement precisely.
|
|
"""
|
|
stack = ExitStack()
|
|
stack.enter_context(patch("vocalinux.main.check_dependencies", return_value=True))
|
|
mock_config_cls = stack.enter_context(
|
|
patch("vocalinux.ui.config_manager.get_shared_config_manager")
|
|
)
|
|
mock_config = MagicMock()
|
|
settings = {
|
|
"speech_recognition": {},
|
|
"general": {"first_run": False},
|
|
}
|
|
if extra_settings:
|
|
settings.update(extra_settings)
|
|
mock_config.get_settings.return_value = settings
|
|
mock_config.get.return_value = False # auto_capitalize off
|
|
mock_config_cls.return_value = mock_config
|
|
|
|
mock_speech_cls = stack.enter_context(
|
|
patch("vocalinux.speech_recognition.recognition_manager.SpeechRecognitionManager")
|
|
)
|
|
mock_speech = MagicMock()
|
|
mock_speech.engine = "whisper_cpp"
|
|
mock_speech_cls.return_value = mock_speech
|
|
# Stashed so tests can drive engine-side attributes (capture floors,
|
|
# the live recognition thread) through the same instance the
|
|
# callbacks close over.
|
|
self._engine = mock_speech
|
|
|
|
mock_text_cls = stack.enter_context(
|
|
patch("vocalinux.text_injection.text_injector.TextInjector")
|
|
)
|
|
mock_text = MagicMock()
|
|
mock_text.inject_text.return_value = True
|
|
mock_text_cls.return_value = mock_text
|
|
|
|
mock_tray_cls = stack.enter_context(patch("vocalinux.ui.tray_indicator.TrayIndicator"))
|
|
mock_tray_cls.return_value = MagicMock()
|
|
|
|
stack.enter_context(patch("vocalinux.ui.logging_manager.initialize_logging"))
|
|
mock_parse = stack.enter_context(patch("vocalinux.main.parse_arguments"))
|
|
stack.enter_context(patch("sys.argv", ["vocalinux"]))
|
|
|
|
mock_args = MagicMock()
|
|
mock_args.debug = False
|
|
mock_args.model = "tiny"
|
|
mock_args.engine = "whisper_cpp"
|
|
mock_args.language = "en-us"
|
|
mock_args.wayland = False
|
|
mock_args.start_minimized = False
|
|
mock_parse.return_value = mock_args
|
|
try:
|
|
main()
|
|
text_cb = mock_speech.register_text_callback.call_args.args[0]
|
|
segment_cb = mock_speech.register_segment_callback.call_args.args[0]
|
|
state_cb = mock_speech.register_state_callback.call_args.args[0]
|
|
history = mock_tray_cls.call_args.kwargs["transcription_history"]
|
|
except BaseException:
|
|
stack.close()
|
|
raise
|
|
|
|
return stack, text_cb, segment_cb, state_cb, history
|
|
|
|
def test_session_segments_commit_as_single_snippet(self) -> None:
|
|
stack, _, segment_cb, state_cb, history = self._boot()
|
|
try:
|
|
state_cb(RecognitionState.LISTENING)
|
|
segment_cb("Hello.", time.monotonic())
|
|
state_cb(RecognitionState.PROCESSING)
|
|
state_cb(RecognitionState.LISTENING)
|
|
segment_cb("World", time.monotonic())
|
|
state_cb(RecognitionState.IDLE)
|
|
self.assertEqual(history.get_all(), ["Hello. World"])
|
|
finally:
|
|
stack.close()
|
|
|
|
def test_successive_sessions_record_separate_snippets_newest_first(self) -> None:
|
|
stack, _, segment_cb, state_cb, history = self._boot()
|
|
try:
|
|
state_cb(RecognitionState.LISTENING)
|
|
segment_cb("first session", time.monotonic())
|
|
state_cb(RecognitionState.IDLE)
|
|
|
|
state_cb(RecognitionState.LISTENING)
|
|
segment_cb("second session", time.monotonic())
|
|
state_cb(RecognitionState.IDLE)
|
|
|
|
self.assertEqual(history.get_all(), ["second session", "first session"])
|
|
finally:
|
|
stack.close()
|
|
|
|
def test_late_segment_merges_into_its_own_sessions_snippet(self) -> None:
|
|
"""A worker that outlives the bounded stop wait lands in its snippet."""
|
|
stack, _, segment_cb, state_cb, history = self._boot()
|
|
try:
|
|
state_cb(RecognitionState.LISTENING)
|
|
segment_cb("hello", time.monotonic())
|
|
segment_cb("world", time.monotonic())
|
|
# IDLE fires while the worker still holds a final segment.
|
|
state_cb(RecognitionState.IDLE)
|
|
segment_cb("late tail", time.monotonic())
|
|
self.assertEqual(history.get_all(), ["hello world late tail"])
|
|
|
|
# The next session's snippet is not contaminated.
|
|
state_cb(RecognitionState.LISTENING)
|
|
segment_cb("next session", time.monotonic())
|
|
state_cb(RecognitionState.IDLE)
|
|
self.assertEqual(history.get_all(), ["next session", "hello world late tail"])
|
|
finally:
|
|
stack.close()
|
|
|
|
def _worker_deliverer(
|
|
self, segment_cb: Callable[[str, float], None]
|
|
) -> Tuple[Callable[[str], None], Callable[[], None]]:
|
|
"""Deliver segments from one persistent worker thread, like the real engine.
|
|
|
|
Returns (deliver, close): ``deliver(text)`` runs the segment callback
|
|
on the worker synchronously; ``close()`` stops it.
|
|
"""
|
|
work: queue.Queue = queue.Queue()
|
|
errors: list[BaseException] = []
|
|
|
|
def worker() -> None:
|
|
while True:
|
|
job = work.get()
|
|
try:
|
|
if job is not None:
|
|
job()
|
|
except BaseException as error:
|
|
errors.append(error)
|
|
finally:
|
|
work.task_done()
|
|
if job is None:
|
|
return
|
|
|
|
thread = threading.Thread(target=worker)
|
|
thread.start()
|
|
|
|
def deliver(text: str) -> None:
|
|
work.put(lambda: segment_cb(text, time.monotonic()))
|
|
work.join()
|
|
if errors:
|
|
raise errors.pop(0)
|
|
|
|
def close() -> None:
|
|
work.put(None)
|
|
work.join()
|
|
thread.join()
|
|
|
|
return deliver, close
|
|
|
|
def test_late_segment_from_old_worker_during_next_session(self) -> None:
|
|
"""A leftover worker delivering on its own thread stays out of the open session."""
|
|
stack, _, segment_cb, state_cb, history = self._boot()
|
|
on_old_worker, close_old_worker = self._worker_deliverer(segment_cb)
|
|
try:
|
|
state_cb(RecognitionState.LISTENING)
|
|
on_old_worker("one")
|
|
state_cb(RecognitionState.IDLE)
|
|
|
|
state_cb(RecognitionState.LISTENING)
|
|
segment_cb("two", time.monotonic())
|
|
# The previous session's worker finally delivers on its own thread.
|
|
on_old_worker("trailing")
|
|
self.assertEqual(history.get_all(), ["one trailing"])
|
|
|
|
state_cb(RecognitionState.IDLE)
|
|
self.assertEqual(history.get_all(), ["two", "one trailing"])
|
|
finally:
|
|
close_old_worker()
|
|
stack.close()
|
|
|
|
def test_straggler_after_newer_session_forms_own_snippet(self) -> None:
|
|
"""An old worker finishing after the next session must not grow its entry."""
|
|
stack, _, segment_cb, state_cb, history = self._boot()
|
|
on_old_worker, close_old_worker = self._worker_deliverer(segment_cb)
|
|
try:
|
|
state_cb(RecognitionState.LISTENING)
|
|
on_old_worker("first")
|
|
state_cb(RecognitionState.IDLE)
|
|
|
|
state_cb(RecognitionState.LISTENING)
|
|
segment_cb("second", time.monotonic())
|
|
state_cb(RecognitionState.IDLE)
|
|
|
|
# The older session's worker decodes last: its text belongs to
|
|
# the first session, not to the currently-newest entry.
|
|
on_old_worker("late tail")
|
|
self.assertEqual(history.get_all(), ["late tail", "second", "first"])
|
|
|
|
# A later straggler on that same worker still extends the
|
|
# snippet it owns.
|
|
on_old_worker("more tail")
|
|
self.assertEqual(history.get_all(), ["late tail more tail", "second", "first"])
|
|
finally:
|
|
close_old_worker()
|
|
stack.close()
|
|
|
|
def test_session_without_segments_creates_no_snippet(self) -> None:
|
|
stack, _, segment_cb, state_cb, history = self._boot()
|
|
try:
|
|
state_cb(RecognitionState.LISTENING)
|
|
state_cb(RecognitionState.IDLE)
|
|
self.assertEqual(history.get_all(), [])
|
|
|
|
# A segment arriving now belongs to that empty session: it becomes
|
|
# its own snippet rather than merging into an older entry.
|
|
segment_cb("orphan", time.monotonic())
|
|
self.assertEqual(history.get_all(), ["orphan"])
|
|
finally:
|
|
stack.close()
|
|
|
|
def test_error_state_also_commits_partial_snippet(self) -> None:
|
|
stack, _, segment_cb, state_cb, history = self._boot()
|
|
try:
|
|
state_cb(RecognitionState.LISTENING)
|
|
segment_cb("partial", time.monotonic())
|
|
state_cb(RecognitionState.ERROR)
|
|
self.assertEqual(history.get_all(), ["partial"])
|
|
finally:
|
|
stack.close()
|
|
|
|
def test_history_disabled_records_nothing(self) -> None:
|
|
stack, _, segment_cb, state_cb, history = self._boot(
|
|
extra_settings={"history": {"enabled": False}}
|
|
)
|
|
try:
|
|
state_cb(RecognitionState.LISTENING)
|
|
segment_cb("hello", time.monotonic())
|
|
state_cb(RecognitionState.IDLE)
|
|
self.assertEqual(history.get_all(), [])
|
|
finally:
|
|
stack.close()
|
|
|
|
def test_invalid_history_max_items_uses_default(self) -> None:
|
|
"""A corrupted saved limit must not prevent startup."""
|
|
stack, _, _, _, history = self._boot(extra_settings={"history": {"max_items": "abc"}})
|
|
try:
|
|
self.assertEqual(history.max_items, 10)
|
|
finally:
|
|
stack.close()
|
|
|
|
def test_late_segment_after_clear_does_not_reappear(self) -> None:
|
|
"""A straggler from an ended session must not resurrect cleared history."""
|
|
stack, _, segment_cb, state_cb, history = self._boot()
|
|
try:
|
|
state_cb(RecognitionState.LISTENING)
|
|
segment_cb("before clear", time.monotonic())
|
|
state_cb(RecognitionState.IDLE)
|
|
self.assertEqual(history.get_all(), ["before clear"])
|
|
|
|
history.clear()
|
|
# Its audio was captured before the clear; the decode only
|
|
# finished afterwards, so it must still be refused.
|
|
segment_cb("late tail", history.cleared_at - 1.0)
|
|
self.assertEqual(history.get_all(), [])
|
|
finally:
|
|
stack.close()
|
|
|
|
def test_late_orphan_segment_after_clear_does_not_reappear(self) -> None:
|
|
"""Late-only output of an empty session is refused after a clear too."""
|
|
stack, _, segment_cb, state_cb, history = self._boot()
|
|
try:
|
|
state_cb(RecognitionState.LISTENING)
|
|
state_cb(RecognitionState.IDLE) # empty session: no snippet
|
|
history.clear()
|
|
segment_cb("orphan", history.cleared_at - 1.0)
|
|
self.assertEqual(history.get_all(), [])
|
|
finally:
|
|
stack.close()
|
|
|
|
def test_clear_during_session_drops_its_snippet(self) -> None:
|
|
"""A clear issued while a session runs keeps its whole snippet out."""
|
|
stack, _, segment_cb, state_cb, history = self._boot()
|
|
try:
|
|
state_cb(RecognitionState.LISTENING)
|
|
segment_cb("dictated", time.monotonic())
|
|
history.clear()
|
|
state_cb(RecognitionState.IDLE)
|
|
self.assertEqual(history.get_all(), [])
|
|
finally:
|
|
stack.close()
|
|
|
|
def test_dictation_after_mid_session_clear_is_kept(self) -> None:
|
|
"""A mid-session clear splits segments by capture time, not arrival.
|
|
|
|
Speech captured before the clear is refused no matter when its
|
|
decode lands; speech captured after it joins the session's snippet.
|
|
"""
|
|
stack, _, segment_cb, state_cb, history = self._boot()
|
|
try:
|
|
state_cb(RecognitionState.LISTENING)
|
|
segment_cb("before", time.monotonic())
|
|
history.clear()
|
|
boundary = history.cleared_at
|
|
# A decode of pre-clear audio finishing after the clear is still
|
|
# refused — this is the segment the epoch check used to keep or
|
|
# drop wholesale.
|
|
segment_cb("pre-clear tail", boundary - 1.0)
|
|
segment_cb("after clear", time.monotonic())
|
|
state_cb(RecognitionState.IDLE)
|
|
self.assertEqual(history.get_all(), ["after clear"])
|
|
finally:
|
|
stack.close()
|
|
|
|
def test_straggler_extends_its_own_snippet_during_next_session(self) -> None:
|
|
"""A late segment lands in its session's entry, not the newest one."""
|
|
stack, _, segment_cb, state_cb, history = self._boot()
|
|
try:
|
|
state_cb(RecognitionState.LISTENING)
|
|
w1 = threading.Thread(target=segment_cb, args=("one", time.monotonic()))
|
|
self._engine.recognition_thread = w1
|
|
w1.start()
|
|
w1.join()
|
|
state_cb(RecognitionState.IDLE)
|
|
|
|
state_cb(RecognitionState.LISTENING)
|
|
# Session one's leftover worker finally delivers while session
|
|
# two is open — it must grow "one", never the pending "two".
|
|
straggler = threading.Thread(target=segment_cb, args=("tail", time.monotonic()))
|
|
straggler.start()
|
|
straggler.join()
|
|
# Session two's own segment arrives on its live worker.
|
|
w2 = threading.Thread(target=segment_cb, args=("two", time.monotonic()))
|
|
self._engine.recognition_thread = w2
|
|
w2.start()
|
|
w2.join()
|
|
state_cb(RecognitionState.IDLE)
|
|
self.assertEqual(history.get_all(), ["two", "one tail"])
|
|
finally:
|
|
stack.close()
|
|
|
|
def test_pre_session_capture_is_not_attributed_to_open_session(self) -> None:
|
|
"""Audio captured before a session opened cannot join its snippet."""
|
|
stack, _, segment_cb, state_cb, history = self._boot()
|
|
try:
|
|
state_cb(RecognitionState.LISTENING)
|
|
session_start = time.monotonic()
|
|
segment_cb("new", time.monotonic())
|
|
# A queued leftover captured before this session began, delivered
|
|
# on the session's worker, still belongs to what came before.
|
|
segment_cb("old", session_start - 10.0)
|
|
state_cb(RecognitionState.IDLE)
|
|
self.assertEqual(history.get_all(), ["new", "old"])
|
|
finally:
|
|
stack.close()
|
|
|
|
def test_mic_test_window_drops_segments_after_test_ends(self) -> None:
|
|
"""Test speech still decoding after restore stays out of history."""
|
|
stack, _, segment_cb, state_cb, history = self._boot()
|
|
try:
|
|
floor = time.monotonic()
|
|
self._engine.test_capture_floor = floor
|
|
self._engine.test_capture_ceiling = floor + 60.0
|
|
|
|
state_cb(RecognitionState.LISTENING)
|
|
segment_cb("test speech", floor + 5.0)
|
|
segment_cb("real dictation", floor + 120.0)
|
|
state_cb(RecognitionState.IDLE)
|
|
self.assertEqual(history.get_all(), ["real dictation"])
|
|
finally:
|
|
stack.close()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|