Files
2026-09-06 01:06:40 +08:00

285 lines
13 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()