同一症状(点一次发送→很久才动/不动)至少有五条路径能到达,上一轮只加固了 native 检测层那一条的一半。本 commit 关掉两条由我们自己的前端反复按 loop_stop 的成因——不需要硬件故障就能把健康总线掐停:motor/start 内部先 loop_stop,停手 >1000ms 从站过程数据看门狗就踢出 OP,反复这样会把 SSC 邮箱楔到只能断电。 1) healthCheck 违反自己的契约。文件注释写着「只读…绝不 motor/start、不动总线」, 代码却是内联的 dc !== 1 || dcs !== 250 → motor/start。这把「回包没有 dc 字段」 当成「DC 配错」,于是一篇错误回包/一份旧 exe 就打出一记 loop_stop,而这条路径 只在 probe() 超时后走(那一刻已经有一记 loop_stop 在飞)。 git 史:断言383535b引入即红 →2304ed6改走 ecat-bus.repairEcatDc(含「缺字段 不动总线」守卫)转绿 →7ea80f0前端回滚删掉那次调用,又红。 2) OTA 负载页以「读数不新鲜」为理由无条件 motor/start。固件 <1.5.61(段5 恒 0)、 GD32 没跑 APP、首个 1s 窗口没滚完都会读数不新鲜而循环完全健康——一个跟电机无关 的页面于是周期性掐停总线。现在先证明再动手;native 完全没起仍算「该拉起」, 刷写体验不变。 做法:判定与预算只放 ecat-bus.js—— proveEcatLoop(h) 'up'|'down'|'unknown':三态是重点,证明不了就不许自动动手 proveEcatDc(h) 同上,把「缺字段=证明不了」收编成唯一实现 ensureEcatLoopRepair() in-flight 锁 + 最小间隔 20s(时间戳先写后发,落 localStorage 才跨标签页/跨 F5)+ 连续 3 次封顶;reason 随 body 走,供代理侧统一上限 repairEcatDc 新增「native 正在全量重连时不动总线」;isEcatBusHealthy 与 proveEcatLoop 共用同一处判定(两个「看起来在跑」迟早漂移)。transport-ecat 的 readHealth 附带原始 回包 raw,证明函数与界面读同一份,不再抄一遍字段名。 测试:test_ecat_autostart.mjs 26→57 项。stub 现在能喂任意 health 形状并按 reason 计数,新增 [2c] 反空断言(DC 确实配错时必须恰又一次且带 reason)、[2d] 三态矩阵、 [2e] OTA 决策、[2f] 预算(间隔/封顶/复位后放行)。另加 scripts/check_fe_guard_mutation.py: 把实现逐条退回旧形状看断言是否真变红(全绿=那条断言是自证的)——M1..M6 实测全部变红, 还原后基线仍 57/0。配套门槛见 tools/ecat/check_guard_regression.py 的 S6/S7。 未做(另计):loop=0 的三重闩锁(transport 的 started 位、transient 正则、device-link 的 e.loop=loadValid||op 假位——native /api/status 的 slave.state 是缓存,循环停了仍报 8) 与 UI 对 loop/guard 的可见性;以及 loop_stop 不清 g_cmd_q 造成的「重启那刻陈旧命令一起灌出去」。
119 lines
5.0 KiB
Python
119 lines
5.0 KiB
Python
"""变异检查:把"motor/start 许可判定"逐条退回旧形状,门槛必须变红。
|
|
|
|
存在理由:`node scripts/test_ecat_autostart.mjs` 全绿只说明"当前实现满足了断言",
|
|
不说明断言有灵敏度 —— 本项目反复栽在"测试绿着但判据永不触发"(tx 冻结守卫、
|
|
t8_soak 反极性都是同族)。这份脚本把实现改坏,看测试是否变红;只要有一条仍全绿,
|
|
那条断言就是自证的空话,禁止当成验收凭据。
|
|
|
|
它会**原地改写**两个 JS 文件再逐字节还原,所以要求这两个文件在工作树里是干净的
|
|
(有自己的未提交改动时先 commit 或 stash,别在这儿叠)。
|
|
|
|
用法:python scripts/check_fe_guard_mutation.py [--keep-report]
|
|
退出码:0=每条变异都被测出(且还原后基线仍绿) 1=有空断言或还原失败 2=前置不满足
|
|
"""
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
|
|
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir))
|
|
BUS = os.path.join(ROOT, "js", "common", "ecat-bus.js")
|
|
TR = os.path.join(ROOT, "js", "motor", "transport-ecat.js")
|
|
TEST = os.path.join("scripts", "test_ecat_autostart.mjs")
|
|
FAIL_MARK = chr(10007) # ✗
|
|
SUMMARY = "通过 (\\d+) 项,失败 (\\d+) 项"
|
|
|
|
# (说明, 文件, 原文, 改成的旧形状)
|
|
MUTS = [
|
|
("healthCheck 不再走 repairEcatDc(退回什么都不自愈)", TR,
|
|
" await repairEcatDc(api, h.raw);", " /* MUTATED */"),
|
|
("proveEcatDc 把缺字段当成配错(7ea80f0 内联副本的形状)", BUS,
|
|
' if (h.dc == null && h.period_ms == null && h.dc_cycle_us == null) return "unknown";',
|
|
" /* MUTATED */"),
|
|
("去掉自动重启的最小间隔", BUS,
|
|
"export const AUTO_START_MIN_INTERVAL_MS = 20000;",
|
|
"export const AUTO_START_MIN_INTERVAL_MS = 0;"),
|
|
("去掉「native 正在全量重连时不动总线」", BUS,
|
|
" if (h.recovering) return null;" + chr(10) + " return ecatPost",
|
|
" /* MUTATED */" + chr(10) + " return ecatPost"),
|
|
("proveEcatLoop 把「读不到」当成 down(抹掉 unknown 三态)", BUS,
|
|
' if (!h || h.ok !== true) {' + chr(10)
|
|
+ ' return (opts && opts.treatOfflineAsDown) ? "down" : "unknown";' + chr(10) + ' }',
|
|
' if (!h || h.ok !== true) return "down";'),
|
|
("去掉连续封顶(AUTO_START_MAX_STREAK 无限大)", BUS,
|
|
"export const AUTO_START_MAX_STREAK = 3;",
|
|
"export const AUTO_START_MAX_STREAK = 9999;"),
|
|
]
|
|
|
|
|
|
def run_suite():
|
|
p = subprocess.run(["node", TEST], cwd=ROOT, capture_output=True, text=True,
|
|
encoding="utf-8", errors="replace", timeout=300)
|
|
out = p.stdout or ""
|
|
m = re.search(SUMMARY, out)
|
|
reds = [l.split(FAIL_MARK)[1].strip()[:44] for l in out.splitlines() if FAIL_MARK in l]
|
|
if not m:
|
|
why = (out.strip().splitlines() or [""])[-1] or (p.stderr or "").strip()[:80]
|
|
return (-1, 1), ["(无汇总行,进程提前退出)" + why]
|
|
return (int(m.group(1)), int(m.group(2))), reds
|
|
|
|
|
|
def dirty(paths):
|
|
try:
|
|
r = subprocess.run(["git", "status", "--porcelain", "--"] + paths,
|
|
cwd=ROOT, capture_output=True, text=True, timeout=20)
|
|
return bool((r.stdout or "").strip())
|
|
except Exception:
|
|
return True
|
|
|
|
|
|
def main():
|
|
if dirty([os.path.relpath(BUS, ROOT), os.path.relpath(TR, ROOT)]):
|
|
print("BLOCKED 目标文件有未提交改动,变异脚本不做叠写;先 commit 或 stash")
|
|
print("VERDICT BLOCKED")
|
|
return 2
|
|
bak = {f: open(f, "rb").read() for f in (BUS, TR)}
|
|
|
|
def restore():
|
|
for f, b in bak.items():
|
|
open(f, "wb").write(b)
|
|
for f, b in bak.items():
|
|
assert open(f, "rb").read() == b, "还原失败: " + f
|
|
|
|
base, _ = run_suite()
|
|
print("基线:通过 %d 失败 %d" % base)
|
|
if base[1] != 0 or base[0] < 0:
|
|
print("VERDICT FAIL 基线就没绿,变异检查无从谈起")
|
|
return 1
|
|
vacuous = []
|
|
for name, path, old, new in MUTS:
|
|
s = open(path, "rb").read().decode("utf-8")
|
|
if s.count(old) != 1:
|
|
vacuous.append(name + "(锚点命中 %d 次:实现已漂移,先更新本脚本)" % s.count(old))
|
|
print("%-56s 锚点失效" % name)
|
|
continue
|
|
open(path, "wb").write(s.replace(old, new, 1).encode("utf-8"))
|
|
try:
|
|
(np, nf), reds = run_suite()
|
|
finally:
|
|
restore()
|
|
hit = nf > 0
|
|
print("%-56s 通过 %2d 失败 %2d %s" % (name, np, nf, "变红 OK" if hit else "仍全绿 = 空断言"))
|
|
for r in reds[:3]:
|
|
print(" 红:", r)
|
|
if not hit:
|
|
vacuous.append(name)
|
|
again, _ = run_suite()
|
|
print("还原后复核:通过 %d 失败 %d" % again)
|
|
if vacuous or again[1] != 0:
|
|
print("VERDICT FAIL vacuous=%d" % len(vacuous))
|
|
for v in vacuous:
|
|
print(" -", v)
|
|
return 1
|
|
print("VERDICT PASS mutations=%d" % len(MUTS))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|