Files
Genarrative/src/services/edutainment-baby-object/babyObjectMatchClient.test.ts
高物 74fd9a33ac Increase VectorEngine timeouts and add image UI
Add VectorEngine image generation config and raise request timeouts (env + scripts) from 180000 to 1000000ms. Introduce a reusable CreativeImageInputPanel component with tests and wire up mobile keyboard-focus helpers; update generation views and related tests (CustomWorldGenerationView, BarkBattle editor, Match3D, Puzzle flows). Improve API error handling / VectorEngine request guidance (packages/shared http.ts and docs), and apply multiple backend/frontend fixes for puzzle/match3d/prompt handling. Also include extensive docs and decision-log updates describing UI/UX decisions and verification steps.
2026-05-15 02:40:59 +08:00

329 lines
11 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import {
BABY_OBJECT_MATCH_EDUTAINMENT_TAG,
type BabyObjectMatchDraft,
hasBabyObjectMatchRequiredTag,
} from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import {
__resetBabyObjectMatchLocalDraftStorageForTests,
BABY_OBJECT_MATCH_ASSET_REQUEST_TIMEOUT_MS,
createBabyObjectMatchDraft,
deleteLocalBabyObjectMatchDraft,
hasBabyObjectMatchPlaceholderAssets,
listLocalBabyObjectMatchDrafts,
publishBabyObjectMatchWork,
regenerateBabyObjectMatchDraftAssets,
} from './babyObjectMatchClient';
describe('babyObjectMatchClient', () => {
beforeEach(() => {
const store = new Map<string, string>();
vi.stubGlobal('window', {
localStorage: {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => {
store.set(key, value);
},
},
});
});
afterEach(() => {
__resetBabyObjectMatchLocalDraftStorageForTests();
vi.unstubAllGlobals();
});
function stubSuccessfulAssetGeneration() {
vi.stubGlobal(
'fetch',
vi.fn(async () => {
return new Response(
JSON.stringify({
assets: [
{
itemId: 'server-item-1',
itemName: '苹果',
imageSrc: 'data:image/png;base64,apple',
assetObjectId: null,
generationProvider: 'vector-engine-gpt-image-2',
prompt: 'prompt apple',
},
{
itemId: 'server-item-2',
itemName: '香蕉',
imageSrc: 'data:image/png;base64,banana',
assetObjectId: null,
generationProvider: 'vector-engine-gpt-image-2',
prompt: 'prompt banana',
},
],
visualPackage: {
themePrompt: '果园主题视觉包装',
assets: [
{
assetId: 'server-background',
assetKind: 'background',
imageSrc: 'data:image/png;base64,background',
assetObjectId: null,
generationProvider: 'vector-engine-gpt-image-2',
prompt: 'background prompt',
},
{
assetId: 'server-ui',
assetKind: 'ui-frame',
imageSrc: 'data:image/png;base64,ui',
assetObjectId: null,
generationProvider: 'vector-engine-gpt-image-2',
prompt: 'ui prompt',
},
{
assetId: 'server-gift',
assetKind: 'gift-box',
imageSrc: 'data:image/png;base64,gift',
assetObjectId: null,
generationProvider: 'vector-engine-gpt-image-2',
prompt: 'gift prompt',
},
{
assetId: 'server-basket',
assetKind: 'basket',
imageSrc: 'data:image/png;base64,basket',
assetObjectId: null,
generationProvider: 'vector-engine-gpt-image-2',
prompt: 'basket prompt',
},
{
assetId: 'server-smoke',
assetKind: 'smoke-puff',
imageSrc: 'data:image/png;base64,smoke',
assetObjectId: null,
generationProvider: 'vector-engine-gpt-image-2',
prompt: 'smoke prompt',
},
],
},
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
}),
);
}
test('creates local demo draft with exact edutainment tag', async () => {
stubSuccessfulAssetGeneration();
vi.stubGlobal('crypto', {
randomUUID: () => '11111111-2222-3333-4444-555555555555',
});
const response = await createBabyObjectMatchDraft({
itemAName: ' 苹果 ',
itemBName: '香蕉',
});
expect(response.draft.templateName).toBe('宝贝识物');
expect(response.draft.itemNames).toEqual(['苹果', '香蕉']);
expect(response.draft.itemAssets).toHaveLength(2);
expect(response.draft.itemAssets[0]?.generationProvider).toBe(
'vector-engine-gpt-image-2',
);
expect(response.draft.visualPackage?.assets).toHaveLength(5);
expect(response.draft.visualPackage?.assets[0]?.generationProvider).toBe(
'vector-engine-gpt-image-2',
);
expect(response.draft.themeTags).toContain(
BABY_OBJECT_MATCH_EDUTAINMENT_TAG,
);
expect(hasBabyObjectMatchRequiredTag(response.draft.themeTags)).toBe(true);
});
test('uses backend generated transparent image assets and visual package when available', async () => {
stubSuccessfulAssetGeneration();
const response = await createBabyObjectMatchDraft({
itemAName: '苹果',
itemBName: '香蕉',
});
expect(fetch).toHaveBeenCalledWith(
'/api/creation/edutainment/baby-object-match/assets',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ itemNames: ['苹果', '香蕉'] }),
signal: expect.any(AbortSignal),
}),
);
expect(BABY_OBJECT_MATCH_ASSET_REQUEST_TIMEOUT_MS).toBe(1_000_000);
expect(response.draft.itemAssets[0]).toMatchObject({
itemId: 'baby-object-item-1',
itemName: '苹果',
imageSrc: 'data:image/png;base64,apple',
generationProvider: 'vector-engine-gpt-image-2',
});
expect(response.draft.itemAssets[1]).toMatchObject({
itemId: 'baby-object-item-2',
itemName: '香蕉',
imageSrc: 'data:image/png;base64,banana',
generationProvider: 'vector-engine-gpt-image-2',
});
expect(response.draft.visualPackage?.themePrompt).toBe('果园主题视觉包装');
expect(
response.draft.visualPackage?.assets.map((asset) => asset.assetKind),
).toEqual(['background', 'ui-frame', 'gift-box', 'basket', 'smoke-puff']);
expect(response.draft.visualPackage?.assets[0]).toMatchObject({
assetId: 'baby-object-visual-background',
generationProvider: 'vector-engine-gpt-image-2',
});
});
test('rejects draft creation when backend asset generation fails', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => {
return new Response(
JSON.stringify({ error: { message: 'missing key' } }),
{
status: 503,
headers: { 'Content-Type': 'application/json' },
},
);
}),
);
await expect(
createBabyObjectMatchDraft({
itemAName: '苹果',
itemBName: '香蕉',
}),
).rejects.toThrow('missing key');
await expect(listLocalBabyObjectMatchDrafts()).resolves.toHaveLength(0);
});
test('rejects draft creation when any item name is empty', async () => {
await expect(
createBabyObjectMatchDraft({
itemAName: '苹果',
itemBName: ' ',
}),
).rejects.toThrow('请填写两个物品名称。');
});
test('publish normalizes exact edutainment tag into payload', async () => {
stubSuccessfulAssetGeneration();
const response = await createBabyObjectMatchDraft({
itemAName: '杯子',
itemBName: '勺子',
});
const published = await publishBabyObjectMatchWork({
draft: {
...response.draft,
themeTags: ['儿童教育', '寓教于乐 '],
},
});
expect(published.publicWorkCode).toMatch(/^BO-/u);
expect(published.draft.publicationStatus).toBe('published');
expect(published.draft.themeTags[0]).toBe(
BABY_OBJECT_MATCH_EDUTAINMENT_TAG,
);
expect(hasBabyObjectMatchRequiredTag(published.draft.themeTags)).toBe(true);
});
test('deletes local baby object match draft by profile id', async () => {
stubSuccessfulAssetGeneration();
const response = await createBabyObjectMatchDraft({
itemAName: '苹果',
itemBName: '香蕉',
});
await expect(listLocalBabyObjectMatchDrafts()).resolves.toHaveLength(1);
const nextItems = await deleteLocalBabyObjectMatchDraft(
response.draft.profileId,
);
expect(nextItems).toHaveLength(0);
await expect(listLocalBabyObjectMatchDrafts()).resolves.toHaveLength(0);
});
test('regenerates placeholder draft assets before playback or publish', async () => {
stubSuccessfulAssetGeneration();
const placeholderDraft: BabyObjectMatchDraft = {
draftId: 'baby-object-draft-legacy',
profileId: 'baby-object-profile-legacy',
templateId: 'baby-object-match',
templateName: '宝贝识物',
workTitle: '宝贝识物',
workDescription: '苹果和香蕉识物分类',
itemNames: ['苹果', '香蕉'],
itemAssets: [
{
itemId: 'baby-object-item-1',
itemName: '苹果',
imageSrc: 'data:image/svg+xml;utf8,a',
assetObjectId: null,
generationProvider: 'placeholder',
prompt: 'legacy apple',
},
{
itemId: 'baby-object-item-2',
itemName: '香蕉',
imageSrc: 'data:image/svg+xml;utf8,b',
assetObjectId: null,
generationProvider: 'placeholder',
prompt: 'legacy banana',
},
],
visualPackage: null,
themeTags: ['寓教于乐'],
publicationStatus: 'draft',
createdAt: '2026-05-11T00:00:00.000Z',
updatedAt: '2026-05-11T00:00:00.000Z',
publishedAt: null,
};
expect(hasBabyObjectMatchPlaceholderAssets(placeholderDraft)).toBe(true);
const response = await regenerateBabyObjectMatchDraftAssets(
placeholderDraft,
);
expect(hasBabyObjectMatchPlaceholderAssets(response.draft)).toBe(false);
expect(response.draft.itemAssets[0]?.generationProvider).toBe(
'vector-engine-gpt-image-2',
);
expect((await listLocalBabyObjectMatchDrafts())[0]?.profileId).toBe(
'baby-object-profile-legacy',
);
});
test('stores generated image drafts without writing large payloads to localStorage', async () => {
stubSuccessfulAssetGeneration();
vi.stubGlobal('window', {
localStorage: {
getItem: () => null,
setItem: () => {
throw new DOMException(
'Setting the value exceeded the quota.',
'QuotaExceededError',
);
},
removeItem: () => {},
},
});
const response = await createBabyObjectMatchDraft({
itemAName: '苹果',
itemBName: '香蕉',
});
expect(response.draft.itemAssets[0]?.generationProvider).toBe(
'vector-engine-gpt-image-2',
);
expect((await listLocalBabyObjectMatchDrafts())[0]?.profileId).toBe(
response.draft.profileId,
);
});
});