/* @vitest-environment jsdom */ import { act, fireEvent, render, screen, waitFor, } from '@testing-library/react'; import { useRef, useState } from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ApiClientError } from '../../services/apiClient'; import type { CanvasGenerationDialogState, CanvasLayer, CanvasTool, CharacterAnimationPanelState, GenerateDialogState, QuickEditPanelState, SidebarPanel, } from './ImageCanvasEditorTypes'; import { createQuickEditPanelDraft } from './ImageCanvasGenerationDialogModel'; import type { UiAssetExtractionState } from './ImageCanvasUiAssetExtractionModel'; import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs'; import { useImageCanvasGenerationSubmissionWorkflow } from './useImageCanvasGenerationSubmissionWorkflow'; const resolveEditorImageReferenceDataUrlMock = vi.hoisted(() => vi.fn()); const resolveEditorImageReferenceDataUrlForGenerationMock = vi.hoisted(() => vi.fn(), ); const editEditorImageMock = vi.hoisted(() => vi.fn()); const extractEditorUiDesignAssetsMock = vi.hoisted(() => vi.fn()); const renderUiDesignAssetExtractionMarkedImageMock = vi.hoisted(() => vi.fn()); const generateEditorCharacterAnimationMock = vi.hoisted(() => vi.fn()); const generateEditorIconSpritesheetMock = vi.hoisted(() => vi.fn()); const generateEditorImageMock = vi.hoisted(() => vi.fn()); const generateEditorVideoMock = vi.hoisted(() => vi.fn()); const generateEditorSoundEffectMock = vi.hoisted(() => vi.fn()); const generateEditorBackgroundMusicMock = vi.hoisted(() => vi.fn()); const loadEditorProjectMock = vi.hoisted(() => vi.fn()); const getExternalGenerationJobStatusMock = vi.hoisted(() => vi.fn()); vi.mock('../../services/image-editor/editorImageReference', () => ({ resolveEditorImageReferenceDataUrl: resolveEditorImageReferenceDataUrlMock, resolveEditorImageReferenceDataUrlForGeneration: resolveEditorImageReferenceDataUrlForGenerationMock, })); vi.mock('../../services/image-editor/editorProjectClient', async () => { const actual = await vi.importActual< typeof import('../../services/image-editor/editorProjectClient') >('../../services/image-editor/editorProjectClient'); return { ...actual, editEditorImage: editEditorImageMock, extractEditorUiDesignAssets: extractEditorUiDesignAssetsMock, generateEditorCharacterAnimation: generateEditorCharacterAnimationMock, generateEditorIconSpritesheet: generateEditorIconSpritesheetMock, generateEditorImage: generateEditorImageMock, generateEditorVideo: generateEditorVideoMock, generateEditorSoundEffect: generateEditorSoundEffectMock, generateEditorBackgroundMusic: generateEditorBackgroundMusicMock, loadEditorProject: loadEditorProjectMock, }; }); vi.mock('../../services/external-generation', () => ({ getExternalGenerationJobStatus: getExternalGenerationJobStatusMock, })); vi.mock('./ImageCanvasUiAssetExtractionRasterModel', () => ({ renderUiDesignAssetExtractionMarkedImage: renderUiDesignAssetExtractionMarkedImageMock, })); function createLayer(overrides: Partial = {}): CanvasLayer { return { id: 'layer-source', resourceId: 'resource-source', title: '源图', src: 'https://assets.example.test/source.png', x: 120, y: 140, width: 320, height: 240, originalWidth: 1024, originalHeight: 768, zIndex: 2, sourceType: 'uploaded', ...overrides, }; } function createGenerated(overrides = {}) { return { imageSrc: 'data:image/png;base64,generated', width: 1024, height: 1024, sourceType: 'generated' as const, prompt: '生成提示词', actualPrompt: '生成提示词', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'task-generated', ...overrides, }; } function createVideoGenerated(overrides = {}) { return { videoSrc: 'https://assets.example.test/generated-video.mp4', width: 854, height: 480, sourceType: 'generated' as const, prompt: '视频提示词', actualPrompt: '视频提示词', model: 'seedance2.0-fast', provider: 'ark', taskId: 'task-video-generated', ...overrides, }; } function createAudioGenerated(overrides = {}) { return { audioSrc: 'https://assets.example.test/generated-audio.mp3', width: 420, height: 120, sourceType: 'generated' as const, prompt: '金币掉落叮当声', actualPrompt: '金币掉落叮当声', model: 'audio1.0', provider: 'VectorEngine', taskId: 'task-audio-generated', priceMudPoints: 5, audioKind: 'sound-effect' as const, durationSeconds: 7, ...overrides, }; } function createIconResult(name: string, imageSrc: string) { return { name, imageSrc, width: 128, height: 128, }; } function createQueueState( overrides: Partial<{ operationId: string; status: 'queued' | 'running' | 'completed' | 'failed'; phaseLabel: string; phaseDetail: string; progress: number; error: string | null; updatedAtMicros: number; }> = {}, ) { return { operationId: 'editor-generation-operation-1', status: 'completed' as const, phaseLabel: '完成', phaseDetail: '', progress: 1, error: null, updatedAtMicros: 1, ...overrides, }; } function SubmissionWorkflowHarness({ initialDialog = null, initialQuickEditPanel = null, initialQuickEditSelectionState = null, initialCharacterAnimationPanel = null, initialLayers = [createLayer()], projectId = undefined, upsertGeneratedAsset = undefined, applyProjectSnapshot = undefined, onQueuedGenerationTask = undefined, onWalletBalanceMayHaveChanged = undefined, }: { initialDialog?: GenerateDialogState | null; initialQuickEditPanel?: QuickEditPanelState | null; initialQuickEditSelectionState?: UiAssetExtractionState | null; initialCharacterAnimationPanel?: CharacterAnimationPanelState | null; initialLayers?: CanvasLayer[]; projectId?: string | null; upsertGeneratedAsset?: Parameters< typeof useImageCanvasGenerationSubmissionWorkflow >[0]['upsertGeneratedAsset']; applyProjectSnapshot?: Parameters< typeof useImageCanvasGenerationSubmissionWorkflow >[0]['applyProjectSnapshot']; onQueuedGenerationTask?: Parameters< typeof useImageCanvasGenerationSubmissionWorkflow >[0]['onQueuedGenerationTask']; onWalletBalanceMayHaveChanged?: Parameters< typeof useImageCanvasGenerationSubmissionWorkflow >[0]['onWalletBalanceMayHaveChanged']; }) { const [layers, setLayers] = useState(initialLayers); const [activeTool, setActiveTool] = useState('generate'); const [activeSidebarPanel, setActiveSidebarPanel] = useState('assets'); const [selectedLayerId, setSelectedLayerId] = useState(null); const [quickEditPanel, setQuickEditPanel] = useState(initialQuickEditPanel); const [characterAnimationPanel, setCharacterAnimationPanel] = useState( initialCharacterAnimationPanel, ); const rememberedImageModelRef = useRef(null); const fitLayersMockRef = useRef(vi.fn()); const layerCounterRef = useRef(0); const dialogs = useCanvasGenerationDialogs(); const sourceLayer = layers[0] ?? null; const quickEditSourceLayer = quickEditPanel ? (layers.find((layer) => layer.id === quickEditPanel.sourceLayerId) ?? null) : null; const characterAnimationSourceLayer = characterAnimationPanel ? (layers.find( (layer) => layer.id === characterAnimationPanel.sourceLayerId, ) ?? null) : null; const activeDialog = dialogs.generateDialog; const activeCanvasDialog = activeDialog && 'id' in activeDialog ? (activeDialog as CanvasGenerationDialogState) : null; const workflow = useImageCanvasGenerationSubmissionWorkflow({ layers, canvasSize: { width: 900, height: 640 }, viewport: { x: 10, y: 20, scale: 2 }, layerCounterRef, quickEditPanel, quickEditSourceLayer, quickEditSelectionState: initialQuickEditSelectionState, canvasGenerationDialogs: dialogs.canvasGenerationDialogs, setQuickEditPanel, characterAnimationPanel, characterAnimationDialog: activeCanvasDialog, characterAnimationSourceLayer, setCharacterAnimationPanel, setGenerateDialog: dialogs.setGenerateDialog, openCanvasGenerationDialog: dialogs.openCanvasGenerationDialog, updateCanvasGenerationDialogById: dialogs.updateCanvasGenerationDialogById, hasCanvasGenerationDialogById: dialogs.hasCanvasGenerationDialogById, getGeneratingDialogPlaceholder: dialogs.getGeneratingDialogPlaceholder, appendCanvasLayersWithResources: (nextLayers) => setLayers((currentLayers) => [...currentLayers, ...nextLayers]), updateSourceLayer: (sourceLayerId, updater) => setLayers((currentLayers) => currentLayers.map((layer) => layer.id === sourceLayerId ? updater(layer) : layer, ), ), selectSingleLayer: setSelectedLayerId, fitLayers: fitLayersMockRef.current, setActiveTool, setActiveSidebarPanel, rememberImageModel: (imageModel) => { rememberedImageModelRef.current = imageModel; }, projectId, assetFolderId: projectId ? 'project' : undefined, upsertGeneratedAsset, applyProjectSnapshot, onQueuedGenerationTask, onWalletBalanceMayHaveChanged, }); return (
{activeTool} {activeSidebarPanel ?? '-'} {selectedLayerId ?? '-'} {layers .map( (layer) => `${layer.id}:${layer.title}:${layer.sourceResourceId ?? '-'}:${layer.assetKind ?? '-'}:${layer.mediaType ?? '-'}:${layer.src}:${layer.thumbnailSrc ?? '-'}:${layer.durationSeconds ?? '-'}`, ) .join('|')} {layers .map( (layer) => `${layer.id}:${layer.x}:${layer.y}:${layer.width}:${layer.height}`, ) .join('|')} {activeDialog ? `${activeDialog.mode}:${activeDialog.status}:${activeDialog.composerOpen !== false ? 'open' : 'closed'}:${activeDialog.generatedLayerId ?? '-'}:${activeDialog.placeholder ? 'placeholder' : '-'}:${activeDialog.errorMessage ?? '-'}` : '-'} {quickEditPanel ? `${quickEditPanel.sourceLayerId}:${quickEditPanel.status}:${quickEditPanel.prompt || '-'}:${quickEditPanel.errorMessage ?? '-'}` : '-'} {characterAnimationPanel ? `${characterAnimationPanel.sourceLayerId}:${characterAnimationPanel.status}:${characterAnimationPanel.promptText || '-'}:${characterAnimationPanel.errorMessage ?? '-'}:${characterAnimationPanel.result ? 'result' : '-'}` : '-'} {fitLayersMockRef.current.mock.calls.length} {rememberedImageModelRef.current ?? '-'}
); } describe('useImageCanvasGenerationSubmissionWorkflow', () => { beforeEach(() => { resolveEditorImageReferenceDataUrlMock.mockReset(); resolveEditorImageReferenceDataUrlForGenerationMock.mockReset(); editEditorImageMock.mockReset(); extractEditorUiDesignAssetsMock.mockReset(); renderUiDesignAssetExtractionMarkedImageMock.mockReset(); generateEditorCharacterAnimationMock.mockReset(); generateEditorIconSpritesheetMock.mockReset(); generateEditorImageMock.mockReset(); generateEditorVideoMock.mockReset(); generateEditorSoundEffectMock.mockReset(); generateEditorBackgroundMusicMock.mockReset(); loadEditorProjectMock.mockReset(); getExternalGenerationJobStatusMock.mockReset(); resolveEditorImageReferenceDataUrlMock.mockImplementation( async (src: string) => `data:image/png;base64,${src.split('/').pop()}`, ); resolveEditorImageReferenceDataUrlForGenerationMock.mockImplementation( async (src: string) => src, ); renderUiDesignAssetExtractionMarkedImageMock.mockResolvedValue( 'data:image/png;base64,marked-ui-design', ); }); it('submits quick edits and updates the source layer directly', async () => { editEditorImageMock.mockResolvedValueOnce( createGenerated({ prompt: '快速修图' }), ); render(); fireEvent.click(screen.getByRole('button', { name: '打开快速编辑' })); fireEvent.click(screen.getByRole('button', { name: '填写快速编辑' })); fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' })); expect(screen.getByTestId('quick-edit').textContent).toBe( 'layer-source:generating:快速修图:-', ); expect(screen.getByTestId('dialog').textContent).toBe('-'); await waitFor(() => { expect(editEditorImageMock).toHaveBeenCalledWith( expect.objectContaining({ prompt: '快速修图', sourceImageSrc: 'data:image/png;base64,source.png', size: '1024x768', model: 'gpt-image-2', }), ); }); expect(editEditorImageMock.mock.calls[0]?.[0]).not.toHaveProperty( 'canvasCompletion', ); expect(generateEditorImageMock).not.toHaveBeenCalled(); await waitFor(() => { expect(screen.getByTestId('layers').textContent).toContain( 'layer-source:源图:resource-source:-:-:data:image/png;base64,generated', ); }); expect(screen.getByTestId('quick-edit').textContent).toBe('-'); expect(screen.getByTestId('tool').textContent).toBe('select'); expect(screen.getByTestId('sidebar').textContent).toBe('layers'); expect(screen.getByTestId('selected').textContent).toBe('-'); expect(screen.getByTestId('fit-count').textContent).toBe('0'); }); it('clears quick edit panel after a queued edit reloads the project snapshot', async () => { const applyProjectSnapshot = vi.fn(); const refreshTaskList = vi.fn(); const backendProject = { projectId: 'editor-project-1', title: '队列修图项目', viewport: { x: 0, y: 0, scale: 1 }, layers: [ { ...createLayer(), src: 'data:image/png;base64,queued-generated', }, ], resources: [], updatedAt: '2026-06-23T08:00:00.000Z', }; loadEditorProjectMock.mockResolvedValueOnce(backendProject); editEditorImageMock.mockResolvedValueOnce({ ...createGenerated({ prompt: '快速修图' }), queueState: createQueueState(), }); render( , ); fireEvent.click(screen.getByRole('button', { name: '打开快速编辑' })); fireEvent.click(screen.getByRole('button', { name: '填写快速编辑' })); fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' })); await waitFor(() => { expect(loadEditorProjectMock).toHaveBeenCalledWith('editor-project-1'); }); expect(refreshTaskList).toHaveBeenCalledTimes(1); expect(applyProjectSnapshot).toHaveBeenCalledWith(backendProject); expect(screen.getByTestId('quick-edit').textContent).toBe('-'); expect(screen.getByTestId('tool').textContent).toBe('select'); expect(screen.getByTestId('sidebar').textContent).toBe('layers'); }); it('submits quick-edit canvas generators with the source image reference', async () => { editEditorImageMock.mockResolvedValueOnce( createGenerated({ prompt: '把当前图改成雨天' }), ); render( , ); fireEvent.click(screen.getByRole('button', { name: '设置初始对话' })); fireEvent.click(screen.getByRole('button', { name: '提交当前生成' })); await waitFor(() => { expect(editEditorImageMock).toHaveBeenCalledWith( expect.objectContaining({ prompt: '把当前图改成雨天', sourceImageSrc: 'data:image/png;base64,source.png', size: '1024x768', model: 'gpt-image-2', sourceResourceId: 'resource-source', targetLayerId: 'layer-source', assetLabel: '源图 快速编辑', }), ); }); expect(generateEditorImageMock).not.toHaveBeenCalled(); await waitFor(() => { expect(screen.getByTestId('layers').textContent).toContain( 'layer-source:源图:resource-source', ); }); expect(screen.getByTestId('quick-edit').textContent).toBe('-'); }); it('refreshes the wallet balance after inline image generation succeeds', async () => { const refreshWalletBalance = vi.fn(); editEditorImageMock.mockResolvedValueOnce( createGenerated({ prompt: '快速修图' }), ); render( , ); fireEvent.click(screen.getByRole('button', { name: '打开快速编辑' })); fireEvent.click(screen.getByRole('button', { name: '填写快速编辑' })); fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' })); await waitFor(() => { expect(editEditorImageMock).toHaveBeenCalledTimes(1); }); await waitFor(() => { expect(refreshWalletBalance).toHaveBeenCalledTimes(1); }); }); it('keeps quick edit results on the source layer when nearby assets exist', async () => { const blockingLayer = createLayer({ id: 'layer-blocker', resourceId: 'resource-blocker', title: '右侧已有素材', x: 472, y: 140, width: 1024, height: 768, originalWidth: 1024, originalHeight: 768, zIndex: 3, }); editEditorImageMock.mockResolvedValueOnce( createGenerated({ prompt: '快速修图', width: 1024, height: 768 }), ); render( , ); fireEvent.click(screen.getByRole('button', { name: '打开快速编辑' })); fireEvent.click(screen.getByRole('button', { name: '填写快速编辑' })); fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' })); await waitFor(() => { expect(screen.getByTestId('layers').textContent).toContain( 'layer-source:源图:resource-source:-:-:data:image/png;base64,generated', ); }); expect(screen.getByTestId('layers').textContent).not.toContain( 'layer-quick-edit-1', ); }); it('submits quick edits with the numbered red selection image as the source image', async () => { editEditorImageMock.mockResolvedValueOnce( createGenerated({ prompt: '对1号红色圈选框里的内容做以下修改:改成蓝色', }), ); render( , ); renderUiDesignAssetExtractionMarkedImageMock.mockResolvedValueOnce( 'data:image/png;base64,marked-source', ); fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' })); await waitFor(() => { expect(renderUiDesignAssetExtractionMarkedImageMock).toHaveBeenCalledWith( { source: 'data:image/png;base64,source.png', marks: [ { id: 'quick-edit-selection-mark-1', tool: 'rect', x: 24, y: 36, width: 160, height: 120, }, ], showIndexLabels: true, }, ); }); await waitFor(() => { expect(editEditorImageMock).toHaveBeenCalledWith( expect.objectContaining({ sourceImageSrc: 'data:image/png;base64,marked-source', targetLayerId: 'layer-source', }), ); }); expect(editEditorImageMock.mock.calls[0]?.[0]).not.toHaveProperty( 'referenceImageSrcs', ); expect(generateEditorImageMock).not.toHaveBeenCalled(); }); it('submits canvas quick edits with numbered selection marks', async () => { editEditorImageMock.mockResolvedValueOnce( createGenerated({ prompt: '对1号红色圈选框里的内容做以下修改:改成蓝色', }), ); render( , ); renderUiDesignAssetExtractionMarkedImageMock.mockResolvedValueOnce( 'data:image/png;base64,marked-source', ); fireEvent.click(screen.getByRole('button', { name: '设置初始对话' })); fireEvent.click(screen.getByRole('button', { name: '提交当前生成' })); await waitFor(() => { expect(renderUiDesignAssetExtractionMarkedImageMock).toHaveBeenCalledWith( { source: 'data:image/png;base64,source.png', marks: [ { id: 'quick-edit-selection-mark-1', tool: 'rect', x: 24, y: 36, width: 160, height: 120, }, ], showIndexLabels: true, }, ); }); await waitFor(() => { expect(editEditorImageMock).toHaveBeenCalledWith( expect.objectContaining({ sourceImageSrc: 'data:image/png;base64,marked-source', model: 'gpt-image-2', targetLayerId: 'layer-source', }), ); }); }); it('keeps failed quick edit panels visible with the normalized prompt', async () => { const refreshWalletBalance = vi.fn(); editEditorImageMock.mockRejectedValueOnce(new Error('修图失败')); render( , ); fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' })); await waitFor(() => { expect(screen.getByTestId('quick-edit').textContent).toBe( 'layer-source:failed:快速编辑图片:修图失败', ); }); expect(refreshWalletBalance).toHaveBeenCalledTimes(1); }); it('submits character redraws through the character image prompt contract', async () => { generateEditorImageMock.mockResolvedValueOnce( createGenerated({ prompt: '角色换成蓝色披风' }), ); render( , ); fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' })); await waitFor(() => { expect(generateEditorImageMock).toHaveBeenCalledWith( expect.objectContaining({ prompt: '角色换成蓝色披风', size: '1024x768', aspectRatio: '4:3', imageSize: '1K', kind: 'character', model: 'gpt-image-2', referenceImageSrcs: ['data:image/png;base64,source.png'], assetKind: 'character', }), ); }); await waitFor(() => { expect(screen.getByTestId('layers').textContent).toContain( 'layer-quick-edit-1:源图 重绘:resource-source:character', ); }); expect(screen.getByTestId('quick-edit').textContent).toBe( 'layer-source:idle:角色换成蓝色披风:-', ); }); it('submits video quick edits through the video API with the source video', async () => { const applyProjectSnapshot = vi.fn(); const upsertGeneratedAsset = vi.fn(); generateEditorVideoMock.mockResolvedValueOnce( createVideoGenerated({ prompt: '把当前图改成雨天', project: { projectId: 'editor-project-1', title: '项目', coverImageSrc: null, viewport: { x: 0, y: 0, scale: 1 }, layers: [], resources: [], createdAt: '2026-06-27T00:00:00.000Z', updatedAt: '2026-06-27T00:00:00.000Z', }, asset: { assetId: 'asset-video-quick-edit', folderId: 'user-1:asset-folder:project', label: '源视频 快速编辑', imageSrc: 'https://assets.example.test/generated-video.mp4', thumbnailSrc: 'https://assets.example.test/generated-video-cover.png', width: 854, height: 480, sourceType: 'generated', assetKind: 'video', }, }), ); render( , ); fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' })); await waitFor(() => { expect(generateEditorVideoMock).toHaveBeenCalledWith( expect.objectContaining({ prompt: '把当前图改成雨天', model: 'seedance2.0-fast', aspectRatio: '16:9', durationSeconds: 4, resolution: '480p', mode: 'std', sound: 'off', referenceVideoSrcs: [ 'generated-character-drafts/editor/videos/source.mp4', ], assetFolderId: 'project', assetLabel: '源视频 快速编辑', }), ); }); expect(generateEditorVideoMock.mock.calls[0]?.[0]).not.toHaveProperty( 'referenceImageSrcs', ); expect(generateEditorImageMock).not.toHaveBeenCalled(); expect(generateEditorCharacterAnimationMock).not.toHaveBeenCalled(); await waitFor(() => { expect(applyProjectSnapshot).toHaveBeenCalledWith( expect.objectContaining({ projectId: 'editor-project-1' }), ); expect(upsertGeneratedAsset).toHaveBeenCalledWith( expect.objectContaining({ assetId: 'asset-video-quick-edit', thumbnailSrc: 'https://assets.example.test/generated-video-cover.png', }), ); }); }); it('submits character action quick edits through image edits', async () => { editEditorImageMock.mockResolvedValueOnce( createGenerated({ prompt: '让当前图跳得更高', imageSrc: 'data:image/png;base64,action-frame-edited', }), ); render( , ); fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' })); await waitFor(() => { expect(editEditorImageMock).toHaveBeenCalledWith( expect.objectContaining({ prompt: '让当前图跳得更高', sourceImageSrc: 'data:image/png;base64,frame.png', model: 'gpt-image-2', assetLabel: '角色动作 快速编辑', targetLayerId: 'layer-source', }), ); }); expect(generateEditorCharacterAnimationMock).not.toHaveBeenCalled(); expect(generateEditorImageMock).not.toHaveBeenCalled(); expect(generateEditorVideoMock).not.toHaveBeenCalled(); await waitFor(() => { expect(screen.getByTestId('layers').textContent).toContain( 'layer-source:角色动作:resource-source:character-animation:image-sequence:data:image/png;base64,action-frame-edited', ); }); }); it('submits edit dialogs beside the source and clears the modal state', async () => { editEditorImageMock.mockResolvedValueOnce( createGenerated({ prompt: '修改当前图片' }), ); render( , ); fireEvent.click(screen.getByRole('button', { name: '设置初始对话' })); fireEvent.click(screen.getByRole('button', { name: '提交当前生成' })); expect(screen.getByTestId('dialog').textContent).toBe( 'edit:generating:open:-:-:-', ); await waitFor(() => { expect(editEditorImageMock).toHaveBeenCalledWith( expect.objectContaining({ prompt: '修改当前图片', sourceImageSrc: 'data:image/png;base64,source.png', }), ); }); await waitFor(() => { expect(screen.getByTestId('layers').textContent).toContain( 'layer-edit-1:源图 修改结果:resource-source:-', ); }); expect(screen.getByTestId('dialog').textContent).toBe('-'); expect(screen.getByTestId('tool').textContent).toBe('select'); expect(screen.getByTestId('fit-count').textContent).toBe('1'); }); it('uses the latest moved canvas placeholder for local-only generation fallback', async () => { generateEditorImageMock.mockResolvedValueOnce( createGenerated({ width: 512, height: 512 }), ); render( , ); fireEvent.click(screen.getByRole('button', { name: '设置初始对话' })); fireEvent.click(screen.getByRole('button', { name: '移动占位' })); fireEvent.click(screen.getByRole('button', { name: '提交当前生成' })); await waitFor(() => { expect(screen.getByTestId('layers').textContent).toContain( 'layer-generated-1:生成图片 1:-:-', ); }); expect(screen.getByTestId('dialog').textContent).toBe( 'generate:idle:open:layer-generated-1:placeholder:-', ); }); it('passes canvas completion context and refreshes from the backend project snapshot', async () => { const applyProjectSnapshot = vi.fn(); generateEditorImageMock.mockResolvedValueOnce({ ...createGenerated({ width: 1024, height: 1024, prompt: '银发游侠', }), project: { projectId: 'editor-project-1', title: '角色项目', viewport: { x: 12, y: 24, scale: 1.5 }, layers: [ { layerId: 'layer-editor-resource-character', resourceId: 'editor-resource-character', }, ], resources: [], updatedAt: '2026-06-21T12:16:22.000Z', }, }); render( , ); fireEvent.click(screen.getByRole('button', { name: '设置初始对话' })); fireEvent.click(screen.getByRole('button', { name: '提交当前生成' })); await waitFor(() => { expect(generateEditorImageMock).toHaveBeenCalledWith( expect.objectContaining({ prompt: '银发游侠', kind: 'character', projectId: 'editor-project-1', assetKind: 'character', assetFolderId: 'project', canvasCompletion: { dialogId: 'dialog-character', title: '角色形象 1', placeholder: { x: 200, y: 160, width: 420, height: 560, originalWidth: 1024, originalHeight: 1536, }, }, }), ); }); await waitFor(() => { expect(applyProjectSnapshot).toHaveBeenCalledWith( expect.objectContaining({ projectId: 'editor-project-1', title: '角色项目', }), ); }); expect(screen.getByTestId('layers').textContent).not.toContain( 'layer-generated-1', ); }); it('submits audio generations with asset library context and upserts the returned asset', async () => { const applyProjectSnapshot = vi.fn(); const upsertGeneratedAsset = vi.fn(); generateEditorSoundEffectMock.mockResolvedValueOnce( createAudioGenerated({ project: { projectId: 'editor-project-audio', title: '音频项目', viewport: { x: 0, y: 0, scale: 1 }, layers: [], resources: [], updatedAt: '2026-06-27T00:00:00.000Z', }, asset: { assetId: 'asset-sound-1', folderId: 'user-1:asset-folder:project', label: '游戏音效 1', imageSrc: 'https://assets.example.test/generated-audio.mp3', width: 420, height: 120, sourceType: 'generated', assetKind: 'sound-effect', }, }), ); render( , ); fireEvent.click(screen.getByRole('button', { name: '设置初始对话' })); fireEvent.click(screen.getByRole('button', { name: '提交当前生成' })); await waitFor(() => { expect(generateEditorSoundEffectMock).toHaveBeenCalledWith( expect.objectContaining({ prompt: '金币掉落叮当声', duration: 7, projectId: 'editor-project-audio', assetFolderId: 'project', assetLabel: '游戏音效 1', canvasCompletion: { dialogId: 'dialog-sound', title: '游戏音效 1', placeholder: { x: 200, y: 160, width: 420, height: 120, originalWidth: 420, originalHeight: 120, }, }, }), ); }); await waitFor(() => { expect(applyProjectSnapshot).toHaveBeenCalledWith( expect.objectContaining({ projectId: 'editor-project-audio' }), ); expect(upsertGeneratedAsset).toHaveBeenCalledWith( expect.objectContaining({ assetId: 'asset-sound-1', assetKind: 'sound-effect', }), ); }); }); it('resolves uploaded objectKey references before submitting image generation', async () => { resolveEditorImageReferenceDataUrlForGenerationMock.mockResolvedValueOnce( 'data:image/jpeg;base64,normalized-ref', ); generateEditorImageMock.mockResolvedValueOnce( createGenerated({ prompt: '参考图生成' }), ); render( , ); fireEvent.click(screen.getByRole('button', { name: '设置初始对话' })); fireEvent.click(screen.getByRole('button', { name: '提交当前生成' })); await waitFor(() => { expect( resolveEditorImageReferenceDataUrlForGenerationMock, ).toHaveBeenCalledWith('generated-character-drafts/editor/refs/ref.png'); expect(generateEditorImageMock).toHaveBeenCalledWith( expect.objectContaining({ prompt: '参考图生成', referenceImageSrcs: ['data:image/jpeg;base64,normalized-ref'], }), ); }); }); it('refreshes the wallet balance after a queued generation reaches a terminal state', async () => { const applyProjectSnapshot = vi.fn(); const refreshTaskList = vi.fn(); const refreshWalletBalance = vi.fn(); const backendProject = { projectId: 'editor-project-1', title: '队列项目', viewport: { x: 0, y: 0, scale: 1 }, layers: [], resources: [], updatedAt: '2026-06-23T08:00:00.000Z', }; loadEditorProjectMock.mockResolvedValueOnce(backendProject); generateEditorImageMock.mockResolvedValueOnce({ ...createGenerated({ imageSrc: '/generated-editor-images/project/image.png', width: 1024, height: 1024, prompt: '队列生成', }), queueState: createQueueState(), }); render( , ); fireEvent.click(screen.getByRole('button', { name: '设置初始对话' })); fireEvent.click(screen.getByRole('button', { name: '提交当前生成' })); await waitFor(() => { expect(refreshTaskList).toHaveBeenCalledTimes(1); }); await waitFor(() => { expect(loadEditorProjectMock).toHaveBeenCalledWith('editor-project-1'); }); expect(refreshWalletBalance).toHaveBeenCalledTimes(1); expect(applyProjectSnapshot).toHaveBeenCalledWith(backendProject); expect(getExternalGenerationJobStatusMock).not.toHaveBeenCalled(); expect(screen.getByTestId('layers').textContent).not.toContain( 'layer-generated-1', ); }); it('keeps a queued generation pending when the frontend queue wait expires', async () => { vi.useFakeTimers(); try { const refreshWalletBalance = vi.fn(); const refreshTaskList = vi.fn(); const runningState = createQueueState({ status: 'running', phaseLabel: '生成中', phaseDetail: '后端任务继续执行中', progress: 0.35, }); generateEditorImageMock.mockResolvedValueOnce({ ...createGenerated({ imageSrc: '/generated-editor-images/project/image.png', width: 1024, height: 1024, prompt: '队列生成', }), queueState: runningState, }); getExternalGenerationJobStatusMock.mockResolvedValue({ job: runningState, }); render( , ); fireEvent.click(screen.getByRole('button', { name: '设置初始对话' })); fireEvent.click(screen.getByRole('button', { name: '提交当前生成' })); await act(async () => { await Promise.resolve(); await Promise.resolve(); }); expect(refreshTaskList).toHaveBeenCalledTimes(1); await act(async () => { await vi.advanceTimersByTimeAsync(20 * 60 * 1000 + 1600); }); expect(refreshWalletBalance).toHaveBeenCalledTimes(1); expect(loadEditorProjectMock).not.toHaveBeenCalled(); expect(getExternalGenerationJobStatusMock).toHaveBeenCalled(); expect(screen.getByTestId('dialog').textContent).toBe( 'generate:generating:closed:-:placeholder:-', ); expect(screen.getByTestId('dialog').textContent).not.toContain( '生成任务仍在队列中', ); expect(screen.getByTestId('layers').textContent).not.toContain( 'layer-generated-1', ); } finally { vi.useRealTimers(); } }); it('does not add a local layer when project generation returns without a backend snapshot', async () => { generateEditorImageMock.mockResolvedValueOnce( createGenerated({ width: 1024, height: 1024, prompt: '银发游侠', project: null, }), ); render( , ); fireEvent.click(screen.getByRole('button', { name: '设置初始对话' })); fireEvent.click(screen.getByRole('button', { name: '提交当前生成' })); await waitFor(() => { expect(generateEditorImageMock).toHaveBeenCalledWith( expect.objectContaining({ projectId: 'editor-project-1', canvasCompletion: expect.objectContaining({ dialogId: 'dialog-character', }), }), ); }); await waitFor(() => { expect(screen.getByTestId('dialog').textContent).toBe( 'character:idle:open:-:placeholder:-', ); }); expect(screen.getByTestId('layers').textContent).not.toContain( 'layer-generated-1', ); }); it('submits publication detail materials as one billable editor image and appends the result', async () => { generateEditorImageMock.mockResolvedValueOnce( createGenerated({ imageSrc: 'data:image/png;base64,publication', width: 720, height: 1280, prompt: '游戏名:云朵消消乐', }), ); render( , ); fireEvent.click(screen.getByRole('button', { name: '设置初始对话' })); fireEvent.click(screen.getByRole('button', { name: '提交当前生成' })); expect(screen.getByTestId('dialog').textContent).toBe( 'publication:generating:closed:-:placeholder:-', ); await waitFor(() => { expect(generateEditorImageMock).toHaveBeenCalledWith( expect.objectContaining({ prompt: expect.stringContaining('【宣发素材类型】详情页单图'), size: '720x1280', kind: 'publication-material', model: 'gpt-image-2', aspectRatio: '9:16', imageSize: '1K', referenceImageSrcs: ['data:image/png;base64,large-ref'], }), ); }); const prompt = generateEditorImageMock.mock.calls[0]?.[0]?.prompt ?? ''; expect(prompt).toContain('游戏名:云朵消消乐'); expect(prompt).toContain('本次只生成一张 720 x 1280 竖版详情图'); expect(prompt).toContain('不是五图合集或拼贴'); await waitFor(() => { expect(screen.getByTestId('layers').textContent).toContain( 'layer-generated-1:1 宣发素材:-:-', ); }); expect(screen.getByTestId('layers').textContent).not.toContain( 'layer-generated-2', ); expect(screen.getByTestId('selected').textContent).toBe( 'layer-generated-1', ); }); it('compresses publication references before submitting generation', async () => { resolveEditorImageReferenceDataUrlForGenerationMock.mockResolvedValueOnce( 'data:image/jpeg;base64,compressed-ref', ); generateEditorImageMock.mockResolvedValueOnce( createGenerated({ imageSrc: 'data:image/png;base64,publication', width: 720, height: 540, prompt: '游戏名:云朵消消乐', }), ); render( , ); fireEvent.click(screen.getByRole('button', { name: '设置初始对话' })); fireEvent.click(screen.getByRole('button', { name: '提交当前生成' })); await waitFor(() => { expect( resolveEditorImageReferenceDataUrlForGenerationMock, ).toHaveBeenCalledWith('data:image/png;base64,large-original-ref'); expect(generateEditorImageMock).toHaveBeenCalledWith( expect.objectContaining({ kind: 'publication-material', aspectRatio: '4:3', imageSize: '1K', referenceImageSrcs: ['data:image/jpeg;base64,compressed-ref'], }), ); }); }); it('submits icon spec objects without requiring an icon spec reference', async () => { generateEditorImageMock.mockResolvedValueOnce( createGenerated({ imageSrc: 'data:image/png;base64,icon-spec', width: 2048, height: 1152, prompt: '图标规范提示词', }), ); render( , ); fireEvent.click(screen.getByRole('button', { name: '设置初始对话' })); fireEvent.click(screen.getByRole('button', { name: '提交当前生成' })); expect(generateEditorIconSpritesheetMock).not.toHaveBeenCalled(); await waitFor(() => { expect(generateEditorImageMock).toHaveBeenCalledWith( expect.objectContaining({ kind: 'spec', assetKind: 'icon-spec', assetLabel: '图标规范 1', size: '2048x1152', aspectRatio: '16:9', imageSize: '2K', }), ); }); const payload = generateEditorImageMock.mock.calls[0]?.[0] ?? {}; expect(payload).not.toHaveProperty('referenceImageSrcs'); expect(payload.prompt).toContain('背包整理玩法'); expect(payload.prompt).not.toContain('参考图生成规范'); await waitFor(() => { expect(screen.getByTestId('layers').textContent).toContain( 'layer-generated-1:图标规范 1:-:icon-spec', ); }); expect(screen.getByTestId('dialog').textContent).not.toContain( '请选择图标规范', ); }); it('validates icon submission before calling the API', async () => { render( , ); fireEvent.click(screen.getByRole('button', { name: '设置初始对话' })); fireEvent.click(screen.getByRole('button', { name: '提交图标' })); expect(generateEditorIconSpritesheetMock).not.toHaveBeenCalled(); await waitFor(() => { expect(screen.getByTestId('dialog').textContent).toContain( '请选择图标规范', ); }); }); it('submits icon spritesheets, trims descriptions, and remembers the model', async () => { generateEditorIconSpritesheetMock.mockResolvedValueOnce({ spritesheetImageSrc: 'data:image/png;base64,sheet', spritesheetWidth: 512, spritesheetHeight: 512, prompt: '图标素材', actualPrompt: '图标素材', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'task-icons', iconImageSrcs: [], }); render( , ); fireEvent.click(screen.getByRole('button', { name: '设置初始对话' })); fireEvent.click(screen.getByRole('button', { name: '提交图标' })); await waitFor(() => { expect(generateEditorIconSpritesheetMock).toHaveBeenCalledWith( expect.objectContaining({ referenceImageSrc: 'data:image/png;base64,spec', iconDescriptions: ['返回按钮', '设置按钮'], model: 'gpt-image-2', }), ); }); await waitFor(() => { expect(screen.getByTestId('layers').textContent).toContain( 'layer-icon-spritesheet-1:图标素材图集 1:-:icon-spritesheet', ); }); expect(screen.getByTestId('layers').textContent).not.toContain( 'layer-icon-2', ); expect(screen.getByTestId('selected').textContent).toBe( 'layer-icon-spritesheet-1', ); expect(screen.getByTestId('dialog').textContent).toBe( 'icon:idle:open:layer-icon-spritesheet-1:placeholder:-', ); expect(screen.getByTestId('remembered-model').textContent).toBe( 'gpt-image-2', ); }); it('submits uploaded objectKey icon references before submitting spritesheets', async () => { generateEditorIconSpritesheetMock.mockResolvedValueOnce({ spritesheetImageSrc: 'data:image/png;base64,sheet', spritesheetWidth: 512, spritesheetHeight: 512, prompt: '图标素材', actualPrompt: '图标素材', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'task-icons', iconImageSrcs: [], }); render( , ); fireEvent.click(screen.getByRole('button', { name: '设置初始对话' })); fireEvent.click(screen.getByRole('button', { name: '提交图标' })); await waitFor(() => { expect(resolveEditorImageReferenceDataUrlForGenerationMock).not.toHaveBeenCalled(); expect(generateEditorIconSpritesheetMock).toHaveBeenCalledWith( expect.objectContaining({ referenceImageSrc: 'generated-character-drafts/editor/specs/icon-spec.png', referenceImageSrcs: [ 'generated-character-drafts/editor/refs/icon-style.png', ], iconDescriptions: ['返回按钮'], }), ); }); }); it('extracts UI design assets into a spritesheet layer and sliced icon layers', async () => { extractEditorUiDesignAssetsMock.mockResolvedValueOnce({ spritesheetImageSrc: 'data:image/png;base64,ui-sheet', spritesheetWidth: 512, spritesheetHeight: 512, prompt: '仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。', actualPrompt: '仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'task-ui-assets', iconImageSrcs: [ createIconResult('素材 1', 'data:image/png;base64,asset-1'), ], }); render( , ); fireEvent.click(screen.getByRole('button', { name: '提取UI素材' })); await waitFor(() => { expect(extractEditorUiDesignAssetsMock).toHaveBeenCalledWith( expect.objectContaining({ sourceImageSrc: 'generated/ui-design.png', model: 'gemini-3.1-flash-image-preview', screenColor: 'auto', segModel: 'birefnet', }), ); }); expect(resolveEditorImageReferenceDataUrlMock).not.toHaveBeenCalledWith( 'generated/ui-design.png', ); await waitFor(() => { expect(screen.getByTestId('layers').textContent).toContain( 'layer-icon-spritesheet-1:源图 素材图集:-:icon-spritesheet', ); }); expect(screen.getByTestId('layers').textContent).toContain( 'layer-icon-2:素材 1:-:icon', ); expect(screen.getByTestId('selected').textContent).toBe( 'layer-icon-spritesheet-1', ); expect(screen.getByTestId('fit-count').textContent).toBe('1'); }); it('keeps the extracted spritesheet when extracted icon results are missing', async () => { extractEditorUiDesignAssetsMock.mockResolvedValueOnce({ spritesheetImageSrc: 'data:image/png;base64,ui-sheet', spritesheetWidth: 512, spritesheetHeight: 512, prompt: '仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。', actualPrompt: '仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'task-ui-assets', }); render( , ); fireEvent.click(screen.getByRole('button', { name: '提取UI素材' })); await waitFor(() => { expect(screen.getByTestId('layers').textContent).toContain( 'layer-icon-spritesheet-1:源图 素材图集:-:icon-spritesheet', ); }); expect(screen.getByTestId('layers').textContent).not.toContain( 'layer-icon-2:', ); expect(screen.getByTestId('selected').textContent).toBe( 'layer-icon-spritesheet-1', ); expect(screen.getByTestId('fit-count').textContent).toBe('1'); }); it('renders selected UI marks into the extraction reference image and sends derived extraction pricing', async () => { extractEditorUiDesignAssetsMock.mockResolvedValueOnce({ spritesheetImageSrc: 'data:image/png;base64,ui-sheet', spritesheetWidth: 512, spritesheetHeight: 512, prompt: '仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。', actualPrompt: '仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'task-ui-assets', iconImageSrcs: [ createIconResult('素材 1', 'data:image/png;base64,asset-1'), ], }); render( , ); fireEvent.click(screen.getByRole('button', { name: '提取已框选UI素材' })); await waitFor(() => { expect(renderUiDesignAssetExtractionMarkedImageMock).toHaveBeenCalledWith( expect.objectContaining({ source: 'data:image/png;base64,ui-design.png', marks: expect.arrayContaining([ { id: 'mark-1', tool: 'rect', x: 10, y: 20, width: 80, height: 60, }, ]), }), ); expect( renderUiDesignAssetExtractionMarkedImageMock.mock.calls[0]?.[0].marks, ).toHaveLength(7); }); await waitFor(() => { expect(extractEditorUiDesignAssetsMock).toHaveBeenCalledWith( expect.objectContaining({ sourceImageSrc: 'data:image/png;base64,marked-ui-design', model: 'gemini-3.1-flash-image-preview', screenColor: 'auto', segModel: 'birefnet', aspectRatio: '1:1', imageSize: '2K', generationInputs: expect.objectContaining({ fields: expect.arrayContaining([ expect.objectContaining({ title: '提取提示词', value: '仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。', }), ]), }), }), ); }); }); it('submits UI asset extraction with uploaded references', async () => { extractEditorUiDesignAssetsMock.mockResolvedValueOnce({ spritesheetImageSrc: 'data:image/png;base64,ui-sheet', spritesheetWidth: 512, spritesheetHeight: 512, prompt: '仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。', actualPrompt: '仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'task-ui-assets', }); render( , ); fireEvent.click(screen.getByRole('button', { name: '提取带参考图UI素材' })); await waitFor(() => { expect(resolveEditorImageReferenceDataUrlForGenerationMock).not.toHaveBeenCalled(); }); await waitFor(() => { expect(extractEditorUiDesignAssetsMock).toHaveBeenCalledWith( expect.objectContaining({ screenColor: 'auto', segModel: 'birefnet', referenceImageSrcs: ['generated/ref-style.png'], generationInputs: expect.objectContaining({ references: expect.arrayContaining([ expect.objectContaining({ title: '参考图 1', label: '风格参考', refType: 'project-resource', }), ]), }), }), ); }); }); it('keeps character generation dialog data after the result is created', async () => { generateEditorImageMock.mockResolvedValueOnce( createGenerated({ width: 768, height: 768 }), ); render( , ); fireEvent.click(screen.getByRole('button', { name: '设置初始对话' })); fireEvent.click(screen.getByRole('button', { name: '提交当前生成' })); await waitFor(() => { expect(screen.getByTestId('layers').textContent).toContain( 'layer-generated-1:角色形象 1:-:character', ); }); expect(screen.getByTestId('dialog').textContent).toBe( 'character:idle:open:layer-generated-1:placeholder:-', ); }); it('moves character animation panels from generating to completed', async () => { generateEditorCharacterAnimationMock.mockResolvedValueOnce({ frames: [{ imageSrc: 'data:image/png;base64,frame' }], previewSrc: 'data:image/png;base64,preview', taskId: 'animation-task', }); render( , ); fireEvent.click(screen.getByRole('button', { name: '提交角色动画' })); expect(screen.getByTestId('character-animation').textContent).toBe( 'character-layer:generating:跑步:-:-', ); await waitFor(() => { expect(generateEditorCharacterAnimationMock).toHaveBeenCalledWith( expect.objectContaining({ sourceLayerId: 'character-layer', sourceImageSrc: 'generated/character.png', promptText: '跑步', model: 'seedance2.0-fast', }), ); }); await waitFor(() => { expect(screen.getByTestId('character-animation').textContent).toBe( 'character-layer:completed:跑步:-:result', ); }); }); it('does not resubmit character animation while it is already generating', () => { render( , ); fireEvent.click(screen.getByRole('button', { name: '提交角色动画' })); expect(generateEditorCharacterAnimationMock).not.toHaveBeenCalled(); }); it('creates an action layer for dialog-driven character animation submissions', async () => { generateEditorCharacterAnimationMock.mockResolvedValueOnce({ taskId: 'animation-task-dialog', model: 'seedance2.0-fast', prompt: '挥手动作', previewVideoPath: '/generated-character-drafts/editor/preview.mp4', frames: [ { frameIndex: 1, imageSrc: 'data:image/png;base64,action-frame', width: 1024, height: 1024, }, ], frameCount: 32, durationSeconds: 4, fps: 8, }); render( , ); fireEvent.click(screen.getByRole('button', { name: '设置初始对话' })); fireEvent.click(screen.getByRole('button', { name: '提交角色动画' })); await waitFor(() => { expect(generateEditorCharacterAnimationMock).toHaveBeenCalledWith( expect.objectContaining({ sourceLayerId: 'character-layer', sourceImageSrc: 'generated/character.png', promptText: '挥手动作', model: 'seedance2.0-fast', }), ); }); await waitFor(() => { expect(screen.getByTestId('layers').textContent).toContain( 'layer-character-animation-1:角色动作:resource-source:character-animation:image-sequence:data:image/png;base64,action-frame:data:image/png;base64,action-frame:4', ); }); expect(screen.getByTestId('dialog').textContent).toBe( 'character-animation:idle:open:layer-character-animation-1:placeholder:-', ); expect(screen.getByTestId('selected').textContent).toBe( 'layer-character-animation-1', ); }); it('shows a readable conflict message for failed character animation submissions', async () => { generateEditorCharacterAnimationMock.mockRejectedValueOnce( new ApiClientError({ message: '请求冲突(requestId: wallet-one)', status: 409, code: 'CONFLICT', details: { provider: 'profile-wallet' }, }), ); render( , ); await act(async () => { fireEvent.click(screen.getByRole('button', { name: '提交角色动画' })); }); await waitFor(() => { expect(screen.getByTestId('character-animation').textContent).toBe( 'character-layer:failed:跑步:泥点余额不足,请充值后再生成:-', ); }); }); it('uses a fallback error message for failed character animation submissions', async () => { generateEditorCharacterAnimationMock.mockRejectedValueOnce(new Error('')); render( , ); await act(async () => { fireEvent.click(screen.getByRole('button', { name: '提交角色动画' })); }); await waitFor(() => { expect(screen.getByTestId('character-animation').textContent).toBe( 'character-layer:failed:-:生成角色动画失败:-', ); }); }); });