"""Ladder-level behaviour tests for the Wayland text-injection fallback. Ticket #31: the decided injection behaviour becomes pinned, observable fact. These tests lock the fallback order per desktop class without real compositors — portal first only where a backend implements it, then direct typing, then the virtual-input tool, then XWayland-only injection, then manual paste — including IBus exclusion on niri, terminal paste-shortcut selection, and waiting for held modifiers to release. Seams under test (public boundaries, no internals): 1. Backend selection — constructing ``TextInjector()`` with mocked ``shutil.which``/``subprocess``/portal/IBus probes and a per-test desktop environment. Catches: niri gaining an IBus or portal dependency, a desktop class losing its portal-first rung. 2. Runtime demotion — ``_inject_with_wayland_tool`` with a failing portal. Catches: a portal denial losing the dictation instead of demoting. 3. Paste delivery — ``_inject_via_clipboard_paste`` with the real terminal probe and ``_inject_with_xdotool`` with held modifiers. Catches: a terminal losing its Shift chord, a held push-to-talk modifier corrupting the paste. The decided rung order pinned here (spec #16, ticket #31) is: the RemoteDesktop portal first where a backend implements it, then direct keysym typing (wtype), then the virtual-input tool (ydotool, which needs a ready ydotoold), then XWayland-only injection (xdotool), then manual paste. KDE follows the same order: a working portal leads there too, and a denial demotes to direct typing for the run. """ import subprocess import threading from typing import Any, Dict, Optional from unittest.mock import MagicMock, patch import pytest from vocalinux.text_injection.remote_desktop_portal import RemoteDesktopPortalError from vocalinux.text_injection.text_injector import DesktopEnvironment, TextInjector _TI = "vocalinux.text_injection.text_injector" def _stub_portal(available: bool) -> MagicMock: """A RemoteDesktopPortal stand-in whose probe reports ``available``.""" instance = MagicMock() instance.probe.return_value = available cls = MagicMock() cls.supported.return_value = True cls.return_value = instance return cls def _selecting_injector( monkeypatch: pytest.MonkeyPatch, *, desktop: str, tools: Dict[str, Optional[str]], portal_available: bool, ibus_available: bool = False, ibus_bridge_running: bool = False, ydotool_ready: bool = True, ) -> TextInjector: """Construct a TextInjector on a mocked Wayland session for ``desktop``. No compositor is contacted: tool presence, the portal probe, the IBus daemon state and the uinput readiness are all stubbed. """ monkeypatch.setenv("XDG_SESSION_TYPE", "wayland") monkeypatch.setenv("WAYLAND_DISPLAY", "wayland-0") monkeypatch.setenv("XDG_CURRENT_DESKTOP", desktop) monkeypatch.setenv("XDG_SESSION_DESKTOP", desktop) monkeypatch.setenv("DESKTOP_SESSION", desktop) monkeypatch.setenv("KDE_FULL_SESSION", "") for var in ( "FLATPAK_ID", "SNAP", "VOCULINUX_FORCE_BACKEND", "GTK_IM_MODULE", "QT_IM_MODULE", "XMODIFIERS", ): monkeypatch.delenv(var, raising=False) monkeypatch.setattr("shutil.which", lambda cmd: tools.get(cmd)) monkeypatch.setattr(f"{_TI}.is_ibus_available", lambda: ibus_available) monkeypatch.setattr(f"{_TI}.is_ibus_active_input_method", lambda: False) monkeypatch.setattr(f"{_TI}.is_ibus_daemon_running", lambda: True) monkeypatch.setattr( TextInjector, "_ibus_wayland_bridge_running", staticmethod(lambda: ibus_bridge_running), ) monkeypatch.setattr(TextInjector, "_ensure_ydotoold", lambda self: ydotool_ready) monkeypatch.setattr(f"{_TI}.RemoteDesktopPortal", _stub_portal(portal_available)) run = MagicMock() run.return_value.returncode = 0 run.return_value.stderr = "" monkeypatch.setattr("subprocess.run", run) monkeypatch.setattr("time.sleep", lambda _seconds: None) return TextInjector() def _bare_wayland_injector(**attrs: Any) -> Any: """A TextInjector without __init__, carrying only what delivery needs.""" injector = TextInjector.__new__(TextInjector) injector._state_lock = threading.Lock() injector._abort_injections = threading.Event() injector._ibus_injector = None injector._ibus_ready = False injector._ibus_init_failed = False injector._ibus_init_thread = None injector._portal = None injector._clipboard_tool_health = {} injector._clipboard_timeout = 0.35 injector._clipboard_restore_generation = 0 injector._clipboard_restore_target = None injector._log_current_window_info = MagicMock() injector._wait_for_modifiers_released = MagicMock() for key, value in attrs.items(): setattr(injector, key, value) return injector class TestNiriLadder: """On niri there is no portal backend and IBus cannot reach apps.""" def test_niri_skips_ibus_even_with_daemon_running( self, monkeypatch: pytest.MonkeyPatch ) -> None: """An IBus daemon on niri must not capture dictation (#31). IBus reports the commit as delivered while native niri clients receive nothing, so the injector must not construct its engine even when IBus looks fully available. """ with patch(f"{_TI}.IBusTextInjector") as mock_ibus_class: injector = _selecting_injector( monkeypatch, desktop="niri", tools={"wtype": "/usr/bin/wtype"}, portal_available=False, ibus_available=True, ) mock_ibus_class.assert_not_called() assert injector._ibus_injector is None assert injector.environment == DesktopEnvironment.WAYLAND assert injector.wayland_tool == "wtype" def test_niri_never_selects_portal_without_backend( self, monkeypatch: pytest.MonkeyPatch ) -> None: """No RemoteDesktop backend on niri means no portal and no prompt.""" injector = _selecting_injector( monkeypatch, desktop="niri", tools={"wtype": "/usr/bin/wtype"}, portal_available=False, ibus_available=True, ) assert injector.wayland_tool == "wtype" assert injector._portal is None def test_niri_first_rung_is_direct_typing_without_clipboard( self, monkeypatch: pytest.MonkeyPatch ) -> None: """The effective first rung on niri types directly, clipboard-free.""" injector = _bare_wayland_injector( environment=DesktopEnvironment.WAYLAND, wayland_tool="wtype" ) runs = [] def fake_run(cmd: Any, **kwargs: Any) -> MagicMock: runs.append(cmd) return MagicMock(returncode=0, stderr="") monkeypatch.setattr("subprocess.run", fake_run) with ( patch.object(injector, "_copy_to_clipboard", side_effect=AssertionError("no copy")), patch.object(injector, "_read_clipboard", side_effect=AssertionError("no read")), patch.object( injector, "_inject_via_clipboard_paste", side_effect=AssertionError("no paste"), ), ): injector._inject_with_wayland_tool("hi") assert runs == [["wtype", "--", "hi"]] class TestPortalFirstWhereImplemented: """GNOME/KDE implement RemoteDesktop, so the portal leads there.""" def test_gnome_selects_portal_where_backend_exists( self, monkeypatch: pytest.MonkeyPatch ) -> None: injector = _selecting_injector( monkeypatch, desktop="GNOME", tools={"wtype": "/usr/bin/wtype", "ydotool": "/usr/bin/ydotool"}, portal_available=True, ) assert injector.wayland_tool == "portal" assert injector.environment == DesktopEnvironment.WAYLAND def test_kde_selects_portal_where_backend_exists(self, monkeypatch: pytest.MonkeyPatch) -> None: """KDE follows the same order: a working portal leads (#31). A present ydotool must not outrank an available portal, even with its daemon ready; the portal denial path below is what demotes. """ injector = _selecting_injector( monkeypatch, desktop="KDE", tools={"wtype": "/usr/bin/wtype", "ydotool": "/usr/bin/ydotool"}, portal_available=True, ydotool_ready=True, ) assert injector.wayland_tool == "portal" assert injector.environment == DesktopEnvironment.WAYLAND def test_portal_deny_falls_back_without_losing_dictation( self, monkeypatch: pytest.MonkeyPatch ) -> None: """Saying no to the portal costs a fallback, not the dictation. The denied session is not retried within the run; the pending text goes through the next rung whole. """ injector = _bare_wayland_injector( environment=DesktopEnvironment.WAYLAND, wayland_tool="portal" ) portal = MagicMock() portal.inject_text.side_effect = RemoteDesktopPortalError("denied by user", delivered=0) injector._portal = portal monkeypatch.setattr( "shutil.which", lambda cmd: "/usr/bin/wtype" if cmd == "wtype" else None ) runs = [] def fake_run(cmd: Any, **kwargs: Any) -> MagicMock: runs.append(cmd) return MagicMock(returncode=0, stderr="") monkeypatch.setattr("subprocess.run", fake_run) injector._inject_with_wayland_tool("hello") portal.inject_text.assert_called_once_with("hello") assert injector.wayland_tool == "wtype" assert runs == [["wtype", "--", "hello"]] class TestDirectTypingBeforeVirtualInput: """Without a portal, direct keysym typing outranks the virtual-input tool.""" def test_no_portal_prefers_wtype_when_both_installed( self, monkeypatch: pytest.MonkeyPatch ) -> None: """The decided second rung is wtype even with ydotool ready (#31).""" injector = _selecting_injector( monkeypatch, desktop="GNOME", tools={"wtype": "/usr/bin/wtype", "ydotool": "/usr/bin/ydotool"}, portal_available=False, ydotool_ready=True, ) assert injector.wayland_tool == "wtype" assert injector.environment == DesktopEnvironment.WAYLAND def test_no_portal_no_wtype_uses_ready_ydotool(self, monkeypatch: pytest.MonkeyPatch) -> None: injector = _selecting_injector( monkeypatch, desktop="GNOME", tools={"ydotool": "/usr/bin/ydotool"}, portal_available=False, ydotool_ready=True, ) assert injector.wayland_tool == "ydotool" def test_no_portal_daemonless_ydotool_without_wtype_still_types( self, monkeypatch: pytest.MonkeyPatch ) -> None: """A daemonless ydotool is picked with a warning when wtype is absent.""" injector = _selecting_injector( monkeypatch, desktop="GNOME", tools={"ydotool": "/usr/bin/ydotool"}, portal_available=False, ydotool_ready=False, ) assert injector.wayland_tool == "ydotool" def test_portal_deny_demotes_to_wtype_when_both_installed( self, monkeypatch: pytest.MonkeyPatch ) -> None: """A denied portal demotes to direct typing, not the virtual tool.""" injector = _bare_wayland_injector( environment=DesktopEnvironment.WAYLAND, wayland_tool="portal" ) portal = MagicMock() portal.inject_text.side_effect = RemoteDesktopPortalError("denied by user", delivered=0) injector._portal = portal monkeypatch.setattr( "shutil.which", lambda cmd: f"/usr/bin/{cmd}" if cmd in ("wtype", "ydotool") else None, ) monkeypatch.setattr(TextInjector, "_ensure_ydotoold", lambda self: True) runs = [] def fake_run(cmd: Any, **kwargs: Any) -> MagicMock: runs.append(cmd) return MagicMock(returncode=0, stderr="") monkeypatch.setattr("subprocess.run", fake_run) injector._inject_with_wayland_tool("hello") portal.inject_text.assert_called_once_with("hello") assert injector.wayland_tool == "wtype" assert runs == [["wtype", "--", "hello"]] def test_wtype_runtime_failure_demotes_to_ydotool( self, monkeypatch: pytest.MonkeyPatch ) -> None: """A wtype failure at injection time falls to ydotool whole (#31).""" injector = _bare_wayland_injector( environment=DesktopEnvironment.WAYLAND, wayland_tool="wtype" ) injector._session_environment = DesktopEnvironment.WAYLAND attempts = [] pastes = [] def fake_run(cmd: Any, **kwargs: Any) -> MagicMock: attempts.append(cmd) if cmd[0] == "wtype": raise subprocess.CalledProcessError(1, cmd, stderr="boom") return MagicMock(returncode=0, stderr="") monkeypatch.setattr("subprocess.run", fake_run) monkeypatch.setattr( "shutil.which", lambda cmd: f"/usr/bin/{cmd}" if cmd in ("wtype", "ydotool") else None, ) monkeypatch.setattr(TextInjector, "_ensure_ydotoold", lambda self: True) with ( patch.object( injector, "_inject_via_clipboard_paste", side_effect=lambda text, **kwargs: pastes.append(text) or True, ), patch.object(injector, "_should_copy_to_clipboard", return_value=False), ): assert injector.inject_text("hi") is True assert injector.wayland_tool == "ydotool" assert [cmd for cmd in attempts if cmd[0] == "wtype"] == [["wtype", "--", "hi"]] assert pastes == ["hi"] class TestTerminalAndModifierGating: """Terminals get the terminal chord; held modifiers wait first.""" def test_terminal_probe_selects_terminal_chord_end_to_end( self, monkeypatch: pytest.MonkeyPatch ) -> None: """Auto-detect on a focused terminal must send Ctrl+Shift+V (#31).""" injector = _bare_wayland_injector( environment=DesktopEnvironment.WAYLAND, wayland_tool="wtype" ) monkeypatch.setattr("threading.Thread", MagicMock()) paste_cmds = [] real_run = MagicMock(returncode=0, stdout="", stderr="") def fake_run(cmd: Any, **kwargs: Any) -> MagicMock: paste_cmds.append(cmd) return real_run monkeypatch.setattr("subprocess.run", fake_run) with ( patch.object(injector, "_paste_shortcut_preference", return_value="auto"), patch( f"{_TI}.is_focused_window_terminal", return_value=True, ), patch.object(injector, "_copy_to_clipboard", return_value=True), patch.object(injector, "_read_clipboard", return_value="old"), ): assert injector._inject_via_clipboard_paste("ls") is True assert ["wtype", "-M", "ctrl", "-M", "shift", "v"] in paste_cmds def test_held_modifier_delays_xwayland_paste_until_release( self, monkeypatch: pytest.MonkeyPatch ) -> None: """A held push-to-talk modifier must not rewrite the paste chord.""" injector = _bare_wayland_injector(environment=DesktopEnvironment.WAYLAND_XDOTOOL) injector._wait_for_modifiers_released = TextInjector._wait_for_modifiers_released.__get__( injector, TextInjector ) states = [{56}, {56}, set()] polls = [] pastes = [] def fake_held() -> set: held = states[min(len(polls), len(states) - 1)] polls.append(set(held)) return set(held) monkeypatch.setattr(injector, "_held_modifier_keycodes", fake_held) monkeypatch.setattr("time.sleep", lambda _seconds: None) with ( patch.object(injector, "_clipboard_holds_non_text", return_value=False), patch.object( injector, "_inject_via_clipboard_paste", side_effect=lambda text, **kwargs: pastes.append(text) or True, ), ): injector._inject_with_xdotool("ls") assert len(polls) == 3 assert pastes == ["ls"] def test_all_tools_down_leaves_dictation_on_clipboard(self) -> None: """Manual paste is the last resort: text is kept, never dropped.""" injector = _bare_wayland_injector(environment=DesktopEnvironment.X11) injector._session_environment = DesktopEnvironment.X11 with ( patch.object(injector, "_paste_long_text_enabled", return_value=False), patch.object(injector, "_clipboard_holds_non_text", return_value=False), patch.object(injector, "_should_copy_to_clipboard", return_value=False), patch( "vocalinux.text_injection.text_injector.subprocess.run", side_effect=subprocess.CalledProcessError(1, "xdotool"), ), patch( "vocalinux.text_injection.text_injector.time.sleep", return_value=None, ), patch.object(injector, "_copy_to_clipboard", return_value=True) as mock_copy, patch.object(injector, "_show_clipboard_fallback_notification") as mock_note, ): assert injector.inject_text("hello") is False mock_copy.assert_called_once_with("hello") mock_note.assert_called_once_with()