Files
voculinux/tests/test_injection_ladder.py
T
xavierk 25c5132b03
CI / security (pull_request) Failing after 1m19s
CI / lint (pull_request) Failing after 22s
CI / ai-review (pull_request) Successful in 8s
docs(models): mark ladder thresholds provisional until ticket 19 is recorded
The rung cutoffs cite ticket 19's transcribe peaks, but ticket 19 is
still open and its measurement is not in this repository. The code and
test docstrings now say the numbers are provisional calibration to
replace, rather than evidence. Also formats the niri guard tests to the
project line length.
2026-11-06 18:29:15 +05:30

542 lines
22 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.
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,
_is_niri_session,
)
_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 TestNiriGuard:
"""Autodetection on niri never selects the portal or IBus (spec #16, c4).
The guard is explicit, so it does not depend on the portal probe failing
or on the ibus-wayland bridge being absent. Explicit user pins stay
honoured as escape hatches and are covered elsewhere.
"""
def test_niri_never_selects_portal_even_when_a_backend_answers(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A RemoteDesktop backend that answers on niri must still be skipped."""
injector = _selecting_injector(
monkeypatch,
desktop="niri",
tools={"wtype": "/usr/bin/wtype"},
portal_available=True,
)
assert injector.wayland_tool == "wtype"
assert injector._portal is None
def test_niri_never_selects_ibus_even_with_ibus_wayland_bridge_running(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The ibus-wayland bridge exception does not reopen IBus on niri."""
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,
ibus_bridge_running=True,
)
mock_ibus_class.assert_not_called()
assert injector._ibus_injector is None
assert injector.wayland_tool == "wtype"
def test_bridge_check_is_false_on_niri_even_with_bridge_running(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The compositor-bridge predicate itself answers no on niri."""
monkeypatch.setenv("XDG_CURRENT_DESKTOP", "niri")
monkeypatch.setattr(
TextInjector, "_ibus_wayland_bridge_running", staticmethod(lambda: True)
)
injector = _bare_wayland_injector(environment=DesktopEnvironment.WAYLAND)
assert injector._wayland_compositor_bridges_ibus() is False
def test_niri_is_recognised_from_its_session_socket(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""NIRI_SOCKET identifies niri even when the desktop variable is unset."""
for var in ("XDG_CURRENT_DESKTOP", "XDG_SESSION_DESKTOP", "DESKTOP_SESSION"):
monkeypatch.delenv(var, raising=False)
monkeypatch.setenv("NIRI_SOCKET", "/run/user/1000/niri.wayland-1.1.sock")
assert _is_niri_session() is True
def test_other_desktops_are_not_niri(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""A GNOME session keeps the portal and IBus eligible."""
monkeypatch.delenv("NIRI_SOCKET", raising=False)
monkeypatch.setenv("XDG_CURRENT_DESKTOP", "GNOME")
assert _is_niri_session() is False
class TestKeyEventToolOrder:
"""Key events after startup follow the same rung order as autodetection.
Direct typing (wtype) outranks the virtual-input tool (ydotool) here too,
so BackSpace and IBus shortcut routing never silently switch tools.
"""
@staticmethod
def _tools(monkeypatch: pytest.MonkeyPatch, names: tuple) -> None:
monkeypatch.setattr("shutil.which", lambda cmd: f"/usr/bin/{cmd}" if cmd in names else None)
def test_wtype_outranks_a_ready_ydotool(self, monkeypatch: pytest.MonkeyPatch) -> None:
self._tools(monkeypatch, ("wtype", "ydotool"))
ensure = MagicMock(return_value=True)
monkeypatch.setattr(TextInjector, "_ensure_ydotoold", lambda self: ensure())
injector = _bare_wayland_injector(environment=DesktopEnvironment.WAYLAND)
assert injector._resolve_wayland_key_tool() == "wtype"
assert injector.wayland_tool == "wtype"
ensure.assert_not_called()
def test_ydotool_is_used_only_when_wtype_is_absent(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
self._tools(monkeypatch, ("ydotool",))
monkeypatch.setattr(TextInjector, "_ensure_ydotoold", lambda self: True)
injector = _bare_wayland_injector(environment=DesktopEnvironment.WAYLAND)
assert injector._resolve_wayland_key_tool() == "ydotool"
def test_no_key_tool_when_neither_is_installed(self, monkeypatch: pytest.MonkeyPatch) -> None:
self._tools(monkeypatch, ())
injector = _bare_wayland_injector(environment=DesktopEnvironment.WAYLAND)
assert injector._resolve_wayland_key_tool() is None
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()