Files
Genarrative/packages/model3d-viewer/src/loader.test.ts
T
k88936 fb9c1d0e29 查看器取消时一并中止在途的模型下载
- packages/model3d-viewer/src/loader.ts:readModel3dViewerSource 与 loadModel3dViewerObject 新增可选 AbortSignal,透传给 fetch;signal 已中止时抛 Model3dViewerAbortedError,不再当成 load-failed
- packages/model3d-viewer/src/scene.ts:场景输入新增 signal,创建时透传给模型加载
- packages/model3d-viewer/src/Model3DViewer.tsx:加载 effect 内建 AbortController,卸载 / 换源时先 abort 再释放场景,避免下载要读完 64 MiB 才被丢弃
- packages/model3d-viewer/src/loader.test.ts:新增回归用例,断言 signal 确实交给 fetch 且中止后抛取消错误

验证:npx vitest run packages/model3d-viewer(20 用例全绿)、npx tsc 严格模式检查查看器包全部源文件
2026-09-22 13:18:40 +08:00

153 lines
4.7 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
isModel3dViewerFormatSupported,
isModel3dViewerOverSize,
MODEL3D_VIEWER_MAX_MODEL_BYTES,
Model3dViewerAbortedError,
readModel3dViewerSource,
resolveModel3dViewerFormat,
resolveModel3dViewerFormatFromBytes,
resolveModel3dViewerFormatFromMimeType,
} 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 },
]);
});
});