# -*- coding: utf-8 -*- """局域网优化这一轮的回归测试。 覆盖四件事(每件都对应一个"原来怎么错/慢"): 1. **网段判断不再写死 /24** 原来 `sameSubnet` 只比前三段,等于假设 /24。在 10.x/8、172.16/12 或任何 /16 网络上(校园网、部分路由器), 手表 10.8.1.5 电脑 10.8.2.5 (掩码 /16,其实同一个网络) 会被判成"不同网络" -> **明明能直连却去走中转**。 现在按网卡真实掩码比较。 2. **广播发现要重发** 原来只在 t=0 发一个 UDP 探测包,丢了就整轮搜不到。 现在按时间点重发多轮,并且同时打受限广播 + 各网段的定向广播。 3. **多网卡要都探** 原来 `localNet()` 拿到第一个 wlan 就 return,只探一个网段。 现在 `localNets()` 返回全部,搜索/扫描都覆盖。 4. **走中转之后要能自己回到局域网** 原来只在"刚经中转连上"那一刻试一次直连,之后永远挂在中转上。 现在会定期回头检查,能直连就切回来。 另外验证「默认中转地址是空白」不会把功能弄坏。 """ from __future__ import annotations import json import os import shutil import socket import subprocess import sys import tempfile import time from pathlib import Path ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT / "windows")) sys.path.insert(0, str(ROOT / "_tests")) WATCH = ROOT / "android" / "src" / "com" / "lanremote" / "watch" MAIN = WATCH / "MainActivity.java" DISCOVERY_JAVA = WATCH / "Discovery.java" CORE_JAVA = WATCH / "DiscoveryCore.java" passed = 0 failed = 0 def check(name, ok, detail=""): global passed, failed if ok: passed += 1 print(" [PASS] " + name) else: failed += 1 print(" [FAIL] " + name + " " + str(detail)) def main() -> int: from lanremote import discovery from lanremote import instance main_src = MAIN.read_text(encoding="utf-8") disc_src = DISCOVERY_JAVA.read_text(encoding="utf-8") core_src = CORE_JAVA.read_text(encoding="utf-8") # ================================================================== print("一、网段判断按真实掩码(不再写死 /24)") # ================================================================== check("DiscoveryCore 有按前缀比较的方法", "public static boolean sameSubnet(String a, String b, int prefix)" in core_src) check("DiscoveryCore 保留 /24 兜底实现", "public static boolean sameSubnet24" in core_src) check("MainActivity 有 inSameSubnet 包装", "private boolean inSameSubnet(" in main_src) check("hostInMySubnet 用真实网卡掩码(Discovery.localNets)", "Discovery.localNets()" in main_src) check("pickSameSubnet 也用真实掩码", main_src.count("Discovery.localNets()") >= 2, main_src.count("Discovery.localNets()")) check("拿不到掩码时退回 /24(不写死也不放弃)", "return sameSubnet(a, b);" in main_src) # ================================================================== print() print("二、广播发现要重发 + 多网段") # ================================================================== check("DiscoveryCore 有重发时间表", "PROBE_AT_MS" in core_src) check("DiscoveryCore 有 searchMany(多广播地址)", "public static List searchMany(" in core_src) check("旧的 search(timeout, extra) 还在(向后兼容)", "public static List search(int timeoutMs, String extraBroadcast)" in core_src) check("Discovery 有 localNets(全部网卡)", "public static List localNets()" in disc_src) check("Discovery 有 directedBroadcasts(全部网段广播)", "public static List directedBroadcasts()" in disc_src) check("搜索用 searchMany 而不是单发", "DiscoveryCore.searchMany(timeoutMs, directedBroadcasts())" in disc_src) check("子网扫描覆盖所有网段", "for (LocalNet net : localNets())" in disc_src) # Python 侧(被控端那边用的是同一套逻辑) check("Python discover 也重发", len(discovery._PROBE_AT) >= 3, discovery._PROBE_AT) check("Python 会算本网段定向广播", isinstance(discovery.local_broadcasts(), list)) broadcasts = discovery.local_broadcasts() check("定向广播都是 x.y.z.255 形式", all(b.endswith(".255") and b.count(".") == 3 for b in broadcasts), broadcasts) check("定向广播不包含 APIPA(169.254)", all(not b.startswith("169.254.") for b in broadcasts), broadcasts) # ================================================================== print() print("三、走中转之后能自己回到局域网") # ================================================================== check("有 scheduleLanRecheck", "private void scheduleLanRecheck()" in main_src) check("有 tryLanReconnect", "private void tryLanReconnect()" in main_src) check("回切前先做便宜的 TCP 探测(不白断中转)", "DiscoveryCore.ping(ip, port, 2000)" in main_src) check("没配中转时不排定时器(默认空白也不空转)", "if (relayUrl().isEmpty())" in main_src) check("防重入", "lanRecheckScheduled" in main_src) check("销毁时取消回调", "removeCallbacksAndMessages(null)" in main_src) check("失败会退避(不会疯狂重试)", "LAN_RECHECK_MAX_MS" in main_src and "lanRecheckDelay * 2" in main_src) # 连接成功时按"走的是哪条路"决定要不要回切 check("连上中转后排一次回切检查", "scheduleLanRecheck();" in main_src) check("切中转成功后也排一次", main_src.count("scheduleLanRecheck();") >= 3, main_src.count("scheduleLanRecheck();")) # ================================================================== print() print("四、默认中转地址是空白,而且不影响局域网") # ================================================================== from lanremote.server import DEFAULT_RELAY_URL check("server.py 的默认中转是空字符串", DEFAULT_RELAY_URL == "", repr(DEFAULT_RELAY_URL)) check("MainActivity 的默认中转是空字符串", 'DEFAULT_RELAY_URL = "";' in main_src) check("不再硬编码旧设备地址", "192.168.2.156" not in main_src) # 旧公网域名只允许出现在注释/文档里,**不允许**作为默认值的字面量 check("没有把旧公网域名当默认值", 'DEFAULT_RELAY_URL = "https://' not in main_src) # 单实例锁端口可以被覆盖(测试才能和用户自己的实例并存) check("instance 有 control_port()", hasattr(instance, "control_port")) old = os.environ.pop("YKXMZ_LOCK_PORT", None) try: check("不设环境变量时用默认 8767", instance.control_port() == 8767, instance.control_port()) os.environ["YKXMZ_LOCK_PORT"] = "18099" check("设了就用覆盖值", instance.control_port() == 18099, instance.control_port()) os.environ["YKXMZ_LOCK_PORT"] = "not-a-port" check("非法值退回默认", instance.control_port() == 8767, instance.control_port()) os.environ["YKXMZ_LOCK_PORT"] = "99999" check("超范围退回默认", instance.control_port() == 8767, instance.control_port()) finally: os.environ.pop("YKXMZ_LOCK_PORT", None) if old is not None: os.environ["YKXMZ_LOCK_PORT"] = old # ================================================================== print() print("五、默认空白时:局域网这条路必须照常工作") # ================================================================== # 起一个"没有配中转"的真实被控端,验证: # * 起得来、不报错、不卡 # * 局域网 TCP 能连、能配对、能执行命令 work = Path(tempfile.mkdtemp(prefix="lan_opt_")) agent = None try: from lanremote.config import Store data = work / "data" data.mkdir(parents=True, exist_ok=True) port = _free_port() store = Store(data) store.set_pairing_code("246813") store.set("setup_done", True) store.set("allow_new_pairing", True) store.set("close_pair_after_first", False) store.set("server_name", "无中转测试机") store.set("port", port) # 刻意**不设** relay_url,走 DEFAULT_RELAY_URL(空) env = dict(os.environ) env.update({"YKXMZ_DATA": str(data), "YKXMZ_LOCK_PORT": str(_free_port()), "PYTHONIOENCODING": "utf-8", "PYTHONUNBUFFERED": "1"}) agent = subprocess.Popen( [sys.executable, "-u", str(ROOT / "windows" / "run_agent.py"), "--silent", "--port", str(port)], cwd=str(ROOT / "windows"), env=env, stdout=open(work / "agent.log", "wb"), stderr=subprocess.STDOUT) ready = False deadline = time.time() + 30 while time.time() < deadline: time.sleep(0.5) if agent.poll() is not None: break try: with socket.create_connection(("127.0.0.1", port), 1.0): ready = True break except OSError: continue check("没配中转时被控端正常起来(局域网端口在听)", ready, (work / "agent.log").read_text(encoding="utf-8", errors="replace")[-300:]) if ready: check("被控端进程没有异常退出", agent.poll() is None) # 真走一遍协议:配对 + ping sock = socket.create_connection(("127.0.0.1", port), 5.0) sock.settimeout(8.0) try: reply = _call(sock, {"cmd": "pair", "deviceId": "lan-opt-test", "deviceName": "测试手表", "code": "246813"}) check("没有中转也能配对(局域网直连)", bool(reply.get("ok")), str(reply)[:120]) token = reply.get("token", "") check("拿到设备令牌", len(token) == 32, token) pong = _call(sock, {"cmd": "ping"}) check("没有中转也能执行命令", bool(pong.get("ok")), str(pong)[:120]) finally: sock.close() # 日志里应该明确说"只走局域网",而不是报错或去连一个不存在的地址 time.sleep(1.0) log = "" try: log = (data / "silent.log").read_text(encoding="utf-8", errors="replace") except OSError: pass check("日志里说明了只走局域网", "只走局域网" in log, log[-200:]) check("日志里没有中转报错", "启用中转失败" not in log and "Traceback" not in log, log[-300:]) finally: if agent is not None: try: agent.terminate() agent.wait(timeout=6) except Exception: # noqa: BLE001 try: agent.kill() except Exception: # noqa: BLE001 pass time.sleep(0.4) shutil.rmtree(work, ignore_errors=True) print() print("=" * 62) print("通过 %d 项,失败 %d 项" % (passed, failed)) return 0 if failed == 0 else 1 def _free_port() -> int: with socket.socket() as probe: probe.bind(("127.0.0.1", 0)) return probe.getsockname()[1] def _call(sock, obj, timeout=8.0): """发一条命令读一条回复(换行分隔 JSON)。""" sock.settimeout(timeout) sock.sendall((json.dumps(obj, ensure_ascii=False) + "\n").encode("utf-8")) buf = b"" deadline = time.time() + timeout while time.time() < deadline: while b"\n" in buf: line, _, buf = buf.partition(b"\n") if not line.strip(): continue message = json.loads(line.decode("utf-8")) if "event" in message: continue return message chunk = sock.recv(65536) if not chunk: raise ConnectionError("被控端关闭了连接") buf += chunk raise TimeoutError("等回复超时") if __name__ == "__main__": raise SystemExit(main())