测试:新增 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
+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 */