chore(web-platform): release V0.6.1 工程质量优化
This commit is contained in:
@@ -1,21 +1,157 @@
|
||||
import {zipSync} from 'fflate';
|
||||
import {choosePreferredEntry,discoverEntries,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">
|
||||
import { zipSync } from 'fflate';
|
||||
import {
|
||||
choosePreferredEntry,
|
||||
discoverEntries,
|
||||
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('解压 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 result=await importBrowserFiles([file]);expect(result.files.map(f=>f.path)).toContain('robot/mesh.obj');expect(result.selectedEntry).toBe('robot/model.urdf');});
|
||||
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');await expect(importBrowserFiles([file],{maxFiles:1,maxFileBytes:2,maxTotalBytes:2,maxZipBytes:2})).rejects.toThrow('单文件超过限制');});
|
||||
it('规范化 MuJoCo 不接受的重复 material 和 ROS package URI',()=>{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'}],selectedEntry:urdf.path,totalBytes:urdf.size+1};const prepared=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',()=>{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'}],selectedEntry:urdf.path,totalBytes:urdf.size+dae.size};const prepared=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 缺失或转换失败时安全降级',()=>{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'}],selectedEntry:urdf.path,totalBytes:urdf.size};const prepared=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('单文件超过限制');});
|
||||
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('解压 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 result = await importBrowserFiles([file]);
|
||||
expect(result.files.map((f) => f.path)).toContain('robot/mesh.obj');
|
||||
expect(result.selectedEntry).toBe('robot/model.urdf');
|
||||
});
|
||||
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');
|
||||
await expect(
|
||||
importBrowserFiles([file], {
|
||||
maxFiles: 1,
|
||||
maxFileBytes: 2,
|
||||
maxTotalBytes: 2,
|
||||
maxZipBytes: 2,
|
||||
}),
|
||||
).rejects.toThrow('单文件超过限制');
|
||||
});
|
||||
it('规范化 MuJoCo 不接受的重复 material 和 ROS package URI', () => {
|
||||
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' }],
|
||||
selectedEntry: urdf.path,
|
||||
totalBytes: urdf.size + 1,
|
||||
};
|
||||
const prepared = 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', () => {
|
||||
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' }],
|
||||
selectedEntry: urdf.path,
|
||||
totalBytes: urdf.size + dae.size,
|
||||
};
|
||||
const prepared = 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 缺失或转换失败时安全降级', () => {
|
||||
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' }],
|
||||
selectedEntry: urdf.path,
|
||||
totalBytes: urdf.size,
|
||||
};
|
||||
const prepared = 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('单文件超过限制');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user