c5295a619c
空生成任务列表不再保持四秒轮询 读取资源字节优先通过read-url直连OSS 保留read-bytes作为OSS读取失败兜底 更新资产导出与项目记忆文档口径
459 lines
15 KiB
TypeScript
459 lines
15 KiB
TypeScript
import { afterEach, describe, expect, test, vi } from 'vitest';
|
|
|
|
import { setStoredAccessToken, clearStoredAccessToken } from './apiClient';
|
|
import { clearSignedAssetReadUrlCache } from './assetReadUrlService';
|
|
import {
|
|
clearMatch3DGeneratedModelBytesCache,
|
|
getMatch3DGeneratedImageViewSources,
|
|
getMatch3DGeneratedImageAssetSources,
|
|
getMatch3DGeneratedRuntimeUiAssetSources,
|
|
getMatch3DGeneratedModelAssetSources,
|
|
hasMatch3DGeneratedImageAsset,
|
|
mergeMatch3DGeneratedItemAssetsForRuntime,
|
|
normalizeMatch3DGeneratedItemAssetsForRuntime,
|
|
preloadMatch3DGeneratedImageAssets,
|
|
preloadMatch3DGeneratedRuntimeAssets,
|
|
preloadMatch3DGeneratedModelAssets,
|
|
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',
|
|
);
|
|
});
|
|
});
|