按关节槽位驱动 13 个五轴案例的三维模型

关节契约扩为槽位制:以 LinuxCNC 联合号标识关节,轴字母降为槽位属性,
重复轴(bridge-mill 的两个 Y)、并联杆长(hexapod/pentapod)和被动槽位
(max5 第 6 关节)都能表达。v1 规则保持不变并合成 jointOrder,已保存
GLB 与既有测试不受影响。

13 个结构 GLB 由 scripts/generate_physical_case_glbs.mjs 统一重建,
可动关节均为按槽位命名的 Group 并写 userData.linuxcncJoint,共 71 个
关节,槽位数与运行目录的 joint_count 逐条一致。

新增 machine-visual-bindings.json 与纯函数模块,以 machine id 为键:
坐标字母不能作键,因为三个案例共用 XYZAB、三个共用 XYZABC。主界面切换
案例时自动装载对应模型,失败时保留内置模型并显式提示。

消除前端五关节截断:visual 适配器的 joints 改为数组并新增
jointsAuthoritative;allHomed 按 joint_count 判断;面板按 worker 断言的
machine id 查找机型。

管理器解锁:坐标与案例下拉以"存在完整视觉绑定"为启用条件,13 案例全部
可选;machine-urdf.ts 新增 generic-wasm/1 profile,AC/BC 严格校验路径
不变;新增 machine-kinematics-specs.json 保存逐关节限位,INI 未声明的
限位保持 null 而不编造。

修复三个真实缺陷:构建漏发 woodpecker/gmoccapy/qtdragon 配置目录(
DEFECT-02 根因);案例切换沿用"装载需要已上电"的门控,导致关机状态下
第二次切换被静默拒绝;重复轴机型上 React 列表键冲突。

验收:13 案例逐个切换装载成功、槽位键与绑定定义逐一一致、浏览器错误 0;
typecheck 与生产构建通过;视觉绑定、GLB 命名、关节契约、投影、store、
双画布、运动学规格漂移、11 案例 Worker 闭环、管理器浏览器回归全部通过。
证据在 coverage/glb-machine-manager/physical-drive/。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-30 06:09:12 +08:00
parent af65b69b2c
commit db29ae625f
157 changed files with 11936 additions and 885 deletions

View File

@@ -0,0 +1,554 @@
// 对线上发布站点做功能巡检并采集使用说明书插图。
//
// 运行:
// PLAYWRIGHT_MODULE=/home/mes006/.npm/_npx/e41f203b7505f1fb/node_modules/playwright/index.mjs \
// node docs/user-manual/capture_deployed_manual.mjs
//
// 可选环境变量:MANUAL_URL(默认线上 58010 站点)、CHROME_PATH。
// 输出:docs/user-manual/screenshots/*.png 与 docs/user-manual/deployment-test.report.json
//
// 结构:
// 阶段一 在 XYZAC 上跑完整操作链(上电、回零、Jog、主轴、MDI、程序运行等)。
// 阶段二 逐案例探测仿真案例在浏览器中的可用性(每案例独立页面)。
import { mkdir, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const shotDir = resolve(here, "screenshots");
const reportPath = resolve(here, "deployment-test.report.json");
const baseUrl = process.env.MANUAL_URL ?? "https://49.232.250.35:58010/";
const chromePath = process.env.CHROME_PATH ?? "/usr/bin/google-chrome";
const playwrightModule = process.env.PLAYWRIGHT_MODULE;
const results = [];
const consoleErrors = [];
const failedRequests = [];
const artifacts = [];
const defects = [];
const caseMatrix = [];
await mkdir(shotDir, { recursive: true });
const { chromium } = await import(playwrightModule);
const browser = await chromium.launch({
executablePath: chromePath,
args: ["--no-sandbox", "--ignore-certificate-errors", "--enable-unsafe-swiftshader"],
});
const newPage = async () => {
const page = await browser.newPage({ ignoreHTTPSErrors: true, viewport: { width: 1440, height: 900 } });
page.on("pageerror", (error) => consoleErrors.push({ kind: "pageerror", text: String(error) }));
page.on("console", (message) => { if (message.type() === "error") consoleErrors.push({ kind: "console", text: message.text() }); });
page.on("requestfailed", (request) => failedRequests.push(`${request.url()} ${request.failure()?.errorText ?? ""}`));
page.on("response", (response) => { if (response.status() >= 400) failedRequests.push(`HTTP ${response.status()} ${response.url()}`); });
return page;
};
const page = await newPage();
const step = async (name, body) => {
const started = Date.now();
try {
const detail = await body();
results.push({ name, status: "pass", ms: Date.now() - started, detail: detail ?? null });
console.log(`PASS ${name}${detail ? ` — ${typeof detail === "string" ? detail : JSON.stringify(detail)}` : ""}`);
} catch (error) {
results.push({ name, status: "fail", ms: Date.now() - started, detail: String(error?.message ?? error) });
console.log(`FAIL ${name} — ${error?.message ?? error}`);
}
};
const shot = async (name, { full = false } = {}) => {
await page.screenshot({ path: resolve(shotDir, `${name}.png`), fullPage: full });
artifacts.push(`${name}.png`);
};
const text = async (selector) => (await page.locator(selector).first().innerText()).replace(/\s+/g, " ").trim();
const statusText = () => text(".machine-status");
const positions = async () => page.locator(".xyzac-position-grid output").allInnerTexts();
const settle = async (ms = 600) => { await page.waitForTimeout(ms); };
const closeDialog = async () => {
const dialog = page.locator("dialog[open]").first();
if (!(await dialog.count())) return;
const button = dialog.locator('header button[aria-label^="关闭"]').first();
if (await button.count()) await button.click();
else await page.keyboard.press("Escape");
await dialog.waitFor({ state: "hidden", timeout: 5000 }).catch(() => {});
};
const waitFor = async (predicate, timeout, label) => {
const deadline = Date.now() + timeout;
let last = "";
while (Date.now() < deadline) {
last = await predicate();
if (last) return last;
await page.waitForTimeout(400);
}
throw new Error(`${label} 在 ${timeout}ms 内未满足,最后观察:${String(last).slice(0, 160)}`);
};
const waitState = async (pattern, timeout = 60000) => {
const deadline = Date.now() + timeout;
let last = "";
while (Date.now() < deadline) {
last = await statusText();
if (pattern.test(last)) return last;
await page.waitForTimeout(400);
}
throw new Error(`状态 ${pattern} 在 ${timeout}ms 内未满足,最后状态:${last.slice(0, 200)}`);
};
const waitCase = (timeout = 120000) => waitFor(async () => {
const value = (await page.locator(".case-status").innerText()).trim();
return value.startsWith("Program:") ? value : null;
}, timeout, "案例程序加载完成");
// 重新载入案例会按设计先给机床下电并复位回零状态,运行程序前需要重新上电并回零。
const ensureReady = async () => {
const estop = page.locator('[data-axis-control="toolbar-estop"]');
if ((await estop.getAttribute("aria-label"))?.includes("Cancel")) { await estop.click(); await settle(1500); }
const power = page.locator('[data-axis-control="toolbar-power"]');
if ((await power.getAttribute("aria-label"))?.includes("Power on") && !(await power.isDisabled())) {
await power.click();
await waitState(/STATE_ON|STATE_IDLE/, 40000);
}
if (!/Machine sequence HOMED/.test(await statusText())) {
await page.locator('[data-axis-control="tab-manual"]').click();
await page.locator('[data-axis-control="manual-home-all"]').click();
await waitState(/Machine sequence HOMED/, 60000);
}
return statusText();
};
// canLoad 为假时下拉框会拒绝切换,这里按值重试直到状态真正生效。
const selectCase = async (value, timeout = 45000) => {
const select = page.locator('[data-axis-control="workspace-case-select"]');
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
await select.selectOption(value);
await settle(2500);
if ((await select.inputValue()) === value) { await waitCase(); await settle(2500); return value; }
}
throw new Error(`案例切换未生效:${value}`);
};
// 冷却液等开关是受 WASM 状态回传驱动的受控复选框,点击后要等状态回传再判断,避免误判失败。
const setToggle = async (selector, target, attempts = 6) => {
const box = page.locator(selector);
for (let attempt = 0; attempt < attempts; attempt += 1) {
if ((await box.isChecked()) === target) return true;
await box.click();
await settle(1200);
}
return (await box.isChecked()) === target;
};
// ---------------------------------------------------------------- 阶段一:TRT 主线
await step("打开线上站点并等待 WASM 会话就绪", async () => {
await page.goto(baseUrl, { waitUntil: "load", timeout: 90000 });
await page.waitForSelector('[data-axis-control="workspace-case-select"]', { timeout: 60000 });
await waitState(/STATE_/, 60000);
await waitCase(90000);
await settle(2500);
const cases = await page.locator('[data-axis-control="workspace-case-select"] option').allInnerTexts();
await shot("01-main-overview");
return { title: await page.title(), canvases: await page.locator("canvas").count(), cases: cases.length };
});
await step("状态栏、轴位与实时参数由 LinuxCNC WASM 驱动", async () => {
const status = await statusText();
const live = await page.locator("[data-xyzac-parameter-source]").first().getAttribute("data-xyzac-parameter-source");
const axes = await positions();
if (!/Task mode/.test(status)) throw new Error(`状态栏异常:${status}`);
if (live !== "linuxcnc-wasm") throw new Error(`实时参数来源不是 WASM:${live}`);
if (axes.length < 5) throw new Error(`轴位显示数量不足:${axes.length}`);
return { live, axes, status: status.slice(0, 120) };
});
await step("Help 菜单可打开 About 对话框", async () => {
await page.locator('[data-axis-control="menu-help"]').click();
await page.locator('[data-axis-control="menu-help-about"]').click();
const dialog = page.locator(".axis-about-dialog");
await dialog.waitFor({ state: "visible", timeout: 5000 });
await shot("02-about-dialog");
const body = (await dialog.innerText()).replace(/\s+/g, " ");
await page.locator('[data-axis-control="about-close"]').click();
await dialog.waitFor({ state: "hidden", timeout: 5000 });
return body.slice(0, 120);
});
await step("File 菜单把宿主边界项正确置灰", async () => {
await page.locator('[data-axis-control="menu-file"]').click();
const states = [
await page.locator('[data-axis-control="menu-file-edit-tool-data"]').isDisabled(),
await page.locator('[data-axis-control="menu-file-quit"]').isDisabled(),
];
await shot("03-file-menu");
await page.keyboard.press("Escape");
if (states.some((disabled) => !disabled)) throw new Error(`宿主边界项未置灰:${states}`);
return states;
});
await step("RTCP 程序库按机型过滤并列出可加载程序", async () => {
await page.locator('[data-axis-control="workspace-rtcp-programs"]').click();
const panel = page.locator("dialog.rtcp-program-dialog");
await panel.waitFor({ state: "visible", timeout: 8000 });
await settle(600);
await shot("04-rtcp-program-library");
const summary = (await panel.locator(".rtcp-program-summary").innerText()).replace(/\s+/g, " ");
const options = await panel.locator('[data-axis-control="rtcp-program-select"] option').count();
const scopes = await panel.locator('[data-axis-control="rtcp-program-scope"] option').allInnerTexts();
await closeDialog();
if (options < 3) throw new Error(`RTCP 程序条目不足:${options}`);
return { options, summary, scopes };
});
await step("导入机床模型对话框列出内置示例与下载入口", async () => {
await page.locator('[data-axis-control="machine-glb-settings"]').click();
const dialog = page.locator("dialog[open]");
await dialog.waitFor({ state: "visible", timeout: 8000 });
await settle(800);
await shot("05-machine-import-dialog");
const downloads = await dialog.locator("a[download]").count();
const summary = (await dialog.innerText()).replace(/\s+/g, " ");
await closeDialog();
return { downloads, summary: summary.slice(0, 160) };
});
await step("三维机床管理四个步骤页签可切换", async () => {
await page.locator('[data-axis-control="machine-manager-settings"]').click();
const dialog = page.locator("dialog[open]");
await dialog.waitFor({ state: "visible", timeout: 8000 });
await settle(1200);
const seen = [];
for (const [id, name] of [["structure", "06-machine-manager-structure"], ["motion", "07-machine-manager-motion"], ["calibration", "08-machine-manager-calibration"], ["files", "09-machine-manager-files"]]) {
await page.locator(`[data-axis-control="machine-manager-tab-${id}"]`).click();
await settle(900);
await shot(name);
seen.push((await text(`#machine-panel-${id}`)).slice(0, 70));
}
await closeDialog();
return seen;
});
await step("机床参数对话框显示旋转中心与轴间偏置字段", async () => {
await page.locator('[data-axis-control="geometry-settings"]').click();
const dialog = page.locator("dialog[open]");
await dialog.waitFor({ state: "visible", timeout: 8000 });
await settle(600);
await shot("10-geometry-parameters");
const fieldCount = await dialog.locator('input[type="number"]').count();
await closeDialog();
if (fieldCount < 4) throw new Error(`几何参数字段不足:${fieldCount}`);
return fieldCount;
});
await step("案例切换到 XYZBC 后机型与轴组同步改变", async () => {
await selectCase("linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzbc-trt.ini");
await shot("11-case-xyzbc");
const axes = await page.locator(".axis-radio span").allInnerTexts();
if (axes.join("") !== "XYZBC") throw new Error(`切换机型后轴组异常:${axes}`);
await selectCase("linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini");
const back = await page.locator(".axis-radio span").allInnerTexts();
if (back.join("") !== "XYZAC") throw new Error(`切回 XYZAC 失败:${back}`);
return { axes, back };
});
await step("急停复位并可上电", async () => {
const estop = page.locator('[data-axis-control="toolbar-estop"]');
if ((await estop.getAttribute("aria-label"))?.includes("Cancel")) { await estop.click(); await settle(1500); }
await page.locator('[data-axis-control="toolbar-power"]').click();
return (await waitState(/STATE_ON|STATE_IDLE/, 40000)).slice(0, 120);
});
await step("HOME ALL 完成全部关节回零", async () => {
await page.locator('[data-axis-control="manual-home-all"]').click();
const status = await waitState(/Machine sequence HOMED/, 60000);
await shot("12-manual-homed");
return status.slice(0, 120);
});
await step("连续 Jog:按住 X 正向位置变化,松手停止", async () => {
await page.locator('[data-axis-control="manual-axis-x"]').check();
const before = (await positions())[0];
await page.locator('[data-axis-control="manual-jog-positive"]').hover();
await page.mouse.down();
await settle(1800);
await shot("13-manual-jog");
await page.mouse.up();
await settle(1500);
const after = (await positions())[0];
if (before === after) throw new Error(`连续 Jog 后 X 未变化:${before}`);
return { before, after };
});
await step("增量 Jog:0.1000 步长按一次步进", async () => {
await page.locator('[data-axis-control="manual-jog-increment"]').selectOption("0.1000");
const before = Number((await positions())[0]);
await page.locator('[data-axis-control="manual-jog-negative"]').hover();
await page.mouse.down();
await settle(1200);
await page.mouse.up();
await settle(2000);
const after = Number((await positions())[0]);
await page.locator('[data-axis-control="manual-jog-increment"]').selectOption("Continuous");
if (!(Math.abs(Math.abs(before - after) - 0.1) < 0.04)) throw new Error(`增量步长不符:${before} -> ${after}`);
return { before, after };
});
await step("主轴启停与冷却液开关可用", async () => {
await page.locator('[data-axis-control="tab-manual"]').click();
await page.locator('[data-axis-control="manual-spindle-cw"]').click();
await settle(2500);
const live = await text('[data-axis-feature="AXIS-LIVE-PARAMETERS"]');
const mistOn = await setToggle('[data-axis-control="manual-coolant-mist"]', true);
await shot("14-spindle-coolant");
await page.locator('[data-axis-control="manual-spindle-stop"]').click();
const mistOff = await setToggle('[data-axis-control="manual-coolant-mist"]', false);
if (!mistOn || !mistOff) throw new Error(`冷却液开关未按预期切换:开=${mistOn} 关=${mistOff}`);
return { mistOn, mistOff, live: live.slice(0, 110) };
});
await step("MDI 指令执行并写入历史", async () => {
await page.locator('[data-axis-control="tab-mdi"]').click();
await page.locator('[data-axis-control="mdi-command"]').fill("G0 X12.5 Y-4 Z0");
await page.locator('[data-axis-control="mdi-submit"]').click();
await settle(3000);
await shot("15-mdi");
const history = await page.locator('[data-axis-control="mdi-history-select"]').first().innerText();
if (!history.includes("G0")) throw new Error(`MDI 历史缺记录:${history}`);
return history;
});
await step("DRO 视图可切换坐标、显示与单位", async () => {
await page.locator('[data-axis-control="tab-dro"]').click();
await page.locator('[data-axis-control="dro-coordinate"]').selectOption("relative");
await page.locator('[data-axis-control="dro-display"]').selectOption("commanded");
await page.locator('[data-axis-control="dro-units"]').selectOption("inch");
await settle(1500);
await shot("16-dro");
const rows = await page.locator(".dro-row").count();
const footer = await text(".dro-footer");
await page.locator('[data-axis-control="dro-units"]').selectOption("mm");
await page.locator('[data-axis-control="dro-coordinate"]').selectOption("machine");
await page.locator('[data-axis-control="dro-display"]').selectOption("actual");
await settle(800);
return { rows, footer: footer.slice(0, 110) };
});
await step("倍率滑块与预览图层开关可用", async () => {
await page.locator('[data-axis-control="override-feed"]').fill("80");
await settle(1500);
const label = (await page.locator("section.axis-metrics").innerText()).replace(/\s+/g, " ");
const layer = page.locator('[data-axis-control="preview-layer-velocity"]');
await layer.uncheck();
await settle(500);
const unchecked = !(await layer.isChecked());
await layer.check();
await shot("17-overrides-layers");
if (!unchecked) throw new Error("预览图层开关无效");
return label.slice(0, 150);
});
await step("工具栏『载入精确案例』按钮重新载入当前案例", async () => {
await page.locator('[data-axis-control="tab-preview"]').click();
await page.locator('[data-axis-control="toolbar-load-case"]').click();
// 修复前这里会停在 blocked: simulation_case_unknown,永远不会回到 Program: 状态。
const settled = await waitCase(120000);
const status = (await page.locator(".case-status").innerText()).trim();
if (/^blocked:/.test(status)) throw new Error(`载入精确案例失败:${status}`);
return status.slice(0, 80);
});
await step("载入案例程序并运行、暂停、继续、停止", async () => {
await ensureReady();
await page.locator('[data-axis-control="workspace-load-boat"]').click();
await waitCase();
await settle(3000);
await page.locator('[data-axis-control="toolbar-run"]').click();
const running = await waitState(/STATE_RUNNING|INTERP_READING|INTERP_WAITING/, 60000);
await settle(2500);
await shot("18-program-running");
let paused = "";
if (await page.locator('[data-axis-control="toolbar-pause-resume"]').isEnabled()) {
await page.locator('[data-axis-control="toolbar-pause-resume"]').click();
paused = await waitState(/PAUSED|INTERP_PAUSED/, 30000);
await settle(1500);
await shot("19-program-paused");
await page.locator('[data-axis-control="toolbar-pause-resume"]').click();
await settle(2500);
}
await page.locator('[data-axis-control="toolbar-stop"]').click();
await settle(3000);
return { running: running.slice(0, 100), paused: paused.slice(0, 100), stopped: (await statusText()).slice(0, 100) };
});
await step("RTCP 程序库『加载到控制器』可用", async () => {
await page.locator('[data-axis-control="workspace-rtcp-programs"]').click();
const panel = page.locator("dialog.rtcp-program-dialog");
await panel.waitFor({ state: "visible", timeout: 8000 });
await settle(800);
try {
const load = panel.locator('[data-axis-control="rtcp-program-load"]');
if (!(await load.isEnabled())) throw new Error("默认主程序当前不可加载");
await load.click();
await panel.locator('output[role="status"]').filter({ hasText: "程序已加载" }).waitFor({ state: "visible", timeout: 60000 });
await shot("22-rtcp-load");
return (await panel.locator('output[role="status"]').innerText()).trim();
} finally {
await closeDialog();
await settle(2500);
}
});
await step("工具栏『打开 G 代码文件』可装载本地 .ngc", async () => {
await page.setInputFiles('[data-axis-control="program-file-input"]', "web/react/public/machine-cases/xyzac-trt.ngc");
await waitCase(90000);
await settle(2500);
return (await page.locator(".case-status").innerText()).trim();
});
await step("工具栏『重载当前程序』可用", async () => {
await page.locator('[data-axis-control="toolbar-reload"]').click();
await settle(4000);
return (await statusText()).slice(0, 110);
});
await step("工具栏视图按钮与清除轨迹可用", async () => {
for (const id of ["toolbar-view-z", "toolbar-view-x", "toolbar-view-y", "toolbar-view-p"]) {
await page.locator(`[data-axis-control="${id}"]`).click();
await settle(500);
}
const pressed = await page.locator('[data-axis-control="toolbar-view-p"]').getAttribute("aria-pressed");
await page.locator('[data-axis-control="toolbar-rotate"]').click();
await page.locator('[data-axis-control="toolbar-clear-live-plot"]').click();
await settle(1200);
await shot("23-views-cleared");
if (pressed !== "true") throw new Error(`视图按钮未置为按下:${pressed}`);
return { pressed };
});
await step("诊断页显示启动资源与门控", async () => {
await page.locator('[data-axis-control="tab-diagnostics"]').click();
await settle(1200);
await shot("20-diagnostics");
const body = await text("#axis-panel-diagnostics");
if (!/Disabled operations|Startup resources/.test(body)) throw new Error(`诊断页内容异常:${body.slice(0, 100)}`);
return body.slice(0, 150);
});
await step("快捷键 F1 / F3 / F5 生效", async () => {
await page.locator("body").click({ position: { x: 5, y: 5 } });
await page.keyboard.press("F1");
const estop = await waitState(/ESTOP/, 15000);
await page.keyboard.press("F1");
await settle(2000);
await page.keyboard.press("F3");
await settle(800);
const manualSelected = await page.locator('[data-axis-control="tab-manual"]').getAttribute("aria-selected");
await page.keyboard.press("F5");
await settle(800);
const mdiSelected = await page.locator('[data-axis-control="tab-mdi"]').getAttribute("aria-selected");
if (manualSelected !== "true" || mdiSelected !== "true") throw new Error(`F3/F5 未切换页签:${manualSelected}/${mdiSelected}`);
return { estopTriggered: /ESTOP/.test(estop), manualSelected, mdiSelected };
});
await step("窄视口 390x844 无横向溢出", async () => {
await page.setViewportSize({ width: 390, height: 844 });
await settle(2500);
await shot("21-mobile-390", { full: true });
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
await page.setViewportSize({ width: 1440, height: 900 });
if (overflow > 2) throw new Error(`出现横向溢出 ${overflow}px`);
return { overflow };
});
await page.close();
// ---------------------------------------------------------------- 阶段二:案例矩阵
const MATRIX = [
["xyzac-trt", "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini"],
["xyzbc-trt", "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzbc-trt.ini"],
["xyzab-tdr", "linuxcnc/configs/sim/axis/vismach/5axis/table-dual-rotary/xyzab-tdr.ini"],
["max5-maxkins", "linuxcnc/configs/sim/axis/vismach/5axis/max5/max5kins.ini"],
["trivkins-xyzya", "linuxcnc/configs/sim/woodpecker/woodpecker_xyzya.ini"],
["hexapod-genhexkins", "linuxcnc/configs/sim/axis/vismach/hexapod-sim/hexapod.ini"],
["scorbot-kins", "linuxcnc/configs/sim/axis/vismach/scorbot/scorbot.ini"],
];
console.log("\n阶段二:案例可用性矩阵(每个案例独立页面)");
for (const [id, entrypoint] of MATRIX) {
const probe = await newPage();
const fourXx = [];
probe.on("response", (response) => { if (response.status() >= 400) fourXx.push(`${response.status()} ${response.url().replace(baseUrl.replace(/\/$/, ""), "")}`); });
try {
await probe.goto(baseUrl, { waitUntil: "load", timeout: 90000 });
await probe.waitForSelector('[data-axis-control="workspace-case-select"]', { timeout: 60000 });
await probe.waitForFunction(() => document.querySelector(".case-status")?.textContent?.startsWith("Program:"), null, { timeout: 90000 });
await probe.waitForTimeout(4000);
const select = probe.locator('[data-axis-control="workspace-case-select"]');
let applied = false;
for (let attempt = 0; attempt < 5 && !applied; attempt += 1) {
await select.selectOption(entrypoint);
await probe.waitForTimeout(2500);
applied = (await select.inputValue()) === entrypoint;
}
await probe.waitForTimeout(20000);
const power = probe.locator('[data-axis-control="toolbar-power"]');
const usable = applied && !(await power.isDisabled());
const status = (await probe.locator(".machine-status").innerText()).replace(/\s+/g, " ");
caseMatrix.push({
id,
applied,
power: usable ? "enabled" : `disabled:${await power.getAttribute("title")}`,
state: status.slice(0, 90),
http_4xx: fourXx.slice(0, 2),
verdict: usable ? "usable" : applied ? "loads_but_controller_pending" : "switch_rejected",
});
console.log(` ${usable ? "可用 " : "不可用"} ${id.padEnd(20)} ${usable ? "可上电" : status.slice(0, 46)}`);
} catch (error) {
caseMatrix.push({ id, applied: false, power: "unknown", state: String(error?.message ?? error).slice(0, 120), http_4xx: fourXx.slice(0, 2), verdict: "probe_error" });
console.log(` 探测失败 ${id} — ${String(error?.message ?? error).slice(0, 80)}`);
} finally {
await probe.close();
}
}
const pendingCases = caseMatrix.filter((row) => row.verdict !== "usable");
if (pendingCases.length) {
defects.push({
id: "DEFECT-02",
severity: "high",
control: "Simulation case 下拉框中的通用运动学案例",
observed: `${pendingCases.map((row) => row.id).join("、")} 载入后控制器停在 PENDING,电源按钮保持禁用(linuxcnc_wasm_snapshot_pending),此时下拉框无法切回其它案例,只能刷新页面恢复`,
expected: "选中案例后可上电、回零并运行其干运行程序",
root_cause: "通用案例运行链在浏览器侧尚未完成:案例的运动学源文件(如 linuxcnc/src/emc/kinematics/trivkins.c、linuxcnc/src/hal/components/xyzab_tdr_kins.comp)没有随前端产物发布,请求返回 404;且会话启动失败后前端不回滚,卡在 pending",
workaround: "当前只使用 XYZAC TRT / XYZBC TRT 两个案例;误选其它案例后刷新浏览器页面",
});
}
const report = {
report_id: "LINUXCNC-WEB-DEPLOYED-MANUAL-TEST",
url: baseUrl,
checked_at: new Date().toISOString(),
// 只有步骤失败才判定 failed;已知缺陷(DEFECT-xx)单独记录,不掩盖功能测试结论。
status: results.every((row) => row.status === "pass") ? "verified" : "failed",
passed: results.filter((row) => row.status === "pass").length,
failed: results.filter((row) => row.status === "fail").length,
results,
defects,
case_matrix: caseMatrix,
console_errors: consoleErrors,
http_and_request_failures: failedRequests,
screenshots: artifacts,
};
await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`);
console.log(`\n${report.status.toUpperCase()} ${report.passed} passed / ${report.failed} failed → ${reportPath}`);
if (defects.length) console.log(`缺陷/限制 ${defects.length} 项:`, defects.map((item) => item.id).join("、"));
if (consoleErrors.length) console.log("console/page errors:", JSON.stringify(consoleErrors.slice(0, 3)));
await browser.close();
process.exit(report.status === "verified" ? 0 : 1);