diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 1bb4502..42660b2 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -371,3 +371,22 @@ most wlroots compositors do not). `ydotool` also needs a usable `/dev/uinput` and a working `ydotoold` setup. `xdotool` types into X11/XWayland windows, not native Wayland windows. A live test in the target application is still the final confirmation that text is delivered. + +### First dictation on niri, GNOME, and KDE + +**niri.** There is no permission prompt on niri: no portal backend implements +`org.freedesktop.portal.RemoteDesktop` there, so the portal is never +selected and the effective first rung is direct typing. Expect your first +dictation to land at the cursor with nothing to approve and nothing to deny. +Do not pin `portal` in `text_injection.backend` (or `VOCULINUX_FORCE_BACKEND`) +as a niri recommendation — there is nothing for it to talk to. IBus stays +excluded on niri as well: it would report the text as delivered while native +windows silently receive nothing. + +**GNOME / KDE Plasma.** The portal leads where a backend implements it. +Nothing asks at startup: the handshake stays lazy and the compositor asks +once, for keyboard access only, at the first actual dictation. A granted +session keeps its restore token so later launches skip the prompt where the +desktop supports persistence. Denying closes that session and is not retried +within the run; the pending dictation is still typed through the next +fallback rung, so saying no costs a fallback rather than the dictation. diff --git a/tests/test_injection_ladder.py b/tests/test_injection_ladder.py new file mode 100644 index 0000000..3b3f5ef --- /dev/null +++ b/tests/test_injection_ladder.py @@ -0,0 +1,317 @@ +"""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()