feat(web-platform): release V0.9.3 全模块界面重构
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled

This commit is contained in:
2026-09-08 17:57:57 +08:00
parent 831d0b95bb
commit 13e35be98b
124 changed files with 5182 additions and 2141 deletions
+171 -89
View File
@@ -1,4 +1,4 @@
import { expect, test } from '@playwright/test';
import { expect, test, type Page } from '@playwright/test';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { zipSync } from 'fflate';
@@ -6,6 +6,33 @@ import { zipSync } from 'fflate';
const fixture = (relative: string) =>
fileURLToPath(new URL(`../fixtures/${relative}`, import.meta.url));
async function openProjectPanel(page: Page) {
const show = page.getByRole('button', { name: '显示工程面板', exact: true });
if (await show.isVisible()) await show.click();
}
async function expectImportedFiles(page: Page, names: string[]) {
await openProjectPanel(page);
await page.getByRole('tab', { name: '工程文件', exact: true }).click();
const tree = page.getByLabel('工程文件树', { exact: true });
for (const name of names) await expect(tree.getByText(name, { exact: true })).toBeVisible();
await page.screenshot({ path: test.info().outputPath('imported-project-files.png') });
}
async function expectNotificationDetails(
page: Page,
messages: RegExp[],
absentMessages: RegExp[] = [],
) {
await page.getByRole('button', { name: '通知中心' }).click();
const notifications = page.getByRole('dialog', { name: '通知中心' });
for (const summary of await notifications.getByText('事件详情', { exact: true }).all())
await summary.click();
for (const message of messages) await expect(notifications).toContainText(message);
for (const message of absentMessages) await expect(notifications).not.toContainText(message);
await page.keyboard.press('Escape');
}
const SIMPLE_MODEL = `
<mujoco model="e2e">
<compiler angle="radian"/>
@@ -79,7 +106,7 @@ const LARGE_MODEL = `
test('独立自调参工作台不需要加载 MuJoCo 主应用即可打开', async ({ page }) => {
await page.goto('/tuning.html');
await expect(page.getByRole('heading', { name: 'Go2 奖励函数自调参 Agent' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Go2 自调参' })).toBeVisible();
await expect(page.getByText('新建 Unitree-Go2-Flat 调参 Session')).toBeVisible();
await expect(page.getByLabel('访问令牌(仅当前标签页)')).toBeVisible();
await expect(page.getByRole('button', { name: '启动自调参' })).toBeVisible();
@@ -102,20 +129,23 @@ test('显示中文平台骨架并加载单文件模型', async ({ page }) => {
const resetCameraBox = await page.getByRole('button', { name: '相机复位' }).boundingBox(),
playBox = await page.getByRole('button', { name: '▶ 播放' }).boundingBox();
expect(
resetCameraBox && playBox && resetCameraBox.x + resetCameraBox.width <= playBox.x,
resetCameraBox && playBox && resetCameraBox.y + resetCameraBox.height < playBox.y,
).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('heading', { name: 'MuJoCo' })).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('menuitem', { 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.getByRole('button', { name: '工作台设置' }).click();
await page.getByRole('button', { name: '更多工作台操作' }).click();
await page.getByRole('menuitem', { name: '工作台设置' }).click();
await expect(page.getByRole('dialog', { name: '工作台设置' })).toBeVisible();
await page.keyboard.press('Escape');
await page.keyboard.press('Control+k');
@@ -123,16 +153,20 @@ test('显示中文平台骨架并加载单文件模型', async ({ page }) => {
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.getByRole('button', { name: '更多工作台操作' }).click();
await page.getByRole('menuitem', { name: '进入全屏' }).click();
await expect.poll(() => page.evaluate(() => Boolean(document.fullscreenElement))).toBe(true);
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 page.getByRole('button', { name: '更多工作台操作' }).click();
await page.getByRole('menuitem', { name: '退出全屏' }).click();
await page.getByRole('button', { name: '更多工作台操作' }).click();
await page.getByRole('menuitem', { 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-light/);
await page.getByRole('button', { name: '更多工作台操作' }).click();
await page.getByRole('menuitem', { name: '切换主题' }).click();
await expect(page.locator('#root > div')).toHaveClass(/theme-dark/);
await page
@@ -144,7 +178,7 @@ test('显示中文平台骨架并加载单文件模型', async ({ page }) => {
buffer: Buffer.from(SIMPLE_MODEL),
});
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 });
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 });
await page.getByRole('button', { name: '显示设置' }).click();
const displayDialog = page.getByRole('dialog', { name: '视图显示设置' });
@@ -160,8 +194,9 @@ test('显示中文平台骨架并加载单文件模型', async ({ page }) => {
await page.getByText('事件日志').click();
await expect(page.getByRole('dialog', { name: '诊断与事件日志' })).toBeVisible();
await page.keyboard.press('Escape');
await page.getByRole('button', { name: /FPS .*物理/ }).click();
await page.getByRole('button', { name: /FPS \d/ }).click();
await expect(page.getByRole('dialog', { name: '性能详情' })).toBeVisible();
await expect(page.getByRole('dialog', { name: '性能详情' })).toContainText('WASM已加载');
await page.keyboard.press('Escape');
await page.getByRole('tab', { name: '控制台' }).click();
const tools = page.getByRole('tabpanel', { name: '控制台' });
@@ -171,7 +206,7 @@ test('显示中文平台骨架并加载单文件模型', async ({ page }) => {
await expect(tools.getByText('motor', { exact: true })).toBeVisible();
await expect(tools.getByText('关节:slide', { exact: true })).toBeVisible();
await page.getByRole('tab', { name: '数据录制' }).click();
await expect(page.getByRole('tabpanel', { name: '数据录制' })).toContainText('仿真遥测记录');
await expect(page.getByRole('tabpanel', { name: '数据录制' })).toContainText('仅当前会话保存');
await expect(page.locator('main canvas')).toBeVisible();
await page.getByRole('tab', { name: '检查器' }).click();
const structure = page.getByRole('navigation', { name: '模型结构树' });
@@ -192,9 +227,9 @@ test('显示中文平台骨架并加载单文件模型', async ({ page }) => {
'aria-pressed',
'true',
);
await expect(page.getByText('已忽略').first()).toBeVisible();
await expect(page.getByText('已忽略关节限位').first()).toBeVisible();
await page.getByRole('button', { name: '重置关节' }).click();
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeVisible();
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled();
});
test('窄视口默认保留完整视口并可按需打开侧栏', async ({ page }) => {
@@ -204,9 +239,7 @@ test('窄视口默认保留完整视口并可按需打开侧栏', async ({ page
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();
await expect(page.getByRole('complementary').filter({ hasText: '检查器待命' })).toBeVisible();
});
test('工作区布局与视口显示偏好在刷新后保留', async ({ page }) => {
@@ -230,7 +263,8 @@ test('转换后的 MJCF 可编辑并重新载入', async ({ 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.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 });
await page.getByRole('button', { name: '工程', exact: true }).click();
await page.getByRole('button', { name: '源代码' }).click();
const dialog = page.getByRole('dialog', { name: '转换后的 MJCF 编辑器' });
await expect(dialog).toBeVisible();
@@ -245,7 +279,7 @@ test('转换后的 MJCF 可编辑并重新载入', async ({ page }) => {
timeout: 30_000,
});
await dialog.getByRole('button', { name: '关闭源代码编辑器' }).click();
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 });
await expect(page.getByRole('button', { name: '导出 URDF' })).toHaveCount(0);
await expect(page.getByRole('button', { name: '导出 MJCF' })).toHaveCount(0);
});
@@ -256,7 +290,8 @@ test('关闭已修改的 MJCF 前要求确认', async ({ 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.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 });
await page.getByRole('button', { name: '工程', exact: true }).click();
await page.getByRole('button', { name: '源代码' }).click();
const editorDialog = page.getByRole('dialog', { name: '转换后的 MJCF 编辑器' });
await editorDialog.locator('.monaco-editor').click({ position: { x: 240, y: 120 } });
@@ -287,8 +322,14 @@ test('加载包含 include、OBJ、STL 与 PNG 的工程', async ({ page }) => {
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();
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 });
await expectImportedFiles(page, [
'model.xml',
'world.xml',
'triangle.obj',
'triangle.stl',
'checker.png',
]);
});
test('加载引用 OBJ 的 URDF 工程', async ({ page }) => {
@@ -301,16 +342,19 @@ test('加载引用 OBJ 的 URDF 工程', async ({ page }) => {
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 expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 });
await expectImportedFiles(page, ['robot.urdf', 'triangle.obj']);
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: '通知中心' });
for (const summary of await notifications.getByText('事件详情', { exact: true }).all())
await summary.click();
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: '工程', exact: true }).click();
await page.getByRole('button', { name: '源代码' }).click();
const sourceDialog = page.getByRole('dialog', { name: '转换后的 MJCF 编辑器' });
await expect(sourceDialog.getByText('缓存文件 · 可编辑')).toBeVisible();
@@ -321,7 +365,7 @@ test('加载引用 OBJ 的 URDF 工程', async ({ page }) => {
await expect(
sourceDialog.getByRole('button', { name: '保存并重新载入', exact: true }),
).toBeDisabled({ timeout: 30_000 });
await expect(page.getByText('WASM 已加载')).toBeVisible();
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled();
await sourceDialog.getByRole('button', { name: '关闭源代码编辑器' }).click();
});
@@ -340,13 +384,15 @@ test('URDF 自动生成的关节驱动器与摄像头可通过 MuJoCo 编译', a
.getByRole('dialog', { name: '配置 URDF 仿真组件' })
.getByRole('button', { name: '转换并加载' })
.click();
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ 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: '通知中心' });
for (const summary of await notifications.getByText('事件详情', { exact: true }).all())
await summary.click();
await expect(notifications).toContainText('已为 1 个 hinge/slide 关节生成 motor 驱动器');
await expect(notifications).toContainText('已将 640×480 摄像头固连到 arm');
await page.keyboard.press('Escape');
@@ -357,8 +403,8 @@ test('URDF 自动生成的关节驱动器与摄像头可通过 MuJoCo 编译', a
.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();
await expect(page.locator('output').filter({ hasText: 'N·m' })).toBeVisible();
await page.getByText('增益与输出限幅').click();
const kp = page.getByLabel(/kp(MJCF stiffness/),
kv = page.getByLabel(/kv(MJCF damping/);
await kp.fill('150');
@@ -387,7 +433,8 @@ test('转换后的 MJCF 保存时保留 DAE 转换缓存资源', async ({ page }
.getByRole('dialog', { name: '配置 URDF 仿真组件' })
.getByRole('button', { name: '转换并加载' })
.click();
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 });
await page.getByRole('button', { name: '工程', exact: true }).click();
await page.getByRole('button', { name: '源代码' }).click();
const dialog = page.getByRole('dialog', { name: '转换后的 MJCF 编辑器' });
await dialog.locator('.monaco-editor').click({ position: { x: 240, y: 120 } });
@@ -397,7 +444,7 @@ test('转换后的 MJCF 保存时保留 DAE 转换缓存资源', async ({ page }
await expect(dialog.getByRole('button', { name: '保存并重新载入', exact: true })).toBeDisabled({
timeout: 30_000,
});
await expect(page.getByText('WASM 已加载')).toBeVisible();
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled();
await expect(page.getByText('模型编译失败')).toHaveCount(0);
});
@@ -411,7 +458,7 @@ test('slide 关节向屏幕轴正方向拖动时 qpos 同向增加', async ({ pa
mimeType: 'text/xml',
buffer: Buffer.from(SLIDE_DIRECTION_MODEL),
});
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 });
await page.getByRole('button', { name: '关节拖动' }).click();
const canvas = page.locator('main canvas').first(),
box = await canvas.boundingBox();
@@ -439,7 +486,7 @@ test('可导入并启用 Python 控制器', async ({ 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.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 });
await page.getByRole('tab', { name: '控制台' }).click();
await page
.getByRole('tabpanel', { name: '控制台' })
@@ -450,11 +497,13 @@ test('可导入并启用 Python 控制器', async ({ 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 page.getByText('脚本详情', { exact: true }).click();
await expect(page.getByText('Python / Pyodide')).toBeVisible();
await page.getByRole('button', { name: '启用', exact: true }).click();
await expect(
page.getByRole('tabpanel', { name: '控制台' }).getByRole('button', { name: /Python 脚本控制/ }),
).toContainText('运行');
await page.screenshot({ path: test.info().outputPath('python-running.png') });
});
test('中等规模模型持续步进并可重复加载', async ({ page }) => {
@@ -462,30 +511,33 @@ test('中等规模模型持续步进并可重复加载', async ({ page }) => {
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 expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ 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 expect(page.getByLabel('视口状态')).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 expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled();
await expect(page.getByLabel('视口状态')).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];
const pausedTime = (await page.getByLabel('视口状态').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);
expect((await page.getByLabel('视口状态').innerText()).match(/时间 ([\d.]+) s/)?.[1]).toBe(
pausedTime,
);
await input.setInputFiles(modelFile);
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 });
await expect(page.getByRole('alert')).toHaveCount(0);
});
test('认证资产可点击创建场景并拖到画布落位', async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto('/');
await page
.locator('input[type="file"]')
@@ -521,9 +573,11 @@ test('认证资产可点击创建场景并拖到画布落位', async ({ page })
const beforeRotation = await gizmoLine.getAttribute('x2');
const canvasBox = await page.locator('main canvas').first().boundingBox();
expect(canvasBox).not.toBeNull();
await page.mouse.move(canvasBox!.x + 24, canvasBox!.y + 24);
// 左上现在是可交互状态槽位;从画布空白中部拖动,继续验证真实 OrbitControls。
const dragY = canvasBox!.y + canvasBox!.height * 0.35;
await page.mouse.move(canvasBox!.x + 24, dragY);
await page.mouse.down({ button: 'left' });
await page.mouse.move(canvasBox!.x + 104, canvasBox!.y + 50, { steps: 8 });
await page.mouse.move(canvasBox!.x + 104, dragY + 26, { steps: 8 });
await page.mouse.up({ button: 'left' });
await expect.poll(() => gizmoLine.getAttribute('x2')).not.toBe(beforeRotation);
await expect(page.getByLabel('对象名称')).toHaveCount(0);
@@ -545,16 +599,16 @@ test('认证资产自动打开地图属性并与参数地形一次编译', async
const library = page.getByLabel('地图资产库');
await library.getByRole('button', { name: '添加基础方盒' }).click();
await expect(page.getByText('Map / Object')).toBeVisible();
await expect(page.getByLabel('地图物体检查器')).toBeVisible();
await expect(page.getByLabel('地图物体检查器')).toBeVisible();
await expect(page.getByText('1 项场景更改待应用')).toBeVisible();
const sceneTree = page.getByLabel('场景资产树');
await expect(sceneTree.getByText('基础方盒')).toBeVisible();
await sceneTree.getByRole('treeitem', { name: 'box', exact: true }).click();
await expect(page.getByText('Robot / Body')).toBeVisible();
await expect(page.getByText(/^Body #/)).toBeVisible();
await sceneTree.getByRole('treeitem', { name: /基础方盒/ }).click();
await expect(page.getByText('Map / Object')).toBeVisible();
await expect(page.getByLabel('地图物体检查器')).toBeVisible();
await library.getByRole('button', { name: '添加随机粗糙地形' }).click();
await page.getByLabel('位置 X(m)').fill('4');
@@ -585,8 +639,13 @@ test('认证资产自动打开地图属性并与参数地形一次编译', async
await page.getByRole('button', { name: '应用场景' }).click();
await expect(page.getByText('2 项场景更改待应用')).toHaveCount(0, { timeout: 30_000 });
await expect(page.getByText(/已加载工程地图“场景 1”(1 个物理几何/)).toBeVisible();
await expect(page.getByText(/已加载随机粗糙地形物理地图/)).toBeVisible();
await page.getByRole('button', { name: '通知中心' }).click();
const notifications = page.getByRole('dialog', { name: '通知中心' });
for (const summary of await notifications.getByText('事件详情', { exact: true }).all())
await summary.click();
await expect(notifications).toContainText(/已加载工程地图“场景 1”(1 个物理几何/);
await expect(notifications).toContainText(/已加载随机粗糙地形物理地图/);
await page.keyboard.press('Escape');
await expect(page.getByLabel('地图来源')).toHaveValue('builtin');
// 当前仍选中参数地形时,直接点已编译的认证资产也必须反查场景与对象,首次点击即挂载操纵器。
@@ -682,8 +741,7 @@ test('工程地图与参数地形共享放置草稿、实例变换和回滚入
await expect(page.getByText('2 项场景更改待应用')).toBeVisible();
await page.getByRole('button', { name: '应用场景' }).click();
await expect(page.getByText('2 项场景更改待应用')).toHaveCount(0, { timeout: 30_000 });
await expect(page.getByText(/已加载工程地图“草稿仓库”/)).toBeVisible();
await expect(page.getByText(/已加载波浪地形物理地图/)).toBeVisible();
await expectNotificationDetails(page, [/已加载工程地图“草稿仓库”/, /已加载波浪地形物理地图/]);
await page.getByRole('button', { name: '删除地图实例 草稿仓库' }).click();
await expect(page.getByText('1 项场景更改待应用')).toBeVisible();
@@ -739,23 +797,26 @@ test('参数化地形可连续拖到画布并一次性编译', async ({ page })
await expect(page.getByLabel('场景资产树')).toContainText('波浪地形');
await page.getByLabel('场景资产树').getByRole('treeitem', { name: 'box' }).click();
await expect(page.getByText('Robot / Body')).toBeVisible();
await expect(page.getByText(/^Body #/)).toBeVisible();
const canvasBox = await page.locator('main canvas').first().boundingBox();
expect(canvasBox).not.toBeNull();
await page.mouse.click(
canvasBox!.x + canvasBox!.width * 0.5,
canvasBox!.y + canvasBox!.height * 0.78,
);
await expect(page.getByText('Map / Instance')).toBeVisible();
await expect(page.getByText('参数化地形')).toBeVisible();
await expect(page.getByLabel('地图视口工具')).toBeVisible();
await expect(page.getByText('地图与对象属性')).toBeVisible();
await expect(page.getByLabel('物理地图预设')).toBeVisible();
await page.getByRole('button', { name: '应用场景' }).click();
await expect(page.getByText('2 项场景更改待应用')).toHaveCount(0, { timeout: 30_000 });
await expect(page.getByText(/已加载随机粗糙地形物理地图/)).toBeVisible({
timeout: 30_000,
});
await expect(page.getByText(/已加载波浪地形物理地图/)).toBeVisible();
await page.getByRole('button', { name: '通知中心' }).click();
const notifications = page.getByRole('dialog', { name: '通知中心' });
for (const summary of await notifications.getByText('事件详情', { exact: true }).all())
await summary.click();
await expect(notifications).toContainText(/已加载随机粗糙地形物理地图/);
await expect(notifications).toContainText(/已加载波浪地形物理地图/);
await page.keyboard.press('Escape');
});
test('应用内置 MJCF 楼梯物理地图', async ({ page }) => {
@@ -769,13 +830,14 @@ test('应用内置 MJCF 楼梯物理地图', async ({ page }) => {
mimeType: 'text/xml',
buffer: Buffer.from(SIMPLE_MODEL),
});
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
await page.getByLabel('地图来源').selectOption('builtin');
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 });
await page.getByLabel('地图资产库').getByRole('button', { name: '添加波浪地形' }).click();
await page.getByLabel('物理地图预设').selectOption('stairs');
await page.getByLabel('台阶数量').fill('6');
await page.getByRole('button', { name: '应用并重新编译' }).click();
await expect(page.getByText(/已加载楼梯物理地图/)).toBeVisible({ timeout: 30_000 });
await expect(page.getByText('WASM 已加载')).toBeVisible();
await expect(page.getByLabel('地图草稿状态')).toBeHidden({ timeout: 30_000 });
await expectNotificationDetails(page, [/已加载楼梯物理地图/]);
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled();
});
test('依次应用全部系统参数化地形', async ({ page }) => {
@@ -800,19 +862,18 @@ test('依次应用全部系统参数化地形', async ({ page }) => {
mimeType: 'text/xml',
buffer: Buffer.from(SIMPLE_MODEL),
});
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
await page.getByLabel('地图来源').selectOption('builtin');
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 });
await page.getByLabel('地图资产库').getByRole('button', { name: '添加波浪地形' }).click();
for (const [preset, label] of terrains) {
await page.getByLabel('物理地图预设').selectOption(preset);
await page.getByLabel('地形边长(m)').fill('6');
if (preset === 'rough' || preset === 'wave')
await page.getByLabel('水平采样间距(m)').fill('0.25');
await page.getByRole('button', { name: '应用并重新编译' }).click();
await expect(page.getByText(new RegExp(`已加载${label}物理地图`))).toBeVisible({
timeout: 30_000,
});
await expect(page.getByLabel('地图草稿状态')).toBeHidden({ timeout: 30_000 });
await expectNotificationDetails(page, [new RegExp(`已加载${label}物理地图`)]);
}
await expect(page.getByText('WASM 已加载')).toBeVisible();
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled();
});
test('导入并应用分层工程地图包', async ({ page }) => {
@@ -843,11 +904,18 @@ test('导入并应用分层工程地图包', async ({ page }) => {
mimeType: 'application/zip',
buffer: Buffer.from(project),
});
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
await page.getByLabel('地图来源').selectOption({ label: '测试场景' });
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 });
await page
.getByLabel('地图资产库')
.getByRole('button', { name: '放置工程地图 测试场景' })
.click();
// 资产库放置默认不移动机器人;显式选择出生点后仍验证原编译路径。
await expect(page.getByLabel('地图出生点')).toHaveValue('');
await page.getByLabel('地图出生点').selectOption('start');
await expect(page.getByLabel('地图出生点')).toHaveValue('start');
await page.getByRole('button', { name: '应用并重新编译' }).click();
await expect(page.getByText(/已加载工程地图“测试场景”/)).toBeVisible({ timeout: 30_000 });
await expect(page.getByLabel('地图草稿状态')).toBeHidden({ timeout: 30_000 });
await expectNotificationDetails(page, [/已加载工程地图“测试场景”/], [/视觉地图加载失败/]);
await expect(page.getByText(/视觉地图加载失败/)).toHaveCount(0);
});
@@ -877,10 +945,13 @@ test('将受支持的只读物理地图转换为可编辑副本', async ({ page
mimeType: 'application/zip',
buffer: Buffer.from(project),
});
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
await page.getByLabel('地图来源').selectOption({ label: '旧版基础场景' });
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 });
await page
.getByLabel('地图资产库')
.getByRole('button', { name: '放置工程地图 旧版基础场景' })
.click();
await page.getByRole('button', { name: '应用并重新编译' }).click();
const editor = page.getByText('认证资产与场景对象属性').locator('..');
const editor = page.getByText('源内容修改影响所有同源实例').locator('../..');
await expect(editor.getByText(/保持只读/)).toBeVisible({ timeout: 30_000 });
await editor.getByRole('button', { name: '创建可编辑副本' }).click();
await expect(page.getByText('已创建可编辑地图副本')).toBeVisible({ timeout: 30_000 });
@@ -924,10 +995,15 @@ test('编辑 V3 地图对象并事务式应用', async ({ page }) => {
mimeType: 'application/zip',
buffer: Buffer.from(project),
});
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
await page.getByLabel('地图来源').selectOption({ label: '可编辑场景' });
await page.getByRole('button', { name: '应用并重新编译' }).click();
await expect(page.getByText('认证资产与场景对象属性')).toBeVisible({ timeout: 30_000 });
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 });
await page
.getByLabel('地图资产库')
.getByRole('button', { name: '放置工程地图 可编辑场景' })
.click();
await page.getByRole('button', { name: '应用场景', exact: true }).click();
await expect(page.getByText('源内容修改影响所有同源实例', { exact: true })).toBeVisible({
timeout: 30_000,
});
const mapTools = page.getByLabel('地图视口工具');
await expect(mapTools.getByRole('button', { name: '移动工具 W' })).toHaveAttribute(
'aria-pressed',
@@ -955,7 +1031,7 @@ test('编辑 V3 地图对象并事务式应用', async ({ page }) => {
).toBeVisible({
timeout: 30_000,
});
await expect(draftStatus).toContainText('地图草稿已同步');
await expect(draftStatus).toBeHidden();
await page
.getByLabel('地图对象列表')
@@ -969,13 +1045,13 @@ test('编辑 V3 地图对象并事务式应用', async ({ page }) => {
.getByRole('button', { name: /box · 方盒/ })
.click();
await expect(page.getByLabel('对象位置X')).toHaveValue('2');
await expect(draftStatus).toContainText('地图草稿已同步');
await expect(draftStatus).toBeHidden();
await page.getByRole('button', { name: '新增', exact: true }).click();
await expect(page.getByLabel('地图对象列表').getByRole('button')).toHaveCount(2);
await page.keyboard.press('Delete');
await expect(page.getByLabel('地图对象列表').getByRole('button')).toHaveCount(1);
await expect(page.getByText('WASM 已加载')).toBeVisible();
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled();
});
test('工程地图编译失败时保留上一仿真会话', async ({ page }) => {
@@ -1004,34 +1080,40 @@ test('工程地图编译失败时保留上一仿真会话', async ({ page }) =>
mimeType: 'application/zip',
buffer: Buffer.from(project),
});
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 });
await page.getByRole('button', { name: '▶ 播放' }).click();
await page.waitForTimeout(200);
const runningTime = Number(
(await page.locator('footer').innerText()).match(/时间 ([\d.]+) s/)?.[1] ?? 0,
(await page.getByLabel('视口状态').innerText()).match(/时间 ([\d.]+) s/)?.[1] ?? 0,
);
await page.getByLabel('地图来源').selectOption({ label: '动态错误地图' });
await page.getByRole('button', { name: '应用并重新编译' }).click();
await page
.getByLabel('地图资产库')
.getByRole('button', { name: '放置工程地图 动态错误地图' })
.click();
await page.getByRole('button', { name: '应用场景', exact: true }).click();
await expect(page.getByRole('alert')).toContainText('模型编译失败', { timeout: 30_000 });
await expect(page.getByLabel('地图来源')).toHaveValue('project:maps/invalid/map.json');
await expect(page.getByLabel('地图草稿状态')).toContainText('应用失败');
await page.screenshot({ path: test.info().outputPath('map-compile-failed.png') });
await expect(page.getByText('1 项场景更改待应用')).toBeVisible();
await expect(page.getByLabel('场景资产树')).toContainText('待应用');
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeVisible();
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled();
await page.getByRole('button', { name: '关闭错误' }).click();
await page.getByRole('button', { name: '▶ 播放' }).click();
await expect
.poll(async () =>
Number((await page.locator('footer').innerText()).match(/时间 ([\d.]+) s/)?.[1] ?? 0),
Number((await page.getByLabel('视口状态').innerText()).match(/时间 ([\d.]+) s/)?.[1] ?? 0),
)
.toBeGreaterThan(runningTime);
await page.getByRole('button', { name: '放弃更改' }).click();
await expect(page.getByText('1 项场景更改待应用')).toHaveCount(0);
await expect(page.getByLabel('地图来源')).toHaveValue('none');
await expect(page.getByLabel('场景资产树')).not.toContainText('动态错误地图');
await expect(page.getByLabel('地图草稿状态')).toBeHidden();
});
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();
await expectImportedFiles(page, ['invalid.xml']);
});