From 03c7fdf8f03ccdeb2927f0ad2c1d83fc17110e18 Mon Sep 17 00:00:00 2001 From: wangdequan Date: Wed, 2 Sep 2026 23:46:00 +0800 Subject: [PATCH] Enforce AXIS source parity across panel controls --- README.md | 1 + .../xyzac-panel-button-parity.report.json | 19 ++ .../xyzac-panel-button-parity-test-report.md | 22 ++ docs/linuxcnc-axis-web-iron-law.md | 43 ++++ scripts/test_xyzac_panel_buttons_browser.mjs | 196 ++++++++++++++++++ web/react/src/App.tsx | 48 ++++- web/react/src/protocol.ts | 5 +- web/react/src/store.ts | 2 + web/worker/sim-session.mjs | 30 ++- web/worker/test_xyzac_protocol_react.mjs | 5 +- 10 files changed, 357 insertions(+), 14 deletions(-) create mode 100644 docs/linuxcnc-axis-web-iron-law.md create mode 100644 scripts/test_xyzac_panel_buttons_browser.mjs diff --git a/README.md b/README.md index c5598a8..db97be1 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ - 配置批量 runtime 已生成 264 个配置注册入口、233 个 sim entrypoint 和 16,093 条资源边;缺失、未解析和未索引资源均输出可定位诊断并继续作为仿真输入闭包处理。 - 路线对齐的 React 前端位于 `web/react`:React + TypeScript + Vite + Tailwind + Radix UI + Three.js,React 只通过 protocol.v1/WorkerClient 访问 Dedicated Worker;FE-M1 构建证据见 `coverage/react-frontend-m1.report.json`。 - AXIS 主界面完全对标的源程序功能清单、菜单/工具栏/状态门控、严格差异流程、任务分解和 M0-M7 验收门槛见 [`LinuxCNC AXIS 完全对标规范与执行计划`](docs/linuxcnc-axis-complete-parity-spec.md)。该文档以锁定的 `linuxcnc/share/axis/tcl/axis.tcl`、Gremlin/GLCanon、原始 bitmap 和 `configs/sim/axis` 为唯一 source oracle。 +- Web 控制面板操作必须遵守 [`LinuxCNC AXIS Web 铁律`](docs/linuxcnc-axis-web-iron-law.md):AXIS 源码和 LinuxCNC WASM 是唯一操作权威,React/JavaScript/Three.js 不得成为第二套运动或状态实现。 ## 当前发布范围 diff --git a/coverage/xyzac-panel-button-parity.report.json b/coverage/xyzac-panel-button-parity.report.json index e5bcb6c..8f4638a 100644 --- a/coverage/xyzac-panel-button-parity.report.json +++ b/coverage/xyzac-panel-button-parity.report.json @@ -141,6 +141,25 @@ }, "browser_errors": 0 }, + "published_panel_audit": { + "command": "PLAYWRIGHT_MODULE=... node scripts/test_xyzac_panel_buttons_browser.mjs", + "url": "https://49.232.250.35:58010/", + "release": "20260902-linuxcnc-wasm-panel-audit-r27", + "status": "verified", + "verified_groups": 20, + "button_count": 83, + "disabled_button_count": 37, + "browser_errors": 0, + "authority": "linuxcnc-wasm-worker", + "motion_authority": "wasm", + "task_authority": "wasm_task_canonical", + "final_machine_state": "Machine OFF", + "final_operation_state": "Operation IDLE", + "final_program_status": "loaded", + "screenshot": "/tmp/xyzac-panel-buttons-browser.png", + "screenshot_sha256": "dbf6d196cec3fe765a64d5a1d194b7ebb5681a30555f238e084c9ad6deac6032", + "status_detail": "WASM startup, file/package load, POWER, HOME, RESET, switchkins, views, clear plot, MANUAL/MDI, XYZAC JOG, spindle/brake, coolant, tool/probe, overrides, MDI, RUN/PAUSE/RESUME/ABORT/STEP, startup sequence, editor Load/Reopen and ABI-disabled gates all verified" + }, "release_status": "STOP_RELEASE", "scope_gaps": [ "override_limits_command", diff --git a/doc-rtcp/xyzac-panel-button-parity-test-report.md b/doc-rtcp/xyzac-panel-button-parity-test-report.md index 7e29197..6a0d2d3 100644 --- a/doc-rtcp/xyzac-panel-button-parity-test-report.md +++ b/doc-rtcp/xyzac-panel-button-parity-test-report.md @@ -326,3 +326,25 @@ program_replace_browser_screenshot c3e0a5b992381e7b6cb26f98a30338935df5239c87575 ``` 报告对应提交前工作树保留了既有 coverage 大文件和 LinuxCNC 子模块改动;本轮提交包含 WASM TP/IO/HOME authority、无 JS JOG 运动计算、前端 pending 投影、长程序快照性能修正、聚合测试和本报告。 + +## AXIS 源码逐项映射与发布站点审计 + +本轮按 `linuxcnc/src/emc/usr_intf/axis/scripts/axis.py` 对照了 `manual_ok()`、`ensure_mode()`、`open_file_guts()`、`task_run/task_pause/task_resume/task_stop/task_step`、`jog_on/jog_off_actual`、`mist_toggle/flood_toggle`、`spindle_*`、`change_tool` 和 MDI 入口。映射约束与禁止事项见 [`LinuxCNC AXIS Web 铁律`](../docs/linuxcnc-axis-web-iron-law.md)。 + +发现并修复的实际偏差: + +- 手动/MDI 页签现在通过 WASM `SET_MODE` 同步 LinuxCNC task mode;之前仅切 React 页签会使 JOG、主轴和 MDI 被错误地显示为不可用。 +- React 现在保留 `panel_snapshot.io` 的 WASM canonical envelope,正确读取 `state.ready/estop` 和设备状态。 +- `JOG_STOP` 对 LinuxCNC source TP 只停止当前 jog 并保持 TP runnable;不再伪造 `AUTO_PAUSE`,避免后续 MDI/主轴操作得到 `linuxcnc_tp_paused` 调度错误。 +- MDI 在前一次 JOG_STOP 后明确恢复 WASM TP runnable,再由 WASM 解释器/队列执行 MDI block。 +- 工具栏 Reset 和 Reopen defaults 均走 WASM gate;Reopen 执行 `LOAD_WORKSPACE + PROGRAM_LOAD`,不再只修改本地编辑器字符串。 + +发布地址 `https://49.232.250.35:58010/` 的 Chromium 审计命令: + +```text +PLAYWRIGHT_MODULE=... node scripts/test_xyzac_panel_buttons_browser.mjs +``` + +审计覆盖:WASM startup、Open G-code file、`3D_Chips.ngc`、Load case、POWER ON、HOME ALL、RESET、IDENTITY/TCP:XYZAC/USERK、视图和 Clear plot、MANUAL/MDI、XYZAC 五轴 JOG、主轴正反转/停止/刹车、Mist/Flood、Tool/Probe、Jog/Feed/Spindle/Max velocity overrides、MDI、Run/Pause/Resume/Abort/Step、Run Startup Sequence、编辑器 Load/Reopen 和 ABI-disabled 控件。发布版本 `20260902-linuxcnc-wasm-panel-audit-r27` 最终结果为 20 组 verified、83 个按钮、37 个按 LinuxCNC gate 禁用、`browser_errors=0`;WASM worker authority 标记为 `linuxcnc-wasm-worker` / `wasm_task_canonical`,截图 `/tmp/xyzac-panel-buttons-browser.png`,SHA-256 `dbf6d196cec3fe765a64d5a1d194b7ebb5681a30555f238e084c9ad6deac6032`。 + +源代码对标结论:AXIS 的可执行操作均通过对应 WASM command/result 和后续 snapshot;`Override Limits`、Block delete/Optional pause、完整 Tool Touch Off/G10 交互以及桌面 tool editor/Quit 仍明确记录为 `blocked_by_abi` 或浏览器宿主边界,不能用 JavaScript 模拟。整体 `release_status` 继续保持 `STOP_RELEASE`,因为本报告不替代完整 TP 数值 oracle。 diff --git a/docs/linuxcnc-axis-web-iron-law.md b/docs/linuxcnc-axis-web-iron-law.md new file mode 100644 index 0000000..9589107 --- /dev/null +++ b/docs/linuxcnc-axis-web-iron-law.md @@ -0,0 +1,43 @@ +# LinuxCNC AXIS Web 铁律 + +本仓库的 Web 控制面板必须对标 `linuxcnc/src/emc/usr_intf/axis/scripts/axis.py` 的 AXIS 行为。LinuxCNC AXIS 源码和 LinuxCNC WASM 是操作语义、门控、状态、错误和运动结果的唯一权威;React、浏览器事件和 Three.js 只能负责输入、异步传输、状态投影和显示。 + +## 强制映射 + +| Web 操作 | AXIS 源入口 | WASM 命令/状态 | Web 规则 | +| --- | --- | --- | --- | +| 急停/解除急停 | `commands.estop`、`General_Halt` | `ESTOP`、IO `estop` | 立即停机;解除后仍须按 LinuxCNC 的恢复/上电顺序 | +| 上电/断电/重置 | `commands.power`、`commands.reset` | `POWER`、`RESET`、IO ready/task state | 仅按 WASM gate 启用;断电保留程序但中止运动 | +| HOME ALL/单轴回零 | `home_all_joints`、`home_joint` | `HOME`、`wasm_home_limit` | 只能在 `manual_ok()` 等价条件满足时执行 | +| 手动/JOG | `ensure_mode(MODE_MANUAL)`、`jog_on/jog_off` | `SET_MODE`、`JOG_START/JOG_STOP`、WASM TP tick | 按住开始、释放停止;五个轴都必须使用同一 WASM 运动源 | +| MDI | `ensure_mode(MODE_MDI)`、`send_mdi_command` | `SET_MODE`、`MDI_EXECUTE` | 解释器运行时拒绝;队列和错误由 WASM 返回 | +| 运行/暂停/恢复/停止 | `task_run`、`task_pause`、`task_resume`、`task_stop` | `PROGRAM_RUN/PAUSE/RESUME/ABORT` | 运行期间不切手动/MDI;暂停保留 TP 队列,恢复继续同一队列 | +| 单步/重启/重新加载 | `task_step`、`reload_file`、`open_file_guts` | `PROGRAM_STEP/RESTART/LOAD_WORKSPACE/PROGRAM_LOAD` | 清理旧预览和执行轨迹,再显示新 WASM 结果 | +| 主轴/冷却/刀具/探针 | `spindle_*`、`mist_toggle`、`flood_toggle`、`change_tool`、`probe` | `SPINDLE_*`、`COOLANT_COMMAND`、`TOOL_*`、`PROBE_CONFIG` | HAL/IO/设备状态由 WASM canonical snapshot 回显 | +| 进给/主轴/最大速度覆盖 | `set_feedrate`、`set_spindlerate`、`tpSetVlimit` | `SET_OVERRIDE`、`SET_SPINDLE_OVERRIDE`、`SET_MAX_VELOCITY` | 只发送目标值;显示值必须来自 WASM snapshot | +| 预览/清图/视图 | `glcanon`/`clear_live_plot` | `PROGRAM_LOAD` 生成的 canonical toolpath、`VISUAL_CLEAR` | 视图变换是显示操作,不能重算 G-code 或运动 | + +## 门控铁律 + +网页必须实现 AXIS `manual_ok()` 的等价行为:task 必须 `STATE_ON`,不能处于 AUTO/MDI 的非空闲解释器状态;MDI 还必须受 queued-MDI 上限限制。按钮的 `disabled` 属性只能投影 WASM snapshot 的 gate,不能在 React 中复制另一套 task/motion/kinematics 判断。WASM 拒绝的命令必须保留错误码、`gate_reason` 和 operation trace。 + +## 禁止事项 + +- 禁止 React/JavaScript 解析 G-code、规划轨迹、计算 RTCP/FK/IK、推进运动或伪造轴值。 +- 禁止用本地按钮状态覆盖 WASM 的 task、IO、HAL、tool、probe、home/limit 或 planner 状态。 +- 禁止把按钮“可点击”、静态截图或单次页面加载当作实现证据;必须有 worker/WASM command result 和后续 snapshot。 +- 不具备对应 LinuxCNC/WASM ABI 的功能必须保持禁用,并明确记录 `blocked_by_abi`,不得用 JS 模拟补齐。 + +## 验收证据 + +每次面板变更必须运行: + +```text +node --experimental-strip-types web/worker/test_xyzac_button_constraints.mjs +node --experimental-strip-types web/worker/test_xyzac_panel_buttons.mjs +npm run typecheck --prefix web/react +npm run build --prefix web/react +PLAYWRIGHT_MODULE=... node scripts/test_xyzac_panel_buttons_browser.mjs +``` + +浏览器审计必须覆盖真实发布站点的启动、打开文件、加载程序、上电、回零、重置、三种运动学、视图、清图、XYZAC JOG、主轴、冷却、刀具/探针、覆盖率、MDI、运行、暂停、恢复、停止、启动序列、编辑器加载和重新打开。结果应记录在 `doc-rtcp/xyzac-panel-button-parity-test-report.md`,并保持 `STOP_RELEASE`,直到更高层的完整 TP 数值 oracle 也通过。 diff --git a/scripts/test_xyzac_panel_buttons_browser.mjs b/scripts/test_xyzac_panel_buttons_browser.mjs new file mode 100644 index 0000000..90b0c5e --- /dev/null +++ b/scripts/test_xyzac_panel_buttons_browser.mjs @@ -0,0 +1,196 @@ +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: "xyzac-panel-buttons-browser.v1", status: "blocked", code: "playwright_unavailable" })); + process.exit(0); +} + +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 failures = []; +const browserErrors = []; +page.on("pageerror", (error) => browserErrors.push(`pageerror:${error.message}`)); +page.on("console", (message) => { if (message.type() === "error") browserErrors.push(`console:${message.text()}`); }); +const main = page.locator("main.axis-app"); +const state = () => main.evaluate((element) => ({ + authority: element.dataset.axisAuthority, + source: element.dataset.axisStateSource, + motion: element.dataset.axisMotionAuthority, + task: element.dataset.axisTaskAuthority, + machine: element.querySelector(".machine-state")?.textContent?.trim(), + operation: element.querySelector(".operation-state")?.textContent?.trim(), + case: element.querySelector(".case-status")?.textContent?.trim(), + mode: element.dataset.axisTaskMode, + program: element.dataset.axisProgramStatus, + runEnabled: element.dataset.axisRunEnabled, + programLoading: element.querySelector(".case-status")?.getAttribute("aria-busy") === "true", + homed: element.dataset.axisHomed, + line: element.dataset.axisCurrentLine, + queue: element.dataset.axisTpQueueDepth, + velocity: element.dataset.axisTpVelocity, + command: element.querySelector(".command-status")?.textContent?.trim(), + error: element.querySelector(".diagnostic-error")?.textContent?.trim() ?? null, +})); +const wait = async (predicate, label, timeout = 45000) => { + const started = Date.now(); let last; + while (Date.now() - started < timeout) { last = await state(); if (predicate(last)) return last; await page.waitForTimeout(250); } + throw new Error(`${label}:${JSON.stringify(last)}`); +}; +const click = async (locator, label, { required = true, timeout = required ? 30000 : 3000 } = {}) => { + const started = Date.now(); + while (Date.now() - started < timeout) { + if (await locator.count() && !await locator.first().isDisabled()) { await locator.first().click(); return true; } + await page.waitForTimeout(250); + } + if (required) throw new Error(`${label}:disabled_or_missing`); + return false; +}; +const record = async (name, action) => { + process.stderr.write(`[panel-audit] ${name} ...\n`); + try { await action(); process.stderr.write(`[panel-audit] ${name}: verified\n`); return { name, result: "verified" }; } + catch (error) { const row = { name, result: "failed", reason: error?.message ?? String(error) }; failures.push(row); process.stderr.write(`[panel-audit] ${name}: failed ${row.reason}\n`); return row; } +}; +const results = []; +try { + await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 }); + results.push(await record("WASM startup", async () => { const value = await wait((s) => s.source === "linuxcnc-wasm" && s.motion === "wasm", "wasm_startup"); if (value.authority !== "linuxcnc-wasm-worker") throw new Error("authority_marker_missing"); })); + results.push(await record("OPEN G-CODE FILE", async () => { + const fileInput = main.locator('input[type="file"][aria-label="Open G-code file"]'); + if (!await fileInput.count()) throw new Error("file_input_missing"); + await fileInput.setInputFiles({ name: "browser-panel-audit.ngc", mimeType: "text/plain", buffer: Buffer.from("G21\nG0 X1 Y1 Z1\nM2\n") }); + await wait((s) => s.program === "loaded" && s.command?.includes("PROGRAM_LOAD acknowledged") && s.error === null, "open_file_ack"); + await wait((s) => s.command?.includes("PROGRAM_LOAD acknowledged") && s.error === null, "open_file_status"); + await click(main.locator('button[aria-label="Load exact XYZAC TRT case"]'), "restore_case_after_open"); + await wait((s) => s.program === "loaded" && s.case?.includes("xyzac_switchkins") && s.error === null, "restore_case_after_open_ack"); + })); + results.push(await record("PACKAGED PROGRAM LOAD", async () => { + await click(main.getByRole("button", { name: "Load 3D_Chips.ngc", exact: true }), "load_3d_chips"); + await wait((s) => s.program === "loaded" && s.command?.includes("PROGRAM_LOAD acknowledged") && s.error === null, "load_3d_chips_ack"); + await click(main.locator('button[aria-label="Load exact XYZAC TRT case"]'), "restore_case_after_packaged"); + await wait((s) => s.program === "loaded" && s.case?.includes("xyzac_switchkins") && s.error === null, "restore_case_after_packaged_ack"); + })); + results.push(await record("POWER ON", async () => { await click(main.locator('button[aria-label="Power on [F2]"]'), "power_on"); await wait((s) => s.machine === "Machine ON", "power_state"); })); + results.push(await record("HOME ALL", async () => { await click(main.locator('button[aria-label="Home all axes"]'), "home_all"); await wait((s) => s.homed === "[true,true,true,true,true]", "home_state"); })); + results.push(await record("RESET", async () => { await click(main.locator('button[aria-label="Reset machine"]'), "reset"); await wait((s) => s.command?.includes("RESET acknowledged") || s.error === null, "reset_state"); })); + results.push(await record("KINEMATICS IDENTITY/TCP/USERK", async () => { + for (const name of ["Identity", "TCP: XYZAC", "User K", "Identity", "TCP: XYZAC", "User K"]) { + await click(main.getByRole("button", { name: new RegExp(`^${name}`) }), `kinematics_${name}`); + await wait((s) => s.error === null, `kinematics_${name}_ack`); + } + // LinuxCNC only exposes the manual XYZAC controls for the identity + // kinematics in this selected WASM configuration; leave the panel in + // that authoritative mode for the following manual-control checks. + await click(main.getByRole("button", { name: /^Identity/ }), "kinematics_restore_identity"); + await wait((s) => s.error === null, "kinematics_restore_identity_ack"); + })); + 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}`); + })); + 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("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", exact: true }).first(), `jog_${axis}_stop`); + } + })); + results.push(await record("SPINDLE", async () => { + await click(main.locator('button[aria-label="Spindle clockwise"]'), "spindle_cw"); + await click(main.locator('button[aria-label="Spindle counter-clockwise"]'), "spindle_ccw"); + await click(main.getByRole("button", { name: "Stop", exact: true }).nth(1), "spindle_stop"); + const brake = main.getByLabel("Brake", { exact: true }); + if (await brake.count() && !await brake.isDisabled()) { + await brake.click(); + await wait((s) => s.error === null && s.command?.includes("SPINDLE_BRAKE acknowledged"), "spindle_brake_toggle_ack"); + await brake.click(); + await wait((s) => s.error === null && s.command?.includes("SPINDLE_BRAKE acknowledged"), "spindle_brake_restore_ack"); + } + })); + results.push(await record("COOLANT", async () => { + for (const name of ["Mist", "Flood"]) { + const box = main.getByLabel(name, { exact: true }); + if (await box.count()) { + if (await box.isDisabled()) throw new Error(`${name}:disabled`); + await box.click({ force: true }); + await wait((s) => s.error === null && s.command?.includes("COOLANT_COMMAND acknowledged"), `${name}_toggle_ack`); + await box.click({ force: true }); + await wait((s) => s.error === null && s.command?.includes("COOLANT_COMMAND acknowledged"), `${name}_restore_ack`); + } + } + })); + 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}"]`); + if (await input.count() && !await input.isDisabled()) { + await input.evaluate((element, next) => { element.value = String(next); element.dispatchEvent(new Event("input", { bubbles: true })); element.dispatchEvent(new Event("change", { bubbles: true })); }, value); + await page.waitForTimeout(300); + } + } + })); + 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("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 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"); + await click(main.locator('button[aria-label="Pause execution [P]"]'), "program_pause"); + await wait((s) => s.program === "paused", "program_paused"); + await click(main.locator('button[aria-label="Resume execution [S]"]'), "program_resume"); + 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 wait((s) => s.program === "loaded" && s.error === null, "program_reload_before_step_ack"); + await click(main.locator(".program-actions button").nth(2), "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 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"); + await click(main.locator('button[aria-label="Stop program execution [ESC]"]'), "program_abort_again"); + await wait((s) => s.operation === "Operation IDLE" && ["loaded", "aborted"].includes(s.program) && s.error === null, "program_abort_again_ack"); + })); + results.push(await record("RUN STARTUP SEQUENCE", async () => { + await click(main.locator(".program-actions button").first(), "startup_sequence_reload"); + await wait((s) => s.program === "loaded" && s.queue === "0" && s.runEnabled === "true" && !s.programLoading && s.error === null, "startup_sequence_reload_ack"); + await click(main.getByRole("button", { name: "Run startup sequence", exact: true }), "startup_sequence"); + await wait((s) => s.program === "running", "startup_sequence_running"); + await click(main.locator('button[aria-label="Stop program execution [ESC]"]'), "startup_sequence_abort"); + await wait((s) => s.operation === "Operation IDLE" && ["loaded", "aborted"].includes(s.program) && s.error === null, "startup_sequence_abort_ack"); + })); + results.push(await record("EDITOR PROGRAM LOAD + REOPEN", 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 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="Reopen editor defaults"]'), "reopen_defaults"); + await wait((s) => s.program === "loaded" && s.case?.includes("xyzac_switchkins") && s.error === null, "reopen_defaults_ack"); + })); + results.push(await record("DISABLED unsupported buttons", async () => { + for (const name of ["Toggle skip lines with slash", "Toggle optional pause"]) if (!await main.locator(`button[aria-label="${name}"]`).isDisabled()) throw new Error(`${name}:must_be_disabled`); + })); +} finally { + const buttons = await main.locator("button").evaluateAll((items) => items.map((button) => ({ text: button.textContent?.trim() ?? "", aria: button.getAttribute("aria-label"), disabled: button.disabled }))); + await page.screenshot({ path: "/tmp/xyzac-panel-buttons-browser.png", fullPage: true }); + console.log(JSON.stringify({ schema: "xyzac-panel-buttons-browser.v1", status: failures.length === 0 && browserErrors.length === 0 ? "verified" : "failed", url, results, button_count: buttons.length, disabled_button_count: buttons.filter((button) => button.disabled).length, failures, browser_errors: browserErrors, final_state: await state(), screenshot: "/tmp/xyzac-panel-buttons-browser.png" })); + await browser.close(); +} diff --git a/web/react/src/App.tsx b/web/react/src/App.tsx index efc575c..043dec6 100644 --- a/web/react/src/App.tsx +++ b/web/react/src/App.tsx @@ -49,7 +49,7 @@ function MachineSequence({ wasmReady, isEstop, machineOn, allHomed, programLoade {machineOn ? "Power Off" : "Power On"} HOME ALL Run Program - Run Startup Sequence + Run Startup Sequence ; } @@ -164,7 +164,7 @@ export function App() { const [casePath, setCasePath] = useState(DEFAULT_CASE); const [caseStatus, setCaseStatus] = useState("ready to load"); const [commandStatus, setCommandStatus] = useState("Idle"); - const [programText, setProgramText] = useState(DEFAULT_PROGRAM); + const [programText, setProgramTextState] = useState(DEFAULT_PROGRAM); const [programPath, setProgramPath] = useState(PROGRAM_PATH); const [programLoading, setProgramLoading] = useState(false); const [selectedAxis, setSelectedAxis] = useState("x"); @@ -213,7 +213,7 @@ export function App() { const stopJogTimer = () => { if (jogTimer.current) { clearInterval(jogTimer.current); jogTimer.current = null; } }; const startJogTimer = () => { if (jogTimer.current) return; jogTimer.current = setInterval(() => { if (jogStepPending.current) return; jogStepPending.current = true; operationController.execute("SIM_STEP", { cycles: 1, response: "motion_delta" }, { state: axisState, gate: false }).catch(() => undefined).finally(() => { jogStepPending.current = false; }); }, 50); }; const startRunTimer = () => { if (runTimer.current) return; runTimer.current = setInterval(() => { if (runStepPending.current) return; runStepPending.current = true; operationController.execute("SIM_STEP", { cycles: 5, response: "motion_delta" }, { state: axisState, gate: false }).catch(() => undefined).finally(() => { runStepPending.current = false; }); }, 50); }; - const dispatch = (kind: string, payload: Record = {}, gate = true, action: string | null = null): Promise => { + const dispatch = async (kind: string, payload: Record = {}, gate = true, action: string | null = null): Promise => { setCommandStatus(`${kind} pending`); // The AXIS task stops issuing servo/task polls before changing the // interpreter state. A SIM_STEP already in flight is allowed to finish, @@ -221,6 +221,13 @@ export function App() { // protocol sequence reserved for RESUME. if (kind === "JOG_STOP") stopJogTimer(); if (["PROGRAM_PAUSE", "PROGRAM_ABORT"].includes(kind)) stopRunTimer(); + // AXIS stops its periodic stat/LivePlotter update before AUTO_PAUSE, + // AUTO_RESUME and abort. Wait for the single already-issued WASM tick to + // settle so the strict protocol sequence cannot interleave a control edge + // with a servo/task sample. + if (["PROGRAM_PAUSE", "PROGRAM_RESUME", "PROGRAM_ABORT"].includes(kind)) { + while (runStepPending.current) await new Promise((resolve) => setTimeout(resolve, 0)); + } const pending = operationController.execute(kind, payload, { state: axisState, gate, ...(action ? { action } : {}) }); setOperationTrace(operationController.exportTrace()); return pending.then(() => { setOperationTrace(operationController.exportTrace()); setCommandStatus(`${kind} acknowledged`); if (kind === "PROGRAM_RUN" || kind === "PROGRAM_RESUME") startRunTimer(); if (["PROGRAM_PAUSE", "PROGRAM_ABORT", "PROGRAM_RESTART"].includes(kind)) stopRunTimer(); if (kind === "JOG_START") startJogTimer(); if (kind === "JOG_STOP") stopJogTimer(); }).catch((error: Error) => { @@ -237,7 +244,7 @@ export function App() { const releaseEstop = () => dispatch("ESTOP", { active: false }, false); const toggleEstop = () => { if (!isEstop) { emergencyStop(); return; } void releaseEstop().catch(() => undefined); }; const powerOn = () => { if (!gates.power.enabled) return; void dispatch("POWER", { active: isEstop ? false : !machineOn }, false).catch(() => undefined); }; - const resetMachine = () => { stopRunTimer(); stopJogTimer(); if (isEstop || programRunning || !machineOn) return; void dispatch("RESET").catch(() => undefined); }; + const resetMachine = () => { stopRunTimer(); stopJogTimer(); if (!gates.reset.enabled) return; void dispatch("RESET").catch(() => undefined); }; const homeAll = () => { void dispatch("HOME", { all: true }).catch(() => undefined); }; const runStartupSequence = () => { if (programLoading) return; @@ -314,6 +321,17 @@ export function App() { setCaseStatus(`blocked: ${error instanceof Error ? error.message : "case_load_failed"}`); } finally { setProgramLoading(false); } }; + const reopenDefaults = () => { + if (programLoading || programRunning || !gates.load.enabled) return; + void loadCase(); + }; + // The legacy menu/toolbar handlers call setProgramText(DEFAULT_PROGRAM). + // Route that action through the same WASM-backed reopen transaction instead + // of changing only a local editor string. + const setProgramText = (value: string) => { + if (value === DEFAULT_PROGRAM) { reopenDefaults(); return; } + setProgramTextState(value); + }; const loadPackagedProgram = async () => { if (programLoading || programRunning) return; const path = "linuxcnc/nc_files/3D_Chips.ngc"; @@ -397,6 +415,13 @@ export function App() { } }; const enterManualMode = () => { setLeftTab("manual"); if (String(axisState.task.mode).toUpperCase() !== "MANUAL") void dispatch("SET_MODE", { mode: "MANUAL" }, false).catch(() => undefined); }; + const enterMdiMode = () => { setLeftTab("mdi"); if (String(axisState.task.mode).toUpperCase() !== "MDI") void dispatch("SET_MODE", { mode: "MDI" }, false).catch(() => undefined); }; + useEffect(() => { + if (!wasmSnapshotReady || programRunning || programPaused || programLoading || leftTab === "diagnostics") return; + const desiredMode = leftTab === "mdi" ? "MDI" : "MANUAL"; + if (String(axisState.task.mode).toUpperCase() === desiredMode) return; + void dispatch("SET_MODE", { mode: desiredMode }, false).catch(() => undefined); + }, [leftTab, axisState.task.mode, wasmSnapshotReady, programRunning, programPaused, programLoading]); useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { const target = event.target as HTMLElement | null; @@ -419,15 +444,18 @@ export function App() { useEffect(() => { const wasmQueueDepth = Number((state.snapshot?.wasm_motion as { state?: { queue_depth?: number } } | undefined)?.state?.queue_depth ?? 0); const programComplete = String(state.program?.program_status ?? "") === "completed" && wasmQueueDepth === 0; - if (programRunning && !programComplete) startRunTimer(); else stopRunTimer(); + const mdiMotionActive = !programRunning && String(state.task?.mode ?? "").toUpperCase() === "MDI" && wasmQueueDepth > 0; + if ((programRunning && !programComplete) || mdiMotionActive) startRunTimer(); else stopRunTimer(); if (!ready) { stopRunTimer(); stopJogTimer(); } - }, [programRunning, ready, state.program?.program_status, state.snapshot?.wasm_motion]); + }, [programRunning, ready, state.program?.program_status, state.task?.mode, state.snapshot?.wasm_motion]); const spindleState = (state.devices as Record | null)?.spindles?.[0] as Record | undefined; const spindleOn = spindleState ? Boolean(spindleState.enabled) : false; const spindleBrake = spindleState ? Boolean(spindleState.brake) : false; const ioState = (state.snapshot?.io as Record | undefined)?.state as Record | undefined; const manualReady = wasmSnapshotReady && Boolean(ioState?.ready) && !Boolean(ioState?.estop); - const coolant = { mist: Boolean((state.deviceIo as Record | null)?.coolant?.mist_requested), flood: Boolean((state.deviceIo as Record | null)?.coolant?.flood_requested) }; + const deviceIoEnvelope = state.deviceIo as Record | null; + const deviceIoCanonical = (deviceIoEnvelope?.state as Record | undefined) ?? deviceIoEnvelope; + const coolant = { mist: Boolean(deviceIoCanonical?.coolant?.mist ?? deviceIoCanonical?.coolant?.mist_requested), flood: Boolean(deviceIoCanonical?.coolant?.flood ?? deviceIoCanonical?.coolant?.flood_requested) }; const onSpindle = (direction: number) => { execute("SPINDLE_COMMAND", { spindle: 0, direction, rpm: direction === 0 ? 0 : 600, enabled: direction !== 0 }); }; const onCoolant = (key: "mist" | "flood") => { const next = { ...coolant, [key]: !coolant[key] }; execute("COOLANT_COMMAND", { mist: next.mist, flood: next.flood, lube: false }); }; const wasmMotion = (state.snapshot?.wasm_motion as { state?: Record } | undefined)?.state ?? {}; @@ -465,15 +493,15 @@ export function App() { const allHomed = homedAxes.length >= 5 && homedAxes.slice(0, 5).every(Boolean); const xyzacCommand = (kind: string, payload: Record = {}, action?: string) => { void dispatch(kind, payload, true, action ?? null).then(() => { if (kind === "VISUAL_CLEAR") setPlotCleared(true); }).catch(() => undefined); }; const position = xyzacView.pose ? `X${Number(xyzacView.pose.x).toFixed(3)} Y${Number(xyzacView.pose.y).toFixed(3)} Z${Number(xyzacView.pose.z).toFixed(3)} A${Number(xyzacView.pose.a).toFixed(3)} C${Number(xyzacView.pose.c).toFixed(3)}` : "WASM state pending"; - return
+ return
AXIS AXISLinuxCNC Web/WASM
{state.status.toUpperCase()}{taskState}
- +
{ setProgramText(DEFAULT_PROGRAM); setProgramPath(PROGRAM_PATH); setPlotCleared(false); }} /> execute("PROGRAM_STEP")} /> execute(programPaused ? "PROGRAM_RESUME" : "PROGRAM_PAUSE")} /> setZoom((value) => Math.min(2, value + .2))} /> setZoom((value) => Math.max(.5, value - .2))} /> { setView("z"); setRightTab("preview"); }} /> { setView("z2"); setRightTab("preview"); }} /> { setView("x"); setRightTab("preview"); }} /> { setView("y"); setRightTab("preview"); }} /> { setView("y2"); setRightTab("preview"); }} /> { setView("p"); setRightTab("preview"); }} /> setRotateMode((value) => !value)} /> xyzacCommand("VISUAL_CLEAR", {}, "vismachClear")} />
Load caseLoad 3D_Chips.ngc{programLoading ? "PROGRAM_LOAD pending: LinuxCNC WASM preview in progress..." : caseStatus}{previewProgress &&
{previewProgress.detail}{previewProgress.percent}%{previewProgress.percent}%
}{commandStatus}
{ void releaseEstop().catch(() => undefined); }} onPower={powerOn} onHomeAll={homeAll} onRun={runProgram} onSequence={runStartupSequence} /> -
+
{(["path", "live", "limits", "velocity", "offsets", "extents"] as const).map((layer) => )}
execute("PROGRAM_STEP")} canLoad={gates.load.enabled && !programLoading} canRun={gates.run.enabled && !programLoading} canStep={gates.step.enabled && !programLoading} currentLine={visibleCurrentLine} executionLine={visibleExecutionLine} executionLineText={visibleExecutionLineText} diagnostic={state.error?.code ?? null} /> diff --git a/web/react/src/protocol.ts b/web/react/src/protocol.ts index a9e6c38..19ffc1a 100644 --- a/web/react/src/protocol.ts +++ b/web/react/src/protocol.ts @@ -61,7 +61,10 @@ export type SimSnapshotV1 = { authoritative?: { state_hash?: string; planner?: TrajectorySnapshot; kinematics?: Record }; coordinates?: { machine?: number[]; relative?: number[]; g5x?: number; g92?: number[]; tool_offset?: number[] }; spindle?: Array<{ enabled?: boolean; direction?: number; commanded?: number; actual?: number; at_speed?: boolean }>; - io?: { coolant?: number; lube?: boolean; digital?: number[]; analog?: number[]; probe?: boolean }; + // XYZAC panel snapshots carry the canonical LinuxCNC WASM IO envelope + // (`{ schema, source, state, ... }`). Generic legacy cases may still add + // coolant/probe fields, so keep this boundary open to both shapes. + io?: Record; tool?: { current?: number; prepared?: number; pocket?: number; change_state?: string }; overrides?: { feed?: number; rapid?: number; spindle?: number[]; adaptive?: number; feed_hold?: boolean }; hal?: { graph_revision?: number; changed_values?: ArrayBuffer }; diff --git a/web/react/src/store.ts b/web/react/src/store.ts index cb3dfaf..6b70922 100644 --- a/web/react/src/store.ts +++ b/web/react/src/store.ts @@ -104,6 +104,7 @@ function projection(result: Record, message: MessageEnvelope, p joints, }, wasm_motion: (result.wasm_motion as Record) ?? previous.snapshot.wasm_motion, + io: (result.io as Record) ?? previous.snapshot.io, ...(visual ? { visual } : {}), } satisfies SimSnapshotV1; return { @@ -158,6 +159,7 @@ function projection(result: Record, message: MessageEnvelope, p visual: { ...panel.visual, toolpath: visualSegments }, kinematics: panel.kinematics, xyzac: panel.xyzac, + io: panel.io ?? previous.snapshot?.io, gates: panel.gates, motion_authority: panel.motion_authority, task_authority: panel.task_authority, diff --git a/web/worker/sim-session.mjs b/web/worker/sim-session.mjs index 07fdee4..bb68b59 100644 --- a/web/worker/sim-session.mjs +++ b/web/worker/sim-session.mjs @@ -549,6 +549,9 @@ export class SimSession { }); if (result === null) return { action, enabled: false, reason: "linuxcnc_wasm_gate_unavailable", taskState: task.state, interpState: "UNKNOWN" }; let reason = result === 0 ? "linuxcnc_wasm_authority" : WASM_GATE_REASONS[result] ?? "linuxcnc_wasm_gate_unknown_command"; + if (input.kind === "PROGRAM_RUN" && Number(program.status) === 5) { + return { action, enabled: false, reason: "xyzac_program_aborted_requires_restart", taskState: task.state, interpState: "IDLE", wasm: { io_ready: ready, io_estop: estop, program_status: program.program_status, queue_depth: wasmMotion.state.queue_depth, gate_result: result } }; + } if (result === 2 && input.kind === "POWER" && input.payload?.active !== true) reason = "xyzac_power_already_off"; if (result === 3 && input.kind === "POWER") reason = "xyzac_power_already_on"; if (result === 4 && input.kind === "MDI_EXECUTE") reason = "mdi_while_program_active"; @@ -1953,7 +1956,14 @@ export class SimSession { if (result.status === "verified") this.xyzacProgram.setRapidVelocity(this.xyzacRapidVelocity); if (result.status === "verified") { const subroutines = this.loadXyzacSubroutines(input.payload?.path); if (subroutines.status !== "verified") return finish(this.error(input, "program_subroutine", subroutines.code), "failed"); } const preview = result.status === "verified" ? this.xyzacProgram.preview() : result; - if (result.status === "verified" && preview.status === "verified") { this.previewState = preview; this.task.setProgram("loaded", 0); } + if (result.status === "verified" && preview.status === "verified") { + this.previewState = preview; + // AXIS open_file_guts()/reload_file() leaves the task usable for + // AUTO single-step after an earlier abort. Recover the task edge + // before publishing the newly loaded interpreter state. + if (this.task.state.state === "STOPPED") this.task.recover(); + this.task.setProgram("loaded", 0); + } const finalStatus = result.status === "verified" && preview.status === "verified" ? "verified" : "error"; return finish(this.envelope(finalStatus === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, preview, program: this.xyzacProgram.snapshot(), task: this.task.export(), motion_authority: "wasm", command: input.kind, operation_id: input.operation_id ?? null }), finalStatus); } @@ -2116,6 +2126,13 @@ export class SimSession { if (current.status === 1) this.xyzacProgram.start(); const result = this.xyzacProgram.step(); if (result.status === "verified") { + // AXIS AUTO_STEP returns to an interpreter-paused state after the + // single canonical block. Keep the WASM interpreter status and + // task status aligned so the Resume gate is authoritative. + if (result.program_status !== 4) { + const pauseResult = this.xyzacProgram.pause(); + if (pauseResult.status !== "verified") return finish(this.error(input, "program_pause", pauseResult.code ?? "program_pause_failed"), "failed"); + } if (this.task.state.state === "READY") this.task.transition("RUNNING"); if (result.motion) this.wasmMotion.enqueue(result.motion); this.wasmMotion.setPaused(true); @@ -2175,6 +2192,11 @@ export class SimSession { const block = input.payload?.block ?? input.payload?.command; if (this.xyzacProgram) { this.task.setMode("MDI"); + // AXIS send_mdi_command() leaves the interpreter/TP runnable after + // a previous manual jog stop. JOG_STOP intentionally pauses the + // WASM TP, so clear that transport pause before queuing MDI motion; + // the queue itself remains authoritative for execution. + this.wasmMotion.setPaused(false); const result = this.xyzacProgram.executeBlock(block); if (result.status === "verified") { const controlCode = Number(result.program?.control_code ?? 0); @@ -2233,7 +2255,11 @@ export class SimSession { if (this.motionAuthority === "wasm") { if (input.kind === "JOG_STOP") { this.wasmMotion.abort(); - this.wasmMotion.setPaused(true); + // AXIS jog_off() stops the active jog, but it does not leave the + // task/TP in AUTO_PAUSE. Keep the LinuxCNC TP runnable so an + // already queued servo sample, MDI block, or spindle/IO command + // cannot surface a synthetic linuxcnc_tp_paused scheduler error. + this.wasmMotion.setPaused(false); return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind, motion: this.wasmMotionProjection(), motion_authority: "wasm", operation_id: input.operation_id ?? null }), "verified"); } if (input.kind === "SET_OVERRIDE") { diff --git a/web/worker/test_xyzac_protocol_react.mjs b/web/worker/test_xyzac_protocol_react.mjs index 43a7803..afd466d 100644 --- a/web/worker/test_xyzac_protocol_react.mjs +++ b/web/worker/test_xyzac_protocol_react.mjs @@ -67,13 +67,16 @@ assert.equal(sent.length, 2); assert.deepEqual(controller.exportTrace().map((item) => item.phase), ["pending", "result", "pending", "result"]); const appSource = await readFile(new URL("../react/src/App.tsx", import.meta.url), "utf8"); +const ironLaw = await readFile(new URL("../../docs/linuxcnc-axis-web-iron-law.md", import.meta.url), "utf8"); +for (const source of [ironLaw, appSource]) assert.ok(source.includes("linuxcnc"), "axis_source_authority"); +for (const symbol of ["manual_ok()", "PROGRAM_RUN/PAUSE/RESUME/ABORT", "JOG_START/JOG_STOP", "MDI_EXECUTE", "COOLANT_COMMAND", "blocked_by_abi"]) assert.ok(ironLaw.includes(symbol), `iron_law_${symbol}`); const panelSource = await readFile(new URL("../react/src/XyzacTrtPanel.tsx", import.meta.url), "utf8"); const contractSource = await readFile(new URL("../react/src/xyzac-panel-contract.ts", import.meta.url), "utf8"); const sessionSource = await readFile(new URL("./sim-session.mjs", import.meta.url), "utf8"); const tpSource = await readFile(new URL("./wasm-linuxcnc-tp.mjs", import.meta.url), "utf8"); const combined = `${appSource}\n${panelSource}\n${contractSource}`; for (const symbol of ["XyzacTrtPanel", "XyzacProgramPane", "XyzacKinematicsControl", "XyzacManualPanel", "XyzacLiveParameters", "XyzacDiagnostics", "Spindle", "Feed actual", "TP velocity", "Live parameters", "KINEMATICS_SELECT", "VISUAL_CLEAR", "POWER", "switchkinsXyzac", "Operation trace", "Alarms / recovery"]) assert.ok(combined.includes(symbol), symbol); -for (const symbol of ["wasmSnapshotReady", "manualReady", "sourceJogScale", "sourceVelocityScale", "spindleOverrideSource", "SET_JOG_SCALE", "SET_MAX_VELOCITY", "SET_SPINDLE_OVERRIDE", "WASM PENDING", "LinuxCNC WASM override-limits command unavailable"]) assert.ok(appSource.includes(symbol), symbol); +for (const symbol of ["wasmSnapshotReady", "manualReady", "sourceJogScale", "sourceVelocityScale", "spindleOverrideSource", "reopenDefaults", "gates.reset.enabled", "SET_JOG_SCALE", "SET_MAX_VELOCITY", "SET_SPINDLE_OVERRIDE", "WASM PENDING", "LinuxCNC WASM override-limits command unavailable"]) assert.ok(appSource.includes(symbol), symbol); for (const forbidden of [/defaultValue=\"50\"/, /defaultValue=\"100\"/, /setBlockDelete/, /setOptionalStop/, /state\.status === \"running\" \? \"READING\"/]) assert.equal(forbidden.test(appSource), false, String(forbidden)); for (const forbidden of [/_sim_/, /_malloc/, /\bHEAP(?:U|F|I)?\d*\b/, /navigator\.storage/, /\bOPFS\b/i, /postMessage\s*\(/]) assert.equal(forbidden.test(combined), false, String(forbidden)); assert.ok(appSource.includes("SELECTED_XYZAC_CASE"));