import fs from 'node:fs'; import path from 'node:path'; import crypto from 'node:crypto'; import assert from 'node:assert/strict'; import {chromium} from '@playwright/test'; import {login,call} from './role-permissions-live.mjs'; import speech from '../../unreal_tran_web/src/features/virtual-training-scripts/narration-manifest.js'; import videos from '../../unreal_tran_web/src/features/virtual-training-scripts/narration-video-manifest.js'; const dir='reports/bridge-005-authoring-20260916';fs.mkdirSync(dir,{recursive:true}); const file=dir+'/pilot.json', fixture=fs.existsSync(file)?JSON.parse(fs.readFileSync(file,'utf8')):{}; const save=()=>fs.writeFileSync(file,JSON.stringify(fixture,null,2)); const signed=await login('root'); const api=async(endpoint,body,method)=>{const r=await call('tran/v1/'+endpoint,signed.accessToken,body,method);assert.equal(r.status,200,endpoint+': '+r.message);return r.data;}; const placements=JSON.parse(fs.readFileSync(dir+'/model-placement.json','utf8')); const original=await api('content/projects/438'); assert.equal(original.currentVersion.id,'533','The source must be the verified published version'); const source=structuredClone(original.currentVersion.content); const snapshot=source.scenario.sceneResource.snapshot; const prefix='BRIDGE-005-AUTHORING-PILOT'; async function create(key,type,content,assets=[],dependencies=[]){ if(fixture[key]){ const current=await api('content/projects/'+fixture[key]); if(type==='SCENE' && !current.project.publishedVersionId)return api('content/projects/'+current.project.id,{...current.project,content,assets,dependencies,version:current.project.version},'PUT'); return current; } const code=prefix+'-'+type; const pilotName='005 液压泵应急切换(编排验证)'; const found=await api('content/projects?type='+type+'&keyword='+encodeURIComponent(pilotName)+'&size=100'); assert.ok(!found.records.some(p=>p.name===pilotName),'Untracked pilot already exists; inspect before adopting'); const detail=await api('content/projects',{code,type,name:'005 液压泵应急切换(编排验证)',description:'独立验证工程;不替换 TASK-VIRTUAL-005 及其发布版本。',categoryCode:'MAINTENANCE',trainingMode:type==='TRAINING'?'VIRTUAL':undefined,coverUri:'/models/step-model-front-pump-thumb.png',content,assets,dependencies}); fixture[key]=detail.project.id;save();return detail; } async function publish(detail){ if(detail.project.publishedVersionId===detail.currentVersion.id)return detail; return api('content/projects/'+detail.project.id+'/publish',{version:detail.project.version}); } const asset=(code,url,type)=>{const b=fs.readFileSync(path.resolve('../unreal_tran_web/public',url.replace(/^\//,'')));return {code,name:code,type,storageUri:url,mimeType:'model/gltf-binary',sizeBytes:b.length,sha256:crypto.createHash('sha256').update(b).digest('hex'),metadata:{builtin:true},status:'READY',sortOrder:0};}; const sceneObjects=snapshot.objects.map((o,i)=>({id:o.sceneId,type:'model',name:o.name,sourceName:o.sourceName,assetId:o.modelId,assetCode:i?'PILOT-VEHICLE':'PILOT-TERRAIN',assetUrl:o.resourceUrl,...placements.objects[i],visible:true,opacity:1,color:'#ffffff',semantic:{category:i?'实训装备':'环境地形',deviceId:o.sceneId,modelId:o.modelId,interactionId:o.interactionId,operable:false,capabilities:['select','inspect']}})); const vehicle=snapshot.objects[1]; for(const p of vehicle.modelPartTargets)sceneObjects.push({id:p.interactionId,parentId:vehicle.sceneId,type:'model-part',name:p.name,sourceNodeName:p.sourceNodeName,sourceInteractionId:p.interactionId,position:[0,0,0],rotation:[0,0,0],scale:[1,1,1],visible:true,opacity:1,semantic:{category:'液压机构',deviceId:vehicle.sceneId,modelId:vehicle.modelId,interactionId:p.interactionId,operable:true,capabilities:p.capabilities}}); const sceneContent={id:prefix+'-SCENE',settings:{translateSnap:0,rotateSnap:0,scaleSnap:0,snapEnabled:false},schema:'unreal-tran.scene',version:'2.0',name:'005 液压泵应急切换(编排验证)',templateId:'outdoor-1',sceneType:'outdoor',objects:sceneObjects,rendering:{background:'#a8cbe1',transparentBackground:false,fogColor:'#a8cbe1',fogNear:34,fogFar:88,gridSize:40,gridDivisions:80,fogEnabled:false,exposure:1.05,hemisphereSkyColor:'#edf4f7',hemisphereGroundColor:'#697276',hemisphereIntensity:1.45,directionalColor:'#ffffff',directionalIntensity:2,directionalPosition:[12,18,10],environmentIntensity:1,gridVisible:false,cameraFov:45,cameraNear:0.05,cameraFar:800,cameraPosition:placements.camera.position,cameraTarget:placements.camera.target}}; for(const o of sceneObjects){Object.assign(o,{deleted:false,locked:false,color:'#ffffff',castShadow:true,receiveShadow:true,components:[]});o.semantic.deviceId=o.id;o.semantic.tags=[];} let scene=await create('sceneId','SCENE',sceneContent,[asset('PILOT-TERRAIN','/models/地形.glb','MODEL_FILE'),asset('PILOT-VEHICLE','/models/步骤模型.glb','MODEL_FILE')]); scene=await publish(scene);fixture.sceneVersionId=scene.project.publishedVersionId;save(); const content=structuredClone(source); delete content.scenario.sceneResource;delete content.resources;delete content.runtime.definitionSource;delete content.runtime.scriptId;delete content.runtime.scriptVersion; content.task.title='005 液压泵应急切换(编排验证)';content.task.code=prefix;content.scenario={sceneProjectId:scene.project.id,sceneVersionId:scene.project.publishedVersionId}; content.steps.forEach(s=>{s.interaction='rule-check';s.targetRef={sceneObjectId:s.targetId,interactionId:s.targetId,sourceNodeName:s.targetNodeName,capabilities:['select','inspect','mechanism'],visible:true};s.audio={narrationSource:'offline',narrationText:'',narrationUrl:'',safetyUrl:'',successCue:'ui',errorCue:'collision-error'};}); const browser=await chromium.launch({channel:'msedge',headless:true}); let normalized; try{const page=await browser.newPage();await page.goto('http://127.0.0.1:6180');normalized=await page.evaluate(async content=>{const m=await import('/src/features/training/trainingDocument.ts');return m.normalizeTrainingContent(content,'VIRTUAL');},content);}finally{await browser.close();} const dependencies=[{targetProjectId:scene.project.id,targetVersionId:scene.project.publishedVersionId,relationType:'TRAINING_SCENE',required:true}]; let training=await create('trainingId','TRAINING',normalized,[],dependencies); if(!fixture.mediaUploaded){ const assets=[]; const pack=speech.scripts.find(p=>p.id==='bridge-pump-switch'),vp=videos.scripts.find(p=>p.id==='bridge-pump-switch'); for(let i=0;i