/** * test_motor_transport.mjs — 电机页双传输的端到端自测(Node,无浏览器) * * 跑法(先确保本地页面服务器在跑,EtherCAT 部分还需 tools/ecat 服务在跑): * node scripts/test_motor_transport.mjs [--port 8080] * * 测什么:**真的 import 生产代码** js/motor/transport-*.js,用 Node 的 * 全局 fetch 打真实的 /ecat/api/* 反向代理 → 真实 ecat_native → 真实从站。 * 不 mock 传输实现本身,否则测不到"契约对不对"这唯一值得测的东西。 * * 覆盖: * 1 两个传输都实现了 transport.js 声明的完整接口(漏一个方法 = 运行时崩) * 2 WiFi 的二进制 /motor/state 解析(喂构造好的字节,不需要真设备) * 3 能力位:只有 WiFi 支持 OTA / 周期同步 * 4 EtherCAT 快照 → WiFi motors[] 契约(字段名/类型逐个核) * 5 seq 不动 → online 转 false(快照永不删除历史槽,必须靠 seq 判活) * 6 CONFIG(0x0101) 从批量接收区累积并回填到 motors[].config_data * 7 版本(0x00FF) 累积且 seq 递增(上层靠 seq 增长判新应答) * 8 节点 ID 应答取走即清 * 9 fetchFeedback:无数据的字段必须是 null 而不是 0(监控 UI 约定) * 10 代理不可用时报错信封可读(不是裸 500/CORS 报错) */ import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, resolve } from "node:path"; const __dir = dirname(fileURLToPath(import.meta.url)); const ROOT = resolve(__dir, ".."); const argPort = (() => { const i = process.argv.indexOf("--port"); return (i > 0 && process.argv[i + 1]) ? Number(process.argv[i + 1]) : 8080; })(); const BASE = `http://127.0.0.1:${argPort}`; /* transport-ecat.js 用相对路径 "/ecat/api/..." 调 fetch;Node 里没有 * document.baseURI,所以把相对路径补成绝对地址后转给真实 fetch。 * 这层只改 URL 前缀,不碰请求体/方法/响应,被测逻辑仍是生产代码。 */ const realFetch = globalThis.fetch; globalThis.fetch = (url, init) => realFetch(typeof url === "string" && url.startsWith("/") ? BASE + url : url, init); if (typeof globalThis.performance === "undefined") { globalThis.performance = { now: () => Number(process.hrtime.bigint() / 1000n) / 1000 }; } const { createWifiTransport } = await import("../js/motor/transport-wifi.js"); const { createEcatTransport } = await import("../js/motor/transport-ecat.js"); const CTX = { getBaseUrl: () => "http://127.0.0.1:1", /* WiFi 用例不打真设备 */ recordSlow: () => {}, isTransient: (e) => /timeout|abort/i.test(String(e && e.message)), }; let pass = 0, fail = 0; const results = []; function check(name, cond, detail) { if (cond) { pass++; results.push(` \u2713 ${name}`); } else { fail++; results.push(` \u2717 ${name}${detail ? " \u2014 " + detail : ""}`); } } function section(t) { results.push(`\n${t}`); } /* ── 1. 接口完整性 ── * transport.js 头部声明的接口,两个实现都必须齐。少一个方法在浏览器里 * 是"点某个按钮才崩",很难发现,所以在这里一次性拦住。 */ section("[1] 接口完整性"); const REQUIRED = [ "id", "label", "supportsOta", "supportsPeriodSync", "sendHint", "probe", "fetchStatus", "describeStatus", "reportedIp", "fetchMotorState", "fetchFeedback", "sendFrames", "sendBurst", "fetchVersionNode", "setPeriod", "teardown", ]; const wifi = createWifiTransport(CTX); const ecat = createEcatTransport(CTX); for (const tp of [wifi, ecat]) { const missing = REQUIRED.filter((k) => tp[k] === undefined); check(`${tp.id} 实现全部 ${REQUIRED.length} 个接口成员`, missing.length === 0, missing.length ? "缺: " + missing.join(",") : ""); } /* ── 2. WiFi 二进制解析 ── 构造 header 12B + 一条 76B entry */ section("[2] WiFi /motor/state?bin=1 二进制解析"); { const buf = new ArrayBuffer(12 + 76); const dv = new DataView(buf); const u8 = new Uint8Array(buf); dv.setUint16(0, 0x4D53, true); // 'MS' dv.setUint8(2, 1); // ver dv.setUint8(3, 0x01); // flags: boot_active dv.setUint32(4, 123, true); // age_ms dv.setUint8(8, 2); // active dv.setUint8(9, 1); // count let o = 12; u8[o] = 5; u8[o + 1] = 2; u8[o + 2] = 1; u8[o + 3] = 32; dv.setUint32(o + 4, 77, true); // rx_count dv.setUint32(o + 8, 999, true); // seq u8[o + 12] = 0xAB; // data[0] dv.setUint32(o + 44, 4, true); // config_rx_count dv.setUint32(o + 48, 6, true); // config_seq u8[o + 52] = 0xCD; // config_data[0] /* 让传输的 fetchMotorState 拿到这段字节:临时替换 fetch */ const saved = globalThis.fetch; globalThis.fetch = async () => ({ ok: true, status: 200, arrayBuffer: async () => buf }); const st = await wifi.fetchMotorState(); globalThis.fetch = saved; check("boot_active 由 flags bit0 解出", st.boot_active === true); check("age_ms/active_count 正确", st.age_ms === 123 && st.active_count === 2, `age=${st.age_ms} active=${st.active_count}`); const m = st.motors[0] || {}; check("entry 步长 76B,字段齐全", m.node === 5 && m.ch === 2 && m.online === true && m.dlc === 32 && m.rx_count === 77 && m.seq === 999, JSON.stringify(m).slice(0, 120)); check("data 是 64 字符大写 hex", m.data && m.data.length === 64 && m.data.startsWith("AB")); check("config_data 是 48 字符 hex", m.config_data && m.config_data.length === 48 && m.config_data.startsWith("CD")); } /* ── 3. 能力位 ── */ section("[3] 链路能力位"); check("WiFi 支持 OTA", wifi.supportsOta === true); check("EtherCAT 不支持 OTA(没有固件上传+relay 通路)", ecat.supportsOta === false); check("WiFi 支持 0xC5 周期同步", wifi.supportsPeriodSync === true); check("EtherCAT 不做周期同步(PDO 周期由后台循环维持)", ecat.supportsPeriodSync === false); check("EtherCAT 不回报设备 IP", ecat.reportedIp({}) === null); /* ── 4~9. EtherCAT:打真实代理 + 真实 ecat_native ── */ section("[4] EtherCAT 端到端(真实代理 → ecat_native → 从站)"); let liveOk = false; try { const st = await ecat.fetchStatus(); liveOk = true; const parts = ecat.describeStatus(st); check("能读到从站状态并生成 AL 描述", Array.isArray(parts) && parts.length > 0, JSON.stringify(parts)); } catch (e) { results.push(` \u26a0 EtherCAT 服务不可用,跳过 4~9: ${e.message}`); } if (liveOk) { const st = await ecat.fetchMotorState(); check("返回 WiFi 契约的顶层字段", "have_data" in st && "age_ms" in st && "active_count" in st && "boot_active" in st && Array.isArray(st.motors)); if (st.motors.length) { const m = st.motors[0]; const shapeOk = typeof m.node === "number" && typeof m.ch === "number" && typeof m.online === "boolean" && typeof m.dlc === "number" && typeof m.rx_count === "number" && typeof m.seq === "number" && typeof m.data === "string" && typeof m.config_data === "string"; check("motors[] 每项字段名/类型与 WiFi 一致", shapeOk, JSON.stringify(m).slice(0, 150)); check("data 是 64 字符 hex(32B STATUS)", m.data.length === 64, `len=${m.data.length}`); results.push(` \u2139 在线电机: ` + st.motors .map((x) => `N${x.node}@CH${x.ch}${x.online ? "" : "(离线)"}`).join(" ")); } else { results.push(" \u2139 总线上无电机槽,跳过 motors[] 字段核对"); } section("[5] 反馈:缺失字段必须是 null 而不是 0"); const fb = await ecat.fetchFeedback(); check("channels 长度为 3", fb.channels && fb.channels.length === 3); const c0 = fb.channels[0]; check("tec/rec 为 null(过程数据没有这两个计数)", c0.tec === null && c0.rec === null, `tec=${c0.tec} rec=${c0.rec}`); check("valid 反映 wkc==expected_wkc", typeof c0.valid === "boolean"); section("[6] 版本/节点ID 累积器"); check("未收到应答时 fetchVersionNode 返回 null", (await ecat.fetchVersionNode(7)) === null); check("takeNodeIdReport 存在且未收到时返回 null", typeof ecat.takeNodeIdReport === "function" && ecat.takeNodeIdReport(7) === null); const dbg = ecat.debug(); check("debug() 暴露 PDO 周期与槽位统计", dbg && typeof dbg.slots === "number" && dbg.slotsPerPdo === 7 && dbg.slotCount === 27 && dbg.scanCycles === 4, JSON.stringify(dbg && { periodMs: dbg.periodMs, slots: dbg.slots })); } /* ── 10. 代理错误信封可读 ── 指向一个没人监听的端口 */ section("[7] 代理不可用时的错误信封"); { const dead = createEcatTransport(CTX); const saved = globalThis.fetch; /* 打一个必定没人监听的端口,模拟"EtherCAT 服务没启动" */ globalThis.fetch = (url, init) => realFetch(typeof url === "string" && url.startsWith("/") ? "http://127.0.0.1:1" + url : url, init); let msg = ""; try { await dead.fetchMotorState(); } catch (e) { msg = e.message || ""; } globalThis.fetch = saved; check("服务不可用时抛出可读错误(不是裸 CORS/undefined)", msg.length > 0, msg.slice(0, 90)); check("fetchVersionNode 纯本地不打网络", (await dead.fetchVersionNode(1)) === null); } /* ── 8. 快照 → WiFi 契约的翻译逻辑 ── * 这几项是本次改动的核心,且**只能**用构造快照来测: * 真实总线此刻可能没有 CAN 流量(global_seq=0 / win_count=0), * 那样 motors[] 永远是空的,seq 判活、CONFIG 回填、版本 seq 递增 * 这些恰恰最容易写错的分支就一条都覆盖不到。 * 这里仍然跑的是生产代码 transport-ecat.js,只是替换它看到的 HTTP 响应。 */ section("[8] 快照→契约翻译(构造快照,跑生产代码)"); { const tp = createEcatTransport(CTX); const saved = globalThis.fetch; /* 每次 fetch 按路由返回预置内容;motor/rx 只在第一次给帧,之后给空。 */ let snap = null; let rxFrames = []; const jsonResp = (obj) => ({ ok: true, status: 200, text: async () => JSON.stringify(obj), }); globalThis.fetch = async (url) => { const u = String(url); if (u.includes("/motor/rx")) { const f = rxFrames; rxFrames = []; return jsonResp({ ok: true, log: [], frames: f, remain: 0 }); } if (u.includes("/motor/state")) return jsonResp(Object.assign({ ok: true, log: [] }, snap)); if (u.includes("/motor/start")) return jsonResp({ ok: true, log: [], period_ms: 4 }); return jsonResp({ ok: true, log: [] }); }; const mkSnap = (motors, extra) => Object.assign({ ts: Date.now() / 1000, wkc: 3, expected_wkc: 3, cycles: 100, period_ms: 4, sent_seq: null, sent_frames: 0, queue_depth: 0, bridge: { valid: true, ack: 0, rx_count: 1000, tx_count: 5, drop: 2, tx_result: 0 }, xrx: null, rx_batch: { count: 0, lost: 0, pending: 0, frames: [] }, head: { active: motors.length, boot_active: false, win_base: 0, win_count: 7, global_seq: 42 }, motors, }, extra || {}); const STATUS_HEX = "AB".repeat(32); /* 32B STATUS */ const CONFIG_HEX = "5A".repeat(24); /* 24B CONFIG(0x0101) */ /* (a) 首次:seq 在动 → online=true,rx_count 从 1 起累 */ snap = mkSnap([{ slot: 0, node: 3, ch: 1, online: true, dlc: 32, seq: 500, data: STATUS_HEX }]); let st = await tp.fetchMotorState(); let m = st.motors[0]; check("(a) 从站 online + seq 在动 → online=true", m.online === true); check("(a) rx_count 本地累计为 1", m.rx_count === 1, `rx=${m.rx_count}`); check("(a) active_count 取自 head.active", st.active_count === 1); /* (b) seq 递增 → rx_count 继续累加 */ snap = mkSnap([{ slot: 0, node: 3, ch: 1, online: true, dlc: 32, seq: 501, data: STATUS_HEX }]); st = await tp.fetchMotorState(); check("(b) seq 递增 → rx_count 累加到 2", st.motors[0].rx_count === 2, `rx=${st.motors[0].rx_count}`); /* (c) CONFIG 从批量接收区累积 → 回填 motors[].config_data。 * 这是 WiFi 有、EtherCAT 没有的字段,回填不了就等于 CONFIG 面板永久空白。 */ rxFrames = [{ ch: 1, dlc: 24, valid: true, id: (3 << 16) | 0x0101, data: CONFIG_HEX }]; snap = mkSnap([{ slot: 0, node: 3, ch: 1, online: true, dlc: 32, seq: 502, data: STATUS_HEX }], { rx_batch: { count: 1, lost: 0, pending: 0, frames: [] } }); st = await tp.fetchMotorState(); check("(c) CONFIG(0x0101) 回填到 config_data(48 hex)", st.motors[0].config_data === CONFIG_HEX, `len=${st.motors[0].config_data.length}`); /* (d) 版本(0x00FF):累积且 seq 递增 —— 上层靠 seq 增长判"这是本次查询的新应答" */ const VER_HEX = "1A00" + "0200" + "0300" + "0400" + "00".repeat(16); rxFrames = [{ ch: 1, dlc: 24, valid: true, id: (3 << 16) | 0x00FF, data: VER_HEX }]; snap = mkSnap([{ slot: 0, node: 3, ch: 1, online: true, dlc: 32, seq: 503, data: STATUS_HEX }], { rx_batch: { count: 1, lost: 0, pending: 0, frames: [] } }); await tp.fetchMotorState(); const v1 = await tp.fetchVersionNode(3); check("(d) 版本应答被累积", v1 && v1.node === 3 && v1.version === VER_HEX); rxFrames = [{ ch: 1, dlc: 24, valid: true, id: (3 << 16) | 0x00FF, data: VER_HEX }]; snap = mkSnap([{ slot: 0, node: 3, ch: 1, online: true, dlc: 32, seq: 504, data: STATUS_HEX }], { rx_batch: { count: 1, lost: 0, pending: 0, frames: [] } }); await tp.fetchMotorState(); const v2 = await tp.fetchVersionNode(3); check("(d) 再收一次 → seq 递增(上层据此判新应答)", v2.seq === v1.seq + 1, `${v1.seq} → ${v2.seq}`); /* (e) 节点 ID 应答:取走即清 */ rxFrames = [{ ch: 1, dlc: 2, valid: true, id: (3 << 16) | 0x0401, data: "0700" }]; snap = mkSnap([{ slot: 0, node: 3, ch: 1, online: true, dlc: 32, seq: 505, data: STATUS_HEX }], { rx_batch: { count: 1, lost: 0, pending: 0, frames: [] } }); await tp.fetchMotorState(); check("(e) 节点 ID 应答可取出", tp.takeNodeIdReport(3) === "0700"); check("(e) 取走即清(第二次为 null)", tp.takeNodeIdReport(3) === null); /* (f) STATUS 已由状态窗口聚合,批量区里的 0x0100 必须跳过(否则重复解一遍) */ rxFrames = [{ ch: 1, dlc: 32, valid: true, id: (3 << 16) | 0x0100, data: "FF".repeat(32) }]; snap = mkSnap([{ slot: 0, node: 3, ch: 1, online: true, dlc: 32, seq: 506, data: STATUS_HEX }], { rx_batch: { count: 1, lost: 0, pending: 0, frames: [] } }); st = await tp.fetchMotorState(); check("(f) 批量区的 STATUS(0x0100) 不污染 config_data", st.motors[0].config_data === CONFIG_HEX); check("(f) data 仍来自状态窗口", st.motors[0].data === STATUS_HEX); /* (g) xrx 长帧快照也要分派:CONFIG/版本是 DLC24,短快照会截断 */ const CFG2 = "77".repeat(24); snap = mkSnap([{ slot: 1, node: 4, ch: 1, online: true, dlc: 32, seq: 10, data: STATUS_HEX }], { xrx: { ch: 1, dlc: 24, id: (4 << 16) | 0x0101, data: CFG2 } }); st = await tp.fetchMotorState(); check("(g) xrx 长帧快照的 CONFIG 也回填", st.motors.find((x) => x.node === 4).config_data === CFG2); /* (h) 关键:seq 停止不动超过阈值 → online 转 false。 * 后端的快照**永不删除**见过的槽(电机拔了也还在), * 只认从站的 online 标志会让界面上永远挂着一台不存在的电机。 */ const stale = { slot: 0, node: 3, ch: 1, online: true, dlc: 32, seq: 506, data: STATUS_HEX }; snap = mkSnap([stale]); st = await tp.fetchMotorState(); check("(h) seq 未变但仍在阈值内 → 暂时仍算在线", st.motors.find((x) => x.node === 3).online === true); /* 把快照 ts 往前推 3 秒不管用(判活用的是本地时间), * 所以真正等过 STALE_OFFLINE_MS(2000ms) —— 这是唯一忠实的验证方式。 */ await new Promise((r) => setTimeout(r, 2100)); snap = mkSnap([stale]); st = await tp.fetchMotorState(); check("(h) seq 停超 2s → online 转 false(快照不删历史槽,必须靠 seq 判活)", st.motors.find((x) => x.node === 3).online === false, `online=${st.motors.find((x) => x.node === 3).online}`); /* (i) 通信异常(wkc != expected)→ feedback 全部 null,而不是 0 */ snap = mkSnap([stale], { wkc: 0, expected_wkc: 3 }); await tp.fetchMotorState(); const fb2 = await tp.fetchFeedback(); check("(i) wkc 不匹配 → valid=false 且 rx/tx 为 null(不能显示成 0)", fb2.channels[0].valid === false && fb2.channels[0].rx === null, JSON.stringify(fb2.channels[0])); /* (i2) 单槽刷新周期:状态栏必须显示"这台电机的数据多久更新一次", * 而不是只显示 PDO 周期。窗口自适应后两者通常相等(在线电机每周期 * 都刷),但电机分散在多个窗口时 slot_period_ms 会是 period_ms 的倍数, * 这时只报 PDO 4ms 就是谎报 —— 实际数据是 16ms 才换一次。 */ await tp.fetchStatus().catch(() => {}); /* 让 periodMs 就位(start 回报 4) */ snap = mkSnap([stale], { period_ms: 4, slot_period_ms: 4 }); await tp.fetchMotorState(); let parts = tp.describeStatus({ slave: { state: 8, name: "DCU Project" } }); check("(i2) 单槽周期 == PDO 周期时只显示一个(不啰嗦)", parts.some((p) => p === "PDO 4ms") && !parts.some((p) => p.includes("单槽")), JSON.stringify(parts)); snap = mkSnap([stale], { period_ms: 4, slot_period_ms: 16 }); await tp.fetchMotorState(); parts = tp.describeStatus({ slave: { state: 8, name: "DCU Project" } }); check("(i2) 单槽周期 != PDO 周期时两者都显示(不谎报数据新鲜度)", parts.some((p) => p.includes("PDO 4ms") && p.includes("单槽 16ms")), JSON.stringify(parts)); check("(i2) debug() 暴露 slotPeriodMs 与 winBases 便于现场排查", tp.debug().slotPeriodMs === 16, JSON.stringify({ slotPeriodMs: tp.debug().slotPeriodMs })); /* (i3) 自旋节拍源:它占满一个 CPU 核,属于用户能感知的代价, * 必须在状态栏可见,且 setSpin 要把选择原样带给后端。 */ snap = mkSnap([stale], { period_ms: 1, slot_period_ms: 1, spin: true }); await tp.fetchMotorState(); parts = tp.describeStatus({ slave: { state: 8, name: "DCU Project" } }); check("(i3) 自旋生效时状态栏标注占核(不能让用户猜 CPU 去哪了)", parts.some((p) => p.includes("自旋") && p.includes("1 核")), JSON.stringify(parts)); /* (i3b) 钉到 P 核时要写出核号 —— 自旋吃满一个核是用户能感知的代价, * 列出实际落在哪些逻辑处理器,用户才知道占的是不是性能核。掩码是 64 位, * JS 移位只有 32 位,所以高低两段分别取,这里专门覆盖跨 32 位的情况。 */ snap = mkSnap([stale], { period_ms: 1, slot_period_ms: 1, spin: true, pcore_mask: 0xfff0000 }); await tp.fetchMotorState(); parts = tp.describeStatus({ slave: { state: 8, name: "DCU Project" } }); check("(i3b) 钉到 P 核时列出核号区间(16-27,不逐个罗列挤掉从站名)", parts.some((p) => p.includes("P 核 16-27")), JSON.stringify(parts)); check("(i3b) 已列出 P 核号时不再写笼统的『占 1 核』", !parts.some((p) => p.includes("占 1 核")), JSON.stringify(parts)); check("(i3b) debug() 暴露 pcores 列表便于现场核对", tp.debug().pcores.length === 12 && tp.debug().pcores[0] === 16 && tp.debug().pcores[11] === 27, JSON.stringify(tp.debug().pcores)); /* 高 32 位之外的核(>31)不能丢:用 2^40 验证跨段位 */ snap = mkSnap([stale], { period_ms: 1, slot_period_ms: 1, spin: true, pcore_mask: 1 + 2 ** 40 }); await tp.fetchMotorState(); check("(i3b) 掩码跨 32 位时高段核号不丢(0 与 40)", JSON.stringify(tp.debug().pcores) === JSON.stringify([0, 40]), JSON.stringify(tp.debug().pcores)); /* 掩码为 0 = 探测失败/对称 CPU,要退回笼统措辞而不是显示空列表 */ snap = mkSnap([stale], { period_ms: 1, slot_period_ms: 1, spin: true, pcore_mask: 0 }); await tp.fetchMotorState(); parts = tp.describeStatus({ slave: { state: 8, name: "DCU Project" } }); check("(i3b) 没钉核时退回『占 1 核』而不是列出空的 P 核", parts.some((p) => p.includes("占 1 核") && !p.includes("P 核")), JSON.stringify(parts)); /* 不连续的核(混合架构常见:P 核未必连成一段)要压成多段而不是硬凑一个区间 */ snap = mkSnap([stale], { period_ms: 1, slot_period_ms: 1, spin: true, pcore_mask: (1 << 16) | (1 << 17) | (1 << 20) | (1 << 24) }); await tp.fetchMotorState(); parts = tp.describeStatus({ slave: { state: 8, name: "DCU Project" } }); check("(i3b) 不连续核压成 16-17,20,24(不硬凑区间)", parts.some((p) => p.includes("16-17,20,24")), JSON.stringify(parts)); snap = mkSnap([stale], { period_ms: 1, slot_period_ms: 1, spin: false }); await tp.fetchMotorState(); parts = tp.describeStatus({ slave: { state: 8, name: "DCU Project" } }); check("(i3) 未自旋时不标注(避免长期挂着无关提示)", !parts.some((p) => p.includes("自旋")), JSON.stringify(parts)); let spinBody = null, spinHits = 0, startHits = 0; globalThis.fetch = async (url, init) => { const u = String(url); if (u.includes("/motor/spin")) { spinHits++; spinBody = JSON.parse(init.body); return jsonResp({ ok: true, log: [], spin: spinBody.spin !== false, period_ms: 1 }); } if (u.includes("/motor/start")) { startHits++; return jsonResp({ ok: true, log: [], period_ms: 1, spin: true }); } return jsonResp({ ok: true, log: [] }); }; /* 先走一遍 probe,让 ensureStarted 把 periodMs 填上 */ await tp.probe(); const startBefore = startHits; check("(i3) setSpin(false) 把 spin:false 带给后端并采纳回报值", (await tp.setSpin(false)) === false && spinBody.spin === false, JSON.stringify(spinBody)); await tp.setSpin(true); check("(i3) setSpin(true) 透传 spin:true", spinBody.spin === true, JSON.stringify(spinBody)); /* 关键:切节拍源绝不能走 motor/start —— 那条路由会先停 PDO 循环, * 停手超过从站 1000ms 看门狗就把从站踢出 OP,重进还可能楔死邮箱。 */ check("(i3) setSpin 走 /motor/spin,不重启 PDO 循环(不碰 motor/start)", spinHits >= 2 && startHits === startBefore, JSON.stringify({ spinHits, startHits, startBefore })); await tp.setSpin(null); check("(i3) setSpin(null) 发空请求体,交回后端自动决定", spinBody && !("spin" in spinBody), JSON.stringify(spinBody)); /* (i4) 内核级周期热调(250µs = 4kHz):必须随时可改、走 /motor/period。 * 走 motor/start 是当年把板子弄楔死的路 —— 它先停循环, * 停手 >1000ms 从站 SM2 看门狗直接踢出 OP。 */ globalThis.localStorage = (() => { const m = new Map(); return { getItem: (k) => (m.has(k) ? m.get(k) : null), setItem: (k, v) => m.set(k, String(v)), removeItem: (k) => m.delete(k), }; })(); let periodBody = null, periodHits = 0, startBody2 = null, startHits2 = 0; globalThis.fetch = async (url, init) => { const u = String(url); if (u.includes("/motor/rx")) return jsonResp({ ok: true, log: [], frames: [], remain: 0 }); if (u.includes("/motor/state")) return jsonResp(Object.assign({ ok: true, log: [] }, snap)); if (u.includes("/motor/period")) { periodHits++; periodBody = JSON.parse(init.body); return jsonResp({ ok: true, log: [], period_ms: periodBody.period_ms, spin: true }); } if (u.includes("/motor/start")) { startHits2++; startBody2 = JSON.parse(init.body); return jsonResp({ ok: true, log: [], period_ms: startBody2.period_ms || 4, spin: true }); } return jsonResp({ ok: true, log: [] }); }; check("(i4) setPdoPeriod(0.25) 带 period_ms:0.25 走 /motor/period 并返回新值", (await tp.setPdoPeriod(0.25)) === 0.25 && periodBody.period_ms === 0.25, JSON.stringify(periodBody)); check("(i4) 热调周期不碰 motor/start(停循环 = 看门狗掉 OP 的楔死路径)", periodHits === 1 && startHits2 === 0, JSON.stringify({ periodHits, startHits2 })); check("(i4) 周期值持久化到 localStorage(服务重启后不用重敲)", globalThis.localStorage.getItem("dcu_ecat_period_ms") === "0.25"); await tp.probe(); check("(i4) 循环重启(ensureStarted)自动带上持久化的周期", startHits2 === 1 && startBody2.period_ms === 0.25, JSON.stringify(startBody2)); let threwPeriod = false; try { await tp.setPdoPeriod(0); } catch (_) { threwPeriod = true; } try { await tp.setPdoPeriod("abc"); } catch (_) { threwPeriod = true; } check("(i4) ≤0 / 非法周期在本地就拒绝,不发给后端", threwPeriod); /* 状态栏显示口径:<1ms 必须写成 µs。"PDO 0.25ms" 这种写法没人读, * 而且和后端 _fmt_period 的口径要一致。 */ snap = mkSnap([stale], { period_ms: 0.25, slot_period_ms: 0.25, spin: true }); await tp.fetchMotorState(); parts = tp.describeStatus({ slave: { state: 8, name: "DCU Project" } }); check("(i4) 亚毫秒周期显示 µs(PDO 250µs,不是 PDO 0.25ms)", parts.some((p) => p.includes("PDO 250µs")), JSON.stringify(parts)); snap = mkSnap([stale], { period_ms: 0.5, slot_period_ms: 1, spin: true }); await tp.fetchMotorState(); parts = tp.describeStatus({ slave: { state: 8, name: "DCU Project" } }); check("(i4) 单槽≠周期时 µs/ms 各自按自己的档位显示", parts.some((p) => p.includes("PDO 500µs") && p.includes("单槽 1ms")), JSON.stringify(parts)); snap = mkSnap([stale], { period_ms: 4, slot_period_ms: 4, spin: false }); await tp.fetchMotorState(); parts = tp.describeStatus({ slave: { state: 8, name: "DCU Project" } }); check("(i4) 别处(rate_probe/另一标签页)改的周期每拍从快照同步", parts.some((p) => p.includes("PDO 4ms")) && tp.debug().periodMs === 4, JSON.stringify(parts)); /* (j) 发送:多帧一次提交(同一 PDO 周期 / 同一 seq 沿)→ 逐帧 results */ let sentBody = null; globalThis.fetch = async (url, init) => { const u = String(url); if (u.includes("/motor/send")) { sentBody = JSON.parse(init.body); return jsonResp({ ok: true, log: [], queued: sentBody.frames.length, batch_max_bytes: 332 }); } return jsonResp({ ok: true, log: [] }); }; const res = await tp.sendFrames( [{ id: 0x30200, dlc: 32, data: "11".repeat(32) }, { id: 0x40200, dlc: 32, data: "22".repeat(32) }], { ch: 1 }); check("(j) 多帧一次提交(同一 seq 沿)", sentBody && sentBody.frames.length === 2, JSON.stringify(sentBody && sentBody.frames.map((f) => f.id))); check("(j) 每帧带上批级通道", sentBody.frames.every((f) => f.ch === 1)); check("(j) 返回逐帧 results(长度与请求一致)", Array.isArray(res) && res.length === 2 && res.every((r) => r.status === "ok")); /* (k) 后端 ok:false → 每帧标错,让上层流控能看到 */ globalThis.fetch = async () => jsonResp({ ok: false, log: ["未连接"] }); const bad = await tp.sendFrames([{ id: 0x30200, dlc: 8, data: "00".repeat(8) }], { ch: 0 }); check("(k) 后端 ok:false → results 标 error 且带原因", bad[0].status === "error" && /未连接/.test(bad[0].desc || ""), JSON.stringify(bad[0])); globalThis.fetch = saved; } console.log(results.join("\n")); console.log(`\n${"=".repeat(56)}`); console.log(`通过 ${pass} 项,失败 ${fail} 项`); process.exit(fail === 0 ? 0 : 1);