Files
STM32F4-Base/test/host/run_boot_test.py
T
wandering1 2055de0303 测试:落地测试架构改进 T-1~T-5(套件开关门禁/汇总行校验/通用 harness/公共断言宏/多套件编排)
背景:L0.5 主机侧单测落地后复审测试架构,给出 6 条改进意见(P0~P3),
登记进 docs/待处理内容.md「测试与验证基础设施」后逐条实施。

- T-1(P0) 测试套件开关门禁:新增 scripts/check_test_suite.py + pre-commit 钩子
  test-suite-switch,默认要求产品构建(全部 TEST_SUITE_* 注释),跑套件回归用
  --allow-test-build / ALLOW_TEST_BUILD=1 显式放行。顺带把 test_config.h 中长期
  开启的 TEST_SUITE_CH395F 改回注释(仓库此前躺着的是测试固件)。
- T-2(P1) verify_log.py 增加 --require-suite-summary:要求
  "=== <名称>: N/M PASSED (failed=0) ===" 且 N>0,堵住"套件没跑到汇总行/
  断言数为 0 仍判 PASS"的盲区;run_boot_test.py 同名参数透传。不传参向后兼容。
- T-3(P1) 抽取通用 harness test/host/common/unit_test_harness.py(工具链探测/
  INCLUDE-LIB 拼接/wd4819/GBK 解码/退出码契约);watchdog 运行器 170→55 行,只声明 TestBuild。
- T-4(P2) 新增 test/board/test_common.h 集中断言内核(TEST_CHECK_IMPL/TEST_REPORT_IMPL),
  4 套件各用一行宏绑定自身统计变量(保持 g_xxx_stats 独立、防跨套件串数)。
  刻意不 include dbg_log.h——否则冻结 DBG_TAG 默认值触发 #47-D 重定义警告。
- T-5(P2) 新增 test/run_all_suites.py 多套件回归编排,try/finally 无条件还原套件开关。

验证:@build 产品构建 0 错 0 警;4 套件逐一构建均 0/0;看门狗单测 39/39 PASS;
pre-commit 14 钩子全通过;编排脚本 dry-run/开关切换/异常还原均实测通过。
2026-10-01 07:28:57 +08:00

224 lines
9.4 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
"""run_boot_test.py - 一键开机日志抓取 + 校验(纯标准库,2026-09-20 替代 run_boot_test.ps1)。
调用仓库内置串口助手(tools/serial_tool,常驻 daemon,子进程 CLI + JSON):
1. open 串口 + clear 日志(本次运行独立代际)
2. 烧录(默认 MDK-ARM\\download.bat:下载 + Reset & Run)
3. wait-idle:RX 静默 >= settle-ms,或 max-wait-ms 超时
4. 从日志提取 [RX] 载荷行(剥掉 "[RX] <ISO时间戳> " 前缀)
5. verify_log.py 标记校验
采集在烧录**之前**武装(open + clear 先于烧录),daemon 常驻全量日志兜底,
无采集窗口/首字节等待仪式(与旧 PowerShell 版行为一致)。
相对旧 PowerShell 版的加固(本机 Windows 环境实测教训,2026-09-20):
- 子进程输出按**字节**读取、显式 UTF-8 解码 —— 不受控制台代码页(GBK)影响
- serial_config.json 按 UTF-8(容忍 BOM)解析 —— 无 PS 5.1 按 GBK 读 JSON
导致 "Unterminated string" 崩溃的问题
- 不经 PowerShell —— 无执行策略(scripts disabled)拦截
- 烧录带超时(默认 180s;旧版无超时,坏 SWD 连接会永久挂起)
用法:
& '.venv\\Scripts\\python.exe' test/host/run_boot_test.py
[--port COM7] [--baud-rate 115200] [--settle-ms 8000] [--max-wait-ms 30000]
[--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(单一配置源)
退出码(与旧版一致):
0 = PASS(采集成功 + Required 全中 + Forbidden 零命中)
1 = FAIL(标记缺失/命中)
2 = 采集或烧录错误
3 = 用法/预检错误
"""
import argparse
import json
import subprocess
import sys
import tempfile
import time
from datetime import datetime
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
REPO_ROOT = SCRIPT_DIR.parent
SERIAL_TOOL = REPO_ROOT / "tools" / "serial_tool" / "serial_tool.py"
CFG_FILE = REPO_ROOT / ".opencode" / "serial_config.json"
DEFAULT_FLASH = REPO_ROOT / "MDK-ARM" / "download.bat"
REQUIRED_DEFAULT = "App init OK;Network Init;net init: OK"
FORBIDDEN_DEFAULT = "FAIL;ERROR;HardFault;[ERR]"
def _cli(cmd_args, timeout):
"""调用串口助手 CLI,返回 (rc, stdout_text, parsed_json_or_None)。
全程字节级处理(capture_output 取 bytes 后显式 UTF-8 解码),
任何 Windows 控制台代码页都不参与数据路径。
"""
try:
proc = subprocess.run(cmd_args, capture_output=True, timeout=timeout)
except subprocess.TimeoutExpired:
return 2, "client timeout ({}s)".format(timeout), None
text = proc.stdout.decode("utf-8", errors="replace")
try:
return proc.returncode, text, json.loads(text)
except ValueError:
return proc.returncode, text, None
def _tool_cmd(py, cmd, *rest, timeout=15):
rc, out, data = _cli([py, str(SERIAL_TOOL), cmd, *rest], timeout)
if rc != 0 or data is None:
return None, out
return data, out
def main(argv=None):
# 控制台输出固定 UTF-8:Windows 代码页只影响显示,不会让脚本崩溃
for stream in (sys.stdout, sys.stderr):
try:
stream.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
ap = argparse.ArgumentParser(description="boot log capture + verify (pure stdlib, no PowerShell)")
ap.add_argument("--port", default="",
help="serial port (default: portName from .opencode/serial_config.json)")
ap.add_argument("--baud-rate", type=int, default=115200)
ap.add_argument("--settle-ms", type=int, default=8000,
help="RX silence that ends the capture")
ap.add_argument("--max-wait-ms", type=int, default=30000,
help="overall wait-idle timeout")
ap.add_argument("--flash-cmd", default="",
help="flash command (default: MDK-ARM/download.bat; pass reset.bat for reset-only)")
ap.add_argument("--flash-timeout", type=int, default=180,
help="flash subprocess timeout in seconds")
ap.add_argument("--required", default=REQUIRED_DEFAULT,
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 '=== <name>: 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)
# 串口助手 CLI 用当前解释器(AGENTS.md 统一入口 = 仓库 .venv python)
py = sys.executable
# 端口缺省:单一配置源 .opencode/serial_config.json(UTF-8,容忍 BOM)
port = args.port
if not port and CFG_FILE.is_file():
try:
cfg = json.loads(CFG_FILE.read_text(encoding="utf-8-sig"))
port = str(cfg.get("portName") or "")
except (OSError, ValueError) as e:
print("RUN_TEST_ERROR: cannot parse {}: {}".format(CFG_FILE, e))
return 3
if not port:
print("RUN_TEST_ERROR: no --port given and no portName in {}".format(CFG_FILE))
return 3
flash_cmd = Path(args.flash_cmd) if args.flash_cmd else DEFAULT_FLASH
# 预检(对应旧版 missing-file 检查)
for p in (SERIAL_TOOL, SCRIPT_DIR / "verify_log.py", flash_cmd):
if not p.is_file():
print("RUN_TEST_ERROR: missing {}".format(p))
return 3
out_dir = Path(args.out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
boot_txt = out_dir / "boot_log_{}.txt".format(stamp)
def tool(cmd, *rest, timeout=15):
return _tool_cmd(py, cmd, *rest, timeout=timeout)
print("=== Boot test {} ===".format(stamp))
print("Port: {} @ {} settle={}ms max={}ms".format(
port, args.baud_rate, args.settle_ms, args.max_wait_ms))
# 1. open(幂等;未运行则自动拉起常驻 daemon)
data, out = tool("open", "--port", port, "--baud", str(args.baud_rate), "--timeout", "15")
if data is None:
print("RUN_TEST_ERROR: open failed: {}".format(out))
return 2
# 2. clear(新代际;此后数据才属于本次运行)
data, out = tool("clear", "--timeout", "15")
if data is None:
print("RUN_TEST_ERROR: clear failed: {}".format(out))
return 2
# 3. flash(下载 + Reset & Run)。烧录失败不中断:板子可能已复位,
# 由采集 + 标记判定结果(与旧版一致)
print("FLASH_START: {}".format(flash_cmd))
t0 = time.monotonic()
try:
proc = subprocess.run(["cmd", "/c", str(flash_cmd)],
capture_output=True, timeout=args.flash_timeout)
flash_rc = proc.returncode
flash_bytes = (proc.stdout or b"") + (proc.stderr or b"")
except subprocess.TimeoutExpired as e:
flash_rc = -1
flash_bytes = (e.stdout or b"") + (e.stderr or b"")
elapsed_ms = int((time.monotonic() - t0) * 1000)
for line in flash_bytes.decode("utf-8", errors="replace").strip().splitlines()[-12:]:
print(line)
print("FLASH_RC: {} elapsed_ms={}".format(flash_rc, elapsed_ms))
if flash_rc != 0:
print("RUN_TEST_WARN: flash rc={} (continuing: board may still have reset)".format(flash_rc))
# 4. wait-idle(RX 静默收尾或整体超时)
wait_timeout_s = args.max_wait_ms // 1000 + 20
data, out = tool("wait-idle",
"--settle-ms", str(args.settle_ms),
"--max-ms", str(args.max_wait_ms),
"--timeout", str(wait_timeout_s),
timeout=wait_timeout_s + 15)
if data is None:
print("RUN_TEST_ERROR: wait-idle failed: {}".format(out))
return 2
print("WAIT: reason={} rx_bytes={} waited_ms={}".format(
data.get("reason"), data.get("rx_bytes"), data.get("waited_ms")))
if data.get("reason") == "no_data":
print("RUN_TEST_ERROR: no RX data at all (check port / board power / flash)")
return 2
# 5. 提取 [RX] 载荷行(剥掉 "[RX] <ISO时间戳> " 前缀)
data, out = tool("log", "--tail", "0", "--timeout", "15")
if data is None:
print("RUN_TEST_ERROR: log read failed: {}".format(out))
return 2
rx = []
for l in data.get("lines", []):
if isinstance(l, str) and l.startswith("[RX] "):
rest = l[5:]
sp = rest.find(" ")
rx.append(rest[sp + 1:] if sp >= 0 else rest)
if not rx:
print("RUN_TEST_ERROR: no [RX] lines in log (device silent?)")
return 2
boot_txt.write_bytes(("\r\n".join(rx) + "\r\n").encode("utf-8"))
print("Boot log: {} (lines={} gen={})".format(boot_txt, len(rx), data.get("gen")))
# 6. 标记校验
from verify_log import verify
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
if __name__ == "__main__":
sys.exit(main())