"""白板 WebSocket 端到端烟测:两客户端实时同步 + 心跳 + 清空。 验证: 1. 客户端 A 连入 -> 收到 init 2. 客户端 B 连入 -> 收到 init 3. A 画一笔 -> B 收到 stroke 广播(A 不收自己的) 4. 心跳 ping -> pong 5. A 清空 -> A、B 都收到 cleared 6. 停发心跳的连接会被服务端 reaper 移除(15s,这里只验证 ping/pong 即可,reaper 已在 hub 单测覆盖) """ from __future__ import annotations import asyncio import json import websockets BASE_WS = "ws://127.0.0.1:6867/ws/wb" BOARD = "e2etest" async def recv_msg(ws, timeout=2.0) -> dict | None: try: raw = await asyncio.wait_for(ws.recv(), timeout=timeout) return json.loads(raw) except asyncio.TimeoutError: return None async def main() -> None: async with websockets.connect(f"{BASE_WS}/{BOARD}") as a, \ websockets.connect(f"{BASE_WS}/{BOARD}") as b: # hello await a.send(json.dumps({"type": "hello", "client_id": "A"})) await b.send(json.dumps({"type": "hello", "client_id": "B"})) init_a = await recv_msg(a) init_b = await recv_msg(b) print("A init:", init_a.get("type") if init_a else None) print("B init:", init_b.get("type") if init_b else None) assert init_a and init_a["type"] == "init" assert init_b and init_b["type"] == "init" # A 画一笔 stroke = {"points": [[10, 10], [20, 20]], "color": "#1565c0", "width": 3} await a.send(json.dumps({"type": "stroke", "stroke": stroke})) # A 不应收到自己的(排除发送者) echo = await recv_msg(a, timeout=1.0) print("A self-echo (expect None):", echo) assert echo is None, "发送者不应收到自己的 stroke" # B 应收到 got = await recv_msg(b) print("B recv:", got.get("type") if got else None, "client_id=", got.get("client_id") if got else None) assert got and got["type"] == "stroke" and got["client_id"] == "A" assert got["stroke"] == stroke # 心跳 await a.send(json.dumps({"type": "ping"})) pong = await recv_msg(a) print("A pong:", pong.get("type") if pong else None) assert pong and pong["type"] == "pong" # 清空 -> 两端都收 cleared await b.send(json.dumps({"type": "clear"})) cleared_b = await recv_msg(b) cleared_a = await recv_msg(a) print("B cleared:", cleared_b.get("type") if cleared_b else None, "A cleared:", cleared_a.get("type") if cleared_a else None) assert cleared_b and cleared_b["type"] == "cleared" and cleared_b["client_id"] == "B" assert cleared_a and cleared_a["type"] == "cleared" and cleared_a["client_id"] == "B" # 验证持久化:重连后 init 应 strokes 为空(已清空) async with websockets.connect(f"{BASE_WS}/{BOARD}") as c: await c.send(json.dumps({"type": "hello", "client_id": "C"})) init_c = await recv_msg(c) print("C init after clear, strokes=", init_c.get("strokes") if init_c else None) assert init_c and init_c["type"] == "init" assert init_c["strokes"] == [], "清空后重连应得到空 strokes" # 验证 stroke_count 累计(之前 1 笔 + 1 次清空 = 2) import urllib.request with urllib.request.urlopen(f"http://127.0.0.1:6867/api/wb/{BOARD}") as r: meta = json.load(r) print("stroke_count after ops:", meta["stroke_count"]) assert meta["stroke_count"] == 2, "1 笔 + 1 清空 = 2 次修改" print("\nWS 端到端全部通过 ✅") if __name__ == "__main__": asyncio.run(main())