"""Tests for configurable modifier+key hotkeys (e.g. Alt+R). Covers the generalized parser/validator in base.py plus combo detection in the evdev and pynput backends. Legacy pure-modifier gestures must keep working. """ import threading import types import pytest from vocalinux.ui.keyboard_backends.base import ( ShortcutSpec, format_shortcut_label, get_shortcut_display_name, is_valid_shortcut, parse_shortcut, parse_shortcut_spec, ) class TestParseShortcutSpec: def test_legacy_pure_modifier(self): spec = parse_shortcut_spec("ctrl+ctrl") assert spec.modifiers == ("ctrl",) assert spec.key is None assert spec.is_combo is False assert spec.primary_modifier == "ctrl" def test_legacy_side_specific(self): spec = parse_shortcut_spec("left_shift+left_shift") assert spec.modifiers == ("left_shift",) assert spec.is_combo is False def test_simple_combo(self): spec = parse_shortcut_spec("alt+r") assert spec.modifiers == ("alt",) assert spec.key == "r" assert spec.is_combo is True def test_multi_modifier_combo(self): spec = parse_shortcut_spec("ctrl+alt+r") assert spec.modifiers == ("ctrl", "alt") assert spec.key == "r" def test_function_key_combo(self): assert parse_shortcut_spec("alt+f5").key == "f5" def test_named_key_combo(self): assert parse_shortcut_spec("super+space").key == "space" def test_case_insensitive_and_whitespace(self): spec = parse_shortcut_spec(" ALT + R ") assert spec.modifiers == ("alt",) assert spec.key == "r" @pytest.mark.parametrize( "bad", ["", " ", "ctrl", "ctrl+alt", "alt+r+t", "invalid_shortcut", "alt+", "+r"], ) def test_invalid(self, bad): with pytest.raises(ValueError): parse_shortcut_spec(bad) def test_canonical_round_trips(self): for s in ["ctrl+ctrl", "alt+r", "ctrl+alt+r", "super+space", "alt+f5"]: assert parse_shortcut_spec(parse_shortcut_spec(s).canonical()).canonical() == ( parse_shortcut_spec(s).canonical() ) class TestBackwardCompatibility: def test_parse_shortcut_legacy_unchanged(self): assert parse_shortcut("ctrl+ctrl") == "ctrl" assert parse_shortcut("right_alt+right_alt") == "right_alt" def test_parse_shortcut_combo_returns_primary_modifier(self): assert parse_shortcut("alt+r") == "alt" assert parse_shortcut("ctrl+alt+r") == "ctrl" def test_parse_shortcut_invalid_raises_with_message(self): with pytest.raises(ValueError) as e: parse_shortcut("bogus") assert "Unsupported shortcut" in str(e.value) def test_parse_shortcut_bare_modifier_still_invalid(self): with pytest.raises(ValueError): parse_shortcut("ctrl") def test_legacy_display_names_unchanged(self): assert get_shortcut_display_name("ctrl+ctrl") == "Ctrl (either side)" assert get_shortcut_display_name("left_shift+left_shift") == "Left Shift" assert get_shortcut_display_name("alt+alt", "toggle") == "Double-tap Alt" assert get_shortcut_display_name("alt+alt", "push_to_talk") == "Hold Alt" class TestValidationAndLabels: def test_is_valid_shortcut(self): assert is_valid_shortcut("alt+r") is True assert is_valid_shortcut("ctrl+ctrl") is True assert is_valid_shortcut("ctrl") is False assert is_valid_shortcut("") is False assert is_valid_shortcut("nope") is False def test_combo_labels(self): assert format_shortcut_label(parse_shortcut_spec("alt+r")) == "Alt+R" assert format_shortcut_label(parse_shortcut_spec("ctrl+alt+r")) == "Ctrl+Alt+R" assert format_shortcut_label(parse_shortcut_spec("super+space")) == "Super+Space" def test_combo_display_names_by_mode(self): assert get_shortcut_display_name("alt+r", "toggle") == "Press Alt+R" assert get_shortcut_display_name("alt+r", "push_to_talk") == "Hold Alt+R" # -------------------------------------------------------------------------- # evdev combo detection # -------------------------------------------------------------------------- evdev_backend = pytest.importorskip("vocalinux.ui.keyboard_backends.evdev_backend") @pytest.mark.skipif(not evdev_backend.EVDEV_AVAILABLE, reason="evdev not available") class TestEvdevComboDetection: def _event(self, code, value): return types.SimpleNamespace(code=code, value=value, type=1) def _wait(self, flag, timeout=1.0): return flag.wait(timeout) def _codes(self): from vocalinux.ui.keyboard_backends.evdev_backend import ( KEY_LEFTALT, KEY_RIGHTALT, evdev_code_for_key, ) return KEY_LEFTALT, KEY_RIGHTALT, evdev_code_for_key("r") def test_toggle_fires_on_combo_press(self): alt_l, _, key_r = self._codes() backend = evdev_backend.EvdevKeyboardBackend(shortcut="alt+r", mode="toggle") fired = threading.Event() backend.register_toggle_callback(fired.set) # Alt down, then R down -> toggle fires. backend._handle_key_event(self._event(alt_l, 1), None) backend._handle_key_event(self._event(key_r, 1), None) assert self._wait(fired) def test_toggle_does_not_fire_without_modifier(self): _, _, key_r = self._codes() backend = evdev_backend.EvdevKeyboardBackend(shortcut="alt+r", mode="toggle") fired = threading.Event() backend.register_toggle_callback(fired.set) # R alone (no Alt held) must not trigger. backend._handle_key_event(self._event(key_r, 1), None) assert not self._wait(fired, timeout=0.2) def test_push_to_talk_press_and_release(self): alt_l, _, key_r = self._codes() backend = evdev_backend.EvdevKeyboardBackend(shortcut="alt+r", mode="push_to_talk") pressed = threading.Event() released = threading.Event() backend.register_press_callback(pressed.set) backend.register_release_callback(released.set) backend._handle_key_event(self._event(alt_l, 1), None) backend._handle_key_event(self._event(key_r, 1), None) assert self._wait(pressed) backend._handle_key_event(self._event(key_r, 0), None) assert self._wait(released) def test_ptt_release_when_modifier_released_first(self): alt_l, _, key_r = self._codes() backend = evdev_backend.EvdevKeyboardBackend(shortcut="alt+r", mode="push_to_talk") pressed = threading.Event() released = threading.Event() backend.register_press_callback(pressed.set) backend.register_release_callback(released.set) backend._handle_key_event(self._event(alt_l, 1), None) backend._handle_key_event(self._event(key_r, 1), None) assert self._wait(pressed) # Release Alt while R still held -> hold ends. backend._handle_key_event(self._event(alt_l, 0), None) assert self._wait(released) def test_split_keyboard_either_alt_variant(self): _, alt_r, key_r = self._codes() backend = evdev_backend.EvdevKeyboardBackend(shortcut="alt+r", mode="toggle") fired = threading.Event() backend.register_toggle_callback(fired.set) # Right Alt (other keyboard half) + R still satisfies "alt". backend._handle_key_event(self._event(alt_r, 1), None) backend._handle_key_event(self._event(key_r, 1), None) assert self._wait(fired) def test_legacy_double_tap_still_works(self): from vocalinux.ui.keyboard_backends.evdev_backend import KEY_LEFTCTRL backend = evdev_backend.EvdevKeyboardBackend(shortcut="ctrl+ctrl", mode="toggle") fired = threading.Event() backend.register_toggle_callback(fired.set) backend._handle_key_event(self._event(KEY_LEFTCTRL, 1), None) backend._handle_key_event(self._event(KEY_LEFTCTRL, 0), None) backend._handle_key_event(self._event(KEY_LEFTCTRL, 1), None) assert self._wait(fired) # -------------------------------------------------------------------------- # pynput combo detection # -------------------------------------------------------------------------- pynput_backend = pytest.importorskip("vocalinux.ui.keyboard_backends.pynput_backend") @pytest.mark.skipif(not pynput_backend.PYNPUT_AVAILABLE, reason="pynput not available") class TestPynputComboDetection: def _kb(self): """The keyboard module the backend actually imported. Using the backend's own reference (rather than a fresh ``from pynput import keyboard``) keeps us consistent with the module-level ``ALL_MODIFIER_KEYS`` set. If another test has replaced ``pynput`` with a mock in ``sys.modules`` and left it there, ``KeyCode.from_char`` yields a mock; detect that and skip rather than assert against a broken stub. """ kb = pynput_backend.keyboard try: if kb.KeyCode.from_char("r").char != "r": pytest.skip("pynput is mocked by another test module") except Exception: pytest.skip("pynput is mocked by another test module") return kb def _wait(self, flag, timeout=1.0): return flag.wait(timeout) def test_toggle_fires_on_combo(self): kb = self._kb() backend = pynput_backend.PynputKeyboardBackend(shortcut="alt+r", mode="toggle") fired = threading.Event() backend.register_toggle_callback(fired.set) backend._on_press(kb.Key.alt_l) backend._on_press(kb.KeyCode.from_char("r")) assert self._wait(fired) def test_no_fire_without_modifier(self): kb = self._kb() backend = pynput_backend.PynputKeyboardBackend(shortcut="alt+r", mode="toggle") fired = threading.Event() backend.register_toggle_callback(fired.set) backend._on_press(kb.KeyCode.from_char("r")) assert not self._wait(fired, timeout=0.2) def test_push_to_talk_press_release(self): kb = self._kb() backend = pynput_backend.PynputKeyboardBackend(shortcut="alt+r", mode="push_to_talk") pressed = threading.Event() released = threading.Event() backend.register_press_callback(pressed.set) backend.register_release_callback(released.set) backend._on_press(kb.Key.alt_l) backend._on_press(kb.KeyCode.from_char("r")) assert self._wait(pressed) backend._on_release(kb.KeyCode.from_char("r")) assert self._wait(released) def test_ctrl_letter_control_char_matches(self): kb = self._kb() # Ctrl+R may arrive as control char \x12; token resolver must map it back. backend = pynput_backend.PynputKeyboardBackend(shortcut="ctrl+r", mode="toggle") fired = threading.Event() backend.register_toggle_callback(fired.set) backend._on_press(kb.Key.ctrl_l) backend._on_press(kb.KeyCode.from_char("\x12")) assert self._wait(fired) @pytest.mark.skipif(not evdev_backend.EVDEV_AVAILABLE, reason="evdev not available") class TestEvdevComboRecovery: """Combo state must be reconciled after lost events (SYN_DROPPED / disconnect).""" def _event(self, code, value): return types.SimpleNamespace(code=code, value=value, type=1) def _codes(self): from vocalinux.ui.keyboard_backends.evdev_backend import ( KEY_LEFTALT, evdev_code_for_key, ) return KEY_LEFTALT, evdev_code_for_key("r") def test_dropped_modifier_release_does_not_false_trigger(self): alt_l, key_r = self._codes() b = evdev_backend.EvdevKeyboardBackend(shortcut="alt+r", mode="toggle") fired = threading.Event() b.register_toggle_callback(fired.set) # Alt goes down; its release is then LOST (SYN_DROPPED). Recovery must # clear the stale "Alt held" state so R alone can't toggle. b._handle_key_event(self._event(alt_l, 1), None) assert alt_l in b._combo_pressed b._reset_combo_state() assert b._combo_pressed == set() b._handle_key_event(self._event(key_r, 1), None) assert not fired.wait(0.2) def test_reset_ends_stuck_push_to_talk_hold(self): alt_l, key_r = self._codes() b = evdev_backend.EvdevKeyboardBackend(shortcut="alt+r", mode="push_to_talk") pressed = threading.Event() released = threading.Event() b.register_press_callback(pressed.set) b.register_release_callback(released.set) b._handle_key_event(self._event(alt_l, 1), None) b._handle_key_event(self._event(key_r, 1), None) assert pressed.wait(1.0) assert b._combo_active b._reset_combo_state() # e.g. a device disconnects mid-hold assert released.wait(1.0) assert not b._combo_active def test_device_disconnect_resets_combo_state(self): alt_l, _ = self._codes() b = evdev_backend.EvdevKeyboardBackend(shortcut="alt+r", mode="toggle") b._handle_key_event(self._event(alt_l, 1), None) assert alt_l in b._combo_pressed fake_device = types.SimpleNamespace(close=lambda: None, name="fake") b._remove_keyboard_device(1234, fake_device) assert b._combo_pressed == set()