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.
This commit is contained in:
Mohamed Mahjoub
2026-10-08 22:14:31 +01:00
parent 6e25e350e0
commit 6a419eb3b6
34 changed files with 3410 additions and 572 deletions

View File

@@ -345,3 +345,40 @@ class Themes(unittest.TestCase):
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)

195
tests/test_backends.py Normal file
View File

@@ -0,0 +1,195 @@
"""
The backend seam.
A split you cannot exercise is not a split, so these tests register a second,
imaginary family and check that the app routes to it: different vendor id,
different slot numbering, different everything. Nothing here touches real
hardware and the fake backend is removed again afterwards.
"""
import tempfile
import unittest
from pathlib import Path
from unittest import mock
from macropad_gui import backends, core
from macropad_gui.backends.base import Backend
import macropad as proto
class Imaginary(Backend):
"""
Stands in for a second device family. Eight keys, no knobs, slots
numbered from 0x20 so nothing it returns could be mistaken for a CH57x
answer that leaked through.
"""
key = "imaginary"
name = "Imaginary pad"
vid = "c0de"
pids = ("0001",)
layers = 1
max_keys = 8
max_knobs = 0
def __init__(self):
self.calls = []
def action_byte(self, control):
self.calls.append(("action_byte", control))
return 0x20 + int(control[3:]) - 1
def info(self, device):
self.calls.append(("info", device.path))
return 8, 0
def read_layer(self, device, layer=1, layout=None):
self.calls.append(("read_layer", layer))
return {f"key{i}": core.Binding(keys="a") for i in range(1, 9)}
def write(self, device, control, binding, state, layer=1, _write=None):
self.calls.append(("write", control))
state.record(control, binding, layer)
state.save()
def detect(self, device):
self.calls.append(("detect", device.path))
return core.Detection(speaks=True, keys=8, knobs=0, layers=1,
bindings={"key1": core.Binding(keys="a")})
def fake_device(vid="c0de", pid="0001", path="/dev/null-pad"):
return core.Device(path=path, report_id=3, writable=True, vid=vid, pid=pid)
class Registry(unittest.TestCase):
def setUp(self):
self.fake = Imaginary()
self._saved = backends.REGISTRY
backends.REGISTRY = backends.REGISTRY + (self.fake,)
def tearDown(self):
backends.REGISTRY = self._saved
def test_each_family_gets_its_own_device(self):
self.assertIs(backends.for_vid_pid("c0de", "0001"), self.fake)
self.assertIsInstance(backends.for_vid_pid("1189", "8840"),
backends.CH57x)
def test_an_unknown_id_is_claimed_by_nobody(self):
self.assertIsNone(backends.for_vid_pid("dead", "beef"))
def test_a_sibling_under_a_known_vendor_is_not_claimed(self):
"""
1189:8890 exists and is not ours. It must not be claimed on the
strength of its vendor id, or the app would write to a pad whose
protocol it has never confirmed.
"""
self.assertIsNone(backends.for_vid_pid("1189", "8890"))
ch = backends.CH57x()
self.assertTrue(ch.could_claim("1189", "8890")) # still worth finding
def test_for_device_falls_back_rather_than_returning_none(self):
self.assertIsInstance(backends.for_device(None), backends.CH57x)
self.assertIsInstance(backends.for_device(fake_device("1189", "8890")),
backends.CH57x)
class Routing(unittest.TestCase):
"""core's functions must land on the backend that owns the device."""
def setUp(self):
self.fake = Imaginary()
self._saved = backends.REGISTRY
backends.REGISTRY = backends.REGISTRY + (self.fake,)
self.dir = tempfile.TemporaryDirectory()
self.state = core.State(Path(self.dir.name) / "s.json")
def tearDown(self):
backends.REGISTRY = self._saved
self.dir.cleanup()
def test_detect_routes(self):
found = core.detect(fake_device())
self.assertTrue(found.speaks)
self.assertEqual((found.keys, found.knobs), (8, 0))
self.assertIn(("detect", "/dev/null-pad"), self.fake.calls)
def test_read_routes(self):
got = core.read_layer(fake_device())
self.assertEqual(len(got), 8)
self.assertIn(("read_layer", 1), self.fake.calls)
def test_info_routes(self):
self.assertEqual(core.read_info(fake_device()), (8, 0))
def test_write_routes(self):
core.write_binding(fake_device(), "key1", core.Binding(keys="a"),
self.state)
self.assertIn(("write", "key1"), self.fake.calls)
self.assertEqual(self.state.get("key1").binding, core.Binding(keys="a"))
def test_slot_numbering_is_per_family(self):
self.assertEqual(self.fake.action_byte("key1"), 0x20)
self.assertEqual(core.action_byte("key1"), 1) # still CH57x
self.assertEqual(core.action_byte("key1", self.fake), 0x20)
def test_the_real_backend_is_untouched_by_the_fake(self):
ch = backends.for_vid_pid("1189", "8840")
self.assertEqual(ch.action_byte("dial2-right"), 0x15)
self.assertEqual(ch.max_keys, proto.KEY_SLOTS)
def test_discovery_covers_every_vendor(self):
self.assertEqual(core.vendors(), {"1189", "8089", "c0de"})
with mock.patch.object(proto, "find_devices", return_value=[]) as f:
core.discover()
f.assert_called_once()
self.assertEqual(f.call_args[0][0], {"1189", "8089", "c0de"})
def test_one_udev_rule_per_vendor(self):
lines = core.rule_lines()
self.assertEqual(len(lines), len(backends.REGISTRY))
self.assertTrue(any('"1189"' in l for l in lines))
self.assertTrue(any('"c0de"' in l for l in lines))
for line in lines:
self.assertIn('TAG+="uaccess"', line)
self.assertIn('KERNEL=="hidraw*"', line)
class HalfWritten(unittest.TestCase):
"""A backend that forgets a method should fail loudly, not silently."""
def test_the_base_class_refuses_to_guess(self):
bare = Backend()
dev = fake_device()
for call in (lambda: bare.action_byte("key1"),
lambda: bare.info(dev),
lambda: bare.read_layer(dev),
lambda: bare.write(dev, "key1", None, None),
lambda: bare.detect(dev)):
with self.assertRaises(NotImplementedError):
call()
class StateFiles(unittest.TestCase):
def test_the_default_file_name_has_not_changed(self):
"""Renaming it would orphan every existing user's bindings."""
with tempfile.TemporaryDirectory() as d:
with mock.patch.object(core, "config_dir", return_value=Path(d)):
self.assertEqual(core.State().path.name, "state-1189-8840.json")
def test_a_second_family_gets_its_own_file(self):
with tempfile.TemporaryDirectory() as d:
with mock.patch.object(core, "config_dir", return_value=Path(d)):
s = core.State(device_id="c0de:0001")
self.assertEqual(s.path.name, "state-c0de-0001.json")
s.record("key1", core.Binding(keys="a"))
s.save()
import json
self.assertEqual(json.loads(s.path.read_text())["device"],
"c0de:0001")
if __name__ == "__main__":
unittest.main()

View File

@@ -13,6 +13,7 @@ import os
import re
import tempfile
import unittest
from unittest import mock
from pathlib import Path
from macropad_gui import core
@@ -306,6 +307,94 @@ class KnobSide(unittest.TestCase):
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
@@ -768,9 +857,17 @@ class PermissionDiagnosis(unittest.TestCase):
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.assertTrue(d.fix[0].startswith("echo 'KERNEL==\"hidraw*\""))
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
@@ -915,3 +1012,122 @@ class ThemedDrawing(unittest.TestCase):
# 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)

352
tests/test_sayo.py Normal file
View File

@@ -0,0 +1,352 @@
"""
The SayoDevice backend, replayed against real captures.
No Sayo pad has ever been written to, so these tests stand in for the
hardware: a fake pad answers out of two WebHID captures of the vendor's
own configurator, and the backend has to come out with the same answers
the configurator did. The write test is the important one. It asserts
that our packet is byte for byte the packet the vendor's page sent for
the same change, which is the only check available until somebody runs
it on a desk.
The backend is deliberately not in backends.REGISTRY, so none of this
affects a real pad. Adding it there is one line, once a write has been
confirmed on hardware.
"""
import unittest
from unittest import mock
from macropad_gui import core
from macropad_gui.backends import sayo_proto as wire
from macropad_gui.backends.sayo import Sayo
def _payloads(direction):
return [bytes.fromhex(h) for d, h in wire.STARTUP if d == direction]
class FakePad:
"""
Answers read requests out of the capture and remembers writes.
It replies the way the real pad does: the report id on the front, a
heartbeat first so the backend has to step over one, and nothing at
all for an index that does not exist.
"""
def __init__(self, keys=8, name=True):
self.path = "/dev/hidraw-fake"
self.vid, self.pid, self.report_id = "8089", "000b", wire.REPORT_ID
self.name = "SayoDevice"
self.keys = keys
self.offer_name = name
self.written = []
self.records = {i: r for i, r in enumerate(wire.LIVE)}
# the heartbeat the real pad sends unasked, roughly once a second
HEARTBEAT = bytes([wire.REPORT_ID]) + bytes(63)
def exchange(self, path, request, want=None):
assert path == self.path
assert len(request) == 64 and request[0] == wire.REPORT_ID
asked = wire.parse_reply(request[1:])
table, index = asked["table"], asked["index"]
out = [self.HEARTBEAT]
if table == wire.TABLE_KEYS:
if index < self.keys:
out.append(bytes([wire.REPORT_ID]) + self.records[index])
else:
end = bytearray(wire.read_request(table, index))
end[wire.FLAG_AT] = wire.NO_SUCH_INDEX
out.append(bytes([wire.REPORT_ID]) + wire.seal(end))
elif table == wire.TABLE_NAME and self.offer_name:
out.append(bytes([wire.REPORT_ID]) + _payloads("in")[1])
return out
def write_report(self, path, report):
assert path == self.path
self.written.append(bytes(report))
class Capture(unittest.TestCase):
"""The captures themselves, before any backend touches them."""
def test_every_captured_packet_verifies(self):
for direction, h in wire.STARTUP:
payload = bytes.fromhex(h)
self.assertEqual(len(payload), 63, h[:8])
self.assertTrue(wire.verify(payload), f"{h[:8]} checksum")
def test_the_pad_reported_eight_keys_in_a_four_by_two_grid(self):
self.assertEqual(len(wire.LIVE), 8)
self.assertEqual(wire.grid_from_records(wire.LIVE), (2, 4))
def test_read_requests_match_the_vendors_own(self):
asked_for = 0
for want in _payloads("out"):
if want[wire.LEN_AT] != wire.REQUEST_LEN:
continue # that one is a write
asked = wire.parse_reply(want)
mine = wire.read_request(asked["table"], asked["index"])
self.assertEqual(mine, want, f"table {asked['table']:#04x}")
asked_for += 1
self.assertGreater(asked_for, 2, "the capture should hold several reads")
class Reading(unittest.TestCase):
def setUp(self):
self.pad = FakePad()
self.backend = Sayo()
patch = mock.patch.object(core, "_exchange", self.pad.exchange)
patch.start()
self.addCleanup(patch.stop)
def test_it_discovers_the_key_count_rather_than_assuming_one(self):
self.assertEqual(self.backend.info(self.pad), (8, 0))
def test_a_smaller_pad_reports_itself_smaller(self):
self.pad.keys = 4
self.assertEqual(self.backend.info(self.pad), (4, 0))
def test_a_silent_pad_gives_none_not_a_fake_shape(self):
"""None is what makes the app refuse to write. It has to stay None."""
self.pad.keys = 0
self.assertIsNone(self.backend.info(self.pad))
def test_heartbeats_do_not_get_mistaken_for_answers(self):
self.pad.HEARTBEAT = bytes([wire.REPORT_ID]) + bytes(63)
self.assertEqual(self.backend.info(self.pad), (8, 0))
def test_bindings_read_back_as_the_configurator_shows_them(self):
got = self.backend.read_layer(self.pad)
self.assertEqual(got["key1"].keys, "ctrl+numpadplus")
self.assertEqual(got["key5"].keys, "ctrl+numpadminus")
self.assertEqual(got["key7"].keys, "ctrl+shift+alt+rightbracket")
self.assertTrue(got["key8"].none)
def test_an_uncaptured_key_type_is_left_out_rather_than_guessed(self):
"""
Keys 2, 3 and 4 of the captured pad are type 0x80, which the owner
says are media transport keys. Read as a modifier they came out as
a bare "ctrl+shift" and "alt" that nobody had set, which is worse
than admitting we do not know.
"""
got = self.backend.read_layer(self.pad)
for control in ("key2", "key3", "key4"):
self.assertNotIn(control, got, control)
for control in ("key1", "key5", "key6", "key7", "key8"):
self.assertIn(control, got, control)
def test_it_reads_its_own_model_name(self):
self.assertEqual(self.backend.model(self.pad), "SayoDevice 2x4V RGB")
class Detecting(unittest.TestCase):
def setUp(self):
self.pad = FakePad()
self.backend = Sayo()
patch = mock.patch.object(core, "_exchange", self.pad.exchange)
patch.start()
self.addCleanup(patch.stop)
def test_it_uses_the_pads_geometry_instead_of_guessing(self):
found = self.backend.detect(self.pad)
self.assertTrue(found.speaks)
self.assertEqual((found.keys, found.knobs), (8, 0))
self.assertEqual(found.grid, (2, 4))
self.assertEqual(found.layout.rows, 2)
self.assertEqual(found.layout.cols, 4)
self.assertEqual(found.layout.name, "SayoDevice 2x4V RGB")
def test_a_pad_that_says_nothing_is_left_alone(self):
self.pad.keys = 0
found = self.backend.detect(self.pad)
self.assertFalse(found.speaks)
self.assertIn("key table", found.why)
class Writing(unittest.TestCase):
def setUp(self):
self.pad = FakePad()
self.backend = Sayo()
self.state = mock.Mock()
patch = mock.patch.object(core, "_exchange", self.pad.exchange)
patch.start()
self.addCleanup(patch.stop)
def _write(self, control, binding):
self.backend.write(self.pad, control, binding, self.state,
_write=self.pad.write_report)
def test_our_packet_is_the_one_the_vendors_page_sent(self):
"""
The capture caught the configurator reading key 8 and then setting
it to p. Same starting record, same change, so the bytes have to
match, checksum included.
"""
theirs = _payloads("out")[-1]
self._write("key8", core.Binding(keys="p"))
self.assertEqual(len(self.pad.written), 1)
self.assertEqual(self.pad.written[0], wire.report(theirs))
def test_it_keeps_the_pads_own_bytes_and_changes_only_the_binding(self):
before = wire.LIVE[0]
self._write("key1", core.Binding(keys="ctrl+shift+a"))
after = self.pad.written[0][1:]
moved = [i for i in range(63) if after[i] != before[i]]
self.assertEqual(moved, [wire.CHECK_AT, wire.CHECK_AT + 1,
wire.MOD_AT, wire.KEYCODE_AT])
self.assertEqual(after[wire.MOD_AT], 0x03)
self.assertEqual(after[wire.KEYCODE_AT], 0x04)
def test_clearing_a_key_zeroes_both_binding_bytes(self):
self._write("key1", core.Binding(none=True))
after = self.pad.written[0][1:]
self.assertEqual((after[wire.MOD_AT], after[wire.KEYCODE_AT]), (0, 0))
def test_a_key_the_pad_does_not_have_is_refused_before_sending(self):
with self.assertRaises(core.WriteError) as caught:
self._write("key9", core.Binding(keys="a"))
self.assertFalse(caught.exception.pad_touched)
self.assertEqual(self.pad.written, [])
def test_the_four_captured_media_keys_write(self):
"""
Each one has to come out as the exact packet the vendor's page sent
for the same change, which is what the third capture recorded.
"""
for name, want in wire.CONSUMER_CAPTURE.items():
self.pad.written.clear()
self._write("key8", core.Binding(media=name))
self.assertEqual(self.pad.written[0], wire.report(bytes.fromhex(want)),
name)
def test_a_media_key_we_have_not_captured_is_refused(self):
"""
This pad numbers media keys its own way, not by HID code, so a name
we have never seen set cannot be derived. Saying so beats writing a
number we made up.
"""
with self.assertRaises(core.WriteError) as caught:
self._write("key8", core.Binding(media="playpause"))
self.assertIn("captured", str(caught.exception).lower())
self.assertFalse(caught.exception.pad_touched)
self.assertEqual(self.pad.written, [])
def test_an_uncaptured_key_type_is_never_overwritten(self):
"""
The real risk this guards: writing a shortcut onto a type 0x80 key
would leave it flagged as one kind of key holding another kind's
bytes. Nothing reaches the pad, so the old binding survives.
"""
with self.assertRaises(core.WriteError) as caught:
self._write("key2", core.Binding(keys="a"))
self.assertFalse(caught.exception.pad_touched)
self.assertEqual(self.pad.written, [])
self.assertIn("0x80", str(caught.exception))
def test_switching_a_media_key_to_a_shortcut_clears_the_type(self):
self.pad.records[7] = bytes.fromhex(wire.CONSUMER_CAPTURE["volumeup"])
self._write("key8", core.Binding(keys="ctrl+c"))
after = self.pad.written[0][1:]
self.assertEqual(after[wire.KIND_AT], wire.KIND_KEYBOARD)
self.assertEqual(wire.read_binding(after), ("keyboard", 0x01, 0x06))
def test_a_keyboard_write_keeps_the_low_bit_of_the_type(self):
"""
Key 8 was type 0x01 and the vendor's page set it to p without
touching that bit, so neither do we.
"""
self._write("key8", core.Binding(keys="p"))
self.assertEqual(self.pad.written[0][1:][wire.KIND_AT], 0x01)
def test_a_sequence_is_refused_because_the_pad_holds_one_keystroke(self):
with self.assertRaises(core.WriteError) as caught:
self._write("key1", core.Binding(keys="a,b,c"))
self.assertFalse(caught.exception.pad_touched)
def test_knobs_are_refused_since_this_pad_has_none(self):
with self.assertRaises(ValueError):
self.backend.action_byte("dial1left")
def test_everything_written_reseals_and_round_trips(self):
for combo in ("a", "ctrl+c", "ctrl+shift+t", "meta+l", "super+l",
"win+l", "f5", "space", "numpadminus"):
self.pad.written.clear()
self._write("key1", core.Binding(keys=combo))
payload = self.pad.written[0][1:]
self.assertTrue(wire.verify(payload), combo)
mods, code = wire.parse_combo(combo)
self.assertEqual(payload[wire.MOD_AT], mods, combo)
self.assertEqual(payload[wire.KEYCODE_AT], code, combo)
class Drawing(unittest.TestCase):
"""
Every pad before this one had knobs, so a knobless board is a shape the
drawing code has never been given. It must come out in every theme
rather than dividing by a knob count of zero somewhere.
"""
def test_a_knobless_pad_draws_in_every_theme(self):
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtWidgets import QApplication
from PySide6.QtGui import QPixmap
from macropad_gui import padview, themes
QApplication.instance() or QApplication([])
shape = core.Layout(8, 0, 2, 4, "SayoDevice 2x4V RGB")
for name, theme in sorted(themes.THEMES.items()):
padview.apply_theme(theme)
view = padview.PadView()
view.layout = shape
view.looks = {c: padview.Look() for c in shape.control_ids}
view.resize(900, 520)
shot = QPixmap(view.size())
view.render(shot)
self.assertFalse(shot.isNull(), name)
def test_a_knobless_layout_has_only_keys(self):
shape = core.Layout(8, 0, 2, 4)
self.assertEqual(len(shape.control_ids), 8)
self.assertFalse([c for c in shape.control_ids if c.startswith("dial")])
class Routing(unittest.TestCase):
"""
The two families must not reach into each other. A Sayo pad has to go
to the Sayo backend and a CH57x to the CH57x one, and a Sayo pid we
have never seen must not be claimed outright: it goes through detect()
first, the same as an unknown CH57x sibling.
"""
def test_a_sayo_pad_routes_to_the_sayo_backend(self):
from macropad_gui import backends
self.assertEqual(backends.for_vid_pid("8089", "000b").key, "sayo")
def test_a_ch57x_pad_still_routes_to_ch57x(self):
from macropad_gui import backends
self.assertEqual(backends.for_vid_pid("1189", "8840").key, "ch57x")
def test_the_ch57x_backend_does_not_claim_a_sayo(self):
from macropad_gui import backends
self.assertFalse(backends.DEFAULT.claims("8089", "000b"))
def test_an_unseen_sayo_pid_is_not_claimed_outright(self):
from macropad_gui import backends
self.assertIsNone(backends.for_vid_pid("8089", "0099"))
self.assertTrue(Sayo().could_claim("8089", "0099"))
def test_the_installer_offers_a_rule_for_the_sayo_vendor(self):
from macropad_gui import core
self.assertTrue(any("8089" in line for line in core.rule_lines()))
if __name__ == "__main__":
unittest.main()