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.
542 lines
22 KiB
Python
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()
|