Files
FreeCADWasmDev/tools/part-fixed-box-business-cases.mjs
wangdequan 9a7c4f0335 Save FreeCAD Web workspace sources and user validation evidence
IFC native binding remains incomplete; preserve accurate test results.
2026-09-15 22:01:58 +08:00

182 lines
16 KiB
JavaScript

import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { loadWasmProbe } from "./wasm-probe-runtime.mjs";
import { playwright } from "./part-primitive-business-cases.mjs";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
export const fixedBoxIds = ["Part:desktop:Part_Box", "Part:desktop:Part_Box2", "Part:desktop:Part_Box3"];
const near = (actual, expected) => assert.ok(Number.isFinite(actual) && Math.abs(actual - expected) < 1e-5, `${actual} != ${expected}`);
const compact = result => ({ ...result, objects: result.objects?.map(({ mesh, ...object }) => ({ ...object,
...(mesh ? { mesh: { bounds: mesh.bounds, triangleCount: mesh.triangleCount } } : {}) })) });
export async function runFixedBoxCases(ids) {
if (!ids.length) return [];
const results = ids.map(capabilityId => ({ capabilityId, status: "failed", cases: [], timing: [] }));
const artifacts = path.join(root, "docs/current/part-fixed-box-browser"); fs.mkdirSync(artifacts, { recursive: true });
let kernel, browser;
try {
const start = performance.now();
({ kernel } = await loadWasmProbe(process.env.FREECAD_PART_DOCUMENT_BUSINESS_BUILD ?? path.join(root, "web/public/kernel"), "FreeCADWebDocumentProbe"));
const loadSeconds = (performance.now() - start) / 1000;
browser = await playwright().chromium.launch({ executablePath: process.env.FREECAD_CHROME ?? "/usr/bin/google-chrome", headless: true, args: ["--no-sandbox", "--disable-dev-shm-usage"] });
const raw = (document, args) => JSON.parse(kernel.ccall("fc_web_dispatch_json", "string", ["string"], [JSON.stringify({ cmd: "part.document", args: { document, ...args } })]));
for (const result of results) {
const boxCommand = result.capabilityId.split(":").at(-1), offset = boxCommand === "Part_Box3" ? 50 : 0;
const size = boxCommand === "Part_Box" ? 10 : 100, expectedVolume = size ** 3;
const createdName = boxCommand === "Part_Box" ? "Box" : "PartBox";
const document = `Fixed_${boxCommand}`, other = `${document}_Other`, filename = `/tmp/${document}.FCStd`;
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } }); context.setDefaultTimeout(15000);
const record = (input, actual, name) => result.cases.push({ input, actual, assertions: [{ name, passed: true }] });
const ok = (args, name = document) => { const value = raw(name, args); assert.equal(value.ok, true, JSON.stringify(value)); return value.result; };
try {
result.timing.push({ stage: "shared-wasm-load", seconds: loadSeconds });
let started = performance.now();
ok({ action: "create" }); ok({ action: "setBox", object: "Existing", dimensions: [2, 3, 4] });
ok({ action: "create" }, other); ok({ action: "setBox", object: "Isolated", dimensions: [7, 8, 9] }, other);
const isolated = ok({ action: "snapshot" }, other), before = ok({ action: "snapshot" });
const input = { action: "createFixedBox", boxCommand, includeMesh: true };
const created = ok(input), name = created.createdObject;
assert.equal(created.boxCommand, boxCommand); assert.equal(created.objectCount, 2);
assert.equal(created.undoCount, before.undoCount + 1);
const box = created.objects.find(object => object.name === name);
assert.equal(box.type, "Part::Box"); assert.deepEqual(box.dimensions, [size, size, size]);
if (boxCommand === "Part_Box") assert.equal(box.label, "Cube");
assert.deepEqual(box.position, [offset, offset, offset]); near(box.volume, expectedVolume);
assert.equal(box.mesh.triangleCount, 12);
for (let axis = 0; axis < 3; axis++) {
near(box.mesh.bounds[axis], offset); near(box.mesh.bounds[axis + 3], offset + size);
}
near(created.objects.find(object => object.name === "Existing").volume, 24);
assert.deepEqual(ok({ action: "snapshot" }, other), isolated);
assert.deepEqual(ok({ action: "undo" }).objects, before.objects);
near(ok({ action: "redo" }).objects.find(object => object.name === name).volume, expectedVolume);
const repeated = ok(input); assert.notEqual(repeated.createdObject, name); assert.equal(repeated.objectCount, 3);
record({ ...input, document, other }, { created: compact(created), repeated: compact(repeated) },
"desktop fixed dimensions and placement create native geometry with unique names, one transaction, unchanged existing objects and isolated documents");
for (const args of [
{ ...input, object: name }, { ...input, object: "bad/name" }, { ...input, object: null }, { ...input, object: 12 }, { ...input, boxCommand: "Other" },
{ ...input, dimensions: [1, 2, 3] }, { ...input, position: [9, 9, 9] },
]) {
const snapshot = ok({ action: "snapshot" }), actual = raw(document, args);
assert.equal(actual.ok, false); assert.deepEqual(ok({ action: "snapshot" }), snapshot);
record(args, actual, "invalid fixed-box requests preserve native geometry and transaction history");
}
assert.equal(raw("MissingFixedDocument", input).ok, false);
ok({ action: "setBox", object: name, dimensions: [4, 5, 6] });
const saved = ok({ action: "save", path: filename }); assert.ok(saved.size > 0);
ok({ action: "close" }); const reopened = ok({ action: "open", path: filename });
assert.deepEqual(reopened.objects, saved.objects);
near(reopened.objects.find(object => object.name === name).volume, 120);
const edited = ok({ action: "setBox", object: name, dimensions: [3, 5, 7] });
near(edited.objects.find(object => object.name === name).volume, 105);
assert.deepEqual(edited.objects.find(object => object.name === name).position, [offset, offset, offset]);
record({ filename, dimensions: [3, 5, 7] }, compact(edited), "FCStd round trip retains placed parametric boxes and continued editing");
result.timing.push({ stage: "native-workflow", seconds: (performance.now() - started) / 1000 });
started = performance.now(); const page = await context.newPage(), errors = [];
page.on("pageerror", error => errors.push(String(error)));
await page.goto(process.env.FREECAD_BROWSER_ORIGIN ?? "http://localhost:5180/", { waitUntil: "domcontentloaded" });
const ready = () => page.waitForFunction(() => document.querySelector('[aria-label="Task: Box dimensions"]')?.disabled === false);
const dimensions = async values => { for (const [i, field] of ["Length", "Width", "Height"].entries()) await page.getByRole("spinbutton", { name: field, exact: true }).fill(String(values[i])); };
const volume = () => page.locator("[data-native-volume]").innerText().then(Number);
const select = name => page.locator(`[role=treeitem][data-object-id="native:${name}"]`).click();
const click = async locator => { await locator.click(); await ready(); };
await dimensions([2, 3, 4]); await click(page.getByRole("button", { name: "创建长方体", exact: true })); near(await volume(), 24);
await click(page.locator(`[data-fixed-box="${boxCommand}"]`)); near(await volume(), expectedVolume);
assert.equal(await page.getByRole("treeitem").count(), 2);
for (const axis of ["X", "Y", "Z"]) assert.equal(Number(await page.getByRole("spinbutton", { name: `Position ${axis}`, exact: true }).inputValue()), offset);
await click(page.getByRole("button", { name: "撤销", exact: true })); assert.equal(await page.getByRole("treeitem").count(), 1);
await click(page.getByRole("button", { name: "重做", exact: true })); await select(createdName); near(await volume(), expectedVolume);
await dimensions([4, 5, 6]); await click(page.getByRole("button", { name: "应用尺寸并重算", exact: true })); near(await volume(), 120);
await select("Box1"); near(await volume(), 24); await select(createdName);
await click(page.getByRole("button", { name: "保存 FCStd 到 OPFS", exact: true }));
const opfs = await page.getByRole("combobox", { name: "OPFS document", exact: true }).inputValue(); assert.ok(opfs.endsWith(".FCStd"));
await page.reload({ waitUntil: "domcontentloaded" });
await page.getByRole("combobox", { name: "OPFS document", exact: true }).selectOption(opfs);
await click(page.getByRole("button", { name: "打开 OPFS 文档", exact: true })); await select(createdName); near(await volume(), 120);
for (const axis of ["X", "Y", "Z"]) assert.equal(Number(await page.getByRole("spinbutton", { name: `Position ${axis}`, exact: true }).inputValue()), offset);
await dimensions([3, 5, 7]); await click(page.getByRole("button", { name: "应用尺寸并重算", exact: true })); near(await volume(), 105);
// Isolate the selected solid for pixel/framing checks without changing native geometry.
await select("Box1"); await page.getByRole("checkbox", { name: "Object Visibility", exact: true }).uncheck(); await select(createdName);
await click(page.getByRole("button", { name: "适合全部对象", exact: true }));
const viewports = [];
for (const viewport of [{ width: 1440, height: 1000 }, { width: 390, height: 844 }]) {
await page.setViewportSize(viewport); const canvas = page.locator(".viewport canvas"); await canvas.scrollIntoViewIfNeeded();
const original = await canvas.screenshot({ path: path.join(artifacts, `${boxCommand}-${viewport.width}-canvas.png`) });
const pixels = await page.evaluate(async data => {
const image = new Image(); image.src = data; await image.decode(); const c = document.createElement("canvas"); c.width = c.height = 128;
const ctx = c.getContext("2d"); ctx.drawImage(image, 0, 0, 128, 128); const rgba = ctx.getImageData(0, 0, 128, 128).data; let painted = 0;
for (let i = 0; i < rgba.length; i += 4) if (Math.max(rgba[i], rgba[i + 1], rgba[i + 2]) - Math.min(rgba[i], rgba[i + 1], rgba[i + 2]) > 25) painted++;
return { painted, overflow: document.documentElement.scrollWidth > innerWidth };
}, `data:image/png;base64,${original.toString("base64")}`);
assert.ok(pixels.painted > 100, JSON.stringify(pixels)); assert.equal(pixels.overflow, false);
const bounds = await canvas.boundingBox(); await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2);
await page.mouse.down(); await page.mouse.move(bounds.x + bounds.width / 2 + 45, bounds.y + bounds.height / 2 + 20, { steps: 5 }); await page.mouse.up();
assert.notDeepEqual(await canvas.screenshot(), original);
await page.locator('[aria-label="Fixed box commands"]').scrollIntoViewIfNeeded();
await page.screenshot({ path: path.join(artifacts, `${boxCommand}-${viewport.width}-controls.png`) });
viewports.push({ ...viewport, ...pixels, orbitChanged: true });
}
await click(page.locator(`[data-fixed-box="${boxCommand}"]`)); assert.equal(await page.getByRole("treeitem").count(), 3); near(await volume(), expectedVolume);
assert.deepEqual(errors, []);
record({ boxCommand, route: "/", opfs }, { viewports, errors, objects: 3, editedVolume: 105 },
"primary React controls operate on current native boxes; native undo/redo and OPFS restore preserve parameters, and Three.js displays the native mesh on desktop/mobile");
const routed = await page.evaluate(async boxCommand => {
const { WebModuleRouter } = await import("/src/module-router.ts");
const { DesktopCommandDispatcher } = await import("/src/desktop-command-dispatcher.ts");
const router = new WebModuleRouter(), selections = [], document = "FixedRouteBusiness";
const payload = { schema: "freecad-web-part/document/v1", module: "Part", document };
try {
await router.loadManifest("/modules/manifest.json");
const created = await router.request("Part", "part.document", { ...payload, action: "create" }, document);
if (created.kind !== "result") throw new Error(JSON.stringify(created));
const dispatcher = new DesktopCommandDispatcher(router);
const record = { id: `Part:desktop:${boxCommand}`, module: "Part", command: boxCommand };
const routes = await fetch("/api/desktop-command-routes.json").then(response => response.json());
const route = routes.records.find(row => row.id === record.id);
const actual = await dispatcher.execute(record, {}, document, (documentId, selection) => selections.push({ documentId, selection }));
const invalid = await dispatcher.execute(record, { dimensions: [1, 2, 3] }, document);
const snapshot = await router.request("Part", "part.document", { ...payload, action: "snapshot" }, document);
return { actual, invalid, snapshot, selections, route, loaded: router.listLoaded() };
} finally { router.dispose(); }
}, boxCommand);
assert.equal(routed.actual.kind, "result", JSON.stringify(routed)); assert.equal(routed.invalid.kind, "error");
assert.equal(routed.snapshot.result.objectCount, 1); near(routed.actual.result.objects[0].volume, expectedVolume);
assert.deepEqual(routed.actual.result.objects[0].position, [offset, offset, offset]);
assert.equal(routed.selections.length, 1); assert.equal(routed.loaded.length, 1); assert.equal(routed.loaded[0].worker, "part-save.worker.ts");
assert.equal(routed.route.worker, "part-save.worker.ts"); assert.equal(routed.route.command, "part.document");
record({ boxCommand, document: "FixedRouteBusiness", dispatcher: "DesktopCommandDispatcher" }, { ...routed, actual: compact(routed.actual.result) },
"the actual desktop dispatcher and browser Worker share the existing native Document runtime, select its new object and reject parameter overrides without mutation");
await page.goto(`${process.env.FREECAD_BROWSER_ORIGIN ?? "http://localhost:5180"}/workbench.html?module=Part&command=part.document`, { waitUntil: "domcontentloaded" });
const workbenchRun = async payload => {
await page.locator("#payload-editor").fill(JSON.stringify(payload)); await page.getByRole("button", { name: "执行选中命令", exact: true }).click();
await page.waitForFunction(() => !!window.__freecadWorkbenchState?.response && !document.querySelector("button.run")?.disabled);
return page.evaluate(() => window.__freecadWorkbenchState.response);
};
const createdDocument = await workbenchRun({ schema: "freecad-web-part/document/v1", module: "Part", document: "FixedWorkbench", action: "create" });
assert.equal(createdDocument.kind, "result", JSON.stringify(createdDocument));
await page.locator(".matrix-list button").filter({ has: page.locator("span", { hasText: new RegExp(`^${boxCommand}$`) }) }).click();
const workbenchResult = await workbenchRun({ document: "FixedWorkbench" });
assert.equal(workbenchResult.kind, "result", JSON.stringify(workbenchResult));
assert.equal(workbenchResult.result.objectCount, 1); near(workbenchResult.result.objects[0].volume, expectedVolume);
assert.deepEqual(workbenchResult.result.objects[0].position, [offset, offset, offset]);
const activeTask = await workbenchRun({ activeTask: true });
assert.equal(activeTask.kind, "error");
const recovered = await workbenchRun({ activeTask: false });
assert.equal(recovered.kind, "result", JSON.stringify(recovered)); assert.equal(recovered.result.objectCount, 2);
assert.equal(recovered.result.document, "FixedWorkbench");
record({ route: "/workbench.html", boxCommand, document: "FixedWorkbench", subsequentDocumentOmitted: true, activeTask: [false, true, false] },
{ created: compact(workbenchResult.result), rejected: activeTask, recovered: compact(recovered.result) },
"mounted workbench adopts the explicit native document for subsequent commands without document overrides; task exclusion and native geometry are preserved");
result.timing.push({ stage: "browser-workflow", seconds: (performance.now() - started) / 1000 });
result.status = "passed";
} catch (error) { result.error = String(error.stack ?? error); }
finally { for (const name of [document, other]) raw(name, { action: "close" }); try { kernel.FS.unlink(filename); } catch {} await context.close(); }
}
} catch (error) { for (const result of results) if (result.status !== "passed") result.error = String(error.stack ?? error); }
finally { await browser?.close(); }
return results;
}