Files
FreeCADWasmDev/tools/part-boolean-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

278 lines
21 KiB
JavaScript

import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { execFileSync } from "node:child_process";
import os from "node:os";
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 booleanCases = {
"Part:desktop:Part_Fuse": "Fuse", "Part:desktop:Part_Cut": "Cut", "Part:desktop:Part_Common": "Common",
};
export const booleanDialogId = "Part:desktop:Part_Boolean";
export async function runBooleanDialogCases(ids, operationResults) {
if (!ids.includes(booleanDialogId)) return [];
// Each operation exercises the actual Part_Boolean adapter as well as the
// native dependency workflow and primary React document controls.
const operations = operationResults ?? await runBooleanCases(Object.keys(booleanCases));
const passed = operations.length === 3 && operations.every(result => result.status === "passed");
return [{
capabilityId: booleanDialogId,
status: passed ? "passed" : "failed",
cases: operations.flatMap(result => result.cases.map(test => ({
...test, operation: booleanCases[result.capabilityId],
}))),
operations,
...(passed ? {
resolvedIssueIds: ["runtime:80ee127e41544bc44247"],
resolvedFailureKeys: ["99e2862ad207c42767fd"],
} : { error: operations.filter(result => result.status !== "passed")
.map(result => `${booleanCases[result.capabilityId]}: ${result.error ?? "workflow incomplete"}`).join("\n") }),
}];
}
const expectedVolume = (operation, base, tool, overlap) => operation === "Fuse" ? base + tool - overlap : operation === "Cut" ? base - overlap : overlap;
const near = (actual, expected) => assert.ok(Math.abs(actual - expected) < 1e-5, `${actual} != ${expected}`);
async function inspectCanvas(page, operation) {
const results = [];
const artifacts = path.join(root, "docs/current/part-booleans-browser");
fs.mkdirSync(artifacts, { recursive: true });
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 before = await canvas.screenshot();
const pixels = await page.evaluate(async dataUrl => {
const source = new Image(); source.src = dataUrl; await source.decode();
const copy = document.createElement("canvas"); copy.width = copy.height = 128;
const ctx = copy.getContext("2d"); ctx.drawImage(source, 0, 0, 128, 128);
const data = ctx.getImageData(0, 0, 128, 128).data;
let painted = 0;
for (let i = 0; i < data.length; i += 4) if (Math.max(data[i], data[i + 1], data[i + 2]) - Math.min(data[i], data[i + 1], data[i + 2]) > 25) painted++;
return { painted, overflow: document.documentElement.scrollWidth > innerWidth };
}, `data:image/png;base64,${before.toString("base64")}`);
assert.ok(pixels.painted > 100, JSON.stringify(pixels));
assert.equal(pixels.overflow, false);
await page.screenshot({ path: path.join(artifacts, `${operation}-${viewport.width}.png`) });
const box = await canvas.boundingBox();
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 2 + 45, box.y + box.height / 2 + 20, { steps: 6 });
await page.mouse.up();
assert.notDeepEqual(await canvas.screenshot(), before);
results.push({ ...viewport, ...pixels, orbitChangedFrame: true });
}
return results;
}
export async function runBooleanCases(ids) {
if (!ids.length) return [];
const results = ids.map(capabilityId => ({ capabilityId, status: "failed", cases: [], timing: [] }));
let browser;
let kernel;
const nativeTiming = new Map();
const loadStarted = performance.now();
const compiled = fs.mkdtempSync(path.join(os.tmpdir(), "part-boolean-adapter-"));
try {
execFileSync(path.join(root, "web/node_modules/.bin/tsc"), ["-p", path.join(root, "web/tsconfig.json"),
"--noEmit", "false", "--outDir", compiled], { cwd: root, stdio: ["ignore", "pipe", "pipe"] });
const { executePartFixedBoxDesktop } = await import(pathToFileURL(path.join(compiled, "part-desktop-adapter.js")));
({ kernel } = await loadWasmProbe(process.env.FREECAD_PART_DOCUMENT_BUSINESS_BUILD ?? path.join(root, "web/public/kernel"), "FreeCADWebDocumentProbe", {
print: process.env.FREECAD_BOOLEAN_TRACE === "1" ? message => fs.writeSync(2, `${message}\n`) : undefined,
printErr: process.env.FREECAD_BOOLEAN_TRACE === "1" ? message => fs.writeSync(2, `${message}\n`) : undefined,
}));
const loadSeconds = (performance.now() - loadStarted) / 1000;
const call = (document, args) => {
const started = performance.now();
if (process.env.FREECAD_BOOLEAN_TRACE === "1") fs.writeSync(2, `${document}: ${JSON.stringify(args)}\n`);
const response = JSON.parse(kernel.ccall("fc_web_dispatch_json", "string", ["string"], [JSON.stringify({ cmd: "part.document", args: { document, ...args } })]));
const times = nativeTiming.get(document) ?? [];
times.push({ action: args.action, object: args.object, seconds: Number(((performance.now() - started) / 1000).toFixed(4)), ok: response.ok });
nativeTiming.set(document, times);
if (process.env.FREECAD_BOOLEAN_TRACE === "1") fs.writeSync(2, `native ${(performance.now() - started).toFixed(1)} ms: ${response.ok}\n`);
return response;
};
const ok = (document, args) => { const response = call(document, args); assert.equal(response.ok, true, JSON.stringify(response)); return response.result; };
browser = await playwright().chromium.launch({ executablePath: process.env.FREECAD_CHROME ?? "/usr/bin/google-chrome", headless: true, args: ["--no-sandbox", "--disable-dev-shm-usage"] });
for (const result of results) {
const operation = booleanCases[result.capabilityId];
const document = `Boolean${operation}`;
const filename = `/tmp/${document}.FCStd`;
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } });
context.setDefaultTimeout(10000);
let started = performance.now();
try {
ok(document, { action: "create" });
ok(document, { action: "setBox", object: "Base", dimensions: [10, 8, 6] });
ok(document, { action: "setBox", object: "Tool", dimensions: [8, 6, 4] });
ok(document, { action: "setPosition", object: "Tool", position: [4, 2, 1] });
const router = { request: async (module, command, payload, documentId) => {
assert.equal(module, "Part"); assert.equal(command, "part.document"); assert.equal(documentId, document);
const response = call(document, payload);
return response.ok ? { kind: "result", result: response.result } : { kind: "error", error: response };
} };
const desktopInput = { document, operation, base: "Base", tool: "Tool", selection: [], activeTask: false };
let selected;
const desktop = await executePartFixedBoxDesktop(router, "Part_Boolean", desktopInput, document,
(documentId, selection) => { selected = { documentId, selection }; });
assert.equal(desktop.kind, "result", JSON.stringify(desktop));
const desktopShape = desktop.result.objects.find(object => object.name === operation);
near(desktopShape.volume, expectedVolume(operation, 480, 192, 144));
assert.equal(desktopShape.base, "Base"); assert.equal(desktopShape.tool, "Tool");
assert.deepEqual(selected, { documentId: document, selection: [{ documentId: document, object: operation }] });
const beforeRejected = ok(document, { action: "snapshot" });
for (const invalid of [{ ...desktopInput, tool: "Missing" }, { ...desktopInput, tool: "Base" },
{ ...desktopInput, selection: [{ documentId: "Other", object: "Base" }] },
{ ...desktopInput, selection: null }, { ...desktopInput, selection: [null] },
{ ...desktopInput, selection: [{ documentId: document, object: "Base", subelements: [1] }] },
{ ...desktopInput, base: undefined }, { ...desktopInput, activeTask: true }]) {
const rejected = await executePartFixedBoxDesktop(router, "Part_Boolean", invalid, document);
assert.notEqual(rejected.kind, "result", JSON.stringify(rejected));
assert.deepEqual(ok(document, { action: "snapshot" }), beforeRejected);
}
result.cases.push({ input: { route: "Part_Boolean", ...desktopInput }, actual: desktop.result,
assertions: [{ name: "explicit task operands work with the shell empty selection and invalid requests preserve the native document", passed: true }] });
const applyInput = { ...desktopInput, action: "apply", base: "Tool", tool: "Base" };
const applied = await executePartFixedBoxDesktop(router, "Part_Boolean", applyInput, document);
assert.equal(applied.kind, "result", JSON.stringify(applied));
const appliedName = `${operation}001`;
const appliedShape = applied.result.objects.find(object => object.name === appliedName);
assert.equal(appliedShape.base, "Tool"); assert.equal(appliedShape.tool, "Base");
near(appliedShape.volume, expectedVolume(operation, 192, 480, 144));
const beforeClose = ok(document, { action: "snapshot" });
const closed = await executePartFixedBoxDesktop(router, "Part_Boolean", { action: "close" }, document,
() => assert.fail("closing the task must preserve selection"));
assert.equal(closed.kind, "result", JSON.stringify(closed));
assert.deepEqual(closed.result, beforeClose);
const afterApplyUndo = ok(document, { action: "undo" });
assert.ok(afterApplyUndo.objects.some(object => object.name === operation));
assert.ok(!afterApplyUndo.objects.some(object => object.name === appliedName));
const afterApplyRedo = ok(document, { action: "redo" });
near(afterApplyRedo.objects.find(object => object.name === appliedName).volume, appliedShape.volume);
result.cases.push({ input: { route: "Part_Boolean", ...applyInput, subsequentAction: "close" }, actual: closed.result,
assertions: [{ name: "Apply uses swapped native operands and a unique feature; Close preserves committed results, selection and independent undo/redo", passed: true }] });
ok(document, { action: "remove", object: appliedName });
ok(document, { action: "remove", object: operation });
const input = { action: "setBoolean", object: "Result", operation, base: "Base", tool: "Tool", includeMesh: true };
const first = ok(document, input);
const shape = first.objects.find(object => object.name === "Result");
near(shape.volume, expectedVolume(operation, 480, 192, 144));
assert.equal(shape.type, `Part::${operation}`);
assert.equal(shape.base, "Base"); assert.equal(shape.tool, "Tool");
assert.equal(shape.materialUUID, first.objects.find(object => object.name === "Base").materialUUID);
assert.ok(shape.mesh.triangleCount > 0);
assert.equal(shape.mesh.positions.length, shape.mesh.triangleCount * 9);
const undone = ok(document, { action: "undo" });
assert.equal(undone.objectCount, 2); assert.ok(undone.redoCount > 0);
const redone = ok(document, { action: "redo" });
near(redone.objects.find(object => object.name === "Result").volume, shape.volume);
const moved = ok(document, { action: "setPosition", object: "Tool", position: [6, 2, 1] });
near(moved.objects.find(object => object.name === "Result").volume, expectedVolume(operation, 480, 192, 96));
result.cases.push({ input: { document, ...input, baseDimensions: [10, 8, 6], toolDimensions: [8, 6, 4], positions: [[4, 2, 1], [6, 2, 1]] }, actual: moved,
assertions: [{ name: "native OCCT boolean retains operands, updates after movement, and participates in FreeCAD undo/redo", passed: true }] });
for (const invalid of [
{ ...input, tool: "Missing" }, { ...input, tool: "Base" }, { ...input, base: "Result" },
{ ...input, operation: "Unknown" }, { action: "remove", object: "Base" },
{ action: "setPosition", object: "Tool", position: [0, "bad", 0] },
{ action: "setBox", object: "Base", dimensions: [1e-12, 8, 6] },
]) {
const before = ok(document, { action: "snapshot" });
const actual = call(document, invalid);
assert.equal(actual.ok, false, JSON.stringify(actual));
assert.deepEqual(ok(document, { action: "snapshot" }), before);
result.cases.push({ input: { document, ...invalid }, actual, assertions: [{ name: "invalid operands, referenced removal and failed recompute preserve the document and transaction history", passed: true }] });
}
ok(document, { action: "setBoolean", object: "Nested", operation: "Fuse", base: "Result", tool: "Tool" });
const beforeCycle = ok(document, { action: "snapshot" });
const cycle = call(document, { ...input, base: "Nested" });
assert.equal(cycle.ok, false);
assert.deepEqual(ok(document, { action: "snapshot" }), beforeCycle);
ok(document, { action: "remove", object: "Nested" });
result.cases.push({ input: { document, ...input, base: "Nested" }, actual: cycle, assertions: [{ name: "transitive dependency cycles are rejected before mutation", passed: true }] });
if (operation === "Common") {
const empty = ok(document, { action: "setPosition", object: "Tool", position: [20, 0, 0], includeMesh: true }).objects.find(object => object.name === "Result");
assert.equal(empty.empty, true); assert.equal(empty.volume, 0); assert.equal(empty.mesh, undefined);
ok(document, { action: "setPosition", object: "Tool", position: [6, 2, 1] });
result.cases.push({ input: { document, action: "setPosition", object: "Tool", position: [20, 0, 0] }, actual: empty,
assertions: [{ name: "disjoint intersection returns an empty native result without stale display geometry and recovers on movement", passed: true }] });
}
const saved = ok(document, { action: "save", path: filename });
assert.ok(saved.size > 0);
ok(document, { action: "close" });
const restored = ok(document, { action: "open", path: filename });
assert.deepEqual(restored.objects, saved.objects);
const edited = ok(document, { action: "setBox", object: "Base", dimensions: [12, 8, 6] });
near(edited.objects.find(object => object.name === "Result").volume, expectedVolume(operation, 576, 192, 144));
result.cases.push({ input: { document, path: filename, editedBase: [12, 8, 6] }, actual: edited,
assertions: [{ name: "FCStd preserves native dependency links and editing the reopened base recomputes the boolean", passed: true }] });
result.timing.push({ stage: "shared-wasm-load", seconds: loadSeconds }, { stage: "native-workflow", seconds: (performance.now() - started) / 1000 });
started = performance.now();
const page = await context.newPage();
const pageErrors = []; page.on("pageerror", error => pageErrors.push(String(error)));
await page.goto(process.env.FREECAD_BROWSER_ORIGIN ?? "http://localhost:5180/", { waitUntil: "domcontentloaded" });
const waitReady = () => page.waitForFunction(() => !document.querySelector("[data-create-boolean]").closest("fieldset").disabled);
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 position = async values => { for (const [i, field] of ["X", "Y", "Z"].entries()) await page.getByRole("spinbutton", { name: `Position ${field}`, exact: true }).fill(String(values[i])); await page.getByRole("button", { name: "应用位置", exact: true }).click(); await waitReady(); };
const select = name => page.locator(`[data-object-id="native:${name}"]`).click();
const volume = () => page.locator("[data-native-volume]").innerText().then(Number);
await dimensions([10, 8, 6]); await page.getByRole("button", { name: "创建长方体", exact: true }).click();
await page.waitForFunction(() => document.querySelectorAll("[role=treeitem]").length === 1); await waitReady();
await dimensions([8, 6, 4]); await page.getByRole("button", { name: "创建长方体", exact: true }).click();
await page.waitForFunction(() => document.querySelectorAll("[role=treeitem]").length === 2); await waitReady();
await position([4, 2, 1]);
await page.locator(`[data-boolean=${operation}]`).click();
await page.getByRole("combobox", { name: "Boolean Base", exact: true }).selectOption("Box1");
await page.getByRole("combobox", { name: "Boolean Tool", exact: true }).selectOption("Box2");
await page.locator("[data-create-boolean]").click();
await page.waitForFunction(() => document.querySelectorAll("[role=treeitem]").length === 3); await waitReady();
near(await volume(), expectedVolume(operation, 480, 192, 144));
assert.equal(await page.getByRole("checkbox", { name: "Visible Box1", exact: true }).isChecked(), false);
assert.equal(await page.getByRole("checkbox", { name: "Visible Box2", exact: true }).isChecked(), false);
await page.getByRole("button", { name: "撤销", exact: true }).click();
await page.waitForFunction(() => document.querySelectorAll("[role=treeitem]").length === 2); await waitReady();
await page.getByRole("button", { name: "重做", exact: true }).click();
await page.waitForFunction(() => document.querySelectorAll("[role=treeitem]").length === 3); await waitReady();
await select("Box1"); await dimensions([12, 8, 6]);
await page.getByRole("button", { name: "应用尺寸并重算", exact: true }).click(); await waitReady();
await select(`${operation}1`);
near(await volume(), expectedVolume(operation, 576, 192, 192));
const viewports = await inspectCanvas(page, operation);
await page.getByRole("button", { name: "保存 FCStd 到 OPFS", exact: true }).click();
await page.waitForFunction(() => document.querySelector("[role=status]")?.textContent.startsWith("Saved "));
const opfsFile = await page.getByRole("combobox", { name: "OPFS document" }).inputValue();
await page.reload({ waitUntil: "domcontentloaded" });
await page.getByRole("combobox", { name: "OPFS document" }).selectOption(opfsFile);
await page.getByRole("button", { name: "打开 OPFS 文档", exact: true }).click();
await page.waitForFunction(() => document.querySelector("[role=status]")?.textContent.startsWith("Opened "));
await select("Box2"); await position([6, 2, 1]); await select(`${operation}1`);
const restoredVolume = await volume(); near(restoredVolume, expectedVolume(operation, 576, 192, 144));
assert.equal(await page.getByRole("combobox", { name: "Boolean Base", exact: true }).inputValue(), "Box1");
assert.equal(await page.getByRole("combobox", { name: "Boolean Tool", exact: true }).inputValue(), "Box2");
await page.getByRole("button", { name: "移除对象", exact: true }).click();
await page.waitForFunction(() => document.querySelectorAll("[role=treeitem]").length === 2); await waitReady();
assert.equal(await page.getByRole("checkbox", { name: "Visible Box1", exact: true }).isChecked(), true);
assert.deepEqual(pageErrors, []);
result.cases.push({ input: { route: "/", operation, base: "Box1", tool: "Box2", editedBase: [12, 8, 6], reopenedToolPosition: [6, 2, 1], opfsFile }, actual: { restoredVolume, viewports, pageErrors },
assertions: [{ name: "React current-document boolean supports native edits, undo/redo, visible OCCT mesh, OPFS reload and result removal", passed: true }] });
result.timing.push({ stage: "browser-workflow", seconds: (performance.now() - started) / 1000 });
result.status = "passed";
} catch (error) { result.error = String(error.stack ?? error); if (process.env.FREECAD_BOOLEAN_TRACE === "1") fs.writeSync(2, `${result.error}\n`); }
finally { call(document, { action: "close" }); result.nativeTiming = nativeTiming.get(document); 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), error.stdout?.toString(), error.stderr?.toString()].filter(Boolean).join("\n"); }
finally {
try { await browser?.close(); }
finally {
// This suite owns its native runtime; idle pthreads otherwise keep Node alive
// and retain the document heap while subsequent business suites execute.
try { kernel?.PThread.terminateAllThreads(); }
finally { fs.rmSync(compiled, { recursive: true, force: true }); }
}
}
return results;
}