Files
wasm-simulator/test-web-wasm-node-smoke.cjs
cnc b8ad3a0346 继续按 align-linuxcnc 约束:闭合 switchkins 构建预检
结论:build-wasm 入口纳入 cached switchkins/remap 表校验,LinuxCNC RS274 INI 加载按 rs274ngc_pre.cc 保留真实 ini_load 路径,Node/browser generated switchkins smoke 跳过会触发 REMAP 依赖加载的 INI_FILE_NAME 别名;native/source-link/表检查均通过。
2026-06-02 15:13:37 +08:00

509 lines
17 KiB
JavaScript

#!/usr/bin/env node
const path = require("path");
const fs = require("fs");
const os = require("os");
const { pathToFileURL } = require("url");
const wasmDir = path.join(__dirname, "web", "public");
const cjsLoader = path.join(os.tmpdir(), `cnc_sim_node_loader_${process.pid}.cjs`);
fs.copyFileSync(path.join(wasmDir, "cnc_sim.js"), cjsLoader);
process.on("exit", () => {
fs.rmSync(cjsLoader, { force: true });
});
const createCncSimModule = require(cjsLoader);
if (typeof createCncSimModule !== "function") {
throw new Error("cnc_sim.js did not export a Node-loadable createCncSimModule function");
}
const EVENT_TYPE = {
2: "comment",
9: "rapid",
10: "linear-feed",
13: "program-end",
14: "rtcp-pivot",
15: "kinematics-switch",
};
const EVENT_OFFSETS = {
type: 4,
line: 8,
tool: 16,
feed: 24,
dwell: 40,
end: 120,
reserved: 268,
};
function readEvent(module, ptr) {
const view = new DataView(module.HEAPU8.buffer, ptr, 272);
return {
type: EVENT_TYPE[view.getInt32(EVENT_OFFSETS.type, true)] || "other",
line: view.getInt32(EVENT_OFFSETS.line, true),
tool: view.getInt32(EVENT_OFFSETS.tool, true),
feed: view.getFloat64(EVENT_OFFSETS.feed, true),
dwellSeconds: view.getFloat64(EVENT_OFFSETS.dwell, true),
endX: view.getFloat64(EVENT_OFFSETS.end + 0, true),
endY: view.getFloat64(EVENT_OFFSETS.end + 8, true),
endZ: view.getFloat64(EVENT_OFFSETS.end + 16, true),
endA: view.getFloat64(EVENT_OFFSETS.end + 24, true),
endB: view.getFloat64(EVENT_OFFSETS.end + 32, true),
endC: view.getFloat64(EVENT_OFFSETS.end + 40, true),
reserved: view.getInt32(EVENT_OFFSETS.reserved, true),
};
}
function writeString(module, value) {
const bytes = module.lengthBytesUTF8(value) + 1;
const ptr = module._malloc(bytes);
module.stringToUTF8(value, ptr, bytes);
return { ptr, len: bytes - 1 };
}
function expectKinematicsSwitch(events, line, kinstype, label) {
if (!events.some((event) => event.type === "kinematics-switch" &&
event.line === line &&
event.reserved === kinstype)) {
throw new Error(`expected LinuxCNC WASM ${label} kinematics switch ${kinstype} on line ${line}`);
}
}
function expectEvent(events, predicate, message) {
if (!events.some(predicate)) {
throw new Error(message);
}
}
async function expectErrorContaining(action, expectedText, message) {
try {
await action();
throw new Error(message);
} catch (error) {
if (!String(error?.message ?? error).includes(expectedText)) {
throw error;
}
}
}
function expectRtcpPivot(events, expected, message) {
expectEvent(
events,
(event) => event.type === "rtcp-pivot" &&
event.line === expected.line &&
event.reserved === expected.reserved &&
near(event.dwellSeconds, expected.dwellSeconds) &&
(expected.endX === undefined || near(event.endX, expected.endX)) &&
(expected.endY === undefined || near(event.endY, expected.endY)) &&
(expected.endZ === undefined || near(event.endZ, expected.endZ)) &&
(expected.endA === undefined || near(event.endA, expected.endA)) &&
(expected.endB === undefined || near(event.endB, expected.endB)) &&
(expected.endC === undefined || near(event.endC, expected.endC)),
message,
);
}
function near(actual, expected) {
return Math.abs(actual - expected) < 1e-6;
}
let completedSteps = 0;
function isIniFileNameField(field) {
return field === "ini" ||
field === "iniFile" ||
field === "inifile" ||
field === "iniFileName" ||
field === "inifilename" ||
field === "ini_file" ||
field === "ini_file_name" ||
field === "INI_FILE_NAME";
}
function loadSwitchkinsCases() {
const configCases = JSON.parse(
fs.readFileSync(path.join(wasmDir, "linuxcnc_switchkins_remap_config_cases.json"), "utf8"),
);
if (!Array.isArray(configCases) || configCases.length === 0) {
throw new Error("expected non-empty generated LinuxCNC switchkins config cases");
}
for (const [index, configCase] of configCases.entries()) {
if (
!configCase ||
typeof configCase.field !== "string" ||
typeof configCase.value !== "string" ||
!Number.isInteger(configCase.m428) ||
!Number.isInteger(configCase.m429) ||
!Number.isInteger(configCase.m430)
) {
throw new Error(`invalid generated LinuxCNC switchkins config case at index ${index}`);
}
}
return configCases.filter((configCase) => !isIniFileNameField(configCase.field)).map((configCase) => ({
label: `${configCase.field}=${configCase.value}`,
config: { backend: "linuxcnc-rs274", [configCase.field]: configCase.value },
program: configCase.m430 >= 0 ? "M428\nM429\nM430\n" : "M428\nM429\n",
expected: [
[1, configCase.m428],
[2, configCase.m429],
...(configCase.m430 >= 0 ? [[3, configCase.m430]] : []),
],
}));
}
async function runStep(name, action) {
try {
await action();
completedSteps += 1;
} catch (error) {
const detail = error && error.stack ? error.stack : error;
throw new Error(`Node WASM smoke step "${name}" failed: ${detail}`);
}
}
(async () => {
const module = await createCncSimModule({
locateFile: (file) => path.join(wasmDir, file),
print: () => {},
printErr: () => {},
});
const create = module.cwrap("cnc_sim_create", "number", []);
const destroy = module.cwrap("cnc_sim_destroy", null, ["number"]);
const reset = module.cwrap("cnc_sim_reset", null, ["number"]);
const setDialect = module.cwrap("cnc_sim_set_dialect", "number", ["number", "number"]);
const setCallback = module.cwrap("cnc_sim_set_event_callback", "number", ["number", "number", "number"]);
const loadConfig = module.cwrap("cnc_sim_load_config_json", "number", ["number", "number", "number"]);
const parseProgram = module.cwrap("cnc_sim_parse_program", "number", ["number", "number", "number"]);
const lastError = module.cwrap("cnc_sim_last_error", "number", ["number"]);
const throwLastError = (targetHandle) => {
throw new Error(module.UTF8ToString(lastError(targetHandle)));
};
const initializeHandle = (targetHandle) => {
reset(targetHandle);
if (setDialect(targetHandle, 0) !== 0) {
throwLastError(targetHandle);
}
};
const loadJsonConfig = (targetHandle, value) => {
const config = writeString(module, JSON.stringify(value));
try {
if (loadConfig(targetHandle, config.ptr, config.len) !== 0) {
throwLastError(targetHandle);
}
} finally {
module._free(config.ptr);
}
};
const parseText = (targetHandle, value) => {
const program = writeString(module, value);
try {
if (parseProgram(targetHandle, program.ptr, program.len) !== 0) {
throwLastError(targetHandle);
}
} finally {
module._free(program.ptr);
}
};
const attachCallback = (targetHandle, targetCallbackPtr) => {
if (setCallback(targetHandle, targetCallbackPtr, 0) !== 0) {
throwLastError(targetHandle);
}
};
const handle = create();
if (!handle) {
throw new Error("cnc_sim_create returned null");
}
let callbackPtr = 0;
try {
initializeHandle(handle);
loadJsonConfig(handle, { backend: "linuxcnc-rs274" });
const events = [];
callbackPtr = module.addFunction((eventPtr) => {
events.push(readEvent(module, eventPtr));
return 0;
}, "iii");
attachCallback(handle, callbackPtr);
const parseCaseWithHandle = (testCase) => {
reset(handle);
events.length = 0;
loadJsonConfig(handle, testCase.config);
parseText(handle, testCase.program);
};
const withConfiguredHandle = async (label, config, program, action) => {
events.length = 0;
const targetHandle = create();
if (!targetHandle) {
throw new Error(`cnc_sim_create returned null for ${label} case`);
}
try {
initializeHandle(targetHandle);
attachCallback(targetHandle, callbackPtr);
loadJsonConfig(targetHandle, config);
parseText(targetHandle, program);
await action();
} finally {
destroy(targetHandle);
}
};
await runStep("basic LinuxCNC RS274 parse", async () => {
parseText(handle, "G21 G90\nG0 X0\nG1 X5 F100\nM30\n");
});
await runStep("basic LinuxCNC RS274 linear-feed event", async () => {
expectEvent(events, (event) => event.type === "linear-feed" && event.endX === 5,
"expected LinuxCNC WASM linear-feed event ending at X5");
});
await runStep("basic LinuxCNC RS274 program-end event", async () => {
expectEvent(events, (event) => event.type === "program-end",
"expected LinuxCNC WASM program-end event");
});
await runStep("default switchkins remap parse", async () => {
events.length = 0;
parseText(handle, "M428\nM429\nM430\n");
});
for (const kinstype of [1, 0, 2]) {
await runStep(`default switchkins kinematics switch ${kinstype}`, async () => {
expectEvent(events, (event) => event.type === "kinematics-switch" && event.reserved === kinstype,
`expected LinuxCNC WASM kinematics switch ${kinstype}`);
});
await runStep(`default switchkins M68 E3 Q${kinstype} side effect`, async () => {
expectEvent(events, (event) => event.type === "comment" &&
event.reserved === 68 &&
event.tool === 3 &&
event.feed === kinstype,
`expected LinuxCNC WASM M68 E3 Q${kinstype} remap side effect`);
});
}
await runStep("default switchkins M66 E0 L0 side effect", async () => {
expectEvent(events, (event) => event.type === "comment" &&
event.reserved === 66 &&
event.tool === 0 &&
event.feed === 0,
"expected LinuxCNC WASM M66 E0 L0 remap side effect");
});
const switchkinsCases = loadSwitchkinsCases();
await runStep("generated switchkins config cases load", async () => {
if (switchkinsCases.length === 0) {
throw new Error("expected generated LinuxCNC WASM switchkins config cases");
}
});
for (const testCase of switchkinsCases) {
await runStep(`generated switchkins ${testCase.label}`, async () => {
parseCaseWithHandle(testCase);
for (const [line, kinstype] of testCase.expected) {
expectKinematicsSwitch(events, line, kinstype, testCase.label);
}
});
}
await runStep("5axiskins RTCP parse", async () => {
reset(handle);
events.length = 0;
loadJsonConfig(handle, {
backend: "linuxcnc-rs274",
rtcp: { enabled: true, toolLength: 250 },
kinematics: "5axiskins",
pivotLength: 250,
});
parseText(handle, "M428\nG0 X260 Y20 Z280 B90 C0\n");
});
await runStep("5axiskins RTCP kinematics switch", async () => {
expectKinematicsSwitch(events, 1, 0, "5axiskins");
});
await runStep("5axiskins RTCP pivot event", async () => {
expectRtcpPivot(events, {
line: 2,
reserved: 0,
dwellSeconds: 250,
endX: 10,
endY: 20,
endZ: 30,
endB: 90,
endC: 0,
}, "expected LinuxCNC WASM 5axiskins RTCP pivot from LinuxCNC 5axiskins inverse");
});
await runStep("userk RTCP parse", async () => {
reset(handle);
events.length = 0;
loadJsonConfig(handle, {
backend: "linuxcnc-rs274",
rtcp: { enabled: true, toolLength: 250 },
});
parseText(handle, "M430\nG0 X260 Y20 Z280 B90 C45\n");
});
await runStep("userk RTCP kinematics switch", async () => {
expectKinematicsSwitch(events, 1, 2, "userk");
});
await runStep("userk RTCP pivot event", async () => {
expectRtcpPivot(events, {
line: 2,
reserved: 2,
dwellSeconds: 250,
endX: 260,
endY: 20,
endZ: 280,
endB: 90,
endC: 45,
}, "expected LinuxCNC WASM userk RTCP pivot from LinuxCNC userk identity inverse");
});
await runStep("configured xyzbc-trt RTCP parse", async () => {
await withConfiguredHandle(
"configured xyzbc-trt",
{
backend: "linuxcnc-rs274",
rtcp: { enabled: false, toolLength: 11 },
xyzbcTrt: {
xRotPoint: 3,
yRotPoint: -4,
zRotPoint: 5,
xOffset: 2,
zOffset: 7,
conventionalDirections: true,
},
},
"M428\nG43.4\nG0 X30 Y40 Z50 B20 C-30\n",
async () => {
},
);
});
await runStep("configured xyzbc-trt kinematics switch", async () => {
expectKinematicsSwitch(events, 1, 1, "configured xyzbc-trt");
});
await runStep("configured xyzbc-trt RTCP pivot event", async () => {
expectRtcpPivot(events, {
line: 3,
reserved: 1,
dwellSeconds: 11,
endX: -4.814629,
endY: 47.605118,
endZ: 48.160567,
endB: 20,
endC: -30,
}, "expected LinuxCNC WASM configured xyzbc-trt RTCP pivot from LinuxCNC trtfuncs inverse");
});
await runStep("configured xyzac-trt RTCP parse", async () => {
await withConfiguredHandle(
"configured xyzac-trt",
{
backend: "linuxcnc-rs274",
rtcp: { enabled: false, toolLength: 7 },
switchkins: "xyzac-trt",
xyzbcTrt: {
yOffset: 20,
zOffset: 10,
},
},
"M428\nG43.4\nG0 X12 Y-8 Z42 A35 C-25\n",
async () => {
},
);
});
await runStep("configured xyzac-trt kinematics switch", async () => {
expectKinematicsSwitch(events, 1, 1, "configured xyzac-trt");
});
await runStep("configured xyzac-trt RTCP pivot event", async () => {
expectRtcpPivot(events, {
line: 3,
reserved: 1,
dwellSeconds: 7,
endX: 7.494747,
endY: -20.815946,
endZ: 18.939732,
endA: 35,
endB: 0,
endC: -25,
}, "expected LinuxCNC WASM configured xyzac-trt RTCP pivot from LinuxCNC xyzab_tdr inverse");
});
await runStep("web wasm-core wrapper defaults and options", async () => {
globalThis.createCncSimModule = createCncSimModule;
const wasmCoreUrl = pathToFileURL(path.join(__dirname, "web", "src", "wasm-core.js")).href;
const { createWasmSimulator } = await import(wasmCoreUrl);
const webSimulator = await createWasmSimulator({
locateFile: (file) => path.join(wasmDir, file),
print: () => {},
printErr: () => {},
});
try {
const defaultBackendEvents = webSimulator.parse("G21 G90\nG1 X3 F30\nM30\n", "linuxcnc");
if (!defaultBackendEvents.some((event) => event.type === "linear-feed" &&
event.line === 2 &&
near(event.end.x, 3))) {
throw new Error("expected web wasm-core default backend to use LinuxCNC RS274");
}
if (webSimulator.fs.opfs !== null) {
throw new Error("expected Node wasm-core wrapper to leave OPFS disabled");
}
await expectErrorContaining(
() => webSimulator.parseFile("programs/node-smoke.ngc", "linuxcnc", { backend: "linuxcnc-rs274" }),
"OPFS workspace is not available",
"expected Node wasm-core parseFile to require OPFS",
);
await expectErrorContaining(
() => webSimulator.parseWithParameterFile("G21 G90\nM30\n", "parameters/node.var", "linuxcnc", {
backend: "linuxcnc-rs274",
}),
"OPFS workspace is not available",
"expected Node wasm-core parseWithParameterFile to require OPFS",
);
await expectErrorContaining(
() => webSimulator.parseFileWithParameterFile(
"programs/node-smoke.ngc",
"parameters/node.var",
"linuxcnc",
{ backend: "linuxcnc-rs274" },
),
"OPFS workspace is not available",
"expected Node wasm-core parseFileWithParameterFile to require OPFS",
);
const webEvents = webSimulator.parse(
"M428\nG43.4\nG0 X12 Y-8 Z42 A35 C-25\n",
"linuxcnc",
{
backend: "linuxcnc-rs274",
rtcp: { enabled: false, toolLength: 7 },
switchkins: "xyzac-trt",
xyzbcTrt: {
yOffset: 20,
zOffset: 10,
},
},
);
expectEvent(webEvents, (event) => event.type === "rtcp-pivot" &&
event.line === 3 &&
event.reserved === 1 &&
near(event.dwellSeconds, 7) &&
near(event.end.x, 7.494747) &&
near(event.end.y, -20.815946) &&
near(event.end.z, 18.939732) &&
near(event.end.a, 35) &&
near(event.end.b, 0) &&
near(event.end.c, -25),
"expected web wasm-core options to pass LinuxCNC xyzac-trt RTCP config into WASM");
} finally {
webSimulator.dispose();
delete globalThis.createCncSimModule;
}
});
} finally {
if (callbackPtr) {
module.removeFunction(callbackPtr);
}
destroy(handle);
}
console.log(`web wasm node smoke passed (${completedSteps} steps)`);
})().catch((error) => {
console.error(error && error.stack ? error.stack : error);
process.exit(1);
}).finally(() => {
fs.rmSync(cjsLoader, { force: true });
});