import assert from 'node:assert/strict'; import { readFile, mkdir, writeFile } from 'node:fs/promises'; import createModule from '../../build-wasm/linuxcnc-sim-core.js'; import { XyzacSession, CONTEXT_ID } from './xyzac-session.mjs'; import { loadSimulationCase, toWorkerConfiguration } from '../workspace/sim-case-loader.mjs'; import { MACHINE_DEFINITIONS } from '../workspace/machine-definitions.mjs'; const root = new URL('../../', import.meta.url); const output = new URL('coverage/glb-machine/jog-limits/', root); await mkdir(output, { recursive: true }); const fetchImpl = async (url) => { try { const body = await readFile(new URL(String(url).replace(/^\//, ''), root), 'utf8'); return { ok: true, text: async () => body }; } catch { return { ok: false }; } }; const report = { report_id: 'jog-limit-recovery-v1', status: 'running', machines: [] }; try { for (const definition of MACHINE_DEFINITIONS) { const core = await createModule({ wasmBinary: await readFile(new URL('build-wasm/linuxcnc-sim-core.wasm', root)) }); const session = new XyzacSession(core, { sessionId: `jog-limit-${definition.id}`, planner: 'linuxcnc-tp' }); let seq = 0; const send = (kind, payload = {}, expected = null) => { const result = session.accept({ schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId, kind, seq: ++seq, operation_id: `limit-${seq}`, payload }); if (expected) assert.equal(result.kind, expected, JSON.stringify(result.payload)); else assert.notEqual(result.kind, 'ERROR', `${kind}: ${result.payload.message_key ?? result.payload.gate_reason}`); assert.equal(session.timeline.events.cursor, seq, `${kind}: event cursor`); assert.equal(core._sim_event_cursor(), seq, `${kind}: native event cursor`); return result; }; send('HELLO'); send('SESSION_ATTACH'); const bundle = await loadSimulationCase({ path: definition.entrypoint, fetchImpl }); send('LOAD_WORKSPACE', { root_path: '/workspace', configuration: toWorkerConfiguration(bundle) }); send('POWER', { active: true }); send('HOME', { all: true }); for (let i = 0; i < 500 && !session.wasmHomeLimit.read().state.homed; i++) send('SIM_STEP', { cycles: 1 }); const tilt = definition.axes[3]; const drain = () => { for (let i = 0; i < 2000 && session.wasmMotion.read().state.queue_depth; i++) send('SIM_STEP', { cycles: 5, response: 'motion_delta' }); assert.equal(session.wasmMotion.read().state.queue_depth, 0); }; send('MDI_EXECUTE', { block: `G21 G90 G0 X0 Y0 Z30 ${tilt.toUpperCase()}49 C20` }); drain(); send('JOG_START', { axis: tilt, direction: 1, continuous: true, increment: null }); drain(); const limit = session.wasmMotion.read().state; assert.ok(limit.joint_commanded[3] <= 50 + 1e-8); send('JOG_STOP'); send('JOG_START', { axis: tilt, direction: -1, increment: 1 }); drain(); send('JOG_STOP'); assert.ok(Math.abs(session.wasmMotion.read().state.joint_commanded[3] - 49) < 1e-7); send('JOG_START', { axis: tilt, direction: 1, increment: 5 }, 'ERROR'); send('JOG_STOP'); send('SNAPSHOT_REQUEST'); const recovered = session.wasmMotion.read().state.joint_commanded; send('MDI_EXECUTE', { block: `G90 G0 X190 Y0 Z30 ${tilt.toUpperCase()}0 C0` }); drain(); send('KINEMATICS_SELECT', { type: 1 }); send('JOG_START', { axis: 'c', direction: 1, continuous: true, increment: null }); let fault; for (let i = 0; i < 2000; i++) { const r = session.accept({ schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId, kind: 'SIM_STEP', seq: ++seq, operation_id: `limit-${seq}`, payload: { cycles: 5, response: 'motion_delta' } }); if (r.kind === 'ERROR') { fault = r; break; } } assert.equal(fault?.payload.code, 'motion_limit', JSON.stringify(fault?.payload)); assert.equal(session.timeline.events.cursor, seq); assert.equal(core._sim_event_cursor(), seq); assert.equal(session.timeline.events.tick, session.tick); assert.equal(session.scheduler.tick, session.tick); const stopped = session.wasmMotion.read().state; assert.equal(stopped.queue_depth, 0); assert.equal(stopped.velocity, 0, 'limit stop must publish zero velocity'); assert.ok(stopped.joint_commanded.every((value, i) => { const range = session.xyzacKinematics.options.limits[definition.axes[i]]; return value >= range[0] - 1e-8 && value <= range[1] + 1e-8; })); send('JOG_STOP'); send('SNAPSHOT_REQUEST'); const checkpoint = session.createCheckpoint(); const restored = session.restoreCheckpoint(checkpoint); assert.equal(restored.status, 'recovered'); send('JOG_START', { axis: 'c', direction: -1, increment: 1 }); drain(); send('JOG_STOP'); assert.ok(session.wasmMotion.read().state.position.c < stopped.position.c - .99); // Invalid sampling requests must also consume their event at the actual // clock, rather than reserving ticks that will never run. send('SIM_STEP', { cycles: 1000001 }, 'ERROR'); send('SIM_STEP', { cycles: -1 }, 'ERROR'); send('SNAPSHOT_REQUEST'); send('SIM_STEP', { cycles: 1, response: 'motion_delta' }); report.machines.push({ coordinates: definition.coordinates, limit: limit.joint_commanded, recovered, rtcp_limit: stopped.joint_commanded, stopped_tick: fault.simulation_tick, completed_cycles: fault.payload.completed_cycles, seq }); } report.status = 'verified'; } catch (error) { report.status = 'failed'; report.error = String(error.stack ?? error); throw error; } finally { await writeFile(new URL('worker.report.json', output), JSON.stringify(report, null, 2) + '\n'); } console.log(JSON.stringify(report));