Files
FreeCADWasmDev/tools/part-simple-cylinder-business-cases.mjs

110 lines
14 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 { createHash } from 'node:crypto';
import { pathToFileURL } from 'node:url';
import { loadWasmProbe } from './wasm-probe-runtime.mjs';
import { playwright } from './part-primitive-business-cases.mjs';
export const simpleCylinderId='Part:desktop:Part_SimpleCylinder';
export async function runSimpleCylinderCases(ids){
if(!ids.includes(simpleCylinderId))return [];
const temporary=fs.mkdtempSync(path.join(os.tmpdir(),'part-simple-cylinder-'));
const directory=path.resolve('docs/current/part-simple-cylinder-business',new Date().toISOString().replace(/[:.]/g,'-'));
fs.mkdirSync(directory,{recursive:true});
const result={capabilityId:simpleCylinderId,status:'failed',cases:[],evidenceFiles:{}};
const record=(input,actual,name)=>result.cases.push({input,actual,assertions:[{name,passed:true}]});
const near=(a,b)=>assert.ok(Number.isFinite(a)&&Math.abs(a-b)<1e-6,`${a} != ${b}`);
let kernel,browser;
try{
execFileSync('web/node_modules/.bin/tsc',['--target','ES2022','--module','ESNext','--moduleResolution','Bundler','--strict','--skipLibCheck',
'--outDir',temporary,'web/node_modules/vite/client.d.ts','web/src/part-desktop-adapter.ts'],{stdio:'pipe'});
const facade=await import(pathToFileURL(path.join(temporary,'part-document-facade.js'))),adapter=await import(pathToFileURL(path.join(temporary,'part-desktop-adapter.js')));
const {FacadeSerialQueue}=await import(pathToFileURL(path.join(temporary,'facade-envelope.js')));
({kernel}=await loadWasmProbe('web/public/kernel','FreeCADWebDocumentProbe'));
const document='SimpleCylinderBusiness',queue=new FacadeSerialQueue();let sequence=0;
const router={request:(_module,command,payload)=>{assert.equal(command,facade.PART_DOCUMENT_COMMAND);return facade.dispatchPartDocument(queue,facade.createPartDocumentRequest(`simple-cylinder-${++sequence}`,payload),kernel);}};
const request=(action,args={},name=document)=>router.request('Part',facade.PART_DOCUMENT_COMMAND,{schema:facade.PART_DOCUMENT_SCHEMA,module:'Part',document:name,action,builderInfo:true,includeMesh:true,...args});
const call=async(action,args={},name=document)=>{const reply=await request(action,args,name);assert.equal(reply.kind,'result',JSON.stringify(reply));return reply.result;};
const python=source=>{const reply=JSON.parse(kernel.ccall('fc_web_dispatch_json','string',['string'],[JSON.stringify({cmd:'part.documentViewCommand',args:{
owner:'DocumentProbe',document,command:'Std_CommandLine',parameters:{operation:'submit',source:`exec(${JSON.stringify(source)})`}}})]));
assert.equal(reply.ok,true,JSON.stringify(reply));assert.equal(reply.result.stderr,'',JSON.stringify(reply));return reply.result.stdout.trim()?JSON.parse(reply.result.stdout.trim()):null;};
const execute=args=>adapter.executePartFixedBoxDesktop(router,'Part_SimpleCylinder',args,document);
const accepted=async args=>{const reply=await execute(args);assert.equal(reply.kind,'result',JSON.stringify(reply));return reply.result;};
const item=(state,name)=>state.objects.find(o=>o.name===name);
// Sampling hints use mesh-dependent cached bounds, not persisted cylinder state.
const objectState=({samplingSettings,...object})=>object;
const signature=state=>({objects:state.objects.map(objectState),undoCount:state.undoCount,redoCount:state.redoCount});
await call('create');await call('setBox',{object:'Cylinder',dimensions:[2,3,4]});await call('create',{},'CylinderOther');const other=await call('readCleanup',{},'CylinderOther');
python(`import FreeCAD as App, Part, json, math\nd=App.getDocument('${document}')`);
const fixture=await call('save',{path:'/tmp/SimpleCylinderBusiness.FCStd'});
const created=[];
for(const options of [{},{radius:2,height:7,position:[3,-4,5],direction:[1,0,0]},
{radius:3,height:4,position:[0,8,0],direction:[0,-2,0]},{radius:1.5,height:6,position:[-3,2,1],direction:[1,2,3]},
{radius:2,height:3,position:[0,0,0],direction:[0,0,-1]}]){
const expected={radius:10,height:100,position:[0,0,0],direction:[0,0,1],...options};const before=await call('readCleanup');
const state=await accepted({simpleCylinder:options});const cylinder=item(state,state.createdObject);created.push(cylinder);
assert.equal(cylinder.type,'Part::Feature');assert.equal(cylinder.topology.Solid,1);assert.equal(cylinder.topology.Face,3);assert.equal(cylinder.topology.valid,true);
assert.deepEqual(cylinder.dependencies,[]);assert.deepEqual(cylinder.simpleCylinder,expected);assert.equal(cylinder.parameters,undefined);
near(cylinder.volume,Math.PI*expected.radius**2*expected.height);near(cylinder.topology.area,2*Math.PI*expected.radius*(expected.radius+expected.height));
const geometric=python(`f=d.getObject(${JSON.stringify(cylinder.name)})
r=Part.makeCylinder(${expected.radius},${expected.height},App.Vector(*${JSON.stringify(expected.position)}),App.Vector(*${JSON.stringify(expected.direction)}))
b=f.Shape.BoundBox;c=f.Shape.CenterOfMass;rc=r.CenterOfMass
print(json.dumps(dict(difference=f.Shape.cut(r).Volume+r.cut(f.Shape).Volume,center=[c.x,c.y,c.z],referenceCenter=[rc.x,rc.y,rc.z],bounds=[b.XMin,b.YMin,b.ZMin,b.XMax,b.YMax,b.ZMax])))`);
near(geometric.difference,0);const length=Math.hypot(...expected.direction);
geometric.center.forEach((v,i)=>near(v,expected.position[i]+expected.direction[i]/length*expected.height/2));
assert.equal(state.undoCount,before.undoCount+1);for(const obj of before.objects)assert.deepEqual(objectState(item(state,obj.name)),objectState(obj));
assert.deepEqual((await call('undo')).objects.map(objectState),before.objects.map(objectState));assert.deepEqual((await call('redo')).objects.map(objectState),state.objects.map(objectState));assert.deepEqual(await call('readCleanup',{},'CylinderOther'),other);
record(options,{name:cylinder.name,volume:cylinder.volume,area:cylinder.topology.area,...geometric},'Native static cylinder matches analytic measures and desktop Part.makeCylinder with unique naming and reversible creation');
}
const stable=signature(await call('readCleanup'));
for(const args of [{simpleCylinder:null},{simpleCylinder:[]},...[
{radius:0},{radius:-1},{height:0},{height:2147480001},{radius:'2'},{radius:Infinity},{direction:[0,0,0]},
{direction:[1,0]},{direction:[0,'1',0]},{position:[1,2,NaN]},{position:[2147480001,0,0]},{angle:180}
].map(simpleCylinder=>({simpleCylinder})),{document:'CylinderOther'},{activeTask:true},{shapes:['BREP']},{object:'Cylinder'},{action:'edit'}]){
const reply=await execute(args);assert.equal(reply.kind,'error',JSON.stringify(reply));assert.deepEqual(signature(await call('readCleanup')),stable);
record(args,reply.error,'Invalid lengths, vectors and document requests preserve geometry and transaction history');
}
const direct=await request('createSimpleCylinder',{simpleCylinder:{direction:[0,0,0]}});assert.equal(direct.kind,'error');assert.deepEqual(signature(await call('readCleanup')),stable);
python('d.Cylinder.Height=5');const dirty=python('print(json.dumps(dict(state=d.Cylinder.State,volume=d.Cylinder.Shape.Volume,undo=d.UndoCount,redo=d.RedoCount)))');
const read=await accepted({action:'read'}),cancel=await accepted({action:'cancel'});assert.equal(read.documentChanged,false);assert.equal(cancel.cancelled,true);
assert.deepEqual(python('print(json.dumps(dict(state=d.Cylinder.State,volume=d.Cylinder.Shape.Volume,undo=d.UndoCount,redo=d.RedoCount)))'),dirty);python('d.recompute()');
record({operations:['direct-invalid','read-dirty','cancel-dirty']},{unchanged:true},'Native validation and task cancellation preserve pending geometry and history');
const saved=await call('save',{path:'/tmp/SimpleCylinderBusiness.FCStd'});await call('close');const restored=await call('open',{path:'/tmp/SimpleCylinderBusiness.FCStd',bytes:saved.bytes});
for(const cylinder of created){const value=item(restored,cylinder.name);assert.deepEqual(value.simpleCylinder,cylinder.simpleCylinder);near(value.volume,cylinder.volume);assert.deepEqual(value.dependencies,[]);}
const next=await accepted({simpleCylinder:{radius:1,height:2}});assert.ok(!created.some(c=>c.name===next.createdObject));
record({operations:['FCStd-save','close','open','create-again']},{bytes:saved.bytes.byteLength,object:next.createdObject},'Native FCStd restores static solids and provenance and permits further uniquely named cylinder creation');
kernel.PThread?.terminateAllThreads();kernel=null;
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:900},deviceScaleFactor:1}),errors=[];page.on('pageerror',e=>errors.push(String(e)));
await page.goto(`${process.env.FREECAD_BROWSER_ORIGIN??'http://127.0.0.1:5180'}/workbench.html?module=Part&command=Part_SimpleCylinder&document=${document}`);
await page.getByLabel('Simple cylinder document',{exact:true}).fill(document);await page.getByLabel('Simple cylinder FCStd file',{exact:true}).setInputFiles({name:'SimpleCylinderBusiness.FCStd',mimeType:'application/octet-stream',buffer:Buffer.from(fixture.bytes)});
assert.equal(await page.getByLabel('Simple cylinder radius',{exact:true}).inputValue(),'10');assert.equal(await page.getByLabel('Simple cylinder height',{exact:true}).inputValue(),'100');
await page.getByLabel('Simple cylinder radius',{exact:true}).fill('2');await page.getByLabel('Simple cylinder height',{exact:true}).fill('7');
await page.getByLabel('Simple cylinder axis',{exact:true}).selectOption('1,0,0');await page.getByLabel('Simple cylinder position X',{exact:true}).fill('5');
await page.getByRole('button',{name:'Create cylinder',exact:true}).click();await page.locator('[data-simple-cylinder="Cylinder001"]').waitFor();
const value=await page.evaluate(()=>window.__freecadWorkbenchState.response.result);near(item(value,'Cylinder001').volume,28*Math.PI);
await page.getByRole('button',{name:'Simple cylinder undo',exact:true}).click();await page.locator('[data-simple-cylinder="Cylinder001"]').waitFor({state:'detached'});
await page.getByRole('button',{name:'Simple cylinder redo',exact:true}).click();await page.locator('[data-simple-cylinder="Cylinder001"]').waitFor();
for(const [axis,value] of [['X','1'],['Y','2'],['Z','3']])await page.getByLabel(`Simple cylinder direction ${axis}`,{exact:true}).fill(value);
await page.getByLabel('Simple cylinder position Y',{exact:true}).fill('8');await page.getByRole('button',{name:'Create cylinder',exact:true}).click();await page.locator('[data-simple-cylinder="Cylinder002"]').waitFor();
const capture=async name=>{const png=await page.locator('[aria-label="Simple cylinder geometry"] canvas').screenshot({path:path.join(directory,name)});
const colors=await page.evaluate(async encoded=>{const image=new Image();image.src=`data:image/png;base64,${encoded}`;await image.decode();const c=document.createElement('canvas');c.width=image.width;c.height=image.height;const ctx=c.getContext('2d');ctx.drawImage(image,0,0);const data=ctx.getImageData(0,0,c.width,c.height).data;const colors=new Set();for(let i=0;i<data.length;i+=4)colors.add(`${data[i]},${data[i+1]},${data[i+2]}`);return colors.size;},png.toString('base64'));assert.ok(colors>8);
const hash=createHash('sha256').update(png).digest('hex');result.evidenceFiles[path.relative('.',path.join(directory,name))]=hash;return hash;};
const before=await capture('desktop-geometry.png');const rect=await page.locator('[aria-label="Simple cylinder geometry"] canvas').boundingBox();await page.mouse.move(rect.x+rect.width/2,rect.y+rect.height/2);await page.mouse.down();await page.mouse.move(rect.x+rect.width/2+70,rect.y+rect.height/2+30,{steps:8});await page.mouse.up();assert.notEqual(await capture('rotated-geometry.png'),before);
await page.getByRole('button',{name:'Save simple cylinder document',exact:true}).click();await page.getByRole('link',{name:'Open simple cylinders in project',exact:true}).waitFor();
const bytes=await page.evaluate(async()=>{const {readDocumentFile}=await import('/src/storage.ts');return [...new Uint8Array(await readDocumentFile('SimpleCylinder-SimpleCylinderBusiness.FCStd'))]});
await page.getByRole('button',{name:'Close simple cylinder task',exact:true}).click();await page.getByRole('button',{name:'Create cylinder',exact:true}).waitFor({state:'detached'});
await page.reload();await page.getByLabel('Simple cylinder document',{exact:true}).fill(document);await page.getByLabel('Simple cylinder FCStd file',{exact:true}).setInputFiles({name:'SimpleCylinderBusiness.FCStd',mimeType:'application/octet-stream',buffer:Buffer.from(bytes)});
await page.locator('[data-simple-cylinder="Cylinder002"]').waitFor();await page.getByLabel('Simple cylinder radius',{exact:true}).fill('0');assert.equal(await page.getByRole('button',{name:'Create cylinder',exact:true}).isDisabled(),true);await page.getByLabel('Simple cylinder radius',{exact:true}).fill('2');
await page.setViewportSize({width:390,height:844});await capture('mobile-geometry.png');const task=page.getByRole('region',{name:'Simple cylinder task',exact:true});assert.ok(await task.evaluate(e=>e.scrollWidth<=e.clientWidth+1));
const png=await task.screenshot({path:path.join(directory,'mobile-task.png')});result.evidenceFiles[path.relative('.',path.join(directory,'mobile-task.png'))]=createHash('sha256').update(png).digest('hex');assert.deepEqual(errors,[]);
record({workflow:['defaults','position','axis','arbitrary-direction','create','undo','redo','rotate','save-OPFS','close','reload','open'],viewports:[[1440,900],[390,844]]},{bytes:bytes.length,screenshots:Object.keys(result.evidenceFiles)},'Mounted cylinder controls and interactive geometry work across history and persistent reopening');
result.status='passed';
}catch(error){result.error=[String(error.stack??error),error.stdout?.toString(),error.stderr?.toString()].filter(Boolean).join('\n');}
finally{await browser?.close();kernel?.PThread?.terminateAllThreads();fs.rmSync(temporary,{recursive:true,force:true});fs.writeFileSync(path.join(directory,'result.json'),JSON.stringify(result,null,2));}
return [result];
}