Files
voculinux/tests/test_tray_model_loading.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

110 lines
3.6 KiB
Python

"""Tray rendering while the speech model loads in the background (#50).
The tray is built before the model load starts, so it must show a loading
state, re-render on the GTK main loop when the load finishes, and keep the
idle keep-alive from unloading a model that is still being loaded.
"""
from unittest.mock import MagicMock, patch
from vocalinux.common_types import RecognitionState
from vocalinux.ui import tray_indicator
LOADING_TITLE = "Loading model…"
ICONS = {
"default": "default-icon",
"active": "active-icon",
"processing": "processing-icon",
}
def _engine(state=RecognitionState.IDLE, loading=False):
engine = MagicMock()
engine.state = state
engine.model_loading = loading
engine.is_auto_paused = False
return engine
def _make_tray(engine):
"""Build a TrayIndicator without running GTK setup; only rendering helpers are real."""
tray = tray_indicator.TrayIndicator.__new__(tray_indicator.TrayIndicator)
tray.speech_engine = engine
tray.indicator = MagicMock()
tray._icon_keys = dict(ICONS)
tray._indicator_title = tray_indicator.TRAY_TITLE
tray._language_shortcuts_refresh_pending = False
tray.overlay = None
tray.shortcut_manager = MagicMock()
tray._model_keepalive = MagicMock()
tray._auto_pause_monitor = MagicMock(paused=False)
tray._set_indicator_icon = MagicMock()
tray._set_indicator_title = MagicMock()
tray._set_menu_item_enabled = MagicMock()
tray._set_cancel_menu_item_visible = MagicMock()
return tray
def test_loading_shows_processing_icon_and_loading_title_while_idle():
tray = _make_tray(_engine(loading=True))
tray._update_ui(RecognitionState.IDLE)
tray._set_indicator_icon.assert_called_with(ICONS["processing"], "Loading speech model")
tray._set_indicator_title.assert_called_with(LOADING_TITLE)
tray._set_menu_item_enabled.assert_any_call("Start Voice Typing", False)
tray._set_menu_item_enabled.assert_any_call("Stop Voice Typing", False)
def test_finished_load_returns_to_idle_icon_and_enables_start():
tray = _make_tray(_engine(loading=False))
tray._update_ui(RecognitionState.IDLE)
tray._set_indicator_icon.assert_called_with(ICONS["default"], "Microphone off")
tray._set_menu_item_enabled.assert_any_call("Start Voice Typing", True)
def test_loading_state_does_not_mask_an_active_session():
tray = _make_tray(_engine(state=RecognitionState.LISTENING, loading=True))
tray._update_ui(RecognitionState.LISTENING)
tray._set_indicator_icon.assert_called_with(ICONS["active"], "Microphone on")
def test_load_change_is_marshalled_onto_the_gtk_main_loop():
tray = _make_tray(_engine(loading=False))
with patch.object(tray_indicator, "GLib") as glib:
tray._on_model_load_changed()
glib.idle_add.assert_called_once_with(tray._apply_model_load_state)
tray._set_indicator_icon.assert_not_called()
def test_finishing_the_load_restarts_the_keep_alive_timer():
engine = _engine(loading=False)
tray = _make_tray(engine)
assert tray._apply_model_load_state() is False
tray._model_keepalive.bump.assert_called_once_with()
tray._set_indicator_icon.assert_called_with(ICONS["default"], "Microphone off")
def test_loading_in_progress_does_not_restart_the_keep_alive_timer():
tray = _make_tray(_engine(loading=True))
tray._apply_model_load_state()
tray._model_keepalive.bump.assert_not_called()
def test_keep_alive_never_unloads_while_the_model_is_loading():
loading = _make_tray(_engine(loading=True))
loaded = _make_tray(_engine(loading=False))
assert loading._is_safe_for_keepalive_unload() is False
assert loaded._is_safe_for_keepalive_unload() is True