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:
2026-10-22 22:19:51 +08:00
parent 15d01d4dc1
commit af5bad5609
5 changed files with 185 additions and 30 deletions
+3 -1
View File
@@ -47,7 +47,9 @@ whenToUse: 需要向目标板烧录或复位后,用串口日志自动判定运
| `done_marker` + `max_boot_ms` | 开机耗时口径与上限 | 空/0 = 只报告不判 |
| `settle_ms` / `max_wait_ms` / `first_wait_ms` | RX 静默判据 / 总超时 / 首字节超时 | 8000 / 30000 / 12000 |
| `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 项目用项目自己的构建入口),确认 0 错误 0 警告后再进闭环。
@@ -24,6 +24,9 @@
"require_selftest_pass": false,
"device_ip": "",
"selftest_scope": "this-run",
"ftp_user": "admin",
"ftp_pass": "admin",
"httpPort": 18765
}
@@ -19,9 +19,12 @@
5. verify_log.py 标记校验(含启动锚点计数 config "boot_marker" + --max-boot-count;
--expect-reset-cause 原因白名单;--require-order 标记顺序;
--done-marker + --max-boot-ms 开机耗时)
6. (仅当 --require-selftest-pass)自检记录联合判定(S7):
--selftest-log 提供本地副本则直接用它,否则从设备 FTP 抓取 /log/selftest.log
(需 config "device_ip");判定:文件缺失 / 0 字节 = 无故障 → PASS
6. (仅当 --require-selftest-pass)自检台账联合判定(S7):默认**只判本次启动新增**
——烧录前先经 FTP 取 /log/selftest.log 基线,启动后再取一次,只把"基线之后新增"的
记录交给判定(该文件是 512 条环形累计历史,"非空即 FAIL"判一次启动永远不可能过)。
设备侧无此文件 = 台账干净 = PASS;**抓取失败 / 没有数据源 / 基线被环覆盖 → FAIL**,
不再"视为无记录"放过。累计口径用 --selftest-scope all;--selftest-log 走本地副本
(无基线可比,自动降级为 all)。FTP 凭据 --ftp-user/--ftp-pass(默认出厂 admin)。
采集在烧录**之前**武装(open + clear 先于烧录),daemon 常驻全量日志兜底,
无采集窗口/首字节等待仪式。
@@ -46,7 +49,7 @@
[--flash-cmd "<烧录命令>"] [--flash-timeout 180]
[--required "m1;m2"] [--forbidden "FAIL;ERROR;HardFault"]
[--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]
[--fw-hex <构建产物 hex>] [--no-fw-check]
[--no-verify-flash] [--readback-timeout 120]
@@ -58,7 +61,7 @@
退出码(与旧版一致):
0 = PASS(采集成功 + Required 全中 + Forbidden 零命中)
1 = FAIL(标记缺失/命中)
1 = FAIL(标记缺失/命中;或要求自检台账判定却判不了——无数据源/抓取失败/无法证明无新增)
2 = 采集或烧录错误(含烧录回读不一致 = 芯片内容与刚编的镜像不同,本次运行作废)
3 = 用法/预检错误
"""
@@ -240,11 +243,53 @@ def _prune_out_dir(out_dir, keep):
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"):
"""从设备 FTP 抓取 /log/selftest.log(lftpd :21 需鉴权,根目录 = 卷根)。
成功返回本地路径字符串;失败(网络不可达 / FTP 错误 / 设备侧文件缺失)返回 ""
(按 S7 语义视为无故障记录;此处打 WARN,调用方不判整体 FAIL)。
返回 (本地路径, 状态),状态 ∈ {"ok", "absent", "error"}:
ok = 抓到文件(路径有效)
absent = 设备侧确实没有这个文件 = 台账干净(正常路径,不是故障)
error = 连不上 / 登录被拒 / FTP 故障 → 结果**未知**,调用方必须判 FAIL
⚠ 2026-10-22 关键更正:旧实现把 absent 与 error 都返回 "",调用方一律"视为无
记录 → PASS"。于是抓取失败会被读成"设备很干净"——这就是那条假绿。三种结果
必须分开。
2026-10-02 台架修复:原实现只 `except OSError`,而 ftplib 的 error_temp /
error_perm 继承自 Exception 而非 OSError —— 台账"仅故障才写",干净设备上
@@ -260,24 +305,24 @@ def _fetch_selftest_log(device_ip, out_dir, stamp, ftp_user="admin", ftp_pass="a
# 设备没有匿名通道——匿名登录一律 530,所以这里必须带凭据。
ftp.login(ftp_user, ftp_pass)
except (OSError, ftplib.Error) as e:
print("RUN_TEST_WARN: selftest fetch failed: {} "
"(按 S7 语义视为无记录;设备网络/FTP 需另行排查)".format(e))
return ""
print("SELFTEST_FETCH: {} -> ERROR ({}) 连不上/登录被拒/FTP 故障 → 状态未知"
.format(device_ip, e))
return "", "error"
buf = bytearray()
try:
ftp.retrbinary("RETR /log/selftest.log", buf.extend)
except ftplib.all_errors as e:
# 450/550 = 设备侧无此文件:干净台账,S7 判 PASS(正常路径,非故障)
print("SELFTEST_FETCH: {} -> (ledger absent: {}) 按 S7 视为无故障记录"
print("SELFTEST_FETCH: {} -> (ledger absent: {}) 台账干净"
.format(device_ip, str(e).strip()))
_ftp_quit(ftp)
return ""
return "", "absent"
_ftp_quit(ftp)
local.write_bytes(bytes(buf))
print("SELFTEST_FETCH: {} -> {} ({} bytes)".format(device_ip, local, len(buf)))
return str(local)
return str(local), "ok"
def main(argv=None):
@@ -316,6 +361,11 @@ def main(argv=None):
"any 64B record = FAIL")
ap.add_argument("--selftest-log", default=None,
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,
help="device IP for fetching /log/selftest.log via FTP")
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.require_suite_summary = str(_pick(args.require_suite_summary,
"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,
"require_selftest_pass", default=False))
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(
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)
data, out = tool("open", "--port", port, "--baud", str(args.baud_rate), "--timeout", "15")
if data is None:
@@ -598,14 +681,50 @@ def main(argv=None):
print("RUN_TEST_ERROR: flash readback mismatch (chip content != built image)")
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
if args.require_selftest_pass and not selftest_local and not args.device_ip:
print("RUN_TEST_WARN: require_selftest_pass but no device_ip/selftest_log "
"-> treated as no record (S7 PASS path)")
if args.require_selftest_pass and not selftest_local and args.device_ip:
selftest_local = _fetch_selftest_log(args.device_ip, out_dir, stamp,
args.ftp_user, args.ftp_pass)
selftest_forced_fail = False
if args.require_selftest_pass:
if selftest_local:
pass # 本地副本:按它自己的口径判(基线已在 2.5 降级)
elif not args.device_ip:
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. 标记校验(含固件身份戳、启动锚点计数、可选的自检记录联合判定)
# 启动锚点(config "boot_marker",如本仓库的 'reset cause:')未配置 =
@@ -625,6 +744,11 @@ def main(argv=None):
require_order=args.require_order,
done_marker=args.done_marker,
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))
return rc
@@ -41,11 +41,15 @@
命中即判 PASS,**套件是否跑到汇总行、断言数是否为 0 完全不可见**(套件中途
挂死/复位、或 Phase 宏全关时会被误判为通过)。`--require-suite-summary`
要求日志中出现 `=== <名称>: N/M PASSED (failed=0) ===` 且 **N > 0**。
- 自检记录联合判定(S7,2026-10 实机窗口):`--require-selftest-pass` 对 /log/selftest.log
的本地副本(定长 64B 记录,格式见 App/log/sys_log.h)判定。自检仅故障写盘(v1 不记
通过项):**文件缺失 / 0 字节 = 无故障 → PASS**;存在任何一条记录 = 有故障 → FAIL
(退出码 1),并逐条打印时间戳 + 描述。抓取失败(设备不可达 / FTP 错误等)由调用方
(run_boot_test.py)先打 WARN,本地文件按缺失处理 → 按上述规则不判整体 FAIL。
- 自检记录联合判定(S7,2026-10 实机窗口;判据 2026-10-22 改为"只看本次新增"):
`--require-selftest-pass` 对 /log/selftest.log 的本地副本(定长 64B 记录,格式见
App/log/sys_log.h)判定。自检仅故障写盘(v1 不记通过项):**文件缺失 / 0 字节 = 无故障
→ PASS**;存在记录 = FAIL(退出码 1),逐条打印时间戳 + 描述。
⚠ 该文件是 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)统计
`reset cause:` 锚点出现次数——**0 次** = 采集没从复位开始(标记全中也可能只是上一轮
的残留日志);**>N 次** = 板子在采集窗口内自己复位过(IWDG 咬狗 / Fault 后