#!/usr/bin/env python3 """Офлайн-тесты парсеров и низкоуровневых функций katana.py (без железа). Запуск: python3 tests/test_parsers.py (или pytest tests/test_parsers.py). Покрывает: parse_color, parse_mode, check_range, resolve_key, build_payload, номера hidraw ioctl, статические проверки Katana (_check, _check_checksum) и целостность keymap.py (карта, слепые слоты, алиасы, группы клавиш). """ import argparse import contextlib import io import os import sys sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) import keymap from katana import (ALPHA_KEYS, ARROW_KEYS, BIND_COUNT, DIGITS_KEYS, KEYS_COUNT, MODES, NUMPAD_KEYS, PUNCT_KEYS, ROW_KEYS, Katana, _hid_ioc, _hid_iocgfeature, _hid_iocsfeature, build_payload, check_range, parse_color, parse_mode, resolve_key) def expect_systemexit(func, *args, **kwargs): """Вызвать func и вернуть текст исключения SystemExit (или зафейлить).""" try: func(*args, **kwargs) except SystemExit as e: return str(e) raise AssertionError(f"{func.__name__}{args} must fail with SystemExit") def test_parse_color(): """parse_color: hex, '#hex', именованные цвета, регистр; мусор отклоняется.""" assert parse_color("ff0000") == (255, 0, 0) assert parse_color("#FF0000") == (255, 0, 0) # решётка и верхний регистр assert parse_color("red") == (255, 0, 0) assert parse_color("BLACK") == (0, 0, 0) assert parse_color("dodgerblue") == (30, 144, 255) for bad in ("ff00", "zzzzzz", "red!", "12345", ""): try: parse_color(bad) except (argparse.ArgumentTypeError, ValueError): # ValueError для 6-символьного не-hex перехватывает argparse pass else: raise AssertionError(f"color {bad!r} must fail") def test_parse_mode(): """parse_mode: номер (в т.ч. 0x-форма), алиас, off; мусор отклоняется.""" assert parse_mode("1") == 1 assert parse_mode("19") == 19 assert parse_mode("0x10") == 16 assert parse_mode("static") == 1 assert parse_mode("SHUTTLE") == 19 # регистр не важен assert parse_mode("off") == 0 and parse_mode("OFF") == 0 # все алиасы MODES распознаются и дают свои номера for num, (name, _) in MODES.items(): assert parse_mode(name) == num, name try: parse_mode("nosuch") except argparse.ArgumentTypeError: pass else: raise AssertionError("unknown mode must fail") def test_check_range(): """check_range: значение в диапазоне возвращается, вне — SystemExit.""" assert check_range(5, 1, 15, "brightness") == 5 msg = expect_systemexit(check_range, 0, 1, 15, "brightness") assert "brightness must be 1..15" in msg, msg msg = expect_systemexit(check_range, 16, 1, 15, "brightness") assert "got 16" in msg, msg def test_resolve_key(): """resolve_key: имена keymap, символы, алиасы, hex-индексы; цифры — имена.""" assert resolve_key("esc") == 0x01 assert resolve_key("Esc") == 0x01 # регистр не важен assert resolve_key("caps") == 0x37 assert resolve_key("space") == 0x5E # символы-псевдонимы (базовые имена из KEYMAP до скобки) assert resolve_key("[") == 0x30 assert resolve_key("~") == 0x13 # shifted-вариант backquote assert resolve_key(";") == 0x41 assert resolve_key(chr(92)) == 0x43 # синонимы из KEY_SYMBOL_ALIASES assert resolve_key("tilde") == 0x13 assert resolve_key("lbracket") == 0x30 assert resolve_key("capslock") == 0x37 assert resolve_key("ctrl") == 0x5B # голые цифры — это ИМЕНА клавиш цифрового ряда, а не индексы assert resolve_key("1") == 0x14 assert resolve_key("0") == 0x1D # hex-индекс передаётся напрямую assert resolve_key("0x25") == 0x25 assert resolve_key("0x8e") == 0x8E # нераспознанное — None assert resolve_key("0xzz") is None assert resolve_key("nosuchkey") is None assert resolve_key("") is None def test_build_payload(): """build_payload: раскладка 64-байтного lighting payload.""" p = build_payload(7, (0x11, 0x22, 0x33), flag=0, brightness=0x0C, speed=0x05, submode=0x01) assert len(p) == 64 assert p[0] == 7 # режим assert p[1:4] == bytes([0x11, 0x22, 0x33]) # RGB assert p[8] == 0 # flag assert p[9] == 0x0C # яркость assert p[10] == 0x05 # скорость assert p[11] == 0x01 # подрежим/направление assert p[14:16] == b"\xaa\x55" # магия assert p[4:8] == bytes(4) and p[12:14] == bytes(2) # остальное — нули def test_hid_ioc_numbers(): """Номера ioctl _IOWR('H', nr, size): размер в байтах 16–29, 'H' в 8–15.""" expected = (3 << 30) | (65 << 16) | (0x48 << 8) | 0x06 assert _hid_ioc(0x06, 65) == expected assert _hid_iocsfeature(65) == _hid_ioc(0x06, 65) # SET_REPORT assert _hid_iocgfeature(65) == _hid_ioc(0x07, 65) # GET_REPORT def test_katana_check(): """Katana._check: ACK с правильной командой и статусом 01 проходит.""" ok = bytes([0x04, 0x18, 0x00, 0x01]) + bytes(60) Katana._check(ok, 0x18) # не должно бросать # чужой opcode в ACK msg = expect_systemexit(Katana._check, ok, 0x13) assert "bad ACK" in msg, msg # короткий ACK msg = expect_systemexit(Katana._check, b"\x04\x18", 0x18) assert "bad ACK" in msg, msg # отказ прошивки (статус 00/ff) bad = bytes([0x04, 0x18, 0x00, 0x00]) + bytes(60) msg = expect_systemexit(Katana._check, bad, 0x18) assert "rejected" in msg and "reset" in msg, msg def test_katana_check_checksum(): """Katana._check_checksum: совпадение молчит, расхождение — warning в stderr.""" data = bytes(range(64)) ack_ok = bytearray(64) ack_ok[4:6] = (sum(data) & 0xFFFF).to_bytes(2, "little") err = io.StringIO() with contextlib.redirect_stderr(err): Katana._check_checksum(bytes(ack_ok), data) assert err.getvalue() == "", "matching checksum must not warn" ack_bad = bytearray(ack_ok) ack_bad[4] ^= 0xFF with contextlib.redirect_stderr(err): Katana._check_checksum(bytes(ack_bad), data) assert "warning" in err.getvalue() and "checksum" in err.getvalue() def test_keymap_integrity(): """keymap.py: индексы в диапазоне слотов, карта не пересекается со слепыми.""" for idx in keymap.KEYMAP: assert 0 <= idx < KEYS_COUNT, f"index {idx:#x} out of range" assert not set(keymap.KEYMAP) & keymap.BLIND, "keymap overlaps blind slots" # name(): клавиша, слепой слот, неизвестный слот assert keymap.name(0x01) == "Esc" assert keymap.name(0x00) == "(blind)" unknown = next(iter(keymap.UNKNOWN), None) if unknown is not None: assert keymap.name(unknown).endswith("?") def test_keymap_aliases_resolve(): """Каждый алиас keymap указывает на тот же индекс, что и каноническое имя.""" for alias, canonical in keymap.KEY_SYMBOL_ALIASES.items(): idx = resolve_key(canonical) if idx is None: continue # каноническое имя не в KEYMAP — алиас просто не заведётся assert resolve_key(alias) == idx, f"{alias} -> {canonical} != {idx:#x}" def test_paint_groups_resolve(): """Все клавиши shorthand-групп paint (--wasd/--numpad/--alpha/...) резолвятся.""" groups = {"numpad": NUMPAD_KEYS, "alpha": ALPHA_KEYS, "punct": PUNCT_KEYS, "digits": DIGITS_KEYS, "arrows": ARROW_KEYS} for name, keys in groups.items(): for key in keys: idx = resolve_key(key) assert idx is not None, f"{name}: key {key!r} does not resolve" assert 0 <= idx < KEYS_COUNT for row_n, keys in ROW_KEYS.items(): assert 1 <= row_n <= 6 for key in keys: idx = resolve_key(key) assert idx is not None, f"row{row_n}: key {key!r} does not resolve" # группа wasd — просто буквы из alpha for key in ("w", "a", "s", "d"): assert resolve_key(key) is not None def test_counts(): """Константы размеров согласованы: 143 слота в обеих таблицах.""" assert KEYS_COUNT == BIND_COUNT == 143 assert len(keymap.KEYMAP) + len(keymap.BLIND) + len(keymap.UNKNOWN) \ == 0x8F # слоты 0x00..0x8e покрыты тремя списками без пересечений 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()