# -*- coding: utf-8 -*- """离屏验证节点菜单:IPv6-only 节点的标注与置灰(真实 PyQt5,不出窗口)。 需要 Windows venv 的真实 PyQt5: ..\\.venv\\Scripts\\python.exe test\\check_node_menu.py 读取 test/nodes.json 作为节点数据,只构建菜单不进事件循环。 """ import os, sys os.environ["QT_QPA_PLATFORM"] = "offscreen" from pathlib import Path LITE = Path(__file__).resolve().parent.parent # 脚本所在目录(test/)会被解释器自动放到 sys.path 首位,那里有 PyQt5 桩, # 会顶掉真实 PyQt5 —— 必须移除,改用本程序自己的 venv sys.path = [str(LITE)] + [p for p in sys.path if Path(p).resolve() != Path(__file__).resolve().parent] sys.stdout.reconfigure(encoding="utf-8") import main as pp from PyQt5.QtWidgets import QApplication # 构造窗口时不触发用户数据初始化或远程同步;本测试只验证静态菜单和本地节点状态。 pp._user_init_local = lambda: None pp._user_init_remote = lambda *args, **kwargs: None pp._nodes_sync_remote = lambda *args, **kwargs: pp._NODES_SYNC_DONE.set() pp._NODES_SYNC_DONE.set() app = QApplication(sys.argv) w = pp.MainWindow() w.timer.stop() # 停掉状态轮询,纯静态检查 failures = [] pp._USER_DIR = LITE / "test" # 真实 nodes.json 所在目录 nodes = pp._reload_nodes() print(f"本机有全局 IPv6: {pp._local_has_ipv6()}") print(f"节点数: {len(nodes)}") menu = w._build_node_menu(nodes) print("\n菜单项(enabled / 文案 / tooltip):") for act in menu.actions(): print(f" {str(act.isEnabled()):5} | {act.text()}") if act.toolTip(): print(f" └ {act.toolTip()}") # 点击被禁用的项不应切换节点 before = pp._selected_index() for act in menu.actions(): if not act.isEnabled(): act.trigger() print(f"\n点击禁用项后节点下标: {before} -> {pp._selected_index()} (应不变)") if pp._selected_index() != before: failures.append("点击不可用节点后发生了切换") # ── 上次选中的节点已不可用:启动时自动回退到第一个可达节点 ────────── print("\n启动校验(_verify_selected_node):") bad = [i for i, n in enumerate(nodes) if not pp._node_reachable(n)] if not bad: print(" 本机所有节点均可达,无法验证回退(本机有 IPv6?)") else: import json _bak = (pp._USER_DIR / "settings.json").read_bytes() if (pp._USER_DIR / "settings.json").is_file() else None bad_idx = bad[0] # 模拟上次持久化选中了不可用节点:内存下标与 current_node 必须一致, # 否则 _verify_selected_node 会先按真实保存的域名重新匹配,测试并非回退分支。 pp._settings_save({**pp._settings_load(), "current_node": nodes[bad_idx].get("domain") or ""}) pp._SELECTED_NODE = bad_idx switched = pp._verify_selected_node() expect = pp._first_reachable_index(nodes) ok = switched and pp._selected_index() == expect print(f" 上次选中 {nodes[bad_idx].get('domain')}(下标 {bad_idx}) 不可用 -> " f"回退到下标 {pp._selected_index()} ({nodes[pp._selected_index()].get('domain')}) " f"{'PASS' if ok else 'FAIL'}") if not ok: failures.append("不可用节点未回退到首个可达节点") saved = json.loads((pp._USER_DIR / "settings.json").read_text("utf-8")).get("current_node") if (pp._USER_DIR / "settings.json").is_file() else "" ok2 = saved == str(nodes[expect].get("domain") or "") print(f" 回退结果已写入设置 current_node={saved!r} {'PASS' if ok2 else 'FAIL'}") if not ok2: failures.append("节点回退未写回 current_node") # 再校验一次:已经是可达节点,不应再切换 ok3 = pp._verify_selected_node() is False print(f" 再次校验不重复切换 {'PASS' if ok3 else 'FAIL'}") if not ok3: failures.append("已回退节点被重复切换") if _bak is None: (pp._USER_DIR / "settings.json").unlink(missing_ok=True) else: (pp._USER_DIR / "settings.json").write_bytes(_bak) # ── 服务端调整节点顺序:按域名重匹配而不是死守下标 ────────────────── print("\n服务端调序(_rematch_saved_domain):") if len(nodes) < 2: print(" 节点不足 2 个,跳过") else: import json dom_old = nodes[0].get("domain") ordered = [nodes[1], nodes[0]] + nodes[2:] # 模拟服务端交换了前两个节点 pp._NODES = ordered # 设置里记录的是原下标 0 的域名,重匹配后下标应跟随域名(新列表里是 1) pp._settings_save({**pp._settings_load(), "current_node": dom_old}) switched = pp._rematch_saved_domain() ok = switched and ordered[pp._selected_index()].get("domain") == dom_old print(f" current_node={dom_old} 调序后跟随域名 -> 下标 {pp._selected_index()} " f"{'PASS' if ok else 'FAIL'}") if not ok: failures.append("节点调序后没有按域名重新匹配") # 已是正确域名时再跑一次:不应重复切换 ok2 = pp._rematch_saved_domain() is False print(f" 再次重匹配不重复切换 {'PASS' if ok2 else 'FAIL'}") if not ok2: failures.append("节点调序重匹配重复切换") # 域名已不在列表(节点被移除):不切换,保持现状 pp._settings_save({**pp._settings_load(), "current_node": "gone.example.com"}) ok3 = pp._rematch_saved_domain() is False print(f" 节点被移除时不动 {'PASS' if ok3 else 'FAIL'}") if not ok3: failures.append("已移除节点仍发生切换") pp._NODES = None # 还原缓存,后续用例重读文件 # 还原 settings.json,避免测试污染 if _bak is None: (pp._USER_DIR / "settings.json").unlink(missing_ok=True) else: (pp._USER_DIR / "settings.json").write_bytes(_bak) if failures: raise SystemExit("FAIL: " + "; ".join(failures)) print("OK")