* fix(snap): build and verify payload from locked python inputs * docs(snap): document locked inputs and artifact verification * test(clipboard): isolate restore workers between tests * test(packaging): tighten isolation and clarify snap audit guarantees * fix(ci): run snap recipe tests on snap-only changes A change to snap/snapcraft.yaml or snap/local/smoke-test.sh only matched the snap filter, so the Python tests that guard the recipe (including the portable whisper.cpp flags from #957) and the smoke script were skipped. Match all of snap/ in the python filter, as scripts/ already is. * docs(snap): use a version placeholder in the smoke-test example --------- Co-authored-by: Jatin <jatinkrmalik@gmail.com>
297 lines
14 KiB
Python
297 lines
14 KiB
Python
"""Tests for clipboard paste-shortcut preference and ydotool chords."""
|
|
|
|
import json
|
|
import os
|
|
import tempfile
|
|
import threading
|
|
import unittest
|
|
from typing import Any, cast
|
|
from unittest.mock import MagicMock, mock_open, patch
|
|
|
|
from vocalinux.ui.config_manager import (
|
|
DEFAULT_PASTE_SHORTCUT,
|
|
ConfigManager,
|
|
normalize_paste_shortcut,
|
|
)
|
|
|
|
|
|
def _make_injector() -> Any:
|
|
from vocalinux.text_injection.text_injector import DesktopEnvironment, TextInjector
|
|
|
|
obj = cast(Any, TextInjector.__new__(TextInjector))
|
|
obj._ibus_injector = None
|
|
obj.environment = DesktopEnvironment.WAYLAND
|
|
obj._session_environment = DesktopEnvironment.WAYLAND
|
|
obj._ibus_ready = False
|
|
obj._ibus_init_failed = False
|
|
obj._ibus_init_thread = None
|
|
obj._state_lock = threading.Lock()
|
|
obj._clipboard_tool_health = {}
|
|
obj._clipboard_timeout = 0.35
|
|
obj._clipboard_restore_generation = 0
|
|
obj._clipboard_restore_target = None
|
|
obj._should_copy_to_clipboard = MagicMock(return_value=False)
|
|
return obj
|
|
|
|
|
|
class TestNormalizePasteShortcut(unittest.TestCase):
|
|
"""Unknown or missing values fall back to auto-detect."""
|
|
|
|
def test_known_ids(self):
|
|
self.assertEqual(normalize_paste_shortcut("auto"), "auto")
|
|
self.assertEqual(normalize_paste_shortcut("ctrl+v"), "ctrl+v")
|
|
self.assertEqual(normalize_paste_shortcut("ctrl+shift+v"), "ctrl+shift+v")
|
|
|
|
def test_unknown_values_become_auto(self):
|
|
self.assertEqual(normalize_paste_shortcut(None), DEFAULT_PASTE_SHORTCUT)
|
|
self.assertEqual(normalize_paste_shortcut(""), DEFAULT_PASTE_SHORTCUT)
|
|
self.assertEqual(normalize_paste_shortcut("enter"), DEFAULT_PASTE_SHORTCUT)
|
|
|
|
def test_config_manager_round_trip(self):
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
config_file = os.path.join(temp_dir, "config.json")
|
|
with (
|
|
patch("vocalinux.ui.config_manager.CONFIG_DIR", temp_dir),
|
|
patch("vocalinux.ui.config_manager.CONFIG_FILE", config_file),
|
|
):
|
|
manager = ConfigManager()
|
|
self.assertEqual(manager.get_paste_shortcut(), "auto")
|
|
manager.set_paste_shortcut("ctrl+shift+v")
|
|
manager.save_config()
|
|
reloaded = ConfigManager()
|
|
self.assertEqual(reloaded.get_paste_shortcut(), "ctrl+shift+v")
|
|
manager.set_paste_shortcut("not-a-shortcut")
|
|
self.assertEqual(manager.get_paste_shortcut(), "auto")
|
|
|
|
|
|
class TestShouldUseTerminalPaste(unittest.TestCase):
|
|
"""Settings override wins; auto-detect is used only for the default."""
|
|
|
|
def test_forced_terminal_shortcut(self):
|
|
obj = _make_injector()
|
|
with patch.object(obj, "_paste_shortcut_preference", return_value="ctrl+shift+v"):
|
|
with patch(
|
|
"vocalinux.text_injection.text_injector.is_focused_window_terminal",
|
|
return_value=False,
|
|
) as mock_detect:
|
|
self.assertTrue(obj._should_use_terminal_paste())
|
|
mock_detect.assert_not_called()
|
|
|
|
def test_forced_standard_shortcut(self):
|
|
obj = _make_injector()
|
|
with patch.object(obj, "_paste_shortcut_preference", return_value="ctrl+v"):
|
|
with patch(
|
|
"vocalinux.text_injection.text_injector.is_focused_window_terminal",
|
|
return_value=True,
|
|
) as mock_detect:
|
|
self.assertFalse(obj._should_use_terminal_paste())
|
|
mock_detect.assert_not_called()
|
|
|
|
def test_auto_uses_focused_window(self):
|
|
obj = _make_injector()
|
|
with patch.object(obj, "_paste_shortcut_preference", return_value="auto"):
|
|
with patch(
|
|
"vocalinux.text_injection.text_injector.is_focused_window_terminal",
|
|
return_value=True,
|
|
):
|
|
self.assertTrue(obj._should_use_terminal_paste())
|
|
|
|
def test_reads_config_from_disk(self):
|
|
obj = _make_injector()
|
|
with patch("vocalinux.text_injection.text_injector.config_dir", return_value="/tmp/cfg"):
|
|
with patch("os.path.exists", return_value=True):
|
|
with patch(
|
|
"builtins.open",
|
|
mock_open(
|
|
read_data=json.dumps({"text_injection": {"paste_shortcut": "ctrl+shift+v"}})
|
|
),
|
|
):
|
|
self.assertEqual(obj._paste_shortcut_preference(), "ctrl+shift+v")
|
|
|
|
|
|
_NEO_CHAR_MAP = {"v": 17, "p": 47} # de(neo): Latin v is KEY_W, KEY_V types p
|
|
_LAYOUT_MAP = "vocalinux.ui.keyboard_backends.layout_key_map.get_active_char_to_evdev_map"
|
|
|
|
|
|
class TestYdotoolTerminalPasteCommand(unittest.TestCase):
|
|
"""Terminal paste must use Ctrl+Shift+V in both ydotool dialects."""
|
|
|
|
@patch(_LAYOUT_MAP, return_value=None)
|
|
@patch("vocalinux.text_injection.text_injector.subprocess.run")
|
|
@patch("vocalinux.text_injection.text_injector.shutil.which")
|
|
def test_legacy_named_sequence(self, mock_which, mock_run, _mock_map):
|
|
mock_which.return_value = "/usr/bin/ydotool"
|
|
mock_run.return_value = MagicMock(
|
|
returncode=0,
|
|
stdout="",
|
|
stderr="Each key sequence can be any number of modifiers and keys, separated by plus (+)\n",
|
|
)
|
|
injector = _make_injector()
|
|
os.environ.pop("FLATPAK_ID", None)
|
|
self.assertEqual(
|
|
injector._ydotool_ctrl_v_command(terminal=True),
|
|
["ydotool", "key", "ctrl+shift+v"],
|
|
)
|
|
self.assertEqual(injector._ydotool_ctrl_v_command(), ["ydotool", "key", "ctrl+v"])
|
|
|
|
@patch(_LAYOUT_MAP, return_value=None)
|
|
@patch("vocalinux.text_injection.text_injector.shutil.which")
|
|
def test_flatpak_uses_shift_keycodes(self, mock_which, _mock_map):
|
|
mock_which.return_value = "/app/bin/ydotool"
|
|
injector = _make_injector()
|
|
with patch.dict("os.environ", {"FLATPAK_ID": "com.vocalinux.Vocalinux"}):
|
|
self.assertEqual(
|
|
injector._ydotool_ctrl_v_command(terminal=True),
|
|
["ydotool", "key", "29:1", "42:1", "47:1", "47:0", "42:0", "29:0"],
|
|
)
|
|
self.assertEqual(
|
|
injector._ydotool_ctrl_v_command(),
|
|
["ydotool", "key", "29:1", "47:1", "47:0", "29:0"],
|
|
)
|
|
|
|
@patch(_LAYOUT_MAP, return_value=_NEO_CHAR_MAP)
|
|
def test_ydotool_v1_uses_layout_keycode_for_v(self, _mock_map):
|
|
"""de(neo) paste must hit KEY_W=17, not QWERTY KEY_V=47 (issue #787)."""
|
|
injector = _make_injector()
|
|
injector._ydotool_legacy_named_keys = False
|
|
self.assertEqual(
|
|
injector._ydotool_ctrl_v_command(),
|
|
["ydotool", "key", "29:1", "17:1", "17:0", "29:0"],
|
|
)
|
|
self.assertEqual(
|
|
injector._ydotool_ctrl_v_command(terminal=True),
|
|
["ydotool", "key", "29:1", "42:1", "17:1", "17:0", "42:0", "29:0"],
|
|
)
|
|
for cmd in (
|
|
injector._ydotool_ctrl_v_command(),
|
|
injector._ydotool_ctrl_v_command(terminal=True),
|
|
):
|
|
self.assertNotIn("47:1", cmd)
|
|
self.assertNotIn("47:0", cmd)
|
|
|
|
@patch(_LAYOUT_MAP, return_value=None)
|
|
def test_ydotool_v1_us_map_uses_key_v(self, _mock_map):
|
|
injector = _make_injector()
|
|
injector._ydotool_legacy_named_keys = False
|
|
self.assertEqual(
|
|
injector._ydotool_ctrl_v_command(),
|
|
["ydotool", "key", "29:1", "47:1", "47:0", "29:0"],
|
|
)
|
|
|
|
@patch(_LAYOUT_MAP, return_value={})
|
|
def test_ydotool_v1_empty_map_uses_key_v(self, _mock_map):
|
|
injector = _make_injector()
|
|
injector._ydotool_legacy_named_keys = False
|
|
self.assertEqual(
|
|
injector._ydotool_ctrl_v_command(),
|
|
["ydotool", "key", "29:1", "47:1", "47:0", "29:0"],
|
|
)
|
|
|
|
@patch(_LAYOUT_MAP, return_value=_NEO_CHAR_MAP)
|
|
def test_ydotool_legacy_uses_linux_name_for_layout_v(self, _mock_map):
|
|
"""0.1.x named ctrl+v is physical KEY_V; Neo must emit ctrl+w."""
|
|
injector = _make_injector()
|
|
injector._ydotool_legacy_named_keys = True
|
|
self.assertEqual(injector._ydotool_ctrl_v_command(), ["ydotool", "key", "ctrl+w"])
|
|
self.assertEqual(
|
|
injector._ydotool_ctrl_v_command(terminal=True),
|
|
["ydotool", "key", "ctrl+shift+w"],
|
|
)
|
|
|
|
def test_prefers_wtype_keysyms_for_paste_chord(self):
|
|
"""Paste may use wtype keysyms even when typing stays on ydotool."""
|
|
injector = _make_injector()
|
|
injector.wayland_tool = "ydotool"
|
|
with patch.object(injector, "_wtype_usable_for_paste", return_value=True):
|
|
cmd = injector._clipboard_paste_command()
|
|
terminal = injector._clipboard_paste_command(terminal=True)
|
|
self.assertEqual(cmd, ["wtype", "-M", "ctrl", "v"])
|
|
self.assertEqual(terminal, ["wtype", "-M", "ctrl", "-M", "shift", "v"])
|
|
self.assertIn("v", cmd)
|
|
self.assertNotIn("47", cmd)
|
|
self.assertNotIn("47:1", cmd)
|
|
|
|
@patch("vocalinux.text_injection.text_injector.shutil.which")
|
|
@patch("vocalinux.text_injection.text_injector.subprocess.run")
|
|
@patch("vocalinux.text_injection.text_injector.threading.Thread")
|
|
def test_clipboard_paste_uses_wtype_argv_when_chosen(
|
|
self, mock_thread: MagicMock, mock_run: MagicMock, mock_which: MagicMock
|
|
) -> None:
|
|
"""Check wtype arguments without launching an unrelated restore worker."""
|
|
mock_which.side_effect = lambda cmd: cmd in ("wl-copy", "wtype", "ydotool")
|
|
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
|
injector = _make_injector()
|
|
injector.wayland_tool = "ydotool"
|
|
with patch.object(injector, "_wtype_usable_for_paste", return_value=True):
|
|
with patch.object(injector, "_copy_to_clipboard", return_value=True):
|
|
with patch.object(injector, "_read_clipboard", return_value="old"):
|
|
with patch.object(injector, "_should_use_terminal_paste", return_value=False):
|
|
self.assertTrue(injector._inject_via_clipboard_paste("hello"))
|
|
# Restore behaviour is covered separately; never let a delayed worker
|
|
# outlive these clipboard/subprocess patches and reach the next test.
|
|
mock_thread.return_value.start.assert_called_once_with()
|
|
paste_cmds = [c.args[0] for c in mock_run.call_args_list if c.args]
|
|
self.assertTrue(
|
|
any(c == ["wtype", "-M", "ctrl", "v"] for c in paste_cmds),
|
|
paste_cmds,
|
|
)
|
|
self.assertFalse(any("47:1" in c for c in paste_cmds))
|
|
|
|
def test_wtype_paste_skipped_on_kde_when_typing_backend_is_ydotool(self):
|
|
injector = _make_injector()
|
|
injector.wayland_tool = "ydotool"
|
|
with patch(
|
|
"vocalinux.text_injection.text_injector._is_kde_plasma_session",
|
|
return_value=True,
|
|
):
|
|
with patch(
|
|
"vocalinux.text_injection.text_injector.shutil.which",
|
|
side_effect=lambda cmd: "/usr/bin/wtype" if cmd == "wtype" else None,
|
|
):
|
|
self.assertFalse(injector._wtype_usable_for_paste())
|
|
|
|
@patch("vocalinux.text_injection.text_injector.shutil.which")
|
|
@patch("vocalinux.text_injection.text_injector.subprocess.run")
|
|
@patch("vocalinux.text_injection.text_injector.threading.Thread")
|
|
def test_clipboard_paste_sends_terminal_chord(
|
|
self, mock_thread: MagicMock, mock_run: MagicMock, mock_which: MagicMock
|
|
) -> None:
|
|
"""Check the terminal chord without launching an unrelated restore worker."""
|
|
mock_which.side_effect = lambda cmd: cmd in ("wl-copy", "ydotool")
|
|
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
|
injector = _make_injector()
|
|
with patch.object(injector, "_should_use_terminal_paste", return_value=True):
|
|
with patch.object(injector, "_copy_to_clipboard", return_value=True):
|
|
with patch.object(injector, "_read_clipboard", return_value="old"):
|
|
with patch.object(
|
|
injector,
|
|
"_ydotool_ctrl_v_command",
|
|
return_value=["ydotool", "key", "ctrl+shift+v"],
|
|
) as mock_cmd:
|
|
self.assertTrue(injector._inject_via_clipboard_paste("hello"))
|
|
mock_cmd.assert_called_once_with(terminal=True)
|
|
mock_thread.return_value.start.assert_called_once_with()
|
|
|
|
@patch("vocalinux.text_injection.text_injector.threading.Thread")
|
|
def test_clipboard_paste_survives_mocked_subprocess_run(self, mock_thread: MagicMock) -> None:
|
|
"""Patching text_injector.subprocess.run also shadows stdlib subprocess.
|
|
|
|
Existing clipboard-restore tests do that. Window detection must fail
|
|
closed instead of raising TypeError on MagicMock stdout.
|
|
"""
|
|
injector = _make_injector()
|
|
with patch.object(injector, "_copy_to_clipboard", return_value=True):
|
|
with patch.object(injector, "_read_clipboard", return_value="old"):
|
|
with patch.object(
|
|
injector,
|
|
"_ydotool_ctrl_v_command",
|
|
return_value=["ydotool", "key", "ctrl+v"],
|
|
):
|
|
with patch.object(injector, "_should_copy_to_clipboard", return_value=False):
|
|
with patch(
|
|
"vocalinux.text_injection.text_injector.subprocess.run",
|
|
return_value=MagicMock(returncode=0),
|
|
):
|
|
self.assertTrue(injector._inject_via_clipboard_paste("hello"))
|
|
mock_thread.return_value.start.assert_called_once_with()
|