Initial commit
This commit is contained in:
Executable
+219
@@ -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()
|
||||
Reference in New Issue
Block a user