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