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:
195
tests/test_backends.py
Normal file
195
tests/test_backends.py
Normal 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()
|
||||
Reference in New Issue
Block a user