fa5485049a
web-platform-release / Build and publish release (push) Has been cancelled
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled
web-platform-ci / TypeScript, lint, unit, build (pull_request) Has been cancelled
web-platform-ci / Playwright E2E (pull_request) Has been cancelled
223 lines
10 KiB
TypeScript
223 lines
10 KiB
TypeScript
import { zipSync } from 'fflate';
|
|
import {
|
|
choosePreferredEntry,
|
|
discoverEntries,
|
|
filesFromDrop,
|
|
importBrowserFiles,
|
|
normalizeProjectPath,
|
|
prepareProjectForMujoco,
|
|
ProjectImportError,
|
|
} from './importer';
|
|
import type { ProjectFile } from './types';
|
|
const encode = (s: string) => new TextEncoder().encode(s);
|
|
const projectFile = (path: string, text: string): ProjectFile => ({
|
|
path,
|
|
data: encode(text),
|
|
size: encode(text).length,
|
|
source: 'file',
|
|
mimeType: 'text/xml',
|
|
});
|
|
const TRIANGLE_DAE = `<?xml version="1.0"?><COLLADA xmlns="http://www.collada.org/2005/11/COLLADASchema" version="1.4.1">
|
|
<asset><unit meter="1"/><up_axis>Z_UP</up_axis></asset><library_effects><effect id="fx"><profile_COMMON><technique sid="common"><lambert><diffuse><color>1 1 1 1</color></diffuse></lambert></technique></profile_COMMON></effect></library_effects><library_materials><material id="mat"><instance_effect url="#fx"/></material></library_materials>
|
|
<library_geometries><geometry id="triangle"><mesh><source id="positions"><float_array id="positions-array" count="9">0 0 0 1 0 0 0 1 0</float_array><technique_common><accessor source="#positions-array" count="3" stride="3"><param name="X" type="float"/><param name="Y" type="float"/><param name="Z" type="float"/></accessor></technique_common></source><vertices id="vertices"><input semantic="POSITION" source="#positions"/></vertices><triangles count="1" material="mat"><input semantic="VERTEX" source="#vertices" offset="0"/><p>0 1 2</p></triangles></mesh></geometry></library_geometries>
|
|
<library_visual_scenes><visual_scene id="scene"><node id="node"><instance_geometry url="#triangle"><bind_material><technique_common><instance_material symbol="mat" target="#mat"/></technique_common></bind_material></instance_geometry></node></visual_scene></library_visual_scenes><scene><instance_visual_scene url="#scene"/></scene></COLLADA>`;
|
|
describe('project importer', () => {
|
|
it('拒绝路径穿越与绝对路径', () => {
|
|
expect(() => normalizeProjectPath('../model.xml')).toThrow(ProjectImportError);
|
|
expect(() => normalizeProjectPath('/model.xml')).toThrow(ProjectImportError);
|
|
expect(normalizeProjectPath('robot\\mesh\\a.obj')).toBe('robot/mesh/a.obj');
|
|
});
|
|
it('识别 MJCF 与 URDF 并执行入口优先级', () => {
|
|
const entries = discoverEntries([
|
|
projectFile('other.xml', '<mujoco/>'),
|
|
projectFile('model.xml', '<mujoco/>'),
|
|
projectFile('robot.urdf', '<robot/>'),
|
|
]);
|
|
expect(entries).toHaveLength(3);
|
|
expect(choosePreferredEntry(entries)).toBe('model.xml');
|
|
});
|
|
it('通过现代文件系统句柄递归读取拖入的文件夹', async () => {
|
|
const model = new File(['<mujoco/>'], 'model.xml', { type: 'text/xml' });
|
|
const fileHandle = {
|
|
kind: 'file',
|
|
name: 'model.xml',
|
|
getFile: async () => model,
|
|
};
|
|
const directoryHandle = {
|
|
kind: 'directory',
|
|
name: 'robot',
|
|
async *values() {
|
|
yield fileHandle;
|
|
},
|
|
};
|
|
const items = [
|
|
{
|
|
kind: 'file',
|
|
getAsFileSystemHandle: async () => directoryHandle,
|
|
},
|
|
] as unknown as DataTransferItemList;
|
|
const files = await filesFromDrop(items, [] as unknown as FileList);
|
|
expect(files).toHaveLength(1);
|
|
expect(files[0].webkitRelativePath).toBe('robot/model.xml');
|
|
});
|
|
it('异步解压 ZIP、保留二进制数据并报告阶段进度', async () => {
|
|
const zipped = zipSync({
|
|
'robot/model.urdf': encode('<robot name="r"/>'),
|
|
'robot/mesh.obj': encode('v 0 0 0'),
|
|
});
|
|
const file = new File([zipped], 'robot.zip', { type: 'application/zip' });
|
|
const phases: string[] = [];
|
|
const result = await importBrowserFiles([file], undefined, (progress) =>
|
|
phases.push(`${progress.phase}:${progress.completed}`),
|
|
);
|
|
expect(result.files.map((f) => f.path)).toContain('robot/mesh.obj');
|
|
expect(result.selectedEntry).toBe('robot/model.urdf');
|
|
expect(phases).toContain('extracting:0');
|
|
expect(phases.at(-1)).toBe('indexing:1');
|
|
});
|
|
it('发现工程地图且不会将 map.json 当作模型入口', async () => {
|
|
const mapJson = JSON.stringify({
|
|
schemaVersion: 1,
|
|
id: 'room',
|
|
name: '房间',
|
|
coordinateSystem: { units: 'm', up: 'Z', forward: '+X' },
|
|
physics: { source: 'world.xml' },
|
|
spawnPoints: [],
|
|
});
|
|
const result = await importBrowserFiles([
|
|
new File(['<mujoco><worldbody/></mujoco>'], 'model.xml'),
|
|
new File([mapJson], 'map.json'),
|
|
new File(['<mujoco><worldbody/></mujoco>'], 'world.xml'),
|
|
]);
|
|
expect(result.maps).toEqual([
|
|
expect.objectContaining({ descriptorPath: 'map.json', id: 'room', name: '房间' }),
|
|
]);
|
|
expect(result.entries.map((entry) => entry.path)).toEqual(['model.xml']);
|
|
});
|
|
it('拒绝 ZIP 路径穿越', async () => {
|
|
const zipped = zipSync({ '../model.xml': encode('<mujoco/>') });
|
|
await expect(importBrowserFiles([new File([zipped], 'bad.zip')])).rejects.toThrow(
|
|
'路径包含越界片段',
|
|
);
|
|
});
|
|
it('拒绝同名路径', async () => {
|
|
const a = new File(['<mujoco/>'], 'model.xml');
|
|
const b = new File(['<mujoco/>'], 'model.xml');
|
|
await expect(importBrowserFiles([a, b])).rejects.toThrow('同名路径');
|
|
});
|
|
it('读取文件内容前根据元数据拒绝超过限制的输入', async () => {
|
|
const file = new File(['<mujoco/>'], 'model.xml'),
|
|
read = vi.spyOn(file, 'arrayBuffer');
|
|
await expect(
|
|
importBrowserFiles([file], {
|
|
maxFiles: 1,
|
|
maxFileBytes: 2,
|
|
maxTotalBytes: 2,
|
|
maxZipBytes: 2,
|
|
}),
|
|
).rejects.toThrow('单文件超过限制');
|
|
expect(read).not.toHaveBeenCalled();
|
|
});
|
|
it('按完成文件数报告普通工程读取进度', async () => {
|
|
const updates: Array<{ phase: string; completed: number; total: number }> = [];
|
|
const result = await importBrowserFiles(
|
|
[new File(['<mujoco/>'], 'model.xml'), new File(['v 0 0 0'], 'mesh.obj')],
|
|
undefined,
|
|
({ phase, completed, total }) => updates.push({ phase, completed, total }),
|
|
);
|
|
expect(result.files).toHaveLength(2);
|
|
expect(updates).toContainEqual({ phase: 'reading', completed: 2, total: 2 });
|
|
expect(updates.at(-1)).toEqual({ phase: 'indexing', completed: 1, total: 1 });
|
|
});
|
|
it('规范化 MuJoCo 不接受的重复 material 和 ROS package URI', async () => {
|
|
const urdf = projectFile(
|
|
'go2w_description/urdf/robot.urdf',
|
|
'<robot><link name="base"><visual><geometry><mesh filename="package://go2w_description/meshes/base.obj"/></geometry><material name="a"/><material name="b"/></visual></link></robot>',
|
|
);
|
|
const mesh: ProjectFile = {
|
|
path: 'go2w_description/meshes/base.obj',
|
|
data: new Uint8Array([1]),
|
|
size: 1,
|
|
source: 'directory',
|
|
mimeType: '',
|
|
};
|
|
const manifest = {
|
|
id: 'go2w',
|
|
name: 'go2w',
|
|
files: [urdf, mesh],
|
|
entries: [{ path: urdf.path, format: 'urdf' as const, label: 'robot' }],
|
|
maps: [],
|
|
selectedEntry: urdf.path,
|
|
totalBytes: urdf.size + 1,
|
|
};
|
|
const prepared = await prepareProjectForMujoco(manifest, urdf.path);
|
|
const text = new TextDecoder().decode(prepared.manifest.files[0].data);
|
|
expect(text.match(/<material/g) ?? []).toHaveLength(1);
|
|
expect(text).toContain('filename="../meshes/base.obj"');
|
|
expect(text).toContain('discardvisual="false"');
|
|
expect(text).toContain('fusestatic="false"');
|
|
expect(prepared.warnings).toHaveLength(2);
|
|
});
|
|
it('将 DAE mesh 转换为 MuJoCo 可读取的 OBJ,并复用于 visual/collision', async () => {
|
|
const urdf = projectFile(
|
|
'robot/robot.urdf',
|
|
'<robot><link name="base"><visual><geometry><mesh filename="meshes/triangle.dae"/></geometry></visual><collision><geometry><mesh filename="meshes/triangle.dae"/></geometry></collision></link></robot>',
|
|
);
|
|
const dae = projectFile('robot/meshes/triangle.dae', TRIANGLE_DAE);
|
|
const manifest = {
|
|
id: 'dae',
|
|
name: 'dae',
|
|
files: [urdf, dae],
|
|
entries: [{ path: urdf.path, format: 'urdf' as const, label: 'robot' }],
|
|
maps: [],
|
|
selectedEntry: urdf.path,
|
|
totalBytes: urdf.size + dae.size,
|
|
};
|
|
const prepared = await prepareProjectForMujoco(manifest, urdf.path);
|
|
const text = new TextDecoder().decode(
|
|
prepared.manifest.files.find((file) => file.path === urdf.path)!.data,
|
|
);
|
|
expect(text).not.toContain('.dae');
|
|
expect(text.match(/meshes\/triangle\.mujoco\.obj/g)).toHaveLength(2);
|
|
const obj = prepared.manifest.files.find(
|
|
(file) => file.path === 'robot/meshes/triangle.mujoco.obj',
|
|
);
|
|
expect(new TextDecoder().decode(obj!.data)).toMatch(/^f\s/m);
|
|
expect(prepared.warnings.join(' ')).toContain('1 个 DAE 文件转换为 OBJ');
|
|
});
|
|
it('DAE 缺失或转换失败时安全降级', async () => {
|
|
const urdf = projectFile(
|
|
'robot.urdf',
|
|
'<robot><link name="base"><visual><geometry><mesh filename="visual.dae"/></geometry></visual><collision><geometry><mesh filename="collision.dae"/></geometry></collision></link></robot>',
|
|
);
|
|
const manifest = {
|
|
id: 'dae',
|
|
name: 'dae',
|
|
files: [urdf],
|
|
entries: [{ path: urdf.path, format: 'urdf' as const, label: 'robot' }],
|
|
maps: [],
|
|
selectedEntry: urdf.path,
|
|
totalBytes: urdf.size,
|
|
};
|
|
const prepared = await prepareProjectForMujoco(manifest, urdf.path);
|
|
const text = new TextDecoder().decode(prepared.manifest.files[0].data);
|
|
expect(text).not.toContain('<visual>');
|
|
expect(text).toContain('<collision>');
|
|
expect(text).toContain('<sphere radius="0.05"');
|
|
expect(prepared.warnings.join(' ')).toContain('DAE visual');
|
|
expect(prepared.warnings.join(' ')).toContain('DAE collision');
|
|
});
|
|
it('在解压前依据 ZIP 元数据拒绝膨胀内容', async () => {
|
|
const zipped = zipSync({ 'model.xml': encode(`<mujoco>${' '.repeat(4096)}</mujoco>`) });
|
|
const file = new File([zipped], 'large.zip');
|
|
await expect(
|
|
importBrowserFiles([file], {
|
|
maxFiles: 2,
|
|
maxFileBytes: 128,
|
|
maxTotalBytes: 256,
|
|
maxZipBytes: 4096,
|
|
}),
|
|
).rejects.toThrow('单文件超过限制');
|
|
});
|
|
});
|