Files
voculinux/tests/test_model_load_in_background.py
xavierk 85cbc37842
CI / security (push) Successful in 1m17s
CI / lint (push) Successful in 23s
CI / ai-review (push) Skipped
fix(tray): show the tray before the speech model loads (#51)
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
2026-11-06 20:00:50 +00:00

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()