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
+469
View File
@@ -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()