Nie możesz wybrać więcej, niż 25 tematów Tematy muszą się zaczynać od litery lub cyfry, mogą zawierać myślniki ('-') i mogą mieć do 35 znaków.
 
 
 
 

155 wiersze
14 KiB

  1. import fs from 'node:fs';
  2. import assert from 'node:assert/strict';
  3. import {chromium,expect} from '@playwright/test';
  4. import {login,call} from './role-permissions-live.mjs';
  5. import {finalAcceptance} from './bridge-final-acceptance.mjs';
  6. const fullFlow=process.env.FULL_FLOW==='1';
  7. const dir=(fullFlow?'reports/bridge-final-acceptance-20260924':'reports/bridge-training-safety-20260924')+(process.env.REPORT_SUFFIX||'');fs.mkdirSync(dir,{recursive:true});
  8. const session=await login('root'),checks=[],errors=[];
  9. const browser=await chromium.launch({channel:'msedge',headless:true,args:['--enable-unsafe-swiftshader']});
  10. const page=await browser.newPage({viewport:{width:Number(process.env.WIDTH)||1440,height:Number(process.env.HEIGHT)||900}});
  11. 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});
  12. // Test-only read-only observer. All training actions below use the visible UI.
  13. await page.route(/\/src\/features\/virtual-training-legacy\/upstream\/views\/training\.js(?:\?|$)/,async route=>{
  14. const response=await route.fetch();await route.fulfill({response,body:(await response.text())+`
  15. globalThis.__safetySnapshot=()=>{
  16. const e=activeTraining;if(!e?.sceneReady)return null;
  17. const id=e.getActiveTargetId(),obj=e.parts.get(id),rect=e.canvas.getBoundingClientRect(),box=obj?e.inspection.projectBounds(obj):null,points=[],freePoints=[];
  18. 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});}
  19. if(box)for(const fy of [.5,.2,.8,.05,.95,.35,.65])for(const fx of [.5,.2,.8,.05,.95,.35,.65]){
  20. const x=rect.left+box.x+box.w*fx,y=rect.top+box.y+box.h*fy;
  21. if(document.elementFromPoint(x,y)!==e.canvas)continue;
  22. const hit=e.pick({clientX:x,clientY:y});if(hit&&e.isObjectInsideTarget(hit.object,obj))points.push({x,y});
  23. }
  24. 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};});
  25. const pose=[...e.parts].map(([id,o])=>({id,p:o.position.toArray(),q:o.quaternion.toArray(),visible:o.visible}));
  26. return {id,tool:e.bridgeTools?.profile,canonicalId:e.getActiveMechanismOperation()?.operation.targetId,step:e.session.stepIndex,operation:e.getActiveMechanismOperation()?.state.index,operationData:e.getActiveMechanismOperation()?.operation,
  27. points,freePoints,box,complete:e.session.currentComplete,pending:e.bridgeOperationPending||e.serverCommitPending,tween:!!e.viewerCameraTween,
  28. completed:[...e.session.completed],introductionActive:!!e.introductionActive,
  29. camera:e.camera.position.toArray(),target:e.controls.target.toArray(),floor:e.viewerTerrainSurface?.heightAt(e.camera.position.x,e.camera.position.z),roots,pose,
  30. 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,
  31. 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)};})};
  32. };`});
  33. });
  34. page.on('pageerror',e=>errors.push(e.message));
  35. const snap=()=>page.evaluate(()=>globalThis.__safetySnapshot?.());
  36. const settle=async()=>{await page.waitForTimeout(850);await expect.poll(async()=>{const d=await snap();return d&&!d.pending&&!d.tween;},{timeout:20000}).toBeTruthy();};
  37. const acceptance=fullFlow?await finalAcceptance({page,dir,checks,snap,settle,session}):null;
  38. const operate=async d=>{
  39. if(d.tool?.trigger==='tool-drop'){
  40. const button=page.locator('[data-bridge-tool="'+d.tool.toolId+'"]');await button.scrollIntoViewIfNeeded();const r=await button.boundingBox();
  41. 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();
  42. }else await page.mouse.click(d.points[0].x,d.points[0].y);
  43. };
  44. let testedToolFailure=false;
  45. try{
  46. for(const id of (process.env.TASK_IDS||'88,89,87,86').split(',')){
  47. const response=await call('tran/v1/teaching/assignments/'+id,session.accessToken);assert.equal(response.status,200);const task=response.data;
  48. await page.goto((process.env.BASE_URL||'http://127.0.0.1:6180')+'/teaching/virtual-training/preview/'+id);
  49. await expect(page.locator('[data-role="loading"]')).toHaveClass(/is-hidden/,{timeout:120000});await expect.poll(snap,{timeout:30000}).not.toBeNull();
  50. if(acceptance)await acceptance.introduce(id,task);
  51. else {const skip=page.locator('[data-intro-command="skip"]');if(await skip.isVisible())await skip.click();}
  52. const teacher=page.locator('[aria-label="关闭数字教员"]');if(process.env.KEEP_TEACHER!=='1'&&await teacher.isVisible())await teacher.click();
  53. await settle();await page.screenshot({path:dir+'/task-'+id+'-overview.png'});
  54. const translucent=page.locator('[data-action="toggle-translucent-others"]');
  55. if(!await translucent.isVisible())await page.locator('[data-viewer-action="tools"]').click();
  56. await translucent.click();await settle();
  57. const faded=await snap();assert.ok(faded.roots.filter(r=>r.environment).every(r=>r.changed===0),'terrain opacity unchanged');
  58. if(id==='87')assert.ok(faded.roots.filter(r=>!r.environment).every(r=>r.faded>0),'all vehicles fade');
  59. await translucent.click();await settle();assert.ok((await snap()).roots.every(r=>r.changed===0),'original opacity restores');
  60. await translucent.click();
  61. const before=(await snap()).pose;
  62. for(const percent of [25,60,100,0]){
  63. const range=page.locator('[data-role="explosion-range"]');
  64. await range.evaluate((el,value)=>{el.value=String(value);el.dispatchEvent(new Event('input',{bubbles:true}));},percent);await settle();
  65. const d=await snap();assert.ok(d.floor===null||d.camera[1]>=d.floor,'camera above ground');
  66. if(percent)for(const m of d.expanded)if(m.floor!==null)assert.ok(m.min[1]>=m.floor-.001,m.name+' underground');
  67. }
  68. assert.deepEqual((await snap()).pose,before,'collapse preserves current poses');
  69. if(process.env.CAMERA_SMOKE==='1'){
  70. for(const [button,dy,wheel] of [['right',700,0],['left',650,0],['left',-800,0],['right',-750,1800],['right',200,-2400]]){
  71. const d=await snap(),point=d.freePoints[0];assert.ok(point,'camera gesture starts on empty canvas');
  72. 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();
  73. 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');
  74. await page.locator('[data-action="reset-view"]').click();await settle();
  75. }
  76. checks.push({task:id,check:'right-button pan, left-button orbit, wheel, reset remain above terrain'});
  77. }
  78. if(process.env.SMOKE_ONLY==='1'){await page.screenshot({path:dir+'/task-'+id+'-camera.png'});continue;}
  79. const stepCount=Math.min(Number(process.env.MAX_STEPS)||100,task.definitionSnapshot.steps.length);
  80. for(let i=0;i<stepCount;i++){
  81. const step=task.definitionSnapshot.steps[i];
  82. await page.locator('[data-open-preparation]').click();
  83. for(const box of await page.locator('[data-safety]').all())await box.check();
  84. if(step.mode==='measure')await page.locator('[data-measurement]').fill('教员预览:按安装段演示连接管路,处理方向与依据提交教员核对。');
  85. await page.locator('[data-save-preparation]').click();await page.locator('[data-close-preparation]').click();
  86. const ops=step.action.mechanism.steps;
  87. for(let j=0;j<ops.length;j++){
  88. await settle();let d=await snap();assert.equal(d.canonicalId,ops[j].targetId);
  89. if(d.tool?.trigger==='tool-drop'){
  90. if(await page.locator('[data-toolbox-toggle]').getAttribute('aria-expanded')==='false')await page.locator('[data-toolbox-toggle]').click();
  91. if(process.env.KEEP_TEACHER==='1'){
  92. const toolRect=await page.locator('.training-toolbox').boundingBox(),teacherRect=await page.locator('.training-teacher.is-open').boundingBox();
  93. if(teacherRect)assert.ok(teacherRect.x>=toolRect.x+toolRect.width,'toolbox does not cover the digital teacher');
  94. }
  95. }
  96. await page.locator('[data-role="part-label"]').click();await settle();d=await snap();
  97. assert.ok(d.points.length,'no visible pick point '+id+' step '+(i+1)+' '+d.id);
  98. if(d.tool?.trigger==='tool-drop'&&!testedToolFailure){
  99. testedToolFailure=true;
  100. const wrongId=(await page.locator('[data-bridge-tool]').evaluateAll(nodes=>nodes.map(n=>n.dataset.bridgeTool))).find(key=>key!==d.tool.toolId);
  101. await page.locator('[data-bridge-tool="'+wrongId+'"]').click();await page.mouse.click(d.points[0].x,d.points[0].y);await settle();
  102. assert.equal((await snap()).operation,j,'wrong tool does not advance');
  103. const button=page.locator('[data-bridge-tool="'+d.tool.toolId+'"]');await button.scrollIntoViewIfNeeded();const b=await button.boundingBox();
  104. 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();
  105. assert.equal((await snap()).operation,j,'Escape cancels before drop');assert.deepEqual((await snap()).pose,d.pose,'cancel preserves model');
  106. checks.push({task:id,step:i+1,check:'wrong tool rejected; Escape leaves poses and progress unchanged'});
  107. d=await snap();
  108. assert.ok(d.points.length,'target remains accessible after Escape');
  109. }
  110. if(process.env.INJECT_FAILURE==='1'&&i===(Number(process.env.FAIL_STEP)||1)-1&&j===ops.length-1){
  111. await page.route('**/preview/validate',route=>route.fulfill({status:503,contentType:'application/json',body:JSON.stringify({code:503,message:'QA:验证服务暂时不可用'})}),{times:1});
  112. }
  113. const saved=j===ops.length-1?page.waitForResponse(r=>r.url().includes('/preview/validate'),{timeout:30000}):null;
  114. await operate(d);
  115. 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'});}
  116. if(saved){const r=await saved;
  117. 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();
  118. 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'});
  119. }else assert.equal(r.status(),200,'server validation');}
  120. else await expect.poll(async()=>(await snap()).operation,{timeout:20000}).toBe(j+1);
  121. checks.push({task:id,step:i+1,target:d.id,picked:d.points[0]});
  122. console.log(id,i+1,j+1,d.id);
  123. 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);
  124. }
  125. await expect(page.locator('[data-action="next"]')).toBeEnabled();
  126. if(process.env.CHECK_TRAY==='1'&&['88','89'].includes(id)){
  127. await settle();const after=await snap();assert.ok(after.tray,'physical workbench exists');
  128. 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);}
  129. const stored=after.tray.items.filter(item=>item.stored).map(item=>item.id);
  130. if(id==='88'&&i===3)assert.equal(stored.filter(id=>id.includes('bolt')).length,4);
  131. if(id==='88'&&i===7){assert.deepEqual(stored,['bridge-old-pump']);}
  132. if(id==='89'&&i===6)assert.equal(stored.length,10);
  133. if(id==='89'&&i===13)assert.equal(stored.length,0);
  134. checks.push({task:id,step:i+1,check:'physical tray occupancy',stored});
  135. if([3,6,7,13].includes(i)){
  136. if(await page.locator('[data-toolbox-toggle]').getAttribute('aria-expanded')==='false')await page.locator('[data-toolbox-toggle]').click();
  137. const details=page.locator('.training-parts-tray');if(!await details.getAttribute('open')&&await details.getAttribute('open')!== '')await details.locator('summary').click();
  138. await page.locator('[data-tray-focus=""]').click();await settle();await page.screenshot({path:dir+'/task-'+id+'-tray-step-'+(i+1)+'.png'});
  139. assert.equal((await snap()).step,i,'viewing workbench never advances training');
  140. }
  141. }
  142. if([1,3,7].includes(i))await page.screenshot({path:dir+'/task-'+id+'-step-'+(i+1)+'.png'});
  143. if(i<stepCount-1)await page.locator('[data-action="next"]').click();
  144. }
  145. if(acceptance)await acceptance.finish(id,task);
  146. }
  147. if(acceptance)await acceptance.verify();
  148. assert.deepEqual(errors,[]);
  149. fs.writeFileSync(dir+'/result.json',JSON.stringify({pass:true,checks,errors},null,2));
  150. }catch(error){
  151. await page.screenshot({path:dir+'/failure.png'}).catch(()=>{});
  152. fs.writeFileSync(dir+'/result.json',JSON.stringify({pass:false,error:error.message,checks,errors,snapshot:await snap().catch(()=>null)},null,2));throw error;
  153. }finally{await browser.close();}