"""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. What this file does NOT pin: the wtype-vs-ydotool preference when both are installed. That order is an implementation choice with Flatpak/KDE constraints behind it; the ladder contract here is that the selected direct-typing tool delivers without involving the portal or IBus on niri and without losing the clipboard contents. """ 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_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 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()