Files
FreeCADWasmDev/tools/bim-relation-business-cases.mjs

142 lines
8.9 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 { pathToFileURL, fileURLToPath } from 'node:url';
const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..');
export const relationIds=['BIM:desktop:BIM_Ungroup','BIM:desktop:BIM_Unclone'];
export async function runRelationCases(kernel, ids) {
if (!ids.length) return [];
const out=fs.mkdtempSync(path.join(os.tmpdir(),'freecad-bim-relations-'));
const results=[];
const python=source=>{
const value=JSON.parse(kernel.ccall('fc_web_bim_test_python','string',['string'],[
'import FreeCAD as App, Part, json\n'+source]));
if(value.ok===false) throw Error(value.error);
return value;
};
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();
for(const capabilityId of ids) {
const command=capabilityId.split(':').at(-1), document='RelationCase', other='RelationOther';
const record={id:capabilityId,module:'BIM',command,sourcePath:`src/Mod/BIM/bimcommands/${command==='BIM_Ungroup'?'BimUngroup':'BimUnclone'}.py`,sourceLine:1};
const row={capabilityId,status:'failed',cases:[]};results.push(row);
let sequence=0;
let selectedObject='Selected', sourceObject='SourceWall';
const invoke=async overrides=>{
const args=adapter.prepareBimDesktopArgs(record,{documentId:document,action:'apply',selection:[{object:selectedObject}],...overrides});
const request=facade.createBimPythonFamilyBatchRequest(`relation-${command}-${++sequence}`,[{apiId:capabilityId,name:command,
family:'desktop-command',sourcePath:record.sourcePath,sourceLine:1,args}],'tier-2-pure');
return adapter.validateBimDesktopResponse(record,await facade.dispatchBimPythonFamilyBatch(queue,request,kernel),capabilityId);
};
const snapshot=()=>python(`
d=App.getDocument('${document}')
result=json.dumps({'active':App.ActiveDocument.Name,'objects':[
{'name':o.Name,'label':o.Label,'group':[c.Name for c in o.Group] if 'Group' in o.PropertiesList else None,
'clone':o.CloneOf.Name if 'CloneOf' in o.PropertiesList and o.CloneOf else None,
'volume':o.Shape.Volume if o.isDerivedFrom('Part::Feature') else None,
'position':list(o.Placement.Base) if hasattr(o,'Placement') else None}
for o in d.Objects], 'otherVolume':App.getDocument('${other}').getObject('Independent').Shape.Volume})`);
const add=(input,actual,name)=>row.cases.push({input,actual,assertions:[{name,passed:true}]});
try {
python(`
for name in ['${document}','${other}']:
if name in App.listDocuments(): App.closeDocument(name)
d=App.newDocument('${document}'); d.UndoMode=1
first=d.addObject('Part::Box','Selected'); first.Length=2; first.Width=3; first.Height=4
second=d.addObject('Part::Box','Unselected'); second.Length=3; second.Width=4; second.Height=5
group=d.addObject('App::DocumentObjectGroup','Group'); group.addObject(first); group.addObject(second)
secondGroup=d.addObject('App::DocumentObjectGroup','OtherGroup')
d.recompute()
other=App.newDocument('${other}'); other.addObject('Part::Box','Independent'); other.recompute()
result=json.dumps({'ready':True})`);
if(command==='BIM_Unclone') {
const created=python(`
import Arch
d=App.getDocument('${document}'); App.setActiveDocument(d.Name)
d.removeObject('Selected'); d.removeObject('Unselected')
source=Arch.makeWall(length=12,width=3,height=4,name='SourceWall')
clone=Arch.makeWall(length=12,width=3,height=4,name='Selected')
clone.CloneOf=source; clone.Placement.Base=App.Vector(10,20,30)
d.getObject('Group').addObject(clone); d.recompute()
App.setActiveDocument('${other}')
result=json.dumps({'clone':clone.Name,'source':source.Name})`);
selectedObject=created.clone;sourceObject=created.source;
}
const original=snapshot();
if(command==='BIM_Ungroup') {
const preview=await invoke({action:'read'});
assert.equal(preview.kind,'result',JSON.stringify(preview));
assert.deepEqual(snapshot(),original);
assert.equal(preview.result.records[0].result.memberships.length,1);
add({command,action:'read'},preview,'read reports native membership without mutating either document');
}
const reply=await invoke({});assert.equal(reply.kind,'result',JSON.stringify(reply));
const after=snapshot();
assert.equal(after.active,original.active);assert.equal(after.otherVolume,original.otherVolume);
if(command==='BIM_Ungroup') {
assert.deepEqual(after.objects.find(o=>o.name==='Group').group,['Unselected']);
assert.deepEqual(after.objects.find(o=>o.name==='OtherGroup').group,[]);
assert.deepEqual(after.objects.filter(o=>o.volume!==null),original.objects.filter(o=>o.volume!==null));
} else {
const clone=after.objects.find(o=>o.name===selectedObject);
assert.equal(clone.clone,null);assert.ok(Math.abs(clone.volume-144)<1e-6);assert.deepEqual(clone.position,[10,20,30]);
assert.deepEqual(after.objects.find(o=>o.name==='Group').group,[selectedObject]);
}
add({command,action:'apply',selection:[selectedObject]},{reply,original,after},'native relationship changes preserve geometry, unrelated objects and the active document');
python(`App.getDocument('${document}').undo(); result=json.dumps({'undo':True})`);
assert.deepEqual(snapshot(),original);
python(`App.getDocument('${document}').redo(); result=json.dumps({'redo':True})`);
assert.deepEqual(snapshot(),after);
add({command,action:'undo-redo'},snapshot(),'native undo and redo restore exact relationships and geometry');
for(const invalid of [{selection:[{object:'Missing'}]},{selection:[{object:selectedObject,documentId:other}]},{activeTask:true}]) {
const before=snapshot(); const rejected=await invoke(invalid);
assert.equal(rejected.kind,'error',JSON.stringify(rejected));assert.deepEqual(snapshot(),before);
add({command,...invalid},rejected,'invalid input is rejected without changing either document');
}
const saved=python(`
import os, shutil
d=App.getDocument('${document}'); d.saveAs('/tmp/RelationSaved.FCStd')
shutil.copyfile('/tmp/RelationSaved.FCStd','/tmp/RelationRestored.FCStd')
r=App.openDocument('/tmp/RelationRestored.FCStd'); r.recompute()
result=json.dumps({'bytes':os.path.getsize('/tmp/RelationSaved.FCStd'), 'objects':[o.Name for o in r.Objects],
'members':[o.Name for o in r.getObject('Group').Group], 'independent':r.Name!=d.Name})
App.closeDocument(r.Name); os.remove('/tmp/RelationSaved.FCStd'); os.remove('/tmp/RelationRestored.FCStd'); App.setActiveDocument('${other}')`);
assert.ok(saved.bytes>100);assert.deepEqual(saved.objects,after.objects.map(o=>o.name));
assert.equal(saved.independent,true);
assert.deepEqual(saved.members,after.objects.find(o=>o.name==='Group').group);
add({command,action:'save-reopen'},saved,'FCStd persists the resulting native relationships');
if(command==='BIM_Unclone') {
const independent=python(`
d=App.getDocument('${document}')
d.getObject(${JSON.stringify(sourceObject)}).Width=6; d.recompute()
result=json.dumps({'sourceVolume':d.getObject(${JSON.stringify(sourceObject)}).Shape.Volume,'cloneVolume':d.getObject(${JSON.stringify(selectedObject)}).Shape.Volume,
'clonePosition':list(d.getObject(${JSON.stringify(selectedObject)}).Placement.Base)})`);
assert.ok(Math.abs(independent.sourceVolume-288)<1e-6);assert.ok(Math.abs(independent.cloneVolume-144)<1e-6);
assert.deepEqual(independent.clonePosition,[10,20,30]);
add({command,action:'edit-source-after-unclone',width:6},independent,'uncloned geometry stays independent after source changes');
}
row.status='passed';
} catch(error) {
row.error=String(error.stack??error);
row.cases.push({input:{command,phase:'relation-workflow'},assertions:[{name:'relationship workflow completes',passed:false,actual:row.error}]});
}
finally {
try {python(`
for name in ['${document}','${other}']:
if name in App.listDocuments(): App.closeDocument(name)
result=json.dumps({'closed':True})`);} catch(error){row.status='failed';row.error=String(error);}
}
}
} finally {fs.rmSync(out,{recursive:true,force:true});}
return results;
}