import { execFileSync } from "node:child_process"; import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { dirname, join, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; const root = resolve(fileURLToPath(new URL("../../..", import.meta.url))); const webRoot = join(root, "kdl-wasm", "web"); const resultRoot = join(webRoot, "test-results", "abb120-spec"); const specRoot = join(webRoot, "tests", "fixtures", "abb120", "spec-programs"); const virtualControllerEvidencePath = join(webRoot, "test-results", "virtual-controller", "evidence.json"); const outputPath = join(root, "work", "doc", "通用机器人离线编程系统测试文档.docx"); const stagingRoot = join(webRoot, "test-results", "docx-build"); const jobId = process.argv[2] ?? latestJobId(); const jobDir = join(resultRoot, jobId); const job = readJson(join(jobDir, "job.json")); const report = readJson(join(jobDir, "report.json")); const manifest = readJson(join(jobDir, "manifest.json")); const uiEvidence = readJson(virtualControllerEvidencePath); const generatedAt = formatShanghaiTime(new Date()); const descriptionByProgram = { W2_00_MinimalModule: "最小模块、language/module/proc main、常量 speed/zone、joint_target、MoveJ 基础执行。", W2_10_DataTargetToolFrame: "数据类型、tool、frame、pose、poseq、joint_target、pose_target、工具和工件坐标切换。", W2_20_ExpressionMath: "算术、比较、逻辑、括号、单位后缀、pi/e、三角函数和编译期求值。", W2_30_MotionAllTypes: "MoveJ、MoveL、MoveC、set_tool、set_frame、set_speed、set_zone 和 inline 参数优先级。", W2_40_PathEvents: "path defaults/source、point、event before/after/at distance、run_path。", W2_50_OperationProcess: "operation kind/process/start_action/end_action、run_operation。", W2_60_IOWaitPulse: "io.di/do/ai/ao/gi/go/ri/ro、wait、timeout、on_timeout、pulse 和边沿条件。", W2_70_ControlFlow: "if/elseif/else、while、for、switch、break、continue 流程控制。", W2_80_ProcFuncCall: "proc、in/out/inout、call、return、func 返回值和作用域。", W2_90_ExceptionAlarm: "alarm、raise、try/catch/finally、trap/task P1 边界诊断。", W2_99_FullSpecExample: "完整示例,覆盖 tool/frame、path、operation、IO event、run_operation 和回 home。", W2_E10_ExpressionDiagnostics: "表达式负向测试:未知符号、未知函数、参数数量、数学域、除零、单位不匹配。", W2_E20_SemanticDiagnostics: "语义负向测试:重复符号、out/inout 非左值、缺失调用目标、函数副作用、缺失目标。", W2_E30_MotionDiagnostics: "运动负向测试:关节限位、不可达 pose、空 path 等 KDL/Path 诊断。", W2_E40_ControlFlowDiagnostics: "流程控制负向测试:非法 break/continue、jump、重复 case、非常量 case、缺失 label。", W2_E50_RuntimeTimeout: "运行时超时负向测试:wait timeout、on_timeout、报警 source map。", W2_P10_BrandHints: "品牌提示后处理测试:post_hint、@brand.* 消费或报告。", W2_P20_CrossBrandMotion: "跨品牌运动后处理:ABB/FANUC/KUKA motion、speed、zone、IO、wait 映射。", W2_P30_Roundtrip: "后处理和导入回读测试:post 后 ABB import roundtrip,记录差异和警告。" }; const commandResults = [ { command: "npm run typecheck", scope: "TypeScript 静态类型检查", process: "执行 tsc -p kdl-wasm/web/tsconfig.json --noEmit,检查 Web、GRL、runtime、post 和 suite 代码类型。", result: "通过,未输出 TypeScript 错误。" }, { command: "npm test", scope: "全量 Vitest 自动化测试", process: "执行 vitest run --config kdl-wasm/web/vitest.config.ts,覆盖 KDL、GRL、runtime、controller、post/import、workspace、report、docs 和 ABB120 integration。", result: "通过,41 个 test files、155 个 tests 全部 passed。" }, { command: "npm run suite:abb120-spec", scope: "ABB120 GRL 规范程序套件", process: "读取 manifest.json 和 19 个 GRL 程序,逐项执行 parse、semantic compile、runtime/static/post 检查,并输出 job/report/program artifacts。", result: `通过,生成 ${job.job_id};programs=${report.summary.programs},pass=${report.summary.pass},expected diagnostic=${report.summary.diagnostic},fail=${report.summary.fail},missingSections=${report.coverage.missingSections.length}。` }, { command: "npm run verify:virtual-controller", scope: "虚拟控制器 UI 和截图验证", process: "使用 Chrome DevTools Protocol 打开 virtual-controller.html,分别验证 desktop/mobile 视口、点击 load/run/DI1、检查主要面板、状态、wait 和横向溢出,并截图。", result: `通过,desktop=${uiEvidence.desktop.screenshotBytes} bytes,mobile=${uiEvidence.mobile.screenshotBytes} bytes;state=${uiEvidence.desktop.state}/${uiEvidence.mobile.state},wait=${uiEvidence.desktop.wait}/${uiEvidence.mobile.wait}。` } ]; const programs = job.programs.map((program) => { const sourcePath = join(specRoot, program.file); return { ...program, manifestEntry: manifest.programs.find((entry) => entry.program_id === program.program_id), sourcePath, source: readFileSync(sourcePath, "utf8") }; }); buildDocx(); console.log(JSON.stringify({ outputPath, jobId, programs: programs.length }, null, 2)); function buildDocx() { assertSafeStaging(stagingRoot); rmSync(stagingRoot, { recursive: true, force: true }); mkdirSync(join(stagingRoot, "_rels"), { recursive: true }); mkdirSync(join(stagingRoot, "docProps"), { recursive: true }); mkdirSync(join(stagingRoot, "word", "_rels"), { recursive: true }); mkdirSync(join(stagingRoot, "word", "media"), { recursive: true }); const images = copyImages(); writeFileSync(join(stagingRoot, "[Content_Types].xml"), contentTypesXml(), "utf8"); writeFileSync(join(stagingRoot, "_rels", ".rels"), packageRelsXml(), "utf8"); writeFileSync(join(stagingRoot, "docProps", "core.xml"), corePropsXml(), "utf8"); writeFileSync(join(stagingRoot, "docProps", "app.xml"), appPropsXml(), "utf8"); writeFileSync(join(stagingRoot, "word", "styles.xml"), stylesXml(), "utf8"); writeFileSync(join(stagingRoot, "word", "_rels", "document.xml.rels"), documentRelsXml(images), "utf8"); writeFileSync(join(stagingRoot, "word", "document.xml"), documentXml(images), "utf8"); if (existsSync(outputPath)) { rmSync(outputPath, { force: true }); } zipDirectory(stagingRoot, outputPath); } function copyImages() { const images = [ { id: "rIdImage1", fileName: "virtual-controller-desktop.png", source: uiEvidence.desktop.screenshot, title: "虚拟控制器桌面端截图", cx: 5943600, cy: 3962400 }, { id: "rIdImage2", fileName: "virtual-controller-mobile.png", source: uiEvidence.mobile.screenshot, title: "虚拟控制器移动端截图", cx: 2743200, cy: 5943600 } ]; for (const image of images) { copyFileSync(image.source, join(stagingRoot, "word", "media", image.fileName)); } return images; } function documentXml(images) { const body = []; body.push(heading("通用机器人离线编程系统测试文档", 1)); body.push(p(`生成时间:${generatedAt}`)); body.push(p(`测试对象:通用机器人离线编程系统 / ABB IRB120 虚拟控制器 / GRL 规范程序套件`)); body.push(p(`工作区:${root}`)); body.push(p(`最新测试 job:${job.job_id}`)); body.push(heading("一、测试结论", 1)); body.push(table([ ["项目", "结果"], ["测试结论", "通过。本轮未发现非预期失败;5 个 diagnostic 程序为预期负向测试。"], ["规范覆盖", `覆盖章节 ${report.coverage.coveredSections.join("、")};缺失章节:${report.coverage.missingSections.length === 0 ? "无" : report.coverage.missingSections.join("、")}`], ["程序统计", `共 ${report.summary.programs} 个 GRL 规范程序;pass=${report.summary.pass},expected diagnostic=${report.summary.diagnostic},fail=${report.summary.fail}`], ["测试层级", `Runtime=${report.coverage.runtimePrograms},Static=${report.coverage.staticPrograms},Post=${report.coverage.postPrograms}`], ["主要证据", `${relative(jobDir)};${relative(dirname(uiEvidence.desktop.screenshot))}`] ])); body.push(heading("二、测试范围", 1)); body.push(p("本次测试覆盖离线编程系统的语法解析、语义编译、KDL 运动规划接口、虚拟控制器状态机、IO/wait/pulse、路径和工艺操作、异常报警、三品牌后处理、导入回读、工作台 UI 响应和截图证据。")); body.push(table([ ["范围", "覆盖内容"], ["GRL 语言", "词法、顶层结构、类型系统、表达式、目标点、速度、zone、motion、path、operation、IO、流程控制、proc/func、异常和 P1 边界。"], ["运动与 KDL", "ABB IRB120 fixture、MoveJ/MoveL/MoveC、path planning、joint limit、unreachable target、trajectory summary。"], ["虚拟控制器", "loadProgram、start、stepInto、hold、resume、stepMotion、stop、resetFault、runtime trace、motion queue、IO image。"], ["后处理与导入", "ABB/FANUC/KUKA 输出文件、post report、ABB import roundtrip。"], ["用户界面", "virtual-controller.html 桌面端和移动端渲染、交互、主要面板可见性和截图。"] ])); body.push(heading("三、测试命令与执行过程", 1)); body.push(table([ ["命令", "测试内容", "执行过程", "执行结果"], ...commandResults.map((item) => [item.command, item.scope, item.process, item.result]) ])); body.push(heading("四、截图证据", 1)); body.push(p(`截图验证应用:${uiEvidence.app}`)); body.push(p(`Chrome:${uiEvidence.chrome}`)); body.push(heading("4.1 桌面端截图", 2)); body.push(p(`视口:${uiEvidence.desktop.viewport.width}x${uiEvidence.desktop.viewport.height};state=${uiEvidence.desktop.state};wait=${uiEvidence.desktop.wait};trace=${uiEvidence.desktop.trace};截图:${relative(uiEvidence.desktop.screenshot)}`)); body.push(imageParagraph(images[0], 1)); body.push(heading("4.2 移动端截图", 2)); body.push(p(`视口:${uiEvidence.mobile.viewport.width}x${uiEvidence.mobile.viewport.height},deviceScaleFactor=${uiEvidence.mobile.viewport.deviceScaleFactor};state=${uiEvidence.mobile.state};wait=${uiEvidence.mobile.wait};trace=${uiEvidence.mobile.trace};截图:${relative(uiEvidence.mobile.screenshot)}`)); body.push(imageParagraph(images[1], 2)); body.push(heading("五、规范覆盖矩阵", 1)); body.push(table([ ["规范章节", "覆盖层级", "程序"], ...manifest.coverage_matrix.map((item) => [ String(item.spec_section), item.coverage_level, item.program_ids.join("、") ]) ])); body.push(heading("六、全部测试程序明细与源码", 1)); for (const [index, program] of programs.entries()) { appendProgramSection(body, program, index + 1); } body.push(heading("七、主要产物索引", 1)); body.push(table([ ["产物", "路径"], ["job.json", relative(join(jobDir, "job.json"))], ["report.json", relative(join(jobDir, "report.json"))], ["report.html", relative(join(jobDir, "report.html"))], ["manifest.json", relative(join(jobDir, "manifest.json"))], ["桌面端截图", relative(uiEvidence.desktop.screenshot)], ["移动端截图", relative(uiEvidence.mobile.screenshot)], ["UI evidence.json", relative(virtualControllerEvidencePath)] ])); return ` ${body.join("\n")} `; } function appendProgramSection(body, program, index) { const expectedDiagnostics = program.manifestEntry?.expected_diagnostics ?? []; const actualCodes = [...new Set(program.diagnostics.map((diagnostic) => diagnostic.code))]; body.push(heading(`6.${index} ${program.program_id}`, 2)); body.push(table([ ["字段", "内容"], ["测试内容", descriptionByProgram[program.program_id] ?? "按 manifest 执行规范测试。"], ["源码文件", relative(program.sourcePath)], ["覆盖层级", program.coverage_level], ["覆盖章节", program.spec_sections.join("、")], ["预期状态", program.expected_status], ["实际状态", program.status], ["预期诊断", expectedDiagnostics.length === 0 ? "无" : expectedDiagnostics.join("、")], ["实际诊断代码", actualCodes.length === 0 ? "无" : actualCodes.join("、")], ["输入哈希", program.input_hash], ["源码行数", String(program.source.split(/\r?\n/).length)] ])); body.push(heading("测试过程", 3)); for (const step of programSteps(program)) { body.push(p(step)); } body.push(heading("执行结果摘要", 3)); body.push(table(programResultRows(program))); if (program.controller?.commands?.length) { body.push(heading("虚拟控制器命令轨迹", 3)); body.push(table([ ["命令", "前状态", "后状态", "结果", "诊断"], ...program.controller.commands.map((command) => [ command.command, command.before ?? "", command.after ?? "", command.result ?? "", command.diagnostics.length === 0 ? "无" : command.diagnostics.map((diagnostic) => diagnostic.code).join("、") ]) ])); } body.push(heading("证据文件", 3)); body.push(table([ ["类型", "路径"], ...Object.entries(program.artifacts).map(([key, value]) => [key, relative(join(resultRoot, value))]) ])); body.push(heading("程序源码", 3)); body.push(codeBlock(program.source)); } function programSteps(program) { const steps = [ `1. 读取源码 ${relative(program.sourcePath)},加载 program_id、spec_sections、coverage_level、expected_status 和 expected_diagnostics。`, "2. 执行 parseGrl,完成词法、语法、模块结构和 source map 解析。", "3. 执行 compileSemanticProgram,生成语义 IR、符号表、procedure/path/operation、KDL motion/path request 和诊断列表。" ]; if (program.coverage_level === "Runtime") { steps.push(`4. 加载 IR 到 VirtualController,按 ${manifest.runtime_commands.join(" -> ")} 执行控制器命令,采集 controller snapshot、motion queue、runtime trace、IO image 和 trajectory summary。`); steps.push("5. 对比实际状态与 manifest 预期状态;对比实际诊断代码与 expected_diagnostics;写入 compile/controller/motion-queue/trace/io/trajectory/post/roundtrip 证据。"); } else if (program.coverage_level === "Static") { steps.push("4. 执行 static preflight diagnostics,覆盖表达式、语义、运动、path、流程控制等负向检查;不要求进入 happy-path 运行。"); steps.push("5. 对比实际诊断代码与 expected_diagnostics,确认 diagnostic 状态为预期负向结果,并写入 compile 及各类空/摘要证据文件。"); } else { steps.push("4. 执行 postProcessAllBrands,生成 ABB/FANUC/KUKA 输出文件名和 post report。"); steps.push("5. 执行 ABB import roundtrip 摘要检查,记录 pass/warn/fail 与导入诊断,并写入 post-report/roundtrip 证据。"); } return steps; } function programResultRows(program) { const rows = [ ["项目", "结果"], ["解析", program.compile.parsed ? "parsed=true" : "parsed=false"], ["模块名", program.compile.moduleName ?? ""], ["sourceMapEntries", String(program.compile.sourceMapEntries)], ["symbol/procedure/path/operation", `${program.compile.symbolCount}/${program.compile.procedureCount}/${program.compile.pathCount}/${program.compile.operationCount}`], ["KDL motion/path requests", `${program.compile.kdlMotionRequests}/${program.compile.kdlPathRequests}`], ["状态", program.status] ]; if (program.controller) { rows.push(["controller final state", program.controller.snapshot.state.state]); rows.push(["runtime trace events", String(program.trace?.length ?? 0)]); rows.push(["motion queue items", String(program.motionQueue?.items?.length ?? 0)]); rows.push(["IO image count", String(Object.keys(program.io?.image ?? {}).length)]); } if (program.trajectory) { rows.push(["trajectory sample/duration", `${program.trajectory.sampleCount}/${program.trajectory.duration}`]); } if (program.post) { rows.push(["post filenames", program.post.filenames.join("、")]); rows.push(["post report codes", program.post.report.length === 0 ? "无" : [...new Set(program.post.report.map((issue) => issue.code))].join("、")]); } if (program.roundtrip) { rows.push(["roundtrip", `${program.roundtrip.status}; ${program.roundtrip.diagnostics.map((diagnostic) => diagnostic.code).join("、") || "无诊断"}`]); } rows.push(["诊断数量", String(program.diagnostics.length)]); return rows; } function heading(text, level) { return paragraph(text, `Heading${Math.min(level, 3)}`); } function p(text) { return paragraph(text, "BodyText"); } function paragraph(text, style) { return `${run(text)}`; } function run(text) { return `${escapeXml(String(text))}`; } function codeBlock(text) { const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); const lines = normalized.endsWith("\n") ? normalized.slice(0, -1).split("\n") : normalized.split("\n"); return lines.map((line) => `${escapeXml(line === "" ? " " : line)}` ).join("\n"); } function table(rows) { const width = Math.floor(9000 / Math.max(1, rows[0]?.length ?? 1)); return ` ${rows.map((row, rowIndex) => `${row.map((cell) => tableCell(cell, width, rowIndex === 0)).join("")}`).join("\n")} `; } function tableCell(text, width, header) { return ` ${header ? "" : ""}${run(text)} `; } function imageParagraph(image, docPrId) { return ` `; } function contentTypesXml() { return ` `; } function packageRelsXml() { return ` `; } function documentRelsXml(images) { return ` ${images.map((image) => ``).join("\n")} `; } function corePropsXml() { const iso = new Date().toISOString(); return ` 通用机器人离线编程系统测试文档 Codex Codex ${iso} ${iso} `; } function appPropsXml() { return ` Codex 0 false KDL Work false false false 16.0000 `; } function stylesXml() { return ` `; } function latestJobId() { const jobs = readdirSync(resultRoot, { withFileTypes: true }) .filter((entry) => entry.isDirectory() && existsSync(join(resultRoot, entry.name, "job.json"))) .map((entry) => ({ name: entry.name, mtimeMs: statSync(join(resultRoot, entry.name, "job.json")).mtimeMs })) .sort((a, b) => b.mtimeMs - a.mtimeMs); if (jobs.length === 0) { throw new Error(`No abb120 spec job found in ${resultRoot}`); } return jobs[0].name; } function readJson(path) { return JSON.parse(readFileSync(path, "utf8")); } function relative(path) { return path.replace(`${root}${sep}`, "").replaceAll("\\", "/"); } function escapeXml(value) { return String(value) .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll("\"", """) .replaceAll("'", "'"); } function psSingleQuoted(value) { return `'${String(value).replaceAll("'", "''")}'`; } function zipDirectory(sourceDir, destinationFile) { const script = [ "Add-Type -AssemblyName System.IO.Compression.FileSystem", `$src = ${psSingleQuoted(sourceDir)}`, `$dst = ${psSingleQuoted(destinationFile)}`, "if (Test-Path -LiteralPath $dst) { Remove-Item -LiteralPath $dst -Force }", "[System.IO.Compression.ZipFile]::CreateFromDirectory($src, $dst)" ].join("\n"); execFileSync("powershell", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script], { stdio: "inherit" }); } function assertSafeStaging(path) { const allowedRoot = join(webRoot, "test-results") + sep; const resolved = resolve(path); if (!resolved.startsWith(allowedRoot)) { throw new Error(`Refusing to clear staging path outside test-results: ${resolved}`); } } function formatShanghaiTime(date) { const parts = new Intl.DateTimeFormat("zh-CN", { timeZone: "Asia/Shanghai", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false }).formatToParts(date); const value = Object.fromEntries(parts.map((part) => [part.type, part.value])); return `${value.year}-${value.month}-${value.day} ${value.hour}:${value.minute}:${value.second} Asia/Shanghai`; }