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"); }