Pass --key-delay 8 to ydotool type instead of relying on the default (12-20ms). This makes text injection ~3x faster while keeping key-delay above zero to avoid the Shift-leak bug where capital letters corrupt following characters. The delay is configurable via VOCALINUX_YDOTOOL_KEY_DELAY env var. Note: --key-hold flag from the original issue does not exist in standard ydotool (verified against official man page and v1.0.4 source). Fixes #482
1789 lines
75 KiB
Python
1789 lines
75 KiB
Python
"""
|
|
Tests for text injection functionality.
|
|
"""
|
|
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import unittest
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
# Update import path to use the new package structure
|
|
from vocalinux.text_injection.text_injector import DesktopEnvironment, TextInjector
|
|
|
|
# Create a mock for audio feedback module
|
|
mock_audio_feedback = MagicMock()
|
|
mock_audio_feedback.play_error_sound = MagicMock()
|
|
|
|
# Add the mock to sys.modules
|
|
sys.modules["vocalinux.ui.audio_feedback"] = mock_audio_feedback
|
|
|
|
|
|
class TestTextInjector(unittest.TestCase):
|
|
"""Test cases for the text injection functionality."""
|
|
|
|
def setUp(self):
|
|
"""Set up for tests."""
|
|
# Create patches for external functions
|
|
self.patch_which = patch("shutil.which")
|
|
self.mock_which = self.patch_which.start()
|
|
|
|
self.patch_subprocess = patch("subprocess.run")
|
|
self.mock_subprocess = self.patch_subprocess.start()
|
|
|
|
self.patch_sleep = patch("time.sleep")
|
|
self.mock_sleep = self.patch_sleep.start()
|
|
|
|
# Disable IBus for these tests (testing fallback paths)
|
|
self.patch_ibus_available = patch(
|
|
"vocalinux.text_injection.text_injector.is_ibus_available",
|
|
return_value=False,
|
|
)
|
|
self.patch_ibus_available.start()
|
|
|
|
# Setup environment variable patching
|
|
self.env_patcher = patch.dict("os.environ", {"XDG_SESSION_TYPE": "x11", "DISPLAY": ":0"})
|
|
self.env_patcher.start()
|
|
|
|
# Set default return values
|
|
self.mock_which.return_value = "/usr/bin/xdotool" # Default to having xdotool
|
|
|
|
# Setup subprocess mock
|
|
mock_process = MagicMock()
|
|
mock_process.returncode = 0
|
|
mock_process.stdout = "1234"
|
|
mock_process.stderr = ""
|
|
self.mock_subprocess.return_value = mock_process
|
|
|
|
# Reset mock for error sound
|
|
mock_audio_feedback.play_error_sound.reset_mock()
|
|
|
|
def tearDown(self):
|
|
"""Clean up after tests."""
|
|
self.patch_which.stop()
|
|
self.patch_subprocess.stop()
|
|
self.patch_sleep.stop()
|
|
self.patch_ibus_available.stop()
|
|
self.env_patcher.stop()
|
|
|
|
def test_detect_x11_environment(self):
|
|
"""Test detection of X11 environment."""
|
|
# Force our mock_which to be selective based on command
|
|
self.mock_which.side_effect = lambda cmd: ("/usr/bin/xdotool" if cmd == "xdotool" else None)
|
|
|
|
# Explicitly set X11 environment
|
|
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "x11"}):
|
|
# Create TextInjector and ensure it detects X11
|
|
injector = TextInjector()
|
|
|
|
# Force X11 detection by patching the _detect_environment method
|
|
with patch.object(injector, "_detect_environment", return_value=DesktopEnvironment.X11):
|
|
injector.environment = DesktopEnvironment.X11
|
|
|
|
# Verify environment is X11
|
|
self.assertEqual(injector.environment, DesktopEnvironment.X11)
|
|
|
|
def test_detect_wayland_environment(self):
|
|
"""Test detection of Wayland environment."""
|
|
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland"}):
|
|
# Make wtype available for Wayland
|
|
self.mock_which.side_effect = lambda cmd: ("/usr/bin/wtype" if cmd == "wtype" else None)
|
|
|
|
# Mock wtype test call to return success
|
|
mock_process = MagicMock()
|
|
mock_process.returncode = 0
|
|
mock_process.stderr = ""
|
|
self.mock_subprocess.return_value = mock_process
|
|
|
|
injector = TextInjector()
|
|
self.assertEqual(injector.environment, DesktopEnvironment.WAYLAND)
|
|
self.assertEqual(injector.wayland_tool, "wtype")
|
|
|
|
def test_force_wayland_mode(self):
|
|
"""Test forcing Wayland mode."""
|
|
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "x11"}):
|
|
# Make wtype available
|
|
self.mock_which.side_effect = lambda cmd: ("/usr/bin/wtype" if cmd == "wtype" else None)
|
|
|
|
# Create injector with wayland_mode=True
|
|
injector = TextInjector(wayland_mode=True)
|
|
|
|
# Should be forced to Wayland
|
|
self.assertEqual(injector.environment, DesktopEnvironment.WAYLAND)
|
|
|
|
def test_wayland_fallback_to_xdotool(self):
|
|
"""Test fallback to XWayland with xdotool when wtype fails."""
|
|
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland"}):
|
|
# Make both wtype and xdotool available
|
|
self.mock_which.side_effect = lambda cmd: {
|
|
"wtype": "/usr/bin/wtype",
|
|
"xdotool": "/usr/bin/xdotool",
|
|
}.get(cmd)
|
|
|
|
# Make wtype test fail with compositor error
|
|
mock_process = MagicMock()
|
|
mock_process.returncode = 1
|
|
mock_process.stderr = "compositor does not support virtual keyboard protocol"
|
|
self.mock_subprocess.return_value = mock_process
|
|
|
|
# Initialize injector
|
|
injector = TextInjector()
|
|
|
|
# Should fall back to XWayland
|
|
self.assertEqual(injector.environment, DesktopEnvironment.WAYLAND_XDOTOOL)
|
|
|
|
def test_x11_text_injection(self):
|
|
"""Test text injection in X11 environment."""
|
|
# Setup X11 environment
|
|
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "x11"}):
|
|
# Force X11 mode
|
|
injector = TextInjector()
|
|
injector.environment = DesktopEnvironment.X11
|
|
|
|
# Create a list to capture subprocess calls
|
|
calls = []
|
|
|
|
def capture_call(*args, **kwargs):
|
|
calls.append((args, kwargs))
|
|
process = MagicMock()
|
|
process.returncode = 0
|
|
return process
|
|
|
|
self.mock_subprocess.side_effect = capture_call
|
|
|
|
# Inject text
|
|
injector.inject_text("Hello world")
|
|
|
|
# Verify xdotool was called correctly
|
|
found_xdotool_call = False
|
|
for args, _ in calls:
|
|
if len(args) > 0 and isinstance(args[0], list):
|
|
cmd = args[0]
|
|
if "xdotool" in cmd and "type" in cmd:
|
|
found_xdotool_call = True
|
|
break
|
|
|
|
self.assertTrue(found_xdotool_call, "No xdotool type calls were made")
|
|
|
|
def test_wayland_text_injection(self):
|
|
"""Test text injection in Wayland environment using wtype."""
|
|
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland"}):
|
|
# Make wtype available
|
|
self.mock_which.side_effect = lambda cmd: ("/usr/bin/wtype" if cmd == "wtype" else None)
|
|
|
|
# Successful wtype test
|
|
mock_process = MagicMock()
|
|
mock_process.returncode = 0
|
|
mock_process.stderr = ""
|
|
self.mock_subprocess.return_value = mock_process
|
|
|
|
# Initialize injector
|
|
injector = TextInjector()
|
|
self.assertEqual(injector.wayland_tool, "wtype")
|
|
|
|
# Inject text
|
|
injector.inject_text("Hello world")
|
|
|
|
# Verify wtype was called correctly
|
|
self.mock_subprocess.assert_any_call(
|
|
["wtype", "Hello world"], check=True, stderr=subprocess.PIPE, text=True
|
|
)
|
|
|
|
def test_wayland_with_ydotool(self):
|
|
"""Test text injection in Wayland environment using ydotool."""
|
|
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland"}):
|
|
# Make only ydotool available
|
|
self.mock_which.side_effect = lambda cmd: (
|
|
"/usr/bin/ydotool" if cmd == "ydotool" else None
|
|
)
|
|
|
|
# Initialize injector
|
|
injector = TextInjector()
|
|
self.assertEqual(injector.wayland_tool, "ydotool")
|
|
|
|
# Inject text
|
|
injector.inject_text("Hello world")
|
|
|
|
# Verify ydotool was called correctly with timing parameters
|
|
self.mock_subprocess.assert_any_call(
|
|
["ydotool", "type", "--key-delay", "8", "Hello world"],
|
|
check=True,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
)
|
|
|
|
def test_inject_special_characters(self):
|
|
"""Test injecting text with special characters that need escaping."""
|
|
# Setup a TextInjector using X11 environment
|
|
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "x11"}):
|
|
# Force X11 mode
|
|
injector = TextInjector()
|
|
injector.environment = DesktopEnvironment.X11
|
|
|
|
# Set up subprocess call to properly collect the escaped command
|
|
calls = []
|
|
|
|
def capture_call(*args, **kwargs):
|
|
calls.append((args, kwargs))
|
|
process = MagicMock()
|
|
process.returncode = 0
|
|
return process
|
|
|
|
self.mock_subprocess.side_effect = capture_call
|
|
|
|
# Text with special characters
|
|
special_text = "Special 'quotes' and \"double quotes\" and $dollar signs"
|
|
|
|
# Inject text
|
|
injector.inject_text(special_text)
|
|
|
|
# Verify xdotool was called with the original text (no escaping needed)
|
|
# Find calls that contain xdotool and check they contain the original text
|
|
found_unescaped = False
|
|
found_escaped = False
|
|
for args, _ in calls:
|
|
if len(args) > 0 and isinstance(args[0], list):
|
|
cmd = args[0]
|
|
if "xdotool" in cmd and "type" in cmd:
|
|
# Join the command to check for characters
|
|
cmd_str = " ".join(cmd)
|
|
# Check for unescaped special characters
|
|
if "'" in cmd_str:
|
|
found_unescaped = True
|
|
if "\\'" in cmd_str or '\\"' in cmd_str or "\\$" in cmd_str:
|
|
# Found escaped characters - this is bad!
|
|
found_escaped = True
|
|
break
|
|
|
|
# Should find unescaped characters and NOT find escaped ones
|
|
self.assertTrue(
|
|
found_unescaped,
|
|
"Text was not passed correctly to xdotool",
|
|
)
|
|
self.assertFalse(
|
|
found_escaped,
|
|
"Text should not be shell-escaped when passed to xdotool",
|
|
)
|
|
|
|
def test_empty_text_injection(self):
|
|
"""Test injecting empty text (should do nothing)."""
|
|
injector = TextInjector()
|
|
|
|
# Reset the subprocess mock to clear previous calls
|
|
self.mock_subprocess.reset_mock()
|
|
|
|
# Inject empty text
|
|
injector.inject_text("")
|
|
|
|
# No subprocess calls should have been made
|
|
self.mock_subprocess.assert_not_called()
|
|
|
|
# Try with just whitespace
|
|
injector.inject_text(" ")
|
|
|
|
# Still no subprocess calls
|
|
self.mock_subprocess.assert_not_called()
|
|
|
|
def test_missing_dependencies(self):
|
|
"""Test error when no text injection dependencies are available."""
|
|
# No tools available
|
|
self.mock_which.return_value = None
|
|
|
|
# Should raise RuntimeError
|
|
with self.assertRaises(RuntimeError):
|
|
TextInjector()
|
|
|
|
def test_xdotool_error_handling(self):
|
|
"""Test handling of xdotool errors."""
|
|
# Setup xdotool to fail
|
|
mock_error = subprocess.CalledProcessError(1, ["xdotool", "type"], stderr="Error")
|
|
self.mock_subprocess.side_effect = mock_error
|
|
|
|
injector = TextInjector()
|
|
|
|
# Get the audio feedback mock
|
|
audio_feedback = sys.modules["vocalinux.ui.audio_feedback"]
|
|
audio_feedback.play_error_sound.reset_mock()
|
|
|
|
# Inject text - this should call play_error_sound
|
|
injector.inject_text("Test text")
|
|
|
|
# Check that error sound was triggered
|
|
audio_feedback.play_error_sound.assert_called_once()
|
|
|
|
def test_detect_environment_unknown(self):
|
|
"""Test environment detection when no indicators are present."""
|
|
with patch.dict("os.environ", {}, clear=True):
|
|
# Clear all environment variables
|
|
with patch.object(TextInjector, "_check_dependencies"):
|
|
injector = TextInjector.__new__(TextInjector)
|
|
injector._state_lock = threading.Lock()
|
|
env = injector._detect_environment()
|
|
# Should default to X11 when unknown
|
|
self.assertEqual(env, DesktopEnvironment.X11)
|
|
|
|
def test_detect_environment_wayland_display(self):
|
|
"""Test environment detection via WAYLAND_DISPLAY."""
|
|
with patch.dict("os.environ", {"WAYLAND_DISPLAY": "wayland-0"}, clear=True):
|
|
with patch.object(TextInjector, "_check_dependencies"):
|
|
injector = TextInjector.__new__(TextInjector)
|
|
injector._state_lock = threading.Lock()
|
|
env = injector._detect_environment()
|
|
self.assertEqual(env, DesktopEnvironment.WAYLAND)
|
|
|
|
def test_detect_environment_display_only(self):
|
|
"""Test environment detection via DISPLAY only."""
|
|
with patch.dict("os.environ", {"DISPLAY": ":0"}, clear=True):
|
|
with patch.object(TextInjector, "_check_dependencies"):
|
|
injector = TextInjector.__new__(TextInjector)
|
|
injector._state_lock = threading.Lock()
|
|
env = injector._detect_environment()
|
|
self.assertEqual(env, DesktopEnvironment.X11)
|
|
|
|
def test_inject_keyboard_shortcut_x11(self):
|
|
"""Test keyboard shortcut injection in X11."""
|
|
injector = TextInjector()
|
|
injector.environment = DesktopEnvironment.X11
|
|
|
|
mock_process = MagicMock()
|
|
mock_process.returncode = 0
|
|
self.mock_subprocess.return_value = mock_process
|
|
|
|
result = injector._inject_keyboard_shortcut("ctrl+z")
|
|
self.assertTrue(result)
|
|
|
|
def test_inject_keyboard_shortcut_wayland_xdotool(self):
|
|
"""Test keyboard shortcut injection with XWayland fallback."""
|
|
injector = TextInjector()
|
|
injector.environment = DesktopEnvironment.WAYLAND_XDOTOOL
|
|
|
|
mock_process = MagicMock()
|
|
mock_process.returncode = 0
|
|
self.mock_subprocess.return_value = mock_process
|
|
|
|
result = injector._inject_keyboard_shortcut("ctrl+c")
|
|
self.assertTrue(result)
|
|
|
|
def test_inject_keyboard_shortcut_wayland_wtype(self):
|
|
"""Test keyboard shortcut injection with wtype (not supported)."""
|
|
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland"}):
|
|
self.mock_which.side_effect = lambda cmd: ("/usr/bin/wtype" if cmd == "wtype" else None)
|
|
|
|
mock_process = MagicMock()
|
|
mock_process.returncode = 0
|
|
mock_process.stderr = ""
|
|
self.mock_subprocess.return_value = mock_process
|
|
|
|
injector = TextInjector()
|
|
injector.wayland_tool = "wtype"
|
|
injector.environment = DesktopEnvironment.WAYLAND
|
|
|
|
result = injector._inject_shortcut_with_wayland_tool("ctrl+z")
|
|
self.assertFalse(result) # wtype doesn't support shortcuts
|
|
|
|
def test_inject_keyboard_shortcut_wayland_ydotool(self):
|
|
"""Test keyboard shortcut injection with ydotool."""
|
|
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland"}):
|
|
self.mock_which.side_effect = lambda cmd: (
|
|
"/usr/bin/ydotool" if cmd == "ydotool" else None
|
|
)
|
|
|
|
injector = TextInjector()
|
|
injector.wayland_tool = "ydotool"
|
|
injector.environment = DesktopEnvironment.WAYLAND
|
|
|
|
mock_process = MagicMock()
|
|
mock_process.returncode = 0
|
|
self.mock_subprocess.return_value = mock_process
|
|
|
|
result = injector._inject_shortcut_with_wayland_tool("ctrl+z")
|
|
self.assertTrue(result)
|
|
|
|
def test_inject_keyboard_shortcut_failure(self):
|
|
"""Test keyboard shortcut injection failure handling."""
|
|
injector = TextInjector()
|
|
injector.environment = DesktopEnvironment.X11
|
|
|
|
self.mock_subprocess.side_effect = subprocess.CalledProcessError(
|
|
1, ["xdotool", "key"], stderr="Error"
|
|
)
|
|
|
|
result = injector._inject_keyboard_shortcut("ctrl+z")
|
|
self.assertFalse(result)
|
|
|
|
def test_log_current_window_info_wayland(self):
|
|
"""Test window info logging for pure Wayland."""
|
|
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland"}):
|
|
self.mock_which.side_effect = lambda cmd: ("/usr/bin/wtype" if cmd == "wtype" else None)
|
|
|
|
mock_process = MagicMock()
|
|
mock_process.returncode = 0
|
|
mock_process.stderr = ""
|
|
self.mock_subprocess.return_value = mock_process
|
|
|
|
injector = TextInjector()
|
|
injector.environment = DesktopEnvironment.WAYLAND
|
|
|
|
# Should not raise, just logs debug message
|
|
injector._log_current_window_info()
|
|
|
|
def test_inject_text_returns_true_on_success(self):
|
|
"""Test that inject_text returns True on success."""
|
|
injector = TextInjector()
|
|
|
|
mock_process = MagicMock()
|
|
mock_process.returncode = 0
|
|
self.mock_subprocess.return_value = mock_process
|
|
|
|
result = injector.inject_text("Test")
|
|
self.assertTrue(result)
|
|
|
|
def test_inject_text_returns_false_on_failure(self):
|
|
"""Test that inject_text returns False on failure."""
|
|
injector = TextInjector()
|
|
|
|
self.mock_subprocess.side_effect = Exception("Injection failed")
|
|
|
|
result = injector.inject_text("Test")
|
|
self.assertFalse(result)
|
|
|
|
def test_wayland_tool_fallback_on_injection_failure(self):
|
|
"""Test automatic fallback to xdotool when Wayland tool fails during injection."""
|
|
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland"}):
|
|
# Make both wtype and xdotool available
|
|
self.mock_which.side_effect = lambda cmd: {
|
|
"wtype": "/usr/bin/wtype",
|
|
"xdotool": "/usr/bin/xdotool",
|
|
}.get(cmd)
|
|
|
|
# First return success for wtype test, then fail for actual injection
|
|
call_count = [0]
|
|
|
|
def mock_subprocess_call(*args, **kwargs):
|
|
call_count[0] += 1
|
|
if call_count[0] <= 1: # wtype test call
|
|
mock = MagicMock()
|
|
mock.returncode = 0
|
|
mock.stderr = ""
|
|
return mock
|
|
elif call_count[0] == 2: # First injection attempt with wtype
|
|
err = subprocess.CalledProcessError(
|
|
1, ["wtype"], stderr="compositor does not support"
|
|
)
|
|
err.stderr = "compositor does not support"
|
|
raise err
|
|
else: # xdotool fallback calls
|
|
mock = MagicMock()
|
|
mock.returncode = 0
|
|
mock.stdout = "12345"
|
|
return mock
|
|
|
|
self.mock_subprocess.side_effect = mock_subprocess_call
|
|
|
|
injector = TextInjector()
|
|
self.assertEqual(injector.environment, DesktopEnvironment.WAYLAND)
|
|
|
|
# Try injection, should fallback to xdotool
|
|
result = injector.inject_text("Test")
|
|
|
|
# After failure, should have switched to WAYLAND_XDOTOOL
|
|
self.assertEqual(injector.environment, DesktopEnvironment.WAYLAND_XDOTOOL)
|
|
|
|
def test_has_non_ascii_with_ascii_text(self):
|
|
"""Test _has_non_ascii returns False for pure ASCII text."""
|
|
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "x11"}):
|
|
injector = TextInjector()
|
|
self.assertFalse(injector._has_non_ascii("Hello world"))
|
|
self.assertFalse(injector._has_non_ascii("123 test!"))
|
|
|
|
def test_has_non_ascii_with_accented_text(self):
|
|
"""Test _has_non_ascii returns True for accented characters (#362)."""
|
|
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "x11"}):
|
|
injector = TextInjector()
|
|
self.assertTrue(injector._has_non_ascii("Esdrújula"))
|
|
self.assertTrue(injector._has_non_ascii("crème brûlée"))
|
|
self.assertTrue(injector._has_non_ascii("café"))
|
|
self.assertTrue(injector._has_non_ascii("niño"))
|
|
|
|
@patch("vocalinux.text_injection.text_injector.is_ibus_active_input_method", return_value=False)
|
|
@patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=False)
|
|
@patch("vocalinux.text_injection.text_injector.shutil.which")
|
|
@patch("vocalinux.text_injection.text_injector.subprocess.run")
|
|
def test_ydotool_non_ascii_uses_clipboard_paste(
|
|
self, mock_run, mock_which, mock_ibus_avail, mock_ibus_active
|
|
):
|
|
"""Test that ydotool uses clipboard paste for non-ASCII text (#362)."""
|
|
mock_which.side_effect = lambda x: x in ("ydotool", "wl-copy")
|
|
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
|
|
|
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland", "WAYLAND_DISPLAY": "w-1"}):
|
|
injector = TextInjector()
|
|
injector.wayland_tool = "ydotool"
|
|
injector.environment = DesktopEnvironment.WAYLAND
|
|
|
|
# Reset call list so init calls don't interfere
|
|
mock_run.reset_mock()
|
|
injector._inject_with_wayland_tool("Esdrújula")
|
|
|
|
# Should have called wl-copy and ydotool key (Ctrl+V), not ydotool type
|
|
calls = [c.args[0] for c in mock_run.call_args_list]
|
|
has_wl_copy = any(c[0] == "wl-copy" for c in calls)
|
|
has_ydotool_key = any(c[:2] == ["ydotool", "key"] for c in calls)
|
|
has_ydotool_type = any(c[:2] == ["ydotool", "type"] for c in calls)
|
|
|
|
self.assertTrue(has_wl_copy, "Should use wl-copy for clipboard")
|
|
self.assertTrue(has_ydotool_key, "Should use ydotool key for Ctrl+V")
|
|
self.assertFalse(has_ydotool_type, "Should NOT use ydotool type for non-ASCII")
|
|
|
|
@patch("vocalinux.text_injection.text_injector.is_ibus_active_input_method", return_value=False)
|
|
@patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=False)
|
|
@patch("vocalinux.text_injection.text_injector.shutil.which")
|
|
@patch("vocalinux.text_injection.text_injector.subprocess.run")
|
|
def test_ydotool_ascii_also_uses_clipboard_paste(
|
|
self, mock_run, mock_which, mock_ibus_avail, mock_ibus_active
|
|
):
|
|
"""ydotool always pastes via the clipboard, even for plain ASCII.
|
|
|
|
`ydotool type` emits positional evdev keycodes that get re-interpreted
|
|
through the active keyboard layout (assumed US QWERTY), so typing ASCII
|
|
is scrambled on non-US layouts (e.g. AZERTY). Clipboard paste (Ctrl+V)
|
|
is layout-independent, so it is used unconditionally for ydotool.
|
|
"""
|
|
mock_which.side_effect = lambda x: x in ("ydotool", "wl-copy")
|
|
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
|
|
|
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland", "WAYLAND_DISPLAY": "w-1"}):
|
|
injector = TextInjector()
|
|
injector.wayland_tool = "ydotool"
|
|
injector.environment = DesktopEnvironment.WAYLAND
|
|
|
|
mock_run.reset_mock()
|
|
injector._inject_with_wayland_tool("Hello world")
|
|
|
|
calls = [c.args[0] for c in mock_run.call_args_list if c.args]
|
|
self.assertTrue(
|
|
any(c[0] == "wl-copy" for c in calls),
|
|
"Should copy ASCII text to the clipboard",
|
|
)
|
|
self.assertTrue(
|
|
any(c[:2] == ["ydotool", "key"] for c in calls),
|
|
"Should paste with ydotool key (Ctrl+V)",
|
|
)
|
|
self.assertFalse(
|
|
any(c[:2] == ["ydotool", "type"] for c in calls),
|
|
"Should NOT use layout-dependent ydotool type",
|
|
)
|
|
|
|
@patch("vocalinux.text_injection.text_injector.is_ibus_active_input_method", return_value=False)
|
|
@patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=False)
|
|
@patch("vocalinux.text_injection.text_injector.shutil.which")
|
|
@patch("vocalinux.text_injection.text_injector.subprocess.run")
|
|
def test_ydotool_non_ascii_falls_back_to_type_when_clipboard_fails(
|
|
self, mock_run, mock_which, mock_ibus_avail, mock_ibus_active
|
|
):
|
|
"""Test ydotool falls back to type when clipboard paste fails (#362)."""
|
|
mock_which.side_effect = lambda x: x == "ydotool" # no wl-copy
|
|
|
|
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
|
|
|
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland", "WAYLAND_DISPLAY": "w-1"}):
|
|
injector = TextInjector()
|
|
injector.wayland_tool = "ydotool"
|
|
injector.environment = DesktopEnvironment.WAYLAND
|
|
|
|
injector._inject_with_wayland_tool("café")
|
|
|
|
# Should fall back to ydotool type since no clipboard tool available
|
|
calls = [str(c) for c in mock_run.call_args_list]
|
|
has_ydotool_type = any("'type'" in c for c in calls)
|
|
self.assertTrue(has_ydotool_type, "Should fall back to ydotool type")
|
|
|
|
@patch("vocalinux.text_injection.text_injector.shutil.which")
|
|
@patch("vocalinux.text_injection.text_injector.subprocess.run")
|
|
def test_clipboard_paste_uses_xclip_fallback(self, mock_run, mock_which):
|
|
"""Test clipboard paste falls back to xclip when wl-copy is unavailable (#362)."""
|
|
mock_which.side_effect = lambda x: x in ("xclip", "ydotool")
|
|
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
|
|
|
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland", "WAYLAND_DISPLAY": "w-1"}):
|
|
injector = TextInjector()
|
|
injector.wayland_tool = "ydotool"
|
|
injector.environment = DesktopEnvironment.WAYLAND
|
|
|
|
result = injector._inject_via_clipboard_paste("café")
|
|
|
|
self.assertTrue(result)
|
|
calls = [c.args[0] for c in mock_run.call_args_list if c.args]
|
|
has_xclip = any(c[0] == "xclip" for c in calls)
|
|
self.assertTrue(has_xclip, "Should use xclip as fallback")
|
|
|
|
@patch("vocalinux.text_injection.text_injector.shutil.which")
|
|
@patch("vocalinux.text_injection.text_injector.subprocess.run")
|
|
def test_clipboard_paste_uses_xsel_fallback(self, mock_run, mock_which):
|
|
"""Test clipboard paste falls back to xsel when wl-copy/xclip unavailable (#362)."""
|
|
mock_which.side_effect = lambda x: x in ("xsel", "ydotool")
|
|
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
|
|
|
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland", "WAYLAND_DISPLAY": "w-1"}):
|
|
injector = TextInjector()
|
|
injector.wayland_tool = "ydotool"
|
|
injector.environment = DesktopEnvironment.WAYLAND
|
|
|
|
result = injector._inject_via_clipboard_paste("café")
|
|
|
|
self.assertTrue(result)
|
|
calls = [c.args[0] for c in mock_run.call_args_list if c.args]
|
|
has_xsel = any(c[0] == "xsel" for c in calls)
|
|
self.assertTrue(has_xsel, "Should use xsel as fallback")
|
|
|
|
@patch("vocalinux.text_injection.text_injector.subprocess.run")
|
|
def test_clipboard_command_does_not_capture_pipe(self, mock_run):
|
|
"""_run_clipboard_command must not capture the tool's stderr via a pipe.
|
|
|
|
wl-copy/xclip/xsel fork a background process that keeps owning the
|
|
selection; if stderr is captured with subprocess.PIPE the call blocks on
|
|
the surviving child and times out even though the copy succeeded.
|
|
Redirecting to DEVNULL avoids that hang (regression guard).
|
|
"""
|
|
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
|
injector = TextInjector.__new__(TextInjector)
|
|
injector._clipboard_timeout = 0.35
|
|
|
|
for tool in ("wl-copy", "xclip", "xsel"):
|
|
mock_run.reset_mock()
|
|
self.assertTrue(injector._run_clipboard_command(tool, "café"))
|
|
_, kwargs = mock_run.call_args
|
|
self.assertEqual(kwargs.get("stdout"), subprocess.DEVNULL)
|
|
self.assertEqual(kwargs.get("stderr"), subprocess.DEVNULL)
|
|
self.assertNotEqual(kwargs.get("stderr"), subprocess.PIPE)
|
|
|
|
@patch("vocalinux.text_injection.text_injector.is_ibus_active_input_method", return_value=False)
|
|
@patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=False)
|
|
@patch("vocalinux.text_injection.text_injector.shutil.which")
|
|
@patch("vocalinux.text_injection.text_injector.subprocess.run")
|
|
def test_wtype_injects_unicode_directly(
|
|
self, mock_run, mock_which, mock_ibus_avail, mock_ibus_active
|
|
):
|
|
"""wtype supports Unicode natively and must NOT use the clipboard-paste path."""
|
|
mock_which.side_effect = lambda x: x in ("wtype", "wl-copy")
|
|
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
|
|
|
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland", "WAYLAND_DISPLAY": "w-1"}):
|
|
injector = TextInjector()
|
|
injector.wayland_tool = "wtype"
|
|
injector.environment = DesktopEnvironment.WAYLAND
|
|
|
|
mock_run.reset_mock()
|
|
injector._inject_with_wayland_tool("café")
|
|
|
|
calls = [c.args[0] for c in mock_run.call_args_list if c.args]
|
|
self.assertTrue(
|
|
any(c[0] == "wtype" and c[1] == "café" for c in calls),
|
|
"wtype should inject text directly",
|
|
)
|
|
self.assertFalse(
|
|
any(c[0] == "wl-copy" for c in calls),
|
|
"wtype must NOT use clipboard-paste workaround",
|
|
)
|
|
|
|
@patch("vocalinux.text_injection.text_injector.shutil.which")
|
|
@patch("vocalinux.text_injection.text_injector.subprocess.run")
|
|
def test_clipboard_paste_returns_false_on_paste_failure(self, mock_run, mock_which):
|
|
"""Test clipboard paste returns False when Ctrl+V simulation fails (#362)."""
|
|
mock_which.side_effect = lambda x: x in ("wl-copy", "ydotool")
|
|
|
|
def side_effect(*args, **kwargs):
|
|
cmd = args[0]
|
|
if cmd[0] == "wl-copy":
|
|
return MagicMock(returncode=0)
|
|
# ydotool key fails
|
|
raise subprocess.CalledProcessError(1, cmd)
|
|
|
|
mock_run.side_effect = side_effect
|
|
|
|
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland", "WAYLAND_DISPLAY": "w-1"}):
|
|
injector = TextInjector()
|
|
injector.wayland_tool = "ydotool"
|
|
injector.environment = DesktopEnvironment.WAYLAND
|
|
|
|
result = injector._inject_via_clipboard_paste("café")
|
|
|
|
self.assertFalse(result)
|
|
|
|
@patch("vocalinux.text_injection.text_injector.is_ibus_active_input_method", return_value=False)
|
|
@patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=False)
|
|
@patch("vocalinux.text_injection.text_injector.shutil.which")
|
|
def test_clipboard_paste_returns_false_when_no_clipboard_tools(
|
|
self, mock_which, mock_ibus_avail, mock_ibus_active
|
|
):
|
|
"""Test clipboard paste returns False when no clipboard tools available."""
|
|
# ydotool available for init, but no clipboard tools (wl-copy/xclip/xsel)
|
|
mock_which.side_effect = lambda x: x if x == "ydotool" else None
|
|
|
|
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland", "WAYLAND_DISPLAY": "w-1"}):
|
|
injector = TextInjector()
|
|
injector.wayland_tool = "ydotool"
|
|
injector.environment = DesktopEnvironment.WAYLAND
|
|
|
|
result = injector._inject_via_clipboard_paste("café")
|
|
|
|
self.assertFalse(result)
|
|
|
|
@patch("vocalinux.text_injection.text_injector.is_ibus_active_input_method", return_value=False)
|
|
@patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=False)
|
|
@patch("vocalinux.text_injection.text_injector.shutil.which")
|
|
@patch("vocalinux.text_injection.text_injector.subprocess.run")
|
|
def test_inject_text_ydotool_non_ascii_end_to_end(
|
|
self, mock_run, mock_which, mock_ibus_avail, mock_ibus_active
|
|
):
|
|
"""inject_text routes accented text through clipboard-paste on Wayland+ydotool (#362)."""
|
|
mock_which.side_effect = lambda x: x in ("ydotool", "wl-copy")
|
|
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
|
|
|
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland", "WAYLAND_DISPLAY": "w-1"}):
|
|
injector = TextInjector()
|
|
injector.wayland_tool = "ydotool"
|
|
injector.environment = DesktopEnvironment.WAYLAND
|
|
|
|
mock_run.reset_mock()
|
|
result = injector.inject_text("Esdrújula")
|
|
|
|
self.assertTrue(result)
|
|
calls = [c.args[0] for c in mock_run.call_args_list if c.args]
|
|
self.assertTrue(
|
|
any(c[0] == "wl-copy" for c in calls),
|
|
"Should copy to clipboard via wl-copy",
|
|
)
|
|
self.assertTrue(
|
|
any(c[:2] == ["ydotool", "key"] for c in calls),
|
|
"Should simulate Ctrl+V via ydotool key",
|
|
)
|
|
self.assertFalse(
|
|
any(c[:2] == ["ydotool", "type"] for c in calls),
|
|
"Should NOT call ydotool type for non-ASCII text",
|
|
)
|
|
|
|
|
|
class TestDesktopEnvironmentEnum(unittest.TestCase):
|
|
"""Tests for DesktopEnvironment enum."""
|
|
|
|
def test_enum_values(self):
|
|
"""Test that enum values are as expected."""
|
|
self.assertEqual(DesktopEnvironment.X11.value, "x11")
|
|
self.assertEqual(DesktopEnvironment.WAYLAND.value, "wayland")
|
|
self.assertEqual(DesktopEnvironment.WAYLAND_XDOTOOL.value, "wayland-xdotool")
|
|
self.assertEqual(DesktopEnvironment.WAYLAND_IBUS.value, "wayland-ibus")
|
|
self.assertEqual(DesktopEnvironment.UNKNOWN.value, "unknown")
|
|
|
|
|
|
class TestTextInjectorEdgeCases(unittest.TestCase):
|
|
"""Tests for edge cases in TextInjector."""
|
|
|
|
def setUp(self):
|
|
"""Set up for tests."""
|
|
self.patch_which = patch("shutil.which")
|
|
self.mock_which = self.patch_which.start()
|
|
self.mock_which.return_value = "/usr/bin/xdotool"
|
|
|
|
self.patch_subprocess = patch("subprocess.run")
|
|
self.mock_subprocess = self.patch_subprocess.start()
|
|
|
|
self.patch_sleep = patch("time.sleep")
|
|
self.mock_sleep = self.patch_sleep.start()
|
|
|
|
# Disable IBus for these tests (testing fallback paths)
|
|
self.patch_ibus_available = patch(
|
|
"vocalinux.text_injection.text_injector.is_ibus_available",
|
|
return_value=False,
|
|
)
|
|
self.patch_ibus_available.start()
|
|
|
|
mock_process = MagicMock()
|
|
mock_process.returncode = 0
|
|
mock_process.stdout = "1234"
|
|
mock_process.stderr = ""
|
|
self.mock_subprocess.return_value = mock_process
|
|
|
|
def tearDown(self):
|
|
"""Clean up after tests."""
|
|
self.patch_which.stop()
|
|
self.patch_subprocess.stop()
|
|
self.patch_sleep.stop()
|
|
self.patch_ibus_available.stop()
|
|
|
|
def test_wtype_test_exception(self):
|
|
"""Test wtype test handling exceptions gracefully."""
|
|
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland"}):
|
|
|
|
def which_side_effect(cmd):
|
|
if cmd == "wtype":
|
|
return "/usr/bin/wtype"
|
|
return None
|
|
|
|
self.mock_which.side_effect = which_side_effect
|
|
|
|
# Make the wtype test raise an exception
|
|
self.mock_subprocess.side_effect = Exception("Test wtype error")
|
|
|
|
# Should still create the injector (will log warning)
|
|
injector = TextInjector()
|
|
self.assertEqual(injector.environment, DesktopEnvironment.WAYLAND)
|
|
|
|
def test_xwayland_fallback_test_exception(self):
|
|
"""Test XWayland fallback test handles exceptions."""
|
|
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland"}):
|
|
# Make only xdotool available, not wtype
|
|
def which_side_effect(cmd):
|
|
if cmd == "xdotool":
|
|
return "/usr/bin/xdotool"
|
|
return None
|
|
|
|
self.mock_which.side_effect = which_side_effect
|
|
|
|
# Make subprocess raise for xdotool test
|
|
self.mock_subprocess.side_effect = Exception("xdotool test failed")
|
|
|
|
# Should create injector and log error
|
|
injector = TextInjector()
|
|
|
|
def test_check_dependencies_no_xdotool(self):
|
|
"""Test _check_dependencies when xdotool is missing on X11."""
|
|
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "x11"}):
|
|
self.mock_which.return_value = None # No tools available
|
|
|
|
with self.assertRaises(RuntimeError):
|
|
TextInjector()
|
|
|
|
def test_wayland_no_tools_available(self):
|
|
"""Test Wayland when no tools are available."""
|
|
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland"}):
|
|
self.mock_which.return_value = None # No tools available
|
|
|
|
with self.assertRaises(RuntimeError):
|
|
TextInjector()
|
|
|
|
def test_detect_environment_unknown(self):
|
|
"""Test environment detection when session type is unknown and no display vars set."""
|
|
# Use clear=True to start fresh, then only set XDG_SESSION_TYPE to empty
|
|
# This ensures WAYLAND_DISPLAY and DISPLAY are not in os.environ
|
|
clean_env = {"XDG_SESSION_TYPE": "", "PATH": os.environ.get("PATH", "")}
|
|
with patch.dict("os.environ", clean_env, clear=True):
|
|
injector = TextInjector.__new__(TextInjector)
|
|
injector._state_lock = threading.Lock()
|
|
# Don't call __init__, just test _detect_environment
|
|
result = injector._detect_environment()
|
|
# Should default to X11 when unknown (no WAYLAND_DISPLAY or DISPLAY)
|
|
self.assertEqual(result, DesktopEnvironment.X11)
|
|
|
|
def test_detect_environment_wayland_display(self):
|
|
"""Test environment detection via WAYLAND_DISPLAY."""
|
|
# Clear session type but set WAYLAND_DISPLAY
|
|
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "", "WAYLAND_DISPLAY": "wayland-0"}):
|
|
injector = TextInjector.__new__(TextInjector)
|
|
injector._state_lock = threading.Lock()
|
|
result = injector._detect_environment()
|
|
self.assertEqual(result, DesktopEnvironment.WAYLAND)
|
|
|
|
def test_detect_environment_display_only(self):
|
|
"""Test environment detection via DISPLAY only."""
|
|
# Clear session type and WAYLAND_DISPLAY, set only DISPLAY
|
|
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "", "DISPLAY": ":0"}, clear=False):
|
|
# Ensure WAYLAND_DISPLAY is not set
|
|
env_copy = dict(os.environ)
|
|
env_copy.pop("WAYLAND_DISPLAY", None)
|
|
env_copy["XDG_SESSION_TYPE"] = ""
|
|
env_copy["DISPLAY"] = ":0"
|
|
|
|
with patch.dict("os.environ", env_copy, clear=True):
|
|
injector = TextInjector.__new__(TextInjector)
|
|
injector._state_lock = threading.Lock()
|
|
result = injector._detect_environment()
|
|
self.assertEqual(result, DesktopEnvironment.X11)
|
|
|
|
def test_wtype_compositor_not_supported(self):
|
|
"""Test wtype fallback when compositor doesn't support virtual keyboard."""
|
|
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland"}):
|
|
# Make both wtype and xdotool available
|
|
def which_side_effect(cmd):
|
|
if cmd in ["wtype", "xdotool"]:
|
|
return f"/usr/bin/{cmd}"
|
|
return None
|
|
|
|
self.mock_which.side_effect = which_side_effect
|
|
|
|
# Make wtype test return error about compositor
|
|
mock_process = MagicMock()
|
|
mock_process.returncode = 1
|
|
mock_process.stderr = "compositor does not support virtual keyboard protocol"
|
|
self.mock_subprocess.return_value = mock_process
|
|
|
|
injector = TextInjector()
|
|
# Should have fallen back to WAYLAND_XDOTOOL
|
|
self.assertEqual(injector.environment, DesktopEnvironment.WAYLAND_XDOTOOL)
|
|
|
|
def test_wtype_compositor_not_supported_no_fallback(self):
|
|
"""Test wtype error when no xdotool fallback available."""
|
|
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland"}):
|
|
# Make only wtype available
|
|
def which_side_effect(cmd):
|
|
if cmd == "wtype":
|
|
return "/usr/bin/wtype"
|
|
return None
|
|
|
|
self.mock_which.side_effect = which_side_effect
|
|
|
|
# Make wtype test return error
|
|
mock_process = MagicMock()
|
|
mock_process.returncode = 1
|
|
mock_process.stderr = "compositor does not support virtual keyboard"
|
|
self.mock_subprocess.return_value = mock_process
|
|
|
|
# Should log error but still create injector
|
|
injector = TextInjector()
|
|
# Should stay as WAYLAND since no fallback
|
|
self.assertEqual(injector.environment, DesktopEnvironment.WAYLAND)
|
|
|
|
def test_xwayland_fallback_test_exception_with_both_tools(self):
|
|
"""Test XWayland fallback when test raises exception with both tools."""
|
|
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland"}):
|
|
# Make both tools available
|
|
def which_side_effect(cmd):
|
|
if cmd in ["wtype", "xdotool"]:
|
|
return f"/usr/bin/{cmd}"
|
|
return None
|
|
|
|
self.mock_which.side_effect = which_side_effect
|
|
|
|
# Make wtype succeed
|
|
mock_wtype = MagicMock()
|
|
mock_wtype.returncode = 0
|
|
mock_wtype.stderr = ""
|
|
|
|
# Make xdotool test fail with exception
|
|
def run_side_effect(*args, **kwargs):
|
|
cmd = args[0] if args else kwargs.get("args", [])
|
|
if isinstance(cmd, list) and "xdotool" in cmd:
|
|
if "getactivewindow" in cmd:
|
|
raise Exception("XWayland test failed")
|
|
return mock_wtype
|
|
|
|
self.mock_subprocess.side_effect = run_side_effect
|
|
|
|
# Should still create injector
|
|
injector = TextInjector()
|
|
self.assertEqual(injector.environment, DesktopEnvironment.WAYLAND)
|
|
|
|
def test_inject_text_import_error_for_audio(self):
|
|
"""Test text injection when audio_feedback import fails."""
|
|
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "x11"}):
|
|
self.mock_which.return_value = "/usr/bin/xdotool"
|
|
|
|
injector = TextInjector()
|
|
|
|
# Make xdotool fail to trigger error path
|
|
self.mock_subprocess.side_effect = Exception("Test error")
|
|
|
|
# Mock the audio import to fail
|
|
with patch.dict("sys.modules", {"vocalinux.ui.audio_feedback": None}):
|
|
result = injector.inject_text("test")
|
|
self.assertFalse(result)
|
|
|
|
def test_inject_with_xdotool_retry_timeout(self):
|
|
"""Test xdotool injection with timeout on retry."""
|
|
import subprocess as real_subprocess
|
|
|
|
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "x11"}):
|
|
self.mock_which.return_value = "/usr/bin/xdotool"
|
|
|
|
injector = TextInjector()
|
|
|
|
# First call for getactivewindow, then timeouts on type
|
|
call_count = [0]
|
|
|
|
def run_side_effect(*args, **kwargs):
|
|
cmd = args[0] if args else kwargs.get("args", [])
|
|
call_count[0] += 1
|
|
if isinstance(cmd, list):
|
|
if "getactivewindow" in cmd:
|
|
result = MagicMock()
|
|
result.returncode = 0
|
|
result.stdout = "12345"
|
|
result.stderr = ""
|
|
return result
|
|
if "type" in cmd:
|
|
raise real_subprocess.TimeoutExpired(cmd, 5)
|
|
result = MagicMock()
|
|
result.returncode = 0
|
|
result.stderr = ""
|
|
return result
|
|
|
|
self.mock_subprocess.side_effect = run_side_effect
|
|
|
|
# Should fail after retries
|
|
with self.assertRaises(real_subprocess.TimeoutExpired):
|
|
injector._inject_with_xdotool("test")
|
|
|
|
def test_inject_with_xdotool_xwayland_no_display(self):
|
|
"""Test xdotool injection in XWayland mode without DISPLAY set."""
|
|
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland"}, clear=False):
|
|
# Temporarily remove DISPLAY
|
|
env_backup = os.environ.get("DISPLAY")
|
|
if "DISPLAY" in os.environ:
|
|
del os.environ["DISPLAY"]
|
|
|
|
try:
|
|
|
|
def which_side_effect(cmd):
|
|
if cmd in ["xdotool"]:
|
|
return f"/usr/bin/{cmd}"
|
|
return None
|
|
|
|
self.mock_which.side_effect = which_side_effect
|
|
|
|
# Create injector in WAYLAND_XDOTOOL mode
|
|
injector = TextInjector.__new__(TextInjector)
|
|
injector._state_lock = threading.Lock()
|
|
injector.environment = DesktopEnvironment.WAYLAND_XDOTOOL
|
|
|
|
# Mock subprocess.run
|
|
mock_result = MagicMock()
|
|
mock_result.returncode = 0
|
|
mock_result.stdout = "12345"
|
|
mock_result.stderr = ""
|
|
self.mock_subprocess.return_value = mock_result
|
|
|
|
# Run injection - should set DISPLAY to :0
|
|
injector._inject_with_xdotool("test")
|
|
|
|
# Verify DISPLAY was set in the env passed to subprocess
|
|
calls = self.mock_subprocess.call_args_list
|
|
for call in calls:
|
|
if "env" in call.kwargs:
|
|
env = call.kwargs["env"]
|
|
if "type" in str(call):
|
|
self.assertEqual(env.get("DISPLAY"), ":0")
|
|
finally:
|
|
# Restore DISPLAY
|
|
if env_backup is not None:
|
|
os.environ["DISPLAY"] = env_backup
|
|
|
|
def test_inject_with_wayland_tool_ydotool(self):
|
|
"""Test text injection with ydotool."""
|
|
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland"}):
|
|
# Make only ydotool available
|
|
def which_side_effect(cmd):
|
|
if cmd == "ydotool":
|
|
return "/usr/bin/ydotool"
|
|
return None
|
|
|
|
self.mock_which.side_effect = which_side_effect
|
|
|
|
# Create injector
|
|
injector = TextInjector()
|
|
self.assertEqual(injector.wayland_tool, "ydotool")
|
|
|
|
# Mock successful injection
|
|
mock_result = MagicMock()
|
|
mock_result.returncode = 0
|
|
self.mock_subprocess.return_value = mock_result
|
|
|
|
# Inject text
|
|
injector._inject_with_wayland_tool("test text")
|
|
|
|
# Verify ydotool was called correctly
|
|
self.mock_subprocess.assert_called()
|
|
call_args = self.mock_subprocess.call_args
|
|
self.assertIn("ydotool", call_args[0][0])
|
|
self.assertIn("type", call_args[0][0])
|
|
|
|
def test_inject_keyboard_shortcut_x11(self):
|
|
"""Test keyboard shortcut injection on X11."""
|
|
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "x11"}):
|
|
self.mock_which.return_value = "/usr/bin/xdotool"
|
|
|
|
injector = TextInjector()
|
|
|
|
# Mock subprocess.run for xdotool key command
|
|
mock_result = MagicMock()
|
|
mock_result.returncode = 0
|
|
self.mock_subprocess.return_value = mock_result
|
|
|
|
result = injector._inject_keyboard_shortcut("ctrl+c")
|
|
|
|
self.assertTrue(result)
|
|
# Verify xdotool key was called
|
|
self.mock_subprocess.assert_called()
|
|
|
|
def test_inject_keyboard_shortcut_wayland(self):
|
|
"""Test keyboard shortcut injection on Wayland with wtype."""
|
|
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland"}):
|
|
|
|
def which_side_effect(cmd):
|
|
if cmd == "wtype":
|
|
return "/usr/bin/wtype"
|
|
return None
|
|
|
|
self.mock_which.side_effect = which_side_effect
|
|
|
|
# Make wtype test succeed
|
|
mock_result = MagicMock()
|
|
mock_result.returncode = 0
|
|
mock_result.stderr = ""
|
|
self.mock_subprocess.return_value = mock_result
|
|
|
|
injector = TextInjector()
|
|
|
|
result = injector._inject_keyboard_shortcut("ctrl+v")
|
|
|
|
# Verify wtype -k was called
|
|
self.mock_subprocess.assert_called()
|
|
|
|
def test_inject_keyboard_shortcut_exception(self):
|
|
"""Test keyboard shortcut injection handles exceptions."""
|
|
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "x11"}):
|
|
self.mock_which.return_value = "/usr/bin/xdotool"
|
|
|
|
injector = TextInjector()
|
|
|
|
# Make subprocess.run raise an exception
|
|
self.mock_subprocess.side_effect = Exception("Shortcut injection failed")
|
|
|
|
result = injector._inject_keyboard_shortcut("ctrl+z")
|
|
|
|
self.assertFalse(result)
|
|
|
|
def test_inject_shortcut_xdotool_error(self):
|
|
"""Test keyboard shortcut injection with xdotool error."""
|
|
import subprocess as real_subprocess
|
|
|
|
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "x11"}):
|
|
self.mock_which.return_value = "/usr/bin/xdotool"
|
|
|
|
injector = TextInjector()
|
|
|
|
# Make xdotool return error
|
|
error = real_subprocess.CalledProcessError(1, "xdotool")
|
|
error.stderr = "xdotool error message"
|
|
self.mock_subprocess.side_effect = error
|
|
|
|
result = injector._inject_shortcut_with_xdotool("ctrl+a")
|
|
|
|
self.assertFalse(result)
|
|
|
|
def test_inject_shortcut_with_ydotool(self):
|
|
"""Test keyboard shortcut injection with ydotool."""
|
|
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland"}):
|
|
# Make only ydotool available
|
|
def which_side_effect(cmd):
|
|
if cmd == "ydotool":
|
|
return "/usr/bin/ydotool"
|
|
return None
|
|
|
|
self.mock_which.side_effect = which_side_effect
|
|
|
|
injector = TextInjector()
|
|
self.assertEqual(injector.wayland_tool, "ydotool")
|
|
|
|
# Mock subprocess.run for successful shortcut
|
|
mock_result = MagicMock()
|
|
mock_result.returncode = 0
|
|
self.mock_subprocess.return_value = mock_result
|
|
|
|
result = injector._inject_shortcut_with_wayland_tool("ctrl+a")
|
|
|
|
self.assertTrue(result)
|
|
# Verify ydotool key was called
|
|
self.mock_subprocess.assert_called()
|
|
call_args = self.mock_subprocess.call_args
|
|
self.assertIn("ydotool", call_args[0][0])
|
|
self.assertIn("key", call_args[0][0])
|
|
|
|
def test_inject_shortcut_with_ydotool_error(self):
|
|
"""Test keyboard shortcut injection with ydotool error."""
|
|
import subprocess as real_subprocess
|
|
|
|
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland"}):
|
|
# Make only ydotool available
|
|
def which_side_effect(cmd):
|
|
if cmd == "ydotool":
|
|
return "/usr/bin/ydotool"
|
|
return None
|
|
|
|
self.mock_which.side_effect = which_side_effect
|
|
|
|
injector = TextInjector()
|
|
|
|
# Make ydotool return error
|
|
error = real_subprocess.CalledProcessError(1, "ydotool")
|
|
error.stderr = "ydotool error message"
|
|
self.mock_subprocess.side_effect = error
|
|
|
|
result = injector._inject_shortcut_with_wayland_tool("ctrl+a")
|
|
|
|
self.assertFalse(result)
|
|
|
|
def test_inject_shortcut_wayland_unsupported_tool(self):
|
|
"""Test keyboard shortcut warning with unsupported wayland tool."""
|
|
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland"}):
|
|
# Make wtype available
|
|
def which_side_effect(cmd):
|
|
if cmd == "wtype":
|
|
return "/usr/bin/wtype"
|
|
return None
|
|
|
|
self.mock_which.side_effect = which_side_effect
|
|
|
|
# Succeed on wtype test
|
|
mock_result = MagicMock()
|
|
mock_result.returncode = 0
|
|
mock_result.stderr = ""
|
|
self.mock_subprocess.return_value = mock_result
|
|
|
|
injector = TextInjector()
|
|
|
|
# Force wayland_tool to something unsupported
|
|
injector.wayland_tool = "unsupported_tool"
|
|
|
|
result = injector._inject_shortcut_with_wayland_tool("ctrl+a")
|
|
|
|
self.assertFalse(result)
|
|
|
|
def test_log_current_window_info_exception(self):
|
|
"""Test _log_current_window_info handles exceptions gracefully."""
|
|
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "x11"}):
|
|
self.mock_which.return_value = "/usr/bin/xdotool"
|
|
|
|
injector = TextInjector()
|
|
|
|
# Make subprocess.run raise an exception
|
|
self.mock_subprocess.side_effect = Exception("Window info error")
|
|
|
|
# Should not raise - just log debug message
|
|
injector._log_current_window_info()
|
|
|
|
def test_log_current_window_info_wayland(self):
|
|
"""Test _log_current_window_info on pure Wayland."""
|
|
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland"}):
|
|
|
|
def which_side_effect(cmd):
|
|
if cmd == "wtype":
|
|
return "/usr/bin/wtype"
|
|
return None
|
|
|
|
self.mock_which.side_effect = which_side_effect
|
|
|
|
mock_result = MagicMock()
|
|
mock_result.returncode = 0
|
|
mock_result.stderr = ""
|
|
self.mock_subprocess.return_value = mock_result
|
|
|
|
injector = TextInjector()
|
|
|
|
# Should just log debug message for pure Wayland
|
|
injector._log_current_window_info()
|
|
|
|
def test_inject_shortcut_xdotool_wayland_no_display(self):
|
|
"""Test shortcut injection in WAYLAND_XDOTOOL mode without DISPLAY."""
|
|
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland"}, clear=False):
|
|
# Remove DISPLAY for this test
|
|
env_backup = os.environ.get("DISPLAY")
|
|
if "DISPLAY" in os.environ:
|
|
del os.environ["DISPLAY"]
|
|
|
|
try:
|
|
|
|
def which_side_effect(cmd):
|
|
if cmd in ["xdotool"]:
|
|
return f"/usr/bin/{cmd}"
|
|
return None
|
|
|
|
self.mock_which.side_effect = which_side_effect
|
|
|
|
# Create injector directly in WAYLAND_XDOTOOL mode
|
|
injector = TextInjector.__new__(TextInjector)
|
|
injector._state_lock = threading.Lock()
|
|
injector.environment = DesktopEnvironment.WAYLAND_XDOTOOL
|
|
|
|
# Mock subprocess.run
|
|
mock_result = MagicMock()
|
|
mock_result.returncode = 0
|
|
self.mock_subprocess.return_value = mock_result
|
|
|
|
# Run shortcut injection - should set DISPLAY to :0
|
|
result = injector._inject_shortcut_with_xdotool("ctrl+a")
|
|
|
|
self.assertTrue(result)
|
|
# Verify xdotool key was called with env containing DISPLAY
|
|
self.mock_subprocess.assert_called()
|
|
call_args = self.mock_subprocess.call_args
|
|
if "env" in call_args.kwargs:
|
|
self.assertEqual(call_args.kwargs["env"].get("DISPLAY"), ":0")
|
|
finally:
|
|
# Restore DISPLAY
|
|
if env_backup is not None:
|
|
os.environ["DISPLAY"] = env_backup
|
|
|
|
def test_xwayland_fallback_test_successful(self):
|
|
"""Test XWayland fallback test when successful."""
|
|
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland", "DISPLAY": ":0"}):
|
|
|
|
def which_side_effect(cmd):
|
|
if cmd == "xdotool":
|
|
return "/usr/bin/xdotool"
|
|
return None
|
|
|
|
self.mock_which.side_effect = which_side_effect
|
|
|
|
# Make getactivewindow succeed
|
|
mock_result = MagicMock()
|
|
mock_result.returncode = 0
|
|
mock_result.stdout = "12345"
|
|
mock_result.stderr = ""
|
|
self.mock_subprocess.return_value = mock_result
|
|
|
|
# Create injector - should test XWayland fallback
|
|
injector = TextInjector()
|
|
|
|
# Verify it's in WAYLAND_XDOTOOL mode
|
|
self.assertEqual(injector.environment, DesktopEnvironment.WAYLAND_XDOTOOL)
|
|
|
|
def test_xwayland_fallback_test_error_logging(self):
|
|
"""Test XWayland fallback logs error when _test_xdotool_fallback raises."""
|
|
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland", "DISPLAY": ":0"}):
|
|
|
|
def which_side_effect(cmd):
|
|
if cmd == "xdotool":
|
|
return "/usr/bin/xdotool"
|
|
return None
|
|
|
|
self.mock_which.side_effect = which_side_effect
|
|
|
|
# Mock subprocess to not raise during dependency check
|
|
mock_result = MagicMock()
|
|
mock_result.returncode = 0
|
|
mock_result.stdout = ""
|
|
mock_result.stderr = ""
|
|
self.mock_subprocess.return_value = mock_result
|
|
|
|
# Patch _test_xdotool_fallback at the class level before instantiation
|
|
with patch.object(
|
|
TextInjector,
|
|
"_test_xdotool_fallback",
|
|
side_effect=Exception("Test error"),
|
|
):
|
|
with patch("time.sleep"): # Skip the sleep
|
|
# Should create injector and log error but not crash
|
|
injector = TextInjector()
|
|
self.assertEqual(injector.environment, DesktopEnvironment.WAYLAND_XDOTOOL)
|
|
|
|
|
|
class TestIBusSetupErrorFallback(unittest.TestCase):
|
|
"""Tests for fallback behavior when IBus setup fails."""
|
|
|
|
def setUp(self):
|
|
"""Set up for tests."""
|
|
self.patch_which = patch("shutil.which")
|
|
self.mock_which = self.patch_which.start()
|
|
self.mock_which.return_value = "/usr/bin/xdotool"
|
|
|
|
self.patch_subprocess = patch("subprocess.run")
|
|
self.mock_subprocess = self.patch_subprocess.start()
|
|
|
|
self.patch_sleep = patch("time.sleep")
|
|
self.mock_sleep = self.patch_sleep.start()
|
|
|
|
mock_process = MagicMock()
|
|
mock_process.returncode = 0
|
|
mock_process.stdout = "1234"
|
|
mock_process.stderr = ""
|
|
self.mock_subprocess.return_value = mock_process
|
|
|
|
def tearDown(self):
|
|
"""Clean up after tests."""
|
|
self.patch_which.stop()
|
|
self.patch_subprocess.stop()
|
|
self.patch_sleep.stop()
|
|
|
|
@patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=True)
|
|
@patch("vocalinux.text_injection.text_injector.IBusTextInjector")
|
|
def test_fallback_when_ibus_setup_fails(self, mock_ibus_class, mock_ibus_available):
|
|
"""Test that TextInjector falls back when IBus setup raises IBusSetupError."""
|
|
from vocalinux.text_injection.ibus_engine import IBusSetupError
|
|
|
|
# Make IBusTextInjector raise IBusSetupError
|
|
mock_ibus_class.side_effect = IBusSetupError("Failed to register IBus engine")
|
|
|
|
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "x11"}):
|
|
injector = TextInjector()
|
|
|
|
# Should have fallen back to X11 with xdotool
|
|
self.assertEqual(injector.environment, DesktopEnvironment.X11)
|
|
self.assertIsNone(injector._ibus_injector)
|
|
|
|
@patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=True)
|
|
@patch("vocalinux.text_injection.text_injector.IBusTextInjector")
|
|
def test_fallback_on_wayland_when_ibus_fails(self, mock_ibus_class, mock_ibus_available):
|
|
"""Test that TextInjector falls back on Wayland when IBus setup fails."""
|
|
from vocalinux.text_injection.ibus_engine import IBusSetupError
|
|
|
|
mock_ibus_class.side_effect = IBusSetupError("Failed to start IBus engine process")
|
|
|
|
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland"}):
|
|
# Make xdotool available for fallback
|
|
self.mock_which.side_effect = lambda cmd: (
|
|
"/usr/bin/xdotool" if cmd == "xdotool" else None
|
|
)
|
|
|
|
injector = TextInjector()
|
|
|
|
# Should have fallen back to WAYLAND_XDOTOOL
|
|
self.assertEqual(injector.environment, DesktopEnvironment.WAYLAND_XDOTOOL)
|
|
self.assertIsNone(injector._ibus_injector)
|
|
|
|
@patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=True)
|
|
@patch("vocalinux.text_injection.text_injector.IBusTextInjector")
|
|
def test_text_injection_works_after_ibus_fallback(self, mock_ibus_class, mock_ibus_available):
|
|
"""Test that text injection still works after IBus setup failure."""
|
|
from vocalinux.text_injection.ibus_engine import IBusSetupError
|
|
|
|
mock_ibus_class.side_effect = IBusSetupError("Test failure")
|
|
|
|
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "x11"}):
|
|
injector = TextInjector()
|
|
|
|
# Should be able to inject text via xdotool
|
|
result = injector.inject_text("Hello world")
|
|
self.assertTrue(result)
|
|
|
|
|
|
class TestIBusRuntimeFallback(unittest.TestCase):
|
|
"""Tests for runtime fallback when IBus injection fails."""
|
|
|
|
def setUp(self):
|
|
self.patch_which = patch("shutil.which")
|
|
self.mock_which = self.patch_which.start()
|
|
self.mock_which.return_value = "/usr/bin/xdotool"
|
|
|
|
self.patch_subprocess = patch("subprocess.run")
|
|
self.mock_subprocess = self.patch_subprocess.start()
|
|
mock_process = MagicMock()
|
|
mock_process.returncode = 0
|
|
mock_process.stdout = ""
|
|
mock_process.stderr = ""
|
|
self.mock_subprocess.return_value = mock_process
|
|
|
|
def tearDown(self):
|
|
self.patch_which.stop()
|
|
self.patch_subprocess.stop()
|
|
|
|
@patch("vocalinux.text_injection.text_injector.is_ibus_daemon_running", return_value=True)
|
|
@patch("vocalinux.text_injection.text_injector.is_ibus_active_input_method", return_value=True)
|
|
@patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=True)
|
|
@patch("vocalinux.text_injection.text_injector.IBusTextInjector")
|
|
def test_runtime_fallback_from_x11_ibus_to_xdotool(
|
|
self,
|
|
mock_ibus_class,
|
|
mock_ibus_available,
|
|
mock_is_active,
|
|
mock_daemon,
|
|
):
|
|
"""If IBus runtime injection fails on X11, fallback to xdotool should work."""
|
|
mock_ibus_instance = MagicMock()
|
|
mock_ibus_instance.inject_text.return_value = False
|
|
mock_ibus_class.return_value = mock_ibus_instance
|
|
|
|
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "x11", "DISPLAY": ":0"}):
|
|
injector = TextInjector()
|
|
# IBus environment promotion happens in a background daemon thread
|
|
# (see TextInjector._initialize_ibus_in_background). Wait for it to
|
|
# finish so the assertion below is not subject to scheduler timing.
|
|
if injector._ibus_init_thread is not None:
|
|
injector._ibus_init_thread.join(timeout=5)
|
|
self.assertEqual(injector.environment, DesktopEnvironment.X11_IBUS)
|
|
|
|
result = injector.inject_text("Hello from fallback")
|
|
|
|
self.assertTrue(result)
|
|
self.assertEqual(injector.environment, DesktopEnvironment.X11)
|
|
|
|
@patch("vocalinux.text_injection.text_injector.is_ibus_daemon_running", return_value=True)
|
|
@patch("vocalinux.text_injection.text_injector.is_ibus_active_input_method", return_value=True)
|
|
@patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=True)
|
|
@patch("vocalinux.text_injection.text_injector.IBusTextInjector")
|
|
def test_runtime_fallback_from_wayland_ibus_to_wtype(
|
|
self,
|
|
mock_ibus_class,
|
|
mock_ibus_available,
|
|
mock_is_active,
|
|
mock_daemon,
|
|
):
|
|
"""If IBus runtime injection fails on Wayland, fallback to wtype should work."""
|
|
mock_ibus_instance = MagicMock()
|
|
mock_ibus_instance.inject_text.return_value = False
|
|
mock_ibus_class.return_value = mock_ibus_instance
|
|
|
|
self.mock_which.side_effect = lambda cmd: {
|
|
"wtype": "/usr/bin/wtype",
|
|
"xdotool": "/usr/bin/xdotool",
|
|
}.get(cmd)
|
|
|
|
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland"}):
|
|
injector = TextInjector()
|
|
if injector._ibus_init_thread is not None:
|
|
injector._ibus_init_thread.join(timeout=5)
|
|
self.assertEqual(injector.environment, DesktopEnvironment.WAYLAND_IBUS)
|
|
|
|
result = injector.inject_text("Hello via wayland fallback")
|
|
|
|
self.assertTrue(result)
|
|
self.assertEqual(injector.environment, DesktopEnvironment.WAYLAND)
|
|
self.assertEqual(injector.wayland_tool, "wtype")
|
|
|
|
def test_switch_from_x11_ibus_fails_without_xdotool(self):
|
|
"""X11 IBus fallback should fail cleanly when xdotool is unavailable."""
|
|
self.mock_which.return_value = None
|
|
injector = TextInjector.__new__(TextInjector)
|
|
injector._state_lock = threading.Lock()
|
|
injector.environment = DesktopEnvironment.X11_IBUS
|
|
|
|
result = injector._switch_to_non_ibus_backend()
|
|
|
|
self.assertFalse(result)
|
|
self.assertEqual(injector.environment, DesktopEnvironment.X11_IBUS)
|
|
|
|
def test_switch_from_wayland_ibus_prefers_running_ydotool(self):
|
|
"""Wayland IBus fallback should prefer ydotool when its daemon responds."""
|
|
self.mock_which.side_effect = lambda cmd: {"ydotool": "/usr/bin/ydotool"}.get(cmd)
|
|
injector = TextInjector.__new__(TextInjector)
|
|
injector._state_lock = threading.Lock()
|
|
injector.environment = DesktopEnvironment.WAYLAND_IBUS
|
|
injector.wayland_tool = None
|
|
|
|
result = injector._switch_to_non_ibus_backend()
|
|
|
|
self.assertTrue(result)
|
|
self.assertEqual(injector.environment, DesktopEnvironment.WAYLAND)
|
|
self.assertEqual(injector.wayland_tool, "ydotool")
|
|
self.mock_subprocess.assert_any_call(
|
|
["ydotool", "type", ""], check=True, stderr=subprocess.PIPE, timeout=2
|
|
)
|
|
|
|
def test_switch_from_wayland_ibus_falls_back_when_ydotool_daemon_down(self):
|
|
"""If ydotool exists but daemon fails, wtype should be selected next."""
|
|
self.mock_which.side_effect = lambda cmd: {
|
|
"ydotool": "/usr/bin/ydotool",
|
|
"wtype": "/usr/bin/wtype",
|
|
}.get(cmd)
|
|
self.mock_subprocess.side_effect = subprocess.CalledProcessError(1, ["ydotool"])
|
|
injector = TextInjector.__new__(TextInjector)
|
|
injector._state_lock = threading.Lock()
|
|
injector.environment = DesktopEnvironment.WAYLAND_IBUS
|
|
injector.wayland_tool = None
|
|
|
|
result = injector._switch_to_non_ibus_backend()
|
|
|
|
self.assertTrue(result)
|
|
self.assertEqual(injector.environment, DesktopEnvironment.WAYLAND)
|
|
self.assertEqual(injector.wayland_tool, "wtype")
|
|
|
|
def test_switch_from_wayland_ibus_uses_xwayland_as_last_tool(self):
|
|
"""Wayland IBus fallback should use xdotool/XWayland when native tools are absent."""
|
|
self.mock_which.side_effect = lambda cmd: {"xdotool": "/usr/bin/xdotool"}.get(cmd)
|
|
injector = TextInjector.__new__(TextInjector)
|
|
injector._state_lock = threading.Lock()
|
|
injector.environment = DesktopEnvironment.WAYLAND_IBUS
|
|
injector.wayland_tool = None
|
|
|
|
result = injector._switch_to_non_ibus_backend()
|
|
|
|
self.assertTrue(result)
|
|
self.assertEqual(injector.environment, DesktopEnvironment.WAYLAND_XDOTOOL)
|
|
|
|
def test_switch_from_wayland_ibus_fails_without_tools(self):
|
|
"""Wayland IBus fallback should fail when no non-IBus tools are available."""
|
|
self.mock_which.return_value = None
|
|
injector = TextInjector.__new__(TextInjector)
|
|
injector._state_lock = threading.Lock()
|
|
injector.environment = DesktopEnvironment.WAYLAND_IBUS
|
|
injector.wayland_tool = None
|
|
|
|
result = injector._switch_to_non_ibus_backend()
|
|
|
|
self.assertFalse(result)
|
|
self.assertEqual(injector.environment, DesktopEnvironment.WAYLAND_IBUS)
|
|
|
|
def test_switch_to_non_ibus_backend_noop_outside_ibus_mode(self):
|
|
"""Non-IBus modes are already on fallback backends."""
|
|
injector = TextInjector.__new__(TextInjector)
|
|
injector._state_lock = threading.Lock()
|
|
injector.environment = DesktopEnvironment.X11
|
|
|
|
self.assertTrue(injector._switch_to_non_ibus_backend())
|
|
self.assertEqual(injector.environment, DesktopEnvironment.X11)
|
|
|
|
@patch("vocalinux.text_injection.text_injector.is_ibus_daemon_running", return_value=True)
|
|
@patch("vocalinux.text_injection.text_injector.is_ibus_active_input_method", return_value=True)
|
|
@patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=True)
|
|
@patch("vocalinux.text_injection.text_injector.IBusTextInjector")
|
|
def test_successful_ibus_injection_still_copies_to_clipboard_when_enabled(
|
|
self,
|
|
mock_ibus_class,
|
|
mock_ibus_available,
|
|
mock_is_active,
|
|
mock_daemon,
|
|
):
|
|
"""Successful IBus injection should keep the existing optional clipboard copy path."""
|
|
mock_ibus_instance = MagicMock()
|
|
mock_ibus_instance.inject_text.return_value = True
|
|
mock_ibus_class.return_value = mock_ibus_instance
|
|
|
|
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "x11", "DISPLAY": ":0"}):
|
|
injector = TextInjector()
|
|
with patch.object(injector, "_should_copy_to_clipboard", return_value=True):
|
|
with patch("threading.Thread") as mock_thread:
|
|
result = injector.inject_text("copy me")
|
|
|
|
self.assertTrue(result)
|
|
mock_thread.assert_called_once()
|
|
mock_thread.return_value.start.assert_called_once()
|
|
|
|
@patch("vocalinux.text_injection.text_injector.is_ibus_daemon_running", return_value=True)
|
|
@patch("vocalinux.text_injection.text_injector.is_ibus_active_input_method", return_value=True)
|
|
@patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=True)
|
|
@patch("vocalinux.text_injection.text_injector.IBusTextInjector")
|
|
def test_uninitialized_ibus_injector_uses_non_ibus_fallback(
|
|
self,
|
|
mock_ibus_class,
|
|
mock_ibus_available,
|
|
mock_is_active,
|
|
mock_daemon,
|
|
):
|
|
"""If the IBus injector disappears at runtime, fallback backend should still run."""
|
|
mock_ibus_class.return_value = MagicMock()
|
|
|
|
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "x11", "DISPLAY": ":0"}):
|
|
injector = TextInjector()
|
|
injector._ibus_injector = None
|
|
result = injector.inject_text("fallback without ibus instance")
|
|
|
|
self.assertTrue(result)
|
|
self.assertEqual(injector.environment, DesktopEnvironment.X11)
|
|
|
|
|
|
class TestCompositorIBusBridging(unittest.TestCase):
|
|
"""Tests for skipping IBus on compositors that don't bridge it to native apps."""
|
|
|
|
def _bare_injector(self, environment=DesktopEnvironment.WAYLAND):
|
|
"""Build a TextInjector without running __init__ side effects."""
|
|
injector = TextInjector.__new__(TextInjector)
|
|
injector.environment = environment
|
|
return injector
|
|
|
|
def test_bridged_desktops_prefer_ibus(self):
|
|
"""GNOME/KDE/Cinnamon and unknown desktops should keep using IBus."""
|
|
injector = self._bare_injector()
|
|
for desktop in ("GNOME", "ubuntu:GNOME", "KDE", "X-Cinnamon", ""):
|
|
with patch.dict(
|
|
"os.environ",
|
|
{
|
|
"XDG_CURRENT_DESKTOP": desktop,
|
|
"XDG_SESSION_DESKTOP": desktop,
|
|
"DESKTOP_SESSION": desktop,
|
|
},
|
|
):
|
|
self.assertTrue(injector._wayland_compositor_bridges_ibus(), desktop)
|
|
|
|
def test_unbridged_compositors_skip_ibus(self):
|
|
"""COSMIC and wlroots compositors do not deliver IBus commits to native apps."""
|
|
injector = self._bare_injector()
|
|
for desktop in ("COSMIC", "sway", "Hyprland", "wayfire", "niri", "river"):
|
|
with patch.dict(
|
|
"os.environ",
|
|
{
|
|
"XDG_CURRENT_DESKTOP": desktop,
|
|
"XDG_SESSION_DESKTOP": desktop,
|
|
"DESKTOP_SESSION": desktop,
|
|
},
|
|
):
|
|
self.assertFalse(injector._wayland_compositor_bridges_ibus(), desktop)
|
|
|
|
def test_non_wayland_always_bridges(self):
|
|
"""On X11/XWayland IBus works via XIM regardless of desktop."""
|
|
injector = self._bare_injector(DesktopEnvironment.X11)
|
|
with patch.dict("os.environ", {"XDG_CURRENT_DESKTOP": "COSMIC"}):
|
|
self.assertTrue(injector._wayland_compositor_bridges_ibus())
|
|
|
|
@patch("os.path.exists", return_value=True)
|
|
def test_is_ydotoold_running_true_when_socket_exists(self, _mock_exists):
|
|
injector = self._bare_injector()
|
|
self.assertTrue(injector._is_ydotoold_running())
|
|
|
|
@patch("vocalinux.text_injection.text_injector.subprocess.run")
|
|
@patch("os.path.exists", return_value=False)
|
|
def test_is_ydotoold_running_true_when_process_found(self, _mock_exists, mock_run):
|
|
injector = self._bare_injector()
|
|
mock_run.return_value = MagicMock(returncode=0)
|
|
self.assertTrue(injector._is_ydotoold_running())
|
|
|
|
@patch("vocalinux.text_injection.text_injector.subprocess.run")
|
|
@patch("os.path.exists", return_value=False)
|
|
def test_is_ydotoold_running_false_when_absent(self, _mock_exists, mock_run):
|
|
injector = self._bare_injector()
|
|
mock_run.return_value = MagicMock(returncode=1)
|
|
self.assertFalse(injector._is_ydotoold_running())
|
|
|
|
@patch("vocalinux.text_injection.text_injector.is_ibus_daemon_running", return_value=True)
|
|
@patch("vocalinux.text_injection.text_injector.is_ibus_active_input_method", return_value=True)
|
|
@patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=True)
|
|
@patch("vocalinux.text_injection.text_injector.IBusTextInjector")
|
|
@patch("vocalinux.text_injection.text_injector.subprocess.run")
|
|
@patch("vocalinux.text_injection.text_injector.shutil.which")
|
|
def test_cosmic_skips_ibus_and_uses_wtype(
|
|
self,
|
|
mock_which,
|
|
mock_run,
|
|
mock_ibus_class,
|
|
mock_ibus_available,
|
|
mock_is_active,
|
|
mock_daemon,
|
|
):
|
|
"""On COSMIC, even with IBus active, injection must use wtype, not IBus."""
|
|
mock_which.side_effect = lambda cmd: "/usr/bin/wtype" if cmd == "wtype" else None
|
|
mock_run.return_value = MagicMock(returncode=0, stderr="")
|
|
|
|
with patch.dict(
|
|
"os.environ",
|
|
{
|
|
"XDG_SESSION_TYPE": "wayland",
|
|
"WAYLAND_DISPLAY": "w-1",
|
|
"XDG_CURRENT_DESKTOP": "COSMIC",
|
|
"XDG_SESSION_DESKTOP": "cosmic",
|
|
"DESKTOP_SESSION": "cosmic",
|
|
},
|
|
):
|
|
injector = TextInjector()
|
|
|
|
self.assertEqual(injector.environment, DesktopEnvironment.WAYLAND)
|
|
self.assertEqual(injector.wayland_tool, "wtype")
|
|
mock_ibus_class.assert_not_called()
|
|
|
|
def test_is_ydotoold_running_true_when_socket_env_set(self):
|
|
"""An explicit YDOTOOL_SOCKET pointing at an existing socket counts as running."""
|
|
injector = self._bare_injector()
|
|
with patch.dict("os.environ", {"YDOTOOL_SOCKET": "/run/user/1000/.ydotool_socket"}):
|
|
with patch("os.path.exists", return_value=True):
|
|
self.assertTrue(injector._is_ydotoold_running())
|
|
|
|
@patch("vocalinux.text_injection.text_injector.is_ibus_available", return_value=False)
|
|
@patch("vocalinux.text_injection.text_injector.shutil.which")
|
|
def test_ydotool_direct_mode_when_no_daemon_and_no_wtype(self, mock_which, _mock_ibus):
|
|
"""ydotool present without a daemon and no wtype falls back to ydotool direct mode."""
|
|
mock_which.side_effect = lambda cmd: "/usr/bin/ydotool" if cmd == "ydotool" else None
|
|
with patch.object(TextInjector, "_is_ydotoold_running", return_value=False):
|
|
with patch.dict(
|
|
"os.environ", {"XDG_SESSION_TYPE": "wayland", "WAYLAND_DISPLAY": "w-1"}
|
|
):
|
|
injector = TextInjector()
|
|
self.assertEqual(injector.wayland_tool, "ydotool")
|