Files
voculinux/tests/test_shortcut_capture.py
Jatin K MalikandGitHub 65de3427fb perf: faster Settings, typing and dictation; mypy in CI, engine interface, architecture docs (#968)
* fix(perf): reduce startup and inference overhead

* fix(perf): report effective whisper.cpp fallback settings

* fix(injection): add opt-in X11 paste for long dictations

* perf(vad): lower default silence timeout to 1.2s

* fix(types): resolve mypy errors across application source

* ci: run mypy in the lint job

* ci: include mypy in lint dependency group

* fix(types): narrow evdev timestamp comparison

* refactor(recognition): add SpeechEngine protocol and engine dispatch table

* refactor(settings): move About section and engine tables out of settings_dialog

* docs: add ARCHITECTURE.md and refresh docs index and AGENTS layout

* fix(settings): preserve GTK dialog compatibility after extraction

* style: format touched Python modules

* fix(types): keep local typecheck from syncing dependencies

* test(vad): align main startup expectation with new default

* style: sort engine registry and protocol test imports

* test(settings): follow extracted About builder attribute alias

* test(settings): isolate GTK dialog base in About test

* test(settings): isolate About color scheme build test

* test(settings): restore GTK base after About scheme test

* test(settings): isolate About scheme check from GTK mocks

* test(settings): support extracted dialog mixin methods

* docs: add before/after performance benchmark charts

* fix(injection): type instead of pasting when the clipboard holds non-text data
2026-10-20 20:23:08 -07:00

544 lines
18 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Tests for the shortcut-capture keyname mapping in the settings dialog.
Covers the pure helpers that turn a GDK key-symbol name and/or hardware
keycode (from the "Record shortcut" capture) into a canonical main-key token.
The GTK dialog itself can't be instantiated under the mocked-GTK test harness,
but these module-level helpers are pure and directly testable.
Also drives `_EvdevShortcutRecorder` with mocked evdev devices and GLib watches
(no `/dev/input`, no SettingsDialog construction).
"""
import importlib
import types
from typing import Any, Optional
from unittest.mock import MagicMock
import pytest
from vocalinux.ui.keyboard_backends.evdev_backend import DEVICE_RESCAN_SECONDS
from vocalinux.ui.settings_dialog import (
SettingsDialog,
_EvdevShortcutRecorder,
_gdk_capture_to_shortcut,
_gdk_keyname_to_token,
_shortcut_from_capture,
function_token_from_evdev_code,
function_token_from_gdk_hardware_keycode,
modifiers_from_active_evdev_codes,
)
# linux/input.h / python-evdev values used by the recorder.
_EV_KEY = 1
_KEY_A = 30
_KEY_LEFTCTRL = 29
_KEY_LEFTALT = 56
_KEY_LEFTSHIFT = 42
_KEY_F13 = 183
_KEY_F19 = 189
_KEY_F24 = 194
_IO_IN = 1
_IO_OUT = 4
_IO_PRI = 2
_IO_ERR = 8
_IO_HUP = 16
@pytest.mark.parametrize(
"name,expected",
[
("r", "r"),
("A", "a"), # capture may report a shifted/upper name
("5", "5"),
("F5", "f5"),
("f12", "f12"),
("space", "space"),
("Return", "enter"),
("Escape", "esc"),
("Page_Up", "pageup"),
("comma", "comma"),
("bracketleft", "leftbracket"),
],
)
def test_maps_known_keys(name: str, expected: str) -> None:
assert _gdk_keyname_to_token(name) == expected
@pytest.mark.parametrize("name", ["Control_L", "Alt_R", "Shift_L", "Super_L", "", None, "F25"])
def test_rejects_modifiers_and_unknown(name: str | None) -> None:
# Modifiers, empty/None, and out-of-range function keys are not main keys.
assert _gdk_keyname_to_token(name) is None
@pytest.mark.parametrize(
"modifiers,token,expected",
[
(["alt"], "r", "alt+r"),
(["ctrl", "alt"], "f5", "ctrl+alt+f5"),
([], "f10", "f10"),
([], "f24", "f24"),
([], "r", None),
([], "space", None),
([], None, None),
(["shift"], "f10", "shift+f10"),
([], "f19", "f19"),
],
)
def test_shortcut_from_capture(
modifiers: list[str],
token: str | None,
expected: str | None,
) -> None:
assert _shortcut_from_capture(modifiers, token) == expected
@pytest.mark.parametrize(
"hardware,expected",
[
(191, "f13"),
(197, "f19"),
(202, "f24"),
(190, None),
(203, None),
(0, None),
(67, None), # F1 XKB keycode; F1–F12 come from keyval names
],
)
def test_function_token_from_gdk_hardware_keycode(hardware: int, expected: str | None) -> None:
assert function_token_from_gdk_hardware_keycode(hardware) == expected
@pytest.mark.parametrize(
"code,expected",
[
(183, "f13"),
(189, "f19"),
(194, "f24"),
(182, None),
(195, None),
],
)
def test_function_token_from_evdev_code(code: int, expected: str | None) -> None:
assert function_token_from_evdev_code(code) == expected
def test_capture_f19_from_hardware_without_keyval() -> None:
assert _gdk_capture_to_shortcut([], "", 197) == "f19"
assert _gdk_capture_to_shortcut([], None, 197) == "f19"
def test_capture_f13_from_hardware_despite_xf86_keyval() -> None:
assert _gdk_capture_to_shortcut([], "XF86Tools", 191) == "f13"
def test_capture_f19_from_keyval_with_hardware_zero() -> None:
assert _gdk_capture_to_shortcut([], "F19", 0) == "f19"
def test_xf86_name_is_not_guessed_without_hardware() -> None:
assert _gdk_capture_to_shortcut([], "XF86Tools", 0) is None
assert _gdk_keyname_to_token("XF86Tools") is None
def test_modifiers_from_active_evdev_codes() -> None:
assert modifiers_from_active_evdev_codes({29}) == ["ctrl"]
assert modifiers_from_active_evdev_codes({189, 56}) == ["alt"]
assert (
_shortcut_from_capture(
modifiers_from_active_evdev_codes({189, 56}),
function_token_from_evdev_code(189),
)
== "alt+f19"
)
class _FakeEvdevEvent:
def __init__(self, code: int, value: int, ev_type: int = _EV_KEY) -> None:
self.type = ev_type
self.code = code
self.value = value
class _FakeEvdevDevice:
def __init__(
self,
path: str,
fd: int,
*,
active_keys: Optional[set[int]] = None,
active_keys_error: Optional[BaseException] = None,
) -> None:
self.path = path
self._fd = fd
self._active_keys = set(active_keys or ())
self._active_keys_error = active_keys_error
self.pending: list[_FakeEvdevEvent] = []
self.read_error: Optional[BaseException] = None
self.closed = False
self.read_calls = 0
def fileno(self) -> int:
return self._fd
def read(self) -> list[_FakeEvdevEvent]:
self.read_calls += 1
if self.read_error is not None:
raise self.read_error
events = self.pending
self.pending = []
return events
def close(self) -> None:
self.closed = True
def active_keys(self) -> set[int]:
if self._active_keys_error is not None:
raise self._active_keys_error
return set(self._active_keys)
class _EvdevRecorderHarness:
"""Patch GLib watches, evdev availability, and InputDevice for one test."""
def __init__(self) -> None:
self.watches: list[dict[str, Any]] = []
self.timeouts: list[dict[str, Any]] = []
self.removed: list[Any] = []
self.devices: dict[str, _FakeEvdevDevice] = {}
self.find_keyboard_devices = MagicMock(return_value=[])
self._next_id = 1
self._next_fd = 100
def add_device(
self,
path: str,
*,
active_keys: Optional[set[int]] = None,
active_keys_error: Optional[BaseException] = None,
) -> _FakeEvdevDevice:
device = _FakeEvdevDevice(
path,
self._next_fd,
active_keys=active_keys,
active_keys_error=active_keys_error,
)
self._next_fd += 1
self.devices[path] = device
return device
def io_add_watch(self, fd: int, flags: int, callback: Any) -> int:
watch_id = self._next_id
self._next_id += 1
self.watches.append({"id": watch_id, "fd": fd, "flags": flags, "callback": callback})
return watch_id
def timeout_add(self, interval_ms: int, callback: Any) -> int:
timeout_id = self._next_id
self._next_id += 1
self.timeouts.append({"id": timeout_id, "interval_ms": interval_ms, "callback": callback})
return timeout_id
def source_remove(self, source_id: Any) -> bool:
self.removed.append(source_id)
return True
def _input_device(self, path: str) -> _FakeEvdevDevice:
if path not in self.devices:
raise OSError(f"No such device: {path}")
return self.devices[path]
def watch_for(self, path: str) -> dict[str, Any]:
fd = self.devices[path].fileno()
matches = [watch for watch in self.watches if watch["fd"] == fd]
assert matches, f"no io watch for {path}"
return matches[-1]
def start_recorder(
self,
paths: list[str],
on_shortcut: Optional[Any] = None,
) -> tuple[_EvdevShortcutRecorder, list[str]]:
self.find_keyboard_devices.return_value = list(paths)
committed: list[str] = []
callback = on_shortcut if on_shortcut is not None else committed.append
recorder = _EvdevShortcutRecorder(callback)
recorder.start()
return recorder, committed
def fire_io(self, path: str, condition: int = _IO_IN) -> bool:
watch = self.watch_for(path)
return bool(watch["callback"](watch["fd"], condition))
@pytest.fixture
def evdev_recorder(monkeypatch: pytest.MonkeyPatch) -> _EvdevRecorderHarness:
harness = _EvdevRecorderHarness()
fake_glib = types.SimpleNamespace(
IO_IN=_IO_IN,
IO_OUT=_IO_OUT,
IO_PRI=_IO_PRI,
IO_ERR=_IO_ERR,
IO_HUP=_IO_HUP,
io_add_watch=harness.io_add_watch,
timeout_add=harness.timeout_add,
source_remove=harness.source_remove,
)
# Methods close over the original module dict, not a reimported sys.modules entry.
monkeypatch.setitem(_EvdevShortcutRecorder.start.__globals__, "GLib", fake_glib)
monkeypatch.setattr(
"vocalinux.ui.keyboard_backends.evdev_backend.EVDEV_AVAILABLE",
True,
)
monkeypatch.setattr(
"vocalinux.ui.keyboard_backends.evdev_backend.find_keyboard_devices",
harness.find_keyboard_devices,
)
# Keyboard tests pop sys.modules["evdev"]; patch the live module, not a stale import.
evdev_mod = importlib.import_module("evdev")
monkeypatch.setattr(evdev_mod, "InputDevice", harness._input_device)
try:
evdev_device = importlib.import_module("evdev.device")
except ImportError:
pass
else:
monkeypatch.setattr(evdev_device, "InputDevice", harness._input_device)
return harness
def test_f19_press_commits_once_non_function_and_release_do_not(
evdev_recorder: _EvdevRecorderHarness,
) -> None:
path = "/dev/input/event0"
device = evdev_recorder.add_device(path)
committed: list[str] = []
recorder_box: list[_EvdevShortcutRecorder] = []
def on_shortcut(shortcut: str) -> None:
committed.append(shortcut)
recorder_box[0].stop()
recorder, _ = evdev_recorder.start_recorder([path], on_shortcut)
recorder_box.append(recorder)
assert len(evdev_recorder.timeouts) == 1
assert evdev_recorder.timeouts[0]["interval_ms"] == int(DEVICE_RESCAN_SECONDS * 1000)
in_flight = evdev_recorder.watch_for(path)
device.pending.append(_FakeEvdevEvent(_KEY_A, 1))
assert evdev_recorder.fire_io(path) is True
assert committed == []
device.pending.append(_FakeEvdevEvent(_KEY_F19, 0))
assert evdev_recorder.fire_io(path) is True
assert committed == []
device.pending.append(_FakeEvdevEvent(_KEY_F19, 1))
assert evdev_recorder.fire_io(path) is False
assert committed == ["f19"]
assert in_flight["id"] not in evdev_recorder.removed
assert evdev_recorder.timeouts[0]["id"] in evdev_recorder.removed
def test_modifiers_split_across_devices_alt_f19(
evdev_recorder: _EvdevRecorderHarness,
) -> None:
left_path = "/dev/input/left"
right_path = "/dev/input/right"
left = evdev_recorder.add_device(left_path)
right = evdev_recorder.add_device(right_path)
recorder, committed = evdev_recorder.start_recorder([left_path, right_path])
left.pending.append(_FakeEvdevEvent(_KEY_LEFTALT, 1))
assert evdev_recorder.fire_io(left_path) is True
assert committed == []
right.pending.append(_FakeEvdevEvent(_KEY_F19, 1))
assert evdev_recorder.fire_io(right_path) is False
assert committed == ["alt+f19"]
def test_modifiers_from_active_keys_at_open_ctrl_f13(
evdev_recorder: _EvdevRecorderHarness,
) -> None:
left_path = "/dev/input/left"
right_path = "/dev/input/right"
evdev_recorder.add_device(left_path, active_keys={_KEY_LEFTCTRL})
right = evdev_recorder.add_device(right_path)
_recorder, committed = evdev_recorder.start_recorder([left_path, right_path])
right.pending.append(_FakeEvdevEvent(_KEY_F13, 1))
assert evdev_recorder.fire_io(right_path) is False
assert committed == ["ctrl+f13"]
def test_per_device_held_sets_release_on_one_half_keeps_the_other(
evdev_recorder: _EvdevRecorderHarness,
) -> None:
left_path = "/dev/input/left"
right_path = "/dev/input/right"
left = evdev_recorder.add_device(left_path)
right = evdev_recorder.add_device(right_path)
_recorder, committed = evdev_recorder.start_recorder([left_path, right_path])
left.pending.append(_FakeEvdevEvent(_KEY_LEFTALT, 1))
assert evdev_recorder.fire_io(left_path) is True
right.pending.append(_FakeEvdevEvent(_KEY_LEFTSHIFT, 1))
assert evdev_recorder.fire_io(right_path) is True
left.pending.append(_FakeEvdevEvent(_KEY_LEFTALT, 0))
assert evdev_recorder.fire_io(left_path) is True
right.pending.append(_FakeEvdevEvent(_KEY_F19, 1))
assert evdev_recorder.fire_io(right_path) is False
assert committed == ["shift+f19"]
def test_ctrl_and_alt_on_separate_halves_commit_canonical_order(
evdev_recorder: _EvdevRecorderHarness,
) -> None:
left_path = "/dev/input/left"
right_path = "/dev/input/right"
left = evdev_recorder.add_device(left_path)
right = evdev_recorder.add_device(right_path)
_recorder, committed = evdev_recorder.start_recorder([left_path, right_path])
left.pending.append(_FakeEvdevEvent(_KEY_LEFTCTRL, 1))
assert evdev_recorder.fire_io(left_path) is True
right.pending.append(_FakeEvdevEvent(_KEY_LEFTALT, 1))
assert evdev_recorder.fire_io(right_path) is True
left.pending.append(_FakeEvdevEvent(_KEY_F24, 1))
assert evdev_recorder.fire_io(left_path) is False
assert committed == ["ctrl+alt+f24"]
def test_read_oserror_drops_only_that_device_and_rescan_attaches_replacement(
evdev_recorder: _EvdevRecorderHarness,
) -> None:
left_path = "/dev/input/left"
right_path = "/dev/input/right"
left = evdev_recorder.add_device(left_path)
right = evdev_recorder.add_device(right_path)
recorder, committed = evdev_recorder.start_recorder([left_path, right_path])
timeout = evdev_recorder.timeouts[0]
right_watch_id = evdev_recorder.watch_for(right_path)["id"]
left.read_error = OSError("disconnected")
assert evdev_recorder.fire_io(left_path) is False
assert left.closed
assert not right.closed
assert right_watch_id not in evdev_recorder.removed
assert timeout["id"] not in evdev_recorder.removed
assert committed == []
replacement_path = "/dev/input/event99"
replacement = evdev_recorder.add_device(replacement_path)
evdev_recorder.find_keyboard_devices.return_value = [right_path, replacement_path]
assert timeout["callback"]() is True
assert evdev_recorder.watch_for(replacement_path)["fd"] == replacement.fileno()
assert recorder._active is True
def test_io_hup_and_io_err_drop_without_reading(
evdev_recorder: _EvdevRecorderHarness,
) -> None:
hup_path = "/dev/input/hup"
err_path = "/dev/input/err"
keep_path = "/dev/input/keep"
hup_dev = evdev_recorder.add_device(hup_path)
err_dev = evdev_recorder.add_device(err_path)
keep = evdev_recorder.add_device(keep_path)
_recorder, committed = evdev_recorder.start_recorder([hup_path, err_path, keep_path])
timeout_id = evdev_recorder.timeouts[0]["id"]
hup_dev.pending.append(_FakeEvdevEvent(_KEY_F19, 1))
assert evdev_recorder.fire_io(hup_path, _IO_HUP) is False
assert hup_dev.read_calls == 0
assert hup_dev.closed
assert committed == []
err_dev.pending.append(_FakeEvdevEvent(_KEY_F19, 1))
assert evdev_recorder.fire_io(err_path, _IO_ERR) is False
assert err_dev.read_calls == 0
assert err_dev.closed
assert committed == []
assert not keep.closed
assert timeout_id not in evdev_recorder.removed
def test_blocking_io_error_keeps_device_and_watch(
evdev_recorder: _EvdevRecorderHarness,
) -> None:
path = "/dev/input/event0"
device = evdev_recorder.add_device(path)
recorder, committed = evdev_recorder.start_recorder([path])
watch_id = evdev_recorder.watch_for(path)["id"]
device.read_error = BlockingIOError()
assert evdev_recorder.fire_io(path) is True
assert not device.closed
assert watch_id not in evdev_recorder.removed
assert recorder._active is True
assert committed == []
def test_stop_is_idempotent_and_callback_after_stop_does_not_commit(
evdev_recorder: _EvdevRecorderHarness,
) -> None:
left_path = "/dev/input/left"
right_path = "/dev/input/right"
left = evdev_recorder.add_device(left_path)
right = evdev_recorder.add_device(right_path)
recorder, committed = evdev_recorder.start_recorder([left_path, right_path])
saved_callback = evdev_recorder.watch_for(left_path)["callback"]
watch_ids = {watch["id"] for watch in evdev_recorder.watches}
timeout_id = evdev_recorder.timeouts[0]["id"]
recorder.stop()
assert left.closed
assert right.closed
assert watch_ids <= set(evdev_recorder.removed)
assert timeout_id in evdev_recorder.removed
recorder.stop()
left.pending.append(_FakeEvdevEvent(_KEY_F19, 1))
assert saved_callback(left.fileno(), _IO_IN) is False
assert committed == []
def _settings_dialog_unbound(name: str) -> Any:
"""Return a SettingsDialog method without instantiating the dialog."""
namespace = getattr(SettingsDialog, "return_value", None)
if isinstance(namespace, dict):
return namespace[name]
if namespace is None:
namespace = SettingsDialog
return getattr(namespace, name)
def test_destroy_clears_recording_flag_and_closes_devices(
evdev_recorder: _EvdevRecorderHarness,
) -> None:
path = "/dev/input/event0"
device = evdev_recorder.add_device(path)
recorder, _committed = evdev_recorder.start_recorder([path])
destroy = _settings_dialog_unbound("_on_shortcut_recorder_destroy")
stop = _settings_dialog_unbound("_stop_evdev_shortcut_recorder")
class _DialogStub:
_stop_evdev_shortcut_recorder = stop
def __init__(self) -> None:
self._recording_shortcut = True
self._evdev_shortcut_recorder = recorder
stub = _DialogStub()
destroy(stub, object())
assert stub._recording_shortcut is False
assert stub._evdev_shortcut_recorder is None
assert device.closed
assert {watch["id"] for watch in evdev_recorder.watches} <= set(evdev_recorder.removed)
assert evdev_recorder.timeouts[0]["id"] in evdev_recorder.removed