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 严格模式检查查看器包全部源文件
153 lines
4.7 KiB
TypeScript
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 },
|
|
]);
|
|
});
|
|
});
|