Add RTCP simulation QA updates

This commit is contained in:
2026-06-22 09:30:27 -04:00
parent 61e2fe8441
commit ea8e10031b
51 changed files with 10008 additions and 178 deletions

View File

@@ -0,0 +1,356 @@
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 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 });
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 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 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("; ")}`);
}
}
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);
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);
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)),
};
}