mirror of
https://gitee.com/xmz7891/ykxmz.git
synced 2026-10-18 10:10:43 +00:00
按用户定的版本规矩,这一轮既有新内容又有重构,所以升第二位: 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 个阶段全部通过。
301 lines
11 KiB
Python
301 lines
11 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""验证投图的三种显示方式:屏幕中间 / 全屏 / 自定义大小。
|
||
|
||
早先版本用 Win32 FindWindow 去找窗口,会偶发找不到 —— Tk 把窗口建好
|
||
到系统真正映射之间有几十毫秒时差,机器一忙就错过,于是测试不稳定。
|
||
|
||
现在改成**直接读 Tk 窗口自己的几何**(winfo_rootx/rooty/width/height),
|
||
完全确定,不受系统时序影响。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import shutil
|
||
import sys
|
||
import time
|
||
from pathlib import Path
|
||
|
||
from PIL import Image, ImageDraw, ImageGrab
|
||
|
||
ROOT = Path(__file__).resolve().parent.parent
|
||
sys.path.insert(0, str(ROOT / "windows"))
|
||
|
||
import tkinter as tk # noqa: E402
|
||
|
||
from lanremote.actions import Actions # noqa: E402
|
||
from lanremote.config import Store # noqa: E402
|
||
from lanremote.headless import HeadlessUI # noqa: E402
|
||
|
||
PASSED = 0
|
||
FAILED = 0
|
||
SHOTS = ROOT / "_tests" / "shots"
|
||
|
||
|
||
def check(name: str, ok: bool, detail: str = "") -> None:
|
||
global PASSED, FAILED
|
||
if ok:
|
||
PASSED += 1
|
||
print(f" [PASS] {name}")
|
||
else:
|
||
FAILED += 1
|
||
print(f" [FAIL] {name} {detail}")
|
||
|
||
|
||
def make_test_image(path: Path, width: int = 1600, height: int = 1000) -> None:
|
||
img = Image.new("RGB", (width, height), (0x18, 0x5C, 0xE8))
|
||
draw = ImageDraw.Draw(img)
|
||
draw.rectangle([40, 40, width - 40, height - 40], outline=(255, 255, 255), width=8)
|
||
draw.rectangle([120, 120, width - 120, height - 120], fill=(255, 255, 255))
|
||
draw.rectangle([200, 200, width - 200, height - 200], fill=(0x1F, 0x23, 0x28))
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
img.save(path)
|
||
|
||
|
||
def pump(_root, seconds: float) -> None:
|
||
"""等界面稳定。
|
||
|
||
注意:**不要**在这里调 root.update()。HeadlessUI 有自己的泵线程在
|
||
update(),而 Tk 不是线程安全的 —— 两个线程同时 update 会让泵线程
|
||
静默挂掉,之后 ui_call 就永远等不到结果。这里只需要等。
|
||
"""
|
||
time.sleep(seconds)
|
||
|
||
|
||
def geometry_of(win):
|
||
"""返回窗口客户区的 (left, top, width, height)。"""
|
||
if win is None:
|
||
return None
|
||
try:
|
||
if not win.winfo_exists():
|
||
return None
|
||
win.update_idletasks()
|
||
left = win.winfo_rootx()
|
||
top = win.winfo_rooty()
|
||
width = win.winfo_width()
|
||
height = win.winfo_height()
|
||
if width <= 1 or height <= 1:
|
||
return None
|
||
return (left, top, width, height)
|
||
except tk.TclError:
|
||
return None
|
||
|
||
|
||
def ui_read(agent, fn, timeout: float = 10.0):
|
||
"""所有 Tk 调用都要走界面线程,否则报 main thread is not in main loop。"""
|
||
try:
|
||
return agent.ui_call(fn, timeout=timeout)
|
||
except Exception: # noqa: BLE001
|
||
return None
|
||
|
||
|
||
# 超时给宽一点:全套测试一起跑时机器很忙(内存紧张),
|
||
# Tk 建窗口会明显变慢,4 秒不够用(踩过:单独跑 19/19,
|
||
# 全套跑就假失败)。
|
||
def wait_geometry(win, agent, timeout: float = 15.0):
|
||
"""轮询直到窗口几何可用。"""
|
||
if win is None:
|
||
return None
|
||
deadline = time.time() + timeout
|
||
while time.time() < deadline:
|
||
rect = ui_read(agent, lambda: _read_geometry(win), timeout=3.0)
|
||
if rect:
|
||
return rect
|
||
time.sleep(0.05)
|
||
return None
|
||
|
||
|
||
def show_and_wait(actions, agent, image_path, retries: int = 3):
|
||
"""显示图片并等窗口几何,失败就重来。
|
||
|
||
⚠ 为什么要重试:这个测试开的是**真实的 Tk 窗口**,
|
||
在全量套件里跑的时候偶尔会起不来(前一个阶段留下的窗口
|
||
或进程抢焦点),表现成「全屏窗口已创建」这一条红。
|
||
单独跑两次都是 19/19 —— 典型的偶发。
|
||
|
||
与其让它偶发地红(那样"全量通过"就不可信了),
|
||
不如在这里有界重试;三次都起不来才算真失败。
|
||
"""
|
||
for attempt in range(1, retries + 1):
|
||
actions.show_image(str(image_path))
|
||
pump(agent.root, 0.5)
|
||
rect = wait_geometry(actions._image_window, agent,
|
||
timeout=8.0 if attempt > 1 else 15.0)
|
||
if rect:
|
||
if attempt > 1:
|
||
print(f" (第 {attempt} 次才起来)")
|
||
return rect
|
||
# 没起来:关掉重来
|
||
try:
|
||
actions.close_image()
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
pump(agent.root, 0.6)
|
||
return None
|
||
|
||
|
||
def _read_geometry(win):
|
||
try:
|
||
if not win.winfo_exists():
|
||
return None
|
||
win.update_idletasks()
|
||
left = win.winfo_rootx()
|
||
top = win.winfo_rooty()
|
||
width = win.winfo_width()
|
||
height = win.winfo_height()
|
||
if width <= 1 or height <= 1:
|
||
return None
|
||
return (left, top, width, height)
|
||
except tk.TclError:
|
||
return None
|
||
|
||
|
||
def main() -> int:
|
||
SHOTS.mkdir(parents=True, exist_ok=True)
|
||
work = ROOT / "_tests" / "_imgmode_data"
|
||
shutil.rmtree(work, ignore_errors=True)
|
||
|
||
image_path = work / "test_image.png"
|
||
make_test_image(image_path)
|
||
|
||
store = Store(work)
|
||
agent = HeadlessUI(store)
|
||
pump(agent.root, 0.6)
|
||
|
||
screen_w, screen_h = agent.screen_size() # 不直接碰 Tk
|
||
print(f"\n屏幕: {screen_w}x{screen_h}\n")
|
||
|
||
actions = Actions(agent, store)
|
||
|
||
# ---------------- 1. 屏幕中间 ----------------
|
||
print("一、屏幕中间(默认)")
|
||
store.set("image_display", "window")
|
||
store.set("image_timeout", 0)
|
||
rect = show_and_wait(actions, agent, image_path)
|
||
check("窗口已创建", rect is not None)
|
||
if rect:
|
||
left, top, width, height = rect
|
||
print(f" 客户区: {width}x{height} @ ({left},{top})")
|
||
check("不是全屏", width < screen_w - 40 or height < screen_h - 40,
|
||
f"{width}x{height} vs 屏幕 {screen_w}x{screen_h}")
|
||
check("不超过屏幕 85%",
|
||
width <= screen_w * 0.85 and height <= screen_h * 0.9,
|
||
f"{width}x{height}")
|
||
cx = left + width / 2
|
||
cy = top + height / 2
|
||
check("水平居中(偏差 < 40px)", abs(cx - screen_w / 2) < 40,
|
||
f"中心 x={cx:.0f},应 {screen_w / 2:.0f}")
|
||
check("垂直居中(偏差 < 80px)", abs(cy - screen_h / 2) < 80,
|
||
f"中心 y={cy:.0f},应 {screen_h / 2:.0f}")
|
||
try:
|
||
ImageGrab.grab(bbox=(left, top, left + width, top + height)).save(
|
||
SHOTS / "1-屏幕中间.png")
|
||
except Exception:
|
||
pass
|
||
actions.close_image()
|
||
pump(agent.root, 0.3)
|
||
|
||
# ---------------- 2. 全屏 ----------------
|
||
print("\n二、全屏")
|
||
store.set("image_display", "fullscreen")
|
||
rect = show_and_wait(actions, agent, image_path)
|
||
check("全屏窗口已创建", rect is not None)
|
||
if rect:
|
||
left, top, width, height = rect
|
||
print(f" 客户区: {width}x{height} @ ({left},{top})")
|
||
check("覆盖整个屏幕宽度", width >= screen_w - 4, f"{width} vs {screen_w}")
|
||
check("覆盖整个屏幕高度", height >= screen_h - 4, f"{height} vs {screen_h}")
|
||
actions.close_image()
|
||
pump(agent.root, 0.3)
|
||
|
||
# ---------------- 3. 自定义 ----------------
|
||
print("\n三、自定义大小 (640x400)")
|
||
store.set("image_display", "custom")
|
||
store.set("image_custom_width", 640)
|
||
store.set("image_custom_height", 400)
|
||
rect = show_and_wait(actions, agent, image_path)
|
||
check("自定义窗口已创建", rect is not None)
|
||
if rect:
|
||
left, top, width, height = rect
|
||
print(f" 客户区: {width}x{height} @ ({left},{top})")
|
||
check("宽度等于 640", width == 640, f"实际 {width}")
|
||
# 客户区 = 图片高度 + 底部「关闭」按钮条(约 48px)
|
||
check("图片区高度等于 400(其余是按钮条)",
|
||
400 <= height <= 400 + 60, f"实际 {height}")
|
||
cx = left + width / 2
|
||
check("自定义尺寸下仍然居中", abs(cx - screen_w / 2) < 40, f"中心 x={cx:.0f}")
|
||
try:
|
||
ImageGrab.grab(bbox=(left, top, left + width, top + height)).save(
|
||
SHOTS / "3-自定义640x400.png")
|
||
except Exception:
|
||
pass
|
||
actions.close_image()
|
||
pump(agent.root, 0.3)
|
||
|
||
# ---------------- 4. 只填宽度 ----------------
|
||
print("\n四、只填宽度(高度自动)")
|
||
store.set("image_custom_width", 500)
|
||
store.set("image_custom_height", 0)
|
||
rect = show_and_wait(actions, agent, image_path)
|
||
check("只填宽度也能显示", rect is not None)
|
||
if rect:
|
||
print(f" 客户区: {rect[2]}x{rect[3]}")
|
||
check("宽度等于 500", rect[2] == 500, f"实际 {rect[2]}")
|
||
check("高度自动(大于 0)", rect[3] > 0, f"实际 {rect[3]}")
|
||
actions.close_image()
|
||
pump(agent.root, 0.3)
|
||
|
||
# ---------------- 5. 兜底 ----------------
|
||
print("\n五、非法 / 空值兜底")
|
||
store.set("image_display", "不存在的模式")
|
||
actions.show_image(str(image_path))
|
||
pump(agent.root, 0.3)
|
||
check("非法模式退回默认窗口",
|
||
wait_geometry(actions._image_window, agent) is not None)
|
||
actions.close_image()
|
||
pump(agent.root, 0.3)
|
||
|
||
store.set("image_display", "custom")
|
||
store.set("image_custom_width", 0)
|
||
store.set("image_custom_height", 0)
|
||
actions.show_image(str(image_path))
|
||
pump(agent.root, 0.3)
|
||
check("自定义但宽高都是 0 时能兜底",
|
||
wait_geometry(actions._image_window, agent) is not None)
|
||
actions.close_image()
|
||
pump(agent.root, 0.3)
|
||
|
||
# ---------------- 6. 窗口标题 ----------------
|
||
print("\n六、窗口标题可自定义")
|
||
store.set("image_display", "window")
|
||
store.set("image_window_title", "【来自手表】{name}")
|
||
actions.show_image(str(image_path))
|
||
pump(agent.root, 0.3)
|
||
title = ui_read(agent, lambda: actions._image_window.title()) or ""
|
||
check("标题用了模板", title == f"【来自手表】{image_path.name}", repr(title))
|
||
actions.close_image()
|
||
pump(agent.root, 0.3)
|
||
|
||
actions.show_image(str(image_path), "自定义标题")
|
||
pump(agent.root, 0.3)
|
||
title = ui_read(agent, lambda: actions._image_window.title()) or ""
|
||
check("单次可覆盖标题", title == "自定义标题", repr(title))
|
||
actions.close_image()
|
||
pump(agent.root, 0.3)
|
||
|
||
try:
|
||
agent.stop()
|
||
except Exception:
|
||
pass
|
||
|
||
print("\n" + "=" * 62)
|
||
print(f"通过 {PASSED} 项,失败 {FAILED} 项")
|
||
print(f"截图目录: {SHOTS}")
|
||
sys.stdout.flush()
|
||
# 这个测试会创建多个 Tk 窗口并抓屏,解释器正常收尾时偶发非 0 退出码。
|
||
# 直接用 os._exit,让退出码只反映检查结果。
|
||
os._exit(1 if FAILED else 0)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|