Files
ykxmz/_tests/test_image_modes.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

301 lines
11 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 -*-
"""验证投图的三种显示方式:屏幕中间 / 全屏 / 自定义大小。
早先版本用 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()