From 2055de030378be621f28833645463d81969ec7c8 Mon Sep 17 00:00:00 2001 From: Wanderingss <1624155937@qq.com> Date: Thu, 1 Oct 2026 07:28:57 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B5=8B=E8=AF=95=EF=BC=9A=E8=90=BD=E5=9C=B0?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E6=9E=B6=E6=9E=84=E6=94=B9=E8=BF=9B=20T-1~T-?= =?UTF-8?q?5=EF=BC=88=E5=A5=97=E4=BB=B6=E5=BC=80=E5=85=B3=E9=97=A8?= =?UTF-8?q?=E7=A6=81/=E6=B1=87=E6=80=BB=E8=A1=8C=E6=A0=A1=E9=AA=8C/?= =?UTF-8?q?=E9=80=9A=E7=94=A8=20harness/=E5=85=AC=E5=85=B1=E6=96=AD?= =?UTF-8?q?=E8=A8=80=E5=AE=8F/=E5=A4=9A=E5=A5=97=E4=BB=B6=E7=BC=96?= =?UTF-8?q?=E6=8E=92=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景:L0.5 主机侧单测落地后复审测试架构,给出 6 条改进意见(P0~P3), 登记进 docs/待处理内容.md「测试与验证基础设施」后逐条实施。 - T-1(P0) 测试套件开关门禁:新增 scripts/check_test_suite.py + pre-commit 钩子 test-suite-switch,默认要求产品构建(全部 TEST_SUITE_* 注释),跑套件回归用 --allow-test-build / ALLOW_TEST_BUILD=1 显式放行。顺带把 test_config.h 中长期 开启的 TEST_SUITE_CH395F 改回注释(仓库此前躺着的是测试固件)。 - T-2(P1) verify_log.py 增加 --require-suite-summary:要求 "=== <名称>: N/M PASSED (failed=0) ===" 且 N>0,堵住"套件没跑到汇总行/ 断言数为 0 仍判 PASS"的盲区;run_boot_test.py 同名参数透传。不传参向后兼容。 - T-3(P1) 抽取通用 harness test/host/common/unit_test_harness.py(工具链探测/ INCLUDE-LIB 拼接/wd4819/GBK 解码/退出码契约);watchdog 运行器 170→55 行,只声明 TestBuild。 - T-4(P2) 新增 test/board/test_common.h 集中断言内核(TEST_CHECK_IMPL/TEST_REPORT_IMPL), 4 套件各用一行宏绑定自身统计变量(保持 g_xxx_stats 独立、防跨套件串数)。 刻意不 include dbg_log.h——否则冻结 DBG_TAG 默认值触发 #47-D 重定义警告。 - T-5(P2) 新增 test/run_all_suites.py 多套件回归编排,try/finally 无条件还原套件开关。 验证:@build 产品构建 0 错 0 警;4 套件逐一构建均 0/0;看门狗单测 39/39 PASS; pre-commit 14 钩子全通过;编排脚本 dry-run/开关切换/异常还原均实测通过。 --- .pre-commit-config.yaml | 16 +- AGENTS.md | 2 +- docs/代码规范/pre-commit使用说明.md | 40 ++++- docs/内存管理/RAM使用分析与优化方案.md | 6 +- docs/待处理内容.md | 21 +++ docs/测试说明/测试开发标准流程.md | 97 ++++++++-- docs/测试说明/看门狗单元测试说明.md | 12 +- scripts/README.md | 3 + scripts/check_test_suite.py | 195 +++++++++++++++++++++ test/README.md | 15 ++ test/board/README.md | 5 + test/board/ch395f_test.h | 28 +-- test/board/gd5f_test.h | 25 +-- test/board/sd2506_test.h | 27 +-- test/board/storage_test.h | 25 +-- test/board/test_common.h | 82 +++++++++ test/board/test_config.h | 10 +- test/host/README.md | 15 +- test/host/common/README.md | 82 +++++++++ test/host/common/unit_test_harness.py | 218 +++++++++++++++++++++++ test/host/run_boot_test.py | 7 +- test/host/verify_log.py | 70 +++++++- test/host/watchdog/run_unit_test.py | 162 +++-------------- test/run_all_suites.py | 233 +++++++++++++++++++++++++ 24 files changed, 1135 insertions(+), 261 deletions(-) create mode 100644 scripts/check_test_suite.py create mode 100644 test/board/test_common.h create mode 100644 test/host/common/README.md create mode 100644 test/host/common/unit_test_harness.py create mode 100644 test/run_all_suites.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 308dcd18..3c366b4b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -159,5 +159,19 @@ repos: name: 看门狗判定状态机单测(主机侧) entry: python test/host/watchdog/run_unit_test.py language: system - files: ^(App/util/task_watchdog\.(c|h)|test/host/watchdog/.*)$ + files: ^(App/util/task_watchdog\.(c|h)|test/host/watchdog/.*|test/host/common/unit_test_harness\.py)$ + pass_filenames: false + + # 8) 测试套件开关门禁(2026-10-01 加) + # 背景:test_config.h 的 TEST_SUITE_* 决定固件里编入哪个测试套件, + # 全部注释 = 产品构建。但此前**无任何门禁检查**——实测已提交的 HEAD 里 + # TEST_SUITE_CH395F 就是开启状态(.map 含 34 处 test 代码),即仓库里 + # 躺着的是测试固件。本钩子抓这个静默漏网:默认要求产品构建,跑套件回归 + # 确需提交测试构建时用 `--allow-test-build` / ALLOW_TEST_BUILD=1 显式放行。 + # 详见 docs/待处理内容.md T-1 + - id: test-suite-switch + name: 测试套件开关=产品构建 + entry: python scripts/check_test_suite.py + language: system + files: ^test/board/test_config\.h$ pass_filenames: false diff --git a/AGENTS.md b/AGENTS.md index 2e3077a1..d5cad583 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,7 +95,7 @@ STM32F407ZGTx @168MHz(HSE 8MHz → PLL M=4/N=168/P=2);**仅 Keil MDK-ARM v - 网口配置:`/config.ini` [network] 段(工厂默认 192.168.1.100/24、gw .1;MAC 全零 = 芯片内置;UI 保存立即生效;旧档 `/cfg/netcfg.bin` 首启自动迁移)→ `docs/子系统设计/网络配置说明.md` - **新 .c 文件必须手动注册到两处**才能双端编译:`MDK-ARM/STM32F407-Demo.uvprojx` + `simulator/CMakeLists.txt`(用 `new-source-file` skill) - **`MDK-ARM/*.uvprojx` 每次 `@build` 后被 Keil UV4 自动改写,改动一律不入库**(2026-09-29 实测):UV4 构建时会把工程 XML 归一化成它本机风格的旧式写法(`` → ``、XML 声明加 `standalone="no"`),并把 `PackID` 降到**本机装的 DFP 版本**(本机实测 `3.1.1` → `3.0.0`,属**降级**)。这些差异是环境相关的,提交后会把别人的 Pack 版本也一起改掉。**判断方法**:`git diff MDK-ARM/STM32F407-Demo.uvprojx` 里若只见 XML 格式/`PackID` 差异而**无源文件挂载增删** → `git checkout -- MDK-ARM/STM32F407-Demo.uvprojx` 丢弃。只有真的新增/删除源文件(`grep 文件名` 计数变化)才提交 -- 测试构建:`test/board/test_config.h` 的 `TEST_SUITE_CH395F/STORAGE/GD5F/RTC` **互斥**(一次只能启用一个,`#error`);全部注释 = 产品构建;**破坏性用例默认关闭**:同文件 `ENABLE_DESTRUCTIVE_TESTS`(默认注释)控制写/擦 NAND、格式化、坏块注入与 FatFS 写类用例——关闭时套件为**只读模式**(开机横幅 `mode: READ-ONLY`,可随时在在用设备上跑、不动卷内容与容量),只在**产品首次上电/产线首启**或明确需要时打开(`mode: DESTRUCTIVE`);2026-09-21 实测一轮破坏性 GD5F 运行即让 dhara 多记一个坏块(容量 95248 → 95200;该计数持久化在 dhara journal checkpoint 头里,普通恢复模式清不掉,需全片擦除——见 `docs/测试说明/存储维护与容量恢复.md`);CH395F 套件 `test/board/ch395f_test.h`:硬件层 Phase 1~5 与 NET 层 Phase 6~11 互斥;CH395F 硬件层模式下 netTask(基础线程:无条件创建、全构建唯一实例)入口自挂起(不运行),testTask 独占 CH395F +- 测试构建:`test/board/test_config.h` 的 `TEST_SUITE_CH395F/STORAGE/GD5F/RTC` **互斥**(一次只能启用一个,`#error`);全部注释 = 产品构建;**该开关受 pre-commit 钩子 `test-suite-switch` 门禁**(默认要求产品构建,跑完套件回归须改回;确需提交测试构建用 `ALLOW_TEST_BUILD=1` 显式放行——2026-10-01 实测已提交 HEAD 里 `TEST_SUITE_CH395F` 长期开启,纯靠人记得改,故补此门禁);**破坏性用例默认关闭**:同文件 `ENABLE_DESTRUCTIVE_TESTS`(默认注释)控制写/擦 NAND、格式化、坏块注入与 FatFS 写类用例——关闭时套件为**只读模式**(开机横幅 `mode: READ-ONLY`,可随时在在用设备上跑、不动卷内容与容量),只在**产品首次上电/产线首启**或明确需要时打开(`mode: DESTRUCTIVE`);2026-09-21 实测一轮破坏性 GD5F 运行即让 dhara 多记一个坏块(容量 95248 → 95200;该计数持久化在 dhara journal checkpoint 头里,普通恢复模式清不掉,需全片擦除——见 `docs/测试说明/存储维护与容量恢复.md`);CH395F 套件 `test/board/ch395f_test.h`:硬件层 Phase 1~5 与 NET 层 Phase 6~11 互斥;CH395F 硬件层模式下 netTask(基础线程:无条件创建、全构建唯一实例)入口自挂起(不运行),testTask 独占 CH395F - **改驱动前先读对应 `docs/问题记录/*_Trap_Records.md`**(CH395F / GD5F2GQ5UE / SD2506);各模块测试指南见 `docs/测试说明/`;**存储维护/容量恢复**(全片擦除恢复 dhara 坏块记账)见 `docs/测试说明/存储维护与容量恢复.md` - **LVGL I1 绘制缓冲含 palette 头(2026-09-21 实证)**:索引色格式 draw buffer 布局 = `[palette 8B][pixel data stride×h]`(`lv_draw_buf_create` 加 8B、`lv_draw_buf_goto_xy`/`lv_refr get_max_row` 都按此跳/扣),但 `lv_display_set_buffers`/`lv_draw_buf_init` 的容量校验**只看 `stride×h`**——只给 3200B 会被静默接受:末 8 个像素字节写到缓冲外(曾打脏相邻 `s_frame_box[0]` 指针 → HardFault),且 flush 收到的首地址含 palette(整帧错位 8 字节)。`ui_port.c` 按 `stride×h + palette` 供给并在 flush 时 `+palette` 取像素;详见 `docs/UI设计/UI_设计架构.md` §1.4 第 8 条 - **MCU 端必须显式应用 dark mono 主题(2026-09-21 实证,远端"纯白屏"根因)**:`lv_display_create()` 会按 `lv_theme_mono_init(disp, false)`(白底黑字)初始化**单例**主题,且 `lv_display_set_theme()` 只把主题重应用到 `screens[0]`(底层)→ **活动屏幕仍是 LVGL 类默认浅灰 `F5F5F5`**;而 UI 内容恒用 `lv_color_white()` 绘制 → 白底白字不可见、远端整帧 99% 为亮(客户端显示纯白)。修法(`ui_port.c`):`lv_theme_mono_init(disp, true, ...)` + `lv_display_set_theme()` + 对活动屏幕再 `lv_theme_apply()`(实测 `F5F5F5` → `000000`);同时显式写 I1 调色板头 8B(`[0]=黑 [1]=白`,LVGL 不替调用方初始化);详见 `docs/UI设计/UI_设计架构.md` §1.4 第 9 条 diff --git a/docs/代码规范/pre-commit使用说明.md b/docs/代码规范/pre-commit使用说明.md index 9eb560be..f4c577d5 100644 --- a/docs/代码规范/pre-commit使用说明.md +++ b/docs/代码规范/pre-commit使用说明.md @@ -21,7 +21,9 @@ git commit ├─ doc-links 文档链接存在性(D-3/D-7,.md) ├─ task-table FreeRTOS 任务表一致性(D-2/D-7) ├─ mem-numbers 内存/Flash 数字一致性(D-7 体积档) - └─ cubemx-survival CubeMX 重生成存活审计(易失定制清单 + 基线漂移) + ├─ cubemx-survival CubeMX 重生成存活审计(易失定制清单 + 基线漂移) + ├─ watchdog-unit-test 看门狗判定状态机主机侧单测(L0.5) + └─ test-suite-switch 测试套件开关=产品构建(防测试固件静默提交) └─ commit-msg(.git/hooks/commit-msg) └─ gitlint 校验 commit message 格式 ``` @@ -40,6 +42,7 @@ git commit | scripts/gen_task_table.py | 随仓库提交 | FreeRTOS 任务表生成/校验(纯 Python,无依赖) | | scripts/check_mem_numbers.py | 随仓库提交 | 内存/Flash 数字一致性(纯 Python,无依赖) | | scripts/check_cubemx_survival.py | 随仓库提交 | CubeMX 重生成存活审计(纯 Python,无依赖) | +| scripts/check_test_suite.py | 随仓库提交 | 测试套件开关门禁(纯 Python,无依赖) | > **PATH 要求**:cppcheck 安装后需在系统 PATH 中(`C:\Program Files\Cppcheck`)。winget 自动写入 User PATH,但当前终端不会立即生效,需**重开终端**。 @@ -260,7 +263,37 @@ cppcheck 是开源 C/C++ 静态分析工具,**不运行代码**,通过语法 > 方法论(L1 写 `.ioc` > L2 搬进 `USER CODE` 区 > L3 审计兜底)详见 > [`../子系统设计/看门狗设计说明.md`](../子系统设计/看门狗设计说明.md) §10.2。 -### 2.13 gitlint — Commit Message 格式 +### 2.13 test-suite-switch — 测试套件开关=产品构建 + +用 `scripts/check_test_suite.py` 检查 `test/board/test_config.h` 的 `TEST_SUITE_*` 开关 +是否已全部注释(= 产品构建),防止**测试固件被静默提交**。 + +| 项 | 说明 | +|---|---| +| **触发条件** | 改动 `test/board/test_config.h`,且其中有 `TEST_SUITE_*` 处于**启用**状态 | +| **处理方式** | **阻止提交**(注释掉开关,或显式放行测试构建) | +| **典型报错** | `[FAIL] 默认要求产品构建:请注释掉 test/board/test_config.h 中的 TEST_SUITE_* 开关。` | +| **放行方式** | `python scripts/check_test_suite.py --allow-test-build` 或 `ALLOW_TEST_BUILD=1 git commit ...`(放行后仍打 `[WARN]` 横幅提醒) | + +**为什么需要这个钩子**(2026-10-01 实测): +`test_config.h` 的 `TEST_SUITE_CH395F/STORAGE/GD5F/RTC` 决定固件里编入哪个测试套件, +**全部注释 = 产品构建**——但此前 `grep TEST_SUITE scripts/*.py` **零命中**,没有任何门禁 +检查它。实测已提交的 HEAD 里 `TEST_SUITE_CH395F` 就是开启状态(`.map` 含 34 处 test 代码), +即仓库里躺着的是**测试固件**而非产品固件,纯靠开发者记得改回去。 + +**判定实现**:先剥掉块注释与行注释再匹配 `#define`,所以注释掉的开关 +(`//#define TEST_SUITE_GD5F`)不会误判;同文件派生的 `TEST_SUITE_SELECTED` / +`TEST_SUITE_NAME` 由**白名单**排除(它们是开关的推导结果,非用户可选项)。 + +```powershell +& '.venv\Scripts\python.exe' scripts/check_test_suite.py # 门禁判定 +& '.venv\Scripts\python.exe' scripts/check_test_suite.py --allow-test-build # 显式放行 +``` + +> 套件互斥(一次最多启用一个)由 `test_config.h:45` 的 `#error` 在**编译期**兜底; +> 本钩子在**提交期**再拦一道(`#error` 只在真正构建时暴露,测试固件仍可能被提交)。 + +### 2.14 gitlint — Commit Message 格式 | 项 | 说明 | |---|---| @@ -383,7 +416,7 @@ $env:PATH = [Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [Env | 文件 | 位置 | 说明 | |---|---|---| -| `.pre-commit-config.yaml` | 仓库根 | 钩子配置(13 个钩子 + 1 个预留) | +| `.pre-commit-config.yaml` | 仓库根 | 钩子配置(15 个钩子 + 1 个预留) | | `.gitlint` | 仓库根 | gitlint 规则(宽松,兼容中文) | | `.cubemx_survival_baseline.json` | 仓库根 | CubeMX 易失定制基线(哈希;`--write` 重建) | | `scripts/check_mojibake.py` | scripts/ | U+FFFD 检测脚本 | @@ -392,6 +425,7 @@ $env:PATH = [Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [Env | `scripts/gen_task_table.py` | scripts/ | FreeRTOS 任务表生成/校验(D-2/D-7) | | `scripts/check_mem_numbers.py` | scripts/ | 内存/Flash 数字一致性(D-7 体积档) | | `scripts/check_cubemx_survival.py` | scripts/ | CubeMX 重生成存活审计 | +| `scripts/check_test_suite.py` | scripts/ | 测试套件开关门禁(防测试固件静默提交) | | `docs/代码规范/嵌入式C语言代码规范_V1.0.md` | docs/代码规范/ | 代码规范原文(S1~S19 规则来源) | | `docs/代码规范/cppcheck使用说明.md` | docs/代码规范/ | cppcheck 钩子详解(规则/配置/抑制) | | `docs/文档规范.md` | docs/ | 文档体系规范(链接形态/命名/门禁要求) | diff --git a/docs/内存管理/RAM使用分析与优化方案.md b/docs/内存管理/RAM使用分析与优化方案.md index 6c5add21..5a2f3614 100644 --- a/docs/内存管理/RAM使用分析与优化方案.md +++ b/docs/内存管理/RAM使用分析与优化方案.md @@ -36,10 +36,10 @@ | 区域 | 用量 | 容量 | 占比 | |---|---|---|---| -| ER_IROM1 | 265,036 B (258.82 KB) | 1,048,576 B (1024 KB) | 25.3% | -| RW_IRAM1 | 87,080 B (85.04 KB) | 131,072 B (128 KB) | 66.4% | +| ER_IROM1 | 261,312 B (255.19 KB) | 1,048,576 B (1024 KB) | 24.9% | +| RW_IRAM1 | 84,864 B (82.88 KB) | 131,072 B (128 KB) | 64.7% | | RW_CCM | 49,184 B (48.03 KB) | 65,536 B (64 KB) | 75.0% | -| **Flash (ROM 合计)** | **265,792 B (259.6 KB)** | 1,048,576 B (1024 KB) | **25.3%** | +| **Flash (ROM 合计)** | **262,012 B (255.9 KB)** | 1,048,576 B (1024 KB) | **25.0%** | 各区的容量与常量来源: diff --git a/docs/待处理内容.md b/docs/待处理内容.md index 17a1a50e..3ad1688d 100644 --- a/docs/待处理内容.md +++ b/docs/待处理内容.md @@ -36,3 +36,24 @@ | N-03c | **CubeMX 重生成会覆盖 `USER CODE` 标记区之外的定制** | ⚠️ **2026-09-29 两次重生成实测确认**:首次覆盖 4 处、第二次覆盖 5 处(P0 `Src/iwdg.c` 自动启动、P1 `HardFault_Handler` 打回空死循环、P1 `FreeRTOSConfig.h` 两个加固宏被删、P2 `freertos.c` 中文注释)。✅ **已建立自动检出**:`scripts/check_cubemx_survival.py`(18 项清单 + 哈希基线,已挂 pre-commit)——第二次实测**准确检出全部 5 处**。⚠️ 关键发现:CubeMX **换了一代中断模板**,"生成区外的自造标记区"同样不被保留(详见设计文档 §10.1)。**今后每次重生成后跑一次该脚本即可** | 本次实施记录 | | N-03e | 判定状态机**缺主机侧纯逻辑回归**(改动后只能靠真机/模拟器才发现回归) | ✅ **已落地(2026-10-01)**:硬件访问收敛到薄平台层(`App/util/task_watchdog.h` 末段声明 3 个 `__weak` 钩子),新建 `test/host/watchdog/` 单元测试(39 用例,`#include` 生产源码 + 强符号覆盖钩子),挂 pre-commit `watchdog-unit-test` 钩子。MCU 端行为零变化(`@build` 0/0)。新增测试分层 **L0.5** | 见 [`测试说明/看门狗单元测试说明.md`](测试说明/看门狗单元测试说明.md) | | N-03d | 3 个次要 Fault(`MemManage`/`BusFault`/`UsageFault`)为空 `while(1)`,死机静默 | ✅ **已修(2026-09-29)**:升级为与 `HardFault` 同一套崩溃采集(手工 `__asm` Handler → per-fault C 入口 → `hard_fault_report(tag, frame)`,日志打 `[ERR] MEMFAULT/BUSFAULT/USAGEFAULT + 全量现场`);新增 `fault_handler_enable()` 使能 `SHCSR` 后三者(默认只开 HardFault,否则故障被升级、看不出根因),在 `app_main_init()` 第一行调用。**刻意不启用** `UNALIGN_TRP`/`DIV_0_TRP`(避免把普通 bug 升级成死机)。`@build` 0/0;审计 18 通过 / 1 警告 / 0 失败 | 见 [`子系统设计/看门狗设计说明.md`](子系统设计/看门狗设计说明.md) §10.2 | + +## 测试与验证基础设施 + +> **来源**:2026-10-01 测试架构评审(L0.5 主机侧单测落地后的复审)。 +> P0/P1 属闭环缺口(会漏检真实故障),P2 属工程效率(减重复、降误提交概率)。 +> 实施依据见 [`测试说明/测试开发标准流程.md`](测试说明/测试开发标准流程.md);消化即销项。 +> +> **已销项(2026-10-01,同日实施)**: +> - **T-1(P0)**:`scripts/check_test_suite.py` + pre-commit 钩子 `test-suite-switch` +> ——默认要求产品构建,跑套件回归用 `--allow-test-build` 显式放行。 +> **顺带修复**:`test/board/test_config.h` 长期开启的 `TEST_SUITE_CH395F` 已注释(现为产品构建)。 +> - **T-2(P1)**:`verify_log.py` / `run_boot_test.py` 新增 `--require-suite-summary` +> ——要求 `=== <名称>: N/M PASSED (failed=0) ===` 且 `N>0`。 +> - **T-3(P1)**:通用 harness `test/host/common/unit_test_harness.py`; +> 看门狗运行器 170 行 → 55 行声明式。 +> - **T-4(P2)**:`test/board/test_common.h` 集中断言内核,4 套件绑定自身统计变量。 +> - **T-5(P2)**:`test/run_all_suites.py` 多套件编排(`try/finally` 无条件还原开关)。 + +| # | 事项 | 优先级 | 问题与证据 | 处置方向 | 出处 | +|---|---|---|---|---|---| +| T-6 | **无 CI——L0/L0.5 已具备上 CI 条件** | P3 | L0(pre-commit 门禁,纯 Python + cppcheck)与 L0.5(MSVC 单测,纯标准库运行器)**均不依赖硬件**,可直接在 CI runner 跑;L1/L2 需真机与 ST-LINK,只能本地。当前全靠开发者本地 `pre-commit install`,新机器漏装即裸奔 | 待有 CI 平台时:挂 L0(`pre-commit run --all-files`)+ L0.5;L1/L2 保留本地。前置:CI runner 需装 cppcheck + VS BuildTools(或自建带工具链镜像) | 2026-10-01 评审;`测试说明/测试开发标准流程.md` §1 | diff --git a/docs/测试说明/测试开发标准流程.md b/docs/测试说明/测试开发标准流程.md index 0cec607e..a3116f72 100644 --- a/docs/测试说明/测试开发标准流程.md +++ b/docs/测试说明/测试开发标准流程.md @@ -91,6 +91,7 @@ flowchart TD ``` - **套件互斥**:一次构建最多启用一个 `TEST_SUITE_*`,否则 `#error`(编译期门禁);全部注释 = 正常产品构建。 +- **测试固件不得静默提交**:`TEST_SUITE_*` 开关受 pre-commit 钩子 `test-suite-switch` 门禁(默认要求全部注释)。跑完套件回归**务必改回产品构建**再提交;确需提交测试构建时用 `ALLOW_TEST_BUILD=1` 显式放行(会打 `[WARN]` 横幅)。见 §7.1。 - **main.c / freertos.c 不含任何测试逻辑** —— 测试代码只能出现在 `test/`。 | 套件 | 宏 | 文件 | 阶段组织 | 备注 | @@ -109,20 +110,41 @@ flowchart TD 断言宏(语义统一,统计变量按套件独立,防跨套件串数): -```c -typedef struct { uint16_t total, passed, failed; } xxx_test_stats_t; +**内核集中在 `test_common.h`**(2026-10-01 抽取,T-4)——4 套件的宏此前逐字重复,改一处要改四处: -#define XXX_TEST_CHECK(cond, fmt, ...) do { \ - g_xxx_stats.total++; \ - if (cond) { g_xxx_stats.passed++; DBG_INFO(" [PASS] " fmt, ##__VA_ARGS__); } \ - else { g_xxx_stats.failed++; DBG_ERROR(" [FAIL] " fmt, ##__VA_ARGS__); } \ +```c +/* test/board/test_common.h */ +typedef struct { uint16_t total, passed, failed; } test_stats_t; + +#define TEST_CHECK_IMPL(stats, cond, fmt, ...) do { \ + (stats).total++; \ + if (cond) { (stats).passed++; DBG_INFO("[PASS] " fmt, ##__VA_ARGS__); } \ + else { (stats).failed++; DBG_ERROR("[FAIL] " fmt, ##__VA_ARGS__); } \ } while (0) -#define XXX_TEST_REPORT(name) do { \ - DBG_INFO("=== %s: %d/%d PASSED (failed=%d) ===", name, ...); \ +#define TEST_REPORT_IMPL(stats, name) do { \ + DBG_INFO("=== %s: %u/%u PASSED (failed=%u) ===", name, ...); \ } while (0) ``` +各套件**用一行宏把它绑定到自己的统计变量**(保持 `g_xxx_stats` 独立): + +```c +/* 套件头文件内(如 gd5f_test.h) */ +#include "test_common.h" +typedef test_stats_t gd5f_test_stats_t; +extern gd5f_test_stats_t g_gd5f_test_stats; +#define GD5F_TEST_CHECK(cond, fmt, ...) \ + TEST_CHECK_IMPL(g_gd5f_test_stats, cond, fmt, ##__VA_ARGS__) +#define GD5F_TEST_REPORT(name) TEST_REPORT_IMPL(g_gd5f_test_stats, name) +``` + +> ⚠️ **`test_common.h` 刻意不 include `dbg_log.h`**:`dbg_log.h` 在未定义 `DBG_TAG` 时给出 +> 默认值 `""`,而本头文件被套件头在**很靠前**的位置包含——若在此处拉入,会把空 `DBG_TAG` +> 冻结,导致 `.c` 随后 `#define DBG_TAG "[TEST_TASK]"` 触发 ARMCC `#47-D: incompatible +> redefinition`(实测 8 条警告)。宏只在**使用点**展开(使用点在已按约定先定义 +> `DBG_TAG` 的 `.c` 内),故无需在此引入。 + 每 Phase 用标准注释头(与文档用例一一对应): ```c @@ -174,7 +196,6 @@ static void phaseN_run(void) { - 硬件层模式下挂起的 netTask 仍占 4KB 栈,为基础线程策略的接受成本(彻底方案可回收,暂无计划)。 ### 3.5 新测试文件 / 新套件 checklist - 1. `test/` 下新建 `xxx_test.h` / `xxx_test_task.c`(按 §3.2 模式); 2. `test/board/test_config.h`:补 `TEST_SUITE_XXX` 注释开关 + 互斥 `#error` 加项 + 分发分支(include + `TEST_SUITE_NAME`); 3. `test/board/test_config.c`:`start_test_task()` 加分发分支; @@ -183,6 +204,24 @@ static void phaseN_run(void) { 6. 立 `docs/测试说明/<模块>_Test_Guide.md`(§2 模板); 7. `@test` 以套件标题行为 `-Required` 标记跑通回归。 +### 3.6 多套件回归编排(`test/run_all_suites.py`,2026-10-01 加 T-5) + +套件互斥(编译期 `#error`)→ 跑 4 个套件的回归本来要手工循环 4 轮 +(改宏 → 构建 → 烧录 → 采集 → 判读)。本脚本编排成一条命令: + +```powershell +& '.venv\Scripts\python.exe' test/run_all_suites.py # 全部套件 +& '.venv\Scripts\python.exe' test/run_all_suites.py --suites STORAGE,GD5F +& '.venv\Scripts\python.exe' test/run_all_suites.py --dry-run # 只看计划 +``` + +**核心保障:`try/finally` 无条件还原 `test_config.h`**(含异常 / `KeyboardInterrupt` +路径)——跑完忘还原 = **测试固件进库**。这与提交期门禁 `test-suite-switch`(§7.1) +是**两条独立防线**:前者是流程自动化、后者是门禁兜底。 + +> ⚠️ **不挂 pre-commit**:每轮真机烧录 + 采集(8s 静默 + boot),4 套件约数分钟起, +> 且破坏性套件会改设备持久状态。仅回归窗口手工使用。 + --- ## 4. 主机侧测试工具 @@ -193,8 +232,9 @@ static void phaseN_run(void) { |---|---|---| | Python 串口助手(serial_tool) | `tools/serial_tool/serial_tool.py`(.venv + pyserial) | 常驻 daemon + 薄 CLI:列端口 / 开关 / 收发 / 全量日志 / wait-idle | | `test/host/run_boot_test.py` | `test/host/` | 一键闭环:采集 → 烧录 → 提取日志 → 校验 | -| `test/host/verify_log.py` | `test/host/` | 日志标记校验(Required / Forbidden → PASS / FAIL) | -| **`test/host/watchdog/run_unit_test.py`** | `test/host/watchdog/` | **L0.5 主机侧单测**:MSVC 离线编译真实 `task_watchdog.c` + 可观测桩,穷举判定状态机(零硬件、秒级)。**需本机装有 VS(`cl.exe`)**,见 §4.2 环境要求 | +| `test/host/verify_log.py` | `test/host/` | 日志标记校验(Required / Forbidden → PASS / FAIL)+ 可选套件汇总行校验(T-2) | +| **`test/host/common/unit_test_harness.py`** | `test/host/common/` | **L0.5 通用单测 harness**:工具链探测 / `INCLUDE`-`LIB` 拼接 / 编译运行 / 退出码契约。各模块运行器只做声明式配置(T-3) | +| **`test/host/watchdog/run_unit_test.py`** | `test/host/watchdog/` | **L0.5 主机侧单测**:声明 `TestBuild` 后调 harness,穷举看门狗判定状态机(零硬件、秒级)。**需本机装有 VS(`cl.exe`)**,见 §4.2 环境要求 | | `.opencode/serial_config.json` | 仓库 | **串口端口单一配置源**(当前 COM6 / 115200);换端口只改这个文件 | | `test/host/ftp_*.py`(4 个)/ `ch395f_socket_test.py` / `time_sync_server.py` | `test/host/` | PC 端交互测试工具(FTP 客户端 / socket 回显 / 对时推送) | | `simulator/tests/*.py`(4 个) | `simulator/tests/` | LVGL 模拟器回归脚本(见 §5) | @@ -209,7 +249,9 @@ static void phaseN_run(void) { 1. 模块的**硬件访问**收敛到薄平台层(`__weak` 钩子或后端函数)——参照 `App/cfg/cfg_file_store.h` 的"共享核心 + 平台后端"; 2. 测试单元 `#include` **生产源文件**(保证测的是真代码,非副本),用桩替换平台层; -3. 用 MSVC 离线编译运行(`run_unit_test.py` 自动探测工具链); +3. 写模块侧运行器:**只声明** `TestBuild`(源码 / 桩目录 / 汇总行标记)+ 调 + `test/host/common/unit_test_harness.py`(工具链探测与编译运行细节在 harness 内, + 见 [`../../test/host/common/README.md`](../../test/host/common/README.md)); 4. 挂 pre-commit 钩子,被改动时自动跑(**只 commit 不烧录**,秒级)。 **环境要求**(详见 [`../环境与部署/项目部署环境要求.md`](../环境与部署/项目部署环境要求.md) §2.3 / §2.7): @@ -302,6 +344,31 @@ flowchart TD | 开机全量(存储 / RTC / RS485 / ADC) | 默认 + `RS485 init: OK;NAND init:;ADC init:;SD2506 ID:` | | 测试套件构建 | 套件结尾汇总行(`=== Storage Tests:` / `=== GD5F2GQ5UE Tests:` 等,确切字符串见各模块 Test_Guide) | +### 6.2.1 套件汇总行校验(2026-10-01 加,T-2) + +> ⚠️ **纯 Required 子串匹配有盲区**:套件启动横幅命中即判 PASS——**套件是否跑到汇总行、 +> 断言数是否为 0 完全不可见**。套件中途挂死/复位、或 Phase 宏全关(没编入任何断言)时, +> 会被**误判为通过**。测试套件构建的回归**必须**加 `--require-suite-summary`。 + +```powershell +# 测试套件构建回归(要求套件跑到汇总行,且断言数 > 0) +& '.venv\Scripts\python.exe' test/host/run_boot_test.py ` + --required "=== Storage Tests:" --forbidden "ERROR;HardFault;[ERR]" ` + --require-suite-summary "Storage Tests" +``` + +校验语义(`verify_log.py`): + +| 情形 | 判定 | +|---|---| +| 汇总行存在、`N>0`、`failed=0`、`N==M` | PASS(打印 `[OK] suite summary ok`) | +| 汇总行**缺失**(套件没跑到/挂死) | FAIL `[MISS]` | +| `total == 0`(没编入任何断言) | FAIL `[FAIL] total is 0` | +| `failed != 0` | FAIL `[FAIL] reports failures` | +| `passed != total` | FAIL `[FAIL] inconsistent` | + +> 未传 `--require-suite-summary` 时行为与旧版**逐字一致**(向后兼容,产品构建回归不受影响)。 + ### 6.3 三条铁律 1. **采集必须先于烧录武装** —— 开串口 + clear 在烧录前完成(daemon 常驻全量日志兜底,不会漏字节;`run_boot_test.py` 已保证顺序); @@ -323,6 +390,7 @@ flowchart TD | cppcheck | 静态分析(warning / performance / portability / style,含 suppress 清单) | | no-mojibake | 禁止 U+FFFD 乱码字节(保护中文注释) | | coding-standard | 代码规范 V1.0 机械检查(S1~S19) | +| **test-suite-switch** | **测试套件开关=产品构建**(`test/board/test_config.h` 的 `TEST_SUITE_*` 须全注释;跑套件回归确需提交测试构建时用 `--allow-test-build` 显式放行) | 范围:仅项目自有代码受检;第三方与生成物(`Lib/`、`Middlewares/`、CMSIS/HAL、`lftpd`、`dhara`、`MDK-ARM/`、`test/`、CubeMX 生成的 `Src/` `Inc/` 等)被排除(详见 `pre-commit使用说明.md`)。 @@ -369,6 +437,7 @@ flowchart LR | 文件 | 作用 | |---|---| | `test_config.h` / `test_config.c` | 套件选择(互斥 `#error`)+ 统一调度入口 `start_test_task()` | +| `test_common.h` | **板端测试公共断言内核**(`test_stats_t` + `TEST_CHECK_IMPL`/`TEST_REPORT_IMPL`) | | `ch395f_test.h` / `ch395f_test_task.c` | CH395F 套件:阶段开关、断言宏、Phase 1~5 实现 | | `net_test_task.h` / `net_test_task.c` | CH395F 套件 NET 层(6~11):TCP 监听循环 / Client 主动连接测试 | | `storage_test.h` / `storage_test_task.c` | STORAGE 套件:FTL + FatFS 集成(TC-STO-01~04)+ 恢复测试 | @@ -395,6 +464,10 @@ flowchart LR | 日期 | 变更 | |---|---| +| 2026-10-01 | §3.6 新增 **`test/run_all_suites.py`** 多套件回归编排(`try/finally` 无条件还原套件开关;T-5);`test/README.md` 同步 | +| 2026-10-01 | §3.2 断言宏改为**集中内核**:新增 `test/board/test_common.h`(`test_stats_t` + `TEST_CHECK_IMPL`/`TEST_REPORT_IMPL`),4 套件各自用一行宏绑定自身统计变量(T-4;零行为变化、零 RAM 增量、`@build` 0/0,四套件均 0/0 验证)。附 `test_common.h` 不 include `dbg_log.h` 的原因(`DBG_TAG` 冻结 → `#47-D`) | +| 2026-10-01 | 新增 §7.1 钩子 **`test-suite-switch`**(测试套件开关=产品构建,防测试固件静默提交)+ §3.1 铁律(跑完套件回归务必改回产品构建);依据 `docs/待处理内容.md` T-1(测试架构评审 P0)。同步更新 `../代码规范/pre-commit使用说明.md` §2.13 | +| 2026-10-01 | §4.1/§4.2 更新:主机侧单测运行器统一走通用 harness `test/host/common/unit_test_harness.py`(T-3),模块运行器只做声明式配置;§6.2.1 新增套件汇总行校验(T-2) | | 2026-10-01 | §4.2 补 **L0.5 环境要求表**(MSVC `cl.exe` = 装了 VS 即有、与模拟器共用;Windows SDK;纯标准库 Python;缺失时退出码 2 且 pre-commit 钩子拦截)+ "不调 `vcvars64.bat`(`reg.exe` 受限)"原因说明;§4.1 工具清单标注该运行器需 VS。同步更新 `../环境与部署/项目部署环境要求.md`(§2.3 / §2.7 / 第 3、10 步 / 清单 / 下载表 / 速查表) | | 2026-09-15 | §3.4 更新:P1-6 修复落地(基础线程策略)——USER CODE 区冗余 netTask 创建删除 + `net_task_func` 入口 `ENABLE_HW_LAYER_TESTS` 自挂起守卫;"代码事实/设计约束"按修复后现状重写(硬件层模式中断断言恢复安全、NET 层/产品仍按"netTask 恒在轮询"设计);彻底方案(`.ioc` 移除)经评估未采用。同日构建验证发现两处**存量**测试固件缺陷(与 P1-6 无关,A/B 对照确证):① `net_test_task.c` `s_rx_buf` 使用先于声明 → NET 层配置自 2026-08(88160a5)起不可编译;② 128KB 静态 RAM 预算溢出(产品镜像 123.6KB,HW 全阶段固件超 8.7KB、NET 固件超 ~28KB)——已立 P1-9/P1-10 跟踪 | | 2026-09-13 | 重写:对齐现测试架构——统一 `test_config.h` 套件(旧版仅描述 CH395F 单套件与 `ch395fTestTask` 旧入口);新增 @test 自动化闭环、pre-commit 门禁、UI 模拟器五步门禁、主机侧工具约定;新增"测试构建下 netTask"已知问题(旧版"硬件层模式 netTask 不创建"表述与代码不符,改为设计约束并给出重构路径);修正附录路径(旧版 `docs/测试说明/CH395F_Test_Guide.md` 等已移入 `docs/测试说明/`);新增双端注册、内存预算、陷阱沉淀要求 | diff --git a/docs/测试说明/看门狗单元测试说明.md b/docs/测试说明/看门狗单元测试说明.md index 20a89c69..e71ab2b9 100644 --- a/docs/测试说明/看门狗单元测试说明.md +++ b/docs/测试说明/看门狗单元测试说明.md @@ -68,8 +68,13 @@ test/host/watchdog/watchdog_test.c | 硬件 | 无(不需要开发板 / 串口 / ST-LINK) | > **为何不调 `vcvars64.bat`**:它内部调用 `reg.exe` 探测环境,本机安全策略禁止 -> `reg.exe`。`run_unit_test.py` 因此**直接拼 `INCLUDE`/`LIB` 环境变量**并用绝对 +> `reg.exe`。运行器因此**直接拼 `INCLUDE`/`LIB` 环境变量**并用绝对 > 路径调 `cl.exe`(只需 MSVC 版本目录 + Windows SDK 的 include/lib),无副作用、可重复。 +> +> 该工具链逻辑**不在本模块目录内**(2026-10-01 T-3 抽取):已沉淀为通用 harness +> [`../../test/host/common/unit_test_harness.py`](../../test/host/common/unit_test_harness.py), +> 本模块的 `run_unit_test.py` 只**声明** `TestBuild`(源文件 / 桩目录 / 汇总行标记)。 +> 新增 L0.5 单测时直接复用 harness,见 [`../../test/host/common/README.md`](../../test/host/common/README.md)。 ### 1.4 运行方式 @@ -78,13 +83,13 @@ test/host/watchdog/watchdog_test.c & '.venv\Scripts\python.exe' test/host/watchdog/run_unit_test.py ``` -退出码:`0`=PASS / `1`=测试失败 / `2`=环境错误(找不到编译器 / 编译失败)。 +退出码:`0`=PASS / `1`=测试失败 / `2`=环境错误(找不到编译器 / 编译失败 / 无汇总行)。 ### 1.5 回归接入 | 项 | 说明 | |---|---| -| **触发范围** | `App/util/task_watchdog.c` / `task_watchdog.h`,或 `test/host/watchdog/**` 变动 | +| **触发范围** | `App/util/task_watchdog.c` / `task_watchdog.h`,或 `test/host/watchdog/**`、`test/host/common/unit_test_harness.py` 变动 | | **门禁层级** | **L0.5 层**(介于 L0 静态门禁与 L1 板端套件之间):主机侧、秒级、纯逻辑,比板端套件便宜、比静态检查更强 | | **pre-commit 钩子** | `watchdog-unit-test`(本地钩子,`.pre-commit-config.yaml` §7) | | **汇总行** | `=== Task Watchdog Tests: N/N PASSED (failed=0) ===` | @@ -175,4 +180,5 @@ if (s_miss_streak >= WD_MISS_TOLERANCE) { DBG_ERROR("[WD] task stall: ..."); } | 日期 | 变更 | |---|---| +| 2026-10-01 | §1.3/§1.4/§1.5 更新:工具链逻辑抽取到通用 harness `test/host/common/unit_test_harness.py`(T-3),本模块 `run_unit_test.py` 改为**声明式** `TestBuild`(原 170 行 → 55 行);hooked 触发范围加 `test/host/common/unit_test_harness.py`。行为与退出码契约不变(39/39 PASSED) | | 2026-10-01 | 创建:看门狗判定状态机主机侧单元测试(39 用例)+ 平台层抽取(`task_watchdog.h` 末段三个 `__weak` 钩子)+ pre-commit 钩子 `watchdog-unit-test`;记录两处已知行为(stall 复报、测试隔离) | diff --git a/scripts/README.md b/scripts/README.md index f875ead3..af9f16fb 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -14,6 +14,7 @@ | `check_doc_links.py` | `doc-links` | **文档链接存在性** + 命名规范(D-3/D-7) | | `gen_task_table.py` | `task-table` | FreeRTOS 任务表以 `Src/freertos.c` 为**唯一事实源**生成/校验(D-2/D-7) | | `check_mem_numbers.py` | `mem-numbers` | 内存/Flash 数字以 `.map` 为唯一事实源校验(D-7 体积档) | +| `check_test_suite.py` | `test-suite-switch` | 测试套件开关=产品构建(防测试固件静默提交) | ### 报告生成(非门禁) @@ -32,6 +33,8 @@ & '.venv\Scripts\python.exe' scripts/check_doc_links.py --all & '.venv\Scripts\python.exe' scripts/gen_task_table.py --check # 或 --write / --diff & '.venv\Scripts\python.exe' scripts/check_mem_numbers.py --check +& '.venv\Scripts\python.exe' scripts/check_test_suite.py # 门禁判定 +& '.venv\Scripts\python.exe' scripts/check_test_suite.py --allow-test-build # 放行测试构建 & '.venv\Scripts\python.exe' scripts/ram_report.py & '.venv\Scripts\python.exe' scripts/stack_report.py -s tools\serial_tool\state\serial.log ``` diff --git a/scripts/check_test_suite.py b/scripts/check_test_suite.py new file mode 100644 index 00000000..179aa9ec --- /dev/null +++ b/scripts/check_test_suite.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""check_test_suite.py - 测试套件开关门禁:防止测试固件被静默提交(纯标准库)。 + +背景(2026-10-01 测试架构评审 T-1,P0): + `test/board/test_config.h` 的 `TEST_SUITE_*` 宏决定固件里编入哪个测试套件; + **全部注释 = 产品构建**。但该开关此前**没有任何门禁检查**——实测已提交的 + HEAD 里 `TEST_SUITE_CH395F` 就是开启状态,`.map` 含 34 处 test 代码, + 即仓库里躺在分支上的是**测试固件**而非产品固件。纯靠人记得改回去。 + +本脚本做什么: + 扫描 `test/board/test_config.h`,判定 `TEST_SUITE_*` 是否处于启用状态: + + - 全部注释(产品构建) -> PASS + - 启用了一个或多个(测试构建) -> FAIL(列出套件名 + 修复方法) + - 启用了多个(违反互斥) -> FAIL(编译期另有 `#error` 兜底) + + 测试构建本身是**合法**的(跑套件回归时就是要开),所以放行通道是 + **显式声明**而非偷偷绕过: + + python scripts/check_test_suite.py --allow-test-build + 或 设置环境变量 ALLOW_TEST_BUILD=1 + + 显式放行后仍然会打印醒目的 `[WARN]` 横幅,提醒"此提交是测试固件"。 + +判定实现: + 只看**有效代码行**——先剥掉块注释与行注释,再匹配 `#define TEST_SUITE_`。 + 这样注释掉的开关(`//#define TEST_SUITE_GD5F`)不会误判为启用。 + +退出码: + 0 = PASS(产品构建,或已显式放行测试构建) + 1 = FAIL(检测到未放行的测试套件开关) + 2 = 环境错误(文件不存在 / 无法读取) + +用法(仓库根目录): + & '.venv\\Scripts\\python.exe' scripts/check_test_suite.py + & '.venv\\Scripts\\python.exe' scripts/check_test_suite.py --allow-test-build + & '.venv\\Scripts\\python.exe' scripts/check_test_suite.py --file <路径> + +设计依据:docs/待处理内容.md T-1;docs/测试说明/测试开发标准流程.md §3.1 +创建日期:2026-10-01 +""" + +import argparse +import os +import re +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +DEFAULT_TARGET = REPO_ROOT / "test" / "board" / "test_config.h" + +# 已知套件宏(顺序 = 报告顺序)。不写死也可用通用正则,但写死可给出 +# "哪个套件被启用"的可读名称,并在新增套件时强制有人来更新本表(有意为之)。 +SUITE_MACROS = [ + ("TEST_SUITE_CH395F", "CH395F 网络套件(硬件层 1~5 / NET 层 6~11)"), + ("TEST_SUITE_STORAGE", "STORAGE 存储套件(FTL + FatFS)"), + ("TEST_SUITE_GD5F", "GD5F NAND 驱动套件(10 阶段)"), + ("TEST_SUITE_RTC", "RTC 套件(SD2506)"), +] + +# 只关注"套件选择开关"本身。同文件里还有派生的 TEST_SUITE_SELECTED / +# TEST_SUITE_NAME(由开关推导,非用户可选项),必须排除——否则会把派生宏 +# 当成"启用的套件"。故用**白名单精确匹配**,不做 Test 前缀泛匹配。 +_SUITE_NAMES = {name for name, _ in SUITE_MACROS} +_DEFINE_RE = re.compile(r"^\s*#\s*define\s+(\w+)\b") + + +def _strip_comments(text): + """剥掉 C 注释,返回等行数的"代码行"列表(保持行号以便定位)。 + + 逐字符扫描而非正则——注释标记出现在字符串字面量里的情况在本文件不存在, + 但逐字符实现能顺带处理 `/* ... */` 跨行,避免"整段注释被当代码"。 + """ + out = [] + in_block = False + for line in text.splitlines(): + buf = [] + i = 0 + while i < len(line): + two = line[i:i + 2] + if in_block: + if two == "*/": + in_block = False + i += 2 + continue + i += 1 + continue + if two == "/*": + in_block = True + i += 2 + continue + if two == "//": + break + buf.append(line[i]) + i += 1 + out.append("".join(buf)) + return out + + +def scan(file_path): + """返回 [(宏名, 行号)]——处于启用状态的测试套件宏,按出现顺序。""" + p = Path(file_path) + raw = p.read_bytes() + # 本仓库 C 源为 UTF-8(多数无 BOM);容错回退,避免个别文件编码异常直接崩 + for enc in ("utf-8-sig", "utf-8", "gbk"): + try: + text = raw.decode(enc) + break + except UnicodeDecodeError: + continue + else: + text = raw.decode("utf-8", errors="replace") + + active = [] + for lineno, code in enumerate(_strip_comments(text), start=1): + m = _DEFINE_RE.match(code) + if not m: + continue + name = m.group(1) + if name not in _SUITE_NAMES: + continue + active.append((name, lineno)) + return active + + +def main(argv=None): + for stream in (sys.stdout, sys.stderr): + try: + stream.reconfigure(encoding="utf-8", errors="replace") + except Exception: + pass + + ap = argparse.ArgumentParser( + description="测试套件开关门禁:确保默认提交的是产品构建(纯标准库)") + ap.add_argument("--file", default=str(DEFAULT_TARGET), + help="测试套件配置文件(默认 test/board/test_config.h)") + ap.add_argument("--allow-test-build", action="store_true", + help="显式放行测试构建(跑套件回归时用;等价于 ALLOW_TEST_BUILD=1)") + ap.add_argument("--quiet", action="store_true", help="只在 FAIL 时输出") + args = ap.parse_args(argv) + + allow = args.allow_test_build or os.environ.get("ALLOW_TEST_BUILD", "") not in ("", "0", "false", "False") + + p = Path(args.file) + if not p.is_file(): + print("[FAIL] 找不到测试套件配置文件:{}".format(p)) + return 2 + + active = scan(p) + known = dict(SUITE_MACROS) + rel = p if not p.is_absolute() else p + try: + rel = p.resolve().relative_to(REPO_ROOT) + except ValueError: + pass + + lines = [] + lines.append("=== 测试套件开关检查: {} ===".format(rel)) + + if not active: + lines.append("[OK] 全部 TEST_SUITE_* 已注释 -> 产品构建 (product build)") + else: + lines.append("[WARN] 检测到 {} 个启用的测试套件开关:".format(len(active))) + for name, lineno in active: + desc = known.get(name, "") + lines.append(" L{:<4} {} {}".format(lineno, name, desc)) + if len(active) > 1: + lines.append("[FAIL] 套件互斥被违反(一次构建最多启用一个;编译期另有 #error 兜底)") + + if not args.quiet or active: + print("\n".join(lines)) + + if not active: + return 0 + + if allow: + print("") + print("[WARN] ============================================") + print("[WARN] 已显式放行测试构建(ALLOW_TEST_BUILD)") + print("[WARN] 本次提交的固件含测试代码,不是产品固件!") + print("[WARN] 提交后请尽快在 test/board/test_config.h 注释掉开关。") + print("[WARN] ============================================") + return 0 + + print("") + print("[FAIL] 默认要求产品构建:请注释掉 test/board/test_config.h 中的 TEST_SUITE_* 开关。") + print(" 正在跑套件回归、确需提交测试构建时,显式放行:") + print(" python scripts/check_test_suite.py --allow-test-build") + print(" 或: ALLOW_TEST_BUILD=1 git commit ...") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test/README.md b/test/README.md index a1fc9e42..17abc755 100644 --- a/test/README.md +++ b/test/README.md @@ -10,6 +10,7 @@ |---|---|---| | `board/` | 板端测试套件(编入 Keil 工程,占 RAM) | [`board/README.md`](board/README.md) | | `host/` | PC 端测试/判读脚本(Python) | [`host/README.md`](host/README.md) | +| `run_all_suites.py` | **多套件回归编排**(改宏 → 构建 → 烧录 → 采集,`try/finally` 保证还原开关) | 本文 §多套件回归 | ## 运行方式 @@ -19,10 +20,24 @@ # 单独校验日志 & '.venv\Scripts\python.exe' test/host/verify_log.py <日志文件> + +# 多套件回归编排(需真机;自动改开关 + 构建 + 烧录 + 采集,结束后还原) +& '.venv\Scripts\python.exe' test/run_all_suites.py +& '.venv\Scripts\python.exe' test/run_all_suites.py --suites STORAGE,GD5F +& '.venv\Scripts\python.exe' test/run_all_suites.py --dry-run # 只看计划 ``` 退出码约定:`0`=PASS / `1`=FAIL / `2`=采集或烧录错误 / `3`=用法或预检错误。 +## 多套件回归(`run_all_suites.py`) + +套件互斥(编译期 `#error`)意味着跑 4 个套件要手工循环 4 轮。本脚本把它编排成一条 +命令,并且**无条件还原** `test_config.h`——**跑完忘还原 = 测试固件进库**,这是它存在的 +核心理由(与提交期门禁 `test-suite-switch` 互为独立防线)。 + +⚠️ 每轮要真机烧录 + 采集,4 套件约数分钟起;破坏性套件还会改设备持久状态。 +**仅回归窗口用,绝不挂 pre-commit**。 + ## ⚠️ 注意 - 本目录**被 pre-commit 门禁排除**(测试代码不受 C 规范约束) diff --git a/test/board/README.md b/test/board/README.md index afe45e3d..15835dec 100644 --- a/test/board/README.md +++ b/test/board/README.md @@ -8,6 +8,7 @@ | 文件 | 测试对象 / 作用 | |---|---| | `test_config.c/h` | **测试任务配置**:`TEST_SUITE_*` 套件选择开关(互斥)+ `ENABLE_DESTRUCTIVE_TESTS` | +| `test_common.h` | **公共断言内核**:`test_stats_t` + `TEST_CHECK_IMPL`/`TEST_REPORT_IMPL`;各套件用一行宏绑定自身统计变量(T-4) | | `ch395f_test_task.c/h` + `ch395f_test.h` | CH395F 以太网(硬件层 Phase 1~5 / NET 层 Phase 6~11) | | `gd5f_test_task.c/h` + `gd5f_test.h` | GD5F2GQ5UE SPI NAND 存储 | | `sd2506_test_task.c/h` + `sd2506_test.h` | SD2506 RTC | @@ -19,6 +20,10 @@ - **套件互斥**:`test_config.h` 的 `TEST_SUITE_CH395F/STORAGE/GD5F/RTC` 一次只能启用一个 (违反则 `#error`);**全部注释 = 产品构建** +- ⚠️ **测试固件不得静默提交**:`TEST_SUITE_*` 开关受 pre-commit 钩子 `test-suite-switch` + 门禁——默认要求全部注释。跑完套件回归**务必改回产品构建**再提交(2026-10-01 实测已提交 + HEAD 里 `TEST_SUITE_CH395F` 就是开启状态,纯靠人记得改;故补此门禁)。确需提交测试构建时用 + `ALLOW_TEST_BUILD=1 git commit ...` 或 `python scripts/check_test_suite.py --allow-test-build` 显式放行 - **破坏性用例默认关闭**:`ENABLE_DESTRUCTIVE_TESTS`(默认注释)控制写/擦 NAND、格式化、 坏块注入。关闭时为**只读模式**(横幅 `mode: READ-ONLY`),可随时在在用设备上跑 - ⚠️ 实测一轮破坏性 GD5F 运行即让 dhara **多记一个坏块**(容量下降且需全片擦除恢复)—— diff --git a/test/board/ch395f_test.h b/test/board/ch395f_test.h index 9a23821c..a59e3aa2 100644 --- a/test/board/ch395f_test.h +++ b/test/board/ch395f_test.h @@ -37,6 +37,7 @@ extern "C" { #endif #include +#include "test_common.h" /* * 阶段使能开关(取消注释即启用对应阶段) @@ -85,30 +86,15 @@ extern "C" { #error "硬件层(1~5)与NET层(6~11)测试互斥:请分两次构建,见 CH395F_Test_Guide 构建模式与执行顺序" #endif -/* 测试统计 */ -typedef struct { - uint16_t total; - uint16_t passed; - uint16_t failed; -} test_stats_t; +/* 测试统计(本套件独立,防跨套件串数;内核见 test_common.h) */ +typedef test_stats_t ch395f_stats_t; -extern test_stats_t g_test_stats; +extern ch395f_stats_t g_test_stats; -#define TEST_CHECK(cond, fmt, ...) do { \ - g_test_stats.total++; \ - if (cond) { \ - g_test_stats.passed++; \ - DBG_INFO("[PASS] " fmt, ##__VA_ARGS__); \ - } else { \ - g_test_stats.failed++; \ - DBG_ERROR("[FAIL] " fmt, ##__VA_ARGS__); \ - } \ -} while (0) +#define TEST_CHECK(cond, fmt, ...) \ + TEST_CHECK_IMPL(g_test_stats, cond, fmt, ##__VA_ARGS__) -#define TEST_REPORT(name) do { \ - DBG_INFO("=== %s: %d/%d PASSED (failed=%d) ===", \ - name, g_test_stats.passed, g_test_stats.total, g_test_stats.failed); \ -} while (0) +#define TEST_REPORT(name) TEST_REPORT_IMPL(g_test_stats, name) #ifdef __cplusplus } diff --git a/test/board/gd5f_test.h b/test/board/gd5f_test.h index 7b349c8e..3aa717a6 100644 --- a/test/board/gd5f_test.h +++ b/test/board/gd5f_test.h @@ -31,6 +31,7 @@ extern "C" { #endif #include +#include "test_common.h" #include "gd5f2gq5ue.h" /* ======================== 阶段使能开关 ======================== */ @@ -53,30 +54,14 @@ extern "C" { /* ======================== 测试统计与断言 ======================== */ -typedef struct { - uint16_t total; - uint16_t passed; - uint16_t failed; -} gd5f_test_stats_t; +typedef test_stats_t gd5f_test_stats_t; extern gd5f_test_stats_t g_gd5f_test_stats; -#define GD5F_TEST_CHECK(cond, fmt, ...) do { \ - g_gd5f_test_stats.total++; \ - if (cond) { \ - g_gd5f_test_stats.passed++; \ - DBG_INFO("[PASS] " fmt, ##__VA_ARGS__); \ - } else { \ - g_gd5f_test_stats.failed++; \ - DBG_ERROR("[FAIL] " fmt, ##__VA_ARGS__); \ - } \ -} while (0) +#define GD5F_TEST_CHECK(cond, fmt, ...) \ + TEST_CHECK_IMPL(g_gd5f_test_stats, cond, fmt, ##__VA_ARGS__) -#define GD5F_TEST_REPORT(name) do { \ - DBG_INFO("=== %s: %d/%d PASSED (failed=%d) ===", \ - name, g_gd5f_test_stats.passed, g_gd5f_test_stats.total, \ - g_gd5f_test_stats.failed); \ -} while (0) +#define GD5F_TEST_REPORT(name) TEST_REPORT_IMPL(g_gd5f_test_stats, name) #if defined(ENABLE_GD5F_TESTS) void gd5f_test_run(void); diff --git a/test/board/sd2506_test.h b/test/board/sd2506_test.h index 9c494d0d..a24b9212 100644 --- a/test/board/sd2506_test.h +++ b/test/board/sd2506_test.h @@ -2,6 +2,7 @@ #define SD2506_TEST_H #include +#include "test_common.h" #ifdef __cplusplus extern "C" { @@ -11,31 +12,15 @@ extern "C" { #define ENABLE_RTC_BASIC_TESTS /* Phase 1: 基本读写 / 走时 */ #define ENABLE_RTC_API_TESTS /* Phase 2: 全功能 API 覆盖(BCD互转/温度/电压/ID/SRAM/报警) */ -/* ============ 测试统计(与 STORAGE/GD5F 套件一致的独立统计) ============ */ -typedef struct { - uint16_t total; - uint16_t passed; - uint16_t failed; -} sd2506_test_stats_t; +/* ============ 测试统计(本套件独立,防跨套件串数;内核见 test_common.h) ============ */ +typedef test_stats_t sd2506_test_stats_t; extern sd2506_test_stats_t g_sd2506_test_stats; -#define SD2506_TEST_CHECK(cond, fmt, ...) do { \ - g_sd2506_test_stats.total++; \ - if (cond) { \ - g_sd2506_test_stats.passed++; \ - DBG_INFO("[PASS] " fmt, ##__VA_ARGS__); \ - } else { \ - g_sd2506_test_stats.failed++; \ - DBG_ERROR("[FAIL] " fmt, ##__VA_ARGS__); \ - } \ -} while (0) +#define SD2506_TEST_CHECK(cond, fmt, ...) \ + TEST_CHECK_IMPL(g_sd2506_test_stats, cond, fmt, ##__VA_ARGS__) -#define SD2506_TEST_REPORT(name) do { \ - DBG_INFO("=== %s: %u/%u PASSED (failed=%u) ===", \ - name, g_sd2506_test_stats.passed, g_sd2506_test_stats.total, \ - g_sd2506_test_stats.failed); \ -} while (0) +#define SD2506_TEST_REPORT(name) TEST_REPORT_IMPL(g_sd2506_test_stats, name) /* 测试入口:由 start_test_task 经 TEST_SUITE_RTC 调度,顺序执行 Phase 1~3 */ void sd2506_test_run(void); diff --git a/test/board/storage_test.h b/test/board/storage_test.h index 64985994..fdf18fc2 100644 --- a/test/board/storage_test.h +++ b/test/board/storage_test.h @@ -16,31 +16,16 @@ extern "C" { #endif #include +#include "test_common.h" -typedef struct { - uint16_t total; - uint16_t passed; - uint16_t failed; -} storage_test_stats_t; +typedef test_stats_t storage_test_stats_t; extern storage_test_stats_t g_storage_stats; -#define STORAGE_TEST_CHECK(cond, fmt, ...) do { \ - g_storage_stats.total++; \ - if (cond) { \ - g_storage_stats.passed++; \ - DBG_INFO(" [PASS] " fmt, ##__VA_ARGS__); \ - } else { \ - g_storage_stats.failed++; \ - DBG_ERROR(" [FAIL] " fmt, ##__VA_ARGS__); \ - } \ -} while (0) +#define STORAGE_TEST_CHECK(cond, fmt, ...) \ + TEST_CHECK_IMPL(g_storage_stats, cond, fmt, ##__VA_ARGS__) -#define STORAGE_TEST_REPORT(name) do { \ - DBG_INFO("=== %s: %d/%d PASSED (failed=%d) ===", \ - name, g_storage_stats.passed, g_storage_stats.total, \ - g_storage_stats.failed); \ -} while (0) +#define STORAGE_TEST_REPORT(name) TEST_REPORT_IMPL(g_storage_stats, name) /* 存储测试入口:FTL + FatFS 文件系统与性能测试 */ void storage_test_run(void); diff --git a/test/board/test_common.h b/test/board/test_common.h new file mode 100644 index 00000000..f4e22360 --- /dev/null +++ b/test/board/test_common.h @@ -0,0 +1,82 @@ +/* + * 模块名称:板端测试套件公共断言宏 + * 模块功能:为 test/board/ 下的各测试套件提供**语义统一**的统计结构体与断言/ + * 报告宏内核,消除 4 套件(CH395F / STORAGE / GD5F / RTC)之间逐字重复的 + * 宏定义(2026-10-01 测试架构评审 T-4)。 + * + * 设计取舍:**统计变量仍按套件独立**(各套件持有自己的 `g_xxx_stats`), + * 公共宏只提供"内核",由各套件用一行宏把它绑定到自己的统计变量。 + * 理由见 docs/测试说明/测试开发标准流程.md §3.2: + * - 统计变量独立 = 防跨套件串数(历史约定,不能破); + * - 内核共用 = 报告行格式/字段一处维护,改一次即全生效。 + * + * 适用平台:STM32F4 系列 + * 作者:王建锋 + * 创建日期:2026-10-01 + * + * ⚠️ 本头文件**刻意不 include `dbg_log.h`**: + * `dbg_log.h` 在未定义 `DBG_TAG` 时会给出默认值 `""`(`#ifndef DBG_TAG`), + * 而本文件被各套件头在**很靠前**的位置包含——若在此处拉入 `dbg_log.h`, + * 会把空 `DBG_TAG` 冻结,导致各 `.c` 文件随后 `#define DBG_TAG "[TEST_TASK]"` + * 触发 ARMCC `#47-D: incompatible redefinition of macro "DBG_TAG"`(实测 8 条警告)。 + * 这里的宏只在**使用点**展开(使用点在 `.c` 中,且那些 `.c` 已按约定先 + * `#define DBG_TAG` 再 include `dbg_log.h`),故无需在此引入。 + * + * 用法(套件头文件内): + * #include "test_common.h" + * typedef test_stats_t gd5f_stats_t; // 或直接用 test_stats_t + * extern gd5f_stats_t g_gd5f_test_stats; + * #define GD5F_TEST_CHECK(cond, fmt, ...) \ + * TEST_CHECK_IMPL(g_gd5f_test_stats, cond, fmt, ##__VA_ARGS__) + * #define GD5F_TEST_REPORT(name) TEST_REPORT_IMPL(g_gd5f_test_stats, name) + */ + +#ifndef TEST_COMMON_H +#define TEST_COMMON_H + +#ifdef __cplusplus +extern "C" { +#endif + +#include + +/* 测试统计(各套件**各自**定义一个此类型的外部变量,防跨套件串数) */ +typedef struct { + uint16_t total; + uint16_t passed; + uint16_t failed; +} test_stats_t; + +/* + * 断言内核:统计自增 + [PASS]/[FAIL] 打印。 + * 套件侧不应直接调用本宏(每次都要写统计变量名),而应用它派生出 + * 自己的 `XXX_TEST_CHECK(cond, fmt, ...)`(见文件头用法)。 + */ +#define TEST_CHECK_IMPL(stats, cond, fmt, ...) do { \ + (stats).total++; \ + if (cond) { \ + (stats).passed++; \ + DBG_INFO("[PASS] " fmt, ##__VA_ARGS__); \ + } else { \ + (stats).failed++; \ + DBG_ERROR("[FAIL] " fmt, ##__VA_ARGS__); \ + } \ +} while (0) + +/* + * 报告内核:套件运行结束时打印汇总行 + * `=== : N/M PASSED (failed=0) ===` + * 该汇总行是 `@test` 回归的默认判定标记(见 测试开发标准流程.md §3.2/§6.2.1)。 + */ +#define TEST_REPORT_IMPL(stats, name) do { \ + DBG_INFO("=== %s: %u/%u PASSED (failed=%u) ===", \ + name, \ + (unsigned)(stats).passed, (unsigned)(stats).total, \ + (unsigned)(stats).failed); \ +} while (0) + +#ifdef __cplusplus +} +#endif + +#endif /* TEST_COMMON_H */ diff --git a/test/board/test_config.h b/test/board/test_config.h index b04bf19b..a1a609e1 100644 --- a/test/board/test_config.h +++ b/test/board/test_config.h @@ -23,11 +23,11 @@ extern "C" { #include /* === 选择当前构建的测试套件(三选一,或都注释 = 正常产品构建)=== - * 2026-09-22:M1~M3(链路时延 Phase 12/13/14,阶段表见说明文档 §4.2)交付 —— - * 当前启用 CH395F 套件 + Phase 14(生产路径时延,M3 基线已锁定,2026-09-22 重锁)。 - * 恢复产品构建 = 注释掉下方 TEST_SUITE_CH395F 一行(ch395f_test.h 的阶段宏 - * 仅在套件启用时生效,无需另改)。详见 docs/测试说明/链路延迟测试说明.md §4.2/§5。 */ -#define TEST_SUITE_CH395F + * ⚠️ 默认必须是**产品构建**(全部注释)——pre-commit 钩子 `test-suite-switch` 会拦截 + * 未显式放行的测试构建(`--allow-test-build` / `ALLOW_TEST_BUILD=1`)。 + * 跑套件回归时取消注释所需套件;跑完务必改回、或直接用 `test/run_all_suites.py` + * (自动改宏 + 结束后无条件还原)。详见 docs/测试说明/测试开发标准流程.md §3.1/§3.6。 */ +//#define TEST_SUITE_CH395F //#define TEST_SUITE_STORAGE //#define TEST_SUITE_GD5F //#define TEST_SUITE_RTC diff --git a/test/host/README.md b/test/host/README.md index 01b51f6c..0f0623a9 100644 --- a/test/host/README.md +++ b/test/host/README.md @@ -9,7 +9,7 @@ | 脚本 | 作用 | |---|---| | `run_boot_test.py` | **`@test` 一键闭环**:开串口+清日志 → 烧录 → wait-idle → `verify_log` 校验(退出码 0=PASS) | -| `verify_log.py` | **日志标记校验**:Required / Forbidden 标记 → PASS / FAIL | +| `verify_log.py` | **日志标记校验**:Required / Forbidden 标记 → PASS / FAIL;可选 `--require-suite-summary` 校验套件汇总行(`=== <名称>: N/M PASSED (failed=0) ===` 且 `N>0`),抓"没跑到汇总行/断言数为 0"的盲区(T-2) | | `e02_probe.py` | E-02 专项探针(错误码传播审计) | ### 网络 / FTP @@ -32,13 +32,15 @@ ### 主机侧单元测试(纯逻辑,无硬件) -| 脚本 | 作用 | +| 文件 | 作用 | |---|---| -| `watchdog/run_unit_test.py` | **看门狗判定状态机单测**:用 MSVC 离线编译真实 `App/util/task_watchdog.c`(可观测桩驱动),39 用例穷举边界。零硬件、秒级(退出码 0=PASS/1=FAIL/2=环境错误) | +| `common/unit_test_harness.py` | **通用单测 harness**(L0.5):探测 MSVC → 拼 `INCLUDE`/`LIB` → 编译 → 运行 → 判定退出码。各模块运行器只做**声明式**配置(`TestBuild`),不含工具链细节 | +| `watchdog/run_unit_test.py` | **看门狗判定状态机单测**:声明 `TestBuild` 后调 harness,用 MSVC 离线编译真实 `App/util/task_watchdog.c`(可观测桩驱动),39 用例穷举边界。零硬件、秒级(退出码 0=PASS/1=FAIL/2=环境错误) | | `watchdog/watchdog_test.c` | 测试单元本体(`#include` 生产源码 + 平台钩子桩) | | `watchdog/stub/` | 桩头文件(`main.h`/`iwdg.h`/`dbg_log.h`),覆盖 MCU 版以在 PC 上编译 | -> 详见 `docs/测试说明/看门狗单元测试说明.md`。已挂 pre-commit 钩子 `watchdog-unit-test`。 +> 详见 `docs/测试说明/看门狗单元测试说明.md`;harness 用法见 [`common/README.md`](common/README.md)。 +> 已挂 pre-commit 钩子 `watchdog-unit-test`。 ## 运行方式 @@ -46,6 +48,11 @@ # @test 一键闭环(推荐,需真机) & '.venv\Scripts\python.exe' test/host/run_boot_test.py +# 测试套件构建的回归:要求套件跑到汇总行且断言数 > 0(T-2) +& '.venv\Scripts\python.exe' test/host/run_boot_test.py ` + --required "=== Storage Tests:" --forbidden "ERROR;HardFault;[ERR]" ` + --require-suite-summary "Storage Tests" + # 看门狗判定状态机单测(纯逻辑,无需真机) & '.venv\Scripts\python.exe' test/host/watchdog/run_unit_test.py ``` diff --git a/test/host/common/README.md b/test/host/common/README.md new file mode 100644 index 00000000..f68e9551 --- /dev/null +++ b/test/host/common/README.md @@ -0,0 +1,82 @@ +# test/host/common/ — 主机侧测试公共库 + +> L0.5 主机侧单元测试的**通用 harness**:被各模块的单测运行器共享, +> 集中承载工具链探测与编译/运行/判定的公共细节。 + +## 文件一览 + +| 文件 | 作用 | +|---|---| +| `unit_test_harness.py` | **通用单测 harness**(纯标准库):探测本机 MSVC → 拼 `INCLUDE`/`LIB` → 编译测试单元 → 运行 → 判定退出码 | + +## 为什么要有这层(2026-10-01 抽取,T-3) + +看门狗单测最早的运行器(`test/host/watchdog/run_unit_test.py`)把工具链探测、 +`INCLUDE`/`LIB` 拼接、GBK 解码、`/wd4819` 抑制、退出码契约**全写死在模块目录内**。 +第二个 L0.5 单测出现时须整段复制——工具链细节一旦修正(新增 VS 版本、SDK 布局 +变化、编码处理调整)就会发散。抽取后各模块运行器只剩"声明式"配置。 + +> **边界**:harness **不认识任何模块**——源码路径、桩目录、额外 include、断言 +> 汇总行特征全部由调用方传入。新单测只写一个 `TestBuild(...)` + 一行 `sys.exit(...)`。 + +## 用法(模块侧 `run_unit_test.py` 模板) + +```python +import os, 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 + +BUILD = TestBuild( + name="<模块名> Tests", # 人类可读名(打印用) + src=os.path.join(HERE, "<模块>_test.c"), # 测试单元(内部 #include 生产源) + include_dirs=[ # 顺序敏感:桩目录必须在生产目录之前 + os.path.join(HERE, "stub"), + os.path.join(ROOT, "App", "util"), + HERE, + ], + summary_marker="=== <模块名> Tests:", # 运行输出中必须出现的汇总行前缀 +) + +if __name__ == "__main__": + sys.exit(run_host_unit_test(BUILD)) +``` + +另一写法(不落 `run_unit_test.py`,直接内联): + +```python +from unit_test_harness import TestBuild, run_host_unit_test +raise SystemExit(run_host_unit_test(TestBuild(name="...", src="...", ...))) +``` + +## 退出码契约(各模块运行器统一遵循) + +| 码 | 含义 | +|---|---| +| `0` | PASS——编译 + 运行成功,且日志含 `summary_marker` | +| `1` | FAIL——有测试用例未通过 | +| `2` | 环境错误——找不到编译器 / 编译失败 / 未产生汇总行 | + +## `TestBuild` 字段 + +| 字段 | 必填 | 说明 | +|---|---|---| +| `name` | ✓ | 打印用名称;同时用于派生 exe 名与 obj 目录名 | +| `src` | ✓ | 测试单元 `.c` 的绝对路径(内部 `#include` 生产源,保证测的是真代码) | +| `include_dirs` | | `/I` 目录列表;**桩目录需排在前面**才能覆盖真实头 | +| `summary_marker` | | 运行输出中必须出现的汇总行前缀;空 = 不校验 | +| `extra_flags` | | 追加的 `cl.exe` 开关 | +| `exe_name` / `obj_tag` | | 覆盖派生的产物名 / 中间目录名(默认由 `name` 派生,落在系统 temp) | + +## 环境要求 + +同 L0.5 通用要求(见 [`../../../docs/测试说明/测试开发标准流程.md`](../../../docs/测试说明/测试开发标准流程.md) §4.2): +MSVC `cl.exe`(装了 VS 即有)+ Windows SDK + 纯标准库 Python;**不调 `vcvars64.bat`** +(内部 `reg.exe` 受限),直接拼环境变量调 `cl.exe`。 + +## ⚠️ 注意 + +- 本目录在 pre-commit 的 cppcheck / 代码规范**排除范围**(`test/` 豁免) +- 改动本 harness 会触发 `watchdog-unit-test` 钩子(`files` 正则已含本文件) diff --git a/test/host/common/unit_test_harness.py b/test/host/common/unit_test_harness.py new file mode 100644 index 00000000..698c02cd --- /dev/null +++ b/test/host/common/unit_test_harness.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""unit_test_harness.py - 主机侧单元测试(L0.5)的通用运行器 harness。 + +职责(**不认识任何具体模块**): + 探测本机 MSVC 工具链 -> 拼 INCLUDE/LIB -> 编译测试单元 -> 运行 -> 判定退出码。 + 被测模块的差异(源文件、桩目录、额外 include、断言汇总行特征)全部由调用方 + (各模块自己的 `run_unit_test.py`)传入。 + +为什么要 harness(2026-10-01 测试架构评审 T-3): + 看门狗单测的运行器(`test/host/watchdog/run_unit_test.py`,170 行)把工具链 + 探测、`INCLUDE`/`LIB` 拼接、GBK 解码、`/wd4819` 抑制、退出码契约**全部写死在 + 模块目录内**。第二个 L0.5 单测出现时须整段复制——工具链细节一旦修正(新增 VS + 版本、SDK 布局变化、编码处理调整)就会发散。抽到这里后,各模块运行器只剩 + "声明式"配置。 + +实现要点(为何不用 vcvars64.bat): + vcvars64.bat 内部调用 reg.exe 探测环境;本机策略禁止 reg.exe。故这里**直接 + 拼 INCLUDE/LIB 环境变量**并绝对路径调用 cl.exe——只需 3 个已知目录 + (MSVC 版本 / Windows SDK include / SDK lib),无副作用、可重复。 + +退出码契约(各模块运行器统一遵循): + 0 = PASS(编译 + 运行成功,且日志满足 summary_marker 要求) + 1 = FAIL(有测试用例未通过) + 2 = 环境错误(找不到编译器 / 编译失败 / 未产生汇总行) + +用法(模块侧 `run_unit_test.py`): + + from unit_test_harness import run_host_unit_test, TestBuild + + if __name__ == "__main__": + raise SystemExit(run_host_unit_test(TestBuild( + name="Task Watchdog Tests", + src=<测试单元 .c 的绝对路径>, + include_dirs=[<桩目录>, <生产源码目录>], + summary_marker="=== Task Watchdog Tests:", + ))) + +设计依据:docs/待处理内容.md T-3;docs/测试说明/测试开发标准流程.md §4.2 +创建日期:2026-10-01 +""" + +import glob +import os +import subprocess +import sys +import tempfile +from dataclasses import dataclass, field + +# ----------------------------- 工具链探测位置 ----------------------------- +# 顺序 = 优先级;`_first` 取每个根目录下最大的版本子目录(新版本优先)。 +VS_ROOTS = [ + r"C:\Program Files (x86)\Microsoft Visual Studio\2019\BuildTools", + r"C:\Program Files (x86)\Microsoft Visual Studio\2019\Community", + r"C:\Program Files (x86)\Microsoft Visual Studio\2019\Professional", + r"C:\Program Files\Microsoft Visual Studio\2022\BuildTools", + r"C:\Program Files\Microsoft Visual Studio\2022\Community", + r"C:\Program Files\Microsoft Visual Studio\2022\Professional", + r"C:\Program Files\Microsoft Visual Studio\2022\Enterprise", + r"C:\Program Files\Microsoft Visual Studio\2026\BuildTools", + r"C:\Program Files\Microsoft Visual Studio\2026\Community", +] +SDK_ROOTS = [r"C:\Program Files (x86)\Windows Kits\10"] + +# 编译开关(各模块共用;模块特有开关见 TestBuild.extra_flags) +_BASE_FLAGS = [ + "/nologo", "/W4", "/std:c11", "/utf-8", + # C4819("源代码包含不能在当前代码页中表示的字符")是**纯编码诊断**: + # 测试单元确为 UTF-8(带 BOM,已核验),且已显式传 /utf-8,字节解码无误、 + # 编译产物正确。但在"只设 INCLUDE/LIB、不调 vcvars"的调用方式下, + # MSVC 的代码页探测会退化为 `代码页(0)` → 恒定误报。 + # 测试单元仅主机侧编译(ARMCC 从不编译 test/),故显式屏蔽,保持门禁输出干净。 + "/wd4819", +] + + +@dataclass +class TestBuild: + """一个主机侧单测的构建描述(声明式;harness 只读这些字段)。""" + + name: str # 人类可读名称(打印用),如 "Task Watchdog Tests" + src: str # 测试单元 .c 的绝对路径(内部 #include 生产源) + include_dirs: list = field(default_factory=list) # /I 目录(桩目录需在前,覆盖真实头) + summary_marker: str = "" # 运行输出中必须出现的汇总行前缀;空 = 不校验 + extra_flags: list = field(default_factory=list) # 追加的 cl.exe 开关 + exe_name: str = "" # 产物 exe 名;空 = 由 name 派生 + obj_tag: str = "" # 中间 obj 目录名;空 = 由 name 派生 + + def resolved_exe(self): + if self.exe_name: + return self.exe_name + tag = "".join(c if c.isalnum() else "_" for c in self.name).strip("_").lower() + return tag + ".exe" + + def resolved_obj_tag(self): + if self.obj_tag: + return self.obj_tag + tag = "".join(c if c.isalnum() else "_" for c in self.name).strip("_").lower() + return tag + "_obj" + + +def _first(pattern): + """glob 命中里取最大者(版本号目录的字符串序即数值序,前提是位数一致)。""" + hits = sorted(glob.glob(pattern)) + return hits[-1] if hits else None + + +def decode(b): + """把 subprocess 字节输出解码为字符串。 + + 本机 MSVC 输出为 GBK(代码页 936),测试程序自身输出为 UTF-8; + 按 UTF-8 优先、失败回退 GBK 处理。 + """ + if isinstance(b, str): + return b + try: + return b.decode("utf-8") + except UnicodeDecodeError: + return b.decode("gbk", errors="replace") + + +def locate_toolchain(): + """返回 (cl.exe, include_dirs, lib_dirs);找不到返回 (None, [], [])。""" + cl = None + for vs in VS_ROOTS: + c = _first(os.path.join(vs, r"VC\Tools\MSVC\*\bin\Hostx64\x64\cl.exe")) + if c: + cl = c + break + if not cl: + return None, [], [] + + # cl.exe = \VC\Tools\MSVC\\bin\Hostx64\x64\cl.exe + msvc_ver_dir = os.path.dirname(os.path.dirname(os.path.dirname( + os.path.dirname(cl)))) # -> ...\MSVC\ + inc = [os.path.join(msvc_ver_dir, "include")] + lib = [os.path.join(msvc_ver_dir, "lib", "x64")] + + for sdk in SDK_ROOTS: + sdk_inc = _first(os.path.join(sdk, r"Include\*")) + sdk_lib = _first(os.path.join(sdk, r"Lib\*")) + if sdk_inc: + inc += [ + os.path.join(sdk_inc, "ucrt"), + os.path.join(sdk_inc, "shared"), + os.path.join(sdk_inc, "um"), + ] + if sdk_lib: + lib += [ + os.path.join(sdk_lib, "ucrt", "x64"), + os.path.join(sdk_lib, "um", "x64"), + ] + return cl, inc, lib + + +def build_and_run(build, out=None): + """编译并运行一个测试单元。返回退出码 0/1/2(见模块 docstring 契约)。""" + if out is None: + out = sys.stdout + err = sys.stderr + + cl, inc, lib = locate_toolchain() + if not cl: + print("[ENV] 找不到 MSVC cl.exe(需要 VS2019/2022/2026 的 C++ 工具链)", file=err) + return 2 + + tmp = tempfile.gettempdir() + objdir = os.path.join(tmp, build.resolved_obj_tag()) + os.makedirs(objdir, exist_ok=True) + exe = os.path.join(tmp, 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] + _BASE_FLAGS + for d in build.include_dirs: + cmd += ["/I", d] + cmd += list(build.extra_flags) + cmd += [build.src, "/Fe:" + exe, "/Fo:" + objdir + os.sep] + + print("=== 编译并运行 {} ===".format(build.name), file=out) + cp = subprocess.run(cmd, env=env, capture_output=True) + cout, cerr = decode(cp.stdout), decode(cp.stderr) + if cout: + out.write(cout) + if cerr: + err.write(cerr) + if cp.returncode != 0: + print("[ENV] 编译失败", file=err) + return 2 + + run = subprocess.run([exe], env=env, capture_output=True) + rout = decode(run.stdout) + out.write(rout) + rerr = decode(run.stderr) + if rerr: + err.write(rerr) + + if build.summary_marker and build.summary_marker not in rout: + print("[ENV] 未产生测试汇总行(找不到 {!r})".format(build.summary_marker), file=err) + return 2 + if run.returncode == 0: + print("[OK] 单元测试全部通过", file=out) + return 0 + print("[FAIL] 有测试用例未通过", file=err) + return 1 + + +def run_host_unit_test(build): + """模块侧入口薄封装:固定控制台 UTF-8 后调用 build_and_run。""" + for stream in (sys.stdout, sys.stderr): + try: + stream.reconfigure(encoding="utf-8", errors="replace") + except Exception: + pass + return build_and_run(build) diff --git a/test/host/run_boot_test.py b/test/host/run_boot_test.py index 38d3e2da..dc7a5149 100644 --- a/test/host/run_boot_test.py +++ b/test/host/run_boot_test.py @@ -24,6 +24,7 @@ [--flash-cmd MDK-ARM\\download.bat] [--flash-timeout 180] [--required "App init OK;Network Init;net init: OK"] [--forbidden "FAIL;ERROR;HardFault;[ERR]"] + [--require-suite-summary "Storage Tests"] [--out-dir <目录>] [--show-tail-lines 15] --port 缺省取 .opencode/serial_config.json 的 portName(单一配置源) @@ -102,6 +103,9 @@ def main(argv=None): help="required markers, ';' separated") ap.add_argument("--forbidden", default=FORBIDDEN_DEFAULT, help="forbidden patterns, ';' separated") + ap.add_argument("--require-suite-summary", default="", + help="suite names that MUST appear as '=== : N/M PASSED (failed=0) ===' " + "with N>0, ';' separated (T-2). Empty = off") ap.add_argument("--out-dir", default=str(Path(tempfile.gettempdir()) / "opencode")) ap.add_argument("--show-tail-lines", type=int, default=15) args = ap.parse_args(argv) @@ -209,7 +213,8 @@ def main(argv=None): # 6. 标记校验 from verify_log import verify - rc = verify(str(boot_txt), args.required, args.forbidden, args.show_tail_lines) + rc = verify(str(boot_txt), args.required, args.forbidden, args.show_tail_lines, + suite_summary=args.require_suite_summary) print("RUN_TEST_RC={}".format(rc)) return rc diff --git a/test/host/verify_log.py b/test/host/verify_log.py index 25b576cf..6a57dea3 100644 --- a/test/host/verify_log.py +++ b/test/host/verify_log.py @@ -8,11 +8,12 @@ & '.venv\\Scripts\\python.exe' test/host/verify_log.py --log-path [--required 'marker1;marker2'] [--forbidden 'FAIL;ERROR;HardFault;assert;[ERR]'] + [--require-suite-summary 'Storage Tests;GD5F2GQ5UE Tests'] [--show-tail-lines 40] 退出码(与旧版一致): - 0 = PASS(所有 Required 标记找到,Forbidden 零命中) - 1 = FAIL(缺 Required 或命中 Forbidden) + 0 = PASS(所有 Required 标记找到,Forbidden 零命中,汇总行要求满足) + 1 = FAIL(缺 Required / 命中 Forbidden / 汇总行要求不满足) 2 = ERROR(日志文件不存在/不可读) 默认 Required 标记对应 STM32F4-Base 启动序列(见 App/app/app_main.c + App/task/net_task.c), @@ -31,16 +32,29 @@ - 读取编码:先按 UTF-8,解码失败回退系统 locale 编码(旧版 Get-Content 行为) - 控制台输出重配置为 UTF-8(errors=replace):任何 Windows 控制台代码页 都只影响终端显示,不会导致本脚本崩溃 + - 汇总行(2026-10-01 加,T-2):纯 Required 子串匹配有个盲区——套件启动横幅 + 命中即判 PASS,**套件是否跑到汇总行、断言数是否为 0 完全不可见**(套件中途 + 挂死/复位、或 Phase 宏全关时会被误判为通过)。`--require-suite-summary` + 要求日志中出现 `=== <名称>: N/M PASSED (failed=0) ===` 且 **N > 0**。 """ import argparse import locale +import re import sys from pathlib import Path REQUIRED_DEFAULT = "App init OK;Network Init;net init: OK" FORBIDDEN_DEFAULT = "FAIL;ERROR;HardFault;assert;[ERR]" +# 套件汇总行:`=== Storage Tests: 3/3 PASSED (failed=0) ===` +# 名称可含空格;N/M 为十进制;failed 允许前导 0。宽松容忍前后空白与 +# 日志时间戳前缀(`[2026-10-01 12:00:00.000]`)——故用 search 而非 match。 +_SUMMARY_RE = re.compile( + r"===\s*(?P[^:=]+?)\s*:\s*(?P\d+)\s*/\s*(?P\d+)\s+" + r"PASSED\s*\(\s*failed\s*=\s*(?P\d+)\s*\)\s*===" +) + def _split_list(spec): """';' 分隔列表 → 去空白、去空项。""" @@ -58,8 +72,12 @@ def _read_lines(path): def verify(log_path, required=REQUIRED_DEFAULT, forbidden=FORBIDDEN_DEFAULT, - show_tail=40, out=print): - """标记校验核心(可被 run_boot_test.py 复用)。返回退出码 0/1/2。""" + show_tail=40, out=print, suite_summary=""): + """标记校验核心(可被 run_boot_test.py 复用)。返回退出码 0/1/2。 + + suite_summary: ';' 分隔的套件名列表。非空时,要求日志中每个套件都出现 + `=== <名称>: N/M PASSED (failed=0) ===` 且 **N > 0**。 + """ p = Path(log_path) if not p.is_file(): out("VERIFY_ERROR: log file not found: {}".format(log_path)) @@ -73,6 +91,7 @@ def verify(log_path, required=REQUIRED_DEFAULT, forbidden=FORBIDDEN_DEFAULT, full = "\n".join(lines) req_list = _split_list(required) forb_list = _split_list(forbidden) + summary_list = _split_list(suite_summary) out("=== Log verdict: {} ===".format(log_path)) out("Lines: {}, bytes: {}".format(len(lines), p.stat().st_size)) @@ -95,6 +114,41 @@ def verify(log_path, required=REQUIRED_DEFAULT, forbidden=FORBIDDEN_DEFAULT, else: out("[OK] forbidden pattern absent: {}".format(f)) + # 套件汇总行解析(T-2):抓"没跑到汇总行 / 断言数为 0"的盲区 + bad_summary = [] + if summary_list: + parsed = {} + for ln in lines: + m = _SUMMARY_RE.search(ln) + if m: + parsed.setdefault(m.group("name").strip(), []).append(m) + for name in summary_list: + cands = parsed.get(name) + if not cands: + out("[MISS] suite summary NOT found: '=== {}: N/M PASSED (failed=0) ==='".format(name)) + bad_summary.append(name) + continue + ok = False + for m in cands: + passed = int(m.group("passed")) + total = int(m.group("total")) + failed = int(m.group("failed")) + if failed != 0: + out("[FAIL] suite summary reports failures: {} -> {}/{} PASSED (failed={})".format( + name, passed, total, failed)) + continue + if total == 0: + out("[FAIL] suite summary total is 0 (no assertion ran): '{}'".format(name)) + continue + if passed != total: + out("[FAIL] suite summary inconsistent: {} -> {}/{} PASSED".format( + name, passed, total)) + continue + out("[OK] suite summary ok: {} -> {}/{} PASSED".format(name, passed, total)) + ok = True + if not ok: + bad_summary.append(name) + if show_tail > 0 and lines: out("") out("=== Last {} line(s) of log ===".format(show_tail)) @@ -102,7 +156,7 @@ def verify(log_path, required=REQUIRED_DEFAULT, forbidden=FORBIDDEN_DEFAULT, out(ln) out("") - if missing or hits: + if missing or hits or bad_summary: out("VERDICT: FAIL") return 1 out("VERDICT: PASS") @@ -123,11 +177,15 @@ def main(argv=None): help="required markers, ';' separated (case-sensitive)") ap.add_argument("--forbidden", default=FORBIDDEN_DEFAULT, help="forbidden patterns, ';' separated (case-insensitive)") + ap.add_argument("--require-suite-summary", default="", + help="suite names that MUST appear as '=== : N/M PASSED (failed=0) ===' " + "with N>0, ';' separated (T-2). Empty = off") ap.add_argument("--show-tail-lines", type=int, default=40, help="show last N log lines (0 = off)") args = ap.parse_args(argv) - return verify(args.log_path, args.required, args.forbidden, args.show_tail_lines) + return verify(args.log_path, args.required, args.forbidden, + args.show_tail_lines, suite_summary=args.require_suite_summary) if __name__ == "__main__": diff --git a/test/host/watchdog/run_unit_test.py b/test/host/watchdog/run_unit_test.py index 526c5033..1922f12e 100644 --- a/test/host/watchdog/run_unit_test.py +++ b/test/host/watchdog/run_unit_test.py @@ -1,170 +1,52 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -"""看门狗判定状态机的主机侧单元测试运行器(纯标准库)。 +"""看门狗判定状态机的主机侧单元测试运行器(L0.5,纯标准库)。 做什么: 用本机 MSVC 编译 test/host/watchdog/watchdog_test.c(它以 #include 纳入 **生产源文件** App/util/task_watchdog.c),在 PC 上离线跑完整判定状态机, 失败即非 0 退出。零硬件、零烧录、毫秒级。 + 工具链探测 / INCLUDE-LIB 拼接 / 编译运行 / 退出码判定全部委托给通用 harness + `test/host/common/unit_test_harness.py`(本文件只做声明,2026-10-01 T-3 抽取)。 + 为什么要主机侧单测(而不是只用板端套件): task_watchdog.c 的判定逻辑(位图取走清零 / 掩码 / miss_streak 累计 / 节流) 是纯逻辑,与硬件无关;板端套件只能"在真机上跑一遍",无法穷举边界(节流窗口 99/100ms、tick 回绕、连续 8 次缺席的第 8/9 次分界)。主机侧单测把这些一次性 钉死,且不占 MCU 的 RAM(项目 RAM 已 95%)。 -实现要点(为何不用 vcvars64.bat): - vcvars64.bat 内部调用 reg.exe 探测环境;本机策略禁止 reg.exe。故这里**直接 - 拼 INCLUDE/LIB 环境变量**并绝对路径调用 cl.exe——只需 3 个已知目录 - (MSVC 版本 / Windows SDK include / SDK lib),无副作用、可重复。 - 用法(仓库根目录): python test/host/watchdog/run_unit_test.py 或统一用 .venv: & '.venv\\Scripts\\python.exe' test/host/watchdog/run_unit_test.py -退出码:0=PASS / 1=测试失败 / 2=环境错误(找不到编译器 / 编译失败)。 +退出码:0=PASS / 1=测试失败 / 2=环境错误(找不到编译器 / 编译失败 / 无汇总行)。 -设计依据:docs/测试说明/看门狗单元测试说明.md +设计依据:docs/测试说明/看门狗单元测试说明.md;docs/待处理内容.md T-3 创建日期:2026-10-01 """ -import glob import os -import subprocess import sys -import tempfile -# ----------------------------- 路径 ----------------------------- HERE = os.path.dirname(os.path.abspath(__file__)) ROOT = os.path.dirname(os.path.dirname(os.path.dirname(HERE))) -STUB = os.path.join(HERE, "stub") -SRC = os.path.join(HERE, "watchdog_test.c") -APP_UTIL = os.path.join(ROOT, "App", "util") # task_watchdog.c 所在目录 +# harness 位于 test/host/common/ +sys.path.insert(0, os.path.join(ROOT, "test", "host", "common")) -VS_ROOTS = [ - r"C:\Program Files (x86)\Microsoft Visual Studio\2019\BuildTools", - r"C:\Program Files\Microsoft Visual Studio\2022\BuildTools", - r"C:\Program Files\Microsoft Visual Studio\2022\Community", - r"C:\Program Files\Microsoft Visual Studio\2022\Professional", -] -SDK_ROOTS = [r"C:\Program Files (x86)\Windows Kits\10"] - - -def _first(pattern): - hits = sorted(glob.glob(pattern)) - return hits[-1] if hits else None - - -def _decode(b): - """把 subprocess 字节输出解码为字符串:本机 MSVC 输出为 GBK(代码页 936), - 测试程序自身输出为 UTF-8;按 UTF-8 优先、失败回退 GBK 处理。""" - if isinstance(b, str): - return b - try: - return b.decode("utf-8") - except UnicodeDecodeError: - return b.decode("gbk", errors="replace") - - -def locate_toolchain(): - """返回 (cl.exe, include_dirs, lib_dirs);找不到返回 (None, [], [])。""" - cl = None - for vs in VS_ROOTS: - c = _first(os.path.join(vs, r"VC\Tools\MSVC\*\bin\Hostx64\x64\cl.exe")) - if c: - cl = c - break - if not cl: - return None, [], [] - - msvc_ver_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(cl)))) - # cl.exe = \VC\Tools\MSVC\\bin\Hostx64\x64\cl.exe - msvc_ver_dir = os.path.dirname(os.path.dirname(os.path.dirname( - os.path.dirname(cl)))) # -> ...\MSVC\ - inc = [ - os.path.join(msvc_ver_dir, "include"), - ] - lib = [ - os.path.join(msvc_ver_dir, "lib", "x64"), - ] - - for sdk in SDK_ROOTS: - sdk_inc = _first(os.path.join(sdk, r"Include\*")) - sdk_lib = _first(os.path.join(sdk, r"Lib\*")) - if sdk_inc: - inc += [ - os.path.join(sdk_inc, "ucrt"), - os.path.join(sdk_inc, "shared"), - os.path.join(sdk_inc, "um"), - ] - if sdk_lib: - lib += [ - os.path.join(sdk_lib, "ucrt", "x64"), - os.path.join(sdk_lib, "um", "x64"), - ] - return cl, inc, lib - - -def main(): - cl, inc, lib = locate_toolchain() - if not cl: - print("[ENV] 找不到 MSVC cl.exe(需要 VS2019/2022 的 C++ 工具链)", - file=sys.stderr) - return 2 - - tmp = tempfile.gettempdir() - objdir = os.path.join(tmp, "wd_test_obj") - os.makedirs(objdir, exist_ok=True) - exe = os.path.join(tmp, "watchdog_unit_test.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, "/nologo", "/W4", "/std:c11", "/utf-8", - # C4819("源代码包含不能在当前代码页中表示的字符")是**纯编码诊断**: - # 本文件确为 UTF-8(带 BOM,已核验),且已显式传 /utf-8,字节解码无误、 - # 编译产物正确。但在"只设 INCLUDE/LIB、不调 vcvars"的调用方式下, - # MSVC 的代码页探测会退化为 `代码页(0)` → 恒定误报 10 条。 - # 该文件仅主机侧编译(ARMCC 从不编译 test/),故显式屏蔽,保持门禁输出干净。 - "/wd4819", - "/I", STUB, # stub 头优先于真实头(覆盖 main.h/iwdg.h/dbg_log.h) - "/I", APP_UTIL, # task_watchdog.c / task_watchdog.h - "/I", HERE, # (保留)本目录 - SRC, - "/Fe:" + exe, - "/Fo:" + objdir + os.sep, - ] - - print("=== 编译并运行看门狗单元测试 ===") - cp = subprocess.run(cmd, env=env, capture_output=True) - cout = _decode(cp.stdout) - cerr = _decode(cp.stderr) - if cout: - sys.stdout.write(cout) - if cerr: - sys.stderr.write(cerr) - if cp.returncode != 0: - print("[ENV] 编译失败", file=sys.stderr) - return 2 - - run = subprocess.run([exe], env=env, capture_output=True) - out = _decode(run.stdout) - sys.stdout.write(out) - errout = _decode(run.stderr) - if errout: - sys.stderr.write(errout) - - if "=== Task Watchdog Tests:" not in out: - print("[ENV] 未产生测试汇总行", file=sys.stderr) - return 2 - if run.returncode == 0: - print("[OK] 单元测试全部通过") - return 0 - print("[FAIL] 有测试用例未通过", file=sys.stderr) - return 1 +from unit_test_harness import TestBuild, run_host_unit_test # noqa: E402 +# 测试单元内部 #include 生产源;stub 目录必须**先于** APP_UTIL,才能覆盖真实头 +# (main.h / iwdg.h / dbg_log.h 由桩提供,使 task_watchdog.c 可在 PC 上编译)。 +BUILD = TestBuild( + name="Task Watchdog Tests", + src=os.path.join(HERE, "watchdog_test.c"), + include_dirs=[ + os.path.join(HERE, "stub"), + os.path.join(ROOT, "App", "util"), + HERE, + ], + summary_marker="=== Task Watchdog Tests:", +) if __name__ == "__main__": - sys.exit(main()) + sys.exit(run_host_unit_test(BUILD)) diff --git a/test/run_all_suites.py b/test/run_all_suites.py new file mode 100644 index 00000000..f6422d91 --- /dev/null +++ b/test/run_all_suites.py @@ -0,0 +1,233 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""run_all_suites.py - 多套件回归编排(纯标准库)。 + +做什么(2026-10-01 测试架构评审 T-5): + 套件互斥(`test_config.h` 的 `#error`)+ 编译期开关 → 跑 4 个套件的回归 + 本来要手工循环 4 轮(改宏 → 构建 → 烧录 → 采集 → 判读)。本脚本把它编排成一条命令: + + 对每个套件: + 1. 改 test_config.h 启用该套件(其它全注释) + 2. @build(0 错 0 警,否则跳过该套件) + 3. 烧录(MDK-ARM/download.bat,Reset & Run) + 4. @test 采集 + 判读(verify_log.py,含套件汇总行校验 T-2) + 结束后: + 5. **无条件还原 test_config.h**(try/finally,含异常/KeyboardInterrupt 路径) + +为什么要还原(这是本脚本存在的核心理由): + 跑完一轮忘了还原 = **测试固件进库**(与 T-1 同源风险)。T-1 是"提交期门禁"兜底, + 本脚本是"流程自动化"——两条防线各自独立。 + +⚠️ 代价提醒: + 每轮要真机烧录 + 采集(8s 静默 + boot),4 套件 ≈ 数分钟起;破坏性套件还会改设备 + 持久状态。**仅回归窗口用,绝不挂 pre-commit。** + +退出码: + 0 = 全部套件 PASS + 1 = 有套件 FAIL(构建失败 / 采集错误 / 标记判定失败) + 2 = 环境/用法错误(缺文件、无法还原配置等) + +用法(仓库根目录): + & '.venv\\Scripts\\python.exe' test/run_all_suites.py + & '.venv\\Scripts\\python.exe' test/run_all_suites.py --suites STORAGE,GD5F + & '.venv\\Scripts\\python.exe' test/run_all_suites.py --dry-run # 只打印计划 + & '.venv\\Scripts\\python.exe' test/run_all_suites.py --keep-config # 不还原(调试用,慎) + +设计依据:docs/待处理内容.md T-5;docs/测试说明/测试开发标准流程.md §3.1 +创建日期:2026-10-01 +""" + +import argparse +import re +import subprocess +import sys +import time +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +CONFIG = REPO_ROOT / "test" / "board" / "test_config.h" +BUILD_BAT = REPO_ROOT / "MDK-ARM" / "build.bat" +DOWNLOAD_BAT = REPO_ROOT / "MDK-ARM" / "download.bat" +RUN_BOOT = REPO_ROOT / "test" / "host" / "run_boot_test.py" + +# 套件 -> (宏名, verify_log 需要的汇总行名称, 该套件的 Required 标记) +# 汇总行名称与套件 `XXX_TEST_REPORT("...")` 里的字符串一致(见各模块 Test_Guide) +SUITES = { + "CH395F": ("TEST_SUITE_CH395F", None, ""), + "STORAGE": ("TEST_SUITE_STORAGE", "Storage Tests", "=== Storage Tests:"), + "GD5F": ("TEST_SUITE_GD5F", "GD5F2GQ5UE Tests", "=== GD5F2GQ5UE Tests:"), + "RTC": ("TEST_SUITE_RTC", "RTC Tests", "=== RTC Tests:"), +} + +# 套件名 -> 汇总行里的名称可能因模块而异,故上面的 summary 值仅为默认;实际以 +# `--required` 给出的前缀做 Required 匹配,`--require-suite-summary` 做严格解析。 +_ALL_MACROS = [m for m, _, _ in SUITES.values()] + + +def _rewrite_config(enable_macro): + """把 test_config.h 里所有 TEST_SUITE_* 开关设为只启用 enable_macro(None = 全注释)。 + + 只动"选择开关"行(`#define TEST_SUITE_` / `//#define TEST_SUITE_`), + 不碰派生的 TEST_SUITE_SELECTED / TEST_SUITE_NAME。 + 按原换行符与 BOM 状态回写(仓库编码铁律)。 + """ + raw = CONFIG.read_bytes() + bom = raw.startswith(b"\xef\xbb\xbf") + text = raw.decode("utf-8-sig") if bom else raw.decode("utf-8") + newline = "\r\n" if "\r\n" in text else "\n" + lines = text.split(newline) + + switch_re = re.compile(r"^\s*(//)?\s*#\s*define\s+(TEST_SUITE_\w+)\s*$") + out = [] + for ln in lines: + m = switch_re.match(ln) + if m and m.group(2) in _ALL_MACROS: + name = m.group(2) + if enable_macro is not None and name == enable_macro: + out.append("#define {}".format(name)) + else: + out.append("//#define {}".format(name)) + else: + out.append(ln) + + data = newline.join(out).encode("utf-8") + if bom: + data = b"\xef\xbb\xbf" + data + CONFIG.write_bytes(data) + + +def _run(cmd, timeout=None): + """跑子进程,返回 (rc, 输出文本)。输出按字节捕获后 UTF-8 兜底解码。""" + try: + cp = subprocess.run(cmd, capture_output=True, timeout=timeout, + cwd=str(REPO_ROOT), shell=isinstance(cmd, str)) + except subprocess.TimeoutExpired as e: + blob = (e.stdout or b"") + (e.stderr or b"") + return -1, blob.decode("utf-8", errors="replace") + blob = (cp.stdout or b"") + (cp.stderr or b"") + return cp.returncode, blob.decode("utf-8", errors="replace") + + +def main(argv=None): + for stream in (sys.stdout, sys.stderr): + try: + stream.reconfigure(encoding="utf-8", errors="replace") + except Exception: + pass + + ap = argparse.ArgumentParser(description="多套件回归编排(T-5,纯标准库)") + ap.add_argument("--suites", default=",".join(SUITES.keys()), + help="要跑的套件,逗号分隔(默认全部:{})".format(",".join(SUITES))) + ap.add_argument("--build-timeout", type=int, default=300) + ap.add_argument("--flash-timeout", type=int, default=300) + ap.add_argument("--dry-run", action="store_true", help="只打印计划,不改配置不构建不烧录") + ap.add_argument("--keep-config", action="store_true", + help="结束后不还原 test_config.h(调试用;⚠️ 会留下测试固件配置)") + args = ap.parse_args(argv) + + py = sys.executable + picked = [s.strip().upper() for s in args.suites.split(",") if s.strip()] + bad = [s for s in picked if s not in SUITES] + if bad: + print("[ERROR] 未知套件:{}(可选:{})".format( + ",".join(bad), ",".join(SUITES))) + return 2 + + for p in (CONFIG, BUILD_BAT, DOWNLOAD_BAT, RUN_BOOT): + if not p.is_file(): + print("[ERROR] 缺文件:{}".format(p)) + return 2 + + print("=== 多套件回归编排 ===") + print("套件:{}".format(", ".join(picked))) + print("构建:{}".format(BUILD_BAT)) + print("烧录:{}".format(DOWNLOAD_BAT)) + print("判读:{}".format(RUN_BOOT)) + if args.dry_run: + print("[DRY-RUN] 仅打印计划,未改动任何文件。") + return 0 + + results = {} # 套件 -> "PASS"/"FAIL"/"BUILD_FAIL" + restored = False + + try: + # 先确保从产品构建(干净起点)开始,避免上一轮的残留干扰 + _rewrite_config(None) + baseline = CONFIG.read_bytes() + + for suite in picked: + macro, summary_name, required = SUITES[suite] + print("") + print("################ {} ################".format(suite)) + _rewrite_config(macro) + + # 1) 构建 + rc, out = _run([str(BUILD_BAT)], timeout=args.build_timeout) + tail = "\n".join(out.strip().splitlines()[-6:]) + print("[BUILD] rc={}".format(rc)) + if tail: + print(tail) + if rc != 0: + print("[{}] BUILD_FAIL -> 跳过本套件".format(suite)) + results[suite] = "BUILD_FAIL" + continue + + # 2) 烧录 + t0 = time.monotonic() + frc, fout = _run(["cmd", "/c", str(DOWNLOAD_BAT)], timeout=args.flash_timeout) + print("[FLASH] rc={} elapsed={:.1f}s".format(frc, time.monotonic() - t0)) + if frc != 0: + print("\n".join(fout.strip().splitlines()[-6:])) + # 烧录失败不直接判死:板子可能已复位,交由采集+判读决定(与 @test 一致) + print("[WARN] 烧录 rc={}(继续:板子可能已复位)".format(frc)) + + # 3) 采集 + 判读(含套件汇总行校验 T-2) + cmd = [py, str(RUN_BOOT)] + if required: + cmd += ["--required", required] + if summary_name: + cmd += ["--require-suite-summary", summary_name] + # 套件构建预期可能出现 [ERR](守卫负路径),放宽 Forbidden 至硬错误 + cmd += ["--forbidden", "HardFault;assert;[ERR]"] + rc, out = _run(cmd, timeout=args.flash_timeout + 120) + print("\n".join(out.strip().splitlines()[-8:])) + results[suite] = "PASS" if rc == 0 else "FAIL" + print("[{}] -> {}".format(suite, results[suite])) + + finally: + # 4) 无条件还原(本脚本存在的核心理由) + if args.keep_config: + print("") + print("[WARN] --keep-config:未还原 test_config.h(⚠️ 可能留下测试固件配置)") + else: + try: + _rewrite_config(None) + # 与进入时的基线一致性核对(防止还原逻辑本身出错) + now = CONFIG.read_bytes() + if now != baseline: + print("[WARN] 还原后的配置与进入时基线不一致,请人工核对 {}".format(CONFIG)) + else: + restored = True + print("") + print("[OK] 已还原 test_config.h 为产品构建(全部 TEST_SUITE_* 注释)") + except Exception as e: # noqa: BLE001 - 还原失败必须显式报出 + print("[ERROR] 还原 test_config.h 失败:{}".format(e), file=sys.stderr) + + # 汇总 + print("") + print("=== 编排结果 ===") + for s in picked: + print(" {:<8} {}".format(s, results.get(s, "SKIPPED"))) + if not restored and not args.keep_config: + return 2 + + failed = [s for s, r in results.items() if r != "PASS"] + if failed: + print("[FAIL] 未通过:{}".format(", ".join(failed))) + return 1 + print("[OK] 全部套件 PASS") + return 0 + + +if __name__ == "__main__": + sys.exit(main())