688 lines
33 KiB
JavaScript
688 lines
33 KiB
JavaScript
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)),
|
||
};
|
||
}
|