#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ check_menu_rows.py — 自绘菜单 8 状态像素回归(160x160 I1,MSB-first,1=白 0=黑)。 用法: python simulator/tests/check_menu_rows.py # 在仓库任意目录均可运行 前置:simulator 已构建(build/Debug/lvgl_sim.exe 存在);纯标准库,任意 python3。 状态(键序注入,u=上 d=下 e=Enter s=Esc): S0 "" 根框 7 行 sel=0 S1 "d" sel=1 S2 "dd" sel=2 S3 "ddu" 回退 → 须与 S1 全像素一致 S4 "de" 开 L2(实时数据 3 子项),根框不变 S5 "ded" L2 sel=1 S6 "des" 返回 → 须与 S1 全像素一致(销毁/重建一致性) S7 "ddes" 根框 sel=2、L2 已毁 → 须与 S2 全像素一致 断言:选中行白带(白占比>0.50)+ 带内黑字;非选行黑底白字(0.03<占比<0.45); 框体 1px 白边框;一致性状态逐字节相同;S0 池水位 = MENU_POOL_BASELINE。 退出码: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" STATES = [ ("S0", ""), ("S1", "d"), ("S2", "dd"), ("S3", "ddu"), ("S4", "de"), ("S5", "ded"), ("S6", "des"), ("S7", "ddes"), ] W = H = 160 STRIDE = W // 8 # I1:20 字节/行 ROW_H = 15 BAND_MIN = 0.50 # 选中行白带:白像素占比下限 NBAND_LO = 0.03 # 非选行:白字占比下限(文本必须存在) NBAND_HI = 0.45 # 非选行:白像素占比上限(背景须为黑) # 根框几何(view_init):x=0,y=44,w=66,h=107 ROOT_X0, ROOT_Y0, ROOT_W, ROOT_H = 0, 44, 66, 107 # LVGL 池基线水位(menu build done,x64 模拟器):菜单结构/样式变更导致漂移时, # 先确认新值合理再更新此常量(内存回归门禁,见 docs/UI设计/UI_自动化测试流程.md) # 2026-09-14: 4784 → 5440(+656B)= run_headless 新增 ui_standby_init():待机模块 # 常驻屏 s_scr + 容器 s_cont 两个 lv_obj(先于池测量创建),属有意结构变更。 MENU_POOL_BASELINE = 5440 def load(path): data = open(path, "rb").read() if len(data) < STRIDE * H: raise RuntimeError("shot 过短: %s (%d)" % (path, len(data))) px = [[0] * W for _ in range(H)] for y in range(H): row = data[y * STRIDE:(y + 1) * STRIDE] for x in range(W): px[y][x] = (row[x // 8] >> (7 - x % 8)) & 1 return px def frac(px, x1, x2, y1, y2): tot = wh = 0 for y in range(y1, y2 + 1): for x in range(x1, x2 + 1): tot += 1 wh += px[y][x] return wh / tot if tot else 0.0 def root_row_strip(i): y1 = ROOT_Y0 + 1 + i * ROW_H return (ROOT_X0 + 1, ROOT_X0 + ROOT_W - 2, y1, y1 + ROW_H - 1) def l2_geom(root_sel, cnt): x0 = ROOT_X0 + ROOT_W - 1 # 65(共享边框列) y0 = ROOT_Y0 + 1 + root_sel * ROW_H # 顶对齐父框选中行行顶 w = 42 # 2+36+2 + 2 边框(3 汉字行) h = cnt * ROW_H + 2 return x0, y0, w, h def l2_row_strip(x0, y0, i, w): y1 = y0 + 1 + i * ROW_H return (x0 + 1, x0 + w - 2, y1, y1 + ROW_H - 1) fails = [] def check(cond, msg): if not cond: fails.append(msg) def raw(path): return open(path, "rb").read() def main(): if not EXE.exists(): print("FAIL: 找不到 %s(先构建 simulator)" % EXE) return 2 tmp = tempfile.mkdtemp(prefix="dtu_ui_rows_") paths = {} s0_out = None try: for name, seq in STATES: out = os.path.join(tmp, "rows_%s.bin" % name) r = subprocess.run([str(EXE), "--keys", seq, out], capture_output=True) if r.returncode != 0: print("[%s] 运行失败 rc=%d: %s" % (name, r.returncode, r.stderr.decode(errors="replace")[:200])) return 2 paths[name] = out if name == "S0": s0_out = r.stdout # S0(空键序)= 菜单基线:池水位门禁 m = re.search(r"pool after keys: used=(\d+)", s0_out.decode(errors="replace")) if not m: print("FAIL: S0 输出未找到 'pool after keys'") return 2 used = int(m.group(1)) check(used == MENU_POOL_BASELINE, "S0 池水位 %d != 基线 %d(菜单结构/样式变更?确认合理后更新常量)" % (used, MENU_POOL_BASELINE)) shots = {n: load(p) for n, p in paths.items()} S = shots def row_check(px, x1, x2, y1, y2, band, tag): f = frac(px, x1, x2, y1, y2) if band: check(f > BAND_MIN, "%s 选中行白占比 %.3f <= %.2f" % (tag, f, BAND_MIN)) # 带内须有黑字(文本落在白带上) check(frac(px, x1, x2, y1, y2) < 0.999, "%s 带内无黑字" % tag) else: check(NBAND_LO < f < NBAND_HI, "%s 非选行白占比 %.3f 不在 (%.2f,%.2f)" % (tag, f, NBAND_LO, NBAND_HI)) def border_check(px, x0, y0, w, h, tag): for y in range(y0 + 1, y0 + h - 1): check(px[y][x0] == 1 and px[y][x0 + w - 1] == 1, "%s 左右边框 y=%d 非白" % (tag, y)) for x in range(x0 + 1, x0 + w - 1): check(px[y0][x] == 1 and px[y0 + h - 1][x] == 1, "%s 上下边框 x=%d 非白" % (tag, x)) # --- S0:根框 7 行,sel=0 --- p = S["S0"] border_check(p, ROOT_X0, ROOT_Y0, ROOT_W, ROOT_H, "S0根框") for i in range(7): row_check(p, *root_row_strip(i), band=(i == 0), tag="S0行%d" % i) # --- S1/S2:sel 移位 --- for name, sel in (("S1", 1), ("S2", 2)): p = S[name] border_check(p, ROOT_X0, ROOT_Y0, ROOT_W, ROOT_H, name + "根框") for i in range(7): row_check(p, *root_row_strip(i), band=(i == sel), tag=name + "行%d" % i) # --- S4:L2 开于 sel=1 行右侧(3 子项、无箭头),根框区与 S1 相同 --- p = S["S4"] lx0, ly0, lw, lh = l2_geom(1, 3) border_check(p, lx0, ly0, lw, lh, "S4-L2框") for i in range(3): row_check(p, *l2_row_strip(lx0, ly0, i, lw), band=(i == 0), tag="S4-L2行%d" % i) # 根框区(x 0..65)须与 S1 逐像素一致 for y in range(H): for x in range(0, ROOT_X0 + ROOT_W): check(S["S1"][y][x] == p[y][x], "S4 根框区(x=%d,y=%d) 与 S1 不一致" % (x, y)) # L2 框下方区域须全黑(无残留对象) f = frac(p, lx0 + 1, lx0 + lw - 2, ly0 + lh + 1, min(ly0 + lh + 14, H - 1)) check(f == 0.0, "S4 L2 框下方有像素(占比 %.3f)" % f) # --- S5:L2 sel=1,根框区仍与 S1 同 --- p = S["S5"] lx0, ly0, lw, lh = l2_geom(1, 3) for i in range(3): row_check(p, *l2_row_strip(lx0, ly0, i, lw), band=(i == 1), tag="S5-L2行%d" % i) for y in range(H): for x in range(0, ROOT_X0 + ROOT_W): check(S["S1"][y][x] == p[y][x], "S5 根框区(x=%d,y=%d) 与 S1 不一致" % (x, y)) # --- 一致性状态:逐字节全像素比对 --- for a, b, tag in (("S3", "S1", "ddu==d"), ("S6", "S1", "des==d(往返)"), ("S7", "S2", "ddes==dd(往返)")): if raw(paths[a]) == raw(paths[b]): print("[%s] PASS 全像素一致 (%s)" % (a, tag)) else: pa, pb = S[a], S[b] diff = sum(1 for y in range(H) for x in range(W) if pa[y][x] != pb[y][x]) fails.append("%s 与 %s 不一致(%d px,%s)" % (a, b, diff, tag)) if fails: print("FAIL: %d 项断言未过" % len(fails)) for msg in fails[:40]: print(" -", msg) if len(fails) > 40: print(" ... 另 %d 项" % (len(fails) - 40)) return 1 print("PASS: 8 状态全部断言通过(%d 图,池基线 %d B)" % (len(paths), MENU_POOL_BASELINE)) return 0 finally: # 清理临时截图(失败时保留现场便于排查:仅成功时删) if not fails: shutil.rmtree(tmp, ignore_errors=True) if __name__ == "__main__": sys.exit(main())