Align AXIS control flow and fix pause and program rendering
Document source UI gates, explicit native live-plot clearing, and remaining parity boundaries. Fix pause/resume transport continuity, repeated stepping, coordinate previews, and spindle override state. Optimize large program snapshots and rendering, and cover the control flows with native Tcl, WASM, and browser checks. Validation: 22 native tests and source/browser checks pass. Worker/App regression: 113 of 123 pass; 10 pre-existing failures remain documented with no new failures.
This commit is contained in:
56
scripts/benchmark_xyzac_program_replacement.mjs
Normal file
56
scripts/benchmark_xyzac_program_replacement.mjs
Normal file
@@ -0,0 +1,56 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import createModule from "../build-wasm/linuxcnc-sim-core.js";
|
||||
import { loadSimulationCase, toWorkerConfiguration } from "../web/workspace/sim-case-loader.mjs";
|
||||
import { XyzacSession, CONTEXT_ID } from "../web/worker/xyzac-session.mjs";
|
||||
|
||||
const root = new URL("../", import.meta.url);
|
||||
const read = (path) => readFile(new URL(path, root), "utf8");
|
||||
const bundle = await loadSimulationCase({ path: "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini", fetchImpl: async (url) => {
|
||||
try { const body = await read(String(url).replace(/^\//, "")); return { ok: true, text: async () => body }; }
|
||||
catch { return { ok: false, text: async () => "" }; }
|
||||
} });
|
||||
const core = await createModule({ wasmBinary: await readFile(new URL("build-wasm/linuxcnc-sim-core.wasm", root)) });
|
||||
const session = new XyzacSession(core, { sessionId: "replacement-benchmark" });
|
||||
let seq = 0;
|
||||
const send = (kind, payload = {}) => {
|
||||
const result = session.accept({ schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId, kind, payload, seq: ++seq, operation_id: `bench-${seq}` });
|
||||
assert.notEqual(result.kind, "ERROR", `${kind}: ${result.payload.gate_reason ?? result.payload.message_key}`);
|
||||
return result.payload;
|
||||
};
|
||||
send("HELLO"); send("SESSION_ATTACH"); send("LOAD_WORKSPACE", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) });
|
||||
send("PROGRAM_LOAD", { source: "G21 G90\nG0 X0 Y0 Z1\nG1 X1 F600\nG1 X0\nM2\n", path: "first.ngc" });
|
||||
send("POWER", { active: true }); send("HOME", { all: true }); send("PROGRAM_RUN");
|
||||
for (let n = 0; n < 1000 && session.task.state.state !== "STOPPED"; n++) send("SIM_STEP", { cycles: 5, response: "motion_delta" });
|
||||
assert.equal(session.task.state.state, "STOPPED");
|
||||
const path = "linuxcnc/nc_files/3D_Chips.ngc", source = await read(path);
|
||||
const ticks = Number(process.env.BENCH_TICKS ?? 200);
|
||||
const rows = [];
|
||||
for (let attempt = 1; attempt <= 2; attempt++) {
|
||||
const start = performance.now();
|
||||
const loaded = send("PROGRAM_LOAD", { source, path });
|
||||
const loadMs = performance.now() - start;
|
||||
const snapshotStart = performance.now();
|
||||
for (let sample = 0; sample < 200; sample++) session.xyzacProgram.snapshot();
|
||||
const snapshotMs = performance.now() - snapshotStart;
|
||||
send("PROGRAM_RUN");
|
||||
const runStart = performance.now();
|
||||
let result;
|
||||
const latencies = [];
|
||||
for (let tick = 0; tick < ticks; tick += 5) {
|
||||
const requestStart = performance.now();
|
||||
result = send("SIM_STEP", { cycles: 5, response: "motion_delta" });
|
||||
latencies.push(performance.now() - requestStart);
|
||||
}
|
||||
const runMs = performance.now() - runStart;
|
||||
const state = session.wasmMotion.read().state;
|
||||
rows.push({ attempt, load_ms: loadMs, snapshot_200_ms: snapshotMs, run_ms: runMs, task_ticks: ticks, simulated_ms: ticks * 10, p95_step_ms: latencies.sort((a, b) => a - b)[Math.floor(latencies.length * .95)], source_hash: loaded.program.source_hash, preview_hash: loaded.preview.preview_hash, preview_segments: loaded.preview.segments.length, final_line: result.program.current_line, pose: state.position, queue: state.queue_depth, feed: state.feed_override, velocity: state.velocity, live_epoch: loaded.panel_snapshot.visual.live_path_epoch });
|
||||
send("PROGRAM_ABORT");
|
||||
if (attempt === 1) {
|
||||
// Reset the machine position through the interpreter, preserving source
|
||||
// semantics, so the repeated load has the same physical starting point.
|
||||
send("MDI_EXECUTE", { block: "G90 G0 X0 Y0 Z1" });
|
||||
for (let tick = 0; tick < 2000 && session.wasmMotion.read().state.queue_depth; tick++) send("SIM_STEP", { cycles: 5, response: "motion_delta" });
|
||||
}
|
||||
}
|
||||
console.log(JSON.stringify({ schema: "xyzac-replacement-benchmark.v1", rows }));
|
||||
@@ -15,6 +15,9 @@ const [axisPy, axisTcl, taskSource, contract, app, worker, programCore, runtime]
|
||||
read("web/worker/wasm-program-runtime.mjs"),
|
||||
]);
|
||||
const sha256 = (value) => createHash("sha256").update(value).digest("hex");
|
||||
const glnav = await read("linuxcnc/lib/python/glnav.py");
|
||||
assert.equal(sha256(glnav), "b2d811a21954966655a40f107c2af217cc7d57bae0dc308dc4698f5859ed3f11", "glnav.py drifted from the documented source lock");
|
||||
assert.match(glnav, /def set_view_z2\(self\):[\s\S]*?glRotatef\(-90, 0, 0, 1\)[\s\S]*?self\.perspective = False/);
|
||||
const squash = (value) => value.replace(/\\\s*/g, " ").replace(/\s+/g, " ");
|
||||
const axisPySquashed = squash(axisPy);
|
||||
const axisTclSquashed = squash(axisTcl);
|
||||
@@ -60,8 +63,11 @@ for (const sourceHandler of ["PROGRAM_LOAD", "PROGRAM_RUN", "PROGRAM_RESTART"])
|
||||
const start = worker.indexOf(`case "${sourceHandler}"`);
|
||||
const next = worker.indexOf("\n case ", start + 8);
|
||||
assert.ok(start >= 0, `missing Worker handler ${sourceHandler}`);
|
||||
assert.doesNotMatch(worker.slice(start, next < 0 ? worker.length : next), /livePathEpoch\s*\+=/, `${sourceHandler} must not clear native Live Plot`);
|
||||
assert.doesNotMatch(worker.slice(start, next < 0 ? worker.length : next), /livePathEpoch\s*\+=/, `${sourceHandler} must use the shared explicit clear boundary`);
|
||||
assert.doesNotMatch(worker.slice(start, next < 0 ? worker.length : next), /clearLivePlot\(/, `${sourceHandler} must preserve the native Live Plot`);
|
||||
}
|
||||
assert.doesNotMatch(app, /clear_live_plot_on_replace/);
|
||||
assert.doesNotMatch(worker, /clear_live_plot_on_replace/);
|
||||
|
||||
for (const term of ["SOURCE_EXACT", "SOURCE_ADAPTER", "SOURCE_BLOCKED", "WEB_EXTENSION", "Source-lock manifest", "Source-exact widget enable formulas", "Exhaustive native-action ledger", "manual_ok", "ensure_mode", "open_file_guts", "EMC_TASK_PLAN_RUN", "PROGRAM_LOAD", "PROGRAM_RUN", "PROGRAM_STEP", "PROGRAM_PAUSE", "PROGRAM_RESUME", "PROGRAM_ABORT", "JOG_START", "HOME", "TOUCH_OFF", "Run after completion or Stop", "Toolpath display lifecycle", "live_path_epoch", "positionlogger", "blocked_by_abi_tool_offsets", "Button Dependency Matrix", "explicit `VISUAL_CLEAR` source adapter advances the epoch"]) assert.match(contract, new RegExp(term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")), `contract missing ${term}`);
|
||||
assert.match(contract, /Native AXIS does \*\*not\*\* bind lowercase `c` to C-axis selection/);
|
||||
|
||||
@@ -44,7 +44,8 @@ const missingIds = inventory.filter((entry) => !entry.id);
|
||||
assert.deepEqual(missingIds, [], `actionable controls without data-axis-control: ${JSON.stringify(missingIds)}`);
|
||||
const ids = inventory.map((entry) => entry.id);
|
||||
for (const id of ids) assert.ok(contract.includes(`\`${id}\``), `runtime control missing from contract: ${id}`);
|
||||
assert.equal(new Set(ids).size, ids.length, `duplicate control IDs: ${JSON.stringify(ids.filter((id, index) => ids.indexOf(id) !== index))}`);
|
||||
const uniqueIds = ids.filter((id) => id !== "mdi-history-select");
|
||||
assert.equal(new Set(uniqueIds).size, uniqueIds.length, `duplicate control IDs: ${JSON.stringify(uniqueIds.filter((id, index) => uniqueIds.indexOf(id) !== index))}`);
|
||||
for (const removed of ["Prepare T1", "Change", "Arm Probe", "Disarm", "Reload editor"]) assert.equal(inventory.some((entry) => entry.text === removed), false, `removed control still rendered: ${removed}`);
|
||||
assert.equal(await main.locator('[data-axis-control="menu-file-edit-tool-data"]').isDisabled(), true);
|
||||
assert.equal(await main.locator('[data-axis-control="menu-file-quit"]').isDisabled(), true);
|
||||
|
||||
235
scripts/test_axis_panel_source_flow_browser.mjs
Normal file
235
scripts/test_axis_panel_source_flow_browser.mjs
Normal file
@@ -0,0 +1,235 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const modulePath = process.env.PLAYWRIGHT_MODULE;
|
||||
assert.ok(modulePath, "PLAYWRIGHT_MODULE is required");
|
||||
const { chromium } = await import(pathToFileURL(modulePath).href);
|
||||
const url = process.env.XYZAC_PANEL_URL ?? "http://127.0.0.1:5174/";
|
||||
const browser = await chromium.launch({ headless: true, executablePath: process.env.BROWSER_EXECUTABLE_PATH ?? "/usr/bin/google-chrome", args: ["--disable-gpu"] });
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
|
||||
const errors = [];
|
||||
const checks = [];
|
||||
let dialogAnswers = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
page.on("dialog", (dialog) => dialogAnswers.length ? dialog.accept(dialogAnswers.shift()) : dialog.dismiss());
|
||||
await page.addInitScript(() => {
|
||||
window.axisMessages = [];
|
||||
window.axisResults = [];
|
||||
const NativeWorker = window.Worker;
|
||||
window.Worker = class extends NativeWorker {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
this.addEventListener("message", (event) => {
|
||||
const value = event.data;
|
||||
window.axisResults.push({ kind: value.kind, seq: value.seq, payload: value.payload });
|
||||
if (window.axisResults.length > 100) window.axisResults.shift();
|
||||
});
|
||||
}
|
||||
postMessage(value, ...args) {
|
||||
window.axisMessages.push({ kind: value.kind, seq: value.seq, payload: value.payload });
|
||||
super.postMessage(value, ...args);
|
||||
}
|
||||
};
|
||||
});
|
||||
const control = (id) => page.locator(`[data-axis-control="${id}"]`);
|
||||
const wait = (fn, arg) => page.waitForFunction(fn, arg, { timeout: 45000 });
|
||||
const ready = () => wait(() => document.querySelector("main")?.dataset.axisProgramLoading === "false");
|
||||
const queueEmpty = () => wait(() => document.querySelector("main")?.dataset.axisTpQueueDepth === "0");
|
||||
const messages = (kind) => page.evaluate((kind) => window.axisMessages.filter((entry) => entry.kind === kind), kind);
|
||||
const commandCount = async (kind) => (await messages(kind)).length;
|
||||
const ack = (kind, after = 0) => wait(({ kind, after }) => window.axisResults.some((result) => result.seq > after && result.kind === "COMMAND_RESULT" && window.axisMessages.some((message) => message.seq === result.seq && message.kind === kind)), { kind, after });
|
||||
const latestSeq = () => page.evaluate(() => window.axisMessages.filter((entry) => !entry.kind.startsWith("UI_STATE_")).at(-1)?.seq ?? 0);
|
||||
const clickAck = async (id, kind) => { const seq = await latestSeq(); await control(id).click(); await ack(kind, seq); };
|
||||
const pose = () => page.locator('[data-xyzac-preview="toolpath"]').evaluate((node) => JSON.parse(node.dataset.xyzacLivePose));
|
||||
const check = async (name, run) => { await run(); checks.push(name); process.stderr.write(`[source-flow] ${name}: verified\n`); };
|
||||
const hold = async (id, release, axis) => {
|
||||
const starts = await commandCount("JOG_START"), stops = await commandCount("JOG_STOP"), seq = await latestSeq();
|
||||
const before = await pose();
|
||||
await control(id).dispatchEvent("pointerdown", { pointerId: 1, button: 0 });
|
||||
await ack("JOG_START", seq);
|
||||
await page.waitForTimeout(400);
|
||||
const during = await pose();
|
||||
assert.notEqual(during[axis], before[axis], `no ${axis} motion`);
|
||||
assert.equal(await commandCount("JOG_START"), starts + 1, "sampling must not reissue JOG_START");
|
||||
await release();
|
||||
await ack("JOG_STOP", seq);
|
||||
await queueEmpty();
|
||||
const stopped = await pose();
|
||||
await page.waitForTimeout(180);
|
||||
assert.deepEqual(await pose(), stopped, "motion continued after release");
|
||||
assert.equal(await commandCount("JOG_STOP"), stops + 1, "release must stop exactly once");
|
||||
};
|
||||
const pixels = () => page.locator('[data-xyzac-preview="toolpath"] canvas').evaluate((canvas) => {
|
||||
const gl = canvas.getContext("webgl2") ?? canvas.getContext("webgl");
|
||||
if (!gl) return { colors: 0 };
|
||||
const data = new Uint8Array(canvas.width * canvas.height * 4);
|
||||
gl.readPixels(0, 0, canvas.width, canvas.height, gl.RGBA, gl.UNSIGNED_BYTE, data);
|
||||
const colors = new Set(); let hash = 2166136261;
|
||||
for (let i = 0; i < data.length; i += 16) { const value = (data[i] << 16) | (data[i + 1] << 8) | data[i + 2]; colors.add(value); hash = Math.imul(hash ^ value, 16777619); }
|
||||
return { colors: colors.size, hash, width: canvas.width, height: canvas.height };
|
||||
});
|
||||
try {
|
||||
await page.goto(url, { waitUntil: "domcontentloaded" });
|
||||
await ready();
|
||||
await check("startup-single-load", async () => assert.equal(await commandCount("PROGRAM_LOAD"), 1));
|
||||
await clickAck("toolbar-power", "POWER");
|
||||
await clickAck("manual-home-all", "HOME");
|
||||
await check("mdi-trim-clear-deduplicate-history", async () => {
|
||||
await control("tab-mdi").click();
|
||||
const input = control("mdi-command");
|
||||
const submit = async (text) => { const seq = await latestSeq(); await input.fill(text); await control("mdi-submit").click(); await ack("MDI_EXECUTE", seq); await queueEmpty(); assert.equal(await input.inputValue(), ""); };
|
||||
await submit(" G21 "); await submit("G21");
|
||||
assert.equal(await control("mdi-history-select").count(), 1);
|
||||
await submit("G90");
|
||||
assert.deepEqual(await control("mdi-history-select").allTextContents(), ["G21", "G90"]);
|
||||
assert.equal((await messages("MDI_EXECUTE"))[0].payload.block, "G21");
|
||||
await input.press("ArrowUp"); assert.equal(await input.inputValue(), "G90");
|
||||
await input.press("ArrowUp"); assert.equal(await input.inputValue(), "G21");
|
||||
await input.press("ArrowUp"); assert.equal(await input.inputValue(), "");
|
||||
await input.press("ArrowDown"); assert.equal(await input.inputValue(), "G21");
|
||||
const count = await commandCount("MDI_EXECUTE");
|
||||
await control("mdi-history-select").last().click();
|
||||
assert.equal(await commandCount("MDI_EXECUTE"), count);
|
||||
const seq = await latestSeq(); await control("mdi-history-select").last().dblclick(); await ack("MDI_EXECUTE", seq);
|
||||
assert.equal(await commandCount("MDI_EXECUTE"), count + 1);
|
||||
assert.equal(await control("mdi-history-select").count(), 2);
|
||||
});
|
||||
await control("tab-manual").click();
|
||||
await check("continuous-five-axes-both-directions", async () => {
|
||||
for (const axis of ["x", "y", "z", "a", "c"]) {
|
||||
await control(`manual-axis-${axis}`).check();
|
||||
for (const direction of ["positive", "negative"]) {
|
||||
const id = `manual-jog-${direction}`;
|
||||
await hold(id, async () => {
|
||||
await control(id).dispatchEvent("pointerup", { pointerId: 1, button: 0 });
|
||||
await control(id).dispatchEvent("pointercancel", { pointerId: 1, button: 0 });
|
||||
}, axis);
|
||||
}
|
||||
}
|
||||
});
|
||||
await control("manual-axis-x").check();
|
||||
await check("jog-blur-and-hidden-tab-stop", async () => {
|
||||
await hold("manual-jog-positive", () => page.evaluate(() => window.dispatchEvent(new Event("blur"))), "x");
|
||||
await hold("manual-jog-positive", () => control("tab-diagnostics").click(), "x");
|
||||
await control("tab-manual").click();
|
||||
});
|
||||
await check("jog-fast-release-and-keyboard-hold", async () => {
|
||||
let starts = await commandCount("JOG_START"), stops = await commandCount("JOG_STOP");
|
||||
await control("manual-jog-positive").evaluate((button) => {
|
||||
button.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, pointerId: 9, button: 0 }));
|
||||
button.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, pointerId: 9, button: 0 }));
|
||||
});
|
||||
await wait((stops) => window.axisMessages.filter((m) => m.kind === "JOG_STOP").length === stops + 1, stops);
|
||||
await queueEmpty(); assert.equal(await commandCount("JOG_START"), starts + 1);
|
||||
await control("manual-jog-positive").focus();
|
||||
starts = await commandCount("JOG_START"); stops = await commandCount("JOG_STOP");
|
||||
await page.keyboard.down("Space"); await page.keyboard.down("Space"); await page.waitForTimeout(200); await page.keyboard.up("Space");
|
||||
await wait((stops) => window.axisMessages.filter((m) => m.kind === "JOG_STOP").length === stops + 1, stops);
|
||||
await queueEmpty(); assert.equal(await commandCount("JOG_START"), starts + 1);
|
||||
});
|
||||
await check("increment-completes-after-release", async () => {
|
||||
await control("manual-jog-increment").selectOption("0.1000");
|
||||
const before = await pose(), seq = await latestSeq(), stops = await commandCount("JOG_STOP");
|
||||
await control("manual-jog-positive").press("Space"); await ack("JOG_START", seq); await queueEmpty();
|
||||
assert.ok(Math.abs((await pose()).x - before.x - 0.1) < 1e-8);
|
||||
assert.equal(await commandCount("JOG_STOP"), stops);
|
||||
await control("manual-jog-increment").selectOption("Continuous");
|
||||
});
|
||||
await check("touch-off-preview-and-relative-coordinate", async () => {
|
||||
const before = await pose();
|
||||
dialogAnswers = ["2", "P1 G54"];
|
||||
await clickAck("manual-touch-off", "TOUCH_OFF");
|
||||
assert.deepEqual(await pose(), before);
|
||||
await control("tab-dro").click(); await control("dro-coordinate").selectOption("relative");
|
||||
assert.equal(Number(await page.locator(".dro-row").first().locator("output").first().textContent()), 2);
|
||||
await control("dro-coordinate").selectOption("machine"); await control("tab-preview").click();
|
||||
dialogAnswers = [String(before.x), "P1 G54"];
|
||||
await clickAck("manual-touch-off", "TOUCH_OFF");
|
||||
});
|
||||
await check("spindle-key-toggle-and-release", async () => {
|
||||
await clickAck("manual-spindle-ccw", "SPINDLE_COMMAND");
|
||||
let seq = await latestSeq(); await page.keyboard.press("F9"); await ack("SPINDLE_COMMAND", seq);
|
||||
assert.equal((await messages("SPINDLE_COMMAND")).at(-1).payload.direction, 0);
|
||||
await clickAck("manual-spindle-cw", "SPINDLE_COMMAND");
|
||||
seq = await latestSeq(); await page.keyboard.press("F10"); await ack("SPINDLE_COMMAND", seq);
|
||||
assert.equal((await messages("SPINDLE_COMMAND")).at(-1).payload.direction, 0);
|
||||
await clickAck("manual-spindle-cw", "SPINDLE_COMMAND");
|
||||
seq = await latestSeq(); await control("manual-spindle-increase").dispatchEvent("pointerdown", { pointerId: 2, button: 0 }); await ack("SPINDLE_COMMAND", seq);
|
||||
await page.evaluate(() => window.dispatchEvent(new Event("blur"))); await ack("SPINDLE_CONSTANT", seq);
|
||||
await clickAck("manual-spindle-stop", "SPINDLE_COMMAND");
|
||||
});
|
||||
await check("paused-open-gate-and-source-key-case", async () => {
|
||||
await clickAck("toolbar-run", "PROGRAM_RUN"); await clickAck("toolbar-pause-resume", "PROGRAM_PAUSE");
|
||||
assert.ok(await control("toolbar-open").isDisabled());
|
||||
assert.ok(await control("toolbar-open").getAttribute("title"));
|
||||
const count = await commandCount("PROGRAM_LOAD");
|
||||
await page.keyboard.press("Control+r"); await page.waitForTimeout(100);
|
||||
assert.equal(await commandCount("PROGRAM_LOAD"), count);
|
||||
await clickAck("toolbar-stop", "PROGRAM_ABORT");
|
||||
await wait(() => document.querySelector("main")?.dataset.axisTaskMode === "MANUAL");
|
||||
const commands = await commandCount("PROGRAM_RUN");
|
||||
await page.keyboard.press("Control+r"); await ready();
|
||||
assert.equal(await commandCount("PROGRAM_RUN"), commands, "Ctrl-R must never become Run");
|
||||
await page.keyboard.press("R"); assert.equal(await commandCount("PROGRAM_RUN"), commands);
|
||||
});
|
||||
await check("repeat-step-and-restore-tab-mode", async () => {
|
||||
const input = control("program-file-input");
|
||||
const seq = await latestSeq();
|
||||
await input.setInputFiles({ name: "source-step.ngc", mimeType: "text/plain", buffer: Buffer.from("G21 G90\nG1 X2 F600\nG1 X3\nM2\n") });
|
||||
await ack("PROGRAM_LOAD", seq); await ready();
|
||||
const x = (await pose()).x;
|
||||
await clickAck("toolbar-step", "PROGRAM_STEP");
|
||||
assert.equal((await pose()).x, x);
|
||||
await clickAck("toolbar-step", "PROGRAM_STEP");
|
||||
await clickAck("toolbar-step", "PROGRAM_STEP");
|
||||
await clickAck("toolbar-step", "PROGRAM_STEP"); await queueEmpty();
|
||||
assert.ok(Math.abs((await pose()).x - 2) < 1e-7);
|
||||
await clickAck("toolbar-step", "PROGRAM_STEP"); await clickAck("toolbar-step", "PROGRAM_STEP"); await queueEmpty();
|
||||
assert.ok(Math.abs((await pose()).x - 3) < 1e-7);
|
||||
await clickAck("toolbar-stop", "PROGRAM_ABORT");
|
||||
await wait(() => document.querySelector("main")?.dataset.axisTaskMode === "MANUAL");
|
||||
});
|
||||
await check("about-modal-and-estop-from-editor", async () => {
|
||||
await control("menu-help").click(); await control("menu-help-about").click();
|
||||
assert.equal(await page.evaluate(() => document.activeElement?.getAttribute("data-axis-control")), "about-close");
|
||||
const runs = await commandCount("PROGRAM_RUN"); await page.keyboard.press("r"); assert.equal(await commandCount("PROGRAM_RUN"), runs);
|
||||
await page.keyboard.press("Escape"); assert.equal(await control("about-close").count(), 0);
|
||||
await control("program-editor").focus(); const seq = await latestSeq(); await page.keyboard.press("F1"); await ack("ESTOP", seq);
|
||||
await wait(() => document.querySelector("[data-axis-state-machine]")?.dataset.axisStateMachine === "ESTOP");
|
||||
assert.ok(await control("toolbar-power").isDisabled());
|
||||
await clickAck("toolbar-estop", "ESTOP"); await clickAck("toolbar-power", "POWER");
|
||||
});
|
||||
await check("desktop-mobile-canvas-and-layout", async () => {
|
||||
for (const viewport of [{ width: 1440, height: 1000 }, { width: 390, height: 844 }]) {
|
||||
await page.setViewportSize(viewport); await control("tab-preview").click(); await control("toolbar-view-p").click(); await page.waitForTimeout(200);
|
||||
for (const preset of ["z", "z2", "x", "y", "y2", "p"]) {
|
||||
await control(`toolbar-view-${preset}`).click();
|
||||
await wait((preset) => document.querySelector("[data-xyzac-preview]")?.dataset.xyzacProjection === (preset === "p" ? "perspective" : "orthographic"), preset);
|
||||
}
|
||||
const before = await pixels(); assert.ok(before.colors > 20, JSON.stringify(before));
|
||||
await control("toolbar-rotate").click(); await page.waitForTimeout(250);
|
||||
const stationary = await pixels(); await page.waitForTimeout(250);
|
||||
assert.equal((await pixels()).hash, stationary.hash, "rotate mode must not animate the camera");
|
||||
await control("preview-layer-velocity").check();
|
||||
await wait(() => document.querySelector(".axis-preview-hud")?.textContent.includes("mm/min"));
|
||||
await control("preview-layer-velocity").uncheck();
|
||||
await control("preview-layer-limits").check();
|
||||
await wait(() => document.querySelector("[data-xyzac-preview]")?.dataset.xyzacLimitsVisible === "true");
|
||||
await control("preview-layer-limits").uncheck();
|
||||
await control("preview-layer-extents").check();
|
||||
await wait(() => document.querySelector("[data-xyzac-preview]")?.dataset.xyzacExtentsVisible === "true");
|
||||
await control("preview-layer-extents").uncheck();
|
||||
await control("toolbar-view-x").click(); await page.waitForTimeout(200);
|
||||
const after = await pixels(); assert.notEqual(after.hash, before.hash, "view did not change canvas");
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1), true, "horizontal page overflow");
|
||||
assert.equal(await page.locator("img").evaluateAll((images) => images.some((image) => !image.complete || image.naturalWidth === 0)), false);
|
||||
await page.screenshot({ path: `/tmp/axis-source-flow-${viewport.width}.png`, fullPage: true });
|
||||
}
|
||||
});
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(JSON.stringify({ schema: "axis-panel-source-flow-browser.v1", status: "verified", url, checks, browser_errors: errors }));
|
||||
} catch (error) {
|
||||
process.stderr.write(JSON.stringify(await page.evaluate(() => ({ messages: window.axisMessages.slice(-12), results: window.axisResults.slice(-3).map((entry) => ({ kind: entry.kind, seq: entry.seq, status: entry.payload?.status, reason: entry.payload?.gate_reason ?? entry.payload?.message_key })) }))) + "\n");
|
||||
await page.screenshot({ path: "/tmp/axis-source-flow-failure.png", fullPage: true });
|
||||
throw error;
|
||||
} finally { await browser.close(); }
|
||||
70
scripts/test_axis_source_ui_oracle.mjs
Normal file
70
scripts/test_axis_source_ui_oracle.mjs
Normal file
@@ -0,0 +1,70 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { axisSourceUi } from "../web/worker/axis-source-ui.mjs";
|
||||
|
||||
const source = await readFile(new URL("../linuxcnc/share/axis/tcl/axis.tcl", import.meta.url), "utf8");
|
||||
const start = source.indexOf("proc update_state {args} {");
|
||||
const end = source.indexOf("\nproc set_mode_from_tab", start);
|
||||
assert.ok(start >= 0 && end > start);
|
||||
const formulas = source.slice(start, end);
|
||||
const scenarios = [];
|
||||
for (const task_state of ["STATE_ESTOP", "STATE_ESTOP_RESET", "STATE_ON"])
|
||||
for (const interp_state of ["INTERP_IDLE", "INTERP_READING", "INTERP_PAUSED", "INTERP_WAITING"])
|
||||
for (const taskfile of [false, true])
|
||||
for (const queued_mdi_commands of [0, 10])
|
||||
for (const motion_mode of ["FREE", "TELEOP"])
|
||||
for (const kinematics_type of ["BOTH", "IDENTITY"])
|
||||
for (const spindle_direction of [0, -1, 1]) scenarios.push({ task_state, interp_state, taskfile, queued_mdi_commands, motion_mode, kinematics_type, spindle_direction, tool: taskfile ? 1 : 0, paused: interp_state === "INTERP_PAUSED" });
|
||||
|
||||
const widgetMap = { power: ".toolbar.machine_power", load: ".toolbar.file_open", reload: ".toolbar.reload", run: ".toolbar.program_run", step: ".toolbar.program_step", pauseResume: ".toolbar.program_pause", abort: ".toolbar.program_stop", manual: "manualgroup", mdi: "mdigroup", spindleAdjust: ".manual.spindlef.spindleminus", touchOff: ".manual.jogf.zerohome.zero", toolTouchOff: ".manual.jogf.zerohome.tooltouch", overrideLimits: ".manual.jogf.override" };
|
||||
const prelude = `
|
||||
proc _ {x} { return $x }
|
||||
proc relief {condition args} {}
|
||||
proc state {condition args} { foreach widget $args { set ::result($widget) [uplevel #0 [list expr $condition]] } }
|
||||
proc enable_group {group} { set ::result($group) 1 }
|
||||
proc disable_group {group} { set ::result($group) 0 }
|
||||
proc set_mode_from_tab {} {}
|
||||
proc unknown {name args} { if {[lindex $args 0] eq "configure"} { set ::result($name) [expr {[lindex $args 2] eq "normal"}] }; return "" }
|
||||
foreach name {STATE_ESTOP STATE_ESTOP_RESET STATE_ON INTERP_IDLE INTERP_READING INTERP_PAUSED INTERP_WAITING TRAJ_MODE_FREE TRAJ_MODE_TELEOP KINEMATICS_IDENTITY KINEMATICS_BOTH TASK_MODE_AUTO} { set ::$name $name }
|
||||
set ::_tabs_manual .manual
|
||||
set ::manualgroup manualgroup
|
||||
set ::mdigroup mdigroup
|
||||
set ::has_editor 1
|
||||
set ::has_ladder 0
|
||||
set ::highlight_line -1
|
||||
set ::task_mode MANUAL
|
||||
set ::coord_type 0
|
||||
set ::display_type 0
|
||||
set ::block_delete 0
|
||||
set ::optional_stop 0
|
||||
set ::last_interp_state INTERP_IDLE
|
||||
set ::last_task_state STATE_ON
|
||||
set ::on_any_limit 0
|
||||
set ::max_queued_mdi_commands 10
|
||||
${formulas}
|
||||
`;
|
||||
const script = prelude + scenarios.map((s) => `
|
||||
set ::task_state ${s.task_state}
|
||||
set ::interp_state ${s.interp_state}
|
||||
set ::taskfile ${s.taskfile ? "program.ngc" : '""'}
|
||||
set ::queued_mdi_commands ${s.queued_mdi_commands}
|
||||
set ::motion_mode TRAJ_MODE_${s.motion_mode}
|
||||
set ::kinematics_type KINEMATICS_${s.kinematics_type}
|
||||
set ::spindledir ${s.spindle_direction}
|
||||
set ::tool ${s.tool || '"No tool"'}
|
||||
set ::interp_pause ${Number(s.paused)}
|
||||
update_state
|
||||
puts [list ${Object.values(widgetMap).map((key) => `$::result(${key})`).join(" ")}]
|
||||
`).join("\n");
|
||||
const result = spawnSync("tclsh", [], { input: script, encoding: "utf8", maxBuffer: 4 * 1024 * 1024 });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(result.stderr, "");
|
||||
const rows = result.stdout.trim().split("\n");
|
||||
assert.equal(rows.length, scenarios.length);
|
||||
for (const [index, row] of rows.entries()) {
|
||||
const expected = row.split(" ").map(Number);
|
||||
const actual = axisSourceUi(scenarios[index]).gates;
|
||||
Object.keys(widgetMap).forEach((name, column) => assert.equal(Number(actual[name].enabled), expected[column], `${name}: ${JSON.stringify(scenarios[index])}`));
|
||||
}
|
||||
console.log(JSON.stringify({ schema: "axis-source-ui-oracle.v1", status: "verified", source: "axis.tcl::update_state", scenarios: scenarios.length, predicates: Object.keys(widgetMap).length, assertions: scenarios.length * Object.keys(widgetMap).length }));
|
||||
46
scripts/test_xyzac_chips_layout_browser.mjs
Normal file
46
scripts/test_xyzac_chips_layout_browser.mjs
Normal file
@@ -0,0 +1,46 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { pathToFileURL } from "node:url";
|
||||
const { chromium } = await import(pathToFileURL(process.env.PLAYWRIGHT_MODULE).href);
|
||||
const browser = await chromium.launch({ executablePath: "/usr/bin/google-chrome", headless: true, args: ["--disable-gpu"] });
|
||||
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
|
||||
const errors = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
const button = (id) => page.locator(`[data-axis-control="${id}"]`);
|
||||
try {
|
||||
await page.goto(process.env.XYZAC_PANEL_URL ?? "http://127.0.0.1:5175/");
|
||||
await page.waitForFunction(() => document.querySelector("main")?.dataset.axisProgramLoading === "false");
|
||||
await button("workspace-load-3d-chips").click();
|
||||
await page.waitForFunction(() => document.querySelector("main")?.dataset.axisProgramPath === "linuxcnc/nc_files/3D_Chips.ngc" && document.querySelector("main")?.dataset.axisProgramLoading === "false");
|
||||
const dimensions = [];
|
||||
for (const width of [390, 768, 1440]) {
|
||||
await page.setViewportSize({ width, height: width === 1440 ? 1000 : 844 });
|
||||
await page.waitForTimeout(150);
|
||||
const size = await page.evaluate(() => {
|
||||
const editor = document.querySelector("#axis-program");
|
||||
return { width: innerWidth, pageWidth: document.documentElement.scrollWidth, pageHeight: document.documentElement.scrollHeight, editorHeight: editor.clientHeight, editorScrollHeight: editor.scrollHeight, renderedLines: document.querySelectorAll(".program-gutter span").length };
|
||||
});
|
||||
assert.ok(size.pageWidth <= width + 1 && size.pageHeight < 5000, JSON.stringify(size));
|
||||
assert.ok(size.editorHeight > 180 && size.editorHeight < 700 && size.editorScrollHeight > 80000, JSON.stringify(size));
|
||||
assert.ok(size.renderedLines < 100, JSON.stringify(size));
|
||||
await button("program-editor").evaluate((editor) => { editor.scrollTop = editor.scrollHeight; editor.dispatchEvent(new Event("scroll")); });
|
||||
await page.waitForTimeout(100);
|
||||
assert.ok((await page.locator(".program-gutter span").last().textContent()).includes("4712"));
|
||||
await button("program-editor").evaluate((editor) => { editor.scrollTop = 0; editor.dispatchEvent(new Event("scroll")); });
|
||||
dimensions.push(size);
|
||||
}
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await button("toolbar-power").click(); await button("manual-home-all").click(); await button("toolbar-run").click();
|
||||
await page.waitForFunction(() => document.querySelector("main")?.dataset.axisProgramStatus === "running");
|
||||
const tick = () => page.locator("[data-xyzac-preview]").getAttribute("data-xyzac-tick").then(Number);
|
||||
const start = await tick(); const wall = performance.now();
|
||||
await page.waitForTimeout(3000);
|
||||
const ratio = (await tick() - start) * 10 / (performance.now() - wall);
|
||||
assert.ok(ratio > .75, `mobile simulation ratio ${ratio}`);
|
||||
await button("toolbar-stop").click();
|
||||
await page.waitForFunction(() => document.querySelector("main")?.dataset.axisProgramStatus === "aborted");
|
||||
await button("toolbar-view-p").click();
|
||||
await page.waitForTimeout(200);
|
||||
await page.screenshot({ path: "/tmp/axis-chips-replacement-390.png", fullPage: true });
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(JSON.stringify({ schema: "xyzac-chips-layout.v1", status: "verified", dimensions, mobile_simulation_ratio: ratio, browser_errors: errors }));
|
||||
} finally { await browser.close(); }
|
||||
@@ -88,19 +88,23 @@ const stopped = await wait((value) => value.operation === "Operation IDLE" && va
|
||||
if (stopped.epoch !== running.epoch || stopped.liveCount < pausedStable.liveCount || stopped.previewCount !== loadedLong.previewCount) throw new Error(`stop_cleared_path:${JSON.stringify(stopped)}`);
|
||||
|
||||
const loadedShort = await load("live-short.ngc", shortSource);
|
||||
if (loadedShort.epoch <= stopped.epoch || loadedShort.liveCount !== 0) throw new Error(`load_did_not_clear_live_path:${JSON.stringify({ stopped, loadedShort })}`);
|
||||
if (loadedShort.epoch !== stopped.epoch || loadedShort.liveCount < stopped.liveCount) throw new Error(`open_cleared_native_live_path:${JSON.stringify({ stopped, loadedShort })}`);
|
||||
await click(main.locator('button[aria-label="Begin executing current file [R]"]'), "run_short");
|
||||
const completed = await wait((value) => value.program === "completed" && value.operation === "Operation IDLE" && value.liveCount >= 2, "short_completed");
|
||||
await page.waitForTimeout(1000);
|
||||
const completedStable = await state();
|
||||
if (completedStable.epoch !== completed.epoch || completedStable.liveCount < completed.liveCount || completedStable.previewCount !== loadedShort.previewCount) throw new Error(`completion_cleared_path:${JSON.stringify({ completed, completedStable })}`);
|
||||
await click(main.locator('[data-axis-control="toolbar-reload"]'), "reload_same_file");
|
||||
await wait((value) => value.program === "loaded" && value.loading === "false", "same_file_reloaded");
|
||||
const reloaded = await state();
|
||||
if (reloaded.epoch !== completedStable.epoch || reloaded.liveCount < completedStable.liveCount) throw new Error(`reload_cleared_native_live_path:${JSON.stringify(reloaded)}`);
|
||||
|
||||
await click(main.locator('button[aria-label="Begin executing current file [R]"]'), "rerun_short");
|
||||
const rerun = await wait((value) => value.epoch > completedStable.epoch && value.liveCount < completedStable.liveCount, "rerun_new_epoch");
|
||||
const rerun = await wait((value) => value.program === "running" && value.epoch === completedStable.epoch && value.liveCount >= completedStable.liveCount, "rerun_retains_native_epoch");
|
||||
await wait((value) => value.program === "completed" && value.operation === "Operation IDLE", "rerun_completed");
|
||||
|
||||
const replacement = await load("live-replacement.ngc", replacementSource);
|
||||
if (replacement.epoch <= rerun.epoch || replacement.liveCount !== 0 || replacement.previewCount !== 1) throw new Error(`replacement_lifecycle_invalid:${JSON.stringify({ rerun, replacement })}`);
|
||||
if (replacement.epoch !== rerun.epoch || replacement.liveCount < rerun.liveCount || replacement.previewCount !== 1) throw new Error(`replacement_lifecycle_invalid:${JSON.stringify({ rerun, replacement })}`);
|
||||
await click(main.locator('button[aria-label="Begin executing current file [R]"]'), "run_replacement");
|
||||
await wait((value) => value.liveCount >= 1 && Number(value.line) > 0, "replacement_trace");
|
||||
await wait((value) => value.operation === "Operation IDLE", "replacement_idle");
|
||||
@@ -109,6 +113,15 @@ await click(main.locator('button[aria-label="Clear live plot [Ctrl-K]"]'), "clea
|
||||
const cleared = await wait((value) => value.epoch > beforeClear.epoch && value.liveCount <= 1, "cleared_live_plot");
|
||||
if (cleared.previewCount !== beforeClear.previewCount) throw new Error(`clear_removed_static_preview:${JSON.stringify({ beforeClear, cleared })}`);
|
||||
|
||||
await load("live-key-clear.ngc", shortSource);
|
||||
await click(main.locator('[data-axis-control="toolbar-run"]'), "run_for_keyboard_clear");
|
||||
await wait((value) => value.operation === "Operation IDLE" && value.program === "completed" && value.liveCount > 2, "regenerate_live_trace");
|
||||
const beforeKeyClear = await state();
|
||||
await page.evaluate(() => document.activeElement?.blur());
|
||||
await page.keyboard.press("Control+k");
|
||||
const keyCleared = await wait((value) => value.epoch === beforeKeyClear.epoch + 1 && value.liveCount === 0, "ctrl_k_clears_live_trace");
|
||||
if (keyCleared.previewCount !== beforeKeyClear.previewCount || keyCleared.queue !== beforeKeyClear.queue || keyCleared.program !== beforeKeyClear.program) throw new Error("ctrl_k_changed_program_or_queue");
|
||||
|
||||
const desktopPixels = await canvasPixels(page);
|
||||
if (desktopPixels.changed < 100) throw new Error(`desktop_canvas_blank:${JSON.stringify(desktopPixels)}`);
|
||||
await page.screenshot({ path: "/tmp/xyzac-live-path-lifecycle-desktop.png", fullPage: true });
|
||||
@@ -118,5 +131,6 @@ const mobilePixels = await canvasPixels(page);
|
||||
if (mobilePixels.changed < 100) throw new Error(`mobile_canvas_blank:${JSON.stringify(mobilePixels)}`);
|
||||
await page.screenshot({ path: "/tmp/xyzac-live-path-lifecycle-mobile.png", fullPage: true });
|
||||
|
||||
console.log(JSON.stringify({ schema: "xyzac-live-path-lifecycle-browser.v1", status: browserErrors.length ? "failed" : "verified", lifecycle: { loadedLong, running, paused: pausedStable, resumed, stopped, loadedShort, completed: completedStable, rerun, replacement, beforeClear, cleared }, canvas_pixels: { desktop: desktopPixels, mobile: mobilePixels }, browser_errors: browserErrors, screenshots: ["/tmp/xyzac-live-path-lifecycle-desktop.png", "/tmp/xyzac-live-path-lifecycle-mobile.png"] }));
|
||||
console.log(JSON.stringify({ schema: "xyzac-live-path-lifecycle-browser.v1", status: browserErrors.length ? "failed" : "verified", lifecycle: { loadedLong, running, paused: pausedStable, resumed, stopped, loadedShort, completed: completedStable, reloaded, rerun, replacement, beforeClear, cleared, beforeKeyClear, keyCleared }, canvas_pixels: { desktop: desktopPixels, mobile: mobilePixels }, browser_errors: browserErrors, screenshots: ["/tmp/xyzac-live-path-lifecycle-desktop.png", "/tmp/xyzac-live-path-lifecycle-mobile.png"] }));
|
||||
if (browserErrors.length) process.exitCode = 1;
|
||||
await browser.close();
|
||||
|
||||
@@ -113,10 +113,8 @@ try {
|
||||
results.push(await record("VISUAL CLEAR", async () => { await click(main.locator('button[aria-label="Clear live plot [Ctrl-K]"]'), "visual_clear"); await wait((s) => s.error === null, "visual_clear_ack"); }));
|
||||
results.push(await record("MANUAL MODE", async () => { await main.getByRole("tab", { name: "Manual Control [F3]" }).click(); await wait((s) => s.mode === "MANUAL" || s.command?.includes("SET_MODE acknowledged"), "manual_mode_ack"); }));
|
||||
results.push(await record("HOME/TOUCH OFF boundaries", async () => {
|
||||
const dialogCount = dialogs.length;
|
||||
await click(main.locator('button[data-axis-control="manual-home-axis"]'), "home_axis_repeat");
|
||||
await page.waitForTimeout(100);
|
||||
if (dialogs.length !== dialogCount + 1 || !dialogs.at(-1)?.message.includes("already homed")) throw new Error("home_axis_confirmation_missing");
|
||||
await wait((s) => s.command?.includes("home_joint_requires_joint_mode"), "world_axis_home_source_rejection");
|
||||
await click(main.locator('button[data-axis-control="manual-touch-off"]'), "touch_off");
|
||||
await wait((s) => s.error === null && s.command?.includes("TOUCH_OFF acknowledged"), "touch_off_ack");
|
||||
if (!await main.locator('[data-axis-control="manual-tool-touch-off"]').isDisabled()) throw new Error("tool_touch_off_must_be_disabled");
|
||||
@@ -138,7 +136,7 @@ try {
|
||||
results.push(await record("SPINDLE", async () => {
|
||||
await click(main.locator('button[aria-label="Spindle clockwise"]'), "spindle_cw");
|
||||
await wait((s) => s.error === null && s.command?.includes("SPINDLE_COMMAND acknowledged"), "spindle_cw_ack");
|
||||
for (const id of ["manual-spindle-decrease", "manual-spindle-increase"]) {
|
||||
for (const id of ["manual-spindle-increase", "manual-spindle-decrease"]) {
|
||||
const adjust = main.locator(`button[data-axis-control="${id}"]`);
|
||||
await adjust.dispatchEvent("pointerdown", { pointerId: 1, button: 0 });
|
||||
await wait((s) => s.error === null && s.command?.includes("SPINDLE_COMMAND acknowledged"), `${id}_press_ack`);
|
||||
@@ -215,6 +213,30 @@ try {
|
||||
results.push(await record("DISABLED unsupported buttons", async () => {
|
||||
for (const name of ["Toggle skip lines with slash", "Toggle optional pause"]) if (!await main.locator(`button[aria-label="${name}"]`).isDisabled()) throw new Error(`${name}:must_be_disabled`);
|
||||
}));
|
||||
results.push(await record("MENUS, ABOUT and CASE SELECTION", async () => {
|
||||
await main.locator('[data-axis-control="workspace-case-select"]').selectOption({ index: 0 });
|
||||
await main.locator('[data-axis-control="menu-file"]').click();
|
||||
for (const id of ["menu-file-edit-tool-data", "menu-file-quit"]) {
|
||||
const button = main.locator(`[data-axis-control="${id}"]`);
|
||||
if (!await button.isDisabled() || !await button.getAttribute("title")) throw new Error(`${id}:missing_boundary`);
|
||||
}
|
||||
await main.locator('[data-axis-control="menu-help"]').click();
|
||||
await main.locator('[data-axis-control="menu-help-about"]').click();
|
||||
await main.locator('[data-axis-control="about-close"]').click();
|
||||
await main.locator('[data-axis-control="tab-diagnostics"]').click();
|
||||
await main.locator('[data-axis-control="tab-manual"]').click();
|
||||
}));
|
||||
results.push(await record("POWER OFF and ESTOP recovery", async () => {
|
||||
await click(main.locator('[data-axis-control="toolbar-power"]'), "power_off");
|
||||
await wait((s) => s.machine !== "Machine ON", "power_off_ack");
|
||||
await click(main.locator('[data-axis-control="toolbar-estop"]'), "estop");
|
||||
await wait((s) => s.machine === "Machine ESTOP", "estop_ack");
|
||||
if (!await main.locator('[data-axis-control="toolbar-power"]').isDisabled()) throw new Error("power_enabled_during_estop");
|
||||
await click(main.locator('[data-axis-control="toolbar-estop"]'), "release_estop");
|
||||
await wait((s) => s.machine !== "Machine ESTOP", "release_estop_ack");
|
||||
await click(main.locator('[data-axis-control="toolbar-power"]'), "power_restore");
|
||||
await wait((s) => s.machine === "Machine ON", "power_restore_ack");
|
||||
}));
|
||||
} finally {
|
||||
const buttons = await main.locator("button").evaluateAll((items) => items.map((button) => ({ text: button.textContent?.trim() ?? "", aria: button.getAttribute("aria-label"), disabled: button.disabled })));
|
||||
const visibleLabels = buttons.map((button) => button.aria ?? button.text).filter(Boolean);
|
||||
@@ -222,5 +244,6 @@ try {
|
||||
for (const [label, count] of duplicateLabels) if (["Emergency Stop [F1]", "Power on [F2]", "Home all axes", "Run program", "Clear plot", "Stop Jog", "Stop Spindle"].includes(label)) failures.push({ name: "duplicate_visible_control", result: "failed", reason: `${label}:${count}` });
|
||||
await page.screenshot({ path: "/tmp/xyzac-panel-buttons-browser.png", fullPage: true });
|
||||
console.log(JSON.stringify({ schema: "xyzac-panel-buttons-browser.v1", status: failures.length === 0 && browserErrors.length === 0 ? "verified" : "failed", url, results, button_count: buttons.length, disabled_button_count: buttons.filter((button) => button.disabled).length, duplicate_labels: duplicateLabels, failures, browser_errors: browserErrors, final_state: await state(), screenshot: "/tmp/xyzac-panel-buttons-browser.png" }));
|
||||
if (failures.length || browserErrors.length) process.exitCode = 1;
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
122
scripts/test_xyzac_pause_resume_browser.mjs
Normal file
122
scripts/test_xyzac_pause_resume_browser.mjs
Normal file
@@ -0,0 +1,122 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
assert.ok(process.env.PLAYWRIGHT_MODULE, "PLAYWRIGHT_MODULE is required");
|
||||
const { chromium } = await import(pathToFileURL(process.env.PLAYWRIGHT_MODULE).href);
|
||||
const url = process.env.XYZAC_PANEL_URL ?? "http://127.0.0.1:5175/";
|
||||
const browser = await chromium.launch({ executablePath: "/usr/bin/google-chrome", headless: true, args: ["--disable-gpu"] });
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
|
||||
const browserErrors = [], checks = [];
|
||||
page.on("pageerror", (error) => browserErrors.push(error.message));
|
||||
await page.addInitScript(() => {
|
||||
window.axisPauseRequests = []; window.axisPauseResults = [];
|
||||
const NativeWorker = window.Worker;
|
||||
window.Worker = class extends NativeWorker {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
this.addEventListener("message", ({ data }) => window.axisPauseResults.push({ seq: data.seq, kind: data.kind, status: data.payload?.status, error: data.payload?.gate_reason ?? data.payload?.message_key ?? data.payload?.code }));
|
||||
}
|
||||
postMessage(data, ...args) {
|
||||
if (!data.kind.startsWith("UI_STATE_")) window.axisPauseRequests.push({ seq: data.seq, kind: data.kind });
|
||||
super.postMessage(data, ...args);
|
||||
}
|
||||
};
|
||||
});
|
||||
const control = (id) => page.locator(`[data-axis-control="${id}"]`);
|
||||
const state = () => page.locator("main.axis-app").evaluate((main) => {
|
||||
const viewer = main.querySelector("[data-xyzac-preview]");
|
||||
return { loading: main.dataset.axisProgramLoading, program: main.dataset.axisProgramStatus, path: main.dataset.axisProgramPath,
|
||||
mode: main.dataset.axisTaskMode, operation: main.querySelector("[data-axis-state-operation]")?.dataset.axisStateOperation,
|
||||
pose: viewer?.dataset.xyzacLivePose, tick: Number(viewer?.dataset.xyzacTick), epoch: viewer?.dataset.xyzacLivePathEpoch,
|
||||
queue: Number(main.dataset.axisTpQueueDepth), command: main.querySelector(".command-status")?.textContent,
|
||||
error: main.querySelector(".diagnostic-error")?.textContent };
|
||||
});
|
||||
const wait = async (predicate, name, timeout = 30000) => {
|
||||
const start = performance.now(); let latest;
|
||||
while (performance.now() - start < timeout) { latest = await state(); if (predicate(latest)) return latest; await page.waitForTimeout(50); }
|
||||
throw new Error(`${name}: ${JSON.stringify(latest)}`);
|
||||
};
|
||||
const action = async (id, kind) => {
|
||||
const seq = await page.evaluate(() => window.axisPauseRequests.at(-1)?.seq ?? 0);
|
||||
await control(id).click();
|
||||
await page.waitForFunction(({ seq, kind }) => window.axisPauseRequests.some((request) => request.seq > seq && request.kind === kind && window.axisPauseResults.some((r) => r.seq === request.seq && r.kind === "COMMAND_RESULT")), { seq, kind });
|
||||
};
|
||||
const clearFocus = () => page.evaluate(() => { document.activeElement?.blur(); });
|
||||
const moveAfterResume = async (paused) => {
|
||||
await action("toolbar-pause-resume", "PROGRAM_RESUME");
|
||||
const resumed = await wait((s) => s.operation === "RUNNING" && s.tick >= paused.tick + 10 && s.pose !== paused.pose, "resume must move");
|
||||
await page.waitForTimeout(250);
|
||||
assert.ok((await state()).tick > resumed.tick, "sampling stopped after one resume frame");
|
||||
return resumed;
|
||||
};
|
||||
try {
|
||||
await page.goto(url);
|
||||
await wait((s) => s.loading === "false" && s.program === "loaded", "startup");
|
||||
await action("toolbar-power", "POWER"); await action("manual-home-all", "HOME");
|
||||
for (const program of ["configured", "chips"]) {
|
||||
if (program === "chips") {
|
||||
await action("workspace-load-3d-chips", "PROGRAM_LOAD");
|
||||
await wait((s) => s.path === "linuxcnc/nc_files/3D_Chips.ngc" && s.loading === "false", "chips load");
|
||||
}
|
||||
await action("toolbar-run", "PROGRAM_RUN");
|
||||
await wait((s) => s.operation === "RUNNING" && s.queue > 0, "running");
|
||||
for (let round = 0; round < 3; round++) {
|
||||
await action("toolbar-pause-resume", "PROGRAM_PAUSE");
|
||||
const paused = await wait((s) => s.operation === "PAUSED", "paused");
|
||||
await action("toolbar-clear-live-plot", "VISUAL_CLEAR");
|
||||
for (const id of ["override-spindle", "override-max-velocity", "override-feed", "override-jog-speed"]) {
|
||||
await control(id).focus(); await page.keyboard.press("ArrowLeft"); await page.waitForTimeout(150);
|
||||
await page.keyboard.press("ArrowRight"); await page.waitForTimeout(150);
|
||||
}
|
||||
await page.waitForTimeout(150);
|
||||
assert.equal((await state()).pose, paused.pose, "a paused control changed position");
|
||||
assert.ok(await control("toolbar-pause-resume").isEnabled());
|
||||
await moveAfterResume(paused);
|
||||
}
|
||||
await clearFocus();
|
||||
// A stale/duplicate Resume is a task rejection, not a connection failure.
|
||||
await page.keyboard.press("s");
|
||||
await wait((s) => s.command?.includes("PROGRAM_RESUME blocked"), "rejected running resume");
|
||||
const refused = await state();
|
||||
await wait((s) => s.tick > refused.tick + 5, "refused command must not stop a running task");
|
||||
await action("toolbar-pause-resume", "PROGRAM_PAUSE");
|
||||
const paused = await state();
|
||||
await clearFocus(); await page.keyboard.press("p");
|
||||
await wait((s) => s.command?.includes("PROGRAM_PAUSE blocked"), "duplicate pause rejection");
|
||||
await moveAfterResume(paused);
|
||||
await action("toolbar-stop", "PROGRAM_ABORT");
|
||||
await wait((s) => s.operation === "IDLE" && s.queue === 0, "stop after resume");
|
||||
checks.push(`${program}: repeated pause/clear/overrides/resume + rejection/stop`);
|
||||
}
|
||||
await action("toolbar-run", "PROGRAM_RUN");
|
||||
await wait((s) => s.queue > 0, "step precondition");
|
||||
await action("toolbar-pause-resume", "PROGRAM_PAUSE");
|
||||
const beforeStep = await state();
|
||||
await action("toolbar-step", "PROGRAM_STEP");
|
||||
await wait((s) => s.pose !== beforeStep.pose, "paused Step moves");
|
||||
const afterStep = await state();
|
||||
await moveAfterResume(afterStep);
|
||||
await action("toolbar-stop", "PROGRAM_ABORT");
|
||||
checks.push("paused Step then Resume then Stop");
|
||||
const requests = await page.evaluate(() => window.axisPauseRequests);
|
||||
requests.forEach((request, index) => assert.equal(request.seq, index + 1));
|
||||
const errors = await page.evaluate(() => window.axisPauseResults.filter((r) => r.kind === "ERROR"));
|
||||
assert.equal(errors.length, 4, JSON.stringify(errors));
|
||||
assert.ok(errors.every((r) => !r.error?.startsWith("protocol.")), JSON.stringify(errors));
|
||||
assert.deepEqual(browserErrors, []);
|
||||
await page.screenshot({ path: "/tmp/axis-pause-resume-desktop.png", fullPage: true });
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await action("toolbar-run", "PROGRAM_RUN");
|
||||
await wait((s) => s.queue > 0, "mobile running");
|
||||
await action("toolbar-pause-resume", "PROGRAM_PAUSE");
|
||||
const mobilePaused = await state();
|
||||
await action("toolbar-clear-live-plot", "VISUAL_CLEAR");
|
||||
await moveAfterResume(mobilePaused);
|
||||
await action("toolbar-stop", "PROGRAM_ABORT");
|
||||
await page.screenshot({ path: "/tmp/axis-pause-resume-mobile.png", fullPage: true });
|
||||
checks.push("mobile pause/clear/resume/stop");
|
||||
console.log(JSON.stringify({ schema: "xyzac-pause-resume-browser.v1", status: "verified", url, checks, ordered_commands: requests.length, expected_task_rejections: errors.length, browser_errors: browserErrors }));
|
||||
} catch (error) {
|
||||
process.stderr.write(JSON.stringify({ state: await state(), recent: await page.evaluate(() => ({ requests: window.axisPauseRequests.slice(-8), results: window.axisPauseResults.slice(-8) })) }) + "\n");
|
||||
throw error;
|
||||
} finally { await browser.close(); }
|
||||
120
scripts/test_xyzac_second_program_browser.mjs
Normal file
120
scripts/test_xyzac_second_program_browser.mjs
Normal file
@@ -0,0 +1,120 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
assert.ok(process.env.PLAYWRIGHT_MODULE, "PLAYWRIGHT_MODULE is required");
|
||||
const { chromium } = await import(pathToFileURL(process.env.PLAYWRIGHT_MODULE).href);
|
||||
const url = process.env.XYZAC_PANEL_URL ?? "http://127.0.0.1:5175/";
|
||||
const browser = await chromium.launch({ headless: true, executablePath: process.env.BROWSER_EXECUTABLE_PATH ?? "/usr/bin/google-chrome", args: ["--disable-gpu"] });
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
|
||||
const errors = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
await page.addInitScript(() => {
|
||||
const NativeWorker = window.Worker;
|
||||
window.stepTimes = [];
|
||||
window.workerErrors = [];
|
||||
window.Worker = class extends NativeWorker {
|
||||
pending = new Map();
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
this.addEventListener("message", (event) => {
|
||||
const value = event.data, request = this.pending.get(value.seq);
|
||||
if (request?.kind === "SIM_STEP") window.stepTimes.push(performance.now() - request.time);
|
||||
if (value.kind === "ERROR") window.workerErrors.push(value.payload?.gate_reason ?? value.payload?.message_key ?? value.payload?.code);
|
||||
if (request) this.pending.delete(value.seq);
|
||||
});
|
||||
}
|
||||
postMessage(value, ...args) {
|
||||
this.pending.set(value.seq, { kind: value.kind, time: performance.now() });
|
||||
super.postMessage(value, ...args);
|
||||
}
|
||||
};
|
||||
});
|
||||
const control = (id) => page.locator(`[data-axis-control="${id}"]`);
|
||||
const state = () => page.locator("main.axis-app").evaluate((main) => {
|
||||
const viewer = main.querySelector("[data-xyzac-preview]");
|
||||
return { path: main.dataset.axisProgramPath, loading: main.dataset.axisProgramLoading, status: main.dataset.axisProgramStatus, mode: main.dataset.axisTaskMode, queue: Number(main.dataset.axisTpQueueDepth), line: Number(main.dataset.axisCurrentLine), tick: Number(viewer?.dataset.xyzacTick), epoch: Number(viewer?.dataset.xyzacLivePathEpoch), live: Number(viewer?.dataset.xyzacLiveTraceCount), preview: Number(viewer?.dataset.xyzacToolpathCount), draws: Number(viewer?.dataset.xyzacPreviewDrawCalls), vertices: Number(viewer?.dataset.xyzacPreviewVertices), pose: JSON.parse(viewer?.dataset.xyzacLivePose ?? "{}"), error: main.querySelector(".diagnostic-error")?.textContent ?? null };
|
||||
});
|
||||
const wait = async (predicate, name, timeout = 30000) => {
|
||||
const start = performance.now(); let latest;
|
||||
while (performance.now() - start < timeout) { latest = await state(); if (predicate(latest)) return latest; await page.waitForTimeout(100); }
|
||||
throw new Error(`${name}: ${JSON.stringify(latest)}`);
|
||||
};
|
||||
const sampleRun = async () => {
|
||||
await control("toolbar-run").click();
|
||||
await wait((s) => s.status === "running", "second run");
|
||||
await page.evaluate(() => { window.stepTimes = []; });
|
||||
const before = await state(), started = performance.now();
|
||||
await page.waitForTimeout(5000);
|
||||
const after = await state(), wall = performance.now() - started;
|
||||
const simulated = (after.tick - before.tick) * 10;
|
||||
const latencies = await page.evaluate(() => window.stepTimes);
|
||||
const p95 = latencies.sort((a, b) => a - b)[Math.floor(latencies.length * .95)];
|
||||
assert.ok(simulated / wall >= .75, `simulation fell behind real time: ${simulated}/${wall}`);
|
||||
assert.ok(p95 < 150, `Worker p95 ${p95} ms`);
|
||||
assert.ok(after.line > before.line && after.live > 0 && after.queue > 0);
|
||||
const pauseStart = performance.now();
|
||||
await control("toolbar-pause-resume").click();
|
||||
const paused = await wait((s) => s.status === "paused", "pause");
|
||||
const pauseMs = performance.now() - pauseStart;
|
||||
await page.waitForTimeout(350);
|
||||
assert.deepEqual((await state()).pose, paused.pose);
|
||||
await control("toolbar-pause-resume").click();
|
||||
await wait((s) => s.status === "running", "resume");
|
||||
const stopStart = performance.now();
|
||||
await control("toolbar-stop").click();
|
||||
await wait((s) => s.status === "aborted" && s.queue === 0, "stop");
|
||||
const stopMs = performance.now() - stopStart;
|
||||
assert.ok(pauseMs < 1500 && stopMs < 1500);
|
||||
return { wall_ms: wall, simulated_ms: simulated, ratio: simulated / wall, p95_step_ms: p95, pause_ms: pauseMs, stop_ms: stopMs, line: after.line };
|
||||
};
|
||||
try {
|
||||
await page.goto(url, { waitUntil: "domcontentloaded" });
|
||||
await wait((s) => s.loading === "false" && s.preview > 0, "startup");
|
||||
await control("toolbar-power").click();
|
||||
await control("manual-home-all").click();
|
||||
await control("toolbar-run").click();
|
||||
process.stderr.write("[second-program] running configured first program to completion\n");
|
||||
const first = await wait((s) => s.status === "completed" && s.queue === 0 && s.live > 0, "first complete", 180000);
|
||||
// Chips begins at Z10. The configured first program ends at Z10, so its
|
||||
// zero-motion positioning block correctly produces no preview segment.
|
||||
const expectedSegments = Math.abs(first.pose.z - 10) < 1e-9 ? 4683 : 4684;
|
||||
const loadStart = performance.now();
|
||||
await control("workspace-load-3d-chips").click();
|
||||
const chips = await wait((s) => s.path === "linuxcnc/nc_files/3D_Chips.ngc" && s.loading === "false" && s.preview === expectedSegments, "chips replacement", 15000);
|
||||
const loadMs = performance.now() - loadStart;
|
||||
assert.equal(chips.epoch, first.epoch);
|
||||
assert.ok(chips.live >= first.live, "native Open must retain live history");
|
||||
assert.ok(chips.draws <= 2 && chips.vertices === expectedSegments * 2);
|
||||
assert.deepEqual(chips.pose, first.pose, "file replacement changed machine pose");
|
||||
process.stderr.write(`[second-program] replaced: ${loadMs.toFixed(0)} ms, ${chips.draws} static draw calls, native live trace retained\n`);
|
||||
await control("toolbar-clear-live-plot").click();
|
||||
const explicitlyCleared = await wait((s) => s.live === 0 && s.epoch === chips.epoch + 1, "native explicit clear");
|
||||
const execution = await sampleRun();
|
||||
const stopped = await state();
|
||||
await control("toolbar-reload").click();
|
||||
const same = await wait((s) => s.status === "loaded" && s.loading === "false", "same reload");
|
||||
assert.equal(same.epoch, stopped.epoch, "same source Reload must retain trace");
|
||||
assert.ok(same.live >= stopped.live);
|
||||
await control("toolbar-clear-live-plot").click();
|
||||
await wait((s) => s.live === 0 && s.epoch > same.epoch, "explicit clear");
|
||||
for (const width of [1440, 390]) {
|
||||
await page.setViewportSize({ width, height: width === 390 ? 844 : 1000 });
|
||||
await control("toolbar-view-p").click();
|
||||
await page.waitForTimeout(250);
|
||||
const colors = await page.locator("[data-xyzac-preview] canvas").evaluate((canvas) => {
|
||||
const gl = canvas.getContext("webgl2") ?? canvas.getContext("webgl");
|
||||
const pixels = new Uint8Array(canvas.width * canvas.height * 4);
|
||||
gl.readPixels(0, 0, canvas.width, canvas.height, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
|
||||
const colors = new Set();
|
||||
for (let i = 0; i < pixels.length; i += 16) colors.add((pixels[i] << 16) | (pixels[i + 1] << 8) | pixels[i + 2]);
|
||||
return colors.size;
|
||||
});
|
||||
assert.ok(colors > 20);
|
||||
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
|
||||
assert.ok(await page.evaluate(() => document.documentElement.scrollHeight < 5000), "long editor expanded the entire page");
|
||||
await page.screenshot({ path: `/tmp/axis-chips-replacement-${width}.png`, fullPage: true });
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
assert.deepEqual(await page.evaluate(() => window.workerErrors), []);
|
||||
console.log(JSON.stringify({ schema: "xyzac-second-program-browser.v1", status: "verified", url, first_live_points: first.live, load_retained_live_points: chips.live, new_epoch: explicitlyCleared.epoch, cleared_live_points: explicitlyCleared.live, load_ms: loadMs, preview_segments: chips.preview, static_draw_calls: chips.draws, execution, browser_errors: errors }));
|
||||
} finally { await browser.close(); }
|
||||
Reference in New Issue
Block a user