355 lines
16 KiB
TypeScript
355 lines
16 KiB
TypeScript
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('视口状态')],
|
||
});
|
||
});
|
||
}
|