import assert from 'node:assert/strict'; import { readFile,writeFile,mkdir } from 'node:fs/promises'; import { pathToFileURL } from 'node:url'; import * as THREE from '../web/react/node_modules/three/build/three.module.js'; import { GLTFExporter } from '../web/react/node_modules/three/examples/jsm/exporters/GLTFExporter.js'; globalThis.FileReader=class{readAsArrayBuffer(blob){blob.arrayBuffer().then(v=>{this.result=v;this.onloadend?.();});}readAsDataURL(blob){blob.arrayBuffer().then(v=>{this.result=`data:${blob.type};base64,${Buffer.from(v).toString('base64')}`;this.onloadend?.();});}}; const fixtures=await Promise.all(Array.from({length:8},async(_,i)=>{const s=new THREE.Scene();s.name=`part_${i}`;const mesh=new THREE.Mesh(new THREE.BoxGeometry(.05,.05,.05),new THREE.MeshStandardMaterial({color:0x458e83}));mesh.name=`mesh_${i}`;mesh.position.set(.01,.02,.03);s.add(mesh);const extra=mesh.clone();extra.name=`extra_${i}`;extra.position.x=.07;s.add(extra);const b=await new GLTFExporter().parseAsync(s,{binary:true,trs:true});return{name:`part-${i}.glb`,mimeType:'model/gltf-binary',buffer:Buffer.from(b)};})); const pw=await import(pathToFileURL(process.env.PLAYWRIGHT_MODULE??'/home/mes006/.npm/_npx/e41f203b7505f1fb/node_modules/playwright/index.js')); const browser=await(pw.default??pw).chromium.launch({headless:true,executablePath:'/usr/bin/google-chrome',args:['--no-sandbox','--use-angle=swiftshader','--enable-unsafe-swiftshader']});const page=await browser.newPage({viewport:{width:1440,height:1000}}); const out=new URL('../coverage/glb-machine-manager/document/',import.meta.url);await mkdir(out,{recursive:true}); const report={report_id:'GLB-MGR-E17',status:'running',url:process.env.MACHINE_MANAGER_URL??'http://127.0.0.1:5180/',checks:[],errors:[]}; page.on('pageerror',e=>report.errors.push(e.message));page.on('console',m=>{if(['error','warning'].includes(m.type()))report.errors.push(m.text());}); const c=id=>page.locator(`[data-axis-control="${id}"]`),preview=page.locator('[data-machine-manager-preview]'); const idle=()=>page.waitForFunction(()=>document.querySelector('main.axis-app')?.dataset.axisProgramLoading==='false'&&document.querySelector('main.axis-app')?.dataset.axisStateSource==='linuxcnc-wasm',null,{timeout:60000}); const choose=async(name)=>{await c('machine-manager-tab-structure').click();await c('machine-tree-search').fill(name);await c('machine-tree-node').filter({has:page.locator('span').filter({hasText:new RegExp(`^${name}$`)})}).first().click();}; const save=async()=>{const p=page.waitForEvent('download');await c('machine-manager-export').click();const d=await p;const bytes=await readFile(await d.path());return{bytes,filename:d.suggestedFilename(),json:JSON.parse(bytes.toString('utf8',20,20+bytes.readUInt32LE(12)))}}; const editor=saved=>saved.json.scenes[0].extras.linuxcncMachineEditor; try{ await page.goto(report.url);await idle();await c('machine-manager-settings').click();await c('machine-manager-files').setInputFiles(fixtures);await page.waitForFunction(()=>document.querySelector('[data-machine-manager-preview]')?.dataset.machineManagerPartCount==='8'); for(const[i,a]of[[1,'X'],[2,'Y'],[3,'Z'],[5,'A'],[6,'C']]){await choose(`part_${i}`);await c('machine-tree-axis-role').selectOption(a);} await choose('part_3');await c('machine-joint-sign').selectOption('-1');await c('machine-manager-show-origin').uncheck(); await choose('extra_3');const extra=c('machine-tree-node').filter({has:page.locator('span').filter({hasText:/^extra_3$/})}).first();const sourcePath=await extra.locator('xpath=ancestor::li[1]').getAttribute('data-machine-tree-path');const partId=await extra.locator('xpath=ancestor::li[1]').getAttribute('data-machine-tree-part'); await c('machine-tree-search').fill('');const target=c('machine-tree-node').filter({has:page.locator('span').filter({hasText:/^mesh_3$/})}).first();const targetPath=await target.locator('xpath=ancestor::li[1]').getAttribute('data-machine-tree-path'); await target.evaluate((el,p)=>{const d=new DataTransfer();d.setData('application/x-linuxcnc-machine-tree',JSON.stringify(p));el.dispatchEvent(new DragEvent('drop',{bubbles:true,cancelable:true,dataTransfer:d}));},{partId,path:sourcePath}); await page.waitForFunction(({partId,sourcePath,targetPath})=>document.querySelector(`[data-machine-tree-part="${partId}"][data-machine-tree-path="${sourcePath}"]`)?.dataset.machineTreeParent===targetPath,{partId,sourcePath,targetPath}); await choose('part_7');await page.locator('.machine-part-flags input').first().uncheck(); await c('machine-manager-tab-calibration').click();const field=t=>page.locator('fieldset.machine-vector').filter({hasText:t});await field('C 轴原点').getByLabel('X',{exact:true}).fill('24');await field('A 到 C 轴偏置').getByLabel('Y',{exact:true}).fill('18');await page.locator('fieldset.machine-zero').getByLabel('A',{exact:true}).fill('12'); await c('machine-manager-tab-files').click();await c('machine-document-name').fill('Workshop Mill');await c('machine-document-description').fill('Revision 2: calibrated A pivot');const first=await save();assert.equal(first.filename,'Workshop Mill-xyzac.glb');assert.equal(editor(first).parts.length,8);assert.equal(editor(first).info.description,'Revision 2: calibrated A pivot'); await page.waitForFunction(()=>document.querySelectorAll('[data-machine-saved-id]').length===1);assert.match(await c('machine-document-location').textContent(),/下载.*本浏览器机床库/s); const id=await page.locator('[data-machine-saved-id]').first().getAttribute('data-machine-saved-id');await writeFile(new URL('saved-eight-parts.glb',out),first.bytes); await choose('part_3');await c('machine-manager-show-origin').check();assert.doesNotMatch(await c('machine-manager-draft-state').textContent(),/与保存版本一致/);await c('machine-manager-undo').click();assert.match(await c('machine-manager-draft-state').textContent(),/与保存版本一致/); report.checks.push('named GLB downloads and atomic browser-library copy with time/size/checksum; editor display changes mark document dirty'); await page.reload();await idle();await c('machine-manager-settings').click();await c('machine-manager-tab-files').click();await page.waitForFunction(()=>document.querySelectorAll('[data-machine-saved-id]').length===1);await c('machine-library-open').click();await page.waitForFunction(()=>document.querySelector('[data-machine-manager-preview]')?.dataset.machineManagerPartCount==='8'); await choose('part_3');assert.equal(await c('machine-tree-axis-role').inputValue(),'Z');assert.equal(await c('machine-joint-sign').inputValue(),'-1');assert.equal(await c('machine-manager-show-origin').isChecked(),false); await choose('part_7');assert.equal(await page.locator('.machine-part-flags input').first().isChecked(),false); await c('machine-manager-tab-calibration').click();assert.equal(await field('C 轴原点').getByLabel('X',{exact:true}).inputValue(),'24');assert.equal(await field('A 到 C 轴偏置').getByLabel('Y',{exact:true}).inputValue(),'18');assert.equal(await page.locator('fieldset.machine-zero').getByLabel('A',{exact:true}).inputValue(),'12'); await c('machine-manager-tab-files').click();assert.equal(await c('machine-document-name').inputValue(),'Workshop Mill');assert.equal(await c('machine-document-description').inputValue(),'Revision 2: calibrated A pivot'); const second=await save();assert.deepEqual(editor(second),editor(first));assert.deepEqual(second.json.scenes[0].extras.linuxcncJointModel,first.json.scenes[0].extras.linuxcncJointModel);assert.deepEqual(second.json.scenes[0].extras.linuxcncMachineGeometry,first.json.scenes[0].extras.linuxcncMachineGeometry); report.checks.push('reload and local-library open restore eight source files, exact edited hierarchy, axis/sign, visibility, origins and calibration; second save has identical metadata'); await c('machine-document-new').click();assert.equal(await preview.getAttribute('data-machine-manager-part-count'),'0');await c('machine-document-open-file').setInputFiles({name:first.filename,mimeType:'model/gltf-binary',buffer:first.bytes});await page.waitForFunction(()=>document.querySelector('[data-machine-manager-preview]')?.dataset.machineManagerPartCount==='8'); await c('machine-manager-tab-files').click();const dl=page.waitForEvent('download');await page.locator(`[data-machine-saved-id="${id}"] [data-axis-control="machine-library-download"]`).click();assert.deepEqual(await readFile(await(await dl).path()),first.bytes); page.once('dialog',d=>d.accept());await page.locator(`[data-machine-saved-id="${id}"] [data-axis-control="machine-library-delete"]`).click();await page.waitForFunction(id=>!document.querySelector(`[data-machine-saved-id="${id}"]`),id);assert.equal(await preview.getAttribute('data-machine-manager-part-count'),'8'); report.checks.push('explicit open-file entry restores document; library redownload is byte identical; delete affects only chosen saved copy'); const damaged=structuredClone(first.json);damaged.scenes[0].extras.linuxcncMachineEditor.parts[3].settings.nodeJoints.scene.sign=1; const text=Buffer.from(JSON.stringify(damaged));const pad=(4-text.length%4)%4;const jsonBytes=Buffer.concat([text,Buffer.alloc(pad,32)]);const oldLen=first.bytes.readUInt32LE(12),bin=first.bytes.subarray(20+oldLen),header=Buffer.alloc(20);first.bytes.copy(header,0,0,12);header.writeUInt32LE(20+jsonBytes.length+bin.length,8);header.writeUInt32LE(jsonBytes.length,12);header.writeUInt32LE(0x4e4f534a,16); await c('machine-document-open-file').setInputFiles({name:'damaged.glb',mimeType:'model/gltf-binary',buffer:Buffer.concat([header,jsonBytes,bin])});await page.waitForFunction(()=>document.querySelector('.machine-manager-workbench')?.getAttribute('aria-busy')==='false');assert.equal(await preview.getAttribute('data-machine-manager-part-count'),'8');assert.match(await page.locator('.machine-manager-footer').textContent(),/无法读取/); report.checks.push('inconsistent editor metadata is rejected atomically without replacing the current draft'); await page.getByRole('button',{name:'清除操作消息'}).click();await page.screenshot({path:new URL('files-and-recovery.png',out).pathname}); await page.keyboard.press('Escape');await c('machine-glb-settings').click();await c('machine-glb-file').setInputFiles({name:first.filename,mimeType:'model/gltf-binary',buffer:first.bytes});await page.waitForFunction(()=>document.querySelector('.xyzac-machine-view')?.dataset.machineJointState);await idle();await page.keyboard.press('Escape'); await c('machine-manager-settings').click();await c('machine-document-new').click();await c('machine-document-from-controller').click();await page.waitForFunction(()=>document.querySelector('[data-machine-manager-preview]')?.dataset.machineManagerPartCount==='8');await choose('part_3');assert.equal(await c('machine-joint-sign').inputValue(),'-1'); report.checks.push('saved GLB loads through control-panel importer and can be sent back into editor with eight original parts'); // The legacy machine remains readable without editor-document metadata. await c('machine-document-open-file').setInputFiles(new URL('../coverage/glb-machine-manager/machine-type/test-xyzbc.glb',import.meta.url).pathname);await page.waitForFunction(()=>document.querySelector('[data-axis-control="machine-manager-type"]')?.value==='XYZBC');assert.equal(await preview.getAttribute('data-machine-manager-part-count'),'1');report.checks.push('legacy GLB remains editable via single-assembly fallback'); // A quota/IDB failure must still allow download and report exact persistence result. await c('machine-manager-tab-files').click();await page.evaluate(()=>{indexedDB.open=()=>{throw new Error('quota');};});const fallback=await save();assert.ok(fallback.bytes.length>0);assert.match(await c('machine-document-location').textContent(),/未保存成功/); report.checks.push('local storage failure does not prevent GLB download and is visibly reported'); assert.deepEqual(report.errors,[]);report.status='verified'; }catch(e){report.status='failed';report.error=e.stack;report.text=await page.locator('body').innerText().catch(()=> '');await page.screenshot({path:new URL('failure.png',out).pathname}).catch(()=>{});throw e;}finally{await writeFile(new URL('browser.report.json',out),JSON.stringify(report,null,2)+'\n');await browser.close();} console.log(JSON.stringify(report));