fix(测试工具): 自检台账判据改为只看本次启动新增(修假绿)
接上一笔(FTP 凭据)之后,`@test --require-selftest-pass` 仍不可用——它其实是**三个 bug 叠
在一起**,这次一并收口。
**① 抓取失败被当成"设备干净"**:`_fetch_selftest_log()` 把"设备侧无此文件"(正常路径,
"仅故障才写"语义下干净设备本就没有该文件)和"连不上/登录被拒/FTP 故障"(结果**未知**)
都返回 `""`,调用方一律按"无记录 → PASS"处理。叠加上一笔修掉的匿名登录 530,这条门禁
**从写下那天起一次都没真正读到过那个文件**。现在 fetch 返回 `(路径, 状态)`,状态 ∈
{ok, absent, error},三者分开判。
**② 判据本身判不了"一次启动"**:`/log/selftest.log` 是 **512 条环形累计历史**(设计文档
§4.2 容量表),而旧判据是"文件非空即 FAIL"——在任何做过故障注入或长跑的设备上**永远不
可能 PASS**。本次实机抓到的 29 条记录,时间戳全是 2026-10-10 ~ 10-14,没有一条来自本次。
新判据 `--selftest-scope this-run`(默认):**烧录之前**先取基线,启动后再取一次,只把
"基线最后一条之后"的记录交给判定(差集落成临时副本,命名落在 `selftest_log_*.log` 里,
才会被 `--keep-out` 的保留规则收走)。
**③ 取基线本身会污染本次启动日志**(我这次实测踩到的):抓基线是一次真实 FTP 会话——
设备把 `USER/PASS/RETR` 打到串口上,客户端退出时还多打一行 `[ERR] [FTP] error reading
next command`。放在 `open/clear`(采集武装)之后,这些行就落进本次启动日志:Forbidden
`[ERR]` 命中 → 本次 FAIL,开机耗时从 612 ms 被拉到 **7997 ms**。所以基线固定在 open 之前,
并把这条写进文档当规避口径。
**判定表**(`docs/测试说明/自动化测试流程.md` §自检结果判定整段重写):无此文件=PASS;
本次无新增=PASS;本次有新增=FAIL;连不上/登录被拒/FTP 故障=**FAIL**;既无 `device_ip`
又无 `--selftest-log`=**FAIL**(要求判定却没有数据源,不能再"视为无记录");基线最后一条
被 512 条环覆盖(更新量 ≥ 环容)= **FAIL**(不拿"看不到"当"没有")。后四种"判不了"由调用
方在 `verify()` 之后强制收口成 RC=1——否则标记全中就把"没判"读成"判过了、没问题"。
`--selftest-scope all` 保留旧的累计口径(审计 / 第一次建基线用);给了 `--selftest-log`
(本地副本)就没有基线可比 → 自动降级为 all 并打印一行说明;FTP 凭据 `--ftp-user`/
`--ftp-pass`(config `ftp_user`/`ftp_pass`,默认出厂 admin/admin)。退出码 1 的含义、
`verify_log.py` 的语义条目、SKILL.md 参数表(+2 行)、config.example.json(+3 键)同步。
**真机三跑,三条路径各自验过**:
· 默认 this-run + `--device-ip` → **PASS / RC=0**:`基线 29 条 → 台账不变`、boot banner ×1、
`net init: OK` **612 ms**(证明污染已消除)、Forbidden 零命中。
· `--selftest-scope all` → **FAIL / RC=1**:`29 fault record(s)`(累计口径仍可用)。
· 不给 `--device-ip` → **FAIL / RC=1**:`verify_log` 那侧显示 `no fault record → PASS`,
调用方收口为 `RUN_TEST_VERDICT: FAIL —— 自检台账判定未能执行`(正是要的行为)。
门禁:`py_compile`(run_boot_test + verify_log)通过;`check_doc_links.py --all
--sections-strict` 146 文档 0 问题;`check_ids.py --strict` 增量拦截通过(可判定 82.82%,
在地板之上);6 个文件字节卫生 BOM 无 / CR 0 / U+FFFD 0 / 无行尾空格。
**仍待办(本笔未做)**:设备侧"FTP 客户端中途退出"按 ERROR 级打日志(要不要降级);lftpd
的 LIST 传完回 550 而非 226;设备 `/` 残留约 24 个旧探针文件;`MDK-ARM/` 下 4 个一次性
探针脚本仍写匿名。未推送。
This commit is contained in:
@@ -47,7 +47,9 @@ whenToUse: 需要向目标板烧录或复位后,用串口日志自动判定运
|
|||||||
| `done_marker` + `max_boot_ms` | 开机耗时口径与上限 | 空/0 = 只报告不判 |
|
| `done_marker` + `max_boot_ms` | 开机耗时口径与上限 | 空/0 = 只报告不判 |
|
||||||
| `settle_ms` / `max_wait_ms` / `first_wait_ms` | RX 静默判据 / 总超时 / 首字节超时 | 8000 / 30000 / 12000 |
|
| `settle_ms` / `max_wait_ms` / `first_wait_ms` | RX 静默判据 / 总超时 / 首字节超时 | 8000 / 30000 / 12000 |
|
||||||
| `require_order` / `expect_reset_cause` / `require_suite_summary` | 顺序校验 / 复位原因白名单 / 套件汇总行 | 空 = 关闭 |
|
| `require_order` / `expect_reset_cause` / `require_suite_summary` | 顺序校验 / 复位原因白名单 / 套件汇总行 | 空 = 关闭 |
|
||||||
| `require_selftest_pass` + `device_ip` | 设备侧自检台账联合判定(FTP 抓取) | 关闭 |
|
| `selftest_scope` | 自检台账判定口径:`this-run`(烧录前取基线,只判新增)/ `all`(累计,任一记录即 FAIL) | `this-run` |
|
||||||
|
| `ftp_user` / `ftp_pass` | 抓 `/log/selftest.log` 用的 FTP 账号(设备无匿名通道) | `admin` / `admin` |
|
||||||
|
| `require_selftest_pass` + `device_ip` | 设备侧自检台账联合判定(FTP 抓取;**默认只判本次启动新增**,抓取失败/无数据源一律 FAIL) | 关闭 |
|
||||||
|
|
||||||
**先跑项目的构建**(本仓库 = `keil-build` skill,其它 Keil 项目照该 skill 套;
|
**先跑项目的构建**(本仓库 = `keil-build` skill,其它 Keil 项目照该 skill 套;
|
||||||
非 Keil 项目用项目自己的构建入口),确认 0 错误 0 警告后再进闭环。
|
非 Keil 项目用项目自己的构建入口),确认 0 错误 0 警告后再进闭环。
|
||||||
|
|||||||
@@ -24,6 +24,9 @@
|
|||||||
|
|
||||||
"require_selftest_pass": false,
|
"require_selftest_pass": false,
|
||||||
"device_ip": "",
|
"device_ip": "",
|
||||||
|
"selftest_scope": "this-run",
|
||||||
|
"ftp_user": "admin",
|
||||||
|
"ftp_pass": "admin",
|
||||||
|
|
||||||
"httpPort": 18765
|
"httpPort": 18765
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,9 +19,12 @@
|
|||||||
5. verify_log.py 标记校验(含启动锚点计数 config "boot_marker" + --max-boot-count;
|
5. verify_log.py 标记校验(含启动锚点计数 config "boot_marker" + --max-boot-count;
|
||||||
--expect-reset-cause 原因白名单;--require-order 标记顺序;
|
--expect-reset-cause 原因白名单;--require-order 标记顺序;
|
||||||
--done-marker + --max-boot-ms 开机耗时)
|
--done-marker + --max-boot-ms 开机耗时)
|
||||||
6. (仅当 --require-selftest-pass)自检记录联合判定(S7):
|
6. (仅当 --require-selftest-pass)自检台账联合判定(S7):默认**只判本次启动新增**
|
||||||
--selftest-log 提供本地副本则直接用它,否则从设备 FTP 抓取 /log/selftest.log
|
——烧录前先经 FTP 取 /log/selftest.log 基线,启动后再取一次,只把"基线之后新增"的
|
||||||
(需 config "device_ip");判定:文件缺失 / 0 字节 = 无故障 → PASS
|
记录交给判定(该文件是 512 条环形累计历史,"非空即 FAIL"判一次启动永远不可能过)。
|
||||||
|
设备侧无此文件 = 台账干净 = PASS;**抓取失败 / 没有数据源 / 基线被环覆盖 → FAIL**,
|
||||||
|
不再"视为无记录"放过。累计口径用 --selftest-scope all;--selftest-log 走本地副本
|
||||||
|
(无基线可比,自动降级为 all)。FTP 凭据 --ftp-user/--ftp-pass(默认出厂 admin)。
|
||||||
|
|
||||||
采集在烧录**之前**武装(open + clear 先于烧录),daemon 常驻全量日志兜底,
|
采集在烧录**之前**武装(open + clear 先于烧录),daemon 常驻全量日志兜底,
|
||||||
无采集窗口/首字节等待仪式。
|
无采集窗口/首字节等待仪式。
|
||||||
@@ -46,7 +49,7 @@
|
|||||||
[--flash-cmd "<烧录命令>"] [--flash-timeout 180]
|
[--flash-cmd "<烧录命令>"] [--flash-timeout 180]
|
||||||
[--required "m1;m2"] [--forbidden "FAIL;ERROR;HardFault"]
|
[--required "m1;m2"] [--forbidden "FAIL;ERROR;HardFault"]
|
||||||
[--require-suite-summary "Storage Tests"]
|
[--require-suite-summary "Storage Tests"]
|
||||||
[--require-selftest-pass] [--selftest-log <本地副本>]
|
[--require-selftest-pass [--selftest-scope this-run|all]] [--selftest-log <副本>]
|
||||||
[--device-ip 192.168.1.100] [--out-dir <目录>] [--show-tail-lines 15]
|
[--device-ip 192.168.1.100] [--out-dir <目录>] [--show-tail-lines 15]
|
||||||
[--fw-hex <构建产物 hex>] [--no-fw-check]
|
[--fw-hex <构建产物 hex>] [--no-fw-check]
|
||||||
[--no-verify-flash] [--readback-timeout 120]
|
[--no-verify-flash] [--readback-timeout 120]
|
||||||
@@ -58,7 +61,7 @@
|
|||||||
|
|
||||||
退出码(与旧版一致):
|
退出码(与旧版一致):
|
||||||
0 = PASS(采集成功 + Required 全中 + Forbidden 零命中)
|
0 = PASS(采集成功 + Required 全中 + Forbidden 零命中)
|
||||||
1 = FAIL(标记缺失/命中)
|
1 = FAIL(标记缺失/命中;或要求自检台账判定却判不了——无数据源/抓取失败/无法证明无新增)
|
||||||
2 = 采集或烧录错误(含烧录回读不一致 = 芯片内容与刚编的镜像不同,本次运行作废)
|
2 = 采集或烧录错误(含烧录回读不一致 = 芯片内容与刚编的镜像不同,本次运行作废)
|
||||||
3 = 用法/预检错误
|
3 = 用法/预检错误
|
||||||
"""
|
"""
|
||||||
@@ -240,11 +243,53 @@ def _prune_out_dir(out_dir, keep):
|
|||||||
return removed
|
return removed
|
||||||
|
|
||||||
|
|
||||||
|
def _selftest_records(path):
|
||||||
|
"""把 selftest.log 的本地副本切成 64 B 定长记录(尾部残块不算记录)。
|
||||||
|
|
||||||
|
返回 [] = 台账为空(干净设备);返回 None = **未知**(文件不可读等)。
|
||||||
|
两者绝不能混——把"看不见"当成"没有"正是假绿的成因。
|
||||||
|
"""
|
||||||
|
if not path:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
raw = Path(path).read_bytes()
|
||||||
|
except OSError as exc:
|
||||||
|
print("RUN_TEST_WARN: 自检台账本地副本不可读: {}".format(exc))
|
||||||
|
return None
|
||||||
|
n = len(raw) - (len(raw) % 64)
|
||||||
|
return [raw[i:i + 64] for i in range(0, n, 64)]
|
||||||
|
|
||||||
|
|
||||||
|
def _selftest_new_records(baseline, after):
|
||||||
|
"""基线对比 → 本次运行新增的记录,返回 (新增列表, 能否证明)。
|
||||||
|
|
||||||
|
· baseline is None → 基线未知 → 无法证明
|
||||||
|
· baseline == [] → 设备原本无台账 → after 全部算本次新增
|
||||||
|
· 基线最后一条在 after 里找得到 → 它之后的就是新增
|
||||||
|
· 找不到 → 512 条环已覆盖基线(更新量 ≥ 环容)→ 无法证明;
|
||||||
|
宁可 FAIL,也不拿"看不到"当"没有"
|
||||||
|
"""
|
||||||
|
if baseline is None or after is None:
|
||||||
|
return [], False
|
||||||
|
if not baseline:
|
||||||
|
return list(after), True
|
||||||
|
last = baseline[-1]
|
||||||
|
for idx in range(len(after) - 1, -1, -1):
|
||||||
|
if after[idx] == last:
|
||||||
|
return list(after[idx + 1:]), True
|
||||||
|
return [], False
|
||||||
|
|
||||||
def _fetch_selftest_log(device_ip, out_dir, stamp, ftp_user="admin", ftp_pass="admin"):
|
def _fetch_selftest_log(device_ip, out_dir, stamp, ftp_user="admin", ftp_pass="admin"):
|
||||||
"""从设备 FTP 抓取 /log/selftest.log(lftpd :21 需鉴权,根目录 = 卷根)。
|
"""从设备 FTP 抓取 /log/selftest.log(lftpd :21 需鉴权,根目录 = 卷根)。
|
||||||
|
|
||||||
成功返回本地路径字符串;失败(网络不可达 / FTP 错误 / 设备侧文件缺失)返回 ""
|
返回 (本地路径, 状态),状态 ∈ {"ok", "absent", "error"}:
|
||||||
(按 S7 语义视为无故障记录;此处打 WARN,调用方不判整体 FAIL)。
|
ok = 抓到文件(路径有效)
|
||||||
|
absent = 设备侧确实没有这个文件 = 台账干净(正常路径,不是故障)
|
||||||
|
error = 连不上 / 登录被拒 / FTP 故障 → 结果**未知**,调用方必须判 FAIL
|
||||||
|
|
||||||
|
⚠ 2026-10-22 关键更正:旧实现把 absent 与 error 都返回 "",调用方一律"视为无
|
||||||
|
记录 → PASS"。于是抓取失败会被读成"设备很干净"——这就是那条假绿。三种结果
|
||||||
|
必须分开。
|
||||||
|
|
||||||
2026-10-02 台架修复:原实现只 `except OSError`,而 ftplib 的 error_temp /
|
2026-10-02 台架修复:原实现只 `except OSError`,而 ftplib 的 error_temp /
|
||||||
error_perm 继承自 Exception 而非 OSError —— 台账"仅故障才写",干净设备上
|
error_perm 继承自 Exception 而非 OSError —— 台账"仅故障才写",干净设备上
|
||||||
@@ -260,24 +305,24 @@ def _fetch_selftest_log(device_ip, out_dir, stamp, ftp_user="admin", ftp_pass="a
|
|||||||
# 设备没有匿名通道——匿名登录一律 530,所以这里必须带凭据。
|
# 设备没有匿名通道——匿名登录一律 530,所以这里必须带凭据。
|
||||||
ftp.login(ftp_user, ftp_pass)
|
ftp.login(ftp_user, ftp_pass)
|
||||||
except (OSError, ftplib.Error) as e:
|
except (OSError, ftplib.Error) as e:
|
||||||
print("RUN_TEST_WARN: selftest fetch failed: {} "
|
print("SELFTEST_FETCH: {} -> ERROR ({}) 连不上/登录被拒/FTP 故障 → 状态未知"
|
||||||
"(按 S7 语义视为无记录;设备网络/FTP 需另行排查)".format(e))
|
.format(device_ip, e))
|
||||||
return ""
|
return "", "error"
|
||||||
|
|
||||||
buf = bytearray()
|
buf = bytearray()
|
||||||
try:
|
try:
|
||||||
ftp.retrbinary("RETR /log/selftest.log", buf.extend)
|
ftp.retrbinary("RETR /log/selftest.log", buf.extend)
|
||||||
except ftplib.all_errors as e:
|
except ftplib.all_errors as e:
|
||||||
# 450/550 = 设备侧无此文件:干净台账,S7 判 PASS(正常路径,非故障)
|
# 450/550 = 设备侧无此文件:干净台账,S7 判 PASS(正常路径,非故障)
|
||||||
print("SELFTEST_FETCH: {} -> (ledger absent: {}) 按 S7 视为无故障记录"
|
print("SELFTEST_FETCH: {} -> (ledger absent: {}) 台账干净"
|
||||||
.format(device_ip, str(e).strip()))
|
.format(device_ip, str(e).strip()))
|
||||||
_ftp_quit(ftp)
|
_ftp_quit(ftp)
|
||||||
return ""
|
return "", "absent"
|
||||||
|
|
||||||
_ftp_quit(ftp)
|
_ftp_quit(ftp)
|
||||||
local.write_bytes(bytes(buf))
|
local.write_bytes(bytes(buf))
|
||||||
print("SELFTEST_FETCH: {} -> {} ({} bytes)".format(device_ip, local, len(buf)))
|
print("SELFTEST_FETCH: {} -> {} ({} bytes)".format(device_ip, local, len(buf)))
|
||||||
return str(local)
|
return str(local), "ok"
|
||||||
|
|
||||||
|
|
||||||
def main(argv=None):
|
def main(argv=None):
|
||||||
@@ -316,6 +361,11 @@ def main(argv=None):
|
|||||||
"any 64B record = FAIL")
|
"any 64B record = FAIL")
|
||||||
ap.add_argument("--selftest-log", default=None,
|
ap.add_argument("--selftest-log", default=None,
|
||||||
help="local copy of /log/selftest.log (skips device-side fetch when given)")
|
help="local copy of /log/selftest.log (skips device-side fetch when given)")
|
||||||
|
ap.add_argument("--selftest-scope", choices=("this-run", "all"), default=None,
|
||||||
|
help="this-run (default): only records added by THIS run count as "
|
||||||
|
"faults -- needs a pre-flash baseline over FTP, so device_ip is "
|
||||||
|
"required. all: legacy cumulative rule (any record = FAIL); use it "
|
||||||
|
"for audits or a first baseline. Unproveable cases FAIL, never PASS.")
|
||||||
ap.add_argument("--device-ip", default=None,
|
ap.add_argument("--device-ip", default=None,
|
||||||
help="device IP for fetching /log/selftest.log via FTP")
|
help="device IP for fetching /log/selftest.log via FTP")
|
||||||
ap.add_argument("--ftp-user", default=None,
|
ap.add_argument("--ftp-user", default=None,
|
||||||
@@ -390,6 +440,11 @@ def main(argv=None):
|
|||||||
args.forbidden = str(_pick(args.forbidden, "forbidden", default=FORBIDDEN_DEFAULT))
|
args.forbidden = str(_pick(args.forbidden, "forbidden", default=FORBIDDEN_DEFAULT))
|
||||||
args.require_suite_summary = str(_pick(args.require_suite_summary,
|
args.require_suite_summary = str(_pick(args.require_suite_summary,
|
||||||
"require_suite_summary", default="") or "")
|
"require_suite_summary", default="") or "")
|
||||||
|
args.selftest_scope = str(_pick(args.selftest_scope, "selftest_scope",
|
||||||
|
default="this-run") or "this-run")
|
||||||
|
if args.selftest_scope not in ("this-run", "all"):
|
||||||
|
print("RUN_TEST_USAGE_ERROR: selftest_scope must be this-run or all")
|
||||||
|
return 3
|
||||||
args.require_selftest_pass = bool(_pick(args.require_selftest_pass,
|
args.require_selftest_pass = bool(_pick(args.require_selftest_pass,
|
||||||
"require_selftest_pass", default=False))
|
"require_selftest_pass", default=False))
|
||||||
args.selftest_log = str(_pick(args.selftest_log, "selftest_log", default="") or "")
|
args.selftest_log = str(_pick(args.selftest_log, "selftest_log", default="") or "")
|
||||||
@@ -454,6 +509,34 @@ def main(argv=None):
|
|||||||
print("Port: {} @ {} settle={}ms max={}ms".format(
|
print("Port: {} @ {} settle={}ms max={}ms".format(
|
||||||
port, args.baud_rate, args.settle_ms, args.max_wait_ms))
|
port, args.baud_rate, args.settle_ms, args.max_wait_ms))
|
||||||
|
|
||||||
|
# 0.6 自检台账基线(S7「只看本次新增」):**必须排在串口采集武装之前**。
|
||||||
|
# /log/selftest.log 是 512 条环形累计历史,用"文件非空即 FAIL"去判一次启动,在任何
|
||||||
|
# 做过故障注入或长跑的设备上永远不可能 PASS(本仓台架实测:抓到的 29 条记录时间戳
|
||||||
|
# 全是 2026-10-10 ~ 10-14 留下的,与本次启动无关)。这里记下基线的最后一条记录,
|
||||||
|
# 启动后只判"它之后新增了几条";抓不到基线就判不了本次 → 退出码 2,不降级成"视为
|
||||||
|
# 无记录"。
|
||||||
|
# ⚠ 为什么排在 open/clear 之前(2026-10-22 实测踩到):抓基线是一次真实 FTP 会话,
|
||||||
|
# 设备会把 `USER/PASS/RETR` 打到串口上,客户端退出时还会多打一行 `[ERR] [FTP] error
|
||||||
|
# reading next command`——放在采集武装之后,这些行就落进本次启动日志:既让 Forbidden
|
||||||
|
# `[ERR]` 命中(本次判 FAIL),又把开机耗时从 ~0.6 s 拉到 ~8 s(假指标)。
|
||||||
|
st_baseline = None
|
||||||
|
if args.require_selftest_pass and args.selftest_scope == "this-run":
|
||||||
|
if args.selftest_log:
|
||||||
|
print("SELFTEST_BASELINE: 用的是本地副本,无基线可比 → 本次按累计口径判定")
|
||||||
|
args.selftest_scope = "all"
|
||||||
|
elif not args.device_ip:
|
||||||
|
print("SELFTEST_BASELINE: 没有 device_ip 就取不到基线 → 本次按累计口径判定")
|
||||||
|
args.selftest_scope = "all"
|
||||||
|
else:
|
||||||
|
base_local, base_status = _fetch_selftest_log(
|
||||||
|
args.device_ip, out_dir, stamp + "_base", args.ftp_user, args.ftp_pass)
|
||||||
|
if base_status == "error":
|
||||||
|
print("RUN_TEST_ERROR: 自检台账基线抓取失败 → 无法证明本次无新增故障"
|
||||||
|
"(不把抓取失败当成设备干净)")
|
||||||
|
return 2
|
||||||
|
st_baseline = _selftest_records(base_local)
|
||||||
|
print("SELFTEST_BASELINE: 既有记录 {} 条(status={})"
|
||||||
|
.format(len(st_baseline or []), base_status))
|
||||||
# 1. open(幂等;未运行则自动拉起常驻 daemon)
|
# 1. open(幂等;未运行则自动拉起常驻 daemon)
|
||||||
data, out = tool("open", "--port", port, "--baud", str(args.baud_rate), "--timeout", "15")
|
data, out = tool("open", "--port", port, "--baud", str(args.baud_rate), "--timeout", "15")
|
||||||
if data is None:
|
if data is None:
|
||||||
@@ -598,14 +681,50 @@ def main(argv=None):
|
|||||||
print("RUN_TEST_ERROR: flash readback mismatch (chip content != built image)")
|
print("RUN_TEST_ERROR: flash readback mismatch (chip content != built image)")
|
||||||
return 2
|
return 2
|
||||||
|
|
||||||
# 5.5 自检记录抓取(S7):启用且未提供本地副本时才走 FTP(需 config "device_ip")
|
# 5.5 自检记录判定(S7):默认只判「本次启动新增」,累计口径要显式指定。
|
||||||
|
# 三种抓取结果必须分开对待(旧实现把后两种都当"无记录 → PASS",就是假绿):
|
||||||
|
# absent(设备侧无此文件) → 台账干净 → 无新增 → PASS
|
||||||
|
# error(连不上/登录被拒/故障)→ 未知 → FAIL(要求判定却没数据,不能当通过)
|
||||||
|
# 没有数据源(无 device_ip 又无 --selftest-log)→ FAIL(同上)
|
||||||
selftest_local = args.selftest_log
|
selftest_local = args.selftest_log
|
||||||
if args.require_selftest_pass and not selftest_local and not args.device_ip:
|
selftest_forced_fail = False
|
||||||
print("RUN_TEST_WARN: require_selftest_pass but no device_ip/selftest_log "
|
if args.require_selftest_pass:
|
||||||
"-> treated as no record (S7 PASS path)")
|
if selftest_local:
|
||||||
if args.require_selftest_pass and not selftest_local and args.device_ip:
|
pass # 本地副本:按它自己的口径判(基线已在 2.5 降级)
|
||||||
selftest_local = _fetch_selftest_log(args.device_ip, out_dir, stamp,
|
elif not args.device_ip:
|
||||||
args.ftp_user, args.ftp_pass)
|
print("RUN_TEST_FAIL: 要求自检台账判定,但既没有 device_ip 也没有 --selftest-log"
|
||||||
|
" → 无数据源,判 FAIL(旧行为是 WARN 后当无记录放过)")
|
||||||
|
selftest_forced_fail = True
|
||||||
|
else:
|
||||||
|
after_local, after_status = _fetch_selftest_log(
|
||||||
|
args.device_ip, out_dir, stamp, args.ftp_user, args.ftp_pass)
|
||||||
|
if after_status == "error":
|
||||||
|
print("RUN_TEST_FAIL: 自检台账抓取失败(设备不可达 / FTP 故障)→ 判 FAIL"
|
||||||
|
"(不把抓取失败当成设备干净)")
|
||||||
|
selftest_forced_fail = True
|
||||||
|
elif after_status == "absent":
|
||||||
|
selftest_local = None # 空台账 = 确无故障
|
||||||
|
elif args.selftest_scope == "this-run":
|
||||||
|
new_recs, provable = _selftest_new_records(
|
||||||
|
st_baseline, _selftest_records(after_local))
|
||||||
|
if not provable:
|
||||||
|
print("SELFTEST_JUDGE: FAIL —— 无法证明本次无新增"
|
||||||
|
"(基线最后一条已被 512 条环覆盖,或基线/台账不可读)")
|
||||||
|
selftest_forced_fail = True
|
||||||
|
elif not new_recs:
|
||||||
|
print("SELFTEST_JUDGE: 本次启动无新增记录(基线 {} 条 → 台账不变)"
|
||||||
|
.format(len(st_baseline or [])))
|
||||||
|
selftest_local = None
|
||||||
|
else:
|
||||||
|
# 文件名必须落在 selftest_log_*.log 里,才会被 --keep-out 的保留规则收走
|
||||||
|
delta = out_dir / "selftest_log_{}_new.log".format(stamp)
|
||||||
|
delta.write_bytes(b"".join(new_recs))
|
||||||
|
print("SELFTEST_JUDGE: 本次启动新增 {} 条 → 只把新增部分交给判定"
|
||||||
|
.format(len(new_recs)))
|
||||||
|
selftest_local = str(delta)
|
||||||
|
else:
|
||||||
|
selftest_local = after_local # --selftest-scope all:累计口径
|
||||||
|
|
||||||
|
|
||||||
# 6. 标记校验(含固件身份戳、启动锚点计数、可选的自检记录联合判定)
|
# 6. 标记校验(含固件身份戳、启动锚点计数、可选的自检记录联合判定)
|
||||||
# 启动锚点(config "boot_marker",如本仓库的 'reset cause:')未配置 =
|
# 启动锚点(config "boot_marker",如本仓库的 'reset cause:')未配置 =
|
||||||
@@ -625,6 +744,11 @@ def main(argv=None):
|
|||||||
require_order=args.require_order,
|
require_order=args.require_order,
|
||||||
done_marker=args.done_marker,
|
done_marker=args.done_marker,
|
||||||
max_boot_ms=args.max_boot_ms)
|
max_boot_ms=args.max_boot_ms)
|
||||||
|
if selftest_forced_fail and rc == 0:
|
||||||
|
# 标记校验全过,但自检台账判定根本没能执行(无数据源 / 抓取失败 / 无法证明无新增)
|
||||||
|
# —— 这类"判不了"必须以 FAIL 收口,否则就又回到假绿。
|
||||||
|
print("RUN_TEST_VERDICT: FAIL —— 自检台账判定未能执行(见上方 SELFTEST 行)")
|
||||||
|
rc = 1
|
||||||
print("RUN_TEST_RC={}".format(rc))
|
print("RUN_TEST_RC={}".format(rc))
|
||||||
return rc
|
return rc
|
||||||
|
|
||||||
|
|||||||
@@ -41,11 +41,15 @@
|
|||||||
命中即判 PASS,**套件是否跑到汇总行、断言数是否为 0 完全不可见**(套件中途
|
命中即判 PASS,**套件是否跑到汇总行、断言数是否为 0 完全不可见**(套件中途
|
||||||
挂死/复位、或 Phase 宏全关时会被误判为通过)。`--require-suite-summary`
|
挂死/复位、或 Phase 宏全关时会被误判为通过)。`--require-suite-summary`
|
||||||
要求日志中出现 `=== <名称>: N/M PASSED (failed=0) ===` 且 **N > 0**。
|
要求日志中出现 `=== <名称>: N/M PASSED (failed=0) ===` 且 **N > 0**。
|
||||||
- 自检记录联合判定(S7,2026-10 实机窗口):`--require-selftest-pass` 对 /log/selftest.log
|
- 自检记录联合判定(S7,2026-10 实机窗口;判据 2026-10-22 改为"只看本次新增"):
|
||||||
的本地副本(定长 64B 记录,格式见 App/log/sys_log.h)判定。自检仅故障写盘(v1 不记
|
`--require-selftest-pass` 对 /log/selftest.log 的本地副本(定长 64B 记录,格式见
|
||||||
通过项):**文件缺失 / 0 字节 = 无故障 → PASS**;存在任何一条记录 = 有故障 → FAIL
|
App/log/sys_log.h)判定。自检仅故障写盘(v1 不记通过项):**文件缺失 / 0 字节 = 无故障
|
||||||
(退出码 1),并逐条打印时间戳 + 描述。抓取失败(设备不可达 / FTP 错误等)由调用方
|
→ PASS**;存在记录 = FAIL(退出码 1),逐条打印时间戳 + 描述。
|
||||||
(run_boot_test.py)先打 WARN,本地文件按缺失处理 → 按上述规则不判整体 FAIL。
|
⚠ 该文件是 512 条环形**累计历史**,所以本函数收到的"有记录"必须由调用方先做差集:
|
||||||
|
run_boot_test.py 在烧录前取基线、启动后只把"基线之后新增"的记录写成临时副本交给本
|
||||||
|
函数(`--selftest-scope this-run`,默认)。**抓取失败/无数据源/无法证明无新增一律由
|
||||||
|
调用方判 FAIL**——旧实现把这些都当"无记录 → PASS",是假绿(详见
|
||||||
|
docs/测试说明/自动化测试流程.md 的自检结果判定节)。
|
||||||
- 启动锚点计数(2026-10-10):`--max-boot-count N`(run_boot_test.py 默认传 1)统计
|
- 启动锚点计数(2026-10-10):`--max-boot-count N`(run_boot_test.py 默认传 1)统计
|
||||||
`reset cause:` 锚点出现次数——**0 次** = 采集没从复位开始(标记全中也可能只是上一轮
|
`reset cause:` 锚点出现次数——**0 次** = 采集没从复位开始(标记全中也可能只是上一轮
|
||||||
的残留日志);**>N 次** = 板子在采集窗口内自己复位过(IWDG 咬狗 / Fault 后
|
的残留日志);**>N 次** = 板子在采集窗口内自己复位过(IWDG 咬狗 / Fault 后
|
||||||
|
|||||||
+26
-4
@@ -51,10 +51,31 @@
|
|||||||
### 自检结果判定(S7,`--require-selftest-pass`)
|
### 自检结果判定(S7,`--require-selftest-pass`)
|
||||||
|
|
||||||
`run_boot_test.py` / `verify_log.py` 支持 `--require-selftest-pass`:除串口标记外,还判定设备侧自检结论
|
`run_boot_test.py` / `verify_log.py` 支持 `--require-selftest-pass`:除串口标记外,还判定设备侧自检结论
|
||||||
(v1 只记故障、不记通过)——`selftest.log` **缺失 / 0 字节 → 无故障 → PASS**;任一 64B 定长记录
|
(v1 只记故障、不记通过)。判定对象(20 处自检描述串)见 `实机测试实施指南.md` §3.2。
|
||||||
(`filesize/64 > 0`)→ FAIL,并打印故障条目(时间戳 + 描述)便于定位。`run_boot_test.py` 未给本地副本时
|
|
||||||
**自动经 FTP 抓取** `/log/selftest.log`(设备侧 450"文件不存在"按正常路径处理——"仅故障才写"语义下
|
**判据默认只看「本次启动新增」**(`--selftest-scope this-run`,2026-10-22 改)。原因:
|
||||||
干净设备本就没有该文件)。判定对象(20 处自检描述串)见 `实机测试实施指南.md` §3.2。
|
`/log/selftest.log` 是 **512 条环形累计历史**,而旧判据是"文件非空即 FAIL"——用它判一次启动,
|
||||||
|
在任何做过故障注入或长跑的设备上**永远不可能 PASS**(本仓台架实测:抓到的 29 条记录时间戳
|
||||||
|
全是 2026-10-10 ~ 10-14 留下的,没有一条来自本次)。新流程 = 烧录**前**先经 FTP 取一次基线、
|
||||||
|
启动后再取一次,只把"基线最后一条之后"的记录交给判定:
|
||||||
|
|
||||||
|
| 情形 | 含义 | 判定 |
|
||||||
|
|---|---|---|
|
||||||
|
| 设备侧无此文件(450/550) | 台账干净("仅故障才写"语义下本就可能不存在) | 无新增 → **PASS** |
|
||||||
|
| 抓到文件、本次无新增 | 本次启动没产生故障 | **PASS** |
|
||||||
|
| 抓到文件、本次有新增 | 本次启动有故障 | **FAIL**,只打印新增条目(落 `selftest_log_<时刻>_new.log`) |
|
||||||
|
| 连不上 / 登录被拒 / FTP 故障 | 结果**未知** | **FAIL**——旧行为是 WARN 后当"无记录"放过,那就是假绿 |
|
||||||
|
| 既没有 `device_ip` 也没有 `--selftest-log` | 要求判定却没有数据源 | **FAIL**(同上,不再 WARN 放行) |
|
||||||
|
| 基线最后一条在台账里找不到 | 512 条环已覆盖基线(更新量 ≥ 环容),无法证明 | **FAIL**(不拿"看不到"当"没有") |
|
||||||
|
|
||||||
|
- ⚠ **采集窗口内不要跑 FTP**:设备会把每条 FTP 命令打到串口,客户端退出时还会多打一行
|
||||||
|
`[ERR] [FTP] error reading next command` —— 默认 Forbidden 含 `[ERR]`,于是**任何**人边跑 `@test`
|
||||||
|
边用 FileZilla 传文件,都会让本次莫名 FAIL(本次改造实测踩到:抓基线原先排在采集武装之后,
|
||||||
|
开机耗时被自己的 FTP 会话拉到 ~8 s 并命中 `[ERR]`)。基线抓取因此固定在 open/clear **之前**。
|
||||||
|
- `--selftest-scope all` = 保留旧的累计口径(任一记录即 FAIL),用于**审计**或第一次建基线。
|
||||||
|
- 给了 `--selftest-log`(本地副本)就没有基线可比 → 自动降级为累计口径并打印一行说明。
|
||||||
|
- 抓基线同样要 FTP 凭据:`--ftp-user` / `--ftp-pass`(config `ftp_user` / `ftp_pass`,默认出厂
|
||||||
|
`admin`/`admin`);设备**没有匿名通道** → `FTP_Test_Guide.md` §1。
|
||||||
|
|
||||||
### 默认开机标记(来自固件)
|
### 默认开机标记(来自固件)
|
||||||
|
|
||||||
@@ -125,3 +146,4 @@
|
|||||||
| 2026-10-10 | **第二批:闭环补上"烧进去的是哪份镜像"与"跑的过程中有没有偷偷复位"**:① 固件身份戳——`App/app/app_main.c` 开机打印 `fw build: <__DATE__> <__TIME__>`,`run_boot_test.py` 从待烧 hex 里抠出该串追加成 Required 标记(旧镜像/错镜像此前所有固定标记照样全中);② 烧录回读校验进闭环——`reset.bat` 新增 `cmp` 子命令(`-CmpFile` 全片比对),采集后自动跑,不一致 exit 2;实测 `-CmpFile` 连接时会复位目标,故必须排在采集之后;③ 启动锚点计数——`verify_log.py` 新增 `--max-boot-count`/`--boot-marker`/`--expect-reset-cause`,`reset cause:` 出现 0 次或 >1 次判 FAIL;④ 修 `--flash-cmd` 带参数时预检必 missing、以及 `cmd /c` 对含空格路径(本仓库目录名 `DTU Code`)的引号解析把回读假判成"芯片不一致"(改为直接 CreateProcess 跑 .bat)。真机验证:完整烧录闭环 PASS(含戳匹配 + `No difference found` + 锚点 x1);伪造戳 → 其余标记全中仍 FAIL |
|
| 2026-10-10 | **第二批:闭环补上"烧进去的是哪份镜像"与"跑的过程中有没有偷偷复位"**:① 固件身份戳——`App/app/app_main.c` 开机打印 `fw build: <__DATE__> <__TIME__>`,`run_boot_test.py` 从待烧 hex 里抠出该串追加成 Required 标记(旧镜像/错镜像此前所有固定标记照样全中);② 烧录回读校验进闭环——`reset.bat` 新增 `cmp` 子命令(`-CmpFile` 全片比对),采集后自动跑,不一致 exit 2;实测 `-CmpFile` 连接时会复位目标,故必须排在采集之后;③ 启动锚点计数——`verify_log.py` 新增 `--max-boot-count`/`--boot-marker`/`--expect-reset-cause`,`reset cause:` 出现 0 次或 >1 次判 FAIL;④ 修 `--flash-cmd` 带参数时预检必 missing、以及 `cmd /c` 对含空格路径(本仓库目录名 `DTU Code`)的引号解析把回读假判成"芯片不一致"(改为直接 CreateProcess 跑 .bat)。真机验证:完整烧录闭环 PASS(含戳匹配 + `No difference found` + 锚点 x1);伪造戳 → 其余标记全中仍 FAIL |
|
||||||
| 2026-10-10 | **第三批:判定可读性 + 时序 + 采集卫生**:① Forbidden 命中逐行打印行号+内容(旧只报条数);② boot_log 每行加 `[+Nms]` daemon 毫秒时基(`--raw-log` 关闭),据此量开机耗时(实测 `net init: OK` = **+537ms**;`--max-boot-ms` 默认 0 只报告);③ `--require-order` 标记先后顺序校验(全中但失序此前不可见);④ `--json` 结构化判定(退出码不变);⑤ `--keep-out`(默认 20)清理 out 目录旧产物(本机曾堆到 31+7 份);⑥ daemon 侧:无换行残行 64KB 封顶 + open/close/clear 会话边界丢弃残行 + 截头按行边界对齐(旧实现截出来的首行是半行)+ `open` 不带 `--dtr/--rts` 时保持现状、显式给则现场应用;⑦ `ports` 回 `configured_present`/`hint`,`status` 回 `rx_age_ms`。selftest 23/23、verify_log 离线回归 17/17、真机闭环 PASS |
|
| 2026-10-10 | **第三批:判定可读性 + 时序 + 采集卫生**:① Forbidden 命中逐行打印行号+内容(旧只报条数);② boot_log 每行加 `[+Nms]` daemon 毫秒时基(`--raw-log` 关闭),据此量开机耗时(实测 `net init: OK` = **+537ms**;`--max-boot-ms` 默认 0 只报告);③ `--require-order` 标记先后顺序校验(全中但失序此前不可见);④ `--json` 结构化判定(退出码不变);⑤ `--keep-out`(默认 20)清理 out 目录旧产物(本机曾堆到 31+7 份);⑥ daemon 侧:无换行残行 64KB 封顶 + open/close/clear 会话边界丢弃残行 + 截头按行边界对齐(旧实现截出来的首行是半行)+ `open` 不带 `--dtr/--rts` 时保持现状、显式给则现场应用;⑦ `ports` 回 `configured_present`/`hint`,`status` 回 `rx_age_ms`。selftest 23/23、verify_log 离线回归 17/17、真机闭环 PASS |
|
||||||
| 2026-10-11 | **身份戳覆盖测试宏**(N-31 复测时发现):切 `test/board/test_config.h` 的套件宏后 `app_main.c` 未被重编,测试镜像与产品镜像顶着同一个 `fw build:` 戳 → 戳校验对"两种构建互换"失明(当时只有 `-CmpFile` 回读发现镜像不同)。修复:`app_main.c` `#include "test_config.h"`(头依赖 → 改宏必重编、戳必刷新)+ 戳串追加 ` suite=<TEST_SUITE_NAME> dstr=<on|off>`(`FW_BUILD_VARIANT`,戳内容本身区分构建)。实测:`20:36:11 suite=NONE dstr=off` ↔ `20:36:40 suite=CH395F dstr=on`;`@test` 闭环对新格式 PASS(脚本无需改动,戳按"标记后连续可打印 ASCII"抠取)。失败模式表补一行:戳命中但 `suite=` 与要跑的套件不符 = 烧了产品镜像 |
|
| 2026-10-11 | **身份戳覆盖测试宏**(N-31 复测时发现):切 `test/board/test_config.h` 的套件宏后 `app_main.c` 未被重编,测试镜像与产品镜像顶着同一个 `fw build:` 戳 → 戳校验对"两种构建互换"失明(当时只有 `-CmpFile` 回读发现镜像不同)。修复:`app_main.c` `#include "test_config.h"`(头依赖 → 改宏必重编、戳必刷新)+ 戳串追加 ` suite=<TEST_SUITE_NAME> dstr=<on|off>`(`FW_BUILD_VARIANT`,戳内容本身区分构建)。实测:`20:36:11 suite=NONE dstr=off` ↔ `20:36:40 suite=CH395F dstr=on`;`@test` 闭环对新格式 PASS(脚本无需改动,戳按"标记后连续可打印 ASCII"抠取)。失败模式表补一行:戳命中但 `suite=` 与要跑的套件不符 = 烧了产品镜像 |
|
||||||
|
| 2026-10-22 | **自检台账判据改为「只看本次启动新增」**(修两处叠出来的假绿):① 抓取用的 FTP 登录还写着匿名,而设备自鉴权落地起就没有匿名通道 → 抓取**必然** 530 失败,旧实现又把"抓取失败"和"设备侧无此文件"都返回空、一律按"无记录 → PASS"处理 → `--require-selftest-pass` 从写下那天起没真正看过文件;② 就算抓到了,`/log/selftest.log` 是 512 条环形累计历史,"非空即 FAIL"在任何做过故障注入/长跑的设备上永远不可能 PASS(本仓台架实测 29 条记录全是 2026-10-10 ~ 10-14 留下的)。现在:烧录前取基线、启动后只判新增;抓取失败 / 无数据源 / 基线被环覆盖 **一律 FAIL**;`--selftest-scope all` 保留累计口径用于审计与首次建基线;FTP 凭据 `--ftp-user/--ftp-pass`(默认出厂 admin/admin)。凭据与判据两处都过真机:`this-run` → PASS(无新增)、`all` → FAIL(累计 29 条)、无 `device_ip` → FAIL(无数据源)。 |
|
||||||
|
|||||||
Reference in New Issue
Block a user