import { afterEach, describe, expect, test, vi } from 'vitest'; import { setStoredAccessToken, clearStoredAccessToken } from './apiClient'; import { clearMatch3DGeneratedModelBytesCache, getMatch3DGeneratedImageViewSources, getMatch3DGeneratedImageAssetSources, getMatch3DGeneratedModelAssetSources, hasMatch3DGeneratedImageAsset, preloadMatch3DGeneratedImageAssets, preloadMatch3DGeneratedModelAssets, readMatch3DGeneratedModelBytes, } from './match3dGeneratedModelCache'; describe('match3dGeneratedModelCache', () => { afterEach(() => { vi.restoreAllMocks(); clearMatch3DGeneratedModelBytesCache(); clearStoredAccessToken({ emit: false }); }); test('预加载生成模型字节并复用本地缓存', async () => { setStoredAccessToken('test-access-token', { emit: false }); vi.spyOn(globalThis, 'fetch').mockResolvedValue( new Response(new Uint8Array([103, 108, 84, 70]), { status: 200, headers: { 'Content-Type': 'model/gltf-binary', }, }), ); await preloadMatch3DGeneratedModelAssets( [ { itemId: 'match3d-item-1', itemName: '草莓', imageSrc: null, imageObjectKey: null, modelSrc: '/generated-match3d-assets/session/profile/items/match3d-item-1/model.glb', modelObjectKey: null, modelFileName: 'strawberry.glb', taskUuid: null, subscriptionKey: null, status: 'model_ready', error: null, }, ], { expireSeconds: 300 }, ); const bytes = await readMatch3DGeneratedModelBytes( '/generated-match3d-assets/session/profile/items/match3d-item-1/model.glb', { expireSeconds: 300 }, ); expect(Array.from(new Uint8Array(bytes))).toEqual([103, 108, 84, 70]); expect(globalThis.fetch).toHaveBeenCalledTimes(1); }); test('模型源列表会去重并兼容 modelObjectKey', () => { const sources = getMatch3DGeneratedModelAssetSources([ { itemId: 'match3d-item-1', itemName: '草莓', imageSrc: null, imageObjectKey: null, modelSrc: null, modelObjectKey: 'generated-match3d-assets/session/profile/items/match3d-item-1/model.glb', modelFileName: 'strawberry.glb', taskUuid: null, subscriptionKey: null, status: 'model_ready', error: null, }, { itemId: 'match3d-item-1-duplicate', itemName: '草莓副本', imageSrc: null, imageObjectKey: null, modelSrc: 'generated-match3d-assets/session/profile/items/match3d-item-1/model.glb', modelObjectKey: null, modelFileName: 'strawberry.glb', taskUuid: null, subscriptionKey: null, status: 'model_ready', error: null, }, ]); expect(sources).toEqual([ 'generated-match3d-assets/session/profile/items/match3d-item-1/model.glb', ]); }); test('同时存在外部 modelSrc 和平台 modelObjectKey 时优先预加载平台对象', () => { const sources = getMatch3DGeneratedModelAssetSources([ { itemId: 'match3d-item-legacy', itemName: '苹果', imageSrc: null, imageObjectKey: null, modelSrc: 'https://rodin.example.com/expired/model.glb', modelObjectKey: 'generated-match3d-assets/session/profile/items/match3d-item-legacy/model.glb', modelFileName: 'apple.glb', taskUuid: null, subscriptionKey: null, status: 'model_ready', error: null, }, ]); expect(sources).toEqual([ 'generated-match3d-assets/session/profile/items/match3d-item-legacy/model.glb', ]); }); test('多视角图片源优先使用 imageViews,兼容首图只做兜底', () => { const sources = getMatch3DGeneratedImageViewSources({ itemId: 'match3d-item-1', itemName: '草莓', imageSrc: '/generated-match3d-assets/session/profile/items/item-1/legacy-primary.png', imageObjectKey: 'generated-match3d-assets/session/profile/items/item-1/legacy-primary.png', imageViews: [1, 2, 3, 4, 5].map((viewIndex) => ({ viewId: `view-${String(viewIndex).padStart(2, '0')}`, viewIndex, imageSrc: `/generated-match3d-assets/session/profile/items/item-1/views/view-${String(viewIndex).padStart(2, '0')}.png`, imageObjectKey: null, })), modelSrc: null, modelObjectKey: null, modelFileName: null, taskUuid: null, subscriptionKey: null, status: 'image_ready', error: null, }); expect(sources).toHaveLength(5); expect(sources[0]).toContain('views/view-01.png'); expect(sources[4]).toContain('views/view-05.png'); expect(sources.some((source) => source.includes('legacy-primary'))).toBe( false, ); }); test('运行态图片素材判断只认物品图片,不把背景或音频当物品素材', () => { expect( hasMatch3DGeneratedImageAsset([ { itemId: 'match3d-item-1', itemName: '草莓', imageSrc: null, imageObjectKey: null, imageViews: [], modelSrc: null, modelObjectKey: null, status: 'image_ready', backgroundMusic: { taskId: 'music-task-1', provider: 'vector-engine-suno', assetObjectId: 'asset-music-1', assetKind: 'match3d_background_music', audioSrc: '/generated-match3d-assets/session/profile/audio/background.mp3', prompt: '', title: '果园轻舞', updatedAt: '2026-05-13T10:00:00.000Z', }, backgroundAsset: { prompt: '果园背景', imageSrc: '/generated-match3d-assets/session/profile/background/background.png', imageObjectKey: null, containerPrompt: '果园浅盘', containerImageSrc: '/generated-match3d-assets/session/profile/ui-container/container.png', containerImageObjectKey: null, status: 'image_ready', error: null, }, }, ]), ).toBe(false); expect( hasMatch3DGeneratedImageAsset([ { itemId: 'match3d-item-1', itemName: '草莓', imageSrc: null, imageObjectKey: null, imageViews: [ { viewId: 'view-01', viewIndex: 1, imageSrc: '/generated-match3d-assets/session/profile/items/item-1/views/view-01.png', imageObjectKey: null, }, ], modelSrc: null, modelObjectKey: null, status: 'image_ready', }, ]), ).toBe(true); }); test('运行态预加载使用 2D 图片源而不是旧模型源', async () => { setStoredAccessToken('test-access-token', { emit: false }); vi.spyOn(globalThis, 'fetch').mockResolvedValue( new Response( JSON.stringify({ read: { signedUrl: 'https://oss.example.com/view-01.png', expiresAt: new Date(Date.now() + 60_000).toISOString(), }, }), { status: 200, headers: { 'Content-Type': 'application/json' }, }, ), ); const assets = [ { itemId: 'match3d-item-1', itemName: '草莓', imageSrc: null, imageObjectKey: null, imageViews: [ { viewId: 'view-01', viewIndex: 1, imageSrc: null, imageObjectKey: 'generated-match3d-assets/session/profile/items/item-1/views/view-01.png', }, ], modelSrc: '/generated-match3d-assets/session/profile/items/item-1/model/model.glb', modelObjectKey: null, status: 'image_ready', }, ]; expect(getMatch3DGeneratedImageAssetSources(assets)).toEqual([ 'generated-match3d-assets/session/profile/items/item-1/views/view-01.png', ]); await preloadMatch3DGeneratedImageAssets(assets, { expireSeconds: 300 }); expect(globalThis.fetch).toHaveBeenCalledTimes(1); expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain( '/api/assets/read-url', ); expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain( 'views%2Fview-01.png', ); }); });