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" + } + ] +}