Files
STM32F4-Base/docs/测试说明/单元测试/配置文件单元测试说明.md
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

280 lines
16 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 配置文件引擎单元测试说明(主机侧)
> **模块**: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`](../../../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` 变动 |
| **门禁层级** | **主机侧纯逻辑单测**(介于静态门禁与板端测试套件之间):主机侧、秒级、纯逻辑 |
| **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)`:
```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,`/keil-build` 0 errors 0 warnings。
### 5.5 建议后续动作
- **板端补一条回归**:保存到**已存在**文件 → 重新 load → 断言值已按新值落盘
(现有板端套件只测了"文件缺失 → 重建"路径,是本缺陷长期漏网的直接原因)。
- 检查是否存在同类"返回长度却按 `== 0` 判成功"的调用点(本次已全库核对 `s_encode`
的 4 处调用点,除本缺陷外其余正确)。