# 配置文件引擎单元测试说明(主机侧) > **模块**:Config File Engine(通用配置文件引擎,`App/cfg/cfg_file.c`) > **测试类型**:**主机侧纯逻辑单元测试**(L0.5 层)——在 PC 上离线验证 INI 解析、 > 值类型编解码、段注册校验与 load/save/poll 契约,零硬件、零烧录、毫秒级、不占 MCU 的 RAM。 > **状态**:✅ 已落地(20 用例 / 139 断言点,`@build` 0/0,已挂 pre-commit 钩子) > **日期**:2026-10-02 > **关联文档**: > [`../../AGENTS.md`](../../AGENTS.md)(平台约束 · FatFs/内存铁律)、 > [`测试开发标准流程.md`](测试开发标准流程.md)(测试分层与门禁总览)、 > [`看门狗单元测试说明.md`](看门狗单元测试说明.md)、 > [`系统时钟单元测试说明.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`](../../test/host/common/unit_test_harness.py), 本模块 `run_unit_test.py` 只**声明** `TestBuild`(源文件 / 桩目录 / 汇总行标记)。 ### 1.4 运行方式 ```powershell # 仓库根目录(统一用 .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. 如何扩展用例 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 与板端套件的分工 | 关注点 | 主机侧 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)`: ```c /* 旧代码(错误)——对任何非空值,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` 及函数约定一致), 并加注释说明返回值语义: ```c /* 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 |