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
110 lines
3.6 KiB
Python
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
|