Files
Genarrative/src/services/match3dGeneratedModelCache.test.ts
高物 ae014ac881 Switch to VectorEngine gpt-image-2 and edits
Replace uses of the legacy `gpt-image-2-all` model with `gpt-image-2` and standardize image workflows: no-reference generation uses POST /v1/images/generations, any-reference flows use POST /v1/images/edits with multipart `image` parts. Update SKILLs, generation scripts, decision logs, and docs to reflect the contract change and edits-vs-generations guidance. Apply corresponding changes across backend (api-server match3d/puzzle modules, openai image adapter, mappers, telemetry, spacetime client/module), frontend components and services (Match3D, Puzzle, CreativeImageInputPanel, runtime shells), and add new spritesheet/parser files and tests. Also add media/logo.png. These changes align repository code and documentation with the VectorEngine image API contract and update generation/upload handling (green-screen -> alpha processing, spritesheet handling, and related tests).
2026-05-22 03:06:41 +08:00

438 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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').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',
);
});
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',
);
});
});