import {expect, test} from '@playwright/test'; import {fileURLToPath} from 'node:url'; const fixture = (relative: string) => fileURLToPath(new URL(`../fixtures/${relative}`, import.meta.url)); const SIMPLE_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 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:'切换到白天主题'})).toHaveText('☀'); await page.getByRole('button',{name:'切换到白天主题'}).click(); await expect(page.locator('#root > div')).toHaveClass(/theme-light/); await expect(page.getByRole('button',{name:'切换到黑夜主题'})).toHaveText('☾'); 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 expect(page.getByText('motor')).toBeVisible(); await expect(page.getByText('slide').first()).toBeVisible(); 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('加载包含 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'), ]); await expect(page.getByText('WASM 已加载')).toBeVisible({timeout: 30_000}); await expect(page.getByText('2 个文件')).toBeVisible(); await expect(page.getByLabel('URDF 处理方式')).toHaveValue('mjcf'); await expect(page.getByLabel('URDF 基座类型')).toHaveValue('floating'); await expect(page.getByText(/URDF 已转换为 MJCF(浮动基座),并整体平移/)).toBeVisible(); await expect(page.getByLabel('显示碰撞几何')).not.toBeChecked(); }); 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:'重置'}).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(); });