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
This commit is contained in:
@@ -369,3 +369,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.
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user