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 {scriptTrainingDefinition} from '../../unreal_tran_web/src/features/virtual-training-scripts/registry.js'; import {collectActions,applyActionBindings} from '../../unreal_tran_web/src/features/content-production/document.js'; const dir='reports/bridge-content-authoring-20260924';fs.mkdirSync(dir,{recursive:true}); const session=await login('root'); const api=async(ep,body,method)=>{const r=await call('tran/v1/'+ep,session.accessToken,body,method);assert.equal(r.status,200,ep+': '+r.message);return r.data;}; const write=(name,value)=>fs.writeFileSync(`${dir}/${name}.json`,JSON.stringify(value,null,2)); const read=name=>JSON.parse(fs.readFileSync(`${dir}/${name}.json`,'utf8')); const backup=(name,value)=>{if(!fs.existsSync(`${dir}/${name}.json`))write(name,value);}; const fixture=fs.existsSync(`${dir}/fixture.json`)?read('fixture'):{items:[]}; const stripVolatile=t=>{const d=structuredClone(t);delete d.serverNow;return d;}; const bytesAsset=(object,index)=>{const bytes=fs.readFileSync(path.resolve('../unreal_tran_web/public',object.resourceUrl.slice(1)));return {code:`PRODUCTION-MODEL-${index+1}`,name:object.sourceName||object.name,type:'MODEL_FILE',storageUri:object.resourceUrl,mimeType:'model/gltf-binary',sizeBytes:bytes.length,sha256:crypto.createHash('sha256').update(bytes).digest('hex'),metadata:{builtin:true},status:'READY',sortOrder:index};}; const browser=await chromium.launch({channel:'msedge',headless:true,args:['--enable-unsafe-swiftshader']}); try{ for(const taskId of [86,87,88,89]){ const task=await api('teaching/assignments/'+taskId);backup('before-task-'+taskId,task); const original=await api('content/projects/'+task.contentProjectId);backup('before-project-'+task.contentProjectId,original); let item=fixture.items.find(i=>i.taskId===String(taskId)); if(item?.complete){console.log('Already prepared',item.taskCode);continue;} const definition=scriptTrainingDefinition(task); if(taskId===86){const pilot=await api('teaching/assignments/116');backup('before-task-116',pilot);definition.scenario.sceneResource=structuredClone(pilot.definitionSnapshot.scenario.sceneResource);delete definition.runtime.definitionSource;delete definition.runtime.scriptId;delete definition.runtime.scriptVersion;} definition.name=task.name;definition.task.title=task.name;definition.introduction=structuredClone(task.definitionSnapshot.introduction); for(const step of definition.steps)step.interaction||='rule-check'; let actions=collectActions(definition); const page=await browser.newPage({viewport:{width:1500,height:1000}}); await page.goto('http://127.0.0.1:6180/'); const captured=await page.evaluate(async({definition,actions})=>{ const host=document.createElement('div');host.style.cssText='position:fixed;inset:0;background:white;z-index:100000';document.body.append(host); const {createProductionPreview}=await import('/src/features/content-production/preview.js'); const viewer=await createProductionPreview(host,definition); try{return {actions:viewer.configure(actions)};}finally{viewer.destroy();host.remove();} },{definition,actions}); actions=captured.actions;await page.close(); assert.equal(actions.length,definition.steps.reduce((n,s)=>n+s.action.mechanism.steps.length,0)); assert.ok(actions.every(a=>a.binding?.sourceNode&&a.motion)); write('actions-'+task.code,actions); const snapshot=structuredClone(definition.scenario.sceneResource.snapshot); const assets=snapshot.objects.map(bytesAsset); const resource=snapshot.objects.find(o=>o.assetRole!=='environment'&&!/地形|terrain/i.test(o.sourceName));assert.ok(resource); const primaryAsset=assets[snapshot.objects.indexOf(resource)]; const production={schemaVersion:1,taskCode:task.code,taskId:String(taskId),trainingProjectId:task.contentProjectId, sourceContentVersionId:task.contentVersionId,scriptId:task.definitionSnapshot.runtime.scriptId,scriptVersion:task.definitionSnapshot.runtime.scriptVersion}; const modelName=task.name+'机构模型',sceneName=task.name+'训练场景'; if(!item){item={taskId:String(taskId),taskCode:task.code,trainingProjectId:task.contentProjectId};fixture.items.push(item);write('fixture',fixture);} const modelContent={schema:'digital-twin-editor-project',version:4,name:modelName,modelId:resource.modelId||resource.id, modelResource:{...structuredClone(resource),id:resource.modelId||resource.id,assetCode:primaryAsset.code,storageUri:primaryAsset.storageUri,kind:'builtin'}, camera:{position:[4,3,5],target:[0,1,0]},model:null,environment:null, timeline:{version:1,duration:Math.max(1,actions.length),currentTime:0,loop:false,zoom:1,tracks:[],clips:[]},blueprint:{version:1,nodes:[],edges:[]}, production:{...production},productionDefinition:definition,productionActions:actions,assemblyObjects:snapshot.objects}; const getOrCreate=async(type,name,content,projectId)=>{ if(projectId)return api('content/projects/'+projectId); const list=await api(`content/projects?type=${type}&keyword=${encodeURIComponent(name)}&size=100`); const found=list.records.find(p=>p.name===name);if(found)return api('content/projects/'+found.id); return api('content/projects',{type,name,description:'重型支援桥装备训练内容',categoryCode:type==='MODEL'?'EQUIPMENT':'OUTDOOR',coverUri:task.coverUri||'/models/step-model-front-pump-thumb.png',content,assets,dependencies:[]}); }; let model=await getOrCreate('MODEL',modelName,modelContent,item.modelProjectId);item.modelProjectId=model.project.id;write('fixture',fixture); let scene=await getOrCreate('SCENE',sceneName,{...snapshot,schema:'edit3dv4.scene',version:'1.5',name:sceneName},item.sceneProjectId);item.sceneProjectId=scene.project.id;write('fixture',fixture); Object.assign(production,{modelProjectId:model.project.id,sceneProjectId:scene.project.id}); modelContent.production={...production}; model=await api('content/projects/'+model.project.id,{...model.project,name:modelName,content:modelContent,assets,dependencies:[],version:model.project.version,changeSummary:'建立部件动作库、旋转轴心与场景制作关联'},'PUT'); model=await api('content/projects/'+model.project.id+'/publish',{version:model.project.version}); item.modelVersionId=model.project.publishedVersionId;production.modelVersionId=item.modelVersionId; const sceneObjects=snapshot.objects.map((o,i)=>({...o,assetCode:assets[i].code,targetProjectId:model.project.id,targetVersionId:item.modelVersionId})); const sceneContent={...snapshot,schema:'edit3dv4.scene',version:'1.5',name:sceneName,objects:sceneObjects, production:{...production},productionDefinition:structuredClone(definition),productionActions:actions}; sceneContent.productionDefinition.scenario.sceneResource.snapshot.objects=structuredClone(sceneObjects); scene=await api('content/projects/'+scene.project.id,{...scene.project,name:sceneName,content:sceneContent,assets, dependencies:[{targetProjectId:model.project.id,targetVersionId:item.modelVersionId,relationType:'SCENE_MODEL',required:true}],version:scene.project.version,changeSummary:'通过模型固定版本组成训练场景'},'PUT'); scene=await api('content/projects/'+scene.project.id+'/publish',{version:scene.project.version}); item.sceneVersionId=scene.project.publishedVersionId;production.sceneVersionId=item.sceneVersionId; const content=structuredClone(original.currentVersion.content); Object.assign(content,{production:{...production},productionDefinition:applyActionBindings(definition,actions,model.project.id,item.modelVersionId),productionActions:actions}); content.steps=content.steps.map((s,i)=>({...s,modelActions:content.productionDefinition.steps[i].modelActions})); content.scenario.sceneProjectId=scene.project.id;content.scenario.sceneVersionId=item.sceneVersionId; let training=await api('content/projects/'+original.project.id,{...original.project,description:'重型支援桥装备操作训练',content,assets:original.currentVersion.assets, dependencies:[{targetProjectId:scene.project.id,targetVersionId:item.sceneVersionId,relationType:'TRAINING_SCENE',required:true}],version:original.project.version,changeSummary:'关联模型动作和场景固定版本,保持现有教学任务不变'},'PUT'); training=await api('content/projects/'+training.project.id+'/publish',{version:training.project.version}); item.trainingVersionId=training.project.publishedVersionId;item.steps=definition.steps.length;item.actions=actions.length;item.complete=true;write('fixture',fixture); for(const [stage,data] of [['model',model],['scene',scene],['training',training]])write('after-'+stage+'-'+task.code,data); assert.deepEqual(stripVolatile(await api('teaching/assignments/'+taskId)),stripVolatile(read('before-task-'+taskId))); console.log(JSON.stringify(item)); } for(const id of [86,87,88,89,116])assert.deepEqual(stripVolatile(await api('teaching/assignments/'+id)),stripVolatile(read('before-task-'+id))); write('registration',{pass:true,items:fixture.items,unchangedAssignments:[86,87,88,89,116],checkedAt:new Date().toISOString()}); }finally{await browser.close();}