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`;
}