Files
qianbian/tests/test_device_manager.py

223 lines
7.2 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.
"""DeviceManager 测试:按需连接/串行/空闲超时/掉线重连。"""
import asyncio
import pytest
from bleak.exc import BleakError
from ppclock.client import PPClient
from ppclock.device_manager import DeviceManager
from ppclock.transports.base import TransportError
class FakeTransport:
def __init__(self):
self.address = "AA:BB:CC:DD:EE:FF"
self.enters = 0
self.exits = 0
self.rxtx_writes = []
self.fail_next_write = False
# fail_next_write 触发时抛出的异常实例(默认 SDK 包装后的 TransportError;
# 真实硬件路径常是 bleak 裸抛的 BleakError,测试可替换)
self.fail_exc = TransportError("connection lost")
self.firmware_revision = "HM42_AIO_V1.0.9"
self.rxtx_uuid = "00001f1f-0000-1000-8000-00805f9b34fb"
async def __aenter__(self):
self.enters += 1
return self
async def __aexit__(self, *exc):
self.exits += 1
return False
async def write_epd(self, data, response=True):
pass
async def write_rxtx(self, data, response=True):
if self.fail_next_write:
self.fail_next_write = False
raise self.fail_exc
self.rxtx_writes.append(data)
async def read_rxtx(self):
return b"\x00"
async def request_device_id(self, timeout=8.0):
return "81233F3C267112"
async def run_ota(self, image, on_progress=None):
return {}
async def delay(self, seconds):
pass
def make_manager(transports, **kw):
"""client_factory 依次弹出预置 transport 包成 PPClient。"""
def factory(mac, timeout):
return PPClient(transports.pop(0))
kw.setdefault("client_factory", factory)
return DeviceManager(**kw)
class TestConnect:
@pytest.mark.asyncio
async def test_connect_on_demand_and_reuse(self):
t = FakeTransport()
m = make_manager([t])
assert not m.connected
await m.run(lambda dev: dev.set_mode("clock1"))
await m.run(lambda dev: dev.set_mode("clock2"))
assert t.enters == 1 # 第二次复用连接
assert m.connected
await m.close()
@pytest.mark.asyncio
async def test_remembers_address(self):
t = FakeTransport()
m = make_manager([t], mac=None)
await m.run(lambda dev: dev.set_mode("clock1"))
assert (await m.status())["mac"] == "AA:BB:CC:DD:EE:FF"
await m.close()
class TestSerialization:
@pytest.mark.asyncio
async def test_ops_are_serialized(self):
t = FakeTransport()
m = make_manager([t])
concurrent = 0
peak = 0
async def op(dev):
nonlocal concurrent, peak
concurrent += 1
peak = max(peak, concurrent)
await asyncio.sleep(0.02)
concurrent -= 1
return 1
await asyncio.gather(*(m.run(op) for _ in range(5)))
assert peak == 1
await m.close()
class TestIdleTimeout:
@pytest.mark.asyncio
async def test_idle_disconnect(self):
t = FakeTransport()
m = make_manager([t], idle_timeout=0.05)
await m.run(lambda dev: dev.set_mode("clock1"))
assert m.connected
await asyncio.sleep(0.15)
assert not m.connected
assert t.exits == 1
@pytest.mark.asyncio
async def test_activity_resets_idle(self):
t = FakeTransport()
m = make_manager([t], idle_timeout=0.1)
await m.run(lambda dev: dev.set_mode("clock1"))
await asyncio.sleep(0.06)
await m.run(lambda dev: dev.set_mode("clock2"))
await asyncio.sleep(0.06)
assert m.connected # 第二次活动续期
await m.close()
class TestReconnect:
@pytest.mark.asyncio
async def test_reconnect_once_on_transport_error(self):
t1, t2 = FakeTransport(), FakeTransport()
t1.fail_next_write = True
m = make_manager([t1, t2])
await m.run(lambda dev: dev.set_mode("clock1"))
assert t1.enters == 1 and t2.enters == 1 # 掉线重连一次并成功
await m.close()
@pytest.mark.asyncio
async def test_retry_exhaustion_raises(self):
t1, t2 = FakeTransport(), FakeTransport()
t1.fail_next_write = True
t2.fail_next_write = True
m = make_manager([t1, t2])
with pytest.raises(TransportError):
await m.run(lambda dev: dev.set_mode("clock1"))
await m.close()
@pytest.mark.asyncio
async def test_reconnect_on_raw_bleak_error(self):
"""真实掉线 bleak 裸抛 BleakError(不经 SDK 包装),也要重连重试。"""
t1, t2 = FakeTransport(), FakeTransport()
t1.fail_next_write = True
t1.fail_exc = BleakError("Not connected")
m = make_manager([t1, t2])
await m.run(lambda dev: dev.set_mode("clock1"))
assert t1.enters == 1 and t2.enters == 1 # 掉线重连一次并成功
await m.close()
@pytest.mark.asyncio
async def test_retry_exhaustion_raises_bleak_error(self):
"""重连后仍抛 BleakError → 原样上抛。"""
t1, t2 = FakeTransport(), FakeTransport()
for t in (t1, t2):
t.fail_next_write = True
t.fail_exc = BleakError("Not connected")
m = make_manager([t1, t2])
with pytest.raises(BleakError):
await m.run(lambda dev: dev.set_mode("clock1"))
await m.close()
class TestEnterFailure:
@pytest.mark.asyncio
async def test_aenter_failure_cleans_up(self):
"""__aenter__ 抛异常时必须补 __aexit__(否则真实硬件泄漏半开连接)。"""
class HalfOpenClient:
def __init__(self):
self.exits = 0
async def __aenter__(self):
raise BleakError("connect boom")
async def __aexit__(self, *exc):
self.exits += 1
return False
client = HalfOpenClient()
m = DeviceManager(mac="AA:BB:CC:DD:EE:FF",
client_factory=lambda mac, timeout: client)
with pytest.raises(BleakError):
await m.run(lambda dev: dev.set_mode("clock1"))
assert client.exits == 1
assert not m.connected
await m.close()
class TestStatus:
@pytest.mark.asyncio
async def test_status_fields(self):
t = FakeTransport()
m = make_manager([t], connect_timeout=90.0, idle_timeout=300.0)
s = await m.status()
assert s["connected"] is True # status 自身触发连接
assert s["device_id"] == "81233F3C267112"
assert s["connect_timeout"] == 90.0
assert s["idle_timeout"] == 300.0
assert s["idle_seconds"] is None # 无先前活动;status 自身不得把读数归零
assert s["firmware_revision"] == "HM42_AIO_V1.0.9"
assert s["rxtx_uuid"].startswith("00001f1f")
await m.close()
@pytest.mark.asyncio
async def test_status_idle_seconds_reflects_last_activity(self):
"""status 自身的 _touch 不得把 idle_seconds 归零(先算后摸)。"""
t = FakeTransport()
m = make_manager([t])
await m.run(lambda dev: dev.set_mode("clock1"))
await asyncio.sleep(0.12)
s = await m.status()
assert s["idle_seconds"] >= 0.1
await m.close()