import fs from 'node:fs/promises'; import path from 'node:path'; import assert from 'node:assert/strict'; import { chromium, expect } from '@playwright/test'; import { login, call } from './role-permissions-live.mjs'; // Capture actual published previews. This hook is injected only into this browser's // development-module response; no application code, task definition or score is changed. const studioHook = ` globalThis.__bridgeCoverStudio = { prepare() { const r = activeTraining; r.stopTeacherSpeech(); r.teacherVoiceEnabled = false; if (r.targetMarker3D) r.targetMarker3D.visible = false; const bounds = object => { if (!object) return null; const box = new THREE.Box3().setFromObject(object, true); return { min:box.min.toArray(), max:box.max.toArray(), center:box.getCenter(new THREE.Vector3()).toArray(), size:box.getSize(new THREE.Vector3()).toArray() }; }; return { vehicle:bounds(r.viewerPrimaryObject), workshop:bounds(r.bridgeWorkshop), parts:[...r.parts].filter(([id])=>id.startsWith('bridge-') || id.startsWith('front-pump-')).map(([id,o])=>({id,name:o.name,bounds:bounds(o)})), camera:r.camera.position.toArray(), target:r.controls.target.toArray() }; }, camera(position, target) { const r = activeTraining; r.cameraAnimation = null; r.viewerCameraTween = null; r.inspection?.reset(); if (r.inspection) r.inspection.outline.enabled = false; r.controls.minDistance = 0.05; r.controls.maxDistance = 200; r.camera.position.fromArray(position); r.controls.target.fromArray(target); r.camera.near=0.01; r.camera.updateProjectionMatrix(); r.controls.update(); }, stage(lastStep) { const r = activeTraining; // Published animation poses only: no session completion or API write. r.applyCompletedMechanismTransforms(lastStep,true); r.bridgeWorkshop?.traverse(o=>{if(o.isSprite)o.visible=false;}); if(r.targetMarker3D)r.targetMarker3D.visible=false; }, };`; const compositions = { 'TASK-VIRTUAL-005': {step:4,position:[-8.6,4.7,7.6],target:[0,1.05,0.1],subject:'整车侧面、辅助控制盒打开、互救开关切换'}, 'TASK-VIRTUAL-006': {step:5,position:[6.4,6.2,13.5],target:[0,1.0,4.5],subject:'架桥车与供回油快速接头,已连接的供回油软管'}, 'TASK-VIRTUAL-007': {step:-1,position:[8.2,4.6,6.4],target:[0,1.05,0.8],subject:'前泵换件场景整车外观,保留正常材质与完整环境'}, 'TASK-VIRTUAL-008': {step:2,position:[-5.8,5.3,12.8],target:[0,1.02,4.8],subject:'大小腔平衡阀,拆开的阀体及油缸接口'}, }; const dir = path.resolve(process.env.REPORT_DIR || 'reports/bridge-task-covers-20260915'); await fs.mkdir(dir, { recursive:true }); const signed = await login('root'); const result = await call('tran/v1/teaching/assignments?channel=VIRTUAL&assignmentKind=TRAINING&size=100',signed.accessToken); assert.equal(result.status,200); const tasks = result.data.records.filter(t=>/^TASK-VIRTUAL-00[5-8]$/.test(t.code)).sort((a,b)=>a.code.localeCompare(b.code)); assert.equal(tasks.length,4); const browser = await chromium.launch({channel:'msedge',headless:true,args:['--enable-unsafe-swiftshader']}); const page = await browser.newPage({viewport:{width:1200,height:1000},deviceScaleFactor:1}); 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); 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())+studioHook}); }); const requestedCodes=new Set((process.env.TASK_CODES || '').split(',').filter(Boolean)); const previousFrames=requestedCodes.size ? JSON.parse(await fs.readFile(path.join(dir,'frames.json'),'utf8').catch(()=>'[]')) : []; const frames=previousFrames.filter(f=>!requestedCodes.has(f.code)); try { for(const task of tasks) { if(requestedCodes.size && !requestedCodes.has(task.code))continue; await page.goto(`http://127.0.0.1:6180/teaching/virtual-training/preview/${task.id}`); await expect(page.locator('[data-role="loading"]')).toHaveClass(/is-hidden/,{timeout:120000}); await page.waitForFunction(()=>globalThis.__bridgeCoverStudio && globalThis.__trainingSceneDiagnostics?.()?.publishedContentMounted); await page.screenshot({path:path.join(dir,`${task.code}-workspace.png`)}); const info=await page.evaluate(()=>globalThis.__bridgeCoverStudio.prepare()); frames.push({code:task.code,name:task.name,taskId:task.id,contentVersionId:task.contentVersionId,...info}); await page.addStyleTag({content:'.training-viewport {position:fixed!important;inset:0!important;width:1200px!important;height:1000px!important;z-index:2147483647!important;border:0!important;border-radius:0!important;} .training-viewport > :not(canvas) {display:none!important;} .training-canvas {width:100%!important;height:100%!important;}'}); const newModels={ 'TASK-VIRTUAL-006':{step:5,position:[-7,5.8,11],target:[1.2,1,1.1],subject:'新版架桥车、保障车与两车连接油管'}, 'TASK-VIRTUAL-007':{step:-1,position:[-6.5,4,8],target:[0,1,1],subject:'新版架桥车及原位前泵换件部件'}, 'TASK-VIRTUAL-008':{step:2,position:[-3.8,3.8,5],target:[-.05,1.3,.75],subject:'新版架桥车大小腔平衡阀与原位拆装区域'}, }; const composition=task.definitionSnapshot.runtime?.scriptVersion==='4'?newModels[task.code]:compositions[task.code]; await page.evaluate(c=>{globalThis.__bridgeCoverStudio.stage(c.step);globalThis.__bridgeCoverStudio.camera(c.position,c.target);},composition); frames.at(-1).composition=composition; await page.waitForTimeout(600); await page.locator('canvas.training-canvas').screenshot({path:path.join(dir,`${task.code}-scene.jpg`),type:'jpeg',quality:88}); console.log(`Captured ${task.code}`); } await fs.writeFile(path.join(dir,'frames.json'),JSON.stringify(frames.sort((a,b)=>a.code.localeCompare(b.code)),null,2)); } finally { await browser.close(); }