#!/usr/bin/env python3 """Офлайн-тесты макросов: блоб, токены, XML, remap-действия (без железа). Запуск: python3 tests/test_macro.py (или pytest tests/test_macro.py). Эталон — реальный дамп вендора raw-data/katana-v1/macros-create.plain.txt и его XML (test.xml, macros-create-test.xml): блоб, собранный build_macro_blob() из событий, распарсенных parse_macro_xml(), должен байт в байт совпадать с тем, что шлёт вендорская утилита. """ import os import sys sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from katana import (EDITOR_ACTIONS, HOTKEY_KEYS, HOTKEY_MODIFIERS, MACRO_KEY_DOWN, MACRO_KEY_UP, MACRO_MOUSE_DOWN, MACRO_MOUSE_UP, MULTIMEDIA_ACTIONS, build_macro_blob, macro_event_str, macro_to_xml, parse_hotkey, parse_macro_action, parse_macro_tokens, parse_macro_xml) ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) DUMP = os.path.join(ROOT, "raw-data", "katana-v1", "macros-create.plain.txt") DUMP_EDITOR = os.path.join(ROOT, "raw-data", "katana-v1", "keybind-editor.plain.txt") DUMP_MULTIMEDIA = os.path.join(ROOT, "raw-data", "katana-v1", "keybind-multimedia.plain.txt") DUMP_HOTKEYS = os.path.join(ROOT, "raw-data", "katana-v1", "keybind-hotkeys.plain.txt") XML1 = os.path.join(ROOT, "raw-data", "katana-v1", "test.xml") XML2 = os.path.join(ROOT, "raw-data", "katana-v1", "macros-create-test.xml") def load_dump_blob(path): """Склеить блоб из data-пакетов дампа (SET-строки, не начинающиеся с 04).""" blob = b"" for line in open(path, encoding="utf-8"): if not line.startswith("SET") or " len=64" not in line: continue payload = bytes.fromhex(line.split(": ", 1)[1].strip()) if payload[:1] == b"\x04": continue # команды 04 19 / 04 15 / 04 02 — не данные blob += payload return blob def mask_delays(blob): """Обнулить байты задержек (4–5 в каждом восьмибайтовом событии).""" out = bytearray(blob) for off in range(0, len(out), 8): out[off + 4:off + 6] = b"\x00\x00" return bytes(out) def test_blob_matches_vendor_dump(): """Блоб из XML вендора = блоб из pcap-дампа (с точностью до 2 задержек).""" expected = load_dump_blob(DUMP) assert len(expected) == 896, f"dump blob is {len(expected)} bytes" macro1 = parse_macro_xml(XML1) # «Макрос 1»: A S D F, 8 событий macro2 = parse_macro_xml(XML2) # «Макрос 2»: 44 события assert len(macro1) == 8 assert len(macro2) == 44 got = build_macro_blob([macro1, macro2]) assert len(got) == len(expected) # заголовок: смещения слотов 0x190 и 0x1d8 assert got[:8] == bytes.fromhex("90010000d8010000") # сверка без задержек assert mask_delays(got) == mask_delays(expected) # расхождения только в задержках двух событий с delay_time="0" в XML: # вендор записывает в прошивку минимум 10 мс (2 наблюдения) diff = [i for i in range(len(got)) if got[i] != expected[i]] assert diff == [468, 828], f"unexpected byte diffs: {diff}" assert got[468] == 0 and expected[468] == 0x0A assert got[828] == 0 and expected[828] == 0x0A def test_parse_tokens(): """Токены: tap, нажатие, отпускание, мышь, задержки.""" events = parse_macro_tokens(["a@126", "+s", "-s@10", "lmb@50"]) assert events == [ (0x04, MACRO_KEY_DOWN, 0), # tap: нажать без задержки (0x04, MACRO_KEY_UP, 126), # отпустить, пауза 126 мс после (0x16, MACRO_KEY_DOWN, 0), (0x16, MACRO_KEY_UP, 10), (0x01, MACRO_MOUSE_DOWN, 0), (0x01, MACRO_MOUSE_UP, 50), ] for bad in ("", "+", "a@", "a@x", "++a", "KEY"): try: parse_macro_tokens([bad]) except ValueError: pass else: raise AssertionError(f"token {bad!r} must fail") try: parse_macro_tokens(["nosuchkey"]) except ValueError: pass else: raise AssertionError("unknown key must fail") def test_xml_roundtrip(): """Токены → XML → parse_macro_xml → те же события (мышь: 1/2/3 ↔ коды).""" events = parse_macro_tokens( ["a@10", "-b", "+lctrl@5", "-lctrl", "lmb@20", "rmb", "mmb"]) xml = macro_to_xml(events, name="Test") open("/tmp/katana-test-macro.xml", "w", encoding="utf-8").write(xml) assert parse_macro_xml("/tmp/katana-test-macro.xml") == events def test_event_str(): """Показ события: медиа-имена предпочитаются буквам (коллизия 05/06).""" assert macro_event_str((0x04, MACRO_KEY_DOWN, 126)) == "+a@126" assert macro_event_str((0x16, MACRO_KEY_UP, 0)) == "-s" assert macro_event_str((0x01, MACRO_MOUSE_DOWN, 0)) == "+lmb" assert macro_event_str((0x06, MACRO_KEY_DOWN, 12)) == "+voldown@12" assert macro_event_str((0x05, MACRO_KEY_UP, 0)) == "-volup" def test_parse_macro_action(): """remap-действия: macro однократно, :K повторов, -toggle.""" assert parse_macro_action("macro0") == (0, 0x00, 0) assert parse_macro_action("macro1:5") == (1, 0x01, 5) assert parse_macro_action("macro2-toggle") == (2, 0x02, 0) for bad in ("macro", "macroX", "macro1:", "macro1:0x2", "macro-1", "macro1:toggled"): assert parse_macro_action(bad) is None, bad def load_bind_records(path): """Извлечь непустые записи таблицы переназначений из дампа канала 04 11. Возвращает [(слот, 4 байта), ...] в порядке появления; между data-begin (04 11) и commit (04 02) идёт 9 data-пакетов = блоб 576 байт. """ records, blob, in_bind = [], b"", False for line in open(path, encoding="utf-8"): if not line.startswith("SET") or " len=64" not in line: continue payload = bytes.fromhex(line.split(": ", 1)[1].strip()) if payload[:2] == b"\x04\x11": in_bind, blob = True, b"" continue if not in_bind: continue if payload[:1] == b"\x04": # commit/save — конец блока данных in_bind = False # 143 слота × 4 байта, хвост (0000 + aa55) не слот таблицы for off in range(0, len(blob) - 4, 4): rec = blob[off:off + 4] if rec != b"\x00\x00\x00\x00": records.append((off // 4, rec)) else: blob += payload return records def test_editor_actions_match_dump(): """Дамп keybind-editor: 9 записей [02 01 код 00] в слоте Caps (0x37), коды = HID-коды букв O N Z S C X V F A — порядок совпадает с порядком функций в списке EDITOR_ACTIONS (открыть, создать, ...).""" records = load_bind_records(DUMP_EDITOR) assert len(records) == 9, f"expected 9 records, got {len(records)}" for slot, rec in records: assert slot == 0x37, f"unexpected slot {slot:#x}" assert rec[:2] == b"\x02\x01" and rec[3] == 0, rec.hex(" ") assert [rec[2] for _, rec in records] == list(EDITOR_ACTIONS.values()) def test_multimedia_actions_match_dump(): """Дамп keybind-multimedia: 18 записей [03, usage u16 LE, 00] в слоте Caps (0x37); usages — HID Consumer Page, порядок совпадает с порядком функций в списке вендора (плеер, play/pause, ...).""" records = load_bind_records(DUMP_MULTIMEDIA) assert len(records) == 18, f"expected 18 records, got {len(records)}" for slot, rec in records: assert slot == 0x37, f"unexpected slot {slot:#x}" assert rec[0] == 0x03 and rec[3] == 0, rec.hex(" ") dump_usages = [rec[1] | rec[2] << 8 for _, rec in records] # порядок функций вендора: плеер, play/pause, stop, prev, next, volup, # voldown, mute, home, refresh, webstop, webback, webforward, favorites, # websearch, mycomputer, calculator, email expected = [0x0183, 0x00CD, 0x00B7, 0x00B6, 0x00B5, 0x00E9, 0x00EA, 0x00E2, 0x0223, 0x0227, 0x0226, 0x0224, 0x0225, 0x022A, 0x0221, 0x0194, 0x0192, 0x018A] assert dump_usages == expected # все коды дампа покрыты таблицей (с учётом алиасов) assert set(expected) == set(MULTIMEDIA_ACTIONS.values()) def test_hotkey_actions_match_dump(): """Дамп keybind-hotkeys: вендор вешает на Caps (0x37) по очереди A, Shift+B, Ctrl+C, Alt+D, Meta+E, Esc, F1, Num1, Fn (записи [02, маска_модификаторов, HID-код, 00]) и «Мой компьютер» (мультимедиа-запись [03, 94 01, 00]).""" records = load_bind_records(DUMP_HOTKEYS) assert len(records) == 10, f"expected 10 records, got {len(records)}" for slot, rec in records: assert slot == 0x37, f"unexpected slot {slot:#x}" assert rec[3] == 0, rec.hex(" ") # маска модификаторов: 00 нет, 01 Ctrl, 02 Shift, 04 Alt, 08 Meta expected = [ (0x02, 0x00, 0x04), # A (0x02, 0x02, 0x05), # Shift+B (0x02, 0x01, 0x06), # Ctrl+C (0x02, 0x04, 0x07), # Alt+D (0x02, 0x08, 0x08), # Meta+E (0x02, 0x00, 0x29), # Esc (0x02, 0x00, 0x3A), # F1 (0x02, 0x00, 0x59), # Num1 (0x02, 0x00, 0xAF), # Fn (собственный код прошивки, не HID) ] got = [tuple(rec[:3]) for _, rec in records[:9]] assert got == expected, got # последняя запись — «Мой компьютер»: [03, usage 0x0194 LE, 00] assert records[9][1] == b"\x03\x94\x01\x00", records[9][1].hex(" ") def test_parse_hotkey(): """Разбор hotkey-действий: модификаторы, комбинирование, регистр.""" assert parse_hotkey("a") == (0x00, 0x04) assert parse_hotkey("shift+b") == (0x02, 0x05) assert parse_hotkey("ctrl+c") == (0x01, 0x06) assert parse_hotkey("alt+d") == (0x04, 0x07) assert parse_hotkey("meta+e") == (0x08, 0x08) assert parse_hotkey("win+e") == (0x08, 0x08) assert parse_hotkey("esc") == (0x00, 0x29) assert parse_hotkey("f1") == (0x00, 0x3A) assert parse_hotkey("num1") == (0x00, 0x59) assert parse_hotkey("fn") == (0x00, 0xAF) assert parse_hotkey("CTRL+SHIFT+X") == (0x03, 0x1B) # все имена HOTKEY_KEYS распознаются for name, code in HOTKEY_KEYS.items(): assert parse_hotkey(name) == (0x00, code), name for bad in ("", "+", "ctrl+", "ctrl+shift+", "nosuch", "ctrl+nosuch", "a+b", "macro0"): assert parse_hotkey(bad) is None, bad # маски модификаторов — степени двойки, покрывают Ctrl..Meta assert set(HOTKEY_MODIFIERS.values()) == {0x01, 0x02, 0x04, 0x08} def test_empty_blob(): """Пустая таблица: заголовок 400 нулевых байт + паддинг + aa55 = 448.""" blob = build_macro_blob([]) assert len(blob) == 448 assert blob[:400] == bytes(400) assert blob[-2:] == b"\xaa\x55" def test_capacity(): """Лимиты: 60 событий в один слот влезает, 61 — нет; индекс 100 — нет.""" events = [(0x04, MACRO_KEY_DOWN, 10)] * 60 assert len(build_macro_blob([events])) == 896 try: build_macro_blob([[(0x04, MACRO_KEY_DOWN, 10)] * 61]) except ValueError: pass else: raise AssertionError("61 events must exceed capacity") try: build_macro_blob([[]] * 100 + [[(0x04, MACRO_KEY_DOWN, 1)]]) except ValueError: pass else: raise AssertionError("slot 100 must be out of range") try: build_macro_blob([[(0x04, MACRO_KEY_DOWN, 0x10000)]]) except ValueError: pass else: raise AssertionError("delay > u16 must fail") def main(): tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")] for test in tests: test() print(f"ok: {test.__name__}") print(f"\n{len(tests)} tests passed") if __name__ == "__main__": main()