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ů.
 
 
 
 

84 řádky
9.1 KiB

  1. import fs from 'node:fs';
  2. import path from 'node:path';
  3. import crypto from 'node:crypto';
  4. import assert from 'node:assert/strict';
  5. import {chromium} from '@playwright/test';
  6. import {login,call} from './role-permissions-live.mjs';
  7. import speech from '../../unreal_tran_web/src/features/virtual-training-scripts/narration-manifest.js';
  8. import videos from '../../unreal_tran_web/src/features/virtual-training-scripts/narration-video-manifest.js';
  9. const dir='reports/bridge-005-authoring-20260916';fs.mkdirSync(dir,{recursive:true});
  10. const file=dir+'/pilot.json', fixture=fs.existsSync(file)?JSON.parse(fs.readFileSync(file,'utf8')):{};
  11. const save=()=>fs.writeFileSync(file,JSON.stringify(fixture,null,2));
  12. const signed=await login('root');
  13. 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;};
  14. const placements=JSON.parse(fs.readFileSync(dir+'/model-placement.json','utf8'));
  15. const original=await api('content/projects/438');
  16. assert.equal(original.currentVersion.id,'533','The source must be the verified published version');
  17. const source=structuredClone(original.currentVersion.content);
  18. const snapshot=source.scenario.sceneResource.snapshot;
  19. const prefix='BRIDGE-005-AUTHORING-PILOT';
  20. async function create(key,type,content,assets=[],dependencies=[]){
  21. if(fixture[key]){
  22. const current=await api('content/projects/'+fixture[key]);
  23. if(type==='SCENE' && !current.project.publishedVersionId)return api('content/projects/'+current.project.id,{...current.project,content,assets,dependencies,version:current.project.version},'PUT');
  24. return current;
  25. }
  26. const code=prefix+'-'+type;
  27. const pilotName='005 液压泵应急切换(编排验证)';
  28. const found=await api('content/projects?type='+type+'&keyword='+encodeURIComponent(pilotName)+'&size=100');
  29. assert.ok(!found.records.some(p=>p.name===pilotName),'Untracked pilot already exists; inspect before adopting');
  30. 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});
  31. fixture[key]=detail.project.id;save();return detail;
  32. }
  33. async function publish(detail){
  34. if(detail.project.publishedVersionId===detail.currentVersion.id)return detail;
  35. return api('content/projects/'+detail.project.id+'/publish',{version:detail.project.version});
  36. }
  37. 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};};
  38. 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']}}));
  39. const vehicle=snapshot.objects[1];
  40. 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}});
  41. 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}};
  42. 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=[];}
  43. let scene=await create('sceneId','SCENE',sceneContent,[asset('PILOT-TERRAIN','/models/地形.glb','MODEL_FILE'),asset('PILOT-VEHICLE','/models/步骤模型.glb','MODEL_FILE')]);
  44. scene=await publish(scene);fixture.sceneVersionId=scene.project.publishedVersionId;save();
  45. const content=structuredClone(source);
  46. delete content.scenario.sceneResource;delete content.resources;delete content.runtime.definitionSource;delete content.runtime.scriptId;delete content.runtime.scriptVersion;
  47. content.task.title='005 液压泵应急切换(编排验证)';content.task.code=prefix;content.scenario={sceneProjectId:scene.project.id,sceneVersionId:scene.project.publishedVersionId};
  48. 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'};});
  49. const browser=await chromium.launch({channel:'msedge',headless:true});
  50. let normalized;
  51. 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();}
  52. const dependencies=[{targetProjectId:scene.project.id,targetVersionId:scene.project.publishedVersionId,relationType:'TRAINING_SCENE',required:true}];
  53. let training=await create('trainingId','TRAINING',normalized,[],dependencies);
  54. if(!fixture.mediaUploaded){
  55. const assets=[];
  56. const pack=speech.scripts.find(p=>p.id==='bridge-pump-switch'),vp=videos.scripts.find(p=>p.id==='bridge-pump-switch');
  57. for(let i=0;i<normalized.steps.length;i++){
  58. const step=normalized.steps[i],clip=pack.steps[i],video=vp.steps[i];
  59. assert.equal(clip.text,video.text);
  60. for(const [kind,url,type]of [['VIDEO',video.url,'VIDEO'],['AUDIO',clip.url,'AUDIO']]){
  61. const code='PILOT-005-'+(i+1)+'-'+kind,form=new FormData();
  62. form.append('file',new Blob([fs.readFileSync(path.resolve('../unreal_tran_web/public',url.slice(1)))]),path.basename(url));
  63. for(const [k,v]of Object.entries({code,name:'步骤 '+(i+1)+' '+kind,type,metadata:JSON.stringify({source:'bridge-005-authoring-pilot'}),sortOrder:String(i),projectVersion:String(training.project.version)}))form.append(k,v);
  64. const r=await fetch('http://127.0.0.1:6180/api/tran/v1/content/projects/'+training.project.id+'/assets/upload',{method:'POST',headers:{Authorization:'Bearer '+signed.accessToken},body:form});const result=await r.json();assert.equal(r.status,200,result.message);assets.push(result.data);
  65. step.audio[kind==='VIDEO'?'narrationVideoAssetCode':'narrationAssetCode']=code;
  66. }
  67. step.audio.narrationText=clip.text;step.audio.digitalHuman={enabled:true,mode:'video',videoSource:'upload',generationStatus:'generated'};
  68. }
  69. training=await api('content/projects/'+training.project.id,{type:'TRAINING',name:training.project.name,description:training.project.description,categoryCode:'MAINTENANCE',trainingMode:'VIRTUAL',content:normalized,assets,dependencies,version:training.project.version,changeSummary:'导入 005 五步机构动作与新版数字人视频、语音'},'PUT');
  70. fixture.mediaUploaded=true;save();
  71. }
  72. training=await publish(training);fixture.trainingVersionId=training.project.publishedVersionId;save();
  73. if(!fixture.taskId){
  74. const task=await api('teaching/assignments',{code:prefix,name:'005 液压泵应急切换(编排验证)',description:'独立编排验证,不替换现有005',channel:'VIRTUAL',assignmentKind:'TRAINING',executionMode:'PRACTICE',audienceType:'COMMON',collaborationMode:'INDIVIDUAL',digitalHumanAllowed:false,contentProjectId:training.project.id,contentVersionId:training.project.publishedVersionId,members:[]});
  75. fixture.taskId=task.id;save();
  76. }
  77. // Leave the isolated assignment in DRAFT: teachers can preview without exposing
  78. // another common task to learners or touching the four published assignments.
  79. const task=await api('teaching/assignments/'+fixture.taskId);
  80. fixture.taskStatus=task.status;fixture.previewUrl='/teaching/virtual-training/preview/'+task.id;save();
  81. const before=JSON.parse(fs.readFileSync(dir+'/original-assignments.json','utf8'));
  82. for(const id of [86,87,88,89]){const after=await api('teaching/assignments/'+id);delete after.serverNow;delete before[id].serverNow;assert.deepEqual(after,before[id]);}
  83. console.log(JSON.stringify(fixture));