From bc69c513e34a0e959bcdeb200128f06f5245ae54 Mon Sep 17 00:00:00 2001 From: wangdequan Date: Mon, 7 Sep 2026 15:48:49 +0800 Subject: [PATCH] Enforce complete AXIS control inventory --- ...cnc-axis-web-control-panel-source-logic.md | 79 +++++++++++++++++-- scripts/test_axis_control_inventory.mjs | 35 ++++++++ .../test_axis_control_inventory_browser.mjs | 55 +++++++++++++ scripts/test_xyzac_panel_buttons_browser.mjs | 64 +++++++++++---- wasm-core/src/sim_io.c | 4 +- wasm-core/tests/sim_io_test.c | 5 ++ web/react/index.html | 1 + web/react/src/App.tsx | 59 +++++++------- web/react/src/XyzacToolPreview.tsx | 2 +- web/react/src/XyzacTrtPanel.tsx | 2 +- web/react/src/styles.css | 5 ++ web/worker/test_xyzac_panel_buttons.mjs | 4 +- 12 files changed, 261 insertions(+), 54 deletions(-) create mode 100644 scripts/test_axis_control_inventory.mjs create mode 100644 scripts/test_axis_control_inventory_browser.mjs diff --git a/docs/linuxcnc-axis-web-control-panel-source-logic.md b/docs/linuxcnc-axis-web-control-panel-source-logic.md index 6b05060..13241b8 100644 --- a/docs/linuxcnc-axis-web-control-panel-source-logic.md +++ b/docs/linuxcnc-axis-web-control-panel-source-logic.md @@ -72,11 +72,11 @@ replaced by a different action. | Source surface | Source entries | Web treatment | | --- | --- | --- | -| File menu | Open, Recent Files, Edit, Reload, Save As, Properties, Edit Tool Data, Reload Tool Data, Ladder Editor, Quit | Open/Reload/Load are Worker-backed; editor text is the Web file source; save, external editors, Ladder and Quit are host-boundary status/disabled entries. | +| File menu | Open, Recent Files, Edit, Reload, Save As, Properties, Edit Tool Data, Reload Tool Data, Ladder Editor, Quit | Open and Reload have one visible toolbar entry each. Edit Tool Data and Quit remain visible but disabled with a host-boundary reason. Other desktop-only entries are not rendered. | | Machine menu | E-stop, Power, Run, Run from selected line, Step, Pause, Resume, Stop, Optional Stop, Block Delete, MDI history, Calibration/HAL tools, Homing, Unhoming, Zero coordinate system, Tool touch-off target | E-stop/Power/Run/Step/Pause/Resume/Stop/Home/Touch Off are represented by documented commands; Run-from-line, MDI clipboard/file actions, calibration/HAL desktop tools and Unhoming are not exposed; Optional Stop, Block Delete, Override Limits and Tool Touch Off remain ABI-blocked. | | View menu | Six preset views, inches/mm, show program/rapids/live plot/tool/extents/offsets/limits/velocity/DTG, grid, DRO font, PyVCP, commanded/actual, machine/relative, joint/world, clear plot | Preset view/zoom/rotate, units, commanded/actual, machine/relative and supported visual layers are Web projections; unsupported teleop, grid/DTG/font/PyVCP settings are not aliased to motion state. | | Toolbar | E-stop, Power, Open, Reload, Run, Step, Pause/Resume, Stop, Block Delete, Optional Stop, zoom, six views, rotate, Clear Plot | Same source order and icon assets are retained for supported controls; unsupported controls are disabled with a source/ABI reason. | -| Manual notebook | Axis/joint selector, continuous/increment jog, Home Axis, Touch Off, Tool Touch Off, Override Limits, spindle direction/stop/speed/brake, coolant, tool/probe | XYZAC axis selector, jog, Home Axis, Touch Off, spindle, coolant, tool and probe are represented; joint selector, Tool Touch Off and Override Limits are explicitly bounded by the selected configuration/ABI. | +| Manual notebook | Axis/joint selector, continuous/increment jog, Home Axis, Touch Off, Tool Touch Off, Override Limits, spindle direction/stop/speed/brake and coolant | XYZAC axis selector, jog, Home Axis, Touch Off, spindle and coolant are represented. Joint selection, Tool Touch Off and Override Limits are explicitly bounded by the selected configuration/ABI. Tool and probe state is read-only; Prepare/Change/Arm/Disarm are IO/HAL operations, not AXIS main-panel controls. | | Web case strip | no AXIS equivalent | `Load exact XYZAC TRT case` is a Web workspace/bootstrap control, not an AXIS source button. It may only call the same documented `LOAD_WORKSPACE` + `PROGRAM_LOAD` transaction and must not introduce a second Run/Power/Home flow. | | MDI notebook | MDI entry, Go, history selection/double-click, Up/Down history, Ctrl-M/H/Shift-H clipboard operations | Entry, Go and local history are represented; clipboard/file history operations are host/UI-only and are not sent as machine commands. | | Browser UI persistence | AXIS preferences do not override `[DISPLAY] OPEN_FILE` with executable source text | OPFS may persist presentation preferences such as view, zoom, tabs, units and selected axis. It must not persist or automatically load G-code source/path. | @@ -127,7 +127,7 @@ command transaction, not a React state assignment. | Emergency Stop / Cancel | `ESTOP {active}` | `estop_clicked` toggles `STATE_ESTOP` and `STATE_ESTOP_RESET`; active E-stop aborts the interpreter and TP, then keeps the loaded taskfile. | | Power on / off | `POWER {active}` | `onoff_clicked` toggles `STATE_ON` and `STATE_OFF`; power-off aborts motion but preserves the loaded file. | | Open G-code file | browser file + `PROGRAM_LOAD` | `open_file_guts`: poll, save mode, enter MANUAL, restore the saved tab mode before `PLAN_OPEN/program_open`, set first line, synchronize, parse/preview, then `set_mode_from_tab` after refresh. | -| Load case / Load editor | `PROGRAM_LOAD` | Same replacement transaction as `open_file_guts`; old preview is cleared before parsing and never reused after a failed replacement. | +| Load case / packaged program / edited source | `LOAD_WORKSPACE` and/or `PROGRAM_LOAD` | The Web case bootstrap and packaged-program selector use the same replacement transaction as `open_file_guts`. Edited text is submitted only through the single toolbar Reload command; the program pane has no duplicate Load/Run/Step buttons. Old preview is cleared before parsing and never reused after a failed replacement. | | Switchkins Identity / TCP: XYZAC / User K | `KINEMATICS_SELECT {type}` | Selected-case remap equivalent for `M429`/`M428`/`M430` from `xyzac-trt.ini`; requires homed, idle, empty TP queue and canonical HAL switchkins state. | | Run | `PROGRAM_RUN` | `run_warn`, `ensure_mode(AUTO)`, `AUTO_RUN` from the selected start line, then periodic task/TP ticks. Normal AXIS Run uses line 0. In `emctaskmain.cc`, an ended or aborted task plan is closed; the next `EMC_TASK_PLAN_RUN` reopens the current taskfile and runs from line 0. Therefore Run after `completed` or `aborted` must work directly and must not require a separate Reload/Restart click. | | Run from selected line | not exposed | AXIS `task_run_line` requires an editor highlight and source `AUTO_RUN` start line. The Web editor has no source-line selection command yet, so no partial/fake button is rendered. | @@ -151,8 +151,7 @@ command transaction, not a React state assignment. | Spindle - / + | `SPINDLE_COMMAND {delta}` → `SPINDLE_CONSTANT` on release | AXIS binds Button-1 to `spindle_decrease`/`spindle_increase` and ButtonRelease to `spindle_constant`; Web preserves this press/release sequence. | | Brake | `SPINDLE_BRAKE` | Source brake command, gated by `manual_ok`. | | Mist / Flood | `COOLANT_COMMAND` | Independent source coolant toggles; canonical device IO is returned after every edge. | -| Prepare / Change tool | `TOOL_PREPARE` / `TOOL_CHANGE` | Source tool IO authority and acknowledgement path; no simulated completion is reported before the Worker verifies it. | -| Arm / Disarm probe | `PROBE_CONFIG` | Source probe IO authority; faults and E-stop remain Worker-owned. | +| Tool / probe status | read-only | AXIS does not put Prepare/Change/Arm/Disarm buttons in this manual notebook. Tool changes remain interpreter/MDI and IO/HAL operations. Web displays canonical tool/probe state but exposes no extra main-panel command buttons. | | Jog Speed | `SET_JOG_SCALE` | `update_jog_slider_vel` writes the source linear jog scale. | | Angular Jog Speed | not exposed | AXIS has a separate `ajogspeed` control for rotary joints; the selected WASM TP exposes one shared jog scale only, so the Web panel does not alias linear Jog Speed to this control. | | Feed Override | `SET_OVERRIDE` | `set_feedrate` writes the source feed override; updates are debounced but ordered in the Worker queue. | @@ -160,7 +159,69 @@ command transaction, not a React state assignment. | Max Velocity | `SET_MAX_VELOCITY` | AXIS `set_maxvel` calls `c.maxvel` and changes the trajectory velocity limit. The selected WASM TP exposes this as its velocity scale. | | Rapid Override | disabled | AXIS `set_rapidrate` calls `c.rapidrate` and is distinct from Max Velocity. The selected WASM TP has no rapid-scale setter, so the Web control is disabled with `blocked_by_abi_rapidrate`. | | MDI command/history | `MDI_EXECUTE` | `send_mdi` validates `manual_ok`, enters MDI, de-duplicates adjacent history entries and submits `c.mdi`. | -| File edit/tool data/quit and desktop diagnostics | disabled or status-only | AXIS source launches external editor, tool editor, Ladder, HAL tools, LinuxCNC status and process quit. These are host-boundary operations and are not represented as browser controls. | +| File tool-data editor / Quit | disabled | AXIS launches a host editor or terminates the desktop process. Browser controls remain visibly disabled with `host_boundary_tool_editor` / `host_boundary_process_quit`; clicking them cannot report a false success. | +| About AXIS | local dialog | Mirrors the source `.about` dialog and its OK/close action. It changes no Worker, task, TP or IO state. | + +## Complete Web Control Inventory + +Every rendered actionable element has a stable `data-axis-control` value. This +table is exhaustive for the selected XYZAC panel. A control may not be added to +React until its ID, source mapping, gate and result are added here. Repeated MDI +history rows intentionally share the family ID `mdi-history-select`. + +| Control ID(s) | Source mapping | Operation and gate | Accepted result / disabled behavior | +| --- | --- | --- | --- | +| `menu-file`, `menu-help` | `axis.tcl` File/Help cascades | Local menu disclosure; always available | Opens/closes menu only; no machine state change | +| `menu-file-edit-tool-data`, `menu-file-quit` | `edit_tooltable`, `quit` host process operations | Permanently disabled in a browser | `host_boundary_tool_editor` / `host_boundary_process_quit`; no fake status success | +| `menu-help-about`, `about-close` | `.menu.help` About entry and `.about.ok` | Local modal open/close | About visibility changes only | +| `toolbar-estop` | `.toolbar.machine_estop` -> `estop_clicked` | `ESTOP {active}`; accepted from every task state | Toggle source E-stop edge; active edge aborts interpreter/TP | +| `toolbar-power` | `.toolbar.machine_power` -> `onoff_clicked` | `POWER {active}`; reset E-stop before power-on | Canonical `STATE_ON`/`STATE_OFF`; power-off aborts motion | +| `toolbar-open`, `program-file-input` | `.toolbar.file_open` -> `open_file`/`open_file_guts` | File picker then `PROGRAM_LOAD`; interpreter must be IDLE | One replacement transaction; input itself is hidden and only the toolbar button opens it | +| `toolbar-load-case` | Web workspace boundary; uses INI `DISPLAY`, `HAL`, `KINS` and `OPEN_FILE` inputs | `LOAD_WORKSPACE` then the documented `PROGRAM_LOAD`; IDLE only | Reloads the selected exact case; never adds Power/Home/Run aliases | +| `toolbar-reload` | `.toolbar.reload` -> `reload_file` | `PROGRAM_LOAD` of current editor source/path; IDLE taskfile required | Replaces preview/interpreter at line 0; this is the only visible commit action for edited source | +| `toolbar-run` | `.toolbar.program_run` -> `task_run` | `PROGRAM_RUN`; ON, taskfile, IDLE and task-layer homing gate | AUTO/RUNNING from line 0; completed/aborted taskfile reopens directly | +| `toolbar-step` | `.toolbar.program_step` -> `task_step` | `PROGRAM_STEP`; ON, taskfile and homing gate | AUTO_STEP then PAUSED at the next source boundary | +| `toolbar-pause-resume` | `.toolbar.program_pause` -> `task_pauseresume` | Pause while READING/WAITING; Resume while PAUSED | One source toggle whose icon/label and command follow canonical pause state | +| `toolbar-stop` | `.toolbar.program_stop` -> `task_stop` | `PROGRAM_ABORT`; visible button enabled only for non-IDLE interpreter | Abort, drain/clear TP and retain selected taskfile | +| `toolbar-block-delete`, `toolbar-optional-pause` | Source toolbar toggles | Permanently disabled for selected ABI | `blocked_by_abi`; no React-only toggle | +| `toolbar-zoom-in`, `toolbar-zoom-out` | `zoomin`, `zoomout` | Local preview camera only | Bounded camera zoom; task/TP unchanged | +| `toolbar-view-z`, `toolbar-view-z2`, `toolbar-view-x`, `toolbar-view-y`, `toolbar-view-y2`, `toolbar-view-p` | `set_view_z/z2/x/y/y2/p` | Local source view selection | Exactly one pressed view; opens Preview tab; machine state unchanged | +| `toolbar-rotate` | `.toolbar.rotate` | Local drag/rotate projection | Toggles pressed state and viewer interaction only | +| `toolbar-clear-live-plot` | `clear_live_plot` -> `positionlogger.clear()` | Worker `VISUAL_CLEAR`; always visual-only | Advances `live_path_epoch`, clears Live Plot only and retains static preview | +| `workspace-case-select` | Web selection of the INI/configuration input | Local selection; selected catalog is restricted to supported XYZAC case | Does not load or change machine state until `toolbar-load-case` | +| `workspace-load-3d-chips` | Web packaged-file equivalent of `open_file_guts` | Fetch published `3D_Chips.ngc`, then `PROGRAM_LOAD`; IDLE only | Same canonical file replacement path as Open; no automatic Run/Home | +| `manual-home-all` | Machine Homing menu -> `home_all_joints` | `HOME {all:true}`; `manual_ok`, then MANUAL | First Home homes all; canonical homed mask changes label to Re-home; repeat asks source confirmation | +| `switchkins-0`, `switchkins-1`, `switchkins-2` | `M429`, `M428`, `M430` remaps and `motion.switchkins-type` | `KINEMATICS_SELECT`; ON, homed, IDLE, empty TP | Identity/XYZAC/User K canonical type, or stable gate reason | +| `tab-manual`, `tab-mdi` | AXIS notebook F3/F5 `ensure_manual`/`ensure_mdi` | `SET_MODE`; no active AUTO motion | Tab selection plus acknowledged MANUAL/MDI mode change | +| `tab-diagnostics` | Web diagnostics boundary | Local tab only | Reads Worker state/gates; sends no command | +| `tab-preview`, `tab-dro` | AXIS preview/DRO presentation | Local tab only | Changes visible projection; machine state unchanged | +| `manual-axis-x`, `manual-axis-y`, `manual-axis-z`, `manual-axis-a`, `manual-axis-c` | AXIS axis radio group / `ja_rbutton` | Local axis selection | Supplies the selected axis to Jog/Home/Touch Off; sends no motion by itself | +| `manual-jog-increment` | `jogincrement` combobox | Local choice: Continuous or configured increment | Determines `JOG_CONTINUOUS` versus `JOG_INCREMENT` payload only | +| `manual-jog-negative`, `manual-jog-positive` | `jogminus`, `jogplus` -> `jog_on` | `JOG_START`; visible Manual tab, ON, IDLE, homed | Incremental submits once; Continuous repeats source-rate segments while held | +| `manual-home-axis` | `home_joint` | `HOME {axis}`; `manual_ok`, MANUAL, identity kinematics for coordinate-axis home | Canonical joint home; repeated home asks confirmation | +| `manual-touch-off` | `touch_off_system` | `TOUCH_OFF`; ON, homed, IDLE, MANUAL and valid value/system | MDI `G10 L20`, synchronize, reload and restore saved mode | +| `manual-tool-touch-off` | `touch_off_tool` | Permanently disabled for selected ABI | `blocked_by_abi_tool_offsets`; never approximated with `G92`/fixed `G43` | +| `manual-override-limits` | `toggle_override_limits` | Permanently disabled for selected ABI | No state mutation; explanatory ABI title | +| `manual-spindle-ccw`, `manual-spindle-stop`, `manual-spindle-cw` | spindle radio group -> `spindle` | `SPINDLE_COMMAND`; `manual_ok`, MANUAL | Canonical signed RPM/direction determines pressed state | +| `manual-spindle-decrease`, `manual-spindle-increase` | `spindle_decrease/increase`; ButtonRelease -> `spindle_constant` | Enabled only while spindle direction is nonzero, matching `axis.tcl update_state` | Press sends speed edge without crossing through zero into the opposite direction; release/cancel sends `SPINDLE_CONSTANT` | +| `manual-spindle-brake` | `brake` | `SPINDLE_BRAKE`; same manual source gate | Canonical brake state controls checkbox | +| `manual-coolant-mist`, `manual-coolant-flood` | manual-group `mist`/`flood` controls | `COOLANT_COMMAND`; visible checkboxes require ON and IDLE | Independent canonical coolant bits; disabled during AUTO activity | +| `mdi-command`, `mdi-submit` | MDI entry and `send_mdi` | Local text; submit/Enter sends `MDI_EXECUTE` under `manual_ok`/queue gate | Trimmed nonempty block, MDI mode, canonical interpreter result | +| `mdi-history-select` | AXIS MDI history row selection | Local history selection | Copies selected prior command into entry; does not execute until Submit/Enter | +| `preview-panLeft`, `preview-panRight`, `preview-panUp`, `preview-panDown`, `preview-reset` | Web accessible equivalent of Gremlin mouse pan/reset | Local camera only | Moves/resets camera; no Worker command | +| `dro-coordinate`, `dro-display`, `dro-units` | `coord_type`, `display_type`, `metric` | Local snapshot projection | Machine/Relative, Actual/Commanded and mm/inch display only | +| `override-jog-speed` | `update_jog_slider_vel` | `SET_JOG_SCALE`; source jog gate | Canonical linear jog scale | +| `override-feed` | `set_feedrate` | Debounced `SET_OVERRIDE`; ON | Canonical feed override, including while AUTO where task layer permits | +| `override-spindle` | `set_spindlerate` | `SET_SPINDLE_OVERRIDE`; ON | Canonical spindle override | +| `override-max-velocity` | `set_maxvel` | `SET_MAX_VELOCITY`; ON | Canonical TP velocity limit/scale | +| `override-rapid` | `set_rapidrate` | Permanently disabled for selected ABI | `blocked_by_abi_rapidrate`; never aliases Max Velocity | +| `preview-layer-path`, `preview-layer-live`, `preview-layer-limits`, `preview-layer-velocity`, `preview-layer-offsets`, `preview-layer-extents` | AXIS View visibility variables | Local visibility only | Shows/hides existing snapshot data without deleting or mutating it | +| `program-editor` | Browser replacement for AXIS external Edit boundary | Edits pending source text locally | Does not change loaded interpreter until `toolbar-reload` | + +Tool/probe IO operations (`TOOL_PREPARE`, `TOOL_PREPARED`, `TOOL_CHANGE`, +`TOOL_CHANGED`, `PROBE_CONFIG`) remain Worker capabilities for interpreter, +MDI, HAL and focused IO tests. They are deliberately absent from the Web main +panel because no corresponding AXIS main-panel buttons exist. ## Button Dependency Matrix @@ -188,7 +249,7 @@ React must derive its button gates from that state on the next snapshot. | `TOUCH_OFF` | Machine on, homed, idle/manual gate and valid dialog value | MDI `G10 L20` updates the selected coordinate system, then the saved mode is restored | Position/DRO and offsets refresh; program run state is unchanged | | `SET_MAX_VELOCITY` | Machine powered and the override gate permits the update | Source TP velocity limit is changed | Motion duration/velocity changes; jog and feed overrides remain independent | | Rapid Override control | Not available in the selected WASM TP | No state change | Rapid Override remains disabled; Max Velocity must not be used as a substitute | -| Spindle/Coolant/Tool/Probe | Machine on, idle, source-specific manual gate | Canonical device IO changes only after Worker verification | Their pressed/checked states update from device IO; no React-only optimistic state is allowed | +| Spindle/Coolant | Visible manual controls require machine ON and interpreter IDLE; spindle also requires MANUAL mode | Canonical device IO changes only after Worker verification | Pressed/checked states update from device IO; spindle speed +/- additionally requires a running spindle, matching `axis.tcl update_state` | ### Dependency Flow @@ -378,6 +439,10 @@ rerun contract is covered by `web/worker/test_xyzac_program_rerun.mjs` and the published-site execution sequence is covered by `scripts/test_xyzac_program_execution_browser_strict.mjs`. The source-surface audit is enforced by `scripts/test_axis_control_contract.mjs`. +The exhaustive control-ID audit is enforced by +`scripts/test_axis_control_inventory.mjs` and +`scripts/test_axis_control_inventory_browser.mjs`; the latter rejects any +rendered actionable element without a documented ID or with a duplicate ID. When LinuxCNC source behavior or the selected machine configuration changes: diff --git a/scripts/test_axis_control_inventory.mjs b/scripts/test_axis_control_inventory.mjs new file mode 100644 index 0000000..40d69c4 --- /dev/null +++ b/scripts/test_axis_control_inventory.mjs @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; + +const root = new URL("../", import.meta.url); +const read = (path) => readFile(new URL(path, root), "utf8"); +const [contract, app, panel, preview] = await Promise.all([ + read("docs/linuxcnc-axis-web-control-panel-source-logic.md"), + read("web/react/src/App.tsx"), + read("web/react/src/XyzacTrtPanel.tsx"), + read("web/react/src/XyzacToolPreview.tsx"), +]); + +const controlIds = [ + "menu-file", "menu-file-edit-tool-data", "menu-file-quit", "menu-help", "menu-help-about", "about-close", + "toolbar-estop", "toolbar-power", "toolbar-open", "toolbar-load-case", "toolbar-reload", "toolbar-run", "toolbar-step", "toolbar-pause-resume", "toolbar-stop", "toolbar-block-delete", "toolbar-optional-pause", "toolbar-zoom-in", "toolbar-zoom-out", "toolbar-view-z", "toolbar-view-z2", "toolbar-view-x", "toolbar-view-y", "toolbar-view-y2", "toolbar-view-p", "toolbar-rotate", "toolbar-clear-live-plot", + "workspace-case-select", "workspace-load-3d-chips", "manual-home-all", + "switchkins-0", "switchkins-1", "switchkins-2", + "tab-manual", "tab-mdi", "tab-diagnostics", "tab-preview", "tab-dro", + "manual-axis-x", "manual-axis-y", "manual-axis-z", "manual-axis-a", "manual-axis-c", + "manual-jog-increment", "manual-jog-negative", "manual-jog-positive", "manual-home-axis", "manual-touch-off", "manual-tool-touch-off", "manual-override-limits", + "manual-spindle-ccw", "manual-spindle-stop", "manual-spindle-cw", "manual-spindle-decrease", "manual-spindle-increase", "manual-spindle-brake", "manual-coolant-mist", "manual-coolant-flood", + "mdi-command", "mdi-submit", "mdi-history-select", + "preview-panLeft", "preview-panRight", "preview-panUp", "preview-panDown", "preview-reset", + "dro-coordinate", "dro-display", "dro-units", + "override-jog-speed", "override-feed", "override-spindle", "override-max-velocity", "override-rapid", + "preview-layer-path", "preview-layer-live", "preview-layer-limits", "preview-layer-velocity", "preview-layer-offsets", "preview-layer-extents", + "program-editor", "program-file-input", +]; + +for (const id of controlIds) assert.ok(contract.includes(`\`${id}\``), `control missing from source contract: ${id}`); +const source = `${app}\n${panel}\n${preview}`; +for (const term of ["data-axis-control", "manual-axis-${axis}", "switchkins-${item.type}", "preview-${action}", "preview-layer-${layer}"]) assert.ok(source.includes(term), `control marker missing: ${term}`); +for (const forbidden of ["Prepare T1", ">Change", "Arm Probe", ">Disarm", "Reload editor", "program-actions\">', '']) assert.equal(source.includes(forbidden), false, `non-source or duplicate control rendered: ${forbidden}`); + +console.log(JSON.stringify({ schema: "axis-control-inventory.v1", status: "verified", documented_controls: controlIds.length, duplicate_command_entries_removed: ["machine_menu", "view_menu", "program_pane_load", "tool_probe_commands"], contract: "linuxcnc-axis-web-control-panel-source-logic.md" })); diff --git a/scripts/test_axis_control_inventory_browser.mjs b/scripts/test_axis_control_inventory_browser.mjs new file mode 100644 index 0000000..1ac51af --- /dev/null +++ b/scripts/test_axis_control_inventory_browser.mjs @@ -0,0 +1,55 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { pathToFileURL } from "node:url"; + +const modulePath = process.env.PLAYWRIGHT_MODULE; +const url = process.env.XYZAC_PANEL_URL ?? "https://49.232.250.35:58010/"; +if (!modulePath) { + console.log(JSON.stringify({ schema: "axis-control-inventory-browser.v1", status: "blocked", code: "playwright_unavailable" })); + process.exit(0); +} +const contract = await readFile(new URL("../docs/linuxcnc-axis-web-control-panel-source-logic.md", import.meta.url), "utf8"); +const imported = await import(pathToFileURL(modulePath).href); +const playwright = imported.default ?? imported; +const browser = await playwright.chromium.launch({ headless: true, executablePath: process.env.BROWSER_EXECUTABLE_PATH ?? "/usr/bin/google-chrome", args: ["--ignore-certificate-errors", "--disable-gpu"] }); +const page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, ignoreHTTPSErrors: true }); +const browserErrors = []; +page.on("pageerror", (error) => browserErrors.push(`pageerror:${error.message}`)); +page.on("console", (message) => { if (message.type() === "error") browserErrors.push(`console:${message.text()}`); }); +await page.goto(`${url}${url.includes("?") ? "&" : "?"}control-inventory=1`, { waitUntil: "domcontentloaded", timeout: 30000 }); +const main = page.locator("main.axis-app"); +await main.waitFor({ state: "visible" }); +for (let index = 0; index < 600; index += 1) { + if (await main.getAttribute("data-axis-program-loading") === "false") break; + await page.waitForTimeout(100); +} + +await main.locator('[data-axis-control="menu-help"]').click(); +await main.locator('[data-axis-control="menu-help-about"]').click(); +await main.locator('[data-axis-control="about-close"]').waitFor({ state: "visible" }); +const aboutClosePresent = await main.locator('[data-axis-control="about-close"]').count() === 1; +await main.locator('[data-axis-control="about-close"]').click(); + +const inventory = await main.locator("button,input,select,summary,textarea").evaluateAll((nodes) => nodes + .filter((node) => !(node instanceof HTMLTextAreaElement && node.readOnly)) + .map((node) => ({ + tag: node.tagName.toLowerCase(), + id: node.getAttribute("data-axis-control"), + text: (node.textContent ?? "").trim().replace(/\s+/g, " ").slice(0, 80), + aria: node.getAttribute("aria-label"), + disabled: "disabled" in node ? Boolean(node.disabled) : false, + hidden: node instanceof HTMLInputElement && node.type === "file", + }))); +const missingIds = inventory.filter((entry) => !entry.id); +assert.deepEqual(missingIds, [], `actionable controls without data-axis-control: ${JSON.stringify(missingIds)}`); +const ids = inventory.map((entry) => entry.id); +for (const id of ids) assert.ok(contract.includes(`\`${id}\``), `runtime control missing from contract: ${id}`); +assert.equal(new Set(ids).size, ids.length, `duplicate control IDs: ${JSON.stringify(ids.filter((id, index) => ids.indexOf(id) !== index))}`); +for (const removed of ["Prepare T1", "Change", "Arm Probe", "Disarm", "Reload editor"]) assert.equal(inventory.some((entry) => entry.text === removed), false, `removed control still rendered: ${removed}`); +assert.equal(await main.locator('[data-axis-control="menu-file-edit-tool-data"]').isDisabled(), true); +assert.equal(await main.locator('[data-axis-control="menu-file-quit"]').isDisabled(), true); +assert.equal(aboutClosePresent, true); +assert.deepEqual(browserErrors, []); + +console.log(JSON.stringify({ schema: "axis-control-inventory-browser.v1", status: "verified", url, actionable_controls: inventory.length + 1, static_control_ids: ids.length, dynamic_controls: ["about-close", "mdi-history-select"], disabled_host_boundaries: ["menu-file-edit-tool-data", "menu-file-quit"], browser_errors: browserErrors })); +await browser.close(); diff --git a/scripts/test_xyzac_panel_buttons_browser.mjs b/scripts/test_xyzac_panel_buttons_browser.mjs index 0868551..6a4b6c7 100644 --- a/scripts/test_xyzac_panel_buttons_browser.mjs +++ b/scripts/test_xyzac_panel_buttons_browser.mjs @@ -13,8 +13,15 @@ const browser = await playwright.chromium.launch({ headless: true, executablePat const page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, ignoreHTTPSErrors: true }); const failures = []; const browserErrors = []; +const dialogs = []; page.on("pageerror", (error) => browserErrors.push(`pageerror:${error.message}`)); page.on("console", (message) => { if (message.type() === "error") browserErrors.push(`console:${message.text()}`); }); +page.on("dialog", async (dialog) => { + dialogs.push({ type: dialog.type(), message: dialog.message() }); + if (dialog.type() === "prompt" && dialog.message().startsWith("Touch Off:")) await dialog.accept("0"); + else if (dialog.type() === "prompt" && dialog.message().startsWith("Coordinate system")) await dialog.accept("P1 G54"); + else await dialog.dismiss(); +}); const main = page.locator("main.axis-app"); const state = () => main.evaluate((element) => ({ authority: element.dataset.axisAuthority, @@ -88,21 +95,56 @@ try { })); results.push(await record("VIEW buttons", async () => { for (const name of ["Zoom in", "Zoom out", "Top view", "Rotated top view", "Side view", "Front view", "Inverted front view", "Perspective view", "Drag / rotate mode"]) await click(main.locator(`button[aria-label="${name}"]`), `view_${name}`); + for (const id of ["preview-panLeft", "preview-panRight", "preview-panUp", "preview-panDown", "preview-reset"]) await click(main.locator(`button[data-axis-control="${id}"]`), id); + await main.locator('button[data-axis-control="tab-dro"]').click(); + await main.locator('select[data-axis-control="dro-coordinate"]').selectOption("relative"); + await main.locator('select[data-axis-control="dro-display"]').selectOption("commanded"); + await main.locator('select[data-axis-control="dro-units"]').selectOption("inch"); + await main.locator('select[data-axis-control="dro-coordinate"]').selectOption("machine"); + await main.locator('select[data-axis-control="dro-display"]').selectOption("actual"); + await main.locator('select[data-axis-control="dro-units"]').selectOption("mm"); + await main.locator('button[data-axis-control="tab-preview"]').click(); + for (const layer of ["path", "live", "limits", "velocity", "offsets", "extents"]) { + const checkbox = main.locator(`input[data-axis-control="preview-layer-${layer}"]`); + await checkbox.click(); + await checkbox.click(); + } })); results.push(await record("VISUAL CLEAR", async () => { await click(main.locator('button[aria-label="Clear live plot [Ctrl-K]"]'), "visual_clear"); await wait((s) => s.error === null, "visual_clear_ack"); })); results.push(await record("MANUAL MODE", async () => { await main.getByRole("tab", { name: "Manual Control [F3]" }).click(); await wait((s) => s.mode === "MANUAL" || s.command?.includes("SET_MODE acknowledged"), "manual_mode_ack"); })); + results.push(await record("HOME/TOUCH OFF boundaries", async () => { + const dialogCount = dialogs.length; + await click(main.locator('button[data-axis-control="manual-home-axis"]'), "home_axis_repeat"); + await page.waitForTimeout(100); + if (dialogs.length !== dialogCount + 1 || !dialogs.at(-1)?.message.includes("already homed")) throw new Error("home_axis_confirmation_missing"); + await click(main.locator('button[data-axis-control="manual-touch-off"]'), "touch_off"); + await wait((s) => s.error === null && s.command?.includes("TOUCH_OFF acknowledged"), "touch_off_ack"); + if (!await main.locator('[data-axis-control="manual-tool-touch-off"]').isDisabled()) throw new Error("tool_touch_off_must_be_disabled"); + if (!await main.locator('[data-axis-control="manual-override-limits"]').isDisabled()) throw new Error("override_limits_must_be_disabled"); + })); results.push(await record("JOG XYZAC", async () => { for (const axis of ["X", "Y", "Z", "A", "C"]) { const axisInput = main.locator(`input[name="axis"][value="${axis.toLowerCase()}"]`); if (!await axisInput.count()) throw new Error(`axis_${axis}:missing`); await axisInput.check(); - await click(main.locator(`button[aria-label="Jog ${axis} positive"]`), `jog_${axis}_positive`); - await wait((s) => s.error === null, `jog_${axis}_ack`); - await click(main.getByRole("button", { name: "Stop jog", exact: true }), `jog_${axis}_stop`); + const jogButton = main.locator(`button[aria-label="Jog ${axis} positive"]`); + await jogButton.dispatchEvent("pointerdown", { pointerId: 1, button: 0 }); + await wait((s) => s.error === null && s.command?.includes("JOG_START acknowledged"), `jog_${axis}_start_ack`); + await page.waitForTimeout(100); + await jogButton.dispatchEvent("pointerup", { pointerId: 1, button: 0 }); + await wait((s) => s.error === null && s.command?.includes("JOG_STOP acknowledged"), `jog_${axis}_stop_ack`); } })); results.push(await record("SPINDLE", async () => { await click(main.locator('button[aria-label="Spindle clockwise"]'), "spindle_cw"); + await wait((s) => s.error === null && s.command?.includes("SPINDLE_COMMAND acknowledged"), "spindle_cw_ack"); + for (const id of ["manual-spindle-decrease", "manual-spindle-increase"]) { + const adjust = main.locator(`button[data-axis-control="${id}"]`); + await adjust.dispatchEvent("pointerdown", { pointerId: 1, button: 0 }); + await wait((s) => s.error === null && s.command?.includes("SPINDLE_COMMAND acknowledged"), `${id}_press_ack`); + await adjust.dispatchEvent("pointerup", { pointerId: 1, button: 0 }); + await wait((s) => s.error === null && s.command?.includes("SPINDLE_CONSTANT acknowledged"), `${id}_release_ack`); + } await click(main.locator('button[aria-label="Spindle counter-clockwise"]'), "spindle_ccw"); await click(main.getByRole("button", { name: "Stop spindle", exact: true }), "spindle_stop"); const brake = main.getByLabel("Brake", { exact: true }); @@ -125,12 +167,6 @@ try { } } })); - results.push(await record("TOOL/PROBE", async () => { - for (const name of ["Prepare T1", "Change", "Arm Probe", "Disarm"]) { - const clicked = await click(main.getByRole("button", { name, exact: true }), `device_${name}`, { required: false }); - if (clicked) await wait((s) => s.error === null, `device_${name}_ack`); - } - })); results.push(await record("OVERRIDES", async () => { for (const [name, value] of [["Jog speed", "50"], ["Feed override", "50"], ["Spindle override", "50"], ["Max velocity", "50"]]) { const input = main.locator(`input[aria-label="${name}"]`); @@ -140,12 +176,12 @@ try { } } })); - results.push(await record("MDI", async () => { await main.getByRole("tab", { name: "MDI [F5]" }).click(); await wait((s) => s.mode === "MDI" || s.command?.includes("SET_MODE acknowledged"), "mdi_mode_ack"); const input = main.locator('.mdi-control input'); await input.fill("G0 X1"); await click(main.getByRole("button", { name: "Go", exact: true }), "mdi_go"); await wait((s) => s.error === null && s.command?.includes("MDI_EXECUTE acknowledged") && s.queue === "0", "mdi_ack"); await main.getByRole("tab", { name: "Manual Control [F3]" }).click(); await wait((s) => s.mode === "MANUAL" || s.command?.includes("SET_MODE acknowledged"), "manual_mode_ack"); })); + results.push(await record("MDI", async () => { await main.getByRole("tab", { name: "MDI [F5]" }).click(); await wait((s) => s.mode === "MDI" || s.command?.includes("SET_MODE acknowledged"), "mdi_mode_ack"); const input = main.locator('.mdi-control input'); await input.fill("G0 X1"); await click(main.getByRole("button", { name: "Go", exact: true }), "mdi_go"); await wait((s) => s.error === null && s.command?.includes("MDI_EXECUTE acknowledged") && s.queue === "0", "mdi_ack"); const history = main.locator('button[data-axis-control="mdi-history-select"]').first(); await history.click(); if (await input.inputValue() !== "G0 X1") throw new Error("mdi_history_selection_failed"); await main.getByRole("tab", { name: "Manual Control [F3]" }).click(); await wait((s) => s.mode === "MANUAL" || s.command?.includes("SET_MODE acknowledged"), "manual_mode_ack"); })); results.push(await record("PROGRAM LOAD/RUN/PAUSE/RESUME/ABORT/RESTART/STEP", async () => { await main.getByRole("tab", { name: "Manual Control [F3]" }).click(); await wait((s) => s.queue === "0" && s.error === null, "program_preflight_idle"); await page.waitForTimeout(1000); - await click(main.locator(".program-actions button").first(), "program_load"); + await click(main.locator('button[data-axis-control="toolbar-reload"]'), "program_load"); await wait((s) => s.program === "loaded" && s.queue === "0" && s.runEnabled === "true" && !s.programLoading && s.error === null, "program_loaded"); await click(main.locator('button[aria-label="Begin executing current file [R]"]'), "program_run"); await wait((s) => s.program === "running", "program_running"); @@ -155,13 +191,13 @@ try { await wait((s) => s.program === "running", "program_resumed"); await click(main.locator('button[aria-label="Stop program execution [ESC]"]'), "program_abort"); await wait((s) => s.operation === "Operation IDLE" && ["loaded", "aborted"].includes(s.program), "program_aborted"); - await click(main.locator(".program-actions button").first(), "program_reload_before_step"); + await click(main.locator('button[data-axis-control="toolbar-reload"]'), "program_reload_before_step"); await wait((s) => s.program === "loaded" && s.error === null, "program_reload_before_step_ack"); await click(main.locator('button[aria-label="Execute next line [T]"]'), "program_step"); await wait((s) => s.program === "paused", "program_stepped"); await click(main.locator('button[aria-label="Stop program execution [ESC]"]'), "program_step_abort"); await wait((s) => s.operation === "Operation IDLE" && ["loaded", "aborted"].includes(s.program), "program_step_abort_ack"); - await click(main.locator(".program-actions button").first(), "program_reload_before_rerun"); + await click(main.locator('button[data-axis-control="toolbar-reload"]'), "program_reload_before_rerun"); await wait((s) => s.program === "loaded" && s.error === null, "program_reload_before_rerun_ack"); await click(main.locator('button[aria-label="Begin executing current file [R]"]'), "program_run_again"); await wait((s) => s.program === "running", "program_running_again"); @@ -171,7 +207,7 @@ try { results.push(await record("EDITOR PROGRAM LOAD + RELOAD", async () => { const editor = main.locator("#axis-program"); await editor.fill("G21\nG1 X2 Y2 Z2 F100\nM2\n"); - await click(main.locator(".program-actions button").first(), "editor_program_load"); + await click(main.locator('button[data-axis-control="toolbar-reload"]'), "editor_program_load"); await wait((s) => s.program === "loaded" && s.command?.includes("PROGRAM_LOAD acknowledged") && s.error === null, "editor_program_load_ack"); await click(main.locator('button[aria-label="Reload current G-code [Ctrl-R]"]'), "reload_current_program"); await wait((s) => s.program === "loaded" && s.command?.includes("PROGRAM_LOAD acknowledged") && s.error === null, "reload_current_program_ack"); diff --git a/wasm-core/src/sim_io.c b/wasm-core/src/sim_io.c index 0f52b8b..120fc61 100644 --- a/wasm-core/src/sim_io.c +++ b/wasm-core/src/sim_io.c @@ -274,8 +274,8 @@ int sim_io_spindle_adjust(sim_io *io, uint32_t spindle, double delta) { state = &io->state.spindles[spindle]; // Match LinuxCNC EMCMOT_SPINDLE_INCREASE/DECREASE: change the signed // speed only while the spindle is running and keep its direction. - if (state->command_rpm > 0.0) target = state->command_rpm + delta; - else if (state->command_rpm < 0.0) target = state->command_rpm - delta; + if (state->command_rpm > 0.0) target = fmax(0.0, state->command_rpm + delta); + else if (state->command_rpm < 0.0) target = fmin(0.0, state->command_rpm - delta); else target = 0.0; target = fmax(-state->max_rpm, fmin(state->max_rpm, target)); state->command_rpm = state->enabled && !state->inhibit && !state->amp_fault ? target : 0.0; diff --git a/wasm-core/tests/sim_io_test.c b/wasm-core/tests/sim_io_test.c index e9d27c9..c2240b1 100644 --- a/wasm-core/tests/sim_io_test.c +++ b/wasm-core/tests/sim_io_test.c @@ -43,6 +43,11 @@ int main(void) { assert(sim_io_spindle_set_override(&io, 0, 1.0) == SIM_IO_OK && io.state.spindles[0].command_rpm == 1200.0); assert(sim_io_spindle_adjust(&io, 0, -100.0) == SIM_IO_OK && io.state.spindles[0].command_rpm == 1100.0); assert(sim_io_spindle_adjust(&io, 0, 100.0) == SIM_IO_OK && io.state.spindles[0].command_rpm == 1200.0); + assert(sim_io_spindle_command(&io, 0, 1.0, 1) == SIM_IO_OK); + assert(sim_io_spindle_adjust(&io, 0, -100.0) == SIM_IO_OK && io.state.spindles[0].command_rpm == 0.0); + assert(sim_io_spindle_command(&io, 0, -1.0, 1) == SIM_IO_OK); + assert(sim_io_spindle_adjust(&io, 0, -100.0) == SIM_IO_OK && io.state.spindles[0].command_rpm == 0.0); + assert(sim_io_spindle_command(&io, 0, 1200.0, 1) == SIM_IO_OK); spindle_step_status = sim_io_step(&io, 10); assert(spindle_step_status == SIM_IO_OK && io.state.spindles[0].speed_rpm == 600.0); assert(sim_io_step(&io, 10) == SIM_IO_OK && io.state.spindles[0].at_speed == 1); diff --git a/web/react/index.html b/web/react/index.html index 7f79866..022ffb5 100644 --- a/web/react/index.html +++ b/web/react/index.html @@ -4,6 +4,7 @@ + LinuxCNC Web/WASM diff --git a/web/react/src/App.tsx b/web/react/src/App.tsx index e26fc07..58d0720 100644 --- a/web/react/src/App.tsx +++ b/web/react/src/App.tsx @@ -18,14 +18,14 @@ const normalizeProgramSource = (source: string) => source.replace(/\r\n/g, "\n") // Worker gates and emits the documented operation. type Action = (kind: string, payload?: Record) => Promise | void; -type IconButtonProps = { label: string; asset: string; onClick?: () => void; disabled?: boolean; disabledReason?: string; danger?: boolean; pressed?: boolean; feature?: string }; +type IconButtonProps = { controlId: string; label: string; asset: string; onClick?: () => void; disabled?: boolean; disabledReason?: string; danger?: boolean; pressed?: boolean; feature?: string }; -function IconButton({ label, asset, onClick, disabled = false, disabledReason, danger = false, pressed = false, feature = "AXIS-UI-05" }: IconButtonProps) { - return ; +function IconButton({ controlId, label, asset, onClick, disabled = false, disabledReason, danger = false, pressed = false, feature = "AXIS-UI-05" }: IconButtonProps) { + return ; } -function TextButton({ children, onClick, onPointerDown, onPointerUp, onPointerCancel, onPointerLeave, ariaLabel, disabled = false, disabledReason, danger = false, feature = "AXIS-UI-07" }: { children: React.ReactNode; onClick?: () => void; onPointerDown?: React.PointerEventHandler; onPointerUp?: React.PointerEventHandler; onPointerCancel?: React.PointerEventHandler; onPointerLeave?: React.PointerEventHandler; ariaLabel?: string; disabled?: boolean; disabledReason?: string; danger?: boolean; feature?: string }) { - return ; +function TextButton({ controlId, children, onClick, onPointerDown, onPointerUp, onPointerCancel, onPointerLeave, ariaLabel, disabled = false, disabledReason, danger = false, feature = "AXIS-UI-07" }: { controlId: string; children: React.ReactNode; onClick?: () => void; onPointerDown?: React.PointerEventHandler; onPointerUp?: React.PointerEventHandler; onPointerCancel?: React.PointerEventHandler; onPointerLeave?: React.PointerEventHandler; ariaLabel?: string; disabled?: boolean; disabledReason?: string; danger?: boolean; feature?: string }) { + return ; } function Separator() { return