eb608ac6f2
为画布历史记录补充操作类型和内容消失保护 增加撤销成功、恢复成功及阻止提示并在三秒后隐藏 补充拖动、上传、生成和替换等操作的历史语义 保留恢复按钮、Ctrl+Shift+Z 快捷键和双向历史栈 补充撤销与恢复规则的定向测试和说明文档 --------- Co-authored-by: 段舒康 <kdletters@qq.com> Co-authored-by: kdletters <kdletters@qq.com> Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/89 Co-authored-by: oj-afraid-student <1373241747@qq.com> Co-committed-by: oj-afraid-student <1373241747@qq.com>
3051 lines
99 KiB
TypeScript
3051 lines
99 KiB
TypeScript
/* @vitest-environment jsdom */
|
||
|
||
import {
|
||
act,
|
||
fireEvent,
|
||
render,
|
||
screen,
|
||
waitFor,
|
||
} from '@testing-library/react';
|
||
import { useRef, useState } from 'react';
|
||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||
|
||
import { ApiClientError } from '../../services/apiClient';
|
||
import type {
|
||
CanvasGenerationDialogState,
|
||
CanvasHistoryAction,
|
||
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,
|
||
assetFolderId = projectId ? 'project' : undefined,
|
||
upsertGeneratedAsset = undefined,
|
||
applyProjectSnapshot = undefined,
|
||
onQueuedGenerationTask = undefined,
|
||
onWalletBalanceMayHaveChanged = undefined,
|
||
captureCanvasHistory = () => undefined,
|
||
fitLayers = undefined,
|
||
onAppendCanvasLayers = undefined,
|
||
}: {
|
||
initialDialog?: GenerateDialogState | null;
|
||
initialQuickEditPanel?: QuickEditPanelState | null;
|
||
initialQuickEditSelectionState?: UiAssetExtractionState | null;
|
||
initialCharacterAnimationPanel?: CharacterAnimationPanelState | null;
|
||
initialLayers?: CanvasLayer[];
|
||
projectId?: string | null;
|
||
assetFolderId?: 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'];
|
||
captureCanvasHistory?: (action: CanvasHistoryAction) => void;
|
||
fitLayers?: (
|
||
targetLayers?: CanvasLayer[],
|
||
options?: { captureHistory?: boolean },
|
||
) => void;
|
||
onAppendCanvasLayers?: (nextLayers: CanvasLayer[]) => void;
|
||
}) {
|
||
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 fitLayersRef = useRef(fitLayers);
|
||
fitLayersRef.current = fitLayers;
|
||
const fitLayersMockRef = useRef(
|
||
vi.fn(
|
||
(targetLayers?: CanvasLayer[], options?: { captureHistory?: boolean }) =>
|
||
fitLayersRef.current?.(targetLayers, options),
|
||
),
|
||
);
|
||
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) => {
|
||
onAppendCanvasLayers?.(nextLayers);
|
||
setLayers((currentLayers) => [...currentLayers, ...nextLayers]);
|
||
},
|
||
captureCanvasHistory,
|
||
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,
|
||
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('uploads image references without an object reference before generation', async () => {
|
||
resolveEditorImageReferenceDataUrlMock.mockResolvedValueOnce(
|
||
'data:image/png;base64,ZXhhbXBsZQ==',
|
||
);
|
||
const result = await resolveEditorGenerationMediaReference(
|
||
{ src: '/creation-type-references/example.webp' },
|
||
'image',
|
||
'project-1',
|
||
);
|
||
|
||
expect(resolveEditorImageReferenceDataUrlMock).toHaveBeenCalledWith(
|
||
'/creation-type-references/example.webp',
|
||
);
|
||
expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledWith(
|
||
expect.any(File),
|
||
'image',
|
||
expect.objectContaining({
|
||
assetKind: 'editor_generation_reference_image',
|
||
}),
|
||
);
|
||
expect(result).toBe(
|
||
'generated-character-drafts/editor/generation-references/reference.png',
|
||
);
|
||
});
|
||
|
||
it('submits quick edits and updates the source layer directly', async () => {
|
||
editEditorImageMock.mockResolvedValueOnce(
|
||
createGenerated({
|
||
prompt: '快速修图',
|
||
model: 'gemini-3.1-flash-image-preview',
|
||
}),
|
||
);
|
||
render(
|
||
<SubmissionWorkflowHarness
|
||
initialLayers={[
|
||
createLayer({
|
||
originalWidth: 1537,
|
||
originalHeight: 1025,
|
||
assetKind: 'icon-spritesheet',
|
||
model: 'gemini-3.1-flash-image-preview',
|
||
}),
|
||
]}
|
||
/>,
|
||
);
|
||
|
||
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',
|
||
aspectRatio: '3:2',
|
||
imageSize: '2K',
|
||
}),
|
||
);
|
||
});
|
||
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:icon-spritesheet:-: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: '角色换成蓝色披风',
|
||
warning: {
|
||
code: 'postprocess-failed-source-preserved',
|
||
reason:
|
||
'生成任务成功,后处理失败。',
|
||
},
|
||
}),
|
||
);
|
||
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:角色换成蓝色披风:-',
|
||
);
|
||
expect(screen.getByTestId('generation-warning').textContent).toBe(
|
||
'生成任务成功,后处理失败。',
|
||
);
|
||
});
|
||
|
||
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('captures a local edit fallback when the project response has no snapshot', async () => {
|
||
const applyProjectSnapshot = vi.fn();
|
||
const captureCanvasHistory = vi.fn();
|
||
const fitLayers = vi.fn();
|
||
editEditorImageMock.mockResolvedValueOnce(
|
||
createGenerated({ prompt: '修改当前图片' }),
|
||
);
|
||
render(
|
||
<SubmissionWorkflowHarness
|
||
projectId="project-edit-size"
|
||
applyProjectSnapshot={applyProjectSnapshot}
|
||
captureCanvasHistory={captureCanvasHistory}
|
||
fitLayers={fitLayers}
|
||
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',
|
||
canvasCompletion: expect.objectContaining({
|
||
placeholder: {
|
||
x: 472,
|
||
y: 140,
|
||
width: 1024,
|
||
height: 768,
|
||
originalWidth: 1024,
|
||
originalHeight: 768,
|
||
},
|
||
}),
|
||
}),
|
||
);
|
||
});
|
||
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');
|
||
expect(captureCanvasHistory).toHaveBeenCalledTimes(1);
|
||
expect(captureCanvasHistory).toHaveBeenCalledWith({
|
||
type: 'generate-image',
|
||
count: 1,
|
||
});
|
||
expect(applyProjectSnapshot).not.toHaveBeenCalled();
|
||
expect(fitLayers).toHaveBeenCalledWith(expect.any(Array), {
|
||
captureHistory: false,
|
||
});
|
||
});
|
||
|
||
it('uses the latest moved canvas placeholder for local-only generation fallback', async () => {
|
||
const callOrder: string[] = [];
|
||
const captureCanvasHistory = vi.fn((action: CanvasHistoryAction) => {
|
||
callOrder.push(`capture:${action.type}:${action.count}`);
|
||
});
|
||
const onAppendCanvasLayers = vi.fn(() => {
|
||
callOrder.push('append');
|
||
});
|
||
generateEditorImageMock.mockResolvedValueOnce(
|
||
createGenerated({ width: 512, height: 512 }),
|
||
);
|
||
render(
|
||
<SubmissionWorkflowHarness
|
||
captureCanvasHistory={captureCanvasHistory}
|
||
onAppendCanvasLayers={onAppendCanvasLayers}
|
||
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:-',
|
||
);
|
||
expect(captureCanvasHistory).toHaveBeenCalledWith({
|
||
type: 'generate-image',
|
||
count: 1,
|
||
});
|
||
expect(onAppendCanvasLayers).toHaveBeenCalledTimes(1);
|
||
expect(callOrder).toEqual(['capture:generate-image:1', 'append']);
|
||
});
|
||
|
||
it('passes canvas completion context and refreshes from the backend project snapshot', async () => {
|
||
const applyProjectSnapshot = vi.fn();
|
||
const captureCanvasHistory = 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}
|
||
captureCanvasHistory={captureCanvasHistory}
|
||
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',
|
||
);
|
||
expect(captureCanvasHistory).not.toHaveBeenCalled();
|
||
});
|
||
|
||
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 and shows the warning after a queued character generation completes', 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({
|
||
warning:
|
||
'生成任务成功,后处理失败。',
|
||
}),
|
||
});
|
||
render(
|
||
<SubmissionWorkflowHarness
|
||
projectId="editor-project-1"
|
||
applyProjectSnapshot={applyProjectSnapshot}
|
||
onQueuedGenerationTask={refreshTaskList}
|
||
onWalletBalanceMayHaveChanged={refreshWalletBalance}
|
||
initialDialog={{
|
||
id: 'dialog-character',
|
||
mode: 'character',
|
||
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('generation-warning').textContent).toBe(
|
||
'生成任务成功,后处理失败。',
|
||
);
|
||
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',
|
||
assetKind: '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 宣发素材:-:publication-material:-',
|
||
);
|
||
});
|
||
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 () => {
|
||
const captureCanvasHistory = vi.fn();
|
||
const fitLayers = vi.fn();
|
||
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
|
||
captureCanvasHistory={captureCanvasHistory}
|
||
fitLayers={fitLayers}
|
||
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');
|
||
expect(captureCanvasHistory).toHaveBeenCalledTimes(1);
|
||
expect(captureCanvasHistory).toHaveBeenCalledWith({
|
||
type: 'generate-image',
|
||
count: 2,
|
||
});
|
||
expect(fitLayers).toHaveBeenCalledWith(expect.any(Array), {
|
||
captureHistory: false,
|
||
});
|
||
});
|
||
|
||
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',
|
||
warning: {
|
||
code: 'postprocess-failed-source-preserved',
|
||
reason:
|
||
'生成任务成功,后处理失败。',
|
||
},
|
||
});
|
||
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');
|
||
expect(screen.getByTestId('generation-warning').textContent).toBe(
|
||
'生成任务成功,后处理失败。',
|
||
);
|
||
});
|
||
|
||
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
|
||
projectId="project-ui-extraction-size"
|
||
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',
|
||
canvasCompletion: expect.objectContaining({
|
||
placeholder: {
|
||
x: 472,
|
||
y: 140,
|
||
width: 2048,
|
||
height: 2048,
|
||
originalWidth: 2048,
|
||
originalHeight: 2048,
|
||
},
|
||
}),
|
||
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,
|
||
warning: {
|
||
code: 'postprocess-failed-source-preserved',
|
||
reason:
|
||
'生成任务成功,后处理失败。',
|
||
},
|
||
}),
|
||
);
|
||
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:-',
|
||
);
|
||
expect(screen.getByTestId('generation-warning').textContent).toBe(
|
||
'生成任务成功,后处理失败。',
|
||
);
|
||
});
|
||
|
||
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('uses updated character animation folder and queue callbacks after rerender', async () => {
|
||
const firstQueuedTaskCallback = vi.fn();
|
||
const latestQueuedTaskCallback = vi.fn();
|
||
generateEditorCharacterAnimationMock.mockResolvedValueOnce({
|
||
taskId: 'animation-task-queued',
|
||
queueState: createQueueState({ status: 'queued' }),
|
||
project: {
|
||
id: 'editor-project-1',
|
||
},
|
||
});
|
||
const harnessProps = {
|
||
projectId: 'editor-project-1',
|
||
initialLayers: [
|
||
createLayer({
|
||
id: 'character-layer',
|
||
assetKind: 'character' as const,
|
||
objectKey: 'generated/character.png',
|
||
}),
|
||
],
|
||
initialCharacterAnimationPanel: {
|
||
sourceLayerId: 'character-layer',
|
||
promptText: '跑步',
|
||
resolution: '480p' as const,
|
||
ratio: 'same' as const,
|
||
frameCount: 32 as const,
|
||
durationSeconds: 4 as const,
|
||
status: 'idle' as const,
|
||
},
|
||
};
|
||
const { rerender } = render(
|
||
<SubmissionWorkflowHarness
|
||
{...harnessProps}
|
||
assetFolderId="folder-before"
|
||
onQueuedGenerationTask={firstQueuedTaskCallback}
|
||
/>,
|
||
);
|
||
|
||
rerender(
|
||
<SubmissionWorkflowHarness
|
||
{...harnessProps}
|
||
assetFolderId="folder-latest"
|
||
onQueuedGenerationTask={latestQueuedTaskCallback}
|
||
/>,
|
||
);
|
||
fireEvent.click(screen.getByRole('button', { name: '提交角色动画' }));
|
||
|
||
await waitFor(() => {
|
||
expect(generateEditorCharacterAnimationMock).toHaveBeenCalledWith(
|
||
expect.objectContaining({ assetFolderId: 'folder-latest' }),
|
||
);
|
||
});
|
||
await waitFor(() => {
|
||
expect(latestQueuedTaskCallback).toHaveBeenCalledTimes(1);
|
||
});
|
||
expect(firstQueuedTaskCallback).not.toHaveBeenCalled();
|
||
});
|
||
|
||
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:-:生成角色动画失败:-',
|
||
);
|
||
});
|
||
});
|
||
});
|