Files
voculinux/tests/test_settings_shortcuts.py
T
xavierk 80a3792576
CI / security (pull_request) Failing after 1m25s
CI / lint (pull_request) Failing after 34s
CI / ai-review (pull_request) Successful in 25s
chore: import vocalinux project
Import the vocalinux source tree (tray app, Next.js site in web/,
packaging, installers, docs and tests) on top of the Gitea template
commit. The project README replaces the template README, and the
template CI files (.gitea/workflows/ci.yml, .jscpd.json, CI.md) are kept.
2026-11-06 11:51:44 +05:30

392 lines
17 KiB
Python

"""
Tests for keyboard shortcuts section in settings dialog.
Uses source code inspection to avoid GTK mocking issues.
"""
import ast
import os
import unittest
from collections.abc import Callable
from types import SimpleNamespace
from unittest.mock import Mock
def _get_source_code() -> str:
"""Read the settings dialog source file."""
source_path = os.path.join(
os.path.dirname(__file__),
"..",
"src",
"vocalinux",
"ui",
"settings_dialog.py",
)
with open(source_path, "r") as f:
return f.read()
class TestSettingsDialogShortcutsSection(unittest.TestCase):
"""Test cases for keyboard shortcuts section in settings dialog."""
def setUp(self):
"""Set up test fixtures."""
self.source_code = _get_source_code()
def test_shortcuts_section_exists(self):
"""Test that shortcuts section build method exists."""
self.assertIn("def _build_shortcuts_section(self)", self.source_code)
def test_shortcuts_section_called_in_init(self):
"""Test that shortcuts section is built in __init__."""
self.assertIn("self._build_shortcuts_section()", self.source_code)
def _pass_through_handler(self) -> Callable[..., bool]:
"""Compile the real method without importing the whole GTK UI."""
cls = next(
x
for x in ast.parse(self.source_code).body
if isinstance(x, ast.ClassDef) and x.name == "SettingsDialog"
)
method = next(
x
for x in cls.body
if isinstance(x, ast.FunctionDef) and x.name == "_on_pass_shortcut_to_apps_toggled"
)
namespace = {"Gtk": SimpleNamespace(Switch=object)}
exec(
compile(ast.Module(body=[method], type_ignores=[]), "settings.py", "exec"),
namespace,
)
return namespace[method.name]
def test_pass_through_signal_is_connected(self) -> None:
self.assertIn(
"self.pass_shortcut_to_apps_switch.connect(\n"
' "state-set", self._on_pass_shortcut_to_apps_toggled\n'
" )",
self.source_code,
)
def test_pass_through_handler_persists_and_calls_live_callback(self) -> None:
handler = self._pass_through_handler()
for enabled in (True, False):
with self.subTest(enabled=enabled):
config, callback = Mock(), Mock()
dialog = SimpleNamespace(
_initializing=False,
_applying_settings=False,
config_manager=config,
hotkey_passthrough_update_callback=callback,
)
self.assertIs(handler(dialog, None, enabled), False)
config.set.assert_called_once_with("shortcuts", "pass_shortcut_to_apps", enabled)
config.save_settings.assert_called_once_with()
callback.assert_called_once_with(enabled)
def test_pass_through_handler_respects_init_and_apply_guards(self) -> None:
handler = self._pass_through_handler()
for initializing, applying in ((True, False), (False, True)):
with self.subTest(initializing=initializing, applying=applying):
config, callback = Mock(), Mock()
dialog = SimpleNamespace(
_initializing=initializing,
_applying_settings=applying,
config_manager=config,
hotkey_passthrough_update_callback=callback,
)
self.assertIs(handler(dialog, None, True), False)
config.set.assert_not_called()
config.save_settings.assert_not_called()
callback.assert_not_called()
def test_shortcut_combo_widget_created(self):
"""Test that shortcut combo box widget is created."""
self.assertIn("self.shortcut_combo = Gtk.ComboBoxText()", self.source_code)
def test_shortcut_combo_size_request(self):
"""Test that shortcut combo uses the shared control width."""
self.assertIn("_style_combo(self.shortcut_combo)", self.source_code)
def test_shortcut_combo_tooltip(self):
"""Test that shortcut combo has a tooltip."""
self.assertIn("self.shortcut_combo.set_tooltip_text(", self.source_code)
def test_shortcut_options_populated(self):
"""Test that shortcut options are populated from SHORTCUT_DISPLAY_NAMES."""
self.assertIn("for group_label, shortcut_ids in SHORTCUT_GROUPS.items()", self.source_code)
self.assertIn(
'self.shortcut_combo.append(separator_id, f"── {group_label} ──")', self.source_code
)
self.assertIn("for shortcut_id in shortcut_ids", self.source_code)
def test_shortcut_config_read(self):
"""Test that shortcut is read from config."""
self.assertIn('"shortcuts", "toggle_recognition"', self.source_code)
def test_shortcut_changed_handler_exists(self):
"""Test that shortcut changed handler exists."""
self.assertIn("def _on_shortcut_changed(self, widget)", self.source_code)
def test_shortcut_change_saved_to_config(self):
"""Test that shortcut change is saved to config."""
self.assertIn(
'self.config_manager.set("shortcuts", "toggle_recognition", shortcut_id)',
self.source_code,
)
def test_shortcut_change_triggers_save(self):
"""Test that shortcut change triggers config save."""
# After setting the shortcut, save_settings should be called
self.assertIn("self.config_manager.save_settings()", self.source_code)
def test_shortcut_preference_row_title(self):
"""Test that preference row has correct title."""
self.assertIn('title="Shortcut Key"', self.source_code)
def test_shortcut_preference_row_subtitle(self):
"""Test that preference row has descriptive subtitle."""
# The subtitle is now dynamic based on mode (toggle or push-to-talk)
self.assertIn("set_subtitle", self.source_code)
"""Test that preference row has correct title."""
self.assertIn('title="Shortcut Key"', self.source_code)
"""Test that preference row has correct title."""
self.assertIn('title="Toggle Recognition"', self.source_code)
def test_shortcut_preference_row_subtitle(self):
"""Test that preference row has descriptive subtitle."""
# The subtitle is now dynamic based on mode (toggle or push-to-talk)
self.assertIn("set_subtitle", self.source_code)
def test_shortcut_info_label_exists(self):
"""Test that info label exists for user guidance."""
self.assertIn("self.shortcut_info_label", self.source_code)
def test_shortcut_info_box_styling(self):
"""Test that info box has proper styling."""
self.assertIn('get_style_context().add_class("info-box")', self.source_code)
def test_shortcut_group_title(self):
"""Test that shortcuts group has proper title."""
self.assertIn('title="Keyboard Shortcuts"', self.source_code)
def test_imports_shortcut_constants(self):
"""Test that shortcut constants are imported."""
# Check for imports (may be multi-line)
self.assertIn("from .keyboard_backends import", self.source_code)
self.assertIn("SHORTCUT_DISPLAY_NAMES", self.source_code)
self.assertIn("SHORTCUT_GROUPS", self.source_code)
self.assertIn("SUPPORTED_SHORTCUTS", self.source_code)
def test_custom_shortcut_option_in_combo(self):
"""Preset combo includes a Custom Shortcut sentinel for non-presets."""
self.assertIn(
'self.shortcut_combo.append("__custom__", "Custom Shortcut")', self.source_code
)
self.assertIn("def _sync_shortcut_selection_ui(self, shortcut: str)", self.source_code)
def test_preset_selection_clears_custom_entry(self):
"""Selecting a preset clears leftover custom text so active binding is obvious."""
self.assertIn('self.custom_shortcut_entry.set_text("")', self.source_code)
# Preset path in _on_shortcut_changed must clear before/while saving.
self.assertIn("def _on_shortcut_changed(self, widget)", self.source_code)
def test_custom_apply_syncs_combo_to_custom(self):
"""Applying a custom shortcut selects Custom in the dropdown."""
self.assertIn("def _apply_custom_shortcut(self, shortcut: str)", self.source_code)
self.assertIn("self._sync_shortcut_selection_ui(shortcut)", self.source_code)
def test_custom_shortcut_row_uses_visible_helper(self):
"""Custom row show path must clear no_show_all before show_all.
Gtk.Widget.show_all() is a no-op while no_show_all is True, which hid
the Record/Set controls after the searchable-settings refactor.
"""
self.assertIn("def _set_custom_shortcut_row_visible(self, visible: bool)", self.source_code)
self.assertIn("self._set_custom_shortcut_row_visible(True)", self.source_code)
self.assertIn("self._set_custom_shortcut_row_visible(False)", self.source_code)
helper_start = self.source_code.index("def _set_custom_shortcut_row_visible")
helper_end = self.source_code.index("\n def ", helper_start + 1)
helper = self.source_code[helper_start:helper_end]
self.assertIn("_set_no_show_all_visible(self.custom_shortcut_row, visible)", helper)
# The shared helper itself must do the clear-then-show dance.
shared_start = self.source_code.index("def _set_no_show_all_visible")
shared_end = self.source_code.index("\ndef ", shared_start + 1)
shared = self.source_code[shared_start:shared_end]
self.assertIn("set_no_show_all(False)", shared)
self.assertIn("show_all()", shared)
# Call sites must go through the helper, not bare show_all on the row.
outside = self.source_code[:helper_start] + self.source_code[helper_end:]
self.assertNotIn("self.custom_shortcut_row.show_all()", outside)
self.assertNotIn("self.custom_shortcut_row.hide()", outside)
def test_separator_revert_syncs_custom_row_visibility(self):
"""Clicking a combo separator must restore custom-row visibility too.
Otherwise selecting Custom Shortcut then a group separator leaves
Record/Set visible while the combo shows a preset again.
"""
start = self.source_code.index("def _revert_shortcut_combo_to_saved")
end = self.source_code.index("\n def ", start + 1)
body = self.source_code[start:end]
self.assertIn("self._sync_shortcut_selection_ui(current)", body)
# Also restore mode info text so the Record/Set hint does not linger.
self.assertIn("self._update_shortcut_ui_for_mode(mode_id)", body)
class TestKeyboardBackendsBase(unittest.TestCase):
"""Test cases for keyboard backends base module."""
def test_supported_shortcuts_defined(self):
"""Test that SUPPORTED_SHORTCUTS is defined."""
from vocalinux.ui.keyboard_backends.base import SUPPORTED_SHORTCUTS
self.assertIsInstance(SUPPORTED_SHORTCUTS, dict)
self.assertIn("ctrl+ctrl", SUPPORTED_SHORTCUTS)
self.assertIn("alt+alt", SUPPORTED_SHORTCUTS)
self.assertIn("shift+shift", SUPPORTED_SHORTCUTS)
def test_shortcut_display_names_defined(self):
"""Test that SHORTCUT_DISPLAY_NAMES is defined."""
from vocalinux.ui.keyboard_backends.base import SHORTCUT_DISPLAY_NAMES
self.assertIsInstance(SHORTCUT_DISPLAY_NAMES, dict)
self.assertEqual(
set(SHORTCUT_DISPLAY_NAMES.keys()),
{
"ctrl+ctrl",
"alt+alt",
"shift+shift",
"left_ctrl+left_ctrl",
"left_alt+left_alt",
"left_shift+left_shift",
"right_ctrl+right_ctrl",
"right_alt+right_alt",
"right_shift+right_shift",
},
)
def test_default_shortcut_defined(self):
"""Test that DEFAULT_SHORTCUT is defined."""
from vocalinux.ui.keyboard_backends.base import DEFAULT_SHORTCUT
self.assertEqual(DEFAULT_SHORTCUT, "right_alt+right_alt")
def test_keyboard_backend_shortcut_property(self):
"""Test that KeyboardBackend has shortcut property."""
from vocalinux.ui.keyboard_backends.base import KeyboardBackend
# Can't instantiate abstract class, but can check the property exists
self.assertTrue(hasattr(KeyboardBackend, "shortcut"))
self.assertTrue(hasattr(KeyboardBackend, "modifier_key"))
self.assertTrue(hasattr(KeyboardBackend, "set_shortcut"))
def test_shortcut_groups_defined(self):
"""Test that SHORTCUT_GROUPS is defined with 3 groups and correct keys."""
from vocalinux.ui.keyboard_backends.base import SHORTCUT_GROUPS
self.assertIsInstance(SHORTCUT_GROUPS, dict)
self.assertIn("Either Side", SHORTCUT_GROUPS)
self.assertIn("Left Side", SHORTCUT_GROUPS)
self.assertIn("Right Side", SHORTCUT_GROUPS)
self.assertEqual(len(SHORTCUT_GROUPS.keys()), 3)
self.assertEqual(list(SHORTCUT_GROUPS.keys()), ["Either Side", "Left Side", "Right Side"])
self.assertEqual(len(SHORTCUT_GROUPS["Either Side"]), 3)
self.assertEqual(len(SHORTCUT_GROUPS["Left Side"]), 3)
self.assertEqual(len(SHORTCUT_GROUPS["Right Side"]), 3)
class TestConfigManagerShortcuts(unittest.TestCase):
"""Test cases for shortcut configuration in config manager."""
def test_shortcuts_section_in_default_config(self):
"""Test that shortcuts section exists in DEFAULT_CONFIG."""
from vocalinux.ui.config_manager import DEFAULT_CONFIG
self.assertIn("shortcuts", DEFAULT_CONFIG)
self.assertIn("toggle_recognition", DEFAULT_CONFIG["shortcuts"])
self.assertEqual(DEFAULT_CONFIG["shortcuts"]["toggle_recognition"], "right_alt+right_alt")
self.assertEqual(DEFAULT_CONFIG["shortcuts"]["mode"], "push_to_talk")
def test_config_manager_get_shortcut(self):
"""Test getting shortcut from config manager."""
from unittest.mock import patch
with patch("vocalinux.ui.config_manager.os.path.exists", return_value=False):
from vocalinux.ui.config_manager import ConfigManager
config = ConfigManager()
shortcut = config.get("shortcuts", "toggle_recognition", "right_alt+right_alt")
self.assertEqual(shortcut, "right_alt+right_alt")
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 (\n"
" self.mode_row,\n"
" self.shortcut_row,\n"
" self.custom_shortcut_row,\n"
" self.pass_shortcut_to_apps_row,\n"
" ):"
),
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()