Files
macropad-itprotech/tests/test_core.py
Bjorn Blomberg 80c6a5da1c Add the ITPROTECH MK series (8808) as a third pad family
Confirmed on the MK-09 (8808:660a, 9 keys): the vendor's .NET configurator
was decompiled for the wire format and every frame was then written to a
real pad and read back. Keys, media keys and multikey sequences are
supported; lighting from the command line.

The firmware acts on every other output report, so each report is sent
twice (as the vendor software does) and every write is verified by reading
the key back. The app now keeps one state file per pad and starts with a
known layout for a known USB id, and the editor asks the backend whether a
binding fits before offering to write it. The actions listener and the
udev rule cover every supported vendor.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-23 19:24:30 +02:00

1139 lines
49 KiB
Python

"""
Run from the repo root: python3 -m unittest -v
The first test class is the important one. It replays the packets the
vendor's own software sent (captured over usbmon) and checks that the
path the GUI uses - Binding -> macropad.py - produces the same bytes.
If anyone edits the protocol layer and breaks it, this fails.
"""
import errno
import json
import os
import re
import tempfile
import unittest
from unittest import mock
from pathlib import Path
from macropad_gui import core
import macropad as proto
CAPTURES = Path(__file__).parent / "captures"
def load_capture(path):
"""[(label, bytes)] from a macropad-capture.py output file."""
out, label = [], None
for line in path.read_text().splitlines():
m = re.match(r"\[(\d+)\] (.*)", line)
if m:
label = m.group(0)
elif line.startswith(" ") and label:
out.append((label, bytes.fromhex(line.strip())))
return out
KEYNAME = {}
for _name, _code in proto.KEYCODES.items():
KEYNAME.setdefault(_code, _name)
class CaptureReplay(unittest.TestCase):
def test_dial_capture_reproduced_exactly(self):
packets = load_capture(CAPTURES / "1189-8840-dials.txt")
self.assertEqual(len(packets), 12)
for i in range(0, len(packets), 2):
(_, config), (_, commit) = packets[i], packets[i + 1]
# usbmon's text interface stops at 32 bytes; the rest is unseen.
self.assertEqual(len(config), 32)
action, layer, mods, code = config[2], config[3], config[11], config[12]
self.assertEqual(mods, 0)
binding = core.Binding(keys=KEYNAME[code])
ours = binding.reports(3, action, layer)
self.assertEqual(len(ours), 2, "config + commit")
self.assertEqual(ours[0][:32], config, f"config for action 0x{action:02x}")
self.assertEqual(ours[1][:32], commit, "commit")
self.assertEqual(len(ours[0]), 65)
self.assertFalse(any(ours[0][32:]), "bytes past the capture are zero")
def test_dial_action_order_matches_capture(self):
packets = load_capture(CAPTURES / "1189-8840-dials.txt")
captured = [p[2] for _, p in packets[0::2]]
ours = [core.action_byte(c) for c in core.CONTROL_IDS if c.startswith("dial")]
self.assertEqual(ours, captured)
def test_keys_are_1_to_12(self):
self.assertEqual([core.action_byte(k) for k in core.KEYS], list(range(1, 13)))
class EveryCapture(unittest.TestCase):
"""Every config packet in every capture must decode and re-encode exactly."""
def test_round_trip_all_captures(self):
seen = 0
for path in sorted(CAPTURES.glob("*.txt")):
for report in core.read_capture(path):
if report[1] in (proto.MAGIC_INFO, proto.MAGIC_READ):
continue # a query, not a binding
if core.is_commit(report):
self.assertEqual(proto.native_commit(3)[:len(report)], report)
continue
decoded = core.decode_config(report)
self.assertIsNotNone(decoded, f"{path.name}: {report.hex()}")
control, layer, binding = decoded
ours = binding.reports(3, core.action_byte(control), layer)[0]
self.assertEqual(ours[:len(report)], report,
f"{path.name} {control}: ours {ours[:16].hex()}")
seen += 1
self.assertGreaterEqual(seen, 21)
def test_media_keys_capture(self):
"""Vendor app: keys 1-12 set to media keys, saved with one commit."""
want = ["next", "stop", "prev", "playpause", "volumedown", "volumeup",
"mute", "calculator", "mail", "mycomputer",
"brightnessdown", "brightnessup"]
with tempfile.TemporaryDirectory() as d:
state = core.State(Path(d) / "s.json")
got = core.import_capture(CAPTURES / "1189-8840-media-keys.txt", state)
self.assertEqual([c for c, _, _ in got][:12], list(core.KEYS))
self.assertEqual([b.media for _, _, b in got][:12], want)
# dial 1, slots labelled anticlockwise / press / clockwise -> 1 / 2 / 3
self.assertEqual(state.get("dial1-left").binding, core.Binding(keys="1"))
self.assertEqual(state.get("dial1-push").binding, core.Binding(keys="2"))
self.assertEqual(state.get("dial1-right").binding, core.Binding(keys="3"))
self.assertTrue(state.get("dial2-left").unknown)
def test_capture_directions_are_separate(self):
"""A reply from the pad must never be mistaken for a binding."""
with tempfile.TemporaryDirectory() as d:
cap = Path(d) / "both.txt"
cap.write_text(
"[1] interrupt out ep=4\n 03fd010101000000000001000400\n"
"[2] interrupt in ep=3\n 03fd020101000000000001001500\n"
"[3] interrupt out ep=4\n 03fdfeff00000000\n")
self.assertEqual(len(core.read_capture(cap)), 2)
self.assertEqual(len(core.read_capture(cap, incoming=True)), 1)
state = core.State(Path(d) / "s.json")
got = core.import_capture(cap, state)
self.assertEqual([(c, b.keys) for c, _, b in got], [("key1", "a")])
self.assertTrue(state.get("key2").unknown)
def test_uncommitted_configs_are_not_imported(self):
with tempfile.TemporaryDirectory() as d:
cap = Path(d) / "c.txt"
cap.write_text("[1] interrupt out ep=4\n"
" 03fd010101000000000001000400\n")
state = core.State(Path(d) / "s.json")
self.assertEqual(core.import_capture(cap, state), [])
self.assertTrue(state.get("key1").unknown)
class ReadingThePad(unittest.TestCase):
"""The vendor software's "view settings", captured and decoded."""
def setUp(self):
self.cap = CAPTURES / "1189-8840-read.txt"
self.sent = core.read_capture(self.cap)
self.got = core.read_capture(self.cap, incoming=True)
def test_our_requests_match_the_vendors(self):
info, read1, read2, read3 = self.sent
# the vendor repeats the command byte; match it rather than assume
self.assertEqual(proto.native_info(3)[:4], info[:4])
for layer, sent in ((1, read1), (2, read2), (3, read3)):
# the vendor's trailing bytes are uninitialised memory; ours are zero
self.assertEqual(proto.native_read(3, layer)[:6], sent[:6])
def test_info_reply(self):
self.assertEqual(core.decode_info(self.got[0]), (12, 2))
self.assertEqual(len(core.KEYS), 12)
self.assertEqual(len(core.DIALS), 2)
def test_layer_1_is_what_the_pad_holds(self):
layer1 = {}
for r in self.got:
d = core.decode_config(r)
if d and d[1] == 1:
layer1[d[0]] = d[2]
# dials, which is the mapping we could never confirm by writing alone
self.assertEqual(layer1["dial1-left"], core.Binding(media="volumedown"))
self.assertEqual(layer1["dial1-push"], core.Binding(media="mute"))
self.assertEqual(layer1["dial1-right"], core.Binding(media="volumeup"))
self.assertEqual(layer1["dial2-left"], core.Binding(media="brightnessdown"))
self.assertEqual(layer1["dial2-push"], core.Binding(media="calculator"))
self.assertEqual(layer1["dial2-right"], core.Binding(media="brightnessup"))
# keys, including modifiers and an empty one
self.assertEqual(layer1["key2"], core.Binding(keys="ctrl+alt+t"))
self.assertEqual(layer1["key6"], core.Binding(keys="ctrl+shift+alt+super+f12"))
self.assertEqual(layer1["key3"], core.Binding(media="prev"))
self.assertTrue(layer1["key1"].none)
def test_a_read_binding_re_encodes_to_the_same_write(self):
"""Whatever we read, writing it back must produce the same meaning."""
for r in self.got:
d = core.decode_config(r)
if not d:
continue
control, layer, binding = d
again = core.decode_config(
binding.reports(3, core.action_byte(control), layer)[0])
self.assertIsNotNone(again, binding)
self.assertEqual(again[0], control)
self.assertEqual(again[2], binding)
def test_the_pad_stores_two_key_sequences(self):
"""
Slots 0x0d to 0x0f hold two keystrokes each, in the layout we had
assumed for sequences: count at byte 10, then (modifier, keycode)
pairs. This is factory data for a 15 key sibling rather than
something we watched being written, so it supports the encoding
without proving the pad accepts it from us.
"""
multi = [r for r in self.got
if r[1] == proto.MAGIC_READ and r[10] == 2 and r[4] != 2]
self.assertTrue(multi)
r = multi[0]
self.assertEqual((r[11], r[13]), (0, 0)) # no modifiers
self.assertTrue(r[12] and r[14]) # two real keycodes
def test_replies_cover_the_firmware_not_the_hardware(self):
"""
The pad answers for 24 slots per layer: 15 keys and 3 knobs, which
is the most this firmware supports. Ours has 12 keys and 2 knobs,
so 6 of those slots are for hardware that isn't there. Those decode
to no control, which is what we want: the app must not offer them.
"""
configs = [r for r in self.got if r[1] == proto.MAGIC_READ]
self.assertEqual(len(configs), 24 * 3)
real = [r for r in configs if core.decode_config(r)]
self.assertEqual(len(real), len(core.CONTROL_IDS) * 3)
phantom = {r[2] for r in configs if not core.decode_config(r)}
self.assertEqual(phantom, {0x0d, 0x0e, 0x0f, # keys 13 to 15
0x16, 0x17, 0x18}) # a third knob
def test_the_same_replies_decode_fully_for_a_bigger_pad(self):
"""
Those six slots are only phantom because THIS pad is 12 keys and 2
knobs. A 15 key, 3 knob sibling shares the USB id and answers the
same 24 slots for real, so decoding has to follow the layout rather
than a module level constant.
"""
big = core.Layout(15, 3, 3, 5)
configs = [r for r in self.got if r[1] == proto.MAGIC_READ]
decoded = [core.decode_config(r, big) for r in configs]
self.assertEqual(sum(d is None for d in decoded), 0)
layer1 = {d[0] for d in decoded if d[1] == 1}
self.assertEqual(layer1, set(big.control_ids))
self.assertIn("key15", layer1)
self.assertIn("dial3-right", layer1)
def test_reading_fills_in_the_state(self):
from unittest import mock
replies = [r for r in self.got if r[1] == proto.MAGIC_READ and r[3] == 1]
dev = core.Device(path="/dev/null-pad", report_id=3, writable=True)
with tempfile.TemporaryDirectory() as d:
state = core.State(Path(d) / "s.json")
with mock.patch.object(core, "_exchange", return_value=replies):
got = core.read_into_state(dev, state)
self.assertEqual(len(got), len(core.CONTROL_IDS))
fresh = core.State(Path(d) / "s.json")
self.assertFalse(any(fresh.get(c).unknown for c in core.CONTROL_IDS))
self.assertEqual(fresh.get("dial2-push").binding,
core.Binding(media="calculator"))
class KnobSide(unittest.TestCase):
"""
Not every pad wears its knobs on the right. accdias's 15 key pad has
three down the left, one above two.
"""
def test_default_is_right(self):
self.assertEqual(core.Layout().knob_side, "right")
self.assertEqual(core.DEFAULT_LAYOUT.knob_side, "right")
def test_flip_and_back(self):
lay = core.Layout(15, 3, 3, 5)
self.assertEqual(lay.with_knobs_on("left").knob_side, "left")
self.assertEqual(lay.with_knobs_on("left").with_knobs_on("right"), lay)
def test_nonsense_side_is_refused(self):
with self.assertRaises(ValueError):
core.Layout(15, 3, 3, 5, knob_side="up")
def test_survives_a_round_trip(self):
lay = core.Layout(15, 3, 3, 5).with_knobs_on("left")
self.assertEqual(core.Layout.from_json(json.loads(json.dumps(lay.to_json()))),
lay)
def test_old_files_still_load_as_right(self):
old = {"keys": 12, "knobs": 2, "rows": 3, "cols": 4, "name": ""}
self.assertEqual(core.Layout.from_json(old).knob_side, "right")
def test_resizing_keeps_the_side(self):
lay = core.Layout(15, 3, 3, 5).with_knobs_on("left")
self.assertEqual(lay.resized(keys=12, knobs=2).knob_side, "left")
def test_the_drawing_actually_moves(self):
try:
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtWidgets import QApplication
from macropad_gui import padview
except ImportError:
self.skipTest("PySide6 not installed")
QApplication.instance() or QApplication([])
view = padview.PadView()
view.resize(1000, 700)
def dial_x(side):
lay = core.Layout(15, 3, 3, 5).with_knobs_on(side)
view.set_layout(lay)
view.set_orientation("flat")
return (view.dials["dial1"].x(),
min(r.x() for r in view.keys.values()))
knob_r, keys_r = dial_x("right")
knob_l, keys_l = dial_x("left")
self.assertGreater(knob_r, keys_r, "knobs should sit right of the keys")
self.assertLess(knob_l, keys_l, "knobs should sit left of the keys")
class ThemeCatalogue(unittest.TestCase):
"""Every theme has to be complete and has to draw without blowing up."""
def test_knobs_north_is_registered(self):
from macropad_gui import themes
t = themes.get("knobs-north")
self.assertEqual(t.key, "knobs-north")
self.assertEqual(t.accent, "#ff7700")
self.assertEqual(t.badge, "star")
self.assertTrue(t.legend_box and t.query_badge and t.byte_prefix)
def test_the_other_styles_are_untouched(self):
"""Drawing switches belong to the style, so recolouring never moves them."""
from macropad_gui import themes
for key, t in themes.THEMES.items():
if themes.parts(key)[0] == "knobs-north":
continue
self.assertEqual(t.badge, "tape", key)
self.assertFalse(t.legend_box, key)
self.assertFalse(t.query_badge, key)
self.assertFalse(t.byte_prefix, key)
self.assertEqual(t.metrics, themes.Metrics(), key)
def test_the_old_keys_still_resolve(self):
from macropad_gui import themes
for old in ("silkscreen", "white-board", "nord", "catppuccin-mocha",
"catppuccin-latte", "blueprint", "sketch", "knobs-north"):
self.assertEqual(themes.get(old).key, old, old)
def test_the_grid_multiplies_out(self):
from macropad_gui import themes
want = sum(len(allowed) for _n, _b, allowed in themes.STYLES.values())
self.assertEqual(len(themes.THEMES), want)
# every style keeps its drawing switches whatever colours it wears
for style, (_n, base, allowed) in themes.STYLES.items():
for pal in allowed:
t = themes.get(themes.compose(style, pal).key)
self.assertEqual(t.metrics, base.metrics, f"{style}/{pal}")
self.assertEqual(t.badge, base.badge, f"{style}/{pal}")
self.assertEqual(t.accent, themes.PALETTES[pal][1]["accent"])
def test_a_style_refuses_colours_it_does_not_offer(self):
from macropad_gui import themes
# blueprint is only itself; asking for nord gives blueprint back
self.assertEqual(themes.get("blueprint/nord").key, "blueprint")
def test_junk_keys_fall_back(self):
from macropad_gui import themes
for junk in ("", "nonsense", "nope/also-nope", "silkscreen/nope"):
self.assertIn(themes.get(junk).key, themes.THEMES)
def test_every_theme_has_a_full_palette(self):
from macropad_gui import themes
fields = ("window", "board", "ink", "ink_dim", "hatch", "accent",
"accent_text", "good", "bad", "line", "field")
for key, t in themes.THEMES.items():
for f in fields:
value = getattr(t, f)
self.assertRegex(value, r"^#[0-9a-fA-F]{6}$", f"{key}.{f}")
def test_every_theme_paints(self):
try:
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtWidgets import QApplication
from macropad_gui import padview, themes
except ImportError:
self.skipTest("PySide6 not installed")
QApplication.instance() or QApplication([])
lay = core.Layout(12, 2, 3, 4)
try:
for key, theme in themes.THEMES.items():
padview.apply_theme(theme)
for orientation in ("flat", "upright"):
v = padview.PadView()
v.set_layout(lay)
v.set_orientation(orientation)
v.resize(700, 600)
v.looks = {c: padview.Look("Ctrl+Z", known=True)
for c in lay.control_ids}
v.looks["key2"] = padview.Look("Ctrl+S", pending=True)
v.looks["key3"] = padview.Look() # unknown
v.current = "dial1-push"
shot = v.grab().toImage()
self.assertFalse(shot.isNull(), f"{key} / {orientation}")
finally:
padview.apply_theme(themes.get(themes.DEFAULT))
class BoardBalance(unittest.TestCase):
"""
Three knobs' worth of legends is taller than three rows of keys. The
shorter of the two gets centred against the other, so the board has no
large empty corner.
"""
def view(self, layout, orientation="flat"):
try:
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtWidgets import QApplication
from macropad_gui import padview
except ImportError:
self.skipTest("PySide6 not installed")
QApplication.instance() or QApplication([])
v = padview.PadView()
v.resize(1000, 700)
v.set_layout(layout)
v.set_orientation(orientation)
return v
def middles(self, v):
from macropad_gui import padview
keys = list(v.keys.values())
key_mid = (min(r.top() for r in keys) + max(r.bottom() for r in keys)) / 2
# dials are centres, not rectangles; their legends hang underneath
dial_top = min(c.y() for c in v.dials.values()) - padview.R_OUT
dial_bottom = max(r.bottom() for r in v.rows.values())
return key_mid, (dial_top + dial_bottom) / 2
def test_keys_and_knobs_share_a_centre_line(self):
for side in ("left", "right"):
v = self.view(core.Layout(15, 3, 3, 5).with_knobs_on(side))
key_mid, dial_mid = self.middles(v)
self.assertLess(abs(key_mid - dial_mid), 60,
f"knobs on the {side}: centres {key_mid} vs {dial_mid}")
def test_no_large_empty_band_under_the_keys(self):
v = self.view(core.Layout(15, 3, 3, 5).with_knobs_on("left"))
keys_bottom = max(r.bottom() for r in v.keys.values())
keys_top = min(r.top() for r in v.keys.values())
below = v.board.bottom() - keys_bottom
above = keys_top - v.board.top()
# the gap under the keys used to be the whole dial column
self.assertLess(below, above + 140)
def test_a_tall_pad_still_works(self):
v = self.view(core.Layout(15, 1, 5, 3))
key_mid, dial_mid = self.middles(v)
self.assertLess(abs(key_mid - dial_mid), 60)
class KeypadKeys(unittest.TestCase):
"""
The capture shows this pad left the factory with keypad combinations on
key slots 13 to 15. We could send those bytes but not name them, so a
read came back as an unknown binding.
"""
def test_the_factory_bindings_in_the_capture_now_have_names(self):
reports = core.read_capture(
str(CAPTURES / "1189-8840-read.txt"), incoming=True)
big = core.Layout(15, 3, 3, 5)
for report in reports:
if report[1] != proto.MAGIC_READ:
continue
self.assertIsNotNone(core.decode_config(report, big),
f"action 0x{report[2]:02x} still has no name")
def test_round_trip(self):
for name in ("numpad0", "numpad5", "numpad9", "numpadplus",
"numpadenter", "numpaddot", "numlock"):
b = core.Binding(keys=name)
report = b.reports(3, core.action_byte("key1"), 1)[0]
self.assertEqual(core.decode_config(report), ("key1", 1, b), name)
def test_they_are_distinct_from_the_number_row(self):
for i in range(10):
self.assertNotEqual(proto.KEYCODES[str(i)],
proto.KEYCODES[f"numpad{i}"])
class ShapeGuess(unittest.TestCase):
"""
The pad reports counts, never geometry. The guess has to land wider
than it is tall, because every pad in this family is.
"""
def guess(self, keys, knobs):
d = core.Detection(speaks=True, keys=keys, knobs=knobs)
return d.layout.rows, d.layout.cols
def test_real_pads(self):
self.assertEqual(self.guess(12, 2), (3, 4)) # the pad this was built on
self.assertEqual(self.guess(15, 3), (3, 5)) # accdias's, confirmed by him
self.assertEqual(self.guess(9, 1), (3, 3))
self.assertEqual(self.guess(6, 1), (2, 3))
self.assertEqual(self.guess(3, 1), (1, 3))
def test_never_taller_than_wide(self):
for keys in range(1, proto.KEY_SLOTS + 1):
rows, cols = self.guess(keys, 0)
self.assertLessEqual(rows, cols, f"{keys} keys guessed {rows}x{cols}")
self.assertEqual(rows * cols, keys)
class Detection(unittest.TestCase):
"""
Detecting an unknown pad, using the real capture as the stand-in for a
pad that speaks this protocol, and mangled versions for ones that don't.
"""
def setUp(self):
self.dev = core.Device(path="/dev/null-pad", report_id=3, writable=True,
name="Some Other Pad", vid="1189", pid="8890")
got = core.read_capture(CAPTURES / "1189-8840-read.txt", incoming=True)
self.info = [r for r in got if r[1] == proto.MAGIC_INFO]
self.layer1 = [r for r in got if r[1] == proto.MAGIC_READ and r[3] == 1]
def run_detect(self, info=None, layer=None):
from unittest import mock
calls = [info if info is not None else self.info,
layer if layer is not None else self.layer1]
with mock.patch.object(core, "_exchange", side_effect=calls):
return core.detect(self.dev)
def test_a_pad_that_speaks_is_recognised(self):
d = self.run_detect()
self.assertTrue(d.speaks, d.why)
self.assertEqual((d.keys, d.knobs), (12, 2))
# 3 rows of 4, the way the pad is actually printed. The guess used
# to come out transposed and every pad was drawn on its side.
self.assertEqual(d.layout, core.Layout(12, 2, 3, 4))
self.assertEqual(d.bindings["dial1-left"], core.Binding(media="volumedown"))
def test_silence_is_not_taken_as_yes(self):
d = self.run_detect(info=[])
self.assertFalse(d.speaks)
self.assertIn("didn't answer", d.why)
def test_a_reply_that_is_not_ours_is_rejected(self):
d = self.run_detect(info=[bytes.fromhex("03aa5501") + bytes(60)])
self.assertFalse(d.speaks)
def test_nonsense_counts_are_rejected(self):
bad = bytearray(self.info[0])
bad[2] = 99 # 99 keys
d = self.run_detect(info=[bytes(bad)])
self.assertFalse(d.speaks)
self.assertIn("99 keys", d.why)
def test_a_pad_that_answers_one_query_but_not_the_other(self):
"""The dangerous case: sounds right, then doesn't describe itself."""
d = self.run_detect(layer=self.layer1[:4])
self.assertFalse(d.speaks)
self.assertIn("didn't report", d.why)
def test_a_six_key_pad(self):
info = bytearray(self.info[0])
info[2], info[3] = 6, 1
keep = {1, 2, 3, 4, 5, 6, 0x10, 0x11, 0x12}
layer = [r for r in self.layer1 if r[2] in keep]
d = self.run_detect(info=[bytes(info)], layer=layer)
self.assertTrue(d.speaks, d.why)
self.assertEqual(d.layout, core.Layout(6, 1, 2, 3))
self.assertEqual(len(d.layout.control_ids), 9)
def test_report_is_pasteable(self):
text = core.detection_report(self.dev, self.run_detect())
self.assertIn("1189:8890", text)
self.assertIn("0x10 dial1-left", text)
refused = core.detection_report(self.dev, self.run_detect(info=[]))
self.assertIn("did NOT answer", refused)
class LayoutIsRemembered(unittest.TestCase):
def test_state_keeps_the_pad_shape(self):
with tempfile.TemporaryDirectory() as d:
path = Path(d) / "s.json"
lay = core.Layout(9, 2, 3, 3, "nine")
st = core.State(path, layout=lay)
st.record("key9", core.Binding(keys="f5"))
st.save()
again = core.State(path)
self.assertEqual(again.layout, lay)
self.assertEqual(len(again.layout.control_ids), 15)
self.assertEqual(again.get("key9").binding, core.Binding(keys="f5"))
def test_an_old_state_file_still_loads(self):
with tempfile.TemporaryDirectory() as d:
path = Path(d) / "s.json"
path.write_text(json.dumps({
"format": core.FORMAT, "device": core.DEVICE_ID,
"layers": {"1": {"key1": {"binding": {"keys": "a"},
"written": "2026-01-01T00:00:00"}}}}))
st = core.State(path)
self.assertEqual(st.layout, core.DEFAULT_LAYOUT)
self.assertEqual(st.get("key1").binding, core.Binding(keys="a"))
class Arranging(unittest.TestCase):
"""Moving keys around the grid, and keeping it consistent when rotated."""
def setUp(self):
self.lay = core.DEFAULT_LAYOUT
def test_the_default_matches_the_printed_pad(self):
flat = {k: (r, c) for k, r, c in self.lay.spots()}
self.assertEqual(flat["key1"], (0, 0)) # top left, reading across
self.assertEqual(flat["key4"], (0, 3))
self.assertEqual(flat["key12"], (2, 3))
def test_upright_is_the_same_pad_turned(self):
from macropad_gui import padview
up = {k: (r, c) for k, r, c in padview._key_grid(self.lay, "upright")}
self.assertEqual(up["key1"], (3, 0)) # bottom left when stood up
self.assertEqual(up["key4"], (0, 0))
self.assertEqual(up["key12"], (0, 2))
self.assertEqual(len(set(up.values())), self.lay.keys)
def test_swapping_two_keys(self):
places = list(self.lay.spots())
moved = self.lay.rearranged(
[(2, 3) if k == "key1" else (0, 0) if k == "key12" else (r, c)
for k, r, c in places])
flat = {k: (r, c) for k, r, c in moved.spots()}
self.assertEqual(flat["key1"], (2, 3))
self.assertEqual(flat["key12"], (0, 0))
self.assertEqual(len(set(flat.values())), 12)
def test_resizing_keeps_what_still_fits(self):
moved = self.lay.rearranged(
[(2, 3) if k == "key1" else (0, 0) if k == "key12" else (r, c)
for k, r, c in self.lay.spots()])
self.assertEqual(moved.resized(cols=6).cols, 6)
smaller = moved.resized(rows=2, cols=2, keys=4)
self.assertEqual(len(smaller.spots()), 4)
for _, r, c in smaller.spots():
self.assertLess(r, 2)
self.assertLess(c, 2)
def test_a_grid_too_small_grows_instead_of_failing(self):
grown = self.lay.resized(rows=1, cols=3)
self.assertEqual((grown.rows, grown.cols), (4, 3))
self.assertEqual(len(grown.spots()), 12)
def test_layout_survives_a_save(self):
with tempfile.TemporaryDirectory() as d:
path = Path(d) / "s.json"
moved = self.lay.rearranged(
[(2, 3) if k == "key1" else (0, 0) if k == "key12" else (r, c)
for k, r, c in self.lay.spots()])
st = core.State(path, layout=moved)
st.save()
self.assertEqual(core.State(path).layout, moved)
class OtherSystems(unittest.TestCase):
def test_a_non_linux_system_gets_an_explanation(self):
from unittest import mock
with mock.patch.object(core, "on_linux", return_value=False):
d = core.diagnose()
self.assertEqual(d.status, "unsupported-os")
self.assertFalse(d.ready)
self.assertIsNone(d.device)
def test_the_permission_rule_covers_the_whole_vendor(self):
"""A rule tied to one product id strands everyone with a sibling pad."""
self.assertIn('idVendor}}=="1189"'.replace("}}", "}"), core.RULE_LINE)
self.assertNotIn("idProduct", core.RULE_LINE)
def test_an_unreadable_unknown_pad_is_told_how_to_fix_it(self):
from unittest import mock
other = [{"path": "/dev/hidraw4", "vid": "1189", "pid": "8890",
"report_id": 0, "writable": False, "name": ""}]
with mock.patch.object(proto, "find_devices", return_value=other), \
mock.patch.object(core, "_keyd_warning", return_value=None):
d = core.diagnose()
self.assertEqual(d.status, "unsupported")
self.assertTrue(d.fix)
self.assertIn("60-macropad.rules", d.fix[0])
class Layouts(unittest.TestCase):
def test_action_bytes_agree_with_the_protocol_module(self):
for c in core.DEFAULT_LAYOUT.control_ids:
self.assertEqual(core.action_byte(c), proto.CONTROLS[c], c)
def test_layouts_of_other_shapes(self):
three = core.Layout(3, 1, 1, 3)
self.assertEqual(three.control_ids,
("key1", "key2", "key3",
"dial1-left", "dial1-push", "dial1-right"))
self.assertEqual(core.action_byte("dial1-left"), 0x10)
fifteen = core.Layout(15, 3, 5, 3)
self.assertEqual(core.action_byte("dial3-right"), 0x18)
self.assertEqual(len(fifteen.control_ids), 24)
def test_impossible_layouts_are_refused(self):
for bad in ((0, 2, 4, 3), (99, 2, 40, 3), (12, 9, 4, 3), (12, 2, 2, 3)):
with self.assertRaises(ValueError, msg=bad):
core.Layout(*bad)
def test_round_trip(self):
l = core.Layout(9, 2, 3, 3, "nine")
self.assertEqual(core.Layout.from_json(l.to_json()), l)
class BindingModel(unittest.TestCase):
def test_round_trip(self):
for b in (core.Binding(keys="ctrl+shift+n"),
core.Binding(keys="a,b,c", delay=250),
core.Binding(media="volumeup")):
self.assertEqual(core.Binding.from_json(json.loads(json.dumps(b.to_json()))), b)
def test_rejects_nonsense(self):
bad = [core.Binding(), core.Binding(keys="a", media="mute"),
core.Binding(media="nope"), core.Binding(keys="ctrl+wat"),
core.Binding(media="mute", delay=10), core.Binding(keys="a", delay=-1)]
for b in bad:
with self.assertRaises(ValueError, msg=repr(b)):
b.validate()
def test_normalize(self):
self.assertEqual(core.normalize_keys(" Ctrl + C ,"), "ctrl+c")
self.assertEqual(core.normalize_keys("a, b"), "a,b")
self.assertEqual(core.normalize_keys("Meta+Ctrl+Left"), "super+ctrl+left")
def test_labels(self):
self.assertEqual(core.Binding(keys="ctrl+shift+n").label(), "Ctrl+Shift+N")
self.assertEqual(core.Binding(keys="super+pageup").label(), "Meta+Page Up")
self.assertEqual(core.Binding(media="mycomputer").label(), "My computer")
def test_nothing(self):
b = core.Binding(none=True).validate()
config, commit = b.reports(3, 0x04)
# type 1 (keys), count 0, empty pair: press nothing
self.assertEqual(config[:14].hex(" "), "03 fd 04 01 01 00 00 00 00 00 00 00 00 00")
self.assertEqual(commit[:4], b"\x03\xfd\xfe\xff")
self.assertFalse(any(config[11:]))
self.assertEqual(b.label(), "Nothing")
self.assertEqual(core.Binding.from_json(b.to_json()), b)
self.assertEqual(core.decode_config(config), ("key4", 1, b))
for bad in (core.Binding(none=True, keys="a"), core.Binding(none=True, media="mute"),
core.Binding(none=True, delay=5)):
with self.assertRaises(ValueError):
bad.validate()
def test_pause_is_unambiguous(self):
key = core.Binding(keys="pause").reports(3, 1)[0]
media = core.Binding(media="pause").reports(3, 1)[0]
self.assertEqual(key[4], proto.KeyType.BASIC)
self.assertEqual(media[4], proto.KeyType.MULTIMEDIA)
class ShadowState(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.path = Path(self.tmp.name) / "state.json"
self.dev = core.Device(path="/dev/null-pad", report_id=3, writable=True)
def tearDown(self):
self.tmp.cleanup()
def test_everything_starts_unknown(self):
s = core.State(self.path)
self.assertTrue(all(s.get(c).unknown for c in core.CONTROL_IDS))
def test_successful_write_is_recorded_and_persisted(self):
sent = []
s = core.State(self.path)
b = core.Binding(keys="ctrl+c")
core.write_binding(self.dev, "key4", b, s, _write=lambda p, r: sent.append(r))
self.assertEqual([r[:4] for r in sent], [b"\x03\xfd\x04\x01", b"\x03\xfd\xfe\xff"])
again = core.State(self.path)
self.assertFalse(again.get("key4").unknown)
self.assertEqual(again.get("key4").binding, b)
self.assertEqual([f for f in os.listdir(self.tmp.name) if f.endswith(".tmp")], [])
def test_failed_commit_leaves_control_unknown(self):
s = core.State(self.path)
s.record("key1", core.Binding(keys="a"))
calls = []
def flaky(path, report):
calls.append(report)
if len(calls) == 2:
raise OSError(errno.EIO, "I/O error")
with self.assertRaises(core.WriteError) as ctx:
core.write_binding(self.dev, "key1", core.Binding(keys="b"), s, _write=flaky)
self.assertTrue(ctx.exception.pad_touched)
e = core.State(self.path).get("key1")
self.assertTrue(e.unknown)
self.assertIn("1 of 2", e.reason)
def test_unplugged_before_first_write_changes_nothing(self):
s = core.State(self.path)
s.record("key1", core.Binding(keys="a"))
s.save()
def gone(path, report):
raise FileNotFoundError(errno.ENOENT, "No such file")
with self.assertRaises(core.WriteError) as ctx:
core.write_binding(self.dev, "key1", core.Binding(keys="b"), s, _write=gone)
self.assertFalse(ctx.exception.pad_touched)
self.assertEqual(core.State(self.path).get("key1").binding, core.Binding(keys="a"))
class UdevOrdering(unittest.TestCase):
"""udev sorts rule files by name; the uaccess tag must land before the cutoff."""
def test_ordering(self):
cutoff = "73-seat-late.rules"
self.assertLess("60-macropad.rules", cutoff)
self.assertLess("70-macropad.rules", cutoff)
self.assertGreaterEqual("99-macropad.rules", cutoff)
self.assertGreaterEqual("macropad.rules", cutoff) # letters sort after digits
def test_diagnose_without_hardware_does_not_crash(self):
d = core.diagnose()
self.assertIn(d.status, {"ready", "unplugged", "no-interface", "no-permission"})
if __name__ == "__main__":
unittest.main()
class PermissionDiagnosis(unittest.TestCase):
"""The three ways a pad ends up unwritable, with the system faked out."""
def diag(self, rules, tags):
from unittest import mock
dev = [{"path": "/dev/hidraw10", "vid": "1189", "pid": "8840",
"report_id": 3, "writable": False,
"name": "USB Composite Device"}]
with mock.patch.object(proto, "find_devices", return_value=dev), \
mock.patch.object(core, "_matching_rules", return_value=rules), \
mock.patch.object(core, "_udev_tags", return_value=tags), \
mock.patch.object(core, "_uaccess_cutoff", return_value="73-seat-late.rules"), \
mock.patch.object(core, "_keyd_warning", return_value=None):
return core.diagnose()
def test_rule_numbered_too_late(self):
d = self.diag(["/etc/udev/rules.d/99-macropad.rules"], {"uaccess", "seat"})
self.assertEqual(d.status, "no-permission")
self.assertIn("sudo mv /etc/udev/rules.d/99-macropad.rules "
"/etc/udev/rules.d/60-macropad.rules", d.fix)
def test_rule_not_loaded_yet(self):
d = self.diag(["/etc/udev/rules.d/60-macropad.rules"], set())
self.assertTrue(any("reload-rules" in c for c in d.fix))
self.assertFalse(any(c.startswith("sudo mv") for c in d.fix))
def test_no_rule(self):
"""
The fix has to write a rule for every vendor the app can talk to,
into the file that loads early enough. How it gets written there
is the rule writer's business: one vendor is an echo, several need
a printf, and pinning the command shape here just breaks the test
the next time a pad family is added.
"""
d = self.diag([], set())
self.assertIn("60-macropad.rules", d.fix[0])
for vendor in core.vendors():
self.assertIn(f'=="{vendor}"', d.fix[0], vendor)
def test_other_family_pad_is_refused_not_ignored(self):
from unittest import mock
other = [{"path": "/dev/hidraw4", "vid": "1189", "pid": "8890",
"report_id": 0, "writable": True, "name": ""}]
with mock.patch.object(proto, "find_devices", return_value=other), \
mock.patch.object(core, "_keyd_warning", return_value=None):
d = core.diagnose()
self.assertEqual(d.status, "unsupported")
self.assertIn("1189:8890", d.headline)
# the device comes with it now, so the app can offer to interrogate it
self.assertEqual(d.device.path, "/dev/hidraw4")
self.assertEqual((d.device.vid, d.device.pid), ("1189", "8890"))
def test_rule_fine_but_not_local_session(self):
d = self.diag(["/etc/udev/rules.d/60-macropad.rules"], {"uaccess"})
self.assertEqual(d.fix, [])
self.assertIn("SSH", d.detail[0])
class GuiSmoke(unittest.TestCase):
"""Builds the window offscreen. Skipped if Qt for Python isn't installed."""
def test_window_builds_and_selects(self):
try:
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtWidgets import QApplication
from macropad_gui import app
except ImportError:
self.skipTest("PySide6 not installed")
from unittest import mock
from macropad_gui import actions as act_mod
# Whatever is plugged in while the tests run, the window under test
# must see no pad, or it follows that pad's layout and the
# assertions below are about the wrong shape.
with tempfile.TemporaryDirectory() as d, \
mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": d}), \
mock.patch.object(core, "diagnose", return_value=core.Diagnosis(
"unplugged", "No pad connected.")):
qa = QApplication.instance() or QApplication([])
w = app.Window()
w.timer.stop()
for c in core.CONTROL_IDS:
w.pad.select(c)
w._refresh()
self.assertEqual(w.inspector.title.text(), core.control_name(c))
# the drawing must cope with any shape the protocol allows
for lay in (core.Layout(3, 1, 1, 3), core.Layout(15, 3, 5, 3),
core.Layout(4, 0, 1, 4)):
w.pad.set_layout(lay)
for orientation in ("upright", "flat"):
w.pad.set_orientation(orientation)
w.pad.grab()
for c in lay.control_ids:
self.assertIsNotNone(core.action_byte(c))
# dragging a key in edit mode swaps it with whatever is there
w.pad.set_layout(core.DEFAULT_LAYOUT)
w._edit_layout(True)
from macropad_gui import padview
before = {k: (r, c) for k, r, c
in padview._key_grid(w.pad.layout, w.pad.orientation)}
w.pad.drag = "key1"
w.pad._place("key1", before["key12"])
after = {k: (r, c) for k, r, c
in padview._key_grid(w.pad.layout, w.pad.orientation)}
self.assertEqual(after["key1"], before["key12"])
self.assertEqual(after["key12"], before["key1"])
w._edit_layout(False, keep=False)
self.assertEqual(w.state.layout, core.DEFAULT_LAYOUT)
self.assertFalse(w.pad.editing)
# an action typed for one key must not leak into the next one
w.pad.select("key2"); w._refresh()
w.inspector.mode_action.click()
w.inspector.action_edit.setText("github.com")
w.inspector._action_edited()
w.pad.select("key3"); w._refresh()
w.inspector.mode_action.click()
self.assertEqual(w.inspector.action_edit.text(), "")
# the picker: automatic avoids F13, and a chosen key is used
w.desired.clear(); w.pending_actions.clear()
w.pad.select("key4"); w._refresh()
w._host_action("key4", act_mod.Action("url", "a.com"))
self.assertEqual(w.desired["key4"].keys, "f14")
w._host_action("key4", act_mod.Action("url", "a.com"), "f22")
self.assertEqual(w.desired["key4"].keys, "f22")
opts = dict(w._trigger_options("key6"))
self.assertEqual(opts["f22"], "Key 4") # taken, shown as whose
self.assertIsNone(opts["f15"])
w.desired.clear(); w.pending_actions.clear()
w._changed("key1", core.Binding(media="mute"))
self.assertEqual(w._pending(), ["key1"])
# choosing Nothing marks the control as changed straight away
w.pad.select("key5")
w._refresh()
w.inspector.mode_none.click()
self.assertEqual(w.desired.get("key5"), core.Binding(none=True))
self.assertIn("key5", w._pending())
w.desired.clear()
w.close()
class ThemedDrawing(unittest.TestCase):
"""Every theme, every shape, both ways up, switched live."""
def test_switching_themes_live(self):
try:
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtWidgets import QApplication
from macropad_gui import app, padview, themes
except ImportError:
self.skipTest("PySide6 not installed")
from unittest import mock
with tempfile.TemporaryDirectory() as d, \
mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": d}):
qa = QApplication.instance() or QApplication([])
w = app.Window()
w.timer.stop()
for t in themes.THEMES.values():
w._set_theme(t.key)
self.assertEqual(padview.THEME.key, t.key)
self.assertEqual(padview.INK.name(), t.ink) # changed in place
for lay in (core.DEFAULT_LAYOUT, core.Layout(15, 3, 3, 5)):
w.pad.set_layout(lay)
for o in ("upright", "flat"):
w.pad.set_orientation(o)
w.pad.grab()
self.assertEqual(w.settings.value("theme"), list(themes.THEMES)[-1])
w._set_theme(themes.DEFAULT)
w.close()
def test_pencil_lines_are_the_same_every_time(self):
try:
from PySide6.QtGui import QPainterPath
from PySide6.QtCore import QRectF
from macropad_gui import padview
except ImportError:
self.skipTest("PySide6 not installed")
path = QPainterPath()
path.addRoundedRect(QRectF(0, 0, 96, 96), 5, 5)
a = padview._wobble_path(path, 1.6, padview._seed("key4"))
b = padview._wobble_path(path, 1.6, padview._seed("key4"))
c = padview._wobble_path(path, 1.6, padview._seed("key5"))
self.assertEqual([(p.x(), p.y()) for p in a.toFillPolygon()],
[(p.x(), p.y()) for p in b.toFillPolygon()])
self.assertNotEqual([(p.x(), p.y()) for p in a.toFillPolygon()],
[(p.x(), p.y()) for p in c.toFillPolygon()])
# it wanders, but only a little
box = a.boundingRect()
self.assertLess(abs(box.width() - 96), 5)
class WindowStaysPutWhenYouClick(unittest.TestCase):
"""
Clicking the first control used to make the window jump to full height,
on Linux and on Windows. The inspector's content is taller than the pad,
and with nothing to contain it the window's minimum height grew to fit,
which the window manager then obeyed. It lives in a scroll area now.
"""
def window(self, theme):
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from unittest import mock
from PySide6.QtWidgets import QApplication
from macropad_gui import app as A
ready = core.Diagnosis("ready", "Ready.", device=core.Device(
path="/dev/null-pad", report_id=3, writable=True,
vid="1189", pid="8840"))
QApplication.instance() or QApplication([])
A.apply_theme(QApplication.instance(), theme)
with mock.patch.object(core, "_keyd_warning", return_value=None), \
mock.patch.object(core, "diagnose", return_value=ready):
w = A.Window()
w.timer.stop()
w.diag = ready
return w, QApplication
def test_minimum_height_does_not_grow_on_the_first_click(self):
try:
from macropad_gui import themes
except ImportError:
self.skipTest("PySide6 not installed")
for key in ("silkscreen", "knobs-north", "blueprint"):
with tempfile.TemporaryDirectory() as d, \
mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": d}):
w, qapp = self.window(key)
w.resize(1000, 800)
w.show()
qapp.processEvents()
before = w.centralWidget().minimumSizeHint().height()
for control in ("key1", "dial1-left", "key7"):
w.pad.select(control)
w._refresh()
qapp.processEvents()
after = w.centralWidget().minimumSizeHint().height()
self.assertEqual(before, after, f"{key}: {before} -> {after}")
w.close()
def test_the_window_can_still_be_made_small(self):
try:
from macropad_gui import themes # noqa: F401
except ImportError:
self.skipTest("PySide6 not installed")
with tempfile.TemporaryDirectory() as d, \
mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": d}):
w, qapp = self.window("silkscreen")
w.resize(1000, 800)
w.show()
qapp.processEvents()
w.pad.select("key1")
w._refresh()
qapp.processEvents()
w.resize(800, 520)
qapp.processEvents()
self.assertLessEqual(w.height(), 540, "window refused to shrink")
w.close()
def test_the_inspector_is_inside_something_that_scrolls(self):
try:
from PySide6.QtWidgets import QScrollArea
except ImportError:
self.skipTest("PySide6 not installed")
with tempfile.TemporaryDirectory() as d, \
mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": d}):
w, _ = self.window("silkscreen")
self.assertIsInstance(w.panel, QScrollArea)
self.assertIs(w.panel.widget(), w.inspector)
self.assertTrue(w.panel.widgetResizable())
w.close()
class VersionIsLabelled(unittest.TestCase):
"""
One version string, in one place, reachable from everywhere that quotes
it. A release should be one line to change, and a bug report should
never be missing it.
"""
def test_it_is_a_plain_semver(self):
import re
from macropad_gui import __version__
self.assertRegex(__version__, r"^\d+\.\d+\.\d+$")
def test_the_cli_reports_it(self):
self.assertEqual(proto.version(), __import__("macropad_gui").__version__)
def test_the_cli_survives_being_copied_out_on_its_own(self):
"""
macropad.py is meant to work as a single file. With no GUI package
beside it the version is unknown, which is fine, but it must not
raise.
"""
from unittest import mock
real = __import__("builtins").__import__
def no_gui(name, *a, **kw):
if name == "macropad_gui":
raise ImportError("not there")
return real(name, *a, **kw)
with mock.patch("builtins.__import__", no_gui):
self.assertEqual(proto.version(), "unknown")
def test_a_detection_report_carries_it(self):
from macropad_gui import __version__
device = core.Device(path="/dev/hidraw9", vid="1189", pid="8840",
report_id=0, writable=True, name="")
text = core.detection_report(device, core.Detection(why="it said nothing"))
self.assertIn(__version__, text)