4a4ad6bc5f
现状口径:/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 全过;字节卫生零漂移。
280 lines
16 KiB
Markdown
280 lines
16 KiB
Markdown
# 配置文件引擎单元测试说明(主机侧)
|
||
|
||
> **模块**: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 处调用点,除本缺陷外其余正确)。
|