import assert from "node:assert/strict"; import { createHash } from "node:crypto"; import { readFile } from "node:fs/promises"; const root = new URL("../", import.meta.url); const read = (path) => readFile(new URL(path, root), "utf8"); const [axisPy, axisTcl, taskSource, contract, app, worker, programCore, runtime] = await Promise.all([ read("linuxcnc/src/emc/usr_intf/axis/scripts/axis.py"), read("linuxcnc/share/axis/tcl/axis.tcl"), read("linuxcnc/src/emc/task/emctaskmain.cc"), read("docs/linuxcnc-axis-web-control-panel-source-logic.md"), read("web/react/src/App.tsx"), read("web/worker/sim-session.mjs"), read("wasm-core/src/sim_program.c"), read("web/worker/wasm-program-runtime.mjs"), ]); const sha256 = (value) => createHash("sha256").update(value).digest("hex"); const squash = (value) => value.replace(/\\\s*/g, " ").replace(/\s+/g, " "); const axisPySquashed = squash(axisPy); const axisTclSquashed = squash(axisTcl); assert.equal(sha256(axisPy), "328c038a1e8fefd952e53c8e398e3fa98b04602351c44318edd1b044e8f63ffa", "axis.py drifted from the documented source lock"); assert.equal(sha256(axisTcl), "c9f0b4dc686717b37565f85fb0ab8cc8ded9cfa0028cc0aaaccdbcf808c4c524", "axis.tcl drifted from the documented source lock"); for (const symbol of ["manual_ok", "ensure_mode", "open_file_guts", "task_run", "task_step", "task_pause", "task_resume", "task_stop", "send_mdi_command", "home_all_joints", "home_joint", "touch_off_system", "touch_off_tool", "spindle_forward_toggle", "spindle_backward_toggle", "toggle_override_limits", "cycle_view"]) assert.match(axisPy, new RegExp(`(?:def|class) ${symbol}`), `missing source symbol ${symbol}`); for (const binding of ["machine_estop", "machine_power", "file_open", "reload", "program_run", "program_step", "program_pause", "program_stop", "program_blockdelete", "program_optpause", "view_zoomin", "view_zoomout", "clear_plot", "jogminus", "jogplus", "zerohome.home", "zerohome.zero", "zerohome.tooltouch", "spindlef.ccw", "spindlef.stop", "spindlef.cw"]) assert.match(axisTcl, new RegExp(`(?:toolbar\\.${binding}|manual\\.jogf|manual\\.spindlef|manual\\.jogf\\.zerohome)`), `missing source binding ${binding}`); for (const taskTerm of ["EMC_TASK_PLAN_RUN_TYPE", "emcTaskPlanOpen", "emcTaskPlanClose", "programStartLine = run_msg->line"]) assert.match(taskSource, new RegExp(taskTerm.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")), `missing task source ${taskTerm}`); for (const formula of [ "state {$interp_state == $INTERP_IDLE} .toolbar.file_open", "state {$interp_state == $INTERP_IDLE && $taskfile != \"\"} .toolbar.reload", "state {$task_state == $STATE_ON && $interp_state == $INTERP_IDLE } .toolbar.program_run", "state {$task_state == $STATE_ON && $taskfile != \"\"} .toolbar.program_step", "state {$task_state == $STATE_ON && $interp_state != $INTERP_IDLE} .toolbar.program_pause", "state {$task_state == $STATE_ON && $interp_state != $INTERP_IDLE} .toolbar.program_stop", "&& $spindledir != 0} $::_tabs_manual.spindlef.spindleminus $::_tabs_manual.spindlef.spindleplus", ]) assert.ok(axisTclSquashed.includes(formula), `missing exact AXIS update_state formula: ${formula}`); for (const sourceFlow of [ "if s.task_state == linuxcnc.STATE_ESTOP: c.state(linuxcnc.STATE_ESTOP_RESET) else: c.state(linuxcnc.STATE_ESTOP)", "if s.task_state == linuxcnc.STATE_ESTOP_RESET: c.state(linuxcnc.STATE_ON) else: c.state(linuxcnc.STATE_OFF)", "ensure_mode(linuxcnc.MODE_AUTO) c.auto(linuxcnc.AUTO_RUN, program_start_line) program_start_line = 0", "if s.paused: global resume_inhibit if resume_inhibit: return c.auto(linuxcnc.AUTO_RESUME) elif s.interp_state != linuxcnc.INTERP_IDLE: c.auto(linuxcnc.AUTO_PAUSE)", "def clear_live_plot(*ignored): live_plotter.clear()", ]) assert.ok(axisPySquashed.includes(sourceFlow), `missing exact AXIS handler flow: ${sourceFlow}`); const livePlotClass = axisPy.slice(axisPy.indexOf("class LivePlotter:"), axisPy.indexOf("def running(")); assert.match(livePlotClass, /def clear\(self\):\s+self\.logger\.clear\(\)/); const pythonFunction = (name) => { const match = new RegExp(`(^|\\n)( *)def ${name}\\(`, "m").exec(axisPy); assert.ok(match, `missing handler ${name}`); const start = match.index + match[1].length; const bodyStart = start + match[0].length - match[1].length; const nextRelative = axisPy.slice(bodyStart).search(new RegExp(`\\n${match[2]}def `)); return axisPy.slice(start, nextRelative < 0 ? axisPy.length : bodyStart + nextRelative); }; for (const sourceHandler of ["open_file_guts", "reload_file", "task_run", "task_step", "task_pause", "task_resume", "task_stop"]) { assert.doesNotMatch(pythonFunction(sourceHandler), /live_plotter\.clear|logger\.clear/, `${sourceHandler} must not clear native Live Plot`); } for (const sourceHandler of ["PROGRAM_LOAD", "PROGRAM_RUN", "PROGRAM_RESTART"]) { const start = worker.indexOf(`case "${sourceHandler}"`); const next = worker.indexOf("\n case ", start + 8); assert.ok(start >= 0, `missing Worker handler ${sourceHandler}`); assert.doesNotMatch(worker.slice(start, next < 0 ? worker.length : next), /livePathEpoch\s*\+=/, `${sourceHandler} must not clear native Live Plot`); } for (const term of ["SOURCE_EXACT", "SOURCE_ADAPTER", "SOURCE_BLOCKED", "WEB_EXTENSION", "Source-lock manifest", "Source-exact widget enable formulas", "Exhaustive native-action ledger", "manual_ok", "ensure_mode", "open_file_guts", "EMC_TASK_PLAN_RUN", "PROGRAM_LOAD", "PROGRAM_RUN", "PROGRAM_STEP", "PROGRAM_PAUSE", "PROGRAM_RESUME", "PROGRAM_ABORT", "JOG_START", "HOME", "TOUCH_OFF", "Run after completion or Stop", "Toolpath display lifecycle", "live_path_epoch", "positionlogger", "blocked_by_abi_tool_offsets", "Button Dependency Matrix", "explicit `VISUAL_CLEAR` source adapter advances the epoch"]) assert.match(contract, new RegExp(term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")), `contract missing ${term}`); assert.match(contract, /Native AXIS does \*\*not\*\* bind lowercase `c` to C-axis selection/); assert.match(contract, /Feed\/spindle\/rapid override and max-velocity sliders \| No `update_state` disable formula/); assert.match(contract, /PROGRAM_LOAD accepted -> live epoch unchanged/); assert.match(contract, /PROGRAM_RUN accepted\s+-> live epoch unchanged/); assert.doesNotMatch(contract, /PROGRAM_RUN accepted\s+-> increment live_path_epoch/); assert.match(worker, /case "VISUAL_CLEAR"/); assert.match(app, /Begin executing current file \[R\]/); assert.match(app, /Reload current G-code \[Ctrl-R\]/); assert.match(app, /Rapid Override/); assert.doesNotMatch(app, /Reset machine/); assert.doesNotMatch(app, /Run Startup Sequence/); assert.doesNotMatch(app, /G92\s+\$\{selectedAxis/); assert.doesNotMatch(app, /cachedProgram/); assert.doesNotMatch(app, /program:\s*\{\s*path:\s*programPath/); assert.doesNotMatch(app, /clearLoadedPreview/); assert.match(contract, /exactly one PROGRAM_LOAD for the configured OPEN_FILE/); assert.match(worker, /blocked_by_abi_tool_offsets/); assert.doesNotMatch(worker, /xyzac_program_aborted_requires_restart/); assert.match(worker, /reopened_taskfile/); assert.match(worker, /livePathEpoch/); assert.doesNotMatch(worker.match(/const visualClearResponse[\s\S]*?switch \(input\.kind\)/)?.[0] ?? "", /previewState\s*=\s*null/); assert.match(programCore, /SIM_PROGRAM_COMPLETED \|\| program->status == SIM_PROGRAM_ABORTED/); assert.match(programCore, /sim_program_set_position/); assert.match(runtime, /synchronizePosition/); assert.match(worker, /xyzac_motion_active/); console.log(JSON.stringify({ schema: "axis-control-source-contract.v2", status: "verified", source: "linuxcnc-axis-web-control-panel-source-logic.md", source_lock: { axis_py_sha256: sha256(axisPy), axis_tcl_sha256: sha256(axisTcl) }, checked: { axis_py_symbols: 17, axis_tcl_bindings: 21, axis_tcl_enable_formulas: 7, axis_handler_flows: 5, native_live_plot_non_clear_handlers: 7, task_source_edges: 4, app_source_controls: 3, worker_abi_boundaries: 1, direct_rerun_edges: 2 } }));