Files
voculinux/tests/test_injection_ladder.py
T
soubarnak e6f7938068 test(injection): pin fallback ladder per desktop class and niri first-run
Locks the ordered rungs without real compositors: portal first where a
backend implements it (GNOME), direct typing first on niri with IBus
excluded and no portal selected, deny demoting to direct typing with the
full dictation kept, terminal probe choosing Ctrl+Shift+V, held modifiers
delaying the XWayland paste, and clipboard manual paste as last resort.

USER_GUIDE states the niri first run (no prompt, typing goes direct,
never pin portal there) and the GNOME/KDE grant, persistence and deny
fallback under the renamed backend setting and override names.

Refs #31
2026-11-06 02:27:15 +05:30

318 lines
13 KiB
Python

"""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()