按关节槽位驱动 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:
554
docs/user-manual/capture_deployed_manual.mjs
Normal file
554
docs/user-manual/capture_deployed_manual.mjs
Normal 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);
|
||||
Reference in New Issue
Block a user