#!/usr/bin/env python3 """Офлайн-тесты выбора транспорта katana.py (без железа). Запуск: python3 tests/test_transport.py (или pytest tests/test_transport.py). Покрывает: фабрику open_katana (платформенный выбор Katana/KatanaMacOS), поиск устройства _mac_find_service (выбор boot keyboard) и формат обмена KatanaMacOS._set/_get через IOKit (feature-репорты, 64 байта, rid=0). IOKit подменяется фейком в katana._mac_load_iokit — реальный IOKit есть только на macOS, поэтому на Linux тесты тоже проходят. """ import os import sys import types sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) import katana from katana import Katana, KatanaMacOS, open_katana class FakeIOKit: """Подменный IOKit: журнал вызовов, зашитые свойства и ACK прошивки. Имитирует только то, что использует KatanaMacOS: перечисление (одна клавиатура: boot keyboard + consumer-интерфейс), IOHIDDeviceSetReport/GetReport (feature-репорты rid=0). """ def __init__(self): self.sent, self.get_count = [], 0 self.ack = bytes([0x04, 0x18, 0x00, 0x01]) + bytes(60) # свойства двух интерфейсов клавиатуры (и одно чужое устройство) self.props = { 0x101: {"VendorID": 0x0C45, "ProductID": 0x8006, "PrimaryUsagePage": 1, "PrimaryUsage": 6}, # iface0 0x102: {"VendorID": 0x0C45, "ProductID": 0x8006, "PrimaryUsagePage": 12, "PrimaryUsage": 1}, # iface1 0x103: {"VendorID": 0x1234, "ProductID": 0x5678, "PrimaryUsagePage": 1, "PrimaryUsage": 6}, # чужое } self.released = [] # -- перечисление -- def IOServiceGetMatchingServices(self, port, matcher, it_ref): # it_ref — ctypes.byref(c_uint); храним итератор отдельно self._it = iter(list(self.props)) return 0 def IOServiceMatching(self, name): return object() # фиктивный словарь соответствия def IOIteratorNext(self, it): return next(self._it, 0) def IOObjectRelease(self, entry): self.released.append(entry) def IOHIDDeviceCreate(self, port, entry): return f"dev{entry:#x}" if entry in self.props else None def IOHIDDeviceGetProperty(self, dev, key): entry = int(dev[3:], 16) return self.props[entry].get(key.value.decode()) # -- feature-репорты -- def IOHIDDeviceSetReport(self, dev, rtype, rid, buf, length): self.sent.append((rtype, rid, bytes(buf[:length]))) return 0 def IOHIDDeviceGetReport(self, dev, rtype, rid, buf, n_ref): # n_ref приходит как ctypes.byref(n) — распаковываем через _obj n = min(n_ref._obj.value, len(self.ack)) for i in range(n): buf[i] = self.ack[i] n_ref._obj.value = n self.get_count += 1 return 0 def IOHIDDeviceOpen(self, dev, options): return 0 def IOHIDDeviceClose(self, dev, options): return 0 class FakeCF: """Подменный CoreFoundation: строки-ключи как есть, CFNumber → int.""" def CFStringCreateWithCString(self, alloc, s, encoding): return types.SimpleNamespace(value=s) def CFNumberGetValue(self, num, ntype, out_ref): out_ref._obj.value = int(num) return True def CFRelease(self, obj): pass def make_macos_katana(): """KatanaMacOS с подмененными IOKit/CF; возвращает (k, fake_iokit, restore).""" fake_iokit, fake_cf = FakeIOKit(), FakeCF() saved = (katana._mac_load_iokit, katana._mac_find_service) def fake_load(): return fake_iokit, fake_cf def fake_find(iokit, cf): return 0x101 # boot keyboard (iface0) katana._mac_load_iokit = fake_load katana._mac_find_service = fake_find k = KatanaMacOS() def restore(): katana._mac_load_iokit, katana._mac_find_service = saved return k, fake_iokit, restore def test_open_katana_darwin(): """open_katana на darwin возвращает KatanaMacOS (IOKit-транспорт).""" orig = sys.platform sys.platform = "darwin" try: k, fake, restore = make_macos_katana() try: got = open_katana() assert isinstance(got, KatanaMacOS) got.close() finally: restore() finally: sys.platform = orig def test_mac_find_service_boot_keyboard(): """_mac_find_service: выбирает boot keyboard (page 1, usage 6) клавиатуры.""" fake_iokit, fake_cf = FakeIOKit(), FakeCF() assert katana._mac_find_service(fake_iokit, fake_cf) == 0x101 def test_mac_find_service_not_found(): """_mac_find_service: без boot keyboard клавиатуры — SystemExit.""" fake_iokit, fake_cf = FakeIOKit(), FakeCF() del fake_iokit.props[0x101] # убрать клавиатурный интерфейс try: katana._mac_find_service(fake_iokit, fake_cf) except SystemExit as e: assert "0c45:8006" in str(e), str(e) else: raise AssertionError("must fail without boot keyboard") def test_macos_set_get_format(): """KatanaMacOS._set/_get: feature-репорт rid=0, payload 64 байта целиком.""" k, fake, restore = make_macos_katana() try: payload = bytes([0x04, 0x18]) + bytes(62) k._set(payload) assert fake.sent == [(KatanaMacOS.REPORT_FEATURE, 0, payload)] assert k._get() == fake.ack assert fake.get_count == 1 k.close() finally: restore() def test_macos_cmd_uses_inherited_logic(): """KatanaMacOS._cmd: унаследованная логика команд работает поверх IOKit.""" k, fake, restore = make_macos_katana() try: ack = k._cmd(0x18) assert ack[:4] == b"\x04\x18\x00\x01" # _cmd шлёт 64-байтную команду 04 как feature-репорт rid=0 rtype, rid, buf = fake.sent[0] assert rtype == KatanaMacOS.REPORT_FEATURE and rid == 0 assert len(buf) == 64 and buf[0:3] == b"\x04\x18\x00" k.close() finally: restore() def test_open_katana_linux_class(): """На не-darwin платформе фабрика возвращает hidraw-класс Katana.""" orig = sys.platform sys.platform = "linux" try: # Katana.__init__ открывает hidraw-узел — на macOS его нет, # поэтому проверяем только тип: SystemExit от find_hidraw = ветка верная try: open_katana() except SystemExit as e: assert "hidraw" in str(e), str(e) except OSError: pass # /dev/hidraw* существует, но клавиатуры нет — тоже ок else: raise AssertionError("linux branch must try hidraw, not IOKit") finally: sys.platform = orig def run_all(): tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")] failed = 0 for t in tests: try: t() print(f"ok {t.__name__}") except AssertionError as e: failed += 1 print(f"FAIL {t.__name__}: {e}") if failed: raise SystemExit(f"{failed} test(s) failed") print(f"all {len(tests)} transport tests passed") if __name__ == "__main__": run_all()