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.
329 lines
11 KiB
TypeScript
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,
|
|
);
|
|
});
|
|
});
|