Add a Mac as the third system, beside Wayland and X11

Dikte already chose its clipboard programs once instead of in every
function; macOS joins that table rather than adding a branch to each one.
A Mac copies through pbcopy and presses Cmd+V straight into CoreGraphics,
records through AVFoundation, and asks Carbon for its global shortcuts.

The three tables are paste.Desktop, audio.Sound, and the pair of
predicates in hotkey.py. Each reads sys.platform inside the chooser, so a
test can stand somewhere else: 697 of the 737 tests now run on any
machine, the Wayland and X11 halves included, and the suite passes whole
whichever system it is run on.

Two things a Mac does not have needed saying rather than pretending:
there is no shortcut registry to install into, so Settings offers no
Install button and the listener is the mechanism instead of a fallback;
and nothing is offered as the sound the speakers are playing, so a
meeting needs BlackHole or Loopback and says so. The KDE-only labels
around them were already wrong on GNOME, and now name whichever desktop
is there.

Co-authored-by: firat <[email protected]>
This commit is contained in:
yusufipk
2026-08-01 21:28:58 +07:00
co-authored by firat
parent 676664ea74
commit 3bd8c1ad27
16 changed files with 1558 additions and 207 deletions
+5
View File
@@ -17,6 +17,11 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
_SANDBOX = tempfile.mkdtemp(prefix="dikte-tests-")
os.environ["XDG_CONFIG_HOME"] = os.path.join(_SANDBOX, "config")
os.environ["XDG_DATA_HOME"] = os.path.join(_SANDBOX, "data")
# Home goes with them: the shortcut file, the applications directory and every
# macOS path start from it rather than from an XDG variable, and a test run is
# not allowed to touch the real one.
os.environ["HOME"] = os.path.join(_SANDBOX, "home")
os.makedirs(os.environ["HOME"], exist_ok=True)
atexit.register(shutil.rmtree, _SANDBOX, True)
# A key sitting in the environment would otherwise reach the code that falls
+168 -10
View File
@@ -1,8 +1,12 @@
"""Level metering, the WAV writer, and what pactl is asked for.
"""Level metering, the WAV writer, and what the sound system is asked for.
The device list is where a platform port lands first, so the parsing is pinned
here: a source that is not a monitor is an input, one that is belongs to the
speakers, and neither list may go missing when pactl is absent.
here: on Linux a source that is not a monitor is an input, one that is belongs
to the speakers, and neither list may go missing when pactl is absent. macOS
answers the same three questions out of one ffmpeg listing.
Each class says which machine it is standing on, so both halves run on either
one: nothing here reaches a real sound server.
"""
import array
@@ -11,6 +15,7 @@ import io
import json
import os
import subprocess
import sys
import unittest
import wave
from unittest import mock
@@ -19,7 +24,6 @@ import audio
from tests.support import (
DikteTest,
FakeCompleted,
linux_only,
only_these_tools,
pcm,
silence,
@@ -28,6 +32,22 @@ from tests.support import (
)
class OnLinux:
"""A test that runs as if the machine ran PulseAudio or PipeWire."""
def setUp(self):
super().setUp()
self.enterContext(mock.patch.object(sys, "platform", "linux"))
class OnMacOS:
"""A test that runs as if the machine were a Mac."""
def setUp(self):
super().setUp()
self.enterContext(mock.patch.object(sys, "platform", "darwin"))
class ChunkLevels(unittest.TestCase):
def test_silence(self):
self.assertEqual(audio.chunk_levels(silence(0.1)), (0.0, 0.0))
@@ -115,8 +135,7 @@ SOURCES = [
]
@linux_only
class Devices(DikteTest):
class Devices(OnLinux, DikteTest):
@contextlib.contextmanager
def pactl(self, sources=None, sink=None, tools=("pactl",)):
payloads = {
@@ -216,8 +235,7 @@ class FakeProcess:
self._alive = False
@linux_only
class RecordingCommand(DikteTest):
class RecordingCommand(OnLinux, DikteTest):
"""Which program captures the microphone, and how it is asked to."""
def test_parec_is_preferred(self):
@@ -270,8 +288,7 @@ class RecordingCommand(DikteTest):
if arg.startswith(flag)])
@linux_only
class RecorderChain(DikteTest):
class RecorderChain(OnLinux, DikteTest):
"""Start to WAV, with pw-record faked out."""
def record(self, data, target="", max_seconds=300):
@@ -404,5 +421,146 @@ class RecorderChain(DikteTest):
self.assertFalse(recorder.active)
class MeetingCommand(unittest.TestCase):
"""One process reading both devices, because two would drift apart."""
def command(self, platform, mic="", system="them"):
with mock.patch.object(sys, "platform", platform):
return audio.meeting_command(mic, system)
def test_linux_reads_both_through_pulse(self):
cmd = self.command("linux", mic="mine")
self.assertEqual(cmd.count("pulse"), 2)
self.assertEqual(cmd[cmd.index("mine") - 1], "-i")
self.assertEqual(cmd[cmd.index("them") - 1], "-i")
def test_a_mac_reads_both_through_avfoundation(self):
cmd = self.command("darwin", mic="1")
self.assertEqual(cmd.count("avfoundation"), 2)
self.assertIn(":1", cmd)
self.assertIn(":them", cmd)
def test_no_microphone_named_means_the_default_one(self):
self.assertIn("default", self.command("linux"))
self.assertIn(":default", self.command("darwin"))
def test_both_merge_the_two_into_one_stereo_stream(self):
for platform in ("linux", "darwin"):
with self.subTest(platform=platform):
cmd = self.command(platform)
self.assertIn(audio.MERGE_FILTER, cmd)
self.assertEqual(cmd[cmd.index("-map") + 1], "[out]")
self.assertEqual(cmd[cmd.index("-f", cmd.index("-map")) + 1], "s16le")
def test_neither_lets_ffmpeg_read_the_terminal(self):
"""It shares stdin with Dikte, and would eat a keypress meant for it."""
for platform in ("linux", "darwin"):
with self.subTest(platform=platform):
self.assertIn("-nostdin", self.command(platform))
class MacDevices(OnMacOS, DikteTest):
"""The one ffmpeg listing all three device questions are answered from."""
LISTING = (
"[AVFoundation indev @ 0x7fb] AVFoundation video devices:\n"
"[AVFoundation indev @ 0x7fb] [0] FaceTime HD Camera\n"
"[AVFoundation indev @ 0x7fb] [1] Capture screen 0\n"
"[AVFoundation indev @ 0x7fb] AVFoundation audio devices:\n"
"[AVFoundation indev @ 0x7fb] [0] MacBook Pro Microphone\n"
"[AVFoundation indev @ 0x7fb] [1] BlackHole 2ch\n"
": Input/output error\n"
)
@contextlib.contextmanager
def listing(self, stderr=None, tools=("ffmpeg",)):
completed = FakeCompleted(
returncode=1, stderr=self.LISTING if stderr is None else stderr)
with only_these_tools(*tools), \
mock.patch.object(subprocess, "run", return_value=completed):
yield
def test_the_audio_half_of_the_listing_is_the_only_half_read(self):
with self.listing():
self.assertEqual(audio.list_sources(),
[("0", "MacBook Pro Microphone"), ("1", "BlackHole 2ch")])
def test_the_index_is_what_ffmpeg_is_given_and_the_name_what_is_shown(self):
with self.listing():
name, description = audio.list_sources()[1]
self.assertEqual(name, "1")
self.assertIn("BlackHole", description)
def test_no_ffmpeg_installed(self):
with only_these_tools():
self.assertEqual(audio.list_sources(), [])
self.assertEqual(audio.list_monitors(), [])
self.assertEqual(audio.default_monitor(), "")
def test_an_ffmpeg_that_will_not_run(self):
with only_these_tools("ffmpeg"), \
mock.patch.object(subprocess, "run", side_effect=OSError("nope")):
self.assertEqual(audio.list_sources(), [])
def test_a_listing_with_no_audio_section(self):
with self.listing(stderr="[AVFoundation indev @ 0x7fb] [0] FaceTime\n"):
self.assertEqual(audio.list_sources(), [])
def test_the_far_side_of_a_meeting_is_offered_the_same_devices(self):
"""macOS calls none of them an output, so the loopback one is in here."""
with self.listing():
self.assertEqual(audio.list_monitors(), audio.list_sources())
def test_the_loopback_driver_is_picked_out_by_name(self):
with self.listing():
self.assertEqual(audio.default_monitor(), "1")
def test_the_other_two_drivers_people_install(self):
for name in ("Loopback Audio", "Soundflower (2ch)"):
with self.subTest(name=name):
listing = ("AVFoundation audio devices:\n"
f"[0] Built-in Microphone\n[1] {name}\n")
with self.listing(stderr=listing):
self.assertEqual(audio.default_monitor(), "1")
def test_a_mac_with_nothing_to_record_the_far_side_from(self):
listing = "AVFoundation audio devices:\n[0] MacBook Pro Microphone\n"
with self.listing(stderr=listing):
self.assertEqual(audio.default_monitor(), "")
class MacRecordingCommand(OnMacOS, DikteTest):
def test_the_microphone_is_read_through_avfoundation(self):
with only_these_tools("ffmpeg"):
cmd = audio.recording_command()
self.assertEqual(cmd[0], "ffmpeg")
self.assertEqual(cmd[cmd.index("-f") + 1], "avfoundation")
def test_the_empty_half_in_front_of_the_colon_is_the_missing_picture(self):
with only_these_tools("ffmpeg"):
self.assertIn(":default", audio.recording_command())
self.assertIn(":2", audio.recording_command("2"))
def test_it_captures_the_format_the_rest_of_the_code_expects(self):
with only_these_tools("ffmpeg"):
cmd = audio.recording_command()
self.assertEqual(cmd[cmd.index("-ar") + 1], str(audio.RATE))
self.assertEqual(cmd[cmd.index("-ac") + 1], str(audio.CHANNELS))
self.assertEqual(cmd[-2:], ["s16le", "-"])
def test_no_ffmpeg_installed(self):
with only_these_tools():
self.assertEqual(audio.recording_command(), [])
def test_what_a_mac_is_told_to_install(self):
recorder = audio.Recorder()
failures = []
recorder.failed.connect(failures.append)
with only_these_tools():
recorder.start()
self.assertIn("brew install ffmpeg", failures[0])
self.assertFalse(recorder.active)
if __name__ == "__main__":
unittest.main()
+34
View File
@@ -14,6 +14,7 @@ from unittest import mock
import api
import config as cfg
import i18n
import paste
from tests.support import DikteTest
@@ -422,6 +423,39 @@ class Defaults(unittest.TestCase):
with self.subTest(prompt=f"{name}_{suffix}"):
self.assertTrue(getattr(cfg, f"{name}_{suffix}").strip())
def test_the_paste_key_is_the_one_this_desktop_pastes_with(self):
"""cmd+v on a Mac, and it must be one paste.py can actually press."""
self.assertEqual(cfg.DEFAULTS["paste_shortcut"],
paste.desktop().shortcuts[0])
class Directories(unittest.TestCase):
"""Where the settings and the recordings are kept, per system."""
def test_linux_keeps_them_apart_and_follows_xdg(self):
with mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": "/c",
"XDG_DATA_HOME": "/d"}):
config_dir, data_dir = cfg._directories("linux")
self.assertEqual(str(config_dir), "/c/dikte")
self.assertEqual(str(data_dir), "/d/dikte")
def test_linux_without_the_variables_set(self):
with mock.patch.dict(os.environ, {}, clear=True):
config_dir, data_dir = cfg._directories("linux")
self.assertTrue(str(config_dir).endswith("/.config/dikte"))
self.assertTrue(str(data_dir).endswith("/.local/share/dikte"))
def test_a_mac_keeps_both_in_application_support(self):
config_dir, data_dir = cfg._directories("darwin")
self.assertEqual(config_dir, data_dir)
self.assertTrue(str(config_dir).endswith("/Library/Application Support/Dikte"))
def test_a_mac_does_not_read_the_xdg_variables(self):
"""A Mac with them set from some other tool still stores in one place."""
with mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": "/c"}):
config_dir, _ = cfg._directories("darwin")
self.assertNotIn("/c", str(config_dir))
if __name__ == "__main__":
unittest.main()
+235 -1
View File
@@ -123,10 +123,13 @@ class Bindings(DikteTest):
self.assertEqual(len(listener._bindings[57]), 2)
@linux_only
class Chooser(DikteTest):
"""Which desktop is asked to register the shortcut."""
def setUp(self):
super().setUp()
self.patch_attr(hotkey.sys, "platform", "linux")
@contextlib.contextmanager
def under(self, desktop, has_gsettings=True):
"""A session that says it is this desktop, with or without gsettings."""
@@ -407,5 +410,236 @@ class KdeShortcut(DikteTest):
self.assertEqual(hotkey.conflicting_shortcuts("Ctrl+Space"), [])
# --- macOS ----------------------------------------------------------------
class ParseMacShortcut(unittest.TestCase):
def test_a_combination_a_mac_would_use(self):
self.assertEqual(hotkey.parse_macos_shortcut("Cmd+Space"),
(hotkey.MAC_MODS["cmd"], 49))
def test_case_and_spacing_do_not_matter(self):
self.assertEqual(hotkey.parse_macos_shortcut(" ctrl + option + a "),
hotkey.parse_macos_shortcut("Ctrl+Option+A"))
def test_several_modifiers_are_one_number(self):
modifiers, key = hotkey.parse_macos_shortcut("Cmd+Shift+M")
self.assertEqual(modifiers,
hotkey.MAC_MODS["cmd"] | hotkey.MAC_MODS["shift"])
self.assertEqual(key, hotkey.MAC_KEYS["m"])
def test_the_names_a_mac_keyboard_uses(self):
for name in ("cmd", "command", "meta", "super"):
with self.subTest(name=name):
self.assertEqual(hotkey.parse_macos_shortcut(f"{name}+space"),
hotkey.parse_macos_shortcut("cmd+space"))
self.assertEqual(hotkey.parse_macos_shortcut("option+a"),
hotkey.parse_macos_shortcut("alt+a"))
def test_a_key_on_its_own(self):
self.assertEqual(hotkey.parse_macos_shortcut("F5"), (0, 96))
def test_modifiers_with_no_key(self):
self.assertEqual(hotkey.parse_macos_shortcut("Cmd+Shift"), (None, None))
def test_a_key_nobody_mapped(self):
self.assertEqual(hotkey.parse_macos_shortcut("Cmd+F13"), (None, None))
def test_two_keys_are_not_a_shortcut(self):
self.assertEqual(hotkey.parse_macos_shortcut("A+B"), (None, None))
def test_nothing(self):
self.assertEqual(hotkey.parse_macos_shortcut(""), (None, None))
self.assertEqual(hotkey.parse_macos_shortcut(None), (None, None))
class FakeCarbon:
"""Enough of Carbon to watch what the listener asks it for.
The references are numbers standing in for pointers, which is all the code
does with them: it collects them and hands them back to be unregistered.
"""
def __init__(self, install=0, register=0):
self.install, self.register = install, register # what they return
self.registered = [] # (key, modifiers, identifier)
self.unregistered = []
self.handlers_removed = 0
self.pressed_id = 0
self.parameter_result = 0
def GetApplicationEventTarget(self):
return 7000
def InstallEventHandler(self, target, callback, count, spec, data, out):
if self.install == 0:
out._obj.value = 8000
return self.install
def RegisterEventHotKey(self, key, modifiers, identifier, target, options, out):
if self.register != 0:
return self.register
self.registered.append((key, modifiers, identifier.id))
out._obj.value = 9000 + len(self.registered)
return 0
def UnregisterEventHotKey(self, reference):
self.unregistered.append(reference.value)
return 0
def RemoveEventHandler(self, handler):
self.handlers_removed += 1
return 0
def GetEventParameter(self, event, kind, name, wanted_type, size, out_size, out):
out._obj.id = self.pressed_id
return self.parameter_result
class CarbonListener(DikteTest):
"""What the listener asks macOS for, without a Mac to ask."""
def setUp(self):
super().setUp()
self.carbon = FakeCarbon()
self.patch_attr(hotkey, "_carbon", lambda: self.carbon)
self.addCleanup(hotkey._REGISTERED.clear)
self.listener = hotkey.CarbonHotkey()
self.addCleanup(self.listener.stop)
self.failures = []
self.listener.failed.connect(self.failures.append)
def test_every_binding_is_asked_for_by_position_and_modifier(self):
self.assertTrue(self.listener.start({"toggle": "Ctrl+Option+Space"}))
self.assertEqual(self.carbon.registered,
[(49, hotkey.MAC_MODS["ctrl"] | hotkey.MAC_MODS["option"], 1)])
self.assertEqual(self.failures, [])
self.assertTrue(self.listener.running)
def test_a_binding_with_no_shortcut_is_skipped(self):
self.assertFalse(self.listener.start({"toggle": "", "ask": ""}))
self.assertEqual(self.carbon.registered, [])
self.assertFalse(self.listener.running)
def test_an_unparsable_shortcut_is_reported_and_the_rest_go_on(self):
self.assertTrue(self.listener.start({"toggle": "Cmd+F13",
"ask": "Cmd+Shift+Space"}))
self.assertEqual(len(self.failures), 1)
self.assertIn("Cmd+F13", self.failures[0])
self.assertEqual(len(self.carbon.registered), 1)
def test_a_combination_another_application_already_holds(self):
"""The conflict warning macOS has: it is the answer to asking."""
self.carbon.register = -9878 # eventHotKeyExistsErr
self.assertFalse(self.listener.start({"toggle": "Cmd+Shift+Space"}))
self.assertIn("Cmd+Shift+Space", self.failures[0])
self.assertFalse(self.listener.running)
def test_a_handler_that_will_not_install(self):
self.carbon.install = -50
self.assertFalse(self.listener.start({"toggle": "Cmd+Shift+Space"}))
self.assertEqual(self.carbon.registered, [])
self.assertEqual(len(self.failures), 1)
def test_no_carbon_to_talk_to(self):
self.patch_attr(hotkey, "_carbon",
mock.Mock(side_effect=OSError("no such library")))
self.assertFalse(self.listener.start({"toggle": "Cmd+Shift+Space"}))
self.assertIn("no such library", self.failures[0])
def test_the_key_press_arrives_under_the_name_it_was_registered_with(self):
self.listener.start({"toggle": "Cmd+Shift+Space", "ask": "Cmd+Shift+A"})
heard = []
self.listener.triggered.connect(heard.append)
self.carbon.pressed_id = 2 # the second binding, which is "ask"
self.listener._callback(None, 0, None)
self.assertEqual(heard, ["ask"])
def test_a_press_carbon_could_not_identify_is_dropped(self):
self.listener.start({"toggle": "Cmd+Shift+Space"})
heard = []
self.listener.triggered.connect(heard.append)
self.carbon.parameter_result = -50
self.listener._callback(None, 0, None)
self.assertEqual(heard, [])
def test_stopping_hands_every_registration_back(self):
self.listener.start({"toggle": "Cmd+Shift+Space", "ask": "Cmd+Shift+A"})
self.listener.stop()
self.assertEqual(self.carbon.unregistered, [9001, 9002])
self.assertEqual(self.carbon.handlers_removed, 1)
self.assertFalse(self.listener.running)
def test_starting_twice_does_not_leave_the_first_set_behind(self):
self.listener.start({"toggle": "Cmd+Shift+Space"})
self.listener.start({"toggle": "Cmd+Shift+A"})
self.assertEqual(self.carbon.unregistered, [9001])
self.assertEqual(len(self.listener._registrations), 1)
def test_what_it_registered_is_what_the_status_line_reads_back(self):
with mock.patch.object(hotkey.sys, "platform", "darwin"):
self.listener.start({"toggle": "Ctrl+Option+Space",
"meeting": "Ctrl+Option+M"})
self.assertEqual(hotkey.shortcut_status(), "Ctrl+Option+Space")
self.assertEqual(hotkey.shortcut_status(hotkey.MEETING_DESKTOP_ID),
"Ctrl+Option+M")
self.listener.stop()
self.assertIsNone(hotkey.shortcut_status())
class MacChooser(DikteTest):
"""What the shortcut verbs mean where there is nothing to write them into."""
def setUp(self):
super().setUp()
self.patch_attr(hotkey.sys, "platform", "darwin")
self.addCleanup(hotkey._REGISTERED.clear)
def test_the_listener_is_the_one_macos_has(self):
self.assertIsInstance(hotkey.listener(), hotkey.CarbonHotkey)
def test_everywhere_else_reads_the_keyboard_itself(self):
with mock.patch.object(hotkey.sys, "platform", "linux"):
self.assertIsInstance(hotkey.listener(), hotkey.EvdevHotkey)
def test_there_is_nothing_to_install_into(self):
self.assertFalse(hotkey.installs_shortcuts())
self.assertFalse(hotkey.shortcut_needs_restart())
self.assertEqual(hotkey.desktop_name(), "macOS")
def test_installing_records_it_rather_than_writing_anything(self):
with mock.patch.object(hotkey.subprocess, "run") as run:
ok, message = hotkey.install_shortcut("Cmd+Shift+Space", "dikte toggle")
run.assert_not_called()
self.assertTrue(ok)
self.assertIn("Cmd+Shift+Space", message)
self.assertEqual(hotkey.shortcut_status(), "Cmd+Shift+Space")
def test_removing_takes_it_back_out(self):
hotkey.install_shortcut("Cmd+Shift+Space", "dikte toggle")
hotkey.remove_shortcut()
self.assertIsNone(hotkey.shortcut_status())
def test_each_verb_is_kept_apart(self):
hotkey.install_shortcut("Cmd+Shift+Space", "dikte toggle")
hotkey.install_shortcut("Cmd+Shift+M", "dikte meeting",
desktop_id=hotkey.MEETING_DESKTOP_ID)
self.assertEqual(hotkey.shortcut_status(), "Cmd+Shift+Space")
self.assertEqual(hotkey.shortcut_status(hotkey.MEETING_DESKTOP_ID),
"Cmd+Shift+M")
def test_no_list_of_conflicts_to_read(self):
"""Not even KDE's file, which a Mac could well have a copy of."""
self.assertEqual(hotkey.conflicting_shortcuts("Ctrl+Space"), [])
def test_a_combination_is_checked_against_the_mac_table(self):
self.assertTrue(hotkey.valid_shortcut("Cmd+Shift+Space"))
self.assertFalse(hotkey.valid_shortcut("Ctrl+F13"))
def test_the_other_table_is_the_one_used_elsewhere(self):
with mock.patch.object(hotkey.sys, "platform", "linux"):
self.assertTrue(hotkey.valid_shortcut("Ctrl+F1"))
self.assertFalse(hotkey.valid_shortcut("Cmd+Space"))
if __name__ == "__main__":
unittest.main()
+192 -22
View File
@@ -1,30 +1,35 @@
"""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.
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.
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.
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 os
import subprocess
import sys
import unittest
from typing import ClassVar
from unittest import mock
import paste
from tests.support import DikteTest, FakeCompleted, linux_only, only_these_tools
from tests.support import DikteTest, FakeCompleted, 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):
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):
@@ -46,18 +51,29 @@ class Chooser(DikteTest):
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)
class DesktopContract:
"""What both desktops owe. Each of them subclasses this once, below."""
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))
# ---- reading the clipboard -------------------------------------------
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():
@@ -81,13 +97,10 @@ class DesktopContract:
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):
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))
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), \
@@ -138,7 +151,15 @@ class DesktopContract:
paste.copy_bytes(b"\x89PNG\r\n")
self.assertEqual(run.call_args.kwargs["input"], b"\x89PNG\r\n")
# ---- pressing the key -------------------------------------------------
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), \
@@ -148,6 +169,10 @@ class DesktopContract:
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())
@@ -180,9 +205,11 @@ class DesktopContract:
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]))
@linux_only
class Wayland(DesktopContract, DikteTest):
class Wayland(ClipboardContract, KeyProgramContract, DikteTest):
env: ClassVar[dict] = {"XDG_SESSION_TYPE": "wayland",
"WAYLAND_DISPLAY": "wayland-0"}
here = paste.WAYLAND
@@ -208,8 +235,7 @@ class Wayland(DesktopContract, DikteTest):
self.assertIn("ydotoold", str(caught.exception))
@linux_only
class X11(DesktopContract, DikteTest):
class X11(ClipboardContract, KeyProgramContract, DikteTest):
env: ClassVar[dict] = {"XDG_SESSION_TYPE": "x11", "DISPLAY": ":0"}
here = paste.X11
@@ -232,5 +258,149 @@ class X11(DesktopContract, DikteTest):
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 = []
# --- ApplicationServices
def AXIsProcessTrusted(self):
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
def setUp(self):
super().setUp()
self.api = FakeCoreGraphics()
self.patch_attr(paste, "_macos_api", lambda: (self.api, self.api))
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_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())
if __name__ == "__main__":
unittest.main()
+39 -2
View File
@@ -6,13 +6,16 @@ save, so a setting added to one half and not the other is silently reset the
next time anybody presses Save. That is the failure this catches.
"""
import sys
import unittest
from typing import ClassVar
from unittest import mock
from PyQt6.QtWidgets import QApplication, QMessageBox
import config as cfg
import overlay as overlay_module
import paste
import settings_ui
from tests.support import DikteTest, only_these_tools
@@ -80,10 +83,16 @@ CHANGED = {
class Settings(DikteTest):
# What a Mac shows instead, where the combination on offer is a different
# one. Everything else about the window is the same on both.
changed = CHANGED
platform = "linux"
def setUp(self):
super().setUp()
# No pactl, no model lists over the network, and no modal dialogue
# waiting for somebody to press OK.
self.enterContext(mock.patch.object(sys, "platform", self.platform))
self.enterContext(only_these_tools())
self.enterContext(mock.patch.object(QMessageBox, "information"))
self.enterContext(mock.patch.object(settings_ui.SettingsWindow,
@@ -116,11 +125,11 @@ class Settings(DikteTest):
self.assertEqual(conf.data, before)
def test_a_setting_of_your_own_survives_the_round_trip(self):
self.write_config(CHANGED)
self.write_config(self.changed)
conf = cfg.Config()
self.window(conf)._save()
stored = self.read_config_file()
for key, value in CHANGED.items():
for key, value in self.changed.items():
with self.subTest(key=key):
self.assertEqual(stored[key], value)
@@ -175,6 +184,34 @@ class Settings(DikteTest):
self.assertEqual(window.windowTitle(), "Dikte Ayarları")
class MacSettings(Settings):
"""The same window and the same round trip, standing on a Mac.
Nothing here is about macOS: it is the rest of the window, checked on the
platform where three of its widgets are gone and one offers other keys.
"""
platform = "darwin"
changed: ClassVar[dict] = {**CHANGED, "paste_shortcut": "cmd+shift+v"}
def test_there_is_no_install_button_where_nothing_is_installed(self):
window = self.window(cfg.Config())
labels = [button.text() for button in
window.findChildren(settings_ui.QPushButton)]
self.assertFalse([text for text in labels if "shortcut" in text.lower()])
def test_the_listener_is_not_offered_as_a_choice(self):
"""It is the whole mechanism there; turning it off would leave nothing."""
window = self.window(cfg.Config())
self.assertFalse(window.evdev_enabled.isVisible())
def test_the_paste_keys_on_offer_are_the_ones_a_mac_uses(self):
window = self.window(cfg.Config())
offered = [window.paste_shortcut.itemText(index)
for index in range(window.paste_shortcut.count())]
self.assertEqual(offered, paste.MACOS.shortcuts)
class Overlay(DikteTest):
def overlay(self, **kwargs):
widget = overlay_module.Overlay(**kwargs)