Files
cnc_wams/qa/web-rtcp-5axis-site-test/capture-test-linuxcnc-source-run.mjs

606 lines
26 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import fs from "node:fs/promises";
import http from "node:http";
import path from "node:path";
import { 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");
}