From 9b91b8b177dc3492285dae79b6aed863b29a916f Mon Sep 17 00:00:00 2001 From: Xavier Karma Date: Sat, 7 Nov 2026 01:24:31 +0530 Subject: [PATCH] fix(tray): show the tray before the speech model loads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/vocalinux/main.py | 7 + .../speech_recognition/recognition_manager.py | 126 ++++++++++-- src/vocalinux/ui/settings_dialog.py | 4 + src/vocalinux/ui/tray_indicator.py | 37 +++- tests/test_main.py | 64 ++++++ tests/test_model_load_in_background.py | 185 ++++++++++++++++++ tests/test_tray_model_loading.py | 109 +++++++++++ 7 files changed, 516 insertions(+), 16 deletions(-) create mode 100644 tests/test_model_load_in_background.py create mode 100644 tests/test_tray_model_loading.py diff --git a/src/vocalinux/main.py b/src/vocalinux/main.py index 3b4a1ab..ab8af7d 100644 --- a/src/vocalinux/main.py +++ b/src/vocalinux/main.py @@ -621,6 +621,8 @@ def main(): remote_api_key=saved_settings.get("remote_api_key", ""), remote_api_endpoint=saved_settings.get("remote_api_endpoint", "/inference"), remote_api_model=saved_settings.get("remote_api_model", "whisper-1"), + # The model loads in the background so the tray can appear first (#50). + defer_model_load=True, ) # Initialize text injection system @@ -1382,6 +1384,11 @@ def main(): dictation_pad=dictation_pad, ) + # Start the model load from the GTK main loop, after the tray's own idle + # callback has put the icon up. The load runs on its own thread, so the + # tray is usable while it loads (#50). + GLib.idle_add(speech_engine.load_model_in_background) + # Start the GTK main loop indicator.run() diff --git a/src/vocalinux/speech_recognition/recognition_manager.py b/src/vocalinux/speech_recognition/recognition_manager.py index 7cb1efe..c3a8bb5 100644 --- a/src/vocalinux/speech_recognition/recognition_manager.py +++ b/src/vocalinux/speech_recognition/recognition_manager.py @@ -288,6 +288,9 @@ def resolve_language_preference(language: str) -> str: logger = logging.getLogger(__name__) +# Engine names the constructor accepts; checked up front when the load is deferred (#50). +_SUPPORTED_ENGINES = ("vosk", "whisper", "whisper_cpp", "parakeet", "faster_whisper", "remote_api") + _WHISPERCPP_ABORT_CALLBACK_SUPPORT: dict[type, bool] = {} @@ -570,6 +573,12 @@ class SpeechRecognitionManager: # cancel-and-retry cannot stack unaccounted request threads. self._download_openers: set[threading.Thread] = set() self._defer_download = defer_download + # Startup may defer the first model load to a background thread (#50): + # the engine reports model_loading until that load has finished. + self._model_loading = bool(kwargs.get("defer_model_load", False)) + self._model_load_started = False + self._model_load_thread: Optional[threading.Thread] = None + self._model_load_callbacks: list[Callable[[], None]] = [] self._model_initialized = False # True while a configured app/game blocks dictation, including pending unloads self._auto_paused = False @@ -690,21 +699,14 @@ class SpeechRecognitionManager: f"Initializing speech recognition with {engine} engine, {self.language} language and {model_size} model" ) - # Initialize the selected speech recognition engine - if engine == "vosk": - self._init_vosk() - elif engine == "whisper": - self._init_whisper() - elif engine == "whisper_cpp": - self._init_whispercpp() - elif engine == "parakeet": - self._init_parakeet() - elif engine == "faster_whisper": - self._init_faster_whisper() - elif engine == "remote_api": - self._init_remote_api() + # Initialize the selected speech recognition engine. A deferred load is + # started later by load_model_in_background(); the engine name is still + # checked now so a bad name fails at construction, as before. + if self._model_loading: + if engine not in _SUPPORTED_ENGINES: + raise ValueError(f"Unsupported speech recognition engine: {engine}") else: - raise ValueError(f"Unsupported speech recognition engine: {engine}") + self._init_selected_engine() @staticmethod def _normalize_language_candidates(value: str | list[str] | None) -> list[str]: @@ -3300,6 +3302,83 @@ class SpeechRecognitionManager: for callback in self.state_callbacks: callback(new_state) + @property + def model_loading(self) -> bool: + """True from a deferred construction until its background load has finished (#50).""" + return bool(getattr(self, "_model_loading", False)) + + def register_model_load_callback(self, callback: Callable[[], None]) -> None: + """Call ``callback()`` on the loader thread once a deferred load has finished. + + Fires whether the load succeeded, failed or was skipped. Consumers marshal + onto the GTK main loop themselves (#50). + """ + self._model_load_callbacks.append(callback) + + def load_model_in_background(self) -> None: + """Start the deferred first model load on a daemon thread (#50). + + A no-op unless the manager was built with ``defer_model_load=True``, and + only the first call starts a load. The request token is taken here, on + the caller's thread, so an auto-pause that lands before the loader gets + the model lock supersedes the load instead of racing it. + """ + if not self._model_loading or self._model_load_started: + return + self._model_load_started = True + request = self._new_model_request() + self._model_load_thread = threading.Thread( + target=self._run_background_model_load, + args=(request,), + name="voculinux-model-load", + daemon=True, + ) + self._model_load_thread.start() + + def _run_background_model_load(self, request: object) -> None: + """Initialize the configured engine on the loader thread (#50). + + Shares the model lock and request ownership with resume, but skips the + resume-only bookkeeping: a startup load publishes the model and nothing + else. The engine's own loading ladder and fallbacks run unchanged. + """ + started = time.monotonic() + try: + with self._model_lock: + # An auto-pause that is already active (a listed app is running) + # must keep the model out of memory; auto-resume loads it later. + if self._auto_paused or not self._is_current_model_request(request): + logger.info("Speech model load skipped: auto-paused or superseded") + else: + with self._model_request_context(request): + self._init_selected_engine() + logger.info("Speech model loaded in %.2fs", time.monotonic() - started) + except Exception as error: + logger.error("Speech model failed to load", exc_info=True) + if self._is_current_model_request(request): + self._update_state(RecognitionState.ERROR, model_request=request) + _show_notification( + "Speech model failed to load", + f"{error}. Restart Voculinux to try again.", + "dialog-error", + ) + finally: + self._model_loading = False + self._notify_model_load_callbacks() + + def _is_current_model_request(self, request: object) -> bool: + """Whether no auto-pause or reconfigure has superseded ``request``.""" + with getattr(self, "_model_request_lock", nullcontext()): + return self._model_request is request + + def _notify_model_load_callbacks(self) -> None: + """Run each load-state callback; one failing consumer must not stop the rest.""" + for callback in list(self._model_load_callbacks): + try: + callback() + except Exception: + logger.debug("Model load callback failed", exc_info=True) + @property def model_ready(self) -> bool: """Check if the model is initialized and ready for recognition.""" @@ -3501,6 +3580,17 @@ class SpeechRecognitionManager: return False reload_in_background = False + # The first load (#50) is still running. Nothing can be dictated yet, and + # the missing-model offer below would be wrong, so say that instead. + if self.model_loading and not self.model_ready: + logger.info("Cannot start recognition: speech model is still loading") + play_error_sound() + _show_notification( + "Speech model loading", + "Dictation will be available once the speech model has finished loading.", + "dialog-information", + ) + return False # Check if model is ready (lazy-reload after idle keep-alive unload) if not self.model_ready: if self._idle_unloaded: @@ -3932,7 +4022,13 @@ class SpeechRecognitionManager: # select it. self._refuse_language_override(language) return False - if target != self.language and not self._serving_model_supports_language_switch(): + # While the first load runs there is no serving model to judge yet; the + # start itself is refused with the loading notice. + if ( + target != self.language + and not self.model_loading + and not self._serving_model_supports_language_switch() + ): self._refuse_language_override(language) return False diff --git a/src/vocalinux/ui/settings_dialog.py b/src/vocalinux/ui/settings_dialog.py index d9ea290..905c506 100644 --- a/src/vocalinux/ui/settings_dialog.py +++ b/src/vocalinux/ui/settings_dialog.py @@ -9346,6 +9346,10 @@ class SettingsDialog(AboutSectionMixin): "Dictation is paused. Close the listed app or remove it from " "Auto-Pause settings." ) + elif getattr(self.speech_engine, "model_loading", False) is True: + self.test_buffer.set_text( + "The speech model is still loading. Try Test Dictation again in a moment." + ) elif not getattr(self.speech_engine, "model_ready", True): self.test_buffer.set_text( "No speech model downloaded. Open the Speech Model page and " diff --git a/src/vocalinux/ui/tray_indicator.py b/src/vocalinux/ui/tray_indicator.py index 0dbaf67..1328824 100644 --- a/src/vocalinux/ui/tray_indicator.py +++ b/src/vocalinux/ui/tray_indicator.py @@ -161,6 +161,9 @@ _INPUT_SETTLE_SECONDS = 2 _INPUT_MONITOR_CAP_SECONDS = 10 _FALLBACK_KEYBOARD_RESTART_SECONDS = 6 +# Tray title while the speech model loads in the background (#50). +LOADING_TITLE = "Loading model…" + class TrayIndicator: """ @@ -282,6 +285,11 @@ class TrayIndicator: # Register for speech recognition state changes self.speech_engine.register_state_callback(self._on_recognition_state_changed) + # The model may still be loading in the background (#50); re-render when + # that load finishes. + register_load_callback = getattr(self.speech_engine, "register_model_load_callback", None) + if callable(register_load_callback): + register_load_callback(self._on_model_load_changed) # Offer the download instead of only telling the user a model is missing self._model_download_active = False @@ -913,6 +921,25 @@ class TrayIndicator: if isinstance(indicator, GMenuIndicator): indicator.refresh_menu() + def _engine_model_loading(self) -> bool: + """True only while the engine reports its first model load in progress (#50).""" + return getattr(self.speech_engine, "model_loading", False) is True + + def _on_model_load_changed(self) -> None: + """Model load finished. Runs on the loader thread, so marshal it (#50).""" + GLib.idle_add(self._apply_model_load_state) + + def _apply_model_load_state(self) -> bool: + """Re-render after a model load changed state (GTK main loop only, #50).""" + if not self._engine_model_loading(): + # Keep-alive refuses to unload while the load runs; restart its + # idle timer now that the model is in memory. + keepalive = getattr(self, "_model_keepalive", None) + if keepalive is not None: + keepalive.bump() + self._update_ui(getattr(self.speech_engine, "state", RecognitionState.IDLE)) + return False # One-shot idle callback + def _on_recognition_state_changed(self, state: RecognitionState): """ Handle changes in the speech recognition state. @@ -1323,7 +1350,12 @@ class TrayIndicator: show_cancel = state == RecognitionState.PROCESSING and transcribing self._set_cancel_menu_item_visible(show_cancel) - if state == RecognitionState.IDLE: + if state == RecognitionState.IDLE and self._engine_model_loading(): + self._set_indicator_title(LOADING_TITLE) + self._set_indicator_icon(self._icon_keys["processing"], "Loading speech model") + self._set_menu_item_enabled("Start Voice Typing", False) + self._set_menu_item_enabled("Stop Voice Typing", False) + elif state == RecognitionState.IDLE: self._set_indicator_title(TRAY_TITLE) self._set_indicator_icon(self._icon_keys["default"], "Microphone off") self._set_menu_item_enabled("Start Voice Typing", True) @@ -1714,6 +1746,9 @@ class TrayIndicator: def _is_safe_for_keepalive_unload(self) -> bool: """Keep-alive may unload only when idle and not under auto-pause.""" + if self._engine_model_loading(): + # The first load (#50) is still putting the model in memory. + return False if getattr(self.speech_engine, "is_auto_paused", False) is True: return False if ( diff --git a/tests/test_main.py b/tests/test_main.py index a93a5aa..b283c8d 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -257,6 +257,7 @@ class TestMainModule(unittest.TestCase): remote_api_key="", remote_api_endpoint="/inference", remote_api_model="whisper-1", + defer_model_load=True, ) mock_text.assert_called_once_with(wayland_mode=True) mock_action_handler.assert_called_once_with(mock_text_instance) @@ -286,6 +287,69 @@ class TestMainModule(unittest.TestCase): # Verify the tray indicator was started mock_tray_instance.run.assert_called_once() + @patch("vocalinux.main.check_dependencies") + @patch("vocalinux.ui.dictation_pad.DictationPad") + @patch("vocalinux.ui.action_handler.ActionHandler") + @patch("vocalinux.speech_recognition.recognition_manager.SpeechRecognitionManager") + @patch("vocalinux.text_injection.text_injector.TextInjector") + @patch("vocalinux.ui.tray_indicator.TrayIndicator") + @patch("vocalinux.main.logging") + @patch("vocalinux.ui.config_manager.get_shared_config_manager") + @patch("vocalinux.ui.logging_manager.initialize_logging") + def test_tray_is_shown_before_the_model_load_is_scheduled( + self, + mock_init_logging, + mock_config_manager, + mock_logging, + mock_tray, + mock_text, + mock_speech, + mock_action_handler, + mock_dictation_pad, + mock_check_deps, + ): + """The tray is built first, then the model load is queued, then the loop runs (#50).""" + mock_check_deps.return_value = True + mock_config_instance = MagicMock() + mock_config_instance.get_bool.return_value = True + mock_config_instance.get_settings.return_value = { + "speech_recognition": {}, + "general": {"first_run": False}, + } + mock_config_instance.get_model_size_for_engine.return_value = "medium" + mock_config_instance.get_str.return_value = "" + mock_config_instance.is_dictate_to_pad_enabled.return_value = False + mock_config_manager.return_value = mock_config_instance + + mock_speech_instance = MagicMock() + mock_speech.return_value = mock_speech_instance + mock_tray_instance = MagicMock() + mock_tray.return_value = mock_tray_instance + + order = MagicMock() + order.attach_mock(mock_tray, "tray") + order.attach_mock(mock_tray_instance.run, "run") + + with ( + patch("vocalinux.main.parse_arguments") as mock_parse, + patch("gi.repository.GLib.idle_add") as mock_idle_add, + ): + order.attach_mock(mock_idle_add, "idle_add") + mock_args = MagicMock() + mock_args.debug = False + mock_args.model = "medium" + mock_args.engine = "whisper_cpp" + mock_args.language = "en-us" + mock_args.wayland = False + mock_parse.return_value = mock_args + + main() + + mock_speech.assert_called_once() + self.assertIs(mock_speech.call_args.kwargs["defer_model_load"], True) + mock_idle_add.assert_called_once_with(mock_speech_instance.load_model_in_background) + self.assertEqual([c[0] for c in order.mock_calls], ["tray", "idle_add", "run"]) + @patch("vocalinux.main.check_dependencies") @patch("vocalinux.speech_recognition.recognition_manager.SpeechRecognitionManager") @patch("vocalinux.text_injection.text_injector.TextInjector") diff --git a/tests/test_model_load_in_background.py b/tests/test_model_load_in_background.py new file mode 100644 index 0000000..e67b716 --- /dev/null +++ b/tests/test_model_load_in_background.py @@ -0,0 +1,185 @@ +"""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() diff --git a/tests/test_tray_model_loading.py b/tests/test_tray_model_loading.py new file mode 100644 index 0000000..171eb51 --- /dev/null +++ b/tests/test_tray_model_loading.py @@ -0,0 +1,109 @@ +"""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 -- 2.54.0