The first model load runs on a background thread after the tray is up. While it loads the tray shows the processing icon and 'Loading model…', and a dictation request gets a 'still loading' notification. A failed load leaves the tray in the error state instead of exiting. Fixes #50
186 lines
6.8 KiB
Python
186 lines
6.8 KiB
Python
"""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()
|