diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ca3cb74c..789e372c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -645,6 +645,26 @@ repos: files: ^(Drivers/BSP/UI/|App/task/ui_task\.(c|h)$|simulator/(tests|sim|src)/|simulator/main\.c$|simulator/CMakeLists\.txt$|simulator/lv_conf\.h$|scripts/check_ui_golden\.py$|\.pre-commit-config\.yaml$) pass_filenames: false + # 12a) 板端 UI 导航器认屏自检(2026-10-25 道E task-25) + # 背景:大窗实测——靠预置键序盲走到深页时,9 帧里只有 3 帧真是它声称的态; + # KEY 的 ACK 是空应答、不携带状态,所以**画面是唯一事实源**。判据脚本 + # scripts/board_ui_nav.py 据此改成闭环:每按一键重新认屏再决定下一个键。 + # 本钩子跑它的**快检档** --selftest-quick:只核"认屏认得对"(18 张黄金参考 + + # 真机留档帧,目录不在版本控制里、缺就整段跳过),秒级、无构建产物依赖。 + # ⚠ 不挂完整 --selftest:第三段闭环需要 lvgl_sim.exe(构建产物,新工作树里没有) + # —— 正是同一天 mem-numbers 缺 .map 那课的形状;脚本对缺 exe 报 FAIL 并给出 + # 可执行提示,不"跳过当通过"。合入前 / 上板前手工跑一次完整档: + # python scripts/board_ui_nav.py --selftest(约 30 s,含全绑定页闭环)。 + # ⚠ 触发面刻意只两块(脚本自身、黄金参考帧):字体或菜单树变动**不**触发它, + # 那两类的守卫分别是 font-charset / ui-golden / registration-check 与 id 侧; + # 把每个 UI 提交都拉去跑导航器,只会让人习惯等一个与本改动无关的钩子。 + - id: uinav-selftest + name: 板端 UI 导航器认屏自检(快检:黄金参考帧分类) + entry: python scripts/board_ui_nav.py --selftest-quick + language: system + files: ^(scripts/board_ui_nav\.py$|simulator/tests/ref/.*\.bin$) + pass_filenames: false + # 13) 编号门禁(2026-10-20 加,docs/编号规范.md §8 的 8 项校验) # 背景:§1.2 用仓内证据列了 10 类编号事故(同字母多义 / 跨文档同号不同义 / # 销项删行后残留悬空引用 / 自造前缀 / 会话临时号入册…),而 36 个钩子里 diff --git a/docs/UI设计/UI_数据供给设计说明.md b/docs/UI设计/UI_数据供给设计说明.md index 2488cad6..d9e0d382 100644 --- a/docs/UI设计/UI_数据供给设计说明.md +++ b/docs/UI设计/UI_数据供给设计说明.md @@ -526,6 +526,14 @@ typedef struct { > 「合位/分位」这条词映射当前**没有任何自动化证据覆盖**(像素恒 IV、页面依赖 LVGL 也进不了 > 主机单测),它已登记为开入落地后的台架判据,而不是假装测过。 +> 【落地注·五(写路径判据的前置口径,指针级,不重述细节)】带 `#P` 注记的叶子**第一屏是密码 +> 页、不是内容页**:真机实测里三帧「深页盲走」的落点都是密码输入页(墨量 512,与黄金参考 +> 的两张密码态逐字对得上)。所以批 5 的写路径判据必须把「过密码」当**显式状态**处理——快照 +> 层不提供「曾否进过某页」这种隐式状态,可写性只由 UI 侧权限态与 `par.locked` 共同决定, +> 「当前是否已过密码」既不真实也不该被推断。判据侧的过法只有一套(查看密码 = 出厂 000 / +> 修改密码 = 123,键序与黄金回归的网口设置编辑态完全一致),口径与纪律见 +> `docs/UI设计/UI_自动化测试流程.md` §7.5;本节只登记这条事实,避免两份文档各说各话。 + --- ## 12. 验收判据 diff --git a/docs/UI设计/UI_自动化测试流程.md b/docs/UI设计/UI_自动化测试流程.md index ff876961..9d837cab 100644 --- a/docs/UI设计/UI_自动化测试流程.md +++ b/docs/UI设计/UI_自动化测试流程.md @@ -102,6 +102,88 @@ cmake --build simulator/build --config Debug # 0) 构建模拟器(须 0 警 - `菜单页面设计.md` §9:内容页对象树 / 几何表 / 内存预算(本文数字的来源) - `simulator/README.md` §3.5/§3.6:无头模式(`--keys`/`--screenshot`)与回归脚本说明 +## 七、板端有状态导航判据(真机侧,`scripts/board_ui_nav.py`) + +> 前五节都在模拟器里跑;模拟器与固件同一份 UI 源码,像素判据在 PC 上就成立。本节管另外 +> 一半:**真机上把页面走到、并证明走对了**——这一步只有真机需要(模拟器侧把键序直接喂给 +> 无头进程,状态是确定的)。 + +### 7.1 为什么必须闭环(实测 2026-10-25 大窗) + +- RemoDispBus 的 **KEY 应答是空帧、不携带任何状态**:发了 5 个键不等于到了第 5 层。 +- 靠预置键序盲走,9 个「应当到达」的态里只有 3 个真是那个态: + +| 留档帧 | 文件名声称 | 实际是 | 墨量 | 证据 | +|---|---|---|---|---| +| 00 / 01 / 02 | 根菜单 / 装置信息 / 交流量 | 名副其实 | 3226 / 3476 / 3346 | 认屏一致 | +| 03_dc | 直流量 | 菜单级联(整屏无页标题) | 4961 | [blindwalk_cascade](../测试说明/boot_log_data/20261025_uinav_blindwalk_cascade.png) | +| 04_yx / 05_yxp2 / 06_soe | 遥信量两页 / SOE记录 | **密码输入页** | 512 / 515 / 512 | [blindwalk_pwd512](../测试说明/boot_log_data/20261025_uinav_blindwalk_pwd512.png) | +| 07_vparam | 参数查看页 | 网口设置页 | 3774 | 标题带命中「网口设置」 | +| 08_vset | 定值查看页 | 装置信息页 | 3474 | 标题带命中「装置信息」 | + + 那三帧的 512 墨量曾被记成「神秘空白态」;与黄金参考 `shot_pwd.bin`、`shot_pwe.bin`(墨量 + 同为 512)逐字对得上之后可以定性:**走带 `#P` 注记的叶子时,第一屏是要密码,不是内容页** + ——写路径判据(批 5)因此必须把「过密码」当显式状态处理,口径见 §7.5。 +- 结论:判据脚本只能信画面。脚本因此**每按一键重新认屏**再决定下一个键,且「到达」只由 + **页面身份**裁定;中途任何假设都写进轨迹,绝不当成结论。 + +### 7.2 认屏的三条判据(精确字形匹配,复用同一份 `ui_font_cn_12`) + +| 态 | 判据 | 为什么这样判 | +|---|---|---| +| 内容页 | 标题带命中 + 该页黄金行文本至少一条 + 页码行「第 N 页,共 M 页」 | 只认标题会把两屏认成一屏(「参数」既是菜单项也是页名);页码让「遥信量第 2 页 / 共 5 页」成为可比对的事实 | +| 菜单 | 在白底横条(反白选中行)覆盖的行范围内、**整幅宽度**找反白标签;长标签优先,命中框被包住者丢 | 选中行是黑字白底,只按正常极性永远读不出选中项;「参数定值」那行必然同时落进子串「参数」「定值」,不去重就凭空多两列 | +| 密码页 | 「用户密码」四个字里中三个 | 整串匹配实测差约 1 px 打不中;数字格随输入变,不参与判定 | + +另两条实测纪律(理由写死在代码注释里,此处不重述): + +- **收级联列按 `l`、不按 `s`**:菜单态按 `s` 不收列,用 `ssssssss` 回根正是深页错位的来源之一; +- 菜单树的 `#P` 注记可能写在 `#page=` **之前**(`网口设置 #P #page=net_cfg`),解析顺序错了就 + 剥不掉,那一叶的页面 id 也永远认不出来。 +### 7.3 用法与门禁档位 + +```powershell +# 认屏快检:18 张黄金参考(+ 若有台架留档帧),秒级,不需要构建产物 +& '.venv\Scripts\python.exe' scripts/board_ui_nav.py --selftest-quick +# 完整自检:再加全绑定页闭环导航(需模拟器可执行文件,约 30 s) +& '.venv\Scripts\python.exe' scripts/board_ui_nav.py --selftest +# 台架窗口:一条命令走完全部绑定页并逐页存帧(须持 bench 锁) +& '.venv\Scripts\python.exe' scripts/board_ui_nav.py --walk +# 只走一页并抓图;--trace 落步迹、--dump-fail 落「认错的那一屏」 +& '.venv\Scripts\python.exe' scripts/board_ui_nav.py --board --to yx --grab D:/tmp/yx +``` + +- 提交钩子 `uinav-selftest` 跑的是**快检档**(`--selftest-quick`)。为什么不挂完整档:闭环 + 那一段要模拟器可执行文件——它是构建产物,新工作树里没有,正是同一天 `mem-numbers` 缺 + `.map` 那课的形状。脚本对「缺可执行文件」报 FAIL 并给可执行提示,不「跳过当通过」; + 合入前 / 上板前请手工跑一次完整档(快检 ≠ 闭环,两者结论不同,别互相顶替)。 +- 板端后端只连 7003 取图注键,**不含串口、烧录、复位**;跑真机必须自己持 `bench` 锁。 +- 台架留档帧目录在 gitignore 内、不入库:缺就整段跳过,不算失败——但脚本把「没跑」与 + 「跑过且通过」分开打印,不把前者写成后者。 + +### 7.4 已知限度(别把它当万能闸) + +1. **根列选中落在最后两行时,子菜单弹框会被屏幕裁掉**:选带不在可扫窗口里,列数会漏计。 + 脚本对策是「枚举 + 验终点」——末列先盲按 N 次下移再进(第 N 次尝试),成败仍只由页面 + 身份裁定,所以它会大声报错而不是静默走错页。实测 `自检记录` / `运行记录` 两页在模拟器 + 上 5 次枚举内没吃到,现场帧见 + [popup_clipped](../测试说明/boot_log_data/20261025_uinav_popup_clipped.png); + **真机上那层弹框究竟画在哪,留台架窗口取证后再定修法**——这是取证题,不是编码题。 +2. 同一标题的多个显示态(网口设置的 只读 / 编辑 / 保存)在分类里都是同一个页面 id + `net_cfg`;要区分得看行内文本,写路径判据需要时再加。 +3. 认屏复用黄金参考的文案:改了页面文案而没同步 `simulator/tests/` 的文本断言表,本节脚本 + 会一起红。那是**有意的耦合**(两处必须同时改),不是脚本脆弱。 +4. 快检档只证「认屏认得对」,不证「导航走得对」;后者只有完整档的闭环那一段会响。 + +### 7.5 过密码的口径(与黄金回归同源,只有一套走法) + +查看密码 = 出厂 `000`(直接 `e` 确认);修改密码 = `123`,键序 `u,r,u,r,u,r,e`,与 +`simulator/tests/check_content_pages.py` 的 `netcfg_edit` 态**完全一致**——刻意不另发明一套走法 +(两套走法迟早分叉,而分叉的那一边往往是真机)。导航器**默认只过查看密码**:判据需要写 +路径权限时必须显式传 `modify=True`,**不因「能进去」就自动升格成可写**——只读判据偷偷拿到 +修改权限,等于把判据的边界让给了实现。 + +--- ## 变更记录 | 日期 | 变更 | @@ -114,4 +196,7 @@ cmake --build simulator/build --config Debug # 0) 构建模拟器(须 0 警 | 2026-09-13 | 管线同步:`UI_界面制作流程.md` 移除 HTML 样机阶段(五阶段 → 四阶段 P0~P4),本文定位改为"P2/P3 门禁的可执行版本";§六 关系描述更新为 P0 规格 → P1 编码 → P2 本文回归 → P3 上机目检 → P4 归档;移除 `tools/ui_mock/` 引用 | | 2026-10-11 | **裁剪 LVGL 死代码(F-1/F-2/F-3)时的门禁记录**:先用**未裁剪**配置 `--update` 生成 11 页黄金参考,再切到裁剪配置重编重跑 → **逐字节全部一致**(菜单 8 状态像素一致),证明裁剪零视觉影响。池水位门禁随之 5440/5520 → **5368/5448**(−72 B;一次性残留仍 80 B,故非泄漏变化,见 §三 水位历史)。顺带揪出并修掉 `Lib/lv_conf.h` 与 `simulator/lv_conf.h` 的**长期漂移**(模拟器早已 I1-only、MCU 端未裁)→ 规则见 `UI_设计架构.md` §1.4 第 10 条,量化见 `../内存管理/RAM使用分析与优化方案.md` §2.6.4 | | 2026-10-15 | **回归改为自洽运行(修 `netcfg_save` 假失败)**:`check_menu_rows.py` / `check_content_pages.py` 一律以**空临时目录当工作目录**跑 `lvgl_sim`(模拟器按 CWD 读写 `sim_config.ini`,残留档会改变页面内容);`simulator/main.c` 的 `--cfgtest` 加外壳,**无论成败一律删除伪造的 4000 B 配置档**——旧行为连 PASS 路径也会留雷,之后每次 UI 回归的保存行都从 `OK` 变 `ERR`(引擎拒写超长文档,属正确行为)。脚本里"删 `sim_netcfg.bin` 残留"的旧兼容清理随之作废(旧档迁移 2026-10-10 已删),改为**只提示不删除**。修后 11 页 + 往返池水位 5,448 B 全过,**未刷任何黄金参考**;排查口径见 §五 | +| 2026-10-25 | 新增第七节「板端有状态导航判据」(`scripts/board_ui_nav.py`):真机侧闭环导航、 + 盲走 9 帧仅 3 帧名副其实的实测表、认屏三判据、`uinav-selftest` 钩子档位、四条已知限度与 + 过密码口径;三张证据 PNG 落 `boot_log_data/` | | 2026-10-22 | **`ref/*.bin` 像素黄金参考改为入库**(撤销 2026-09-10 的"整目录不入库"):`.gitignore` 由忽略整目录收窄为只忽略 `ref/*.png`。动因是这条门禁不可移植——新克隆/新工作树里基准必然缺失,`ui-golden` 报「缺黄金参考」而不是回归结论;本仓实测两次同类踩坑(并行工作树里第一次跑必失败,只能现场 `--update` 造基准,而"现场造的基准"恰好失去意义)。入库前做了可复现性验证:主树(`ref/` 生成于 2026-10-11)与新工作树各自独立构建 `lvgl_sim`,11 个 `.bin` SHA-256 全部一致。§四 与"何时不该更新"随之改写:`--update` = 改基准,会进 `git diff`,须逐页过目并在提交正文说明 | diff --git a/docs/测试说明/boot_log_data/20261025_uinav_blindwalk_cascade.png b/docs/测试说明/boot_log_data/20261025_uinav_blindwalk_cascade.png new file mode 100644 index 00000000..fb54e0d3 Binary files /dev/null and b/docs/测试说明/boot_log_data/20261025_uinav_blindwalk_cascade.png differ diff --git a/docs/测试说明/boot_log_data/20261025_uinav_blindwalk_pwd512.png b/docs/测试说明/boot_log_data/20261025_uinav_blindwalk_pwd512.png new file mode 100644 index 00000000..5e5b50ad Binary files /dev/null and b/docs/测试说明/boot_log_data/20261025_uinav_blindwalk_pwd512.png differ diff --git a/docs/测试说明/boot_log_data/20261025_uinav_popup_clipped.png b/docs/测试说明/boot_log_data/20261025_uinav_popup_clipped.png new file mode 100644 index 00000000..00bda64e Binary files /dev/null and b/docs/测试说明/boot_log_data/20261025_uinav_popup_clipped.png differ diff --git a/scripts/board_ui_nav.py b/scripts/board_ui_nav.py new file mode 100644 index 00000000..484f8554 --- /dev/null +++ b/scripts/board_ui_nav.py @@ -0,0 +1,975 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +模块名称:板端 UI 有状态导航器(道E 台架判据执行器 v2) +模块功能:在 RemoDispBus(KEY 注键 + LCDMEM 取帧)上**靠画面反馈**决定下一个键, + 走到指定页面并抓图;不再依赖"预置键序盲走"。 +为什么要它(2026-10-25 大窗实测): + 盲走(mega_ui.py 里那套 'dee' / 'dedde' 固定串)在板端不稳——级联列没收回时 + 同一串键落在不同的列上,落点整体错位;KEY 的 ACK 帧是空应答、不携带状态, + 所以"发了 5 个键"不等于"到了第 5 层"。实测里深页盲走退化成"整屏只剩约 512 bit + 有墨"的形态,却没有任何一帧告诉我们走错了。 + 结论:**导航必须闭环**——每步 grab 一帧、认出"当前在哪一屏/哪一列/哪一行", + 再决定下一个键。模拟器侧已有的精确字形匹配器(同一份字体、同样的 3200 B I1 + 布局)可以直接用在板端帧上,本文件 = 那套匹配器 + 闭环状态机。 +两种设备后端(同一套导航逻辑): + SimDevice = 无头模拟器(lvgl_sim.exe --keys <串> ),**不需要台架**, + --selftest 全部在它上面跑; + BoardDevice = RemoDispBus TCP 7003(真机),只在 --board 显式给出时才连, + 且必须持 bench 锁(本文件自己不碰串口与烧录)。 +用法: python scripts/board_ui_nav.py --selftest-quick # 认屏快检(提交钩子跑这档) + python scripts/board_ui_nav.py --selftest # 加闭环导航(需 lvgl_sim.exe) + python scripts/board_ui_nav.py --walk # 台架窗口:走全部绑定页并存帧 + python scripts/board_ui_nav.py --board --to yx --grab /tmp/yx + 可选:--trace 路径(步迹落盘) --dump-fail 目录(把认错的那一屏存下来) +退出码: 0 = 成功;1 = 分类或导航失败;2 = 环境错误(exe 缺失 / 板子连不上) +创建日期:2026-10-25(道E,Lead 大窗反馈后的判据脚本升级) +""" + +import importlib.util +import json +import os +import socket +import subprocess +import sys +import time + +try: + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + sys.stderr.reconfigure(encoding="utf-8", errors="replace") +except Exception: + pass + +HERE = os.path.dirname(os.path.abspath(__file__)) +# 仓库根:本文件入库后位于 /scripts/board_ui_nav.py(上跳一级即根)。 +# 允许 DTU_REPO_ROOT 覆盖,便于从别的目录或工作树里跑同一份脚本取证。 +REPO = os.environ.get("DTU_REPO_ROOT") or os.path.abspath(os.path.join(HERE, os.pardir)) +HARNESS = os.path.join(REPO, "simulator", "tests", "check_content_pages.py") +SIM_EXE = os.path.join(REPO, "simulator", "build", "Debug", "lvgl_sim.exe") +MENU_TREE = os.path.join(REPO, "ui_menu_tree.txt") +# 真机留档帧目录:不在版本控制里(各次台架各自的产物),缺就跳过那一段并说明 +WALK_DIR = os.environ.get("DSH_UINAV_WALK") or os.path.join(REPO, "ci-artifacts", "uiwalk") +# 轨迹/失败帧**默认不写进仓库**:只有 --trace / --dump-fail 显式给了路径才落盘 +# (判据脚本自己在提交门禁里跑,顺手往 scripts/ 写 json 就是把工作树弄脏) +TRACE = None +FAIL_DIR = None + +W = H = 160 +STRIDE = W // 8 +FRAME_BYTES = STRIDE * H + +# 键码(与 mega_ui.py 及板端 KEY 通道一致) +KEYS = {"u": 0x02, "d": 0x40, "l": 0x10, "r": 0x08, "e": 0x20, "s": 0x01} + +# 菜单根框几何(与 check_menu_rows.py 同源:view_init 的 x=0,y=44,w=66,h=107) +ROOT_X0, ROOT_Y0, ROOT_W, ROOT_H = 0, 44, 66, 107 +ROW_H = 15 +BAND_SEL_MIN = 0.50 # 白底选带占比下限(同 check_menu_rows 口径) +COL_W = 66 # 一级子列宽(板端与模拟器同源) +BLANK_INK = 900 # "几乎空白"屏的墨量上限(待机/密码页形态,实测 512) +MAX_STEPS = 60 # 单次导航按键上限,到顶即失败而不是继续猜 + + +def load_harness(): + """载入库内黄金回归脚本,复用它的字体解析与字形渲染(不复制第二套规则)。""" + spec = importlib.util.spec_from_file_location("cc_harness", HARNESS) + m = importlib.util.module_from_spec(spec) + old = sys.argv + sys.argv = ["board_nav"] + try: + spec.loader.exec_module(m) + finally: + sys.argv = old + return m + + +CC = load_harness() + + +class Glyph(object): + """一个字符串的精确字形占位描述(bbox + 每相对行的期望位模式)。""" + + def __init__(self, s): + self.s = s + ink = CC._render(s) + if not ink: + self.empty = True + return + self.empty = False + xs = [p[0] for p in ink] + ys = [p[1] for p in ink] + self.bx1, self.bx2 = min(xs), max(xs) + self.by1, self.by2 = min(ys), max(ys) + self.wb = self.bx2 - self.bx1 + 1 + rows = [] + for iy in range(self.by1, self.by2 + 1): + mask = 0 + for (x, y) in ink: + if y == iy: + mask |= 1 << (self.bx2 - x) + rows.append((iy, mask)) + self.rows = rows + self.anchor = max(rows, key=lambda t: bin(t[1]).count("1")) + + +class Frame(object): + """一帧 3200 B 的 I1 位图;每行存成一个 160 bit 整数(bit 159-x = 像素 x)。""" + + def __init__(self, data, name=""): + if len(data) != FRAME_BYTES: + raise ValueError("帧长度 %d != %d(%s)" % (len(data), FRAME_BYTES, name)) + self.name = name + self.data = data + self.rows = [int.from_bytes(data[y * STRIDE:(y + 1) * STRIDE], "big") + for y in range(H)] + self.ink = sum(bin(r).count("1") for r in self.rows) + + @classmethod + def file(cls, path): + with open(path, "rb") as f: + return cls(f.read(), os.path.basename(path)) + + def span(self, y, ox, wb): + """第 y 行从像素 ox 起、宽 wb 的一段(低位对齐成 wb 位整数)。""" + if y < 0 or y >= H or ox < 0 or ox + wb > W: + return None + shift = W - (ox + wb) + return (self.rows[y] >> shift) & ((1 << wb) - 1) + + def match(self, g, band, x0=0, x1=W, invert=False, slack=6): + """精确字形匹配,判定强度与 _match_text 相同(有墨处必须 1、bbox 内其余必须 0)。 + + 快法:先用**锚行**一次整数比较筛出候选 ox,再逐行验全 bbox。原实现逐像素 + 双循环,闭环导航每步要判十几次、一屏几十秒等不起。 + + invert=True 认的是**反白行**(选中行是黑字白底:字形处必须 0、bbox 其余必须 1)。 + 这不是可选优化——菜单的选中行天然就是反白的,不认反白就永远读不出"当前选中的 + 是哪一项",闭环导航第一步就瞎了。 + """ + if g.empty: + return [] + aiy, amask0 = g.anchor + allw = (1 << g.wb) - 1 + amask = amask0 ^ allw if invert else amask0 + lo = max(0, x0 - g.bx1) + hi = min(W - g.bx2, x1 - g.bx1) + hits = [] + for oy in range(band[0] - slack, band[1]): + ay = oy + aiy + for ox in range(lo, hi + 1): + if self.span(ay, ox + g.bx1, g.wb) != amask: + continue + ok = True + for (iy, mask0) in g.rows: + if iy != aiy: + mask = mask0 ^ allw if invert else mask0 + if self.span(oy + iy, ox + g.bx1, g.wb) != mask: + ok = False + break + if ok: + hits.append((ox, oy)) + return hits + + def match_any(self, g, band, x0=0, x1=W, slack=6): + """正拷/反白两种极性都试(选中行与未选中行都可能是目标)。""" + h = self.match(g, band, x0, x1, False, slack) + return h if h else self.match(g, band, x0, x1, True, slack) + + def white_bars(self, min_w=26, y0=None, y1=None): + """检出"白底横条"(反白选中行)→ [(x0, x1, ya, yb), ...] 按 x0 排序。 + + 判据是"连续 >= min_w 列全白",再纵向合并成条(间隙 <=2 行算同一条)。 + min_w 取 26:根列条宽约 64、子列条约 42,而汉字笔画里最宽的连续白只有 + 字间空隙(远小于 26),所以不会把普通文字行误判成选带。 + """ + ya0 = ROOT_Y0 if y0 is None else y0 + ya1 = (ROOT_Y0 + ROOT_H + 8) if y1 is None else y1 + runs = [] + for y in range(max(0, ya0), min(H, ya1)): + bits = self.rows[y] + x = 0 + while x < W: + if (bits >> (W - 1 - x)) & 1: + x2 = x + while x2 + 1 < W and ((bits >> (W - 2 - x2)) & 1): + x2 += 1 + if (x2 - x + 1) >= min_w: + runs.append((y, x, x2)) + x = x2 + 1 + else: + x += 1 + out = [] + for (y, a, b) in runs: + if out and (y - out[-1][3]) <= 2 and abs(a - out[-1][0]) <= 3 and abs(b - out[-1][1]) <= 3: + out[-1][3] = y + else: + out.append([a, b, y, y]) + res = [(r[0], r[1], r[2], r[3]) for r in out] + res.sort(key=lambda t: (t[0], t[2])) + return res + + def band_frac(self, y0, y1, x0, x1): + tot = (max(0, min(H, y1)) - max(0, y0)) * (x1 - x0) + if tot <= 0: + return 0.0 + on = 0 + for y in range(max(0, y0), min(H, y1)): + s = self.span(y, x0, x1 - x0) + if s is not None: + on += bin(s).count("1") + return float(on) / float(tot) + + def sel_bands(self, x0, x1, y0, y1): + """x 范围内"白底选带"的行段 [(ya, yb), ...](连续行合并,间隙 <=2 行算一段)。""" + rows = [y for y in range(max(0, y0), min(H, y1)) + if self.band_frac(y, y + 1, x0, x1) >= BAND_SEL_MIN] + out = [] + for y in rows: + if out and (y - out[-1][-1]) <= 2: + out[-1].append(y) + else: + out.append([y]) + return [(g[0], g[-1]) for g in out] + + +def load_menu_tree(): + """解析 ui_menu_tree.txt → [(level, label, page)]。 + + 注释行('#' 开头)与指令行('@' 开头,如 @password)都不是节点,必须跳过—— + 否则整块文件头注释会被当成 level 0 的"菜单项",导航器会去找一个不存在的标签。 + """ + out = [] + with open(MENU_TREE, encoding="utf-8-sig") as f: + for line in f: + t = line.strip() + if not t or t.startswith("#") or t.startswith("@"): + continue + lvl = (len(line) - len(line.lstrip(" "))) // 2 + # 是否带密码注记要在剥掉之前记下来(剥掉后就无从得知了) + need_pwd = t.endswith("#P") or (" #P " in t) + page = None + if "#page=" in t: + t, page = t.split("#page=", 1)[0].strip(), t.split("#page=", 1)[1].strip() + # ` #P`(该叶要密码)是**注记不是名字**:屏上没有这几个字符,留着它字面 + # 匹配永远打不中。它也可能写在 #page= 之前(网口设置那行就是 + # "网口设置 #P #page=net_cfg"),所以必须放在 #page 拆分之后剥——第一版用 + # endswith 判,对那一行永远不成立,网口设置就一直认不出来。 + if t.endswith("#P"): + t = t[:-2].strip() + out.append((lvl, t, page, need_pwd)) + return out + + +class Classifier(object): + """画面 → 状态。页面看标题 + 黄金行文本;菜单看"网格对齐的白底选带 + 该带内的标签"。 + + 菜单列模型(实测校正 2026-10-25):level 0(主界面)不是可选项而是根列的表头, + **可选行从 level 1 起**,列号 = level - 1。所以"根列选中的是 装置信息",'de' 之后 + 选中 实时数据 并展开子列——与黄金回归的键序 dee / dedde 完全对得上。 + """ + + def __init__(self): + self.tree = load_menu_tree() + self.title2harness = {} + for key, items in CC.TEXTS.items(): + for (lab, txt, band, inv) in items: + if lab == "标题": + self.title2harness.setdefault(txt, []).append(key) + # 标题 → 规范页面 id(菜单 #page=):一个标题可能对应多个黄金态(如网口设置 + # 的 4 个态、遥信量的两页),规范 id 取菜单树上真正绑定的那个 + self.title2page = {} + self.pwd_pages = set() + for (lvl, label, page, need_pwd) in self.tree: + if page: + self.title2page.setdefault(label, page) + if need_pwd: + self.pwd_pages.add(page) + self.tglyph = {t: Glyph(t) for t in self.title2harness} + self.labelg = {} + for (lvl, label, page, _np) in self.tree: + if lvl >= 1: + self.labelg.setdefault(label, Glyph(label)) + self.pages = {} + for key, items in CC.TEXTS.items(): + sig = [] + for (lab, txt, band, inv) in items: + if lab == "标题": + sig.append((self.tglyph[txt], band, 0, W)) + elif str(lab).endswith("名"): + sig.append((Glyph(txt), band, 0, 101)) + if len(sig) >= 3: + break + self.pages[key] = sig + # 密码页正签:屏上那行"用户密码"。整串匹配实测**打不中**(第四个字落点与逐字 + # 渲染的推进量差约 1 px,精确匹配就废了),所以退成"四个字里中三个"的投票—— + # 数字格随输入变不参与,而这四个字同时出现三个的地方只有密码页。 + self.pwd_chars = [Glyph(c) for c in "用户密码"] + # 页码:"第 N 页,共 M 页"(1..9 全枚举精确字形匹配,不做正则与浮点解析) + self.footer = {} + for n in range(1, 10): + for m in range(1, 10): + self.footer[(n, m)] = Glyph("第 %d 页,共 %d 页" % (n, m)) + + def is_pwd(self, fr): + """密码页判定(投票口径见上面的注释)。""" + hit = 0 + for g in self.pwd_chars: + if fr.match(g, (24, 80)): + hit += 1 + return hit >= 3 + + def page_of(self, fr): + """标题命中 → (规范页面 id, 黄金态键列表)。多态共用标题时黄金态键不止一个。""" + for txt, keys in self.title2harness.items(): + if fr.match(self.tglyph[txt], (0, 24)): + return self.title2page.get(txt, keys[0]), keys, txt + return None, [], None + + def page_evidence(self, fr, keys): + """除标题外还命中该页黄金文本的行数(第二道证据,防"同词不同屏")。""" + best = 0 + for k in keys: + n = 0 + for (g, band, x0, x1) in self.pages.get(k, []): + if fr.match_any(g, band, x0, x1): + n += 1 + best = max(best, n) + return best + + def page_no(self, fr): + n_m = None + for (n, m), g in self.footer.items(): + if fr.match(g, (138, 160)): + n_m = (n, m) + break + return n_m + + def menu_cols(self, fr): + """逐列识别选中项 → [(标签, 条 x0, 条 y0), ...] 按 x 从左到右(列号 = 下标)。 + + 这里**不用**"列宽 66 的固定网格":子菜单是**弹框**,它的左上角跟着父列选中行 + 的行顶(l2_geom:x0 = 父框右边、y0 = 根顶 + 1 + 选中行 × 15,宽约 42),根列一 + 动,子列整体上下平移——按固定网格找选带会整列错位(实测把 de 之后的第二列 + 整个看漏)。 + 改成找**白底横条**(反白文字一定在白底上,一条 = 一个打开列的选中行): + 几何无关,且天然给出"打开了几列 / 最深列在哪"。 + """ + # 认"每列当前选中的是哪一项":在**白底条覆盖的行范围**内、**整幅宽度**上找反白 + # 标签。两个要点都是实测逼出来的: + # · x 不能限制在单条 run 内——根列选带与子列选带在同一行首尾相连,限宽会把 + # 子列吞掉("找不到 SOE记录"就是这么来的); + # · 长标签优先 + 命中框被已接受者包住就丢短者——"参数定值"那一行里必然同时 + # 落进子串"参数"与"定值"(它们是更深层的菜单项名),不去重就凭空多出两列。 + bars = fr.white_bars() + if not bars: + return [] + spans = sorted(set((by0, by1) for (_a, _b, by0, by1) in bars)) + acc = [] + for (label, g) in sorted(self.labelg.items(), key=lambda kv: -len(kv[0])): + for (by0, by1) in spans: + for (hx, hy) in fr.match(g, (by0 - 1, by1 + ROW_H), 0, W, True, 3): + x0, x1 = hx + g.bx1, hx + g.bx2 + y0, y1 = hy + g.by1, hy + g.by2 + inside = False + for (ax0, ax1, ay0, ay1, _lab, _hx, _hy) in acc: + if x0 >= ax0 - 2 and x1 <= ax1 + 2 and y0 >= ay0 - 3 and y1 <= ay1 + 3: + inside = True + break + if not inside: + acc.append((x0, x1, y0, y1, label, hx, hy)) + acc.sort(key=lambda t: (t[5], t[6])) + return [(t[4], t[5], t[6]) for t in acc] + + +def classify(fr, clf): + """整屏分类:kind = PAGE / MENU / BLANK / UNKNOWN。""" + st = {"kind": "UNKNOWN", "page": None, "states": [], "title": None, "evid": 0, + "ink": fr.ink, "no": None, "cols": None} + # 密码页先判:整屏只有"用户密码"四个字 + 三个数字格(实测 ink ≈ 512),既没有页 + # 标题也没有选带,不先认就会被"空白/菜单"分支糊过去。Lead 大窗里"深页盲走退化成 + # 512 bits"的那几帧正是这个态(--selftest 的留档帧表把它钉成断言)。 + if clf.is_pwd(fr): + st["kind"] = "PWD" + return st + pid, keys, txt = clf.page_of(fr) + if pid is not None or keys: + st["kind"] = "PAGE" + st["page"] = pid + st["states"] = keys + st["title"] = txt + st["evid"] = clf.page_evidence(fr, keys) + st["no"] = clf.page_no(fr) + # 同一标题下的多态(如遥信量第 1 页与第 2 页)用页码区分;黄金态键里带 p2 + # 的那个才是第 2 页——不靠"我记得键序"来判断,认屏只认屏上有的东西 + if st["no"] and len(keys) > 1: + for k in keys: + if k.endswith("p2") and st["no"][0] >= 2: + st["states"] = [k] + return st + cols = clf.menu_cols(fr) + if cols and cols[0] is not None: + st["kind"] = "MENU" + st["cols"] = cols + return st + if fr.ink <= BLANK_INK: + st["kind"] = "BLANK" + return st + + +# ---------------------------------------------------------------- 设备后端 + +class SimDevice(object): + """无头模拟器当设备用:状态 = 从开机起的按键串,每次按键后重放并截图。 + + 不需要台架,因此闭环逻辑的正确性可以先在 PC 上证完,再上板换传输。 + ⚠ 一律在空临时目录里跑(沿用黄金回归的同一条纪律:目录里残留 sim_config.ini + 会让网口设置页显示 ERR,把"工厂默认态"污染成另一个态)。 + """ + + def __init__(self): + import tempfile + self.seq = "" + self.dir = tempfile.mkdtemp(prefix="uinav_") + + def key(self, ch): + if ch not in KEYS: + raise ValueError("未知键 " + repr(ch)) + self.seq = self.seq + ch + return self.grab() + + def boot(self): + self.seq = "" + return self.grab() + + def grab(self): + import os as _os + out = _os.path.join(self.dir, "shot.bin") + CC.run_sim(self.seq, out, self.dir) + return Frame.file(out) + + +class BoardDevice(object): + """RemoDispBus(TCP 7003):CMD_KEY 注键 + CMD_LCDMEM 取 3200 B 帧。 + + 只在 --board 明确给出时构造;调用方(人)必须先持 bench 锁。本类刻意不含 + 任何串口 / 烧录 / 复位动作——那些是另外的票与另外的锁。 + """ + + TAG_C = 0xAA + CMD_KEY = 2 + CMD_LCDMEM = 3 + + def __init__(self, ip="192.168.1.100", port=7003): + self.s = socket.create_connection((ip, port), timeout=5) + self.s.settimeout(3) + + def close(self): + try: + self.s.close() + except Exception: + pass + + @staticmethod + def _crc(d): + c = 0 + for b in d: + c ^= b + return c + + def _send(self, cmd, data=b""): + ln = len(data) + self.s.sendall(bytes([self.TAG_C, cmd, (ln >> 8) & 0xFF, ln & 0xFF]) + + data + bytes([self._crc(data)])) + + def _recv_n(self, n, deadline): + buf = b"" + while len(buf) < n: + if time.time() > deadline: + raise TimeoutError("recv timeout") + ch = self.s.recv(n - len(buf)) + if not ch: + raise ConnectionError("peer closed") + buf += ch + return buf + + def _read_frame(self, deadline): + hdr = self._recv_n(4, deadline) + n = (hdr[2] << 8) | hdr[3] + body = self._recv_n(n + 1, deadline) + return hdr[1], body[:n] + + def key(self, ch): + self._send(self.CMD_KEY, bytes([KEYS[ch]])) + # ACK 是空应答:这里**不**试图从应答里读状态(读不到才是本次改造的起因) + try: + self._read_frame(time.time() + 2.0) + except (TimeoutError, ConnectionError): + pass + time.sleep(0.30) + return self.grab() + + def boot(self): + return self.grab() + + def grab(self): + self._send(self.CMD_LCDMEM, (0).to_bytes(4, "big")) + _cmd, data = self._read_frame(time.time() + 4.0) + bmp = data[4:4 + FRAME_BYTES] + if len(bmp) != FRAME_BYTES: + raise ValueError("LCDMEM 帧短:%d 字节" % len(bmp)) + return Frame(bmp, "board") + + +# ---------------------------------------------------------------- 闭环导航 + +def page_path(clf, pid): + """页面 id → 从根列到该项的标签序列(菜单树是唯一的真源)。""" + stack = [] + for (lvl, label, page, _np) in clf.tree: + while stack and stack[-1][0] >= lvl: + stack.pop() + stack.append((lvl, label)) + if page == pid: + # level 0(主界面)是根列表头、不是可选行,路径里必须剔掉 + return [x[1] for x in stack if x[0] >= 1] + return None + + +class Navigator(object): + """靠状态反馈走过去:每按一键都重新认屏,认不出就回收,绝不"我以为到了"。""" + + def __init__(self, dev, clf, log=None): + self.dev = dev + self.clf = clf + self.log = log if log is not None else [] + + def snap(self): + fr = self.dev.grab() + st = classify(fr, self.clf) + st["ink"] = fr.ink + return fr, st + + def sel_label(self, st, col): + cols = st.get("cols") or [] + if col < len(cols): + return cols[col][0] + return None + + def n_cols(self, st): + return len(st.get("cols") or []) + + def _step(self, ch, why): + fr = self.dev.key(ch) + st = classify(fr, self.clf) + self.log.append({"key": ch, "why": why, "ink": fr.ink, "kind": st["kind"], + "page": st["page"], "cols": st["cols"]}) + return fr, st + + def to_root(self, limit=14): + """回到"只有一列打开"的根菜单。 + + 两条实测纪律写在这里: + · 页面态用 s 退(s = 退出页); + · **级联列只能用 l 收**,菜单态按 s 不收列(Lead 大窗实测)——沿用盲走脚本 + 的 ssssssss 回根法,正是深页错位的来源之一。 + """ + for _ in range(limit): + fr, st = self.snap() + if st["kind"] == "MENU" and self.n_cols(st) == 1: + # "只有一列"不等于"在根":子列弹框被屏幕裁掉时它的选带根本不在可扫窗口 + # 里,n_cols 会漏数它——于是上一轮留在弹框里的选中项还在,直接按 e 就 + # 钻进同胞页(实测 selftest / runlog 两次就是这么掉进去的)。 + # 对策:认定已回根之后再按两次 l 兜底(根列上 l 不会破坏任何东西), + # 然后重新验一次列数——**多收比漏收安全**,这条纪律与"枚举后验终点"同源。 + self._step("l", "回根后补收一次(防被裁弹框漏计)") + _fr2, st2 = self.snap() + if st2["kind"] == "MENU" and self.n_cols(st2) == 1: + return st2 + continue + if st["kind"] in ("PAGE", "BLANK", "PWD"): + # PWD 也必须能退:板子上电时如果停在密码页(留档帧里就有三帧), + # 不退出来整个导航就再也开不了工——这一步用 s(密码页上 s = 放弃) + self._step("s", "退出页面 / 密码页 / 空白态回菜单") + continue + if st["kind"] == "UNKNOWN": + # 认不出的屏:先按 l(收列不会造成破坏),再按 s 兜一次 + if self.n_cols(st) > 1: + self._step("l", "UNKNOWN 且看起来有多列 → 收列") + else: + self._step("s", "UNKNOWN 且无列可收 → 退出") + continue + self._step("l", "菜单多列 → 收回一级") + raise RuntimeError("回根失败(14 步内没见到单列根菜单)") + + def seek_in_column(self, col, want, limit=12): + """在当前列把选带移到标签 want:先下后上,走到边界就换向(不猜步数)。 + + 读不到这一列时**分两种情况**(实测:选中行落在根列最后两行时,子列弹框会画到 + 屏幕外,选带根本不在可扫窗口里): + · 这一列本就不该存在(col >= 已检出列数)→ 认为父列刚展开、首项天然被选中, + 直接交给上层的"按 e + 验到达页"闭环兜底——**到达与否只认页面身份**, + 中途的假设一律写进 trace,不留成"我以为到了"; + · 该列存在却认不出选中项 → 交上层重开(外部状态变了,别硬猜)。 + """ + fr, st = self.snap() + if self.sel_label(st, col) == want: + return True + if col >= self.n_cols(st): + self.log.append({"key": "-", "why": "第 %d 列没检出(子列可能被屏幕裁掉)," + "按首项已选中处理,最终靠页面身份验收" % col, + "kind": st["kind"], "page": st["page"], "cols": st["cols"]}) + return True + # 单调扫:先一路下移、再一路回上移,靠"选带标签变了没有"判断边界。 + # 不猜"目标在第几行"——菜单内容会变,猜步数就等于回到盲走。 + for ch in ("d" * limit) + ("u" * (limit * 2)): + _fr, st = self._step(ch, "在列 %d 里找 %s" % (col, want)) + got = self.sel_label(st, col) + if got == want: + return True + if got is None: + return False # 这一列认不出选中项 = 状态被外部改变,交上层重开 + return False + + def _pwd_pass(self, modify): + """过密码页:查看密码 = 出厂 000(直接 e 确认),修改密码 = 123(u,r,u,r,u,r,e)。 + + 键序与 simulator/tests/check_content_pages.py 里 netcfg_ro / netcfg_edit 两个黄金态 + **完全一致**:那是同一份 UI 源码的黄金回归,这里对齐它就不会出现"两套过法"。 + ⚠ 写路径(批 5)判据要区分权限:查看密码只给只读,修改密码才可写——导航器 + 默认走查看密码,**不因"能进去"就用修改密码**(那会把只读判据偷偷升格成可写)。 + """ + if modify: + for ch in "uruuruuue": + self._step(ch, "过修改密码(123)") + else: + self._step("e", "过查看密码(出厂 000,直接确认)") + + def _descend(self, path, blind_last, modify, pid): + """沿路径下钻;blind_last = 在最后一列里先盲按几次下移(弹框被裁时的枚举)。""" + for (i, label) in enumerate(path): + assumed = (i >= self.n_cols(self.snap()[1])) + if assumed and i == len(path) - 1 and blind_last: + for _k in range(blind_last): + self._step("d", "末列盲移一次(弹框被屏幕裁掉,读不到选带)") + if not self.seek_in_column(i, label): + raise RuntimeError("第 %d 列找不到 %s" % (i, label)) + _fr, st = self._step("e", "进入 " + label) + if st["kind"] == "PWD": + # 密码页只有两种正确解释:这一叶在菜单树上确实标了 #P(那就过一次), + # 或者它没标(那就是我们走到了意料之外的屏)——后者必须报错而不是顺手输密码 + if pid not in self.clf.pwd_pages: + raise RuntimeError("停在密码页,但 " + pid + " 在菜单树上没有 #P 注记") + self._pwd_pass(modify) + return + if i < len(path) - 1 and st["kind"] != "MENU": + raise RuntimeError("进入 " + label + " 后不是菜单态而是 " + st["kind"]) + + def go_page(self, pid, modify=False, tries=5): + """走到指定页面并用**页面身份**验收;到不了就报错,不返回"大概到了"。 + + 末列弹框被屏幕裁掉时(根列选中落在最后两行)读不到选带,"首项已选中"是猜的。 + 对策是**枚举 + 验终点**:第 N 次尝试先在末列盲按 N 次下移再进,成败仍只由页面 + 身份裁定。这不算回到盲走——盲走的问题从来不是"按了 unknown 的键",而是 + "按完就认为到了";这里每一次都验,验不过就退出来重走。 + """ + path = page_path(self.clf, pid) + if not path: + raise RuntimeError("菜单树里没有页面 " + pid) + last_err = None + for attempt in range(0, tries): + self.to_root() + try: + self._descend(path, attempt, modify, pid) + except RuntimeError as exc: + last_err = exc + continue + fr, st = self.snap() + if st["kind"] == "PAGE" and st["page"] == pid: + return fr, st + last_err = RuntimeError("第 %d 次尝试后停在 %s(目标 %s)" + % (attempt, st["kind"] + ":" + str(st["page"]), pid)) + # 把认错/走歪那一屏存下来:台架窗口只有几分钟,事后能翻图比"现场再走一遍" + # 便宜得多,而且 UNKNOWN 到底长什么样只有图能回答 + if FAIL_DIR: + try: + if not os.path.isdir(FAIL_DIR): + os.makedirs(FAIL_DIR) + with open(os.path.join(FAIL_DIR, "fail_%s_%d.bin" % (pid, attempt)), "wb") as fp: + fp.write(fr.data) + except OSError: + pass + self._step("s", "未到目标页,退出重走") + raise RuntimeError(str(last_err) + ";末列枚举 %d 次仍未到达" % tries) + + + +# ---------------------------------------------------------------- 自检与入口 + +# Lead 大窗留档帧(ci-artifacts/uiwalk/)的身份。 +# ⚠ 右边的期望值**不是照抄文件名**:本表按"认屏结果应当是什么"来写,实测证据是 +# 文件名声称到达的 9 个态里只有 3 个真是那个态(00 根 / 01 装置信息 / 02 交流量), +# 其余 6 帧的墨量与内容说明它们停在别处——03 是**菜单级联**(ink 4961、没有页标题)、 +# 04/05/06 是 ink ≈ 512 的**近空白态**(正是 Lead 说的 512 bits 形态)、 +# 07 是网口设置页、08 是装置信息页。这正是"盲走不可信"的量化证据,也是本文件存在的 +# 理由;把它写成断言而不是评论,是因为下次改 UI 后这条结论也要能被重新检查。 +# 已声明的导航限度(不是"允许失败",是**记账**):这两页落在根列最后两行,其子菜单 +# 弹框会被屏幕裁掉、选带不在可扫窗口内,"枚举 + 验终点"目前吃不到它们。 +# 依据与现场帧见 docs/UI设计/UI_自动化测试流程.md §7.4;真机上弹框画在哪要等台架取证。 +# ⚠ 对称设计:这两页哪天真走到了,本表就是**过期**——脚本会当成失败报出来,逼着同步删 +# 条目与文档限度,免得限度清单变成永久遮羞布。 +KNOWN_UNREACHABLE = { + "selftest": "根列末两行叶子的子列弹框被屏裁(见 §7.4 第 1 条)", + "runlog": "同上(与 自检记录 同一个被裁弹框)", +} + +REF_TRUTH = { + "pwd": ("PWD",), "pwe": ("PWD",), "pwc": ("PWD",), "pwk": ("MENU",), +} + +BOARD_TRUTH = { + "00_root.bin": ("MENU", None), + "01_info.bin": ("PAGE", "device_info"), + "02_ac.bin": ("PAGE", "ac"), + "03_dc.bin": ("MENU", None), + # 这三帧(ink 512/515/512)现在被认成 **PWD = 密码输入页**,比"近空白"更进一步, + # 而且与仓库黄金参考 shot_pwd/pwe.bin(ink 同为 512/512)逐字对得上: + # 也就是说"遥信量第 2 页 / SOE 记录"这两次深页盲走实际落点是**密码页**—— + # 走到 记录查询 / 三遥设置 这类带 #P 注记的叶子时,第一屏是要密码,不是内容页。 + # 这条结论值得留在断言里:批 5 的写路径判据必须先把"过密码"这一步做成显式状态。 + "04_yx.bin": ("PWD", None), + "05_yxp2.bin": ("PWD", None), + "06_soe.bin": ("PWD", None), + "07_vparam.bin": ("PAGE", "net_cfg"), + "08_vset.bin": ("PAGE", "device_info"), +} + + +def selftest_board_frames(clf): + """真机留档帧上的分类自检:不需要台架也能证明认屏认得对。""" + bad = [] + n = 0 + for name, (kind, pid) in sorted(BOARD_TRUTH.items()): + p = os.path.join(WALK_DIR, name) + if not os.path.exists(p): + print(" 跳过(无留档)", name) + continue + n += 1 + fr = Frame.file(p) + st = classify(fr, clf) + ok = (st["kind"] == kind) and (pid is None or st["page"] == pid) + print(" %-16s ink=%-5d 期望 %-5s %-11s 实得 %-5s %-11s %s" + % (name, fr.ink, kind, pid or "-", st["kind"], st["page"] or "-", + "OK" if ok else "MISMATCH")) + if not ok: + bad.append(name) + return n, bad + + +def selftest_refs(clf): + """仓库黄金参考(18 页)逐帧分类自检:每张图的期望值 = 它的文件名。 + + 这一组是**语义正确的基线**(模拟器与板端同一份 UI 源码),用来把"分类器错了"与 + "留档帧本来就是走歪的"分开——两类失败含义完全不同,混在一起就只能靠猜。 + """ + bad = [] + n = 0 + for (key, _seq) in CC.PAGES: + p = os.path.join(CC.REF_DIR, "shot_" + key + ".bin") + if not os.path.exists(p): + continue + n += 1 + fr = Frame.file(p) + st = classify(fr, clf) + # 有些参考只有像素断言、没有文本断言(TEXTS 里没这一项),它们的标题就取不到, + # 这类帧只要求"认出是某个内容页",不硬猜是哪一页(认不准就说认不准) + exp = None + for (lab, txt, band, inv) in CC.TEXTS.get(key, []): + if lab == "标题": + exp = clf.title2page.get(txt, key) + break + if exp is None: + # 密码系列(pwd/pwe/pwc = 密码输入态、pwk = 密码通过后的级联菜单态)没有 + # 页标题可核,期望值写死在下面这张表里,而不是"能认成什么都算过" + exp_kind = REF_TRUTH.get(key, ("PAGE",))[0] + ok = st["kind"] == exp_kind + print(" %-16s ink=%-5d 期望 (无标题可核) 实得 %-11s 页码 %-8s %s" + % (key, fr.ink, st["kind"], str(st["no"]), "OK" if ok else "MISMATCH")) + if not ok: + bad.append(key) + continue + ok = (st["kind"] == "PAGE") and (st["page"] == exp) + # 同一标题的多态(遥信量第 1 页 / 第 2 页)用页码再分一道 + if ok and key.endswith("p2"): + ok = bool(st["no"]) and st["no"][0] >= 2 + print(" %-16s ink=%-5d 期望 %-11s 实得 %-11s 页码 %-8s %s" + % (key, fr.ink, exp, st["page"], str(st["no"]), "OK" if ok else "MISMATCH")) + if not ok: + bad.append(key) + return n, bad + + +def known_unreachable_snapshot(): + """已声明限度里的页面,且**确实还在菜单树上**(页面被删掉时不该再报限度过期)。""" + return list(KNOWN_UNREACHABLE.keys()) + + +def selftest_closed_loop(clf, want): + """闭环导航自检:在模拟器上**只用状态反馈**走到目标页(不给键序)。""" + if not os.path.exists(SIM_EXE): + # 与 mem-numbers 缺 .map 是同一课:**新工作树没构建过就没有这个事实源**。 + # 这里刻意不"跳过当通过":闭环是判据的核心,静默 skip 等于把闸门打开。 + print(" [FAIL] 缺 lvgl_sim.exe(%s)——先跑 simulator/build_sim.bat," % SIM_EXE) + print(" 或改跑 --selftest-quick(只核认屏,不需要构建产物)") + return len(want), list(want), [] + log = [] + bad = [] + known = [] + for pid in want: + dev = SimDevice() + nav = Navigator(dev, clf, log=log) + t0 = time.time() + try: + fr, st = nav.go_page(pid) + print(" %-12s 到达 page=%-11s evid=%d 页码 %-6s 用键 %2d 次 %.1fs" + % (pid, st["page"], st["evid"], str(st["no"]), len(dev.seq), time.time() - t0)) + except Exception as exc: + if pid in KNOWN_UNREACHABLE: + print(" %-12s 预期内失败(已声明限度):%s" % (pid, KNOWN_UNREACHABLE[pid])) + known.append(pid) + else: + print(" %-12s 失败:%s" % (pid, exc)) + bad.append(pid) + # 反向检查:限度清单必须**恰好**命中——被声明的页真走到了,说明限度已解除, + # 那也是一次失败(过期清单比没有清单更害人:下一个人会照着它解释真实现象) + fixed = [p for p in known_unreachable_snapshot() if p not in known] + for p in fixed: + print(" %-12s 限度已解除:请删 KNOWN_UNREACHABLE 条目并更新文档 §7.4" % p) + bad.extend(fixed) + return len(want), bad, log + + +def _dump_trace(log): + """轨迹只在 --trace 给了路径时落盘(默认不往仓库里写东西)。""" + if not TRACE: + return 0 + try: + d = os.path.dirname(os.path.abspath(TRACE)) + if d and not os.path.isdir(d): + os.makedirs(d) + with open(TRACE, "w", encoding="utf-8") as f: + json.dump(log, f, ensure_ascii=False, indent=1) + print("轨迹已写 %s(%d 步)" % (TRACE, len(log))) + except OSError as exc: + print("轨迹写盘失败(不影响判定):", exc) + return len(log) + + +def main(argv): + global TRACE, FAIL_DIR + if "--trace" in argv: + TRACE = argv[argv.index("--trace") + 1] + if "--dump-fail" in argv: + FAIL_DIR = argv[argv.index("--dump-fail") + 1] + clf = Classifier() + if "--help" in argv: + print(__doc__) + return 0 + if "--list" in argv: + for k in sorted(clf.pages): + print(k, "标题=", [t for t, g in clf.titles.items() if g == k]) + return 0 + if "--classify" in argv: + tgt = argv[argv.index("--classify") + 1] + files = [] + if os.path.isdir(tgt): + files = [os.path.join(tgt, x) for x in sorted(os.listdir(tgt)) + if x.endswith(".bin")] + else: + files = [tgt] + for p in files: + fr = Frame.file(p) + st = classify(fr, clf) + print(os.path.basename(p), st["kind"], st["page"] or "-", + "evid=%d" % st["evid"], "ink=%d" % fr.ink) + return 0 + if "--selftest-quick" in argv: + # 提交门禁用的快检档:只核"认屏认得对",不需要模拟器构建产物、不需要台架 + print("[1/2] 仓库黄金参考分类(模拟器与板端同一份 UI 源码 = 语义基线)") + nr, badr = selftest_refs(clf) + print("[2/2] 真机留档帧分类(目录缺则整段跳过,不算失败)") + n, bad1 = selftest_board_frames(clf) + print("汇总(快检):黄金参考 %d 帧错 %d;留档帧 %d 帧错 %d" % (nr, len(badr), n, len(bad1))) + print("闭环导航未跑(需 lvgl_sim.exe):完整自检用 --selftest") + return 1 if (badr or bad1 or nr == 0) else 0 + if "--selftest" in argv: + print("[1/3] 仓库黄金参考分类(模拟器与板端同一份 UI 源码 = 语义基线)") + nr, badr = selftest_refs(clf) + print("[2/3] 真机留档帧分类(%s)" % WALK_DIR) + n, bad1 = selftest_board_frames(clf) + print("[3/3] 模拟器闭环导航(只用状态反馈,不给键序)") + # 覆盖菜单树上**所有绑定了页面的叶子**(不是挑几个好看的):判据脚本自己也得 + # 被同样的标准检查,否则"闭环可用"就只是一次演示 + all_ids = [p for (_l, _n, p, _np) in clf.tree if p] + m, bad2, log = selftest_closed_loop(clf, all_ids) + _dump_trace(log) + print("汇总:黄金参考 %d 帧错 %d;留档帧 %d 帧错 %d;闭环 %d 页失败 %d" + % (nr, len(badr), n, len(bad1), m, len(bad2))) + # 留档帧目录(ci-artifacts/uiwalk,不受版本控制)可以不存在 → 那一段不算失败; + # 但黄金参考与闭环必须真跑过:任一为 0 说明这次"自检"其实什么都没检查 + return 1 if (badr or bad1 or bad2 or nr == 0 or m == 0) else 0 + if "--walk" in argv: + # 台架窗口用:一条命令走完菜单树上**所有绑定页**,每页存一帧 + 一份轨迹。 + # 之所以要"一条命令",是因为 5 分钟窗口里没时间让人逐页手按;跑完只需把 + # uinav/walk/ 与 trace.json 交给 Lead,判据失败在哪一屏、当时认出的是什么, + # 都在文件里,不用现场再猜。 + pid = None + print("⚠ --walk 直连真机:调用方必须已持 bench 锁") + dev = BoardDevice() + ok, bad = 0, [] + try: + ids = [p for (_l, _n, p, _np) in clf.tree if p] + for pid in ids: + nav = Navigator(dev, clf) + try: + fr, st = nav.go_page(pid) + outdir = os.path.join(HERE, "walk") + if not os.path.isdir(outdir): + os.makedirs(outdir) + with open(os.path.join(outdir, pid + ".bin"), "wb") as fp: + fp.write(fr.data) + print(" %-12s OK evid=%d 页码 %s ink=%d" % (pid, st["evid"], st["no"], fr.ink)) + ok += 1 + except Exception as exc: + print(" %-12s FAIL %s" % (pid, exc)) + bad.append(pid) + _dump_trace(nav.log) + finally: + dev.close() + print("走完 %d 页:成功 %d,失败 %d;轨迹 %s" % (ok + len(bad), ok, len(bad), TRACE)) + return 1 if bad else 0 + if "--board" in argv: + if "--to" not in argv: + print("--board 需要 --to <页面 id>(见 --list)") + return 3 + pid = argv[argv.index("--to") + 1] + print("⚠ 直连真机:调用方必须已持 bench 锁(本脚本不申请锁、不碰串口与烧录)") + dev = BoardDevice() + try: + nav = Navigator(dev, clf) + fr, st = nav.go_page(pid) + print("到达", pid, "kind=", st["kind"], "evid=", st["evid"], "ink=", fr.ink) + out = argv[argv.index("--grab") + 1] + "_" + pid + ".bin" if "--grab" in argv else None + if out: + with open(out, "wb") as f: + f.write(fr.data) + print("帧已存", out) + _dump_trace(nav.log) + finally: + dev.close() + return 0 + print("没有要做的事:给 --selftest / --classify <文件或目录> / --board --to <页> / --list") + return 3 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:]))