/** * test_motor_refresh.mjs — 刷新速度「每链路一份」的自测(Node,无浏览器、无服务) * * 跑法:node scripts/test_motor_refresh.mjs * * 测什么:**真的 import 生产代码** js/motor/refresh-rate.js。 * 这里唯一值得测的是"值会不会串到另一条链路上" —— 那是共用单个变量时 * 会出的 bug(ECAT 的 50ms 带到 WiFi 上会让 HTTP 请求排队卡顿), * 也是旧数据迁移最容易搞错的地方。 * * 覆盖: * 1 默认值:wifi 250 / ecat 50,且 defaultRefreshMap 每次给新对象 * 2 归一 + 钳制:脏值/越界/字符串,**下限按链路**(wifi 50 / ecat 6) * 3 旧数据迁移:单个 refreshRate → 只进 wifi,ecat 保持默认 * 4 取值:缺失/脏值回落该链路默认,不回落成另一条链路的值 * 5 隔离:改一条链路不影响另一条(这就是本次改动的全部意义) * 6 不限速谓词:6ms 档要关掉卡顿黄标与理论 Hz 显示(6ms ≠ 166Hz) * 7 判活阈值 staleLimitMs:随实测 seq 间隔自适应,钳在 [2s, 30s] */ import { REFRESH_DEFAULTS, REFRESH_MIN_MS, REFRESH_MAX_MS, REFRESH_MIN_BY_TP, UNTHROTTLED_MAX_MS, minRefreshFor, isUnthrottled, defaultRefreshMap, normalizeRefreshMap, pickRefresh, staleLimitMs, STALE_OFFLINE_MS, STALE_IV_FACTOR, STALE_MAX_MS } from "../js/motor/refresh-rate.js"; let pass = 0, fail = 0; const out = []; function check(name, cond, detail) { if (cond) { pass++; out.push(` \u2713 ${name}`); } else { fail++; out.push(` \u2717 ${name}${detail ? " \u2014 " + detail : ""}`); } } function section(t) { out.push(`\n${t}`); } /* ── 1. 默认值 ── */ section("[1] 每链路默认值"); check("wifi 默认 250ms(无线 + ESP32 HTTP 往返)", REFRESH_DEFAULTS.wifi === 250, `实际 ${REFRESH_DEFAULTS.wifi}`); check("ecat 默认 50ms(本机快照,实测 p90≈22ms)", REFRESH_DEFAULTS.ecat === 50, `实际 ${REFRESH_DEFAULTS.ecat}`); check("两个默认值不相等(否则这次改动没意义)", REFRESH_DEFAULTS.wifi !== REFRESH_DEFAULTS.ecat); check("上限 5000ms 与 #refreshRate 的 max 一致", REFRESH_MAX_MS === 5000); const m1 = defaultRefreshMap(), m2 = defaultRefreshMap(); m1.wifi = 999; check("defaultRefreshMap 每次返回新对象(改一份不污染另一份)", m2.wifi === 250); check("defaultRefreshMap 不污染 REFRESH_DEFAULTS 常量", REFRESH_DEFAULTS.wifi === 250); /* ── 1b. 下限也按链路 ── */ section("[1b] 每链路最小间隔"); check("ecat 下限 6ms(≈160Hz,与本机 160Hz 屏的绘制上限对齐)", REFRESH_MIN_BY_TP.ecat === 6, `实际 ${REFRESH_MIN_BY_TP.ecat}`); check("wifi 下限仍 50ms(每拍是一次无线 HTTP 往返,再快只会积压)", REFRESH_MIN_BY_TP.wifi === 50, `实际 ${REFRESH_MIN_BY_TP.wifi}`); check("minRefreshFor('ecat') = 6", minRefreshFor("ecat") === 6); check("minRefreshFor('wifi') = 50", minRefreshFor("wifi") === 50); check("未知链路回落到兜底下限 50(防御)", minRefreshFor("nope") === REFRESH_MIN_MS); /* ── 2. 归一 + 钳制(下限按链路) ── */ section("[2] 归一与钳制(下限按链路)"); const norm = normalizeRefreshMap({ wifi: 400, ecat: 20 }); check("正常值原样保留", norm.wifi === 400); check("ecat 的 20ms 合法(下限 6ms,不再被抬到 50)", norm.ecat === 20, `实际 ${norm.ecat}`); check("ecat 可以设到 6ms", normalizeRefreshMap({ ecat: 6 }).ecat === 6); /* 存量缓存里还留着旧的 1ms 档:读到就按新下限抬起来,不能原样放行 */ check("存量 1ms 被抬到 6ms(绘制上限之上没有消费者)", normalizeRefreshMap({ ecat: 1 }).ecat === 6, `实际 ${normalizeRefreshMap({ ecat: 1 }).ecat}`); check("ecat 的 0/负数被抬到 6ms", normalizeRefreshMap({ ecat: 0 }).ecat === 6); check("ecat 的 -5 被抬到 6ms", normalizeRefreshMap({ ecat: -5 }).ecat === 6); check("wifi 的 1ms 仍被抬到 50(无线往返撑不住)", normalizeRefreshMap({ wifi: 1 }).wifi === 50, `实际 ${normalizeRefreshMap({ wifi: 1 }).wifi}`); check("wifi 的 20ms 也被抬到 50", normalizeRefreshMap({ wifi: 20 }).wifi === 50); check("高于上限被压到 5000", normalizeRefreshMap({ wifi: 99999 }).wifi === REFRESH_MAX_MS); check("ecat 高于上限同样压到 5000", normalizeRefreshMap({ ecat: 99999 }).ecat === REFRESH_MAX_MS); check("字符串数字可用(localStorage 里存的是字符串)", normalizeRefreshMap({ wifi: "300" }).wifi === 300); check("ecat 字符串 \"8\" 可用", normalizeRefreshMap({ ecat: "8" }).ecat === 8); const dirty = normalizeRefreshMap({ wifi: "abc", ecat: null }); check("脏值回落该链路默认(wifi)", dirty.wifi === 250, `实际 ${dirty.wifi}`); check("脏值回落该链路默认(ecat)", dirty.ecat === 50, `实际 ${dirty.ecat}`); check("未知链路 key 被丢弃(不在 localStorage 里攒垃圾)", normalizeRefreshMap({ wifi: 300, bogus: 111 }).bogus === undefined); const empty = normalizeRefreshMap(undefined, undefined); check("完全没有历史数据 → 全用默认", empty.wifi === 250 && empty.ecat === 50); check("传入非对象(数组/字符串)不炸且回落默认", normalizeRefreshMap("garbage").wifi === 250); /* ── 3. 旧数据迁移 ── * 旧版只存单个 refreshRate(那时只有 WiFi 链路,值是为无线调的)。 * 迁到 wifi 才对;套到 ecat 上等于让用户平白继承一个不合适的值。 */ section("[3] 旧版单值迁移"); const mig = normalizeRefreshMap(undefined, 500); check("旧单值迁进 wifi", mig.wifi === 500, `实际 ${mig.wifi}`); check("ecat **不**继承旧单值,保持 50ms 默认", mig.ecat === 50, `实际 ${mig.ecat}`); const migClamp = normalizeRefreshMap(undefined, "10"); check("旧单值也走 wifi 的下限钳制(10 → 50)", migClamp.wifi === 50); const both = normalizeRefreshMap({ wifi: 300, ecat: 80 }, 500); check("已有新格式时忽略旧字段(不被旧值覆盖)", both.wifi === 300 && both.ecat === 80); /* ── 4. 取值 ── */ section("[4] pickRefresh 取当前链路的生效值"); check("正常取值", pickRefresh({ wifi: 300, ecat: 60 }, "ecat") === 60); check("缺该链路 → 回落该链路默认(不是另一条的值)", pickRefresh({ wifi: 300 }, "ecat") === 50); check("map 为 null 不炸", pickRefresh(null, "wifi") === 250); check("ecat 取出 8ms 原样保留(不再被抬到 50)", pickRefresh({ ecat: 8 }, "ecat") === 8); check("ecat 取出旧的 1ms 抬到下限 6ms", pickRefresh({ ecat: 1 }, "ecat") === 6); check("wifi 取出 1ms 被抬到 50", pickRefresh({ wifi: 1 }, "wifi") === 50); check("未知链路 id 回落到 wifi 默认(防御,不应出现)", pickRefresh({}, "somethingnew") === 250); /* ── 5. 隔离:本次改动的核心 ── */ section("[5] 两条链路互不污染"); const map = defaultRefreshMap(); /* 模拟:用户在 ECAT 下把刷新速度拉到下限 6ms(applyRefreshRate 只写当前链路那一份) */ map.ecat = 6; check("改 ecat 到 6ms 后 wifi 仍是 250(不会把 WiFi 链路拖垮)", map.wifi === 250); /* 模拟:切回 WiFi,装载的应是 wifi 那一份 */ check("切回 wifi 装载 250 而非 6", pickRefresh(map, "wifi") === 250); /* 模拟:在 WiFi 下改成 1000ms,再切去 ECAT */ map.wifi = 1000; check("改 wifi 后 ecat 仍是 6ms(WiFi 的慢值不会浪费 ECAT 的低延迟)", map.ecat === 6); check("切到 ecat 装载 6 而非 1000", pickRefresh(map, "ecat") === 6); /* 存盘 → 读回一圈后仍各自独立(saveUiState/loadUiState 的往返) */ const roundTrip = normalizeRefreshMap(JSON.parse(JSON.stringify(map))); check("存盘再读回:wifi 保留 1000", roundTrip.wifi === 1000); check("存盘再读回:ecat 保留 6ms(不被 wifi 的下限 50 顺手抬高)", roundTrip.ecat === 6, `实际 ${roundTrip.ecat}`); /* ── 6. 不限速谓词 ── * 下限档(6ms)贴着往返耗时,节拍源仍会比请求完成得快,撞上"上一拍还在飞"是 * 预期行为,所以要据此关掉卡顿黄标;6ms 硬算成 166Hz 也偏乐观(实测 ≈160Hz)。 */ section("[6] 不限速谓词(关黄标 + 不谎报 Hz)"); check("阈值 20ms(50Hz):比浏览器实测每拍耗时(~8ms)宽裕,又远小于常用档", UNTHROTTLED_MAX_MS === 20, `实际 ${UNTHROTTLED_MAX_MS}`); check("1ms 属不限速", isUnthrottled(1) === true); check("10ms 属不限速", isUnthrottled(10) === true); check("19ms 属不限速(边界内)", isUnthrottled(19) === true); check("20ms 不属不限速(边界外)", isUnthrottled(20) === false); check("ECAT 默认 50ms 是正常限速档(黄标与 Hz 显示都保留)", isUnthrottled(REFRESH_DEFAULTS.ecat) === false); check("WiFi 默认 250ms 是正常限速档", isUnthrottled(REFRESH_DEFAULTS.wifi) === false); check("WiFi 下限 50ms 永远不进不限速区(该链路无此档)", isUnthrottled(REFRESH_MIN_BY_TP.wifi) === false); check("ECAT 下限 6ms 一定在不限速区", isUnthrottled(REFRESH_MIN_BY_TP.ecat) === true); check("非数字不炸且判 false", isUnthrottled(undefined) === false && isUnthrottled(NaN) === false); /* ── 7. ECAT 判活阈值自适应 ── * 固定 2s 阈值的问题:电机 CAN 回报周期调到秒级后,两次**正常**回报之间 * 就超过 2s,节点反复判离线(LED 闪/波形虚线/面板切离线层)。 * 生效阈值 = clamp(3×EWMA(实测间隔), 2s, 30s)。 * 注:STATUS 面板的 Hz 刻意不按 seq 实测电机回报率 —— 面板显示的是网页 * 实际刷新率(数据到达口径),WiFi 慢于电机时按电机档位显示就是谎报。 */ section("[7] 判活阈值 staleLimitMs(随实测 seq 间隔自适应)"); check("下限 2s / 系数 3 / 上限 30s(契约常量)", STALE_OFFLINE_MS === 2000 && STALE_IV_FACTOR === 3 && STALE_MAX_MS === 30000); check("还没观察到第二次变化(iv=0)→ 下限 2s,与旧固定阈值行为一致", staleLimitMs(0) === 2000, `实际 ${staleLimitMs(0)}`); check("undefined 同样落到下限(防御)", staleLimitMs(undefined) === 2000); check("快周期 20ms → 仍取下限 2s(兜底快周期,阈值不小于扫表+抖动)", staleLimitMs(20) === 2000, `实际 ${staleLimitMs(20)}`); check("回报周期 1s → 阈值 3s(连丢 3 拍才判离线,2s 不再误判)", staleLimitMs(1000) === 3000, `实际 ${staleLimitMs(1000)}`); check("回报周期 5s → 阈值 15s", staleLimitMs(5000) === 15000, `实际 ${staleLimitMs(5000)}`); check("回报周期 20s → 被 30s 上限压住(长停顿后不能迟迟不判离线)", staleLimitMs(20000) === 30000, `实际 ${staleLimitMs(20000)}`); console.log(out.join("\n")); console.log("\n" + "=".repeat(56)); console.log(`通过 ${pass} 项,失败 ${fail} 项`); process.exit(fail === 0 ? 0 : 1);