Files
cnc_wams/qa/web-rtcp-5axis-site-test/capture-toolpath-preview-cases.mjs

688 lines
33 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/mmThree.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)),
};
}