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

296 lines
19 KiB
JavaScript

import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { fileURLToPath, pathToFileURL } from "node:url";
import { loadWasmProbe } from "./wasm-probe-runtime.mjs";
import { playwright } from "./part-primitive-business-cases.mjs";
export const compoundId = "Part:desktop:Part_Compound";
export async function runCompoundCases(ids) {
if (!ids.includes(compoundId)) return [];
const cases = [];
const timing = {};
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const out = fs.mkdtempSync(path.join(os.tmpdir(), "freecad-compound-"));
let documentCall;
let kernel;
let browser;
const documents = ["CompoundBusiness", "CompoundIsolation"];
try {
let started = performance.now();
execFileSync(path.join(root, "web/node_modules/.bin/tsc"), [
"--target", "ES2022", "--module", "ESNext", "--moduleResolution", "Bundler",
"--strict", "--skipLibCheck", "--outDir", out,
path.join(root, "web/node_modules/vite/client.d.ts"),
path.join(root, "web/src/part-desktop-adapter.ts"),
], { cwd: root, stdio: "pipe" });
const facade = await import(pathToFileURL(path.join(out, "part-document-facade.js")));
const adapter = await import(pathToFileURL(path.join(out, "part-desktop-adapter.js")));
const { FacadeSerialQueue } = await import(pathToFileURL(path.join(out, "facade-envelope.js")));
timing.transpileSeconds = (performance.now() - started) / 1000;
started = performance.now();
({ kernel } = await loadWasmProbe(process.env.FREECAD_PART_DOCUMENT_BUSINESS_BUILD
?? path.join(root, "web/public/kernel"), "FreeCADWebDocumentProbe"));
timing.wasmLoadSeconds = (performance.now() - started) / 1000;
started = performance.now();
const queue = new FacadeSerialQueue();
let sequence = 0;
const router = { request: (_module, command, payload) => {
assert.equal(command, facade.PART_DOCUMENT_COMMAND);
return facade.dispatchPartDocument(queue,
facade.createPartDocumentRequest(`compound-${++sequence}`, payload), kernel);
} };
documentCall = async (document, action, args = {}) => {
const response = await router.request("Part", facade.PART_DOCUMENT_COMMAND, {
schema: facade.PART_DOCUMENT_SCHEMA, module: "Part", document, action, ...args,
});
assert.equal(response.kind, "result", JSON.stringify(response));
return response.result;
};
const document = documents[0];
for (const name of documents) {
await documentCall(name, "create");
await documentCall(name, "setBox", { object: "SourceA", dimensions: [2, 3, 4] });
await documentCall(name, "setBox", { object: "SourceB", dimensions: [1, 2, 3] });
}
const isolated = await documentCall(documents[1], "snapshot");
const selection = ["SourceB", "SourceA", "SourceB"].map(object => ({ documentId: document, object }));
const input = { document, selection };
let selectionChanges = 0;
const execute = args => adapter.executePartFixedBoxDesktop(router, "Part_Compound", args,
document, () => { selectionChanges++; });
const response = await execute(input);
assert.equal(response.kind, "result", JSON.stringify(response));
const name = response.result.createdObject;
const feature = state => state.objects.find(object => object.name === name);
assert.equal(feature(response.result).type, "Part::Compound");
assert.deepEqual(feature(response.result).links, ["SourceB", "SourceA"]);
assert.ok(Math.abs(feature(response.result).volume - 30) < 1e-6);
assert.equal(selectionChanges, 0);
cases.push({ input, actual: feature(response.result), assertions: [{ name: "Native compound preserves ordered unique links, volume and desktop selection", passed: true }] });
let state = await documentCall(document, "setBox", { object: "SourceA", dimensions: [4, 3, 4] });
assert.ok(Math.abs(feature(state).volume - 54) < 1e-6);
state = await documentCall(document, "undo");
assert.ok(Math.abs(feature(state).volume - 30) < 1e-6);
state = await documentCall(document, "redo");
assert.ok(Math.abs(feature(state).volume - 54) < 1e-6);
cases.push({ input: { object: "SourceA", dimensions: [4, 3, 4], actions: ["setBox", "undo", "redo"] },
assertions: [{ name: "Linked compound recomputes through source edits and native undo/redo", passed: true }] });
const before = await documentCall(document, "snapshot");
for (const invalid of [
{ ...input, document: documents[1] },
{ ...input, selection: [{ documentId: documents[1], object: "SourceA" }] },
{ ...input, selection: [{ documentId: document, object: "MissingSource" }] },
{ ...input, object: "SourceA" },
{ ...input, selection: [] },
]) {
const rejected = await execute(invalid);
assert.notEqual(rejected.kind, "result", JSON.stringify(rejected));
assert.deepEqual(await documentCall(document, "snapshot"), before);
assert.deepEqual(await documentCall(documents[1], "snapshot"), isolated);
cases.push({ input: invalid, assertions: [{ name: "Invalid compound preserves both native documents and transaction counts", passed: true }] });
}
const second = await execute(input);
assert.equal(second.kind, "result", JSON.stringify(second));
assert.notEqual(second.result.createdObject, name);
assert.deepEqual(second.result.objects.find(object => object.name === second.result.createdObject).links,
["SourceB", "SourceA"]);
state = await documentCall(document, "undo");
assert.equal(state.objects.some(object => object.name === second.result.createdObject), false);
state = await documentCall(document, "redo");
assert.ok(state.objects.some(object => object.name === second.result.createdObject));
cases.push({ input, assertions: [{ name: "Repeated command uses a unique native name and one reversible creation transaction", passed: true }] });
for (const options of [{}, { iMsg: 0 }, { mode: "compound" }]) {
const dropdownInput = { ...input, ...options };
const dropdown = await adapter.executePartFixedBoxDesktop(router, "Part_CompCompoundTools",
dropdownInput, document, () => { selectionChanges++; });
assert.equal(dropdown.kind, "result", JSON.stringify(dropdown));
const created = dropdown.result.objects.find(object => object.name === dropdown.result.createdObject);
assert.equal(created.type, "Part::Compound");
assert.deepEqual(created.links, ["SourceB", "SourceA"]);
assert.ok(Math.abs(created.volume - 54) < 1e-6);
assert.equal(selectionChanges, 0);
state = await documentCall(document, "undo");
assert.equal(state.objects.some(object => object.name === created.name), false);
state = await documentCall(document, "redo");
assert.deepEqual(state.objects.find(object => object.name === created.name).links, created.links);
assert.deepEqual(await documentCall(documents[1], "snapshot"), isolated);
cases.push({ input: { command: "Part_CompCompoundTools", ...dropdownInput }, assertions: [
{ name: "Default, index-zero and named Compound actions create a linked native document feature with undo/redo and preserve selection", passed: true },
] });
}
const saved = await documentCall(document, "save", { path: "/tmp/compound-business.FCStd" });
assert.ok(saved.bytes.byteLength > 0);
await documentCall(document, "close");
state = await documentCall(document, "open", { path: "/tmp/compound-business.FCStd", bytes: saved.bytes });
assert.deepEqual(feature(state).links, ["SourceB", "SourceA"]);
state = await documentCall(document, "setBox", { object: "SourceB", dimensions: [2, 2, 3] });
assert.ok(Math.abs(feature(state).volume - 60) < 1e-6);
assert.deepEqual(await documentCall(documents[1], "snapshot"), isolated);
cases.push({ input: { actions: ["save", "close", "open", "setBox"], object: "SourceB", dimensions: [2, 2, 3] },
assertions: [{ name: "FCStd restores editable compound links and preserves the other document", passed: true }] });
const restoredDropdown = await adapter.executePartFixedBoxDesktop(router, "Part_CompCompoundTools",
{ ...input, mode: "compound" }, document);
assert.equal(restoredDropdown.kind, "result", JSON.stringify(restoredDropdown));
assert.ok(restoredDropdown.result.undoCount > 0);
state = await documentCall(document, "undo");
assert.equal(state.objects.some(object => object.name === restoredDropdown.result.createdObject), false);
state = await documentCall(document, "redo");
assert.deepEqual(state.objects.find(object => object.name === restoredDropdown.result.createdObject).links, ["SourceB", "SourceA"]);
cases.push({ input: { phase: "post-restore-transactions", actions: ["createCompound", "undo", "redo"] },
actual: { undoCount: state.undoCount, redoCount: state.redoCount, links: feature(state).links },
assertions: [{ name: "Restoration finishes before new commands so creation remains undoable and redoable", passed: true }] });
timing.nativeWorkflowSeconds = (performance.now() - started) / 1000;
started = performance.now();
browser = await playwright().chromium.launch({ executablePath: process.env.FREECAD_CHROME ?? "/usr/bin/google-chrome",
headless: true, args: ["--no-sandbox", "--disable-dev-shm-usage"] });
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } });
context.setDefaultTimeout(15000);
const page = await context.newPage();
const origin = process.env.FREECAD_BROWSER_ORIGIN ?? "http://localhost:5180";
const errors = [];
page.on("pageerror", error => errors.push(String(error)));
await page.goto(`${origin}/workbench.html?module=Part&command=part.document`, { waitUntil: "domcontentloaded" });
const run = async payload => {
await page.locator("#payload-editor").fill(JSON.stringify(payload));
await page.evaluate(() => { if (window.__freecadWorkbenchState) window.__freecadWorkbenchState.response = null; });
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 webDocument = "CompoundWorkbench";
const payload = { schema: facade.PART_DOCUMENT_SCHEMA, module: "Part", document: webDocument };
for (const args of [{ action: "create" }, { action: "setBox", object: "Base", dimensions: [3, 4, 5] },
{ action: "setBox", object: "Tool", dimensions: [2, 3, 4] },
{ action: "setPosition", object: "Tool", position: [6, 0, 0] }]) {
const reply = await run({ ...payload, ...args });
assert.equal(reply.kind, "result", JSON.stringify(reply));
}
await page.locator(".matrix-list button").filter({ has: page.locator("span", { hasText: /^Part_Compound$/ }) }).click();
const webInput = { document: webDocument, selection: ["Tool", "Base", "Tool"].map(object => ({ documentId: webDocument, object })) };
const webResult = await run(webInput);
assert.equal(webResult.kind, "result", JSON.stringify(webResult));
const webName = webResult.result.createdObject;
const webFeature = webResult.result.objects.find(object => object.name === webName);
assert.equal(webFeature.type, "Part::Compound");
assert.deepEqual(webFeature.links, ["Tool", "Base"]);
assert.ok(Math.abs(webFeature.volume - 84) < 1e-6);
assert.ok(webFeature.mesh.triangleCount > 0);
const malformed = await run({ ...webInput, selection: [{ documentId: "Other", object: "Base" }] });
assert.equal(malformed.kind, "error");
// Switch back to the existing document command without recreating its Worker.
await page.locator("aside:nth-child(2) > button.command").filter({ has: page.locator("span", { hasText: /^part.document$/ }) }).click();
const savedWeb = await run({ ...payload, action: "save", path: "/tmp/CompoundWorkbench.FCStd" });
assert.equal(savedWeb.kind, "result", JSON.stringify(savedWeb));
const stored = await page.evaluate(async () => {
const { writeDocumentFile } = await import("/src/storage.ts");
const response = window.__freecadWorkbenchState.response;
return writeDocumentFile("compound-workbench.FCStd", response.result.bytes);
});
await page.reload({ waitUntil: "domcontentloaded" });
const reopened = await page.evaluate(async ({ stored, document, name }) => {
const { WebModuleRouter } = await import("/src/module-router.ts");
const { DesktopCommandDispatcher } = await import("/src/desktop-command-dispatcher.ts");
const { readDocumentFile } = await import("/src/storage.ts");
const router = new WebModuleRouter(), dispatcher = new DesktopCommandDispatcher(router);
const base = { schema: "freecad-web-part/document/v1", module: "Part", document };
const call = async args => {
const response = await router.request("Part", "part.document", { ...base, ...args }, document);
if (response.kind !== "result") throw new Error(JSON.stringify(response));
return response.result;
};
try {
await router.loadManifest("/modules/manifest.json");
const state = await call({ action: "open", path: "/tmp/CompoundWorkbench.FCStd", bytes: await readDocumentFile(stored) });
const edit = await call({ action: "setBox", object: "Base", dimensions: [5, 4, 5] });
const undo = await call({ action: "undo" });
const redo = await call({ action: "redo" });
const selection = ["Base", "Tool"].map(object => ({ documentId: document, object }));
let selectionChanges = 0;
const created = await dispatcher.execute({ id: "Part:desktop:Part_Compound", module: "Part", command: "Part_Compound" },
{ selection }, document, () => { selectionChanges++; });
const creationUndo = await call({ action: "undo" });
const creationRedo = await call({ action: "redo" });
const feature = snapshot => snapshot.objects.find(object => object.name === name);
return { state: feature(state), edit: feature(edit), undo: feature(undo), redo: feature(redo),
created: created.kind === "result" ? { kind: created.kind, name: created.result.createdObject } : created,
creationUndoNames: creationUndo.objects.map(object => object.name),
creationRedoNames: creationRedo.objects.map(object => object.name), selectionChanges, loaded: router.listLoaded() };
} finally { router.dispose(); }
}, { stored, document: webDocument, name: webName });
for (const [key, volume] of [["state", 84], ["edit", 124], ["undo", 84], ["redo", 124]]) {
assert.deepEqual(reopened[key].links, ["Tool", "Base"]);
assert.ok(Math.abs(reopened[key].volume - volume) < 1e-6, JSON.stringify(reopened));
}
assert.equal(reopened.created.kind, "result", JSON.stringify(reopened));
assert.equal(reopened.creationUndoNames.includes(reopened.created.name), false);
assert.equal(reopened.creationRedoNames.includes(reopened.created.name), true);
assert.equal(reopened.selectionChanges, 0);
assert.equal(reopened.loaded.length, 1);
assert.equal(reopened.loaded[0].worker, "part-save.worker.ts");
assert.deepEqual(errors, []);
await page.goto(`${origin}/`, { waitUntil: "domcontentloaded" });
await page.getByRole("combobox", { name: "OPFS document", exact: true }).selectOption(stored);
await page.getByRole("button", { name: "打开 OPFS 文档", exact: true }).click();
const compoundTree = page.locator(`[role=treeitem][data-object-id="native:${webName}"]`);
await compoundTree.click();
assert.ok(Math.abs(Number(await page.locator("[data-native-volume]").innerText()) - 84) < 1e-6);
for (const sourceName of ["Base", "Tool"]) {
await page.locator(`[role=treeitem][data-object-id="native:${sourceName}"]`).click();
await page.getByRole("checkbox", { name: "Object Visibility", exact: true }).uncheck();
}
await compoundTree.click();
await page.getByRole("button", { name: "适合全部对象", exact: true }).click();
const artifactDirectory = path.join(root, "docs/current/part-compound-browser");
fs.mkdirSync(artifactDirectory, { recursive: 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 frame = await canvas.screenshot({ path: path.join(artifactDirectory, `${viewport.width}-canvas.png`) });
const pixels = await page.evaluate(async dataUrl => {
const image = new Image(); image.src = dataUrl; await image.decode();
const copy = document.createElement("canvas"); copy.width = copy.height = 128;
const ctx = copy.getContext("2d"); ctx.drawImage(image, 0, 0, 128, 128);
const data = ctx.getImageData(0, 0, 128, 128).data; let painted = 0;
for (let index = 0; index < data.length; index += 4)
if (Math.max(data[index], data[index + 1], data[index + 2]) - Math.min(data[index], data[index + 1], data[index + 2]) > 25) painted++;
return { painted, overflow: document.documentElement.scrollWidth > innerWidth };
}, `data:image/png;base64,${frame.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: 6 });
await page.mouse.up();
assert.notDeepEqual(await canvas.screenshot(), frame);
await page.screenshot({ path: path.join(artifactDirectory, `${viewport.width}-workspace.png`) });
viewports.push({ ...viewport, ...pixels, orbitChanged: true });
}
assert.deepEqual(errors, []);
cases.push({ input: { route: "/workbench.html", command: "Part_Compound", ...webInput, stored },
actual: { created: { name: webName, links: webFeature.links, volume: webFeature.volume, triangles: webFeature.mesh.triangleCount }, reopened, errors, viewports },
assertions: [{ name: "Mounted React command uses the document Worker; OPFS survives reload and restored links support edits, undo/redo and repeated desktop creation", passed: true },
{ name: "Primary workspace restores the compound tree, native volume and interactive nonblank Three.js mesh on desktop and mobile", passed: true }] });
timing.browserWorkflowSeconds = (performance.now() - started) / 1000;
return [{ capabilityId: compoundId, status: "passed", cases, timing }];
} catch (error) {
return [{ capabilityId: compoundId, status: "failed", cases, timing,
error: [String(error.stack ?? error), error.stdout?.toString(), error.stderr?.toString()].filter(Boolean).join("\n") }];
} finally {
if (documentCall) for (const document of documents) {
await documentCall(document, "close").catch(() => {});
}
await browser?.close();
kernel?.PThread?.terminateAllThreads?.();
fs.rmSync(out, { recursive: true, force: true });
}
}