Files
ykxmz/_tests/test_notice_options.py
xmz7891 f0c05079f7 2.1.7:手表快捷栏 + 鼠标触摸板 + 弹窗可配 + 后台远程更新
用户这次提了一整套升级,这是最后一批。

== 一、手表端顶部快捷栏 ==

用户原话:"手表端不管干什么,上面都有一栏,栏里的按钮可以在设置页
自定义,最多可以加 4 个,其中有一个按钮不能改,功能就是一键返回桌面"。

("返回桌面"经确认 = **让被控电脑显示桌面**,即 Win+D。)

  * 一条常驻的栏,贴在屏幕顶部,切页面时不动
  * **4 个格子**:3 个可自定义 + **1 个固定的「显示桌面」(改不了)**
  * 7 个可选动作:传文件 / 屏幕镜像 / 鼠标控制 / 音效 / 关机 /
    语音播报 / 投图到电脑
  * 设置页里点一下换;也能把某一格留空
  * 没连上电脑时按钮置灰(不是藏起来 —— "一直在但暂时不能用"
    比"时有时无"更好理解)

实现上的几个要点:

  * 快捷栏 addView 在 5 个面板**之后** —— FrameLayout 里后加的在上层,
    加在前面会被面板(MATCH_PARENT)整个盖住
  * 面板加了 topMargin 给栏让位,集中在 applyPanelTopMargin 里改
    (每个面板各写一遍很容易漏一个,表现是"就某一页顶部被切掉")
  * 第 4 格在代码里**写死** desktop,而且 QUICK_ACTIONS 里**没有**它 ——
    这样它既不会出现在可选列表里,也不可能被改成别的
  * 存储用 SharedPreferences。读取时**过滤不认识的 id + 补齐到 3 个**:
    以后删了某个动作,老配置也不会让栏变空或下标越界

== 二、远程鼠标控制(手表上的触摸板)==

  * 拖动 = 移动光标,单击 = 左键,双击 = 右键
  * 下面还有专门的 左键 / 右键 / 滚轮上 / 滚轮下 按钮
  * 电脑端用 SendInput 注入(见下)

== 三、弹文字窗口的位置和大小可配 ==

用户原话:"弹文字窗口 …… 位置和大小都能自定义"。

  * 位置:右下(默认,保持老习惯)/ 右上 / 左上 / 左下 /
    屏幕正中间 / 自定义坐标
  * 字号:8~32(夹住,免得设成 200 一个通知占满屏幕)
  * 宽度:可指定,0 = 按内容自适应
  * 三处窗口定位统一到一个 _notice_geometry() —— 以前三处各写死一遍
    右下角,改一次要改三个地方,漏一个用户就看得见

== 四、屏幕被观看时的提示条改成默认不显示 ==

用户原话:"以后不需要弹,啥窗户都没有,就可以像 UU 远程一样"。

  * 默认**连窗口都不建**
  * **但保留成开关**,设置里可以打开

  关于这条:那条提示原来是**故意加**的(有人在看屏幕时,电脑前面的人
  应该知道)。用户要求去掉,这是他自己的电脑、自己的教室,
  属于正当的远程管理需求。所以能力保留、默认关闭 ——
  这软件应该始终有一个"让本机用户看得见正在被控制"的选项可查。

== 五、中转后台能看到版本并远程点更新 ==

  * 设备表新增「版本」列
  * 每行一个「更新」按钮:点一下让那台电脑自己静默升级
  * 离线设备的按钮**置灰并说明原因**(命令只会挂在队列里等它上线,
    那时用户早忘了自己点过,突然重启很莫名其妙)
  * 和手表那边走**同一条命令**(cmd=update, action=start),
    不给"后台触发"单独写一套逻辑 —— 少一套就少一处不一致

== 六、手表端可开关「允许新设备连接」 ==

  * 关掉后只有配对过的电脑能连,教室里防别人乱连
  * 电脑端新增 _do_allow_new_pairing 这个 feature
  * 只影响**配对新设备**,不踢现有会话
    (否则老师一点"禁止",自己正连着的那台也被踢掉,成了"自杀开关")

== 修掉的几个自己造的坑 ==

1. **管理页 JS 裸换行** —— 最严重的一个。
   ADMIN_JS 是 Python 三引号字符串,我在里面写 `\n` 拼多行文案,
   结果是**真的换行**插进 JS,字符串字面量跨行 -> 语法非法 ->
   **整个 script 块解析失败 -> 管理页所有按钮全死**(改码/删除/踢下线/全选)。
   改成 String.fromCharCode(10),不依赖任何转义层。

2. **路由落进了 do_GET** —— 我拿 "没有这个管理接口" 当锚点,
   但那个字符串在文件里有**两处**(do_GET 和 _route_post 各一个),
   于是路由加到了 GET 块里,管理页的 POST 打不到它。
   表现是"点了更新没反应"。

3. **isConnected() 根本不存在** —— 写快捷栏时凭直觉调了个方法名,
   编译才发现。顺手包了一层并加上说明。

4. **两个不存在的方法名** —— onCreate 里写了
   buildMousePanelIfNeeded / mousePanelHolder,编译报 cannot find symbol。

5. **未调用方法扫描的假阳性** —— GestureDetector 的
   onSingleTapConfirmed / onDoubleTap / onScroll 是框架回调,
   扫描把"名字只出现 1 次"当成死代码。改成**跳过 @Override 的方法**。
   这种假阳性最坏的结果是有人为了让它变绿把回调删了,手势就失灵了。

6. **测试断言太严导致误报** —— "不能有写死的 240",
   但 helper 里 `auto = max(240, ...)` 是自适应的兜底,故意的。

7. **屏幕共享提示那个测试断言的是旧行为** —— 没有简单删掉,
   改成验两个方向:开关打开时提示条正常(位置/尺寸/出现/消失),
   关闭时默认值必须是 False 且代码路径上不建窗口。

== 验证 ==

    全量回归(19 关)全部 PASS,零失败
    新增关卡:3a10 静默更新 / 3a11 鼠标控制 / 3a12 后台更新+弹窗 /
              3a13 手表快捷栏
    Java 编译通过(android-34, --release 8)
    APK / 安装包 / 服务端包 全部重建,Win7 兼容校验通过
    623 个 Python 文件语法通过
2026-10-09 22:55:28 +08:00

179 lines
7.1 KiB
Python
Raw Permalink 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 -*-
r'''验「弹窗位置/大小可配置」和「屏幕提示条默认关」。
== 用户的要求 ==
1. 弹文字窗口:位置和大小都能自定义(原来写死右下角)
2. 屏幕共享提示条:以后不要弹,像 UU 远程一样啥窗口都没有
== 验什么 ==
一、设置项都在,而且默认值符合要求
(位置默认右下角 = 不改变老用户习惯;
但 show_screen_notice 默认必须是 False)
二、_notice_geometry 对 6 种位置算出来的坐标对不对
三、三处写死的 geometry 都改成了调 helper(不能再有硬编码的右下角)
四、字号和宽度是可配置的
五、屏幕提示条真的会读那个开关
== 为什么第三层重要 ==
这次改了 3 处 geometry、5 处字号、2 处宽度。
如果漏掉一处,表现就是"有一个窗口还在右下角" ——
而用户只会看到"你改了没用"。所以必须断言**没有残留的硬编码**。
'''
from __future__ import annotations
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "windows"))
FAILS: list = []
def check(name: str, ok: bool, why: str = "") -> None:
print(" [%s] %s%s" % ("PASS" if ok else "FAIL", name,
(" —— " + why) if why else ""))
if not ok:
FAILS.append(name)
HEADLESS = ROOT / "windows" / "lanremote" / "headless.py"
CONFIG = ROOT / "windows" / "lanremote" / "config.py"
def main() -> int:
print("=" * 58)
print(" 弹窗位置/大小可配置 + 屏幕提示条默认关")
print("=" * 58)
print()
src = HEADLESS.read_text(encoding="utf-8")
cfg_src = CONFIG.read_text(encoding="utf-8")
# ---------------- 一、设置项 ----------------
print("一、设置项")
from lanremote.config import DEFAULT_SETTINGS as D
for key in ("notice_position", "notice_x", "notice_y",
"notice_width", "notice_font_size", "show_screen_notice"):
check(" %s 存在" % key, key in D)
check("位置默认右下角(不改变老用户习惯)",
D.get("notice_position") == "bottom-right",
"拿到 %r" % D.get("notice_position"))
check("**屏幕提示条默认关**(用户明确要求)",
D.get("show_screen_notice") is False,
"拿到 %r —— 这个默认必须是 False" % D.get("show_screen_notice"))
check("字号默认 11(原来的值)", D.get("notice_font_size") == 11)
check("宽度默认 0(自适应)", D.get("notice_width") == 0)
# ---------------- 二、_notice_geometry 算得对不对 ----------------
print()
print("二、_notice_geometry 的坐标计算")
# 直接复刻逻辑来验(不启动 Tk)—— 和源码里的算法必须一致
def geom(pos, w, h, screen=(1920, 1080), cx=0, cy=0):
screen_w, screen_h = screen
pad, pad_bottom = 24, 72
if pos == "custom":
x = max(0, min(max(0, screen_w - w), cx))
y = max(0, min(max(0, screen_h - h), cy))
return (x, y)
if pos == "center":
return (max(0, (screen_w - w) // 2), max(0, (screen_h - h) // 2))
left = pos in ("top-left", "bottom-left")
top = pos in ("top-left", "top-right")
x = pad if left else screen_w - w - pad
y = pad if top else screen_h - h - pad_bottom
return (max(0, x), max(0, y))
cases = [
("bottom-right", (1920 - 300 - 24, 1080 - 100 - 72), "右下角"),
("bottom-left", (24, 1080 - 100 - 72), "左下角"),
("top-right", (1920 - 300 - 24, 24), "右上角"),
("top-left", (24, 24), "左上角"),
("center", ((1920 - 300) // 2, (1080 - 100) // 2), "屏幕正中间"),
]
for pos, want, label in cases:
got = geom(pos, 300, 100)
check(" %s(%s)" % (pos, label), got == want,
"期望 %s,拿到 %s" % (want, got))
# custom:正常值
check(" custom 用指定坐标", geom("custom", 300, 100, cx=500, cy=400)
== (500, 400))
# custom:超范围要夹回来
check(" custom 超右边界被夹住",
geom("custom", 300, 100, cx=99999, cy=0)
== (1920 - 300, 0),
"拿到 %s" % (geom("custom", 300, 100, cx=99999, cy=0),))
check(" custom 负坐标被夹到 0",
geom("custom", 300, 100, cx=-50, cy=-50) == (0, 0))
# ---------------- 三、没有残留的硬编码定位 ----------------
print()
print("三、源码里不能有残留的硬编码(漏一处用户就看得见)")
check("没有写死的右下角 geometry",
"screen_h - height - 72" not in src,
"还有窗口固定放右下角")
check("没有写死的字号 11",
'"Microsoft YaHei UI", 11, "bold"' not in src,
"还有窗口用写死的字号")
# ⚠ 这里要排除 helper 自己那处 —— _notice_width 里
# uto = max(240, win.winfo_reqwidth()) 是**自适应的兜底值**,
# 属于故意保留的。第一版断言太粗,把这处也算成写死了。
hard = [ln for ln in src.splitlines()
if "max(240, win.winfo_reqwidth())" in ln
and "auto =" not in ln]
check("没有别处写死的宽度 240", not hard,
"还有窗口用写死的宽度:%s" % hard)
n_geom = src.count("self._notice_geometry(width, height)")
check("三处定位都改调 helper(实际 %d 处)" % n_geom, n_geom == 3,
"应该是 3 处(通知×2 + 屏幕提示×1)")
check("有 _notice_geometry 方法", "def _notice_geometry(self" in src)
check("有 _notice_font 方法", "def _notice_font(self" in src)
check("有 _notice_width 方法", "def _notice_width(self" in src)
# ---------------- 四、屏幕提示条读开关 ----------------
print()
print("四、屏幕提示条")
m = re.search(r"def set_screen_sharing\(self.*?(?=\n def )", src, re.S)
body = m.group(0) if m else ""
check("找到了 set_screen_sharing", bool(body))
check("它读了 show_screen_notice",
'store.get("show_screen_notice")' in body,
"没读开关的话提示条还是会强制弹出来")
# 读开关的判断必须在 _show_notice 之前
i_check = body.find('store.get("show_screen_notice")')
i_show = body.find("_show_notice(")
check("判断在弹窗之前(关着就不会弹)",
0 <= i_check < i_show,
"顺序反了的话会先弹再判断")
# ---------------- 五、设置界面里能不能改 ----------------
print()
print("五、设置界面")
ui_src = (ROOT / "windows" / "lanremote" / "ui.py").read_text(
encoding="utf-8")
has_any = any(k in ui_src for k in
("notice_position", "notice_width", "notice_font_size",
"show_screen_notice"))
check("设置界面里有这些项", has_any,
"配置项加了但界面没入口的话,用户根本改不了")
print()
print("=" * 58)
if FAILS:
print(" 失败 %d 项:%s" % (len(FAILS), ", ".join(FAILS)))
return 1
print(" 全部通过")
return 0
if __name__ == "__main__":
sys.exit(main())