测试:落地测试架构改进 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/开关切换/异常还原均实测通过。
This commit is contained in:
+64
-6
@@ -8,11 +8,12 @@
|
||||
& '.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)
|
||||
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<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):
|
||||
"""';' 分隔列表 → 去空白、去空项。"""
|
||||
@@ -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 '=== <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)
|
||||
return verify(args.log_path, args.required, args.forbidden,
|
||||
args.show_tail_lines, suite_summary=args.require_suite_summary)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user