Files
2026-09-06 01:06:40 +08:00

470 lines
21 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()