220 lines
9.6 KiB
Python
Executable File
220 lines
9.6 KiB
Python
Executable File
#!/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 misc.keymap as 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()
|