186 lines
12 KiB
JavaScript
186 lines
12 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';
|
|
|
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
export const nudgeIds = ['Up', 'Down', 'Left', 'Right', 'Extend', 'Shrink', 'RotateLeft', 'RotateRight', 'Switch']
|
|
.map(mode => `BIM:desktop:BIM_Nudge_${mode}`);
|
|
const near = (actual, expected) => assert.ok(Number.isFinite(actual) && Math.abs(actual - expected) < 1e-6, `${actual} != ${expected}`);
|
|
const vectorNear = (actual, expected) => { assert.equal(actual.length, expected.length); actual.forEach((v, i) => near(v, expected[i])); };
|
|
|
|
export async function runNudgeCases(kernel, ids) {
|
|
if (!ids.length) return [];
|
|
const out = fs.mkdtempSync(path.join(os.tmpdir(), 'freecad-bim-nudge-'));
|
|
const results = [];
|
|
const started = performance.now();
|
|
const python = script => {
|
|
const reply = JSON.parse(kernel.ccall('fc_web_bim_test_python', 'string', ['string'], [`import FreeCAD as App, Part, json\n${script}`]));
|
|
if (reply?.ok === false) throw new Error(reply.error || JSON.stringify(reply));
|
|
return reply;
|
|
};
|
|
try {
|
|
execFileSync(path.join(root, 'web/node_modules/.bin/tsc'), [
|
|
'--target', 'ES2022', '--module', 'ESNext', '--moduleResolution', 'Bundler', '--strict', '--skipLibCheck',
|
|
'--outDir', out, path.join(root, 'web/src/bim-python-family-batch-facade.ts'), path.join(root, 'web/src/bim-desktop-adapter.ts'),
|
|
], { cwd: root, stdio: 'pipe' });
|
|
const facade = await import(pathToFileURL(path.join(out, 'bim-python-family-batch-facade.js')));
|
|
const adapter = await import(pathToFileURL(path.join(out, 'bim-desktop-adapter.js')));
|
|
const { FacadeSerialQueue } = await import(pathToFileURL(path.join(out, 'facade-envelope.js')));
|
|
const queue = new FacadeSerialQueue();
|
|
const compileSeconds = (performance.now() - started) / 1000;
|
|
for (const capabilityId of ids) {
|
|
const row = { capabilityId, status: 'failed', cases: [], timing: { sharedTranspileSeconds: compileSeconds } };
|
|
results.push(row);
|
|
const start = performance.now();
|
|
const command = capabilityId.split(':').at(-1), mode = command.replace('BIM_Nudge_', '');
|
|
const document = `Nudge_${mode}`, other = `${document}_Other`, restored = `${document}_Restored`;
|
|
const savedPath = `/tmp/${document}.FCStd`;
|
|
const record = { id: capabilityId, module: 'BIM', command, sourcePath: 'src/Mod/BIM/bimcommands/BimNudge.py', sourceLine: 1 };
|
|
let sequence = 0;
|
|
const baseArgs = { documentId: document, selection: [{ object: 'First' }, { object: 'Second' }], distance: 2 };
|
|
const invoke = async overrides => {
|
|
const args = adapter.prepareBimDesktopArgs(record, { ...baseArgs, ...overrides });
|
|
const request = facade.createBimPythonFamilyBatchRequest(`nudge-${mode}-${++sequence}`, [{
|
|
apiId: capabilityId, name: command, family: 'desktop-command', sourcePath: record.sourcePath,
|
|
sourceLine: record.sourceLine, args,
|
|
}], 'tier-2-pure');
|
|
const response = await facade.dispatchBimPythonFamilyBatch(queue, request, kernel);
|
|
return adapter.validateBimDesktopResponse(record, response, capabilityId);
|
|
};
|
|
const ok = async args => { const reply = await invoke(args); assert.equal(reply.kind, 'result', JSON.stringify(reply)); return reply.result.records[0].result; };
|
|
const snapshot = (name = document) => python(`
|
|
d = App.getDocument(${JSON.stringify(name)})
|
|
result = json.dumps({'active': App.ActiveDocument.Name, 'objects': [
|
|
{'name': o.Name, 'position': list(o.Placement.Base), 'rotation': list(o.Placement.Rotation.Q),
|
|
'height': o.Height.Value, 'volume': o.Shape.Volume, 'valid': o.Shape.isValid(),
|
|
'bounds': [o.Shape.BoundBox.XMin, o.Shape.BoundBox.YMin, o.Shape.BoundBox.ZMin,
|
|
o.Shape.BoundBox.XMax, o.Shape.BoundBox.YMax, o.Shape.BoundBox.ZMax]}
|
|
for o in d.Objects]})`);
|
|
const add = (input, actual, name) => row.cases.push({ input, actual, assertions: [{ name, passed: true }] });
|
|
try {
|
|
assert.ok(nudgeIds.includes(capabilityId));
|
|
python(`
|
|
for name in ${JSON.stringify([document, other, restored])}:
|
|
if name in App.listDocuments(): App.closeDocument(name)
|
|
d = App.newDocument(${JSON.stringify(document)})
|
|
d.UndoMode = 1
|
|
for name, dims, position in [('First', [4, 6, 10], [10, 20, 30]), ('Second', [2, 3, 8], [-7, 2, 3]), ('Unselected', [1, 2, 3], [80, 80, 80])]:
|
|
obj = d.addObject('Part::Box', name)
|
|
obj.Length, obj.Width, obj.Height = dims
|
|
obj.Placement.Base = App.Vector(*position)
|
|
d.recompute()
|
|
other = App.newDocument(${JSON.stringify(other)})
|
|
obj = other.addObject('Part::Box', 'Independent')
|
|
obj.Length, obj.Width, obj.Height = [7, 8, 9]
|
|
other.recompute()
|
|
App.setActiveDocument(other.Name)
|
|
result = json.dumps({'created': d.Name, 'other': other.Name})`);
|
|
const isolated = snapshot(other);
|
|
const rotating = mode.startsWith('Rotate'), resizing = ['Extend', 'Shrink'].includes(mode);
|
|
const variants = mode === 'Switch' ? [{ nudge: '5 mm', selection: [] }, { nudge: 'Auto', viewWidth: 600, selection: [] }]
|
|
: rotating ? [{}, { workingPlane: { u: [0, 1, 0], v: [0, 0, 1] } }]
|
|
: [{ distance: 2 }, { distance: '0.5 cm', workingPlane: { u: [0, 1, 0], v: [0, 0, 1] } }, { nudge: 'Auto', viewWidth: 600 }];
|
|
for (const input of variants) {
|
|
const before = snapshot(), actual = await ok(input), after = snapshot();
|
|
assert.equal(actual.documentId, document); assert.equal(after.active, before.active);
|
|
assert.deepEqual(after.objects[2], before.objects[2]); assert.deepEqual(snapshot(other), isolated);
|
|
if (mode === 'Switch') {
|
|
assert.equal(actual.nudge.toLowerCase().includes('auto'), input.nudge !== 'Auto');
|
|
assert.deepEqual(after, before);
|
|
} else {
|
|
assert.equal(actual.objects.length, 2);
|
|
const distance = input.distance === 2 ? 2 : 5;
|
|
for (let i = 0; i < 2; i++) {
|
|
const previous = before.objects[i], next = after.objects[i];
|
|
assert.equal(next.valid, true); vectorNear(actual.objects[i].placement, next.position);
|
|
if (resizing) {
|
|
near(next.height, previous.height + (mode === 'Extend' ? distance : -distance));
|
|
near(next.volume, previous.volume / previous.height * next.height); vectorNear(next.position, previous.position);
|
|
} else if (rotating) {
|
|
const center = [0, 1, 2].map(axis => (Math.min(...before.objects.slice(0, 2).map(o => o.bounds[axis]))
|
|
+ Math.max(...before.objects.slice(0, 2).map(o => o.bounds[axis + 3]))) / 2);
|
|
const angle = (mode === 'RotateLeft' ? 1 : -1) * Math.PI / 4, c = Math.cos(angle), s = Math.sin(angle);
|
|
const rel = previous.position.map((v, axis) => v - center[axis]);
|
|
const expected = input.workingPlane ? [rel[0], c * rel[1] - s * rel[2], s * rel[1] + c * rel[2]]
|
|
: [c * rel[0] - s * rel[1], s * rel[0] + c * rel[1], rel[2]];
|
|
vectorNear(next.position, expected.map((v, axis) => v + center[axis])); near(next.volume, previous.volume);
|
|
} else {
|
|
const plane = input.workingPlane || { u: [1, 0, 0], v: [0, 1, 0] };
|
|
const axis = ['Up', 'Down'].includes(mode) ? plane.v : plane.u;
|
|
const sign = ['Down', 'Left'].includes(mode) ? -1 : 1;
|
|
vectorNear(next.position, previous.position.map((v, i) => v + sign * distance * axis[i])); near(next.volume, previous.volume);
|
|
}
|
|
}
|
|
python(`App.getDocument(${JSON.stringify(document)}).undo()\nresult = json.dumps({'undone': True})`);
|
|
assert.deepEqual(snapshot(), before);
|
|
python(`App.getDocument(${JSON.stringify(document)}).redo()\nresult = json.dumps({'redone': True})`);
|
|
assert.deepEqual(snapshot(), after);
|
|
// Keep each parameter variant independent, including shrink's positive heights.
|
|
python(`App.getDocument(${JSON.stringify(document)}).undo()\nresult = json.dumps({'reset': True})`);
|
|
}
|
|
add({ command, ...baseArgs, ...input }, { actual, before, after },
|
|
'production facade accepts native result; supplied parameters change selected geometry with undo/redo and document isolation');
|
|
}
|
|
const invalidInputs = [
|
|
{ activeTask: true }, { documentId: 'MissingNudgeDocument' },
|
|
{ selection: [{ object: 'First' }, { object: 'Missing' }] },
|
|
{ selection: [{ object: 'First', documentId: other }] },
|
|
{ workingPlane: { u: [2, 0, 0], v: [0, 1, 0] } },
|
|
{ workingPlane: { u: [1, 0, 0], v: [1, 0, 0] } },
|
|
...(!rotating ? [{ distance: 0 }, { distance: 'invalid unit' }] : []),
|
|
...(mode === 'Switch' ? [] : [{ selection: [] }]),
|
|
];
|
|
for (const input of invalidInputs) {
|
|
const before = snapshot(), rejected = await invoke(input);
|
|
assert.equal(rejected.kind, 'error', JSON.stringify(rejected));
|
|
assert.notEqual(rejected.error.code, 'native_protocol_error', JSON.stringify(rejected));
|
|
assert.deepEqual(snapshot(), before); assert.deepEqual(snapshot(other), isolated);
|
|
add({ command, ...baseArgs, ...input }, rejected, 'invalid inputs return business errors without changing either document');
|
|
}
|
|
const recovered = await ok(mode === 'Switch' ? { nudge: '5 mm', selection: [] } : {}), saved = snapshot();
|
|
const restoredState = python(`
|
|
import os, shutil
|
|
d = App.getDocument(${JSON.stringify(document)})
|
|
d.saveAs(${JSON.stringify(savedPath)})
|
|
copy = ${JSON.stringify(savedPath + '.copy.FCStd')}
|
|
shutil.copyfile(${JSON.stringify(savedPath)}, copy)
|
|
r = App.openDocument(copy)
|
|
r.recompute()
|
|
result = json.dumps({'size': os.path.getsize(copy), 'objects': [
|
|
{'name': o.Name, 'position': list(o.Placement.Base), 'rotation': list(o.Placement.Rotation.Q),
|
|
'height': o.Height.Value, 'volume': o.Shape.Volume} for o in r.Objects]})
|
|
App.closeDocument(r.Name)
|
|
App.setActiveDocument(${JSON.stringify(other)})
|
|
os.remove(copy)`);
|
|
assert.ok(restoredState.size > 0);
|
|
assert.equal(restoredState.objects.length, saved.objects.length);
|
|
restoredState.objects.forEach((o, i) => {
|
|
assert.equal(o.name, saved.objects[i].name); vectorNear(o.position, saved.objects[i].position);
|
|
vectorNear(o.rotation, saved.objects[i].rotation); near(o.height, saved.objects[i].height); near(o.volume, saved.objects[i].volume);
|
|
});
|
|
add({ command, phase: 'recovery-and-FCStd-reopen', path: savedPath }, { recovered, restoredState },
|
|
'valid execution recovers after rejected input and saved FCStd preserves current native geometry');
|
|
row.status = 'passed';
|
|
} catch (error) {
|
|
row.error = String(error.stack || error);
|
|
row.cases.push({ input: { command, phase: 'execution' }, assertions: [{ name: 'workflow completed', passed: false, actual: row.error }] });
|
|
} finally {
|
|
try {
|
|
python(`
|
|
import os
|
|
for name in ${JSON.stringify([document, other, restored])}:
|
|
if name in App.listDocuments(): App.closeDocument(name)
|
|
for file in ${JSON.stringify([savedPath, savedPath + '1', savedPath + '.copy.FCStd'])}:
|
|
if os.path.isfile(file): os.remove(file)
|
|
result = json.dumps({'closed': True})`);
|
|
} catch (error) { row.status = 'failed'; row.error = `${row.error || ''}\ncleanup: ${error}`; }
|
|
row.timing.nativeAndFacadeSeconds = (performance.now() - start) / 1000;
|
|
}
|
|
}
|
|
} finally { fs.rmSync(out, { recursive: true, force: true }); }
|
|
return results;
|
|
}
|