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>
196 lines
7.1 KiB
Python
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()
|