Files
Genarrative/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx
T
kdletters fc14190f58
Project CI / AI game creator shell Rust shard 1/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (push) Has been cancelled
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
图集切片模式改为必须显式声明并补齐决策要求 (#408)
## 背景

切图新增基于连通域的切分后,LLM 仍倾向显式传 `sliceMode=grid`:参数只存在于部分 LLM 可见面、带默认值、没有任何决策规则,生成结果也不回显生效模式。

## 变更

- 平台:`/api/editor/icon-spritesheets/generations` 与 `/api/external/v1/editor/icon-spritesheets/generations` 把 `sliceMode` 改为必填并移除默认值;缺失、空白或未知取值在引用解析、定价与任何 provider / OSS 副作用之前返回 `400`,错误统一带 `field` 与决策要求。
- 契约:`grid` 必须同时提供 `gridX`/`gridY`,`connected-components` 不接受网格尺寸;`sliceCount` 只约束连通域切分,请求与响应的公开上限统一为 `256`;OpenAPI 去掉默认值并补必填与失败语义。
- AGC:MCP 工具说明去掉默认值并补决策要求,桥接层新增可测试的切分声明校验;原生工具 `canvas.asset_generate` 暴露 `sliceMode/gridX/gridY/sliceCount` 并要求图集显式声明;生成结果回显 `sliceMode/gridX/gridY` 与 `slicePaths`;严格图集在本地提交前校验平台回显与请求声明一致。
- 标准美术包:显式声明 `connected-components` 加 `sliceCount=4`,并在四张 canonical 切片用途映射前校验数量,禁止截断或错位。
- 前端与画板:画板 Agent 工具装配与画板提交计划显式声明连通域切分;前端类型要求显式 `sliceMode` 并在本地校验声明自洽。
- 文档与 Skill:主规范、OpenAPI、AGC Skill、外部编辑器 Skill、里程碑与实施计划、共享决策记录同步更新。
- 测试环境:测试构建对提权 Windows 主机上系统临时目录的所有者偏差做一次性所有者初始化重试,临时目录之外的越权所有者继续失败关闭。

## 兼容性影响

省略 `sliceMode` 的旧调用方(含已发布但未更新的 AGC 客户端与第三方外部 API 调用方)会在图集生成上收到 `400`;这是本次"不允许默认值"的预期结果,仓库内自有调用方已全部改为显式声明。

## 验证

- 平台:`slice_mode_must_be_declared_*` 与 OpenAPI 契约测试通过;全量 `cargo test -p api-server` 1043 通过 / 11 失败(`wallet_refund_outbox` 临时文件 `拒绝访问`,已在改动前基线复现,属本机环境)。
- AGC:`slice` 30、`spritesheet` 21、`direct_tools_mcp` 23、`agent_native_tools` 16、`canvas_generation_tests` 83、提示词上限与桥接门禁各 1 条、`cargo check --tests` 全部通过。
- 前端:182 条定向测试与 `typecheck` 通过。
- 门禁:`cargo fmt --check`(两个 workspace)、`check:encoding`、`check:doc-index`、`git diff --check` 通过。
- 未验证:真实 Provider 与浏览器试玩、确定性 e2e 车道;整机全量 AGC 单进程运行在本机受提权 shell 的所有者与时序问题影响,不作为门禁信号。

---------

Co-authored-by: kdletters <61648117+kdletters@users.noreply.github.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/408
2026-09-17 18:10:34 +08:00

4930 lines
160 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* @vitest-environment jsdom */
import {
act,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react';
import { useRef, useState } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import backgroundMusicPromptCanonicalizationCases from '../../../packages/shared/test-fixtures/background-music-prompt-canonicalization.json';
import { ApiClientError } from '../../services/apiClient';
import type {
CanvasGenerationDialogState,
CanvasHistoryAction,
CanvasLayer,
CanvasTool,
CharacterAnimationPanelState,
GenerateDialogState,
QuickEditPanelState,
SidebarPanel,
} from './ImageCanvasEditorTypes';
import { createQuickEditPanelDraft } from './ImageCanvasGenerationDialogModel';
import type { UiAssetExtractionState } from './ImageCanvasUiAssetExtractionModel';
import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs';
import { useImageCanvasBackgroundMusicPromptAssist } from './useImageCanvasBackgroundMusicPromptAssist';
import {
applyQueuedEditorGenerationProject,
resolveEditorGenerationMediaReference,
useImageCanvasGenerationSubmissionWorkflow,
} from './useImageCanvasGenerationSubmissionWorkflow';
import { useImageCanvasSoundEffectPromptAssist } from './useImageCanvasSoundEffectPromptAssist';
const resolveEditorImageReferenceDataUrlMock = vi.hoisted(() => vi.fn());
const resolveEditorImageReferenceDataUrlForGenerationMock = vi.hoisted(() =>
vi.fn(),
);
const uploadEditorMediaAssetObjectFileMock = vi.hoisted(() => vi.fn());
const uploadEditorMediaAssetFileMock = vi.hoisted(() => vi.fn());
const editEditorImageMock = vi.hoisted(() => vi.fn());
const extractEditorUiDesignAssetsMock = vi.hoisted(() => vi.fn());
const renderUiDesignAssetExtractionMarkedImageMock = vi.hoisted(() => vi.fn());
const generateEditorCharacterAnimationMock = vi.hoisted(() => vi.fn());
const generateEditorIconSpecMock = vi.hoisted(() => vi.fn());
const generateEditorIconSpritesheetMock = vi.hoisted(() => vi.fn());
const generateEditorImageMock = vi.hoisted(() => vi.fn());
const generateEditorSceneMock = 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,
generateEditorIconSpec: generateEditorIconSpecMock,
generateEditorIconSpritesheet: generateEditorIconSpritesheetMock,
generateEditorImage: generateEditorImageMock,
generateEditorScene: generateEditorSceneMock,
generateEditorVideo: generateEditorVideoMock,
generateEditorSoundEffect: generateEditorSoundEffectMock,
generateEditorBackgroundMusic: generateEditorBackgroundMusicMock,
loadEditorProject: loadEditorProjectMock,
};
});
vi.mock('../../services/image-editor/editorMediaAssetUploadClient', () => ({
uploadEditorMediaAssetObjectFile: uploadEditorMediaAssetObjectFileMock,
uploadEditorMediaAssetFile: uploadEditorMediaAssetFileMock,
}));
vi.mock('../../services/external-generation', () => ({
getExternalGenerationJobStatus: getExternalGenerationJobStatusMock,
}));
vi.mock('./ImageCanvasUiAssetExtractionRasterModel', () => ({
renderUiDesignAssetExtractionMarkedImage:
renderUiDesignAssetExtractionMarkedImageMock,
}));
function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
return {
id: 'layer-source',
resourceId: 'resource-source',
title: '源图',
src: 'https://assets.example.test/source.png',
x: 120,
y: 140,
width: 320,
height: 240,
originalWidth: 1024,
originalHeight: 768,
zIndex: 2,
sourceType: 'uploaded',
...overrides,
};
}
function createGenerated(overrides = {}) {
return {
imageSrc: 'data:image/png;base64,generated',
width: 1024,
height: 1024,
sourceType: 'generated' as const,
prompt: '生成提示词',
actualPrompt: '生成提示词',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'task-generated',
...overrides,
};
}
function createVideoGenerated(overrides = {}) {
return {
videoSrc: 'https://assets.example.test/generated-video.mp4',
width: 854,
height: 480,
sourceType: 'generated' as const,
prompt: '视频提示词',
actualPrompt: '视频提示词',
model: 'seedance2.0-fast',
provider: 'ark',
taskId: 'task-video-generated',
...overrides,
};
}
function createAudioGenerated(overrides = {}) {
return {
audioSrc: 'https://assets.example.test/generated-audio.mp3',
width: 420,
height: 120,
sourceType: 'generated' as const,
prompt: '金币掉落叮当声',
actualPrompt: '金币掉落叮当声',
model: 'audio1.0',
taskId: 'task-audio-generated',
priceMudPoints: 5,
audioKind: 'sound-effect' as const,
durationSeconds: 7,
...overrides,
};
}
function createSoundEffectWalletDialog(
id: string,
prompt: string,
): GenerateDialogState {
return {
id,
mode: 'audio-sound-effect',
prompt,
status: 'idle',
composerOpen: true,
soundModel: 'eleven_text_to_sound_v2',
soundDurationMode: 'auto',
soundDurationSeconds: 5,
soundLoop: false,
placeholder: {
x: 200,
y: 160,
width: 420,
height: 120,
originalWidth: 420,
originalHeight: 120,
},
};
}
function createIconResult(name: string, imageSrc: string) {
return {
name,
imageSrc,
width: 128,
height: 128,
};
}
function createQueueState(
overrides: Partial<{
operationId: string;
status: 'queued' | 'running' | 'completed' | 'failed';
phaseLabel: string;
phaseDetail: string;
progress: number;
error: string | null;
warning: string | null;
updatedAtMicros: number;
}> = {},
) {
return {
operationId: 'editor-generation-operation-1',
status: 'completed' as const,
phaseLabel: '完成',
phaseDetail: '',
progress: 1,
error: null,
updatedAtMicros: 1,
...overrides,
};
}
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
}
function SubmissionWorkflowHarness({
initialDialog = null,
initialQuickEditPanel = null,
initialQuickEditSelectionState = null,
initialCharacterAnimationPanel = null,
initialLayers = [createLayer()],
projectId = undefined,
currentUserId = undefined,
assetFolderId = projectId ? 'project' : undefined,
upsertGeneratedAsset = undefined,
applyProjectSnapshot = undefined,
onQueuedGenerationTask = undefined,
onWalletBalanceMayHaveChanged = undefined,
captureCanvasHistory = () => undefined,
fitLayers = undefined,
onAppendCanvasLayers = undefined,
}: {
initialDialog?: GenerateDialogState | null;
initialQuickEditPanel?: QuickEditPanelState | null;
initialQuickEditSelectionState?: UiAssetExtractionState | null;
initialCharacterAnimationPanel?: CharacterAnimationPanelState | null;
initialLayers?: CanvasLayer[];
projectId?: string | null;
currentUserId?: string | null;
assetFolderId?: string | null;
upsertGeneratedAsset?: Parameters<
typeof useImageCanvasGenerationSubmissionWorkflow
>[0]['upsertGeneratedAsset'];
applyProjectSnapshot?: Parameters<
typeof useImageCanvasGenerationSubmissionWorkflow
>[0]['applyProjectSnapshot'];
onQueuedGenerationTask?: Parameters<
typeof useImageCanvasGenerationSubmissionWorkflow
>[0]['onQueuedGenerationTask'];
onWalletBalanceMayHaveChanged?: Parameters<
typeof useImageCanvasGenerationSubmissionWorkflow
>[0]['onWalletBalanceMayHaveChanged'];
captureCanvasHistory?: (action: CanvasHistoryAction) => void;
fitLayers?: (
targetLayers?: CanvasLayer[],
options?: { captureHistory?: boolean },
) => void;
onAppendCanvasLayers?: (nextLayers: CanvasLayer[]) => void;
}) {
const [layers, setLayers] = useState<CanvasLayer[]>(initialLayers);
const [activeTool, setActiveTool] = useState<CanvasTool>('generate');
const [activeSidebarPanel, setActiveSidebarPanel] =
useState<SidebarPanel | null>('assets');
const [selectedLayerId, setSelectedLayerId] = useState<string | null>(null);
const [quickEditPanel, setQuickEditPanel] =
useState<QuickEditPanelState | null>(initialQuickEditPanel);
const [characterAnimationPanel, setCharacterAnimationPanel] =
useState<CharacterAnimationPanelState | null>(
initialCharacterAnimationPanel,
);
const [generationWarning, setGenerationWarning] = useState<string | null>(
null,
);
const rememberedImageModelRef = useRef<string | null>(null);
const fitLayersRef = useRef(fitLayers);
fitLayersRef.current = fitLayers;
const fitLayersMockRef = useRef(
vi.fn(
(targetLayers?: CanvasLayer[], options?: { captureHistory?: boolean }) =>
fitLayersRef.current?.(targetLayers, options),
),
);
const layerCounterRef = useRef(0);
const dialogs = useCanvasGenerationDialogs();
const backgroundMusicPromptAssist = useImageCanvasBackgroundMusicPromptAssist(
{
canvasGenerationDialogs: dialogs.canvasGenerationDialogs,
getCanvasGenerationDialogById: dialogs.getCanvasGenerationDialogById,
updateCanvasGenerationDialogById:
dialogs.updateCanvasGenerationDialogById,
currentUserId,
projectId,
},
);
const soundEffectPromptAssist = useImageCanvasSoundEffectPromptAssist({
canvasGenerationDialogs: dialogs.canvasGenerationDialogs,
getCanvasGenerationDialogById: dialogs.getCanvasGenerationDialogById,
updateCanvasGenerationDialogById: dialogs.updateCanvasGenerationDialogById,
currentUserId,
projectId,
});
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,
setQuickEditPanel,
characterAnimationPanel,
characterAnimationDialog: activeCanvasDialog,
characterAnimationSourceLayer,
setCharacterAnimationPanel,
setGenerateDialog: dialogs.setGenerateDialog,
updateCanvasGenerationDialogById: dialogs.updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById: dialogs.hasCanvasGenerationDialogById,
getCanvasGenerationDialogById: dialogs.getCanvasGenerationDialogById,
activeCanvasGenerationDialogId: activeCanvasDialog?.id,
backgroundMusicPromptAssist,
soundEffectPromptAssist,
getGeneratingDialogPlaceholder: dialogs.getGeneratingDialogPlaceholder,
appendCanvasLayersWithResources: (nextLayers) => {
onAppendCanvasLayers?.(nextLayers);
setLayers((currentLayers) => [...currentLayers, ...nextLayers]);
},
captureCanvasHistory,
updateSourceLayer: (sourceLayerId, updater) =>
setLayers((currentLayers) =>
currentLayers.map((layer) =>
layer.id === sourceLayerId ? updater(layer) : layer,
),
),
selectSingleLayer: setSelectedLayerId,
fitLayers: fitLayersMockRef.current,
setActiveTool,
setActiveSidebarPanel,
rememberImageModel: (imageModel) => {
rememberedImageModelRef.current = imageModel;
},
projectId,
currentUserId,
assetFolderId,
upsertGeneratedAsset,
applyProjectSnapshot,
onQueuedGenerationTask,
onWalletBalanceMayHaveChanged,
onGenerationWarning: setGenerationWarning,
});
const trackedDialogId =
initialDialog && 'id' in initialDialog ? initialDialog.id : null;
const trackedDialog = trackedDialogId
? dialogs.getCanvasGenerationDialogById(trackedDialogId)
: undefined;
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.imageSequenceDurationMs ?? '-'}`,
)
.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="tracked-dialog">
{trackedDialog
? `${trackedDialog.mode}:${trackedDialog.prompt}:${trackedDialog.status}:${trackedDialog.composerOpen !== false ? 'open' : 'closed'}:${trackedDialog.errorMessage ?? '-'}`
: '-'}
</span>
<span data-testid="background-music-assist">
{trackedDialogId
? backgroundMusicPromptAssist.getDialogState(trackedDialogId).status
: '-'}
</span>
<span data-testid="sound-effect-assist">
{trackedDialogId
? soundEffectPromptAssist.getDialogState(trackedDialogId).status
: '-'}
</span>
<span data-testid="quick-edit">
{quickEditPanel
? `${quickEditPanel.sourceLayerId}:${quickEditPanel.status}:${quickEditPanel.prompt || '-'}:${quickEditPanel.errorMessage ?? '-'}`
: '-'}
</span>
<span data-testid="character-animation">
{characterAnimationPanel
? `${characterAnimationPanel.sourceLayerId}:${characterAnimationPanel.status}:${characterAnimationPanel.promptText || '-'}:${characterAnimationPanel.errorMessage ?? '-'}:${characterAnimationPanel.result ? 'result' : '-'}`
: '-'}
</span>
<span data-testid="fit-count">
{fitLayersMockRef.current.mock.calls.length}
</span>
<span data-testid="remembered-model">
{rememberedImageModelRef.current ?? '-'}
</span>
<span data-testid="generation-warning">{generationWarning ?? '-'}</span>
<button
type="button"
onClick={() => {
if (initialDialog) {
dialogs.setGenerateDialog(initialDialog);
}
}}
>
设置初始对话
</button>
<button
type="button"
onClick={() =>
dialogs.openCanvasGenerationDialog({
mode: 'generate',
prompt: '其它生成任务',
status: 'idle',
composerOpen: true,
})
}
>
切换生成对话
</button>
<button
type="button"
onClick={() => {
if (trackedDialogId) {
dialogs.removeCanvasGenerationDialogById(trackedDialogId);
}
}}
>
删除跟踪对话
</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();
uploadEditorMediaAssetObjectFileMock.mockReset();
uploadEditorMediaAssetFileMock.mockReset();
editEditorImageMock.mockReset();
extractEditorUiDesignAssetsMock.mockReset();
renderUiDesignAssetExtractionMarkedImageMock.mockReset();
generateEditorCharacterAnimationMock.mockReset();
generateEditorIconSpecMock.mockReset();
generateEditorIconSpritesheetMock.mockReset();
generateEditorImageMock.mockReset();
generateEditorSceneMock.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,
);
uploadEditorMediaAssetObjectFileMock.mockResolvedValue({
objectKey:
'generated-character-drafts/editor/generation-references/reference.png',
assetObjectId: 'asset-object-generation-reference',
legacyPublicPath:
'/generated-character-drafts/editor/generation-references/reference.png',
});
renderUiDesignAssetExtractionMarkedImageMock.mockResolvedValue(
'data:image/png;base64,bWFya2VkLXVpLWRlc2lnbg==',
);
});
it('uses distinct upload paths for concurrent inline references in the same millisecond', async () => {
const dateNowSpy = vi.spyOn(Date, 'now').mockReturnValue(1_752_144_000_000);
try {
await Promise.all([
resolveEditorGenerationMediaReference(
{ src: 'data:image/png;base64,YQ==' },
'image',
'project-1',
),
resolveEditorGenerationMediaReference(
{ src: 'data:image/png;base64,Yg==' },
'image',
'project-1',
),
]);
} finally {
dateNowSpy.mockRestore();
}
expect(uploadEditorMediaAssetObjectFileMock).toHaveBeenCalledTimes(2);
const [firstFile, , firstOptions] =
uploadEditorMediaAssetObjectFileMock.mock.calls[0] ?? [];
const [secondFile, , secondOptions] =
uploadEditorMediaAssetObjectFileMock.mock.calls[1] ?? [];
expect((firstFile as File).name).not.toBe((secondFile as File).name);
expect(firstOptions.pathSegments).not.toEqual(secondOptions.pathSegments);
});
it('reuses a caller supplied upload id for the same inline operation', async () => {
const options = { uploadId: 'perfect-pixel-operation-1' };
await resolveEditorGenerationMediaReference(
{ src: 'data:image/png;base64,YQ==' },
'image',
'project-1',
options,
);
await resolveEditorGenerationMediaReference(
{ src: 'data:image/png;base64,YQ==' },
'image',
'project-1',
options,
);
expect(uploadEditorMediaAssetObjectFileMock).toHaveBeenCalledTimes(2);
for (const [
file,
mediaType,
uploadOptions,
] of uploadEditorMediaAssetObjectFileMock.mock.calls) {
expect((file as File).name).toBe(
'generation-reference-perfect-pixel-operation-1.png',
);
expect(mediaType).toBe('image');
expect(uploadOptions).toEqual(
expect.objectContaining({
pathSegments: [
'editor',
'generation-references',
'project-1',
'perfect-pixel-operation-1',
],
}),
);
}
});
it('uploads image references without an object reference before generation', async () => {
const controller = new AbortController();
resolveEditorImageReferenceDataUrlMock.mockResolvedValueOnce(
'data:image/png;base64,ZXhhbXBsZQ==',
);
const result = await resolveEditorGenerationMediaReference(
{ src: '/creation-type-references/example.webp' },
'image',
'project-1',
{
signal: controller.signal,
uploadId: 'perfect-pixel-operation-1',
},
);
expect(resolveEditorImageReferenceDataUrlMock).toHaveBeenCalledWith(
'/creation-type-references/example.webp',
controller.signal,
);
expect(uploadEditorMediaAssetObjectFileMock).toHaveBeenCalledWith(
expect.any(File),
'image',
expect.objectContaining({
assetKind: 'editor_generation_reference_image',
signal: controller.signal,
}),
);
expect(uploadEditorMediaAssetFileMock).not.toHaveBeenCalled();
expect(result).toBe(
'generated-character-drafts/editor/generation-references/reference.png',
);
});
it('passes the abort signal through blob fetch and object registration', async () => {
const controller = new AbortController();
const blobMock = vi
.fn()
.mockResolvedValue(new Blob(['video'], { type: 'video/mp4' }));
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
blob: blobMock,
});
vi.stubGlobal('fetch', fetchMock);
try {
await resolveEditorGenerationMediaReference(
{ src: 'blob:https://editor.example.test/reference-video' },
'video',
'project-1',
{
signal: controller.signal,
uploadId: 'video-operation-1',
},
);
} finally {
vi.unstubAllGlobals();
}
expect(fetchMock).toHaveBeenCalledWith(
'blob:https://editor.example.test/reference-video',
{ signal: controller.signal },
);
expect(blobMock).toHaveBeenCalledTimes(1);
expect(uploadEditorMediaAssetObjectFileMock).toHaveBeenCalledWith(
expect.any(File),
'video',
expect.objectContaining({
pathSegments: [
'editor',
'generation-references',
'project-1',
'video-operation-1',
],
signal: controller.signal,
}),
);
});
it('does not parse or upload an inline Data URL after cancellation', async () => {
const controller = new AbortController();
controller.abort(new DOMException('已取消', 'AbortError'));
await expect(
resolveEditorGenerationMediaReference(
{ src: 'data:image/png;base64,YQ==' },
'image',
'project-1',
{
signal: controller.signal,
uploadId: 'cancelled-operation',
},
),
).rejects.toMatchObject({ name: 'AbortError' });
expect(uploadEditorMediaAssetObjectFileMock).not.toHaveBeenCalled();
});
it('stops after image source parsing when cancellation wins the boundary', async () => {
const controller = new AbortController();
let finishImageParsing!: (value: string) => void;
resolveEditorImageReferenceDataUrlMock.mockImplementationOnce(
() =>
new Promise<string>((resolve) => {
finishImageParsing = resolve;
}),
);
const resolution = resolveEditorGenerationMediaReference(
{ src: '/creation-type-references/slow.webp' },
'image',
'project-1',
{
signal: controller.signal,
uploadId: 'cancelled-after-parse',
},
);
controller.abort(new DOMException('已取消', 'AbortError'));
finishImageParsing('data:image/png;base64,YQ==');
await expect(resolution).rejects.toMatchObject({ name: 'AbortError' });
expect(resolveEditorImageReferenceDataUrlMock).toHaveBeenCalledWith(
'/creation-type-references/slow.webp',
controller.signal,
);
expect(uploadEditorMediaAssetObjectFileMock).not.toHaveBeenCalled();
});
it('submits quick edits and updates the source layer directly', async () => {
editEditorImageMock.mockResolvedValueOnce(
createGenerated({
prompt: '快速修图',
model: 'gemini-3.1-flash-image-preview',
}),
);
render(
<SubmissionWorkflowHarness
initialLayers={[
createLayer({
originalWidth: 1537,
originalHeight: 1025,
assetKind: 'icon-spritesheet',
model: 'gemini-3.1-flash-image-preview',
}),
]}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '打开快速编辑' }));
fireEvent.click(screen.getByRole('button', { name: '填写快速编辑' }));
fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' }));
expect(screen.getByTestId('quick-edit').textContent).toBe(
'layer-source:generating:快速修图:-',
);
expect(screen.getByTestId('dialog').textContent).toBe('-');
await waitFor(() => {
expect(editEditorImageMock).toHaveBeenCalledWith(
expect.objectContaining({
prompt: '快速修图',
sourceReferenceId: 'resource-source',
size: '2048x1365',
model: 'gemini-3.1-flash-image-preview',
aspectRatio: '3:2',
imageSize: '2K',
}),
);
});
expect(editEditorImageMock.mock.calls[0]?.[0]).not.toHaveProperty(
'canvasCompletion',
);
expect(generateEditorImageMock).not.toHaveBeenCalled();
expect(resolveEditorImageReferenceDataUrlMock).not.toHaveBeenCalled();
await waitFor(() => {
expect(screen.getByTestId('layers').textContent).toContain(
'layer-source:源图 快速编辑:resource-source:icon-spritesheet:-:data:image/png;base64,generated',
);
});
expect(screen.getByTestId('quick-edit').textContent).toBe('-');
expect(screen.getByTestId('tool').textContent).toBe('select');
expect(screen.getByTestId('sidebar').textContent).toBe('layers');
expect(screen.getByTestId('selected').textContent).toBe('-');
expect(screen.getByTestId('fit-count').textContent).toBe('0');
});
it('uses one custom name for quick-edit requests and local layer results', async () => {
editEditorImageMock.mockResolvedValueOnce(
createGenerated({ prompt: '改成雨天' }),
);
render(
<SubmissionWorkflowHarness
initialQuickEditPanel={{
sourceLayerId: 'layer-source',
prompt: '改成雨天',
assetLabel: ' 雨天勇者 ',
size: '1024x768',
model: 'gpt-image-2',
status: 'idle',
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' }));
await waitFor(() => {
expect(editEditorImageMock).toHaveBeenCalledWith(
expect.objectContaining({ assetLabel: '雨天勇者' }),
);
expect(screen.getByTestId('layers').textContent).toContain(
'layer-source:雨天勇者:resource-source',
);
});
});
it('shows dimension restore warnings after non-character quick edits', async () => {
editEditorImageMock.mockResolvedValueOnce(
createGenerated({
prompt: '改成雨天',
width: 1024,
height: 576,
warning: {
code: 'dimension-restore-fallback',
reason: 'provider 回图小于目标尺寸,已保留实际回图。',
},
}),
);
render(
<SubmissionWorkflowHarness
initialQuickEditPanel={{
sourceLayerId: 'layer-source',
prompt: '改成雨天',
size: '2048x1152',
aspectRatio: '16:9',
imageSize: '2K',
model: 'gpt-image-2',
status: 'idle',
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' }));
await waitFor(() => {
expect(screen.getByTestId('generation-warning').textContent).toBe(
'provider 回图小于目标尺寸,已保留实际回图。',
);
});
});
it('rejects direct quick edit submission for icon assets', async () => {
render(
<SubmissionWorkflowHarness
initialLayers={[createLayer({ assetKind: 'icon' })]}
initialQuickEditPanel={{
sourceLayerId: 'layer-source',
prompt: '修改图标',
size: '1024x1024',
aspectRatio: '1:1',
imageSize: '1K',
model: 'gpt-image-2',
status: 'idle',
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' }));
expect(editEditorImageMock).not.toHaveBeenCalled();
expect(screen.getByTestId('quick-edit').textContent).toContain(
'failed:修改图标:当前素材类型不支持快速编辑',
);
});
it.each(['scene', 'spec'] as const)(
'submits quick edits for icon resources overridden to %s',
async (assetKind) => {
editEditorImageMock.mockResolvedValueOnce(
createGenerated({ prompt: `修改${assetKind}` }),
);
render(
<SubmissionWorkflowHarness
initialLayers={[
createLayer({
resourceAssetKind: 'icon',
assetKind,
}),
]}
initialQuickEditPanel={{
sourceLayerId: 'layer-source',
prompt: `修改${assetKind}`,
size: '1024x1024',
aspectRatio: '1:1',
imageSize: '1K',
model: 'gpt-image-2',
status: 'idle',
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' }));
await waitFor(() => {
expect(editEditorImageMock).toHaveBeenCalledWith(
expect.objectContaining({
sourceReferenceId: 'resource-source',
targetLayerId: 'layer-source',
}),
);
});
},
);
it('rejects unregistered quick-edit sources before uploading marked references', async () => {
render(
<SubmissionWorkflowHarness
initialLayers={[
createLayer({
resourceId: 'local-resource-source',
sourceAssetId: 'upload-source',
}),
]}
initialQuickEditPanel={{
sourceLayerId: 'layer-source',
prompt: '修改未登记图片',
model: 'gpt-image-2',
aspectRatio: '1:1',
imageSize: '1K',
size: '1024x1024',
status: 'idle',
}}
initialQuickEditSelectionState={{
sourceLayerId: 'layer-source',
tool: 'rect',
model: 'gpt-image-2',
marks: [
{
id: 'quick-edit-selection-mark-1',
tool: 'rect',
x: 24,
y: 36,
width: 160,
height: 120,
},
],
references: [],
draftMark: null,
status: 'idle',
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' }));
await waitFor(() => {
expect(screen.getByTestId('quick-edit').textContent).toContain(
'failed:修改未登记图片:原图尚未登记为项目资源或素材',
);
});
expect(renderUiDesignAssetExtractionMarkedImageMock).not.toHaveBeenCalled();
expect(uploadEditorMediaAssetObjectFileMock).not.toHaveBeenCalled();
expect(editEditorImageMock).not.toHaveBeenCalled();
});
it('allows direct quick edit submission for icon spritesheets', async () => {
editEditorImageMock.mockResolvedValueOnce(
createGenerated({ prompt: '调整图集布局' }),
);
render(
<SubmissionWorkflowHarness
initialLayers={[createLayer({ assetKind: 'icon-spritesheet' })]}
initialQuickEditPanel={{
sourceLayerId: 'layer-source',
prompt: '调整图集布局',
size: '1024x1024',
aspectRatio: '1:1',
imageSize: '1K',
model: 'gpt-image-2',
status: 'idle',
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' }));
await waitFor(() => {
expect(editEditorImageMock).toHaveBeenCalledWith(
expect.objectContaining({ prompt: '调整图集布局' }),
);
});
});
it('clears quick edit panel after a queued edit reloads the project snapshot', async () => {
const applyProjectSnapshot = vi.fn();
const refreshTaskList = vi.fn();
const backendProject = {
projectId: 'editor-project-1',
title: '队列修图项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
...createLayer(),
src: 'data:image/png;base64,queued-generated',
},
],
resources: [],
updatedAt: '2026-06-23T08:00:00.000Z',
};
loadEditorProjectMock.mockResolvedValueOnce(backendProject);
editEditorImageMock.mockResolvedValueOnce({
...createGenerated({ prompt: '快速修图' }),
queueState: createQueueState(),
});
render(
<SubmissionWorkflowHarness
projectId="editor-project-1"
applyProjectSnapshot={applyProjectSnapshot}
onQueuedGenerationTask={refreshTaskList}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '打开快速编辑' }));
fireEvent.click(screen.getByRole('button', { name: '填写快速编辑' }));
fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' }));
await waitFor(() => {
expect(loadEditorProjectMock).toHaveBeenCalledWith('editor-project-1');
});
expect(refreshTaskList).toHaveBeenCalledTimes(1);
expect(applyProjectSnapshot).toHaveBeenCalledWith(backendProject);
expect(screen.getByTestId('quick-edit').textContent).toBe('-');
expect(screen.getByTestId('tool').textContent).toBe('select');
expect(screen.getByTestId('sidebar').textContent).toBe('layers');
});
it('submits quick-edit canvas generators with the source image reference', async () => {
editEditorImageMock.mockResolvedValueOnce(
createGenerated({ prompt: '把当前图改成雨天' }),
);
render(
<SubmissionWorkflowHarness
initialDialog={{
id: 'generation-dialog-quick-edit',
mode: 'quick-edit',
prompt: '把当前图改成雨天',
status: 'idle',
composerOpen: true,
sourceLayerId: 'layer-source',
imageModel: 'gpt-image-2',
aspectRatio: '4:3',
imageSize: '1K',
placeholder: {
x: 472,
y: 140,
width: 1024,
height: 768,
originalWidth: 1024,
originalHeight: 768,
},
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
await waitFor(() => {
expect(editEditorImageMock).toHaveBeenCalledWith(
expect.objectContaining({
prompt: '把当前图改成雨天',
sourceReferenceId: 'resource-source',
size: '1024x768',
model: 'gpt-image-2',
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('uploads local quick-edit references before submitting the edit request', async () => {
editEditorImageMock.mockResolvedValueOnce(
createGenerated({ prompt: '参考局部素材修图' }),
);
render(
<SubmissionWorkflowHarness
initialDialog={{
id: 'generation-dialog-quick-edit-local-reference',
mode: 'quick-edit',
prompt: '参考局部素材修图',
status: 'idle',
composerOpen: true,
sourceLayerId: 'layer-source',
imageModel: 'gpt-image-2',
generationReferences: [
{
id: 'local-reference',
label: '本地参考图',
src: 'data:image/png;base64,bG9jYWwtcmVmZXJlbmNl',
},
],
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
await waitFor(() => {
expect(editEditorImageMock).toHaveBeenCalledWith(
expect.objectContaining({
referenceImageSrcs: [
'generated-character-drafts/editor/generation-references/reference.png',
],
}),
);
});
expect(uploadEditorMediaAssetObjectFileMock).toHaveBeenCalledTimes(1);
});
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={{
sourceLayerId: 'layer-source',
prompt: '对1号红色圈选框里的内容做以下修改:改成蓝色',
size: '1024x768',
model: 'gpt-image-2',
status: 'idle',
quickEditReferences: [
{
id: 'ref-style',
label: '风格参考',
src: 'https://assets.example.test/style.png',
},
],
}}
initialQuickEditSelectionState={{
sourceLayerId: 'layer-source',
tool: 'rect',
model: 'gemini-3.1-flash-image-preview',
marks: [
{
id: 'quick-edit-selection-mark-1',
tool: 'rect',
x: 24,
y: 36,
width: 160,
height: 120,
},
],
references: [],
draftMark: null,
status: 'idle',
}}
/>,
);
renderUiDesignAssetExtractionMarkedImageMock.mockResolvedValueOnce(
'data:image/png;base64,bWFya2VkLXNvdXJjZQ==',
);
fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' }));
await waitFor(() => {
expect(renderUiDesignAssetExtractionMarkedImageMock).toHaveBeenCalledWith(
{
source: 'data:image/png;base64,source.png',
marks: [
{
id: 'quick-edit-selection-mark-1',
tool: 'rect',
x: 24,
y: 36,
width: 160,
height: 120,
},
],
showIndexLabels: true,
},
);
});
await waitFor(() => {
expect(uploadEditorMediaAssetObjectFileMock).toHaveBeenCalledWith(
expect.any(File),
'image',
expect.objectContaining({
assetKind: 'editor_generation_reference_image',
}),
);
expect(editEditorImageMock).toHaveBeenCalledWith(
expect.objectContaining({
sourceReferenceId: 'resource-source',
referenceImageSrcs: [
'generated-character-drafts/editor/generation-references/reference.png',
],
targetLayerId: 'layer-source',
}),
);
});
expect(
uploadEditorMediaAssetObjectFileMock.mock.invocationCallOrder[0],
).toBeLessThan(editEditorImageMock.mock.invocationCallOrder[0] ?? 0);
expect(generateEditorImageMock).not.toHaveBeenCalled();
});
it('submits canvas quick edits with numbered selection marks', async () => {
editEditorImageMock.mockResolvedValueOnce(
createGenerated({
prompt: '对1号红色圈选框里的内容做以下修改:改成蓝色',
}),
);
render(
<SubmissionWorkflowHarness
initialDialog={{
id: 'generation-dialog-quick-edit-marked',
mode: 'quick-edit',
prompt: '对1号红色圈选框里的内容做以下修改:改成蓝色',
status: 'idle',
composerOpen: true,
sourceLayerId: 'layer-source',
imageModel: 'gpt-image-2',
aspectRatio: '4:3',
imageSize: '1K',
placeholder: {
x: 472,
y: 140,
width: 1024,
height: 768,
originalWidth: 1024,
originalHeight: 768,
},
}}
initialQuickEditSelectionState={{
sourceLayerId: 'layer-source',
tool: 'rect',
model: 'gemini-3.1-flash-image-preview',
marks: [
{
id: 'quick-edit-selection-mark-1',
tool: 'rect',
x: 24,
y: 36,
width: 160,
height: 120,
},
],
references: [],
draftMark: null,
status: 'idle',
}}
/>,
);
renderUiDesignAssetExtractionMarkedImageMock.mockResolvedValueOnce(
'data:image/png;base64,bWFya2VkLXNvdXJjZQ==',
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
await waitFor(() => {
expect(renderUiDesignAssetExtractionMarkedImageMock).toHaveBeenCalledWith(
{
source: 'data:image/png;base64,source.png',
marks: [
{
id: 'quick-edit-selection-mark-1',
tool: 'rect',
x: 24,
y: 36,
width: 160,
height: 120,
},
],
showIndexLabels: true,
},
);
});
await waitFor(() => {
expect(editEditorImageMock).toHaveBeenCalledWith(
expect.objectContaining({
sourceReferenceId: 'resource-source',
referenceImageSrcs: [
'generated-character-drafts/editor/generation-references/reference.png',
],
model: 'gpt-image-2',
targetLayerId: 'layer-source',
}),
);
});
});
it('keeps failed quick edit panels visible with the normalized prompt', async () => {
const refreshWalletBalance = vi.fn();
editEditorImageMock.mockRejectedValueOnce(new Error('修图失败'));
render(
<SubmissionWorkflowHarness
onWalletBalanceMayHaveChanged={refreshWalletBalance}
initialQuickEditPanel={{
sourceLayerId: 'layer-source',
prompt: '',
size: '1024x768',
model: 'gpt-image-2',
status: 'idle',
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' }));
await waitFor(() => {
expect(screen.getByTestId('quick-edit').textContent).toBe(
'layer-source:failed:快速编辑图片:修图失败',
);
});
expect(refreshWalletBalance).toHaveBeenCalledTimes(1);
});
it('rejects direct video quick-edit submission without calling generation APIs', () => {
render(
<SubmissionWorkflowHarness
initialLayers={[
createLayer({
title: '源视频',
src: 'https://assets.example.test/source-video.mp4',
mediaType: 'video',
assetKind: 'video',
originalWidth: 854,
originalHeight: 480,
width: 427,
height: 240,
}),
]}
initialQuickEditPanel={{
sourceLayerId: 'layer-source',
prompt: '把当前图改成雨天',
size: '854x480',
model: 'gpt-image-2',
status: 'idle',
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' }));
expect(editEditorImageMock).not.toHaveBeenCalled();
expect(generateEditorImageMock).not.toHaveBeenCalled();
expect(generateEditorCharacterAnimationMock).not.toHaveBeenCalled();
expect(generateEditorVideoMock).not.toHaveBeenCalled();
expect(screen.getByTestId('quick-edit').textContent).toContain(
'failed:把当前图改成雨天:当前素材类型不支持快速编辑',
);
});
it('rejects a hydrated video quick-edit dialog before calling generation APIs', async () => {
render(
<SubmissionWorkflowHarness
initialLayers={[
createLayer({
title: '历史视频',
src: 'https://assets.example.test/history.mp4',
mediaType: 'video',
assetKind: 'video',
originalWidth: 854,
originalHeight: 480,
width: 427,
height: 240,
}),
]}
initialDialog={{
id: 'generation-dialog-hydrated-video-quick-edit',
mode: 'quick-edit',
prompt: '把当前画面改成雨天',
status: 'idle',
composerOpen: true,
sourceLayerId: 'layer-source',
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
await waitFor(() => {
expect(screen.getByTestId('tracked-dialog').textContent).toContain(
'quick-edit:把当前画面改成雨天:failed:open:当前素材类型不支持快速编辑',
);
});
expect(editEditorImageMock).not.toHaveBeenCalled();
expect(generateEditorImageMock).not.toHaveBeenCalled();
expect(generateEditorVideoMock).not.toHaveBeenCalled();
});
it('uploads inline video references and keeps image objectKey and asset URLs', async () => {
generateEditorVideoMock.mockResolvedValueOnce(createVideoGenerated());
render(
<SubmissionWorkflowHarness
initialDialog={{
id: 'dialog-video-references',
mode: 'video',
prompt: '让画面动起来',
status: 'idle',
composerOpen: true,
videoModel: 'seedance2.0-fast',
generationReferences: [
{
id: 'image-reference',
label: '图片参考',
src: 'data:image/png;base64,aW1hZ2U=',
objectKey: 'generated-character-drafts/editor/refs/image.png',
},
{
id: 'video-reference',
label: '视频参考',
src: 'data:video/mp4;base64,dmlkZW8=',
mediaType: 'video',
},
{
id: 'audio-reference',
label: '音频参考',
src: 'asset://asset-audio-reference',
mediaType: 'audio',
},
],
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
await waitFor(() => {
expect(uploadEditorMediaAssetObjectFileMock).toHaveBeenCalledWith(
expect.any(File),
'video',
expect.objectContaining({
assetKind: 'editor_generation_reference_video',
}),
);
expect(generateEditorVideoMock).toHaveBeenCalledWith(
expect.objectContaining({
referenceImageSrcs: [
'generated-character-drafts/editor/refs/image.png',
],
referenceVideoSrcs: [
'generated-character-drafts/editor/generation-references/reference.png',
],
referenceAudioSrcs: ['asset://asset-audio-reference'],
}),
);
});
});
it('rejects character action quick edits', async () => {
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: [
{
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={{
sourceLayerId: 'layer-source',
prompt: '让当前图跳得更高',
size: '1024x1024',
model: 'gpt-image-2',
status: 'idle',
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' }));
expect(editEditorImageMock).not.toHaveBeenCalled();
expect(generateEditorCharacterAnimationMock).not.toHaveBeenCalled();
expect(generateEditorImageMock).not.toHaveBeenCalled();
expect(generateEditorVideoMock).not.toHaveBeenCalled();
expect(screen.getByTestId('quick-edit').textContent).toContain(
'failed:让当前图跳得更高:当前素材类型不支持快速编辑',
);
});
it('captures a local edit fallback when the project response has no snapshot', async () => {
const applyProjectSnapshot = vi.fn();
const captureCanvasHistory = vi.fn();
const fitLayers = vi.fn();
editEditorImageMock.mockResolvedValueOnce(
createGenerated({ prompt: '修改当前图片' }),
);
render(
<SubmissionWorkflowHarness
projectId="project-edit-size"
applyProjectSnapshot={applyProjectSnapshot}
captureCanvasHistory={captureCanvasHistory}
fitLayers={fitLayers}
initialDialog={{
mode: 'edit',
prompt: '',
status: 'idle',
sourceLayerId: 'layer-source',
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
expect(screen.getByTestId('dialog').textContent).toBe(
'edit:generating:open:-:-:-',
);
await waitFor(() => {
expect(editEditorImageMock).toHaveBeenCalledWith(
expect.objectContaining({
prompt: '修改当前图片',
sourceReferenceId: 'resource-source',
canvasCompletion: expect.objectContaining({
placeholder: {
x: 472,
y: 140,
width: 1024,
height: 768,
originalWidth: 1024,
originalHeight: 768,
},
}),
}),
);
});
await waitFor(() => {
expect(screen.getByTestId('layers').textContent).toContain(
'layer-edit-1:源图 修改结果:resource-source:-',
);
});
expect(screen.getByTestId('dialog').textContent).toBe('-');
expect(screen.getByTestId('tool').textContent).toBe('select');
expect(screen.getByTestId('fit-count').textContent).toBe('1');
expect(captureCanvasHistory).toHaveBeenCalledTimes(1);
expect(captureCanvasHistory).toHaveBeenCalledWith({
type: 'generate-image',
count: 1,
});
expect(applyProjectSnapshot).not.toHaveBeenCalled();
expect(fitLayers).toHaveBeenCalledWith(expect.any(Array), {
captureHistory: false,
});
});
it('submits scene intent through the dedicated scene endpoint', async () => {
generateEditorSceneMock.mockResolvedValueOnce(
createGenerated({
width: 1024,
height: 576,
prompt: '雨夜中的欧洲小镇街道',
}),
);
render(
<SubmissionWorkflowHarness
initialDialog={{
id: 'dialog-scene',
mode: 'scene',
prompt: '雨夜中的欧洲小镇街道',
status: 'idle',
composerOpen: true,
sceneStylePreset: 'anime',
sceneCustomStyle: '',
generationReferences: [],
imageModel: 'gemini-3.1-flash-image-preview',
aspectRatio: '16:9',
imageSize: '1K',
placeholder: {
x: 100,
y: 100,
width: 1024,
height: 576,
originalWidth: 1024,
originalHeight: 576,
},
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
await waitFor(() => {
expect(generateEditorSceneMock).toHaveBeenCalledWith(
expect.objectContaining({
sceneContent: '雨夜中的欧洲小镇街道',
stylePreset: 'anime',
model: 'gemini-3.1-flash-image-preview',
aspectRatio: '16:9',
imageSize: '1K',
assetLabel: '游戏场景 1',
generationInputs: {
fields: [
{ title: '画面内容', value: '雨夜中的欧洲小镇街道' },
{ title: '视觉风格', value: 'anime' },
{
title: '模型',
value: 'gemini-3.1-flash-image-preview',
},
{ title: '比例', value: '16:9' },
{ title: '尺寸', value: '1K' },
],
references: [],
},
}),
);
});
expect(generateEditorImageMock).not.toHaveBeenCalled();
await waitFor(() => {
expect(screen.getByTestId('layers').textContent).toContain(
'layer-generated-1:游戏场景 1:-:scene',
);
});
});
it('forwards inline scene generation warnings before adding the result', async () => {
generateEditorSceneMock.mockResolvedValueOnce(
createGenerated({
width: 1024,
height: 576,
warning: {
code: 'delivery-size-normalization-failed',
reason: '尺寸恢复失败,已保留生成服务返回的实际尺寸。',
},
}),
);
render(
<SubmissionWorkflowHarness
initialDialog={{
id: 'dialog-scene-warning',
mode: 'scene',
prompt: '雨夜海边车站',
status: 'idle',
composerOpen: true,
sceneStylePreset: 'anime',
sceneCustomStyle: '',
generationReferences: [],
imageModel: 'gemini-3.1-flash-image-preview',
aspectRatio: '16:9',
imageSize: '1K',
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
await waitFor(() => {
expect(screen.getByTestId('generation-warning').textContent).toBe(
'尺寸恢复失败,已保留生成服务返回的实际尺寸。',
);
});
expect(screen.getByTestId('layers').textContent).toContain(
'layer-generated-1:游戏场景 1:-:scene',
);
});
it('reloads the authoritative scene project when the first queued snapshot is unresolved', async () => {
vi.useFakeTimers();
try {
const applyProjectSnapshot = vi.fn();
const unresolvedProject = {
projectId: 'editor-project-1',
title: '场景队列项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
layerId: 'generation-dialog-scene-queued',
resourceId: 'generation-dialog-scene-queued',
itemType: 'generation-dialog',
dialog: {
id: 'dialog-scene-queued',
mode: 'scene',
status: 'generating',
},
},
],
resources: [],
updatedAt: '2026-08-06T08:00:00.000Z',
};
const completedProject = {
...unresolvedProject,
layers: [
{
layerId: 'layer-scene-result',
resourceId: 'resource-scene-result',
title: '游戏场景 1',
assetKind: 'scene',
},
{
layerId: 'generation-dialog-scene-queued',
resourceId: 'generation-dialog-scene-queued',
itemType: 'generation-dialog',
dialog: {
id: 'dialog-scene-queued',
mode: 'scene',
status: 'idle',
generatedLayerId: 'layer-scene-result',
},
},
],
updatedAt: '2026-08-06T08:00:01.000Z',
};
generateEditorSceneMock.mockResolvedValueOnce({
...createGenerated({ width: 1024, height: 576 }),
queueState: createQueueState(),
});
loadEditorProjectMock
.mockResolvedValueOnce(unresolvedProject)
.mockResolvedValueOnce(completedProject);
render(
<SubmissionWorkflowHarness
projectId="editor-project-1"
applyProjectSnapshot={applyProjectSnapshot}
initialDialog={{
id: 'dialog-scene-queued',
mode: 'scene',
prompt: '雨夜海边车站',
status: 'idle',
composerOpen: true,
sceneStylePreset: 'anime',
sceneCustomStyle: '',
generationReferences: [],
imageModel: 'gemini-3.1-flash-image-preview',
aspectRatio: '16:9',
imageSize: '1K',
placeholder: {
x: 100,
y: 100,
width: 1024,
height: 576,
originalWidth: 1024,
originalHeight: 576,
},
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(loadEditorProjectMock).toHaveBeenCalledTimes(1);
await act(async () => {
await vi.advanceTimersByTimeAsync(650);
});
expect(loadEditorProjectMock).toHaveBeenCalledTimes(2);
expect(applyProjectSnapshot).toHaveBeenNthCalledWith(
1,
unresolvedProject,
);
expect(applyProjectSnapshot).toHaveBeenNthCalledWith(2, completedProject);
} finally {
vi.useRealTimers();
}
});
it('does not add a local scene layer when the backend omits its project snapshot', async () => {
generateEditorSceneMock.mockResolvedValueOnce(
createGenerated({
width: 1024,
height: 576,
project: null,
}),
);
render(
<SubmissionWorkflowHarness
projectId="editor-project-1"
initialDialog={{
id: 'dialog-scene-authority',
mode: 'scene',
prompt: '雨夜海边车站',
status: 'idle',
composerOpen: true,
sceneStylePreset: 'anime',
sceneCustomStyle: '',
generationReferences: [],
imageModel: 'gemini-3.1-flash-image-preview',
aspectRatio: '16:9',
imageSize: '1K',
placeholder: {
x: 100,
y: 100,
width: 1024,
height: 576,
originalWidth: 1024,
originalHeight: 576,
},
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
await waitFor(() => {
expect(screen.getByTestId('dialog').textContent).toBe(
'scene:idle:open:-:placeholder:-',
);
});
expect(screen.getByTestId('layers').textContent).not.toContain(
'layer-generated-1',
);
});
it('uses the latest moved canvas placeholder for local-only generation fallback', async () => {
const callOrder: string[] = [];
const captureCanvasHistory = vi.fn((action: CanvasHistoryAction) => {
callOrder.push(`capture:${action.type}:${action.count}`);
});
const onAppendCanvasLayers = vi.fn(() => {
callOrder.push('append');
});
generateEditorImageMock.mockResolvedValueOnce(
createGenerated({ width: 512, height: 512 }),
);
render(
<SubmissionWorkflowHarness
captureCanvasHistory={captureCanvasHistory}
onAppendCanvasLayers={onAppendCanvasLayers}
initialDialog={{
id: 'dialog-1',
mode: 'generate',
prompt: '占位生成',
status: 'idle',
composerOpen: true,
placeholder: {
x: 100,
y: 100,
width: 420,
height: 420,
originalWidth: 2048,
originalHeight: 2048,
},
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
fireEvent.click(screen.getByRole('button', { name: '移动占位' }));
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
await waitFor(() => {
expect(screen.getByTestId('layers').textContent).toContain(
'layer-generated-1:生成图片 1:-:-',
);
});
expect(screen.getByTestId('dialog').textContent).toBe(
'generate:idle:open:layer-generated-1:placeholder:-',
);
expect(captureCanvasHistory).toHaveBeenCalledWith({
type: 'generate-image',
count: 1,
});
expect(onAppendCanvasLayers).toHaveBeenCalledTimes(1);
expect(callOrder).toEqual(['capture:generate-image:1', 'append']);
});
it('passes canvas completion context and refreshes from the backend project snapshot', async () => {
const applyProjectSnapshot = vi.fn();
const captureCanvasHistory = vi.fn();
generateEditorImageMock.mockResolvedValueOnce({
...createGenerated({
width: 1024,
height: 1024,
prompt: '银发游侠',
}),
project: {
projectId: 'editor-project-1',
title: '角色项目',
viewport: { x: 12, y: 24, scale: 1.5 },
layers: [
{
layerId: 'layer-editor-resource-character',
resourceId: 'editor-resource-character',
},
{
layerId: 'layer-editor-resource-character-source',
resourceId: 'editor-resource-character-source',
},
],
resources: [
{
resourceId: 'editor-resource-character',
projectId: 'editor-project-1',
imageSrc: '/generated-character-drafts/editor/character.png',
width: 1024,
height: 1024,
sourceType: 'generated',
assetKind: 'character',
},
{
resourceId: 'editor-resource-character-source',
projectId: 'editor-project-1',
imageSrc:
'/generated-character-drafts/editor/character-green-screen-source.png',
width: 1024,
height: 1024,
sourceType: 'generated',
assetKind: 'character',
},
],
updatedAt: '2026-06-21T12:16:22.000Z',
},
});
render(
<SubmissionWorkflowHarness
projectId="editor-project-1"
applyProjectSnapshot={applyProjectSnapshot}
captureCanvasHistory={captureCanvasHistory}
initialDialog={{
id: 'dialog-character',
mode: 'character',
prompt: ' 银发游侠 ',
status: 'idle',
composerOpen: true,
imageModel: 'gpt-image-2',
placeholder: {
x: 200,
y: 160,
width: 420,
height: 560,
originalWidth: 1024,
originalHeight: 1536,
},
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
await waitFor(() => {
expect(generateEditorImageMock).toHaveBeenCalledWith(
expect.objectContaining({
prompt: '银发游侠',
kind: 'character',
projectId: 'editor-project-1',
assetKind: 'character',
assetFolderId: 'project',
canvasCompletion: {
dialogId: 'dialog-character',
title: '角色形象 1',
placeholder: {
x: 200,
y: 160,
width: 420,
height: 560,
originalWidth: 1024,
originalHeight: 1536,
},
},
}),
);
});
await waitFor(() => {
expect(applyProjectSnapshot).toHaveBeenCalledWith(
expect.objectContaining({
projectId: 'editor-project-1',
title: '角色项目',
layers: expect.arrayContaining([
expect.objectContaining({
resourceId: 'editor-resource-character',
}),
expect.objectContaining({
resourceId: 'editor-resource-character-source',
}),
]),
resources: expect.arrayContaining([
expect.objectContaining({
resourceId: 'editor-resource-character',
assetKind: 'character',
}),
expect.objectContaining({
resourceId: 'editor-resource-character-source',
assetKind: 'character',
}),
]),
}),
);
});
expect(screen.getByTestId('layers').textContent).not.toContain(
'layer-generated-1',
);
expect(captureCanvasHistory).not.toHaveBeenCalled();
});
it('submits audio generations with asset library context and upserts the returned asset', async () => {
const applyProjectSnapshot = vi.fn();
const upsertGeneratedAsset = vi.fn();
generateEditorSoundEffectMock.mockResolvedValueOnce(
createAudioGenerated({
project: {
projectId: 'editor-project-audio',
title: '音频项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
resources: [],
updatedAt: '2026-06-27T00:00:00.000Z',
},
asset: {
assetId: 'asset-sound-1',
folderId: 'user-1:asset-folder:project',
label: '游戏音效 1',
imageSrc: 'https://assets.example.test/generated-audio.mp3',
width: 420,
height: 120,
sourceType: 'generated',
assetKind: 'sound-effect',
},
}),
);
render(
<SubmissionWorkflowHarness
projectId="editor-project-audio"
applyProjectSnapshot={applyProjectSnapshot}
upsertGeneratedAsset={upsertGeneratedAsset}
initialDialog={{
id: 'dialog-sound',
mode: 'audio-sound-effect',
prompt: '\uFEFF金币掉落叮当声\uFEFF',
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('rejects an empty canonical sound effect prompt without a fallback', async () => {
render(
<SubmissionWorkflowHarness
initialDialog={{
id: 'dialog-sound-empty',
mode: 'audio-sound-effect',
prompt: '\uFEFF \n\t\uFEFF',
status: 'idle',
composerOpen: true,
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
await waitFor(() => {
expect(generateEditorSoundEffectMock).not.toHaveBeenCalled();
expect(screen.getByTestId('tracked-dialog').textContent).toBe(
'audio-sound-effect::failed:open:音效描述不能为空',
);
});
expect(screen.getByTestId('tracked-dialog').textContent).not.toContain(
'游戏音效',
);
});
it('claims and canonicalizes one SFX submission before the first await', async () => {
const generation =
createDeferred<ReturnType<typeof createAudioGenerated>>();
generateEditorSoundEffectMock.mockReturnValueOnce(generation.promise);
render(
<SubmissionWorkflowHarness
currentUserId="user-a"
initialDialog={{
id: 'dialog-sfx-claim',
mode: 'audio-sound-effect',
prompt: '\uFEFF循环环境声\u3000',
status: 'idle',
composerOpen: true,
soundModel: 'eleven_text_to_sound_v2',
soundDurationMode: 'auto',
soundDurationSeconds: 7.3,
soundLoop: true,
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: '提交当前生成' }));
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
expect(generateEditorSoundEffectMock).toHaveBeenCalledTimes(1);
expect(screen.getByTestId('tracked-dialog').textContent).toBe(
'audio-sound-effect:循环环境声:idle:open:-',
);
expect(screen.getByTestId('sound-effect-assist').textContent).toBe(
'submitting',
);
await act(async () => {
generation.resolve(
createAudioGenerated({
prompt: '循环环境声',
audioKind: 'sound-effect',
}),
);
await generation.promise;
});
await waitFor(() => {
expect(screen.getByTestId('sound-effect-assist').textContent).toBe(
'idle',
);
expect(screen.getByTestId('layers').textContent).toContain(
'sound-effect',
);
});
});
it('claims and canonicalizes one background music submission before the first await', async () => {
const representativeComplexPromptCase =
backgroundMusicPromptCanonicalizationCases.find(
({ name }) => name === 'representative-complex',
);
if (!representativeComplexPromptCase) {
throw new Error('missing representative-complex prompt fixture');
}
const canonicalPrompt = representativeComplexPromptCase.prompt;
const generation =
createDeferred<ReturnType<typeof createAudioGenerated>>();
const onAppendCanvasLayers = vi.fn<[CanvasLayer[]], void>();
generateEditorBackgroundMusicMock.mockReturnValueOnce(generation.promise);
render(
<SubmissionWorkflowHarness
currentUserId="user-a"
onAppendCanvasLayers={onAppendCanvasLayers}
initialDialog={{
id: 'dialog-bgm',
mode: 'audio-background-music',
prompt: representativeComplexPromptCase.input,
status: 'idle',
composerOpen: true,
placeholder: {
x: 200,
y: 160,
width: 420,
height: 120,
originalWidth: 420,
originalHeight: 120,
},
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
const submitButton = screen.getByRole('button', {
name: '提交当前生成',
});
fireEvent.click(submitButton);
fireEvent.click(submitButton);
expect(generateEditorBackgroundMusicMock).toHaveBeenCalledTimes(1);
expect(generateEditorBackgroundMusicMock).toHaveBeenCalledWith({
gptDescriptionPrompt: canonicalPrompt,
makeInstrumental: true,
projectId: undefined,
generationInputs: {
version: 2,
action: 'audio.background-music.generate',
fields: [
{
id: 'prompt',
title: 'gpt_description_prompt',
value: canonicalPrompt,
},
],
references: [],
},
assetFolderId: undefined,
assetLabel: '游戏背景音乐 1',
});
expect(screen.getByTestId('tracked-dialog').textContent).toContain(
`audio-background-music:${canonicalPrompt}:idle:open`,
);
expect(screen.getByTestId('background-music-assist').textContent).toBe(
'submitting',
);
await act(async () => {
generation.resolve(
createAudioGenerated({
prompt: canonicalPrompt,
actualPrompt: canonicalPrompt,
model: 'chirp-v4',
audioKind: 'background-music',
durationSeconds: null,
}),
);
await generation.promise;
});
await waitFor(() => {
expect(screen.getByTestId('background-music-assist').textContent).toBe(
'idle',
);
expect(screen.getByTestId('layers').textContent).toContain(
'background-music',
);
expect(onAppendCanvasLayers).toHaveBeenCalledTimes(1);
});
const generatedLayer = onAppendCanvasLayers.mock.calls[0]?.[0]?.[0];
expect(generatedLayer?.prompt).toBe(canonicalPrompt);
expect(generatedLayer?.actualPrompt).toBe(canonicalPrompt);
expect(generatedLayer?.generationInputs).toEqual({
version: 2,
action: 'audio.background-music.generate',
fields: [
{
id: 'prompt',
title: 'gpt_description_prompt',
value: canonicalPrompt,
},
],
references: [],
});
});
it('does not send empty or over-limit background music prompts', () => {
const view = render(
<SubmissionWorkflowHarness
currentUserId="user-a"
projectId="editor-project-bgm"
initialDialog={{
id: 'dialog-bgm-invalid',
mode: 'audio-background-music',
prompt: '\u0085 \n\t\u0085',
status: 'idle',
composerOpen: true,
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
expect(generateEditorBackgroundMusicMock).not.toHaveBeenCalled();
expect(screen.getByTestId('tracked-dialog').textContent).toBe(
'audio-background-music::idle:open:-',
);
view.rerender(
<SubmissionWorkflowHarness
currentUserId="user-a"
projectId="editor-project-bgm"
initialDialog={{
id: 'dialog-bgm-invalid',
mode: 'audio-background-music',
prompt: 'A'.repeat(201),
status: 'idle',
composerOpen: true,
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
expect(generateEditorBackgroundMusicMock).not.toHaveBeenCalled();
expect(screen.getByTestId('tracked-dialog').textContent).toContain(
`${'A'.repeat(201)}:idle:open`,
);
});
it.each([
['one code point', 'A'],
['exactly 200 code points', '乐'.repeat(200)],
])('submits a background music prompt with %s', async (_label, prompt) => {
generateEditorBackgroundMusicMock.mockResolvedValueOnce(
createAudioGenerated({
prompt,
actualPrompt: prompt,
model: 'chirp-v4',
audioKind: 'background-music',
durationSeconds: null,
}),
);
render(
<SubmissionWorkflowHarness
currentUserId="user-a"
initialDialog={{
id: 'dialog-bgm-boundary',
mode: 'audio-background-music',
prompt,
status: 'idle',
composerOpen: true,
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
await waitFor(() => {
expect(generateEditorBackgroundMusicMock).toHaveBeenCalledWith(
expect.objectContaining({
gptDescriptionPrompt: prompt,
makeInstrumental: true,
}),
);
});
});
it('keeps a rejected background music prompt visible and restores the current composer', async () => {
const generation =
createDeferred<ReturnType<typeof createAudioGenerated>>();
const refreshWalletBalance = vi.fn();
generateEditorBackgroundMusicMock.mockReturnValueOnce(generation.promise);
render(
<SubmissionWorkflowHarness
currentUserId="user-a"
projectId="editor-project-bgm"
onWalletBalanceMayHaveChanged={refreshWalletBalance}
initialDialog={{
id: 'dialog-bgm-rejected',
mode: 'audio-background-music',
prompt: '\u0085 森林冒险 \u0085',
status: 'idle',
composerOpen: true,
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
expect(screen.getByTestId('tracked-dialog').textContent).toBe(
'audio-background-music:森林冒险:idle:open:-',
);
expect(screen.getByTestId('background-music-assist').textContent).toBe(
'submitting',
);
await act(async () => {
generation.reject(new Error('正式生成被拒绝'));
await generation.promise.catch(() => undefined);
});
await waitFor(() => {
expect(screen.getByTestId('tracked-dialog').textContent).toBe(
'audio-background-music:森林冒险:failed:open:正式生成被拒绝',
);
expect(screen.getByTestId('background-music-assist').textContent).toBe(
'idle',
);
});
expect(refreshWalletBalance).toHaveBeenCalledTimes(1);
});
it('records a background music rejection without reopening an archived dialog', async () => {
const generation =
createDeferred<ReturnType<typeof createAudioGenerated>>();
generateEditorBackgroundMusicMock.mockReturnValueOnce(generation.promise);
render(
<SubmissionWorkflowHarness
currentUserId="user-a"
projectId="editor-project-bgm"
initialDialog={{
id: 'dialog-bgm-archived',
mode: 'audio-background-music',
prompt: '森林冒险',
status: 'idle',
composerOpen: true,
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
fireEvent.click(screen.getByRole('button', { name: '切换生成对话' }));
expect(screen.getByTestId('tracked-dialog').textContent).toBe(
'audio-background-music:森林冒险:idle:closed:-',
);
await act(async () => {
generation.reject(new Error('正式生成失败'));
await generation.promise.catch(() => undefined);
});
await waitFor(() => {
expect(screen.getByTestId('tracked-dialog').textContent).toBe(
'audio-background-music:森林冒险:failed:closed:正式生成失败',
);
});
expect(screen.getByTestId('dialog').textContent).toContain(
'generate:idle:open',
);
});
it('restores the current composer when an accepted background music queue task later fails', async () => {
generateEditorBackgroundMusicMock.mockResolvedValueOnce(
createAudioGenerated({
prompt: '森林冒险',
actualPrompt: '森林冒险',
model: 'chirp-v4',
audioKind: 'background-music',
durationSeconds: null,
queueState: createQueueState({
status: 'failed',
error: '背景音乐生成失败',
}),
}),
);
render(
<SubmissionWorkflowHarness
currentUserId="user-a"
projectId="editor-project-bgm"
initialDialog={{
id: 'dialog-bgm-queue-failed',
mode: 'audio-background-music',
prompt: '森林冒险',
status: 'idle',
composerOpen: true,
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
await waitFor(() => {
expect(screen.getByTestId('tracked-dialog').textContent).toBe(
'audio-background-music:森林冒险:failed:open:背景音乐生成失败',
);
expect(screen.getByTestId('background-music-assist').textContent).toBe(
'idle',
);
});
});
it('keeps an archived composer closed when an accepted background music queue task later fails', async () => {
vi.useFakeTimers();
try {
generateEditorBackgroundMusicMock.mockResolvedValueOnce(
createAudioGenerated({
prompt: '森林冒险',
actualPrompt: '森林冒险',
model: 'chirp-v4',
audioKind: 'background-music',
durationSeconds: null,
queueState: createQueueState({ status: 'queued' }),
}),
);
getExternalGenerationJobStatusMock.mockResolvedValueOnce({
job: createQueueState({
status: 'failed',
error: '背景音乐生成失败',
}),
});
render(
<SubmissionWorkflowHarness
currentUserId="user-a"
projectId="editor-project-bgm"
initialDialog={{
id: 'dialog-bgm-queue-failed-archived',
mode: 'audio-background-music',
prompt: '森林冒险',
status: 'idle',
composerOpen: true,
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(screen.getByTestId('tracked-dialog').textContent).toBe(
'audio-background-music:森林冒险:generating:closed:-',
);
fireEvent.click(screen.getByRole('button', { name: '切换生成对话' }));
await act(async () => {
await vi.advanceTimersByTimeAsync(1600);
});
expect(screen.getByTestId('tracked-dialog').textContent).toBe(
'audio-background-music:森林冒险:failed:closed:背景音乐生成失败',
);
expect(screen.getByTestId('dialog').textContent).toContain(
'generate:idle:open',
);
} finally {
vi.useRealTimers();
}
});
it('refreshes the project task list and account wallet after the submitted dialog is deleted', async () => {
vi.useFakeTimers();
try {
const generation =
createDeferred<ReturnType<typeof createAudioGenerated>>();
const refreshTaskList = vi.fn();
const refreshWalletBalance = vi.fn();
const appendCanvasLayers = vi.fn();
generateEditorBackgroundMusicMock.mockReturnValueOnce(generation.promise);
getExternalGenerationJobStatusMock.mockResolvedValueOnce({
job: createQueueState({ status: 'completed' }),
});
render(
<SubmissionWorkflowHarness
currentUserId="user-a"
projectId="project-a"
onAppendCanvasLayers={appendCanvasLayers}
onQueuedGenerationTask={refreshTaskList}
onWalletBalanceMayHaveChanged={refreshWalletBalance}
initialDialog={{
id: 'dialog-bgm-deleted-after-submit',
mode: 'audio-background-music',
prompt: '森林冒险',
status: 'idle',
composerOpen: true,
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
fireEvent.click(screen.getByRole('button', { name: '删除跟踪对话' }));
await act(async () => {
generation.resolve(
createAudioGenerated({
prompt: '森林冒险',
actualPrompt: '森林冒险',
model: 'chirp-v4',
audioKind: 'background-music',
durationSeconds: null,
queueState: createQueueState({ status: 'queued' }),
}),
);
await generation.promise;
await Promise.resolve();
});
expect(refreshTaskList).toHaveBeenCalledTimes(1);
expect(refreshWalletBalance).not.toHaveBeenCalled();
expect(screen.getByTestId('tracked-dialog').textContent).toBe('-');
await act(async () => {
await vi.advanceTimersByTimeAsync(1600);
});
expect(refreshWalletBalance).toHaveBeenCalledTimes(1);
expect(appendCanvasLayers).not.toHaveBeenCalled();
expect(screen.getByTestId('tracked-dialog').textContent).toBe('-');
} finally {
vi.useRealTimers();
}
});
it('refreshes only the account wallet after the same account switches projects before queue acceptance', async () => {
const generation =
createDeferred<ReturnType<typeof createAudioGenerated>>();
const refreshTaskList = vi.fn();
const refreshWalletBalance = vi.fn();
const appendCanvasLayers = vi.fn();
generateEditorBackgroundMusicMock.mockReturnValueOnce(generation.promise);
const initialDialog = {
id: 'dialog-bgm-project-switch',
mode: 'audio-background-music' as const,
prompt: '项目 A 的背景音乐',
status: 'idle' as const,
composerOpen: true,
};
const view = render(
<SubmissionWorkflowHarness
currentUserId="user-a"
projectId="project-a"
onAppendCanvasLayers={appendCanvasLayers}
onQueuedGenerationTask={refreshTaskList}
onWalletBalanceMayHaveChanged={refreshWalletBalance}
initialDialog={initialDialog}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
view.rerender(
<SubmissionWorkflowHarness
currentUserId="user-a"
projectId="project-b"
onAppendCanvasLayers={appendCanvasLayers}
onQueuedGenerationTask={refreshTaskList}
onWalletBalanceMayHaveChanged={refreshWalletBalance}
initialDialog={{
...initialDialog,
prompt: '项目 B 的背景音乐',
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
await act(async () => {
generation.resolve(
createAudioGenerated({
prompt: initialDialog.prompt,
actualPrompt: initialDialog.prompt,
model: 'chirp-v4',
audioKind: 'background-music',
durationSeconds: null,
queueState: createQueueState({ status: 'completed' }),
}),
);
await generation.promise;
await Promise.resolve();
});
expect(refreshTaskList).not.toHaveBeenCalled();
expect(refreshWalletBalance).toHaveBeenCalledTimes(1);
expect(appendCanvasLayers).not.toHaveBeenCalled();
expect(screen.getByTestId('tracked-dialog').textContent).toBe(
'audio-background-music:项目 B 的背景音乐:idle:open:-',
);
});
it('does not refresh the previous account task list or wallet when the account changes with the same project id before queue acceptance', async () => {
const generation =
createDeferred<ReturnType<typeof createAudioGenerated>>();
const refreshTaskList = vi.fn();
const refreshWalletBalance = vi.fn();
const appendCanvasLayers = vi.fn();
generateEditorBackgroundMusicMock.mockReturnValueOnce(generation.promise);
const initialDialog = {
id: 'dialog-bgm-account-switch',
mode: 'audio-background-music' as const,
prompt: '账号 A 的背景音乐',
status: 'idle' as const,
composerOpen: true,
};
const view = render(
<SubmissionWorkflowHarness
currentUserId="user-a"
projectId="project-a"
onAppendCanvasLayers={appendCanvasLayers}
onQueuedGenerationTask={refreshTaskList}
onWalletBalanceMayHaveChanged={refreshWalletBalance}
initialDialog={initialDialog}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
view.rerender(
<SubmissionWorkflowHarness
currentUserId="user-b"
projectId="project-a"
onAppendCanvasLayers={appendCanvasLayers}
onQueuedGenerationTask={refreshTaskList}
onWalletBalanceMayHaveChanged={refreshWalletBalance}
initialDialog={{
...initialDialog,
prompt: '账号 B 的背景音乐',
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
await act(async () => {
generation.resolve(
createAudioGenerated({
prompt: initialDialog.prompt,
actualPrompt: initialDialog.prompt,
model: 'chirp-v4',
audioKind: 'background-music',
durationSeconds: null,
queueState: createQueueState({ status: 'completed' }),
}),
);
await generation.promise;
await Promise.resolve();
});
expect(refreshTaskList).not.toHaveBeenCalled();
expect(refreshWalletBalance).not.toHaveBeenCalled();
expect(appendCanvasLayers).not.toHaveBeenCalled();
expect(screen.getByTestId('tracked-dialog').textContent).toBe(
'audio-background-music:账号 B 的背景音乐:idle:open:-',
);
});
it('refreshes the wallet for an inline sound effect response while the account stays current', async () => {
const generation =
createDeferred<ReturnType<typeof createAudioGenerated>>();
const refreshWalletBalance = vi.fn();
generateEditorSoundEffectMock.mockReturnValueOnce(generation.promise);
render(
<SubmissionWorkflowHarness
currentUserId="user-a"
projectId="project-a"
onWalletBalanceMayHaveChanged={refreshWalletBalance}
initialDialog={createSoundEffectWalletDialog(
'dialog-sfx-wallet-current',
'账号 A 的音效',
)}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
await act(async () => {
// 直连回包:不带 queueState,钱包刷新由共享 wrapper 立即触发。
generation.resolve(
createAudioGenerated({
prompt: '账号 A 的音效',
audioKind: 'sound-effect',
}),
);
await generation.promise;
await Promise.resolve();
});
expect(refreshWalletBalance).toHaveBeenCalledTimes(1);
});
it('does not refresh the previous account wallet when an inline sound effect response lands after the account changes', async () => {
const generation =
createDeferred<ReturnType<typeof createAudioGenerated>>();
const refreshWalletBalance = vi.fn();
generateEditorSoundEffectMock.mockReturnValueOnce(generation.promise);
const initialDialog = createSoundEffectWalletDialog(
'dialog-sfx-wallet-account-switch',
'账号 A 的音效',
);
const view = render(
<SubmissionWorkflowHarness
currentUserId="user-a"
projectId="project-a"
onWalletBalanceMayHaveChanged={refreshWalletBalance}
initialDialog={initialDialog}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
view.rerender(
<SubmissionWorkflowHarness
currentUserId="user-b"
projectId="project-a"
onWalletBalanceMayHaveChanged={refreshWalletBalance}
initialDialog={{
...initialDialog,
prompt: '账号 B 的音效',
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
await act(async () => {
generation.resolve(
createAudioGenerated({
prompt: '账号 A 的音效',
audioKind: 'sound-effect',
}),
);
await generation.promise;
await Promise.resolve();
});
expect(refreshWalletBalance).not.toHaveBeenCalled();
});
it('does not refresh the previous account wallet when an inline sound effect request fails after the account changes', async () => {
const generation =
createDeferred<ReturnType<typeof createAudioGenerated>>();
const refreshWalletBalance = vi.fn();
generateEditorSoundEffectMock.mockReturnValueOnce(generation.promise);
const initialDialog = createSoundEffectWalletDialog(
'dialog-sfx-wallet-account-switch-failure',
'账号 A 的音效',
);
const view = render(
<SubmissionWorkflowHarness
currentUserId="user-a"
projectId="project-a"
onWalletBalanceMayHaveChanged={refreshWalletBalance}
initialDialog={initialDialog}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
view.rerender(
<SubmissionWorkflowHarness
currentUserId="user-b"
projectId="project-a"
onWalletBalanceMayHaveChanged={refreshWalletBalance}
initialDialog={{
...initialDialog,
prompt: '账号 B 的音效',
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
await act(async () => {
generation.reject(new Error('音效生成失败'));
await generation.promise.catch(() => undefined);
await Promise.resolve();
});
expect(refreshWalletBalance).not.toHaveBeenCalled();
expect(screen.getByTestId('tracked-dialog').textContent).toBe(
'audio-sound-effect:账号 B 的音效:idle:open:-',
);
});
it('does not write a background music response after the scope changes away and back before acceptance', async () => {
const generation =
createDeferred<ReturnType<typeof createAudioGenerated>>();
const appendCanvasLayers = vi.fn();
const refreshWalletBalance = vi.fn();
generateEditorBackgroundMusicMock.mockReturnValueOnce(generation.promise);
const initialDialog = {
id: 'dialog-bgm-scope-before-acceptance',
mode: 'audio-background-music' as const,
prompt: '账号 A 的背景音乐',
status: 'idle' as const,
composerOpen: true,
};
const view = render(
<SubmissionWorkflowHarness
currentUserId="user-a"
projectId="project-a"
onAppendCanvasLayers={appendCanvasLayers}
onWalletBalanceMayHaveChanged={refreshWalletBalance}
initialDialog={initialDialog}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
view.rerender(
<SubmissionWorkflowHarness
currentUserId="user-b"
projectId="project-b"
onAppendCanvasLayers={appendCanvasLayers}
onWalletBalanceMayHaveChanged={refreshWalletBalance}
initialDialog={{
...initialDialog,
prompt: '账号 B 的背景音乐',
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
view.rerender(
<SubmissionWorkflowHarness
currentUserId="user-a"
projectId="project-a"
onAppendCanvasLayers={appendCanvasLayers}
onWalletBalanceMayHaveChanged={refreshWalletBalance}
initialDialog={initialDialog}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
await act(async () => {
generation.resolve(
createAudioGenerated({
prompt: initialDialog.prompt,
actualPrompt: initialDialog.prompt,
model: 'chirp-v4',
audioKind: 'background-music',
durationSeconds: null,
}),
);
await generation.promise;
await Promise.resolve();
});
expect(appendCanvasLayers).not.toHaveBeenCalled();
expect(refreshWalletBalance).toHaveBeenCalledTimes(1);
expect(screen.getByTestId('tracked-dialog').textContent).toBe(
'audio-background-music:账号 A 的背景音乐:idle:open:-',
);
});
it('does not apply an accepted background music queue result after the scope changes away and back', async () => {
const generation =
createDeferred<ReturnType<typeof createAudioGenerated>>();
const projectLoad = createDeferred<{
projectId: string;
title: string;
viewport: { x: number; y: number; scale: number };
layers: never[];
resources: never[];
updatedAt: string;
}>();
const applyProjectSnapshot = vi.fn();
const appendCanvasLayers = vi.fn();
generateEditorBackgroundMusicMock.mockReturnValueOnce(generation.promise);
loadEditorProjectMock.mockReturnValueOnce(projectLoad.promise);
const initialDialog = {
id: 'dialog-bgm-scope',
mode: 'audio-background-music' as const,
prompt: '账号 A 的背景音乐',
status: 'idle' as const,
composerOpen: true,
};
const view = render(
<SubmissionWorkflowHarness
currentUserId="user-a"
projectId="project-a"
applyProjectSnapshot={applyProjectSnapshot}
onAppendCanvasLayers={appendCanvasLayers}
initialDialog={initialDialog}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
await act(async () => {
generation.resolve(
createAudioGenerated({
prompt: initialDialog.prompt,
actualPrompt: initialDialog.prompt,
model: 'chirp-v4',
audioKind: 'background-music',
durationSeconds: null,
queueState: createQueueState(),
}),
);
await generation.promise;
});
await waitFor(() => {
expect(screen.getByTestId('tracked-dialog').textContent).toBe(
'audio-background-music:账号 A 的背景音乐:generating:closed:-',
);
expect(screen.getByTestId('background-music-assist').textContent).toBe(
'idle',
);
expect(loadEditorProjectMock).toHaveBeenCalledWith('project-a');
});
const nextDialog = {
...initialDialog,
prompt: '账号 B 的背景音乐',
};
view.rerender(
<SubmissionWorkflowHarness
currentUserId="user-b"
projectId="project-b"
applyProjectSnapshot={applyProjectSnapshot}
onAppendCanvasLayers={appendCanvasLayers}
initialDialog={nextDialog}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
view.rerender(
<SubmissionWorkflowHarness
currentUserId="user-a"
projectId="project-a"
applyProjectSnapshot={applyProjectSnapshot}
onAppendCanvasLayers={appendCanvasLayers}
initialDialog={initialDialog}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
await act(async () => {
projectLoad.resolve({
projectId: 'project-a',
title: '账号 A 项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
resources: [],
updatedAt: '2026-08-05T00:00:00.000Z',
});
await projectLoad.promise;
await Promise.resolve();
});
expect(applyProjectSnapshot).not.toHaveBeenCalled();
expect(appendCanvasLayers).not.toHaveBeenCalled();
expect(screen.getByTestId('tracked-dialog').textContent).toBe(
'audio-background-music:账号 A 的背景音乐:idle:open:-',
);
});
it('submits uploaded objectKey references without downloading or inlining them', async () => {
generateEditorImageMock.mockResolvedValueOnce(
createGenerated({ prompt: '参考图生成' }),
);
render(
<SubmissionWorkflowHarness
initialDialog={{
id: 'dialog-generate',
mode: 'generate',
prompt: '参考图生成',
status: 'idle',
composerOpen: true,
imageModel: 'gpt-image-2',
generationReferences: [
{
id: 'ref-uploaded',
label: '上传参考图',
src: 'https://signed.example.test/ref.png?expires=1',
objectKey: 'generated-character-drafts/editor/refs/ref.png',
assetObjectId: 'assetobj-ref',
},
],
placeholder: {
x: 200,
y: 160,
width: 420,
height: 420,
originalWidth: 1024,
originalHeight: 1024,
},
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
await waitFor(() => {
expect(generateEditorImageMock).toHaveBeenCalledWith(
expect.objectContaining({
prompt: '参考图生成',
referenceImageSrcs: [
'generated-character-drafts/editor/refs/ref.png',
],
}),
);
});
expect(resolveEditorImageReferenceDataUrlMock).not.toHaveBeenCalled();
expect(uploadEditorMediaAssetObjectFileMock).not.toHaveBeenCalled();
});
it('refreshes the wallet and shows the warning after a queued character generation completes', async () => {
const applyProjectSnapshot = vi.fn();
const refreshTaskList = vi.fn();
const refreshWalletBalance = vi.fn();
const backendProject = {
projectId: 'editor-project-1',
title: '队列项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
resources: [],
updatedAt: '2026-06-23T08:00:00.000Z',
};
loadEditorProjectMock.mockResolvedValueOnce(backendProject);
generateEditorImageMock.mockResolvedValueOnce({
...createGenerated({
imageSrc: '/generated-editor-images/project/image.png',
width: 1024,
height: 1024,
prompt: '队列角色生成',
}),
queueState: createQueueState({
warning: '生成任务成功,后处理失败。',
}),
});
render(
<SubmissionWorkflowHarness
projectId="editor-project-1"
applyProjectSnapshot={applyProjectSnapshot}
onQueuedGenerationTask={refreshTaskList}
onWalletBalanceMayHaveChanged={refreshWalletBalance}
initialDialog={{
id: 'dialog-character',
mode: 'character',
prompt: '队列角色生成',
status: 'idle',
composerOpen: true,
imageModel: 'gpt-image-2',
placeholder: {
x: 200,
y: 160,
width: 420,
height: 420,
originalWidth: 1024,
originalHeight: 1024,
},
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
await waitFor(() => {
expect(refreshTaskList).toHaveBeenCalledTimes(1);
});
await waitFor(() => {
expect(loadEditorProjectMock).toHaveBeenCalledWith('editor-project-1');
});
expect(refreshWalletBalance).toHaveBeenCalledTimes(1);
expect(applyProjectSnapshot).toHaveBeenCalledWith(backendProject);
expect(getExternalGenerationJobStatusMock).not.toHaveBeenCalled();
expect(screen.getByTestId('generation-warning').textContent).toBe(
'生成任务成功,后处理失败。',
);
expect(screen.getByTestId('layers').textContent).not.toContain(
'layer-generated-1',
);
});
it('reloads a queued project snapshot when a later duplicate completion dialog is unresolved', async () => {
vi.useFakeTimers();
try {
const applyProjectSnapshot = vi.fn();
const refreshTaskList = vi.fn();
const refreshWalletBalance = vi.fn();
const unresolvedProject = {
projectId: 'editor-project-1',
title: '队列项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
layerId: 'generation-dialog-background-removal-completed',
resourceId: 'generation-dialog-background-removal-completed',
itemType: 'generation-dialog',
dialog: {
id: 'dialog-background-removal',
status: 'idle',
generatedLayerId: 'layer-background-removal-result',
},
},
{
layerId: 'generation-dialog-background-removal',
resourceId: 'generation-dialog-background-removal',
itemType: 'generation-dialog',
dialog: {
id: 'dialog-background-removal',
status: 'generating',
},
},
],
resources: [],
updatedAt: '2026-06-23T08:00:00.000Z',
};
const completedProject = {
...unresolvedProject,
layers: [
{
layerId: 'generation-dialog-background-removal',
resourceId: 'generation-dialog-background-removal',
itemType: 'generation-dialog',
dialog: {
id: 'dialog-background-removal',
status: 'idle',
generatedLayerId: 'layer-background-removal-result',
},
},
],
updatedAt: '2026-06-23T08:00:01.000Z',
};
loadEditorProjectMock
.mockResolvedValueOnce(unresolvedProject)
.mockResolvedValueOnce(completedProject);
const applyPromise = applyQueuedEditorGenerationProject(
{ queueState: createQueueState() },
'editor-project-1',
applyProjectSnapshot,
refreshTaskList,
refreshWalletBalance,
undefined,
'dialog-background-removal',
);
await Promise.resolve();
await Promise.resolve();
expect(refreshTaskList).toHaveBeenCalledTimes(1);
expect(refreshWalletBalance).toHaveBeenCalledTimes(1);
expect(loadEditorProjectMock).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(650);
await applyPromise;
expect(loadEditorProjectMock).toHaveBeenCalledTimes(2);
expect(applyProjectSnapshot).toHaveBeenNthCalledWith(
1,
unresolvedProject,
);
expect(applyProjectSnapshot).toHaveBeenNthCalledWith(2, completedProject);
} finally {
vi.useRealTimers();
}
});
it('reports a non-blocking warning after a queued generation completes', async () => {
const onGenerationWarning = vi.fn();
loadEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-1',
title: '队列图集项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
resources: [],
updatedAt: '2026-06-23T08:00:00.000Z',
});
await applyQueuedEditorGenerationProject(
{
queueState: createQueueState({
warning: '生成任务成功,后处理失败。',
}),
},
'editor-project-1',
vi.fn(),
vi.fn(),
vi.fn(),
onGenerationWarning,
);
expect(onGenerationWarning).toHaveBeenCalledWith(
'生成任务成功,后处理失败。',
);
});
it('keeps a queued generation pending when the frontend queue wait expires', async () => {
vi.useFakeTimers();
try {
const refreshWalletBalance = vi.fn();
const refreshTaskList = vi.fn();
const runningState = createQueueState({
status: 'running',
phaseLabel: '生成中',
phaseDetail: '后端任务继续执行中',
progress: 0.35,
});
generateEditorImageMock.mockResolvedValueOnce({
...createGenerated({
imageSrc: '/generated-editor-images/project/image.png',
width: 1024,
height: 1024,
prompt: '队列生成',
}),
queueState: runningState,
});
getExternalGenerationJobStatusMock.mockResolvedValue({
job: runningState,
});
render(
<SubmissionWorkflowHarness
projectId="editor-project-1"
onQueuedGenerationTask={refreshTaskList}
onWalletBalanceMayHaveChanged={refreshWalletBalance}
initialDialog={{
id: 'dialog-generate',
mode: 'generate',
prompt: '队列生成',
status: 'idle',
composerOpen: true,
imageModel: 'gpt-image-2',
placeholder: {
x: 200,
y: 160,
width: 420,
height: 420,
originalWidth: 1024,
originalHeight: 1024,
},
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(refreshTaskList).toHaveBeenCalledTimes(1);
await act(async () => {
await vi.advanceTimersByTimeAsync(20 * 60 * 1000 + 1600);
});
expect(refreshWalletBalance).toHaveBeenCalledTimes(1);
expect(loadEditorProjectMock).not.toHaveBeenCalled();
expect(getExternalGenerationJobStatusMock).toHaveBeenCalled();
expect(screen.getByTestId('dialog').textContent).toBe(
'generate:generating:closed:-:placeholder:-',
);
expect(screen.getByTestId('dialog').textContent).not.toContain(
'生成任务仍在队列中',
);
expect(screen.getByTestId('layers').textContent).not.toContain(
'layer-generated-1',
);
} finally {
vi.useRealTimers();
}
});
it('does not add a local layer when project generation returns without a backend snapshot', async () => {
generateEditorImageMock.mockResolvedValueOnce(
createGenerated({
width: 1024,
height: 1024,
prompt: '银发游侠',
project: null,
}),
);
render(
<SubmissionWorkflowHarness
projectId="editor-project-1"
initialDialog={{
id: 'dialog-character',
mode: 'character',
prompt: '银发游侠',
status: 'idle',
composerOpen: true,
imageModel: 'gpt-image-2',
placeholder: {
x: 200,
y: 160,
width: 420,
height: 560,
originalWidth: 1024,
originalHeight: 1536,
},
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
await waitFor(() => {
expect(generateEditorImageMock).toHaveBeenCalledWith(
expect.objectContaining({
projectId: 'editor-project-1',
canvasCompletion: expect.objectContaining({
dialogId: 'dialog-character',
}),
}),
);
});
await waitFor(() => {
expect(screen.getByTestId('dialog').textContent).toBe(
'character:idle:open:-:placeholder:-',
);
});
expect(screen.getByTestId('layers').textContent).not.toContain(
'layer-generated-1',
);
});
it('does not add a local icon spec layer when project completion returns without a backend snapshot', async () => {
const onAppendCanvasLayers = vi.fn();
generateEditorIconSpecMock.mockResolvedValueOnce(
createGenerated({
imageSrc: 'data:image/png;base64,icon-spec',
width: 2048,
height: 1152,
project: null,
}),
);
render(
<SubmissionWorkflowHarness
projectId="editor-project-icon-spec"
onAppendCanvasLayers={onAppendCanvasLayers}
initialDialog={{
id: 'dialog-icon-spec-persisted',
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: '提交当前生成' }));
await waitFor(() => {
expect(generateEditorIconSpecMock).toHaveBeenCalledWith(
expect.objectContaining({
projectId: 'editor-project-icon-spec',
canvasCompletion: expect.objectContaining({
dialogId: 'dialog-icon-spec-persisted',
}),
}),
);
expect(screen.getByTestId('dialog').textContent).toBe(
'spec:idle:open:-:placeholder:-',
);
});
expect(onAppendCanvasLayers).not.toHaveBeenCalled();
});
it('does not add a local video layer when project completion returns without a backend snapshot', async () => {
const onAppendCanvasLayers = vi.fn();
generateEditorVideoMock.mockResolvedValueOnce(
createVideoGenerated({ project: null }),
);
render(
<SubmissionWorkflowHarness
projectId="editor-project-video"
onAppendCanvasLayers={onAppendCanvasLayers}
initialDialog={{
id: 'dialog-video-persisted',
mode: 'video',
prompt: '让云朵飘动',
status: 'idle',
composerOpen: true,
placeholder: {
x: 160,
y: 120,
width: 640,
height: 360,
originalWidth: 1280,
originalHeight: 720,
},
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
await waitFor(() => {
expect(generateEditorVideoMock).toHaveBeenCalledWith(
expect.objectContaining({
projectId: 'editor-project-video',
canvasCompletion: expect.objectContaining({
dialogId: 'dialog-video-persisted',
}),
}),
);
expect(screen.getByTestId('dialog').textContent).toBe(
'video:idle:open:-:placeholder:-',
);
});
expect(onAppendCanvasLayers).not.toHaveBeenCalled();
});
it('does not add a local audio layer when project completion returns without a backend snapshot', async () => {
const onAppendCanvasLayers = vi.fn();
generateEditorSoundEffectMock.mockResolvedValueOnce(
createAudioGenerated({ project: null }),
);
render(
<SubmissionWorkflowHarness
projectId="editor-project-audio"
onAppendCanvasLayers={onAppendCanvasLayers}
initialDialog={{
id: 'dialog-audio-persisted',
mode: 'audio-sound-effect',
prompt: '金币掉落叮当声',
status: 'idle',
composerOpen: true,
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({
projectId: 'editor-project-audio',
canvasCompletion: expect.objectContaining({
dialogId: 'dialog-audio-persisted',
}),
}),
);
expect(screen.getByTestId('dialog').textContent).toBe(
'audio-sound-effect:idle:open:-:placeholder:-',
);
});
expect(onAppendCanvasLayers).not.toHaveBeenCalled();
});
it('submits publication detail materials as one billable editor image and appends the result', async () => {
generateEditorImageMock.mockResolvedValueOnce(
createGenerated({
imageSrc: 'data:image/png;base64,publication',
width: 720,
height: 1280,
prompt: '游戏名:云朵消消乐',
}),
);
render(
<SubmissionWorkflowHarness
initialDialog={{
id: 'dialog-publication',
mode: 'publication',
prompt: '',
status: 'idle',
composerOpen: true,
publicationWorkflowId: 'publication-detail-gallery',
publicationGameInfo: {
gameName: '云朵消消乐',
gameCategories: '休闲消除',
gameDescription: '点击同类云朵完成消除',
},
publicationReferences: [
{
id: 'ref-1',
label: '主视觉参考',
src: 'DaTa:image/png;base64,bGFyZ2UtcmVm',
},
],
placeholder: {
x: 180,
y: 120,
width: 360,
height: 640,
originalWidth: 720,
originalHeight: 1280,
},
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
expect(screen.getByTestId('dialog').textContent).toBe(
'publication:generating:closed:-:placeholder:-',
);
await waitFor(() => {
expect(generateEditorImageMock).toHaveBeenCalledWith(
expect.objectContaining({
prompt: expect.stringContaining('【宣发素材类型】详情页单图'),
size: '720x1280',
kind: 'publication-material',
assetKind: 'publication-material',
model: 'gpt-image-2',
aspectRatio: '9:16',
imageSize: '1K',
referenceImageSrcs: [
'generated-character-drafts/editor/generation-references/reference.png',
],
}),
);
});
const prompt = generateEditorImageMock.mock.calls[0]?.[0]?.prompt ?? '';
expect(prompt).toContain('游戏名:云朵消消乐');
expect(prompt).toContain('本次只生成一张 720 x 1280 竖版详情图');
expect(prompt).toContain('不是五图合集或拼贴');
await waitFor(() => {
expect(screen.getByTestId('layers').textContent).toContain(
'layer-generated-1:1 宣发素材:-:publication-material:-',
);
});
expect(screen.getByTestId('layers').textContent).not.toContain(
'layer-generated-2',
);
expect(screen.getByTestId('selected').textContent).toBe(
'layer-generated-1',
);
});
it('submits publication resource ids without downloading or inlining them', async () => {
generateEditorImageMock.mockResolvedValueOnce(
createGenerated({
imageSrc: 'data:image/png;base64,publication',
width: 720,
height: 540,
prompt: '游戏名:云朵消消乐',
}),
);
render(
<SubmissionWorkflowHarness
initialDialog={{
id: 'dialog-publication',
mode: 'publication',
prompt: '',
status: 'idle',
composerOpen: true,
publicationWorkflowId: 'publication-cover-image',
publicationGameInfo: {
gameName: '云朵消消乐',
gameCategories: '休闲消除',
gameDescription: '点击同类云朵完成消除',
},
publicationReferences: [
{
id: 'ref-1',
label: '主视觉参考',
src: 'data:image/png;base64,large-original-ref',
resourceId: 'resource-publication-ref',
},
],
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
await waitFor(() => {
expect(generateEditorImageMock).toHaveBeenCalledWith(
expect.objectContaining({
kind: 'publication-material',
aspectRatio: '4:3',
imageSize: '1K',
referenceImageSrcs: ['resource-publication-ref'],
}),
);
});
expect(resolveEditorImageReferenceDataUrlMock).not.toHaveBeenCalled();
expect(uploadEditorMediaAssetObjectFileMock).not.toHaveBeenCalled();
});
it('submits icon spec objects without requiring an icon spec reference', async () => {
generateEditorIconSpecMock.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(generateEditorIconSpecMock).toHaveBeenCalledWith(
expect.objectContaining({
playSetting: '背包整理玩法',
artStyle: '清爽卡通',
assetLabel: '图标规范 1',
generationInputs: {
version: 2,
action: 'spec.generate',
fields: [
{ id: 'specType', title: '规范类型', value: 'icon' },
{
id: 'playSetting',
title: '玩法设定',
value: '背包整理玩法',
},
{ id: 'artStyle', title: '美术风格', value: '清爽卡通' },
],
references: [],
},
}),
);
});
const payload = generateEditorIconSpecMock.mock.calls[0]?.[0] ?? {};
expect(payload).not.toHaveProperty('referenceImageSrcs');
expect(payload).not.toHaveProperty('prompt');
expect(payload).not.toHaveProperty('kind');
expect(payload).not.toHaveProperty('assetKind');
await waitFor(() => {
expect(screen.getByTestId('layers').textContent).toContain(
'layer-generated-1:图标规范 1:-:icon-spec',
);
});
expect(screen.getByTestId('dialog').textContent).not.toContain(
'请选择图标规范',
);
});
it('normalizes non-icon spec sources with canonical reference metadata', async () => {
generateEditorImageMock.mockResolvedValueOnce(
createGenerated({
imageSrc: 'data:image/png;base64,character-spec',
width: 2048,
height: 1152,
}),
);
render(
<SubmissionWorkflowHarness
initialDialog={{
id: 'dialog-character-spec-mixed-references',
mode: 'spec',
prompt: '',
status: 'idle',
composerOpen: true,
specType: 'character',
specValues: {
playSetting: '动作冒险',
artStyle: '赛璐璐',
bodyRatio: '4',
characterView: '正面站姿',
customPrompt: '',
},
specReference: {
id: 'legacy-reference',
label: '历史参考',
src: '/generated/legacy-fallback.png',
objectKey: 'generated/legacy-object.png',
resourceId: 'resource-legacy',
},
generationReferences: [
{
id: 'canonical-reference',
label: '当前参考',
src: '/generated/current-fallback.png',
objectKey: 'generated/current-object.png',
resourceId: 'resource-current',
},
],
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
await waitFor(() => {
expect(generateEditorImageMock).toHaveBeenCalledWith(
expect.objectContaining({
kind: 'spec',
referenceImageSrcs: ['generated/current-object.png'],
generationInputs: expect.objectContaining({
references: [expect.objectContaining({ label: '当前参考' })],
}),
}),
);
});
expect(
generateEditorImageMock.mock.calls[0]?.[0]?.referenceImageSrcs,
).not.toContain('generated/legacy-object.png');
});
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 the complete prompt, and remembers the model', async () => {
generateEditorIconSpritesheetMock.mockResolvedValueOnce({
spritesheetImageSrc: 'data:image/png;base64,sheet',
spritesheetWidth: 512,
spritesheetHeight: 512,
prompt: '图标素材',
actualPrompt: '图标素材',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'task-icons',
iconImageSrcs: [],
sliceWarning: {
code: 'insufficient-connected-components',
reason: '连通域数量不足',
},
});
render(
<SubmissionWorkflowHarness
projectId="editor-project-1"
initialDialog={{
id: 'dialog-icon',
mode: 'icon',
prompt: ' 返回按钮\n设置按钮 ',
assetLabel: ' 复古操作图标 ',
status: 'idle',
composerOpen: true,
imageModel: 'gpt-image-2',
iconSpecReference: {
id: 'spec',
label: '图标规范',
src: 'data:image/png;base64,c3BlYw==',
resourceId: 'resource-icon-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({
referenceId: 'resource-icon-spec',
iconDescriptions: ['返回按钮\n设置按钮'],
model: 'gpt-image-2',
assetLabel: '复古操作图标',
canvasCompletion: expect.objectContaining({
title: '复古操作图标',
}),
}),
);
});
await waitFor(() => {
expect(screen.getByTestId('layers').textContent).toContain(
'layer-icon-spritesheet-1:复古操作图标:-:icon-spritesheet',
);
});
expect(screen.getByTestId('layers').textContent).not.toContain(
'layer-icon-2',
);
expect(screen.getByTestId('selected').textContent).toBe(
'layer-icon-spritesheet-1',
);
expect(screen.getByTestId('dialog').textContent).toBe(
'icon:idle:open:layer-icon-spritesheet-1:placeholder:-',
);
expect(screen.getByTestId('remembered-model').textContent).toBe(
'gpt-image-2',
);
expect(screen.getByTestId('generation-warning').textContent).toBe(
'图集已生成,但自动拆分未完成:连通域数量不足',
);
});
it('keeps both the common warning and the slice warning when they coexist', 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: [],
warning: {
code: 'unsupported-image-style',
reason: '不支持的图片风格,已按无风格继续生成。',
},
sliceWarning: {
code: 'insufficient-connected-components',
reason: '连通域数量不足',
},
});
render(
<SubmissionWorkflowHarness
projectId="editor-project-1"
initialDialog={{
id: 'dialog-icon',
mode: 'icon',
prompt: '返回按钮',
status: 'idle',
composerOpen: true,
imageModel: 'gpt-image-2',
iconSpecReference: {
id: 'spec',
label: '图标规范',
src: 'data:image/png;base64,c3BlYw==',
resourceId: 'resource-icon-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(screen.getByTestId('generation-warning').textContent).toBe(
'不支持的图片风格,已按无风格继续生成。 图集已生成,但自动拆分未完成:连通域数量不足',
);
});
});
it('submits registered icon spec IDs and keeps objectKey extra references', 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',
resourceId: 'resource-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({
referenceId: 'resource-icon-spec',
referenceImageSrcs: [
'generated-character-drafts/editor/refs/icon-style.png',
],
iconDescriptions: ['返回按钮'],
sliceMode: 'connected-components',
}),
);
});
});
it('extracts UI design assets into a spritesheet layer and sliced icon layers', async () => {
const captureCanvasHistory = vi.fn();
const fitLayers = vi.fn();
extractEditorUiDesignAssetsMock.mockResolvedValueOnce({
spritesheetImageSrc: 'data:image/png;base64,ui-sheet',
spritesheetWidth: 512,
spritesheetHeight: 512,
prompt:
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。',
actualPrompt:
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'task-ui-assets',
iconImageSrcs: [
createIconResult('素材 1', 'data:image/png;base64,asset-1'),
],
});
render(
<SubmissionWorkflowHarness
captureCanvasHistory={captureCanvasHistory}
fitLayers={fitLayers}
initialLayers={[
createLayer({
assetKind: 'ui-design',
objectKey: 'generated/ui-design.png',
}),
]}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '提取UI素材' }));
await waitFor(() => {
expect(extractEditorUiDesignAssetsMock).toHaveBeenCalledWith(
expect.objectContaining({
sourceImageSrc: 'generated/ui-design.png',
model: 'gemini-3.1-flash-image-preview',
screenColor: 'auto',
segModel: 'birefnet',
}),
);
});
expect(resolveEditorImageReferenceDataUrlMock).not.toHaveBeenCalledWith(
'generated/ui-design.png',
);
await waitFor(() => {
expect(screen.getByTestId('layers').textContent).toContain(
'layer-icon-spritesheet-1:源图 素材图集:-:icon-spritesheet',
);
});
expect(screen.getByTestId('layers').textContent).toContain(
'layer-icon-2:素材 1:-:icon',
);
expect(screen.getByTestId('selected').textContent).toBe(
'layer-icon-spritesheet-1',
);
expect(screen.getByTestId('fit-count').textContent).toBe('1');
expect(captureCanvasHistory).toHaveBeenCalledTimes(1);
expect(captureCanvasHistory).toHaveBeenCalledWith({
type: 'generate-image',
count: 2,
});
expect(fitLayers).toHaveBeenCalledWith(expect.any(Array), {
captureHistory: false,
});
});
it('keeps the extracted spritesheet when extracted icon results are missing', async () => {
extractEditorUiDesignAssetsMock.mockResolvedValueOnce({
spritesheetImageSrc: 'data:image/png;base64,ui-sheet',
spritesheetWidth: 512,
spritesheetHeight: 512,
prompt:
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。',
actualPrompt:
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'task-ui-assets',
warning: {
code: 'postprocess-failed-source-preserved',
reason: '生成任务成功,后处理失败。',
},
});
render(
<SubmissionWorkflowHarness
initialLayers={[
createLayer({
assetKind: 'ui-design',
objectKey: 'generated/ui-design.png',
}),
]}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '提取UI素材' }));
await waitFor(() => {
expect(screen.getByTestId('layers').textContent).toContain(
'layer-icon-spritesheet-1:源图 素材图集:-:icon-spritesheet',
);
});
expect(screen.getByTestId('layers').textContent).not.toContain(
'layer-icon-2:',
);
expect(screen.getByTestId('selected').textContent).toBe(
'layer-icon-spritesheet-1',
);
expect(screen.getByTestId('fit-count').textContent).toBe('1');
expect(screen.getByTestId('generation-warning').textContent).toBe(
'生成任务成功,后处理失败。',
);
});
it('renders selected UI marks into the extraction reference image and sends derived extraction pricing', async () => {
renderUiDesignAssetExtractionMarkedImageMock.mockResolvedValueOnce(
'data:image/png;base64,bWFya2VkLXVpLWRlc2lnbg==',
);
extractEditorUiDesignAssetsMock.mockResolvedValueOnce({
spritesheetImageSrc: 'data:image/png;base64,ui-sheet',
spritesheetWidth: 512,
spritesheetHeight: 512,
prompt:
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。',
actualPrompt:
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'task-ui-assets',
iconImageSrcs: [
createIconResult('素材 1', 'data:image/png;base64,asset-1'),
],
});
render(
<SubmissionWorkflowHarness
projectId="project-ui-extraction-size"
initialLayers={[
createLayer({
assetKind: 'ui-design',
objectKey: 'generated/ui-design.png',
}),
]}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '提取已框选UI素材' }));
await waitFor(() => {
expect(renderUiDesignAssetExtractionMarkedImageMock).toHaveBeenCalledWith(
expect.objectContaining({
source: 'data:image/png;base64,ui-design.png',
marks: expect.arrayContaining([
{
id: 'mark-1',
tool: 'rect',
x: 10,
y: 20,
width: 80,
height: 60,
},
]),
}),
);
expect(
renderUiDesignAssetExtractionMarkedImageMock.mock.calls[0]?.[0].marks,
).toHaveLength(7);
});
await waitFor(() => {
expect(extractEditorUiDesignAssetsMock).toHaveBeenCalledWith(
expect.objectContaining({
sourceImageSrc:
'generated-character-drafts/editor/generation-references/reference.png',
model: 'gemini-3.1-flash-image-preview',
screenColor: 'auto',
segModel: 'birefnet',
aspectRatio: '1:1',
imageSize: '2K',
canvasCompletion: expect.objectContaining({
placeholder: {
x: 472,
y: 140,
width: 2048,
height: 2048,
originalWidth: 2048,
originalHeight: 2048,
},
}),
generationInputs: expect.objectContaining({
fields: expect.arrayContaining([
expect.objectContaining({
title: '提取提示词',
value:
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。',
}),
]),
}),
}),
);
});
});
it('submits UI asset extraction with uploaded references', async () => {
extractEditorUiDesignAssetsMock.mockResolvedValueOnce({
spritesheetImageSrc: 'data:image/png;base64,ui-sheet',
spritesheetWidth: 512,
spritesheetHeight: 512,
prompt:
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。',
actualPrompt:
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'task-ui-assets',
});
render(
<SubmissionWorkflowHarness
initialLayers={[
createLayer({
assetKind: 'ui-design',
objectKey: 'generated/ui-design.png',
}),
]}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '提取带参考图UI素材' }));
await waitFor(() => {
expect(
resolveEditorImageReferenceDataUrlForGenerationMock,
).not.toHaveBeenCalled();
});
await waitFor(() => {
expect(extractEditorUiDesignAssetsMock).toHaveBeenCalledWith(
expect.objectContaining({
screenColor: 'auto',
segModel: 'birefnet',
referenceImageSrcs: ['generated/ref-style.png'],
generationInputs: expect.objectContaining({
version: 2,
action: 'ui-design.extract-assets',
references: expect.arrayContaining([
expect.objectContaining({
id: 'reference',
title: '参考图 1',
label: '风格参考',
refType: 'project-resource',
}),
]),
}),
}),
);
});
});
it('keeps character generation dialog data after the result is created', async () => {
generateEditorImageMock.mockResolvedValueOnce(
createGenerated({
width: 768,
height: 768,
warning: {
code: 'postprocess-failed-source-preserved',
reason: '生成任务成功,后处理失败。',
},
}),
);
render(
<SubmissionWorkflowHarness
initialDialog={{
id: 'dialog-character',
mode: 'character',
prompt: '骑士角色',
status: 'idle',
composerOpen: true,
imageModel: 'gpt-image-2',
characterSpecReference: {
id: 'spec-character',
label: '角色规范',
src: 'data:image/png;base64,spec',
},
placeholder: {
x: 120,
y: 140,
width: 420,
height: 420,
originalWidth: 2048,
originalHeight: 2048,
},
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
await waitFor(() => {
expect(screen.getByTestId('layers').textContent).toContain(
'layer-generated-1:角色形象 1:-:character',
);
});
expect(screen.getByTestId('dialog').textContent).toBe(
'character:idle:open:layer-generated-1:placeholder:-',
);
expect(screen.getByTestId('generation-warning').textContent).toBe(
'生成任务成功,后处理失败。',
);
});
it('moves character animation panels from generating to completed', async () => {
generateEditorCharacterAnimationMock.mockResolvedValueOnce({
frames: [{ imageSrc: 'data:image/png;base64,frame' }],
previewSrc: 'data:image/png;base64,preview',
taskId: 'animation-task',
});
render(
<SubmissionWorkflowHarness
projectId="editor-project-1"
initialLayers={[
createLayer({
id: 'character-layer',
assetKind: 'character',
src: 'data:image/png;base64,Y2hhcmFjdGVy',
}),
]}
initialCharacterAnimationPanel={{
sourceLayerId: 'character-layer',
promptText: ' 跑步 ',
resolution: '480p',
ratio: 'same',
frameCount: 32,
durationSeconds: 4,
status: 'idle',
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '提交角色动画' }));
expect(screen.getByTestId('character-animation').textContent).toBe(
'character-layer:generating:跑步:-:-',
);
await waitFor(() => {
expect(generateEditorCharacterAnimationMock).toHaveBeenCalledWith(
expect.objectContaining({
sourceLayerId: 'character-layer',
sourceImageSrc:
'generated-character-drafts/editor/generation-references/reference.png',
promptText: '跑步',
model: 'seedance2.0-fast',
assetFolderId: 'project',
}),
);
});
await waitFor(() => {
expect(screen.getByTestId('character-animation').textContent).toBe(
'character-layer:completed:跑步:-:result',
);
});
});
it('uses updated character animation folder and queue callbacks after rerender', async () => {
const firstQueuedTaskCallback = vi.fn();
const latestQueuedTaskCallback = vi.fn();
generateEditorCharacterAnimationMock.mockResolvedValueOnce({
taskId: 'animation-task-queued',
queueState: createQueueState({ status: 'queued' }),
project: {
id: 'editor-project-1',
},
});
const harnessProps = {
projectId: 'editor-project-1',
initialLayers: [
createLayer({
id: 'character-layer',
assetKind: 'character' as const,
objectKey: 'generated/character.png',
}),
],
initialCharacterAnimationPanel: {
sourceLayerId: 'character-layer',
promptText: '跑步',
resolution: '480p' as const,
ratio: 'same' as const,
frameCount: 32 as const,
durationSeconds: 4 as const,
status: 'idle' as const,
},
};
const { rerender } = render(
<SubmissionWorkflowHarness
{...harnessProps}
assetFolderId="folder-before"
onQueuedGenerationTask={firstQueuedTaskCallback}
/>,
);
rerender(
<SubmissionWorkflowHarness
{...harnessProps}
assetFolderId="folder-latest"
onQueuedGenerationTask={latestQueuedTaskCallback}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '提交角色动画' }));
await waitFor(() => {
expect(generateEditorCharacterAnimationMock).toHaveBeenCalledWith(
expect.objectContaining({ assetFolderId: 'folder-latest' }),
);
});
await waitFor(() => {
expect(latestQueuedTaskCallback).toHaveBeenCalledTimes(1);
});
expect(firstQueuedTaskCallback).not.toHaveBeenCalled();
});
it('does not resubmit character animation while it is already generating', () => {
render(
<SubmissionWorkflowHarness
initialLayers={[
createLayer({
id: 'character-layer',
assetKind: 'character',
}),
]}
initialCharacterAnimationPanel={{
sourceLayerId: 'character-layer',
promptText: '跑步',
resolution: '480p',
ratio: 'same',
frameCount: 32,
durationSeconds: 4,
status: 'generating',
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '提交角色动画' }));
expect(generateEditorCharacterAnimationMock).not.toHaveBeenCalled();
});
it('reports stale character animation source validation failures', async () => {
render(
<SubmissionWorkflowHarness
initialLayers={[
createLayer({
id: 'character-layer',
assetKind: 'character-animation',
mediaType: 'image-sequence',
imageSequenceFrames: [],
}),
]}
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:跑步:角色动作缺少正式序列首帧,无法继续生成。:-',
);
});
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: [
{
imageSrc: '/generated-character-drafts/editor/action-frame-1.png',
width: 1024,
height: 1024,
},
{
imageSrc: '/generated-character-drafts/editor/action-frame-2.png',
width: 1024,
height: 1024,
},
],
frameCount: 2,
durationSeconds: 4,
fps: 8,
resource: {
resourceId: 'resource-action-final',
projectId: 'editor-project-1',
imageSrc: '/generated-character-drafts/editor/action-frame-1.png',
width: 1024,
height: 1024,
sourceType: 'generated',
sourceResourceId: 'resource-action-preview',
assetKind: 'character-animation',
imageSequenceFrames: [
{
imageSrc: '/generated-character-drafts/editor/action-frame-1.png',
width: 1024,
height: 1024,
},
{
imageSrc: '/generated-character-drafts/editor/action-frame-2.png',
width: 1024,
height: 1024,
},
],
imageSequenceDurationMs: 4_000,
},
});
render(
<SubmissionWorkflowHarness
projectId="editor-project-1"
initialLayers={[
createLayer({
id: 'character-layer',
title: '角色源图',
assetKind: 'character',
objectKey: 'generated/character.png',
}),
]}
initialDialog={{
id: 'dialog-character-animation',
mode: 'character-animation',
sourceLayerId: 'character-layer',
prompt: ' 挥手动作 ',
assetLabel: ' 勇者挥手 ',
status: 'idle',
composerOpen: true,
characterAnimationResolution: '480p',
characterAnimationRatio: 'same',
characterAnimationFrameCount: 32,
characterAnimationDurationSeconds: 4,
placeholder: {
x: 80,
y: 60,
width: 420,
height: 420,
originalWidth: 1024,
originalHeight: 1024,
},
}}
initialCharacterAnimationPanel={{
sourceLayerId: 'character-layer',
promptText: ' 挥手动作 ',
assetLabel: ' 勇者挥手 ',
resolution: '480p',
ratio: 'same',
frameCount: 32,
durationSeconds: 4,
status: 'idle',
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
fireEvent.click(screen.getByRole('button', { name: '提交角色动画' }));
await waitFor(() => {
expect(generateEditorCharacterAnimationMock).toHaveBeenCalledWith(
expect.objectContaining({
sourceLayerId: 'character-layer',
sourceImageSrc: 'generated/character.png',
promptText: '挥手动作',
model: 'seedance2.0-fast',
assetLabel: '勇者挥手',
canvasCompletion: expect.objectContaining({
title: '勇者挥手',
}),
}),
);
});
await waitFor(() => {
expect(screen.getByTestId('layers').textContent).toContain(
'layer-character-animation-1:勇者挥手:resource-action-preview:character-animation:image-sequence:/generated-character-drafts/editor/action-frame-1.png:/generated-character-drafts/editor/action-frame-1.png:4000',
);
});
expect(screen.getByTestId('dialog').textContent).toBe(
'character-animation:idle:open:layer-character-animation-1:placeholder:-',
);
expect(screen.getByTestId('selected').textContent).toBe(
'layer-character-animation-1',
);
});
it('shows a readable conflict message for failed character animation submissions', async () => {
generateEditorCharacterAnimationMock.mockRejectedValueOnce(
new ApiClientError({
message: '请求冲突(requestId: wallet-one)',
status: 409,
code: 'CONFLICT',
details: { provider: 'profile-wallet' },
}),
);
render(
<SubmissionWorkflowHarness
initialLayers={[
createLayer({
id: 'character-layer',
objectKey: 'generated/character.png',
}),
]}
initialCharacterAnimationPanel={{
sourceLayerId: 'character-layer',
promptText: '跑步',
resolution: '480p',
ratio: 'same',
frameCount: 32,
durationSeconds: 4,
status: 'idle',
}}
/>,
);
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: '提交角色动画' }));
});
await waitFor(() => {
expect(screen.getByTestId('character-animation').textContent).toBe(
'character-layer:failed:跑步:泥点余额不足,请充值后再生成:-',
);
});
});
it('uses a fallback error message for failed character animation submissions', async () => {
generateEditorCharacterAnimationMock.mockRejectedValueOnce(new Error(''));
render(
<SubmissionWorkflowHarness
initialLayers={[
createLayer({
id: 'character-layer',
objectKey: 'generated/character.png',
}),
]}
initialCharacterAnimationPanel={{
sourceLayerId: 'character-layer',
promptText: '',
resolution: '480p',
ratio: 'same',
frameCount: 32,
durationSeconds: 4,
status: 'idle',
}}
/>,
);
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: '提交角色动画' }));
});
await waitFor(() => {
expect(screen.getByTestId('character-animation').textContent).toBe(
'character-layer:failed:-:生成角色动画失败:-',
);
});
});
});