Files
DCU_DesktopController/scripts/test_motor_refresh.mjs
T

173 lines
11 KiB
JavaScript
Raw Normal View History

/**
* 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);