"""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") 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 自身不得把读数归零 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()