import { expect, test, type Page } from '@playwright/test';
import { zipSync } from 'fflate';
const MODEL = `
`;
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('[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(
'',
),
});
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('视口状态')],
});
});
}