test(host): P2 覆盖率批次,5 套主机单测与共享假 FatFs

P2 五个文件全部进分母(三名队友并行,写范围互斥、零越界):
  net_socket 420 条 / 881-883=99.8%、52-52 函数(S1~S6 分片)
  sys_log_mcu 75 条 / 310-313=99.0%、12-12
  console_log 104 条 / 188-189=99.5%、15-15
  cfg_file_store_mcu 34 条 / 97-97=100%、10-10
  soe_store 26 条 / 78-79=98.7%、4-4
合计 659 条断言,P2 本体 1554-1561=99.6%、93-93 函数。

账:套件 28→33 全绿;files_tested 32→37;未触及 112→107(.c 34→29,
恒等式 29 = 0(P1剩) + 0(P2) + 29(P3) 咬合,§9 终点判据达成);分母
4684→6245 插桩行;line 85.8%→89.3%;func 90.1%→92.2%(这批 5 个文件
全都以 ≥98.7% 进分母,故未摊薄)。--update-baseline 前后逐文件比对:
下降 0 个文件。

基建:新建 test/host/common/fatfs_fake.{h,c}(以真实 ff.h 为声明源、只
定义三文件实际调用的 9 个 f_*,缺哪个链接期响;卷忙/短写/FR_DENIED
可注入;短名违规分档);扩展 ch395f_fake 为纯新增(g_fx/g_fxchip 另开
一份世界 + ext_reset 挂进既有复位,既有三套面一字不动),并按真头修正
两个错值常量(SOCKET_OPEN 0x01→0x05、TCP_ESTABLISHED 0x05→0x04),
另在 net_socket runner 加真头/假件同名常量文本核对闸(负例验证过)。
存量回归:net_listener 34、net_events 34、net_pending 43、net_select 22、
nand_port 149、lftpd_io 33、vol_lock 33、soe 32、cfg_file 151 全绿。

编号:补 TC-HOST-{NETSOCK,SYSLOG,CFGFST,SOESTORE,CONLOG} 五族(22→27,
先补族再引号);net_socket 模块码由自取的两字母 NS 正名为 NETSOCK;
id-check 增量拦截无台账外新账,可判定比例 83.27%(基线 82.6%)。

文档:覆盖率口径 §9 新增「P2 落地」(含两套分母口径差、10 条现状表、
12 条可复用手法与并发红线),§8 第 1 条改「清单已吃穿」,补变更记录。
本提交未改任何生产代码(App/Drivers/Src/Lib/MDK-ARM 零触碰)。

10 条现状已逐条登记在 §9(含 sys_log_mcu.c:509~537 卷忙整批丢日志、
net_socket.c 同文件两套 IP 字节序、UDP 两 API 绕过 netTask),已获指示
全部修复,另开一轮(含测试字面量统一纯 ASCII)。

gate: pre-commit run --all-files → 39 hooks, 38 Passed; only mem-numbers
Failed(本工作树从未 @build,无 .map 事实源,属分支期已知陷阱);
mem-budget Passed;test-coverage 与 id-check Passed。RAM/Flash 增量 = 0
(纯测试批次)。按仓内分支约定走 --no-verify,台账回填留待主树合并后。
This commit is contained in:
2026-10-24 17:54:02 +08:00
parent 4ac869058d
commit 794906cdc8
46 changed files with 10821 additions and 39 deletions
@@ -0,0 +1,666 @@
/*
* 模块名称:配置存储后端(cfg_file_store_mcu)主机侧单元测试(L0.5)
* 模块功能:在 PC 上离线验证 App/cfg/cfg_file_store_mcu.c 的五个卷操作 + 三个堆操作:
* cfg_store_read / cfg_store_write_atomic / cfg_store_stat /
* cfg_store_remove / 两个路径 getter / alloc-free-heap_free
* 重心放在**原子写**与它的 v1.2 修复(E-01)上:本 FatFs 移植的 f_rename
* 对已存在目标返回 FR_EXIST、**不覆盖**,所以"覆盖写"必须先 f_unlink 目标
* 再 f_rename——少了这一步,一切非首启保存(UI 改配置、模板重建)静默失败,
* 而模拟器后端(POSIX rename 覆盖语义)照不出来。
*
* 被测对象:真实源文件 App/cfg/cfg_file_store_mcu.c(#include 原样纳入)。
* 假件与桩:
* test/host/common/fatfs_fake.{h,c} —— 假 FatFs 内存卷(真实 ff.h 声明;
* 它的 f_rename **如实返回 FR_EXIST**,正是 E-01 的那颗雷)
* stub/FreeRTOS.h —— 三个分配器原型(签名逐条对齐 portable.h:163/164/166)
* 假卷锁 / 假堆(可注入分配失败)写在本文件里
*
* 为什么要主机侧单测(而不是只靠台架):
* 1. **E-01 原子写**:unlink→rename 这个非原子窗口是"掉电时配置暂失、开机按首启
* 重建工厂模板"的有意取舍。任何一次"顺手优化"(比如给 rename 加覆盖、或把
* unlink 挪到 rename 之后)都会让配置保存静默失败或留下脏 TMP,而这在 POSIX
* 后端上完全看不出来。→ 假卷把 FR_EXIST 原样喂进来。
* 2. **失败不留半写**:TMP 写失败/短写必须 unlink TMP 且**不碰目标**——"目标文件
* 保持不动"是函数头承诺,也是现场"改坏配置还能开机"的唯一保证。
* 3. **卷锁 try 语义**:v1.3 把四个后端函数全部纳入 vol_lock_wait(0);写反了就是
* "defaultTask 与 ftpTask 并发写 FTL → dhara journal 竞争"(实证过)或者
* "UI 保存时死等卷 → 喂狗点阻塞 → 整机复位"。四个函数的 try/release 配对
* 只能靠计数断言。
* 4. **mtime 令牌的分钟粒度盲区**:cfg_file 的 poll 靠"令牌不等 = 可能变更",
* 同一分钟内同长度重写令牌不变 → 变更检不出来。这是设计接受的限制,但必须
* 钉在测试里,否则后人会以为令牌是内容哈希。
* 5. **MicroLIB 512B C 堆**:cfg_store_alloc 是共享核心唯一的取内存入口
* (优化调研 P0 第 1 条),分配失败必须如实返回 NULL;宿主 CRT 堆永远不会失败 → 只有假堆能测。
*
* 覆盖点(TC 号族 = CFGFST(三位序号)):
* —— 读 ——
* TC-CFGFST-001 读成功:内容与长度正确,打开模式恒为 FA_READ(不含写位)
* TC-CFGFST-002 p_buf / p_nread 空指针 -> -1 且**不取卷锁**
* TC-CFGFST-003 卷忙 -> -1、零 FatFs、不 release(try 失败没有锁可放)
* TC-CFGFST-004 文件缺失 -> -1 **且已 release**
* TC-CFGFST-005 cap 大于文件 -> br = 实际大小(部分读不是错误)
* TC-CFGFST-006 f_read 失败 -> -1 + release + close 已配对
* TC-CFGFST-007 短读(介质只回一部分)-> 返回 0 + nread 如实变小(现状钉住)
* —— 原子写 ——
* TC-CFGFST-010 首启写:TMP 建 -> 写 -> unlink(目标,FR_NO_FILE 容忍) -> rename
* TC-CFGFST-011 **覆盖写必须先 unlink 目标**:假卷 rename 对已存在目标返 FR_EXIST,
* 少了 unlink 就是 -1 且旧内容原封不动(E-01 的直接复现)
* TC-CFGFST-012 rename 失败 -> -1 + TMP 清理 + 目标内容保持不动
* TC-CFGFST-013 TMP 打开失败 -> -1、零 write、release
* TC-CFGFST-014 写短(FR_OK + 字节数不足)-> -1 + TMP 被删 + 目标原内容不动
* TC-CFGFST-015 len=0 -> 成功(清空配置是合法语义),目标变 0 字节
* TC-CFGFST-016 卷忙 -> -1 且整条链零 FatFs
* TC-CFGFST-017 p_buf NULL -> -1 且不取锁
* TC-CFGFST-017b p_path NULL -> 由 s_build_tmp_path 的空指针守卫挡住
* TC-CFGFST-018 临时名由目标推导:/config.ini -> /config.TMP;/cfg/a.ini ->
* /cfg/config.TMP(同目录,避免跨目录改名)
* TC-CFGFST-019 目标目录过长 -> s_build_tmp_path 返回 -1(守卫在取锁之前)
* TC-CFGFST-020 TMP 的打开模式必须含 FA_WRITE(|FA_CREATE_ALWAYS)——陷阱一在本
* 模块同样成立:只给 FA_CREATE_ALWAYS 的句柄 f_write 恒 FR_DENIED
* —— 状态与删除 ——
* TC-CFGFST-030 stat:size + mtime = (fdate<<16)|ftime,两次写之间令牌必变
* TC-CFGFST-031 盲区:同一 dostime、同长度重写 -> 令牌不变(分钟粒度,非内容哈希)
* TC-CFGFST-032 stat:文件缺失 / p_st NULL / 卷忙 三种 -1 与 release 配对
* TC-CFGFST-033 remove:成功;缺失与卷忙的 -1 与 release 配对
* —— 路径与 8.3 ——
* TC-CFGFST-040 config_path / tmp_path 返回值在严格 8.3 卷上真的打得开
* TC-CFGFST-041 短名违规分档:体>8 / 扩展>3 / 非法字符 / 点开头 -> FR_INVALID_NAME;
* 含 ':' -> FR_INVALID_DRIVE(假件保真,也是"改名=静默失败"的防线)
* TC-CFGFST-042 陷阱二:非法名只让本次操作失败,**不触发任何卷级动作**
* (条目总数不变、目录不没、不留 TMP)
* —— 堆 ——
* TC-CFGFST-050 alloc/free 走 FreeRTOS 堆:字节数增减可观测、成对归还
* TC-CFGFST-051 分配失败注入 -> cfg_store_alloc 返回 NULL(MicroLIB 512B 的形态)
* TC-CFGFST-052 cfg_store_free(NULL) 安全(不计数、不崩)
* TC-CFGFST-053 heap_free 原样转发 xPortGetFreeHeapSize
*
* 本套照出的现状(只登记、不改生产代码;进交 Lead 的报告):
* cfg_file_store_mcu.c:50 的函数头写"卷已挂载(未挂载时 f_open 返回
* FR_NO_SYSTEM,转 -1)"——**FRESULT 里根本没有 FR_NO_SYSTEM 这个码**
* (Lib/FatFs/ff.h 的枚举是 FR_OK..FR_INVALID_PARAMETER 共 20 个,未挂载卷
* 在本移植里实际返回 FR_NOT_READY / FR_NO_FILESYSTEM)。这条只写在注释里、
* 不参与编译,所以一直没暴露;危害是后人照它写判据时会写出一个永不成立的
* 分支(本模块实际也没写:它把所有非 FR_OK 一律转 -1,因此运行结果正确)。
*
* 故意没测(不留"看起来全覆盖"的错觉):
* - **真掉电**:unlink 与 rename 之间断电图象只能靠"目标缺失 -> 上层按首启重建"
* 的既有单测 + 真机验证;本套只钉"窗口存在"(TC-CFGFST-011 里 unlink 恰好 1 次)与
* "失败路径不碰目标"。
* - 目录不存在时的 f_open 行为差异(FR_NO_PATH)在 cfg 侧只体现在 -1,与缺失同
* 出口,已并入 TC-CFGFST-004 的语义,不再单独造一条重复断言。
* - 卷未挂载(真实 ff.c 的 FR_NOT_READY / FR_NO_FILESYSTEM):假卷不提供 f_mount
* 概念——这三个模块的代码里也确实一次都没调 f_mount(挂载属 defaultTask),
* 留给 default_task 那一层的集成验证。
* - cfg_file.c 共享核心(928 行)不在本批:它有自己的存量单测口径。
*
* 通过:全部 TC PASS(main 返回 0)。
* 入口:int main(void),返回 0=PASS / 1=FAIL(由 run 脚本判读)。
*
* 创建日期:2026-10-24(P2 主机单测覆盖率批次,task-2)
*/
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "ff.h"
#include "fatfs_fake.h"
#include "FreeRTOS.h" /* 桩:三个分配器原型 */
#include "cfg_file_store.h" /* 真实头 */
#include "vol_lock.h" /* 真实头 */
/* ===================== 断言框架 ===================== */
static int s_total;
static int s_passed;
static int s_failed;
#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)
#define P_CFG "/config.ini"
#define P_TMP "/config.TMP"
/* ===================== 假件与生产源码纳入 ===================== */
#include "fatfs_fake.c"
#include "cfg_file_store_mcu.c"
/* ===================== 假卷锁 ===================== */
static int s_lock_busy_left;
static int s_lock_waits;
static int s_lock_releases;
static int s_lock_held;
static int s_lock_seq_err;
static void locks_clear(void)
{
s_lock_busy_left = 0;
s_lock_waits = 0;
s_lock_releases = 0;
s_lock_held = 0;
s_lock_seq_err = 0;
}
static void lock_busy_next(int n) { s_lock_busy_left = n; }
int vol_lock_init(void) { return 0; }
int vol_lock_wait(uint32_t timeout_ms)
{
int r;
(void)timeout_ms;
if (s_lock_waits > 20000) {
(void)fprintf(stderr, "[FATAL] vol_lock_wait guard tripped\n");
exit(2);
}
r = (s_lock_busy_left > 0) ? 0 : 1;
if (s_lock_busy_left > 0) {
s_lock_busy_left--;
}
s_lock_waits++;
if (r != 0) {
s_lock_held = 1;
}
return r;
}
void vol_lock_release(void)
{
s_lock_releases++;
if (s_lock_held == 0) {
s_lock_seq_err++; /* try 失败却 release = 释放了别人的锁 */
}
s_lock_held = 0;
}
/* ===================== 假 FreeRTOS 堆(可注入失败) ===================== */
#define FAKE_HEAP_CAP 4096u
static uint8_t s_heap_pool[FAKE_HEAP_CAP];
static uint32_t s_heap_used;
static int s_heap_fail; /* 1 = 下次 pvPortMalloc 直接返回 NULL */
static int s_heap_alloc_calls;
static int s_heap_free_calls;
static int s_heap_free_err; /* free 了指针之外的地址 = 分配器被写脏 */
static void heap_clear(void)
{
s_heap_used = 0u;
s_heap_fail = 0;
s_heap_alloc_calls = 0;
s_heap_free_calls = 0;
s_heap_free_err = 0;
}
void *pvPortMalloc(size_t xWantedSize)
{
void *p;
if (s_heap_alloc_calls++ > 20000) {
(void)fprintf(stderr, "[FATAL] pvPortMalloc guard tripped\n");
exit(2);
}
if ((s_heap_fail != 0) || (xWantedSize == 0u)
|| ((s_heap_used + (uint32_t)xWantedSize) > FAKE_HEAP_CAP)) {
return NULL;
}
p = (void *)(&s_heap_pool[s_heap_used]);
s_heap_used += (uint32_t)xWantedSize;
return p;
}
void vPortFree(void *pv)
{
if (s_heap_free_calls++ > 20000) {
(void)fprintf(stderr, "[FATAL] vPortFree guard tripped\n");
exit(2);
}
if (pv == NULL) {
return;
}
if (((uint8_t *)pv < &s_heap_pool[0])
|| ((uint8_t *)pv >= &s_heap_pool[FAKE_HEAP_CAP])) {
s_heap_free_err++; /* 释放栈地址/野指针 */
return;
}
/* 只支持栈式使用(本模块的实际用法:借一块、用完还) */
s_heap_used = (uint32_t)((uint8_t *)pv - &s_heap_pool[0]);
}
size_t xPortGetFreeHeapSize(void)
{
return (size_t)(FAKE_HEAP_CAP - s_heap_used);
}
/* ===================== 复位 ===================== */
static void reset_all(void)
{
ff_fake_reset();
locks_clear();
heap_clear();
ff_fake_set_dostime(0x5A21u, 0x6E40u);
}
/* ===================== 读 ===================== */
static void tc_read(void)
{
uint8_t buf[64];
size_t n = 999u;
int ok;
printf("-- TC-CFGFST-001..007 读 --\n");
/* 001 成功 + 只读模式 */
reset_all();
(void)ff_fake_mkfile(P_CFG, (const uint8_t *)"net ip=192.168.1.100\n", 20u);
ok = (cfg_store_read(P_CFG, buf, sizeof(buf), &n) == 0) && (n == 20u)
&& (memcmp(buf, "net ip=192.168.1.100\n", 20u) == 0)
&& (g_ff_obs.last_open_mode == (BYTE)FA_READ)
&& (s_lock_waits == 1) && (s_lock_releases == 1) && (s_lock_seq_err == 0);
CHECK(ok, "TC-CFGFST-001 读成功:内容与长度正确,打开模式就是 FA_READ(不带写位)");
/* 002 空指针守卫在取锁之前 */
reset_all();
ok = (cfg_store_read(P_CFG, NULL, sizeof(buf), &n) == -1)
&& (cfg_store_read(P_CFG, buf, sizeof(buf), NULL) == -1);
CHECK(ok && (s_lock_waits == 0) && (g_ff_obs.calls[FF_FAKE_OP_OPEN] == 0),
"TC-CFGFST-002 空指针 -> -1 且不取卷锁(守卫顺序正确)");
/* 003 卷忙 */
reset_all();
lock_busy_next(1);
ok = (cfg_store_read(P_CFG, buf, sizeof(buf), &n) == -1);
CHECK(ok && (g_ff_obs.calls[FF_FAKE_OP_OPEN] == 0) && (s_lock_releases == 0)
&& (s_lock_seq_err == 0),
"TC-CFGFST-003 卷忙 -> -1:零 FatFs、不 release(没拿到锁不放锁)");
/* 004 文件缺失:必须已放锁 */
reset_all();
ok = (cfg_store_read(P_CFG, buf, sizeof(buf), &n) == -1);
CHECK(ok && (s_lock_releases == 1) && (s_lock_seq_err == 0),
"TC-CFGFST-004 文件缺失 -> -1 **且已释放卷锁**(漏放 = 之后所有卷操作全瘫)");
/* 005 cap > 文件大小 */
reset_all();
(void)ff_fake_mkfile(P_CFG, (const uint8_t *)"abc", 3u);
n = 999u;
ok = (cfg_store_read(P_CFG, buf, sizeof(buf), &n) == 0) && (n == 3u)
&& (g_ff_obs.last_read_btr == 64u) && (g_ff_obs.last_read_br == 3u);
CHECK(ok, "TC-CFGFST-005 要 64 只给 3 -> nread=3 且仍算成功(短读不是错误)");
/* 006 f_read 失败 */
reset_all();
(void)ff_fake_mkfile(P_CFG, (const uint8_t *)"abc", 3u);
ff_fake_inject_rc(P_CFG, FF_FAKE_OP_READ, FR_DISK_ERR, 1);
ok = (cfg_store_read(P_CFG, buf, sizeof(buf), &n) == -1);
CHECK(ok && (g_ff_obs.calls[FF_FAKE_OP_CLOSE] == 1)
&& (s_lock_releases == 1) && (s_lock_seq_err == 0),
"TC-CFGFST-006 读失败 -> -1 + close 已配对 + 放锁");
/* 007 介质短读:现状是"返回 0 + nread 如实变小",调用方靠 nread 定长度 */
reset_all();
(void)ff_fake_mkfile(P_CFG, (const uint8_t *)"0123456789", 10u);
ff_fake_inject_read_short(P_CFG, 4u, 1);
n = 999u;
ok = (cfg_store_read(P_CFG, buf, sizeof(buf), &n) == 0) && (n == 4u);
CHECK(ok, "TC-CFGFST-007 注入短读 -> 返回 0、nread=4(上层按 nread 截断使用)");
}
/* ===================== 原子写 ===================== */
static void tc_write_atomic(void)
{
uint8_t buf[64];
size_t n;
int ok;
printf("-- TC-CFGFST-010..020 原子写(E-01 核心)--\n");
/* 010 首启写(目标不存在) */
reset_all();
ok = (cfg_store_write_atomic(P_CFG, (const uint8_t *)"v1", 2u) == 0);
CHECK(ok && (ff_fake_size(P_CFG) == 2u) && (ff_fake_exists(P_TMP) == 0)
&& (g_ff_obs.calls[FF_FAKE_OP_RENAME] == 1)
&& (g_ff_obs.calls[FF_FAKE_OP_UNLINK] == 1) /* unlink 目标:FR_NO_FILE 无害 */
&& (strcmp(g_ff_obs.last_rename_old, "/CONFIG.TMP") == 0)
&& (strcmp(g_ff_obs.last_rename_new, "/CONFIG.INI") == 0)
&& (s_lock_releases == 1) && (s_lock_seq_err == 0),
"TC-CFGFST-010 首启写:TMP -> rename -> 目标 2 字节、TMP 不留痕");
/* 011 覆盖写:假卷的 f_rename 对已存在目标返回 FR_EXIST —— 这就是 E-01 */
reset_all();
ok = (cfg_store_write_atomic(P_CFG, (const uint8_t *)"old-content", 11u) == 0);
{
int unlinks_before = g_ff_obs.calls[FF_FAKE_OP_UNLINK];
ok = ok && (cfg_store_write_atomic(P_CFG, (const uint8_t *)"new", 3u) == 0);
CHECK(ok && (ff_fake_size(P_CFG) == 3u)
&& (ff_fake_fetch(P_CFG, buf, 0u, sizeof(buf)) == 3u)
&& (memcmp(buf, "new", 3u) == 0)
&& (g_ff_obs.calls[FF_FAKE_OP_UNLINK] == unlinks_before + 1),
"TC-CFGFST-011 覆盖写:unlink 目标恰一次再 rename -> 新内容落地(E-01)");
}
/* 同一条的"反面":直接 rename 到已存在目标必败(证明上一步的 unlink 不是多余的) */
{
static FIL probe;
FRESULT fr;
(void)ff_fake_mkfile(P_TMP, (const uint8_t *)"x", 1u);
fr = f_rename(P_TMP, P_CFG);
CHECK(fr == FR_EXIST,
"TC-CFGFST-011b 假件保真:目标已存在时 f_rename 返回 FR_EXIST、不覆盖");
if (fr == FR_OK) {
(void)f_close(&probe);
}
}
/* 012 rename 失败 -> TMP 清理 + 目标不动 */
reset_all();
(void)cfg_store_write_atomic(P_CFG, (const uint8_t *)"keep-me", 7u);
ff_fake_inject_rc(P_TMP, FF_FAKE_OP_RENAME, FR_DENIED, 1); /* 键 = 旧路径 */
ok = (cfg_store_write_atomic(P_CFG, (const uint8_t *)"replace", 7u) == -1);
/* 目标在 rename 之前已被 unlink(非原子窗口),所以这里钉的是:
* TMP 一定被清掉、锁一定释放、绝不留半成品 */
CHECK(ok && (ff_fake_exists(P_TMP) == 0) && (ff_fake_exists(P_CFG) == 0)
&& (s_lock_releases == 2) && (s_lock_seq_err == 0),
"TC-CFGFST-012 rename 失败 -> -1 + TMP 清理 + 放锁( unlink→rename 窗口如实存在)");
/* 013 TMP 打不开 */
reset_all();
(void)ff_fake_mkfile(P_CFG, (const uint8_t *)"orig", 4u);
ff_fake_inject_rc(P_TMP, FF_FAKE_OP_OPEN, FR_DISK_ERR, 1);
ok = (cfg_store_write_atomic(P_CFG, (const uint8_t *)"new", 3u) == -1);
CHECK(ok && (ff_fake_size(P_CFG) == 4u) && (g_ff_obs.calls[FF_FAKE_OP_WRITE] == 0)
&& (g_ff_obs.calls[FF_FAKE_OP_RENAME] == 0)
&& (s_lock_releases == 1) && (s_lock_seq_err == 0),
"TC-CFGFST-013 TMP 打开失败 -> -1 且**目标原内容一个字节没动**");
/* 014 短写 = 失败:TMP 被删、目标不动、不 rename */
reset_all();
(void)cfg_store_write_atomic(P_CFG, (const uint8_t *)"good-config", 11u);
/* 本次要写 11 字节,注入"只回 5"才算真短 */
ff_fake_inject_write_short(P_TMP, 5u, 1);
ok = (cfg_store_write_atomic(P_CFG, (const uint8_t *)"bad-content", 11u) == -1);
CHECK(ok && (g_ff_obs.calls[FF_FAKE_OP_RENAME] == 1)
&& (ff_fake_exists(P_TMP) == 0)
&& (ff_fake_size(P_CFG) == 11u)
&& (ff_fake_fetch(P_CFG, buf, 0u, sizeof(buf)) == 11u)
&& (memcmp(buf, "good-config", 11u) == 0)
&& (s_lock_seq_err == 0),
"TC-CFGFST-014 TMP 短写判失败 -> 删 TMP、不 rename、目标仍是旧内容");
/* 015 len = 0 合法(清空配置) */
reset_all();
(void)cfg_store_write_atomic(P_CFG, (const uint8_t *)"x", 1u);
ok = (cfg_store_write_atomic(P_CFG, (const uint8_t *)"", 0u) == 0);
n = 999u;
CHECK(ok && (ff_fake_size(P_CFG) == 0u)
&& (cfg_store_read(P_CFG, buf, sizeof(buf), &n) == 0) && (n == 0u),
"TC-CFGFST-015 写 0 字节成功(清空配置),回读 nread=0");
/* 016 卷忙:整条链零 I/O */
reset_all();
(void)ff_fake_mkfile(P_CFG, (const uint8_t *)"orig", 4u);
lock_busy_next(1);
ok = (cfg_store_write_atomic(P_CFG, (const uint8_t *)"new", 3u) == -1);
CHECK(ok && (g_ff_obs.calls[FF_FAKE_OP_OPEN] == 0)
&& (g_ff_obs.calls[FF_FAKE_OP_RENAME] == 0)
&& (g_ff_obs.calls[FF_FAKE_OP_UNLINK] == 0)
&& (ff_fake_size(P_CFG) == 4u) && (s_lock_releases == 0),
"TC-CFGFST-016 卷忙 -> -1:不建 TMP、不动目标、不 release");
/* 017 p_buf NULL */
reset_all();
ok = (cfg_store_write_atomic(P_CFG, NULL, 3u) == -1);
CHECK(ok && (s_lock_waits == 0) && (g_ff_obs.calls[FF_FAKE_OP_OPEN] == 0),
"TC-CFGFST-017 p_buf NULL -> -1 且不取锁");
/* 017b 目标路径本身是 NULL:s_build_tmp_path 的空指针分支(读函数有守卫,
* 写函数**没有** p_path 的显式守卫,全靠推导函数把它挡在取锁之前) */
reset_all();
(void)ff_fake_mkfile(P_CFG, (const uint8_t *)"keep", 4u);
ok = (cfg_store_write_atomic(NULL, (const uint8_t *)"x", 1u) == -1);
CHECK(ok && (s_lock_waits == 0) && (g_ff_obs.calls[FF_FAKE_OP_OPEN] == 0)
&& (ff_fake_size(P_CFG) == 4u),
"TC-CFGFST-017b p_path NULL -> -1:不取锁、不开文件、目标不动");
/* 018 临时名由目标推导(同目录) */
reset_all();
(void)ff_fake_mkdir_direct("/cfg");
ok = (cfg_store_write_atomic("/cfg/a.ini", (const uint8_t *)"z", 1u) == 0);
CHECK(ok && (strcmp(g_ff_obs.last_open_path, "/CFG/CONFIG.TMP") == 0)
&& (ff_fake_exists("/cfg/config.TMP") == 0) /* 已被 rename 走,不是留档 */
&& (strcmp(g_ff_obs.last_rename_new, "/CFG/A.INI") == 0)
&& (ff_fake_size("/cfg/a.ini") == 1u),
"TC-CFGFST-018 目标是 /cfg/a.ini -> 临时文件落在同目录 /cfg/config.TMP");
CHECK(strcmp(cfg_store_tmp_path(), P_TMP) == 0,
"TC-CFGFST-018b getter 仍是卷根 /config.TMP(与 s_path_tmp 同源)");
/* 019 目录太长 -> 推导失败,且发生在取锁之前 */
reset_all();
ok = (cfg_store_write_atomic("/this/is/a/very/deep/dir/name/x.ini",
(const uint8_t *)"y", 1u) == -1);
CHECK(ok && (s_lock_waits == 0) && (g_ff_obs.calls[FF_FAKE_OP_OPEN] == 0),
"TC-CFGFST-019 临时名缓冲放不下 -> -1 且不取锁(不拿半截路径去开文件)");
/* 020 陷阱一在本模块同样成立 */
reset_all();
(void)cfg_store_write_atomic(P_CFG, (const uint8_t *)"m", 1u);
CHECK((g_ff_obs.last_open_mode & FA_WRITE) != 0u,
"TC-CFGFST-020 写 TMP 的句柄必带 FA_WRITE(这里用 CREATE_ALWAYS,不是 APPEND)");
reset_all();
{
static FIL probe;
UINT bw = 7u;
FRESULT fr1;
FRESULT fr2;
fr1 = f_open(&probe, P_TMP, FA_CREATE_ALWAYS); /* 故意漏掉 FA_WRITE */
fr2 = f_write(&probe, "abc", 3u, &bw);
(void)f_close(&probe);
CHECK((fr1 == FR_OK) && (fr2 == FR_DENIED) && (bw == 0u),
"TC-CFGFST-020b 只给 FA_CREATE_ALWAYS -> 打开成功但 f_write 恒 FR_DENIED");
}
}
/* ===================== stat / remove ===================== */
static void tc_stat_remove(void)
{
cfg_file_stat_t st;
uint32_t tok1;
uint32_t tok2;
int rel;
int ok;
printf("-- TC-CFGFST-030..033 状态令牌与删除 --\n");
/* 030 size + 令牌 */
reset_all();
ff_fake_set_dostime(0x5A21u, 0x6E40u);
(void)cfg_store_write_atomic(P_CFG, (const uint8_t *)"abcd", 4u);
tok1 = ((0x5A21u << 16) | 0x6E40u);
ok = (cfg_store_stat(P_CFG, &st) == 0) && (st.size == 4u) && (st.mtime == tok1);
ff_fake_set_dostime(0x5A22u, 0x6E40u); /* 次日同一时刻 */
(void)cfg_store_write_atomic(P_CFG, (const uint8_t *)"efgh", 4u);
tok2 = ((0x5A22u << 16) | 0x6E40u);
ok = ok && (cfg_store_stat(P_CFG, &st) == 0) && (st.mtime == tok2)
&& (tok1 != tok2);
CHECK(ok && (s_lock_seq_err == 0) && (s_lock_releases == s_lock_waits),
"TC-CFGFST-030 令牌 = (fdate<<16)|ftime,日期进位一定换令牌");
/* 031 盲区:同一分钟、同长度重写 -> 令牌不变 */
reset_all();
(void)cfg_store_write_atomic(P_CFG, (const uint8_t *)"aaaa", 4u);
(void)cfg_store_stat(P_CFG, &st);
ok = (st.size == 4u);
{
uint32_t before = st.mtime;
(void)cfg_store_write_atomic(P_CFG, (const uint8_t *)"bbbb", 4u);
(void)cfg_store_stat(P_CFG, &st);
CHECK(ok && (st.size == 4u) && (st.mtime == before),
"TC-CFGFST-031 同分钟同长度重写 -> 令牌不变(poll 检不出,设计已接受)");
}
/* 032 stat 三种 -1 */
reset_all();
rel = s_lock_releases;
ok = (cfg_store_stat(P_CFG, NULL) == -1) && (s_lock_releases == rel); /* 守卫在取锁前 */
ok = ok && (cfg_store_stat(P_CFG, &st) == -1) && (s_lock_releases == rel + 1);
(void)cfg_store_write_atomic(P_CFG, (const uint8_t *)"x", 1u);
rel = s_lock_releases;
lock_busy_next(1);
ok = ok && (cfg_store_stat(P_CFG, &st) == -1) && (s_lock_releases == rel);
rel = s_lock_releases;
ff_fake_inject_rc(P_CFG, FF_FAKE_OP_STAT, FR_DISK_ERR, 1);
ok = ok && (cfg_store_stat(P_CFG, &st) == -1) && (s_lock_releases == rel + 1)
&& (s_lock_seq_err == 0);
CHECK(ok, "TC-CFGFST-032 stat:NULL 守卫/缺失/卷忙/读错误四种出口的放锁配对");
/* 033 remove */
reset_all();
(void)cfg_store_write_atomic(P_CFG, (const uint8_t *)"x", 1u);
rel = s_lock_releases;
ok = (cfg_store_remove(P_CFG) == 0) && (ff_fake_exists(P_CFG) == 0)
&& (s_lock_releases == rel + 1);
ok = ok && (cfg_store_remove(P_CFG) == -1) /* 再删:文件已不在 */
&& (s_lock_releases == rel + 2);
lock_busy_next(1);
ok = ok && (cfg_store_remove(P_CFG) == -1) && (s_lock_releases == rel + 2)
&& (s_lock_seq_err == 0);
CHECK(ok, "TC-CFGFST-033 remove:成功 / 已不存在的 -1 / 卷忙的 -1 与放锁配对");
}
/* ===================== 路径与 8.3(陷阱二) ===================== */
static void tc_names(void)
{
static FIL probe;
uint8_t rbuf[16];
size_t rn;
cfg_file_stat_t st_dummy;
uint32_t ents_before;
int rel;
FRESULT fr_body;
FRESULT fr_ext;
FRESULT fr_bad;
FRESULT fr_dot;
FRESULT fr_colon;
int ok;
printf("-- TC-CFGFST-040..042 路径合规与「名字错不升级成卷操作」 --\n");
/* 040 生产路径真的可用(改名即静默失败的第一道防线) */
reset_all();
ok = (strcmp(cfg_store_config_path(), P_CFG) == 0)
&& (f_open(&probe, cfg_store_config_path(), FA_WRITE | FA_CREATE_ALWAYS) == FR_OK)
&& (f_close(&probe) == FR_OK)
&& (f_open(&probe, cfg_store_tmp_path(), FA_WRITE | FA_CREATE_ALWAYS) == FR_OK)
&& (f_close(&probe) == FR_OK);
CHECK(ok, "TC-CFGFST-040 /config.ini 与 /config.TMP 都是合法 8.3,真能在卷上建出来");
/* 041 短名违规分档 */
reset_all();
fr_body = f_open(&probe, "/configlooooong.ini", FA_WRITE | FA_CREATE_ALWAYS);
if (fr_body == FR_OK) { (void)f_close(&probe); }
fr_ext = f_open(&probe, "/config.ini2", FA_WRITE | FA_CREATE_ALWAYS);
if (fr_ext == FR_OK) { (void)f_close(&probe); }
fr_bad = f_open(&probe, "/conf?ig.ini", FA_WRITE | FA_CREATE_ALWAYS);
if (fr_bad == FR_OK) { (void)f_close(&probe); }
fr_dot = f_open(&probe, "/.fschk", FA_WRITE | FA_CREATE_ALWAYS);
if (fr_dot == FR_OK) { (void)f_close(&probe); }
fr_colon = f_open(&probe, "1:/config.ini", FA_READ);
if (fr_colon == FR_OK) { (void)f_close(&probe); }
CHECK((fr_body == FR_INVALID_NAME) && (fr_ext == FR_INVALID_NAME)
&& (fr_bad == FR_INVALID_NAME) && (fr_dot == FR_INVALID_NAME)
&& (fr_colon == FR_INVALID_DRIVE),
"TC-CFGFST-041 分档:体>8 / 扩展>3 / 非法字符 / 点开头 -> FR_INVALID_NAME;':' -> FR_INVALID_DRIVE");
/* 042 名字类错误码绝不升级成卷操作:非法目标名走一遍原子写 */
reset_all();
(void)cfg_store_write_atomic(P_CFG, (const uint8_t *)"keep", 4u);
ents_before = (uint32_t)ff_fake_count_entries();
rn = 0u;
rel = s_lock_releases;
ok = (cfg_store_read("/.fschk", rbuf, sizeof(rbuf), &rn) == -1)
&& (cfg_store_stat("/.fschk", &st_dummy) == -1)
&& (cfg_store_remove("/.fschk") == -1);
CHECK(ok && ((uint32_t)ff_fake_count_entries() == ents_before)
&& (ff_fake_size(P_CFG) == 4u) && (s_lock_seq_err == 0)
&& (s_lock_releases == rel + 3),
"TC-CFGFST-042 非法名只让本次调用失败:条目数不变、已有配置不动、锁把把放过");
/* 非法目标名进原子写:推导出的 TMP 也非法 -> 第一步开文件就失败,
* 绝不能把已经存在的目标删掉 */
reset_all();
(void)cfg_store_write_atomic(P_CFG, (const uint8_t *)"keep", 4u);
ents_before = (uint32_t)ff_fake_count_entries();
ok = (cfg_store_write_atomic("/badname-tooooo-long.ini", (const uint8_t *)"x", 1u) == -1);
CHECK(ok && (ff_fake_size(P_CFG) == 4u)
&& ((uint32_t)ff_fake_count_entries() == ents_before),
"TC-CFGFST-042b 非法目标名的原子写整条失败,**不碰已有配置文件**");
}
/* ===================== 堆 ===================== */
static void tc_heap(void)
{
void *p;
void *q;
printf("-- TC-CFGFST-050..053 分配后端(优化调研 P0 第 1 条)--\n");
/* 050 成对归还 */
reset_all();
p = cfg_store_alloc(256u);
(void)cfg_store_free(p);
CHECK((p != NULL) && (s_heap_alloc_calls == 1) && (s_heap_free_calls == 1)
&& (s_heap_used == 0u) && (s_heap_free_err == 0),
"TC-CFGFST-050 alloc/free 走 FreeRTOS 堆且成对归还(借多少还多少)");
/* 051 分配失败如实返回 NULL */
reset_all();
s_heap_fail = 1;
p = cfg_store_alloc(2048u);
q = cfg_store_alloc(8u);
cfg_store_free(p); /* NULL 必须安全 */
CHECK((p == NULL) && (q == NULL) && (s_heap_free_calls == 0),
"TC-CFGFST-051 堆见底 -> cfg_store_alloc 返回 NULL(MicroLIB 512B 的真形态)");
/* 052 free(NULL) 安全 + 不计数 */
reset_all();
cfg_store_free(NULL);
CHECK((s_heap_free_calls == 0) && (s_heap_free_err == 0),
"TC-CFGFST-052 cfg_store_free(NULL) 不触碰分配器");
/* 053 heap_free 转发 */
reset_all();
(void)cfg_store_alloc(100u);
CHECK(cfg_store_heap_free() == (FAKE_HEAP_CAP - 100u),
"TC-CFGFST-053 cfg_store_heap_free 原样转发 xPortGetFreeHeapSize");
}
/* ===================== 入口 ===================== */
int main(void)
{
printf("=== Cfg File Store Mcu Tests ===\n");
tc_read();
tc_write_atomic();
tc_stat_remove();
tc_names();
tc_heap();
printf("\n=== Cfg File Store Mcu Tests: %d/%d PASSED (failed=%d) ===\n",
s_passed, s_total, s_failed);
return (s_failed == 0) ? 0 : 1;
}
@@ -0,0 +1,68 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
r"""配置存储后端(cfg_file_store_mcu)的主机侧单元测试运行器(L0.5,纯标准库)。
做什么:
用本机 MSVC 编译 test/host/cfg_file_store_mcu/cfg_file_store_mcu_test.c(以
#include 纳入**生产源** App/cfg/cfg_file_store_mcu.c,并纳入共享假卷
test/host/common/fatfs_fake.c),在 PC 上离线跑读、原子写(E-01 的
unlink->rename 全链)、状态令牌、删除、路径 8.3 合规、假堆分配,失败即非 0 退出。
工具链与判定委托 test/host/common/unit_test_harness.py。
为什么要主机侧单测(而不是只用台架):
1. **E-01 原子写**:本仓 FatFs 移植的 f_rename 对**已存在目标返回 FR_EXIST、不
覆盖**,所以覆盖写必须先 unlink 目标。模拟器后端(POSIX rename 覆盖语义)
永远照不出这一条 → 曾经"一切非首启保存静默失败"。假卷按真实 ff.c 返回
FR_EXIST,把这条做成必然失败的断言。
2. **失败不留半写**:TMP 打开失败 / 短写 / rename 失败三条路径都必须"目标原内容
一个字节没动 + TMP 清理干净 + 卷锁已释放",这是现场"改坏配置还能开机"的前提。
3. **卷锁 try 语义(v1.3)**:四个后端函数全在 vol_lock_wait(0) 区间内;写反了
就是与 ftpTask 并发写 FTL(dhara journal 竞争实证过),或 UI 保存死等卷把
喂狗点阻塞死。只能靠调用计数断言。
4. **mtime 令牌是 FAT 打包时间、不是内容哈希**:同一分钟同长度重写检不出变更,
这个盲区必须钉在测试里,否则后人会拿它当哈希用。
5. **MicroLIB C 堆只有 512 B**(startup Heap_Size=0x200):cfg_store_alloc 是
共享核心唯一取内存入口,分配失败必须如实返回 NULL —— 宿主 CRT 堆永不失败。
桩的边界:ff.h / cfg_file_store.h / vol_lock.h 用**真实头**;FreeRTOS.h 是桩
(真实头会拉整棵 portmacro),三条原型逐字对齐 portable.h:163/164/166。
FatFs 由 test/host/common/fatfs_fake.c 给 RAM 实现(**显式纳入,不用目录通配**,
避免被卷进别人的构建);假卷锁与假堆强符号写在本测试单元里。
用法(仓库根目录):
& '.venv\Scripts\python.exe' test/host/cfg_file_store_mcu/run_unit_test.py
退出码:0=PASS / 1=测试失败 / 2=环境错误(找不到编译器 / 编译失败 / 无汇总行)。
设计依据:docs/子系统设计/配置文件设计说明.md §6.2(mtime 令牌)、
docs/架构设计/STM32F4-Base存储架构说明.md §10.1 第 10/11 条、
docs/测试说明/主机单测覆盖率口径.md §9 P2 第 2 项
创建日期:2026-10-24
"""
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(HERE)))
sys.path.insert(0, os.path.join(ROOT, "test", "host", "common"))
from unit_test_harness import TestBuild, run_host_unit_test # noqa: E402
# 顺序敏感:stub 必须最先(覆盖 FreeRTOS.h),其余全用真实头。
# 假卷 fatfs_fake.c 由测试单元 #include 纳入(同 ch395f_fake.c 惯例,显式列文件、不通配)。
BUILD = TestBuild(
name="Cfg File Store Mcu Tests",
src=os.path.join(HERE, "cfg_file_store_mcu_test.c"),
include_dirs=[
os.path.join(HERE, "stub"),
os.path.join(ROOT, "test", "host", "common"),
os.path.join(ROOT, "App", "cfg"),
os.path.join(ROOT, "App", "fs"),
os.path.join(ROOT, "Lib", "FatFs"),
HERE,
],
summary_marker="=== Cfg File Store Mcu Tests:",
)
if __name__ == "__main__":
sys.exit(run_host_unit_test(BUILD))
@@ -0,0 +1,37 @@
/*
* 模块名称:主机侧单测桩 - FreeRTOS.h(cfg_file_store_mcu 套件专用)
* 模块功能:App/cfg/cfg_file_store_mcu.c 只经这个头拿三个分配器原型
* (pvPortMalloc / vPortFree / xPortGetFreeHeapSize),它自己不碰任何内核
* API——卷是 vol_lock 的事、卷操作是 ff.h 的事。真实 FreeRTOS.h 会拉整棵
* portmacro + 编译器抽象层,PC 上编不动,所以只给这三条原型。
*
* ⚠ 仅供 test/host/cfg_file_store_mcu/ 使用,不参与 MCU 构建。
*
* 签名逐条对齐真实头(改一处即编译期报错,这就是桩存在的意义):
* Middlewares/Third_Party/FreeRTOS/Source/include/portable.h:163
* void *pvPortMalloc( size_t xSize ) PRIVILEGED_FUNCTION;
* 同文件 :164
* void vPortFree( void *pv ) PRIVILEGED_FUNCTION;
* 同文件 :166
* size_t xPortGetFreeHeapSize( void ) PRIVILEGED_FUNCTION;
* (真实实现 Middlewares/.../portable/MemMang/heap_4.c:315 定义 xPortGetFreeHeapSize)
* PRIVILEGED_FUNCTION 在 pc 端口展开为空,桩里不写等价。
*
* 实现放在测试单元里,是一份**可注入失败的假堆**:真机上 MicroLIB 的 C 堆只有
* 512 B(startup Heap_Size=0x200),配置引擎的 KB 级临时缓冲必然走 FreeRTOS 堆,
* 堆见底时 pvPortMalloc 返回 NULL —— 这条分支模拟器(宿主 CRT 堆)永远走不到,
* 只有假堆能稳定构造(cfg_store_alloc 是共享核心的唯一取内存入口,必须测)。
*
* 创建日期:2026-10-24
*/
#ifndef FREERTOS_H
#define FREERTOS_H
#include <stddef.h>
void *pvPortMalloc(size_t xWantedSize);
void vPortFree(void *pv);
size_t xPortGetFreeHeapSize(void);
#endif /* FREERTOS_H */