Files
STM32F4-Base/docs/测试说明/单元测试/配置文件单元测试说明.md
T
wandering1 4a4ad6bc5f chore(全仓): @build/@test 旧文档别名清退为技能调用名
现状口径:/keil-build 与 /serial-boot-test 是 DSH 会话的技能调用名,
不是 shell 命令;「名 + 命令行参数」的命令形态改为可执行体
run_boot_test.py 的真实调用。

- AGENTS.md:别名说明重写为「技能调用名不是 shell 命令」,两条目改
  「编译(/keil-build)」「串口回归(/serial-boot-test)」
- keil-build / serial-boot-test 两 skill 调用名与示例同步;task-worktree
  方案文档去掉 @build/@test 惯例从句;@task 保留(不在点名范围)
- 123 文件现状陈述全改;台架捕获、变更记录/时间线、rename 映射输入
  等 41 行历史证据保留旧名
- gen_memory_svg.py 6 处措辞随改,3 张 SVG 产物同批重跑
- 顺手补并行方案同行漏网计数 51→52 钩子

验证:提交期快钩子对 123 文件实跑全 Passed;check_doc_links --all
rc=0(3038 处、无法判定 0);check_mermaid --all rc=0;YAML 解析 52
钩子不变;skill-size rc=0;py_compile 全过;字节卫生零漂移。
2026-10-30 13:03:12 +08:00

16 KiB
Raw Blame History

配置文件引擎单元测试说明(主机侧)

模块:Config File Engine(通用配置文件引擎,App/cfg/cfg_file.c) 测试类型:主机侧纯逻辑单元测试——在 PC 上离线验证 INI 解析、 值类型编解码、段注册校验与 load/save/poll 契约,零硬件、零烧录、毫秒级、不占 MCU 的 RAM。 状态:✅ 已落地(20 用例 / 139 断言点,/keil-build 0/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 变动
门禁层级 主机侧纯逻辑单测(介于静态门禁与板端测试套件之间):主机侧、秒级、纯逻辑
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. 如何扩展用例

  1. 在 cfg_file_test.c 加一个 static void tc_0NN_xxx(void);
  2. 用例开头调 vfs_reset()(复位模块静态态 + 虚拟盘 + 观测计数)+ register_std();
  3. 用 vfs_put(text) 建立/更新文件(会 mtime++,模拟一次外部写盘); 需"内容变但令牌不变"的边界用 vfs_put_notoken(text);
  4. 用 vfs_get() 取当前文件文本做检索断言(注释/未知段是否保留等);
  5. 用故障注入开关 s_fail_read / s_fail_write / s_fail_alloc / s_fail_stat 构造故障路径;
  6. 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 与板端套件的分工

关注点 主机侧纯逻辑单测(本单测) 板端测试套件
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,/keil-build 0 errors 0 warnings。

5.5 建议后续动作

  • 板端补一条回归:保存到已存在文件 → 重新 load → 断言值已按新值落盘 (现有板端套件只测了"文件缺失 → 重建"路径,是本缺陷长期漏网的直接原因)。
  • 检查是否存在同类"返回长度却按 == 0 判成功"的调用点(本次已全库核对 s_encode 的 4 处调用点,除本缺陷外其余正确)。