4332142680
批 4 主体(票 task-15;规格 docs/UI设计/页面扩充详细方案.md §3.1.3 方案 A)。V0.1 只有 6 点有 硬件来源,其余 27 点在屏上就是 "--"——这是真实状态,不是页面没做完。 页面与数据 - 33 行固定名表(Flash const)+ 动态分页(8 行/页 = 5 页)。两处命名不照抄文档简写:idx0~3 屏上写「位置1~4」而非「YK1~4 位置」(遥信页出现 YK 前缀会被读成遥控对象,对应关系留在注释); idx30 写「保护闭锁」(写侧位号注释的语义即此,用编号反而丢信息)。idx4~13、idx31~32 点表未 定义功能名,如实写点号占位(YX5…、YX32),不编造功能名。 - 值映射次序:先判无效 → 再判单/双点 → 最后看位值。无效位上的位值不代表任何事实,次序颠倒就会 把垃圾位画成「复归」。 - 快照新增 yx_dp 双点位图(+8 B)。不教页面"前 4 点是双点":单双点是 104 点表事实(YX_DP_N / iec104_pts_yx_is_dp),抄进表现层就是第二真相——点表把双点段挪位或改起始下标,屏上不报错, 只会用错词形容同一个点。写侧逐点问 is_dp() 拼位图。 - UI_DATA_YX_N(33) 与 iec104_pts_yx_count() 编译期比不了(那个 #define 在协议层 .c 内),故写侧 留一条一次性 DBG_ERROR(约 90 B Flash,只在真漂移时打一次)。名表行数 = UI_DATA_YX_N 由数组 宽度法编译期守卫钉住(ARMCC C99 无 _Static_assert,本仓惯用写法)。 - 补 include 一条实测坑:DBG_ERROR 是宏,漏 include "dbg_log.h" 时 ARMCC 把它当隐式声明函数放过 编译期,错落在链接期(L6218E Undefined symbol DBG_ERROR)——报错点离根因隔了一整个链接步骤, 已在 include 处写明「DBG_TAG 必须在 include 之前」。 一个只有像素文本断言能抓到的截断 bug - 照抄批 1 的 *_PUT(dst, 源) 宏(长度取 sizeof(源)),但本表源是 const char *const 数组的 **元素(指针)**,sizeof 得 4 → 「故障总」被截成「故」,而 "YX9" 恰好 4 字节看起来完全正常。 编译器 / cppcheck / 编码钩子全不响。定位来自断言:第 2 页「过流Ⅰ段」匹配不到,同一行值列 「复归」却在原位 = 名字被截、值没错位。修法:长度取目标缓冲容量 UI_BUF_STRLEN。同名宏、不同 参数类别(数组 vs 指针),批 1 那处本身是对的,照抄才中招——教训写在宏定义上方。 模拟器与覆盖诚实性 - 固定帧发布 yx_dp = 0x0F,但刻意不把这几位置进 yx_valid:V0.1 无开入硬件,屏上就是 "--"。不用 「让它显示合位/分位」的假数据填满画面,那等于让黄金参考声称本机有开关位置遥信。 - 代价如实登记:合位/分位这条词映射当前**没有任何自动化证据**(像素恒 IV;页面依赖 LVGL 进不了 主机单测)。已写进方案 §3.1.3 落地注与契约落地进度·四,作为开入落地后的台架判据,不假装测过。 测试 - 主机单测 +2 条(TC-UIDATA-009c 双点位图原样透传、不被 valid 掩;009d 点数 33 的独立见证,注明 绊红时要同步改哪四处)→ 85/85。 - 文本断言 +33 条(yx / yxp2 两态,含页码钉住 33/8 = 5 页的分页数学);两页像素基线单独一笔。 - font-charset 本批零新增字:名表用字全部命中既有 183 字子集——「置/故/障/总/外/部」正属批 1 成组并入 KEEP_EXTRA 的那批字,第二次兑现(当时若按 extract 输出重写字符集,这些字早被洗掉, 上屏是空白,而门禁只会说「字不在清单里」)。 体积(同一棵树、构建前后的实测增量) - Code 224272→224688(+416);RO 24040→24640(+600);RW 1324→1328(+4); ZI 134844→135088(+244)→ Flash 约 +1020 B、RAM +248 B。 - RO 增量主要是 33 行名表与名称串,是"把 33 个真点名上屏"的代价,不是可省开销。 跳过的门禁(三条同族,同一根因):mem-numbers / mem-budget / size-gate(L0c)——台账与体积凭证 回填是 Lead 单写者,本分支不做静默处理,数值见上。
544 lines
28 KiB
Python
544 lines
28 KiB
Python
#!/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_<page>.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"
|
||
|
||
# (页名, 注入键序):与 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"),
|
||
("pwd", "ddeee"), # 密码页初始态:3 框 000、光标首框
|
||
("pwe", "ddeee uru"), # 密码页调值+移光标:110、光标第 2 框
|
||
("pwk", "ddeee uruuruuue"), # 密码页输 123:u / r / u,u / r / u,u,u / e → 关页进空白终点框(菜单屏)
|
||
("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
|
||
]
|
||
|
||
# 文本级断言表:页名 -> [(名称, 期望串, y 带, 是否反显)]
|
||
# 只断言确定的内容;标签与值分列断言(两列 x 不同,不能拼成一串)。
|
||
# 覆盖本工程新增/易缺字的关键文案(网口设置/地址/掩码/逗号/保存/OK 等)。
|
||
TEXTS = {
|
||
# 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值", "192.168.001.100", (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值", "192.168.001.100", (24, 38), True),
|
||
("r1名", "子网掩码", (38, 52), False), ("r1值", "255.255.255.000", (38, 52), False),
|
||
("r2名", "默认网关", (52, 66), False), ("r2值", "192.168.001.001", (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值", "192.168.001.100", (24, 38), True),
|
||
("r1名", "子网掩码", (38, 52), False), ("r1值", "255.255.255.000", (38, 52), False),
|
||
("r2名", "默认网关", (52, 66), False), ("r2值", "192.168.001.001", (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值尾", "168.001.100", (24, 38), True), # 首字节 192 被光标反显覆盖
|
||
("r1名", "子网掩码", (38, 52), False),
|
||
("r4名", "保存", (80, 95), False),
|
||
],
|
||
"netcfg_save": [
|
||
("标题", "网口设置", (0, 24), False),
|
||
("r0名", "IP地址", (24, 38), False), ("r0值", "192.168.001.100", (24, 38), False),
|
||
("r3名", "MAC地址", (66, 80), False),
|
||
("r4名", "保存", (80, 95), True), ("r4值", "OK", (80, 95), True),
|
||
],
|
||
}
|
||
|
||
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 控件类(F-2),随菜单基线 5440→5368 同移;
|
||
# **一次性残留不变,仍是 80 B**(5448 − 5368),说明泄漏特征未变,只是每对象样式表更小。
|
||
POOL_AFTER_ROUNDTRIP = 5448
|
||
|
||
|
||
def run_sim(seq, out, cwd):
|
||
"""在指定工作目录里跑一次无头截图。
|
||
|
||
模拟器按**工作目录**读写 `sim_config.ini`:目录里残留一份配置(尤其
|
||
`--cfgtest` E-01 场景 2 伪造的 4000 B 纯注释"雷"文件)会让引擎按设计
|
||
拒绝保存 → 网口设置页保存行显示 ERR → 与"工厂默认态"的黄金参考对不上。
|
||
因此一律在空临时目录里跑,回到"无配置文件 = 工厂默认"这一确定态。
|
||
"""
|
||
r = subprocess.run([str(EXE), "--keys", seq, out], 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:
|
||
shot = os.path.join(tmp, "shot_%s.bin" % name)
|
||
run_sim(seq, shot, tmp)
|
||
shots[name] = open(shot, "rb").read()
|
||
|
||
for name, _seq in PAGES:
|
||
for tname, text, band, inv in TEXTS.get(name, []):
|
||
if not _match_text(shots[name], text, band, inv):
|
||
ok = False
|
||
print("[%s] FAIL 文本断言未命中:%s = %r(y 带 %s 反显=%s)"
|
||
% (name, tname, text, band, inv))
|
||
if not ok:
|
||
print("FAIL: 文本级断言未通过(缺字/错标签);中止像素比对(现场保留在 %s)" % tmp)
|
||
return 1
|
||
print("文本级断言:%d 条全部命中" % sum(len(TEXTS.get(n, [])) for n, _ in PAGES))
|
||
|
||
# 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())
|