本笔含两组收尾工作(同一工作区内交错在 6 个共享文档/门禁配置里, 拆开会产生"文档声称的页数与树里黄金数不一致"的中间提交,故合为一笔): 一、UI 补齐(清单清空) - 新建 6 个内容页模块:ui_page_accident / oper / alarm / start / yk 复用 ui_w_records 共享组件(分页、空态"无记录"、按实例读盘缓存), 记录层与五个写入点已由道F task-10 / 道G task-12 落地,本批只补页面; ui_page_runreport 为 13 行 2 页只读统计,四个数逐个挂范围标签 (自电池周期 / 本次开机),boot_ready=0 显 "--" 不显 0。 - 数据面:ui_data_info_t 尾部追加 9 字段,ui_data_feed 用 boot_stats_get() + iec104_serv_get_stats() 纯搬运;模拟器填确定值假数据 (差异登记 docs/测试说明/模拟器差距清单.md §2.3 两行)。 - 接线:菜单树 6 叶绑 #page=,绑定叶 22→28、UI_PAGE_MAP_COUNT=28; 字库 185→218 字 / 280→313 字形;双端注册 uvprojx +30、CMakeLists +6。 - 回归:内容页黄金 53→60(含 runreport_p2 真拍第 2 页),文本断言 425 正向 / 36 反向;存量 53 张逐字节零漂移;记录 5 页各用独立空目录 (模拟器把操作留痕写进 cwd 的 sim_oper.log,共用 tmp 会让空态拍不到)。 二、数据可信销项 + 「数据可信」任务卡号整族清退 - 删除已闭环的"已知电压注入验证"行与闭环结论块(证据在 docs/硬件平台/ADC通道映射说明.md §8.1 与 docs/测试说明/ADC前端注入验证流程.md §5~§7),§5 标识列改语义短语。 - 卡 0/0b/1~12 共 14 个号全仓 288 站点 / 56 文件清退成语义短语, 代码注释、测试、门禁配置与文档一并改;换算表入 docs/编号规范.md §9.3。 这一族是中文前缀,不进 check_ids 语料,刻意不入 id_aliases.json。 验证(实测) - @build 0 错 0 警:Code=236456 RO=28124 RW=1876 ZI=137084; RAM +464 B(RW +304 / ZI +160),RW_IRAM1 62.2% / RW_CCM 87.9%, 体积同码凭证与 RAM 台账已回填。 - check_menu_rows 8 状态 rc=0(池 5368/5448 常量未动); check_content_pages 60 页 rc=0;check_font_charset / registration / coding-standard / bom-drift / mojibake / doc-links --all / layer-deps / nested-comment 全 rc=0;check_ids --strict 无错误;主机单测 ui_data 295 / boot_stats 21 / sys_log_mcu 141 / soe 32 / soe_store 27。 未做(不是遗漏) - 六页真机判据(真记录翻页、掉电保持、清除后归零、boot_count 自增、 拔 RTC 电池落 RESTARTED、IWDG 复位落 rst_cause、主站连一次 conn_up+1) 已登记 docs/任务规划/登记簿.md §7;模拟器结构上照不到。 - docs/产品需求/产品需求设计文档.md 属另一条在飞改动,本笔不含; 其中我已同步 6 处现状数字(28 叶 / 60 页 / 218 字),随该笔入库。
17 KiB
配置文件引擎单元测试说明(主机侧)
模块:Config File Engine(通用配置文件引擎,
App/cfg/cfg_file.c) 测试类型:主机侧纯逻辑单元测试(L0.5 层)——在 PC 上离线验证 INI 解析、 值类型编解码、段注册校验与 load/save/poll 契约,零硬件、零烧录、毫秒级、不占 MCU 的 RAM。 状态:✅ 已落地(20 用例 / 139 断言点,@build0/0,已挂 pre-commit 钩子) 日期:2026-10-02 关联文档:../../AGENTS.md(平台约束 · FatFs/内存铁律)、测试开发标准流程.md(测试分层与门禁总览)、看门狗单元测试说明.md、系统时钟单元测试说明.md(同层范本)、../子系统设计/配置文件设计说明.md(模块设计)
1. 概述
1.1 为什么单独做主机侧单测
cfg_file.c(1250 行)是双端共享核心(MCU + PC 模拟器),承担:
| 半边 | 内容 | 可测性 |
|---|---|---|
| 纯逻辑(被测) | INI 段/键解析、四种值类型(UINT/IP4/MAC/STR)编解码、段注册校验、外科手术式保存(保留注释/未知段/未知键)、超长守卫、沉降去抖轮询与分段回调 | 纯"文本 ↔ 结构"变换,与硬件无关 → 可在 PC 上位穷举 |
| 平台后端 | 文件 I/O 与临时缓冲分配(cfg_file_store.h 的 8 个接口) |
已抽象为后端 → 主机侧给内存虚拟 FS 即可 |
板端套件(test/board/)无法穷举边界,更无法构造以下场景:
- 畸形 INI(段头前出现键值行、缺段、重复键、CRLF/LF 混排、段名/键名大小写混用);
- 四种值类型的边界(UINT 极值/带后缀、IP4 段数/超 255、MAC 分隔符/大小写、STR 恰好放下);
- 超长文件(>
CFG_FILE_MAX_LEN= 4096 B)→ 工厂默认 + 重建模板 + 自检留痕; - 故障注入:读取失败、写盘失败、堆分配失败(真机需掉电/坏块才能复现);
- 外科手术保存:对已存在文件保留注释/未知段、受管键原位改写、缺失段/键补写、输出超长拒写。
这些用主机侧单测一次性钉死,且不占 MCU 的 RAM(本项目 RAM 已 95%,见 docs/内存管理/)。
⭐ 本单测首次落地即抓出一处 P0 生产缺陷——
s_encode()的返回值被误判, 使外科手术保存的受管值改写路径沦为死码:对已存在的/config.ini保存时, 受管键的值从不被改写(静默不落盘,而net_cfg_save()仍返回成功并打 "save: OK")。详见 §5。
1.2 被测对象与桩
关键设计:cfg_file.c 的全部文件/堆操作都经 cfg_file_store.h 的 8 个接口
(cfg_store_alloc/free/heap_free、cfg_store_read、cfg_store_write_atomic、
cfg_store_stat、cfg_store_remove、以及 3 个路径查询)。测试单元提供这 8 个接口的
强符号桩(内存虚拟文件系统),生产代码零改动(缺陷修复除外,见 §5)。
test/host/cfg_file/cfg_file_test.c
├── #include "dbg_log.h" → stub/dbg_log.h (DBG_* → 计数)
├── #include "cfg_file_store.h" → 真实头(后端接口契约原汁)
├── #include "sys_log.h" → 真实头(syslog_id_t 枚举 + syslog_post 原型)
├── #include "cfg_file.h" → 真实头(被测 API 原型)
└── #include "cfg_file.c" ← 生产源文件(App/cfg/),原样纳入
| 后端接口 | MCU 端(cfg_file_store_mcu.c) |
主机侧桩 |
|---|---|---|
cfg_store_alloc/free |
FreeRTOS 堆 pvPortMalloc/vPortFree |
宿主 CRT malloc/free |
cfg_store_heap_free |
xPortGetFreeHeapSize |
恒返回 0(模拟器语义) |
cfg_store_read |
FatFS 读 /config.ini |
从内存虚拟盘复制;可注入失败 s_fail_read |
cfg_store_write_atomic |
临时文件 + 原子改名 | 写入内存虚拟盘并 mtime++;可注入失败 s_fail_write |
cfg_store_stat |
FatFS f_stat → (size, fdate<<16|fmtime) |
返回 (s_vfs_len, s_vfs_mtime);可注入失败 s_fail_stat |
cfg_store_remove |
FatFS 删除 | 清空虚拟盘存在标志 |
syslog_post |
写 /log/selftest.log |
计次 s_syslog_count(断言"超长/写失败"留痕) |
DBG_ERROR/INFO/DEBUG |
USART + RTC 时间戳 | 计数 g_cfgtest_err/info/debug_count |
生产端零增量:
cfg_file.c未因本单测新增任何#ifdef钩子(缺陷修复属独立 修正,见 §5)。
1.3 测试环境
| 依赖 | 版本 / 说明 |
|---|---|
| 编译器 | 本机 MSVC(VS2019/2022/2026 任一),由 run_unit_test.py 自动探测 |
| Python | 仓库 .venv 或任意 python3(纯标准库) |
| 硬件 | 无(不需要开发板 / 串口 / ST-LINK / NAND) |
工具链探测 / INCLUDE-LIB 拼接 / 编译运行 / 退出码判定全部委托通用 harness
../../test/host/common/unit_test_harness.py,
本模块 run_unit_test.py 只声明 TestBuild(源文件 / 桩目录 / 汇总行标记)。
1.4 运行方式
# 仓库根目录(统一用 .venv)
& '.venv\Scripts\python.exe' test/host/cfg_file/run_unit_test.py
退出码:0=PASS / 1=测试失败 / 2=环境错误(找不到编译器 / 编译失败 / 无汇总行)。
1.5 回归接入
| 项 | 说明 |
|---|---|
| 触发范围 | App/cfg/cfg_file.c / cfg_file.h、App/cfg/cfg_file_store.h、App/log/sys_log.h,或 test/host/cfg_file/**、test/host/common/unit_test_harness.py 变动 |
| 门禁层级 | L0.5 层(介于 L0 静态门禁与 L1 板端套件之间):主机侧、秒级、纯逻辑 |
| pre-commit 钩子 | cfg-file-unit-test(本地钩子,.pre-commit-config.yaml §7c) |
| 汇总行 | === Config File Tests: N/N PASSED (failed=0) === |
| 与板端测试的关系 | 互补:本单测保证解析/编解码/契约逻辑正确;板端保证 NAND 上 FatFS 真实读写链路正确 |
2. 测试用例
命名
TC-CFG-NNN(CFG = Config File)。全部在cfg_file_test.c内,主机侧运行。 部分用例直接调用static内部函数(白盒,因源文件被#include纳入同一编译单元)。
2.1 值类型编解码
| 用例 | 断言 | 验证的设计点 |
|---|---|---|
| TC-CONFIG-001 | UINT 1/2/4 字节往返(0 / 最大值);小端序(0x1234 → 34 12) |
小端编解码 |
| TC-CONFIG-002 | UINT 非法:空 / 非数字 / 带后缀 / 超范围 / 负数 / len=3 | 严格校验 |
| TC-CONFIG-003 | IP4:0.0.0.0 / 255.255.255.255 / 常规;段数不足/超、>255、非数字、尾点、空 |
点分四段 |
| TC-CONFIG-004 | MAC:: / - / . 三种分隔符、大小写;过短/过长/非十六进制/坏分隔符 |
分隔符 = 字节边界替代 |
| TC-CONFIG-005 | STR:正常 + NUL;恰好 len-1 放下;len 字符拒绝;空串 |
长度边界 |
| TC-CONFIG-006 | INT 合法:0 / -1 / INT32_MIN / INT32_MAX / int8·int16 正负边界 / 前导 + / -0 / 前导零 |
有符号十进制解码(「数据可信」有符号零点类型,引擎 v1.5) |
| TC-CONFIG-007 | INT 非法:空 / 非数字 / 尾随垃圾 / 裸符号 / 小数点 / 0x 文本 / 各宽度越界(含 strtol 饱和带 2147483648)/ 坏 len |
ERANGE + 宽度上下界双校验(两端 long=32 位) |
| TC-CONFIG-008 | INT 编码带负号(%ld);[sys] 段 load→save→load 全链往返一致、出厂模板带负号默认;越界键拒收留默认并留痕;注册拒绝 INT len=3 |
有符号落盘闭环(负零点偏移可表达) |
2.2 INI 解析
| 用例 | 断言 | 验证的设计点 |
|---|---|---|
| TC-CONFIG-010 | ; 与 # 注释、空行忽略 |
注释语义 |
| TC-CONFIG-011 | LF-only 行结束符解析正常 | CRLF/LF 兼容 |
| TC-CONFIG-012 | 段名/键名大小写无关;= 两侧与值两侧空白修剪 |
宽松匹配 |
| TC-CONFIG-013 | 键值行 = 前后空白、值尾空白被修剪 |
值修剪 |
| TC-CONFIG-014 | 未知键 / 未知段 / 缺失键 → 保留工厂默认,不崩溃 | 前向兼容 |
| TC-CONFIG-014b | 非法值 → 保留默认 + DBG_ERROR 留痕 |
无效键留痕 |
| TC-CONFIG-015 | 段头之前的键值行被忽略 | 段作用域 |
2.3 段注册校验
| 用例 | 断言 | 验证的设计点 |
|---|---|---|
| TC-CONFIG-020 | 正常注册;重复段名拒绝;NULL 描述符 / NULL p_data 拒绝 | 参数防御 |
| TC-CONFIG-021 | UINT len=3、MAC len≠6、STR len>79、key len=0 拒绝 | 键定义校验 |
| TC-CONFIG-022 | 段名 ≥ CFG_NAME_MAX(16) 拒绝 |
段名长度 |
| TC-CONFIG-023 | 挂回调段 data_size > CFG_SNAP_MAX(64) 拒绝;同段不挂回调则接受 |
回调段快照约束 |
2.4 load / save / poll 契约
| 用例 | 断言 | 验证的设计点 |
|---|---|---|
| TC-CONFIG-030 | 文件缺失 → 生成出厂模板写盘 + 返回 0;模板含段头与键 | 首启建文件 |
| TC-CONFIG-031 | 文件存在 → 有效键进 RAM 副本 | 正常加载 |
| TC-CONFIG-032 | 超长文件 → 工厂默认 + 重建模板 + 自检留痕 + 返回 0 | 损坏自愈 |
| TC-CONFIG-033 | 读取失败 → 返回 -1,RAM 保持工厂默认 | 读故障 |
| TC-CONFIG-034 | 未注册任何段 → 返回 -1 | 前置校验 |
| TC-CONFIG-040 | 文件缺失 → 重建完整文档 | 整文档生成 |
| TC-CONFIG-041 | 外科手术:保留注释/未知段/未知键,受管键原位改写 | ⭐ 核心特性 |
| TC-CONFIG-042 | 缺失受管段/键 → 文末补写 | 补写路径 |
| TC-CONFIG-043 | 输出超长 → 拒绝保存(-1) + 原文件不动 + 自检留痕 | 溢出保护 |
| TC-CONFIG-050 | 首轮 poll 建立基线,不重载 | 基线建立 |
| TC-CONFIG-051/052 | 沉降去抖(连续 2 次稳定才重载);与基线一致不重载 | 去抖语义 |
| TC-CONFIG-053 | 变更后重载 + 挂回调段差异触发回调 | 变更响应 |
| TC-CONFIG-055 | (size, mtime) 令牌不变 → 不重载(令牌 = 变更信号) |
令牌契约 |
| TC-CONFIG-054 | poll 检出超长 → 工厂默认 + 重建模板 + 留痕;不触发回调 | 在线修复通道保留 |
| TC-CONFIG-056 | [comm] 段容量钉:段数上限 8、键数上限 16 写进断言(改上限必须同批改本行与用例) |
段/键上限是配置引擎的公开契约,不能靠"跑起来看看" |
| TC-CONFIG-057 | 段/键写满后再新增 → 拒绝保存且原文件不动(满则拒,不是静默截断) | 溢出语义与 TC-CONFIG-043 同源,覆盖 [comm] 新增段 |
当前结果:139/139 PASSED(failed=0)。
3. 如何扩展用例
- 在
cfg_file_test.c加一个static void tc_0NN_xxx(void); - 用例开头调
vfs_reset()(复位模块静态态 + 虚拟盘 + 观测计数)+register_std(); - 用
vfs_put(text)建立/更新文件(会mtime++,模拟一次外部写盘); 需"内容变但令牌不变"的边界用vfs_put_notoken(text); - 用
vfs_get()取当前文件文本做检索断言(注释/未知段是否保留等); - 用故障注入开关
s_fail_read / s_fail_write / s_fail_alloc / s_fail_stat构造故障路径; CHECK(cond, "描述")断言;在main()里注册。
⚠
vfs_put与vfs_put_notoken的区别很关键:poll 依(size, mtime)判变更, 若只改内容而不动令牌(且长度不变),poll 按设计不会重载。构造"应重载"的场景 必须用vfs_put;构造"令牌不变"边界才用vfs_put_notoken。⚠
vfs_reset()直接memset生产模块的静态态(s_sections/s_base/s_sec_hdr/s_key_line等)——这是白盒单测的可接受代价(源文件已被#include, 静态态在同一编译单元内可见)。新增静态变量时若不复位,跨用例会互相污染。
4. 平台与隔离机制
4.1 为什么不需要 #ifdef 测试钩子(与看门狗的差异)
看门狗的平台层是内联 __weak 函数,测试时必须用 #ifndef TASK_WATCHDOG_UNIT_TEST
排除默认实现。cfg_file.c 则经 cfg_file_store.h 调用外部符号——这些符号在主机侧
本就不存在(只有声明),测试单元给出定义即可,无需任何生产代码钩子(与 sys_clock
同属"零侵入"范式)。
4.2 隔离:如何在不改生产代码的前提下复位模块静态态
引擎静态态(s_sections / s_base / s_last / s_stable / s_base_valid /
s_sec_hdr / s_key_line)均为文件静态、无公开复位接口。隔离手段:
vfs_reset() 一次性 memset 全部静态态 + 清空虚拟盘 + 清零观测计数,等价于"刚上电"。
段 RAM 副本(测试自有的 s_net_data / s_sys_data)同时复位工厂默认。
4.3 与板端套件的分工
| 关注点 | 主机侧 L0.5(本单测) | 板端 L1 |
|---|---|---|
| INI 解析 / 值编解码 / 契约逻辑 | ✅ 穷举 | 不覆盖 |
| 段注册校验 / 故障注入 | ✅ 穷举 | 不覆盖 |
| NAND 上 FatFS 真实读写 | ❌ | ✅ |
| 掉电安全 / 原子改名在真实 FS 上的行为 | ❌ | ✅ |
5. ⭐ 本单测抓出的 P0 生产缺陷(已修复)
5.1 现象
TC-CONFIG-041(外科手术保存)首次运行即失败:对已存在的 /config.ini 调用
cfg_file_save(),即使 RAM 副本的值已改(如 u8: 1 → 200),落盘文件仍是
原值 u8 = 1。
5.2 根因
s_encode() 的返回约定是"成功 = 文本长度(≥1);失败 = -1"(见函数头注释)。
但外科手术保存的 3 个调用点写成了 if (s_encode(...) == 0):
/* 旧代码(错误)——对任何非空值,s_encode 返回长度 ≥1,`== 0` 恒假 */
if (s_encode(p_key, (const uint8_t *)s_sections[i_sec].p_data + p_key->offset,
v, sizeof(v)) == 0) {
s_emit_kv(...); /* 永远不执行 */
} else {
s_emit_line(p_out, ..., p_line); /* 永远走这里:原样输出旧行 */
}
结果:s_emit_kv(写入新值)成为死码,受管键行永远走 else 原样输出。
唯一判对的地方是 s_gen_document()(整文档生成,用 < 0),故只有"文件缺失/损坏 →
重建文档"路径正常——这解释了为何此前从未暴露:
- 板端套件保存时目标文件总是缺失/新建(走
s_gen_document); - 真机首次上电后
/config.ini由模板创建,此后每次改 IP 保存都走外科手术路径 → 受管值静默不落盘,而net_cfg_save()仍返回 0 并打save: OK。
5.3 影响面
| 维度 | 评估 |
|---|---|
| 触发条件 | 已存在 /config.ini(正常运行的设备 100% 命中) |
| 后果 | 网络配置(IP/掩码/网关/MAC)经 UI 或 FTP 修改后保存不生效(重启后回旧值) |
| 可观测性 | ❌ 无任何报错——返回成功、日志 "save: OK",属静默数据丢失 |
| 注释/未知段保留 | 该特性本身正常(走 s_emit_line)——讽刺的是"保留原文"正是掩盖 bug 的原因 |
| 引入时间 | 自 cfg_file 首个提交 9766141 起存在,从未修复 |
5.4 修复
将 3 个外科手术调用点的判定由 == 0 改为 >= 0(与 s_gen_document 及函数约定一致),
并加注释说明返回值语义:
/* s_encode 成功返回文本长度(≥1),失败返回 -1:判成功用 `>= 0`。
* 旧代码写 `== 0`(恒假)→ 受管键行永远走 else 原样输出 →
* 对已存在文件保存时受管值从不改写(静默不落盘,2026-10-02 修复) */
if (s_encode(p_key, ..., v, sizeof(v)) >= 0) {
s_emit_kv(...);
}
修复后 TC-CONFIG-041 / TC-CONFIG-042 转 PASS,@build 0 errors 0 warnings。
5.5 建议后续动作
- 板端补一条回归:保存到已存在文件 → 重新 load → 断言值已按新值落盘 (现有板端套件只测了"文件缺失 → 重建"路径,是本缺陷长期漏网的直接原因)。
- 检查是否存在同类"返回长度却按
== 0判成功"的调用点(本次已全库核对s_encode的 4 处调用点,除本缺陷外其余正确)。
6. 变更记录
| 日期 | 变更 |
|---|---|
| 2026-10-02 | 创建;20 用例 / 139 断言点全绿;抓出并修复 s_encode 返回值误判 P0 缺陷(外科手术保存死码);挂 pre-commit 钩子 cfg-file-unit-test |
| 2026-10-25 | 随配置引擎新增 CFG_VAL_INT(「数据可信」有符号零点类型)补登记 TC-CONFIG-006~008;套件 200/200 PASS |