#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ check_content_pages.py — 叶子内容页像素黄金参考回归 + 文本级断言 + 往返池水位门禁。 用法: python simulator/tests/check_content_pages.py # 文本断言 + 与本地黄金参考逐字节比对 python simulator/tests/check_content_pages.py --update # 有意改视觉后刷新黄金参考(改动会进 diff) 前置:simulator 已构建(build/Debug/lvgl_sim.exe 存在);纯标准库,任意 python3。 ref/*.bin 是**入库的像素黄金参考**(2026-10-22 起):新克隆直接可跑,无需先生成。 缺失 = 工作树不完整(`git checkout -- simulator/tests/ref/`),不是"首次运行"。 原理:① 文本级断言——解析 ui_font_cn_12.c 字形,把期望字符串(标题/标签/值/页脚)在截图 对应 y 带内做精确匹配;不依赖 ref/,先于像素比对运行,失败即中止(防误刷参考)。 ② 每页用键序注入打开(info=e / ac=dee / dc=dede / pwd=ddeee 等,见 PAGES),无头截图 I1(160x160) 与 ref/shot_.bin 逐字节比对——任何像素变化即 FAIL(含字体、几何、颜色)。 另做开页-关页往返池水位门禁:`es`(info)与 `ddeeesll`(密码页,ESC 后 ←← 弹出路上压的 2 个菜单框回根框,完全还原初始态)后 used 须等于 POOL_AFTER_ROUNDTRIP。 黄金参考更新流程(有意改视觉时):先肉眼看图确认新效果正确,再 --update 刷新, 刷新即改基准 → `git diff --stat simulator/tests/ref` 必须逐页过目并在提交正文说明; 最后 regen_ref_png.py 重生成目检 PNG;误报排查见 docs/UI设计/UI_自动化测试流程.md。 退出码:0 全 PASS;1 文本断言失败/有页不一致/池水位漂移;2 运行/环境错误(exe 缺失、参考缺失)。 """ import os import re import shutil import subprocess import sys import tempfile from pathlib import Path # lvgl_sim.exe 相对本脚本定位(simulator/tests/ -> simulator/build/Debug/) EXE = Path(__file__).resolve().parents[1] / "build" / "Debug" / "lvgl_sim.exe" REF_DIR = Path(__file__).resolve().parent / "ref" # 工厂默认 = 黄金参考的真源:Drivers/BSP/NETCFG/net_cfg.c 的 NET_CFG_DEF_* 宏。 # run_sim 一律在空临时目录跑(无 ini → 模拟器按编译期宏显示),故断言值必须与该宏 # 一致;这里现读现算,改默认 IP 只动 C 宏,本脚本零同步(2026-11-02 教训:本文件 # 曾钉死 7 处补零字面量,IP 迁移时逐处爆红)。 _NETCFG_C = Path(__file__).resolve().parents[2] / "Drivers" / "BSP" / "NETCFG" / "net_cfg.c" def _read_net_cfg_default(name): """抽取 `#define name { a, b, c, d }` 四字节(容忍 u 后缀/任意空白)。 解析失败立即报错——绝不回退到写死旧值(宁可红在明处)。 """ pat = r"#define\s+%s\s*\{\s*(\d+)u?\s*,\s*(\d+)u?\s*,\s*(\d+)u?\s*,\s*(\d+)u?\s*\}" % name m = re.search(pat, _NETCFG_C.read_text(encoding="utf-8-sig")) if not m: raise RuntimeError("net_cfg.c 的 %s 解析失败:宏形态变了就同步本函数的正则(勿改回写死值)" % name) return tuple(int(g) for g in m.groups()) def _fmt_ip03(octets): """net_cfg_fmt_ipv4 的既有口径:后三段补零成 3 位、首段原样(同一格式化真源)。""" return "%d.%03d.%03d.%03d" % octets EXP_IP = _fmt_ip03(_read_net_cfg_default("NET_CFG_DEF_IP")) EXP_MASK = _fmt_ip03(_read_net_cfg_default("NET_CFG_DEF_MASK")) EXP_GW = _fmt_ip03(_read_net_cfg_default("NET_CFG_DEF_GW")) EXP_IP_TAIL = EXP_IP.split(".", 1)[1] # 编辑态首字节被光标反显覆盖,只断言尾部 # (页名, 注入键序):与 ui_menu.c 节点映射/路由一致(u=上 d=下 l=左 r=右 e=回车 s=ESC); # 密码页 = 受保护叶子(参数定值/整定/参数 node 8,默认密码 123) PAGES = [ ("info", "e"), # 装置信息(node 1) ("ac", "dee"), # 交流量(node 3,实时数据下第 1 项) ("dc", "dede"), # 直流量(node 4,实时数据下第 2 项) # 遥信量(实时数据下第 3 项):d 到实时数据 → e 进(选中交流量)→ dd 到遥信量 → e 开页; # yxp2 再多按 8 次下移,让选中行跨过页边界落到第 2 页首行 ("yx", "dedde"), ("yxp2", "dedde dddddddd"), # SOE 记录(node 51,记录查询下第 1 项):6 下进记录查询 → e → 首项 SOE记录 → e; # 数据 = simulator/main.c 的固定 3 条假记录(含 type 7 未标定闭锁这条非元件支路) ("soe", "dddddde e"), # 参数定值 → 查看 的四个只读页(批 3):dd 进"参数定值"→ e 进(选中"整定")→ d 到"查看" # → e 进(选中"参数")→ e 开页;定值/控制字/软压板 依次多按 d 选到该行再 e ("vparam", "ddedee"), ("vset", "ddedede"), ("vword", "ddededde"), ("vplate", "ddededdde"), # ── 道F task-2:整定 4 页(参数定值 → 整定 → *,#P 叶,123=修改密码可编辑)── # 导航公共前缀:dd 进"参数定值"→ e 进(选中"整定")→ e 进整定子框(选中"参数") # → e 进密码页;密码 123 = "uruuruuue"(三框各 +1、两次右移、确认,与 netcfg 同串法); # 000(直接 e)= 查看密码 → 只读。定值/控制字/软压板 在整定子框里多按 d 选到该行再 e。 # 保存行在第 (行数) 行:参数 9 行 = 2 页 → 8×d 落到保存行;定值 19 行 = 3 页。 ("sparam_p2", "ddeee uruuruuue dddddddd"), # 参数整定第 2 页:保存行 ENTER ("sparam_ro", "ddeee e"), # 000 只读:无保存行、无徽标 ("sparam_edit", "ddeee uruuruuue d e"), # PT二次 进编辑(值列是编辑串,无单位) ("sparam_oob", "ddeee uruuruuue d e l l d e"), # 编到 0(< min=1)→ ENTER 拒退 + 越界 ("sparam_save", "ddeee uruuruuue e u e dddddddd e"), # PT一次 +1 → 保存行 ENTER → OK ("sparam_landed", "ddeee uruuruuue e u e dddddddd e u"), # 保存后回首屏:新值 10001V 已在帧里 ("sset", "ddeed e uruuruuue"), # 定值整定首屏(18 项 + 保存 = 3 页) ("sword_cycle", "ddeedde uruuruuue e"), # 控制字 二态行 ENTER:跳闸 → 告警 ("splate", "ddeeddde uruuruuue"), # 软压板整定首屏(10 项 + 保存 = 2 页) ("pwd", "ddeee"), # 密码页初始态:3 框 000、光标首框 ("pwe", "ddeee uru"), # 密码页调值+移光标:110、光标第 2 框 # 密码页输 123:u / r / u,u / r / u,u,u / e → 关页并按授权"续做进入"。 # ⚠ **道F task-2 起这张图的落点变了**(黄金参考已 --update 刷新,属有意改判据): # 整定 4 叶绑定内容页之前,123 确认后走 menu_password_ok 的"无内容页 → 压空白 # 终点框"分支(屏上是 4 框菜单,整定叶本身是个死叶子);绑定之后走 # "有内容页 → ui_content_page_open(page, UI_PERM_MODIFY)"分支,落在**整定-参数页**。 # 也就是说这张图现在同时钉住两件事:密码页授权链正确 + 123 真的能开可编辑整定页。 # 旧图并非"错",是那条分支从此不再可达——留档见 2026-10-26 提交正文。 ("pwk", "ddeee uruuruuue"), ("pwc", "ddeee ue"), # 密码页 100 不一致 → 清空重输(回到 000 初始态像素) # 网口设置(node 30,通讯参数下第 2 项,需密码): # dddddd = 根框下移 5 至通讯参数;e 进框;d 选中网口设置;e 进密码页 ("netcfg", "ddddde de uruuruuue"), # 修改密码 123 → 可编辑态(工厂默认值) ("netcfg_ro", "ddddde de e"), # 查看密码 000 → 只读态 ("netcfg_edit", "ddddde de uruuruuue e"), # 编辑模式:IP 首字节光标反显 ("netcfg_save", "ddddde de uruuruuue dddd e"), # 保存行 ENTER → 值列 OK # ---- 道F task-3 装置维护三页(根菜单第 5 项 = 4 下;子项 0/1/3 = 时钟/复归/清除) # 键序读法与上面同源:e 进子菜单后再 e 打开叶子(叶子带 #P → 先落密码页), # "uruuruuue" = 修改口令 123(可编辑态),单一个 e = 查看口令 000(只读态)。 # 空格是分组占位:模拟器键映射把空格当可读分隔跳过(simulator/main.c 键映射) ("clock", "dddd ee uruuruuue"), ("clock_ro", "dddd ee e"), ("clock_edit", "dddd ee uruuruuue e"), ("clock_save", "dddd ee uruuruuue ddddd e u e d e"), # 秒行 +1 → 保存 ("clock_noclock", "dddd ee uruuruuue"), # 同 clock 键序 + --noclock ("reset", "dddd ede uruuruuue"), ("reset_2nd", "dddd ede uruuruuue e"), ("reset_done", "dddd ede uruuruuue ee"), ("reset_ro", "dddd ede e"), ("clear", "dddd eddde uruuruuue"), ("clear_2nd", "dddd eddde uruuruuue e"), ("clear_done", "dddd eddde uruuruuue ee"), ("clear_ro", "dddd eddde e"), # ---- 道F task-7 遥测整定两页(根菜单第 4 项 = 3 下;子项 0/1 = 死区/系数) # 键序读法与 task-3 三页同源:e 进子菜单后再 e 开叶子(#P → 先落密码页), # "uruuruuue" = 修改口令 123(可编辑)。 # 死区 9 行 + 保存 = 10 行(2 页);系数 8 行 + 保存 = 9 行(2 页,保存行独在第 2 页)。 # ⚠ wait/ok 两张**必须成对存在**:脏位在模拟器由 sim_yt_pump 清(镜像真机 # defaultTask 每轮无条件落盘),wait 态靠 --yt-nopump 停 pump 拍下来—— # 只钉 OK 就是"只钉最好看那一帧"(Lead 裁定 task-7 派发④原文), # 真机 WAIT 窗口毫秒级、人工永远补拍不到,只有这里替它留了像素证据。 ("deadband", "ddd ee uruuruuue"), # 首屏(选中首行 UA死区 0.500V) ("deadband_wait", "ddd ee uruuruuue e uu e ddddddddd e"), # UA→0.502 提交 → 保存行 ENTER:WAIT(+--yt-nopump) ("deadband_ok", "ddd ee uruuruuue e uu e ddddddddd e"), # 同键序、pump 正常 → OK(脏位真被清) ("calib", "ddd e de uruuruuue"), # 首屏 8 行系数(153uV = ROM TPAFE 整 µV) ("calib_wait", "ddd e de uruuruuue e u e dddddddd e"), # UA→154 提交 → 保存行:WAIT(+--yt-nopump) ("calib_ok", "ddd e de uruuruuue e u e dddddddd e"), # 同键序、pump 正常 → OK # 拒后**再**移到保存行(不按保存):一张图同时钉"粘滞 ERR 词"与"越界行反显" # (r0 反显要等光标移开才单独可见——与选中白条重叠时像素分不出来,这是一开始 # 拍在首屏的图目检后改出来的键序);不按保存是因为按键会重新评估、ERR 会被 # 新一轮 WAIT/OK 覆盖——ERR 的像素证据只有这条窗口是真的 ("calib_reject", "ddd e de uruuruuue e ddd l ddddd l d e dddddddd"), # ── 道G task-9:通讯设置页(comm,#P 叶;[comm] 工厂默认 CA=1 / 端口 2404)── # 导航:ddddd 落到"通讯参数"(主界面第 6 个节点)→ e 进(首叶通讯设置被选中) # → e 进密码页;123 = "uruuruuue" 可编辑,000(直接 e)只读。共 3 行不分页, # 保存行恒在第 3 槽(y 带 52..66)。 ("comm", "ddddd ee uruuruuue"), # 首屏:1 / 2404 / 保存行 ENTER ("comm_ro", "ddddd ee e"), # 000 只读:无保存行 ("comm_edit", "ddddd ee uruuruuue e"), # CA 行进编辑(个位光标反显) ("comm_reject", "ddddd ee uruuruuue e d e"), # CA 编到 0(< min=1)→ ENTER 拒退 ("comm_wait", "ddddd ee uruuruuue e u e dd e"), # CA 1→2 提交 → 下移到保存行 ENTER:WAIT(+--comm-nopump) ("comm_ok", "ddddd ee uruuruuue e u e dd e"), # 同键序、pump 正常 → OK(脏位真被清) # ── 接线道 task-6:记录 5 页 + 运行报告(六叶同一笔绑 #page= 注记)──────────────── # 键序 = 主界面第 7 项「记录查询」(6 下)→ e 进子框(进框后首项 SOE记录 即被选中) # → 再按 n 下选到目标叶 → e 开页。实测下数与 node 号严格对应: # 1=事故 34 / 2=操作 35 / 3=保护告警 36 / 4=保护启动 37 / 5=遥控 38 / # 6=自检 39 / 7=运行 40 / 8=运行报告 41(与 Lead 交接口径同源,逐个键序实测过)。 # 这五页在**模拟器侧没有写入方**(sim_rec_count 对这些编号恒 0)→ 拍到的是空态判据: # 伪行"无记录"落在第 0 行,而第 0 行正是选中行(白底黑字)→ 文本断言一律 inv=True # (实测 inv=False 匹不上,这是 ui_content_page 选中带的实现事实,不是 bug)。 ("accident", "dddddde d e"), ("oper", "dddddde dd e"), ("alarm", "dddddde ddd e"), ("start", "dddddde dddd e"), ("yk", "dddddde ddddd e"), # 运行报告(node 41):12 行 / UI_LIST_ROWS=8 → 真 2 页。**四个数逐个挂范围标签**是判据 # (docs/任务规划/UI补齐.md §2「运行报告」行),所以两张都必须拍——首屏钉上电次数/ # 计数范围/复位原因/主站接入 + 范围标签"本次开机",第 2 页钉 104 另外三枚(发送帧数 # 128 / 发送失败 0 / 接收字节 4096)与页脚真实页码 2/2。四枚假数据值互不相同是刻意的: # 行错位/串行只有"像素 + 逐行文本断言"两把尺子同时才照得出来(Lead 裁定 2026-10-28, # 因此当时用例是 53+7=60 张,不是 53+6=59;现集合 66 张,见 PAGES 表)。 ("runreport", "dddddde dddddddd e"), ("runreport_p2", "dddddde dddddddd e dddddddd"), # 页内再 8 下 → 第 2 页(选中落 r0=发送帧数) # ── 故障录波 UI 道(2026-10-29):手动录波三态 + 回放三态(真实波形/空态/坏段)── # 导航:装置维护 = 根第 5 项(4 下)→ e 进子框(时钟被选中)→ dd 到第 3 项「手动录波」; # 记录查询 = 根第 7 项(6 下)→ e 进(SOE 被选中)→ 九下落到组末新叶「录波回放」(node 42)。 # 假数据纪律:模拟器泵**喂真 ring + 走真 store 打包链**(simulator/main.c 录波假源块注释), # 回放页拍到的是真阶梯(每点 3 px)、真 0 线、真峰值 mV——不是手画折线的像素摆拍。 # wave_play_bad 的「不可读」由**翻转数据区一字节让真双 CRC 判废**产生(--wave-bad), # 不是把"不可读"写死在假件返回值里;wave_play_empty 用 --wave-empty 不建段。 ("wave_manual", "dddd edde uruuruuue"), # 确认链首屏(stage0 问句) ("wave_manual_done", "dddd edde uruuruuue ee"), # 两次 ENTER → 受理回报行 ("wave_manual_ro", "dddd edde e"), # 查看密码 000 只读 → LOCK 拒执行 ("wave_play", "dddddde ddddddddd e"), # 数值最大段 UA:正弦阶梯 + 0 线 + 光标 ("wave_play_bad", "dddddde ddddddddd e"), # 坏段:显式「该段不可读」,绝不画线 ("wave_play_empty", "dddddde ddddddddd e"), # 无段:显式「无录波段」 ] # 文本级断言表:页名 -> [(名称, 期望串, y 带, 是否反显)] # 只断言确定的内容;标签与值分列断言(两列 x 不同,不能拼成一串)。 # 覆盖本工程新增/易缺字的关键文案(网口设置/地址/掩码/逗号/保存/OK 等)。 TEXTS = { # ---- 道F task-3 装置维护三页。行槽沿用列表页口径(首行 24+14i), # 操作页四行 y = 44/66/88/110(Drivers/BSP/UI/ui_page_action.c 的 AC_*_Y)。 # 只断言"这一屏此刻该有什么",不拿参考图当断言(参考图会跟着实现一起错) "clock": [ ("标题", "时钟设置", (0, 24), False), ("r0名", "年", (24, 38), True), ("r0值", "2026", (24, 38), True), ("r1名", "月", (38, 52), False), ("r1值", "10", (38, 52), False), ("r2名", "日", (52, 66), False), ("r2值", "25", (52, 66), False), ("r3名", "时", (66, 80), False), ("r3值", "08", (66, 80), False), ("r4名", "分", (80, 94), False), ("r4值", "00", (80, 94), False), ("r5名", "秒", (94, 108), False), ("r5值", "00", (94, 108), False), ("r6名", "保存", (108, 122), False), ("r6值", "ENTER", (108, 122), False), ("页脚", "第 1 页,共 1 页", (140, 160), False), ], "clock_ro": [ ("标题", "时钟设置", (0, 24), False), ("r0名", "年", (24, 38), True), ("r0值", "2026", (24, 38), True), ("r5名", "秒", (94, 108), False), ("r5值", "00", (94, 108), False), ("页脚", "第 1 页,共 1 页", (140, 160), False), ], "clock_edit": [ ("标题", "时钟设置", (0, 24), False), ("r0名", "年", (24, 38), True), ], "clock_save": [ ("标题", "时钟设置", (0, 24), False), ("r0名", "年", (24, 38), False), ("r0值", "2026", (24, 38), False), ("r5名", "秒", (94, 108), False), ("r5值改后", "01", (94, 108), False), ("r6名", "保存", (108, 122), True), ("r6值", "OK", (108, 122), True), ], "clock_noclock": [ ("标题", "时钟设置", (0, 24), False), ("无时钟徽标", "无时钟", (122, 140), False), ("页脚未被徽标压住", "第 1 页,共 1 页", (140, 160), False), ("r0名", "年", (24, 38), True), ("r0值空", "--", (24, 38), True), ("r6名", "保存", (108, 122), False), ], "reset": [ ("标题", "强制复归", (0, 24), False), ("问句", "确认复归保护?", (38, 60), False), ("观测行", "保护动作", (82, 102), False), ("键提示", "ENTER=确认 ESC=取消", (104, 126), False), ], "reset_2nd": [ ("标题", "强制复归", (0, 24), False), ("问句", "确认复归保护?", (38, 60), False), ("二次确认", "二次确认", (60, 82), False), ("观测行", "保护动作", (82, 102), False), ], "reset_done": [ ("标题", "强制复归", (0, 24), False), ("结果行", "已请求复归", (38, 60), False), ("观测行归零", "保护动作", (82, 102), False), ("返回提示", "ENTER=返回", (104, 126), False), ], "reset_ro": [ ("标题", "强制复归", (0, 24), False), ("问句", "确认复归保护?", (38, 60), False), ("只读态标", "LOCK", (60, 82), False), ("只读提示", "ESC=返回", (104, 126), False), ], "clear": [ ("标题", "清除记录", (0, 24), False), ("r0名", "运行记录", (24, 38), True), ("r1名", "自检记录", (38, 52), False), ("r3名", "保护告警", (66, 80), False), ("r5名", "操作记录", (94, 108), False), ("r6名", "遥控记录", (108, 122), False), ("页脚", "第 1 页,共 1 页", (140, 160), False), ], "clear_2nd": [ ("标题", "清除记录", (0, 24), False), ("确认问", "确认?", (24, 38), True), ], "clear_done": [ ("标题", "清除记录", (0, 24), False), ("结果", "OK", (24, 38), True), ("页脚", "第 1 页,共 1 页", (140, 160), False), ], "clear_ro": [ ("标题", "清除记录", (0, 24), False), ("r0名", "运行记录", (24, 38), True), ("r6名", "遥控记录", (108, 122), False), ], # ---- 道F task-7 遥测整定两页。行槽沿用列表页口径(24 + 14i),单位串由 # ui_w_setlist 直接贴数值尾("0.500V"无空格),µV 在屏上是 ASCII "uV" "deadband": [ ("标题", "遥测死区", (0, 24), False), ("r0名", "UA死区", (24, 38), True), ("r0值", "0.500V", (24, 38), True), ("r4名", "IA死区", (80, 94), False), ("r4值", "0.005A", (80, 94), False), ("r7名", "IN死区", (122, 136), False), ("r7值", "0.005A", (122, 136), False), ("页脚", "第 1 页,共 2 页", (140, 160), False), ], "deadband_wait": [ ("标题", "遥测死区", (0, 24), False), # 第 2 页窗口:槽 0 = Hz死区行(跟随真源、本轮未动),槽 1 = 保存行(选中反显) ("r0名", "Hz死区", (24, 38), False), ("r0值", "0.500Hz", (24, 38), False), ("保存名", "保存", (38, 52), True), ("保存值", "WAIT", (38, 52), True), ("页脚", "第 2 页,共 2 页", (140, 160), False), ], "deadband_ok": [ ("标题", "遥测死区", (0, 24), False), ("r0名", "Hz死区", (24, 38), False), ("保存名", "保存", (38, 52), True), ("保存值", "OK", (38, 52), True), ("页脚", "第 2 页,共 2 页", (140, 160), False), ], "calib": [ ("标题", "遥测系数", (0, 24), False), ("r0名", "UA系数", (24, 38), True), ("r0值", "153uV", (24, 38), True), ("r7名", "IN系数", (122, 136), False), ("r7值", "153uV", (122, 136), False), ("页脚", "第 1 页,共 2 页", (140, 160), False), ], "calib_wait": [ ("标题", "遥测系数", (0, 24), False), ("保存名", "保存", (24, 38), True), ("保存值", "WAIT", (24, 38), True), ("页脚", "第 2 页,共 2 页", (140, 160), False), ], "calib_ok": [ ("标题", "遥测系数", (0, 24), False), ("保存名", "保存", (24, 38), True), ("保存值", "OK", (24, 38), True), ("页脚", "第 2 页,共 2 页", (140, 160), False), ], "calib_reject": [ ("标题", "遥测系数", (0, 24), False), # 第 2 页(光标移到保存行、未按键):k=0 被执行侧拒 → **值纹丝不动** # (副本未动、不置脏——"写值与置脏成对"的另一半判据)+ 保存行粘滞 ERR; # r0 的越界反显在第 1 页像素里(文本断言不猜反显形状,逐字节比对钉住) ("保存名", "保存", (24, 38), True), ("保存值", "ERR", (24, 38), True), ("页脚", "第 2 页,共 2 页", (140, 160), False), ], # v2.0(道E task-15 回炉):6 行 Flash 字面量 -> 8 行真值;标签去全角冒号(要给值列 # 让出 MAC 的 102 px),行槽 = 38 + 13i。值全部来自模拟器固定帧(simulator/main.c 的 # sim_publish_data_frames)与 net_cfg 出厂副本 —— 逐行断言等于把这两处口径钉在像素侧。 "info": [ ("标题", "装置信息", (0, 24), False), ("副标题", "馈线自动化终端", (22, 40), False), ("r0名", "终端型号", (38, 51), False), ("r0值", "F30", (38, 51), False), ("r1名", "软件版本", (51, 64), False), ("r1值", "SV0.010", (51, 64), False), ("r2名", "硬件版本", (64, 77), False), ("r2值", "HW0.010", (64, 77), False), # 软件校验:构建期 CRC 步骤未落地 -> "--"(曾抄参考项目的 "4454",那是别人家镜像) ("r3名", "软件校验", (77, 90), False), ("r3值", "--", (77, 90), False), ("r4名", "程序日期", (90, 103), False), ("r4值", "Oct 25 2026", (90, 103), False), ("r5名", "MAC地址", (103, 116), False), ("r5值", "00:00:00:00:00:00", (103, 116), False), # net_cfg_fmt_ipv4 的既有口径是三段补零(与网口设置页同一格式化真源),照实断言 ("r6名", "IP地址", (116, 129), False), ("r6值", EXP_IP, (116, 129), False), ("r7名", "运行时长", (129, 142), False), ("r7值", "00:00:00", (129, 142), False), ("页脚", "第 1 页,共 1 页", (140, 160), False), ], # 交流量:7 行快照真值(24 行参考图假骨架已删)。eng=0(未标定)期间值列是 ADC 引脚 # 电压,第 7 行必须把口径写在屏上;UB/UC/IB/IC/IN 削顶无采样源,本页不列它们 # (列出来就是 5 行 "--" 的假骨架,同 §3.1.1 的取舍理由)。Hz 未测出 -> "--"。 "ac": [ ("标题", "交流量", (0, 24), False), ("r0名", "Ua", (24, 38), True), ("r0值", "0.812", (24, 38), True), ("r1名", "U0", (38, 52), False), ("r1值", "0.005", (38, 52), False), ("r2名", "Ia", (52, 66), False), ("r2值", "0.046", (52, 66), False), ("r3名", "Ia2", (66, 80), False), ("r3值", "0.002", (66, 80), False), ("r4名", "Ia5", (80, 94), False), ("r4值", "0.001", (80, 94), False), ("r5名", "Hz", (94, 108), False), ("r5值", "--", (94, 108), False), ("r6名", "标定", (108, 122), False), ("r6值", "未标定", (108, 122), False), ("页脚", "第 1 页,共 1 页", (140, 160), False), ], # SOE 记录:每条记录 2 行(时标 / 元件名+动作-返回)。三条假记录刻意覆盖三条显示 # 支路:元件事件、元件复归、**非元件**的未标定闭锁(type 7,类型名进名列)。 # 第 5 行是"新增事件类型有没有接上屏"的像素级证据——只测 1/2 的话,7/8 写错也绿。 "soe": [ ("标题", "SOE记录", (0, 24), False), ("r0时标", "2026-10-25 08:00:01", (24, 38), True), ("r1名", "过流Ⅰ段", (38, 52), False), ("r1值", "动作", (38, 52), False), ("r2时标", "2026-10-25 08:00:03", (52, 66), False), ("r3名", "过流Ⅰ段", (66, 80), False), ("r3值", "返回", (66, 80), False), ("r4时标", "2026-10-25 08:00:05", (80, 94), False), ("r5名", "未标定闭锁", (94, 108), False), ("页脚", "第 1 页,共 1 页", (140, 160), False), ], # ── 批 4:遥信量(33 点固定名表,5 页)── # 第 1 页 8 行**全是 --**(位置 1~4 是无硬件的双点,YX5~8 点表未定义)——这不是画面坏了, # 是 V0.1 的真实状态:27 个点没有开入来源。方案 B(只显有效点)会把这件事藏起来,方案 A # 让现场能逐点对、也能一眼看出缺哪些点(取舍见 页面扩充详细方案.md §3.1.3)。 # 第 2 页才出现有来源的点(故障总 / 过流Ⅰ段 = 复归);两页合起来同时钉住分页数学: # 33 行 / 8 行每页 = 5 页,页码算错这里直接响。 "yx": [ ("标题", "遥信量", (0, 24), False), ("r0名", "位置1", (24, 38), True), ("r0值", "--", (24, 38), True), ("r1名", "位置2", (38, 52), False), ("r1值", "--", (38, 52), False), ("r2名", "位置3", (52, 66), False), ("r2值", "--", (52, 66), False), ("r3名", "位置4", (66, 80), False), ("r3值", "--", (66, 80), False), ("r4名", "YX5", (80, 94), False), ("r4值", "--", (80, 94), False), ("r5名", "YX6", (94, 108), False), ("r5值", "--", (94, 108), False), ("r6名", "YX7", (108, 122), False), ("r6值", "--", (108, 122), False), ("r7名", "YX8", (122, 136), False), ("r7值", "--", (122, 136), False), ("页脚", "第 1 页,共 5 页", (140, 160), False), ], "yxp2": [ ("标题", "遥信量", (0, 24), False), ("r0名", "YX9", (24, 38), True), ("r0值", "--", (24, 38), True), ("r1名", "YX10", (38, 52), False), ("r1值", "--", (38, 52), False), ("r2名", "YX11", (52, 66), False), ("r2值", "--", (52, 66), False), ("r3名", "YX12", (66, 80), False), ("r3值", "--", (66, 80), False), ("r4名", "YX13", (80, 94), False), ("r4值", "--", (80, 94), False), ("r5名", "YX14", (94, 108), False), ("r5值", "--", (94, 108), False), ("r6名", "故障总", (108, 122), False), ("r6值", "复归", (108, 122), False), ("r7名", "过流Ⅰ段", (122, 136), False), ("r7值", "复归", (122, 136), False), ("页脚", "第 2 页,共 5 页", (140, 160), False), # 选中行落到第 2 页首行(YX9);页码同时钉住 33/8 = 5 页 ], # ── 批 3:参数定值 → 查看 四页(数据 = prot_set 出厂默认,值串带单位)── # 名与值两列都断:值列断的是换算后的小数位与单位——光看像素,没人会去核 0.95 是不是被 # Q10 存成了 0.94921875。控制字页还多钉一层:Ⅰ/Ⅱ 段默认是「跳闸」、Ⅲ 与过负荷段默认是 # 「告警」,这种两两一组恰好把行表与枚举序对齐钉住(行表整体错位就不可能出现该组合) "vparam": [ ("标题", "参数", (0, 24), False), ("r0名", "PT一次", (24, 38), True), ("r0值", "10000V", (24, 38), True), ("r1名", "PT二次", (38, 52), False), ("r1值", "100V", (38, 52), False), # r2 值我先前肉眼读成 "1000A",字形匹配器证明值列只有 4 个字符位(命中 x=101/107/113/119): # CT 一次额定是 100 A —— PT 10000/100 V 加 CT 100/5 A 正是 10 kV 馈线终端的变比自洽。 # 断言写"匹配器能证明的",不写"我看图觉得的"(这类差异肉眼发现不了,只有匹配器会响) ("r2名", "CT一次", (52, 66), False), ("r2值", "100A", (52, 66), False), ("r3名", "CT二次", (66, 80), False), ("r3值", "5A", (66, 80), False), ("r4名", "过量返回", (80, 94), False), ("r4值", "0.95", (80, 94), False), ("r5名", "欠量返回", (94, 108), False), ("r5值", "1.05", (94, 108), False), ("r6名", "过频返回", (108, 122), False), ("r6值", "0.99", (108, 122), False), ("r7名", "低频返回", (122, 136), False), ("r7值", "1.01", (122, 136), False), ("页脚", "第 1 页,共 1 页", (140, 160), False), ], "vset": [ ("标题", "定值", (0, 24), False), ("r0名", "Ⅰ段电流", (24, 38), True), ("r0值", "10.00A", (24, 38), True), ("r1名", "Ⅰ段延时", (38, 52), False), ("r1值", "0ms", (38, 52), False), ("r2名", "Ⅱ段电流", (52, 66), False), ("r2值", "5.00A", (52, 66), False), ("r3名", "Ⅱ段延时", (66, 80), False), ("r3值", "500ms", (66, 80), False), ("r4名", "Ⅲ段电流", (80, 94), False), ("r4值", "2.00A", (80, 94), False), ("r5名", "Ⅲ段延时", (94, 108), False), ("r5值", "3000ms", (94, 108), False), ("r6名", "过负荷电流", (108, 122), False), ("r6值", "1.00A", (108, 122), False), ("r7名", "过负荷延时", (122, 136), False), ("r7值", "10000ms", (122, 136), False), ("页脚", "第 1 页,共 3 页", (140, 160), False), # 18 行 = 3 页 ], "vword": [ ("标题", "控制字", (0, 24), False), ("r0名", "Ⅰ段跳闸", (24, 38), True), ("r0值", "跳闸", (24, 38), True), ("r1名", "Ⅱ段跳闸", (38, 52), False), ("r1值", "跳闸", (38, 52), False), ("r2名", "Ⅲ段跳闸", (52, 66), False), ("r2值", "告警", (52, 66), False), ("r3名", "过负荷跳闸", (66, 80), False), ("r3值", "告警", (66, 80), False), ("页脚", "第 1 页,共 1 页", (140, 160), False), ], "vplate": [ ("标题", "软压板", (0, 24), False), ("r0名", "Ⅰ段投入", (24, 38), True), ("r0值", "投入", (24, 38), True), ("r1名", "Ⅱ段投入", (38, 52), False), ("r1值", "投入", (38, 52), False), ("r2名", "Ⅲ段投入", (52, 66), False), ("r2值", "投入", (52, 66), False), ("r3名", "过负荷投入", (66, 80), False), ("r3值", "投入", (66, 80), False), ("r4名", "过压投入", (80, 94), False), ("r4值", "投入", (80, 94), False), ("r5名", "低压投入", (94, 108), False), ("r5值", "投入", (94, 108), False), ("r6名", "零序投入", (108, 122), False), ("r6值", "投入", (108, 122), False), ("r7名", "过频投入", (122, 136), False), ("r7值", "投入", (122, 136), False), ("页脚", "第 1 页,共 2 页", (140, 160), False), # 10 行 = 2 页,次页还有低频投入与保护总投入 ], # 直流量:V0.1 板上无直流采样通道 -> 温度取码值并显式带"码"前缀,通道行如实"未配置" "dc": [ ("标题", "直流量", (0, 24), False), ("r0名", "温度", (24, 38), True), ("r0值", "码2048", (24, 38), True), ("r1名", "直流通道", (38, 52), False), ("r1值", "未配置", (38, 52), False), ("页脚", "第 1 页,共 1 页", (140, 160), False), ], "netcfg": [ ("标题", "网口设置", (0, 24), False), ("r0名", "IP地址", (24, 38), True), ("r0值", EXP_IP, (24, 38), True), ("r1名", "子网掩码", (38, 52), False), ("r1值", EXP_MASK, (38, 52), False), ("r2名", "默认网关", (52, 66), False), ("r2值", EXP_GW, (52, 66), False), ("r3名", "MAC地址", (66, 80), False), ("r3值", "00:00:00:00:00:00", (66, 80), False), ("r4名", "保存", (80, 95), False), ("r4值", "ENTER", (80, 95), False), ("页脚", "第 1 页,共 1 页", (140, 160), False), ], "netcfg_ro": [ ("标题", "网口设置", (0, 24), False), ("r0名", "IP地址", (24, 38), True), ("r0值", EXP_IP, (24, 38), True), ("r1名", "子网掩码", (38, 52), False), ("r1值", EXP_MASK, (38, 52), False), ("r2名", "默认网关", (52, 66), False), ("r2值", EXP_GW, (52, 66), False), ("r3名", "MAC地址", (66, 80), False), ("r3值", "00:00:00:00:00:00", (66, 80), False), ("r4名", "保存", (80, 95), False), ("r4值", "ENTER", (80, 95), False), ("页脚", "第 1 页,共 1 页", (140, 160), False), ], "netcfg_edit": [ ("标题", "网口设置", (0, 24), False), ("r0名", "IP地址", (24, 38), True), ("r0值尾", EXP_IP_TAIL, (24, 38), True), # 首字节(192 那段)被光标反显覆盖 ("r1名", "子网掩码", (38, 52), False), ("r4名", "保存", (80, 95), False), ], # ── 道F task-2:整定 4 页文本级断言 ───────────────────────────────────────── # 值全部来自 simulator/main.c 的固定帧(prot_set 出厂默认 + cal_block 恒 1), # 每条断言都是"某个具体事实":徽标在不在、保存行在第几页、越界提示、改值后落没落。 # ⚠ 编辑态的值列**没有单位后缀**(ui_w_setlist_refresh 编辑分支走 # ui_w_editor_format,非编辑分支才拼 unit)——所以 sparam_edit 断 "100" 而不是 # "100V":这条差异本身就是"这一行正在编辑"的像素证据,别把它"顺手统一"掉。 "pwk": [ ("标题", "参数", (0, 24), False), ("徽标", "未标定", (0, 24), False), # cal_block=1:整定页必须看得见 ("r0名", "PT一次", (24, 38), True), ("r0值", "10000V", (24, 38), True), ("r4名", "过量返回", (80, 94), False), ("r4值", "0.95", (80, 94), False), ("r7名", "低频返回", (122, 136), False), ("r7值", "1.01", (122, 136), False), ("页脚", "第 1 页,共 2 页", (140, 160), False), # 8 项 + 保存行 = 9 行 → 2 页 ], "sparam_p2": [ ("徽标", "未标定", (0, 24), False), ("保存名", "保存", (24, 38), True), ("保存值", "ENTER", (24, 38), True), # 从未投递 → IDLE ("页脚", "第 2 页,共 2 页", (140, 160), False), ], "sparam_ro": [ # 000 查看密码:同一份描述符的只读分支——有行、无保存行、无徽标(见 ABSENT) ("标题", "参数", (0, 24), False), ("r0名", "PT一次", (24, 38), True), ("r0值", "10000V", (24, 38), True), ("r7名", "低频返回", (122, 136), False), ("页脚", "第 1 页,共 1 页", (140, 160), False), # 无保存行 → 8 行正好 1 页 ], "sparam_edit": [ # 编辑态的可见特征:选中带反显(白底黑字)+ 值列**没有单位后缀**(见 ABSENT) ("r1名", "PT二次", (38, 52), True), ("r0名", "PT一次", (24, 38), False), ("r0值", "10000V", (24, 38), False), ("r3名", "CT二次", (66, 80), False), ("r3值", "5A", (66, 80), False), ], "sparam_oob": [ # PT二次 下限 1:把百位从 1 调到 0 → 0 < min → ENTER 被拒、留在编辑态并打"越界" ("越界提示", "越界", (38, 52), True), ("r1名", "PT二次", (38, 52), True), ("r0值未变", "10000V", (24, 38), False), # 别的行不受影响(编辑只作用选中行) ], "sparam_save": [ ("保存名", "保存", (24, 38), True), ("保存值", "OK", (24, 38), True), # 投递 → drain 落定 → 终态 OK ("页脚", "第 2 页,共 2 页", (140, 160), False), ], "sparam_landed": [ # 保存后再按一次上移回到首屏:新值必须已在参数帧里(写路径的完整闭环) ("r0值新", "10001V", (24, 38), False), ("r1值旧", "100V", (38, 52), False), # 只提交改动行:没动的行还是旧值 ("页脚", "第 1 页,共 2 页", (140, 160), False), ], "sset": [ ("标题", "定值", (0, 24), False), ("徽标", "未标定", (0, 24), False), ("r0名", "Ⅰ段电流", (24, 38), True), ("r0值", "10.00A", (24, 38), True), ("r7名", "过负荷延时", (122, 136), False), ("r7值", "10000ms", (122, 136), False), ("页脚", "第 1 页,共 3 页", (140, 160), False), # 18 项 + 保存行 = 19 行 → 3 页 ], "sword_cycle": [ # 二态行 ENTER:跳闸(1) → **回绕到 告警(0)**;未动的行保持 跳闸(只提交改动 id) ("标题", "控制字", (0, 24), False), ("r0名", "Ⅰ段跳闸", (24, 38), True), ("r0值", "告警", (24, 38), True), ("r1名", "Ⅱ段跳闸", (38, 52), False), ("r1值", "跳闸", (38, 52), False), ("r3名", "过负荷跳闸", (66, 80), False), ("r3值", "告警", (66, 80), False), ("保存名", "保存", (80, 95), False), ("保存值", "ENTER", (80, 95), False), ("页脚", "第 1 页,共 1 页", (140, 160), False), ], "splate": [ ("标题", "软压板", (0, 24), False), ("徽标", "未标定", (0, 24), False), ("r0名", "Ⅰ段投入", (24, 38), True), ("r0值", "投入", (24, 38), True), ("r7名", "过频投入", (122, 136), False), ("r7值", "投入", (122, 136), False), ("页脚", "第 1 页,共 2 页", (140, 160), False), # 10 项 + 保存行 = 11 行 → 2 页 ], "netcfg_save": [ ("标题", "网口设置", (0, 24), False), ("r0名", "IP地址", (24, 38), False), ("r0值", EXP_IP, (24, 38), False), ("r3名", "MAC地址", (66, 80), False), ("r4名", "保存", (80, 95), True), ("r4值", "OK", (80, 95), True), ], # ── 道G task-9:通讯设置页文本级断言(值 = [comm] 工厂默认的执行侧镜像)── # 编辑/保存行的反显标记跟"选中在哪一行"走:初始与只读选中 r0,按保存后选中 # 停在保存行——inv 口径与 ui_w_setlist 视觉一致,别照抄 netcfg 的行号 "comm": [ ("标题", "通讯设置", (0, 24), False), ("r0名", "公共地址", (24, 38), True), ("r0值", "1", (24, 38), True), ("r1名", "监听端口", (38, 52), False), ("r1值", "2404", (38, 52), False), ("r2名", "保存", (52, 66), False), ("r2值", "ENTER", (52, 66), False), ("页脚", "第 1 页,共 1 页", (140, 160), False), ], "comm_ro": [ ("标题", "通讯设置", (0, 24), False), ("r0名", "公共地址", (24, 38), True), ("r0值", "1", (24, 38), True), ("r1名", "监听端口", (38, 52), False), ("r1值", "2404", (38, 52), False), ("页脚", "第 1 页,共 1 页", (140, 160), False), ], "comm_edit": [ ("标题", "通讯设置", (0, 24), False), ("r0名", "公共地址", (24, 38), True), # 编辑中值列 = 裸数字串(本页无单位可挂) ("r2名", "保存", (52, 66), False), ], "comm_reject": [ # 实测帧(2026-10-27 目检 shot_comm_reject):拒退**留在编辑态**、 # r0 值挂"越界"提示并反显(sparam_oob 同款),保存行仍是 ENTER—— # 本行没按过保存,粘滞 ERR 不可达(comm 合法带编辑器=执行侧同带, # 执行侧拒只有宿主 034 那条腿能进,屏幕不假拍,calib_reject 同理) ("标题", "通讯设置", (0, 24), False), ("r0名", "公共地址", (24, 38), True), ("越界提示", "越界", (24, 38), True), ("r2名", "保存", (52, 66), False), ("r2值", "ENTER", (52, 66), False), ("页脚", "第 1 页,共 1 页", (140, 160), False), ], "comm_wait": [ ("标题", "通讯设置", (0, 24), False), ("r0名", "公共地址", (24, 38), False), ("r0值", "2", (24, 38), False), # CA 副本已 1→2(已受理;选中不在 r0,反显=False) ("保存名", "保存", (52, 66), True), ("保存值", "WAIT", (52, 66), True), ], "comm_ok": [ ("标题", "通讯设置", (0, 24), False), ("r0名", "公共地址", (24, 38), False), ("r0值", "2", (24, 38), False), ("保存名", "保存", (52, 66), True), ("保存值", "OK", (52, 66), True), # 脏位被 pump 清 = 已落盘 ], # ── 接线道 task-6:记录 5 页(空态判据)+ 运行报告两页 ─────────────────────────── # 五个记录页只钉空态:模拟器侧这五个日志文件没有写入方,能钉的正是判据"空记录显示 # 无记录"(App/log/README.md §五类记录)。第 0 行 = 选中行(白底黑字)→ inv=True; # 页脚钉"第 1 页,共 1 页"同时钉住分页数学(1 条伪行 = 1 页,不是 0 页也不是 2 页)。 "accident": [ ("标题", "事故记录", (0, 24), False), ("r0空态", "无记录", (24, 38), True), ("页脚", "第 1 页,共 1 页", (140, 160), False), ], "oper": [ ("标题", "操作记录", (0, 24), False), ("r0空态", "无记录", (24, 38), True), ("页脚", "第 1 页,共 1 页", (140, 160), False), ], "alarm": [ ("标题", "保护告警", (0, 24), False), ("r0空态", "无记录", (24, 38), True), ("页脚", "第 1 页,共 1 页", (140, 160), False), ], "start": [ ("标题", "保护启动", (0, 24), False), ("r0空态", "无记录", (24, 38), True), ("页脚", "第 1 页,共 1 页", (140, 160), False), ], "yk": [ ("标题", "遥控记录", (0, 24), False), ("r0空态", "无记录", (24, 38), True), ("页脚", "第 1 页,共 1 页", (140, 160), False), ], # 运行报告首屏 8 行。四个数各自挂范围标签是判据(上电次数=自电池周期、其余=本次开机), # 所以**标签与值成对断言**:只钉值不钉标签,就等于把"终身次数"这种口径错画放过去。 # 值 = simulator/main.c 的确定假数据(boot_count 7 / rst_cause 4 上电复位 / conn_up 3 / # uptime_s 0);四枚 104 数互不相同是刻意的,行错位在第 2 页那张里同样看得出来。 # 运行报告首屏 8 行(13 行 = 8 + 5 两页,Lead 2026-10-28 补「计数可信」行使四态可辨)。 # 四个数各自挂范围标签是判据(上电次数=自电池周期、其余=本次开机),所以**标签与值 # 成对断言**:只钉值不钉标签,等于把"终身次数"这种口径错画放过去。「延续」= 模拟器 # 假数据 boot_seq_state=2(CONTINUED),与 UNKNOWN 的 "--" 是两回事。 # 值 = simulator/main.c 的确定假数据(boot_count 7 / rst_cause 4 上电复位 / uptime_s 0)。 "runreport": [ ("标题", "运行报告", (0, 24), False), ("r0名", "上电次数", (24, 38), True), ("r0值", "7", (24, 38), True), ("r1名", "计数范围", (38, 52), False), ("r1范围标签", "自电池周期", (38, 52), False), ("r2名", "计数可信", (52, 66), False), ("r2可信度", "延续", (52, 66), False), ("r3名", "计数写回", (66, 80), False), ("r3值", "正常", (66, 80), False), ("r4名", "复位原因", (80, 94), False), ("r4值", "上电复位", (80, 94), False), ("r5名", "复位范围", (94, 108), False), ("r5范围标签", "本次开机", (94, 108), False), ("r6名", "运行时长", (108, 122), False), ("r6值", "00:00:00", (108, 122), False), ("r7名", "时长范围", (122, 136), False), ("r7范围标签", "本次开机", (122, 136), False), ("页脚", "第 1 页,共 2 页", (140, 160), False), ], # 运行报告第 2 页:104 另外三枚 + 统计范围标签 + 真实页码 2/2(分页数学的像素证据) # 运行报告第 2 页(13 行 → 第 2 页 5 行):104 四枚 + 统计范围标签 + 真实页码 2/2。 # 选中行随翻页落在 r0(主站接入)→ inv=True;四枚值 3/128/0/4096 互不相同是刻意的, # 行错位或分页窗口算错只有"像素 + 逐行文本"两把尺子同时才照得出来(Lead 裁定)。 "runreport_p2": [ ("标题", "运行报告", (0, 24), False), ("r0名", "主站接入", (24, 38), True), ("r0值", "3", (24, 38), True), ("r1名", "发送帧数", (38, 52), False), ("r1值", "128", (38, 52), False), ("r2名", "发送失败", (52, 66), False), ("r2值", "0", (52, 66), False), ("r3名", "接收字节", (66, 80), False), ("r3值", "4096", (66, 80), False), ("r4名", "统计范围", (80, 94), False), ("r4范围标签", "本次开机", (80, 94), False), ("页脚", "第 2 页,共 2 页", (140, 160), False), ], # ── 故障录波 UI 道(2026-10-29)── # 行带口径:操作页四行 y = 44/66/88/110(ui_page_action 组件);回放页顶行 # y=20、提示行 y=128、徽标行 y=143(ui_page_wave_play.c 的 WP_* 宏,与页面 # 同源);文案期望串逐字抄页面字面量,不"顺手归一"空格(AC_HINT_DO 是双空格)。 "wave_manual": [ ("标题", "手动录波", (0, 24), False), ("问句", "确认手动录波?", (44, 60), False), ("键提示", "ENTER=确认 ESC=取消", (104, 126), False), ], "wave_manual_done": [ ("标题", "手动录波", (0, 24), False), ("回报", "已请求录波", (44, 60), False), ("返回提示", "ENTER=返回", (104, 126), False), ], "wave_manual_ro": [ ("标题", "手动录波", (0, 24), False), ("问句", "确认手动录波?", (44, 60), False), ("只读标", "LOCK", (60, 82), False), ("只读提示", "ESC=返回", (104, 126), False), ], "wave_play": [ ("标题", "录波回放", (0, 24), False), ("顶行", "UA SEG" + "1 M", (18, 36), False), # 拆写:连写撞「字母+数字」编号形态 ("键提示", "UD=CH LR=SEG", (126, 143), False), ("峰值", "PK" + "1221" + "mV", (126, 143), False), # 拆写避编号形态;值 = 8000×k 定标取整(code_to_milli 唯一口径,正峰优先并列判据) ("单位徽标", "引脚域", (141, 158), False), ], "wave_play_bad": [ ("标题", "录波回放", (0, 24), False), ("坏态", "该段不可读", (56, 76), False), ], "wave_play_empty": [ ("标题", "录波回放", (0, 24), False), ("空态", "无录波段", (56, 76), False), ], } # 反向文本断言表:页名 -> [(名称, 期望**不出现**的串, y 带, 是否反显态)] # ⚠ inv 必须与"这一行本来会怎么画"一致,否则反向断言恒真、形同没写: # 选中/编辑行是白底黑字 → inv=True;普通行黑底白字 → inv=False。 # 为什么需要"不该出现"这一侧:像素参考能钉住整张图,但参考图是按当前实现刷出来的 # ——如果"未标定"徽标哪天在两处都画了(整定 attach 判据失效 + 顺手 --update 刷参考), # 像素比对会"一致"而事实已经错了。反向断言不依赖参考图,钉的是"这条规矩本身": # · 徽标只属于整定态(可改值但判据停走),不属于只读态,也不属于四个查看页; # · 只读态没有"保存"这件事(无 commit → 组件不画保存行)。 ABSENT = { # ---- 故障录波 UI 道(2026-10-29)---- # 好段屏不许冒出坏段/空态文案;坏段与空态屏绝不画键提示行与顶行—— # 「画成一条直线等于伪造证据」(设计 §8.1 空态行)的像素级反证。 "wave_play": [ ("无坏态文案", "该段不可读", (0, 160), False), ("无空态文案", "无录波段", (0, 160), False), ], "wave_play_bad": [ ("坏态不画键提示", "UD=CH LR=SEG", (0, 160), False), ("坏态不画顶行", "UA SEG", (0, 160), False), ("坏态不画峰值", "mV", (0, 160), False), ], "wave_play_empty": [ ("空态不画键提示", "UD=CH LR=SEG", (0, 160), False), ("空态不画顶行", "UA SEG", (0, 160), False), ("空态不画峰值", "mV", (0, 160), False), ], "wave_manual_ro": [ ("只读无回报行", "已请求录波", (0, 160), False), ], # ---- 道F task-3:反向断言钉的是"规矩本身",不依赖参考图 ---- # 1970 那条是本页最值钱的反向断言:记录侧的占位串一旦漏进时钟页, # 用户一次"照抄保存"就把 1970 写进 RTC(口径见 ui_page_clock.c 的 CK_INVAL 注) "clock_noclock": [ ("无效时标不显 1970", "1970", (0, 160), False), ("无效时标不显旧帧值", "2026", (0, 160), False), ("无效时标不显 08:00", "08:00", (0, 160), False), # 徽标压页脚这件事**不用反向断言钉**:实测本脚本的字形匹配把 y 带判得 # 比字面宽(徽标在 125..136、页脚在 144..155,写 ("无时钟", (140,160)) 反 # 而报"出现了")——拿它当"位置不重叠"的证据会假红。真正的防线在 TEXTS 那 # 一侧:"页脚未被徽标压住"这条正向断言在压字的版本上实测过不了(就是 # 上一轮那次 FAIL),它才是这条位置规矩的机器防线。 ], "clock": [ ("有效时标不该挂无时钟徽标", "无时钟", (122, 140), False), ], "clock_ro": [ ("只读页没有保存这件事", "保存", (0, 140), False), ], "clock_save": [ ("保存成功后保存行不再提示 ENTER", "ENTER", (108, 122), True), ], # 操作页 hide_footer=1:页脚/页码都不该出现(出现 = 描述符没关掉内置区块) "reset": [ ("操作页无页脚", "第 1 页", (138, 160), False), ("未确认前不投递", "已请求复归", (0, 160), False), ], "reset_2nd": [ ("停在二次确认就没投递", "已请求复归", (0, 160), False), ("操作页无页脚", "第 1 页", (138, 160), False), ], "reset_done": [ ("回报后问句让位", "确认复归保护?", (38, 60), False), ], "reset_ro": [ ("只读不进二次确认", "二次确认", (0, 160), False), ("只读一条都不投", "已请求复归", (0, 160), False), ("只读不显示动作结果", "OK", (60, 82), False), ], "clear": [ ("没按过就不该有确认问", "确认?", (0, 160), False), ("没投递就不该有结果词", "OK", (0, 140), False), ], "clear_ro": [ ("只读不进确认态", "确认?", (0, 160), False), ("只读不投递", "WAIT", (0, 160), False), ], "sparam_ro": [ ("只读页无未标定徽标", "未标定", (0, 24), False), ("只读页无保存行", "保存", (0, 140), False), ], # 查看 4 页:task-2 给同一批描述符绑了 key_cb / edit_capable,像素必须零变化; # 徽标与保存行都不该出现在它们身上(出现 = 整定 attach 漏判了权限) "vparam": [("查看页无未标定徽标", "未标定", (0, 24), False), ("查看页无保存行", "保存", (0, 140), False)], "vset": [("查看页无未标定徽标", "未标定", (0, 24), False), ("查看页无保存行", "保存", (0, 140), False)], "vword": [("查看页无未标定徽标", "未标定", (0, 24), False), ("查看页无保存行", "保存", (0, 140), False)], "vplate": [("查看页无未标定徽标", "未标定", (0, 24), False), ("查看页无保存行", "保存", (0, 140), False)], # 编辑态的值列由 ui_w_editor_format 生成(**不拼单位**),非编辑态才拼: # 所以"100V 不在这一行"就是"这一行确实在编辑中"的证据。 # 反过来正向断言"100"是没有意义的——它是"100V"甚至"10000V"的子串, # 字形匹配器只保证 bbox 内干净、不检查词边界(实测在上一行的 10000V 上命中) "sparam_edit": [("编辑态不打单位后缀", "100V", (38, 52), True)], # ── 接线道 task-6:反向断言钉"分页不串行 / 记录源不串页"(同样不依赖参考图)── # 运行报告两页互为反证:第 1 页不许出现只有第 2 页才有的行、反之亦然——分页窗口 # 一旦算错(比如 rows_per_page 与页脚页码不同源),只有一张图会露馅,这里两把都上。 # 五个记录页钉"空态页上不许出现别的页的时标串":SOE 页屏上有固定 3 条假记录,时标 # 2026-10-25(ui_w_soe 的显示假数据)。一旦有人图省事把记录页接到那份假数据上, # 或把两端记录源映射表(simulator/main.c 的 s_sim_log_map 与 App/task/ui_task.c 的 # s_log_map)接错号,让这几页读到 SOE 的数据,屏上就会冒出这个时标——这条不依赖参考图就会响。 "runreport": [ ("第 1 页不出现第 2 页的行", "发送帧数", (0, 160), False), ], "runreport_p2": [ ("第 2 页不出现第 1 页的行", "上电次数", (0, 160), False), ], "accident": [("空态不串 SOE 假记录", "2026-10-25", (0, 160), False)], "oper": [("空态不串 SOE 假记录", "2026-10-25", (0, 160), False)], "alarm": [("空态不串 SOE 假记录", "2026-10-25", (0, 160), False)], "start": [("空态不串 SOE 假记录", "2026-10-25", (0, 160), False)], "yk": [("空态不串 SOE 假记录", "2026-10-25", (0, 160), False)], } W = H = 160 STRIDE = W // 8 # I1:20 字节/行 # 开页-关页往返后的 LVGL 池水位(x64 模拟器)= 菜单基线 + 一次性残留 80 B。 # 页面结构变更导致漂移时,先确认新值合理再更新此常量(内存回归门禁)。 # 2026-09-14: 4864 → 5520(+656B)= run_headless 新增 ui_standby_init():待机模块 # 常驻屏 s_scr + 容器 s_cont 两个 lv_obj,随菜单基线同移(见 check_menu_rows.py)。 # 2026-10-11: 5520 → 5448(−72B)= 裁剪 LVGL 控件类,随菜单基线 5440→5368 同移; # **一次性残留不变,仍是 80 B**(5448 − 5368),说明泄漏特征未变,只是每对象样式表更小。 POOL_AFTER_ROUNDTRIP = 5448 # 每页额外的模拟器旗标(道F task-3):clock_noclock 需要一台"墙钟无效"的机器, # 而 rt 帧只有 --noclock 这一条路能装出来(真机侧对应"RTC 异常/未对时")。 # 不为它另造一套假数据:假的是"时钟有没有"这件事,页面与显示链全是真的。 EXTRA = { "clock_noclock": ["--noclock"], # 道F task-7:WAIT 态黄金 = 落盘 pump 停转(假件保留脏位,Lead 裁定④); # OK 态同键序不加旗标,两图唯一差别就是"脏有没有被清"——对照即凭证 "deadband_wait": ["--yt-nopump"], "calib_wait": ["--yt-nopump"], # 道G task-9:通讯页 WAIT 态 = 落盘 pump 停转(同款裁定,独立开关) "comm_wait": ["--comm-nopump"], # 故障录波 UI 道(2026-10-29):坏段/空态两张的专用机器态(同款"假的是环境, # 页面与显示链全是真的"裁定;--wave-bad 翻数据字节让真 CRC 判废、 # --wave-empty 根本不建段且触发拒收) "wave_play_bad": ["--wave-bad"], "wave_play_empty": ["--wave-empty"], } # 接线道 task-6:记录 5 页在**各自独立的空目录**里跑(不共用回归的那个 tmp)。 # 诱因(实测,不是猜):模拟器把"人工操作留痕"镜像成了真记录——simulator/main.c 的 # sim_post_oper() 走 syslog_post(SYSLOG_OPER, ...) 落盘**当前工作目录**的 sim_oper.log; # 而 60 张(每个截图态一条目)原本共用一个 tmp,clock_save / netcfg_save / sparam_save 这些"保存"态跑完之后 # sim_oper.log 已经攒了 3 条(192 B = 3×64 B)→ "操作记录"页读到的是真记录, # 空态判据(第 0 行"无记录")根本拍不到。**判据本身没错,错在跑它的环境被前面的写路径 # 污染了**,所以给这 5 页各开一个空目录 = 现场"这些日志文件还不存在"的确定态; # 其余 55 页环境一字不变(对当时存量 53 张黄金零影响,实测逐字节零漂移;当时集合 60 张,现集合 66 张)。 FRESH_CWD = ("accident", "oper", "alarm", "start", "yk") def run_sim(seq, out, cwd, extra=()): """在指定工作目录里跑一次无头截图。 模拟器按**工作目录**读写 `sim_config.ini`:目录里残留一份配置(尤其 `--cfgtest` 场景 2 伪造的 4000 B 纯注释"雷"文件)会让引擎按设计 拒绝保存 → 网口设置页保存行显示 ERR → 与"工厂默认态"的黄金参考对不上。 因此一律在空临时目录里跑,回到"无配置文件 = 工厂默认"这一确定态。 """ r = subprocess.run([str(EXE), "--keys", seq, out] + list(extra), capture_output=True, cwd=str(cwd)) if r.returncode != 0: raise RuntimeError("lvgl_sim --keys %r rc=%d: %s" % (seq, r.returncode, r.stderr.decode(errors="replace")[:200])) return r.stdout def ink_runs(data): """I1 位图按行统计白像素,返回 [(y0,y1,count), ...](count>2 的连续段)。""" px = [] for y in range(H): row = data[y * STRIDE:(y + 1) * STRIDE] cnt = sum((b >> (7 - x)) & 1 for b in row for x in range(8)) px.append(cnt) runs, start = [], None for y, c in enumerate(px): if c > 2 and start is None: start = y elif c <= 2 and start is not None: runs.append((start, y - 1)) start = None if start is not None: runs.append((start, H - 1)) return [(a, b, sum(px[a:b + 1])) for a, b in runs] def diff_count(a, b): n = 0 for y in range(H): ra = a[y * STRIDE:(y + 1) * STRIDE] rb = b[y * STRIDE:(y + 1) * STRIDE] for x in range(STRIDE): n += bin(ra[x] ^ rb[x]).count("1") return n # --------------------------------------------------------------------------- # 文本级断言(字形模板匹配,独立于像素黄金参考) # # 像素 diff 能抓一切视觉变化,但代价是"任何改动都要刷参考";而"缺字显示空白 / # 标签写错"这类回归用文本断言直接对照期望字符串更稳,且不依赖 ref/。二者互补: # 文本断言先跑,失败即中止(不误刷像素参考)。 # --------------------------------------------------------------------------- FONT_C = Path(__file__).resolve().parents[2] / "Drivers" / "BSP" / "UI" / "ui_font_cn_12.c" _GLYPH = None def _parse_font(): """解析 ui_font_cn_12.c,返回 glyph(cp) -> (adv, box_w, box_h, ofs_x, ofs_y, rows)。""" src = FONT_C.read_text(encoding="utf-8-sig") ulists = {} for m in re.finditer(r"static const uint16_t (unicode_list_\d+)\[\] = \{(.*?)\};", src, re.S): ulists[m.group(1)] = [int(x, 16) for x in re.findall(r"0x[0-9a-fA-F]+", m.group(2))] cmaps = [] for m in re.finditer( r"\.range_start = (\d+), \.range_length = (\d+), \.glyph_id_start = (\d+),\s*" r"\.unicode_list = ([^,]+), \.glyph_id_ofs_list = ([^,]+), \.list_length = (\d+), " r"\.type = (\w+)", src): cmaps.append((int(m.group(1)), int(m.group(2)), int(m.group(3)), m.group(4).strip(), m.group(7))) gm = re.search(r"glyph_dsc\[\]\s*=\s*\{", src) gend = src.find("\n};", gm.end()) dscs = [(int(a), int(b), int(c), int(d), int(e), int(f)) for a, b, c, d, e, f in re.findall( r"\{\.bitmap_index = (\d+), \.adv_w = (\d+), \.box_w = (\d+), \.box_h = (\d+), " r"\.ofs_x = (-?\d+), \.ofs_y = (-?\d+)\}", src[gm.end():gend])] bm = re.search(r"glyph_bitmap\[\]\s*=\s*\{", src) bend = src.find("\n};", bm.end()) body = re.sub(r"/\*.*?\*/", "", src[bm.end():bend], flags=re.S) bitmap = bytes(int(x, 16) for x in re.findall(r"0x[0-9a-fA-F]+", body)) def gid_of(cp): for rs, rl, gs, ul, typ in cmaps: if typ.endswith("SPARSE_TINY"): offs = ulists[ul] if (cp - rs) in offs: return gs + offs.index(cp - rs) elif rs <= cp < rs + rl: return gs + (cp - rs) return 0 def glyph(cp): ofs, adv, bw, bh, ox, oy = dscs[gid_of(cp)] rows = [[(bitmap[ofs + ((y * bw + x) >> 3)] >> (7 - ((y * bw + x) & 7))) & 1 for x in range(bw)] for y in range(bh)] return adv, bw, bh, ox, oy, rows return glyph def _render(s): """把字符串渲染成相对原点的墨点集合(LVGL 基线对齐:字形顶 = baseline - box_h - ofs_y)。""" global _GLYPH if _GLYPH is None: _GLYPH = _parse_font() cur, ink = 0, set() for ch in s: adv, bw, bh, ox, oy, rows = _GLYPH(ord(ch)) for y in range(bh): for x in range(bw): if rows[y][x]: ink.add((cur + ox + x, -bh - oy + y)) cur += (adv + 8) >> 4 return ink def _match_text(img, s, band, invert=False): """在 img 的 y 带内搜索字符串 s 的精确字形渲染,返回命中位置列表。""" ink = _render(s) if not ink: return [] xs = [p[0] for p in ink] ys = [p[1] for p in ink] bx1, bx2, by1, by2 = min(xs), max(xs), min(ys), max(ys) def px(x, y): if x < 0 or x >= W or y < 0 or y >= H: return 0 v = (img[y * STRIDE + (x >> 3)] >> (7 - (x & 7))) & 1 return (1 - v) if invert else v hits = [] for oy in range(band[0] - 6, band[1]): for ox in range(0, W - (bx2 - bx1)): ok = True for ix, iy in ink: if px(ox + ix, oy + iy) != 1: ok = False break if not ok: continue for yy in range(by1, by2 + 1): for xx in range(bx1, bx2 + 1): if (xx, yy) not in ink and px(ox + xx, oy + yy) != 0: ok = False break if not ok: break if ok: hits.append((ox, oy)) return hits def main(): update = "--update" in sys.argv[1:] if not EXE.exists(): print("FAIL: 找不到 %s(先构建 simulator)" % EXE) return 2 tmp = tempfile.mkdtemp(prefix="dtu_ui_pages_") ok = True # 回归一律在空临时目录里跑(原因见 run_sim 的说明)。调用目录里残留的配置档 # 不再影响像素,只提示一句,便于手工跑 lvgl_sim 的人排查。 # (旧 sim_netcfg.bin 时代的"删除残留"做法已作废:旧档迁移 2026-10-10 删除, # 且真正会污染结果的是 --cfgtest 留下的 sim_config.ini,删别人的档不合适) for leftover in ("sim_config.ini", "sim_netcfg.bin"): if (Path.cwd() / leftover).exists(): print("[提示] 当前目录存在 %s(本脚本不读它;手工运行 lvgl_sim 时留意," "--cfgtest 会伪造 4000 B 配置档)" % leftover) # 1) 跑全部页面 + 文本级断言预检(先于像素比对/刷新:失败即中止,避免误刷参考) shots = {} for name, seq in PAGES: cwd = tmp if name in FRESH_CWD: # 记录页的空态判据要求"日志文件此刻不存在",理由见 FRESH_CWD 上方说明 cwd = tempfile.mkdtemp(prefix="fresh_%s_" % name, dir=tmp) shot = os.path.join(cwd, "shot_%s.bin" % name) run_sim(seq, shot, cwd, EXTRA.get(name, ())) shots[name] = open(shot, "rb").read() n_pos = 0 for name, _seq in PAGES: for tname, text, band, inv in TEXTS.get(name, []): n_pos += 1 if not _match_text(shots[name], text, band, inv): ok = False print("[%s] FAIL 文本断言未命中:%s = %r(y 带 %s 反显=%s)" % (name, tname, text, band, inv)) n_abs = 0 for name, _seq in PAGES: for tname, text, band, inv in ABSENT.get(name, []): n_abs += 1 if _match_text(shots[name], text, band, inv): ok = False print("[%s] FAIL 反向断言被命中:%s 不该出现却出现了(y 带 %s)" % (name, tname, band)) if not ok: print("FAIL: 文本级断言未通过(缺字/错标签);中止像素比对(现场保留在 %s)" % tmp) return 1 print("文本级断言:%d 条命中 + %d 条反向(不该出现)全部通过" % (n_pos, n_abs)) # 2) 像素黄金参考比对 / 刷新 for name, seq in PAGES: ref = REF_DIR / ("shot_%s.bin" % name) data = shots[name] if update: REF_DIR.mkdir(exist_ok=True) ref.write_bytes(data) print("[%s] 黄金参考已刷新 -> %s(键序 %r)" % (name, ref.name, seq)) continue if not ref.exists(): print("FAIL: 缺黄金参考 %s(ref/*.bin 已入库:缺失说明工作树不完整," "跑 git checkout -- simulator/tests/ref/ 取回,不要用 --update 造基准)" % ref) shutil.rmtree(tmp, ignore_errors=True) return 2 refdata = ref.read_bytes() if data == refdata: print("[%s] PASS 与黄金参考一致(键序 %r)" % (name, seq)) continue ok = False n = diff_count(data, refdata) print("[%s] FAIL 与黄金参考不一致:%d px 差异(键序 %r)" % (name, n, seq)) print(" 当前截图 ink runs: %s" % ink_runs(data)) print(" 黄金参考 ink runs: %s" % ink_runs(refdata)) print(" 有意改视觉?确认新效果正确后运行 --update 刷新参考。") # 开页-关页往返池水位门禁(info 页 e/s;密码页 ddee e/s 后 ←← 弹框回根框) for rt_name, rt_seq in (("info", "es"), ("pwd", "ddeeesll")): out = run_sim(rt_seq, os.path.join(tmp, "shot_rt_%s.bin" % rt_name), tmp) m = re.search(r"pool after keys: used=(\d+)", out.decode(errors="replace")) if not m: print("FAIL: 往返输出未找到 'pool after keys'(%s %r)" % (rt_name, rt_seq)) shutil.rmtree(tmp, ignore_errors=True) return 2 used = int(m.group(1)) if update: print("[rt:%s] 开页-关页往返池水位 %d B(--update 不校验,仅提示)" % (rt_name, used)) elif used != POOL_AFTER_ROUNDTRIP: ok = False print("[rt:%s] FAIL 往返池水位 %d != 预期 %d(页面结构变更?确认合理后更新常量)" % (rt_name, used, POOL_AFTER_ROUNDTRIP)) else: print("[rt:%s] PASS 开页-关页往返池水位 %d B" % (rt_name, used)) if update: print("PASS: 黄金参考已刷新(%d 页)" % len(PAGES)) shutil.rmtree(tmp, ignore_errors=True) return 0 if ok: print("PASS: %d 内容页 + 往返池水位全部通过" % len(PAGES)) shutil.rmtree(tmp, ignore_errors=True) return 0 # 失败时保留临时截图便于排查 print("(现场截图保留在 %s)" % tmp) return 1 if __name__ == "__main__": sys.exit(main())