对应 Code_Review_Report §15.6 第 1 项(方案 B:有结构,无列视图)。
结构
- EdgePanelLayout 的 16 个平行 std::vector 合并为 std::vector<PanelRow> rows,
PanelRow 带 17 个具名成员:ref / header / nameRect / nameClose / triangle / check /
stats / levelTrack / levelValue / levelReset / iterTrack / iterValue / iterReset /
infoMaxInterval / infoMinInterval / intervalField / frequencyField。
- 唯一新增入口:AppendRow(ref, header, nameRect, triangle, check)(返回新行引用)与
AppendMarkerGroupRow(ref, nameRect, nameClose)(「自由标记」分组行)。
参数行/重采样行一律 layout.rows.back().xxx = … 赋值。
- 原 int rows(可见信号数)与新容器同名 ⇒ 改名 signalCount,并注明它与 rows.size()
在 MARK 面板里不相等(只有有固定点的信号才有行)。
消掉的两类问题
1. 某分支漏 push 一列 ⇒ 该列之后所有行读到别人的矩形(静默错位)。现在一行一次成型。
2. rowRefs 字段重载(列表式 = 行信号 / MARK = 分组信号,其余 12 列必须为空)。
现在每行自带 ref,语义由“填哪些字段”表达;不变式简化为语义断言
(每行必须有名称区;MARK 行不得带 header/check/stats/滑块/重采样字段)。
顺带清理
- 合并后 rowIndex < layout.rowXxx.size() 全部变成恒真,批量删除 50+ 处
(含 15 元组长度数组、? x : gd::RectF() 三元判空、if(...){extendRow(...)} 包裹)。
- 调试框与缓存键改为逐行遍历(新增字段只需在 PanelRow 与这两处循环各加一行)。
等价性证明(rowgeom 键名刻意保持不变,故可逐键比对)
old keys = 919, new keys = 965
仅存在于旧 = 0;已有键值变化 = 20(全部是 ".count":0 -> N)
仅存在于新 = 46(全部是新增列的 [NNN],值 0.00,0.00,0.00,0.00)
⇒ 逐行逐字段的真实几何零位移;7 个面板像素哈希(含 resample=2BFC74B3D9B3239C)逐项不变。
验证
- make release:零 warning;784,896 → 782,336 B(−2,560);make debug / make -C Tools 同样零 warning
- UnitTests:checks=260 failed=0 cases=19
- OffscreenChecks -all:7 检查 / 133 断言(上轮 127)全 PASS
- panels -mode compare:7 面板像素哈希逐项不变
- rowgeom -mode compare:965+7 键 0 差异;-data MF4-Record.MF4 时 1268+7 键 0 差异
- 读点:旧 243 处 layout.rowXxx[…] → 现 95 处 layout.rows[…](84 命中判定 / 11 绘制)
过程记录:清理恒真判空时曾用整段替换把「均值滤波」双滑块段误换成「弱峰」双滑块,
编译器报 brace 不平衡,用备份回滚后重做 —— 整段替换必须先精确读出上下文。
文档:Plan §十一、Progress 第 39 轮、Report §16、agent_mem 第 39 轮、Tools/README §4.1/§4.4
16 KiB
Tools/ —— 单元测试与离屏验证工具
本目录是 验证基础设施,不是产品代码: 根目录
Makefile只构建应用本体(Build/Release、Build/Debug), 不会编译这里的任何文件;这里的一切由Tools/Makefile独立构建。设计目标:让"改完能不能证明没改坏"变成一条命令的事, 而不是靠肉眼截图 + 反复手点鼠标。
1. 目录结构
Tools/
├─ Makefile # 统一构建入口(构建 / 清理 / 帮助)
├─ README.md # 本文件(改动工具后必须同步更新)
├─ UnitTests/ # 工具一:纯数据层单元测试(不创建窗口)
│ ├─ Inc/
│ │ └─ UnitTestFramework.hpp
│ ├─ Src/
│ │ ├─ Main.cpp # 测试执行器:-list / -only / -report / -verbose
│ │ ├─ Utf8UtilTests.cpp # 数值解析与格式化语义
│ │ ├─ FormulaEvalTests.cpp# 公式语法 / 语义 / 批量求值一致性
│ │ └─ DataModelTests.cpp # 变换顺序、滑动平均、公式值域缓存
│ └─ Build/ # 构建产物(.gitignore,不入库)
└─ OffscreenChecks/ # 工具二:离屏验证(隐藏窗口 + 抓帧)
├─ Inc/
│ ├─ OffscreenSupport.hpp# 白盒支撑:抓帧 / 哈希 / 隐藏窗口会话
│ └─ CheckRunner.hpp # 检查注册表 + 断言 / 输出接口
├─ Src/
│ ├─ Main.cpp # 命令行入口
│ ├─ Session.cpp # 隐藏窗口、加载、逐帧渲染(合成鼠标消息)
│ ├─ Capture.cpp # 后备缓冲抓帧、FNV 哈希、PNG 落盘
│ └─ Checks.cpp # 5 个检查的具体实现
└─ Build/ # 构建产物(.gitignore,不入库)
约定
| 项 | 约定 |
|---|---|
| 头文件 | 只放 <Tool>/Inc |
| 实现 | 只放 <Tool>/Src(该目录下每个 .cpp 都会被编译) |
| 产物 | <Tool>/Build,与 Inc / Src 同级 |
| 无 Debug/Release 之分 | 工具一律 -O2 -g:要跑得快、要能上调试器 |
| 临时产物 | PNG / 基线 / 报告一律写 Temp/tools/(已 gitignore),不许写进 Tools/ |
2. 构建
# 前置:应用本体必须先构建(工具链接的是本体的 .o,排除入口 Main.o)
make release -j8
# 构建全部工具
make -C Tools
# 只构建某一个
make -C Tools UnitTests
make -C Tools OffscreenChecks
# 清理(只删两个工具自己的 Build/,不动本体)
make -C Tools clean
# 看帮助
make -C Tools help
产物:
Tools/UnitTests/Build/UnitTests.exe
Tools/OffscreenChecks/Build/OffscreenChecks.exe
⚠️ 必须与本体保持同一套 ABI 开关:
-fno-exceptions、-fno-rtti、 链接时-flto -flto-partition=one。工具源码自身不加-flto(否则 GCC 15.2.0 在choose_baseaddr处 ICE)。⚠️ 头文件依赖用
-MMD -MP跟踪:改头文件、增删源文件都会自动重编重链。 若出现"离奇的 undefined reference",先make -C Tools clean。
3. 工具一:UnitTests(纯逻辑,最快、最常用)
不创建窗口、不初始化 GUI,因此可以无限次在后台运行。
Tools\UnitTests\Build\UnitTests.exe # 全部
... -only formula # 只跑名字含 formula 的用例
... -list # 列出用例
... -report ..\Temp\tools\ut.txt # 指定报告路径
输出约定(便于脚本 / Agent 解析):
[RUN ] formula_semantics
[FAIL] <表达式> (file:line)
SUMMARY checks=<n> failed=<m> cases=<k> result=<PASS|FAIL>
3.1 测试项
| 用例 | 覆盖内容 | 为什么重要 |
|---|---|---|
parse_fixed_corpus_matches_strtod |
常见字面量(含 1e22、-0、1e-22)与 strtod 逐位相同 |
数值解析的基准线 |
parse_acceptance_semantics |
空/空白/垃圾/尾随字符/十六进制浮点/inf/nan 的接受性 |
与改造前的宽松语义一致 |
parse_random_corpus_bit_exact |
6 万条随机十进制串;≤15 位有效数字要求逐位相同,≤19 位允许 ≤1 ULP | 「自写 strtod 替身」类优化的唯一验收标准 |
format_decimal_never_scientific |
FormatDecimal* 永不输出科学计数法;FormatValue = %.6g |
显示层硬约束 |
format_byte_size_and_frequency |
单位后缀存在且无指数形式 | 单位格式化回归 |
text_trim_and_utf8_conversion |
Trim / ToLowerAscii / Utf8ToWide / 大小写无关包含 |
文本基础 |
ticks_nice_step_and_build_ticks |
NiceStep 落在 1/2/2.5/5×10ⁿ;BuildTicks 端点 |
标尺可读性 |
formula_syntax_acceptance |
合法 / 非法写法(单独 f、2e、()、*2 …) |
公式输入框的报错边界 |
formula_semantics |
2^3^2=512、-2^2=-4、2^-1、e/pi、恒等 |
语法语义 |
formula_identity_helpers |
f(t) ≡ 空串;HasFormula();NormalizeFormulaText |
第 32 轮「恒等公式卡死」的护栏 |
formula_program_matches_pointwise |
批量求值 vs 逐点求值 逐位相同(8 条公式 × 4000 点) | 常量折叠 / 幂次改写不许改数值 |
formula_invalid_falls_back_safely |
非法公式原样返回采样值 | 写错公式不得让波形消失 |
transform_apply_order |
公式→系数→绝对值→垂直镜像→Y 偏移;NaN 保持;水平镜像只改下标 | 变换顺序 |
transform_value_range_and_order_preserving |
区间映射(含跨 0 取绝对值)与保序判定 | Y 轴自适应 |
filter_moving_average_equivalence |
1 阶 ≡ 单次、2 阶 ≡ 链式、常数信号不变 | 均值滤波多阶 |
dataset_group_access |
AddGroup / GroupAt / Style(ref) 读写 |
数据层入口 |
dataset_formula_transform_cache |
值域 + 逐分组极值 vs 逐点参考逐位相同;命中同指针;样式变化失效 | 第 32 轮核心缓存的正确性 |
stdio_mingw_vs_ucrt_formats |
MinGW ANSI stdio 与系统 UCRT 的 printf 在本程序用到的全部格式上逐字符比对(13.6 万次调用) | 评估“把 swprintf 交给 UCRT”是否可行(结论:0 字节,libstdc++ 强制开 MinGW 版本) |
integer_replacement_matches_std |
FormatInteger* 与 std::to_wstring / std::to_string 逐字符对照(含 LLONG_MIN/MAX、ULLONG_MAX) |
全仓 78 处整数转换的语义护栏(它们已替代标准库,以去掉 printf 依赖) |
3.2 验收标准
- 零失败(
result=PASS)才算通过; - 断言数量只增不减(当前 260 项 / 19 个用例):删断言必须说明理由;
- 任何"为了让它过"而放宽的断言,必须在代码注释里写清依据。
4. 工具二:OffscreenChecks(真实渲染,隐藏窗口)
不显示窗口、不移动真实鼠标、不抢焦点:窗口 SW_HIDE 创建,
"鼠标"通过 Application::HandleMouseMove() 注入合成消息,
像素从后备缓冲区读取(不是窗口 DC)。
Tools\OffscreenChecks\Build\OffscreenChecks.exe -list
... -all # 依次跑全部检查
... -run smoke # 单个检查
... -run panels -mode record # 记录面板像素基线
... -run panels -mode compare # 与基线比对(重构前后 A/B)
... -run rowgeom -mode record # 记录逐行几何基线(默认数据)
... -run rowgeom -mode compare # 逐项比对几何(含每行每列矩形)
... -data "..\TestData\MF4-Record.MF4" -run rowgeom -mode record # 566 行规模基线
... -run frames -frames 12 # 连续抓 12 帧
... -data "..\TestData\MF4-Record.MF4" -run perf
... -out ..\Temp\tools\run1 # 换输出目录(避免覆盖基线)
... -no-png # 不落盘 PNG(只算哈希/耗时)
数据文件默认按顺序探测 TestData/ 里的
SCATTER_TEST.csv → [1].CSV → BIG_DATA.csv → Problem_Data.CSV;
TestData/ 不入库,缺失时请用 -data 指定。
默认路径(数据 / 输出 / 报告)同时支持在
Tools/下运行和在仓库根目录运行 (两种写法会自动回退),因此下面两种调用等价:cd Tools ; .\OffscreenChecks\Build\OffscreenChecks.exe -all .\Tools\OffscreenChecks\Build\OffscreenChecks.exe -all显式
-data/-out/-report仍以当前工作目录为准。
4.1 检查项
| 检查 | 做了什么 | 断言 / 判据 |
|---|---|---|
smoke |
建隐藏窗口 → 加载 → 显示 3 条信号 → 渲染 → 抓帧 → 落盘 smoke.png |
客户区/绘图区尺寸合理、绘图区墨迹 > 1000 像素、同一状态连续两帧逐像素相同(渲染确定性) |
frames |
沿信号列表逐行移动"鼠标",每帧抓图 + 区域哈希 + 耗时 | 平均帧 < 500ms、帧序列至少出现一次可见变化;输出 frame-XXX.png 供事后分析 |
panels |
打开全部 7 个实验性面板(含双光标下的「选区截取」) | 每个面板:功能已启用、有尺寸、必须被绘图区夹住、行结构自检(每行都要有名称区;「自由标记」行不得带参数行字段,见 §4.4);-mode record 写 panels-baseline.txt,-mode compare 逐面板比对像素哈希 + 几何 |
panelscroll |
全部信号合并到同一网格后,逐一滚动 7 种面板的内容区 | 可滚动时:滚到底/滚回顶、scrollOffset 夹到 maxScroll、滑块到达/返回轨道端点、内容区几何不动;不可滚动时不得出现滚动条。另守住“每行都有名称区” |
rowgeom |
逐行几何基线:7 个面板按「细节 / 规模」两层导出全部矩形并比对 | 细节层(6 信号 1 网格、参数行展开、3 个固定点、折起+展开)导出每行全部字段共 965 条键(-data MF4-Record.MF4 时 1268 条);规模层(全部信号 1 网格)导出每面板 FNV-1a 摘要 + 行数。-mode record / -mode compare 双向比对(MISSING / ADDED / CHANGED) |
edgestats |
3 个阈值下的边沿计数(普通计数)+ 中心轴计数 | 与 Checks.cpp 内的独立参考实现(朴素逐点)逐位相等;同参数重复查询结果稳定 |
perf |
整帧中位数、EdgePanelLayoutFor 单次耗时、198 个统计状态的整轮查询耗时 |
整帧 < 500ms、面板几何 < 200µs/次、统计查询 < 20µs/次;数字用于前后对比 |
4.2 像素基线的正确用法(A/B)
# 1) 改动前:记录基线(同一台机器、同一 DPI、同一数据文件)
OffscreenChecks.exe -run panels -mode record -out ..\Temp\tools\panel-before
# 2) 改代码 + 重新构建本体与工具
# 3) 改动后:用同一输出目录比对
OffscreenChecks.exe -run panels -mode compare -out ..\Temp\tools\panel-before
⚠️ 像素哈希是"本机、本 DPI、本数据文件"的基线,跨机器/跨缩放不可比。 它适合"我改了一处,其它像素有没有动"的本地 A/B,不适合入库当黄金图。
4.3 几何基线(rowgeom)与像素基线的分工
像素基线(panels) |
几何基线(rowgeom) |
|
|---|---|---|
| 回答的问题 | “用户看到的画面变了吗” | “哪个面板的哪一行哪一列变了吗” |
| 粒度 | 整个面板区域一个哈希 | 每个矩形的每个字段(mean.rowNameRects[001]=x,y,w,h) |
| 定位能力 | 只能告诉你“变了” | 直接给出字段名、行号、新旧值 |
| 规模覆盖 | 固定场景 | 另加“全部信号 1 网格”(MF4 = 566 行)的摘要哈希 |
两者都要跑:像素基线防止“几何没变但画错了”,几何基线防止“画得没错但位置挪了”。
基线是检测器,不是黄金图:预期内的几何变化要重新 -mode record,并在提交信息里写明原因。
4.4 长度不变式为什幺要“按类别”判定(2026-11-04 已作废,改为结构+语义断言)
⚠️ 本节描述的历史问题已经不存在:
EdgePanelLayout的 16 个平行数组 已在第 39 轮合并为std::vector<PanelRow> rows,“某列比别的列短”在结构上不可能。 现在panels/panelscroll守的是语义:每行必须有名称区;「自由标记」行不得带header/check/stats/滑块/重采样字段。rowgeom的键名(<panel>.<旧列名>[NNN]) 刻意保持不变,因此重构前后可以直接逐键比对(919 → 965 键时,消失 0 条、 已有值变化仅 20 条.count、新增 46 条全为空矩形 ⇒ 几何零位移)。
历史背景(第 37–38 轮):当时 EdgePanelLayout::rowRefs 是重载字段 ——
列表式面板里是"第 i 行的信号",而「自由标记」面板里是"第 i 个信号分组",
且只填 rowNameRects / rowNameCloses,其余 12 列必须为空。
因此那时的长度不变式只能按 PanelStyleOf(kind).markerGroups 分流判定。
第 39 轮合并成 PanelRow 后,这类"同一字段两种含义"的判据全部消失。
4.5 输出(全部在 Temp/tools/,已 gitignore)
| 文件 | 内容 |
|---|---|
offscreen-report.txt |
完整控制台输出(含 SUMMARY 行) |
UnitTests-report.txt |
单元测试报告 |
smoke.png / panels.png / perf.png |
关键状态截图 |
frame-000.png … |
连续帧(问题分析用) |
panels-baseline.txt |
面板像素基线(-mode record 生成) |
rowgeom-baseline.txt |
逐行几何明细基线(-mode record 生成,约 1000 条键) |
rowgeom-scale-baseline.txt |
规模场景的每面板摘要哈希(-mode record 生成,7 条键) |
5. 怎么用它(按改动类型选最小集)
| 你改了什么 | 建议跑 |
|---|---|
| 任何数据层 / 解析 / 公式 / 变换 | UnitTests.exe |
| 绘图、面板、布局、缓存键 | -run smoke -run panels(+ 需要时 -mode compare) |
| 面板几何 / 行距 / 内边距 / 折起规则 / 布局算式 | -run panels -mode compare + -run rowgeom -mode compare(改动前先 -mode record) |
| 面板内容滚动 / 新增面板种类 | -run panels -run panelscroll(大文件加 -data ..\TestData\MF4-Record.MF4) |
| 边沿统计 / 中心轴 / 扫描预算 | -run edgestats -run perf |
| 性能相关 | -run perf(前后各跑一次,比 us/query 与 median ms) |
| 完全不确定影响面 | -all(约几秒到几十秒)+ UnitTests.exe |
用户明确要求:除非要求"全测一遍",否则只跑针对性检查。
6. 新增一个检查 / 一个工具
6.1 新增离屏检查
- 在
OffscreenChecks/Src/Checks.cpp里写int CheckXxx(Session&, const CheckOptions&), 用Expect()/ExpectEqual()记录断言,末尾return CheckFailed(); - 在
AvailableChecks()的列表里追加一行{ "xxx", "one-line summary", CheckXxx } - 在 README 的 §4.1 表格里补一行(必须)
6.2 新增工具
- 建
Tools/<NewTool>/{Inc,Src} - 在
Tools/Makefile的TOOLS里追加<NewTool>(目录名 = 目标名) - 在本 README 的 §1 / §2 / §5 里补说明
7. 已知限制与坑
- 工具是白盒:
OffscreenSupport.hpp用#define private public放宽访问级别, 为了读后备缓冲区 / 直接调面板几何。产品接口不因测试而放宽。 - 必须先构建本体:工具链接
../Build/Release/*.o,本体没构建会给出明确警告。 - 像素基线不可跨机复现:DPI、字体、主题不同则哈希不同。
frames里个别帧可能重复(鼠标落在筛选框/空白带),因此判据是"至少一次可见变化"。- 窗口一律
SW_HIDE:不要为了"看看效果"改成显示窗口,那会占用用户屏幕。 需要看图请直接打开Temp/tools/*.png。 - 进程退出用
std::_Exit():Application/PlotView是单例, 若走正常析构会在GdiplusShutdown之后访问 GDI+ 对象(历史退出码0xC0000005)。 - 报告/截图不要提交:全部落在
Temp/(已忽略)。Tools/*/Build/也已忽略, 其它协作者本地make -C Tools即可重建。