Files
macropad-itprotech/tests/test_backends.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

196 lines
7.1 KiB
Python

"""
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", "8808", "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", "8808", "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()