Nelze vybrat více než 25 témat Téma musí začínat písmenem nebo číslem, může obsahovat pomlčky („-“) a může být dlouhé až 35 znaků.
 
 
 
 

299 řádky
27 KiB

  1. import fs from 'node:fs/promises';
  2. import path from 'node:path';
  3. import assert from 'node:assert/strict';
  4. import {chromium,expect} from '@playwright/test';
  5. import {login,call} from './role-permissions-live.mjs';
  6. import {scripts,scriptDefinition} from '../../unreal_tran_web/src/features/virtual-training-scripts/definition.js';
  7. const dir=path.resolve('reports/'+(process.env.REPORT_NAME||'bridge-controls-review-20260915'));await fs.mkdir(dir,{recursive:true});
  8. const resultFile=process.env.RESULT_FILE||'result.json';
  9. const signed=await login('root'),checks=[],errors=[],writes=[];
  10. const pending=new Map(),networkErrors=[];
  11. const browser=await chromium.launch({channel:'msedge',headless:true,args:['--enable-unsafe-swiftshader']});
  12. const page=await browser.newPage({viewport:{width:Number(process.env.WIDTH)||1366,height:Number(process.env.HEIGHT)||768}});
  13. await page.addInitScript(s=>{sessionStorage.setItem('unreal-tran:web:access-token:v1',s.accessToken);sessionStorage.setItem('unreal-tran:web:refresh-token:v1',s.refreshToken);localStorage.setItem('unreal-tran:web:preferences:v1',JSON.stringify({themeMode:'military'}));},signed);
  14. // Read-only geometry preflight before publishing a migration. Never use this
  15. // mode to claim completion: server validation remains on the existing version.
  16. if(process.env.PROPOSED_MODELS==='1'){
  17. assert.notEqual(process.env.EXECUTE,'1');
  18. await page.route(/\/api\/tran\/v1\/teaching\/assignments\/\d+$/,async route=>{
  19. const response=await route.fetch(),body=await response.json(),script=scripts.find(s=>s.code===body.data?.code);
  20. if(script?.version==='4')body.data.definitionSnapshot=scriptDefinition(script);
  21. await route.fulfill({response,json:body});
  22. });
  23. }
  24. await page.route(/\/src\/features\/virtual-training-legacy\/upstream\/views\/training\.js(?:\?|$)/,async route=>{
  25. const response=await route.fetch();await route.fulfill({response,body:(await response.text()).replace("this.showFeedback('error', `编辑场景加载失败:", "globalThis.__bridgeLoadError=error.stack; this.showFeedback('error', `编辑场景加载失败:").replace('const partId = hit.object.userData.partId;', 'globalThis.__lastPointerPick={id:hit.object.userData.partId,x:event.clientX,y:event.clientY,active:this.getActiveTargetId()}; const partId = hit.object.userData.partId;')+`
  26. globalThis.__inspectionSnapshot=(options={})=>{
  27. const e=activeTraining;if(!e?.sceneReady)return null;
  28. const id=e.getActiveTargetId(),object=e.parts.get(id),box=object?physicalBounds(object):null;
  29. const screen=object?e.worldToScreen(box.getCenter(new THREE.Vector3())):null;
  30. const materials=[];e.publishedContentRoot.traverse(o=>{if(o.isMesh&&!o.userData.trainingPickProxy)for(const m of Array.isArray(o.material)?o.material:[o.material])materials.push({opacity:m.opacity,baseline:e.viewerMaterialBaseline.get(m)?.opacity,side:m.side});});
  31. const labels=[...e.viewport.querySelectorAll('.training-part-label.is-visible')].map(n=>{const r=n.getBoundingClientRect();return {text:n.textContent.trim(),x:r.x,y:r.y,w:r.width,h:r.height,id:n.dataset.labelTarget||id};});
  32. const op=e.getActiveMechanismOperation()?.operation;
  33. const lid=e.parts.get('front-pump-aux-control-box-cover');
  34. const projected=object?e.inspection.projectBounds(object):null;
  35. const canvas=e.canvas.getBoundingClientRect();
  36. const pickPoints=[];
  37. if(projected){const ray=new THREE.Raycaster();for(const fy of [0.5,0.35,0.65,0.2,0.8,0.05,0.95])for(const fx of [0.5,0.35,0.65,0.2,0.8,0.05,0.95]){
  38. const x=projected.x+projected.w*fx,y=projected.y+projected.h*fy;
  39. if(document.elementFromPoint(x+canvas.left,y+canvas.top)!==e.canvas)continue;
  40. ray.setFromCamera(new THREE.Vector2(x/canvas.width*2-1,1-y/canvas.height*2),e.camera);
  41. const hit=ray.intersectObjects(e.pickables,false).find(h=>!h.object.userData.trainingPickProxy&&effectivelyVisible(h.object)&&!(Array.isArray(h.object.material)?h.object.material:[h.object.material]).some(m=>m.transparent&&m.opacity<VIEWER_SEE_THROUGH_OPACITY));
  42. if(hit&&e.isObjectInsideTarget(hit.object,object))pickPoints.push({x:x+canvas.left,y:y+canvas.top});
  43. }}
  44. const pivot=op&&object?e.getMechanismPivot(object,op).toArray():null;
  45. const freePoints=[];
  46. if(options.free){const ray=new THREE.Raycaster();for(let y=70;y<canvas.height-80;y+=40)for(let x=30;x<canvas.width-30;x+=40){
  47. if(document.elementFromPoint(canvas.left+x,canvas.top+y)!==e.canvas)continue;
  48. ray.setFromCamera(new THREE.Vector2(x/canvas.width*2-1,1-y/canvas.height*2),e.camera);
  49. const hit=ray.intersectObjects(e.pickables,false).find(h=>!h.object.userData.trainingPickProxy&&effectivelyVisible(h.object));
  50. if(hit?.object.userData.partId==='scene:front-pump-rescue-vehicle')freePoints.push({x:canvas.left+x,y:canvas.top+y});
  51. }}
  52. let grounding=null;
  53. if(options.newModel){
  54. const vehicle=e.viewerPrimaryObject,terrain=e.publishedContentRoot.children.filter(o=>o!==vehicle),bounds=physicalBounds(vehicle),ray=new THREE.Raycaster(),gaps=[];
  55. vehicle.traverse(mesh=>{if(!mesh.isMesh||mesh.userData.trainingPickProxy)return;const a=mesh.geometry.attributes.position,p=new THREE.Vector3();for(let i=0;i<a.count;i++){p.fromBufferAttribute(a,i).applyMatrix4(mesh.matrixWorld);if(p.y>bounds.min.y+.002)continue;ray.set(new THREE.Vector3(p.x,p.y+2,p.z),new THREE.Vector3(0,-1,0));const hit=ray.intersectObjects(terrain,true)[0];if(hit)gaps.push(p.y-hit.point.y);}});
  56. grounding={samples:gaps.length,minGap:Math.min(...gaps),maxGap:Math.max(...gaps)};
  57. }
  58. return {id,step:e.session.stepIndex,operation:e.getActiveMechanismOperation()?.state.index,errors:e.errorCount,complete:e.session.isComplete(),pending:!!e.bridgeOperationPending||!!e.serverCommitPending,
  59. drag:e.bridgeDrag?.operation.targetId,cameraTween:!!e.viewerCameraTween,lastPointerPick:globalThis.__lastPointerPick,
  60. lid:lid?{position:lid.position.toArray(),quaternion:lid.quaternion.toArray()}:null,evidenceCount:e.mechanismState?.evidence.length,
  61. grounding,modelSources:e.scenario.sceneResource.snapshot.objects.filter(o=>o.type==='imported').map(o=>({name:o.sourceName,url:o.resourceUrl})),
  62. hdr:e.scene.environment?.name,distance:e.camera.position.distanceTo(e.controls.target),zoom:e.camera.zoom,exposure:e.renderer.toneMappingExposure,light:[e.ambient.intensity,e.sun.intensity],
  63. screen,bounds:box?box.getSize(new THREE.Vector3()).toArray():null,projected,pivot,pickPoints,freePoints,
  64. tip:object?.userData.trainingHandleTipLocal ? object.localToWorld(object.userData.trainingHandleTipLocal.clone()).toArray():null,
  65. front:object?.userData.trainingInspectionFront?.toArray(),
  66. workflowCoverage:e.inspection.workflowTargets().filter(effectivelyVisible).map(o=>{const meshes=[];o.traverse(n=>{if(n.isMesh&&!n.userData.trainingPickProxy&&effectivelyVisible(n))meshes.push(n);});return {name:o.userData.partId,glowing:meshes.every(m=>e.inspection.outline.selectedObjects.includes(m)||e.inspection.workflowOutline.selectedObjects.includes(m))};}),
  67. camera:e.camera.position.toArray(),target:e.controls.target.toArray(),outline:e.inspection.outline.selectedObjects.length,cutaway:e.inspection.cutaway?.size||0,materials,labels,
  68. narration:e.steps.map(s=>({video:e.context.videoUrls?.includes(s.audio?.narrationUrl)===true,fallback:!!s.audio?.narrationFallbackUrl})),
  69. teacherWarning:e.teacherWarning?.textContent,stepCount:e.steps.length,hasNormalControl:e.parts.has('bridge-normal-control'),
  70. hoseConnections:['bridge-hose-line-a','bridge-hose-line-b','bridge-return-line-a','bridge-return-line-b'].map(id=>{const p=e.parts.get(id);return p?{id,position:p.position.toArray(),remaining:p.position.distanceTo(p.userData.home.position)}:null;}).filter(Boolean),
  71. operationData:op,position:object?.position.toArray(),quaternion:(object?.userData.bridgeRotatingPart||object)?.quaternion.toArray()};
  72. };`});
  73. });
  74. page.on('pageerror',error=>errors.push(error.message));
  75. page.on('request',r=>pending.set(r,new URL(r.url()).pathname));
  76. page.on('requestfinished',r=>pending.delete(r));
  77. page.on('requestfailed',r=>{networkErrors.push({path:new URL(r.url()).pathname,error:r.failure()?.errorText});pending.delete(r);});
  78. page.on('request',request=>{if(request.url().includes('/api/tran/')&&!['GET','HEAD'].includes(request.method()))writes.push(request.url());});
  79. const snapshot=()=>page.evaluate(()=>globalThis.__inspectionSnapshot?.());
  80. const ready=async()=>{await expect(page.locator('[data-role="loading"]')).toHaveClass(/is-hidden/,{timeout:120000});const error=await page.evaluate(()=>globalThis.__bridgeLoadError);if(error)throw new Error(error);await expect.poll(snapshot,{timeout:30000}).not.toBeNull();await page.waitForTimeout(1800);};
  81. try {
  82. for(const id of (process.env.TASK_IDS||'86,87,88,89').split(',')){
  83. console.log('OPEN',id);
  84. await page.goto((process.env.BASE_URL||'http://127.0.0.1:6180')+'/teaching/virtual-training/preview/'+id);await ready();
  85. const introSkip=page.locator('.training-introduction [data-intro-command="skip"]');
  86. if(await introSkip.isVisible())await introSkip.click();
  87. const state=await snapshot();
  88. await page.screenshot({path:path.join(dir,`task-${id}-initial.png`)});
  89. assert.equal(state.hdr,'venice_sunset_1k');assert.ok(state.outline>0);
  90. assert.ok(state.materials.every(m=>m.side===2),'DoubleSide physical materials');
  91. assert.ok(state.materials.every(m=>m.baseline==null||m.opacity===m.baseline),'original opacity');
  92. assert.equal(state.zoom,1,'initial overview is not magnified');assert.ok(state.workflowCoverage.every(t=>t.glowing),'all visible workflow parts glow on entry');
  93. assert.ok(state.labels.some(l=>l.id===state.id),'active target label visible');
  94. checks.push({name:'task '+id+' first view',state});console.log('VIEW',id,JSON.stringify({projected:state.projected,labels:state.labels.map(l=>l.text),cutaway:state.cutaway}));
  95. if(process.env.CHECK_NEW_MODEL==='1'){
  96. const inspected=await page.evaluate(()=>globalThis.__inspectionSnapshot({newModel:true}));
  97. assert.ok(inspected.modelSources.some(m=>m.name==='0916架桥车.glb'&&m.url.includes('bridge-erector-20260916.glb')));
  98. assert.ok(inspected.grounding.samples>0);assert.ok(inspected.grounding.minGap>-.003&&inspected.grounding.maxGap<.003,'new vehicle contacts authored terrain');
  99. checks.push({name:'new model source and ground contacts',pass:true,grounding:inspected.grounding,modelSources:inspected.modelSources});
  100. }
  101. if(process.env.REQUIRE_VIDEO==='1'){
  102. assert.ok(state.narration.length>0);assert.ok(state.narration.every(s=>s.video&&s.fallback),'all steps have video and voice fallback');
  103. await page.locator('[data-action="teacher-speak"]').click();
  104. const video=page.locator('.training-server-video');
  105. await expect(video).toBeVisible();
  106. await expect.poll(()=>video.evaluate(v=>v.readyState>=2&&v.videoWidth>0&&v.currentTime>0),{timeout:45000}).toBe(true);
  107. checks.push({name:'task '+id+' digital-human video playback',pass:true,media:await video.evaluate(v=>({width:v.videoWidth,height:v.videoHeight,duration:v.duration,currentTime:v.currentTime}))});
  108. }
  109. if(process.env.EXECUTE==='1'||process.env.EXTRA_CONTROLS==='1'){
  110. for(const action of ['steps','tools']){
  111. const button=page.locator(`[data-viewer-action="${action}"]`);
  112. if(await button.getAttribute('aria-expanded')==='true')await button.click();
  113. }
  114. await page.waitForTimeout(400);
  115. }
  116. if(process.env.EXTRA_CONTROLS==='1'){
  117. await page.locator('[data-viewer-action="tools"]').click();
  118. await page.locator('[data-action="viewer-reset-view"]').click();await page.waitForTimeout(1000);
  119. const free=await page.evaluate(()=>globalThis.__inspectionSnapshot({free:true}));assert.ok(free.freePoints.length,'vehicle body can be selected');
  120. const point=free.freePoints[0],beforeWrites=writes.length;
  121. await page.mouse.click(point.x,point.y);await page.waitForTimeout(900);
  122. const selected=await snapshot();assert.equal(selected.errors,free.errors);assert.equal(selected.step,free.step);assert.equal(selected.operation,free.operation);assert.equal(writes.length,beforeWrites);
  123. for(let i=0;i<4;i++)await page.locator('[data-action="toggle-issue-highlight"]').click();
  124. assert.ok((await snapshot()).materials.every(m=>m.baseline==null||m.opacity===m.baseline));
  125. await page.locator('[data-action="viewer-reset-view"]').click();await page.waitForTimeout(1000);
  126. const orbit=await page.evaluate(()=>globalThis.__inspectionSnapshot({free:true}));assert.ok(orbit.freePoints.length);
  127. const dragPoint=orbit.freePoints[0];await page.mouse.move(dragPoint.x,dragPoint.y);await page.mouse.down();await page.mouse.move(dragPoint.x+45,dragPoint.y+20,{steps:8});await page.mouse.up();await page.waitForTimeout(900);
  128. const moved=await snapshot();assert.equal(moved.errors,0);assert.notDeepEqual(moved.camera,orbit.camera);assert.equal(writes.length,beforeWrites);
  129. await page.locator('[data-viewer-action="tools"]').click();
  130. await page.locator('[data-viewer-action="fullscreen"]').click();await page.waitForTimeout(400);await page.keyboard.press('Escape');await page.waitForTimeout(500);
  131. await expect(page.locator('[data-viewer-action="fullscreen"]')).toHaveAttribute('title','全屏操作');await expect(page.locator('[data-role="modal"]')).toBeHidden();
  132. assert.ok(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),'no page horizontal overflow');
  133. await page.screenshot({path:path.join(dir,'free-inspection-controls.png')});checks.push({name:'non-workflow body click/orbit, selection-highlight restoration, Escape fullscreen exit',pass:true});
  134. }
  135. if(process.env.EXECUTE==='1'){
  136. // Labels only focus: no operation, no validation request, no score/error change.
  137. const beforeWrites=writes.length;
  138. await page.locator('[data-role="part-label"]').click();await page.waitForTimeout(900);
  139. const enlarged=await snapshot();assert.equal(enlarged.step,state.step);assert.equal(enlarged.operation,state.operation);assert.equal(writes.length,beforeWrites);assert.ok(enlarged.projected.w+enlarged.projected.h>state.projected.w+state.projected.h,'label enlarges the target from overview');
  140. const standOff=enlarged.distance;
  141. // Native fullscreen and both collapsible panels remain reversible on laptops.
  142. await page.locator('[data-viewer-action="steps"]').click();await expect(page.locator('.training-steps-panel')).toBeVisible();
  143. await page.locator('[data-viewer-action="steps"]').click();await expect(page.locator('.training-steps-panel')).toBeHidden();
  144. await page.locator('[data-viewer-action="tools"]').click();await expect(page.locator('[data-role="model-commandbar"]')).toBeVisible();
  145. assert.equal(await page.locator('.training-bottom-bar [data-viewer-action]').count(),3,'display controls are in the bottom bar');
  146. assert.equal(await page.locator('.training-viewer-actions').innerText(),'','icon-only controls');
  147. for(const button of await page.locator('[data-viewer-action]').all())assert.ok(await button.getAttribute('title'),'icons have hover titles');
  148. await page.locator('[data-action="toggle-translucent-others"]').click();await page.waitForTimeout(250);
  149. assert.ok((await snapshot()).materials.some(m=>m.baseline>0 && m.opacity<m.baseline),'independent tool fades surrounding equipment');
  150. for(let i=0;i<2;i++)await page.locator('[data-action="toggle-issue-highlight"]').click();
  151. assert.ok((await snapshot()).materials.some(m=>m.baseline>0 && m.opacity<m.baseline),'issue glow does not reset translucent tool');
  152. await page.screenshot({path:path.join(dir,`task-${id}-translucent.png`)});
  153. await page.locator('[data-action="toggle-translucent-others"]').click();
  154. assert.ok((await snapshot()).materials.every(m=>m.baseline==null||m.opacity===m.baseline),'opacity restored after separate translucency is off');
  155. await page.locator('[data-action="toggle-part-labels"]').click();
  156. for(let n=0;n<2;n++){await page.locator('[data-action="toggle-issue-highlight"]').click();await page.waitForTimeout(100);}
  157. assert.ok((await snapshot()).materials.every(m=>m.baseline==null||m.opacity===m.baseline));
  158. await page.locator('[data-action="toggle-hide-others"]').click();await page.waitForTimeout(300);
  159. const isolated=await snapshot();
  160. assert.ok(isolated.labels.length>0 && isolated.labels.every(l=>l.id===enlarged.id),'hide others keeps the focused label and hides all others');
  161. await page.locator('[data-action="toggle-hide-others"]').click();
  162. await page.locator('[data-viewer-action="tools"]').click();
  163. await page.locator('[data-viewer-action="fullscreen"]').click();await page.waitForTimeout(500);
  164. await expect(page.locator('[data-viewer-action="fullscreen"]')).toHaveAttribute('title','退出全屏');
  165. await page.screenshot({path:path.join(dir,`task-${id}-fullscreen.png`)});
  166. await page.locator('[data-viewer-action="fullscreen"]').click();await page.waitForTimeout(500);
  167. await expect(page.locator('[data-viewer-action="fullscreen"]')).toHaveAttribute('title','全屏操作');
  168. const assignment=await call('tran/v1/teaching/assignments/'+id,signed.accessToken);
  169. const total=assignment.data.definitionSnapshot.steps.length;
  170. const prepared=new Set();
  171. let gestures=0;
  172. while(true){
  173. await expect.poll(async()=>!!(await snapshot())&&!(await snapshot()).cameraTween,{timeout:10000}).toBe(true);
  174. let start=await snapshot();
  175. if(process.env.PREPARE_SAFETY==='1'&&!prepared.has(start.step)&&!(process.env.LATE_PREPARATION==='1'&&start.step===4)){
  176. await page.locator('[data-open-preparation]').click();
  177. for(const box of await page.locator('[data-safety]').all())await box.check();
  178. if(assignment.data.definitionSnapshot.steps[start.step].mode==='measure')await page.locator('[data-measurement]').fill('本次教员预览按脚本演示连接管路;管路处理方向与依据需由教员核对,此记录仅用于验证交互,不作实装质量判定。');
  179. await page.locator('[data-save-preparation]').click();
  180. await page.locator('[data-close-preparation]').click();prepared.add(start.step);
  181. }
  182. if(process.env.MANUAL_OCCLUSION_TOOLS==='1'&&!start.pickPoints.length){
  183. const toolsButton=page.locator('[data-viewer-action="tools"]');
  184. if(await toolsButton.getAttribute('aria-expanded')!=='true')await toolsButton.click();
  185. const transparent=page.locator('[data-action="toggle-translucent-others"]');
  186. if(await transparent.getAttribute('aria-pressed')!=='true')await transparent.click();
  187. await toolsButton.click();await page.waitForTimeout(500);start=await snapshot();
  188. if(!start.pickPoints.length){
  189. await page.locator('[data-role="part-label"]').click();await page.waitForTimeout(900);
  190. await toolsButton.click();await page.locator('[data-action="toggle-hide-others"]').click();await toolsButton.click();
  191. await page.waitForTimeout(400);start=await snapshot();
  192. checks.push({name:'task '+id+' explicit isolate for overlapping workflow targets',targetId:start.id,pass:start.pickPoints.length>0});
  193. }
  194. checks.push({name:'task '+id+' internal target inspected with explicit translucent tool',targetId:start.id,pass:start.pickPoints.length>0});
  195. }
  196. if(assignment.data.definitionSnapshot.steps[start.step].mode==='measure' && !prepared.has(start.step)){
  197. await page.locator('[data-open-preparation]').click();
  198. await page.locator('[data-measurement]').fill('本次教员预览按脚本演示连接管路;管路处理方向与依据需由教员核对,此记录仅用于验证交互,不作实装质量判定。');
  199. await page.locator('[data-save-preparation]').click();await expect(page.locator('[data-save-status]')).toContainText('已保留');
  200. await page.locator('[data-close-preparation]').click();prepared.add(start.step);
  201. checks.push({name:'task '+id+' step '+(start.step+1)+' observation retained for instructor review',pass:true});
  202. }
  203. if(start.step===0 && start.zoom===1){await page.locator('[data-role=part-label]').click();await page.waitForTimeout(900);continue;}
  204. assert.ok(start.pickPoints.length,'visible target has a real mesh hit: '+start.id);
  205. assert.ok(Math.abs(start.distance-standOff)<0.015,'consistent stand-off '+start.id);
  206. if(start.front)assert.ok(start.front.reduce((n,v,i)=>n+v*(start.camera[i]-start.target[i])/start.distance,0)>.99,'camera faces the authored control front');
  207. assert.equal(start.exposure,state.exposure);assert.deepEqual(start.light,state.light);
  208. for(let i=0;i<start.labels.length;i++)for(let j=i+1;j<start.labels.length;j++){const a=start.labels[i],b=start.labels[j];assert.ok(a.x+a.w<=b.x||b.x+b.w<=a.x||a.y+a.h<=b.y||b.y+b.h<=a.y,'labels do not overlap');}
  209. const point=start.pickPoints[0],operation=start.operationData;
  210. if(process.env.CHECK_HOSE_CONNECTIONS==='1'&&start.step===1&&start.operation===2){
  211. await page.mouse.move(point.x,point.y);await page.mouse.down();await page.mouse.move(point.x-50,point.y,{steps:5});await page.mouse.up();
  212. await page.waitForTimeout(450);
  213. const cancelled=await snapshot();
  214. assert.equal(cancelled.operation,2);assert.equal(cancelled.complete,false);
  215. assert.deepEqual(cancelled.hoseConnections,start.hoseConnections,'short drag must restore the disconnected end and keep other ends unchanged');
  216. checks.push({name:'006 short hose drag does not count as a connection',pass:true});
  217. }
  218. await page.mouse.move(point.x,point.y);await page.mouse.down();
  219. const draggable=process.env.CLICK_ONLY!=='1' && (operation.kind==='rotate'||operation.effects?.some(f=>f.offset||f.scale));
  220. if(draggable){
  221. await page.mouse.move(point.x-68,point.y,{steps:6});await page.waitForTimeout(100);
  222. const mid=await snapshot();
  223. if(operation.kind==='rotate'){
  224. if(start.tip){assert.ok(mid.tip[1]>start.tip[1]+0.0001,'rescue handle moves upwards toward panel open arrow');await page.screenshot({path:path.join(dir,`task-${id}-rescue-moving.png`)});}
  225. assert.notDeepEqual(mid.quaternion,start.quaternion,'rotation follows held pointer '+start.id);
  226. if(!operation.effects?.some(effect=>effect.offset))assert.ok(Math.hypot(...mid.pivot.map((v,i)=>v-start.pivot[i]))<0.0001,'hinge remains anchored '+start.id);
  227. }
  228. await page.mouse.move(point.x-175,point.y,{steps:8});
  229. }
  230. await page.mouse.up();
  231. if(process.env.LATE_PREPARATION==='1'&&start.step===4&&start.operation===1&&!prepared.has(start.step)){
  232. await expect(page.locator('.training-server-preparation')).toHaveAttribute('open','',{timeout:20000});
  233. await expect.poll(async()=>!(await snapshot()).pending,{timeout:20000}).toBe(true);
  234. const rejected=await snapshot();
  235. assert.deepEqual(rejected.lid,start.lid,'preparation rejection must keep the already opened lid in place');
  236. assert.equal(rejected.operation,1);assert.equal(rejected.evidenceCount,1);assert.equal(rejected.complete,false);
  237. assert.deepEqual(rejected.quaternion,start.quaternion,'unaccepted rescue rotation is rolled back');
  238. for(const box of await page.locator('[data-safety]').all())await box.check();
  239. for(let save=0;save<2;save++){
  240. await page.locator('[data-save-preparation]').click();
  241. await expect(page.locator('[data-save-status]')).toContainText('已保留');
  242. assert.deepEqual((await snapshot()).lid,start.lid,'saving preparation preserves the open lid');
  243. }
  244. await page.locator('[data-close-preparation]').click();prepared.add(start.step);
  245. await page.screenshot({path:path.join(dir,`task-${id}-late-preparation-lid-open.png`)});
  246. checks.push({name:'late preparation: rejected switch resets only its own motion; open lid survives repeated saves',pass:true,lid:start.lid});
  247. continue;
  248. }
  249. await expect.poll(async()=>{const s=await snapshot();return !s.pending&&(s.complete||s.operation!==start.operation);},{timeout:20000}).toBe(true);
  250. await page.waitForTimeout(850);const done=await snapshot();if(start.tip)assert.ok(done.tip[1]>start.tip[1]+0.0001,'completed rescue switch stays upwards');assert.equal(done.errors,0,'no false operation error');
  251. if(process.env.CHECK_HOSE_CONNECTIONS==='1'&&start.step===1){
  252. const connected=Math.max(0,start.operation-1);
  253. assert.equal(done.hoseConnections.length,4);
  254. done.hoseConnections.forEach((end,i)=>assert.ok(Math.abs(end.remaining-(i<connected?0:.12))<1e-6,'only the operated ends may connect: '+end.id));
  255. assert.equal(done.complete,start.operation===5,'two covers alone cannot complete this step');
  256. assert.ok(done.teacherWarning.includes(done.complete?'已完成':done.operationData.directionHint),'digital teacher follows the current sub-operation');
  257. if(start.operation===2){
  258. await page.locator('[data-open-preparation]').click();
  259. for(let i=0;i<2;i++)await page.locator('[data-save-preparation]').click();
  260. await page.locator('[data-close-preparation]').click();
  261. assert.deepEqual((await snapshot()).hoseConnections,done.hoseConnections,'saving preparation preserves connected and disconnected ends');
  262. }
  263. await page.screenshot({path:path.join(dir,`hose-operation-${start.operation+1}.png`)});
  264. checks.push({name:'006 hose operation '+(start.operation+1)+' geometry and completion guard',pass:true,connected,ends:done.hoseConnections});
  265. }
  266. if(process.env.MANUAL_OCCLUSION_TOOLS==='1'&&await page.locator('[data-action="toggle-hide-others"]').getAttribute('aria-pressed')==='true'){
  267. await page.locator('[data-viewer-action="tools"]').click();await page.locator('[data-action="toggle-hide-others"]').click();await page.locator('[data-viewer-action="tools"]').click();
  268. }
  269. checks.push({name:'task '+id+' step '+(start.step+1)+' operation '+(start.operation+1),target:start.id,drag:!!draggable,distance:start.distance,projected:start.projected,labels:start.labels.length});
  270. console.log('ACTION',id,start.step+1,start.operation+1,start.id);
  271. if(++gestures>120)throw new Error('unexpected operation loop');
  272. if(done.complete){
  273. await page.screenshot({path:path.join(dir,`task-${id}-step-${start.step+1}.png`)});
  274. if(start.step+1>=total)break;
  275. await page.locator('[data-action="next"]').click();await page.waitForTimeout(1200);
  276. }
  277. }
  278. if(process.env.CHECK_006_SIX_STEPS==='1'){
  279. assert.equal(String(id),'87');assert.equal(total,6);
  280. const final=await snapshot();assert.equal(final.stepCount,6);assert.equal(final.hasNormalControl,false);assert.equal(final.step,5);assert.equal(final.complete,true);
  281. assert.equal(final.narration.length,6);assert.ok(final.narration.every(item=>item.video&&item.fallback));
  282. await expect(page.locator('.training-step[data-step-index]')).toHaveCount(6);
  283. await expect(page.locator('[data-action="next"]')).toHaveText(/完成训练/);
  284. await page.locator('[data-action="next"]').click();
  285. await expect(page.locator('[data-role="modal-content"]')).toContainText('应急替代操作训练已完成,后续可按正常作业规程使用架桥车。');
  286. await page.screenshot({path:path.join(dir,'six-step-completion.png')});
  287. checks.push({name:'006 completes after six steps with no synthetic normal-operation handle, six paired narrations and completion message',pass:true});
  288. }
  289. const after=await call('tran/v1/teaching/assignments/'+id,signed.accessToken);
  290. assert.deepEqual(after.data.definitionSnapshot,assignment.data.definitionSnapshot);assert.equal(after.data.version,assignment.data.version);
  291. }
  292. }
  293. assert.deepEqual(errors,[]);
  294. await fs.writeFile(path.join(dir,resultFile),JSON.stringify({pass:true,checks,errors,writes},null,2));
  295. console.log('PASS',checks.length);
  296. }catch(error){await page.screenshot({path:path.join(dir,'failure.png')}).catch(()=>{});await fs.writeFile(path.join(dir,resultFile),JSON.stringify({pass:false,error:error.message,checks,state:await snapshot(),errors,writes,networkErrors,pending:[...pending.values()]},null,2));throw error;}
  297. finally{await browser.close();}