Files
FreeCADWasmDev/tools/user-manual-remaining-cases.mjs
wangdequan 9a7c4f0335 Save FreeCAD Web workspace sources and user validation evidence
IFC native binding remains incomplete; preserve accurate test results.
2026-09-15 22:01:58 +08:00

200 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import fs from 'node:fs';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
export function createRemainingCases(h) {
const {root,output,fixture,step,open,button,evaluate,send,until,wait,check,box,dimensions,volume,count,upload,save,reopen,idle,hash}=h;
const objectCount="document.querySelectorAll('[role=treeitem]').length";
const stage="document.querySelector('.stage > [role=status]')?.textContent";
const json=(name,data)=>fs.writeFileSync(path.join(output,name),JSON.stringify(data,null,2)+'\n');
async function memory() {
const rows=execFileSync('ps',['-eo','pid=,ppid=,rss='],{encoding:'utf8'}).trim().split('\n').map(line=>line.trim().split(/\s+/).map(Number));
const pids=new Set([h.getBrowserPid()]);
for(let changed=true;changed;){changed=false;for(const [pid,parent]of rows)if(pids.has(parent)&&!pids.has(pid)){pids.add(pid);changed=true;}}
const rss=rows.filter(([pid])=>pids.has(pid)).reduce((n,row)=>n+row[2],0)*1024;
await send('Performance.enable');
const {metrics}=await send('Performance.getMetrics');
return {at:new Date().toISOString(),browserTreeRssBytes:rss,processes:pids.size,
rendererJsHeapBytes:metrics.find(m=>m.name==='JSHeapUsedSize')?.value,
note:'RSS sums browser descendants and can double-count shared pages; JS heap excludes Worker WASM. Not a physical-memory measurement.'};
}
async function importFixture(file) {
const n=await count();await upload(file);
await until('Native import complete',`({count:${objectCount},status:${stage},busy:document.querySelector('[aria-label="Import STEP files"]').disabled})`,s=>{
if(!s.busy&&s.status&&!/^(Importing|Imported)/.test(s.status))throw Error(s.status);
return !s.busy&&s.count===n+1;
},60000);
const v=await evaluate("Number(document.querySelector('[data-native-volume]')?.textContent)");
check(v>0,'Imported shape has no positive native volume');return v;
}
async function chooseLocalFile(file) {
await until('Open command registration loaded',"window.__freecadWorkbenchState?.selected?.command",s=>s==='Std_Open');
await send('Page.setInterceptFileChooserDialog',{enabled:true});
const eventStart=h.getEvents().length;
const point=await evaluate("(()=>{const b=[...document.querySelectorAll('button')].find(b=>b.textContent==='Choose files');if(!b||b.disabled)throw Error('File chooser button unavailable');b.scrollIntoView({block:'center'});const r=b.getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2};})()");
await send('Input.dispatchMouseEvent',{type:'mousePressed',...point,button:'left',clickCount:1});
await send('Input.dispatchMouseEvent',{type:'mouseReleased',...point,button:'left',clickCount:1});
let chooser;
for(let i=0;i<100;i++){chooser=h.getEvents().slice(eventStart).find(e=>e.method==='Page.fileChooserOpened');if(chooser)break;await wait(100);}
check(chooser?.params.backendNodeId,'Native file chooser did not open');
await send('DOM.setFileInputFiles',{backendNodeId:chooser.params.backendNodeId,files:[file]});
await send('Page.setInterceptFileChooserDialog',{enabled:false});
}
async function openResult() {
return until('Native open response',"window.__freecadWorkbenchState?.response",r=>{
if(r?.kind==='error')throw Error(JSON.stringify(r.error));
return r?.kind==='result';
},60000);
}
const downloadSnapshot=()=>Object.fromEntries(fs.readdirSync(path.join(output,'downloads')).map(n=>[n,fs.statSync(path.join(output,'downloads',n)).mtimeMs]));
async function downloadedFile(extension,previous={}) {
for(let i=0;i<200;i++) {
const names=fs.readdirSync(path.join(output,'downloads')).filter(n=>n.toLowerCase().endsWith('.'+extension.toLowerCase())&&previous[n]!==fs.statSync(path.join(output,'downloads',n)).mtimeMs);
if(names.length)return path.join(output,'downloads',names.at(-1));
await wait(100);
}
throw Error(`Missing completed .${extension} download`);
}
async function nativeExport(format) {
const previous=downloadSnapshot();
await h.select('Native export format',format);await button('导出选中对象');
await until('Native export',stage,s=>{if(s?.startsWith('Error:'))throw Error(s);return s?.startsWith('Exported ');},30000);
return downloadedFile(format,previous);
}
async function command(module,name,args={}) {
await evaluate(`(()=>{const b=[...document.querySelectorAll('button.module')].find(b=>b.querySelector('strong')?.textContent===${JSON.stringify(module)});if(!b)throw Error('Module missing');b.click();})()`);
await until('Command registration loaded',`[...document.querySelectorAll('button.command')].some(b=>b.querySelector('span')?.textContent===${JSON.stringify(name)})`);
await evaluate(`(()=>{const b=[...document.querySelectorAll('button.command')].find(b=>b.querySelector('span')?.textContent===${JSON.stringify(name)});b.click();})()`);
await h.input('命令 JSON 参数',JSON.stringify(args));
await until('Selected command ready',"[...document.querySelectorAll('button')].some(b=>b.textContent==='执行选中命令'&&!b.disabled)");
await button('执行选中命令');
const response=await until('Native command result',"window.__freecadWorkbenchState?.response",r=>{if(r?.kind==='error')throw Error(JSON.stringify(r.error));return r?.kind==='result';},60000);
const row=response.result?.records?.[0];
if(row&&'status' in row)check(['success','exposed','ok'].includes(row.status),JSON.stringify(row));
const result=row?.result??response.result;
check(!result?.sceneError,`Scene refresh failed: ${result?.sceneError}`);
return result;
}
async function workbenchDocument() {
await open();await box();await box([3,4,5]);await save();
const previous=downloadSnapshot();await button('下载 native FCStd');
const file=await downloadedFile('fcstd',previous);
await open('/workbench.html?module=Web&command=Std_Open');await chooseLocalFile(file);
const r=await openResult(),v=r.result.records[0].result;
check(!v.sceneError&&v.errors.length===0,'Native open failed');return v.activeDocument;
}
async function inspect(documentId,expression) {
const value=await command('Web','Std_CommandLine',{documentId,runtimeOwner:'PartBox',operation:'submit',source:`import json; print(json.dumps(${expression}))`});
check(!value.stderr,'Native console returned an error: '+value.stderr);
return JSON.parse(value.stdout.trim().split('\n').at(-1));
}
async function saveAndReopenNative(documentId,name) {
const saved=await command('Web','Std_SaveAs',{documentId,runtimeOwner:'PartBox',filename:name});
const file=saved.files?.[0];check(file?.contentsBase64,'Save returned no FCStd bytes');
const local=path.join(output,name);fs.writeFileSync(local,Buffer.from(file.contentsBase64,'base64'));
await open('/workbench.html?module=Web&command=Std_Open');await chooseLocalFile(local);
const r=await openResult(),v=r.result.records[0].result;check(!v.sceneError&&v.errors.length===0,'Restore failed');return v.activeDocument;
}
const cases={
'GEO-01':async()=>{
await step('创建 2 x 3 x 4 Box',async()=>{await open();return box();},'1 个对象,原生体积约 24');
await step('读取原生包围盒属性',async()=>{const bounds=await evaluate("JSON.parse(document.querySelector('[data-native-bounds]').dataset.nativeBounds)");check(bounds.length===6&&bounds.every((n,i)=>Math.abs(n-[0,0,0,2,3,4][i])<1e-5),'OCCT bounds differ');return {bounds,volume:await volume(24),tolerance:1e-5};},'原生返回 [0,0,0]-[2,3,4],容差 1e-5 mm');
},
'FILE-03':async()=>{
let downloaded;
await step('创建和编辑保存文档',async()=>{await open();await box();await h.input('Object Label','User acceptance box');await evaluate("document.querySelector('[aria-label=\"Object Label\"]').blur()");await idle();return save();},'FCStd ZIP 已写入 OPFS');
await step('下载真实 FCStd 文件',async()=>{await button('下载 native FCStd');for(let i=0;i<100;i++){const files=fs.readdirSync(path.join(output,'downloads')).filter(n=>/\.FCStd$/i.test(n));if(files.length){downloaded=path.join(output,'downloads',files.at(-1));break;}await wait(100);}check(downloaded,'FCStd download missing');return {file:downloaded,bytes:fs.statSync(downloaded).size,sha256:hash(downloaded)};},'下载完成并记录文件指纹');
await step('关闭后打开 OPFS 文档',async()=>{await button('关闭活动 Document');const state=await reopen();check(state.objects===1,'Object count differs');await volume(24);return state;},'对象数量和体积恢复');
await step('通过 Std_Open 文件选择器重开下载文件',async()=>{await open('/workbench.html?module=Web&command=Std_Open');await chooseLocalFile(downloaded);const r=await openResult();const v=r.result.records[0].result;check(v.errors.length===0&&v.opened.length===1,'Native file open did not succeed');check(v.opened[0].documents[0].objectCount===1,'FCStd object count differs');check(!v.opened[0].documents[0].partialRestore&&!v.opened[0].documents[0].restoreError&&!v.sceneError,'Partial restore or scene failure');return v;},'本地 FCStd 完整恢复,不以 OPFS 回读代替');
},
'FILE-05':async()=>{
let stl,before;
await step('准备基准 Box',async()=>{await open();await box();before=await evaluate("JSON.parse(document.querySelector('[data-native-bounds]').dataset.nativeBounds)");return {bounds:before,volume:await volume(24)};},'原生体积 24,包围盒 [0,0,0]-[2,3,4]');
await step('使用原生 OCCT 导出 STL',async()=>{stl=await nativeExport('stl');return {file:stl,size:fs.statSync(stl).size,sha256:hash(stl)};},'下载非空 STL 文件');
await step('真实文件回读和误差比较',async()=>{await open();await upload(stl);await until('STL object',objectCount,n=>n===1,30000);await idle();const bounds=await evaluate("JSON.parse(document.querySelector('[data-native-bounds]').dataset.nativeBounds)");check(bounds.every((v,i)=>Math.abs(v-before[i])<1e-5),'STL bounds changed');return {bounds,volume:await volume(24),tolerance:1e-5};},'STL 回读体积约 24,包围盒误差小于 1e-5 mm');
},
'FILE-02':async()=>{
const report=[];
for(const format of ['step','iges','brep','stl'])await step(`${format.toUpperCase()} 原生文件导出及导入`,async()=>{
await open();await box();const file=await nativeExport(format);await open();const v=await importFixture(file);
const bounds=await evaluate("JSON.parse(document.querySelector('[data-native-bounds]').dataset.nativeBounds)");
check(bounds.every((n,i)=>Math.abs(n-[0,0,0,2,3,4][i])<1e-4),'Imported bounds differ');
const result={format,file,sha256:hash(file),supported:true,imported:true,volume:v,bounds};report.push(result);json('format-validation.json',report);return result;
},'有效文件由现有 OCCT 接口生成;导入非空几何,包围盒正确');
await step('DXF 本地桌面样本导入',async()=>{
const file=path.join(root,'freecad-source/src/Mod/OpenSCAD/OpenSCADTest/data/Square.dxf');
await open('/workbench.html?module=Draft');const result=await command('Draft','draft.formatBatch',{schema:'freecad-web-draft/format-batch/v1',module:'Draft',records:[{format:'dxf',direction:'import',documentId:'DxfValidation',payload:fs.readFileSync(file,'utf8')}]});
const row=result.records[0];check(row.objectCount>0&&row.objects.some(o=>o.length>0||o.area>0),'DXF has no native geometry');
report.push({format:'dxf',file,sha256:hash(file),supported:true,imported:true,result:row});json('format-validation.json',report);return row;
},'复用桌面 DXF 导入链路,非空原生对象');
for(const [format,p]of [
['obj','FreeCADBaseLib/sources/src/Qt-5.15.13/qt3d/src/3rdparty/assimp/src/test/models/OBJ/box_without_lineending.obj'],
['ifc','FreeCADBaseLib/sources/src/Qt-5.15.13/qt3d/src/3rdparty/assimp/src/test/models/IFC/AC14-FZK-Haus.ifc'],
])await step(`${format.toUpperCase()} 实际注册能力及文件处理`,async()=>{
await open('/workbench.html?module=Web&command=Std_Open');await chooseLocalFile(path.join(root,p));
const response=await until('File handled',"window.__freecadWorkbenchState?.response",r=>r?.kind==='result'||r?.kind==='error',60000);
if(response.kind==='error') {
const error=response.error;
check(error.code==='file_open_failed'&&error.diagnostic?.openResult?.errors?.length,'Unstructured format failure');
const value={format,file:p,sha256:hash(path.join(root,p)),supported:false,imported:false,error};report.push(value);json('format-validation.json',report);
throw new Error(`${format.toUpperCase()} native import is not available: ${error.message}`);
}
const value=response.result.records[0].result;
check(value.opened.length>0&&!value.sceneError,'Imported document or display missing');
report.push({format,file:p,sha256:hash(path.join(root,p)),supported:true,imported:true,result:value});json('format-validation.json',report);return value;
},'支持则完整导入;依赖或登记缺口记录失败,不伪装支持');
},
'ERR-01':async()=>{
await step('准备已有文档对象',async()=>{await open();return box();},'已有体积 24 对象');
await step('在原生导入中请求取消',async()=>{await upload(path.join(root,'freecad-source/data/examples/Schenkel.stp'));await until('Import entered native operation',"document.querySelector('[data-import-phase]')?.dataset.importPhase",v=>v==='native',30000);await button('取消导入');return {phase:await evaluate("document.querySelector('[data-import-phase]')?.dataset.importPhase"),status:await evaluate(stage)};},'原生事务尚未提交,显示取消中');
await step('等待回滚并核对原文档',async()=>{await until('Import rolled back',stage,s=>s?.startsWith('Import cancelled'),60000);await idle();check(await count()===1,'Cancelled import left an object');return {objects:await count(),volume:await volume(24)};},'取消后无半成品,原对象保留');
await step('取消后正常导入恢复',async()=>{const v=await importFixture(fixture);check(await count()===2,'Recovery import missing');return {objects:await count(),volume:v};},'Worker 和原生文档可继续使用');
},
'DOC-02':async()=>{
let doc,part,varset;
await step('准备含两个实体的原生工作台文档',async()=>{doc=await workbenchDocument();return {doc};},'两个实体从 FCStd 打开在 PartBox 宿主');
await step('创建 Part 并按桌面语义组合选择',async()=>{const v=await command('Web','Std_Part',{documentId:doc,label:'Validation Part',action:'create',selection:['Box1','Box2'].map(object=>({documentId:doc,object}))});part=v.createdObject;check(v.activePart===part&&v.objects.find(o=>o.name===part).children.length===2,'Part grouping or document Tip differs');return v;},'Part 包含两个实体,文档 Tip 指向新 Part');
await step('移出一个对象再加入',async()=>{for(const names of [['Box1'],['Box1','Box2']]){await command('Web','Std_Properties',{documentId:doc,operation:'apply',object:part,selection:[{documentId:doc,object:part}],property:'Group',value:JSON.stringify(names.map(object=>({documentId:doc,object})))});const actual=await inspect(doc,`[o.Name for o in App.getDocument(${JSON.stringify(doc)}).getObject(${JSON.stringify(part)}).Group]`);check(JSON.stringify(actual.sort())===JSON.stringify(names.sort()),'Group edit not applied');}return {part,children:['Box1','Box2']};},'原生 Group 属性执行移出/加入,层级正确');
await step('创建 VarSet 并设置变量',async()=>{const v=await command('Web','Std_VarSet',{documentId:doc,action:'create',label:'Validation Variables',selection:[{documentId:doc,object:part}]});varset=v.createdObject;await command('Web','Std_VarSet',{documentId:doc,action:'set-variable',object:varset,name:'Width',type:'App::PropertyFloat',value:'7.5',expression:'',selection:[]});const read=await command('Web','Std_VarSet',{documentId:doc,action:'read',object:varset,selection:[]});check(read.objects.find(o=>o.name===varset).properties.some(p=>p.name==='Width'&&p.value===7.5),'Variable not stored');return read;},'VarSet 动态变量为 7.5');
await step('设置并清除变量表达式',async()=>{await command('Web','Std_VarSet',{documentId:doc,action:'set-variable',object:varset,name:'DoubleWidth',type:'App::PropertyFloat',value:'',expression:'Width * 2',selection:[]});const actual=await inspect(doc,`App.getDocument(${JSON.stringify(doc)}).getObject(${JSON.stringify(varset)}).DoubleWidth`);check(actual===15,'Expression not evaluated');await command('Web','Std_VarSet',{documentId:doc,action:'remove-variable',object:varset,name:'DoubleWidth',selection:[]});return {evaluated:actual};},'表达式求值为 15,动态变量可以移除');
await step('保存刷新重开结构与 Tip',async()=>{doc=await saveAndReopenNative(doc,'Structure.FCStd');const state=await inspect(doc,`{'tip':App.getDocument(${JSON.stringify(doc)}).Tip.Name,'children':[o.Name for o in App.getDocument(${JSON.stringify(doc)}).getObject(${JSON.stringify(part)}).Group],'width':App.getDocument(${JSON.stringify(doc)}).getObject(${JSON.stringify(varset)}).Width}`);check(state.tip===part&&state.children.includes('Box1')&&state.children.includes(varset)&&state.width===7.5,'Structure not restored');return state;},'FCStd 恢复树层级、文档 Tip 和变量');
},
'DOC-03':async()=>{
let doc,part,link;
await step('准备可替换引用的原生文档',async()=>{doc=await workbenchDocument();const v=await command('Web','Std_Part',{documentId:doc,action:'create',selection:['Box1','Box2'].map(object=>({documentId:doc,object}))});part=v.createdObject;return {doc,part};},'Part 包含两个原生对象');
await step('用 Link 替换父容器引用',async()=>{const v=await command('Web','Std_LinkReplace',{documentId:doc,action:'apply',parentDocument:doc,parentObject:part,selection:[{documentId:doc,object:'Box1'}]});const children=await inspect(doc,`[o.Name for o in App.getDocument(${JSON.stringify(doc)}).getObject(${JSON.stringify(part)}).Group]`);link=children.find(n=>n!=='Box2');check(link&&link!=='Box1','Reference was not replaced');const target=await inspect(doc,`App.getDocument(${JSON.stringify(doc)}).getObject(${JSON.stringify(link)}).getLinkedObject().Name`);check(target==='Box1','Wrong link target');return {v,children,link,target};},'父容器引用 Link,Link 指向原 Box1');
await step('撤销重做链接替换',async()=>{await command('Web','Std_Undo',{documentId:doc});const before=await inspect(doc,`[o.Name for o in App.getDocument(${JSON.stringify(doc)}).getObject(${JSON.stringify(part)}).Group]`);check(before.includes('Box1'),'Undo failed');await command('Web','Std_Redo',{documentId:doc});const after=await inspect(doc,`[o.Name for o in App.getDocument(${JSON.stringify(doc)}).getObject(${JSON.stringify(part)}).Group]`);check(after.includes(link),'Redo failed');return {before,after};},'事务准确恢复父容器引用');
await step('保存重开链接并解除',async()=>{doc=await saveAndReopenNative(doc,'Links.FCStd');await command('Web','Std_LinkUnlink',{documentId:doc,action:'apply',parentDocument:doc,parentObject:part,selection:[{documentId:doc,object:link}]});const children=await inspect(doc,`[o.Name for o in App.getDocument(${JSON.stringify(doc)}).getObject(${JSON.stringify(part)}).Group]`);check(children.includes('Box1')&&!children.includes(link),'Unlink failed');return children;},'链接持久化后可解除,父容器重新引用原对象');
},
'PERF-01':async()=>{
const files=[['small','freecad-source/src/Mod/Import/App/SCL/Product1.stp'],['medium','web/public/samples/freecad-sample.step'],['large','freecad-source/data/examples/Schenkel.stp']];
const measurements=[];
await step('登记本机实际样本和性能范围',async()=>{const data=files.map(([tier,p])=>({tier,path:p,bytes:fs.statSync(path.join(root,p)).size,sha256:hash(path.join(root,p))}));json('performance-fixtures.json',{scope:'Relative size tiers of provided desktop STEP examples, not production maximum-capacity certification',maxImportSeconds:60,fixtures:data});return data;},'样本来自本机桌面 FreeCAD;逐项记录字节数和 SHA-256');
for(const [tier,p]of files)await step(`${tier} 样本加载与交互测量`,async()=>{
await open();const samples=[await memory()],start=performance.now();
let sampling=true;const sampler=(async()=>{while(sampling){await wait(250);if(sampling)samples.push(await memory());}})();
let v;try{v=await importFixture(path.join(root,p));}finally{sampling=false;await sampler;}
const seconds=(performance.now()-start)/1000;samples.push(await memory());
check(seconds<60,'Import exceeds 60 second local test budget');
const frames=await evaluate("new Promise(resolve=>{let n=0;const start=performance.now();const tick=now=>{n++;if(now-start>=1000)resolve({frames:n,seconds:(now-start)/1000});else requestAnimationFrame(tick);};requestAnimationFrame(tick);})");
const value={tier,file:p,bytes:fs.statSync(path.join(root,p)).size,seconds,volume:v,peakBrowserTreeRssBytes:Math.max(...samples.map(s=>s.browserTreeRssBytes)),samples,animationCallbackHz:frames.frames/frames.seconds,rendering:'headless software WebGL; callback cadence is not CAD draw FPS'};
measurements.push(value);json('performance-loading.json',measurements);return value;
},'60 秒内完成导入,原生实体有正体积,记录内存采样和帧回调频率');
},
'PERF-02':async()=>{
const results=[];
for(let iteration=1;iteration<=20;iteration++)await step(`第 ${iteration}/20 轮导入、修改、保存、刷新重开`,async()=>{
await open();const start=performance.now();const importedVolume=await importFixture(fixture);
await box();await dimensions([3,4,5]);await button('应用尺寸并重算');await idle();await volume(60);
await save();await open();const restored=await reopen();check(restored.objects===2,'Restored object count differs');
await h.click('[role=treeitem]');await volume(importedVolume);await evaluate("document.querySelectorAll('[role=treeitem]')[1].click()");await volume(60);
const filenames=await evaluate("[...document.querySelector('[aria-label=\"OPFS document\"]').options].map(o=>o.value).filter(Boolean)");check(new Set(filenames).size===filenames.length,'Duplicate OPFS entries');
await wait(250);const mem=await memory();
const result={iteration,seconds:(performance.now()-start)/1000,objects:restored.objects,importedVolume,boxVolume:60,files:filenames,memory:mem};results.push(result);json('stability-cycles.json',results);return result;
},'每轮真实 STEP 和修改后的 Box 均恢复,无重复文件或对象');
await step('检查稳定阶段内存趋势',async()=>{const values=results.slice(5).map(r=>r.memory.browserTreeRssBytes);const mean=a=>a.reduce((n,v)=>n+v,0)/a.length;const growth=mean(values.slice(-5))-mean(values.slice(0,5));const allRising=values.every((v,i)=>i===0||v>values[i-1]);const report={cycles:20,warmupCycles:5,earlyMean:mean(values.slice(0,5)),lateMean:mean(values.slice(-5)),growthBytes:growth,allRising,allowedGrowthBytes:256*1024*1024,scope:'Same host/software WebGL, process-tree RSS (shared pages counted per process)'};json('stability-summary.json',report);check(!allRising&&growth<report.allowedGrowthBytes,'Sustained browser memory growth requires investigation');return report;},'预热后不存在持续单调增长,后五轮平均 RSS 增长不超过 256 MiB');
},
};
return cases;
}