From fe963e7d91923f3a56b4ec344978cf00107d7948 Mon Sep 17 00:00:00 2001 From: Wanderingss <1624155937@qq.com> Date: Fri, 2 Oct 2026 13:00:54 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B5=8B=E8=AF=95=EF=BC=9A=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=20cfg=5Ffile=20=E4=B8=BB=E6=9C=BA=E4=BE=A7=E5=8D=95=E6=B5=8B?= =?UTF-8?q?=E5=B9=B6=E4=BF=AE=E5=A4=8D=E5=A4=96=E7=A7=91=E6=89=8B=E6=9C=AF?= =?UTF-8?q?=E4=BF=9D=E5=AD=98=E6=AD=BB=E7=A0=81=E7=BC=BA=E9=99=B7=EF=BC=88?= =?UTF-8?q?S1-b+S2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .pre-commit-config.yaml | 16 + App/cfg/cfg_file.c | 13 +- docs/README.md | 1 + docs/内存管理/RAM使用分析与优化方案.md | 4 +- docs/测试说明/测试开发标准流程.md | 34 ++ docs/测试说明/配置文件单元测试说明.md | 283 +++++++++ docs/问题记录/错误码传播审计.md | 9 + test/host/README.md | 15 +- test/host/cfg_file/cfg_file_test.c | 802 +++++++++++++++++++++++++ test/host/cfg_file/run_unit_test.py | 55 ++ test/host/cfg_file/stub/dbg_log.h | 31 + 11 files changed, 1254 insertions(+), 9 deletions(-) create mode 100644 docs/测试说明/配置文件单元测试说明.md create mode 100644 test/host/cfg_file/cfg_file_test.c create mode 100644 test/host/cfg_file/run_unit_test.py create mode 100644 test/host/cfg_file/stub/dbg_log.h diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b183e79f..e44c9827 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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 里 diff --git a/App/cfg/cfg_file.c b/App/cfg/cfg_file.c index c59433ef..f1520eac 100644 --- a/App/cfg/cfg_file.c +++ b/App/cfg/cfg_file.c @@ -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; } diff --git a/docs/README.md b/docs/README.md index 7bd917ad..be893427 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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) | **模块测试指南**(动手写测试前先读) diff --git a/docs/内存管理/RAM使用分析与优化方案.md b/docs/内存管理/RAM使用分析与优化方案.md index ac57c878..154bdd15 100644 --- a/docs/内存管理/RAM使用分析与优化方案.md +++ b/docs/内存管理/RAM使用分析与优化方案.md @@ -36,10 +36,10 @@ | 区域 | 用量 | 容量 | 占比 | |---|---|---|---| -| 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%** | 各区的容量与常量来源: diff --git a/docs/测试说明/测试开发标准流程.md b/docs/测试说明/测试开发标准流程.md index e7c44970..bb46fc9e 100644 --- a/docs/测试说明/测试开发标准流程.md +++ b/docs/测试说明/测试开发标准流程.md @@ -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` 同步 | diff --git a/docs/测试说明/配置文件单元测试说明.md b/docs/测试说明/配置文件单元测试说明.md new file mode 100644 index 00000000..7ebc38e2 --- /dev/null +++ b/docs/测试说明/配置文件单元测试说明.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` | diff --git a/docs/问题记录/错误码传播审计.md b/docs/问题记录/错误码传播审计.md index b94cce35..8886a439 100644 --- a/docs/问题记录/错误码传播审计.md +++ b/docs/问题记录/错误码传播审计.md @@ -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 十五项 diff --git a/test/host/README.md b/test/host/README.md index 5ca16a47..bd6d955c 100644 --- a/test/host/README.md +++ b/test/host/README.md @@ -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) diff --git a/test/host/cfg_file/cfg_file_test.c b/test/host/cfg_file/cfg_file_test.c new file mode 100644 index 00000000..ff7a9d2a --- /dev/null +++ b/test/host/cfg_file/cfg_file_test.c @@ -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 +#include +#include +#include + +/* 先纳入桩头(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; +} diff --git a/test/host/cfg_file/run_unit_test.py b/test/host/cfg_file/run_unit_test.py new file mode 100644 index 00000000..577bade4 --- /dev/null +++ b/test/host/cfg_file/run_unit_test.py @@ -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)) diff --git a/test/host/cfg_file/stub/dbg_log.h b/test/host/cfg_file/stub/dbg_log.h new file mode 100644 index 00000000..c462de24 --- /dev/null +++ b/test/host/cfg_file/stub/dbg_log.h @@ -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 + +/* 测试观测计数(在 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 */