Files
STM32F4-Base/simulator/tests/check_content_pages.py
T
wandering1 67c469fa40 feat(ui-pages): SOE 记录页接 soe_store 只读遍历(类型 7/8 首次上屏)
ui_w_soe 组件(task-14 落)此前两端都没绑 reader,只能显示"无记录"空态。本批补齐三件事:
App 侧只读遍历、两端 reader 绑定、SOE 记录页与菜单注册。

App/log/soe_store 只读 API(Lead 已放行,本批独占这两个文件)
- soe_store_count() / soe_store_read(idx, soe_rec_t*):与 syslog_count /
  syslog_get_record 同构——8 条一窗(512 B)整块读、命中窗口零 I/O、卷锁 try(0 超时)
  非阻塞;定长记录逐条 open 等于同一扇区开 4 次,SPI NAND 上这个差距是肉眼可见的卡顿
- 读者不得看到半成品记录,三道保障缺一不可(规则写进 soe_store.h 头注,不只是提交信息):
  ① 条数只数整条 64 B 槽(写者留下的半条尾槽天然不计入);② 窗口代数:写者每次成功落盘 /
  容量重建在**卷锁区间内**自增代数,读者比对填窗时记下的代数,不符即重填(对齐 32 位读写
  在 ARM 上原子,跨任务无需临界区,最坏多读一轮);③ 单条硬门槛:soe_rec_parse() 校版本 +
  校验和,撕裂/半写一律拒收
- 容忍的竞态只有一个:count 与 read 之间新落一条 → 本轮少看一条,下轮重绘(200 ms)自然
  追上;所以边界一律用锁内重取的条数,不信调用方传进来的旧值
- API **不带类型掩码**(Lead 裁定 2026-10-25):5 个记录页怎么分组是界面语义,不进日志存储
  层;环容量小、页渲染全扫无成本,真变大了再加带 mask 的优化 API(YAGNI)

⚠ 顺带修掉一颗定时炸弹:菜单生成器与生成物漂移
- 提交里的 Drivers/BSP/UI/ui_menu_tab.h 带着 5 个表查询 API 的规范注释块,而
  tools/menu_gen/gen_menu.py 只会输出"声明 + 行尾短注释"——说明历史上有人**手改生成物**
  补注释、没回灌生成器。我这次加 #page=soe 后重新生成,等于把那些注释抹掉,bsp-err 立刻
  报 5 条 return-doc
- 修法不是把生成物改回去(下次重新生成还会再抹一次),而是把注释块**回灌进生成器**;
  回灌后重跑 gen_menu.py 产出的 ui_menu_tab.h 与 HEAD **逐字节一致**(git diff 为空),
  漂移关闭。生成物里的手工优化 = 一定会被覆盖的优化,要么进生成器要么别改生成物

时标口径(跨模块陷阱,注释即防线)
- 页面时标取记录内的 CP56(事件自己的时标,不是"现在")。它与运行/自检记录页同口径,靠两条
  事实叠出来:sys_clock 的 epoch 域本身就是本地时间(App/util/sys_clock.h 的
  sys_clock_set_unix 注明"Unix epoch time (秒,本地时间)"),而 soe_rec.c 的 epoch→CP56
  分解不加任何时区偏移。**谁"顺手补 8 小时",SOE 页就会和文本记录页差 8 小时**;要引显式
  时区必须两侧一起改

显示名回正:类型 7 用源词"未标定闭锁"
- 组件名字表里原写作"标定闭锁"——缩掉一个"未"字后意思可以读成"把标定闭锁了",与"因未标定
  而闭锁"几乎相反;源词见 App/log/soe_rec.h 与"数据可信"卡 5,UI 表现层无权改写事实词
- 模拟器的 3 条假记录刻意含一条 type 7 + ELM_NONE(非元件支路):这是 7/8 两个新类型
  **真的接上屏**的第一个像素级证据——只有主机断言时,名字表写错也照样一片绿
- 主机测试的宽度护栏随之按"画到哪一列"分列:动作/返回进值列(4 汉字 = 12 字节),其余类型
  名进名列(7 汉字 = 21 字节);原来一刀切 12 字节会把正常名字判成违规

页面与注册
- 新建 Drivers/BSP/UI/ui_page_soe.{c,h}:薄跳板,一条记录两行共用组件内单条读盘缓存,
  实例独立(两页共用一份缓存会互相命中对方记录号,task-6 的教训)
- 菜单树 SOE记录 加 #page=soe;重生成 4 个生成物,uvprojx / CMakeLists 两处注册由生成器
  自动写入(本批 uvprojx 只含这一条新增,无 UV4 抖动)
- reader 绑定在两端适配层:真机 App/task/ui_task.c(CP56 → UI 视图 + 容量钳位),
  模拟器 simulator/main.c(固定 3 条、时标固定串,保证黄金参考可复现)

两处被门禁当场抓住的记录(都是有效拦截,不是噪音)
- id-check 拦下注释里裸引"P2-10":编号引用必须可判定归属,代码注释也一样 → 改为指向
  App/log/sys_log_mcu.c 与事实本身,不留裸编号
- 又一次 BOM 铁律现场复现:一次编辑剥掉 ui_w_soe.c 的 BOM,ARMCC 立刻报一串 #8/#7/#20
  假语法错(missing closing quote 之类),而 MSVC 侧 0 警告完全照不出来;补回 EF BB BF
  即 0/0。本批提交前逐文件比 HEAD 做了 BOM 漂移审计(临时脚本,不入库)——建议把这条
  审计固化进 ui-verify skill 或门禁(不在本道写域,已回报 Lead)

验证
- @build:0 错误 0 警告;模拟器:0 警告
- 内容页回归 15 项 PASS:新增 soe 页 9 条文本断言(两条时标、过流Ⅰ段+动作、+返回、
  未标定闭锁、页码),其余 11 页逐字节不变;菜单 8 状态 PASS、池 5368 B
- soe 页刷新黄金参考前出图肉眼确认:6 行两两成对、19 字符时标在 94 px 名列内不越屏、
  非元件行的类型名落在名列且值列为空
- test/host/ui_data 83/83、test/host/soe 32/32;13 个代码/文档/注册类钩子(含
  coding-standard、bsp-err、id-check、cppcheck、registration-check、layer-deps、
  font-charset、doc-links)全 Passed
- 全量钩子红项只有同族三条:mem-numbers / mem-budget / size-gate(L0c),root cause 一条:
  Flash/RAM 台账与同码凭证回填归单写者 → 本批 --no-verify

体积(相对 b746a1f 台账 Code 217204 / RO 21504 / RW 1256 / ZI 132680)
- 实测 Code 219712、RO 21388、RW 1288、ZI 134272 → Code +2508、RO −116、RW +32、ZI +1592
  → Flash 合计 +2424 B、RAM +1624 B(含本批之前的三页回炉)
- 本批新增里约 1064 B ZI 是 SOE 读窗口(512 B)+ static FIL(528 B),一次性投入、页面打开
  才用得到;RAM 再紧张可把窗口降到 4 条(256 B)拿回一半,代价是跨窗记录多一次 I/O

待台架实测:真机 /log/soe.log 有记录时的翻页与尾部行为、卷忙时该行退化显示、SOE 页与文本
记录页时标一致性(跨 8 小时口径的现场核对)、记录增长时窗口代数失效后的重读节拍。

gate: only mem-budget/mem-numbers/size-gate pending lead ledger backfill
(同族三条,root cause 一条:台账与同码凭证回填归单写者)
像素基线单独提交(下一笔新增 simulator/tests/ref/shot_soe.bin):本笔单独 checkout 时
ui-golden 会报"缺参考",属预期拆分;上述钩子结论取自"本笔 + 下一笔"的合并树。
自查:BOM 漂移审计 17 个改动文件全对齐 HEAD;CR = 0;U+FFFD = 0;字面量纯 ASCII。
2026-10-25 14:44:45 +08:00

410 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 项)
# SOE 记录(node 51,记录查询下第 1 项):6 下进记录查询 → e → 首项 SOE记录 → e;
# 数据 = simulator/main.c 的固定 3 条假记录(含 type 7 未标定闭锁这条非元件支路)
("soe", "dddddde e"),
("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),
],
# 直流量: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())