feat(offload): auto-pause apps + idle keep-alive model unload (#592)
* feat(auto-pause): unload speech model while selected apps run Add Settings toggle + process list so Vocalinux frees CPU/GPU/RAM while configured games/apps are running, then reloads the model when they exit. Closes the process-list half of #445 (window-focus pause is separate). * test(auto-pause): isolate tray wiring to avoid stale GLib mocks Move TrayIndicator auto-pause tests into test_suspend_handler so test_auto_pause_monitor never imports tray_indicator under a collection-time gi MagicMock. That import pinned GLib.SOURCE_REMOVE and broke resume tests later in the suite (CI on #517). * test(auto-pause): raise patch coverage for codecov target Cover process snapshot I/O, GLib poll scheduling, config coercion, unload edge cases, and tray fallback paths so codecov/patch clears 80%. * feat(keepalive): idle model offload for GPU/battery saving Add opt-in keep-alive that unloads the speech model after inactivity so hybrid-GPU laptops can release VRAM/Vulkan. Next dictation lazy-reloads. Shares unload_model with auto-pause (#445) using distinct reasons so app-pause still blocks dictation while idle unload does not. Fixes #591 (with #445 via shared offload infrastructure). * test(keepalive): mock gi.repository.GLib via sys.modules Match the auto-pause GLib test pattern so CI on Python 3.9/3.10 sees the late `from gi.repository import GLib` import. * test(keepalive): drop post-fire source_remove assertion After the GLib timeout callback runs, _timeout_id is already cleared, so shutdown does not call source_remove. * fix(settings): hide auto-pause empty-state label when apps are listed The dialog-wide show_all() after _load_and_apply_settings() re-showed the 'No apps listed yet' row that _refresh_auto_pause_list had hidden. Mark the row no_show_all and manage visibility explicitly. Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com> * feat(settings): move auto-pause and idle unload to dedicated Power tab - New Power tab between Shortcuts and General so resource management features are easy to find; General returns to app-behavior toggles only - Rename 'Model Keep-Alive (Battery)' to 'Unload When Idle' — the old name read as the opposite of what the feature does - Clearer copy throughout: row titles, descriptions, tooltips, 'From running…' becomes 'Choose Running App…', empty-state hint - Gray out app-list and idle-timeout controls while their feature toggle is off Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com> * fix(settings): show which notebook tab is selected The tab CSS targeted :selected, but GTK3 marks the active notebook tab with :checked, so no selected styling ever applied — all tabs rendered identically. Use :checked and add an accent underline + bold label. Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com>
This commit is contained in:
co-authored by
Jatin K Malik
Cursor Agent
parent
b945137d60
commit
4c070ded46
@@ -457,5 +457,191 @@ class TestTrayIndicatorResumeFlow(unittest.TestCase):
|
||||
mock_glib.source_remove.assert_not_called()
|
||||
|
||||
|
||||
class TestTrayAutoPauseWiring(unittest.TestCase):
|
||||
"""TrayIndicator auto-pause config + pause/resume handlers.
|
||||
|
||||
Kept here (not in test_auto_pause_monitor) so tray_indicator is only
|
||||
imported from this module's existing tray tests, avoiding early import
|
||||
under a collection-time gi MagicMock that would pin a stale GLib on the
|
||||
module and break SOURCE_REMOVE comparisons later in the suite.
|
||||
"""
|
||||
|
||||
def _make_tray_indicator(self):
|
||||
from vocalinux.ui.tray_indicator import TrayIndicator
|
||||
|
||||
with patch.object(TrayIndicator, "__init__", lambda self: None):
|
||||
indicator = TrayIndicator.__new__(TrayIndicator)
|
||||
|
||||
indicator.speech_engine = MagicMock()
|
||||
indicator.speech_engine.state = RecognitionState.IDLE
|
||||
indicator.speech_engine.unload_model = MagicMock()
|
||||
indicator.speech_engine.reinitialize_after_resume = MagicMock()
|
||||
indicator.speech_engine.is_auto_paused = False
|
||||
indicator.config_manager = MagicMock()
|
||||
indicator._setup_keyboard_shortcuts = MagicMock()
|
||||
indicator._input_monitor = None
|
||||
indicator._settle_timer_id = None
|
||||
indicator._monitor_timeout_id = None
|
||||
indicator._model_keepalive = MagicMock()
|
||||
indicator._auto_pause_monitor = MagicMock()
|
||||
indicator._auto_pause_monitor.paused = False
|
||||
return indicator
|
||||
|
||||
def test_get_auto_pause_config(self):
|
||||
indicator = self._make_tray_indicator()
|
||||
indicator.config_manager.get_bool.return_value = True
|
||||
indicator.config_manager.get.return_value = ["steam"]
|
||||
indicator.config_manager.get_float.return_value = 7.0
|
||||
|
||||
enabled, apps, interval = indicator._get_auto_pause_config()
|
||||
|
||||
assert enabled is True
|
||||
assert apps == ["steam"]
|
||||
assert interval == 7.0
|
||||
indicator.config_manager.get_bool.assert_called_with("auto_pause", "enabled", False)
|
||||
indicator.config_manager.get.assert_called_with("auto_pause", "apps", [])
|
||||
|
||||
def test_on_auto_pause_calls_unload(self):
|
||||
indicator = self._make_tray_indicator()
|
||||
indicator._on_auto_pause()
|
||||
indicator.speech_engine.unload_model.assert_called_once_with(reason="auto_pause")
|
||||
indicator._model_keepalive.cancel.assert_called_once()
|
||||
|
||||
def test_on_auto_resume_calls_reinit(self):
|
||||
indicator = self._make_tray_indicator()
|
||||
indicator._on_auto_resume()
|
||||
indicator.speech_engine.reinitialize_after_resume.assert_called_once()
|
||||
indicator._model_keepalive.bump.assert_called_once()
|
||||
|
||||
def test_get_model_keepalive_config(self):
|
||||
indicator = self._make_tray_indicator()
|
||||
indicator.config_manager.get_bool.return_value = True
|
||||
indicator.config_manager.get_float.return_value = 600.0
|
||||
|
||||
enabled, timeout = indicator._get_model_keepalive_config()
|
||||
|
||||
assert enabled is True
|
||||
assert timeout == 600.0
|
||||
indicator.config_manager.get_bool.assert_called_with("model_keepalive", "enabled", False)
|
||||
|
||||
def test_on_keepalive_idle_unload(self):
|
||||
indicator = self._make_tray_indicator()
|
||||
indicator._on_keepalive_idle_unload()
|
||||
indicator.speech_engine.unload_model.assert_called_once_with(reason="idle_keepalive")
|
||||
|
||||
def test_is_safe_for_keepalive_unload(self):
|
||||
indicator = self._make_tray_indicator()
|
||||
indicator.speech_engine.state = RecognitionState.IDLE
|
||||
indicator.speech_engine.is_auto_paused = False
|
||||
assert indicator._is_safe_for_keepalive_unload() is True
|
||||
|
||||
indicator.speech_engine.state = RecognitionState.LISTENING
|
||||
assert indicator._is_safe_for_keepalive_unload() is False
|
||||
|
||||
indicator.speech_engine.state = RecognitionState.IDLE
|
||||
indicator.speech_engine.is_auto_paused = True
|
||||
assert indicator._is_safe_for_keepalive_unload() is False
|
||||
|
||||
@patch("vocalinux.ui.tray_indicator.GLib")
|
||||
def test_system_resume_skips_reinit_when_auto_paused(self, mock_glib):
|
||||
indicator = self._make_tray_indicator()
|
||||
indicator.speech_engine.is_auto_paused = True
|
||||
indicator._start_input_device_monitor = MagicMock()
|
||||
|
||||
indicator._on_system_resume()
|
||||
|
||||
assert mock_glib.timeout_add_seconds.call_count == 1
|
||||
assert (
|
||||
mock_glib.timeout_add_seconds.call_args[0][1] == indicator._start_input_device_monitor
|
||||
)
|
||||
|
||||
@patch("vocalinux.ui.tray_indicator.GLib")
|
||||
def test_system_resume_reinits_when_not_auto_paused(self, mock_glib):
|
||||
indicator = self._make_tray_indicator()
|
||||
indicator.speech_engine.is_auto_paused = False
|
||||
indicator._reinit_speech_after_resume = MagicMock()
|
||||
indicator._start_input_device_monitor = MagicMock()
|
||||
|
||||
indicator._on_system_resume()
|
||||
|
||||
assert mock_glib.timeout_add_seconds.call_count == 2
|
||||
|
||||
def test_get_auto_pause_config_coerces_non_list_apps(self):
|
||||
indicator = self._make_tray_indicator()
|
||||
indicator.config_manager.get_bool.return_value = True
|
||||
indicator.config_manager.get.return_value = "steam" # not a list
|
||||
indicator.config_manager.get_float.return_value = 5.0
|
||||
|
||||
enabled, apps, interval = indicator._get_auto_pause_config()
|
||||
|
||||
assert enabled is True
|
||||
assert apps == []
|
||||
assert interval == 5.0
|
||||
|
||||
def test_get_auto_pause_config_treats_none_apps_as_empty(self):
|
||||
indicator = self._make_tray_indicator()
|
||||
indicator.config_manager.get_bool.return_value = False
|
||||
indicator.config_manager.get.return_value = None
|
||||
indicator.config_manager.get_float.return_value = 5.0
|
||||
|
||||
enabled, apps, _interval = indicator._get_auto_pause_config()
|
||||
|
||||
assert enabled is False
|
||||
assert apps == []
|
||||
|
||||
def test_on_auto_pause_fallback_stops_when_no_unload(self):
|
||||
indicator = self._make_tray_indicator()
|
||||
# Non-callable attribute so getattr finds it but callable() is False
|
||||
indicator.speech_engine.unload_model = None
|
||||
indicator.speech_engine.state = RecognitionState.LISTENING
|
||||
indicator.speech_engine.stop_recognition = MagicMock()
|
||||
|
||||
indicator._on_auto_pause()
|
||||
|
||||
indicator.speech_engine.stop_recognition.assert_called_once()
|
||||
|
||||
def test_on_auto_pause_fallback_idle_is_noop(self):
|
||||
indicator = self._make_tray_indicator()
|
||||
indicator.speech_engine.unload_model = None
|
||||
indicator.speech_engine.state = RecognitionState.IDLE
|
||||
indicator.speech_engine.stop_recognition = MagicMock()
|
||||
|
||||
indicator._on_auto_pause()
|
||||
|
||||
indicator.speech_engine.stop_recognition.assert_not_called()
|
||||
|
||||
def test_on_auto_resume_swallows_reinit_errors(self):
|
||||
indicator = self._make_tray_indicator()
|
||||
indicator.speech_engine.reinitialize_after_resume.side_effect = RuntimeError("boom")
|
||||
|
||||
indicator._on_auto_resume() # must not raise
|
||||
|
||||
indicator.speech_engine.reinitialize_after_resume.assert_called_once()
|
||||
|
||||
def test_on_auto_resume_without_reinit_method(self):
|
||||
indicator = self._make_tray_indicator()
|
||||
indicator.speech_engine.reinitialize_after_resume = None
|
||||
|
||||
indicator._on_auto_resume() # no-op path
|
||||
indicator._model_keepalive.bump.assert_called_once()
|
||||
|
||||
def test_quit_shuts_down_keepalive(self):
|
||||
indicator = self._make_tray_indicator()
|
||||
monitor = MagicMock()
|
||||
indicator._auto_pause_monitor = monitor
|
||||
indicator._suspend_handler = MagicMock()
|
||||
indicator._cleanup_input_monitor = MagicMock()
|
||||
indicator.shortcut_manager = MagicMock()
|
||||
indicator.text_injector = None
|
||||
|
||||
with patch("vocalinux.ui.tray_indicator.Gtk") as mock_gtk:
|
||||
indicator._quit()
|
||||
|
||||
monitor.shutdown.assert_called_once()
|
||||
indicator._model_keepalive.shutdown.assert_called_once()
|
||||
indicator._suspend_handler.shutdown.assert_called_once()
|
||||
mock_gtk.main_quit.assert_called_once()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user