import {expect, test} from '@playwright/test'; import {readFileSync} from 'node:fs'; import {fileURLToPath} from 'node:url'; import {zipSync} from 'fflate'; const fixture = (relative: string) => fileURLToPath(new URL(`../fixtures/${relative}`, import.meta.url)); const SIMPLE_MODEL = ` `; const SLIDE_DIRECTION_MODEL=``; const LARGE_MODEL = ` `; test('显示中文平台骨架并加载单文件模型', async ({page}) => { page.on('console', (message) => console.log(`[browser:${message.type()}] ${message.text()}`)); page.on('pageerror', (error) => console.log(`[browser:error] ${error.message}`)); page.on('requestfailed', (request) => console.log(`[browser:requestfailed] ${request.url()} ${request.failure()?.errorText}`)); page.on('response', (response) => { if (response.status() >= 400 || response.url().endsWith('.wasm')) console.log(`[browser:response] ${response.status()} ${response.url()} ${response.headers()['content-type'] ?? ''}`); }); await page.goto('/'); await page.setViewportSize({width:1024,height:768}); const resetCameraBox=await page.getByRole('button',{name:'相机复位'}).boundingBox(),playBox=await page.getByRole('button',{name:'▶ 播放'}).boundingBox(); expect(resetCameraBox&&playBox&&resetCameraBox.x+resetCameraBox.width<=playBox.x).toBeTruthy(); await page.getByRole('button',{name:'更多工作台操作'}).click();await expect(page.getByRole('menuitem',{name:'工作台设置'})).toBeVisible();await page.keyboard.press('Escape'); await page.setViewportSize({width:1440,height:900}); await expect(page.getByRole('heading', {name: 'MuJoCo Web 仿真平台'})).toBeVisible(); await expect(page.getByRole('main').getByText('拖放模型工程到此处')).toBeVisible(); await expect(page.getByRole('img',{name:'XYZ 方向指示器'})).toBeVisible(); await expect(page.getByRole('button',{name:'切换到白天主题'})).toBeVisible(); await page.getByRole('button',{name:'布局设置'}).click();await expect(page.getByRole('dialog',{name:'布局设置'})).toBeVisible();await page.keyboard.press('Escape'); await page.getByRole('button',{name:'工作台设置'}).click();await expect(page.getByRole('dialog',{name:'工作台设置'})).toBeVisible();await page.keyboard.press('Escape'); await page.keyboard.press('Control+k'); await expect(page.getByRole('dialog',{name:'命令面板'})).toBeVisible(); await page.getByLabel('搜索命令').fill('复位相机'); await expect(page.getByRole('option',{name:/复位相机/})).toBeVisible(); await page.keyboard.press('Escape'); await page.getByRole('button',{name:'进入全屏'}).click(); await expect(page.getByRole('button',{name:'退出全屏'})).toBeVisible(); await page.keyboard.press('Control+k'); await expect(page.getByRole('dialog',{name:'命令面板'})).toBeVisible(); await page.keyboard.press('Escape'); await page.getByRole('button',{name:'退出全屏'}).click(); await page.getByRole('button',{name:'切换到白天主题'}).click(); await expect(page.locator('#root > div')).toHaveClass(/theme-light/); await expect(page.getByRole('button',{name:'切换到黑夜主题'})).toBeVisible(); await page.getByRole('button',{name:'切换到黑夜主题'}).click(); await expect(page.locator('#root > div')).toHaveClass(/theme-dark/); await page.locator('input[type="file"]').first().setInputFiles({ name: 'model.xml', mimeType: 'text/xml', buffer: Buffer.from(SIMPLE_MODEL), }); await expect(page.getByText('WASM 已加载')).toBeVisible({timeout: 30_000}); await page.getByRole('button',{name:'显示设置'}).click(); const displayDialog=page.getByRole('dialog',{name:'视图显示设置'});await expect(displayDialog).toBeVisible();await expect(displayDialog.getByRole('switch')).toHaveCount(7); const centerOfMassSwitch=displayDialog.getByRole('switch',{name:/^质心/});await centerOfMassSwitch.click();await expect(centerOfMassSwitch).toHaveAttribute('aria-checked','true');await page.keyboard.press('Escape');await expect(displayDialog).toBeHidden(); await page.getByRole('button',{name:'通知中心'}).click();await expect(page.getByRole('dialog',{name:'通知中心'})).toContainText('模型加载完成');await page.getByText('事件日志').click();await expect(page.getByRole('dialog',{name:'诊断与事件日志'})).toBeVisible();await page.keyboard.press('Escape'); await page.getByRole('button',{name:/FPS .*物理/}).click(); await expect(page.getByRole('dialog',{name:'性能详情'})).toBeVisible(); await page.keyboard.press('Escape'); await page.getByRole('tab',{name:'控制'}).click(); await page.getByRole('button',{name:'Actuator'}).click(); await expect(page.getByText('motor',{exact:true})).toBeVisible(); await expect(page.getByRole('tabpanel',{name:'控制'}).getByText('slide',{exact:true})).toBeVisible(); await page.getByRole('tab',{name:'模型结构'}).click(); const structure=page.getByRole('navigation',{name:'模型结构树'});await expect(structure).toBeVisible();await structure.getByRole('treeitem',{name:/hinge/}).hover(); await expect(page.getByRole('alert')).toHaveCount(0); await expect(page.getByRole('button',{name:'重置关节'})).toBeVisible(); await page.getByRole('button',{name:'高级'}).click(); await expect(page.getByText('下限 -1.571 rad')).toBeVisible(); await expect(page.getByText('上限 1.571 rad')).toBeVisible(); await page.getByRole('button',{name:'rad 弧度制'}).click(); await expect(page.getByText('下限 -90.000°')).toBeVisible(); await expect(page.getByText('上限 90.000°')).toBeVisible(); await page.getByRole('button',{name:'忽略关节限位'}).click(); await expect(page.getByRole('button',{name:'忽略关节限位'})).toHaveAttribute('aria-pressed','true'); await expect(page.getByText('已忽略').first()).toBeVisible(); await page.getByRole('button',{name:'重置关节'}).click(); await expect(page.getByRole('button',{name:'▶ 播放'})).toBeVisible(); }); test('窄视口默认保留完整视口并可按需打开侧栏',async({page})=>{ await page.setViewportSize({width:800,height:700}); await page.goto('/'); await expect(page.getByRole('main')).toBeInViewport(); await expect(page.getByRole('button',{name:'显示工程面板'})).toBeVisible(); await expect(page.getByRole('button',{name:'显示属性面板'})).toBeVisible(); await page.getByRole('button',{name:'显示属性面板'}).click(); await expect(page.getByRole('complementary').filter({hasText:'导入模型后显示属性'})).toBeVisible(); }); test('工作区布局与视口显示偏好在刷新后保留',async({page})=>{ await page.setViewportSize({width:1440,height:900}); await page.goto('/'); await page.getByRole('button',{name:'隐藏工程面板'}).click(); await page.getByRole('button',{name:'显示设置'}).click(); await page.getByRole('switch',{name:/^坐标系/}).click(); await page.reload(); await expect(page.getByRole('button',{name:'显示工程面板'})).toBeVisible(); await page.getByRole('button',{name:'显示设置'}).click(); await expect(page.getByRole('switch',{name:/^坐标系/})).toHaveAttribute('aria-checked','true'); }); test('转换后的 MJCF 可编辑并重新载入', async ({page}) => { await page.goto('/'); await page.locator('input[type="file"]').first().setInputFiles({name:'model.xml',mimeType:'text/xml',buffer:Buffer.from(SIMPLE_MODEL)}); await expect(page.getByText('WASM 已加载')).toBeVisible({timeout:30_000}); await page.getByRole('button',{name:'源代码'}).click(); const dialog=page.getByRole('dialog',{name:'转换后的 MJCF 编辑器'});await expect(dialog).toBeVisible(); await expect(dialog.getByText('缓存文件 · 可编辑')).toBeVisible(); await expect(dialog.getByText('转换后的 MJCF',{exact:true})).toBeVisible(); const editor=dialog.locator('.monaco-editor');await editor.click({position:{x:240,y:120}}); await page.keyboard.press('Control+a');await page.keyboard.insertText(SIMPLE_MODEL.replace('model="e2e"','model="cached-edit"')); await dialog.getByRole('button',{name:'保存并重新载入',exact:true}).click(); await expect(dialog.getByRole('button',{name:'保存并重新载入',exact:true})).toBeDisabled({timeout:30_000}); await dialog.getByRole('button',{name:'关闭源代码编辑器'}).click(); await expect(page.getByText('WASM 已加载')).toBeVisible({timeout:30_000}); await expect(page.getByRole('button',{name:'导出 URDF'})).toHaveCount(0); await expect(page.getByRole('button',{name:'导出 MJCF'})).toHaveCount(0); }); test('关闭已修改的 MJCF 前要求确认',async({page})=>{ await page.goto('/'); await page.locator('input[type="file"]').first().setInputFiles({name:'model.xml',mimeType:'text/xml',buffer:Buffer.from(SIMPLE_MODEL)}); await expect(page.getByText('WASM 已加载')).toBeVisible({timeout:30_000}); await page.getByRole('button',{name:'源代码'}).click(); const editorDialog=page.getByRole('dialog',{name:'转换后的 MJCF 编辑器'}); await editorDialog.locator('.monaco-editor').click({position:{x:240,y:120}}); await page.keyboard.press('Control+End');await page.keyboard.insertText('\n'); await editorDialog.getByRole('button',{name:'关闭源代码编辑器'}).click(); const confirm=page.getByRole('dialog',{name:'放弃未保存的修改?'}); await expect(confirm).toBeVisible(); await confirm.getByRole('button',{name:'继续编辑'}).click(); await expect(editorDialog).toBeVisible(); await editorDialog.getByRole('button',{name:'关闭源代码编辑器'}).click(); await page.getByRole('dialog',{name:'放弃未保存的修改?'}).getByRole('button',{name:'放弃修改'}).click(); await expect(editorDialog).toHaveCount(0); }); test('加载包含 include、OBJ、STL 与 PNG 的工程', async ({page}) => { await page.goto('/'); await page.locator('input[type="file"]').first().setInputFiles([ fixture('mjcf_include/model.xml'), fixture('mjcf_include/world.xml'), fixture('mjcf_include/triangle.obj'), fixture('mjcf_include/triangle.stl'), fixture('mjcf_include/checker.png'), ]); await expect(page.getByText('WASM 已加载')).toBeVisible({timeout: 30_000}); await expect(page.getByText('5 个文件')).toBeVisible(); }); test('加载引用 OBJ 的 URDF 工程', async ({page}) => { await page.goto('/'); await page.locator('input[type="file"]').first().setInputFiles([ fixture('urdf_mesh/robot.urdf'), fixture('urdf_mesh/triangle.obj'), ]); const options=page.getByRole('dialog',{name:'配置 URDF 仿真组件'}); await expect(options.getByRole('checkbox',{name:/为关节添加驱动器/})).toBeChecked(); await expect(options.getByRole('checkbox',{name:/添加传感器/})).toBeChecked(); await options.getByRole('button',{name:'转换并加载'}).click(); await expect(page.getByText('WASM 已加载')).toBeVisible({timeout: 30_000}); await expect(page.getByText('2 个文件')).toBeVisible(); await page.getByRole('button',{name:'URDF 处理方式'}).click(); await expect(page.getByLabel('URDF 处理方式')).toHaveValue('mjcf'); await expect(page.getByLabel('URDF 基座类型')).toHaveValue('floating'); await page.getByRole('button',{name:'通知中心'}).click(); const notifications=page.getByRole('dialog',{name:'通知中心'}); await expect(notifications).toContainText(/模型已加载 · \d+ 项兼容调整/); await expect(notifications).toContainText(/URDF 已转换为 MJCF(浮动基座),并整体平移/); await page.keyboard.press('Escape'); await expect(page.getByLabel('显示碰撞几何')).not.toBeChecked(); await page.getByRole('button',{name:'源代码'}).click(); const sourceDialog=page.getByRole('dialog',{name:'转换后的 MJCF 编辑器'});await expect(sourceDialog.getByText('缓存文件 · 可编辑')).toBeVisible(); await sourceDialog.locator('.monaco-editor').click({position:{x:240,y:120}});await page.keyboard.press('Control+End');await page.keyboard.insertText('\n'); await sourceDialog.getByRole('button',{name:'保存并重新载入',exact:true}).click();await expect(sourceDialog.getByRole('button',{name:'保存并重新载入',exact:true})).toBeDisabled({timeout:30_000}); await expect(page.getByText('WASM 已加载')).toBeVisible();await sourceDialog.getByRole('button',{name:'关闭源代码编辑器'}).click(); }); test('URDF 自动生成的关节驱动器与摄像头可通过 MuJoCo 编译',async({page})=>{ const urdf=``; await page.goto('/');await page.locator('input[type="file"]').first().setInputFiles({name:'jointed.urdf',mimeType:'application/xml',buffer:Buffer.from(urdf)}); await page.getByRole('dialog',{name:'配置 URDF 仿真组件'}).getByRole('button',{name:'转换并加载'}).click(); await expect(page.getByText('WASM 已加载')).toBeVisible({timeout:30_000}); await expect(page.getByLabel('摄像头画面')).toBeVisible();await page.getByRole('button',{name:'隐藏画面'}).click();await page.getByRole('button',{name:'显示摄像头画面'}).click();await expect(page.getByLabel('摄像头画面')).toBeVisible(); await page.getByRole('button',{name:'通知中心'}).click(); const notifications=page.getByRole('dialog',{name:'通知中心'}); await expect(notifications).toContainText('已为 1 个 hinge/slide 关节生成 motor 驱动器'); await expect(notifications).toContainText('已将 640×480 摄像头固连到 arm'); await page.keyboard.press('Escape');await page.getByRole('tab',{name:'控制'}).click();await page.getByRole('button',{name:'Actuator'}).click(); await expect(page.getByText('shoulder_motor')).toBeVisible();await expect(page.getByText('关节:shoulder')).toBeVisible();await expect(page.getByText('N·m',{exact:true})).toBeVisible(); await page.getByText('常用参数').click();const kp=page.getByLabel(/kp(MJCF stiffness/),kv=page.getByLabel(/kv(MJCF damping/);await kp.fill('150');await kp.press('Enter');await kv.fill('15');await kv.press('Enter');await expect(kp).toHaveValue('150');await expect(kv).toHaveValue('15'); }); test('转换后的 MJCF 保存时保留 DAE 转换缓存资源',async({page})=>{ const zip=zipSync({'robot/urdf/robot.urdf':new Uint8Array(readFileSync(fixture('urdf_dae/robot/urdf/robot.urdf'))),'robot/dae/triangle.dae':new Uint8Array(readFileSync(fixture('urdf_dae/robot/dae/triangle.dae')))}); await page.goto('/');await page.locator('input[type="file"]').first().setInputFiles({name:'dae.zip',mimeType:'application/zip',buffer:Buffer.from(zip)});await page.getByRole('dialog',{name:'配置 URDF 仿真组件'}).getByRole('button',{name:'转换并加载'}).click();await expect(page.getByText('WASM 已加载')).toBeVisible({timeout:30_000}); await page.getByRole('button',{name:'源代码'}).click();const dialog=page.getByRole('dialog',{name:'转换后的 MJCF 编辑器'});await dialog.locator('.monaco-editor').click({position:{x:240,y:120}});await page.keyboard.press('Control+End');await page.keyboard.insertText('\n');await dialog.getByRole('button',{name:'保存并重新载入',exact:true}).click();await expect(dialog.getByRole('button',{name:'保存并重新载入',exact:true})).toBeDisabled({timeout:30_000});await expect(page.getByText('WASM 已加载')).toBeVisible();await expect(page.getByText('模型编译失败')).toHaveCount(0); }); test('slide 关节向屏幕轴正方向拖动时 qpos 同向增加',async({page})=>{ await page.goto('/');await page.locator('input[type="file"]').first().setInputFiles({name:'slide.xml',mimeType:'text/xml',buffer:Buffer.from(SLIDE_DIRECTION_MODEL)});await expect(page.getByText('WASM 已加载')).toBeVisible({timeout:30_000}); await page.getByRole('button',{name:'关节拖动'}).click();const canvas=page.locator('main canvas').first(),box=await canvas.boundingBox();expect(box).not.toBeNull();const x=box!.x+box!.width/2,y=box!.y+box!.height/2;await page.mouse.move(x,y);await page.mouse.down();await page.mouse.move(x+70,y,{steps:8});await page.mouse.up(); await page.getByRole('tab',{name:'控制'}).click();const jointSection=page.getByRole('button',{name:'关节 1'});if(await jointSection.getAttribute('aria-expanded')==='false')await jointSection.click();const output=page.getByText('screen_x').locator('..').locator('output');await expect.poll(async()=>Number.parseFloat(await output.textContent()||'0')).toBeGreaterThan(0); }); test('可导入并启用 Python 控制器',async({page})=>{ await page.goto('/');await page.locator('input[type="file"]').first().setInputFiles({name:'model.xml',mimeType:'text/xml',buffer:Buffer.from(SIMPLE_MODEL)});await expect(page.getByText('WASM 已加载')).toBeVisible({timeout:30_000}); await page.getByRole('tab',{name:'控制'}).click();const python=`NAME = "测试 PD 控制器"\nCONTROL_HZ = 100\ndef init(api):\n return {"joint": api.joint("slide"), "actuator": api.actuator("motor"), "body": api.body("box")}\ndef step(ctx, state):\n assert len(ctx.body_quat(state["body"])) == 4\n assert len(ctx.body_position(state["body"])) == 3\n ctx.set_control(state["actuator"], -ctx.qpos(state["joint"]) - 0.1 * ctx.qvel(state["joint"]))\n`; await page.locator('input[accept=".py,text/x-python"]').setInputFiles({name:'balance.py',mimeType:'text/x-python',buffer:Buffer.from(python)});await expect(page.getByText('测试 PD 控制器',{exact:true})).toBeVisible({timeout:30_000});await expect(page.getByText('Python / Pyodide')).toBeVisible();await page.getByRole('button',{name:'启用',exact:true}).click();await expect(page.getByText('运行中')).toBeVisible(); }); test('中等规模模型持续步进并可重复加载', async ({page}) => { await page.goto('/'); const input = page.locator('input[type="file"]').first(); const modelFile = {name:'large.xml',mimeType:'text/xml',buffer:Buffer.from(LARGE_MODEL)}; await input.setInputFiles(modelFile); await expect(page.getByText('WASM 已加载')).toBeVisible({timeout: 30_000}); await page.getByRole('button', {name:'▶ 播放'}).click(); await page.waitForTimeout(2_000); await expect(page.locator('footer')).not.toContainText('时间 0.000 s'); // 播放过程中重置必须同时暂停底层会话,之后仍可正常播放和暂停。 await page.getByRole('button',{name:'重置',exact:true}).click(); await expect(page.getByRole('button',{name:'▶ 播放'})).toBeVisible(); await expect(page.locator('footer')).toContainText('时间 0.000 s'); await page.getByRole('button',{name:'▶ 播放'}).click(); await page.waitForTimeout(500); await page.getByRole('button',{name:'⏸ 暂停'}).click(); await page.waitForTimeout(200); const pausedTime=(await page.locator('footer').innerText()).match(/时间 ([\d.]+) s/)?.[1]; expect(Number(pausedTime)).toBeGreaterThan(0); await page.waitForTimeout(500); expect((await page.locator('footer').innerText()).match(/时间 ([\d.]+) s/)?.[1]).toBe(pausedTime); await input.setInputFiles(modelFile); await expect(page.getByText('WASM 已加载')).toBeVisible({timeout: 30_000}); await expect(page.getByRole('alert')).toHaveCount(0); }); test('无效模型显示中文诊断且保留工程树', async ({page}) => { await page.goto('/'); await page.locator('input[type="file"]').first().setInputFiles(fixture('invalid.xml')); await expect(page.getByRole('alert')).toContainText('模型编译失败', {timeout: 30_000}); await expect(page.getByText('invalid.xml', {exact: false}).first()).toBeVisible(); });