Files
Genarrative/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx
T
kdletters 2f092c3566 统一素材任务层级与成本归因
后台素材查询按任务分页并将中间产物逐行收纳为子项
生成原始产物沿用真实素材类型并承载任务生成成本
抠图、拆分和逐帧后处理记录实际生成器且阶段成本归零
补齐历史类型映射、角色动作链路、测试与文档
2026-07-15 03:04:41 +08:00

2833 lines
92 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 {
applyQueuedEditorGenerationProject,
resolveEditorGenerationMediaReference,
useImageCanvasGenerationSubmissionWorkflow,
} from './useImageCanvasGenerationSubmissionWorkflow';
const resolveEditorImageReferenceDataUrlMock = vi.hoisted(() => vi.fn());
const resolveEditorImageReferenceDataUrlForGenerationMock = vi.hoisted(() =>
vi.fn(),
);
const uploadEditorMediaAssetFileMock = 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/image-editor/editorMediaAssetUploadClient', () => ({
uploadEditorMediaAssetFile: uploadEditorMediaAssetFileMock,
}));
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;
warning: 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 [generationWarning, setGenerationWarning] = useState<string | null>(
null,
);
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,
onGenerationWarning: setGenerationWarning,
});
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>
<span data-testid="generation-warning">{generationWarning ?? '-'}</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();
uploadEditorMediaAssetFileMock.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,
);
uploadEditorMediaAssetFileMock.mockResolvedValue({
src: 'https://signed.example.test/generation-reference.png',
objectKey:
'generated-character-drafts/editor/generation-references/reference.png',
assetObjectId: 'asset-object-generation-reference',
legacyPublicPath:
'/generated-character-drafts/editor/generation-references/reference.png',
});
renderUiDesignAssetExtractionMarkedImageMock.mockResolvedValue(
'data:image/png;base64,bWFya2VkLXVpLWRlc2lnbg==',
);
});
it('uses distinct upload paths for concurrent inline references in the same millisecond', async () => {
const dateNowSpy = vi.spyOn(Date, 'now').mockReturnValue(1_752_144_000_000);
try {
await Promise.all([
resolveEditorGenerationMediaReference(
{ src: 'data:image/png;base64,YQ==' },
'image',
'project-1',
),
resolveEditorGenerationMediaReference(
{ src: 'data:image/png;base64,Yg==' },
'image',
'project-1',
),
]);
} finally {
dateNowSpy.mockRestore();
}
expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledTimes(2);
const [firstFile, , firstOptions] =
uploadEditorMediaAssetFileMock.mock.calls[0] ?? [];
const [secondFile, , secondOptions] =
uploadEditorMediaAssetFileMock.mock.calls[1] ?? [];
expect((firstFile as File).name).not.toBe((secondFile as File).name);
expect(firstOptions.pathSegments).not.toEqual(secondOptions.pathSegments);
});
it('submits quick edits and updates the source layer directly', async () => {
editEditorImageMock.mockResolvedValueOnce(
createGenerated({ prompt: '快速修图' }),
);
render(
<SubmissionWorkflowHarness
initialLayers={[
createLayer({ originalWidth: 1537, originalHeight: 1025 }),
]}
/>,
);
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: 'resource-source',
size: '2048x1365',
model: 'gemini-3.1-flash-image-preview',
}),
);
});
expect(editEditorImageMock.mock.calls[0]?.[0]).not.toHaveProperty(
'canvasCompletion',
);
expect(generateEditorImageMock).not.toHaveBeenCalled();
expect(resolveEditorImageReferenceDataUrlMock).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('uses one custom name for quick-edit requests and local layer results', async () => {
editEditorImageMock.mockResolvedValueOnce(
createGenerated({ prompt: '改成雨天' }),
);
render(
<SubmissionWorkflowHarness
initialQuickEditPanel={{
mode: 'quick-edit',
sourceLayerId: 'layer-source',
prompt: '改成雨天',
assetLabel: ' 雨天勇者 ',
size: '1024x768',
model: 'gpt-image-2',
status: 'idle',
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' }));
await waitFor(() => {
expect(editEditorImageMock).toHaveBeenCalledWith(
expect.objectContaining({ assetLabel: '雨天勇者' }),
);
expect(screen.getByTestId('layers').textContent).toContain(
'layer-source:雨天勇者:resource-source',
);
});
});
it('rejects direct quick edit submission for icon assets', async () => {
render(
<SubmissionWorkflowHarness
initialLayers={[createLayer({ assetKind: 'icon' })]}
initialQuickEditPanel={{
mode: 'quick-edit',
sourceLayerId: 'layer-source',
prompt: '修改图标',
size: '1024x1024',
aspectRatio: '1:1',
imageSize: '1K',
model: 'gpt-image-2',
status: 'idle',
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' }));
expect(editEditorImageMock).not.toHaveBeenCalled();
expect(screen.getByTestId('quick-edit').textContent).toContain(
'failed:修改图标:图标类素材不支持快速编辑',
);
});
it('allows direct quick edit submission for icon spritesheets', async () => {
editEditorImageMock.mockResolvedValueOnce(
createGenerated({ prompt: '调整图集布局' }),
);
render(
<SubmissionWorkflowHarness
initialLayers={[
createLayer({ assetKind: 'icon-spritesheet' }),
]}
initialQuickEditPanel={{
mode: 'quick-edit',
sourceLayerId: 'layer-source',
prompt: '调整图集布局',
size: '1024x1024',
aspectRatio: '1:1',
imageSize: '1K',
model: 'gpt-image-2',
status: 'idle',
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' }));
await waitFor(() => {
expect(editEditorImageMock).toHaveBeenCalledWith(
expect.objectContaining({ prompt: '调整图集布局' }),
);
});
});
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: 'resource-source',
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,bWFya2VkLXNvdXJjZQ==',
);
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(uploadEditorMediaAssetFileMock).toHaveBeenCalledWith(
expect.any(File),
'image',
expect.objectContaining({
assetKind: 'editor_generation_reference_image',
}),
);
expect(editEditorImageMock).toHaveBeenCalledWith(
expect.objectContaining({
sourceImageSrc:
'generated-character-drafts/editor/generation-references/reference.png',
targetLayerId: 'layer-source',
}),
);
});
expect(
uploadEditorMediaAssetFileMock.mock.invocationCallOrder[0],
).toBeLessThan(editEditorImageMock.mock.invocationCallOrder[0] ?? 0);
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,bWFya2VkLXNvdXJjZQ==',
);
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:
'generated-character-drafts/editor/generation-references/reference.png',
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
projectId="editor-project-1"
initialLayers={[
createLayer({
assetKind: 'character',
prompt: '原始角色设定',
}),
]}
initialQuickEditPanel={{
mode: 'redraw',
sourceLayerId: 'layer-source',
prompt: ' 角色换成蓝色披风 ',
assetLabel: ' 蓝披风勇者 ',
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: ['resource-source'],
assetKind: 'character',
assetLabel: '蓝披风勇者',
canvasCompletion: expect.objectContaining({
title: '蓝披风勇者',
}),
}),
);
});
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: 'data:video/mp4;base64,c291cmNlLXZpZGVv',
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/generation-references/reference.png',
],
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('uploads inline video references and keeps image objectKey and asset URLs', async () => {
generateEditorVideoMock.mockResolvedValueOnce(createVideoGenerated());
render(
<SubmissionWorkflowHarness
initialDialog={{
id: 'dialog-video-references',
mode: 'video',
prompt: '让画面动起来',
status: 'idle',
composerOpen: true,
videoModel: 'seedance2.0-fast',
generationReferences: [
{
id: 'image-reference',
label: '图片参考',
src: 'data:image/png;base64,aW1hZ2U=',
objectKey: 'generated-character-drafts/editor/refs/image.png',
},
{
id: 'video-reference',
label: '视频参考',
src: 'data:video/mp4;base64,dmlkZW8=',
mediaType: 'video',
},
{
id: 'audio-reference',
label: '音频参考',
src: 'asset://asset-audio-reference',
mediaType: 'audio',
},
],
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
await waitFor(() => {
expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledWith(
expect.any(File),
'video',
expect.objectContaining({
assetKind: 'editor_generation_reference_video',
}),
);
expect(generateEditorVideoMock).toHaveBeenCalledWith(
expect.objectContaining({
referenceImageSrcs: [
'generated-character-drafts/editor/refs/image.png',
],
referenceVideoSrcs: [
'generated-character-drafts/editor/generation-references/reference.png',
],
referenceAudioSrcs: ['asset://asset-audio-reference'],
}),
);
});
});
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: 'resource-source',
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: 'resource-source',
}),
);
});
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',
},
{
layerId: 'layer-editor-resource-character-source',
resourceId: 'editor-resource-character-source',
},
],
resources: [
{
resourceId: 'editor-resource-character',
projectId: 'editor-project-1',
imageSrc: '/generated-character-drafts/editor/character.png',
width: 1024,
height: 1024,
sourceType: 'generated',
assetKind: 'character',
},
{
resourceId: 'editor-resource-character-source',
projectId: 'editor-project-1',
imageSrc:
'/generated-character-drafts/editor/character-green-screen-source.png',
width: 1024,
height: 1024,
sourceType: 'generated',
assetKind: 'character',
},
],
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: '角色项目',
layers: expect.arrayContaining([
expect.objectContaining({
resourceId: 'editor-resource-character',
}),
expect.objectContaining({
resourceId: 'editor-resource-character-source',
}),
]),
resources: expect.arrayContaining([
expect.objectContaining({
resourceId: 'editor-resource-character',
assetKind: 'character',
}),
expect.objectContaining({
resourceId: 'editor-resource-character-source',
assetKind: 'character',
}),
]),
}),
);
});
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('submits uploaded objectKey references without downloading or inlining them', async () => {
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(generateEditorImageMock).toHaveBeenCalledWith(
expect.objectContaining({
prompt: '参考图生成',
referenceImageSrcs: [
'generated-character-drafts/editor/refs/ref.png',
],
}),
);
});
expect(resolveEditorImageReferenceDataUrlMock).not.toHaveBeenCalled();
expect(uploadEditorMediaAssetFileMock).not.toHaveBeenCalled();
});
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('reloads a queued project snapshot again when the completion dialog is still unresolved', async () => {
vi.useFakeTimers();
try {
const applyProjectSnapshot = vi.fn();
const refreshTaskList = vi.fn();
const refreshWalletBalance = vi.fn();
const unresolvedProject = {
projectId: 'editor-project-1',
title: '队列项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
layerId: 'generation-dialog-background-removal',
resourceId: 'generation-dialog-background-removal',
itemType: 'generation-dialog',
dialog: {
id: 'dialog-background-removal',
status: 'generating',
},
},
],
resources: [],
updatedAt: '2026-06-23T08:00:00.000Z',
};
const completedProject = {
...unresolvedProject,
layers: [
{
layerId: 'generation-dialog-background-removal',
resourceId: 'generation-dialog-background-removal',
itemType: 'generation-dialog',
dialog: {
id: 'dialog-background-removal',
status: 'idle',
generatedLayerId: 'layer-background-removal-result',
},
},
],
updatedAt: '2026-06-23T08:00:01.000Z',
};
loadEditorProjectMock
.mockResolvedValueOnce(unresolvedProject)
.mockResolvedValueOnce(completedProject);
const applyPromise = applyQueuedEditorGenerationProject(
{ queueState: createQueueState() },
'editor-project-1',
applyProjectSnapshot,
refreshTaskList,
refreshWalletBalance,
undefined,
'dialog-background-removal',
);
await Promise.resolve();
await Promise.resolve();
expect(refreshTaskList).toHaveBeenCalledTimes(1);
expect(refreshWalletBalance).toHaveBeenCalledTimes(1);
expect(loadEditorProjectMock).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(650);
await applyPromise;
expect(loadEditorProjectMock).toHaveBeenCalledTimes(2);
expect(applyProjectSnapshot).toHaveBeenNthCalledWith(
1,
unresolvedProject,
);
expect(applyProjectSnapshot).toHaveBeenNthCalledWith(2, completedProject);
} finally {
vi.useRealTimers();
}
});
it('reports a non-blocking warning after a queued generation completes', async () => {
const onGenerationWarning = vi.fn();
loadEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-1',
title: '队列图集项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
resources: [],
updatedAt: '2026-06-23T08:00:00.000Z',
});
await applyQueuedEditorGenerationProject(
{
queueState: createQueueState({
warning: '连通域数量不足',
}),
},
'editor-project-1',
vi.fn(),
vi.fn(),
vi.fn(),
onGenerationWarning,
);
expect(onGenerationWarning).toHaveBeenCalledWith(
'图集已生成,但自动拆分未完成:连通域数量不足',
);
});
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,bGFyZ2UtcmVm',
},
],
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: [
'generated-character-drafts/editor/generation-references/reference.png',
],
}),
);
});
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('submits publication resource ids without downloading or inlining them', async () => {
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',
resourceId: 'resource-publication-ref',
},
],
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
await waitFor(() => {
expect(generateEditorImageMock).toHaveBeenCalledWith(
expect.objectContaining({
kind: 'publication-material',
aspectRatio: '4:3',
imageSize: '1K',
referenceImageSrcs: ['resource-publication-ref'],
}),
);
});
expect(resolveEditorImageReferenceDataUrlMock).not.toHaveBeenCalled();
expect(uploadEditorMediaAssetFileMock).not.toHaveBeenCalled();
});
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: [],
sliceWarning: {
code: 'insufficient-connected-components',
reason: '连通域数量不足',
},
});
render(
<SubmissionWorkflowHarness
projectId="editor-project-1"
initialDialog={{
id: 'dialog-icon',
mode: 'icon',
prompt: ' 返回按钮 \n\n 设置按钮 ',
assetLabel: ' 复古操作图标 ',
status: 'idle',
composerOpen: true,
imageModel: 'gpt-image-2',
iconSpecReference: {
id: 'spec',
label: '图标规范',
src: 'data:image/png;base64,c3BlYw==',
},
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:
'generated-character-drafts/editor/generation-references/reference.png',
iconDescriptions: ['返回按钮', '设置按钮'],
model: 'gpt-image-2',
assetLabel: '复古操作图标',
canvasCompletion: expect.objectContaining({
title: '复古操作图标',
}),
}),
);
});
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('dialog').textContent).toBe(
'icon:idle:open:layer-icon-spritesheet-1:placeholder:-',
);
expect(screen.getByTestId('remembered-model').textContent).toBe(
'gpt-image-2',
);
expect(screen.getByTestId('generation-warning').textContent).toBe(
'图集已生成,但自动拆分未完成:连通域数量不足',
);
});
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 () => {
renderUiDesignAssetExtractionMarkedImageMock.mockResolvedValueOnce(
'data:image/png;base64,bWFya2VkLXVpLWRlc2lnbg==',
);
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:
'generated-character-drafts/editor/generation-references/reference.png',
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
projectId="editor-project-1"
initialLayers={[
createLayer({
id: 'character-layer',
assetKind: 'character',
src: 'data:image/png;base64,Y2hhcmFjdGVy',
}),
]}
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-drafts/editor/generation-references/reference.png',
promptText: '跑步',
model: 'seedance2.0-fast',
assetFolderId: 'project',
}),
);
});
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
projectId="editor-project-1"
initialLayers={[
createLayer({
id: 'character-layer',
title: '角色源图',
assetKind: 'character',
objectKey: 'generated/character.png',
}),
]}
initialDialog={{
id: 'dialog-character-animation',
mode: 'character-animation',
sourceLayerId: 'character-layer',
prompt: ' 挥手动作 ',
assetLabel: ' 勇者挥手 ',
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: ' 挥手动作 ',
assetLabel: ' 勇者挥手 ',
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',
assetLabel: '勇者挥手',
canvasCompletion: expect.objectContaining({
title: '勇者挥手',
}),
}),
);
});
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',
objectKey: 'generated/character.png',
}),
]}
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',
objectKey: 'generated/character.png',
}),
]}
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:-:生成角色动画失败:-',
);
});
});
});