470 lines
21 KiB
Python
Executable File
470 lines
21 KiB
Python
Executable File
#!/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()
|