Files
Genarrative/src/services/match3dGeneratedModelCache.test.ts
T
kdletters 387a1c26e3 完成全量检查整改
清理已跟踪原始日志与个人本地配置
修复前后端有效门禁、测试和构建问题
补齐生产 Jenkins、SpacetimeDB 本地命令与文档约束
收紧图片编辑器状态、附件与媒体引用测试
排除已下线旧创作入口测试并清理 warning
2026-07-17 16:56:46 +08:00

459 lines
15 KiB
TypeScript

import { afterEach, describe, expect, test, vi } from 'vitest';
import { clearStoredAccessToken,setStoredAccessToken } from './apiClient';
import { clearSignedAssetReadUrlCache } from './assetReadUrlService';
import {
clearMatch3DGeneratedModelBytesCache,
getMatch3DGeneratedImageAssetSources,
getMatch3DGeneratedImageViewSources,
getMatch3DGeneratedModelAssetSources,
getMatch3DGeneratedRuntimeUiAssetSources,
hasMatch3DGeneratedImageAsset,
mergeMatch3DGeneratedItemAssetsForRuntime,
normalizeMatch3DGeneratedItemAssetsForRuntime,
preloadMatch3DGeneratedImageAssets,
preloadMatch3DGeneratedModelAssets,
preloadMatch3DGeneratedRuntimeAssets,
readMatch3DGeneratedModelBytes,
} from './match3dGeneratedModelCache';
describe('match3dGeneratedModelCache', () => {
afterEach(() => {
vi.restoreAllMocks();
clearMatch3DGeneratedModelBytesCache();
clearSignedAssetReadUrlCache();
clearStoredAccessToken({ emit: false });
});
test('预加载生成模型字节并复用本地缓存', async () => {
setStoredAccessToken('test-access-token', { emit: false });
vi.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(
new Response(
JSON.stringify({
read: {
signedUrl: 'https://oss.example.com/model.glb',
expiresAt: new Date(Date.now() + 60_000).toISOString(),
},
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
},
),
)
.mockResolvedValueOnce(
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(2);
expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain(
'/api/assets/read-url',
);
expect(String(vi.mocked(globalThis.fetch).mock.calls[1]?.[0])).toBe(
'https://oss.example.com/model.glb',
);
});
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',
);
});
test('运行态预加载同时解析背景和spritesheet资产', async () => {
setStoredAccessToken('test-access-token', { emit: false });
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(
JSON.stringify({
read: {
signedUrl: 'https://oss.example.com/match3d-asset.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: null,
modelObjectKey: null,
status: 'image_ready',
backgroundAsset: {
prompt: '果园背景',
imageSrc: null,
imageObjectKey:
'generated-match3d-assets/session/profile/background/task/background.png',
uiSpritesheetPrompt: '果园 UI',
uiSpritesheetImageSrc: null,
uiSpritesheetImageObjectKey:
'generated-match3d-assets/session/profile/ui-spritesheet/task/ui.png',
itemSpritesheetPrompt: '果园物品',
itemSpritesheetImageSrc: null,
itemSpritesheetImageObjectKey:
'generated-match3d-assets/session/profile/item-spritesheet/task/items.png',
containerPrompt: '果园浅盘',
containerImageSrc: null,
containerImageObjectKey:
'generated-match3d-assets/session/profile/ui-container/task/container.png',
status: 'image_ready',
error: null,
},
},
];
expect(getMatch3DGeneratedRuntimeUiAssetSources(assets)).toEqual([
'generated-match3d-assets/session/profile/background/task/background.png',
'generated-match3d-assets/session/profile/ui-spritesheet/task/ui.png',
'generated-match3d-assets/session/profile/item-spritesheet/task/items.png',
'generated-match3d-assets/session/profile/ui-container/task/container.png',
]);
await preloadMatch3DGeneratedRuntimeAssets(assets, null, {
expireSeconds: 300,
});
expect(globalThis.fetch).toHaveBeenCalledTimes(5);
expect(
vi
.mocked(globalThis.fetch)
.mock.calls.map((call) => decodeURIComponent(String(call[0]))),
).toEqual(
expect.arrayContaining([
expect.stringContaining('/items/item-1/views/view-01.png'),
expect.stringContaining('/background/task/background.png'),
expect.stringContaining('/ui-spritesheet/task/ui.png'),
expect.stringContaining('/item-spritesheet/task/items.png'),
expect.stringContaining('/ui-container/task/container.png'),
]),
);
});
test('作品级背景音乐会归一化到首个抓大鹅素材', () => {
const assets = normalizeMatch3DGeneratedItemAssetsForRuntime([
{
itemId: 'match3d-item-1',
itemName: '草莓',
imageSrc: '/match3d/strawberry.png',
imageObjectKey: null,
modelSrc: null,
modelObjectKey: null,
status: 'image_ready',
},
{
itemId: 'match3d-item-2',
itemName: '苹果',
imageSrc: '/match3d/apple.png',
imageObjectKey: null,
modelSrc: null,
modelObjectKey: null,
status: 'image_ready',
backgroundMusicTitle: '果园轻舞',
backgroundMusic: {
taskId: 'music-task-1',
provider: 'vector-engine-suno',
assetObjectId: 'asset-music-1',
assetKind: 'match3d_background_music',
audioSrc: '/generated-match3d-assets/audio/music.mp3',
prompt: '',
title: '果园轻舞',
updatedAt: '2026-05-14T00:00:00.000Z',
},
},
]);
expect(assets[0]?.backgroundMusic?.audioSrc).toBe(
'/generated-match3d-assets/audio/music.mp3',
);
expect(assets[0]?.backgroundMusicTitle).toBe('果园轻舞');
expect(assets[1]?.backgroundMusic).toBeNull();
});
test('合并 action 草稿和作品详情时保留详情里的背景音乐', () => {
const assets = mergeMatch3DGeneratedItemAssetsForRuntime(
[
{
itemId: 'match3d-item-1',
itemName: '草莓',
imageSrc: '/match3d/strawberry.png',
imageObjectKey: null,
modelSrc: null,
modelObjectKey: null,
status: 'image_ready',
},
],
[
{
itemId: 'match3d-item-1',
itemName: '草莓',
imageSrc: '/match3d/strawberry.png',
imageObjectKey: null,
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/audio/music.mp3',
prompt: '',
title: '果园轻舞',
updatedAt: '2026-05-14T00:00:00.000Z',
},
},
],
);
expect(assets).toHaveLength(1);
expect(assets[0]?.backgroundMusic?.audioSrc).toBe(
'/generated-match3d-assets/audio/music.mp3',
);
});
});