import { afterEach, describe, expect, it, vi } from 'vitest'; import backgroundMusicPromptCanonicalizationCases from '../../../packages/shared/test-fixtures/background-music-prompt-canonicalization.json'; import { completeEditorBackgroundMusicPrompt, createEditorAsset, createEditorAssetFolder, createEditorProject, createEditorProjectResource, deleteEditorAsset, deleteEditorAssetFolder, deleteEditorProject, editEditorImage, EDITOR_BACKGROUND_MUSIC_PROMPT_ASSIST_TIMEOUT_MS, EDITOR_ICON_DESCRIPTION_MAX_CHARS, EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_CHARS, EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_UTF8_BYTES, EDITOR_ICON_SPEC_PROMPT_MAX_LENGTH, EDITOR_SOUND_EFFECT_PROMPT_OPTIMIZE_TIMEOUT_MS, extractEditorUiDesignAssets, generateEditorBackgroundMusic, generateEditorCharacterAnimation, generateEditorIconSpec, generateEditorIconSpritesheet, generateEditorImage, generateEditorScene, generateEditorSoundEffect, generateEditorVideo, listEditorProjects, listPublicEditorProjectResources, loadEditorAssetLibrary, loadEditorGenerationPricing, loadEditorProject, loadOrCreateRecentEditorProject, optimizeEditorSoundEffectPrompt, refineEditorIconSpecArtStyle, refineEditorIconSpecPlaySetting, removeEditorImageBackground, renameEditorProject, saveEditorProjectLayout, simplifyEditorBackgroundMusicPrompt, snapEditorImageToPixelArt, splitEditorIconSpritesheet, submitEditorAssetShowcase, toggleEditorShowcaseAssetLike, updateEditorAsset, updateEditorAssetFolder, updateEditorProjectResourceShowcase, } from './editorProjectClient'; const requestJsonMock = vi.hoisted(() => vi.fn()); const editorRetryOptionsExpectation = expect.objectContaining({ maxRetries: 0, retryUnsafeMethods: false, }); // 助手请求只带有界超时:额外的 transport retry 会把一次业务语义轮放大成多次上游调用。 const promptAssistRequestOptions = { timeoutMs: EDITOR_BACKGROUND_MUSIC_PROMPT_ASSIST_TIMEOUT_MS, }; vi.mock('../apiClient', () => ({ requestJson: requestJsonMock, })); describe('editorProjectClient', () => { afterEach(() => { requestJsonMock.mockReset(); }); it('loads the recent project without creating a duplicate when it exists', async () => { requestJsonMock.mockResolvedValueOnce({ project: { projectId: 'editor-project-1', title: '未命名画布', canvas: { canvasId: 'editor-project-1:canvas:default', projectId: 'editor-project-1', title: '默认画布', viewport: { x: 0, y: 0, scale: 1 }, layers: [], updatedAt: '2026-06-12T00:00:00.000Z', }, viewport: { x: 0, y: 0, scale: 1 }, layers: [], resources: [], updatedAt: '2026-06-12T00:00:00.000Z', }, }); const project = await loadOrCreateRecentEditorProject(); expect(project.projectId).toBe('editor-project-1'); expect(requestJsonMock).toHaveBeenCalledTimes(1); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/projects/recent', { method: 'GET' }, '读取图片画布工程失败', ); }); it('loads editor generation pricing without requiring auth', async () => { requestJsonMock.mockResolvedValueOnce({ models: { 'gemini-3.1-flash-image-preview': { unit: 'perGeneration', prices: { '0.5K': 9, '1K': 18, '2K': 36 }, }, 'gpt-image-2': { unit: 'perGeneration', prices: { '1K': 29, '2K': 58 }, }, 'seedance2.0': { unit: 'perSecond', prices: { '480p': 13, '720p': 26, '1080p': 52 }, }, 'audio1.0': { unit: 'perGeneration', price: 15 }, eleven_text_to_sound_v2: { unit: 'perGeneration', price: 16 }, 'chirp-v5': { unit: 'perGeneration', price: 9 }, }, }); const pricing = await loadEditorGenerationPricing(); expect(pricing.models['gpt-image-2']?.prices?.['1K']).toBe(29); expect(pricing.models.eleven_text_to_sound_v2?.price).toBe(16); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/generation-pricing', { method: 'GET' }, '读取模型定价失败', { skipAuth: true, skipRefresh: true, notifyAuthStateChange: false, clearAuthOnUnauthorized: false, }, ); }); it('creates a default project when there is no recent project', async () => { requestJsonMock .mockResolvedValueOnce({ project: null }) .mockResolvedValueOnce({ project: { projectId: 'editor-project-created', title: '未命名画布', canvas: { canvasId: 'editor-project-created:canvas:default', projectId: 'editor-project-created', title: '默认画布', viewport: { x: 0, y: 0, scale: 1 }, layers: [], updatedAt: '2026-06-12T00:00:00.000Z', }, viewport: { x: 0, y: 0, scale: 1 }, layers: [], resources: [], updatedAt: '2026-06-12T00:00:00.000Z', }, }); const project = await loadOrCreateRecentEditorProject(); expect(project.projectId).toBe('editor-project-created'); expect(requestJsonMock).toHaveBeenNthCalledWith( 2, '/api/editor/projects', expect.objectContaining({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: '未命名画布' }), }), '创建图片画布工程失败', ); }); it('saves viewport and layer layout through the project API', async () => { const startedAt = Date.now(); requestJsonMock.mockResolvedValueOnce({ projectId: 'editor-project-1', canvasId: 'editor-project-1:canvas:default', revision: 8, updatedAt: '2026-06-12T00:00:00.000Z', }); const result = await saveEditorProjectLayout('editor-project-1', { viewport: { x: 12, y: 24, scale: 0.5 }, layers: [{ layerId: 'layer-1', resourceId: 'resource-1', x: 10, y: 20 }], expectedRevision: 7, }); expect(result).toEqual({ projectId: 'editor-project-1', canvasId: 'editor-project-1:canvas:default', revision: 8, updatedAt: '2026-06-12T00:00:00.000Z', }); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/projects/editor-project-1', expect.objectContaining({ method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ viewport: { x: 12, y: 24, scale: 0.5 }, layers: [ { layerId: 'layer-1', resourceId: 'resource-1', x: 10, y: 20 }, ], expectedRevision: 7, }), }), '保存图片画布工程失败', { deadlineAt: expect.any(Number) }, ); const requestOptions = requestJsonMock.mock.calls[0]?.[3] as | { deadlineAt?: number } | undefined; expect(requestOptions?.deadlineAt).toBeGreaterThanOrEqual( startedAt + 60_000, ); expect(requestOptions?.deadlineAt).toBeLessThanOrEqual(Date.now() + 60_000); }); it('lists editor projects from the project API', async () => { requestJsonMock.mockResolvedValueOnce({ projects: [ { projectId: 'editor-project-1', title: '角色设定板', canvas: { canvasId: 'editor-project-1:canvas:default', projectId: 'editor-project-1', title: '默认画布', viewport: { x: 0, y: 0, scale: 1 }, layers: [], updatedAt: '2026-06-12T00:00:00.000Z', }, viewport: { x: 0, y: 0, scale: 1 }, layers: [], resources: [], updatedAt: '2026-06-12T00:00:00.000Z', }, ], }); const projects = await listEditorProjects(); expect(projects).toHaveLength(1); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/projects', { method: 'GET' }, '读取图片画布工程列表失败', ); }); it('loads public showcase project resources without requiring auth', async () => { requestJsonMock.mockResolvedValueOnce({ resources: [ { resourceId: 'resource-public-1', projectId: 'editor-project-1', ownerUserId: 'user-public-author', authorDisplayName: '公开作者', authorPublicUserCode: 'SY-00000042', label: '公开角色素材', imageSrc: '/generated-editor-assets/public.png', width: 512, height: 512, sourceType: 'generated', assetKind: 'character-animation', imageSequenceFrames: [ { imageSrc: '/generated-editor-assets/public-frame-1.png', objectKey: 'generated-editor-assets/public-frame-1.png', width: 512, height: 512, }, { imageSrc: '/generated-editor-assets/public-frame-2.png', objectKey: 'generated-editor-assets/public-frame-2.png', width: 512, height: 512, }, ], imageSequenceDurationMs: 6_000, publicShowcaseEnabled: true, viewerLiked: false, }, ], nextCursor: 'cursor-next', campaign: { enabled: true, title: '活动精选', imageSrc: '/campaign.png', imageWidth: 900, imageHeight: 1200, prompt: '活动提示词', author: '运营', costText: '50泥点', }, }); const page = await listPublicEditorProjectResources(); expect(page.resources).toHaveLength(1); expect(page.resources[0]?.resourceId).toBe('resource-public-1'); expect(page.resources[0]?.ownerUserId).toBe('user-public-author'); expect(page.resources[0]?.authorDisplayName).toBe('公开作者'); expect(page.resources[0]?.authorPublicUserCode).toBe('SY-00000042'); expect(page.resources[0]?.imageSequenceFrames).toHaveLength(2); expect(page.resources[0]?.imageSequenceDurationMs).toBe(6_000); expect(page.resources[0]?.viewerLiked).toBe(false); expect(page.nextCursor).toBe('cursor-next'); expect(page.campaign?.title).toBe('活动精选'); expect(page.campaign?.imageWidth).toBe(900); expect(page.campaign?.imageHeight).toBe(1200); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/showcase/resources', { method: 'GET' }, '读取陶泥儿精选素材失败', { skipAuth: true, skipRefresh: true, notifyAuthStateChange: false, clearAuthOnUnauthorized: false, }, ); }); it('loads public showcase project resources after a cursor', async () => { requestJsonMock.mockResolvedValueOnce({ resources: [], nextCursor: null, }); const page = await listPublicEditorProjectResources({ cursor: ' cursor-1 ', }); expect(page.resources).toEqual([]); expect(page.nextCursor).toBeNull(); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/showcase/resources?cursor=cursor-1', { method: 'GET' }, '读取陶泥儿精选素材失败', { skipAuth: true, skipRefresh: true, notifyAuthStateChange: false, clearAuthOnUnauthorized: false, }, ); }); it('loads viewer-specific showcase state with authentication enabled', async () => { requestJsonMock.mockResolvedValueOnce({ resources: [ { resourceId: 'showcase-1', projectId: 'editor-showcase', imageSrc: '/showcase-1.png', width: 512, height: 512, sourceType: 'generated', viewerLiked: true, }, ], nextCursor: null, }); const page = await listPublicEditorProjectResources({ viewer: 'authenticated', }); expect(page.resources[0]?.viewerLiked).toBe(true); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/showcase/resources', { method: 'GET' }, '读取陶泥儿精选素材失败', ); }); it('loads an explicit project by id', async () => { requestJsonMock.mockResolvedValueOnce({ project: { projectId: 'editor-project-1', title: '角色设定板', canvas: { canvasId: 'editor-project-1:canvas:default', projectId: 'editor-project-1', title: '默认画布', viewport: { x: 8, y: 9, scale: 1.5 }, layers: [], updatedAt: '2026-06-12T00:00:00.000Z', }, viewport: { x: 8, y: 9, scale: 1.5 }, layers: [], resources: [], updatedAt: '2026-06-12T00:00:00.000Z', }, }); const project = await loadEditorProject('editor-project-1'); expect(project.viewport.scale).toBe(1.5); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/projects/editor-project-1', { method: 'GET' }, '读取图片画布工程失败', // 中文注释:同上,超时写死在断言里。这是未知结果对账路径上的读取,挂住会让 catch // 迟迟不结束,连带把本会话对占位的归属登记一起拖过存活窗口。 { timeoutMs: 60_000 }, ); }); it('forwards an abort signal and custom timeout when loading a project', async () => { const controller = new AbortController(); requestJsonMock.mockResolvedValueOnce({ project: { projectId: 'editor-project-1', title: '角色设定板', canvas: { canvasId: 'editor-project-1:canvas:default', projectId: 'editor-project-1', title: '默认画布', viewport: { x: 8, y: 9, scale: 1.5 }, layers: [], updatedAt: '2026-06-12T00:00:00.000Z', }, viewport: { x: 8, y: 9, scale: 1.5 }, layers: [], resources: [], updatedAt: '2026-06-12T00:00:00.000Z', }, }); await loadEditorProject('editor-project-1', { signal: controller.signal, timeoutMs: 5_000, }); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/projects/editor-project-1', { method: 'GET', signal: controller.signal }, '读取图片画布工程失败', { timeoutMs: 5_000 }, ); }); it('forwards an absolute lifecycle deadline when loading a project', async () => { const controller = new AbortController(); const deadlineAt = Date.now() + 10_000; requestJsonMock.mockResolvedValueOnce({ project: { projectId: 'editor-project-1', title: '角色设定板', viewport: { x: 8, y: 9, scale: 1.5 }, layers: [], resources: [], updatedAt: '2026-06-12T00:00:00.000Z', }, }); await loadEditorProject('editor-project-1', { signal: controller.signal, deadlineAt, }); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/projects/editor-project-1', { method: 'GET', signal: controller.signal }, '读取图片画布工程失败', { deadlineAt }, ); }); it('renames and deletes an editor project', async () => { requestJsonMock .mockResolvedValueOnce({ project: { projectId: 'editor-project-1', title: '新标题', canvas: { canvasId: 'editor-project-1:canvas:default', projectId: 'editor-project-1', title: '默认画布', viewport: { x: 0, y: 0, scale: 1 }, layers: [], updatedAt: '2026-06-12T00:00:00.000Z', }, viewport: { x: 0, y: 0, scale: 1 }, layers: [], resources: [], updatedAt: '2026-06-12T00:00:00.000Z', }, }) .mockResolvedValueOnce({ deletedProjectId: 'editor-project-1' }); await renameEditorProject('editor-project-1', '新标题'); await deleteEditorProject('editor-project-1'); expect(requestJsonMock).toHaveBeenNthCalledWith( 1, '/api/editor/projects/editor-project-1/metadata', expect.objectContaining({ method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: '新标题' }), }), '重命名图片画布工程失败', ); expect(requestJsonMock).toHaveBeenNthCalledWith( 2, '/api/editor/projects/editor-project-1', { method: 'DELETE' }, '删除图片画布工程失败', ); }); it('creates a resource with upload or generated metadata', async () => { requestJsonMock.mockResolvedValueOnce({ resource: { resourceId: 'resource-generated-1', projectId: 'editor-project-1', imageSrc: '/generated-editor-assets/project/image.png', objectKey: 'generated-editor-assets/project/image.png', width: 2048, height: 2048, sourceType: 'generated', prompt: 'dragon knight', model: 'gpt-image-2', }, }); await createEditorProjectResource('editor-project-1', { imageSrc: '/generated-editor-assets/project/image.png', objectKey: 'generated-editor-assets/project/image.png', width: 2048, height: 2048, sourceType: 'generated', prompt: 'dragon knight', model: 'gpt-image-2', sourceResourceId: 'resource-source', assetKind: 'character', generationInputs: { fields: [{ title: '角色设定', value: 'dragon knight' }], references: [], }, }); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/projects/editor-project-1/resources', expect.objectContaining({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ imageSrc: '/generated-editor-assets/project/image.png', objectKey: 'generated-editor-assets/project/image.png', width: 2048, height: 2048, sourceType: 'generated', prompt: 'dragon knight', model: 'gpt-image-2', sourceResourceId: 'resource-source', assetKind: 'character', generationInputs: { fields: [{ title: '角色设定', value: 'dragon knight' }], references: [], }, }), }), '创建图片画布资源失败', ); }); it('passes image sequence duration milliseconds without rounding', async () => { requestJsonMock .mockResolvedValueOnce({ resource: { resourceId: 'resource-action', projectId: 'editor-project-1', imageSrc: '/generated-actions/frame-01.png', width: 1024, height: 1024, sourceType: 'generated', assetKind: 'character-animation', imageSequenceDurationMs: 2_600, }, }) .mockResolvedValueOnce({ asset: { assetId: 'asset-action', folderId: 'project', label: '角色动作', imageSrc: '/generated-actions/frame-01.png', width: 1024, height: 1024, sourceType: 'generated', assetKind: 'character-animation', imageSequenceDurationMs: 2_400, }, }); await createEditorProjectResource('editor-project-1', { imageSrc: '/generated-actions/frame-01.png', width: 1024, height: 1024, sourceType: 'generated', assetKind: 'character-animation', imageSequenceFrames: [ { imageSrc: '/generated-actions/frame-01.png', width: 1024, height: 1024, }, { imageSrc: '/generated-actions/frame-02.png', width: 1024, height: 1024, }, ], imageSequenceDurationMs: 2_600, }); await createEditorAsset({ folderId: 'project', label: '角色动作', imageSrc: '/generated-actions/frame-01.png', width: 1024, height: 1024, sourceType: 'generated', assetKind: 'character-animation', imageSequenceFrames: [ { imageSrc: '/generated-actions/frame-01.png', width: 1024, height: 1024, }, { imageSrc: '/generated-actions/frame-02.png', width: 1024, height: 1024, }, ], imageSequenceDurationMs: 2_400, }); expect(requestJsonMock).toHaveBeenNthCalledWith( 1, '/api/editor/projects/editor-project-1/resources', expect.objectContaining({ body: expect.stringContaining('"imageSequenceDurationMs":2600'), }), '创建图片画布资源失败', ); expect(requestJsonMock).toHaveBeenNthCalledWith( 2, '/api/editor/assets', expect.objectContaining({ body: expect.stringContaining('"imageSequenceDurationMs":2400'), }), '创建图片画布素材失败', ); }); it('updates project resource showcase visibility', async () => { requestJsonMock.mockResolvedValueOnce({ resource: { resourceId: 'resource-generated-1', projectId: 'editor-project-1', label: '角色素材', imageSrc: '/generated-editor-assets/project/image.png', objectKey: 'generated-editor-assets/project/image.png', width: 2048, height: 2048, sourceType: 'generated', publicShowcaseEnabled: false, }, }); const resource = await updateEditorProjectResourceShowcase( 'resource-generated-1', false, ); expect(resource.publicShowcaseEnabled).toBe(false); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/project-resources/resource-generated-1/showcase', expect.objectContaining({ method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ publicShowcaseEnabled: false }), }), '更新素材公开展示状态失败', ); }); it('submits account assets to showcase review', async () => { requestJsonMock.mockResolvedValueOnce({ showcaseAsset: { resourceId: 'showcase-asset-1', showcaseId: 'showcase-asset-1', assetId: 'asset-1', projectId: 'editor-showcase', imageSrc: '/generated-editor-assets/project/image.png', width: 2048, height: 2048, sourceType: 'generated', reviewStatus: 'pending', displayEnabled: false, likeCount: 0, generationCostMudPoints: 20, refundMudPoints: 10, }, }); const showcaseAsset = await submitEditorAssetShowcase('asset-1'); expect(showcaseAsset.showcaseId).toBe('showcase-asset-1'); expect(showcaseAsset.reviewStatus).toBe('pending'); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/assets/asset-1/showcase-submissions', { method: 'POST' }, '提交精选审核失败', ); }); it('toggles showcase asset likes', async () => { requestJsonMock.mockResolvedValueOnce({ showcaseAsset: { resourceId: 'showcase-asset-1', showcaseId: 'showcase-asset-1', assetId: 'asset-1', projectId: 'editor-showcase', imageSrc: '/generated-editor-assets/project/image.png', width: 2048, height: 2048, sourceType: 'generated', reviewStatus: 'approved', displayEnabled: true, likeCount: 8, viewerLiked: true, generationCostMudPoints: 20, refundMudPoints: 10, }, }); const showcaseAsset = await toggleEditorShowcaseAssetLike( 'showcase-asset-1', true, ); expect(showcaseAsset.likeCount).toBe(8); expect(showcaseAsset.viewerLiked).toBe(true); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/showcase/assets/showcase-asset-1/likes', expect.objectContaining({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ liked: true }), }), '点赞精选素材失败', ); }); it('loads and mutates the account-level asset library', async () => { requestJsonMock .mockResolvedValueOnce({ library: { folders: [ { folderId: 'folder-project', label: '项目素材', sortOrder: 0, collapsed: false, systemDefault: true, }, ], assets: [], }, }) .mockResolvedValueOnce({ folder: { folderId: 'folder-role', label: '角色', sortOrder: 100, collapsed: false, systemDefault: false, }, }) .mockResolvedValueOnce({ folder: { folderId: 'folder-role', label: '角色参考', sortOrder: 100, collapsed: true, systemDefault: false, }, }) .mockResolvedValueOnce({ library: { folders: [], assets: [], }, }); await loadEditorAssetLibrary(); await createEditorAssetFolder('角色', 100); await updateEditorAssetFolder('folder-role', { label: '角色参考', collapsed: true, }); await deleteEditorAssetFolder('folder-role'); expect(requestJsonMock).toHaveBeenNthCalledWith( 1, '/api/editor/assets/library', { method: 'GET' }, '读取图片画布素材库失败', ); expect(requestJsonMock).toHaveBeenNthCalledWith( 2, '/api/editor/assets/folders', expect.objectContaining({ method: 'POST', body: JSON.stringify({ label: '角色', sortOrder: 100 }), }), '创建图片画布素材文件夹失败', ); expect(requestJsonMock).toHaveBeenNthCalledWith( 3, '/api/editor/assets/folders/folder-role', expect.objectContaining({ method: 'PATCH', body: JSON.stringify({ label: '角色参考', collapsed: true }), }), '更新图片画布素材文件夹失败', ); expect(requestJsonMock).toHaveBeenNthCalledWith( 4, '/api/editor/assets/folders/folder-role', { method: 'DELETE' }, '删除图片画布素材文件夹失败', ); }); it('creates, updates, and deletes account-level image assets', async () => { requestJsonMock .mockResolvedValueOnce({ asset: { assetId: 'asset-1', folderId: 'folder-project', label: '主视觉.png', imageSrc: 'data:image/png;base64,ZmFrZQ==', width: 640, height: 480, sourceType: 'uploaded', }, }) .mockResolvedValueOnce({ asset: { assetId: 'asset-1', folderId: 'folder-role', label: '角色主视觉.png', imageSrc: 'data:image/png;base64,ZmFrZQ==', width: 640, height: 480, sourceType: 'uploaded', }, }) .mockResolvedValueOnce({ asset: { assetId: 'asset-1', folderId: 'folder-role', label: '角色主视觉.png', imageSrc: 'data:image/png;base64,ZmFrZQ==', width: 640, height: 480, sourceType: 'uploaded', }, }); await createEditorAsset({ folderId: 'folder-project', label: '主视觉.png', imageSrc: 'data:image/png;base64,ZmFrZQ==', width: 640, height: 480, sourceType: 'uploaded', assetKind: 'icon', generationInputs: { fields: [{ title: '素材描述', value: '返回按钮' }], references: [], }, }); await updateEditorAsset('asset-1', { label: '角色主视觉.png', folderId: 'folder-role', }); await deleteEditorAsset('asset-1'); expect(requestJsonMock).toHaveBeenNthCalledWith( 1, '/api/editor/assets', expect.objectContaining({ method: 'POST', body: JSON.stringify({ folderId: 'folder-project', label: '主视觉.png', imageSrc: 'data:image/png;base64,ZmFrZQ==', width: 640, height: 480, sourceType: 'uploaded', assetKind: 'icon', generationInputs: { fields: [{ title: '素材描述', value: '返回按钮' }], references: [], }, }), }), '创建图片画布素材失败', ); expect(requestJsonMock).toHaveBeenNthCalledWith( 2, '/api/editor/assets/asset-1', expect.objectContaining({ method: 'PATCH', body: JSON.stringify({ label: '角色主视觉.png', folderId: 'folder-role', }), }), '更新图片画布素材失败', ); expect(requestJsonMock).toHaveBeenNthCalledWith( 3, '/api/editor/assets/asset-1', { method: 'DELETE' }, '删除图片画布素材失败', ); }); it('creates an explicit project from title input', async () => { requestJsonMock.mockResolvedValueOnce({ project: { projectId: 'editor-project-explicit', title: '角色设定板', canvas: { canvasId: 'editor-project-explicit:canvas:default', projectId: 'editor-project-explicit', title: '默认画布', viewport: { x: 0, y: 0, scale: 1 }, layers: [], updatedAt: '2026-06-12T00:00:00.000Z', }, viewport: { x: 0, y: 0, scale: 1 }, layers: [], resources: [], updatedAt: '2026-06-12T00:00:00.000Z', }, }); await createEditorProject({ title: '角色设定板' }); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/projects', expect.objectContaining({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: '角色设定板' }), }), '创建图片画布工程失败', ); }); it('generates editor images through the backend BFF', async () => { requestJsonMock.mockResolvedValueOnce({ imageSrc: 'data:image/png;base64,abc', width: 1024, height: 1024, sourceType: 'generated', prompt: '一张画布图片', actualPrompt: '一张画布图片', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'vector-task-1', warning: { code: 'postprocess-failed-source-preserved', reason: '生成任务成功,后处理失败。', }, }); const result = await generateEditorImage({ prompt: '一张画布图片', }); expect(result.taskId).toBe('vector-task-1'); expect(result.warning).toEqual({ code: 'postprocess-failed-source-preserved', reason: '生成任务成功,后处理失败。', }); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/images/generations', expect.objectContaining({ method: 'POST', headers: { 'Content-Type': 'application/json' }, }), '生成图片失败', expect.objectContaining({ timeoutMs: 1_200_000, retry: editorRetryOptionsExpectation, }), ); }); it('generates editor scenes through the structured endpoint without automatic retries', async () => { requestJsonMock.mockResolvedValueOnce({ queueState: { jobId: 'scene-job-1', status: 'pending' }, }); await generateEditorScene({ sceneContent: '雨夜中的欧洲小镇街道', stylePreset: 'anime', model: 'gemini-3.1-flash-image-preview', aspectRatio: '16:9', imageSize: '1K', referenceImageSrcs: ['editor-resource-reference'], }); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/scenes/generations', expect.objectContaining({ method: 'POST', body: JSON.stringify({ sceneContent: '雨夜中的欧洲小镇街道', stylePreset: 'anime', model: 'gemini-3.1-flash-image-preview', aspectRatio: '16:9', imageSize: '1K', referenceImageSrcs: ['editor-resource-reference'], }), }), '生成游戏场景失败', expect.objectContaining({ timeoutMs: 1_200_000, retry: { maxRetries: 0, retryUnsafeMethods: false, }, }), ); }); it('rejects scene reference overflow without sending an HTTP request', async () => { await expect( generateEditorScene({ sceneContent: '雨夜中的欧洲小镇街道', stylePreset: 'anime', referenceImageSrcs: Array.from( { length: 6 }, (_, index) => `/generated-images/editor/scene-reference-${index + 1}.png`, ), }), ).rejects.toThrow('场景参考图最多允许 5 张'); expect(requestJsonMock).not.toHaveBeenCalled(); }); it('rejects inline media before submitting editor generation requests', async () => { await expect( generateEditorImage({ prompt: '一张画布图片', referenceImageSrcs: ['data:image/png;base64,inline'], }), ).rejects.toThrow('必须先上传 OSS'); await expect( generateEditorVideo({ prompt: '生成视频', model: 'seedance2.0-fast', aspectRatio: '16:9', durationSeconds: 5, resolution: '720p', mode: 'std', sound: 'off', referenceVideoSrcs: ['blob:local-video'], }), ).rejects.toThrow('必须先上传 OSS'); expect(requestJsonMock).not.toHaveBeenCalled(); }); it('rejects image reference overflow before submitting generation requests', async () => { const references = Array.from( { length: 6 }, (_, index) => `/generated-images/editor/reference-${index + 1}.png`, ); await expect( generateEditorImage({ prompt: '一张画布图片', referenceImageSrcs: references, }), ).rejects.toThrow('生成参考图最多允许 5 张'); await expect( generateEditorImage({ prompt: '快速编辑画布图片', kind: 'quick-edit', referenceImageSrcs: references, }), ).rejects.toThrow('生成参考图最多允许 5 张'); await expect( generateEditorIconSpritesheet({ referenceId: 'editor-resource-spec', referenceImageSrcs: references.slice(0, 5), iconDescriptions: ['返回按钮'], model: 'gpt-image-2', }), ).rejects.toThrow('图标素材参考图最多允许 4 张'); await expect( editEditorImage({ prompt: '修改图片', sourceReferenceId: 'resource-source', referenceImageSrcs: references.slice(0, 5), model: 'gpt-image-2', }), ).rejects.toThrow('修改参考图最多允许 4 张'); await expect( extractEditorUiDesignAssets({ sourceImageSrc: '/generated-images/editor/ui.png', referenceImageSrcs: references.slice(0, 5), model: 'gpt-image-2', aspectRatio: '1:1', imageSize: '1K', }), ).rejects.toThrow('UI素材参考图最多允许 4 张'); expect(requestJsonMock).not.toHaveBeenCalled(); }); it('uses the nanobanana2 capacity when icon generation omits the model', async () => { requestJsonMock.mockResolvedValue({ queueState: { status: 'queued' } }); const references = Array.from( { length: 8 }, (_, index) => `/generated-images/editor/icon-reference-${index + 1}.png`, ); await generateEditorIconSpritesheet({ referenceId: 'editor-resource-icon-spec', referenceImageSrcs: references, iconDescriptions: ['返回按钮'], sliceMode: 'connected-components', }); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/icon-spritesheets/generations', expect.objectContaining({ body: expect.stringMatching( /(?=.*"model":"gemini-3\.1-flash-image-preview")(?=.*"sliceMode":"connected-components")/, ), }), '生成图标素材失败', expect.anything(), ); }); it('rejects oversized stable video reference fields before submission', async () => { await expect( generateEditorVideo({ prompt: '生成视频', model: 'seedance2.0-fast', aspectRatio: '16:9', durationSeconds: 5, resolution: '720p', mode: 'std', sound: 'off', referenceVideoSrcs: [ `https://assets.example.test/${'a'.repeat(256 * 1024)}`, ], }), ).rejects.toThrow('稳定引用字段总长度不能超过 256KB'); expect(requestJsonMock).not.toHaveBeenCalled(); }); it('passes image model options to editor image generation', async () => { requestJsonMock.mockResolvedValueOnce({ imageSrc: 'data:image/png;base64,character', width: 1024, height: 1536, sourceType: 'generated', prompt: '角色设定', actualPrompt: '角色设定', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'vector-character-1', }); await generateEditorImage({ prompt: '角色设定', kind: 'character', model: 'gpt-image-2', screenColor: '#FFD6C2', segModel: 'anime-seg', style: 'pixelArt', aspectRatio: '2:3', imageSize: '1K', projectId: 'editor-project-1', assetKind: 'character', assetFolderId: 'project', assetLabel: '角色形象', sourceResourceId: 'resource-spec-1', generationInputs: { fields: [{ title: '角色设定', value: '角色设定' }], references: [], }, }); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/images/generations', expect.objectContaining({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt: '角色设定', kind: 'character', model: 'gpt-image-2', screenColor: '#FFD6C2', segModel: 'anime-seg', style: 'pixelArt', aspectRatio: '2:3', imageSize: '1K', projectId: 'editor-project-1', assetKind: 'character', generationInputs: { fields: [{ title: '角色设定', value: '角色设定' }], references: [], }, assetFolderId: 'project', assetLabel: '角色形象', sourceResourceId: 'resource-spec-1', }), }), '生成图片失败', expect.objectContaining({ timeoutMs: 1_200_000, retry: editorRetryOptionsExpectation, }), ); }); it('passes canvas completion context to editor image generation', async () => { requestJsonMock.mockResolvedValueOnce({ imageSrc: 'data:image/png;base64,character', width: 1024, height: 1536, sourceType: 'generated', prompt: '角色设定', actualPrompt: '角色设定', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'vector-character-1', project: null, }); await generateEditorImage({ prompt: '角色设定', kind: 'character', projectId: 'editor-project-1', assetKind: 'character', canvasCompletion: { dialogId: 'generation-dialog-1', title: '角色形象 1', placeholder: { x: 120, y: 140, width: 420, height: 560, originalWidth: 1024, originalHeight: 1536, }, }, }); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/images/generations', expect.objectContaining({ body: JSON.stringify({ prompt: '角色设定', kind: 'character', projectId: 'editor-project-1', assetKind: 'character', canvasCompletion: { dialogId: 'generation-dialog-1', title: '角色形象 1', placeholder: { x: 120, y: 140, width: 420, height: 560, originalWidth: 1024, originalHeight: 1536, }, }, }), }), '生成图片失败', expect.objectContaining({ timeoutMs: 1_200_000, retry: editorRetryOptionsExpectation, }), ); }); it('generates icon spritesheets through the dedicated backend BFF', async () => { requestJsonMock.mockResolvedValueOnce({ spritesheetImageSrc: 'data:image/png;base64,sheet', spritesheetWidth: 512, spritesheetHeight: 512, iconImageSrcs: [], sliceWarning: { code: 'insufficient-connected-components', reason: '连通域数量不足', }, prompt: '图标素材 prompt', actualPrompt: '图标素材 prompt', model: 'gemini-3.1-flash-image-preview', provider: 'VectorEngine', taskId: 'icon-spritesheet-task-1', }); const result = await generateEditorIconSpritesheet({ referenceId: 'editor-resource-spec', referenceImageSrcs: ['/generated-images/editor/icon-ref.png'], iconDescriptions: ['返回按钮', '设置按钮'], assetLabel: '冒险游戏图标', }); expect(result.taskId).toBe('icon-spritesheet-task-1'); expect(result.sliceWarning).toEqual({ code: 'insufficient-connected-components', reason: '连通域数量不足', }); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/icon-spritesheets/generations', expect.objectContaining({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ referenceId: 'editor-resource-spec', referenceImageSrcs: ['/generated-images/editor/icon-ref.png'], iconDescriptions: ['返回按钮', '设置按钮'], model: 'gemini-3.1-flash-image-preview', assetLabel: '冒险游戏图标', }), }), '生成图标素材失败', expect.objectContaining({ timeoutMs: 1_200_000, retry: editorRetryOptionsExpectation, }), ); }); it('rejects icon descriptions outside the item and aggregate contracts before requesting', async () => { await expect( generateEditorIconSpritesheet({ referenceId: 'editor-resource-spec', iconDescriptions: ['图'.repeat(EDITOR_ICON_DESCRIPTION_MAX_CHARS + 1)], }), ).rejects.toThrow(`不能超过 ${EDITOR_ICON_DESCRIPTION_MAX_CHARS} 个字符`); await expect( generateEditorIconSpritesheet({ referenceId: 'editor-resource-spec', iconDescriptions: [ '图'.repeat(EDITOR_ICON_DESCRIPTION_MAX_CHARS), ...Array.from({ length: 10 }, () => '图'.repeat(EDITOR_ICON_DESCRIPTION_MAX_CHARS), ), ], }), ).rejects.toThrow( `合计不能超过 ${EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_CHARS} 个字符`, ); await expect( generateEditorIconSpritesheet({ referenceId: 'editor-resource-spec', iconDescriptions: Array.from({ length: 8 }, () => '😀'.repeat(EDITOR_ICON_DESCRIPTION_MAX_CHARS), ), }), ).rejects.toThrow( `合计不能超过 ${EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_UTF8_BYTES} 个 UTF-8 字节`, ); expect(requestJsonMock).not.toHaveBeenCalled(); }); it('passes image model options to icon spritesheet generation', async () => { requestJsonMock.mockResolvedValueOnce({ spritesheetImageSrc: 'data:image/png;base64,sheet', spritesheetWidth: 1024, spritesheetHeight: 1024, iconImageSrcs: [], prompt: '图标素材 prompt', actualPrompt: '图标素材 prompt', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'icon-spritesheet-task-2', }); await generateEditorIconSpritesheet({ referenceId: 'editor-resource-spec', iconDescriptions: ['返回按钮'], model: 'gpt-image-2', screenColor: '#E6D8FF', segModel: 'anime-seg', style: 'none', aspectRatio: '1:1', imageSize: '2K', projectId: 'editor-project-1', assetFolderId: 'project', generationInputs: { fields: [{ title: '素材描述', value: '返回按钮' }], references: [], }, }); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/icon-spritesheets/generations', expect.objectContaining({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ referenceId: 'editor-resource-spec', iconDescriptions: ['返回按钮'], model: 'gpt-image-2', screenColor: '#E6D8FF', segModel: 'anime-seg', style: 'none', aspectRatio: '1:1', imageSize: '2K', projectId: 'editor-project-1', generationInputs: { fields: [{ title: '素材描述', value: '返回按钮' }], references: [], }, assetFolderId: 'project', }), }), '生成图标素材失败', expect.objectContaining({ timeoutMs: 1_200_000, retry: editorRetryOptionsExpectation, }), ); }); it('splits an existing icon spritesheet into project assets', async () => { const project = { projectId: 'editor-project-1', title: '未命名画布', canvas: { canvasId: 'editor-project-1:canvas:default', projectId: 'editor-project-1', title: '默认画布', viewport: { x: 0, y: 0, scale: 1 }, layers: [], updatedAt: '2026-07-13T00:00:00.000Z', }, viewport: { x: 0, y: 0, scale: 1 }, layers: [], resources: [], updatedAt: '2026-07-13T00:00:00.000Z', }; requestJsonMock.mockResolvedValueOnce({ iconImageSrcs: [ { name: '素材 1', imageSrc: '/generated/atlas/asset-001.png', width: 128, height: 128, }, ], project, }); const result = await splitEditorIconSpritesheet({ projectId: 'editor-project-1', sourceLayerId: 'layer-atlas-1', sourceResourceId: 'resource-atlas-1', assetFolderId: 'project', canvasCompletion: { title: '拆分图集', placeholder: { x: 120, y: 80, width: 360, height: 360, originalWidth: 1024, originalHeight: 1024, }, }, }); expect(result.project).toBe(project); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/icon-spritesheets/slices', expect.objectContaining({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ projectId: 'editor-project-1', sourceLayerId: 'layer-atlas-1', sourceResourceId: 'resource-atlas-1', assetFolderId: 'project', canvasCompletion: { title: '拆分图集', placeholder: { x: 120, y: 80, width: 360, height: 360, originalWidth: 1024, originalHeight: 1024, }, }, }), }), '拆分图集失败', ); }); it('extracts UI design assets through the dedicated backend BFF', async () => { requestJsonMock.mockResolvedValueOnce({ spritesheetImageSrc: 'data:image/png;base64,ui-sheet', spritesheetWidth: 1024, spritesheetHeight: 1024, iconImageSrcs: [ { name: '素材 1', imageSrc: 'data:image/png;base64,asset-1', width: 128, height: 128, }, ], prompt: '仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯色背景 中度天蓝 #7FB3FF / RGB(127,179,255),方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板或阴影。', actualPrompt: '仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯色背景 中度天蓝 #7FB3FF / RGB(127,179,255),方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板或阴影。', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'ui-asset-extraction-1', }); const result = await extractEditorUiDesignAssets({ sourceImageSrc: '/generated-images/editor/ui-design.png', model: 'gpt-image-2', screenColor: '#7FB3FF', segModel: 'anime-seg', referenceImageSrcs: ['/generated-images/editor/ui-ref.png'], projectId: 'editor-project-1', assetFolderId: 'project', spritesheetLabel: '战斗UI设计图 素材图集', aspectRatio: '1:1', imageSize: '2K', generationInputs: { fields: [{ title: '来源', value: '战斗UI设计图' }], references: [], }, }); expect(result.taskId).toBe('ui-asset-extraction-1'); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/ui-designs/assets/extractions', expect.objectContaining({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sourceImageSrc: '/generated-images/editor/ui-design.png', model: 'gpt-image-2', screenColor: '#7FB3FF', segModel: 'anime-seg', referenceImageSrcs: ['/generated-images/editor/ui-ref.png'], projectId: 'editor-project-1', generationInputs: { fields: [{ title: '来源', value: '战斗UI设计图' }], references: [], }, assetFolderId: 'project', spritesheetLabel: '战斗UI设计图 素材图集', aspectRatio: '1:1', imageSize: '2K', }), }), '提取UI设计图素材失败', expect.objectContaining({ timeoutMs: 1_200_000, retry: editorRetryOptionsExpectation, }), ); }); it('passes spec generation size and kind to the backend BFF', async () => { requestJsonMock.mockResolvedValueOnce({ imageSrc: 'data:image/png;base64,spec', width: 2048, height: 1152, sourceType: 'generated', prompt: '生成规范图', actualPrompt: '生成规范图', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'vector-spec-1', }); const result = await generateEditorImage({ prompt: '生成规范图', size: '2048x1152', kind: 'spec', model: 'gpt-image-2', }); expect(result.width).toBe(2048); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/images/generations', expect.objectContaining({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt: '生成规范图', size: '2048x1152', kind: 'spec', model: 'gpt-image-2', }), }), '生成图片失败', expect.objectContaining({ timeoutMs: 1_200_000, retry: editorRetryOptionsExpectation, }), ); }); it('uses dedicated icon spec refine endpoints', async () => { requestJsonMock .mockResolvedValueOnce({ playSetting: '优化玩法' }) .mockResolvedValueOnce({ artStyle: '优化画风' }); await expect(refineEditorIconSpecPlaySetting('原玩法')).resolves.toBe( '优化玩法', ); await expect(refineEditorIconSpecArtStyle('原画风')).resolves.toBe( '优化画风', ); expect(requestJsonMock.mock.calls[0]).toEqual([ '/api/editor/llm/icon-specs/refine-game-play', expect.objectContaining({ method: 'POST', body: JSON.stringify({ playSetting: '原玩法' }), }), '优化玩法设定失败', ]); expect(requestJsonMock.mock.calls[1]).toEqual([ '/api/editor/llm/icon-specs/refine-art-style', expect.objectContaining({ method: 'POST', body: JSON.stringify({ artStyle: '原画风' }), }), '优化美术风格失败', ]); }); it('enforces icon spec prompt length before and after refine requests', async () => { const overLimit = '玩'.repeat(EDITOR_ICON_SPEC_PROMPT_MAX_LENGTH + 1); await expect(refineEditorIconSpecPlaySetting(overLimit)).rejects.toThrow( '玩法设定不能超过 200 个字符', ); await expect(refineEditorIconSpecArtStyle(overLimit)).rejects.toThrow( '美术风格不能超过 200 个字符', ); expect(requestJsonMock).not.toHaveBeenCalled(); requestJsonMock.mockResolvedValueOnce({ playSetting: overLimit }); await expect(refineEditorIconSpecPlaySetting('原玩法')).rejects.toThrow( '玩法设定不能超过 200 个字符', ); }); it('normalizes icon spec fields and rejects blank values before requesting', async () => { await expect(refineEditorIconSpecPlaySetting(' \n ')).rejects.toThrow( '玩法设定不能为空', ); await expect( generateEditorIconSpec({ playSetting: '回合制', artStyle: ' ', }), ).rejects.toThrow('美术风格不能为空'); expect(requestJsonMock).not.toHaveBeenCalled(); requestJsonMock.mockResolvedValueOnce({ playSetting: ' 优化玩法 ' }); await expect(refineEditorIconSpecPlaySetting(' 原玩法 ')).resolves.toBe( '优化玩法', ); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/llm/icon-specs/refine-game-play', expect.objectContaining({ body: JSON.stringify({ playSetting: '原玩法' }), }), '优化玩法设定失败', ); }); it('rejects overlong icon spec generation fields before dispatch', async () => { await expect( generateEditorIconSpec({ playSetting: '玩'.repeat(EDITOR_ICON_SPEC_PROMPT_MAX_LENGTH + 1), artStyle: '低多边形', }), ).rejects.toThrow('玩法设定不能超过 200 个字符'); expect(requestJsonMock).not.toHaveBeenCalled(); }); it('submits icon spec business fields without a client prompt or image kind', async () => { requestJsonMock.mockResolvedValueOnce({ imageSrc: 'data:image/png;base64,spec', width: 2048, height: 1152, }); await generateEditorIconSpec({ playSetting: '回合制占点', artStyle: '低多边形', referenceId: 'resource-reference-1', projectId: 'editor-project-1', generationInputs: { fields: [], references: [ { title: '参考图', label: '参考图', refType: 'project-resource', refId: 'resource-reference-1', }, ], }, assetFolderId: 'project', assetLabel: '图标规范 1', sourceResourceId: 'resource-source-1', }); const body = JSON.parse(requestJsonMock.mock.calls[0]?.[1]?.body as string); expect(body).toEqual({ playSetting: '回合制占点', artStyle: '低多边形', referenceId: 'resource-reference-1', projectId: 'editor-project-1', generationInputs: { fields: [], references: [ { title: '参考图', label: '参考图', refType: 'project-resource', refId: 'resource-reference-1', }, ], }, assetFolderId: 'project', assetLabel: '图标规范 1', sourceResourceId: 'resource-source-1', }); expect(body).not.toHaveProperty('prompt'); expect(body).not.toHaveProperty('kind'); expect(body).not.toHaveProperty('assetKind'); expect(requestJsonMock.mock.calls[0]?.[3]).toEqual({ timeoutMs: 1_200_000, }); }); it('passes publication material generation kind and size to the backend BFF', async () => { requestJsonMock.mockResolvedValueOnce({ imageSrc: 'data:image/png;base64,publication', width: 720, height: 1280, sourceType: 'generated', prompt: '宣发素材 prompt', actualPrompt: '宣发素材 prompt', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'vector-publication-1', }); const result = await generateEditorImage({ prompt: '宣发素材 prompt', size: '720x1280', kind: 'publication-material', aspectRatio: '9:16', imageSize: '1K', referenceImageSrcs: ['/generated-images/editor/publication-ref.png'], }); expect(result.height).toBe(1280); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/images/generations', expect.objectContaining({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt: '宣发素材 prompt', size: '720x1280', kind: 'publication-material', aspectRatio: '9:16', imageSize: '1K', referenceImageSrcs: ['/generated-images/editor/publication-ref.png'], }), }), '生成图片失败', expect.objectContaining({ timeoutMs: 1_200_000, retry: editorRetryOptionsExpectation, }), ); }); it('generates editor character animations through the backend BFF', async () => { requestJsonMock.mockResolvedValueOnce({ taskId: 'character-animation-1', model: 'seedance2.0-fast', prompt: '生成游戏角色动画\n动作描述:\n待机', previewVideoPath: '/generated-character-drafts/editor/preview.mp4', frames: [ { imageSrc: '/generated-character-drafts/editor/frame01.png', width: 1024, height: 1024, }, ], frameCount: 32, durationSeconds: 4, fps: 8, }); const result = await generateEditorCharacterAnimation({ sourceLayerId: 'layer-character', sourceImageSrc: '/generated-images/editor/character.png', sourceWidth: 1024, sourceHeight: 1024, promptText: '待机', resolution: '480p', ratio: 'same', frameCount: 32, durationSeconds: 4, model: 'seedance2.0-fast', assetLabel: '勇者待机', }); expect(result.taskId).toBe('character-animation-1'); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/character-animations/generations', expect.objectContaining({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sourceLayerId: 'layer-character', sourceImageSrc: '/generated-images/editor/character.png', sourceWidth: 1024, sourceHeight: 1024, promptText: '待机', resolution: '480p', ratio: 'same', frameCount: 32, durationSeconds: 4, model: 'seedance2.0-fast', assetLabel: '勇者待机', }), }), '生成角色动画失败', expect.objectContaining({ timeoutMs: 1_200_000, retry: editorRetryOptionsExpectation, }), ); }); it('generates editor videos through the backend BFF', async () => { requestJsonMock.mockResolvedValueOnce({ videoSrc: '/generated-editor-videos/video-task-1/preview.mp4', width: 1280, height: 720, sourceType: 'generated', prompt: '让角色向镜头挥手', actualPrompt: '让角色向镜头挥手', model: 'kling3.0-omni', provider: 'VectorEngine', taskId: 'video-task-1', durationSeconds: 5, resolution: '480p', thumbnailSrc: '/generated-editor-videos/video-task-1/cover.png', asset: { assetId: 'asset-video-1', folderId: 'user-1:asset-folder:project', label: '生成视频 1', imageSrc: '/generated-editor-videos/video-task-1/preview.mp4', thumbnailSrc: '/generated-editor-videos/video-task-1/cover.png', width: 1280, height: 720, sourceType: 'generated', assetKind: 'video', }, }); const result = await generateEditorVideo({ prompt: '让角色向镜头挥手', model: 'kling3.0-omni', aspectRatio: '16:9', durationSeconds: 5, resolution: '480p', mode: 'std', sound: 'off', assetFolderId: 'project', assetLabel: '生成视频 1', }); expect(result.taskId).toBe('video-task-1'); expect(result.thumbnailSrc).toBe( '/generated-editor-videos/video-task-1/cover.png', ); expect(result.asset?.assetId).toBe('asset-video-1'); expect(result.asset?.thumbnailSrc).toBe( '/generated-editor-videos/video-task-1/cover.png', ); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/videos/generations', expect.objectContaining({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt: '让角色向镜头挥手', model: 'kling3.0-omni', aspectRatio: '16:9', durationSeconds: 5, resolution: '480p', mode: 'std', sound: 'off', webSearchEnabled: true, assetFolderId: 'project', assetLabel: '生成视频 1', }), }), '生成视频失败', expect.objectContaining({ timeoutMs: 1_200_000, retry: editorRetryOptionsExpectation, }), ); }); it('passes seedance video multimodal references through the backend BFF', async () => { requestJsonMock.mockResolvedValueOnce({ videoSrc: '/generated-editor-videos/video-task-2/preview.mp4', width: 1280, height: 720, sourceType: 'generated', prompt: '参考素材生成视频', actualPrompt: '参考素材生成视频', model: 'seedance2.0-fast', provider: 'VectorEngine', taskId: 'video-task-2', durationSeconds: 5, resolution: '720p', }); await generateEditorVideo({ prompt: '参考素材生成视频', model: 'seedance2.0-fast', aspectRatio: '16:9', durationSeconds: 5, resolution: '720p', mode: 'std', sound: 'off', referenceImageSrcs: ['/generated-images/editor/video-reference.png'], referenceVideoSrcs: [ 'generated-character-drafts/editor/seedance-references/video/video.mp4', ], referenceAudioSrcs: [ 'generated-character-drafts/editor/seedance-references/audio/audio.mp3', ], }); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/videos/generations', expect.objectContaining({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt: '参考素材生成视频', model: 'seedance2.0-fast', aspectRatio: '16:9', durationSeconds: 5, resolution: '720p', mode: 'std', sound: 'off', webSearchEnabled: true, referenceImageSrcs: ['/generated-images/editor/video-reference.png'], referenceVideoSrcs: [ 'generated-character-drafts/editor/seedance-references/video/video.mp4', ], referenceAudioSrcs: [ 'generated-character-drafts/editor/seedance-references/audio/audio.mp3', ], }), }), '生成视频失败', expect.objectContaining({ timeoutMs: 1_200_000 }), ); }); it('generates editor sound effects through the backend BFF', async () => { requestJsonMock.mockResolvedValueOnce({ audioSrc: '/generated-character-drafts/editor-audios/sfx.mp3', width: 420, height: 120, sourceType: 'generated', prompt: '金币掉落叮当声', actualPrompt: 'A bright coin pickup chime', model: 'eleven_text_to_sound_v2', taskId: 'sound-task-1', audioKind: 'sound-effect', asset: { assetId: 'asset-sound-1', folderId: 'user-1:asset-folder:project', label: '游戏音效 1', imageSrc: '/generated-character-drafts/editor-audios/sfx.mp3', width: 420, height: 120, sourceType: 'generated', assetKind: 'sound-effect', }, }); const result = await generateEditorSoundEffect({ prompt: '金币掉落叮当声', model: 'eleven_text_to_sound_v2', duration: 7.5, loop: true, assetFolderId: 'project', assetLabel: '游戏音效 1', }); expect(result.taskId).toBe('sound-task-1'); expect(result.asset?.assetId).toBe('asset-sound-1'); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/audios/sound-effects/generations', expect.objectContaining({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt: '金币掉落叮当声', model: 'eleven_text_to_sound_v2', duration: 7.5, loop: true, assetFolderId: 'project', assetLabel: '游戏音效 1', }), }), '生成游戏音效失败', { timeoutMs: 1_200_000 }, ); }); it('canonicalizes omitted duration to null and omitted loop to false', async () => { requestJsonMock.mockResolvedValueOnce({ audioSrc: '/generated-character-drafts/editor-audios/sfx-null.mp3', width: 420, height: 120, sourceType: 'generated', prompt: '按钮确认短促音', actualPrompt: 'A short confirmation click', model: 'eleven_text_to_sound_v2', taskId: 'sound-task-null', audioKind: 'sound-effect', }); await generateEditorSoundEffect({ prompt: '按钮确认短促音', model: 'eleven_text_to_sound_v2', }); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/audios/sound-effects/generations', expect.objectContaining({ body: JSON.stringify({ prompt: '按钮确认短促音', model: 'eleven_text_to_sound_v2', duration: null, loop: false, }), }), '生成游戏音效失败', expect.any(Object), ); }); it('accepts the 0.5 and 30 second sound effect duration boundaries', async () => { requestJsonMock.mockResolvedValue({}); for (const duration of [0.5, 30]) { await generateEditorSoundEffect({ prompt: '按钮确认短促音', model: 'eleven_text_to_sound_v2', duration, }); } expect(requestJsonMock).toHaveBeenNthCalledWith( 1, '/api/editor/audios/sound-effects/generations', expect.objectContaining({ body: JSON.stringify({ prompt: '按钮确认短促音', model: 'eleven_text_to_sound_v2', duration: 0.5, loop: false, }), }), '生成游戏音效失败', expect.any(Object), ); expect(requestJsonMock).toHaveBeenNthCalledWith( 2, '/api/editor/audios/sound-effects/generations', expect.objectContaining({ body: JSON.stringify({ prompt: '按钮确认短促音', model: 'eleven_text_to_sound_v2', duration: 30, loop: false, }), }), '生成游戏音效失败', expect.any(Object), ); }); it('rejects invalid sound effect durations before the request', async () => { for (const duration of [ 0.49, 30.01, Number.NaN, Number.POSITIVE_INFINITY, ]) { await expect( generateEditorSoundEffect({ prompt: '按钮确认短促音', model: 'eleven_text_to_sound_v2', duration, }), ).rejects.toThrow('游戏音效时长必须在 0.5-30 秒之间'); } expect(requestJsonMock).not.toHaveBeenCalled(); }); it('generates editor background music through the backend BFF', async () => { const representativeComplexPromptCase = backgroundMusicPromptCanonicalizationCases.find( ({ name }) => name === 'representative-complex', ); if (!representativeComplexPromptCase) { throw new Error('missing representative-complex prompt fixture'); } const canonicalPrompt = representativeComplexPromptCase.prompt; requestJsonMock.mockResolvedValueOnce({ audioSrc: '/generated-character-drafts/editor-audios/bgm.mp3', width: 420, height: 120, sourceType: 'generated', prompt: canonicalPrompt, actualPrompt: canonicalPrompt, model: 'chirp-v4', taskId: 'music-task-1', audioKind: 'background-music', }); const result = await generateEditorBackgroundMusic({ gptDescriptionPrompt: canonicalPrompt, makeInstrumental: true, assetFolderId: 'project', assetLabel: '游戏背景音乐 1', }); expect(result.taskId).toBe('music-task-1'); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/audios/background-music/generations', expect.objectContaining({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ gptDescriptionPrompt: canonicalPrompt, makeInstrumental: true, assetFolderId: 'project', assetLabel: '游戏背景音乐 1', }), }), '生成游戏背景音乐失败', { timeoutMs: 1_200_000, }, ); }); it('forwards background music transport failures without configuring retries', async () => { requestJsonMock.mockRejectedValueOnce(new Error('transport failed')); await expect( generateEditorBackgroundMusic({ gptDescriptionPrompt: '森林冒险背景音乐', makeInstrumental: true, }), ).rejects.toThrow('transport failed'); expect(requestJsonMock.mock.calls[0]?.[3]).toEqual({ timeoutMs: 1_200_000, }); }); it('completes a background music prompt through the authenticated internal BFF', async () => { requestJsonMock.mockResolvedValueOnce({ prompt: '轻快明亮的森林冒险背景音乐', charCount: 13, }); const result = await completeEditorBackgroundMusicPrompt({ currentPrompt: '森林冒险', targetChars: 180, maxChars: 200, } as Parameters[0] & { targetChars: number; maxChars: number; }); expect(result).toEqual({ prompt: '轻快明亮的森林冒险背景音乐', charCount: 13, }); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/audios/background-music/prompts/completions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ currentPrompt: '森林冒险', }), signal: undefined, }, 'AI 补全背景音乐提示词失败', promptAssistRequestOptions, ); expect(EDITOR_BACKGROUND_MUSIC_PROMPT_ASSIST_TIMEOUT_MS).toBe(180_000); expect(requestJsonMock.mock.calls[0]).toHaveLength(4); expect(requestJsonMock.mock.calls[0][3]).toEqual( promptAssistRequestOptions, ); }); it('simplifies a background music prompt without forwarding client-controlled limits', async () => { requestJsonMock.mockResolvedValueOnce({ prompt: '紧张推进的战斗背景音乐', charCount: 11, }); const result = await simplifyEditorBackgroundMusicPrompt({ currentPrompt: '紧张推进、铜管与鼓点交织的战斗背景音乐'.repeat(10), targetChars: 170, maxChars: 200, model: 'client-must-not-control', } as Parameters[0] & { targetChars: number; maxChars: number; model: string; }); expect(result).toEqual({ prompt: '紧张推进的战斗背景音乐', charCount: 11, }); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/audios/background-music/prompts/simplifications', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ currentPrompt: '紧张推进、铜管与鼓点交织的战斗背景音乐'.repeat(10), }), signal: undefined, }, '简化背景音乐提示词失败', promptAssistRequestOptions, ); expect(requestJsonMock.mock.calls[0]).toHaveLength(4); expect(requestJsonMock.mock.calls[0][3]).toEqual( promptAssistRequestOptions, ); }); it('optimizes an SFX prompt through the authenticated internal BFF without retries or client-controlled limits', async () => { requestJsonMock.mockResolvedValueOnce({ prompt: '清脆明亮的金币落地声', charCount: 10, }); const controller = new AbortController(); const result = await optimizeEditorSoundEffectPrompt( { currentPrompt: '金币落地声', targetChars: 2048, model: 'client-must-not-control', } as Parameters[0] & { targetChars: number; model: string; }, { signal: controller.signal }, ); expect(result).toEqual({ prompt: '清脆明亮的金币落地声', charCount: 10, }); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/audios/sound-effects/prompts/optimizations', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ currentPrompt: '金币落地声' }), signal: controller.signal, }, '优化游戏音效提示词失败', { timeoutMs: EDITOR_SOUND_EFFECT_PROMPT_OPTIMIZE_TIMEOUT_MS }, ); expect(EDITOR_SOUND_EFFECT_PROMPT_OPTIMIZE_TIMEOUT_MS).toBe(180_000); expect(requestJsonMock.mock.calls[0]).toHaveLength(4); }); it.each([ [ 'completion', completeEditorBackgroundMusicPrompt, '/api/editor/audios/background-music/prompts/completions', 'AI 补全背景音乐提示词失败', ], [ 'simplification', simplifyEditorBackgroundMusicPrompt, '/api/editor/audios/background-music/prompts/simplifications', '简化背景音乐提示词失败', ], ] as const)( 'forwards the AbortSignal for background music prompt %s', async (_label, requestPromptAssist, path, fallbackMessage) => { const controller = new AbortController(); const abortReason = new Error('prompt assist cancelled'); requestJsonMock.mockImplementationOnce( (_url: string, init: RequestInit) => new Promise((_resolve, reject) => { init.signal?.addEventListener( 'abort', () => reject(init.signal?.reason), { once: true }, ); }), ); const request = requestPromptAssist( { currentPrompt: '森林冒险' }, { signal: controller.signal }, ); controller.abort(abortReason); await expect(request).rejects.toBe(abortReason); expect(requestJsonMock).toHaveBeenCalledWith( path, expect.objectContaining({ signal: controller.signal, body: JSON.stringify({ currentPrompt: '森林冒险' }), }), fallbackMessage, promptAssistRequestOptions, ); }, ); it.each([ [ 'completion', completeEditorBackgroundMusicPrompt, 'AI 补全背景音乐提示词失败', ], [ 'simplification', simplifyEditorBackgroundMusicPrompt, '简化背景音乐提示词失败', ], ] as const)( 'preserves the v1 API error from background music prompt %s', async (_label, requestPromptAssist, fallbackMessage) => { const apiError = Object.assign(new Error('登录状态已失效'), { name: 'ApiClientError', status: 401, code: 'UNAUTHORIZED', }); requestJsonMock.mockRejectedValueOnce(apiError); await expect( requestPromptAssist({ currentPrompt: '森林冒险' }), ).rejects.toBe(apiError); expect(requestJsonMock).toHaveBeenCalledWith( expect.stringMatching( /^\/api\/editor\/audios\/background-music\/prompts\//, ), expect.objectContaining({ method: 'POST', body: JSON.stringify({ currentPrompt: '森林冒险' }), }), fallbackMessage, promptAssistRequestOptions, ); expect(requestJsonMock.mock.calls[0]).toHaveLength(4); }, ); it('edits editor images through the backend BFF', async () => { requestJsonMock.mockResolvedValueOnce({ imageSrc: 'data:image/png;base64,edited', width: 1024, height: 1024, sourceType: 'generated', prompt: '把画面改成黄昏光线', actualPrompt: '把画面改成黄昏光线', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'vector-edit-1', }); const result = await editEditorImage({ prompt: '把画面改成黄昏光线', sourceReferenceId: 'resource-character-1', size: '1024x1024', model: 'gpt-image-2', aspectRatio: '1:1', imageSize: '1K', referenceImageSrcs: ['/generated-images/editor/style.png'], projectId: 'editor-project-1', assetFolderId: 'project', assetLabel: '角色形象 修改结果', targetLayerId: 'layer-character-1', generationInputs: { fields: [{ title: '修改提示词', value: '把画面改成黄昏光线' }], references: [], }, }); expect(result.taskId).toBe('vector-edit-1'); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/images/edits', expect.objectContaining({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt: '把画面改成黄昏光线', sourceReferenceId: 'resource-character-1', size: '1024x1024', model: 'gpt-image-2', aspectRatio: '1:1', imageSize: '1K', referenceImageSrcs: ['/generated-images/editor/style.png'], projectId: 'editor-project-1', generationInputs: { fields: [{ title: '修改提示词', value: '把画面改成黄昏光线' }], references: [], }, assetFolderId: 'project', assetLabel: '角色形象 修改结果', targetLayerId: 'layer-character-1', }), }), '修改图片失败', expect.objectContaining({ timeoutMs: 1_200_000, retry: editorRetryOptionsExpectation, }), ); }); it('passes canvas completion context to background removal', async () => { requestJsonMock.mockResolvedValueOnce({ queueState: { operationId: 'background-removal-1', status: 'queued', phaseLabel: '排队中', phaseDetail: '排队中', progress: 0, updatedAtMicros: 1, }, }); const response = await removeEditorImageBackground({ sourceImageSrc: '/generated-images/editor/source.png', projectId: 'editor-project-1', targetLayerId: 'layer-source', canvasCompletion: { dialogId: 'generation-dialog-1', title: '源图 去背景', placeholder: { x: 120, y: 140, width: 320, height: 240, originalWidth: 320, originalHeight: 240, }, }, }); expect(response.queueState.operationId).toBe('background-removal-1'); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/images/background-removals', expect.objectContaining({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sourceImageSrc: '/generated-images/editor/source.png', projectId: 'editor-project-1', targetLayerId: 'layer-source', canvasCompletion: { dialogId: 'generation-dialog-1', title: '源图 去背景', placeholder: { x: 120, y: 140, width: 320, height: 240, originalWidth: 320, originalHeight: 240, }, }, }), }), '去除背景失败', expect.objectContaining({ timeoutMs: 1_200_000, retry: editorRetryOptionsExpectation, }), ); }); it('submits a stable perfect-pixel source without unsafe POST retries', async () => { requestJsonMock.mockResolvedValueOnce({ imageSrc: '/generated-images/editor/perfect-pixel.png', objectKey: 'generated-images/editor/perfect-pixel.png', assetObjectId: 'asset-object-perfect-pixel', width: 1024, height: 768, sourceType: 'generated', taskId: 'perfect-pixel-1', elapsedMs: 321, provider: 'Genarrative', resource: { resourceId: 'resource-perfect-pixel' }, asset: { assetId: 'asset-perfect-pixel' }, project: null, }); const result = await snapEditorImageToPixelArt({ sourceImageSrc: 'generated-images/editor/source.png', projectId: 'editor-project-1', sourceResourceId: 'resource-source', assetKind: 'character', generationInputs: { fields: [{ title: '角色设定', value: '红发骑士' }], references: [], }, assetFolderId: 'project', assetLabel: '源图 · 完美像素', canvasCompletion: { dialogId: 'generation-dialog-perfect-pixel', title: '源图 · 完美像素', placeholder: { x: 472, y: 140, width: 320, height: 240, originalWidth: 320, originalHeight: 240, }, }, }); expect(result.taskId).toBe('perfect-pixel-1'); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/images/pixel-art-snaps', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sourceImageSrc: 'generated-images/editor/source.png', projectId: 'editor-project-1', sourceResourceId: 'resource-source', assetKind: 'character', generationInputs: { fields: [{ title: '角色设定', value: '红发骑士' }], references: [], }, assetFolderId: 'project', assetLabel: '源图 · 完美像素', canvasCompletion: { dialogId: 'generation-dialog-perfect-pixel', title: '源图 · 完美像素', placeholder: { x: 472, y: 140, width: 320, height: 240, originalWidth: 320, originalHeight: 240, }, }, }), }, '完美像素处理失败', { timeoutMs: 120_000 }, ); }); it('rejects inline perfect-pixel media before sending the request', async () => { await expect( snapEditorImageToPixelArt({ sourceImageSrc: 'data:image/png;base64,source', projectId: 'editor-project-1', canvasCompletion: { dialogId: 'generation-dialog-perfect-pixel', title: '源图 · 完美像素', placeholder: { x: 0, y: 0, width: 320, height: 240, originalWidth: 320, originalHeight: 240, }, }, }), ).rejects.toThrow('待完美像素处理图片必须先上传 OSS'); expect(requestJsonMock).not.toHaveBeenCalled(); }); });