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(); 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, ); }); });