"""Startup model load runs on a background thread so the tray can show first (#50). Seams covered here, all at the SpeechRecognitionManager public surface: the deferred-load constructor flag, ``load_model_in_background`` (thread, loading flag, callbacks, failure reporting), the dictation guard while the model is loading, supersession by an auto-pause, and the language-shortcut path while loading. """ import threading from unittest.mock import patch import pytest MANAGER_MODULE = "vocalinux.speech_recognition.recognition_manager" @pytest.fixture def init_whispercpp(): """Stand in for the whisper.cpp load so no model file is read.""" from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager with patch.object(SpeechRecognitionManager, "_init_whispercpp") as init: yield init def _deferred_manager(): from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager return SpeechRecognitionManager( engine="whisper_cpp", model_size="small", language="en-us", defer_model_load=True, ) class TestDeferredConstruction: def test_construction_does_not_load_the_engine(self, init_whispercpp): manager = _deferred_manager() init_whispercpp.assert_not_called() assert manager.model_loading is True assert manager.model_ready is False def test_default_construction_still_loads_synchronously(self, init_whispercpp): from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager manager = SpeechRecognitionManager( engine="whisper_cpp", model_size="small", language="en-us" ) init_whispercpp.assert_called_once_with() assert manager.model_loading is False def test_unknown_engine_still_fails_at_construction(self, init_whispercpp): from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager with pytest.raises(ValueError, match="Unsupported speech recognition engine"): SpeechRecognitionManager( engine="no-such-engine", model_size="small", language="en-us", defer_model_load=True, ) class TestBackgroundLoad: def test_load_runs_on_a_daemon_thread_and_returns_at_once(self, init_whispercpp): entered = threading.Event() release = threading.Event() seen_threads = [] def slow_load(*_args, **_kwargs): seen_threads.append(threading.current_thread()) entered.set() release.wait(5) init_whispercpp.side_effect = slow_load manager = _deferred_manager() manager.load_model_in_background() assert entered.wait(5), "the load never started" assert manager.model_loading is True assert seen_threads[0] is not threading.main_thread() assert seen_threads[0].daemon is True release.set() manager._model_load_thread.join(5) def test_loading_flag_clears_and_callbacks_fire_after_success(self, init_whispercpp): manager = _deferred_manager() flags_seen_by_callback = [] manager.register_model_load_callback( lambda: flags_seen_by_callback.append(manager.model_loading) ) manager.load_model_in_background() manager._model_load_thread.join(5) init_whispercpp.assert_called_once_with() assert manager.model_loading is False assert flags_seen_by_callback, "the load-finished callback never ran" assert flags_seen_by_callback[-1] is False def test_failed_load_reports_error_instead_of_raising(self, init_whispercpp): from vocalinux.common_types import RecognitionState init_whispercpp.side_effect = RuntimeError("model file is corrupt") manager = _deferred_manager() states = [] manager.register_state_callback(states.append) with patch(f"{MANAGER_MODULE}._show_notification") as notify: manager.load_model_in_background() manager._model_load_thread.join(5) assert manager.model_loading is False assert manager.state == RecognitionState.ERROR assert states[-1] == RecognitionState.ERROR notify.assert_called_once() assert "failed to load" in notify.call_args.args[0].lower() def test_auto_pause_already_active_at_startup_skips_the_load(self, init_whispercpp): manager = _deferred_manager() # A configured app is already running when the tray comes up. Its # auto-pause runs before the load is scheduled, so the load must not # keep the model resident; auto-resume loads it when the app exits. manager._new_model_request(auto_pause=True) manager.load_model_in_background() manager._model_load_thread.join(5) init_whispercpp.assert_not_called() assert manager.is_auto_paused is True assert manager.model_loading is False def test_auto_pause_arriving_before_the_load_supersedes_it(self, init_whispercpp): manager = _deferred_manager() # The loader must wait for the model lock; an auto-pause takes a newer # request while the lock is still held, so the load must not run. with manager._model_lock: manager.load_model_in_background() manager._new_model_request(auto_pause=True) manager._model_load_thread.join(5) init_whispercpp.assert_not_called() assert manager.is_auto_paused is True assert manager.model_loading is False class TestDictationWhileLoading: def test_start_is_refused_with_a_loading_notice(self, init_whispercpp): from vocalinux.common_types import RecognitionState manager = _deferred_manager() with ( patch(f"{MANAGER_MODULE}._show_notification") as notify, patch(f"{MANAGER_MODULE}.play_error_sound") as error_sound, patch.object(manager, "_notify_model_missing") as model_missing_offer, ): started = manager.start_recognition() assert started is False assert manager.state == RecognitionState.IDLE model_missing_offer.assert_not_called() error_sound.assert_called_once() assert "loading" in notify.call_args.args[0].lower() init_whispercpp.assert_not_called() def test_language_shortcut_during_load_reports_loading_not_unsupported(self, init_whispercpp): manager = _deferred_manager() with ( patch.object(manager, "_refuse_language_override") as refuse, patch(f"{MANAGER_MODULE}._show_notification") as notify, patch(f"{MANAGER_MODULE}.play_error_sound"), ): started = manager.start_recognition_with_language("de") assert started is False refuse.assert_not_called() assert "loading" in notify.call_args.args[0].lower()