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 = `
Z_UP1 1 1 1
0 0 0 1 0 0 0 1 00 1 2
`;
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', ''),
projectFile('model.xml', ''),
projectFile('robot.urdf', ''),
]);
expect(entries).toHaveLength(3);
expect(choosePreferredEntry(entries)).toBe('model.xml');
});
it('通过现代文件系统句柄递归读取拖入的文件夹', async () => {
const model = new File([''], '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/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([''], 'model.xml'),
new File([mapJson], 'map.json'),
new File([''], '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('') });
await expect(importBrowserFiles([new File([zipped], 'bad.zip')])).rejects.toThrow(
'路径包含越界片段',
);
});
it('拒绝同名路径', async () => {
const a = new File([''], 'model.xml');
const b = new File([''], 'model.xml');
await expect(importBrowserFiles([a, b])).rejects.toThrow('同名路径');
});
it('读取文件内容前根据元数据拒绝超过限制的输入', async () => {
const file = new File([''], '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([''], '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',
'',
);
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(/ {
const urdf = projectFile(
'robot/robot.urdf',
'',
);
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',
'',
);
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('');
expect(text).toContain('');
expect(text).toContain(' {
const zipped = zipSync({ 'model.xml': encode(`${' '.repeat(4096)}`) });
const file = new File([zipped], 'large.zip');
await expect(
importBrowserFiles([file], {
maxFiles: 2,
maxFileBytes: 128,
maxTotalBytes: 256,
maxZipBytes: 4096,
}),
).rejects.toThrow('单文件超过限制');
});
});