Initial commit
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Офлайн-тесты команды apply: YAML-конфиг → argv существующих команд (без железа).
|
||||
|
||||
Запуск: python3 tests/test_apply.py (или pytest tests/test_apply.py).
|
||||
|
||||
Эталон — katana.yaml.example: конфиг должен разбираться и превращаться
|
||||
в валидные argv для build_parser().parse_args() (валидация значений —
|
||||
та же, что у обычных команд).
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from katana import (APPLY_SECTION_ORDER, CONFIG_KEYS, _build_macro_slots,
|
||||
build_parser, config_argv, load_config, macro_argv)
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
EXAMPLE = os.path.join(ROOT, "katana.yaml.example")
|
||||
|
||||
|
||||
def parse_ok(argv):
|
||||
"""parse_args без запуска команды: проверяет валидность значений."""
|
||||
return build_parser().parse_args(argv)
|
||||
|
||||
|
||||
def test_example_config_loads():
|
||||
"""Пример конфига читается, секции — только разрешённые."""
|
||||
cfg = load_config(EXAMPLE)
|
||||
assert set(cfg) <= set(APPLY_SECTION_ORDER), set(cfg)
|
||||
assert {"macros", "remap", "paint", "lighting"} <= set(cfg)
|
||||
# YAML-ключи слотов макросов парсятся как int
|
||||
assert sorted(cfg["macros"]) == [0, 1]
|
||||
|
||||
|
||||
def test_example_config_argv_valid():
|
||||
"""Все секции примера превращаются в валидные argv существующих команд.
|
||||
|
||||
save=False для всех секций, кроме последней — так делает cmd_apply
|
||||
(save коммитит всю RAM, поэтому он только у последней команды).
|
||||
"""
|
||||
cfg = load_config(EXAMPLE)
|
||||
for slot, spec in cfg["macros"].items():
|
||||
ns = parse_ok(macro_argv(slot, spec, save=False))
|
||||
assert ns.cmd == "macro" and ns.macro_cmd == "set" and not ns.save
|
||||
ns = parse_ok(config_argv("remap", cfg["remap"], save=False))
|
||||
assert ns.cmd == "remap" and "caps=macro0" in ns.key and ns.no_save
|
||||
ns = parse_ok(config_argv("paint", cfg["paint"], save=False))
|
||||
assert ns.cmd == "paint" and ns.all == (0, 0, 0) and ns.no_save
|
||||
assert ns.wasd == (30, 144, 255) # dodgerblue через parse_color
|
||||
assert "esc=ff0000" in ns.key
|
||||
ns = parse_ok(config_argv("lighting", cfg["lighting"], save=True))
|
||||
assert ns.cmd == "mode" and ns.n == 1 # static (последняя секция — с save)
|
||||
assert ns.color == (255, 0, 0) and not ns.no_save
|
||||
|
||||
|
||||
def test_remap_argv():
|
||||
"""[remap]: keys → --key, save управляется cmd_apply (не ключом секции)."""
|
||||
argv = config_argv("remap", {"keys": {"caps": "copy", "menu": "meta+e"}},
|
||||
save=False)
|
||||
assert argv == ["remap", "--key", "caps=copy", "--key", "menu=meta+e",
|
||||
"--no-save"]
|
||||
argv = config_argv("remap", {"keys": {"caps": "copy"}}, save=True)
|
||||
assert argv == ["remap", "--key", "caps=copy"] # save=True — без --no-save
|
||||
argv = config_argv("remap", {"clear": True}, save=False)
|
||||
assert "--clear" in argv
|
||||
parse_ok(argv)
|
||||
|
||||
|
||||
def test_lighting_argv():
|
||||
"""[lighting]: имена/номера режимов, off/default, опциональные поля."""
|
||||
ns = parse_ok(config_argv("lighting", {"mode": "static", "speed": 5},
|
||||
save=False))
|
||||
assert (ns.cmd, ns.n, ns.speed) == ("mode", 1, 5) and ns.no_save
|
||||
ns = parse_ok(config_argv("lighting", {"mode": 1, "color": "ff8800"},
|
||||
save=True))
|
||||
assert (ns.cmd, ns.n, ns.color) == ("mode", 1, (255, 136, 0))
|
||||
assert config_argv("lighting", {"mode": "off"}, save=False) == \
|
||||
["off", "--no-save"]
|
||||
assert config_argv("lighting", {"mode": "off"}, save=True) == ["off"]
|
||||
# default сам делает save, флаг не нужен
|
||||
assert config_argv("lighting", {"mode": "default"}, save=False) == ["default"]
|
||||
|
||||
|
||||
def test_paint_argv():
|
||||
"""[paint]: all/keys/rows/shorthand-алиасы/black/keep/brightness."""
|
||||
argv = config_argv("paint", {
|
||||
"all": "black", "keys": {"esc": "red"}, "rows": {1: "202020"},
|
||||
"wasd": "dodgerblue", "black": True, "keep": True, "brightness": 10,
|
||||
}, save=False)
|
||||
ns = parse_ok(argv)
|
||||
assert ns.all == (0, 0, 0) and ns.black and ns.keep and ns.no_save
|
||||
assert ns.brightness == 10
|
||||
assert "esc=red" in ns.key
|
||||
assert ns.row1 == (32, 32, 32) # --row1 202020
|
||||
assert ns.wasd == (30, 144, 255)
|
||||
|
||||
|
||||
def test_macro_argv():
|
||||
"""[macros]: tokens через '--', xml, save управляется cmd_apply."""
|
||||
argv = macro_argv(0, {"tokens": ["+lctrl", "a@50", "-lctrl"]}, save=False)
|
||||
assert argv == ["macro", "set", "0", "--", "+lctrl", "a@50", "-lctrl"]
|
||||
ns = parse_ok(argv) # токены с '-' не съедаются как опции
|
||||
assert ns.index == 0 and ns.tokens == ["+lctrl", "a@50", "-lctrl"] and not ns.save
|
||||
argv = macro_argv(1, {"xml": "test.xml"}, save=True)
|
||||
# --save строго ДО '--': после '--' argparse считает всё позиционным
|
||||
ns = parse_ok(argv)
|
||||
assert ns.xml == "test.xml" and ns.save
|
||||
for spec in ({"tokens": ["a"], "xml": "t.xml"}, {}):
|
||||
try:
|
||||
macro_argv(0, spec, save=False)
|
||||
except SystemExit:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError(f"spec {spec} must fail")
|
||||
|
||||
|
||||
def test_build_macro_slots():
|
||||
"""[macros] → слоты одним блобом: несколько слотов, пропуски, ошибки."""
|
||||
slots = _build_macro_slots({
|
||||
0: {"tokens": ["+lctrl", "a@50", "-lctrl"]},
|
||||
2: {"tokens": ["b"]},
|
||||
})
|
||||
# слот 1 — пропуск (пустой), слоты 0 и 2 заполнены
|
||||
assert len(slots) == 3 and len(slots[0]) == 4 and slots[1] == [] \
|
||||
and len(slots[2]) == 2
|
||||
slots = _build_macro_slots({5: {"xml": "raw-data/katana-v1/test.xml"}})
|
||||
assert len(slots) == 6 and slots[0] == [] and len(slots[5]) == 8
|
||||
for slots_cfg in ({"0": {"tokens": ["a"], "xml": "t.xml"}}, # оба поля
|
||||
{"0": {}}, # ни одного
|
||||
{"x": {"tokens": ["a"]}}, # не число
|
||||
{"0": {"tokens": []}}, # пустой список
|
||||
{"0": {"tokens": ["a"], "save": True}}): # save не поддержан
|
||||
try:
|
||||
_build_macro_slots(slots_cfg)
|
||||
except SystemExit:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError(f"{slots_cfg} must fail")
|
||||
|
||||
|
||||
def test_unknown_keys_and_sections():
|
||||
"""Опечатки в ключах секций и неизвестные секции отклоняются.
|
||||
Ключ 'save' в секциях не поддерживается (apply управляет save сам)."""
|
||||
for section, values in (
|
||||
("remap", {"key": {"caps": "copy"}}), # опечатка keys
|
||||
("paint", {"colour": "red"}), # опечатка color
|
||||
("lighting", {"mode": "static", "spead": 5}), # опечатка speed
|
||||
("remap", {"keys": {"caps": "copy"}, "save": True}), # save не поддержан
|
||||
("paint", {"all": "black", "save": False}), # save не поддержан
|
||||
("lighting", {"mode": "off", "save": True}), # save не поддержан
|
||||
):
|
||||
try:
|
||||
config_argv(section, values, save=False)
|
||||
except SystemExit as e:
|
||||
assert "unknown keys" in str(e), e
|
||||
else:
|
||||
raise AssertionError(f"{section} {values} must fail")
|
||||
try:
|
||||
load_config("/nonexistent/katana.yaml")
|
||||
except SystemExit as e:
|
||||
assert "not found" in str(e), e
|
||||
else:
|
||||
raise AssertionError("missing config must fail")
|
||||
|
||||
|
||||
def test_section_order():
|
||||
"""Порядок применения: macros последними — запись макросов сбивает
|
||||
режим подсветки, а save коммитит всю RAM (см. katana.py)."""
|
||||
assert APPLY_SECTION_ORDER == ("remap", "paint", "lighting", "raw", "macros")
|
||||
assert set(CONFIG_KEYS) == {"remap", "paint", "lighting"}
|
||||
|
||||
|
||||
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()
|
||||
Executable
+469
@@ -0,0 +1,469 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Офлайн-тесты команд CLI katana.py (без железа, через подменный Katana).
|
||||
|
||||
Запуск: python3 tests/test_cli.py (или pytest tests/test_cli.py).
|
||||
|
||||
Команды прогоняются как в бою: build_parser().parse_args(argv) → dispatch()
|
||||
с подменным устройством, которое пишет вызовы в журнал вместо обмена с USB.
|
||||
Проверяются: формирование payload'ов, раскрытие shorthand-групп paint,
|
||||
записи таблицы переназначений, поток apply (порядок секций и save'ов)
|
||||
и сообщения об ошибках.
|
||||
"""
|
||||
import contextlib
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from katana import (BIND_COUNT, KEYS_COUNT, build_parser, cmd_apply, dispatch,
|
||||
parse_macro_tokens, parse_macro_xml)
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
XML1 = os.path.join(ROOT, "raw-data", "katana-v1", "test.xml")
|
||||
|
||||
|
||||
class FakeKatana:
|
||||
"""Подменный Katana: вместо USB пишет вызовы методов в журнал calls."""
|
||||
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
# живой кадр для read_frame (используется paint --keep и keys)
|
||||
self.frame = [(0, 0, 0)] * KEYS_COUNT
|
||||
|
||||
def lighting(self, payload, save=True):
|
||||
self.calls.append(("lighting", bytes(payload), save))
|
||||
|
||||
def show_custom(self, brightness=0x0F, save=True):
|
||||
self.calls.append(("show_custom", brightness, save))
|
||||
|
||||
def write_key_table(self, colors, save=True):
|
||||
self.calls.append(("write_key_table", list(colors), save))
|
||||
|
||||
def write_bind_table(self, records, save=True):
|
||||
self.calls.append(("write_bind_table", list(records), save))
|
||||
|
||||
def write_macro_table(self, macros, save=False):
|
||||
self.calls.append(("write_macro_table", list(macros), save))
|
||||
|
||||
def read_frame(self):
|
||||
self.calls.append(("read_frame",))
|
||||
return list(self.frame)
|
||||
|
||||
def reset(self):
|
||||
self.calls.append(("reset",))
|
||||
|
||||
|
||||
def run(argv, k=None):
|
||||
"""Разобрать argv и выполнить команду на подменном устройстве."""
|
||||
k = k if k is not None else FakeKatana()
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
dispatch(build_parser().parse_args(argv), k)
|
||||
return k
|
||||
|
||||
|
||||
def expect_fail(argv, *needles):
|
||||
"""Команда должна упасть с SystemExit, текст содержит все подстроки."""
|
||||
try:
|
||||
with contextlib.redirect_stderr(io.StringIO()): # шум argparse не нужен
|
||||
run(argv)
|
||||
except SystemExit as e:
|
||||
text = str(e)
|
||||
for needle in needles:
|
||||
assert needle in text, f"{needle!r} not in {text!r}"
|
||||
return
|
||||
raise AssertionError(f"{argv} must fail")
|
||||
|
||||
|
||||
# ---- mode / off / default / raw ---------------------------------------------
|
||||
|
||||
def test_mode_payload():
|
||||
"""mode: раскладка payload, вендорские flag/speed по умолчанию, save."""
|
||||
k = run(["mode", "7", "--color", "112233"])
|
||||
(kind, payload, save), = k.calls
|
||||
assert kind == "lighting" and save
|
||||
assert payload[0] == 7
|
||||
assert payload[1:4] == bytes([0x11, 0x22, 0x33])
|
||||
assert payload[8] == 0x01 # flag по умолчанию для не-1/4
|
||||
assert payload[9] == 0x0F # яркость по умолчанию
|
||||
assert payload[10] == 0x0A # скорость по умолчанию
|
||||
assert payload[11] == 0x00 # без направления
|
||||
assert payload[14:16] == b"\xaa\x55"
|
||||
|
||||
|
||||
def test_mode_static_forced_speed_and_flag():
|
||||
"""mode 1: скорость форсируется вендорской 0x0a (с warning), flag = 0x00."""
|
||||
k = FakeKatana()
|
||||
err = io.StringIO()
|
||||
with contextlib.redirect_stderr(err):
|
||||
run(["mode", "1", "--color", "ff0000", "--speed", "5"], k)
|
||||
(kind, payload, _), = k.calls
|
||||
assert payload[10] == 0x0A # не 5, а вендорское значение
|
||||
assert payload[8] == 0x00 # flag статики
|
||||
assert "static mode has no speed" in err.getvalue()
|
||||
# --flag переопределяет вендорский дефолт
|
||||
k = run(["mode", "1", "--color", "ff0000", "--flag", "2"])
|
||||
(kind, payload, _), = k.calls
|
||||
assert payload[8] == 2
|
||||
|
||||
|
||||
def test_mode_speed_validation():
|
||||
"""mode: скорость вне наблюдаемого диапазона — warning; вне 1..15 — отказ."""
|
||||
k = FakeKatana()
|
||||
err = io.StringIO()
|
||||
with contextlib.redirect_stderr(err):
|
||||
run(["mode", "5", "--color", "ff0000", "--speed", "1"], k)
|
||||
(kind, payload, _), = k.calls
|
||||
assert payload[10] == 1 # 1 пропускается с warning
|
||||
assert "below observed range" in err.getvalue()
|
||||
expect_fail(["mode", "5", "--speed", "16"], "speed must be")
|
||||
expect_fail(["mode", "5", "--brightness", "0"], "brightness must be")
|
||||
|
||||
|
||||
def test_mode_direction():
|
||||
"""mode --direction: валидные направления, чужие и неподдерживаемые — отказ."""
|
||||
k = run(["mode", "10", "--color", "ff0000", "--direction", "south-north"])
|
||||
(kind, payload, _), = k.calls
|
||||
assert payload[11] == 0x03 # south-north водопада
|
||||
k = run(["mode", "16", "--color", "ff0000", "--direction", "west-east"])
|
||||
(kind, payload, _), = k.calls
|
||||
assert payload[11] == 0x00
|
||||
expect_fail(["mode", "10", "--direction", "east-west"], "directions:")
|
||||
expect_fail(["mode", "11", "--direction", "west-east"], "no direction")
|
||||
expect_fail(["mode", "1", "--direction", "north-south"], "no direction")
|
||||
|
||||
|
||||
def test_mode_rainbow_and_no_save():
|
||||
"""mode --rainbow: подрежим 0x01; --no-save убирает save."""
|
||||
k = run(["mode", "1", "--color", "ff0000", "--rainbow", "--no-save"])
|
||||
(kind, payload, save), = k.calls
|
||||
assert payload[11] == 0x01 and not save
|
||||
|
||||
|
||||
def test_mode_range_checks():
|
||||
"""mode: номер вне 1..19 и яркость вне 1..15 отклоняются."""
|
||||
expect_fail(["mode", "0"], "mode must be 1..19")
|
||||
expect_fail(["mode", "20"], "mode must be 1..19")
|
||||
expect_fail(["mode", "5", "--brightness", "16"], "brightness must be")
|
||||
|
||||
|
||||
def test_off_default_raw():
|
||||
"""off: нулевой payload + aa55; default: вендорский 0x80; raw: паддинг/обрезка."""
|
||||
k = run(["off"])
|
||||
(kind, payload, save), = k.calls
|
||||
assert kind == "lighting" and save
|
||||
assert payload[:14] == bytes(14) and payload[14:16] == b"\xaa\x55"
|
||||
k = run(["default"])
|
||||
(kind, payload, save), = k.calls
|
||||
assert payload[0] == 0x80 and payload[9] == 0x0F
|
||||
assert payload[14:16] == b"\xaa\x55"
|
||||
# raw: короткий hex дополняется нулями, длинный обрезается до 64
|
||||
k = run(["raw", "aa55"])
|
||||
(kind, payload, save), = k.calls
|
||||
assert payload == bytes.fromhex("aa55") + bytes(62) and save
|
||||
k = run(["raw", "01" * 70, "--no-save"])
|
||||
(kind, payload, save), = k.calls
|
||||
assert len(payload) == 64 and payload == bytes.fromhex("01" * 64)
|
||||
assert not save
|
||||
|
||||
|
||||
def test_scan():
|
||||
"""scan: по одной транзакции на каждый режим диапазона, всегда с save."""
|
||||
k = run(["scan", "--from", "2", "--to", "4", "--delay", "0"])
|
||||
assert [c[1][0] for c in k.calls] == [2, 3, 4]
|
||||
assert all(kind == "lighting" and save for kind, _, save in k.calls)
|
||||
|
||||
|
||||
def test_reset():
|
||||
"""reset: вызывается USB port reset (без переподключения hidraw)."""
|
||||
k = run(["reset"])
|
||||
assert k.calls == [("reset",)]
|
||||
|
||||
|
||||
# ---- paint --------------------------------------------------------------------
|
||||
|
||||
def test_paint_all_and_key():
|
||||
"""paint: база --all, точечная покраска по имени и hex-индексу."""
|
||||
k = run(["paint", "--all", "red", "--key", "esc=00ff00", "--key", "0x25=0000ff"])
|
||||
calls = [c for c in k.calls if c[0] == "write_key_table"]
|
||||
(kind, table, save), = calls
|
||||
assert save and len(table) == KEYS_COUNT
|
||||
assert table[0x01] == (0, 255, 0) # esc
|
||||
assert table[0x25] == (0, 0, 255) # hex-индекс Tab
|
||||
# все прочие слоты — базовый цвет --all
|
||||
assert all(c == (255, 0, 0)
|
||||
for i, c in enumerate(table) if i not in (0x01, 0x25))
|
||||
# после записи таблицы включается режим 0x80 с той же яркостью и save
|
||||
kind, brightness, save = [c for c in k.calls if c[0] == "show_custom"][0]
|
||||
assert brightness == 0x0F and save
|
||||
|
||||
|
||||
def test_paint_black_overrides_all():
|
||||
"""paint: --black даёт чёрную базу даже вместе с --all."""
|
||||
k = run(["paint", "--all", "red", "--black"])
|
||||
(kind, table, _), = [c for c in k.calls if c[0] == "write_key_table"]
|
||||
assert set(table) == {(0, 0, 0)}
|
||||
|
||||
|
||||
def test_paint_shorthands():
|
||||
"""paint: shorthand-группы --wasd/--numpad/--alpha/--digits.
|
||||
|
||||
Группы проверяются раздельными запусками: они перекрываются
|
||||
(alpha включает wasd и применяется позже).
|
||||
"""
|
||||
from katana import ALPHA_KEYS, DIGITS_KEYS, NUMPAD_KEYS, resolve_key
|
||||
k = run(["paint", "--wasd", "dodgerblue"])
|
||||
(kind, table, _), = [c for c in k.calls if c[0] == "write_key_table"]
|
||||
for idx in (0x27, 0x38, 0x39, 0x3A): # W A S D из keymap
|
||||
assert table[idx] == (30, 144, 255), f"wasd idx {idx:#x}"
|
||||
k = run(["paint", "--numpad", "112233"])
|
||||
(kind, table, _), = [c for c in k.calls if c[0] == "write_key_table"]
|
||||
for idx in [resolve_key(key) for key in NUMPAD_KEYS]:
|
||||
assert table[idx] == (0x11, 0x22, 0x33)
|
||||
k = run(["paint", "--alpha", "224466"])
|
||||
(kind, table, _), = [c for c in k.calls if c[0] == "write_key_table"]
|
||||
for idx in [resolve_key(key) for key in ALPHA_KEYS]:
|
||||
assert table[idx] == (0x22, 0x44, 0x66)
|
||||
k = run(["paint", "--digits", "0a0a0a"])
|
||||
(kind, table, _), = [c for c in k.calls if c[0] == "write_key_table"]
|
||||
for idx in [resolve_key(key) for key in DIGITS_KEYS]:
|
||||
assert table[idx] == (0x0A, 0x0A, 0x0A)
|
||||
|
||||
|
||||
def test_paint_rows_and_punct_arrows():
|
||||
"""paint: --row1..6, --punct (включая space), --arrows.
|
||||
|
||||
Ряды и shorthand'ы проверяются раздельно: --row6 содержит space и стрелки
|
||||
и применяется позже shorthand'ов, перекрывая их.
|
||||
"""
|
||||
from katana import ARROW_KEYS, PUNCT_KEYS, ROW_KEYS, SPACE_KEY, resolve_key
|
||||
k = run(["paint", "--row1", "ff0000", "--row6", "00ff00"])
|
||||
(kind, table, _), = [c for c in k.calls if c[0] == "write_key_table"]
|
||||
for idx in [resolve_key(key) for key in ROW_KEYS[1]]:
|
||||
assert table[idx] == (255, 0, 0)
|
||||
for idx in [resolve_key(key) for key in ROW_KEYS[6]]:
|
||||
assert table[idx] == (0, 255, 0)
|
||||
k = run(["paint", "--punct", "111111", "--arrows", "222222"])
|
||||
(kind, table, _), = [c for c in k.calls if c[0] == "write_key_table"]
|
||||
for key in (*PUNCT_KEYS, SPACE_KEY):
|
||||
assert table[resolve_key(key)] == (0x11, 0x11, 0x11)
|
||||
for key in ARROW_KEYS:
|
||||
assert table[resolve_key(key)] == (0x22, 0x22, 0x22)
|
||||
|
||||
|
||||
def test_paint_keep():
|
||||
"""paint --keep: база — текущий живой кадр, меняются только --key."""
|
||||
k = FakeKatana()
|
||||
k.frame[0x25] = (1, 2, 3) # Tab уже покрашен
|
||||
run(["paint", "--keep", "--key", "esc=ff0000"], k)
|
||||
assert ("read_frame",) in k.calls
|
||||
(kind, table, _), = [c for c in k.calls if c[0] == "write_key_table"]
|
||||
assert table[0x01] == (255, 0, 0) # esc перекрашен
|
||||
assert table[0x25] == (1, 2, 3) # прочие — из живого кадра
|
||||
# --keep несовместим с --all/--black и бессмыслен без --key
|
||||
expect_fail(["paint", "--keep", "--all", "red"], "cannot be combined")
|
||||
expect_fail(["paint", "--keep", "--black"], "cannot be combined")
|
||||
expect_fail(["paint", "--keep"], "used with --key")
|
||||
|
||||
|
||||
def test_paint_errors():
|
||||
"""paint: без аргументов, плохой spec и неизвестная клавиша отклоняются."""
|
||||
expect_fail(["paint"], "specify --all")
|
||||
expect_fail(["paint", "--key", "esc"], "bad --key spec")
|
||||
expect_fail(["paint", "--key", "nosuchkey=red"], "unknown key")
|
||||
expect_fail(["paint", "--brightness", "0"], "brightness must be")
|
||||
|
||||
|
||||
# ---- remap --------------------------------------------------------------------
|
||||
|
||||
def test_remap_actions():
|
||||
"""remap: записи всех типов действий в слоте клавиши (0x37 = Caps)."""
|
||||
k = run(["remap", "--key", "caps=copy", "--key", "esc=lmb",
|
||||
"--key", "f1=volup", "--key", "menu=meta+e", "--key", "fn=macro0:5",
|
||||
"--key", "tab=macro1-toggle", "--key", "lctrl=default"])
|
||||
(kind, records, save), = [c for c in k.calls if c[0] == "write_bind_table"]
|
||||
assert save and len(records) == BIND_COUNT
|
||||
assert records[0x37] == (0x02, 0x01, 0x06, 0) # copy: шорткат редактора
|
||||
assert records[0x01] == (0x01, 0x01, 0x01, 0) # lmb: мышь
|
||||
assert records[0x02] == (0x03, 0xE9, 0x00, 0) # volup: Consumer usage LE
|
||||
assert records[0x61] == (0x02, 0x08, 0x08, 0) # meta+e: маска Meta
|
||||
assert records[0x60] == (0x06, 0, 0x01, 5) # macro0:5: повтор 5 раз
|
||||
assert records[0x25] == (0x06, 1, 0x02, 0) # macro1-toggle (Tab)
|
||||
assert records[0x5B] == (0, 0, 0, 0) # default: нули
|
||||
# нетронутые слоты остаются нулевыми
|
||||
assert records[0x26] == (0, 0, 0, 0) # Q
|
||||
|
||||
|
||||
def test_remap_clear():
|
||||
"""remap --clear: вся таблица обнуляется; с --key несовместим."""
|
||||
k = run(["remap", "--clear"])
|
||||
(kind, records, save), = [c for c in k.calls if c[0] == "write_bind_table"]
|
||||
assert set(records) == {(0, 0, 0, 0)} and save
|
||||
expect_fail(["remap", "--clear", "--key", "caps=copy"], "cannot be combined")
|
||||
expect_fail(["remap"], "specify --key")
|
||||
|
||||
|
||||
def test_remap_errors():
|
||||
"""remap: плохой spec, неизвестные клавиша/действие/макрос отклоняются."""
|
||||
expect_fail(["remap", "--key", "caps"], "bad --key spec")
|
||||
expect_fail(["remap", "--key", "nosuchkey=copy"], "unknown key")
|
||||
expect_fail(["remap", "--key", "caps=nosuch"], "unknown action")
|
||||
expect_fail(["remap", "--key", "caps=macroX"], "bad macro action")
|
||||
expect_fail(["remap", "--key", "caps=macro1:"], "bad macro action")
|
||||
|
||||
|
||||
# ---- macro --------------------------------------------------------------------
|
||||
|
||||
def test_macro_set_tokens():
|
||||
"""macro set: слот из токенов пишется одним блобом, прочие стираются."""
|
||||
k = run(["macro", "set", "2", "--", "+lctrl", "a@50", "-lctrl"])
|
||||
(kind, slots, save), = [c for c in k.calls if c[0] == "write_macro_table"]
|
||||
assert not save
|
||||
assert slots == [[]] * 2 + [parse_macro_tokens(["+lctrl", "a@50", "-lctrl"])]
|
||||
# --save строго ДО '--': после '--' argparse считает всё позиционным
|
||||
k = run(["macro", "set", "0", "--save", "--", "a"])
|
||||
(kind, slots, save), = [c for c in k.calls if c[0] == "write_macro_table"]
|
||||
assert save and len(slots[0]) == 2 # tap = нажать + отпустить
|
||||
|
||||
|
||||
def test_macro_set_xml():
|
||||
"""macro set --xml: события из вендорского XML."""
|
||||
k = run(["macro", "set", "1", "--xml", XML1])
|
||||
(kind, slots, save), = [c for c in k.calls if c[0] == "write_macro_table"]
|
||||
assert slots == [[]] + [parse_macro_xml(XML1)] and not save
|
||||
|
||||
|
||||
def test_macro_set_errors():
|
||||
"""macro set: индекс вне диапазона, нет токенов, xml вместе с токенами."""
|
||||
expect_fail(["macro", "set", "100", "--", "a"], "out of range")
|
||||
expect_fail(["macro", "set", "-1", "--", "a"], "out of range")
|
||||
expect_fail(["macro", "set", "0"], "provide tokens or --xml")
|
||||
expect_fail(["macro", "set", "0", "--xml", XML1, "--", "a"], "not both")
|
||||
expect_fail(["macro", "set", "0", "--", "nosuchkey"], "unknown key")
|
||||
|
||||
|
||||
def test_macro_clear_show_export():
|
||||
"""macro clear/show/export: очистка слотов, показ XML, генерация XML."""
|
||||
k = run(["macro", "clear"])
|
||||
(kind, slots, save), = [c for c in k.calls if c[0] == "write_macro_table"]
|
||||
assert slots == [] and not save
|
||||
k = run(["macro", "clear", "--save"])
|
||||
(kind, slots, save), = [c for c in k.calls if c[0] == "write_macro_table"]
|
||||
assert save
|
||||
# show печатает число событий и токены (run() гасит stdout — зовём напрямую)
|
||||
out = io.StringIO()
|
||||
with contextlib.redirect_stdout(out):
|
||||
dispatch(build_parser().parse_args(["macro", "show", XML1]), FakeKatana())
|
||||
assert "8 events" in out.getvalue() and "+a" in out.getvalue()
|
||||
# export: токены → XML-файл, который парсится обратно в те же события
|
||||
events = parse_macro_tokens(["a@10", "+lctrl", "-lctrl", "lmb@5"])
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = os.path.join(tmp, "out.xml")
|
||||
# --name строго ДО '--': после '--' argparse считает всё позиционным
|
||||
run(["macro", "export", path, "--name", "Test", "--",
|
||||
"a@10", "+lctrl", "-lctrl", "lmb@5"])
|
||||
assert parse_macro_xml(path) == events
|
||||
|
||||
|
||||
# ---- keys ---------------------------------------------------------------------
|
||||
|
||||
def test_keys_prints_frame():
|
||||
"""keys: живой кадр печатается сеткой по 16 слотов с hex-цветами."""
|
||||
k = FakeKatana()
|
||||
k.frame[0x01] = (255, 0, 0)
|
||||
out = io.StringIO()
|
||||
with contextlib.redirect_stdout(out):
|
||||
dispatch(build_parser().parse_args(["keys"]), k)
|
||||
lines = out.getvalue().strip().splitlines()
|
||||
assert len(lines) == 9 # 143 слота → 9 строк
|
||||
assert lines[0].startswith("00+:") and "ff0000" in lines[0]
|
||||
assert "......" in lines[1] # нулевые слоты — точки
|
||||
|
||||
|
||||
# ---- apply --------------------------------------------------------------------
|
||||
|
||||
def write_config(text):
|
||||
"""Записать YAML-конфиг во временный файл и вернуть путь."""
|
||||
fd, path = tempfile.mkstemp(suffix=".yaml")
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
f.write(text)
|
||||
return path
|
||||
|
||||
|
||||
def test_apply_order_and_saves():
|
||||
"""apply: порядок секций и расстановка save (последняя секция — с save)."""
|
||||
path = write_config(
|
||||
"remap:\n keys:\n caps: copy\n"
|
||||
"paint:\n all: black\n"
|
||||
"lighting:\n mode: static\n color: red\n"
|
||||
"macros:\n 0:\n tokens: ['+lctrl', 'a', '-lctrl']\n")
|
||||
try:
|
||||
k = FakeKatana()
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
cmd_apply(k, build_parser().parse_args(["apply", path]))
|
||||
finally:
|
||||
os.unlink(path)
|
||||
# remap → paint (+режим 0x80) → lighting, все без save;
|
||||
# затем macros с save и повторные remap/lighting с save
|
||||
assert [(kind, save) for kind, _, save in k.calls] == [
|
||||
("write_bind_table", False),
|
||||
("write_key_table", False), ("show_custom", False),
|
||||
("lighting", False),
|
||||
("write_macro_table", True),
|
||||
("write_bind_table", True),
|
||||
("lighting", True),
|
||||
]
|
||||
|
||||
|
||||
def test_apply_raw_and_empty():
|
||||
"""apply: секция raw (строкой), пустой конфиг — nothing to do."""
|
||||
path = write_config('raw: "aa55"\n')
|
||||
try:
|
||||
k = FakeKatana()
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
cmd_apply(k, build_parser().parse_args(["apply", path]))
|
||||
finally:
|
||||
os.unlink(path)
|
||||
(kind, payload, save), = k.calls
|
||||
assert kind == "lighting" and payload == bytes.fromhex("aa55") + bytes(62)
|
||||
# пустой конфиг (отображение без секций) — ни одного вызова к железу
|
||||
path = write_config("{}\n")
|
||||
try:
|
||||
k = FakeKatana()
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
cmd_apply(k, build_parser().parse_args(["apply", path]))
|
||||
finally:
|
||||
os.unlink(path)
|
||||
assert k.calls == []
|
||||
|
||||
|
||||
def test_apply_bad_config():
|
||||
"""apply: конфиг не-отображение и невалидные значения секций отклоняются."""
|
||||
path = write_config("- just\n- a\n- list\n")
|
||||
try:
|
||||
expect_fail(["apply", path], "top level must be a mapping")
|
||||
finally:
|
||||
os.unlink(path)
|
||||
path = write_config("paint:\n all: notacolor\n")
|
||||
try:
|
||||
expect_fail(["apply", path], "invalid values in [paint]")
|
||||
finally:
|
||||
os.unlink(path)
|
||||
path = write_config("macros:\n 0:\n tokens: ['nosuchkey']\n")
|
||||
try:
|
||||
expect_fail(["apply", path], "unknown key")
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
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()
|
||||
Executable
+284
@@ -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()
|
||||
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