"""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