Files
Mujoco_WASM/web_platform/e2e/ui-layout.spec.ts
T
chenlin 13e35be98b
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled
feat(web-platform): release V0.9.3 全模块界面重构
2026-09-08 17:57:57 +08:00

355 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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('视口状态')],
});
});
}