import fs from 'node:fs'; import path from 'node:path'; import crypto from 'node:crypto'; 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/virtual-html-workspace-20260912');fs.mkdirSync(report,{recursive:true}); const checks=[],errors=[],admin=await login('root'); const api=async(endpoint,session=admin,body,method)=>{const r=await call(endpoint,session?.accessToken,body,method);assert.equal(r.status,200,`${endpoint}: ${r.message}`);return r.data;}; const checked=(name,details)=>{checks.push({name,pass:true,details});console.log('PASS',name);}; const browser=await chromium.launch({channel:'msedge',headless:true,args:['--enable-unsafe-swiftshader']}); const createContext=async(session,width=1920)=>{const ctx=await browser.newContext({viewport:{width,height:1080}});await ctx.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'})); },session);return ctx;}; const context=await createContext(admin),page=await context.newPage();page.on('pageerror',e=>errors.push(e.message)); const diagnostic=p=>p.evaluate(()=>globalThis.__trainingSceneDiagnostics?.()); const open=async(p,url)=>{await p.goto('http://127.0.0.1:6180'+url);await p.locator('.training-shell').waitFor({timeout:120000});await expect.poll(async()=>Boolean((await diagnostic(p))?.publishedContentMounted),{timeout:90000}).toBe(true);}; const screenshot=async(p,name)=>{await p.screenshot({path:path.join(report,name+'.png'),fullPage:true});}; let studentPage; try { await open(page,'/teaching/virtual-training/preview/54'); await expect(page.locator('[data-role="model-commandbar"]')).toBeVisible(); for(const action of ['toggle-structure-tree','focus-selected-part','viewer-reset-view','toggle-part-labels','toggle-issue-highlight','toggle-hide-others','toggle-explosion'])await expect(page.locator(`[data-role="model-commandbar"] [data-action="${action}"]`)).toBeVisible(); await page.locator('[data-role="model-commandbar"] [data-action="toggle-structure-tree"]').click(); const tree=page.locator('[data-role="structure-tree"]');await expect(tree).toHaveClass(/is-open/); assert.ok((await tree.locator('[data-action="select-structure-part"]').count())>20); await tree.locator('[data-action="select-structure-part"]').first().click(); await page.locator('[data-action="focus-selected-part"]').click(); await page.locator('[data-role="model-commandbar"] [data-action="toggle-structure-tree"]').click(); await page.locator('[data-action="toggle-issue-highlight"]').click(); await expect.poll(async()=>(await diagnostic(page)).framing.keptTargets.length).toBeGreaterThan(0); await page.locator('[data-action="toggle-hide-others"]').click(); assert.equal((await diagnostic(page)).framing.hideOthers,true); await page.locator('[data-action="toggle-hide-others"]').click(); await page.locator('[data-action="toggle-part-labels"]').click(); assert.equal((await diagnostic(page)).framing.labelsVisible,true); await screenshot(page,'01-bridge-tools'); await page.locator('[data-action="toggle-explosion"]').click(); await screenshot(page,'02-bridge-explosion'); await page.locator('[data-action="viewer-reset-view"]').click(); await expect(page.locator('[data-role="teacher-image"]')).toBeVisible(); const bridge=await diagnostic(page);assert.equal(bridge.proceduralMachineVisible,false);assert.ok(bridge.server.registeredTargets.includes(bridge.framing.targetOnScreen.targetId)); checked('桥梁:完整结构树、聚焦、高亮、隐藏、标签、爆炸、复位和数字教员',bridge.framing); const suffix=Date.now().toString(36),password='Vh!'+crypto.randomBytes(8).toString('hex'); const roles=await api('auth/v1/roles?size=100');const role=(roles.records||roles).find(r=>r.code==='student'); const user=await api('auth/v1/users',admin,{username:'vh_'+suffix,displayName:'HTML验收-'+suffix,departmentId:'5',roleIds:[role.id],defaultRoleId:role.id,enabled:true,password,remark:'JS/HTML正式工作区隔离测试账号'}); const learner=await api('auth/v1/auth/login',null,{userId:(user.user||user).id,roleCode:'student',password,rememberMe:false}); const source=await api('tran/v1/content/projects/419'); source.currentVersion.content.steps.forEach((step,index)=>{ step.mode=index===0?'measure':'remove';step.interaction=index===0?'rule-check':'spatial-drag';step.targetNodeId=step.targetId; step.destination=index===0?'压力测量检查':'编号存放区'; step.action={...step.action,kind:step.interaction,mode:step.mode,targetId:step.targetId,requiresPlacement:index===1,tolerance:.55}; }); const content=await api('tran/v1/content/projects',admin,{type:'TRAINING',name:'HTML闭环验收-'+suffix,categoryCode:'MAINTENANCE',trainingMode:'VIRTUAL',content:source.currentVersion.content,dependencies:source.currentVersion.dependencies,assets:[]}); await api(`tran/v1/content/projects/${content.project.id}/publish`,admin,{version:content.project.version}); const reference=(await api(`tran/v1/content/projects/${content.project.id}`)).project; const task=await api('tran/v1/teaching/assignments',admin,{code:'HTML-'+suffix,name:'HTML工作区-卡车动画闭环-'+suffix,description:'开发环境真实操作、断点恢复和动画验收',channel:'VIRTUAL',assignmentKind:'TRAINING',executionMode:'PRACTICE',audienceType:'ASSIGNED',collaborationMode:'INDIVIDUAL',digitalHumanAllowed:true,contentProjectId:reference.id,contentVersionId:reference.publishedVersionId,members:[{userId:(user.user||user).id,memberType:'LEARNER',teamCode:'NEUTRAL',positionCode:'LEARNER',sortOrder:0}]}); const published=await api(`tran/v1/teaching/assignments/${task.id}/publish`,admin,{version:task.version}); const run=await api(`tran/v1/teaching/assignments/${task.id}/accept`,learner,{assignmentVersion:published.version,teamCode:'NEUTRAL',positionCode:'LEARNER'}); fs.writeFileSync(path.join(report,'fixture.json'),JSON.stringify({userId:(user.user||user).id,assignmentId:task.id,runId:run.id},null,2)); const ctx=await createContext(learner);studentPage=await ctx.newPage();studentPage.on('pageerror',e=>errors.push(e.message)); await open(studentPage,`/teaching/virtual-training/runs/${run.id}`); await studentPage.waitForTimeout(5200); const animation=(await diagnostic(studentPage)).server.animations.find(a=>a.bindings); assert.equal(animation.bindings,4);assert.equal(animation.unresolved,0);assert.equal(animation.time,4);assert.equal(animation.flowVisible,true); checked('卡车:正式工作区播放四种已编排动画轨道并保持末帧',animation); const prepare=async()=>{ const panel=studentPage.locator('.training-server-preparation');if(!(await panel.evaluate(p=>p.open)))await panel.locator('summary').click(); await panel.locator('[data-tool="pressure-gauge"]').check();for(const checkbox of await panel.locator('[data-safety]').all())await checkbox.check(); await panel.locator('textarea').fill('测试测量:压力为零,检查已完成。'); await panel.locator('[data-save-preparation]').click();await expect(panel.locator('[data-save-status]')).toHaveText('已保存'); }; await prepare(); await studentPage.reload();await expect.poll(async()=>Boolean((await diagnostic(studentPage))?.publishedContentMounted),{timeout:90000}).toBe(true); await studentPage.locator('.training-server-preparation summary').click(); await expect(studentPage.locator('[data-tool="pressure-gauge"]')).toBeChecked();await expect(studentPage.locator('[data-measurement]')).toHaveValue('测试测量:压力为零,检查已完成。'); await screenshot(studentPage,'03-learner-preparation-restored'); checked('学员:工具、安全、测量记录通过真实接口保存并刷新恢复',{runId:run.id}); await studentPage.locator('.training-server-preparation summary').click(); const clickTarget=async()=>{ await studentPage.locator('[data-action="toggle-issue-highlight"]').click(); await studentPage.waitForTimeout(500); const d=await diagnostic(studentPage),rect=await studentPage.locator('canvas.training-canvas').boundingBox(); assert.ok(d.framing.targetOnScreen?.insideCanvas,'Target must be on screen'); await studentPage.mouse.click(rect.x+d.framing.targetOnScreen.localX,rect.y+d.framing.targetOnScreen.localY+38); }; // Lose one progress response before it reaches the API; ensure no local advance. let rejected=false; await studentPage.route('**/api/tran/v1/teaching/runs/*/progress',async route=>{if(!rejected){rejected=true;await route.abort('failed');}else await route.continue();}); await clickTarget();await studentPage.waitForTimeout(1500); assert.equal(rejected,true,'An actual progress request must have been attempted'); assert.equal((await api(`tran/v1/teaching/runs/${run.id}`,learner)).progressPercent,0); assert.equal((await diagnostic(studentPage)).server.completed.length,0); checked('进度请求失败时不在本地提前完成步骤'); await clickTarget(); await expect.poll(async()=>(await api(`tran/v1/teaching/runs/${run.id}`,learner)).progressPercent,{timeout:20000}).toBe(50); await expect(studentPage.locator('[data-action="next"]')).toBeEnabled(); await studentPage.locator('[data-action="next"]').click();await studentPage.waitForTimeout(5200); const reset=(await diagnostic(studentPage)).server.animations.find(a=>a.bindings);assert.equal(reset.time,0);assert.equal(reset.flowVisible,false); checked('步骤完成服务器确认后推进,下一步动画按发布片段复位',reset); await prepare();await studentPage.locator('.training-server-preparation summary').click(); const dragState=await diagnostic(studentPage),canvasRect=await studentPage.locator('canvas.training-canvas').boundingBox(),drop=await studentPage.locator('[data-role="dropzone"]').boundingBox(); await studentPage.mouse.move(canvasRect.x+dragState.framing.targetOnScreen.localX,canvasRect.y+dragState.framing.targetOnScreen.localY+38); await studentPage.mouse.down();await studentPage.mouse.move(drop.x+drop.width/2,drop.y+drop.height/2,{steps:25});await studentPage.mouse.up(); await expect.poll(async()=>(await api(`tran/v1/teaching/runs/${run.id}`,learner)).progressPercent,{timeout:20000}).toBe(100); await studentPage.locator('[data-action="next"]').click(); await expect.poll(async()=>(await api(`tran/v1/teaching/runs/${run.id}`,learner)).status,{timeout:20000}).toBe('SUBMITTED'); await screenshot(studentPage,'04-submitted');checked('真实两步操作完成、服务端判定、提交结果闭环',{runId:run.id}); await open(page,`/teaching/virtual-training/runs/${run.id}`);assert.equal((await diagnostic(page)).server.readOnly,true);await screenshot(page,'05-observer'); checked('维护账号观察他人运行时只读,不修改学员进度'); assert.equal(errors.length,0,errors.join('\n')); } catch(error) {checks.push({name:'HTML工作区端到端',pass:false,error:error.message});console.error(error.message);process.exitCode=1;await screenshot(studentPage||page,'failure').catch(()=>{});} finally {fs.writeFileSync(path.join(report,'checks.json'),JSON.stringify({checks,errors},null,2));await browser.close();}