import assert from "node:assert/strict"; import { createHash } from "node:crypto"; import { readFile } from "node:fs/promises"; import createModule from "../../build-wasm/linuxcnc-sim-core.js"; import { DeviceIoRuntime } from "./device-io.mjs"; import { IoAckTracker } from "./io-ack.mjs"; import { MotionState } from "./motion-state.mjs"; import { SimSession, CONTEXT_ID } from "./sim-session.mjs"; import { fnv1a64 } from "./replay.mjs"; const ROOT = new URL("../../", import.meta.url); const SESSION_ID = "source-io-s5"; async function read(path) { return readFile(new URL(path, ROOT)); } async function fixture(paths) { const files = []; for (const path of paths) { const value = await read(path); files.push({ path, bytes: value.byteLength, sha256: createHash("sha256").update(value).digest("hex") }); } return files; } function resultHash(value) { return fnv1a64(value); } async function hardLimitResult() { const motion = new MotionState(); assert.equal(motion.home().status, "verified"); assert.equal(motion.startJog({ axis: "x", direction: 1, speed: 10 }).status, "verified"); motion.step(1); assert.equal(motion.state.axes.x, 0.01); assert.equal(motion.setLimit("x", true).state.fault, "limit"); const result = { tick: motion.state.tick, axis: motion.state.axes.x, limit: motion.state.limits.x, fault: motion.state.fault, state_hash: motion.hash() }; assert.equal(result.limit, true); return result; } async function limit3Result() { const motion = new MotionState(); assert.equal(motion.home().status, "verified"); assert.equal(motion.startJog({ axis: "x", direction: 1, speed: 500 }).status, "verified"); motion.step(1); assert.equal(motion.setFollowingError(0.02).state.fault, "following_error"); return { tick: motion.state.tick, axis: motion.state.axes.x, following_error: motion.state.following_error, fault: motion.state.fault, state_hash: motion.hash() }; } async function toolAckResult() { const tracker = new IoAckTracker(); assert.equal(tracker.issue("tool-change-7", "TOOL_CHANGE", 0, 2).status, "verified"); assert.equal(tracker.step(1).status, "verified"); const ack = tracker.ack("tool-change-7", 1); assert.equal(ack.command.status, "acked"); assert.equal(ack.command.side_effect_count, 1); return { command: ack.command, state_hash: tracker.hash() }; } async function abortResult(source) { const core = await createModule({ wasmBinary: await read("build-wasm/linuxcnc-sim-core.wasm") }); const session = new SimSession(core, { sessionId: SESSION_ID }); const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: SESSION_ID }; const send = (kind, seq, operation_id, payload = {}) => session.accept({ ...base, kind, seq, operation_id, payload }); send("HELLO", 1); send("SESSION_ATTACH", 2, "attach"); send("LOAD_WORKSPACE", 3, "workspace", { root_path: "/workspace" }); const loaded = send("PROGRAM_LOAD", 4, "load", { source, path: "startup-abort.ngc" }); assert.equal(loaded.kind, "COMMAND_RESULT"); send("PROGRAM_RUN", 5, "run"); const aborted = send("PROGRAM_ABORT", 6, "abort"); assert.equal(aborted.payload.task.program_status, "aborted"); return { task_program_status: aborted.payload.task.program_status, task_hash: aborted.payload.task.state_hash, session_tick: session.tick }; } async function main() { const cases = []; const add = async (id, adapter, paths, result, status = "PASSED", reason = null) => cases.push({ id, adapter, status, ...(reason ? { reason } : {}), fixture: await fixture(paths), result, result_hash: resultHash(result) }); await add("source-test:linuxcnc/tests/hard-limits/test.sh", "WORKER_SIM_ADAPTER", ["linuxcnc/tests/hard-limits/test.sh", "linuxcnc/tests/hard-limits/hard-limits.ini", "linuxcnc/tests/hard-limits/hard-limits.hal", "linuxcnc/tests/hard-limits/checkresult"], await hardLimitResult()); await add("source-test:linuxcnc/tests/limit3/limit-max-velocity/test.hal", "WORKER_SIM_ADAPTER", ["linuxcnc/tests/limit3/limit-max-velocity/test.hal", "linuxcnc/tests/limit3/limit-max-velocity/expected"], await limit3Result()); await add("source-test:linuxcnc/tests/toolchanger/m61/test.sh", "WORKER_SIM_ADAPTER", ["linuxcnc/tests/toolchanger/m61/test.sh", "linuxcnc/tests/toolchanger/m61/checkresult", "linuxcnc/tests/toolchanger/m61/m61-test.ini", "linuxcnc/tests/toolchanger/m61/mod_core_sim.hal", "linuxcnc/tests/toolchanger/m61/test-ui.py"], await toolAckResult()); for (const [id, subdir, expectedTool] of [ ["source-test:linuxcnc/tests/io-startup/nonrandom/no-tool-in-p0/test.sh", "no-tool-in-P0", 0], ["source-test:linuxcnc/tests/io-startup/nonrandom/tool-in-p0/test.sh", "tool-in-P0", 0], ]) { const expected = (await read(`linuxcnc/tests/io-startup/nonrandom/${subdir}/expected-startup-tool-number`)).toString().trim(); assert.equal(Number(expected), expectedTool); await add(id, "SOURCE_BLOCKED", [ `linuxcnc/tests/io-startup/nonrandom/${subdir}/expected-startup-tool-number`, `linuxcnc/tests/io-startup/nonrandom/${subdir}/tool.tbl.original`, "linuxcnc/tests/io-startup/nonrandom/test.ini", "linuxcnc/tests/io-startup/shared-test.sh", "linuxcnc/tests/io-startup/checkresult", ], { expected_startup_tool: Number(expected), reason: "startup tool table requires full Task/configuration runtime" }, "SOURCE_BLOCKED", "startup tool table requires full Task/configuration runtime"); } const abortSource = (await read("linuxcnc/tests/motion-logger/startup-gcode-abort/nc_files/init.ngc")).toString("utf8"); await add("source-test:linuxcnc/tests/motion-logger/startup-gcode-abort/test.sh", "WORKER_TASK_MOTION_PENDING", [ "linuxcnc/tests/motion-logger/startup-gcode-abort/test.sh", "linuxcnc/tests/motion-logger/startup-gcode-abort/test.ini", "linuxcnc/tests/motion-logger/startup-gcode-abort/test-ui.py", "linuxcnc/tests/motion-logger/startup-gcode-abort/nc_files/init.ngc", "linuxcnc/tests/motion-logger/startup-gcode-abort/checkresult", "linuxcnc/tests/motion-logger/startup-gcode-abort/expected.motion-logger.in", ], await abortResult(abortSource), "WORKER_TASK_MOTION_PENDING", "motion logger and startup abort endpoint require Task/motion adapter"); await add("source-test:linuxcnc/tests/classicladder/estop/test.sh", "HOST_EXCLUDED", [ "linuxcnc/tests/classicladder/estop/test.sh", "linuxcnc/tests/classicladder/estop/classicladder-estop.ini", "linuxcnc/tests/classicladder/estop/classicladder-estop.hal", "linuxcnc/tests/classicladder/estop/classicladder-estop.clp", "linuxcnc/tests/classicladder/estop/classicladder-estop-ui.py", "linuxcnc/tests/classicladder/estop/checkresult", ], { reason: "ClassicLadder GUI/runtime is host excluded" }, "HOST_EXCLUDED", "ClassicLadder GUI/runtime is host excluded"); const counts = Object.fromEntries(["PASSED", "SOURCE_BLOCKED", "HOST_EXCLUDED", "WORKER_TASK_MOTION_PENDING"].map((status) => [status, cases.filter((item) => item.status === status).length])); const output = { schema: "linuxcnc-source-io-adapter.v1", context_id: CONTEXT_ID, cases, counts, fixture_file_count: new Set(cases.flatMap((item) => item.fixture.map((file) => file.path))).size, boundaries: [ "startup tool table resolution remains SOURCE_BLOCKED until full Task/configuration startup is available", "motion logger abort remains WORKER_TASK_MOTION_PENDING; no GUI or wall-clock result is promoted", "ClassicLadder and realtime hardware paths remain HOST_EXCLUDED", ], }; output.aggregate_hash = resultHash({ cases: output.cases, counts: output.counts }); console.log(JSON.stringify(output)); } await main();