Files
voculinux/tests/test_keyboard_init_ext.py
T
devin-ai-integration[bot]andGitHub e866cb85e4 fix(hotkeys): prefer evdev on GNOME X11 and skip redundant IBus engine restore (#963)
Fixes #954.

GNOME X11: mutter never delivers physical key events to the XRecord extension pynput relies on, so the listener started cleanly but shortcuts stayed dead. Prefer the evdev backend there (same path as Wayland), falling back to pynput when /dev/input is unusable.

Shutdown: `ibus engine <name>` rewrites the whole X server XKB map, and the unconditional restore collapsed GNOME's multi-layout map and broke layout switching. The daemon check now uses _query_active_engine() (no GNOME gsettings fallback) so a cleared engine reads as None; the restore write is skipped when a real engine is live or the target is an xkb:* source GNOME owns, and still runs for real IM engines (#558).
2026-10-16 09:48:28 -07:00

500 lines
23 KiB
Python

"""
Tests for keyboard backends __init__.py module.
This module tests:
- DesktopEnvironment.detect() for all cases (wayland, x11, unknown, fallback env vars)
- create_backend() for all branches (preferred evdev, preferred pynput, unknown preferred,
wayland auto, x11 auto, no backends)
- Mocking EVDEV_AVAILABLE, PYNPUT_AVAILABLE, and backend classes
"""
import os
import sys
from unittest.mock import MagicMock, Mock, patch
import pytest
# Import the module under test
import vocalinux.ui.keyboard_backends as kb_module
from vocalinux.ui.keyboard_backends import (
EVDEV_AVAILABLE,
PYNPUT_AVAILABLE,
DesktopEnvironment,
create_backend,
)
from vocalinux.ui.keyboard_backends.evdev_backend import EVDEV_AVAILABLE as EVDEV_BACKEND_AVAILABLE
from vocalinux.ui.keyboard_backends.pynput_backend import (
PYNPUT_AVAILABLE as PYNPUT_BACKEND_AVAILABLE,
)
class TestDesktopEnvironmentDetect:
"""Test DesktopEnvironment.detect() method."""
def test_package_availability_flags_match_backend_modules(self):
"""Test package-level availability flags mirror backend import checks."""
assert EVDEV_AVAILABLE == EVDEV_BACKEND_AVAILABLE
assert PYNPUT_AVAILABLE == PYNPUT_BACKEND_AVAILABLE
def test_detect_xdg_session_type_wayland(self):
"""Test detection of Wayland via XDG_SESSION_TYPE."""
with patch.dict(os.environ, {"XDG_SESSION_TYPE": "wayland"}, clear=False):
result = DesktopEnvironment.detect()
assert result == DesktopEnvironment.WAYLAND
def test_detect_xdg_session_type_x11(self):
"""Test detection of X11 via XDG_SESSION_TYPE."""
with patch.dict(os.environ, {"XDG_SESSION_TYPE": "x11"}, clear=False):
result = DesktopEnvironment.detect()
assert result == DesktopEnvironment.X11
def test_detect_flatpak_wayland_without_socket_uses_x11(self):
"""Flatpak with only X11/XWayland is labeled X11 (injection path).
Global shortcuts still prefer evdev via create_backend when FLATPAK_ID
is set — pynput over XWayland is not truly global.
"""
env = {
"FLATPAK_ID": "com.vocalinux.Vocalinux",
"XDG_SESSION_TYPE": "wayland",
"DISPLAY": ":0",
}
with patch.dict(os.environ, env, clear=True):
assert DesktopEnvironment.detect() == DesktopEnvironment.X11
def test_detect_flatpak_wayland_with_socket_stays_wayland(self):
"""With a Wayland socket exposed, detection still reports Wayland."""
env = {
"FLATPAK_ID": "com.vocalinux.Vocalinux",
"XDG_SESSION_TYPE": "wayland",
"WAYLAND_DISPLAY": "wayland-0",
}
with patch.dict(os.environ, env, clear=True):
assert DesktopEnvironment.detect() == DesktopEnvironment.WAYLAND
def test_create_backend_flatpak_prefers_evdev(self):
"""Flatpak must use evdev so hotkeys work when another app is focused."""
from vocalinux.ui.keyboard_backends import create_backend
fake = MagicMock()
fake.is_available.return_value = True
env = {
"FLATPAK_ID": "com.vocalinux.Vocalinux",
"XDG_SESSION_TYPE": "wayland",
"DISPLAY": ":0",
}
with patch.dict(os.environ, env, clear=True):
with (
patch.object(kb_module, "EVDEV_AVAILABLE", True),
patch.object(kb_module, "EvdevKeyboardBackend", return_value=fake) as mock_evdev,
patch.object(kb_module, "PYNPUT_AVAILABLE", True),
patch.object(kb_module, "PynputKeyboardBackend") as mock_pynput,
):
backend = create_backend()
assert backend is fake
mock_evdev.assert_called_once()
mock_pynput.assert_not_called()
def test_create_backend_native_x11_prefers_pynput(self):
"""Outside Flatpak on X11, pynput remains the default."""
from vocalinux.ui.keyboard_backends import create_backend
env = {"XDG_SESSION_TYPE": "x11", "DISPLAY": ":0"}
with patch.dict(os.environ, env, clear=True):
with (
patch.object(kb_module, "EVDEV_AVAILABLE", True),
patch.object(kb_module, "EvdevKeyboardBackend") as mock_evdev,
patch.object(kb_module, "PYNPUT_AVAILABLE", True),
patch.object(
kb_module, "PynputKeyboardBackend", return_value=MagicMock()
) as mock_pynput,
):
backend = create_backend()
assert backend is mock_pynput.return_value
mock_pynput.assert_called_once()
mock_evdev.assert_not_called()
def test_create_backend_gnome_x11_prefers_evdev(self) -> None:
"""GNOME X11 must prefer evdev: mutter's XRecord drops physical key events (#954)."""
from vocalinux.ui.keyboard_backends import create_backend
fake = MagicMock()
fake.is_available.return_value = True
env = {
"XDG_SESSION_TYPE": "x11",
"XDG_CURRENT_DESKTOP": "GNOME",
"DISPLAY": ":0",
}
with patch.dict(os.environ, env, clear=True):
with (
patch.object(kb_module, "EVDEV_AVAILABLE", True),
patch.object(kb_module, "EvdevKeyboardBackend", return_value=fake) as mock_evdev,
patch.object(kb_module, "PYNPUT_AVAILABLE", True),
patch.object(kb_module, "PynputKeyboardBackend") as mock_pynput,
):
backend = create_backend()
assert backend is fake
mock_evdev.assert_called_once()
mock_pynput.assert_not_called()
def test_create_backend_gnome_x11_falls_back_to_pynput(self) -> None:
"""GNOME X11 keeps pynput when evdev cannot read /dev/input (#954)."""
from vocalinux.ui.keyboard_backends import create_backend
fake = MagicMock()
fake.is_available.return_value = False
fake.get_permission_hint.return_value = "not in input group"
env = {
"XDG_SESSION_TYPE": "x11",
"XDG_CURRENT_DESKTOP": "ubuntu:GNOME",
"DISPLAY": ":0",
}
with patch.dict(os.environ, env, clear=True):
with (
patch.object(kb_module, "EVDEV_AVAILABLE", True),
patch.object(kb_module, "EvdevKeyboardBackend", return_value=fake),
patch.object(kb_module, "PYNPUT_AVAILABLE", True),
patch.object(
kb_module, "PynputKeyboardBackend", return_value=MagicMock()
) as mock_pynput,
):
backend = create_backend()
assert backend is mock_pynput.return_value
mock_pynput.assert_called_once()
def test_detect_xdg_session_type_case_insensitive(self):
"""Test that XDG_SESSION_TYPE detection is case-insensitive."""
with patch.dict(os.environ, {"XDG_SESSION_TYPE": "WAYLAND"}, clear=False):
result = DesktopEnvironment.detect()
assert result == DesktopEnvironment.WAYLAND
def test_detect_xdg_session_type_mixed_case(self):
"""Test that XDG_SESSION_TYPE detection handles mixed case."""
with patch.dict(os.environ, {"XDG_SESSION_TYPE": "Wayland"}, clear=False):
result = DesktopEnvironment.detect()
assert result == DesktopEnvironment.WAYLAND
def test_detect_wayland_display_env_var(self):
"""Test detection of Wayland via WAYLAND_DISPLAY env var (fallback)."""
env = {
"WAYLAND_DISPLAY": "wayland-0",
}
with patch.dict(os.environ, env, clear=True):
result = DesktopEnvironment.detect()
assert result == DesktopEnvironment.WAYLAND
def test_detect_display_env_var(self):
"""Test detection of X11 via DISPLAY env var (fallback)."""
env = {
"DISPLAY": ":0",
}
with patch.dict(os.environ, env, clear=True):
result = DesktopEnvironment.detect()
assert result == DesktopEnvironment.X11
def test_detect_wayland_display_priority_over_display(self):
"""Test that WAYLAND_DISPLAY takes priority over DISPLAY."""
env = {
"WAYLAND_DISPLAY": "wayland-0",
"DISPLAY": ":0",
}
with patch.dict(os.environ, env, clear=True):
result = DesktopEnvironment.detect()
assert result == DesktopEnvironment.WAYLAND
def test_detect_xdg_session_type_priority_over_env_vars(self):
"""Test that XDG_SESSION_TYPE takes priority over WAYLAND_DISPLAY/DISPLAY."""
env = {
"XDG_SESSION_TYPE": "x11",
"WAYLAND_DISPLAY": "wayland-0",
}
with patch.dict(os.environ, env, clear=True):
result = DesktopEnvironment.detect()
assert result == DesktopEnvironment.X11
def test_detect_unknown_no_env_vars(self):
"""Test detection returns unknown when no env vars are set."""
with patch.dict(os.environ, {}, clear=True):
result = DesktopEnvironment.detect()
assert result == DesktopEnvironment.UNKNOWN
def test_detect_unknown_xdg_session_type_unknown(self):
"""Test detection returns unknown for unknown XDG_SESSION_TYPE."""
with patch.dict(os.environ, {"XDG_SESSION_TYPE": "unknown_session"}, clear=True):
result = DesktopEnvironment.detect()
assert result == DesktopEnvironment.UNKNOWN
def test_constants_defined(self):
"""Test that DesktopEnvironment constants are defined."""
assert DesktopEnvironment.X11 == "x11"
assert DesktopEnvironment.WAYLAND == "wayland"
assert DesktopEnvironment.UNKNOWN == "unknown"
class TestCreateBackendPreferred:
"""Test create_backend() with preferred backend specified."""
def test_create_backend_preferred_evdev_available_and_usable(self):
"""Test preferred evdev backend when available and usable."""
mock_evdev_backend = MagicMock()
mock_evdev_backend.is_available.return_value = True
with patch("vocalinux.ui.keyboard_backends.EVDEV_AVAILABLE", True):
with patch(
"vocalinux.ui.keyboard_backends.EvdevKeyboardBackend",
return_value=mock_evdev_backend,
):
result = create_backend(preferred_backend="evdev")
assert result == mock_evdev_backend
mock_evdev_backend.is_available.assert_called_once()
def test_create_backend_preferred_evdev_not_available(self):
"""Test preferred evdev backend when module not available."""
with patch("vocalinux.ui.keyboard_backends.EVDEV_AVAILABLE", False):
with patch("vocalinux.ui.keyboard_backends.PYNPUT_AVAILABLE", False):
result = create_backend(preferred_backend="evdev")
assert result is None
def test_create_backend_preferred_evdev_available_but_not_usable(self):
"""Test preferred evdev backend when available but not usable."""
mock_evdev_backend = MagicMock()
mock_evdev_backend.is_available.return_value = False
mock_evdev_backend.get_permission_hint.return_value = "Permission denied"
with patch("vocalinux.ui.keyboard_backends.EVDEV_AVAILABLE", True):
with patch(
"vocalinux.ui.keyboard_backends.EvdevKeyboardBackend",
return_value=mock_evdev_backend,
):
with patch("vocalinux.ui.keyboard_backends.PYNPUT_AVAILABLE", False):
result = create_backend(preferred_backend="evdev")
assert result is None
def test_create_backend_preferred_pynput_available(self):
"""Test preferred pynput backend when available."""
mock_pynput_backend = MagicMock()
with patch("vocalinux.ui.keyboard_backends.PYNPUT_AVAILABLE", True):
with patch(
"vocalinux.ui.keyboard_backends.PynputKeyboardBackend",
return_value=mock_pynput_backend,
):
result = create_backend(preferred_backend="pynput")
assert result == mock_pynput_backend
def test_create_backend_preferred_pynput_not_available(self):
"""Test preferred pynput backend when not available."""
with patch("vocalinux.ui.keyboard_backends.PYNPUT_AVAILABLE", False):
with patch("vocalinux.ui.keyboard_backends.EVDEV_AVAILABLE", False):
result = create_backend(preferred_backend="pynput")
assert result is None
def test_create_backend_preferred_unknown_backend(self):
"""Test create_backend with unknown preferred backend."""
with patch("vocalinux.ui.keyboard_backends.EVDEV_AVAILABLE", False):
with patch("vocalinux.ui.keyboard_backends.PYNPUT_AVAILABLE", False):
result = create_backend(preferred_backend="unknown_backend")
assert result is None
def test_create_backend_preferred_with_custom_shortcut(self):
"""Test that shortcut parameter is passed to backend."""
mock_pynput_backend = MagicMock()
with patch("vocalinux.ui.keyboard_backends.PYNPUT_AVAILABLE", True):
with patch(
"vocalinux.ui.keyboard_backends.PynputKeyboardBackend",
return_value=mock_pynput_backend,
) as mock_constructor:
result = create_backend(
preferred_backend="pynput", shortcut="alt+alt", mode="push_to_talk"
)
mock_constructor.assert_called_once_with(shortcut="alt+alt", mode="push_to_talk")
assert result == mock_pynput_backend
def test_create_backend_preferred_with_custom_mode(self):
"""Test that mode parameter is passed to backend."""
mock_evdev_backend = MagicMock()
mock_evdev_backend.is_available.return_value = True
with patch("vocalinux.ui.keyboard_backends.EVDEV_AVAILABLE", True):
with patch(
"vocalinux.ui.keyboard_backends.EvdevKeyboardBackend",
return_value=mock_evdev_backend,
) as mock_constructor:
result = create_backend(preferred_backend="evdev", mode="push_to_talk")
mock_constructor.assert_called_once()
call_kwargs = mock_constructor.call_args[1]
assert call_kwargs["mode"] == "push_to_talk"
class TestCreateBackendAutoDetect:
"""Test create_backend() with auto-detection based on desktop environment."""
def test_create_backend_wayland_evdev_available_and_usable(self):
"""Test Wayland environment auto-selects evdev when available."""
mock_evdev_backend = MagicMock()
mock_evdev_backend.is_available.return_value = True
with patch.dict(os.environ, {"XDG_SESSION_TYPE": "wayland"}, clear=True):
with patch("vocalinux.ui.keyboard_backends.EVDEV_AVAILABLE", True):
with patch(
"vocalinux.ui.keyboard_backends.EvdevKeyboardBackend",
return_value=mock_evdev_backend,
):
with patch("vocalinux.ui.keyboard_backends.PYNPUT_AVAILABLE", False):
result = create_backend()
assert result == mock_evdev_backend
def test_create_backend_wayland_evdev_not_available(self):
"""Test Wayland returns None when evdev not available."""
with patch.dict(os.environ, {"XDG_SESSION_TYPE": "wayland"}, clear=True):
with patch("vocalinux.ui.keyboard_backends.EVDEV_AVAILABLE", False):
with patch("vocalinux.ui.keyboard_backends.PYNPUT_AVAILABLE", False):
result = create_backend()
assert result is None
def test_create_backend_wayland_evdev_available_but_not_usable(self):
"""Test Wayland returns None when evdev not usable."""
mock_evdev_backend = MagicMock()
mock_evdev_backend.is_available.return_value = False
mock_evdev_backend.get_permission_hint.return_value = "Permission denied"
with patch.dict(os.environ, {"XDG_SESSION_TYPE": "wayland"}, clear=True):
with patch("vocalinux.ui.keyboard_backends.EVDEV_AVAILABLE", True):
with patch(
"vocalinux.ui.keyboard_backends.EvdevKeyboardBackend",
return_value=mock_evdev_backend,
):
result = create_backend()
assert result is None
def test_create_backend_x11_pynput_available(self):
"""Test X11 environment auto-selects pynput."""
mock_pynput_backend = MagicMock()
with patch.dict(os.environ, {"XDG_SESSION_TYPE": "x11"}, clear=True):
with patch("vocalinux.ui.keyboard_backends.PYNPUT_AVAILABLE", True):
with patch(
"vocalinux.ui.keyboard_backends.PynputKeyboardBackend",
return_value=mock_pynput_backend,
):
result = create_backend()
assert result == mock_pynput_backend
def test_create_backend_x11_pynput_not_available_evdev_available(self):
"""Test X11 falls back to evdev when pynput not available."""
mock_evdev_backend = MagicMock()
mock_evdev_backend.is_available.return_value = True
with patch.dict(os.environ, {"XDG_SESSION_TYPE": "x11"}, clear=True):
with patch("vocalinux.ui.keyboard_backends.PYNPUT_AVAILABLE", False):
with patch("vocalinux.ui.keyboard_backends.EVDEV_AVAILABLE", True):
with patch(
"vocalinux.ui.keyboard_backends.EvdevKeyboardBackend",
return_value=mock_evdev_backend,
):
result = create_backend()
assert result == mock_evdev_backend
def test_create_backend_unknown_env_pynput_available(self):
"""Test unknown environment defaults to pynput."""
mock_pynput_backend = MagicMock()
with patch.dict(os.environ, {}, clear=True):
with patch("vocalinux.ui.keyboard_backends.PYNPUT_AVAILABLE", True):
with patch(
"vocalinux.ui.keyboard_backends.PynputKeyboardBackend",
return_value=mock_pynput_backend,
):
result = create_backend()
assert result == mock_pynput_backend
def test_create_backend_unknown_env_no_backends(self):
"""Test unknown environment returns None when no backends available."""
with patch.dict(os.environ, {}, clear=True):
with patch("vocalinux.ui.keyboard_backends.PYNPUT_AVAILABLE", False):
with patch("vocalinux.ui.keyboard_backends.EVDEV_AVAILABLE", False):
result = create_backend()
assert result is None
def test_create_backend_no_backends_available(self):
"""Test create_backend returns None when no backends available at all."""
with patch("vocalinux.ui.keyboard_backends.PYNPUT_AVAILABLE", False):
with patch("vocalinux.ui.keyboard_backends.EVDEV_AVAILABLE", False):
result = create_backend()
assert result is None
def test_create_backend_with_custom_parameters(self):
"""Test that custom parameters are passed to auto-selected backend."""
mock_pynput_backend = MagicMock()
with patch.dict(os.environ, {"XDG_SESSION_TYPE": "x11"}, clear=True):
with patch("vocalinux.ui.keyboard_backends.PYNPUT_AVAILABLE", True):
with patch(
"vocalinux.ui.keyboard_backends.PynputKeyboardBackend",
return_value=mock_pynput_backend,
) as mock_constructor:
result = create_backend(shortcut="alt+alt", mode="push_to_talk")
mock_constructor.assert_called_once_with(
shortcut="alt+alt", mode="push_to_talk"
)
def test_create_backend_default_parameters(self):
"""Test that default parameters are used when not specified."""
mock_pynput_backend = MagicMock()
with patch.dict(os.environ, {"XDG_SESSION_TYPE": "x11"}, clear=True):
with patch("vocalinux.ui.keyboard_backends.PYNPUT_AVAILABLE", True):
with patch(
"vocalinux.ui.keyboard_backends.PynputKeyboardBackend",
return_value=mock_pynput_backend,
) as mock_constructor:
result = create_backend()
# Check that the constructor was called with some default shortcut/mode
mock_constructor.assert_called_once()
call_kwargs = mock_constructor.call_args[1]
# Shortcut and mode should be passed as defaults
assert "shortcut" in call_kwargs
assert "mode" in call_kwargs
class TestCreateBackendFallback:
"""Test create_backend() fallback scenarios."""
def test_create_backend_pynput_not_available_evdev_available_not_usable(self):
"""Test fallback to evdev when pynput unavailable and evdev not usable."""
mock_evdev_backend = MagicMock()
mock_evdev_backend.is_available.return_value = False
mock_evdev_backend.get_permission_hint.return_value = "Permission denied"
with patch.dict(os.environ, {"XDG_SESSION_TYPE": "x11"}, clear=True):
with patch("vocalinux.ui.keyboard_backends.PYNPUT_AVAILABLE", False):
with patch("vocalinux.ui.keyboard_backends.EVDEV_AVAILABLE", True):
with patch(
"vocalinux.ui.keyboard_backends.EvdevKeyboardBackend",
return_value=mock_evdev_backend,
):
result = create_backend()
assert result is None
def test_create_backend_wayland_preferred_pynput_fails_evdev_available(self):
"""Test that preferred pynput on Wayland fails and then tries evdev."""
# When preferred_backend is pynput and Wayland is detected,
# if pynput is not available, it falls back to auto-detection which tries evdev
mock_evdev_backend = MagicMock()
mock_evdev_backend.is_available.return_value = True
with patch.dict(os.environ, {"XDG_SESSION_TYPE": "wayland"}, clear=True):
with patch("vocalinux.ui.keyboard_backends.PYNPUT_AVAILABLE", False):
with patch("vocalinux.ui.keyboard_backends.EVDEV_AVAILABLE", True):
with patch(
"vocalinux.ui.keyboard_backends.EvdevKeyboardBackend",
return_value=mock_evdev_backend,
):
result = create_backend(preferred_backend="pynput")
# Falls back to auto-detection which selects evdev on Wayland
assert result == mock_evdev_backend