Files
ykxmz/_tests/test_admin_update.py
T
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

169 lines
6.3 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 -*-
r'''验「后台点一下让电脑自己更新」这条链路。
== 链路 ==
管理页点「更新」
-> POST /v1/admin/update_device {deviceId}
-> Relay.request_update() 把 {"cmd":"update","action":"start"}
塞进该设备的 to_agent 队列
-> 被控端下一次长轮询拿到它
-> server.py 的 _do_update:查最新版 -> 下载 -> 校验 -> 静默装 -> 重启
== 为什么要专门测 ==
这条链路横跨三端(管理页 JS -> 中转 -> 被控端),任何一环写错,
表现都是"我点了更新,电脑没反应" —— 而用户在办公室里没法排查。
而且它**会重启别人的电脑**,所以几个边界必须卡死:
* 设备不存在 -> 明确说没有,不能静默成功
* 设备离线 -> 直接拒。命令挂在队列里等它上线才执行,
那时用户早忘了,突然重启很莫名其妙
* 重复点 -> 不重复塞(队列会堆积)
* 在线 -> 真的塞进队列,而且内容就是那条更新命令
== 上一版测试自己写错的地方 ==
我用了个 FakeDevice,`__init__` 里默认 `_on = True`,
结果"离线设备"那条用例测的其实是**在线**设备 —— 假绿。
现在 FakeDevice 显式接收 online 参数,不再有隐藏默认值。
'''
from __future__ import annotations
import json
import re
import sys
import threading
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "relay"))
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)
class FakeDevice:
"""假的被控端。online 必须显式给,不给就报错 ——
第一版给了个 True 的默认值,导致"离线"用例永远测不到离线。"""
def __init__(self, online: bool):
self.to_agent = bytearray()
self._online = bool(online)
self.lan_ips: list = []
self.port = 0
def online(self) -> bool:
return self._online
def push_to_agent(self, data: bytes) -> None:
self.to_agent.extend(data)
def main() -> int:
print("=" * 58)
print(" 后台「更新」按钮链路")
print("=" * 58)
print()
import relay as R
print("一、Relay.request_update 的边界")
r = R.Relay.__new__(R.Relay)
r.lock = threading.RLock()
r.devices = {}
res = r.request_update("不存在的设备")
check("设备不存在 -> 拒绝", res.get("ok") is False,
"拿到 %r" % res)
check(" 错误信息说清楚是没这台", "没有这台设备" in str(res.get("error")),
"拿到 %r" % res.get("error"))
check("设备 id 为空也当不存在",
r.request_update("").get("ok") is False)
check(" None 也不炸", r.request_update(None).get("ok") is False)
off = FakeDevice(online=False)
r.devices["off1"] = off
res = r.request_update("off1")
check("离线设备 -> 拒绝", res.get("ok") is False, "拿到 %r" % res)
check(" 理由提到离线", "离线" in str(res.get("error")),
"拿到 %r" % res.get("error"))
check(" 离线时**没往队列里塞东西**", len(off.to_agent) == 0,
"队列里有 %d 字节 —— 离线时不该堆命令"
% len(off.to_agent))
on = FakeDevice(online=True)
r.devices["on1"] = on
res = r.request_update("on1")
check("在线设备 -> 成功", res.get("ok") is True, "拿到 %r" % res)
check(" 队列里真的放了东西", len(on.to_agent) > 0)
raw = bytes(on.to_agent).decode("utf-8")
check(" 是换行结尾的 JSON(协议要求)", raw.endswith("\n"),
"尾部是 %r" % raw[-6:])
try:
msg = json.loads(raw.strip())
except Exception as exc: # noqa: BLE001
check(" 内容是合法 JSON", False, str(exc))
msg = {}
check(" 内容是合法 JSON", bool(msg))
check(" cmd == update", msg.get("cmd") == "update", "拿到 %r" % msg)
check(" action == start", msg.get("action") == "start",
"拿到 %r" % msg)
n_before = len(on.to_agent)
res = r.request_update("on1")
check("重复点 -> 不重复塞", len(on.to_agent) == n_before,
"队列从 %d 涨到 %d" % (n_before, len(on.to_agent)))
check(" 但仍然返回 ok(用户看到的是「已经在排队」)",
res.get("ok") is True and "排队" in str(res.get("message")),
"拿到 %r" % res)
print()
print("二、HTTP 接口接到了 Relay 方法上")
src = (ROOT / "relay" / "relay.py").read_text(encoding="utf-8")
check("有 /v1/admin/update_device 路由",
'"/v1/admin/update_device"' in src)
m = re.search(r'if path == "/v1/admin/update_device":(.{0,300})',
src, re.S)
body = m.group(1) if m else ""
check(" 它调的是 RELAY.request_update", "RELAY.request_update(" in body,
"应该走 Relay 的方法,而不是在 HTTP 处理里伸手掏 RELAY.devices")
check(" 它把 deviceId 传下去了", 'get("deviceId")' in body)
check("有 Relay.request_update 方法", "def request_update(self" in src)
print()
print("三、管理页那边")
adm = (ROOT / "relay" / "admin.py").read_text(encoding="utf-8")
check("设备行有「更新」按钮", 'data-update="%s"' in adm)
check("JS 里有 data-update 的处理",
"querySelectorAll('button[data-update]')" in adm)
check("JS 会二次确认(别误点就重启别人电脑)",
"confirm(" in adm and "更新到最新版" in adm)
check("离线设备的按钮带 disabled",
'disabled title="设备离线' in adm)
check("设备行显示了版本", 'data-l="版本"' in adm)
check("表头也有版本列", "<th>版本</th>" in adm)
# 空表提示的 colspan 要跟列数对上(加了版本列之后是 9)
check("空表 colspan 是 9(跟列数对上)", 'colspan="9"' in adm,
"加列之后忘了改 colspan,空表时表格会错位")
print()
print("=" * 58)
if FAILS:
print(" 失败 %d 项:%s" % (len(FAILS), ", ".join(FAILS)))
return 1
print(" 全部通过")
return 0
if __name__ == "__main__":
sys.exit(main())