import fs from 'node:fs'; 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'; const report=path.resolve('reports/animation-capabilities-20260912'),file=path.join(report,'fixture.json'); const fixture=JSON.parse(fs.readFileSync(file,'utf8')),session=await login('root'); const save=()=>fs.writeFileSync(file,JSON.stringify(fixture,null,2)); const api=async(url,body,method)=>{const r=await call('tran/v1/'+url,session.accessToken,body,method);assert.equal(r.status,200,`${url}: ${r.message}`);return r.data;}; const browser=await chromium.launch({channel:'msedge',headless:true,args:['--enable-unsafe-swiftshader']}); const context=await browser.newContext({viewport:{width:1920,height:1080}}); await context.addInitScript(s=>{sessionStorage.setItem('unreal-tran:web:access-token:v1',s.accessToken);sessionStorage.setItem('unreal-tran:web:refresh-token:v1',s.refreshToken)},session); const page=await context.newPage(),checks=[],errors=[];page.on('pageerror',e=>errors.push(e.message)); try { await page.goto('http://127.0.0.1:6180/content/scenes'); if(!fixture.animationSceneId){ const content=await page.evaluate(async f=>{ const {createEmptySceneDocument,createObjectDocument}=await import('/src/features/editors/scene/presets.ts'); const scene=createEmptySceneDocument(),object=createObjectDocument('box'); scene.name='动画能力验收场景-20260912'; Object.assign(object,{id:'animation-truck',type:'model',name:'组合动作牛奶卡车',targetProjectId:f.modelId,targetVersionId:f.modelVersionId,assetCode:f.savedDocument.modelResource.assetCode,assetId:f.savedDocument.modelId}); object.semantic.modelId=f.savedDocument.modelId;object.semantic.deviceId='ANIM-TRUCK';object.semantic.interactionId='animation-truck';object.semantic.operable=true; scene.objects=[object];scene.rendering.cameraPosition=[6,4,7];scene.rendering.cameraTarget=[0,1,0]; return scene; },fixture); const created=await api('content/projects',{type:'SCENE',name:content.name,categoryCode:'WORKSHOP',content,dependencies:[{targetProjectId:fixture.modelId,targetVersionId:fixture.modelVersionId,relationType:'SCENE_MODEL',required:true}],assets:[]}); fixture.animationSceneId=created.project.id;save(); } let scene=await api(`content/projects/${fixture.animationSceneId}`); if(scene.project.status!=='PUBLISHED'&&!scene.currentVersion.content.objects[0].semantic.deviceId){ scene.currentVersion.content.objects[0].semantic.deviceId='ANIM-TRUCK'; scene=await api(`content/projects/${fixture.animationSceneId}`,{type:'SCENE',name:scene.project.name,categoryCode:'WORKSHOP',content:scene.currentVersion.content,version:scene.project.version,dependencies:scene.dependencies,assets:[]},'PUT'); } if(scene.project.status!=='PUBLISHED')await api(`content/projects/${fixture.animationSceneId}/publish`,{version:scene.project.version}); const publishedScene=await api(`content/projects/${fixture.animationSceneId}`);fixture.animationSceneVersionId=publishedScene.project.publishedVersionId;save(); if(!fixture.animationTrainingId){ const content=await page.evaluate(async({f,scene})=>{ const {createTrainingProject}=await import('/src/features/guide-legacy/demo-src/training-project-io.js'); const doc=createTrainingProject();doc.name='动画能力验收训练-20260912';doc.task.title=doc.name; const step=doc.steps[0];step.id='animation-step';step.code='animation-step';step.title='旋出、清洁与回路流向';step.targetId='animation-truck';step.targetName='组合动作牛奶卡车';step.duration=4; step.animationBinding={assetId:`animation-${f.modelId}@${f.modelVersionId}-work`,modelAssetId:`${f.modelId}@${f.modelVersionId}`,clipName:'旋出与清洁演示',sourceStart:0,finish:'hold',duration:4}; doc.steps=[step,{...structuredClone(step),id:'reset-step',code:'reset-step',title:'播放后回到起点',animationBinding:{...step.animationBinding,clipName:'阀门复位演示',finish:'reset'}}]; doc.steps.forEach(step=>{step.stepCode=step.code;step.actionCode='CHECK-'+step.code;}); doc.scenario.sceneResource={id:`${f.animationSceneId}@${f.animationSceneVersionId}`,projectId:f.animationSceneId,versionId:f.animationSceneVersionId,name:scene.name,snapshot:scene}; doc.scenario.visibleParts=['animation-truck'];doc.scenario.operableParts=['animation-truck']; doc.resources.models=[{id:`${f.modelId}@${f.modelVersionId}`,modelId:f.savedDocument.modelId,targetProjectId:f.modelId,targetVersionId:f.modelVersionId,timeline:f.savedDocument.timeline}]; return doc; },{f:fixture,scene:publishedScene.currentVersion.content}); const created=await api('content/projects',{type:'TRAINING',name:content.name,categoryCode:'MAINTENANCE',trainingMode:'VIRTUAL',content,dependencies:[{targetProjectId:fixture.animationSceneId,targetVersionId:fixture.animationSceneVersionId,relationType:'TRAINING_SCENE',required:true}],assets:[]}); fixture.animationTrainingId=created.project.id;save(); } let training=await api(`content/projects/${fixture.animationTrainingId}`); if(training.project.status!=='PUBLISHED'&&training.currentVersion.content.steps[0].actionCode==='CHECK'){ training.currentVersion.content.steps.forEach(step=>{step.stepCode=step.code;step.actionCode='CHECK-'+step.code;}); training=await api(`content/projects/${fixture.animationTrainingId}`,{type:'TRAINING',name:training.project.name,categoryCode:'MAINTENANCE',trainingMode:'VIRTUAL',content:training.currentVersion.content,version:training.project.version,dependencies:training.dependencies,assets:[]},'PUT'); } if(training.project.status!=='PUBLISHED')await api(`content/projects/${fixture.animationTrainingId}/publish`,{version:training.project.version}); const published=await api(`content/projects/${fixture.animationTrainingId}`);fixture.animationTrainingVersionId=published.project.publishedVersionId;save(); if(!fixture.animationAssignmentId){ const a=await api('teaching/assignments',{code:'ANIM-CAP-'+Date.now(),name:'动画能力验收任务-20260912',description:'开发环境动画播放验证',channel:'VIRTUAL',assignmentKind:'TRAINING',executionMode:'DEMO',audienceType:'COMMON',collaborationMode:'INDIVIDUAL',digitalHumanAllowed:false,contentProjectId:fixture.animationTrainingId,contentVersionId:fixture.animationTrainingVersionId,members:[]}); fixture.animationAssignmentId=a.id;save(); } const assignment=await api(`teaching/assignments/${fixture.animationAssignmentId}`); fixture.animationAssignmentContentVersionId=assignment.contentVersionId;save(); const object=assignment.definitionSnapshot.teachingScene.objects.find(o=>o.targetProjectId===fixture.modelId); assert.equal(object.modelTimeline.clips.length,2);assert.equal(object.modelTimeline.tracks.length,4);assert.ok(Object.keys(object.modelOverrides).length>0); assert.equal(object.targetVersionId,fixture.modelVersionId); checks.push({name:'真实教学任务固定模型版本、动作、材质、枢轴和流向配置',pass:true,modelVersionId:object.targetVersionId,assignmentId:assignment.id}); await page.goto(`http://127.0.0.1:6180/teaching/virtual-training/preview/${assignment.id}`); await expect(page.locator('.teaching-three-scene canvas')).toBeVisible({timeout:60000}); await page.getByRole('button',{name:'适应',exact:true}).click(); await page.waitForTimeout(6500); // Inspect the real component's runtime, not a second test renderer. const inspect=()=>page.evaluate(()=>{ const host=document.querySelector('.teaching-three-scene');let instance=host?.__vueParentComponent; while(instance&&!instance.type.__name?.includes('TeachingRunScene'))instance=instance.parent; const state=instance?.devtoolsRawSetupState; if(!state)throw new Error('Teaching scene setup unavailable'); const entry=state.timelineRuntimes[0],runtime=entry?.runtime; const wheel=runtime?.bindings.find(b=>b.track.id==='move')?.object; const material=runtime?.materials.values().next().value; const mesh=runtime?.materials.keys().next().value; return {status:state.sceneStatus?.value || state.sceneStatus,time:runtime?.time,bindings:runtime?.bindings.length,unresolved:runtime?.unresolved, wheel:wheel?.position.toArray(),base:wheel&&runtime.bases.get(wheel).position,roughness:material?.clones[0].roughness, actualRoughness:mesh?.material.roughness,flowVisible:runtime?.flows[0]?.dots.some(dot=>dot.visible),step:state.props.currentStepCode}; }); const state=await inspect();assert.doesNotMatch(String(state.status),/失败/); assert.equal(state.bindings,4);assert.equal(state.unresolved,0);assert.equal(state.time,4);assert.equal(state.roughness,.2);assert.equal(state.actualRoughness,.2);assert.equal(state.flowVisible,true);assert.notDeepEqual(state.wheel,state.base); await page.screenshot({path:path.join(report,'09-teaching-animation.png')}); checks.push({name:'正式教学预览实际播放指定片段并保持末帧,四条轨道全部绑定',pass:true,detail:state}); await page.getByRole('button',{name:'引导完成',exact:true}).click(); await page.getByRole('button',{name:'下一步',exact:true}).click(); await page.waitForTimeout(6500); const reset=await inspect();assert.equal(reset.step,'reset-step');assert.equal(reset.time,0);assert.equal(reset.roughness,.95);assert.equal(reset.flowVisible,false);assert.deepEqual(reset.wheel,reset.base); await page.screenshot({path:path.join(report,'10-teaching-reset.png')}); checks.push({name:'切换步骤后播放复位片段,结束恢复起点并关闭流向',pass:true,detail:reset}); const native=await page.evaluate(async()=>{ let instance=document.querySelector('.teaching-three-scene').__vueParentComponent; while(!instance.type.__name?.includes('TeachingRunScene'))instance=instance.parent; const s=instance.devtoolsRawSetupState,entry=s.nativePlayers[0]; if(!entry?.clips.length)throw new Error('Fixture native GLB clip missing'); const clip=entry.clips[0],step=s.definitionSteps().find(step=>s.stepCodeOf(step)===s.props.currentStepCode); const binding=step.animationBinding,previous=binding.clipName; binding.clipName=clip.name;s.syncStepTimelines(); await new Promise(resolve=>setTimeout(resolve,900)); const time=entry.mixer.clipAction(clip).time; binding.clipName=previous;s.syncStepTimelines(); return {name:clip.name,time}; }); assert.ok(native.time>0);checks.push({name:'同一教学视口原生 GLB 动画与自编片段切换兼容(浏览器运行时检查)',pass:true,detail:native}); fs.writeFileSync(path.join(report,'teaching-inspection.json'),JSON.stringify(state,null,2)); assert.equal(errors.length,0,errors.join('\n')); }catch(error){checks.push({name:'教学动画端到端',pass:false,error:error.message});process.exitCode=1;console.error(error.message);await page.screenshot({path:path.join(report,'teaching-failure.png')}).catch(()=>{});} finally{fs.writeFileSync(path.join(report,'teaching-checks.json'),JSON.stringify({checks,errors},null,2));await browser.close();}