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

200 lines
8.5 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 -*-
'''防回归:抓"定义了但从没被调用"的方法。
为什么值得单独一个测试:
1.7.0 交付后用户装上发现**一片白屏**。根因是
private void initTabs() { ... } // 定义在第 937 行
而 onCreate 里只调了 buildControlPanel(),**从头到尾没人调 initTabs()**。
结果 tabDefs 为 null -> selectTab() 直接 return -> 内容区空白。
这种错误编译能过、别的测试也能过,只有真机跑起来才看得见,
所以必须静态查出来。
判定方法:
统计方法名在文件里出现的次数(定义那一处也算)。
只出现 1 次 = 只有定义没调用。
分两档:
* **硬性失败**:关键路径上的方法(initTabs / 三个面板 / selectTab)。
这些漏了就是白屏或功能全无,必须拦住。
* **仅报告**:其它可疑方法。有些确实是历史遗留的死代码
(下面的 KNOWN_DEAD 记录了已确认的),有些是接口回调
(由框架调用,本文件里当然只有定义)。
报告出来供清理,但不拦构建 —— 免得噪音太大没人看。
'''
from __future__ import annotations
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
MAIN = (ROOT / "android" / "src" / "com" / "lanremote" / "watch"
/ "MainActivity.java")
# 框架/接口回调:由 Android 或 RemoteClient 调用,本文件里只有定义是正常的
WHITELIST = {
"onCreate", "onDestroy", "onResume", "onPause", "onStart", "onStop",
"onRestart", "onNewIntent", "onSaveInstanceState", "onRestoreInstanceState",
"onBackPressed", "onKeyDown", "onKeyUp", "onWindowFocusChanged",
"onRequestPermissionsResult", "onActivityResult", "onOptionsItemSelected",
"onCreateOptionsMenu", "onPrepareOptionsMenu", "onConfigurationChanged",
"onTouchEvent", "onClick", "onLongClick", "onTouch", "onAnimationUpdate",
"onAnimationStart", "onAnimationEnd", "onAnimationCancel", "onAnimationRepeat",
"onPreDraw", "onGlobalLayout", "onScrollChanged", "onItemClick",
"onItemSelected", "onNothingSelected", "onFocusChange", "onEditorAction",
"run", "call", "get", "set", "equals", "hashCode", "toString",
"uncaughtException", "afterTextChanged", "beforeTextChanged",
"onTextChanged", "onProgressChanged", "onStartTrackingTouch",
"onStopTrackingTouch", "onDateSet", "onTimeSet", "onDismiss",
"onCancel", "onShow", "onKey", "onCreateDialog", "onPrepareDialog",
"onReceive", "onServiceConnected", "onServiceDisconnected",
"onBind", "onCreateView", "onViewCreated", "onDestroyView",
"onAttach", "onDetach", "onActivityCreated", "onOptionsMenuClosed",
"onContextItemSelected", "onCreateContextMenu", "handleMessage",
"onChildViewAdded", "onChildViewRemoved", "onGlobalFocusChanged",
"onLayoutChange", "onScrollStateChanged", "onScrolled",
"onPageStarted", "onPageFinished", "shouldOverrideUrlLoading",
"onReceivedError", "onDownloadStart", "onChildDraw",
# RemoteClient.Listener 的实现
"onDisconnected", "onEvent", "onFrame", "onProgress", "onLog",
}
# 已确认的历史遗留死代码:早期版本的实现,后来换成了别的写法,
# 函数留着但没人调。不影响功能,列出来是为了有朝一日清掉。
KNOWN_DEAD = {
"buildFeatureTab": "旧的分页构建方式,已被 renderGroups() 取代",
"pickFolder": "已被 pickFile() 取代",
"pickClipHistory": "功能已移到服务端注册表",
"confirmWallpaper": "功能已移到服务端注册表",
"confirmMonitor": "功能已移到服务端注册表",
"sendMedia": "已被 media() 取代",
"promptRun": "已被 runFeature() 取代",
"showInfo": "已被 feature_run 的 info 取代",
"findSavedToken": "旧的重连逻辑,已被 RelayTransport 取代",
"glassBackground": "旧的美化辅助函数,样式改成 glass() 后没人用了",
"sp": "旧的字号辅助,已被 dp() 取代",
}
# 关键路径:这些漏了就是白屏 / 功能全无,必须硬性拦住
CRITICAL = ("buildConnectPanel", "buildControlPanel", "initTabs",
"buildMirrorPanel", "buildLanPanel")
passed = 0
failed = 0
def check(name, ok, detail=""):
global passed, failed
if ok:
passed += 1
print(" [PASS] " + name)
else:
failed += 1
print(" [FAIL] " + name + " " + str(detail))
def defined_methods(text: str) -> list[str]:
"""找出主程序里定义的方法名。
⚠ **带 `@Override` 的跳过**。
为什么:这些是**父类/接口回调**,由框架调用,源码里当然只出现一次 ——
而这个扫描的判据正是"名字只出现 1 次 = 没人调"。
踩到的实例:手表端的 `GestureDetector.SimpleOnGestureListener`
里有 onSingleTapConfirmed / onDoubleTap / onScroll,
它们由 Android 的触摸框架回调,源码里各只出现一次。
不跳过的话扫描会把它们报成"未知的未调用方法"(假阳性)——
而这种假阳性最坏的结果是**有人为了让它变绿,把回调删了**,
于是手势就失灵了。
同类还会遇到的:View.OnClickListener.onClick、
DialogInterface.OnClickListener.onClick、Runnable.run 等。
"""
pattern = re.compile(
r"(?:private|public|protected|static|final|\s)+"
r"[\w<>\[\],.\s]+\s+(\w+)\s*\([^)]*\)\s*(?:throws [\w.,\s]+)?\{")
names = []
for match in pattern.finditer(text):
name = match.group(1)
if name in ("if", "for", "while", "switch", "catch", "synchronized",
"try", "return", "new"):
continue
# 往上看几行,如果有 @Override 就跳过
head = text[max(0, match.start() - 200):match.start()]
tail_lines = head.splitlines()[-3:]
if any("@Override" in ln for ln in tail_lines):
continue
names.append(name)
return names
def main() -> int:
check("MainActivity.java 在", MAIN.is_file())
if not MAIN.is_file():
return 1
text = MAIN.read_text(encoding="utf-8")
print()
print("一、关键路径必须被调用(漏了就是白屏)")
# initTabs 专项:白屏的根因就是它
init_def = len(re.findall(r"private void initTabs\s*\(", text))
init_use = len(re.findall(r"\binitTabs\s*\(\s*\)", text))
check("initTabs() 有定义", init_def >= 1, f"定义 {init_def} 处")
check("initTabs() 被调用(否则连上后一片白屏)",
init_use >= 2, f"出现 {init_use} 次(1 次是定义,>1 才算被调用)")
check("initTabs() 紧跟 buildControlPanel()",
re.search(r"buildControlPanel\(\);\s*(?://[^\n]*\n\s*)*initTabs\(\);",
text) is not None,
"必须在 buildControlPanel() 之后立刻调用")
for method in CRITICAL:
n = len(re.findall(r"\b" + method + r"\s*\(", text))
check(f"{method}() 被调用", n > 1, f"出现 {n} 次")
print()
print("二、分页定义完整性")
tab_names = re.findall(r'new TabDef\("([^"]+)"', text)
check("定义了 10 个分页", len(tab_names) == 10, f"实际 {len(tab_names)} 个")
print(f" 分页: {' '.join(tab_names)}")
check("selectTab 有定义", "private void selectTab(" in text)
check("selectTab 被调用", len(re.findall(r"\bselectTab\s*\(", text)) > 1)
print()
print("三、未调用方法扫描")
names = defined_methods(text)
orphans = []
for name in sorted(set(names)):
if name in WHITELIST or name in KNOWN_DEAD:
continue
n = len(re.findall(r"\b" + re.escape(name) + r"\s*\(", text))
if n <= 1:
orphans.append((name, n))
check("没有**未知**的未调用方法", not orphans,
"\n ".join(f"{n}({c} 次)" for n, c in orphans)
if orphans else "")
if orphans:
print()
print(" 未知的未调用方法(可能是漏接的事件处理,也可能是新的死代码):")
for name, cnt in orphans:
print(f" {name} (出现 {cnt} 次)")
if KNOWN_DEAD:
print()
print(f" 已知死代码 {len(KNOWN_DEAD)} 个(不影响功能,待清理):")
for name, why in sorted(KNOWN_DEAD.items()):
still = len(re.findall(r"\b" + re.escape(name) + r"\s*\(", text))
mark = "" if still else " [已删除]"
print(f" {name}: {why}{mark}")
print()
print("=" * 58)
print(f"通过 {passed} 项,失败 {failed} 项")
return 0 if failed == 0 else 1
if __name__ == "__main__":
sys.exit(main())