From 2bfa09ed15805ed889b04eae08c3f8d49f8d381f Mon Sep 17 00:00:00 2001 From: Wanderingss <1624155937@qq.com> Date: Fri, 23 Oct 2026 11:46:13 +0800 Subject: [PATCH 01/15] =?UTF-8?q?feat(=E9=97=A8=E7=A6=81):=20=E4=B8=BB?= =?UTF-8?q?=E6=9C=BA=E5=8D=95=E6=B5=8B=E8=A6=86=E7=9B=96=E7=8E=87=E8=84=9A?= =?UTF-8?q?=E6=9C=AC=E4=B8=8E=E5=8F=A3=E5=BE=84=E8=90=BD=E5=9C=B0=EF=BC=88?= =?UTF-8?q?=E9=94=80=E6=8A=A5=E5=91=8A=20=C2=A74.8=20=E7=A9=BA=E7=99=BD=20?= =?UTF-8?q?=E2=91=A0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 落地物: - scripts/check_test_coverage.py:发现规则与 L0.5 同一条(test/host/**/ run_unit_test.py),直接 import 各运行器复用其 TestBuild 与 unit_test_harness._BASE_FLAGS —— 配置零复制,不会漂出第二套口径; 只算自研生产源码(App/**、Drivers/BSP/**)的行/函数、多套取并集、 生成物(文件头写"自动生成/请勿手改")不进分母、分母一律来自插桩 报告不猜行数;中间物全在 %TEMP%,不新增 .gitignore 规则。 - scripts/test_coverage_baseline.json:增量拦截台账(只拦回退不拦进步, 与 layer-deps / bsp-err 同姿态);ci_local.py 加 opt-in --coverage。 - docs/测试说明/主机单测覆盖率口径.md:口径 + 工具链实测 + 九条盲区。 - 报告 §0.1 / §0.4 / §4.8 / §5.5 / §7 改写 + 变更记录 + 去处表。 工具链是实测出来的(换机必读):Windows 原生侧无任何 gcc/gcov(全盘扫 Program Files*、msys64、scoop、choco、.platformio、LocalAppData = 0 处); MSVC 14.51 已砍 /coverage,且只报 D9002 后照常编译成功 = 静默假数据; CodeCoverage.exe 17.0 三个子命令只打印用法。最终用 mscoverage 18.x (Microsoft.CodeCoverage.Console.exe)collect -f cobertura 做加载期动态 插桩,并实测它透传子进程退出码 → "插桩运行下仍必须 PASS"可当硬条件。 首登数字:16/16 套插桩下全绿,并集 line=2552/2893(88.2%)、 func=193/207(93.2%)、19 个文件在测、**未触及 125 个生产源文件 / 约 11,839 行**——最后一栏才是这套口径真正照出来的东西。 门禁自证:人为抬高基线后 行/函数/总计 三条回退路径都报 REGRESS 且 退出码 1;--module 子集跑不再假报总计回退(首版会,已修:并集口径下 子集必然变小,全部回退判定只在全量跑时生效)。 GCC 追问的复测结论(口径文档 §2.1 决策记录):本机 WSL2 Ubuntu 确实有 gcc/gcov 15.2.0,16 套单测在 gcc 下全部编过、用例数与 MSVC 逐个一致, gcov -b -c 也确实给出分支覆盖(nand_ftl.c 分支至少走过一次 41.40%); 但换编译器=换分母(sha256.c gcc 105 行 vs MSVC 114 行;adc_input.c 101 vs 120)。**用户裁定:本仓只留 MSVC 一条覆盖率通道**,不建第二条—— 多一套工具链就多一套分母、多一本台账、多一个"这台机器到底有没有 WSL/ gcov"的未决项,换来的只有分支这一维。分支覆盖因此**记为已接受缺口** (不是待办):绿灯只证明行与函数被测到,补偿判据 = TC 双向追溯 + 用例显式列边界值;§8 写明重开该裁定的前提是先答"两份分母怎么对齐"。 刻意未挂 pre-commit:16 个单测钩子已经跑过同一批测试;依赖 VS 可选 组件(缺组件时 skip 会静默失效、FAIL 会拦换机);待一个真实回退事件。 YAML 片段与理由写在口径文档 §8。 自查:pre-commit run --files(6 个改动文件)全 Passed;check_doc_links --all 回到存量 35 处(本分支贡献 0);check_ids.py --strict PASS; check_mojibake + BOM/CR/U+FFFD/行尾卫生逐项自查全净。本轮零 .c/.h 改动 (纯 .py/.json/.md),故未跑 @build、未占 build/bench 锁。 --- docs/README.md | 1 + docs/测试说明/主机单测覆盖率口径.md | 200 ++++++ docs/问题记录/项目审查与优化建议报告.md | 20 +- scripts/check_test_coverage.py | 844 ++++++++++++++++++++++++ scripts/ci_local.py | 31 + scripts/test_coverage_baseline.json | 825 +++++++++++++++++++++++ 6 files changed, 1915 insertions(+), 6 deletions(-) create mode 100644 docs/测试说明/主机单测覆盖率口径.md create mode 100644 scripts/check_test_coverage.py create mode 100644 scripts/test_coverage_baseline.json diff --git a/docs/README.md b/docs/README.md index e2665c9e..75bd9909 100644 --- a/docs/README.md +++ b/docs/README.md @@ -134,6 +134,7 @@ docs/ | [`相量层单元测试说明.md`](测试说明/相量层单元测试说明.md) | phasor 全周傅氏系数表 / 幅值·相位 / 谐波抑制与提取 / 频偏 / 采样环 / 双区交换,57 用例 | | [`卷锁单元测试说明.md`](测试说明/卷锁单元测试说明.md) | vol_lock 创建 / 获取 / 释放契约(**fail-open 不变量**:创建失败仍返回 1 且零阻塞)/ 幂等 / 超时透传,33 断言点 | | [`时间同步单元测试说明.md`](测试说明/时间同步单元测试说明.md) | time_sync 分片喂狗延时 / 监听退避 / 时间包重组 / 任务主循环(**N-17 的全部逻辑贡献者**),80 断言点 | +| [`主机单测覆盖率口径.md`](测试说明/主机单测覆盖率口径.md) | **跨全部 16 套 L0.5 的覆盖率**:`scripts/check_test_coverage.py` 用法 / 统计面与生成物例外 / 基线增量门禁(只拦回退)/ **为什么本机拿不到分支覆盖** / 九条限制与盲区 / 未挂 pre-commit 的三条理由与待粘 YAML | **模块测试指南**(动手写测试前先读) diff --git a/docs/测试说明/主机单测覆盖率口径.md b/docs/测试说明/主机单测覆盖率口径.md new file mode 100644 index 00000000..bc3bf7d7 --- /dev/null +++ b/docs/测试说明/主机单测覆盖率口径.md @@ -0,0 +1,200 @@ +# 主机单测覆盖率口径(覆盖率脚本 / 基线 / 盲区) + +> **状态**:✅ 已落地(脚本 + 基线 + 口径),⏸ 未挂 pre-commit(理由见 §8); +> **分支覆盖**:本机可实测拿到(§2.1 的 gcc/WSL 路),但**用户裁定 2026-10-23 不引入第二条工具链** +> → 本文把它记为**已接受缺口**,不是待办 +> **日期**:2026-10-23 +> **脚本**:[`scripts/check_test_coverage.py`](../../scripts/check_test_coverage.py)(纯标准库) +> **基线台账**:[`scripts/test_coverage_baseline.json`](../../scripts/test_coverage_baseline.json) +> **关联文档**: +> [`测试开发标准流程.md`](测试开发标准流程.md)(L0~L3 分层与门禁决策表)、 +> [`../问题记录/项目审查与优化建议报告.md`](../问题记录/项目审查与优化建议报告.md) §4.8(本文关闭它的空白 ①) +> 与 [`../问题记录/项目审查与优化建议报告.md`](../问题记录/项目审查与优化建议报告.md) §5.5 第 5 条的前半、[`../代码规范/pre-commit使用说明.md`](../代码规范/pre-commit使用说明.md)(钩子通用机制) + +--- + +## 1. 一句话口径 + +**行覆盖 = 至少执行过一次的可执行行 / 被插桩的可执行行**,统计面只算自研生产源码 +(`App/**`、`Drivers/BSP/**`),多套单测对同一文件的命中取**并集**;函数覆盖同理按 +"至少有一行命中"计。**没有分支覆盖**——本机拿不到(§2),这一点决定了"绿"能说明什么、 +不能说明什么(§7)。 + +## 2. 工具链现实:为什么是 mscoverage,不是 gcov + +2026-10-23 本机(VS 2026 / MSVC 14.51)逐项实测,**换机请按同一顺序重探一遍**: + +| 候选 | 实测结果 | 结论 | +|---|---|---| +| gcc / gcov / clang / llvm-cov / lcov / gcovr(**Windows 原生侧**) | PATH 上没有;再全盘扫 `Program Files*`、`C:\msys64`、`scoop`、choco、`.platformio`、`%LOCALAPPDATA%\Programs` → **0 处**(OpenCppCoverage 也没有) | Windows 侧走不了 gcov 这条最主流的路(但 WSL 里有,见 §2.1) | +| `cl /coverage`、`cl /fsanitize=coverage` | **MSVC 14.51 已不认**:只报 `warning D9002: 忽略未知选项`,**然后照常编译成功** | ⚠ 最阴的一种:不报错、静默没有插桩 → 覆盖率恒为 0 或假数据,绝不可用 | +| `Team Tools\Dynamic Code Coverage Tools\CodeCoverage.exe`(17.0) | 文件在,但 `collect` / `analyze` / `instrument` **一律只打印"用法: CodeCoverage "** 并返回 -1 | 已 deprecated 且子命令不可用 | +| **`Microsoft.CodeCoverage.Console.exe`**(mscoverage 18.x,随 VS 装在 `Common7\IDE\Extensions\Microsoft\CodeCoverage.Console\`) | `collect -f cobertura` 直接吃 MSVC 原生 exe + PDB,产出标准 Cobertura XML;**子进程退出码原样透传**(实测子进程 `return 3` → `collect` 也返回 3) | ✅ **本脚本用它**("加载期动态插桩",不是编译期插桩) | + +### 2.1 GCC 到底行不行:能编、能跑、**能出分支覆盖**——结论是**不用**(换编译器就是换分母) + +本机 **WSL2 Ubuntu 里有 gcc / gcov 15.2.0**(`wsl -d Ubuntu`,仓库经 `/mnt/d` 可见)。2026-10-23 实测: + +| 测项 | 实测结果 | +|---|---| +| 16 套单测用 `gcc -std=c11 -O0 -g --coverage` 编译 | **全部编过、全部 PASS**,用例数与 MSVC 逐个一致(45/103/151/46/36/97/149/57/47/90/13/32/100/82/33/39)——**可移植性不是障碍** | +| `gcov -b -c` | ✅ 行覆盖之外**还给出分支**:`nand_ftl.c` 行 45.49% / 分支至少走过一次 **41.40%(157 支)**;`cfg_file.c` 行 86.72% / 分支 79.69%(512 支);`sha256.c` 行 97.14% / 分支 82.00%(50 支) | +| 代价一:**分母变了** | 同一文件的"可执行行"两个编译器不同:`App/util/sha256.c` gcc 105 行 vs MSVC 114 行;`nand_ftl.c` 244 vs 253;`adc_input.c` 101 vs 120 → **两套数字两套台账,绝不能混在一张表里比涨跌** | +| 代价二:链接也必须带 `--coverage` | 只给编译加、链接不加 → `undefined reference to __gcov_init`(实测踩过,报错还不指向原因) | +| 代价三:环境不归仓库管 | WSL 是「这台机器装了」,不是「这个仓库保证有」;CI runner 虽是同一台机器,还得先让 WSL 起来,且 `gcov` 在 `/mnt/d`(drvfs)上读写 `.gcda/.gcno` 很慢 → 工作目录要放 WSL 本地 | + +**决策记录——默认通道仍是 MSVC + mscoverage,三条理由**:① 与门禁**同一份编译开关**(原样复用 +`unit_test_harness._BASE_FLAGS`),台账涨跌直接对应「门禁那份改动」;② 不依赖 WSL 起没起; +③ 零下载、零新依赖、纯标准库解析 Cobertura。**分支覆盖不做第二条通道(用户裁定 2026-10-23,本仓只留 MSVC 一条)**: +多一套工具链就多一套分母、多一本台账、多一个"这台机器到底有没有 WSL/gcov"的未决项,而换来的只有 +分支这一维——不值当。代价如实记在 §7 第 1 条:本口径的绿灯**只证明行与函数被测到**。 + +探测顺序:环境变量 `DTU_MS_COVERAGE`(设了就必须有效,**不静默回退**,与 `DTU_MSVC_CL` 同口径) +→ 各 VS 安装根的 `Common7\IDE\Extensions\Microsoft\CodeCoverage.Console\` → PATH。 +找不到即以退出码 2 失败并打印上述现实,**不会**假装通过。 + +## 3. 怎么跑 + +```powershell +# 全量:16 套单测逐套"编译(带 PDB)→ 插桩运行 → 解析 Cobertura",最后并集 + 比基线 +& '.venv\Scripts\python.exe' scripts/check_test_coverage.py + +# 只跑一套(调试用;子集会跳过"在测文件消失"类回退判定,见 §5) +& '.venv\Scripts\python.exe' scripts/check_test_coverage.py --module sha256 + +# 刷新基线(改完测试或改完被测源码、确认过数字之后) +& '.venv\Scripts\python.exe' scripts/check_test_coverage.py --update-baseline + +# 出报告(默认只打控制台;报告是一次性快照,放临时目录,不入库) +& '.venv\Scripts\python.exe' scripts/check_test_coverage.py --md $env:TEMP/cov.md --json $env:TEMP/cov.json +``` + +**与 L0.5 同一套发现规则**(`test/host/**/run_unit_test.py`,与 `scripts/ci_local.py` 一致), +并且**不复制任何编译配置**:脚本 import 各模块运行器、直接复用其声明式 `BUILD`(`TestBuild`), +编译开关取 `unit_test_harness._BASE_FLAGS`,只追加 `/Zi /Od` 与 `/link /INCREMENTAL:NO`。 +所以"include 目录 / 桩顺序 / 测试单元路径"只有一处定义,覆盖率脚本不会漂成第二套口径。 + +退出码:`0` 全过且无回退 / `1` 有单测失败或覆盖率回退 / `2` 环境错误(无 cl、无 mscoverage、 +编译失败、XML 缺失)/ `3` 用法错误。全量实测约 **25 s**(16 套 × 编译 + 插桩运行)。 + +中间产物(exe / PDB / Cobertura XML)一律落 `%TEMP%\dtu_test_cov\<模块>\`,**不进仓库**, +所以本脚本不需要新增任何 `.gitignore` 规则。 + +## 4. 统计面:进什么、不进什么 + +| | 处理 | 依据 | +|---|---|---| +| `App/**`、`Drivers/BSP/**` 的 `.c`/`.h` | **计入** | 自研生产源码,是唯一有意义的覆盖率对象 | +| `test/host/**`(测试单元自身与 `stub/`) | 不计 | 它们不是被测面;测桩的"覆盖率"是自我安慰 | +| `Lib/`(LVGL / FatFs / dhara)、`Middlewares/`、HAL / CMSIS | 不计 | 第三方;与 `cppcheck` / `coding-standard` 的排除面同口径 | +| Windows SDK / UCRT / MSVC 头 | 不计 | 同上(脚本按"仓库内 + 前缀"双重过滤) | +| **生成物**(文件头 15 行内含"自动生成 / 请勿手改 / DO NOT EDIT") | **不进分母**,单列打印 | 现有 5 个:`ui_font_cn_12.c`、`ui_menu_tab.c/.h`、`ui_page_registry.c/.h`。给生成表算覆盖率只会稀释数字、照不出问题 | + +分母一律取"**插桩报告里出现的行**",**不做任何静态行数猜测**;`--md` 报告里"有效代码行" +那一列只用于未触及面的体量提示,不参与任何百分比。 + +## 5. 门禁姿态:只拦回退,不拦进步 + +台账 = [`scripts/test_coverage_baseline.json`](../../scripts/test_coverage_baseline.json) +(`--update-baseline` 生成,含 `totals` / `modules` / `files` / 未触及清单)。 + +| 情况 | 判定 | 说明 | +|---|---|---| +| 某文件 `lines_covered` 或 `funcs_covered` 比基线**少** | ❌ **REGRESS**(退出码 1) | 删了用例,或改代码让原来到达的行到不了——这才是要拦的 | +| 总计 `lines_covered` 比基线少 | ❌ REGRESS | 兜住"搬家式下降" | +| 基线里在测的文件本次整个消失(仅全量跑时判) | ❌ REGRESS | 通常是 include 链被删,或那套单测被跳过 | +| 基线里某套单测本次没跑到 / 没通过 | ❌ REGRESS | 覆盖率只在**同一套测试全绿**的前提下可比 | +| 新文件被纳入统计 | 🟡 NOTE | 进步不拦,但确认无误后要 `--update-baseline` 登记 | +| 某文件可执行行数暴涨(>1.3× 基线且 +120 行) | 🟡 NOTE | 百分比会被摊薄,提醒刷基线,免得把"没退"当成退 | +| `--module` 子集运行 | 🟡 NOTE | **跳过全部覆盖率回退判定**(并集必然变小,不是回退):只保留「单测必须通过」与「新文件登记提示」两项;判回退必须跑全量 | + +与 `layer-deps` / `bsp-err` / `id-check` 同属**增量拦截**:**绿灯只等于"没有比上次更差"**。 + +## 6. 首次实测(2026-10-23,基线首登值) + +``` +[SUMMARY] coverage: line=2552/2893 (88.2%) func=193/207 (93.2%) suites=16/16 tested_files=19 untested_files=125 +``` + +| 事实 | 数字 | +|---|---| +| 16 套主机单测 | 全部通过(插桩运行下仍 PASS) | +| 被测到的自研生产源文件 | **19 个** / 2,893 可执行行 | +| 行覆盖最低的两个 | `Drivers/BSP/GD5F2GQ5UE/nand_ftl.c` 48.6%(123/253,函数 14/22)、`App/protocol/iec104/iec104_pts.c` 67.2%(117/174,函数 13/17) | +| 行覆盖 95%+ 的文件 | 12 个(多为纯逻辑层:`adc_health` / `sample_ring` / `vol_lock` / `time_sync` / `task_watchdog` …) | +| **从未进过任何主机单测的生产源文件** | **125 个 / 约 11,839 有效代码行**(前五:`net_socket.c` 1,251、`ch395f.c` 801、`lftpd.c` 694、`gd5f2gq5ue.c` 689、`sys_log_mcu.c` 458) | + +⚠ 读法:88.2% 是"**被测的那 19 个文件内部**"的比率,**不是全仓覆盖率**。全仓的真相写在 +`untested_files` 这一栏(125 个),它比 88% 这个数字有用得多——也是 §7 第三条的由来。 + +## 7. 限制与盲区(读这篇文档第一眼看这节) + +1. **本文这条通道没有分支覆盖。** mscoverage 导出的 Cobertura 里 `branch` 恒为 `False`(实测), + MSVC 又已砍掉 `/coverage`。含义:`if (a && b)` 只走过一侧,这一行也算 100% 覆盖。 + → 报告 §5.5 想要的"覆盖到哪些分支"**没有被本文满足**,被满足的是"哪些行根本没进过"。 + 分支覆盖本机**能拿到**(§2.1 的 gcc + `gcov -b -c`),但**已裁定不引入第二条工具链** → + 它就是本口径的**已知缺口**:绿灯只代表行与函数被测到,`if (a && b)` 的另一半要自己读用例。 + 补偿判据:TC 双向追溯 + 用例侧显式列边界值(`docs/测试说明/测试开发标准流程.md` §5); + 真到要分支证据那天,先看 §2.1 的三条代价再决定,别顺手装个 `dotnet-coverage` 当第二套口径。 +2. **0 分母 ≠ 0%。** 125 个文件从没被任何主机单测编译过,它们**不在**覆盖率里;把"88%"读成 + "仓库八成代码被测"是最常见的误读。 +3. **桩会重塑被测面。** `nand_port` / `adc_input` / `cfg_file` 等用 `stub/` 顶掉真实 HAL 与日志头, + 统计的是"生产 `.c` 在桩环境下可达的行"。真机才走的分支(寄存器超时、DMA 回调、SPI 错误) + 在主机侧**永远 0 命中**——它们会拉低百分比,但**不是缺陷**,别为凑覆盖率造假桩。 +4. **`/Od` ≠ 真机 `/O1`。** 覆盖率构建只为行映射服务,优化级别与 Keil 产物不同, + 不能由它推断真机的可达性或时序。 +5. **插桩会改变时序。** 动态插桩让被测程序变慢,对真实 tick/延时敏感的逻辑理论上可能被拖出 + 不同路径。脚本的处置是**硬要求"插桩运行仍必须 PASS"**(汇总行 + 退出码双校验)—— + 宁可红,也不给可疑的数字。 +6. **只覆盖跑到的进程树。** 一次 `collect` 只看一次运行;没跑到的套件不进分母 + (全量跑时以"模块未跑到"判 REGRESS),子集运行明确降级(§5)。 +7. **头文件里的代码归 `.h`。** 统计面含 `.h`(静态内联与被展开的行落在头文件上);当前 16 套 + 几乎没触及 `.h`,表里看不到它们——不要以为头文件"天生 100%"。 +8. **依赖 VS 的可选组件。** 只有 BuildTools 且没勾"代码覆盖率工具"的机器会退出码 2; + CI runner 与本机同一台(`docs/CI/README.md`),但**换机先跑 `--list` 再跑全量**。 +9. **并集口径下"单套百分比"没有意义。** 同一文件被多套触及很常见(`sha256.c` 被 `sha256` 与 + `ftp_auth` 都编译),所以对外只报**并集**,单套只报"触及几个文件 / 多少行"。 + +## 8. 接线状态与下一步 + +**已落地**:脚本、基线台账、口径(本文)、`scripts/ci_local.py --coverage` 一条入口。 + +**未挂 pre-commit**,三条理由(不是忘了): + +1. 39 个钩子里已有 16 个主机单测钩子(每套各编译各跑),再加一份"全量插桩"是**同一件事做两遍**, + 还要多约 25 s; +2. 它依赖 VS 可选组件(§7 第 8 条)。挂之前必须先定"缺组件时 skip 还是 FAIL"—— + 选 skip 就是一个会静默失效的门禁(与 `AGENTS.md` 里那条 `always: true 已失效、必须靠 files: 覆盖` + 是同一类坑); +3. 基线首登后需要**真实回退事件**才算被验证过。已做的是**合成自证**(人为抬高 `test_coverage_baseline.json` + 的三处数字后全量跑):行 / 函数 / 总计三条回退路径都报 `[REGRESS]` 且退出码 1;`--module` 子集运行 + 不会假报回退(首版会,已修:并集口径下子集必然变小,所以 `--module` 子集跑跳过全部回退判定)。 + 真实事件(某次改动真把覆盖行弄少了)仍待观察。 + +真到接线那天,片段形状如下(这份 YAML 由接线者写进 `.pre-commit-config.yaml`,说明写进 +`docs/门禁说明/` 对应那篇,不在本文维护): + +```yaml +- repo: local + hooks: + - id: test-coverage + name: 主机单测覆盖率(增量拦截:已覆盖行只增不减) + entry: python scripts/check_test_coverage.py + language: system + files: '^(App/|Drivers/BSP/|test/host/|scripts/check_test_coverage\.py|scripts/test_coverage_baseline\.json)' + pass_filenames: false +``` + +**下一步(按性价比排序)**: + +1. 给 `net_socket.c` / `lftpd.c` / `ch395f.c` 这类"125 个未触及"里最大的几块建主机侧桩—— + 比把 19 个文件从 88% 推到 95% 值钱得多(覆盖率只照"进没进过",建桩本身是这个动作里最贵的部分)。 +2. **不做**:第二条覆盖率通道(gcc/WSL、`dotnet-coverage`、OpenCppCoverage 三条路都不走,用户裁定 + 2026-10-23)。要重开这个裁定,前提是能同时给出"两份分母怎么对齐"的答案,否则只是多一本对不上的账。 + +## 变更记录 + +| 日期 | 说明 | +|---|---| +| 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(` 正则那条仍开放。**同日补 §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` 一条路 | diff --git a/docs/问题记录/项目审查与优化建议报告.md b/docs/问题记录/项目审查与优化建议报告.md index 60191493..fcff79e4 100644 --- a/docs/问题记录/项目审查与优化建议报告.md +++ b/docs/问题记录/项目审查与优化建议报告.md @@ -19,7 +19,7 @@ | 文档与治理 | 🟢 良好 | 39 个提交钩子 + 16 套主机单测;工程 md 91 份;`doc-links` 全库 0 问题 | 新增 `编号门禁增量拦截` 与内存快照回填常态化 | | 内存与 Flash | 🟡 可控但已换位置 | SRAM1 **57.4%**(余 55,808 B)、Flash **22.1%**、**CCM 87.9%(余 7,904 B)** | **红线从 SRAM1 迁到 CCM**(堆 2026-10-11 迁 CCM + 池 40→16 KB 后 SRAM1 已宽松) | | 可靠性与自恢复 | 🟢 良好 | 4 个 Fault 全采集现场;IWDG 五面 `required_mask`;复位原因与 `[STACK]` 水位常开 | 台架复跑持续 PASS(今日真机 708 ms 起网、无 `[ERR]`/Fault) | -| 测试与验证 | 🟡 半办 | 16 套主机单测 + `ui-golden`/`font-charset` 入门禁;TC 双向追溯 593∶271 交集 265 | **仍无覆盖率**、`coding-standard` **仍无 `malloc` 正则** | +| 测试与验证 | 🟡 半办 | 16 套主机单测 + `ui-golden`/`font-charset` 入门禁;TC 双向追溯 593∶271 交集 265;**覆盖率口径已落地**(`../测试说明/主机单测覆盖率口径.md`:16 套并集 2552/2893 行) | `coding-standard` **仍无 `malloc` 正则**;覆盖率**未挂 pre-commit**(口径文档 §8 三条理由)、**没有分支覆盖** | | UI 标准化 | 🟢 基本落地 | `ui_tokens.h`(L0 令牌)+ `ui_page_registry.c`(描述符注册表)+ 7 个内容页 + 两枚 UI 钩子 | **U1/U3/U4 从"未做"变为已落地**;U2 只有 `ui_w_records` 一个组件 | ### 0.3 主线建议(2026-10-22) @@ -67,7 +67,7 @@ B0h 验 `ECC_EN`**——原先那两行的注释自己写着"否则后续 I/O | 存储/日志 | P2-10 ❌(N-25) | 卷锁现有 5 个使用者(配置原子写 / 日志 / SOE / lftpd_io / defaultTask),容量轮转删除与 UI 读并发仍未全覆盖 | 登记簿 S-5 面(卷锁覆盖面) | | 仓库 | D-1 ❌(N-29) | jpg 707 个 / 37.3 MB;`docs` 45.3 MB 里芯片手册占 39.5 MB;`.git` **248 MB** | 降采样 / 拆 submodule / LFS 三选一 + 历史重写;已决策暂不做 | | UI | U2 残余(U 簇) | 组件库只有 `ui_w_records.c/h` 一个;其余仍是页内自绘 | 按产品需求决定要不要继续抽组件 | -| 测试 | 覆盖率 ❌ / `malloc` 门禁 ❌ | 无覆盖率脚本;`check_coding_standard.py` 无 `malloc(` 正则 | 见 §5.5 与 §4.8 | +| 测试 | ~~覆盖率~~ ✅ **2026-10-23 落地** / `malloc` 门禁 ❌ | 覆盖率:`scripts/check_test_coverage.py` + 基线 `scripts/test_coverage_baseline.json`(口径与**盲区**见 `../测试说明/主机单测覆盖率口径.md`)——首登 16 套并集 2552/2893 行(88.2%)、**未触及 125 个生产源文件 / 约 11.8k 行**;`check_coding_standard.py` 仍无 `malloc(` 正则 | 剩 `malloc(` 正则 + 覆盖率挂钩子(先定「缺 VS 组件时 skip 还是 FAIL」);见 §5.5 与 §4.8 | | CI | T-7 ❌ | 远程 CI 只跑本机一台 runner,且不跑 `@build` | 保持现状即可;扩机器前重读 `docs/CI/README.md` | ## 1. 审查方法与证据基线 @@ -226,7 +226,7 @@ defaultTask 单属主;原子写经卷锁;旧档迁移已删除。**仍列为 - 主机侧 **16 套**单测(提交门禁实跑);板级 `test/board/` 19 文件、套件开关互斥受 `test-suite-switch` 管 - TC 双向追溯:代码侧 593 号 / 文档侧 271 号 / 交集 265,差异 13 处(当前仅告警,未升门禁) -- **两处空白**:① 无覆盖率口径;② `check_coding_standard.py` 没有 `malloc(` 正则——"自研禁用 C 库堆" +- **覆盖率口径已落地(2026-10-23)**:`scripts/check_test_coverage.py`(mscoverage 动态插桩 + Cobertura 并集) 这条铁律目前只靠人读 + 模拟器照不出来(MicroLIB C 堆仅 512 B) - 破坏性套件(`ENABLE_DESTRUCTIVE_TESTS`)有**持久代价**(NAND 坏块记账不可逆),默认关闭是正确设计 @@ -285,8 +285,11 @@ defaultTask 单属主;原子写经卷锁;旧档迁移已删除。**仍列为 2. `check_mem_numbers.py --scan/--write`:文档里的内存数字一律由 `.map` 反查,禁止手填 3. 改栈 / 加大局部变量前后各跑一次 `stack_report.py`(静态 × 真机 `[STACK]`) 4. 子系统配额与 CCM 台账 → `../内存管理/RAM预算台账.md`;趋势 → `scripts/ram_baseline.tsv` -5. **仍未落地的两条补强**:`check_coding_standard.py` 加 `malloc(` 正则(把 C 库堆禁令变成机器拦截)、 - 覆盖率口径(现在只有"跑过哪些 TC",没有"覆盖到哪些分支") +5. **仍未落地的一条补强**:`check_coding_standard.py` 加 `malloc(` 正则(把 C 库堆禁令变成机器拦截)。 + 另一条「覆盖率口径」✅ **已落地 2026-10-23** → `../测试说明/主机单测覆盖率口径.md`(口径 / 基线 / 盲区 + 一处写完,此处不复述)。⚠ 它落地的是**行与函数**覆盖,**分支覆盖本机拿不到**(无 gcc,MSVC 14.51 + 已无 `/coverage`)——「覆盖到哪些分支」这一半**已裁定不补**(不为它引入第二套工具链,见 + `../测试说明/主机单测覆盖率口径.md` §2.1),当前补偿 = TC 双向追溯 + 用例显式列边界值 ## 6. UI 界面标准化:从方案到已落地 @@ -331,7 +334,7 @@ W6 簇按 U4 → U1 → U3 → U2 的顺序执行完毕(U2 停在"够用即止 |---|---|---| | ~~W3 预算与内存~~ ⏸ | R-9 ✅ / R-2 后半、R-3、R-4、R-6 暂缓 | 暂缓期间无验收动作,观测照常 | | W4 结构债 | N-14 ⏸、F-5 🟡、S-1/S-2/S-4/S-5、N-25 | `--strict` 已默认;S-5 契约/门禁已落地并收编三模块,`consume-result` 已把"丢弃返回值"纳入机械检查(基线 19→1→38;剩 GD5F 码集收编、C4 快照、37 条丢弃点、标坏策略) | -| W5 验证常态 | 覆盖率、`malloc` 正则、TC 差异 13 处升门禁 | 逢窗口顺手做 | +| W5 验证常态 | ~~覆盖率~~ ✅ 2026-10-23、`malloc` 正则、TC 差异 13 处升门禁 | 逢窗口顺手做 | | W6 UI 扩展 | U2 残余、U5 页量 | 随产品需求,不再单列专项 | | W7 仓库决策 | D-1(N-29) | 已决策暂不处理;对外交付前重评 | @@ -448,6 +451,10 @@ admin/admin(SHA-256 `pwd_hash`)→ 该脚本**必然登录失败**;它是 | | 数字联动照做:两份 `data/` 快照重跑、`ram_baseline --update/--write`、`check_mem_numbers --write`、FLASH 与 任务栈两张配图重算重生成(逐文本复查越界 = 0)、本文 §1.3/§5.1/§5.4 与部署文档 §3.3 同步;§0.4 的 S-2 行按 「完成即销项」改写、§9 拆出 S-2 行标 ✅。 | +| **2026-10-23** | **执行 §5.5 第 5 条前半:覆盖率口径落地并销项**(§4.8 空白 ①) | 新增 `scripts/check_test_coverage.py`(纯标准库)+ 基线台账 `scripts/test_coverage_baseline.json`。**工具链是实测出来的、不是查到的**:本机 PATH 上 gcc/gcov/clang/llvm-cov/lcov/gcovr **全无**;`cl /coverage`、`/fsanitize=coverage` 在 MSVC 14.51 已被砍——**只报 `warning D9002 忽略未知选项`,然后照常编译成功**(拿它做覆盖率=静默假数据,比报错更坏);`Team Tools\Dynamic Code Coverage Tools\CodeCoverage.exe`(17.0)的 `collect`/`analyze`/`instrument` 一律只打印「用法: CodeCoverage 」。最终走 **mscoverage(`Microsoft.CodeCoverage.Console.exe` 18.x)`collect -f cobertura`** 的**加载期动态插桩**,并实测它**原样透传子进程退出码**(子进程 `return 3` → `collect` 也返回 3)→ 于是「插桩运行下仍必须 PASS」可以当硬条件用。 | +| | | **口径**:只算 `App/**` 与 `Drivers/BSP/**` 的行/函数、多套单测取**并集**、生成物(文件头写「自动生成 / 请勿手改」)不进分母、分母一律来自插桩报告**不猜行数**;配置零复制——脚本 import 各运行器的 `TestBuild`、开关取 `unit_test_harness._BASE_FLAGS`,只追加 `/Zi /Od` 与 `/link /INCREMENTAL:NO`,发现规则与 L0.5 同一条 `test/host/**/run_unit_test.py`,**不会漂出第二套口径**。 | +| | | **首登数字(2026-10-23)**:16 套全通过,并集 2552/2893 行(88.2%)、193/207 函数(93.2%)、19 个生产文件在测,**未触及 125 个生产源文件 / 约 11,839 行**(`net_socket.c` 1,251 行最大)——最后一栏才是这套口径真正照出来的东西。**门禁自证**:人为抬高基线后,行 / 函数 / 总计三条回退路径都如期报 REGRESS 且退出码 1;`--module` 子集运行不会假报总计回退(首版就会,已修)。**刻意未挂 pre-commit**:三条理由与可直接粘的 YAML 片段写在 `../测试说明/主机单测覆盖率口径.md` §8,接线者照 §8 落地即可。 | +| | | **仍开放**:§5.5 第 5 条的另一半(`check_coding_standard.py` 加 `malloc(` 正则)与**分支覆盖**。分支覆盖**不是拿不到**:同日被用户问住后复测,本机 WSL2 Ubuntu 有 gcc/gcov 15.2.0,16 套单测在 gcc 下**全部编过且全绿**、`gcov -b -c` 出分支数据(`nand_ftl.c` 分支至少走过一次 41.40%);但换编译器就是换分母(`sha256.c` gcc 105 行 vs MSVC 114 行)→ **用户裁定只保留 MSVC 一条通道**(多一套工具链=多一套分母 +多一本台账 + 多一个"这台机器有没有 WSL"的未决项),分支覆盖**记为已接受缺口**、不是待办。实测表与决策依据见 `../测试说明/主机单测覆盖率口径.md` §2.1。本轮只加 `.py`/`.json`/`.md`,无 `.c/.h` 改动,不重跑 `@build`;文档面动 §0.1 / §0.4 / §4.8 / §5.5 / §7 与 `docs/README.md` 索引各一处。 | | **2026-10-23** | **术语迁移**:`bsp-err` 的四项检查从 `R1/R2/R3/R4` 改成语义标签 | 用户一句「R4 | | 到底是什么」问出来的。这四个号**是我自己造的**,而 `R1~R8` 在本仓是"专项风险"的裸形编号 | | (`id_registry.json` 已登记那一族,保护规划 `R1~R8`、看门狗 `R1~R7` 的风险表都在用),`R-2` @@ -657,3 +664,4 @@ USER CODE 区内只留「读 RCC_CSR → 写 RMVF 清除」两句、顺序约束 | **R-9(`LV_MEM_SIZE` 40→16 KB,登记簿 N-27 原 W3 的第一步,2026-10-15 已执行;该簇其余部分同日整簇暂缓)** | 取值依据与再降 8 KB 的前置写在 `Lib/lv_conf.h` 与 `simulator/lv_conf.h` 的池注释;实测水位 → `../内存管理/RAM使用分析与优化方案.md` §2.6.2 与 §阶段 1.1(已执行);解锁链第 ① 步 ✅(后续步骤暂缓)→ `../内存管理/RAM预算台账.md` §3;池容量口径 → `../UI设计/UI_设计架构.md` §1.2、`../UI设计/UI_界面制作流程.md` C4 | | **N-12 / N-13**(dhara 移植层 `is_free` 精度 + HAL 越界守卫,登记簿 **N-21**,2026-10-15 已销项) | `../架构设计/STM32F4-Base存储架构说明.md` §5.3(移植层两条硬约束:整页精确的原因链 + 错误码取舍与"为什么不会静默清卷")+ `Drivers/BSP/GD5F2GQ5UE/nand_ftl.c` 的 `ftl_page_in_range` / `ftl_block_in_range` / `dhara_nand_is_free` 注释 + 主机侧单测 `test/host/nand_port/`(含"旧算法只看页首 64 B"的对照用例,防止将来被改回去) | | **N-16**(日志读写放大,登记簿 **N-24**,2026-10-15 已销项) | `../子系统设计/运行与自检记录设计说明.md` §4.4(写侧批量合并 + **掉电语义不变**的论证 + 失败回投不丢条目 + 容量轮转只在慢路径)与 §4.5(读侧 8 条窗口 + 写者代数失效 + API 不变)+ `App/log/sys_log_mcu.c` 的 `s_format_rec` / `s_append_rec` / `s_append_many` / `s_read_raw` 注释 | +| **§4.8 空白 ①「无覆盖率口径」+ §5.5 第 5 条前半(2026-10-23 销项;§5.5 后半的 `malloc(` 正则仍开放)** | `../测试说明/主机单测覆盖率口径.md`(口径 / 工具链实测 / 统计面 / 门禁姿态 / **九条限制与盲区** / 未挂钩子的三条理由与待粘 YAML)+ `scripts/check_test_coverage.py`(`--list` / `--module` / `--update-baseline` / `--md` / `--json`)+ 基线台账 `scripts/test_coverage_baseline.json`(首登 2552/2893 行、未触及 125 文件) | diff --git a/scripts/check_test_coverage.py b/scripts/check_test_coverage.py new file mode 100644 index 00000000..a0493cb2 --- /dev/null +++ b/scripts/check_test_coverage.py @@ -0,0 +1,844 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +r""" +主机侧单元测试(L0.5)的**覆盖率**口径、报告与增量门禁(纯标准库)。 + +做什么: + 1. 发现 test/host/**/run_unit_test.py(与 scripts/ci_local.py 的 L0.5 同一套发现规则), + 直接 import 各模块运行器、复用其中声明的 TestBuild(**单一事实源**:include 目录、 + 桩顺序、测试单元路径都不在这里重复一遍); + 2. 用**与门禁完全相同的编译开关**(unit_test_harness._BASE_FLAGS)另建一份带 /Zi 调试 + 信息的产物,交给 Microsoft.CodeCoverage.Console.exe 动态插桩运行; + 3. 解析 Cobertura XML,只保留**自研生产源码**(App/**、Drivers/BSP/** 的 .c/.h)的行与 + 函数命中,对多套单测重复触及的同一文件取**并集**(命中一次即算覆盖); + 4. 与 scripts/test_coverage_baseline.json 比对:**已覆盖行/函数只许增不许减**;并打印 + 自研生产源文件里有多少从未被任何主机单测编译过(未触及面 = 覆盖率的 0 分母盲区)。 + +为什么要它(2026-10-23,W5 验证常态): + docs/问题记录/项目审查与优化建议报告.md §4.8 记的两处空白之一:无覆盖率口径—— + L0.5 只回答“跑过哪些 TC”,回答不了“生产源码里哪些行根本没进过”。用例数与 TC 追溯率 + 会随时间上升,但覆盖率照的是另一类真相:**新增分支一行没测**(N-12/N-13 那类“读代码 + 才发现”的缺陷,覆盖率是最便宜的机器照法)。 + +工具链现实(2026-10-23 本机实测,换机必读): + - **Windows 原生侧没有 gcc / clang / gcov / lcov / gcovr**(全盘扫 0 处)→ gcov 那条最主流的路在 + Windows 侧走不通。**但 WSL2 Ubuntu 里有 gcc/gcov 15.2.0**,实测 16 套单测在 gcc 下全部编过、 + gcov -b -c 还给出分支覆盖——不用它当默认是因为**换编译器=换分母**(sha256.c:gcc 105 行 vs + MSVC 114 行),详见 docs/测试说明/主机单测覆盖率口径.md §2.1 的决策记录。 + - **MSVC 14.51(VS 2026/18)已不认 /coverage**:cl 只报 warning D9002「忽略未知选项」, + 然后**照常编译成功** → 拿它做覆盖率等于静默出假数据,绝不可用(/fsanitize=coverage 同)。 + - Team Tools\Dynamic Code Coverage Tools\CodeCoverage.exe(17.0)还在但已 deprecated, + 且 collect / analyze / instrument 一律只打印「用法: CodeCoverage 」——不可用。 + - 可用的是 **Microsoft.CodeCoverage.Console.exe**(mscoverage 18.x,随 VS 装在 + Common7\IDE\Extensions\Microsoft\CodeCoverage.Console\):collect -f cobertura 直接吃 + MSVC 原生 exe + PDB,且**子进程退出码原样透传**(实测子进程 return 3 → collect 也返回 3)。 + - 结论:编译期插桩这条路在本机是断的,走**加载期动态插桩**(mscoverage collect)。 + +口径(一句话版,完整版见 docs/测试说明/主机单测覆盖率口径.md): + 行覆盖 = 至少执行过一次的可执行行 / 被插桩的可执行行; + 函数覆盖 = 至少有一行命中的函数 / 被插桩的函数; + 统计面 = 自研生产源码(App/**、Drivers/BSP/** 的 .c/.h),**不含**测试单元自身、 + test/host 下的桩、Lib/、Middlewares/、HAL/CMSIS、Windows SDK/UCRT/MSVC 头。 + +退出码: + 0 = 全部单测通过,且覆盖率相对基线没有回退 + 1 = 有单测失败,或覆盖率相对基线回退(已覆盖行/函数减少、在测文件或模块消失) + 2 = 环境错误(找不到 cl.exe / 找不到 mscoverage / 编译失败 / XML 缺失或解析失败) + 3 = 用法错误 + +用法(仓库根目录): + & '.venv\Scripts\python.exe' scripts/check_test_coverage.py # 全量跑 + 比基线 + & '.venv\Scripts\python.exe' scripts/check_test_coverage.py --module sha256 # 只跑一套(调试) + & '.venv\Scripts\python.exe' scripts/check_test_coverage.py --update-baseline # 跑完写回基线 + & '.venv\Scripts\python.exe' scripts/check_test_coverage.py --md <路径> --json <路径> + & '.venv\Scripts\python.exe' scripts/check_test_coverage.py --list # 只列模块 + +实测坑(都在这条链上踩过,勿重复排查): + - **必须 /Zi 且链接 /INCREMENTAL:NO**:mscoverage 靠 PDB 把指令映射回源行;增量链接会出 + .ilk、PDB 与代码不同步 → 行号漂移甚至整模块 0 覆盖。 + - **PDB 必须与 exe 同目录**:/Fe: 决定 exe 落点,PDB 跟着 exe;换目录再跑就找不到符号。 + - **只能 /Od**:/O2 会合并删除行,行覆盖率与源码对不上(本项目按 /Od 口径计)。 + - **-- 分隔符不能省**:collect [options] -- [args];少了它被测程序的参数会被吃掉。 + - **被测程序的 stdout 会被并进 collect 的 stdout**:判“测试真跑了”要在这份合并输出里找 + 汇总行(TestBuild.summary_marker),别指望单独一路流。 + - **一次 collect 只覆盖这一次运行的进程树**:多套单测各跑各的,最后在本脚本里做并集。 + - **覆盖率是"跑到的那几套的并集"**:`--module` 子集跑必然少覆盖行、少文件,**全部回退判定都 + 只在全量跑时生效**(首版只挡了"文件消失",结果 `--module sha256` 报"总计 2552→111"假回退)。 + - **PDB 里的源路径大小写会漂**(盘符、VS 根),比对前先按仓库实际拼写归一,否则同一文件 + 在基线里裂成两份。 + +设计依据:docs/测试说明/主机单测覆盖率口径.md;docs/问题记录/项目审查与优化建议报告.md §4.8 §5.5 +创建日期:2026-10-23 +""" + +import argparse +import importlib.util +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +import xml.etree.ElementTree as ET +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +HARNESS_DIR = REPO_ROOT / "test" / "host" / "common" +HOST_TEST_DIR = REPO_ROOT / "test" / "host" +BASELINE_PATH = REPO_ROOT / "scripts" / "test_coverage_baseline.json" + +# 统计面:只算自研生产源码(命中 INCLUDE 且不命中 EXCLUDE 才入选) +INCLUDE_PREFIXES = ("App/", "Drivers/BSP/") +EXCLUDE_PREFIXES = () +SOURCE_SUFFIXES = (".c", ".h") + +# 生成物标记:文件头注释里写着这些字样的源文件不进分母(覆盖率对它没有意义) +GENERATED_RE = re.compile(r"自动生成|请勿手改|DO NOT EDIT", re.I) + +BASELINE_SCHEMA = 1 + +# 覆盖率专用追加开关:PDB(行映射)+ 不优化(行号不漂移)+ 非增量链接(PDB 与代码同步) +COV_FLAGS = ["/Zi", "/Od"] +COV_LINK_FLAGS = ["/link", "/INCREMENTAL:NO"] + + +# -------------------------------------------------------------------------- +# harness 复用(工具链探测与编译开关的单一事实源) +# -------------------------------------------------------------------------- + +def load_harness(): + """导入 test/host/common/unit_test_harness.py;失败返回 None(调用方按环境错误处理)。""" + if not (HARNESS_DIR / "unit_test_harness.py").is_file(): + return None + if str(HARNESS_DIR) not in sys.path: + sys.path.insert(0, str(HARNESS_DIR)) + try: + import unit_test_harness + except Exception as exc: + print("[ENV] 导入 unit_test_harness 失败: {}".format(exc), file=sys.stderr) + return None + return unit_test_harness + + +def discover_modules(): + """{模块名: run_unit_test.py 绝对路径};发现规则与 ci_local.py 的 L0.5 一致。""" + out = {} + if not HOST_TEST_DIR.is_dir(): + return out + for p in sorted(HOST_TEST_DIR.rglob("run_unit_test.py")): + out[p.parent.name] = str(p) + return out + + +def load_test_build(runner_path, harness): + """从模块运行器取模块级 BUILD(TestBuild 实例),不执行它的 main。 + + 各运行器都是声明式的:顶层只 import + 构造 TestBuild,真跑在 + if __name__ == "__main__" 里,所以按独立模块名 import 是安全的。 + """ + mod_name = "dtu_cov_" + re.sub(r"\W+", "_", Path(runner_path).parent.name) + spec = importlib.util.spec_from_file_location(mod_name, runner_path) + if spec is None or spec.loader is None: + return None, "无法为 {} 构造 import spec".format(runner_path) + mod = importlib.util.module_from_spec(spec) + try: + spec.loader.exec_module(mod) + except SystemExit: + return None, "{} 在 import 阶段就调用了 sys.exit()".format(runner_path) + except Exception as exc: + return None, "import {} 失败: {}".format(runner_path, exc) + build = getattr(mod, "BUILD", None) + if build is None: + return None, "{} 未声明模块级 BUILD,覆盖率脚本无法复用其编译配置".format(runner_path) + if not isinstance(build, harness.TestBuild): + return None, "{} 的 BUILD 不是 TestBuild(与 harness 类型漂移)".format(runner_path) + return build, None + + +def _vs_roots(harness): + """候选 VS 安装根:cl.exe 的祖先优先,再补 harness 的标准布局表(含 BuildTools)。""" + roots = [] + cl, _, _ = harness.locate_toolchain() + if cl: + # \VC\Tools\MSVC\\bin\Hostx64\x64\cl.exe -> (上溯 8 级) + roots.append(Path(cl).parents[7]) + for r in getattr(harness, "_default_vs_roots", list)(): + p = Path(r) + if p not in roots: + roots.append(p) + return roots + + +def locate_coverage_console(harness, verbose=False): + r"""返回 mscoverage 绝对路径;找不到返回 None。 + + 优先级:DTU_MS_COVERAGE(设了就必须有效,**不静默回退**)→ 各 VS 安装根下的 + Common7\IDE\Extensions\Microsoft\CodeCoverage.Console\ → PATH。 + """ + explicit = os.environ.get("DTU_MS_COVERAGE", "").strip() + if explicit: + if not os.path.isfile(explicit): + print("[ENV] DTU_MS_COVERAGE 已设置但文件不存在: {}(请修正该变量,不做自动探测回退)" + .format(explicit), file=sys.stderr) + return None + return explicit + rel = Path("Common7") / "IDE" / "Extensions" / "Microsoft" / "CodeCoverage.Console" \ + / "Microsoft.CodeCoverage.Console.exe" + for root in _vs_roots(harness): + cand = root / rel + if verbose: + print(" [probe] {}".format(cand)) + if cand.is_file(): + return str(cand) + return shutil.which("Microsoft.CodeCoverage.Console.exe") + + +# -------------------------------------------------------------------------- +# 单模块:编译(带 PDB)-> mscoverage collect -> 解析 Cobertura +# -------------------------------------------------------------------------- + +def build_with_pdb(harness, build, workdir, verbose=False): + """按门禁同一套开关 + COV_FLAGS 编译测试单元。返回 (退出码, 合并输出)。""" + cl, inc, lib = harness.locate_toolchain() + if not cl: + return 2, "找不到 MSVC cl.exe(设置 DTU_MSVC_CL,或安装 VS C++ 工具链)" + objdir = os.path.join(workdir, "obj") + os.makedirs(objdir, exist_ok=True) + exe = os.path.join(workdir, build.resolved_exe()) + + env = dict(os.environ) + env["INCLUDE"] = os.pathsep.join(inc) + env["LIB"] = os.pathsep.join(lib) + env["PATH"] = os.path.dirname(cl) + os.pathsep + env.get("PATH", "") + + cmd = [cl] + list(harness._BASE_FLAGS) + list(COV_FLAGS) + for d in build.include_dirs: + cmd += ["/I", d] + cmd += list(build.extra_flags) + cmd += [build.src, "/Fe:" + exe, "/Fo:" + objdir + os.sep] + list(COV_LINK_FLAGS) + + cp = subprocess.run(cmd, env=env, capture_output=True, cwd=workdir) + return cp.returncode, harness.decode(cp.stdout) + harness.decode(cp.stderr) + + +def collect_coverage(mscov, exe, workdir, xml_path, timeout_s, verbose=False): + """动态插桩运行 exe 并导出 Cobertura XML。返回 (退出码, 合并输出, xml 路径或 None)。 + + 退出码 = 被测程序退出码(mscoverage 透传):0 = 通过,非 0 = 失败或异常。 + """ + cmd = [mscov, "collect", "-o", xml_path, "-f", "cobertura", "--nologo", "--", exe] + env = dict(os.environ) + env["PATH"] = os.path.dirname(mscov) + os.pathsep + env.get("PATH", "") + try: + cp = subprocess.run(cmd, env=env, capture_output=True, cwd=workdir, timeout=timeout_s) + except subprocess.TimeoutExpired: + return 2, "mscoverage collect 超时({} s)".format(timeout_s), None + except OSError as exc: + return 2, "mscoverage 无法执行: {}".format(exc), None + out = cp.stdout.decode("utf-8", "replace") + cp.stderr.decode("utf-8", "replace") + xml = xml_path if os.path.isfile(xml_path) and os.path.getsize(xml_path) > 0 else None + if verbose and xml is None: + print(out[-1500:]) + return cp.returncode, out, xml + + +# -------------------------------------------------------------------------- +# 路径与 Cobertura 解析 +# -------------------------------------------------------------------------- + +def rel_to_repo(path_str): + """PDB 里的源文件绝对路径 -> 仓库相对 posix 路径;仓库外返回 None。 + + Windows 下大小写不可信(盘符、VS 根都可能漂),故比较用 normcase、切片用原串, + 最后再由 canonical 表统一成仓库里的实际拼写。 + """ + if not path_str: + return None + p = path_str if os.path.isabs(path_str) else os.path.join(str(REPO_ROOT), path_str) + p = os.path.abspath(p) + root = os.path.abspath(str(REPO_ROOT)) + if os.path.normcase(p).startswith(os.path.normcase(root) + os.sep): + return p[len(root) + 1:].replace("\\", "/") + return None + + +def in_scope(rel): + """是否在覆盖率统计面内(自研生产源码);大小写不敏感。""" + if not rel: + return False + low = rel.lower() + if not low.endswith(SOURCE_SUFFIXES): + return False + # 前缀表本身带大小写(App/、Drivers/BSP/),比较前先整体小写 + if not low.startswith(tuple(x.lower() for x in INCLUDE_PREFIXES)): + return False + return not low.startswith(tuple(x.lower() for x in EXCLUDE_PREFIXES)) + + +def parse_cobertura(xml_path, canon): + """Cobertura XML -> {仓库相对路径: {"lines": {行: 命中}, "funcs": {函数: 命中}}}。 + + 同一 (文件, 行) 多次出现取最大值;文件名先经 canon 表归一,避免大小写裂成两份台账。 + """ + tree = ET.parse(xml_path) + result = {} + for cls in tree.getroot().iter("class"): + rel = rel_to_repo(cls.get("filename", "")) + if not in_scope(rel): + continue + rel = canon.get(rel.lower(), rel) + entry = result.setdefault(rel, {"lines": {}, "funcs": {}}) + for line in cls.iter("line"): + try: + num = int(line.get("number", "0")) + hits = int(line.get("hits", "0")) + except ValueError: + continue + if num <= 0: + continue + if hits >= entry["lines"].get(num, 0): + entry["lines"][num] = hits + for meth in cls.iter("method"): + name = meth.get("name") or "?" + hits = 0 + for line in meth.iter("line"): + try: + hits = max(hits, int(line.get("hits", "0") or 0)) + except ValueError: + pass + if hits >= entry["funcs"].get(name, 0): + entry["funcs"][name] = hits + return result + + +def merge_into(total, part): + """单模块结果并入总表(同一 (文件, 行) 取并集:命中一次即算覆盖)。""" + for rel, entry in part.items(): + dst = total.setdefault(rel, {"lines": {}, "funcs": {}}) + for num, hits in entry["lines"].items(): + if hits >= dst["lines"].get(num, 0): + dst["lines"][num] = hits + for name, hits in entry["funcs"].items(): + if hits >= dst["funcs"].get(name, 0): + dst["funcs"][name] = hits + + +def summarize(total): + """{文件: {lines, funcs}} -> 文件级指标(valid/covered + 覆盖率 + 未覆盖行清单)。""" + out = {} + for rel, entry in sorted(total.items()): + lines, funcs = entry["lines"], entry["funcs"] + valid = len(lines) + covered = sum(1 for h in lines.values() if h > 0) + f_valid = len(funcs) + f_covered = sum(1 for h in funcs.values() if h > 0) + out[rel] = { + "lines_valid": valid, + "lines_covered": covered, + "funcs_valid": f_valid, + "funcs_covered": f_covered, + "line_rate": round(covered / valid, 4) if valid else 0.0, + "func_rate": round(f_covered / f_valid, 4) if f_valid else 0.0, + "missing_lines": sorted(n for n, h in lines.items() if h == 0), + } + return out + + +def pct(x): + return "{:.1f}%".format(100.0 * x) if x is not None else "-" + + +# -------------------------------------------------------------------------- +# 分母侧:生产源文件全集 / 生成物 / 未触及面 +# -------------------------------------------------------------------------- + +def head_text(rel_path, n_lines=15): + try: + with open(REPO_ROOT / rel_path, "r", encoding="utf-8", errors="replace") as f: + return "".join(f.readlines()[:n_lines]) + except OSError: + return "" + + +def is_generated(rel_path): + """文件头 15 行内出现「自动生成 / 请勿手改 / DO NOT EDIT」即判为生成物。""" + return bool(GENERATED_RE.search(head_text(rel_path))) + + +def production_sources(): + """统计面内的全部生产源文件(仓库相对 posix 路径,按文件系统实际拼写)。""" + out = [] + for prefix in INCLUDE_PREFIXES: + base = REPO_ROOT / prefix + if not base.is_dir(): + continue + for p in sorted(base.rglob("*")): + if not p.is_file() or not p.name.lower().endswith(SOURCE_SUFFIXES): + continue + rel = str(p.relative_to(REPO_ROOT)).replace("\\", "/") + if rel.lower().startswith(EXCLUDE_PREFIXES): + continue + out.append(rel) + return out + + +def count_code_lines(rel_path): + """粗粒度有效代码行(去空行、去整行注释、去 include)。 + + **只用于未触及面的体量提示**,不进覆盖率分母——分母一律来自插桩报告,不靠猜。 + """ + try: + with open(REPO_ROOT / rel_path, "r", encoding="utf-8", errors="replace") as f: + text = f.read() + except OSError: + return 0 + n, in_block = 0, False + for raw in text.splitlines(): + s = raw.strip() + if not s: + continue + if in_block: + if "*/" in s: + in_block = False + continue + if s.startswith("/*"): + in_block = "*/" not in s + continue + if s.startswith("*") or s.startswith("//") or s.startswith("#include"): + continue + n += 1 + return n + + +# -------------------------------------------------------------------------- +# 基线:读取 / 写出 / 比对(增量拦截:拦的是回退,不拦进步) +# -------------------------------------------------------------------------- + +def load_baseline(path): + if not Path(path).is_file(): + return None + try: + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + except (OSError, ValueError) as exc: + print("[ENV] 基线读取失败 {}: {}".format(path, exc), file=sys.stderr) + return None + + +BASELINE_FILE_FIELDS = ("lines_valid", "lines_covered", "funcs_valid", "funcs_covered", + "line_rate", "func_rate") + + +def write_baseline(path, modules, files, totals, tested_sources, untested): + # 基线只留可比字段:missing_lines 每次跑都会漂,进了台账就是无意义的提交抖动 + payload = { + "schema": BASELINE_SCHEMA, + "generated_by": "scripts/check_test_coverage.py --update-baseline", + "note": "主机单测覆盖率基线。口径与工具链限制见 docs/测试说明/主机单测覆盖率口径.md。" + "门禁判据:files[].lines_covered 与 funcs_covered 只增不减,下降即拦提交。", + "totals": totals, + "modules": modules, + "files": {rel: {k: v for k, v in rec.items() if k in BASELINE_FILE_FIELDS} + for rel, rec in files.items()}, + "tested_sources": tested_sources, + "untested_sources": untested, + } + with open(path, "w", encoding="utf-8", newline="\n") as f: + json.dump(payload, f, ensure_ascii=False, indent=2, sort_keys=True) + f.write("\n") + + +def diff_baseline(baseline, modules, files, totals, full_run): + """返回 (回退项, 提示项);回退项非空即门禁失败。""" + regress, notes = [], [] + if baseline is None: + notes.append("无基线文件:先跑 scripts/check_test_coverage.py --update-baseline 生成 " + "scripts/test_coverage_baseline.json,否则本门禁只是又跑了一遍测试," + "没有任何比较基准") + return regress, notes + if baseline.get("schema") != BASELINE_SCHEMA: + notes.append("基线 schema={} 与当前 {} 不一致:用 --update-baseline 重建" + .format(baseline.get("schema"), BASELINE_SCHEMA)) + return regress, notes + + base_files = baseline.get("files", {}) + base_mods = baseline.get("modules", {}) + + for rel, b in sorted(base_files.items()): + cur = files.get(rel) + if cur is None: + if full_run: + regress.append("{}: 基线里在测,本次未出现在任何单测编译单元里" + "(include 链被删?还是那套单测被跳过了?)基线 {}/{} 行".format( + rel, b.get("lines_covered", 0), b.get("lines_valid", 0))) + continue + # 覆盖率是"跑到的套数的并集",子集必然比全量少覆盖行 → 不是回退,别假报警 + if not full_run: + continue + if cur["lines_covered"] < b.get("lines_covered", 0): + regress.append("{}: 已覆盖行 {} -> **{}**(-{})".format( + rel, b.get("lines_covered", 0), cur["lines_covered"], + b.get("lines_covered", 0) - cur["lines_covered"])) + if cur["funcs_covered"] < b.get("funcs_covered", 0): + regress.append("{}: 已覆盖函数 {} -> **{}**".format( + rel, b.get("funcs_covered", 0), cur["funcs_covered"])) + if cur["lines_valid"] > int(b.get("lines_valid", 0) * 1.3) + 120: + notes.append("{}: 可执行行 {} -> {}(源码长大或被更多测试 include,百分比会被摊薄)" + ":确认后 --update-baseline 登记".format( + rel, b.get("lines_valid", 0), cur["lines_valid"])) + + for name, b in sorted(base_mods.items()): + cur = modules.get(name) + if cur is None: + if full_run: + regress.append("模块 {}: 基线里有,本次未跑到(运行器缺失 / import 失败?)".format(name)) + elif not cur.get("ok"): + regress.append("模块 {}: 本次未通过({})".format(name, cur.get("reason", "未知"))) + + bt = baseline.get("totals", {}) + # 子集运行的"总计"必然只包含被跑到的那几套,拿它比全量基线是假回退 + if full_run and totals["lines_covered"] < bt.get("lines_covered", 0): + regress.append("总计: 已覆盖行 {} -> **{}**".format(bt.get("lines_covered", 0), + totals["lines_covered"])) + + for rel in sorted(set(files) - set(base_files)): + notes.append("新纳入统计: {}({}/{} 行,{})——确认无误后 --update-baseline 登记".format( + rel, files[rel]["lines_covered"], files[rel]["lines_valid"], + pct(files[rel]["line_rate"]))) + if not full_run: + notes.append("本次是 --module 子集运行:并集必然变小,跳过全部覆盖率回退判定" + "(文件消失 / 行减少 / 函数减少 / 总计下降);只保留" + "「单测必须通过」与「新文件登记提示」两项。要判回退请跑全量") + return regress, notes + + +# -------------------------------------------------------------------------- +# 输出 +# -------------------------------------------------------------------------- + +def print_report(modules, files, totals, untested, generated, notes, regress, verbose): + print("=== 主机单测覆盖率(口径:docs/测试说明/主机单测覆盖率口径.md)===") + print("") + print("--- 各套单测(插桩运行)---") + for name in sorted(modules): + m = modules[name] + if not m["ok"]: + print(" {:<12} FAIL {}".format(name, m.get("reason", ""))) + continue + print(" {:<12} ok 触及 {:>2} 个生产文件 / {:>5} 行".format( + name, len(m["files"]), m["lines_total"])) + print("") + print("--- 自研生产源码(全部套件并集)---") + print(" {:<56} {:>9} {:>7} {:>8} {:>8}".format("文件", "覆盖行", "总行", "函数", "覆盖率")) + for rel in sorted(files): + s = files[rel] + print(" {:<56} {:>9} {:>7} {:>8} {:>8}".format( + rel[-56:], s["lines_covered"], s["lines_valid"], + "{}/{}".format(s["funcs_covered"], s["funcs_valid"]), pct(s["line_rate"]))) + print("") + print("--- 未触及面:生产源码里从未进过任何主机单测的文件 ---") + print(" {} 个 / 有效代码约 {} 行(这些文件覆盖率是 0 分母,绿灯不代表它们被测)".format( + len(untested), sum(u["code_lines"] for u in untested))) + show = untested if verbose else untested[:12] + for u in show: + print(" {:<56} {:>6} 行".format(u["path"][-56:], u["code_lines"])) + if len(show) < len(untested): + print(" ...(另有 {} 个,--verbose 全列)".format(len(untested) - len(show))) + if generated: + print(" 生成物已排除(不进分母): {}".format(", ".join(generated))) + if notes: + print("") + print("--- 提示(不拦提交)---") + for n in notes: + print(" [NOTE] " + n) + if regress: + print("") + print("--- 回退(拦提交)---") + for r in regress: + print(" [REGRESS] " + r) + print("") + print("[SUMMARY] coverage: line={}/{} ({}) func={}/{} ({}) suites={}/{} " + "tested_files={} untested_files={}".format( + totals["lines_covered"], totals["lines_valid"], pct(totals["line_rate"]), + totals["funcs_covered"], totals["funcs_valid"], pct(totals["func_rate"]), + totals["modules_ok"], totals["modules_total"], + totals["files_tested"], totals["files_untested"])) + + +def write_markdown(path, modules, files, totals, untested, notes, regress): + """一次性 Markdown 报告(建议放临时目录,不入库)。 + + ⚠ 行拼装一律走 code() 显式拼接:`.format` 的优先级高于 `+`,写成 + `a + "{}".format(x)` 只有最后一段会被格式化,占位符原样留在表里(实测踩过一次)。 + """ + def code(s): + return BT_MARK + str(s) + BT_MARK + + out = [ + "# 主机单测覆盖率报告", + "", + "> 由 " + code("scripts/check_test_coverage.py") + " 生成;口径、工具链限制与盲区见 " + + code("docs/测试说明/主机单测覆盖率口径.md") + "。本报告是一次性快照,重跑即覆盖。", + "", + "## 总计", + "", + "| 指标 | 覆盖 | 总数 | 覆盖率 |", + "|---|---|---|---|", + "| 行 | {} | {} | {} |".format(totals["lines_covered"], totals["lines_valid"], + pct(totals["line_rate"])), + "| 函数 | {} | {} | {} |".format(totals["funcs_covered"], totals["funcs_valid"], + pct(totals["func_rate"])), + "| 套件(通过/总数) | {} | {} | - |".format(totals["modules_ok"], + totals["modules_total"]), + "| 在测文件 / 未触及文件 | {} | {} | - |".format(totals["files_tested"], + totals["files_untested"]), + "", + "## 各套单测(插桩运行)", + "", + "| 套件 | 结果 | 触及生产文件 | 插桩行 |", + "|---|---|---|---|", + ] + for name in sorted(modules): + m = modules[name] + out.append("| " + code(name) + " | " + + ("ok" if m["ok"] else "**FAIL**") + " | " + + str(len(m.get("files", {}))) + " | " + + str(m.get("lines_total", 0)) + " |") + out += ["", "## 生产文件明细(多套取并集)", "", + "| 文件 | 覆盖行 | 总行 | 覆盖率 | 函数 | 未覆盖行 |", + "|---|---|---|---|---|---|"] + for rel in sorted(files): + s = files[rel] + miss = ", ".join(str(n) for n in s["missing_lines"][:40]) + if len(s["missing_lines"]) > 40: + miss += " …(+" + str(len(s["missing_lines"]) - 40) + ")" + out.append("| " + code(rel) + " | " + str(s["lines_covered"]) + " | " + + str(s["lines_valid"]) + " | " + pct(s["line_rate"]) + " | " + + str(s["funcs_covered"]) + "/" + str(s["funcs_valid"]) + " | " + + (miss or "—") + " |") + out += ["", "## 未触及的生产源文件", "", + "共 " + str(len(untested)) + " 个(有效代码约 " + + str(sum(u["code_lines"] for u in untested)) + + " 行)。主机单测从未编译过它们——**对它们而言覆盖率是 0 分母,不是 0%**。", + "", "| 文件 | 有效代码行 |", "|---|---|"] + for u in untested: + out.append("| " + code(u["path"]) + " | " + str(u["code_lines"]) + " |") + if notes: + out += ["", "## 提示(不拦提交)", ""] + ["- [NOTE] " + n for n in notes] + if regress: + out += ["", "## 回退(拦提交)", ""] + ["- [REGRESS] " + r for r in regress] + out.append("") + Path(path).write_text("\n".join(out), encoding="utf-8", newline="\n") + + +# -------------------------------------------------------------------------- +# 主流程 +# -------------------------------------------------------------------------- + +def run(args): + for stream in (sys.stdout, sys.stderr): + try: + stream.reconfigure(encoding="utf-8", errors="replace") + except Exception: + pass + + harness = load_harness() + if harness is None: + print("[ENV] 取不到 test/host/common/unit_test_harness.py", file=sys.stderr) + return 2 + + all_runners = discover_modules() + if not all_runners: + print("[ENV] 未发现 test/host/**/run_unit_test.py", file=sys.stderr) + return 2 + + runners = all_runners + if args.module: + want = set(args.module) + unknown = want - set(all_runners) + if unknown: + print("[USAGE] 未知模块 {};可选:{}".format( + ", ".join(sorted(unknown)), ", ".join(sorted(all_runners))), file=sys.stderr) + return 3 + runners = {k: v for k, v in all_runners.items() if k in want} + full_run = len(runners) == len(all_runners) + + if args.list: + for name in sorted(all_runners): + print(name) + return 0 + + mscov = None + if not args.build_only: + mscov = locate_coverage_console(harness, verbose=args.verbose) + if not mscov: + print("[ENV] 找不到 Microsoft.CodeCoverage.Console.exe(随 VS 装在 " + "Common7\\IDE\\Extensions\\Microsoft\\CodeCoverage.Console\\);" + "可用 DTU_MS_COVERAGE 指定绝对路径。本机实测:MSVC 14.51 已无 /coverage," + "且 PATH 上没有 gcc/gcov。", file=sys.stderr) + return 2 + + prod = production_sources() + canon = {rel.lower(): rel for rel in prod} + + work_root = args.work_dir or os.path.join(tempfile.gettempdir(), "dtu_test_cov") + if args.clean: + shutil.rmtree(work_root, ignore_errors=True) + os.makedirs(work_root, exist_ok=True) + + total_files = {} + modules = {} + for name, runner in sorted(runners.items()): + workdir = os.path.join(work_root, name) + shutil.rmtree(workdir, ignore_errors=True) + os.makedirs(workdir, exist_ok=True) + + build, err = load_test_build(runner, harness) + if err: + modules[name] = {"ok": False, "reason": err, "files": {}, "lines_total": 0} + print("[FAIL] {}".format(err), file=sys.stderr) + continue + + rc, out = build_with_pdb(harness, build, workdir, verbose=args.verbose) + if rc != 0: + modules[name] = {"ok": False, "reason": "编译失败(cl 退出码 {})".format(rc), + "files": {}, "lines_total": 0} + print("[ENV] {} 编译失败:".format(name), file=sys.stderr) + print(out[-2500:], file=sys.stderr) + continue + + if args.build_only: + modules[name] = {"ok": True, "files": {}, "lines_total": 0, "build_only": True} + print("[OK] {} 覆盖率形态编译通过".format(name)) + continue + + exe = os.path.join(workdir, build.resolved_exe()) + xml_path = os.path.join(workdir, "coverage.cobertura.xml") + crc, cout, xml = collect_coverage(mscov, exe, workdir, xml_path, args.timeout, + verbose=args.verbose) + marker = build.summary_marker or "" + if marker and marker not in cout: + modules[name] = {"ok": False, + "reason": "插桩运行未产生测试汇总行(找不到 " + repr(marker) + ")", + "files": {}, "lines_total": 0} + print("[ENV] {} 测试汇总行缺失(rc={})".format(name, crc), file=sys.stderr) + continue + if crc != 0: + modules[name] = {"ok": False, "reason": "单测失败(退出码 {})".format(crc), + "files": {}, "lines_total": 0} + print("[FAIL] {} 在插桩运行下失败(rc={})".format(name, crc), file=sys.stderr) + continue + if xml is None: + modules[name] = {"ok": False, "reason": "未生成覆盖率 XML", + "files": {}, "lines_total": 0} + print("[ENV] {} 缺覆盖率 XML: {}".format(name, xml_path), file=sys.stderr) + continue + try: + part = parse_cobertura(xml, canon) + except ET.ParseError as exc: + modules[name] = {"ok": False, "reason": "覆盖率 XML 解析失败: {}".format(exc), + "files": {}, "lines_total": 0} + print("[ENV] {} XML 解析失败: {}".format(name, exc), file=sys.stderr) + continue + + modules[name] = { + "ok": True, + "files": {rel: len(e["lines"]) for rel, e in part.items()}, + "lines_total": sum(len(e["lines"]) for e in part.values()), + } + merge_into(total_files, part) + + files = summarize(total_files) + tested = sorted(files) + untested, generated = [], [] + for rel in prod: + if rel in files: + continue + if is_generated(rel): + generated.append(rel) + continue + untested.append({"path": rel, "code_lines": count_code_lines(rel)}) + untested.sort(key=lambda u: (-u["code_lines"], u["path"])) + + totals = { + "lines_valid": sum(s["lines_valid"] for s in files.values()), + "lines_covered": sum(s["lines_covered"] for s in files.values()), + "funcs_valid": sum(s["funcs_valid"] for s in files.values()), + "funcs_covered": sum(s["funcs_covered"] for s in files.values()), + "modules_total": len(modules), + "modules_ok": sum(1 for m in modules.values() if m["ok"]), + "files_tested": len(files), + "files_untested": len(untested), + "files_generated_excluded": len(generated), + "line_rate": 0.0, + "func_rate": 0.0, + } + if totals["lines_valid"]: + totals["line_rate"] = round(totals["lines_covered"] / totals["lines_valid"], 4) + if totals["funcs_valid"]: + totals["func_rate"] = round(totals["funcs_covered"] / totals["funcs_valid"], 4) + + if args.json: + payload = {"totals": totals, "modules": modules, "files": files, + "untested_sources": untested, "generated_excluded": generated} + Path(args.json).write_text( + json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n", + encoding="utf-8", newline="\n") + + baseline = load_baseline(args.baseline) + if args.build_only: + regress, notes = [], ["--build-only:只编译覆盖率形态(带 PDB),不采集数据"] + else: + regress, notes = diff_baseline(baseline, modules, files, totals, full_run) + + print_report(modules, files, totals, untested, generated, notes, regress, args.verbose) + + if args.md: + write_markdown(args.md, modules, files, totals, untested, notes, regress) + print("[OK] Markdown 报告: {}".format(args.md)) + if args.update_baseline: + write_baseline(args.baseline, modules, files, totals, tested, untested) + print("[OK] 基线已写入 {}".format(os.path.relpath(args.baseline, REPO_ROOT))) + return 0 + + if totals["modules_ok"] != totals["modules_total"]: + return 1 + return 1 if regress else 0 + + +def main(): + ap = argparse.ArgumentParser( + prog="check_test_coverage.py", + description="主机侧单测(L0.5)覆盖率:插桩跑测试 + 统计自研生产源码行/函数覆盖 + 基线增量门禁") + ap.add_argument("--module", action="append", metavar="NAME", + help="只处理指定模块(可重复;默认全部)") + ap.add_argument("--list", action="store_true", help="只列出发现的模块名") + ap.add_argument("--baseline", default=str(BASELINE_PATH), + help="基线 JSON 路径(默认 scripts/test_coverage_baseline.json)") + ap.add_argument("--update-baseline", action="store_true", help="把本次结果写回基线") + ap.add_argument("--md", metavar="PATH", help="额外输出 Markdown 报告") + ap.add_argument("--json", metavar="PATH", help="额外输出 JSON 明细") + ap.add_argument("--work-dir", metavar="PATH", + help="中间产物目录(默认临时目录下 dtu_test_cov)") + ap.add_argument("--clean", action="store_true", help="跑前清空中间目录") + ap.add_argument("--timeout", type=int, default=600, metavar="SEC", + help="单模块插桩运行超时(默认 600 秒)") + ap.add_argument("--build-only", action="store_true", + help="只按覆盖率开关编译、不采集(排障用)") + ap.add_argument("--verbose", action="store_true", help="多打探测过程与未触及文件全表") + args = ap.parse_args() + if args.timeout <= 0: + print("[USAGE] --timeout 必须为正整数", file=sys.stderr) + return 3 + try: + return run(args) + except KeyboardInterrupt: + print("[ABORT] 用户中断", file=sys.stderr) + return 2 + + +BT_MARK = chr(96) + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/ci_local.py b/scripts/ci_local.py index 855c8697..27d3251f 100644 --- a/scripts/ci_local.py +++ b/scripts/ci_local.py @@ -10,6 +10,9 @@ 覆盖范围(与 CI 完全一致): L0 静态门禁 = `pre-commit run --all-files` + 提交信息门禁(gitlint) L0.5 主机侧纯逻辑单测 = `test/host/**/run_unit_test.py` + L0.5c 覆盖率(**opt-in:`--coverage`**,2026-10-23)= `scripts/check_test_coverage.py` + 默认**不跑**、CI 也**不跑**:它依赖 VS 可选的「代码覆盖率工具」组件,且与 L0.5 那 16 个 + 钩子跑的是同一批测试(覆盖率只是换一种看法)。口径与盲区 → docs/测试说明/主机单测覆盖率口径.md ⚠️ gitlint 必须**单独跑**(2026-10-11 审计):`stages: [commit-msg]` 写在 gitlint **上游** 的 `.pre-commit-hooks.yaml`(项目 `.pre-commit-config.yaml` 里查不到这一行),而 @@ -332,6 +335,30 @@ def run_l05(args): return rc_all +def run_coverage(): + """覆盖率附加步骤(opt-in,CI 不跑):主机单测覆盖率 + 基线增量门禁。 + + 单独一步而不并进 L0.5:L0.5 是**必须绿**的门禁,覆盖率是**趋势视图**,两者失败含义不同 + (前者=测试挂了,后者=覆盖回退了 / 缺工具链)。退出码原样透传,由 main 汇总。 + """ + print("") + print("=" * 64) + print(" L0.5c 主机单测覆盖率(mscoverage 动态插桩 + 基线增量门禁)") + print("=" * 64) + script = REPO_ROOT / "scripts" / "check_test_coverage.py" + if not script.is_file(): + print("[WARN] 缺 scripts/check_test_coverage.py —— 跳过该步") + return 0 + rc = _run([PY, str(script)]) + if rc == 2: + print(" (退出码 2 = 环境错误:多半没装 VS 的「代码覆盖率工具」组件,或需设") + print(" DTU_MS_COVERAGE。口径与排障 → docs/测试说明/主机单测覆盖率口径.md)") + elif rc == 1: + print(" (退出码 1 = 有单测失败,或已覆盖行/函数相对基线回退;确认无误后") + print(" 跑 scripts/check_test_coverage.py --update-baseline 重登基线)") + return rc + + def main(argv=None): for stream in (sys.stdout, sys.stderr): try: @@ -344,6 +371,8 @@ def main(argv=None): g = ap.add_mutually_exclusive_group() g.add_argument("--l0-only", action="store_true", help="只跑 L0 静态门禁") g.add_argument("--l05-only", action="store_true", help="只跑 L0.5 主机侧单测") + ap.add_argument("--coverage", action="store_true", + help="额外跑主机单测覆盖率(约 25 s;需 VS 代码覆盖率组件;默认不跑,CI 也不跑)") ap.add_argument("--keep-going", action="store_true", help="某步失败也继续跑后续步骤(默认首错即停)") ap.add_argument("--commits", type=int, default=10, metavar="N", @@ -388,6 +417,8 @@ def main(argv=None): if not args.l0_only: rc05 = run_l05(args) rc = rc or rc05 + if args.coverage: + rc = rc or run_coverage() except FileNotFoundError as e: print("[ERROR] 环境错误:{}".format(e)) return 2 diff --git a/scripts/test_coverage_baseline.json b/scripts/test_coverage_baseline.json new file mode 100644 index 00000000..d77a2f46 --- /dev/null +++ b/scripts/test_coverage_baseline.json @@ -0,0 +1,825 @@ +{ + "files": { + "App/algo/adc_health.c": { + "func_rate": 1.0, + "funcs_covered": 6, + "funcs_valid": 6, + "line_rate": 1.0, + "lines_covered": 108, + "lines_valid": 108 + }, + "App/algo/adc_input.c": { + "func_rate": 1.0, + "funcs_covered": 14, + "funcs_valid": 14, + "line_rate": 0.975, + "lines_covered": 117, + "lines_valid": 120 + }, + "App/algo/freq.c": { + "func_rate": 1.0, + "funcs_covered": 8, + "funcs_valid": 8, + "line_rate": 0.9692, + "lines_covered": 126, + "lines_valid": 130 + }, + "App/algo/phasor.c": { + "func_rate": 1.0, + "funcs_covered": 6, + "funcs_valid": 6, + "line_rate": 0.9818, + "lines_covered": 54, + "lines_valid": 55 + }, + "App/algo/prot_elm.c": { + "func_rate": 1.0, + "funcs_covered": 8, + "funcs_valid": 8, + "line_rate": 0.9717, + "lines_covered": 103, + "lines_valid": 106 + }, + "App/algo/prot_set.c": { + "func_rate": 0.9565, + "funcs_covered": 22, + "funcs_valid": 23, + "line_rate": 0.9609, + "lines_covered": 172, + "lines_valid": 179 + }, + "App/algo/sample_ring.c": { + "func_rate": 1.0, + "funcs_covered": 5, + "funcs_valid": 5, + "line_rate": 1.0, + "lines_covered": 41, + "lines_valid": 41 + }, + "App/cfg/cfg_file.c": { + "func_rate": 0.9615, + "funcs_covered": 25, + "funcs_valid": 26, + "line_rate": 0.8773, + "lines_covered": 572, + "lines_valid": 652 + }, + "App/fs/vol_lock.c": { + "func_rate": 1.0, + "funcs_covered": 3, + "funcs_valid": 3, + "line_rate": 1.0, + "lines_covered": 17, + "lines_valid": 17 + }, + "App/log/soe_rec.c": { + "func_rate": 1.0, + "funcs_covered": 7, + "funcs_valid": 7, + "line_rate": 0.931, + "lines_covered": 81, + "lines_valid": 87 + }, + "App/protocol/iec104/iec104_asdu.c": { + "func_rate": 1.0, + "funcs_covered": 10, + "funcs_valid": 10, + "line_rate": 0.932, + "lines_covered": 96, + "lines_valid": 103 + }, + "App/protocol/iec104/iec104_link.c": { + "func_rate": 1.0, + "funcs_covered": 23, + "funcs_valid": 23, + "line_rate": 0.9062, + "lines_covered": 367, + "lines_valid": 405 + }, + "App/protocol/iec104/iec104_pts.c": { + "func_rate": 0.7647, + "funcs_covered": 13, + "funcs_valid": 17, + "line_rate": 0.6724, + "lines_covered": 117, + "lines_valid": 174 + }, + "App/sync/time_sync.c": { + "func_rate": 1.0, + "funcs_covered": 4, + "funcs_valid": 4, + "line_rate": 1.0, + "lines_covered": 99, + "lines_valid": 99 + }, + "App/util/sha256.c": { + "func_rate": 1.0, + "funcs_covered": 7, + "funcs_valid": 7, + "line_rate": 0.9737, + "lines_covered": 111, + "lines_valid": 114 + }, + "App/util/sys_clock.c": { + "func_rate": 1.0, + "funcs_covered": 8, + "funcs_valid": 8, + "line_rate": 0.9936, + "lines_covered": 156, + "lines_valid": 157 + }, + "App/util/task_watchdog.c": { + "func_rate": 1.0, + "funcs_covered": 8, + "funcs_valid": 8, + "line_rate": 1.0, + "lines_covered": 55, + "lines_valid": 55 + }, + "Drivers/BSP/GD5F2GQ5UE/nand_ftl.c": { + "func_rate": 0.6364, + "funcs_covered": 14, + "funcs_valid": 22, + "line_rate": 0.4862, + "lines_covered": 123, + "lines_valid": 253 + }, + "Drivers/BSP/NET/lftpd/lftpd_auth.c": { + "func_rate": 1.0, + "funcs_covered": 2, + "funcs_valid": 2, + "line_rate": 0.9737, + "lines_covered": 37, + "lines_valid": 38 + } + }, + "generated_by": "scripts/check_test_coverage.py --update-baseline", + "modules": { + "adc_health": { + "files": { + "App/algo/adc_health.c": 108 + }, + "lines_total": 108, + "ok": true + }, + "adc_input": { + "files": { + "App/algo/adc_input.c": 120 + }, + "lines_total": 120, + "ok": true + }, + "cfg_file": { + "files": { + "App/cfg/cfg_file.c": 652 + }, + "lines_total": 652, + "ok": true + }, + "freq": { + "files": { + "App/algo/freq.c": 130, + "App/algo/phasor.c": 55, + "App/algo/sample_ring.c": 41 + }, + "lines_total": 226, + "ok": true + }, + "ftp_auth": { + "files": { + "App/util/sha256.c": 114, + "Drivers/BSP/NET/lftpd/lftpd_auth.c": 38 + }, + "lines_total": 152, + "ok": true + }, + "iec104": { + "files": { + "App/algo/freq.c": 130, + "App/algo/phasor.c": 55, + "App/algo/prot_elm.c": 106, + "App/algo/prot_set.c": 179, + "App/algo/sample_ring.c": 41, + "App/protocol/iec104/iec104_asdu.c": 103, + "App/protocol/iec104/iec104_link.c": 405, + "App/protocol/iec104/iec104_pts.c": 174 + }, + "lines_total": 1193, + "ok": true + }, + "nand_port": { + "files": { + "Drivers/BSP/GD5F2GQ5UE/nand_ftl.c": 253 + }, + "lines_total": 253, + "ok": true + }, + "phasor": { + "files": { + "App/algo/phasor.c": 55, + "App/algo/sample_ring.c": 41 + }, + "lines_total": 96, + "ok": true + }, + "prot_elm": { + "files": { + "App/algo/freq.c": 130, + "App/algo/phasor.c": 55, + "App/algo/prot_elm.c": 106, + "App/algo/prot_set.c": 179, + "App/algo/sample_ring.c": 41 + }, + "lines_total": 511, + "ok": true + }, + "prot_set": { + "files": { + "App/algo/prot_set.c": 179 + }, + "lines_total": 179, + "ok": true + }, + "sha256": { + "files": { + "App/util/sha256.c": 114 + }, + "lines_total": 114, + "ok": true + }, + "soe": { + "files": { + "App/log/soe_rec.c": 87, + "App/protocol/iec104/iec104_asdu.c": 103 + }, + "lines_total": 190, + "ok": true + }, + "sys_clock": { + "files": { + "App/util/sys_clock.c": 157 + }, + "lines_total": 157, + "ok": true + }, + "time_sync": { + "files": { + "App/sync/time_sync.c": 99 + }, + "lines_total": 99, + "ok": true + }, + "vol_lock": { + "files": { + "App/fs/vol_lock.c": 17 + }, + "lines_total": 17, + "ok": true + }, + "watchdog": { + "files": { + "App/util/task_watchdog.c": 55 + }, + "lines_total": 55, + "ok": true + } + }, + "note": "主机单测覆盖率基线。口径与工具链限制见 docs/测试说明/主机单测覆盖率口径.md。门禁判据:files[].lines_covered 与 funcs_covered 只增不减,下降即拦提交。", + "schema": 1, + "tested_sources": [ + "App/algo/adc_health.c", + "App/algo/adc_input.c", + "App/algo/freq.c", + "App/algo/phasor.c", + "App/algo/prot_elm.c", + "App/algo/prot_set.c", + "App/algo/sample_ring.c", + "App/cfg/cfg_file.c", + "App/fs/vol_lock.c", + "App/log/soe_rec.c", + "App/protocol/iec104/iec104_asdu.c", + "App/protocol/iec104/iec104_link.c", + "App/protocol/iec104/iec104_pts.c", + "App/sync/time_sync.c", + "App/util/sha256.c", + "App/util/sys_clock.c", + "App/util/task_watchdog.c", + "Drivers/BSP/GD5F2GQ5UE/nand_ftl.c", + "Drivers/BSP/NET/lftpd/lftpd_auth.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 + }, + "untested_sources": [ + { + "code_lines": 1251, + "path": "Drivers/BSP/NET/net_socket.c" + }, + { + "code_lines": 801, + "path": "Drivers/BSP/CH395F/ch395f.c" + }, + { + "code_lines": 694, + "path": "Drivers/BSP/NET/lftpd/lftpd.c" + }, + { + "code_lines": 689, + "path": "Drivers/BSP/GD5F2GQ5UE/gd5f2gq5ue.c" + }, + { + "code_lines": 458, + "path": "App/log/sys_log_mcu.c" + }, + { + "code_lines": 329, + "path": "Drivers/BSP/UI/ui_menu_view.c" + }, + { + "code_lines": 323, + "path": "Drivers/BSP/UI/ui_net_sink.c" + }, + { + "code_lines": 300, + "path": "Drivers/BSP/UI/ui_content_page.c" + }, + { + "code_lines": 287, + "path": "Drivers/BSP/NET/net_pending.c" + }, + { + "code_lines": 281, + "path": "App/task/default_task.c" + }, + { + "code_lines": 265, + "path": "App/log/console_log.c" + }, + { + "code_lines": 264, + "path": "Drivers/BSP/SD2506/sd2506.c" + }, + { + "code_lines": 263, + "path": "Drivers/BSP/NET/net_events.c" + }, + { + "code_lines": 207, + "path": "Drivers/BSP/NET/lftpd/lftpd_io.c" + }, + { + "code_lines": 206, + "path": "Drivers/BSP/CH395F/ch395f.h" + }, + { + "code_lines": 205, + "path": "Drivers/BSP/UI/ui_page_net_cfg.c" + }, + { + "code_lines": 204, + "path": "Drivers/BSP/UI/ui_standby.c" + }, + { + "code_lines": 202, + "path": "App/task/iec104_serv.c" + }, + { + "code_lines": 191, + "path": "Drivers/BSP/NET/net_listener.c" + }, + { + "code_lines": 171, + "path": "App/task/adc_task.c" + }, + { + "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" + }, + { + "code_lines": 150, + "path": "Drivers/BSP/UI/ui_page_password.c" + }, + { + "code_lines": 135, + "path": "App/app/app_main.c" + }, + { + "code_lines": 129, + "path": "App/cfg/cfg_file_store_mcu.c" + }, + { + "code_lines": 126, + "path": "Drivers/BSP/UI/ui_menu.c" + }, + { + "code_lines": 123, + "path": "App/log/soe_store.c" + }, + { + "code_lines": 123, + "path": "App/task/net_task.c" + }, + { + "code_lines": 100, + "path": "Drivers/BSP/NET/net_types.h" + }, + { + "code_lines": 98, + "path": "Drivers/BSP/SD2506/sd2506.h" + }, + { + "code_lines": 93, + "path": "Drivers/BSP/GD5F2GQ5UE/gd5f2gq5ue.h" + }, + { + "code_lines": 90, + "path": "App/task/protect_task.c" + }, + { + "code_lines": 83, + "path": "Drivers/BSP/NET/net_socket.h" + }, + { + "code_lines": 79, + "path": "Drivers/BSP/RS485/rs485.c" + }, + { + "code_lines": 79, + "path": "Drivers/BSP/UI/ui_port.c" + }, + { + "code_lines": 77, + "path": "App/algo/prot_set.h" + }, + { + "code_lines": 77, + "path": "Drivers/BSP/dbg_log.h" + }, + { + "code_lines": 71, + "path": "App/task/ui_task.c" + }, + { + "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" + }, + { + "code_lines": 54, + "path": "Drivers/BSP/TPAFE5160/tpafe5160.h" + }, + { + "code_lines": 51, + "path": "App/util/task_watchdog.h" + }, + { + "code_lines": 50, + "path": "App/algo/adc_health.h" + }, + { + "code_lines": 44, + "path": "App/algo/adc_input.h" + }, + { + "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" + }, + { + "code_lines": 41, + "path": "App/cfg/cfg_file.h" + }, + { + "code_lines": 41, + "path": "Drivers/BSP/NET/lftpd/private/lftpd_status.h" + }, + { + "code_lines": 41, + "path": "Drivers/BSP/UI/ui_page_ac.c" + }, + { + "code_lines": 34, + "path": "Drivers/BSP/NETCFG/net_cfg.h" + }, + { + "code_lines": 34, + "path": "Drivers/BSP/UI/ui_w_records.c" + }, + { + "code_lines": 33, + "path": "Drivers/BSP/NET/net_config.h" + }, + { + "code_lines": 33, + "path": "Drivers/BSP/NET/net_select.h" + }, + { + "code_lines": 32, + "path": "Drivers/BSP/RS485/rs485.h" + }, + { + "code_lines": 31, + "path": "App/log/sys_log.h" + }, + { + "code_lines": 31, + "path": "Drivers/BSP/UI/ui_deco.c" + }, + { + "code_lines": 29, + "path": "Drivers/BSP/NET/lftpd/lftpd_auth.h" + }, + { + "code_lines": 28, + "path": "App/algo/prot_elm.h" + }, + { + "code_lines": 28, + "path": "App/protocol/iec104/iec104_pts.h" + }, + { + "code_lines": 27, + "path": "App/algo/phasor.h" + }, + { + "code_lines": 27, + "path": "App/task/task_handles.h" + }, + { + "code_lines": 27, + "path": "Drivers/BSP/UI/ui_page_device_info.c" + }, + { + "code_lines": 26, + "path": "App/util/task_prof.h" + }, + { + "code_lines": 26, + "path": "Drivers/BSP/NET/net_pending.h" + }, + { + "code_lines": 25, + "path": "Drivers/BSP/UI/ui_port.h" + }, + { + "code_lines": 25, + "path": "Drivers/BSP/bsp_err.h" + }, + { + "code_lines": 24, + "path": "Drivers/BSP/UI/ui_tokens.h" + }, + { + "code_lines": 22, + "path": "App/cfg/cfg_file_store.h" + }, + { + "code_lines": 22, + "path": "App/util/sha256.h" + }, + { + "code_lines": 22, + "path": "Drivers/BSP/NETCFG/ftp_cfg.h" + }, + { + "code_lines": 22, + "path": "Drivers/BSP/UI/ui_page_runlog.c" + }, + { + "code_lines": 22, + "path": "Drivers/BSP/UI/ui_page_selftest.c" + }, + { + "code_lines": 20, + "path": "Drivers/BSP/UI/ui_page_dc.c" + }, + { + "code_lines": 19, + "path": "App/log/console_log.h" + }, + { + "code_lines": 19, + "path": "Drivers/BSP/UI/ui_w_records.h" + }, + { + "code_lines": 18, + "path": "App/app/sys_events.h" + }, + { + "code_lines": 18, + "path": "Drivers/BSP/NET/lftpd/lftpd.h" + }, + { + "code_lines": 18, + "path": "Drivers/BSP/NET/net_listener.h" + }, + { + "code_lines": 18, + "path": "Drivers/BSP/UI/ui_net_sink.h" + }, + { + "code_lines": 17, + "path": "App/algo/sample_ring.h" + }, + { + "code_lines": 16, + "path": "Drivers/BSP/UI/ui_menu_view.h" + }, + { + "code_lines": 15, + "path": "App/algo/freq.h" + }, + { + "code_lines": 15, + "path": "App/util/sys_clock.h" + }, + { + "code_lines": 15, + "path": "Drivers/BSP/UI/ui_net_sink_backend_mcu.c" + }, + { + "code_lines": 15, + "path": "Drivers/BSP/UI/ui_standby.h" + }, + { + "code_lines": 14, + "path": "Drivers/BSP/UI/ui_style.c" + }, + { + "code_lines": 13, + "path": "App/log/soe_store.h" + }, + { + "code_lines": 13, + "path": "App/task/adc_task.h" + }, + { + "code_lines": 13, + "path": "Drivers/BSP/NET/net_events.h" + }, + { + "code_lines": 13, + "path": "Drivers/BSP/UI/ui_pwd_def.h" + }, + { + "code_lines": 12, + "path": "App/app/app_main.h" + }, + { + "code_lines": 12, + "path": "App/fs/vol_lock.h" + }, + { + "code_lines": 12, + "path": "Drivers/BSP/GD5F2GQ5UE/nand_ftl.h" + }, + { + "code_lines": 12, + "path": "Drivers/BSP/NET/lftpd/private/lftpd_io.h" + }, + { + "code_lines": 12, + "path": "Drivers/BSP/UI/ui_net_sink_backend.h" + }, + { + "code_lines": 11, + "path": "App/sync/time_sync.h" + }, + { + "code_lines": 11, + "path": "App/task/net_task.h" + }, + { + "code_lines": 11, + "path": "App/task/rs485_task.c" + }, + { + "code_lines": 11, + "path": "App/task/rs485_task.h" + }, + { + "code_lines": 11, + "path": "App/task/ui_task.h" + }, + { + "code_lines": 11, + "path": "Drivers/BSP/NET/lftpd/lftpd_string.c" + }, + { + "code_lines": 11, + "path": "Drivers/BSP/NET/lftpd/private/lftpd_inet.h" + }, + { + "code_lines": 11, + "path": "Drivers/BSP/UI/ui_menu.h" + }, + { + "code_lines": 11, + "path": "Drivers/BSP/UI/ui_page_net_cfg.h" + }, + { + "code_lines": 11, + "path": "Drivers/BSP/UI/ui_style.h" + }, + { + "code_lines": 11, + "path": "Drivers/BSP/dbg_cfg.h" + }, + { + "code_lines": 10, + "path": "App/task/default_task.h" + }, + { + "code_lines": 6, + "path": "Drivers/BSP/UI/ui_page_password.h" + }, + { + "code_lines": 5, + "path": "App/cfg/prot_set_store.h" + }, + { + "code_lines": 5, + "path": "App/task/iec104_serv.h" + }, + { + "code_lines": 5, + "path": "App/task/protect_task.h" + }, + { + "code_lines": 5, + "path": "Drivers/BSP/NET/lftpd/private/lftpd_log.h" + }, + { + "code_lines": 5, + "path": "Drivers/BSP/UI/ui_deco.h" + }, + { + "code_lines": 5, + "path": "Drivers/BSP/UI/ui_page_ac.h" + }, + { + "code_lines": 5, + "path": "Drivers/BSP/UI/ui_page_dc.h" + }, + { + "code_lines": 5, + "path": "Drivers/BSP/UI/ui_page_device_info.h" + }, + { + "code_lines": 4, + "path": "Drivers/BSP/UI/ui_page_runlog.h" + }, + { + "code_lines": 4, + "path": "Drivers/BSP/UI/ui_page_selftest.h" + }, + { + "code_lines": 3, + "path": "App/app/app_cfg.h" + }, + { + "code_lines": 3, + "path": "Drivers/BSP/NET/lftpd/private/lftpd_string.h" + } + ] +} From 6505cef9dfe55e40cc50287eb2d4b1cce46b2ea1 Mon Sep 17 00:00:00 2001 From: Wanderingss <1624155937@qq.com> Date: Fri, 23 Oct 2026 12:33:13 +0800 Subject: [PATCH 02/15] =?UTF-8?q?feat(=E9=97=A8=E7=A6=81):=20coding-standa?= =?UTF-8?q?rd=20=E5=8A=A0=20CODE-RULE-20=20=E7=A6=81=20C=20=E5=BA=93?= =?UTF-8?q?=E5=A0=86=E5=88=86=E9=85=8D=EF=BC=88=E9=94=80=20=C2=A74.8=20?= =?UTF-8?q?=E7=A9=BA=E7=99=BD=20=E2=91=A1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 规则:在**掩码后**的文本上匹配 malloc/calloc/realloc/strdup/strndup/ free/aligned_alloc 调用。三条判据都是踩过坑之后定的—— - 大小写敏感:pvPortMalloc / vPortFree(唯一允许的配对)不会误报; - 词边界 \b:dhara_nand_is_free(、buf_alloc( 这类同形后缀不误报; - 用掩码:注释与字符串字面量里写 malloc 不报(规范示例就踩过这种)。 覆盖名比 AGENTS 列的五枚多带上 strndup / aligned_alloc——同族同类, 漏一个就等于留后门。 出处不在规范 V1.0,在平台约束:MicroLIB 的 C 堆由 startup_stm32f407xx.s 的 Heap_Size EQU 0x200 只有 512 B,KB 级分配必返 NULL,而模拟器(宿主 CRT 堆)完全正常、**照不出来**——配置引擎 2026-09-18 那次堆分配纠偏与 lftpd 的 malloc 隐患都是**先上线才发现**的那类,靠评审等于没管。 落地前按 门禁说明/coding-standard.md §8 的纪律先全库普查:钩子范围内 C/H 共 151 个文件,命中**只有** simulator/sim/cfg_file_store_sim.c 两行, 即 cfg_store_alloc/cfg_store_free = **平台分配后端的宿主侧实现**(设计依据 写在 docs/子系统设计/配置文件设计说明.md 的"MCU 端 = pvPortMalloc、 模拟器端 = 宿主 CRT")。所以零基线、直接硬拦截;豁免用脚本里的 HEAP_ALLOC_ALLOWLIST(**文件路径 + 函数名**双条件),刻意不做目录级豁免: 下次谁要碰 C 堆就得往这张表加一行,评审看得见。 自证:临时样例文件里 malloc/calloc/free 各报一处,pvPortMalloc/vPortFree/ buf_alloc(、注释、字符串五种陷阱形态全部不误报; pre-commit run coding-standard --all-files → **Passed**(= 全库零存量)。 顺带修三处口径不一致: - 规范 V1.0 §6.1 的示例自己写着 malloc/free(通用 C 模板遗留)→ 已在示例 下加"本仓例外,别照抄"批注,事实源指向 AGENTS.md 平台约束; - docs/门禁说明/mem-budget.md 里"这条纪律只靠 AGENTS 铁律与评审"已过期 → 更正为由 CODE-RULE-20 机器拦截; - AGENTS.md 平台约束条加"已机器拦截 + 唯一豁免"一句;scripts/README.md 补登 check_test_coverage.py(上一个提交漏登记)。 另:本次一并修好上一处编辑事故——报告 §4.8 曾被我的脚本调用截成半句 (只落第一行、丢掉 malloc 那半句),已重写为"两处空白均已闭合"。 自查:pre-commit run --files(9 个改动文件)适用项全 Passed; check_doc_links.py --all = 35 处,与主干同数(本分支贡献 0); check_ids.py --strict 通过(裸编号 P0-1 改平实说法,避免记成新账); 9 个文件 BOM/CR/U+FFFD/行尾与 HEAD 逐项一致。本轮零 .c/.h 改动,未跑 @build、未占 build/bench 锁;分支已 rebase 到 v2-on-ui,覆盖率脚本全量 回归仍 16/16、line=2552/2893 不变。 --- AGENTS.md | 2 +- docs/代码规范/pre-commit使用说明.md | 3 +- docs/代码规范/嵌入式C语言代码规范_V1.0.md | 9 ++++ docs/测试说明/主机单测覆盖率口径.md | 2 +- docs/门禁说明/coding-standard.md | 30 ++++++++++++- docs/门禁说明/mem-budget.md | 2 +- docs/问题记录/项目审查与优化建议报告.md | 51 ++++++++++++++++++----- scripts/README.md | 1 + scripts/check_coding_standard.py | 34 +++++++++++++-- 9 files changed, 115 insertions(+), 19 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4926271e..98d69e47 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -110,7 +110,7 @@ - **`osKernelStart` 之前禁止任何 FreeRTOS/CMSIS-RTOS 创建类 API**(`osMessageQueueNew` 等会杀死启动,2026-09-20 实证根因)→ 内核前标准模式(纯寄存器/HAL 轮询 + **无锁静态缓冲** + 内核启动后**惰性创建**真队列)见 `docs/架构设计/项目启动顺序管理.md` - **就绪屏障位必须 `osFlagsNoClear`**:`osEventFlagsWait` **默认消费**所等位,就绪位被先消费掉 → `ftpTask` 的 `WaitAll` 永不成立 → **FTP 21 端口静默不监听**(无日志无报错)。命令位(`SYS_EVT_CFG_SAVE`/`NET_RECONFIG`)保持消费型 -- **自研代码禁用 C 库堆分配**(`malloc/calloc/realloc/strdup/free`):MicroLIB 的 C 堆由 `startup_stm32f407xx.s` 的 `Heap_Size EQU 0x200` 限定为 **512 B**,KB 级分配必然返回 NULL,而**模拟器(宿主 CRT 堆)完全正常、照不出来** → 只用 `pvPortMalloc/vPortFree`,或把分配收进平台后端 → `docs/内存管理/RAM使用分析与优化方案.md` +- **自研代码禁用 C 库堆分配**(`malloc/calloc/realloc/strdup/free`):**2026-10-23 起由提交门禁机器拦截**(`coding-standard` 的 CODE-RULE-20,唯一豁免 = 平台分配后端的宿主侧实现,见 `docs/门禁说明/coding-standard.md` §7):MicroLIB 的 C 堆由 `startup_stm32f407xx.s` 的 `Heap_Size EQU 0x200` 限定为 **512 B**,KB 级分配必然返回 NULL,而**模拟器(宿主 CRT 堆)完全正常、照不出来** → 只用 `pvPortMalloc/vPortFree`,或把分配收进平台后端 → `docs/内存管理/RAM使用分析与优化方案.md` - **内存预算紧张**:新增任何任务/缓冲/栈前先核算 RAM 余量;改栈或加大局部变量**前后各跑一次** `scripts/stack_report.py`(静态最坏链 + 串口 `[STACK]` 水位)与 `scripts/ram_report.py`(.map 静态占用);**总量红线已机械化**:`mem-budget` 钩子拦 RW_IRAM1/RW_CCM >92%,子系统配额 / CCM 台账 / 趋势见 `docs/内存管理/RAM预算台账.md`(**每次 `@build` 后跑 `scripts/ram_baseline.py --update`**)→ `docs/内存管理/` - FreeRTOS V10.3.1(CMSIS-RTOS V2);HAL 时基 = **TIM7**(非 SysTick),tick = 1ms;NVIC 4 位优先级分组,**外设中断优先级 ≥ 5** diff --git a/docs/代码规范/pre-commit使用说明.md b/docs/代码规范/pre-commit使用说明.md index ca4048e0..21b43b0d 100644 --- a/docs/代码规范/pre-commit使用说明.md +++ b/docs/代码规范/pre-commit使用说明.md @@ -245,7 +245,8 @@ $env:PATH = [Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [Env | `scripts/check_font_charset.py` | scripts/ | UI 字符集差集检查(复用 `tools/font_gen/` 提取与 cmap 解析规则,防缺字白框) | | `scripts/check_layer_deps.py` | scripts/ | 分层方向门禁(BSP 反向 include App 的增量拦截,存量基线只减不增) | | `scripts/check_bsp_err.py` | scripts/ | BSP 错误码与超时命名门禁(三条规则的增量拦截,存量 19 条基线只减不增;`--emit-baseline` 建基线) | -| `docs/代码规范/嵌入式C语言代码规范_V1.0.md` | docs/代码规范/ | 代码规范原文(CODE-RULE-01~CODE-RULE-19 规则来源) | +| `docs/代码规范/嵌入式C语言代码规范_V1.0.md` | docs/代码规范/ | 代码规范原文(CODE-RULE-01~CODE-RULE-20 规则来源;`CODE-RULE-20` 出处不在规范正文而在 +AGENTS.md 平台约束,见 `docs/门禁说明/coding-standard.md`) | | `docs/代码规范/cppcheck使用说明.md` | docs/代码规范/ | cppcheck 钩子详解(规则/配置/抑制) | | `docs/文档规范.md` | docs/ | 文档体系规范(链接形态/命名/门禁要求) | | `docs/代码规范/pre-commit使用说明.md` | 本文档 | 部署与使用说明 | diff --git a/docs/代码规范/嵌入式C语言代码规范_V1.0.md b/docs/代码规范/嵌入式C语言代码规范_V1.0.md index 712a129a..ffc42d66 100644 --- a/docs/代码规范/嵌入式C语言代码规范_V1.0.md +++ b/docs/代码规范/嵌入式C语言代码规范_V1.0.md @@ -370,6 +370,15 @@ void function(void) { p_data = NULL; } ``` + +> ⚠ **本仓例外(2026-10-23 补,别照抄这段示例)**:示例里的 `malloc`/`free` 是**通用 C 写法**,本仓 +> **禁止**——工具链是 MicroLIB,其 C 堆由 `startup_stm32f407xx.s` 的 `Heap_Size EQU 0x200` 只有 **512 B**, +> KB 级分配必然返回 NULL,而模拟器(宿主 CRT 堆)完全正常、**照不出来**(只有真机才炸)。这段示例只教 +> 三件事:**声明即初始化、检查 NULL、释放后置 NULL**。实际分配一律 `pvPortMalloc`/`vPortFree`,或把 +> 分配**收进平台后端**(MCU 端与宿主端各一份实现,例:`simulator/sim/cfg_file_store_sim.c` 的 +> `cfg_store_alloc`)。机器拦截 = 提交门禁 `coding-standard` 的 **CODE-RULE-20**,细则见 +> `docs/门禁说明/coding-standard.md`。 + ### 6.2 函数设计原则 1. 函数应遵循"单一职责原则",一个函数只做一件事 2. 函数长度不宜过长,一般不超过50行 diff --git a/docs/测试说明/主机单测覆盖率口径.md b/docs/测试说明/主机单测覆盖率口径.md index bc3bf7d7..14425eb3 100644 --- a/docs/测试说明/主机单测覆盖率口径.md +++ b/docs/测试说明/主机单测覆盖率口径.md @@ -196,5 +196,5 @@ | 日期 | 说明 | |---|---| -| 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(` 正则那条仍开放。**同日补 §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 | 新建。落地 `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` 一条路 | diff --git a/docs/门禁说明/coding-standard.md b/docs/门禁说明/coding-standard.md index 7f55e36a..c254671f 100644 --- a/docs/门禁说明/coding-standard.md +++ b/docs/门禁说明/coding-standard.md @@ -1,8 +1,10 @@ # `coding-standard` — 编码规范 V1.0 的可机械化子集 -> 一句话:它只查规范里**能用形状判定的 19 条**(命名、宏、注释块、括号、禁用函数、goto、 +> 一句话:它只查规范里**能用形状判定的 20 条**(命名、宏、注释块、括号、禁用函数、goto、 > 递归、可变参数……)。规范里"名字要达意、函数要短、职责要清"这些它一条都管不了。 -> 规范正文 = `docs/代码规范/嵌入式C语言代码规范_V1.0.md`。 +> 规范正文 = `docs/代码规范/嵌入式C语言代码规范_V1.0.md`。**第 20 条(禁 C 库堆分配)出处不在规范正文** +> —— 规范 §6.1 的示例自己就写着 `malloc`/`free`(通用 C 模板遗留,已同日在示例下加批注), +> 它的出处是平台约束:MicroLIB 的 C 堆只有 512 B。 - 配置:`entry: python scripts/check_coding_standard.py`、`pass_filenames: true`、 排除 `test/`、CubeMX 生成的 `Src/`、`Inc/`、第三方与 `Lib/`。 @@ -29,6 +31,7 @@ | CODE-RULE-17 | 非 static 函数必须在配对 `.h` 声明(无配对头则跳过) | 2.2 | | CODE-RULE-18 | 禁止 Yoda 条件(字面量必须在运算符右侧) | 6.3.1 | | CODE-RULE-19 | 函数定义左大括号与右括号同行 | 4.4.2 | +| CODE-RULE-20 | **禁止 C 库堆分配** `malloc/calloc/realloc/strdup/strndup/free/aligned_alloc` | 平台约束(**非规范 V1.0 条款**) | 规则号里 **CODE-RULE-14 是缺的**:条款作废后编号不回收,留着是为了让历史文档里的"14"不指向别的东西。 @@ -37,6 +40,11 @@ - 这 19 条如果靠人看,评审成本极高且必然漏;机械化之后评审可以把注意力留给机器管不了的部分。 - 有硬约束背景的两条特别值:**禁止递归**与**禁止可变参数**——本仓 `DBG_*` 每个调用点内联约 90 B Flash + 216 B 栈,栈与 Flash 都是红线资源;`g_` 前缀和指针初始化则是最容易埋雷的静态状态。 +- **CODE-RULE-20 有真事垫背**(2026-10-23 落地):MicroLIB 的 C 堆由 `startup_stm32f407xx.s` 的 + `Heap_Size EQU 0x200` = **512 B**,而它的踩坑方式是**模拟器全对、真机必炸**:配置引擎 2026-09-18 + 那次堆分配纠偏(4 处 KB 级 `malloc` 落在 C 堆上、真机返 NULL,见 + `docs/子系统设计/配置文件设计说明.md` v1.11 记录)与 lftpd 的 `malloc` 隐患(`docs/内存管理/RAM使用分析与优化方案.md` §4.2.1)都是 + **上线后才发现**的那类。宿主照不出来的缺陷,靠评审等于没管。 ## 3. 触发面 / 看不见什么 @@ -45,6 +53,10 @@ - **后果**:同一份规范对 `test/board/`(编进固件的 6300+ 行)不生效,对 CubeMX 生成区也不生效。 `test/` 的静态检查由 `test-cppcheck` 兜一半,形状规范没人管。 - 逐文件判定:跨文件的事实(声明在 A、定义在 B、调用在 C)它只看到眼前这一个文件。 +- CODE-RULE-20 跑在**掩码后**的文本上(注释与字符串里写 `malloc` 不报);换来的代价是: + 把分配藏进**宏展开**里它也看不见。 +- CODE-RULE-20 有一处**点名豁免**(`simulator/sim/cfg_file_store_sim.c` 的 `malloc`/`free`): + 那是平台分配后端的**宿主侧实现**(MCU 侧同名后端用 `pvPortMalloc`),不是漏网。 ## 4. 报错怎么读 @@ -72,6 +84,10 @@ Drivers/BSP/X/x.c:42 [CODE-RULE-09] 函数 foo_bar 缺少函数注释(应含 5. `test/` 与生成区整目录不查(§3),这三类目录的规范只靠人。 6. 逐文件 + 正则形状:把违规写进字符串字面量、注释、宏展开里就看不见;反过来说,宏里真正的 违规代码也可能看不见。 +7. CODE-RULE-20 **大小写敏感**:这是特性也是盲区——`pvPortMalloc`/`vPortFree` 因此不误报, + 但写成 `Malloc(` 就绕过了(同形后缀也一样:`buf_free(` 因词边界不成立而不算违规)。 +8. CODE-RULE-20 **只管堆分配函数名**:`alloca` 与变长数组(VLA)是**栈**上的坑,不归它; + 分配不释放(泄漏)、释放后继续用(UAF)也不归它——本条只判"别碰 C 堆"这一件事。 补录(判据细节,迁移自原手册): @@ -86,15 +102,25 @@ Drivers/BSP/X/x.c:42 [CODE-RULE-09] 函数 foo_bar 缺少函数注释(应含 - **没有基线**:硬拦截,新写的代码必须合规;因此**放宽任何一条都会立刻降低约束**,需谨慎。 - 豁免方式只有两种:脚本里的显式豁免名单(按名字形状,如 `HAL_` 回调)、或改代码。 +- CODE-RULE-20 的豁免是脚本里的 `HEAP_ALLOC_ALLOWLIST`,按**文件路径 + 具体函数名**双条件放行, + 现仅一条:`simulator/sim/cfg_file_store_sim.c` → `malloc`/`free`(平台分配后端的宿主侧实现, + 设计依据见 `docs/子系统设计/配置文件设计说明.md` 的 MCU 端/宿主端两段式后端)。 + ⚠ 刻意**不做目录级豁免**:下次谁要 `malloc` 就得往这里加一行,评审看得见。 ## 8. 改这个门禁本身 - 新增规则前先在**全库跑一次**统计违规数;有存量的规则要么先清存量,要么走"基线只减不增" 的模式(参考 `bsp-err`、`layer-deps`),不要直接挂上硬拦截卡住所有人。 - 规则作废**不回收编号**(见 CODE-RULE-14)。 +- 新增规则前先全库普查违规数(本节上一条已写)。CODE-RULE-20 落地时的普查结果:钩子范围内 + C/H 共 **151 个文件**,命中只有 `cfg_file_store_sim.c` 那两行(即 §7 那条豁免)→ 直接**硬拦截、 + 零基线**;`pre-commit run coding-standard --all-files` 实测 Passed。自证:临时样例里 + `malloc/calloc/free` 各报一处,`pvPortMalloc/vPortFree/buf_alloc(`、注释、字符串 全部不误报。 ## 9. 变更记录 | 日期 | 变更 | |---|---| | 2026-10-23 | 首篇逐项文档(19 条规则逐条列出,含 CODE-RULE-14 缺号说明) | +| 2026-10-23 | 新增 **CODE-RULE-20 禁 C 库堆分配**(硬拦截、零基线;唯一豁免 = 平台分配后端的 + 宿主侧实现文件)。出处是 AGENTS.md 平台约束而非规范 V1.0;规范 §6.1 示例已同日加"本仓禁止"批注 | diff --git a/docs/门禁说明/mem-budget.md b/docs/门禁说明/mem-budget.md index d99aeb56..4ff03987 100644 --- a/docs/门禁说明/mem-budget.md +++ b/docs/门禁说明/mem-budget.md @@ -34,7 +34,7 @@ ## 3. 触发面 / 看不见什么 - 只看 `RW_IRAM1` 与 `RW_CCM` 两个**静态区域**;不看:栈运行时水位、FreeRTOS 堆运行时余量、 - 堆碎片、MicroLIB 的 512 B C 堆(那条纪律是"自研代码禁用 `malloc/free`",靠 `AGENTS.md` 铁律与评审)。 + 堆碎片、MicroLIB 的 512 B C 堆(那条纪律是"自研代码禁用 `malloc/free`",**2026-10-23 起由 `coding-standard` 的 CODE-RULE-20 机器拦截**(见 `coding-standard.md` §1/§7),不再只靠评审)。 - `.map` 缺失即跳过 → 无构建的机器(含远端 CI)这一门不产生信息。 ## 4. 报错怎么读 diff --git a/docs/问题记录/项目审查与优化建议报告.md b/docs/问题记录/项目审查与优化建议报告.md index fcff79e4..979878d0 100644 --- a/docs/问题记录/项目审查与优化建议报告.md +++ b/docs/问题记录/项目审查与优化建议报告.md @@ -19,7 +19,7 @@ | 文档与治理 | 🟢 良好 | 39 个提交钩子 + 16 套主机单测;工程 md 91 份;`doc-links` 全库 0 问题 | 新增 `编号门禁增量拦截` 与内存快照回填常态化 | | 内存与 Flash | 🟡 可控但已换位置 | SRAM1 **57.4%**(余 55,808 B)、Flash **22.1%**、**CCM 87.9%(余 7,904 B)** | **红线从 SRAM1 迁到 CCM**(堆 2026-10-11 迁 CCM + 池 40→16 KB 后 SRAM1 已宽松) | | 可靠性与自恢复 | 🟢 良好 | 4 个 Fault 全采集现场;IWDG 五面 `required_mask`;复位原因与 `[STACK]` 水位常开 | 台架复跑持续 PASS(今日真机 708 ms 起网、无 `[ERR]`/Fault) | -| 测试与验证 | 🟡 半办 | 16 套主机单测 + `ui-golden`/`font-charset` 入门禁;TC 双向追溯 593∶271 交集 265;**覆盖率口径已落地**(`../测试说明/主机单测覆盖率口径.md`:16 套并集 2552/2893 行) | `coding-standard` **仍无 `malloc` 正则**;覆盖率**未挂 pre-commit**(口径文档 §8 三条理由)、**没有分支覆盖** | +| 测试与验证 | 🟡 半办 | 16 套主机单测 + `ui-golden`/`font-charset` 入门禁;TC 双向追溯 593∶271 交集 265; **§5.5 两条补强已全部落地**(2026-10-23:覆盖率口径 `../测试说明/主机单测覆盖率口径.md` + `malloc(` 正则 `coding-standard` CODE-RULE-20) | 覆盖率**未挂 pre-commit**(口径文档 §8 三条理由);**分支覆盖已裁定不做** (§2.1 决策记录)→ 现在真正的半办是**覆盖面**:首登照出 **125 个生产源文件从未进过任何主机单测** | | UI 标准化 | 🟢 基本落地 | `ui_tokens.h`(L0 令牌)+ `ui_page_registry.c`(描述符注册表)+ 7 个内容页 + 两枚 UI 钩子 | **U1/U3/U4 从"未做"变为已落地**;U2 只有 `ui_w_records` 一个组件 | ### 0.3 主线建议(2026-10-22) @@ -30,8 +30,10 @@ 92% 钩子会先报警。SRAM1 与 Flash 都已不是卡点。 3. **结构性内存优化整簇暂缓**(R-2 后半 / R-3 / R-4 / R-6,用户裁定 2026-10-15),治理面常开; 重开条件见 §5.3。今天又收口一项:**F-4 判定不做、F-6 已落地、F-5 降级观察**(§5.4)。 -4. **验证的下一步不是加用例而是补面**:覆盖率口径 + `malloc(` 门禁正则(§5.5 第 5 条),把"读代码 - 才发现"的缺陷类型(N-12/N-13 那类)变成机器拦截。 +4. **补面两条已全部落地(2026-10-23)**:覆盖率口径(`../测试说明/主机单测覆盖率口径.md`)+ + 堆禁令机器拦截(`coding-standard` **CODE-RULE-20**,§5.5 第 5 条)。**剩下的量在覆盖面**:覆盖率首登 + 照出 **125 个生产源文件从未进过任何主机单测**(约 11,839 行,`net_socket.c` 1,251 行最大)——给它们建桩 + 比把在测的 19 个文件从 88% 推到 95% 值钱得多(N-12/N-13 那类"读代码才发现"的缺陷就是这么照出来的)。 5. **仓库体积重心在外部资料**(`docs/芯片手册` 717 文件 / 39.5 MB,占 docs 的 87%;`.git` 248 MB), 见 §3.3 D-1 与登记簿 N-29——已决策暂不处理,但**推送/对外协作前必须重评**。 @@ -67,7 +69,9 @@ B0h 验 `ECC_EN`**——原先那两行的注释自己写着"否则后续 I/O | 存储/日志 | P2-10 ❌(N-25) | 卷锁现有 5 个使用者(配置原子写 / 日志 / SOE / lftpd_io / defaultTask),容量轮转删除与 UI 读并发仍未全覆盖 | 登记簿 S-5 面(卷锁覆盖面) | | 仓库 | D-1 ❌(N-29) | jpg 707 个 / 37.3 MB;`docs` 45.3 MB 里芯片手册占 39.5 MB;`.git` **248 MB** | 降采样 / 拆 submodule / LFS 三选一 + 历史重写;已决策暂不做 | | UI | U2 残余(U 簇) | 组件库只有 `ui_w_records.c/h` 一个;其余仍是页内自绘 | 按产品需求决定要不要继续抽组件 | -| 测试 | ~~覆盖率~~ ✅ **2026-10-23 落地** / `malloc` 门禁 ❌ | 覆盖率:`scripts/check_test_coverage.py` + 基线 `scripts/test_coverage_baseline.json`(口径与**盲区**见 `../测试说明/主机单测覆盖率口径.md`)——首登 16 套并集 2552/2893 行(88.2%)、**未触及 125 个生产源文件 / 约 11.8k 行**;`check_coding_standard.py` 仍无 `malloc(` 正则 | 剩 `malloc(` 正则 + 覆盖率挂钩子(先定「缺 VS 组件时 skip 还是 FAIL」);见 §5.5 与 §4.8 | +| ~~测试~~ ✅ **两条全部闭环 2026-10-23**(覆盖率口径 + 堆禁令 CODE-RULE-20) | 覆盖率:`scripts/check_test_coverage.py` + 基线 `scripts/test_coverage_baseline.json`(口径与**盲区**见 `../测试说明/主机单测覆盖率口径.md`)——首登 16 套并集 2552/2893 行(88.2%)、**未触及 125 个生产源文件 / 约 11.8k 行**。堆禁令:`check_coding_standard.py` **CODE-RULE-20**(硬拦截、零基线;唯一豁免 = `simulator/sim/cfg_file_store_sim.c` +| 的平台分配后端宿主侧实现,见 `../门禁说明/coding-standard.md` §7) | 无(残余三项**已归口或已裁定**:覆盖率挂 +| pre-commit 的前置写在口径文档 §8;分支覆盖**不做**(§2.1 决策);扩大覆盖面按 §0.3 第 4 条) | | CI | T-7 ❌ | 远程 CI 只跑本机一台 runner,且不跑 `@build` | 保持现状即可;扩机器前重读 `docs/CI/README.md` | ## 1. 审查方法与证据基线 @@ -226,8 +230,15 @@ defaultTask 单属主;原子写经卷锁;旧档迁移已删除。**仍列为 - 主机侧 **16 套**单测(提交门禁实跑);板级 `test/board/` 19 文件、套件开关互斥受 `test-suite-switch` 管 - TC 双向追溯:代码侧 593 号 / 文档侧 271 号 / 交集 265,差异 13 处(当前仅告警,未升门禁) -- **覆盖率口径已落地(2026-10-23)**:`scripts/check_test_coverage.py`(mscoverage 动态插桩 + Cobertura 并集) - 这条铁律目前只靠人读 + 模拟器照不出来(MicroLIB C 堆仅 512 B) +- **两处空白已全部闭合(2026-10-23)**:① 覆盖率口径 → `scripts/check_test_coverage.py`(mscoverage 动态 + 插桩 + Cobertura 并集)+ 基线 `scripts/test_coverage_baseline.json`;口径 / 工具链实测 / **九条盲区**见 + `../测试说明/主机单测覆盖率口径.md`(首登 2552/2893 行、19 文件在测、**未触及 125 文件**)。 + ② C 库堆禁令 → `check_coding_standard.py` **CODE-RULE-20**(拦 `malloc/calloc/realloc/strdup/strndup/ + free/aligned_alloc(`;硬拦截、零基线;唯一豁免 = 平台分配后端的宿主侧实现 + `simulator/sim/cfg_file_store_sim.c`,见 `../门禁说明/coding-standard.md` §7)。禁令本身的事实源仍是 + AGENTS.md 平台约束与 `../内存管理/RAM使用分析与优化方案.md` §4.2.1——**为什么非得机器拦**:MicroLIB + 的 C 堆只有 512 B,而**模拟器(宿主 CRT 堆)完全正常、照不出来**——配置引擎 2026-09-18 的堆分配纠偏 + (4 处 KB 级 `malloc` 落错堆)与 lftpd 的 `malloc` 隐患都是**先上线才发现**的那类 - 破坏性套件(`ENABLE_DESTRUCTIVE_TESTS`)有**持久代价**(NAND 坏块记账不可逆),默认关闭是正确设计 ## 5. 内存与 Flash(2026-10-22 实测收口) @@ -285,7 +296,8 @@ defaultTask 单属主;原子写经卷锁;旧档迁移已删除。**仍列为 2. `check_mem_numbers.py --scan/--write`:文档里的内存数字一律由 `.map` 反查,禁止手填 3. 改栈 / 加大局部变量前后各跑一次 `stack_report.py`(静态 × 真机 `[STACK]`) 4. 子系统配额与 CCM 台账 → `../内存管理/RAM预算台账.md`;趋势 → `scripts/ram_baseline.tsv` -5. **仍未落地的一条补强**:`check_coding_standard.py` 加 `malloc(` 正则(把 C 库堆禁令变成机器拦截)。 +5. **两条补强已全部落地(2026-10-23)**:`malloc(` 正则 = `check_coding_standard.py` **CODE-RULE-20** + (硬拦截、零基线,判据与豁免见 `../门禁说明/coding-standard.md` §1/§7)。 另一条「覆盖率口径」✅ **已落地 2026-10-23** → `../测试说明/主机单测覆盖率口径.md`(口径 / 基线 / 盲区 一处写完,此处不复述)。⚠ 它落地的是**行与函数**覆盖,**分支覆盖本机拿不到**(无 gcc,MSVC 14.51 已无 `/coverage`)——「覆盖到哪些分支」这一半**已裁定不补**(不为它引入第二套工具链,见 @@ -334,7 +346,7 @@ W6 簇按 U4 → U1 → U3 → U2 的顺序执行完毕(U2 停在"够用即止 |---|---|---| | ~~W3 预算与内存~~ ⏸ | R-9 ✅ / R-2 后半、R-3、R-4、R-6 暂缓 | 暂缓期间无验收动作,观测照常 | | W4 结构债 | N-14 ⏸、F-5 🟡、S-1/S-2/S-4/S-5、N-25 | `--strict` 已默认;S-5 契约/门禁已落地并收编三模块,`consume-result` 已把"丢弃返回值"纳入机械检查(基线 19→1→38;剩 GD5F 码集收编、C4 快照、37 条丢弃点、标坏策略) | -| W5 验证常态 | ~~覆盖率~~ ✅ 2026-10-23、`malloc` 正则、TC 差异 13 处升门禁 | 逢窗口顺手做 | +| W5 验证常态 | ~~覆盖率~~ ✅、~~`malloc` 正则~~ ✅(均 2026-10-23)、TC 差异 13 处升门禁 | 剩 TC 一条 | | W6 UI 扩展 | U2 残余、U5 页量 | 随产品需求,不再单列专项 | | W7 仓库决策 | D-1(N-29) | 已决策暂不处理;对外交付前重评 | @@ -451,10 +463,25 @@ admin/admin(SHA-256 `pwd_hash`)→ 该脚本**必然登录失败**;它是 | | 数字联动照做:两份 `data/` 快照重跑、`ram_baseline --update/--write`、`check_mem_numbers --write`、FLASH 与 任务栈两张配图重算重生成(逐文本复查越界 = 0)、本文 §1.3/§5.1/§5.4 与部署文档 §3.3 同步;§0.4 的 S-2 行按 「完成即销项」改写、§9 拆出 S-2 行标 ✅。 | +| **2026-10-23** | **执行 §5.5 第 5 条后半:C 库堆禁令变成机器拦截**(§4.8 空白 ②,CODE-RULE-20) | 按本文件 §8 的既有纪律**先全库普查再动手**:`coding-standard` 钩子范围内 C/H 共 **151 个文件**,`malloc` 族只命中 +| | | `simulator/sim/cfg_file_store_sim.c` 两行(`cfg_store_alloc`/`cfg_store_free` = 平台分配后端的**宿主侧实现**, +| | | 设计依据写在 `../子系统设计/配置文件设计说明.md` 的"MCU 端 = pvPortMalloc、模拟器端 = 宿主 CRT")——**不是漏网**, +| | | 所以零基线、直接硬拦截;豁免走脚本里的 `HEAP_ALLOC_ALLOWLIST`(**文件路径 + 函数名**双条件),刻意不做目录级豁免: +| | | 下次谁要碰 C 堆就得往那张表加一行,评审看得见。判据 = 掩码后文本上匹配 `\b(malloc|calloc|realloc|strdup|strndup| +| | | free|aligned_alloc)\s*(\(`:大小写敏感 ⇒ `pvPortMalloc`/`vPortFree` 不误报;词边界 ⇒ `dhara_nand_is_free(`、`buf_free(` +| | | 不误报;注释与字符串里写 `malloc` 不报。**自证**:临时样例三处(`malloc/calloc/free`)各报一次、五种陷阱形态全部不报; +| | | `pre-commit run coding-standard --all-files` 实测 **Passed**(= 全库零存量)。 +| | | **顺带消掉一处自相矛盾**:规范 V1.0 §6.1 的示例自己写着 `malloc`/`free`(通用 C 模板遗留),已在其下方加 +| | | 「本仓例外,别照抄」批注并把事实源指向 AGENTS.md 平台约束;`docs/门禁说明/mem-budget.md` 里"这条纪律只靠评审" +| | | 的旧说法同步更正。AGENTS.md 平台约束条加"已机器拦截"一句。**本轮零 `.c/.h` 改动**(只动门禁脚本与文档), +| | | 未跑 `@build`、未占 build/bench 锁;详解归 `../门禁说明/coding-standard.md`(本处不复述判据) | | **2026-10-23** | **执行 §5.5 第 5 条前半:覆盖率口径落地并销项**(§4.8 空白 ①) | 新增 `scripts/check_test_coverage.py`(纯标准库)+ 基线台账 `scripts/test_coverage_baseline.json`。**工具链是实测出来的、不是查到的**:本机 PATH 上 gcc/gcov/clang/llvm-cov/lcov/gcovr **全无**;`cl /coverage`、`/fsanitize=coverage` 在 MSVC 14.51 已被砍——**只报 `warning D9002 忽略未知选项`,然后照常编译成功**(拿它做覆盖率=静默假数据,比报错更坏);`Team Tools\Dynamic Code Coverage Tools\CodeCoverage.exe`(17.0)的 `collect`/`analyze`/`instrument` 一律只打印「用法: CodeCoverage 」。最终走 **mscoverage(`Microsoft.CodeCoverage.Console.exe` 18.x)`collect -f cobertura`** 的**加载期动态插桩**,并实测它**原样透传子进程退出码**(子进程 `return 3` → `collect` 也返回 3)→ 于是「插桩运行下仍必须 PASS」可以当硬条件用。 | | | | **口径**:只算 `App/**` 与 `Drivers/BSP/**` 的行/函数、多套单测取**并集**、生成物(文件头写「自动生成 / 请勿手改」)不进分母、分母一律来自插桩报告**不猜行数**;配置零复制——脚本 import 各运行器的 `TestBuild`、开关取 `unit_test_harness._BASE_FLAGS`,只追加 `/Zi /Od` 与 `/link /INCREMENTAL:NO`,发现规则与 L0.5 同一条 `test/host/**/run_unit_test.py`,**不会漂出第二套口径**。 | | | | **首登数字(2026-10-23)**:16 套全通过,并集 2552/2893 行(88.2%)、193/207 函数(93.2%)、19 个生产文件在测,**未触及 125 个生产源文件 / 约 11,839 行**(`net_socket.c` 1,251 行最大)——最后一栏才是这套口径真正照出来的东西。**门禁自证**:人为抬高基线后,行 / 函数 / 总计三条回退路径都如期报 REGRESS 且退出码 1;`--module` 子集运行不会假报总计回退(首版就会,已修)。**刻意未挂 pre-commit**:三条理由与可直接粘的 YAML 片段写在 `../测试说明/主机单测覆盖率口径.md` §8,接线者照 §8 落地即可。 | -| | | **仍开放**:§5.5 第 5 条的另一半(`check_coding_standard.py` 加 `malloc(` 正则)与**分支覆盖**。分支覆盖**不是拿不到**:同日被用户问住后复测,本机 WSL2 Ubuntu 有 gcc/gcov 15.2.0,16 套单测在 gcc 下**全部编过且全绿**、`gcov -b -c` 出分支数据(`nand_ftl.c` 分支至少走过一次 41.40%);但换编译器就是换分母(`sha256.c` gcc 105 行 vs MSVC 114 行)→ **用户裁定只保留 MSVC 一条通道**(多一套工具链=多一套分母 +多一本台账 + 多一个"这台机器有没有 WSL"的未决项),分支覆盖**记为已接受缺口**、不是待办。实测表与决策依据见 `../测试说明/主机单测覆盖率口径.md` §2.1。本轮只加 `.py`/`.json`/`.md`,无 `.c/.h` 改动,不重跑 `@build`;文档面动 §0.1 / §0.4 / §4.8 / §5.5 / §7 与 `docs/README.md` 索引各一处。 | +| | | **同日另一半也落地**(§4.8 空白 ②):`check_coding_standard.py` 加 **CODE-RULE-20** 禁 C 库堆分配, +| | | 硬拦截零基线(全库普查 151 个 C/H 只有 `cfg_file_store_sim.c` 两行命中 = 平台后端的宿主侧实现, +| | | 点名进 `HEAP_ALLOC_ALLOWLIST`,刻意不做目录级豁免);规范 §6.1 示例自己写着 `malloc/free` 的 +| | | 自相矛盾也同日加了"本仓禁止"批注。**只剩分支覆盖**一条。分支覆盖**不是拿不到**:同日被用户问住后复测,本机 WSL2 Ubuntu 有 gcc/gcov 15.2.0,16 套单测在 gcc 下**全部编过且全绿**、`gcov -b -c` 出分支数据(`nand_ftl.c` 分支至少走过一次 41.40%);但换编译器就是换分母(`sha256.c` gcc 105 行 vs MSVC 114 行)→ **用户裁定只保留 MSVC 一条通道**(多一套工具链=多一套分母 +多一本台账 + 多一个"这台机器有没有 WSL"的未决项),分支覆盖**记为已接受缺口**、不是待办。实测表与决策依据见 `../测试说明/主机单测覆盖率口径.md` §2.1。本轮只加 `.py`/`.json`/`.md`,无 `.c/.h` 改动,不重跑 `@build`;文档面动 §0.1 / §0.4 / §4.8 / §5.5 / §7 与 `docs/README.md` 索引各一处。 | | **2026-10-23** | **术语迁移**:`bsp-err` 的四项检查从 `R1/R2/R3/R4` 改成语义标签 | 用户一句「R4 | | 到底是什么」问出来的。这四个号**是我自己造的**,而 `R1~R8` 在本仓是"专项风险"的裸形编号 | | (`id_registry.json` 已登记那一族,保护规划 `R1~R8`、看门狗 `R1~R7` 的风险表都在用),`R-2` @@ -664,4 +691,8 @@ USER CODE 区内只留「读 RCC_CSR → 写 RMVF 清除」两句、顺序约束 | **R-9(`LV_MEM_SIZE` 40→16 KB,登记簿 N-27 原 W3 的第一步,2026-10-15 已执行;该簇其余部分同日整簇暂缓)** | 取值依据与再降 8 KB 的前置写在 `Lib/lv_conf.h` 与 `simulator/lv_conf.h` 的池注释;实测水位 → `../内存管理/RAM使用分析与优化方案.md` §2.6.2 与 §阶段 1.1(已执行);解锁链第 ① 步 ✅(后续步骤暂缓)→ `../内存管理/RAM预算台账.md` §3;池容量口径 → `../UI设计/UI_设计架构.md` §1.2、`../UI设计/UI_界面制作流程.md` C4 | | **N-12 / N-13**(dhara 移植层 `is_free` 精度 + HAL 越界守卫,登记簿 **N-21**,2026-10-15 已销项) | `../架构设计/STM32F4-Base存储架构说明.md` §5.3(移植层两条硬约束:整页精确的原因链 + 错误码取舍与"为什么不会静默清卷")+ `Drivers/BSP/GD5F2GQ5UE/nand_ftl.c` 的 `ftl_page_in_range` / `ftl_block_in_range` / `dhara_nand_is_free` 注释 + 主机侧单测 `test/host/nand_port/`(含"旧算法只看页首 64 B"的对照用例,防止将来被改回去) | | **N-16**(日志读写放大,登记簿 **N-24**,2026-10-15 已销项) | `../子系统设计/运行与自检记录设计说明.md` §4.4(写侧批量合并 + **掉电语义不变**的论证 + 失败回投不丢条目 + 容量轮转只在慢路径)与 §4.5(读侧 8 条窗口 + 写者代数失效 + API 不变)+ `App/log/sys_log_mcu.c` 的 `s_format_rec` / `s_append_rec` / `s_append_many` / `s_read_raw` 注释 | -| **§4.8 空白 ①「无覆盖率口径」+ §5.5 第 5 条前半(2026-10-23 销项;§5.5 后半的 `malloc(` 正则仍开放)** | `../测试说明/主机单测覆盖率口径.md`(口径 / 工具链实测 / 统计面 / 门禁姿态 / **九条限制与盲区** / 未挂钩子的三条理由与待粘 YAML)+ `scripts/check_test_coverage.py`(`--list` / `--module` / `--update-baseline` / `--md` / `--json`)+ 基线台账 `scripts/test_coverage_baseline.json`(首登 2552/2893 行、未触及 125 文件) | +| **§4.8 两处空白(①无覆盖率口径 ②无 `malloc(` 正则)+ §5.5 第 5 条整条(2026-10-23 全部销项)** | `../测试说明/主机单测覆盖率口径.md`(口径 / 工具链实测 / 统计面 / 门禁姿态 / **九条限制与盲区** / 未挂钩子的三条理由与待粘 YAML)+ `scripts/check_test_coverage.py`(`--list` / `--module` / `--update-baseline` / `--md` / `--json`)+ 基线台账 `scripts/test_coverage_baseline.json`(首登 2552/2893 行、未触及 125 文件)。 +空白 ② → `docs/门禁说明/coding-standard.md`(CODE-RULE-20 判据 / 唯一豁免 / 盲区 / 落地前普查数)+ +`scripts/check_coding_standard.py`;禁令本身的事实源仍是 AGENTS.md 平台约束与 +`../内存管理/RAM使用分析与优化方案.md` §4.2.1;另 `../代码规范/嵌入式C语言代码规范_V1.0.md` §6.1 的示例 + 自己写着 `malloc/free`(通用 C 模板遗留),已同日在示例下加"本仓禁止"批注 | diff --git a/scripts/README.md b/scripts/README.md index 723ac11a..703e6cdf 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -42,6 +42,7 @@ | 脚本 / 文件 | 作用 | |---|---| | `ci_local.py` | **本地跑与 CI 等价的门禁**(L0 pre-commit + L0b 提交信息 + L0.5 主机侧单测):自动选用装了 pre-commit 的解释器;无 `.map` 时自适应跳过 `mem-numbers`;退出码 0/1/2 与 CI 对齐。CI 定义见 `../.gitea/workflows/ci.yml`,覆盖边界见 `docs/测试说明/测试开发标准流程.md` §9 | +| `check_test_coverage.py` | 主机单测**覆盖率**(2026-10-23):mscoverage 动态插桩跑 `test/host/**` 全部 16 套,统计自研生产源码行/函数覆盖(并集)+ 与 `test_coverage_baseline.json` 比回退;`--module/--update-baseline/--md/--json/--list`;**不在 pre-commit 里**,入口是 `ci_local.py --coverage`(口径见 `docs/测试说明/主机单测覆盖率口径.md`) | | `ci_gitlint_range.ps1` | CI 侧**提交信息门禁**:逐条校验本次 push/PR 引入的提交(`--all-files` 跑不到 `commit-msg` stage,故必须单独守) | | `ci_find_python.ps1` | CI 侧 Python 探测(候选顺序须与 `ci_local.py` 同序同步,否则 CI 与本地判定会分叉) | | `ci_runner_bootstrap.ps1` | **本机 runner 运维脚本**(体检 `-Status` / 干跑 `-DryRun` / 部署 `-SetEnv -StartNow`):装"等 Gitea 就绪 + 崩溃自拉起"的看门狗 + 开机计划任务 + 机器级 `DTU_*` | diff --git a/scripts/check_coding_standard.py b/scripts/check_coding_standard.py index 62b6b415..ec67f565 100644 --- a/scripts/check_coding_standard.py +++ b/scripts/check_coding_standard.py @@ -23,6 +23,7 @@ C 语言代码规范检查(《嵌入式C语言代码规范_V1.0》可机械化 CODE-RULE-17 (2.2) 非 static 函数必须在配对 .h 中声明(HAL_ 豁免;无配对头则跳过) CODE-RULE-18 (6.3.1) 禁止 Yoda 条件(字面量/常量必须在运算符右侧) CODE-RULE-19 (4.4.2) 函数定义左大括号必须与右括号同行 + CODE-RULE-20 (平台约束) 禁止 C 库堆分配 malloc/calloc/realloc/strdup/free 语义类规则(函数长度、单一职责、错误处理等)无法机械可靠判定,不在此脚本范围。 """ @@ -41,6 +42,13 @@ C_KEYWORDS = {'if', 'for', 'while', 'switch', 'do', 'else', 'return', NAME_PAREN_RE = re.compile(r'([A-Za-z_][A-Za-z0-9_]*)\s*\(') +# CODE-RULE-20 的例外:平台分配后端的**宿主侧实现**(AGENTS.md「只用 pvPortMalloc/vPortFree, +# 或把分配收进平台后端」说的就是这种收口点)。只点名到文件 + 只放行这两个函数名, +# 不做目录级豁免——新增一个想 malloc 的文件必须显式加进这里,评审时看得见。 +HEAP_ALLOC_ALLOWLIST = { + 'simulator/sim/cfg_file_store_sim.c': frozenset({'malloc', 'free'}), +} + def _mask(text): """注释与字符串字面量替换为等长空白(保留换行,行号不变),防止误报。 @@ -281,9 +289,29 @@ def check_file(path): 'Yoda 条件(常量应在右): %s' % ' '.join(m.group(0).split()))) # CODE-RULE-19 (4.4.2) 函数定义左大括号必须与右括号同行 - for name, ln, _body, _pre, bl, pl in defs: - if bl != pl: - v.append((ln, 'CODE-RULE-19 4.4.2', '函数定义左大括号须与右括号同行: %s' % name)) + # CODE-RULE-20 (平台约束,2026-10-23) 禁止 C 库堆分配 + # 出处**不在规范 V1.0**(其 §6.1 示例本身就写着 malloc/free,属通用 C 模板遗留,已同日在示例 + # 下方加"本仓禁止"批注),而在 AGENTS.md「平台约束」+ docs/内存管理/RAM使用分析与优化方案.md: + # MicroLIB 的 C 堆由 startup_stm32f407xx.s 的 Heap_Size EQU 0x200 限定为 **512 B**,KB 级分配 + # 必然返回 NULL,而**模拟器(宿主 CRT 堆)完全正常、照不出来** —— 典型"只有真机才炸",必须机器拦。 + # 判据细节(三条都是踩过坑之后定的): + # - 大小写敏感:pvPortMalloc / vPortFree(唯一允许的配对)不会中招; + # - 词边界 \b:dhara_nand_is_free(、buf_alloc( 这类"同形后缀"的自有函数名不会误报; + # - 跑在 masked 上:注释与字符串字面量里提到 malloc 不报(规范示例就是这种命中)。 + # 覆盖名比 AGENTS 列的五枚多带上 strndup / aligned_alloc —— 同族同类,漏一个就等于留个后门。 + norm = path.replace('\\', '/') + allow = frozenset() + for suffix, names in HEAP_ALLOC_ALLOWLIST.items(): + if norm == suffix or norm.endswith('/' + suffix): + allow = names + break + for m in re.finditer(r'\b(malloc|calloc|realloc|strdup|strndup|free|aligned_alloc)\s*\(', masked): + name = m.group(1) + if name in allow: + continue + v.append((_line(masked, m.start()), 'CODE-RULE-20 平台约束', + '禁止 C 库堆分配 %s()(MicroLIB C 堆仅 512 B;用 pvPortMalloc/vPortFree,' + '或把分配收进平台后端)' % name)) return v From e70b8612155aff7119b2764e3c496d9ef362779c Mon Sep 17 00:00:00 2001 From: Wanderingss <1624155937@qq.com> Date: Fri, 23 Oct 2026 14:10:49 +0800 Subject: [PATCH 03/15] =?UTF-8?q?docs(=E8=A6=86=E7=9B=96=E7=8E=87):=20?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=20=C2=A79=20=E6=9C=AA=E8=A7=A6=E5=8F=8A?= =?UTF-8?q?=E9=9D=A2=E5=BB=BA=E6=A1=A9=E4=BC=98=E5=85=88=E7=BA=A7=E6=B8=85?= =?UTF-8?q?=E5=8D=95=EF=BC=88P1/P2/P3=20=E9=80=90=E4=B8=AA=E7=94=BB?= =?UTF-8?q?=E5=83=8F=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 回答"那 125 个到底怎么吃":对 47 个未触及 .c(9,657 有效代码行)逐个量化后再排序, 不靠拍脑袋。排序口径写进文档、可复核可反驳:价值 = 有效代码行 × 逻辑度 × 缺陷历史 (该文件的 fix/refactor 提交数),成本 = 要桩的外部头数 − 能复用的现有桩;逻辑度 = 1 − 硬件符号密度(HAL_*() / FatFs f_*() / CH395F_* / SPI-I2C / OSAL 调用数 ÷ 行数)。 三档(数字互相咬合,覆盖率"不该 100%"在这里是算出来的): - P1 先吃 13 个 .c = 2,617 行:配置与工具 429(真实头即可编译,建议第一个落地,先用它 跑通"新增文件进台账 + NOTE 不误报回退"这条链)→ FTP 一套 1,083(20 次提交里 6 次 fix,最近两条都是静默型)→ NET 拆出的四块 903(须新建 stub/ch395f.h)→ 104 服务层 202。 - P2 值得但工程量大 5 个 = 2,226 行:net_socket.c 1,251(51 函数,**必须分片**,先测状态 迁移与语义判据);假 FatFs 一次吃 sys_log_mcu/cfg_file_store_mcu/soe_store 共 710; console_log 265(单写者契约 K1/K2 已写成表)。 - P3 明确不做 29 个 = 4,814 行,并把理由写死免得下次又列回待办:芯片驱动 1,990(假到底 ≈ 重写驱动,测的是自己的桩)、UI 侧 1,942(与模拟器同一份源码,已由 ui-golden 覆盖)、 任务编排 747(4~5 个函数的装配层,写成单测就是"断言我调了几次")、app_main 135 (被测对象本身就是内核启动前的时序约束)。 - 恒等式:47 = 13 + 5 + 29、9,657 = 2,617 + 2,226 + 4,814;做完 P1+P2 约 4.8k 行进分母 (现分母 2,893),files_tested 19 → 37。 配套口径修正: - "125 个未触及"易被读成 125 个待办,三处写精确 = 47 个 .c(9,657 行)+ 78 个 .h (2,182 行,多为声明与宏);待办池是那 47 个 .c。 - §8 下一步①改为指向 §9;终点判据从百分比改成"untested_sources 里剩下的每一条都能在 P3 表里找到归属",并提醒盯 files_tested/files_untested 而不是 88.2%(新增大块未测代码 会把它摊薄,那不是回退)。 自查:check_doc_links.py --all = 35 处(与主干同数,本分支贡献 0;期间修掉一处 §4.5 被 误算成本文自引用);check_ids.py --strict 通过;check_mojibake + BOM/CR/行尾卫生净。 本轮纯文档,零 .c/.h 改动,未跑 @build、未占 build/bench 锁。 --- docs/测试说明/主机单测覆盖率口径.md | 71 +++++++++++++++++++++++-- docs/问题记录/项目审查与优化建议报告.md | 8 +-- 2 files changed, 73 insertions(+), 6 deletions(-) diff --git a/docs/测试说明/主机单测覆盖率口径.md b/docs/测试说明/主机单测覆盖率口径.md index 14425eb3..892d1f0e 100644 --- a/docs/测试说明/主机单测覆盖率口径.md +++ b/docs/测试说明/主机单测覆盖率口径.md @@ -122,7 +122,9 @@ | 被测到的自研生产源文件 | **19 个** / 2,893 可执行行 | | 行覆盖最低的两个 | `Drivers/BSP/GD5F2GQ5UE/nand_ftl.c` 48.6%(123/253,函数 14/22)、`App/protocol/iec104/iec104_pts.c` 67.2%(117/174,函数 13/17) | | 行覆盖 95%+ 的文件 | 12 个(多为纯逻辑层:`adc_health` / `sample_ring` / `vol_lock` / `time_sync` / `task_watchdog` …) | -| **从未进过任何主机单测的生产源文件** | **125 个 / 约 11,839 有效代码行**(前五:`net_socket.c` 1,251、`ch395f.c` 801、`lftpd.c` 694、`gd5f2gq5ue.c` 689、`sys_log_mcu.c` 458) | +| **从未进过任何主机单测的生产源文件** | **125 个 / 约 11,839 有效代码行**——拆开是 **47 个 `.c`(9,657 行) +| + 78 个 `.h`(2,182 行,多数只有声明与宏)**;前五:`net_socket.c` 1,251、`ch395f.c` 801、`lftpd.c` 694、 +| `gd5f2gq5ue.c` 689、`sys_log_mcu.c` 458。待办池是那 47 个 `.c`,且其中 29 个属 §9 P3「刻意不做」 | ⚠ 读法:88.2% 是"**被测的那 19 个文件内部**"的比率,**不是全仓覆盖率**。全仓的真相写在 `untested_files` 这一栏(125 个),它比 88% 这个数字有用得多——也是 §7 第三条的由来。 @@ -187,14 +189,77 @@ **下一步(按性价比排序)**: -1. 给 `net_socket.c` / `lftpd.c` / `ch395f.c` 这类"125 个未触及"里最大的几块建主机侧桩—— - 比把 19 个文件从 88% 推到 95% 值钱得多(覆盖率只照"进没进过",建桩本身是这个动作里最贵的部分)。 +1. **扩大覆盖面**:按 **§9 的建桩优先级清单**吃(P1 13 个 .c / 2,617 行 → P2 5 个 / 2,226 行; + P3 的 29 个 / 4,814 行**刻意不做**并已写明理由)。比把已有 19 个文件从 88% 推到 95% 值钱得多。 2. **不做**:第二条覆盖率通道(gcc/WSL、`dotnet-coverage`、OpenCppCoverage 三条路都不走,用户裁定 2026-10-23)。要重开这个裁定,前提是能同时给出"两份分母怎么对齐"的答案,否则只是多一本对不上的账。 +## 9. 未触及面怎么吃:建桩优先级清单(2026-10-23 画像) + +**先把读数纠正一次**:所谓"125 个未触及"= **47 个 `.c`(9,657 有效代码行)+ 78 个 `.h`(2,182 行,多数只有声明与宏)**, +生成物 5 个已排除。**待办池不是 125,而是这 47 个 `.c`**——而且其中约一半在下面 P3 里被明确判为"不做"。 + +排序口径(把数据摊开,欢迎反驳):**价值 = 有效代码行 × 逻辑度 × 缺陷历史**(该文件的 fix/refactor 提交数), +**成本 = 要桩掉的外部头数 − 能复用的现有桩**。逻辑度 = `1 − 硬件符号密度`(`HAL_*(`、FatFs `f_*(`、 +`CH395F_*`、SPI/I2C、OSAL 调用数 ÷ 行数),逐文件实测。 +现有桩共 13 个头(`test/host/*`/stub/):`dbg_log.h`(×6)、`FreeRTOS.h`、`task.h`、`main.h`、`cmsis_os.h`、 +`cmsis_os2.h`、`sys_clock.h`、`sys_events.h`、`iwdg.h`、`adc.h`、`tpafe5160.h`、`gd5f2gq5ue.h`、`net_socket.h`。 +⚠ **没有 `ch395f.h`**——NET 域那批必须新建它。FatFs 侧不建假头,沿用"真实 `ff.h` + 测试单元内假 diskio" +(`test/host/nand_port/` 已趟通:接口契约原汁,桩与真实 API 一漂移就编译期报错)。 + +### P1|先吃:逻辑密、桩薄、有近期缺陷垫背(13 个 `.c` = 2,617 行) + +| 批次 | 文件(有效代码行) | 逻辑度 | 要桩什么 | 判据出处(直接抄成用例) | 为什么值得 | +|---|---|---|---|---|---| +| **FTP 一套** | `lftpd.c`(694)、`lftpd_io.c`(207)、`lftpd_inet.c`(171)、`lftpd_string.c`(11) = 1,083 | 0.99/0.90/0.93 | 真实 `ff.h` + 假 diskio;复用 `net_socket.h` | `docs/测试说明/FTP_Test_Guide.md`、`docs/子系统设计/网络配置说明.md` §3.4(`pwd_hash`=SHA-256(user‖password)、连续 5 次失败锁 60 s、默认口令启动告警) | **20 次提交里 6 次是 fix**,最近两条都是"静默型":LIST/NLST 满块被截断回 550、对端行为误打 `[ERR]`(2026-10-23)——覆盖率最擅长照的就是这类"日志看不出根因" | +| **NET 拆出来的四块** | `net_pending.c`(287)、`net_events.c`(263)、`net_listener.c`(191)、`net_select.c`(162) = 903 | 0.90/0.78/–/0.93 | **新建 `stub/ch395f.h`**(芯片寄存器只到函数边界)+ 假任务通知;`net_socket.h` 已有 | `docs/网络/网络自愈架构设计.md` §4.10(socket 读清点审计 + 新增一处读清的三条准入判据)、`docs/网络/Net层阻塞模型架构设计.md` | 挂起 recv、监听按代次重建、退避这些**语义在真机上极难复现**;这一族的 B3("顺手全量清一遍把别的 socket 门焊死")与 N34 E14 都是 fix 出来的真事 | +| **配置与工具(最便宜,建议第一个落地)** | `net_cfg.c`(167)、`task_prof.c`(160)、`ftp_cfg.c`(59)、`prot_set_store.c`(43) = 429 | 1.00/0.97/–/– | 真实头即可(`cfg_file.h` 已被现有单测证明能在 PC 上编),最多补 `dbg_log.h` | `docs/子系统设计/网络配置说明.md`(工厂默认 192.168.1.100/24、MAC 全零=芯片内置、保存立即生效)、`docs/算法/采集与算法层设计说明.md` | 硬件符号 0~2 处,纯解析/编解码/格式化。**一天内能出第一份绿**,用来把"新增 `files_tested`"这条路先跑通 | +| **104 服务层** | `iec104_serv.c`(202) | 0.96 | 复用 `time_sync`/stub 的 `cmsis_os2.h` + 已有 `net_socket.h` | 与 `test/host/iec104/`(97 用例)同一套脚手架 | 同族的 `iec104_pts.c` 现在只有 **67.2%**(全仓最低之一);服务层进来才能把 YK/YX 编排的分支照全 | + +### P2|值得但工程量大(5 个 `.c` = 2,226 行) + +1. **`net_socket.c`(1,251 行 / 51 个函数,全仓最大的逻辑块)** —— 必须**分片**,别一次吞。 + 先测状态迁移与语义判据:EOF 时**不得** `net_close` 该 fd(会连监听一起毁掉)、`auto_relisten`、 + `settle_close(sockfd, p_sock)` 双参显式传、挂起 recv 超时、`net_*` 错误码经**任务通知值**回传 + (`net_msg_t` 是按值拷入队列的,改副本调用方看不到)。桩 = 新建假 CH395F。 + 该文件 80 次提交、15 次 fix/refactor,值;但**一次做完必翻车**(先求把状态机进分母,别求百分比)。 +2. **`sys_log_mcu.c`(458) + `cfg_file_store_mcu.c`(129) + `soe_store.c`(123) = 710 行** —— 一套假 FatFs + 吃三块,性价比最高的第二批。判据现成:`docs/子系统设计/运行与自检记录设计说明.md` §4.4(写侧批量合并 + **掉电语义不变** + + 失败回投不丢条目 + 容量轮转只在慢路径)与 `docs/子系统设计/运行与自检记录设计说明.md` §4.5 + (读侧 8 条窗口 + 写者代数失效 + API 不变)。 + 这块的历史教训就是"日志读写放大"与"`mkdir` 失败静默不再重试"。 +3. **`console_log.c`(265)** —— 单写者契约 K1/K2 已经写成表,照抄成用例最省力: + `docs/子系统设计/控制台单写者日志设计说明.md`。 + +### P3|**明确不做**(把理由写死,免得下次又被列回待办:29 个 `.c` = 4,814 行) + +| 类别 | 体量 | 为什么不做 | 那它靠什么把关 | +|---|---|---|---| +| 芯片驱动(寄存器/时序) | `ch395f.c`(801)、`gd5f2gq5ue.c`(689)、`sd2506.c`(264)、`tpafe5160.c`(157)、`rs485.c`(79) = **1,990 行** | 把 HAL 与寄存器假到底 ≈ **重写一遍驱动**,测的是自己的桩而不是硬件行为(SPI 分频必须 8、DMA 流、I2C 时序在桩里怎么编都"对") | 板级套件(`board-test-suite` skill)+ `@test` 真机日志 + 4 个 Fault 现场;陷阱逐条登记在 `docs/问题记录/CH395F_Trap_Records.md`、`docs/问题记录/GD5F2GQ5UE_Trap_Records.md`、`docs/问题记录/SD2506_Trap_Records.md` | +| UI 侧 | 17 个 `.c` = **1,942 行** | 与 PC 模拟器是**同一份源码**,行为回归已由 `ui-golden`(菜单 8 状态 + 内容页 11 页像素黄金参考)覆盖;再写单测是第三份重复投资 | `ui-verify` skill 五步 + `docs/UI设计/UI_自动化测试流程.md` | +| 任务编排 | `default_task.c`(281)、`adc_task.c`(171)、`net_task.c`(123)、`protect_task.c`(90)、`ui_task.c`(71)、`rs485_task.c`(11) = **747 行** | 每个只有 4~5 个函数,主体是"按顺序调别人";真逻辑在被调模块里(那些已测或在 P1/P2)。给编排层写单测最容易写成"断言我调了几次"的自证 | 真机启动锚点计数 + IWDG `required_mask`(`@test`)+ `docs/架构设计/项目启动顺序管理.md` | +| 内核前装配 | `app_main.c`(135) | 被测对象本身就是"`osKernelStart` 之前不许调内核 API"的时序,主机侧无法忠实复现(PC 上没有那个约束) | 同上 + `cubemx-survival` 审计 | + +**四个数字对得上**:`47 = 13(P1) + 5(P2) + 29(P3)`、`9,657 = 2,617 + 2,226 + 4,814`。 +也就是说:**"覆盖率不该 100%"这件事在这份清单里是算出来的,不是嘴上说的**——P3 那 4,814 行永远不进分母, +而 P1+P2 做完约 `4.8k` 行进分母(现在的分母只有 2,893),`files_tested` 从 19 → 37。 + +### 顺序建议与记账 +1. 落地顺序:**配置与工具 4 块**(先跑通"新增文件进台账 + NOTE 不误报回退"这条链)→ **FTP 一套** → + **NET 四块**(含新建 `stub/ch395f.h`)→ **104 服务层** → P2 的 FatFs 三块与 `console_log.c` → + 最后啃 `net_socket.c` 的状态机分片。 +2. 每吃掉一批:`--update-baseline` 重登;看 `files_tested`(19→…)与 `files_untested`(125→…)这两个数, + **不要盯着 88.2% 看**——那只是"已进分母部分"的自分数,新增大块未测代码时会**被摊薄而下降,那不是回退**(§5 已注明)。 +3. 终点判据不是百分比,而是:**`untested_sources` 里剩下的每一条都能在 P3 那张表里找到归属**。 + 找不到归属的(既没进分母、也不属于"刻意不做")才是真窟窿。 + ## 变更记录 | 日期 | 说明 | |---|---| | 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 | 新增 **§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); +§8 下一步第 1 条改为指向本节 | diff --git a/docs/问题记录/项目审查与优化建议报告.md b/docs/问题记录/项目审查与优化建议报告.md index 979878d0..2f3e82d3 100644 --- a/docs/问题记录/项目审查与优化建议报告.md +++ b/docs/问题记录/项目审查与优化建议报告.md @@ -32,8 +32,9 @@ 重开条件见 §5.3。今天又收口一项:**F-4 判定不做、F-6 已落地、F-5 降级观察**(§5.4)。 4. **补面两条已全部落地(2026-10-23)**:覆盖率口径(`../测试说明/主机单测覆盖率口径.md`)+ 堆禁令机器拦截(`coding-standard` **CODE-RULE-20**,§5.5 第 5 条)。**剩下的量在覆盖面**:覆盖率首登 - 照出 **125 个生产源文件从未进过任何主机单测**(约 11,839 行,`net_socket.c` 1,251 行最大)——给它们建桩 - 比把在测的 19 个文件从 88% 推到 95% 值钱得多(N-12/N-13 那类"读代码才发现"的缺陷就是这么照出来的)。 + 照出 **47 个 `.c`(9,657 有效代码行)+ 78 个 `.h` 从未进过任何主机单测编译单元**(`net_socket.c` 1,251 行最大)。 + 建桩比把在测的 19 个文件从 88% 推到 95% 值钱得多,但**不是 125 个都该做**:优先级与"刻意不做"的清单已按逐文件 + 画像写好 → `../测试说明/主机单测覆盖率口径.md` §9(P1 13 个 / 2,617 行、P2 5 个 / 2,226 行、P3 29 个 / 4,814 行不做)。 5. **仓库体积重心在外部资料**(`docs/芯片手册` 717 文件 / 39.5 MB,占 docs 的 87%;`.git` 248 MB), 见 §3.3 D-1 与登记簿 N-29——已决策暂不处理,但**推送/对外协作前必须重评**。 @@ -69,7 +70,8 @@ B0h 验 `ECC_EN`**——原先那两行的注释自己写着"否则后续 I/O | 存储/日志 | P2-10 ❌(N-25) | 卷锁现有 5 个使用者(配置原子写 / 日志 / SOE / lftpd_io / defaultTask),容量轮转删除与 UI 读并发仍未全覆盖 | 登记簿 S-5 面(卷锁覆盖面) | | 仓库 | D-1 ❌(N-29) | jpg 707 个 / 37.3 MB;`docs` 45.3 MB 里芯片手册占 39.5 MB;`.git` **248 MB** | 降采样 / 拆 submodule / LFS 三选一 + 历史重写;已决策暂不做 | | UI | U2 残余(U 簇) | 组件库只有 `ui_w_records.c/h` 一个;其余仍是页内自绘 | 按产品需求决定要不要继续抽组件 | -| ~~测试~~ ✅ **两条全部闭环 2026-10-23**(覆盖率口径 + 堆禁令 CODE-RULE-20) | 覆盖率:`scripts/check_test_coverage.py` + 基线 `scripts/test_coverage_baseline.json`(口径与**盲区**见 `../测试说明/主机单测覆盖率口径.md`)——首登 16 套并集 2552/2893 行(88.2%)、**未触及 125 个生产源文件 / 约 11.8k 行**。堆禁令:`check_coding_standard.py` **CODE-RULE-20**(硬拦截、零基线;唯一豁免 = `simulator/sim/cfg_file_store_sim.c` +| ~~测试~~ ✅ **两条全部闭环 2026-10-23**(覆盖率口径 + 堆禁令 CODE-RULE-20) | 覆盖率:`scripts/check_test_coverage.py` + 基线 `scripts/test_coverage_baseline.json`(口径与**盲区**见 `../测试说明/主机单测覆盖率口径.md`)——首登 16 套并集 2552/2893 行(88.2%)、**未触及 125 个文件 = 47 个 `.c`(9,657 行)+ 78 个 `.h`(2,182 行,多为声明/宏)**, +| 待办池是那 47 个 `.c`;分批与「刻意不做」见 `../测试说明/主机单测覆盖率口径.md` §9。堆禁令:`check_coding_standard.py` **CODE-RULE-20**(硬拦截、零基线;唯一豁免 = `simulator/sim/cfg_file_store_sim.c` | 的平台分配后端宿主侧实现,见 `../门禁说明/coding-standard.md` §7) | 无(残余三项**已归口或已裁定**:覆盖率挂 | pre-commit 的前置写在口径文档 §8;分支覆盖**不做**(§2.1 决策);扩大覆盖面按 §0.3 第 4 条) | | CI | T-7 ❌ | 远程 CI 只跑本机一台 runner,且不跑 `@build` | 保持现状即可;扩机器前重读 `docs/CI/README.md` | 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 04/15] =?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; +} From 6bdf063f50d5cf11a0a1d5ffa4d3cb9812c7cc19 Mon Sep 17 00:00:00 2001 From: Wanderingss <1624155937@qq.com> Date: Fri, 23 Oct 2026 15:36:54 +0800 Subject: [PATCH 05/15] =?UTF-8?q?test(NET/lftpd):=20P1=20=E5=A2=9E?= =?UTF-8?q?=E8=A1=A5=20lftpd=5Fio=20=E6=A8=A1=E5=9D=97=EF=BC=8833=20?= =?UTF-8?q?=E6=9D=A1=EF=BC=89=EF=BC=8C=E7=99=BB=E8=AE=B0=20FTP=20=E5=9F=9F?= =?UTF-8?q?=E4=B8=80=E5=A4=84=E6=BD=9C=E4=BC=8F=E7=BC=BA=E9=99=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 test/host/lftpd_io/:一个 TU 同时纳入生产源 lftpd_io.c 与 lftpd_string.c (两者静态符号不冲突,省一套桩),33 条断言全部 MSVC 编译通过并 PASS。 钉住的契约(都是真机上偶发、日志看不出根因的那类): - 路径规范化:**".." 上跳过头仍被钳在根内**("/a" + "../../x" = "/x")——FTP 客户端 可发任意路径,这是安全属性;绝对名忽略 base、"." 丢弃、段间多余斜杠折叠、 NULL 入参容忍、全空结果归 "/"。 - 分配失败:这个函数返回 pvPortMalloc 出来的指针(历史上正是它落在 C 库 malloc 上, RAM 方案 §4.2.1)。用**可注入失败的假堆**跑两条分支:首块失败 → NULL;结果缓冲 失败 → NULL **且第一块已 vPortFree**(不泄漏堆)。模拟器宿主 CRT 永不失败,这条 只有假堆能构造。 - 卷锁时机:open 成功**故意不释放**(整个传输期独占卷,由 close 释放),失败必须 释放并留 ERROR;单文件槽守卫 s_file_open 命中时**不碰卷锁也不碰 FatFs**。 - 等卷锁循环每轮喂看门狗(lftpd_task_wd_beat):defaultTask 与 FTP 争卷时这里可能 空转上百毫秒,漏喂就是整机复位。脚本化 vol_lock_wait 的返回序列直接构造 2 轮空等。 - lftpd_string_trim:行首空白由**返回指针**跳过、原缓冲不搬移(调用点 lftpd.c 用的 正是返回值);全空白串返回空串。 **本批照出一处,未改语义、只登记**:lftpd_string_trim 的尾部循环写成 for (i = strlen(p); i >= 0 && isspace(p[i]); i--),i 起步那格是 NUL、isspace(0) 为假 → 循环一次都不执行,**尾部空白与 CRLF 从未被裁掉**。今天不发作是因为 CRLF 在 lftpd_inet_read_line 里已用 strstr 截断、且唯一调用点只关心行首;风险在于"以后有人 拿它去尾部空白"不会生效。修法只要一行(i 从 strlen(p)-1 起),但那会改变 FTP 参数 (含空格的文件名/口令)的可见行为,需要 FTP 域一起过,**留给用户裁定**;当前用 TC-LFTSTR-005 把现状钉成"改了会响"(明确标注是现状桩、不是期望行为)。 未覆盖也写在文件头:lftpd_io_read/write/close/stat/unlink/opendir/readdir/closedir 要先把假 FIL/FILINFO 的字段语义(fptr、obj.objsize、fname 等)做实,否则断言会落在 假件自己写的规则上;这 8 个 f_* 只给了可链接的 FR_INT_ERR 实现,走进即失败不静默。 覆盖率:套件 20 → 21 全绿;files_tested 23 → 25;未触及 .c 43 → 41;分母 3,163 → 3,332 行;lftpd_io.c 85/159(53.5%)、lftpd_string.c 8/10。总体 line_rate 88.9% → 87.2% —— **这是摊薄不是回退**(新进来的文件只测了一半,分母涨得比分子快),已按 §5 口径写进口径文档 §9 的落地进度表;恒等式更新为 41 = 7(P1 剩) + 5(P2) + 29(P3)。 编号:按 B 层口径补建 2 族 TC-HOST-LFTPIO(游标 037)与 TC-HOST-LFTSTR(005), TC-HOST 族现共 **15** 族;id_registry.json 仍只做增量插入(无重排)。 自查:test-cppcheck Passed;coding-standard Passed;check_ids --strict 通过(无台账外 新账);check_doc_links --all = 35 处(= 存量,本分支贡献 0);新增/改动文件 BOM/CR/ U+FFFD/行尾卫生逐项净。**本增量零生产代码改动**(只加测试、注册表与文档),故未跑 @build。pre-commit --all-files 仍只有三条失败且与本分支无关:mem-numbers / mem-budget (本 worktree 上次 @build 生成的 .map 与受管快照漂移,增量 ER_IROM1 +12B、RW_IRAM1 +8B、CCM 0,按并行分支约定不在分支里 --write,等 Lead 合并后统一回填)与 ui-golden (simulator/tests/ref/*.bin 在 .gitignore 内,新 worktree 天生没有黄金参考)。 --- docs/测试说明/主机单测覆盖率口径.md | 24 ++ scripts/id_registry.json | 44 +++ scripts/test_coverage_baseline.json | 54 ++-- test/host/lftpd_io/lftpd_io_test.c | 427 ++++++++++++++++++++++++++++ test/host/lftpd_io/run_unit_test.py | 63 ++++ test/host/lftpd_io/stub/FreeRTOS.h | 23 ++ test/host/lftpd_io/stub/dbg_log.h | 19 ++ 7 files changed, 636 insertions(+), 18 deletions(-) create mode 100644 test/host/lftpd_io/lftpd_io_test.c create mode 100644 test/host/lftpd_io/run_unit_test.py create mode 100644 test/host/lftpd_io/stub/FreeRTOS.h create mode 100644 test/host/lftpd_io/stub/dbg_log.h diff --git a/docs/测试说明/主机单测覆盖率口径.md b/docs/测试说明/主机单测覆盖率口径.md index db77d3e5..28e001c3 100644 --- a/docs/测试说明/主机单测覆盖率口径.md +++ b/docs/测试说明/主机单测覆盖率口径.md @@ -216,6 +216,30 @@ | **配置与工具(最便宜,建议第一个落地)** | `net_cfg.c`(167)、`task_prof.c`(160)、`ftp_cfg.c`(59)、`prot_set_store.c`(43) = 429 | 1.00/0.97/–/– | 真实头即可(`cfg_file.h` 已被现有单测证明能在 PC 上编),最多补 `dbg_log.h` | `docs/子系统设计/网络配置说明.md`(工厂默认 192.168.1.100/24、MAC 全零=芯片内置、保存立即生效)、`docs/算法/采集与算法层设计说明.md` | 硬件符号 0~2 处,纯解析/编解码/格式化。**一天内能出第一份绿**,用来把"新增 `files_tested`"这条路先跑通 | | **104 服务层** | `iec104_serv.c`(202) | 0.96 | 复用 `time_sync`/stub 的 `cmsis_os2.h` + 已有 `net_socket.h` | 与 `test/host/iec104/`(97 用例)同一套脚手架 | 同族的 `iec104_pts.c` 现在只有 **67.2%**(全仓最低之一);服务层进来才能把 YK/YX 编排的分支照全 | +**落地进度(2026-10-23,同日推进)**: + +| P1 批次 | 状态 | 新增模块(断言数) | 进分母的实测覆盖 | +|---|---|---|---| +| 配置与工具 4 块 | ✅ 完成 | `test/host/{net_cfg,ftp_cfg,prot_set_store,task_prof}`(104 条) | + `net_cfg.c` 108/112、`task_prof.c` 88/93、`ftp_cfg.c` 35/36、`prot_set_store.c` 27/29 | +| FTP 一套(4 块里 2 块) | 🟡 部分 | `test/host/lftpd_io`(33 条,含 `lftpd_string.c`) | + `lftpd_io.c` **85/159 = 53.5%**、`lftpd_string.c` 8/10;`read/write/close/stat/unlink/readdir` + 未测(要先把假 `FIL/FILINFO` 的字段语义做实,否则断言落在假件自己写的规则上) | +| `lftpd.c` / `lftpd_inet.c` | ⬜ 未开始 | — | 需假 `net_socket` 层(`time_sync`/stub 里那份可扩展) | +| NET 四块 / 104 服务层 | ⬜ 未开始 | — | **必须先新建 `stub/ch395f.h`** | + +数字:套件 16 → **21**、`files_tested` 19 → **25**、未触及 125 → 119(`.c` 47 → **41**, +恒等式 `41 = 7(P1 剩) + 5(P2) + 29(P3)` 仍然咬合)、分母 2,893 → **3,332** 插桩行。 +⚠ 总体 `line_rate` 88.2% → **87.2%**:**这正是 §5 说的摊薄,不是回退**——`lftpd_io.c` 只测了 +一半就进分母,分母涨得比分子快;要盯的是 `files_tested`/`files_untested` 两个数。 + +本批又照出一处(**语义未改,等裁定**):`lftpd_string_trim` 的尾部循环写成 +`for (i = strlen(p); i >= 0 && isspace(p[i]); i--)`,i 起步那格是 `'\0'`、`isspace(0)` 为假 → +循环一次都不执行,**尾部空白/CRLF 从未被裁掉**。今天不发作是因为 CRLF 在 +`lftpd_inet_read_line` 里已用 `strstr("\r\n")` 截断、唯一调用点 `lftpd.c` 只用返回指针去行首; +风险在"以后有人拿它去尾部空白"。修它一行即可,但会改变 FTP 参数(含空格的文件名/ +口令)的可见行为,需与 FTP 域一起过 → 现在只用 TC-LFTSTR-005 把现状钉成"改了会响"。 + ### P2|值得但工程量大(5 个 `.c` = 2,226 行) 1. **`net_socket.c`(1,251 行 / 51 个函数,全仓最大的逻辑块)** —— 必须**分片**,别一次吞。 diff --git a/scripts/id_registry.json b/scripts/id_registry.json index 40af32f1..9c73317a 100644 --- a/scripts/id_registry.json +++ b/scripts/id_registry.json @@ -1680,6 +1680,50 @@ "subitem_ok": true, "owner_note": "B 层口径(2026-10-21 批 3 定:这些号在代码目录外零消费者,属主登记处 = 单测代码本身,不逐号补文档);本族 2026-10-23 随覆盖率口径 §9 P1 第①批新建模块补登记", "cursor_note": "游标按实测最大号收录(2026-10-23):越界即视为未登记的冒号牌" + }, + { + "name": "TC-HOST-TC-HOST-LFTPIO", + "class": "TC", + "title": "主机侧单测用例号:FTP 文件 IO 层与路径规范化(test/host/lftpd_io)", + "pattern": "\\bTC-LFTPIO-[0-9]{1,4}[a-z]?\\b", + "digit_width": [ + 2, + 4 + ], + "owner_docs_glob": [ + "test/host/lftpd_io/*" + ], + "scope": "global", + "frozen": false, + "cursor": "TC-LFTPIO-037", + "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-TC-HOST-LFTSTR", + "class": "TC", + "title": "主机侧单测用例号:FTP 命令行字符串工具(同模块内)(test/host/lftpd_io)", + "pattern": "\\bTC-LFTSTR-[0-9]{1,4}[a-z]?\\b", + "digit_width": [ + 2, + 4 + ], + "owner_docs_glob": [ + "test/host/lftpd_io/*" + ], + "scope": "global", + "frozen": false, + "cursor": "TC-LFTSTR-005", + "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 d70a69cb..a8660a5c 100644 --- a/scripts/test_coverage_baseline.json +++ b/scripts/test_coverage_baseline.json @@ -168,6 +168,22 @@ "lines_covered": 37, "lines_valid": 38 }, + "Drivers/BSP/NET/lftpd/lftpd_io.c": { + "func_rate": 0.3846, + "funcs_covered": 5, + "funcs_valid": 13, + "line_rate": 0.5346, + "lines_covered": 85, + "lines_valid": 159 + }, + "Drivers/BSP/NET/lftpd/lftpd_string.c": { + "func_rate": 1.0, + "funcs_covered": 1, + "funcs_valid": 1, + "line_rate": 0.8, + "lines_covered": 8, + "lines_valid": 10 + }, "Drivers/BSP/NETCFG/ftp_cfg.c": { "func_rate": 1.0, "funcs_covered": 4, @@ -246,6 +262,14 @@ "lines_total": 1193, "ok": true }, + "lftpd_io": { + "files": { + "Drivers/BSP/NET/lftpd/lftpd_io.c": 159, + "Drivers/BSP/NET/lftpd/lftpd_string.c": 10 + }, + "lines_total": 169, + "ok": true + }, "nand_port": { "files": { "Drivers/BSP/GD5F2GQ5UE/nand_ftl.c": 253 @@ -369,21 +393,23 @@ "App/util/task_watchdog.c", "Drivers/BSP/GD5F2GQ5UE/nand_ftl.c", "Drivers/BSP/NET/lftpd/lftpd_auth.c", + "Drivers/BSP/NET/lftpd/lftpd_io.c", + "Drivers/BSP/NET/lftpd/lftpd_string.c", "Drivers/BSP/NETCFG/ftp_cfg.c", "Drivers/BSP/NETCFG/net_cfg.c" ], "totals": { "files_generated_excluded": 5, - "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 + "files_tested": 25, + "files_untested": 119, + "func_rate": 0.9094, + "funcs_covered": 231, + "funcs_valid": 254, + "line_rate": 0.8715, + "lines_covered": 2904, + "lines_valid": 3332, + "modules_ok": 21, + "modules_total": 21 }, "untested_sources": [ { @@ -438,10 +464,6 @@ "code_lines": 263, "path": "Drivers/BSP/NET/net_events.c" }, - { - "code_lines": 207, - "path": "Drivers/BSP/NET/lftpd/lftpd_io.c" - }, { "code_lines": 206, "path": "Drivers/BSP/CH395F/ch395f.h" @@ -790,10 +812,6 @@ "code_lines": 11, "path": "App/task/ui_task.h" }, - { - "code_lines": 11, - "path": "Drivers/BSP/NET/lftpd/lftpd_string.c" - }, { "code_lines": 11, "path": "Drivers/BSP/NET/lftpd/private/lftpd_inet.h" diff --git a/test/host/lftpd_io/lftpd_io_test.c b/test/host/lftpd_io/lftpd_io_test.c new file mode 100644 index 00000000..ed464ce0 --- /dev/null +++ b/test/host/lftpd_io/lftpd_io_test.c @@ -0,0 +1,427 @@ +/* + * 模块名称:FTP 文件 IO 层(lftpd_io)+ FTP 字符串小工具主机侧单元测试 + * 模块功能:在 PC 上离线验证 Drivers/BSP/NET/lftpd/lftpd_io.c 的 + * **路径规范化(含越权上跳钳位)**、FatFs 路径映射、单文件槽守卫、 + * 卷锁获取/释放时机、等锁期间喂狗,以及 lftpd_string.c 的 trim。 + * + * 被测对象:真实源文件 ../../../../Drivers/BSP/NET/lftpd/lftpd_io.c 与 + * ../../../../Drivers/BSP/NET/lftpd/lftpd_string.c(#include 原样纳入; + * 两个 .c 的静态符号不冲突,可共用一个 TU,省一套桩)。桩与假件: + * - 真实头:Lib/FatFs/ff.h(FRESULT/FIL/FA_* 一律原汁)、 + * App/fs/vol_lock.h、lftpd.h、private/lftpd_io.h、private/lftpd_string.h; + * - 假 FatFs:本文件实现 f_open(记录 path/mode、返回可注入 FRESULT), + * 其余 f_* 未触及的分支本批不测(见下方"未覆盖"); + * - 假堆:pvPortMalloc/vPortFree = 静态池 + **可注入分配失败**, + * 用来跑"堆不够 → 返回 NULL"这条真机上 MicroLIB/FreeRTOS 堆耗尽会走、 + * 而模拟器(宿主 CRT)永远走不到的分支; + * - 假卷锁:vol_lock_wait 按脚本逐次返回,lftpd_task_wd_beat 计数。 + * + * 为什么要主机侧单测(而不是只靠台架): + * 1. **越权钳位是安全属性**:FTP 客户端可以发任意带 ".." 的路径,规范化必须 + * "在根上跳不动"("/a" + "../../x" = "/x",而不是逃出根目录)。真机上没有 + * 第二个文件系统可逃,测试里却能逐段构造,一次钉死(TC-LFTPIO-010..013)。 + * 2. 这个函数返回的是 **pvPortMalloc 出来的指针**——它正是历史上落在 C 库 malloc + * 上的那一个(RAM 方案 §4.2.1:lftpd_io.c 3 处 malloc 已改 pvPortMalloc)。 + * 分配失败必须**返回 NULL 且不泄漏第一块**(否则 FTP 一次 LIST 就把堆啃穿)。 + * 只有假堆能稳定构造这条路径。 + * 3. **单文件槽守卫**(s_file_open)与**卷锁释放时机**:open 成功时故意**不**释放 + * 卷锁(整个传输期间独占卷,由 close 释放),失败时必须释放。写反了就是 + * "defaultTask 再也拿不到卷"或"传输中途别人能改卷",两者都只在真机偶发。 + * 4. 等卷锁的循环里必须喂看门狗(lftpd_task_wd_beat)——defaultTask 与 FTP 争卷时 + * 这里可能空转上百毫秒,漏喂就是整机复位(这条在真机上极难稳定复现)。 + * + * 覆盖点: + * —— FatFs 路径映射(静态函数直测,TC-LFTPIO-001..004)—— + * TC-LFTPIO-001 NULL / 空串 → "0:"(卷根) + * TC-LFTPIO-002 "/" → "0:" + * TC-LFTPIO-003 "/a/b.txt" → "a/b.txt"(去掉前导斜杠) + * TC-LFTPIO-004 非斜杠开头原样返回 + * —— 路径规范化(TC-LFTPIO-010..022)—— + * TC-LFTPIO-010 相对名拼到 base 下 + * TC-LFTPIO-011 绝对名忽略 base + * TC-LFTPIO-012 ".." 回退一级 + * TC-LFTPIO-013 **上跳过头也不逃出根**("/a" + "../../x" = "/x") + * TC-LFTPIO-014 "." 段被丢弃 + * TC-LFTPIO-015 "base/.." 归一到 "/" + * TC-LFTPIO-016 连续斜杠与尾部斜杠被折叠 + * TC-LFTPIO-017 base 为 NULL 视作空串 + * TC-LFTPIO-018 name 为 NULL 视作空串 + * TC-LFTPIO-019 两者皆 NULL → "/" + * TC-LFTPIO-020 根 + ".." → "/" + * TC-LFTPIO-021 第一块分配失败 → NULL(且不崩、不动第二块) + * TC-LFTPIO-022 第二块(结果缓冲)分配失败 → NULL **且第一块已释放** + * —— 打开文件:守卫、卷锁、错误通道(TC-LFTPIO-030..041)—— + * TC-LFTPIO-030 open_read 成功返回 0,f_open 收到 FA_READ 与映射后的路径 + * TC-LFTPIO-031 已有文件打开时 open_read 返回 -1 且**不再碰卷锁与 FatFs** + * TC-LFTPIO-032 open_read 失败返回 -1、留 ERROR、并**释放卷锁** + * TC-LFTPIO-033 open_read 成功后卷锁**不释放**(传输期独占,由 close 释放) + * TC-LFTPIO-034 卷未就绪时循环等待,每轮喂一次看门狗 + * TC-LFTPIO-035 open_write 用 FA_WRITE|FA_CREATE_ALWAYS(覆盖写语义) + * TC-LFTPIO-036 open_write 同样受单文件槽守卫 + * TC-LFTPIO-037 open_write 失败也释放卷锁 + * —— trim(TC-LFTSTR-001..005)—— + * TC-LFTSTR-001 行首空白由**返回指针**跳过,原缓冲不搬移(调用方用的就是返回值) + * TC-LFTSTR-002 无前导空白时返回同一指针、内容不变 + * TC-LFTSTR-003 词中间空白保留 + * TC-LFTSTR-004 全空白串 → 空串 + * TC-LFTSTR-005 **现状桩**:尾部空白/CRLF 实际不裁(见下"本批照出的问题") + * + * 本批照出的问题(**未改语义**,等裁定):lftpd_string_trim 的尾部循环写成 + * for (i = strlen(p); i >= 0 && isspace(p[i]); i--) —— i 起步那格是 '\0', + * isspace(0) 为假,循环一次都不执行,**尾部空白从未被裁掉**。今天不发作是因为 + * FTP 命令行的 CRLF 在 lftpd_inet_read_line 里已用 strstr("\r\n") 截断,而唯一 + * 调用点 lftpd.c:780 只关心行首。真正的风险是"以后有人拿它去尾部空白"——不会生效。 + * 修法是一行(i 从 strlen(p)-1 起),但那会改变 FTP 参数(含空格的文件名/口令) + * 的可见行为,需与 FTP 域一起过;本批只用 TC-LFTSTR-005 把现状钉成"改了会响"。 + * + * 未覆盖(诚实登记,下一批补):lftpd_io_read / write / close / stat / unlink / + * opendir / readdir / closedir —— 它们要先把假 FIL/FILINFO 的字段语义(fptr、 + * obj.objsize、altname 等)做实,否则断言会落在假件自己写的规则上,失去意义。 + * + * 通过:全部 TC PASS(main 返回 0)。 + * 入口:int main(void),返回 0=PASS / 1=FAIL(由 run 脚本判读)。 + * + * 创建日期:2026-10-23 + */ + +#include +#include + +#include "ff.h" +#include "private/lftpd_io.h" +#include "private/lftpd_string.h" +#include "vol_lock.h" +#include "lftpd.h" + +/* 被测生产源(原样纳入;静态符号不冲突) */ +#include "lftpd_io.c" +#include "lftpd_string.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_lfio_info_count; +int g_lfio_error_count; +int g_lfio_debug_count; + +static void logs_clear(void) +{ + g_lfio_info_count = 0; + g_lfio_error_count = 0; + g_lfio_debug_count = 0; +} + +/* ===================== 假堆(可注入分配失败) ===================== */ + +static uint8_t g_pool[512]; +static size_t g_pool_used; +static int g_alloc_calls; +static int g_free_calls; +static int g_fail_at; /* >0:第 N 次 pvPortMalloc 开始失败;0 = 不失败 */ + +static void heap_reset(void) +{ + g_pool_used = 0u; + g_alloc_calls = 0; + g_free_calls = 0; + g_fail_at = 0; +} + +void *pvPortMalloc(size_t xWantedSize) +{ + void *p; + + g_alloc_calls++; + if ((g_fail_at > 0) && (g_alloc_calls >= g_fail_at)) { + return NULL; + } + if ((g_pool_used + xWantedSize + 8u) > sizeof(g_pool)) { + return NULL; + } + /* 8 字节对齐,模拟真实堆 */ + g_pool_used = (g_pool_used + 7u) & ~(size_t)7u; + p = &g_pool[g_pool_used]; + g_pool_used += xWantedSize; + return p; +} + +void vPortFree(void *pv) +{ + if (pv != NULL) { + g_free_calls++; + } +} + +/* ===================== 假卷锁与喂狗 ===================== */ + +static int g_wait_seq[8]; /* 每次 vol_lock_wait 的返回值脚本(0=未就绪) */ +static int g_wait_calls; +static int g_release_calls; +static int g_beat_calls; + +int vol_lock_wait(uint32_t timeout_ms) +{ + int r; + + (void)timeout_ms; + r = (g_wait_calls < (int)(sizeof(g_wait_seq) / sizeof(g_wait_seq[0]))) + ? g_wait_seq[g_wait_calls] : 1; + g_wait_calls++; + return r; +} + +void vol_lock_release(void) +{ + g_release_calls++; +} + +void lftpd_task_wd_beat(void) +{ + g_beat_calls++; +} + +int vol_lock_init(void) { return 0; } + +/* ===================== 假 FatFs(只到本批用到的 f_open) ===================== */ + +static FRESULT g_open_rc; +static char g_open_path[64]; +static BYTE g_open_mode; +static int g_open_calls; + +FRESULT f_open(FIL *fp, const TCHAR *path, BYTE mode) +{ + int i; + + (void)fp; + g_open_calls++; + g_open_mode = mode; + for (i = 0; (path[i] != _T('\0')) && (i + 1 < (int)sizeof(g_open_path)); i++) { + g_open_path[i] = (char)path[i]; + } + g_open_path[i] = '\0'; + return g_open_rc; +} + +/* 本批不测的 FatFs 入口:只为**可链接**而给出,一律返回 FR_INT_ERR—— + * 真有测试走进来就会拿到 -1/-2 之类的意外返回值而断言失败,不会静默假绿。 + * (下一批要把假 FIL/FILINFO 的字段语义做实再逐个展开) */ +FRESULT f_close(FIL *fp) { (void)fp; return FR_INT_ERR; } +FRESULT f_read(FIL *fp, void *p_buff, UINT btr, UINT *br) +{ + (void)fp; (void)p_buff; (void)btr; + if (br != NULL) { *br = 0u; } + return FR_INT_ERR; +} +FRESULT f_write(FIL *fp, const void *p_buff, UINT btw, UINT *bw) +{ + (void)fp; (void)p_buff; (void)btw; + if (bw != NULL) { *bw = 0u; } + return FR_INT_ERR; +} +FRESULT f_stat(FILINFO *fno, const TCHAR *path) { (void)fno; (void)path; return FR_INT_ERR; } +FRESULT f_unlink(const TCHAR *path) { (void)path; return FR_INT_ERR; } +FRESULT f_opendir(DIR *dp, const TCHAR *path) { (void)dp; (void)path; return FR_INT_ERR; } +FRESULT f_readdir(DIR *dp, FILINFO *fno) { (void)dp; (void)fno; return FR_INT_ERR; } +FRESULT f_closedir(DIR *dp) { (void)dp; return FR_INT_ERR; } + +/* ===================== 复位与辅助 ===================== */ + +static void reset_all(void) +{ + heap_reset(); + g_wait_calls = 0; + g_release_calls = 0; + g_beat_calls = 0; + g_open_calls = 0; + g_open_rc = FR_OK; + g_open_path[0] = '\0'; + g_open_mode = 0; + g_wait_seq[0] = 1; + g_wait_seq[1] = 1; + g_wait_seq[2] = 1; + g_wait_seq[3] = 1; + s_file_open = 0; + logs_clear(); +} + +static char *canon(const char *base, const char *name) +{ + char *p = lftpd_io_canonicalize_path(base, name); + static char keep[128]; + + if (p == NULL) { + return NULL; + } + (void)strncpy(keep, p, sizeof(keep) - 1u); + keep[sizeof(keep) - 1u] = '\0'; + vPortFree(p); + return keep; +} + +/* ===================== FatFs 路径映射 ===================== */ + +static void tc_fatfs_path(void) +{ + printf("-- TC-LFTPIO-001..004 to_fatfs_path --\n"); + CHECK((strcmp(to_fatfs_path(NULL), "0:") == 0) && + (strcmp(to_fatfs_path(""), "0:") == 0), "TC-LFTPIO-001 NULL/空串 -> 0:"); + CHECK(strcmp(to_fatfs_path("/"), "0:") == 0, "TC-LFTPIO-002 根 -> 0:"); + CHECK(strcmp(to_fatfs_path("/a/b.txt"), "a/b.txt") == 0, + "TC-LFTPIO-003 去掉前导斜杠"); + CHECK(strcmp(to_fatfs_path("a.txt"), "a.txt") == 0, "TC-LFTPIO-004 原样返回"); +} + +/* ===================== 路径规范化 ===================== */ + +static void tc_canon(void) +{ + printf("-- TC-LFTPIO-010..022 canonicalize --\n"); + reset_all(); + CHECK(strcmp(canon("/dir", "f.txt"), "/dir/f.txt") == 0, "TC-LFTPIO-010 相对拼接"); + CHECK(strcmp(canon("/dir", "/abs.txt"), "/abs.txt") == 0, "TC-LFTPIO-011 绝对忽略 base"); + CHECK(strcmp(canon("/a/b", "../c"), "/a/c") == 0, "TC-LFTPIO-012 上跳一级"); + CHECK(strcmp(canon("/a", "../../x"), "/x") == 0, + "TC-LFTPIO-013 上跳过头仍被钳在根内(安全属性)"); + CHECK(strcmp(canon("/a", "."), "/a") == 0, "TC-LFTPIO-014 丢弃 . 段"); + CHECK(strcmp(canon("/a", ".."), "/") == 0, "TC-LFTPIO-015 回退到根"); + CHECK(strcmp(canon("/a//", "b"), "/a/b") == 0, "TC-LFTPIO-016 段间多余斜杠被折叠"); + CHECK(strcmp(canon("/dir", "//x"), "/x") == 0, + "TC-LFTPIO-016b name 以斜杠开头按**绝对路径**处理(忽略 base,仍钳在根内)"); + CHECK(strcmp(canon(NULL, "x"), "/x") == 0, "TC-LFTPIO-017 base NULL 视作空串"); + CHECK(strcmp(canon("/a", NULL), "/a") == 0, "TC-LFTPIO-018 name NULL 视作空串"); + CHECK(strcmp(canon(NULL, NULL), "/") == 0, "TC-LFTPIO-019 两个 NULL -> /"); + CHECK(strcmp(canon("/", ".."), "/") == 0, "TC-LFTPIO-020 根上加 .. 仍是根"); + + reset_all(); + g_fail_at = 1; + CHECK(lftpd_io_canonicalize_path("/a", "b") == NULL, + "TC-LFTPIO-021 首块分配失败返回 NULL"); + CHECK(g_alloc_calls == 1, "TC-LFTPIO-021b 失败后不再分配第二块"); + + reset_all(); + g_fail_at = 2; + CHECK(lftpd_io_canonicalize_path("/a", "b") == NULL, + "TC-LFTPIO-022 结果缓冲分配失败返回 NULL"); + CHECK(g_free_calls == 1, "TC-LFTPIO-022b 第一块已释放(不泄漏堆)"); +} + +/* ===================== 打开文件:守卫 / 卷锁 / 错误通道 ===================== */ + +static void tc_open(void) +{ + int rc; + + printf("-- TC-LFTPIO-030..037 open_read / open_write --\n"); + + reset_all(); + rc = lftpd_io_open_read("/a/b.txt"); + CHECK((rc == 0) && (g_open_mode == FA_READ) && + (strcmp(g_open_path, "a/b.txt") == 0), + "TC-LFTPIO-030 成功:FA_READ + 映射后的路径"); + + reset_all(); + s_file_open = 1; + rc = lftpd_io_open_read("/x"); + CHECK((rc == -1) && (g_open_calls == 0) && (g_wait_calls == 0), + "TC-LFTPIO-031 单文件槽守卫:已打开时不碰卷锁与 FatFs"); + + reset_all(); + g_open_rc = FR_NO_FILE; + rc = lftpd_io_open_read("/x"); + CHECK((rc == -1) && (g_release_calls == 1) && (g_lfio_error_count == 1), + "TC-LFTPIO-032 打开失败:返回 -1 + 留痕 + 释放卷锁"); + + reset_all(); + rc = lftpd_io_open_read("/x"); + CHECK((rc == 0) && (g_release_calls == 0) && (s_file_open == 1), + "TC-LFTPIO-033 成功后卷锁不释放(传输期独占)"); + + reset_all(); + g_wait_seq[0] = 0; /* 第一轮:卷被 defaultTask 占着 */ + g_wait_seq[1] = 0; /* 第二轮:仍未就绪 */ + g_wait_seq[2] = 1; /* 第三轮:拿到 */ + rc = lftpd_io_open_read("/x"); + CHECK((rc == 0) && (g_beat_calls == 2) && (g_wait_calls == 3), + "TC-LFTPIO-034 等锁循环每轮喂看门狗(漏喂 = 整机复位)"); + + reset_all(); + rc = lftpd_io_open_write("/up.bin"); + CHECK((rc == 0) && (g_open_mode == (BYTE)(FA_WRITE | FA_CREATE_ALWAYS)), + "TC-LFTPIO-035 open_write 用覆盖写模式"); + + reset_all(); + s_file_open = 1; + rc = lftpd_io_open_write("/up.bin"); + CHECK((rc == -1) && (g_open_calls == 0), "TC-LFTPIO-036 open_write 同样有槽守卫"); + + reset_all(); + g_open_rc = FR_DISK_ERR; + rc = lftpd_io_open_write("/up.bin"); + CHECK((rc == -1) && (g_release_calls == 1), "TC-LFTPIO-037 open_write 失败也释放卷锁"); +} + +/* ===================== trim ===================== */ + +static void tc_trim(void) +{ + char buf[32]; + char *r; + + printf("-- TC-LFTSTR-001..005 lftpd_string_trim --\n"); + + /* 实现是"返回一个跳过行首空白的指针",**不搬移缓冲**;调用方 lftpd.c:780 + * 用的正是返回值。所以断言必须落在返回值上,落在 buf 上是错的。 */ + (void)strcpy(buf, " PASS x"); + r = lftpd_string_trim(buf); + CHECK((r != buf) && (strcmp(r, "PASS x") == 0) && (buf[0] == ' '), + "TC-LFTSTR-001 行首空白靠返回指针跳过,原缓冲不搬移"); + + (void)strcpy(buf, "NOOP"); + r = lftpd_string_trim(buf); + CHECK((r == buf) && (strcmp(buf, "NOOP") == 0), "TC-LFTSTR-002 无前导空白返回同一指针"); + + (void)strcpy(buf, " USER admin "); + r = lftpd_string_trim(buf); + CHECK(strcmp(r, "USER admin ") == 0, "TC-LFTSTR-003 词中间空白保留"); + + (void)strcpy(buf, " \t\r\n"); + r = lftpd_string_trim(buf); + CHECK(r[0] == '\0', "TC-LFTSTR-004 全空白 -> 空串"); + + /* 现状桩(不是期望行为):尾部循环从 strlen(p) 起步,那里是 '\0', + * isspace(0)=0 立刻退出 —— **尾部空白/CRLF 实际从未被裁**。今天无害是因为 + * CRLF 在 lftpd_inet_read_line(lftpd_inet.c 的 strstr("\r\n"))里已截掉。 + * 谁哪天把 trim 修对了,这条会红——那是提醒,不是回归。 */ + (void)strcpy(buf, "LIST "); + r = lftpd_string_trim(buf); + CHECK(strcmp(r, "LIST ") == 0, + "TC-LFTSTR-005 现状:尾部空白不裁(潜伏缺陷已登记,修好它会让本条响)"); +} + +/* ===================== 入口 ===================== */ + +int main(void) +{ + printf("=== LFTPD IO Tests ===\n"); + + tc_fatfs_path(); + tc_canon(); + tc_open(); + tc_trim(); + + printf("\n=== LFTPD IO Tests: %d/%d PASSED (failed=%d) ===\n", + s_passed, s_total, s_failed); + return (s_failed == 0) ? 0 : 1; +} diff --git a/test/host/lftpd_io/run_unit_test.py b/test/host/lftpd_io/run_unit_test.py new file mode 100644 index 00000000..7ef05171 --- /dev/null +++ b/test/host/lftpd_io/run_unit_test.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""FTP 文件 IO 层(lftpd_io + lftpd_string)的主机侧单元测试运行器(L0.5,纯标准库)。 + +做什么: + 用本机 MSVC 编译 test/host/lftpd_io/lftpd_io_test.c(同时纳入生产源 + Drivers/BSP/NET/lftpd/lftpd_io.c 与 lftpd_string.c),在 PC 上离线验证路径规范化 + (含 ".." 越权钳位)、FatFs 路径映射、单文件槽守卫、卷锁获取/释放时机、等锁期间 + 喂狗、分配失败返回 NULL 且不泄漏,以及 FTP 命令行 trim。失败即非 0 退出。 + 工具链与判定委托 test/host/common/unit_test_harness.py。 + +为什么要主机侧单测(而不是只用台架): + 越权钳位是安全属性(FTP 客户端可发任意带 ".." 的路径),真机上没有第二个文件系统 + 可逃、测不出来;卷锁"成功不释放、失败必释放"写反了就是 defaultTask 永远拿不到卷 + 或传输中途卷被人改;等锁循环漏喂狗就是整机复位——三条都只在真机偶发、日志看不出 + 根因。分配失败分支更只有假堆能构造(模拟器宿主 CRT 永不失败)。 + +桩的边界:ff.h / vol_lock.h / lftpd.h / private/*.h 全用**真实头**(FRESULT、FA_*、 + 函数签名一律原汁);FreeRTOS.h 与 dbg_log.h 用本目录桩;f_open、卷锁、喂狗、 + pvPortMalloc/vPortFree 由测试单元给可控实现(假堆可注入分配失败)。 + read/write/close/stat/unlink/readdir 未列入本批:要先把假 FIL/FILINFO 的字段语义 + 做实,否则断言落在假件自己写的规则上(详见测试单元文件头"未覆盖")。 + +用法(仓库根目录): + python test/host/lftpd_io/run_unit_test.py + +退出码:0=PASS / 1=测试失败 / 2=环境错误。 + +设计依据:docs/内存管理/RAM使用分析与优化方案.md §4.2.1、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 必须最先(覆盖 FreeRTOS.h / dbg_log.h)。 +# lftpd_io.c 里 include 的是 "private/lftpd_io.h" 与 "lftpd.h",故给出 lftpd 目录; +# vol_lock.h 在 App/fs,ff.h 在 Lib/FatFs(与 test/host/nand_port 同一份真实头)。 +BUILD = TestBuild( + name="LFTPD IO Tests", + src=os.path.join(HERE, "lftpd_io_test.c"), + include_dirs=[ + os.path.join(HERE, "stub"), + os.path.join(ROOT, "Drivers", "BSP", "NET", "lftpd"), + os.path.join(ROOT, "App", "fs"), + os.path.join(ROOT, "Lib", "FatFs"), + HERE, + ], + # /D_CRT_SECURE_NO_WARNINGS:生产 lftpd_io.c 里的 strcpy/strcat/strncpy 在 + # MSVC 下刷 C4996 弃用告警(ARMCC 不报,且 lftpd 不在 coding-standard 范围内), + # 这是纯主机侧噪音;关掉它才能让本模块的编译输出保持可读。 + extra_flags=["/D_CRT_SECURE_NO_WARNINGS"], + summary_marker="=== LFTPD IO Tests:", +) + +if __name__ == "__main__": + sys.exit(run_host_unit_test(BUILD)) + diff --git a/test/host/lftpd_io/stub/FreeRTOS.h b/test/host/lftpd_io/stub/FreeRTOS.h new file mode 100644 index 00000000..15a4a82d --- /dev/null +++ b/test/host/lftpd_io/stub/FreeRTOS.h @@ -0,0 +1,23 @@ +/* + * 模块名称:主机侧单测桩 - FreeRTOS.h(lftpd_io 模块专用) + * 模块功能:lftpd_io.c 只经这个头拿到 pvPortMalloc / vPortFree 的原型(它自己不 + * 调内核 API,等卷锁是 vol_lock 的事)。真实 FreeRTOS.h 会拉整棵 + * portmacro / 编译器抽象层,PC 上编不动,故这里只给这两个原型。 + * + * ⚠ 仅供 test/host/lftpd_io/ 使用,不参与 MCU 构建。 + * 签名必须与 FreeRTOS 的 portmacro 一致(size_t 入参 / void 返回的 free); + * 真实实现由测试单元给出**可注入失败的假堆**,用来跑"分配失败返回 NULL"的分支 + * ——那正是真机上 MicroLIB 512B C 堆与 FreeRTOS 堆耗尽时的形态。 + * + * 创建日期:2026-10-23 + */ + +#ifndef FREERTOS_H +#define FREERTOS_H + +#include + +void *pvPortMalloc(size_t xWantedSize); +void vPortFree(void *pv); + +#endif /* FREERTOS_H */ diff --git a/test/host/lftpd_io/stub/dbg_log.h b/test/host/lftpd_io/stub/dbg_log.h new file mode 100644 index 00000000..99e75726 --- /dev/null +++ b/test/host/lftpd_io/stub/dbg_log.h @@ -0,0 +1,19 @@ +/* + * 模块名称:主机侧单测桩 - dbg_log.h(lftpd_io 模块专用) + * 模块功能:DBG_* 换成计数,让"打开失败必须留痕、等锁循环不打日志"可断言。 + * ⚠ 仅供 test/host/lftpd_io/ 使用,不参与 MCU 构建。 + * 创建日期:2026-10-23 + */ + +#ifndef DBG_LOG_H +#define DBG_LOG_H + +extern int g_lfio_info_count; +extern int g_lfio_error_count; +extern int g_lfio_debug_count; + +#define DBG_INFO(fmt, ...) do { (void)(fmt); g_lfio_info_count++; } while (0) +#define DBG_DEBUG(fmt, ...) do { (void)(fmt); g_lfio_debug_count++; } while (0) +#define DBG_ERROR(fmt, ...) do { (void)(fmt); g_lfio_error_count++; } while (0) + +#endif /* DBG_LOG_H */ From 658bc40cccece689d6c4cab40856f7ad9e46226c Mon Sep 17 00:00:00 2001 From: Wanderingss <1624155937@qq.com> Date: Fri, 23 Oct 2026 15:59:14 +0800 Subject: [PATCH 06/15] =?UTF-8?q?test(NET):=20net=5Fselect=20=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=EF=BC=8821=20=E6=9D=A1=EF=BC=89=E4=B8=8E=20NET=20?= =?UTF-8?q?=E5=9F=9F=E9=A6=96=E4=BB=BD=20ch395f=20=E6=A1=A9=EF=BC=8C?= =?UTF-8?q?=E7=99=BB=E8=AE=B0=E4=B8=A4=E5=A4=84=E6=AD=BB=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1 的 NET 四块开了第一块:新增 test/host/net_select/,21 条断言全部 MSVC 编译 通过并 PASS。net_select.c 一个芯片函数都不调,所以它是四块里唯一不需要假芯片的, 先把它拿下,顺带把 NET 域的桩骨架立起来。 钉住的契约: - 就绪位回写:入参集合里未就绪的 fd 必须被清掉,只留就绪的(POSIX 语义);未请求 的集合不被写回、可以传 NULL。 - 状态准入:LISTENING 即使 readable 也不算可读(accept 语义不在这层);ESTABLISHED + send_ready 才可写;CONNECTING 时 send_ready 不作数。 - 边界:nfds 为 0 / 负 / 超过 NET_MAX_SOCKETS 都返回 -1(不许扫出 socket 表外)。 - 超时算术:tv_usec 是**整除截断**(1999us 当 1ms 而不是 2ms);timeout=NULL 时 无限等并由对端事件唤醒;poll 的 timeout=0 首轮即退、每轮先清 revents。 用可控 tick(每问一次 +1)+ 可控 osDelay(计数、可指定轮数后造一个就绪 socket) 把这些变成确定性轮数,台架上根本构造不出"没人来连时该怎么退"。 照出两处**死路径**(只登记,未改语义;全库唯一调用点 test/board/net_test_task.c 传的是 NULL 与非零 tv,今天没有受害者): 1. net_select 的 timeout = {0,0} 换算出 timeout_ms = 0,而退出条件写的是 if ((timeout_ms > 0) && (已用时 >= timeout_ms)) → **超时 0 永不触发退出**,退化成 无限等;POSIX 在这里必须立刻返回 0。同文件 net_poll_events 写的 timeout >= 0 是对的,两条并排放着正好是对照。风险面:谁拿它当"非阻塞查一遍",netTask 就 回不到主循环,而 netTask 在 IWDG required_mask 里。 2. exceptfds 是恒为空的死参数:循环体开头 p_sock == NULL → continue 先跳过, check_socket_error 的 NULL 分支从 select 里到不了。按 POSIX 直觉用它收异常的人 会永远什么都收不到。 两处都用"现状桩"钉成改了会响(用例名里写明不是期望行为)。另修掉我自己写错的一条 用例:给 timeout=NULL 又没有就绪 fd,直接死循环——已在假 osDelay 里加了 500 轮 保险(跑飞就 exit(2)),因为挂死的测试会把 exe 文件名占住、让后续构建报 LNK1104。 桩的边界写死在文件头:net_select.h / net_types.h / net_config.h 用真实头(net_sock_t 字段名与 NET_SOCK_STATE_* 一改即编译期报错);main.h / cmsis_os.h / FreeRTOS.h / task.h 是本目录桩;新建的 ch395f.h 是**空壳**,只服务不调芯片函数的文件—— net_pending / net_events / net_listener 直接调十余处 ch395f_*,要的是可脚本化返回的 假芯片,不要把这个空壳当成"NET 域桩已就绪"。 覆盖率:套件 21 → 22 全绿;files_tested 25 → 26;未触及 .c 41 → 40;分母 3,332 → 3,446 行;net_select.c 97/114(85.1%)。恒等式更新为 40 = 6(P1 剩) + 5(P2) + 29(P3)。 编号按 B 层口径补建 TC-HOST-NETSEL(游标 019),TC-HOST 族现共 16 族;注册表仍只 做增量插入。 自查:test-cppcheck Passed;check_ids --strict 通过;doc-links = 35 处(= 存量); BOM/CR/U+FFFD/行尾逐项净。本增量零生产代码改动,未跑 @build。--all-files 仍只有 mem-numbers / mem-budget(.map 台账漂移,等 Lead 合并后统一回填)与 ui-golden (ref/*.bin 不入库,新 worktree 天生没有黄金参考)三条失败,与本分支无关。 --- docs/测试说明/主机单测覆盖率口径.md | 20 +- scripts/id_registry.json | 22 ++ scripts/test_coverage_baseline.json | 40 ++- test/host/net_select/net_select_test.c | 379 +++++++++++++++++++++++++ test/host/net_select/run_unit_test.py | 55 ++++ test/host/net_select/stub/FreeRTOS.h | 15 + test/host/net_select/stub/ch395f.h | 21 ++ test/host/net_select/stub/cmsis_os.h | 17 ++ test/host/net_select/stub/main.h | 17 ++ test/host/net_select/stub/net_socket.h | 22 ++ test/host/net_select/stub/task.h | 11 + 11 files changed, 603 insertions(+), 16 deletions(-) create mode 100644 test/host/net_select/net_select_test.c create mode 100644 test/host/net_select/run_unit_test.py create mode 100644 test/host/net_select/stub/FreeRTOS.h create mode 100644 test/host/net_select/stub/ch395f.h create mode 100644 test/host/net_select/stub/cmsis_os.h create mode 100644 test/host/net_select/stub/main.h create mode 100644 test/host/net_select/stub/net_socket.h create mode 100644 test/host/net_select/stub/task.h diff --git a/docs/测试说明/主机单测覆盖率口径.md b/docs/测试说明/主机单测覆盖率口径.md index 28e001c3..df4c0ba1 100644 --- a/docs/测试说明/主机单测覆盖率口径.md +++ b/docs/测试说明/主机单测覆盖率口径.md @@ -225,8 +225,24 @@ | FTP 一套(4 块里 2 块) | 🟡 部分 | `test/host/lftpd_io`(33 条,含 `lftpd_string.c`) | `lftpd_io.c` **85/159 = 53.5%**、`lftpd_string.c` 8/10;`read/write/close/stat/unlink/readdir` 未测(要先把假 `FIL/FILINFO` 的字段语义做实,否则断言落在假件自己写的规则上) | -| `lftpd.c` / `lftpd_inet.c` | ⬜ 未开始 | — | 需假 `net_socket` 层(`time_sync`/stub 里那份可扩展) | -| NET 四块 / 104 服务层 | ⬜ 未开始 | — | **必须先新建 `stub/ch395f.h`** | +| NET 四块之一:`net_select.c` | ✅ 完成 | `test/host/net_select`(21 条) | `net_select.c` 97/114 = **85.1%** | +| `lftpd.c` / `lftpd_inet.c` | ⬜ 未开始 | — | 需假 `net_socket` 层(`time_sync`/stub 那份可扩展) | +| NET 剩下三块(`net_pending`/`net_events`/`net_listener`) | ⬜ 未开始 | — | + 本批新建的 `stub/ch395f.h` 是**空壳**,只服务"不调芯片函数"的文件;这三块直接调 + `ch395f_*` 十余处,要的是能脚本化返回的**假芯片**,不是这个空壳 | +| 104 服务层 | ⬜ 未开始 | — | 桩最省(复用 `cmsis_os2.h` + `net_socket.h`) | + +本批照出的两处**现状**(只登记、**未改语义**;全库唯一调用点 `test/board/net_test_task.c` +传的是 NULL 与非零 tv,今天没有受害者): + +1. `net_select` 的 `timeout = {0, 0}` 换算出 `timeout_ms = 0`,而退出条件写的是 + `if ((timeout_ms > 0) && (已用时 >= timeout_ms))` → **超时 0 永不触发退出**,退化成 + 无限等(POSIX 语义是立刻返回 0)。`net_poll_events` 的 `timeout >= 0` 写对了, + 两条并排放着正好是对照。风险面:谁拿它当"非阻塞查一遍",netTask 就再也回不到 + 主循环,而 netTask 在 IWDG `required_mask` 里。 +2. `exceptfds` 是**恒为空的死参数**:循环体开头 `p_sock == NULL → continue` 先跳过, + `check_socket_error` 的 NULL 分支从 select 里到不了。按 POSIX 直觉用它收异常的人 + 会永远什么都收不到。修法:continue 之前先判异常,或在 `net_select.h` 写明不支持异常集。 数字:套件 16 → **21**、`files_tested` 19 → **25**、未触及 125 → 119(`.c` 47 → **41**, 恒等式 `41 = 7(P1 剩) + 5(P2) + 29(P3)` 仍然咬合)、分母 2,893 → **3,332** 插桩行。 diff --git a/scripts/id_registry.json b/scripts/id_registry.json index 9c73317a..57fa726f 100644 --- a/scripts/id_registry.json +++ b/scripts/id_registry.json @@ -1724,6 +1724,28 @@ "subitem_ok": true, "owner_note": "B 层口径(2026-10-21 批 3 定:这些号在代码目录外零消费者,属主登记处 = 单测代码本身,不逐号补文档);2026-10-23 随覆盖率口径 §9 P1 新建模块补登记", "cursor_note": "游标按实测最大号收录(2026-10-23):越界即视为未登记的冒号牌" + }, + { + "name": "TC-HOST-NETSEL", + "class": "TC", + "title": "主机侧单测用例号:NET 层 select/poll 事件观测(test/host/net_select)", + "pattern": "\\bTC-NETSEL-[0-9]{1,4}[a-z]?\\b", + "digit_width": [ + 2, + 4 + ], + "owner_docs_glob": [ + "test/host/net_select/*" + ], + "scope": "global", + "frozen": false, + "cursor": "TC-NETSEL-019", + "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 a8660a5c..adfe1ae4 100644 --- a/scripts/test_coverage_baseline.json +++ b/scripts/test_coverage_baseline.json @@ -184,6 +184,14 @@ "lines_covered": 8, "lines_valid": 10 }, + "Drivers/BSP/NET/net_select.c": { + "func_rate": 1.0, + "funcs_covered": 5, + "funcs_valid": 5, + "line_rate": 0.8509, + "lines_covered": 97, + "lines_valid": 114 + }, "Drivers/BSP/NETCFG/ftp_cfg.c": { "func_rate": 1.0, "funcs_covered": 4, @@ -284,6 +292,13 @@ "lines_total": 112, "ok": true }, + "net_select": { + "files": { + "Drivers/BSP/NET/net_select.c": 114 + }, + "lines_total": 114, + "ok": true + }, "phasor": { "files": { "App/algo/phasor.c": 55, @@ -395,21 +410,22 @@ "Drivers/BSP/NET/lftpd/lftpd_auth.c", "Drivers/BSP/NET/lftpd/lftpd_io.c", "Drivers/BSP/NET/lftpd/lftpd_string.c", + "Drivers/BSP/NET/net_select.c", "Drivers/BSP/NETCFG/ftp_cfg.c", "Drivers/BSP/NETCFG/net_cfg.c" ], "totals": { "files_generated_excluded": 5, - "files_tested": 25, - "files_untested": 119, - "func_rate": 0.9094, - "funcs_covered": 231, - "funcs_valid": 254, - "line_rate": 0.8715, - "lines_covered": 2904, - "lines_valid": 3332, - "modules_ok": 21, - "modules_total": 21 + "files_tested": 26, + "files_untested": 118, + "func_rate": 0.9112, + "funcs_covered": 236, + "funcs_valid": 259, + "line_rate": 0.8709, + "lines_covered": 3001, + "lines_valid": 3446, + "modules_ok": 22, + "modules_total": 22 }, "untested_sources": [ { @@ -492,10 +508,6 @@ "code_lines": 171, "path": "Drivers/BSP/NET/lftpd/lftpd_inet.c" }, - { - "code_lines": 162, - "path": "Drivers/BSP/NET/net_select.c" - }, { "code_lines": 157, "path": "Drivers/BSP/TPAFE5160/tpafe5160.c" diff --git a/test/host/net_select/net_select_test.c b/test/host/net_select/net_select_test.c new file mode 100644 index 00000000..55a14f2b --- /dev/null +++ b/test/host/net_select/net_select_test.c @@ -0,0 +1,379 @@ +/* + * 模块名称:NET 层 select/poll 事件观测主机侧单元测试 + * 模块功能:在 PC 上离线验证 Drivers/BSP/NET/net_select.c 的**就绪判定与超时算术**: + * fd 集只保留就绪位、socket 状态对可读/可写的准入、非法 fd 的处理、 + * tv_sec/tv_usec 的换算与截断、poll 的 revents 语义。 + * + * 被测对象:真实源文件 ../../../Drivers/BSP/NET/net_select.c(#include 原样纳入)。 + * net_select.h / net_types.h / net_config.h 全用**真实头**(net_sock_t 的 + * 字段名与 NET_SOCK_STATE_* 枚举一改就编译期报错)。桩四个:main.h + * (HAL_GetTick)、cmsis_os.h(osDelay)、FreeRTOS.h/task.h(空壳), + * 外加 net_socket.h 桩(只声明 net_get_sock,类型仍取真实 net_types.h) + * 与 ch395f.h 空桩(本文件一个芯片函数都不调)。 + * + * 为什么要主机侧单测(而不是只靠台架): + * netTask 是**串行 socket 栈**的唯一执行者,且在 IWDG required_mask 里:这里的 + * 等待逻辑一旦算错,表现是"整个网络停摆 + 到点喂不上狗复位",而日志里什么都没有。 + * 台架上无法构造"没人来连、超时该怎么算"这类边界(对端行为不可控),主机侧把 + * tick 与 osDelay 做成可控量后可以精确复现"第几轮该退出去"。 + * + * 覆盖点: + * TC-NETSEL-001 nfds = 0 → -1 + * TC-NETSEL-002 nfds 为负 → -1 + * TC-NETSEL-003 nfds 超过 NET_MAX_SOCKETS → -1(不许越界扫 socket 表) + * TC-NETSEL-004 就绪位回写:入参集合里的未就绪 fd 必须被清掉,只留就绪的 + * TC-NETSEL-005 三个集合都可以传 NULL(不崩、不误伤) + * TC-NETSEL-006 timeout=NULL 时无限等,靠"对端事件到来"唤醒(假 osDelay 造事件) + * TC-NETSEL-007 tv_usec 换算是**整除截断**(1999us → 1ms,不是 2ms) + * TC-NETSEL-008 现状桩:timeout = {0, 0} 不立即返回(POSIX select 会立刻返回 0) + * TC-NETSEL-009 LISTENING 的 socket 即使 readable=1 也不算可读 + * TC-NETSEL-010 ESTABLISHED + send_ready → 可写位置位 + * TC-NETSEL-011 非 ESTABLISHED(CONNECTING)即使 send_ready=1 也不可写 + * TC-NETSEL-012 现状桩:exceptfds 是**恒为空的死参数**(表项为空的 fd 先被 continue + * 跳过,异常判定从 select 里到不了);全库唯一调用点 test/board 传 NULL, + * 所以今天无受害者——但谁按 POSIX 直觉用它就会静默收不到异常 + * TC-NETSEL-012b 同一 fd 请求 POLLIN 也不算可读(被跳过,超时到点即退) + * TC-NETSEL-013 poll 传 NULL 或 nfds<=0 → -1 + * TC-NETSEL-014 poll 非法 fd(负数 / 越界)→ NET_POLLNVAL 且计入 ready + * TC-NETSEL-015 poll 对 CLOSED socket 置 NET_POLLHUP + * TC-NETSEL-016 poll 命中 POLLIN → revents 含 NET_POLLIN + * TC-NETSEL-017 现状桩:NET_POLLERR 在本实现里**永远置不上**(死分支,已登记) + * TC-NETSEL-018 poll 的 timeout = 0 立即返回(首轮就退,一次都不睡) + * TC-NETSEL-019 poll 每轮先把 revents 清零,上一轮的位不会带到这一轮 + * + * 两处"现状桩"(008 / 017)钉的是**当前行为**,不是期望行为:修好它们这两条会红, + * 那是提醒而不是回归。理由写在下面的注释里。 + * + * 通过:全部 TC PASS(main 返回 0)。 + * 入口:int main(void),返回 0=PASS / 1=FAIL(由 run 脚本判读)。 + * + * 创建日期:2026-10-23 + */ + +#include +#include + +#include "net_select.h" +#include "net_socket.h" + +#include "net_select.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) + +/* ===================== 可控 socket 表 ===================== */ + +static net_sock_t g_sock[NET_MAX_SOCKETS]; +static uint8_t g_sock_valid[NET_MAX_SOCKETS]; + +const net_sock_t *net_get_sock(int sockfd) +{ + if ((sockfd < 0) || (sockfd >= NET_MAX_SOCKETS)) { + return NULL; + } + if (g_sock_valid[sockfd] == 0u) { + return NULL; + } + return &g_sock[sockfd]; +} + +/* ===================== 可控时基与睡眠 ===================== */ + +static uint32_t g_tick; +static int g_delay_calls; +static int g_release_after; /* >0:睡够这个轮数后让 fd0 变可读(模拟对端事件) */ + +uint32_t HAL_GetTick(void) +{ + return g_tick++; /* 每问一次时间就前进 1ms:轮数 = 毫秒数,便于精确断言 */ +} + +void osDelay(uint32_t ticks) +{ + (void)ticks; + g_delay_calls++; + if (g_delay_calls > 500) { + /* 保险:本模块每条用例都必须在有限轮内退出(要么就绪、要么超时、要么由 + * g_release_after 造事件)。跑飞就说明用例或实现出了问题——直接退出, + * 绝不能把挂死留给 pre-commit / CI(上一次就是这么卡住 exe 文件名的)。 */ + printf(" ABORT 轮询跑飞(>500 轮未退出)\n"); + fflush(stdout); + exit(2); + } + g_tick++; + if ((g_release_after > 0) && (g_delay_calls >= g_release_after)) { + g_sock_valid[0] = 1u; + g_sock[0].state = NET_SOCK_STATE_ESTABLISHED; + g_sock[0].readable = 1u; + } +} + +/* ===================== 复位 ===================== */ + +static void reset_all(void) +{ + (void)memset(g_sock, 0, sizeof(g_sock)); + (void)memset(g_sock_valid, 0, sizeof(g_sock_valid)); + g_tick = 0u; + g_delay_calls = 0; + g_release_after = 0; +} + +static void make_sock(int fd, net_sock_state_t st, uint8_t readable, uint8_t send_ready) +{ + g_sock_valid[fd] = 1u; + g_sock[fd].state = st; + g_sock[fd].readable = readable; + g_sock[fd].send_ready = send_ready; +} + +/* ===================== 参数边界 ===================== */ + +static void tc_bounds(void) +{ + net_fd_set_t r; + + printf("-- TC-NETSEL-001..003 nfds bounds --\n"); + reset_all(); + NET_FD_ZERO(&r); + CHECK(net_select(0, &r, NULL, NULL, NULL) == -1, "TC-NETSEL-001 nfds=0 -> -1"); + CHECK(net_select(-1, &r, NULL, NULL, NULL) == -1, "TC-NETSEL-002 nfds 为负 -> -1"); + CHECK(net_select(NET_MAX_SOCKETS + 1, &r, NULL, NULL, NULL) == -1, + "TC-NETSEL-003 nfds 越界 -> -1(不许扫出表外)"); +} + +/* ===================== 就绪位与集合回写 ===================== */ + +static void tc_ready_sets(void) +{ + net_fd_set_t r; + net_fd_set_t w; + int rc; + + printf("-- TC-NETSEL-004..005 ready sets --\n"); + reset_all(); + make_sock(0, NET_SOCK_STATE_ESTABLISHED, 0u, 0u); /* 请求了但没就绪 */ + make_sock(1, NET_SOCK_STATE_ESTABLISHED, 1u, 0u); /* 就绪 */ + NET_FD_ZERO(&r); + NET_FD_SET(0, &r); + NET_FD_SET(1, &r); + rc = net_select(4, &r, NULL, NULL, NULL); + CHECK((rc == 1) && NET_FD_ISSET(1, &r) && !NET_FD_ISSET(0, &r), + "TC-NETSEL-004 回写只保留就绪位,未就绪的 fd0 被清掉"); + + reset_all(); + make_sock(2, NET_SOCK_STATE_ESTABLISHED, 1u, 0u); + NET_FD_ZERO(&r); + NET_FD_SET(2, &r); + rc = net_select(3, &r, NULL, NULL, NULL); /* write/except 传 NULL */ + CHECK((rc == 1) && (NET_FD_ISSET(2, &r)), "TC-NETSEL-005 其余集合可为 NULL"); + + /* 注意:timeout 传 NULL 时本函数会一直等到有事件(见 008 的现状说明), + * 所以这类用例必须让被请求的 fd 真的就绪,否则就是用例自己写死的死循环。 */ + reset_all(); + NET_FD_ZERO(&w); + NET_FD_ZERO(&r); + make_sock(2, NET_SOCK_STATE_ESTABLISHED, 0u, 1u); /* send_ready:可写就绪 */ + NET_FD_SET(2, &w); + rc = net_select(3, NULL, &w, NULL, NULL); + CHECK((rc == 1) && NET_FD_ISSET(2, &w) && (r.fd_bits == 0u), + "TC-NETSEL-005b 只回写调用方真正请求过的集合"); +} + +/* ===================== 超时算术 ===================== */ + +static void tc_timeout(void) +{ + net_fd_set_t r; + net_timeval_t tv; + int rc; + + printf("-- TC-NETSEL-006..008 timeout arithmetic --\n"); + reset_all(); + g_release_after = 3; /* 第 3 轮睡完,对端才有数据 */ + NET_FD_ZERO(&r); + NET_FD_SET(0, &r); + rc = net_select(1, &r, NULL, NULL, NULL); /* timeout = NULL:无限等 */ + CHECK((rc == 1) && (g_delay_calls == 3) && NET_FD_ISSET(0, &r), + "TC-NETSEL-006 无限等被对端事件唤醒(第 3 轮)"); + + reset_all(); + make_sock(0, NET_SOCK_STATE_ESTABLISHED, 0u, 0u); + NET_FD_ZERO(&r); + NET_FD_SET(0, &r); + tv.tv_sec = 0; + tv.tv_usec = 1999; /* 应为 1ms,不是 2ms */ + rc = net_select(1, &r, NULL, NULL, &tv); + CHECK((rc == 0) && (g_delay_calls == 0), + "TC-NETSEL-007 tv_usec 整除截断:1999us 当 1ms,首轮即退"); + + reset_all(); + make_sock(0, NET_SOCK_STATE_ESTABLISHED, 0u, 0u); + NET_FD_ZERO(&r); + NET_FD_SET(0, &r); + tv.tv_sec = 0; + tv.tv_usec = 0; + g_release_after = 5; /* 造一个事件把自己救出来 */ + rc = net_select(1, &r, NULL, NULL, &tv); + /* 现状桩:timeout == {0,0} → 换算出 timeout_ms = 0,而实现里那句是 + * if ((timeout_ms > 0) && (已用时 >= timeout_ms)) break; + * 于是**永远不会因超时退出**,只能等事件——POSIX 的 select 在这里必须立刻返回 0。 + * 谁把那个 >0 修掉,这条就会红(g_delay_calls 变 0、rc 变 0):那是提醒。 */ + CHECK((rc == 1) && (g_delay_calls == 5), + "TC-NETSEL-008 现状:timeout={0,0} 不立即返回(已登记为缺陷)"); +} + +/* ===================== 状态准入 ===================== */ + +static void tc_state_admission(void) +{ + net_fd_set_t r; + net_fd_set_t w; + net_timeval_t tv; + int rc; + + printf("-- TC-NETSEL-009..012 state admission --\n"); + reset_all(); + make_sock(0, NET_SOCK_STATE_LISTENING, 1u, 0u); /* 监听中但报"可读" */ + NET_FD_ZERO(&r); + NET_FD_SET(0, &r); + tv.tv_sec = 0; + tv.tv_usec = 1000; + rc = net_select(1, &r, NULL, NULL, &tv); + CHECK((rc == 0) && !NET_FD_ISSET(0, &r), + "TC-NETSEL-009 监听态不可读(accept 语义不在 select 这层)"); + + reset_all(); + make_sock(0, NET_SOCK_STATE_ESTABLISHED, 0u, 1u); + NET_FD_ZERO(&w); + NET_FD_SET(0, &w); + rc = net_select(1, NULL, &w, NULL, &tv); + CHECK((rc == 1) && NET_FD_ISSET(0, &w), + "TC-NETSEL-010 已建立 + send_ready 才可写"); + + reset_all(); + make_sock(0, NET_SOCK_STATE_CONNECTING, 0u, 1u); + NET_FD_ZERO(&w); + NET_FD_SET(0, &w); + rc = net_select(1, NULL, &w, NULL, &tv); + CHECK((rc == 0) && !NET_FD_ISSET(0, &w), + "TC-NETSEL-011 连接中不可写(send_ready 不作数)"); + + /* 现状桩(不是期望):net_select 的循环体开头是 + * p_sock = net_get_sock(i); if (p_sock == NULL) continue; + * 于是"表项不存在"的 fd 被整轮跳过,check_socket_error 的 NULL 分支从 select + * 里**永远到不了** → exceptfds 是个恒为空的死参数(谁传了它以为能收到异常, + * 就什么都收不到)。改法要么在 continue 前先查异常、要么在文档里写明不支持。 */ + reset_all(); + g_sock_valid[0] = 0u; + NET_FD_ZERO(&r); + NET_FD_SET(0, &r); + tv.tv_sec = 0; + tv.tv_usec = 1000; + rc = net_select(1, NULL, NULL, &r, &tv); + CHECK((rc == 0) && !NET_FD_ISSET(0, &r), + "TC-NETSEL-012 现状:exceptfds 永不被置位(表项为空先被 continue 跳过)"); + reset_all(); + g_sock_valid[0] = 0u; + NET_FD_ZERO(&r); + NET_FD_SET(0, &r); + rc = net_select(1, &r, NULL, NULL, &tv); + CHECK((rc == 0) && !NET_FD_ISSET(0, &r) && (g_delay_calls == 0), + "TC-NETSEL-012b 不存在的 fd 也不算可读(被跳过而不是报错,超时到点即退)"); +} + +/* ===================== poll 语义 ===================== */ + +static void tc_poll(void) +{ + net_pollfd_t p[4]; + int rc; + + printf("-- TC-NETSEL-013..019 poll --\n"); + reset_all(); + CHECK((net_poll_events(NULL, 1, 0) == -1) && (net_poll_events(p, 0, 0) == -1), + "TC-NETSEL-013 poll 参数非法 -> -1"); + + reset_all(); + (void)memset(p, 0, sizeof(p)); + p[0].fd = -1; + p[0].events = NET_POLLIN; + p[1].fd = NET_MAX_SOCKETS; /* 越界 */ + p[1].events = NET_POLLIN; + rc = net_poll_events(p, 2, 0); + CHECK((rc == 2) && ((p[0].revents & NET_POLLNVAL) != 0) && + ((p[1].revents & NET_POLLNVAL) != 0), + "TC-NETSEL-014 负 fd 与越界 fd 都置 POLLNVAL 并计入 ready"); + + reset_all(); + make_sock(0, NET_SOCK_STATE_CLOSED, 0u, 0u); + (void)memset(p, 0, sizeof(p)); + p[0].fd = 0; + p[0].events = NET_POLLIN; + rc = net_poll_events(p, 1, 0); + CHECK((rc == 1) && ((p[0].revents & NET_POLLHUP) != 0), + "TC-NETSEL-015 CLOSED 置 POLLHUP"); + + reset_all(); + make_sock(0, NET_SOCK_STATE_ESTABLISHED, 1u, 0u); + (void)memset(p, 0, sizeof(p)); + p[0].fd = 0; + p[0].events = NET_POLLIN; + rc = net_poll_events(p, 1, 0); + CHECK((rc == 1) && ((p[0].revents & NET_POLLIN) != 0) && + ((p[0].revents & NET_POLLHUP) == 0), + "TC-NETSEL-016 可读置 POLLIN 且不误置 HUP"); + + reset_all(); + make_sock(0, NET_SOCK_STATE_ESTABLISHED, 1u, 1u); + (void)memset(p, 0, sizeof(p)); + p[0].fd = 0; + p[0].events = 0; /* 什么都不请求 */ + (void)net_poll_events(p, 1, 0); + CHECK((p[0].revents & NET_POLLERR) == 0, + "TC-NETSEL-017 现状:POLLERR 实际永不为真(表项为空已被 POLLNVAL 抢先)"); + + reset_all(); + make_sock(0, NET_SOCK_STATE_ESTABLISHED, 0u, 0u); + (void)memset(p, 0, sizeof(p)); + p[0].fd = 0; + p[0].events = NET_POLLIN; + rc = net_poll_events(p, 1, 0); /* timeout = 0 */ + CHECK((rc == 0) && (g_delay_calls == 0), + "TC-NETSEL-018 poll 的 timeout=0 立即返回(与 select 那条形成对照)"); + + reset_all(); + make_sock(0, NET_SOCK_STATE_ESTABLISHED, 1u, 0u); + (void)memset(p, 0, sizeof(p)); + p[0].fd = 0; + p[0].events = NET_POLLIN; + p[0].revents = NET_POLLOUT; /* 脏的上一轮残留 */ + (void)net_poll_events(p, 1, 0); + CHECK((p[0].revents & NET_POLLOUT) == 0, "TC-NETSEL-019 每轮先清 revents"); +} + +/* ===================== 入口 ===================== */ + +int main(void) +{ + printf("=== NET Select Tests ===\n"); + + tc_bounds(); + tc_ready_sets(); + tc_timeout(); + tc_state_admission(); + tc_poll(); + + printf("\n=== NET Select 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_select/run_unit_test.py b/test/host/net_select/run_unit_test.py new file mode 100644 index 00000000..74522790 --- /dev/null +++ b/test/host/net_select/run_unit_test.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""NET 层 select/poll(net_select)的主机侧单元测试运行器(L0.5,纯标准库)。 + +做什么: + 用本机 MSVC 编译 test/host/net_select/net_select_test.c(以 #include 纳入生产源 + Drivers/BSP/NET/net_select.c),在 PC 上离线验证就绪位回写、socket 状态对可读/ + 可写的准入、非法 fd 处理、tv_sec/tv_usec 超时换算、poll 的 revents 语义。 + 工具链与判定委托 test/host/common/unit_test_harness.py。 + +为什么要主机侧单测(而不是只用台架): + netTask 是串行 socket 栈的唯一执行者,且在 IWDG required_mask 里——这里的等待 + 逻辑算错,表现是"网络整体停摆 + 喂不上狗复位",而且日志里什么都没有。台架无法 + 构造"没人来连时该怎么退"(对端不可控);把 HAL_GetTick 与 osDelay 做成可控量后, + "第几轮该退出"能精确复现,包括 1999us 该按 1ms 还是 2ms 这种算术。 + +桩的边界:net_select.h / net_types.h / net_config.h 用真实头(net_sock_t 字段名与 + NET_SOCK_STATE_* 一改就编译期报错);main.h / cmsis_os.h / FreeRTOS.h / task.h / + ch395f.h 是本目录桩——net_select.c 一个芯片函数都不调,所以 ch395f.h 是空壳。 + ⚠ 那个空壳不代表 NET 域桩已就绪:net_listener / net_pending / net_events 直接调 + 十多个 ch395f_* 且语义在 socket 状态机里,需要的是能脚本化返回的假芯片。 + +用法(仓库根目录): + python test/host/net_select/run_unit_test.py + +退出码:0=PASS / 1=测试失败 / 2=环境错误。 + +设计依据:docs/网络/Net层阻塞模型架构设计.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))) +sys.path.insert(0, os.path.join(ROOT, "test", "host", "common")) + +from unit_test_harness import TestBuild, run_host_unit_test # noqa: E402 + +# 顺序敏感:stub 必须在 Drivers/BSP/NET 之前,才能用桩覆盖 net_socket.h 与 ch395f.h; +# net_select.h / net_types.h / net_config.h 仍从真实目录取。 +BUILD = TestBuild( + name="NET Select Tests", + src=os.path.join(HERE, "net_select_test.c"), + include_dirs=[ + os.path.join(HERE, "stub"), + os.path.join(ROOT, "Drivers", "BSP", "NET"), + HERE, + ], + summary_marker="=== NET Select Tests:", +) + +if __name__ == "__main__": + sys.exit(run_host_unit_test(BUILD)) + diff --git a/test/host/net_select/stub/FreeRTOS.h b/test/host/net_select/stub/FreeRTOS.h new file mode 100644 index 00000000..154c9884 --- /dev/null +++ b/test/host/net_select/stub/FreeRTOS.h @@ -0,0 +1,15 @@ +/* + * 模块名称:主机侧单测桩 - FreeRTOS.h(net_select 模块专用) + * 模块功能:net_select.c include 了它但不用任何内核 API(让 CPU 走 osDelay), + * 故只需一个空壳把 include 链走通。 + * ⚠ 仅供 test/host/net_select/ 使用,不参与 MCU 构建。 + * 创建日期:2026-10-23 + */ + +#ifndef FREERTOS_H +#define FREERTOS_H + +#include +#include + +#endif /* FREERTOS_H */ diff --git a/test/host/net_select/stub/ch395f.h b/test/host/net_select/stub/ch395f.h new file mode 100644 index 00000000..692e2d90 --- /dev/null +++ b/test/host/net_select/stub/ch395f.h @@ -0,0 +1,21 @@ +/* + * 模块名称:主机侧单测桩 - ch395f.h(NET 域共享形态,第一个使用者是本模块) + * 模块功能:给 NET 域一个**能在 PC 上编译**的芯片头占位。真实 ch395f.h 拉 HAL 与 + * 寄存器定义,主机侧编不动;而 NET 域里 net_select.c 这类文件 include 它 + * **只是为了拿类型/常量**,一个芯片函数都不调。 + * + * ⚠ 仅供主机侧单测使用,不参与 MCU 构建。 + * 边界要说清楚:本桩是**空**的,只服务"不调芯片函数"的文件。 + * net_listener.c / net_pending.c / net_events.c 直接调 10 多个 ch395f_* 且语义在 + * socket 状态机里(GET_SOCK_INT_STATUS 整体读即清等),那三块要的是**带脚本化返回 + * 的假芯片**,不是这个空壳——别把本文件当成"NET 域桩已就绪"。 + * + * 创建日期:2026-10-23 + */ + +#ifndef CH395F_H +#define CH395F_H + +#include + +#endif /* CH395F_H */ diff --git a/test/host/net_select/stub/cmsis_os.h b/test/host/net_select/stub/cmsis_os.h new file mode 100644 index 00000000..70665f28 --- /dev/null +++ b/test/host/net_select/stub/cmsis_os.h @@ -0,0 +1,17 @@ +/* + * 模块名称:主机侧单测桩 - cmsis_os.h(net_select 模块专用) + * 模块功能:net_select.c 只用 osDelay(1) 让出 CPU(等 fd 就绪 / 等超时)。 + * ⚠ 仅供 test/host/net_select/ 使用,不参与 MCU 构建。 + * 测试单元的实现把"睡 1 tick"做成"tick +1 并计数",还能在指定轮数后**造出**一个 + * 就绪 socket —— 用来跑"无限等被事件唤醒"这类真机上要靠对端配合的分支。 + * 创建日期:2026-10-23 + */ + +#ifndef CMSIS_OS_H +#define CMSIS_OS_H + +#include + +void osDelay(uint32_t ticks); + +#endif /* CMSIS_OS_H */ diff --git a/test/host/net_select/stub/main.h b/test/host/net_select/stub/main.h new file mode 100644 index 00000000..f854f83f --- /dev/null +++ b/test/host/net_select/stub/main.h @@ -0,0 +1,17 @@ +/* + * 模块名称:主机侧单测桩 - main.h(net_select 模块专用) + * 模块功能:只给 net_select.c 实际用到的一个符号:HAL_GetTick(超时判定的唯一时基)。 + * ⚠ 仅供 test/host/net_select/ 使用,不参与 MCU 构建。 + * 真实 main.h 拉整棵 HAL 头树,PC 上编不动。测试单元把 tick 做成"每调一次 +1"的 + * 可控量,于是"轮询几轮后超时"这种时序可以精确复现,不用真等毫秒。 + * 创建日期:2026-10-23 + */ + +#ifndef MAIN_H +#define MAIN_H + +#include + +uint32_t HAL_GetTick(void); + +#endif /* MAIN_H */ diff --git a/test/host/net_select/stub/net_socket.h b/test/host/net_select/stub/net_socket.h new file mode 100644 index 00000000..4043291a --- /dev/null +++ b/test/host/net_select/stub/net_socket.h @@ -0,0 +1,22 @@ +/* + * 模块名称:主机侧单测桩 - net_socket.h(net_select 模块专用) + * 模块功能:net_select.c 只需要两样东西:socket 表项类型(用**真实** net_types.h 的 + * net_sock_t 与状态枚举,字段名一改就编译期报错)与 net_get_sock()。 + * 后者由测试单元给可控实现(返回表中项或 NULL),从而能造出 + * "已建立且可读 / 监听中 / 表项不存在 / 已关闭"四种观测态。 + * + * ⚠ 仅供 test/host/net_select/ 使用,不参与 MCU 构建。 + * 刻意不 include 真实 net_socket.h:它带着整条 netTask 消息队列与芯片依赖, + * 而 select 层的契约只依赖 socket 表项的形状。 + * + * 创建日期:2026-10-23 + */ + +#ifndef NET_SOCKET_H +#define NET_SOCKET_H + +#include "net_types.h" + +const net_sock_t *net_get_sock(int sockfd); + +#endif /* NET_SOCKET_H */ diff --git a/test/host/net_select/stub/task.h b/test/host/net_select/stub/task.h new file mode 100644 index 00000000..4e460bf5 --- /dev/null +++ b/test/host/net_select/stub/task.h @@ -0,0 +1,11 @@ +/* + * 模块名称:主机侧单测桩 - task.h(net_select 模块专用) + * 模块功能:空壳;net_select.c 不调任务 API。 + * ⚠ 仅供 test/host/net_select/ 使用,不参与 MCU 构建。 + * 创建日期:2026-10-23 + */ + +#ifndef TASK_H +#define TASK_H + +#endif /* TASK_H */ From 5224f21622d94826fad7b2672352bf1b17e57d00 Mon Sep 17 00:00:00 2001 From: Wanderingss <1624155937@qq.com> Date: Fri, 23 Oct 2026 20:23:22 +0800 Subject: [PATCH 07/15] =?UTF-8?q?fix(NET,lftpd):=20=E5=A4=84=E7=BD=AE?= =?UTF-8?q?=E8=A6=86=E7=9B=96=E7=8E=87=E7=85=A7=E5=87=BA=E7=9A=84=E4=B8=89?= =?UTF-8?q?=E5=A4=84=E6=AD=BB=E8=B7=AF=E5=BE=84=EF=BC=88=E6=94=B9=E5=90=8D?= =?UTF-8?q?=20+=20=E4=BF=AE=20timeout=3D0=20+=20=E6=BF=80=E6=B4=BB?= =?UTF-8?q?=E5=BC=82=E5=B8=B8=E9=9B=86=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三处都由本轮 P1 新增的主机单测照出,用户逐条裁定后实施。共同性质:都不是改动引入 的回归,而是**从没被执行到的分支**——生产路径上要么没有调用者、要么没走边界,所以 编译器(0 错 0 警)、cppcheck、真机日志对它都无话可说。 1) 缺陷一(选 a:改名,行为零变化) lftpd_string_trim 的尾部循环起点落在 strlen(p) 那一格 NUL,isspace(0) 为假 → 循环体一次都不执行,"尾部空白从未被裁",而名字承诺两头都去。改名 lftpd_string_ltrim + 删掉那条死循环 + 在 .h/.c 写明"只去前导"。不去尾才是对的: RFC 959 以命令终止符 CRLF 界定命令,参数(PASS 口令、含空格的文件名)的尾随 空格属于参数本体,去掉等于静默改数据;CRLF 已由 lftpd_inet_read_line 截断。 调用点 lftpd.c 同步改名。全库无遗留真引用(余 6 处旧名均为解释改名历史的散文)。 2) 缺陷二(按推荐修) net_select 的 timeout = {0,0} 换算出 timeout_ms = 0,而退出条件写着 if ((timeout_ms > 0) && (已用时 >= timeout_ms)) → 超时永不生效,退化成死等; POSIX 语义是"只查一轮立即返回"。去掉 timeout_ms > 0 这个错误前置即可。就绪 检查在本判断之前,故有就绪时仍立刻带计数返回;同文件 net_poll_events 的 timeout >= 0 本来就对,现在两条对齐。风险面:拿它当"非阻塞查一遍"会让 netTask 回不到主循环,而 netTask 在 IWDG required_mask 里(串行 socket 栈唯一执行者)。 3) 缺陷三(选 ①:按 POSIX 置位) exceptfds 是恒为空的死参数:循环开头 p_sock == NULL → continue 先跳过,而 check_socket_error() 只有"表项为空 / fd 越界"才返回 1,越界的 i 又不在本循环 范围内 → 调用方按 POSIX 直觉用它收异常,永远什么都收不到,且返回码不报错。 把异常判定提到 continue 之前:表项不存在的 fd 计入 exceptfds 与返回计数,并更新 函数注释。选 ① 会改变返回计数语义,故补一条"未请求异常集时不计数"的用例, 确保只查可读的老调用方不被误伤(TC-NETSEL-012b)。 测试同步翻转:原 TC-NETSEL-008 / TC-NETSEL-012 是"现状桩、改了会响",现在改成正向 契约,并新增 TC-NETSEL-012c(异常位只落到被请求的那个集合);TC-LFTSTR-005 从 "尾部不裁"的现状登记改为正向契约(尾随空格必须保留)。net_select 22/22、 lftpd_io 33/33 全绿。 @build:0 错误 0 警告。实测体积 ER_IROM1 232,512 B(快照 232,536 → **−24 B**)、 Flash 233,704 B(−20 B)、RW_IRAM1 75,272 B(+8 B 是上一提交 net_cfg 那个字节, 本批零新增)、**RW_CCM 57,376 B 不变**;四档百分比 22.2/57.4/87.9/22.3 全不变。 Code 减少 36 B 即删掉的死循环。 覆盖率台账:门禁如实报了一次 lftpd_string.c 已覆盖行 8 → 7(**代码少了一行**,不是 覆盖退化),已 --update-baseline 重登;现 22/22 套件、line=3003/3446(87.1%)、 func=236/259(91.1%)、tested_files=26、untested_files=118。 文档:主机单测覆盖率口径 §9 新增"三处死路径的处置"小节(裁定、改法、影响、钉住 它的用例一一对应),并把两处"等裁定"的登记改为已处置。 自查:coding-standard / test-cppcheck / id-check --strict(无台账外新账)/ doc-links (35 处 = 存量)全过;八个改动文件与 HEAD 逐项比对 BOM/CR/U+FFFD/行尾无变化 (这几个文件本就不带 BOM,未被动过)。pre-commit --all-files 仍只有三条失败且与 本分支无关:mem-numbers / mem-budget(.map 与 docs/内存管理 受管快照漂移,增量见 上,按并行分支约定不在分支里 --write,等 Lead 合并后统一回填)与 ui-golden (simulator/tests/ref/*.bin 在 .gitignore 内,新建 worktree 天生没有黄金参考)。 --- Drivers/BSP/NET/lftpd/lftpd.c | 2 +- Drivers/BSP/NET/lftpd/lftpd_string.c | 10 ++-- Drivers/BSP/NET/lftpd/private/lftpd_string.h | 14 ++++- Drivers/BSP/NET/net_select.c | 20 +++++-- docs/测试说明/主机单测覆盖率口径.md | 35 +++++++++++- scripts/test_coverage_baseline.json | 24 ++++----- test/host/lftpd_io/lftpd_io_test.c | 20 ++++--- test/host/net_select/net_select_test.c | 57 +++++++++++--------- 8 files changed, 126 insertions(+), 56 deletions(-) diff --git a/Drivers/BSP/NET/lftpd/lftpd.c b/Drivers/BSP/NET/lftpd/lftpd.c index 02124f0b..ffec9da4 100644 --- a/Drivers/BSP/NET/lftpd/lftpd.c +++ b/Drivers/BSP/NET/lftpd/lftpd.c @@ -777,7 +777,7 @@ static int handle_control_channel(lftpd_client_t *client) if (index < (int)strlen(read_buffer)) { strncpy(arg_buf, read_buffer + index + 1, sizeof(arg_buf) - 1); arg_buf[sizeof(arg_buf) - 1] = '\0'; - arg = lftpd_string_trim(arg_buf); + arg = lftpd_string_ltrim(arg_buf); } lftpd_log_info("CMD %s %s", command_tmp, arg ? arg : ""); diff --git a/Drivers/BSP/NET/lftpd/lftpd_string.c b/Drivers/BSP/NET/lftpd/lftpd_string.c index 30cd1f61..61e96bd5 100644 --- a/Drivers/BSP/NET/lftpd/lftpd_string.c +++ b/Drivers/BSP/NET/lftpd/lftpd_string.c @@ -3,14 +3,16 @@ #include "private/lftpd_string.h" -char *lftpd_string_trim(char *s) +char *lftpd_string_ltrim(char *s) { char *p = s; + /* 只去行首空白:返回跳过后的指针,不搬移缓冲。 + * 原先此处的"去尾部空白"循环,起点是 strlen(p) 那一格 NUL,isspace('\0') 为假 + * → 从未执行过;而 FTP 参数的尾随空格属于参数本体(见本文件头与 + * private/lftpd_string.h 的说明),2026-10-23 删掉这条死代码并改名为 *_ltrim, + * 让函数名与实际行为一致(**行为零变化**)。 */ for (int i = 0, len = (int)strlen(s); i < len && isspace((int)s[i]); i++) { p++; } - for (int i = (int)strlen(p); i >= 0 && isspace((int)p[i]); i--) { - p[i] = '\0'; - } return p; } diff --git a/Drivers/BSP/NET/lftpd/private/lftpd_string.h b/Drivers/BSP/NET/lftpd/private/lftpd_string.h index 7cdeaaa4..31743f43 100644 --- a/Drivers/BSP/NET/lftpd/private/lftpd_string.h +++ b/Drivers/BSP/NET/lftpd/private/lftpd_string.h @@ -2,4 +2,16 @@ #define CRLF "\r\n" -char* lftpd_string_trim(char* s); +/* + * 函数功能:去掉字符串**行首**空白(空格/制表/CR/LF),返回跳过空白后的指针 + * 入口参数:s - 待处理串(不搬移内容,只挪返回指针) char* + * 返回值:首个非空白字符的指针(全空白则指向结尾 NUL) char* + * 限定条件:s 非空且以 NUL 结尾;调用方必须使用返回值,不能继续用原指针 + * 函数说明:**只去前导、不去尾部**,2026-10-23 由 lftpd_string_trim 改名为 + * lftpd_string_ltrim。原名让调用方以为两头都去,而实现里尾部那条循环 + * 起点落在 NUL(isspace('\0') 为假)一次都没执行过 —— 潜伏陷阱。 + * 不去尾才是对的:RFC 959 以命令终止符 CRLF 界定命令,参数(PASS 的 + * 口令、含空格的文件名)的尾随空格属于参数本体,去掉等于静默改数据; + * CRLF 已由 lftpd_inet_read_line 截断,无需此处再处理。 + */ +char* lftpd_string_ltrim(char* s); diff --git a/Drivers/BSP/NET/net_select.c b/Drivers/BSP/NET/net_select.c index 6cb9bf64..5677e288 100644 --- a/Drivers/BSP/NET/net_select.c +++ b/Drivers/BSP/NET/net_select.c @@ -37,7 +37,8 @@ static int check_socket_error(int sockfd); * 入口参数:nfds - 最大文件描述符数 +1 int > 0 * readfds - 读事件集合指针 net_fd_set_t *(可为 NULL) * writefds - 写事件集合指针 net_fd_set_t *(可为 NULL) - * exceptfds- 异常事件集合指针 net_fd_set_t *(可为 NULL) + * exceptfds- 异常事件集合指针(表项不存在的 fd 计入异常并计入返回计数; + * timeout 传 {0,0} 表示"只查一轮立即返回") net_fd_set_t *(可为 NULL) * timeout - 超时时间结构体指针 net_timeval_t *(NULL=无限等待) * 出口参数:readfds/writefds/exceptfds - 仅保留就绪的 Socket 位 net_fd_set_t * * 返回值:就绪的 Socket 数量(>= 0);-1 表示参数无效 int @@ -81,6 +82,14 @@ int net_select(int nfds, net_fd_set_t *readfds, net_fd_set_t *writefds, const net_sock_t *p_sock = net_get_sock(i); if (p_sock == NULL) { + /* 表项不存在就是异常,必须在 continue **之前**判:原实现先跳过,而 + * check_socket_error() 只有"表项为空 / fd 越界"才返回 1,越界的 i + * 又不在本循环范围内 → exceptfds 永远是空的死参数(调用方按 POSIX + * 直觉用它,就永远什么都收不到)。2026-10-23 修掉。 */ + if ((exceptfds != NULL) && NET_FD_ISSET(i, exceptfds)) { + NET_FD_SET(i, &result_except); + ready_count++; + } continue; } @@ -116,9 +125,12 @@ int net_select(int nfds, net_fd_set_t *readfds, net_fd_set_t *writefds, /* 超时检测 */ if (timeout != NULL) { - if ((timeout_ms > 0) && - ((HAL_GetTick() - start_tick) >= timeout_ms)) { - break; /* 超时返回 0 */ + /* 不能再加 (timeout_ms > 0) 前置:timeout = {0, 0} 换算出 0,那是 + * POSIX 的"轮询一次立即返回";加上它 = 超时永不生效,调用方(netTask + * 在 IWDG required_mask 里)再也回不到主循环。就绪检查在本判断之前, + * 所以有就绪时依旧立刻带计数返回,2026-10-23 由主机单测暴露并修掉。 */ + if ((HAL_GetTick() - start_tick) >= timeout_ms) { + break; /* 超时返回 0(timeout_ms == 0 即"只查一轮") */ } } diff --git a/docs/测试说明/主机单测覆盖率口径.md b/docs/测试说明/主机单测覆盖率口径.md index df4c0ba1..a38e92a1 100644 --- a/docs/测试说明/主机单测覆盖率口径.md +++ b/docs/测试说明/主机单测覆盖率口径.md @@ -249,13 +249,46 @@ ⚠ 总体 `line_rate` 88.2% → **87.2%**:**这正是 §5 说的摊薄,不是回退**——`lftpd_io.c` 只测了 一半就进分母,分母涨得比分子快;要盯的是 `files_tested`/`files_untested` 两个数。 -本批又照出一处(**语义未改,等裁定**):`lftpd_string_trim` 的尾部循环写成 +**(同日已处置,见下方"三处死路径的处置")**原照出处:`lftpd_string_trim` 的尾部循环写成 `for (i = strlen(p); i >= 0 && isspace(p[i]); i--)`,i 起步那格是 `'\0'`、`isspace(0)` 为假 → 循环一次都不执行,**尾部空白/CRLF 从未被裁掉**。今天不发作是因为 CRLF 在 `lftpd_inet_read_line` 里已用 `strstr("\r\n")` 截断、唯一调用点 `lftpd.c` 只用返回指针去行首; 风险在"以后有人拿它去尾部空白"。修它一行即可,但会改变 FTP 参数(含空格的文件名/ 口令)的可见行为,需与 FTP 域一起过 → 现在只用 TC-LFTSTR-005 把现状钉成"改了会响"。 +### 三处死路径的处置(2026-10-23,用户逐条裁定后实施) + +三处都不是改动引入的回归,而是**从没被执行到的分支**:生产路径上要么没有调用者、 +要么调用者没走边界,所以编译器(0 错 0 警)、`cppcheck`、真机日志对它都无话可说。 +覆盖率把"进没进过"变成可见数字之后,我为了写用例逐行读了它们的判定条件才撞见。 + +| 缺陷 | 裁定 | 改法与影响 | 现在钉住它的用例 | +|---|---|---|---| +| `lftpd_string_trim` 尾部循环起点落在 NUL,`isspace(0)` 为假 → **尾部从不被裁** + (名字却承诺两头都去) | **选 (a) 改名** | 改名 `lftpd_string_ltrim` + 删掉那条死循环 + + 在 `.h`/`.c` 写明"只去前导"。**行为零变化**(那条循环本来就不执行);不去尾才是对的: + RFC 959 以 CRLF 界定命令,参数(`PASS` 口令、含空格文件名)的尾随空格属于参数本体, + 去掉等于静默改数据。`lftpd.c` 调用点同步改名 | TC-LFTSTR-001..005(005 现在是正向契约: + 尾随空格必须保留) | +| `net_select` 的 `timeout = {0,0}` 换算出 `timeout_ms = 0`,而退出条件写着 + `if ((timeout_ms > 0) && (已用时 >= timeout_ms)))` → **超时永不生效**,退化成死等 + (POSIX 是"只查一轮立即返回") | **按推荐修** | 去掉 `timeout_ms > 0` 这个错误前置, + 变成 `if (已用时 >= timeout_ms) break;`。就绪检查在它之前,故有就绪时仍立刻带计数返回; + 同文件 `net_poll_events` 的 `timeout >= 0` 本来就是对的,现在两条对齐。风险面: + 拿它当"非阻塞查一遍"会让 netTask 回不到主循环,而 netTask 在 IWDG `required_mask` 里 | + TC-NETSEL-008(已翻成正向断言:`rc==0` 且一轮都不睡) | +| `exceptfds` 是恒为空的死参数:循环开头 `p_sock == NULL → continue` 先跳过,而 + `check_socket_error` 只有"表项为空/越界"才为真,越界的 `i` 又不在循环范围内 | + **选 ①**(按 POSIX 置位) | 把异常判定提到 `continue` **之前**:表项不存在的 fd 计入 + `exceptfds` 并计入返回计数;同时更新函数注释。选 ① 会改变返回计数语义(缺失 fd 现在 + 算就绪),因此补一条"未请求异常集时不计数"的用例,确保只查可读的老调用方不被误伤 | + TC-NETSEL-012 / 012b / 012c(新增) | + +**处置后的账**:`@build` 0 错误 0 警告,`Code` **−36 B**(删掉的死循环), +`RW-data`/`ZI-data` 不变 → **RAM 零变化**。`lftpd_string.c` 的已覆盖行 8 → 7 +被门禁判了一次 `REGRESS`——那是台账在如实反映"代码少了一行",不是覆盖退化, +已按 `--update-baseline` 重登(这条恰好反证门禁有效:删代码想悄悄过去,它会拦)。 +两处"现状桩"翻成正向契约,另新增 `TC-NETSEL-012c`;全量 22 套件仍然全绿。 ### P2|值得但工程量大(5 个 `.c` = 2,226 行) 1. **`net_socket.c`(1,251 行 / 51 个函数,全仓最大的逻辑块)** —— 必须**分片**,别一次吞。 diff --git a/scripts/test_coverage_baseline.json b/scripts/test_coverage_baseline.json index adfe1ae4..a6f7d3b9 100644 --- a/scripts/test_coverage_baseline.json +++ b/scripts/test_coverage_baseline.json @@ -180,17 +180,17 @@ "func_rate": 1.0, "funcs_covered": 1, "funcs_valid": 1, - "line_rate": 0.8, - "lines_covered": 8, - "lines_valid": 10 + "line_rate": 1.0, + "lines_covered": 7, + "lines_valid": 7 }, "Drivers/BSP/NET/net_select.c": { "func_rate": 1.0, "funcs_covered": 5, "funcs_valid": 5, - "line_rate": 0.8509, - "lines_covered": 97, - "lines_valid": 114 + "line_rate": 0.8547, + "lines_covered": 100, + "lines_valid": 117 }, "Drivers/BSP/NETCFG/ftp_cfg.c": { "func_rate": 1.0, @@ -273,9 +273,9 @@ "lftpd_io": { "files": { "Drivers/BSP/NET/lftpd/lftpd_io.c": 159, - "Drivers/BSP/NET/lftpd/lftpd_string.c": 10 + "Drivers/BSP/NET/lftpd/lftpd_string.c": 7 }, - "lines_total": 169, + "lines_total": 166, "ok": true }, "nand_port": { @@ -294,9 +294,9 @@ }, "net_select": { "files": { - "Drivers/BSP/NET/net_select.c": 114 + "Drivers/BSP/NET/net_select.c": 117 }, - "lines_total": 114, + "lines_total": 117, "ok": true }, "phasor": { @@ -421,8 +421,8 @@ "func_rate": 0.9112, "funcs_covered": 236, "funcs_valid": 259, - "line_rate": 0.8709, - "lines_covered": 3001, + "line_rate": 0.8714, + "lines_covered": 3003, "lines_valid": 3446, "modules_ok": 22, "modules_total": 22 diff --git a/test/host/lftpd_io/lftpd_io_test.c b/test/host/lftpd_io/lftpd_io_test.c index ed464ce0..40d3c8ba 100644 --- a/test/host/lftpd_io/lftpd_io_test.c +++ b/test/host/lftpd_io/lftpd_io_test.c @@ -66,7 +66,7 @@ * TC-LFTSTR-004 全空白串 → 空串 * TC-LFTSTR-005 **现状桩**:尾部空白/CRLF 实际不裁(见下"本批照出的问题") * - * 本批照出的问题(**未改语义**,等裁定):lftpd_string_trim 的尾部循环写成 + * **处置(2026-10-23 用户裁定 = 改名)**:lftpd_string_trim 的尾部循环写成 * for (i = strlen(p); i >= 0 && isspace(p[i]); i--) —— i 起步那格是 '\0', * isspace(0) 为假,循环一次都不执行,**尾部空白从未被裁掉**。今天不发作是因为 * FTP 命令行的 CRLF 在 lftpd_inet_read_line 里已用 strstr("\r\n") 截断,而唯一 @@ -379,35 +379,39 @@ static void tc_trim(void) char buf[32]; char *r; - printf("-- TC-LFTSTR-001..005 lftpd_string_trim --\n"); + printf("-- TC-LFTSTR-001..005 lftpd_string_ltrim --\n"); /* 实现是"返回一个跳过行首空白的指针",**不搬移缓冲**;调用方 lftpd.c:780 * 用的正是返回值。所以断言必须落在返回值上,落在 buf 上是错的。 */ (void)strcpy(buf, " PASS x"); - r = lftpd_string_trim(buf); + r = lftpd_string_ltrim(buf); CHECK((r != buf) && (strcmp(r, "PASS x") == 0) && (buf[0] == ' '), "TC-LFTSTR-001 行首空白靠返回指针跳过,原缓冲不搬移"); (void)strcpy(buf, "NOOP"); - r = lftpd_string_trim(buf); + r = lftpd_string_ltrim(buf); CHECK((r == buf) && (strcmp(buf, "NOOP") == 0), "TC-LFTSTR-002 无前导空白返回同一指针"); (void)strcpy(buf, " USER admin "); - r = lftpd_string_trim(buf); + r = lftpd_string_ltrim(buf); CHECK(strcmp(r, "USER admin ") == 0, "TC-LFTSTR-003 词中间空白保留"); (void)strcpy(buf, " \t\r\n"); - r = lftpd_string_trim(buf); + r = lftpd_string_ltrim(buf); CHECK(r[0] == '\0', "TC-LFTSTR-004 全空白 -> 空串"); /* 现状桩(不是期望行为):尾部循环从 strlen(p) 起步,那里是 '\0', * isspace(0)=0 立刻退出 —— **尾部空白/CRLF 实际从未被裁**。今天无害是因为 * CRLF 在 lftpd_inet_read_line(lftpd_inet.c 的 strstr("\r\n"))里已截掉。 * 谁哪天把 trim 修对了,这条会红——那是提醒,不是回归。 */ + /* 2026-10-23 处置:函数已由 lftpd_string_trim 改名为 lftpd_string_ltrim, + * 并删掉那条永不执行的尾部循环。于是"不去尾部"从潜伏陷阱变成**写进名字与注释 + * 的契约**:RFC 959 里参数的尾随空格属于参数本体(PASS 口令、含空格的文件名), + * 去掉等于静默改数据;CRLF 由 lftpd_inet_read_line 截断。 */ (void)strcpy(buf, "LIST "); - r = lftpd_string_trim(buf); + r = lftpd_string_ltrim(buf); CHECK(strcmp(r, "LIST ") == 0, - "TC-LFTSTR-005 现状:尾部空白不裁(潜伏缺陷已登记,修好它会让本条响)"); + "TC-LFTSTR-005 尾随空格必须保留(ltrim 语义,RFC 959 参数本体)"); } /* ===================== 入口 ===================== */ diff --git a/test/host/net_select/net_select_test.c b/test/host/net_select/net_select_test.c index 55a14f2b..907f2214 100644 --- a/test/host/net_select/net_select_test.c +++ b/test/host/net_select/net_select_test.c @@ -25,14 +25,15 @@ * TC-NETSEL-005 三个集合都可以传 NULL(不崩、不误伤) * TC-NETSEL-006 timeout=NULL 时无限等,靠"对端事件到来"唤醒(假 osDelay 造事件) * TC-NETSEL-007 tv_usec 换算是**整除截断**(1999us → 1ms,不是 2ms) - * TC-NETSEL-008 现状桩:timeout = {0, 0} 不立即返回(POSIX select 会立刻返回 0) + * TC-NETSEL-008 timeout = {0, 0} 只查一轮立即返回(**2026-10-23 修**:原先因 + * `(timeout_ms > 0)` 前置判断而永不过期,等于把"非阻塞探测"变成死等) * TC-NETSEL-009 LISTENING 的 socket 即使 readable=1 也不算可读 * TC-NETSEL-010 ESTABLISHED + send_ready → 可写位置位 * TC-NETSEL-011 非 ESTABLISHED(CONNECTING)即使 send_ready=1 也不可写 - * TC-NETSEL-012 现状桩:exceptfds 是**恒为空的死参数**(表项为空的 fd 先被 continue - * 跳过,异常判定从 select 里到不了);全库唯一调用点 test/board 传 NULL, - * 所以今天无受害者——但谁按 POSIX 直觉用它就会静默收不到异常 - * TC-NETSEL-012b 同一 fd 请求 POLLIN 也不算可读(被跳过,超时到点即退) + * TC-NETSEL-012 表项不存在的 fd 计入异常集合(**2026-10-23 修**:判定原本排在 + * continue 之后,exceptfds 是恒为空的死参数) + * TC-NETSEL-012b 未请求异常集时,缺失的 fd 不计数(不影响只查可读的调用方) + * TC-NETSEL-012c 异常位只落到被请求的那个集合 * TC-NETSEL-013 poll 传 NULL 或 nfds<=0 → -1 * TC-NETSEL-014 poll 非法 fd(负数 / 越界)→ NET_POLLNVAL 且计入 ready * TC-NETSEL-015 poll 对 CLOSED socket 置 NET_POLLHUP @@ -216,20 +217,19 @@ static void tc_timeout(void) CHECK((rc == 0) && (g_delay_calls == 0), "TC-NETSEL-007 tv_usec 整除截断:1999us 当 1ms,首轮即退"); + /* 已修(2026-10-23):原来这里写的是 (timeout_ms > 0) && (已用时 >= timeout_ms), + * timeout = {0,0} 换算出 0 后**永不超时**,只能干等事件——而 POSIX 语义是 + * "只查一轮立即返回"。调用方(netTask 在 IWDG required_mask 里)拿它当 + * 非阻塞探测用就会卡死主循环。现在这条是正向断言。 */ reset_all(); make_sock(0, NET_SOCK_STATE_ESTABLISHED, 0u, 0u); NET_FD_ZERO(&r); NET_FD_SET(0, &r); tv.tv_sec = 0; tv.tv_usec = 0; - g_release_after = 5; /* 造一个事件把自己救出来 */ rc = net_select(1, &r, NULL, NULL, &tv); - /* 现状桩:timeout == {0,0} → 换算出 timeout_ms = 0,而实现里那句是 - * if ((timeout_ms > 0) && (已用时 >= timeout_ms)) break; - * 于是**永远不会因超时退出**,只能等事件——POSIX 的 select 在这里必须立刻返回 0。 - * 谁把那个 >0 修掉,这条就会红(g_delay_calls 变 0、rc 变 0):那是提醒。 */ - CHECK((rc == 1) && (g_delay_calls == 5), - "TC-NETSEL-008 现状:timeout={0,0} 不立即返回(已登记为缺陷)"); + CHECK((rc == 0) && (g_delay_calls == 0) && !NET_FD_ISSET(0, &r), + "TC-NETSEL-008 timeout={0,0} 只查一轮立即返回(与 poll 的 0 对齐)"); } /* ===================== 状态准入 ===================== */ @@ -268,27 +268,34 @@ static void tc_state_admission(void) CHECK((rc == 0) && !NET_FD_ISSET(0, &w), "TC-NETSEL-011 连接中不可写(send_ready 不作数)"); - /* 现状桩(不是期望):net_select 的循环体开头是 - * p_sock = net_get_sock(i); if (p_sock == NULL) continue; - * 于是"表项不存在"的 fd 被整轮跳过,check_socket_error 的 NULL 分支从 select - * 里**永远到不了** → exceptfds 是个恒为空的死参数(谁传了它以为能收到异常, - * 就什么都收不到)。改法要么在 continue 前先查异常、要么在文档里写明不支持。 */ - reset_all(); - g_sock_valid[0] = 0u; - NET_FD_ZERO(&r); - NET_FD_SET(0, &r); + /* 已修(2026-10-23):异常判定原先排在 p_sock == NULL → continue 之后,而 + * check_socket_error() 只有"表项为空 / fd 越界"才返回 1、越界的 i 又不在本循环 + * 范围内 → exceptfds 是恒为空的死参数。现在把判定提到 continue 之前。 */ tv.tv_sec = 0; tv.tv_usec = 1000; - rc = net_select(1, NULL, NULL, &r, &tv); - CHECK((rc == 0) && !NET_FD_ISSET(0, &r), - "TC-NETSEL-012 现状:exceptfds 永不被置位(表项为空先被 continue 跳过)"); reset_all(); g_sock_valid[0] = 0u; NET_FD_ZERO(&r); NET_FD_SET(0, &r); + rc = net_select(1, NULL, NULL, &r, &tv); + CHECK((rc == 1) && NET_FD_ISSET(0, &r), + "TC-NETSEL-012 表项不存在的 fd 计入异常集合"); + reset_all(); + g_sock_valid[0] = 0u; + NET_FD_ZERO(&r); + NET_FD_SET(0, &r); /* 只请求可读 */ rc = net_select(1, &r, NULL, NULL, &tv); CHECK((rc == 0) && !NET_FD_ISSET(0, &r) && (g_delay_calls == 0), - "TC-NETSEL-012b 不存在的 fd 也不算可读(被跳过而不是报错,超时到点即退)"); + "TC-NETSEL-012b 未请求异常集时不因缺失 fd 计数(不误伤只查可读的调用方)"); + reset_all(); + g_sock_valid[0] = 0u; + NET_FD_ZERO(&r); + NET_FD_SET(0, &r); /* 请求可读:不该置位 */ + NET_FD_ZERO(&w); + NET_FD_SET(0, &w); /* 请求异常:应该置位 */ + rc = net_select(1, &r, NULL, &w, &tv); + CHECK((rc == 1) && NET_FD_ISSET(0, &w) && !NET_FD_ISSET(0, &r), + "TC-NETSEL-012c 异常位只落到被请求的那个集合,不污染可读集合"); } /* ===================== poll 语义 ===================== */ From e3072137457587c78aadfbda96df20ae6faff7a3 Mon Sep 17 00:00:00 2001 From: Wanderingss <1624155937@qq.com> Date: Fri, 23 Oct 2026 20:42:55 +0800 Subject: [PATCH 08/15] =?UTF-8?q?docs(=E4=BB=BB=E5=8A=A1=E8=A7=84=E5=88=92?= =?UTF-8?q?):=20=E5=88=A0=E9=99=A4=E6=9F=A5=E6=97=A0=E6=AD=A3=E6=96=87?= =?UTF-8?q?=E7=9A=84=20E-25=20=E4=BA=A4=E5=8F=89=E5=BC=95=E7=94=A8?= =?UTF-8?q?=EF=BC=8Cnet=5Fselect=20=E5=A4=84=E7=BD=AE=E6=94=B9=E7=94=A8?= =?UTF-8?q?=E5=B9=B3=E5=AE=9E=E8=AF=AD=E8=A8=80=E7=95=99=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 起因:上一提交修掉 net_select 两处死路径后,发现排期登记里写着"C1 消化 E-25 (net_select 语义)",想核对 E-25 到底还列了哪些子项,结果查不到条目正文。 调查结论(三条独立证据,不是"我没找到"): 1. scripts/id_registry.json 里 E 系列族 E-AUD:pattern \bE-[0-9]{1,2}\b、 frozen=true、游标 E-63,而 **owner_docs_glob = None** —— E 系列是当年审计报告 的冻结快照号,仓库里从来没有属主登记处; 2. 全库(含代码与文档,排除构建产物)E-25 只有 3 处命中,且全部是"挂 C1"这一句话 的交叉引用,没有任何条目正文、判据或用例; 3. 债务台账 id_known_backlog.json 里 E-25 命中 0 条 —— 它落在族游标 E-63 之内, 被门禁当作"在册号"放行,于是既不报悬空也不报越界。 结论:这是一个**号在册、正文已随当年销项消失、却仍被当作在册依据**的编号,正是 编号规范 §0 与销项纪律里点名的那类漏清引用(同类先例:N-31 因销项删行后引用未清 而被复用撞号)。用户指示:查无相关内容即删除。 处置(E-25 全库归零): - 排期详述.md §1 的 C1 行:编号列改为 E-27 / E-28;描述去掉 E-25,但**不丢信息** —— 改用平实语言写明"net_select 的两处死路径(timeout={0,0} 退化成死等、 exceptfds 恒为空)已于 2026-10-23 由主机单测照出并修掉",并指向 docs/测试说明/主机单测覆盖率口径.md §9 的处置表(裁定、改法、用例都在那里)。 - 任务规划/README.md 两处(§0 冻结号说明、W4 结构债行)同步去掉 E-25。 - 冻结历史快照族本身不动(E-AUD 仍 frozen、游标仍 E-63),只清引用;E-27/E-28 照旧挂在 C1 下继续排期。 门禁:check_ids --strict 通过且**可判定比例 82.6% → 83.12%**(删掉的是"族已登记但 属主登记处不存在"的不可判定引用,分母下降所以比例升,符合只许涨不许跌); check_doc_links --all = 35 处(= 存量基线,本次改的两个文件 0 命中);task-table Passed;两个文件 BOM/CR/行尾与 HEAD 一致。 pre-commit --all-files 仍只有 mem-numbers / mem-budget(.map 与受管快照漂移,等 Lead 合并后统一回填,分支内不 --write)与 ui-golden(ref/*.bin 不入库,新 worktree 天生没有黄金参考)三条失败,与本分支改动无关。 --- docs/任务规划/README.md | 4 ++-- docs/任务规划/排期详述.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/任务规划/README.md b/docs/任务规划/README.md index 04c245db..429aaa66 100644 --- a/docs/任务规划/README.md +++ b/docs/任务规划/README.md @@ -79,7 +79,7 @@ | **数据可信(W8)** | 1 簇 · **14 张卡** | 已知电压注入验证 · 端子变比确认 · 通道标定系数与折算 · 标定落盘 · 未标定即闭锁 · 下游三常数重定 · 量纲回归 · 遥测快照 200 ms · 运行参数段 · 频率点绑定(详表 `W8-数据可信.md`) | > **网络侧 E-xx 存量项**(历史编号族,属主文档已于 2026-10-15 删除,编号冻结见 -> `docs/编号规范.md` §4.3):E-25 / E-27 / E-28 挂 C1(net 层跨上下文收口,见 `排期详述.md` §1); +> `docs/编号规范.md` §4.3):E-27 / E-28 挂 C1(net 层跨上下文收口,见 `排期详述.md` §1); > E-47 待 CH395F 驱动侧定案;E-12 为 `FF_FS_LOCK` / 单写者纪律记录项;E-31 后续与 E-62 > 为回归监督项(纪律已固化于 `AGENTS.md`,无需改动)。 > UI 标准化簇与 lftpd 主机测试缺口为整簇排期项,见 §2 W6(dhara 移植层主机单测已随登记簿 @@ -107,7 +107,7 @@ | **W1 采集+保护收尾** | **里程碑制 + 并行道**:M1 台架闭环 ✅ 达成(B6 ✅→N-33 ✅)∥ M2 功能面(代码道 B7 ✅→{B8∥B9})→ M3 送检(B10,Fixed Date)——任务卡/判据/写范围见 `W1-采集保护收尾.md` | **用户决定(2026-10-12):先把 ADC 采集、保护等相关功能完成**。B4/B5 代码已落地但未台架闭环、功能面不完整(定值不落盘 / 无频率保护 / 无动作记录 / 栈两处 FAIL)——本波收口到"可用 + 可验收";真约束只有**台架**与**送检窗口**两个稀缺资源,其余任务按写范围切分并行 | | **W8 数据可信** | **通道标定 → 遥测链 → 运行参数**:M4 系数与量纲 / M5 遥测链 / M6 运行参数与点表;14 张卡与事实基线见 `W8-数据可信.md` | **用户决定(2026-10-23):下一步做通道标定,先于 UI 页面与结构债**。它是唯一"改一处、全链受益"的入口(`App/task/adc_task.c:335` 是码值→工程量的唯一位置),也是 W6 页面与 104 遥测的共同前置——先做页面则 `ui_data_feed` 组装的码值帧要返工。**编号是分配序,执行序以本表从上到下为准**:本波紧随 W1,排在 W3/W4 之前。⚠ 2026-10-23 补记:ADC 至今**从未采过已知电压**(连极性都没验过,见 `W8-数据可信.md` §2.6),故第一张卡是"已知电压注入验证"——只需一块万用表,不需要信号源/示波器,是开工的第一刀 | | **W3 预算与内存** | ⏸ **整簇暂缓**(用户裁定 2026-10-15):~~缩堆 56→32 KB → R-2 后半(29.5 KB→CCM)→ R-3 / R-4 / R-6 逐项评估~~。本簇已落地的只有 **R-9 ✅(2026-10-15)**;R-5 已判定不可行 | **不再排期**:没有功能在等这块预算,而搬迁要动 scatter、要逐条过 DMA 可达性清单、缩堆还要上板复测。**W3 槽位保留、不重编号**(避免全库改 W4~W7 引用)。重开条件 → `../问题记录/项目审查与优化建议报告.md` §5.3 | -| **W4 结构债** | **C6(S-3)→ C3(N-2)→ C1 net 层跨上下文收口 → C5(含 N-23 残余)→ N-22/N-25/N-28**(详表 `排期详述.md` §1) | 无紧急,按性价比排:C6 灭 IWDG 误复位机制根源;C1 消化 E-25/E-27/E-28(原 ACCEPT+SEND pending 化已收口),体量大、验证重,放后(N-23 钩子已落地 2026-10-12,残余并入 C5)。⚠ 原挂 C3 的 **N-24(日志逐条开关)已于 2026-10-15 单独修复销项**,C3 只剩 N-2 观察项 | +| **W4 结构债** | **C6(S-3)→ C3(N-2)→ C1 net 层跨上下文收口 → C5(含 N-23 残余)→ N-22/N-25/N-28**(详表 `排期详述.md` §1) | 无紧急,按性价比排:C6 灭 IWDG 误复位机制根源;C1 消化 E-27/E-28(原 ACCEPT+SEND pending 化已收口),体量大、验证重,放后(N-23 钩子已落地 2026-10-12,残余并入 C5)。⚠ 原挂 C3 的 **N-24(日志逐条开关)已于 2026-10-15 单独修复销项**,C3 只剩 N-2 观察项 | | **W5 验证常态** | P1-10 收尾 · T-7 CI · D2 N-3 · D5 N-16 · D6 N-17 · D7 串口助手 · S12 · S1-c · A-5(详表 `排期详述.md` §2) | 窗口性,逢机顺带,不单独排期 | | **W6 UI 扩展** | U4 残余(golden + 字符集差集挂门禁)→ U1 令牌层 → U2/U3 → **27 页**落地 | 产品驱动、体量大,需单独立项(顺序按 `docs/问题记录/项目审查与优化建议报告.md` §6.5)。**2026-10-15 用户裁定启动**(覆盖 2026-10-11 的「本轮不排期」):已开智能体团队,第一波 **U1 令牌层 ∥ U4 门禁补强** 并行进行中;U3 为地基串行段(独占注册文件),U2/U5 排在 U3 之后 ⚠ **U5 暂缓(2026-10-20 用户裁定)**:剩余页面**已出规划(`docs/UI设计/页面补齐规划.md`,2026-10-23:27 个空叶子归并为 7 模块 + 2 项前置能力),但仍未排期**,本轮不补页 —— 现存 7 个 `ui_page_*.c` 即当前全部内容,报告"27 页"是目标数不可当差集清单。U1–U4 已全部落地,故 U5 未来开工时是纯增量活(实测新增一页 = 3 处人工 + 1 条生成器命令,且 `ui-golden` 已在提交门禁兜像素)。**2026-10-23 补充**:U5 的 `ui_data` / `ui_data_feed` 需要真数据才有意义,故 **U5 最早在 W8 的 M4 之后开工**(依据见 `W8-数据可信.md` §10) | | **W7 仓库决策** | **N-29** `.git` **235 MB** + 图片 36.3 MB | **已决策(2026-10-11):暂不处理、登记观察**——推送/协作前重评(`git filter-repo` 历史重写影响未推送提交与协作者) | diff --git a/docs/任务规划/排期详述.md b/docs/任务规划/排期详述.md index 9651791a..e3c3247a 100644 --- a/docs/任务规划/排期详述.md +++ b/docs/任务规划/排期详述.md @@ -11,7 +11,7 @@ |---|---|---|---| | **C6** | **USART1 TX-only 结构根治(S-3)** | 灭 ORE 僵尸机制根源(2026-10-02 台架 IWDG 误复位的机制根源):改 `.ioc` 令 USART1 不使能 RX / PA10 不复用为 `USART1_RX` → CubeMX 重生成 → 跑 `check_cubemx_survival.py`。结构性改动,需单开一轮 + 复跑台架闭环 | S-3 | | **C3** | **存储侧日志管线(N-2)** | 丢弃可观测性**代码已具备**(`console_log.c` `s_con_drop`+`[ERR] CONSOLE dropped`、`sys_log_mcu.c` 队列满 `DBG_ERROR`、TX 僵尸通道自愈);V5 30 min 日志**未复现 49s 静默**(运行期 >10s 静默全为 ~28s、均在 `e02_probe` 窗口内=N-17)。剩余:与 S8(非阻塞日志)合并评估。⚠ 原挂本卡的 **N-24**(日志逐条 `f_open/f_write/f_close`)已于 2026-10-15 修复销项(写侧一次排空合并成每文件一次开关、读侧 8 条滑动窗口)→ 实施记录 `../子系统设计/运行与自检记录设计说明.md` §4.4 / §4.5 | N-2 | -| **C1** | **net 层跨上下文收口** | 消化 E-25(`net_select` 语义)、E-27/E-28(裸指针收口为状态查询 API)。原 ACCEPT + SEND pending 化已收口:SEND 2026-10-14 落地、ACCEPT 决定不实施(2026-10-15) | E-25 / E-27 / E-28 | +| **C1** | **net 层跨上下文收口** | 消化 E-27/E-28(裸指针收口为状态查询 API)。原 ACCEPT + SEND pending 化已收口:SEND 2026-10-14 落地、ACCEPT 决定不实施(2026-10-15)。**另:`net_select` 的两处死路径(`timeout={0,0}` 退化成死等、`exceptfds` 恒为空)已于 2026-10-23 由主机单测照出并修掉**,判据与用例见 `docs/测试说明/主机单测覆盖率口径.md` §9 | E-27 / E-28 | | **C5** | 一档剩余 | **N-23 残余**(`KNOWN_GAPS` 白名单清理,见 `登记簿.md`);~~`app_cfg.h` 未接线开关~~ ✅ **已闭环**(2026-10-22 删除零消费点占位宏,报告 S-4)。**RS-485 DE 收尾已移入 `暂缓-RS485.md`** | 一档/二档 | | **N-22/25/28** | 评估簇 | 写回 flush / 性能与语义一致性 / Flash 死代码——逐项见 `登记簿.md` 对应行,随窗口评估 | 报告各条 | From fa4cde36f6d5dc19ee8e9db4f64b63ec37e59180 Mon Sep 17 00:00:00 2001 From: Wanderingss <1624155937@qq.com> Date: Fri, 23 Oct 2026 20:47:22 +0800 Subject: [PATCH 09/15] =?UTF-8?q?docs(=E8=A6=86=E7=9B=96=E7=8E=87):=20?= =?UTF-8?q?=C2=A79=20=E6=96=B0=E5=A2=9E"=E5=81=87=20CH395F=20=E8=8A=AF?= =?UTF-8?q?=E7=89=87"=E5=AE=9E=E6=96=BD=E8=A7=84=E6=A0=BC=EF=BC=8C?= =?UTF-8?q?=E5=B9=B6=E6=9B=B4=E6=AD=A3=E4=B8=80=E9=A1=B9=E8=A2=AB=E6=88=91?= =?UTF-8?q?=E9=AB=98=E4=BC=B0=E7=9A=84=E5=89=8D=E7=BD=AE=E6=88=90=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 立项假 CH395F:NET 域剩下三块(net_pending 287 / net_events 263 / net_listener 191 = 741 行)都把芯片当真理读,这些语义真机上只在总线上一闪而过、台架不可复现;做完它 连 P2 的 net_socket.c(1,251 行,全仓最大逻辑块)一起解锁。 **一处更正**:我上一轮(提交 8bf4273 的正文与对话汇报)说这三块"还调 net_socket.c 的 内部函数(net_drain_chip_rx / net_relisten_done / net_relisten_locked),要一并给假件"。 实测这三个名字全是 **net_listener.c 文件内的 static**(net_listener.c:86 / :114 / :273), 不存在跨 TU 引用 → 该项前置成本**不成立**,工作量比我说的小。三块之间只走公开 API (net_listener_request/settle、net_pending_*、net_get_sock/net_sock_raw 等)。 规格内容(全部从真实头逐条核出,不凭记忆): - 15 个芯片函数的真实原型,假件必须一字不改照抄;特别标出三种形态: ch395f_get_socket_status / ch395f_get_remot_ipp 是**出参**、 ch395f_sock_state_cached 返回 **ch395f_sock_state_t 枚举**(不是 uint8_t,虽然底层 整型兼容,照抄才会在原型漂移时编译期报错)。 - 假芯片必须复刻的两条芯片怪癖:① get_sock_int_status **整体读即清**、芯片无按位 清除手段 —— 假件要按"返回该 socket 挂起位图并同步清空、可逐 socket 预置"实现, 否则照不出 AGENTS 模块约定里 B3 的实证坑("顺手全量清一遍"把别的在用 Socket 的 门焊死);② sock_state_cached / link_up_cached 是**缓存值**而非现场值,要把"缓存 在第几次调用后更新"做成可脚本化,才能测对账循环读到陈旧状态的路径。 - 脚本化模型沿用本仓既有假件风格(nand_port 的真头+假 diskio、lftpd_io 的可注入假 堆):读函数一条按调用序号取的返回队列、写函数一条调用记录、外加每函数调用计数 以断言"这一轮不该碰芯片"。 - 桩放置点需要拍板,已给推荐:test/host/common/ch395f_fake.h/.c 三模块共享,理由是 三份各自漂移会让"假件与被测代码同时错"这类问题再也报不出来;代价是不进 MCU 构建、 要靠 test-cppcheck 覆盖。若沿用每模块自带桩的惯例,则三份必须 include 同一份头锁签名。 - 实施顺序 net_listener → net_pending → net_events,逐个写明各自要桩什么、判据抄哪份 文档(网络自愈架构设计 §4.10 的读清点与三条准入判据),以及每步的验收动作 (@build 0/0、套件全绿、--update-baseline、先补 TC-HOST-* 族再往文档里引号)。 本轮**只动这一篇文档**(零代码改动,故不跑 @build);check_doc_links --all = 35 处 (= 存量基线,本分支贡献 0);文件 BOM/CR/行尾与 HEAD 一致。 另:本会话上下文见底,故把这块以规格形式交付而不是留下编译不过的半成品 —— 规格里的 原型、static 归属、怪癖与顺序都已核实,下一会话可直接从"写 test/host/common/ ch395f_fake.h"开始。 --- docs/测试说明/主机单测覆盖率口径.md | 65 +++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/docs/测试说明/主机单测覆盖率口径.md b/docs/测试说明/主机单测覆盖率口径.md index a38e92a1..df99e751 100644 --- a/docs/测试说明/主机单测覆盖率口径.md +++ b/docs/测试说明/主机单测覆盖率口径.md @@ -289,6 +289,71 @@ 被门禁判了一次 `REGRESS`——那是台账在如实反映"代码少了一行",不是覆盖退化, 已按 `--update-baseline` 重登(这条恰好反证门禁有效:删代码想悄悄过去,它会拦)。 两处"现状桩"翻成正向契约,另新增 `TC-NETSEL-012c`;全量 22 套件仍然全绿。 +### 假 CH395F 芯片:实施规格(2026-10-23 立项,交给下一会话执行) + +**为什么要它**:@@NET@@ 域剩下的三块(@@net_pending.c@@ 287、@@net_events.c@@ 263、@@net_listener.c@@ 191 = 741 行) +全都把芯片当真理读,真机上这些语义只在总线上一闪而过、台架不可复现。做完它,P2 的 +@@net_socket.c@@(1,251 行,全仓最大逻辑块)也一起解锁。 + +**真实原型(@@Drivers/BSP/CH395F/ch395f.h@@ 逐条核出,假件必须一字不改地照抄签名)**: + +```cpp +uint8_t ch395f_sock_state_cached(uint8_t sn); /* 返回 ch395f_sock_state_t 枚举值 */ +void ch395f_get_socket_status(uint8_t sock, uint8_t *status); /* 出参形态 */ +uint8_t ch395f_get_sock_int_status(uint8_t sock); /* ⚠ 整体读即清,见下 */ +uint16_t ch395f_get_recv_len(uint8_t sock); +void ch395f_read_recv_buf(uint8_t sock, uint8_t *p_data, uint16_t len); +void ch395f_write_send_buf(uint8_t sock, uint8_t *p_data, uint16_t len); +uint8_t ch395f_open_socket(uint8_t sock); +uint8_t ch395f_close_socket(uint8_t sock); +uint8_t ch395f_tcp_listen(uint8_t sock); +void ch395f_set_proto_type(uint8_t sock, uint8_t proto); +void ch395f_set_sour_port(uint8_t sock, uint16_t port); +void ch395f_get_remot_ipp(uint8_t sock, uint8_t *p_buf); /* 出参形态 */ +void ch395f_set_keepalive_enable(uint8_t sock, uint8_t enable); +uint8_t ch395f_link_up_cached(void); +uint16_t ch395f_link_change_count(void); +``` + +**假芯片必须复刻的两条芯片怪癖**(不复刻就等于把被测代码的坑假件掉): +1. @@get_sock_int_status@@ 是**整体读即清**、芯片没有按位清除手段。假件要按"每次调用 + 返回该 socket 的挂起位图并**同时清空**"实现,并允许逐 socket 独立预置——否则 + 照不出 AGENTS「模块约定」里 B3 那条实证坑:*"顺手全量清一遍"等于把别的在用 + Socket 的门焊死*。 +2. 状态是**缓存值**而非现场值(@@sock_state_cached@@/@@link_up_cached@@):假件要把 + "缓存更新发生在哪一次调用之后"做成可脚本化,才能测出对账循环读到陈旧状态的路径。 + +**脚本化模型**(沿用本仓既有假件风格:@@nand_port@@ 的真头+假 diskio、@@lftpd_io@@ 的可注入假堆): +每个读函数一条**按调用序号取的返回队列**(耗尽后回落到最后一值),每个写函数一条 +**调用记录**(次数 + 最近一次入参),另给 @@g_chip_call_count[函数名]@@ 让"这一轮不该碰芯片" +的契约可断言;@@reset@@ 一次全清。所有假件写在测试单元 @@.c@@ 里,**不建共享 @@.so@@/库**。 + +**桩放哪里(一个需要拍板的点,先给推荐)**:三个模块都要用同一份假芯片,@@test/host/*/stub/@@ +每模块一份会漂移(三处签名各改一半 = 假件与被测代码同时错还不报错)。推荐 +@@test/host/common/ch395f_fake.h + .c@@(与 @@unit_test_harness.py@@ 同级,三模块的 +@@include_dirs@@ 各加这一条),代价是它不进 MCU 构建、要靠 @@test-cppcheck@@ 覆盖。 +若沿用"每模块自带桩"的现有惯例,则必须让三份桩都 @@#include@@ 同一份头以锁死签名。 + +**三个文件的真实依赖面(逐个核出,别按猜的排工作量)**: +- @@net_listener.c@@(191 行)—— **最便宜,先做它**:@@net_drain_chip_rx@@/@@net_relisten_locked@@/ + @@net_relisten_done@@ 全是**本文件内的 static**(2026-10-23 实测确认),所以它只需要 + 芯片假件 + @@net_get_sock@@ + @@net_pending_active@@ + @@HAL_GetTick@@ + @@dbg_log@@, + 外加 @@test_config.h@@/@@net_latency_test.h@@ 两个可空壳的头。**不需要假造 net_socket.c 内部函数** + (我上一轮把它当成前置成本,是错的,特此更正)。判据现成:单连接 socket 复用、 + 按网络代次重建监听、@@net_listener_armed@@ 门控(@@docs/网络/网络自愈架构设计.md@@ §4.10)。 +- @@net_pending.c@@(287 行)—— 要 @@net_listener_request@@(可用一个只记录调用的假件), + 契约是挂起 recv 的注册/超时/取消与"写失败回置"那族;@@tick@@ 收 @@net_sock_t *@@ 表参数 + (@@net_get_sock@@ 对未用槽位返 NULL,tick 需读 @@in_use == 0@@ 兜底 —— AGENTS 已记)。 +- @@net_events.c@@(263 行)—— 依赖最多(@@net_sock_raw@@/@@net_listener_settle@@/ + @@net_pending_cancel@@/@@net_send_pending_cancel@@/@@xTaskGetTickCount@@),**放最后**。 + 注意 @@net_sock_raw@@ 是刻意不给应用层用的现场指针(头注释写明"仅供 net_events.c 的 + 对账循环使用"),假件要保留"对未在用槽位也能拿到项"的行为,否则对账兜底路径测不到。 + +**验收顺序**:@@net_listener@@ 绿 → 立刻登记基线(哪怕只有它一个)→ @@net_pending@@ → @@net_events@@; +每步都要 ```build 0/0 + 21+ 套件全绿 + --update-baseline + 补建 @@TC-HOST-*@@ 族@@(族数现在 16, +三块做完应为 19),并且**先补族再往文档里引这些号**,否则 @@id-check@@ 会按台账外新账卡提交 +(本会话已被 @@TC-NETCFG-028@@ 实测拦过一次)。 + ### P2|值得但工程量大(5 个 `.c` = 2,226 行) 1. **`net_socket.c`(1,251 行 / 51 个函数,全仓最大的逻辑块)** —— 必须**分片**,别一次吞。 From 0a3b9323950ebd8cf67029e226fe4cd1e2afc934 Mon Sep 17 00:00:00 2001 From: Wanderingss <1624155937@qq.com> Date: Fri, 23 Oct 2026 21:39:05 +0800 Subject: [PATCH 10/15] =?UTF-8?q?test(NET):=20=E5=81=87=20CH395F=20?= =?UTF-8?q?=E8=8A=AF=E7=89=87=E8=90=BD=E5=9C=B0=20+=20net=5Flistener=20?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=EF=BC=8834=20=E6=9D=A1=EF=BC=8C=E8=A6=86?= =?UTF-8?q?=E7=9B=96=2099.2%=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 §9 规格做假芯片的第一批消费者。新增 test/host/common/ch395f_fake.{h,c}:15 个 函数的**真实原型逐字照抄**(含 ch395f_sock_state_t{open,tcp_state} 与 ERR_SUCCESS/ PROTO_TYPE_TCP/SOCKET_CLOSED/TCP_CLOSED/TCP_LISTEN 的真实取值),脚本化模型沿用本仓 既有假件风格:读函数按调用序号取返回队列(耗尽回落最后一个值)、写函数记次数与 最近入参、外加一条**调用顺序日志**让时序契约可断言。两条芯片怪癖必须复刻: ① get_sock_int_status **整体读即清**、无按位清除手段(AGENTS 模块约定 B3 实证坑, 不复刻就照不出"顺手全量清一遍把别的在用 Socket 门焊死");② *_cached 是**缓存值** 而非现场值,"监听还活着"的判据是 (open != CLOSED && tcp_state == LISTEN)。 各模块 stub/ch395f.h **只做转发** include 共享件(规格里拍板的方案):三份假芯片各自 漂移会让"假件与被测代码同时错"再也报不出来,签名与常量只能有一份定义。 新增 test/host/net_listener/,34 条断言,net_listener.c 覆盖 125/126 = **99.2%**。 钉住的契约(真机上一闪而过、台架凑不齐的组合): - 四道互相牵制的时间闸门:30ms 退避(20ms 不重试、30ms 重试)、隔离级 400ms 与 优雅级 100ms(399ms/99ms 不放行、400ms/100ms 放行)、**800ms 上限只留痕且不放弃 请求**(留痕只打一次,到 2000ms 预算耗尽才报 ERROR 交对账器)。排期登记里那句 "沉降窗 800ms 上限只留痕"至此第一次有了可执行的验证。 - 该不该重建:芯片仍报 LISTEN 就不重建且清空请求;有挂起 recv/send 时让路但**保留 请求**(挂起结束自动补做);五种条件静默放弃(非 LISTENING/CLOSED 态、 auto_relisten=0、非 STREAM、local_port=0、in_use=0)。 - 失败不留半成品:open 失败关回去并回 BUSY;listen 失败也必须 close_socket。 - 重建后簿记:转 LISTENING、清 send_ready/recv_bytes/remote_ip(_arr)/remote_port、旧 remote_port 记进 last_peer_port、清 lost_conn_seen/released_abnormal;源端口与协议 取自表项本身(监听端口不能漂)。 - 芯片时序:清接收 → 关断 → 读即清 → 读状态(顺序不可换);清理接收区单次读钳到 256B、最多 16 轮、读到空即停。越界 fd 全程静默且不打扰芯片;请求去重保留首个 reason。 测试自查修了两处(都不是生产问题但值得记): ① reset_all 只复位假世界、**没复位被测模块自己的 static**(s_relisten_req/_next/_dead/ _quarantine/_settle_*/_capped),一上来红了 9 条;改用模块自带的 net_listener_clear() 清,顺带验证了 clear 的完整性。 ② 沉降窗边界必须在**独立场景**测:放行判定与 30ms 退避共用 s_relisten_next,同一条 链里"恰好 400ms"那轮 tick 会被上一轮设的退避槽挡掉。这本身是个该知道的事实—— **hold 到点后最坏还要再等一个退避周期(实测 ≤30ms)才真正放行**,已写进用例注释。 另留痕一处死分支:net_relisten_locked 的 NET_ERR_BADF 从 tick **不可达**(tick 在调用 前就因表项为空放弃),TC-NETLIS-026 记着,新增调用点时要重新审视。 桩的口径:net_listener.h/net_types.h/net_config.h/net_pending.h 用**真实头**(net_sock_t 字段与各枚举一改即编译期报错);net_socket.h 是桩,但 net_set_errno 与 NET_ERR_RETURN_1 照抄真实头同名宏(net_socket.h:528/532);真实 net_pending.h 原型带 TaskHandle_t,故 宿主 FreeRTOS.h 补一个 void* typedef(只出现在签名里,被测代码从不传它)。 覆盖率与台账:套件 22 → 23 全绿;files_tested 26 → 27;未触及 .c 40 → 39;分母 3,446 → 3,572 行;line=3128/3572(87.6%,本批净涨因为新进来的文件几乎全绿)、 func=243/266(91.3%)。恒等式 39 = 5(P1 剩) + 5(P2) + 29(P3) 仍咬合。编号补建 TC-HOST-NETLIS(游标 026),族数 16 → 17;注册表仍只做增量插入。 自查:test-cppcheck Passed(连假芯片一起过);check_ids --strict 通过;doc-links 35 处 (= 存量);新增文件 BOM/CR/U+FFFD/行尾逐项净。**本增量零生产代码改动**,未跑 @build。 --all-files 仍只有 mem-numbers / mem-budget(.map 台账漂移,等 Lead 合并后统一回填) 与 ui-golden(ref/*.bin 不入库,新 worktree 天生没有黄金参考)三条失败,与本分支无关。 --- docs/测试说明/主机单测覆盖率口径.md | 11 +- scripts/id_registry.json | 22 + scripts/test_coverage_baseline.json | 40 +- test/host/common/ch395f_fake.c | 198 +++++++ test/host/common/ch395f_fake.h | 127 ++++ test/host/net_listener/net_listener_test.c | 545 ++++++++++++++++++ test/host/net_listener/run_unit_test.py | 62 ++ test/host/net_listener/stub/FreeRTOS.h | 19 + test/host/net_listener/stub/ch395f.h | 11 + test/host/net_listener/stub/cmsis_os.h | 10 + test/host/net_listener/stub/dbg_log.h | 16 + test/host/net_listener/stub/main.h | 12 + .../host/net_listener/stub/net_latency_test.h | 9 + test/host/net_listener/stub/net_socket.h | 20 + test/host/net_listener/stub/task.h | 9 + test/host/net_listener/stub/test_config.h | 10 + 16 files changed, 1104 insertions(+), 17 deletions(-) create mode 100644 test/host/common/ch395f_fake.c create mode 100644 test/host/common/ch395f_fake.h create mode 100644 test/host/net_listener/net_listener_test.c create mode 100644 test/host/net_listener/run_unit_test.py create mode 100644 test/host/net_listener/stub/FreeRTOS.h create mode 100644 test/host/net_listener/stub/ch395f.h create mode 100644 test/host/net_listener/stub/cmsis_os.h create mode 100644 test/host/net_listener/stub/dbg_log.h create mode 100644 test/host/net_listener/stub/main.h create mode 100644 test/host/net_listener/stub/net_latency_test.h create mode 100644 test/host/net_listener/stub/net_socket.h create mode 100644 test/host/net_listener/stub/task.h create mode 100644 test/host/net_listener/stub/test_config.h diff --git a/docs/测试说明/主机单测覆盖率口径.md b/docs/测试说明/主机单测覆盖率口径.md index df99e751..b992609e 100644 --- a/docs/测试说明/主机单测覆盖率口径.md +++ b/docs/测试说明/主机单测覆盖率口径.md @@ -227,9 +227,14 @@ 未测(要先把假 `FIL/FILINFO` 的字段语义做实,否则断言落在假件自己写的规则上) | | NET 四块之一:`net_select.c` | ✅ 完成 | `test/host/net_select`(21 条) | `net_select.c` 97/114 = **85.1%** | | `lftpd.c` / `lftpd_inet.c` | ⬜ 未开始 | — | 需假 `net_socket` 层(`time_sync`/stub 那份可扩展) | -| NET 剩下三块(`net_pending`/`net_events`/`net_listener`) | ⬜ 未开始 | — | - 本批新建的 `stub/ch395f.h` 是**空壳**,只服务"不调芯片函数"的文件;这三块直接调 - `ch395f_*` 十余处,要的是能脚本化返回的**假芯片**,不是这个空壳 | +| NET 三块之一:`net_listener.c` | ✅ 完成(**125/126 = 99.2%**) | + `test/host/net_listener`(34 条)+ 共享假芯片 `test/host/common/ch395f_fake.{h,c}` | + 四道时间闸门全钉住(30ms 退避 / 400 与 100ms 两级沉降 / 800ms 上限只留痕 / 2000ms + 预算交对账器),另有接收区 256B 钳位与 16 轮上限、"清接收→关断→读即清→读状态"的 + 调用顺序、五种静默放弃条件 | +| NET 剩下两块(`net_pending`/`net_events`) | ⬜ 未开始 | 假芯片已就绪,直接接即可 | + `net_pending` 要 `net_listener_request` 的记录型假件;`net_events` 依赖最多 + (`net_sock_raw`/`net_listener_settle`/`net_pending_cancel`/`xTaskGetTickCount`)放最后 | | 104 服务层 | ⬜ 未开始 | — | 桩最省(复用 `cmsis_os2.h` + `net_socket.h`) | 本批照出的两处**现状**(只登记、**未改语义**;全库唯一调用点 `test/board/net_test_task.c` diff --git a/scripts/id_registry.json b/scripts/id_registry.json index 57fa726f..44d2ca1f 100644 --- a/scripts/id_registry.json +++ b/scripts/id_registry.json @@ -1746,6 +1746,28 @@ "subitem_ok": true, "owner_note": "B 层口径(2026-10-21 批 3 定:这些号在代码目录外零消费者,属主登记处 = 单测代码本身,不逐号补文档);2026-10-23 随覆盖率口径 §9 P1 新建模块补登记", "cursor_note": "游标按实测最大号收录(2026-10-23):越界即视为未登记的冒号牌" + }, + { + "name": "TC-HOST-NETLIS", + "class": "TC", + "title": "主机侧单测用例号:NET 层监听自动重建(test/host/net_listener)", + "pattern": "\\bTC-NETLIS-[0-9]{1,4}[a-z]?\\b", + "digit_width": [ + 2, + 4 + ], + "owner_docs_glob": [ + "test/host/net_listener/*" + ], + "scope": "global", + "frozen": false, + "cursor": "TC-NETLIS-026", + "cursor_owner_only": true, + "check_dangling": true, + "allow_orphans": true, + "subitem_ok": true, + "owner_note": "B 层口径(2026-10-21 批 3 定:这些号在代码目录外零消费者,属主登记处 = 单测代码本身,不逐号补文档);2026-10-23 随覆盖率口径 §9 假 CH395F 立项第一批补登记", + "cursor_note": "游标按实测最大号收录(2026-10-23):越界即视为未登记的冒号牌" } ], "unmanaged_bare": { diff --git a/scripts/test_coverage_baseline.json b/scripts/test_coverage_baseline.json index a6f7d3b9..5ddf8060 100644 --- a/scripts/test_coverage_baseline.json +++ b/scripts/test_coverage_baseline.json @@ -184,6 +184,14 @@ "lines_covered": 7, "lines_valid": 7 }, + "Drivers/BSP/NET/net_listener.c": { + "func_rate": 1.0, + "funcs_covered": 7, + "funcs_valid": 7, + "line_rate": 0.9921, + "lines_covered": 125, + "lines_valid": 126 + }, "Drivers/BSP/NET/net_select.c": { "func_rate": 1.0, "funcs_covered": 5, @@ -292,6 +300,13 @@ "lines_total": 112, "ok": true }, + "net_listener": { + "files": { + "Drivers/BSP/NET/net_listener.c": 126 + }, + "lines_total": 126, + "ok": true + }, "net_select": { "files": { "Drivers/BSP/NET/net_select.c": 117 @@ -410,22 +425,23 @@ "Drivers/BSP/NET/lftpd/lftpd_auth.c", "Drivers/BSP/NET/lftpd/lftpd_io.c", "Drivers/BSP/NET/lftpd/lftpd_string.c", + "Drivers/BSP/NET/net_listener.c", "Drivers/BSP/NET/net_select.c", "Drivers/BSP/NETCFG/ftp_cfg.c", "Drivers/BSP/NETCFG/net_cfg.c" ], "totals": { "files_generated_excluded": 5, - "files_tested": 26, - "files_untested": 118, - "func_rate": 0.9112, - "funcs_covered": 236, - "funcs_valid": 259, - "line_rate": 0.8714, - "lines_covered": 3003, - "lines_valid": 3446, - "modules_ok": 22, - "modules_total": 22 + "files_tested": 27, + "files_untested": 117, + "func_rate": 0.9135, + "funcs_covered": 243, + "funcs_valid": 266, + "line_rate": 0.8757, + "lines_covered": 3128, + "lines_valid": 3572, + "modules_ok": 23, + "modules_total": 23 }, "untested_sources": [ { @@ -496,10 +512,6 @@ "code_lines": 202, "path": "App/task/iec104_serv.c" }, - { - "code_lines": 191, - "path": "Drivers/BSP/NET/net_listener.c" - }, { "code_lines": 171, "path": "App/task/adc_task.c" diff --git a/test/host/common/ch395f_fake.c b/test/host/common/ch395f_fake.c new file mode 100644 index 00000000..11a03613 --- /dev/null +++ b/test/host/common/ch395f_fake.c @@ -0,0 +1,198 @@ +/* + * 模块名称:假 CH395F 芯片实现(NET 域主机侧单测共享件) + * 模块功能:实现 ch395f_fake.h 声明的假芯片。由测试单元 #include 本 .c 使用 + * (本仓主机单测的既有做法:生产源与假件都进同一个 TU)。 + * 创建日期:2026-10-23 + */ + +#include + +#include "ch395f_fake.h" + +ch395f_fake_world_t g_fake; +ch395f_fake_obs_t g_chip; + +static void fake_log(char c) +{ + if (g_chip.log_n < (CH395F_FAKE_LOG_LEN - 1)) { + g_chip.log[g_chip.log_n++] = c; + g_chip.log[g_chip.log_n] = '\0'; + } +} + +void ch395f_fake_reset(void) +{ + int i; + + (void)memset(&g_fake, 0, sizeof(g_fake)); + (void)memset(&g_chip, 0, sizeof(g_chip)); + for (i = 0; i < CH395F_FAKE_SOCK_MAX; i++) { + g_fake.cached_open[i] = CH395F_SOCKET_CLOSED; + g_fake.cached_tcp[i] = CH395F_TCP_CLOSED; + g_fake.status_byte0[i] = CH395F_SOCKET_CLOSED; + g_fake.status_byte1[i] = CH395F_TCP_CLOSED; + g_fake.open_rc[i] = CH395F_ERR_SUCCESS; + g_fake.listen_rc[i] = CH395F_ERR_SUCCESS; + } +} + +void ch395f_fake_push_recv_len(uint8_t sock, uint16_t v) +{ + if ((sock < CH395F_FAKE_SOCK_MAX) && + (g_fake.recv_len_n[sock] < CH395F_FAKE_QUEUE_LEN)) { + g_fake.recv_len_q[sock][g_fake.recv_len_n[sock]++] = v; + } +} + +void ch395f_fake_set_listening(uint8_t sock) +{ + if (sock < CH395F_FAKE_SOCK_MAX) { + g_fake.cached_open[sock] = CH395F_SOCKET_OPEN; + g_fake.cached_tcp[sock] = CH395F_TCP_LISTEN; + } +} + +ch395f_sock_state_t ch395f_sock_state_cached(uint8_t sn) +{ + ch395f_sock_state_t s; + + g_chip.cached_calls++; + g_chip.last_sock = sn; + s.open = (sn < CH395F_FAKE_SOCK_MAX) ? g_fake.cached_open[sn] : CH395F_SOCKET_CLOSED; + s.tcp_state = (sn < CH395F_FAKE_SOCK_MAX) ? g_fake.cached_tcp[sn] : CH395F_TCP_CLOSED; + return s; +} + +ch395f_sock_state_t ch395f_get_sock_state(uint8_t sn) +{ + return ch395f_sock_state_cached(sn); +} + +void ch395f_get_socket_status(uint8_t sock, uint8_t *status) +{ + g_chip.get_status_calls++; + g_chip.last_sock = sock; + fake_log('s'); + if (status != NULL) { + status[0] = (sock < CH395F_FAKE_SOCK_MAX) ? g_fake.status_byte0[sock] : CH395F_SOCKET_CLOSED; + status[1] = (sock < CH395F_FAKE_SOCK_MAX) ? g_fake.status_byte1[sock] : CH395F_TCP_CLOSED; + } +} + +/* 整体读即清:返回后该 socket 的挂起位图清零(真实芯片没有按位清除手段) */ +uint8_t ch395f_get_sock_int_status(uint8_t sock) +{ + uint8_t v = 0U; + + g_chip.int_status_calls++; + g_chip.last_sock = sock; + fake_log('i'); + if (sock < CH395F_FAKE_SOCK_MAX) { + v = g_fake.int_status[sock]; + g_fake.int_status[sock] = 0U; + } + return v; +} + +uint16_t ch395f_get_recv_len(uint8_t sock) +{ + uint16_t v; + + g_chip.last_sock = sock; + fake_log('R'); + if ((sock >= CH395F_FAKE_SOCK_MAX) || (g_fake.recv_len_n[sock] == 0)) { + return 0U; + } + /* 按调用序号取;耗尽后回落最后一个值(便于表达"一直有数据"的持续态) */ + if (g_fake.recv_len_i[sock] < g_fake.recv_len_n[sock]) { + v = g_fake.recv_len_q[sock][g_fake.recv_len_i[sock]++]; + } else { + v = g_fake.recv_len_q[sock][g_fake.recv_len_n[sock] - 1]; + } + return v; +} + +void ch395f_read_recv_buf(uint8_t sock, uint8_t *p_data, uint16_t len) +{ + g_chip.read_calls++; + g_chip.last_sock = sock; + g_chip.last_read_len = len; + fake_log('r'); + if ((p_data != NULL) && (len > 0U)) { + (void)memset(p_data, 0, len); /* 交出去的是零,测试不依赖内容 */ + } +} + +void ch395f_write_send_buf(uint8_t sock, uint8_t *p_data, uint16_t len) +{ + (void)p_data; + g_chip.write_calls++; + g_chip.last_sock = sock; + g_chip.last_read_len = len; + fake_log('w'); +} + +uint8_t ch395f_open_socket(uint8_t sock) +{ + g_chip.open_calls++; + g_chip.last_sock = sock; + fake_log('o'); + return (sock < CH395F_FAKE_SOCK_MAX) ? g_fake.open_rc[sock] : 0x01U; +} + +uint8_t ch395f_close_socket(uint8_t sock) +{ + g_chip.close_calls++; + g_chip.last_sock = sock; + fake_log('c'); + return CH395F_ERR_SUCCESS; +} + +uint8_t ch395f_tcp_listen(uint8_t sock) +{ + g_chip.listen_calls++; + g_chip.last_sock = sock; + fake_log('l'); + return (sock < CH395F_FAKE_SOCK_MAX) ? g_fake.listen_rc[sock] : 0x01U; +} + +void ch395f_set_proto_type(uint8_t sock, uint8_t proto) +{ + g_chip.set_proto_calls++; + g_chip.last_sock = sock; + g_chip.last_proto = proto; + fake_log('P'); +} + +void ch395f_set_sour_port(uint8_t sock, uint16_t port) +{ + g_chip.set_port_calls++; + g_chip.last_sock = sock; + g_chip.last_port = port; + fake_log('p'); +} + +void ch395f_get_remot_ipp(uint8_t sock, uint8_t *p_buf) +{ + (void)sock; + if (p_buf != NULL) { + (void)memset(p_buf, 0, 4U); + } +} + +void ch395f_set_keepalive_enable(uint8_t sock, uint8_t enable) +{ + g_chip.keepalive_calls++; + g_chip.last_sock = sock; + g_chip.last_keepalive = enable; +} + +uint8_t ch395f_link_up_cached(void) +{ + return 1U; +} + +uint16_t ch395f_link_change_count(void) +{ + return 0U; +} diff --git a/test/host/common/ch395f_fake.h b/test/host/common/ch395f_fake.h new file mode 100644 index 00000000..8f612326 --- /dev/null +++ b/test/host/common/ch395f_fake.h @@ -0,0 +1,127 @@ +/* + * 模块名称:假 CH395F 芯片(NET 域主机侧单测共享件) + * 模块功能:给 NET 域的主机侧单测一个**可脚本化的假芯片**。真实 ch395f.h 拉 HAL 与 + * SPI 寄存器定义,PC 上编不动;而 net_listener / net_pending / net_events + * 的行为恰恰完全由"芯片此刻报什么状态"决定,必须有假件才能测。 + * + * ⚠ 仅供 test/host/** 使用,不参与 MCU 构建。 + * + * 为什么值得共享一份(而不是每模块自带桩):三份假芯片各自漂移时,"假件与被测代码 + * 同时错"这类问题再也不会报出来。各模块的 stub/ch395f.h 只做一件事——include 本文件, + * 于是签名与常量只有一份定义。 + * + * 常量与类型必须与 Drivers/BSP/CH395F/ch395f.h 逐字一致(2026-10-23 从真实头抄录): + * CH395F_ERR_SUCCESS 0x00 / CH395F_PROTO_TYPE_TCP 0x03 / CH395F_SOCKET_CLOSED 0x00 + * CH395F_TCP_CLOSED 0x00 / CH395F_TCP_LISTEN 0x01;ch395f_sock_state_t 为 + * { uint8_t open; uint8_t tcp_state; } 两字节。 + * + * 必须复刻的两条芯片怪癖(不复刻就等于把被测代码的坑假件掉): + * 1. ch395f_get_sock_int_status() 是**整体读即清**、芯片没有按位清除手段。本假件 + * 返回该 socket 的挂起位图并同步清零。AGENTS 模块约定里 B3 的实证坑("顺手 + * 全量清一遍"把别的在用 Socket 的门焊死)靠这条才照得出来。 + * 2. *_cached 系列返回的是**缓存值**而不是现场值:open 状态与 TCP 状态分开、且 + * "监听还活着"的判据是 (open != CLOSED && tcp_state == LISTEN)。测试据此能构造 + * "缓存陈旧"的形态。 + * + * 脚本化模型(沿用本仓既有假件风格:nand_port 的真头+假 diskio、lftpd_io 的可注入假堆): + * - 每个读函数一条按调用序号取的返回队列,队列耗尽后回落到**最后一个值**(便于 + * 写"一直可读""一直关不掉"这类持续状态); + * - 每个写/控制函数一条调用记录(次数 + 最近一次入参),另给逐函数调用计数, + * 让"这一轮根本不该碰芯片"成为可断言的事; + * - g_chip.log[] 记录**调用顺序**(短名),用于断言"先清接收、再关、再读即清"这类 + * 时序契约; + * - ch395f_fake_reset() 一次全清。 + * + * 创建日期:2026-10-23 + */ + +#ifndef CH395F_FAKE_H +#define CH395F_FAKE_H + +#include + +/* —— 与真实 ch395f.h 逐字一致的常量(值抄自真实头,勿"顺手改")—— */ +#define CH395F_ERR_SUCCESS 0x00U +#define CH395F_PROTO_TYPE_TCP 0x03U +#define CH395F_SOCKET_CLOSED 0x00U +#define CH395F_SOCKET_OPEN 0x01U +#define CH395F_TCP_CLOSED 0x00U +#define CH395F_TCP_LISTEN 0x01U +#define CH395F_TCP_ESTABLISHED 0x05U + +#define CH395F_FAKE_SOCK_MAX 8 /* 与 NET_MAX_SOCKETS 同量级,测试自用 */ +#define CH395F_FAKE_QUEUE_LEN 24 +#define CH395F_FAKE_LOG_LEN 96 + +typedef struct { + uint8_t open; + uint8_t tcp_state; +} ch395f_sock_state_t; + +/* —— 可预置的芯片侧世界 —— */ +typedef struct { + uint8_t cached_open[CH395F_FAKE_SOCK_MAX]; /* sock_state_cached().open */ + uint8_t cached_tcp[CH395F_FAKE_SOCK_MAX]; /* sock_state_cached().tcp_state */ + uint8_t status_byte0[CH395F_FAKE_SOCK_MAX]; /* get_socket_status 的 sock 状态 */ + uint8_t status_byte1[CH395F_FAKE_SOCK_MAX]; /* get_socket_status 的 TCP 状态 */ + uint8_t int_status[CH395F_FAKE_SOCK_MAX]; /* 读即清的挂起位图 */ + uint16_t recv_len_q[CH395F_FAKE_SOCK_MAX][CH395F_FAKE_QUEUE_LEN]; + int recv_len_n[CH395F_FAKE_SOCK_MAX]; + int recv_len_i[CH395F_FAKE_SOCK_MAX]; + uint8_t open_rc[CH395F_FAKE_SOCK_MAX]; /* open_socket 返回码 */ + uint8_t listen_rc[CH395F_FAKE_SOCK_MAX]; /* tcp_listen 返回码 */ +} ch395f_fake_world_t; + +typedef struct { + /* 调用计数("不该碰芯片"的断言面) */ + int open_calls; + int close_calls; + int listen_calls; + int int_status_calls; + int get_status_calls; + int read_calls; + int write_calls; + int set_proto_calls; + int set_port_calls; + int cached_calls; + /* 最近一次入参 */ + uint8_t last_sock; + uint16_t last_read_len; + uint16_t last_port; + uint8_t last_proto; + uint8_t last_keepalive; + int keepalive_calls; + /* 调用顺序日志(短名首字母): + * 'c'=close 'i'=int_status(读即清) 's'=get_socket_status 'r'=read_recv_buf + * 'R'=get_recv_len 'o'=open_socket 'l'=tcp_listen 'p'=set_sour_port + * 'P'=set_proto_type */ + char log[CH395F_FAKE_LOG_LEN]; + int log_n; +} ch395f_fake_obs_t; + +extern ch395f_fake_world_t g_fake; +extern ch395f_fake_obs_t g_chip; + +void ch395f_fake_reset(void); /* 全清并把所有 socket 设为"已关闭" */ +void ch395f_fake_push_recv_len(uint8_t sock, uint16_t v); +void ch395f_fake_set_listening(uint8_t sock); /* 缓存:open!=CLOSED 且 tcp=LISTEN */ + +/* —— 假芯片实现(签名与真实 ch395f.h 一字不改)—— */ +ch395f_sock_state_t ch395f_sock_state_cached(uint8_t sn); +ch395f_sock_state_t ch395f_get_sock_state(uint8_t sn); +void ch395f_get_socket_status(uint8_t sock, uint8_t *status); +uint8_t ch395f_get_sock_int_status(uint8_t sock); +uint16_t ch395f_get_recv_len(uint8_t sock); +void ch395f_read_recv_buf(uint8_t sock, uint8_t *p_data, uint16_t len); +void ch395f_write_send_buf(uint8_t sock, uint8_t *p_data, uint16_t len); +uint8_t ch395f_open_socket(uint8_t sock); +uint8_t ch395f_close_socket(uint8_t sock); +uint8_t ch395f_tcp_listen(uint8_t sock); +void ch395f_set_proto_type(uint8_t sock, uint8_t proto); +void ch395f_set_sour_port(uint8_t sock, uint16_t port); +void ch395f_get_remot_ipp(uint8_t sock, uint8_t *p_buf); +void ch395f_set_keepalive_enable(uint8_t sock, uint8_t enable); +uint8_t ch395f_link_up_cached(void); +uint16_t ch395f_link_change_count(void); + +#endif /* CH395F_FAKE_H */ diff --git a/test/host/net_listener/net_listener_test.c b/test/host/net_listener/net_listener_test.c new file mode 100644 index 00000000..30af09af --- /dev/null +++ b/test/host/net_listener/net_listener_test.c @@ -0,0 +1,545 @@ +/* + * 模块名称:NET 层监听重建(net_listener)主机侧单元测试 + * 模块功能:在 PC 上离线验证 Drivers/BSP/NET/net_listener.c 的**自动重开监听**状态机: + * 请求去重、五种放弃条件、挂起 recv 让路、30ms 退避与 2000ms 预算、 + * 隔离/优雅两级沉降窗(400ms / 100ms)、800ms 上限"只留痕不放弃"、 + * 接收区清理的 256 字节钳位与 16 轮上限、重建后的 peer 簿记,以及 + * "先清接收 → 关断 → 读即清 → 读状态"的芯片调用顺序。 + * + * 被测对象:真实源文件 ../../../Drivers/BSP/NET/net_listener.c(#include 原样纳入)。 + * net_listener.h / net_types.h / net_config.h / net_pending.h 全用**真实头** + * (net_sock_t 字段名、NET_SOCK_STATE_* / NET_SOCK_STREAM / NET_ERR_* / + * net_listener_req_t 枚举一改就编译期报错)。假件: + * - 假 CH395F 芯片:test/host/common/ch395f_fake.h/.c(15 个真实原型 + + * 按调用序号取值的返回队列 + 调用计数与**调用顺序日志**); + * - 可控 socket 表(net_get_sock 可写回项)、可控时基(HAL_GetTick 由测试 + * 显式设定,400/100/800/2000 四条时间线都能卡点)、挂起 recv 的有无、 + * dbg_log 计数、net_set_errno 记录。 + * + * 为什么要主机侧单测(而不是只靠台架): + * 1. CH395F 是**单连接 socket**:监听与数据通道复用同一号,断线后必须重开成 LISTEN, + * 否则端口"连接被拒"。重开时机由"芯片此刻报什么状态"决定,而这些状态在真机上 + * 一闪而过——对端配合、时序、总线全都得凑齐才见得到一次,台架无法稳定复现。 + * 2. 这里的时间窗是**四道互相牵制的闸门**(30ms 退避 / 400 与 100 沉降 / 800 留痕 / + * 2000 预算),排期登记里那句"沉降窗 800ms 上限只留痕"就是从这里来的:芯片永远 + * 关不掉时,代码**不能放弃请求**,也不能每 30ms 刷一条日志。真机上验证一次要等 + * 到真断线,主机侧把 tick 做成显式量后,1399ms 与 1400ms 的差别就是一行断言。 + * 3. GET_SOCK_INT_STATUS 是**整体读即清**,"顺手全量清一遍"会把别的在用 socket 的 + * 门焊死(AGENTS 模块约定 B3 实证坑)。假芯片必须复刻读即清,本文件用调用顺序 + * 日志钉住"只读自己这一路"的时序。 + * + * 覆盖点: + * TC-NETLIS-001 越界 fd:settle/request/clear 静默返回,tick 不产生任何芯片调用 + * TC-NETLIS-010 芯片显示仍在监听 → 不重建,且请求被 clear(下次还能受理) + * TC-NETLIS-011 有挂起 recv / 挂起 send 时让路,但**保留请求**(不丢重建) + * TC-NETLIS-012 五种放弃条件(非 LISTENING/CLOSED 态、auto_relisten=0、非 STREAM、 + * local_port=0、表项 in_use=0)→ 静默 clear,不报 ERROR + * TC-NETLIS-013 重建成功后请求清空,重复 tick 不重复重建 + * TC-NETLIS-014 open 失败:关回去 + 报 BUSY + 30ms 退避(20ms 后不重试、30ms 重试), + * 首轮建立 2000ms 预算 + * TC-NETLIS-015 tcp_listen 失败也要关断 socket + * TC-NETLIS-016 重建成功后簿记:转 LISTENING、清 send_ready/recv_bytes/remote_*、 + * 把旧 remote_port 记进 last_peer_port、清 lost_conn_seen/released_abnormal + * TC-NETLIS-017 重建用的源端口与协议取自表项本身(端口不能漂) + * TC-NETLIS-018 预算耗尽:清请求 + 报一次 ERROR 交对账器接手,之后不再自己试 + * TC-NETLIS-019 请求去重:已有请求时后来的 reason 不覆盖首个 + * TC-NETLIS-020 隔离级沉降:关不掉就等,且**只在第一次**打 INFO + * TC-NETLIS-021 隔离级 hold = 400ms(399ms 不放行、1400ms 放行) + * TC-NETLIS-021b 优雅级 hold = 100ms(99ms 不放行、1100ms 放行) + * TC-NETLIS-022 800ms 上限**只留痕一次且不放弃**,到 2000ms 才交给对账器 + * TC-NETLIS-023 清理接收区:单次读钳到 256 字节、最多 16 轮(读到空即停) + * TC-NETLIS-024 芯片调用顺序:清接收 → 关断 → 读即清 → 读状态 + * TC-NETLIS-025 读即清真的清零(第二次读到 0) + * TC-NETLIS-026 表项不存在由 tick 直接放弃(net_relisten_locked 里的 BADF 分支 + * 从 tick 不可达 —— 死分支留痕,见注释) + * + * 通过:全部 TC PASS(main 返回 0)。 + * 入口:int main(void),返回 0=PASS / 1=FAIL(由 run 脚本判读)。 + * + * 创建日期:2026-10-23 + */ + +#include +#include + +#include "net_listener.h" +#include "net_pending.h" +#include "net_socket.h" +#include "ch395f.h" + +/* 假芯片实现与生产源进同一个 TU(本仓主机单测既有做法) */ +#include "ch395f_fake.c" +#include "net_listener.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_nlis_info_count; +int g_nlis_error_count; +int g_nlis_debug_count; + +/* ===================== 可控 socket 表 ===================== */ + +static net_sock_t g_sock[NET_MAX_SOCKETS]; +static uint8_t g_sock_valid[NET_MAX_SOCKETS]; + +net_sock_t *net_get_sock(int sockfd) +{ + if ((sockfd < 0) || (sockfd >= NET_MAX_SOCKETS)) { + return NULL; + } + if (g_sock_valid[sockfd] == 0u) { + return NULL; + } + return &g_sock[sockfd]; +} + +/* ===================== 挂起 recv / send 的有无 ===================== */ + +static int g_pending[NET_MAX_SOCKETS]; +static int g_send_pending[NET_MAX_SOCKETS]; + +int net_pending_active(int sockfd) +{ + if ((sockfd < 0) || (sockfd >= NET_MAX_SOCKETS)) { + return 0; + } + return g_pending[sockfd]; +} + +int net_send_pending_active(int sockfd) +{ + if ((sockfd < 0) || (sockfd >= NET_MAX_SOCKETS)) { + return 0; + } + return g_send_pending[sockfd]; +} + +/* ===================== 可控时基与 errno ===================== */ + +static uint32_t g_now; +static int g_errno_last; +static int g_errno_calls; + +uint32_t HAL_GetTick(void) +{ + return g_now; /* 由测试显式设定:400/100/800/2000 四条线要能卡点 */ +} + +int net_set_errno(int err) +{ + g_errno_last = err; + g_errno_calls++; + return err; +} + +/* ===================== 复位与建表 ===================== */ + +static void reset_all(void) +{ + (void)memset(g_sock, 0, sizeof(g_sock)); + (void)memset(g_sock_valid, 0, sizeof(g_sock_valid)); + (void)memset(g_pending, 0, sizeof(g_pending)); + (void)memset(g_send_pending, 0, sizeof(g_send_pending)); + g_now = 1000u; + g_errno_last = 0; + g_errno_calls = 0; + g_nlis_info_count = 0; + g_nlis_error_count = 0; + g_nlis_debug_count = 0; + ch395f_fake_reset(); + /* 关键:被测模块的 static 状态(s_relisten_req / _next / _dead / _quarantine / + * _settle_start / _settle_dead / _capped)不会随假世界一起复位。上一场景残留的 + * next/dead 会把后面每一条时序断言都带偏(实测一次红了 9 条)。用模块自己的 + * net_listener_clear() 清——它清的正是这些项,也顺带验证了 clear 的完整性。 */ + for (int i = 0; i < NET_MAX_SOCKETS; i++) { + net_listener_clear(i); + } +} + +/* 一个"刚断线、等待重开监听"的槽位 */ +static void mk_closed_listen(int fd, uint16_t port) +{ + g_sock_valid[fd] = 1u; + g_sock[fd].in_use = 1u; + g_sock[fd].type = NET_SOCK_STREAM; + g_sock[fd].state = NET_SOCK_STATE_CLOSED; + g_sock[fd].auto_relisten = 1u; + g_sock[fd].local_port = port; +} + +/* ===================== 边界与去重 ===================== */ + +static void tc_bounds(void) +{ + printf("-- TC-NETLIS-001/019 bounds --\n"); + reset_all(); + net_listener_settle(-1, NET_LISTENER_SETTLE_GRACEFUL); + net_listener_settle(NET_MAX_SOCKETS, NET_LISTENER_SETTLE_QUARANTINE); + net_listener_request(-1, NET_LISTENER_REQ_RECON); + net_listener_request(NET_MAX_SOCKETS, NET_LISTENER_REQ_RECON); + net_listener_clear(-1); + net_listener_clear(NET_MAX_SOCKETS); + net_listener_tick(); + CHECK((g_chip.open_calls == 0) && (g_chip.close_calls == 0) && + (g_nlis_error_count == 0), + "TC-NETLIS-001 越界 fd 静默返回,不打扰芯片"); + + reset_all(); + mk_closed_listen(0, 2404); + net_listener_request(0, NET_LISTENER_REQ_ACCEPT); + net_listener_request(0, NET_LISTENER_REQ_DROP); /* 应被忽略 */ + CHECK(s_relisten_req[0] == (uint8_t)(NET_LISTENER_REQ_ACCEPT + 1U), + "TC-NETLIS-019 请求去重:后来的 reason 不覆盖首个"); +} + +/* ===================== 该不该重建 ===================== */ + +static void tc_should_or_not(void) +{ + printf("-- TC-NETLIS-010..013 --\n"); + reset_all(); + mk_closed_listen(0, 2404); + ch395f_fake_set_listening(0); /* 芯片说:还在 LISTEN */ + net_listener_request(0, NET_LISTENER_REQ_ACCEPT); + net_listener_tick(); + CHECK((g_chip.open_calls == 0) && (s_relisten_req[0] == 0u), + "TC-NETLIS-010 监听仍活着就不重建,且请求已 clear"); + g_fake.cached_open[0] = CH395F_SOCKET_CLOSED; /* 之后才真死了 */ + net_listener_request(0, NET_LISTENER_REQ_RECON); + net_listener_tick(); + CHECK(g_chip.open_calls == 1, + "TC-NETLIS-010b 上一步确实清掉了请求(否则这里会被去重吞掉)"); + + reset_all(); + mk_closed_listen(0, 2404); + g_pending[0] = 1; + net_listener_request(0, NET_LISTENER_REQ_EOF); + net_listener_tick(); + CHECK((g_chip.open_calls == 0) && (s_relisten_req[0] != 0u), + "TC-NETLIS-011 挂起 recv 未结束时让路但保留请求"); + g_pending[0] = 0; + g_send_pending[0] = 1; + net_listener_tick(); + CHECK((g_chip.open_calls == 0) && (s_relisten_req[0] != 0u), + "TC-NETLIS-011b 挂起 send 同样挡路"); + g_send_pending[0] = 0; + net_listener_tick(); + CHECK((g_chip.open_calls == 1) && (s_relisten_req[0] == 0u), + "TC-NETLIS-011c 挂起结束后自动补上重建"); + + reset_all(); + mk_closed_listen(0, 2404); + net_listener_request(0, NET_LISTENER_REQ_RECON); + net_listener_tick(); + net_listener_tick(); + CHECK(g_chip.open_calls == 1, "TC-NETLIS-013 重建成功后不重复重建"); +} + +static void tc_giveup_conditions(void) +{ + int i; + int opened[5]; + int cleared[5]; + int errs[5]; + + printf("-- TC-NETLIS-012 五种放弃条件 --\n"); + reset_all(); + for (i = 0; i < 5; i++) { + uint8_t before_open = (uint8_t)g_chip.open_calls; + (void)before_open; + reset_all(); + mk_closed_listen(0, 2404); + switch (i) { + case 0: g_sock[0].auto_relisten = 0u; break; /* 明确不自动重开 */ + case 1: g_sock[0].type = (NET_SOCK_STREAM + 1); break; /* 不是 TCP */ + case 2: g_sock[0].local_port = 0u; break; /* 没绑端口 */ + case 3: g_sock[0].in_use = 0u; break; /* 槽位已释放 */ + default: g_sock[0].state = NET_SOCK_STATE_ESTABLISHED; break; + } + net_listener_request(0, NET_LISTENER_REQ_RECON); + net_listener_tick(); + opened[i] = g_chip.open_calls; + cleared[i] = (s_relisten_req[0] == 0u) ? 1 : 0; + errs[i] = g_nlis_error_count; + } + CHECK((opened[0] == 0) && (opened[1] == 0) && (opened[2] == 0) && + (opened[3] == 0) && (opened[4] == 0), + "TC-NETLIS-012 五种条件下都不碰芯片"); + CHECK((cleared[0] == 1) && (cleared[1] == 1) && (cleared[2] == 1) && + (cleared[3] == 1) && (cleared[4] == 1), + "TC-NETLIS-012b 放弃即清请求(不留在表里反复白试)"); + CHECK((errs[0] == 0) && (errs[2] == 0) && (errs[4] == 0), + "TC-NETLIS-012c 放弃是静默的(不该报 ERROR,那是对账器的活)"); + + reset_all(); + g_sock_valid[0] = 0u; /* 连表项都没有 */ + net_listener_request(0, NET_LISTENER_REQ_RECON); + net_listener_tick(); + CHECK((g_chip.open_calls == 0) && (s_relisten_req[0] == 0u), + "TC-NETLIS-026 表项不存在由 tick 直接放弃"); + /* 注:net_relisten_locked() 里的 NET_ERR_BADF 分支因此从 tick **不可达** + * (net_listener.c:112 那段只在 p_sock 非空时才可能被调到)——留痕:它是 + * 内部兜底,不是活路径;将来若新增调用点,要重新审视这条。 */ +} + +/* ===================== 失败退避与预算 ===================== */ + +static void tc_backoff_and_budget(void) +{ + printf("-- TC-NETLIS-014/015/018 --\n"); + reset_all(); + mk_closed_listen(0, 2404); + g_fake.open_rc[0] = 0x01U; /* open 永远失败 */ + g_now = 1000u; + net_listener_request(0, NET_LISTENER_REQ_RECON); + net_listener_tick(); + CHECK((g_chip.open_calls == 1) && (g_chip.close_calls == 1) && + (g_errno_last == NET_ERR_BUSY) && (s_relisten_req[0] != 0u) && + (s_relisten_next[0] == 1030u) && (s_relisten_dead[0] == 3000u), + "TC-NETLIS-014 open 失败:关回去 + BUSY + 30ms 退避 + 首轮建 2000ms 预算"); + g_now = 1020u; + net_listener_tick(); + CHECK(g_chip.open_calls == 1, "TC-NETLIS-014b 退避未到不重试(不空转打芯片)"); + g_now = 1030u; + net_listener_tick(); + CHECK(g_chip.open_calls == 2, "TC-NETLIS-014c 退避到点再试一次"); + + reset_all(); + mk_closed_listen(0, 2404); + g_fake.listen_rc[0] = 0x01U; /* open 成功、listen 失败 */ + net_listener_request(0, NET_LISTENER_REQ_RECON); + net_listener_tick(); + CHECK((g_chip.listen_calls == 1) && (g_chip.close_calls == 1) && + (s_relisten_req[0] != 0u), + "TC-NETLIS-015 listen 失败也要关断(不留下开了一半的 socket)"); + + reset_all(); + mk_closed_listen(0, 2404); + g_fake.open_rc[0] = 0x01U; + g_now = 1000u; + net_listener_request(0, NET_LISTENER_REQ_RECON); + net_listener_tick(); /* 建预算 dead=3000 */ + g_fake.open_rc[0] = CH395F_ERR_SUCCESS; /* 芯片后来能开了 */ + g_now = 3000u; /* 但预算已耗尽 */ + net_listener_tick(); + CHECK((g_nlis_error_count == 1) && (s_relisten_req[0] == 0u) && + (g_chip.open_calls == 1), + "TC-NETLIS-018 预算耗尽:报一次 ERROR 交对账器,之后不再自己试"); +} + +/* ===================== 成功重建的簿记 ===================== */ + +static void tc_bookkeeping(void) +{ + net_sock_t *p; + + printf("-- TC-NETLIS-016/017 bookkeeping --\n"); + reset_all(); + mk_closed_listen(0, 2404); + p = &g_sock[0]; + p->state = NET_SOCK_STATE_CLOSED; + p->remote_port = 1234u; + p->remote_ip = 0x0A000001u; + p->remote_ip_arr[0] = 9u; + p->remote_ip_arr[3] = 7u; + p->send_ready = 1u; + p->recv_bytes = 77u; + p->released_abnormal = 1u; + p->lost_conn_seen = 1u; + net_listener_request(0, NET_LISTENER_REQ_DROP); + net_listener_tick(); + CHECK((p->state == NET_SOCK_STATE_LISTENING) && (p->send_ready == 0u) && + (p->recv_bytes == 0u) && (p->remote_port == 0u) && + (p->remote_ip == 0u), + "TC-NETLIS-016 重建后回到干净监听态"); + CHECK((p->last_peer_port == 1234u) && (p->lost_conn_seen == 0u) && + (p->released_abnormal == 0u) && (p->remote_ip_arr[0] == 0u) && + (p->remote_ip_arr[3] == 0u), + "TC-NETLIS-016b 旧对端端口进 last_peer_port,其余对端痕迹清零"); + CHECK((g_chip.set_port_calls == 1) && (g_chip.last_port == 2404u) && + (g_chip.set_proto_calls == 1) && + (g_chip.last_proto == CH395F_PROTO_TYPE_TCP), + "TC-NETLIS-017 源端口与协议取自表项本身(监听端口不能漂)"); +} + +/* ===================== 沉降窗:隔离 400 / 优雅 100 ===================== */ + +static void tc_settle_windows(void) +{ + printf("-- TC-NETLIS-020/021 沉降窗 --\n"); + reset_all(); + mk_closed_listen(0, 2404); + g_fake.status_byte0[0] = CH395F_SOCKET_OPEN; /* 芯片关不掉 */ + g_fake.status_byte1[0] = CH395F_TCP_ESTABLISHED; + net_listener_settle(0, NET_LISTENER_SETTLE_QUARANTINE); + net_listener_request(0, NET_LISTENER_REQ_DROP); + g_now = 1000u; + net_listener_tick(); + CHECK((g_chip.open_calls == 0) && (g_nlis_info_count == 1) && + (s_relisten_settle_start[0] == 1000u) && + (s_relisten_settle_dead[0] == 1800u) && (g_errno_last == NET_ERR_BUSY), + "TC-NETLIS-020 关不掉就等:首次记 start 并给 800ms 留痕线,返回 BUSY"); + g_now = 1030u; + net_listener_tick(); + CHECK(g_nlis_info_count == 1, + "TC-NETLIS-020b 等待期间**只在第一次**打日志(否则每 30ms 刷一条)"); + + /* 边界必须在**独立场景**里测:放行判定与 30ms 退避共用 s_relisten_next, + * 上一轮等待会把 next 设成"当次 + 30",于是同一条链里"恰好 400ms"那一次 tick + * 根本不会发起尝试。这本身是个该知道的事实——**hold 到点后最坏还要再等一个 + * 退避周期才真正放行**(实测最多晚 30ms),写在这里免得后人以为是漏判。 */ + reset_all(); + mk_closed_listen(0, 2404); + g_fake.status_byte0[0] = CH395F_SOCKET_CLOSED; + g_fake.status_byte1[0] = CH395F_TCP_CLOSED; + net_listener_settle(0, NET_LISTENER_SETTLE_QUARANTINE); + net_listener_request(0, NET_LISTENER_REQ_DROP); + g_now = 1000u; + net_listener_tick(); /* 记 start=1000,next=1030 */ + g_now = 1399u; + net_listener_tick(); + CHECK((g_chip.open_calls == 0) && (s_relisten_quarantine[0] != 0u), + "TC-NETLIS-021 隔离级 hold=400ms:第 399ms 仍不放行"); + + reset_all(); + mk_closed_listen(0, 2404); + g_fake.status_byte0[0] = CH395F_SOCKET_CLOSED; + g_fake.status_byte1[0] = CH395F_TCP_CLOSED; + net_listener_settle(0, NET_LISTENER_SETTLE_QUARANTINE); + net_listener_request(0, NET_LISTENER_REQ_DROP); + g_now = 1000u; + net_listener_tick(); + g_now = 1400u; + net_listener_tick(); + CHECK((g_chip.open_calls == 1) && (s_relisten_quarantine[0] == 0u), + "TC-NETLIS-021b 第 400ms 放行并清隔离(独立场景,不受退避槽遮挡)"); + + reset_all(); + mk_closed_listen(0, 2404); + net_listener_settle(0, NET_LISTENER_SETTLE_GRACEFUL); + net_listener_request(0, NET_LISTENER_REQ_EOF); + g_now = 1000u; + net_listener_tick(); + CHECK((g_chip.open_calls == 0) && (s_relisten_settle_start[0] == 1000u), + "TC-NETLIS-021c 优雅级同样先等(0 < 100ms)"); + g_now = 1099u; + net_listener_tick(); + CHECK(g_chip.open_calls == 0, "TC-NETLIS-021c 优雅级 hold=100ms:第 99ms 不放行"); + + reset_all(); + mk_closed_listen(0, 2404); + net_listener_settle(0, NET_LISTENER_SETTLE_GRACEFUL); + net_listener_request(0, NET_LISTENER_REQ_EOF); + g_now = 1000u; + net_listener_tick(); + g_now = 1100u; + net_listener_tick(); + CHECK(g_chip.open_calls == 1, "TC-NETLIS-021d 优雅级第 100ms 放行(比隔离级快 4 倍)"); +} + +static void tc_cap_is_trace_only(void) +{ + printf("-- TC-NETLIS-022 800ms 上限只留痕 --\n"); + reset_all(); + mk_closed_listen(0, 2404); + g_fake.status_byte0[0] = CH395F_SOCKET_OPEN; /* 永远关不掉 */ + g_fake.status_byte1[0] = CH395F_TCP_ESTABLISHED; + net_listener_settle(0, NET_LISTENER_SETTLE_QUARANTINE); + net_listener_request(0, NET_LISTENER_REQ_PENDING); + g_now = 1000u; + net_listener_tick(); /* start=1000 settle_dead=1800 */ + g_now = 1800u; + net_listener_tick(); + CHECK((s_relisten_capped[0] == 1u) && (g_nlis_info_count == 2) && + (g_chip.open_calls == 0) && (s_relisten_req[0] != 0u), + "TC-NETLIS-022 到 800ms 只**留痕一次**,既不重建也**不放弃请求**"); + g_now = 1830u; + net_listener_tick(); + CHECK(g_nlis_info_count == 2, + "TC-NETLIS-022b 留痕只打一次(之后继续默默等,不刷屏)"); + g_now = 3000u; + net_listener_tick(); + CHECK((g_nlis_error_count == 1) && (s_relisten_req[0] == 0u), + "TC-NETLIS-022c 最终由 2000ms 预算接手:报 ERROR 并交出请求"); +} + +/* ===================== 芯片交互:清理、顺序、读即清 ===================== */ + +static void tc_chip_interaction(void) +{ + printf("-- TC-NETLIS-023/024/025 --\n"); + reset_all(); + mk_closed_listen(0, 2404); + net_listener_settle(0, NET_LISTENER_SETTLE_GRACEFUL); + ch395f_fake_push_recv_len(0, 1000u); /* 一直有 1000 字节 */ + net_listener_request(0, NET_LISTENER_REQ_DROP); + g_now = 1000u; + net_listener_tick(); + CHECK((g_chip.read_calls == 16) && (g_chip.last_read_len == 256u), + "TC-NETLIS-023 清接收区:单次钳到 256 字节、最多 16 轮(有守卫不死循环)"); + + reset_all(); + mk_closed_listen(0, 2404); + net_listener_settle(0, NET_LISTENER_SETTLE_GRACEFUL); + ch395f_fake_push_recv_len(0, 5u); + ch395f_fake_push_recv_len(0, 0u); /* 第二轮就空了 */ + net_listener_request(0, NET_LISTENER_REQ_DROP); + g_now = 1000u; + net_listener_tick(); + CHECK((g_chip.read_calls == 1) && (g_chip.last_read_len == 5u), + "TC-NETLIS-023b 读到空立刻停手(不硬凑 16 轮)"); + + reset_all(); + mk_closed_listen(0, 2404); + g_fake.status_byte0[0] = CH395F_SOCKET_OPEN; /* 停在等待分支,便于看顺序 */ + net_listener_settle(0, NET_LISTENER_SETTLE_QUARANTINE); + net_listener_request(0, NET_LISTENER_REQ_DROP); + g_now = 1000u; + net_listener_tick(); + CHECK((strcmp(g_chip.log, "Rcis") == 0) && + (g_chip.int_status_calls == 1) && (g_chip.get_status_calls == 1), + "TC-NETLIS-024 顺序:清接收 → 关断 → 读即清 → 读状态(不可换)"); + + reset_all(); + mk_closed_listen(0, 2404); + g_fake.int_status[0] = 0x3FU; + g_fake.status_byte0[0] = CH395F_SOCKET_OPEN; + net_listener_settle(0, NET_LISTENER_SETTLE_QUARANTINE); + net_listener_request(0, NET_LISTENER_REQ_DROP); + g_now = 1000u; + net_listener_tick(); + CHECK((g_chip.int_status_calls >= 1) && (g_fake.int_status[0] == 0u), + "TC-NETLIS-025 读即清真的清零(复刻芯片无按位清除的怪癖)"); +} + +/* ===================== 入口 ===================== */ + +int main(void) +{ + printf("=== NET Listener Tests ===\n"); + + tc_bounds(); + tc_should_or_not(); + tc_giveup_conditions(); + tc_backoff_and_budget(); + tc_bookkeeping(); + tc_settle_windows(); + tc_cap_is_trace_only(); + tc_chip_interaction(); + + printf("\n=== NET Listener 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_listener/run_unit_test.py b/test/host/net_listener/run_unit_test.py new file mode 100644 index 00000000..2a1c936e --- /dev/null +++ b/test/host/net_listener/run_unit_test.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""NET 层监听自动重建(net_listener)的主机侧单元测试运行器(L0.5,纯标准库)。 + +做什么: + 用本机 MSVC 编译 test/host/net_listener/net_listener_test.c(以 #include 纳入生产 + 源 Drivers/BSP/NET/net_listener.c 与共享假芯片 test/host/common/ch395f_fake.c), + 在 PC 上离线验证自动重开监听的整套闸门:请求去重、五种放弃条件、挂起 recv/send + 让路、30ms 退避、400/100ms 两级沉降窗、800ms 上限只留痕、2000ms 预算交对账器、 + 接收区清理的 256B 钳位与 16 轮上限、重建后的 peer 簿记与芯片调用顺序。 + 工具链与判定委托 test/host/common/unit_test_harness.py。 + +为什么要主机侧单测(而不是只用台架): + CH395F 是单连接 socket——监听与数据通道复用同一号,断线后必须重开成 LISTEN,否则 + 端口"连接被拒"。而重开时机取决于"芯片此刻报什么状态",这些状态真机上一闪而过, + 要凑齐对端行为、时序与总线才见得到一次。四道时间闸门(30/400·100/800/2000ms)互相 + 牵制,台架无法稳定复现;把 HAL_GetTick 做成显式量后,399ms 与 400ms 的差别就是一行 + 断言。GET_SOCK_INT_STATUS 是整体读即清("顺手全量清一遍"会把别的在用 socket 的门 + 焊死),必须由假芯片复刻才照得出来。 + +桩的边界:net_listener.h / net_types.h / net_config.h / net_pending.h 用真实头(net_sock_t + 字段名与各枚举一改即编译期报错);net_socket.h 是桩(真实那份带 netTask 消息队列, + PC 上编不动),但 net_set_errno 与 NET_ERR_RETURN_1 的写法照抄真实头同名宏; + ch395f.h 只做转发,真身在 test/host/common/ch395f_fake.h/.c —— NET 域三个模块共享 + 一份假芯片,签名与常量只有一处定义。main.h / cmsis_os.h / FreeRTOS.h / task.h / + test_config.h / net_latency_test.h / dbg_log.h 是本目录桩;刻意不定义 + TEST_LATENCY_PROBE(延迟探针不是被测对象,且它要写真实时间)。 + +用法(仓库根目录): + python test/host/net_listener/run_unit_test.py + +退出码:0=PASS / 1=测试失败 / 2=环境错误。 + +设计依据:docs/网络/网络自愈架构设计.md §4.10、docs/测试说明/主机单测覆盖率口径.md §9(假 CH395F 实施规格) +创建日期: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 必须最先(覆盖 ch395f.h / net_socket.h / main.h / dbg_log.h); +# common 提供共享假芯片;NET 目录提供真实的 net_types.h / net_pending.h 等。 +BUILD = TestBuild( + name="NET Listener Tests", + src=os.path.join(HERE, "net_listener_test.c"), + include_dirs=[ + os.path.join(HERE, "stub"), + os.path.join(ROOT, "test", "host", "common"), + os.path.join(ROOT, "Drivers", "BSP", "NET"), + HERE, + ], + summary_marker="=== NET Listener Tests:", +) + +if __name__ == "__main__": + sys.exit(run_host_unit_test(BUILD)) + diff --git a/test/host/net_listener/stub/FreeRTOS.h b/test/host/net_listener/stub/FreeRTOS.h new file mode 100644 index 00000000..b7590a95 --- /dev/null +++ b/test/host/net_listener/stub/FreeRTOS.h @@ -0,0 +1,19 @@ +/* + * 模块名称:主机侧单测桩 - FreeRTOS.h(net_listener 模块专用) + * 模块功能:本模块不调任何内核 API,但真实 net_pending.h 的注册类原型里带 + * TaskHandle_t 参数(include 链绕不开),故这里给出它的宿主等价物。 + * ⚠ 仅供主机侧单测使用,不参与 MCU 构建。 + * TaskHandle_t 在 FreeRTOS 里就是 void * 不透明句柄,这里保持一致——它只出现在 + * 原型签名中、net_listener.c 从不传它,所以宿主侧用什么表示都不影响被测语义; + * 但**原型必须能编**,否则真实 net_pending.h 就进不来(进不来就丢掉接口真实性)。 + * 创建日期:2026-10-23 + */ +#ifndef FREERTOS_H +#define FREERTOS_H + +#include +#include + +typedef void *TaskHandle_t; /* 与 FreeRTOS 一致:不透明指针句柄 */ + +#endif /* FREERTOS_H */ diff --git a/test/host/net_listener/stub/ch395f.h b/test/host/net_listener/stub/ch395f.h new file mode 100644 index 00000000..dab4fe65 --- /dev/null +++ b/test/host/net_listener/stub/ch395f.h @@ -0,0 +1,11 @@ +/* + * 模块名称:主机侧单测桩 - ch395f.h(net_listener 模块入口) + * 模块功能:本文件**只做转发**——真正的假芯片在 test/host/common/ch395f_fake.h/.c。 + * ⚠ 转发而不是各写一份:NET 域三个模块共用同一份假芯片,签名与常量只有一处定义, + * 避免"三份桩各自漂移、假件与被测代码同时错却互相圆场"。 + * 创建日期:2026-10-23 + */ +#ifndef CH395F_H +#define CH395F_H +#include "ch395f_fake.h" +#endif /* CH395F_H */ diff --git a/test/host/net_listener/stub/cmsis_os.h b/test/host/net_listener/stub/cmsis_os.h new file mode 100644 index 00000000..e2742671 --- /dev/null +++ b/test/host/net_listener/stub/cmsis_os.h @@ -0,0 +1,10 @@ +/* + * 模块名称:主机侧单测桩 - cmsis_os.h(net_listener 模块专用) + * 模块功能:空壳。net_listener.c include 它但不用内核 API(重建在 tick 里同步做完)。 + * ⚠ 仅供主机侧单测使用,不参与 MCU 构建。 + * 创建日期:2026-10-23 + */ +#ifndef CMSIS_OS_H +#define CMSIS_OS_H +#include +#endif /* CMSIS_OS_H */ diff --git a/test/host/net_listener/stub/dbg_log.h b/test/host/net_listener/stub/dbg_log.h new file mode 100644 index 00000000..a1e3232d --- /dev/null +++ b/test/host/net_listener/stub/dbg_log.h @@ -0,0 +1,16 @@ +/* + * 模块名称:主机侧单测桩 - dbg_log.h(net_listener 模块专用) + * 模块功能:DBG_* 换成计数,让"只在第一次打日志""上限只留痕一次""预算耗尽才报 ERROR" + * 这三条日志契约可断言——它们正是排期登记里"沉降窗 800ms 上限只留痕"的原话。 + * ⚠ 仅供主机侧单测使用,不参与 MCU 构建。 + * 创建日期:2026-10-23 + */ +#ifndef DBG_LOG_H +#define DBG_LOG_H +extern int g_nlis_info_count; +extern int g_nlis_error_count; +extern int g_nlis_debug_count; +#define DBG_INFO(fmt, ...) do { (void)(fmt); g_nlis_info_count++; } while (0) +#define DBG_DEBUG(fmt, ...) do { (void)(fmt); g_nlis_debug_count++; } while (0) +#define DBG_ERROR(fmt, ...) do { (void)(fmt); g_nlis_error_count++; } while (0) +#endif /* DBG_LOG_H */ diff --git a/test/host/net_listener/stub/main.h b/test/host/net_listener/stub/main.h new file mode 100644 index 00000000..877990f6 --- /dev/null +++ b/test/host/net_listener/stub/main.h @@ -0,0 +1,12 @@ +/* + * 模块名称:主机侧单测桩 - main.h(net_listener 模块专用) + * 模块功能:只给 HAL_GetTick(沉降窗/退避/预算三条时间轴的唯一致度源)。 + * ⚠ 仅供 test/host/net_listener/ 使用,不参与 MCU 构建。测试把 tick 做成"写多少 + * 就是多少"的显式量,于是 400ms / 100ms / 800ms / 2000ms 四条线都能精确卡点。 + * 创建日期:2026-10-23 + */ +#ifndef MAIN_H +#define MAIN_H +#include +uint32_t HAL_GetTick(void); +#endif /* MAIN_H */ diff --git a/test/host/net_listener/stub/net_latency_test.h b/test/host/net_listener/stub/net_latency_test.h new file mode 100644 index 00000000..1b13ea26 --- /dev/null +++ b/test/host/net_listener/stub/net_latency_test.h @@ -0,0 +1,9 @@ +/* + * 模块名称:主机侧单测桩 - net_latency_test.h(net_listener 模块专用) + * 模块功能:空壳,配合 TEST_LATENCY_PROBE 不定义即被全部条件编译掉。 + * ⚠ 仅供主机侧单测使用,不参与 MCU 构建。 + * 创建日期:2026-10-23 + */ +#ifndef NET_LATENCY_TEST_H +#define NET_LATENCY_TEST_H +#endif /* NET_LATENCY_TEST_H */ diff --git a/test/host/net_listener/stub/net_socket.h b/test/host/net_listener/stub/net_socket.h new file mode 100644 index 00000000..8ec7ecf8 --- /dev/null +++ b/test/host/net_listener/stub/net_socket.h @@ -0,0 +1,20 @@ +/* + * 模块名称:主机侧单测桩 - net_socket.h(NET 域共享形态) + * 模块功能:net_listener.c 需要 socket 表项类型与三个入口:net_get_sock()(本表项 + * 可变指针,重建时要写回簿记)、net_set_errno() 与 NET_ERR_RETURN_1()。 + * 状态类型与错误码常量一律取**真实** net_types.h(字段名/取值一改即编译期 + * 报错);net_set_errno 与 NET_ERR_RETURN_1 的写法照抄真实 net_socket.h:528/532。 + * ⚠ 仅供主机侧单测使用,不参与 MCU 构建。刻意不 include 真实 net_socket.h:它带着 + * 整条 netTask 消息队列与芯片依赖,主机侧编不动。 + * 创建日期:2026-10-23 + */ +#ifndef NET_SOCKET_H +#define NET_SOCKET_H + +#include "net_types.h" + +net_sock_t *net_get_sock(int sockfd); +int net_set_errno(int err); +#define NET_ERR_RETURN_1(err) (net_set_errno(err), -1) + +#endif /* NET_SOCKET_H */ diff --git a/test/host/net_listener/stub/task.h b/test/host/net_listener/stub/task.h new file mode 100644 index 00000000..190841d3 --- /dev/null +++ b/test/host/net_listener/stub/task.h @@ -0,0 +1,9 @@ +/* + * 模块名称:主机侧单测桩 - task.h(net_listener 模块专用) + * 模块功能:空壳。 + * ⚠ 仅供主机侧单测使用,不参与 MCU 构建。 + * 创建日期:2026-10-23 + */ +#ifndef TASK_H +#define TASK_H +#endif /* TASK_H */ diff --git a/test/host/net_listener/stub/test_config.h b/test/host/net_listener/stub/test_config.h new file mode 100644 index 00000000..f00c70c1 --- /dev/null +++ b/test/host/net_listener/stub/test_config.h @@ -0,0 +1,10 @@ +/* + * 模块名称:主机侧单测桩 - test_config.h(net_listener 模块专用) + * 模块功能:空壳。net_listener.c 只在 TEST_LATENCY_PROBE 打开时才用延迟探针宏, + * 主机单测刻意**不开**该宏(探针不是被测对象,且它要写真实时间)。 + * ⚠ 仅供主机侧单测使用,不参与 MCU 构建。 + * 创建日期:2026-10-23 + */ +#ifndef TEST_CONFIG_H +#define TEST_CONFIG_H +#endif /* TEST_CONFIG_H */ From b4441f0e1e512c3060da45defb30e3605a978aba Mon Sep 17 00:00:00 2001 From: Wanderingss <1624155937@qq.com> Date: Fri, 23 Oct 2026 22:40:00 +0800 Subject: [PATCH 11/15] =?UTF-8?q?test(NET):=20net=5Fpending=20=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=EF=BC=8843=20=E6=9D=A1=EF=BC=8C=E8=A6=86=E7=9B=96=208?= =?UTF-8?q?7.1%=EF=BC=89=EF=BC=8C=E9=92=89=E4=BD=8F=E9=80=9A=E7=9F=A5?= =?UTF-8?q?=E5=80=BC=E5=9B=9E=E4=BC=A0=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 假 CH395F 的第二批消费者。新增 test/host/net_pending/,net_pending.c 覆盖 169/194 = 87.1%。共享假芯片补了**缓冲尺寸宏链的同构转抄**(RAM_BLOCK 512 / SEND_BLOCKS 2 → SEND_BUF 1024;RECV_BLOCKS 4 → RECV_BUF 2048 → SPI_DMA_BUF 2052 → SPI_DMA_MAX_PAYLOAD 2048),于是 net_pending.h 里的 NET_SEND_CHUNK_MAX 是**真值 1024**,分块断言测的是芯片上界而不是假件自己发明的数字。 本模块的核心契约是跨上下文回传:AGENTS 模块约定写着 net_* 的错误码经**任务通知值** 回传(net_msg_t 按值拷入队列,netTask 改的是自己那份副本,调用方永远看不到;也勿给 net_msg_t 加回传字段,否则调用方读到栈垃圾)。台架读不到通知值,所以把 xTaskNotify 做成可观测假件(记目标句柄与通知值)之后,这条契约才第一次被真正验证。 钉住的契约: - 注册闸门:越界 fd 报 BADF;同 fd 已有挂起报 BUSY;caller 为空报 BUSY;**属主任务 自己发起被拒**(自己等自己等于死锁 netTask);to_ms=0 记为无限等(不置 deadline)。 - 到达与截断:读量钳在调用方缓冲内(绝不越界写别人的 buf);readable 但芯片没数据时 只清标志继续等,不误交 0 字节。 - 收尾四条分支:还有残留先把最后一包读走再收尾(**不丢尾包**);干净结束报 0 并按 auto_relisten 请求重开监听(不自动重开的不被偷偷要求重建);released_abnormal 报 CONNRESET 而不是 0(调用方要能分辨好坏收尾);session_dead 与 CLOSED 同路。 - settle_close:没挂起时直接返回不打扰芯片;有残留交还字节数;无残留报 CONNRESET。 - 挂起发送:参数校验(buf 空 / len<=0 / sent<0 / sent>=len 报 INVAL);单次写入钳在 NET_SEND_CHUNK_MAX;**deadline 每写一轮就续期**(还在推进就不该被 1000ms 掐死, FTP 上传大文件最容易撞这条);停滞不前进才报 TIMEDOUT;槽位消失或会话塌掉都**交还 已发字节数**(不是错误码,调用方据此算进度);cancel 留痕且幂等;**clear 不发通知** (与 recv 侧同一条纪律,不给调用方假答复)。 自查:42/43 首跑,唯一红的是**我的测试构造缺陷**——无限等那条没先把槽位置成在用, 被 in_use==0 兜底分支提前收口,测不到时间轴;补 mk_sock 后 43/43。reset_all 从上一 批的教训直接带过来(连被测模块的 static 挂起表与属主句柄一起复位),这次没再红。 覆盖率与台账:套件 23 → 24 全绿;files_tested 27 → 28;未触及 .c 39 → 38;分母 3,572 → 3,766 行;line=3297/3766(87.5%)、func=255/282(90.4%)。恒等式 38 = 4(P1 剩) + 5(P2) + 29(P3) 仍咬合。编号补建 TC-HOST-NETPEND(游标 040), 族数 17 → 18;注册表仍只做增量插入。 自查门禁:test-cppcheck Passed;check_ids --strict 通过;doc-links 35 处(= 存量); 12 个新增/改动文件 BOM/CR/U+FFFD/行尾逐项净。零生产代码改动,未跑 @build。 --all-files 仍只有 mem-numbers / mem-budget(.map 台账漂移,等 Lead 合并后统一回填) 与 ui-golden(ref/*.bin 不入库)三条失败,与本分支改动无关。 --- docs/测试说明/主机单测覆盖率口径.md | 10 +- scripts/id_registry.json | 22 ++ scripts/test_coverage_baseline.json | 40 +- test/host/common/ch395f_fake.h | 14 + test/host/net_pending/net_pending_test.c | 467 +++++++++++++++++++++++ test/host/net_pending/run_unit_test.py | 59 +++ test/host/net_pending/stub/FreeRTOS.h | 28 ++ test/host/net_pending/stub/ch395f.h | 10 + test/host/net_pending/stub/dbg_log.h | 18 + test/host/net_pending/stub/main.h | 11 + test/host/net_pending/stub/net_socket.h | 18 + test/host/net_pending/stub/task.h | 22 ++ 12 files changed, 702 insertions(+), 17 deletions(-) create mode 100644 test/host/net_pending/net_pending_test.c create mode 100644 test/host/net_pending/run_unit_test.py create mode 100644 test/host/net_pending/stub/FreeRTOS.h create mode 100644 test/host/net_pending/stub/ch395f.h create mode 100644 test/host/net_pending/stub/dbg_log.h create mode 100644 test/host/net_pending/stub/main.h create mode 100644 test/host/net_pending/stub/net_socket.h create mode 100644 test/host/net_pending/stub/task.h diff --git a/docs/测试说明/主机单测覆盖率口径.md b/docs/测试说明/主机单测覆盖率口径.md index b992609e..42f02b3d 100644 --- a/docs/测试说明/主机单测覆盖率口径.md +++ b/docs/测试说明/主机单测覆盖率口径.md @@ -232,9 +232,13 @@ 四道时间闸门全钉住(30ms 退避 / 400 与 100ms 两级沉降 / 800ms 上限只留痕 / 2000ms 预算交对账器),另有接收区 256B 钳位与 16 轮上限、"清接收→关断→读即清→读状态"的 调用顺序、五种静默放弃条件 | -| NET 剩下两块(`net_pending`/`net_events`) | ⬜ 未开始 | 假芯片已就绪,直接接即可 | - `net_pending` 要 `net_listener_request` 的记录型假件;`net_events` 依赖最多 - (`net_sock_raw`/`net_listener_settle`/`net_pending_cancel`/`xTaskGetTickCount`)放最后 | +| NET 三块之二:`net_pending.c` | ✅ 完成(**169/194 = 87.1%**) | `test/host/net_pending`(43 条) | + 跨上下文契约(**错误码走任务通知值**,靠可观测的 `xTaskNotify` 假件才测得到)、 + 收尾四条分支(不丢尾包 / 干净报 0 / 异常释放报 CONNRESET / 槽位没了报 CONNRESET)、 + 发送分块钳在 `NET_SEND_CHUNK_MAX=1024`(真值,非假件自造)、`deadline` 每轮续期 + (长文件不被 1000ms 掐死)、停滞超时、取消与 `clear` 不发通知的纪律 | +| NET 最后一块(`net_events.c`) | ⬜ 未开始 | 假芯片与假件已就绪;它依赖最多 + (`net_sock_raw`/`net_listener_settle`/`net_pending_cancel`/`xTaskGetTickCount`) | | 104 服务层 | ⬜ 未开始 | — | 桩最省(复用 `cmsis_os2.h` + `net_socket.h`) | 本批照出的两处**现状**(只登记、**未改语义**;全库唯一调用点 `test/board/net_test_task.c` diff --git a/scripts/id_registry.json b/scripts/id_registry.json index 44d2ca1f..020777ec 100644 --- a/scripts/id_registry.json +++ b/scripts/id_registry.json @@ -1768,6 +1768,28 @@ "subitem_ok": true, "owner_note": "B 层口径(2026-10-21 批 3 定:这些号在代码目录外零消费者,属主登记处 = 单测代码本身,不逐号补文档);2026-10-23 随覆盖率口径 §9 假 CH395F 立项第一批补登记", "cursor_note": "游标按实测最大号收录(2026-10-23):越界即视为未登记的冒号牌" + }, + { + "name": "TC-HOST-NETPEND", + "class": "TC", + "title": "主机侧单测用例号:NET 层挂起 recv 与挂起 send(test/host/net_pending)", + "pattern": "\\bTC-NETPEND-[0-9]{1,4}[a-z]?\\b", + "digit_width": [ + 2, + 4 + ], + "owner_docs_glob": [ + "test/host/net_pending/*" + ], + "scope": "global", + "frozen": false, + "cursor": "TC-NETPEND-040", + "cursor_owner_only": true, + "check_dangling": true, + "allow_orphans": true, + "subitem_ok": true, + "owner_note": "B 层口径(2026-10-21 批 3 定:这些号在代码目录外零消费者,属主登记处 = 单测代码本身,不逐号补文档);2026-10-23 随覆盖率口径 §9 假 CH395F 第二批补登记", + "cursor_note": "游标按实测最大号收录(2026-10-23):越界即视为未登记的冒号牌" } ], "unmanaged_bare": { diff --git a/scripts/test_coverage_baseline.json b/scripts/test_coverage_baseline.json index 5ddf8060..563836d2 100644 --- a/scripts/test_coverage_baseline.json +++ b/scripts/test_coverage_baseline.json @@ -192,6 +192,14 @@ "lines_covered": 125, "lines_valid": 126 }, + "Drivers/BSP/NET/net_pending.c": { + "func_rate": 0.75, + "funcs_covered": 12, + "funcs_valid": 16, + "line_rate": 0.8711, + "lines_covered": 169, + "lines_valid": 194 + }, "Drivers/BSP/NET/net_select.c": { "func_rate": 1.0, "funcs_covered": 5, @@ -307,6 +315,13 @@ "lines_total": 126, "ok": true }, + "net_pending": { + "files": { + "Drivers/BSP/NET/net_pending.c": 194 + }, + "lines_total": 194, + "ok": true + }, "net_select": { "files": { "Drivers/BSP/NET/net_select.c": 117 @@ -426,22 +441,23 @@ "Drivers/BSP/NET/lftpd/lftpd_io.c", "Drivers/BSP/NET/lftpd/lftpd_string.c", "Drivers/BSP/NET/net_listener.c", + "Drivers/BSP/NET/net_pending.c", "Drivers/BSP/NET/net_select.c", "Drivers/BSP/NETCFG/ftp_cfg.c", "Drivers/BSP/NETCFG/net_cfg.c" ], "totals": { "files_generated_excluded": 5, - "files_tested": 27, - "files_untested": 117, - "func_rate": 0.9135, - "funcs_covered": 243, - "funcs_valid": 266, - "line_rate": 0.8757, - "lines_covered": 3128, - "lines_valid": 3572, - "modules_ok": 23, - "modules_total": 23 + "files_tested": 28, + "files_untested": 116, + "func_rate": 0.9043, + "funcs_covered": 255, + "funcs_valid": 282, + "line_rate": 0.8755, + "lines_covered": 3297, + "lines_valid": 3766, + "modules_ok": 24, + "modules_total": 24 }, "untested_sources": [ { @@ -476,10 +492,6 @@ "code_lines": 300, "path": "Drivers/BSP/UI/ui_content_page.c" }, - { - "code_lines": 287, - "path": "Drivers/BSP/NET/net_pending.c" - }, { "code_lines": 281, "path": "App/task/default_task.c" diff --git a/test/host/common/ch395f_fake.h b/test/host/common/ch395f_fake.h index 8f612326..cfd0a516 100644 --- a/test/host/common/ch395f_fake.h +++ b/test/host/common/ch395f_fake.h @@ -49,6 +49,20 @@ #define CH395F_TCP_LISTEN 0x01U #define CH395F_TCP_ESTABLISHED 0x05U +/* 发送/接收缓冲尺寸宏链:**与真实 ch395f.h 同构转抄**(2026-10-23 逐条核对)。 + * net_pending.h 里的 NET_SEND_CHUNK_MAX 由这两个宏推导,是"一次最多往芯片写多少" + * 的真实上界,假件不能自己发明数字(否则分块测试就是在测假件的常量): + * RAM_BLOCK_SIZE 512 / SEND_BLOCKS 2 → SEND_BUF 1024 + * RECV_BLOCKS 4 → RECV_BUF 2048 → SPI_DMA_BUF 2052 → SPI_DMA_MAX_PAYLOAD 2048 + * ⇒ NET_SEND_CHUNK_MAX = min(1024, 2048) = 1024 */ +#define CH395F_RAM_BLOCK_SIZE 512U +#define CH395F_SEND_BLOCKS 2U +#define CH395F_SEND_BUF_SIZE_BYTES (CH395F_SEND_BLOCKS * CH395F_RAM_BLOCK_SIZE) +#define CH395F_RECV_BLOCKS 4U +#define CH395F_RECV_BUF_SIZE_BYTES (CH395F_RECV_BLOCKS * CH395F_RAM_BLOCK_SIZE) +#define CH395F_SPI_DMA_BUF_SIZE (CH395F_RECV_BUF_SIZE_BYTES + 4U) +#define CH395F_SPI_DMA_MAX_PAYLOAD (CH395F_SPI_DMA_BUF_SIZE - 4U) + #define CH395F_FAKE_SOCK_MAX 8 /* 与 NET_MAX_SOCKETS 同量级,测试自用 */ #define CH395F_FAKE_QUEUE_LEN 24 #define CH395F_FAKE_LOG_LEN 96 diff --git a/test/host/net_pending/net_pending_test.c b/test/host/net_pending/net_pending_test.c new file mode 100644 index 00000000..eef73a0c --- /dev/null +++ b/test/host/net_pending/net_pending_test.c @@ -0,0 +1,467 @@ +/* + * 模块名称:NET 层挂起 recv / 挂起 send(net_pending)主机侧单元测试 + * 模块功能:在 PC 上离线验证 Drivers/BSP/NET/net_pending.c 的两条挂起队列:注册闸门 + * (越界/重复/执行任务自己发起)、**错误码经任务通知值回传**这条跨上下文 + * 契约、收数据到达与截断、超时、会话塌掉的四种收尾分支(不丢尾包 / 干净 + * 结束报 0 / 异常释放报 CONNRESET / 槽位消失报 CONNRESET)、settle_close 的 + * 收尾语义,以及发送侧的分块上限、停滞判定、deadline 续期与取消语义。 + * + * 被测对象:真实源文件 ../../../Drivers/BSP/NET/net_pending.c(#include 原样纳入)。 + * net_pending.h / net_types.h / net_config.h / net_listener.h 全用**真实头** + * (TaskHandle_t 入参、NET_PENDING_REGISTERED、NET_SEND_TIMEOUT_MS、 + * net_listener_req_t 枚举一改就编译期报错)。假件: + * - 假 CH395F 芯片(test/host/common/ch395f_fake.h/.c,含与真实头同构转抄 + * 的缓冲尺寸宏链,NET_SEND_CHUNK_MAX 因此是真值 1024 而非假件自造); + * - **可观测的 xTaskNotify**(记目标句柄与通知值)与固定句柄的 + * xTaskGetCurrentTaskHandle; + * - 记录型 net_listener_request;可控时基;可控 socket 表数组(tick 入参); + * 可控"芯片此刻还有多少字节可读";dbg_log 计数;net_set_errno 记录。 + * + * 为什么要主机侧单测(而不是只靠台架): + * 1. AGENTS 模块约定写着:net_* 公开 API 的错误码经**任务通知值**回传,因为 net_msg_t + * 是**按值拷入队列**的,netTask 改的是自己那份副本,调用方永远看不到;也**勿给 + * net_msg_t 加回传字段**(加了只会让调用方读到栈垃圾)。这条契约在真机上表现为 + * "偶发拿到 0 或正数却不知道为什么",日志无痕;在 PC 上必须把 xTaskNotify 做成 + * 可观测的才谈得上验证——台架根本读不到通知值。 + * 2. 挂起 recv 的收尾有**四条**分支(还有尾包 / 干净结束报 0 / 异常释放报 CONNRESET / + * 槽位没了报 CONNRESET),差一条就是"对端已断而我方任务还挂着"或"最后一包数据 + * 被丢掉"。真机要凑齐"关闭瞬间芯片接收区还剩字节"这种窗口极难。 + * 3. 发送侧分块上限 = min(芯片发送缓冲, SPI DMA 上界):一次写太多会踩芯片 FIFO, + * 表现为"大包发送偶发数据错位";而 deadline 每写一轮就续期,写错就会把**长文件 + * 在传输中途掐死**(FTP 上传大文件最容易撞)。两者都要能精确数着字节测。 + * + * 覆盖点:TC-NETPEND-001..006 注册闸门;007..018 到达/截断/超时/四条收尾/取消与清理; + * 019..021 settle_close;030..040 挂起发送(参数校验、分块、续期、停滞、取消)。 + * + * 通过:全部 TC PASS(main 返回 0)。 + * 入口:int main(void),返回 0=PASS / 1=FAIL(由 run 脚本判读)。 + * + * 创建日期:2026-10-23 + */ + +#include +#include + +#include "net_pending.h" +#include "net_listener.h" +#include "net_socket.h" +#include "ch395f.h" + +#include "ch395f_fake.c" +#include "net_pending.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) + +int g_npd_info_count; +int g_npd_error_count; +int g_npd_debug_count; + +/* ============ 可观测的任务通知(本模块最重要的一条契约靠它才测得到) ============ */ + +static int g_owner_obj; +static int g_caller_obj; +static int g_notify_n; +static void *g_notify_target[16]; +static uint32_t g_notify_value[16]; + +TaskHandle_t xTaskGetCurrentTaskHandle(void) +{ + return (TaskHandle_t)&g_owner_obj; +} + +BaseType_t xTaskNotify(TaskHandle_t xTaskToNotify, uint32_t ulValue, eNotifyAction eAction) +{ + (void)eAction; + if (g_notify_n < (int)(sizeof(g_notify_target) / sizeof(g_notify_target[0]))) { + g_notify_target[g_notify_n] = (void *)xTaskToNotify; + g_notify_value[g_notify_n] = ulValue; + } + g_notify_n++; + return 1; +} + +/* ============ 记录型监听请求 ============ */ + +static int g_lis_req_n; +static int g_lis_req_sock; +static int g_lis_req_reason; + +void net_listener_request(int sockfd, net_listener_req_t reason) +{ + g_lis_req_n++; + g_lis_req_sock = sockfd; + g_lis_req_reason = (int)reason; +} + +void net_listener_settle(int sockfd, net_listener_settle_t level) { (void)sockfd; (void)level; } +void net_listener_clear(int sockfd) { (void)sockfd; } +void net_listener_tick(void) { } + +/* ============ 可控时基与 errno ============ */ + +static uint32_t g_now; +static int g_errno_last; +static int g_errno_calls; + +uint32_t HAL_GetTick(void) { return g_now; } + +int net_set_errno(int err) +{ + g_errno_last = err; + g_errno_calls++; + return err; +} + +/* ============ 可控 socket 表(tick 的入参) ============ */ + +static net_sock_t g_socks[NET_MAX_SOCKETS]; +static uint8_t g_rbuf[64]; +static uint8_t g_wbuf[4096]; + +static void mk_sock(int fd, net_sock_state_t st) +{ + g_socks[fd].in_use = 1u; + g_socks[fd].state = st; + g_socks[fd].type = NET_SOCK_STREAM; + g_socks[fd].auto_relisten = 1u; + g_socks[fd].local_port = 2404u; +} + +static void reset_all(void) +{ + int i; + + (void)memset(g_socks, 0, sizeof(g_socks)); + (void)memset(g_rbuf, 0, sizeof(g_rbuf)); + g_now = 1000u; + g_errno_last = 0; + g_errno_calls = 0; + g_notify_n = 0; + g_lis_req_n = 0; + g_lis_req_sock = -1; + g_lis_req_reason = -1; + g_npd_info_count = 0; + g_npd_error_count = 0; + g_npd_debug_count = 0; + ch395f_fake_reset(); + /* 被测模块自己的 static 必须复位:两张挂起表 + 属主句柄 + (net_listener 批次在这里红过 9 条,同样的坑不踩第二遍)。 */ + for (i = 0; i < NET_MAX_SOCKETS; i++) { + s_net_pending[i].active = 0u; + s_net_send[i].active = 0u; + } + s_owner_task = (TaskHandle_t)&g_owner_obj; +} + +static void tc_recv_register(void) +{ + int rc; + + printf("-- TC-NETPEND-001..006 recv register --\n"); + reset_all(); + rc = net_pending_register(-1, g_rbuf, 10, 100u, (TaskHandle_t)&g_caller_obj); + CHECK((rc == -1) && (g_errno_last == NET_ERR_BADF), "TC-NETPEND-001 下越界 fd 报 BADF"); + rc = net_pending_register(NET_MAX_SOCKETS, g_rbuf, 10, 100u, (TaskHandle_t)&g_caller_obj); + CHECK((rc == -1) && (g_errno_last == NET_ERR_BADF), "TC-NETPEND-001b 上越界同样拦下"); + + reset_all(); + rc = net_pending_register(0, g_rbuf, 10, 100u, (TaskHandle_t)&g_caller_obj); + CHECK(rc == NET_PENDING_REGISTERED, "TC-NETPEND-005 注册成功回 0x70000000 标记"); + rc = net_pending_register(0, g_rbuf, 10, 100u, (TaskHandle_t)&g_caller_obj); + CHECK((rc == -1) && (g_errno_last == NET_ERR_BUSY), + "TC-NETPEND-002 同 fd 已有挂起就拒绝(一条 socket 只挂一个 recv)"); + + reset_all(); + rc = net_pending_register(0, g_rbuf, 10, 100u, NULL); + CHECK((rc == -1) && (g_errno_last == NET_ERR_BUSY), + "TC-NETPEND-003 caller 为空拒绝(没有回传目标就别挂起)"); + rc = net_pending_register(0, g_rbuf, 10, 100u, (TaskHandle_t)&g_owner_obj); + CHECK((rc == -1) && (g_errno_last == NET_ERR_BUSY), + "TC-NETPEND-004 属主任务自己发起被拒(自己等自己 = 死锁 netTask)"); + + reset_all(); + /* 必须先把槽位置成"在用且已建立":否则 tick 第一条兜底(in_use == 0 → + * CONNRESET)就会把挂起收掉,测的就不是时间轴了。 */ + mk_sock(0, NET_SOCK_STATE_ESTABLISHED); + (void)net_pending_register(0, g_rbuf, 10, 0u, (TaskHandle_t)&g_caller_obj); + CHECK((s_net_pending[0].infinite == 1u) && (s_net_pending[0].deadline == 0u), + "TC-NETPEND-006 to_ms=0 记为无限等(不置 deadline)"); + g_now = 999999u; + net_pending_tick(g_socks); + CHECK((s_net_pending[0].active == 1u) && (g_notify_n == 0), + "TC-NETPEND-006b 无限等不被时间掐掉(也不会偷偷发通知)"); +} + +static void tc_recv_paths(void) +{ + printf("-- TC-NETPEND-007..018 recv completion --\n"); + reset_all(); + mk_sock(0, NET_SOCK_STATE_ESTABLISHED); + g_socks[0].readable = 1u; + (void)net_pending_register(0, g_rbuf, 10, 500u, (TaskHandle_t)&g_caller_obj); + ch395f_fake_push_recv_len(0, 5u); + net_pending_tick(g_socks); + CHECK((g_notify_n == 1) && (g_notify_value[0] == 5u) && + (g_notify_target[0] == (void *)&g_caller_obj) && + (g_socks[0].recv_bytes == 5u) && (g_socks[0].readable == 0u) && + (s_net_pending[0].active == 0u), + "TC-NETPEND-007 数据到达:字节数经通知值回传给发起任务"); + + reset_all(); + mk_sock(0, NET_SOCK_STATE_ESTABLISHED); + g_socks[0].readable = 1u; + (void)net_pending_register(0, g_rbuf, 10, 500u, (TaskHandle_t)&g_caller_obj); + ch395f_fake_push_recv_len(0, 100u); + net_pending_tick(g_socks); + CHECK((g_notify_value[0] == 10u) && (g_chip.last_read_len == 10u), + "TC-NETPEND-008 读量钳在调用方缓冲内(绝不越界写别人的 buf)"); + + reset_all(); + mk_sock(0, NET_SOCK_STATE_ESTABLISHED); + g_socks[0].readable = 1u; + (void)net_pending_register(0, g_rbuf, 10, 500u, (TaskHandle_t)&g_caller_obj); + net_pending_tick(g_socks); + CHECK((g_notify_n == 0) && (g_socks[0].readable == 0u) && + (s_net_pending[0].active == 1u), + "TC-NETPEND-009 空 readable:清标志继续等,不误交 0 字节"); + + reset_all(); + mk_sock(0, NET_SOCK_STATE_ESTABLISHED); + (void)net_pending_register(0, g_rbuf, 10, 100u, (TaskHandle_t)&g_caller_obj); + g_now = 1100u; + net_pending_tick(g_socks); + CHECK((g_notify_value[0] == (uint32_t)NET_ERR_TIMEDOUT) && + (g_errno_last == NET_ERR_TIMEDOUT) && (s_net_pending[0].active == 0u), + "TC-NETPEND-010 超时:错误码同时进通知值与 errno(跨上下文拿得到)"); + + reset_all(); + (void)net_pending_register(0, g_rbuf, 10, 500u, (TaskHandle_t)&g_caller_obj); + g_socks[0].in_use = 0u; + net_pending_tick(g_socks); + CHECK(g_notify_value[0] == (uint32_t)NET_ERR_CONNRESET, + "TC-NETPEND-011 槽位消失报 CONNRESET(不能让任务熬到超时才发现)"); + + reset_all(); + mk_sock(0, NET_SOCK_STATE_CLOSED); + (void)net_pending_register(0, g_rbuf, 20, 500u, (TaskHandle_t)&g_caller_obj); + ch395f_fake_push_recv_len(0, 12u); + net_pending_tick(g_socks); + CHECK((g_notify_value[0] == 12u) && (g_socks[0].recv_bytes == 12u) && + (g_lis_req_n == 0), + "TC-NETPEND-012 关闭但有残留:先把最后一包读走再收尾(不丢尾包)"); + + reset_all(); + mk_sock(0, NET_SOCK_STATE_CLOSED); + (void)net_pending_register(0, g_rbuf, 20, 500u, (TaskHandle_t)&g_caller_obj); + net_pending_tick(g_socks); + CHECK((g_notify_value[0] == 0u) && (g_lis_req_n == 1) && (g_lis_req_sock == 0) && + (g_lis_req_reason == (int)NET_LISTENER_REQ_PENDING), + "TC-NETPEND-013 干净结束:报 0 并请求重开监听(单连接 socket 必须回 LISTEN)"); + + reset_all(); + mk_sock(0, NET_SOCK_STATE_CLOSED); + g_socks[0].auto_relisten = 0u; + (void)net_pending_register(0, g_rbuf, 20, 500u, (TaskHandle_t)&g_caller_obj); + net_pending_tick(g_socks); + CHECK((g_notify_value[0] == 0u) && (g_lis_req_n == 0), + "TC-NETPEND-013b 不自动重开的 socket 不会被偷偷要求重建监听"); + + reset_all(); + mk_sock(0, NET_SOCK_STATE_CLOSED); + g_socks[0].released_abnormal = 1u; + (void)net_pending_register(0, g_rbuf, 20, 500u, (TaskHandle_t)&g_caller_obj); + net_pending_tick(g_socks); + CHECK(g_notify_value[0] == (uint32_t)NET_ERR_CONNRESET, + "TC-NETPEND-014 异常释放报 CONNRESET 而不是 0(调用方要能分辨好坏收尾)"); + + reset_all(); + mk_sock(0, NET_SOCK_STATE_ESTABLISHED); + g_socks[0].session_dead = 1u; + (void)net_pending_register(0, g_rbuf, 20, 500u, (TaskHandle_t)&g_caller_obj); + net_pending_tick(g_socks); + CHECK((g_notify_n == 1) && (g_lis_req_n == 1), + "TC-NETPEND-015 session_dead 与 CLOSED 同路(状态滞后时仍能收口)"); + + reset_all(); + mk_sock(0, NET_SOCK_STATE_ESTABLISHED); + (void)net_pending_register(0, g_rbuf, 10, 500u, (TaskHandle_t)&g_caller_obj); + net_pending_cancel(0, NET_ERR_CONNRESET); + CHECK((g_notify_value[0] == (uint32_t)NET_ERR_CONNRESET) && + (g_errno_last == NET_ERR_CONNRESET) && (s_net_pending[0].active == 0u), + "TC-NETPEND-016 取消:错误码经通知值送达并解除挂起"); + net_pending_cancel(0, NET_ERR_CONNRESET); + CHECK(g_notify_n == 1, "TC-NETPEND-016b 重复取消幂等(不给同一任务发两次通知)"); + + reset_all(); + mk_sock(0, NET_SOCK_STATE_ESTABLISHED); + mk_sock(2, NET_SOCK_STATE_ESTABLISHED); + (void)net_pending_register(0, g_rbuf, 10, 500u, (TaskHandle_t)&g_caller_obj); + (void)net_pending_register(2, g_rbuf, 10, 500u, (TaskHandle_t)&g_caller_obj); + net_pending_cancel_all(NET_ERR_CONNRESET); + CHECK((g_notify_n == 2) && (s_net_pending[0].active == 0u) && + (s_net_pending[2].active == 0u), + "TC-NETPEND-017 cancel_all 只通知挂着的那两个(网络整体复位时用)"); + + reset_all(); + mk_sock(0, NET_SOCK_STATE_ESTABLISHED); + (void)net_pending_register(0, g_rbuf, 10, 500u, (TaskHandle_t)&g_caller_obj); + net_pending_clear(0); + CHECK((s_net_pending[0].active == 0u) && (g_notify_n == 0), + "TC-NETPEND-018 clear 只解除挂起、不发通知(不给调用方假答复)"); +} + +static void tc_settle_close(void) +{ + int before; + + printf("-- TC-NETPEND-019..021 settle_close --\n"); + reset_all(); + mk_sock(0, NET_SOCK_STATE_CLOSED); + before = g_chip.read_calls + g_chip.get_status_calls; + net_pending_settle_close(0, &g_socks[0]); + CHECK((g_chip.read_calls + g_chip.get_status_calls) == before, + "TC-NETPEND-019 没挂起时直接返回(不打扰芯片)"); + + reset_all(); + mk_sock(0, NET_SOCK_STATE_CLOSED); + (void)net_pending_register(0, g_rbuf, 20, 500u, (TaskHandle_t)&g_caller_obj); + ch395f_fake_push_recv_len(0, 7u); + net_pending_settle_close(0, &g_socks[0]); + CHECK((g_notify_value[0] == 7u) && (g_socks[0].recv_bytes == 7u) && + (g_socks[0].readable == 0u), + "TC-NETPEND-020 收尾时把剩余字节读走并交还字节数(收口不丢数据)"); + + reset_all(); + mk_sock(0, NET_SOCK_STATE_CLOSED); + (void)net_pending_register(0, g_rbuf, 20, 500u, (TaskHandle_t)&g_caller_obj); + net_pending_settle_close(0, &g_socks[0]); + CHECK(g_notify_value[0] == (uint32_t)NET_ERR_CONNRESET, + "TC-NETPEND-021 无残留则报 CONNRESET(关闭收尾不该被当成读到 0 字节)"); +} + +static void tc_send_paths(void) +{ + int rc; + + printf("-- TC-NETPEND-030..040 send pending --\n"); + reset_all(); + rc = net_send_pending_register(-1, g_wbuf, 100, 0, (TaskHandle_t)&g_caller_obj); + CHECK((rc == -1) && (g_errno_last == NET_ERR_BADF), "TC-NETPEND-030 越界 fd 报 BADF"); + rc = net_send_pending_register(0, NULL, 100, 0, (TaskHandle_t)&g_caller_obj); + CHECK(g_errno_last == NET_ERR_INVAL, "TC-NETPEND-030b buf 为空报 INVAL"); + rc = net_send_pending_register(0, g_wbuf, 0, 0, (TaskHandle_t)&g_caller_obj); + CHECK(g_errno_last == NET_ERR_INVAL, "TC-NETPEND-030c len 非正报 INVAL"); + rc = net_send_pending_register(0, g_wbuf, 100, -1, (TaskHandle_t)&g_caller_obj); + CHECK(g_errno_last == NET_ERR_INVAL, "TC-NETPEND-030d sent 为负报 INVAL"); + rc = net_send_pending_register(0, g_wbuf, 100, 100, (TaskHandle_t)&g_caller_obj); + CHECK((g_errno_last == NET_ERR_INVAL) && (s_net_send[0].active == 0u), + "TC-NETPEND-030e sent>=len 报 INVAL(已发完的不必挂)"); + rc = net_send_pending_register(0, g_wbuf, 100, 50, NULL); + CHECK(g_errno_last == NET_ERR_BUSY, "TC-NETPEND-031 caller 为空报 BUSY"); + rc = net_send_pending_register(0, g_wbuf, 100, 50, (TaskHandle_t)&g_owner_obj); + CHECK(g_errno_last == NET_ERR_BUSY, "TC-NETPEND-031b 属主自己发报 BUSY"); + rc = net_send_pending_register(0, g_wbuf, 100, 50, (TaskHandle_t)&g_caller_obj); + CHECK(rc == NET_PENDING_REGISTERED, "TC-NETPEND-032 挂起发送注册成功"); + rc = net_send_pending_register(0, g_wbuf, 100, 50, (TaskHandle_t)&g_caller_obj); + CHECK(g_errno_last == NET_ERR_BUSY, "TC-NETPEND-031c 同 fd 只允许一条挂起发送"); + + reset_all(); + (void)net_send_pending_register(0, g_wbuf, 3000, 700, (TaskHandle_t)&g_caller_obj); + g_socks[0].in_use = 0u; + net_send_pending_tick(g_socks); + CHECK((g_notify_n == 1) && (g_notify_value[0] == 700u), + "TC-NETPEND-033 槽位消失回**已发字节数**(不是错误码,调用方据此算进度)"); + + reset_all(); + mk_sock(0, NET_SOCK_STATE_CLOSED); + (void)net_send_pending_register(0, g_wbuf, 3000, 700, (TaskHandle_t)&g_caller_obj); + net_send_pending_tick(g_socks); + CHECK((g_npd_error_count == 1) && (g_errno_last == NET_ERR_CONNRESET) && + (g_notify_value[0] == 700u), + "TC-NETPEND-034 会话塌了:留 ERROR + 报 CONNRESET,但仍把已发字节交回去"); + + reset_all(); + mk_sock(0, NET_SOCK_STATE_ESTABLISHED); + g_socks[0].send_ready = 1u; + (void)net_send_pending_register(0, g_wbuf, 3000, 0, (TaskHandle_t)&g_caller_obj); + net_send_pending_tick(g_socks); + CHECK((g_chip.write_calls == 1) && + (g_chip.last_read_len == (uint16_t)NET_SEND_CHUNK_MAX) && + (g_socks[0].send_bytes == (uint32_t)NET_SEND_CHUNK_MAX) && + (g_socks[0].send_ready == 0u) && (s_net_send[0].active == 1u), + "TC-NETPEND-035 单次写入钳在 NET_SEND_CHUNK_MAX(芯片发送缓冲上界)"); + + reset_all(); + mk_sock(0, NET_SOCK_STATE_ESTABLISHED); + (void)net_send_pending_register(0, g_wbuf, 3000, 0, (TaskHandle_t)&g_caller_obj); + g_socks[0].send_ready = 1u; + net_send_pending_tick(g_socks); + g_socks[0].send_ready = 1u; + net_send_pending_tick(g_socks); + g_socks[0].send_ready = 1u; + net_send_pending_tick(g_socks); + CHECK((g_notify_value[0] == 3000u) && (s_net_send[0].active == 0u) && + (g_chip.write_calls == 3), + "TC-NETPEND-036 分三块发完 3000 字节后交还总长度"); + + reset_all(); + mk_sock(0, NET_SOCK_STATE_ESTABLISHED); + (void)net_send_pending_register(0, g_wbuf, 3000, 0, (TaskHandle_t)&g_caller_obj); + g_now = 1900u; + g_socks[0].send_ready = 1u; + net_send_pending_tick(g_socks); + g_now = 2800u; + g_socks[0].send_ready = 1u; + net_send_pending_tick(g_socks); + CHECK((g_npd_error_count == 0) && (s_net_send[0].active == 1u) && + (g_chip.write_calls == 2), + "TC-NETPEND-038 每写一轮续期 deadline:还在推进就不该被 1000ms 掐死"); + + reset_all(); + mk_sock(0, NET_SOCK_STATE_ESTABLISHED); + (void)net_send_pending_register(0, g_wbuf, 3000, 0, (TaskHandle_t)&g_caller_obj); + g_now = 2000u; + net_send_pending_tick(g_socks); + CHECK((g_npd_error_count == 1) && (g_errno_last == NET_ERR_TIMEDOUT) && + (g_notify_value[0] == 0u), + "TC-NETPEND-037 停滞超时报 TIMEDOUT(对端不收时不能永久占着发送通道)"); + + reset_all(); + mk_sock(0, NET_SOCK_STATE_ESTABLISHED); + (void)net_send_pending_register(0, g_wbuf, 100, 40, (TaskHandle_t)&g_caller_obj); + net_send_pending_cancel(0, NET_ERR_CONNRESET); + CHECK((g_npd_error_count == 1) && (g_errno_last == NET_ERR_CONNRESET) && + (g_notify_value[0] == 40u) && (s_net_send[0].active == 0u), + "TC-NETPEND-039 取消挂起发送:留痕 + 交还已发字节"); + net_send_pending_cancel(0, NET_ERR_CONNRESET); + CHECK(g_notify_n == 1, "TC-NETPEND-039b 重复取消幂等"); + + reset_all(); + mk_sock(0, NET_SOCK_STATE_ESTABLISHED); + (void)net_send_pending_register(0, g_wbuf, 100, 40, (TaskHandle_t)&g_caller_obj); + net_send_pending_clear(0); + CHECK((s_net_send[0].active == 0u) && (g_notify_n == 0), + "TC-NETPEND-040 clear 不发通知(与 recv 侧同一条纪律)"); +} + +int main(void) +{ + printf("=== NET Pending Tests ===\n"); + + tc_recv_register(); + tc_recv_paths(); + tc_settle_close(); + tc_send_paths(); + + printf("\n=== NET Pending 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_pending/run_unit_test.py b/test/host/net_pending/run_unit_test.py new file mode 100644 index 00000000..9e212c54 --- /dev/null +++ b/test/host/net_pending/run_unit_test.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""NET 层挂起 recv / 挂起 send(net_pending)主机侧单元测试运行器(L0.5,纯标准库)。 + +做什么: + 用本机 MSVC 编译 test/host/net_pending/net_pending_test.c(以 #include 纳入生产源 + Drivers/BSP/NET/net_pending.c 与共享假芯片 test/host/common/ch395f_fake.c),在 PC + 上离线验证两条挂起队列:注册闸门、错误码经任务通知值回传、收数据到达/截断/超时、 + 会话塌掉的四条收尾分支、settle_close 语义、发送侧分块上限与停滞判定与 deadline + 续期。工具链与判定委托 test/host/common/unit_test_harness.py。 + +为什么要主机侧单测(而不是只用台架): + 本模块的核心契约是"跨上下文回传"——net_msg_t 按值拷入队列,netTask 改的是自己那份 + 副本,错误码只能搭任务通知值回来(也绝不能给 net_msg_t 加回传字段,否则调用方读到 + 栈垃圾)。台架根本读不到通知值,所以这条契约在真机上只表现为"偶发拿到 0 或正数却 + 不知道为何"。另外:收尾四条分支少一条就是"对端已断而我方任务还挂着"或"最后一包数据 + 被丢掉";发送分块上限踩错会写爆芯片 FIFO;deadline 续期写错会把 FTP 大文件在传输 + 中途掐死——这些都要能精确数着字节与毫秒测。 + +桩的边界:net_pending.h / net_types.h / net_config.h / net_listener.h 用真实头 + (TaskHandle_t 入参与 NET_PENDING_REGISTERED、NET_SEND_TIMEOUT_MS、net_listener_req_t + 一改即编译期报错);net_socket.h 是桩(net_set_errno 与 NET_ERR_RETURN_1 照抄真实头 + 同名宏);ch395f.h 只转发到共享假芯片(缓冲尺寸宏链与真实头同构转抄, + NET_SEND_CHUNK_MAX 因此是真值 1024);FreeRTOS.h / task.h 是宿主等价壳,但把 + xTaskNotify 做成**可观测**假件——没有它,本模块最重要的那条契约根本无法验证。 + +用法(仓库根目录): + python test/host/net_pending/run_unit_test.py + +退出码:0=PASS / 1=测试失败 / 2=环境错误。 + +设计依据:AGENTS.md 模块约定(错误码经任务通知值回传)、 +docs/网络/Net层阻塞模型架构设计.md、docs/测试说明/主机单测覆盖率口径.md §9 +创建日期: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 + +BUILD = TestBuild( + name="NET Pending Tests", + src=os.path.join(HERE, "net_pending_test.c"), + include_dirs=[ + os.path.join(HERE, "stub"), + os.path.join(ROOT, "test", "host", "common"), + os.path.join(ROOT, "Drivers", "BSP", "NET"), + HERE, + ], + summary_marker="=== NET Pending Tests:", +) + +if __name__ == "__main__": + sys.exit(run_host_unit_test(BUILD)) + diff --git a/test/host/net_pending/stub/FreeRTOS.h b/test/host/net_pending/stub/FreeRTOS.h new file mode 100644 index 00000000..14694d3b --- /dev/null +++ b/test/host/net_pending/stub/FreeRTOS.h @@ -0,0 +1,28 @@ +/* + * 模块名称:主机侧单测桩 - FreeRTOS.h(net_pending 模块专用) + * 模块功能:net_pending 的整个设计就是"错误码经**任务通知值**回传",所以它必须拿到 + * TaskHandle_t / BaseType_t / eNotifyAction 这三个类型才能编译——真实 + * net_pending.h 的原型里就带着它们(绕不开,且绕开就丢掉接口真实性)。 + * ⚠ 仅供主机侧单测使用,不参与 MCU 构建。三个宿主等价物与 FreeRTOS 一致:句柄是 + * void*,BaseType_t 是 portBASE_TYPE(最小整型),通知动作是三值枚举。 + * 创建日期:2026-10-23 + */ +#ifndef FREERTOS_H +#define FREERTOS_H + +#include +#include + +typedef void *TaskHandle_t; +typedef long BaseType_t; +typedef unsigned long UBaseType_t; + +typedef enum { + eNoAction = 0, + eSetBits = 1, + eIncrement = 2, + eSetValueWithOverwrite = 3, + eSetValueWithoutOverwrite = 4 +} eNotifyAction; + +#endif /* FREERTOS_H */ diff --git a/test/host/net_pending/stub/ch395f.h b/test/host/net_pending/stub/ch395f.h new file mode 100644 index 00000000..d88d6ba1 --- /dev/null +++ b/test/host/net_pending/stub/ch395f.h @@ -0,0 +1,10 @@ +/* + * 模块名称:主机侧单测桩 - ch395f.h(net_pending 模块入口) + * 模块功能:只做转发,真身在 test/host/common/ch395f_fake.h/.c(NET 域共享假芯片)。 + * ⚠ 仅供主机侧单测使用,不参与 MCU 构建。 + * 创建日期:2026-10-23 + */ +#ifndef CH395F_H +#define CH395F_H +#include "ch395f_fake.h" +#endif /* CH395F_H */ diff --git a/test/host/net_pending/stub/dbg_log.h b/test/host/net_pending/stub/dbg_log.h new file mode 100644 index 00000000..aed034f2 --- /dev/null +++ b/test/host/net_pending/stub/dbg_log.h @@ -0,0 +1,18 @@ +/* + * 模块名称:主机侧单测桩 - dbg_log.h(net_pending 模块专用) + * 模块功能:DBG_* 换成计数,让"会话塌了必须留 ERROR""停滞超时必须留 ERROR"可断言。 + * ⚠ 仅供主机侧单测使用,不参与 MCU 构建。 + * 创建日期:2026-10-23 + */ +#ifndef DBG_LOG_H +#define DBG_LOG_H + +extern int g_npd_info_count; +extern int g_npd_error_count; +extern int g_npd_debug_count; + +#define DBG_INFO(fmt, ...) do { (void)(fmt); g_npd_info_count++; } while (0) +#define DBG_DEBUG(fmt, ...) do { (void)(fmt); g_npd_debug_count++; } while (0) +#define DBG_ERROR(fmt, ...) do { (void)(fmt); g_npd_error_count++; } while (0) + +#endif /* DBG_LOG_H */ diff --git a/test/host/net_pending/stub/main.h b/test/host/net_pending/stub/main.h new file mode 100644 index 00000000..4c84aa67 --- /dev/null +++ b/test/host/net_pending/stub/main.h @@ -0,0 +1,11 @@ +/* + * 模块名称:主机侧单测桩 - main.h(net_pending 模块专用) + * 模块功能:只给 HAL_GetTick(recv 超时窗与 send 停滞窗的时基,由测试显式设定)。 + * ⚠ 仅供主机侧单测使用,不参与 MCU 构建。 + * 创建日期:2026-10-23 + */ +#ifndef MAIN_H +#define MAIN_H +#include +uint32_t HAL_GetTick(void); +#endif /* MAIN_H */ diff --git a/test/host/net_pending/stub/net_socket.h b/test/host/net_pending/stub/net_socket.h new file mode 100644 index 00000000..5a70dfeb --- /dev/null +++ b/test/host/net_pending/stub/net_socket.h @@ -0,0 +1,18 @@ +/* + * 模块名称:主机侧单测桩 - net_socket.h(NET 域共享形态,见 net_listener 同名文件) + * 模块功能:net_pending.c 需要 net_set_errno() 与 NET_ERR_RETURN_1()(写法照抄真实 + * net_socket.h:528/532);socket 状态与错误码常量取**真实** net_types.h。 + * 本模块**不用** net_get_sock——tick 收的是 net_sock_t 表数组(AGENTS 模块 + * 约定:net_get_sock 对未用槽位返回 NULL,而 tick 必须读 in_use==0 兜底)。 + * ⚠ 仅供主机侧单测使用,不参与 MCU 构建。 + * 创建日期:2026-10-23 + */ +#ifndef NET_SOCKET_H +#define NET_SOCKET_H + +#include "net_types.h" + +int net_set_errno(int err); +#define NET_ERR_RETURN_1(err) (net_set_errno(err), -1) + +#endif /* NET_SOCKET_H */ diff --git a/test/host/net_pending/stub/task.h b/test/host/net_pending/stub/task.h new file mode 100644 index 00000000..768f78ba --- /dev/null +++ b/test/host/net_pending/stub/task.h @@ -0,0 +1,22 @@ +/* + * 模块名称:主机侧单测桩 - task.h(net_pending 模块专用) + * 模块功能:只声明 net_pending 真正用到的两个内核入口,实现由测试单元给出**可观测** + * 的假件:xTaskNotify 把 (目标句柄, 通知值) 记进日志, + * xTaskGetCurrentTaskHandle 返回一个固定的"属主任务"句柄。 + * ⚠ 仅供主机侧单测使用,不参与 MCU 构建。 + * 为什么这两个假件是重点:AGENTS 模块约定写着"net_msg_t 是按值拷入队列的, + * netTask 改的是自己那份副本,调用方永远看不到 → 错误码必须搭通知值回来", + * 还写着"勿给 net_msg_t 加回传字段"。这条契约只有把 xTaskNotify 做成可观测的, + * 才能在主机侧被验证(否则"调用方拿到错误码"这件事在 PC 上根本无法发生)。 + * 创建日期:2026-10-23 + */ +#ifndef TASK_H +#define TASK_H + +#include "FreeRTOS.h" + +TaskHandle_t xTaskGetCurrentTaskHandle(void); +BaseType_t xTaskNotify(TaskHandle_t xTaskToNotify, uint32_t ulValue, + eNotifyAction eAction); + +#endif /* TASK_H */ From 59e6d772247ea01e3fe4348baf7574de5471b517 Mon Sep 17 00:00:00 2001 From: Wanderingss <1624155937@qq.com> Date: Fri, 23 Oct 2026 22:50:36 +0800 Subject: [PATCH 12/15] =?UTF-8?q?test(NET):=20net=5Fevents=20=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=EF=BC=8834=20=E6=9D=A1=EF=BC=8C=E8=A6=86=E7=9B=96=209?= =?UTF-8?q?7.8%=EF=BC=89=EF=BC=8CNET=20=E4=B8=89=E5=9D=97=E6=94=B6?= =?UTF-8?q?=E5=AE=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 假 CH395F 的第三批、也是 NET 域最后一块。net_events.c 覆盖 175/179 = 97.8%。共享 假芯片本批补齐**对端六元组**(get_remot_ipp 的 6 字节)与**链路变更计数**( link_change_count / link_up_cached 改为可预置,原来硬编码 0/1)。 钉住的契约(都是真机上会发生、却不能按需复现的那类): - 对端六元组解析:remote_ip 是**低字节在前**拼的(192.168.1.50 -> 0x3201A8C0,与 网络序相反)。钉住它是防止有人"顺手改成大端",让所有按 IP 比对/白名单静默错位。 - 陈旧中断防误判:芯片说 TCP 还 ESTABLISHED 时,迟到的 DISCONNECT 一律忽略并报 ERROR(拆活会话的代价是 104 主站看到莫名掉线);一条数据都没收就报断且芯片接收区 还有字节,判为疑似误报、只置 lost_conn_seen 延后交给对账器。 - 正常断开:优雅级沉降 + session_dead + last_peer_port 记旧端口 + 转 CLOSED + 发事件。 - 超时:ESTABLISHED 走隔离级沉降并置 released_abnormal —— 这正是 net_pending 收尾时 把挂起 recv 报成 CONNRESET 而不是 0 的那条输入(TC-NETPEND-014),**跨文件因果** 只有两边都能离线跑才可测。 - 丢中断自愈:芯片已连上而软件还写着 LISTENING 时补一次 accept;若软件已 CLOSED 则 只留痕不凭空造会话(TC-NETEV-042b 记现状)。 - 对账器:100ms 周期闸门(同一时刻不重复查芯片);链路计数**基线那趟不打日志** (0xFFFF 哨兵,否则每次上电都冒一条假"link up")、变化即留痕;五种不一致形态的 稳定计数分三档(会话死亡要连观 4 次、芯片偷重开监听 2 次、丢 CONNECT 自接 1 次); 动作后 1000ms 冷却内不重复折腾;U11 重同步(软件 CLOSED 而芯片在 LISTEN -> 直接跟 芯片,不做多余重建);不在用的槽位直接跳过。 三条初判失败的复盘(都是**我的期望值/场景写错**,实现是对的):① remote_ip 字节序 我按网络序写了期望;② 自愈场景我用了 CLOSED 态,而 accept 分支只认 LISTENING/ CONNECTING,改场景并补一条现状断言;③ 链路"基线不打日志"我测在非基线场景(s_ link_last 是函数内 static,跨场景本就延续)。对账器的 s_last/s_bad[]/s_cool[] 都是 函数内 static(外部够不着),因此每个形态**各占一个 fd**、时基单调前进,互不串扰。 覆盖率与台账:套件 24 → 25 全绿;files_tested 28 → 29;未触及 .c 38 → 37;分母 3,766 → 3,945 行;line=3472/3945(**88.0%**,连涨两批)、func=263/290(90.7%)。 恒等式 37 = 3(P1 剩) + 5(P2) + 29(P3) 仍咬合。编号补建 TC-HOST-NETEV(游标 058), 族数 18 → 19。 自查:test-cppcheck Passed(含补齐后的假芯片);check_ids --strict 通过;doc-links 35 处(= 存量);16 个新增/改动文件 BOM/CR/U+FFFD/行尾逐项净。零生产代码改动,未跑 @build。--all-files 仍只有 mem-numbers / mem-budget(.map 台账漂移,等 Lead 合并后 统一 --write 回填)与 ui-golden(simulator/tests/ref/*.bin 不入库,新 worktree 没有 黄金参考)三条失败,与本分支改动无关;提交按并行分支约定走 --no-verify。 --- docs/测试说明/主机单测覆盖率口径.md | 7 +- scripts/id_registry.json | 22 + scripts/test_coverage_baseline.json | 40 +- test/host/common/ch395f_fake.c | 29 +- test/host/common/ch395f_fake.h | 7 +- test/host/net_events/net_events_test.c | 530 +++++++++++++++++++ test/host/net_events/run_unit_test.py | 60 +++ test/host/net_events/stub/FreeRTOS.h | 25 + test/host/net_events/stub/ch395f.h | 10 + test/host/net_events/stub/cmsis_os.h | 10 + test/host/net_events/stub/dbg_log.h | 20 + test/host/net_events/stub/main.h | 11 + test/host/net_events/stub/net_latency_test.h | 9 + test/host/net_events/stub/net_socket.h | 19 + test/host/net_events/stub/task.h | 15 + test/host/net_events/stub/test_config.h | 10 + 16 files changed, 801 insertions(+), 23 deletions(-) create mode 100644 test/host/net_events/net_events_test.c create mode 100644 test/host/net_events/run_unit_test.py create mode 100644 test/host/net_events/stub/FreeRTOS.h create mode 100644 test/host/net_events/stub/ch395f.h create mode 100644 test/host/net_events/stub/cmsis_os.h create mode 100644 test/host/net_events/stub/dbg_log.h create mode 100644 test/host/net_events/stub/main.h create mode 100644 test/host/net_events/stub/net_latency_test.h create mode 100644 test/host/net_events/stub/net_socket.h create mode 100644 test/host/net_events/stub/task.h create mode 100644 test/host/net_events/stub/test_config.h diff --git a/docs/测试说明/主机单测覆盖率口径.md b/docs/测试说明/主机单测覆盖率口径.md index 42f02b3d..76197ae3 100644 --- a/docs/测试说明/主机单测覆盖率口径.md +++ b/docs/测试说明/主机单测覆盖率口径.md @@ -237,9 +237,10 @@ 收尾四条分支(不丢尾包 / 干净报 0 / 异常释放报 CONNRESET / 槽位没了报 CONNRESET)、 发送分块钳在 `NET_SEND_CHUNK_MAX=1024`(真值,非假件自造)、`deadline` 每轮续期 (长文件不被 1000ms 掐死)、停滞超时、取消与 `clear` 不发通知的纪律 | -| NET 最后一块(`net_events.c`) | ⬜ 未开始 | 假芯片与假件已就绪;它依赖最多 - (`net_sock_raw`/`net_listener_settle`/`net_pending_cancel`/`xTaskGetTickCount`) | -| 104 服务层 | ⬜ 未开始 | — | 桩最省(复用 `cmsis_os2.h` + `net_socket.h`) | +| NET 三块之三:`net_events.c` | ✅ 完成 | `test/host/net_events`(34 条) | 陈旧中断防误判 | + (芯片说还在连接就不许拆活会话)、疑似误报延后、超时标异常释放(正是 `net_pending` + 收尾报 CONNRESET 的那条输入,**跨文件因果两头都能离线跑才测得到**)、丢中断自愈, + 以及对账器的 100ms 周期闸门 / 稳定计数 4·2·1 三档 / 1000ms 冷却 / U11 重同步 | 本批照出的两处**现状**(只登记、**未改语义**;全库唯一调用点 `test/board/net_test_task.c` 传的是 NULL 与非零 tv,今天没有受害者): diff --git a/scripts/id_registry.json b/scripts/id_registry.json index 020777ec..d5c63445 100644 --- a/scripts/id_registry.json +++ b/scripts/id_registry.json @@ -1790,6 +1790,28 @@ "subitem_ok": true, "owner_note": "B 层口径(2026-10-21 批 3 定:这些号在代码目录外零消费者,属主登记处 = 单测代码本身,不逐号补文档);2026-10-23 随覆盖率口径 §9 假 CH395F 第二批补登记", "cursor_note": "游标按实测最大号收录(2026-10-23):越界即视为未登记的冒号牌" + }, + { + "name": "TC-HOST-NETEV", + "class": "TC", + "title": "主机侧单测用例号:NET 层事件分发与周期对账(test/host/net_events)", + "pattern": "\\bTC-NETEV-[0-9]{1,4}[a-z]?\\b", + "digit_width": [ + 2, + 4 + ], + "owner_docs_glob": [ + "test/host/net_events/*" + ], + "scope": "global", + "frozen": false, + "cursor": "TC-NETEV-058", + "cursor_owner_only": true, + "check_dangling": true, + "allow_orphans": true, + "subitem_ok": true, + "owner_note": "B 层口径(2026-10-21 批 3 定:这些号在代码目录外零消费者,属主登记处 = 单测代码本身,不逐号补文档);2026-10-23 随覆盖率口径 §9 假 CH395F 第三批补登记", + "cursor_note": "游标按实测最大号收录(2026-10-23):越界即视为未登记的冒号牌" } ], "unmanaged_bare": { diff --git a/scripts/test_coverage_baseline.json b/scripts/test_coverage_baseline.json index 563836d2..4efe76e4 100644 --- a/scripts/test_coverage_baseline.json +++ b/scripts/test_coverage_baseline.json @@ -184,6 +184,14 @@ "lines_covered": 7, "lines_valid": 7 }, + "Drivers/BSP/NET/net_events.c": { + "func_rate": 1.0, + "funcs_covered": 8, + "funcs_valid": 8, + "line_rate": 0.9777, + "lines_covered": 175, + "lines_valid": 179 + }, "Drivers/BSP/NET/net_listener.c": { "func_rate": 1.0, "funcs_covered": 7, @@ -308,6 +316,13 @@ "lines_total": 112, "ok": true }, + "net_events": { + "files": { + "Drivers/BSP/NET/net_events.c": 179 + }, + "lines_total": 179, + "ok": true + }, "net_listener": { "files": { "Drivers/BSP/NET/net_listener.c": 126 @@ -440,6 +455,7 @@ "Drivers/BSP/NET/lftpd/lftpd_auth.c", "Drivers/BSP/NET/lftpd/lftpd_io.c", "Drivers/BSP/NET/lftpd/lftpd_string.c", + "Drivers/BSP/NET/net_events.c", "Drivers/BSP/NET/net_listener.c", "Drivers/BSP/NET/net_pending.c", "Drivers/BSP/NET/net_select.c", @@ -448,16 +464,16 @@ ], "totals": { "files_generated_excluded": 5, - "files_tested": 28, - "files_untested": 116, - "func_rate": 0.9043, - "funcs_covered": 255, - "funcs_valid": 282, - "line_rate": 0.8755, - "lines_covered": 3297, - "lines_valid": 3766, - "modules_ok": 24, - "modules_total": 24 + "files_tested": 29, + "files_untested": 115, + "func_rate": 0.9069, + "funcs_covered": 263, + "funcs_valid": 290, + "line_rate": 0.8801, + "lines_covered": 3472, + "lines_valid": 3945, + "modules_ok": 25, + "modules_total": 25 }, "untested_sources": [ { @@ -504,10 +520,6 @@ "code_lines": 264, "path": "Drivers/BSP/SD2506/sd2506.c" }, - { - "code_lines": 263, - "path": "Drivers/BSP/NET/net_events.c" - }, { "code_lines": 206, "path": "Drivers/BSP/CH395F/ch395f.h" diff --git a/test/host/common/ch395f_fake.c b/test/host/common/ch395f_fake.c index 11a03613..717b490f 100644 --- a/test/host/common/ch395f_fake.c +++ b/test/host/common/ch395f_fake.c @@ -31,6 +31,7 @@ void ch395f_fake_reset(void) g_fake.cached_tcp[i] = CH395F_TCP_CLOSED; g_fake.status_byte0[i] = CH395F_SOCKET_CLOSED; g_fake.status_byte1[i] = CH395F_TCP_CLOSED; + g_fake.link_up = 1U; g_fake.open_rc[i] = CH395F_ERR_SUCCESS; g_fake.listen_rc[i] = CH395F_ERR_SUCCESS; } @@ -44,6 +45,20 @@ void ch395f_fake_push_recv_len(uint8_t sock, uint16_t v) } } +void ch395f_fake_set_peer(uint8_t sock, uint8_t b0, uint8_t b1, uint8_t b2, + uint8_t b3, uint16_t peer_port) +{ + if (sock >= CH395F_FAKE_SOCK_MAX) { + return; + } + g_fake.remote[sock][0] = b0; + g_fake.remote[sock][1] = b1; + g_fake.remote[sock][2] = b2; + g_fake.remote[sock][3] = b3; + g_fake.remote[sock][4] = (uint8_t)(peer_port & 0xFFU); + g_fake.remote[sock][5] = (uint8_t)((peer_port >> 8) & 0xFFU); +} + void ch395f_fake_set_listening(uint8_t sock) { if (sock < CH395F_FAKE_SOCK_MAX) { @@ -174,9 +189,13 @@ void ch395f_set_sour_port(uint8_t sock, uint16_t port) void ch395f_get_remot_ipp(uint8_t sock, uint8_t *p_buf) { - (void)sock; - if (p_buf != NULL) { - (void)memset(p_buf, 0, 4U); + if (p_buf == NULL) { + return; + } + if (sock < CH395F_FAKE_SOCK_MAX) { + (void)memcpy(p_buf, g_fake.remote[sock], 6U); + } else { + (void)memset(p_buf, 0, 6U); } } @@ -189,10 +208,10 @@ void ch395f_set_keepalive_enable(uint8_t sock, uint8_t enable) uint8_t ch395f_link_up_cached(void) { - return 1U; + return g_fake.link_up; } uint16_t ch395f_link_change_count(void) { - return 0U; + return g_fake.link_change; } diff --git a/test/host/common/ch395f_fake.h b/test/host/common/ch395f_fake.h index cfd0a516..6453b169 100644 --- a/test/host/common/ch395f_fake.h +++ b/test/host/common/ch395f_fake.h @@ -82,6 +82,9 @@ typedef struct { uint16_t recv_len_q[CH395F_FAKE_SOCK_MAX][CH395F_FAKE_QUEUE_LEN]; int recv_len_n[CH395F_FAKE_SOCK_MAX]; int recv_len_i[CH395F_FAKE_SOCK_MAX]; + uint8_t remote[CH395F_FAKE_SOCK_MAX][6]; /* get_remot_ipp 的 6 字节:ip[4]+port(小端) */ + uint16_t link_change; /* link_change_count(链路变更计数) */ + uint8_t link_up; /* link_up_cached */ uint8_t open_rc[CH395F_FAKE_SOCK_MAX]; /* open_socket 返回码 */ uint8_t listen_rc[CH395F_FAKE_SOCK_MAX]; /* tcp_listen 返回码 */ } ch395f_fake_world_t; @@ -118,7 +121,9 @@ extern ch395f_fake_obs_t g_chip; void ch395f_fake_reset(void); /* 全清并把所有 socket 设为"已关闭" */ void ch395f_fake_push_recv_len(uint8_t sock, uint16_t v); -void ch395f_fake_set_listening(uint8_t sock); /* 缓存:open!=CLOSED 且 tcp=LISTEN */ +void ch395f_fake_set_listening(uint8_t sock); +void ch395f_fake_set_peer(uint8_t sock, uint8_t b0, uint8_t b1, uint8_t b2, + uint8_t b3, uint16_t peer_port); /* 预置对端六元组 */ /* 缓存:open!=CLOSED 且 tcp=LISTEN */ /* —— 假芯片实现(签名与真实 ch395f.h 一字不改)—— */ ch395f_sock_state_t ch395f_sock_state_cached(uint8_t sn); diff --git a/test/host/net_events/net_events_test.c b/test/host/net_events/net_events_test.c new file mode 100644 index 00000000..48231fa3 --- /dev/null +++ b/test/host/net_events/net_events_test.c @@ -0,0 +1,530 @@ +/* + * 模块名称:NET 层事件分发与周期对账(net_events)主机侧单元测试 + * 模块功能:在 PC 上离线验证 Drivers/BSP/NET/net_events.c 的五个事件入口与周期对账器: + * 对端六元组解析、**陈旧中断防误判**(芯片说还在连接就不许拆会话)、疑似 + * 误报延后交给对账器、超时把会话标成异常释放(喂给 net_pending 收尾报 + * CONNRESET 的那条输入)、掉线自愈自接,以及对账器的 100ms 周期闸门、 + * 1000ms 冷却、五种不一致形态的稳定计数(死亡要连观 4 次、重开监听 2 次、 + * 自接 1 次)与 U11 的 CLOSED->LISTENING 重同步。 + * + * 被测对象:真实源文件 ../../../Drivers/BSP/NET/net_events.c(#include 原样纳入)。 + * net_events.h / net_types.h / net_pending.h / net_listener.h 全用**真实头** + * (net_event_cb_t 回调签名、NET_EVENT_* / NET_SOCK_STATE_* / + * net_listener_settle_t / NET_LISTENER_REQ_* 一改即编译期报错)。假件: + * 共享假 CH395F(本批补齐对端六元组与链路变更计数)、可控 socket 表 + * (net_sock_raw 对未在用槽位也返回项,与真实语义一致)、**可观测的事件 + * 回调**(记 fd / 事件 / arg)、记录型 net_listener_settle/request 与 + * net_pending_cancel / net_send_pending_cancel、可控时基、dbg_log 计数。 + * + * 为什么要主机侧单测(而不是只靠台架): + * 1. CH395F 的中断是**整体读即清**且单连接 socket 复用同一个号,"迟到的 DISCONNECT" + * 与"丢了 CONNECT 的已连接"在真机上都会发生,却无法按需复现。误判的代价直接落在 + * 现场设备上:把还活着的会话拆掉(104 主站看到莫名掉线),或让 socket 永远停在 + * 已连接假象里不再重开。 + * 2. 对账器的**稳定计数**(死亡 4 次、重开监听 2 次、自接 1 次)本质是"别拿一次 + * 采样就把连接折腾死"。真机一次采样 100ms,凑够 4 次要 400ms,且必须一路稳定; + * 台架上没法按毫秒喂,也看不出为什么"偶尔不重开"。主机侧把时基做成显式量后, + * 第 3 次不该动、第 4 次该动、之后 1000ms 内不许再动,全部可精确断言。 + * 3. handle_timeout_event 会置 released_abnormal=1,而 net_pending 收尾时正是靠 + * 这个标志把挂起 recv 报成 CONNRESET 而不是 0(见 test/host/net_pending 的 + * TC-NETPEND-014)。两个模块之间这条**跨文件因果**,只有两边都能离线跑时才可测。 + * + * 覆盖点:TC-NETEV-001..006 CONNECT;010..014 DISCONNECT(含两条陈旧中断防误判); + * 020/021 RECV 与 SEND;030..032 TIMEOUT;040..042 掉线自愈;050..058 周期对账 + * (周期闸门、链路变更留痕、五种不一致形态、稳定计数、冷却、in_use 兜底)。 + * + * 通过:全部 TC PASS(main 返回 0)。 + * 入口:int main(void),返回 0=PASS / 1=FAIL(由 run 脚本判读)。 + * + * 创建日期:2026-10-23 + */ + +#include +#include + +#include "net_events.h" +#include "net_listener.h" +#include "net_pending.h" +#include "net_socket.h" +#include "ch395f.h" + +#include "ch395f_fake.c" +#include "net_events.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_nev_info_count; +int g_nev_error_count; +int g_nev_debug_count; + +/* ===================== 可观测的事件回调 ===================== */ + +static int g_ev_n; +static int g_ev_fd[16]; +static int g_ev_kind[16]; +static void *g_ev_arg[16]; +static int g_ev_token; + +static void ev_cb(int sockfd, net_event_t event, void *arg) +{ + if (g_ev_n < (int)(sizeof(g_ev_fd) / sizeof(g_ev_fd[0]))) { + g_ev_fd[g_ev_n] = sockfd; + g_ev_kind[g_ev_n] = (int)event; + g_ev_arg[g_ev_n] = arg; + } + g_ev_n++; +} + +/* ===================== 记录型监听与挂起取消 ===================== */ + +static int g_settle_n; +static int g_settle_sock; +static int g_settle_level; +static int g_req_n; +static int g_req_sock; +static int g_req_reason; +static int g_pcan_n; +static int g_scan_n; +static int g_pcan_err; + +void net_listener_settle(int sockfd, net_listener_settle_t level) +{ + g_settle_n++; + g_settle_sock = sockfd; + g_settle_level = (int)level; +} + +void net_listener_request(int sockfd, net_listener_req_t reason) +{ + g_req_n++; + g_req_sock = sockfd; + g_req_reason = (int)reason; +} + +void net_listener_clear(int sockfd) { (void)sockfd; } +void net_listener_tick(void) { } + +void net_pending_cancel(int sockfd, int err) +{ + (void)sockfd; + g_pcan_n++; + g_pcan_err = err; +} + +void net_send_pending_cancel(int sockfd, int err) +{ + (void)sockfd; + (void)err; + g_scan_n++; +} + +/* ===================== 可控时基、errno 与 socket 表 ===================== */ + +static uint32_t g_now; +static int g_errno_last; + +uint32_t HAL_GetTick(void) { return g_now; } + +int net_set_errno(int err) { g_errno_last = err; return err; } + +uint32_t xTaskGetTickCount(void) { return g_now; } + +static net_sock_t g_tbl[NET_MAX_SOCKETS]; + +net_sock_t *net_sock_raw(int sockfd) +{ + if ((sockfd < 0) || (sockfd >= NET_MAX_SOCKETS)) { + return NULL; + } + /* 与真实语义一致:对账循环必须能读到 in_use == 0 的槽位,所以这里不给 NULL */ + return &g_tbl[sockfd]; +} + +net_sock_t *net_get_sock(int sockfd) +{ + net_sock_t *p = net_sock_raw(sockfd); + + if ((p == NULL) || (p->in_use == 0u)) { + return NULL; + } + return p; +} + +static void reset_all(void) +{ + int i; + + (void)memset(g_tbl, 0, sizeof(g_tbl)); + g_now = 1000u; + g_errno_last = 0; + g_ev_n = 0; + g_settle_n = 0; + g_settle_sock = -1; + g_settle_level = -1; + g_req_n = 0; + g_req_sock = -1; + g_req_reason = -1; + g_pcan_n = 0; + g_scan_n = 0; + g_pcan_err = 0; + g_nev_info_count = 0; + g_nev_error_count = 0; + g_nev_debug_count = 0; + ch395f_fake_reset(); + (void)i; +} + +/* 一个"带应用回调的在用槽位",ch395_sock 与数组下标同号 */ +static net_sock_t *mk(int fd, net_sock_state_t st, uint16_t local_port) +{ + net_sock_t *p = &g_tbl[fd]; + + p->in_use = 1u; + p->ch395_sock = (uint8_t)fd; + p->type = NET_SOCK_STREAM; + p->state = st; + p->auto_relisten = 1u; + p->local_port = local_port; + p->event_cb = ev_cb; + p->event_arg = (void *)&g_ev_token; + return p; +} + +/* ===================== CONNECT ===================== */ + +static void tc_connect(void) +{ + net_sock_t *p; + + printf("-- TC-NETEV-001..006 CONNECT --\n"); + reset_all(); + p = mk(0, NET_SOCK_STATE_LISTENING, 2404u); + ch395f_fake_set_peer(0, 192u, 168u, 1u, 50u, 51234u); + handle_connect_event(p); + /* remote_ip 是**低字节在前**拼的(remote[0] 落在最低 8 位),与网络序相反: + * 192.168.1.50 -> 0x3201A8C0。这是既有实现的字节序,钉住它是防止有人"顺手 + * 改成大端"导致所有按 IP 比对/白名单的判断静默错位。 */ + CHECK((p->remote_ip_arr[0] == 192u) && (p->remote_ip_arr[3] == 50u) && + (p->remote_ip == ((192u) | (168u << 8) | (1u << 16) | (50u << 24))) && + (p->remote_port == 51234u), + "TC-NETEV-001 对端六元组解析:ip 低字节在前拼 32 位、端口取第 5/6 字节"); + CHECK((p->state == NET_SOCK_STATE_ESTABLISHED) && (p->session_dead == 0u) && + (p->lost_conn_seen == 0u) && (p->send_ready == 1u), + "TC-NETEV-002 监听口自接后转已连接并立刻可写"); + CHECK((g_chip.keepalive_calls == 1) && (g_chip.last_keepalive == 1u) && + (g_chip.int_status_calls == 1), + "TC-NETEV-003 建链即开 keepalive 并清一次挂起位(读即清)"); + CHECK((g_ev_n == 1) && (g_ev_kind[0] == (int)NET_EVENT_CONNECTED) && + (g_ev_fd[0] == 0) && (g_ev_arg[0] == (void *)&g_ev_token), + "TC-NETEV-004 事件带 fd 与调用方 arg 回调给应用"); + + reset_all(); + p = mk(0, NET_SOCK_STATE_CONNECTING, 0u); + ch395f_fake_set_peer(0, 10u, 0u, 0u, 1u, 2404u); + handle_connect_event(p); + CHECK((p->state == NET_SOCK_STATE_ESTABLISHED) && (p->send_ready == 0u) && + (g_chip.keepalive_calls == 1) && (g_ev_n == 1), + "TC-NETEV-005 主动连接分支同样转已连接,但**不动 send_ready**(与自接有别)"); + + reset_all(); + p = mk(0, NET_SOCK_STATE_CLOSED, 2404u); + handle_connect_event(p); + CHECK((g_ev_n == 0) && (p->state == NET_SOCK_STATE_CLOSED), + "TC-NETEV-006 其它状态收到 CONNECT 中断一律忽略(不许凭空造会话)"); +} + +/* ===================== DISCONNECT ===================== */ + +static void tc_disconnect(void) +{ + net_sock_t *p; + + printf("-- TC-NETEV-010..014 DISCONNECT --\n"); + reset_all(); + p = mk(0, NET_SOCK_STATE_ESTABLISHED, 2404u); + g_fake.status_byte0[0] = CH395F_SOCKET_OPEN; + g_fake.status_byte1[0] = CH395F_TCP_ESTABLISHED; + handle_disconnect_event(p); + CHECK((p->state == NET_SOCK_STATE_ESTABLISHED) && (g_ev_n == 0) && + (g_nev_error_count == 1), + "TC-NETEV-010 芯片说还在连接:忽略这个陈旧 DISCONNECT(不许拆活会话)"); + + reset_all(); + p = mk(0, NET_SOCK_STATE_ESTABLISHED, 2404u); + g_fake.status_byte0[0] = CH395F_SOCKET_OPEN; + g_fake.status_byte1[0] = CH395F_TCP_CLOSED; + ch395f_fake_push_recv_len(0, 9u); /* 还有 9 字节没读走 */ + handle_disconnect_event(p); + CHECK((p->state == NET_SOCK_STATE_ESTABLISHED) && (p->lost_conn_seen == 1u) && + (g_ev_n == 0) && (g_settle_n == 0), + "TC-NETEV-011 疑似误报(一条数据都没收就断):延后交给对账器,不立即拆"); + + reset_all(); + p = mk(0, NET_SOCK_STATE_ESTABLISHED, 2404u); + p->remote_port = 51234u; + g_fake.status_byte0[0] = CH395F_SOCKET_OPEN; + g_fake.status_byte1[0] = CH395F_TCP_CLOSED; + handle_disconnect_event(p); + CHECK((g_settle_n == 1) && (g_settle_level == (int)NET_LISTENER_SETTLE_GRACEFUL) && + (p->session_dead == 1u) && (p->released_abnormal == 0u) && + (p->last_peer_port == 51234u) && + (p->state == NET_SOCK_STATE_CLOSED) && + (g_ev_kind[0] == (int)NET_EVENT_DISCONNECTED), + "TC-NETEV-012 正常断开:优雅沉降 + 会话标死 + 记旧端口 + 发事件"); + + reset_all(); + p = mk(0, NET_SOCK_STATE_LISTENING, 2404u); + g_fake.status_byte0[0] = CH395F_SOCKET_OPEN; + g_fake.status_byte1[0] = CH395F_TCP_LISTEN; + handle_disconnect_event(p); + CHECK((p->state == NET_SOCK_STATE_LISTENING) && (g_ev_n == 0) && + (g_nev_info_count == 1) && (g_nev_error_count == 0), + "TC-NETEV-013 监听还在:陈旧监听 DISCONNECT 只留痕,不报 ERROR"); + + reset_all(); + p = mk(0, NET_SOCK_STATE_LISTENING, 2404u); + g_fake.status_byte1[0] = CH395F_TCP_CLOSED; /* 芯片说不在 LISTEN */ + handle_disconnect_event(p); + CHECK((p->state == NET_SOCK_STATE_LISTENING) && (g_ev_n == 0) && (g_req_n == 0), + "TC-NETEV-013b 监听丢了也只留痕:重开交给对账器,事件层不越权"); + + reset_all(); + p = mk(0, NET_SOCK_STATE_CONNECTING, 0u); + handle_disconnect_event(p); + CHECK((g_nev_error_count == 1) && (g_ev_kind[0] == (int)NET_EVENT_DISCONNECTED), + "TC-NETEV-014 非常规状态收到 DISCONNECT:报 ERROR 但仍要通知应用"); +} + +/* ===================== RECV / SEND / TIMEOUT ===================== */ + +static void tc_recv_send_timeout(void) +{ + net_sock_t *p; + + printf("-- TC-NETEV-020/021/030..032 --\n"); + reset_all(); + p = mk(0, NET_SOCK_STATE_ESTABLISHED, 2404u); + handle_recv_event(p); + CHECK((p->readable == 1u) && (g_ev_kind[0] == (int)NET_EVENT_DATA_RECEIVED), + "TC-NETEV-020 RECV 事件置可读并通知应用"); + + reset_all(); + p = mk(0, NET_SOCK_STATE_ESTABLISHED, 2404u); + p->send_ready = 1u; + handle_send_event(p); + CHECK((g_ev_kind[0] == (int)NET_EVENT_SEND_COMPLETE) && (p->send_ready == 1u), + "TC-NETEV-021 SEND 完成只发事件,字段留给写路径自己管"); + + reset_all(); + p = mk(0, NET_SOCK_STATE_CONNECTING, 0u); + handle_timeout_event(p); + CHECK((p->state == NET_SOCK_STATE_CREATED) && (g_nev_error_count == 1) && + (g_ev_kind[0] == (int)NET_EVENT_TIMEOUT), + "TC-NETEV-030 连接超时:退回 CREATED 并通知应用"); + + reset_all(); + p = mk(0, NET_SOCK_STATE_ESTABLISHED, 2404u); + p->remote_port = 777u; + handle_timeout_event(p); + CHECK((g_settle_level == (int)NET_LISTENER_SETTLE_QUARANTINE) && + (p->released_abnormal == 1u) && (p->session_dead == 1u) && + (p->last_peer_port == 777u) && (p->state == NET_SOCK_STATE_CLOSED), + "TC-NETEV-031 会话超时:隔离级沉降 + 标异常释放(net_pending 收尾据此报 CONNRESET)"); + + reset_all(); + p = mk(0, NET_SOCK_STATE_LISTENING, 2404u); + handle_timeout_event(p); + CHECK((p->state == NET_SOCK_STATE_LISTENING) && (g_nev_error_count == 1) && + (g_ev_kind[0] == (int)NET_EVENT_TIMEOUT), + "TC-NETEV-032 监听超时只留痕:不改状态,但仍要通知等待方"); +} + +/* ===================== 掉线自愈 ===================== */ + +static void tc_lost_probe(void) +{ + net_sock_t *p; + + printf("-- TC-NETEV-040..042 lost-CONNECT probe --\n"); + reset_all(); + p = mk(0, NET_SOCK_STATE_CLOSED, 2404u); + p->lost_conn_seen = 1u; + g_fake.cached_tcp[0] = CH395F_TCP_CLOSED; + net_lost_connect_probe(p); + CHECK((p->lost_conn_seen == 0u) && (g_ev_n == 0), + "TC-NETEV-040 芯片不在已连接态:只清标记,不自作主张建会话"); + + reset_all(); + p = mk(0, NET_SOCK_STATE_CLOSED, 2404u); + p->lost_conn_seen = 1u; + p->last_peer_port = 51234u; + g_fake.cached_tcp[0] = CH395F_TCP_ESTABLISHED; + ch395f_fake_set_peer(0, 10u, 0u, 0u, 9u, 51234u); + net_lost_connect_probe(p); + CHECK((p->lost_conn_seen == 0u) && (g_ev_n == 0), + "TC-NETEV-041 对端口与上次相同:认定是自己没看到 CONNECT,不自接第二遍"); + + /* 自愈的适用面是软件还写着 LISTENING、芯片其实已经连上——accept 分支只认 + * LISTENING/CONNECTING。若软件态已被别处改成 CLOSED,本函数打完 INFO 后什么都 + * 不会发生(现状留痕,见第二断言)。 */ + reset_all(); + p = mk(0, NET_SOCK_STATE_LISTENING, 2404u); + p->lost_conn_seen = 1u; + p->last_peer_port = 1u; + g_fake.cached_tcp[0] = CH395F_TCP_ESTABLISHED; + ch395f_fake_set_peer(0, 10u, 0u, 0u, 9u, 55555u); + net_lost_connect_probe(p); + CHECK((g_ev_n == 1) && (g_ev_kind[0] == (int)NET_EVENT_CONNECTED) && + (p->state == NET_SOCK_STATE_ESTABLISHED) && (p->remote_port == 55555u) && + (p->lost_conn_seen == 0u), + "TC-NETEV-042 丢了 CONNECT 中断时自愈:补一次 accept,会话不至于永久失联"); + + reset_all(); + p = mk(0, NET_SOCK_STATE_CLOSED, 2404u); + g_fake.cached_tcp[0] = CH395F_TCP_ESTABLISHED; + ch395f_fake_set_peer(0, 10u, 0u, 0u, 9u, 55555u); + net_lost_connect_probe(p); + CHECK((g_ev_n == 0) && (p->state == NET_SOCK_STATE_CLOSED), + "TC-NETEV-042b 现状:软件已 CLOSED 时自愈接不上(只留痕,不凭空造会话)"); +} + +/* ===================== 周期对账 ===================== */ + +static void recon_step(uint32_t now) +{ + g_now = now; + net_reconcile_tick(); +} + +static void tc_reconcile(void) +{ + net_sock_t *p; + int infos; + int c; + + printf("-- TC-NETEV-050..058 reconcile --\n"); + /* 对账器里的 s_last / s_bad[] / s_cool[] 是函数内 static(外部够不着), + * 所以每个形态**各占一个 fd**、时间统一单调前进,互不串扰。 */ + reset_all(); + infos = g_nev_info_count; + recon_step(1000u); /* priming:链路计数记基线 */ + CHECK(g_nev_info_count == infos, + "TC-NETEV-051a 上电第一趟不因链路计数打日志(0xFFFF 哨兵只记基线)"); + c = g_chip.cached_calls; + recon_step(1000u); /* 同一时刻再调一次 */ + CHECK(g_chip.cached_calls == c, "TC-NETEV-050 100ms 周期闸门:同一时刻不重复查芯片"); + + reset_all(); + p = mk(1, NET_SOCK_STATE_ESTABLISHED, 2404u); + g_fake.cached_open[1] = CH395F_SOCKET_CLOSED; + g_fake.cached_tcp[1] = CH395F_TCP_CLOSED; + infos = g_nev_info_count; + recon_step(1100u); + recon_step(1200u); + recon_step(1300u); + CHECK((p->state == NET_SOCK_STATE_ESTABLISHED) && (g_ev_n == 0) && + (g_nev_info_count == infos), + "TC-NETEV-052 会话死亡要**连观 4 次**才动手:前三次保持原状"); + recon_step(1400u); + CHECK((p->state == NET_SOCK_STATE_CLOSED) && (p->session_dead == 1u) && + (g_ev_kind[0] == (int)NET_EVENT_DISCONNECTED), + "TC-NETEV-052b 第 4 次稳定后走断开收尾"); + p->state = NET_SOCK_STATE_ESTABLISHED; /* 假装又活了,验证冷却 */ + recon_step(1500u); + recon_step(1600u); + recon_step(1700u); + recon_step(1800u); + CHECK(g_ev_n == 1, "TC-NETEV-052c 动作后 1000ms 冷却内不再重复折腾(哪怕稳定计数够了)"); + + reset_all(); + p = mk(2, NET_SOCK_STATE_ESTABLISHED, 2404u); + g_fake.cached_open[2] = CH395F_SOCKET_OPEN; + g_fake.cached_tcp[2] = CH395F_TCP_LISTEN; /* 芯片自己重开监听了,却没发 DISCONNECT */ + recon_step(2900u); + CHECK((p->state == NET_SOCK_STATE_ESTABLISHED) && (g_pcan_n == 0), + "TC-NETEV-053 芯片偷偷重开监听:需要 2 次稳定,第一次只累计"); + recon_step(3000u); + CHECK((p->state == NET_SOCK_STATE_LISTENING) && (g_pcan_n == 1) && + (g_scan_n == 1) && (g_pcan_err == NET_ERR_CONNRESET), + "TC-NETEV-053b 第 2 次稳定:重同步成监听并**取消两条挂起**(否则任务永远等不到回)"); + + reset_all(); + p = mk(3, NET_SOCK_STATE_LISTENING, 2404u); + p->last_peer_port = 1u; + g_fake.cached_tcp[3] = CH395F_TCP_ESTABLISHED; /* 连上了却没 CONNECT 中断 */ + ch395f_fake_set_peer(3, 172u, 16u, 0u, 5u, 40000u); + recon_step(4100u); + CHECK((p->state == NET_SOCK_STATE_ESTABLISHED) && (p->remote_port == 40000u) && + (g_ev_kind[0] == (int)NET_EVENT_CONNECTED), + "TC-NETEV-054 连接完成但中断丢了:1 次即补 accept"); + + reset_all(); + p = mk(4, NET_SOCK_STATE_LISTENING, 2404u); + g_fake.cached_open[4] = CH395F_SOCKET_CLOSED; /* 监听没了 */ + g_fake.cached_tcp[4] = CH395F_TCP_CLOSED; + recon_step(5200u); + recon_step(5300u); + CHECK((g_req_n == 1) && (g_req_sock == 4) && + (g_req_reason == (int)NET_LISTENER_REQ_RECON), + "TC-NETEV-055 监听丢了:2 次稳定后请求重开监听(不自已在事件层里开)"); + + reset_all(); + p = mk(5, NET_SOCK_STATE_CLOSED, 2404u); + g_fake.cached_open[5] = CH395F_SOCKET_OPEN; /* 芯片还在 LISTEN */ + g_fake.cached_tcp[5] = CH395F_TCP_LISTEN; + p->remote_port = 999u; + p->lost_conn_seen = 1u; + recon_step(6400u); + CHECK((p->state == NET_SOCK_STATE_LISTENING) && (p->last_peer_port == 999u) && + (p->lost_conn_seen == 0u) && (g_req_n == 0), + "TC-NETEV-056 U11 重同步:软件 CLOSED 而芯片在 LISTEN -> 直接跟芯片,不多余重开"); + + reset_all(); + p = mk(6, NET_SOCK_STATE_CLOSED, 2404u); + g_fake.cached_open[6] = CH395F_SOCKET_CLOSED; + recon_step(7500u); + recon_step(7600u); + CHECK((g_req_n == 1) && (g_req_sock == 6), + "TC-NETEV-057 软件 CLOSED 且芯片也不在监听:请求重开(重建交回监听层)"); + + reset_all(); + (void)memset(&g_tbl[7], 0, sizeof(g_tbl[7])); /* 槽位不在用 */ + infos = g_nev_info_count; + recon_step(8700u); + CHECK(g_nev_info_count == infos, "TC-NETEV-058 不在用的槽位直接跳过(不产生任何动作)"); + + /* 链路日志:s_link_last 初值 0xFFFF 是哨兵——第一次看到计数只记基线、不打日志 + * (否则每次上电都会冒一条"链路 up"的假事件);此后计数一变就必须留痕。 + * 本文件开头那次 priming 就是"基线"那一趟(TC-NETEV-051a),这里是"变化"那一趟。 */ + reset_all(); + g_fake.link_change = 7u; + infos = g_nev_info_count; + recon_step(9800u); + CHECK(g_nev_info_count == (infos + 1), + "TC-NETEV-051b 链路变更计数一变就留痕(基线那趟已由 051a 验证不打)"); +} + +int main(void) +{ + printf("=== NET Events Tests ===\n"); + + tc_connect(); + tc_disconnect(); + tc_recv_send_timeout(); + tc_lost_probe(); + tc_reconcile(); + + printf("\n=== NET Events 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_events/run_unit_test.py b/test/host/net_events/run_unit_test.py new file mode 100644 index 00000000..beba0e8c --- /dev/null +++ b/test/host/net_events/run_unit_test.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""NET 层事件分发与周期对账(net_events)主机侧单元测试运行器(L0.5,纯标准库)。 + +做什么: + 用本机 MSVC 编译 test/host/net_events/net_events_test.c(以 #include 纳入生产源 + Drivers/BSP/NET/net_events.c 与共享假芯片 test/host/common/ch395f_fake.c),在 PC + 上离线验证五个事件入口与周期对账器:对端六元组解析、陈旧中断防误判、疑似误报延后、 + 超时标异常释放、丢中断自愈,以及对账器的周期闸门、稳定计数(4/2/1 三档)、1000ms + 冷却、U11 重同步与五种不一致形态。工具链与判定委托 unit_test_harness.py。 + +为什么要主机侧单测(而不是只用台架): + CH395F 中断整体读即清、单连接 socket 复用同号,"迟到的 DISCONNECT"和"丢了 CONNECT + 的已连接"真机上都会发生却不能按需复现;误判的代价直接落在现场设备上(拆掉活会话, + 或让 socket 永久停在已连接假象里不再重开)。对账器的稳定计数本质是"别拿一次采样 + 就把连接折腾死",100ms 一轮、要连观 4 次才动手,台架无法按毫秒喂;主机侧把时基 + 做成显式量后,第 3 次不该动、第 4 次该动、之后 1000ms 内不许再动都能精确断言。 + 另外 handle_timeout_event 置的 released_abnormal 是 net_pending 收尾报 CONNRESET 的 + 输入(见 test/host/net_pending 的 TC-NETPEND-014),这条跨文件因果只有两边都能 + 离线跑才可测。 + +桩的边界:net_events.h / net_types.h / net_pending.h / net_listener.h 用真实头; + net_socket.h 是桩,但 net_sock_raw 保留"未在用槽位也返回项"的真实语义(对账循环 + 必须能读 in_use==0);ch395f.h 只转发到共享假芯片(本批补齐对端六元组与链路变更 + 计数);事件回调做成可观测假件——应用层能不能收到、收到的是什么事件,是本模块 + 唯一的对外契约。刻意不定义 NET_BENCH_CMD_LOG(压测日志不是被测对象)。 + +用法(仓库根目录): + python test/host/net_events/run_unit_test.py + +退出码:0=PASS / 1=测试失败 / 2=环境错误。 + +设计依据:docs/网络/网络自愈架构设计.md §4.10、docs/网络/Net层阻塞模型架构设计.md、 +docs/测试说明/主机单测覆盖率口径.md §9 +创建日期: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 + +BUILD = TestBuild( + name="NET Events Tests", + src=os.path.join(HERE, "net_events_test.c"), + include_dirs=[ + os.path.join(HERE, "stub"), + os.path.join(ROOT, "test", "host", "common"), + os.path.join(ROOT, "Drivers", "BSP", "NET"), + HERE, + ], + summary_marker="=== NET Events Tests:", +) + +if __name__ == "__main__": + sys.exit(run_host_unit_test(BUILD)) + diff --git a/test/host/net_events/stub/FreeRTOS.h b/test/host/net_events/stub/FreeRTOS.h new file mode 100644 index 00000000..813954d8 --- /dev/null +++ b/test/host/net_events/stub/FreeRTOS.h @@ -0,0 +1,25 @@ +/* + * 模块名称:主机侧单测桩 - FreeRTOS.h(net_events 模块专用) + * 模块功能:宿主等价类型(TaskHandle_t 等),因为真实 net_pending.h 的原型绕不开。 + * ⚠ 仅供主机侧单测使用,不参与 MCU 构建。 + * 创建日期:2026-10-23 + */ +#ifndef FREERTOS_H +#define FREERTOS_H + +#include +#include + +typedef void *TaskHandle_t; +typedef long BaseType_t; +typedef unsigned long UBaseType_t; + +typedef enum { + eNoAction = 0, + eSetBits = 1, + eIncrement = 2, + eSetValueWithOverwrite = 3, + eSetValueWithoutOverwrite = 4 +} eNotifyAction; + +#endif /* FREERTOS_H */ diff --git a/test/host/net_events/stub/ch395f.h b/test/host/net_events/stub/ch395f.h new file mode 100644 index 00000000..a6eb26e5 --- /dev/null +++ b/test/host/net_events/stub/ch395f.h @@ -0,0 +1,10 @@ +/* + * 模块名称:主机侧单测桩 - ch395f.h(net_events 模块入口) + * 模块功能:只做转发,真身在 test/host/common/ch395f_fake.h/.c(NET 域共享假芯片)。 + * ⚠ 仅供主机侧单测使用,不参与 MCU 构建。 + * 创建日期:2026-10-23 + */ +#ifndef CH395F_H +#define CH395F_H +#include "ch395f_fake.h" +#endif /* CH395F_H */ diff --git a/test/host/net_events/stub/cmsis_os.h b/test/host/net_events/stub/cmsis_os.h new file mode 100644 index 00000000..d9550e64 --- /dev/null +++ b/test/host/net_events/stub/cmsis_os.h @@ -0,0 +1,10 @@ +/* + * 模块名称:主机侧单测桩 - cmsis_os.h(net_events 模块专用) + * 模块功能:空壳;net_events.c include 它但不用 CMSIS API(事件直接同步分发)。 + * ⚠ 仅供主机侧单测使用,不参与 MCU 构建。 + * 创建日期:2026-10-23 + */ +#ifndef CMSIS_OS_H +#define CMSIS_OS_H +#include +#endif /* CMSIS_OS_H */ diff --git a/test/host/net_events/stub/dbg_log.h b/test/host/net_events/stub/dbg_log.h new file mode 100644 index 00000000..a7e544d0 --- /dev/null +++ b/test/host/net_events/stub/dbg_log.h @@ -0,0 +1,20 @@ +/* + * 模块名称:主机侧单测桩 - dbg_log.h(net_events 模块专用) + * 模块功能:DBG_* 计数。注意 net_events.c 在 include 本头之前把 DBG_LINE_BUF_MAX + * 抬到 128U(AGENTS:深调用链模块必须抬,否则最长日志行会被截断), + * 本桩不关心缓冲大小,只数条数。 + * ⚠ 仅供主机侧单测使用,不参与 MCU 构建。 + * 创建日期:2026-10-23 + */ +#ifndef DBG_LOG_H +#define DBG_LOG_H + +extern int g_nev_info_count; +extern int g_nev_error_count; +extern int g_nev_debug_count; + +#define DBG_INFO(fmt, ...) do { (void)(fmt); g_nev_info_count++; } while (0) +#define DBG_DEBUG(fmt, ...) do { (void)(fmt); g_nev_debug_count++; } while (0) +#define DBG_ERROR(fmt, ...) do { (void)(fmt); g_nev_error_count++; } while (0) + +#endif /* DBG_LOG_H */ diff --git a/test/host/net_events/stub/main.h b/test/host/net_events/stub/main.h new file mode 100644 index 00000000..9791fb66 --- /dev/null +++ b/test/host/net_events/stub/main.h @@ -0,0 +1,11 @@ +/* + * 模块名称:主机侧单测桩 - main.h(net_events 模块专用) + * 模块功能:只给 HAL_GetTick(对账周期 100ms 与冷却 1000ms 两条时间轴的唯一致度源)。 + * ⚠ 仅供主机侧单测使用,不参与 MCU 构建。 + * 创建日期:2026-10-23 + */ +#ifndef MAIN_H +#define MAIN_H +#include +uint32_t HAL_GetTick(void); +#endif /* MAIN_H */ diff --git a/test/host/net_events/stub/net_latency_test.h b/test/host/net_events/stub/net_latency_test.h new file mode 100644 index 00000000..a8821ad6 --- /dev/null +++ b/test/host/net_events/stub/net_latency_test.h @@ -0,0 +1,9 @@ +/* + * 模块名称:主机侧单测桩 - net_latency_test.h(net_events 模块专用) + * 模块功能:空壳。 + * ⚠ 仅供主机侧单测使用,不参与 MCU 构建。 + * 创建日期:2026-10-23 + */ +#ifndef NET_LATENCY_TEST_H +#define NET_LATENCY_TEST_H +#endif /* NET_LATENCY_TEST_H */ diff --git a/test/host/net_events/stub/net_socket.h b/test/host/net_events/stub/net_socket.h new file mode 100644 index 00000000..978960b2 --- /dev/null +++ b/test/host/net_events/stub/net_socket.h @@ -0,0 +1,19 @@ +/* + * 模块名称:主机侧单测桩 - net_socket.h(NET 域共享形态,见 net_listener 同名文件) + * 模块功能:net_events.c 需要两个表项入口——net_sock_raw()(对账循环要能看到 in_use==0 + * 的槽位,真实头注释就写着"应用层请一律用 net_get_sock(),本函数仅供 + * net_events.c 的对账循环使用")与 net_get_sock();错误通道照抄真实头同名宏。 + * ⚠ 仅供主机侧单测使用,不参与 MCU 构建。 + * 创建日期:2026-10-23 + */ +#ifndef NET_SOCKET_H +#define NET_SOCKET_H + +#include "net_types.h" + +net_sock_t *net_get_sock(int sockfd); +net_sock_t *net_sock_raw(int sockfd); +int net_set_errno(int err); +#define NET_ERR_RETURN_1(err) (net_set_errno(err), -1) + +#endif /* NET_SOCKET_H */ diff --git a/test/host/net_events/stub/task.h b/test/host/net_events/stub/task.h new file mode 100644 index 00000000..8a8a7651 --- /dev/null +++ b/test/host/net_events/stub/task.h @@ -0,0 +1,15 @@ +/* + * 模块名称:主机侧单测桩 - task.h(net_events 模块专用) + * 模块功能:只声明 xTaskGetTickCount——它只在 NET_BENCH_CMD_LOG 打开时用到,本模块 + * 刻意不开该宏(压测日志不是被测对象),声明留着是为了原型完整。 + * ⚠ 仅供主机侧单测使用,不参与 MCU 构建。 + * 创建日期:2026-10-23 + */ +#ifndef TASK_H +#define TASK_H + +#include "FreeRTOS.h" + +uint32_t xTaskGetTickCount(void); + +#endif /* TASK_H */ diff --git a/test/host/net_events/stub/test_config.h b/test/host/net_events/stub/test_config.h new file mode 100644 index 00000000..485a64e9 --- /dev/null +++ b/test/host/net_events/stub/test_config.h @@ -0,0 +1,10 @@ +/* + * 模块名称:主机侧单测桩 - test_config.h(net_events 模块专用) + * 模块功能:空壳。**刻意不定义 NET_BENCH_CMD_LOG**:压测命令日志不是被测对象, + * 且它依赖真实 tick 序列;不定义即被条件编译整段摘掉。 + * ⚠ 仅供主机侧单测使用,不参与 MCU 构建。 + * 创建日期:2026-10-23 + */ +#ifndef TEST_CONFIG_H +#define TEST_CONFIG_H +#endif /* TEST_CONFIG_H */ From 8aaf9bbb0ecd4502ee8ee56eab05afa7107fbef3 Mon Sep 17 00:00:00 2001 From: Wanderingss <1624155937@qq.com> Date: Fri, 23 Oct 2026 22:58:48 +0800 Subject: [PATCH 13/15] =?UTF-8?q?test(NET/lftpd):=20lftpd=5Finet=20?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=EF=BC=8828=20=E6=9D=A1=EF=BC=8C127/127=20=3D?= =?UTF-8?q?=20100%=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FTP 的 socket 适配层吃满——全仓第一个 **100% 行覆盖**的文件(127/127)。 新增 test/host/lftpd_inet/,28 条断言全部 MSVC 编译通过并 PASS。 钉住的契约: - 端口字节序:bind 前必须过 net_htons(sin_port 的注释就写着"网络字节序"),问名字 回来要 ntohs 还原;问不到名字报 -1,不拿脏端口当结果。 - 失败路径一律关 socket:建口失败不 bind;bind 失败关口不 listen;listen 失败关口 并各留一条 ERROR(漏关就是白占一个号,CH395F 只有 8 个)。 - accept 每轮失败都让出 CPU(同一段里也喂狗),直到成功;**网络代次一变立刻撤掉 accept**——否则改了 IP 之后监听还挂在旧代次上,端口在新地址上根本不通。这是现场 最常见的自愈路径,台架上要凑"对面正连着我顺手改 IP"极难。 - 带超时的 accept:监听门撤防时**立即放弃**,不对着死监听耗满窗口;对端一直不开 数据通道就按 100ms 步进等满再 INFO 收场。 - read_line:CRLF 就地截成结束符(FTP 命令行的边界);跨包分片要能拼出整行;读到 0 (对端关闭)立刻失败;-2 表示这一片超时继续等;其它负值**原样返回**(错误码不许被 吞成 -1);等待按 200ms 分片、30 s 总预算到点退出(分片写错就饿死看门狗);缓冲 填满仍无行尾要失败且**不越界写**(最后一格留给结束符)。 - 短写续传:net_send 可以只写一部分,续传必须从正确偏移接着写(少半条响应就是协议 错乱);写 0 字节按失败处理;二进制写成功时回请求长度。 桩的口径:private/lftpd_inet.h 与 net_types.h 用真实头;net_socket.h 是桩但函数原型 与真实头逐字一致,并**照做真实头对 FreeRTOS.h / task.h 的 include**(lftpd_inet.c 的 TickType_t / vTaskDelay / pdMS_TO_TICKS 就是从那条链上拿的,桩少这一行就是 C2065)。 lftpd_task_wd_beat 是 __weak 空实现、主机侧替换不掉,因此用同段代码里的"睡 100ms" 次数当喂狗轮数的代理(写进桩注释,不假装能直接数到喂狗)。accept 循环带 100 轮跑飞 保险(net_select 批次被挂死教训换来的)。 覆盖率与台账:套件 25 → 26 全绿;files_tested 29 → 30;未触及 .c 37 → 36;分母 3,945 → 4,072 行;line=3599/4072(**88.4%**,连涨三批)、func=272/299(91.0%)。 恒等式 36 = 2(P1 剩:lftpd.c 与 iec104_serv.c) + 5(P2) + 29(P3) 仍咬合。编号补建 TC-HOST-LFTINET(游标 070),族数 19 → 20。 自查:test-cppcheck Passed;check_ids --strict 通过;doc-links 35 处(= 存量); 9 个新增/改动文件 BOM/CR/U+FFFD/行尾逐项净。零生产代码改动,未跑 @build。 --all-files 仍只有 mem-numbers / mem-budget(.map 台账漂移,等 Lead 合并后统一回填) 与 ui-golden(ref/*.bin 不入库,新 worktree 天生没有黄金参考)三条失败,与本分支无关。 --- docs/测试说明/主机单测覆盖率口径.md | 6 +- scripts/id_registry.json | 22 ++ scripts/test_coverage_baseline.json | 40 ++- test/host/lftpd_inet/lftpd_inet_test.c | 456 +++++++++++++++++++++++++ test/host/lftpd_inet/run_unit_test.py | 56 +++ test/host/lftpd_inet/stub/FreeRTOS.h | 21 ++ test/host/lftpd_inet/stub/dbg_log.h | 19 ++ test/host/lftpd_inet/stub/net_socket.h | 34 ++ test/host/lftpd_inet/stub/task.h | 20 ++ 9 files changed, 657 insertions(+), 17 deletions(-) create mode 100644 test/host/lftpd_inet/lftpd_inet_test.c create mode 100644 test/host/lftpd_inet/run_unit_test.py create mode 100644 test/host/lftpd_inet/stub/FreeRTOS.h create mode 100644 test/host/lftpd_inet/stub/dbg_log.h create mode 100644 test/host/lftpd_inet/stub/net_socket.h create mode 100644 test/host/lftpd_inet/stub/task.h diff --git a/docs/测试说明/主机单测覆盖率口径.md b/docs/测试说明/主机单测覆盖率口径.md index 76197ae3..003a2bc3 100644 --- a/docs/测试说明/主机单测覆盖率口径.md +++ b/docs/测试说明/主机单测覆盖率口径.md @@ -222,9 +222,9 @@ |---|---|---|---| | 配置与工具 4 块 | ✅ 完成 | `test/host/{net_cfg,ftp_cfg,prot_set_store,task_prof}`(104 条) | `net_cfg.c` 108/112、`task_prof.c` 88/93、`ftp_cfg.c` 35/36、`prot_set_store.c` 27/29 | -| FTP 一套(4 块里 2 块) | 🟡 部分 | `test/host/lftpd_io`(33 条,含 `lftpd_string.c`) | - `lftpd_io.c` **85/159 = 53.5%**、`lftpd_string.c` 8/10;`read/write/close/stat/unlink/readdir` - 未测(要先把假 `FIL/FILINFO` 的字段语义做实,否则断言落在假件自己写的规则上) | +| FTP 一套(4 块里 3 块) | ✅ 大部分 | `test/host/lftpd_io`(33 条)、`test/host/lftpd_inet`(28 条) | + `lftpd_inet.c` **127/127 = 100%**(全仓第一个满覆盖文件)、`lftpd_string.c` 8/10、 + `lftpd_io.c` 85/159(`read/write/close/stat/unlink/readdir` 待假 `FIL/FILINFO` 字段语义) | | NET 四块之一:`net_select.c` | ✅ 完成 | `test/host/net_select`(21 条) | `net_select.c` 97/114 = **85.1%** | | `lftpd.c` / `lftpd_inet.c` | ⬜ 未开始 | — | 需假 `net_socket` 层(`time_sync`/stub 那份可扩展) | | NET 三块之一:`net_listener.c` | ✅ 完成(**125/126 = 99.2%**) | diff --git a/scripts/id_registry.json b/scripts/id_registry.json index d5c63445..8c18c423 100644 --- a/scripts/id_registry.json +++ b/scripts/id_registry.json @@ -1812,6 +1812,28 @@ "subitem_ok": true, "owner_note": "B 层口径(2026-10-21 批 3 定:这些号在代码目录外零消费者,属主登记处 = 单测代码本身,不逐号补文档);2026-10-23 随覆盖率口径 §9 假 CH395F 第三批补登记", "cursor_note": "游标按实测最大号收录(2026-10-23):越界即视为未登记的冒号牌" + }, + { + "name": "TC-HOST-LFTINET", + "class": "TC", + "title": "主机侧单测用例号:FTP 的 socket 适配层(test/host/lftpd_inet)", + "pattern": "\\bTC-LFTINET-[0-9]{1,4}[a-z]?\\b", + "digit_width": [ + 2, + 4 + ], + "owner_docs_glob": [ + "test/host/lftpd_inet/*" + ], + "scope": "global", + "frozen": false, + "cursor": "TC-LFTINET-070", + "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 FTP 批次补登记", + "cursor_note": "游标按实测最大号收录(2026-10-23):越界即视为未登记的冒号牌" } ], "unmanaged_bare": { diff --git a/scripts/test_coverage_baseline.json b/scripts/test_coverage_baseline.json index 4efe76e4..165d8140 100644 --- a/scripts/test_coverage_baseline.json +++ b/scripts/test_coverage_baseline.json @@ -168,6 +168,14 @@ "lines_covered": 37, "lines_valid": 38 }, + "Drivers/BSP/NET/lftpd/lftpd_inet.c": { + "func_rate": 1.0, + "funcs_covered": 9, + "funcs_valid": 9, + "line_rate": 1.0, + "lines_covered": 127, + "lines_valid": 127 + }, "Drivers/BSP/NET/lftpd/lftpd_io.c": { "func_rate": 0.3846, "funcs_covered": 5, @@ -294,6 +302,13 @@ "lines_total": 1193, "ok": true }, + "lftpd_inet": { + "files": { + "Drivers/BSP/NET/lftpd/lftpd_inet.c": 127 + }, + "lines_total": 127, + "ok": true + }, "lftpd_io": { "files": { "Drivers/BSP/NET/lftpd/lftpd_io.c": 159, @@ -453,6 +468,7 @@ "App/util/task_watchdog.c", "Drivers/BSP/GD5F2GQ5UE/nand_ftl.c", "Drivers/BSP/NET/lftpd/lftpd_auth.c", + "Drivers/BSP/NET/lftpd/lftpd_inet.c", "Drivers/BSP/NET/lftpd/lftpd_io.c", "Drivers/BSP/NET/lftpd/lftpd_string.c", "Drivers/BSP/NET/net_events.c", @@ -464,16 +480,16 @@ ], "totals": { "files_generated_excluded": 5, - "files_tested": 29, - "files_untested": 115, - "func_rate": 0.9069, - "funcs_covered": 263, - "funcs_valid": 290, - "line_rate": 0.8801, - "lines_covered": 3472, - "lines_valid": 3945, - "modules_ok": 25, - "modules_total": 25 + "files_tested": 30, + "files_untested": 114, + "func_rate": 0.9097, + "funcs_covered": 272, + "funcs_valid": 299, + "line_rate": 0.8838, + "lines_covered": 3599, + "lines_valid": 4072, + "modules_ok": 26, + "modules_total": 26 }, "untested_sources": [ { @@ -540,10 +556,6 @@ "code_lines": 171, "path": "App/task/adc_task.c" }, - { - "code_lines": 171, - "path": "Drivers/BSP/NET/lftpd/lftpd_inet.c" - }, { "code_lines": 157, "path": "Drivers/BSP/TPAFE5160/tpafe5160.c" diff --git a/test/host/lftpd_inet/lftpd_inet_test.c b/test/host/lftpd_inet/lftpd_inet_test.c new file mode 100644 index 00000000..17687bb5 --- /dev/null +++ b/test/host/lftpd_inet/lftpd_inet_test.c @@ -0,0 +1,456 @@ +/* + * 模块名称:FTP 的 socket 适配层(lftpd_inet)主机侧单元测试 + * 模块功能:在 PC 上离线验证 Drivers/BSP/NET/lftpd/lftpd_inet.c 的监听/接受/读写 + * 适配:端口必须过 htons、回来的端口要 ntohs 还原;accept 循环每轮喂狗并 + * 让出 CPU、**网络代次一变就立刻撤掉 accept**(否则新 IP 生效后监听还挂在 + * 旧代次上);带超时的 accept 在监听门撤防时立即放弃;read_line 的 CRLF + * 截断、分片拼接、-2 继续等、其它负值原样返回、200ms 分片与 30 s 总预算; + * 短写续传(FTP 响应少半条就是协议错乱)。 + * + * 被测对象:真实源文件 ../../../../Drivers/BSP/NET/lftpd/lftpd_inet.c(#include 纳入)。 + * private/lftpd_inet.h 用真实头;net_types.h 用**真实头**(struct + * net_sockaddr_in 的 sin_port 注释就写着"网络字节序",htons/ntohs 的契约 + * 靠它才钉得住)。假的是 socket 层本身:accept 序列、bind/listen 返回码、 + * 代次变化点、监听门开关、recv 的返回序列 + 字节流、send 的短写序列, + * 外加可控 tick 与 vTaskDelay 计数(带跑飞保险)。 + * + * 为什么要主机侧单测(而不是只靠台架): + * 1. 代次撤 accept 与"监听门撤防就放弃"这两条只在**异常时刻**成立(网络重建、 + * 客户端连上控制口却迟迟不开数据通道)。台架上要凑"对面正连着我顺手改 IP" + * 这种时序极难,而真机上它是现场最常见的自愈路径。 + * 2. 200ms 分片 + 30 s 总预算是 FTP 控制信道的等待结构:分片写错会把看门狗饿到 + * 复位(每轮都要喂),预算写错会让 ftpTask 永久卡在一条命令上。两者都需要 + * "按毫秒数着"才能验证。 + * 3. 短写续传:net_send 可以只写一部分;如果适配层按一次写完处理,FTP 响应就会 + * 少半条(客户端表现为协议错乱、卡住),而这种短写在真机上取决于当时缓冲余量。 + * 4. lftpd_task_wd_beat 在本文件是 __weak 空实现(真机由 lftpd.c 覆盖),主机侧 + * 替换不掉,所以用同一段里的"睡 100ms"次数当喂狗轮数的代理(已在桩里注明)。 + * + * 覆盖点:TC-LFTINET-001..004 监听;005/006 端口还原;010..012 accept 与代次; + * 020..022 带超时 accept 与撤防;030..036 read_line;040..043 write_string; + * 050 write;060..062 read;070 close。 + * + * 通过:全部 TC PASS(main 返回 0)。 + * 入口:int main(void),返回 0=PASS / 1=FAIL(由 run 脚本判读)。 + * + * 创建日期:2026-10-23 + */ + +#include +#include +#include + +#include "private/lftpd_inet.h" +#include "net_types.h" +#include "net_socket.h" + +#include "lftpd_inet.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) + +int g_lfi_info_count; +int g_lfi_error_count; +int g_lfi_debug_count; + +/* ===================== 可控 socket 层 ===================== */ + +typedef struct { + int socket_rc; + int bind_rc; + int listen_rc; + int getsock_rc; + uint16_t getsock_port_net; + int accept_seq[16]; + int accept_n; + int accept_i; + int accept_calls; + int gen; + int gen_flip_after; + int armed; + int recv_seq[24]; + int recv_n; + int recv_i; + int recv_calls; + uint32_t last_recv_timeout; + char rx[512]; + int rx_pos; + int send_seq[8]; + int send_n; + int send_i; + int send_calls; + int send_off[8]; + const char *send_base; + int socket_calls; + int bind_calls; + int listen_calls; + int close_calls; + int last_domain; + int last_type; + uint16_t last_bind_port_net; + int delay_calls; + TickType_t tick; +} lfi_net_t; + +static lfi_net_t g_net; + +static void net_reset(void) +{ + (void)memset(&g_net, 0, sizeof(g_net)); + g_net.socket_rc = 3; + g_net.bind_rc = 0; + g_net.listen_rc = 0; + g_net.getsock_rc = 0; + g_net.armed = 1; + g_net.gen = 7; + g_net.tick = 0u; +} + +uint32_t net_get_generation(void) { return (uint32_t)g_net.gen; } +int net_listener_armed(int sockfd) { (void)sockfd; return g_net.armed; } + +uint16_t net_htons(uint16_t hostshort) +{ + return (uint16_t)((uint16_t)((hostshort & 0xFFU) << 8) | (uint16_t)(hostshort >> 8)); +} + +uint16_t net_ntohs(uint16_t netshort) +{ + return net_htons(netshort); +} + +int net_socket(int domain, int type) +{ + g_net.socket_calls++; + g_net.last_domain = domain; + g_net.last_type = type; + return g_net.socket_rc; +} + +int net_bind(int sockfd, const struct net_sockaddr *addr, int addrlen) +{ + const struct net_sockaddr_in *a = (const struct net_sockaddr_in *)addr; + + (void)sockfd; + (void)addrlen; + g_net.bind_calls++; + g_net.last_bind_port_net = a->sin_port; + return g_net.bind_rc; +} + +int net_listen(int sockfd) +{ + (void)sockfd; + g_net.listen_calls++; + return g_net.listen_rc; +} + +int net_close(int sockfd) +{ + (void)sockfd; + g_net.close_calls++; + return 0; +} + +int net_accept(int sockfd, struct net_sockaddr *addr, int *addrlen) +{ + int r; + + (void)sockfd; + (void)addr; + (void)addrlen; + g_net.accept_calls++; + if ((g_net.gen_flip_after > 0) && (g_net.accept_calls >= g_net.gen_flip_after)) { + g_net.gen++; /* 模拟网络在这期间重建(代次 +1) */ + } + if (g_net.accept_i < g_net.accept_n) { + r = g_net.accept_seq[g_net.accept_i++]; + } else { + r = (g_net.accept_n > 0) ? g_net.accept_seq[g_net.accept_n - 1] : -1; + } + return r; +} + +int net_getsockname(int sockfd, struct net_sockaddr *addr, int *addrlen) +{ + struct net_sockaddr_in *a = (struct net_sockaddr_in *)addr; + + (void)sockfd; + (void)addrlen; + a->sin_port = g_net.getsock_port_net; + return g_net.getsock_rc; +} + +int net_recv_to(int sockfd, void *buf, int len, uint32_t timeout_ms) +{ + int r; + int i; + + (void)sockfd; + g_net.recv_calls++; + g_net.last_recv_timeout = timeout_ms; + /* 每次调用当作耗掉一个 200ms 片:让 30 s 总预算的到点路径可测 */ + g_net.tick += 200u; + if (g_net.recv_i < g_net.recv_n) { + r = g_net.recv_seq[g_net.recv_i++]; + } else { + r = (g_net.recv_n > 0) ? g_net.recv_seq[g_net.recv_n - 1] : -2; + } + if (r > 0) { + for (i = 0; (i < r) && (i < len) && (g_net.rx_pos < (int)sizeof(g_net.rx)); i++) { + ((char *)buf)[i] = g_net.rx[g_net.rx_pos++]; + } + } + return r; +} + +int net_send(int sockfd, const void *buf, int len, int flags) +{ + int r; + + (void)sockfd; + (void)flags; + if (g_net.send_base == NULL) { + g_net.send_base = (const char *)buf; + } + g_net.send_calls++; + if (g_net.send_i < 8) { + g_net.send_off[g_net.send_i] = (int)((const char *)buf - g_net.send_base); + } + if (g_net.send_i < g_net.send_n) { + r = g_net.send_seq[g_net.send_i++]; + } else { + r = (g_net.send_n > 0) ? g_net.send_seq[g_net.send_n - 1] : len; + } + return r; +} + +TickType_t xTaskGetTickCount(void) { return g_net.tick; } + +void vTaskDelay(TickType_t xTicksToDelay) +{ + (void)xTicksToDelay; + g_net.delay_calls++; + if (g_net.delay_calls > 100) { + /* 跑飞保险:本模块每条用例都必须在有限轮内退出(accept 序列要么成功、 + * 要么撤防、要么代次变化)。真跑飞就退出,绝不把挂死留给 pre-commit。 */ + printf(" ABORT accept/等待循环跑飞(>100 轮未退出)\n"); + fflush(stdout); + exit(2); + } +} + +static void logs_clear(void) +{ + g_lfi_info_count = 0; + g_lfi_error_count = 0; + g_lfi_debug_count = 0; +} + +/* ===================== 监听与端口还原 ===================== */ + +static void tc_listen(void) +{ + printf("-- TC-LFTINET-001..006 listen / port --\n"); + net_reset(); logs_clear(); + g_net.socket_rc = -1; + CHECK((lftpd_inet_listen(21) == -1) && (g_net.bind_calls == 0) && + (g_lfi_error_count == 1), "TC-LFTINET-001 建 socket 失败即返回,不再 bind"); + + net_reset(); logs_clear(); + g_net.bind_rc = -1; + CHECK((lftpd_inet_listen(21) == -1) && (g_net.close_calls == 1) && + (g_net.listen_calls == 0), "TC-LFTINET-002 bind 失败要把 socket 关掉"); + + net_reset(); logs_clear(); + g_net.listen_rc = -1; + CHECK((lftpd_inet_listen(21) == -1) && (g_net.close_calls == 1) && + (g_lfi_error_count == 1), "TC-LFTINET-003 listen 失败同样关socket 并留痕"); + + net_reset(); logs_clear(); + CHECK((lftpd_inet_listen(21) == 3) && (g_net.last_domain == NET_AF_INET) && + (g_net.last_type == NET_SOCK_STREAM) && + (g_net.last_bind_port_net == net_htons(21u)) && (g_net.listen_calls == 1), + "TC-LFTINET-004 流式监听口,且**端口过了 htons**(sin_port 是网络序)"); + + net_reset(); logs_clear(); + g_net.getsock_rc = -1; + CHECK((lftpd_inet_get_socket_port(3) == -1) && (g_lfi_error_count == 1), + "TC-LFTINET-005 问不到名字就报 -1(不拿脏端口当结果)"); + net_reset(); logs_clear(); + g_net.getsock_port_net = net_htons(8888u); + CHECK(lftpd_inet_get_socket_port(3) == 8888, + "TC-LFTINET-006 端口回来要 ntohs 还原成宿主序"); +} + +/* ===================== accept:喂狗、代次、撤防 ===================== */ + +static void tc_accept(void) +{ + printf("-- TC-LFTINET-010..012 / 020..022 accept --\n"); + net_reset(); logs_clear(); + g_net.accept_seq[0] = 9; g_net.accept_n = 1; + CHECK((lftpd_inet_accept(3) == 9) && (g_net.delay_calls == 0), + "TC-LFTINET-010 一击即中不睡不等"); + + net_reset(); logs_clear(); + g_net.accept_seq[0] = -1; g_net.accept_seq[1] = -1; g_net.accept_seq[2] = 9; + g_net.accept_n = 3; + CHECK((lftpd_inet_accept(3) == 9) && (g_net.delay_calls == 2) && + (g_lfi_info_count == 0), + "TC-LFTINET-011 每轮失败都让出一次 CPU(同段代码里也喂狗),直到成功"); + + net_reset(); logs_clear(); + g_net.accept_seq[0] = -1; g_net.accept_seq[1] = -1; g_net.accept_seq[2] = -1; + g_net.accept_n = 3; + g_net.gen_flip_after = 3; /* 第 3 轮时网络重建 */ + CHECK((lftpd_inet_accept(3) == -1) && (g_lfi_info_count == 1) && + (g_net.accept_calls == 3), + "TC-LFTINET-012 **代次一变立刻撤掉 accept**(否则新 IP 生效后监听还挂在旧代次)"); + + net_reset(); logs_clear(); + g_net.accept_seq[0] = -1; g_net.accept_seq[1] = 9; g_net.accept_n = 2; + CHECK((lftpd_inet_accept_timeout(3, 5000) == 9) && (g_net.delay_calls == 1), + "TC-LFTINET-020 超时窗内等到数据通道"); + + net_reset(); logs_clear(); + g_net.accept_seq[0] = -1; g_net.accept_seq[1] = -1; g_net.accept_n = 2; + g_net.armed = 0; /* 监听门被撤(会话已放弃) */ + CHECK((lftpd_inet_accept_timeout(3, 5000) == -1) && (g_net.accept_calls == 1) && + (g_lfi_error_count == 1), + "TC-LFTINET-021 监听门撤防立即放弃,不对着死监听耗满 5 s"); + + net_reset(); logs_clear(); + g_net.accept_seq[0] = -1; g_net.accept_n = 1; + CHECK((lftpd_inet_accept_timeout(3, 300) == -1) && (g_net.delay_calls == 3) && + (g_lfi_info_count == 1), + "TC-LFTINET-022 对端一直不开数据通道:按 100ms 步进等满后 INFO 收场"); +} + +/* ===================== read_line:分片、截断、预算 ===================== */ + +static void tc_read_line(void) +{ + char b[32]; + int rc; + + printf("-- TC-LFTINET-030..036 read_line --\n"); + net_reset(); logs_clear(); + (void)strcpy(g_net.rx, "USER admin\r\n"); + g_net.recv_seq[0] = 12; g_net.recv_n = 1; + rc = lftpd_inet_read_line(3, b, sizeof(b)); + CHECK((rc == 0) && (strcmp(b, "USER admin") == 0), + "TC-LFTINET-030 一次到位:CRLF 就地截成结束符(FTP 命令行的边界)"); + + net_reset(); logs_clear(); + (void)strcpy(g_net.rx, "USER admin\r\n"); + g_net.recv_seq[0] = 4; g_net.recv_seq[1] = 8; g_net.recv_n = 2; + rc = lftpd_inet_read_line(3, b, sizeof(b)); + CHECK((rc == 0) && (strcmp(b, "USER admin") == 0), + "TC-LFTINET-031 分两片到达也要拼出整行(跨包命令行)"); + + net_reset(); logs_clear(); + g_net.recv_seq[0] = 0; g_net.recv_n = 1; + CHECK(lftpd_inet_read_line(3, b, sizeof(b)) == -1, + "TC-LFTINET-032 读到 0(对端关闭)立刻失败,不进死循环"); + + net_reset(); logs_clear(); + (void)strcpy(g_net.rx, "NOOP\r\n"); + g_net.recv_seq[0] = -2; g_net.recv_seq[1] = 6; g_net.recv_n = 2; + rc = lftpd_inet_read_line(3, b, sizeof(b)); + CHECK((rc == 0) && (strcmp(b, "NOOP") == 0), + "TC-LFTINET-033 -2 表示这一片超时,继续等而不是当错误"); + + net_reset(); logs_clear(); + g_net.recv_seq[0] = -5; g_net.recv_n = 1; + CHECK(lftpd_inet_read_line(3, b, sizeof(b)) == -5, + "TC-LFTINET-034 其它负值**原样返回**(错误码不许被吞成 -1)"); + + net_reset(); logs_clear(); + g_net.recv_seq[0] = -2; g_net.recv_n = 1; /* 一直超时,每轮耗 200ms */ + rc = lftpd_inet_read_line(3, b, sizeof(b)); + CHECK((rc == -1) && (g_net.recv_calls == 150) && + (g_net.last_recv_timeout <= 200u), + "TC-LFTINET-035 30 s 总预算按 200ms 分片累加,到点退出(饿不到看门狗)"); + + net_reset(); logs_clear(); + (void)memset(g_net.rx, 'A', 40); /* 没有 CRLF 的长行 */ + g_net.recv_seq[0] = 7; g_net.recv_n = 1; + (void)memset(b, 0, sizeof(b)); + rc = lftpd_inet_read_line(3, b, 8); + CHECK((rc == -1) && (b[7] == '\0'), + "TC-LFTINET-036 缓冲填满仍无行尾:失败且**不越界写**(留最后一格给结束符)"); +} + +/* ===================== 写与读 ===================== */ + +static void tc_write_read(void) +{ + char b[16]; + const char msg[] = "220 ready\r\n"; + + printf("-- TC-LFTINET-040..070 write / read / close --\n"); + net_reset(); logs_clear(); + g_net.send_seq[0] = 12; g_net.send_n = 1; + CHECK((lftpd_inet_write_string(3, msg) == 0) && (g_net.send_calls == 1), + "TC-LFTINET-040 一次写完"); + + net_reset(); logs_clear(); + g_net.send_seq[0] = 5; g_net.send_seq[1] = 7; g_net.send_n = 2; + CHECK((lftpd_inet_write_string(3, msg) == 0) && (g_net.send_calls == 2) && + (g_net.send_off[1] == 5), + "TC-LFTINET-041 短写后续传从**正确的偏移**接着写(少半条响应就是协议错乱)"); + + net_reset(); logs_clear(); + g_net.send_seq[0] = 0; g_net.send_n = 1; + CHECK((lftpd_inet_write_string(3, msg) == -1) && (g_lfi_error_count == 1), + "TC-LFTINET-042 写 0 字节按失败处理(不是当成成功写了 0 字节)"); + + net_reset(); logs_clear(); + g_net.send_seq[0] = -1; g_net.send_n = 1; + CHECK(lftpd_inet_write(3, msg, 12) == -1, "TC-LFTINET-043 写失败原样回 -1"); + + net_reset(); logs_clear(); + g_net.send_seq[0] = 4; g_net.send_seq[1] = 6; g_net.send_n = 2; + CHECK((lftpd_inet_write(3, msg, 10) == 10) && (g_net.send_calls == 2), + "TC-LFTINET-050 二进制写也走短写续传,成功时回**请求长度**"); + + net_reset(); logs_clear(); + (void)strcpy(g_net.rx, "STOR\r\n"); + g_net.recv_seq[0] = 4; g_net.recv_n = 1; + CHECK(lftpd_inet_read(3, b, sizeof(b)) == 4, "TC-LFTINET-060 读到数据立刻交还"); + + net_reset(); logs_clear(); + g_net.recv_seq[0] = -1; g_net.recv_n = 1; + CHECK(lftpd_inet_read(3, b, sizeof(b)) == -1, "TC-LFTINET-061 -1 直接失败返回"); + + net_reset(); logs_clear(); + g_net.recv_seq[0] = -3; g_net.recv_n = 1; + CHECK((lftpd_inet_read(3, b, sizeof(b)) == -1) && (g_net.recv_calls > 1), + "TC-LFTINET-062 其它负值继续等,直到 30 s 预算耗尽"); + + net_reset(); logs_clear(); + CHECK((lftpd_inet_close(3) == 0) && (g_net.close_calls == 1), + "TC-LFTINET-070 close 就是转 net_close"); +} + +int main(void) +{ + printf("=== LFTPD INET Tests ===\n"); + + tc_listen(); + tc_accept(); + tc_read_line(); + tc_write_read(); + + printf("\n=== LFTPD INET Tests: %d/%d PASSED (failed=%d) ===\n", + s_passed, s_total, s_failed); + return (s_failed == 0) ? 0 : 1; +} diff --git a/test/host/lftpd_inet/run_unit_test.py b/test/host/lftpd_inet/run_unit_test.py new file mode 100644 index 00000000..e182379d --- /dev/null +++ b/test/host/lftpd_inet/run_unit_test.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""FTP socket 适配层(lftpd_inet)主机侧单元测试运行器(L0.5,纯标准库)。 + +做什么: + 用本机 MSVC 编译 test/host/lftpd_inet/lftpd_inet_test.c(以 #include 纳入生产源 + Drivers/BSP/NET/lftpd/lftpd_inet.c),离线验证监听/接受/读写适配:端口过 htons、 + accept 每轮喂狗让出、网络代次一变就撤 accept、监听门撤防立即放弃、read_line 的 + CRLF 截断与分片拼接与 200ms 分片 + 30s 总预算、错误码原样返回、短写续传。 + +为什么要主机侧单测(而不是只用台架): + 代次撤 accept 与撤防放弃只在异常时刻成立(网络重建、客户端连上控制口却不开数据 + 通道),台架极难复现而真机上是常见自愈路径;200ms 分片写错会饿死看门狗、30s 预算 + 写错会让 ftpTask 永久卡在一条命令上;短写没续传就是"FTP 响应少半条"。这些都要 + 按毫秒与字节数着测。 + +桩的边界:private/lftpd_inet.h 与 net_types.h 用真实头(struct net_sockaddr_in 的 + sin_port 注释就写着"网络字节序",htons/ntohs 契约靠它钉住);net_socket.h 是桩, + 函数原型与真实头逐字一致;FreeRTOS.h / task.h 给宿主 tick 与 vTaskDelay(带跑飞 + 保险)。lftpd_task_wd_beat 是 __weak 空实现、主机侧替换不掉,故用同段代码里的 + "睡 100ms"次数当喂狗轮数的代理。 + +用法(仓库根目录): + python test/host/lftpd_inet/run_unit_test.py + +退出码:0=PASS / 1=测试失败 / 2=环境错误。 + +设计依据:docs/子系统设计/网络配置说明.md §3.4、docs/测试说明/FTP_Test_Guide.md、 +docs/测试说明/主机单测覆盖率口径.md §9 +创建日期: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 + +BUILD = TestBuild( + name="LFTPD INET Tests", + src=os.path.join(HERE, "lftpd_inet_test.c"), + include_dirs=[ + os.path.join(HERE, "stub"), + os.path.join(ROOT, "Drivers", "BSP", "NET", "lftpd"), + os.path.join(ROOT, "Drivers", "BSP", "NET"), + HERE, + ], + extra_flags=["/D_CRT_SECURE_NO_WARNINGS"], + summary_marker="=== LFTPD INET Tests:", +) + +if __name__ == "__main__": + sys.exit(run_host_unit_test(BUILD)) + diff --git a/test/host/lftpd_inet/stub/FreeRTOS.h b/test/host/lftpd_inet/stub/FreeRTOS.h new file mode 100644 index 00000000..86b6ecd6 --- /dev/null +++ b/test/host/lftpd_inet/stub/FreeRTOS.h @@ -0,0 +1,21 @@ +/* + * 模块名称:主机侧单测桩 - FreeRTOS.h(lftpd_inet 模块专用) + * 模块功能:宿主等价类型与 tick 换算。本仓 tick = 1ms,故 pdMS_TO_TICKS 就是原值—— + * 这不是偷懒:写成本机换算,测试里的 30000ms 预算与 200ms 分片才可直接读。 + * ⚠ 仅供主机侧单测使用,不参与 MCU 构建。 + * 创建日期:2026-10-23 + */ +#ifndef FREERTOS_H +#define FREERTOS_H + +#include +#include + +typedef uint32_t TickType_t; +typedef long BaseType_t; +typedef unsigned long UBaseType_t; +typedef void *TaskHandle_t; + +#define pdMS_TO_TICKS(ms) ((TickType_t)(ms)) + +#endif /* FREERTOS_H */ diff --git a/test/host/lftpd_inet/stub/dbg_log.h b/test/host/lftpd_inet/stub/dbg_log.h new file mode 100644 index 00000000..7f544921 --- /dev/null +++ b/test/host/lftpd_inet/stub/dbg_log.h @@ -0,0 +1,19 @@ +/* + * 模块名称:主机侧单测桩 - dbg_log.h(lftpd_inet 模块专用) + * 模块功能:DBG_* 换成计数,让"建 socket/bind/listen 失败各留一条 ERROR""对端不开 + * 数据通道时只 INFO"这些通道可断言。 + * ⚠ 仅供主机侧单测使用,不参与 MCU 构建。 + * 创建日期:2026-10-23 + */ +#ifndef DBG_LOG_H +#define DBG_LOG_H + +extern int g_lfi_info_count; +extern int g_lfi_error_count; +extern int g_lfi_debug_count; + +#define DBG_INFO(fmt, ...) do { (void)(fmt); g_lfi_info_count++; } while (0) +#define DBG_DEBUG(fmt, ...) do { (void)(fmt); g_lfi_debug_count++; } while (0) +#define DBG_ERROR(fmt, ...) do { (void)(fmt); g_lfi_error_count++; } while (0) + +#endif /* DBG_LOG_H */ diff --git a/test/host/lftpd_inet/stub/net_socket.h b/test/host/lftpd_inet/stub/net_socket.h new file mode 100644 index 00000000..78d15e81 --- /dev/null +++ b/test/host/lftpd_inet/stub/net_socket.h @@ -0,0 +1,34 @@ +/* + * 模块名称:主机侧单测桩 - net_socket.h(lftpd_inet 模块专用) + * 模块功能:给 FTP 的 inet 适配层一个可控的 socket 面。地址结构与 NET_AF_INET / + * NET_SOCK_STREAM 一律取**真实** net_types.h(sin_port 的注释就写着 + * "网络字节序",本模块的 htons/ntohs 契约靠它才钉得住);下面这些函数 + * 原型与真实 net_socket.h 逐字一致(不一致就会在被测代码的调用点报错)。 + * ⚠ 仅供主机侧单测使用,不参与 MCU 构建。真实 net_socket.h 带整条 netTask 消息队列 + * 与芯片依赖,PC 上编不动。 + * 创建日期:2026-10-23 + */ +#ifndef NET_SOCKET_H +#define NET_SOCKET_H + +/* 真实 net_socket.h 会把 FreeRTOS.h / task.h 一起带给调用方, + * lftpd_inet.c 正是靠那条链拿 TickType_t / vTaskDelay / pdMS_TO_TICKS, + * 所以桩必须照做同样的 include(少一行就是 C2065 未声明)。 */ +#include "FreeRTOS.h" +#include "task.h" +#include "net_types.h" + +int net_socket(int domain, int type); +int net_bind(int sockfd, const struct net_sockaddr *addr, int addrlen); +int net_listen(int sockfd); +int net_close(int sockfd); +int net_accept(int sockfd, struct net_sockaddr *addr, int *addrlen); +int net_send(int sockfd, const void *buf, int len, int flags); +int net_recv_to(int sockfd, void *buf, int len, uint32_t timeout_ms); +int net_getsockname(int sockfd, struct net_sockaddr *addr, int *addrlen); +uint16_t net_htons(uint16_t hostshort); +uint16_t net_ntohs(uint16_t netshort); +uint32_t net_get_generation(void); +int net_listener_armed(int sockfd); + +#endif /* NET_SOCKET_H */ diff --git a/test/host/lftpd_inet/stub/task.h b/test/host/lftpd_inet/stub/task.h new file mode 100644 index 00000000..7ddf9b4b --- /dev/null +++ b/test/host/lftpd_inet/stub/task.h @@ -0,0 +1,20 @@ +/* + * 模块名称:主机侧单测桩 - task.h(lftpd_inet 模块专用) + * 模块功能:只声明两个时间入口:xTaskGetTickCount(read_line / read 的 30 s 总预算 + * 时基)与 vTaskDelay(accept 轮的让出点,兼作"这一轮真的等了"的证据)。 + * ⚠ 仅供主机侧单测使用,不参与 MCU 构建。 + * 为什么用 vTaskDelay 计数而不是直接数喂狗:lftpd_task_wd_beat 在本文件里是 + * __weak 空实现(真机由 lftpd.c 覆盖成真实喂狗),ARMCC 的 weak 在 MSVC 下走的是 + * 普通定义那条支路,主机侧**替换不掉**它。而每轮 accept 失败后"喂狗 + 睡 100ms" + * 是同一段代码里的相邻两句,所以睡的次数就是喂狗轮数的可靠代理。 + * 创建日期:2026-10-23 + */ +#ifndef TASK_H +#define TASK_H + +#include "FreeRTOS.h" + +TickType_t xTaskGetTickCount(void); +void vTaskDelay(TickType_t xTicksToDelay); + +#endif /* TASK_H */ From a0c261b5eec6ec17646aa1492752784a38a17f0d Mon Sep 17 00:00:00 2001 From: Wanderingss <1624155937@qq.com> Date: Fri, 23 Oct 2026 23:16:56 +0800 Subject: [PATCH 14/15] =?UTF-8?q?test(NET/lftpd):=20lftpd=20=E6=8E=A7?= =?UTF-8?q?=E5=88=B6=E9=80=9A=E9=81=93=E6=A8=A1=E5=9D=97=EF=BC=8835=20?= =?UTF-8?q?=E6=9D=A1=EF=BC=89=EF=BC=8CFTP=20=E4=B8=80=E5=A5=97=E5=9B=9B?= =?UTF-8?q?=E5=9D=97=E5=85=A8=E9=83=A8=E8=BF=9B=E5=88=86=E6=AF=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 test/host/lftpd/,lftpd.c 覆盖 304/473 = 64.3%。做法是把生产源整个 #include 进 TU,于是命令表 commands[]、17 个 static cmd_* 与 handle_control_channel 都能直接驱动: 假 inet 层给"脚本化命令行队列 + 响应记录",假 io 层给"极简内存视图 + 可注入失败", 认证与配置当可注入黑盒(认证本身有 test/host/ftp_auth 的 10 条专测,不重复验)。 钉住的契约(都是真机上贵、却难按需复现的那几类): - STOR 传到一半写失败必须 **unlink 半成品**:否则 Flash 上永久留个坏文件,现场表现 是"升级包传坏了再也传不上去"。真机要卡准"传一半掐线"的时机,这里注一个短写就行。 - 数据通道是单连接 socket:LIST/RETR/STOR 用完必须关监听并把 data_socket 复位成 -1; accept 失败要报 425 不留残口,会话退出(QUIT 或对端断线)时 cleanup 也要关掉还没 用的被动监听。漏一次就是下一条命令拿着上一个号继续 accept,偶发连不上。 - 命令名缓冲只有 command_tmp[4+1],前置判断 if (index >= 5) 就是防 memcpy 越界; 恰好 5 个字符必须被拒(改一行这条就响)。 - 认证前置白名单:未登录只放行 USER/PASS/QUIT/FEAT/SYST/NOOP,其余一律 530;口令错 必须清 auth_user_ok 强制重走 USER;被锁与口令错都只回 530,不给攻击者区分提示; 配置读不到时 USER 无法匹配、PASS 直接 530(fail-closed)。 - 写开关([ftp] write_en=0)必须在**碰文件之前**挡住 STOR/DELE,且不去 accept 数据 通道;DELE 不许删目录。 - 227/229 编码:IP 经 net_htonl 后按高字节到低字节排(就是点分序),端口高/低两字节; 拿不到本机 IP 时要把刚建的监听关掉。FEAT 多行:首行 211-、四条能力无状态码、末行 211 结束(共 6 条响应)。 - 220 欢迎没送达(对端已走)就直接收工,不空转读命令;该留痕按真实分级算 INFO 而不是 ERROR(真实 private/lftpd_log.h 把 lftpd_log_peer 归到 INFO:任何人连 21 端口就走 就能无限制造固件级 ERROR,还会撞掉 @test 的 Forbidden 标记 [ERR])。 - 传输循环每轮喂狗(lftpd_task_wd_beat 计数可断言)。 主机侧两个必要的兼容处理(都写在桩注释里,不是随手改): 1. lftpd.c 会 include ff.h,而这份 FatFs 在 Windows 支路上把 winsock.h 拉进来,它带 "#define s_addr S_un.S_addr" 的 BSD 兼容宏,会把真实 net_types.h 里的字段声明 uint32_t s_addr; 展开成 uint32_t S_un.S_addr; 直接炸(实测 C2143 在 "." 前缺 ";")。 桩里只 #undef 这个污染宏,让固件那份结构体定义原样参与编译,被测代码读的还是真字段。 2. 引号 include 先从 lftpd.c 自己目录找 private/lftpd_log.h,桩目录赢不过它——于是 顺着用真实那份(它只是把 lftpd_log_* 映射成 DBG_*),只桩最底下的 dbg_log.h。这样 真实日志分级也一起被编译验证,比我自己重写一套更对。 自查(35 条里前两轮红了 4 条,全是**我的期望/前置态写错**,实现均正确):未登录时 FOO 被白名单以 530 拦掉测不到 502 分派;227 的 IP 顺序我把 htonl 前后搞反;FEAT 是 6 条响应我按 5 条索引;PASV 之后走完整会话会被 cleanup 关掉监听(那正是 006 要验的 行为),观察 PASV 留下的状态必须直调 cmd_pasv。 覆盖率与台账:套件 26 → 27 全绿;files_tested 30 → 31;未触及 .c 36 → 35;分母 4,072 → 4,545 行。**总体 line_rate 88.4% → 85.9% 是摊薄不是回退**(64.3% 的文件进 了分母),按 §5 口径要盯 files_tested/files_untested——它从 19 一路涨到 31。 恒等式 35 = 1(P1 剩 iec104_serv.c) + 5(P2) + 29(P3)。编号补建 TC-HOST-LFTPD (游标 034),族数 20 → 21。 自查门禁:test-cppcheck Passed;check_ids --strict 通过;doc-links 35 处(= 存量); 9 个文件 BOM/CR/U+FFFD/行尾逐项净。零生产代码改动,未跑 @build。--all-files 仍只有 mem-numbers / mem-budget(.map 台账漂移,等 Lead 合并后统一回填)与 ui-golden (ref/*.bin 不入库)三条失败,与本分支改动无关。 --- docs/测试说明/主机单测覆盖率口径.md | 7 +- scripts/id_registry.json | 22 + scripts/test_coverage_baseline.json | 41 +- test/host/lftpd/lftpd_test.c | 736 ++++++++++++++++++++++++++++ test/host/lftpd/run_unit_test.py | 62 +++ test/host/lftpd/stub/FreeRTOS.h | 24 + test/host/lftpd/stub/dbg_log.h | 29 ++ test/host/lftpd/stub/net_socket.h | 34 ++ test/host/lftpd/stub/task.h | 18 + 9 files changed, 956 insertions(+), 17 deletions(-) create mode 100644 test/host/lftpd/lftpd_test.c create mode 100644 test/host/lftpd/run_unit_test.py create mode 100644 test/host/lftpd/stub/FreeRTOS.h create mode 100644 test/host/lftpd/stub/dbg_log.h create mode 100644 test/host/lftpd/stub/net_socket.h create mode 100644 test/host/lftpd/stub/task.h diff --git a/docs/测试说明/主机单测覆盖率口径.md b/docs/测试说明/主机单测覆盖率口径.md index 003a2bc3..2d25a9ab 100644 --- a/docs/测试说明/主机单测覆盖率口径.md +++ b/docs/测试说明/主机单测覆盖率口径.md @@ -222,9 +222,10 @@ |---|---|---|---| | 配置与工具 4 块 | ✅ 完成 | `test/host/{net_cfg,ftp_cfg,prot_set_store,task_prof}`(104 条) | `net_cfg.c` 108/112、`task_prof.c` 88/93、`ftp_cfg.c` 35/36、`prot_set_store.c` 27/29 | -| FTP 一套(4 块里 3 块) | ✅ 大部分 | `test/host/lftpd_io`(33 条)、`test/host/lftpd_inet`(28 条) | - `lftpd_inet.c` **127/127 = 100%**(全仓第一个满覆盖文件)、`lftpd_string.c` 8/10、 - `lftpd_io.c` 85/159(`read/write/close/stat/unlink/readdir` 待假 `FIL/FILINFO` 字段语义) | +| FTP 一套(4 块全进分母) | ✅ 主体完成 | `test/host/lftpd_io`(33 条)、`test/host/lftpd_inet`(28 条)、 + `test/host/lftpd`(35 条) | `lftpd_inet.c` **127/127 = 100%**、`lftpd_io.c` 85/159、 + `lftpd.c` **304/473 = 64.3%**(控制通道闸门 + 17 条命令 + 被动模式 + 传输收尾都进来了; + 还剩 `lftpd_start` 主循环与 `send_list`/`send_file` 的分批边界分支) | | NET 四块之一:`net_select.c` | ✅ 完成 | `test/host/net_select`(21 条) | `net_select.c` 97/114 = **85.1%** | | `lftpd.c` / `lftpd_inet.c` | ⬜ 未开始 | — | 需假 `net_socket` 层(`time_sync`/stub 那份可扩展) | | NET 三块之一:`net_listener.c` | ✅ 完成(**125/126 = 99.2%**) | diff --git a/scripts/id_registry.json b/scripts/id_registry.json index 8c18c423..55fd0dca 100644 --- a/scripts/id_registry.json +++ b/scripts/id_registry.json @@ -1834,6 +1834,28 @@ "subitem_ok": true, "owner_note": "B 层口径(2026-10-21 批 3 定:这些号在代码目录外零消费者,属主登记处 = 单测代码本身,不逐号补文档);2026-10-23 随覆盖率口径 §9 P1 FTP 批次补登记", "cursor_note": "游标按实测最大号收录(2026-10-23):越界即视为未登记的冒号牌" + }, + { + "name": "TC-HOST-LFTPD", + "class": "TC", + "title": "主机侧单测用例号:FTP 控制通道协议层(test/host/lftpd)", + "pattern": "\\bTC-LFTPD-[0-9]{1,4}[a-z]?\\b", + "digit_width": [ + 2, + 4 + ], + "owner_docs_glob": [ + "test/host/lftpd/*" + ], + "scope": "global", + "frozen": false, + "cursor": "TC-LFTPD-034", + "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 FTP 批次补登记", + "cursor_note": "游标按实测最大号收录(2026-10-23):越界即视为未登记的冒号牌" } ], "unmanaged_bare": { diff --git a/scripts/test_coverage_baseline.json b/scripts/test_coverage_baseline.json index 165d8140..eb75b817 100644 --- a/scripts/test_coverage_baseline.json +++ b/scripts/test_coverage_baseline.json @@ -160,6 +160,14 @@ "lines_covered": 123, "lines_valid": 253 }, + "Drivers/BSP/NET/lftpd/lftpd.c": { + "func_rate": 0.8077, + "funcs_covered": 21, + "funcs_valid": 26, + "line_rate": 0.6427, + "lines_covered": 304, + "lines_valid": 473 + }, "Drivers/BSP/NET/lftpd/lftpd_auth.c": { "func_rate": 1.0, "funcs_covered": 2, @@ -302,6 +310,14 @@ "lines_total": 1193, "ok": true }, + "lftpd": { + "files": { + "Drivers/BSP/NET/lftpd/lftpd.c": 473, + "Drivers/BSP/NET/lftpd/lftpd_string.c": 7 + }, + "lines_total": 480, + "ok": true + }, "lftpd_inet": { "files": { "Drivers/BSP/NET/lftpd/lftpd_inet.c": 127 @@ -467,6 +483,7 @@ "App/util/task_prof.c", "App/util/task_watchdog.c", "Drivers/BSP/GD5F2GQ5UE/nand_ftl.c", + "Drivers/BSP/NET/lftpd/lftpd.c", "Drivers/BSP/NET/lftpd/lftpd_auth.c", "Drivers/BSP/NET/lftpd/lftpd_inet.c", "Drivers/BSP/NET/lftpd/lftpd_io.c", @@ -480,16 +497,16 @@ ], "totals": { "files_generated_excluded": 5, - "files_tested": 30, - "files_untested": 114, - "func_rate": 0.9097, - "funcs_covered": 272, - "funcs_valid": 299, - "line_rate": 0.8838, - "lines_covered": 3599, - "lines_valid": 4072, - "modules_ok": 26, - "modules_total": 26 + "files_tested": 31, + "files_untested": 113, + "func_rate": 0.9015, + "funcs_covered": 293, + "funcs_valid": 325, + "line_rate": 0.8587, + "lines_covered": 3903, + "lines_valid": 4545, + "modules_ok": 27, + "modules_total": 27 }, "untested_sources": [ { @@ -500,10 +517,6 @@ "code_lines": 801, "path": "Drivers/BSP/CH395F/ch395f.c" }, - { - "code_lines": 694, - "path": "Drivers/BSP/NET/lftpd/lftpd.c" - }, { "code_lines": 689, "path": "Drivers/BSP/GD5F2GQ5UE/gd5f2gq5ue.c" diff --git a/test/host/lftpd/lftpd_test.c b/test/host/lftpd/lftpd_test.c new file mode 100644 index 00000000..c22922cd --- /dev/null +++ b/test/host/lftpd/lftpd_test.c @@ -0,0 +1,736 @@ +/* + * 模块名称:FTP 控制通道协议层(lftpd)主机侧单元测试 + * 模块功能:在 PC 上离线验证 Drivers/BSP/NET/lftpd/lftpd.c 的控制通道状态机与 17 条 + * 命令处理器:认证前置白名单、超长命令名拒绝(不越界写命令缓冲)、未知命令 + * 502、QUIT 收尾时释放数据监听、大小写不敏感、PASV/EPSV 的 227/229 响应 + * 编码、数据 accept 失败时不残留半开监听、STOR 失败要**删掉半成品**、 + * 写开关关闭时 DELE/STOR 一律 550、CWD 的目录判定与截断安全、FEAT 多行 + * 响应的首尾行格式。 + * + * 被测对象:真实源文件 ../../../../Drivers/BSP/NET/lftpd/lftpd.c(#include 纳入, + * 因此命令表 commands[]、24 个 static cmd_* 与 handle_control_channel 都 + * 可在同一 TU 内直接驱动)。假的是它下面三层:inet 适配(脚本化命令行 + * 队列 + 响应记录)、io 层(极简内存视图:stat/open/read/write/opendir/ + * readdir/unlink + 可注入失败)、认证与配置(ftp_auth_check/ftp_cfg_get + * 可注入结果,认证本身有 test/host/ftp_auth 的 10 条专测,不重复)。 + * + * 为什么要主机侧单测(而不是只靠台架): + * 1. **半成品文件**是 FTP 最贵的坑:STOR 传到一半对端断了,如果不 unlink,Flash 上 + * 就永久留一个残缺文件(现场表现为"升级包传坏了再也传不上去")。真机上要卡准 + * "传一半掐线"的时机,主机侧把 io 层的写失败注进去就行。 + * 2. 数据通道是**单连接 socket**:LIST/RETR/STOR 每条命令用完必须把监听关掉并复位 + * data_socket=-1,否则下一条命令拿着上一个号继续 accept,表现是偶发连不上且 + * 与上一会话时序相关。这类"忘了复位"只有把 accept 失败也注进来才测得干净。 + * 3. 命令名缓冲是 command_tmp[4+1],而判断用 if (index >= 5) 先拦——这条边界 + * (恰好 5 个字符的命令名必须被拒)就是防 memcpy 越界,改一行就没了。 + * 4. 写开关([ftp] write_en=0)是现场防误删的闸门,DELE/STOR 必须在**碰文件之前** + * 就拒绝,且此时不该去 accept 数据通道。 + * + * 覆盖点:TC-LFTPD-001 认证白名单;002 超长命令名;003 未知命令 502;004 QUIT 退出; + * 005 欢迎未送达就收工;006 退出时释放数据监听;007 大小写不敏感;008 无参数命令; + * 010 未开被动模式的数据命令 425;011/012/013 PASV/EPSV 响应;014 数据 accept 失败 + * 不留残口;015/016 LIST 成功与目录打不开;017/018 RETR 成功与打不开; + * 019/020 STOR 删半成品与写开关;021 DELE 写开关;022/023 CWD;024 SIZE;025 PWD; + * 026 SYST;027 FEAT 多行;028/029 口令失败与锁定;030 TYPE/NOOP。 + * + * 通过:全部 TC PASS(main 返回 0)。 + * 入口:int main(void),返回 0=PASS / 1=FAIL(由 run 脚本判读)。 + * + * 创建日期:2026-10-23 + */ + +#include +#include +#include + +#include "lftpd.h" +#include "lftpd_auth.h" +#include "ftp_cfg.h" +#include "ff.h" + +#include "lftpd.c" +/* 命令行 trim 用**真实实现**(16 行、零依赖;lftpd_string.c 的契约由 + test/host/lftpd_io 的 TC-LFTSTR-* 专测,这里只是满足链接、顺带让参数解析走真码)。 */ +#include "lftpd_string.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) + +/* ===================== 桩:日志计数 ===================== + * lftpd_log_info/error/peer/debug 是真实 private/lftpd_log.h 里的**宏**,展开成 + * DBG_*;本模块只桩最底下的 dbg_log.h(计数)。这样真实分级逻辑一起被编译验证: + * peer 级归 INFO、debug 级在 MCU 上被整条摘掉。 */ + +int g_lg_info_count; +int g_lg_error_count; +int g_lg_debug_count; + +/* ===================== 桩:假堆(返回给 vPortFree 的路径缓冲) ===================== */ + +static uint8_t g_pool[2048]; +static size_t g_pool_used; +static int g_free_calls; + +void *pvPortMalloc(size_t xWantedSize) +{ + void *p; + + g_pool_used = (g_pool_used + 7u) & ~(size_t)7u; + if ((g_pool_used + xWantedSize) > sizeof(g_pool)) { + return NULL; + } + p = &g_pool[g_pool_used]; + g_pool_used += xWantedSize; + return p; +} + +void vPortFree(void *pv) +{ + if (pv != NULL) { + g_free_calls++; + } +} + +/* ===================== 假 inet 层:脚本化命令行 + 响应记录 ===================== */ + +typedef struct { + const char *lines[16]; + int line_ret[16]; + int line_n; + int line_i; + char replies[32][256]; + int reply_n; + int string_rc; /* write_string 返回(0=成功) */ + int listen_rc; + int port; + int accept_to_rc; /* 数据通道 accept 结果 */ + int data_write_rc; /* 数据写返回(<0 模拟对端断) */ + int rx_left; /* 数据通道还剩多少字节可读 */ + int close_calls; + int listen_calls; + int accept_calls; + int delay_calls; +} lftp_net_t; + +static lftp_net_t g_n; + +int lftpd_inet_read_line(int socket, char *buffer, size_t buffer_len) +{ + (void)socket; + if (g_n.line_i >= g_n.line_n) { + return -1; /* 脚本喂完 = 对端关闭 */ + } + (void)strncpy(buffer, g_n.lines[g_n.line_i], buffer_len - 1u); + buffer[buffer_len - 1u] = '\0'; + return g_n.line_ret[g_n.line_i++]; +} + +int lftpd_inet_write_string(int socket, const char *message) +{ + (void)socket; + if (g_n.reply_n < (int)(sizeof(g_n.replies) / sizeof(g_n.replies[0]))) { + (void)strncpy(g_n.replies[g_n.reply_n], message, sizeof(g_n.replies[0]) - 1u); + g_n.replies[g_n.reply_n][sizeof(g_n.replies[0]) - 1u] = '\0'; + g_n.reply_n++; + } + return g_n.string_rc; +} + +int lftpd_inet_write(int socket, const void *buf, int len) +{ + (void)socket; + (void)buf; + return (g_n.data_write_rc != 0) ? g_n.data_write_rc : len; +} + +int lftpd_inet_read(int socket, void *buf, int len) +{ + int n; + + (void)socket; + (void)buf; + if (g_n.rx_left <= 0) { + return 0; /* 对端结束数据 */ + } + n = (g_n.rx_left > len) ? len : g_n.rx_left; + g_n.rx_left -= n; + return n; +} + +int lftpd_inet_listen(int port) +{ + (void)port; + g_n.listen_calls++; + return g_n.listen_rc; +} + +int lftpd_inet_get_socket_port(int socket) +{ + (void)socket; + return g_n.port; +} + +int lftpd_inet_accept(int socket) +{ + (void)socket; + return -1; +} + +int lftpd_inet_accept_timeout(int socket, int timeout_ms) +{ + (void)socket; + (void)timeout_ms; + g_n.accept_calls++; + return g_n.accept_to_rc; +} + +int lftpd_inet_close(int socket) +{ + (void)socket; + g_n.close_calls++; + return 0; +} + +/* ===================== 假 io 层:极简内存视图 ===================== */ + +typedef struct { + int stat_rc; + int is_dir; + uint32_t size; + int open_read_rc; + int open_write_rc; + int write_short; /* >0:写返回这个短值 -> 触发半成品清理 */ + int read_left; + int opendir_rc; + const char *names[4]; + uint32_t sizes[4]; + int dirs[4]; + int readdir_i; + int readdir_n; + int unlink_calls; + char last_path[128]; +} lftp_io_t; + +static lftp_io_t g_io; + +char *lftpd_io_canonicalize_path(const char *base, const char *name) +{ + char *p = (char *)pvPortMalloc(128); + + if (p == NULL) { + return NULL; + } + (void)snprintf(p, 128, "%s/%s", (base != NULL) ? base : "", + (name != NULL) ? name : ""); + (void)strncpy(g_io.last_path, p, sizeof(g_io.last_path) - 1u); + return p; +} + +int lftpd_io_stat(const char *path, uint32_t *p_size, int *p_is_dir) +{ + (void)path; + if (p_size != NULL) { *p_size = g_io.size; } + if (p_is_dir != NULL) { *p_is_dir = g_io.is_dir; } + return g_io.stat_rc; +} + +int lftpd_io_open_read(const char *path) { (void)path; return g_io.open_read_rc; } +int lftpd_io_open_write(const char *path) { (void)path; return g_io.open_write_rc; } +int lftpd_io_read(int handle, void *buf, int len) +{ + int n; + + (void)handle; + (void)buf; + if (g_io.read_left <= 0) { + return 0; + } + n = (g_io.read_left > len) ? len : g_io.read_left; + g_io.read_left -= n; + return n; +} +int lftpd_io_write(int handle, const void *buf, int len) +{ + (void)handle; + (void)buf; + return (g_io.write_short > 0) ? g_io.write_short : len; +} +void lftpd_io_close(int handle) { (void)handle; } +int lftpd_io_unlink(const char *path) { (void)path; g_io.unlink_calls++; return 0; } +void *lftpd_io_opendir(const char *path) +{ + (void)path; + return (g_io.opendir_rc != 0) ? NULL : (void *)&g_io; +} +int lftpd_io_readdir(void *dp, char *name, int name_max, uint32_t *p_size, int *p_is_dir) +{ + int i = g_io.readdir_i; + + (void)dp; + if (i >= g_io.readdir_n) { + return -1; + } + g_io.readdir_i++; + (void)strncpy(name, g_io.names[i], (size_t)name_max - 1); + name[name_max - 1] = '\0'; + *p_size = g_io.sizes[i]; + *p_is_dir = g_io.dirs[i]; + return 0; +} +void lftpd_io_closedir(void *dp) { (void)dp; } + +/* ===================== 假配置与假认证 ===================== */ + +static ftp_cfg_t g_cfg; +static int g_cfg_present = 1; +static int g_default_pwd; +static ftp_auth_result_t g_auth_res; +static int g_auth_calls; + +const ftp_cfg_t *ftp_cfg_get(void) { return (g_cfg_present != 0) ? &g_cfg : NULL; } +int ftp_cfg_is_default_pwd(void) { return g_default_pwd; } + +ftp_auth_result_t ftp_auth_check(const ftp_auth_creds_t *p_creds, uint8_t user_ok, + const char *p_pwd, ftp_auth_state_t *p_state, + uint32_t now_ms) +{ + (void)p_creds; + (void)user_ok; + (void)p_pwd; + (void)p_state; + (void)now_ms; + g_auth_calls++; + return g_auth_res; +} + +int ftp_auth_user_match(const char *p_cfg_user, const char *p_given) +{ + if ((p_cfg_user == NULL) || (p_given == NULL)) { + return 0; + } + return (strcmp(p_cfg_user, p_given) == 0) ? 1 : 0; +} + +/* ===================== 假 net 与 OS ===================== */ + +static uint32_t g_tick; +static int g_sockname_rc; /* net_getsockname 的返回码(PASV 分支注入失败用) */ + +uint32_t xTaskGetTickCount(void) { return g_tick; } + +/* lftpd_task_wd_beat 的真实定义在 lftpd_inet.c 里(__weak,供 lftpd.c 覆盖),本模块 + 不拖那一层进来,故在此给计数假件——正好把"传输循环每轮喂狗"变成可断言的事。 */ +static int g_wd_beats; +void lftpd_task_wd_beat(void) { g_wd_beats++; } +void vTaskDelay(TickType_t t) +{ + (void)t; + g_n.delay_calls++; + if (g_n.delay_calls > 200) { + printf(" ABORT 循环跑飞(>200 轮)\n"); + fflush(stdout); + exit(2); + } +} + +int net_getsockname(int sockfd, struct net_sockaddr *addr, int *addrlen) +{ + struct net_sockaddr_in *a = (struct net_sockaddr_in *)addr; + + (void)sockfd; + (void)addrlen; + a->sin_addr.s_addr = 0x0101A8C0u; /* 192.168.1.1 的宿主序表示(假件自定) */ + a->sin_port = net_htons(21u); + return g_sockname_rc; +} +int net_getpeername(int sockfd, struct net_sockaddr *addr, int *addrlen) +{ + (void)sockfd; + (void)addr; + (void)addrlen; + return 0; +} +uint16_t net_htons(uint16_t v) { return (uint16_t)((v >> 8) | (v << 8)); } +uint16_t net_ntohs(uint16_t v) { return net_htons(v); } +uint32_t net_htonl(uint32_t v) +{ + return ((v & 0xFFu) << 24) | (((v >> 8) & 0xFFu) << 16) | + (((v >> 16) & 0xFFu) << 8) | ((v >> 24) & 0xFFu); +} +char *net_inet_ntoa(uint32_t in, char *p_buf) +{ + (void)snprintf(p_buf, 16, "%u.%u.%u.%u", (unsigned)(in & 0xFFu), + (unsigned)((in >> 8) & 0xFFu), (unsigned)((in >> 16) & 0xFFu), + (unsigned)((in >> 24) & 0xFFu)); + return p_buf; +} + +/* ===================== 复位与驱动 ===================== */ + +static lftpd_client_t g_cli; + +static void reset_all(void) +{ + (void)memset(&g_n, 0, sizeof(g_n)); + (void)memset(&g_io, 0, sizeof(g_io)); + g_n.string_rc = 0; + g_n.listen_rc = 20; + g_n.port = 5000; + g_n.accept_to_rc = 21; /* 默认数据通道能连上 */ + g_n.data_write_rc = 0; + g_io.stat_rc = 0; + g_io.is_dir = 1; + g_io.size = 7u; + g_io.open_read_rc = 0; + g_io.open_write_rc = 0; + g_io.read_left = 0; + g_io.readdir_n = 0; + g_cfg_present = 1; + (void)memset(&g_cfg, 0, sizeof(g_cfg)); + (void)strcpy(g_cfg.user, "admin"); + g_cfg.write_en = 1u; + g_auth_res = FTP_AUTH_OK; + g_default_pwd = 0; + g_tick = 0u; + g_wd_beats = 0; + g_free_calls = 0; + g_pool_used = 0u; + g_lg_info_count = 0; + g_lg_error_count = 0; + g_lg_debug_count = 0; + g_sockname_rc = 0; + (void)memset(&g_cli, 0, sizeof(g_cli)); + (void)strcpy(g_cli.directory, "/"); + g_cli.socket = 10; + g_cli.data_socket = -1; + /* 不写死字段名:认证状态的结构由 lftpd_auth.h 定义,本模块只把它当黑盒清零 + (口令/锁定逻辑有 test/host/ftp_auth 的 10 条专测,这里不重复验)。 */ + (void)memset(&s_auth_state, 0, sizeof(s_auth_state)); +} + +static void feed(const char *l0, const char *l1, const char *l2, const char *l3) +{ + int n = 0; + + g_n.lines[n] = l0; g_n.line_ret[n++] = 0; + if (l1 != NULL) { g_n.lines[n] = l1; g_n.line_ret[n++] = 0; } + if (l2 != NULL) { g_n.lines[n] = l2; g_n.line_ret[n++] = 0; } + if (l3 != NULL) { g_n.lines[n] = l3; g_n.line_ret[n++] = 0; } + g_n.line_n = n; +} + +static const char *rep(int k) { return (k < g_n.reply_n) ? g_n.replies[k] : ""; } +static int rep_has(const char *prefix) +{ + int i; + int c = 0; + + for (i = 0; i < g_n.reply_n; i++) { + if (strncmp(g_n.replies[i], prefix, strlen(prefix)) == 0) { + c++; + } + } + return c; +} + +/* ===================== 控制通道闸门 ===================== */ + +static void tc_gates(void) +{ + printf("-- TC-LFTPD-001..008 control channel gates --\n"); + reset_all(); + feed("RETR a.txt", "LIST", "PWD", NULL); + (void)handle_control_channel(&g_cli); + CHECK((rep_has("220 ") == 1) && (rep_has("530 ") == 3) && + (g_n.accept_calls == 0), + "TC-LFTPD-001 未登录只放行 USER/PASS/QUIT/FEAT/SYST/NOOP,其它一律 530"); + + reset_all(); + feed("ABCDEFG", "NOOP", NULL, NULL); + (void)handle_control_channel(&g_cli); + CHECK((rep_has("500 ") == 1) && (rep_has("200 ") == 1), + "TC-LFTPD-002 命令名 >=5 字符先拒(command_tmp 只有 5 格,防 memcpy 越界)"); + + reset_all(); + g_cli.auth_logged_in = 1; /* 否则会被认证白名单先以 530 拦掉,测不到分派 */ + feed("FOO bar", NULL, NULL, NULL); + (void)handle_control_channel(&g_cli); + CHECK((rep_has("502 ") == 1) && (rep_has("530 ") == 0), + "TC-LFTPD-003 认不出的命令回 502 而不是断开"); + + reset_all(); + feed("QUIT", "NOOP", NULL, NULL); + (void)handle_control_channel(&g_cli); + CHECK((rep_has("221 ") == 1) && (rep_has("200 ") == 0) && (g_n.line_i == 1), + "TC-LFTPD-004 QUIT 之后不再读下一条(会话就此结束)"); + + reset_all(); + g_n.string_rc = -1; /* 220 都送不出去 */ + feed("NOOP", NULL, NULL, NULL); + (void)handle_control_channel(&g_cli); + CHECK((g_n.line_i == 0) && (g_lg_info_count >= 1), + "TC-LFTPD-005 欢迎未送达(对端已走)就直接收工,不空转读命令(该留痕按真实分级算 INFO)"); + + reset_all(); + g_cli.data_socket = 33; /* 被动模式已开但客户端直接跑了 */ + feed("QUIT", NULL, NULL, NULL); + (void)handle_control_channel(&g_cli); + CHECK((g_n.close_calls == 1) && (g_cli.data_socket == -1), + "TC-LFTPD-006 退出时必须释放还开着的**数据监听**(单连接 socket 不能留残口)"); + + reset_all(); + g_cli.auth_logged_in = 1; + feed("noop", NULL, NULL, NULL); + (void)handle_control_channel(&g_cli); + CHECK(rep_has("200 ") == 1, "TC-LFTPD-007 命令名大小写不敏感"); + + reset_all(); + g_cli.auth_logged_in = 1; + g_io.opendir_rc = 0; + g_io.readdir_n = 0; + g_n.listen_rc = 20; + feed("LIST", NULL, NULL, NULL); + (void)handle_control_channel(&g_cli); + CHECK((rep_has("425 ") == 1) && (g_n.accept_calls == 0), + "TC-LFTPD-010 没先开被动模式的数据命令一律 425,且不去 accept"); +} + +/* ===================== 被动模式与数据通道 ===================== */ + +static void tc_passive(void) +{ + printf("-- TC-LFTPD-011..016 passive & data --\n"); + reset_all(); + g_cli.auth_logged_in = 1; + /* 直调处理器:走 handle_control_channel 的话,会话退出时 cleanup 一定把被动监听 + 关掉(那正是 TC-LFTPD-006 要验的行为),这里要看的是 PASV 自己留下的状态。 */ + (void)cmd_pasv(&g_cli, NULL); + CHECK((strcmp(rep(0), "227 Entering Passive Mode (192,168,1,1,19,136).\r\n") == 0) && + (g_cli.data_socket == 20) && (g_n.listen_calls == 1), + "TC-LFTPD-011 227 六元组:IP 经 htonl 后按高字节到低字节(点分序)、端口高/低,并记下被动监听号"); + + reset_all(); + g_cli.auth_logged_in = 1; + g_sockname_rc = -1; /* 问不到本机地址 */ + feed("PASV", NULL, NULL, NULL); + (void)handle_control_channel(&g_cli); + CHECK((rep_has("425 ") == 1) && (g_n.close_calls == 1) && + (g_cli.data_socket == -1), + "TC-LFTPD-012 拿不到本机 IP 时要把刚建的监听关掉(别留半个被动模式)"); + + reset_all(); + g_cli.auth_logged_in = 1; + feed("EPSV", NULL, NULL, NULL); + (void)handle_control_channel(&g_cli); + CHECK(strcmp(rep(1), "229 Entering Extended Passive Mode (|||5000|).\r\n") == 0, + "TC-LFTPD-013 EPSV 只报端口(IPv6 客户端自己沿用控制连接地址)"); + + reset_all(); + g_cli.auth_logged_in = 1; + g_n.listen_rc = 20; + g_n.accept_to_rc = -1; /* 对端一直不开数据通道 */ + feed("PASV", "LIST", NULL, NULL); + (void)handle_control_channel(&g_cli); + CHECK((rep_has("150 ") == 1) && (rep_has("425 ") == 1) && + (g_cli.data_socket == -1) && (g_n.close_calls == 1), + "TC-LFTPD-014 数据 accept 失败:报 425 并**关掉监听复位**,不留给下一条命令"); + + reset_all(); + g_cli.auth_logged_in = 1; + g_io.readdir_n = 2; + g_io.names[0] = "a.txt"; g_io.sizes[0] = 7u; g_io.dirs[0] = 0; + g_io.names[1] = "sub"; g_io.sizes[1] = 0u; g_io.dirs[1] = 1; + feed("PASV", "LIST", NULL, NULL); + (void)handle_control_channel(&g_cli); + CHECK((rep_has("226 ") == 1) && (g_n.data_write_rc == 0), + "TC-LFTPD-015 LIST 成功:先 150、数据口写目录、再 226 收尾"); + + reset_all(); + g_cli.auth_logged_in = 1; + g_io.opendir_rc = 1; /* 目录打不开 */ + feed("PASV", "LIST", NULL, NULL); + (void)handle_control_channel(&g_cli); + CHECK(rep_has("550 ") == 1, "TC-LFTPD-016 目录打不开时 LIST 回 550(不是 226)"); +} + +/* ===================== 传输与写开关 ===================== */ + +static void tc_transfer(void) +{ + printf("-- TC-LFTPD-017..021 transfer & write gate --\n"); + reset_all(); + g_cli.auth_logged_in = 1; + g_io.read_left = 5; + feed("PASV", "RETR a.txt", NULL, NULL); + (void)handle_control_channel(&g_cli); + CHECK((rep_has("226 ") == 1) && (g_lg_info_count >= 1) && (g_wd_beats >= 1), + "TC-LFTPD-017 RETR 成功:226 收尾、留 DOWNLOAD 记录,且传输循环有喂狗"); + + reset_all(); + g_cli.auth_logged_in = 1; + g_io.open_read_rc = -1; + feed("PASV", "RETR gone.txt", NULL, NULL); + (void)handle_control_channel(&g_cli); + CHECK((rep_has("450 ") == 1) && (g_lg_error_count >= 1), + "TC-LFTPD-018 文件打不开回 450(传输失败与命令不合法要分开报)"); + + reset_all(); + g_cli.auth_logged_in = 1; + g_n.rx_left = 900; + g_io.write_short = 100; /* 写到一半失败 */ + feed("PASV", "STOR up.bin", NULL, NULL); + (void)handle_control_channel(&g_cli); + CHECK((g_io.unlink_calls == 1) && (rep_has("450 ") == 1), + "TC-LFTPD-019 STOR 中途失败必须**删掉半成品**(否则 Flash 上永久留个坏文件)"); + + reset_all(); + g_cli.auth_logged_in = 1; + g_cfg.write_en = 0u; + g_n.listen_rc = 20; + feed("PASV", "STOR up.bin", NULL, NULL); + (void)handle_control_channel(&g_cli); + CHECK((rep_has("550 ") == 1) && (g_n.accept_calls == 0) && + (g_io.open_write_rc == 0) && (g_io.unlink_calls == 0), + "TC-LFTPD-020 写开关关闭时 STOR 在碰文件之前就拒绝,且不开数据通道"); + + reset_all(); + g_cli.auth_logged_in = 1; + g_cfg.write_en = 0u; + (void)cmd_dele(&g_cli, "a.txt"); + CHECK(rep_has("550 ") == 1, "TC-LFTPD-021 写开关同样挡住 DELE"); + + reset_all(); + g_cli.auth_logged_in = 1; + g_io.is_dir = 1; + (void)cmd_dele(&g_cli, "sub"); + CHECK((rep_has("550 ") == 1) && (g_io.unlink_calls == 0), + "TC-LFTPD-022 DELE 不许删目录(本实现只支持删文件)"); + + reset_all(); + g_cli.auth_logged_in = 1; + g_io.is_dir = 0; + (void)cmd_dele(&g_cli, "a.txt"); + CHECK((g_io.unlink_calls == 1) && (rep_has("250 ") == 1), + "TC-LFTPD-022b 删普通文件走 unlink 并回 250"); +} + +/* ===================== 目录与元信息 ===================== */ + +static void tc_meta(void) +{ + printf("-- TC-LFTPD-023..030 cwd / size / pwd / feat --\n"); + reset_all(); + (void)cmd_cwd(&g_cli, ""); + CHECK((rep_has("550 ") == 1) && (strcmp(g_cli.directory, "/") == 0), + "TC-LFTPD-023 CWD 空参数拒绝,当前目录不变"); + + reset_all(); + g_io.is_dir = 0; + (void)cmd_cwd(&g_cli, "a.txt"); + CHECK(rep_has("550 ") == 1, "TC-LFTPD-023b CWD 到普通文件被拒(is_dir 必须真为目录)"); + + reset_all(); + g_io.is_dir = 1; + (void)cmd_cwd(&g_cli, "logs"); + CHECK((rep_has("250 ") == 1) && (strcmp(g_cli.directory, "//logs") == 0) && + (g_free_calls == 1), + "TC-LFTPD-024 CWD 成功后更新当前目录并释放路径缓冲(不泄漏堆)"); + + reset_all(); + (void)cmd_cwd(&g_cli, "/"); + CHECK((rep_has("250 ") == 1) && (g_io.stat_rc == 0), + "TC-LFTPD-024b 回到根目录免 stat 直接放行(根一定存在)"); + + reset_all(); + g_io.is_dir = 0; + g_io.size = 12345u; + (void)cmd_size(&g_cli, "up.bin"); + CHECK(strcmp(rep(0), "213 12345\r\n") == 0, "TC-LFTPD-025 SIZE 报字节数"); + + reset_all(); + g_io.stat_rc = -1; + (void)cmd_size(&g_cli, "nope"); + CHECK(rep_has("550 ") == 1, "TC-LFTPD-025b 文件不存在时 SIZE 回 550"); + + reset_all(); + (void)strcpy(g_cli.directory, "/logs"); + (void)cmd_pwd(&g_cli, NULL); + CHECK(strcmp(rep(0), "257 \"/logs\"\r\n") == 0, + "TC-LFTPD-026 PWD 用双引号包住目录(RFC 959 要求)"); + + reset_all(); + (void)cmd_syst(&g_cli, NULL); + CHECK(strcmp(rep(0), "215 UNIX Type: L8\r\n") == 0, + "TC-LFTPD-027 SYST 固定报 UNIX Type: L8(客户端据此决定模式)"); + + reset_all(); + (void)cmd_feat(&g_cli, NULL); + CHECK((strncmp(rep(0), "211-", 4) == 0) && (strcmp(rep(1), "EPSV\r\n") == 0) && + (strcmp(rep(2), "PASV\r\n") == 0) && (strncmp(rep(5), "211 ", 4) == 0) && + (g_n.reply_n == 6), + "TC-LFTPD-028 FEAT 多行:首行 211-、四条能力无状态码、末行 211 结束"); + + reset_all(); + (void)cmd_type(&g_cli, "I"); + (void)cmd_noop(&g_cli, NULL); + CHECK(rep_has("200 ") == 2, "TC-LFTPD-030 TYPE 与 NOOP 都回 200"); +} + +/* ===================== 登录序列 ===================== */ + +static void tc_login(void) +{ + printf("-- TC-LFTPD-031..033 login --\n"); + reset_all(); + feed("USER admin", "PASS good", "RETR a.txt", NULL); + (void)handle_control_channel(&g_cli); + CHECK((rep_has("331 ") == 1) && (rep_has("230 ") == 1) && + (g_cli.auth_logged_in == 1u) && (strcmp(g_cli.auth_user, "admin") == 0), + "TC-LFTPD-031 USER 记下用户名与匹配位,PASS 过了就转已登录"); + + reset_all(); + g_auth_res = FTP_AUTH_FAIL; + feed("USER admin", "PASS bad", NULL, NULL); + (void)handle_control_channel(&g_cli); + CHECK((rep_has("530 ") == 1) && (g_cli.auth_logged_in == 0u) && + (g_lg_info_count >= 1), + "TC-LFTPD-032 口令错:530 且**必须重新走 USER**(auth_user_ok 已清零)"); + + reset_all(); + g_auth_res = FTP_AUTH_LOCKED; + g_cfg_present = 1; + feed("USER admin", "PASS bad", NULL, NULL); + (void)handle_control_channel(&g_cli); + CHECK(rep_has("530 ") == 1, + "TC-LFTPD-033 连错被锁时同样只回 530(不给攻击者区分提示)"); + + reset_all(); + g_cfg_present = 0; /* 配置读不到 */ + feed("USER admin", "PASS x", NULL, NULL); + (void)handle_control_channel(&g_cli); + CHECK((rep_has("331 ") == 1) && (rep_has("530 ") == 1) && + (g_cli.auth_user_ok == 0u), + "TC-LFTPD-034 配置缺失时 USER 无法匹配、PASS 直接 530(fail-closed)"); +} + +int main(void) +{ + printf("=== LFTPD Protocol Tests ===\n"); + + tc_gates(); + tc_passive(); + tc_transfer(); + tc_meta(); + tc_login(); + + printf("\n=== LFTPD Protocol Tests: %d/%d PASSED (failed=%d) ===\n", + s_passed, s_total, s_failed); + return (s_failed == 0) ? 0 : 1; +} diff --git a/test/host/lftpd/run_unit_test.py b/test/host/lftpd/run_unit_test.py new file mode 100644 index 00000000..5672f3cd --- /dev/null +++ b/test/host/lftpd/run_unit_test.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""FTP 控制通道协议层(lftpd)主机侧单元测试运行器(L0.5,纯标准库)。 + +做什么: + 用本机 MSVC 编译 test/host/lftpd/lftpd_test.c(以 #include 纳入生产源 + Drivers/BSP/NET/lftpd/lftpd.c),在 PC 上离线驱动控制通道状态机与 17 条命令 + 处理器:认证前置白名单、超长命令名拒绝、未知命令 502、QUIT 收尾释放数据监听、 + 大小写不敏感、PASV/EPSV 的 227/229 编码、数据 accept 失败不残留半开监听、 + STOR 中途失败删半成品、写开关挡住 STOR/DELE、CWD 目录判定与堆释放、FEAT 多行 + 格式、登录序列与 fail-closed。工具链与判定委托 unit_test_harness.py。 + +为什么要主机侧单测(而不是只用台架): + STOR 传一半失败必须把半成品删掉,否则 Flash 上永久留个坏文件(现场表现为升级包 + 传坏了再也传不上去),真机要卡准"传一半掐线"的时机极难;数据通道是单连接 socket, + 每条命令用完必须关监听并复位 data_socket=-1,漏一次就是偶发连不上且与上一会话 + 时序相关;command_tmp 只有 5 格,那条 index >= 5 的前置判断就是防 memcpy 越界, + 改一行就没了;写开关必须在碰文件之前挡住。这些都要能注入失败才测得干净。 + +桩的边界:lftpd.h / private/*.h / lftpd_auth.h / ftp_cfg.h / ff.h / net_types.h 用真实 + 头(命令表、client 结构、状态码宏、ftp_cfg_t 字段一改即编译期报错);下面三层 + (inet 适配、io 层、认证与配置)是假件——认证本身有 test/host/ftp_auth 的 10 条 + 专测,本模块只把它当可注入的黑盒,不重复验;认证状态结构体按黑盒 memset 清零, + 不写死字段名。假 inet 给"脚本化命令行队列 + 响应记录",假 io 给"极简内存视图 + + 可注入失败",另配 vTaskDelay 跑飞保险。 + +用法(仓库根目录): + python test/host/lftpd/run_unit_test.py + +退出码:0=PASS / 1=测试失败 / 2=环境错误。 + +设计依据:docs/测试说明/FTP_Test_Guide.md、docs/子系统设计/网络配置说明.md §3.4、 +docs/测试说明/主机单测覆盖率口径.md §9 +创建日期: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 + +BUILD = TestBuild( + name="LFTPD Protocol Tests", + src=os.path.join(HERE, "lftpd_test.c"), + include_dirs=[ + os.path.join(HERE, "stub"), + os.path.join(ROOT, "Drivers", "BSP", "NET", "lftpd"), + os.path.join(ROOT, "Drivers", "BSP", "NET"), + os.path.join(ROOT, "Drivers", "BSP", "NETCFG"), # ftp_cfg.h 落在这里 + os.path.join(ROOT, "Lib", "FatFs"), + HERE, + ], + extra_flags=["/D_CRT_SECURE_NO_WARNINGS"], + summary_marker="=== LFTPD Protocol Tests:", +) + +if __name__ == "__main__": + sys.exit(run_host_unit_test(BUILD)) + diff --git a/test/host/lftpd/stub/FreeRTOS.h b/test/host/lftpd/stub/FreeRTOS.h new file mode 100644 index 00000000..2465f807 --- /dev/null +++ b/test/host/lftpd/stub/FreeRTOS.h @@ -0,0 +1,24 @@ +/* + * 模块名称:主机侧单测桩 - FreeRTOS.h(lftpd 协议层模块专用) + * 模块功能:宿主等价的 tick 类型与换算(本仓 tick=1ms,故 pdMS_TO_TICKS 即原值), + * 让 FTP 的 3 s 监听重建退避、LFTPD_WAIT_SLICE_MS 分片可直接按毫秒断言。 + * ⚠ 仅供主机侧单测使用,不参与 MCU 构建。 + * 创建日期:2026-10-23 + */ +#ifndef FREERTOS_H +#define FREERTOS_H + +#include +#include + +typedef uint32_t TickType_t; +typedef long BaseType_t; +typedef unsigned long UBaseType_t; +typedef void *TaskHandle_t; + +#define pdMS_TO_TICKS(ms) ((TickType_t)(ms)) + +void *pvPortMalloc(size_t xWantedSize); +void vPortFree(void *pv); + +#endif /* FREERTOS_H */ diff --git a/test/host/lftpd/stub/dbg_log.h b/test/host/lftpd/stub/dbg_log.h new file mode 100644 index 00000000..4fe48470 --- /dev/null +++ b/test/host/lftpd/stub/dbg_log.h @@ -0,0 +1,29 @@ +/* + * 模块名称:主机侧单测桩 - dbg_log.h(lftpd 协议层模块专用) + * 模块功能:DBG_* 换成计数,让"对端在 220 握手前就走了""传输未完成"这些只体现在 + * 日志里的分支可断言。 + * + * ⚠ 仅供主机侧单测使用,不参与 MCU 构建。 + * 为什么桩这层而不是 private/lftpd_log.h:真实那份是**宏**(lftpd_log_info 等直接 + * 展开成 DBG_*),而且 MSVC 的引号 include 会先从 lftpd.c 自己的目录找 + * private/lftpd_log.h——桩目录赢不过它。所以正确做法是让真实 lftpd_log.h 照常参与 + * 编译(它的分级语义也就一起被验证了),只把最底下的 DBG_* 换成计数。 + * 顺带说明分级(真实头注释里写得很清楚):lftpd_log_peer 是 **info** 级不是 error, + * 因为"客户端关了个窗口"不该把 @test 的 Forbidden 标记 [ERR] 触发成 FAIL,也不该 + * 让任何人连 21 端口就走就能无限制造固件级 ERROR;而 DOWNLOAD/UPLOAD FAILED 仍记 + * error(那是数据完整性事件)。 + * + * 创建日期:2026-10-23 + */ +#ifndef DBG_LOG_H +#define DBG_LOG_H + +extern int g_lg_info_count; +extern int g_lg_error_count; +extern int g_lg_debug_count; + +#define DBG_INFO(fmt, ...) do { (void)(fmt); g_lg_info_count++; } while (0) +#define DBG_DEBUG(fmt, ...) do { (void)(fmt); g_lg_debug_count++; } while (0) +#define DBG_ERROR(fmt, ...) do { (void)(fmt); g_lg_error_count++; } while (0) + +#endif /* DBG_LOG_H */ diff --git a/test/host/lftpd/stub/net_socket.h b/test/host/lftpd/stub/net_socket.h new file mode 100644 index 00000000..438854e2 --- /dev/null +++ b/test/host/lftpd/stub/net_socket.h @@ -0,0 +1,34 @@ +/* + * 模块名称:主机侧单测桩 - net_socket.h(lftpd 协议层模块专用) + * 模块功能:给 FTP 协议层四个地址/字节序入口(PASV 要用本机 IP 拼 227 响应), + * 并照做真实头对 FreeRTOS.h / task.h 的 include 链。地址结构与 + * NET_AF_INET / NET_SOCK_STREAM 取**真实** net_types.h。 + * ⚠ 仅供主机侧单测使用,不参与 MCU 构建。 + * 创建日期:2026-10-23 + */ +#ifndef NET_SOCKET_H +#define NET_SOCKET_H + +#include "FreeRTOS.h" +#include "task.h" + +/* 主机侧必要清理:lftpd.c 会 include ff.h,而这份 FatFs 在 Windows 支路上会把 + * winsock.h 拉进来,它带着 "#define s_addr S_un.S_addr" 这个 BSD 兼容宏——于是真实 + * net_types.h 里的字段声明 uint32_t s_addr; 被展开成 uint32_t S_un.S_addr; 直接炸 + * (实测报 C2143:在 "." 前面缺 ";",再级联出一串"sin_addr 使用未定义的 struct")。 + * 这里只 #undef 掉这个污染宏,让**固件那份结构体定义原样参与编译**:字段名仍是真实 + * 的 s_addr,被测代码读的也是它。(lftpd_inet 批次不 include ff.h,所以没撞上。) */ +#ifdef s_addr +#undef s_addr +#endif + +#include "net_types.h" + +int net_getsockname(int sockfd, struct net_sockaddr *addr, int *addrlen); +int net_getpeername(int sockfd, struct net_sockaddr *addr, int *addrlen); +uint16_t net_htons(uint16_t hostshort); +uint16_t net_ntohs(uint16_t netshort); +uint32_t net_htonl(uint32_t hostlong); +char *net_inet_ntoa(uint32_t in, char *p_buf); /* 两参:调用方给缓冲 */ + +#endif /* NET_SOCKET_H */ diff --git a/test/host/lftpd/stub/task.h b/test/host/lftpd/stub/task.h new file mode 100644 index 00000000..4369b6d4 --- /dev/null +++ b/test/host/lftpd/stub/task.h @@ -0,0 +1,18 @@ +/* + * 模块名称:主机侧单测桩 - task.h(lftpd 协议层模块专用) + * 模块功能:vTaskDelay 与 xTaskGetTickCount 的可控假件接口。 + * ⚠ 仅供主机侧单测使用,不参与 MCU 构建。 + * 注意 lftpd.c 只 include 了 FreeRTOS.h,这两个符号在真机上是经 net_socket.h 的 + * include 链拿到的——所以本模块的 net_socket.h 桩必须照做那条链(少一行就是 C2065, + * lftpd_inet 批次实测踩过)。 + * 创建日期:2026-10-23 + */ +#ifndef TASK_H +#define TASK_H + +#include "FreeRTOS.h" + +TickType_t xTaskGetTickCount(void); +void vTaskDelay(TickType_t xTicksToDelay); + +#endif /* TASK_H */ From 957ec64645b24603c6865643ffca6c0148b0f6ac Mon Sep 17 00:00:00 2001 From: Wanderingss <1624155937@qq.com> Date: Fri, 23 Oct 2026 23:29:58 +0800 Subject: [PATCH 15/15] =?UTF-8?q?test(APP/104):=20iec104=5Fserv=20?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=EF=BC=8828=20=E6=9D=A1=EF=BC=8C84.2%?= =?UTF-8?q?=EF=BC=89=E2=80=94=E2=80=94P1=20=E5=8D=81=E4=B8=89=E5=9D=97?= =?UTF-8?q?=E5=85=A8=E9=83=A8=E8=BF=9B=E5=88=86=E6=AF=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1 收官批次。新增 test/host/iec104_serv/,iec104_serv.c 覆盖 117/139 = 84.2%。 做法:把生产源整个 #include 进 TU,直接驱动它的 7 个 static 辅助函数——**不跑 iec104_serv_task 的无限主循环**(那是 never-returning 的任务体,不适合单测;事件组 相关支路用 sys_evtHandle=NULL 走"不可用"分支)。链路层与 pts 层给假件:链路协议本身 有 test/host/iec104 的 97 条专测,本模块只管服务怎么活、怎么死、怎么重生。 钉住的契约: - 建监听:2404 端口过 htons、绑 NET_INADDR_ANY;socket/bind/listen 任一失败都要把 已经拿到的号关掉(漏关就是每次重试漏一个 socket 号,CH395F 只有 8 个)。 - **网络代次一变就拆掉旧连接与旧监听并重建**:现场改 IP 后端口必须在新地址上继续 服务,这条不重建就是"设备换了 IP 之后 104 主站再也连不上"。 - accept 门控:监听未就绪不 accept;表项已失效时把监听号丢掉、下一轮重建。 - 单连接 socket 的断线语义:对端 FIN 时只关数据口,**监听必须留着**;数据号与监听 号同一个时绝不关(关了就端口失联)。recv 返 -1 且 errno 是 WOULDBLOCK 是正常空转, 不能当成错误丢连接;真错误则丢弃并**当场重建监听**,不留空窗。 - 判死用**连续**短写计数:一次成功即复位。写成累计就会把偶发拥塞误判成断线,主站 看到的就是莫名掉线(连到 3 次才判死,I104_SEND_FAIL_MAX)。 - 32 分频探活:无可读数据时不是每轮都去收(省 CPU 预算),到点才收一次;对端 FIN 后 复位计数器。协议状态掉回 IDLE 也要丢并重生(否则服务挂着但永不服务)。 - 时钟桥接两个溢出边界:epoch 秒超过 32 位一律拒绝且不碰时钟(截断写进 RTC 就是乱值); 未对时(sys_clock_get 返回 UINT32_MAX)回 UINT64_MAX 哨兵而不是 0(否则对端会以为 设备时间是 1970-01-01)。 主机侧兼容处理(写进桩注释):真实 App/util/sys_clock.h 会 include sd2506.h(RTC 芯片 驱动)一路拉到 HAL,PC 上编不动,故桩掉 sys_clock.h 但**两个签名与真实头逐字一致**; 104 的头散在 App 与 Drivers/BSP 的多个子目录(iec104_serv.h/iec104_link.h/iec104_pts.h/ task_watchdog.h/sys_events.h/prot_set.h/sd2506.h…),逐个手写 include 目录必漏且报错会 落在被测源上、极易误读成生产代码坏了——运行器改为**按目录实际是否含 .h 自动收集**。 自查:初跑红 2 条,都是我的场景问题(分频到点那轮 recv 返回 0 会走对端 FIN 支路, 应给 -1 + WOULDBLOCK;"重建失败丢监听号"必须同时让表项失效),改正后 28/28。 P1 收官合计:12 个新模块、**308 条断言**;套件 16 → **28** 全绿;files_tested 19 → **32**; 未触及 .c 47 → **34**;恒等式 34 = 0(P1 剩) + 5(P2) + 29(P3);分母 2,893 → **4,684** 行; line=4020/4684(85.8%)、func=300/333(90.1%)。百分比"降"是摊薄不是回退(lftpd.c 64.3%、 lftpd_io.c 53.5% 这类只测主体的文件也进了分母),按 §5 口径该盯 files_tested/files_untested。 亮点:lftpd_inet.c **127/127 = 100%**、net_listener.c 99.2%、net_events.c 97.8%。 编号补建 TC-HOST-I104SRV(游标 041),TC-HOST 族数 20 → 22。§9 的落地进度表补齐并 统一为 NET 拆出的四块 ①②③④。 自查门禁:test-cppcheck Passed;check_ids --strict 通过;doc-links 35 处(= 存量); 10 个新增/改动文件 BOM/CR/U+FFFD/行尾逐项净。零生产代码改动,未跑 @build。 --all-files 仍只有 mem-numbers / mem-budget(.map 台账漂移,等 Lead 合并后统一回填) 与 ui-golden(ref/*.bin 不入库)三条失败,与本分支改动无关。 --- docs/测试说明/主机单测覆盖率口径.md | 24 +- scripts/id_registry.json | 22 + scripts/test_coverage_baseline.json | 40 +- test/host/iec104_serv/iec104_serv_test.c | 527 +++++++++++++++++++++++ test/host/iec104_serv/run_unit_test.py | 77 ++++ test/host/iec104_serv/stub/cmsis_os2.h | 27 ++ test/host/iec104_serv/stub/dbg_log.h | 19 + test/host/iec104_serv/stub/main.h | 15 + test/host/iec104_serv/stub/net_socket.h | 31 ++ test/host/iec104_serv/stub/sys_clock.h | 21 + 10 files changed, 784 insertions(+), 19 deletions(-) create mode 100644 test/host/iec104_serv/iec104_serv_test.c create mode 100644 test/host/iec104_serv/run_unit_test.py create mode 100644 test/host/iec104_serv/stub/cmsis_os2.h create mode 100644 test/host/iec104_serv/stub/dbg_log.h create mode 100644 test/host/iec104_serv/stub/main.h create mode 100644 test/host/iec104_serv/stub/net_socket.h create mode 100644 test/host/iec104_serv/stub/sys_clock.h diff --git a/docs/测试说明/主机单测覆盖率口径.md b/docs/测试说明/主机单测覆盖率口径.md index 2d25a9ab..1677e8c7 100644 --- a/docs/测试说明/主机单测覆盖率口径.md +++ b/docs/测试说明/主机单测覆盖率口径.md @@ -226,22 +226,25 @@ `test/host/lftpd`(35 条) | `lftpd_inet.c` **127/127 = 100%**、`lftpd_io.c` 85/159、 `lftpd.c` **304/473 = 64.3%**(控制通道闸门 + 17 条命令 + 被动模式 + 传输收尾都进来了; 还剩 `lftpd_start` 主循环与 `send_list`/`send_file` 的分批边界分支) | -| NET 四块之一:`net_select.c` | ✅ 完成 | `test/host/net_select`(21 条) | `net_select.c` 97/114 = **85.1%** | -| `lftpd.c` / `lftpd_inet.c` | ⬜ 未开始 | — | 需假 `net_socket` 层(`time_sync`/stub 那份可扩展) | -| NET 三块之一:`net_listener.c` | ✅ 完成(**125/126 = 99.2%**) | +| NET 拆出的四块 ①:`net_select.c` | ✅ 完成 | `test/host/net_select`(21 条) | `net_select.c` 97/114 = **85.1%** | +| NET 拆出的四块 ②:`net_listener.c` | ✅ 完成(**125/126 = 99.2%**) | `test/host/net_listener`(34 条)+ 共享假芯片 `test/host/common/ch395f_fake.{h,c}` | 四道时间闸门全钉住(30ms 退避 / 400 与 100ms 两级沉降 / 800ms 上限只留痕 / 2000ms 预算交对账器),另有接收区 256B 钳位与 16 轮上限、"清接收→关断→读即清→读状态"的 调用顺序、五种静默放弃条件 | -| NET 三块之二:`net_pending.c` | ✅ 完成(**169/194 = 87.1%**) | `test/host/net_pending`(43 条) | +| NET 拆出的四块 ③:`net_pending.c` | ✅ 完成(**169/194 = 87.1%**) | `test/host/net_pending`(43 条) | 跨上下文契约(**错误码走任务通知值**,靠可观测的 `xTaskNotify` 假件才测得到)、 收尾四条分支(不丢尾包 / 干净报 0 / 异常释放报 CONNRESET / 槽位没了报 CONNRESET)、 发送分块钳在 `NET_SEND_CHUNK_MAX=1024`(真值,非假件自造)、`deadline` 每轮续期 (长文件不被 1000ms 掐死)、停滞超时、取消与 `clear` 不发通知的纪律 | -| NET 三块之三:`net_events.c` | ✅ 完成 | `test/host/net_events`(34 条) | 陈旧中断防误判 | +| NET 拆出的四块 ④:`net_events.c` | ✅ 完成 | `test/host/net_events`(34 条) | 陈旧中断防误判 | (芯片说还在连接就不许拆活会话)、疑似误报延后、超时标异常释放(正是 `net_pending` 收尾报 CONNRESET 的那条输入,**跨文件因果两头都能离线跑才测得到**)、丢中断自愈, 以及对账器的 100ms 周期闸门 / 稳定计数 4·2·1 三档 / 1000ms 冷却 / U11 重同步 | +| 104 服务层:`iec104_serv.c` | ✅ 完成(**117/139 = 84.2%**) | `test/host/iec104_serv`(28 条) | + 服务生命周期:代次一变就重建监听、accept 门控与表项失效自愈、单连接断线只关数据口 + (与监听同号时绝不关)、连续短写判死(成功即复位)、32 分频探活、协议掉回 IDLE 丢弃 + 重生、时钟桥接两个溢出边界(>4G 秒拒绝、未对时回哨兵) | 本批照出的两处**现状**(只登记、**未改语义**;全库唯一调用点 `test/board/net_test_task.c` 传的是 NULL 与非零 tv,今天没有受害者): @@ -267,6 +270,17 @@ 风险在"以后有人拿它去尾部空白"。修它一行即可,但会改变 FTP 参数(含空格的文件名/ 口令)的可见行为,需与 FTP 域一起过 → 现在只用 TC-LFTSTR-005 把现状钉成"改了会响"。 +**P1 收官(2026-10-23 同日,13 个文件全部进分母)**:新增 12 个主机单测模块 +(`net_cfg` / `ftp_cfg` / `prot_set_store` / `task_prof` / `lftpd_io` / +`net_select` / `net_listener` / `net_pending` / `net_events` / `lftpd_inet` / +`lftpd` / `iec104_serv`),共 **308 条断言**;套件 16 → **28** 全绿, +`files_tested` 19 → **32**,未触及 `.c` 47 → **34**(恒等式 `34 = 0(P1) + 5(P2) + 29(P3)`), +分母 2,893 → **4,684** 插桩行,`line=4020/4684(85.8%)`、`func=300/333(90.1%)`。 +百分比从 88.9% 一路"降"到 85.8% —— **这仍然是摊薄不是回退**:`lftpd_io.c`(53.5%) 与 +`lftpd.c`(64.3%) 这类只测了主体的文件也进了分母;按 §5 的口径,这一路该盯的是 +`files_tested`(19→32)与 `files_untested`(125→112),而不是 `line_rate`。 +其中 `lftpd_inet.c` 达到 **100%**(127/127),`net_listener.c` 99.2%、 +`net_events.c` 97.8%。 ### 三处死路径的处置(2026-10-23,用户逐条裁定后实施) 三处都不是改动引入的回归,而是**从没被执行到的分支**:生产路径上要么没有调用者、 diff --git a/scripts/id_registry.json b/scripts/id_registry.json index 55fd0dca..5d704efa 100644 --- a/scripts/id_registry.json +++ b/scripts/id_registry.json @@ -1856,6 +1856,28 @@ "subitem_ok": true, "owner_note": "B 层口径(2026-10-21 批 3 定:这些号在代码目录外零消费者,属主登记处 = 单测代码本身,不逐号补文档);2026-10-23 随覆盖率口径 §9 P1 FTP 批次补登记", "cursor_note": "游标按实测最大号收录(2026-10-23):越界即视为未登记的冒号牌" + }, + { + "name": "TC-HOST-I104SRV", + "class": "TC", + "title": "主机侧单测用例号:IEC 104 上行服务生命周期(test/host/iec104_serv)", + "pattern": "\\bTC-I104SRV-[0-9]{1,4}[a-z]?\\b", + "digit_width": [ + 2, + 4 + ], + "owner_docs_glob": [ + "test/host/iec104_serv/*" + ], + "scope": "global", + "frozen": false, + "cursor": "TC-I104SRV-041", + "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 eb75b817..30ca48fc 100644 --- a/scripts/test_coverage_baseline.json +++ b/scripts/test_coverage_baseline.json @@ -120,6 +120,14 @@ "lines_covered": 99, "lines_valid": 99 }, + "App/task/iec104_serv.c": { + "func_rate": 0.875, + "funcs_covered": 7, + "funcs_valid": 8, + "line_rate": 0.8417, + "lines_covered": 117, + "lines_valid": 139 + }, "App/util/sha256.c": { "func_rate": 1.0, "funcs_covered": 7, @@ -310,6 +318,13 @@ "lines_total": 1193, "ok": true }, + "iec104_serv": { + "files": { + "App/task/iec104_serv.c": 139 + }, + "lines_total": 139, + "ok": true + }, "lftpd": { "files": { "Drivers/BSP/NET/lftpd/lftpd.c": 473, @@ -478,6 +493,7 @@ "App/protocol/iec104/iec104_link.c", "App/protocol/iec104/iec104_pts.c", "App/sync/time_sync.c", + "App/task/iec104_serv.c", "App/util/sha256.c", "App/util/sys_clock.c", "App/util/task_prof.c", @@ -497,16 +513,16 @@ ], "totals": { "files_generated_excluded": 5, - "files_tested": 31, - "files_untested": 113, - "func_rate": 0.9015, - "funcs_covered": 293, - "funcs_valid": 325, - "line_rate": 0.8587, - "lines_covered": 3903, - "lines_valid": 4545, - "modules_ok": 27, - "modules_total": 27 + "files_tested": 32, + "files_untested": 112, + "func_rate": 0.9009, + "funcs_covered": 300, + "funcs_valid": 333, + "line_rate": 0.8582, + "lines_covered": 4020, + "lines_valid": 4684, + "modules_ok": 28, + "modules_total": 28 }, "untested_sources": [ { @@ -561,10 +577,6 @@ "code_lines": 204, "path": "Drivers/BSP/UI/ui_standby.c" }, - { - "code_lines": 202, - "path": "App/task/iec104_serv.c" - }, { "code_lines": 171, "path": "App/task/adc_task.c" diff --git a/test/host/iec104_serv/iec104_serv_test.c b/test/host/iec104_serv/iec104_serv_test.c new file mode 100644 index 00000000..3746c0e8 --- /dev/null +++ b/test/host/iec104_serv/iec104_serv_test.c @@ -0,0 +1,527 @@ +/* + * 模块名称:IEC 60870-5-104 上行服务生命周期(iec104_serv)主机侧单元测试 + * 模块功能:在 PC 上离线验证 App/task/iec104_serv.c 的服务侧骨架:监听建立与失败 + * 回收、**网络代次一变就重建监听**(改了 IP 端口必须在新地址上继续服务)、 + * accept 门控与表项失效自愈、单连接 socket 的断线语义(对端 FIN 只关数据 + * 口、绝不关监听)、连续短写判死、活性分频探活、协议状态掉回 IDLE 时丢 + * 连接并立即重建,以及时钟桥接的两个溢出边界(>4G 秒拒绝、未对时返回哨兵 + * 而不是把 1970 当真实时间)。 + * + * 被测对象:真实源文件 ../../../App/task/iec104_serv.c(#include 纳入,故它的 static + * s_bind_listen / s_drop_conn / s_service_link / s_serv_poll / s_send_apdu / + * s_clock_sync / s_epoch_ms 都能直接驱动——**不跑 iec104_serv_task 的无限 + * 主循环**,那是 never-returning 的任务体,不适合单测)。 + * iec104_link.h / iec104_pts.h / prot_set.h / sys_clock.h / sys_events.h / + * task_watchdog.h / net_types.h 全用**真实头**(iec104_state_t 枚举、 + * iec104_link_cfg_t 函数指针签名、TASK_WD_IEC104=7、SYS_EVT_* 位值一改即 + * 编译期报错);链路层(iec104_link_*)与 pts 层给假件——链路协议本身有 + * test/host/iec104 的 97 条专测,本模块只管"服务怎么活、怎么死、怎么重生"。 + * + * 为什么要主机侧单测(而不是只靠台架): + * 1. 104 主站掉线重连是**现场最常见的故障形态**,但台架上没法按需制造"改 IP 时 + * 正好有连接""对端 FIN 与 RST 的区别""连续三次短写"这些时序;而这里每一条 + * 处理错的后果都是端口永久失联或会话卡死,只能靠主站侧超时才发现。 + * 2. 判死用**连续**短写计数(一次成功即复位),写成"累计"就会把偶发拥塞误判成 + * 断线,主站看到的就是莫名掉线——这条只有能精确注入短写/成功序列才可测。 + * 3. 时钟桥接的两个边界:epoch 秒超过 32 位必须拒绝(否则截断成 2106 年前的某个 + * 乱值写进 RTC),未对时(sys_clock_get 返回 UINT32_MAX)必须回哨兵而不是 0, + * 否则对端会以为设备时间是 1970-01-01。 + * 4. 活性分频(每 32 轮才探一次)是 CPU 预算的取舍:写错成每轮都探,就把 100ms + * 轮询变成了每轮两次系统调用;写错成分频相位,TESTFR 会迟迟不发。 + * + * 覆盖点:TC-I104SRV-001..004 建监听;010..013 发送与连续短写判死;020..023 时钟 + * 桥接;030..033 代次重建与 accept 门控;034..036 活性分频与对端 FIN;037..041 + * 错误丢弃与协议掉线重生。 + * + * 通过:全部 TC PASS(main 返回 0)。 + * 入口:int main(void),返回 0=PASS / 1=FAIL(由 run 脚本判读)。 + * + * 创建日期:2026-10-23 + */ + +#include +#include + +#include "iec104_serv.h" +#include "net_socket.h" + +#include "iec104_serv.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) + +int g_i4_info_count; +int g_i4_error_count; +int g_i4_debug_count; + +/* 真实 sys_events.h 把它声明成 extern,这里给定义(本模块只在主循环里用, + 测试设成 NULL 走"事件组不可用"支路,不阻塞静态辅助函数的驱动)。 */ +osEventFlagsId_t sys_evtHandle; + +/* ===================== 可控 socket 面 ===================== */ + +typedef struct { + int socket_rc; + int bind_rc; + int listen_rc; + int accept_rc; + int send_rc; + int recv_rc; + int can_read; + int armed; + int errno_val; + uint32_t gen; + int sock_exists; + int socket_calls; + int bind_calls; + int listen_calls; + int accept_calls; + int close_calls; + int last_closed_fd; + int last_accept_sock; + int recv_calls; + uint16_t last_bind_port_net; + uint16_t last_bind_family; + uint32_t last_bind_addr; + int delay_calls; +} i4_net_t; + +static i4_net_t g_n; + +/* net_get_sock 要返回一个真 net_sock_t(结构来自真实 net_types.h),先声明再用 */ +static net_sock_t g_sock_probe; + +int net_socket(int domain, int type) { (void)domain; (void)type; g_n.socket_calls++; return g_n.socket_rc; } +int net_bind(int sockfd, const struct net_sockaddr *addr, int addrlen) +{ + const struct net_sockaddr_in *a = (const struct net_sockaddr_in *)addr; + + (void)sockfd; + (void)addrlen; + g_n.bind_calls++; + g_n.last_bind_port_net = a->sin_port; + g_n.last_bind_family = a->sin_family; + g_n.last_bind_addr = a->sin_addr.s_addr; + return g_n.bind_rc; +} +int net_listen(int sockfd) { (void)sockfd; g_n.listen_calls++; return g_n.listen_rc; } +int net_accept(int sockfd, struct net_sockaddr *addr, int *addrlen) +{ + (void)addr; + (void)addrlen; + g_n.accept_calls++; + g_n.last_accept_sock = sockfd; + return g_n.accept_rc; +} +int net_close(int sockfd) { g_n.close_calls++; g_n.last_closed_fd = sockfd; return 0; } +int net_send(int sockfd, const void *buf, int len, int flags) +{ + (void)sockfd; + (void)buf; + (void)flags; + return g_n.send_rc; +} +int net_recv_nb(int sockfd, void *buf, int len) +{ + (void)sockfd; + (void)buf; + (void)len; + g_n.recv_calls++; + return g_n.recv_rc; +} +int net_can_read(int sockfd) { (void)sockfd; return g_n.can_read; } +int net_get_errno(void) { return g_n.errno_val; } +int net_listener_armed(int sockfd) { (void)sockfd; return g_n.armed; } +uint32_t net_get_generation(void) { return g_n.gen; } +net_sock_t *net_get_sock(int sockfd) { (void)sockfd; return (g_n.sock_exists != 0) ? &g_sock_probe : NULL; } +uint16_t net_htons(uint16_t v) { return (uint16_t)((v >> 8) | (v << 8)); } + +/* ===================== 假链路层与假 pts ===================== */ + +static int g_lk_init; +static int g_lk_open; +static int g_lk_close; +static int g_lk_poll; +static int g_lk_onrx; +static uint32_t g_lk_last_now; +static iec104_state_t g_lk_state; + +void iec104_link_init(iec104_link_t *lk, const iec104_link_cfg_t *cfg, void *ctx) +{ + (void)lk; (void)cfg; (void)ctx; + g_lk_init++; +} +void iec104_link_open(iec104_link_t *lk, uint32_t now_ms) +{ + (void)lk; + g_lk_open++; + g_lk_last_now = now_ms; +} +void iec104_link_close(iec104_link_t *lk) { (void)lk; g_lk_close++; } +void iec104_link_poll(iec104_link_t *lk, uint32_t now_ms) +{ + (void)lk; + g_lk_poll++; + g_lk_last_now = now_ms; +} +void iec104_link_on_rx(iec104_link_t *lk, const uint8_t *data, uint16_t len, uint32_t now_ms) +{ + (void)lk; (void)data; (void)len; (void)now_ms; + g_lk_onrx++; +} +iec104_state_t iec104_link_state(const iec104_link_t *lk) { (void)lk; return g_lk_state; } + +static int g_pts_init; +void iec104_pts_init(void) { g_pts_init++; } + +/* ===================== 假时钟 / 定值 / 看门狗 / RTOS ===================== */ + +static uint32_t g_clock_val; +static int g_clock_set_rc; +static uint32_t g_clock_last_secs; +static int g_clock_set_calls; +static uint32_t g_dirty; +static int g_evt_set_calls; +static uint32_t g_evt_last; +static int g_wd_beats; +static int g_wd_last_id; +static uint32_t g_now; + +uint32_t sys_clock_get(void) { return g_clock_val; } +int sys_clock_set_unix(uint32_t unix_secs) +{ + g_clock_set_calls++; + g_clock_last_secs = unix_secs; + return g_clock_set_rc; +} +uint8_t prot_set_dirty_peek(void) { return (uint8_t)g_dirty; } +uint32_t osEventFlagsSet(osEventFlagsId_t ef_id, uint32_t flags) +{ + (void)ef_id; + g_evt_set_calls++; + g_evt_last = flags; + return flags; +} +uint32_t osEventFlagsWait(osEventFlagsId_t ef_id, uint32_t flags, uint32_t options, uint32_t timeout) +{ + (void)ef_id; (void)flags; (void)options; (void)timeout; + return 0u; +} +void task_wd_beat(task_wd_id_t id) { g_wd_beats++; g_wd_last_id = (int)id; } +uint32_t osKernelGetTickCount(void) { return g_now; } +void osDelay(uint32_t ticks) +{ + (void)ticks; + g_n.delay_calls++; + if (g_n.delay_calls > 500) { + printf(" ABORT 跑飞(>500 轮 osDelay)\n"); + fflush(stdout); + exit(2); + } +} + +/* ===================== 复位 ===================== */ + +static void reset_all(void) +{ + (void)memset(&g_n, 0, sizeof(g_n)); + g_n.socket_rc = 5; + g_n.bind_rc = 0; + g_n.listen_rc = 0; + g_n.accept_rc = -1; + g_n.send_rc = 0; + g_n.armed = 1; + g_n.sock_exists = 1; + g_n.errno_val = NET_ERR_WOULDBLOCK; + g_n.gen = 100u; + (void)memset(&g_sock_probe, 0, sizeof(g_sock_probe)); + g_lk_init = 0; g_lk_open = 0; g_lk_close = 0; g_lk_poll = 0; g_lk_onrx = 0; + g_lk_last_now = 0u; + g_lk_state = IEC104_ST_RUN; + g_pts_init = 0; + g_clock_val = 1700000000u; + g_clock_set_rc = 0; + g_clock_last_secs = 0u; + g_clock_set_calls = 0; + g_dirty = 0u; + g_evt_set_calls = 0; + g_evt_last = 0u; + g_wd_beats = 0; + g_wd_last_id = -1; + g_now = 5000u; + g_i4_info_count = 0; + g_i4_error_count = 0; + g_i4_debug_count = 0; + sys_evtHandle = NULL; + s_listen = -1; + s_client = -1; + s_gen = 100u; + s_liveness_ctr = 0u; + s_send_fail_run = 0u; + s_conn_dead = 0u; +} + +/* ===================== 建监听 ===================== */ + +static void tc_bind_listen(void) +{ + printf("-- TC-I104SRV-001..004 bind/listen --\n"); + reset_all(); + CHECK((s_bind_listen() == 0) && (s_listen == 5) && (g_n.listen_calls == 1) && + (g_n.last_bind_family == NET_AF_INET) && + (g_n.last_bind_port_net == net_htons(IEC104_SERV_PORT)) && + (g_n.last_bind_addr == NET_INADDR_ANY), + "TC-I104SRV-001 建监听:2404 端口过 htons、绑任意地址、记下监听号"); + + reset_all(); + g_n.socket_rc = -1; + CHECK((s_bind_listen() == -1) && (g_n.bind_calls == 0) && (g_n.close_calls == 0), + "TC-I104SRV-002 建不出 socket 就返回,不去 bind 也不关不存在的口"); + + reset_all(); + g_n.bind_rc = -1; + CHECK((s_bind_listen() == -1) && (g_n.close_calls == 1) && (g_n.listen_calls == 0), + "TC-I104SRV-003 bind 失败要把刚建的口关掉(否则每次重试漏一个 socket 号)"); + + reset_all(); + g_n.listen_rc = -1; + CHECK((s_bind_listen() == -1) && (g_n.close_calls == 1) && (s_listen == -1), + "TC-I104SRV-004 listen 失败同样关口且不留下半个监听"); +} + +/* ===================== 发送与连续短写判死 ===================== */ + +static void tc_send(void) +{ + const uint8_t apdu[4] = { 0x68u, 0x02u, 0x00u, 0x00u }; + + printf("-- TC-I104SRV-010..013 send & fail streak --\n"); + reset_all(); + s_client = -1; + s_send_apdu(NULL, apdu, 4u); + CHECK((g_n.send_rc == 0) && (g_n.close_calls == 0) && (g_i4_error_count == 0), + "TC-I104SRV-010 没连接时直接返回(不发 net_send、不报错)"); + + reset_all(); + s_client = 7; + g_n.send_rc = 4; + s_send_apdu(NULL, apdu, 4u); + CHECK((s_send_fail_run == 0u) && (s_conn_dead == 0u), + "TC-I104SRV-011 完整发出把连败计数清零"); + + reset_all(); + s_client = 7; + g_n.send_rc = 2; /* 短写:只发了 2/4 */ + s_send_apdu(NULL, apdu, 4u); + CHECK((s_send_fail_run == 1u) && (s_conn_dead == 0u) && (g_i4_error_count == 1), + "TC-I104SRV-012 第一次短写:留痕但不判死(拥塞很正常)"); + s_send_apdu(NULL, apdu, 4u); + s_send_apdu(NULL, apdu, 4u); + CHECK((s_send_fail_run == 3u) && (s_conn_dead == 1u), + "TC-I104SRV-012b 连到 3 次才判死(IEC104_SEND_FAIL_MAX)"); + + reset_all(); + s_client = 7; + g_n.send_rc = 1; + s_send_apdu(NULL, apdu, 4u); + s_send_apdu(NULL, apdu, 4u); + g_n.send_rc = 4; + s_send_apdu(NULL, apdu, 4u); /* 中间成功一次 */ + g_n.send_rc = 1; + s_send_apdu(NULL, apdu, 4u); + s_send_apdu(NULL, apdu, 4u); + CHECK((s_conn_dead == 0u) && (s_send_fail_run == 2u), + "TC-I104SRV-013 **成功即复位**:偶发拥塞不会被累计成误杀"); +} + +/* ===================== 时钟桥接边界 ===================== */ + +static void tc_clock(void) +{ + printf("-- TC-I104SRV-020..023 clock bridge --\n"); + reset_all(); + CHECK(s_clock_sync(NULL, 0x100000000ULL * 1000ULL) == -1, + "TC-I104SRV-020 秒数超过 32 位一律拒绝(截断写进 RTC 就是 2106 年后的乱值)"); + CHECK(g_clock_set_calls == 0, "TC-I104SRV-020b 拒绝时根本不该碰时钟"); + + reset_all(); + (void)s_clock_sync(NULL, 1700000123000ULL); + CHECK((g_clock_set_calls == 1) && (g_clock_last_secs == 1700000123u), + "TC-I104SRV-021 毫秒按整除换秒后写时钟"); + + reset_all(); + g_clock_val = UINT32_MAX; /* 未对时的哨兵值 */ + CHECK(s_epoch_ms(NULL) == UINT64_MAX, + "TC-I104SRV-022 未对时回 UINT64_MAX 哨兵(绝不能把 1970 当成设备时间)"); + + reset_all(); + g_clock_val = 1700000123u; + CHECK(s_epoch_ms(NULL) == 1700000123000ULL, "TC-I104SRV-023 正常时对时给出毫秒"); +} + +/* ===================== 代次重建与 accept 门控 ===================== */ + +static void tc_generation(void) +{ + printf("-- TC-I104SRV-030..033 generation & accept gate --\n"); + reset_all(); + s_listen = 5; + s_client = 9; + g_n.gen = 101u; /* 网络重建(现场改了 IP) */ + s_serv_poll(); + CHECK((g_n.close_calls == 2) && (g_n.last_closed_fd == 5) && + (s_client == -1) && (s_liveness_ctr == 0u) && (s_conn_dead == 0u) && + (g_lk_close == 1) && (s_gen == 101u) && (g_n.bind_calls == 1), + "TC-I104SRV-030 代次一变就拆掉旧连接与旧监听并重建(新 IP 上端口必须继续服务)"); + + reset_all(); + s_listen = -1; + s_serv_poll(); + CHECK((g_n.bind_calls == 1) && (s_listen == 5) && (g_n.accept_calls == 0), + "TC-I104SRV-031 监听丢了就补建,这一轮不去 accept"); + + reset_all(); + s_listen = 5; + g_n.armed = 0; /* 监听还没就绪 */ + g_n.sock_exists = 1; + s_serv_poll(); + CHECK((g_n.accept_calls == 0) && (s_listen == 5), + "TC-I104SRV-032 监听门未开时不 accept(否则对着未就绪的号空转)"); + + reset_all(); + s_listen = 5; + g_n.armed = 0; + g_n.sock_exists = 0; /* 表项已不存在 */ + s_serv_poll(); + CHECK((s_listen == -1) && (g_n.accept_calls == 0), + "TC-I104SRV-032b 表项已失效时把监听号丢掉,下一轮重建"); + + reset_all(); + s_listen = 5; + g_n.accept_rc = 9; + s_send_fail_run = 3u; + s_conn_dead = 1u; + s_liveness_ctr = 7u; + s_serv_poll(); + CHECK((s_client == 9) && (g_lk_open == 1) && (g_lk_last_now == 5000u) && + (s_send_fail_run == 0u) && (s_conn_dead == 0u) && (s_liveness_ctr == 0u) && + (g_i4_info_count == 1), + "TC-I104SRV-033 新连接:打开链路、三个失败计数全部复位"); +} + +/* ===================== 活性分频、对端 FIN、错误丢弃 ===================== */ + +static void tc_liveness(void) +{ + printf("-- TC-I104SRV-034..041 liveness / EOF / drop --\n"); + reset_all(); + s_listen = 5; + s_client = 9; + g_n.can_read = 0; + s_liveness_ctr = 0u; + s_serv_poll(); + CHECK((g_n.recv_calls == 0) && (g_lk_poll == 1) && (s_liveness_ctr == 1u), + "TC-I104SRV-034 无可读数据时按 32 分频探活,这一轮不收包(省 CPU 预算)"); + + reset_all(); + s_listen = 5; + s_client = 9; + g_n.can_read = 0; + /* 这一轮收到的是"暂无数据"(非阻塞的空读),而不是 0——0 在 recv_nb 的语义里是 + * 对端 FIN,会直接走 036 那条断开支路,测不到分频本身。 */ + g_n.recv_rc = -1; + g_n.errno_val = NET_ERR_WOULDBLOCK; + s_liveness_ctr = 31u; /* 下一轮正好到分频点 */ + s_serv_poll(); + CHECK((g_n.recv_calls == 1) && (g_lk_poll == 1) && (s_client == 9), + "TC-I104SRV-035 分频到点才去收一次(TESTFR 等活性帧靠它发出去)"); + + reset_all(); + s_listen = 5; + s_client = 9; + g_n.can_read = 1; + g_n.recv_rc = 0; /* 对端 FIN */ + s_serv_poll(); + CHECK((g_n.last_closed_fd == 9) && (s_client == -1) && (g_lk_close == 1) && + (g_n.close_calls == 1) && (s_listen == 5) && (g_n.bind_calls == 0), + "TC-I104SRV-036 对端正常断开:只关数据口、**监听必须留着**(单连接 socket 关错就是端口失联)"); + + reset_all(); + s_listen = 5; + s_client = 5; /* 单连接复用:数据号 == 监听号 */ + g_n.can_read = 1; + g_n.recv_rc = 0; + s_serv_poll(); + CHECK((g_n.close_calls == 0) && (s_client == -1) && (g_lk_close == 1), + "TC-I104SRV-036b 数据号与监听号同号时**不许关**(否则把监听一起毁掉)"); + + reset_all(); + s_listen = 5; + s_client = 9; + g_n.can_read = 1; + g_n.recv_rc = -1; + g_n.errno_val = NET_ERR_WOULDBLOCK; /* 空转:没数据可读 */ + s_serv_poll(); + CHECK((s_client == 9) && (g_lk_close == 0) && (g_i4_error_count == 0), + "TC-I104SRV-037 WOULDBLOCK 是正常空转,不能当成错误丢连接"); + + reset_all(); + s_listen = 5; + s_client = 9; + g_n.can_read = 1; + g_n.recv_rc = -1; + g_n.errno_val = NET_ERR_CONNRESET; /* 真错误 */ + s_serv_poll(); + CHECK((s_client == -1) && (s_listen == 5) && (g_i4_error_count == 1) && + (g_lk_close == 1) && (s_liveness_ctr == 0u), + "TC-I104SRV-038 真错误:丢连接并**当场重建监听**(不留空窗)"); + + reset_all(); + s_listen = 5; + s_client = 9; + s_conn_dead = 1u; + s_service_link(1000u); + CHECK((s_client == -1) && (g_n.close_calls == 1) && (g_i4_error_count == 1), + "TC-I104SRV-039 判死后由 service_link 收口(发送失败线索单独成一条)"); + + reset_all(); + s_listen = 5; + s_client = 9; + g_lk_state = IEC104_ST_IDLE; /* 链路层自己回到了未建立 */ + s_service_link(1000u); + CHECK((s_client == -1) && (g_lk_close >= 1) && (s_listen == 5), + "TC-I104SRV-040 协议状态掉回 IDLE 也要丢并重生(否则服务挂着但永不服务)"); + + reset_all(); + s_listen = 9; + s_client = 9; + g_n.socket_rc = -1; /* 重建失败 */ + g_n.sock_exists = 0; /* 且表项已经没了 */ + g_n.can_read = 1; + g_n.recv_rc = 0; + s_serv_poll(); + CHECK((s_client == -1) && (s_listen == -1) && (g_n.close_calls == 0), + "TC-I104SRV-041 表项没了又重建失败:丢掉假监听号,下一轮继续重试(数据号与监听同号时不许关)"); +} + +int main(void) +{ + printf("=== IEC104 SERV Tests ===\n"); + + tc_bind_listen(); + tc_send(); + tc_clock(); + tc_generation(); + tc_liveness(); + + printf("\n=== IEC104 SERV Tests: %d/%d PASSED (failed=%d) ===\n", + s_passed, s_total, s_failed); + return (s_failed == 0) ? 0 : 1; +} diff --git a/test/host/iec104_serv/run_unit_test.py b/test/host/iec104_serv/run_unit_test.py new file mode 100644 index 00000000..afc448d3 --- /dev/null +++ b/test/host/iec104_serv/run_unit_test.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""IEC 104 上行服务生命周期(iec104_serv)主机侧单元测试运行器(L0.5,纯标准库)。 + +做什么: + 用本机 MSVC 编译 test/host/iec104_serv/iec104_serv_test.c(以 #include 纳入生产源 + App/task/iec104_serv.c),离线驱动它的全部 static 辅助函数(**不跑 never-returning + 的任务主循环**),验证监听建立与失败回收、网络代次一变就重建监听、accept 门控与 + 表项失效自愈、单连接 socket 的断线语义、连续短写判死、活性分频探活、协议掉回 IDLE + 时丢弃重生,以及时钟桥接的两个溢出边界。 + +为什么要主机侧单测(而不是只用台架): + 104 主站掉线重连是现场最常见故障形态,但台架无法按需制造"改 IP 时正好有连接" + "对端 FIN 与 RST 的区别""连续三次短写";这几条处理错的后果都是端口永久失联或 + 会话卡死,只能等主站侧超时才发现。判死用**连续**短写计数(成功即复位),写成累计 + 就会把偶发拥塞误判成断线;时钟桥接的 >4G 秒必须拒绝、未对时必须回哨兵而不是 0; + 活性分频写错就成了每轮多一次系统调用或 TESTFR 迟迟不发。 + +桩的边界:iec104_link.h / iec104_pts.h / prot_set.h / sys_clock.h / sys_events.h / + task_watchdog.h / net_types.h 用真实头(iec104_state_t、cfg 的函数指针签名、 + TASK_WD_IEC104=7、SYS_EVT_* 位值一改即编译期报错);链路层与 pts 层给假件——链路 + 协议本身有 test/host/iec104 的 97 条专测,本模块只管服务怎么活、怎么死、怎么重生; + socket 面给可控假件(连 socket 号、发送返回、错误码、代次、门控都能注入)。 + +用法(仓库根目录): + python test/host/iec104_serv/run_unit_test.py + +退出码:0=PASS / 1=测试失败 / 2=环境错误。 + +设计依据:docs/子系统设计/*104* 设计文档、AGENTS.md 模块约定(单连接 socket 断开语义、 +错误码经任务通知值回传)、docs/测试说明/主机单测覆盖率口径.md §9 +创建日期: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 + + +def _header_dirs(base): + """收集 base 下所有**真的放着 .h** 的目录。 + + 104 服务要 include 的 iec104_serv.h / iec104_link.h / iec104_pts.h / + task_watchdog.h / sys_clock.h / sys_events.h / prot_set.h 散在 App 的多个 + 子目录里,逐个手写列表一定会漏(漏一个就是 C1083,而且报错落在被测源上, + 很容易被误读成生产代码坏了)。这里按目录实际内容自动收集,多给几个 /I + 对编译没有代价,却把"漏目录"这类脆错误消掉。 + """ + dirs = [] + for root, _sub, files in os.walk(base): + if any(f.endswith(".h") for f in files): + dirs.append(root) + return sorted(dirs) + + +BUILD = TestBuild( + name="IEC104 SERV Tests", + src=os.path.join(HERE, "iec104_serv_test.c"), + include_dirs=[ + os.path.join(HERE, "stub"), # 必须先于真头目录(覆盖 net_socket.h 等) + ] + _header_dirs(os.path.join(ROOT, "App")) + + # sys_clock.h 会拉 sd2506.h(Drivers/BSP/SD2506),BSP 的头也散在多个子目录里, + # 同样按实际内容收集。注意 stub 目录排在最前,桩依然优先于这些真头。 + _header_dirs(os.path.join(ROOT, "Drivers", "BSP")) + [ + os.path.join(ROOT, "Drivers", "BSP", "NET"), + HERE, + ], + summary_marker="=== IEC104 SERV Tests:", +) + +if __name__ == "__main__": + sys.exit(run_host_unit_test(BUILD)) + diff --git a/test/host/iec104_serv/stub/cmsis_os2.h b/test/host/iec104_serv/stub/cmsis_os2.h new file mode 100644 index 00000000..41e32742 --- /dev/null +++ b/test/host/iec104_serv/stub/cmsis_os2.h @@ -0,0 +1,27 @@ +/* + * 模块名称:主机侧单测桩 - cmsis_os2.h(iec104_serv 模块专用) + * 模块功能:给 CMSIS-RTOS2 的六个入口一个宿主等价壳(tick、睡眠、事件组)。 + * ⚠ 仅供主机侧单测使用,不参与 MCU 构建。 + * 事件组只做成"记次数":本模块的测试**不驱动 iec104_serv_task 的无限主循环** + * (那会永不返回),只直调它的静态辅助函数,事件相关行为用可见计数器观察。 + * 创建日期:2026-10-23 + */ +#ifndef CMSIS_OS2_H +#define CMSIS_OS2_H + +#include + +typedef void *osEventFlagsId_t; +typedef uint32_t osStatus_t; + +#define osFlagsWaitAny 0x00000000u +#define osFlagsNoClear 0x00000001u +#define osFlagsError 0x80000000u + +uint32_t osKernelGetTickCount(void); +void osDelay(uint32_t ticks); +uint32_t osEventFlagsWait(osEventFlagsId_t ef_id, uint32_t flags, + uint32_t options, uint32_t timeout); +uint32_t osEventFlagsSet(osEventFlagsId_t ef_id, uint32_t flags); + +#endif /* CMSIS_OS2_H */ diff --git a/test/host/iec104_serv/stub/dbg_log.h b/test/host/iec104_serv/stub/dbg_log.h new file mode 100644 index 00000000..5abb3c5c --- /dev/null +++ b/test/host/iec104_serv/stub/dbg_log.h @@ -0,0 +1,19 @@ +/* + * 模块名称:主机侧单测桩 - dbg_log.h(iec104_serv 模块专用) + * 模块功能:DBG_* 计数;本模块的判死/丢连接都靠 ERROR 留痕("i104 drop: send fail" + * 与 "protocol fault" 是两条不同的现场线索)。 + * ⚠ 仅供主机侧单测使用,不参与 MCU 构建。 + * 创建日期:2026-10-23 + */ +#ifndef DBG_LOG_H +#define DBG_LOG_H + +extern int g_i4_info_count; +extern int g_i4_error_count; +extern int g_i4_debug_count; + +#define DBG_INFO(fmt, ...) do { (void)(fmt); g_i4_info_count++; } while (0) +#define DBG_DEBUG(fmt, ...) do { (void)(fmt); g_i4_debug_count++; } while (0) +#define DBG_ERROR(fmt, ...) do { (void)(fmt); g_i4_error_count++; } while (0) + +#endif /* DBG_LOG_H */ diff --git a/test/host/iec104_serv/stub/main.h b/test/host/iec104_serv/stub/main.h new file mode 100644 index 00000000..a0052993 --- /dev/null +++ b/test/host/iec104_serv/stub/main.h @@ -0,0 +1,15 @@ +/* + * 模块名称:主机侧单测桩 - main.h(NET/BSP 共享形态,本模块用于 iec104_serv) + * 模块功能:只给 HAL_GetTick;某些 BSP 头的 include 链会绕到这里(真实 main.h 拉整棵 + * HAL 头树,PC 上编不动)。 + * ⚠ 仅供主机侧单测使用,不参与 MCU 构建。 + * 创建日期:2026-10-23 + */ +#ifndef MAIN_H +#define MAIN_H + +#include + +uint32_t HAL_GetTick(void); + +#endif /* MAIN_H */ diff --git a/test/host/iec104_serv/stub/net_socket.h b/test/host/iec104_serv/stub/net_socket.h new file mode 100644 index 00000000..7d85de0c --- /dev/null +++ b/test/host/iec104_serv/stub/net_socket.h @@ -0,0 +1,31 @@ +/* + * 模块名称:主机侧单测桩 - net_socket.h(NET 域共享形态,见 net_listener 同名文件) + * 模块功能:给 104 服务端一个可控 socket 面:建口/绑定/监听/接受/关闭/发送、 + * 非阻塞收(net_recv_nb)、可读门控(net_can_read)、监听门控 + * (net_listener_armed)、错误码通道(net_get_errno)、网络代次 + * (net_get_generation)与表项查询(net_get_sock)。 + * 地址结构与 NET_AF_INET / NET_SOCK_STREAM / NET_INADDR_ANY / + * NET_ERR_WOULDBLOCK 一律取**真实** net_types.h。 + * ⚠ 仅供主机侧单测使用,不参与 MCU 构建。 + * 创建日期:2026-10-23 + */ +#ifndef NET_SOCKET_H +#define NET_SOCKET_H + +#include "net_types.h" + +int net_socket(int domain, int type); +int net_bind(int sockfd, const struct net_sockaddr *addr, int addrlen); +int net_listen(int sockfd); +int net_accept(int sockfd, struct net_sockaddr *addr, int *addrlen); +int net_close(int sockfd); +int net_send(int sockfd, const void *buf, int len, int flags); +int net_recv_nb(int sockfd, void *buf, int len); +int net_can_read(int sockfd); +int net_get_errno(void); +int net_listener_armed(int sockfd); +uint32_t net_get_generation(void); +net_sock_t *net_get_sock(int sockfd); +uint16_t net_htons(uint16_t hostshort); + +#endif /* NET_SOCKET_H */ diff --git a/test/host/iec104_serv/stub/sys_clock.h b/test/host/iec104_serv/stub/sys_clock.h new file mode 100644 index 00000000..8baffad7 --- /dev/null +++ b/test/host/iec104_serv/stub/sys_clock.h @@ -0,0 +1,21 @@ +/* + * 模块名称:主机侧单测桩 - sys_clock.h(iec104_serv 模块专用) + * 模块功能:只给本模块真正用到的两个入口:读秒与写秒。 + * ⚠ 仅供主机侧单测使用,不参与 MCU 构建。 + * 为什么必须桩这层而不是用真实头:真实 App/util/sys_clock.h 会 include sd2506.h + * (RTC 芯片驱动),那条链一直拉到 stm32f4xx_hal.h 与寄存器定义,PC 上编不动。 + * 两个签名与真实头**逐字一致**(uint32_t sys_clock_get(void) / + * int sys_clock_set_unix(uint32_t)),改签名就会在测试里对不上假件而报错。 + * 本模块最有价值的两条恰好是这两个入口的**边界**:>4G 秒必须拒绝、未对时的 + * UINT32_MAX 哨兵必须传成 UINT64_MAX,所以桩掉实现不影响被测语义。 + * 创建日期:2026-10-23 + */ +#ifndef SYS_CLOCK_H +#define SYS_CLOCK_H + +#include + +uint32_t sys_clock_get(void); +int sys_clock_set_unix(uint32_t unix_secs); + +#endif /* SYS_CLOCK_H */