Files
macropad-itprotech/tests/test_actions.py
Mohamed Mahjoub 6a419eb3b6 Add SayoDevice support, 15 key pads and theme styles
Second protocol family (8089:000b) behind a new backend seam, 15 key and 3 knob pads, Knobs North plus the style and colour grid, Windows import fix, window resize fix.
2026-10-08 22:14:31 +01:00

385 lines
17 KiB
Python

"""
Host actions. The listener can't be tried against a real pad here, so the
parts that touch hardware are tested against the shapes of report the HID
spec allows, and the rest against a fake store and a fake runner.
"""
import os
import tempfile
import unittest
from pathlib import Path
from unittest import mock
import macropad as proto
from macropad_gui import actions as A, core
class Triggers(unittest.TestCase):
def test_f13_to_f24_are_standard_hid_usages(self):
self.assertEqual(proto.KEYCODES["f13"], 0x68)
self.assertEqual(proto.KEYCODES["f24"], 0x73)
self.assertEqual(len(A.TRIGGERS), 12)
def test_a_trigger_binding_writes_like_any_key(self):
config = core.Binding(keys="f17").reports(3, 0x05)[0]
self.assertEqual(config[:14].hex(" "), "03 fd 05 01 01 00 00 00 00 00 01 00 6c 00")
self.assertEqual(core.decode_config(config)[2], core.Binding(keys="f17"))
def test_pick_keeps_a_controls_own_trigger(self):
store = A.ActionStore(Path(tempfile.mkdtemp()) / "a.json")
store.set("f15", A.Action("url", "x.com"))
self.assertEqual(A.pick_trigger(store, {"f15"}, current="f15"), "f15")
def test_pick_skips_anything_in_use(self):
store = A.ActionStore(Path(tempfile.mkdtemp()) / "a.json")
store.set("f13", A.Action("url", "x.com"))
# f14 is on the pad as a plain key someone chose themselves
self.assertEqual(A.pick_trigger(store, {"f13", "f14"}), "f15")
def test_automatic_pick_leaves_the_troublemakers_till_last(self):
store = A.ActionStore(Path(tempfile.mkdtemp()) / "a.json")
order = []
for _ in A.TRIGGERS:
t = A.pick_trigger(store, set())
order.append(t)
store.set(t, A.Action("command", "true"))
self.assertEqual(order[0], "f14")
self.assertEqual(set(order[-5:]), set(A.CLAIMED))
self.assertEqual(set(order), set(A.TRIGGERS))
def test_a_chosen_key_is_honoured_when_free(self):
store = A.ActionStore(Path(tempfile.mkdtemp()) / "a.json")
self.assertEqual(A.pick_trigger(store, set(), want="f22"), "f22")
def test_a_chosen_key_that_is_taken_is_not(self):
store = A.ActionStore(Path(tempfile.mkdtemp()) / "a.json")
store.set("f22", A.Action("url", "x.com"))
self.assertNotEqual(A.pick_trigger(store, set(), want="f22"), "f22")
# but a control can always keep, or re-choose, its own key
self.assertEqual(A.pick_trigger(store, set(), current="f22", want="f22"), "f22")
def test_moving_an_action_to_another_key(self):
store = A.ActionStore(Path(tempfile.mkdtemp()) / "a.json")
store.set("f14", A.Action("url", "x.com"))
self.assertEqual(A.pick_trigger(store, {"f14"}, current="f14", want="f19"), "f19")
def test_pick_runs_out_honestly(self):
store = A.ActionStore(Path(tempfile.mkdtemp()) / "a.json")
for t in A.TRIGGERS:
store.set(t, A.Action("command", "true"))
self.assertIsNone(A.pick_trigger(store, set()))
class Actions(unittest.TestCase):
def test_urls_get_a_scheme_when_missing(self):
self.assertEqual(A.Action("url", "github.com").url, "https://github.com")
self.assertEqual(A.Action("url", "http://x.org").url, "http://x.org")
self.assertEqual(A.Action("url", "mailto:a@b.c").url, "mailto:a@b.c")
def test_labels_fit_a_keycap(self):
self.assertEqual(A.Action("url", "https://www.github.com/Probler-Yt").label(),
"Open github.com")
self.assertEqual(A.Action("app", "spotify", "Spotify").label(), "Spotify")
self.assertEqual(A.Action("command", "~/bin/switch-audio.sh --next").label(),
"Run switch-audio.sh")
def test_only_commands_go_through_a_shell(self):
self.assertEqual(A.Action("url", "x.com").argv(), ["xdg-open", "https://x.com"])
self.assertEqual(A.Action("app", "firefox --new-window").argv(),
["firefox", "--new-window"])
self.assertEqual(A.Action("command", "echo hi | wc").argv()[:2], ["sh", "-c"])
def test_nonsense_is_refused(self):
for bad in (A.Action("url", ""), A.Action("url", "not a url"),
A.Action("teleport", "x"), A.Action("command", " ")):
with self.assertRaises(ValueError, msg=bad):
bad.validate()
def test_running_detaches(self):
seen = {}
A.Action("url", "x.com").run(_popen=lambda argv, **kw: seen.update(argv=argv, **kw))
self.assertEqual(seen["argv"], ["xdg-open", "https://x.com"])
self.assertTrue(seen["start_new_session"])
class Store(unittest.TestCase):
def test_round_trip_and_live_reload(self):
path = Path(tempfile.mkdtemp()) / "a.json"
a = A.ActionStore(path)
a.set("f13", A.Action("url", "github.com"))
a.save()
b = A.ActionStore(path)
self.assertEqual(b.get("f13"), A.Action("url", "github.com"))
a.set("f14", A.Action("command", "true"))
a.save()
os.utime(path, (1, 1)) # make sure the mtime visibly moves
b.reload()
self.assertIn("f14", b.by_trigger)
def test_prune_forgets_triggers_no_longer_on_the_pad(self):
s = A.ActionStore(Path(tempfile.mkdtemp()) / "a.json")
s.set("f13", A.Action("url", "a.com"))
s.set("f14", A.Action("url", "b.com"))
self.assertEqual(s.prune({"f14"}), ["f13"])
self.assertEqual(list(s.by_trigger), ["f14"])
class DesktopApps(unittest.TestCase):
def test_exec_lines_are_cleaned(self):
self.assertEqual(A.clean_exec("firefox %u"), "firefox")
self.assertEqual(A.clean_exec('code --new-window %F'), "code --new-window")
self.assertEqual(
A.clean_exec("/usr/bin/flatpak run --branch=stable --arch=x86_64 "
"--command=brave com.brave.Browser @@u %U @@"),
"/usr/bin/flatpak run --branch=stable --arch=x86_64 "
"--command=brave com.brave.Browser")
def test_listing_apps(self):
d = Path(tempfile.mkdtemp())
(d / "a.desktop").write_text("[Desktop Entry]\nType=Application\nName=Zed\n"
"Name[fr]=Zède\nExec=zed %F\n")
(d / "b.desktop").write_text("[Desktop Entry]\nType=Application\nName=Alpha\n"
"Exec=alpha\n[Desktop Action new]\nExec=alpha --new\n")
(d / "hidden.desktop").write_text("[Desktop Entry]\nType=Application\n"
"Name=Secret\nExec=x\nNoDisplay=true\n")
(d / "link.desktop").write_text("[Desktop Entry]\nType=Link\nName=L\nURL=x\n")
self.assertEqual(A.installed_apps([d]), [("Alpha", "alpha"), ("Zed", "zed")])
class ReadingTheKeyboard(unittest.TestCase):
"""The pad's keyboard interface, in the forms the HID spec allows."""
# a standard boot keyboard descriptor, no report ids
BOOT = bytes.fromhex(
"05010906a101050719e029e71500250175019508810295017508810195067508"
"150025650507190029658100c0")
# the same keyboard behind report id 1, followed by a consumer collection
WITH_ID = bytes.fromhex(
"05010906a1018501050719e029e7150025017501950881029506750815002565"
"0507190029658100c0050c0901a1018502150026ff031900 2aff03 7510 9501 8100c0"
.replace(" ", ""))
VENDOR = bytes([6, 0, 255, 9, 1, 161, 1, 133, 2, 9, 1, 21, 0, 38, 255, 0,
117, 8, 149, 63, 129, 0, 192])
def test_descriptors(self):
self.assertEqual(A._descriptor_keyboard_report_id(self.BOOT), (True, 0))
self.assertEqual(A._descriptor_keyboard_report_id(self.WITH_ID), (True, 1))
self.assertEqual(A._descriptor_keyboard_report_id(self.VENDOR), (False, 0))
def test_reports(self):
f17 = proto.KEYCODES["f17"]
self.assertEqual(A.pressed_codes(bytes([0, 0, f17, 0, 0, 0, 0, 0]), 0), {f17})
self.assertEqual(A.pressed_codes(bytes([1, 0, 0, f17, 0, 0, 0, 0, 0]), 1), {f17})
self.assertIsNone(A.pressed_codes(bytes([2, 0xe9, 0]), 1)) # a media report
self.assertEqual(A.pressed_codes(bytes(8), 0), set()) # all released
class Listening(unittest.TestCase):
def setUp(self):
self.store = A.ActionStore(Path(tempfile.mkdtemp()) / "a.json")
self.store.set("f13", A.Action("url", "github.com"))
self.ran = []
self.logs = []
self.l = A.Listener(self.store, log=self.logs.append, run=self.ran.append)
self.f13, self.f14 = proto.KEYCODES["f13"], proto.KEYCODES["f14"]
def test_fires_once_per_press_however_long_it_is_held(self):
self.l.feed({self.f13})
self.l.feed({self.f13}) # still held: a repeat report
self.l.feed(set()) # released
self.l.feed({self.f13}) # pressed again
self.assertEqual(len(self.ran), 2)
def test_ignores_ordinary_keys_and_unset_triggers(self):
self.l.feed({proto.KEYCODES["a"]})
self.l.feed({self.f14})
self.assertEqual(self.ran, [])
self.assertIn("no action is set", self.logs[-1])
def test_ignores_reports_that_arent_keyboard_ones(self):
self.assertEqual(self.l.feed(None), [])
def test_a_failing_action_doesnt_kill_the_listener(self):
def boom(_):
raise FileNotFoundError(2, "No such file")
l = A.Listener(self.store, log=self.logs.append, run=boom)
self.assertEqual(l.feed({self.f13}), [])
self.assertIn("couldn't run", self.logs[-1])
class Autostart(unittest.TestCase):
def test_toggle_writes_and_removes_an_autostart_entry(self):
with tempfile.TemporaryDirectory() as d, \
mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": d}):
A.set_autostart(True)
text = A.autostart_path().read_text()
self.assertIn("Exec=", text)
self.assertIn("actions", text)
self.assertTrue(A.autostart_enabled())
A.set_autostart(False)
self.assertFalse(A.autostart_enabled())
def test_never_signals_a_process_that_isnt_ours(self):
with tempfile.TemporaryDirectory() as d, \
mock.patch.dict(os.environ, {"XDG_RUNTIME_DIR": d}):
(Path(d) / A.LOCK).write_text(str(os.getpid())) # this test runner
self.assertIsNone(A.listener_pid())
if __name__ == "__main__":
unittest.main()
class TypingText(unittest.TestCase):
"""The phrase feature: clipboard, then a virtual keyboard presses paste."""
def fake_keyboard(self):
import struct
self.ioctls, self.writes, self.slept = [], [], []
kb = A.VirtualKeyboard(
_open=lambda path, flags: 99,
_ioctl=lambda fd, req, arg=None: self.ioctls.append((req, arg)),
_write=lambda fd, data: self.writes.append(data),
_sleep=self.slept.append)
return kb, struct
def test_the_text_action(self):
a = A.Action("text", "Kind regards,\nProbler").validate()
self.assertEqual(a.label(), 'Type "Kind regards,"')
self.assertEqual(A.Action.from_json(a.to_json()), a)
long = A.Action("text", "Thanks for reaching out, I'll reply soon")
self.assertLessEqual(len(long.label()), 24)
with self.assertRaises(ValueError):
A.Action("text", "hi", paste="ctrl+p").validate()
def test_the_device_is_set_up_the_way_the_kernel_expects(self):
kb, struct = self.fake_keyboard()
requests = [r for r, _ in self.ioctls]
self.assertEqual(requests[0], A._UI_SET_EVBIT)
self.assertEqual(sorted(a for r, a in self.ioctls if r == A._UI_SET_KEYBIT),
[29, 42, 47]) # ctrl, shift, v only
self.assertEqual(requests[-1], A._UI_DEV_CREATE)
self.assertEqual(len(self.writes[0]), 1116) # struct uinput_user_dev
self.assertIn(0.3, self.slept) # waits to be noticed
def test_ctrl_v_is_pressed_and_released_in_order(self):
kb, struct = self.fake_keyboard()
self.writes.clear()
kb.press("ctrl+v")
events = [struct.unpack("@llHHi", w)[2:] for w in self.writes]
self.assertEqual(events, [(1, 29, 1), (1, 47, 1), (0, 0, 0),
(1, 47, 0), (1, 29, 0), (0, 0, 0)])
def test_terminal_paste_holds_shift_too(self):
kb, struct = self.fake_keyboard()
self.writes.clear()
kb.press("ctrl+shift+v")
downs = [struct.unpack("@llHHi", w)[3] for w in self.writes
if struct.unpack("@llHHi", w)[2:] [0] == 1 and struct.unpack("@llHHi", w)[4] == 1]
self.assertEqual(downs, [29, 42, 47])
def test_clipboard_first_then_paste(self):
order = []
class Kb:
def press(self, combo):
order.append(("press", combo))
A.type_text("hello £ @ 👋", "ctrl+v",
_clip=lambda t: order.append(("clip", t)),
_keyboard_factory=Kb)
self.assertEqual(order, [("clip", "hello £ @ 👋"), ("press", "ctrl+v")])
def test_clipboard_tool_by_session(self):
with mock.patch.dict(os.environ, {"WAYLAND_DISPLAY": "wayland-0"}), \
mock.patch("shutil.which", side_effect=lambda n: "/usr/bin/" + n):
self.assertEqual(A._clipboard_tool(), ["wl-copy"])
with mock.patch.dict(os.environ, {"WAYLAND_DISPLAY": "", "XDG_SESSION_TYPE": "x11"}), \
mock.patch("shutil.which", side_effect=lambda n: "/usr/bin/xclip" if n == "xclip" else None):
self.assertEqual(A._clipboard_tool(), ["xclip", "-selection", "clipboard"])
def test_problems_come_with_fixes(self):
with mock.patch.object(A.os, "access", return_value=False), \
mock.patch.object(A, "_clipboard_tool", return_value=None):
probs = A.text_problems()
self.assertEqual(len(probs), 2)
self.assertIn("60-macropad-uinput.rules", probs[0][1][0])
with mock.patch.object(A.os, "access", return_value=True), \
mock.patch.object(A, "_clipboard_tool", return_value=["wl-copy"]):
self.assertEqual(A.text_problems(), [])
def test_the_listener_runs_text_actions_too(self):
store = A.ActionStore(Path(tempfile.mkdtemp()) / "a.json")
store.set("f16", A.Action("text", "gg ez"))
typed = []
with mock.patch.object(A, "type_text", side_effect=lambda t, p: typed.append((t, p))):
A.Listener(store, log=lambda *_: None).feed({proto.KEYCODES["f16"]})
self.assertEqual(typed, [("gg ez", "ctrl+v")])
class Themes(unittest.TestCase):
def test_every_theme_is_complete_and_valid(self):
import re
from macropad_gui import themes
colours = ("window", "board", "ink", "ink_dim", "hatch", "accent",
"accent_text", "good", "bad", "line", "field")
for t in themes.THEMES.values():
for c in colours:
self.assertRegex(getattr(t, c), r"^#[0-9a-f]{6}$", f"{t.key}.{c}")
self.assertIn(t.unknown, ("hatch", "hidden"))
self.assertIn(themes.DEFAULT, themes.THEMES)
self.assertEqual(themes.get("no such theme").key, themes.DEFAULT)
def test_mixing(self):
from macropad_gui import themes
self.assertEqual(themes.mix("#000000", "#ffffff", 0.5), "#808080")
self.assertEqual(themes.mix("#123456", "#abcdef", 0), "#123456")
def test_the_sketch_font_ships_with_its_licence(self):
from macropad_gui import themes
self.assertTrue((themes.FONTS / "ArchitectsDaughter-Regular.ttf").exists())
self.assertIn("Open Font License", (themes.FONTS / "OFL.txt").read_text())
class WindowsImport(unittest.TestCase):
"""
app.py imports actions to build the window, so actions must import on a
system with no fcntl. It used to fail twice: the import itself, and
VirtualKeyboard's _ioctl default, which is evaluated at import time.
"""
def test_actions_imports_without_fcntl(self):
import builtins
import sys
real = builtins.__import__
def no_fcntl(name, *a, **k):
if name == "fcntl":
raise ImportError("No module named 'fcntl'")
return real(name, *a, **k)
saved = {m: sys.modules[m] for m in list(sys.modules)
if m.startswith("macropad_gui.actions") or m == "fcntl"}
for m in saved:
del sys.modules[m]
builtins.__import__ = no_fcntl
try:
import importlib
mod = importlib.import_module("macropad_gui.actions")
self.assertIsNone(mod.fcntl)
with self.assertRaises(OSError):
mod.VirtualKeyboard()
self.assertEqual(mod.listen(log=lambda _s: None), 1)
finally:
builtins.__import__ = real
for m in list(sys.modules):
if m.startswith("macropad_gui.actions"):
del sys.modules[m]
sys.modules.update(saved)