summaryrefslogtreecommitdiff
path: root/test
diff options
context:
space:
mode:
authorPeter Hutterer <peter.hutterer@who-t.net>2022-02-08 22:09:46 +1000
committerPeter Hutterer <peter.hutterer@who-t.net>2022-02-22 07:39:28 +1000
commit858bb568716f19fd3973088a2af237904584fea9 (patch)
tree1095ced675b65857a0394c396d676030b7671338 /test
parentdbe4f9e687a481020ad27b495b6f16c2767f0814 (diff)
downloadxf86-input-wacom-858bb568716f19fd3973088a2af237904584fea9.tar.gz
test: add a pytest test suite
This test suite makes use of the gwacom library and its GObject bindings, allowing us to write the actual tests in python. Real devices can be specified as a YAML file in test/devices. In the tests we can either create custom device or load the correct Pen/Finger/Pad component from the real device. Devices are then created as uinput devices [1], with a Monitor class set up to capture any events. The test needs to initialize that device and monitor, then play events through the device and analyze the output. [1] This unfortunately won't work in containers, like the github CI... Signed-off-by: Peter Hutterer <peter.hutterer@who-t.net>
Diffstat (limited to 'test')
-rw-r--r--test/Makefile.am7
-rw-r--r--test/__init__.py354
-rw-r--r--test/conftest.py31
-rw-r--r--test/devices/wacom-pth660.yml76
-rw-r--r--test/test_wacom.py72
5 files changed, 540 insertions, 0 deletions
diff --git a/test/Makefile.am b/test/Makefile.am
index fe371e2..d2b7f33 100644
--- a/test/Makefile.am
+++ b/test/Makefile.am
@@ -17,3 +17,10 @@ wacom_tests_SOURCES = wacom-tests.c
TESTS=$(check_PROGRAMS)
endif
+
+EXTRA_DIST= \
+ __init__.py \
+ conftest.py \
+ test_wacom.py \
+ devices/wacom-pth660.yml \
+ $(NULL)
diff --git a/test/__init__.py b/test/__init__.py
new file mode 100644
index 0000000..e6d93fa
--- /dev/null
+++ b/test/__init__.py
@@ -0,0 +1,354 @@
+# Copyright 2022 Red Hat, Inc
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+from typing import Dict, List, Union
+from pathlib import Path
+
+import attr
+import enum
+import pytest
+import libevdev
+import logging
+import yaml
+
+import gi
+
+try:
+ gi.require_version("wacom", "1.0")
+ from gi.repository import wacom
+except ValueError as e:
+ print(e)
+ print(
+ "Export the following variables to fix this error (note the build directory name)"
+ )
+ print('$ export GI_TYPELIB_PATH="$PWD/builddir:$GI_TYPELIB_PATH"')
+ print('$ export LD_LIBRARY_PATH="$PWD/builddir:$LD_LIBRARY_PATH"')
+ raise ImportError("Unable to load GIR bindings")
+
+
+logger = logging.getLogger(__name__)
+
+
+@attr.s
+class InputId:
+ product: int = attr.ib()
+ bustype: int = attr.ib(default=0x3)
+ vendor: int = attr.ib(default=0x56A)
+ version: int = attr.ib(default=0)
+
+
+@attr.s
+class Device:
+ """
+ The class to set up a device. The best way to use this class is to define
+ a device in a yaml file, then use :meth:`Device.from_name` to load that
+ file.
+ """
+
+ name: str = attr.ib()
+ id: InputId = attr.ib()
+ bits: List[libevdev.EventCode] = attr.ib()
+ absinfo: Dict[libevdev.EventCode, libevdev.InputAbsInfo] = attr.ib(
+ default=attr.Factory(dict)
+ )
+ props: List[libevdev.InputProperty] = attr.ib(default=attr.Factory(list))
+
+ def create_uinput(self) -> "UinputDevice":
+ """
+ Convert this device into a uinput device and return it.
+ """
+ d = libevdev.Device()
+ d.name = self.name
+ d.id = {
+ "bustype": self.id.bustype,
+ "vendor": self.id.vendor,
+ "product": self.id.product,
+ }
+
+ for b in self.bits:
+ d.enable(b)
+
+ for code, absinfo in self.absinfo.items():
+ d.enable(code, absinfo)
+
+ for prop in self.props:
+ d.enable(prop)
+
+ try:
+ return UinputDevice(d.create_uinput_device())
+ except PermissionError:
+ pytest.skip("Insufficient permissions to create uinput device")
+ except FileNotFoundError:
+ pytest.skip("/dev/uinput not available")
+
+ @classmethod
+ def from_name(cls, name: str, type: str) -> "Device":
+ """
+ Create a Device from the given name with the given type (pen, pad,
+ finger). This method iterates through the test/devices/*.yml files and
+ finds the file for the device with the given name, then loads the
+ matching event node for that type.
+ """
+ type = type.lower()
+ assert type.lower() in ("pen", "pad", "finger")
+
+ for ymlfile in Path("test/devices").glob("*.yml"):
+ with open(ymlfile) as fd:
+ yml = yaml.safe_load(fd)
+ logger.debug(f"Found device: {yml['name']}")
+ if yml["name"].upper() != name.upper():
+ continue
+
+ for d in yml["devices"]:
+ if d["type"] != type:
+ continue
+
+ name = d["name"]
+ id = InputId(*[int(i, 16) for i in d["id"]])
+ bits = [libevdev.evbit(b) for b in d["bits"]]
+ abs = {
+ libevdev.evbit(n): libevdev.InputAbsInfo(*v)
+ for n, v in d["abs"].items()
+ }
+ props = [libevdev.propbit(p) for p in d["props"]]
+
+ return Device(name=name, id=id, bits=bits, absinfo=abs, props=props)
+ raise ValueError(f"Device '{name}' does not have type '{type}'")
+
+ raise ValueError(f"Device '{name}' does not exist")
+
+
+@attr.s
+class UinputDevice:
+ """
+ A warpper around a uinput device.
+ """
+
+ uidev: libevdev.Device = attr.ib()
+
+ @property
+ def devnode(self):
+ return self.uidev.devnode
+
+ def write_events(self, events: List["Ev"]):
+ """
+ Write the list of events to the uinput device. If a SYN_REPORT is not
+ the last element in the event list, it is automatically appended.
+ """
+ last = events[-1].libevdev_event
+ if last.code != libevdev.EV_SYN.SYN_REPORT:
+ events += [libevdev.InputEvent(libevdev.EV_SYN.SYN_REPORT, 0)]
+ self.uidev.send_events([e.libevdev_event for e in events])
+
+
+@attr.s
+class Monitor:
+ """
+ An event monitor for a Wacom driver device. This monitor logs any messages
+ from the driver and accumulates all events emitted in a list.
+ """
+
+ uidev: UinputDevice = attr.ib()
+ device: Device = attr.ib()
+ wacom_device: wacom.Device = attr.ib()
+ events = attr.ib(default=attr.Factory(list))
+
+ @classmethod
+ def new(
+ cls,
+ device: Device,
+ uidev: UinputDevice,
+ wacom_device: wacom.Device,
+ ) -> "Monitor":
+ m = cls(
+ device=device,
+ uidev=uidev,
+ wacom_device=wacom_device,
+ )
+
+ def cb_log(wacom_device, prefix, msg):
+ logger.info(f"{prefix}: {msg.strip()}")
+
+ def cb_debug_log(wacom_device, level, func, msg):
+ logger.debug(f"DEBUG: {level:2d}: {func:32s}| {msg.strip()}")
+
+ def cb_proximity(wacom_device, is_prox_in, axes):
+ m.events.append(Proximity(is_prox_in, axes))
+
+ def cb_button(wacom_device, is_absolute, button, is_press, axes):
+ m.events.append(
+ Button(
+ is_absolute=is_absolute, button=button, is_press=is_press, axes=axes
+ )
+ )
+
+ def cb_motion(wacom_device, is_absolute, axes):
+ m.events.append(Motion(is_absolute=is_absolute, axes=axes))
+
+ def cb_key(wacom_device, key, is_press):
+ m.events.append(Key(is_press=is_press, key=key))
+
+ def cb_touch(wacom_device, type, touchid, x, y):
+ m.events.append(Touch(type=Touch.Type(type), id=touchid, x=x, y=y))
+
+ wacom_device.connect("log-message", cb_log)
+ wacom_device.connect("debug-message", cb_debug_log)
+ wacom_device.connect("proximity", cb_proximity)
+ wacom_device.connect("button", cb_button)
+ wacom_device.connect("motion", cb_motion)
+ wacom_device.connect("keycode", cb_key)
+ wacom_device.connect("button", cb_button)
+
+ return m
+
+ @classmethod
+ def new_from_device(cls, device: Device, opts: Dict[str, str]) -> "Monitor":
+ uidev = device.create_uinput()
+ try:
+ with open(uidev.devnode, "rb"):
+ pass
+ except PermissionError:
+ pytest.skip("Insufficient permissions to open event node")
+
+ opts["Device"] = uidev.devnode
+ wacom_options = wacom.Options()
+ for name, value in opts.items():
+ wacom_options.set(name, value)
+
+ wacom_driver = wacom.Driver()
+ wacom_device = wacom.Device.new(wacom_driver, device.name, wacom_options)
+ logger.debug(f"PreInit for '{device.name}' with options {opts}")
+
+ monitor = cls.new(device, uidev, wacom_device)
+
+ assert wacom_device.preinit()
+ assert wacom_device.setup()
+ assert wacom_device.enable()
+
+ return monitor
+
+ def write_events(self, events: List[Union["Ev", "Sev"]]) -> None:
+ evs = [e.scale(self.device) if isinstance(e, Sev) else e for e in events]
+ self.uidev.write_events(evs)
+
+
+@attr.s
+class Ev:
+ """
+ A class to simplify writing event sequences.
+
+ >>> Ev("BTN_TOUCH", 1)
+ >>> Ev("ABS_X", 1234)
+
+ Note that the value in an Ev must be in device coordinates, see
+ :class:`Sev` for the scaled version.
+ """
+
+ name: str = attr.ib()
+ value: int = attr.ib()
+
+ @property
+ def libevdev_event(self):
+ return libevdev.InputEvent(libevdev.evbit(self.name.upper()), self.value)
+
+
+@attr.s
+class Sev:
+ """
+ A class to simplify writing event sequences in a generic manner. The value
+ range for any ``ABS_FOO`` axis is interpreted as percent of the axis range
+ on the device to replay. For example, to put the cursor in the middle of a
+ tablet, use:
+
+ >>> Sev("ABS_X", 50.0)
+ >>> Sev("ABS_Y", 50.0)
+
+ The value is a real number, converted to an int when scaled into the axis
+ range.
+ """
+
+ name: str = attr.ib()
+ value: float = attr.ib()
+
+ @value.validator
+ def _check_value(self, attribute, value):
+ if -20 <= value <= 120: # Allow for 20% outside range for niche test cases
+ return
+ raise ValueError("value must be in percent")
+
+ def scale(self, device: Device) -> Ev:
+ value = self.value
+ if self.name.startswith("ABS_") and self.name not in [
+ "ABS_MT_SLOT",
+ "ABS_MT_TRACKING_ID",
+ ]:
+ evbit = libevdev.evbit(self.name)
+ absinfo = device.absinfo[evbit]
+ value = (
+ absinfo.minimum
+ + (absinfo.maximum - absinfo.minimum + 1) * self.value / 100.0
+ )
+
+ return Ev(self.name, int(value))
+
+
+@attr.s
+class Proximity:
+ """A proximity event"""
+
+ is_prox_in: bool = attr.ib()
+ axes: wacom.EventData = attr.ib()
+
+
+@attr.s
+class Button:
+ """A button event"""
+
+ is_absolute: bool = attr.ib()
+ button: int = attr.ib()
+ is_press: bool = attr.ib()
+ axes: wacom.EventData = attr.ib()
+
+
+@attr.s
+class Key:
+ """A key event"""
+
+ button: int = attr.ib()
+ is_press: bool = attr.ib()
+
+
+@attr.s
+class Motion:
+ """A motion event"""
+
+ is_absolute: bool = attr.ib()
+ axes: wacom.EventData = attr.ib()
+
+
+@attr.s
+class Touch:
+ """A touch event"""
+
+ class Type(enum.IntEnum):
+ BEGIN = 0
+ UPDATE = 1
+ END = 2
+
+ type: Type = attr.ib()
+ id: int = attr.ib()
+ x: int = attr.ib()
+ y: int = attr.ib()
diff --git a/test/conftest.py b/test/conftest.py
new file mode 100644
index 0000000..c68a1d0
--- /dev/null
+++ b/test/conftest.py
@@ -0,0 +1,31 @@
+import pytest
+
+# We set up hooks to count how many tests actually ran. Since we need uinput
+# for the tests, it's likely they all get skipped when we don't run as root or
+# uinput isn't available.
+# If all tests are skipped, we want to exit with 77, not success
+
+
+def pytest_sessionstart(session):
+ session.count_not_skipped = 0
+ session.count_skipped = 0
+
+
+@pytest.hookimpl(tryfirst=True, hookwrapper=True)
+def pytest_runtest_makereport(item, call):
+ outcome = yield
+ result = outcome.get_result()
+
+ if result.when == "call":
+ if result.skipped:
+ item.session.count_skipped += 1
+ else:
+ item.session.count_not_skipped += 1
+
+
+def pytest_sessionfinish(session, exitstatus):
+ if session.count_not_skipped == 0 and session.count_skipped > 0:
+ session.exitstatus = 77
+ reporter = session.config.pluginmanager.get_plugin("terminalreporter")
+ reporter.section("Session errors", sep="-", red=True, bold=True)
+ reporter.line(f"{session.count_skipped} tests were skipped, none were run")
diff --git a/test/devices/wacom-pth660.yml b/test/devices/wacom-pth660.yml
new file mode 100644
index 0000000..84b3bcb
--- /dev/null
+++ b/test/devices/wacom-pth660.yml
@@ -0,0 +1,76 @@
+# Wacom Intuos Pro (PTH660)
+name: "PTH660"
+devices:
+ # Pen device
+ - type: "pen" # one out of pen, pad, finger
+ name: "Wacom Intuos Pro M Pen"
+ id: ["0x3", "0x56a", "0x357", "0x110"]
+ bits:
+ - BTN_TOOL_PEN
+ - BTN_TOOL_RUBBER
+ - BTN_TOOL_AIRBRUSH
+ - BTN_STYLUS
+ - BTN_STYLUS2
+ - BTN_STYLUS3
+ - BTN_TOUCH
+ - MSC_SERIAL
+ abs:
+ # value, min, max, fuzz, flat, resolution
+ ABS_X: [0, 44800, 4, 0, 200]
+ ABS_Y: [0, 29600, 4, 0, 200]
+ ABS_Z: [-900, 899, 0, 0, 287]
+ ABS_WHEEL: [0, 2047, 0, 0, 0]
+ ABS_PRESSURE: [0, 8191, 0, 0, 0]
+ ABS_DISTANCE: [0, 63, 0, 0, 0]
+ ABS_TILT_X: [-64, 63, 0, 0, 57]
+ ABS_TILT_Y: [-64, 63, 0, 0, 57]
+ ABS_MISC: [-2147483648, 2147483647, 0, 0, 0]
+ props: [INPUT_PROP_POINTER]
+ # Pad device
+ - type: "pad"
+ name: "Wacom Intuos Pro M Pad"
+ id: ["0x3", "0x56a", "0x357", "0x110"]
+ bits:
+ - BTN_0
+ - BTN_1
+ - BTN_2
+ - BTN_3
+ - BTN_4
+ - BTN_5
+ - BTN_6
+ - BTN_7
+ - BTN_8
+ - BTN_8
+ - BTN_STYLUS
+ abs:
+ # value, min, max, fuzz, flat, resolution
+ ABS_X: [0, 1, 0, 0, 0]
+ ABS_Y: [0, 1, 0, 0, 0]
+ ABS_WHEEL: [0, 71, 0, 0, 11]
+ ABS_MISC: [0, 0, 0, 0, 0]
+ props: [INPUT_PROP_POINTER]
+ # Finger device
+ - type: "finger"
+ name: "Wacom Intuos Pro M Finger"
+ id: ["0x3", "0x56a", "0x357", "0x110"]
+ bits:
+ - BTN_TOOL_FINGER
+ - BTN_TOOL_DOUBLETAP
+ - BTN_TOOL_TRIPLETAP
+ - BTN_TOOL_QUADTAP
+ - BTN_TOOL_QUINTTAP
+ - BTN_TOUCH
+ - SW_MUTE_DEVICE
+ abs:
+ # value, min, max, fuzz, flat, resolution
+ ABS_X: [0, 8960, 0, 0, 40]
+ ABS_Y: [0, 5920, 0, 0, 40]
+ ABS_MT_SLOT: [0, 9, 0, 0, 0]
+ ABS_MT_TOUCH_MAJOR: [0, 31, 0, 0, 2]
+ ABS_MT_TOUCH_MINOR: [0, 31, 0, 0, 2]
+ ABS_MT_ORIENTATION: [0, 1, 0, 0, 0]
+ ABS_MT_POSITION_X: [0, 8960, 0, 0, 40]
+ ABS_MT_POSITION_Y: [0, 5920, 0, 0, 40]
+ ABS_MT_TRACKING_ID: [0, 65535, 0, 0, 0]
+ props: [INPUT_PROP_POINTER]
+
diff --git a/test/test_wacom.py b/test/test_wacom.py
new file mode 100644
index 0000000..4274a5c
--- /dev/null
+++ b/test/test_wacom.py
@@ -0,0 +1,72 @@
+# Copyright 2022 Red Hat, Inc
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+
+from typing import Dict
+from . import Device, Monitor, Sev, Proximity
+
+import pytest
+import logging
+from gi.repository import GLib
+
+logger = logging.getLogger(__name__)
+
+
+@pytest.fixture
+def mainloop():
+ """Default mainloop fixture, exiting after 500ms"""
+ mainloop = GLib.MainLoop()
+ GLib.timeout_add(500, mainloop.quit)
+ return mainloop
+
+
+@pytest.fixture
+def opts() -> Dict[str, str]:
+ """Default driver options (for debugging)"""
+ return {
+ "CommonDBG": "12",
+ "DebugLevel": "12",
+ }
+
+
+def test_proximity(mainloop, opts):
+ """
+ Simple test to verify proximity in/out events are sent
+ """
+ dev = Device.from_name("PTH660", "Pen")
+ monitor = Monitor.new_from_device(dev, opts)
+
+ prox_in = [
+ Sev("ABS_X", 50),
+ Sev("ABS_Y", 50),
+ Sev("BTN_TOOL_PEN", 1),
+ Sev("SYN_REPORT", 0),
+ ]
+ prox_out = [
+ Sev("ABS_X", 50),
+ Sev("ABS_Y", 50),
+ Sev("BTN_TOOL_PEN", 0),
+ Sev("SYN_REPORT", 0),
+ ]
+ monitor.write_events(prox_in)
+ monitor.write_events(prox_out)
+ mainloop.run()
+
+ assert isinstance(monitor.events[0], Proximity)
+ assert monitor.events[0].is_prox_in
+
+ assert isinstance(monitor.events[-1], Proximity)
+ assert not monitor.events[-1].is_prox_in