diff --git a/coverage/xyzac-panel-button-parity.report.json b/coverage/xyzac-panel-button-parity.report.json index 403dc59..7f35991 100644 --- a/coverage/xyzac-panel-button-parity.report.json +++ b/coverage/xyzac-panel-button-parity.report.json @@ -41,6 +41,19 @@ "browser_screenshot_sha256": "c3e0a5b992381e7b6cb26f98a30338935df5239c875755727fc678911452ac24", "status": "verified" }, + "live_trace_program_replacement": { + "command": "node scripts/test_xyzac_live_trace_replacement_browser.mjs", + "release": "20260903-linuxcnc-wasm-live-replace-r32", + "status": "verified", + "first_program_preview_segments": 4, + "first_program_live_trace_points": 5, + "second_program_preview_segments": 1, + "second_program_live_trace_points": 0, + "old_live_trace_retained": false, + "browser_errors": 0, + "screenshot": "/tmp/xyzac-live-trace-replacement.png", + "screenshot_sha256": "4bdf642d3e51cffae8afadb881422d58408d3f095aa795566d045a35d2c05482" + }, "switchkins_direct_matrix": { "command": "node --experimental-strip-types web/worker/test_xyzac_trt_case.mjs", "sequence": [0, 1, 2, 0, 1, 2, 0], diff --git a/doc-rtcp/xyzac-panel-button-parity-test-report.md b/doc-rtcp/xyzac-panel-button-parity-test-report.md index 2ad7b85..5733391 100644 --- a/doc-rtcp/xyzac-panel-button-parity-test-report.md +++ b/doc-rtcp/xyzac-panel-button-parity-test-report.md @@ -365,3 +365,7 @@ UI cache does not consume WASM sequence: verified 详细字段、文件布局、原子写入和恢复边界见 [`AXIS Web/WASM OPFS 页面临时缓存设计`](../docs/linuxcnc-axis-web-wasm-opfs-ui-cache.md)。 发布版本 `20260903-linuxcnc-wasm-opfs-r30` 的真实 Chromium OPFS 往返:改变 Preview 视角为 `z`、切换 DRO、选择 A 轴,等待 1.8 秒防抖保存后刷新页面;刷新后 `view=z`、DRO 仍激活、A 轴仍选中,OPFS 中实际存在 `state-b.json` 和 `manifest.json`,两次 `localStorage` key 数均为 `0`,`browser_errors=0`。截图 `/tmp/xyzac-opfs-browser.png`。 + +## 实时轨迹程序替换验证 + +发布版本 `20260903-linuxcnc-wasm-live-replace-r32` 执行了真实浏览器流程:第一程序运行完成并产生 5 个 live trace 点,随后加载第二个程序。第二程序加载完成后 WASM preview 为 1 段、live trace 为 0,旧程序实时轨迹不再保留,`browser_errors=0`。截图 `/tmp/xyzac-live-trace-replacement.png`,SHA-256 `4bdf642d3e51cffae8afadb881422d58408d3f095aa795566d045a35d2c05482`。 diff --git a/scripts/test_xyzac_live_trace_replacement_browser.mjs b/scripts/test_xyzac_live_trace_replacement_browser.mjs new file mode 100644 index 0000000..b1e05af --- /dev/null +++ b/scripts/test_xyzac_live_trace_replacement_browser.mjs @@ -0,0 +1,33 @@ +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-live-trace-replacement-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 errors = []; +page.on("pageerror", (error) => errors.push(`pageerror:${error.message}`)); +page.on("console", (message) => { if (message.type() === "error") errors.push(`console:${message.text()}`); }); +const main = page.locator("main.axis-app"); +const state = () => main.evaluate((element) => ({ source: element.dataset.axisStateSource, machine: element.querySelector(".machine-state")?.textContent?.trim(), program: element.dataset.axisProgramStatus, line: element.dataset.axisCurrentLine, path: element.querySelector(".xyzac-tool-preview")?.dataset.xyzacToolpathCount, live: element.querySelector(".xyzac-tool-preview")?.dataset.xyzacLiveTraceCount, loading: element.querySelector(".case-status")?.getAttribute("aria-busy") === "true", command: element.querySelector(".command-status")?.textContent, error: element.querySelector(".diagnostic-error")?.textContent ?? 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(100); } throw new Error(`${label}:${JSON.stringify(last)}`); }; +const click = async (locator, label) => { for (let index = 0; index < 300; index += 1) { if (await locator.count() && !await locator.first().isDisabled()) { await locator.first().click(); return; } await page.waitForTimeout(100); } throw new Error(`${label}:disabled_or_missing`); }; +const loadFile = async (name, source) => { const input = main.locator('input[type="file"][aria-label="Open G-code file"]'); await input.setInputFiles({ name, mimeType: "text/plain", buffer: Buffer.from(source) }); return wait((value) => value.program === "loaded" && !value.loading && value.error === null, `${name}_loaded`); }; +const runUntilTrace = async () => { await click(main.locator('button[aria-label="Power on [F2]"]'), "power_on"); await click(main.locator('button[aria-label="Home all axes"]'), "home_all"); await click(main.getByRole("button", { name: /^Identity/ }), "identity"); await main.getByRole("tab", { name: "Manual Control [F3]" }).click(); await click(main.locator('button[aria-label="Begin executing current file [R]"]'), "run_first"); return wait((value) => Number(value.live) > 2, "first_live_trace"); }; +try { + await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 }); + await wait((value) => value.source === "linuxcnc-wasm", "wasm_startup"); + const first = await loadFile("live-first.ngc", "G21\nG90\nG1 X20 F120\nG1 Y20\nG1 X0\nG1 Y0\nM2\n"); + const running = await runUntilTrace(); + await click(main.locator('button[aria-label="Stop program execution [ESC]"]'), "stop_first"); + await wait((value) => value.program === "aborted" || value.program === "loaded", "first_stop"); + const second = await loadFile("live-second.ngc", "G21\nG90\nG1 X3 Y4 F120\nM2\n"); + const after = await wait((value) => value.program === "loaded" && value.path === "1" && value.live === "0" && value.error === null, "second_replacement"); + await page.screenshot({ path: "/tmp/xyzac-live-trace-replacement.png", fullPage: true }); + console.log(JSON.stringify({ schema: "xyzac-live-trace-replacement-browser.v1", status: errors.length === 0 && Number(running.live) > 2 && Number(after.path) === 1 && after.live === "0" ? "verified" : "failed", url, first_loaded: first, first_running: running, second_loaded: second, after_replacement: after, browser_errors: errors, screenshot: "/tmp/xyzac-live-trace-replacement.png" })); +} catch (error) { + console.log(JSON.stringify({ schema: "xyzac-live-trace-replacement-browser.v1", status: "failed", url, reason: error?.message ?? String(error), browser_errors: errors, screenshot: "/tmp/xyzac-live-trace-replacement.png" })); +} +await browser.close(); diff --git a/web/react/src/XyzacToolPreview.tsx b/web/react/src/XyzacToolPreview.tsx index f37512d..9d7a97d 100644 --- a/web/react/src/XyzacToolPreview.tsx +++ b/web/react/src/XyzacToolPreview.tsx @@ -218,6 +218,27 @@ export function XyzacToolPreview({ visual, view = "p", zoom = 1, rotateMode = fa } clearLines(pathGroup); clearLines(liveGroup); + // A new PROGRAM_LOAD, including the transient empty snapshot while + // loading, starts a new live-trace epoch. Clear internal playback and + // history buffers as well as rendered geometry so the previous + // program's yellow/orange path cannot be painted again. + liveTrace = []; + playbackQueue = []; + playbackStart = 0; + queuedLastTick = -1; + renderedTick = state.tick; + renderedPose = state.livePose; + renderedJoints = state.joints; + renderedActiveSegment = state.activeSegmentId ?? 0; + renderedProgress = state.activeProgress; + paintedTick = renderedTick; + paintedPose = renderedPose; + paintedJoints = renderedJoints; + paintedActiveSegment = renderedActiveSegment; + paintedProgress = renderedProgress; + // Seed the new epoch without adding a visible history point. The + // first post-load motion delta will create the first live segment. + liveTick = renderedTick; bounds = fit(segments); // Long programs can contain thousands of linear blocks. Batch their // vertices so preview loading does not block the UI on one WebGL diff --git a/web/react/src/test_xyzac_tool_preview.mjs b/web/react/src/test_xyzac_tool_preview.mjs index ae3ac17..6c99049 100644 --- a/web/react/src/test_xyzac_tool_preview.mjs +++ b/web/react/src/test_xyzac_tool_preview.mjs @@ -16,6 +16,8 @@ assert.deepEqual(visual.pose, { x: 11, y: -4, z: 19, a: 31, b: 0, c: 44, u: 0, v assert.deepEqual(visual.livePose, visual.pose); assert.equal(visual.activeProgress, 0.375); assert.equal(visual.activeSegmentId, 3); +for (const token of ["liveTrace = []", "playbackQueue = []", "queuedLastTick = -1", "liveTick = -1"]) assert.ok(source.includes(token), token); +for (const token of ["renderedTick = state.tick", "paintedTick = renderedTick", "A new PROGRAM_LOAD"]) assert.ok(source.includes(token), token); for (const token of ["xyzac-tool-preview", "xyzac-preview-toolpath", "xyzac-preview-live-toolpath", "xyzac-preview-tool", "xyzac-preview-tool-shaft", "xyzac-preview-tool-tip", "xyzac-origin-coordinate-system", "xyzac-origin-axis-${axis.label.toLowerCase()}", "xyzac-origin-label-${axis.label.toLowerCase()}", "OrbitControls", "enablePan", "enableRotate", "zoomIn", "panRight", "data-xyzac-controller=\"orbit\"", "data-xyzac-origin=\"0,0,0\"", "sourcePoint", "toolGroup.position.copy(pose)", "toolGroup.rotation.set(-Number(renderedPose.a ?? 0) * DEG, -Number(renderedPose.c ?? 0) * DEG, 0)", "const segments = state.toolpath as Segment[]", "activeSegment.samples.map(sourcePoint)", "segmentCandidatesBySourceLine", "candidates.length === 1", "state.activeProgress", "state.livePose", "motionTrace", "playbackQueue", "data-xyzac-preview=\"toolpath\"", "data-xyzac-helix-count", "data-xyzac-sample-count", "data-xyzac-samples-source", "data-xyzac-live-source", "data-xyzac-live-pose", "preserveDrawingBuffer: true", "authoritative tool preview"]) assert.ok(source.includes(token), token); for (const forbidden of [/arcPoints/, /Math\.atan2/, /Quaternion/, /cross\(/]) assert.equal(forbidden.test(source), false, String(forbidden)); for (const forbidden of [/xyzac-machine/, /table-x/, /saddle-y/, /spindle-z/, /accept\s*\(/, /postMessage\s*\(/, /_sim_/, /_malloc/, /navigator\.storage/]) assert.equal(forbidden.test(source), false, String(forbidden));