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 0

0 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('单文件超过限制'); }); });