Files
voculinux/tests/test_model_load_in_background.py
T
xavierk 9b91b8b177
CI / security (pull_request) Successful in 1m12s
CI / lint (pull_request) Successful in 24s
CI / ai-review (pull_request) Successful in 8s
fix(tray): show the tray before the speech model loads
The model load ran inside SpeechRecognitionManager's constructor, so the
tray was not built until the model was in memory and the app looked dead
for the whole load. The manager can now defer its first load
(defer_model_load). main() builds the tray first, then starts the load on
a background thread from the GTK main loop.

While the model loads the tray shows the processing icon, a "Loading
model…" title, and disables Start/Stop Voice Typing. Dictation attempted
during the load is refused with an error sound and a "Speech model
loading" notification; it is not queued. A failed load leaves the tray in
the Error state and shows a notification instead of exiting. Keep-alive
does not unload while the first load runs. If an auto-pause is already
active when the load would start, the load is skipped and auto-resume
loads the model later.

The model ladder, legacy import, D-Bus service and single-instance lock
are unchanged.

Fixes #50
2026-11-07 01:28:25 +05:30

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