Files
Genarrative/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx
T
lhk229 308d440816 新抠图算法(轻量版)上线 (#73)
新抠图算法(轻量版)上线,生图背景色同步改为多颜色+自动决策

---------

Co-authored-by: kdletters <kdletters@qq.com>
Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/73
Reviewed-by: kdletters <kdletters@qq.com>
Co-authored-by: Linghong <ink29535@proton.me>
Co-committed-by: Linghong <ink29535@proton.me>
2026-07-07 14:57:10 +08:00

2426 lines
79 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* @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> = {}): 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<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]),
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 (
<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={() => {
if (sourceLayer) {
void workflow.extractUiDesignAssets(sourceLayer, {
marks: [
{
id: 'mark-ref',
tool: 'rect',
x: 10,
y: 20,
width: 80,
height: 60,
},
],
references: [
{
id: 'ref-a',
label: '风格参考',
src: '/generated/ref-fallback.png',
objectKey: 'generated/ref-style.png',
resourceId: 'resource-style-ref',
},
],
});
}
}}
>
提取带参考图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 and updates the source layer directly', async () => {
editEditorImageMock.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(
'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(
<SubmissionWorkflowHarness
projectId="editor-project-1"
applyProjectSnapshot={applyProjectSnapshot}
onQueuedGenerationTask={refreshTaskList}
/>,
);
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(
<SubmissionWorkflowHarness
initialDialog={{
id: 'generation-dialog-quick-edit',
mode: 'quick-edit',
prompt: '把当前图改成雨天',
status: 'idle',
composerOpen: true,
sourceLayerId: 'layer-source',
imageModel: 'gpt-image-2',
aspectRatio: '4:3',
imageSize: '1K',
placeholder: {
x: 472,
y: 140,
width: 1024,
height: 768,
originalWidth: 1024,
originalHeight: 768,
},
}}
/>,
);
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(
<SubmissionWorkflowHarness
onWalletBalanceMayHaveChanged={refreshWalletBalance}
/>,
);
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(
<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-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(
<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',
model: 'gemini-3.1-flash-image-preview',
marks: [
{
id: 'quick-edit-selection-mark-1',
tool: 'rect',
x: 24,
y: 36,
width: 160,
height: 120,
},
],
references: [],
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(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(
<SubmissionWorkflowHarness
initialDialog={{
id: 'generation-dialog-quick-edit-marked',
mode: 'quick-edit',
prompt: '对1号红色圈选框里的内容做以下修改:改成蓝色',
status: 'idle',
composerOpen: true,
sourceLayerId: 'layer-source',
imageModel: 'gpt-image-2',
aspectRatio: '4:3',
imageSize: '1K',
placeholder: {
x: 472,
y: 140,
width: 1024,
height: 768,
originalWidth: 1024,
originalHeight: 768,
},
}}
initialQuickEditSelectionState={{
sourceLayerId: 'layer-source',
tool: 'rect',
model: 'gemini-3.1-flash-image-preview',
marks: [
{
id: 'quick-edit-selection-mark-1',
tool: 'rect',
x: 24,
y: 36,
width: 160,
height: 120,
},
],
references: [],
draftMark: null,
status: 'idle',
}}
/>,
);
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(
<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', 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(
<SubmissionWorkflowHarness
projectId="editor-project-1"
applyProjectSnapshot={applyProjectSnapshot}
upsertGeneratedAsset={upsertGeneratedAsset}
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: '把当前图改成雨天',
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(
<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(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(
<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('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(
<SubmissionWorkflowHarness
projectId="editor-project-audio"
applyProjectSnapshot={applyProjectSnapshot}
upsertGeneratedAsset={upsertGeneratedAsset}
initialDialog={{
id: 'dialog-sound',
mode: 'audio-sound-effect',
prompt: ' 金币掉落叮当声 ',
status: 'idle',
composerOpen: true,
soundDurationSeconds: 7,
placeholder: {
x: 200,
y: 160,
width: 420,
height: 120,
originalWidth: 420,
originalHeight: 120,
},
}}
/>,
);
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(
<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 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(
<SubmissionWorkflowHarness
projectId="editor-project-1"
applyProjectSnapshot={applyProjectSnapshot}
onQueuedGenerationTask={refreshTaskList}
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(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(
<SubmissionWorkflowHarness
projectId="editor-project-1"
onQueuedGenerationTask={refreshTaskList}
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();
});
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(
<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: [],
});
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).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(
<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',
},
generationReferences: [
{
id: 'ref-style',
label: '风格参考',
src: 'https://signed.example.test/icon-style.png?expires=1',
objectKey: 'generated-character-drafts/editor/refs/icon-style.png',
assetObjectId: 'assetobj-icon-style',
},
],
}}
/>,
);
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(
<SubmissionWorkflowHarness
initialLayers={[
createLayer({
assetKind: 'ui-design',
objectKey: 'generated/ui-design.png',
}),
]}
/>,
);
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(
<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',
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(
<SubmissionWorkflowHarness
initialLayers={[
createLayer({
assetKind: 'ui-design',
objectKey: 'generated/ui-design.png',
}),
]}
/>,
);
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(
<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('does not resubmit character animation while it is already generating', () => {
render(
<SubmissionWorkflowHarness
initialLayers={[
createLayer({
id: 'character-layer',
assetKind: 'character',
}),
]}
initialCharacterAnimationPanel={{
sourceLayerId: 'character-layer',
promptText: '跑步',
resolution: '480p',
ratio: 'same',
frameCount: 32,
durationSeconds: 4,
status: 'generating',
}}
/>,
);
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(
<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('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(
<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:跑步:泥点余额不足,请充值后再生成:-',
);
});
});
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:-:生成角色动画失败:-',
);
});
});
});