Поддержка MacOS (проверено в Tahoe)

This commit is contained in:
2026-09-06 17:13:34 +08:00
parent 57c4e80f0d
commit 4515b39090
24 changed files with 590 additions and 65 deletions
+1 -1
View File
@@ -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,
+221
View File
@@ -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()