import fs from "node:fs/promises"; import path from "node:path"; import { AlignmentType, Document, HeadingLevel, ImageRun, Packer, Paragraph, Table, TableCell, TableRow, TextRun, WidthType, } from "docx"; const ROOT = path.resolve("/home/meswork/cnc_wams/qa/web-rtcp-5axis-site-test"); const OUTPUT_DIR = path.join(ROOT, "output"); const reportJsonPath = path.join(OUTPUT_DIR, "toolpath-preview-cases.json"); const report = JSON.parse(await fs.readFile(reportJsonPath, "utf8")); const generated = new Date(report.generatedAt || Date.now()); const ymd = generated.toISOString().slice(0, 10); const docxPath = path.join(OUTPUT_DIR, `web-rtcp-5axis-toolpath-preview-report-${ymd}.docx`); const counts = report.cases.reduce((acc, testCase) => { acc[testCase.status] = (acc[testCase.status] || 0) + 1; return acc; }, {}); const children = [ new Paragraph({ text: "刀具预览与 G-code 执行刀具轨迹专项测试报告", heading: HeadingLevel.TITLE, alignment: AlignmentType.CENTER, }), centered(`测试对象:${report.targetUrl}`), centered(`项目路径:${report.projectPath || "/home/meswork/cnc_wams/web-rtcp-5axis-sim-plan/app/index.html"}`), centered(`生成时间:${formatDateTime(generated)}`), blank(), heading("1. 测试结论"), para(`本次专项覆盖 6 个刀具预览/刀具轨迹场景:PASS ${counts.PASS || 0} 项,FAIL ${counts.FAIL || 0} 项。`), para("已验证首屏预览、矩形 G-code、圆弧 G-code、清空预览、LinuxCNC vendored impeller 长路径、RTCP 运行态执行轨迹。截图中机床参考模型、TCP 球、刀轴线和刀路均有可见性证据。"), para("发现 1 项关键问题:vendored impeller 程序在预览态为 RTCP on,但进入 G-code 运行态后 canvas dataset 中 RTCP 状态变为 off。"), summaryTable(), heading("2. 测试范围与判定口径"), kvTable([ ["预览对象", "五轴机床参考模型、工作台、XYZ 坐标轴、旋转轴、刀具/TCP 球、刀轴线"], ["轨迹对象", "G-code canonical motion 预览路径、rapid/feed/arc 分层、已执行轨迹、当前段高亮"], ["运行反馈", "LinuxCNC interpreter WASM、TP/runtime samples、task/HAL runtime feedback"], ["可见性口径", "canvas dataset ready + WebGL renderer + scene objects + pixel luminance/non-black ratio + 截图"], ["语义边界", "可视化层只消费 runtime/canonical motion/task-HAL feedback,不生成 G-code/CNC 语义"], ]), heading("3. 场景结果总表"), caseSummaryTable(report.cases), heading("4. 失败/风险项"), issueTable(report.cases.filter((testCase) => testCase.status !== "PASS")), heading("5. 场景明细与截图"), ]; for (const testCase of report.cases) { children.push(...(await caseBlock(testCase))); } children.push( heading("6. 原始证据"), kvTable([ ["原始 JSON", reportJsonPath], ["截图数量", String(Object.keys(report.screenshots || {}).length)], ["Console error", String((report.consoleErrors || []).length)], ["Chrome", report.chromePath || "-"], ["Word 报告", docxPath], ["项目路径", report.projectPath || "-"], ]), ); if ((report.consoleErrors || []).length > 0) { children.push( heading("7. Console 记录"), ...report.consoleErrors.map((entry) => para(entry)), ); } const doc = new Document({ sections: [{ properties: {}, children: children.flat() }], }); await fs.writeFile(docxPath, await Packer.toBuffer(doc)); console.log(`toolpath_docx_report=${docxPath}`); function heading(text) { return new Paragraph({ text, heading: HeadingLevel.HEADING_1, spacing: { before: 240, after: 120 }, }); } function centered(text) { return new Paragraph({ alignment: AlignmentType.CENTER, children: [new TextRun(String(text))], }); } function blank() { return new Paragraph({ text: "" }); } function para(text) { return new Paragraph({ children: [new TextRun(String(text))], spacing: { after: 100 }, }); } function summaryTable() { return new Table({ width: { size: 100, type: WidthType.PERCENTAGE }, rows: [ new TableRow({ children: [ cell("场景总数", true), cell(String(report.cases.length)), cell("PASS", true), cell(String(counts.PASS || 0)), cell("FAIL", true), cell(String(counts.FAIL || 0)), ], }), ], }); } 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 caseSummaryTable(cases) { return new Table({ width: { size: 100, type: WidthType.PERCENTAGE }, rows: [ new TableRow({ children: [cell("场景", true), cell("结果", true), cell("路径点", true), cell("执行点", true), cell("RTCP", true), cell("可见性", true)], }), ...cases.map((testCase) => new TableRow({ children: [ cell(`${testCase.name}\n${testCase.summary}`), cell(testCase.status), cell(testCase.dataset.threePathPoints), cell(testCase.dataset.threeExecutedPathPoints), cell(testCase.dataset.threeRtcpState), cell(`luma=${testCase.pixelStats?.averageLuminance ?? "-"}\nnonBlack=${testCase.pixelStats?.nonBlackRatio ?? "-"}`), ], })), ], }); } function issueTable(failedCases) { if (failedCases.length === 0) return para("未发现失败场景。"); return new Table({ width: { size: 100, type: WidthType.PERCENTAGE }, rows: [ new TableRow({ children: [cell("场景", true), cell("失败检查", true), cell("影响", true), cell("建议", true)] }), ...failedCases.map((testCase) => new TableRow({ children: [ cell(testCase.name), cell(testCase.checks.filter((item) => !item.pass).map((item) => `${item.name}: ${item.detail}`).join("\n")), cell("G-code 运行态下 RTCP/TCP 轨迹状态与预览态不一致,可能误导操作者判断刀具姿态和 TCP 执行轨迹。"), cell("检查 RUN/task-HAL status 合并逻辑,避免运行反馈将 RTCP/kinesType 从已加载程序的 TCP 状态回退到 identity/off。"), ], })), ], }); } async function caseBlock(testCase) { const screenshotPath = report.screenshots[testCase.name]; const blocks = [ new Paragraph({ text: `${testCase.name} - ${testCase.status}`, heading: HeadingLevel.HEADING_2, spacing: { before: 180, after: 120 }, }), para(testCase.summary), kvTable([ ["activeProgram", testCase.state.activeProgram], ["programSource", testCase.state.programSource], ["programExecutionSourceMode", testCase.state.programExecutionSourceMode], ["runState", testCase.state.runState], ["rtcpState / kinsType", `${testCase.state.rtcpState} / ${testCase.state.kinsType}`], ["motion/sample index", `${testCase.state.programExecutionMotionIndex} / ${testCase.state.programExecutionSampleIndex}`], ["runtime feedback", testCase.state.programRuntimeFeedbackSource || "-"], ["path/executed/rapid/feed/arc", `${testCase.dataset.threePathPoints}/${testCase.dataset.threeExecutedPathPoints}/${testCase.dataset.threeRapidPathPoints}/${testCase.dataset.threeFeedPathPoints}/${testCase.dataset.threeArcPathPoints}`], ["renderer/model", `${testCase.dataset.threeRenderer} / ${testCase.dataset.threeMachineReferenceModel}`], ["pixel stats", `averageLuminance=${testCase.pixelStats?.averageLuminance ?? "-"}, nonBlackRatio=${testCase.pixelStats?.nonBlackRatio ?? "-"}`], ]), new Paragraph({ text: "检查项", heading: HeadingLevel.HEADING_3, spacing: { before: 120, after: 80 }, }), checksTable(testCase.checks), ]; if (screenshotPath) { const image = await fs.readFile(screenshotPath); blocks.push( new Paragraph({ alignment: AlignmentType.CENTER, children: [ new ImageRun({ data: image, type: "png", transformation: { width: 520, height: 390 }, }), ], }), para(`截图文件:${screenshotPath}`), ); } return blocks; } 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 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 formatDateTime(date) { return date.toISOString().replace("T", " ").replace(/\.\d+Z$/, " UTC"); }