import fs from 'node:fs'; import assert from 'node:assert/strict'; import {chromium,expect} from '@playwright/test'; import {login,call} from './role-permissions-live.mjs'; import {finalAcceptance} from './bridge-final-acceptance.mjs'; const fullFlow=process.env.FULL_FLOW==='1'; const dir=process.env.REPORT_DIR||((fullFlow?'reports/bridge-final-acceptance-20260924':'reports/bridge-training-safety-20260924')+(process.env.REPORT_SUFFIX||''));fs.mkdirSync(dir,{recursive:true}); const session=await login('root'),checks=[],errors=[]; const browser=await chromium.launch({channel:'msedge',headless:true,args:['--enable-unsafe-swiftshader']}); const page=await browser.newPage({viewport:{width:Number(process.env.WIDTH)||1440,height:Number(process.env.HEIGHT)||900}}); await page.addInitScript(({s,theme})=>{sessionStorage.setItem('unreal-tran:web:access-token:v1',s.accessToken);sessionStorage.setItem('unreal-tran:web:refresh-token:v1',s.refreshToken);if(theme)localStorage.setItem('unreal-tran:web:preferences:v1',JSON.stringify({themeMode:theme}));},{s:session,theme:process.env.THEME}); // Test-only read-only observer. All training actions below use the visible UI. await page.route(/\/src\/features\/virtual-training-legacy\/upstream\/views\/training\.js(?:\?|$)/,async route=>{ const response=await route.fetch();await route.fulfill({response,body:(await response.text())+` globalThis.__safetySnapshot=()=>{ const e=activeTraining;if(!e?.sceneReady)return null; const id=e.getActiveTargetId(),obj=e.parts.get(id),rect=e.canvas.getBoundingClientRect(),box=obj?e.inspection.projectBounds(obj):null,points=[],freePoints=[]; for(const fy of [.4,.55,.7])for(const fx of [.15,.4,.65,.85]){const x=rect.left+rect.width*fx,y=rect.top+rect.height*fy;if(document.elementFromPoint(x,y)===e.canvas&&!e.pick({clientX:x,clientY:y}))freePoints.push({x,y});} if(box)for(const fy of [.5,.2,.8,.05,.95,.35,.65])for(const fx of [.5,.2,.8,.05,.95,.35,.65]){ const x=rect.left+box.x+box.w*fx,y=rect.top+box.y+box.h*fy; if(document.elementFromPoint(x,y)!==e.canvas)continue; const hit=e.pick({clientX:x,clientY:y});if(hit&&e.isObjectInsideTarget(hit.object,obj))points.push({x,y}); } const roots=(e.publishedContentRoot?.children||[]).map(root=>{let faded=0,opaque=0,changed=0;root.traverse(m=>{if(!m.isMesh||m.userData.trainingPickProxy)return;for(const mat of Array.isArray(m.material)?m.material:[m.material]){if(mat.opacity<.4)faded++;else opaque++;if(mat.opacity!==e.viewerMaterialBaseline.get(mat)?.opacity)changed++;}});return {name:root.name,environment:e.viewerEnvironmentRoots?.has(root),faded,opaque,changed};}); const pose=[...e.parts].map(([id,o])=>({id,p:o.position.toArray(),q:o.quaternion.toArray(),visible:o.visible})); const activeFront=obj?.userData.trainingInspectionFront,viewDirection=e.camera.position.clone().sub(e.controls.target).normalize(); return {id,tool:e.bridgeTools?.profile,canonicalId:e.getActiveMechanismOperation()?.operation.targetId,step:e.session.stepIndex,operation:e.getActiveMechanismOperation()?.state.index,operationData:e.getActiveMechanismOperation()?.operation, points,freePoints,box,complete:e.session.currentComplete,pending:e.bridgeOperationPending||e.serverCommitPending,tween:!!e.viewerCameraTween, mountingDistance:obj?.userData.home?obj.getWorldPosition(new THREE.Vector3()).distanceTo(obj.parent.localToWorld(obj.userData.home.position.clone())):null, frontAlignment:activeFront?activeFront.dot(viewDirection):null, needsPlacement:e.bridgePartsWorkbench?.needsPlacement(), completed:[...e.session.completed],introductionActive:!!e.introductionActive, camera:e.camera.position.toArray(),target:e.controls.target.toArray(),floor:e.viewerTerrainSurface?.heightAt(e.camera.position.x,e.camera.position.z),roots,pose, tray:e.bridgePartsWorkbench?.root?{position:e.bridgePartsWorkbench.root.position.toArray(),items:[...e.bridgePartsWorkbench.items.values()].map(item=>{const bounds=new THREE.Box3().setFromObject(item.part,true);return {id:item.id,stored:item.stored,number:item.number,uuid:item.part.uuid,position:item.part.position.toArray(),slot:item.position.toArray(),bottom:bounds.min.y,top:e.bridgePartsWorkbench.layout.position.y};})}:null, explosion:e.viewerExplosionPercent,expanded:e.viewerExplosionItems?.filter(i=>i.object.visible).map(i=>{const b=new THREE.Box3().setFromObject(i.object,true);return {name:i.object.name,min:b.min.toArray(),floor:e.viewerTerrainSurface?.heightAt((b.min.x+b.max.x)/2,(b.min.z+b.max.z)/2)};})}; };`}); }); page.on('pageerror',e=>errors.push(e.message)); const snap=()=>page.evaluate(()=>globalThis.__safetySnapshot?.()); const settle=async()=>{await page.waitForTimeout(850);await expect.poll(async()=>{const d=await snap();return d&&!d.pending&&!d.tween;},{timeout:20000}).toBeTruthy();}; const acceptance=fullFlow?await finalAcceptance({page,dir,checks,snap,settle,session}):null; const operate=async d=>{ if(d.tool?.trigger==='tool-drop'){ const button=page.locator('[data-bridge-tool="'+d.tool.toolId+'"]');await button.scrollIntoViewIfNeeded();const r=await button.boundingBox(); await page.mouse.move(r.x+r.width/2,r.y+r.height/2);await page.mouse.down();await page.mouse.move(d.points[0].x,d.points[0].y,{steps:18});await page.mouse.up(); }else await page.mouse.click(d.points[0].x,d.points[0].y); }; let testedToolFailure=false; try{ for(const id of (process.env.TASK_IDS||'88,89,87,86').split(',')){ const response=await call('tran/v1/teaching/assignments/'+id,session.accessToken);assert.equal(response.status,200);const task=response.data; await page.goto((process.env.BASE_URL||'http://127.0.0.1:6180')+'/teaching/virtual-training/preview/'+id); await expect(page.locator('[data-role="loading"]')).toHaveClass(/is-hidden/,{timeout:120000});await expect.poll(snap,{timeout:30000}).not.toBeNull(); if(acceptance)await acceptance.introduce(id,task); else {const skip=page.locator('[data-intro-command="skip"]');if(await skip.isVisible())await skip.click();} const teacher=page.locator('[aria-label="关闭数字教员"]');if(process.env.KEEP_TEACHER!=='1'&&await teacher.isVisible())await teacher.click(); await settle();await page.screenshot({path:dir+'/task-'+id+'-overview.png'}); const translucent=page.locator('[data-action="toggle-translucent-others"]'); if(!await translucent.isVisible())await page.locator('[data-viewer-action="tools"]').click(); await translucent.click();await settle(); const faded=await snap();assert.ok(faded.roots.filter(r=>r.environment).every(r=>r.changed===0),'terrain opacity unchanged'); if(id==='87')assert.ok(faded.roots.filter(r=>!r.environment).every(r=>r.faded>0),'all vehicles fade'); await translucent.click();await settle();assert.ok((await snap()).roots.every(r=>r.changed===0),'original opacity restores'); await translucent.click(); const before=(await snap()).pose; for(const percent of [25,60,100,0]){ const range=page.locator('[data-role="explosion-range"]'); await range.evaluate((el,value)=>{el.value=String(value);el.dispatchEvent(new Event('input',{bubbles:true}));},percent);await settle(); const d=await snap();assert.ok(d.floor===null||d.camera[1]>=d.floor,'camera above ground'); if(percent)for(const m of d.expanded)if(m.floor!==null)assert.ok(m.min[1]>=m.floor-.001,m.name+' underground'); } assert.deepEqual((await snap()).pose,before,'collapse preserves current poses'); if(process.env.CAMERA_SMOKE==='1'){ for(const [button,dy,wheel] of [['right',700,0],['left',650,0],['left',-800,0],['right',-750,1800],['right',200,-2400]]){ const d=await snap(),point=d.freePoints[0];assert.ok(point,'camera gesture starts on empty canvas'); await page.mouse.move(point.x,point.y);await page.mouse.down({button});await page.mouse.move(point.x+250,point.y+dy,{steps:35});await page.mouse.up({button});if(wheel)await page.mouse.wheel(0,wheel);await settle(); const moved=await snap();assert.ok(moved.floor===null||moved.camera[1]>=moved.floor,'gesture camera above terrain');assert.ok(moved.camera[1]>moved.target[1],'camera cannot flip below target'); await page.locator('[data-action="reset-view"]').click();await settle(); } checks.push({task:id,check:'right-button pan, left-button orbit, wheel, reset remain above terrain'}); } if(process.env.SMOKE_ONLY==='1'){await page.screenshot({path:dir+'/task-'+id+'-camera.png'});continue;} const stepCount=Math.min(Number(process.env.MAX_STEPS)||100,task.definitionSnapshot.steps.length); for(let i=0;iitem.id===d.id); assert.equal(stored.stored,true,'installation starts with the bolt on the workbench'); assert.ok(Math.hypot(...stored.position.map((v,k)=>v-stored.slot[k]))<.00001); assert.equal(d.tool.trigger,'direct','first phase is picking the bolt, not using a wrench'); await page.locator('[data-role="part-label"]').click();await settle();d=await snap(); assert.equal(d.needsPlacement,true,'focusing alone must not move the bolt'); assert.ok(d.points.length,'workbench bolt is clickable'); if(process.env.CHECK_INSTALLATION==='1'){ assert.equal(await page.locator('[data-bridge-tool]:enabled').count(),0,'tools unavailable until the bolt is placed'); await page.screenshot({path:dir+'/task-'+id+'-pick-step-'+(i+1)+'-bolt-'+j+'.png'}); } await page.mouse.click(d.points[0].x,d.points[0].y);await settle();d=await snap(); assert.equal(d.needsPlacement,false);assert.equal(d.operation,j,'picking never completes tightening'); assert.equal(d.tool.trigger,'tool-drop','wrench is required after placing the bolt'); if(process.env.CHECK_INSTALLATION==='1'&&j===1){ await page.locator('[data-open-preparation]').click();await page.locator('[data-save-preparation]').click();await page.locator('[data-close-preparation]').click();await settle();d=await snap(); assert.equal(d.needsPlacement,false,'preparation save retains picked placement'); checks.push({task:id,step:i+1,target:d.id,check:'preparation save preserves placed but untightened bolt'}); } checks.push({task:id,step:i+1,target:d.id,check:'bolt remains on workbench until clicked; click places only; tools unlock at hole'}); } if(process.env.CHECK_INSTALLATION==='1'){ const installation=(id==='88'&&i===7)||(id==='89'&&[9,13].includes(i)); if(installation&&d.id.includes('-bolt-')){ assert.equal(d.tray.items.find(item=>item.id===d.id).stored,false,'bolt leaves storage before tool use'); assert.ok(Math.abs(d.mountingDistance-.008)<.00001,'bolt is staged at mounting hole before tool use'); assert.equal(d.operation,j,'click placement never completes tightening');assert.ok(!d.complete); assert.ok(d.frontAlignment>.95,'automatic camera faces mounting plane'); checks.push({task:id,step:i+1,target:d.id,check:'bolt placed by click; tightening unconfirmed; front-facing camera',mountingDistance:d.mountingDistance}); } if(id==='89'&&[8,12].includes(i)&&d.id.includes('-plug-')){ assert.equal(d.tool.trigger,'direct','plug removal must not require another plug'); checks.push({task:id,step:i+1,target:d.id,check:'installed oil plug removed directly'}); } } if(d.tool?.trigger==='tool-drop'){ if(await page.locator('[data-toolbox-toggle]').getAttribute('aria-expanded')==='false')await page.locator('[data-toolbox-toggle]').click(); if(process.env.KEEP_TEACHER==='1'){ const toolRect=await page.locator('.training-toolbox').boundingBox(),teacherRect=await page.locator('.training-teacher.is-open').boundingBox(); if(teacherRect)assert.ok(teacherRect.x>=toolRect.x+toolRect.width,'toolbox does not cover the digital teacher'); } } await page.locator('[data-role="part-label"]').click();await settle();d=await snap(); assert.ok(d.points.length,'no visible pick point '+id+' step '+(i+1)+' '+d.id); if(process.env.CHECK_INSTALLATION==='1'&&((id==='89'&&[9,13].includes(i))||(id==='88'&&i===7))&&d.id.includes('-bolt-'))await page.screenshot({path:dir+'/task-'+id+'-mount-step-'+(i+1)+'-bolt-'+j+'.png'}); if(d.tool?.trigger==='tool-drop'&&!testedToolFailure){ testedToolFailure=true; const wrongId=(await page.locator('[data-bridge-tool]').evaluateAll(nodes=>nodes.map(n=>n.dataset.bridgeTool))).find(key=>key!==d.tool.toolId); await page.locator('[data-bridge-tool="'+wrongId+'"]').click();await page.mouse.click(d.points[0].x,d.points[0].y);await settle(); assert.equal((await snap()).operation,j,'wrong tool does not advance'); const button=page.locator('[data-bridge-tool="'+d.tool.toolId+'"]');await button.scrollIntoViewIfNeeded();const b=await button.boundingBox(); await page.mouse.move(b.x+b.width/2,b.y+b.height/2);await page.mouse.down();await page.mouse.move(d.points[0].x,d.points[0].y,{steps:10});await page.keyboard.press('Escape');await page.mouse.up();await settle(); assert.equal((await snap()).operation,j,'Escape cancels before drop');assert.deepEqual((await snap()).pose,d.pose,'cancel preserves model'); checks.push({task:id,step:i+1,check:'wrong tool rejected; Escape leaves poses and progress unchanged'}); d=await snap(); assert.ok(d.points.length,'target remains accessible after Escape'); } if(process.env.INJECT_FAILURE==='1'&&i===(Number(process.env.FAIL_STEP)||1)-1&&j===ops.length-1){ await page.route('**/preview/validate',route=>route.fulfill({status:503,contentType:'application/json',body:JSON.stringify({code:503,message:'QA:验证服务暂时不可用'})}),{times:1}); } const saved=j===ops.length-1?page.waitForResponse(r=>r.url().includes('/preview/validate'),{timeout:30000}):null; await operate(d); if(d.tool?.trigger==='tool-drop'&&[0,3,7].includes(i)&&j===0){await page.waitForTimeout(450);await page.screenshot({path:dir+'/task-'+id+'-tool-step-'+(i+1)+'.png'});} if(saved){const r=await saved; if(r.status()===503){await settle();const rejected=await snap();assert.equal(rejected.operation,j,'failed save retains sub-operation');await expect(page.locator('[data-action="next"]')).toBeDisabled(); const retry=page.waitForResponse(r=>r.url().includes('/preview/validate'),{timeout:30000});assert.ok(rejected.points.length);await operate(rejected);assert.equal((await retry).status(),200,'retry after unavailable service');checks.push({task:id,step:i+1,check:'503 preserves operation and retry succeeds'}); }else assert.equal(r.status(),200,'server validation');} else await expect.poll(async()=>(await snap()).operation,{timeout:20000}).toBe(j+1); checks.push({task:id,step:i+1,target:d.id,picked:d.points[0]}); console.log(id,i+1,j+1,d.id); if(acceptance&&((id==='86'&&i===4&&j===0)||(id==='87'&&i===1&&j===0)||(id==='88'&&i===3&&j===1)||(id==='89'&&i===5&&j===1)))await acceptance.revisit(id,i,j); } await expect(page.locator('[data-action="next"]')).toBeEnabled(); if(process.env.CHECK_TRAY==='1'&&['88','89'].includes(id)){ await settle();const after=await snap();assert.ok(after.tray,'physical workbench exists'); for(const item of after.tray.items)if(item.stored){assert.ok(Math.hypot(...item.position.map((v,k)=>v-item.slot[k]))<.00001,'stored part occupies its physical slot: '+item.id);assert.ok(item.bottom>=item.top-.001,'stored part rests above workbench: '+item.id);} const stored=after.tray.items.filter(item=>item.stored).map(item=>item.id); if(id==='88'&&i===3)assert.equal(stored.filter(id=>id.includes('bolt')).length,4); if(id==='88'&&i===7){assert.deepEqual(stored,['bridge-old-pump']);} if(id==='89'&&i===6)assert.equal(stored.length,10); if(id==='89'&&i===13)assert.equal(stored.length,0); checks.push({task:id,step:i+1,check:'physical tray occupancy',stored}); if([3,6,7,13].includes(i)){ if(await page.locator('[data-toolbox-toggle]').getAttribute('aria-expanded')==='false')await page.locator('[data-toolbox-toggle]').click(); const details=page.locator('.training-parts-tray');if(!await details.getAttribute('open')&&await details.getAttribute('open')!== '')await details.locator('summary').click(); await page.locator('[data-tray-focus=""]').click();await settle();await page.screenshot({path:dir+'/task-'+id+'-tray-step-'+(i+1)+'.png'}); assert.equal((await snap()).step,i,'viewing workbench never advances training'); } } if([1,3,7].includes(i))await page.screenshot({path:dir+'/task-'+id+'-step-'+(i+1)+'.png'}); if(i{}); fs.writeFileSync(dir+'/result.json',JSON.stringify({pass:false,error:error.message,checks,errors,snapshot:await snap().catch(()=>null)},null,2));throw error; }finally{await browser.close();}