import assert from "node:assert/strict"; import { readFile, mkdir, writeFile } from "node:fs/promises"; import { pathToFileURL } from "node:url"; import * as THREE from "../web/react/node_modules/three/build/three.module.js"; const pw = await import(pathToFileURL(process.env.PLAYWRIGHT_MODULE ?? "/home/mes006/.npm/_npx/e41f203b7505f1fb/node_modules/playwright/index.js")); const browser = await (pw.default ?? pw).chromium.launch({ headless: true, executablePath: "/usr/bin/google-chrome", args: ["--no-sandbox", "--use-angle=swiftshader", "--enable-unsafe-swiftshader"] }); const output = new URL("../coverage/glb-machine-manager/joint-dof/", import.meta.url); await mkdir(output, { recursive: true }); const bytes = await readFile(new URL("../MILL700-U_1.glb", import.meta.url)); const report = { report_id: "GLB-MGR-E13", status: "running", url: process.env.MACHINE_MANAGER_URL ?? "http://127.0.0.1:5180/", machines: [], browser_errors: [] }; const near = (a, b, label) => { assert.equal(a.length, b.length, label); a.forEach((v, i) => assert.ok(Math.abs(v - b[i]) < 2e-6, `${label} ${i}: ${v} vs ${b[i]}`)); }; try { for (const coordinates of (process.env.JOINT_TEST_COORDINATES ?? "XYZAC,XYZBC").split(",")) { const page = await browser.newPage({ viewport: { width: 1280, height: 900 } }); page.on("pageerror", (e) => report.browser_errors.push(e.message)); page.on("console", (m) => { if (["error", "warning"].includes(m.type())) report.browser_errors.push(m.text()); }); const c = (id) => page.locator(`[data-axis-control="${id}"]`); const preview = page.locator("[data-machine-manager-preview]"); const idle = () => page.waitForFunction(() => document.querySelector("main.axis-app")?.dataset.axisProgramLoading === "false" && document.querySelector("main.axis-app")?.dataset.axisStateSource === "linuxcnc-wasm", null, { timeout: 60000 }); await page.goto(report.url); await idle(); if (coordinates === "XYZBC") { await c("workspace-case-select").selectOption("linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzbc-trt.ini"); await idle(); } await c("machine-manager-settings").click(); await c("machine-manager-files").setInputFiles({ name: "MILL700-U_1.glb", mimeType: "model/gltf-binary", buffer: bytes }); await page.waitForFunction(() => document.querySelectorAll('[data-axis-control="machine-tree-node"]').length >= 132); assert.equal(await c("machine-joint-value").isDisabled(), true); assert.equal(await c("machine-manager-apply").isDisabled(), true); assert.equal(await c("machine-manager-translation").count(), 0); assert.equal(await c("machine-manager-rotation").count(), 0); const choose = async (name) => { const node = c("machine-tree-node").filter({ has: page.locator("span").filter({ hasText: new RegExp(`^${name}$`) }) }).first(); const path = await node.locator("xpath=ancestor::li[1]").getAttribute("data-machine-tree-path"); await node.dispatchEvent("click"); await page.waitForFunction((path) => document.querySelector("[data-machine-manager-preview]")?.dataset.machineManagerSelectedJoint?.endsWith(`:${path}`), path); return JSON.parse(await preview.getAttribute("data-machine-manager-selected-transform")); }; const names = { X: "MILL700-A-主轴-JT1", Y: "MILL700-A-主轴-JT2", Z: "MILL700-A-主轴-JT3", [coordinates[3]]: "MILL700-A-工作台-JT1", C: "MILL700-A-工作台-JT2" }; for (const [axis, name] of Object.entries(names)) { await choose(name); await c("machine-tree-axis-role").selectOption(axis); } await choose("MILL700-A底座"); await c("machine-manager-role").selectOption("base"); assert.equal(await c("machine-manager-apply").isDisabled(), false); const parent = await choose(names.Y), sibling = await choose("SHS25C100020_Track_TSXXX-0000058522002"), child = await choose("MILL700-A-主轴-JT4"), zeroZ = await choose(names.Z); await c("machine-joint-direction").selectOption("x"); await c("machine-joint-sign").selectOption("-1"); await c("machine-joint-value").fill("30"); const movedZ = await choose(names.Z); near(movedZ.local, [zeroZ.local[0] - .03, ...zeroZ.local.slice(1)], "single parent X translation"); near(movedZ.localQuaternion, zeroZ.localQuaternion, "locked rotation"); assert.deepEqual(await choose(names.Y), parent); assert.deepEqual(await choose("SHS25C100020_Track_TSXXX-0000058522002"), sibling); const movedChild = await choose("MILL700-A-主轴-JT4"); near(movedChild.world, [child.world[0] - .03, ...child.world.slice(1)], "child follows only configured DOF"); await choose(names.Z); await c("machine-joint-value").fill("30"); near((await choose(names.Z)).local, movedZ.local, "absolute, no accumulated movement"); await c("machine-joint-value").fill("0"); await c("machine-joint-direction").selectOption("z"); await choose("MILL700-A-主轴-JT4"); await c("machine-tree-axis-role").selectOption("Z"); assert.match(await page.locator(".machine-manager-footer").textContent(), /重复|已绑定/); assert.equal(await c("machine-tree-axis-role").inputValue(), ""); const rotaryZero = await choose(names[coordinates[3]]); await c("machine-joint-value").fill("25"); const rotaryMoved = await choose(names[coordinates[3]]); near(rotaryMoved.local, rotaryZero.local, "rotation pivot fixed"); const axis = coordinates === "XYZAC" ? new THREE.Vector3(1, 0, 0) : new THREE.Vector3(0, 1, 0); near(rotaryMoved.localQuaternion, new THREE.Quaternion().setFromAxisAngle(axis, 25 * Math.PI / 180).multiply(new THREE.Quaternion(...rotaryZero.localQuaternion)).toArray(), "one rotational DOF"); await c("machine-test-reset").click(); const zeroFields = page.locator("fieldset.machine-zero"); await zeroFields.getByLabel("X", { exact: true }).fill("5"); await zeroFields.getByLabel("Z", { exact: true }).fill("7"); await zeroFields.getByLabel(coordinates[3], { exact: true }).fill("12"); await zeroFields.getByLabel("C", { exact: true }).fill("9"); const jsonDownload = page.waitForEvent("download"); await c("machine-manager-export-json").click(); const json = JSON.parse(await readFile(await (await jsonDownload).path(), "utf8")); assert.equal(json.schema, "linuxcnc.joint-model/1"); assert.equal(json.coordinates, coordinates); assert.deepEqual(Object.keys(json.axisMap).sort(), [...coordinates].sort()); assert.equal(json.nodes.filter((n) => n.joint).length, 5); assert.ok(json.nodes.every((n) => n.joint === null || n.joint.dof === 1)); assert.equal(json.nodes.find((n) => n.id === json.axisMap.Z).joint.sign, -1); const glbDownload = page.waitForEvent("download"); await c("machine-manager-export").click(); const glb = await readFile(await (await glbDownload).path()); const gltf = JSON.parse(glb.toString("utf8", 20, 20 + glb.readUInt32LE(12))); assert.deepEqual(gltf.scenes[0].extras.linuxcncJointModel, json); const bed = gltf.nodes.find((n) => n.name === "MILL700-A底座"); assert.deepEqual(bed.extras.linuxcncJointModel, json); assert.ok(bed.extras.linuxcncMachineGeometry); await writeFile(new URL(`${coordinates.toLowerCase()}.json`, output), JSON.stringify(json, null, 2) + "\n"); await c("machine-manager-delete").click(); await c("machine-manager-files").setInputFiles({ name: `managed-${coordinates.toLowerCase()}.glb`, mimeType: "model/gltf-binary", buffer: glb }); await page.waitForFunction(() => document.querySelectorAll('[data-axis-control="machine-tree-node"]').length >= 132); await choose(names.Z); assert.equal(await c("machine-tree-axis-role").inputValue(), "Z"); assert.equal(await c("machine-joint-sign").inputValue(), "-1"); assert.equal(await zeroFields.getByLabel("Z", { exact: true }).inputValue(), "7"); console.log(`${coordinates}: GLB export and editor roundtrip verified`); // Standard machine-import route validates exported GLB, JSON hierarchy and zero restoration. await page.keyboard.press("Escape"); await c("machine-glb-settings").click(); await c("machine-glb-file").setInputFiles({ name: `managed-${coordinates.toLowerCase()}.glb`, mimeType: "model/gltf-binary", buffer: glb }); await page.waitForFunction(() => document.querySelector(".xyzac-machine-view")?.dataset.machineJointState, null, { timeout: 60000 }); await idle(); await page.keyboard.press("Escape"); console.log(`${coordinates}: standard GLB importer verified`); // Apply directly from the restored one-file hierarchy as well. await c("machine-manager-settings").click(); await c("machine-manager-apply").click(); await idle(); assert.match(await page.locator(".machine-manager-footer").textContent(), /已应用/); await page.keyboard.press("Escape"); const program = `M428 G21 G90 G94 G17 G40 G49 G1 X20 Y10 Z10 ${coordinates[3]}20 C30 F120 G1 X-10 Y-5 Z-5 ${coordinates[3]}-10 C-20 F120 M2 `; await c("program-file-input").setInputFiles({ name: `joint-map-${coordinates.toLowerCase()}.ngc`, mimeType: "text/plain", buffer: Buffer.from(program) }); await idle(); console.log(`${coordinates}: apply verified; starting simultaneous five-axis WASM motion`); await c("toolbar-power").click(); await page.waitForFunction(() => document.querySelector("main.axis-app")?.dataset.axisStateMachine === "ON"); await c("manual-home-all").click(); await page.waitForFunction(() => document.querySelector("main.axis-app")?.dataset.axisHomed === "[true,true,true,true,true]", null, { timeout: 60000 }); await c("toolbar-run").click(); await page.waitForFunction(() => { const view = document.querySelector(".xyzac-machine-view"); const q = JSON.parse(view?.dataset.machineJointInputs ?? "[]"); return Number(document.querySelector("main.axis-app")?.dataset.axisCurrentLine) > 0 && q.some((v) => Math.abs(v) > 1e-6); }, null, { timeout: 60000 }); const samples = []; for (let i = 0; i < 8; i++) { if (samples.length) await page.waitForFunction((tick) => Number(document.querySelector(".xyzac-machine-view")?.dataset.machineJointTick) > tick, samples.at(-1).tick, { timeout: 60000 }); const sample = await page.locator(".xyzac-machine-view").evaluate((el) => ({ tick: Number(el.dataset.machineJointTick), q: JSON.parse(el.dataset.machineJointInputs), nodes: JSON.parse(el.dataset.machineJointState) })); for (const [index, axisName] of [...coordinates].entries()) { const state = sample.nodes[axisName]; const original = json.nodes.find((n) => n.joint?.axis === axisName); const d = original.joint; const q = d.sign * (sample.q[index] - json.calibration.physicalZero[axisName.toLowerCase()]); const direction = new THREE.Vector3(...["x", "y", "z"].map((a) => a === d.direction ? 1 : 0)); if ("XYZ".includes(axisName)) { near(state.localMm, new THREE.Vector3(...original.zero.positionMm).addScaledVector(direction, q).toArray(), `${axisName} runtime position`); near(state.quaternion, original.zero.quaternion, `${axisName} locked orientation`); } else { near(state.localMm, original.zero.positionMm, `${axisName} fixed pivot`); near(state.quaternion, new THREE.Quaternion().setFromAxisAngle(direction, q * Math.PI / 180).multiply(new THREE.Quaternion(...original.zero.quaternion)).toArray(), `${axisName} runtime rotation`); } } samples.push(sample); } for (let axisIndex = 0; axisIndex < 5; axisIndex++) assert.ok(samples.some((s) => Math.abs(s.q[axisIndex] - samples[0].q[axisIndex]) > 1e-6), `${coordinates[axisIndex]} live movement required`); await c("machine-manager-settings").click(); await c("machine-joint-follow-live").check(); assert.equal(await c("machine-joint-value").isDisabled(), true); await page.waitForFunction(() => document.querySelector('[data-axis-control="machine-joint-live-state"]')?.textContent.includes("WASM tick")); const liveSample = await page.evaluate(() => ({ q: JSON.parse(document.querySelector('[data-axis-control="machine-test-state"]').textContent), joints: JSON.parse(document.querySelector("[data-machine-manager-preview]").dataset.machineManagerJointTransforms) })); const z = json.nodes.find((n) => n.joint?.axis === "Z"); near(liveSample.joints.Z.localMm, [z.zero.positionMm[0], z.zero.positionMm[1], z.zero.positionMm[2] - liveSample.q.z], "dialog follows live Z sign"); await page.screenshot({ path: new URL(`${coordinates.toLowerCase()}-live.png`, output).pathname }); report.machines.push({ coordinates, checks: ["fixed or single DOF", "direction/sign and parent isolation", "pivot and child inheritance", "duplicate axis rejected", "JSON on scene and real bed", "GLB roundtrip restores calibration and bindings", "single-file apply", "WASM runtime axis mapping", "live dialog follows actual joints"], nodeCount: json.nodes.length, samples, liveSample }); await page.close(); } assert.deepEqual(report.browser_errors, []); report.status = "verified"; } catch (e) { report.status = "failed"; report.error = e.stack; for (const page of browser.contexts().flatMap((c) => c.pages())) { report.pageText = await page.locator("body").innerText().catch(() => ""); await page.screenshot({ path: new URL("failure.png", output).pathname }).catch(() => {}); } throw e; } finally { await writeFile(new URL("browser.report.json", output), JSON.stringify(report, null, 2) + "\n"); await browser.close(); } console.log(JSON.stringify({ report_id: report.report_id, status: report.status, machines: report.machines.map((m) => ({ coordinates: m.coordinates, checks: m.checks, samples: m.samples.length })), browser_errors: report.browser_errors }));