feat(web-platform): release V0.9.3 全模块界面重构
This commit is contained in:
+171
-89
@@ -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']);
|
||||
});
|
||||
|
||||
@@ -63,7 +63,7 @@ test('训练作业一键导入:真实WASM地图+97维ORT+PiP+射线开关', as
|
||||
.locator('input[type="file"]')
|
||||
.first()
|
||||
.setInputFiles({ name: 'go2.xml', mimeType: 'text/xml', buffer: Buffer.from(go2) });
|
||||
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();
|
||||
const tools = page.getByRole('tabpanel', { name: '控制台' });
|
||||
await tools.getByRole('button', { name: /强化学习任务/ }).click();
|
||||
@@ -82,6 +82,7 @@ test('训练作业一键导入:真实WASM地图+97维ORT+PiP+射线开关', as
|
||||
if ((await section.getAttribute('aria-expanded')) === 'false') await section.click();
|
||||
await expect(tools.getByText('97 / 12')).toBeVisible();
|
||||
await expect(tools.getByText('Go2 前视射线避障导航', { exact: true })).toBeVisible();
|
||||
await tools.getByText('推理详情', { exact: true }).click();
|
||||
const count = tools.getByText('推理次数', { exact: true }).locator('..');
|
||||
await expect
|
||||
.poll(async () => Number((await count.textContent())?.replace(/\D/g, '')))
|
||||
@@ -90,7 +91,9 @@ test('训练作业一键导入:真实WASM地图+97维ORT+PiP+射线开关', as
|
||||
if (await pause.isVisible()) await pause.click();
|
||||
const stop = tools.getByRole('button', { name: '停止', exact: true });
|
||||
if (await stop.isVisible()) await stop.click();
|
||||
const targetRow = tools.getByText('当前目标 (X, Y)', { exact: true }).locator('..');
|
||||
await section.evaluate((element) => element.scrollIntoView({ block: 'start' }));
|
||||
await page.screenshot({ path: test.info().outputPath('onnx-policy.png') });
|
||||
const targetRow = tools.getByText('当前目标 (X, Y) m', { exact: true }).locator('..');
|
||||
const initialTarget = await targetRow.textContent();
|
||||
const selection = () => page.locator('[aria-selected="true"]').allTextContents();
|
||||
const beforeSelection = await selection();
|
||||
@@ -139,6 +142,7 @@ test('训练作业一键导入:真实WASM地图+97维ORT+PiP+射线开关', as
|
||||
await tools.getByRole('button', { name: '导入策略' }).click();
|
||||
await expect(tools.getByRole('alert')).toContainText('维度');
|
||||
await expect(tools.getByText('97 / 12')).toBeVisible();
|
||||
await page.screenshot({ path: test.info().outputPath('onnx-contract-error.png') });
|
||||
await expect(targetRow).toHaveText(initialTarget!);
|
||||
await tools.getByRole('button', { name: '设定目标', exact: true }).click();
|
||||
await tools.getByRole('button', { name: '卸载', exact: true }).click();
|
||||
@@ -148,6 +152,7 @@ test('训练作业一键导入:真实WASM地图+97维ORT+PiP+射线开关', as
|
||||
await expect(tools.locator('.uplot canvas')).toHaveCount(1);
|
||||
await tools.getByRole('tab', { name: '综合', exact: true }).click();
|
||||
await expect(tools.locator('.uplot canvas')).toHaveCount(5);
|
||||
await page.screenshot({ path: test.info().outputPath('training-metrics.png') });
|
||||
await tools.getByRole('button', { name: /训练指标趋势/ }).click();
|
||||
await expect(tools.locator('.uplot')).toHaveCount(0);
|
||||
});
|
||||
|
||||
@@ -62,7 +62,7 @@ test('训练作业一键导入:真实WASM地图+81维ORT+PiP+射线开关', as
|
||||
.locator('input[type="file"]')
|
||||
.first()
|
||||
.setInputFiles({ name: 'go2.xml', mimeType: 'text/xml', buffer: Buffer.from(go2) });
|
||||
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();
|
||||
const tools = page.getByRole('tabpanel', { name: '控制台' });
|
||||
await tools.getByRole('button', { name: /强化学习任务/ }).click();
|
||||
@@ -89,7 +89,7 @@ test('训练作业一键导入:真实WASM地图+81维ORT+PiP+射线开关', as
|
||||
if (await pause.isVisible()) await pause.click();
|
||||
const stop = tools.getByRole('button', { name: '停止', exact: true });
|
||||
if (await stop.isVisible()) await stop.click();
|
||||
const targetRow = tools.getByText('当前目标 (X, Y)', { exact: true }).locator('..');
|
||||
const targetRow = tools.getByText('当前目标 (X, Y) m', { exact: true }).locator('..');
|
||||
const initialTarget = await targetRow.textContent();
|
||||
const selection = () => page.locator('[aria-selected="true"]').allTextContents();
|
||||
const beforeSelection = await selection();
|
||||
@@ -153,7 +153,7 @@ test('下载metadata与作业不匹配时拒绝,未更换地图或启用策略
|
||||
.locator('input[type="file"]')
|
||||
.first()
|
||||
.setInputFiles({ name: 'go2.xml', mimeType: 'text/xml', buffer: Buffer.from(go2) });
|
||||
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();
|
||||
const tools = page.getByRole('tabpanel', { name: '控制台' });
|
||||
await tools.getByRole('button', { name: /强化学习任务/ }).click();
|
||||
@@ -194,7 +194,7 @@ for (const failure of ['wrong-graph', 'ort-init-failure']) {
|
||||
.locator('input[type="file"]')
|
||||
.first()
|
||||
.setInputFiles({ name: 'go2.xml', mimeType: 'text/xml', buffer: Buffer.from(go2) });
|
||||
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();
|
||||
const tools = page.getByRole('tabpanel', { name: '控制台' });
|
||||
await tools.getByRole('button', { name: /强化学习任务/ }).click();
|
||||
@@ -220,7 +220,7 @@ for (const failure of ['wrong-graph', 'ort-init-failure']) {
|
||||
await expect(inference).toHaveText(before!);
|
||||
await expect(page.getByLabel('摄像头画面', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('训练配套物理地图', { exact: false })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -274,7 +274,7 @@ test('新server默认Flat作业兼容旧无metadata47维ONNX,错误graph拒绝
|
||||
mimeType: 'text/xml',
|
||||
buffer: Buffer.from(go2.replace('</mujoco>', actuators + '</mujoco>')),
|
||||
});
|
||||
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();
|
||||
const tools = page.getByRole('tabpanel', { name: '控制台' });
|
||||
await tools.getByRole('button', { name: /强化学习任务/ }).click();
|
||||
|
||||
@@ -170,7 +170,7 @@ test('用户原始47维ONNX通过普通Flat面板加载,不新增点击导航
|
||||
.locator('input[type="file"]')
|
||||
.first()
|
||||
.setInputFiles({ name: 'go2.xml', mimeType: 'text/xml', buffer: Buffer.from(xml) });
|
||||
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();
|
||||
const tools = page.getByRole('tabpanel', { name: '控制台' });
|
||||
const section = tools.getByRole('button', { name: /ONNX 策略运行/ });
|
||||
|
||||
@@ -103,6 +103,7 @@ for (const panel of ['ordinary', 'tuning'] as const) {
|
||||
await ui
|
||||
.getByRole('button', { name: panel === 'ordinary' ? /^连接$/ : '连接/刷新' })
|
||||
.click();
|
||||
await ui.getByText('上传基础策略', { exact: true }).click();
|
||||
await expect(ui.getByLabel('确认Go2 legacy47模板')).toBeEnabled();
|
||||
await expect(ui.getByLabel('基础策略', { exact: true })).toHaveValue('');
|
||||
await ui.getByLabel('确认Go2 legacy47模板').check();
|
||||
@@ -121,6 +122,7 @@ for (const panel of ['ordinary', 'tuning'] as const) {
|
||||
).json();
|
||||
const record = catalog.pretrainedSources[0];
|
||||
await expect(ui.getByLabel('基础策略', { exact: true })).toHaveValue(record.id);
|
||||
await ui.getByText('来源与校验详情', { exact: true }).click();
|
||||
await expect(ui.getByText(/原文件 SHA256/)).toContainText(sha);
|
||||
await expect(ui.getByText(/上传格式/)).toContainText(format);
|
||||
await expect(ui.getByText(/已选择.*仅继承策略权重/)).toBeVisible();
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { TuningSession } from '../src/training/types';
|
||||
const objectives = {
|
||||
velocity_tracking: 0.35,
|
||||
action_smoothness: 0.2,
|
||||
posture_stability: 0.15,
|
||||
fall_avoidance: 0.15,
|
||||
foot_slip: 0.1,
|
||||
energy: 0.05,
|
||||
};
|
||||
|
||||
export function tuningFixture(): TuningSession {
|
||||
const baseConfig = { weights: { pose: 1 }, params: {} };
|
||||
const resultConfig = { weights: { pose: 1.1 }, params: {} };
|
||||
return {
|
||||
id: 'a'.repeat(32),
|
||||
state: 'awaiting_approval',
|
||||
mode: 'approval',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-01T00:02:00Z',
|
||||
config: {
|
||||
taskId: 'Unitree-Go2-Flat',
|
||||
mode: 'approval',
|
||||
runName: '界面验收会话',
|
||||
numEnvs: 16,
|
||||
seed: 42,
|
||||
gpuIds: [0],
|
||||
trialCount: 2,
|
||||
initialIterations: 300,
|
||||
middleIterations: 900,
|
||||
finalIterations: 2000,
|
||||
evalNumEnvs: 8,
|
||||
evalSteps: 10,
|
||||
objectiveWeights: objectives,
|
||||
fallbackEnabled: false,
|
||||
rungs: [300, 900, 2000],
|
||||
promote: [2, 2, 2],
|
||||
},
|
||||
objectiveWeights: objectives,
|
||||
message: 'paused',
|
||||
bestTrialId: '2'.repeat(32),
|
||||
consecutiveNoImprove: 0,
|
||||
fallbackEnabled: false,
|
||||
trials: [
|
||||
{
|
||||
id: '1'.repeat(32),
|
||||
sessionId: 'a'.repeat(32),
|
||||
number: 0,
|
||||
state: 'completed',
|
||||
rung: 0,
|
||||
targetIterations: 300,
|
||||
rewardConfig: baseConfig,
|
||||
score: 0,
|
||||
eligible: true,
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
message: 'done',
|
||||
},
|
||||
{
|
||||
id: '2'.repeat(32),
|
||||
sessionId: 'a'.repeat(32),
|
||||
number: 1,
|
||||
state: 'completed',
|
||||
rung: 0,
|
||||
targetIterations: 300,
|
||||
rewardConfig: resultConfig,
|
||||
proposalId: '3'.repeat(32),
|
||||
score: 0.12,
|
||||
eligible: true,
|
||||
evaluation: {
|
||||
metrics: { linear_velocity_rmse: 0.2 },
|
||||
score: { score: 0.12, eligible: true, components: { velocity_tracking: 0.2 } },
|
||||
},
|
||||
createdAt: '2026-01-01T00:01:00Z',
|
||||
message: 'done',
|
||||
},
|
||||
],
|
||||
proposals: [
|
||||
{
|
||||
id: '3'.repeat(32),
|
||||
sessionId: 'a'.repeat(32),
|
||||
baseTrialId: '1'.repeat(32),
|
||||
state: 'pending',
|
||||
source: 'agent',
|
||||
patch: { weights: { pose: 1.1 }, params: {} },
|
||||
rationale: '提高姿态奖励以降低躯干倾角。',
|
||||
expectedImpact: { posture_stability: '姿态误差预计下降 8%' },
|
||||
confidence: 0.82,
|
||||
createdAt: '2026-01-01T00:00:30Z',
|
||||
},
|
||||
],
|
||||
audit: [],
|
||||
control: {
|
||||
runPolicy: 'step',
|
||||
dispatchTokens: 0,
|
||||
constraintsRevision: 0,
|
||||
constraints: {},
|
||||
effectiveAfterCurrent: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
|
||||
const MODEL = `<mujoco model="domain-panels"><worldbody><geom type="plane" size="3 3 .1"/><body name="box" pos="0 0 1"><joint name="slide" type="slide" axis="1 0 0" range="-1 1"/><geom type="box" size=".2 .2 .2" mass="1"/></body></worldbody><actuator><motor name="motor" joint="slide" ctrlrange="-2 2"/></actuator></mujoco>`;
|
||||
async function load(page: Page) {
|
||||
await page.goto('/');
|
||||
await page
|
||||
.locator('#mujoco-project-files')
|
||||
.setInputFiles({ name: 'domain.xml', mimeType: 'text/xml', buffer: Buffer.from(MODEL) });
|
||||
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled();
|
||||
}
|
||||
async function left(page: Page) {
|
||||
const button = page.getByRole('button', { name: '显示工程面板', exact: true });
|
||||
if (await button.isVisible()) await button.click();
|
||||
}
|
||||
async function right(page: Page) {
|
||||
const button = page.getByRole('button', { name: '显示右侧面板', exact: true });
|
||||
if (await button.isVisible()) await button.click();
|
||||
}
|
||||
async function screenshot(page: Page, name: string) {
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
|
||||
await page.screenshot({
|
||||
path: test.info().outputPath(name + '.png'),
|
||||
mask: [page.getByLabel('视口状态')],
|
||||
});
|
||||
}
|
||||
|
||||
for (const theme of ['dark', 'light']) {
|
||||
for (const [width, height] of [
|
||||
[1920, 1080],
|
||||
[1440, 900],
|
||||
[1366, 768],
|
||||
[1024, 768],
|
||||
[768, 800],
|
||||
]) {
|
||||
test(`领域检查器与录制 ${theme} ${width}`, async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
await page.setViewportSize({ width, height });
|
||||
await page.addInitScript(
|
||||
(value) => localStorage.setItem('mujoco-platform-theme', value),
|
||||
theme,
|
||||
);
|
||||
await load(page);
|
||||
await left(page);
|
||||
await page
|
||||
.getByRole('navigation', { name: '模型结构树' })
|
||||
.getByRole('treeitem', { name: /box/ })
|
||||
.first()
|
||||
.click();
|
||||
await expect(page.getByText(/^Body #/)).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '标识与层级' })).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'false',
|
||||
);
|
||||
await screenshot(page, 'body');
|
||||
await page
|
||||
.getByRole('tabpanel', { name: '检查器' })
|
||||
.getByRole('button', { name: /slide/ })
|
||||
.click();
|
||||
await expect(page.getByRole('slider', { name: 'slide' })).toBeVisible();
|
||||
await page.getByRole('slider', { name: 'slide' }).focus();
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await expect(page.getByRole('slider', { name: 'slide' })).not.toHaveValue('0');
|
||||
await page.getByText('增益与输出限幅', { exact: true }).click();
|
||||
await expect(page.getByText(/参数立即作用于模型/)).toBeVisible();
|
||||
await screenshot(page, 'joint-actuator');
|
||||
await page.getByRole('tab', { name: '数据录制' }).click();
|
||||
await page.getByRole('button', { name: '开始记录' }).click();
|
||||
await expect(page.getByText('记录中', { exact: true })).toBeVisible();
|
||||
await expect(page.getByLabel('采样频率', { exact: true })).toBeDisabled();
|
||||
await page.getByRole('button', { name: '▶ 播放' }).click();
|
||||
await page.waitForTimeout(150);
|
||||
await page.getByRole('button', { name: '⏸ 暂停' }).click();
|
||||
await page.getByRole('button', { name: '停止记录' }).click();
|
||||
await page.getByText('实时运动状态', { exact: true }).click();
|
||||
await expect(page.getByRole('button', { name: '导出 CSV' })).toBeEnabled();
|
||||
await screenshot(page, 'recording');
|
||||
await left(page);
|
||||
await page.getByLabel('地图资产库').getByRole('button', { name: '添加基础方盒' }).click();
|
||||
await page.getByRole('tab', { name: '检查器' }).click();
|
||||
await expect(page.getByLabel('对象参数sizeX', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('表面材质', { exact: true }).locator('..')).not.toHaveAttribute(
|
||||
'open',
|
||||
);
|
||||
await page.getByLabel('对象参数sizeX', { exact: true }).fill('1.5');
|
||||
await page.getByLabel('对象参数sizeX', { exact: true }).press('Enter');
|
||||
await expect(page.getByLabel('地图草稿状态')).toContainText('未保存');
|
||||
await screenshot(page, 'map-draft');
|
||||
if (width === 1440) {
|
||||
await page
|
||||
.getByLabel('对象参数sizeX', { exact: true })
|
||||
.locator('..')
|
||||
.getByRole('button')
|
||||
.focus();
|
||||
await expect(page.getByRole('tooltip')).toContainText('Shift 精调');
|
||||
const tip = await page.getByRole('tooltip').boundingBox();
|
||||
expect(tip!.x + tip!.width).toBeLessThanOrEqual(width);
|
||||
await screenshot(page, 'map-keyboard-tooltip');
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.getByRole('tooltip')).toHaveCount(0);
|
||||
}
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
test(`训练折叠详情与跨视图状态 ${theme}`, async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page.addInitScript(
|
||||
(value) => localStorage.setItem('mujoco-platform-theme', value),
|
||||
theme,
|
||||
);
|
||||
let starts = 0;
|
||||
let offline = true;
|
||||
const job = {
|
||||
id: 'a'.repeat(32),
|
||||
taskId: 'Unitree-Go2-Flat',
|
||||
state: 'running',
|
||||
progress: 0.4,
|
||||
iteration: 4,
|
||||
maxIterations: 10,
|
||||
message: '模拟训练中,未启动真实作业',
|
||||
artifactReady: false,
|
||||
logs: ['Learning iteration 4 / 10', 'Mean value loss: 0.9'],
|
||||
};
|
||||
await page.route('http://127.0.0.1:8765/**', async (route) => {
|
||||
const request = route.request();
|
||||
if (offline) return route.fulfill({ status: 503, json: { error: '模拟服务断连' } });
|
||||
if (request.url().endsWith('/health'))
|
||||
return route.fulfill({
|
||||
json: {
|
||||
ready: true,
|
||||
trainerRoot: '/mock/trainer',
|
||||
tasks: ['Unitree-Go2-Flat'],
|
||||
taskMetadata: [
|
||||
{
|
||||
id: 'Unitree-Go2-Flat',
|
||||
name: 'Go2 平地',
|
||||
terrainPresets: ['plane'],
|
||||
terrainParameters: { size: { min: 4, max: 30, default: 8 } },
|
||||
sensorParameters: {},
|
||||
browserCompatible: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
if (request.url().endsWith('/presets')) return route.fulfill({ json: { presets: [] } });
|
||||
if (request.method() === 'POST') starts++;
|
||||
return route.fulfill({ json: job });
|
||||
});
|
||||
await load(page);
|
||||
await right(page);
|
||||
await page.getByRole('tab', { name: '控制台' }).click();
|
||||
const tools = page.getByRole('tabpanel', { name: '控制台' });
|
||||
const group = tools.getByRole('button', { name: /强化学习任务/ });
|
||||
await group.click();
|
||||
await page.getByLabel('训练服务访问令牌').fill('mock-token');
|
||||
await tools.getByRole('button', { name: '连接', exact: true }).click();
|
||||
await expect(tools.getByRole('alert')).toContainText('模拟服务断连');
|
||||
await screenshot(page, 'training-disconnected');
|
||||
offline = false;
|
||||
await tools.getByRole('button', { name: '连接', exact: true }).click();
|
||||
await page.getByLabel('训练地形', { exact: true }).selectOption('plane');
|
||||
await tools.getByRole('button', { name: '地形详细参数' }).click();
|
||||
await page.getByLabel('地图尺寸 m', { exact: true }).fill('100');
|
||||
await tools.getByRole('button', { name: '地形详细参数' }).click();
|
||||
await tools.getByRole('button', { name: '发起本地训练' }).click();
|
||||
await expect(tools.getByRole('alert')).toContainText('超出允许范围');
|
||||
await expect(tools.getByRole('alert')).toBeInViewport();
|
||||
await expect(page.getByLabel('地图尺寸 m', { exact: true })).toBeVisible();
|
||||
expect(starts).toBe(0);
|
||||
await screenshot(page, 'training-validation');
|
||||
await page.getByLabel('地图尺寸 m', { exact: true }).fill('12');
|
||||
await page.getByLabel('运行名称', { exact: true }).fill('preserved');
|
||||
await group.click();
|
||||
await page.getByRole('tab', { name: '检查器' }).click();
|
||||
await page.getByRole('tab', { name: '数据录制' }).click();
|
||||
await page.getByRole('tab', { name: '控制台' }).click();
|
||||
await group.click();
|
||||
await expect(page.getByLabel('运行名称', { exact: true })).toHaveValue('preserved');
|
||||
await expect(page.getByLabel('训练服务访问令牌')).toHaveValue('mock-token');
|
||||
await tools.getByRole('button', { name: '发起本地训练' }).click();
|
||||
await expect(tools.getByRole('button', { name: '停止训练' })).toBeVisible();
|
||||
await screenshot(page, 'training-running');
|
||||
await group.click();
|
||||
await expect(group).toContainText('训练中');
|
||||
await page.getByRole('tab', { name: '检查器' }).click();
|
||||
await page.getByRole('tab', { name: '控制台' }).click();
|
||||
await expect(group).toContainText('训练中');
|
||||
expect(starts).toBe(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { expect, test, type Locator } from '@playwright/test';
|
||||
|
||||
async function expectWithinViewport(locator: Locator, width: number, height: number) {
|
||||
await expect(locator).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const box = await locator.boundingBox();
|
||||
return Boolean(
|
||||
box &&
|
||||
box.x >= 7 &&
|
||||
box.y >= 7 &&
|
||||
box.x + box.width <= width - 7 &&
|
||||
box.y + box.height <= height - 7,
|
||||
);
|
||||
})
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
for (const theme of ['dark', 'light']) {
|
||||
for (const [width, height] of [
|
||||
[1920, 1080],
|
||||
[1440, 900],
|
||||
[1366, 768],
|
||||
[1024, 768],
|
||||
[768, 800],
|
||||
]) {
|
||||
test(`共享浮层边界与主题 ${theme} ${width}×${height}`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height });
|
||||
await page.addInitScript(
|
||||
(value) => localStorage.setItem('mujoco-platform-theme', value),
|
||||
theme,
|
||||
);
|
||||
await page.goto('/');
|
||||
const trigger = page.getByRole('button', { name: /隐藏工程面板|显示工程面板/ });
|
||||
await trigger.focus();
|
||||
const tooltip = page.getByRole('tooltip');
|
||||
await expectWithinViewport(tooltip, width, height);
|
||||
await expect(trigger).toHaveAttribute(
|
||||
'aria-describedby',
|
||||
(await tooltip.getAttribute('id')) as string,
|
||||
);
|
||||
expect(
|
||||
await tooltip.evaluate((node) => node.closest('.theme-dark, .theme-light')?.className),
|
||||
).toContain(`theme-${theme}`);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(tooltip).toHaveCount(0);
|
||||
await page.getByRole('button', { name: /FPS \d/ }).click();
|
||||
const popover = page.getByRole('dialog', { name: '性能详情' });
|
||||
await expectWithinViewport(popover, width, height);
|
||||
await page.screenshot({
|
||||
path: test.info().outputPath(`performance-${theme}-${width}.png`),
|
||||
fullPage: true,
|
||||
});
|
||||
await page.setViewportSize({ width: width - 40, height: height - 40 });
|
||||
await expectWithinViewport(popover, width - 40, height - 40);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(popover).toHaveCount(0);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test('边缘 hover 提示可移入,disabled 控件说明可经键盘访问', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page.goto('/');
|
||||
const theme = page.getByRole('button', { name: '打开命令面板' });
|
||||
await theme.hover();
|
||||
const tooltip = page.getByRole('tooltip');
|
||||
await expectWithinViewport(tooltip, 1440, 900);
|
||||
await tooltip.hover();
|
||||
await expect(tooltip).toBeVisible();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(tooltip).toHaveCount(0);
|
||||
const step = page.getByRole('button', { name: '单步', exact: true });
|
||||
await expect(step).toBeDisabled();
|
||||
await step.locator('..').focus();
|
||||
await expect(tooltip).toHaveText('单步(暂停时可用)');
|
||||
expect(await theme.evaluate((node) => node.parentElement?.hasAttribute('tabindex'))).toBe(false);
|
||||
});
|
||||
|
||||
test('全屏中的对话框 Tooltip 不裁剪且 Escape 仅关闭顶层', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: '更多工作台操作' }).click();
|
||||
await page.getByRole('menuitem', { name: '进入全屏' }).click();
|
||||
await page.getByRole('button', { name: '更多工作台操作' }).click();
|
||||
await page.getByRole('menuitem', { name: '工作台设置' }).click();
|
||||
const dialog = page.getByRole('dialog', { name: '工作台设置' });
|
||||
await expect(dialog).toBeVisible();
|
||||
const close = dialog.getByRole('button', { name: '关闭', exact: true });
|
||||
await close.focus();
|
||||
const tooltip = page.getByRole('tooltip');
|
||||
await expectWithinViewport(tooltip, 1440, 900);
|
||||
expect(await tooltip.evaluate((node) => document.fullscreenElement?.contains(node))).toBe(true);
|
||||
expect(await tooltip.evaluate((node) => Boolean(node.closest('[role="dialog"]')))).toBe(false);
|
||||
await page.screenshot({
|
||||
path: test.info().outputPath('fullscreen-dialog-tooltip.png'),
|
||||
fullPage: true,
|
||||
});
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(tooltip).toHaveCount(0);
|
||||
await expect(dialog).toBeVisible();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await page.getByRole('button', { name: '更多工作台操作' }).click();
|
||||
await page.getByRole('menuitem', { name: '退出全屏' }).click();
|
||||
});
|
||||
@@ -0,0 +1,354 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
import { zipSync } from 'fflate';
|
||||
|
||||
const MODEL = `<mujoco model="layout-camera"><worldbody>
|
||||
<light pos="0 0 3"/><geom type="plane" size="3 3 .1"/>
|
||||
<camera pos="3 -3 2" xyaxes="1 1 0 -1 1 3"/>
|
||||
<body name="box" pos="0 0 1"><joint name="slide" type="slide" axis="1 0 0" range="-1 1"/>
|
||||
<geom type="box" size=".2 .2 .2" mass="1"/></body>
|
||||
</worldbody></mujoco>`;
|
||||
async function importModel(page: Page) {
|
||||
await page
|
||||
.locator('#mujoco-project-files')
|
||||
.setInputFiles({ name: 'layout.xml', mimeType: 'text/xml', buffer: Buffer.from(MODEL) });
|
||||
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 });
|
||||
}
|
||||
async function openLeft(page: Page) {
|
||||
const button = page.getByRole('button', { name: '显示工程面板', exact: true });
|
||||
if (await button.isVisible()) await button.click();
|
||||
}
|
||||
async function expectSlots(page: Page) {
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.locator('main').evaluate((main) => {
|
||||
const bounds = main.getBoundingClientRect();
|
||||
const slots = [...main.querySelectorAll<HTMLElement>('[data-overlay-slot]')]
|
||||
.map((node) => ({ name: node.dataset.overlaySlot, rect: node.getBoundingClientRect() }))
|
||||
.filter(({ rect }) => rect.width > 0 && rect.height > 0);
|
||||
const problems: string[] = [];
|
||||
for (const { name, rect } of slots) {
|
||||
if (
|
||||
rect.left < bounds.left - 1 ||
|
||||
rect.right > bounds.right + 1 ||
|
||||
rect.top < bounds.top - 1 ||
|
||||
rect.bottom > bounds.bottom + 1
|
||||
)
|
||||
problems.push(`越界 ${name}`);
|
||||
}
|
||||
for (let a = 0; a < slots.length; a++)
|
||||
for (let b = a + 1; b < slots.length; b++) {
|
||||
const x = slots[a],
|
||||
y = slots[b];
|
||||
if (
|
||||
Math.min(x.rect.right, y.rect.right) - Math.max(x.rect.left, y.rect.left) > 1 &&
|
||||
Math.min(x.rect.bottom, y.rect.bottom) - Math.max(x.rect.top, y.rect.top) > 1
|
||||
)
|
||||
problems.push(`重叠 ${x.name}/${y.name}`);
|
||||
}
|
||||
if (document.documentElement.scrollWidth > window.innerWidth) problems.push('页面横向溢出');
|
||||
return problems;
|
||||
}),
|
||||
)
|
||||
.toEqual([]);
|
||||
}
|
||||
|
||||
for (const theme of ['dark', 'light'])
|
||||
for (const [width, height] of [
|
||||
[1920, 1080],
|
||||
[1440, 900],
|
||||
[1366, 768],
|
||||
[1024, 768],
|
||||
[768, 800],
|
||||
]) {
|
||||
test(`工作台槽位 ${theme} ${width}×${height} 摄像头/草稿/通知`, async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
await page.setViewportSize({ width, height });
|
||||
await page.addInitScript(
|
||||
(value) => localStorage.setItem('mujoco-platform-theme', value),
|
||||
theme,
|
||||
);
|
||||
await page.goto('/');
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: width >= 1440 ? '隐藏工程面板' : '显示工程面板',
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: width >= 1024 ? '隐藏右侧面板' : '显示右侧面板',
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(page.locator('header')).not.toContainText('播放');
|
||||
await expect(page.getByLabel('地图草稿状态')).toHaveCount(0);
|
||||
await expectSlots(page);
|
||||
await page.screenshot({ path: test.info().outputPath(`empty-${theme}-${width}.png`) });
|
||||
await importModel(page);
|
||||
await openLeft(page);
|
||||
await page.getByLabel('地图资产库').getByRole('button', { name: '添加基础方盒' }).click();
|
||||
await expect(page.getByLabel('地图草稿状态')).toContainText('1 项未保存改动');
|
||||
await expect(page.getByLabel('摄像头画面')).toBeVisible();
|
||||
if (width < 1024)
|
||||
await page.getByRole('button', { name: '隐藏右侧面板', exact: true }).click();
|
||||
await expect(page.locator('[data-overlay-slot="notices"]')).toContainText('模型加载完成');
|
||||
await expectSlots(page);
|
||||
if (width >= 1024)
|
||||
expect((await page.getByRole('main').boundingBox())!.width).toBeGreaterThanOrEqual(480);
|
||||
await page.screenshot({
|
||||
path: test.info().outputPath(`draft-camera-${theme}-${width}.png`),
|
||||
mask: [page.getByLabel('视口状态')],
|
||||
});
|
||||
await page.getByRole('button', { name: '隐藏画面' }).click();
|
||||
await expect(page.getByLabel('摄像头画面')).toHaveCount(0);
|
||||
await page.getByRole('button', { name: '显示摄像头画面' }).click();
|
||||
await expectSlots(page);
|
||||
// 摄像头与草稿占位时,浮层仍须脱离侧栏裁剪并保持主题和键盘关闭。
|
||||
const performance = page.getByRole('button', { name: /FPS \d/ });
|
||||
await performance.click();
|
||||
const popover = page.getByRole('dialog', { name: '性能详情' });
|
||||
await expect(popover).toBeVisible();
|
||||
expect(
|
||||
await popover.evaluate((node) => {
|
||||
const rect = node.getBoundingClientRect();
|
||||
return (
|
||||
rect.left >= 7 &&
|
||||
rect.top >= 7 &&
|
||||
rect.right <= innerWidth - 7 &&
|
||||
rect.bottom <= innerHeight - 7
|
||||
);
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
await popover.evaluate((node) => node.closest('.theme-dark, .theme-light')?.className),
|
||||
).toContain(`theme-${theme}`);
|
||||
await page.screenshot({
|
||||
path: test.info().outputPath(`draft-camera-popover-${theme}-${width}.png`),
|
||||
mask: [page.getByLabel('视口状态')],
|
||||
});
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(popover).toHaveCount(0);
|
||||
await expect(performance).toBeFocused();
|
||||
if (width === 1024 || width === 768) {
|
||||
await page.getByRole('button', { name: '更多工作台操作' }).click();
|
||||
await page.getByRole('menuitem', { name: '进入全屏' }).click();
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => Boolean(document.fullscreenElement)))
|
||||
.toBe(true);
|
||||
await expectSlots(page);
|
||||
await page.screenshot({
|
||||
path: test.info().outputPath('fullscreen-draft-camera.png'),
|
||||
mask: [page.getByLabel('视口状态')],
|
||||
});
|
||||
await page.getByRole('button', { name: '更多工作台操作' }).click();
|
||||
await page.getByRole('menuitem', { name: '工作台设置' }).click();
|
||||
const settings = page.getByRole('dialog', { name: '工作台设置' });
|
||||
await settings.getByRole('button', { name: '关闭', exact: true }).focus();
|
||||
const tooltip = page.getByRole('tooltip');
|
||||
await expect(tooltip).toBeVisible();
|
||||
expect(
|
||||
await tooltip.evaluate((node) => {
|
||||
const rect = node.getBoundingClientRect();
|
||||
return (
|
||||
Boolean(document.fullscreenElement?.contains(node)) &&
|
||||
rect.left >= 7 &&
|
||||
rect.top >= 7 &&
|
||||
rect.right <= innerWidth - 7 &&
|
||||
rect.bottom <= innerHeight - 7
|
||||
);
|
||||
}),
|
||||
).toBe(true);
|
||||
await page.screenshot({
|
||||
path: test.info().outputPath(`fullscreen-dialog-camera-${theme}-${width}.png`),
|
||||
mask: [page.getByLabel('视口状态')],
|
||||
});
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(tooltip).toHaveCount(0);
|
||||
await expect(settings).toBeVisible();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(settings).toHaveCount(0);
|
||||
await expect(page.getByLabel('地图草稿状态')).toContainText('未保存改动');
|
||||
await expect(page.getByLabel('摄像头画面')).toBeVisible();
|
||||
await expectSlots(page);
|
||||
await page.getByRole('button', { name: '更多工作台操作' }).click();
|
||||
await page.getByRole('menuitem', { name: '退出全屏' }).click();
|
||||
}
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
test('导入→大纲选择→键盘编辑关节→运行,侧栏切换保留输入与草稿', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page.goto('/');
|
||||
await importModel(page);
|
||||
await page.getByRole('treeitem', { name: /slide/ }).click();
|
||||
const joint = page.getByRole('slider', { name: /slide/ });
|
||||
await joint.focus();
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await expect(joint).not.toHaveValue('0');
|
||||
const value = await joint.inputValue();
|
||||
await page.getByRole('button', { name: '隐藏右侧面板', exact: true }).click();
|
||||
await page.getByRole('button', { name: '显示右侧面板', exact: true }).click();
|
||||
await expect(joint).toHaveValue(value);
|
||||
await page.getByRole('button', { name: '隐藏右侧面板', exact: true }).blur();
|
||||
await page.keyboard.press('Space');
|
||||
await expect(page.getByRole('button', { name: '⏸ 暂停' })).toBeEnabled();
|
||||
await expect(page.getByLabel('视口状态')).not.toContainText('时间 0.000 s');
|
||||
await page.keyboard.press('Space');
|
||||
await expect(page.getByLabel('视口状态')).toContainText('已暂停');
|
||||
await page.getByLabel('地图资产库').getByRole('button', { name: '添加基础方盒' }).click();
|
||||
await page.getByLabel('对象位置X').fill('2');
|
||||
await page.getByLabel('对象位置X').blur();
|
||||
await page.getByRole('button', { name: '隐藏右侧面板', exact: true }).click();
|
||||
await page.getByRole('button', { name: '显示右侧面板', exact: true }).click();
|
||||
await expect(page.getByLabel('对象位置X')).toHaveValue('2');
|
||||
await expect(page.getByLabel('地图草稿状态')).toContainText('未保存改动');
|
||||
await page.keyboard.press('Control+s');
|
||||
await expect(page.getByLabel('地图草稿状态')).toBeHidden({ timeout: 30_000 });
|
||||
await page
|
||||
.getByLabel('地图对象列表')
|
||||
.getByRole('button', { name: /基础方盒/ })
|
||||
.click();
|
||||
await page.getByLabel('对象位置X').fill('3');
|
||||
await page.getByLabel('地图草稿状态').getByRole('button', { name: '丢弃地图草稿' }).click();
|
||||
await page
|
||||
.getByLabel('地图对象列表')
|
||||
.getByRole('button', { name: /基础方盒/ })
|
||||
.click();
|
||||
await expect(page.getByLabel('对象位置X')).toHaveValue('2');
|
||||
await expect(page.getByLabel('地图草稿状态')).toBeHidden();
|
||||
await page.screenshot({
|
||||
path: test.info().outputPath('closed-loop-applied.png'),
|
||||
mask: [page.getByLabel('视口状态')],
|
||||
});
|
||||
});
|
||||
|
||||
test('宽度预算、键盘调整与窄屏临时折叠不覆盖桌面偏好', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('mujoco-platform-layout', '{"left":true,"right":true}');
|
||||
localStorage.setItem('mujoco-left-sidebar-width', '500');
|
||||
localStorage.setItem('mujoco-right-sidebar-width', '500');
|
||||
});
|
||||
await page.goto('/');
|
||||
expect((await page.getByRole('main').boundingBox())!.width).toBeGreaterThanOrEqual(480);
|
||||
await page.setViewportSize({ width: 1024, height: 768 });
|
||||
await expect
|
||||
.poll(async () => (await page.getByRole('main').boundingBox())!.width)
|
||||
.toBeGreaterThanOrEqual(480);
|
||||
await page.setViewportSize({ width: 768, height: 800 });
|
||||
await expect(page.getByRole('button', { name: '显示工程面板', exact: true })).toBeVisible();
|
||||
await page.getByRole('button', { name: '显示工程面板', exact: true }).click();
|
||||
await page.getByRole('button', { name: '显示右侧面板', exact: true }).click();
|
||||
await expect(page.getByRole('button', { name: '显示工程面板', exact: true })).toBeVisible();
|
||||
expect(await page.evaluate(() => localStorage.getItem('mujoco-left-sidebar-width'))).toBe('500');
|
||||
expect(await page.evaluate(() => localStorage.getItem('mujoco-platform-layout'))).toBe(
|
||||
'{"left":true,"right":true}',
|
||||
);
|
||||
await page.setViewportSize({ width: 1920, height: 1080 });
|
||||
const left = page.getByRole('separator', { name: '调整工程面板宽度' });
|
||||
await expect.poll(async () => (await left.locator('..').boundingBox())!.width).toBe(500);
|
||||
await left.focus();
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
expect(await page.evaluate(() => localStorage.getItem('mujoco-left-sidebar-width'))).toBe('484');
|
||||
});
|
||||
|
||||
test('编译失败时摄像头、风险摘要与重试/放弃入口不重叠', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1024, height: 768 });
|
||||
await page.goto('/');
|
||||
const project = zipSync({
|
||||
'model.xml': Buffer.from(MODEL),
|
||||
'maps/invalid/map.json': Buffer.from(
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
id: 'invalid',
|
||||
name: '错误地图',
|
||||
coordinateSystem: { units: 'm', up: 'Z', forward: '+X' },
|
||||
physics: { source: 'world.xml' },
|
||||
spawnPoints: [],
|
||||
}),
|
||||
),
|
||||
'maps/invalid/world.xml': Buffer.from(
|
||||
'<mujoco><worldbody><body><joint/><geom type="box" size="1 1 1"/></body></worldbody></mujoco>',
|
||||
),
|
||||
});
|
||||
await page.locator('#mujoco-project-files').setInputFiles({
|
||||
name: 'invalid.zip',
|
||||
mimeType: 'application/zip',
|
||||
buffer: Buffer.from(project),
|
||||
});
|
||||
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled();
|
||||
await openLeft(page);
|
||||
await page
|
||||
.getByLabel('地图资产库')
|
||||
.getByRole('button', { name: '放置工程地图 错误地图' })
|
||||
.click();
|
||||
await page.getByLabel('地图草稿状态').getByRole('button', { name: '提交地图草稿' }).click();
|
||||
await expect(page.getByRole('alert')).toContainText('模型编译失败');
|
||||
await expect(page.getByLabel('地图草稿状态')).toContainText('应用失败');
|
||||
await expect(page.getByLabel('摄像头画面')).toBeVisible();
|
||||
await expectSlots(page);
|
||||
await page.getByRole('button', { name: '技术详情', exact: true }).click();
|
||||
await expectSlots(page);
|
||||
await page.screenshot({ path: test.info().outputPath('camera-draft-error-1024.png') });
|
||||
await page.getByRole('button', { name: '关闭错误' }).click();
|
||||
await expect(page.getByLabel('地图草稿状态')).toContainText('应用失败');
|
||||
await page.getByLabel('地图草稿状态').getByRole('button', { name: '丢弃地图草稿' }).click();
|
||||
await expect(page.getByLabel('地图草稿状态')).toBeHidden();
|
||||
});
|
||||
|
||||
for (const theme of ['dark', 'light']) {
|
||||
test(`主动作实际对比度与减少动效 ${theme}`, async ({ page }) => {
|
||||
await page.setViewportSize({ width: 768, height: 800 });
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await page.addInitScript(
|
||||
(value) => localStorage.setItem('mujoco-platform-theme', value),
|
||||
theme,
|
||||
);
|
||||
await page.goto('/');
|
||||
const primary = page.getByRole('button', { name: '选择文件', exact: true });
|
||||
await expect(primary).toBeVisible();
|
||||
const contrast = () =>
|
||||
primary.evaluate((node) => {
|
||||
const style = getComputedStyle(node);
|
||||
const luminance = (color: string) => {
|
||||
const values = color
|
||||
.match(/[\d.]+/g)!
|
||||
.slice(0, 3)
|
||||
.map(Number)
|
||||
.map((value) => {
|
||||
const channel = value / 255;
|
||||
return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
return values[0] * 0.2126 + values[1] * 0.7152 + values[2] * 0.0722;
|
||||
};
|
||||
const fg = luminance(style.color),
|
||||
bg = luminance(style.backgroundColor);
|
||||
return (Math.max(fg, bg) + 0.05) / (Math.min(fg, bg) + 0.05);
|
||||
});
|
||||
await expect.poll(contrast).toBeGreaterThanOrEqual(4.5);
|
||||
await primary.hover();
|
||||
await expect.poll(contrast).toBeGreaterThanOrEqual(4.5);
|
||||
await primary.focus();
|
||||
expect(
|
||||
await primary.evaluate((node) =>
|
||||
Number.parseFloat(getComputedStyle(node).transitionDuration),
|
||||
),
|
||||
).toBeLessThanOrEqual(0.00001);
|
||||
await importModel(page);
|
||||
await openLeft(page);
|
||||
await page.getByLabel('地图资产库').getByRole('button', { name: '添加基础方盒' }).click();
|
||||
await page.getByRole('button', { name: '隐藏右侧面板', exact: true }).click();
|
||||
await expectSlots(page);
|
||||
expect(
|
||||
await page
|
||||
.locator('.draft-dirty-dot')
|
||||
.evaluate((node) => Number.parseFloat(getComputedStyle(node).animationDuration)),
|
||||
).toBeLessThanOrEqual(0.00001);
|
||||
await page.screenshot({
|
||||
path: test.info().outputPath(`reduced-motion-${theme}.png`),
|
||||
mask: [page.getByLabel('视口状态')],
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
import { tuningFixture } from './tuningFixture';
|
||||
|
||||
const sizes = [
|
||||
[1920, 1080],
|
||||
[1440, 900],
|
||||
[1366, 768],
|
||||
[1024, 768],
|
||||
[768, 800],
|
||||
] as const;
|
||||
async function noOverflow(page: Page) {
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
|
||||
const overflowing = await page
|
||||
.locator('#root')
|
||||
.evaluate((root) =>
|
||||
[...root.querySelectorAll<HTMLElement>('main, nav, header, [role="dialog"]')]
|
||||
.filter(
|
||||
(el) => el.getClientRects().length && el.getBoundingClientRect().right > innerWidth + 1,
|
||||
)
|
||||
.map((el) => el.tagName),
|
||||
);
|
||||
expect(overflowing).toEqual([]);
|
||||
}
|
||||
for (const theme of ['dark', 'light'] as const)
|
||||
for (const [width, height] of sizes) {
|
||||
test(`调参与系统 ${theme} ${width}`, async ({ page }, info) => {
|
||||
await page.setViewportSize({ width, height });
|
||||
await page.addInitScript(
|
||||
(value) => localStorage.setItem('mujoco-platform-theme', value),
|
||||
theme,
|
||||
);
|
||||
const errors: string[] = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
const session = tuningFixture();
|
||||
const actions: { method: string; path: string; body: unknown }[] = [];
|
||||
await page.route('**/api/tuning/**', async (route) => {
|
||||
const req = route.request(),
|
||||
path = new URL(req.url()).pathname;
|
||||
expect(req.headers().authorization).toBe('Bearer ui-mock-token');
|
||||
if (req.method() !== 'GET')
|
||||
actions.push({ method: req.method(), path, body: req.postDataJSON() });
|
||||
let body: unknown = session;
|
||||
if (path.endsWith('/capabilities'))
|
||||
body = { configured: true, ready: true, model: 'UI Mock Agent', pretrainedSources: [] };
|
||||
else if (path === '/api/tuning/sessions') body = { sessions: [session] };
|
||||
else if (path.endsWith('/metrics'))
|
||||
body = {
|
||||
series: [
|
||||
{
|
||||
tag: 'Train/mean_reward',
|
||||
points: Array.from({ length: 30 }, (_, step) => ({
|
||||
step,
|
||||
wallTime: step,
|
||||
value: step / 10 + Math.sin(step) / 4,
|
||||
})),
|
||||
},
|
||||
],
|
||||
};
|
||||
else if (path.endsWith('/approve')) {
|
||||
session.proposals[0].state = 'approved';
|
||||
session.state = 'paused';
|
||||
} else if (req.method() === 'DELETE') session.state = 'cancelled';
|
||||
await route.fulfill({ json: body });
|
||||
});
|
||||
await page.goto('/tuning.html');
|
||||
await expect(page.getByRole('heading', { name: 'Go2 自调参' })).toBeVisible();
|
||||
await expect(page.locator('#root > div')).toHaveClass(new RegExp(`theme-${theme}`));
|
||||
await noOverflow(page);
|
||||
await page.screenshot({ path: info.outputPath('tuning-empty.png') });
|
||||
await page.getByLabel('训练服务地址').fill('http://127.0.0.1:8765');
|
||||
await page.getByLabel('访问令牌(仅当前标签页)').fill('ui-mock-token');
|
||||
await page.getByRole('button', { name: '连接/刷新' }).click();
|
||||
await expect(page.getByRole('img', { name: /收敛曲线/ })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '停止 Session' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /决策与审批 · 待审批 1/ })).toBeVisible();
|
||||
await page.getByRole('button', { name: '收敛曲线放大', exact: true }).click();
|
||||
await page
|
||||
.getByRole('button', { name: `切换到${theme === 'dark' ? '白天' : '黑夜'}主题` })
|
||||
.click();
|
||||
await expect(page.locator('#root > div')).toHaveClass(
|
||||
new RegExp(`theme-${theme === 'dark' ? 'light' : 'dark'}`),
|
||||
);
|
||||
await page
|
||||
.getByRole('button', { name: `切换到${theme === 'dark' ? '黑夜' : '白天'}主题` })
|
||||
.click();
|
||||
await noOverflow(page);
|
||||
await page.screenshot({ path: info.outputPath('tuning-metrics.png') });
|
||||
await page.getByRole('heading', { name: 'Trial Leaderboard' }).scrollIntoViewIfNeeded();
|
||||
await noOverflow(page);
|
||||
await page.screenshot({ path: info.outputPath('tuning-leaderboard.png') });
|
||||
await page.getByRole('button', { name: '会话与 Trial' }).click();
|
||||
await expect(page.getByRole('button', { name: '导入', exact: true })).toBeVisible();
|
||||
await expect(page.getByText('导入会替换主工作台策略;无主窗口时下载文件。')).toBeVisible();
|
||||
await noOverflow(page);
|
||||
await page.screenshot({ path: info.outputPath('tuning-sessions.png') });
|
||||
await page.getByRole('button', { name: /决策与审批/ }).click();
|
||||
await page.getByRole('button', { name: 'Monaco Diff 审查' }).scrollIntoViewIfNeeded();
|
||||
await noOverflow(page);
|
||||
await page.screenshot({ path: info.outputPath('tuning-decisions.png') });
|
||||
await page.getByRole('button', { name: 'Monaco Diff 审查' }).click();
|
||||
const review = page.getByRole('dialog', { name: /Reward Merge Patch 审查/ });
|
||||
await expect(review.locator('.monaco-diff-editor')).toBeVisible();
|
||||
await expect(
|
||||
review.locator(theme === 'light' ? '.monaco-editor.vs' : '.monaco-editor.vs-dark').first(),
|
||||
).toBeVisible();
|
||||
await review
|
||||
.locator('.monaco-editor')
|
||||
.last()
|
||||
.click({ position: { x: 180, y: 80 } });
|
||||
await page.keyboard.press('Control+Home');
|
||||
await page.keyboard.press('ArrowDown');
|
||||
await page.keyboard.press('ArrowDown');
|
||||
await page.keyboard.press('Home');
|
||||
await page.keyboard.press('Shift+End');
|
||||
await page.keyboard.insertText(' "pose": 1.2');
|
||||
await page.evaluate(
|
||||
(value) => {
|
||||
localStorage.setItem('mujoco-platform-theme', value);
|
||||
window.dispatchEvent(new StorageEvent('storage', { key: 'mujoco-platform-theme' }));
|
||||
},
|
||||
theme === 'light' ? 'dark' : 'light',
|
||||
);
|
||||
await expect(
|
||||
review.locator(theme === 'light' ? '.monaco-editor.vs-dark' : '.monaco-editor.vs').last(),
|
||||
).toBeVisible();
|
||||
await page.evaluate((value) => {
|
||||
localStorage.setItem('mujoco-platform-theme', value);
|
||||
window.dispatchEvent(new StorageEvent('storage', { key: 'mujoco-platform-theme' }));
|
||||
}, theme);
|
||||
await expect(review.locator('.monaco-editor').last()).toContainText('1.2');
|
||||
await page.getByLabel('Proposal 审批反馈').fill('界面验收,不启动真实训练');
|
||||
await noOverflow(page);
|
||||
await page.screenshot({ path: info.outputPath('tuning-review.png') });
|
||||
await page.getByRole('button', { name: '批准编辑后的 Patch' }).click();
|
||||
await expect(review).toBeHidden();
|
||||
expect(actions.find((item) => item.path.endsWith('/approve'))?.body).toEqual({
|
||||
feedback: '界面验收,不启动真实训练',
|
||||
patch: { weights: { pose: 1.2 }, params: {} },
|
||||
});
|
||||
await page.getByRole('button', { name: '参数护栏' }).click();
|
||||
await expect(page.getByRole('dialog', { name: /参数安全护栏/ })).toBeVisible();
|
||||
await noOverflow(page);
|
||||
await page.screenshot({ path: info.outputPath('tuning-constraints.png') });
|
||||
await page.keyboard.press('Escape');
|
||||
await page.getByRole('button', { name: '停止 Session' }).click();
|
||||
expect(actions.filter((item) => item.method === 'DELETE')).toHaveLength(1);
|
||||
expect(
|
||||
actions.filter((item) => item.path === '/api/tuning/sessions' && item.method === 'POST'),
|
||||
).toHaveLength(0);
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('region', { name: '导入模型工程' })).toBeVisible();
|
||||
await page.getByRole('button', { name: '更多工作台操作' }).click();
|
||||
await page.getByRole('menuitem', { name: '工作台设置' }).click();
|
||||
await expect(page.getByRole('dialog', { name: '工作台设置' })).toBeVisible();
|
||||
await noOverflow(page);
|
||||
await page.screenshot({ path: info.outputPath('settings.png') });
|
||||
await page.keyboard.press('Escape');
|
||||
await page.getByRole('button', { name: '布局设置' }).click();
|
||||
await page.screenshot({ path: info.outputPath('layout.png') });
|
||||
await page.keyboard.press('Escape');
|
||||
await page.keyboard.press('Control+k');
|
||||
await page.getByLabel('搜索命令').fill('快捷');
|
||||
await expect(page.getByLabel('搜索命令')).toBeFocused();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(page.getByRole('dialog', { name: '快捷键与视口操作' })).toBeVisible();
|
||||
await page.screenshot({ path: info.outputPath('help.png') });
|
||||
await page.keyboard.press('Escape');
|
||||
await page.keyboard.press('Control+k');
|
||||
await page.getByLabel('搜索命令').fill('无匹配命令');
|
||||
await expect(page.getByText('没有匹配的命令')).toBeVisible();
|
||||
await page.screenshot({ path: info.outputPath('command-empty.png') });
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
for (const theme of ['dark', 'light'] as const)
|
||||
test(`源码与导入风险 ${theme}`, async ({ page }, info) => {
|
||||
await page.setViewportSize({ width: 768, height: 800 });
|
||||
await page.addInitScript(
|
||||
(value) => localStorage.setItem('mujoco-platform-theme', value),
|
||||
theme,
|
||||
);
|
||||
const errors: string[] = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
await page.goto('/');
|
||||
await page.locator('#mujoco-project-files').setInputFiles({
|
||||
name: 'model.urdf',
|
||||
mimeType: 'text/xml',
|
||||
buffer: Buffer.from(
|
||||
'<robot name="jointed"><link name="base"><inertial><mass value="1"/><origin xyz="0 0 0"/><inertia ixx=".1" iyy=".1" izz=".1" ixy="0" ixz="0" iyz="0"/></inertial><visual><geometry><box size=".4 .4 .2"/></geometry></visual></link><link name="arm"><inertial><mass value=".2"/><origin xyz="0 0 .25"/><inertia ixx=".01" iyy=".01" izz=".01" ixy="0" ixz="0" iyz="0"/></inertial><visual><origin xyz="0 0 .25"/><geometry><box size=".1 .1 .5"/></geometry></visual></link><joint name="shoulder" type="revolute"><parent link="base"/><child link="arm"/><origin xyz="0 0 .1"/><axis xyz="0 1 0"/><limit lower="-1" upper="1" effort="10" velocity="2"/></joint></robot>',
|
||||
),
|
||||
});
|
||||
const urdf = page.getByRole('dialog', { name: '配置 URDF 仿真组件' });
|
||||
await expect(urdf.getByText(/生成不限幅 motor 控制输入/)).toBeVisible();
|
||||
await page.screenshot({ path: info.outputPath('urdf-options.png') });
|
||||
await urdf.getByRole('button', { name: '转换并加载' }).click();
|
||||
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled();
|
||||
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 page.screenshot({ path: info.outputPath('notifications.png') });
|
||||
await page.getByRole('button', { name: '事件日志', exact: true }).click();
|
||||
await expect(page.getByRole('dialog', { name: '诊断与事件日志' })).toBeVisible();
|
||||
await page.screenshot({ path: info.outputPath('diagnostics.png') });
|
||||
await page.keyboard.press('Escape');
|
||||
await page.getByRole('button', { name: '工程', exact: true }).click();
|
||||
await page.getByRole('button', { name: '源代码', exact: true }).click();
|
||||
const source = page.getByRole('dialog', { name: '转换后的 MJCF 编辑器' });
|
||||
await expect(source.locator('.monaco-editor')).toBeVisible();
|
||||
await noOverflow(page);
|
||||
await page.screenshot({ path: info.outputPath('source.png') });
|
||||
await source.locator('.monaco-editor').click({ position: { x: 200, y: 100 } });
|
||||
await page.keyboard.press('Control+End');
|
||||
await page.keyboard.insertText('\n');
|
||||
await expect(source.getByText('已修改', { exact: true })).toBeVisible();
|
||||
await page.keyboard.press('Escape');
|
||||
const confirm = page.getByRole('dialog', { name: '放弃未保存的修改?' });
|
||||
await expect(confirm.getByText(/无法恢复/)).toBeVisible();
|
||||
await page.screenshot({ path: info.outputPath('confirm.png') });
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(confirm).toBeHidden();
|
||||
await expect(source).toBeVisible();
|
||||
await source.getByRole('button', { name: '关闭源代码编辑器' }).click();
|
||||
await confirm.getByRole('button', { name: '放弃修改' }).click();
|
||||
await expect(source).toBeHidden();
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test('主工作台与独立调参标签页双向同步主题', async ({ page, context }) => {
|
||||
await page.goto('/');
|
||||
const tuning = await context.newPage();
|
||||
await tuning.goto('/tuning.html');
|
||||
await page.getByRole('button', { name: '更多工作台操作' }).click();
|
||||
await page.getByRole('menuitem', { name: '切换主题' }).click();
|
||||
await expect(tuning.locator('#root > div')).toHaveClass(/theme-light/);
|
||||
await tuning.getByRole('button', { name: '切换到黑夜主题' }).click();
|
||||
await expect(page.locator('#root > div')).toHaveClass(/theme-dark/);
|
||||
await tuning.close();
|
||||
});
|
||||
|
||||
for (const theme of ['dark', 'light'] as const)
|
||||
test(`连接错误与入口选择 ${theme}`, async ({ page }, info) => {
|
||||
await page.setViewportSize({ width: 768, height: 800 });
|
||||
await page.addInitScript(
|
||||
(value) => localStorage.setItem('mujoco-platform-theme', value),
|
||||
theme,
|
||||
);
|
||||
await page.route('**/api/tuning/**', (route) =>
|
||||
route.fulfill({ status: 503, json: { error: '训练服务未就绪,请检查本地服务' } }),
|
||||
);
|
||||
await page.goto('/tuning.html');
|
||||
await page.getByLabel('访问令牌(仅当前标签页)').fill('ui-mock-token');
|
||||
await page.getByRole('button', { name: '连接/刷新' }).click();
|
||||
await expect(page.getByRole('alert')).toBeInViewport();
|
||||
await expect(page.getByRole('button', { name: '关闭错误' })).toBeVisible();
|
||||
await page.screenshot({ path: info.outputPath('tuning-error.png') });
|
||||
await page.goto('/');
|
||||
await page.locator('#mujoco-project-files').setInputFiles(
|
||||
['first.xml', 'second.xml'].map((name) => ({
|
||||
name,
|
||||
mimeType: 'text/xml',
|
||||
buffer: Buffer.from(
|
||||
'<mujoco><worldbody><geom type="sphere" size=".1"/></worldbody></mujoco>',
|
||||
),
|
||||
})),
|
||||
);
|
||||
const entries = page.getByRole('dialog', { name: '选择模型入口' });
|
||||
await expect(entries).toBeVisible();
|
||||
await page.screenshot({ path: info.outputPath('entry-selection.png') });
|
||||
await entries.getByRole('button', { name: /first.xml/ }).click();
|
||||
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled();
|
||||
await page.locator('#mujoco-project-files').setInputFiles({
|
||||
name: 'bad.xml',
|
||||
mimeType: 'text/xml',
|
||||
buffer: Buffer.from(
|
||||
'<mujoco><worldbody><geom type="sphere" size="-1"/></worldbody></mujoco>',
|
||||
),
|
||||
});
|
||||
await expect(page.getByRole('alert')).toContainText('模型编译失败');
|
||||
await page.getByRole('button', { name: '技术详情', exact: true }).click();
|
||||
await noOverflow(page);
|
||||
await page.screenshot({ path: info.outputPath('compile-error.png') });
|
||||
});
|
||||
Reference in New Issue
Block a user