feat: sync latest run execution updates

This commit is contained in:
2026-06-22 21:47:16 -04:00
parent 0b1aad39e1
commit 8d3177cb73
92 changed files with 22837 additions and 246 deletions

View File

@@ -8,11 +8,15 @@ 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",
@@ -144,6 +148,22 @@ async function captureCase(name) {
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())));
@@ -211,6 +231,44 @@ function previewChecks(dataset, {
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 };
}
@@ -222,6 +280,111 @@ function assertChecks(caseName, checks) {
}
}
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 });
@@ -303,6 +466,92 @@ try {
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());
@@ -323,6 +572,88 @@ try {
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"),