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())