import { expect, test, type Page } from '@playwright/test'; import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { zipSync } from 'fflate'; const fixture = (relative: string) => fileURLToPath(new URL(`../fixtures/${relative}`, import.meta.url)); async function openProjectPanel(page: Page) { const show = page.getByRole('button', { name: '显示工程面板', exact: true }); if (await show.isVisible()) await show.click(); } async function expectImportedFiles(page: Page, names: string[]) { await openProjectPanel(page); await page.getByRole('tab', { name: '工程文件', exact: true }).click(); const tree = page.getByLabel('工程文件树', { exact: true }); for (const name of names) await expect(tree.getByText(name, { exact: true })).toBeVisible(); await page.screenshot({ path: test.info().outputPath('imported-project-files.png') }); } async function expectNotificationDetails( page: Page, messages: RegExp[], absentMessages: RegExp[] = [], ) { 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(); for (const message of messages) await expect(notifications).toContainText(message); for (const message of absentMessages) await expect(notifications).not.toContainText(message); await page.keyboard.press('Escape'); } const SIMPLE_MODEL = ` `; const SLIDE_DIRECTION_MODEL = ``; function minimalGlb(): Buffer { const json = Buffer.from( JSON.stringify({ asset: { version: '2.0' }, scene: 0, scenes: [{ nodes: [0] }], nodes: [{ mesh: 0 }], meshes: [{ primitives: [{ attributes: { POSITION: 0 } }] }], buffers: [{ byteLength: 36 }], bufferViews: [{ buffer: 0, byteOffset: 0, byteLength: 36, target: 34962 }], accessors: [ { bufferView: 0, componentType: 5126, count: 3, type: 'VEC3', min: [0, 0, 0], max: [1, 1, 0], }, ], }), ); const jsonPadding = (4 - (json.length % 4)) % 4; const jsonChunk = Buffer.concat([json, Buffer.alloc(jsonPadding, 0x20)]); const positions = Buffer.from(new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]).buffer); const totalLength = 12 + 8 + jsonChunk.length + 8 + positions.length; const glb = Buffer.alloc(totalLength); glb.writeUInt32LE(0x46546c67, 0); glb.writeUInt32LE(2, 4); glb.writeUInt32LE(totalLength, 8); glb.writeUInt32LE(jsonChunk.length, 12); glb.writeUInt32LE(0x4e4f534a, 16); jsonChunk.copy(glb, 20); const binOffset = 20 + jsonChunk.length; glb.writeUInt32LE(positions.length, binOffset); glb.writeUInt32LE(0x004e4942, binOffset + 4); positions.copy(glb, binOffset + 8); return glb; } const LARGE_MODEL = ` `; test('独立自调参工作台不需要加载 MuJoCo 主应用即可打开', async ({ page }) => { await page.goto('/tuning.html'); await expect(page.getByRole('heading', { name: 'Go2 自调参' })).toBeVisible(); await expect(page.getByText('新建 Unitree-Go2-Flat 调参 Session')).toBeVisible(); await expect(page.getByLabel('访问令牌(仅当前标签页)')).toBeVisible(); await expect(page.getByRole('button', { name: '启动自调参' })).toBeVisible(); }); test('显示中文平台骨架并加载单文件模型', async ({ page }) => { page.on('console', (message) => console.log(`[browser:${message.type()}] ${message.text()}`)); page.on('pageerror', (error) => console.log(`[browser:error] ${error.message}`)); page.on('requestfailed', (request) => console.log(`[browser:requestfailed] ${request.url()} ${request.failure()?.errorText}`), ); page.on('response', (response) => { if (response.status() >= 400 || response.url().endsWith('.wasm')) console.log( `[browser:response] ${response.status()} ${response.url()} ${response.headers()['content-type'] ?? ''}`, ); }); await page.goto('/'); await page.setViewportSize({ width: 1024, height: 768 }); const resetCameraBox = await page.getByRole('button', { name: '相机复位' }).boundingBox(), playBox = await page.getByRole('button', { name: '▶ 播放' }).boundingBox(); expect( resetCameraBox && playBox && resetCameraBox.y + resetCameraBox.height < playBox.y, ).toBeTruthy(); await page.getByRole('button', { name: '更多工作台操作' }).click(); await expect(page.getByRole('menuitem', { name: '工作台设置' })).toBeVisible(); await page.keyboard.press('Escape'); await page.setViewportSize({ width: 1440, height: 900 }); await expect(page.getByRole('heading', { name: 'MuJoCo' })).toBeVisible(); await expect(page.getByRole('main').getByText('拖放模型工程到此处')).toBeVisible(); await expect(page.getByRole('img', { name: 'XYZ 方向指示器' })).toBeVisible(); await page.getByRole('button', { name: '更多工作台操作' }).click(); await expect(page.getByRole('menuitem', { name: '切换主题' })).toBeVisible(); await page.keyboard.press('Escape'); await page.getByRole('button', { name: '布局设置' }).click(); await expect(page.getByRole('dialog', { name: '布局设置' })).toBeVisible(); await page.keyboard.press('Escape'); await page.getByRole('button', { name: '更多工作台操作' }).click(); await page.getByRole('menuitem', { name: '工作台设置' }).click(); await expect(page.getByRole('dialog', { name: '工作台设置' })).toBeVisible(); await page.keyboard.press('Escape'); await page.keyboard.press('Control+k'); await expect(page.getByRole('dialog', { name: '命令面板' })).toBeVisible(); await page.getByLabel('搜索命令').fill('复位相机'); await expect(page.getByRole('option', { name: /复位相机/ })).toBeVisible(); await page.keyboard.press('Escape'); await page.getByRole('button', { name: '更多工作台操作' }).click(); await page.getByRole('menuitem', { name: '进入全屏' }).click(); await expect.poll(() => page.evaluate(() => Boolean(document.fullscreenElement))).toBe(true); await page.keyboard.press('Control+k'); await expect(page.getByRole('dialog', { name: '命令面板' })).toBeVisible(); await page.keyboard.press('Escape'); await page.getByRole('button', { name: '更多工作台操作' }).click(); await page.getByRole('menuitem', { name: '退出全屏' }).click(); await page.getByRole('button', { name: '更多工作台操作' }).click(); await page.getByRole('menuitem', { name: '切换主题' }).click(); await expect(page.locator('#root > div')).toHaveClass(/theme-light/); await expect(page.locator('#root > div')).toHaveClass(/theme-light/); await page.getByRole('button', { name: '更多工作台操作' }).click(); await page.getByRole('menuitem', { name: '切换主题' }).click(); await expect(page.locator('#root > div')).toHaveClass(/theme-dark/); await page .locator('input[type="file"]') .first() .setInputFiles({ name: 'model.xml', mimeType: 'text/xml', buffer: Buffer.from(SIMPLE_MODEL), }); await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 }); await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 }); await page.getByRole('button', { name: '显示设置' }).click(); const displayDialog = page.getByRole('dialog', { name: '视图显示设置' }); await expect(displayDialog).toBeVisible(); await expect(displayDialog.getByRole('switch')).toHaveCount(7); const centerOfMassSwitch = displayDialog.getByRole('switch', { name: /^质心/ }); await centerOfMassSwitch.click(); await expect(centerOfMassSwitch).toHaveAttribute('aria-checked', 'true'); await page.keyboard.press('Escape'); await expect(displayDialog).toBeHidden(); await page.getByRole('button', { name: '通知中心' }).click(); await expect(page.getByRole('dialog', { name: '通知中心' })).toContainText('模型加载完成'); await page.getByText('事件日志').click(); await expect(page.getByRole('dialog', { name: '诊断与事件日志' })).toBeVisible(); await page.keyboard.press('Escape'); await page.getByRole('button', { name: /FPS \d/ }).click(); await expect(page.getByRole('dialog', { name: '性能详情' })).toBeVisible(); await expect(page.getByRole('dialog', { name: '性能详情' })).toContainText('WASM已加载'); await page.keyboard.press('Escape'); await page.getByRole('tab', { name: '控制台' }).click(); const tools = page.getByRole('tabpanel', { name: '控制台' }); await expect(page.getByRole('dialog', { name: '工作区工具' })).toHaveCount(0); await expect(page.locator('main canvas')).toBeVisible(); await tools.getByRole('button', { name: /执行器实时控制/ }).click(); await expect(tools.getByText('motor', { exact: true })).toBeVisible(); await expect(tools.getByText('关节:slide', { exact: true })).toBeVisible(); await page.getByRole('tab', { name: '数据录制' }).click(); await expect(page.getByRole('tabpanel', { name: '数据录制' })).toContainText('仅当前会话保存'); await expect(page.locator('main canvas')).toBeVisible(); await page.getByRole('tab', { name: '检查器' }).click(); const structure = page.getByRole('navigation', { name: '模型结构树' }); await expect(structure).toBeVisible(); const hinge = structure.getByRole('treeitem', { name: /hinge/ }); await hinge.hover(); await hinge.click(); await expect(page.getByRole('alert')).toHaveCount(0); await expect(page.getByRole('button', { name: '重置关节' })).toBeVisible(); await page.getByRole('button', { name: '高级' }).click(); await expect(page.getByText('下限 -1.571 rad')).toBeVisible(); await expect(page.getByText('上限 1.571 rad')).toBeVisible(); await page.getByRole('button', { name: 'rad 弧度制' }).click(); await expect(page.getByText('下限 -90.000°')).toBeVisible(); await expect(page.getByText('上限 90.000°')).toBeVisible(); await page.getByRole('button', { name: '忽略关节限位' }).click(); await expect(page.getByRole('button', { name: '忽略关节限位' })).toHaveAttribute( 'aria-pressed', 'true', ); await expect(page.getByText('已忽略关节限位').first()).toBeVisible(); await page.getByRole('button', { name: '重置关节' }).click(); await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled(); }); test('窄视口默认保留完整视口并可按需打开侧栏', async ({ page }) => { await page.setViewportSize({ width: 800, height: 700 }); await page.goto('/'); await expect(page.getByRole('main')).toBeInViewport(); await expect(page.getByRole('button', { name: '显示工程面板' })).toBeVisible(); await expect(page.getByRole('button', { name: '显示右侧面板' })).toBeVisible(); await page.getByRole('button', { name: '显示右侧面板' }).click(); await expect(page.getByRole('complementary').filter({ hasText: '检查器待命' })).toBeVisible(); }); test('工作区布局与视口显示偏好在刷新后保留', async ({ page }) => { await page.setViewportSize({ width: 1440, height: 900 }); await page.goto('/'); await page.getByRole('button', { name: '隐藏工程面板' }).click(); await page.getByRole('button', { name: '显示设置' }).click(); await page.getByRole('switch', { name: /^坐标系/ }).click(); await page.reload(); await expect(page.getByRole('button', { name: '显示工程面板' })).toBeVisible(); await page.getByRole('button', { name: '显示设置' }).click(); await expect(page.getByRole('switch', { name: /^坐标系/ })).toHaveAttribute( 'aria-checked', 'true', ); }); test('转换后的 MJCF 可编辑并重新载入', async ({ page }) => { await page.goto('/'); await page .locator('input[type="file"]') .first() .setInputFiles({ name: 'model.xml', mimeType: 'text/xml', buffer: Buffer.from(SIMPLE_MODEL) }); await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 }); await page.getByRole('button', { name: '源代码' }).click(); const dialog = page.getByRole('dialog', { name: '转换后的 MJCF 编辑器' }); await expect(dialog).toBeVisible(); await expect(dialog.getByText('缓存文件 · 可编辑')).toBeVisible(); await expect(dialog.getByText('转换后的 MJCF', { exact: true })).toBeVisible(); const editor = dialog.locator('.monaco-editor'); await editor.click({ position: { x: 240, y: 120 } }); await page.keyboard.press('Control+a'); await page.keyboard.insertText(SIMPLE_MODEL.replace('model="e2e"', 'model="cached-edit"')); await dialog.getByRole('button', { name: '保存并重新载入', exact: true }).click(); await expect(dialog.getByRole('button', { name: '保存并重新载入', exact: true })).toBeDisabled({ timeout: 30_000, }); await dialog.getByRole('button', { name: '关闭源代码编辑器' }).click(); await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 }); await expect(page.getByRole('button', { name: '导出 URDF' })).toHaveCount(0); await expect(page.getByRole('button', { name: '导出 MJCF' })).toHaveCount(0); }); test('关闭已修改的 MJCF 前要求确认', async ({ page }) => { await page.goto('/'); await page .locator('input[type="file"]') .first() .setInputFiles({ name: 'model.xml', mimeType: 'text/xml', buffer: Buffer.from(SIMPLE_MODEL) }); await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 }); await page.getByRole('button', { name: '源代码' }).click(); const editorDialog = page.getByRole('dialog', { name: '转换后的 MJCF 编辑器' }); await editorDialog.locator('.monaco-editor').click({ position: { x: 240, y: 120 } }); await page.keyboard.press('Control+End'); await page.keyboard.insertText('\n'); await editorDialog.getByRole('button', { name: '关闭源代码编辑器' }).click(); const confirm = page.getByRole('dialog', { name: '放弃未保存的修改?' }); await expect(confirm).toBeVisible(); await confirm.getByRole('button', { name: '继续编辑' }).click(); await expect(editorDialog).toBeVisible(); await editorDialog.getByRole('button', { name: '关闭源代码编辑器' }).click(); await page .getByRole('dialog', { name: '放弃未保存的修改?' }) .getByRole('button', { name: '放弃修改' }) .click(); await expect(editorDialog).toHaveCount(0); }); test('加载包含 include、OBJ、STL 与 PNG 的工程', async ({ page }) => { await page.goto('/'); await page .locator('input[type="file"]') .first() .setInputFiles([ fixture('mjcf_include/model.xml'), fixture('mjcf_include/world.xml'), fixture('mjcf_include/triangle.obj'), fixture('mjcf_include/triangle.stl'), fixture('mjcf_include/checker.png'), ]); await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 }); await expectImportedFiles(page, [ 'model.xml', 'world.xml', 'triangle.obj', 'triangle.stl', 'checker.png', ]); }); test('加载引用 OBJ 的 URDF 工程', async ({ page }) => { await page.goto('/'); await page .locator('input[type="file"]') .first() .setInputFiles([fixture('urdf_mesh/robot.urdf'), fixture('urdf_mesh/triangle.obj')]); const options = page.getByRole('dialog', { name: '配置 URDF 仿真组件' }); await expect(options.getByRole('checkbox', { name: /为关节添加驱动器/ })).toBeChecked(); await expect(options.getByRole('checkbox', { name: /添加传感器/ })).toBeChecked(); await options.getByRole('button', { name: '转换并加载' }).click(); await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 }); await expectImportedFiles(page, ['robot.urdf', 'triangle.obj']); await expect(page.getByLabel('URDF 处理方式')).toHaveValue('mjcf'); await expect(page.getByLabel('URDF 基座类型')).toHaveValue('floating'); 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 expect(notifications).toContainText(/模型已加载 · \d+ 项兼容调整/); await expect(notifications).toContainText(/URDF 已转换为 MJCF(浮动基座),并整体平移/); await page.keyboard.press('Escape'); await expect(page.getByLabel('显示碰撞几何')).not.toBeChecked(); await page.getByRole('button', { name: '源代码' }).click(); const sourceDialog = page.getByRole('dialog', { name: '转换后的 MJCF 编辑器' }); await expect(sourceDialog.getByText('缓存文件 · 可编辑')).toBeVisible(); await sourceDialog.locator('.monaco-editor').click({ position: { x: 240, y: 120 } }); await page.keyboard.press('Control+End'); await page.keyboard.insertText('\n'); await sourceDialog.getByRole('button', { name: '保存并重新载入', exact: true }).click(); await expect( sourceDialog.getByRole('button', { name: '保存并重新载入', exact: true }), ).toBeDisabled({ timeout: 30_000 }); await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled(); await sourceDialog.getByRole('button', { name: '关闭源代码编辑器' }).click(); }); test('URDF 自动生成的关节驱动器与摄像头可通过 MuJoCo 编译', async ({ page }) => { const urdf = ``; await page.goto('/'); await page .locator('input[type="file"]') .first() .setInputFiles({ name: 'jointed.urdf', mimeType: 'application/xml', buffer: Buffer.from(urdf), }); await page .getByRole('dialog', { name: '配置 URDF 仿真组件' }) .getByRole('button', { name: '转换并加载' }) .click(); await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 }); await expect(page.getByLabel('摄像头画面')).toBeVisible(); await page.getByRole('button', { name: '隐藏画面' }).click(); await page.getByRole('button', { name: '显示摄像头画面' }).click(); await expect(page.getByLabel('摄像头画面')).toBeVisible(); 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 expect(notifications).toContainText('已为 1 个 hinge/slide 关节生成 motor 驱动器'); await expect(notifications).toContainText('已将 640×480 摄像头固连到 arm'); await page.keyboard.press('Escape'); await page.getByRole('tab', { name: '控制台' }).click(); await page .getByRole('tabpanel', { name: '控制台' }) .getByRole('button', { name: /执行器实时控制/ }) .click(); await expect(page.getByText('shoulder_motor')).toBeVisible(); await expect(page.getByText('关节:shoulder')).toBeVisible(); await expect(page.locator('output').filter({ hasText: 'N·m' })).toBeVisible(); await page.getByText('增益与输出限幅').click(); const kp = page.getByLabel(/kp(MJCF stiffness/), kv = page.getByLabel(/kv(MJCF damping/); await kp.fill('150'); await kp.press('Enter'); await kv.fill('15'); await kv.press('Enter'); await expect(kp).toHaveValue('150'); await expect(kv).toHaveValue('15'); }); test('转换后的 MJCF 保存时保留 DAE 转换缓存资源', async ({ page }) => { const zip = zipSync({ 'robot/urdf/robot.urdf': new Uint8Array( readFileSync(fixture('urdf_dae/robot/urdf/robot.urdf')), ), 'robot/dae/triangle.dae': new Uint8Array( readFileSync(fixture('urdf_dae/robot/dae/triangle.dae')), ), }); await page.goto('/'); await page .locator('input[type="file"]') .first() .setInputFiles({ name: 'dae.zip', mimeType: 'application/zip', buffer: Buffer.from(zip) }); await page .getByRole('dialog', { name: '配置 URDF 仿真组件' }) .getByRole('button', { name: '转换并加载' }) .click(); await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 }); await page.getByRole('button', { name: '源代码' }).click(); const dialog = page.getByRole('dialog', { name: '转换后的 MJCF 编辑器' }); await dialog.locator('.monaco-editor').click({ position: { x: 240, y: 120 } }); await page.keyboard.press('Control+End'); await page.keyboard.insertText('\n'); await dialog.getByRole('button', { name: '保存并重新载入', exact: true }).click(); await expect(dialog.getByRole('button', { name: '保存并重新载入', exact: true })).toBeDisabled({ timeout: 30_000, }); await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled(); await expect(page.getByText('模型编译失败')).toHaveCount(0); }); test('slide 关节向屏幕轴正方向拖动时 qpos 同向增加', async ({ page }) => { await page.goto('/'); await page .locator('input[type="file"]') .first() .setInputFiles({ name: 'slide.xml', mimeType: 'text/xml', buffer: Buffer.from(SLIDE_DIRECTION_MODEL), }); await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 }); await page.getByRole('button', { name: '关节拖动' }).click(); const canvas = page.locator('main canvas').first(), box = await canvas.boundingBox(); expect(box).not.toBeNull(); const x = box!.x + box!.width / 2, y = box!.y + box!.height / 2; await page.mouse.move(x, y); await page.mouse.down(); await page.mouse.move(x + 70, y, { steps: 8 }); await page.mouse.up(); await page.getByRole('tab', { name: '控制台' }).click(); const jointSection = page .getByRole('tabpanel', { name: '控制台' }) .getByRole('button', { name: /关节姿态调试/ }); if ((await jointSection.getAttribute('aria-expanded')) === 'false') await jointSection.click(); const output = page.getByText('screen_x').locator('..').locator('output'); await expect .poll(async () => Number.parseFloat((await output.textContent()) || '0')) .toBeGreaterThan(0); }); test('可导入并启用 Python 控制器', async ({ page }) => { await page.goto('/'); await page .locator('input[type="file"]') .first() .setInputFiles({ name: 'model.xml', mimeType: 'text/xml', buffer: Buffer.from(SIMPLE_MODEL) }); await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 }); await page.getByRole('tab', { name: '控制台' }).click(); await page .getByRole('tabpanel', { name: '控制台' }) .getByRole('button', { name: /Python 脚本控制/ }) .click(); const python = `NAME = "测试 PD 控制器"\nCONTROL_HZ = 100\ndef init(api):\n return {"joint": api.joint("slide"), "actuator": api.actuator("motor"), "body": api.body("box")}\ndef step(ctx, state):\n assert len(ctx.body_quat(state["body"])) == 4\n assert len(ctx.body_position(state["body"])) == 3\n ctx.set_control(state["actuator"], -ctx.qpos(state["joint"]) - 0.1 * ctx.qvel(state["joint"]))\n`; await page .locator('input[accept=".py,text/x-python"]') .setInputFiles({ name: 'balance.py', mimeType: 'text/x-python', buffer: Buffer.from(python) }); await expect(page.getByText('测试 PD 控制器', { exact: true })).toBeVisible({ timeout: 30_000 }); await page.getByText('脚本详情', { exact: true }).click(); await expect(page.getByText('Python / Pyodide')).toBeVisible(); await page.getByRole('button', { name: '启用', exact: true }).click(); await expect( page.getByRole('tabpanel', { name: '控制台' }).getByRole('button', { name: /Python 脚本控制/ }), ).toContainText('运行'); await page.screenshot({ path: test.info().outputPath('python-running.png') }); }); test('中等规模模型持续步进并可重复加载', async ({ page }) => { await page.goto('/'); const input = page.locator('input[type="file"]').first(); const modelFile = { name: 'large.xml', mimeType: 'text/xml', buffer: Buffer.from(LARGE_MODEL) }; await input.setInputFiles(modelFile); await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 }); await page.getByRole('button', { name: '▶ 播放' }).click(); await page.waitForTimeout(2_000); await expect(page.getByLabel('视口状态')).not.toContainText('时间 0.000 s'); // 播放过程中重置必须同时暂停底层会话,之后仍可正常播放和暂停。 await page.getByRole('button', { name: '重置', exact: true }).click(); await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled(); await expect(page.getByLabel('视口状态')).toContainText('时间 0.000 s'); await page.getByRole('button', { name: '▶ 播放' }).click(); await page.waitForTimeout(500); await page.getByRole('button', { name: '⏸ 暂停' }).click(); await page.waitForTimeout(200); const pausedTime = (await page.getByLabel('视口状态').innerText()).match(/时间 ([\d.]+) s/)?.[1]; expect(Number(pausedTime)).toBeGreaterThan(0); await page.waitForTimeout(500); expect((await page.getByLabel('视口状态').innerText()).match(/时间 ([\d.]+) s/)?.[1]).toBe( pausedTime, ); await input.setInputFiles(modelFile); await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 }); await expect(page.getByRole('alert')).toHaveCount(0); }); test('认证资产可点击创建场景并拖到画布落位', async ({ page }) => { await page.setViewportSize({ width: 1440, height: 900 }); await page.goto('/'); await page .locator('input[type="file"]') .first() .setInputFiles({ name: 'model.xml', mimeType: 'text/xml', buffer: Buffer.from(SIMPLE_MODEL), }); await expect(page.getByText('模型加载完成')).toBeVisible({ timeout: 30_000 }); const library = page.getByLabel('地图资产库'); await expect(library.getByText('物理几何原语', { exact: true })).toBeVisible(); await library.getByRole('button', { name: '添加基础方盒' }).click(); await expect(page.getByText('正在加载 MuJoCo 与模型…')).toHaveCount(0); await expect(page.getByLabel('地图来源')).toHaveValue('project:maps/scene_1/map.json', { timeout: 30_000, }); await expect(page.getByLabel('地图物体检查器')).toBeVisible(); await expect(page.getByLabel('地图对象列表').getByText('基础方盒 · 方盒')).toBeVisible(); await page.locator('[data-map-asset="ramp"]').dragTo(page.locator('main canvas')); await expect(page.getByLabel('地图对象列表').getByText('标准坡道 · 坡道')).toBeVisible(); await expect(page.getByText('地图草稿尚未应用')).toBeVisible(); await page.getByLabel('地图对象列表').getByText('基础方盒 · 方盒').click(); await page.getByLabel('贴地检测模式').selectOption('locked'); await expect(page.getByLabel('对象位置X')).toBeDisabled(); await page.getByLabel('贴地检测模式').selectOption('auto_ground'); await expect(page.getByLabel('对象位置X')).toBeEnabled(); const gizmoLine = page.getByRole('img', { name: 'XYZ 方向指示器' }).locator('line').first(); const beforeRotation = await gizmoLine.getAttribute('x2'); const canvasBox = await page.locator('main canvas').first().boundingBox(); expect(canvasBox).not.toBeNull(); // 左上现在是可交互状态槽位;从画布空白中部拖动,继续验证真实 OrbitControls。 const dragY = canvasBox!.y + canvasBox!.height * 0.35; await page.mouse.move(canvasBox!.x + 24, dragY); await page.mouse.down({ button: 'left' }); await page.mouse.move(canvasBox!.x + 104, dragY + 26, { steps: 8 }); await page.mouse.up({ button: 'left' }); await expect.poll(() => gizmoLine.getAttribute('x2')).not.toBe(beforeRotation); await expect(page.getByLabel('对象名称')).toHaveCount(0); await expect(page.getByLabel('地图对象列表').getByText('基础方盒 · 方盒')).toBeVisible(); }); test('认证资产自动打开地图属性并与参数地形一次编译', async ({ page }) => { await page.goto('/'); await page.setViewportSize({ width: 1440, height: 900 }); await page .locator('input[type="file"]') .first() .setInputFiles({ name: 'unified-map-model.xml', mimeType: 'text/xml', buffer: Buffer.from(SIMPLE_MODEL), }); await expect(page.getByText('模型加载完成')).toBeVisible({ timeout: 30_000 }); const library = page.getByLabel('地图资产库'); await library.getByRole('button', { name: '添加基础方盒' }).click(); await expect(page.getByLabel('地图物体检查器')).toBeVisible(); await expect(page.getByLabel('地图物体检查器')).toBeVisible(); await expect(page.getByText('1 项场景更改待应用')).toBeVisible(); const sceneTree = page.getByLabel('场景资产树'); await expect(sceneTree.getByText('基础方盒')).toBeVisible(); await sceneTree.getByRole('treeitem', { name: 'box', exact: true }).click(); await expect(page.getByText(/^Body #/)).toBeVisible(); await sceneTree.getByRole('treeitem', { name: /基础方盒/ }).click(); await expect(page.getByLabel('地图物体检查器')).toBeVisible(); await library.getByRole('button', { name: '添加随机粗糙地形' }).click(); await page.getByLabel('位置 X(m)').fill('4'); await expect(page.getByText('2 项场景更改待应用')).toBeVisible(); // 切换到参数地形后,尚未编译的认证资产仍保留在画布中并可直接选回。 const draftCanvasBox = await page.locator('main canvas').first().boundingBox(); expect(draftCanvasBox).not.toBeNull(); let selectedDraftAsset = false; for (let y = 0.15; y <= 0.85 && !selectedDraftAsset; y += 0.05) { for (let x = 0.05; x <= 0.95; x += 0.05) { await page.mouse.click( draftCanvasBox!.x + draftCanvasBox!.width * x, draftCanvasBox!.y + draftCanvasBox!.height * y, ); await page.waitForTimeout(20); if (await page.getByLabel('对象名称').count()) { selectedDraftAsset = true; break; } } } expect(selectedDraftAsset).toBe(true); await expect(page.getByLabel('地图来源')).toHaveValue('project:maps/scene_1/map.json'); await sceneTree.getByRole('treeitem', { name: /随机粗糙地形/ }).click(); await expect(page.getByLabel('地图来源')).toHaveValue('builtin'); await page.getByRole('button', { name: '应用场景' }).click(); await expect(page.getByText('2 项场景更改待应用')).toHaveCount(0, { timeout: 30_000 }); 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 expect(notifications).toContainText(/已加载工程地图“场景 1”(1 个物理几何/); await expect(notifications).toContainText(/已加载随机粗糙地形物理地图/); await page.keyboard.press('Escape'); await expect(page.getByLabel('地图来源')).toHaveValue('builtin'); // 当前仍选中参数地形时,直接点已编译的认证资产也必须反查场景与对象,首次点击即挂载操纵器。 const canvasBox = await page.locator('main canvas').first().boundingBox(); expect(canvasBox).not.toBeNull(); let selectedCompiledAsset = false; for (let y = 0.15; y <= 0.85 && !selectedCompiledAsset; y += 0.05) { for (let x = 0.05; x <= 0.95; x += 0.05) { await page.mouse.click( canvasBox!.x + canvasBox!.width * x, canvasBox!.y + canvasBox!.height * y, ); await page.waitForTimeout(20); if (await page.getByLabel('对象名称').count()) { selectedCompiledAsset = true; break; } } } expect(selectedCompiledAsset).toBe(true); await expect(page.getByLabel('地图来源')).toHaveValue('project:maps/scene_1/map.json'); await expect(page.getByLabel('对象名称')).toHaveValue('基础方盒'); await expect(page.getByLabel('地图对象列表').getByText('基础方盒 · 方盒')).toBeVisible(); await expect(page.getByText('地图草稿尚未应用')).toHaveCount(0); }); test('放弃首次认证资产会完整回滚临时场景和工程文件', async ({ page }) => { await page.goto('/'); await page.setViewportSize({ width: 1440, height: 900 }); await page .locator('input[type="file"]') .first() .setInputFiles({ name: 'discard-map-model.xml', mimeType: 'text/xml', buffer: Buffer.from(SIMPLE_MODEL), }); await expect(page.getByText('模型加载完成')).toBeVisible({ timeout: 30_000 }); const library = page.getByLabel('地图资产库'); await library.getByRole('button', { name: '添加基础方盒' }).click(); await expect(page.getByText('1 项场景更改待应用')).toBeVisible(); await expect(page.getByLabel('场景资产树')).toContainText('场景 1'); await page.getByRole('button', { name: '放弃更改' }).click(); await expect(page.getByText('1 项场景更改待应用')).toHaveCount(0); await expect(page.getByLabel('场景资产树')).not.toContainText('场景 1'); await expect(library.getByText('场景 1')).toHaveCount(0); // 文件和 map id 也必须回滚;再次创建应复用 scene_1,而不是泄漏出 scene_2。 await library.getByRole('button', { name: '添加基础方盒' }).click(); await expect(page.getByLabel('场景资产树')).toContainText('场景 1'); await expect(page.getByLabel('场景资产树')).not.toContainText('场景 2'); }); test('工程地图与参数地形共享放置草稿、实例变换和回滚入口', async ({ page }) => { const mapJson = JSON.stringify({ schemaVersion: 1, id: 'warehouse-draft', name: '草稿仓库', coordinateSystem: { units: 'm', up: 'Z', forward: '+X' }, physics: { source: 'physics/world.xml' }, spawnPoints: [], }); const project = zipSync({ 'model.xml': Buffer.from(SIMPLE_MODEL), 'maps/warehouse/map.json': Buffer.from(mapJson), 'maps/warehouse/physics/world.xml': Buffer.from( '', ), }); await page.goto('/'); await page.setViewportSize({ width: 1440, height: 900 }); await page .locator('input[type="file"]') .first() .setInputFiles({ name: 'unified-project-map.zip', mimeType: 'application/zip', buffer: Buffer.from(project), }); await expect(page.getByText('模型加载完成')).toBeVisible({ timeout: 30_000 }); const library = page.getByLabel('地图资产库'); await library.getByRole('button', { name: '放置工程地图 草稿仓库' }).click(); await expect(page.getByText('1 项场景更改待应用')).toBeVisible(); await expect(page.getByLabel('场景资产树')).toContainText('待应用'); await expect(page.getByText('正在准备仿真')).toHaveCount(0); await page.getByLabel('位置 X(m)').fill('3'); await page.getByLabel('位置 Y(m)').fill('-2'); await expect(page.getByText('1 项场景更改待应用')).toBeVisible(); await library.getByRole('button', { name: '添加波浪地形' }).click(); await expect(page.getByText('2 项场景更改待应用')).toBeVisible(); await page.getByRole('button', { name: '应用场景' }).click(); await expect(page.getByText('2 项场景更改待应用')).toHaveCount(0, { timeout: 30_000 }); await expectNotificationDetails(page, [/已加载工程地图“草稿仓库”/, /已加载波浪地形物理地图/]); await page.getByRole('button', { name: '删除地图实例 草稿仓库' }).click(); await expect(page.getByText('1 项场景更改待应用')).toBeVisible(); await expect(page.getByLabel('场景资产树')).not.toContainText('草稿仓库'); await page.getByRole('button', { name: '放弃更改' }).click(); await expect(page.getByText('1 项场景更改待应用')).toHaveCount(0); await expect(page.getByLabel('场景资产树')).toContainText('草稿仓库'); }); test('认证资产重力放置使用参数地形的真实承载高度', async ({ page }) => { await page.goto('/'); await page.setViewportSize({ width: 1440, height: 900 }); await page .locator('input[type="file"]') .first() .setInputFiles({ name: 'terrain-surface-model.xml', mimeType: 'text/xml', buffer: Buffer.from(SIMPLE_MODEL), }); await expect(page.getByText('模型加载完成')).toBeVisible({ timeout: 30_000 }); const library = page.getByLabel('地图资产库'); await library.getByRole('button', { name: '添加深坑地形' }).click(); await page.getByLabel('地形难度(0–1)').fill('1'); await library.getByLabel('新增资产放置方式').selectOption('gravity'); await library.getByRole('button', { name: '添加基础方盒' }).click(); await expect(page.getByLabel('贴地检测模式')).toHaveValue('gravity'); await expect(page.getByLabel('对象位置Z')).toHaveValue('-0.3'); await expect(page.getByText('2 项场景更改待应用')).toBeVisible(); }); test('参数化地形可连续拖到画布并一次性编译', async ({ page }) => { await page.goto('/'); await page.setViewportSize({ width: 1440, height: 900 }); await page .locator('input[type="file"]') .first() .setInputFiles({ name: 'terrain-draft-model.xml', mimeType: 'text/xml', buffer: Buffer.from(SIMPLE_MODEL), }); await expect(page.getByText('模型加载完成')).toBeVisible({ timeout: 30_000 }); await page.locator('[data-system-terrain="rough"]').dragTo(page.locator('main canvas')); await expect(page.getByText('1 项场景更改待应用')).toBeVisible(); await expect(page.getByText('正在准备仿真')).toHaveCount(0); await page.locator('[data-system-terrain="wave"]').dragTo(page.locator('main canvas')); await expect(page.getByText('2 项场景更改待应用')).toBeVisible(); await expect(page.getByLabel('场景资产树')).toContainText('随机粗糙地形'); await expect(page.getByLabel('场景资产树')).toContainText('波浪地形'); await page.getByLabel('场景资产树').getByRole('treeitem', { name: 'box' }).click(); await expect(page.getByText(/^Body #/)).toBeVisible(); const canvasBox = await page.locator('main canvas').first().boundingBox(); expect(canvasBox).not.toBeNull(); await page.mouse.click( canvasBox!.x + canvasBox!.width * 0.5, canvasBox!.y + canvasBox!.height * 0.78, ); await expect(page.getByText('参数化地形')).toBeVisible(); await expect(page.getByLabel('地图视口工具')).toBeVisible(); await expect(page.getByLabel('物理地图预设')).toBeVisible(); await page.getByRole('button', { name: '应用场景' }).click(); await expect(page.getByText('2 项场景更改待应用')).toHaveCount(0, { timeout: 30_000 }); 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 expect(notifications).toContainText(/已加载随机粗糙地形物理地图/); await expect(notifications).toContainText(/已加载波浪地形物理地图/); await page.keyboard.press('Escape'); }); test('应用内置 MJCF 楼梯物理地图', async ({ page }) => { await page.goto('/'); await page.setViewportSize({ width: 1440, height: 900 }); await page .locator('input[type="file"]') .first() .setInputFiles({ name: 'map-model.xml', mimeType: 'text/xml', buffer: Buffer.from(SIMPLE_MODEL), }); await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 }); await page.getByLabel('地图资产库').getByRole('button', { name: '添加波浪地形' }).click(); await page.getByLabel('物理地图预设').selectOption('stairs'); await page.getByLabel('台阶数量').fill('6'); await page.getByRole('button', { name: '应用并重新编译' }).click(); await expect(page.getByLabel('地图草稿状态')).toBeHidden({ timeout: 30_000 }); await expectNotificationDetails(page, [/已加载楼梯物理地图/]); await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled(); }); test('依次应用全部系统参数化地形', async ({ page }) => { const terrains = [ ['discrete_obstacles', '离散障碍地形'], ['gap', '沟壑地形'], ['inverted_pyramid_stairs', '倒金字塔阶梯'], ['pit', '深坑地形'], ['pyramid_stairs', '金字塔阶梯'], ['rails', '轨道地形'], ['rough', '随机粗糙地形'], ['stepping_stones', '踏石地形'], ['wave', '波浪地形'], ] as const; await page.goto('/'); await page.setViewportSize({ width: 1440, height: 900 }); await page .locator('input[type="file"]') .first() .setInputFiles({ name: 'terrain-model.xml', mimeType: 'text/xml', buffer: Buffer.from(SIMPLE_MODEL), }); await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 }); await page.getByLabel('地图资产库').getByRole('button', { name: '添加波浪地形' }).click(); for (const [preset, label] of terrains) { await page.getByLabel('物理地图预设').selectOption(preset); await page.getByLabel('地形边长(m)').fill('6'); if (preset === 'rough' || preset === 'wave') await page.getByLabel('水平采样间距(m)').fill('0.25'); await page.getByRole('button', { name: '应用并重新编译' }).click(); await expect(page.getByLabel('地图草稿状态')).toBeHidden({ timeout: 30_000 }); await expectNotificationDetails(page, [new RegExp(`已加载${label}物理地图`)]); } await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled(); }); test('导入并应用分层工程地图包', async ({ page }) => { const mapJson = JSON.stringify({ schemaVersion: 1, id: 'test-room', name: '测试场景', coordinateSystem: { units: 'm', up: 'Z', forward: '+X' }, physics: { source: 'physics/world.xml' }, visual: { source: 'visuals/scene.glb' }, spawnPoints: [{ id: 'start', name: '起点', position: [2, 0, 0.5], yawDeg: 0 }], }); const project = zipSync({ 'model.xml': Buffer.from(SIMPLE_MODEL), 'maps/test/map.json': Buffer.from(mapJson), 'maps/test/physics/world.xml': Buffer.from( '', ), 'maps/test/visuals/scene.glb': minimalGlb(), }); await page.goto('/'); await page.setViewportSize({ width: 1440, height: 900 }); await page .locator('input[type="file"]') .first() .setInputFiles({ name: 'map-project.zip', mimeType: 'application/zip', buffer: Buffer.from(project), }); await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 }); await page .getByLabel('地图资产库') .getByRole('button', { name: '放置工程地图 测试场景' }) .click(); // 资产库放置默认不移动机器人;显式选择出生点后仍验证原编译路径。 await expect(page.getByLabel('地图出生点')).toHaveValue(''); await page.getByLabel('地图出生点').selectOption('start'); await expect(page.getByLabel('地图出生点')).toHaveValue('start'); await page.getByRole('button', { name: '应用并重新编译' }).click(); await expect(page.getByLabel('地图草稿状态')).toBeHidden({ timeout: 30_000 }); await expectNotificationDetails(page, [/已加载工程地图“测试场景”/], [/视觉地图加载失败/]); await expect(page.getByText(/视觉地图加载失败/)).toHaveCount(0); }); test('将受支持的只读物理地图转换为可编辑副本', async ({ page }) => { const mapJson = JSON.stringify({ schemaVersion: 1, id: 'legacy-room', name: '旧版基础场景', coordinateSystem: { units: 'm', up: 'Z', forward: '+X' }, physics: { source: 'physics/world.xml' }, spawnPoints: [{ id: 'start', name: '入口', position: [0, 0, 0], yawDeg: 0 }], }); const project = zipSync({ 'model.xml': Buffer.from(SIMPLE_MODEL), 'maps/legacy/map.json': Buffer.from(mapJson), 'maps/legacy/physics/world.xml': Buffer.from( '', ), }); await page.goto('/'); await page.setViewportSize({ width: 1440, height: 900 }); await page .locator('input[type="file"]') .first() .setInputFiles({ name: 'legacy-map.zip', mimeType: 'application/zip', buffer: Buffer.from(project), }); await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 }); await page .getByLabel('地图资产库') .getByRole('button', { name: '放置工程地图 旧版基础场景' }) .click(); await page.getByRole('button', { name: '应用并重新编译' }).click(); const editor = page.getByText('源内容修改影响所有同源实例').locator('../..'); await expect(editor.getByText(/保持只读/)).toBeVisible({ timeout: 30_000 }); await editor.getByRole('button', { name: '创建可编辑副本' }).click(); await expect(page.getByText('已创建可编辑地图副本')).toBeVisible({ timeout: 30_000 }); await expect( page.getByLabel('地图视口工具').getByRole('button', { name: '移动工具 W' }), ).toBeVisible(); await expect(editor.getByRole('button', { name: /floor · 方盒/ })).toBeVisible(); await expect(editor.getByRole('button', { name: /wall · 方盒/ })).toBeVisible(); }); test('编辑 V3 地图对象并事务式应用', async ({ page }) => { const authoring = JSON.stringify({ schemaVersion: 1, mapId: 'editable-room', revision: 0, objects: [], spawnPoints: [], }); const mapJson = JSON.stringify({ schemaVersion: 2, id: 'editable-room', name: '可编辑场景', coordinateSystem: { units: 'm', up: 'Z', forward: '+X' }, physics: { source: 'physics/world.xml' }, authoring: { source: 'authoring/map.scene.json' }, spawnPoints: [], }); const project = zipSync({ 'model.xml': Buffer.from(SIMPLE_MODEL), 'maps/edit/map.json': Buffer.from(mapJson), 'maps/edit/physics/world.xml': Buffer.from(''), 'maps/edit/authoring/map.scene.json': Buffer.from(authoring), }); await page.goto('/'); await page.setViewportSize({ width: 1440, height: 900 }); await page .locator('input[type="file"]') .first() .setInputFiles({ name: 'editable-map.zip', mimeType: 'application/zip', buffer: Buffer.from(project), }); await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 }); await page .getByLabel('地图资产库') .getByRole('button', { name: '放置工程地图 可编辑场景' }) .click(); await page.getByRole('button', { name: '应用场景', exact: true }).click(); await expect(page.getByText('源内容修改影响所有同源实例', { exact: true })).toBeVisible({ timeout: 30_000, }); const mapTools = page.getByLabel('地图视口工具'); await expect(mapTools.getByRole('button', { name: '移动工具 W' })).toHaveAttribute( 'aria-pressed', 'true', ); await page.keyboard.press('e'); await expect(mapTools.getByRole('button', { name: '旋转工具 E' })).toHaveAttribute( 'aria-pressed', 'true', ); await page.keyboard.press('r'); await expect(mapTools.getByRole('button', { name: '缩放工具 R' })).toHaveAttribute( 'aria-pressed', 'true', ); await page.getByRole('button', { name: '新增', exact: true }).click(); await page.getByLabel('对象位置X').fill('2'); await page.getByLabel('对象位置X').blur(); await page.keyboard.press('f'); const draftStatus = page.getByLabel('地图草稿状态'); await expect(draftStatus).toContainText('未保存改动'); await page.keyboard.press('Control+s'); await expect( page.getByLabel('地图对象列表').getByRole('button', { name: /box · 方盒/ }), ).toBeVisible({ timeout: 30_000, }); await expect(draftStatus).toBeHidden(); await page .getByLabel('地图对象列表') .getByRole('button', { name: /box · 方盒/ }) .click(); await page.getByLabel('对象位置X').fill('3'); await expect(draftStatus).toContainText('未保存改动'); await draftStatus.getByRole('button', { name: '丢弃地图草稿' }).click(); await page .getByLabel('地图对象列表') .getByRole('button', { name: /box · 方盒/ }) .click(); await expect(page.getByLabel('对象位置X')).toHaveValue('2'); await expect(draftStatus).toBeHidden(); await page.getByRole('button', { name: '新增', exact: true }).click(); await expect(page.getByLabel('地图对象列表').getByRole('button')).toHaveCount(2); await page.keyboard.press('Delete'); await expect(page.getByLabel('地图对象列表').getByRole('button')).toHaveCount(1); await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled(); }); test('工程地图编译失败时保留上一仿真会话', async ({ page }) => { const mapJson = JSON.stringify({ schemaVersion: 1, id: 'invalid-map', name: '动态错误地图', coordinateSystem: { units: 'm', up: 'Z', forward: '+X' }, physics: { source: 'world.xml' }, spawnPoints: [], }); const project = zipSync({ 'model.xml': Buffer.from(SIMPLE_MODEL), 'maps/invalid/map.json': Buffer.from(mapJson), 'maps/invalid/world.xml': Buffer.from( '', ), }); await page.goto('/'); await page.setViewportSize({ width: 1440, height: 900 }); await page .locator('input[type="file"]') .first() .setInputFiles({ name: 'invalid-map.zip', mimeType: 'application/zip', buffer: Buffer.from(project), }); await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 30_000 }); await page.getByRole('button', { name: '▶ 播放' }).click(); await page.waitForTimeout(200); const runningTime = Number( (await page.getByLabel('视口状态').innerText()).match(/时间 ([\d.]+) s/)?.[1] ?? 0, ); await page .getByLabel('地图资产库') .getByRole('button', { name: '放置工程地图 动态错误地图' }) .click(); await page.getByRole('button', { name: '应用场景', exact: true }).click(); await expect(page.getByRole('alert')).toContainText('模型编译失败', { timeout: 30_000 }); await expect(page.getByLabel('地图来源')).toHaveValue('project:maps/invalid/map.json'); await expect(page.getByLabel('地图草稿状态')).toContainText('应用失败'); await page.screenshot({ path: test.info().outputPath('map-compile-failed.png') }); await expect(page.getByText('1 项场景更改待应用')).toBeVisible(); await expect(page.getByLabel('场景资产树')).toContainText('待应用'); await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled(); await page.getByRole('button', { name: '关闭错误' }).click(); await page.getByRole('button', { name: '▶ 播放' }).click(); await expect .poll(async () => Number((await page.getByLabel('视口状态').innerText()).match(/时间 ([\d.]+) s/)?.[1] ?? 0), ) .toBeGreaterThan(runningTime); await page.getByRole('button', { name: '放弃更改' }).click(); await expect(page.getByText('1 项场景更改待应用')).toHaveCount(0); await expect(page.getByLabel('场景资产树')).not.toContainText('动态错误地图'); await expect(page.getByLabel('地图草稿状态')).toBeHidden(); }); test('无效模型显示中文诊断且保留工程树', async ({ page }) => { await page.goto('/'); await page.locator('input[type="file"]').first().setInputFiles(fixture('invalid.xml')); await expect(page.getByRole('alert')).toContainText('模型编译失败', { timeout: 30_000 }); await expectImportedFiles(page, ['invalid.xml']); });