606 lines
26 KiB
JavaScript
606 lines
26 KiB
JavaScript
import fs from "node:fs/promises";
|
||
import http from "node:http";
|
||
import path from "node:path";
|
||
import { createHash } from "node:crypto";
|
||
import puppeteer from "puppeteer-core";
|
||
import { PNG } from "pngjs";
|
||
import {
|
||
AlignmentType,
|
||
Document,
|
||
HeadingLevel,
|
||
ImageRun,
|
||
Packer,
|
||
Paragraph,
|
||
Table,
|
||
TableCell,
|
||
TableRow,
|
||
TextRun,
|
||
WidthType,
|
||
} from "docx";
|
||
|
||
const REPO_ROOT = path.resolve("/home/meswork/cnc_wams");
|
||
const QA_ROOT = path.join(REPO_ROOT, "qa/web-rtcp-5axis-site-test");
|
||
const OUTPUT_DIR = path.join(QA_ROOT, "output");
|
||
const SCREENSHOT_DIR = path.join(QA_ROOT, "screenshots", "test-linuxcnc-source-run");
|
||
const TEST_SOURCE_DIR = path.join(REPO_ROOT, "web-rtcp-5axis-sim-plan/working_run/test_linuxcnc_source");
|
||
const TEST_INI_PATH = path.join(TEST_SOURCE_DIR, "xyzac-trt.ini");
|
||
const TEST_GCODE_PATH = path.join(TEST_SOURCE_DIR, "impeller-7bl-xyzac.ngc");
|
||
const VENDORED_GCODE_REL = "configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/impeller-7bl-xyzac.ngc";
|
||
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 iniText = await fs.readFile(TEST_INI_PATH, "utf8");
|
||
const gcodeText = await fs.readFile(TEST_GCODE_PATH, "utf8");
|
||
const sourceEvidence = {
|
||
iniPath: TEST_INI_PATH,
|
||
iniSha256: sha256(iniText),
|
||
gcodePath: TEST_GCODE_PATH,
|
||
gcodeSha256: sha256(gcodeText),
|
||
gcodeLineCount: gcodeText.split(/\r?\n/).length,
|
||
gcodeBytes: Buffer.byteLength(gcodeText),
|
||
};
|
||
|
||
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}`,
|
||
chromePath: CHROME_PATH,
|
||
sourceEvidence,
|
||
screenshots: {},
|
||
steps: [],
|
||
checks: [],
|
||
samples: [],
|
||
consoleErrors,
|
||
};
|
||
|
||
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(800);
|
||
await captureStep("01-initial-ui", "初始界面", "确认浏览器应用、五轴预览区、G-code 区和 DRO 面板已渲染。");
|
||
|
||
await page.evaluate(async ({ ini, gcode, sourceRel }) => {
|
||
await window.webRtcp5AxisSimulation.stageMachineFiles({
|
||
iniText: ini,
|
||
storageMode: "memory",
|
||
sourceTextOverrides: {
|
||
[sourceRel]: gcode,
|
||
},
|
||
});
|
||
}, { ini: iniText, gcode: gcodeText, sourceRel: VENDORED_GCODE_REL });
|
||
await waitForState((state) => state.machineFileStaging?.status === "staged", 20000, "test INI staged");
|
||
await captureStep("02-stage-test-ini", "Stage test_linuxcnc_source INI", "使用 working_run/test_linuxcnc_source/xyzac-trt.ini 重新 staging machine files。");
|
||
|
||
await page.evaluate(({ sourceRel }) => {
|
||
window.webRtcp5AxisSimulation.dispatch({ type: "LOAD_LINUXCNC_GCODE_SOURCE", sourceRel });
|
||
}, { sourceRel: VENDORED_GCODE_REL });
|
||
await waitForState((state) => (
|
||
state.activeProgram?.endsWith("impeller-7bl-xyzac.ngc") &&
|
||
state.programExecutionSourceMode === "linuxcnc-interpreter-wasm" &&
|
||
state.taskHalSession?.programPath?.endsWith("impeller-7bl-xyzac.ngc") &&
|
||
state.loadedSourceBytes === sourceEvidence.gcodeBytes
|
||
), 25000, "impeller program loaded");
|
||
await waitForCanvasReady();
|
||
await wait(1200);
|
||
await captureStep("03-toolpath-preview", "刀具路径预览", "加载 test_linuxcnc_source/impeller-7bl-xyzac.ngc 对应程序,确认 canonical motion 预览路径、TCP 球和刀轴线可见。");
|
||
|
||
await setMachineReady();
|
||
await captureStep("04-ready-before-run", "RUN 前准备", "POWER ON、HOME、AUTO、TCP 模式就绪,RUN gate 所需条件已满足。");
|
||
|
||
await page.evaluate(() => document.querySelector('[data-action="RUN"]').click());
|
||
await waitForState((state) => (
|
||
["running", "complete"].includes(state.runState) &&
|
||
state.programRuntimeFeedback?.sourceMode === "linuxcnc-task-motion-hal-wasm"
|
||
), 15000, "RUN feedback started");
|
||
|
||
const startedAt = Date.now();
|
||
for (const [elapsedMs, title] of [
|
||
[200, "G-code 执行 200ms"],
|
||
[500, "G-code 执行 500ms"],
|
||
[1000, "G-code 执行 1000ms"],
|
||
[2000, "G-code 执行 2000ms"],
|
||
[5000, "G-code 执行 5000ms"],
|
||
[17000, "G-code 执行 17000ms"],
|
||
]) {
|
||
const delay = Math.max(startedAt + elapsedMs - Date.now(), 0);
|
||
if (delay > 0) await wait(delay);
|
||
await waitForCanvasReady();
|
||
await captureRunSample(elapsedMs, `05-run-${String(elapsedMs).padStart(4, "0")}ms`, title);
|
||
}
|
||
|
||
await page.evaluate(() => document.querySelector('[data-action="STOP"]').click());
|
||
await waitForState((state) => state.taskHalStatusLoop?.active === false, 10000, "RUN stopped");
|
||
await captureStep("06-stop-after-run", "STOP 后状态", "停止 RUN,确认 status loop 停止且执行证据已保留。");
|
||
|
||
addChecks();
|
||
report.status = report.checks.every((check) => check.pass) ? "PASS" : "FAIL";
|
||
|
||
const jsonPath = path.join(OUTPUT_DIR, "test-linuxcnc-source-run-report.json");
|
||
await fs.writeFile(jsonPath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
|
||
report.jsonPath = jsonPath;
|
||
|
||
const docxPath = await writeDocxReport(report);
|
||
report.docxPath = docxPath;
|
||
await fs.writeFile(jsonPath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
|
||
console.log(`test_linuxcnc_source_run_status=${report.status}`);
|
||
console.log(`test_linuxcnc_source_run_json=${jsonPath}`);
|
||
console.log(`test_linuxcnc_source_run_docx=${docxPath}`);
|
||
} finally {
|
||
await page.close().catch(() => {});
|
||
await browser.close().catch(() => {});
|
||
await new Promise((resolve) => server.close(resolve));
|
||
}
|
||
|
||
async function setMachineReady() {
|
||
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 power on");
|
||
await page.evaluate(() => document.querySelector('[data-action="mode-manual"]').click());
|
||
await waitForState((state) => state.machine.mode === "manual", 10000, "manual mode");
|
||
await page.evaluate(() => document.querySelector('[data-action="HOME"]').click());
|
||
await waitForState((state) => state.machine.allHomed === true, 10000, "homed");
|
||
await page.evaluate(() => document.querySelector('[data-action="mode-auto"]').click());
|
||
await waitForState((state) => state.machine.mode === "auto", 10000, "auto mode");
|
||
await page.evaluate(() => document.querySelector('[data-action="kins-tcp"]').click());
|
||
await waitForState((state) => state.rtcpState === "on" && state.kinsType === "tcp-xyzac", 10000, "tcp mode");
|
||
}
|
||
|
||
async function captureRunSample(elapsedMs, name, title) {
|
||
const step = await captureStep(name, title, "采集 G-code 当前高亮行、执行轨迹、实时 DRO/axisPose 和 task/HAL feedback。");
|
||
const state = step.state;
|
||
const sample = {
|
||
elapsedMs,
|
||
screenshotPath: step.screenshotPath,
|
||
activeLine: state.activeLine,
|
||
activeUiLine: state.activeUiLine,
|
||
activeLineMatchesUi: state.activeLine === state.activeUiLine,
|
||
runState: state.runState,
|
||
axisPose: state.axisPose,
|
||
dro: state.dro,
|
||
droMatchesAxisPose: axesMatch(state.dro, state.programRuntimeFeedback?.axisPose),
|
||
velocity: state.programRuntimeFeedback?.currentVelocityMmPerMin ?? null,
|
||
expectedVelocity: state.currentTimingSegment?.velocityMmPerMin ?? null,
|
||
feedRate: state.currentTimingSegment?.feedRate ?? null,
|
||
feedMode: state.currentTimingSegment?.feedMode ?? null,
|
||
segmentDurationSeconds: state.currentTimingSegment?.durationSeconds ?? null,
|
||
distanceToGo: state.programRuntimeFeedback?.distanceToGo ?? null,
|
||
feedbackSource: state.programRuntimeFeedback?.sourceMode ?? null,
|
||
taskCycle: state.programRuntimeFeedback?.taskCycle ?? null,
|
||
servoCycle: state.programRuntimeFeedback?.cycle ?? null,
|
||
historyLength: state.programRuntimeFeedbackHistory?.length || 0,
|
||
lineExecution: state.currentLineExecution,
|
||
lineExecutionVisible: state.currentLineExecutionVisible,
|
||
executedPathPoints: Number(step.dataset.threeExecutedPathPoints || 0),
|
||
currentSegmentHighlight: step.dataset.threeCurrentSegmentHighlight,
|
||
};
|
||
report.samples.push(sample);
|
||
return sample;
|
||
}
|
||
|
||
async function captureStep(name, title, description) {
|
||
const screenshotPath = path.join(SCREENSHOT_DIR, `${name}.png`);
|
||
await page.screenshot({ path: screenshotPath, fullPage: true });
|
||
const dataset = await getCanvasDataset();
|
||
const state = await getStateWithUi();
|
||
const pixelStats = await analyzePng(screenshotPath);
|
||
const step = {
|
||
name,
|
||
title,
|
||
description,
|
||
screenshotPath,
|
||
dataset,
|
||
pixelStats,
|
||
state: summarizeState(state),
|
||
};
|
||
report.screenshots[name] = screenshotPath;
|
||
report.steps.push(step);
|
||
return step;
|
||
}
|
||
|
||
function addChecks() {
|
||
const finalState = report.steps.at(-1)?.state || {};
|
||
const previewStep = report.steps.find((step) => step.name === "03-toolpath-preview");
|
||
const runSamples = report.samples;
|
||
const movingVelocities = runSamples
|
||
.map((sample) => Number(sample.velocity || 0))
|
||
.filter((velocity) => velocity > 0);
|
||
const distinctVelocities = new Set(movingVelocities.map((velocity) => Math.round(velocity * 1000) / 1000));
|
||
const sourceState = previewStep?.state || {};
|
||
report.checks.push(
|
||
check("test_linuxcnc_source INI used", sourceState.iniPath?.endsWith("xyzac-trt.ini"), sourceState.iniPath || "-"),
|
||
check("test_linuxcnc_source G-code hash matches loaded source", sourceEvidence.gcodeSha256 === "e90f0b4b6c43809da94a8170ee1029b5afc3e1bbe9bf2ae66298a8baefad013c", sourceEvidence.gcodeSha256),
|
||
check("刀具路径预览点可见", Number(previewStep?.dataset?.threePathPoints || 0) > 100, `pathPoints=${previewStep?.dataset?.threePathPoints}`),
|
||
check("刀具路径使用完整 canonical motion 点", Number(previewStep?.dataset?.threePathPoints || 0) === Number(sourceState.programSummary?.motionEventCount || 0), `threePathPoints=${previewStep?.dataset?.threePathPoints}, motionEventCount=${sourceState.programSummary?.motionEventCount}`),
|
||
check("TCP/刀轴线可见", previewStep?.dataset?.threeTcpMarker === "sphere" && previewStep?.dataset?.threeToolAxisMarker === "line", `tcp=${previewStep?.dataset?.threeTcpMarker}, axis=${previewStep?.dataset?.threeToolAxisMarker}`),
|
||
check("RUN 采样数量", runSamples.length >= 5, `samples=${runSamples.length}`),
|
||
check("每行执行高亮同步", runSamples.every((sample) => sample.activeLineMatchesUi), runSamples.map((sample) => `${sample.activeLine}/${sample.activeUiLine}`).join(", ")),
|
||
check("程序列表显示每行执行过程", runSamples.every((sample) => sample.lineExecution?.line === sample.activeLine && sample.lineExecutionVisible === true), runSamples.map((sample) => `${sample.activeLine}:${sample.lineExecution?.status || "-"}:${sample.lineExecutionVisible}`).join(", ")),
|
||
check("执行轨迹可见", runSamples.every((sample) => sample.executedPathPoints >= 1 && sample.currentSegmentHighlight === "ok"), runSamples.map((sample) => `${sample.executedPathPoints}/${sample.currentSegmentHighlight}`).join(", ")),
|
||
check("实时轴值来自 task/HAL feedback", runSamples.every((sample) => sample.feedbackSource === "linuxcnc-task-motion-hal-wasm" && sample.droMatchesAxisPose), runSamples.map((sample) => `${sample.feedbackSource}/${sample.droMatchesAxisPose}`).join(", ")),
|
||
check("RUN feed 不是固定 3600 mm/min", movingVelocities.length > 0 && movingVelocities.every((velocity) => Math.abs(velocity - 3600) > 0.001), runSamples.map((sample) => `${sample.elapsedMs}ms=${sample.velocity}`).join(", ")),
|
||
check("RUN feed 随实际 G-code F/G93 段变化", distinctVelocities.size >= 3, [...distinctVelocities].join(", ")),
|
||
check("RUN 使用 G93 inverse-time feed", runSamples.some((sample) => sample.feedMode === "inverse-time" && Number(sample.segmentDurationSeconds || 0) > 0), runSamples.map((sample) => `${sample.elapsedMs}ms F${sample.feedRate} ${sample.feedMode} ${sample.segmentDurationSeconds}s`).join(", ")),
|
||
check("task/HAL cycle 推进", runSamples.some((sample) => Number(sample.taskCycle || 0) > 0 && Number(sample.servoCycle || 0) > 0), runSamples.map((sample) => `${sample.taskCycle}/${sample.servoCycle}`).join(", ")),
|
||
check("STOP 后 loop 停止", finalState.taskHalStatusLoop?.active === false, `active=${finalState.taskHalStatusLoop?.active}, stopReason=${finalState.taskHalStatusLoop?.stopReason}`),
|
||
);
|
||
}
|
||
|
||
function summarizeState(state) {
|
||
const currentTimingSegment = currentTimingSegmentForState(state);
|
||
return {
|
||
activeProgram: state.activeProgram,
|
||
programSource: state.programSource,
|
||
programExecutionSourceMode: state.programExecutionSourceMode,
|
||
programSummary: state.programExecution?.summary || null,
|
||
machineProfile: state.machineProfile,
|
||
iniPath: state.iniConfigReadiness?.path || state.linuxCncIniConfig?.path || null,
|
||
selectedGcodeSourceRel: state.machineFileStaging?.selectedGcodeSourceRel || null,
|
||
loadedSourceBytes: state.machineFileStaging?.save?.files?.find((file) => file.sourceRel === state.machineFileStaging?.selectedGcodeSourceRel)?.bytes || null,
|
||
taskHalProgramPath: state.taskHalSession?.programPath || null,
|
||
runState: state.runState,
|
||
activeLine: state.activeLine,
|
||
activeUiLine: state.__activeUiLine,
|
||
machine: state.machine,
|
||
rtcpState: state.rtcpState,
|
||
kinsType: state.kinsType,
|
||
dro: pickAxes(state.dro),
|
||
axisPose: pickAxes(state.axisPose),
|
||
programRuntimeFeedback: state.programRuntimeFeedback,
|
||
currentTimingSegment,
|
||
currentLineExecution: state.programLineExecution?.[state.activeLine] || null,
|
||
currentLineExecutionVisible: Boolean(state.__activeUiLineExecutionText?.includes("F ") && state.__activeUiLineExecutionText?.includes("cycle")),
|
||
feedbackHistoryLength: state.programRuntimeFeedbackHistory?.length || 0,
|
||
taskHalStatusLoop: state.taskHalStatusLoop,
|
||
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,
|
||
activeLine: state.taskHalStatus.ui?.activeLine || null,
|
||
taskCycle: state.taskHalStatus.ui?.taskCycle || null,
|
||
servoCycle: state.taskHalStatus.ui?.servoCycle || null,
|
||
} : null,
|
||
};
|
||
}
|
||
|
||
function currentTimingSegmentForState(state) {
|
||
const activeLine = Number(state.activeLine || 0);
|
||
const segments = state.programExecutionTiming?.segments || [];
|
||
if (!Number.isFinite(activeLine) || !Array.isArray(segments) || segments.length === 0) {
|
||
return null;
|
||
}
|
||
return segments.find((segment) => Number(segment.line) === activeLine)
|
||
|| [...segments].reverse().find((segment) => Number(segment.line) <= activeLine)
|
||
|| null;
|
||
}
|
||
|
||
async function wait(ms) {
|
||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||
}
|
||
|
||
async function waitForState(predicate, timeoutMs, label) {
|
||
const start = Date.now();
|
||
while (Date.now() - start < timeoutMs) {
|
||
const state = await getStateWithUi();
|
||
if (predicate(state)) return state;
|
||
await wait(100);
|
||
}
|
||
throw new Error(`timeout waiting for ${label}`);
|
||
}
|
||
|
||
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 getStateWithUi() {
|
||
return page.evaluate(() => {
|
||
const state = JSON.parse(JSON.stringify(window.webRtcp5AxisSimulation.getState()));
|
||
const activeRow = document.querySelector(".gcode-row.active");
|
||
state.__activeUiLine = Number(activeRow?.dataset.programLine || 0);
|
||
state.__activeUiLineExecutionText = activeRow?.querySelector("[data-line-execution]")?.textContent || "";
|
||
state.loadedSourceBytes = state.machineFileStaging?.save?.files
|
||
?.find((file) => file.sourceRel === state.machineFileStaging?.selectedGcodeSourceRel)
|
||
?.bytes || null;
|
||
return state;
|
||
});
|
||
}
|
||
|
||
async function getCanvasDataset() {
|
||
return page.$eval("[data-five-axis-canvas]", (canvas) => ({ ...canvas.dataset }));
|
||
}
|
||
|
||
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 filePath = targetPath;
|
||
let stat = await fs.stat(filePath).catch(() => null);
|
||
if (stat?.isDirectory()) {
|
||
filePath = path.join(filePath, "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));
|
||
}
|
||
});
|
||
}
|
||
|
||
function contentTypeFor(filePath) {
|
||
const ext = path.extname(filePath).toLowerCase();
|
||
return {
|
||
".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",
|
||
}[ext] || "application/octet-stream";
|
||
}
|
||
|
||
async function analyzePng(filePath) {
|
||
const png = PNG.sync.read(await fs.readFile(filePath));
|
||
let luminanceSum = 0;
|
||
let nonBlack = 0;
|
||
for (let index = 0; index < png.data.length; index += 4) {
|
||
const luminance = png.data[index] * 0.2126 + png.data[index + 1] * 0.7152 + png.data[index + 2] * 0.0722;
|
||
luminanceSum += luminance;
|
||
if (luminance > 8) nonBlack += 1;
|
||
}
|
||
const total = png.width * png.height;
|
||
return {
|
||
width: png.width,
|
||
height: png.height,
|
||
averageLuminance: Number((luminanceSum / total).toFixed(2)),
|
||
nonBlackRatio: Number((nonBlack / total).toFixed(4)),
|
||
};
|
||
}
|
||
|
||
async function writeDocxReport(data) {
|
||
const generated = new Date(data.generatedAt);
|
||
const ymd = generated.toISOString().slice(0, 10);
|
||
const docxPath = path.join(OUTPUT_DIR, `test-linuxcnc-source-run-report-${ymd}.docx`);
|
||
const children = [
|
||
new Paragraph({
|
||
text: "test_linuxcnc_source RUN 功能测试报告",
|
||
heading: HeadingLevel.TITLE,
|
||
alignment: AlignmentType.CENTER,
|
||
}),
|
||
centered(`测试对象:${data.targetUrl}`),
|
||
centered(`生成时间:${formatDateTime(generated)}`),
|
||
blank(),
|
||
heading("1. 测试结论"),
|
||
para(`本次使用 working_run/test_linuxcnc_source 中的 INI 与 G-code 文件执行 RUN 测试,结果:${data.status}。`),
|
||
checksTable(data.checks),
|
||
heading("2. 测试源文件"),
|
||
kvTable([
|
||
["INI", data.sourceEvidence.iniPath],
|
||
["INI SHA-256", data.sourceEvidence.iniSha256],
|
||
["G-code", data.sourceEvidence.gcodePath],
|
||
["G-code SHA-256", data.sourceEvidence.gcodeSha256],
|
||
["G-code 行数/字节", `${data.sourceEvidence.gcodeLineCount} / ${data.sourceEvidence.gcodeBytes}`],
|
||
]),
|
||
heading("3. 测试过程"),
|
||
];
|
||
|
||
for (const step of data.steps) {
|
||
children.push(...(await stepBlock(step)));
|
||
}
|
||
|
||
children.push(
|
||
heading("4. RUN 采样明细"),
|
||
sampleTable(data.samples),
|
||
heading("5. 原始证据"),
|
||
kvTable([
|
||
["JSON", data.jsonPath || path.join(OUTPUT_DIR, "test-linuxcnc-source-run-report.json")],
|
||
["截图目录", SCREENSHOT_DIR],
|
||
["Word", docxPath],
|
||
["Console error", String(data.consoleErrors.length)],
|
||
["Chrome", data.chromePath],
|
||
]),
|
||
);
|
||
|
||
if (data.consoleErrors.length > 0) {
|
||
children.push(heading("6. Console Error"), ...data.consoleErrors.map((entry) => para(entry)));
|
||
}
|
||
|
||
const doc = new Document({ sections: [{ properties: {}, children }] });
|
||
await fs.writeFile(docxPath, await Packer.toBuffer(doc));
|
||
return docxPath;
|
||
}
|
||
|
||
async function stepBlock(step) {
|
||
const image = await fs.readFile(step.screenshotPath);
|
||
return [
|
||
new Paragraph({
|
||
text: `${step.name} - ${step.title}`,
|
||
heading: HeadingLevel.HEADING_2,
|
||
spacing: { before: 180, after: 120 },
|
||
}),
|
||
para(step.description),
|
||
kvTable([
|
||
["activeProgram", step.state.activeProgram],
|
||
["runState / activeLine", `${step.state.runState} / ${step.state.activeLine}`],
|
||
["program source", `${step.state.programSource} / ${step.state.programExecutionSourceMode}`],
|
||
["selected G-code", step.state.selectedGcodeSourceRel],
|
||
["task/HAL program", step.state.taskHalProgramPath],
|
||
["DRO XYZAC", axesText(step.state.dro)],
|
||
["axisPose XYZAC", axesText(step.state.axisPose)],
|
||
["path/executed", `${step.dataset.threePathPoints || 0} / ${step.dataset.threeExecutedPathPoints || 0}`],
|
||
["RTCP / kins", `${step.state.rtcpState} / ${step.state.kinsType}`],
|
||
["pixel", `luma=${step.pixelStats.averageLuminance}, nonBlack=${step.pixelStats.nonBlackRatio}`],
|
||
]),
|
||
new Paragraph({
|
||
alignment: AlignmentType.CENTER,
|
||
children: [new ImageRun({
|
||
data: image,
|
||
type: "png",
|
||
transformation: { width: 520, height: 390 },
|
||
})],
|
||
}),
|
||
para(`截图文件:${step.screenshotPath}`),
|
||
];
|
||
}
|
||
|
||
function sampleTable(samples) {
|
||
return new Table({
|
||
width: { size: 100, type: WidthType.PERCENTAGE },
|
||
rows: [
|
||
new TableRow({
|
||
children: ["时间", "行号", "状态", "DRO XYZAC", "速度/feed", "轨迹点", "反馈"].map((text) => cell(text, true)),
|
||
}),
|
||
...samples.map((sample) => new TableRow({
|
||
children: [
|
||
cell(`${sample.elapsedMs}ms`),
|
||
cell(`${sample.activeLine} / UI ${sample.activeUiLine}`),
|
||
cell(sample.runState),
|
||
cell(axesText(sample.dro)),
|
||
cell(`${sample.velocity ?? "-"} mm/min\nF${sample.feedRate ?? "-"} ${sample.feedMode ?? "-"}`),
|
||
cell(String(sample.executedPathPoints)),
|
||
cell(`${sample.feedbackSource}\ncycle=${sample.taskCycle}/${sample.servoCycle}`),
|
||
],
|
||
})),
|
||
],
|
||
});
|
||
}
|
||
|
||
function checksTable(checks) {
|
||
return new Table({
|
||
width: { size: 100, type: WidthType.PERCENTAGE },
|
||
rows: [
|
||
new TableRow({ children: [cell("检查项", true), cell("结果", true), cell("证据", true)] }),
|
||
...checks.map((item) => new TableRow({
|
||
children: [cell(item.name), cell(item.pass ? "PASS" : "FAIL"), cell(item.detail)],
|
||
})),
|
||
],
|
||
});
|
||
}
|
||
|
||
function kvTable(rows) {
|
||
return new Table({
|
||
width: { size: 100, type: WidthType.PERCENTAGE },
|
||
rows: [
|
||
new TableRow({ children: [cell("项目", true), cell("内容", true)] }),
|
||
...rows.map(([key, value]) => new TableRow({ children: [cell(key), cell(value)] })),
|
||
],
|
||
});
|
||
}
|
||
|
||
function centered(text) {
|
||
return new Paragraph({ alignment: AlignmentType.CENTER, children: [new TextRun(String(text))] });
|
||
}
|
||
|
||
function heading(text) {
|
||
return new Paragraph({ text, heading: HeadingLevel.HEADING_1, spacing: { before: 240, after: 120 } });
|
||
}
|
||
|
||
function blank() {
|
||
return new Paragraph({ text: "" });
|
||
}
|
||
|
||
function para(text) {
|
||
return new Paragraph({ children: [new TextRun(String(text ?? "-"))], spacing: { after: 100 } });
|
||
}
|
||
|
||
function cell(text, bold = false) {
|
||
return new TableCell({
|
||
width: { size: 25, type: WidthType.PERCENTAGE },
|
||
children: String(text ?? "-").split("\n").map((line) => new Paragraph({
|
||
children: [new TextRun({ text: line, bold })],
|
||
})),
|
||
});
|
||
}
|
||
|
||
function check(name, pass, detail) {
|
||
return { name, pass: Boolean(pass), detail: String(detail ?? "-") };
|
||
}
|
||
|
||
function pickAxes(value = {}) {
|
||
return {
|
||
x: Number(value.x || 0),
|
||
y: Number(value.y || 0),
|
||
z: Number(value.z || 0),
|
||
a: Number(value.a || 0),
|
||
b: Number(value.b || 0),
|
||
c: Number(value.c || 0),
|
||
};
|
||
}
|
||
|
||
function axesMatch(left = {}, right = {}) {
|
||
return ["x", "y", "z", "a", "b", "c"].every((axis) => Math.abs(Number(left?.[axis] || 0) - Number(right?.[axis] || 0)) < 1e-9);
|
||
}
|
||
|
||
function axesText(value = {}) {
|
||
const axes = pickAxes(value);
|
||
return `X=${fmt(axes.x)} Y=${fmt(axes.y)} Z=${fmt(axes.z)} A=${fmt(axes.a)} B=${fmt(axes.b)} C=${fmt(axes.c)}`;
|
||
}
|
||
|
||
function fmt(value) {
|
||
return Number(value || 0).toFixed(3);
|
||
}
|
||
|
||
function sha256(text) {
|
||
return createHash("sha256").update(text).digest("hex");
|
||
}
|
||
|
||
function formatDateTime(date) {
|
||
return date.toISOString().replace("T", " ").replace(/\.\d+Z$/, " UTC");
|
||
}
|