242 lines
16 KiB
JavaScript
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;
|
|
}
|