Files

222 lines
7.7 KiB
Python
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
"""Офлайн-тесты выбора транспорта 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()