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

380 lines
23 KiB
JavaScript

import { loadWasmProbe } from './wasm-probe-runtime.mjs';
import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { runInNewContext } from 'node:vm';
import { runProfileCases, profileIds } from './partdesign-profile-business-cases.mjs';
import { runFinishingCases, finishingCases } from './partdesign-finishing-business-cases.mjs';
import { runAdditiveCases, additiveIds } from './partdesign-additive-business-cases.mjs';
import { runSubtractiveCases, subtractiveIds } from './partdesign-subtractive-business-cases.mjs';
function loadBusinessWorker(kernel) {
const require = createRequire(new URL('../web/package.json', import.meta.url));
const ts = require('typescript');
const modules = new Map();
const messages = [];
const scope = { postMessage: message => messages.push(message) };
const load = name => {
if (modules.has(name)) return modules.get(name);
const exports = {};
modules.set(name, exports);
let source = readFileSync(new URL(`../web/src/${name}.ts`, import.meta.url), 'utf8');
// Inject the public WASM instance only at the loader boundary; execute the real message handler.
if (name === 'partdesign.worker') source = source.replace('let kernel = createKernel();', 'let kernel = Promise.resolve(injectedKernel);');
const code = ts.transpileModule(source, { compilerOptions: {
target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS,
} }).outputText;
runInNewContext(code, { exports, require: path => load(path.replace(/^\.\//, '').replace(/\.js$/, '')),
self: scope, injectedKernel: kernel, AbortController, setTimeout, clearTimeout });
return exports;
};
load('partdesign.worker');
return {
create: load('partdesign-facade').createPartDesignRequest,
request: load('facade-envelope').createRequest,
desktop: load('partdesign-desktop-adapter').executePartDesignDesktop,
async send(request) {
await scope.onmessage({ data: request });
const reply = messages.find(message => message.requestId === request.requestId);
if (!reply) throw new Error(`Worker did not reply to ${request.requestId}`);
return reply;
},
};
}
async function loadSaveFacade() {
const require = createRequire(new URL('../web/package.json', import.meta.url));
const ts = require('typescript');
const compile = name => ts.transpileModule(readFileSync(new URL(`../web/src/${name}.ts`, import.meta.url), 'utf8'), {
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext },
}).outputText;
const url = source => `data:text/javascript;base64,${Buffer.from(source).toString('base64')}`;
const envelope = url(compile('facade-envelope'));
return import(url(compile('partdesign-save-facade').replace('./facade-envelope.js', envelope)));
}
const capabilityId = 'PartDesign:partdesign.createRecompute';
const selection = process.env.FREECAD_BUSINESS_IDS;
const selectedIds = !selection ? null : (selection.trim().startsWith('[')
? JSON.parse(selection) : selection.split(/[\s,]+/));
const selected = !selectedIds || selectedIds.includes(capabilityId);
const results = [];
let kernel;
let exitCode = 0;
if (selected) {
const cases = [];
const handles = new Set();
const dispatch = args => JSON.parse(String(kernel.ccall('fc_web_dispatch_json', 'string',
['string'], [JSON.stringify({ cmd: 'partdesign.createRecompute', args })])));
const record = (input, actual, checks) => {
const assertions = Object.entries(checks).map(([name, passed]) => ({ name, passed: Boolean(passed) }));
cases.push({ input: { cmd: 'partdesign.createRecompute', args: input }, actual, assertions });
if (assertions.some(check => !check.passed)) exitCode = 1;
};
try {
({ kernel } = await loadWasmProbe('web/public/kernel', 'FreeCADWebPartDesignRuntimeProbe'));
const documentHandle = `BusinessPD${process.pid}`;
handles.add(documentHandle);
for (const [objectHandle, dimensions, expectedCount] of [
['BoxA', [2, 3, 4], 1], ['BoxA', [5, 2, 3], 1],
['BoxB', [1.5, 4, 2], 2], ['BoxA', [3, 4, 5], 2],
]) {
const input = { documentHandle, objectHandle, dimensions, createDocument: cases.length === 0 };
const actual = dispatch(input);
record(input, actual, {
'native operation succeeded': actual.ok === true,
'shape is valid': actual.valid === true,
'volume follows supplied dimensions': Math.abs(actual.volume - dimensions.reduce((a, b) => a * b, 1)) < 1e-8,
'native properties match dimensions': JSON.stringify(actual.dimensions) === JSON.stringify(dimensions),
'recompute preserves current document and other objects': actual.documentHandle === documentHandle && actual.documentRetained === true && actual.objectCount === expectedCount,
'requested object was recomputed': actual.objectHandle === objectHandle,
});
}
for (const dimensions of [[0, 3, 4], [-1, 2, 3], [2, 3], ['2', 3, 4], [null, 3, 4]]) {
const input = { documentHandle, objectHandle: 'BoxA', dimensions };
const actual = dispatch(input);
record(input, actual, { 'native rejects invalid dimensions': actual.ok === false && typeof actual.error === 'string' && actual.error.length > 0 });
}
const missing = { documentHandle: `${documentHandle}Missing`, dimensions: [2, 3, 4] };
const missingResult = dispatch(missing);
record(missing, missingResult, { 'missing current document is rejected': missingResult.ok === false && typeof missingResult.error === 'string' });
const recovery = { documentHandle, objectHandle: 'BoxB', dimensions: [2, 2, 2] };
const actual = dispatch(recovery);
record(recovery, actual, {
'same document remains usable after errors': actual.ok === true && actual.valid === true && actual.objectCount === 2 && actual.documentHandle === documentHandle,
'recovery produces computed geometry': Math.abs(actual.volume - 8) < 1e-8,
});
} catch (error) {
exitCode = 1;
cases.push({ input: { capabilityId }, assertions: [{ name: 'WASM business execution completed', passed: false }], error: String(error.stack || error) });
} finally {
if (kernel) for (const documentHandle of handles) {
const input = { documentHandle, operation: 'close' };
try {
const actual = dispatch(input);
record(input, actual, { 'test document released': actual.ok === true && actual.closed === true });
} catch (error) {
exitCode = 1;
cases.push({ input, assertions: [{ name: 'test document released', passed: false }], error: String(error) });
}
}
}
results.push({ capabilityId, status: exitCode ? 'failed' : 'passed', cases });
}
const saveId = 'PartDesign:partdesign.saveReopen';
if (!selectedIds || selectedIds.includes(saveId)) {
const cases = [];
const handles = new Set();
const paths = new Set();
const call = (cmd, args, checks, reply) => {
const actual = reply ?? JSON.parse(String(kernel.ccall('fc_web_dispatch_json', 'string',
['string'], [JSON.stringify({ cmd, args })])));
const assertions = Object.entries(checks(actual)).map(([name, passed]) => ({ name, passed: Boolean(passed) }));
cases.push({ input: { cmd, args }, actual, assertions });
if (assertions.some(check => !check.passed)) exitCode = 1;
return actual;
};
const create = (documentHandle, objectHandle, dimensions, createDocument, count) => {
const actual = call('partdesign.createRecompute', { documentHandle, objectHandle, dimensions, createDocument }, actual => ({
'current document geometry computed': actual.ok === true && actual.valid === true && actual.documentHandle === documentHandle,
'input dimensions and object count preserved': JSON.stringify(actual.dimensions) === JSON.stringify(dimensions) && actual.objectCount === count,
'volume computed from input': Math.abs(actual.volume - dimensions.reduce((a, b) => a * b, 1)) < 1e-8,
}));
if (actual.ok) handles.add(documentHandle);
};
let facade;
const save = async (documentHandle, reopenedDocumentHandle, path, expected) => {
paths.add(path);
const matches = objects => Array.isArray(objects) && objects.length === Object.keys(expected).length &&
new Set(objects.map(item => item.name)).size === objects.length && objects.every(item =>
Object.hasOwn(expected, item.name) && item.type === 'PartDesign::AdditiveBox' && item.valid === true &&
Math.abs(item.volume - expected[item.name]) < 1e-8);
const payload = { schema: facade.PARTDESIGN_SAVE_SCHEMA, module: 'PartDesign', action: 'save/reopen', documentHandle, reopenedDocumentHandle, path };
const reply = await facade.handlePartDesignSave(payload, new AbortController().signal, kernel);
const actual = call('partdesign.saveReopen', payload, actual => ({
'native FCStd round trip succeeded': actual.ok === true && actual.roundTrip === true && actual.schema === 'freecad-web-partdesign/save-reopen/v1',
'requested documents and file used': actual.documentHandle === documentHandle && actual.reopenedDocumentHandle === reopenedDocumentHandle && actual.path === path,
'source retained and file written': actual.documentRetained === true && actual.size > 0,
'all current objects saved': matches(actual.before),
'all reopened shapes match input geometry': matches(actual.objects) && actual.objectCount === Object.keys(expected).length,
'names types and labels survive persistence': Array.isArray(actual.before) && Array.isArray(actual.objects) &&
actual.before.every(before => actual.objects.some(after => after.name === before.name && after.type === before.type && after.label === before.label)),
}), reply);
if (actual.ok) handles.add(actual.reopenedDocumentHandle);
};
try {
facade = await loadSaveFacade();
if (!kernel) ({ kernel } = await loadWasmProbe('web/public/kernel', 'FreeCADWebPartDesignRuntimeProbe'));
const worker = loadBusinessWorker(kernel);
const workerDoc = `BusinessPDWorker${process.pid}`;
const workerCopy = `${workerDoc}Copy`;
handles.add(workerDoc);
handles.add(workerCopy);
const workerPath = `/tmp/${workerDoc}.FCStd`;
paths.add(workerPath);
const registry = readFileSync(new URL('../web/src/module-registry.generated.ts', import.meta.url), 'utf8');
const routeMatches = /case "PartDesign:partdesign.saveReopen": return "partdesign.worker.ts";/.test(registry);
const requests = [
worker.create('worker-create', { schema: 'freecad-web-partdesign/create-recompute/v1', module: 'PartDesign',
action: 'create/recompute', baseType: 'Part::Box', featureType: 'PartDesign::AdditiveBox',
documentHandle: workerDoc, objectHandle: 'WorkerBox', dimensions: [3, 5, 7], createDocument: true }),
worker.request('worker-save', 'partdesign.saveReopen', { schema: facade.PARTDESIGN_SAVE_SCHEMA,
module: 'PartDesign', action: 'save/reopen', documentHandle: workerDoc, reopenedDocumentHandle: workerCopy, path: workerPath }),
worker.create('worker-edit-reopened', { schema: 'freecad-web-partdesign/create-recompute/v1', module: 'PartDesign',
action: 'create/recompute', baseType: 'Part::Box', featureType: 'PartDesign::AdditiveBox',
documentHandle: workerCopy, objectHandle: 'WorkerBox', dimensions: [2, 5, 7] }),
];
for (const request of requests) {
const actual = await worker.send(request);
const result = actual.result;
const saved = request.requestId === 'worker-save';
const passed = routeMatches && (saved
? result?.roundTrip === true && result.objects?.length === 1 && Math.abs(result.objects[0].volume - 105) < 1e-8
: result?.valid === true && Math.abs(result.volume - (request.requestId === 'worker-create' ? 105 : 70)) < 1e-8);
if (!passed) exitCode = 1;
cases.push({ input: request, actual, assertions: [
{ name: 'save route uses the current-document Worker', passed: routeMatches },
{ name: 'real Worker preserves native document across create save and continued editing', passed },
] });
}
const doc = `BusinessPDSave${process.pid}`;
const first = `${doc}First`, second = `${doc}Second`, third = `${doc}Third`;
create(doc, 'BoxA', [2, 3, 4], true, 1);
create(doc, 'BoxB', [1.5, 4, 2], false, 2);
await save(doc, first, `/tmp/${doc}.FCStd`, { BoxA: 24, BoxB: 12 });
create(doc, 'BoxA', [5, 2, 3], false, 2);
await save(doc, second, `/tmp/${doc}.FCStd`, { BoxA: 30, BoxB: 12 });
// Saving the first reopened document proves it remains independent of later source edits.
await save(first, third, `/tmp/${first}.FCStd`, { BoxA: 24, BoxB: 12 });
create(second, 'BoxB', [3, 4, 5], false, 2);
for (const args of [
{ documentHandle: `${doc}Missing`, reopenedDocumentHandle: `${doc}Rejected`, path: `/tmp/${doc}.FCStd` },
{ documentHandle: doc, reopenedDocumentHandle: doc, path: `/tmp/${doc}.FCStd` },
{ documentHandle: doc, reopenedDocumentHandle: first, path: `/tmp/${doc}.FCStd` },
...['/outside.FCStd', '/tmp/../outside.FCStd', `/tmp/${doc}.txt`].map(path =>
({ documentHandle: doc, reopenedDocumentHandle: `${doc}Rejected`, path })),
]) {
call('partdesign.saveReopen', args, actual => ({
'invalid save request rejected': actual.ok === false && typeof actual.error === 'string' && actual.error.length > 0,
}));
let rejected = false;
let errorMessage;
const payload = { schema: facade.PARTDESIGN_SAVE_SCHEMA, module: 'PartDesign', action: 'save/reopen', ...args };
try {
await facade.handlePartDesignSave(payload, new AbortController().signal, kernel);
} catch (error) {
rejected = true;
errorMessage = error.message;
}
if (!rejected) exitCode = 1;
cases.push({ input: { cmd: 'partdesign.saveReopen', args: payload }, errorMessage,
assertions: [{ name: 'facade rejects invalid current-document save', passed: rejected }] });
}
await save(second, `${doc}Recovery`, `/tmp/${second}.FCStd`, { BoxA: 30, BoxB: 60 });
} catch (error) {
exitCode = 1;
cases.push({ input: { capabilityId: saveId }, assertions: [{ name: 'WASM save/reopen execution completed', passed: false }], error: String(error.stack || error) });
} finally {
if (kernel) {
for (const documentHandle of handles) {
try {
call('partdesign.createRecompute', { documentHandle, operation: 'close' }, actual => ({
'source or reopened document released': actual.ok === true && actual.closed === true,
}));
} catch (error) {
exitCode = 1;
cases.push({ input: { documentHandle, operation: 'close' }, assertions: [{ name: 'document cleanup completed', passed: false }], error: String(error) });
}
}
for (const path of paths) {
try {
try {
kernel.FS_unlink(path);
} catch (error) {
if (error.code !== 'ENOENT') throw error;
}
let removed = false;
try {
kernel.FS_readFile(path);
} catch (error) {
if (error.code !== 'ENOENT') throw error;
removed = true;
}
if (!removed) exitCode = 1;
cases.push({ input: { operation: 'unlink', path }, assertions: [{ name: 'test FCStd removed', passed: removed }] });
} catch (error) {
exitCode = 1;
cases.push({ input: { operation: 'unlink', path }, assertions: [{ name: 'test FCStd removed', passed: false }], error: String(error) });
}
}
}
}
results.push({ capabilityId: saveId, status: cases.some(item => item.assertions.some(check => !check.passed)) ? 'failed' : 'passed', cases });
}
const holeId = 'PartDesign:desktop:PartDesign_Hole';
if (!selectedIds || selectedIds.includes(holeId)) {
const cases = [];
const document = `BusinessHole${process.pid}`;
const copy = `${document}Copy`;
const path = `/tmp/${document}.FCStd`;
let worker;
let sequence = 0;
const check = (input, actual, assertions) => {
cases.push({ input, actual, assertions: Object.entries(assertions).map(([name, passed]) => ({ name, passed: Boolean(passed) })) });
};
const send = (action, args = {}, doc = document) => worker.send(worker.request(`hole-${++sequence}`, 'partdesign.document', {
schema: 'freecad-web-partdesign/document/v1', module: 'PartDesign', document: doc, action, ...args,
}, doc));
const object = (reply, name) => reply.result?.objects?.find(item => item.name === name);
const holeArgs = { body: 'Body', object: 'Hole', profile: 'HoleProfile', diameter: 4, depth: 5 };
const desktop = (args, doc = document) => worker.desktop({ request: (_module, command, payload, documentId) =>
worker.send(worker.request(`hole-desktop-${++sequence}`, command, payload, documentId)) },
'PartDesign_Hole', { ...args, document: doc }, doc);
try {
if (!kernel) ({ kernel } = await loadWasmProbe('web/public/kernel', 'FreeCADWebPartDesignRuntimeProbe'));
worker = loadBusinessWorker(kernel);
for (const [action, args] of [
['create', {}], ['createBody', { object: 'Body' }],
['setSketch', { body: 'Body', object: 'BaseProfile', profileType: 'Rectangle', parameters: { Width: 20, Height: 20 }, position: [0, 0, 0] }],
['setFeature', { body: 'Body', object: 'Pad', featureType: 'Pad', profile: 'BaseProfile', length: 10 }],
['setSketch', { body: 'Body', object: 'HoleProfile', profileType: 'Circle', parameters: { Radius: 2 }, position: [10, 10, 10] }],
]) {
const reply = await send(action, args);
check({ action, ...args }, reply, { 'native fixture created': reply.kind === 'result' });
if (reply.kind !== 'result') throw new Error(`Hole setup failed: ${JSON.stringify(reply)}`);
}
const created = await desktop({ ...holeArgs, selection: [{ documentId: document, object: 'HoleProfile' }], includeMesh: true });
const first = object(created, 'Hole');
check(holeArgs, created, {
'native Hole owns profile and body': first?.type === 'PartDesign::Hole' && first.body === 'Body' && first.profile === 'HoleProfile',
'native Hole is body tip': object(created, 'Body')?.tip === 'Hole',
'native Hole removes material and provides mesh': first?.volume > 0 && first.volume < 4000 && first.mesh?.triangleCount > 0,
'native Hole parameters persist': first?.diameter === 4 && first.depth === 5 && first.depthType === 'Dimension',
});
const edited = await desktop({ ...holeArgs, diameter: 6, selection: [{ documentId: document, object: 'Hole' }] });
const second = object(edited, 'Hole');
check({ diameter: 6, selectedObject: 'Hole' }, edited, {
'selected Hole retains its native profile on edit': second?.profile === 'HoleProfile' && second?.body === 'Body',
'larger diameter removes more native material': second?.volume > 0 && second.volume < first?.volume,
});
const undone = await send('undo');
check({ action: 'undo' }, undone, { 'undo restores Hole geometry and diameter': object(undone, 'Hole')?.diameter === 4 && Math.abs(object(undone, 'Hole')?.volume - first?.volume) < 1e-7 });
const redone = await send('redo');
check({ action: 'redo' }, redone, { 'redo restores Hole edit': object(redone, 'Hole')?.diameter === 6 && Math.abs(object(redone, 'Hole')?.volume - second?.volume) < 1e-7 });
// Native validation occurs inside the edit transaction; a missing profile must roll it back.
const rejected = await desktop({ ...holeArgs, profile: 'MissingProfile' });
const unchanged = await send('snapshot');
check({ profile: 'MissingProfile' }, rejected, {
'invalid native edit fails': rejected.kind !== 'result',
'failed edit preserves geometry and transaction history': object(unchanged, 'Hole')?.diameter === 6
&& Math.abs(object(unchanged, 'Hole')?.volume - second?.volume) < 1e-7
&& unchanged.result?.undoCount === redone.result?.undoCount,
});
const foreign = await desktop({ ...holeArgs, selection: [{ documentId: copy, object: 'HoleProfile' }] });
check({ selectionDocument: copy }, foreign, { 'foreign selection rejected': foreign.kind !== 'result' });
for (const selection of [
[{ documentId: document, object: 'Body' }],
[{ documentId: document, object: 'Hole', subelements: ['Face1'] }],
[{ documentId: copy, object: 'Hole' }],
]) {
const invalidSelection = await desktop({ ...holeArgs, selection });
check({ selection }, invalidSelection, { 'invalid feature edit selection rejected': invalidSelection.kind !== 'result' });
}
const saved = await send('save', { path });
check({ action: 'save' }, saved, { 'native FCStd bytes produced': saved.result?.bytes?.byteLength > 0 });
const opened = await send('open', { path, bytes: saved.result?.bytes }, copy);
check({ action: 'open' }, opened, {
'reopen preserves native Hole links and geometry': object(opened, 'Hole')?.profile === 'HoleProfile'
&& object(opened, 'Hole')?.body === 'Body' && object(opened, 'Body')?.tip === 'Hole'
&& Math.abs(object(opened, 'Hole')?.volume - second?.volume) < 1e-7,
});
const continued = await desktop({ ...holeArgs, depth: 3, selection: [{ documentId: copy, object: 'Hole' }] }, copy);
check({ document: copy, depth: 3 }, continued, { 'reopened Hole remains editable': object(continued, 'Hole')?.depth === 3 && object(continued, 'Hole')?.volume > second?.volume });
const restored = await send('undo', {}, copy);
check({ document: copy, action: 'undo' }, restored, { 'reopened edit supports undo': object(restored, 'Hole')?.diameter === 6 && object(restored, 'Hole')?.depth === 5 });
} catch (error) {
check({ capabilityId: holeId }, { error: String(error.stack || error) }, { 'Hole workflow completed': false });
} finally {
if (worker) for (const doc of [document, copy]) await send('close', {}, doc);
}
const failed = cases.some(item => item.assertions.some(assertion => !assertion.passed));
results.push({ capabilityId: holeId, status: failed ? 'failed' : 'passed', cases });
if (failed) exitCode = 1;
}
const { datumCloneIds, runDatumCloneCases } = await import('./partdesign-datum-clone-business-cases.mjs');
const datumCloneResults = await runDatumCloneCases((selectedIds ?? datumCloneIds).filter(id => datumCloneIds.includes(id)), loadBusinessWorker);
results.push(...datumCloneResults);
if (datumCloneResults.some(result => result.status !== 'passed')) exitCode = 1;
const profileResults = await runProfileCases((selectedIds ?? []).filter(id => profileIds.includes(id)));
results.push(...profileResults);
if (profileResults.some(result => result.status !== 'passed')) exitCode = 1;
const finishingResults = await runFinishingCases((selectedIds ?? []).filter(id => Object.hasOwn(finishingCases, id)), loadBusinessWorker);
results.push(...finishingResults);
if (finishingResults.some(result => result.status !== 'passed')) exitCode = 1;
const additiveResults = await runAdditiveCases((selectedIds ?? []).filter(id => additiveIds.includes(id)), loadBusinessWorker);
results.push(...additiveResults);
if (additiveResults.some(result => result.status !== 'passed')) exitCode = 1;
const subtractiveResults = await runSubtractiveCases((selectedIds ?? []).filter(id => subtractiveIds.includes(id)), loadBusinessWorker);
results.push(...subtractiveResults);
if (subtractiveResults.some(result => result.status !== 'passed')) exitCode = 1;
process.stdout.write(`${JSON.stringify({ schema: 'freecad-web-business-cases/v1', results })}\n`, () => process.exit(exitCode));