Files
FreeCADWasmDev/tools/partdesign-body-business-cases.mjs

242 lines
16 KiB
JavaScript

import assert from 'node:assert/strict';
import { loadWasmProbe } from './wasm-probe-runtime.mjs';
import { playwright } from './part-primitive-business-cases.mjs';
export const bodyCommandIds = ['DuplicateSelection', 'Migrate', 'MoveFeature', 'MoveFeatureInTree', 'MoveTip']
.map(command => `PartDesign:desktop:PartDesign_${command}`);
const schema = 'freecad-web-partdesign/document/v1';
const compact = state => ({ ...state, objects: state.objects?.map(({ mesh, ...object }) => ({ ...object,
...(mesh ? { mesh: { triangleCount: mesh.triangleCount } } : {}) })) });
const near = (actual, expected) => assert.ok(Number.isFinite(actual) && Math.abs(actual - expected) < 1e-6, `${actual} != ${expected}`);
async function workflow(type, document, native, execute, record) {
if (type === 'MoveTip') return moveTipWorkflow(document, native, execute, record);
const object = (state, name) => state.objects.find(item => item.name === name);
const accept = async args => {
const reply = await execute(args); assert.equal(reply.kind, 'result', JSON.stringify(reply)); return reply.result;
};
await native('create'); await native('createBody', { object: 'Body' });
await native('setSketch', { object: 'Profile', profileType: 'Rectangle', parameters: { Width: 4, Height: 3 }, position: [0, 0, 0] });
await native('setFeature', { object: 'Pad', featureType: 'Pad', profile: 'Profile', length: 5 });
await native('createBody', { object: 'Target' });
let input, state, copiedPad, copiedProfile;
if (type === 'Migrate') {
const copy = await native('duplicateSelection', { objects: ['Pad'], recursive: true, body: undefined });
copiedPad = copy.objects.find(item => item.type === 'PartDesign::Pad' && item.name !== 'Pad').name;
copiedProfile = object(copy, copiedPad).profile;
assert.equal(object(copy, copiedPad).body, undefined);
input = { selection: [], includeMesh: true };
} else if (type === 'DuplicateSelection') {
input = { body: 'Target', objects: ['Pad'], recursive: true,
selection: [{ documentId: document, object: 'Pad' }], includeMesh: true };
} else if (type === 'MoveFeature') {
input = { body: 'Body', targetBody: 'Target', objects: ['Pad'],
selection: [{ documentId: document, object: 'Pad' }], includeMesh: true };
} else {
await native('setDatum', { object: 'First', datumType: 'Point', origin: [1, 2, 3] });
await native('setDatum', { object: 'Second', datumType: 'Point', origin: [4, 5, 6] });
input = { body: 'Body', objects: ['Second'], after: 'Profile',
selection: [{ documentId: document, object: 'Second' }], includeMesh: true };
}
const before = await native('snapshot');
state = await accept(input);
if (type === 'DuplicateSelection') {
copiedPad = state.objects.find(item => item.type === 'PartDesign::Pad' && item.name !== 'Pad').name;
copiedProfile = object(state, copiedPad).profile;
}
const verify = state => {
assert.equal(object(state, 'Pad').type, 'PartDesign::Pad'); near(object(state, 'Pad').volume, 60);
if (type === 'DuplicateSelection' || type === 'Migrate') {
const owner = type === 'Migrate' ? object(state, copiedPad).body : 'Target';
assert.ok(owner); assert.notEqual(copiedProfile, 'Profile');
assert.equal(object(state, copiedPad).body, owner);
assert.equal(object(state, copiedProfile).body, owner);
assert.equal(object(state, copiedProfile).dof, 0);
assert.equal(object(state, owner).tip, copiedPad);
assert.deepEqual(Array.from(object(state, owner).group), [copiedProfile, copiedPad]);
near(object(state, copiedPad).volume, 60); assert.equal(object(state, 'Pad').body, 'Body');
} else if (type === 'MoveFeature') {
assert.equal(object(state, 'Pad').body, 'Target'); assert.equal(object(state, 'Profile').body, 'Target');
assert.equal(object(state, 'Target').tip, 'Pad');
assert.deepEqual(Array.from(object(state, 'Target').group), ['Profile', 'Pad']);
assert.equal(object(state, 'Body').group.length, 0); assert.equal(object(state, 'Body').tip, '');
} else {
assert.deepEqual(Array.from(object(state, 'Body').group), ['Profile', 'Second', 'Pad', 'First']);
assert.equal(object(state, 'Body').tip, 'Pad'); assert.equal(object(state, 'Second').body, 'Body');
}
};
verify(state);
assert.ok(object(state, copiedPad ?? 'Pad').mesh.triangleCount > 0);
assert.equal(state.undoCount, before.undoCount + 1);
record(input, state, 'native command preserves constrained profiles, real Pad geometry, Body membership, order and tip in one transaction');
const undone = await native('undo');
assert.deepEqual(undone.objects, before.objects);
state = await native('redo'); verify(state);
record({ actions: ['undo', 'redo'] }, state, 'native undo restores original objects and redo restores command ownership and geometry');
const invalid = type === 'Migrate' ? [{ selection: [{ documentId: 'Foreign', object: 'Pad' }] }]
: [{ objects: ['Missing'], selection: [] }, { objects: ['Pad', 'Pad'], selection: [] },
{ selection: [{ documentId: 'Foreign', object: 'Pad' }] },
...(type === 'MoveFeature' ? [{ body: 'Target', targetBody: 'Target', selection: [] }] : []),
...(type === 'MoveFeatureInTree' ? [{ objects: ['Profile'], after: 'Pad', selection: [] }] : [])];
for (const change of [...invalid, { activeTask: true }, { action: 'cancel' }]) {
const stable = await native('snapshot');
const reply = await execute({ ...input, ...change });
assert.equal(reply.kind, 'error', JSON.stringify(reply)); assert.deepEqual(await native('snapshot'), stable);
record(change, reply, 'invalid selection, dependency order or active task preserves document and undo history');
}
const path = `/tmp/${document}.FCStd`;
await native('save', { path }); await native('close');
state = await native('open', { path }); verify(state);
record({ path, actions: ['save', 'close', 'open'] }, state, 'FCStd restores actual native object types, profiles, ownership, order and tip');
const editBody = type === 'MoveFeature' ? 'Target' : 'Body';
state = await native('setFeature', { body: editBody, object: 'Pad', featureType: 'Pad', profile: 'Profile', length: 7 });
near(object(state, 'Pad').volume, 84);
if (copiedPad) near(object(state, copiedPad).volume, 60);
record({ object: 'Pad', length: 7 }, state, 'continued parametric edit after restore updates source geometry while recursive copies stay independent');
if (copiedPad) {
const owner = object(state, copiedPad).body;
state = await native('setFeature', { body: owner, object: copiedPad, featureType: 'Pad', profile: copiedProfile, length: 9 });
near(object(state, copiedPad).volume, 108); near(object(state, 'Pad').volume, 84);
record({ object: copiedPad, length: 9 }, state, 'copied or migrated Pad retains its editable native profile relationship');
}
await native('close');
}
async function moveTipWorkflow(document, native, execute, record) {
const object = (state, name) => state.objects.find(item => item.name === name);
const select = name => ({ selection: [{ documentId: document, object: name }], includeMesh: true });
const move = async name => {
const reply = await execute(select(name));
assert.equal(reply.kind, 'result', JSON.stringify(reply));
assert.deepEqual(Array.from(reply.result.selectedObjects), [name]);
return reply.result;
};
await native('create'); await native('createBody', { object: 'Body' });
await native('setSketch', { object: 'Profile', profileType: 'Rectangle', parameters: { Width: 4, Height: 3 }, position: [0, 0, 0] });
await native('setFeature', { object: 'Pad', featureType: 'Pad', profile: 'Profile', length: 5 });
await native('setSketch', { object: 'Upper', profileType: 'Rectangle', parameters: { Width: 4, Height: 3 }, position: [0, 0, 5] });
await native('setFeature', { object: 'TopPad', featureType: 'Pad', profile: 'Upper', length: 2 });
await native('setDatum', { object: 'Datum', datumType: 'Point', origin: [1, 2, 3] });
const initial = await native('snapshot');
const group = Array.from(object(initial, 'Body').group);
assert.equal(object(initial, 'Body').tip, 'TopPad');
const verify = (state, tip) => {
assert.equal(object(state, 'Body').tip, tip);
assert.deepEqual(Array.from(object(state, 'Body').group), group);
for (const [name, volume] of [['Pad', 60], ['TopPad', 84]]) {
assert.equal(object(state, name).type, 'PartDesign::Pad');
assert.equal(object(state, name).body, 'Body'); near(object(state, name).volume, volume);
}
assert.equal(object(state, 'Pad').profile, 'Profile');
assert.equal(object(state, 'TopPad').profile, 'Upper');
if (tip) near(object(state, 'Body').volume, tip === 'Pad' ? 60 : 84);
};
let state = await move('Pad'); verify(state, 'Pad');
assert.ok(object(state, 'Pad').mesh.triangleCount > 0);
assert.equal(state.undoCount, initial.undoCount + 1);
record(select('Pad'), state, 'moving Tip changes native Body geometry to the selected Pad while preserving downstream features, links and order');
const stable = await native('snapshot');
state = await move('Pad'); verify(state, 'Pad');
assert.equal(state.undoCount, stable.undoCount); assert.equal(state.redoCount, stable.redoCount);
record(select('Pad'), state, 'selecting the current Tip is a successful no-op without undo history changes');
state = await native('undo'); verify(state, 'TopPad');
state = await native('redo'); verify(state, 'Pad');
record({ actions: ['undo', 'redo'] }, state, 'undo and redo restore native Body Tip and shape');
state = await move('Body'); verify(state, '');
record(select('Body'), state, 'selecting the Body clears its Tip without deleting or reordering features');
state = await native('undo'); verify(state, 'Pad');
state = await native('redo'); verify(state, '');
record({ actions: ['undo', 'redo'] }, state, 'clearing Tip participates in native undo and redo');
for (const input of [
{ selection: [] }, { selection: [select('Pad').selection[0], select('TopPad').selection[0]] },
select('Missing'), select('Profile'), select('Datum'),
{ selection: [{ documentId: 'Foreign', object: 'Pad' }] },
{ selection: [{ documentId: document, object: 'Pad', subelements: ['Face1'] }] },
{ ...select('Pad'), objects: ['TopPad'] }, { ...select('Pad'), activeTask: true },
{ ...select('Pad'), action: 'cancel' },
]) {
const before = await native('snapshot');
const reply = await execute(input); assert.equal(reply.kind, 'error', JSON.stringify(reply));
assert.deepEqual(await native('snapshot'), before);
record(input, reply, 'invalid MoveTip selection or task state preserves native document and undo history');
}
const path = `/tmp/${document}.FCStd`;
await native('save', { path }); await native('close');
state = await native('open', { path }); verify(state, '');
record({ path, tip: '', actions: ['save', 'close', 'open'] }, state, 'FCStd preserves a cleared Tip and all native feature relationships');
state = await move('Pad'); verify(state, 'Pad');
await native('save', { path }); await native('close');
state = await native('open', { path }); verify(state, 'Pad');
record({ path, tip: 'Pad', actions: ['save', 'close', 'open'] }, state, 'FCStd restores a nonterminal Tip with downstream features intact');
state = await move('TopPad'); verify(state, 'TopPad');
state = await native('setFeature', { object: 'TopPad', featureType: 'Pad', profile: 'Upper', length: 3 });
near(object(state, 'TopPad').volume, 96); near(object(state, 'Body').volume, 96);
record({ object: 'TopPad', length: 3 }, state, 'restored downstream feature remains parametrically editable after moving Tip forward');
await native('close');
}
export async function runBodyCommandCases(ids, loadWorker) {
if (!ids.length) return [];
const started = performance.now();
const { kernel } = await loadWasmProbe('web/public/kernel', 'FreeCADWebPartDesignRuntimeProbe');
const worker = loadWorker(kernel), results = [];
const loadSeconds = (performance.now() - started) / 1000;
let sequence = 0;
const router = { request: (_module, command, payload, document) =>
worker.send(worker.request(`body-command-${++sequence}`, command, payload, document)) };
for (const capabilityId of ids) {
const type = capabilityId.split('_').at(-1), document = `Body${type}${process.pid}`;
const result = { capabilityId, status: 'failed', cases: [], timing: [{ stage: 'load', seconds: loadSeconds }] };
results.push(result);
const record = (input, actual, name) => result.cases.push({ input, actual: compact(actual), assertions: [{ name, passed: true }] });
const native = async (action, args = {}) => {
const reply = await router.request('PartDesign', 'partdesign.document', { schema, module: 'PartDesign', document, body: 'Body', action, ...args }, document);
assert.equal(reply.kind, 'result', JSON.stringify(reply)); return reply.result;
};
let browser, selected;
try {
let start = performance.now();
await workflow(type, document, native, args => worker.desktop(router, `PartDesign_${type}`, args, document,
(_document, selection) => { selected = selection; }), record);
assert.ok(selected.length > 0);
record({ selection: true }, selected, 'successful desktop adapter publishes native object selection');
result.timing.push({ stage: 'native-workflow', seconds: (performance.now() - start) / 1000 });
start = 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 page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
page.setDefaultTimeout(20000);
await page.goto(`${process.env.FREECAD_BROWSER_ORIGIN ?? 'http://localhost:5180'}/workbench.html?module=PartDesign`);
const browserDocument = `Browser${type}`;
const run = async (command, args) => {
const buttons = page.locator('button.command').filter({ has: page.getByText(command, { exact: true }) });
await (command.startsWith('PartDesign_') ? buttons.filter({ hasText: 'desktop-gui' }) : buttons).click();
const before = await page.locator('.result').textContent();
await page.locator('#payload-editor').fill(JSON.stringify({ document: browserDocument, documentId: browserDocument, body: 'Body', ...args }));
await page.locator('button.run').filter({ hasText: '\u6267\u884c\u9009\u4e2d\u547d\u4ee4' }).click();
await page.waitForFunction(previous => {
const text = document.querySelector('.result')?.textContent;
if (!text || text === previous) return false;
try { return ['result', 'error'].includes(JSON.parse(text).kind); } catch { return false; }
}, before);
return JSON.parse(await page.locator('.result').textContent());
};
const browserNative = async (action, args = {}) => {
const reply = await run('partdesign.document', { schema, module: 'PartDesign', action, ...args });
assert.equal(reply.kind, 'result', JSON.stringify(reply)); return reply.result;
};
await workflow(type, browserDocument, browserNative, args => run(`PartDesign_${type}`, args),
(input, actual, name) => record({ browser: true, ...input }, actual, name));
result.timing.push({ stage: 'mounted-browser-workflow', seconds: (performance.now() - start) / 1000 });
result.status = 'passed';
} catch (error) {
result.cases.push({ actual: { error: String(error), stack: error.stack }, assertions: [{ name: 'native and mounted Body command workflow completes', passed: false }] });
} finally {
if (browser) await browser.close();
try { await native('close'); } catch { /* Preserve first failure. */ }
try { kernel.FS.unlink(`/tmp/${document}.FCStd`); } catch { /* No file on early failure. */ }
}
}
return results;
}