222 lines
7.7 KiB
Python
222 lines
7.7 KiB
Python
#!/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 <opcode> как 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()
|