feat: add edutainment drawing and visual package flows
This commit is contained in:
@@ -2,13 +2,18 @@ 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', () => {
|
||||
@@ -25,10 +30,88 @@ describe('babyObjectMatchClient', () => {
|
||||
});
|
||||
|
||||
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',
|
||||
});
|
||||
@@ -42,7 +125,11 @@ describe('babyObjectMatchClient', () => {
|
||||
expect(response.draft.itemNames).toEqual(['苹果', '香蕉']);
|
||||
expect(response.draft.itemAssets).toHaveLength(2);
|
||||
expect(response.draft.itemAssets[0]?.generationProvider).toBe(
|
||||
'placeholder',
|
||||
'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,
|
||||
@@ -50,6 +137,69 @@ describe('babyObjectMatchClient', () => {
|
||||
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(600_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({
|
||||
@@ -60,6 +210,7 @@ describe('babyObjectMatchClient', () => {
|
||||
});
|
||||
|
||||
test('publish normalizes exact edutainment tag into payload', async () => {
|
||||
stubSuccessfulAssetGeneration();
|
||||
const response = await createBabyObjectMatchDraft({
|
||||
itemAName: '杯子',
|
||||
itemBName: '勺子',
|
||||
@@ -80,16 +231,98 @@ describe('babyObjectMatchClient', () => {
|
||||
});
|
||||
|
||||
test('deletes local baby object match draft by profile id', async () => {
|
||||
stubSuccessfulAssetGeneration();
|
||||
const response = await createBabyObjectMatchDraft({
|
||||
itemAName: '苹果',
|
||||
itemBName: '香蕉',
|
||||
});
|
||||
|
||||
expect(listLocalBabyObjectMatchDrafts()).toHaveLength(1);
|
||||
await expect(listLocalBabyObjectMatchDrafts()).resolves.toHaveLength(1);
|
||||
|
||||
const nextItems = deleteLocalBabyObjectMatchDraft(response.draft.profileId);
|
||||
const nextItems = await deleteLocalBabyObjectMatchDraft(
|
||||
response.draft.profileId,
|
||||
);
|
||||
|
||||
expect(nextItems).toHaveLength(0);
|
||||
expect(listLocalBabyObjectMatchDrafts()).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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user