Files
ykxmz/_tests/test_lan_optimize.py
T
xmz7891 4295b482a1 2.0.0:安装时可设中转服务器 + 手表端新增设置页 + 中转默认留空
按用户定的版本规矩,这一轮既有新内容又有重构,所以升第二位:
1.9.0 -> 2.0.0(最后一位清 0)。

== 一、安装时就能设中转服务器(用户要求)==

安装向导那一页现在是两个输入框:配对码 + 中转服务器。
装的时候是"第一次设置",最该一次把地址填好,不然装完还得去
双击 exe 找设置窗口,很容易漏。

只填域名就行(自动补 https://),留空 = 只走局域网。
批量静默部署同样支持:
    ykxmz-setup-2.0.0.exe /VERYSILENT /CODE=246810 /RELAY=relay.你的域名

为此新增了一个 --relay-url 参数:
    --relay       连接一次、打印状态、然后退出(人工排查用)
    --relay-url   只写进配置,不连接、不退出(安装流程用)
用错那个的话,安装包的命令链会在中间就 return,后面的 --silent
根本跑不到 —— 所以必须分开。

== 二、手表端新增「设置」页(用户要求)==

用户原话:"安卓端加一个设置,各种设置就在这里面调"。
以前设置散在首页(只有一个"中转设置"按钮),现在统一收进设置页:
    * 中转服务器(填写 / 一键清空)
    * 记住的配对码(显示 / 清除)
    * 已配对的电脑(看数量 / 清空,带二次确认)
    * 关于(版本号从 APK 读,不会和代码对不上)

== 三、中转地址默认留空 + 一次性迁移(用户报的问题)==

用户原话:"安卓端为什么还是默认填的我的中转站,以后改成手动填"。

代码里的默认值早就是空的了,但用户看到的还是那个地址 ——
因为**老版本 1.5.0 把作者的域名硬编码成了默认值**,第一次运行就
存进了 SharedPreferences。装新 APK 不会清偏好,所以那个值一直留着。
用户其实从来没"选"过这个地址,是程序硬塞的。

所以加了 migrateLegacyRelay():存的值如果正好等于当时内置的域名,
就当成没设过、清掉。只清完全相等的,用户手填的不会动。

== 四、两个测试自身的毛病 ==

* test_lan_optimize 那条"没有把旧公网域名当默认值"太粗 ——
  它查的是源码里任何位置,于是迁移注释里引用那行老代码就把它判红了。
  改成先剥掉注释再查(断言要测的是"有没有把它当默认值")。

* test_image_modes 偶发红:它开真实 Tk 全屏窗口,在全量套件里跑
  偶尔起不来(单独跑 19/19 全过)。加了有界重试。
  偶发红的测试会让"全量通过"失去意义。

== 五、新增防复发断言 ==

_tests/test_relay_default_blank.py(15 项,回归阶段 3a3b):
    * 默认值必须是空串
    * 除了迁移清单,源码里不许有写死的中转域名
    * 迁移必须真的被调用(不是只定义)
    * 迁移必须是 remove,不能换成另一个硬编码值
    * Windows 端的默认也得是空

理由:用户说过"这个中转站我就自己用,不对外" —— 值得用断言钉死。

== 六、顺手修的 ==

* release_all.py 崩在 Gitee 那一步:Gitee 的
  GET /releases/tags/{tag} 在"标签存在、但还没发行版"时返回的是
  **HTTP 200 + JSON null**(不是 404),于是 .get("id") 直接
  AttributeError。发 1.9.0 时踩到,已改成容忍 None。

* installer/build_installer.py 里 license-combined.txt 是从
  docs/ 三份文档重新生成的,所以光改生成物没用(下次构建又变回去)。
  改成在 build_license() 里统一注入当前版本。

* ExE 文件属性里的版本(version_info.txt)从 1.7.1 起就一直没跟上。

全量回归 43 个阶段全部通过。
2026-10-07 23:02:44 +08:00

316 lines
13 KiB
Python
Raw 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.
# -*- 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<Found> searchMany(" in core_src)
check("旧的 search(timeout, extra) 还在(向后兼容)",
"public static List<Found> search(int timeoutMs, String extraBroadcast)"
in core_src)
check("Discovery 有 localNets(全部网卡)",
"public static List<LocalNet> localNets()" in disc_src)
check("Discovery 有 directedBroadcasts(全部网段广播)",
"public static List<String> 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)
# 旧公网域名只允许出现在注释/文档里,**不允许**作为默认值的字面量。
#
# ⚠ 必须先把注释剥掉再查 —— 这条断言原来直接查原文,于是
# migrateLegacyRelay() 的注释里引用那行老代码
# (写的是"老版本是这样写的:private static final String
# DEFAULT_RELAY_URL = https://...")就把它判红了。
# 断言要测的是"有没有把它当默认值",不是"源码里能不能出现这串字"。
import re as _re
code_only = _re.sub(r"/\*.*?\*/", "", main_src, flags=_re.S) # 块注释
code_only = _re.sub(r"//[^\n]*", "", code_only) # 行注释
check("没有把旧公网域名当默认值",
not _re.search(r'DEFAULT_RELAY_URL\s*=\s*"https?://', code_only),
"赋值语句里出现了公网地址")
# 单实例锁端口可以被覆盖(测试才能和用户自己的实例并存)
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())