import fs from "node:fs/promises"; import http from "node:http"; import path from "node:path"; import puppeteer from "puppeteer-core"; import { PNG } from "pngjs"; const REPO_ROOT = path.resolve("/home/meswork/cnc_wams"); const QA_ROOT = path.resolve("/home/meswork/cnc_wams/qa/web-rtcp-5axis-site-test"); const OUTPUT_DIR = path.join(QA_ROOT, "output"); const SCREENSHOT_DIR = path.join(QA_ROOT, "screenshots", "toolpath-preview-cases"); const RUN_FEEDBACK_SCREENSHOT_DIR = path.join(QA_ROOT, "screenshots", "run-preconditions-feedback"); const METER_SCENE_SCREENSHOT_DIR = path.join(QA_ROOT, "screenshots", "meter-scene-evidence"); const CHROME_PATH = process.env.CHROME_PATH || "/usr/bin/google-chrome"; const FIXTURE_URL = "/web-rtcp-5axis-sim-plan/app/index.html"; await fs.mkdir(OUTPUT_DIR, { recursive: true }); await fs.mkdir(SCREENSHOT_DIR, { recursive: true }); await fs.mkdir(RUN_FEEDBACK_SCREENSHOT_DIR, { recursive: true }); await fs.mkdir(METER_SCENE_SCREENSHOT_DIR, { recursive: true }); const MIME_TYPES = { ".css": "text/css; charset=utf-8", ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".json": "application/json; charset=utf-8", ".mjs": "text/javascript; charset=utf-8", ".svg": "image/svg+xml", ".wasm": "application/wasm", ".xml": "application/xml; charset=utf-8", }; function contentTypeFor(filePath) { return MIME_TYPES[path.extname(filePath).toLowerCase()] || "application/octet-stream"; } function createStaticServer(rootDir) { return http.createServer(async (request, response) => { try { const requestPath = decodeURIComponent(new URL(request.url || "/", "http://127.0.0.1").pathname); const relativePath = requestPath === "/" ? "/index.html" : requestPath; const targetPath = path.resolve(rootDir, `.${relativePath}`); if (!targetPath.startsWith(rootDir)) { response.writeHead(403); response.end("forbidden"); return; } let stat = await fs.stat(targetPath).catch(() => null); let filePath = targetPath; if (stat?.isDirectory()) { filePath = path.join(targetPath, "index.html"); stat = await fs.stat(filePath).catch(() => null); } if (!stat?.isFile()) { response.writeHead(404); response.end("not found"); return; } const body = await fs.readFile(filePath); response.writeHead(200, { "Content-Type": contentTypeFor(filePath), "Content-Length": String(body.byteLength), "Cache-Control": "no-store", }); response.end(body); } catch (error) { response.writeHead(500); response.end(error instanceof Error ? error.message : String(error)); } }); } const server = createStaticServer(REPO_ROOT); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); const address = server.address(); if (!address || typeof address === "string") { throw new Error("failed to start static server"); } const baseUrl = `http://127.0.0.1:${address.port}`; const browser = await puppeteer.launch({ headless: true, executablePath: CHROME_PATH, defaultViewport: { width: 1600, height: 1200, deviceScaleFactor: 1 }, args: [ "--disable-gpu", "--enable-webgl", "--use-angle=swiftshader", "--enable-unsafe-swiftshader", "--no-sandbox", ], }); const page = await browser.newPage(); const consoleErrors = []; page.on("console", (msg) => { if (msg.type() === "error") { consoleErrors.push(msg.text()); } }); page.on("pageerror", (error) => { consoleErrors.push(error.message); }); const report = { generatedAt: new Date().toISOString(), targetUrl: `${baseUrl}${FIXTURE_URL}`, projectPath: path.join(REPO_ROOT, "web-rtcp-5axis-sim-plan", "app", "index.html"), chromePath: CHROME_PATH, screenshots: {}, cases: [], consoleErrors, }; async function wait(ms) { await new Promise((resolve) => setTimeout(resolve, ms)); } async function waitForState(predicate, timeoutMs = 20000, label = "state condition") { const start = Date.now(); while (Date.now() - start < timeoutMs) { const snapshot = await page.evaluate(() => JSON.parse(JSON.stringify(window.webRtcp5AxisSimulation.getState()))); if (predicate(snapshot)) return snapshot; await wait(100); } throw new Error(`timeout waiting for ${label}`); } async function getCanvasDataset() { return page.$eval("[data-five-axis-canvas]", (canvas) => ({ ...canvas.dataset })); } async function waitForCanvasReady(timeoutMs = 20000) { await page.waitForFunction(() => { const canvas = document.querySelector("[data-five-axis-canvas]"); return canvas?.dataset?.threeReady === "true" && canvas?.dataset?.threePreviewScope === "machine-reference-and-toolpath"; }, { timeout: timeoutMs }); } async function captureCase(name) { const screenshotPath = path.join(SCREENSHOT_DIR, `${name}.png`); const canvas = await page.$("[data-five-axis-canvas]"); if (!canvas) { throw new Error(`missing canvas for ${name}`); } await canvas.screenshot({ path: screenshotPath }); report.screenshots[name] = screenshotPath; return screenshotPath; } async function captureRunFeedbackFrame(name) { const screenshotPath = path.join(RUN_FEEDBACK_SCREENSHOT_DIR, `${name}.png`); await page.screenshot({ path: screenshotPath, fullPage: true }); return screenshotPath; } async function captureMeterSceneFrame(name) { const screenshotPath = path.join(METER_SCENE_SCREENSHOT_DIR, `${name}.png`); const canvas = await page.$("[data-five-axis-canvas]"); if (!canvas) { throw new Error(`missing canvas for ${name}`); } await canvas.screenshot({ path: screenshotPath }); return screenshotPath; } async function recordCase(name, summary, checks = []) { const dataset = await getCanvasDataset(); const state = await page.evaluate(() => JSON.parse(JSON.stringify(window.webRtcp5AxisSimulation.getState()))); const screenshotPath = report.screenshots[name]; const pixelStats = screenshotPath ? await analyzePng(screenshotPath) : null; report.cases.push({ name, summary, status: checks.every((check) => check.pass) ? "PASS" : "FAIL", checks, pixelStats, dataset, state: { activeProgram: state.activeProgram, activeLine: state.activeLine, programSource: state.programSource, programExecutionSourceMode: state.programExecutionSourceMode, programExecutionSummary: state.programExecution?.summary || null, runState: state.runState, rtcpState: state.rtcpState, kinsType: state.kinsType, activeLine: state.activeLine, programExecutionMotionIndex: state.programExecutionMotionIndex, programExecutionSampleIndex: state.programExecutionSampleIndex, programRuntimeFeedbackSource: state.programRuntimeFeedback?.sourceMode || null, }, }); } function previewChecks(dataset, { requirePathPoints = true, requireExecutedPath = false, requireArcPoints = false, requireRapidFeed = false, expectPathPoints = null, expectRtcp = null, } = {}) { const checks = [ check("canvas ready", dataset.threeReady === "true", `threeReady=${dataset.threeReady}`), check("WebGL renderer", dataset.threeRenderer === "webgl", `renderer=${dataset.threeRenderer}`), check("机床参考模型", dataset.threePreviewScope === "machine-reference-and-toolpath" && dataset.threeMachineReferenceModel === "webgl-five-axis-reference", `scope=${dataset.threePreviewScope}, model=${dataset.threeMachineReferenceModel}`), check("TCP 球标记", dataset.threeTcpMarker === "sphere" && dataset.threeToolExecutionMarker === "true", `tcp=${dataset.threeTcpMarker}, marker=${dataset.threeToolExecutionMarker}`), check("刀轴线", dataset.threeToolAxisMarker === "line", `toolAxisMarker=${dataset.threeToolAxisMarker}`), check("场景对象数量", Number(dataset.threeSceneObjects || 0) >= 12, `sceneObjects=${dataset.threeSceneObjects}`), check("无 G-code 语义生成", dataset.threeNoGcodeSemanticsGeneration === "ok", `semanticGuard=${dataset.threeNoGcodeSemanticsGeneration}`), ]; if (requirePathPoints) { checks.push(check("刀路预览点", Number(dataset.threePathPoints || 0) >= 1, `pathPoints=${dataset.threePathPoints}`)); } if (requireExecutedPath) { checks.push(check("执行轨迹点", Number(dataset.threeExecutedPathPoints || 0) >= 1, `executed=${dataset.threeExecutedPathPoints}`)); } if (requireArcPoints) { checks.push(check("圆弧轨迹点", Number(dataset.threeArcPathPoints || 0) >= 1, `arc=${dataset.threeArcPathPoints}`)); } if (requireRapidFeed) { checks.push(check("rapid/feed 区分", Number(dataset.threeRapidPathPoints || 0) >= 1 && Number(dataset.threeFeedPathPoints || 0) >= 1, `rapid=${dataset.threeRapidPathPoints}, feed=${dataset.threeFeedPathPoints}`)); } if (expectPathPoints !== null) { checks.push(check("路径点期望", Number(dataset.threePathPoints || 0) === expectPathPoints, `pathPoints=${dataset.threePathPoints}, expected=${expectPathPoints}`)); } if (expectRtcp !== null) { checks.push(check("RTCP 状态", dataset.threeRtcpState === expectRtcp, `rtcp=${dataset.threeRtcpState}`)); } return checks; } function meterSceneChecks(dataset, pixelStats = null) { const bounds = parseJson(dataset.threePathBoundsMeters); return [ check("scene units are meters", dataset.threeSceneUnits === "m", `sceneUnits=${dataset.threeSceneUnits}`), check("linear scale visible", Number(dataset.threeLinearUnitScaleToMeters || 0) > 0, `scale=${dataset.threeLinearUnitScaleToMeters}`), check("path fit bounds", dataset.threePathFitBounds === "ok", `fit=${dataset.threePathFitBounds}`), check("path bounds in meters", Number(bounds?.maxSpan || 0) > 0 && Number(bounds?.maxSpan || 0) < 5, `bounds=${dataset.threePathBoundsMeters}`), check("canvas nonblank", !pixelStats || pixelStats.nonBlackRatio > 0.015, `nonBlack=${pixelStats?.nonBlackRatio ?? "-"}`), ]; } function mixedUnitsChecks(dataset, state) { const motion = state.programExecution?.motion || []; const units = [...new Set(motion.map((event) => event.linearUnits).filter(Boolean))]; const xMeters = motion.map((event) => sceneMeterX(event)).filter(Number.isFinite); return [ check("G20/G21 motion units", units.includes("inch") && units.includes("mm"), `units=${units.join(",")}`), check("scene units are meters", dataset.threeSceneUnits === "m", `sceneUnits=${dataset.threeSceneUnits}`), check("mixed unit path visible", Number(dataset.threePathPoints || 0) >= 2, `pathPoints=${dataset.threePathPoints}`), check("1 inch equals 25.4 mm in scene", xMeters.some((value) => Math.abs(value - 0.0254) < 1e-9), `xMeters=${xMeters.join(",")}`), check("no unit fallback in canonical motion", state.programExecutionSourceMode === "linuxcnc-interpreter-wasm", `source=${state.programExecutionSourceMode}`), ]; } function sceneMeterX(event) { const factor = event?.linearUnits === "inch" ? 0.0254 : event?.linearUnits === "m" ? 1 : 0.001; const value = Number(event?.axes?.x); return Number.isFinite(value) ? value * factor : null; } function parseJson(text) { try { return JSON.parse(text || "null"); } catch { return null; } } function check(name, pass, detail) { return { name, pass: Boolean(pass), detail }; } function assertChecks(caseName, checks) { const failed = checks.filter((item) => !item.pass); if (failed.length > 0) { console.warn(`${caseName} failed: ${failed.map((item) => `${item.name} (${item.detail})`).join("; ")}`); } } function summarizeRunFeedbackState(state, dataset, elapsedMs, screenshotPath) { const activeRowLine = Number(documentActiveLineFromState(state)); return { elapsedMs, screenshotPath, canvas: { threeReady: dataset.threeReady, rtcpState: dataset.threeRtcpState, executedPathPoints: Number(dataset.threeExecutedPathPoints || 0), currentSegmentHighlight: dataset.threeCurrentSegmentHighlight, toolExecutionTraceSource: dataset.threeToolExecutionTraceSource, }, state: { profileId: state.machineProfile, iniReady: state.iniConfigReadiness?.ready === true, iniPath: state.iniConfigReadiness?.path || null, coordinates: state.iniConfigReadiness?.coordinates || null, kinematicsModuleId: state.profile?.kinematicsModuleId || null, selectedGcodeSourceRel: state.machineFileStaging?.selectedGcodeSourceRel || null, taskHalSessionProgramPath: state.taskHalSession?.programPath || null, taskHalStatusLoop: state.taskHalStatusLoop || null, runState: state.runState, activeLine: state.activeLine, activeRowLine, activeLineMatchesUi: activeRowLine === Number(state.activeLine), droAxisPose: state.dro ? { x: state.dro.x, y: state.dro.y, z: state.dro.z, a: state.dro.a, b: state.dro.b, c: state.dro.c, } : null, feedbackAxisPose: state.programRuntimeFeedback?.axisPose || null, droMatchesFeedback: axisPoseMatchesDro(state.dro, state.programRuntimeFeedback?.axisPose), rtcpState: state.rtcpState, kinsType: state.kinsType, feedback: state.programRuntimeFeedback ? { sourceMode: state.programRuntimeFeedback.sourceMode, semanticBoundary: state.programRuntimeFeedback.semanticBoundary, line: state.programRuntimeFeedback.line, taskCycle: state.programRuntimeFeedback.taskCycle, servoCycle: state.programRuntimeFeedback.cycle, velocity: state.programRuntimeFeedback.currentVelocityMmPerMin, distanceToGo: state.programRuntimeFeedback.distanceToGo, } : null, feedbackHistoryLength: state.programRuntimeFeedbackHistory?.length || 0, feedbackHistorySourceModes: [...new Set((state.programRuntimeFeedbackHistory || []).map((entry) => entry.sourceMode))], taskHalStatus: state.taskHalStatus ? { taskState: state.taskHalStatus.ui?.taskState || state.taskHalStatus.task?.state || null, taskMode: state.taskHalStatus.ui?.taskMode || state.taskHalStatus.task?.mode || null, interpState: state.taskHalStatus.ui?.interpState || state.taskHalStatus.task?.interpState || null, taskCycle: state.taskHalStatus.ui?.taskCycle || null, servoCycle: state.taskHalStatus.ui?.servoCycle || null, } : null, }, }; } function documentActiveLineFromState(state) { return state.__activeRowLine ?? null; } function axisPoseMatchesDro(dro, axisPose) { if (!dro || !axisPose) return false; return ["x", "y", "z", "a", "b", "c"].every((axis) => ( Math.abs(Number(dro[axis] || 0) - Number(axisPose[axis] || 0)) < 1e-9 )); } async function snapshotRunFeedback(elapsedMs, screenshotName) { const screenshotPath = await captureRunFeedbackFrame(screenshotName); const dataset = await getCanvasDataset(); const state = await page.evaluate(() => { const snapshot = JSON.parse(JSON.stringify(window.webRtcp5AxisSimulation.getState())); snapshot.__activeRowLine = Number(document.querySelector(".gcode-row.active")?.dataset.programLine || 0); return snapshot; }); return summarizeRunFeedbackState(state, dataset, elapsedMs, screenshotPath); } function runFeedbackChecks(samples, baseline = null) { const states = samples.map((sample) => sample.state); const histories = states.map((state) => Number(state.feedbackHistoryLength || 0)); const ticks = states.map((state) => Number(state.taskHalStatusLoop?.tickCount || 0)); const baselineSequence = Number(baseline?.state?.taskHalStatusLoop?.sequence || 0); const sourceModes = states.flatMap((state) => state.feedbackHistorySourceModes || []); return [ check("INI ready", states.every((state) => state.iniReady), `iniReady=${states.map((state) => state.iniReady).join(",")}`), check("selected LinuxCNC G-code", states.every((state) => state.selectedGcodeSourceRel?.endsWith("xyzac_switchkins_test_1.ngc")), states.at(-1)?.selectedGcodeSourceRel || "-"), check("task/HAL session opened selected G-code", states.every((state) => state.taskHalSessionProgramPath?.endsWith("xyzac_switchkins_test_1.ngc")), states.at(-1)?.taskHalSessionProgramPath || "-"), check("taskHalStatusLoop 新 RUN sequence", states.every((state) => Number(state.taskHalStatusLoop?.sequence || 0) > baselineSequence), `baselineSequence=${baselineSequence}, sequences=${states.map((state) => state.taskHalStatusLoop?.sequence || 0).join(",")}`), check("taskHalStatusLoop 本次 RUN tickCount", Math.max(...ticks) >= 3, `ticks=${ticks.join(",")}`), check("programRuntimeFeedbackHistory 本次 RUN 采样", Math.max(...histories) >= 3, `history=${histories.join(",")}`), check("feedback sourceMode 来自 task/HAL", sourceModes.length > 0 && sourceModes.every((mode) => mode === "linuxcnc-task-motion-hal-wasm"), `sourceModes=${sourceModes.join(",")}`), check("无 fixture-line-playback feedback", !sourceModes.includes("fixture-line-playback"), `sourceModes=${sourceModes.join(",")}`), check("semantic boundary", states.every((state) => state.feedback?.semanticBoundary === "linuxcnc_task_motion_hal_wasm_simulation_runtime"), states.map((state) => state.feedback?.semanticBoundary || "-").join(",")), check("activeLine 等于 UI 高亮行", states.every((state) => state.activeLineMatchesUi), `active=${states.map((state) => `${state.activeLine}/${state.activeRowLine}`).join(",")}`), check("DRO 等于 runtime feedback axisPose", states.every((state) => state.droMatchesFeedback), "droMatchesFeedback=true for all samples"), check("RTCP canvas 与 state 一致", samples.every((sample) => sample.canvas.rtcpState === sample.state.rtcpState), samples.map((sample) => `${sample.canvas.rtcpState}/${sample.state.rtcpState}`).join(",")), check("runtime cycle 可见", states.every((state) => Number(state.feedback?.taskCycle || 0) > 0 && Number(state.feedback?.servoCycle || 0) > 0), states.map((state) => `${state.feedback?.taskCycle || 0}/${state.feedback?.servoCycle || 0}`).join(",")), check("canvas 执行轨迹", samples.every((sample) => sample.canvas.threeReady === "true" && sample.canvas.currentSegmentHighlight === "ok" && sample.canvas.executedPathPoints >= 1), samples.map((sample) => `${sample.canvas.threeReady}/${sample.canvas.executedPathPoints}/${sample.canvas.currentSegmentHighlight}`).join(",")), ]; } try { await page.goto(`${baseUrl}${FIXTURE_URL}`, { waitUntil: "networkidle2", timeout: 60000 }); await page.waitForSelector('[data-shell="gmoccapy-5axis"]', { timeout: 15000 }); await page.waitForFunction(() => Boolean(window.webRtcp5AxisSimulation?.getState), { timeout: 15000 }); await waitForState((state) => state.kinematicsRuntimeReadiness?.loaded === true, 20000, "kinematics runtime ready"); await waitForCanvasReady(); await wait(1200); let dataset = await getCanvasDataset(); let checks = previewChecks(dataset, { requirePathPoints: true, requireExecutedPath: true }); assertChecks("01-home-toolpath", checks); await captureCase("01-home-toolpath"); await recordCase("01-home-toolpath", "默认首屏预览:机床参考模型、TCP 球、刀轴线、fixture 路径和执行轨迹可见", checks); await page.evaluate(() => { window.webRtcp5AxisSimulation.dispatch({ type: "LOAD_PROGRAM", filename: "operator-demo.ngc", content: [ "G90 G17", "G0 X0 Y0 Z0", "G1 X10 F100", "G1 Y10", "G1 X0", "G1 Y0", "G0 Z5", "M5", "M2", ].join("\n"), }); }); await waitForState((state) => state.programExecutionSourceMode === "linuxcnc-interpreter-wasm" && state.activeProgram === "operator-demo.ngc", 20000, "operator demo loaded"); await waitForCanvasReady(); await wait(500); dataset = await getCanvasDataset(); checks = previewChecks(dataset, { requirePathPoints: true, requireExecutedPath: true, requireRapidFeed: true }); assertChecks("02-operator-demo-toolpath", checks); await captureCase("02-operator-demo-toolpath"); await recordCase("02-operator-demo-toolpath", "本地矩形 G-code:验证 LinuxCNC interpreter canonical motion、rapid/feed 区分、执行轨迹和 TCP 标记", checks); await page.evaluate(() => { window.webRtcp5AxisSimulation.dispatch({ type: "LOAD_PROGRAM", filename: "operator-arc-demo.ngc", content: [ "G90 G17", "G0 X1 Y0 Z0", "G2 X0 Y1 I-1 J0 F60", "M2", ].join("\n"), }); }); await waitForState((state) => state.activeProgram === "operator-arc-demo.ngc" && state.programExecution?.summary?.motionTypes?.includes("ARC_FEED"), 20000, "arc demo loaded"); await waitForCanvasReady(); await wait(500); dataset = await getCanvasDataset(); checks = previewChecks(dataset, { requirePathPoints: true, requireExecutedPath: true, requireArcPoints: true }); assertChecks("03-arc-demo-toolpath", checks); await captureCase("03-arc-demo-toolpath"); await recordCase("03-arc-demo-toolpath", "圆弧 G-code:验证 ARC_FEED 进入弧线轨迹图层并保留执行轨迹", checks); await page.evaluate(() => document.querySelector('[data-action="clear-preview"]').click()); await waitForCanvasReady(); await wait(500); dataset = await getCanvasDataset(); checks = previewChecks(dataset, { requirePathPoints: false, expectPathPoints: 0 }); assertChecks("04-clear-preview-reference", checks); await captureCase("04-clear-preview-reference"); await recordCase("04-clear-preview-reference", "清空刀路后:路径点为 0,但机床参考模型、TCP 球和刀轴线仍应可见", checks); await waitForState((state) => state.machineFileStaging?.status === "staged" && (state.machineFileStaging?.gcodeSources?.length || 0) >= 4, 25000, "machine files staged"); await page.select('[data-action="select-linuxcnc-gcode-source"]', "configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/impeller-7bl-xyzac.ngc"); await waitForState((state) => state.activeProgram?.endsWith("impeller-7bl-xyzac.ngc"), 25000, "vendored impeller loaded"); await waitForCanvasReady(); await wait(1200); dataset = await getCanvasDataset(); checks = previewChecks(dataset, { requirePathPoints: true, requireExecutedPath: true, requireRapidFeed: true, expectRtcp: "on" }); assertChecks("05-vendored-impeller-toolpath", checks); await captureCase("05-vendored-impeller-toolpath"); await recordCase("05-vendored-impeller-toolpath", "LinuxCNC vendored 五轴 impeller 程序:验证长路径、switchkins/RTCP 状态、rapid/feed 图层和 TCP 执行轨迹", checks); const meterSceneSamples = []; for (const viewport of [ { name: "desktop", width: 1600, height: 1200, deviceScaleFactor: 1 }, { name: "mobile", width: 390, height: 844, deviceScaleFactor: 2 }, ]) { await page.setViewport(viewport); await waitForCanvasReady(); await wait(600); const screenshotPath = await captureMeterSceneFrame(`08-meter-scene-${viewport.name}`); const sampleDataset = await getCanvasDataset(); const pixelStats = await analyzePng(screenshotPath); const sampleChecks = [ ...previewChecks(sampleDataset, { requirePathPoints: true, requireExecutedPath: true }), ...meterSceneChecks(sampleDataset, pixelStats), ]; assertChecks(`08-meter-scene-${viewport.name}`, sampleChecks); meterSceneSamples.push({ viewport, screenshotPath, pixelStats, dataset: sampleDataset, status: sampleChecks.every((item) => item.pass) ? "PASS" : "FAIL", checks: sampleChecks, }); } await page.setViewport({ width: 1600, height: 1200, deviceScaleFactor: 1 }); const meterSceneChecksAll = meterSceneSamples.flatMap((sample) => sample.checks); const meterSceneReport = { generatedAt: new Date().toISOString(), targetUrl: report.targetUrl, caseName: "08-meter-scene-desktop-mobile", summary: "浏览器米尺度 Three.js 证据:desktop/mobile canvas 非空、路径 bounds 为米、预览稳定居中", status: meterSceneChecksAll.every((item) => item.pass) ? "PASS" : "FAIL", samples: meterSceneSamples, consoleErrors, }; await fs.writeFile( path.join(OUTPUT_DIR, "meter-scene-evidence.json"), `${JSON.stringify(meterSceneReport, null, 2)}\n`, "utf8", ); report.cases.push({ name: "08-meter-scene-desktop-mobile", summary: meterSceneReport.summary, status: meterSceneReport.status, screenshotDir: METER_SCENE_SCREENSHOT_DIR, output: path.join(OUTPUT_DIR, "meter-scene-evidence.json"), sampleCount: meterSceneSamples.length, }); await page.evaluate(() => { window.webRtcp5AxisSimulation.dispatch({ type: "LOAD_PROGRAM", filename: "operator-g20-g21-mixed-units.ngc", content: [ "G90 G20", "G1 X1.0 Y0 Z0 F10", "G21", "G1 X25.4 Y25.4 Z0 F254", "M2", ].join("\n"), }); }); await waitForState((state) => ( state.activeProgram === "operator-g20-g21-mixed-units.ngc" && state.programExecutionSourceMode === "linuxcnc-interpreter-wasm" && (state.programExecution?.motion || []).some((event) => event.linearUnits === "inch") && (state.programExecution?.motion || []).some((event) => event.linearUnits === "mm") ), 20000, "G20/G21 mixed unit program loaded"); await waitForCanvasReady(); await wait(600); dataset = await getCanvasDataset(); const mixedUnitsState = await page.evaluate(() => JSON.parse(JSON.stringify(window.webRtcp5AxisSimulation.getState()))); checks = [ ...previewChecks(dataset, { requirePathPoints: true, requireExecutedPath: true }), ...mixedUnitsChecks(dataset, mixedUnitsState), ]; assertChecks("09-g20-g21-mixed-units-preview", checks); await captureCase("09-g20-g21-mixed-units-preview"); await recordCase("09-g20-g21-mixed-units-preview", "G20/G21 混合单位程序:验证 canonical motion 保留 inch/mm,Three.js 统一按米绘制", checks); await page.select('[data-action="select-linuxcnc-gcode-source"]', "configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/impeller-7bl-xyzac.ngc"); await waitForState((state) => state.activeProgram?.endsWith("impeller-7bl-xyzac.ngc"), 25000, "vendored impeller restored before run"); await waitForCanvasReady(); await wait(800); await page.evaluate(() => document.querySelector('[data-action="power"]').click()); await waitForState((state) => state.machine.taskState === "on", 10000, "machine powered on"); await page.evaluate(() => document.querySelector('[data-action="mode-manual"]').click()); await waitForState((state) => state.machine.mode === "manual", 10000, "manual mode selected"); await page.evaluate(() => document.querySelector('[data-action="HOME"]').click()); await waitForState((state) => state.machine.allHomed === true, 10000, "machine homed"); await page.evaluate(() => document.querySelector('[data-action="mode-auto"]').click()); await waitForState((state) => state.machine.mode === "auto", 10000, "auto mode selected"); await page.evaluate(() => document.querySelector('[data-action="kins-tcp"]').click()); await waitForState((state) => state.rtcpState === "on" && state.kinsType === "tcp-xyzac", 10000, "RTCP enabled"); await page.evaluate(() => document.querySelector('[data-action="RUN"]').click()); await waitForState((state) => state.runState === "running" && state.programRuntimeFeedback?.sourceMode === "linuxcnc-task-motion-hal-wasm", 15000, "program running"); await waitForCanvasReady(); await wait(600); dataset = await getCanvasDataset(); checks = previewChecks(dataset, { requirePathPoints: true, requireExecutedPath: true, requireRapidFeed: true, expectRtcp: "on" }); assertChecks("06-running-rtcp-toolpath", checks); await captureCase("06-running-rtcp-toolpath"); await recordCase("06-running-rtcp-toolpath", "G-code 运行态:验证 task/HAL runtime feedback 驱动执行轨迹、当前段高亮、TCP 球和刀轴线跟随", checks); await page.evaluate(() => document.querySelector('[data-action="STOP"]').click()); await waitForState((state) => state.taskHalStatusLoop?.active === false, 10000, "previous run stopped"); await page.select('[data-action="select-linuxcnc-gcode-source"]', "configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzac_switchkins_test_1.ngc"); await waitForState((state) => ( state.activeProgram?.endsWith("xyzac_switchkins_test_1.ngc") && state.taskHalSession?.programPath?.endsWith("xyzac_switchkins_test_1.ngc") && state.iniConfigReadiness?.ready === true && state.profile?.kinematicsModuleId === "xyzac-trt" ), 25000, "run feedback selected program ready"); await waitForCanvasReady(); await page.evaluate(() => { const state = window.webRtcp5AxisSimulation.getState(); if (state.machine?.taskState !== "on") { document.querySelector('[data-action="power"]').click(); } }); await waitForState((state) => state.machine.taskState === "on", 10000, "machine powered on for run feedback"); await page.evaluate(() => document.querySelector('[data-action="mode-manual"]').click()); await waitForState((state) => state.machine.mode === "manual", 10000, "manual mode selected for run feedback"); await page.evaluate(() => document.querySelector('[data-action="HOME"]').click()); await waitForState((state) => state.machine.allHomed === true, 10000, "machine homed for run feedback"); await page.evaluate(() => document.querySelector('[data-action="mode-auto"]').click()); await waitForState((state) => state.machine.mode === "auto", 10000, "auto mode selected for run feedback"); const runFeedbackBaseline = await snapshotRunFeedback(0, "07-run-preconditions-and-feedback-0000ms-before-run"); await page.evaluate(() => document.querySelector('[data-action="RUN"]').click()); try { await waitForState((state) => ( ["running", "complete"].includes(state.runState) && (state.taskHalStatusLoop?.tickCount > runFeedbackBaseline.state.taskHalStatusLoop.tickCount || (state.programRuntimeFeedbackHistory?.length || 0) >= 3) && state.programRuntimeFeedback?.sourceMode === "linuxcnc-task-motion-hal-wasm" ), 15000, "run feedback status loop produced feedback"); } catch (error) { const failureSnapshot = await snapshotRunFeedback(15000, "07-run-preconditions-and-feedback-timeout"); await fs.writeFile( path.join(OUTPUT_DIR, "run-preconditions-feedback-timeout.json"), `${JSON.stringify({ error: error instanceof Error ? error.message : String(error), baseline: runFeedbackBaseline, failureSnapshot, consoleErrors, }, null, 2)}\n`, "utf8", ); throw error; } const runFeedbackStartedAt = Date.now(); const runFeedbackSamples = []; for (const elapsedMs of [200, 500, 1000, 2000, 5000]) { const waitMs = Math.max(runFeedbackStartedAt + elapsedMs - Date.now(), 0); if (waitMs > 0) await wait(waitMs); await waitForCanvasReady(); runFeedbackSamples.push(await snapshotRunFeedback(elapsedMs, `07-run-preconditions-and-feedback-${elapsedMs}ms`)); } const runFeedbackCaseChecks = runFeedbackChecks(runFeedbackSamples, runFeedbackBaseline); assertChecks("07-run-preconditions-and-feedback", runFeedbackCaseChecks); const runFeedbackReport = { generatedAt: new Date().toISOString(), targetUrl: report.targetUrl, caseName: "07-run-preconditions-and-feedback", summary: "浏览器 RUN 证据:INI/profile/kinematics/task-HAL 同一上下文,taskHalStatusLoop 持续推进,UI 消费 task/HAL/motion feedback", status: runFeedbackCaseChecks.every((item) => item.pass) ? "PASS" : "FAIL", checks: runFeedbackCaseChecks, baseline: runFeedbackBaseline, samples: runFeedbackSamples, consoleErrors, }; await fs.writeFile( path.join(OUTPUT_DIR, "run-preconditions-feedback.json"), `${JSON.stringify(runFeedbackReport, null, 2)}\n`, "utf8", ); report.cases.push({ name: "07-run-preconditions-and-feedback", summary: runFeedbackReport.summary, status: runFeedbackReport.status, checks: runFeedbackCaseChecks, screenshotDir: RUN_FEEDBACK_SCREENSHOT_DIR, output: path.join(OUTPUT_DIR, "run-preconditions-feedback.json"), sampleCount: runFeedbackSamples.length, }); report.consoleErrors = consoleErrors; await fs.writeFile( path.join(OUTPUT_DIR, "toolpath-preview-cases.json"), `${JSON.stringify(report, null, 2)}\n`, "utf8", ); } finally { await page.close().catch(() => {}); await browser.close().catch(() => {}); await new Promise((resolve) => server.close(resolve)); } async function analyzePng(filePath) { const buffer = await fs.readFile(filePath); const png = PNG.sync.read(buffer); const { width, height, data } = png; let luminanceSum = 0; let nonBlack = 0; for (let index = 0; index < data.length; index += 4) { const luminance = data[index] * 0.2126 + data[index + 1] * 0.7152 + data[index + 2] * 0.0722; luminanceSum += luminance; if (luminance > 8) nonBlack += 1; } const total = width * height; return { width, height, averageLuminance: Number((luminanceSum / total).toFixed(2)), nonBlackRatio: Number((nonBlack / total).toFixed(4)), }; }