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
+219
View File
@@ -0,0 +1,219 @@
#!/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()