185 lines
8.6 KiB
JavaScript
185 lines
8.6 KiB
JavaScript
/**
|
||
* test_ecat_autostart.mjs — EtherCAT 自动接入链条自测(Node,无浏览器)
|
||
*
|
||
* 跑法:node scripts/test_ecat_autostart.mjs [--port 2625]
|
||
*
|
||
* 测什么:**真的 import 生产代码** transport-ecat.js,验证"自动接入"这条
|
||
* 四级链条的每一级都被正确驱动,且失败原因可读。
|
||
*
|
||
* 为什么要 stub 后端:板子当前 SSC 邮箱楔死(config_map 失败,ecat_native.c 里
|
||
* 同样注明必须断电复位 MCU),真实链路走不到 OP。但"自动开网卡→自动进 OP→自动起循环"
|
||
* 这三级的**驱动逻辑**必须能独立验证,否则一块坏板子就让这段代码永远测不到。
|
||
* 服务自动拉起那一级已在真机实测过(16s 起、带 --parent-pid),见 README。
|
||
*/
|
||
import { fileURLToPath } from "node:url";
|
||
import { dirname, resolve } from "node:path";
|
||
import http from "node:http";
|
||
|
||
const __dir = dirname(fileURLToPath(import.meta.url));
|
||
|
||
if (typeof globalThis.performance === "undefined") {
|
||
globalThis.performance = { now: () => Number(process.hrtime.bigint() / 1000n) / 1000 };
|
||
}
|
||
/* transport-ecat.js 会读 localStorage(网卡子串),Node 里没有,给个内存版 */
|
||
const _ls = new Map();
|
||
globalThis.localStorage = {
|
||
getItem: (k) => (_ls.has(k) ? _ls.get(k) : null),
|
||
setItem: (k, v) => _ls.set(k, String(v)),
|
||
removeItem: (k) => _ls.delete(k),
|
||
};
|
||
|
||
const { createEcatTransport } = await import("../js/motor/transport-ecat.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}`); }
|
||
|
||
/* ── stub 后端:模拟 ecat_native 的状态机 ──
|
||
* opened=false 时 /api/status 回 {ok:false,log:["未连接"]}(真实行为),
|
||
* /api/open 之后才变 ok:true;/api/motor/start 记录被调用次数。 */
|
||
function makeStub(opts) {
|
||
const st = { opened: false, started: false, calls: [], openSubstr: null,
|
||
failOpen: opts && opts.failOpen, failStart: opts && opts.failStart };
|
||
const srv = http.createServer((req, res) => {
|
||
let body = "";
|
||
req.on("data", (c) => (body += c));
|
||
req.on("end", () => {
|
||
st.calls.push(req.url);
|
||
const send = (o) => {
|
||
const b = Buffer.from(JSON.stringify(o), "utf8");
|
||
res.writeHead(200, { "Content-Type": "application/json", "Content-Length": b.length });
|
||
res.end(b);
|
||
};
|
||
let p = {};
|
||
try { p = body ? JSON.parse(body) : {}; } catch (_) {}
|
||
if (req.url === "/ecat/api/status") {
|
||
return send(st.opened
|
||
? { ok: true, slave: { name: "STUB", man: 0, id: 1, rev: 1, state: 8, al_status: 0 } }
|
||
: { ok: false, log: ["未连接"] });
|
||
}
|
||
if (req.url === "/ecat/api/open") {
|
||
if (st.failOpen) return send({ ok: false, log: [st.failOpen] });
|
||
st.opened = true; st.openSubstr = p.substr;
|
||
return send({ ok: true, log: ["已连接 STUB"],
|
||
slave: { name: "STUB", man: 0, id: 1, rev: 1, state: 1, al_status: 0 } });
|
||
}
|
||
if (req.url === "/ecat/api/close") { st.opened = false; return send({ ok: true, log: [] }); }
|
||
if (req.url === "/ecat/api/motor/start") {
|
||
if (st.failStart) return send({ ok: false, log: [st.failStart] });
|
||
st.started = true;
|
||
return send({ ok: true, log: [], period_ms: p.period_ms || 4 });
|
||
}
|
||
if (req.url === "/ecat/api/motor/state") {
|
||
if (!st.opened) return send({ ok: false, log: ["未连接"] });
|
||
return send({ ok: true, log: [], ts: Date.now() / 1000, wkc: 3, expected_wkc: 3,
|
||
cycles: 10, period_ms: 4, sent_seq: null, sent_frames: 0, queue_depth: 0,
|
||
bridge: { valid: true, ack: 0, rx_count: 0, tx_count: 0, drop: 0, tx_result: 0 },
|
||
xrx: null, rx_batch: { count: 0, lost: 0, pending: 0, frames: [] },
|
||
head: { active: 0, boot_active: false, win_base: 0, win_count: 0, global_seq: 0 },
|
||
motors: [] });
|
||
}
|
||
if (req.url === "/ecat/api/motor/rx") return send({ ok: true, log: [], frames: [], remain: 0 });
|
||
return send({ ok: false, log: ["接口不存在: " + req.url] });
|
||
});
|
||
});
|
||
return new Promise((r) => srv.listen(0, "127.0.0.1", () => r({ st, srv, port: srv.address().port })));
|
||
}
|
||
|
||
const CTX = { getBaseUrl: () => "", recordSlow: () => {}, isTransient: () => false };
|
||
/** 把 transport 的相对路径 fetch 指到 stub 上 */
|
||
function bindFetch(port) {
|
||
const real = globalThis.fetch;
|
||
globalThis.fetch = (u, i) =>
|
||
real(typeof u === "string" && u.startsWith("/") ? `http://127.0.0.1:${port}${u}` : u, i);
|
||
return () => { globalThis.fetch = real; };
|
||
}
|
||
|
||
/* ── 1. 未连接时 probe() 应自动 open 再 start ── */
|
||
section("[1] probe() 自动开网卡 + 自动起循环(四级链条的 2/3/4 级)");
|
||
{
|
||
const { st, srv, port } = await makeStub();
|
||
const un = bindFetch(port);
|
||
const tp = createEcatTransport(CTX);
|
||
await tp.probe();
|
||
un(); srv.close();
|
||
check("先探 status 发现未连接", st.calls[0] === "/ecat/api/status", st.calls[0]);
|
||
check("自动调用 /api/open(代替用户去点连接按钮)", st.calls.includes("/ecat/api/open"));
|
||
check("open 用默认网卡子串 I210", st.openSubstr === "I210", String(st.openSubstr));
|
||
check("随后自动调用 /api/motor/start(进 OP + 起后台循环)", st.started === true);
|
||
check("open 在 start 之前", st.calls.indexOf("/ecat/api/open") < st.calls.indexOf("/ecat/api/motor/start"));
|
||
check("periodMs 由后端回报写入", tp.debug().periodMs === 4, String(tp.debug().periodMs));
|
||
}
|
||
|
||
/* ── 2. 已连接时不要重复 open(api_open 会 close 旧 master,反复调会重置总线)── */
|
||
section("[2] 已连接则不重复 open(幂等)");
|
||
{
|
||
const { st, srv, port } = await makeStub();
|
||
const un = bindFetch(port);
|
||
const tp = createEcatTransport(CTX);
|
||
await tp.probe();
|
||
const opensAfterFirst = st.calls.filter((c) => c === "/ecat/api/open").length;
|
||
await tp.probe(); // 第二次探测
|
||
const opensTotal = st.calls.filter((c) => c === "/ecat/api/open").length;
|
||
un(); srv.close();
|
||
check("首次 probe 调了一次 open", opensAfterFirst === 1, String(opensAfterFirst));
|
||
check("已连接后再 probe 不再 open(避免反复重置总线)",
|
||
opensTotal === 1, `共 ${opensTotal} 次`);
|
||
}
|
||
|
||
/* ── 3. 网卡打不开 → 报错要冒泡且**不**标 transient(否则页面一直转圈不给红灯)── */
|
||
section("[3] 打不开网卡时的错误语义");
|
||
{
|
||
const { srv, port } = await makeStub({ failOpen: "没有找到描述包含 'I210' 的适配器" });
|
||
const un = bindFetch(port);
|
||
const tp = createEcatTransport(CTX);
|
||
let err = null;
|
||
try { await tp.probe(); } catch (e) { err = e; }
|
||
un(); srv.close();
|
||
check("probe 抛错(不静默成功)", err !== null);
|
||
check("错误消息带候选网卡线索", err && /适配器/.test(err.message), err && err.message);
|
||
check("不标 transient(外部条件不满足,不是瞬时抖动)", err && err.transient === false,
|
||
err && String(err.transient));
|
||
}
|
||
|
||
/* ── 4. 进 OP 失败(板子邮箱楔死那类)→ 原因要原样带上来 ── */
|
||
section("[4] 进 OP 失败时原因可读");
|
||
{
|
||
const { srv, port } = await makeStub({
|
||
failStart: "config_map 失败: ConfigMapError —— SM0/SM1 配置正常却持续失败=SSC 邮箱地址错位楔死" });
|
||
const un = bindFetch(port);
|
||
const tp = createEcatTransport(CTX);
|
||
let err = null;
|
||
try { await tp.probe(); } catch (e) { err = e; }
|
||
un(); srv.close();
|
||
check("probe 抛错", err !== null);
|
||
check("错误里保留楔死关键词(供上层归类成'需复位 MCU')",
|
||
err && /楔死/.test(err.message), err && err.message.slice(0, 80));
|
||
}
|
||
|
||
/* ── 5. 换网卡:立即 close 并用新子串重开 ── */
|
||
section("[5] 换网卡后用新子串重连");
|
||
{
|
||
const { st, srv, port } = await makeStub();
|
||
const un = bindFetch(port);
|
||
const tp = createEcatTransport(CTX);
|
||
await tp.probe();
|
||
const changed = await tp.setAdapter("X710");
|
||
check("setAdapter 返回 true(确实变了)", changed === true);
|
||
check("换网卡会 close 旧连接", st.calls.includes("/ecat/api/close"));
|
||
await tp.probe();
|
||
un(); srv.close();
|
||
check("重新 open 用的是新子串", st.openSubstr === "X710", String(st.openSubstr));
|
||
check("同值 setAdapter 不重连", (await tp.setAdapter("X710")) === false);
|
||
check("网卡子串已持久化", _ls.get("dcu_ecat_adapter") === "X710", _ls.get("dcu_ecat_adapter"));
|
||
check("debug() 暴露当前网卡", tp.debug().adapter === "X710");
|
||
}
|
||
|
||
console.log(out.join("\n"));
|
||
console.log(`\n${"=".repeat(56)}`);
|
||
console.log(`通过 ${pass} 项,失败 ${fail} 项`);
|
||
process.exit(fail === 0 ? 0 : 1);
|