278 lines
21 KiB
JavaScript
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;
|
|
}
|