Files
STM32F4-Base/test/host/verify_log.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

193 lines
8.0 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
"""verify_log.py - 对抓取的 MCU 调试日志做标记校验(纯标准库,2026-09-20 替代 verify_log.ps1)。
用于 @test 闭环:烧录后从串口助手日志提取的开机日志交给本脚本 grep 标记,
输出结构化判定(格式与旧 PowerShell 版逐字一致,下游文档/习惯不受影响)。
用法:
& '.venv\\Scripts\\python.exe' test/host/verify_log.py --log-path <file>
[--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 / 汇总行要求不满足)
2 = ERROR(日志文件不存在/不可读)
默认 Required 标记对应 STM32F4-Base 启动序列(见 App/app/app_main.c + App/task/net_task.c),
按测试用例覆盖,例如:
--required 'App init OK;net init: OK;PHY: 100M FULL'
--forbidden 'FAIL;ERROR;HardFault;assert;[ERR]'
语义约定(与旧 PowerShell 版对齐):
- Required:大小写敏感子串匹配(全文)
- Forbidden:大小写**不敏感**子串匹配、逐行计数(旧版 -like 语义;
如 Forbidden 'FAIL' 会命中 "write /log/run.log fail (7)" 这类行)
- 固件错误级日志前缀是 `[ERR]`(`DBG_ERROR`,形如 `[时间][ERR][TAG] 消息`),
与 `ERROR` 子串**不匹配**——默认 Forbidden 因此显式包含 `[ERR]`,驱动/模块
错误行不再漏检(2026-09-21 修复)。测试套件中**预期**出现 `[ERR]` 的用例
(如 GD5F DMA 守卫负路径、FatFs 非法名校验)须用 --forbidden 覆盖默认值。
- 读取编码:先按 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<name>[^:=]+?)\s*:\s*(?P<passed>\d+)\s*/\s*(?P<total>\d+)\s+"
r"PASSED\s*\(\s*failed\s*=\s*(?P<failed>\d+)\s*\)\s*==="
)
def _split_list(spec):
"""';' 分隔列表 → 去空白、去空项。"""
return [p.strip() for p in spec.split(";") if p.strip()]
def _read_lines(path):
raw = Path(path).read_bytes()
for enc in ("utf-8", locale.getpreferredencoding(False)):
try:
return raw.decode(enc).splitlines()
except UnicodeDecodeError:
continue
return raw.decode("utf-8", errors="replace").splitlines()
def verify(log_path, required=REQUIRED_DEFAULT, forbidden=FORBIDDEN_DEFAULT,
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))
return 2
try:
lines = _read_lines(log_path)
except OSError:
out("VERIFY_ERROR: cannot read log file: {}".format(log_path))
return 2
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))
missing = []
for m in req_list:
if m in full:
out("[OK] required marker found: {}".format(m))
else:
out("[MISS] required marker NOT found: {}".format(m))
missing.append(m)
hits = []
for f in forb_list:
fl = f.lower()
n = sum(1 for ln in lines if fl in ln.lower())
if n > 0:
out("[HIT] forbidden pattern: {} ({} line(s))".format(f, n))
hits.append(f)
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))
for ln in lines[-show_tail:]:
out(ln)
out("")
if missing or hits or bad_summary:
out("VERDICT: FAIL")
return 1
out("VERDICT: PASS")
return 0
def main(argv=None):
# 控制台输出固定 UTF-8:Windows 代码页(GBK/UTF-8 终端)均不会让本脚本崩溃
for stream in (sys.stdout, sys.stderr):
try:
stream.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
ap = argparse.ArgumentParser(description="MCU debug log marker check (pure stdlib)")
ap.add_argument("--log-path", required=True, help="boot log file to check")
ap.add_argument("--required", default=REQUIRED_DEFAULT,
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 '=== <name>: 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, suite_summary=args.require_suite_summary)
if __name__ == "__main__":
sys.exit(main())