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

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