Files
Genarrative/packages/model3d-viewer/src/loader.test.ts
T
k88936 f4083dcb6d 3D 查看器给签名地址的兄弟资源补回鉴权 query
- loader 新增 resolveModel3dViewerSourceQuery / resolveModel3dViewerResourceUrl,只补模型目录下的资源地址
- glTF / FBX 解析改用带 URL 修饰器的专用 LoadingManager,不动全局 DefaultLoadingManager
- 补测试:query 提取与片段剔除、目录内外的补与不补、多文件 glTF 兄弟资源解析
2026-09-23 11:48:48 +08:00

265 lines
8.5 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest';
import {
isModel3dViewerFormatSupported,
isModel3dViewerOverSize,
loadModel3dViewerObject,
MODEL3D_VIEWER_MAX_MODEL_BYTES,
Model3dViewerAbortedError,
readModel3dViewerSource,
resolveModel3dViewerFormat,
resolveModel3dViewerFormatFromBytes,
resolveModel3dViewerFormatFromMimeType,
resolveModel3dViewerResourceUrl,
resolveModel3dViewerSourceQuery,
} from './loader';
describe('模型格式承诺范围', () => {
it('只承诺 glb / gltf / fbx', () => {
for (const format of ['glb', 'gltf', 'fbx']) {
expect(isModel3dViewerFormatSupported(format)).toBe(true);
}
for (const format of [
'obj',
'stl',
'usdz',
'3mf',
'',
null,
undefined,
7,
]) {
expect(isModel3dViewerFormatSupported(format)).toBe(false);
}
});
});
describe('模型体积上限', () => {
it('超过 64 MiB 判定为超限', () => {
expect(isModel3dViewerOverSize(0)).toBe(false);
expect(isModel3dViewerOverSize(MODEL3D_VIEWER_MAX_MODEL_BYTES)).toBe(false);
expect(isModel3dViewerOverSize(MODEL3D_VIEWER_MAX_MODEL_BYTES + 1)).toBe(
true,
);
});
it('非有限值不算超限,由加载流程自行失败', () => {
expect(isModel3dViewerOverSize(Number.NaN)).toBe(false);
});
});
const GLB_MAGIC = new Uint8Array([0x67, 0x6c, 0x54, 0x46, 0x02, 0x00, 0x00]);
const FBX_MAGIC = new Uint8Array([
0x4b, 0x61, 0x79, 0x64, 0x61, 0x72, 0x61, 0x20, 0x46, 0x42, 0x58, 0x20, 0x42,
0x69, 0x6e, 0x61, 0x72, 0x79, 0x00,
]);
describe('模型格式判定', () => {
it('声明类型按子串匹配各家拼法', () => {
expect(resolveModel3dViewerFormatFromMimeType('model/gltf-binary')).toBe(
'glb',
);
expect(resolveModel3dViewerFormatFromMimeType('model/gltf+json')).toBe(
'gltf',
);
expect(
resolveModel3dViewerFormatFromMimeType('application/x-fbx; charset=x'),
).toBe('fbx');
expect(
resolveModel3dViewerFormatFromMimeType('application/octet-stream'),
).toBeNull();
expect(resolveModel3dViewerFormatFromMimeType(null)).toBeNull();
});
it('字节魔数认出自包含的三种容器', () => {
expect(resolveModel3dViewerFormatFromBytes(GLB_MAGIC)).toBe('glb');
expect(resolveModel3dViewerFormatFromBytes(FBX_MAGIC)).toBe('fbx');
expect(
resolveModel3dViewerFormatFromBytes(
new TextEncoder().encode('\n {"asset":{"version":"2.0"}}'),
),
).toBe('gltf');
expect(
resolveModel3dViewerFormatFromBytes(
new TextEncoder().encode('\uFEFF{"asset":{}}'),
),
).toBe('gltf');
expect(
resolveModel3dViewerFormatFromBytes(new Uint8Array([0x00, 0x01])),
).toBeNull();
});
it('顺序是声明 → 魔数 → 地址扩展名', () => {
// 声明正确时轮不到魔数。
expect(
resolveModel3dViewerFormat({
declaredType: 'model/gltf-binary',
url: 'https://x/model.fbx',
bytes: FBX_MAGIC,
}),
).toBe('glb');
// 声明写歪(provider 给 octet-stream、扩展名也不对)时按魔数纠正。
expect(
resolveModel3dViewerFormat({
declaredType: 'application/octet-stream',
url: 'https://x/model.obj',
bytes: GLB_MAGIC,
}),
).toBe('glb');
// 没声明也没字节时只剩扩展名这条派生线索。
expect(
resolveModel3dViewerFormat({ url: 'https://x/model.glb?sig=1' }),
).toBe('glb');
expect(resolveModel3dViewerFormat({ url: 'https://x/model.FBX' })).toBe(
'fbx',
);
});
it('三条线索都给不出答案时返回 null,交给加载流程报 unsupported-format', () => {
expect(
resolveModel3dViewerFormat({
declaredType: 'application/octet-stream',
url: 'https://x/model.obj',
bytes: new Uint8Array([0x00, 0x01]),
}),
).toBeNull();
});
});
describe('模型下载可中止', () => {
it('把宿主的 signal 交给 fetch,中止后抛取消错误而不是加载失败', async () => {
const controller = new AbortController();
const originalFetch = globalThis.fetch;
const calls: Array<{ url: string; signal?: AbortSignal | null }> = [];
globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
calls.push({ url: String(input), signal: init?.signal });
controller.abort();
return Promise.reject(new DOMException('aborted', 'AbortError'));
}) as typeof fetch;
try {
await expect(
readModel3dViewerSource(
{ kind: 'url', url: 'https://x/model.glb' },
controller.signal,
),
).rejects.toBeInstanceOf(Model3dViewerAbortedError);
} finally {
globalThis.fetch = originalFetch;
}
expect(calls).toEqual([
{ url: 'https://x/model.glb', signal: controller.signal },
]);
});
});
/**
* 签名模型地址的兄弟资源补鉴权。
*
* 宿主给的是私有对象的签名地址,token 在 query 里;`GLTFLoader` / `FBXLoader` 用
* `path + uri` 拼多文件模型的兄弟 buffer 与贴图,那一层只拼目录、不带 query。
*/
describe('签名模型地址的兄弟资源补鉴权', () => {
it('从模型地址的 query 里取出鉴权参数,丢掉片段', () => {
expect(
resolveModel3dViewerSourceQuery(
'https://bucket.oss-cn-hangzhou.aliyuncs.com/editor/model3d/t-1/model.glb?token=abc&expires=1',
),
).toBe('?token=abc&expires=1');
expect(
resolveModel3dViewerSourceQuery(
'https://bucket.oss-cn-hangzhou.aliyuncs.com/m.glb?token=abc#frag',
),
).toBe('?token=abc');
expect(resolveModel3dViewerSourceQuery('https://x/m.glb?')).toBeNull();
expect(resolveModel3dViewerSourceQuery('https://x/m.glb')).toBeNull();
expect(resolveModel3dViewerSourceQuery(null)).toBeNull();
});
it('只补模型目录下的资源地址,并保留资源自己的 query', () => {
const basePath =
'https://bucket.oss-cn-hangzhou.aliyuncs.com/editor/model3d/t-1/';
const query = '?token=abc';
expect(
resolveModel3dViewerResourceUrl({
url: `${basePath}buffer.bin`,
basePath,
query,
}),
).toBe(`${basePath}buffer.bin?token=abc`);
expect(
resolveModel3dViewerResourceUrl({
url: `${basePath}textures/tex.png`,
basePath,
query,
}),
).toBe(`${basePath}textures/tex.png?token=abc`);
expect(
resolveModel3dViewerResourceUrl({
url: `${basePath}buffer.bin?v=2`,
basePath,
query,
}),
).toBe(`${basePath}buffer.bin?v=2&token=abc`);
});
it('第三方 CDN、同源其它对象、无目录与空地址都原样返回', () => {
const basePath = 'https://bucket/editor/model3d/t-1/';
const query = '?token=abc';
for (const input of [
{ url: 'https://cdn.example.com/tex.png', basePath, query },
{ url: 'https://bucket/other/tex.png', basePath, query },
{
url: 'https://bucket/editor/model3d/t-1/buffer.bin',
basePath: '',
query,
},
{ url: '', basePath, query },
]) {
expect(resolveModel3dViewerResourceUrl(input)).toBe(input.url);
}
});
it('多文件 glTF 加载时把 query 补到兄弟资源地址上', async () => {
const modelUrl =
'https://bucket.oss-cn-hangzhou.aliyuncs.com/editor/model3d/t-1/model.gltf?token=abc';
const resolvedUrls: string[] = [];
const originalFetch = globalThis.fetch;
// 动态 import 的替身只记录一次兄弟资源解析,不真的解析 glTF。
vi.doMock('three/examples/jsm/loaders/GLTFLoader.js', () => ({
GLTFLoader: class {
private readonly manager: { resolveURL: (url: string) => string };
constructor(manager: { resolveURL: (url: string) => string }) {
this.manager = manager;
}
parse(
_payload: unknown,
path: string,
onLoad: (gltf: { scene: object }) => void,
) {
resolvedUrls.push(this.manager.resolveURL(`${path}buffer.bin`));
onLoad({ scene: {} });
}
},
}));
globalThis.fetch = (async () =>
new Response(new TextEncoder().encode('{"asset":{"version":"2.0"}}'), {
headers: { 'content-type': 'model/gltf+json' },
})) as typeof fetch;
try {
await loadModel3dViewerObject({ kind: 'url', url: modelUrl });
} finally {
globalThis.fetch = originalFetch;
vi.doUnmock('three/examples/jsm/loaders/GLTFLoader.js');
}
expect(resolvedUrls).toEqual([
'https://bucket.oss-cn-hangzhou.aliyuncs.com/editor/model3d/t-1/buffer.bin?token=abc',
]);
});
});