测试:新增 cfg_file 主机侧单测并修复外科手术保存死码缺陷(S1-b+S2)
CI / L0 静态门禁 (pre-commit) (push) Successful in 49s
CI / L0.5 主机侧单测 (MSVC) (push) Successful in 17s

This commit is contained in:
2026-10-02 13:00:54 +08:00
parent f3befaed08
commit fe963e7d91
11 changed files with 1254 additions and 9 deletions
+16
View File
@@ -176,6 +176,22 @@ repos:
files: ^(App/util/sys_clock\.(c|h)|Drivers/BSP/SD2506/sd2506\.h|test/host/sys_clock/.*|test/host/common/unit_test_harness\.py)$
pass_filenames: false
# 7c) 配置引擎主机侧单元测试(2026-10-02 加,S1-b)
# 背景:cfg_file.c 的核心是"文本 <-> 结构"纯变换(INI 段/键解析、四种值
# 类型编解码、外科手术式保存、超长守卫、load/save/poll 契约);板端套件只
# 保存到缺失/新文件(走 s_gen_document 路径),**从不触发对已存在文件的外科
# 手术改写**,故掩盖了该路径的缺陷。本钩子用 MSVC 离线编译真实源文件
# (#include 纳入)+ 内存虚拟文件系统后端驱动,首次落地即抓出 P0:
# `s_encode(...) == 0` 恒假(其成功返回文本长度而非 0)使**外科手术保存的
# 受管值改写路径沦为死码**——对已存在 /config.ini 保存时受管键从不改写(静默
# 不落盘,且 net_cfg_save 仍报 OK)。详见测试说明文档。
- id: cfg-file-unit-test
name: 配置引擎单测(主机侧)
entry: python test/host/cfg_file/run_unit_test.py
language: system
files: ^(App/cfg/cfg_file\.(c|h)|App/cfg/cfg_file_store\.h|App/log/sys_log\.h|test/host/cfg_file/.*|test/host/common/unit_test_harness\.py)$
pass_filenames: false
# 8) 测试套件开关门禁(2026-10-01 加)
# 背景:test_config.h 的 TEST_SUITE_* 决定固件里编入哪个测试套件,
# 全部注释 = 产品构建。但此前**无任何门禁检查**——实测已提交的 HEAD 里
+10 -3
View File
@@ -731,8 +731,11 @@ static int s_surgical(char *p_in, size_t in_len, char *p_out, size_t out_cap, si
if (s_key_line[idx][k] == 0xFFFFu) {
const cfg_key_def_t *p_key = &s_sections[idx].p_keys[k];
char v[80];
/* s_encode 成功返回文本长度(≥1),失败返回 -1:
* 故判成功必须 `< 0` 取反,不能写 `== 0`(恒假,
* 使补写路径沦为死码,见 2026-10-02 L0.5 修复) */
if (s_encode(p_key, (const uint8_t *)s_sections[idx].p_data + p_key->offset,
v, sizeof(v)) == 0) {
v, sizeof(v)) >= 0) {
if (s_emit_kv(p_out, out_cap, &off, p_key->name, v) != 0) {
return -1;
}
@@ -759,8 +762,11 @@ static int s_surgical(char *p_in, size_t in_len, char *p_out, size_t out_cap, si
if (is_key) {
const cfg_key_def_t *p_key = &s_sections[i_sec].p_keys[k];
char v[80];
/* s_encode 成功返回文本长度(≥1),失败返回 -1:判成功用 `>= 0`。
* 旧代码写 `== 0`(恒假)→ 受管键行永远走下面 else 原样输出 →
* 对已存在文件保存时**受管值从不改写**(静默不落盘,2026-10-02 修复) */
if (s_encode(p_key, (const uint8_t *)s_sections[i_sec].p_data + p_key->offset,
v, sizeof(v)) == 0) {
v, sizeof(v)) >= 0) {
if (s_emit_kv(p_out, out_cap, &off, p_key->name, v) != 0) {
return -1;
}
@@ -801,8 +807,9 @@ static int s_surgical(char *p_in, size_t in_len, char *p_out, size_t out_cap, si
for (k = 0; k < (int)p_sec->key_count; k++) {
const cfg_key_def_t *p_key = &p_sec->p_keys[k];
char v[80];
/* 同上:s_encode 成功 = 文本长度(≥1),判成功用 `>= 0` */
if (s_encode(p_key, (const uint8_t *)p_sec->p_data + p_key->offset,
v, sizeof(v)) == 0) {
v, sizeof(v)) >= 0) {
if (s_emit_kv(p_out, out_cap, &off, p_key->name, v) != 0) {
return -1;
}
+1
View File
@@ -99,6 +99,7 @@ docs/
|---|---|
| [`看门狗单元测试说明.md`](测试说明/看门狗单元测试说明.md) | Task Watchdog 判定状态机(位图 / 掩码 / 节流 / tick 回绕),39 用例 |
| [`系统时钟单元测试说明.md`](测试说明/系统时钟单元测试说明.md) | sys_clock 时间换算 / 星期 Zeller / 30 天惰性再基准化 / RTC 故障降级,100 断言点 |
| [`配置文件单元测试说明.md`](测试说明/配置文件单元测试说明.md) | cfg_file INI 解析 / 值编解码 / load-save-poll 契约 / 超长守卫,139 断言点(含抓出的外科手术保存死码 P0) |
**模块测试指南**(动手写测试前先读)
@@ -36,10 +36,10 @@
<!-- MEM_SNAPSHOT:BEGIN generated by scripts/check_mem_numbers.py -->
| 区域 | 用量 | 容量 | 占比 |
|---|---|---|---|
| ER_IROM1 | 261,200 B (255.08 KB) | 1,048,576 B (1024 KB) | 24.9% |
| ER_IROM1 | 261,196 B (255.07 KB) | 1,048,576 B (1024 KB) | 24.9% |
| RW_IRAM1 | 84,864 B (82.88 KB) | 131,072 B (128 KB) | 64.7% |
| RW_CCM | 49,184 B (48.03 KB) | 65,536 B (64 KB) | 75.0% |
| **Flash (ROM 合计)** | **261,900 B (255.8 KB)** | 1,048,576 B (1024 KB) | **25.0%** |
| **Flash (ROM 合计)** | **261,896 B (255.8 KB)** | 1,048,576 B (1024 KB) | **25.0%** |
<!-- MEM_SNAPSHOT:END -->
各区的容量与常量来源:
@@ -240,6 +240,8 @@ static void phaseN_run(void) {
| `test/host/verify_log.py` | `test/host/` | 日志标记校验(Required / Forbidden → PASS / FAIL)+ 可选套件汇总行校验(T-2) |
| **`test/host/common/unit_test_harness.py`** | `test/host/common/` | **L0.5 通用单测 harness**:工具链探测 / `INCLUDE`-`LIB` 拼接 / 编译运行 / 退出码契约。各模块运行器只做声明式配置(T-3) |
| **`test/host/watchdog/run_unit_test.py`** | `test/host/watchdog/` | **L0.5 主机侧单测**:声明 `TestBuild` 后调 harness,穷举看门狗判定状态机(零硬件、秒级)。**需本机装有 VS(`cl.exe`)**,见 §4.2 环境要求 |
| **`test/host/sys_clock/run_unit_test.py`** | `test/host/sys_clock/` | **L0.5 主机侧单测**:穷举 `sys_clock` 时间换算 / 星期 / 惰性再基准化 / RTC 故障降级(100 断言,S1-a) |
| **`test/host/cfg_file/run_unit_test.py`** | `test/host/cfg_file/` | **L0.5 主机侧单测**:INI 解析 / 值编解码 / 段注册 / load-save-poll 契约,内存虚拟 FS 驱动(139 断言,S1-b) |
| **`scripts/ci_local.py`** | `scripts/` | **本地跑与 CI 等价的门禁**(L0 + L0.5):自动选用装了 pre-commit 的解释器;`.map` 自适应跳过 `mem-numbers`;退出码 0/1/2 与 CI 对齐。见 §9.3 |
| `.opencode/serial_config.json` | 仓库 | **串口端口单一配置源**(当前 COM6 / 115200);换端口只改这个文件 |
| `test/host/ftp_*.py`(4 个)/ `ch395f_socket_test.py` / `time_sync_server.py` | `test/host/` | PC 端交互测试工具(FTP 客户端 / socket 回显 / 对时推送) |
@@ -280,6 +282,34 @@ static void phaseN_run(void) {
- 测试专用钩子(如静态态复位)用 `#ifdef <模块>_UNIT_TEST` 守卫,MCU 构建不编入(零符号);
- 单测**不能替代**板端验证:真机寄存器 / 复位原因 / 时序仍须 L1/L2 台架闭环。
#### 4.2.1 L0.5 适格模块台账(2026-10-02 加,S2)
**问题**:L0.5 起步阶段只有"看门狗"一个先例,**下一个该测谁**靠临时判断,易遗漏高价值模块。
**判据**(是否适格 L0.5):模块的核心逻辑是否**无副作用**且**与硬件解耦**——
满足"纯逻辑 + 已抽象平台层(或依赖全是外部符号/宏)"即可主机侧穷举。
| 模块 | 规模 | 状态 | 平台层形态 | 备注 |
|---|---|---|---|---|
| `App/util/task_watchdog.c` | — | ✅ 已覆盖(39 断言) | 内联 `__weak` 钩子 | 范式来源;`#ifdef TASK_WATCHDOG_UNIT_TEST` 复位钩子 |
| `App/util/sys_clock.c` | 479 行 | ✅ 已覆盖(100 断言,2026-10-02 S1-a) | 外部符号(`HAL_GetTick`/`sd2506_*`)+ 宏 | **零侵入**(无需 `#ifdef`);首测即抓出 Zeller 星期缺陷 |
| `App/cfg/cfg_file.c` | 1250 行 | ✅ 已覆盖(139 断言,2026-10-02 S1-b) | 后端抽象(`cfg_file_store.h`) | 内存虚拟 FS 桩;**首测即抓出外科手术保存死码 P0**(见 §5) |
| `App/log/sys_log_mcu.c` | — | ⬜ 候选 | 记录编解码(定长 64B) | 编解码纯逻辑;写入端依赖 FatFS + 队列,测编解码即可 |
| `App/util/time_sync.c` | — | ⬜ 候选 | 协议帧编解码 | 报文组装/解析纯逻辑;网络收发不测 |
| `Drivers/BSP/NETCFG/net_cfg_legacy.c` | — | ⬜ 候选 | 旧档 NCFG 迁移 + 自复制 CRC | CRC 校验与旧格式解析;可与 cfg_file 同桩 |
| `App/net/*`(网络栈上层) | — | ⬜ 待评估 | 依赖 socket/CH395F | 纯协议状态机部分或可测,需先确认抽象质量 |
| `App/util/rs485.c` | — | ⏸ 待功能实现 | — | 上游 B2;当前为空桩 |
| `Drivers/BSP/TPAFE5160/*.c` | — | ❌ 不适格 | — | 寄存器序列为主,需真机时序 |
| 其余驱动(CH395F/GD5F2GQ5/SD2506) | — | ❌ 不适格 | — | 需硬件时序 / SPI / I2C 总线 |
| `App/util/crc.c` · `ringbuf.c` | 120/154 行 | ⛔ 已删除(2026-10-02 S8) | — | 孤儿文件,零引用,已 `git rm` |
**使用方式**:新增/改动上述模块前,按表判断是否需补 L0.5;`⬜ 候选` 表示"适格但未做",
在性价比允许时应排入(判据:边界密集、零硬件、单次真机回归 30s~2min vs 单测 2~5s)。
> ⭐ **两条实证**:`sys_clock`(S1-a)与 `cfg_file`(S1-b)**首次落地即各抓出一处
> 生产缺陷**(Zeller 星期公式错误 / 外科手术保存死码)。这印证了本台账的性价比判断——
> 高价值纯逻辑模块的 L0.5 覆盖,是**投入最小、产出最大**的测试改进方向。
### 4.3 新工具约定
- **test/ 自动化脚本为纯 Python 标准库**(2026-09-20 由 PowerShell 迁移:绕开控制台代码页/执行策略陷阱,全部 I/O 显式 UTF-8/字节级处理;`requirements.txt` 仍须纯 ASCII——pip 按 GBK 代码页解码);
@@ -544,11 +574,15 @@ CI 只在 push / PR 时跑,且需 runner 就绪。**提交前自查、或 runn
| `docs/测试说明/串口助手设计说明.md` | 串口调试工具(Python 串口助手) |
| `docs/测试说明/自动化测试流程.md` | @test 闭环细节 |
| `docs/测试说明/测试文档模板规范.md` | 测试文档模板与命名 |
| `docs/测试说明/看门狗单元测试说明.md` | 看门狗判定状态机(L0.5 主机侧) |
| `docs/测试说明/系统时钟单元测试说明.md` | 系统时钟换算(L0.5 主机侧) |
| `docs/测试说明/配置文件单元测试说明.md` | 配置引擎解析/契约(L0.5 主机侧) |
## 变更记录
| 日期 | 变更 |
|---|---|
| 2026-10-02 | **S1-b + S2**:新增 **§4.2.1「L0.5 适格模块台账」**(判据 + 模块状态表 + "两条实证":sys_clock / cfg_file 首测即各抓一处生产缺陷);§4.1 登记 `test/host/cfg_file/` 新单测。同步新增 [`配置文件单元测试说明.md`](配置文件单元测试说明.md)(含抓出的 `s_encode` 返回值误判 P0 缺陷记录) |
| 2026-10-02 | **S3 + S14**:§9.1 第 2 条改正(原文"强制跑 `mem-numbers`"自相矛盾 → 改为"按 `.map` 有无自适应跳过")并加 🔴 门禁盲区声明;§9.2 补 S3 结果可见性(`Tee-Object` 落盘 + `$GITHUB_STEP_SUMMARY` + `::notice::` 汇总行,不用 `actions/upload-artifact`)。另:§4.1 登记 `test/host/sys_clock/` 新单测(S1-a) |
| 2026-10-01 | 新增 **§9 CI 持续集成(Gitea Actions,T-6)**:`.gitea/workflows/ci.yml`(`l0-gates` + `l05-host-unit-tests`,Windows runner);**L0 + L0.5 上 CI、L1/L2/L3 留本地**;刻意不跑 `@build`、`.map` 缺失时自适应跳过 `mem-numbers`;新增 `scripts/ci_local.py` 本地等价命令(§4.1 登记);§1 补 CI 说明;`docs/待处理内容.md` T-6 销项 |
| 2026-10-01 | §3.6 新增 **`test/run_all_suites.py`** 多套件回归编排(`try/finally` 无条件还原套件开关;T-5);`test/README.md` 同步 |
@@ -0,0 +1,283 @@
# 配置文件引擎单元测试说明(主机侧)
> **模块**: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)(模块设计)、
> [`../审查记录/测试与架构改进建议.md`](../审查记录/测试与架构改进建议.md)(S1-b)
---
## 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-CFG-001** | UINT 1/2/4 字节往返(0 / 最大值);小端序(`0x1234` → `34 12`) | 小端编解码 |
| **TC-CFG-002** | UINT 非法:空 / 非数字 / 带后缀 / 超范围 / 负数 / len=3 | 严格校验 |
| **TC-CFG-003** | IP4:`0.0.0.0` / `255.255.255.255` / 常规;段数不足/超、>255、非数字、尾点、空 | 点分四段 |
| **TC-CFG-004** | MAC:`:` / `-` / `.` 三种分隔符、大小写;过短/过长/非十六进制/坏分隔符 | 分隔符 = **字节边界**替代 |
| **TC-CFG-005** | STR:正常 + NUL;恰好 `len-1` 放下;`len` 字符拒绝;空串 | 长度边界 |
### 2.2 INI 解析
| 用例 | 断言 | 验证的设计点 |
|---|---|---|
| **TC-CFG-010** | `;` 与 `#` 注释、空行忽略 | 注释语义 |
| **TC-CFG-011** | LF-only 行结束符解析正常 | CRLF/LF 兼容 |
| **TC-CFG-012** | 段名/键名大小写无关;`=` 两侧与值两侧空白修剪 | 宽松匹配 |
| **TC-CFG-013** | 键值行 `=` 前后空白、值尾空白被修剪 | 值修剪 |
| **TC-CFG-014** | 未知键 / 未知段 / 缺失键 → 保留工厂默认,不崩溃 | 前向兼容 |
| **TC-CFG-014b** | 非法值 → 保留默认 + `DBG_ERROR` 留痕 | 无效键留痕 |
| **TC-CFG-015** | 段头之前的键值行被忽略 | 段作用域 |
### 2.3 段注册校验
| 用例 | 断言 | 验证的设计点 |
|---|---|---|
| **TC-CFG-020** | 正常注册;重复段名拒绝;NULL 描述符 / NULL p_data 拒绝 | 参数防御 |
| **TC-CFG-021** | UINT len=3、MAC len≠6、STR len>79、key len=0 拒绝 | 键定义校验 |
| **TC-CFG-022** | 段名 ≥ `CFG_NAME_MAX`(16) 拒绝 | 段名长度 |
| **TC-CFG-023** | 挂回调段 `data_size > CFG_SNAP_MAX`(64) 拒绝;同段不挂回调则接受 | 回调段快照约束 |
### 2.4 load / save / poll 契约
| 用例 | 断言 | 验证的设计点 |
|---|---|---|
| **TC-CFG-030** | 文件缺失 → 生成出厂模板写盘 + 返回 0;模板含段头与键 | 首启建文件 |
| **TC-CFG-031** | 文件存在 → 有效键进 RAM 副本 | 正常加载 |
| **TC-CFG-032** | 超长文件 → 工厂默认 + 重建模板 + 自检留痕 + 返回 0 | 损坏自愈 |
| **TC-CFG-033** | 读取失败 → 返回 -1,RAM 保持工厂默认 | 读故障 |
| **TC-CFG-034** | 未注册任何段 → 返回 -1 | 前置校验 |
| **TC-CFG-040** | 文件缺失 → 重建完整文档 | 整文档生成 |
| **TC-CFG-041** | 外科手术:保留注释/未知段/未知键,**受管键原位改写** | ⭐ 核心特性 |
| **TC-CFG-042** | 缺失受管段/键 → 文末补写 | 补写路径 |
| **TC-CFG-043** | 输出超长 → 拒绝保存(-1) + 原文件不动 + 自检留痕 | 溢出保护 |
| **TC-CFG-050** | 首轮 poll 建立基线,不重载 | 基线建立 |
| **TC-CFG-051/052** | 沉降去抖(连续 2 次稳定才重载);与基线一致不重载 | 去抖语义 |
| **TC-CFG-053** | 变更后重载 + 挂回调段差异触发回调 | 变更响应 |
| **TC-CFG-055** | `(size, mtime)` 令牌不变 → 不重载(令牌 = 变更信号) | 令牌契约 |
| **TC-CFG-054** | poll 检出超长 → 工厂默认 + 重建模板 + 留痕;**不触发回调** | 在线修复通道保留 |
**当前结果**: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-CFG-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-CFG-041` / `TC-CFG-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` |
@@ -19,6 +19,14 @@
**当前未闭环 25 项:P1×5、P2×20。无 P0。**
> **2026-10-02 追记(L0.5 单测新发现,已修复)**:**E-63(P0)** ——
> `App/cfg/cfg_file.c` 外科手术保存的 3 处 `s_encode(...) == 0` 判定恒假
> (该函数成功返回**文本长度**而非 0)→ **受管值改写路径沦为死码**:
> 对已存在的 `/config.ini` 保存时受管键**从不被改写**(静默不落盘),
> 而 `net_cfg_save()` 仍返回 0 并打 `save: OK`。由本日新增的 `cfg_file`
> 主机侧单测(S1-b)首跑抓出,已修复并通过 `@build` 0/0。**已闭环**,
> 明细见 §2.1。
首版 59 项中 **34 项已闭环**(见 §2),其中 3 项 P0 全部修复并在位。剩余问题分三类:
| 类别 | 数量 | 性质 | 处置 |
@@ -101,6 +109,7 @@
| **E-01** | `App/cfg/cfg_file.c` | `cfg_file_poll` 的"超长=损坏"检查是**死代码**(读长被钉死在阈值)→ >4096B 配置被截断当有效解析 → 界外内容静默回工厂默认 → 基线粘住错误状态 → 下次开机 load 守卫判"损坏" → **用户配置文件整体销毁** | `d030cb0` + 关联 `70f1cc8`(`f_rename` 不覆盖既有目标) |
| **E-31** | `nand_ftl.c` / `journal.c` | 扫描读失败被**覆盖为 `TOO_BAD`** → 非空白卷在 I/O 故障下被误判"全新"→ 直接 `clear` → 映射表整体丢失 | `da1a6e8` |
| **E-02** | `net_socket.c` / `lftpd` / `time_sync` | 一条空闲 FTP 控制连接把 netTask 独占 30s → **全体消费者队列停摆**(实测冻结远端显示 17.7s) | `f8d6e90`+`4a1605b`(分片)→ `a45c0cb`(L3 pending 化,结构性消灭) |
| **E-63** | `App/cfg/cfg_file.c` | 外科手术保存 3 处 `s_encode(...) == 0` 判定恒假(函数成功返回**文本长度**)→ 受管值改写为**死码**:对已存在 `/config.ini` 保存时受管键从不改写(**静默不落盘**,`net_cfg_save` 仍报 OK) | **2026-10-02**(L0.5 `cfg_file` 单测首跑抓出)——判据由 `== 0` 改 `>= 0`;见 [`../测试说明/配置文件单元测试说明.md`](../测试说明/配置文件单元测试说明.md) §5 |
### 2.2 P1 十五项
+11 -4
View File
@@ -41,10 +41,14 @@
| `sys_clock/run_unit_test.py` | **系统时钟换算单测**:离线编译真实 `App/util/sys_clock.c`,以可控时钟 + 阴影 RTC 桩驱动,100 断言点覆盖闰年/月末/世纪/2038 上限/星期/30 天惰性再基准化/RTC 故障降级 |
| `sys_clock/sys_clock_test.c` | 测试单元本体(`#include` 生产源码 + 强符号桩;**不改生产代码**) |
| `sys_clock/stub/` | 桩头文件(`main.h`/`FreeRTOS.h`/`task.h`/`dbg_log.h`),覆盖 MCU 版以在 PC 上编译 |
| `cfg_file/run_unit_test.py` | **配置引擎单测**:离线编译真实 `App/cfg/cfg_file.c`,以**内存虚拟文件系统**桩驱动,139 断言点覆盖 INI 解析/四种值类型编解码/段注册校验/load-save-poll 契约/超长守卫/故障注入 |
| `cfg_file/cfg_file_test.c` | 测试单元本体(`#include` 生产源码 + `cfg_store_*` 内存 FS 后端桩;**不改生产代码**) |
| `cfg_file/stub/` | 桩头文件(`dbg_log.h`),覆盖 MCU 版以在 PC 上编译 |
> 详见 `docs/测试说明/看门狗单元测试说明.md`、`docs/测试说明/系统时钟单元测试说明.md`;
> 详见 `docs/测试说明/看门狗单元测试说明.md`、`docs/测试说明/系统时钟单元测试说明.md`、
> `docs/测试说明/配置文件单元测试说明.md`;
> harness 用法见 [`common/README.md`](common/README.md)。
> 已挂 pre-commit 钩子 `watchdog-unit-test`、`sys-clock-unit-test`。
> 已挂 pre-commit 钩子 `watchdog-unit-test`、`sys-clock-unit-test`、`cfg-file-unit-test`。
## 运行方式
@@ -62,6 +66,9 @@
# 系统时钟换算单测(纯逻辑,无需真机)
& '.venv\Scripts\python.exe' test/host/sys_clock/run_unit_test.py
# 配置引擎单测(纯逻辑,无需真机)
& '.venv\Scripts\python.exe' test/host/cfg_file/run_unit_test.py
```
> 串口端口配置在 `.opencode/serial_config.json`(单一配置源,当前 COM6/115200)。
@@ -70,8 +77,8 @@
- **PYTHONPATH/路径敏感**:脚本按仓库根相对路径读写,**从仓库根执行**
- 本目录**被 pre-commit 门禁的 cppcheck / 代码规范排除**(`test/` 豁免);
但 `watchdog/run_unit_test.py`、`sys_clock/run_unit_test.py` 自身**作为独立钩子**
参与门禁(见上)
但 `watchdog/run_unit_test.py`、`sys_clock/run_unit_test.py`、`cfg_file/run_unit_test.py`
自身**作为独立钩子**参与门禁(见上)
- 台架探针族(TCP 复连 / FTP 中止等)在 `MDK-ARM/*.py`,与本目录分工不同
- `watchdog/`、`sys_clock/` 单测需要本机装有 MSVC(VS2019/2022 的 C++ 工具链);无则脚本报环境错误(退出码 2)
+802
View File
@@ -0,0 +1,802 @@
/*
* 模块名称:cfg_file 主机侧单元测试
* 模块功能:在 PC 上对 App/cfg/cfg_file.c(通用配置引擎核心)做穷举验证。
*
* 被测对象:真实源文件 ../../../App/cfg/cfg_file.c(以 #include 原样纳入,
* 保证测的是生产代码而非副本)。平台文件操作由本文件提供的
* **内存虚拟文件系统**桩实现(cfg_store_* 系列强符号)。
*
* 为什么要主机侧单测(而不是只用板端套件):
* 1. 配置引擎的核心是"文本 <-> 结构"的纯变换(INI 段/键解析、四种值类型
* 编解码、外科手术式保存、超长守卫),与硬件无关;板端套件无法穷举
* 畸形输入,更无法在毫秒内构造"超长文件 / 写失败 / 堆分配失败"。
* 2. 主机侧可**精确注入**故障:文件缺失、读取失败、写失败、分配失败——
* 这些在真机上极难复现(需掉电/坏块)。
* 3. 不占 MCU 的 RAM。
*
* 覆盖点(对应 docs/测试说明/配置文件单元测试说明.md 的 TC-CFG-xxx):
* —— 值类型编解码(s_encode / s_decode 经公开 load/save 间接验证 + 直接白盒)——
* TC-CFG-001 UINT 1/2/4 字节小端往返(含 0 / 最大值)
* TC-CFG-002 UINT 非法:空 / 非数字 / 带后缀 / 超范围
* TC-CFG-003 IP4 正常与非法(段数不足、>255、前导非数字、多余后缀)
* TC-CFG-004 MAC 三种分隔符 + 大小写 + 非法
* TC-CFG-005 STR 正常 / 超长拒绝 / 边界(恰好放下)
* —— INI 解析(s_split_line / s_match_section / s_key_value / s_parse)——
* TC-CFG-010 注释(; 与 #)与空行忽略
* TC-CFG-011 CRLF / LF 混排
* TC-CFG-012 段名大小写无关匹配、前后空白修剪
* TC-CFG-013 键名大小写无关、值前后空白修剪
* TC-CFG-014 未知段 / 未知键留默认(不崩溃)
* TC-CFG-015 键值行出现在段头之前 = 忽略
* —— 注册校验(cfg_file_register)——
* TC-CFG-020 正常注册 / 重复段名拒绝 / NULL 参数拒绝
* TC-CFG-021 非法键定义拒绝(UINT len 非 1/2/4、MAC len≠6、STR len>79)
* TC-CFG-022 段名超长拒绝(≥ CFG_NAME_MAX)
* TC-CFG-023 挂回调段 data_size > CFG_SNAP_MAX 拒绝
* —— load 契约(cfg_file_load)——
* TC-CFG-030 文件缺失 -> 生成出厂模板写盘 + 返回 0
* TC-CFG-031 文件存在 -> 解析有效键进 RAM 副本
* TC-CFG-032 超长文件(> CFG_FILE_MAX_LEN)-> 工厂默认 + 重建模板 + 自检留痕
* TC-CFG-033 读取失败 -> 返回 -1(RAM 保持工厂默认)
* TC-CFG-034 未注册任何段 -> 返回 -1
* —— save 契约(cfg_file_save)——
* TC-CFG-040 文件缺失 -> 重建完整文档
* TC-CFG-041 外科手术:保留注释/未知段/未知键,受管键原位改写
* TC-CFG-042 缺失受管段/键补写
* TC-CFG-043 输出超长 -> 拒绝保存(-1) 且原文件不动
* —— poll 契约(cfg_file_poll)——
* TC-CFG-050 首轮建立基线(不重载)
* TC-CFG-051 沉降去抖:连续 2 次稳定才重载
* TC-CFG-052 与基线一致不重载
* TC-CFG-053 变更后重载 + 挂回调段差异触发回调
* TC-CFG-054 溢出到文件超长 -> 工厂默认 + 重建模板 + 自检留痕(不触发回调)
*
* 通过:全部 TC PASS(main 返回 0)。
* 入口:int main(void),返回 0=PASS / 1=FAIL(由 run 脚本判读)。
*
* 创建日期:2026-10-02
*/
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
/* 先纳入桩头(DBG_* 映射为计数),再纳入生产源码 */
#include "dbg_log.h"
/* ===================== 内存虚拟文件系统(cfg_store_* 强符号) =====================
*
* 生产代码经 cfg_file_store.h 声明的 8 个接口做全部文件/堆操作。本桩提供:
* - 一块固定容量的内存"磁盘"(模拟 config.ini 的字节内容)
* - 统计 read/write/alloc 失败注入开关(复现真机极难构造的故障路径)
* - stat 的 (size, mtime) 令牌:mtime 由单测手动改,驱动 poll 的沉降逻辑
*/
#include "cfg_file_store.h"
#define VFS_CAP 8192u /* 虚拟盘容量(> CFG_FILE_MAX_LEN,便于构造超长 */
/* 虚拟文件:内容 + 长度 + mtime 令牌 + 是否存在 */
static uint8_t s_vfs[VFS_CAP];
static size_t s_vfs_len = 0u;
static uint32_t s_vfs_mtime = 100u;
static int s_vfs_exists = 0;
/* 故障注入开关 */
static int s_fail_read = 0; /* cfg_store_read 返回 -1 */
static int s_fail_write = 0; /* cfg_store_write_atomic 返回 -1 */
static int s_fail_alloc = 0; /* cfg_store_alloc 返回 NULL */
static int s_fail_stat = 0; /* cfg_store_stat 返回 -1 */
/* 观测计数 */
static int s_write_count = 0;
static int s_read_count = 0;
/* 路径常量(与生产逻辑无关,仅作为桩内部标识) */
static const char *s_cfg_path = "/config.ini";
static const char *s_tmp_path = "/config.tmp";
static const char *s_legacy_path = "/netcfg.dat";
/* ---- 堆:直接映射宿主 CRT(单测不模拟 MicroLIB 512B 堆约束) ---- */
void *cfg_store_alloc(size_t size) {
if (s_fail_alloc) {
return NULL;
}
return malloc(size);
}
void cfg_store_free(void *p_buf) {
free(p_buf);
}
uint32_t cfg_store_heap_free(void) {
return 0u; /* 模拟器语义:不跟踪宿主堆 */
}
/* ---- 文件操作 ---- */
int cfg_store_read(const char *p_path, uint8_t *p_buf, size_t cap, size_t *p_nread) {
size_t n;
(void)p_path;
s_read_count++;
if (s_fail_read || !s_vfs_exists) {
return -1;
}
n = s_vfs_len;
if (n > cap) {
n = cap;
}
(void)memcpy(p_buf, s_vfs, n);
*p_nread = n;
return 0;
}
int cfg_store_write_atomic(const char *p_path, const uint8_t *p_buf, size_t len) {
(void)p_path;
s_write_count++;
if (s_fail_write) {
return -1;
}
if (len > VFS_CAP) {
return -1; /* 虚拟盘放不下 */
}
(void)memcpy(s_vfs, p_buf, len);
s_vfs_len = len;
s_vfs_exists = 1;
/* 真实后端每次写盘 mtime 必变(分钟粒度,同分钟内可能不变);
* 单测里显式 +1 让"写盘 = 令牌变化"确定可预期 */
s_vfs_mtime++;
return 0;
}
int cfg_store_stat(const char *p_path, cfg_file_stat_t *p_st) {
(void)p_path;
if (s_fail_stat || !s_vfs_exists) {
return -1;
}
p_st->size = (uint32_t)s_vfs_len;
p_st->mtime = s_vfs_mtime;
return 0;
}
int cfg_store_remove(const char *p_path) {
(void)p_path;
s_vfs_exists = 0;
s_vfs_len = 0u;
return 0;
}
const char *cfg_store_config_path(void) { return s_cfg_path; }
const char *cfg_store_tmp_path(void) { return s_tmp_path; }
const char *cfg_store_legacy_path(void) { return s_legacy_path; }
/* ---- sys_log 桩:只计数自检留痕(不落盘) ---- */
#include "sys_log.h"
static int s_syslog_count = 0;
void syslog_post(syslog_id_t id, const char *desc) {
(void)id;
(void)desc;
s_syslog_count++;
}
/* ---- dbg_log 桩观测计数定义(stub/dbg_log.h 声明为 extern) ---- */
int g_cfgtest_err_count = 0;
int g_cfgtest_info_count = 0;
int g_cfgtest_debug_count = 0;
/* ===================== 测试框架(极简) ===================== */
static int s_total = 0, s_passed = 0, s_failed = 0;
#define CHECK(cond, name) do { \
s_total++; \
if (cond) { s_passed++; printf(" [PASS] %s\n", name); } \
else { s_failed++; printf(" [FAIL] %s (line %d)\n", name, __LINE__); } \
} while (0)
/* ===================== 被测代码 ===================== */
/* 原样纳入生产源文件(stub 的 dbg_log.h 生效) */
#include "cfg_file.c"
/* ===================== 测试用段定义 ===================== */
/* 段 A:[net],含四种值类型,data_size ≤ 64 可挂回调 */
typedef struct {
uint8_t u8; /* offset 0 len 1 UINT */
uint16_t u16; /* offset 2 len 2 UINT(对齐后) */
uint8_t ip[4]; /* offset 4 len 4 IP4 */
uint8_t mac[6]; /* offset 8 len 6 MAC */
char name[12]; /* offset 14 len 12 STR */
} sec_net_t;
static sec_net_t s_net_data;
static const sec_net_t s_net_def = {
7u, 1000u, {192u, 168u, 1u, 100u}, {0x00u, 0x11u, 0x22u, 0x33u, 0x44u, 0x55u}, "default"
};
static int s_net_cb_count = 0;
static void net_on_change(void) { s_net_cb_count++; }
/* 注意:键定义 offset 相对结构体起始,按字段真实偏移书写 */
static const cfg_key_def_t s_net_keys[] = {
{ "u8", CFG_VAL_UINT, offsetof(sec_net_t, u8), 1u },
{ "u16", CFG_VAL_UINT, offsetof(sec_net_t, u16), 2u },
{ "ip", CFG_VAL_IP4, offsetof(sec_net_t, ip), 4u },
{ "mac", CFG_VAL_MAC, offsetof(sec_net_t, mac), 6u },
{ "name", CFG_VAL_STR, offsetof(sec_net_t, name), 12u },
};
/* 段 B:[sys],无回调,用于验证多段与超长字段拒绝 */
typedef struct {
uint32_t baud; /* offset 0 len 4 UINT */
} sec_sys_t;
static sec_sys_t s_sys_data;
static const sec_sys_t s_sys_def = { 115200u };
static const cfg_key_def_t s_sys_keys[] = {
{ "baud", CFG_VAL_UINT, offsetof(sec_sys_t, baud), 4u },
};
/* ===================== 辅助 ===================== */
/* 复位"模块静态态 + 虚拟盘 + 全部观测计数" = 刚上电 */
static void vfs_reset(void) {
memset(s_vfs, 0, sizeof(s_vfs));
s_vfs_len = 0u;
s_vfs_mtime = 100u;
s_vfs_exists = 0;
s_fail_read = s_fail_write = s_fail_alloc = s_fail_stat = 0;
s_write_count = s_read_count = 0;
s_syslog_count = 0;
g_cfgtest_err_count = 0;
g_cfgtest_info_count = 0;
g_cfgtest_debug_count = 0;
s_net_cb_count = 0;
/* 清空段注册表(生产静态态无公开复位接口,测试用直接 memset——
* 这是白盒单测的可接受代价,模块静态态布局在头文件可见) */
memset(s_sections, 0, sizeof(s_sections));
memset(&s_base, 0, sizeof(s_base));
memset(&s_last, 0, sizeof(s_last));
s_stable = 0u;
s_base_valid = 0u;
memset(s_snap, 0, sizeof(s_snap));
memset(s_sec_hdr, 0, sizeof(s_sec_hdr));
memset(s_key_line, 0, sizeof(s_key_line));
/* 段 RAM 副本复位工厂默认 */
s_net_data = s_net_def;
s_sys_data = s_sys_def;
}
/* 向虚拟盘写入一段文本(建立/更新"文件已存在"状态)。
* 语义 = 一次外部写盘:内容变 + mtime 令牌变(真实后端每次写盘 mtime 必变,
* 是 poll 变更检测的信号源)。需精确控制令牌时用 vfs_put_notoken()。 */
static void vfs_put(const char *text) {
size_t n = strlen(text);
if (n > VFS_CAP) {
n = VFS_CAP;
}
memcpy(s_vfs, text, n);
s_vfs_len = n;
s_vfs_exists = 1;
s_vfs_mtime++;
}
/* 写入内容但**不改** mtime 令牌(用于构造"内容变、令牌不变"的边界) */
static void vfs_put_notoken(const char *text) {
size_t n = strlen(text);
if (n > VFS_CAP) {
n = VFS_CAP;
}
memcpy(s_vfs, text, n);
s_vfs_len = n;
s_vfs_exists = 1;
}
/* 取虚拟盘当前内容为 NUL 结尾字符串(供检索断言);返回静态缓冲 */
static const char *vfs_get(void) {
static char buf[VFS_CAP + 1];
memcpy(buf, s_vfs, s_vfs_len);
buf[s_vfs_len] = '\0';
return buf;
}
/* 注册标准两段 */
static void register_std(void) {
cfg_section_reg_t a;
cfg_section_reg_t b;
memset(&a, 0, sizeof(a));
a.name = "net";
a.p_data = &s_net_data;
a.p_defaults = &s_net_def;
a.data_size = sizeof(sec_net_t);
a.p_keys = s_net_keys;
a.key_count = (uint8_t)(sizeof(s_net_keys) / sizeof(s_net_keys[0]));
a.p_cb = net_on_change;
memset(&b, 0, sizeof(b));
b.name = "sys";
b.p_data = &s_sys_data;
b.p_defaults = &s_sys_def;
b.data_size = sizeof(sec_sys_t);
b.p_keys = s_sys_keys;
b.key_count = (uint8_t)(sizeof(s_sys_keys) / sizeof(s_sys_keys[0]));
b.p_cb = NULL;
CHECK(cfg_file_register(&a) == 0, "register net section");
CHECK(cfg_file_register(&b) == 0, "register sys section");
}
/* ===================== 用例 ===================== */
/* TC-CFG-001/002:UINT 往返与非法值 */
static void tc_uint(void) {
printf("[TC-CFG-001] UINT encode/decode round-trip\n");
{
uint8_t d1[1]; uint8_t d2[2]; uint8_t d4[4];
CHECK(s_decode_uint(d1, 1u, "255") == 0 && d1[0] == 0xFFu, "u8 max 255");
CHECK(s_decode_uint(d1, 1u, "0") == 0 && d1[0] == 0u, "u8 zero");
CHECK(s_decode_uint(d2, 2u, "65535") == 0 && d2[0] == 0xFFu && d2[1] == 0xFFu,
"u16 max little-endian");
CHECK(s_decode_uint(d4, 4u, "4294967295") == 0 &&
d4[0] == 0xFFu && d4[3] == 0xFFu, "u32 max little-endian");
/* 小端序验证:0x1234 -> d[0]=0x34 d[1]=0x12 */
CHECK(s_decode_uint(d2, 2u, "4660") == 0 && d2[0] == 0x34u && d2[1] == 0x12u,
"u16 0x1234 little-endian order");
}
printf("[TC-CFG-002] UINT illegal values rejected\n");
{
uint8_t d[4];
CHECK(s_decode_uint(d, 1u, "") == -1, "empty rejected");
CHECK(s_decode_uint(d, 1u, "abc") == -1, "non-numeric rejected");
CHECK(s_decode_uint(d, 1u, "12x") == -1, "trailing garbage rejected");
CHECK(s_decode_uint(d, 1u, "256") == -1, "u8 overflow rejected");
CHECK(s_decode_uint(d, 2u, "65536") == -1, "u16 overflow rejected");
CHECK(s_decode_uint(d, 3u, "1") == -1, "invalid len 3 rejected");
CHECK(s_decode_uint(d, 1u, "-1") == -1, "negative rejected");
}
}
/* TC-CFG-003:IP4 */
static void tc_ip4(void) {
uint8_t d[4];
printf("[TC-CFG-003] IPv4 decode\n");
CHECK(s_decode_ip4(d, "0.0.0.0") == 0 && d[0] == 0u && d[3] == 0u, "all-zero");
CHECK(s_decode_ip4(d, "255.255.255.255") == 0 &&
d[0] == 255u && d[3] == 255u, "all-255");
CHECK(s_decode_ip4(d, "192.168.1.100") == 0 &&
d[0] == 192u && d[1] == 168u && d[2] == 1u && d[3] == 100u, "typical");
CHECK(s_decode_ip4(d, "1.2.3") == -1, "too few octets rejected");
CHECK(s_decode_ip4(d, "1.2.3.4.5") == -1, "too many octets rejected");
CHECK(s_decode_ip4(d, "256.1.1.1") == -1, "octet >255 rejected");
CHECK(s_decode_ip4(d, "a.1.1.1") == -1, "non-digit rejected");
CHECK(s_decode_ip4(d, "1.1.1.") == -1, "trailing dot rejected");
CHECK(s_decode_ip4(d, "") == -1, "empty rejected");
}
/* TC-CFG-004:MAC */
static void tc_mac(void) {
uint8_t d[6];
printf("[TC-CFG-004] MAC decode (separators/case)\n");
CHECK(s_decode_mac(d, "00:11:22:33:44:55") == 0 &&
d[0] == 0x00u && d[5] == 0x55u, "colon separator");
CHECK(s_decode_mac(d, "00-11-22-33-44-55") == 0 &&
d[1] == 0x11u, "dash separator");
/* 点号仅在**字节边界**作分隔符替代(与 ':'/'-' 等价,见 cfg_file.h 注释);
* 不支持 Cisco 4-4-4 分组("0011.2233.4455")——那不是本引擎的契约 */
CHECK(s_decode_mac(d, "00.11.22.33.44.55") == 0 &&
d[0] == 0x00u && d[1] == 0x11u && d[5] == 0x55u, "dot separator (byte boundary)");
CHECK(s_decode_mac(d, "AA:BB:CC:DD:EE:FF") == 0 &&
d[0] == 0xAAu && d[5] == 0xFFu, "uppercase hex");
CHECK(s_decode_mac(d, "aa:bb:cc:dd:ee:ff") == 0 &&
d[0] == 0xAAu, "lowercase hex");
CHECK(s_decode_mac(d, "00:11:22:33:44") == -1, "too short rejected");
CHECK(s_decode_mac(d, "00:11:22:33:44:55:66") == -1, "too long rejected");
CHECK(s_decode_mac(d, "00:11:22:33:44:zz") == -1, "non-hex rejected");
CHECK(s_decode_mac(d, "00:11:22:33:44;55") == -1, "bad separator rejected");
CHECK(s_decode_mac(d, "") == -1, "empty rejected");
}
/* TC-CFG-005:STR */
static void tc_str(void) {
uint8_t d[8];
printf("[TC-CFG-005] STR decode (length boundary)\n");
CHECK(s_decode_str(d, 8u, "hello") == 0 && d[0] == 'h' && d[5] == '\0',
"normal string + NUL");
CHECK(s_decode_str(d, 8u, "1234567") == 0 && d[6] == '7' && d[7] == '\0',
"exactly len-1 chars fits");
CHECK(s_decode_str(d, 8u, "12345678") == -1, "len chars rejected (no room for NUL)");
CHECK(s_decode_str(d, 8u, "") == 0 && d[0] == '\0', "empty string ok");
}
/* TC-CFG-010~015:INI 解析 */
static void tc_parse(void) {
printf("[TC-CFG-010..015] INI parsing via load\n");
{
vfs_reset();
register_std();
vfs_put(
"; comment line\r\n"
"# hash comment\r\n"
"\r\n"
"[net]\r\n"
"u8 = 42\r\n"
"ip = 10.0.0.1\r\n"
"unknown_key = whatever\r\n"
"\r\n"
"[unknown_section]\r\n"
"x = 1\r\n"
"\r\n"
"[sys]\r\n"
"baud = 9600\r\n");
CHECK(cfg_file_load() == 0, "load returns 0");
CHECK(s_net_data.u8 == 42u, "TC-010/013: comment skipped, key parsed, ws trimmed");
CHECK(s_net_data.ip[0] == 10u && s_net_data.ip[3] == 1u, "ip parsed");
CHECK(s_net_data.u16 == s_net_def.u16, "TC-014: absent key keeps default (u16)");
CHECK(s_net_data.name[0] == 'd', "TC-014: unknown key leaves default (name)");
CHECK(s_sys_data.baud == 9600u, "multi-section: sys parsed");
}
printf("[TC-CFG-012] section/key name case-insensitive + value whitespace trim\n");
{
vfs_reset();
register_std();
/* 段名大小写无关;键名大小写无关;'=' 两侧与值两侧空白修剪。
* 注意:段名括号内**不**支持前导空白([ NET ] 中的前导空格会保留),
* 这是既有契约(s_match_section 只 trim 尾空白),不是本用例的断言点。 */
vfs_put(
"[NET]\r\n"
" U8 = 9 \r\n"
" IP = 1.2.3.4\r\n");
CHECK(cfg_file_load() == 0, "load ok");
CHECK(s_net_data.u8 == 9u, "uppercase section+key matched (case-insensitive)");
CHECK(s_net_data.ip[0] == 1u && s_net_data.ip[3] == 4u, "value whitespace trimmed");
}
printf("[TC-CFG-011] LF-only line endings\n");
{
vfs_reset();
register_std();
vfs_put("[net]\nu8=77\nip=8.8.8.8\n");
CHECK(cfg_file_load() == 0, "load ok with LF");
CHECK(s_net_data.u8 == 77u, "LF: value parsed");
CHECK(s_net_data.ip[3] == 8u, "LF: ip parsed");
}
printf("[TC-CFG-015] key-value before any section ignored\n");
{
vfs_reset();
register_std();
vfs_put("u8 = 123\r\n[net]\r\nip = 5.5.5.5\r\n");
CHECK(cfg_file_load() == 0, "load ok");
CHECK(s_net_data.u8 == s_net_def.u8, "orphan key before section ignored");
CHECK(s_net_data.ip[0] == 5u, "valid key after section parsed");
}
printf("[TC-CFG-014b] invalid value keeps factory default + logs\n");
{
vfs_reset();
register_std();
vfs_put("[net]\r\nu8 = 999\r\nip = not-an-ip\r\n");
g_cfgtest_err_count = 0;
CHECK(cfg_file_load() == 0, "load ok despite invalid values");
CHECK(s_net_data.u8 == s_net_def.u8, "invalid u8 keeps default");
CHECK(s_net_data.ip[0] == s_net_def.ip[0], "invalid ip keeps default");
CHECK(g_cfgtest_err_count >= 2, "invalid values logged (DBG_ERROR)");
}
}
/* TC-CFG-020~023:register 校验 */
static void tc_register(void) {
printf("[TC-CFG-020] register validation\n");
{
cfg_section_reg_t r;
vfs_reset();
memset(&r, 0, sizeof(r));
r.name = "net";
r.p_data = &s_net_data;
r.p_defaults = &s_net_def;
r.data_size = sizeof(sec_net_t);
r.p_keys = s_net_keys;
r.key_count = 5u;
CHECK(cfg_file_register(&r) == 0, "valid register");
CHECK(cfg_file_register(&r) == -1, "duplicate section name rejected");
CHECK(cfg_file_register(NULL) == -1, "NULL descriptor rejected");
r.name = "another";
r.p_data = NULL;
CHECK(cfg_file_register(&r) == -1, "NULL p_data rejected");
}
printf("[TC-CFG-021] invalid key definitions rejected\n");
{
cfg_section_reg_t r;
static const cfg_key_def_t bad_uint[] = { { "k", CFG_VAL_UINT, 0u, 3u } };
static const cfg_key_def_t bad_mac[] = { { "k", CFG_VAL_MAC, 0u, 4u } };
static const cfg_key_def_t bad_str[] = { { "k", CFG_VAL_STR, 0u, 80u } };
static const cfg_key_def_t zero_len[] = { { "k", CFG_VAL_UINT, 0u, 0u } };
vfs_reset();
memset(&r, 0, sizeof(r));
r.name = "x";
r.p_data = &s_net_data;
r.p_defaults = &s_net_def;
r.data_size = 8u;
r.key_count = 1u;
r.p_keys = bad_uint;
CHECK(cfg_file_register(&r) == -1, "UINT len=3 rejected");
r.p_keys = bad_mac;
CHECK(cfg_file_register(&r) == -1, "MAC len=4 rejected");
r.p_keys = bad_str;
CHECK(cfg_file_register(&r) == -1, "STR len=80 rejected");
r.p_keys = zero_len;
CHECK(cfg_file_register(&r) == -1, "key len=0 rejected");
}
printf("[TC-CFG-022] section name length limit\n");
{
cfg_section_reg_t r;
vfs_reset();
memset(&r, 0, sizeof(r));
r.name = "0123456789abcdef"; /* 16 chars >= CFG_NAME_MAX(16) */
r.p_data = &s_net_data;
r.p_defaults = &s_net_def;
r.data_size = 8u;
r.p_keys = s_sys_keys;
r.key_count = 1u;
CHECK(cfg_file_register(&r) == -1, "section name >= 16 chars rejected");
}
printf("[TC-CFG-023] callback section data_size limit\n");
{
cfg_section_reg_t r;
vfs_reset();
memset(&r, 0, sizeof(r));
r.name = "cb";
r.p_data = &s_net_data;
r.p_defaults = &s_net_def;
r.data_size = CFG_SNAP_MAX + 1u; /* > 64,挂回调则拒绝 */
r.p_keys = s_sys_keys;
r.key_count = 1u;
r.p_cb = net_on_change;
CHECK(cfg_file_register(&r) == -1, "callback with data_size>64 rejected");
r.p_cb = NULL;
CHECK(cfg_file_register(&r) == 0, "same section without callback accepted");
}
}
/* TC-CFG-030~034:load 契约 */
static void tc_load(void) {
printf("[TC-CFG-030] missing file -> factory template written\n");
{
vfs_reset();
register_std();
s_write_count = 0;
CHECK(cfg_file_load() == 0, "load returns 0 when file missing");
CHECK(s_vfs_exists == 1, "template file created");
CHECK(s_write_count == 1, "one write happened");
/* 模板须含段头与键 */
CHECK(strstr(vfs_get(), "[net]") != NULL, "template has [net]");
CHECK(strstr(vfs_get(), "[sys]") != NULL, "template has [sys]");
CHECK(strstr(vfs_get(), "u8=7") != NULL, "template has default u8=7");
}
printf("[TC-CFG-032] oversized file -> factory default + regenerate + syslog\n");
{
size_t i;
vfs_reset();
register_std();
/* 构造 > CFG_FILE_MAX_LEN 的文件 */
for (i = 0; i < CFG_FILE_MAX_LEN + 100u; i++) {
s_vfs[i] = (uint8_t)'A';
}
s_vfs_len = CFG_FILE_MAX_LEN + 100u;
s_vfs_exists = 1;
s_net_data.u8 = 0xEEu; /* 预置脏值,验证被复位 */
s_syslog_count = 0;
CHECK(cfg_file_load() == 0, "oversized load returns 0");
CHECK(s_net_data.u8 == s_net_def.u8, "RAM reset to factory default");
CHECK(s_syslog_count >= 1, "self-test log recorded");
CHECK(s_vfs_len <= CFG_FILE_MAX_LEN, "file regenerated within limit");
}
printf("[TC-CFG-033] read failure -> -1, RAM keeps factory default\n");
{
vfs_reset();
register_std();
vfs_put("[net]\r\nu8=99\r\n");
s_fail_read = 1;
CHECK(cfg_file_load() == -1, "read failure returns -1");
CHECK(s_net_data.u8 == s_net_def.u8, "RAM stays factory default");
}
printf("[TC-CFG-034] load with no registered section -> -1\n");
{
vfs_reset();
CHECK(cfg_file_load() == -1, "no section registered returns -1");
}
}
/* TC-CFG-040~043:save 契约 */
static void tc_save(void) {
printf("[TC-CFG-040] save with missing file -> full document\n");
{
vfs_reset();
register_std();
s_net_data.u8 = 12u;
s_sys_data.baud = 38400u;
CHECK(cfg_file_save() == 0, "save ok");
CHECK(s_vfs_exists == 1, "file created");
CHECK(strstr(vfs_get(), "u8=12") != NULL, "current value written");
CHECK(strstr(vfs_get(), "baud=38400") != NULL, "second section written");
}
printf("[TC-CFG-041] surgical save preserves comments/unknown\n");
{
vfs_reset();
register_std();
vfs_put(
"; my precious comment\r\n"
"[net]\r\n"
"u8 = 1\r\n"
"vendor_key = keepme\r\n"
"\r\n"
"[vendor]\r\n"
"x = y\r\n");
s_net_data.u8 = 200u;
CHECK(cfg_file_save() == 0, "surgical save ok");
CHECK(strstr(vfs_get(), "; my precious comment") != NULL, "comment preserved");
CHECK(strstr(vfs_get(), "vendor_key = keepme") != NULL ||
strstr(vfs_get(), "vendor_key=keepme") != NULL, "unknown key preserved");
CHECK(strstr(vfs_get(), "[vendor]") != NULL, "unknown section preserved");
CHECK(strstr(vfs_get(), "u8=200") != NULL, "managed key rewritten in place");
}
printf("[TC-CFG-042] surgical save appends missing managed section/key\n");
{
vfs_reset();
register_std();
vfs_put(
"; head\r\n"
"[net]\r\n"
"u8 = 3\r\n");
s_sys_data.baud = 57600u;
CHECK(cfg_file_save() == 0, "save ok");
CHECK(strstr(vfs_get(), "[sys]") != NULL, "missing section appended");
CHECK(strstr(vfs_get(), "baud=57600") != NULL, "missing key written");
}
printf("[TC-CFG-043] output overflow -> reject, original untouched\n");
{
size_t i;
vfs_reset();
register_std();
/* 构造一个接近上限、且含大量未知内容的文件;外科手术输出会超限 */
for (i = 0; i < CFG_FILE_MAX_LEN - 4u; i++) {
s_vfs[i] = (uint8_t)((i % 60u == 0u) ? '\n' : 'Z');
}
s_vfs_len = CFG_FILE_MAX_LEN - 4u;
s_vfs_exists = 1;
s_syslog_count = 0;
/* 记录原文件长度,验证不动 */
{
size_t before = s_vfs_len;
int r = cfg_file_save();
CHECK(r == -1, "overflow rejected with -1");
CHECK(s_vfs_len == before, "original file untouched on overflow");
CHECK(s_syslog_count >= 1, "overflow self-test logged");
}
}
}
/* TC-CFG-050~054:poll 契约 */
static void tc_poll(void) {
printf("[TC-CFG-050] poll first round establishes baseline\n");
{
vfs_reset();
register_std();
vfs_put("[net]\r\nu8=5\r\n");
cfg_file_poll(); /* 建基线 */
CHECK(s_net_data.u8 == s_net_def.u8, "no reload on first poll (baseline only)");
CHECK(s_base_valid == 1u, "baseline established");
}
printf("[TC-CFG-051/052] settle debounce + no-change skip\n");
{
vfs_reset();
register_std();
vfs_put("[net]\r\nu8=5\r\n");
cfg_file_poll(); /* baseline */
cfg_file_poll(); /* stable 1, same as base -> no reload */
CHECK(s_net_data.u8 == s_net_def.u8, "same-as-base poll: no reload");
/* 改变文件(mtime 变) -> 需连续 CFG_FILE_SETTLE_CNT 次稳定才重载 */
vfs_put("[net]\r\nu8=88\r\n");
cfg_file_poll(); /* stable=1, 未沉降 */
CHECK(s_net_data.u8 == s_net_def.u8, "not reloaded before settle");
cfg_file_poll(); /* stable=2 -> 重载 */
CHECK(s_net_data.u8 == 88u, "reloaded after settle");
}
printf("[TC-CFG-053] change triggers reload + callback\n");
{
vfs_reset();
register_std();
vfs_put("[net]\r\nu8=5\r\nip=1.1.1.1\r\n");
cfg_file_poll(); /* baseline */
s_net_cb_count = 0;
vfs_put("[net]\r\nu8=5\r\nip=2.2.2.2\r\n"); /* ip 变化 + mtime 令牌变 */
cfg_file_poll();
cfg_file_poll(); /* settle -> reload + cb */
CHECK(s_net_data.ip[0] == 2u, "changed value reloaded");
CHECK(s_net_cb_count >= 1, "callback fired on section change");
}
printf("[TC-CFG-055] content change without size/mtime token change -> not detected\n");
{
vfs_reset();
register_std();
vfs_put("[net]\r\nu8=55\r\n"); /* 基线:u8 两位 */
cfg_file_poll(); /* baseline */
/* 只改内容、且**长度与 mtime 都不变**:(size,mtime) 令牌相同 -> 不重载。
* 这是既有契约(令牌 = 变更信号源);真实写盘 mtime 必变,故不构成漏更新 */
vfs_put_notoken("[net]\r\nu8=77\r\n"); /* 同为两位字符,size 不变 */
cfg_file_poll();
cfg_file_poll();
CHECK(s_net_data.u8 == s_net_def.u8,
"same-token change ignored (size+mtime is the change signal)");
}
printf("[TC-CFG-054] poll detects oversized file -> factory + regenerate + syslog\n");
{
size_t i;
vfs_reset();
register_std();
vfs_put("[net]\r\nu8=5\r\n");
cfg_file_poll(); /* baseline */
/* 膨胀文件到超限 */
for (i = 0; i < CFG_FILE_MAX_LEN + 50u; i++) {
s_vfs[i] = (uint8_t)'B';
}
s_vfs_len = CFG_FILE_MAX_LEN + 50u;
s_vfs_mtime++;
s_net_data.u8 = 0xABu;
s_syslog_count = 0;
s_net_cb_count = 0;
cfg_file_poll();
cfg_file_poll(); /* settle -> 走超长分支 */
CHECK(s_net_data.u8 == s_net_def.u8, "oversized poll resets to factory default");
CHECK(s_syslog_count >= 1, "oversized poll logged to self-test");
CHECK(s_vfs_len <= CFG_FILE_MAX_LEN, "file regenerated within limit");
CHECK(s_net_cb_count == 0, "no callback on oversized (online-repair channel kept)");
}
}
int main(void) {
printf("=== Config File Tests ===\n");
tc_uint();
tc_ip4();
tc_mac();
tc_str();
tc_parse();
tc_register();
tc_load();
tc_save();
tc_poll();
printf("\n=== Config File Tests: %d/%d PASSED (failed=%d) ===\n",
s_passed, s_total, s_failed);
return (s_failed == 0) ? 0 : 1;
}
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""配置引擎(cfg_file)的主机侧单元测试运行器(L0.5,纯标准库)。
做什么:
用本机 MSVC 编译 test/host/cfg_file/cfg_file_test.c(它以 #include 纳入
**生产源文件** App/cfg/cfg_file.c),在 PC 上离线跑完整的 INI 解析 / 值类型
编解码 / 注册校验 / load-save-poll 契约,失败即非 0 退出。零硬件、零烧录、毫秒级。
工具链探测 / INCLUDE-LIB 拼接 / 编译运行 / 退出码判定全部委托给通用 harness
`test/host/common/unit_test_harness.py`(本文件只做声明)。
为什么要主机侧单测(而不是只用板端套件):
配置引擎的核心是"文本 <-> 结构"的纯变换,与硬件无关。板端套件无法穷举畸形
输入(畸形 INI、非法键值、超长文件),更无法在毫秒内构造"读取失败 / 写失败 /
堆分配失败 / 掉电"等真机极难复现的故障路径。主机侧用**内存虚拟文件系统**后端
把这些一次性钉死,且不占 MCU 的 RAM。
用法(仓库根目录):
python test/host/cfg_file/run_unit_test.py
或统一用 .venv: & '.venv\\Scripts\\python.exe' test/host/cfg_file/run_unit_test.py
退出码:0=PASS / 1=测试失败 / 2=环境错误(找不到编译器 / 编译失败 / 无汇总行)。
设计依据:docs/审查记录/测试与架构改进建议.md §3.1(S1-b);
docs/测试说明/配置文件单元测试说明.md
创建日期:2026-10-02
"""
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(HERE)))
# harness 位于 test/host/common/
sys.path.insert(0, os.path.join(ROOT, "test", "host", "common"))
from unit_test_harness import TestBuild, run_host_unit_test # noqa: E402
# 顺序敏感:stub 目录必须**先于**生产目录,才能覆盖 dbg_log.h(使 cfg_file.c 可在
# PC 上编译)。cfg_file.h / cfg_file_store.h 用真实头(接口契约原汁),sys_log.h 亦
# 用真实头(syslog_id_t 枚举 + syslog_post 声明),其 syslog_post 由测试单元给强符号桩。
BUILD = TestBuild(
name="Config File Tests",
src=os.path.join(HERE, "cfg_file_test.c"),
include_dirs=[
os.path.join(HERE, "stub"),
os.path.join(ROOT, "App", "cfg"),
os.path.join(ROOT, "App", "log"),
HERE,
],
summary_marker="=== Config File Tests:",
)
if __name__ == "__main__":
sys.exit(run_host_unit_test(BUILD))
+31
View File
@@ -0,0 +1,31 @@
/*
* 主机侧单测桩:dbg_log.h
* 模块功能:为 cfg_file.c 提供主机可编译的日志宏(映射到 printf / 计数)。
*
* ⚠ 仅供 test/host/cfg_file/ 使用,不参与 MCU 构建。
*
* MCU 版依赖 sys_clock(RTC 时间戳)与 USART;主机侧替换为计数/直通,
* 使"非法键值是否留痕"(DBG_ERROR)与"注册/加载成功日志"(DBG_INFO)
* 成为可观测行为——单测据此断言既有留痕、又不被噪声淹没。
*
* 创建日期:2026-10-02
*/
#ifndef DBG_LOG_H
#define DBG_LOG_H
#include <stdio.h>
/* 测试观测计数(在 cfg_file_test.c 实现):
* g_cfgtest_err_count —— DBG_ERROR 次数(如非法键值留痕)
* g_cfgtest_info_count —— DBG_INFO 次数(如 load 成功)
* g_cfgtest_debug_count—— DBG_DEBUG 次数 */
extern int g_cfgtest_err_count;
extern int g_cfgtest_info_count;
extern int g_cfgtest_debug_count;
#define DBG_INFO(fmt, ...) do { g_cfgtest_info_count++; } while (0)
#define DBG_DEBUG(fmt, ...) do { g_cfgtest_debug_count++; } while (0)
#define DBG_ERROR(fmt, ...) do { g_cfgtest_err_count++; } while (0)
#endif /* DBG_LOG_H */