308d440816
新抠图算法(轻量版)上线,生图背景色同步改为多颜色+自动决策 --------- Co-authored-by: kdletters <kdletters@qq.com> Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/73 Reviewed-by: kdletters <kdletters@qq.com> Co-authored-by: Linghong <ink29535@proton.me> Co-committed-by: Linghong <ink29535@proton.me>
2426 lines
79 KiB
TypeScript
2426 lines
79 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,
|
||
CanvasLayer,
|
||
CanvasTool,
|
||
CharacterAnimationPanelState,
|
||
GenerateDialogState,
|
||
QuickEditPanelState,
|
||
SidebarPanel,
|
||
} from './ImageCanvasEditorTypes';
|
||
import { createQuickEditPanelDraft } from './ImageCanvasGenerationDialogModel';
|
||
import type { UiAssetExtractionState } from './ImageCanvasUiAssetExtractionModel';
|
||
import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs';
|
||
import { useImageCanvasGenerationSubmissionWorkflow } from './useImageCanvasGenerationSubmissionWorkflow';
|
||
|
||
const resolveEditorImageReferenceDataUrlMock = vi.hoisted(() => vi.fn());
|
||
const resolveEditorImageReferenceDataUrlForGenerationMock = vi.hoisted(() =>
|
||
vi.fn(),
|
||
);
|
||
const editEditorImageMock = vi.hoisted(() => vi.fn());
|
||
const extractEditorUiDesignAssetsMock = vi.hoisted(() => vi.fn());
|
||
const renderUiDesignAssetExtractionMarkedImageMock = vi.hoisted(() => vi.fn());
|
||
const generateEditorCharacterAnimationMock = vi.hoisted(() => vi.fn());
|
||
const generateEditorIconSpritesheetMock = vi.hoisted(() => vi.fn());
|
||
const generateEditorImageMock = vi.hoisted(() => vi.fn());
|
||
const generateEditorVideoMock = vi.hoisted(() => vi.fn());
|
||
const generateEditorSoundEffectMock = vi.hoisted(() => vi.fn());
|
||
const generateEditorBackgroundMusicMock = vi.hoisted(() => vi.fn());
|
||
const loadEditorProjectMock = vi.hoisted(() => vi.fn());
|
||
const getExternalGenerationJobStatusMock = vi.hoisted(() => vi.fn());
|
||
|
||
vi.mock('../../services/image-editor/editorImageReference', () => ({
|
||
resolveEditorImageReferenceDataUrl: resolveEditorImageReferenceDataUrlMock,
|
||
resolveEditorImageReferenceDataUrlForGeneration:
|
||
resolveEditorImageReferenceDataUrlForGenerationMock,
|
||
}));
|
||
|
||
vi.mock('../../services/image-editor/editorProjectClient', async () => {
|
||
const actual = await vi.importActual<
|
||
typeof import('../../services/image-editor/editorProjectClient')
|
||
>('../../services/image-editor/editorProjectClient');
|
||
return {
|
||
...actual,
|
||
editEditorImage: editEditorImageMock,
|
||
extractEditorUiDesignAssets: extractEditorUiDesignAssetsMock,
|
||
generateEditorCharacterAnimation: generateEditorCharacterAnimationMock,
|
||
generateEditorIconSpritesheet: generateEditorIconSpritesheetMock,
|
||
generateEditorImage: generateEditorImageMock,
|
||
generateEditorVideo: generateEditorVideoMock,
|
||
generateEditorSoundEffect: generateEditorSoundEffectMock,
|
||
generateEditorBackgroundMusic: generateEditorBackgroundMusicMock,
|
||
loadEditorProject: loadEditorProjectMock,
|
||
};
|
||
});
|
||
|
||
vi.mock('../../services/external-generation', () => ({
|
||
getExternalGenerationJobStatus: getExternalGenerationJobStatusMock,
|
||
}));
|
||
|
||
vi.mock('./ImageCanvasUiAssetExtractionRasterModel', () => ({
|
||
renderUiDesignAssetExtractionMarkedImage:
|
||
renderUiDesignAssetExtractionMarkedImageMock,
|
||
}));
|
||
|
||
function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
|
||
return {
|
||
id: 'layer-source',
|
||
resourceId: 'resource-source',
|
||
title: '源图',
|
||
src: 'https://assets.example.test/source.png',
|
||
x: 120,
|
||
y: 140,
|
||
width: 320,
|
||
height: 240,
|
||
originalWidth: 1024,
|
||
originalHeight: 768,
|
||
zIndex: 2,
|
||
sourceType: 'uploaded',
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
function createGenerated(overrides = {}) {
|
||
return {
|
||
imageSrc: 'data:image/png;base64,generated',
|
||
width: 1024,
|
||
height: 1024,
|
||
sourceType: 'generated' as const,
|
||
prompt: '生成提示词',
|
||
actualPrompt: '生成提示词',
|
||
model: 'gpt-image-2',
|
||
provider: 'VectorEngine',
|
||
taskId: 'task-generated',
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
function createVideoGenerated(overrides = {}) {
|
||
return {
|
||
videoSrc: 'https://assets.example.test/generated-video.mp4',
|
||
width: 854,
|
||
height: 480,
|
||
sourceType: 'generated' as const,
|
||
prompt: '视频提示词',
|
||
actualPrompt: '视频提示词',
|
||
model: 'seedance2.0-fast',
|
||
provider: 'ark',
|
||
taskId: 'task-video-generated',
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
function createAudioGenerated(overrides = {}) {
|
||
return {
|
||
audioSrc: 'https://assets.example.test/generated-audio.mp3',
|
||
width: 420,
|
||
height: 120,
|
||
sourceType: 'generated' as const,
|
||
prompt: '金币掉落叮当声',
|
||
actualPrompt: '金币掉落叮当声',
|
||
model: 'audio1.0',
|
||
provider: 'VectorEngine',
|
||
taskId: 'task-audio-generated',
|
||
priceMudPoints: 5,
|
||
audioKind: 'sound-effect' as const,
|
||
durationSeconds: 7,
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
function createIconResult(name: string, imageSrc: string) {
|
||
return {
|
||
name,
|
||
imageSrc,
|
||
width: 128,
|
||
height: 128,
|
||
};
|
||
}
|
||
|
||
function createQueueState(
|
||
overrides: Partial<{
|
||
operationId: string;
|
||
status: 'queued' | 'running' | 'completed' | 'failed';
|
||
phaseLabel: string;
|
||
phaseDetail: string;
|
||
progress: number;
|
||
error: string | null;
|
||
updatedAtMicros: number;
|
||
}> = {},
|
||
) {
|
||
return {
|
||
operationId: 'editor-generation-operation-1',
|
||
status: 'completed' as const,
|
||
phaseLabel: '完成',
|
||
phaseDetail: '',
|
||
progress: 1,
|
||
error: null,
|
||
updatedAtMicros: 1,
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
function SubmissionWorkflowHarness({
|
||
initialDialog = null,
|
||
initialQuickEditPanel = null,
|
||
initialQuickEditSelectionState = null,
|
||
initialCharacterAnimationPanel = null,
|
||
initialLayers = [createLayer()],
|
||
projectId = undefined,
|
||
upsertGeneratedAsset = undefined,
|
||
applyProjectSnapshot = undefined,
|
||
onQueuedGenerationTask = undefined,
|
||
onWalletBalanceMayHaveChanged = undefined,
|
||
}: {
|
||
initialDialog?: GenerateDialogState | null;
|
||
initialQuickEditPanel?: QuickEditPanelState | null;
|
||
initialQuickEditSelectionState?: UiAssetExtractionState | null;
|
||
initialCharacterAnimationPanel?: CharacterAnimationPanelState | null;
|
||
initialLayers?: CanvasLayer[];
|
||
projectId?: string | null;
|
||
upsertGeneratedAsset?: Parameters<
|
||
typeof useImageCanvasGenerationSubmissionWorkflow
|
||
>[0]['upsertGeneratedAsset'];
|
||
applyProjectSnapshot?: Parameters<
|
||
typeof useImageCanvasGenerationSubmissionWorkflow
|
||
>[0]['applyProjectSnapshot'];
|
||
onQueuedGenerationTask?: Parameters<
|
||
typeof useImageCanvasGenerationSubmissionWorkflow
|
||
>[0]['onQueuedGenerationTask'];
|
||
onWalletBalanceMayHaveChanged?: Parameters<
|
||
typeof useImageCanvasGenerationSubmissionWorkflow
|
||
>[0]['onWalletBalanceMayHaveChanged'];
|
||
}) {
|
||
const [layers, setLayers] = useState<CanvasLayer[]>(initialLayers);
|
||
const [activeTool, setActiveTool] = useState<CanvasTool>('generate');
|
||
const [activeSidebarPanel, setActiveSidebarPanel] =
|
||
useState<SidebarPanel | null>('assets');
|
||
const [selectedLayerId, setSelectedLayerId] = useState<string | null>(null);
|
||
const [quickEditPanel, setQuickEditPanel] =
|
||
useState<QuickEditPanelState | null>(initialQuickEditPanel);
|
||
const [characterAnimationPanel, setCharacterAnimationPanel] =
|
||
useState<CharacterAnimationPanelState | null>(
|
||
initialCharacterAnimationPanel,
|
||
);
|
||
const rememberedImageModelRef = useRef<string | null>(null);
|
||
const fitLayersMockRef = useRef(vi.fn());
|
||
const layerCounterRef = useRef(0);
|
||
const dialogs = useCanvasGenerationDialogs();
|
||
|
||
const sourceLayer = layers[0] ?? null;
|
||
const quickEditSourceLayer = quickEditPanel
|
||
? (layers.find((layer) => layer.id === quickEditPanel.sourceLayerId) ??
|
||
null)
|
||
: null;
|
||
const characterAnimationSourceLayer = characterAnimationPanel
|
||
? (layers.find(
|
||
(layer) => layer.id === characterAnimationPanel.sourceLayerId,
|
||
) ?? null)
|
||
: null;
|
||
const activeDialog = dialogs.generateDialog;
|
||
const activeCanvasDialog =
|
||
activeDialog && 'id' in activeDialog
|
||
? (activeDialog as CanvasGenerationDialogState)
|
||
: null;
|
||
|
||
const workflow = useImageCanvasGenerationSubmissionWorkflow({
|
||
layers,
|
||
canvasSize: { width: 900, height: 640 },
|
||
viewport: { x: 10, y: 20, scale: 2 },
|
||
layerCounterRef,
|
||
quickEditPanel,
|
||
quickEditSourceLayer,
|
||
quickEditSelectionState: initialQuickEditSelectionState,
|
||
canvasGenerationDialogs: dialogs.canvasGenerationDialogs,
|
||
setQuickEditPanel,
|
||
characterAnimationPanel,
|
||
characterAnimationDialog: activeCanvasDialog,
|
||
characterAnimationSourceLayer,
|
||
setCharacterAnimationPanel,
|
||
setGenerateDialog: dialogs.setGenerateDialog,
|
||
openCanvasGenerationDialog: dialogs.openCanvasGenerationDialog,
|
||
updateCanvasGenerationDialogById: dialogs.updateCanvasGenerationDialogById,
|
||
hasCanvasGenerationDialogById: dialogs.hasCanvasGenerationDialogById,
|
||
getGeneratingDialogPlaceholder: dialogs.getGeneratingDialogPlaceholder,
|
||
appendCanvasLayersWithResources: (nextLayers) =>
|
||
setLayers((currentLayers) => [...currentLayers, ...nextLayers]),
|
||
updateSourceLayer: (sourceLayerId, updater) =>
|
||
setLayers((currentLayers) =>
|
||
currentLayers.map((layer) =>
|
||
layer.id === sourceLayerId ? updater(layer) : layer,
|
||
),
|
||
),
|
||
selectSingleLayer: setSelectedLayerId,
|
||
fitLayers: fitLayersMockRef.current,
|
||
setActiveTool,
|
||
setActiveSidebarPanel,
|
||
rememberImageModel: (imageModel) => {
|
||
rememberedImageModelRef.current = imageModel;
|
||
},
|
||
projectId,
|
||
assetFolderId: projectId ? 'project' : undefined,
|
||
upsertGeneratedAsset,
|
||
applyProjectSnapshot,
|
||
onQueuedGenerationTask,
|
||
onWalletBalanceMayHaveChanged,
|
||
});
|
||
|
||
return (
|
||
<div>
|
||
<span data-testid="tool">{activeTool}</span>
|
||
<span data-testid="sidebar">{activeSidebarPanel ?? '-'}</span>
|
||
<span data-testid="selected">{selectedLayerId ?? '-'}</span>
|
||
<span data-testid="layers">
|
||
{layers
|
||
.map(
|
||
(layer) =>
|
||
`${layer.id}:${layer.title}:${layer.sourceResourceId ?? '-'}:${layer.assetKind ?? '-'}:${layer.mediaType ?? '-'}:${layer.src}:${layer.thumbnailSrc ?? '-'}:${layer.durationSeconds ?? '-'}`,
|
||
)
|
||
.join('|')}
|
||
</span>
|
||
<span data-testid="layer-rects">
|
||
{layers
|
||
.map(
|
||
(layer) =>
|
||
`${layer.id}:${layer.x}:${layer.y}:${layer.width}:${layer.height}`,
|
||
)
|
||
.join('|')}
|
||
</span>
|
||
<span data-testid="dialog">
|
||
{activeDialog
|
||
? `${activeDialog.mode}:${activeDialog.status}:${activeDialog.composerOpen !== false ? 'open' : 'closed'}:${activeDialog.generatedLayerId ?? '-'}:${activeDialog.placeholder ? 'placeholder' : '-'}:${activeDialog.errorMessage ?? '-'}`
|
||
: '-'}
|
||
</span>
|
||
<span data-testid="quick-edit">
|
||
{quickEditPanel
|
||
? `${quickEditPanel.sourceLayerId}:${quickEditPanel.status}:${quickEditPanel.prompt || '-'}:${quickEditPanel.errorMessage ?? '-'}`
|
||
: '-'}
|
||
</span>
|
||
<span data-testid="character-animation">
|
||
{characterAnimationPanel
|
||
? `${characterAnimationPanel.sourceLayerId}:${characterAnimationPanel.status}:${characterAnimationPanel.promptText || '-'}:${characterAnimationPanel.errorMessage ?? '-'}:${characterAnimationPanel.result ? 'result' : '-'}`
|
||
: '-'}
|
||
</span>
|
||
<span data-testid="fit-count">
|
||
{fitLayersMockRef.current.mock.calls.length}
|
||
</span>
|
||
<span data-testid="remembered-model">
|
||
{rememberedImageModelRef.current ?? '-'}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
if (initialDialog) {
|
||
dialogs.setGenerateDialog(initialDialog);
|
||
}
|
||
}}
|
||
>
|
||
设置初始对话
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
if (sourceLayer) {
|
||
setQuickEditPanel(createQuickEditPanelDraft(sourceLayer));
|
||
}
|
||
}}
|
||
>
|
||
打开快速编辑
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
setQuickEditPanel((currentPanel) =>
|
||
currentPanel ? { ...currentPanel, prompt: ' 快速修图 ' } : null,
|
||
)
|
||
}
|
||
>
|
||
填写快速编辑
|
||
</button>
|
||
<button type="button" onClick={() => void workflow.submitQuickEdit()}>
|
||
提交快速编辑
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
if (activeDialog) {
|
||
void workflow.submitImageGeneration(activeDialog);
|
||
}
|
||
}}
|
||
>
|
||
提交当前生成
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
if (activeDialog) {
|
||
void workflow.submitIconSpritesheetGeneration(activeDialog);
|
||
}
|
||
}}
|
||
>
|
||
提交图标
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
if (sourceLayer) {
|
||
void workflow.extractUiDesignAssets(sourceLayer);
|
||
}
|
||
}}
|
||
>
|
||
提取UI素材
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
if (sourceLayer) {
|
||
void workflow.extractUiDesignAssets(sourceLayer, {
|
||
marks: Array.from({ length: 7 }, (_, index) => ({
|
||
id: `mark-${index + 1}`,
|
||
tool: 'rect' as const,
|
||
x: 10 + index * 8,
|
||
y: 20 + index * 6,
|
||
width: 80,
|
||
height: 60,
|
||
})),
|
||
});
|
||
}
|
||
}}
|
||
>
|
||
提取已框选UI素材
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
if (sourceLayer) {
|
||
void workflow.extractUiDesignAssets(sourceLayer, {
|
||
marks: [
|
||
{
|
||
id: 'mark-ref',
|
||
tool: 'rect',
|
||
x: 10,
|
||
y: 20,
|
||
width: 80,
|
||
height: 60,
|
||
},
|
||
],
|
||
references: [
|
||
{
|
||
id: 'ref-a',
|
||
label: '风格参考',
|
||
src: '/generated/ref-fallback.png',
|
||
objectKey: 'generated/ref-style.png',
|
||
resourceId: 'resource-style-ref',
|
||
},
|
||
],
|
||
});
|
||
}
|
||
}}
|
||
>
|
||
提取带参考图UI素材
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => void workflow.submitCharacterAnimation()}
|
||
>
|
||
提交角色动画
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
if (activeCanvasDialog) {
|
||
dialogs.updateCanvasGenerationDialogById(
|
||
activeCanvasDialog.id,
|
||
(dialog) => ({
|
||
...dialog,
|
||
placeholder: dialog.placeholder
|
||
? { ...dialog.placeholder, x: 500, y: 400 }
|
||
: dialog.placeholder,
|
||
}),
|
||
);
|
||
}
|
||
}}
|
||
>
|
||
移动占位
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||
beforeEach(() => {
|
||
resolveEditorImageReferenceDataUrlMock.mockReset();
|
||
resolveEditorImageReferenceDataUrlForGenerationMock.mockReset();
|
||
editEditorImageMock.mockReset();
|
||
extractEditorUiDesignAssetsMock.mockReset();
|
||
renderUiDesignAssetExtractionMarkedImageMock.mockReset();
|
||
generateEditorCharacterAnimationMock.mockReset();
|
||
generateEditorIconSpritesheetMock.mockReset();
|
||
generateEditorImageMock.mockReset();
|
||
generateEditorVideoMock.mockReset();
|
||
generateEditorSoundEffectMock.mockReset();
|
||
generateEditorBackgroundMusicMock.mockReset();
|
||
loadEditorProjectMock.mockReset();
|
||
getExternalGenerationJobStatusMock.mockReset();
|
||
resolveEditorImageReferenceDataUrlMock.mockImplementation(
|
||
async (src: string) => `data:image/png;base64,${src.split('/').pop()}`,
|
||
);
|
||
resolveEditorImageReferenceDataUrlForGenerationMock.mockImplementation(
|
||
async (src: string) => src,
|
||
);
|
||
renderUiDesignAssetExtractionMarkedImageMock.mockResolvedValue(
|
||
'data:image/png;base64,marked-ui-design',
|
||
);
|
||
});
|
||
|
||
it('submits quick edits and updates the source layer directly', async () => {
|
||
editEditorImageMock.mockResolvedValueOnce(
|
||
createGenerated({ prompt: '快速修图' }),
|
||
);
|
||
render(<SubmissionWorkflowHarness />);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '打开快速编辑' }));
|
||
fireEvent.click(screen.getByRole('button', { name: '填写快速编辑' }));
|
||
fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' }));
|
||
|
||
expect(screen.getByTestId('quick-edit').textContent).toBe(
|
||
'layer-source:generating:快速修图:-',
|
||
);
|
||
expect(screen.getByTestId('dialog').textContent).toBe('-');
|
||
await waitFor(() => {
|
||
expect(editEditorImageMock).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
prompt: '快速修图',
|
||
sourceImageSrc: 'data:image/png;base64,source.png',
|
||
size: '1024x768',
|
||
model: 'gpt-image-2',
|
||
}),
|
||
);
|
||
});
|
||
expect(editEditorImageMock.mock.calls[0]?.[0]).not.toHaveProperty(
|
||
'canvasCompletion',
|
||
);
|
||
expect(generateEditorImageMock).not.toHaveBeenCalled();
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId('layers').textContent).toContain(
|
||
'layer-source:源图:resource-source:-:-:data:image/png;base64,generated',
|
||
);
|
||
});
|
||
expect(screen.getByTestId('quick-edit').textContent).toBe('-');
|
||
expect(screen.getByTestId('tool').textContent).toBe('select');
|
||
expect(screen.getByTestId('sidebar').textContent).toBe('layers');
|
||
expect(screen.getByTestId('selected').textContent).toBe('-');
|
||
expect(screen.getByTestId('fit-count').textContent).toBe('0');
|
||
});
|
||
|
||
it('clears quick edit panel after a queued edit reloads the project snapshot', async () => {
|
||
const applyProjectSnapshot = vi.fn();
|
||
const refreshTaskList = vi.fn();
|
||
const backendProject = {
|
||
projectId: 'editor-project-1',
|
||
title: '队列修图项目',
|
||
viewport: { x: 0, y: 0, scale: 1 },
|
||
layers: [
|
||
{
|
||
...createLayer(),
|
||
src: 'data:image/png;base64,queued-generated',
|
||
},
|
||
],
|
||
resources: [],
|
||
updatedAt: '2026-06-23T08:00:00.000Z',
|
||
};
|
||
loadEditorProjectMock.mockResolvedValueOnce(backendProject);
|
||
editEditorImageMock.mockResolvedValueOnce({
|
||
...createGenerated({ prompt: '快速修图' }),
|
||
queueState: createQueueState(),
|
||
});
|
||
render(
|
||
<SubmissionWorkflowHarness
|
||
projectId="editor-project-1"
|
||
applyProjectSnapshot={applyProjectSnapshot}
|
||
onQueuedGenerationTask={refreshTaskList}
|
||
/>,
|
||
);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '打开快速编辑' }));
|
||
fireEvent.click(screen.getByRole('button', { name: '填写快速编辑' }));
|
||
fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' }));
|
||
|
||
await waitFor(() => {
|
||
expect(loadEditorProjectMock).toHaveBeenCalledWith('editor-project-1');
|
||
});
|
||
expect(refreshTaskList).toHaveBeenCalledTimes(1);
|
||
expect(applyProjectSnapshot).toHaveBeenCalledWith(backendProject);
|
||
expect(screen.getByTestId('quick-edit').textContent).toBe('-');
|
||
expect(screen.getByTestId('tool').textContent).toBe('select');
|
||
expect(screen.getByTestId('sidebar').textContent).toBe('layers');
|
||
});
|
||
|
||
it('submits quick-edit canvas generators with the source image reference', async () => {
|
||
editEditorImageMock.mockResolvedValueOnce(
|
||
createGenerated({ prompt: '把当前图改成雨天' }),
|
||
);
|
||
render(
|
||
<SubmissionWorkflowHarness
|
||
initialDialog={{
|
||
id: 'generation-dialog-quick-edit',
|
||
mode: 'quick-edit',
|
||
prompt: '把当前图改成雨天',
|
||
status: 'idle',
|
||
composerOpen: true,
|
||
sourceLayerId: 'layer-source',
|
||
imageModel: 'gpt-image-2',
|
||
aspectRatio: '4:3',
|
||
imageSize: '1K',
|
||
placeholder: {
|
||
x: 472,
|
||
y: 140,
|
||
width: 1024,
|
||
height: 768,
|
||
originalWidth: 1024,
|
||
originalHeight: 768,
|
||
},
|
||
}}
|
||
/>,
|
||
);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
|
||
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
|
||
|
||
await waitFor(() => {
|
||
expect(editEditorImageMock).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
prompt: '把当前图改成雨天',
|
||
sourceImageSrc: 'data:image/png;base64,source.png',
|
||
size: '1024x768',
|
||
model: 'gpt-image-2',
|
||
sourceResourceId: 'resource-source',
|
||
targetLayerId: 'layer-source',
|
||
assetLabel: '源图 快速编辑',
|
||
}),
|
||
);
|
||
});
|
||
expect(generateEditorImageMock).not.toHaveBeenCalled();
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId('layers').textContent).toContain(
|
||
'layer-source:源图:resource-source',
|
||
);
|
||
});
|
||
expect(screen.getByTestId('quick-edit').textContent).toBe('-');
|
||
});
|
||
|
||
it('refreshes the wallet balance after inline image generation succeeds', async () => {
|
||
const refreshWalletBalance = vi.fn();
|
||
editEditorImageMock.mockResolvedValueOnce(
|
||
createGenerated({ prompt: '快速修图' }),
|
||
);
|
||
render(
|
||
<SubmissionWorkflowHarness
|
||
onWalletBalanceMayHaveChanged={refreshWalletBalance}
|
||
/>,
|
||
);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '打开快速编辑' }));
|
||
fireEvent.click(screen.getByRole('button', { name: '填写快速编辑' }));
|
||
fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' }));
|
||
|
||
await waitFor(() => {
|
||
expect(editEditorImageMock).toHaveBeenCalledTimes(1);
|
||
});
|
||
await waitFor(() => {
|
||
expect(refreshWalletBalance).toHaveBeenCalledTimes(1);
|
||
});
|
||
});
|
||
|
||
it('keeps quick edit results on the source layer when nearby assets exist', async () => {
|
||
const blockingLayer = createLayer({
|
||
id: 'layer-blocker',
|
||
resourceId: 'resource-blocker',
|
||
title: '右侧已有素材',
|
||
x: 472,
|
||
y: 140,
|
||
width: 1024,
|
||
height: 768,
|
||
originalWidth: 1024,
|
||
originalHeight: 768,
|
||
zIndex: 3,
|
||
});
|
||
editEditorImageMock.mockResolvedValueOnce(
|
||
createGenerated({ prompt: '快速修图', width: 1024, height: 768 }),
|
||
);
|
||
render(
|
||
<SubmissionWorkflowHarness
|
||
initialLayers={[createLayer(), blockingLayer]}
|
||
/>,
|
||
);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '打开快速编辑' }));
|
||
fireEvent.click(screen.getByRole('button', { name: '填写快速编辑' }));
|
||
fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' }));
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId('layers').textContent).toContain(
|
||
'layer-source:源图:resource-source:-:-:data:image/png;base64,generated',
|
||
);
|
||
});
|
||
expect(screen.getByTestId('layers').textContent).not.toContain(
|
||
'layer-quick-edit-1',
|
||
);
|
||
});
|
||
|
||
it('submits quick edits with the numbered red selection image as the source image', async () => {
|
||
editEditorImageMock.mockResolvedValueOnce(
|
||
createGenerated({
|
||
prompt: '对1号红色圈选框里的内容做以下修改:改成蓝色',
|
||
}),
|
||
);
|
||
render(
|
||
<SubmissionWorkflowHarness
|
||
initialQuickEditPanel={{
|
||
mode: 'quick-edit',
|
||
sourceLayerId: 'layer-source',
|
||
prompt: '对1号红色圈选框里的内容做以下修改:改成蓝色',
|
||
size: '1024x768',
|
||
model: 'gpt-image-2',
|
||
status: 'idle',
|
||
quickEditReferences: [
|
||
{
|
||
id: 'ref-style',
|
||
label: '风格参考',
|
||
src: 'https://assets.example.test/style.png',
|
||
},
|
||
],
|
||
}}
|
||
initialQuickEditSelectionState={{
|
||
sourceLayerId: 'layer-source',
|
||
tool: 'rect',
|
||
model: 'gemini-3.1-flash-image-preview',
|
||
marks: [
|
||
{
|
||
id: 'quick-edit-selection-mark-1',
|
||
tool: 'rect',
|
||
x: 24,
|
||
y: 36,
|
||
width: 160,
|
||
height: 120,
|
||
},
|
||
],
|
||
references: [],
|
||
draftMark: null,
|
||
status: 'idle',
|
||
}}
|
||
/>,
|
||
);
|
||
renderUiDesignAssetExtractionMarkedImageMock.mockResolvedValueOnce(
|
||
'data:image/png;base64,marked-source',
|
||
);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' }));
|
||
|
||
await waitFor(() => {
|
||
expect(renderUiDesignAssetExtractionMarkedImageMock).toHaveBeenCalledWith(
|
||
{
|
||
source: 'data:image/png;base64,source.png',
|
||
marks: [
|
||
{
|
||
id: 'quick-edit-selection-mark-1',
|
||
tool: 'rect',
|
||
x: 24,
|
||
y: 36,
|
||
width: 160,
|
||
height: 120,
|
||
},
|
||
],
|
||
showIndexLabels: true,
|
||
},
|
||
);
|
||
});
|
||
await waitFor(() => {
|
||
expect(editEditorImageMock).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
sourceImageSrc: 'data:image/png;base64,marked-source',
|
||
targetLayerId: 'layer-source',
|
||
}),
|
||
);
|
||
});
|
||
expect(editEditorImageMock.mock.calls[0]?.[0]).not.toHaveProperty(
|
||
'referenceImageSrcs',
|
||
);
|
||
expect(generateEditorImageMock).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('submits canvas quick edits with numbered selection marks', async () => {
|
||
editEditorImageMock.mockResolvedValueOnce(
|
||
createGenerated({
|
||
prompt: '对1号红色圈选框里的内容做以下修改:改成蓝色',
|
||
}),
|
||
);
|
||
render(
|
||
<SubmissionWorkflowHarness
|
||
initialDialog={{
|
||
id: 'generation-dialog-quick-edit-marked',
|
||
mode: 'quick-edit',
|
||
prompt: '对1号红色圈选框里的内容做以下修改:改成蓝色',
|
||
status: 'idle',
|
||
composerOpen: true,
|
||
sourceLayerId: 'layer-source',
|
||
imageModel: 'gpt-image-2',
|
||
aspectRatio: '4:3',
|
||
imageSize: '1K',
|
||
placeholder: {
|
||
x: 472,
|
||
y: 140,
|
||
width: 1024,
|
||
height: 768,
|
||
originalWidth: 1024,
|
||
originalHeight: 768,
|
||
},
|
||
}}
|
||
initialQuickEditSelectionState={{
|
||
sourceLayerId: 'layer-source',
|
||
tool: 'rect',
|
||
model: 'gemini-3.1-flash-image-preview',
|
||
marks: [
|
||
{
|
||
id: 'quick-edit-selection-mark-1',
|
||
tool: 'rect',
|
||
x: 24,
|
||
y: 36,
|
||
width: 160,
|
||
height: 120,
|
||
},
|
||
],
|
||
references: [],
|
||
draftMark: null,
|
||
status: 'idle',
|
||
}}
|
||
/>,
|
||
);
|
||
renderUiDesignAssetExtractionMarkedImageMock.mockResolvedValueOnce(
|
||
'data:image/png;base64,marked-source',
|
||
);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
|
||
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
|
||
|
||
await waitFor(() => {
|
||
expect(renderUiDesignAssetExtractionMarkedImageMock).toHaveBeenCalledWith(
|
||
{
|
||
source: 'data:image/png;base64,source.png',
|
||
marks: [
|
||
{
|
||
id: 'quick-edit-selection-mark-1',
|
||
tool: 'rect',
|
||
x: 24,
|
||
y: 36,
|
||
width: 160,
|
||
height: 120,
|
||
},
|
||
],
|
||
showIndexLabels: true,
|
||
},
|
||
);
|
||
});
|
||
await waitFor(() => {
|
||
expect(editEditorImageMock).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
sourceImageSrc: 'data:image/png;base64,marked-source',
|
||
model: 'gpt-image-2',
|
||
targetLayerId: 'layer-source',
|
||
}),
|
||
);
|
||
});
|
||
});
|
||
|
||
it('keeps failed quick edit panels visible with the normalized prompt', async () => {
|
||
const refreshWalletBalance = vi.fn();
|
||
editEditorImageMock.mockRejectedValueOnce(new Error('修图失败'));
|
||
render(
|
||
<SubmissionWorkflowHarness
|
||
onWalletBalanceMayHaveChanged={refreshWalletBalance}
|
||
initialQuickEditPanel={{
|
||
sourceLayerId: 'layer-source',
|
||
prompt: '',
|
||
size: '1024x768',
|
||
model: 'gpt-image-2',
|
||
status: 'idle',
|
||
}}
|
||
/>,
|
||
);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' }));
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId('quick-edit').textContent).toBe(
|
||
'layer-source:failed:快速编辑图片:修图失败',
|
||
);
|
||
});
|
||
expect(refreshWalletBalance).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it('submits character redraws through the character image prompt contract', async () => {
|
||
generateEditorImageMock.mockResolvedValueOnce(
|
||
createGenerated({ prompt: '角色换成蓝色披风' }),
|
||
);
|
||
render(
|
||
<SubmissionWorkflowHarness
|
||
initialLayers={[
|
||
createLayer({
|
||
assetKind: 'character',
|
||
prompt: '原始角色设定',
|
||
}),
|
||
]}
|
||
initialQuickEditPanel={{
|
||
mode: 'redraw',
|
||
sourceLayerId: 'layer-source',
|
||
prompt: ' 角色换成蓝色披风 ',
|
||
size: '1024x768',
|
||
model: 'gpt-image-2',
|
||
status: 'idle',
|
||
}}
|
||
/>,
|
||
);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' }));
|
||
|
||
await waitFor(() => {
|
||
expect(generateEditorImageMock).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
prompt: '角色换成蓝色披风',
|
||
size: '1024x768',
|
||
aspectRatio: '4:3',
|
||
imageSize: '1K',
|
||
kind: 'character',
|
||
model: 'gpt-image-2',
|
||
referenceImageSrcs: ['data:image/png;base64,source.png'],
|
||
assetKind: 'character',
|
||
}),
|
||
);
|
||
});
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId('layers').textContent).toContain(
|
||
'layer-quick-edit-1:源图 重绘:resource-source:character',
|
||
);
|
||
});
|
||
expect(screen.getByTestId('quick-edit').textContent).toBe(
|
||
'layer-source:idle:角色换成蓝色披风:-',
|
||
);
|
||
});
|
||
|
||
it('submits video quick edits through the video API with the source video', async () => {
|
||
const applyProjectSnapshot = vi.fn();
|
||
const upsertGeneratedAsset = vi.fn();
|
||
generateEditorVideoMock.mockResolvedValueOnce(
|
||
createVideoGenerated({
|
||
prompt: '把当前图改成雨天',
|
||
project: {
|
||
projectId: 'editor-project-1',
|
||
title: '项目',
|
||
coverImageSrc: null,
|
||
viewport: { x: 0, y: 0, scale: 1 },
|
||
layers: [],
|
||
resources: [],
|
||
createdAt: '2026-06-27T00:00:00.000Z',
|
||
updatedAt: '2026-06-27T00:00:00.000Z',
|
||
},
|
||
asset: {
|
||
assetId: 'asset-video-quick-edit',
|
||
folderId: 'user-1:asset-folder:project',
|
||
label: '源视频 快速编辑',
|
||
imageSrc: 'https://assets.example.test/generated-video.mp4',
|
||
thumbnailSrc: 'https://assets.example.test/generated-video-cover.png',
|
||
width: 854,
|
||
height: 480,
|
||
sourceType: 'generated',
|
||
assetKind: 'video',
|
||
},
|
||
}),
|
||
);
|
||
render(
|
||
<SubmissionWorkflowHarness
|
||
projectId="editor-project-1"
|
||
applyProjectSnapshot={applyProjectSnapshot}
|
||
upsertGeneratedAsset={upsertGeneratedAsset}
|
||
initialLayers={[
|
||
createLayer({
|
||
title: '源视频',
|
||
src: 'https://assets.example.test/source.mp4',
|
||
objectKey: 'generated-character-drafts/editor/videos/source.mp4',
|
||
mediaType: 'video',
|
||
assetKind: 'video',
|
||
model: 'gpt-image-2',
|
||
originalWidth: 854,
|
||
originalHeight: 480,
|
||
width: 427,
|
||
height: 240,
|
||
}),
|
||
]}
|
||
initialQuickEditPanel={{
|
||
mode: 'quick-edit',
|
||
sourceLayerId: 'layer-source',
|
||
prompt: '把当前图改成雨天',
|
||
size: '854x480',
|
||
model: 'gpt-image-2',
|
||
status: 'idle',
|
||
quickEditReferences: [
|
||
{
|
||
id: 'ref-style',
|
||
label: '风格参考',
|
||
src: 'https://assets.example.test/style.png',
|
||
},
|
||
],
|
||
}}
|
||
/>,
|
||
);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' }));
|
||
|
||
await waitFor(() => {
|
||
expect(generateEditorVideoMock).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
prompt: '把当前图改成雨天',
|
||
model: 'seedance2.0-fast',
|
||
aspectRatio: '16:9',
|
||
durationSeconds: 4,
|
||
resolution: '480p',
|
||
mode: 'std',
|
||
sound: 'off',
|
||
referenceVideoSrcs: [
|
||
'generated-character-drafts/editor/videos/source.mp4',
|
||
],
|
||
assetFolderId: 'project',
|
||
assetLabel: '源视频 快速编辑',
|
||
}),
|
||
);
|
||
});
|
||
expect(generateEditorVideoMock.mock.calls[0]?.[0]).not.toHaveProperty(
|
||
'referenceImageSrcs',
|
||
);
|
||
expect(generateEditorImageMock).not.toHaveBeenCalled();
|
||
expect(generateEditorCharacterAnimationMock).not.toHaveBeenCalled();
|
||
await waitFor(() => {
|
||
expect(applyProjectSnapshot).toHaveBeenCalledWith(
|
||
expect.objectContaining({ projectId: 'editor-project-1' }),
|
||
);
|
||
expect(upsertGeneratedAsset).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
assetId: 'asset-video-quick-edit',
|
||
thumbnailSrc: 'https://assets.example.test/generated-video-cover.png',
|
||
}),
|
||
);
|
||
});
|
||
});
|
||
|
||
it('submits character action quick edits through image edits', async () => {
|
||
editEditorImageMock.mockResolvedValueOnce(
|
||
createGenerated({
|
||
prompt: '让当前图跳得更高',
|
||
imageSrc: 'data:image/png;base64,action-frame-edited',
|
||
}),
|
||
);
|
||
render(
|
||
<SubmissionWorkflowHarness
|
||
initialLayers={[
|
||
createLayer({
|
||
title: '角色动作',
|
||
src: 'generated-character-drafts/editor/actions/frame.png',
|
||
mediaType: 'image-sequence',
|
||
thumbnailSrc: 'generated-character-drafts/editor/actions/frame.png',
|
||
imageSequenceFrames: [
|
||
{
|
||
frameIndex: 1,
|
||
imageSrc: 'generated-character-drafts/editor/actions/frame.png',
|
||
width: 1024,
|
||
height: 1024,
|
||
},
|
||
],
|
||
previewVideoPath: 'https://assets.example.test/action-preview.mp4',
|
||
assetKind: 'character-animation',
|
||
model: 'seedance2.0',
|
||
originalWidth: 1024,
|
||
originalHeight: 1024,
|
||
width: 420,
|
||
height: 420,
|
||
}),
|
||
]}
|
||
initialQuickEditPanel={{
|
||
mode: 'quick-edit',
|
||
sourceLayerId: 'layer-source',
|
||
prompt: '让当前图跳得更高',
|
||
size: '1024x1024',
|
||
model: 'gpt-image-2',
|
||
status: 'idle',
|
||
}}
|
||
/>,
|
||
);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' }));
|
||
|
||
await waitFor(() => {
|
||
expect(editEditorImageMock).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
prompt: '让当前图跳得更高',
|
||
sourceImageSrc: 'data:image/png;base64,frame.png',
|
||
model: 'gpt-image-2',
|
||
assetLabel: '角色动作 快速编辑',
|
||
targetLayerId: 'layer-source',
|
||
}),
|
||
);
|
||
});
|
||
expect(generateEditorCharacterAnimationMock).not.toHaveBeenCalled();
|
||
expect(generateEditorImageMock).not.toHaveBeenCalled();
|
||
expect(generateEditorVideoMock).not.toHaveBeenCalled();
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId('layers').textContent).toContain(
|
||
'layer-source:角色动作:resource-source:character-animation:image-sequence:data:image/png;base64,action-frame-edited',
|
||
);
|
||
});
|
||
});
|
||
|
||
it('submits edit dialogs beside the source and clears the modal state', async () => {
|
||
editEditorImageMock.mockResolvedValueOnce(
|
||
createGenerated({ prompt: '修改当前图片' }),
|
||
);
|
||
render(
|
||
<SubmissionWorkflowHarness
|
||
initialDialog={{
|
||
mode: 'edit',
|
||
prompt: '',
|
||
status: 'idle',
|
||
sourceLayerId: 'layer-source',
|
||
}}
|
||
/>,
|
||
);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
|
||
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
|
||
|
||
expect(screen.getByTestId('dialog').textContent).toBe(
|
||
'edit:generating:open:-:-:-',
|
||
);
|
||
await waitFor(() => {
|
||
expect(editEditorImageMock).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
prompt: '修改当前图片',
|
||
sourceImageSrc: 'data:image/png;base64,source.png',
|
||
}),
|
||
);
|
||
});
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId('layers').textContent).toContain(
|
||
'layer-edit-1:源图 修改结果:resource-source:-',
|
||
);
|
||
});
|
||
expect(screen.getByTestId('dialog').textContent).toBe('-');
|
||
expect(screen.getByTestId('tool').textContent).toBe('select');
|
||
expect(screen.getByTestId('fit-count').textContent).toBe('1');
|
||
});
|
||
|
||
it('uses the latest moved canvas placeholder for local-only generation fallback', async () => {
|
||
generateEditorImageMock.mockResolvedValueOnce(
|
||
createGenerated({ width: 512, height: 512 }),
|
||
);
|
||
render(
|
||
<SubmissionWorkflowHarness
|
||
initialDialog={{
|
||
id: 'dialog-1',
|
||
mode: 'generate',
|
||
prompt: '占位生成',
|
||
status: 'idle',
|
||
composerOpen: true,
|
||
placeholder: {
|
||
x: 100,
|
||
y: 100,
|
||
width: 420,
|
||
height: 420,
|
||
originalWidth: 2048,
|
||
originalHeight: 2048,
|
||
},
|
||
}}
|
||
/>,
|
||
);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
|
||
fireEvent.click(screen.getByRole('button', { name: '移动占位' }));
|
||
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId('layers').textContent).toContain(
|
||
'layer-generated-1:生成图片 1:-:-',
|
||
);
|
||
});
|
||
expect(screen.getByTestId('dialog').textContent).toBe(
|
||
'generate:idle:open:layer-generated-1:placeholder:-',
|
||
);
|
||
});
|
||
|
||
it('passes canvas completion context and refreshes from the backend project snapshot', async () => {
|
||
const applyProjectSnapshot = vi.fn();
|
||
generateEditorImageMock.mockResolvedValueOnce({
|
||
...createGenerated({
|
||
width: 1024,
|
||
height: 1024,
|
||
prompt: '银发游侠',
|
||
}),
|
||
project: {
|
||
projectId: 'editor-project-1',
|
||
title: '角色项目',
|
||
viewport: { x: 12, y: 24, scale: 1.5 },
|
||
layers: [
|
||
{
|
||
layerId: 'layer-editor-resource-character',
|
||
resourceId: 'editor-resource-character',
|
||
},
|
||
],
|
||
resources: [],
|
||
updatedAt: '2026-06-21T12:16:22.000Z',
|
||
},
|
||
});
|
||
render(
|
||
<SubmissionWorkflowHarness
|
||
projectId="editor-project-1"
|
||
applyProjectSnapshot={applyProjectSnapshot}
|
||
initialDialog={{
|
||
id: 'dialog-character',
|
||
mode: 'character',
|
||
prompt: ' 银发游侠 ',
|
||
status: 'idle',
|
||
composerOpen: true,
|
||
imageModel: 'gpt-image-2',
|
||
placeholder: {
|
||
x: 200,
|
||
y: 160,
|
||
width: 420,
|
||
height: 560,
|
||
originalWidth: 1024,
|
||
originalHeight: 1536,
|
||
},
|
||
}}
|
||
/>,
|
||
);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
|
||
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
|
||
|
||
await waitFor(() => {
|
||
expect(generateEditorImageMock).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
prompt: '银发游侠',
|
||
kind: 'character',
|
||
projectId: 'editor-project-1',
|
||
assetKind: 'character',
|
||
assetFolderId: 'project',
|
||
canvasCompletion: {
|
||
dialogId: 'dialog-character',
|
||
title: '角色形象 1',
|
||
placeholder: {
|
||
x: 200,
|
||
y: 160,
|
||
width: 420,
|
||
height: 560,
|
||
originalWidth: 1024,
|
||
originalHeight: 1536,
|
||
},
|
||
},
|
||
}),
|
||
);
|
||
});
|
||
await waitFor(() => {
|
||
expect(applyProjectSnapshot).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
projectId: 'editor-project-1',
|
||
title: '角色项目',
|
||
}),
|
||
);
|
||
});
|
||
expect(screen.getByTestId('layers').textContent).not.toContain(
|
||
'layer-generated-1',
|
||
);
|
||
});
|
||
|
||
it('submits audio generations with asset library context and upserts the returned asset', async () => {
|
||
const applyProjectSnapshot = vi.fn();
|
||
const upsertGeneratedAsset = vi.fn();
|
||
generateEditorSoundEffectMock.mockResolvedValueOnce(
|
||
createAudioGenerated({
|
||
project: {
|
||
projectId: 'editor-project-audio',
|
||
title: '音频项目',
|
||
viewport: { x: 0, y: 0, scale: 1 },
|
||
layers: [],
|
||
resources: [],
|
||
updatedAt: '2026-06-27T00:00:00.000Z',
|
||
},
|
||
asset: {
|
||
assetId: 'asset-sound-1',
|
||
folderId: 'user-1:asset-folder:project',
|
||
label: '游戏音效 1',
|
||
imageSrc: 'https://assets.example.test/generated-audio.mp3',
|
||
width: 420,
|
||
height: 120,
|
||
sourceType: 'generated',
|
||
assetKind: 'sound-effect',
|
||
},
|
||
}),
|
||
);
|
||
render(
|
||
<SubmissionWorkflowHarness
|
||
projectId="editor-project-audio"
|
||
applyProjectSnapshot={applyProjectSnapshot}
|
||
upsertGeneratedAsset={upsertGeneratedAsset}
|
||
initialDialog={{
|
||
id: 'dialog-sound',
|
||
mode: 'audio-sound-effect',
|
||
prompt: ' 金币掉落叮当声 ',
|
||
status: 'idle',
|
||
composerOpen: true,
|
||
soundDurationSeconds: 7,
|
||
placeholder: {
|
||
x: 200,
|
||
y: 160,
|
||
width: 420,
|
||
height: 120,
|
||
originalWidth: 420,
|
||
originalHeight: 120,
|
||
},
|
||
}}
|
||
/>,
|
||
);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
|
||
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
|
||
|
||
await waitFor(() => {
|
||
expect(generateEditorSoundEffectMock).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
prompt: '金币掉落叮当声',
|
||
duration: 7,
|
||
projectId: 'editor-project-audio',
|
||
assetFolderId: 'project',
|
||
assetLabel: '游戏音效 1',
|
||
canvasCompletion: {
|
||
dialogId: 'dialog-sound',
|
||
title: '游戏音效 1',
|
||
placeholder: {
|
||
x: 200,
|
||
y: 160,
|
||
width: 420,
|
||
height: 120,
|
||
originalWidth: 420,
|
||
originalHeight: 120,
|
||
},
|
||
},
|
||
}),
|
||
);
|
||
});
|
||
await waitFor(() => {
|
||
expect(applyProjectSnapshot).toHaveBeenCalledWith(
|
||
expect.objectContaining({ projectId: 'editor-project-audio' }),
|
||
);
|
||
expect(upsertGeneratedAsset).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
assetId: 'asset-sound-1',
|
||
assetKind: 'sound-effect',
|
||
}),
|
||
);
|
||
});
|
||
});
|
||
|
||
it('resolves uploaded objectKey references before submitting image generation', async () => {
|
||
resolveEditorImageReferenceDataUrlForGenerationMock.mockResolvedValueOnce(
|
||
'data:image/jpeg;base64,normalized-ref',
|
||
);
|
||
generateEditorImageMock.mockResolvedValueOnce(
|
||
createGenerated({ prompt: '参考图生成' }),
|
||
);
|
||
render(
|
||
<SubmissionWorkflowHarness
|
||
initialDialog={{
|
||
id: 'dialog-generate',
|
||
mode: 'generate',
|
||
prompt: '参考图生成',
|
||
status: 'idle',
|
||
composerOpen: true,
|
||
imageModel: 'gpt-image-2',
|
||
generationReferences: [
|
||
{
|
||
id: 'ref-uploaded',
|
||
label: '上传参考图',
|
||
src: 'https://signed.example.test/ref.png?expires=1',
|
||
objectKey: 'generated-character-drafts/editor/refs/ref.png',
|
||
assetObjectId: 'assetobj-ref',
|
||
},
|
||
],
|
||
placeholder: {
|
||
x: 200,
|
||
y: 160,
|
||
width: 420,
|
||
height: 420,
|
||
originalWidth: 1024,
|
||
originalHeight: 1024,
|
||
},
|
||
}}
|
||
/>,
|
||
);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
|
||
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
|
||
|
||
await waitFor(() => {
|
||
expect(
|
||
resolveEditorImageReferenceDataUrlForGenerationMock,
|
||
).toHaveBeenCalledWith('generated-character-drafts/editor/refs/ref.png');
|
||
expect(generateEditorImageMock).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
prompt: '参考图生成',
|
||
referenceImageSrcs: ['data:image/jpeg;base64,normalized-ref'],
|
||
}),
|
||
);
|
||
});
|
||
});
|
||
|
||
it('refreshes the wallet balance after a queued generation reaches a terminal state', async () => {
|
||
const applyProjectSnapshot = vi.fn();
|
||
const refreshTaskList = vi.fn();
|
||
const refreshWalletBalance = vi.fn();
|
||
const backendProject = {
|
||
projectId: 'editor-project-1',
|
||
title: '队列项目',
|
||
viewport: { x: 0, y: 0, scale: 1 },
|
||
layers: [],
|
||
resources: [],
|
||
updatedAt: '2026-06-23T08:00:00.000Z',
|
||
};
|
||
loadEditorProjectMock.mockResolvedValueOnce(backendProject);
|
||
generateEditorImageMock.mockResolvedValueOnce({
|
||
...createGenerated({
|
||
imageSrc: '/generated-editor-images/project/image.png',
|
||
width: 1024,
|
||
height: 1024,
|
||
prompt: '队列生成',
|
||
}),
|
||
queueState: createQueueState(),
|
||
});
|
||
render(
|
||
<SubmissionWorkflowHarness
|
||
projectId="editor-project-1"
|
||
applyProjectSnapshot={applyProjectSnapshot}
|
||
onQueuedGenerationTask={refreshTaskList}
|
||
onWalletBalanceMayHaveChanged={refreshWalletBalance}
|
||
initialDialog={{
|
||
id: 'dialog-generate',
|
||
mode: 'generate',
|
||
prompt: '队列生成',
|
||
status: 'idle',
|
||
composerOpen: true,
|
||
imageModel: 'gpt-image-2',
|
||
placeholder: {
|
||
x: 200,
|
||
y: 160,
|
||
width: 420,
|
||
height: 420,
|
||
originalWidth: 1024,
|
||
originalHeight: 1024,
|
||
},
|
||
}}
|
||
/>,
|
||
);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
|
||
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
|
||
|
||
await waitFor(() => {
|
||
expect(refreshTaskList).toHaveBeenCalledTimes(1);
|
||
});
|
||
await waitFor(() => {
|
||
expect(loadEditorProjectMock).toHaveBeenCalledWith('editor-project-1');
|
||
});
|
||
expect(refreshWalletBalance).toHaveBeenCalledTimes(1);
|
||
expect(applyProjectSnapshot).toHaveBeenCalledWith(backendProject);
|
||
expect(getExternalGenerationJobStatusMock).not.toHaveBeenCalled();
|
||
expect(screen.getByTestId('layers').textContent).not.toContain(
|
||
'layer-generated-1',
|
||
);
|
||
});
|
||
|
||
it('keeps a queued generation pending when the frontend queue wait expires', async () => {
|
||
vi.useFakeTimers();
|
||
try {
|
||
const refreshWalletBalance = vi.fn();
|
||
const refreshTaskList = vi.fn();
|
||
const runningState = createQueueState({
|
||
status: 'running',
|
||
phaseLabel: '生成中',
|
||
phaseDetail: '后端任务继续执行中',
|
||
progress: 0.35,
|
||
});
|
||
generateEditorImageMock.mockResolvedValueOnce({
|
||
...createGenerated({
|
||
imageSrc: '/generated-editor-images/project/image.png',
|
||
width: 1024,
|
||
height: 1024,
|
||
prompt: '队列生成',
|
||
}),
|
||
queueState: runningState,
|
||
});
|
||
getExternalGenerationJobStatusMock.mockResolvedValue({
|
||
job: runningState,
|
||
});
|
||
render(
|
||
<SubmissionWorkflowHarness
|
||
projectId="editor-project-1"
|
||
onQueuedGenerationTask={refreshTaskList}
|
||
onWalletBalanceMayHaveChanged={refreshWalletBalance}
|
||
initialDialog={{
|
||
id: 'dialog-generate',
|
||
mode: 'generate',
|
||
prompt: '队列生成',
|
||
status: 'idle',
|
||
composerOpen: true,
|
||
imageModel: 'gpt-image-2',
|
||
placeholder: {
|
||
x: 200,
|
||
y: 160,
|
||
width: 420,
|
||
height: 420,
|
||
originalWidth: 1024,
|
||
originalHeight: 1024,
|
||
},
|
||
}}
|
||
/>,
|
||
);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
|
||
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
|
||
|
||
await act(async () => {
|
||
await Promise.resolve();
|
||
await Promise.resolve();
|
||
});
|
||
expect(refreshTaskList).toHaveBeenCalledTimes(1);
|
||
await act(async () => {
|
||
await vi.advanceTimersByTimeAsync(20 * 60 * 1000 + 1600);
|
||
});
|
||
|
||
expect(refreshWalletBalance).toHaveBeenCalledTimes(1);
|
||
expect(loadEditorProjectMock).not.toHaveBeenCalled();
|
||
expect(getExternalGenerationJobStatusMock).toHaveBeenCalled();
|
||
expect(screen.getByTestId('dialog').textContent).toBe(
|
||
'generate:generating:closed:-:placeholder:-',
|
||
);
|
||
expect(screen.getByTestId('dialog').textContent).not.toContain(
|
||
'生成任务仍在队列中',
|
||
);
|
||
expect(screen.getByTestId('layers').textContent).not.toContain(
|
||
'layer-generated-1',
|
||
);
|
||
} finally {
|
||
vi.useRealTimers();
|
||
}
|
||
});
|
||
|
||
it('does not add a local layer when project generation returns without a backend snapshot', async () => {
|
||
generateEditorImageMock.mockResolvedValueOnce(
|
||
createGenerated({
|
||
width: 1024,
|
||
height: 1024,
|
||
prompt: '银发游侠',
|
||
project: null,
|
||
}),
|
||
);
|
||
render(
|
||
<SubmissionWorkflowHarness
|
||
projectId="editor-project-1"
|
||
initialDialog={{
|
||
id: 'dialog-character',
|
||
mode: 'character',
|
||
prompt: '银发游侠',
|
||
status: 'idle',
|
||
composerOpen: true,
|
||
imageModel: 'gpt-image-2',
|
||
placeholder: {
|
||
x: 200,
|
||
y: 160,
|
||
width: 420,
|
||
height: 560,
|
||
originalWidth: 1024,
|
||
originalHeight: 1536,
|
||
},
|
||
}}
|
||
/>,
|
||
);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
|
||
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
|
||
|
||
await waitFor(() => {
|
||
expect(generateEditorImageMock).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
projectId: 'editor-project-1',
|
||
canvasCompletion: expect.objectContaining({
|
||
dialogId: 'dialog-character',
|
||
}),
|
||
}),
|
||
);
|
||
});
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId('dialog').textContent).toBe(
|
||
'character:idle:open:-:placeholder:-',
|
||
);
|
||
});
|
||
expect(screen.getByTestId('layers').textContent).not.toContain(
|
||
'layer-generated-1',
|
||
);
|
||
});
|
||
|
||
it('submits publication detail materials as one billable editor image and appends the result', async () => {
|
||
generateEditorImageMock.mockResolvedValueOnce(
|
||
createGenerated({
|
||
imageSrc: 'data:image/png;base64,publication',
|
||
width: 720,
|
||
height: 1280,
|
||
prompt: '游戏名:云朵消消乐',
|
||
}),
|
||
);
|
||
render(
|
||
<SubmissionWorkflowHarness
|
||
initialDialog={{
|
||
id: 'dialog-publication',
|
||
mode: 'publication',
|
||
prompt: '',
|
||
status: 'idle',
|
||
composerOpen: true,
|
||
publicationWorkflowId: 'publication-detail-gallery',
|
||
publicationGameInfo: {
|
||
gameName: '云朵消消乐',
|
||
gameCategories: '休闲消除',
|
||
gameDescription: '点击同类云朵完成消除',
|
||
},
|
||
publicationReferences: [
|
||
{
|
||
id: 'ref-1',
|
||
label: '主视觉参考',
|
||
src: 'data:image/png;base64,large-ref',
|
||
},
|
||
],
|
||
placeholder: {
|
||
x: 180,
|
||
y: 120,
|
||
width: 360,
|
||
height: 640,
|
||
originalWidth: 720,
|
||
originalHeight: 1280,
|
||
},
|
||
}}
|
||
/>,
|
||
);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
|
||
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
|
||
|
||
expect(screen.getByTestId('dialog').textContent).toBe(
|
||
'publication:generating:closed:-:placeholder:-',
|
||
);
|
||
await waitFor(() => {
|
||
expect(generateEditorImageMock).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
prompt: expect.stringContaining('【宣发素材类型】详情页单图'),
|
||
size: '720x1280',
|
||
kind: 'publication-material',
|
||
model: 'gpt-image-2',
|
||
aspectRatio: '9:16',
|
||
imageSize: '1K',
|
||
referenceImageSrcs: ['data:image/png;base64,large-ref'],
|
||
}),
|
||
);
|
||
});
|
||
const prompt = generateEditorImageMock.mock.calls[0]?.[0]?.prompt ?? '';
|
||
expect(prompt).toContain('游戏名:云朵消消乐');
|
||
expect(prompt).toContain('本次只生成一张 720 x 1280 竖版详情图');
|
||
expect(prompt).toContain('不是五图合集或拼贴');
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId('layers').textContent).toContain(
|
||
'layer-generated-1:1 宣发素材:-:-',
|
||
);
|
||
});
|
||
expect(screen.getByTestId('layers').textContent).not.toContain(
|
||
'layer-generated-2',
|
||
);
|
||
expect(screen.getByTestId('selected').textContent).toBe(
|
||
'layer-generated-1',
|
||
);
|
||
});
|
||
|
||
it('compresses publication references before submitting generation', async () => {
|
||
resolveEditorImageReferenceDataUrlForGenerationMock.mockResolvedValueOnce(
|
||
'data:image/jpeg;base64,compressed-ref',
|
||
);
|
||
generateEditorImageMock.mockResolvedValueOnce(
|
||
createGenerated({
|
||
imageSrc: 'data:image/png;base64,publication',
|
||
width: 720,
|
||
height: 540,
|
||
prompt: '游戏名:云朵消消乐',
|
||
}),
|
||
);
|
||
render(
|
||
<SubmissionWorkflowHarness
|
||
initialDialog={{
|
||
id: 'dialog-publication',
|
||
mode: 'publication',
|
||
prompt: '',
|
||
status: 'idle',
|
||
composerOpen: true,
|
||
publicationWorkflowId: 'publication-cover-image',
|
||
publicationGameInfo: {
|
||
gameName: '云朵消消乐',
|
||
gameCategories: '休闲消除',
|
||
gameDescription: '点击同类云朵完成消除',
|
||
},
|
||
publicationReferences: [
|
||
{
|
||
id: 'ref-1',
|
||
label: '主视觉参考',
|
||
src: 'data:image/png;base64,large-original-ref',
|
||
},
|
||
],
|
||
}}
|
||
/>,
|
||
);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
|
||
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
|
||
|
||
await waitFor(() => {
|
||
expect(
|
||
resolveEditorImageReferenceDataUrlForGenerationMock,
|
||
).toHaveBeenCalledWith('data:image/png;base64,large-original-ref');
|
||
expect(generateEditorImageMock).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
kind: 'publication-material',
|
||
aspectRatio: '4:3',
|
||
imageSize: '1K',
|
||
referenceImageSrcs: ['data:image/jpeg;base64,compressed-ref'],
|
||
}),
|
||
);
|
||
});
|
||
});
|
||
|
||
it('submits icon spec objects without requiring an icon spec reference', async () => {
|
||
generateEditorImageMock.mockResolvedValueOnce(
|
||
createGenerated({
|
||
imageSrc: 'data:image/png;base64,icon-spec',
|
||
width: 2048,
|
||
height: 1152,
|
||
prompt: '图标规范提示词',
|
||
}),
|
||
);
|
||
render(
|
||
<SubmissionWorkflowHarness
|
||
initialDialog={{
|
||
id: 'dialog-icon-spec',
|
||
mode: 'spec',
|
||
prompt: '',
|
||
status: 'idle',
|
||
composerOpen: true,
|
||
specType: 'icon',
|
||
specValues: {
|
||
playSetting: '背包整理玩法',
|
||
artStyle: '清爽卡通',
|
||
bodyRatio: '3',
|
||
characterView: '',
|
||
customPrompt: '',
|
||
},
|
||
placeholder: {
|
||
x: 120,
|
||
y: 140,
|
||
width: 640,
|
||
height: 360,
|
||
originalWidth: 2048,
|
||
originalHeight: 1152,
|
||
},
|
||
}}
|
||
/>,
|
||
);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
|
||
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
|
||
|
||
expect(generateEditorIconSpritesheetMock).not.toHaveBeenCalled();
|
||
await waitFor(() => {
|
||
expect(generateEditorImageMock).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
kind: 'spec',
|
||
assetKind: 'icon-spec',
|
||
assetLabel: '图标规范 1',
|
||
size: '2048x1152',
|
||
aspectRatio: '16:9',
|
||
imageSize: '2K',
|
||
}),
|
||
);
|
||
});
|
||
const payload = generateEditorImageMock.mock.calls[0]?.[0] ?? {};
|
||
expect(payload).not.toHaveProperty('referenceImageSrcs');
|
||
expect(payload.prompt).toContain('背包整理玩法');
|
||
expect(payload.prompt).not.toContain('参考图生成规范');
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId('layers').textContent).toContain(
|
||
'layer-generated-1:图标规范 1:-:icon-spec',
|
||
);
|
||
});
|
||
expect(screen.getByTestId('dialog').textContent).not.toContain(
|
||
'请选择图标规范',
|
||
);
|
||
});
|
||
|
||
it('validates icon submission before calling the API', async () => {
|
||
render(
|
||
<SubmissionWorkflowHarness
|
||
initialDialog={{
|
||
id: 'dialog-icon',
|
||
mode: 'icon',
|
||
prompt: ' 返回按钮 \n\n设置按钮',
|
||
status: 'idle',
|
||
composerOpen: true,
|
||
iconDescriptions: ['返回按钮'],
|
||
}}
|
||
/>,
|
||
);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
|
||
fireEvent.click(screen.getByRole('button', { name: '提交图标' }));
|
||
|
||
expect(generateEditorIconSpritesheetMock).not.toHaveBeenCalled();
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId('dialog').textContent).toContain(
|
||
'请选择图标规范',
|
||
);
|
||
});
|
||
});
|
||
|
||
it('submits icon spritesheets, trims descriptions, and remembers the model', async () => {
|
||
generateEditorIconSpritesheetMock.mockResolvedValueOnce({
|
||
spritesheetImageSrc: 'data:image/png;base64,sheet',
|
||
spritesheetWidth: 512,
|
||
spritesheetHeight: 512,
|
||
prompt: '图标素材',
|
||
actualPrompt: '图标素材',
|
||
model: 'gpt-image-2',
|
||
provider: 'VectorEngine',
|
||
taskId: 'task-icons',
|
||
iconImageSrcs: [],
|
||
});
|
||
render(
|
||
<SubmissionWorkflowHarness
|
||
initialDialog={{
|
||
id: 'dialog-icon',
|
||
mode: 'icon',
|
||
prompt: ' 返回按钮 \n\n 设置按钮 ',
|
||
status: 'idle',
|
||
composerOpen: true,
|
||
imageModel: 'gpt-image-2',
|
||
iconSpecReference: {
|
||
id: 'spec',
|
||
label: '图标规范',
|
||
src: 'data:image/png;base64,spec',
|
||
},
|
||
iconDescriptions: ['旧返回', '旧设置'],
|
||
placeholder: {
|
||
x: 140,
|
||
y: 160,
|
||
width: 360,
|
||
height: 360,
|
||
originalWidth: 512,
|
||
originalHeight: 512,
|
||
},
|
||
}}
|
||
/>,
|
||
);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
|
||
fireEvent.click(screen.getByRole('button', { name: '提交图标' }));
|
||
|
||
await waitFor(() => {
|
||
expect(generateEditorIconSpritesheetMock).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
referenceImageSrc: 'data:image/png;base64,spec',
|
||
iconDescriptions: ['返回按钮', '设置按钮'],
|
||
model: 'gpt-image-2',
|
||
}),
|
||
);
|
||
});
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId('layers').textContent).toContain(
|
||
'layer-icon-spritesheet-1:图标素材图集 1:-:icon-spritesheet',
|
||
);
|
||
});
|
||
expect(screen.getByTestId('layers').textContent).not.toContain(
|
||
'layer-icon-2',
|
||
);
|
||
expect(screen.getByTestId('selected').textContent).toBe(
|
||
'layer-icon-spritesheet-1',
|
||
);
|
||
expect(screen.getByTestId('dialog').textContent).toBe(
|
||
'icon:idle:open:layer-icon-spritesheet-1:placeholder:-',
|
||
);
|
||
expect(screen.getByTestId('remembered-model').textContent).toBe(
|
||
'gpt-image-2',
|
||
);
|
||
});
|
||
|
||
it('submits uploaded objectKey icon references before submitting spritesheets', async () => {
|
||
generateEditorIconSpritesheetMock.mockResolvedValueOnce({
|
||
spritesheetImageSrc: 'data:image/png;base64,sheet',
|
||
spritesheetWidth: 512,
|
||
spritesheetHeight: 512,
|
||
prompt: '图标素材',
|
||
actualPrompt: '图标素材',
|
||
model: 'gpt-image-2',
|
||
provider: 'VectorEngine',
|
||
taskId: 'task-icons',
|
||
iconImageSrcs: [],
|
||
});
|
||
render(
|
||
<SubmissionWorkflowHarness
|
||
initialDialog={{
|
||
id: 'dialog-icon',
|
||
mode: 'icon',
|
||
prompt: '返回按钮',
|
||
status: 'idle',
|
||
composerOpen: true,
|
||
imageModel: 'gpt-image-2',
|
||
iconSpecReference: {
|
||
id: 'spec',
|
||
label: '图标规范',
|
||
src: 'https://signed.example.test/icon-spec.png?expires=1',
|
||
objectKey: 'generated-character-drafts/editor/specs/icon-spec.png',
|
||
assetObjectId: 'assetobj-icon-spec',
|
||
},
|
||
generationReferences: [
|
||
{
|
||
id: 'ref-style',
|
||
label: '风格参考',
|
||
src: 'https://signed.example.test/icon-style.png?expires=1',
|
||
objectKey: 'generated-character-drafts/editor/refs/icon-style.png',
|
||
assetObjectId: 'assetobj-icon-style',
|
||
},
|
||
],
|
||
}}
|
||
/>,
|
||
);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
|
||
fireEvent.click(screen.getByRole('button', { name: '提交图标' }));
|
||
|
||
await waitFor(() => {
|
||
expect(resolveEditorImageReferenceDataUrlForGenerationMock).not.toHaveBeenCalled();
|
||
expect(generateEditorIconSpritesheetMock).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
referenceImageSrc:
|
||
'generated-character-drafts/editor/specs/icon-spec.png',
|
||
referenceImageSrcs: [
|
||
'generated-character-drafts/editor/refs/icon-style.png',
|
||
],
|
||
iconDescriptions: ['返回按钮'],
|
||
}),
|
||
);
|
||
});
|
||
});
|
||
|
||
it('extracts UI design assets into a spritesheet layer and sliced icon layers', async () => {
|
||
extractEditorUiDesignAssetsMock.mockResolvedValueOnce({
|
||
spritesheetImageSrc: 'data:image/png;base64,ui-sheet',
|
||
spritesheetWidth: 512,
|
||
spritesheetHeight: 512,
|
||
prompt:
|
||
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。',
|
||
actualPrompt:
|
||
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。',
|
||
model: 'gpt-image-2',
|
||
provider: 'VectorEngine',
|
||
taskId: 'task-ui-assets',
|
||
iconImageSrcs: [
|
||
createIconResult('素材 1', 'data:image/png;base64,asset-1'),
|
||
],
|
||
});
|
||
render(
|
||
<SubmissionWorkflowHarness
|
||
initialLayers={[
|
||
createLayer({
|
||
assetKind: 'ui-design',
|
||
objectKey: 'generated/ui-design.png',
|
||
}),
|
||
]}
|
||
/>,
|
||
);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '提取UI素材' }));
|
||
|
||
await waitFor(() => {
|
||
expect(extractEditorUiDesignAssetsMock).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
sourceImageSrc: 'generated/ui-design.png',
|
||
model: 'gemini-3.1-flash-image-preview',
|
||
screenColor: 'auto',
|
||
segModel: 'birefnet',
|
||
}),
|
||
);
|
||
});
|
||
expect(resolveEditorImageReferenceDataUrlMock).not.toHaveBeenCalledWith(
|
||
'generated/ui-design.png',
|
||
);
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId('layers').textContent).toContain(
|
||
'layer-icon-spritesheet-1:源图 素材图集:-:icon-spritesheet',
|
||
);
|
||
});
|
||
expect(screen.getByTestId('layers').textContent).toContain(
|
||
'layer-icon-2:素材 1:-:icon',
|
||
);
|
||
expect(screen.getByTestId('selected').textContent).toBe(
|
||
'layer-icon-spritesheet-1',
|
||
);
|
||
expect(screen.getByTestId('fit-count').textContent).toBe('1');
|
||
});
|
||
|
||
it('keeps the extracted spritesheet when extracted icon results are missing', async () => {
|
||
extractEditorUiDesignAssetsMock.mockResolvedValueOnce({
|
||
spritesheetImageSrc: 'data:image/png;base64,ui-sheet',
|
||
spritesheetWidth: 512,
|
||
spritesheetHeight: 512,
|
||
prompt:
|
||
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。',
|
||
actualPrompt:
|
||
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。',
|
||
model: 'gpt-image-2',
|
||
provider: 'VectorEngine',
|
||
taskId: 'task-ui-assets',
|
||
});
|
||
render(
|
||
<SubmissionWorkflowHarness
|
||
initialLayers={[
|
||
createLayer({
|
||
assetKind: 'ui-design',
|
||
objectKey: 'generated/ui-design.png',
|
||
}),
|
||
]}
|
||
/>,
|
||
);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '提取UI素材' }));
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId('layers').textContent).toContain(
|
||
'layer-icon-spritesheet-1:源图 素材图集:-:icon-spritesheet',
|
||
);
|
||
});
|
||
expect(screen.getByTestId('layers').textContent).not.toContain(
|
||
'layer-icon-2:',
|
||
);
|
||
expect(screen.getByTestId('selected').textContent).toBe(
|
||
'layer-icon-spritesheet-1',
|
||
);
|
||
expect(screen.getByTestId('fit-count').textContent).toBe('1');
|
||
});
|
||
|
||
it('renders selected UI marks into the extraction reference image and sends derived extraction pricing', async () => {
|
||
extractEditorUiDesignAssetsMock.mockResolvedValueOnce({
|
||
spritesheetImageSrc: 'data:image/png;base64,ui-sheet',
|
||
spritesheetWidth: 512,
|
||
spritesheetHeight: 512,
|
||
prompt:
|
||
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。',
|
||
actualPrompt:
|
||
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。',
|
||
model: 'gpt-image-2',
|
||
provider: 'VectorEngine',
|
||
taskId: 'task-ui-assets',
|
||
iconImageSrcs: [
|
||
createIconResult('素材 1', 'data:image/png;base64,asset-1'),
|
||
],
|
||
});
|
||
render(
|
||
<SubmissionWorkflowHarness
|
||
initialLayers={[
|
||
createLayer({
|
||
assetKind: 'ui-design',
|
||
objectKey: 'generated/ui-design.png',
|
||
}),
|
||
]}
|
||
/>,
|
||
);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '提取已框选UI素材' }));
|
||
|
||
await waitFor(() => {
|
||
expect(renderUiDesignAssetExtractionMarkedImageMock).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
source: 'data:image/png;base64,ui-design.png',
|
||
marks: expect.arrayContaining([
|
||
{
|
||
id: 'mark-1',
|
||
tool: 'rect',
|
||
x: 10,
|
||
y: 20,
|
||
width: 80,
|
||
height: 60,
|
||
},
|
||
]),
|
||
}),
|
||
);
|
||
expect(
|
||
renderUiDesignAssetExtractionMarkedImageMock.mock.calls[0]?.[0].marks,
|
||
).toHaveLength(7);
|
||
});
|
||
await waitFor(() => {
|
||
expect(extractEditorUiDesignAssetsMock).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
sourceImageSrc: 'data:image/png;base64,marked-ui-design',
|
||
model: 'gemini-3.1-flash-image-preview',
|
||
screenColor: 'auto',
|
||
segModel: 'birefnet',
|
||
aspectRatio: '1:1',
|
||
imageSize: '2K',
|
||
generationInputs: expect.objectContaining({
|
||
fields: expect.arrayContaining([
|
||
expect.objectContaining({
|
||
title: '提取提示词',
|
||
value:
|
||
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。',
|
||
}),
|
||
]),
|
||
}),
|
||
}),
|
||
);
|
||
});
|
||
});
|
||
|
||
it('submits UI asset extraction with uploaded references', async () => {
|
||
extractEditorUiDesignAssetsMock.mockResolvedValueOnce({
|
||
spritesheetImageSrc: 'data:image/png;base64,ui-sheet',
|
||
spritesheetWidth: 512,
|
||
spritesheetHeight: 512,
|
||
prompt:
|
||
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。',
|
||
actualPrompt:
|
||
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。',
|
||
model: 'gpt-image-2',
|
||
provider: 'VectorEngine',
|
||
taskId: 'task-ui-assets',
|
||
});
|
||
render(
|
||
<SubmissionWorkflowHarness
|
||
initialLayers={[
|
||
createLayer({
|
||
assetKind: 'ui-design',
|
||
objectKey: 'generated/ui-design.png',
|
||
}),
|
||
]}
|
||
/>,
|
||
);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '提取带参考图UI素材' }));
|
||
|
||
await waitFor(() => {
|
||
expect(resolveEditorImageReferenceDataUrlForGenerationMock).not.toHaveBeenCalled();
|
||
});
|
||
await waitFor(() => {
|
||
expect(extractEditorUiDesignAssetsMock).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
screenColor: 'auto',
|
||
segModel: 'birefnet',
|
||
referenceImageSrcs: ['generated/ref-style.png'],
|
||
generationInputs: expect.objectContaining({
|
||
references: expect.arrayContaining([
|
||
expect.objectContaining({
|
||
title: '参考图 1',
|
||
label: '风格参考',
|
||
refType: 'project-resource',
|
||
}),
|
||
]),
|
||
}),
|
||
}),
|
||
);
|
||
});
|
||
});
|
||
|
||
it('keeps character generation dialog data after the result is created', async () => {
|
||
generateEditorImageMock.mockResolvedValueOnce(
|
||
createGenerated({ width: 768, height: 768 }),
|
||
);
|
||
render(
|
||
<SubmissionWorkflowHarness
|
||
initialDialog={{
|
||
id: 'dialog-character',
|
||
mode: 'character',
|
||
prompt: '骑士角色',
|
||
status: 'idle',
|
||
composerOpen: true,
|
||
imageModel: 'gpt-image-2',
|
||
characterSpecReference: {
|
||
id: 'spec-character',
|
||
label: '角色规范',
|
||
src: 'data:image/png;base64,spec',
|
||
},
|
||
placeholder: {
|
||
x: 120,
|
||
y: 140,
|
||
width: 420,
|
||
height: 420,
|
||
originalWidth: 2048,
|
||
originalHeight: 2048,
|
||
},
|
||
}}
|
||
/>,
|
||
);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
|
||
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId('layers').textContent).toContain(
|
||
'layer-generated-1:角色形象 1:-:character',
|
||
);
|
||
});
|
||
expect(screen.getByTestId('dialog').textContent).toBe(
|
||
'character:idle:open:layer-generated-1:placeholder:-',
|
||
);
|
||
});
|
||
|
||
it('moves character animation panels from generating to completed', async () => {
|
||
generateEditorCharacterAnimationMock.mockResolvedValueOnce({
|
||
frames: [{ imageSrc: 'data:image/png;base64,frame' }],
|
||
previewSrc: 'data:image/png;base64,preview',
|
||
taskId: 'animation-task',
|
||
});
|
||
render(
|
||
<SubmissionWorkflowHarness
|
||
initialLayers={[
|
||
createLayer({
|
||
id: 'character-layer',
|
||
assetKind: 'character',
|
||
objectKey: 'generated/character.png',
|
||
}),
|
||
]}
|
||
initialCharacterAnimationPanel={{
|
||
sourceLayerId: 'character-layer',
|
||
promptText: ' 跑步 ',
|
||
resolution: '480p',
|
||
ratio: 'same',
|
||
frameCount: 32,
|
||
durationSeconds: 4,
|
||
status: 'idle',
|
||
}}
|
||
/>,
|
||
);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '提交角色动画' }));
|
||
|
||
expect(screen.getByTestId('character-animation').textContent).toBe(
|
||
'character-layer:generating:跑步:-:-',
|
||
);
|
||
await waitFor(() => {
|
||
expect(generateEditorCharacterAnimationMock).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
sourceLayerId: 'character-layer',
|
||
sourceImageSrc: 'generated/character.png',
|
||
promptText: '跑步',
|
||
model: 'seedance2.0-fast',
|
||
}),
|
||
);
|
||
});
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId('character-animation').textContent).toBe(
|
||
'character-layer:completed:跑步:-:result',
|
||
);
|
||
});
|
||
});
|
||
|
||
it('does not resubmit character animation while it is already generating', () => {
|
||
render(
|
||
<SubmissionWorkflowHarness
|
||
initialLayers={[
|
||
createLayer({
|
||
id: 'character-layer',
|
||
assetKind: 'character',
|
||
}),
|
||
]}
|
||
initialCharacterAnimationPanel={{
|
||
sourceLayerId: 'character-layer',
|
||
promptText: '跑步',
|
||
resolution: '480p',
|
||
ratio: 'same',
|
||
frameCount: 32,
|
||
durationSeconds: 4,
|
||
status: 'generating',
|
||
}}
|
||
/>,
|
||
);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '提交角色动画' }));
|
||
|
||
expect(generateEditorCharacterAnimationMock).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('creates an action layer for dialog-driven character animation submissions', async () => {
|
||
generateEditorCharacterAnimationMock.mockResolvedValueOnce({
|
||
taskId: 'animation-task-dialog',
|
||
model: 'seedance2.0-fast',
|
||
prompt: '挥手动作',
|
||
previewVideoPath: '/generated-character-drafts/editor/preview.mp4',
|
||
frames: [
|
||
{
|
||
frameIndex: 1,
|
||
imageSrc: 'data:image/png;base64,action-frame',
|
||
width: 1024,
|
||
height: 1024,
|
||
},
|
||
],
|
||
frameCount: 32,
|
||
durationSeconds: 4,
|
||
fps: 8,
|
||
});
|
||
render(
|
||
<SubmissionWorkflowHarness
|
||
initialLayers={[
|
||
createLayer({
|
||
id: 'character-layer',
|
||
title: '角色源图',
|
||
assetKind: 'character',
|
||
objectKey: 'generated/character.png',
|
||
}),
|
||
]}
|
||
initialDialog={{
|
||
id: 'dialog-character-animation',
|
||
mode: 'character-animation',
|
||
sourceLayerId: 'character-layer',
|
||
prompt: ' 挥手动作 ',
|
||
status: 'idle',
|
||
composerOpen: true,
|
||
characterAnimationResolution: '480p',
|
||
characterAnimationRatio: 'same',
|
||
characterAnimationFrameCount: 32,
|
||
characterAnimationDurationSeconds: 4,
|
||
placeholder: {
|
||
x: 80,
|
||
y: 60,
|
||
width: 420,
|
||
height: 420,
|
||
originalWidth: 1024,
|
||
originalHeight: 1024,
|
||
},
|
||
}}
|
||
initialCharacterAnimationPanel={{
|
||
sourceLayerId: 'character-layer',
|
||
promptText: ' 挥手动作 ',
|
||
resolution: '480p',
|
||
ratio: 'same',
|
||
frameCount: 32,
|
||
durationSeconds: 4,
|
||
status: 'idle',
|
||
}}
|
||
/>,
|
||
);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
|
||
fireEvent.click(screen.getByRole('button', { name: '提交角色动画' }));
|
||
|
||
await waitFor(() => {
|
||
expect(generateEditorCharacterAnimationMock).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
sourceLayerId: 'character-layer',
|
||
sourceImageSrc: 'generated/character.png',
|
||
promptText: '挥手动作',
|
||
model: 'seedance2.0-fast',
|
||
}),
|
||
);
|
||
});
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId('layers').textContent).toContain(
|
||
'layer-character-animation-1:角色动作:resource-source:character-animation:image-sequence:data:image/png;base64,action-frame:data:image/png;base64,action-frame:4',
|
||
);
|
||
});
|
||
expect(screen.getByTestId('dialog').textContent).toBe(
|
||
'character-animation:idle:open:layer-character-animation-1:placeholder:-',
|
||
);
|
||
expect(screen.getByTestId('selected').textContent).toBe(
|
||
'layer-character-animation-1',
|
||
);
|
||
});
|
||
|
||
it('shows a readable conflict message for failed character animation submissions', async () => {
|
||
generateEditorCharacterAnimationMock.mockRejectedValueOnce(
|
||
new ApiClientError({
|
||
message: '请求冲突(requestId: wallet-one)',
|
||
status: 409,
|
||
code: 'CONFLICT',
|
||
details: { provider: 'profile-wallet' },
|
||
}),
|
||
);
|
||
render(
|
||
<SubmissionWorkflowHarness
|
||
initialLayers={[createLayer({ id: 'character-layer' })]}
|
||
initialCharacterAnimationPanel={{
|
||
sourceLayerId: 'character-layer',
|
||
promptText: '跑步',
|
||
resolution: '480p',
|
||
ratio: 'same',
|
||
frameCount: 32,
|
||
durationSeconds: 4,
|
||
status: 'idle',
|
||
}}
|
||
/>,
|
||
);
|
||
|
||
await act(async () => {
|
||
fireEvent.click(screen.getByRole('button', { name: '提交角色动画' }));
|
||
});
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId('character-animation').textContent).toBe(
|
||
'character-layer:failed:跑步:泥点余额不足,请充值后再生成:-',
|
||
);
|
||
});
|
||
});
|
||
|
||
it('uses a fallback error message for failed character animation submissions', async () => {
|
||
generateEditorCharacterAnimationMock.mockRejectedValueOnce(new Error(''));
|
||
render(
|
||
<SubmissionWorkflowHarness
|
||
initialLayers={[createLayer({ id: 'character-layer' })]}
|
||
initialCharacterAnimationPanel={{
|
||
sourceLayerId: 'character-layer',
|
||
promptText: '',
|
||
resolution: '480p',
|
||
ratio: 'same',
|
||
frameCount: 32,
|
||
durationSeconds: 4,
|
||
status: 'idle',
|
||
}}
|
||
/>,
|
||
);
|
||
|
||
await act(async () => {
|
||
fireEvent.click(screen.getByRole('button', { name: '提交角色动画' }));
|
||
});
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByTestId('character-animation').textContent).toBe(
|
||
'character-layer:failed:-:生成角色动画失败:-',
|
||
);
|
||
});
|
||
});
|
||
});
|