diff --git a/README.md b/README.md index 9f916a0..4ee05b9 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,13 @@ found in `setup.cfg` loosening the rules in isolated cases, notably `user_keymaps` (which is *also* not subject to Black formatting for reasons documented in `pyproject.toml`). +## Tests + +Unit tests within the `tests` folder mock various CicuitPython modules to allow +them to be executed in a desktop development environment. + +Execute tests using the command `python -m unittest`. + ## License, Copyright, and Legal All software in this repository is licensed under the [GNU Public License, diff --git a/kmk/hid.py b/kmk/hid.py index 8684ceb..56b0329 100644 --- a/kmk/hid.py +++ b/kmk/hid.py @@ -53,6 +53,7 @@ class AbstractHID: REPORT_BYTES = 8 def __init__(self, **kwargs): + self._prev_evt = bytearray(self.REPORT_BYTES) self._evt = bytearray(self.REPORT_BYTES) self.report_device = memoryview(self._evt)[0:1] self.report_device[0] = HIDReportTypes.KEYBOARD @@ -125,7 +126,10 @@ class AbstractHID: pass def send(self): - self.hid_send(self._evt) + changed = not self._evt.startswith(self._prev_evt) + if changed: + self._prev_evt[:] = self._evt + self.hid_send(self._evt) return self diff --git a/kmk/kmk_keyboard.py b/kmk/kmk_keyboard.py index ea1fe3f..b643de8 100644 --- a/kmk/kmk_keyboard.py +++ b/kmk/kmk_keyboard.py @@ -373,6 +373,11 @@ class KMKKeyboard: print('Failed to run post hid function in extension: ', err, ext) def go(self, hid_type=HIDModes.USB, secondary_hid_type=None, **kwargs): + self._init(hid_type=hid_type, secondary_hid_type=secondary_hid_type, **kwargs) + while True: + self._main_loop() + + def _init(self, hid_type=HIDModes.USB, secondary_hid_type=None, **kwargs): self._go_args = kwargs self.hid_type = hid_type self.secondary_hid_type = secondary_hid_type @@ -398,46 +403,44 @@ class KMKKeyboard: self._print_debug_cycle(init=True) - while True: - self.current_key = None - self.state_changed = False - self.sandbox.active_layers = self.active_layers.copy() + def _main_loop(self): + self.current_key = None + self.state_changed = False + self.sandbox.active_layers = self.active_layers.copy() - self.before_matrix_scan() + self.before_matrix_scan() - self.matrix_update = ( - self.sandbox.matrix_update - ) = self.matrix.scan_for_changes() - self.sandbox.secondary_matrix_update = self.secondary_matrix_update + self.matrix_update = self.sandbox.matrix_update = self.matrix.scan_for_changes() + self.sandbox.secondary_matrix_update = self.secondary_matrix_update - self.after_matrix_scan() + self.after_matrix_scan() - self._handle_matrix_report(self.secondary_matrix_update) - self.secondary_matrix_update = None - self._handle_matrix_report(self.matrix_update) - self.matrix_update = None + self._handle_matrix_report(self.secondary_matrix_update) + self.secondary_matrix_update = None + self._handle_matrix_report(self.matrix_update) + self.matrix_update = None - self.before_hid_send() + self.before_hid_send() + if self.hid_pending: + self._send_hid() + + self._old_timeouts_len = len(self._timeouts) + self._process_timeouts() + self._new_timeouts_len = len(self._timeouts) + + if self._old_timeouts_len != self._new_timeouts_len: + self.state_changed = True if self.hid_pending: self._send_hid() - self._old_timeouts_len = len(self._timeouts) - self._process_timeouts() - self._new_timeouts_len = len(self._timeouts) + self.after_hid_send() - if self._old_timeouts_len != self._new_timeouts_len: - self.state_changed = True - if self.hid_pending: - self._send_hid() + if self._trigger_powersave_enable: + self.powersave_enable() - self.after_hid_send() + if self._trigger_powersave_disable: + self.powersave_disable() - if self._trigger_powersave_enable: - self.powersave_enable() - - if self._trigger_powersave_disable: - self.powersave_disable() - - if self.state_changed: - self._print_debug_cycle() + if self.state_changed: + self._print_debug_cycle() diff --git a/kmk/modules/holdtap.py b/kmk/modules/holdtap.py index 840c4ee..4145e8f 100644 --- a/kmk/modules/holdtap.py +++ b/kmk/modules/holdtap.py @@ -44,8 +44,7 @@ class HoldTap(Module): self.ht_activate_on_interrupt( key, keyboard, *state.args, **state.kwargs ) - if keyboard.hid_pending: - keyboard._send_hid() + keyboard._send_hid() return current_key def before_hid_send(self, keyboard): diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e17e7e5 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,3 @@ +from tests.mocks import init_circuit_python_modules_mocks + +init_circuit_python_modules_mocks() diff --git a/tests/keyboard_test.py b/tests/keyboard_test.py new file mode 100644 index 0000000..0caff80 --- /dev/null +++ b/tests/keyboard_test.py @@ -0,0 +1,94 @@ +import random +import time +from functools import reduce +from unittest.mock import Mock, patch + +from kmk.hid import HIDModes +from kmk.keys import ModifierKey +from kmk.kmk_keyboard import KMKKeyboard +from kmk.matrix import DiodeOrientation + + +class DigitalInOut(Mock): + value = False + + +class KeyboardTest: + def __init__( + self, modules, keymap, keyboard_debug_enabled=False, debug_enabled=False + ): + self.debug_enabled = debug_enabled + + self.keyboard = KMKKeyboard() + self.keyboard.debug_enabled = keyboard_debug_enabled + + self.keyboard.modules = modules + + self.pins = tuple(DigitalInOut() for k in keymap[0]) + + self.keyboard.col_pins = (DigitalInOut(),) + self.keyboard.row_pins = self.pins + self.keyboard.diode_orientation = DiodeOrientation.COL2ROW + self.keyboard.keymap = keymap + + self.keyboard._init(hid_type=HIDModes.NOOP) + + @patch('kmk.hid.AbstractHID.hid_send') + def test(self, testname, key_events, assert_hid_reports, hid_send): + if self.debug_enabled: + print(testname, key_events, assert_hid_reports) + + hid_send_call_arg_list = [] + hid_send.side_effect = lambda hid_report: hid_send_call_arg_list.append( + hid_report[1:] + ) + + for e in key_events: + if isinstance(e, int): + starttime_ms = time.time_ns() // 1_000_000 + while time.time_ns() // 1_000_000 - starttime_ms < e: + self.do_main_loop() + else: + key_pos = e[0] + is_pressed = e[1] + self.pins[key_pos].value = is_pressed + self.do_main_loop() + + if self.debug_enabled: + for hid_report in hid_send_call_arg_list: + print(hid_report) + + for i, hid_report in enumerate( + hid_send_call_arg_list[-len(assert_hid_reports) :] + ): + hid_report_keys = {code for code in hid_report[2:] if code != 0} + assert_keys = { + k.code for k in assert_hid_reports[i] if not isinstance(k, ModifierKey) + } + if self.debug_enabled: + print( + 'assert keys:', + hid_report_keys == assert_keys, + hid_report_keys, + assert_keys, + ) + assert hid_report_keys == assert_keys + + hid_report_modifiers = hid_report[0] + assert_modifiers = reduce( + lambda mod, all_mods: all_mods | mod, + {k.code for k in assert_hid_reports[i] if isinstance(k, ModifierKey)}, + 0, + ) + if self.debug_enabled: + print( + 'assert mods:', + hid_report_modifiers == assert_modifiers, + hid_report_modifiers, + assert_modifiers, + ) + assert hid_report_modifiers == assert_modifiers + + def do_main_loop(self): + for i in range(random.randint(5, 50)): + self.keyboard._main_loop() diff --git a/tests/mocks.py b/tests/mocks.py new file mode 100644 index 0000000..2f8afc1 --- /dev/null +++ b/tests/mocks.py @@ -0,0 +1,20 @@ +import sys +import time +from unittest.mock import Mock + + +def init_circuit_python_modules_mocks(): + sys.modules['usb_hid'] = Mock() + sys.modules['digitalio'] = Mock() + sys.modules['neopixel'] = Mock() + sys.modules['pulseio'] = Mock() + sys.modules['busio'] = Mock() + sys.modules['microcontroller'] = Mock() + sys.modules['board'] = Mock() + sys.modules['storage'] = Mock() + + sys.modules['micropython'] = Mock() + sys.modules['micropython'].const = lambda x: x + + sys.modules['supervisor'] = Mock() + sys.modules['supervisor'].ticks_ms = lambda: time.time_ns() // 1_000_000 diff --git a/tests/test_hold_tap.py b/tests/test_hold_tap.py new file mode 100644 index 0000000..01b8794 --- /dev/null +++ b/tests/test_hold_tap.py @@ -0,0 +1,106 @@ +import unittest + +from kmk.keys import KC +from kmk.modules.layers import Layers +from kmk.modules.modtap import ModTap +from tests.keyboard_test import KeyboardTest + + +class TestHoldTap(unittest.TestCase): + def test_basic_kmk_keyboard(self): + keyboard = KeyboardTest( + [Layers(), ModTap()], + [ + [KC.MT(KC.A, KC.LCTL), KC.LT(1, KC.B), KC.C, KC.D], + [KC.N1, KC.N2, KC.N3, KC.N4], + ], + debug_enabled=False, + ) + + keyboard.test('MT tap behaviour', [(0, True), 100, (0, False)], [{KC.A}, {}]) + + keyboard.test( + 'MT hold behaviour', [(0, True), 350, (0, False)], [{KC.LCTL}, {}] + ) + + # TODO test multiple mods being held + + # MT + keyboard.test( + 'MT within tap time sequential -> tap behavior', + [(0, True), 100, (0, False), (3, True), (3, False)], + [{KC.A}, {}, {KC.D}, {}], + ) + + keyboard.test( + 'MT within tap time rolling -> tap behavior', + [(0, True), 100, (3, True), 250, (0, False), (3, False)], + [{KC.A}, {KC.A, KC.D}, {KC.D}, {}], + ) + + keyboard.test( + 'MT within tap time nested -> tap behavior', + [(0, True), 100, (3, True), (3, False), 250, (0, False)], + [{KC.A}, {KC.A, KC.D}, {KC.A}, {}], + ) + + keyboard.test( + 'MT after tap time sequential -> hold behavior', + [(0, True), 350, (0, False), (3, True), (3, False)], + [{KC.LCTL}, {}, {KC.D}, {}], + ) + + keyboard.test( + 'MT after tap time rolling -> hold behavior', + [(0, True), 350, (3, True), (0, False), (3, False)], + [{KC.LCTL}, {KC.LCTL, KC.D}, {KC.D}, {}], + ) + + keyboard.test( + 'MT after tap time nested -> hold behavior', + [(0, True), 350, (3, True), (3, False), (0, False)], + [{KC.LCTL}, {KC.LCTL, KC.D}, {KC.LCTL}, {}], + ) + + # LT + keyboard.test( + 'LT within tap time sequential -> tap behavior', + [(1, True), 100, (1, False), (3, True), (3, False)], + [{KC.B}, {}, {KC.D}, {}], + ) + + keyboard.test( + 'LT within tap time rolling -> tap behavior', + [(1, True), 100, (3, True), 250, (1, False), (3, False)], + [{KC.B}, {KC.B, KC.D}, {KC.D}, {}], + ) + + keyboard.test( + 'LT within tap time nested -> tap behavior', + [(1, True), 100, (3, True), (3, False), 250, (1, False)], + [{KC.B}, {KC.B, KC.D}, {KC.B}, {}], + ) + + keyboard.test( + 'LT after tap time sequential -> hold behavior', + [(1, True), 350, (1, False), (3, True), (3, False)], + [{KC.D}, {}], + ) + + keyboard.test( + 'LT after tap time rolling -> hold behavior', + [(1, True), 350, (3, True), (1, False), (3, False)], + [{KC.N4}, {}], + ) + + keyboard.test( + 'LT after tap time nested -> hold behavior', + [(1, True), 350, (3, True), (3, False), (1, False)], + [{KC.N4}, {}], + ) + + # TODO test TT + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_kmk_keyboard.py b/tests/test_kmk_keyboard.py new file mode 100644 index 0000000..237e5f3 --- /dev/null +++ b/tests/test_kmk_keyboard.py @@ -0,0 +1,15 @@ +import unittest + +from kmk.keys import KC +from tests.keyboard_test import KeyboardTest + + +class TestKmkKeyboard(unittest.TestCase): + def test_basic_kmk_keyboard(self): + keyboard = KeyboardTest([], [[KC.N1, KC.N2, KC.N3, KC.N4]]) + + keyboard.test('Simple key press', [(0, True), (0, False)], [{KC.N1}, {}]) + + +if __name__ == '__main__': + unittest.main()