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('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(); const sessions = page.getByRole('region', { name: '会话与 Trial' }); const metrics = page.getByRole('main', { name: '指标与排行' }); const decisions = page.getByRole('complementary', { name: '决策与审批' }); await expect(sessions).toBeVisible(); await expect(metrics).toBeVisible(); await expect(decisions).toBeVisible(); const left = (await sessions.boundingBox())!; const center = (await metrics.boundingBox())!; const right = (await decisions.boundingBox())!; if (width >= 1280) { expect(Math.abs(left.y - center.y)).toBeLessThan(2); expect(Math.abs(center.y - right.y)).toBeLessThan(2); expect(left.x + left.width).toBeLessThanOrEqual(center.x + 1); expect(center.x + center.width).toBeLessThanOrEqual(right.x + 1); } else { expect(left.y + left.height).toBeLessThanOrEqual(center.y + 1); expect(center.y + center.height).toBeLessThanOrEqual(right.y + 1); } 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: '导入', exact: true }).scrollIntoViewIfNeeded(); 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: '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( '', ), }); 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(); 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( '', ), })), ); 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( '', ), }); 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') }); });