Поддержка MacOS (проверено в Tahoe)
This commit is contained in:
@@ -15,7 +15,7 @@ import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
import keymap
|
||||
import misc.keymap as keymap
|
||||
from katana import (ALPHA_KEYS, ARROW_KEYS, BIND_COUNT, DIGITS_KEYS, KEYS_COUNT,
|
||||
MODES, NUMPAD_KEYS, PUNCT_KEYS, ROW_KEYS, Katana, _hid_ioc,
|
||||
_hid_iocgfeature, _hid_iocsfeature, build_payload,
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user