755 lines
36 KiB
TypeScript
755 lines
36 KiB
TypeScript
import { expect, test } 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));
|
||
|
||
const SIMPLE_MODEL = `
|
||
<mujoco model="e2e">
|
||
<compiler angle="radian"/>
|
||
<worldbody>
|
||
<light pos="0 0 3"/>
|
||
<body name="box" pos="0 0 1">
|
||
<joint name="slide" type="slide" axis="1 0 0" range="-1 1"/>
|
||
<joint name="hinge" type="hinge" axis="0 1 0" range="-1.57079632679 1.57079632679"/>
|
||
<geom name="box_geom" type="box" size=".2 .2 .2" mass="1"/>
|
||
</body>
|
||
</worldbody>
|
||
<actuator><motor name="motor" joint="slide" ctrlrange="-2 2"/></actuator>
|
||
</mujoco>`;
|
||
|
||
const SLIDE_DIRECTION_MODEL = `<mujoco model="drag-direction"><worldbody><body name="slider" pos="0 0 1"><joint name="screen_x" type="slide" axis="1 0 0" range="-2 2"/><geom type="box" size=".25 .25 .25" mass="1"/></body></worldbody></mujoco>`;
|
||
|
||
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 = `
|
||
<mujoco model="soak">
|
||
<option timestep=".005"/>
|
||
<worldbody>
|
||
<replicate count="6" offset=".12 0 0">
|
||
<replicate count="6" offset="0 .12 0">
|
||
<replicate count="6" offset="0 0 .12">
|
||
<body pos="-.3 -.3 .5"><joint type="free"/><geom type="sphere" size=".03" mass=".01"/></body>
|
||
</replicate>
|
||
</replicate>
|
||
</replicate>
|
||
<geom type="plane" size="2 2 .1"/>
|
||
</worldbody>
|
||
</mujoco>`;
|
||
|
||
test('独立自调参工作台不需要加载 MuJoCo 主应用即可打开', async ({ page }) => {
|
||
await page.goto('/tuning.html');
|
||
await expect(page.getByRole('heading', { name: 'Go2 奖励函数自调参 Agent' })).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.x + resetCameraBox.width <= playBox.x,
|
||
).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 Web 仿真平台' })).toBeVisible();
|
||
await expect(page.getByRole('main').getByText('拖放模型工程到此处')).toBeVisible();
|
||
await expect(page.getByRole('img', { name: 'XYZ 方向指示器' })).toBeVisible();
|
||
await expect(page.getByRole('button', { name: '切换到白天主题' })).toBeVisible();
|
||
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 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 expect(page.getByRole('button', { name: '退出全屏' })).toBeVisible();
|
||
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('button', { name: '切换到白天主题' }).click();
|
||
await expect(page.locator('#root > div')).toHaveClass(/theme-light/);
|
||
await expect(page.getByRole('button', { name: '切换到黑夜主题' })).toBeVisible();
|
||
await page.getByRole('button', { 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.getByText('WASM 已加载')).toBeVisible({ 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 .*物理/ }).click();
|
||
await expect(page.getByRole('dialog', { name: '性能详情' })).toBeVisible();
|
||
await page.keyboard.press('Escape');
|
||
await page.getByRole('tab', { name: '控制' }).click();
|
||
await page.getByRole('button', { name: 'Actuator' }).click();
|
||
await expect(page.getByText('motor', { exact: true })).toBeVisible();
|
||
await expect(
|
||
page.getByRole('tabpanel', { name: '控制' }).getByText('slide', { exact: true }),
|
||
).toBeVisible();
|
||
await page.getByRole('tab', { name: '模型结构' }).click();
|
||
const structure = page.getByRole('navigation', { name: '模型结构树' });
|
||
await expect(structure).toBeVisible();
|
||
await structure.getByRole('treeitem', { name: /hinge/ }).hover();
|
||
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: '▶ 播放' })).toBeVisible();
|
||
});
|
||
|
||
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.getByText('WASM 已加载')).toBeVisible({ 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.getByText('WASM 已加载')).toBeVisible({ 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.getByText('WASM 已加载')).toBeVisible({ 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.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
|
||
await expect(page.getByText('5 个文件')).toBeVisible();
|
||
});
|
||
|
||
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.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
|
||
await expect(page.getByText('2 个文件')).toBeVisible();
|
||
await page.getByRole('button', { name: 'URDF 处理方式' }).click();
|
||
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: '通知中心' });
|
||
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.getByText('WASM 已加载')).toBeVisible();
|
||
await sourceDialog.getByRole('button', { name: '关闭源代码编辑器' }).click();
|
||
});
|
||
|
||
test('URDF 自动生成的关节驱动器与摄像头可通过 MuJoCo 编译', async ({ page }) => {
|
||
const urdf = `<robot name="jointed"><link name="base"><inertial><mass value="1"/><origin xyz="0 0 0"/><inertia ixx=".1" iyy=".1" izz=".1" ixy="0" ixz="0" iyz="0"/></inertial><visual><geometry><box size=".4 .4 .2"/></geometry></visual></link><link name="arm"><inertial><mass value=".2"/><origin xyz="0 0 .25"/><inertia ixx=".01" iyy=".01" izz=".01" ixy="0" ixz="0" iyz="0"/></inertial><visual><origin xyz="0 0 .25"/><geometry><box size=".1 .1 .5"/></geometry></visual></link><joint name="shoulder" type="revolute"><parent link="base"/><child link="arm"/><origin xyz="0 0 .1"/><axis xyz="0 1 0"/><limit lower="-1" upper="1" effort="10" velocity="2"/></joint></robot>`;
|
||
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.getByText('WASM 已加载')).toBeVisible({ 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: '通知中心' });
|
||
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('button', { name: 'Actuator' }).click();
|
||
await expect(page.getByText('shoulder_motor')).toBeVisible();
|
||
await expect(page.getByText('关节:shoulder')).toBeVisible();
|
||
await expect(page.getByText('N·m', { exact: true })).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.getByText('WASM 已加载')).toBeVisible({ 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.getByText('WASM 已加载')).toBeVisible();
|
||
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.getByText('WASM 已加载')).toBeVisible({ 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('button', { name: '关节 1' });
|
||
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.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
|
||
await page.getByRole('tab', { name: '控制' }).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 expect(page.getByText('Python / Pyodide')).toBeVisible();
|
||
await page.getByRole('button', { name: '启用', exact: true }).click();
|
||
await expect(page.getByText('运行中')).toBeVisible();
|
||
});
|
||
|
||
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.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
|
||
await page.getByRole('button', { name: '▶ 播放' }).click();
|
||
await page.waitForTimeout(2_000);
|
||
await expect(page.locator('footer')).not.toContainText('时间 0.000 s');
|
||
|
||
// 播放过程中重置必须同时暂停底层会话,之后仍可正常播放和暂停。
|
||
await page.getByRole('button', { name: '重置', exact: true }).click();
|
||
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeVisible();
|
||
await expect(page.locator('footer')).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.locator('footer').innerText()).match(/时间 ([\d.]+) s/)?.[1];
|
||
expect(Number(pausedTime)).toBeGreaterThan(0);
|
||
await page.waitForTimeout(500);
|
||
expect((await page.locator('footer').innerText()).match(/时间 ([\d.]+) s/)?.[1]).toBe(pausedTime);
|
||
|
||
await input.setInputFiles(modelFile);
|
||
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
|
||
await expect(page.getByRole('alert')).toHaveCount(0);
|
||
});
|
||
|
||
test('认证资产可点击创建场景并拖到画布落位', 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.getByText('模型加载完成')).toBeVisible({ timeout: 30_000 });
|
||
await page.getByRole('tab', { name: '地图' }).click();
|
||
|
||
const library = page.getByLabel('认证资产');
|
||
await expect(library.getByText('点击添加,或按住资产拖到画布落位。')).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.getByText('V3 地图编辑器')).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();
|
||
await page.mouse.move(canvasBox!.x + 24, canvasBox!.y + 24);
|
||
await page.mouse.down({ button: 'left' });
|
||
await page.mouse.move(canvasBox!.x + 104, canvasBox!.y + 50, { steps: 8 });
|
||
await page.mouse.up({ button: 'left' });
|
||
await expect.poll(() => gizmoLine.getAttribute('x2')).not.toBe(beforeRotation);
|
||
});
|
||
|
||
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.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
|
||
await page.getByRole('tab', { name: '地图' }).click();
|
||
await page.getByLabel('地图来源').selectOption('builtin');
|
||
await page.getByLabel('物理地图预设').selectOption('stairs');
|
||
await page.getByLabel('台阶数量').fill('6');
|
||
await page.getByRole('button', { name: '应用并重新编译' }).click();
|
||
await expect(page.getByText(/已加载楼梯物理地图/)).toBeVisible({ timeout: 30_000 });
|
||
await expect(page.getByText('WASM 已加载')).toBeVisible();
|
||
});
|
||
|
||
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.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
|
||
await page.getByRole('tab', { name: '地图' }).click();
|
||
await page.getByLabel('地图来源').selectOption('builtin');
|
||
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.getByText(new RegExp(`已加载${label}物理地图`))).toBeVisible({
|
||
timeout: 30_000,
|
||
});
|
||
}
|
||
await expect(page.getByText('WASM 已加载')).toBeVisible();
|
||
});
|
||
|
||
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(
|
||
'<mujoco><worldbody><geom name="floor" type="plane" size="8 8 .1"/><geom name="wall" type="box" pos="3 0 .5" size=".1 2 .5"/></worldbody></mujoco>',
|
||
),
|
||
'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.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
|
||
await page.getByRole('tab', { name: '地图' }).click();
|
||
await page.getByLabel('地图来源').selectOption({ label: '测试场景' });
|
||
await expect(page.getByLabel('地图出生点')).toHaveValue('start');
|
||
await page.getByRole('button', { name: '应用并重新编译' }).click();
|
||
await expect(page.getByText(/已加载工程地图“测试场景”/)).toBeVisible({ timeout: 30_000 });
|
||
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(
|
||
'<mujoco><worldbody><geom name="floor" type="box" pos="0 0 -.1" size="5 5 .1"/><body name="wall" pos="2 0 .5"><geom type="box" size=".1 1 .5"/></body></worldbody></mujoco>',
|
||
),
|
||
});
|
||
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.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
|
||
await page.getByRole('tab', { name: '地图' }).click();
|
||
await page.getByLabel('地图来源').selectOption({ label: '旧版基础场景' });
|
||
await page.getByRole('button', { name: '应用并重新编译' }).click();
|
||
const editor = page.getByText('V3 地图编辑器').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(editor.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('<mujoco><worldbody/></mujoco>'),
|
||
'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.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
|
||
await page.getByRole('tab', { name: '地图' }).click();
|
||
await page.getByLabel('地图来源').selectOption({ label: '可编辑场景' });
|
||
await page.getByRole('button', { name: '应用并重新编译' }).click();
|
||
await expect(page.getByText('V3 地图编辑器')).toBeVisible({ timeout: 30_000 });
|
||
const editor = page.getByText('V3 地图编辑器').locator('..');
|
||
await expect(editor.getByRole('button', { name: '移动工具 W' })).toHaveAttribute(
|
||
'aria-pressed',
|
||
'true',
|
||
);
|
||
await page.keyboard.press('e');
|
||
await expect(editor.getByRole('button', { name: '旋转工具 E' })).toHaveAttribute(
|
||
'aria-pressed',
|
||
'true',
|
||
);
|
||
await page.keyboard.press('s');
|
||
await expect(editor.getByRole('button', { name: '缩放工具 S' })).toHaveAttribute(
|
||
'aria-pressed',
|
||
'true',
|
||
);
|
||
await editor.getByRole('button', { name: '新增', exact: true }).click();
|
||
await editor.getByLabel('对象位置X').fill('2');
|
||
await editor.getByRole('button', { name: '应用并重新编译' }).click();
|
||
await expect(editor.getByRole('button', { name: /box · 方盒/ })).toBeVisible({ timeout: 30_000 });
|
||
await expect(editor.getByText('地图草稿尚未应用')).toHaveCount(0);
|
||
await expect(page.getByText('WASM 已加载')).toBeVisible();
|
||
});
|
||
|
||
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(
|
||
'<mujoco><worldbody><body><joint/><geom type="box" size="1 1 1"/></body></worldbody></mujoco>',
|
||
),
|
||
});
|
||
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.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
|
||
await page.getByRole('tab', { name: '地图' }).click();
|
||
await page.getByLabel('地图来源').selectOption({ label: '动态错误地图' });
|
||
await page.getByRole('button', { name: '应用并重新编译' }).click();
|
||
await expect(page.getByRole('alert')).toContainText('模型编译失败', { timeout: 30_000 });
|
||
await expect(page.getByLabel('地图来源')).toHaveValue('none');
|
||
await page.getByRole('button', { name: '关闭错误' }).click();
|
||
await page.getByRole('button', { name: '▶ 播放' }).click();
|
||
await page.waitForTimeout(300);
|
||
await expect(page.locator('footer')).not.toContainText('时间 0.000 s');
|
||
});
|
||
|
||
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 expect(page.getByText('invalid.xml', { exact: false }).first()).toBeVisible();
|
||
});
|