* fix(injection): skip unbridged IBus on KDE Plasma Wayland Do not auto-start XIM ibus-daemon on Wayland (circular viability check). On KDE, require KWin VirtualKeyboard enabled before selecting IBus. Fixes #574 * refactor(injection): tighten KDE VirtualKeyboard bridge check Ponytail cleanup: shorter logs and fewer failure branches, same behavior. * style: black-format ibus daemon Wayland test for CI * fix(injection): parse KWin VirtualKeyboard gdbus variants strictly Match <<true>>/<<false>> wrappers instead of bare substrings so ambiguous gdbus output cannot flip the #574 IBus bridge decision. Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Jatin K Malik <jatinkrmalik@users.noreply.github.com>
325 lines
12 KiB
Python
325 lines
12 KiB
Python
"""
|
|
Final comprehensive tests for IBus engine.
|
|
|
|
These tests exercise code paths to improve coverage without platform-specific issues.
|
|
"""
|
|
|
|
import subprocess
|
|
import sys
|
|
import unittest
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
# Mock GI imports before importing any vocalinux modules that use gi.
|
|
# On CI, real gi/IBus may be installed; importing ibus_engine without mocks
|
|
# would set IBUS_AVAILABLE=True and VocalinuxEngine would inherit from real
|
|
# IBus.Engine, causing hangs when super().__init__() tries to connect to IBus.
|
|
_mock_gi = MagicMock()
|
|
_mock_ibus = MagicMock()
|
|
_mock_glib = MagicMock()
|
|
_mock_gobject = MagicMock()
|
|
_mock_ibus.Engine = MagicMock
|
|
_mock_glib.MainLoop = MagicMock
|
|
|
|
_mock_gi_repo = MagicMock()
|
|
_mock_gi_repo.IBus = _mock_ibus
|
|
_mock_gi_repo.GLib = _mock_glib
|
|
_mock_gi_repo.GObject = _mock_gobject
|
|
|
|
sys.modules["gi"] = _mock_gi
|
|
sys.modules["gi.repository"] = _mock_gi_repo
|
|
|
|
# Force reload ibus_engine so it picks up mocks (in case it was already imported)
|
|
for _key in list(sys.modules.keys()):
|
|
if "vocalinux" in _key and "ibus_engine" in _key:
|
|
del sys.modules[_key]
|
|
|
|
|
|
class TestIBusSetupError(unittest.TestCase):
|
|
"""Tests for IBusSetupError exception."""
|
|
|
|
def test_ibus_setup_error_is_runtime_error(self):
|
|
"""Test that IBusSetupError is a RuntimeError."""
|
|
from vocalinux.text_injection.ibus_engine import IBusSetupError
|
|
|
|
self.assertTrue(issubclass(IBusSetupError, RuntimeError))
|
|
|
|
def test_ibus_setup_error_accepts_message(self):
|
|
"""Test that IBusSetupError accepts a message."""
|
|
from vocalinux.text_injection.ibus_engine import IBusSetupError
|
|
|
|
msg = "Test error message"
|
|
error = IBusSetupError(msg)
|
|
self.assertEqual(str(error), msg)
|
|
|
|
|
|
class TestEnsureIBusDir(unittest.TestCase):
|
|
"""Tests for ensure_ibus_dir function."""
|
|
|
|
@patch("pathlib.Path.mkdir")
|
|
@patch("pathlib.Path.chmod")
|
|
def test_ensure_ibus_dir_creates_and_secures(self, mock_chmod, mock_mkdir):
|
|
"""Test that ensure_ibus_dir creates directory with proper permissions."""
|
|
from vocalinux.text_injection.ibus_engine import ensure_ibus_dir
|
|
|
|
ensure_ibus_dir()
|
|
|
|
mock_mkdir.assert_called_once_with(parents=True, exist_ok=True)
|
|
mock_chmod.assert_called_once_with(0o700)
|
|
|
|
|
|
class TestIsIBusAvailable(unittest.TestCase):
|
|
"""Tests for is_ibus_available function."""
|
|
|
|
@patch("vocalinux.text_injection.ibus_engine.IBUS_AVAILABLE", True)
|
|
def test_is_ibus_available_true(self):
|
|
"""Test is_ibus_available when IBus is available."""
|
|
from vocalinux.text_injection.ibus_engine import is_ibus_available
|
|
|
|
result = is_ibus_available()
|
|
# Verify the function returns True when IBUS_AVAILABLE flag is True
|
|
self.assertTrue(result)
|
|
# Verify it's checking the flag, not just always returning a value
|
|
self.assertEqual(result, True)
|
|
|
|
@patch("vocalinux.text_injection.ibus_engine.IBUS_AVAILABLE", False)
|
|
def test_is_ibus_available_false(self):
|
|
"""Test is_ibus_available when IBus is not available."""
|
|
from vocalinux.text_injection.ibus_engine import is_ibus_available
|
|
|
|
result = is_ibus_available()
|
|
self.assertFalse(result)
|
|
|
|
|
|
class TestIsIBusDaemonRunning(unittest.TestCase):
|
|
"""Tests for is_ibus_daemon_running function."""
|
|
|
|
@patch("subprocess.run")
|
|
def test_is_ibus_daemon_running_true(self, mock_run):
|
|
"""Test is_ibus_daemon_running returns True when daemon is running."""
|
|
from vocalinux.text_injection.ibus_engine import is_ibus_daemon_running
|
|
|
|
mock_run.return_value.returncode = 0
|
|
result = is_ibus_daemon_running()
|
|
self.assertTrue(result)
|
|
|
|
@patch("subprocess.run")
|
|
def test_is_ibus_daemon_running_false(self, mock_run):
|
|
"""Test is_ibus_daemon_running returns False when daemon is not running."""
|
|
from vocalinux.text_injection.ibus_engine import is_ibus_daemon_running
|
|
|
|
mock_run.return_value.returncode = 1
|
|
result = is_ibus_daemon_running()
|
|
self.assertFalse(result)
|
|
|
|
@patch("subprocess.run", side_effect=FileNotFoundError("pgrep not found"))
|
|
def test_is_ibus_daemon_running_file_not_found(self, mock_run):
|
|
"""Test is_ibus_daemon_running handles FileNotFoundError."""
|
|
from vocalinux.text_injection.ibus_engine import is_ibus_daemon_running
|
|
|
|
result = is_ibus_daemon_running()
|
|
self.assertFalse(result)
|
|
|
|
|
|
class TestIsIBusActiveInputMethod(unittest.TestCase):
|
|
"""Tests for is_ibus_active_input_method function."""
|
|
|
|
@patch.dict("os.environ", {"GTK_IM_MODULE": "ibus"})
|
|
def test_is_ibus_active_gtk_im_module(self):
|
|
"""Test detection via GTK_IM_MODULE."""
|
|
from vocalinux.text_injection.ibus_engine import is_ibus_active_input_method
|
|
|
|
result = is_ibus_active_input_method()
|
|
self.assertTrue(result)
|
|
|
|
@patch.dict("os.environ", {"QT_IM_MODULE": "ibus"}, clear=True)
|
|
def test_is_ibus_active_qt_im_module(self):
|
|
"""Test detection via QT_IM_MODULE."""
|
|
from vocalinux.text_injection.ibus_engine import is_ibus_active_input_method
|
|
|
|
result = is_ibus_active_input_method()
|
|
self.assertTrue(result)
|
|
|
|
@patch.dict("os.environ", {"XMODIFIERS": "@im=ibus"}, clear=True)
|
|
def test_is_ibus_active_xmodifiers(self):
|
|
"""Test detection via XMODIFIERS."""
|
|
from vocalinux.text_injection.ibus_engine import is_ibus_active_input_method
|
|
|
|
result = is_ibus_active_input_method()
|
|
self.assertTrue(result)
|
|
|
|
@patch.dict("os.environ", {}, clear=True)
|
|
def test_is_ibus_active_not_active(self):
|
|
"""Test when IBus is not the active input method."""
|
|
from vocalinux.text_injection.ibus_engine import is_ibus_active_input_method
|
|
|
|
result = is_ibus_active_input_method()
|
|
self.assertFalse(result)
|
|
|
|
@patch("vocalinux.text_injection.ibus_engine.is_ibus_daemon_running", return_value=False)
|
|
@patch.dict("os.environ", {"XMODIFIERS": "@im=none"}, clear=True)
|
|
def test_xmodifiers_none_is_not_a_competing_input_method(self, mock_daemon):
|
|
"""Treat the XIM 'none' sentinel like an unset input method."""
|
|
from vocalinux.text_injection.ibus_engine import is_ibus_active_input_method
|
|
|
|
self.assertFalse(is_ibus_active_input_method())
|
|
mock_daemon.assert_called_once_with()
|
|
|
|
|
|
class TestStartIBusDaemon(unittest.TestCase):
|
|
"""Tests for start_ibus_daemon function."""
|
|
|
|
@patch("vocalinux.text_injection.ibus_engine.is_ibus_daemon_running")
|
|
def test_start_ibus_daemon_already_running(self, mock_is_running):
|
|
"""Test when daemon is already running."""
|
|
from vocalinux.text_injection.ibus_engine import start_ibus_daemon
|
|
|
|
mock_is_running.return_value = True
|
|
result = start_ibus_daemon()
|
|
self.assertTrue(result)
|
|
|
|
@patch("vocalinux.text_injection.ibus_engine.is_ibus_available")
|
|
@patch("vocalinux.text_injection.ibus_engine.is_ibus_daemon_running")
|
|
def test_start_ibus_daemon_not_available(self, mock_is_running, mock_available):
|
|
"""Test when IBus is not available."""
|
|
from vocalinux.text_injection.ibus_engine import start_ibus_daemon
|
|
|
|
mock_is_running.return_value = False
|
|
mock_available.return_value = False
|
|
result = start_ibus_daemon()
|
|
self.assertFalse(result)
|
|
|
|
@patch("vocalinux.text_injection.ibus_engine.subprocess.Popen")
|
|
@patch("vocalinux.text_injection.ibus_engine.is_ibus_available", return_value=True)
|
|
@patch("vocalinux.text_injection.ibus_engine.is_ibus_daemon_running", return_value=False)
|
|
def test_start_ibus_daemon_noop_on_wayland(self, mock_is_running, mock_available, mock_popen):
|
|
"""On Wayland, never spawn XIM ibus-daemon -x -d -r (#574)."""
|
|
from vocalinux.text_injection.ibus_engine import start_ibus_daemon
|
|
|
|
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland"}):
|
|
result = start_ibus_daemon()
|
|
|
|
self.assertFalse(result)
|
|
mock_popen.assert_not_called()
|
|
|
|
@patch("vocalinux.text_injection.ibus_engine.time.sleep")
|
|
@patch("vocalinux.text_injection.ibus_engine.subprocess.Popen")
|
|
@patch("vocalinux.text_injection.ibus_engine.is_ibus_available", return_value=True)
|
|
def test_start_ibus_daemon_still_starts_on_x11(self, mock_available, mock_popen, mock_sleep):
|
|
"""X11 sessions may still auto-start ibus-daemon with XIM flags."""
|
|
from vocalinux.text_injection.ibus_engine import start_ibus_daemon
|
|
|
|
with patch(
|
|
"vocalinux.text_injection.ibus_engine.is_ibus_daemon_running",
|
|
side_effect=[False, True],
|
|
):
|
|
with patch.dict("os.environ", {"XDG_SESSION_TYPE": "x11"}, clear=False):
|
|
result = start_ibus_daemon()
|
|
|
|
self.assertTrue(result)
|
|
mock_popen.assert_called_once()
|
|
self.assertEqual(mock_popen.call_args[0][0], ["ibus-daemon", "-x", "-d", "-r"])
|
|
|
|
|
|
class TestIsEngineActive(unittest.TestCase):
|
|
"""Tests for is_engine_active function."""
|
|
|
|
@patch("subprocess.run")
|
|
def test_is_engine_active_true(self, mock_run):
|
|
"""Test when engine is active."""
|
|
from vocalinux.text_injection.ibus_engine import ENGINE_NAME, is_engine_active
|
|
|
|
mock_run.return_value.returncode = 0
|
|
mock_run.return_value.stdout = ENGINE_NAME
|
|
result = is_engine_active()
|
|
self.assertTrue(result)
|
|
|
|
@patch("subprocess.run")
|
|
def test_is_engine_active_false(self, mock_run):
|
|
"""Test when engine is not active."""
|
|
from vocalinux.text_injection.ibus_engine import is_engine_active
|
|
|
|
mock_run.return_value.returncode = 0
|
|
mock_run.return_value.stdout = "other"
|
|
result = is_engine_active()
|
|
self.assertFalse(result)
|
|
|
|
|
|
class TestGetCurrentEngine(unittest.TestCase):
|
|
"""Tests for get_current_engine function."""
|
|
|
|
@patch("subprocess.run")
|
|
def test_get_current_engine_success(self, mock_run):
|
|
"""Test successfully retrieving current engine."""
|
|
from vocalinux.text_injection.ibus_engine import get_current_engine
|
|
|
|
mock_run.return_value.returncode = 0
|
|
mock_run.return_value.stdout = "test-engine\n"
|
|
result = get_current_engine()
|
|
self.assertEqual(result, "test-engine")
|
|
|
|
@patch("subprocess.run", side_effect=FileNotFoundError("ibus not found"))
|
|
def test_get_current_engine_not_found(self, mock_run):
|
|
"""Test when ibus command is not found."""
|
|
from vocalinux.text_injection.ibus_engine import get_current_engine
|
|
|
|
result = get_current_engine()
|
|
self.assertIsNone(result)
|
|
|
|
|
|
class TestSwitchEngine(unittest.TestCase):
|
|
"""Tests for switch_engine function."""
|
|
|
|
@patch("vocalinux.text_injection.ibus_engine.get_current_engine")
|
|
@patch("time.sleep")
|
|
@patch("subprocess.run")
|
|
def test_switch_engine_success(self, mock_run, mock_sleep, mock_get_engine):
|
|
"""Test successfully switching engines."""
|
|
from vocalinux.text_injection.ibus_engine import switch_engine
|
|
|
|
mock_get_engine.return_value = "vocalinux"
|
|
result = switch_engine("vocalinux")
|
|
self.assertTrue(result)
|
|
|
|
@patch("vocalinux.text_injection.ibus_engine.get_current_engine")
|
|
@patch("time.sleep")
|
|
@patch("subprocess.run")
|
|
def test_switch_engine_failure(self, mock_run, mock_sleep, mock_get_engine):
|
|
"""Test when engine switch fails."""
|
|
from vocalinux.text_injection.ibus_engine import switch_engine
|
|
|
|
mock_get_engine.return_value = "other"
|
|
result = switch_engine("vocalinux")
|
|
self.assertFalse(result)
|
|
|
|
|
|
class TestIsEngineProcessRunning(unittest.TestCase):
|
|
"""Tests for is_engine_process_running function."""
|
|
|
|
@patch("pathlib.Path.exists")
|
|
def test_is_engine_process_running_no_pid_file(self, mock_exists):
|
|
"""Test when PID file doesn't exist."""
|
|
from vocalinux.text_injection.ibus_engine import is_engine_process_running
|
|
|
|
mock_exists.return_value = False
|
|
result = is_engine_process_running()
|
|
self.assertFalse(result)
|
|
|
|
|
|
class TestStopEngineProcess(unittest.TestCase):
|
|
"""Tests for stop_engine_process function."""
|
|
|
|
@patch("pathlib.Path.exists")
|
|
def test_stop_engine_process_no_pid_file(self, mock_exists):
|
|
"""Test when no PID file exists."""
|
|
from vocalinux.text_injection.ibus_engine import stop_engine_process
|
|
|
|
mock_exists.return_value = False
|
|
# Should not raise any exception
|
|
result = stop_engine_process()
|
|
# Verify the function handles missing PID file gracefully
|
|
self.assertIsNone(result)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|