Merge feature/31 into integration/spec16 (ticket #31: injection ladder tests and first-dictation docs)

This commit is contained in:
soubarnak
2026-11-06 03:04:14 +05:30
2 changed files with 336 additions and 0 deletions
+19
View File
@@ -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.
+317
View File
@@ -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()