feat(activation): D-Bus activation for compositor global shortcuts (#568)
Rebased onto latest main (conflict-resolution squash for PR #568). Co-authored-by: Alexander <ab@digital-benefits.de> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
Alexander
Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent
c0217a8aec
commit
db07590761
@@ -0,0 +1,316 @@
|
||||
"""
|
||||
Tests for D-Bus based external activation.
|
||||
|
||||
Covers:
|
||||
- CLI dispatch: --toggle/--start/--stop forward the right D-Bus command.
|
||||
- Config gate: external mode skips the internal hotkey listener.
|
||||
- D-Bus handlers route to the correct recognition callbacks.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vocalinux.dbus_service import VocalinuxDBusService
|
||||
|
||||
# gi is mocked globally by conftest. All vocalinux imports are done lazily
|
||||
# inside the individual tests. Importing them at module level would bind their
|
||||
# `gi.repository` references at collection time and clash with test_main.py,
|
||||
# which replaces sys.modules["gi"] / sys.modules["gi.repository"] wholesale --
|
||||
# leading to MagicMock identity mismatches (e.g. GLib.SOURCE_REMOVE) in
|
||||
# unrelated tests that run in the same session.
|
||||
|
||||
# -- CLI dispatch ----------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"flag,command",
|
||||
[("--toggle", "toggle"), ("--start", "start"), ("--stop", "stop")],
|
||||
)
|
||||
def test_cli_flag_sends_matching_command(flag, command):
|
||||
"""Each trigger flag forwards the matching command and exits with 0."""
|
||||
from vocalinux.main import main
|
||||
|
||||
with patch("vocalinux.dbus_service.send_command", return_value=True) as mock_send:
|
||||
with patch.object(sys, "argv", ["vocalinux", flag]):
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main()
|
||||
assert exc_info.value.code == 0
|
||||
mock_send.assert_called_once_with(command)
|
||||
|
||||
|
||||
def test_cli_flag_exits_nonzero_when_no_instance():
|
||||
"""When no instance answers, the trigger exits with a non-zero code."""
|
||||
from vocalinux.main import main
|
||||
|
||||
with patch("vocalinux.dbus_service.send_command", return_value=False):
|
||||
with patch.object(sys, "argv", ["vocalinux", "--toggle"]):
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main()
|
||||
assert exc_info.value.code == 1
|
||||
|
||||
|
||||
def test_no_trigger_flag_does_not_dispatch():
|
||||
"""Without a trigger flag, main() proceeds past the dispatch shortcut."""
|
||||
from vocalinux.main import main
|
||||
|
||||
# Mock single_instance via sys.modules (not patch("...acquire_lock")) so we
|
||||
# don't import the real module and set it as a vocalinux package attribute,
|
||||
# which would defeat patch.dict-based single-instance tests elsewhere.
|
||||
mock_single_instance = MagicMock()
|
||||
mock_single_instance.acquire_lock.return_value = True
|
||||
with patch("vocalinux.dbus_service.send_command") as mock_send:
|
||||
# check_dependencies returns False so main() exits early after dispatch.
|
||||
with patch.dict(sys.modules, {"vocalinux.single_instance": mock_single_instance}):
|
||||
with patch("vocalinux.main.check_dependencies", return_value=False):
|
||||
with patch.object(sys, "argv", ["vocalinux"]):
|
||||
with pytest.raises(SystemExit):
|
||||
main()
|
||||
mock_send.assert_not_called()
|
||||
|
||||
|
||||
# Config-gate tests for TrayIndicator._setup_keyboard_shortcuts live in
|
||||
# test_tray_indicator_ext.py (TestExternalActivationGate). They require the real
|
||||
# tray_indicator module, which must not be imported this early in the session --
|
||||
# doing so freezes tray_indicator.GLib against a stale gi.repository mock and
|
||||
# breaks unrelated GLib-identity assertions elsewhere.
|
||||
|
||||
# -- D-Bus handlers --------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"method,expected",
|
||||
[("Toggle", "on_toggle"), ("Start", "on_start"), ("Stop", "on_stop")],
|
||||
)
|
||||
def test_method_call_routes_to_callback(method, expected):
|
||||
"""A D-Bus method call invokes the matching callback on the main thread."""
|
||||
from vocalinux import dbus_service
|
||||
|
||||
callbacks = {
|
||||
"on_toggle": MagicMock(),
|
||||
"on_start": MagicMock(),
|
||||
"on_stop": MagicMock(),
|
||||
}
|
||||
service = dbus_service.VocalinuxDBusService(
|
||||
on_toggle=callbacks["on_toggle"],
|
||||
on_start=callbacks["on_start"],
|
||||
on_stop=callbacks["on_stop"],
|
||||
)
|
||||
|
||||
invocation = MagicMock()
|
||||
# Make idle_add run the marshalled call synchronously for the assertion.
|
||||
with patch.object(dbus_service.GLib, "idle_add", side_effect=lambda fn, *a: fn(*a)):
|
||||
service._handle_method_call(
|
||||
MagicMock(),
|
||||
"sender",
|
||||
dbus_service.OBJECT_PATH,
|
||||
dbus_service.INTERFACE_NAME,
|
||||
method,
|
||||
None,
|
||||
invocation,
|
||||
)
|
||||
|
||||
callbacks[expected].assert_called_once_with()
|
||||
for name, cb in callbacks.items():
|
||||
if name != expected:
|
||||
cb.assert_not_called()
|
||||
invocation.return_value.assert_called_once()
|
||||
|
||||
|
||||
def test_unknown_method_returns_error():
|
||||
"""An unknown method name returns a D-Bus error and no callback runs."""
|
||||
from vocalinux import dbus_service
|
||||
|
||||
on_toggle = MagicMock()
|
||||
service = dbus_service.VocalinuxDBusService(
|
||||
on_toggle=on_toggle, on_start=MagicMock(), on_stop=MagicMock()
|
||||
)
|
||||
invocation = MagicMock()
|
||||
service._handle_method_call(
|
||||
MagicMock(),
|
||||
"sender",
|
||||
dbus_service.OBJECT_PATH,
|
||||
dbus_service.INTERFACE_NAME,
|
||||
"Bogus",
|
||||
None,
|
||||
invocation,
|
||||
)
|
||||
invocation.return_error_literal.assert_called_once()
|
||||
on_toggle.assert_not_called()
|
||||
|
||||
|
||||
def test_shutdown_releases_name_and_object():
|
||||
"""shutdown() unregisters the object and unowns the bus name, idempotently."""
|
||||
from vocalinux import dbus_service
|
||||
|
||||
service = dbus_service.VocalinuxDBusService(
|
||||
on_toggle=MagicMock(), on_start=MagicMock(), on_stop=MagicMock()
|
||||
)
|
||||
# Simulate a successful registration having happened.
|
||||
connection = MagicMock()
|
||||
service._connection = connection
|
||||
service._registration_id = 42
|
||||
service._owner_id = 7
|
||||
|
||||
with patch.object(dbus_service.Gio, "bus_unown_name") as mock_unown:
|
||||
service.shutdown()
|
||||
connection.unregister_object.assert_called_once_with(42)
|
||||
mock_unown.assert_called_once_with(7)
|
||||
|
||||
# A second shutdown must be a no-op (ids cleared).
|
||||
connection.unregister_object.reset_mock()
|
||||
mock_unown.reset_mock()
|
||||
service.shutdown()
|
||||
connection.unregister_object.assert_not_called()
|
||||
mock_unown.assert_not_called()
|
||||
|
||||
|
||||
def test_send_command_uses_proxy_call():
|
||||
"""send_command forwards the mapped method name through a D-Bus proxy."""
|
||||
from vocalinux import dbus_service
|
||||
|
||||
mock_proxy = MagicMock()
|
||||
with patch.object(dbus_service.Gio.DBusProxy, "new_for_bus_sync", return_value=mock_proxy):
|
||||
result = dbus_service.send_command("toggle")
|
||||
assert result is True
|
||||
args = mock_proxy.call_sync.call_args.args
|
||||
assert args[0] == "Toggle"
|
||||
|
||||
|
||||
def test_send_command_rejects_unknown():
|
||||
"""An unknown command name raises ValueError."""
|
||||
from vocalinux import dbus_service
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
dbus_service.send_command("explode")
|
||||
|
||||
|
||||
def test_send_command_returns_false_without_session_bus():
|
||||
"""A generic failure (e.g. no session bus) yields False, not a traceback."""
|
||||
from vocalinux import dbus_service
|
||||
|
||||
# GLib.Error is a MagicMock under the test harness, so patch it to a real
|
||||
# exception class; the raised error is a different type and must fall
|
||||
# through to the generic handler.
|
||||
with patch.object(dbus_service.GLib, "Error", RuntimeError):
|
||||
with patch.object(
|
||||
dbus_service.Gio.DBusProxy,
|
||||
"new_for_bus_sync",
|
||||
side_effect=ValueError("no session bus"),
|
||||
):
|
||||
assert dbus_service.send_command("toggle") is False
|
||||
|
||||
|
||||
def test_send_command_returns_false_on_glib_error():
|
||||
"""A GLib.Error from the proxy call is caught and yields False."""
|
||||
from vocalinux import dbus_service
|
||||
|
||||
# Patch GLib.Error to a real class so both the raise and the `except` clause
|
||||
# reference the same type and the GLib.Error branch is exercised.
|
||||
with patch.object(dbus_service.GLib, "Error", RuntimeError):
|
||||
with patch.object(
|
||||
dbus_service.Gio.DBusProxy,
|
||||
"new_for_bus_sync",
|
||||
side_effect=RuntimeError("bus error"),
|
||||
):
|
||||
assert dbus_service.send_command("toggle") is False
|
||||
|
||||
|
||||
# -- Service lifecycle & error paths --------------------------------------
|
||||
|
||||
|
||||
def _make_service() -> "VocalinuxDBusService":
|
||||
from vocalinux import dbus_service
|
||||
|
||||
return dbus_service.VocalinuxDBusService(
|
||||
on_toggle=MagicMock(), on_start=MagicMock(), on_stop=MagicMock()
|
||||
)
|
||||
|
||||
|
||||
def test_register_handles_bus_own_name_failure():
|
||||
"""A failure while requesting the bus name is logged, not raised."""
|
||||
from vocalinux import dbus_service
|
||||
|
||||
# Patch GLib.Error to a real class so both the raise and the `except` clause
|
||||
# reference the same type and the GLib.Error branch is exercised.
|
||||
with patch.object(dbus_service.GLib, "Error", RuntimeError):
|
||||
with patch.object(dbus_service.Gio, "bus_own_name", side_effect=RuntimeError("no bus")):
|
||||
# __init__ calls _register(); it must not propagate the error.
|
||||
service = _make_service()
|
||||
assert service._owner_id == 0
|
||||
|
||||
|
||||
def test_on_bus_acquired_registers_object():
|
||||
"""_on_bus_acquired stores the connection and registers the control object."""
|
||||
from vocalinux import dbus_service
|
||||
|
||||
service = _make_service()
|
||||
connection = MagicMock()
|
||||
connection.register_object.return_value = 99
|
||||
|
||||
service._on_bus_acquired(connection, dbus_service.BUS_NAME)
|
||||
|
||||
assert service._connection is connection
|
||||
assert service._registration_id == 99
|
||||
connection.register_object.assert_called_once()
|
||||
|
||||
|
||||
def test_on_bus_acquired_handles_register_failure():
|
||||
"""A failed object registration is caught and leaves the id unset."""
|
||||
from vocalinux import dbus_service
|
||||
|
||||
service = _make_service()
|
||||
connection = MagicMock()
|
||||
connection.register_object.side_effect = RuntimeError("register failed")
|
||||
|
||||
# Patch GLib.Error to a real class so both the raise and the `except` clause
|
||||
# reference the same type and the GLib.Error branch is exercised.
|
||||
with patch.object(dbus_service.GLib, "Error", RuntimeError):
|
||||
service._on_bus_acquired(connection, "com.vocalinux.Vocalinux")
|
||||
|
||||
assert service._registration_id == 0
|
||||
|
||||
|
||||
def test_name_acquired_and_lost_are_safe_to_call():
|
||||
"""The name-acquired/lost callbacks only log and never raise."""
|
||||
service = _make_service()
|
||||
connection = MagicMock()
|
||||
service._on_name_acquired(connection, "com.vocalinux.Vocalinux")
|
||||
service._on_name_lost(connection, "com.vocalinux.Vocalinux")
|
||||
|
||||
|
||||
def test_invoke_swallows_callback_errors():
|
||||
"""_invoke logs a raising callback and still returns SOURCE_REMOVE."""
|
||||
from vocalinux import dbus_service
|
||||
|
||||
failing = MagicMock(side_effect=RuntimeError("boom"))
|
||||
result = dbus_service.VocalinuxDBusService._invoke(failing)
|
||||
failing.assert_called_once_with()
|
||||
assert result == dbus_service.GLib.SOURCE_REMOVE
|
||||
|
||||
|
||||
def test_shutdown_swallows_unregister_and_unown_errors():
|
||||
"""shutdown() ignores failures from unregister_object and bus_unown_name."""
|
||||
from vocalinux import dbus_service
|
||||
|
||||
service = _make_service()
|
||||
connection = MagicMock()
|
||||
connection.unregister_object.side_effect = RuntimeError("unregister failed")
|
||||
service._connection = connection
|
||||
service._registration_id = 42
|
||||
service._owner_id = 7
|
||||
|
||||
# Patch GLib.Error to a real class so both the raise and the `except` clause
|
||||
# reference the same type and the GLib.Error branch is exercised.
|
||||
with patch.object(dbus_service.GLib, "Error", RuntimeError):
|
||||
with patch.object(
|
||||
dbus_service.Gio, "bus_unown_name", side_effect=RuntimeError("unown failed")
|
||||
):
|
||||
# Must not raise despite both cleanup calls failing.
|
||||
service.shutdown()
|
||||
|
||||
assert service._registration_id == 0
|
||||
assert service._owner_id == 0
|
||||
@@ -8,7 +8,7 @@ import os
|
||||
import unittest
|
||||
|
||||
|
||||
def _get_source_code():
|
||||
def _get_source_code() -> str:
|
||||
"""Read the settings dialog source file."""
|
||||
source_path = os.path.join(
|
||||
os.path.dirname(__file__),
|
||||
@@ -253,5 +253,64 @@ class TestConfigManagerShortcuts(unittest.TestCase):
|
||||
self.assertEqual(config.get("shortcuts", "mode"), "push_to_talk")
|
||||
|
||||
|
||||
class TestExternalActivationToggle(unittest.TestCase):
|
||||
"""Source-inspection tests for the external-activation switch.
|
||||
|
||||
The dialog subclasses a mocked Gtk.Dialog, so its methods can't be called
|
||||
directly; we assert the wiring is present in source (same approach as
|
||||
TestSettingsDialogShortcutsSection above).
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.source_code = _get_source_code()
|
||||
|
||||
def test_switch_widget_created(self):
|
||||
"""The external-activation switch is created in the shortcuts section."""
|
||||
self.assertIn("self.disable_internal_hotkey_switch = Gtk.Switch()", self.source_code)
|
||||
|
||||
def test_switch_signal_connected(self):
|
||||
"""The switch is wired to its state-set handler."""
|
||||
self.assertIn("self.disable_internal_hotkey_switch.connect(", self.source_code)
|
||||
self.assertIn("self._on_disable_internal_hotkey_toggled", self.source_code)
|
||||
|
||||
def test_handler_persists_config_key(self):
|
||||
"""The handler saves the disable_internal_hotkey flag."""
|
||||
self.assertIn(
|
||||
'self.config_manager.set("shortcuts", "disable_internal_hotkey", disabled)',
|
||||
self.source_code,
|
||||
)
|
||||
self.assertIn("self.config_manager.save_settings()", self.source_code)
|
||||
|
||||
def test_handler_live_applies_via_callback(self):
|
||||
"""Toggling live-applies by invoking the listener update callback."""
|
||||
self.assertIn("self.hotkey_listener_update_callback()", self.source_code)
|
||||
|
||||
def test_handler_guards_during_initialization(self):
|
||||
"""The handler is inert while the dialog is initializing/applying."""
|
||||
self.assertIn(
|
||||
"def _on_disable_internal_hotkey_toggled(self, widget: Gtk.Switch, state: bool)"
|
||||
" -> bool:",
|
||||
self.source_code,
|
||||
)
|
||||
self.assertIn("if self._initializing or self._applying_settings:", self.source_code)
|
||||
|
||||
def test_sensitivity_helper_greys_internal_rows(self):
|
||||
"""External activation greys out the built-in shortcut rows."""
|
||||
self.assertIn(
|
||||
"def _update_internal_hotkey_sensitivity(self, disabled: bool) -> None:",
|
||||
self.source_code,
|
||||
)
|
||||
self.assertIn(
|
||||
"for row in (self.mode_row, self.shortcut_row, self.custom_shortcut_row):",
|
||||
self.source_code,
|
||||
)
|
||||
self.assertIn("row.set_sensitive(not disabled)", self.source_code)
|
||||
|
||||
def test_initial_state_loaded(self):
|
||||
"""The switch's initial state is loaded from config during populate."""
|
||||
self.assertIn("self.disable_internal_hotkey_switch.set_active(", self.source_code)
|
||||
self.assertIn('"shortcuts", "disable_internal_hotkey", False', self.source_code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -302,6 +302,9 @@ class TestTrayIndicatorResumeFlow(unittest.TestCase):
|
||||
indicator = TrayIndicator.__new__(TrayIndicator)
|
||||
|
||||
indicator.speech_engine = MagicMock()
|
||||
indicator.config_manager = MagicMock()
|
||||
indicator.config_manager.get_bool.return_value = False
|
||||
indicator._external_activation_unavailable = False
|
||||
indicator._setup_keyboard_shortcuts = MagicMock()
|
||||
indicator._input_monitor = None
|
||||
indicator._settle_timer_id = None
|
||||
@@ -343,6 +346,19 @@ class TestTrayIndicatorResumeFlow(unittest.TestCase):
|
||||
assert calls[1][0][0] == 2
|
||||
assert calls[1][0][1] == indicator._start_input_device_monitor
|
||||
|
||||
@patch("vocalinux.ui.tray_indicator.GLib")
|
||||
def test_on_system_resume_skips_monitor_for_external_activation(self, mock_glib):
|
||||
"""External activation promises no /dev/input access; a resume must
|
||||
not open the device monitor that (re)starts the internal listener."""
|
||||
indicator = self._make_tray_indicator()
|
||||
indicator.config_manager.get_bool.return_value = True
|
||||
|
||||
indicator._on_system_resume()
|
||||
|
||||
calls = mock_glib.timeout_add_seconds.call_args_list
|
||||
assert len(calls) == 1
|
||||
assert calls[0][0][1] == indicator._reinit_speech_after_resume
|
||||
|
||||
@patch("vocalinux.ui.tray_indicator.Gio")
|
||||
@patch("vocalinux.ui.tray_indicator.GLib")
|
||||
def test_start_input_device_monitor_sets_up_gio_monitor(self, mock_glib, mock_gio):
|
||||
@@ -497,6 +513,7 @@ class TestTrayAutoPauseWiring(unittest.TestCase):
|
||||
indicator.speech_engine.reinitialize_after_resume = MagicMock()
|
||||
indicator.speech_engine.is_auto_paused = False
|
||||
indicator.config_manager = MagicMock()
|
||||
indicator._external_activation_unavailable = False
|
||||
indicator._setup_keyboard_shortcuts = MagicMock()
|
||||
indicator._input_monitor = None
|
||||
indicator._settle_timer_id = None
|
||||
@@ -565,6 +582,7 @@ class TestTrayAutoPauseWiring(unittest.TestCase):
|
||||
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.config_manager.get_bool.return_value = False
|
||||
indicator._start_input_device_monitor = MagicMock()
|
||||
|
||||
indicator._on_system_resume()
|
||||
@@ -578,6 +596,7 @@ class TestTrayAutoPauseWiring(unittest.TestCase):
|
||||
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.config_manager.get_bool.return_value = False
|
||||
indicator._reinit_speech_after_resume = MagicMock()
|
||||
indicator._start_input_device_monitor = MagicMock()
|
||||
|
||||
|
||||
@@ -513,5 +513,207 @@ class TestTrayIndicatorSignalHandling(unittest.TestCase):
|
||||
assert isinstance(result, bool)
|
||||
|
||||
|
||||
class TestExternalActivationGate(unittest.TestCase):
|
||||
"""Tests for the D-Bus external-activation config gate.
|
||||
|
||||
These live here (and not in test_dbus_activation.py) so that the real
|
||||
tray_indicator module is imported late in the session -- after the tests
|
||||
that swap sys.modules["gi.repository"] at runtime. Importing it earlier
|
||||
would freeze tray_indicator.GLib against a stale mock and break unrelated
|
||||
GLib-identity assertions (e.g. in test_suspend_handler.py).
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _fake_indicator(active: bool) -> MagicMock:
|
||||
"""Minimal stand-in for calling _setup_keyboard_shortcuts in isolation.
|
||||
|
||||
``active`` stubs _external_activation_active() directly rather than
|
||||
the config it normally derives from -- that derivation (including the
|
||||
once-failed-stays-off persistence) has its own tests below.
|
||||
"""
|
||||
fake = MagicMock()
|
||||
fake.shortcut_manager.active = False
|
||||
fake.config_manager.get_str.return_value = "toggle"
|
||||
fake._external_activation_active.return_value = active
|
||||
return fake
|
||||
|
||||
def test_external_mode_skips_listener_start(self):
|
||||
"""With the option enabled, the evdev/pynput listener is not started."""
|
||||
from vocalinux.ui.tray_indicator import TrayIndicator
|
||||
|
||||
fake = self._fake_indicator(active=True)
|
||||
TrayIndicator._setup_keyboard_shortcuts(fake)
|
||||
fake.shortcut_manager.start.assert_not_called()
|
||||
fake.shortcut_manager.register_toggle_callback.assert_called_once_with(None)
|
||||
|
||||
def test_default_mode_starts_listener(self):
|
||||
"""With the option disabled (default), the listener starts as before."""
|
||||
from vocalinux.ui.tray_indicator import TrayIndicator
|
||||
|
||||
fake = self._fake_indicator(active=False)
|
||||
TrayIndicator._setup_keyboard_shortcuts(fake)
|
||||
fake.shortcut_manager.start.assert_called_once()
|
||||
|
||||
def test_reconfigure_stops_active_recognition_first(self):
|
||||
"""An active push-to-talk session is stopped before the listener that
|
||||
would have delivered its release callback is torn down, so it cannot
|
||||
be left running with no way back to idle."""
|
||||
from vocalinux.common_types import RecognitionState
|
||||
from vocalinux.ui.tray_indicator import TrayIndicator
|
||||
|
||||
fake = self._fake_indicator(active=True)
|
||||
fake.speech_engine.state = RecognitionState.LISTENING
|
||||
TrayIndicator._setup_keyboard_shortcuts(fake)
|
||||
fake._stop_recognition.assert_called_once_with()
|
||||
|
||||
def test_reconfigure_leaves_idle_recognition_alone(self):
|
||||
"""No spurious stop is issued when nothing is active."""
|
||||
from vocalinux.common_types import RecognitionState
|
||||
from vocalinux.ui.tray_indicator import TrayIndicator
|
||||
|
||||
fake = self._fake_indicator(active=False)
|
||||
fake.speech_engine.state = RecognitionState.IDLE
|
||||
TrayIndicator._setup_keyboard_shortcuts(fake)
|
||||
fake._stop_recognition.assert_not_called()
|
||||
|
||||
|
||||
class TestExternalActivationActiveGate(unittest.TestCase):
|
||||
"""Tests for _external_activation_active(): the saved setting, unless the
|
||||
D-Bus service has already failed to register this run."""
|
||||
|
||||
@staticmethod
|
||||
def _fake_indicator(disable_internal_hotkey: bool, unavailable: bool) -> MagicMock:
|
||||
fake = MagicMock()
|
||||
fake.config_manager.get_bool.return_value = disable_internal_hotkey
|
||||
fake._external_activation_unavailable = unavailable
|
||||
return fake
|
||||
|
||||
def test_true_when_configured_and_available(self):
|
||||
from vocalinux.ui.tray_indicator import TrayIndicator
|
||||
|
||||
fake = self._fake_indicator(disable_internal_hotkey=True, unavailable=False)
|
||||
assert TrayIndicator._external_activation_active(fake) is True
|
||||
|
||||
def test_false_when_not_configured(self):
|
||||
from vocalinux.ui.tray_indicator import TrayIndicator
|
||||
|
||||
fake = self._fake_indicator(disable_internal_hotkey=False, unavailable=False)
|
||||
assert TrayIndicator._external_activation_active(fake) is False
|
||||
|
||||
def test_false_once_unavailable_even_if_still_configured(self):
|
||||
"""Once the service has failed, the saved setting is overridden for
|
||||
the rest of the process -- it does not retry."""
|
||||
from vocalinux.ui.tray_indicator import TrayIndicator
|
||||
|
||||
fake = self._fake_indicator(disable_internal_hotkey=True, unavailable=True)
|
||||
assert TrayIndicator._external_activation_active(fake) is False
|
||||
|
||||
|
||||
class TestDBusRegistrationFailedFallback(unittest.TestCase):
|
||||
"""Tests for falling back to the internal listener when the D-Bus service
|
||||
could not register, so the user is never left with no activation path."""
|
||||
|
||||
@staticmethod
|
||||
def _fake_indicator(disable_internal_hotkey: bool) -> MagicMock:
|
||||
fake = MagicMock()
|
||||
fake.config_manager.get_bool.return_value = disable_internal_hotkey
|
||||
return fake
|
||||
|
||||
@patch("vocalinux.ui.tray_indicator.notifications")
|
||||
def test_falls_back_when_external_activation_configured(self, mock_notifications):
|
||||
from vocalinux.ui.tray_indicator import TrayIndicator
|
||||
|
||||
fake = self._fake_indicator(disable_internal_hotkey=True)
|
||||
TrayIndicator._on_dbus_registration_failed(fake)
|
||||
assert fake._external_activation_unavailable is True
|
||||
fake._setup_keyboard_shortcuts.assert_called_once_with()
|
||||
mock_notifications.notify.assert_called_once()
|
||||
|
||||
@patch("vocalinux.ui.tray_indicator.notifications")
|
||||
def test_fallback_persists_across_a_later_reconfigure(self, mock_notifications):
|
||||
"""The exact regression this fixes: a reconfigure after the fallback
|
||||
(mode change, settings toggle, resume) must not re-disable the only
|
||||
working activation path just because the saved setting still asks
|
||||
for external activation."""
|
||||
from vocalinux.ui.tray_indicator import TrayIndicator
|
||||
|
||||
with patch.object(TrayIndicator, "__init__", lambda self: None):
|
||||
indicator = TrayIndicator.__new__(TrayIndicator)
|
||||
indicator._external_activation_unavailable = False
|
||||
indicator.config_manager = MagicMock()
|
||||
indicator.config_manager.get_bool.return_value = True
|
||||
indicator.config_manager.get_str.return_value = "toggle"
|
||||
indicator.speech_engine = MagicMock()
|
||||
indicator.shortcut_manager = MagicMock()
|
||||
indicator.shortcut_manager.active = False
|
||||
|
||||
indicator._on_dbus_registration_failed()
|
||||
indicator.shortcut_manager.start.assert_called_once()
|
||||
|
||||
# A later reconfigure -- e.g. the mode combo changing in Settings --
|
||||
# calls this with no knowledge of the earlier failure.
|
||||
indicator.shortcut_manager.reset_mock()
|
||||
indicator._setup_keyboard_shortcuts()
|
||||
indicator.shortcut_manager.start.assert_called_once()
|
||||
|
||||
def test_noop_when_internal_listener_already_active(self):
|
||||
"""Nothing to fall back to/from if the internal listener was already
|
||||
the active mode."""
|
||||
from vocalinux.ui.tray_indicator import TrayIndicator
|
||||
|
||||
fake = self._fake_indicator(disable_internal_hotkey=False)
|
||||
TrayIndicator._on_dbus_registration_failed(fake)
|
||||
fake._setup_keyboard_shortcuts.assert_not_called()
|
||||
|
||||
|
||||
class TestExternalTriggerHandlers(unittest.TestCase):
|
||||
"""Tests for the D-Bus external Start/Stop handlers on TrayIndicator.
|
||||
|
||||
These use normal (non push-to-talk) start semantics and guard on the
|
||||
current recognition state, so repeated `vocalinux --start`/`--stop` calls
|
||||
are idempotent.
|
||||
"""
|
||||
|
||||
def test_external_start_starts_when_idle(self):
|
||||
"""_external_start starts recognition only when currently idle."""
|
||||
from vocalinux.common_types import RecognitionState
|
||||
from vocalinux.ui.tray_indicator import TrayIndicator
|
||||
|
||||
fake = MagicMock()
|
||||
fake.speech_engine.state = RecognitionState.IDLE
|
||||
TrayIndicator._external_start(fake)
|
||||
fake.speech_engine.start_recognition.assert_called_once_with()
|
||||
|
||||
def test_external_start_noop_when_already_running(self):
|
||||
"""_external_start does nothing if recognition is already active."""
|
||||
from vocalinux.common_types import RecognitionState
|
||||
from vocalinux.ui.tray_indicator import TrayIndicator
|
||||
|
||||
fake = MagicMock()
|
||||
fake.speech_engine.state = RecognitionState.LISTENING
|
||||
TrayIndicator._external_start(fake)
|
||||
fake.speech_engine.start_recognition.assert_not_called()
|
||||
|
||||
def test_external_stop_stops_when_active(self):
|
||||
"""_external_stop stops recognition when it is not idle."""
|
||||
from vocalinux.common_types import RecognitionState
|
||||
from vocalinux.ui.tray_indicator import TrayIndicator
|
||||
|
||||
fake = MagicMock()
|
||||
fake.speech_engine.state = RecognitionState.LISTENING
|
||||
TrayIndicator._external_stop(fake)
|
||||
fake.speech_engine.stop_recognition.assert_called_once_with()
|
||||
|
||||
def test_external_stop_noop_when_idle(self):
|
||||
"""_external_stop does nothing if recognition is already idle."""
|
||||
from vocalinux.common_types import RecognitionState
|
||||
from vocalinux.ui.tray_indicator import TrayIndicator
|
||||
|
||||
fake = MagicMock()
|
||||
fake.speech_engine.state = RecognitionState.IDLE
|
||||
TrayIndicator._external_stop(fake)
|
||||
fake.speech_engine.stop_recognition.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user