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
@@ -0,0 +1,156 @@
|
||||
"""Tests for model keep-alive idle unload helper."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from vocalinux.model_keepalive import (
|
||||
DEFAULT_IDLE_TIMEOUT_SECONDS,
|
||||
ModelKeepAlive,
|
||||
clamp_idle_timeout,
|
||||
)
|
||||
from vocalinux.ui.config_manager import DEFAULT_CONFIG
|
||||
|
||||
|
||||
class TestClampIdleTimeout(unittest.TestCase):
|
||||
def test_clamps_low_and_high(self):
|
||||
self.assertEqual(clamp_idle_timeout(10), 60)
|
||||
self.assertEqual(clamp_idle_timeout(99999), 3600)
|
||||
self.assertEqual(clamp_idle_timeout(300), 300)
|
||||
|
||||
def test_invalid_falls_back_to_default(self):
|
||||
self.assertEqual(clamp_idle_timeout("nope"), float(DEFAULT_IDLE_TIMEOUT_SECONDS))
|
||||
self.assertEqual(clamp_idle_timeout(None), float(DEFAULT_IDLE_TIMEOUT_SECONDS))
|
||||
|
||||
|
||||
class TestModelKeepAlive(unittest.TestCase):
|
||||
def test_bump_when_disabled_cancels(self):
|
||||
fired = []
|
||||
ka = ModelKeepAlive(
|
||||
get_config=lambda: (False, 300),
|
||||
on_idle_unload=lambda: fired.append(1),
|
||||
use_glib=False,
|
||||
)
|
||||
ka.start()
|
||||
ka.bump()
|
||||
self.assertFalse(ka.armed)
|
||||
self.assertFalse(ka.fire_if_due())
|
||||
self.assertEqual(fired, [])
|
||||
|
||||
def test_bump_arms_and_fire_unloads(self):
|
||||
fired = []
|
||||
ka = ModelKeepAlive(
|
||||
get_config=lambda: (True, 300),
|
||||
on_idle_unload=lambda: fired.append(1),
|
||||
is_safe_to_unload=lambda: True,
|
||||
use_glib=False,
|
||||
)
|
||||
ka.start()
|
||||
ka.bump()
|
||||
self.assertTrue(ka.armed)
|
||||
self.assertTrue(ka.fire_if_due())
|
||||
self.assertEqual(fired, [1])
|
||||
self.assertFalse(ka.armed)
|
||||
# Second fire without re-arm is a no-op
|
||||
self.assertFalse(ka.fire_if_due())
|
||||
|
||||
def test_cancel_prevents_fire(self):
|
||||
fired = []
|
||||
ka = ModelKeepAlive(
|
||||
get_config=lambda: (True, 120),
|
||||
on_idle_unload=lambda: fired.append(1),
|
||||
use_glib=False,
|
||||
)
|
||||
ka.start()
|
||||
ka.bump()
|
||||
ka.cancel()
|
||||
self.assertFalse(ka.armed)
|
||||
self.assertFalse(ka.fire_if_due())
|
||||
self.assertEqual(fired, [])
|
||||
|
||||
def test_unsafe_predicate_skips_unload(self):
|
||||
fired = []
|
||||
ka = ModelKeepAlive(
|
||||
get_config=lambda: (True, 120),
|
||||
on_idle_unload=lambda: fired.append(1),
|
||||
is_safe_to_unload=lambda: False,
|
||||
use_glib=False,
|
||||
)
|
||||
ka.start()
|
||||
ka.bump()
|
||||
self.assertFalse(ka.fire_if_due())
|
||||
self.assertEqual(fired, [])
|
||||
|
||||
def test_callback_error_is_swallowed(self):
|
||||
def boom():
|
||||
raise RuntimeError("unload failed")
|
||||
|
||||
ka = ModelKeepAlive(
|
||||
get_config=lambda: (True, 120),
|
||||
on_idle_unload=boom,
|
||||
is_safe_to_unload=lambda: True,
|
||||
use_glib=False,
|
||||
)
|
||||
ka.start()
|
||||
ka.bump()
|
||||
self.assertFalse(ka.fire_if_due())
|
||||
|
||||
def test_config_error_disables(self):
|
||||
def bad_config():
|
||||
raise RuntimeError("config boom")
|
||||
|
||||
ka = ModelKeepAlive(get_config=bad_config, use_glib=False)
|
||||
ka.start()
|
||||
ka.bump()
|
||||
self.assertFalse(ka.armed)
|
||||
|
||||
def test_stop_and_shutdown(self):
|
||||
ka = ModelKeepAlive(get_config=lambda: (True, 120), use_glib=False)
|
||||
ka.start()
|
||||
ka.bump()
|
||||
ka.shutdown()
|
||||
self.assertFalse(ka.active)
|
||||
self.assertFalse(ka.armed)
|
||||
|
||||
def test_start_idempotent(self):
|
||||
ka = ModelKeepAlive(get_config=lambda: (True, 120), use_glib=False)
|
||||
ka.start()
|
||||
ka.start()
|
||||
self.assertTrue(ka.active)
|
||||
|
||||
def test_glib_schedule_and_timeout(self):
|
||||
"""Drive GLib scheduling via mocked gi.repository (same pattern as auto-pause)."""
|
||||
mock_glib = MagicMock()
|
||||
mock_glib.timeout_add_seconds.return_value = 42
|
||||
fired = []
|
||||
|
||||
with patch.dict(
|
||||
"sys.modules",
|
||||
{"gi": MagicMock(), "gi.repository": MagicMock(GLib=mock_glib)},
|
||||
):
|
||||
ka = ModelKeepAlive(
|
||||
get_config=lambda: (True, 90),
|
||||
on_idle_unload=lambda: fired.append(1),
|
||||
is_safe_to_unload=lambda: True,
|
||||
use_glib=True,
|
||||
)
|
||||
ka.start()
|
||||
ka.bump()
|
||||
mock_glib.timeout_add_seconds.assert_called()
|
||||
# Invoke the scheduled callback
|
||||
cb = mock_glib.timeout_add_seconds.call_args[0][1]
|
||||
self.assertFalse(cb())
|
||||
self.assertEqual(fired, [1])
|
||||
# Callback clears _timeout_id; shutdown should be a no-op cancel
|
||||
ka.shutdown()
|
||||
self.assertFalse(ka.active)
|
||||
self.assertFalse(ka.armed)
|
||||
|
||||
def test_default_config_section(self):
|
||||
self.assertIn("model_keepalive", DEFAULT_CONFIG)
|
||||
self.assertFalse(DEFAULT_CONFIG["model_keepalive"]["enabled"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user