Autodetection on niri now never probes or selects the RemoteDesktop portal, and the ibus-wayland bridge exception no longer reopens IBus there. Key events after startup resolve wtype before ydotool, matching _check_dependencies and the decided ladder. conftest scrubs NIRI_SOCKET so tests modelling other desktops are not affected when run inside a niri session.
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()
|