Initial commit

This commit is contained in:
2026-09-06 01:06:40 +08:00
commit 57c4e80f0d
69 changed files with 20940 additions and 0 deletions
+284
View File
@@ -0,0 +1,284 @@
#!/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()