mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 19:06:11 +00:00
Which pair of tools a session uses was being asked inside copy(), read(), copy_bytes() and press(), four times, in a conditional each. A third desktop means a third branch in each of them, and macOS is already open as a pull request doing exactly that in a different style. Wayland and X11 are two entries at the top of the file now, each naming its programs and how to build a key press from them. The functions below take whichever entry the session gets. Adding a desktop is an entry and a line in desktop(), and it touches nothing else. Two things fall out of it. The key press is checked against the same table on both: xdotool was being handed the shortcut as typed, so ctrl+f13 was refused on Wayland and passed through on X11. And a missing tool names the package to install again, which the shared message had dropped. The tests say the promises once and run them against both desktops, so a third one inherits the list rather than needing a copy of it.
237 lines
10 KiB
Python
237 lines
10 KiB
Python
"""The clipboard and the key press, which is where a dictation actually lands.
|
||
|
||
Everything here shells out, so the tools are faked. What the tests hold onto is
|
||
the command line: a paste that presses the wrong keys, or in the wrong order,
|
||
types nothing and looks like a hang.
|
||
|
||
Both desktops owe the same promises, so those are written once and run against
|
||
each of them. A third one added to paste.py inherits the same list rather than
|
||
needing its own copy of it.
|
||
"""
|
||
|
||
import os
|
||
import subprocess
|
||
import unittest
|
||
from typing import ClassVar
|
||
from unittest import mock
|
||
|
||
import paste
|
||
from tests.support import DikteTest, FakeCompleted, linux_only, only_these_tools
|
||
|
||
|
||
@linux_only
|
||
class Chooser(DikteTest):
|
||
"""Which pair of programs this session's clipboard goes through."""
|
||
|
||
def under(self, **env):
|
||
with mock.patch.dict(os.environ, env, clear=True):
|
||
return paste.desktop()
|
||
|
||
def test_a_wayland_session(self):
|
||
self.assertIs(self.under(XDG_SESSION_TYPE="wayland",
|
||
WAYLAND_DISPLAY="wayland-0"), paste.WAYLAND)
|
||
|
||
def test_an_x11_session(self):
|
||
self.assertIs(self.under(XDG_SESSION_TYPE="x11", DISPLAY=":0"), paste.X11)
|
||
|
||
def test_a_display_with_no_wayland_beside_it(self):
|
||
self.assertIs(self.under(DISPLAY=":0"), paste.X11)
|
||
|
||
def test_an_x11_display_under_wayland_is_still_wayland(self):
|
||
"""XWayland sets DISPLAY too; the session type is the one to believe."""
|
||
self.assertIs(self.under(XDG_SESSION_TYPE="wayland",
|
||
DISPLAY=":0", WAYLAND_DISPLAY="wayland-0"),
|
||
paste.WAYLAND)
|
||
|
||
def test_nothing_set_at_all(self):
|
||
self.assertIs(self.under(), paste.WAYLAND)
|
||
|
||
|
||
class DesktopContract:
|
||
"""What both desktops owe. Each of them subclasses this once, below."""
|
||
|
||
env: ClassVar[dict] = {}
|
||
here = None
|
||
|
||
def setUp(self):
|
||
super().setUp()
|
||
self.enterContext(mock.patch.dict(os.environ, self.env, clear=True))
|
||
|
||
# ---- reading the clipboard -------------------------------------------
|
||
|
||
def test_no_reader_installed(self):
|
||
with only_these_tools():
|
||
self.assertIsNone(paste.read_clipboard())
|
||
|
||
def test_what_is_on_the_clipboard_comes_back_as_bytes(self):
|
||
with only_these_tools(self.here.read_command[0]), \
|
||
mock.patch.object(subprocess, "run",
|
||
return_value=FakeCompleted(stdout=b"hello")) as run:
|
||
self.assertEqual(paste.read_clipboard(), b"hello")
|
||
self.assertEqual(run.call_args.args[0], self.here.read_command)
|
||
|
||
def test_an_empty_clipboard_is_not_an_error(self):
|
||
with only_these_tools(self.here.read_command[0]), \
|
||
mock.patch.object(subprocess, "run",
|
||
return_value=FakeCompleted(returncode=1)):
|
||
self.assertIsNone(paste.read_clipboard())
|
||
|
||
def test_a_reader_that_will_not_run(self):
|
||
with only_these_tools(self.here.read_command[0]), \
|
||
mock.patch.object(subprocess, "run", side_effect=OSError("nope")):
|
||
self.assertIsNone(paste.read_clipboard())
|
||
|
||
# ---- copying ----------------------------------------------------------
|
||
|
||
def test_no_clipboard_tool_installed_says_what_to_install(self):
|
||
with only_these_tools(), self.assertRaises(paste.PasteError) as caught:
|
||
paste.copy("hello")
|
||
self.assertIn(self.here.clipboard, str(caught.exception))
|
||
self.assertIn(self.here.packages.split(" and ")[0], str(caught.exception))
|
||
|
||
def test_the_text_goes_in_as_utf8(self):
|
||
with only_these_tools(self.here.clipboard), \
|
||
mock.patch.object(subprocess, "run",
|
||
return_value=FakeCompleted()) as run:
|
||
paste.copy("günaydın")
|
||
self.assertEqual(run.call_args.args[0], self.here.copy_command)
|
||
self.assertEqual(run.call_args.kwargs["input"], "günaydın".encode())
|
||
|
||
def test_the_pipes_are_closed_so_the_call_can_return(self):
|
||
"""The clipboard owner forks; a pipe nobody drains hangs the caller."""
|
||
with only_these_tools(self.here.clipboard), \
|
||
mock.patch.object(subprocess, "run",
|
||
return_value=FakeCompleted()) as run:
|
||
paste.copy("hello")
|
||
self.assertEqual(run.call_args.kwargs["stdout"], subprocess.DEVNULL)
|
||
self.assertEqual(run.call_args.kwargs["stderr"], subprocess.DEVNULL)
|
||
|
||
def test_a_non_zero_exit_is_reported(self):
|
||
with only_these_tools(self.here.clipboard), \
|
||
mock.patch.object(subprocess, "run",
|
||
return_value=FakeCompleted(returncode=1)), \
|
||
self.assertRaises(paste.PasteError):
|
||
paste.copy("hello")
|
||
|
||
def test_a_clipboard_tool_that_will_not_run(self):
|
||
with only_these_tools(self.here.clipboard), \
|
||
mock.patch.object(subprocess, "run", side_effect=OSError("nope")), \
|
||
self.assertRaises(paste.PasteError):
|
||
paste.copy("hello")
|
||
|
||
def test_there_is_nothing_to_restore(self):
|
||
with only_these_tools(self.here.clipboard), \
|
||
mock.patch.object(subprocess, "run") as run:
|
||
paste.copy_bytes(None)
|
||
run.assert_not_called()
|
||
|
||
def test_restoring_never_raises(self):
|
||
"""It runs after the paste went in; failing here must not undo that."""
|
||
with only_these_tools(self.here.clipboard), \
|
||
mock.patch.object(subprocess, "run", side_effect=OSError("nope")):
|
||
paste.copy_bytes(b"whatever was there before")
|
||
|
||
def test_the_bytes_go_back_untouched(self):
|
||
with only_these_tools(self.here.clipboard), \
|
||
mock.patch.object(subprocess, "run",
|
||
return_value=FakeCompleted()) as run:
|
||
paste.copy_bytes(b"\x89PNG\r\n")
|
||
self.assertEqual(run.call_args.kwargs["input"], b"\x89PNG\r\n")
|
||
|
||
# ---- pressing the key -------------------------------------------------
|
||
|
||
def press(self, shortcut, result=None):
|
||
with only_these_tools(self.here.keyboard), \
|
||
mock.patch.object(paste.time, "sleep", lambda seconds: None), \
|
||
mock.patch.object(subprocess, "run",
|
||
return_value=result or FakeCompleted()) as run:
|
||
paste.press(shortcut)
|
||
return run.call_args.args[0]
|
||
|
||
def test_no_keyboard_tool_installed(self):
|
||
with only_these_tools():
|
||
self.assertFalse(paste.paste_ready())
|
||
with self.assertRaises(paste.PasteError) as caught:
|
||
paste.press()
|
||
self.assertIn(self.here.keyboard, str(caught.exception))
|
||
|
||
def test_case_and_spacing_do_not_matter(self):
|
||
self.assertEqual(self.press(" Ctrl + V "), self.press("ctrl+v"))
|
||
|
||
def test_a_key_nobody_mapped_is_refused_before_the_tool_runs(self):
|
||
"""Whichever desktop it is, the shortcut is held to one table."""
|
||
with only_these_tools(self.here.keyboard), \
|
||
mock.patch.object(subprocess, "run") as run, \
|
||
self.assertRaises(paste.PasteError) as caught:
|
||
paste.press("ctrl+f13")
|
||
self.assertIn("f13", str(caught.exception))
|
||
run.assert_not_called()
|
||
|
||
def test_a_tool_that_will_not_run(self):
|
||
with only_these_tools(self.here.keyboard), \
|
||
mock.patch.object(paste.time, "sleep", lambda seconds: None), \
|
||
mock.patch.object(subprocess, "run", side_effect=OSError("nope")), \
|
||
self.assertRaises(paste.PasteError):
|
||
paste.press("ctrl+v")
|
||
|
||
def test_a_failed_key_press_names_the_tool_and_what_it_said(self):
|
||
with self.assertRaises(paste.PasteError) as caught:
|
||
self.press("ctrl+v", FakeCompleted(returncode=1, stderr="no socket"))
|
||
self.assertIn(self.here.keyboard, str(caught.exception))
|
||
self.assertIn("no socket", str(caught.exception))
|
||
|
||
|
||
@linux_only
|
||
class Wayland(DesktopContract, DikteTest):
|
||
env: ClassVar[dict] = {"XDG_SESSION_TYPE": "wayland",
|
||
"WAYLAND_DISPLAY": "wayland-0"}
|
||
here = paste.WAYLAND
|
||
|
||
def test_ydotool_presses_down_then_lets_go_in_reverse(self):
|
||
self.assertEqual(self.press("ctrl+v"),
|
||
["ydotool", "key", "29:1", "47:1", "47:0", "29:0"])
|
||
|
||
def test_three_keys(self):
|
||
self.assertEqual(self.press("ctrl+shift+v"),
|
||
["ydotool", "key", "29:1", "42:1", "47:1",
|
||
"47:0", "42:0", "29:0"])
|
||
|
||
def test_the_synonyms_land_on_the_same_codes(self):
|
||
self.assertEqual(self.press("control+insert"),
|
||
["ydotool", "key", "29:1", "110:1", "110:0", "29:0"])
|
||
self.assertEqual(self.press("super+enter"), self.press("meta+return"))
|
||
|
||
def test_a_failure_asks_after_the_daemon(self):
|
||
"""ydotool needs ydotoold, and says nothing useful when it is not up."""
|
||
with self.assertRaises(paste.PasteError) as caught:
|
||
self.press("ctrl+v", FakeCompleted(returncode=1, stderr="no socket"))
|
||
self.assertIn("ydotoold", str(caught.exception))
|
||
|
||
|
||
@linux_only
|
||
class X11(DesktopContract, DikteTest):
|
||
env: ClassVar[dict] = {"XDG_SESSION_TYPE": "x11", "DISPLAY": ":0"}
|
||
here = paste.X11
|
||
|
||
def test_xdotool_takes_the_combination_as_one_argument(self):
|
||
self.assertEqual(self.press("ctrl+v"),
|
||
["xdotool", "key", "--clearmodifiers", "ctrl+v"])
|
||
|
||
def test_three_keys(self):
|
||
self.assertEqual(self.press("ctrl+shift+v"),
|
||
["xdotool", "key", "--clearmodifiers", "ctrl+shift+v"])
|
||
|
||
def test_the_keys_x_spells_differently(self):
|
||
"""xdotool wants keysyms, not the names the code table is keyed by."""
|
||
self.assertEqual(self.press("control+insert")[-1], "ctrl+Insert")
|
||
self.assertEqual(self.press("meta+enter")[-1], "super+Return")
|
||
|
||
def test_no_daemon_to_ask_after(self):
|
||
with self.assertRaises(paste.PasteError) as caught:
|
||
self.press("ctrl+v", FakeCompleted(returncode=1, stderr="bad keysym"))
|
||
self.assertNotIn("ydotoold", str(caught.exception))
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|