#!/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 # 有意改视觉后刷新本地黄金参考 前置:simulator 已构建(build/Debug/lvgl_sim.exe 存在);纯标准库,任意 python3。 ref/*.bin 为**本地生成物、不入库**(.gitignore):首次/换机后须先 `--update` 生成一次。 原理:① 文本级断言——解析 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 刷新, 最后 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 项) ("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 = { "info": [ ("标题", "装置信息", (0, 24), False), ("副标题", "馈线自动化终端", (22, 40), False), ("r0名", "终端类型:", (40, 55), False), ("r0值", "F30", (40, 55), False), ("r1名", "终端型号:", (55, 70), False), ("r1值", "F30", (55, 70), False), ("r2名", "软件版本:", (70, 85), False), ("r2值", "SV0.010", (70, 85), False), ("r3名", "硬件版本:", (85, 100), False), ("r3值", "HW0.010", (85, 100), False), ("r4名", "软件校验:", (100, 115), False), ("r4值", "4454", (100, 115), False), ("r5名", "程序日期:", (115, 130), False), ("r5值", "2024.08.27", (115, 130), False), ("页脚", "第 10 页,共 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)。 POOL_AFTER_ROUNDTRIP = 5520 def run_sim(seq, out): r = subprocess.run([str(EXE), "--keys", seq, out], capture_output=True) 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 # 清理手工运行残留的配置存储文件(模拟器工作目录),防"工厂默认值"像素参考被污染 stale = Path.cwd() / "sim_netcfg.bin" if stale.exists(): stale.unlink() # 1) 跑全部页面 + 文本级断言预检(先于像素比对/刷新:失败即中止,避免误刷参考) shots = {} for name, seq in PAGES: shot = os.path.join(tmp, "shot_%s.bin" % name) run_sim(seq, shot) 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(首次/换机后请加 --update 生成;ref/ 不入库)" % 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)) 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())