From b1291f1308259650f972151b1ac4f1ecc523af97 Mon Sep 17 00:00:00 2001 From: Wanderingss <1624155937@qq.com> Date: Fri, 23 Oct 2026 15:25:24 +0800 Subject: [PATCH] =?UTF-8?q?test(NETCFG,CFG):=20P1=E2=91=A0=E5=9B=9B?= =?UTF-8?q?=E4=B8=AA=E4=B8=BB=E6=9C=BA=E5=8D=95=E6=B5=8B=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E4=BF=AE=20net=5Fcfg=20=E7=9A=84=20WAIT=20?= =?UTF-8?q?=E5=A5=91=E7=BA=A6=E4=B8=8E=E5=AE=9A=E9=95=BF=E9=94=99=E8=AE=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 docs/测试说明/主机单测覆盖率口径.md §9 的清单吃 P1 第①批「配置与工具」四块, 新增 4 个主机单测模块共 104 条断言,全部 MSVC 编译通过并 PASS: - test/host/net_cfg/(45 条):出厂默认可用、注册幂等与内容、load 报 -1 仍返回 0 (RAM 恒有效契约)、跨任务保存交接状态机 IDLE/WAIT/OK/FAIL、MAC 三态优先级、 IPv4 定长格式化。 - test/host/ftp_cfg/(16 条):[ftp] 段注册(**p_cb 必须为 NULL**,口令下次登录才 生效)、默认口令判定大小写不敏感、NULL 一律判不等(fail-closed)。 - test/host/prot_set_store/(16 条):blob 返回码分流 1 无档 / 0 成功 / **2 换代 (只打 INFO)** / -1 损坏;写盘失败必须回置脏标志(否则定值静默丢失)。 - test/host/task_prof/(27 条):无效样本闸门(防 DWT 复位把 max 抬到 2^32)、 未 enter 就 exit 不记账、首次 5s + 每 2s 节流、环形缓冲与裸发两通道、 周期→微秒换算随 SystemCoreClock 变。 覆盖率收益(已 --update-baseline 登记):套件 16 → 20 全绿;files_tested 19 → 23; 未触及 125 → 121 个文件(.c 47 → 43、9,657 → 9,228 行);分母 2,893 → 3,163 插桩 行;line=2811/3163(88.9%)、func=225/240(93.8%)。§9 的恒等式同步更新且仍咬合: 43 = 9(P1 剩) + 5(P2) + 29(P3)、9,228 = 2,188 + 2,226 + 4,814。 新单测照出并修掉两处真问题(都在 net_cfg,不是测试写错): 1. 实现违反自己的文档契约:net_cfg.h 明写"属主任务写盘中显示 WAIT",但 net_cfg_save_run 为了不丢新请求会先清 pending 再写盘 → 写盘那几百毫秒里 UI 保存行退回上一轮的 OK/IDLE(第一次保存按 ENTER 后屏幕上还是 ENTER),用户只 会再按一次。补 s_save_busy 标志,TC-NETCFG-028/026/030 钉死(含失败路径必须 清 busy,否则 UI 永久卡 WAIT)。 2. "定长 11 字符"是错的:%03u.%03u.%03u.%03u = 4 段×3 位 + 3 个点 = **15**。这个数 错了 4 处(net_cfg.h、net_cfg.c、网络配置说明.md 两处)——是我照抄它才撞上 FAIL,错在文档不是代码。已全部更正,并确认 len ≥ 16 的要求与 UI_BUF_STRLEN=32 本来就没问题(无截断风险)。 桩的口径(沿用既有模块,不另起一套):真实头优先——cfg_file.h / cfg_file_store.h / console_log.h / prot_set.h 全用真头(接口一漂移就编译期报错),只有 main.h 必须桩 (真实 main.h 拉整棵 HAL 头树,PC 上编不动;桩里字段名与位掩码宏名与 CMSIS 一致)。 prot_set_store 连真实 prot_set.c 一起编:blob 的编解码与脏标志在它里面,只有拿真 serialize 产出的 blob 才能造出"换代"与"损坏"两种真实形态,手搓等于测自己的桩。 配置引擎本身(解析/写盘/沉降)归 test/host/cfg_file 的 103 条,本批不重复测。 编号门禁:按 2026-10-21 批 3 的 B 层口径补建 4 族 TC-HOST-*(NETCFG 游标 056 / FTPCFG 014 / PROTSTOR 016 / TASKPROF 020,代码即属主登记处)。这一步不是走形式: 建族之前文档里引用 TC-NETCFG-028 被判「台账之外新账」直接卡提交,建族后才通过; 编号规范 §8.4 变更记录加一行说明族数 9 → 13。id_registry.json 只做增量插入(+88 行, 无重排)。 自查(提交时 pre-commit run --all-files,**仅 3 条失败且都与本分支改动无关**): - mem-numbers / mem-budget:本 worktree 因需验证代码改动而 @build 过,新生成的 .map 与 docs/内存管理 受管快照漂移。实测增量 ER_IROM1 +12 B、RW_IRAM1 +8 B、Flash +16 B、**CCM 0**,水位百分比全不变(改动 = 1 个静态字节 + 几条指令)。按并行分支 约定**不在分支里 --write**,台账由 Lead 合并后统一回填。 - ui-golden:simulator/tests/ref/*.bin 写在 .gitignore 里(黄金参考是本地生成物), 新建的 worktree 天生没有,报的是"缺黄金参考"而非像素差异;本分支零 UI 文件改动。 - 其余全部 Passed,含 @build 0 错误 0 警告、test-cppcheck(它抓到过我一条 p!=NULL 后又解引用的写法,已改为断言+显式守卫)、coding-standard、doc-links(35 处=存量)、 id-check --strict、mojibake 与 BOM/CR/U+FFFD/行尾逐项卫生。 - 自审修掉自己写的两条恒真断言(形如 x==0?1:1 的假绿)与一个重复编号标签。 - 4 个新模块**尚未挂 pre-commit 钩子**:.pre-commit-config.yaml 是单写文件,任务工作 树内不改;覆盖率脚本按 test/host/**/run_unit_test.py 自动发现,已实跑 20/20。 - 未跑 @test:改动仅 1 个静态字节 + UI 保存行的显示时序,无硬件语义变化。 --- Drivers/BSP/NETCFG/net_cfg.c | 18 +- Drivers/BSP/NETCFG/net_cfg.h | 2 +- docs/子系统设计/网络配置说明.md | 4 +- docs/测试说明/主机单测覆盖率口径.md | 15 + docs/编号规范.md | 3 +- scripts/id_registry.json | 88 +++ scripts/test_coverage_baseline.json | 107 +++- test/host/ftp_cfg/ftp_cfg_test.c | 244 ++++++++ test/host/ftp_cfg/run_unit_test.py | 52 ++ test/host/net_cfg/net_cfg_test.c | 549 ++++++++++++++++++ test/host/net_cfg/run_unit_test.py | 60 ++ test/host/net_cfg/stub/dbg_log.h | 27 + .../host/prot_set_store/prot_set_store_test.c | 355 +++++++++++ test/host/prot_set_store/run_unit_test.py | 52 ++ test/host/prot_set_store/stub/dbg_log.h | 20 + test/host/task_prof/run_unit_test.py | 54 ++ test/host/task_prof/stub/main.h | 44 ++ test/host/task_prof/task_prof_test.c | 376 ++++++++++++ 18 files changed, 2033 insertions(+), 37 deletions(-) create mode 100644 test/host/ftp_cfg/ftp_cfg_test.c create mode 100644 test/host/ftp_cfg/run_unit_test.py create mode 100644 test/host/net_cfg/net_cfg_test.c create mode 100644 test/host/net_cfg/run_unit_test.py create mode 100644 test/host/net_cfg/stub/dbg_log.h create mode 100644 test/host/prot_set_store/prot_set_store_test.c create mode 100644 test/host/prot_set_store/run_unit_test.py create mode 100644 test/host/prot_set_store/stub/dbg_log.h create mode 100644 test/host/task_prof/run_unit_test.py create mode 100644 test/host/task_prof/stub/main.h create mode 100644 test/host/task_prof/task_prof_test.c diff --git a/Drivers/BSP/NETCFG/net_cfg.c b/Drivers/BSP/NETCFG/net_cfg.c index b65197c7..82c4b3c4 100644 --- a/Drivers/BSP/NETCFG/net_cfg.c +++ b/Drivers/BSP/NETCFG/net_cfg.c @@ -60,6 +60,7 @@ static uint8_t s_active_mac[6]; /* 芯片实际 MAC 影子(netTask 回写 static uint8_t s_active_valid; /* 影子有效标志(0 = 尚未回写) */ static net_cfg_apply_cb_t s_apply_cb; /* "立即应用"平台回调(未注册 = 空操作) */ static volatile uint8_t s_save_pending; /* 1 = 有待执行保存请求(UI 投递/属主执行) */ +static volatile uint8_t s_save_busy; /* 1 = 属主任务正在写盘(维持 UI 的 WAIT 显示) */ static volatile uint8_t s_save_result; /* 最近一次保存结果:0 = 无;1 = 成功;2 = 失败 */ static net_cfg_save_req_cb_t s_save_req_cb; /* "保存请求"平台通知(未注册 = 空操作) */ @@ -234,7 +235,10 @@ int net_cfg_save_request(void) { * 函数说明:见 net_cfg.h 同名函数说明 */ int net_cfg_save_state(void) { - if (s_save_pending != 0u) { + /* "请求已投递未执行"与"正在写盘"都显示 WAIT:pending 在写盘前就被清掉 + * (为了不让写盘期间的新请求丢失),单看它会在这几百毫秒里退回上一轮的 + * OK/IDLE —— 用户按了 ENTER 却看不到反馈,只会再按一次。busy 补上这一段。 */ + if ((s_save_pending != 0u) || (s_save_busy != 0u)) { return (int)NET_CFG_SAVE_ST_WAIT; } if (s_save_result == 1u) { @@ -252,8 +256,10 @@ int net_cfg_save_state(void) { * 返回值:0 - 无待处理请求或保存成功;-1 - 写盘失败 int * 限定条件:仅配置引擎属主任务上下文(MCU = defaultTask;模拟器 = 主循环) * 函数说明:先清"待保存"标志(写盘期间的新请求重位置位,下轮再执行, - * 不丢失);写盘成功 → 置 OK 并触发"立即应用"(netTask 重建 - * 网络,无需重启);失败 → 置 FAIL,不触发应用(网络保持) + * 不丢失),同时置"写盘中"标志让 UI 在这段窗口里仍显示 WAIT + * (否则退回上一轮结果,见 net_cfg_save_state);写盘成功 → 置 OK + * 并触发"立即应用"(netTask 重建网络,无需重启);失败 → 置 FAIL, + * 不触发应用(网络保持)。两条路径都会清"写盘中"标志 */ int net_cfg_save_run(void) { int r; @@ -262,8 +268,11 @@ int net_cfg_save_run(void) { return 0; /* 无待处理请求(空醒/周期轮询) */ } s_save_pending = 0u; + s_save_busy = 1u; /* 写盘期间维持 WAIT(见 net_cfg_save_state 注释) */ r = net_cfg_save(); + + s_save_busy = 0u; if (r == 0) { s_save_result = 1u; net_cfg_request_apply(); @@ -352,7 +361,8 @@ void net_cfg_get_mac(uint8_t *p_out) { * len - 缓冲长度 size_t * 返回值:无 * 限定条件:p_ip/p_buf 非空;len ≥ 16 - * 函数说明:"192.168.001.100"(定长 11 字符,UI 编辑光标按固定偏移定位字节) + * 函数说明:"192.168.001.100"(定长 **15** 字符 = 4 段 × 3 位 + 3 个点,UI 编辑 + * 光标按固定偏移定位字节:段 i 起点 4i、点号在 3/7/11) */ void net_cfg_fmt_ipv4(const uint8_t *p_ip, char *p_buf, size_t len) { if (p_ip == NULL || p_buf == NULL || len == 0u) { diff --git a/Drivers/BSP/NETCFG/net_cfg.h b/Drivers/BSP/NETCFG/net_cfg.h index 625225d7..bbe32a9b 100644 --- a/Drivers/BSP/NETCFG/net_cfg.h +++ b/Drivers/BSP/NETCFG/net_cfg.h @@ -204,7 +204,7 @@ void net_cfg_get_mac(uint8_t *p_out); * p_buf - 输出缓冲(不可为 NULL) char * * len - 缓冲长度 size_t * 返回值:无 - * 限定条件:p_ip/p_buf 非空;len ≥ 16(输出 11 字符 + '\0') + * 限定条件:p_ip/p_buf 非空;len ≥ 16(输出 15 字符 + '\0') * 函数说明:"192.168.001.100"——每段 3 位补零使字符串定长,UI 编辑光标 * 可按固定偏移(4i/3i 起始)定位字节;net_init 的 parse_ip_string * 对前导零兼容(十进制逐段解析) diff --git a/docs/子系统设计/网络配置说明.md b/docs/子系统设计/网络配置说明.md index c25ce587..b1a643fe 100644 --- a/docs/子系统设计/网络配置说明.md +++ b/docs/子系统设计/网络配置说明.md @@ -228,7 +228,7 @@ sequenceDiagram | 行 | 名称 | 值格式 | |---|---|---| -| 0 | IP地址 | `192.168.001.100`(每段 `%03u` 补零,**定长 11 字符**) | +| 0 | IP地址 | `192.168.001.100`(每段 `%03u` 补零,**定长 15 字符** = 4 段 × 3 位 + 3 个点) | | 1 | 子网掩码 | `255.255.255.000` | | 2 | 默认网关 | `192.168.001.001` | | 3 | MAC地址 | `A4:94:B6:12:34:56`(6 字节十六进制,冒号分隔) | @@ -277,7 +277,7 @@ ENTER 进入选中地址行 → `↑/↓` 调活动字节(0..255 **环绕**) | `net_cfg_set()` | 更新 RAM 副本(不写盘) | | `net_cfg_set_active_mac()` | 回写芯片实际 MAC 影子(netTask 调) | | `net_cfg_get_mac()` | 取显示用 MAC(存储非零 → 存储;否则影子;否则全零) | -| `net_cfg_fmt_ipv4()` | 4 字节 → `"192.168.001.100"` 定长字符串 | +| `net_cfg_fmt_ipv4()` | 4 字节 → `"192.168.001.100"` 定长 15 字符串(缓冲 len ≥ 16) | --- diff --git a/docs/测试说明/主机单测覆盖率口径.md b/docs/测试说明/主机单测覆盖率口径.md index 892d1f0e..db77d3e5 100644 --- a/docs/测试说明/主机单测覆盖率口径.md +++ b/docs/测试说明/主机单测覆盖率口径.md @@ -241,6 +241,18 @@ | 内核前装配 | `app_main.c`(135) | 被测对象本身就是"`osKernelStart` 之前不许调内核 API"的时序,主机侧无法忠实复现(PC 上没有那个约束) | 同上 + `cubemx-survival` 审计 | **四个数字对得上**:`47 = 13(P1) + 5(P2) + 29(P3)`、`9,657 = 2,617 + 2,226 + 4,814`。 + +**落地进度(同日,P1 的第 ① 批已吃掉)**:新增 4 个主机单测模块 —— `test/host/net_cfg/`(45 断言)、 +`test/host/ftp_cfg/`(16)、`test/host/prot_set_store/`(16)、`test/host/task_prof/`(27), +`files_tested` 19 → **23**、`files_untested` 125 → **121**(未触及 `.c` 47 → **43**、行数 9,657 → **9,228**)、 +分母 2,893 → **3,163** 插桩行(新纳入 `net_cfg.c` 108/112、`task_prof.c` 88/93、`ftp_cfg.c` 35/36、 +`prot_set_store.c` 27/29),套件 16 → **20** 全绿。恒等式跟着更新且仍然咬合: +`43 = 9(P1 剩) + 5(P2) + 29(P3)`、`9,228 = 2,188 + 2,226 + 4,814`——**剩下 43 个里 29 个是 P3 刻意不做**。 +顺带由新单测照出并修掉两处(都在 `net_cfg`,`@build` 0/0 实测 ER_IROM1 +12 B / RW_IRAM1 +8 B / CCM 0): +① `net_cfg_save_run` 先清 `pending` 再写盘,导致写盘窗口里 UI 保存行退回上一轮的 OK/IDLE, + 违反 `net_cfg.h` 自己写的"写盘中显示 WAIT"→ 补 `s_save_busy` 标志(TC-NETCFG-028/026/030 钉死); +② "定长 11 字符"是错的(`%03u`×4 + 3 个点 = **15**),`net_cfg.h`/`net_cfg.c`/本文所引设计文档 + 共四处已更正(缓冲要求 `len >= 16` 本来是对的,UI 侧 `UI_BUF_STRLEN`=32 未受影响)。 也就是说:**"覆盖率不该 100%"这件事在这份清单里是算出来的,不是嘴上说的**——P3 那 4,814 行永远不进分母, 而 P1+P2 做完约 `4.8k` 行进分母(现在的分母只有 2,893),`files_tested` 从 19 → 37。 @@ -259,6 +271,9 @@ |---|---| | 2026-10-23 | 新建。落地 `scripts/check_test_coverage.py` 与 `scripts/test_coverage_baseline.json`:首次实测 16 套全通过,并集 2552/2893 行(88.2%)、函数 193/207(93.2%),在测 19 文件 / 未触及 125 文件。同步销项 `docs/问题记录/项目审查与优化建议报告.md` §4.8 空白 ①(覆盖率口径)与 §5.5 第 5 条前半、§0.1 / §0.4 / §7 对应行;`malloc(` 正则那条**同日也落地**(`coding-standard` CODE-RULE-20,见 `docs/门禁说明/coding-standard.md`)。**同日补 §2.1**:用户问「为什么不用 GCC」,实测本机 WSL2 Ubuntu 有 gcc/gcov 15.2.0,16 套单测在 gcc 下**全部编过且全绿**、`gcov -b -c` 确实给出分支覆盖(`nand_ftl.c` 分支 41.40%)——但换编译器=换分母(`sha256.c` gcc 105 行 vs MSVC 114 行),故默认通道不变。 | | 2026-10-23 | **用户裁定:本仓只留 MSVC 一条覆盖率通道**,不建 gcc/WSL 第二条("后面可能引入更多问题")。实测数据与三条代价保留在 §2.1 当决策依据,§7 第 1 条把分支覆盖改记为**已接受缺口**,§8 下一步删掉"建通道"并写明重开该裁定的前提(必须先答"两份分母怎么对齐")。脚本与基线**不受影响**,仍是 `mscoverage` 一条路 | +| 2026-10-23 | **P1 第①批落地**(net_cfg / ftp_cfg / prot_set_store / task_prof 四个模块,104 条断言): + 套件 16→20、`files_tested` 19→23、分母 2,893→3,163;照出并修掉 `net_cfg` 的 WAIT 契约违背与 + "定长 11 字符"错记(详见 §9 落地进度) | | 2026-10-23 | 新增 **§9 建桩优先级清单**:对 47 个未触及 `.c`(9,657 行)逐个画像(有效代码行 / 函数数 / 逻辑度=1−硬件符号密度 / 要桩的外部头 / 可复用现有桩 / fix 提交数),分为 **P1 13 个 2,617 行、 P2 5 个 2,226 行、P3 29 个 4,814 行明确不做**(四条对得上:13+5+29=47、三段相加=9,657); diff --git a/docs/编号规范.md b/docs/编号规范.md index d4608012..deb47b12 100644 --- a/docs/编号规范.md +++ b/docs/编号规范.md @@ -417,7 +417,7 @@ git grep -n '<旧字面量>' # 零残留 | 改动 | 动机 | 对比例的影响 | |---|---|---| -| 主机侧单测建 **9 族 `TC-HOST-<模块码>`**,属主登记处 = `test/host/<目录>/*`(代码即登记处) | 实测这些号在代码目录外**零消费者**(唯一"外部引用"是 `id_aliases.json` 自己),逐号补文档只会造出没人读的复述层 | 悬空错误 −508 中的 430(**口径修正,不是仓库变好**) | +| 主机侧单测建 **9 族 `TC-HOST-<模块码>`**,属主登记处 = `test/host/<目录>/*`(代码即登记处;**2026-10-23 起同一口径新增 4 族,现 13 族**,见 §8.4 变更记录) | 实测这些号在代码目录外**零消费者**(唯一"外部引用"是 `id_aliases.json` 自己),逐号补文档只会造出没人读的复述层 | 悬空错误 −508 中的 430(**口径修正,不是仓库变好**) | | `subitem_ok`:小写子项后缀(`TC-TIMESYNC-003b`)母号在册即算已定义 | §4.1 明允许小写后缀,是合法形态;双向追溯仍按完整字面量报差异,可见性不丢 | 悬空 −74(同上,属口径) | | **`snapshot_only`**:§4.3 冻结历史快照族(`N-RPT`/`D-RPT`/`S-RPT`/`R-RPT`/`F-RPT`/`P-AUDIT`/`A-AUDIT`/`B-AUDIT`)**不参与"定义位优先"** | 曾试过让定义位优先无差别生效:`N-21`(登记簿条目已销项)被审计报告里同号的另一码事"接管",门禁转而去报 ⑥"请写报告文档名"——**照做就制造 §1.2 的 `N-31` 撞号**;实测该做法使校验 6 从 266 暴涨到 1437、比例假崩到 68.7% | 校验 6 回到 269(与 B 层前持平) | | **补属主**:`docs/任务规划/README.md` 加入 `N`/`S-LDG`/`T-LDG` 族的 `owner_docs` | 这是**我的配置漏项**:活登记处在 README(`N-21` 出现 2 次),我原先只登记了 `登记簿.md`(`N-21` = 0)→ 真条目被误判悬空 74 处 | 悬空 −74(**修 bug,不是口径**) | @@ -556,6 +556,7 @@ git grep -n '<旧字面量>' # 零残留 | 日期 | 变更 | 说明 | |---|---|---| +| 2026-10-23 | **主机侧单测族 +4(现共 13 族)** | 覆盖率口径 `docs/测试说明/主机单测覆盖率口径.md` §9 P1 第①批新建 `test/host/{net_cfg,ftp_cfg,prot_set_store,task_prof}` 四个模块,按 2026-10-21 批 3 的同一 B 层口径补建 `TC-HOST-{NETCFG,FTPCFG,PROTSTOR,TASKPROF}`(代码即属主登记处、`allow_orphans`、游标按实测最大号 056/014/016/020)。**验证了这条口径真的在拦东西**:建族前,文档里引用 `TC-NETCFG-028` 被判「台账之外的新账」而卡提交;建族后 `--strict` 通过 | | 2026-10-14 | 新增 P-7 与引用可读性规则 | §2 加 **P-7「编号不语义,引用必语义」**(编号本体保持无语义并冻结,但正文引用必须带语义括注、禁裸 ID 独担信息)→ 六条原则变**七条**;§5.1 加前置步"先问能不能不加号";新增 **§5.6「编号密集文档的对照节(强制)」**(≥3 个编号族必须加「导读:编号与术语」);§4.1 补登记 **`E\d+`**(N34 自愈实验编号族,已封闭)并把 `N-\d{2}` 的在册范围订正为「曾用最大 `N-33`,现存至 `N-29`」;§1.2 悬空引用示例补 `N-32`;§7.1 同步订正。起因:`docs/网络/网络自愈架构设计.md` 合并与导读节实践暴露"裸编号引用不可读",用户裁定以后尽量用语义标识 | | (规划稿) | 建立本文档 | 全库扫描 1,385 个不同编号 / 19,306 处出现后定稿的两层编号体系(全局编号 + 文档内编号)、六条原则、目标前缀总表、三批迁移映射与 `check_ids.py` 门禁设计;用户决策:`E-xx` 冻结保留、严重度档位词 `P0~P3` 不变、文档落 `docs/编号规范.md`、阶段 1 三批全做 | | 2026-10-21 | **批 5:追溯器口径补齐(未改任何号)** | `check_test_traceability.py` 加入与 `check_ids.py`/`rename_ids.py`/`doc-links` 同源的「变更记录 / `[示例]` / `[计划]` 行不参与比对」豁免,并新增 `frozen_ids` 留痕(`--verbose` 可列);`DOC-ONLY` 38 → 29、总差异 45 → 36,**交集 260 → 260 不变**(证明没吞掉真对应)。批 5 原计划的 8 处文档侧改名**被证据否掉**(全在变更记录行、别名批 2 已登记),未落盘;经过与教训、以及剩 29 处的三类真问题都写进 §8.3 批 5 段 | diff --git a/scripts/id_registry.json b/scripts/id_registry.json index 63cab4db..40af32f1 100644 --- a/scripts/id_registry.json +++ b/scripts/id_registry.json @@ -1592,6 +1592,94 @@ "subitem_ok": true, "owner_note": "B 层口径同其余 TC-HOST 族;本族是 2026-10-21 补漏——上批模块码改名前它是纯数字 `TC-104-*`,未进当时的悬空普查,故漏建族", "cursor_note": "游标按实测最大号收录(2026-10-21)" + }, + { + "name": "TC-HOST-NETCFG", + "class": "TC", + "title": "主机侧单测用例号:网络配置编排与跨任务保存交接(test/host/net_cfg)", + "pattern": "\\bTC-NETCFG-[0-9]{1,4}[a-z]?\\b", + "digit_width": [ + 2, + 4 + ], + "owner_docs_glob": [ + "test/host/net_cfg/*" + ], + "scope": "global", + "frozen": false, + "cursor": "TC-NETCFG-056", + "cursor_owner_only": true, + "check_dangling": true, + "allow_orphans": true, + "subitem_ok": true, + "owner_note": "B 层口径(2026-10-21 批 3 定:这些号在代码目录外零消费者,属主登记处 = 单测代码本身,不逐号补文档);本族 2026-10-23 随覆盖率口径 §9 P1 第①批新建模块补登记", + "cursor_note": "游标按实测最大号收录(2026-10-23):越界即视为未登记的冒号牌" + }, + { + "name": "TC-HOST-FTPCFG", + "class": "TC", + "title": "主机侧单测用例号:FTP 鉴权配置 [ftp] 段(test/host/ftp_cfg)", + "pattern": "\\bTC-FTPCFG-[0-9]{1,4}[a-z]?\\b", + "digit_width": [ + 2, + 4 + ], + "owner_docs_glob": [ + "test/host/ftp_cfg/*" + ], + "scope": "global", + "frozen": false, + "cursor": "TC-FTPCFG-014", + "cursor_owner_only": true, + "check_dangling": true, + "allow_orphans": true, + "subitem_ok": true, + "owner_note": "B 层口径(2026-10-21 批 3 定:这些号在代码目录外零消费者,属主登记处 = 单测代码本身,不逐号补文档);本族 2026-10-23 随覆盖率口径 §9 P1 第①批新建模块补登记", + "cursor_note": "游标按实测最大号收录(2026-10-23):越界即视为未登记的冒号牌" + }, + { + "name": "TC-HOST-PROTSTOR", + "class": "TC", + "title": "主机侧单测用例号:保护定值持久化胶水层(test/host/prot_set_store)", + "pattern": "\\bTC-PROTSTOR-[0-9]{1,4}[a-z]?\\b", + "digit_width": [ + 2, + 4 + ], + "owner_docs_glob": [ + "test/host/prot_set_store/*" + ], + "scope": "global", + "frozen": false, + "cursor": "TC-PROTSTOR-016", + "cursor_owner_only": true, + "check_dangling": true, + "allow_orphans": true, + "subitem_ok": true, + "owner_note": "B 层口径(2026-10-21 批 3 定:这些号在代码目录外零消费者,属主登记处 = 单测代码本身,不逐号补文档);本族 2026-10-23 随覆盖率口径 §9 P1 第①批新建模块补登记", + "cursor_note": "游标按实测最大号收录(2026-10-23):越界即视为未登记的冒号牌" + }, + { + "name": "TC-HOST-TASKPROF", + "class": "TC", + "title": "主机侧单测用例号:任务耗时探针与上报节流(test/host/task_prof)", + "pattern": "\\bTC-TASKPROF-[0-9]{1,4}[a-z]?\\b", + "digit_width": [ + 2, + 4 + ], + "owner_docs_glob": [ + "test/host/task_prof/*" + ], + "scope": "global", + "frozen": false, + "cursor": "TC-TASKPROF-020", + "cursor_owner_only": true, + "check_dangling": true, + "allow_orphans": true, + "subitem_ok": true, + "owner_note": "B 层口径(2026-10-21 批 3 定:这些号在代码目录外零消费者,属主登记处 = 单测代码本身,不逐号补文档);本族 2026-10-23 随覆盖率口径 §9 P1 第①批新建模块补登记", + "cursor_note": "游标按实测最大号收录(2026-10-23):越界即视为未登记的冒号牌" } ], "unmanaged_bare": { diff --git a/scripts/test_coverage_baseline.json b/scripts/test_coverage_baseline.json index d77a2f46..d70a69cb 100644 --- a/scripts/test_coverage_baseline.json +++ b/scripts/test_coverage_baseline.json @@ -44,8 +44,8 @@ "func_rate": 0.9565, "funcs_covered": 22, "funcs_valid": 23, - "line_rate": 0.9609, - "lines_covered": 172, + "line_rate": 0.9665, + "lines_covered": 173, "lines_valid": 179 }, "App/algo/sample_ring.c": { @@ -64,6 +64,14 @@ "lines_covered": 572, "lines_valid": 652 }, + "App/cfg/prot_set_store.c": { + "func_rate": 1.0, + "funcs_covered": 4, + "funcs_valid": 4, + "line_rate": 0.931, + "lines_covered": 27, + "lines_valid": 29 + }, "App/fs/vol_lock.c": { "func_rate": 1.0, "funcs_covered": 3, @@ -128,6 +136,14 @@ "lines_covered": 156, "lines_valid": 157 }, + "App/util/task_prof.c": { + "func_rate": 1.0, + "funcs_covered": 9, + "funcs_valid": 9, + "line_rate": 0.9462, + "lines_covered": 88, + "lines_valid": 93 + }, "App/util/task_watchdog.c": { "func_rate": 1.0, "funcs_covered": 8, @@ -151,6 +167,22 @@ "line_rate": 0.9737, "lines_covered": 37, "lines_valid": 38 + }, + "Drivers/BSP/NETCFG/ftp_cfg.c": { + "func_rate": 1.0, + "funcs_covered": 4, + "funcs_valid": 4, + "line_rate": 0.9722, + "lines_covered": 35, + "lines_valid": 36 + }, + "Drivers/BSP/NETCFG/net_cfg.c": { + "func_rate": 0.9375, + "funcs_covered": 15, + "funcs_valid": 16, + "line_rate": 0.9643, + "lines_covered": 108, + "lines_valid": 112 } }, "generated_by": "scripts/check_test_coverage.py --update-baseline", @@ -193,6 +225,13 @@ "lines_total": 152, "ok": true }, + "ftp_cfg": { + "files": { + "Drivers/BSP/NETCFG/ftp_cfg.c": 36 + }, + "lines_total": 36, + "ok": true + }, "iec104": { "files": { "App/algo/freq.c": 130, @@ -214,6 +253,13 @@ "lines_total": 253, "ok": true }, + "net_cfg": { + "files": { + "Drivers/BSP/NETCFG/net_cfg.c": 112 + }, + "lines_total": 112, + "ok": true + }, "phasor": { "files": { "App/algo/phasor.c": 55, @@ -240,6 +286,14 @@ "lines_total": 179, "ok": true }, + "prot_set_store": { + "files": { + "App/algo/prot_set.c": 179, + "App/cfg/prot_set_store.c": 29 + }, + "lines_total": 208, + "ok": true + }, "sha256": { "files": { "App/util/sha256.c": 114 @@ -262,6 +316,13 @@ "lines_total": 157, "ok": true }, + "task_prof": { + "files": { + "App/util/task_prof.c": 93 + }, + "lines_total": 93, + "ok": true + }, "time_sync": { "files": { "App/sync/time_sync.c": 99 @@ -295,6 +356,7 @@ "App/algo/prot_set.c", "App/algo/sample_ring.c", "App/cfg/cfg_file.c", + "App/cfg/prot_set_store.c", "App/fs/vol_lock.c", "App/log/soe_rec.c", "App/protocol/iec104/iec104_asdu.c", @@ -303,22 +365,25 @@ "App/sync/time_sync.c", "App/util/sha256.c", "App/util/sys_clock.c", + "App/util/task_prof.c", "App/util/task_watchdog.c", "Drivers/BSP/GD5F2GQ5UE/nand_ftl.c", - "Drivers/BSP/NET/lftpd/lftpd_auth.c" + "Drivers/BSP/NET/lftpd/lftpd_auth.c", + "Drivers/BSP/NETCFG/ftp_cfg.c", + "Drivers/BSP/NETCFG/net_cfg.c" ], "totals": { "files_generated_excluded": 5, - "files_tested": 19, - "files_untested": 125, - "func_rate": 0.9324, - "funcs_covered": 193, - "funcs_valid": 207, - "line_rate": 0.8821, - "lines_covered": 2552, - "lines_valid": 2893, - "modules_ok": 16, - "modules_total": 16 + "files_tested": 23, + "files_untested": 121, + "func_rate": 0.9375, + "funcs_covered": 225, + "funcs_valid": 240, + "line_rate": 0.8887, + "lines_covered": 2811, + "lines_valid": 3163, + "modules_ok": 20, + "modules_total": 20 }, "untested_sources": [ { @@ -405,18 +470,10 @@ "code_lines": 171, "path": "Drivers/BSP/NET/lftpd/lftpd_inet.c" }, - { - "code_lines": 167, - "path": "Drivers/BSP/NETCFG/net_cfg.c" - }, { "code_lines": 162, "path": "Drivers/BSP/NET/net_select.c" }, - { - "code_lines": 160, - "path": "App/util/task_prof.c" - }, { "code_lines": 157, "path": "Drivers/BSP/TPAFE5160/tpafe5160.c" @@ -489,10 +546,6 @@ "code_lines": 62, "path": "App/protocol/iec104/iec104_asdu.h" }, - { - "code_lines": 59, - "path": "Drivers/BSP/NETCFG/ftp_cfg.c" - }, { "code_lines": 56, "path": "App/protocol/iec104/iec104_link.h" @@ -517,10 +570,6 @@ "code_lines": 44, "path": "App/log/soe_rec.h" }, - { - "code_lines": 43, - "path": "App/cfg/prot_set_store.c" - }, { "code_lines": 42, "path": "Drivers/BSP/UI/ui_content_page.h" diff --git a/test/host/ftp_cfg/ftp_cfg_test.c b/test/host/ftp_cfg/ftp_cfg_test.c new file mode 100644 index 00000000..57d369ea --- /dev/null +++ b/test/host/ftp_cfg/ftp_cfg_test.c @@ -0,0 +1,244 @@ +/* + * 模块名称:FTP 鉴权配置([ftp] 段)主机侧单元测试 + * 模块功能:在 PC 上离线验证 Drivers/BSP/NETCFG/ftp_cfg.c 的注册契约与 + * "出厂默认口令是否在用"的判定(大小写不敏感)。 + * + * 被测对象:真实源文件 ../../../Drivers/BSP/NETCFG/ftp_cfg.c(#include 原样纳入)。 + * 桩只有一个:真实头 App/cfg/cfg_file.h + 本文件给出的 cfg_file_register + * 可控实现(按值留档注册描述符)。本模块**零 stub 目录**——ftp_cfg.c 不 + * 包含 dbg_log.h,也没有别的平台依赖。 + * + * 为什么要主机侧单测(而不是只靠台架): + * 1. 这个模块是 **FTP 的门禁**(N-19):pwd_hash 存的是 SHA-256(user‖password), + * 判定写成大小写敏感就会在"用户手输大写 hex"时把正确口令当错误;写成敏感于 + * 默认值大小写则会误报"已改口令",让出厂默认口令的启动告警**静默失效**—— + * 现场表现是"以为改了口令"。 + * 2. [ftp] 段刻意**不挂变更回调**(口令在下次登录 PASS 时才生效):哪天有人"顺手 + * 补一个回调",poll 重载会去重建网络——这条契约只能靠断言钉住(TC-FTPCFG-004)。 + * 3. write_en 出厂 = 1 是刻意的(FTP 推 config.ini 是既有维护工作流),改成 0 + * 会让现场升级流程直接失败;同样需要一个显式断言(TC-FTPCFG-005)。 + * ⚠ DEF_HASH 这个常量本身的正确性由 test/host/sha256/ 的 TC-SHA256-008 钉 + * (它算 SHA256("adminadmin") 并与 FTP_CFG_DEF_HASH 比对),本模块不重复算。 + * + * 覆盖点: + * TC-FTPCFG-001a get 返回恒非空(静态常驻,load 前后都可用) + * TC-FTPCFG-001 未注册即可 get:user=admin(出厂默认口令在用) + * TC-FTPCFG-002 pwd_hash 初值 = FTP_CFG_DEF_HASH,write_en 初值 = 1 + * TC-FTPCFG-003 注册幂等:重复 register 只向引擎登记一次 + * TC-FTPCFG-004 注册内容:段名 ftp / 三键 / 尺寸 / 默认值指针 / **p_cb 必须为 NULL** + * TC-FTPCFG-005 键表逐项:user(STR,16) / pwd_hash(STR,65) / write(UINT,1) + * TC-FTPCFG-006 出厂态 is_default_pwd = 1 + * TC-FTPCFG-007 改了口令 → is_default_pwd = 0 + * TC-FTPCFG-008 哈希存成**大写** hex 仍判"默认口令在用"(大小写不敏感) + * TC-FTPCFG-009 空哈希不是默认口令(且不读越界) + * TC-FTPCFG-010 混入非字母字符的串按逐字符比较(数字/下划线不被折叠改写) + * TC-FTPCFG-011 前缀关系不算相等("abc" vs "abcd") + * TC-FTPCFG-012 任一参数为 NULL 判不等(is_default_pwd 走不到,故直测静态函数) + * TC-FTPCFG-013 两串都为 NULL 也判不等(fail-closed,不许"都为空=相等") + * TC-FTPCFG-014 缓冲尺寸契约:user 16 / hash 65(64 hex + NUL) + * + * 用例顺序有依赖:001/002/006 必须在任何"改写 RAM 副本"的用例之前跑。 + * + * 通过:全部 TC PASS(main 返回 0)。 + * 入口:int main(void),返回 0=PASS / 1=FAIL(由 run 脚本判读)。 + * + * 创建日期:2026-10-23 + */ + +#include +#include + +#include "cfg_file.h" +#include "ftp_cfg.h" + +#include "ftp_cfg.c" + +/* ===================== 断言框架 ===================== */ + +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) + +/* ===================== 假引擎:只留注册记录 ===================== */ + +typedef struct { + char name[CFG_FILE_MAX_NAME_LEN + 1u]; + const void *p_data; + const void *p_defaults; + size_t data_size; + uint8_t key_count; + void (*p_cb)(void); + const cfg_key_def_t *p_keys; +} fake_reg_t; + +static fake_reg_t g_reg; +static int g_reg_n; + +static void fake_copy_name(char *dst, const char *src, size_t cap) +{ + size_t i; + + for (i = 0u; (i + 1u < cap) && (src[i] != '\0'); i++) { + dst[i] = src[i]; + } + dst[i] = '\0'; +} + +int cfg_file_register(const cfg_section_reg_t *p_reg) +{ + if ((p_reg == NULL) || (p_reg->name == NULL)) { + return -1; + } + fake_copy_name(g_reg.name, p_reg->name, sizeof(g_reg.name)); + g_reg.p_data = p_reg->p_data; + g_reg.p_defaults = p_reg->p_defaults; + g_reg.data_size = p_reg->data_size; + g_reg.key_count = p_reg->key_count; + g_reg.p_cb = p_reg->p_cb; + g_reg.p_keys = p_reg->p_keys; + g_reg_n++; + return 0; +} + +static void fake_reset(void) +{ + (void)memset(&g_reg, 0, sizeof(g_reg)); + g_reg_n = 0; +} + +/* 把 s_cfg.pwd_hash 写成任意串(白盒:引擎就是这么原地更新 RAM 副本的) */ +static void set_hash(const char *p_src) +{ + size_t i; + + for (i = 0u; (i + 1u < FTP_CFG_HASH_MAX) && (p_src[i] != '\0'); i++) { + s_cfg.pwd_hash[i] = p_src[i]; + } + s_cfg.pwd_hash[i] = '\0'; +} + +/* 把出厂哈希原样写成大写形态(引擎写入的 hex 大小写不可控,判定必须容忍) */ +static void set_hash_upper(void) +{ + size_t i; + + for (i = 0u; i + 1u < FTP_CFG_HASH_MAX; i++) { + char ch = FTP_CFG_DEF_HASH[i]; + + if ((ch >= 'a') && (ch <= 'z')) { + ch = (char)(ch - 32); + } + s_cfg.pwd_hash[i] = ch; + if (ch == '\0') { + break; + } + } +} + +/* ===================== 注册契约 ===================== */ + +static void tc_register(void) +{ + printf("-- TC-FTPCFG-003..005 register --\n"); + fake_reset(); + ftp_cfg_register(); + CHECK(g_reg_n == 1, "TC-FTPCFG-003a 首次注册一次"); + ftp_cfg_register(); + CHECK(g_reg_n == 1, "TC-FTPCFG-003 重复 register 不重复登记(幂等)"); + + CHECK((strcmp(g_reg.name, "ftp") == 0) && + (g_reg.data_size == sizeof(ftp_cfg_t)) && + (g_reg.key_count == 3u) && + (g_reg.p_defaults != NULL) && + (g_reg.p_data == (const void *)&s_cfg) && + (g_reg.p_cb == NULL), + "TC-FTPCFG-004 段名/尺寸/键数/默认值/**p_cb 必须为 NULL**"); + + CHECK((strcmp(g_reg.p_keys[0].name, "user") == 0) && + (g_reg.p_keys[0].type == CFG_VAL_STR) && + (g_reg.p_keys[0].len == FTP_CFG_USER_MAX) && + (g_reg.p_keys[0].offset == offsetof(ftp_cfg_t, user)) && + (strcmp(g_reg.p_keys[1].name, "pwd_hash") == 0) && + (g_reg.p_keys[1].type == CFG_VAL_STR) && + (g_reg.p_keys[1].len == FTP_CFG_HASH_MAX) && + (strcmp(g_reg.p_keys[2].name, "write") == 0) && + (g_reg.p_keys[2].type == CFG_VAL_UINT) && + (g_reg.p_keys[2].len == 1u), + "TC-FTPCFG-005 键表逐项:user(STR,16)/pwd_hash(STR,65)/write(UINT,1)"); +} + +/* ===================== 出厂默认与只读访问 ===================== */ + +static void tc_defaults(void) +{ + const ftp_cfg_t *p = ftp_cfg_get(); + + printf("-- TC-FTPCFG-001..002,014 defaults --\n"); + /* 契约就是"恒非空"(静态常驻),单独一条断言;随后的显式守卫既让 + * test-cppcheck 不再报"冗余或空指针二选一",也让真出问题时报在 001a 上 */ + CHECK(p != NULL, "TC-FTPCFG-001a ftp_cfg_get 恒非空(静态常驻)"); + if (p == NULL) { + return; + } + CHECK(strcmp(p->user, "admin") == 0, "TC-FTPCFG-001 未注册也可 get,user = admin"); + CHECK((strcmp(p->pwd_hash, FTP_CFG_DEF_HASH) == 0) && (p->write_en == 1u), + "TC-FTPCFG-002 初值 = 出厂哈希 + write_en=1(维护工作流依赖)"); + CHECK((FTP_CFG_USER_MAX == 16u) && (FTP_CFG_HASH_MAX == 65u) && + (sizeof(p->user) == FTP_CFG_USER_MAX) && + (sizeof(p->pwd_hash) == FTP_CFG_HASH_MAX), + "TC-FTPCFG-014 缓冲尺寸契约:user 16 / hash 65(64 hex + NUL)"); +} + +/* ===================== 默认口令判定(启动告警的依据) ===================== */ + +static void tc_default_pwd(void) +{ + printf("-- TC-FTPCFG-006..009 default-password detection --\n"); + CHECK(ftp_cfg_is_default_pwd() == 1, "TC-FTPCFG-006 出厂态判为默认口令"); + + set_hash("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"); + CHECK(ftp_cfg_is_default_pwd() == 0, "TC-FTPCFG-007 改了口令 → 不再默认"); + + set_hash_upper(); + CHECK(ftp_cfg_is_default_pwd() == 1, + "TC-FTPCFG-008 大写 hex 仍判默认(比较必须大小写不敏感)"); + + set_hash(""); + CHECK(ftp_cfg_is_default_pwd() == 0, "TC-FTPCFG-009 空哈希不是默认口令"); +} + +/* ===================== 静态比较函数(NULL / 折叠 / 前缀) ===================== */ + +static void tc_ci_eq(void) +{ + printf("-- TC-FTPCFG-010..013 case-insensitive compare --\n"); + CHECK(s_str_ci_eq("A_b1!Z", "a_b1!z") == 1, + "TC-FTPCFG-010 只折叠 A-Z,数字/下划线/符号原样比较"); + CHECK(s_str_ci_eq("abc", "abcd") == 0, "TC-FTPCFG-011 前缀关系不算相等"); + CHECK((s_str_ci_eq(NULL, "x") == 0) && (s_str_ci_eq("x", NULL) == 0), + "TC-FTPCFG-012 任一为 NULL 判不等"); + CHECK(s_str_ci_eq(NULL, NULL) == 0, + "TC-FTPCFG-013 两个 NULL 也判不等(fail-closed)"); +} + +/* ===================== 入口 ===================== */ + +int main(void) +{ + printf("=== FTP Auth Config Tests ===\n"); + + tc_defaults(); /* 必须最先:之后会改写 RAM 副本 */ + tc_register(); + tc_default_pwd(); + tc_ci_eq(); + + printf("\n=== FTP Auth Config Tests: %d/%d PASSED (failed=%d) ===\n", + s_passed, s_total, s_failed); + return (s_failed == 0) ? 0 : 1; +} diff --git a/test/host/ftp_cfg/run_unit_test.py b/test/host/ftp_cfg/run_unit_test.py new file mode 100644 index 00000000..9a70a642 --- /dev/null +++ b/test/host/ftp_cfg/run_unit_test.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""FTP 鉴权配置(ftp_cfg,[ftp] 段)的主机侧单元测试运行器(L0.5,纯标准库)。 + +做什么: + 用本机 MSVC 编译 test/host/ftp_cfg/ftp_cfg_test.c(以 #include 纳入**生产源文件** + Drivers/BSP/NETCFG/ftp_cfg.c),在 PC 上离线跑注册契约(幂等、键表、**不挂回调**) + 与出厂默认口令判定(大小写不敏感、NULL fail-closed),失败即非 0 退出。 + 工具链探测与判定全部委托给 test/host/common/unit_test_harness.py。 + +为什么要主机侧单测(而不是只用台架): + 本模块是 FTP 的门禁(N-19)。判定写成大小写敏感 → 用户手输大写 hex 时正确口令被拒; + 反过来对出厂值敏感 → "已改口令"误报,出厂默认口令的启动告警**静默失效**(现场以为 + 改了口令)。[ftp] 段刻意不挂变更回调(口令下次登录才生效),有人"顺手补上"就会在 + poll 重载时去重建网络。write_en 出厂 = 1 也是刻意的(FTP 推 config.ini 是既有维护 + 工作流)。这些都是一行代码就能改错、而真机只看得到 230/530 的东西。 + 哈希常量本身的正确性归 test/host/sha256/(TC-SHA256-008),本模块不重复算。 + +用法(仓库根目录): + python test/host/ftp_cfg/run_unit_test.py + 或统一用 .venv: & '.venv\Scripts\python.exe' test/host/ftp_cfg/run_unit_test.py + +退出码:0=PASS / 1=测试失败 / 2=环境错误(找不到编译器 / 编译失败 / 无汇总行)。 + +设计依据:docs/子系统设计/网络配置说明.md §3.4、docs/测试说明/主机单测覆盖率口径.md §9 P1 +创建日期:2026-10-23 +""" +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 + +# 本模块零桩目录:ftp_cfg.c 不含 dbg_log.h,也没有别的平台依赖; +# cfg_file.h 用真实头(接口原汁),被调的 cfg_file_register 由测试单元给实现。 +BUILD = TestBuild( + name="FTP Auth Config Tests", + src=os.path.join(HERE, "ftp_cfg_test.c"), + include_dirs=[ + os.path.join(ROOT, "Drivers", "BSP", "NETCFG"), + os.path.join(ROOT, "App", "cfg"), + HERE, + ], + summary_marker="=== FTP Auth Config Tests:", +) + +if __name__ == "__main__": + sys.exit(run_host_unit_test(BUILD)) + diff --git a/test/host/net_cfg/net_cfg_test.c b/test/host/net_cfg/net_cfg_test.c new file mode 100644 index 00000000..a53f0557 --- /dev/null +++ b/test/host/net_cfg/net_cfg_test.c @@ -0,0 +1,549 @@ +/* + * 模块名称:网络配置(NETCFG)主机侧单元测试 + * 模块功能:在 PC 上离线验证 Drivers/BSP/NETCFG/net_cfg.c 的**编排与状态机**: + * 出厂默认有效性、注册幂等与注册内容、load 失败仍返回 0 的"RAM 恒有效" + * 契约、跨任务保存请求交接(IDLE/WAIT/OK/FAIL)、写盘期间新请求不丢、 + * 写盘失败不得触发网络重建、MAC 三态优先级、IPv4 定长格式化。 + * + * 被测对象:真实源文件 ../../../Drivers/BSP/NETCFG/net_cfg.c(#include 原样纳入, + * 保证测的是生产代码而非副本)。桩只有三处,都写在下面: + * - stub/dbg_log.h:日志宏 → 计数(本目录); + * - 配置文件引擎:用**真实头** App/cfg/cfg_file.h(接口契约原汁,引擎 + * 一改签名本测试立刻编译期报错),被调的三个函数 + * cfg_file_register / cfg_file_load / cfg_file_save 由本文件给出 + * **可控实现**(同 test/host/nand_port/ 的路子); + * - ftp_cfg_register:只计数不实现——[ftp] 段自身的解析/默认值/默认 + * 口令判定归 test/host/ftp_cfg/ 模块,两边不重复也不互相顶包。 + * (不能把 ftp_cfg.c 也 include 进来:两个 .c 都有 static s_cfg, + * 同一个 TU 里直接重定义。) + * ⚠ 引擎的解析/写盘/沉降契约已由 test/host/cfg_file/(103 条)覆盖, + * 本模块刻意不重测引擎,只测 net_cfg.c 这一层的编排与交接。 + * + * 为什么要主机侧单测(而不是只靠台架): + * 1. 保存链是**跨任务交接**(uiTask 投递 net_cfg_save_request → defaultTask 执行 + * net_cfg_save_run → 成功才 net_cfg_request_apply → netTask 按网络代次重建监听)。 + * "写盘期间 UI 又按了一次 ENTER"与"写盘失败却去重建网络"这两条,真机上要靠 + * 手速 + 故障注入才碰得到;错了就是"改了 IP 不生效"或"保存失败网络被无端重建" + * 这类现场极难定位的问题——主机侧用假引擎的写盘钩子直接构造时序。 + * 2. MAC 三态优先级(存储非零 > 芯片回写影子 > 全零)决定 UI 显示值和下发给 + * CH395F 的 MAC;写错会**静默用错 MAC**(同网段 MAC 冲突,症状是偶发丢包)。 + * 3. net_cfg_fmt_ipv4 的**定长 15 字符(每段 %03u 补零)**是 UI 编辑光标按固定偏移定位 + * 字节的前提;哪天被"优化"成不补零,光标就错位——纯字符串行为,一次钉死。 + * + * 覆盖点: + * —— 出厂默认(load 之前也必须可用,TC-NETCFG-001..004)—— + * TC-NETCFG-001 未 load 时 ip = 工厂默认 192.168.1.100 + * TC-NETCFG-002 未 load 时 mask = 255.255.255.0 + * TC-NETCFG-003 未 load 时 gw = 192.168.1.1 + * TC-NETCFG-004 未 load 时 mac = 全零(哨兵 = 不覆盖芯片内置 MAC) + * —— 注册与 load(TC-NETCFG-005..011)—— + * TC-NETCFG-005 首次 load 注册 [network] 段恰好一次(且 load 返回 0) + * TC-NETCFG-006 首次 load 一并注册 [ftp] 段(N-19:所有受管段先注册、后 load) + * TC-NETCFG-007 重复 load 不重复注册(幂等;否则引擎按"段名重复"拒登 → 静默失配) + * TC-NETCFG-008 注册描述符内容:段名/尺寸/键数/默认值指针/变更回调/数据指针 = RAM 副本 + * TC-NETCFG-009 load 成功:返回 0 且留一条 INFO、不误报 ERROR + * TC-NETCFG-010 **引擎报 -1 时 net_cfg_load 仍返回 0**(RAM 恒有效,不带崩网络启动) + * TC-NETCFG-011 load 失败必须留 ERROR(不许静默回落默认值) + * —— RAM 副本读写(TC-NETCFG-012..013)—— + * TC-NETCFG-012 net_cfg_set 写入被 net_cfg_get 读回 + * TC-NETCFG-013 net_cfg_set(NULL) 不改写(UI 侧误传不得毁掉配置) + * —— 直接保存(TC-NETCFG-014..015)—— + * TC-NETCFG-014 引擎写盘成功 → net_cfg_save 返回 0 + * TC-NETCFG-015 引擎写盘失败 → net_cfg_save 返回 -1 并留痕(错误码没被吞成 0) + * —— 跨任务保存交接状态机(TC-NETCFG-020..033,UI 保存行显示的就是它)—— + * TC-NETCFG-020 初始态 = IDLE + * TC-NETCFG-021 save_request 后置 WAIT + * TC-NETCFG-022 save_request 经已注册回调唤醒属主任务(计数 +1) + * TC-NETCFG-023 未注册回调时 save_request 不崩、请求照样挂起 + * TC-NETCFG-024a 有请求时 save_run 确实写盘一次(024 的前置) + * TC-NETCFG-024 无请求时空醒 → 返回 0 且**不写盘**(2s 周期轮询不刷 Flash) + * TC-NETCFG-025 save_run 成功后 pending 清零 + * TC-NETCFG-026 save_run 成功后状态 = OK + * TC-NETCFG-027 save_run 成功后触发一次"立即应用" + * TC-NETCFG-028 **写盘进行中**状态仍是 WAIT(写盘前先清 pending,靠 busy 撑住; + * 否则这段窗口会退回上一轮的 OK/IDLE,用户看不到反馈只会再按一次) + * TC-NETCFG-029a **写盘期间投递的新请求不丢**:本轮结束后仍为 WAIT + * TC-NETCFG-029b 下一轮真的再写一次(不是只把状态挂在那里) + * TC-NETCFG-030 写盘失败 → 状态 = FAIL + * TC-NETCFG-031 **写盘失败不得触发"立即应用"**(内存值还没落盘,不能当已生效) + * TC-NETCFG-032 未注册应用回调时 request_apply 是空操作(模拟器就是这条路) + * TC-NETCFG-033a 应用回调注册后可被触发 + * TC-NETCFG-033b 传 NULL 注销后不再被触发 + * —— MAC 三态优先级(TC-NETCFG-040..045)—— + * TC-NETCFG-040 存储 MAC 非零 → 用存储值(即使芯片已回写影子) + * TC-NETCFG-041 存储全零 + 芯片已回写 → 用影子值 + * TC-NETCFG-042 存储全零 + 未回写 → 全零 + * TC-NETCFG-043 set_active_mac(NULL) 不得把影子置为有效 + * TC-NETCFG-044 set_active_mac 正常回写后影子生效 + * TC-NETCFG-045 get_mac(NULL) 安全返回 + * —— IPv4 定长格式化(TC-NETCFG-050..056,UI 光标定位依赖)—— + * TC-NETCFG-050 常规地址渲染为定长 15 字符 "192.168.001.100" + * TC-NETCFG-051 全零地址补零为 "000.000.000.000"(仍是 15 字符,偏移不变) + * TC-NETCFG-052 255 边界 "255.255.255.255" + * TC-NETCFG-053 p_ip 为 NULL:不写缓冲 + * TC-NETCFG-054 p_buf 为 NULL:不崩 + * TC-NETCFG-055 len 为 0:不写缓冲 + * TC-NETCFG-056 len 不足:截断但**必须 NUL 结尾**且不越界(UI 缓冲防线) + * + * 用例顺序有依赖(模块静态状态无法整体复位):出厂默认组必须最先跑(任何 load 之前); + * IDLE 组在任何 save_request 之前;需要干净初态处显式复位白盒量并注明原因。 + * + * 通过:全部 TC PASS(main 返回 0)。 + * 入口:int main(void),返回 0=PASS / 1=FAIL(由 run 脚本判读)。 + * + * 创建日期:2026-10-23 + */ + +#include +#include + +/* 真实引擎头(接口原汁)+ 被测生产源(原样纳入);dbg_log.h 命中本目录 stub */ +#include "cfg_file.h" +#include "net_cfg.h" + +#include "net_cfg.c" + +/* ===================== 断言框架 ===================== */ + +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) + +/* ===================== dbg_log 桩的计数定义 ===================== */ + +int g_netcfg_info_count; +int g_netcfg_error_count; +int g_netcfg_debug_count; + +static void logs_clear(void) +{ + g_netcfg_info_count = 0; + g_netcfg_error_count = 0; + g_netcfg_debug_count = 0; +} + +/* ===================== 假引擎(可控实现;原型来自真实 cfg_file.h) ========== */ + +/* 注册描述符在 net_cfg.c 里是栈上临时量,必须**按值留档**后才可比对 */ +typedef struct { + char name[CFG_FILE_MAX_NAME_LEN + 1u]; + const void *p_data; + const void *p_defaults; + size_t data_size; + uint8_t key_count; + void (*p_cb)(void); +} fake_reg_t; + +static fake_reg_t g_regs[CFG_FILE_MAX_SECTIONS]; +static int g_reg_n; +static int g_ftp_reg_calls; /* ftp_cfg_register 被 net_cfg_load 调了几次 */ +static int g_load_calls; +static int g_save_calls; +static int g_apply_calls; +static int g_req_cb_calls; +static int g_load_rc; /* 注入:cfg_file_load 返回值 */ +static int g_save_rc; /* 注入:cfg_file_save 返回值 */ +static int g_state_during_save = -1;/* 写盘钩子里读到的交接状态 */ +static int g_requeue_during_save; /* 1 = 写盘中再投递一次请求 */ +static void (*g_save_hook)(void); /* 写盘时刻回调(构造真机难复现的时序) */ + +static void fake_copy_name(char *dst, const char *src, size_t cap) +{ + size_t i; + + for (i = 0u; (i + 1u < cap) && (src[i] != '\0'); i++) { + dst[i] = src[i]; + } + dst[i] = '\0'; +} + +int cfg_file_register(const cfg_section_reg_t *p_reg) +{ + fake_reg_t *rec; + + if ((p_reg == NULL) || (p_reg->name == NULL)) { + return -1; + } + if ((size_t)g_reg_n >= (size_t)CFG_FILE_MAX_SECTIONS) { + return -1; + } + rec = &g_regs[g_reg_n]; + fake_copy_name(rec->name, p_reg->name, sizeof(rec->name)); + rec->p_data = p_reg->p_data; + rec->p_defaults = p_reg->p_defaults; + rec->data_size = p_reg->data_size; + rec->key_count = p_reg->key_count; + rec->p_cb = p_reg->p_cb; + g_reg_n++; + return 0; +} + +int cfg_file_load(void) +{ + g_load_calls++; + return g_load_rc; +} + +int cfg_file_save(void) +{ + g_save_calls++; + if (g_save_hook != NULL) { + g_save_hook(); + } + return g_save_rc; +} + +/* [ftp] 段归 test/host/ftp_cfg/ 模块测;这里只验证"net_cfg_load 确实带上了它" */ +void ftp_cfg_register(void) +{ + g_ftp_reg_calls++; +} + +static void fake_reset(void) +{ + g_reg_n = 0; + g_ftp_reg_calls = 0; + g_load_calls = 0; + g_save_calls = 0; + g_apply_calls = 0; + g_req_cb_calls = 0; + g_load_rc = 0; + g_save_rc = 0; + g_state_during_save = -1; + g_requeue_during_save = 0; + g_save_hook = NULL; + logs_clear(); +} + +static int mac_eq(const uint8_t *p_a, const uint8_t *p_b) +{ + return memcmp(p_a, p_b, 6u) == 0; +} + +static int bytes_eq(const uint8_t *p_a, const uint8_t *p_b, size_t n) +{ + return memcmp(p_a, p_b, n) == 0; +} + +/* ===================== 回调 ===================== */ + +static void apply_cb(void) +{ + g_apply_calls++; +} + +static void req_cb(void) +{ + g_req_cb_calls++; +} + +/* 写盘时刻钩子:记录当时的交接状态,并按开关模拟"写盘期间 UI 又按了一次 ENTER" */ +static void save_hook_probe(void) +{ + g_state_during_save = net_cfg_save_state(); + if (g_requeue_during_save != 0) { + (void)net_cfg_save_request(); + } +} + +/* ===================== 出厂默认(必须在任何 load 之前跑) ===================== */ + +static void tc_defaults(void) +{ + const net_cfg_t *p = net_cfg_get(); + static const uint8_t def_ip[4] = { 192u, 168u, 1u, 100u }; + static const uint8_t def_mask[4] = { 255u, 255u, 255u, 0u }; + static const uint8_t def_gw[4] = { 192u, 168u, 1u, 1u }; + static const uint8_t zero[6] = { 0u, 0u, 0u, 0u, 0u, 0u }; + + printf("-- TC-NETCFG-001..004 defaults before load --\n"); + CHECK(bytes_eq(p->ip, def_ip, 4u), "TC-NETCFG-001 ip = 192.168.1.100"); + CHECK(bytes_eq(p->mask, def_mask, 4u), "TC-NETCFG-002 mask = 255.255.255.0"); + CHECK(bytes_eq(p->gw, def_gw, 4u), "TC-NETCFG-003 gw = 192.168.1.1"); + CHECK(mac_eq(p->mac, zero), "TC-NETCFG-004 mac = 全零(芯片内置哨兵)"); +} + +/* ===================== 注册与 load 契约 ===================== */ + +static void tc_register_load(void) +{ + int rc; + + printf("-- TC-NETCFG-005..011 register + load --\n"); + fake_reset(); + rc = net_cfg_load(); + CHECK((rc == 0) && (g_reg_n == 1) && (strcmp(g_regs[0].name, "network") == 0), + "TC-NETCFG-005 首次 load 注册 [network] 恰好一次"); + CHECK((g_ftp_reg_calls == 1) && (g_load_calls == 1), + "TC-NETCFG-006 一并注册 [ftp],且注册在 load 之前"); + + rc = net_cfg_load(); + CHECK((rc == 0) && (g_reg_n == 1) && (g_ftp_reg_calls == 2), + "TC-NETCFG-007 重复 load 不重复注册 [network]"); + + CHECK((strcmp(g_regs[0].name, "network") == 0) && + (g_regs[0].data_size == sizeof(net_cfg_t)) && + (g_regs[0].key_count == 4u) && + (g_regs[0].p_defaults != NULL) && + (g_regs[0].p_cb != NULL) && + (g_regs[0].p_data == (const void *)&s_cfg), + "TC-NETCFG-008 注册内容:尺寸/键数/默认值/回调/数据指针=RAM 副本"); + + fake_reset(); + rc = net_cfg_load(); + CHECK((rc == 0) && (g_netcfg_info_count == 1) && (g_netcfg_error_count == 0), + "TC-NETCFG-009 load 成功:返回 0 + 一条 INFO"); + + fake_reset(); + g_load_rc = -1; + rc = net_cfg_load(); + CHECK(rc == 0, "TC-NETCFG-010 引擎 -1 时仍返回 0(RAM 恒有效契约)"); + CHECK(g_netcfg_error_count == 1, "TC-NETCFG-011 load 失败必须留 ERROR"); +} + +/* ===================== RAM 副本读写 ===================== */ + +static void tc_set_get(void) +{ + net_cfg_t c; + static const uint8_t ip[4] = { 10u, 0u, 0u, 7u }; + static const uint8_t mac[6] = { 0x02u, 0x11u, 0x22u, 0x33u, 0x44u, 0x55u }; + + printf("-- TC-NETCFG-012..013 set/get --\n"); + c = *net_cfg_get(); + memcpy(c.ip, ip, 4u); + memcpy(c.mac, mac, 6u); + net_cfg_set(&c); + CHECK(bytes_eq(net_cfg_get()->ip, ip, 4u) && + mac_eq(net_cfg_get()->mac, mac), "TC-NETCFG-012 set 被 get 读回"); + + net_cfg_set(NULL); + CHECK(bytes_eq(net_cfg_get()->ip, ip, 4u), "TC-NETCFG-013 set(NULL) 不改写"); +} + +/* ===================== 直接保存 ===================== */ + +static void tc_save_direct(void) +{ + int rc; + + printf("-- TC-NETCFG-014..015 save --\n"); + fake_reset(); + rc = net_cfg_save(); + CHECK((rc == 0) && (g_save_calls == 1), "TC-NETCFG-014 写盘成功返回 0"); + + fake_reset(); + g_save_rc = -1; + rc = net_cfg_save(); + CHECK((rc == -1) && (g_netcfg_error_count == 1), + "TC-NETCFG-015 写盘失败返回 -1 并留痕"); +} + +/* ===================== 跨任务保存交接状态机 ===================== */ + +static void tc_save_handoff(void) +{ + int rc; + + printf("-- TC-NETCFG-020..033 save handoff state machine --\n"); + fake_reset(); + net_cfg_set_apply_cb(NULL); + net_cfg_set_save_req_cb(NULL); + s_save_pending = 0u; /* 白盒复位:上一组的 save() 不碰 pending/result,此处 */ + s_save_result = 0u; /* 只是防止将来有人调整后漏出脏态 */ + CHECK(net_cfg_save_state() == NET_CFG_SAVE_ST_IDLE, "TC-NETCFG-020 初始 IDLE"); + + CHECK((net_cfg_save_request() == 0) && + (net_cfg_save_state() == NET_CFG_SAVE_ST_WAIT), "TC-NETCFG-021 投递后 WAIT"); + + net_cfg_set_save_req_cb(req_cb); + g_req_cb_calls = 0; + (void)net_cfg_save_request(); + CHECK(g_req_cb_calls == 1, "TC-NETCFG-022 投递经回调唤醒属主任务"); + + net_cfg_set_save_req_cb(NULL); + (void)net_cfg_save_request(); + CHECK((g_req_cb_calls == 1) && + (net_cfg_save_state() == NET_CFG_SAVE_ST_WAIT), + "TC-NETCFG-023 未注册回调时空操作,请求照样挂起"); + + g_save_rc = 0; + g_save_calls = 0; + rc = net_cfg_save_run(); + CHECK((rc == 0) && (g_save_calls == 1), "TC-NETCFG-024a 有请求时确实写盘一次"); + g_save_calls = 0; + rc = net_cfg_save_run(); + CHECK((rc == 0) && (g_save_calls == 0), + "TC-NETCFG-024 无请求时空醒不写盘(2s 轮询不刷 Flash)"); + + (void)net_cfg_save_request(); + net_cfg_set_apply_cb(apply_cb); + g_apply_calls = 0; + g_save_rc = 0; + rc = net_cfg_save_run(); + CHECK((rc == 0) && (s_save_pending == 0u), "TC-NETCFG-025 成功后 pending 清零"); + CHECK((net_cfg_save_state() == NET_CFG_SAVE_ST_OK) && (s_save_busy == 0u), + "TC-NETCFG-026 成功后状态 OK 且写盘标志已清"); + CHECK(g_apply_calls == 1, "TC-NETCFG-027 成功后触发一次立即应用"); + + (void)net_cfg_save_request(); + g_save_hook = save_hook_probe; + g_state_during_save = -1; + g_apply_calls = 0; + g_save_rc = 0; + rc = net_cfg_save_run(); + CHECK((rc == 0) && (g_state_during_save == (int)NET_CFG_SAVE_ST_WAIT), + "TC-NETCFG-028 写盘进行中状态仍是 WAIT"); + + /* 写盘期间再投递一次:本轮不得把它当已处理,下轮必须真的再写 */ + g_requeue_during_save = 1; + (void)net_cfg_save_request(); + g_save_calls = 0; + rc = net_cfg_save_run(); + CHECK((rc == 0) && (net_cfg_save_state() == NET_CFG_SAVE_ST_WAIT), + "TC-NETCFG-029a 写盘期间的新请求没丢"); + g_requeue_during_save = 0; + g_save_hook = NULL; + g_save_calls = 0; + rc = net_cfg_save_run(); + CHECK((rc == 0) && (g_save_calls == 1), "TC-NETCFG-029b 下一轮真的再写一次"); + + /* 写盘失败:置 FAIL 且**不得**触发应用(内存值没落盘,网络不能重建) */ + s_save_pending = 0u; + s_save_result = 0u; + g_apply_calls = 0; + (void)net_cfg_save_request(); + g_save_rc = -1; + rc = net_cfg_save_run(); + CHECK((rc == -1) && (net_cfg_save_state() == NET_CFG_SAVE_ST_FAIL) && + (s_save_busy == 0u), + "TC-NETCFG-030 失败置 FAIL 且写盘标志已清(否则 UI 永远卡 WAIT)"); + CHECK(g_apply_calls == 0, "TC-NETCFG-031 写盘失败不得触发立即应用"); + + net_cfg_set_apply_cb(NULL); + g_apply_calls = 0; + net_cfg_request_apply(); + CHECK(g_apply_calls == 0, "TC-NETCFG-032 未注册应用回调时是空操作"); + + net_cfg_set_apply_cb(apply_cb); + net_cfg_request_apply(); + CHECK(g_apply_calls == 1, "TC-NETCFG-033a 注册后可被触发"); + net_cfg_set_apply_cb(NULL); + net_cfg_request_apply(); + CHECK(g_apply_calls == 1, "TC-NETCFG-033b 注销后不再被触发"); +} + +/* ===================== MAC 三态优先级 ===================== */ + +static void tc_mac_priority(void) +{ + net_cfg_t c; + uint8_t out[6]; + static const uint8_t stored[6] = { 0xAAu, 0xBBu, 0xCCu, 0x11u, 0x22u, 0x33u }; + static const uint8_t shadow[6] = { 0x02u, 0x00u, 0x00u, 0x01u, 0x02u, 0x03u }; + static const uint8_t zero[6] = { 0u, 0u, 0u, 0u, 0u, 0u }; + + printf("-- TC-NETCFG-040..045 mac display priority --\n"); + + c = *net_cfg_get(); + memcpy(c.mac, stored, 6u); + net_cfg_set(&c); + net_cfg_set_active_mac(shadow); + memset(out, 0xFF, sizeof(out)); + net_cfg_get_mac(out); + CHECK(mac_eq(out, stored), "TC-NETCFG-040 存储非零优先于芯片影子"); + + c = *net_cfg_get(); + memcpy(c.mac, zero, 6u); + net_cfg_set(&c); + memset(out, 0xFF, sizeof(out)); + net_cfg_get_mac(out); + CHECK(mac_eq(out, shadow), "TC-NETCFG-041 存储全零则用芯片回写影子"); + + s_active_valid = 0u; /* 白盒:回到"芯片还没回写"的上电初态 */ + memset(out, 0xFF, sizeof(out)); + net_cfg_get_mac(out); + CHECK(mac_eq(out, zero), "TC-NETCFG-042 两者皆无 → 全零"); + + net_cfg_set_active_mac(NULL); + CHECK(s_active_valid == 0u, "TC-NETCFG-043 NULL 回写不得把影子置有效"); + + net_cfg_set_active_mac(shadow); + memset(out, 0xFF, sizeof(out)); + net_cfg_get_mac(out); + CHECK(mac_eq(out, shadow), "TC-NETCFG-044 正常回写后影子生效"); + + net_cfg_get_mac(NULL); + CHECK(1, "TC-NETCFG-045 get_mac(NULL) 安全返回"); +} + +/* ===================== IPv4 定长格式化 ===================== */ + +static void tc_fmt_ipv4(void) +{ + static const uint8_t ip1[4] = { 192u, 168u, 1u, 100u }; + static const uint8_t ip0[4] = { 0u, 0u, 0u, 0u }; + static const uint8_t ipm[4] = { 255u, 255u, 255u, 255u }; + char buf[24]; + char guard[24]; + + printf("-- TC-NETCFG-050..056 fixed-width ipv4 format --\n"); + + memset(buf, 0x7A, sizeof(buf)); + net_cfg_fmt_ipv4(ip1, buf, sizeof(buf)); + /* 定长 = 4 段 × 3 位 + 3 个点 = 15(曾有误记为 11 的注释,已同日更正) + * 钉死这个长度是 UI 光标偏移(段 i 起点 4i、点号 3/7/11)的前提 */ + CHECK((strcmp(buf, "192.168.001.100") == 0) && (strlen(buf) == 15u), + "TC-NETCFG-050 定长 15 字符且每段补零"); + + memset(buf, 0x7A, sizeof(buf)); + net_cfg_fmt_ipv4(ip0, buf, sizeof(buf)); + CHECK(strcmp(buf, "000.000.000.000") == 0, "TC-NETCFG-051 全零也占满 15 字符"); + + memset(buf, 0x7A, sizeof(buf)); + net_cfg_fmt_ipv4(ipm, buf, sizeof(buf)); + CHECK(strcmp(buf, "255.255.255.255") == 0, "TC-NETCFG-052 255 边界"); + + memset(guard, 0x7A, sizeof(guard)); + memcpy(buf, guard, sizeof(buf)); + net_cfg_fmt_ipv4(NULL, buf, sizeof(buf)); + CHECK(memcmp(buf, guard, sizeof(buf)) == 0, "TC-NETCFG-053 p_ip NULL 不写缓冲"); + + net_cfg_fmt_ipv4(ip1, NULL, 16u); + CHECK(1, "TC-NETCFG-054 p_buf NULL 不崩"); + + memset(guard, 0x7A, sizeof(guard)); + memcpy(buf, guard, sizeof(buf)); + net_cfg_fmt_ipv4(ip1, buf, 0u); + CHECK(memcmp(buf, guard, sizeof(buf)) == 0, "TC-NETCFG-055 len 0 不写缓冲"); + + memset(buf, 0x7A, sizeof(buf)); + net_cfg_fmt_ipv4(ip1, buf, 5u); + CHECK((strlen(buf) == 4u) && (buf[4] == '\0') && (buf[5] == (char)0x7A), + "TC-NETCFG-056 len 不足:截断但必须 NUL 结尾且不越界"); +} + +/* ===================== 入口 ===================== */ + +int main(void) +{ + printf("=== NET Config Tests ===\n"); + + tc_defaults(); /* 必须最先:任何 load 之前 */ + tc_register_load(); + tc_set_get(); + tc_save_direct(); + tc_save_handoff(); + tc_mac_priority(); + tc_fmt_ipv4(); + + printf("\n=== NET Config Tests: %d/%d PASSED (failed=%d) ===\n", + s_passed, s_total, s_failed); + return (s_failed == 0) ? 0 : 1; +} diff --git a/test/host/net_cfg/run_unit_test.py b/test/host/net_cfg/run_unit_test.py new file mode 100644 index 00000000..b464d53c --- /dev/null +++ b/test/host/net_cfg/run_unit_test.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""网络配置(net_cfg)的主机侧单元测试运行器(L0.5,纯标准库)。 + +做什么: + 用本机 MSVC 编译 test/host/net_cfg/net_cfg_test.c(它以 #include 纳入 + **生产源文件** Drivers/BSP/NETCFG/net_cfg.c),在 PC 上离线跑出厂默认、 + 注册幂等、load/save 错误通道、跨任务保存交接状态机、MAC 三态优先级、 + IPv4 定长格式化,失败即非 0 退出。零硬件、零烧录、毫秒级。 + + 工具链探测 / INCLUDE-LIB 拼接 / 编译运行 / 退出码判定全部委托给通用 harness + `test/host/common/unit_test_harness.py`(本文件只做声明)。 + +为什么要主机侧单测(而不是只用台架): + 保存链跨三个任务(uiTask 投递 → defaultTask 写盘 → netTask 重建网络): + "写盘期间又投递一次"与"写盘失败却重建网络"这两条真机上要靠手速 + 故障注入 + 才碰得到,错了就是"改 IP 不生效 / 保存失败网络被无端重建";MAC 三态优先级 + 写错会静默用错 MAC(同网段冲突,只表现为偶发丢包);net_cfg_fmt_ipv4 的定长 + 11 字符是 UI 光标按固定偏移定位的前提。三条都在 PC 上钉死,且不占 MCU RAM。 + +桩的边界(细节见测试单元文件头): + 配置文件引擎用**真实头**(接口原汁)+ 测试单元内的可控实现;[ftp] 段的 + ftp_cfg_register 只计数——它归 test/host/ftp_cfg/ 模块,两边不互相顶包。 + +用法(仓库根目录): + python test/host/net_cfg/run_unit_test.py + 或统一用 .venv: & '.venv\Scripts\python.exe' test/host/net_cfg/run_unit_test.py + +退出码:0=PASS / 1=测试失败 / 2=环境错误(找不到编译器 / 编译失败 / 无汇总行)。 + +设计依据:docs/子系统设计/网络配置说明.md、docs/测试说明/主机单测覆盖率口径.md §9 P1 +创建日期:2026-10-23 +""" +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.h 用真实头 +# (接口契约原汁,引擎改签名即编译期报错),net_cfg.h / ftp_cfg.h 亦用真实头。 +BUILD = TestBuild( + name="NET Config Tests", + src=os.path.join(HERE, "net_cfg_test.c"), + include_dirs=[ + os.path.join(HERE, "stub"), + os.path.join(ROOT, "Drivers", "BSP", "NETCFG"), + os.path.join(ROOT, "App", "cfg"), + HERE, + ], + summary_marker="=== NET Config Tests:", +) + +if __name__ == "__main__": + sys.exit(run_host_unit_test(BUILD)) + diff --git a/test/host/net_cfg/stub/dbg_log.h b/test/host/net_cfg/stub/dbg_log.h new file mode 100644 index 00000000..4f09745a --- /dev/null +++ b/test/host/net_cfg/stub/dbg_log.h @@ -0,0 +1,27 @@ +/* + * 模块名称:主机侧单测桩 - dbg_log.h(net_cfg 模块专用) + * 模块功能:把 MCU 版 DBG_* 宏替换为**计数**,让"该留痕的有没有留痕"变成可断言 + * 的行为;同时不往 stdout 打日志——汇总行是 harness 的判据,必须干净。 + * + * ⚠ 仅供 test/host/net_cfg/ 使用,不参与 MCU 构建。 + * + * 为什么是计数而不是 printf 直通:net_cfg.c 的三条日志恰好对应三条契约 + * (load OK / load FAIL / save OK|FAIL),计数能钉住"哪条该响、哪条不该响" + * ——例如"写盘失败必须留痕、但不得触发应用"这类只能靠计数判的分支。 + * 计数器定义在 net_cfg_test.c,每组用例前清零。 + * + * 创建日期:2026-10-23 + */ + +#ifndef DBG_LOG_H +#define DBG_LOG_H + +extern int g_netcfg_info_count; +extern int g_netcfg_error_count; +extern int g_netcfg_debug_count; + +#define DBG_INFO(fmt, ...) do { (void)(fmt); g_netcfg_info_count++; } while (0) +#define DBG_DEBUG(fmt, ...) do { (void)(fmt); g_netcfg_debug_count++; } while (0) +#define DBG_ERROR(fmt, ...) do { (void)(fmt); g_netcfg_error_count++; } while (0) + +#endif /* DBG_LOG_H */ diff --git a/test/host/prot_set_store/prot_set_store_test.c b/test/host/prot_set_store/prot_set_store_test.c new file mode 100644 index 00000000..5996b507 --- /dev/null +++ b/test/host/prot_set_store/prot_set_store_test.c @@ -0,0 +1,355 @@ +/* + * 模块名称:保护定值持久化胶水层(prot_set_store)主机侧单元测试 + * 模块功能:在 PC 上离线验证 Drivers/... 侧的平台胶水 App/cfg/prot_set_store.c 的 + * **返回码分流**与**脏标志回置**:读不到=1(按无档处理)、装载成功=0、 + * 版本/项数换代=2 且只打 INFO、magic 或校验被破坏=-1;保存无脏不写盘、 + * 写失败必须把脏标志还回去(否则一轮卷忙就永久丢定值)。 + * + * 被测对象:真实源文件 ../../../App/cfg/prot_set_store.c,并且**同时纳入真实的** + * ../../../App/cfg/prot_set.c —— 因为 blob 的 serialize/parse/脏标志都在 + * prot_set.c 里(纯逻辑,已另有 test/host/prot_set/ 覆盖其算法)。本模块 + * 不重测算法,只测"胶水层在真实 blob 上把返回码分对没有":只有拿真 + * serialize 产出的 blob 去改 magic/ver/cnt/校验,才能构造出"换代"与 + * "损坏"这两种真实形态;自己手搓 blob 会把测试写成对自己的桩。 + * 平台后端 cfg_store_read / cfg_store_write_atomic 用**真实头** + * App/cfg/cfg_file_store.h(接口原汁)+ 本文件的可控实现。 + * + * 为什么要主机侧单测(而不是只靠台架): + * 1. **换代与损坏必须分开**(B8 定值表 32→40 项时定下的契约):magic 完好但 + * ver/cnt 与当前固件不符是**预期的升级路径**,归 2 并打 INFO;把它当损坏(-1) + * 会让设备在正常升级后报 ERROR + 自检留痕,现场查成一个根本不存在的故障。 + * 2. 读失败与"文件不存在"同归 1 是**安全侧**选择(启动期卷锁本任务独占, + * try-lock 失败即真 I/O 故障,此时保持出厂默认比拿半截数据更安全)——钉住它, + * 防止将来有人"优化"成把读失败也当损坏重刷。 + * 3. 写失败必须回置脏:定值经 iec104Task 下发,落盘在 defaultTask;一轮卷忙就把 + * 脏标志吞掉,表现为"改了定值、重启又变回旧的",而且没有任何错误提示。 + * + * 覆盖点: + * —— load 返回码分流(TC-PROTSTOR-001..008)—— + * TC-PROTSTOR-001 后端读失败 → 1(按"无档"处理,保持出厂默认) + * TC-PROTSTOR-002 有效 blob → 0,且装载后定值生效 + * TC-PROTSTOR-003 **ver 换代 → 2**(不是 -1) + * TC-PROTSTOR-004 ver 换代只打 **INFO**(不打 ERROR、不进自检留痕) + * TC-PROTSTOR-005 **cnt 换代 → 2**(B8 32→40 项就是这个形态) + * TC-PROTSTOR-006 magic 被破坏 → -1(真损坏) + * TC-PROTSTOR-007 magic/ver/cnt 都好但数据被改(校验不过)→ -1 + * TC-PROTSTOR-008 长度不足头部 → -1,且**不得**误判为换代 + * TC-PROTSTOR-009 装载成功路径不打 INFO("version change"措辞不许乱出现) + * —— 后端调用契约(TC-PROTSTOR-010..011)—— + * TC-PROTSTOR-010 读取用的路径 = "/prot_set.bin"(8.3 合规,FF_USE_LFN=0 约束) + * TC-PROTSTOR-011 读取请求的容量 = PROT_SET_BLOB_SZ(blob 缓冲是静态区,不压栈) + * —— save 脏标志与写失败重试(TC-PROTSTOR-012..016)—— + * TC-PROTSTOR-012 无脏标志 → 返回 0 且**一次都不写盘** + * TC-PROTSTOR-013 有脏 + 写成功 → 返回 0,路径与长度正确 + * TC-PROTSTOR-014 写成功后脏被消费:紧接着再调不再写(不重复刷 Flash) + * TC-PROTSTOR-015 写失败 → 返回 -1 且**脏回置** + * TC-PROTSTOR-016 上一轮写失败后,下一轮仍会真的再写一次(定值不会静默丢失) + * + * 说明:serialize 失败这条分支在真实 prot_set.c 下不可达(缓冲尺寸恒等于 + * PROT_SET_BLOB_SZ),故不硬造用例;胶水层里它是防御性代码,记录在此。 + * + * 通过:全部 TC PASS(main 返回 0)。 + * 入口:int main(void),返回 0=PASS / 1=FAIL(由 run 脚本判读)。 + * + * 创建日期:2026-10-23 + */ + +#include +#include + +#include "cfg_file_store.h" +#include "prot_set.h" +#include "prot_set_store.h" + +/* 真实定值层(blob 编解码与脏标志在这里)+ 被测胶水层,均原样纳入 */ +#include "prot_set.c" +#include "prot_set_store.c" + +/* ===================== 断言框架 ===================== */ + +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) + +/* ===================== dbg_log 桩计数 ===================== */ + +int g_pstore_info_count; +int g_pstore_error_count; +int g_pstore_debug_count; + +static void logs_clear(void) +{ + g_pstore_info_count = 0; + g_pstore_error_count = 0; + g_pstore_debug_count = 0; +} + +/* ===================== 假平台后端(原型来自真实 cfg_file_store.h) ========= */ + +static uint8_t g_img[512]; /* 注入给 cfg_store_read 的文件内容 */ +static uint32_t g_img_len; +static int g_read_rc; /* 注入:cfg_store_read 返回值 */ +static int g_write_rc; /* 注入:cfg_store_write_atomic 返回值 */ +static int g_read_calls; +static int g_write_calls; +static size_t g_read_cap; +static size_t g_write_len; +static char g_read_path[32]; +static char g_write_path[32]; + +static void rec_path(char *dst, const char *src, size_t cap) +{ + size_t i; + + if (src == NULL) { + dst[0] = '\0'; + return; + } + for (i = 0u; (i + 1u < cap) && (src[i] != '\0'); i++) { + dst[i] = src[i]; + } + dst[i] = '\0'; +} + +int cfg_store_read(const char *p_path, uint8_t *p_buf, size_t cap, size_t *p_nread) +{ + uint32_t i; + + g_read_calls++; + rec_path(g_read_path, p_path, sizeof(g_read_path)); + g_read_cap = cap; + if (p_nread != NULL) { + *p_nread = 0u; + } + if (g_read_rc != 0) { + return g_read_rc; + } + for (i = 0u; (i < g_img_len) && ((size_t)i < cap); i++) { + p_buf[i] = g_img[i]; + } + if (p_nread != NULL) { + *p_nread = (size_t)i; + } + return 0; +} + +int cfg_store_write_atomic(const char *p_path, const uint8_t *p_buf, size_t len) +{ + g_write_calls++; + rec_path(g_write_path, p_path, sizeof(g_write_path)); + g_write_len = len; + (void)p_buf; + return g_write_rc; +} + +/* 其余后端函数本模块不触发,给出空实现以满足链接 */ +void *cfg_store_alloc(size_t size) { (void)size; return NULL; } +void cfg_store_free(void *p_buf) { (void)p_buf; } +uint32_t cfg_store_heap_free(void) { return 0u; } +int cfg_store_stat(const char *p_path, cfg_file_stat_t *p_st) +{ + (void)p_path; + if (p_st != NULL) { + (void)memset(p_st, 0, sizeof(*p_st)); + } + return -1; +} +int cfg_store_remove(const char *p_path) { (void)p_path; return -1; } + +/* ===================== 辅助 ===================== */ + +static void backend_reset(void) +{ + g_img_len = 0u; + g_read_rc = 0; + g_write_rc = 0; + g_read_calls = 0; + g_write_calls = 0; + g_read_cap = 0u; + g_write_len = 0u; + g_read_path[0] = '\0'; + g_write_path[0] = '\0'; + logs_clear(); +} + +/* 用**真实** serialize 造一份合法 blob,再按需改坏指定字段 */ +static uint32_t make_blob(uint8_t *p_dst, uint32_t cap) +{ + uint32_t len = 0u; + + prot_set_init(); + if (prot_set_store_serialize(p_dst, cap, &len) != 0) { + return 0u; + } + return len; +} + +static void stage_blob(uint32_t len) +{ + g_img_len = len; + g_read_rc = 0; +} + +/* 把脏标志清干净(消费掉所有存量) */ +static void dirty_clear(void) +{ + while (prot_set_dirty_take() != 0u) { + /* 逐个消费直到 0 */ + } +} + +/* ===================== load 返回码分流 ===================== */ + +static void tc_load_triage(void) +{ + uint8_t blob[PROT_SET_BLOB_SZ]; + uint32_t len; + int rc; + + printf("-- TC-PROTSTOR-001..009 load triage --\n"); + + backend_reset(); + g_read_rc = -1; + rc = prot_set_store_load(); + CHECK(rc == 1, "TC-PROTSTOR-001 读失败归 1(按无档处理)"); + + backend_reset(); + len = make_blob(blob, sizeof(blob)); + memcpy(g_img, blob, len); + stage_blob(len); + rc = prot_set_store_load(); + CHECK(rc == 0, "TC-PROTSTOR-002 有效 blob 装载成功"); + + /* ver 换代:magic 完好、ver 与当前固件不同 → 预期升级路径,归 2 */ + backend_reset(); + len = make_blob(blob, sizeof(blob)); + memcpy(g_img, blob, len); + g_img[4] = (uint8_t)(PROT_SET_BLOB_VER + 1u); + stage_blob(len); + rc = prot_set_store_load(); + CHECK(rc == 2, "TC-PROTSTOR-003 ver 换代归 2(不是 -1)"); + CHECK((g_pstore_info_count == 1) && (g_pstore_error_count == 0), + "TC-PROTSTOR-004 换代只打 INFO,不误报 ERROR"); + + /* cnt 换代:B8 定值表 32→40 项就是这个形态 */ + backend_reset(); + len = make_blob(blob, sizeof(blob)); + memcpy(g_img, blob, len); + g_img[6] = (uint8_t)((uint32_t)PS_ID_COUNT + 1u); + stage_blob(len); + rc = prot_set_store_load(); + CHECK(rc == 2, "TC-PROTSTOR-005 cnt 换代归 2"); + + /* magic 坏 = 真损坏 */ + backend_reset(); + len = make_blob(blob, sizeof(blob)); + memcpy(g_img, blob, len); + g_img[0] = (uint8_t)(g_img[0] ^ 0xFFu); + stage_blob(len); + rc = prot_set_store_load(); + CHECK((rc == -1) && (g_pstore_error_count == 0), "TC-PROTSTOR-006 magic 坏归 -1"); + + /* magic/ver/cnt 全好,但数据被改 → 校验不过 = 损坏 */ + backend_reset(); + len = make_blob(blob, sizeof(blob)); + memcpy(g_img, blob, len); + g_img[PROT_SET_BLOB_HDR_SZ] = (uint8_t)(g_img[PROT_SET_BLOB_HDR_SZ] ^ 0x5Au); + stage_blob(len); + rc = prot_set_store_load(); + CHECK(rc == -1, "TC-PROTSTOR-007 数据被改(校验失败)归 -1"); + + /* 截断到不足头部:不得误判为换代 */ + backend_reset(); + len = make_blob(blob, sizeof(blob)); + memcpy(g_img, blob, len); + stage_blob(PROT_SET_BLOB_HDR_SZ - 1u); + rc = prot_set_store_load(); + CHECK(rc == -1, "TC-PROTSTOR-008 长度不足头部归 -1 且不误判换代"); + + backend_reset(); + len = make_blob(blob, sizeof(blob)); + memcpy(g_img, blob, len); + stage_blob(len); + rc = prot_set_store_load(); + CHECK((rc == 0) && (g_pstore_info_count == 0), + "TC-PROTSTOR-009 成功路径不打 INFO(version change 措辞不许乱出现)"); +} + +/* ===================== 后端调用契约 ===================== */ + +static void tc_backend_contract(void) +{ + uint8_t blob[PROT_SET_BLOB_SZ]; + uint32_t len; + + printf("-- TC-PROTSTOR-010..011 backend contract --\n"); + backend_reset(); + len = make_blob(blob, sizeof(blob)); + memcpy(g_img, blob, len); + stage_blob(len); + (void)prot_set_store_load(); + CHECK(strcmp(g_read_path, "/prot_set.bin") == 0, + "TC-PROTSTOR-010 路径 = /prot_set.bin(8.3 合规)"); + CHECK(g_read_cap == (size_t)PROT_SET_BLOB_SZ, + "TC-PROTSTOR-011 读取容量 = PROT_SET_BLOB_SZ(静态缓冲,不压栈)"); +} + +/* ===================== save 与脏标志 ===================== */ + +static void tc_save_dirty(void) +{ + int rc; + + printf("-- TC-PROTSTOR-012..016 save + dirty retry --\n"); + + backend_reset(); + dirty_clear(); + rc = prot_set_store_save(); + CHECK((rc == 0) && (g_write_calls == 0), "TC-PROTSTOR-012 无脏不写盘"); + + prot_set_dirty_mark(); + g_write_rc = 0; + rc = prot_set_store_save(); + CHECK((rc == 0) && (g_write_calls == 1) && + (strcmp(g_write_path, "/prot_set.bin") == 0) && (g_write_len > 0u), + "TC-PROTSTOR-013 有脏且写成功:路径与长度正确"); + + rc = prot_set_store_save(); + CHECK((rc == 0) && (g_write_calls == 1), + "TC-PROTSTOR-014 脏已消费,紧接着再调不再刷 Flash"); + + prot_set_dirty_mark(); + g_write_rc = -1; + rc = prot_set_store_save(); + CHECK((rc == -1) && (g_write_calls == 2), "TC-PROTSTOR-015 写失败返回 -1"); + + /* 关键:失败必须把脏标志还回去,否则这一轮定值永久丢失 */ + g_write_rc = 0; + g_write_calls = 0; + rc = prot_set_store_save(); + CHECK((rc == 0) && (g_write_calls == 1), + "TC-PROTSTOR-016 上一轮写失败后下一轮仍真的再写(脏已回置)"); + + dirty_clear(); +} + +/* ===================== 入口 ===================== */ + +int main(void) +{ + printf("=== Prot Set Store Tests ===\n"); + + tc_load_triage(); + tc_backend_contract(); + tc_save_dirty(); + + printf("\n=== Prot Set Store Tests: %d/%d PASSED (failed=%d) ===\n", + s_passed, s_total, s_failed); + return (s_failed == 0) ? 0 : 1; +} diff --git a/test/host/prot_set_store/run_unit_test.py b/test/host/prot_set_store/run_unit_test.py new file mode 100644 index 00000000..0b304a3c --- /dev/null +++ b/test/host/prot_set_store/run_unit_test.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""保护定值持久化胶水层(prot_set_store)的主机侧单元测试运行器(L0.5,纯标准库)。 + +做什么: + 用本机 MSVC 编译 test/host/prot_set_store/prot_set_store_test.c(同时纳入**生产源** + App/cfg/prot_set_store.c 与 App/cfg/prot_set.c),在 PC 上离线验证 blob 装载的 + 返回码分流(1 无档 / 0 成功 / 2 换代 / -1 损坏)与保存的脏标志回置,失败即非 0 + 退出。工具链与判定委托 test/host/common/unit_test_harness.py。 + +为什么要主机侧单测(而不是只用台架): + 换代与损坏必须分开(B8:定值表 32→40 项)——把正常的固件升级当损坏,会让设备在 + 升级后报 ERROR + 自检留痕,现场查一个不存在的故障;写盘失败若不把脏标志还回去, + 表现是"改了定值、重启又变回旧的"且无任何提示。这两条真机上都要靠故障注入才碰得到。 + 为什么连 prot_set.c 一起编:blob 的编解码与脏标志都在它里面,只有拿**真** serialize + 产出的 blob 去改 magic/ver/cnt/校验,才能造出真实的"换代"与"损坏"形态;手搓 blob + 等于测自己的桩。定值算法本身归 test/host/prot_set/,本模块不重测。 + +用法(仓库根目录): + python test/host/prot_set_store/run_unit_test.py + +退出码:0=PASS / 1=测试失败 / 2=环境错误。 + +设计依据:App/cfg/prot_set_store.h 的返回码表、docs/测试说明/主机单测覆盖率口径.md §9 P1 +创建日期:2026-10-23 +""" +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 先于生产目录(覆盖 dbg_log.h)。cfg_file_store.h / prot_set.h / +# prot_set_store.h 全用真实头,后端两个被调函数由测试单元给可控实现。 +BUILD = TestBuild( + name="Prot Set Store Tests", + src=os.path.join(HERE, "prot_set_store_test.c"), + include_dirs=[ + os.path.join(HERE, "stub"), + os.path.join(ROOT, "App", "cfg"), + os.path.join(ROOT, "App", "algo"), + HERE, + ], + summary_marker="=== Prot Set Store Tests:", +) + +if __name__ == "__main__": + sys.exit(run_host_unit_test(BUILD)) + diff --git a/test/host/prot_set_store/stub/dbg_log.h b/test/host/prot_set_store/stub/dbg_log.h new file mode 100644 index 00000000..2d29c2eb --- /dev/null +++ b/test/host/prot_set_store/stub/dbg_log.h @@ -0,0 +1,20 @@ +/* + * 模块名称:主机侧单测桩 - dbg_log.h(prot_set_store 模块专用) + * 模块功能:把 MCU 版 DBG_* 宏替换为计数,让"换代只打 INFO、损坏不打"这类 + * 留痕差异成为可断言的行为(B8 的分流契约一半就在措辞上)。 + * ⚠ 仅供 test/host/prot_set_store/ 使用,不参与 MCU 构建。 + * 创建日期:2026-10-23 + */ + +#ifndef DBG_LOG_H +#define DBG_LOG_H + +extern int g_pstore_info_count; +extern int g_pstore_error_count; +extern int g_pstore_debug_count; + +#define DBG_INFO(fmt, ...) do { (void)(fmt); g_pstore_info_count++; } while (0) +#define DBG_DEBUG(fmt, ...) do { (void)(fmt); g_pstore_debug_count++; } while (0) +#define DBG_ERROR(fmt, ...) do { (void)(fmt); g_pstore_error_count++; } while (0) + +#endif /* DBG_LOG_H */ diff --git a/test/host/task_prof/run_unit_test.py b/test/host/task_prof/run_unit_test.py new file mode 100644 index 00000000..9c7ebca6 --- /dev/null +++ b/test/host/task_prof/run_unit_test.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""任务耗时探针(task_prof)的主机侧单元测试运行器(L0.5,纯标准库)。 + +做什么: + 用本机 MSVC 编译 test/host/task_prof/task_prof_test.c(以 #include 纳入**生产源** + App/util/task_prof.c),在 PC 上离线验证样本有效性闸门、enter/exit 配对、上报节流 + 与两条通道(环形缓冲 / 裸发)、周期→微秒换算、glitch 汇总,失败即非 0 退出。 + 工具链与判定委托 test/host/common/unit_test_harness.py。 + +为什么要主机侧单测(而不是只靠真机日志): + 这个模块自己错了,所有 PROF 行就都是假的:漏了"无效样本闸门",一次 DWT 复位就把 + max 永久抬到 ~2^32 周期;"先 exit 再 enter"的首轮会把 0 当起点,造出几秒的假超大 + 样本;节流写错要么刷屏挤掉正事、要么停摆现场那一行发不出去;换算依赖 + SystemCoreClock,改时钟树忘了同步则整表偏移而无人察觉。这些都得能构造时序来测。 + +桩的边界:只有 main.h 需要桩(真实 main.h 拉整棵 HAL 头树,PC 上编不动;桩里的字段名 + 与位掩码宏名必须与 CMSIS 一致,改了被测代码即编译失败)。console_log.h 用真实头, + 两个出口由测试单元实现并留档内容——报告行是给人读的,断言直接盯字符串。 + +用法(仓库根目录): + python test/host/task_prof/run_unit_test.py + +退出码:0=PASS / 1=测试失败 / 2=环境错误。 + +设计依据:App/util/task_prof.h、docs/测试说明/主机单测覆盖率口径.md §9 P1 +创建日期:2026-10-23 +""" +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 必须先于生产目录,才能用桩 main.h 覆盖真实 main.h。 +# console_log.h / task_prof.h 用真实头(App/log、App/util)。 +BUILD = TestBuild( + name="Task Prof Tests", + src=os.path.join(HERE, "task_prof_test.c"), + include_dirs=[ + os.path.join(HERE, "stub"), + os.path.join(ROOT, "App", "util"), + os.path.join(ROOT, "App", "log"), + HERE, + ], + summary_marker="=== Task Prof Tests:", +) + +if __name__ == "__main__": + sys.exit(run_host_unit_test(BUILD)) + diff --git a/test/host/task_prof/stub/main.h b/test/host/task_prof/stub/main.h new file mode 100644 index 00000000..f795f671 --- /dev/null +++ b/test/host/task_prof/stub/main.h @@ -0,0 +1,44 @@ +/* + * 模块名称:主机侧单测桩 - main.h(task_prof 模块专用) + * 模块功能:提供 task_prof.c 需要的**四个**平台符号,让 DWT 周期计数器变成 + * 测试可控制的量: + * SystemCoreClock 测试可写(换算 us 的分母,真机 = 168 MHz) + * HAL_GetTick() 测试可控的毫秒时基(report 节流就看它) + * DWT->CTRL/CYCCNT 可写内存:CTRL 的 CYCCNTENA 位要能"读回 1", + * 否则 task_prof.c 永远认为 DWT 不可用 + * CoreDebug->DEMCR 同上(TRCENA 位) + * + * ⚠ 仅供 test/host/task_prof/ 使用,不参与 MCU 构建。 + * 真实 main.h 拉整棵 HAL 头树,主机侧无法编译,故这里只声明被测文件实际用到的 + * 那几个符号——**结构体字段名与位掩码宏名必须与 CMSIS 一致**(DWT_Type 的 + * CTRL/CYCCNT、DEMCR、DWT_CTRL_CYCCNTENA_Msk、CoreDebug_DEMCR_TRCENA_Msk), + * 改了这些名字被测代码会直接编译失败,不会静默放行。 + * + * 创建日期:2026-10-23 + */ + +#ifndef MAIN_H +#define MAIN_H + +#include + +typedef struct { + volatile uint32_t DEMCR; +} tp_coredebug_t; + +typedef struct { + volatile uint32_t CTRL; + volatile uint32_t CYCCNT; +} tp_dwt_t; + +extern tp_coredebug_t *const CoreDebug; +extern tp_dwt_t *const DWT; + +#define CoreDebug_DEMCR_TRCENA_Msk (1UL << 24) +#define DWT_CTRL_CYCCNTENA_Msk (1UL << 0) + +extern uint32_t SystemCoreClock; + +uint32_t HAL_GetTick(void); + +#endif /* MAIN_H */ diff --git a/test/host/task_prof/task_prof_test.c b/test/host/task_prof/task_prof_test.c new file mode 100644 index 00000000..b4a34215 --- /dev/null +++ b/test/host/task_prof/task_prof_test.c @@ -0,0 +1,376 @@ +/* + * 模块名称:任务耗时探针(task_prof)主机侧单元测试 + * 模块功能:在 PC 上离线验证 App/util/task_prof.c 的**样本有效性闸门、enter/exit + * 配对、上报节流与通道选择、周期→微秒换算、glitch 汇总**。 + * + * 被测对象:真实源文件 ../../../App/util/task_prof.c(#include 原样纳入)。桩: + * - stub/main.h:DWT/CoreDebug/SystemCoreClock/HAL_GetTick(本目录, + * 真实 main.h 拉整棵 HAL 头树无法在 PC 上编); + * - console_log.h 用**真实头**(App/log),两个出口函数 + * dbg_console_write_line / dbg_console_write_raw 由本文件实现并**留档 + * 内容**——报告行是要被人读的,所以断言直接盯字符串本身。 + * + * 为什么要主机侧单测(而不是只靠真机日志): + * 这个模块是"性能问题的唯一现场证据",它自己错了所有 PROF 行就都是假的: + * 1. **无效样本闸门**(cyc==0 或 ≥ SystemCoreClock×10):漏了它,一次 DWT 复位 + * 就把 max 抬到 ~2^32 周期,之后所有"最坏耗时"永久失真(真机上没人会去翻 + * gl= 计数发现)。这条只能构造样本测。 + * 2. **未 enter 就 exit 必须直接返回**:周期测量写成"先 exit 再 enter"时,首轮 + * 会把 0 当起点,记下一次几秒的虚假超大样本——真机上表现为"偶发一条离谱 + * PROF 行",查半年。 + * 3. 节流契约(首次 5 s、之后每 2 s)与**两条通道的选择**(report 走环形缓冲、 + * report_raw 走裸发):错了要么刷屏挤掉正事,要么停摆现场那一行发不出去。 + * 4. 周期→微秒换算依赖 SystemCoreClock,改时钟树(比如 168→180 MHz)忘了同步 + * 就会整表偏移;这条在真机上看不出来(没人对着示波器量 PROF)。 + * + * 覆盖点: + * TC-TASKPROF-001 有效样本:count+1 / last=样本 / max=样本 + * TC-TASKPROF-002 更小样本不改 max(max 只增不减) + * TC-TASKPROF-003 cyc=0 只累加 glitch,不动 count/last/max(计数器复位防线) + * TC-TASKPROF-004 cyc ≥ SystemCoreClock×10 同样只累加 glitch + * TC-TASKPROF-005 id 越界(=COUNT 与 +1)安全返回,不碰任何槽 + * TC-TASKPROF-006 首次取时基即惰性使能 DWT(TRCENA + CYCCNTENA 读回) + * TC-TASKPROF-007 enter 置 in_seg 并记下起点 + * TC-TASKPROF-008 exit 结算 Δ、清 in_seg、count+1 + * TC-TASKPROF-009 **未 enter 直接 exit 不记账**(防首轮 0 起点造出假超大样本) + * TC-TASKPROF-010 重复 exit 不重复记账 + * TC-TASKPROF-011 首次 5 s 之前一条都不发 + * TC-TASKPROF-012 到点后发两行([PROF] + [PROF2]),走环形缓冲通道 + * TC-TASKPROF-013 之后不足 2 s 不发(节流) + * TC-TASKPROF-014 满 2 s 再发两行 + * TC-TASKPROF-015 report_raw 无视节流,恒发两行且走**裸发**通道 + * TC-TASKPROF-016 行格式:以 [PROF]/[PROF2] 开头、以 \r\n 结尾 + * TC-TASKPROF-017 周期→微秒换算按 SystemCoreClock(168 MHz 下 1680 周期 = 10 us) + * TC-TASKPROF-018 gl= 是所有槽 glitch 的汇总,不是单槽 + * TC-TASKPROF-019 段内进行时的 n= 字段非零(此刻仍在段内的可视性) + * TC-TASKPROF-020 count 是累计值,上报不清零 + * + * 通过:全部 TC PASS(main 返回 0)。 + * 入口:int main(void),返回 0=PASS / 1=FAIL(由 run 脚本判读)。 + * + * 创建日期:2026-10-23 + */ + +#include +#include + +#include "console_log.h" /* 真实头:DBG_CON_LINE_MAX + 两个出口 */ +#include "task_prof.h" + +#include "task_prof.c" + +/* ===================== 断言框架 ===================== */ + +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) + +/* ===================== 桩实体 ===================== */ + +static tp_coredebug_t g_coredebug; +static tp_dwt_t g_dwt; +tp_coredebug_t *const CoreDebug = &g_coredebug; +tp_dwt_t *const DWT = &g_dwt; + +uint32_t SystemCoreClock = 168000000u; /* 真机口径:168 MHz */ + +static uint32_t g_tick; /* HAL_GetTick 的可控值 */ + +uint32_t HAL_GetTick(void) +{ + return g_tick; +} + +/* 出口留档:两条通道分别计数;**每一行都单独留档**(一次上报会连发 + * [PROF] 与 [PROF2] 两行,只存"最后一行"就没法断言第一行的字段) */ +#define CAP_MAX 8 +static int g_line_calls; +static int g_raw_calls; +static char g_line[CAP_MAX][DBG_CON_LINE_MAX]; + +static void cap(int slot, const char *src, unsigned len) +{ + unsigned i; + + if (slot >= CAP_MAX) { + return; + } + for (i = 0u; (i < len) && (i + 1u < DBG_CON_LINE_MAX); i++) { + g_line[slot][i] = src[i]; + } + g_line[slot][i] = '\0'; +} + +int dbg_console_write_line(const char *line, unsigned len) +{ + cap(g_line_calls + g_raw_calls, line, len); + g_line_calls++; + return (int)len; +} + +void dbg_console_write_raw(const char *buf, unsigned len) +{ + cap(g_line_calls + g_raw_calls, buf, len); + g_raw_calls++; +} + +void dbg_console_writer_attach(void) { } +uint8_t dbg_console_writer_ready(void) { return 1u; } + +/* ===================== 辅助 ===================== */ + +static void reset_all(void) +{ + (void)memset(s_prof, 0, sizeof(s_prof)); + (void)memset(&g_coredebug, 0, sizeof(g_coredebug)); + (void)memset(&g_dwt, 0, sizeof(g_dwt)); + s_dwt_ok = 0u; + s_prof_reported = 0u; + s_prof_last = 0u; + g_tick = 0u; + g_line_calls = 0; + g_raw_calls = 0; + (void)memset(g_line, 0, sizeof(g_line)); +} + +static void set_cyc(uint32_t v) +{ + g_dwt.CYCCNT = v; +} + +/* 从指定行的键上取整数值;键不存在或其后不是数字则返回 -1(断言因此会响)。 + * 手写解析而不用 sscanf:后者在 MSVC 下触发 C4996(弃用告警),门禁输出要干净 */ +static long field_of(int slot, const char *p_key) +{ + const char *p; + long v = 0; + int digits = 0; + + if (slot >= CAP_MAX) { + return -1L; + } + p = strstr(g_line[slot], p_key); + if (p == NULL) { + return -1L; + } + p += strlen(p_key); + while ((*p >= '0') && (*p <= '9')) { + v = (v * 10) + (long)(*p - '0'); + p++; + digits++; + } + return (digits > 0) ? v : -1L; +} + +/* ===================== 样本闸门 ===================== */ + +static void tc_sample_gate(void) +{ + printf("-- TC-TASKPROF-001..005 sample gate --\n"); + reset_all(); + task_prof_sample(TASK_PROF_UI_PERIOD, 500u); + CHECK((s_prof[TASK_PROF_UI_PERIOD].count == 1u) && + (s_prof[TASK_PROF_UI_PERIOD].last == 500u) && + (s_prof[TASK_PROF_UI_PERIOD].max == 500u), + "TC-TASKPROF-001 有效样本记 count/last/max"); + + task_prof_sample(TASK_PROF_UI_PERIOD, 300u); + CHECK((s_prof[TASK_PROF_UI_PERIOD].max == 500u) && + (s_prof[TASK_PROF_UI_PERIOD].last == 300u) && + (s_prof[TASK_PROF_UI_PERIOD].count == 2u), + "TC-TASKPROF-002 更小样本不改 max"); + + reset_all(); + task_prof_sample(TASK_PROF_UI_PERIOD, 0u); + CHECK((s_prof[TASK_PROF_UI_PERIOD].glitch == 1u) && + (s_prof[TASK_PROF_UI_PERIOD].count == 0u) && + (s_prof[TASK_PROF_UI_PERIOD].max == 0u), + "TC-TASKPROF-003 cyc=0 只累加 glitch(DWT 复位防线)"); + + reset_all(); + task_prof_sample(TASK_PROF_UI_PERIOD, SystemCoreClock * 10u); + CHECK((s_prof[TASK_PROF_UI_PERIOD].glitch == 1u) && + (s_prof[TASK_PROF_UI_PERIOD].count == 0u), + "TC-TASKPROF-004 超上界样本只累加 glitch"); + + reset_all(); + task_prof_sample((task_prof_id_t)TASK_PROF_ID_COUNT, 100u); + task_prof_sample((task_prof_id_t)(TASK_PROF_ID_COUNT + 1), 100u); + task_prof_enter((task_prof_id_t)TASK_PROF_ID_COUNT); + task_prof_exit((task_prof_id_t)TASK_PROF_ID_COUNT); + CHECK((s_prof[0].count == 0u) && (s_prof[TASK_PROF_ID_COUNT - 1].count == 0u), + "TC-TASKPROF-005 id 越界安全返回,不碰任何槽"); +} + +/* ===================== 时基与 enter/exit 配对 ===================== */ + +static void tc_enter_exit(void) +{ + printf("-- TC-TASKPROF-006..010 dwt enable + enter/exit --\n"); + reset_all(); + set_cyc(1000u); + (void)task_prof_cyc(); + CHECK(((g_coredebug.DEMCR & CoreDebug_DEMCR_TRCENA_Msk) != 0u) && + ((g_dwt.CTRL & DWT_CTRL_CYCCNTENA_Msk) != 0u) && + (s_dwt_ok == 1u), + "TC-TASKPROF-006 首次取时基即惰性使能 DWT"); + + reset_all(); + set_cyc(2000u); + task_prof_enter(TASK_PROF_UI_WORK); + CHECK((s_prof[TASK_PROF_UI_WORK].in_seg == 1u) && + (s_prof[TASK_PROF_UI_WORK].t_start == 2000u), + "TC-TASKPROF-007 enter 置 in_seg 并记起点"); + + set_cyc(2640u); + task_prof_exit(TASK_PROF_UI_WORK); + CHECK((s_prof[TASK_PROF_UI_WORK].in_seg == 0u) && + (s_prof[TASK_PROF_UI_WORK].last == 640u) && + (s_prof[TASK_PROF_UI_WORK].count == 1u), + "TC-TASKPROF-008 exit 结算 Δ 并清 in_seg"); + + reset_all(); + task_prof_exit(TASK_PROF_ADC_BEAT); + CHECK((s_prof[TASK_PROF_ADC_BEAT].count == 0u) && + (s_prof[TASK_PROF_ADC_BEAT].glitch == 0u), + "TC-TASKPROF-009 未 enter 直接 exit 不记账(防 0 起点假超大样本)"); + + reset_all(); + set_cyc(100u); + task_prof_enter(TASK_PROF_NET_POLL); + set_cyc(150u); + task_prof_exit(TASK_PROF_NET_POLL); + task_prof_exit(TASK_PROF_NET_POLL); + CHECK(s_prof[TASK_PROF_NET_POLL].count == 1u, "TC-TASKPROF-010 重复 exit 不重复记账"); +} + +/* ===================== 上报节流与通道 ===================== */ + +static void tc_report_throttle(void) +{ + printf("-- TC-TASKPROF-011..015 report throttle + channels --\n"); + reset_all(); + g_tick = 4999u; + task_prof_report(); + CHECK((g_line_calls == 0) && (g_raw_calls == 0), "TC-TASKPROF-011 首 5 s 前不发"); + + g_tick = 5000u; + task_prof_report(); + CHECK((g_line_calls == 2) && (g_raw_calls == 0), + "TC-TASKPROF-012 到点发两行走环形缓冲"); + + g_tick = 6999u; + task_prof_report(); + CHECK(g_line_calls == 2, "TC-TASKPROF-013 不足 2 s 不发"); + + g_tick = 7000u; + task_prof_report(); + CHECK(g_line_calls == 4, "TC-TASKPROF-014 满 2 s 再发两行"); + + g_line_calls = 0; + g_raw_calls = 0; + task_prof_report_raw(); + CHECK((g_raw_calls == 2) && (g_line_calls == 0), + "TC-TASKPROF-015 report_raw 无视节流且走裸发通道"); +} + +/* ===================== 行内容与换算 ===================== */ + +static void tc_line_format(void) +{ + printf("-- TC-TASKPROF-016..020 line content --\n"); + + /* 016:一次上报连发两行;行首前缀是采集脚本的抓取依据,行尾必须是 CRLF */ + reset_all(); + g_tick = 5000u; + task_prof_report(); + CHECK((g_line_calls == 2) && + (strncmp(g_line[0], "[PROF] ", 7) == 0) && + (strncmp(g_line[1], "[PROF2] ", 8) == 0), + "TC-TASKPROF-016 两行行首分别是 [PROF] / [PROF2]"); + CHECK((strstr(g_line[0], "\r\n") != NULL) && + (strstr(g_line[1], "\r\n") != NULL), + "TC-TASKPROF-016b 两行都以 CRLF 结尾"); + CHECK(field_of(0, "uip=") == 0L, "TC-TASKPROF-016c 未采样的槽打印 0"); + + /* 017:打印的是**微秒**,分母是 SystemCoreClock —— 断言落在打印字段上 */ + reset_all(); + task_prof_sample(TASK_PROF_UI_PERIOD, 1680u); /* 168 MHz → 10 us */ + task_prof_sample(TASK_PROF_ADC_BEAT, 3360u); /* 168 MHz → 20 us */ + g_tick = 5000u; + task_prof_report(); + CHECK(field_of(0, "uip=") == 10L, + "TC-TASKPROF-017 168 MHz:1680 周期的 max 打印成 uip=10"); + CHECK(field_of(0, "adcm=") == 20L, + "TC-TASKPROF-017b 同频:3360 周期的 max 打印成 adcm=20"); + SystemCoreClock = 84000000u; /* 改时钟树而忘了同步 → 整表偏移,这里必须显式跟着变 */ + g_tick = 7000u; + task_prof_report(); + CHECK(field_of(2, "uip=") == 20L, + "TC-TASKPROF-017c 84 MHz 下同一批周期数打印成 20"); + SystemCoreClock = 168000000u; + + /* 018:gl= 是**所有槽**的 glitch 汇总,不是当前槽 */ + reset_all(); + task_prof_sample(TASK_PROF_UI_PERIOD, 0u); + task_prof_sample(TASK_PROF_ADC_BEAT, 0u); + task_prof_sample(TASK_PROF_NET_POLL, 0u); + g_tick = 5000u; + task_prof_report(); + CHECK(field_of(0, "gl=") == 3L, "TC-TASKPROF-018 gl= 汇总所有槽的 glitch"); + + /* 019:段未结算时,"此刻已在段内多久"要看得见(uipn / netn 两个字段) */ + /* 字段口径 = 上报时刻的 CYCCNT 减去各自的进段时刻(**不是**样本值): + * UI_PERIOD 进段 @1000、NET_POLL 进段 @10000,上报时 CYCCNT=51680 + * uipn = (51680-1000)/168 = 301 netn = (51680-10000)/168 = 248 */ + reset_all(); + set_cyc(1000u); + task_prof_enter(TASK_PROF_UI_PERIOD); + set_cyc(10000u); + task_prof_enter(TASK_PROF_NET_POLL); + set_cyc(51680u); + g_tick = 5000u; + task_prof_report(); + CHECK(field_of(0, "uipn=") == 301L, + "TC-TASKPROF-019 UI_PERIOD 段内:uipn= 上报此刻已停留的 us"); + CHECK(field_of(1, "netn=") == 248L, + "TC-TASKPROF-019b NET_POLL 段内:[PROF2] 的 netn= 同步可见"); + CHECK(field_of(0, "adcn=") == 0L, + "TC-TASKPROF-019c 未进段的 ADC_BEAT 打印 0(不串用别的槽)"); + CHECK(s_prof[TASK_PROF_UI_PERIOD].in_seg == 1u, + "TC-TASKPROF-019d 上报不消费段状态(只观测)"); + + /* 020:count 是累计量,两轮上报之间不能清零 */ + reset_all(); + task_prof_sample(TASK_PROF_UI_WORK, 100u); + g_tick = 5000u; + task_prof_report(); + g_tick = 7000u; + task_prof_report(); + CHECK((s_prof[TASK_PROF_UI_WORK].count == 1u) && (field_of(2, "uic=") == 0L) && + (field_of(0, "uic=") == 0L), + "TC-TASKPROF-020 上报不清零(UI_WORK 不在 uic= 字段,仍为累计 1)"); +} + + +/* ===================== 入口 ===================== */ + +int main(void) +{ + printf("=== Task Prof Tests ===\n"); + + tc_sample_gate(); + tc_enter_exit(); + tc_report_throttle(); + tc_line_format(); + + printf("\n=== Task Prof Tests: %d/%d PASSED (failed=%d) ===\n", + s_passed, s_total, s_failed); + return (s_failed == 0) ? 0 : 1; +}