285 lines
13 KiB
Python
Executable File
285 lines
13 KiB
Python
Executable File
#!/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<N> однократно, :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()
|