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
## 背景 切图新增基于连通域的切分后,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
4930 lines
160 KiB
TypeScript
4930 lines
160 KiB
TypeScript
/* @vitest-environment jsdom */
|
||
|
||
import {
|
||
act,
|
||
fireEvent,
|
||
render,
|
||
screen,
|
||
waitFor,
|
||
} from '@testing-library/react';
|
||
import { useRef, useState } from 'react';
|
||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||
|
||
import 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:-:生成角色动画失败:-',
|
||
);
|
||
});
|
||
});
|
||
});
|