Files
dikte/tests/test_paste.py
T
yusufipek b186f7fde2 Merge master: the modules moved into a package
Every file this branch touches moved into dikte/, so the merge is mostly the
rename following the edits. What needed a hand:

hotkey.py: master replaced the _macos()/_gnome() pair with one backend()
chooser, and this branch had added _windows() to the pair. Windows is a fifth
value of the chooser now, and everything that used to ask "macOS or Windows?"
asks backend() instead. The key is held by the running process there, so
installs_shortcuts() and shortcut_needs_restart() are both false for it, and
desktop_name() says Windows.

install.ps1 and the Windows README name dikte/__main__.py, the entry point the
Linux and macOS installers were pointed at in the same commit. The Start Menu
entry, the autostart entry and the dikte.cmd shim all come off one $entry
variable.

settings_ui.py: the shortcut tab now has a Windows sentence of its own, with
the Turkish for it. Falling through to the branch master wrote for a desktop
with no registry would have told a Windows user to check /dev/input. Nothing
covers that branch: there is no Windows Settings test class, the way there is
one for macOS.

CONTRIBUTING: the chooser it names is backend() now, and the test count is the
merged one, 1067 of 1110 running anywhere.
2026-08-16 14:10:28 +03:00

618 lines
25 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""The clipboard and the key press, which is where a dictation actually lands.
Everything here is faked: the programs the two Linux desktops shell out to, and
the frameworks macOS goes through. What the tests hold onto is what was asked
for. A paste that presses the wrong keys, or in the wrong order, types nothing
and looks like a hang.
Every system owes the same promises about the clipboard, so those are written
once and run against each of them. A fourth one added to paste.py inherits the
same list rather than needing its own copy of it. Each class says which system
it is standing on, which is why none of this is skipped anywhere: the Linux half
is checked on a Mac and the macOS half on Linux, and a change to the chooser
cannot quietly break the platform nobody is sitting at.
"""
import ctypes
import os
import pathlib
import subprocess
import sys
import tempfile
import unittest
from typing import ClassVar
from unittest import mock
from dikte import paste
from tests.support import DikteTest, FakeCompleted, only_these_tools
class Chooser(DikteTest):
"""Which pair of programs this session's clipboard goes through."""
def under(self, platform="linux", **env):
with mock.patch.object(sys, "platform", platform), \
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)
def test_a_mac(self):
self.assertIs(self.under("darwin"), paste.MACOS)
def test_windows(self):
self.assertIs(self.under("win32"), paste.WINDOWS)
def test_a_mac_running_an_x_server_is_still_a_mac(self):
"""XQuartz sets DISPLAY, and none of X's programs are what pastes here."""
self.assertIs(self.under("darwin", DISPLAY=":0"), paste.MACOS)
class Standing:
"""A test that runs as if it were sitting at one particular system."""
env: ClassVar[dict] = {}
platform = "linux"
here = None
def setUp(self):
super().setUp()
self.enterContext(mock.patch.object(sys, "platform", self.platform))
self.enterContext(mock.patch.dict(os.environ, self.env, clear=True))
class ClipboardContract(Standing):
"""What every system owes the text on its way to 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())
def test_no_clipboard_tool_installed_names_it(self):
with only_these_tools(), self.assertRaises(paste.PasteError) as caught:
paste.copy("hello")
self.assertIn(self.here.clipboard, 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")
def test_the_paste_key_it_offers_is_one_it_can_press(self):
"""Whatever Settings lists, pressing it must not come back unknown."""
for shortcut in self.here.shortcuts:
with self.subTest(shortcut=shortcut):
self.assertTrue(self.pressing(shortcut))
class KeyProgramContract(Standing):
"""The half of it that is another program: ydotool, xdotool."""
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 pressing(self, shortcut):
"""The command a shortcut would run, for the contract above."""
return self.press(shortcut)
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))
def test_nothing_named_presses_the_one_this_desktop_pastes_with(self):
self.assertEqual(self.press(""), self.press(self.here.shortcuts[0]))
class Wayland(ClipboardContract, KeyProgramContract, 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))
class X11(ClipboardContract, KeyProgramContract, 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))
class FakeCoreGraphics:
"""Enough of the two frameworks to watch what a paste does to them.
The events are numbers standing in for pointers, which is all the code
treats them as: it makes them, sets flags on them, posts them, and hands
them back.
"""
def __init__(self, trusted=True, makes=None):
self.trusted = trusted
self.makes = makes # how many it will hand out; None for as many as asked
self.made = [] # (keycode, is_down)
self.flags = [] # (event, flags)
self.posted = [] # (tap, event)
self.released = []
self.prompted = [] # the options dictionaries asked with
# --- ApplicationServices
def AXIsProcessTrusted(self):
return self.trusted
def AXIsProcessTrustedWithOptions(self, options):
self.prompted.append(options)
return self.trusted
def CGEventCreateKeyboardEvent(self, source, keycode, down):
self.made.append((keycode, down))
if self.makes is not None and len(self.made) > self.makes:
return None
return 1000 + len(self.made)
def CGEventSetFlags(self, event, flags):
self.flags.append((event, flags))
def CGEventPost(self, tap, event):
self.posted.append((tap, event))
# --- CoreFoundation
def CFRelease(self, event):
self.released.append(event)
class MacOS(ClipboardContract, DikteTest):
platform = "darwin"
here = paste.MACOS
# A stand-in for the CFDictionary: the real one is built out of constants
# read from the frameworks, which a fake has none of.
OPTIONS = 4242
def setUp(self):
super().setUp()
self.api = FakeCoreGraphics()
self.patch_attr(paste, "_macos_api", lambda: (self.api, self.api))
self.patch_attr(paste, "_macos_prompt_options",
lambda services, core: self.OPTIONS)
self.patch_attr(paste.time, "sleep", lambda seconds: None)
# It opens the settings pane once per run; each test gets its own run.
self.patch_attr(paste, "_asked_for_permission", False)
self.opened = self.patch_attr(paste.subprocess, "Popen", mock.Mock())
def pressing(self, shortcut):
paste.press(shortcut)
return self.api.posted
def test_the_key_goes_in_by_position_with_the_modifiers_on_it(self):
paste.press("cmd+v")
self.assertEqual(self.api.made, [(9, True), (9, False)])
self.assertEqual([flags for _, flags in self.api.flags],
[paste.MAC_FLAGS["command"]] * 2)
self.assertEqual([tap for tap, _ in self.api.posted],
[paste.HID_EVENT_TAP] * 2)
def test_the_down_is_posted_before_the_up(self):
paste.press("cmd+v")
self.assertEqual([event for _, event in self.api.posted], [1001, 1002])
def test_both_events_are_handed_back(self):
"""CoreGraphics gives out memory that nothing else will free."""
paste.press("cmd+v")
self.assertEqual(sorted(self.api.released), [1001, 1002])
def test_several_modifiers_are_one_number(self):
paste.press("cmd+shift+v")
self.assertEqual(self.api.flags[0][1],
paste.MAC_FLAGS["command"] | paste.MAC_FLAGS["shift"])
def test_the_names_a_mac_keyboard_uses(self):
for name in ("cmd", "command", "meta", "super"):
with self.subTest(name=name):
self.assertEqual(paste._macos_keys(f"{name}+v"),
(9, paste.MAC_FLAGS["command"]))
self.assertEqual(paste._macos_keys("option+v"), paste._macos_keys("alt+v"))
self.assertEqual(paste._macos_keys("control+v"), paste._macos_keys("ctrl+v"))
def test_case_and_spacing_do_not_matter(self):
self.assertEqual(paste._macos_keys(" Cmd + V "), paste._macos_keys("cmd+v"))
def test_a_key_nobody_mapped_is_refused_before_anything_is_posted(self):
with self.assertRaises(paste.PasteError) as caught:
paste.press("cmd+f13")
self.assertIn("f13", str(caught.exception))
self.assertEqual(self.api.posted, [])
def test_a_modifier_nobody_mapped(self):
with self.assertRaises(paste.PasteError) as caught:
paste.press("hyper+v")
self.assertIn("hyper", str(caught.exception))
def test_nothing_at_all(self):
with self.assertRaises(paste.PasteError):
paste.press("+")
def test_nothing_is_typed_until_macos_says_so(self):
self.api.trusted = False
with self.assertRaises(paste.PasteError) as caught:
paste.press("cmd+v")
self.assertIn("Accessibility", str(caught.exception))
self.assertEqual(self.api.posted, [])
def test_the_permission_pane_is_opened_once_and_not_again(self):
"""It is a window in the user's face, and one paste is every dictation."""
self.api.trusted = False
for _ in range(3):
with self.assertRaises(paste.PasteError):
paste.press("cmd+v")
self.opened.assert_called_once()
self.assertIn("Privacy_Accessibility", self.opened.call_args.args[0][1])
def test_a_system_that_will_not_open_the_pane_still_says_what_is_wrong(self):
self.api.trusted = False
self.opened.side_effect = OSError("no open(1) here")
with self.assertRaises(paste.PasteError) as caught:
paste.press("cmd+v")
self.assertIn("Accessibility", str(caught.exception))
def test_asking_is_what_puts_dikte_in_the_accessibility_list(self):
"""Opening the pane is not enough on its own.
AXIsProcessTrusted only answers the question; an application that has
never asked with the prompt is not in the list, so the pane opens on a
list Dikte is not in and the only way through is the + button.
"""
self.api.trusted = False
with self.assertRaises(paste.PasteError):
paste.press("cmd+v")
self.assertEqual(self.api.prompted, [self.OPTIONS])
# The dictionary is ours to release, and nothing else made an event.
self.assertIn(self.OPTIONS, self.api.released)
def test_it_asks_once_however_many_dictations_fail(self):
self.api.trusted = False
for _ in range(3):
with self.assertRaises(paste.PasteError):
paste.press("cmd+v")
self.assertEqual(len(self.api.prompted), 1)
def test_a_trusted_process_is_never_prompted(self):
paste.press("cmd+v")
self.assertEqual(self.api.prompted, [])
def test_readiness_is_the_permission_rather_than_a_program(self):
self.assertTrue(paste.paste_ready())
self.api.trusted = False
self.assertFalse(paste.paste_ready())
def test_an_event_that_could_not_be_made_takes_its_pair_with_it(self):
self.api.makes = 1 # the second one comes back null
with self.assertRaises(paste.PasteError):
paste.press("cmd+v")
self.assertEqual(self.api.released, [1001])
self.assertEqual(self.api.posted, [])
def test_the_frameworks_not_being_there_is_not_a_crash(self):
"""Every other system imports this module too, and must survive it."""
self.patch_attr(paste, "_macos_api",
mock.Mock(side_effect=paste.PasteError("no such library")))
self.assertFalse(paste.paste_ready())
class MacClipboardSnapshot(DikteTest):
def test_every_native_type_is_restored_and_the_files_are_removed(self):
directory = tempfile.mkdtemp(prefix="dikte-test-clipboard-")
manifest = '[[{"type":"public.tiff","file":"0-0.bin"}]]'
pathlib.Path(directory, "0-0.bin").write_bytes(b"a TIFF")
snapshot = paste._MAC_SNAPSHOT(directory, manifest)
with mock.patch.object(subprocess, "run",
return_value=FakeCompleted()) as run:
paste.copy_bytes(snapshot)
self.assertEqual(run.call_args.kwargs["input"], manifest)
self.assertEqual(run.call_args.kwargs["env"]["DIKTE_PASTEBOARD_DIR"],
directory)
self.assertFalse(os.path.exists(directory))
def test_a_failed_snapshot_leaves_no_temporary_directory(self):
directory = tempfile.mkdtemp(prefix="dikte-test-clipboard-")
with mock.patch.object(paste.tempfile, "mkdtemp", return_value=directory), \
mock.patch.object(subprocess, "run",
return_value=FakeCompleted(stdout=b"not json")):
self.assertIsNone(paste._macos_snapshot())
self.assertFalse(os.path.exists(directory))
class FakeWin32:
"""user32 and kernel32, as much of both as paste.py calls.
The clipboard is a string held here. A read materialises it as this
machine's own wide characters, which is what wstring_at reads wherever the
test runs; a write arrives as the UTF-16 the real clipboard is handed, so
what the code sent is exactly what is checked.
"""
def __init__(self):
self.text = None
self.buffers = {}
self.next_handle = 1
self.pressed = [] # (virtual key, flags), in the order sent
self.send_result = None # None: report every event as delivered
self.held = False # another program has the clipboard open
self.out_of_memory = False
def _keep(self, buffer):
handle = self.next_handle
self.next_handle += 1
self.buffers[handle] = buffer
return handle
# --- user32
def OpenClipboard(self, owner):
return 0 if self.held else 1
def CloseClipboard(self):
return 1
def EmptyClipboard(self):
self.text = None
return 1
def GetClipboardData(self, fmt):
if self.text is None:
return 0
return self._keep(ctypes.create_unicode_buffer(self.text))
def SetClipboardData(self, fmt, handle):
raw = self.buffers[handle].raw
self.text = raw.decode("utf-16-le").split("\x00", 1)[0]
return handle
def SendInput(self, count, inputs, size):
self.pressed.extend((entry.union.ki.wVk, entry.union.ki.dwFlags)
for entry in inputs)
return count if self.send_result is None else self.send_result
# --- kernel32
def GlobalAlloc(self, flags, size):
if self.out_of_memory:
return 0
return self._keep(ctypes.create_string_buffer(size))
def GlobalLock(self, handle):
buffer = self.buffers.get(handle)
return ctypes.addressof(buffer) if buffer else 0
def GlobalUnlock(self, handle):
return 1
def GlobalFree(self, handle):
self.buffers.pop(handle, None)
return 1
class Windows(Standing, DikteTest):
"""Windows shells out to nothing: both halves are calls into the system."""
platform = "win32"
here = paste.WINDOWS
def setUp(self):
super().setUp()
self.api = FakeWin32()
self.patch_attr(paste, "_win_api", lambda: (self.api, self.api))
self.patch_attr(paste.time, "sleep", lambda seconds: None)
def test_what_is_copied_is_what_reads_back(self):
paste.copy("ığüşöç İ")
self.assertEqual(paste.read_clipboard(), "ığüşöç İ".encode("utf-8"))
def test_an_empty_clipboard_reads_as_empty_text(self):
self.assertEqual(paste.read_clipboard(), b"")
def test_what_was_saved_goes_back_after_the_paste(self):
paste.copy("mine")
saved = paste.read_clipboard()
paste.copy("the dictation")
paste.copy_bytes(saved)
self.assertEqual(self.api.text, "mine")
def test_a_copy_that_fails_leaves_what_was_there(self):
"""EmptyClipboard is the point of no return, so nothing runs after it."""
paste.copy("mine")
for failure in ("out_of_memory", "held"):
with self.subTest(failure=failure):
setattr(self.api, failure, True)
with self.assertRaises(paste.PasteError):
paste.copy("the dictation")
self.assertEqual(self.api.text, "mine")
setattr(self.api, failure, False)
def test_the_handle_is_not_leaked_when_the_copy_fails(self):
self.api.held = True
with self.assertRaises(paste.PasteError):
paste.copy("the dictation")
self.assertEqual(self.api.buffers, {})
def test_readiness_asks_for_no_program_and_no_permission(self):
with only_these_tools():
self.assertTrue(paste.paste_ready())
def test_the_keys_go_down_in_order_and_up_in_reverse(self):
paste.press("ctrl+v")
keyup = 0x0002
self.assertEqual(self.api.pressed,
[(0x11, 0), (0x56, 0), (0x56, keyup), (0x11, keyup)])
def test_three_keys(self):
paste.press("ctrl+shift+v")
self.assertEqual([code for code, _ in self.api.pressed],
[0x11, 0x10, 0x56, 0x56, 0x10, 0x11])
def test_the_other_spellings_land_on_the_same_keys(self):
paste.press("super+enter")
first, self.api.pressed = self.api.pressed, []
paste.press("meta+return")
self.assertEqual(self.api.pressed, first)
def test_a_key_nobody_mapped_is_refused_before_anything_is_sent(self):
with self.assertRaises(paste.PasteError):
paste.press("ctrl+f13")
self.assertEqual(self.api.pressed, [])
def test_a_press_the_system_did_not_take_says_so(self):
self.api.send_result = 0
with self.assertRaises(paste.PasteError) as caught:
paste.press("ctrl+v")
self.assertIn("SendInput", str(caught.exception))
if __name__ == "__main__":
unittest.main()