Files
Genarrative/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx
T
kdletters 73a17193d4 升级外部生成为正式任务列表
将 external_generation_job 扩展为含价格、确认时间、退款流水和事件审计的正式生成任务事实源

新增外部生成任务列表与确认 BFF,并让平台入口按后端任务补弹完成或失败提示

保护画布生成队列超时、快照回写和自动保存覆盖场景

将 worker 计费退款流水写入 externalGenerationJobId,并补充冷备份后 worker/controller 恢复守护
2026-06-24 15:17:12 +08:00

1969 lines
63 KiB
TypeScript

/* @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 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> = {}): 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 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 parseLayerRect(text: string, layerId: string) {
const entry = text
.split('|')
.find((currentEntry) => currentEntry.startsWith(`${layerId}:`));
if (!entry) {
throw new Error(`missing layer rect for ${layerId}`);
}
const [, x, y, width, height] = entry.split(':');
return {
x: Number(x),
y: Number(y),
width: Number(width),
height: Number(height),
};
}
function rectOverlapsExpanded(
rect: { x: number; y: number; width: number; height: number },
blocker: { x: number; y: number; width: number; height: number },
gap: number,
) {
return (
rect.x < blocker.x + blocker.width + gap &&
rect.x + rect.width > blocker.x - gap &&
rect.y < blocker.y + blocker.height + gap &&
rect.y + rect.height > blocker.y - gap
);
}
function SubmissionWorkflowHarness({
initialDialog = null,
initialQuickEditPanel = null,
initialQuickEditSelectionState = null,
initialCharacterAnimationPanel = null,
initialLayers = [createLayer()],
projectId = undefined,
applyProjectSnapshot = undefined,
onWalletBalanceMayHaveChanged = undefined,
}: {
initialDialog?: GenerateDialogState | null;
initialQuickEditPanel?: QuickEditPanelState | null;
initialQuickEditSelectionState?: UiAssetExtractionState | null;
initialCharacterAnimationPanel?: CharacterAnimationPanelState | null;
initialLayers?: CanvasLayer[];
projectId?: string | null;
applyProjectSnapshot?: Parameters<
typeof useImageCanvasGenerationSubmissionWorkflow
>[0]['applyProjectSnapshot'];
onWalletBalanceMayHaveChanged?: Parameters<
typeof useImageCanvasGenerationSubmissionWorkflow
>[0]['onWalletBalanceMayHaveChanged'];
}) {
const [layers, setLayers] = useState<CanvasLayer[]>(initialLayers);
const [activeTool, setActiveTool] = useState<CanvasTool>('generate');
const [activeSidebarPanel, setActiveSidebarPanel] =
useState<SidebarPanel | null>('assets');
const [selectedLayerId, setSelectedLayerId] = useState<string | null>(null);
const [quickEditPanel, setQuickEditPanel] =
useState<QuickEditPanelState | null>(initialQuickEditPanel);
const [characterAnimationPanel, setCharacterAnimationPanel] =
useState<CharacterAnimationPanelState | null>(
initialCharacterAnimationPanel,
);
const rememberedImageModelRef = useRef<string | null>(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]),
selectSingleLayer: setSelectedLayerId,
fitLayers: fitLayersMockRef.current,
setActiveTool,
setActiveSidebarPanel,
rememberImageModel: (imageModel) => {
rememberedImageModelRef.current = imageModel;
},
projectId,
assetFolderId: projectId ? 'project' : undefined,
applyProjectSnapshot,
onWalletBalanceMayHaveChanged,
});
return (
<div>
<span data-testid="tool">{activeTool}</span>
<span data-testid="sidebar">{activeSidebarPanel ?? '-'}</span>
<span data-testid="selected">{selectedLayerId ?? '-'}</span>
<span data-testid="layers">
{layers
.map(
(layer) =>
`${layer.id}:${layer.title}:${layer.sourceResourceId ?? '-'}:${layer.assetKind ?? '-'}:${layer.mediaType ?? '-'}:${layer.src}:${layer.thumbnailSrc ?? '-'}:${layer.durationSeconds ?? '-'}`,
)
.join('|')}
</span>
<span data-testid="layer-rects">
{layers
.map(
(layer) =>
`${layer.id}:${layer.x}:${layer.y}:${layer.width}:${layer.height}`,
)
.join('|')}
</span>
<span data-testid="dialog">
{activeDialog
? `${activeDialog.mode}:${activeDialog.status}:${activeDialog.composerOpen !== false ? 'open' : 'closed'}:${activeDialog.generatedLayerId ?? '-'}:${activeDialog.placeholder ? 'placeholder' : '-'}:${activeDialog.errorMessage ?? '-'}`
: '-'}
</span>
<span data-testid="quick-edit">
{quickEditPanel
? `${quickEditPanel.sourceLayerId}:${quickEditPanel.status}:${quickEditPanel.prompt || '-'}:${quickEditPanel.errorMessage ?? '-'}`
: '-'}
</span>
<span data-testid="character-animation">
{characterAnimationPanel
? `${characterAnimationPanel.sourceLayerId}:${characterAnimationPanel.status}:${characterAnimationPanel.promptText || '-'}:${characterAnimationPanel.errorMessage ?? '-'}:${characterAnimationPanel.result ? 'result' : '-'}`
: '-'}
</span>
<span data-testid="fit-count">
{fitLayersMockRef.current.mock.calls.length}
</span>
<span data-testid="remembered-model">
{rememberedImageModelRef.current ?? '-'}
</span>
<button
type="button"
onClick={() => {
if (initialDialog) {
dialogs.setGenerateDialog(initialDialog);
}
}}
>
设置初始对话
</button>
<button
type="button"
onClick={() => {
if (sourceLayer) {
setQuickEditPanel(createQuickEditPanelDraft(sourceLayer));
}
}}
>
打开快速编辑
</button>
<button
type="button"
onClick={() =>
setQuickEditPanel((currentPanel) =>
currentPanel ? { ...currentPanel, prompt: ' 快速修图 ' } : null,
)
}
>
填写快速编辑
</button>
<button type="button" onClick={() => void workflow.submitQuickEdit()}>
提交快速编辑
</button>
<button
type="button"
onClick={() => {
if (activeDialog) {
void workflow.submitImageGeneration(activeDialog);
}
}}
>
提交当前生成
</button>
<button
type="button"
onClick={() => {
if (activeDialog) {
void workflow.submitIconSpritesheetGeneration(activeDialog);
}
}}
>
提交图标
</button>
<button
type="button"
onClick={() => {
if (sourceLayer) {
void workflow.extractUiDesignAssets(sourceLayer);
}
}}
>
提取UI素材
</button>
<button
type="button"
onClick={() => {
if (sourceLayer) {
void workflow.extractUiDesignAssets(sourceLayer, {
marks: Array.from({ length: 7 }, (_, index) => ({
id: `mark-${index + 1}`,
tool: 'rect' as const,
x: 10 + index * 8,
y: 20 + index * 6,
width: 80,
height: 60,
})),
});
}
}}
>
提取已框选UI素材
</button>
<button
type="button"
onClick={() => void workflow.submitCharacterAnimation()}
>
提交角色动画
</button>
<button
type="button"
onClick={() => {
if (activeCanvasDialog) {
dialogs.updateCanvasGenerationDialogById(
activeCanvasDialog.id,
(dialog) => ({
...dialog,
placeholder: dialog.placeholder
? { ...dialog.placeholder, x: 500, y: 400 }
: dialog.placeholder,
}),
);
}
}}
>
移动占位
</button>
</div>
);
}
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, clears the panel, and fits the source plus result', async () => {
generateEditorImageMock.mockResolvedValueOnce(
createGenerated({ prompt: '快速修图' }),
);
render(<SubmissionWorkflowHarness />);
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('-');
expect(screen.getByTestId('dialog').textContent).toBe(
'quick-edit:generating:closed:-:placeholder:-',
);
await waitFor(() => {
expect(generateEditorImageMock).toHaveBeenCalledWith(
expect.objectContaining({
prompt: '快速修图',
size: '1024x768',
aspectRatio: '4:3',
imageSize: '1K',
kind: 'quick-edit',
model: 'gemini-3.1-flash-image-preview',
referenceImageSrcs: ['data:image/png;base64,source.png'],
}),
);
});
await waitFor(() => {
expect(screen.getByTestId('layers').textContent).toContain(
'layer-quick-edit-1:源图 快速编辑:resource-source:-',
);
});
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(
'layer-quick-edit-1',
);
expect(screen.getByTestId('fit-count').textContent).toBe('1');
});
it('refreshes the wallet balance after inline image generation succeeds', async () => {
const refreshWalletBalance = vi.fn();
generateEditorImageMock.mockResolvedValueOnce(
createGenerated({ prompt: '快速修图' }),
);
render(
<SubmissionWorkflowHarness
onWalletBalanceMayHaveChanged={refreshWalletBalance}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '打开快速编辑' }));
fireEvent.click(screen.getByRole('button', { name: '填写快速编辑' }));
fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' }));
await waitFor(() => {
expect(generateEditorImageMock).toHaveBeenCalledTimes(1);
});
await waitFor(() => {
expect(refreshWalletBalance).toHaveBeenCalledTimes(1);
});
});
it('places quick edit result with the new image placement logic away from existing assets', 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,
});
generateEditorImageMock.mockResolvedValueOnce(
createGenerated({ prompt: '快速修图', width: 1024, height: 768 }),
);
render(
<SubmissionWorkflowHarness
initialLayers={[createLayer(), blockingLayer]}
/>,
);
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-quick-edit-1:源图 快速编辑:resource-source:-',
);
});
const layerRects = screen.getByTestId('layer-rects').textContent ?? '';
const resultRect = parseLayerRect(layerRects, 'layer-quick-edit-1');
expect(rectOverlapsExpanded(resultRect, blockingLayer, 32)).toBe(false);
});
it('submits quick edits with the numbered red selection image as the final source reference', async () => {
generateEditorImageMock.mockResolvedValueOnce(
createGenerated({
prompt: '对1号红色圈选框里的内容做以下修改:改成蓝色',
}),
);
render(
<SubmissionWorkflowHarness
initialQuickEditPanel={{
mode: 'quick-edit',
sourceLayerId: 'layer-source',
prompt: '对1号红色圈选框里的内容做以下修改:改成蓝色',
size: '1024x768',
model: 'gpt-image-2',
status: 'idle',
quickEditReferences: [
{
id: 'ref-style',
label: '风格参考',
src: 'https://assets.example.test/style.png',
},
],
}}
initialQuickEditSelectionState={{
sourceLayerId: 'layer-source',
tool: 'rect',
marks: [
{
id: 'quick-edit-selection-mark-1',
tool: 'rect',
x: 24,
y: 36,
width: 160,
height: 120,
},
],
draftMark: null,
status: 'idle',
}}
/>,
);
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(generateEditorImageMock).toHaveBeenCalledWith(
expect.objectContaining({
referenceImageSrcs: [
'data:image/png;base64,style.png',
'data:image/png;base64,marked-source',
],
}),
);
});
});
it('keeps failed quick edit panels visible with the normalized prompt', async () => {
const refreshWalletBalance = vi.fn();
generateEditorImageMock.mockRejectedValueOnce(new Error('修图失败'));
render(
<SubmissionWorkflowHarness
onWalletBalanceMayHaveChanged={refreshWalletBalance}
initialQuickEditPanel={{
sourceLayerId: 'layer-source',
prompt: '',
size: '1024x768',
model: 'gpt-image-2',
status: 'idle',
}}
/>,
);
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(
<SubmissionWorkflowHarness
initialLayers={[
createLayer({
assetKind: 'character',
prompt: '原始角色设定',
}),
]}
initialQuickEditPanel={{
mode: 'redraw',
sourceLayerId: 'layer-source',
prompt: ' 角色换成蓝色披风 ',
size: '1024x768',
model: 'gpt-image-2',
status: 'idle',
}}
/>,
);
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 as the hidden last reference', async () => {
generateEditorVideoMock.mockResolvedValueOnce(
createVideoGenerated({ prompt: '把图2改成雨天' }),
);
render(
<SubmissionWorkflowHarness
initialLayers={[
createLayer({
title: '源视频',
src: 'https://assets.example.test/source.mp4',
objectKey: 'generated-character-drafts/editor/videos/source.mp4',
mediaType: 'video',
assetKind: 'video',
model: 'gpt-image-2',
originalWidth: 854,
originalHeight: 480,
width: 427,
height: 240,
}),
]}
initialQuickEditPanel={{
mode: 'quick-edit',
sourceLayerId: 'layer-source',
prompt: '把当前图改成雨天',
size: '854x480',
model: 'gpt-image-2',
status: 'idle',
quickEditReferences: [
{
id: 'ref-style',
label: '风格参考',
src: 'https://assets.example.test/style.png',
},
],
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' }));
await waitFor(() => {
expect(generateEditorVideoMock).toHaveBeenCalledWith(
expect.objectContaining({
prompt: '把图2改成雨天',
model: 'seedance2.0-fast',
aspectRatio: '16:9',
durationSeconds: 4,
resolution: '480p',
mode: 'std',
sound: 'off',
referenceImageSrcs: ['data:image/png;base64,style.png'],
referenceVideoSrcs: [
'generated-character-drafts/editor/videos/source.mp4',
],
}),
);
});
expect(generateEditorImageMock).not.toHaveBeenCalled();
expect(generateEditorCharacterAnimationMock).not.toHaveBeenCalled();
await waitFor(() => {
expect(screen.getByTestId('layers').textContent).toContain(
'layer-video-1:源视频 快速编辑:resource-source:video:video:https://assets.example.test/generated-video.mp4',
);
});
});
it('submits character action quick edits through seedance fast character animation generation', async () => {
generateEditorCharacterAnimationMock.mockResolvedValueOnce({
taskId: 'action-quick-edit-task',
model: 'seedance2.0-fast',
prompt: '让图1跳得更高',
previewVideoPath: '/generated-character-drafts/editor/action-preview.mp4',
frames: [
{
frameIndex: 1,
imageSrc: 'data:image/png;base64,action-frame-edited',
width: 1024,
height: 1024,
},
],
frameCount: 32,
durationSeconds: 4,
fps: 8,
});
render(
<SubmissionWorkflowHarness
initialLayers={[
createLayer({
title: '角色动作',
src: 'generated-character-drafts/editor/actions/frame.png',
mediaType: 'image-sequence',
thumbnailSrc: 'generated-character-drafts/editor/actions/frame.png',
imageSequenceFrames: [
{
frameIndex: 1,
imageSrc: 'generated-character-drafts/editor/actions/frame.png',
width: 1024,
height: 1024,
},
],
previewVideoPath: 'https://assets.example.test/action-preview.mp4',
assetKind: 'character-animation',
model: 'seedance2.0',
originalWidth: 1024,
originalHeight: 1024,
width: 420,
height: 420,
}),
]}
initialQuickEditPanel={{
mode: 'quick-edit',
sourceLayerId: 'layer-source',
prompt: '让当前图跳得更高',
size: '1024x1024',
model: 'gpt-image-2',
status: 'idle',
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' }));
await waitFor(() => {
expect(generateEditorCharacterAnimationMock).toHaveBeenCalledWith(
expect.objectContaining({
sourceLayerId: 'layer-source',
sourceImageSrc: 'generated-character-drafts/editor/actions/frame.png',
promptText: '让图1跳得更高',
resolution: '480p',
ratio: 'same',
frameCount: 32,
durationSeconds: 4,
model: 'seedance2.0-fast',
}),
);
});
expect(generateEditorImageMock).not.toHaveBeenCalled();
expect(generateEditorVideoMock).not.toHaveBeenCalled();
await waitFor(() => {
expect(screen.getByTestId('layers').textContent).toContain(
'layer-character-animation-1:角色动作 快速编辑:resource-source:character-animation:image-sequence:data:image/png;base64,action-frame-edited:data:image/png;base64,action-frame-edited:4',
);
});
});
it('submits edit dialogs beside the source and clears the modal state', async () => {
editEditorImageMock.mockResolvedValueOnce(
createGenerated({ prompt: '修改当前图片' }),
);
render(
<SubmissionWorkflowHarness
initialDialog={{
mode: 'edit',
prompt: '',
status: 'idle',
sourceLayerId: 'layer-source',
}}
/>,
);
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(
<SubmissionWorkflowHarness
initialDialog={{
id: 'dialog-1',
mode: 'generate',
prompt: '占位生成',
status: 'idle',
composerOpen: true,
placeholder: {
x: 100,
y: 100,
width: 420,
height: 420,
originalWidth: 2048,
originalHeight: 2048,
},
}}
/>,
);
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(
<SubmissionWorkflowHarness
projectId="editor-project-1"
applyProjectSnapshot={applyProjectSnapshot}
initialDialog={{
id: 'dialog-character',
mode: 'character',
prompt: ' 银发游侠 ',
status: 'idle',
composerOpen: true,
imageModel: 'gpt-image-2',
placeholder: {
x: 200,
y: 160,
width: 420,
height: 560,
originalWidth: 1024,
originalHeight: 1536,
},
}}
/>,
);
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('resolves uploaded objectKey references before submitting image generation', async () => {
resolveEditorImageReferenceDataUrlForGenerationMock.mockResolvedValueOnce(
'data:image/jpeg;base64,normalized-ref',
);
generateEditorImageMock.mockResolvedValueOnce(
createGenerated({ prompt: '参考图生成' }),
);
render(
<SubmissionWorkflowHarness
initialDialog={{
id: 'dialog-generate',
mode: 'generate',
prompt: '参考图生成',
status: 'idle',
composerOpen: true,
imageModel: 'gpt-image-2',
generationReferences: [
{
id: 'ref-uploaded',
label: '上传参考图',
src: 'https://signed.example.test/ref.png?expires=1',
objectKey: 'generated-character-drafts/editor/refs/ref.png',
assetObjectId: 'assetobj-ref',
},
],
placeholder: {
x: 200,
y: 160,
width: 420,
height: 420,
originalWidth: 1024,
originalHeight: 1024,
},
}}
/>,
);
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 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(
<SubmissionWorkflowHarness
projectId="editor-project-1"
applyProjectSnapshot={applyProjectSnapshot}
onWalletBalanceMayHaveChanged={refreshWalletBalance}
initialDialog={{
id: 'dialog-generate',
mode: 'generate',
prompt: '队列生成',
status: 'idle',
composerOpen: true,
imageModel: 'gpt-image-2',
placeholder: {
x: 200,
y: 160,
width: 420,
height: 420,
originalWidth: 1024,
originalHeight: 1024,
},
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
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 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(
<SubmissionWorkflowHarness
projectId="editor-project-1"
onWalletBalanceMayHaveChanged={refreshWalletBalance}
initialDialog={{
id: 'dialog-generate',
mode: 'generate',
prompt: '队列生成',
status: 'idle',
composerOpen: true,
imageModel: 'gpt-image-2',
placeholder: {
x: 200,
y: 160,
width: 420,
height: 420,
originalWidth: 1024,
originalHeight: 1024,
},
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
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(
<SubmissionWorkflowHarness
projectId="editor-project-1"
initialDialog={{
id: 'dialog-character',
mode: 'character',
prompt: '银发游侠',
status: 'idle',
composerOpen: true,
imageModel: 'gpt-image-2',
placeholder: {
x: 200,
y: 160,
width: 420,
height: 560,
originalWidth: 1024,
originalHeight: 1536,
},
}}
/>,
);
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(
<SubmissionWorkflowHarness
initialDialog={{
id: 'dialog-publication',
mode: 'publication',
prompt: '',
status: 'idle',
composerOpen: true,
publicationWorkflowId: 'publication-detail-gallery',
publicationGameInfo: {
gameName: '云朵消消乐',
gameCategories: '休闲消除',
gameDescription: '点击同类云朵完成消除',
},
publicationReferences: [
{
id: 'ref-1',
label: '主视觉参考',
src: 'data:image/png;base64,large-ref',
},
],
placeholder: {
x: 180,
y: 120,
width: 360,
height: 640,
originalWidth: 720,
originalHeight: 1280,
},
}}
/>,
);
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(
<SubmissionWorkflowHarness
initialDialog={{
id: 'dialog-publication',
mode: 'publication',
prompt: '',
status: 'idle',
composerOpen: true,
publicationWorkflowId: 'publication-cover-image',
publicationGameInfo: {
gameName: '云朵消消乐',
gameCategories: '休闲消除',
gameDescription: '点击同类云朵完成消除',
},
publicationReferences: [
{
id: 'ref-1',
label: '主视觉参考',
src: 'data:image/png;base64,large-original-ref',
},
],
}}
/>,
);
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(
<SubmissionWorkflowHarness
initialDialog={{
id: 'dialog-icon-spec',
mode: 'spec',
prompt: '',
status: 'idle',
composerOpen: true,
specType: 'icon',
specValues: {
playSetting: '背包整理玩法',
artStyle: '清爽卡通',
bodyRatio: '3',
characterView: '',
customPrompt: '',
},
placeholder: {
x: 120,
y: 140,
width: 640,
height: 360,
originalWidth: 2048,
originalHeight: 1152,
},
}}
/>,
);
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(
<SubmissionWorkflowHarness
initialDialog={{
id: 'dialog-icon',
mode: 'icon',
prompt: ' 返回按钮 \n\n设置按钮',
status: 'idle',
composerOpen: true,
iconDescriptions: ['返回按钮'],
}}
/>,
);
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: [
createIconResult('返回按钮', 'data:image/png;base64,back'),
createIconResult('设置按钮', 'data:image/png;base64,setting'),
],
});
render(
<SubmissionWorkflowHarness
initialDialog={{
id: 'dialog-icon',
mode: 'icon',
prompt: ' 返回按钮 \n\n 设置按钮 ',
status: 'idle',
composerOpen: true,
imageModel: 'gpt-image-2',
iconSpecReference: {
id: 'spec',
label: '图标规范',
src: 'data:image/png;base64,spec',
},
iconDescriptions: ['旧返回', '旧设置'],
placeholder: {
x: 140,
y: 160,
width: 360,
height: 360,
originalWidth: 512,
originalHeight: 512,
},
}}
/>,
);
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).toContain(
'layer-icon-2:返回按钮:-:icon',
);
expect(screen.getByTestId('layers').textContent).toContain(
'layer-icon-3:设置按钮:-:icon',
);
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('resolves uploaded objectKey icon spec before submitting spritesheets', async () => {
resolveEditorImageReferenceDataUrlForGenerationMock.mockResolvedValueOnce(
'data:image/jpeg;base64,normalized-icon-spec',
);
generateEditorIconSpritesheetMock.mockResolvedValueOnce({
spritesheetImageSrc: 'data:image/png;base64,sheet',
spritesheetWidth: 512,
spritesheetHeight: 512,
prompt: '图标素材',
actualPrompt: '图标素材',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'task-icons',
iconImageSrcs: [
createIconResult('返回按钮', 'data:image/png;base64,back'),
],
});
render(
<SubmissionWorkflowHarness
initialDialog={{
id: 'dialog-icon',
mode: 'icon',
prompt: '返回按钮',
status: 'idle',
composerOpen: true,
imageModel: 'gpt-image-2',
iconSpecReference: {
id: 'spec',
label: '图标规范',
src: 'https://signed.example.test/icon-spec.png?expires=1',
objectKey: 'generated-character-drafts/editor/specs/icon-spec.png',
assetObjectId: 'assetobj-icon-spec',
},
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
fireEvent.click(screen.getByRole('button', { name: '提交图标' }));
await waitFor(() => {
expect(
resolveEditorImageReferenceDataUrlForGenerationMock,
).toHaveBeenCalledWith(
'generated-character-drafts/editor/specs/icon-spec.png',
);
expect(generateEditorIconSpritesheetMock).toHaveBeenCalledWith(
expect.objectContaining({
referenceImageSrc: 'data:image/jpeg;base64,normalized-icon-spec',
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(
<SubmissionWorkflowHarness
initialLayers={[
createLayer({
assetKind: 'ui-design',
objectKey: 'generated/ui-design.png',
}),
]}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '提取UI素材' }));
await waitFor(() => {
expect(resolveEditorImageReferenceDataUrlMock).toHaveBeenCalledWith(
'generated/ui-design.png',
);
});
await waitFor(() => {
expect(extractEditorUiDesignAssetsMock).toHaveBeenCalledWith(
expect.objectContaining({
sourceImageSrc: 'data:image/png;base64,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(
<SubmissionWorkflowHarness
initialLayers={[
createLayer({
assetKind: 'ui-design',
objectKey: 'generated/ui-design.png',
}),
]}
/>,
);
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(
<SubmissionWorkflowHarness
initialLayers={[
createLayer({
assetKind: 'ui-design',
objectKey: 'generated/ui-design.png',
}),
]}
/>,
);
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',
aspectRatio: '1:1',
imageSize: '2K',
generationInputs: expect.objectContaining({
fields: expect.arrayContaining([
expect.objectContaining({
title: '提取提示词',
value: '仅提取被红色框框选的素材并整理成spritesheet',
}),
]),
}),
}),
);
});
});
it('keeps character generation dialog data after the result is created', async () => {
generateEditorImageMock.mockResolvedValueOnce(
createGenerated({ width: 768, height: 768 }),
);
render(
<SubmissionWorkflowHarness
initialDialog={{
id: 'dialog-character',
mode: 'character',
prompt: '骑士角色',
status: 'idle',
composerOpen: true,
imageModel: 'gpt-image-2',
characterSpecReference: {
id: 'spec-character',
label: '角色规范',
src: 'data:image/png;base64,spec',
},
placeholder: {
x: 120,
y: 140,
width: 420,
height: 420,
originalWidth: 2048,
originalHeight: 2048,
},
}}
/>,
);
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(
<SubmissionWorkflowHarness
initialLayers={[
createLayer({
id: 'character-layer',
assetKind: 'character',
objectKey: 'generated/character.png',
}),
]}
initialCharacterAnimationPanel={{
sourceLayerId: 'character-layer',
promptText: ' 跑步 ',
resolution: '480p',
ratio: 'same',
frameCount: 32,
durationSeconds: 4,
status: 'idle',
}}
/>,
);
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('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(
<SubmissionWorkflowHarness
initialLayers={[
createLayer({
id: 'character-layer',
title: '角色源图',
assetKind: 'character',
objectKey: 'generated/character.png',
}),
]}
initialDialog={{
id: 'dialog-character-animation',
mode: 'character-animation',
sourceLayerId: 'character-layer',
prompt: ' 挥手动作 ',
status: 'idle',
composerOpen: true,
characterAnimationResolution: '480p',
characterAnimationRatio: 'same',
characterAnimationFrameCount: 32,
characterAnimationDurationSeconds: 4,
placeholder: {
x: 80,
y: 60,
width: 420,
height: 420,
originalWidth: 1024,
originalHeight: 1024,
},
}}
initialCharacterAnimationPanel={{
sourceLayerId: 'character-layer',
promptText: ' 挥手动作 ',
resolution: '480p',
ratio: 'same',
frameCount: 32,
durationSeconds: 4,
status: 'idle',
}}
/>,
);
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('uses a fallback error message for failed character animation submissions', async () => {
generateEditorCharacterAnimationMock.mockRejectedValueOnce(new Error(''));
render(
<SubmissionWorkflowHarness
initialLayers={[createLayer({ id: 'character-layer' })]}
initialCharacterAnimationPanel={{
sourceLayerId: 'character-layer',
promptText: '',
resolution: '480p',
ratio: 'same',
frameCount: 32,
durationSeconds: 4,
status: 'idle',
}}
/>,
);
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: '提交角色动画' }));
});
await waitFor(() => {
expect(screen.getByTestId('character-animation').textContent).toBe(
'character-layer:failed:-:生成角色动画失败:-',
);
});
});
});