73a17193d4
将 external_generation_job 扩展为含价格、确认时间、退款流水和事件审计的正式生成任务事实源 新增外部生成任务列表与确认 BFF,并让平台入口按后端任务补弹完成或失败提示 保护画布生成队列超时、快照回写和自动保存覆盖场景 将 worker 计费退款流水写入 externalGenerationJobId,并补充冷备份后 worker/controller 恢复守护
1969 lines
63 KiB
TypeScript
1969 lines
63 KiB
TypeScript
/* @vitest-environment jsdom */
|
|
|
|
import {
|
|
act,
|
|
fireEvent,
|
|
render,
|
|
screen,
|
|
waitFor,
|
|
} from '@testing-library/react';
|
|
import { useRef, useState } from 'react';
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import type {
|
|
CanvasGenerationDialogState,
|
|
CanvasLayer,
|
|
CanvasTool,
|
|
CharacterAnimationPanelState,
|
|
GenerateDialogState,
|
|
QuickEditPanelState,
|
|
SidebarPanel,
|
|
} from './ImageCanvasEditorTypes';
|
|
import { createQuickEditPanelDraft } from './ImageCanvasGenerationDialogModel';
|
|
import type { UiAssetExtractionState } from './ImageCanvasUiAssetExtractionModel';
|
|
import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs';
|
|
import { useImageCanvasGenerationSubmissionWorkflow } from './useImageCanvasGenerationSubmissionWorkflow';
|
|
|
|
const resolveEditorImageReferenceDataUrlMock = vi.hoisted(() => vi.fn());
|
|
const resolveEditorImageReferenceDataUrlForGenerationMock = vi.hoisted(() =>
|
|
vi.fn(),
|
|
);
|
|
const editEditorImageMock = vi.hoisted(() => vi.fn());
|
|
const extractEditorUiDesignAssetsMock = vi.hoisted(() => vi.fn());
|
|
const renderUiDesignAssetExtractionMarkedImageMock = vi.hoisted(() => vi.fn());
|
|
const generateEditorCharacterAnimationMock = vi.hoisted(() => vi.fn());
|
|
const generateEditorIconSpritesheetMock = vi.hoisted(() => vi.fn());
|
|
const generateEditorImageMock = vi.hoisted(() => vi.fn());
|
|
const generateEditorVideoMock = vi.hoisted(() => vi.fn());
|
|
const generateEditorSoundEffectMock = vi.hoisted(() => vi.fn());
|
|
const generateEditorBackgroundMusicMock = vi.hoisted(() => vi.fn());
|
|
const loadEditorProjectMock = vi.hoisted(() => vi.fn());
|
|
const getExternalGenerationJobStatusMock = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock('../../services/image-editor/editorImageReference', () => ({
|
|
resolveEditorImageReferenceDataUrl: resolveEditorImageReferenceDataUrlMock,
|
|
resolveEditorImageReferenceDataUrlForGeneration:
|
|
resolveEditorImageReferenceDataUrlForGenerationMock,
|
|
}));
|
|
|
|
vi.mock('../../services/image-editor/editorProjectClient', async () => {
|
|
const actual = await vi.importActual<
|
|
typeof import('../../services/image-editor/editorProjectClient')
|
|
>('../../services/image-editor/editorProjectClient');
|
|
return {
|
|
...actual,
|
|
editEditorImage: editEditorImageMock,
|
|
extractEditorUiDesignAssets: extractEditorUiDesignAssetsMock,
|
|
generateEditorCharacterAnimation: generateEditorCharacterAnimationMock,
|
|
generateEditorIconSpritesheet: generateEditorIconSpritesheetMock,
|
|
generateEditorImage: generateEditorImageMock,
|
|
generateEditorVideo: generateEditorVideoMock,
|
|
generateEditorSoundEffect: generateEditorSoundEffectMock,
|
|
generateEditorBackgroundMusic: generateEditorBackgroundMusicMock,
|
|
loadEditorProject: loadEditorProjectMock,
|
|
};
|
|
});
|
|
|
|
vi.mock('../../services/external-generation', () => ({
|
|
getExternalGenerationJobStatus: getExternalGenerationJobStatusMock,
|
|
}));
|
|
|
|
vi.mock('./ImageCanvasUiAssetExtractionRasterModel', () => ({
|
|
renderUiDesignAssetExtractionMarkedImage:
|
|
renderUiDesignAssetExtractionMarkedImageMock,
|
|
}));
|
|
|
|
function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
|
|
return {
|
|
id: 'layer-source',
|
|
resourceId: 'resource-source',
|
|
title: '源图',
|
|
src: 'https://assets.example.test/source.png',
|
|
x: 120,
|
|
y: 140,
|
|
width: 320,
|
|
height: 240,
|
|
originalWidth: 1024,
|
|
originalHeight: 768,
|
|
zIndex: 2,
|
|
sourceType: 'uploaded',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function createGenerated(overrides = {}) {
|
|
return {
|
|
imageSrc: 'data:image/png;base64,generated',
|
|
width: 1024,
|
|
height: 1024,
|
|
sourceType: 'generated' as const,
|
|
prompt: '生成提示词',
|
|
actualPrompt: '生成提示词',
|
|
model: 'gpt-image-2',
|
|
provider: 'VectorEngine',
|
|
taskId: 'task-generated',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function createVideoGenerated(overrides = {}) {
|
|
return {
|
|
videoSrc: 'https://assets.example.test/generated-video.mp4',
|
|
width: 854,
|
|
height: 480,
|
|
sourceType: 'generated' as const,
|
|
prompt: '视频提示词',
|
|
actualPrompt: '视频提示词',
|
|
model: 'seedance2.0-fast',
|
|
provider: 'ark',
|
|
taskId: 'task-video-generated',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function createIconResult(name: string, imageSrc: string) {
|
|
return {
|
|
name,
|
|
imageSrc,
|
|
width: 128,
|
|
height: 128,
|
|
};
|
|
}
|
|
|
|
function createQueueState(
|
|
overrides: Partial<{
|
|
operationId: string;
|
|
status: 'queued' | 'running' | 'completed' | 'failed';
|
|
phaseLabel: string;
|
|
phaseDetail: string;
|
|
progress: number;
|
|
error: string | null;
|
|
updatedAtMicros: number;
|
|
}> = {},
|
|
) {
|
|
return {
|
|
operationId: 'editor-generation-operation-1',
|
|
status: 'completed' as const,
|
|
phaseLabel: '完成',
|
|
phaseDetail: '',
|
|
progress: 1,
|
|
error: null,
|
|
updatedAtMicros: 1,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function parseLayerRect(text: string, layerId: string) {
|
|
const entry = text
|
|
.split('|')
|
|
.find((currentEntry) => currentEntry.startsWith(`${layerId}:`));
|
|
if (!entry) {
|
|
throw new Error(`missing layer rect for ${layerId}`);
|
|
}
|
|
const [, x, y, width, height] = entry.split(':');
|
|
return {
|
|
x: Number(x),
|
|
y: Number(y),
|
|
width: Number(width),
|
|
height: Number(height),
|
|
};
|
|
}
|
|
|
|
function rectOverlapsExpanded(
|
|
rect: { x: number; y: number; width: number; height: number },
|
|
blocker: { x: number; y: number; width: number; height: number },
|
|
gap: number,
|
|
) {
|
|
return (
|
|
rect.x < blocker.x + blocker.width + gap &&
|
|
rect.x + rect.width > blocker.x - gap &&
|
|
rect.y < blocker.y + blocker.height + gap &&
|
|
rect.y + rect.height > blocker.y - gap
|
|
);
|
|
}
|
|
|
|
function SubmissionWorkflowHarness({
|
|
initialDialog = null,
|
|
initialQuickEditPanel = null,
|
|
initialQuickEditSelectionState = null,
|
|
initialCharacterAnimationPanel = null,
|
|
initialLayers = [createLayer()],
|
|
projectId = undefined,
|
|
applyProjectSnapshot = undefined,
|
|
onWalletBalanceMayHaveChanged = undefined,
|
|
}: {
|
|
initialDialog?: GenerateDialogState | null;
|
|
initialQuickEditPanel?: QuickEditPanelState | null;
|
|
initialQuickEditSelectionState?: UiAssetExtractionState | null;
|
|
initialCharacterAnimationPanel?: CharacterAnimationPanelState | null;
|
|
initialLayers?: CanvasLayer[];
|
|
projectId?: string | null;
|
|
applyProjectSnapshot?: Parameters<
|
|
typeof useImageCanvasGenerationSubmissionWorkflow
|
|
>[0]['applyProjectSnapshot'];
|
|
onWalletBalanceMayHaveChanged?: Parameters<
|
|
typeof useImageCanvasGenerationSubmissionWorkflow
|
|
>[0]['onWalletBalanceMayHaveChanged'];
|
|
}) {
|
|
const [layers, setLayers] = useState<CanvasLayer[]>(initialLayers);
|
|
const [activeTool, setActiveTool] = useState<CanvasTool>('generate');
|
|
const [activeSidebarPanel, setActiveSidebarPanel] =
|
|
useState<SidebarPanel | null>('assets');
|
|
const [selectedLayerId, setSelectedLayerId] = useState<string | null>(null);
|
|
const [quickEditPanel, setQuickEditPanel] =
|
|
useState<QuickEditPanelState | null>(initialQuickEditPanel);
|
|
const [characterAnimationPanel, setCharacterAnimationPanel] =
|
|
useState<CharacterAnimationPanelState | null>(
|
|
initialCharacterAnimationPanel,
|
|
);
|
|
const rememberedImageModelRef = useRef<string | null>(null);
|
|
const fitLayersMockRef = useRef(vi.fn());
|
|
const layerCounterRef = useRef(0);
|
|
const dialogs = useCanvasGenerationDialogs();
|
|
|
|
const sourceLayer = layers[0] ?? null;
|
|
const quickEditSourceLayer = quickEditPanel
|
|
? (layers.find((layer) => layer.id === quickEditPanel.sourceLayerId) ??
|
|
null)
|
|
: null;
|
|
const characterAnimationSourceLayer = characterAnimationPanel
|
|
? (layers.find(
|
|
(layer) => layer.id === characterAnimationPanel.sourceLayerId,
|
|
) ?? null)
|
|
: null;
|
|
const activeDialog = dialogs.generateDialog;
|
|
const activeCanvasDialog =
|
|
activeDialog && 'id' in activeDialog
|
|
? (activeDialog as CanvasGenerationDialogState)
|
|
: null;
|
|
|
|
const workflow = useImageCanvasGenerationSubmissionWorkflow({
|
|
layers,
|
|
canvasSize: { width: 900, height: 640 },
|
|
viewport: { x: 10, y: 20, scale: 2 },
|
|
layerCounterRef,
|
|
quickEditPanel,
|
|
quickEditSourceLayer,
|
|
quickEditSelectionState: initialQuickEditSelectionState,
|
|
canvasGenerationDialogs: dialogs.canvasGenerationDialogs,
|
|
setQuickEditPanel,
|
|
characterAnimationPanel,
|
|
characterAnimationDialog: activeCanvasDialog,
|
|
characterAnimationSourceLayer,
|
|
setCharacterAnimationPanel,
|
|
setGenerateDialog: dialogs.setGenerateDialog,
|
|
openCanvasGenerationDialog: dialogs.openCanvasGenerationDialog,
|
|
updateCanvasGenerationDialogById: dialogs.updateCanvasGenerationDialogById,
|
|
hasCanvasGenerationDialogById: dialogs.hasCanvasGenerationDialogById,
|
|
getGeneratingDialogPlaceholder: dialogs.getGeneratingDialogPlaceholder,
|
|
appendCanvasLayersWithResources: (nextLayers) =>
|
|
setLayers((currentLayers) => [...currentLayers, ...nextLayers]),
|
|
selectSingleLayer: setSelectedLayerId,
|
|
fitLayers: fitLayersMockRef.current,
|
|
setActiveTool,
|
|
setActiveSidebarPanel,
|
|
rememberImageModel: (imageModel) => {
|
|
rememberedImageModelRef.current = imageModel;
|
|
},
|
|
projectId,
|
|
assetFolderId: projectId ? 'project' : undefined,
|
|
applyProjectSnapshot,
|
|
onWalletBalanceMayHaveChanged,
|
|
});
|
|
|
|
return (
|
|
<div>
|
|
<span data-testid="tool">{activeTool}</span>
|
|
<span data-testid="sidebar">{activeSidebarPanel ?? '-'}</span>
|
|
<span data-testid="selected">{selectedLayerId ?? '-'}</span>
|
|
<span data-testid="layers">
|
|
{layers
|
|
.map(
|
|
(layer) =>
|
|
`${layer.id}:${layer.title}:${layer.sourceResourceId ?? '-'}:${layer.assetKind ?? '-'}:${layer.mediaType ?? '-'}:${layer.src}:${layer.thumbnailSrc ?? '-'}:${layer.durationSeconds ?? '-'}`,
|
|
)
|
|
.join('|')}
|
|
</span>
|
|
<span data-testid="layer-rects">
|
|
{layers
|
|
.map(
|
|
(layer) =>
|
|
`${layer.id}:${layer.x}:${layer.y}:${layer.width}:${layer.height}`,
|
|
)
|
|
.join('|')}
|
|
</span>
|
|
<span data-testid="dialog">
|
|
{activeDialog
|
|
? `${activeDialog.mode}:${activeDialog.status}:${activeDialog.composerOpen !== false ? 'open' : 'closed'}:${activeDialog.generatedLayerId ?? '-'}:${activeDialog.placeholder ? 'placeholder' : '-'}:${activeDialog.errorMessage ?? '-'}`
|
|
: '-'}
|
|
</span>
|
|
<span data-testid="quick-edit">
|
|
{quickEditPanel
|
|
? `${quickEditPanel.sourceLayerId}:${quickEditPanel.status}:${quickEditPanel.prompt || '-'}:${quickEditPanel.errorMessage ?? '-'}`
|
|
: '-'}
|
|
</span>
|
|
<span data-testid="character-animation">
|
|
{characterAnimationPanel
|
|
? `${characterAnimationPanel.sourceLayerId}:${characterAnimationPanel.status}:${characterAnimationPanel.promptText || '-'}:${characterAnimationPanel.errorMessage ?? '-'}:${characterAnimationPanel.result ? 'result' : '-'}`
|
|
: '-'}
|
|
</span>
|
|
<span data-testid="fit-count">
|
|
{fitLayersMockRef.current.mock.calls.length}
|
|
</span>
|
|
<span data-testid="remembered-model">
|
|
{rememberedImageModelRef.current ?? '-'}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
if (initialDialog) {
|
|
dialogs.setGenerateDialog(initialDialog);
|
|
}
|
|
}}
|
|
>
|
|
设置初始对话
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
if (sourceLayer) {
|
|
setQuickEditPanel(createQuickEditPanelDraft(sourceLayer));
|
|
}
|
|
}}
|
|
>
|
|
打开快速编辑
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
setQuickEditPanel((currentPanel) =>
|
|
currentPanel ? { ...currentPanel, prompt: ' 快速修图 ' } : null,
|
|
)
|
|
}
|
|
>
|
|
填写快速编辑
|
|
</button>
|
|
<button type="button" onClick={() => void workflow.submitQuickEdit()}>
|
|
提交快速编辑
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
if (activeDialog) {
|
|
void workflow.submitImageGeneration(activeDialog);
|
|
}
|
|
}}
|
|
>
|
|
提交当前生成
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
if (activeDialog) {
|
|
void workflow.submitIconSpritesheetGeneration(activeDialog);
|
|
}
|
|
}}
|
|
>
|
|
提交图标
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
if (sourceLayer) {
|
|
void workflow.extractUiDesignAssets(sourceLayer);
|
|
}
|
|
}}
|
|
>
|
|
提取UI素材
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
if (sourceLayer) {
|
|
void workflow.extractUiDesignAssets(sourceLayer, {
|
|
marks: Array.from({ length: 7 }, (_, index) => ({
|
|
id: `mark-${index + 1}`,
|
|
tool: 'rect' as const,
|
|
x: 10 + index * 8,
|
|
y: 20 + index * 6,
|
|
width: 80,
|
|
height: 60,
|
|
})),
|
|
});
|
|
}
|
|
}}
|
|
>
|
|
提取已框选UI素材
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => void workflow.submitCharacterAnimation()}
|
|
>
|
|
提交角色动画
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
if (activeCanvasDialog) {
|
|
dialogs.updateCanvasGenerationDialogById(
|
|
activeCanvasDialog.id,
|
|
(dialog) => ({
|
|
...dialog,
|
|
placeholder: dialog.placeholder
|
|
? { ...dialog.placeholder, x: 500, y: 400 }
|
|
: dialog.placeholder,
|
|
}),
|
|
);
|
|
}
|
|
}}
|
|
>
|
|
移动占位
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
|
beforeEach(() => {
|
|
resolveEditorImageReferenceDataUrlMock.mockReset();
|
|
resolveEditorImageReferenceDataUrlForGenerationMock.mockReset();
|
|
editEditorImageMock.mockReset();
|
|
extractEditorUiDesignAssetsMock.mockReset();
|
|
renderUiDesignAssetExtractionMarkedImageMock.mockReset();
|
|
generateEditorCharacterAnimationMock.mockReset();
|
|
generateEditorIconSpritesheetMock.mockReset();
|
|
generateEditorImageMock.mockReset();
|
|
generateEditorVideoMock.mockReset();
|
|
generateEditorSoundEffectMock.mockReset();
|
|
generateEditorBackgroundMusicMock.mockReset();
|
|
loadEditorProjectMock.mockReset();
|
|
getExternalGenerationJobStatusMock.mockReset();
|
|
resolveEditorImageReferenceDataUrlMock.mockImplementation(
|
|
async (src: string) => `data:image/png;base64,${src.split('/').pop()}`,
|
|
);
|
|
resolveEditorImageReferenceDataUrlForGenerationMock.mockImplementation(
|
|
async (src: string) => src,
|
|
);
|
|
renderUiDesignAssetExtractionMarkedImageMock.mockResolvedValue(
|
|
'data:image/png;base64,marked-ui-design',
|
|
);
|
|
});
|
|
|
|
it('submits quick edits, clears the panel, and fits the source plus result', async () => {
|
|
generateEditorImageMock.mockResolvedValueOnce(
|
|
createGenerated({ prompt: '快速修图' }),
|
|
);
|
|
render(<SubmissionWorkflowHarness />);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '打开快速编辑' }));
|
|
fireEvent.click(screen.getByRole('button', { name: '填写快速编辑' }));
|
|
fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' }));
|
|
|
|
expect(screen.getByTestId('quick-edit').textContent).toBe('-');
|
|
expect(screen.getByTestId('dialog').textContent).toBe(
|
|
'quick-edit:generating:closed:-:placeholder:-',
|
|
);
|
|
await waitFor(() => {
|
|
expect(generateEditorImageMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
prompt: '快速修图',
|
|
size: '1024x768',
|
|
aspectRatio: '4:3',
|
|
imageSize: '1K',
|
|
kind: 'quick-edit',
|
|
model: 'gemini-3.1-flash-image-preview',
|
|
referenceImageSrcs: ['data:image/png;base64,source.png'],
|
|
}),
|
|
);
|
|
});
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('layers').textContent).toContain(
|
|
'layer-quick-edit-1:源图 快速编辑:resource-source:-',
|
|
);
|
|
});
|
|
expect(screen.getByTestId('quick-edit').textContent).toBe('-');
|
|
expect(screen.getByTestId('tool').textContent).toBe('select');
|
|
expect(screen.getByTestId('sidebar').textContent).toBe('layers');
|
|
expect(screen.getByTestId('selected').textContent).toBe(
|
|
'layer-quick-edit-1',
|
|
);
|
|
expect(screen.getByTestId('fit-count').textContent).toBe('1');
|
|
});
|
|
|
|
it('refreshes the wallet balance after inline image generation succeeds', async () => {
|
|
const refreshWalletBalance = vi.fn();
|
|
generateEditorImageMock.mockResolvedValueOnce(
|
|
createGenerated({ prompt: '快速修图' }),
|
|
);
|
|
render(
|
|
<SubmissionWorkflowHarness
|
|
onWalletBalanceMayHaveChanged={refreshWalletBalance}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '打开快速编辑' }));
|
|
fireEvent.click(screen.getByRole('button', { name: '填写快速编辑' }));
|
|
fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' }));
|
|
|
|
await waitFor(() => {
|
|
expect(generateEditorImageMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
await waitFor(() => {
|
|
expect(refreshWalletBalance).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
it('places quick edit result with the new image placement logic away from existing assets', async () => {
|
|
const blockingLayer = createLayer({
|
|
id: 'layer-blocker',
|
|
resourceId: 'resource-blocker',
|
|
title: '右侧已有素材',
|
|
x: 472,
|
|
y: 140,
|
|
width: 1024,
|
|
height: 768,
|
|
originalWidth: 1024,
|
|
originalHeight: 768,
|
|
zIndex: 3,
|
|
});
|
|
generateEditorImageMock.mockResolvedValueOnce(
|
|
createGenerated({ prompt: '快速修图', width: 1024, height: 768 }),
|
|
);
|
|
render(
|
|
<SubmissionWorkflowHarness
|
|
initialLayers={[createLayer(), blockingLayer]}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '打开快速编辑' }));
|
|
fireEvent.click(screen.getByRole('button', { name: '填写快速编辑' }));
|
|
fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' }));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('layers').textContent).toContain(
|
|
'layer-quick-edit-1:源图 快速编辑:resource-source:-',
|
|
);
|
|
});
|
|
const layerRects = screen.getByTestId('layer-rects').textContent ?? '';
|
|
const resultRect = parseLayerRect(layerRects, 'layer-quick-edit-1');
|
|
|
|
expect(rectOverlapsExpanded(resultRect, blockingLayer, 32)).toBe(false);
|
|
});
|
|
|
|
it('submits quick edits with the numbered red selection image as the final source reference', async () => {
|
|
generateEditorImageMock.mockResolvedValueOnce(
|
|
createGenerated({
|
|
prompt: '对1号红色圈选框里的内容做以下修改:改成蓝色',
|
|
}),
|
|
);
|
|
render(
|
|
<SubmissionWorkflowHarness
|
|
initialQuickEditPanel={{
|
|
mode: 'quick-edit',
|
|
sourceLayerId: 'layer-source',
|
|
prompt: '对1号红色圈选框里的内容做以下修改:改成蓝色',
|
|
size: '1024x768',
|
|
model: 'gpt-image-2',
|
|
status: 'idle',
|
|
quickEditReferences: [
|
|
{
|
|
id: 'ref-style',
|
|
label: '风格参考',
|
|
src: 'https://assets.example.test/style.png',
|
|
},
|
|
],
|
|
}}
|
|
initialQuickEditSelectionState={{
|
|
sourceLayerId: 'layer-source',
|
|
tool: 'rect',
|
|
marks: [
|
|
{
|
|
id: 'quick-edit-selection-mark-1',
|
|
tool: 'rect',
|
|
x: 24,
|
|
y: 36,
|
|
width: 160,
|
|
height: 120,
|
|
},
|
|
],
|
|
draftMark: null,
|
|
status: 'idle',
|
|
}}
|
|
/>,
|
|
);
|
|
renderUiDesignAssetExtractionMarkedImageMock.mockResolvedValueOnce(
|
|
'data:image/png;base64,marked-source',
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' }));
|
|
|
|
await waitFor(() => {
|
|
expect(renderUiDesignAssetExtractionMarkedImageMock).toHaveBeenCalledWith(
|
|
{
|
|
source: 'data:image/png;base64,source.png',
|
|
marks: [
|
|
{
|
|
id: 'quick-edit-selection-mark-1',
|
|
tool: 'rect',
|
|
x: 24,
|
|
y: 36,
|
|
width: 160,
|
|
height: 120,
|
|
},
|
|
],
|
|
showIndexLabels: true,
|
|
},
|
|
);
|
|
});
|
|
await waitFor(() => {
|
|
expect(generateEditorImageMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
referenceImageSrcs: [
|
|
'data:image/png;base64,style.png',
|
|
'data:image/png;base64,marked-source',
|
|
],
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
it('keeps failed quick edit panels visible with the normalized prompt', async () => {
|
|
const refreshWalletBalance = vi.fn();
|
|
generateEditorImageMock.mockRejectedValueOnce(new Error('修图失败'));
|
|
render(
|
|
<SubmissionWorkflowHarness
|
|
onWalletBalanceMayHaveChanged={refreshWalletBalance}
|
|
initialQuickEditPanel={{
|
|
sourceLayerId: 'layer-source',
|
|
prompt: '',
|
|
size: '1024x768',
|
|
model: 'gpt-image-2',
|
|
status: 'idle',
|
|
}}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' }));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('quick-edit').textContent).toBe(
|
|
'layer-source:failed:快速编辑图片:修图失败',
|
|
);
|
|
});
|
|
expect(refreshWalletBalance).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('submits character redraws through the character image prompt contract', async () => {
|
|
generateEditorImageMock.mockResolvedValueOnce(
|
|
createGenerated({ prompt: '角色换成蓝色披风' }),
|
|
);
|
|
render(
|
|
<SubmissionWorkflowHarness
|
|
initialLayers={[
|
|
createLayer({
|
|
assetKind: 'character',
|
|
prompt: '原始角色设定',
|
|
}),
|
|
]}
|
|
initialQuickEditPanel={{
|
|
mode: 'redraw',
|
|
sourceLayerId: 'layer-source',
|
|
prompt: ' 角色换成蓝色披风 ',
|
|
size: '1024x768',
|
|
model: 'gpt-image-2',
|
|
status: 'idle',
|
|
}}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' }));
|
|
|
|
await waitFor(() => {
|
|
expect(generateEditorImageMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
prompt: '角色换成蓝色披风',
|
|
size: '1024x768',
|
|
aspectRatio: '4:3',
|
|
imageSize: '1K',
|
|
kind: 'character',
|
|
model: 'gpt-image-2',
|
|
referenceImageSrcs: ['data:image/png;base64,source.png'],
|
|
assetKind: 'character',
|
|
}),
|
|
);
|
|
});
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('layers').textContent).toContain(
|
|
'layer-quick-edit-1:源图 重绘:resource-source:character',
|
|
);
|
|
});
|
|
expect(screen.getByTestId('quick-edit').textContent).toBe(
|
|
'layer-source:idle:角色换成蓝色披风:-',
|
|
);
|
|
});
|
|
|
|
it('submits video quick edits through the video API with the source video as the hidden last reference', async () => {
|
|
generateEditorVideoMock.mockResolvedValueOnce(
|
|
createVideoGenerated({ prompt: '把图2改成雨天' }),
|
|
);
|
|
render(
|
|
<SubmissionWorkflowHarness
|
|
initialLayers={[
|
|
createLayer({
|
|
title: '源视频',
|
|
src: 'https://assets.example.test/source.mp4',
|
|
objectKey: 'generated-character-drafts/editor/videos/source.mp4',
|
|
mediaType: 'video',
|
|
assetKind: 'video',
|
|
model: 'gpt-image-2',
|
|
originalWidth: 854,
|
|
originalHeight: 480,
|
|
width: 427,
|
|
height: 240,
|
|
}),
|
|
]}
|
|
initialQuickEditPanel={{
|
|
mode: 'quick-edit',
|
|
sourceLayerId: 'layer-source',
|
|
prompt: '把当前图改成雨天',
|
|
size: '854x480',
|
|
model: 'gpt-image-2',
|
|
status: 'idle',
|
|
quickEditReferences: [
|
|
{
|
|
id: 'ref-style',
|
|
label: '风格参考',
|
|
src: 'https://assets.example.test/style.png',
|
|
},
|
|
],
|
|
}}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' }));
|
|
|
|
await waitFor(() => {
|
|
expect(generateEditorVideoMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
prompt: '把图2改成雨天',
|
|
model: 'seedance2.0-fast',
|
|
aspectRatio: '16:9',
|
|
durationSeconds: 4,
|
|
resolution: '480p',
|
|
mode: 'std',
|
|
sound: 'off',
|
|
referenceImageSrcs: ['data:image/png;base64,style.png'],
|
|
referenceVideoSrcs: [
|
|
'generated-character-drafts/editor/videos/source.mp4',
|
|
],
|
|
}),
|
|
);
|
|
});
|
|
expect(generateEditorImageMock).not.toHaveBeenCalled();
|
|
expect(generateEditorCharacterAnimationMock).not.toHaveBeenCalled();
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('layers').textContent).toContain(
|
|
'layer-video-1:源视频 快速编辑:resource-source:video:video:https://assets.example.test/generated-video.mp4',
|
|
);
|
|
});
|
|
});
|
|
|
|
it('submits character action quick edits through seedance fast character animation generation', async () => {
|
|
generateEditorCharacterAnimationMock.mockResolvedValueOnce({
|
|
taskId: 'action-quick-edit-task',
|
|
model: 'seedance2.0-fast',
|
|
prompt: '让图1跳得更高',
|
|
previewVideoPath: '/generated-character-drafts/editor/action-preview.mp4',
|
|
frames: [
|
|
{
|
|
frameIndex: 1,
|
|
imageSrc: 'data:image/png;base64,action-frame-edited',
|
|
width: 1024,
|
|
height: 1024,
|
|
},
|
|
],
|
|
frameCount: 32,
|
|
durationSeconds: 4,
|
|
fps: 8,
|
|
});
|
|
render(
|
|
<SubmissionWorkflowHarness
|
|
initialLayers={[
|
|
createLayer({
|
|
title: '角色动作',
|
|
src: 'generated-character-drafts/editor/actions/frame.png',
|
|
mediaType: 'image-sequence',
|
|
thumbnailSrc: 'generated-character-drafts/editor/actions/frame.png',
|
|
imageSequenceFrames: [
|
|
{
|
|
frameIndex: 1,
|
|
imageSrc: 'generated-character-drafts/editor/actions/frame.png',
|
|
width: 1024,
|
|
height: 1024,
|
|
},
|
|
],
|
|
previewVideoPath: 'https://assets.example.test/action-preview.mp4',
|
|
assetKind: 'character-animation',
|
|
model: 'seedance2.0',
|
|
originalWidth: 1024,
|
|
originalHeight: 1024,
|
|
width: 420,
|
|
height: 420,
|
|
}),
|
|
]}
|
|
initialQuickEditPanel={{
|
|
mode: 'quick-edit',
|
|
sourceLayerId: 'layer-source',
|
|
prompt: '让当前图跳得更高',
|
|
size: '1024x1024',
|
|
model: 'gpt-image-2',
|
|
status: 'idle',
|
|
}}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' }));
|
|
|
|
await waitFor(() => {
|
|
expect(generateEditorCharacterAnimationMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
sourceLayerId: 'layer-source',
|
|
sourceImageSrc: 'generated-character-drafts/editor/actions/frame.png',
|
|
promptText: '让图1跳得更高',
|
|
resolution: '480p',
|
|
ratio: 'same',
|
|
frameCount: 32,
|
|
durationSeconds: 4,
|
|
model: 'seedance2.0-fast',
|
|
}),
|
|
);
|
|
});
|
|
expect(generateEditorImageMock).not.toHaveBeenCalled();
|
|
expect(generateEditorVideoMock).not.toHaveBeenCalled();
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('layers').textContent).toContain(
|
|
'layer-character-animation-1:角色动作 快速编辑:resource-source:character-animation:image-sequence:data:image/png;base64,action-frame-edited:data:image/png;base64,action-frame-edited:4',
|
|
);
|
|
});
|
|
});
|
|
|
|
it('submits edit dialogs beside the source and clears the modal state', async () => {
|
|
editEditorImageMock.mockResolvedValueOnce(
|
|
createGenerated({ prompt: '修改当前图片' }),
|
|
);
|
|
render(
|
|
<SubmissionWorkflowHarness
|
|
initialDialog={{
|
|
mode: 'edit',
|
|
prompt: '',
|
|
status: 'idle',
|
|
sourceLayerId: 'layer-source',
|
|
}}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
|
|
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
|
|
|
|
expect(screen.getByTestId('dialog').textContent).toBe(
|
|
'edit:generating:open:-:-:-',
|
|
);
|
|
await waitFor(() => {
|
|
expect(editEditorImageMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
prompt: '修改当前图片',
|
|
sourceImageSrc: 'data:image/png;base64,source.png',
|
|
}),
|
|
);
|
|
});
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('layers').textContent).toContain(
|
|
'layer-edit-1:源图 修改结果:resource-source:-',
|
|
);
|
|
});
|
|
expect(screen.getByTestId('dialog').textContent).toBe('-');
|
|
expect(screen.getByTestId('tool').textContent).toBe('select');
|
|
expect(screen.getByTestId('fit-count').textContent).toBe('1');
|
|
});
|
|
|
|
it('uses the latest moved canvas placeholder for local-only generation fallback', async () => {
|
|
generateEditorImageMock.mockResolvedValueOnce(
|
|
createGenerated({ width: 512, height: 512 }),
|
|
);
|
|
render(
|
|
<SubmissionWorkflowHarness
|
|
initialDialog={{
|
|
id: 'dialog-1',
|
|
mode: 'generate',
|
|
prompt: '占位生成',
|
|
status: 'idle',
|
|
composerOpen: true,
|
|
placeholder: {
|
|
x: 100,
|
|
y: 100,
|
|
width: 420,
|
|
height: 420,
|
|
originalWidth: 2048,
|
|
originalHeight: 2048,
|
|
},
|
|
}}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
|
|
fireEvent.click(screen.getByRole('button', { name: '移动占位' }));
|
|
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('layers').textContent).toContain(
|
|
'layer-generated-1:生成图片 1:-:-',
|
|
);
|
|
});
|
|
expect(screen.getByTestId('dialog').textContent).toBe(
|
|
'generate:idle:open:layer-generated-1:placeholder:-',
|
|
);
|
|
});
|
|
|
|
it('passes canvas completion context and refreshes from the backend project snapshot', async () => {
|
|
const applyProjectSnapshot = vi.fn();
|
|
generateEditorImageMock.mockResolvedValueOnce({
|
|
...createGenerated({
|
|
width: 1024,
|
|
height: 1024,
|
|
prompt: '银发游侠',
|
|
}),
|
|
project: {
|
|
projectId: 'editor-project-1',
|
|
title: '角色项目',
|
|
viewport: { x: 12, y: 24, scale: 1.5 },
|
|
layers: [
|
|
{
|
|
layerId: 'layer-editor-resource-character',
|
|
resourceId: 'editor-resource-character',
|
|
},
|
|
],
|
|
resources: [],
|
|
updatedAt: '2026-06-21T12:16:22.000Z',
|
|
},
|
|
});
|
|
render(
|
|
<SubmissionWorkflowHarness
|
|
projectId="editor-project-1"
|
|
applyProjectSnapshot={applyProjectSnapshot}
|
|
initialDialog={{
|
|
id: 'dialog-character',
|
|
mode: 'character',
|
|
prompt: ' 银发游侠 ',
|
|
status: 'idle',
|
|
composerOpen: true,
|
|
imageModel: 'gpt-image-2',
|
|
placeholder: {
|
|
x: 200,
|
|
y: 160,
|
|
width: 420,
|
|
height: 560,
|
|
originalWidth: 1024,
|
|
originalHeight: 1536,
|
|
},
|
|
}}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
|
|
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
|
|
|
|
await waitFor(() => {
|
|
expect(generateEditorImageMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
prompt: '银发游侠',
|
|
kind: 'character',
|
|
projectId: 'editor-project-1',
|
|
assetKind: 'character',
|
|
assetFolderId: 'project',
|
|
canvasCompletion: {
|
|
dialogId: 'dialog-character',
|
|
title: '角色形象 1',
|
|
placeholder: {
|
|
x: 200,
|
|
y: 160,
|
|
width: 420,
|
|
height: 560,
|
|
originalWidth: 1024,
|
|
originalHeight: 1536,
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
});
|
|
await waitFor(() => {
|
|
expect(applyProjectSnapshot).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
projectId: 'editor-project-1',
|
|
title: '角色项目',
|
|
}),
|
|
);
|
|
});
|
|
expect(screen.getByTestId('layers').textContent).not.toContain(
|
|
'layer-generated-1',
|
|
);
|
|
});
|
|
|
|
it('resolves uploaded objectKey references before submitting image generation', async () => {
|
|
resolveEditorImageReferenceDataUrlForGenerationMock.mockResolvedValueOnce(
|
|
'data:image/jpeg;base64,normalized-ref',
|
|
);
|
|
generateEditorImageMock.mockResolvedValueOnce(
|
|
createGenerated({ prompt: '参考图生成' }),
|
|
);
|
|
render(
|
|
<SubmissionWorkflowHarness
|
|
initialDialog={{
|
|
id: 'dialog-generate',
|
|
mode: 'generate',
|
|
prompt: '参考图生成',
|
|
status: 'idle',
|
|
composerOpen: true,
|
|
imageModel: 'gpt-image-2',
|
|
generationReferences: [
|
|
{
|
|
id: 'ref-uploaded',
|
|
label: '上传参考图',
|
|
src: 'https://signed.example.test/ref.png?expires=1',
|
|
objectKey: 'generated-character-drafts/editor/refs/ref.png',
|
|
assetObjectId: 'assetobj-ref',
|
|
},
|
|
],
|
|
placeholder: {
|
|
x: 200,
|
|
y: 160,
|
|
width: 420,
|
|
height: 420,
|
|
originalWidth: 1024,
|
|
originalHeight: 1024,
|
|
},
|
|
}}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
|
|
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
|
|
|
|
await waitFor(() => {
|
|
expect(
|
|
resolveEditorImageReferenceDataUrlForGenerationMock,
|
|
).toHaveBeenCalledWith('generated-character-drafts/editor/refs/ref.png');
|
|
expect(generateEditorImageMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
prompt: '参考图生成',
|
|
referenceImageSrcs: ['data:image/jpeg;base64,normalized-ref'],
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
it('refreshes the wallet balance after a queued generation reaches a terminal state', async () => {
|
|
const applyProjectSnapshot = vi.fn();
|
|
const refreshWalletBalance = vi.fn();
|
|
const backendProject = {
|
|
projectId: 'editor-project-1',
|
|
title: '队列项目',
|
|
viewport: { x: 0, y: 0, scale: 1 },
|
|
layers: [],
|
|
resources: [],
|
|
updatedAt: '2026-06-23T08:00:00.000Z',
|
|
};
|
|
loadEditorProjectMock.mockResolvedValueOnce(backendProject);
|
|
generateEditorImageMock.mockResolvedValueOnce({
|
|
...createGenerated({
|
|
imageSrc: '/generated-editor-images/project/image.png',
|
|
width: 1024,
|
|
height: 1024,
|
|
prompt: '队列生成',
|
|
}),
|
|
queueState: createQueueState(),
|
|
});
|
|
render(
|
|
<SubmissionWorkflowHarness
|
|
projectId="editor-project-1"
|
|
applyProjectSnapshot={applyProjectSnapshot}
|
|
onWalletBalanceMayHaveChanged={refreshWalletBalance}
|
|
initialDialog={{
|
|
id: 'dialog-generate',
|
|
mode: 'generate',
|
|
prompt: '队列生成',
|
|
status: 'idle',
|
|
composerOpen: true,
|
|
imageModel: 'gpt-image-2',
|
|
placeholder: {
|
|
x: 200,
|
|
y: 160,
|
|
width: 420,
|
|
height: 420,
|
|
originalWidth: 1024,
|
|
originalHeight: 1024,
|
|
},
|
|
}}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
|
|
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
|
|
|
|
await waitFor(() => {
|
|
expect(loadEditorProjectMock).toHaveBeenCalledWith('editor-project-1');
|
|
});
|
|
expect(refreshWalletBalance).toHaveBeenCalledTimes(1);
|
|
expect(applyProjectSnapshot).toHaveBeenCalledWith(backendProject);
|
|
expect(getExternalGenerationJobStatusMock).not.toHaveBeenCalled();
|
|
expect(screen.getByTestId('layers').textContent).not.toContain(
|
|
'layer-generated-1',
|
|
);
|
|
});
|
|
|
|
it('keeps a queued generation pending when the frontend queue wait expires', async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const refreshWalletBalance = vi.fn();
|
|
const runningState = createQueueState({
|
|
status: 'running',
|
|
phaseLabel: '生成中',
|
|
phaseDetail: '后端任务继续执行中',
|
|
progress: 0.35,
|
|
});
|
|
generateEditorImageMock.mockResolvedValueOnce({
|
|
...createGenerated({
|
|
imageSrc: '/generated-editor-images/project/image.png',
|
|
width: 1024,
|
|
height: 1024,
|
|
prompt: '队列生成',
|
|
}),
|
|
queueState: runningState,
|
|
});
|
|
getExternalGenerationJobStatusMock.mockResolvedValue({
|
|
job: runningState,
|
|
});
|
|
render(
|
|
<SubmissionWorkflowHarness
|
|
projectId="editor-project-1"
|
|
onWalletBalanceMayHaveChanged={refreshWalletBalance}
|
|
initialDialog={{
|
|
id: 'dialog-generate',
|
|
mode: 'generate',
|
|
prompt: '队列生成',
|
|
status: 'idle',
|
|
composerOpen: true,
|
|
imageModel: 'gpt-image-2',
|
|
placeholder: {
|
|
x: 200,
|
|
y: 160,
|
|
width: 420,
|
|
height: 420,
|
|
originalWidth: 1024,
|
|
originalHeight: 1024,
|
|
},
|
|
}}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
|
|
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
|
|
|
|
await act(async () => {
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
});
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(20 * 60 * 1000 + 1600);
|
|
});
|
|
|
|
expect(refreshWalletBalance).toHaveBeenCalledTimes(1);
|
|
expect(loadEditorProjectMock).not.toHaveBeenCalled();
|
|
expect(getExternalGenerationJobStatusMock).toHaveBeenCalled();
|
|
expect(screen.getByTestId('dialog').textContent).toBe(
|
|
'generate:generating:closed:-:placeholder:-',
|
|
);
|
|
expect(screen.getByTestId('dialog').textContent).not.toContain(
|
|
'生成任务仍在队列中',
|
|
);
|
|
expect(screen.getByTestId('layers').textContent).not.toContain(
|
|
'layer-generated-1',
|
|
);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it('does not add a local layer when project generation returns without a backend snapshot', async () => {
|
|
generateEditorImageMock.mockResolvedValueOnce(
|
|
createGenerated({
|
|
width: 1024,
|
|
height: 1024,
|
|
prompt: '银发游侠',
|
|
project: null,
|
|
}),
|
|
);
|
|
render(
|
|
<SubmissionWorkflowHarness
|
|
projectId="editor-project-1"
|
|
initialDialog={{
|
|
id: 'dialog-character',
|
|
mode: 'character',
|
|
prompt: '银发游侠',
|
|
status: 'idle',
|
|
composerOpen: true,
|
|
imageModel: 'gpt-image-2',
|
|
placeholder: {
|
|
x: 200,
|
|
y: 160,
|
|
width: 420,
|
|
height: 560,
|
|
originalWidth: 1024,
|
|
originalHeight: 1536,
|
|
},
|
|
}}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
|
|
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
|
|
|
|
await waitFor(() => {
|
|
expect(generateEditorImageMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
projectId: 'editor-project-1',
|
|
canvasCompletion: expect.objectContaining({
|
|
dialogId: 'dialog-character',
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('dialog').textContent).toBe(
|
|
'character:idle:open:-:placeholder:-',
|
|
);
|
|
});
|
|
expect(screen.getByTestId('layers').textContent).not.toContain(
|
|
'layer-generated-1',
|
|
);
|
|
});
|
|
|
|
it('submits publication detail materials as one billable editor image and appends the result', async () => {
|
|
generateEditorImageMock.mockResolvedValueOnce(
|
|
createGenerated({
|
|
imageSrc: 'data:image/png;base64,publication',
|
|
width: 720,
|
|
height: 1280,
|
|
prompt: '游戏名:云朵消消乐',
|
|
}),
|
|
);
|
|
render(
|
|
<SubmissionWorkflowHarness
|
|
initialDialog={{
|
|
id: 'dialog-publication',
|
|
mode: 'publication',
|
|
prompt: '',
|
|
status: 'idle',
|
|
composerOpen: true,
|
|
publicationWorkflowId: 'publication-detail-gallery',
|
|
publicationGameInfo: {
|
|
gameName: '云朵消消乐',
|
|
gameCategories: '休闲消除',
|
|
gameDescription: '点击同类云朵完成消除',
|
|
},
|
|
publicationReferences: [
|
|
{
|
|
id: 'ref-1',
|
|
label: '主视觉参考',
|
|
src: 'data:image/png;base64,large-ref',
|
|
},
|
|
],
|
|
placeholder: {
|
|
x: 180,
|
|
y: 120,
|
|
width: 360,
|
|
height: 640,
|
|
originalWidth: 720,
|
|
originalHeight: 1280,
|
|
},
|
|
}}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
|
|
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
|
|
|
|
expect(screen.getByTestId('dialog').textContent).toBe(
|
|
'publication:generating:closed:-:placeholder:-',
|
|
);
|
|
await waitFor(() => {
|
|
expect(generateEditorImageMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
prompt: expect.stringContaining('【宣发素材类型】详情页单图'),
|
|
size: '720x1280',
|
|
kind: 'publication-material',
|
|
model: 'gpt-image-2',
|
|
aspectRatio: '9:16',
|
|
imageSize: '1K',
|
|
referenceImageSrcs: ['data:image/png;base64,large-ref'],
|
|
}),
|
|
);
|
|
});
|
|
const prompt = generateEditorImageMock.mock.calls[0]?.[0]?.prompt ?? '';
|
|
expect(prompt).toContain('游戏名:云朵消消乐');
|
|
expect(prompt).toContain('本次只生成一张 720 x 1280 竖版详情图');
|
|
expect(prompt).toContain('不是五图合集或拼贴');
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('layers').textContent).toContain(
|
|
'layer-generated-1:1 宣发素材:-:-',
|
|
);
|
|
});
|
|
expect(screen.getByTestId('layers').textContent).not.toContain(
|
|
'layer-generated-2',
|
|
);
|
|
expect(screen.getByTestId('selected').textContent).toBe(
|
|
'layer-generated-1',
|
|
);
|
|
});
|
|
|
|
it('compresses publication references before submitting generation', async () => {
|
|
resolveEditorImageReferenceDataUrlForGenerationMock.mockResolvedValueOnce(
|
|
'data:image/jpeg;base64,compressed-ref',
|
|
);
|
|
generateEditorImageMock.mockResolvedValueOnce(
|
|
createGenerated({
|
|
imageSrc: 'data:image/png;base64,publication',
|
|
width: 720,
|
|
height: 540,
|
|
prompt: '游戏名:云朵消消乐',
|
|
}),
|
|
);
|
|
render(
|
|
<SubmissionWorkflowHarness
|
|
initialDialog={{
|
|
id: 'dialog-publication',
|
|
mode: 'publication',
|
|
prompt: '',
|
|
status: 'idle',
|
|
composerOpen: true,
|
|
publicationWorkflowId: 'publication-cover-image',
|
|
publicationGameInfo: {
|
|
gameName: '云朵消消乐',
|
|
gameCategories: '休闲消除',
|
|
gameDescription: '点击同类云朵完成消除',
|
|
},
|
|
publicationReferences: [
|
|
{
|
|
id: 'ref-1',
|
|
label: '主视觉参考',
|
|
src: 'data:image/png;base64,large-original-ref',
|
|
},
|
|
],
|
|
}}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
|
|
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
|
|
|
|
await waitFor(() => {
|
|
expect(
|
|
resolveEditorImageReferenceDataUrlForGenerationMock,
|
|
).toHaveBeenCalledWith('data:image/png;base64,large-original-ref');
|
|
expect(generateEditorImageMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
kind: 'publication-material',
|
|
aspectRatio: '4:3',
|
|
imageSize: '1K',
|
|
referenceImageSrcs: ['data:image/jpeg;base64,compressed-ref'],
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
it('submits icon spec objects without requiring an icon spec reference', async () => {
|
|
generateEditorImageMock.mockResolvedValueOnce(
|
|
createGenerated({
|
|
imageSrc: 'data:image/png;base64,icon-spec',
|
|
width: 2048,
|
|
height: 1152,
|
|
prompt: '图标规范提示词',
|
|
}),
|
|
);
|
|
render(
|
|
<SubmissionWorkflowHarness
|
|
initialDialog={{
|
|
id: 'dialog-icon-spec',
|
|
mode: 'spec',
|
|
prompt: '',
|
|
status: 'idle',
|
|
composerOpen: true,
|
|
specType: 'icon',
|
|
specValues: {
|
|
playSetting: '背包整理玩法',
|
|
artStyle: '清爽卡通',
|
|
bodyRatio: '3',
|
|
characterView: '',
|
|
customPrompt: '',
|
|
},
|
|
placeholder: {
|
|
x: 120,
|
|
y: 140,
|
|
width: 640,
|
|
height: 360,
|
|
originalWidth: 2048,
|
|
originalHeight: 1152,
|
|
},
|
|
}}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
|
|
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
|
|
|
|
expect(generateEditorIconSpritesheetMock).not.toHaveBeenCalled();
|
|
await waitFor(() => {
|
|
expect(generateEditorImageMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
kind: 'spec',
|
|
assetKind: 'icon-spec',
|
|
assetLabel: '图标规范 1',
|
|
size: '2048x1152',
|
|
aspectRatio: '16:9',
|
|
imageSize: '2K',
|
|
}),
|
|
);
|
|
});
|
|
const payload = generateEditorImageMock.mock.calls[0]?.[0] ?? {};
|
|
expect(payload).not.toHaveProperty('referenceImageSrcs');
|
|
expect(payload.prompt).toContain('背包整理玩法');
|
|
expect(payload.prompt).not.toContain('参考图生成规范');
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('layers').textContent).toContain(
|
|
'layer-generated-1:图标规范 1:-:icon-spec',
|
|
);
|
|
});
|
|
expect(screen.getByTestId('dialog').textContent).not.toContain(
|
|
'请选择图标规范',
|
|
);
|
|
});
|
|
|
|
it('validates icon submission before calling the API', async () => {
|
|
render(
|
|
<SubmissionWorkflowHarness
|
|
initialDialog={{
|
|
id: 'dialog-icon',
|
|
mode: 'icon',
|
|
prompt: ' 返回按钮 \n\n设置按钮',
|
|
status: 'idle',
|
|
composerOpen: true,
|
|
iconDescriptions: ['返回按钮'],
|
|
}}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
|
|
fireEvent.click(screen.getByRole('button', { name: '提交图标' }));
|
|
|
|
expect(generateEditorIconSpritesheetMock).not.toHaveBeenCalled();
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('dialog').textContent).toContain(
|
|
'请选择图标规范',
|
|
);
|
|
});
|
|
});
|
|
|
|
it('submits icon spritesheets, trims descriptions, and remembers the model', async () => {
|
|
generateEditorIconSpritesheetMock.mockResolvedValueOnce({
|
|
spritesheetImageSrc: 'data:image/png;base64,sheet',
|
|
spritesheetWidth: 512,
|
|
spritesheetHeight: 512,
|
|
prompt: '图标素材',
|
|
actualPrompt: '图标素材',
|
|
model: 'gpt-image-2',
|
|
provider: 'VectorEngine',
|
|
taskId: 'task-icons',
|
|
iconImageSrcs: [
|
|
createIconResult('返回按钮', 'data:image/png;base64,back'),
|
|
createIconResult('设置按钮', 'data:image/png;base64,setting'),
|
|
],
|
|
});
|
|
render(
|
|
<SubmissionWorkflowHarness
|
|
initialDialog={{
|
|
id: 'dialog-icon',
|
|
mode: 'icon',
|
|
prompt: ' 返回按钮 \n\n 设置按钮 ',
|
|
status: 'idle',
|
|
composerOpen: true,
|
|
imageModel: 'gpt-image-2',
|
|
iconSpecReference: {
|
|
id: 'spec',
|
|
label: '图标规范',
|
|
src: 'data:image/png;base64,spec',
|
|
},
|
|
iconDescriptions: ['旧返回', '旧设置'],
|
|
placeholder: {
|
|
x: 140,
|
|
y: 160,
|
|
width: 360,
|
|
height: 360,
|
|
originalWidth: 512,
|
|
originalHeight: 512,
|
|
},
|
|
}}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
|
|
fireEvent.click(screen.getByRole('button', { name: '提交图标' }));
|
|
|
|
await waitFor(() => {
|
|
expect(generateEditorIconSpritesheetMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
referenceImageSrc: 'data:image/png;base64,spec',
|
|
iconDescriptions: ['返回按钮', '设置按钮'],
|
|
model: 'gpt-image-2',
|
|
}),
|
|
);
|
|
});
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('layers').textContent).toContain(
|
|
'layer-icon-spritesheet-1:图标素材图集 1:-:icon-spritesheet',
|
|
);
|
|
});
|
|
expect(screen.getByTestId('layers').textContent).toContain(
|
|
'layer-icon-2:返回按钮:-:icon',
|
|
);
|
|
expect(screen.getByTestId('layers').textContent).toContain(
|
|
'layer-icon-3:设置按钮:-:icon',
|
|
);
|
|
expect(screen.getByTestId('selected').textContent).toBe(
|
|
'layer-icon-spritesheet-1',
|
|
);
|
|
expect(screen.getByTestId('dialog').textContent).toBe(
|
|
'icon:idle:open:layer-icon-spritesheet-1:placeholder:-',
|
|
);
|
|
expect(screen.getByTestId('remembered-model').textContent).toBe(
|
|
'gpt-image-2',
|
|
);
|
|
});
|
|
|
|
it('resolves uploaded objectKey icon spec before submitting spritesheets', async () => {
|
|
resolveEditorImageReferenceDataUrlForGenerationMock.mockResolvedValueOnce(
|
|
'data:image/jpeg;base64,normalized-icon-spec',
|
|
);
|
|
generateEditorIconSpritesheetMock.mockResolvedValueOnce({
|
|
spritesheetImageSrc: 'data:image/png;base64,sheet',
|
|
spritesheetWidth: 512,
|
|
spritesheetHeight: 512,
|
|
prompt: '图标素材',
|
|
actualPrompt: '图标素材',
|
|
model: 'gpt-image-2',
|
|
provider: 'VectorEngine',
|
|
taskId: 'task-icons',
|
|
iconImageSrcs: [
|
|
createIconResult('返回按钮', 'data:image/png;base64,back'),
|
|
],
|
|
});
|
|
render(
|
|
<SubmissionWorkflowHarness
|
|
initialDialog={{
|
|
id: 'dialog-icon',
|
|
mode: 'icon',
|
|
prompt: '返回按钮',
|
|
status: 'idle',
|
|
composerOpen: true,
|
|
imageModel: 'gpt-image-2',
|
|
iconSpecReference: {
|
|
id: 'spec',
|
|
label: '图标规范',
|
|
src: 'https://signed.example.test/icon-spec.png?expires=1',
|
|
objectKey: 'generated-character-drafts/editor/specs/icon-spec.png',
|
|
assetObjectId: 'assetobj-icon-spec',
|
|
},
|
|
}}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
|
|
fireEvent.click(screen.getByRole('button', { name: '提交图标' }));
|
|
|
|
await waitFor(() => {
|
|
expect(
|
|
resolveEditorImageReferenceDataUrlForGenerationMock,
|
|
).toHaveBeenCalledWith(
|
|
'generated-character-drafts/editor/specs/icon-spec.png',
|
|
);
|
|
expect(generateEditorIconSpritesheetMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
referenceImageSrc: 'data:image/jpeg;base64,normalized-icon-spec',
|
|
iconDescriptions: ['返回按钮'],
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
it('extracts UI design assets into a spritesheet layer and sliced icon layers', async () => {
|
|
extractEditorUiDesignAssetsMock.mockResolvedValueOnce({
|
|
spritesheetImageSrc: 'data:image/png;base64,ui-sheet',
|
|
spritesheetWidth: 512,
|
|
spritesheetHeight: 512,
|
|
prompt: '仅提取被红色框框选的素材并整理成spritesheet',
|
|
actualPrompt: '仅提取被红色框框选的素材并整理成spritesheet',
|
|
model: 'gpt-image-2',
|
|
provider: 'VectorEngine',
|
|
taskId: 'task-ui-assets',
|
|
iconImageSrcs: [
|
|
createIconResult('素材 1', 'data:image/png;base64,asset-1'),
|
|
],
|
|
});
|
|
render(
|
|
<SubmissionWorkflowHarness
|
|
initialLayers={[
|
|
createLayer({
|
|
assetKind: 'ui-design',
|
|
objectKey: 'generated/ui-design.png',
|
|
}),
|
|
]}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '提取UI素材' }));
|
|
|
|
await waitFor(() => {
|
|
expect(resolveEditorImageReferenceDataUrlMock).toHaveBeenCalledWith(
|
|
'generated/ui-design.png',
|
|
);
|
|
});
|
|
await waitFor(() => {
|
|
expect(extractEditorUiDesignAssetsMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
sourceImageSrc: 'data:image/png;base64,ui-design.png',
|
|
}),
|
|
);
|
|
});
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('layers').textContent).toContain(
|
|
'layer-icon-spritesheet-1:源图 素材图集:-:icon-spritesheet',
|
|
);
|
|
});
|
|
expect(screen.getByTestId('layers').textContent).toContain(
|
|
'layer-icon-2:素材 1:-:icon',
|
|
);
|
|
expect(screen.getByTestId('selected').textContent).toBe(
|
|
'layer-icon-spritesheet-1',
|
|
);
|
|
expect(screen.getByTestId('fit-count').textContent).toBe('1');
|
|
});
|
|
|
|
it('keeps the extracted spritesheet when extracted icon results are missing', async () => {
|
|
extractEditorUiDesignAssetsMock.mockResolvedValueOnce({
|
|
spritesheetImageSrc: 'data:image/png;base64,ui-sheet',
|
|
spritesheetWidth: 512,
|
|
spritesheetHeight: 512,
|
|
prompt: '仅提取被红色框框选的素材并整理成spritesheet',
|
|
actualPrompt: '仅提取被红色框框选的素材并整理成spritesheet',
|
|
model: 'gpt-image-2',
|
|
provider: 'VectorEngine',
|
|
taskId: 'task-ui-assets',
|
|
});
|
|
render(
|
|
<SubmissionWorkflowHarness
|
|
initialLayers={[
|
|
createLayer({
|
|
assetKind: 'ui-design',
|
|
objectKey: 'generated/ui-design.png',
|
|
}),
|
|
]}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '提取UI素材' }));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('layers').textContent).toContain(
|
|
'layer-icon-spritesheet-1:源图 素材图集:-:icon-spritesheet',
|
|
);
|
|
});
|
|
expect(screen.getByTestId('layers').textContent).not.toContain(
|
|
'layer-icon-2:',
|
|
);
|
|
expect(screen.getByTestId('selected').textContent).toBe(
|
|
'layer-icon-spritesheet-1',
|
|
);
|
|
expect(screen.getByTestId('fit-count').textContent).toBe('1');
|
|
});
|
|
|
|
it('renders selected UI marks into the extraction reference image and sends derived extraction pricing', async () => {
|
|
extractEditorUiDesignAssetsMock.mockResolvedValueOnce({
|
|
spritesheetImageSrc: 'data:image/png;base64,ui-sheet',
|
|
spritesheetWidth: 512,
|
|
spritesheetHeight: 512,
|
|
prompt: '仅提取被红色框框选的素材并整理成spritesheet',
|
|
actualPrompt: '仅提取被红色框框选的素材并整理成spritesheet',
|
|
model: 'gpt-image-2',
|
|
provider: 'VectorEngine',
|
|
taskId: 'task-ui-assets',
|
|
iconImageSrcs: [
|
|
createIconResult('素材 1', 'data:image/png;base64,asset-1'),
|
|
],
|
|
});
|
|
render(
|
|
<SubmissionWorkflowHarness
|
|
initialLayers={[
|
|
createLayer({
|
|
assetKind: 'ui-design',
|
|
objectKey: 'generated/ui-design.png',
|
|
}),
|
|
]}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '提取已框选UI素材' }));
|
|
|
|
await waitFor(() => {
|
|
expect(renderUiDesignAssetExtractionMarkedImageMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
source: 'data:image/png;base64,ui-design.png',
|
|
marks: expect.arrayContaining([
|
|
{
|
|
id: 'mark-1',
|
|
tool: 'rect',
|
|
x: 10,
|
|
y: 20,
|
|
width: 80,
|
|
height: 60,
|
|
},
|
|
]),
|
|
}),
|
|
);
|
|
expect(
|
|
renderUiDesignAssetExtractionMarkedImageMock.mock.calls[0]?.[0].marks,
|
|
).toHaveLength(7);
|
|
});
|
|
await waitFor(() => {
|
|
expect(extractEditorUiDesignAssetsMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
sourceImageSrc: 'data:image/png;base64,marked-ui-design',
|
|
aspectRatio: '1:1',
|
|
imageSize: '2K',
|
|
generationInputs: expect.objectContaining({
|
|
fields: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
title: '提取提示词',
|
|
value: '仅提取被红色框框选的素材并整理成spritesheet',
|
|
}),
|
|
]),
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
it('keeps character generation dialog data after the result is created', async () => {
|
|
generateEditorImageMock.mockResolvedValueOnce(
|
|
createGenerated({ width: 768, height: 768 }),
|
|
);
|
|
render(
|
|
<SubmissionWorkflowHarness
|
|
initialDialog={{
|
|
id: 'dialog-character',
|
|
mode: 'character',
|
|
prompt: '骑士角色',
|
|
status: 'idle',
|
|
composerOpen: true,
|
|
imageModel: 'gpt-image-2',
|
|
characterSpecReference: {
|
|
id: 'spec-character',
|
|
label: '角色规范',
|
|
src: 'data:image/png;base64,spec',
|
|
},
|
|
placeholder: {
|
|
x: 120,
|
|
y: 140,
|
|
width: 420,
|
|
height: 420,
|
|
originalWidth: 2048,
|
|
originalHeight: 2048,
|
|
},
|
|
}}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
|
|
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('layers').textContent).toContain(
|
|
'layer-generated-1:角色形象 1:-:character',
|
|
);
|
|
});
|
|
expect(screen.getByTestId('dialog').textContent).toBe(
|
|
'character:idle:open:layer-generated-1:placeholder:-',
|
|
);
|
|
});
|
|
|
|
it('moves character animation panels from generating to completed', async () => {
|
|
generateEditorCharacterAnimationMock.mockResolvedValueOnce({
|
|
frames: [{ imageSrc: 'data:image/png;base64,frame' }],
|
|
previewSrc: 'data:image/png;base64,preview',
|
|
taskId: 'animation-task',
|
|
});
|
|
render(
|
|
<SubmissionWorkflowHarness
|
|
initialLayers={[
|
|
createLayer({
|
|
id: 'character-layer',
|
|
assetKind: 'character',
|
|
objectKey: 'generated/character.png',
|
|
}),
|
|
]}
|
|
initialCharacterAnimationPanel={{
|
|
sourceLayerId: 'character-layer',
|
|
promptText: ' 跑步 ',
|
|
resolution: '480p',
|
|
ratio: 'same',
|
|
frameCount: 32,
|
|
durationSeconds: 4,
|
|
status: 'idle',
|
|
}}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '提交角色动画' }));
|
|
|
|
expect(screen.getByTestId('character-animation').textContent).toBe(
|
|
'character-layer:generating:跑步:-:-',
|
|
);
|
|
await waitFor(() => {
|
|
expect(generateEditorCharacterAnimationMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
sourceLayerId: 'character-layer',
|
|
sourceImageSrc: 'generated/character.png',
|
|
promptText: '跑步',
|
|
model: 'seedance2.0-fast',
|
|
}),
|
|
);
|
|
});
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('character-animation').textContent).toBe(
|
|
'character-layer:completed:跑步:-:result',
|
|
);
|
|
});
|
|
});
|
|
|
|
it('creates an action layer for dialog-driven character animation submissions', async () => {
|
|
generateEditorCharacterAnimationMock.mockResolvedValueOnce({
|
|
taskId: 'animation-task-dialog',
|
|
model: 'seedance2.0-fast',
|
|
prompt: '挥手动作',
|
|
previewVideoPath: '/generated-character-drafts/editor/preview.mp4',
|
|
frames: [
|
|
{
|
|
frameIndex: 1,
|
|
imageSrc: 'data:image/png;base64,action-frame',
|
|
width: 1024,
|
|
height: 1024,
|
|
},
|
|
],
|
|
frameCount: 32,
|
|
durationSeconds: 4,
|
|
fps: 8,
|
|
});
|
|
render(
|
|
<SubmissionWorkflowHarness
|
|
initialLayers={[
|
|
createLayer({
|
|
id: 'character-layer',
|
|
title: '角色源图',
|
|
assetKind: 'character',
|
|
objectKey: 'generated/character.png',
|
|
}),
|
|
]}
|
|
initialDialog={{
|
|
id: 'dialog-character-animation',
|
|
mode: 'character-animation',
|
|
sourceLayerId: 'character-layer',
|
|
prompt: ' 挥手动作 ',
|
|
status: 'idle',
|
|
composerOpen: true,
|
|
characterAnimationResolution: '480p',
|
|
characterAnimationRatio: 'same',
|
|
characterAnimationFrameCount: 32,
|
|
characterAnimationDurationSeconds: 4,
|
|
placeholder: {
|
|
x: 80,
|
|
y: 60,
|
|
width: 420,
|
|
height: 420,
|
|
originalWidth: 1024,
|
|
originalHeight: 1024,
|
|
},
|
|
}}
|
|
initialCharacterAnimationPanel={{
|
|
sourceLayerId: 'character-layer',
|
|
promptText: ' 挥手动作 ',
|
|
resolution: '480p',
|
|
ratio: 'same',
|
|
frameCount: 32,
|
|
durationSeconds: 4,
|
|
status: 'idle',
|
|
}}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
|
|
fireEvent.click(screen.getByRole('button', { name: '提交角色动画' }));
|
|
|
|
await waitFor(() => {
|
|
expect(generateEditorCharacterAnimationMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
sourceLayerId: 'character-layer',
|
|
sourceImageSrc: 'generated/character.png',
|
|
promptText: '挥手动作',
|
|
model: 'seedance2.0-fast',
|
|
}),
|
|
);
|
|
});
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('layers').textContent).toContain(
|
|
'layer-character-animation-1:角色动作:resource-source:character-animation:image-sequence:data:image/png;base64,action-frame:data:image/png;base64,action-frame:4',
|
|
);
|
|
});
|
|
expect(screen.getByTestId('dialog').textContent).toBe(
|
|
'character-animation:idle:open:layer-character-animation-1:placeholder:-',
|
|
);
|
|
expect(screen.getByTestId('selected').textContent).toBe(
|
|
'layer-character-animation-1',
|
|
);
|
|
});
|
|
|
|
it('uses a fallback error message for failed character animation submissions', async () => {
|
|
generateEditorCharacterAnimationMock.mockRejectedValueOnce(new Error(''));
|
|
render(
|
|
<SubmissionWorkflowHarness
|
|
initialLayers={[createLayer({ id: 'character-layer' })]}
|
|
initialCharacterAnimationPanel={{
|
|
sourceLayerId: 'character-layer',
|
|
promptText: '',
|
|
resolution: '480p',
|
|
ratio: 'same',
|
|
frameCount: 32,
|
|
durationSeconds: 4,
|
|
status: 'idle',
|
|
}}
|
|
/>,
|
|
);
|
|
|
|
await act(async () => {
|
|
fireEvent.click(screen.getByRole('button', { name: '提交角色动画' }));
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('character-animation').textContent).toBe(
|
|
'character-layer:failed:-:生成角色动画失败:-',
|
|
);
|
|
});
|
|
});
|
|
});
|