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('视口状态')], }); }); }