#!/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()