# -*- coding: utf-8 -*- """被控端协议回归测试。 用一个"桩 UI"替代 tkinter,从而可以在无界面干扰的前提下, 把服务端全部命令跑一遍。这是产品功能的真实验证,不是 mock 逻辑。 """ from __future__ import annotations import os import shutil import socket import struct import sys import tempfile import threading import time import wave from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "windows")) from lanremote import protocol as P # noqa: E402 from lanremote.actions import Actions # noqa: E402 from lanremote.config import Store # noqa: E402 from lanremote.server import RemoteServer # noqa: E402 PORT = 8799 PASSED: list[str] = [] FAILED: list[str] = [] def check(name: str, cond: bool, detail: str = "") -> None: (PASSED if cond else FAILED).append(name) mark = "PASS" if cond else "FAIL" print(f" [{mark}] {name}" + (f" {detail}" if detail and not cond else "")) class StubUI: """替身界面:只记录,不弹窗、不显示图片。""" def __init__(self, workdir: Path): self.workdir = workdir self.logs: list[str] = [] self.notifications: list[str] = [] self.images: list[str] = [] self.audio: list[str] = [] self.clipboard = "" self.screen_indicator = False self.screen_flashed = False # --- 服务端调用 --- def log(self, text): self.logs.append(text) def notify(self, title, text): self.notifications.append(f"{title}|{text}") def on_state_changed(self): pass def screen_size(self): return (1920, 1080) def set_screen_sharing(self, active): self.screen_indicator = bool(active) def flash_screen_indicator(self, seconds=2.5): self.screen_flashed = True def ui_call(self, fn, timeout=8.0): return fn() def root(self): # actions 里用到 root.clipboard_* —— 由下面替换 raise RuntimeError("stub 不提供 tkinter root") def make_stub_actions(ui: "StubUI", store: Store) -> Actions: """构造 Actions,但把依赖 tkinter/显示的部分替换为记录。""" acts = Actions.__new__(Actions) acts.ui = ui acts.store = store acts._audio_alias = None acts._image_window = None acts._lock = threading.RLock() def show_image(path, title=""): ui.images.append(Path(path).name) ui.last_image_title = title return f"已显示 {Path(path).name}" def play_audio(path): ui.audio.append(Path(path).name) return f"正在播放 {Path(path).name}" def clip_set(text): ui.clipboard = text return f"剪贴板已更新({len(text)} 字符)" def clip_get(): return ui.clipboard acts.show_image = show_image # type: ignore[method-assign] acts.play_audio = play_audio # type: ignore[method-assign] acts.clip_set = clip_set # type: ignore[method-assign] acts.clip_get = clip_get # type: ignore[method-assign] acts.stop_audio = lambda: "已停止" # type: ignore[method-assign] return acts class Client: """极简协议客户端(与安卓端行为一致)。""" def __init__(self, port=PORT): self.sock = socket.create_connection(("127.0.0.1", port), timeout=10) self.sock.settimeout(15) self.buf = b"" self.events: list[dict] = [] def send(self, obj): self.sock.sendall(P.dumps(obj)) def recv(self) -> dict: while b"\n" not in self.buf: data = self.sock.recv(65536) if not data: raise ConnectionError("服务端关闭了连接") self.buf += data line, self.buf = self.buf.split(b"\n", 1) return P.loads(line) def call(self, obj) -> dict: self.send(obj) while True: msg = self.recv() if "event" in msg: self.events.append(msg) continue return msg def send_chunks(self, cmd, name, blob): for seq, last, piece in P.chunk_bytes(blob): self.send({"cmd": cmd, "name": name, "seq": seq, "last": last, "data": P.b64encode(piece)}) return self.recv() def wait_event(self, kind, timeout=20.0) -> dict | None: deadline = time.time() + timeout for ev in self.events: if ev.get("event") == kind: return ev while time.time() < deadline: try: msg = self.recv() except (socket.timeout, ConnectionError): return None if msg.get("event") == kind: return msg self.events.append(msg) return None def close(self): try: self.sock.close() except OSError: pass def make_wav(path: Path, seconds=1.0, rate=8000): with wave.open(str(path), "wb") as fh: fh.setnchannels(1) fh.setsampwidth(2) fh.setframerate(rate) frames = b"".join( struct.pack(" int: work = Path(tempfile.mkdtemp(prefix="lanremote_test_")) print(f"测试工作目录: {work}") store = Store(work / "data") store.set("port", PORT) store.set("allow_run", True) store.set("allow_power", True) code = store.new_pairing_code() print(f"配对码: {code}\n") ui = StubUI(work) actions = make_stub_actions(ui, store) server = RemoteServer(store, actions, ui=ui) server.start() time.sleep(0.5) # 准备测试素材 png = work / "test_image.png" try: from PIL import Image Image.new("RGB", (300, 200), (30, 120, 200)).save(png) except ImportError: png.write_bytes(b"\x89PNG\r\n\x1a\n" + b"0" * 100) wav = work / "test_audio.wav" make_wav(wav) print("=" * 68) print("1. 未鉴权保护") c = Client() check("未鉴权时命令被拒绝", c.call({"cmd": "ping"}).get("ok") is False) c.close() print("=" * 68) print("2. 配对流程") c = Client() wrong = c.call({"cmd": "pair", "code": "000000", "deviceId": "w1", "deviceName": "手表一号"}) check("错误配对码被拒绝", wrong.get("ok") is False and "配对码" in wrong.get("error", "")) good = c.call({"cmd": "pair", "code": code, "deviceId": "w1", "deviceName": "手表一号"}) check("正确配对码通过", good.get("ok") is True) token = good.get("token", "") check("返回了设备令牌", len(token) == 32, f"token={token!r}") check("返回了服务器名", bool(good.get("serverName"))) c.close() print("=" * 68) print("3. 令牌免配对重连(模拟下次打开 App)") c = Client() bad = c.call({"cmd": "auth", "deviceId": "w1", "token": "deadbeef"}) check("错误令牌被拒绝", bad.get("ok") is False and bad.get("needPair") is True) ok = c.call({"cmd": "auth", "deviceId": "w1", "token": token, "deviceName": "手表一号"}) check("正确令牌免配对登录", ok.get("ok") is True) c.close() print("=" * 68) print("4. 基础命令") c = Client() c.call({"cmd": "auth", "deviceId": "w1", "token": token, "deviceName": "手表一号"}) check("ping", c.call({"cmd": "ping"}).get("pong") is True) info = c.call({"cmd": "info"}).get("info", {}) check("info 返回系统信息", bool(info.get("os")), str(info)) # 媒体按键会注入到当前前台播放器。音量/静音会改变系统状态, # 因此每测一项立刻用反向按键还原。 check("media 播放/暂停", c.call({"cmd": "media", "action": "playpause"}).get("ok") is True) check("media 下一曲", c.call({"cmd": "media", "action": "next"}).get("ok") is True) check("media 上一曲", c.call({"cmd": "media", "action": "prev"}).get("ok") is True) check("media 停止", c.call({"cmd": "media", "action": "stop"}).get("ok") is True) check("media 音量+", c.call({"cmd": "media", "action": "volup"}).get("ok") is True) check("media 音量-(还原)", c.call({"cmd": "media", "action": "voldown"}).get("ok") is True) check("media 静音", c.call({"cmd": "media", "action": "mute"}).get("ok") is True) check("media 静音(还原)", c.call({"cmd": "media", "action": "mute"}).get("ok") is True) check("media 非法动作被拒绝", c.call({"cmd": "media", "action": "explode"}).get("ok") is False) print("=" * 68) print("5. 剪贴板") check("clip_set", c.call({"cmd": "clip_set", "text": "来自手表的文本"}).get("ok") is True) got = c.call({"cmd": "clip_get"}) check("clip_get 取回内容", got.get("text") == "来自手表的文本", str(got)) print("=" * 68) print("6. 语音播报 / 通知") check("say", c.call({"cmd": "say", "text": "测试播报"}).get("ok") is True) check("notify", c.call({"cmd": "notify", "title": "T", "text": "B"}).get("ok") is True) check("通知已送达界面", any("T|B" == n for n in ui.notifications), str(ui.notifications)) print("=" * 68) print("7. 投图(分块传输)") blob = png.read_bytes() reply = c.send_chunks("image", "remote_photo.png", blob) check("图片接收成功", reply.get("ok") is True and reply.get("size") == len(blob), str(reply)) check("触发了全屏显示", ui.images == ["remote_photo.png"], str(ui.images)) print("=" * 68) print("8. 放音(分块传输)") abytes = wav.read_bytes() reply = c.send_chunks("audio", "remote_audio.wav", abytes) check("音频接收成功", reply.get("ok") is True and reply.get("size") == len(abytes), str(reply)) check("触发了音频播放", ui.audio == ["remote_audio.wav"], str(ui.audio)) print("=" * 68) print("9. 文件上传 / 列表 / 下载") payload = os.urandom(300 * 1024) # 300KB,跨越多个分块 reply = c.send_chunks("file_push", "bigfile.bin", payload) check("大文件分块上传成功", reply.get("ok") is True and reply.get("size") == len(payload), str(reply)) saved = Path(reply.get("path", "")) check("落盘内容一致", saved.is_file() and saved.read_bytes() == payload) listing = c.call({"cmd": "file_list"}) names = [f["name"] for f in listing.get("files", [])] check("file_list 包含刚上传的文件", "bigfile.bin" in names, str(names)) c.send({"cmd": "file_pull", "path": "bigfile.bin"}) assembled = bytearray() ok_pull = False deadline = time.time() + 30 while time.time() < deadline: msg = c.recv() if msg.get("event") == "file": assembled.extend(P.b64decode(msg.get("data", ""))) if msg.get("last"): ok_pull = True elif "ok" in msg: break # 服务端在分块发完后的最终回执,必须读走 check("file_pull 回传内容一致", ok_pull and bytes(assembled) == payload, f"got {len(assembled)} bytes") print("=" * 68) print("9b. 同名文件重复上传:拉回必须是最新的一份(回归曾经的 bug)") first_blob = b"AAAA" * 100 second_blob = b"BBBB" * 250 r1 = c.send_chunks("file_push", "dup.bin", first_blob) check("第一次上传 dup.bin", r1.get("ok") is True, str(r1)) time.sleep(0.1) r2 = c.send_chunks("file_push", "dup.bin", second_blob) check("第二次上传 dup.bin", r2.get("ok") is True, str(r2)) check("两次保存到不同文件(不覆盖旧数据)", r1.get("path") != r2.get("path"), f"{r1.get('path')} vs {r2.get('path')}") c.send({"cmd": "file_pull", "path": "dup.bin"}) dup = bytearray() dup_done = False deadline = time.time() + 20 while time.time() < deadline: msg = c.recv() if msg.get("event") == "file": dup.extend(P.b64decode(msg.get("data", ""))) if msg.get("last"): dup_done = True elif "ok" in msg: break check("dup.bin 拉回的是最新内容(不是旧文件)", dup_done and bytes(dup) == second_blob, f"got {len(dup)} bytes, expected {len(second_blob)}") print("=" * 68) print("10. 自定义命令") r = c.call({"cmd": "run", "command": "echo lanremote-ok"}) check("run 执行并回传输出", r.get("ok") is True and "lanremote-ok" in r.get("stdout", ""), str(r)) check("run 返回退出码", r.get("code") == 0, str(r.get("code"))) print("=" * 68) print("11. 权限开关生效") store.set("allow_run", False) check("关闭后 run 被拒绝", c.call({"cmd": "run", "command": "echo x"}).get("ok") is False) store.set("allow_run", True) store.set("allow_power", False) check("关闭后 power 被拒绝", c.call({"cmd": "power", "action": "lock"}).get("ok") is False) store.set("allow_power", True) print("=" * 68) print("12. 电源:取消关机(安全动作)") check("power abort 正常返回", c.call({"cmd": "power", "action": "abort"}).get("ok") is True) print("=" * 68) print("13. 多设备与状态") c2 = Client() pair2 = c2.call({"cmd": "pair", "code": code, "deviceId": "w2", "deviceName": "手表二号"}) check("第二台设备可配对", pair2.get("ok") is True) status = server.status() check("服务端报告 2 台在线", len(status["connected"]) == 2, str(status["connected"])) check("已保存 2 台设备", len(status["paired_saved"]) == 2) check("设备名正确记录", {"手表一号", "手表二号"} == {d["name"] for d in status["paired_saved"]}, str(status["paired_saved"])) print("=" * 68) print("14. 屏幕镜像 / 截屏") store.set("allow_screen", False) check("关闭屏幕权限后 screen_start 被拒绝", c.call({"cmd": "screen_start"}).get("ok") is False) check("关闭屏幕权限后 screenshot 被拒绝", c.call({"cmd": "screenshot"}).get("ok") is False) store.set("allow_screen", True) # --- 单次截屏 --- shot = c.call({"cmd": "screenshot", "maxWidth": 640, "quality": 70}) check("截屏返回成功", shot.get("ok") is True, str(shot)[:160]) raw = P.b64decode(shot.get("data", "")) check("截屏是合法 JPEG", raw[:2] == b"\xff\xd8" and raw[-2:] == b"\xff\xd9", f"{len(raw)} 字节") check("截屏尺寸有效", int(shot.get("w") or 0) > 0 and int(shot.get("h") or 0) > 0, f"{shot.get('w')}x{shot.get('h')}") check("截屏遵守 maxWidth", int(shot.get("w") or 9999) <= 640, str(shot.get("w"))) check("截屏时被控端闪了可见提示", ui.screen_flashed is True) # --- 实时镜像 --- start = c.call({"cmd": "screen_start", "fps": 4, "maxWidth": 320, "quality": 50}) check("开始屏幕镜像", start.get("ok") is True, str(start)) check("共享时被控端显示了可见提示(强制)", ui.screen_indicator is True) check("服务端报告正在共享", server.status().get("screen_sharing") is True) check("服务端报告观看者数量", server.status().get("screen_viewers") == 1, str(server.status().get("screen_viewers"))) frames: list[dict] = [] deadline = time.time() + 20 while time.time() < deadline and len(frames) < 3: try: msg = c.recv() except Exception as exc: # noqa: BLE001 print(f" 收帧异常: {exc}") break if msg.get("event") == P.EVT_FRAME: frames.append(msg) check("收到至少 3 帧镜像", len(frames) >= 3, f"实际 {len(frames)} 帧") if frames: first = frames[0] data = P.b64decode(first.get("data", "")) check("镜像帧是合法 JPEG", data[:2] == b"\xff\xd8" and data[-2:] == b"\xff\xd9", f"{len(data)} 字节") check("镜像帧遵守 maxWidth", int(first.get("w") or 9999) <= 320, str(first.get("w"))) sizes = [int(f.get("size") or 0) for f in frames] check("帧大小合理(几 KB 量级)", all(0 < s < 2_000_000 for s in sizes), str(sizes)) stop = c.call({"cmd": "screen_stop"}) check("停止屏幕镜像", stop.get("ok") is True) time.sleep(0.8) check("停止后不再共享", server.status().get("screen_sharing") is False) check("停止后可见提示关闭", ui.screen_indicator is False) # --- 被控端本机强制掐断(最终否决权)--- c.call({"cmd": "screen_start", "fps": 2}) time.sleep(0.5) check("重新开始共享生效", server.status().get("screen_sharing") is True) affected = server.stop_screen_sharing() time.sleep(0.5) check("本机一键掐断生效", affected == 1 and server.status().get("screen_sharing") is False, f"affected={affected}, sharing={server.status().get('screen_sharing')}") check("掐断后可见提示关闭", ui.screen_indicator is False) c.events.clear() # 丢掉残留帧事件,避免影响后续断言 print("=" * 68) print("15. 断开与清理") n = server.disconnect_all() check("断开全部连接", n == 2, f"disconnected={n}") time.sleep(0.4) check("断开后在线数为 0", len(server.status()["connected"]) == 0) check("移除设备凭据", store.forget_device("w2") is True) check("移除后仅剩 1 台", len(store.device_list()) == 1) c.close() c2.close() server.stop() time.sleep(0.3) print("\n" + "=" * 68) print(f"通过 {len(PASSED)} 项,失败 {len(FAILED)} 项") if FAILED: print("失败项:") for name in FAILED: print(f" - {name}") shutil.rmtree(work, ignore_errors=True) return 1 if FAILED else 0 if __name__ == "__main__": sys.exit(main())