Files
STM32F4-Base/test/run_all_suites.py
T
wandering1 5f1057ac74 test(P1-10): 套件回归 forbidden 换成显式四 Fault 标签 + 台架记录 21/21
台架第一轮(boot log 20261025_050539)判 VERDICT FAIL,命中 469 条 [ERR],两类:
  1) [ERR][CH395] ch395f P2 boundary fault @get_state sn —— **设计内**:Phase 15 的
     sn 越界负向用例故意命中驱动守卫(Drivers/BSP/CH395F/ch395f.c 的
     ch395f_p2_fault_note():首次 DBG_ERROR、之后只计数),用例本身 PASS;
  2) [ERR][WD] task stall ×468 —— **设计外**,已在 a9ba8ec 单独修(掩码死分支),
     不用豁免去掩盖它。
本笔只管判据:套件回归这条路要豁免的是"设计内守卫 [ERR]",不是崩溃。

为什么不能只是把 [ERR] 从 forbidden 里删掉(核过 verify_log 源码与固件崩溃行):
四种 Fault 的崩溃行是 [ERR] HARDFAULT / MEMFAULT / BUSFAULT / USAGEFAULT
(Src/stm32f4xx_it.c 的 hard_fault_report 先打 "[ERR] " 再打标签),而 verify_log 的
forbidden 是**大小写不敏感的子串匹配**(fl in ln.lower())→ 默认集合
HardFault;assert;[ERR] 里的 HardFault 只兜得住硬Fault,**MEM/BUS/USAGE 三种平时
完全靠 [ERR] 兜**。据此:
- 新增 FORBIDDEN_SUITE =
  HardFault;MEMFAULT;BUSFAULT;USAGEFAULT;assert;bring-up FAIL
- CH395F 用它(原来吃默认值,含 [ERR]);
- GD5F 也用它——旧值 "HardFault;assert" 正是上面那个漏法(当初只为豁免
  TC-GD5F-704 的设计内 [ERR] 就把整条 [ERR] 摘了,等于顺手放走了三种 Fault),
  现在既保留豁免意图,又把崩溃判定补齐;
- STORAGE / RTC 不动(它们没有设计内 [ERR],继续吃含 [ERR] 的默认判据)。
边界写死在注释里:产品 @test 的判据(.serialtest/config.json 的 forbidden 含 [ERR])
**一个字节没动**,豁免只活在"套件回归"这条路上,渗不回产品路径。

test/board/ch395f_test.h:
- Phase 15 汇总实测 21/21(旧注释写"台架 20/20 PASS"是历史记录,已按本轮改正);
- 记一条台架档案:镜像 ch395f_hw_nopc_v2.hex 单次开机跑完 1/12/15,汇总行
  9/9、1/1、21/21,failed=0,全程无 Fault;并记下同轮暴露的掩码死分支结论与它的
  证据位置,后来人切阶段时不必再踩一遍。

验证:
- py_compile 通过;test/run_all_suites.py --check-build --suites CH395F 走**官方编排**
  实跑 PASS(BUILD_OK 0 错 0 警,Code=217468 RO=21452 RW=1268 ZI=134076),脚本自己
  还原 test_config.h 并重建产品固件;这个 Code 与上一笔实测表里"默认阶段 11/14"一行
  逐项相同 → 两个独立工具互证同一组数字。
- gate:id-check / no-mojibake / trailing-whitespace / end-of-file-fixer /
  doc-links / cppcheck(test/) / test-suite-switch(产品态 Skipped)全 0;
  未用 --no-verify。
- RAM/Flash 增量:0 B(Python 与注释,不进固件;test/board/*.h 的注释不改代码)。

下一轮台架等 lead:f2 镜像我会在 sync 到 A-3/B-3 之后重编(现在这份
ci-artifacts/p110_fw/ch395f_hw_nopc_f2.hex 戳 05:23:03,若中途主干动过固件就要重编),
判据用上面的 FORBIDDEN_SUITE;请顺手带回两个数:[ERR][WD] 总条数、mask 含 bit4(ui)
缺位的条数。
2026-10-25 05:36:25 +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 测试架构评审 T-5):
套件互斥(`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,含套件汇总行校验 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 --check-build # 只编译不烧录(S6,无需 MCU)
& '.venv\\Scripts\\python.exe' test/run_all_suites.py --keep-config # 不还原(调试用,慎)
--check-build(S6 / 上游 P1-10):
只做"改宏 + @build",不烧录不采集,故**不需要 MCU / 串口 / ST-LINK**。
用途:① 改动测试固件或 test_config.h 后自检 4 套件仍可编译;
② 防止"某套件长期编不过而无人知"(2026-08~09 CH395F 套件实例)。
注意 RAM 溢出(P1-9)会自然暴露为链接错误 L6406E——这正是想要的。
设计依据: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"
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="多套件回归编排(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("--check-build", action="store_true",
help="只编译不烧录(S6/P1-10):逐套件改宏 + @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:编译通过即达标,不烧录不采集(S6)
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) 采集 + 判读(含套件汇总行校验 T-2)
# 烧录统一走 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())