Files
STM32F4-Base/test/run_all_suites.py
T
wandering1 f754d59218 docs(测试说明): 按用途分六个子目录并同步全库引用
分层:流程与规范/ 单元测试/ 板级测试/ 专项验证/ 测试工具/ 测试数据/,
顶层只留新增的目录导航 README.md(归层判据 + 硬约束 + 本次处置记录)。

- 搬迁 28 个路径:测试数据三个目录 58 个文件为纯改名(R100,内容逐字节不变)
- 全库引用同步 124 个文件:AGENTS / docs 索引 / skills / 门禁注释 / scripts
- 门禁面:scripts/check_test_traceability.py 的 glob 改 rglob
  (分层后不递归扫描=追溯门禁直接瞎掉)
- scripts/rename_ref.py:收尾的 ℹ 提示在 GBK 代码页抛 UnicodeEncodeError,
  让进程以退出码 1 收场而改写其实已落盘(RC 与真实结果相反);改为把
  stdout/stderr reconfigure 成 UTF-8,与 check_doc_links.py 等同法
- docs/文档规范.md §1.1 补「子目录文件过多时再分一层」;§3.6 补 rename_ref
  搬整目录的两个实测坑:① 会改写目录内部文件的内容 ② 内部文件的 ../ 出链
  不补层级(本次 23 个被误改的证据文件已按 HEAD 原始字节逐字节还原)
- docs/README.md §6 改为与六个子目录一一对应,并补登原先漏登记的
  「启动日志原子性复验流程」一行
- .cubemx_survival_baseline.json:App/util/task_watchdog.c 的注释路径也随分层更新,
  该文件是存活审计的 watchdog-module 漂移锚点,按脚本自己的提示跑 --write 重建基线
  (实测重建后仅 watchdog-module 一条哈希变化)
- 铁律 4 例外(本次在任务树里改了单写者文件):AGENTS.md 与
  .pre-commit-config.yaml 只被 rename_ref 改写路径字面量(后者全在注释行),
  任务表块与钩子声明一字未动

验证:check_doc_links.py --sections-strict 覆盖全部 88 个改动 .md 全绿;
check_test_traceability.py 输出与主树逐行一致;196 个文本文件无 BOM、无新增
CR、无 U+FFFD、无行尾空白;陈旧引用面由主树 25 处降到 6 处(无新增断链)。
2026-10-30 07:16:07 +08:00

306 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""run_all_suites.py - 多套件回归编排(纯标准库)。
做什么(2026-10-01 测试架构评审):
套件互斥(`test_config.h` 的 `#error`)+ 编译期开关 → 跑 4 个套件的回归
本来要手工循环 4 轮(改宏 → 构建 → 烧录 → 采集 → 判读)。本脚本把它编排成一条命令:
对每个套件:
1. 改 test_config.h 启用该套件(其它全注释)
2. @build(0 错 0 警,否则跳过该套件)
3. 烧录(stlink-flash skill:stlink_flash.py flash,Reset & Run)
4. @test 采集 + 判读(.dsh/skills/serial-boot-test 的 verify_log.py,含套件汇总行校验)
结束后:
5. **无条件还原 test_config.h**(try/finally,含异常/KeyboardInterrupt 路径)
为什么要还原(这是本脚本存在的核心理由):
跑完一轮忘了还原 = **测试固件进库**(与提交期门禁同源风险)。提交期门禁是兜底,
本脚本是"流程自动化"——两条防线各自独立。
⚠️ 代价提醒:
每轮要真机烧录 + 采集(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 --check-build # 只编译不烧录(无需 MCU)
& '.venv\\Scripts\\python.exe' test/run_all_suites.py --keep-config # 不还原(调试用,慎)
--check-build:
只做"改宏 + @build",不烧录不采集,故**不需要 MCU / 串口 / ST-LINK**。
用途:① 改动测试固件或 test_config.h 后自检 4 套件仍可编译;
② 防止"某套件长期编不过而无人知"(2026-08~09 CH395F 套件实例)。
注意 RAM 溢出会自然暴露为链接错误 L6406E——这正是想要的。
设计依据: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"
KEIL_BUILD = REPO_ROOT / ".dsh" / "skills" / "keil-build" / "scripts" / "keil_build.py"
STLINK_FLASH = REPO_ROOT / ".dsh" / "skills" / "stlink-flash" / "scripts" / "stlink_flash.py"
RUN_BOOT = REPO_ROOT / ".dsh" / "skills" / "serial-boot-test" / "scripts" / "run_boot_test.py"
# 套件态的 forbidden 覆盖(判据边界见下方注释块,正文只这一处)。
FORBIDDEN_SUITE = "HardFault;MEMFAULT;BUSFAULT;USAGEFAULT;assert;bring-up FAIL"
# 套件 -> (宏名, verify_log 需要的汇总行名称, 该套件的 Required 标记, --forbidden 覆盖)
# 汇总行名称与套件 `XXX_TEST_REPORT("...")` 里的字符串一致(见各模块 Test_Guide)。
# forbidden 覆盖:套件含"预期 [ERR]"的守卫负路径时须覆盖(空 = 用默认
# `HardFault;assert;[ERR]`,与 .serialtest/config.json 的产品判据同源)。
#
# 为什么不能只是"把 [ERR] 删掉"(2026-10-25 台架实测 + verify_log 源码核对):
# 四种 Fault 的崩溃行长这样:`[ERR] HARDFAULT|MEMFAULT|BUSFAULT|USAGEFAULT …`
# (Src/stm32f4xx_it.c 的 hard_fault_report),而 verify_log 的 forbidden 是**大小写
# 不敏感的子串匹配**(fl in ln.lower())→ 默认集合里的 `HardFault` 能命中硬Fault,
# 但 MEM/BUS/USAGE 三种**平时完全靠 [ERR] 兜**。所以旧 GD5F 只写 `HardFault;assert` 其实
# 放过了后三种崩溃。覆盖集合一律显式列出四个 Fault 标签 + assert + 起网失败字样:
# 豁免的是**设计内的守卫 [ERR]**,崩溃照旧判失败。
# 产品 @test 判据(.serialtest/config.json 的 forbidden 含 [ERR])**不在这里动**——
# 豁免只活在"套件回归"这条路上,渗不回产品路径。
# GD5F:TC-GD5F-704 故意用非 DMA 可达缓冲触发 `[ERR][NAND] RX DMA buffer not
# DMA-reachable` 回落轮询(用例 PASS)→ 需豁免设计内 [ERR]。
# CH395F:Phase 15 的 sn 越界负向用例命中驱动守卫 `[ERR][CH395] ch395f P2
# boundary fault @get_state sn`(Drivers/BSP/CH395F/ch395f.c 的
# ch395f_p2_fault_note():首次留痕、之后只计数)→ 同属设计内。
# 另外 `[ERR][WD] task stall` 那类**不是设计内**,是构建配置缺陷(HW 层
# 测试构建下必需心跳掩码仍含 netTask),已单独提修,不用豁免掩盖它。
SUITES = {
"CH395F": ("TEST_SUITE_CH395F", None, "", FORBIDDEN_SUITE),
"STORAGE": ("TEST_SUITE_STORAGE", "Storage Tests", "=== Storage Tests:", ""),
"GD5F": ("TEST_SUITE_GD5F", "GD5F2GQ5UE Tests", "=== GD5F2GQ5UE Tests:", FORBIDDEN_SUITE),
"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_<NAME>` / `//#define TEST_SUITE_<NAME>`),
不碰派生的 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="多套件回归编排(纯标准库)")
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("--check-build", action="store_true",
help="只编译不烧录:逐套件改宏 + @build,验证 4 套件可编译性;"
"不需要 MCU / 串口 / ST-LINK,可用于 CI 或改动测试固件后自检")
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
# --check-build 不需要串口/烧录工具,故不强求 run_boot_test.py 存在
required = (CONFIG, KEIL_BUILD) if args.check_build else (CONFIG, KEIL_BUILD, STLINK_FLASH, RUN_BOOT)
for p in required:
if not p.is_file():
print("[ERROR] 缺文件:{}".format(p))
return 2
mode = "只编译(--check-build,不烧录)" if args.check_build else "完整回归(编译+烧录+采集)"
print("=== 多套件回归编排 ===")
print("模式:{}".format(mode))
print("套件:{}".format(", ".join(picked)))
print("构建:{}".format(KEIL_BUILD))
if not args.check_build:
print("烧录:{}".format(STLINK_FLASH))
print("判读:{}".format(RUN_BOOT))
if args.dry_run:
print("[DRY-RUN] 仅打印计划,未改动任何文件。")
return 0
results = {} # 套件 -> "PASS"/"FAIL"/"BUILD_FAIL"
restored = False
baseline = None # 进入时的 test_config.h 基线(try 内赋值;finally 依赖它,故先置 None)
try:
# 先确保从产品构建(干净起点)开始,避免上一轮的残留干扰
_rewrite_config(None)
baseline = CONFIG.read_bytes()
for suite in picked:
macro, summary_name, required, forbid = SUITES[suite]
print("")
print("################ {} ################".format(suite))
_rewrite_config(macro)
# 1) 构建(@build = keil-build skill;脚本内超时先于 _run 超时触发,
# 超时会返回 rc=1 + BUILD_FAIL 文案,而不是被 _run 杀掉)
rc, out = _run([py, str(KEIL_BUILD), "--timeout", str(args.build_timeout)],
timeout=args.build_timeout + 60)
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
# --check-build:编译通过即达标,不烧录不采集
if args.check_build:
results[suite] = "PASS"
print("[{}] BUILD_OK -> PASS(--check-build,未烧录)".format(suite))
continue
# 2) 烧录
t0 = time.monotonic()
frc, fout = _run([py, str(STLINK_FLASH), "flash"], 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) 采集 + 判读(含套件汇总行校验)
# 烧录统一走 stlink-flash skill(与 .serialtest/config.json 的 flash_cmd 同源)
flash_cmd = '"{}" "{}" flash'.format(py, STLINK_FLASH)
cmd = [py, str(RUN_BOOT), "--flash-cmd", flash_cmd]
if required:
cmd += ["--required", required]
if summary_name:
cmd += ["--require-suite-summary", summary_name]
# 套件构建预期可能出现 [ERR](守卫负路径)——由套件自身的 forbid 覆盖决定
# (空 = 默认 HardFault;assert;[ERR];GD5F = HardFault;assert,见 SUITES 注释)
cmd += ["--forbidden", forbid or "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 baseline is not None and 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)
# 5) 复位构建产物为产品态(2026-10-02):最后一轮构建留下的是**测试套件**的
# .map/.axf,会污染 mem-numbers 体积门禁(下次提交即 FAIL)。仅还原源码不够
# ——必须再编一次产品固件,使 .map 与 test_config.h 一起回到产品态。
# --check-build 也要做:它每轮都 @build,同样会留下测试态 .map。
if restored and not args.keep_config:
print("")
print("[..] 复位构建产物为产品态(重建一次产品固件,避免 .map 停留在测试套件态)...")
rc, out = _run([py, str(KEIL_BUILD), "--timeout", str(args.build_timeout)],
timeout=args.build_timeout + 60)
tail = "\n".join(out.strip().splitlines()[-4:])
if rc == 0:
print("[OK] 产品固件已重建(.map/.axf 复位)")
else:
# 不判死:配置已还原,仅提示手工重建
print("[WARN] 产品固件重建失败(rc={}),请手工跑 @build 复位 .map".format(rc))
if tail:
print(tail)
# 汇总
print("")
print("=== 编排结果{} ===".format("(--check-build)" if args.check_build else ""))
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)))
if args.check_build:
print(" (--check-build:失败 = 该套件编译不过,需修测试固件代码)")
return 1
if args.check_build:
print("[OK] 全部套件编译通过(--check-build:未烧录、未采集)")
else:
print("[OK] 全部套件 PASS")
return 0
if __name__ == "__main__":
sys.exit(main())