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:
@@ -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
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()
|
||||
@@ -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
352
tests/test_sayo.py
Normal 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()
|
||||
Reference in New Issue
Block a user