dd24690b02
Project CI / AI game creator shell Rust shard 1/4 (push) Failing after 4m37s
Project CI / AI game creator shell Rust shard 2/4 (push) Failing after 4m36s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m31s
Project CI / Backend tests (push) Failing after 10s
Project CI / AI game creator shell Rust shard 4/4 (push) Failing after 4m2s
Project CI / AI game creator shell Rust shard 3/4 (push) Failing after 4m45s
Project CI / Repository checks (push) Failing after 11s
Project CI / AI game creator shell web tests (push) Failing after 1m18s
Project CI / AI game creator shell Rust crates (push) Successful in 2m36s
Project CI / Native shell tests (push) Failing after 2m34s
Project CI / Frontend tests (push) Successful in 4m52s
增加 connected-components 与 grid 切分模式 支持 gridX/gridY 并同步 API、MCP、Skill、AGC 客户端 移除固定 2x2 图集切分契约与文档
2659 lines
82 KiB
TypeScript
2659 lines
82 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import backgroundMusicPromptCanonicalizationCases from '../../../packages/shared/test-fixtures/background-music-prompt-canonicalization.json';
|
|
import {
|
|
completeEditorBackgroundMusicPrompt,
|
|
createEditorAsset,
|
|
createEditorAssetFolder,
|
|
createEditorProject,
|
|
createEditorProjectResource,
|
|
deleteEditorAsset,
|
|
deleteEditorAssetFolder,
|
|
deleteEditorProject,
|
|
editEditorImage,
|
|
EDITOR_BACKGROUND_MUSIC_PROMPT_ASSIST_TIMEOUT_MS,
|
|
EDITOR_ICON_DESCRIPTION_MAX_CHARS,
|
|
EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_CHARS,
|
|
EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_UTF8_BYTES,
|
|
EDITOR_ICON_SPEC_PROMPT_MAX_LENGTH,
|
|
EDITOR_SOUND_EFFECT_PROMPT_OPTIMIZE_TIMEOUT_MS,
|
|
extractEditorUiDesignAssets,
|
|
generateEditorBackgroundMusic,
|
|
generateEditorCharacterAnimation,
|
|
generateEditorIconSpec,
|
|
generateEditorIconSpritesheet,
|
|
generateEditorImage,
|
|
generateEditorScene,
|
|
generateEditorSoundEffect,
|
|
generateEditorVideo,
|
|
listEditorProjects,
|
|
listPublicEditorProjectResources,
|
|
loadEditorAssetLibrary,
|
|
loadEditorGenerationPricing,
|
|
loadEditorProject,
|
|
loadOrCreateRecentEditorProject,
|
|
optimizeEditorSoundEffectPrompt,
|
|
refineEditorIconSpecArtStyle,
|
|
refineEditorIconSpecPlaySetting,
|
|
removeEditorImageBackground,
|
|
renameEditorProject,
|
|
saveEditorProjectLayout,
|
|
simplifyEditorBackgroundMusicPrompt,
|
|
snapEditorImageToPixelArt,
|
|
splitEditorIconSpritesheet,
|
|
submitEditorAssetShowcase,
|
|
toggleEditorShowcaseAssetLike,
|
|
updateEditorAsset,
|
|
updateEditorAssetFolder,
|
|
updateEditorProjectResourceShowcase,
|
|
} from './editorProjectClient';
|
|
|
|
const requestJsonMock = vi.hoisted(() => vi.fn());
|
|
const editorRetryOptionsExpectation = expect.objectContaining({
|
|
maxRetries: 0,
|
|
retryUnsafeMethods: false,
|
|
});
|
|
// 助手请求只带有界超时:额外的 transport retry 会把一次业务语义轮放大成多次上游调用。
|
|
const promptAssistRequestOptions = {
|
|
timeoutMs: EDITOR_BACKGROUND_MUSIC_PROMPT_ASSIST_TIMEOUT_MS,
|
|
};
|
|
|
|
vi.mock('../apiClient', () => ({
|
|
requestJson: requestJsonMock,
|
|
}));
|
|
|
|
describe('editorProjectClient', () => {
|
|
afterEach(() => {
|
|
requestJsonMock.mockReset();
|
|
});
|
|
|
|
it('loads the recent project without creating a duplicate when it exists', async () => {
|
|
requestJsonMock.mockResolvedValueOnce({
|
|
project: {
|
|
projectId: 'editor-project-1',
|
|
title: '未命名画布',
|
|
canvas: {
|
|
canvasId: 'editor-project-1:canvas:default',
|
|
projectId: 'editor-project-1',
|
|
title: '默认画布',
|
|
viewport: { x: 0, y: 0, scale: 1 },
|
|
layers: [],
|
|
updatedAt: '2026-06-12T00:00:00.000Z',
|
|
},
|
|
viewport: { x: 0, y: 0, scale: 1 },
|
|
layers: [],
|
|
resources: [],
|
|
updatedAt: '2026-06-12T00:00:00.000Z',
|
|
},
|
|
});
|
|
|
|
const project = await loadOrCreateRecentEditorProject();
|
|
|
|
expect(project.projectId).toBe('editor-project-1');
|
|
expect(requestJsonMock).toHaveBeenCalledTimes(1);
|
|
expect(requestJsonMock).toHaveBeenCalledWith(
|
|
'/api/editor/projects/recent',
|
|
{ method: 'GET' },
|
|
'读取图片画布工程失败',
|
|
);
|
|
});
|
|
|
|
it('loads editor generation pricing without requiring auth', async () => {
|
|
requestJsonMock.mockResolvedValueOnce({
|
|
models: {
|
|
'gemini-3.1-flash-image-preview': {
|
|
unit: 'perGeneration',
|
|
prices: { '0.5K': 9, '1K': 18, '2K': 36 },
|
|
},
|
|
'gpt-image-2': {
|
|
unit: 'perGeneration',
|
|
prices: { '1K': 29, '2K': 58 },
|
|
},
|
|
'seedance2.0': {
|
|
unit: 'perSecond',
|
|
prices: { '480p': 13, '720p': 26, '1080p': 52 },
|
|
},
|
|
'audio1.0': { unit: 'perGeneration', price: 15 },
|
|
eleven_text_to_sound_v2: { unit: 'perGeneration', price: 16 },
|
|
'chirp-v5': { unit: 'perGeneration', price: 9 },
|
|
},
|
|
});
|
|
|
|
const pricing = await loadEditorGenerationPricing();
|
|
|
|
expect(pricing.models['gpt-image-2']?.prices?.['1K']).toBe(29);
|
|
expect(pricing.models.eleven_text_to_sound_v2?.price).toBe(16);
|
|
expect(requestJsonMock).toHaveBeenCalledWith(
|
|
'/api/editor/generation-pricing',
|
|
{ method: 'GET' },
|
|
'读取模型定价失败',
|
|
{
|
|
skipAuth: true,
|
|
skipRefresh: true,
|
|
notifyAuthStateChange: false,
|
|
clearAuthOnUnauthorized: false,
|
|
},
|
|
);
|
|
});
|
|
|
|
it('creates a default project when there is no recent project', async () => {
|
|
requestJsonMock
|
|
.mockResolvedValueOnce({ project: null })
|
|
.mockResolvedValueOnce({
|
|
project: {
|
|
projectId: 'editor-project-created',
|
|
title: '未命名画布',
|
|
canvas: {
|
|
canvasId: 'editor-project-created:canvas:default',
|
|
projectId: 'editor-project-created',
|
|
title: '默认画布',
|
|
viewport: { x: 0, y: 0, scale: 1 },
|
|
layers: [],
|
|
updatedAt: '2026-06-12T00:00:00.000Z',
|
|
},
|
|
viewport: { x: 0, y: 0, scale: 1 },
|
|
layers: [],
|
|
resources: [],
|
|
updatedAt: '2026-06-12T00:00:00.000Z',
|
|
},
|
|
});
|
|
|
|
const project = await loadOrCreateRecentEditorProject();
|
|
|
|
expect(project.projectId).toBe('editor-project-created');
|
|
expect(requestJsonMock).toHaveBeenNthCalledWith(
|
|
2,
|
|
'/api/editor/projects',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ title: '未命名画布' }),
|
|
}),
|
|
'创建图片画布工程失败',
|
|
);
|
|
});
|
|
|
|
it('saves viewport and layer layout through the project API', async () => {
|
|
const startedAt = Date.now();
|
|
requestJsonMock.mockResolvedValueOnce({
|
|
projectId: 'editor-project-1',
|
|
canvasId: 'editor-project-1:canvas:default',
|
|
revision: 8,
|
|
updatedAt: '2026-06-12T00:00:00.000Z',
|
|
});
|
|
|
|
const result = await saveEditorProjectLayout('editor-project-1', {
|
|
viewport: { x: 12, y: 24, scale: 0.5 },
|
|
layers: [{ layerId: 'layer-1', resourceId: 'resource-1', x: 10, y: 20 }],
|
|
expectedRevision: 7,
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
projectId: 'editor-project-1',
|
|
canvasId: 'editor-project-1:canvas:default',
|
|
revision: 8,
|
|
updatedAt: '2026-06-12T00:00:00.000Z',
|
|
});
|
|
expect(requestJsonMock).toHaveBeenCalledWith(
|
|
'/api/editor/projects/editor-project-1',
|
|
expect.objectContaining({
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
viewport: { x: 12, y: 24, scale: 0.5 },
|
|
layers: [
|
|
{ layerId: 'layer-1', resourceId: 'resource-1', x: 10, y: 20 },
|
|
],
|
|
expectedRevision: 7,
|
|
}),
|
|
}),
|
|
'保存图片画布工程失败',
|
|
{ deadlineAt: expect.any(Number) },
|
|
);
|
|
const requestOptions = requestJsonMock.mock.calls[0]?.[3] as
|
|
| { deadlineAt?: number }
|
|
| undefined;
|
|
expect(requestOptions?.deadlineAt).toBeGreaterThanOrEqual(
|
|
startedAt + 60_000,
|
|
);
|
|
expect(requestOptions?.deadlineAt).toBeLessThanOrEqual(Date.now() + 60_000);
|
|
});
|
|
|
|
it('lists editor projects from the project API', async () => {
|
|
requestJsonMock.mockResolvedValueOnce({
|
|
projects: [
|
|
{
|
|
projectId: 'editor-project-1',
|
|
title: '角色设定板',
|
|
canvas: {
|
|
canvasId: 'editor-project-1:canvas:default',
|
|
projectId: 'editor-project-1',
|
|
title: '默认画布',
|
|
viewport: { x: 0, y: 0, scale: 1 },
|
|
layers: [],
|
|
updatedAt: '2026-06-12T00:00:00.000Z',
|
|
},
|
|
viewport: { x: 0, y: 0, scale: 1 },
|
|
layers: [],
|
|
resources: [],
|
|
updatedAt: '2026-06-12T00:00:00.000Z',
|
|
},
|
|
],
|
|
});
|
|
|
|
const projects = await listEditorProjects();
|
|
|
|
expect(projects).toHaveLength(1);
|
|
expect(requestJsonMock).toHaveBeenCalledWith(
|
|
'/api/editor/projects',
|
|
{ method: 'GET' },
|
|
'读取图片画布工程列表失败',
|
|
);
|
|
});
|
|
|
|
it('loads public showcase project resources without requiring auth', async () => {
|
|
requestJsonMock.mockResolvedValueOnce({
|
|
resources: [
|
|
{
|
|
resourceId: 'resource-public-1',
|
|
projectId: 'editor-project-1',
|
|
ownerUserId: 'user-public-author',
|
|
authorDisplayName: '公开作者',
|
|
authorPublicUserCode: 'SY-00000042',
|
|
label: '公开角色素材',
|
|
imageSrc: '/generated-editor-assets/public.png',
|
|
width: 512,
|
|
height: 512,
|
|
sourceType: 'generated',
|
|
assetKind: 'character-animation',
|
|
imageSequenceFrames: [
|
|
{
|
|
imageSrc: '/generated-editor-assets/public-frame-1.png',
|
|
objectKey: 'generated-editor-assets/public-frame-1.png',
|
|
width: 512,
|
|
height: 512,
|
|
},
|
|
{
|
|
imageSrc: '/generated-editor-assets/public-frame-2.png',
|
|
objectKey: 'generated-editor-assets/public-frame-2.png',
|
|
width: 512,
|
|
height: 512,
|
|
},
|
|
],
|
|
imageSequenceDurationMs: 6_000,
|
|
publicShowcaseEnabled: true,
|
|
viewerLiked: false,
|
|
},
|
|
],
|
|
nextCursor: 'cursor-next',
|
|
campaign: {
|
|
enabled: true,
|
|
title: '活动精选',
|
|
imageSrc: '/campaign.png',
|
|
imageWidth: 900,
|
|
imageHeight: 1200,
|
|
prompt: '活动提示词',
|
|
author: '运营',
|
|
costText: '50泥点',
|
|
},
|
|
});
|
|
|
|
const page = await listPublicEditorProjectResources();
|
|
|
|
expect(page.resources).toHaveLength(1);
|
|
expect(page.resources[0]?.resourceId).toBe('resource-public-1');
|
|
expect(page.resources[0]?.ownerUserId).toBe('user-public-author');
|
|
expect(page.resources[0]?.authorDisplayName).toBe('公开作者');
|
|
expect(page.resources[0]?.authorPublicUserCode).toBe('SY-00000042');
|
|
expect(page.resources[0]?.imageSequenceFrames).toHaveLength(2);
|
|
expect(page.resources[0]?.imageSequenceDurationMs).toBe(6_000);
|
|
expect(page.resources[0]?.viewerLiked).toBe(false);
|
|
expect(page.nextCursor).toBe('cursor-next');
|
|
expect(page.campaign?.title).toBe('活动精选');
|
|
expect(page.campaign?.imageWidth).toBe(900);
|
|
expect(page.campaign?.imageHeight).toBe(1200);
|
|
expect(requestJsonMock).toHaveBeenCalledWith(
|
|
'/api/editor/showcase/resources',
|
|
{ method: 'GET' },
|
|
'读取陶泥儿精选素材失败',
|
|
{
|
|
skipAuth: true,
|
|
skipRefresh: true,
|
|
notifyAuthStateChange: false,
|
|
clearAuthOnUnauthorized: false,
|
|
},
|
|
);
|
|
});
|
|
|
|
it('loads public showcase project resources after a cursor', async () => {
|
|
requestJsonMock.mockResolvedValueOnce({
|
|
resources: [],
|
|
nextCursor: null,
|
|
});
|
|
|
|
const page = await listPublicEditorProjectResources({
|
|
cursor: ' cursor-1 ',
|
|
});
|
|
|
|
expect(page.resources).toEqual([]);
|
|
expect(page.nextCursor).toBeNull();
|
|
expect(requestJsonMock).toHaveBeenCalledWith(
|
|
'/api/editor/showcase/resources?cursor=cursor-1',
|
|
{ method: 'GET' },
|
|
'读取陶泥儿精选素材失败',
|
|
{
|
|
skipAuth: true,
|
|
skipRefresh: true,
|
|
notifyAuthStateChange: false,
|
|
clearAuthOnUnauthorized: false,
|
|
},
|
|
);
|
|
});
|
|
|
|
it('loads viewer-specific showcase state with authentication enabled', async () => {
|
|
requestJsonMock.mockResolvedValueOnce({
|
|
resources: [
|
|
{
|
|
resourceId: 'showcase-1',
|
|
projectId: 'editor-showcase',
|
|
imageSrc: '/showcase-1.png',
|
|
width: 512,
|
|
height: 512,
|
|
sourceType: 'generated',
|
|
viewerLiked: true,
|
|
},
|
|
],
|
|
nextCursor: null,
|
|
});
|
|
|
|
const page = await listPublicEditorProjectResources({
|
|
viewer: 'authenticated',
|
|
});
|
|
|
|
expect(page.resources[0]?.viewerLiked).toBe(true);
|
|
expect(requestJsonMock).toHaveBeenCalledWith(
|
|
'/api/editor/showcase/resources',
|
|
{ method: 'GET' },
|
|
'读取陶泥儿精选素材失败',
|
|
);
|
|
});
|
|
|
|
it('loads an explicit project by id', async () => {
|
|
requestJsonMock.mockResolvedValueOnce({
|
|
project: {
|
|
projectId: 'editor-project-1',
|
|
title: '角色设定板',
|
|
canvas: {
|
|
canvasId: 'editor-project-1:canvas:default',
|
|
projectId: 'editor-project-1',
|
|
title: '默认画布',
|
|
viewport: { x: 8, y: 9, scale: 1.5 },
|
|
layers: [],
|
|
updatedAt: '2026-06-12T00:00:00.000Z',
|
|
},
|
|
viewport: { x: 8, y: 9, scale: 1.5 },
|
|
layers: [],
|
|
resources: [],
|
|
updatedAt: '2026-06-12T00:00:00.000Z',
|
|
},
|
|
});
|
|
|
|
const project = await loadEditorProject('editor-project-1');
|
|
|
|
expect(project.viewport.scale).toBe(1.5);
|
|
expect(requestJsonMock).toHaveBeenCalledWith(
|
|
'/api/editor/projects/editor-project-1',
|
|
{ method: 'GET' },
|
|
'读取图片画布工程失败',
|
|
// 中文注释:同上,超时写死在断言里。这是未知结果对账路径上的读取,挂住会让 catch
|
|
// 迟迟不结束,连带把本会话对占位的归属登记一起拖过存活窗口。
|
|
{ timeoutMs: 60_000 },
|
|
);
|
|
});
|
|
|
|
it('forwards an abort signal and custom timeout when loading a project', async () => {
|
|
const controller = new AbortController();
|
|
requestJsonMock.mockResolvedValueOnce({
|
|
project: {
|
|
projectId: 'editor-project-1',
|
|
title: '角色设定板',
|
|
canvas: {
|
|
canvasId: 'editor-project-1:canvas:default',
|
|
projectId: 'editor-project-1',
|
|
title: '默认画布',
|
|
viewport: { x: 8, y: 9, scale: 1.5 },
|
|
layers: [],
|
|
updatedAt: '2026-06-12T00:00:00.000Z',
|
|
},
|
|
viewport: { x: 8, y: 9, scale: 1.5 },
|
|
layers: [],
|
|
resources: [],
|
|
updatedAt: '2026-06-12T00:00:00.000Z',
|
|
},
|
|
});
|
|
|
|
await loadEditorProject('editor-project-1', {
|
|
signal: controller.signal,
|
|
timeoutMs: 5_000,
|
|
});
|
|
|
|
expect(requestJsonMock).toHaveBeenCalledWith(
|
|
'/api/editor/projects/editor-project-1',
|
|
{ method: 'GET', signal: controller.signal },
|
|
'读取图片画布工程失败',
|
|
{ timeoutMs: 5_000 },
|
|
);
|
|
});
|
|
|
|
it('forwards an absolute lifecycle deadline when loading a project', async () => {
|
|
const controller = new AbortController();
|
|
const deadlineAt = Date.now() + 10_000;
|
|
requestJsonMock.mockResolvedValueOnce({
|
|
project: {
|
|
projectId: 'editor-project-1',
|
|
title: '角色设定板',
|
|
viewport: { x: 8, y: 9, scale: 1.5 },
|
|
layers: [],
|
|
resources: [],
|
|
updatedAt: '2026-06-12T00:00:00.000Z',
|
|
},
|
|
});
|
|
|
|
await loadEditorProject('editor-project-1', {
|
|
signal: controller.signal,
|
|
deadlineAt,
|
|
});
|
|
|
|
expect(requestJsonMock).toHaveBeenCalledWith(
|
|
'/api/editor/projects/editor-project-1',
|
|
{ method: 'GET', signal: controller.signal },
|
|
'读取图片画布工程失败',
|
|
{ deadlineAt },
|
|
);
|
|
});
|
|
|
|
it('renames and deletes an editor project', async () => {
|
|
requestJsonMock
|
|
.mockResolvedValueOnce({
|
|
project: {
|
|
projectId: 'editor-project-1',
|
|
title: '新标题',
|
|
canvas: {
|
|
canvasId: 'editor-project-1:canvas:default',
|
|
projectId: 'editor-project-1',
|
|
title: '默认画布',
|
|
viewport: { x: 0, y: 0, scale: 1 },
|
|
layers: [],
|
|
updatedAt: '2026-06-12T00:00:00.000Z',
|
|
},
|
|
viewport: { x: 0, y: 0, scale: 1 },
|
|
layers: [],
|
|
resources: [],
|
|
updatedAt: '2026-06-12T00:00:00.000Z',
|
|
},
|
|
})
|
|
.mockResolvedValueOnce({ deletedProjectId: 'editor-project-1' });
|
|
|
|
await renameEditorProject('editor-project-1', '新标题');
|
|
await deleteEditorProject('editor-project-1');
|
|
|
|
expect(requestJsonMock).toHaveBeenNthCalledWith(
|
|
1,
|
|
'/api/editor/projects/editor-project-1/metadata',
|
|
expect.objectContaining({
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ title: '新标题' }),
|
|
}),
|
|
'重命名图片画布工程失败',
|
|
);
|
|
expect(requestJsonMock).toHaveBeenNthCalledWith(
|
|
2,
|
|
'/api/editor/projects/editor-project-1',
|
|
{ method: 'DELETE' },
|
|
'删除图片画布工程失败',
|
|
);
|
|
});
|
|
|
|
it('creates a resource with upload or generated metadata', async () => {
|
|
requestJsonMock.mockResolvedValueOnce({
|
|
resource: {
|
|
resourceId: 'resource-generated-1',
|
|
projectId: 'editor-project-1',
|
|
imageSrc: '/generated-editor-assets/project/image.png',
|
|
objectKey: 'generated-editor-assets/project/image.png',
|
|
width: 2048,
|
|
height: 2048,
|
|
sourceType: 'generated',
|
|
prompt: 'dragon knight',
|
|
model: 'gpt-image-2',
|
|
},
|
|
});
|
|
|
|
await createEditorProjectResource('editor-project-1', {
|
|
imageSrc: '/generated-editor-assets/project/image.png',
|
|
objectKey: 'generated-editor-assets/project/image.png',
|
|
width: 2048,
|
|
height: 2048,
|
|
sourceType: 'generated',
|
|
prompt: 'dragon knight',
|
|
model: 'gpt-image-2',
|
|
sourceResourceId: 'resource-source',
|
|
assetKind: 'character',
|
|
generationInputs: {
|
|
fields: [{ title: '角色设定', value: 'dragon knight' }],
|
|
references: [],
|
|
},
|
|
});
|
|
|
|
expect(requestJsonMock).toHaveBeenCalledWith(
|
|
'/api/editor/projects/editor-project-1/resources',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
imageSrc: '/generated-editor-assets/project/image.png',
|
|
objectKey: 'generated-editor-assets/project/image.png',
|
|
width: 2048,
|
|
height: 2048,
|
|
sourceType: 'generated',
|
|
prompt: 'dragon knight',
|
|
model: 'gpt-image-2',
|
|
sourceResourceId: 'resource-source',
|
|
assetKind: 'character',
|
|
generationInputs: {
|
|
fields: [{ title: '角色设定', value: 'dragon knight' }],
|
|
references: [],
|
|
},
|
|
}),
|
|
}),
|
|
'创建图片画布资源失败',
|
|
);
|
|
});
|
|
|
|
it('passes image sequence duration milliseconds without rounding', async () => {
|
|
requestJsonMock
|
|
.mockResolvedValueOnce({
|
|
resource: {
|
|
resourceId: 'resource-action',
|
|
projectId: 'editor-project-1',
|
|
imageSrc: '/generated-actions/frame-01.png',
|
|
width: 1024,
|
|
height: 1024,
|
|
sourceType: 'generated',
|
|
assetKind: 'character-animation',
|
|
imageSequenceDurationMs: 2_600,
|
|
},
|
|
})
|
|
.mockResolvedValueOnce({
|
|
asset: {
|
|
assetId: 'asset-action',
|
|
folderId: 'project',
|
|
label: '角色动作',
|
|
imageSrc: '/generated-actions/frame-01.png',
|
|
width: 1024,
|
|
height: 1024,
|
|
sourceType: 'generated',
|
|
assetKind: 'character-animation',
|
|
imageSequenceDurationMs: 2_400,
|
|
},
|
|
});
|
|
|
|
await createEditorProjectResource('editor-project-1', {
|
|
imageSrc: '/generated-actions/frame-01.png',
|
|
width: 1024,
|
|
height: 1024,
|
|
sourceType: 'generated',
|
|
assetKind: 'character-animation',
|
|
imageSequenceFrames: [
|
|
{
|
|
imageSrc: '/generated-actions/frame-01.png',
|
|
width: 1024,
|
|
height: 1024,
|
|
},
|
|
{
|
|
imageSrc: '/generated-actions/frame-02.png',
|
|
width: 1024,
|
|
height: 1024,
|
|
},
|
|
],
|
|
imageSequenceDurationMs: 2_600,
|
|
});
|
|
await createEditorAsset({
|
|
folderId: 'project',
|
|
label: '角色动作',
|
|
imageSrc: '/generated-actions/frame-01.png',
|
|
width: 1024,
|
|
height: 1024,
|
|
sourceType: 'generated',
|
|
assetKind: 'character-animation',
|
|
imageSequenceFrames: [
|
|
{
|
|
imageSrc: '/generated-actions/frame-01.png',
|
|
width: 1024,
|
|
height: 1024,
|
|
},
|
|
{
|
|
imageSrc: '/generated-actions/frame-02.png',
|
|
width: 1024,
|
|
height: 1024,
|
|
},
|
|
],
|
|
imageSequenceDurationMs: 2_400,
|
|
});
|
|
|
|
expect(requestJsonMock).toHaveBeenNthCalledWith(
|
|
1,
|
|
'/api/editor/projects/editor-project-1/resources',
|
|
expect.objectContaining({
|
|
body: expect.stringContaining('"imageSequenceDurationMs":2600'),
|
|
}),
|
|
'创建图片画布资源失败',
|
|
);
|
|
expect(requestJsonMock).toHaveBeenNthCalledWith(
|
|
2,
|
|
'/api/editor/assets',
|
|
expect.objectContaining({
|
|
body: expect.stringContaining('"imageSequenceDurationMs":2400'),
|
|
}),
|
|
'创建图片画布素材失败',
|
|
);
|
|
});
|
|
|
|
it('updates project resource showcase visibility', async () => {
|
|
requestJsonMock.mockResolvedValueOnce({
|
|
resource: {
|
|
resourceId: 'resource-generated-1',
|
|
projectId: 'editor-project-1',
|
|
label: '角色素材',
|
|
imageSrc: '/generated-editor-assets/project/image.png',
|
|
objectKey: 'generated-editor-assets/project/image.png',
|
|
width: 2048,
|
|
height: 2048,
|
|
sourceType: 'generated',
|
|
publicShowcaseEnabled: false,
|
|
},
|
|
});
|
|
|
|
const resource = await updateEditorProjectResourceShowcase(
|
|
'resource-generated-1',
|
|
false,
|
|
);
|
|
|
|
expect(resource.publicShowcaseEnabled).toBe(false);
|
|
expect(requestJsonMock).toHaveBeenCalledWith(
|
|
'/api/editor/project-resources/resource-generated-1/showcase',
|
|
expect.objectContaining({
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ publicShowcaseEnabled: false }),
|
|
}),
|
|
'更新素材公开展示状态失败',
|
|
);
|
|
});
|
|
|
|
it('submits account assets to showcase review', async () => {
|
|
requestJsonMock.mockResolvedValueOnce({
|
|
showcaseAsset: {
|
|
resourceId: 'showcase-asset-1',
|
|
showcaseId: 'showcase-asset-1',
|
|
assetId: 'asset-1',
|
|
projectId: 'editor-showcase',
|
|
imageSrc: '/generated-editor-assets/project/image.png',
|
|
width: 2048,
|
|
height: 2048,
|
|
sourceType: 'generated',
|
|
reviewStatus: 'pending',
|
|
displayEnabled: false,
|
|
likeCount: 0,
|
|
generationCostMudPoints: 20,
|
|
refundMudPoints: 10,
|
|
},
|
|
});
|
|
|
|
const showcaseAsset = await submitEditorAssetShowcase('asset-1');
|
|
|
|
expect(showcaseAsset.showcaseId).toBe('showcase-asset-1');
|
|
expect(showcaseAsset.reviewStatus).toBe('pending');
|
|
expect(requestJsonMock).toHaveBeenCalledWith(
|
|
'/api/editor/assets/asset-1/showcase-submissions',
|
|
{ method: 'POST' },
|
|
'提交精选审核失败',
|
|
);
|
|
});
|
|
|
|
it('toggles showcase asset likes', async () => {
|
|
requestJsonMock.mockResolvedValueOnce({
|
|
showcaseAsset: {
|
|
resourceId: 'showcase-asset-1',
|
|
showcaseId: 'showcase-asset-1',
|
|
assetId: 'asset-1',
|
|
projectId: 'editor-showcase',
|
|
imageSrc: '/generated-editor-assets/project/image.png',
|
|
width: 2048,
|
|
height: 2048,
|
|
sourceType: 'generated',
|
|
reviewStatus: 'approved',
|
|
displayEnabled: true,
|
|
likeCount: 8,
|
|
viewerLiked: true,
|
|
generationCostMudPoints: 20,
|
|
refundMudPoints: 10,
|
|
},
|
|
});
|
|
|
|
const showcaseAsset = await toggleEditorShowcaseAssetLike(
|
|
'showcase-asset-1',
|
|
true,
|
|
);
|
|
|
|
expect(showcaseAsset.likeCount).toBe(8);
|
|
expect(showcaseAsset.viewerLiked).toBe(true);
|
|
expect(requestJsonMock).toHaveBeenCalledWith(
|
|
'/api/editor/showcase/assets/showcase-asset-1/likes',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ liked: true }),
|
|
}),
|
|
'点赞精选素材失败',
|
|
);
|
|
});
|
|
|
|
it('loads and mutates the account-level asset library', async () => {
|
|
requestJsonMock
|
|
.mockResolvedValueOnce({
|
|
library: {
|
|
folders: [
|
|
{
|
|
folderId: 'folder-project',
|
|
label: '项目素材',
|
|
sortOrder: 0,
|
|
collapsed: false,
|
|
systemDefault: true,
|
|
},
|
|
],
|
|
assets: [],
|
|
},
|
|
})
|
|
.mockResolvedValueOnce({
|
|
folder: {
|
|
folderId: 'folder-role',
|
|
label: '角色',
|
|
sortOrder: 100,
|
|
collapsed: false,
|
|
systemDefault: false,
|
|
},
|
|
})
|
|
.mockResolvedValueOnce({
|
|
folder: {
|
|
folderId: 'folder-role',
|
|
label: '角色参考',
|
|
sortOrder: 100,
|
|
collapsed: true,
|
|
systemDefault: false,
|
|
},
|
|
})
|
|
.mockResolvedValueOnce({
|
|
library: {
|
|
folders: [],
|
|
assets: [],
|
|
},
|
|
});
|
|
|
|
await loadEditorAssetLibrary();
|
|
await createEditorAssetFolder('角色', 100);
|
|
await updateEditorAssetFolder('folder-role', {
|
|
label: '角色参考',
|
|
collapsed: true,
|
|
});
|
|
await deleteEditorAssetFolder('folder-role');
|
|
|
|
expect(requestJsonMock).toHaveBeenNthCalledWith(
|
|
1,
|
|
'/api/editor/assets/library',
|
|
{ method: 'GET' },
|
|
'读取图片画布素材库失败',
|
|
);
|
|
expect(requestJsonMock).toHaveBeenNthCalledWith(
|
|
2,
|
|
'/api/editor/assets/folders',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
body: JSON.stringify({ label: '角色', sortOrder: 100 }),
|
|
}),
|
|
'创建图片画布素材文件夹失败',
|
|
);
|
|
expect(requestJsonMock).toHaveBeenNthCalledWith(
|
|
3,
|
|
'/api/editor/assets/folders/folder-role',
|
|
expect.objectContaining({
|
|
method: 'PATCH',
|
|
body: JSON.stringify({ label: '角色参考', collapsed: true }),
|
|
}),
|
|
'更新图片画布素材文件夹失败',
|
|
);
|
|
expect(requestJsonMock).toHaveBeenNthCalledWith(
|
|
4,
|
|
'/api/editor/assets/folders/folder-role',
|
|
{ method: 'DELETE' },
|
|
'删除图片画布素材文件夹失败',
|
|
);
|
|
});
|
|
|
|
it('creates, updates, and deletes account-level image assets', async () => {
|
|
requestJsonMock
|
|
.mockResolvedValueOnce({
|
|
asset: {
|
|
assetId: 'asset-1',
|
|
folderId: 'folder-project',
|
|
label: '主视觉.png',
|
|
imageSrc: 'data:image/png;base64,ZmFrZQ==',
|
|
width: 640,
|
|
height: 480,
|
|
sourceType: 'uploaded',
|
|
},
|
|
})
|
|
.mockResolvedValueOnce({
|
|
asset: {
|
|
assetId: 'asset-1',
|
|
folderId: 'folder-role',
|
|
label: '角色主视觉.png',
|
|
imageSrc: 'data:image/png;base64,ZmFrZQ==',
|
|
width: 640,
|
|
height: 480,
|
|
sourceType: 'uploaded',
|
|
},
|
|
})
|
|
.mockResolvedValueOnce({
|
|
asset: {
|
|
assetId: 'asset-1',
|
|
folderId: 'folder-role',
|
|
label: '角色主视觉.png',
|
|
imageSrc: 'data:image/png;base64,ZmFrZQ==',
|
|
width: 640,
|
|
height: 480,
|
|
sourceType: 'uploaded',
|
|
},
|
|
});
|
|
|
|
await createEditorAsset({
|
|
folderId: 'folder-project',
|
|
label: '主视觉.png',
|
|
imageSrc: 'data:image/png;base64,ZmFrZQ==',
|
|
width: 640,
|
|
height: 480,
|
|
sourceType: 'uploaded',
|
|
assetKind: 'icon',
|
|
generationInputs: {
|
|
fields: [{ title: '素材描述', value: '返回按钮' }],
|
|
references: [],
|
|
},
|
|
});
|
|
await updateEditorAsset('asset-1', {
|
|
label: '角色主视觉.png',
|
|
folderId: 'folder-role',
|
|
});
|
|
await deleteEditorAsset('asset-1');
|
|
|
|
expect(requestJsonMock).toHaveBeenNthCalledWith(
|
|
1,
|
|
'/api/editor/assets',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
folderId: 'folder-project',
|
|
label: '主视觉.png',
|
|
imageSrc: 'data:image/png;base64,ZmFrZQ==',
|
|
width: 640,
|
|
height: 480,
|
|
sourceType: 'uploaded',
|
|
assetKind: 'icon',
|
|
generationInputs: {
|
|
fields: [{ title: '素材描述', value: '返回按钮' }],
|
|
references: [],
|
|
},
|
|
}),
|
|
}),
|
|
'创建图片画布素材失败',
|
|
);
|
|
expect(requestJsonMock).toHaveBeenNthCalledWith(
|
|
2,
|
|
'/api/editor/assets/asset-1',
|
|
expect.objectContaining({
|
|
method: 'PATCH',
|
|
body: JSON.stringify({
|
|
label: '角色主视觉.png',
|
|
folderId: 'folder-role',
|
|
}),
|
|
}),
|
|
'更新图片画布素材失败',
|
|
);
|
|
expect(requestJsonMock).toHaveBeenNthCalledWith(
|
|
3,
|
|
'/api/editor/assets/asset-1',
|
|
{ method: 'DELETE' },
|
|
'删除图片画布素材失败',
|
|
);
|
|
});
|
|
|
|
it('creates an explicit project from title input', async () => {
|
|
requestJsonMock.mockResolvedValueOnce({
|
|
project: {
|
|
projectId: 'editor-project-explicit',
|
|
title: '角色设定板',
|
|
canvas: {
|
|
canvasId: 'editor-project-explicit:canvas:default',
|
|
projectId: 'editor-project-explicit',
|
|
title: '默认画布',
|
|
viewport: { x: 0, y: 0, scale: 1 },
|
|
layers: [],
|
|
updatedAt: '2026-06-12T00:00:00.000Z',
|
|
},
|
|
viewport: { x: 0, y: 0, scale: 1 },
|
|
layers: [],
|
|
resources: [],
|
|
updatedAt: '2026-06-12T00:00:00.000Z',
|
|
},
|
|
});
|
|
|
|
await createEditorProject({ title: '角色设定板' });
|
|
|
|
expect(requestJsonMock).toHaveBeenCalledWith(
|
|
'/api/editor/projects',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ title: '角色设定板' }),
|
|
}),
|
|
'创建图片画布工程失败',
|
|
);
|
|
});
|
|
|
|
it('generates editor images through the backend BFF', async () => {
|
|
requestJsonMock.mockResolvedValueOnce({
|
|
imageSrc: 'data:image/png;base64,abc',
|
|
width: 1024,
|
|
height: 1024,
|
|
sourceType: 'generated',
|
|
prompt: '一张画布图片',
|
|
actualPrompt: '一张画布图片',
|
|
model: 'gpt-image-2',
|
|
provider: 'VectorEngine',
|
|
taskId: 'vector-task-1',
|
|
warning: {
|
|
code: 'postprocess-failed-source-preserved',
|
|
reason: '生成任务成功,后处理失败。',
|
|
},
|
|
});
|
|
|
|
const result = await generateEditorImage({
|
|
prompt: '一张画布图片',
|
|
});
|
|
|
|
expect(result.taskId).toBe('vector-task-1');
|
|
expect(result.warning).toEqual({
|
|
code: 'postprocess-failed-source-preserved',
|
|
reason: '生成任务成功,后处理失败。',
|
|
});
|
|
expect(requestJsonMock).toHaveBeenCalledWith(
|
|
'/api/editor/images/generations',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
}),
|
|
'生成图片失败',
|
|
expect.objectContaining({
|
|
timeoutMs: 1_200_000,
|
|
retry: editorRetryOptionsExpectation,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('generates editor scenes through the structured endpoint without automatic retries', async () => {
|
|
requestJsonMock.mockResolvedValueOnce({
|
|
queueState: { jobId: 'scene-job-1', status: 'pending' },
|
|
});
|
|
|
|
await generateEditorScene({
|
|
sceneContent: '雨夜中的欧洲小镇街道',
|
|
stylePreset: 'anime',
|
|
model: 'gemini-3.1-flash-image-preview',
|
|
aspectRatio: '16:9',
|
|
imageSize: '1K',
|
|
referenceImageSrcs: ['editor-resource-reference'],
|
|
});
|
|
|
|
expect(requestJsonMock).toHaveBeenCalledWith(
|
|
'/api/editor/scenes/generations',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
sceneContent: '雨夜中的欧洲小镇街道',
|
|
stylePreset: 'anime',
|
|
model: 'gemini-3.1-flash-image-preview',
|
|
aspectRatio: '16:9',
|
|
imageSize: '1K',
|
|
referenceImageSrcs: ['editor-resource-reference'],
|
|
}),
|
|
}),
|
|
'生成游戏场景失败',
|
|
expect.objectContaining({
|
|
timeoutMs: 1_200_000,
|
|
retry: {
|
|
maxRetries: 0,
|
|
retryUnsafeMethods: false,
|
|
},
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('rejects scene reference overflow without sending an HTTP request', async () => {
|
|
await expect(
|
|
generateEditorScene({
|
|
sceneContent: '雨夜中的欧洲小镇街道',
|
|
stylePreset: 'anime',
|
|
referenceImageSrcs: Array.from(
|
|
{ length: 6 },
|
|
(_, index) =>
|
|
`/generated-images/editor/scene-reference-${index + 1}.png`,
|
|
),
|
|
}),
|
|
).rejects.toThrow('场景参考图最多允许 5 张');
|
|
|
|
expect(requestJsonMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects inline media before submitting editor generation requests', async () => {
|
|
await expect(
|
|
generateEditorImage({
|
|
prompt: '一张画布图片',
|
|
referenceImageSrcs: ['data:image/png;base64,inline'],
|
|
}),
|
|
).rejects.toThrow('必须先上传 OSS');
|
|
|
|
await expect(
|
|
generateEditorVideo({
|
|
prompt: '生成视频',
|
|
model: 'seedance2.0-fast',
|
|
aspectRatio: '16:9',
|
|
durationSeconds: 5,
|
|
resolution: '720p',
|
|
mode: 'std',
|
|
sound: 'off',
|
|
referenceVideoSrcs: ['blob:local-video'],
|
|
}),
|
|
).rejects.toThrow('必须先上传 OSS');
|
|
|
|
expect(requestJsonMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects image reference overflow before submitting generation requests', async () => {
|
|
const references = Array.from(
|
|
{ length: 6 },
|
|
(_, index) => `/generated-images/editor/reference-${index + 1}.png`,
|
|
);
|
|
|
|
await expect(
|
|
generateEditorImage({
|
|
prompt: '一张画布图片',
|
|
referenceImageSrcs: references,
|
|
}),
|
|
).rejects.toThrow('生成参考图最多允许 5 张');
|
|
await expect(
|
|
generateEditorImage({
|
|
prompt: '快速编辑画布图片',
|
|
kind: 'quick-edit',
|
|
referenceImageSrcs: references,
|
|
}),
|
|
).rejects.toThrow('生成参考图最多允许 5 张');
|
|
await expect(
|
|
generateEditorIconSpritesheet({
|
|
referenceId: 'editor-resource-spec',
|
|
referenceImageSrcs: references.slice(0, 5),
|
|
iconDescriptions: ['返回按钮'],
|
|
model: 'gpt-image-2',
|
|
}),
|
|
).rejects.toThrow('图标素材参考图最多允许 4 张');
|
|
await expect(
|
|
editEditorImage({
|
|
prompt: '修改图片',
|
|
sourceReferenceId: 'resource-source',
|
|
referenceImageSrcs: references.slice(0, 5),
|
|
model: 'gpt-image-2',
|
|
}),
|
|
).rejects.toThrow('修改参考图最多允许 4 张');
|
|
await expect(
|
|
extractEditorUiDesignAssets({
|
|
sourceImageSrc: '/generated-images/editor/ui.png',
|
|
referenceImageSrcs: references.slice(0, 5),
|
|
model: 'gpt-image-2',
|
|
aspectRatio: '1:1',
|
|
imageSize: '1K',
|
|
}),
|
|
).rejects.toThrow('UI素材参考图最多允许 4 张');
|
|
|
|
expect(requestJsonMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('uses the nanobanana2 capacity when icon generation omits the model', async () => {
|
|
requestJsonMock.mockResolvedValue({ queueState: { status: 'queued' } });
|
|
const references = Array.from(
|
|
{ length: 8 },
|
|
(_, index) => `/generated-images/editor/icon-reference-${index + 1}.png`,
|
|
);
|
|
|
|
await generateEditorIconSpritesheet({
|
|
referenceId: 'editor-resource-icon-spec',
|
|
referenceImageSrcs: references,
|
|
iconDescriptions: ['返回按钮'],
|
|
sliceMode: 'connected-components',
|
|
});
|
|
|
|
expect(requestJsonMock).toHaveBeenCalledWith(
|
|
'/api/editor/icon-spritesheets/generations',
|
|
expect.objectContaining({
|
|
body: expect.stringMatching(
|
|
/(?=.*"model":"gemini-3\.1-flash-image-preview")(?=.*"sliceMode":"connected-components")/,
|
|
),
|
|
}),
|
|
'生成图标素材失败',
|
|
expect.anything(),
|
|
);
|
|
});
|
|
|
|
it('rejects oversized stable video reference fields before submission', async () => {
|
|
await expect(
|
|
generateEditorVideo({
|
|
prompt: '生成视频',
|
|
model: 'seedance2.0-fast',
|
|
aspectRatio: '16:9',
|
|
durationSeconds: 5,
|
|
resolution: '720p',
|
|
mode: 'std',
|
|
sound: 'off',
|
|
referenceVideoSrcs: [
|
|
`https://assets.example.test/${'a'.repeat(256 * 1024)}`,
|
|
],
|
|
}),
|
|
).rejects.toThrow('稳定引用字段总长度不能超过 256KB');
|
|
|
|
expect(requestJsonMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('passes image model options to editor image generation', async () => {
|
|
requestJsonMock.mockResolvedValueOnce({
|
|
imageSrc: 'data:image/png;base64,character',
|
|
width: 1024,
|
|
height: 1536,
|
|
sourceType: 'generated',
|
|
prompt: '角色设定',
|
|
actualPrompt: '角色设定',
|
|
model: 'gpt-image-2',
|
|
provider: 'VectorEngine',
|
|
taskId: 'vector-character-1',
|
|
});
|
|
|
|
await generateEditorImage({
|
|
prompt: '角色设定',
|
|
kind: 'character',
|
|
model: 'gpt-image-2',
|
|
screenColor: '#FFD6C2',
|
|
segModel: 'anime-seg',
|
|
style: 'pixelArt',
|
|
aspectRatio: '2:3',
|
|
imageSize: '1K',
|
|
projectId: 'editor-project-1',
|
|
assetKind: 'character',
|
|
assetFolderId: 'project',
|
|
assetLabel: '角色形象',
|
|
sourceResourceId: 'resource-spec-1',
|
|
generationInputs: {
|
|
fields: [{ title: '角色设定', value: '角色设定' }],
|
|
references: [],
|
|
},
|
|
});
|
|
|
|
expect(requestJsonMock).toHaveBeenCalledWith(
|
|
'/api/editor/images/generations',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
prompt: '角色设定',
|
|
kind: 'character',
|
|
model: 'gpt-image-2',
|
|
screenColor: '#FFD6C2',
|
|
segModel: 'anime-seg',
|
|
style: 'pixelArt',
|
|
aspectRatio: '2:3',
|
|
imageSize: '1K',
|
|
projectId: 'editor-project-1',
|
|
assetKind: 'character',
|
|
generationInputs: {
|
|
fields: [{ title: '角色设定', value: '角色设定' }],
|
|
references: [],
|
|
},
|
|
assetFolderId: 'project',
|
|
assetLabel: '角色形象',
|
|
sourceResourceId: 'resource-spec-1',
|
|
}),
|
|
}),
|
|
'生成图片失败',
|
|
expect.objectContaining({
|
|
timeoutMs: 1_200_000,
|
|
retry: editorRetryOptionsExpectation,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('passes canvas completion context to editor image generation', async () => {
|
|
requestJsonMock.mockResolvedValueOnce({
|
|
imageSrc: 'data:image/png;base64,character',
|
|
width: 1024,
|
|
height: 1536,
|
|
sourceType: 'generated',
|
|
prompt: '角色设定',
|
|
actualPrompt: '角色设定',
|
|
model: 'gpt-image-2',
|
|
provider: 'VectorEngine',
|
|
taskId: 'vector-character-1',
|
|
project: null,
|
|
});
|
|
|
|
await generateEditorImage({
|
|
prompt: '角色设定',
|
|
kind: 'character',
|
|
projectId: 'editor-project-1',
|
|
assetKind: 'character',
|
|
canvasCompletion: {
|
|
dialogId: 'generation-dialog-1',
|
|
title: '角色形象 1',
|
|
placeholder: {
|
|
x: 120,
|
|
y: 140,
|
|
width: 420,
|
|
height: 560,
|
|
originalWidth: 1024,
|
|
originalHeight: 1536,
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(requestJsonMock).toHaveBeenCalledWith(
|
|
'/api/editor/images/generations',
|
|
expect.objectContaining({
|
|
body: JSON.stringify({
|
|
prompt: '角色设定',
|
|
kind: 'character',
|
|
projectId: 'editor-project-1',
|
|
assetKind: 'character',
|
|
canvasCompletion: {
|
|
dialogId: 'generation-dialog-1',
|
|
title: '角色形象 1',
|
|
placeholder: {
|
|
x: 120,
|
|
y: 140,
|
|
width: 420,
|
|
height: 560,
|
|
originalWidth: 1024,
|
|
originalHeight: 1536,
|
|
},
|
|
},
|
|
}),
|
|
}),
|
|
'生成图片失败',
|
|
expect.objectContaining({
|
|
timeoutMs: 1_200_000,
|
|
retry: editorRetryOptionsExpectation,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('generates icon spritesheets through the dedicated backend BFF', async () => {
|
|
requestJsonMock.mockResolvedValueOnce({
|
|
spritesheetImageSrc: 'data:image/png;base64,sheet',
|
|
spritesheetWidth: 512,
|
|
spritesheetHeight: 512,
|
|
iconImageSrcs: [],
|
|
sliceWarning: {
|
|
code: 'insufficient-connected-components',
|
|
reason: '连通域数量不足',
|
|
},
|
|
prompt: '图标素材 prompt',
|
|
actualPrompt: '图标素材 prompt',
|
|
model: 'gemini-3.1-flash-image-preview',
|
|
provider: 'VectorEngine',
|
|
taskId: 'icon-spritesheet-task-1',
|
|
});
|
|
|
|
const result = await generateEditorIconSpritesheet({
|
|
referenceId: 'editor-resource-spec',
|
|
referenceImageSrcs: ['/generated-images/editor/icon-ref.png'],
|
|
iconDescriptions: ['返回按钮', '设置按钮'],
|
|
assetLabel: '冒险游戏图标',
|
|
});
|
|
|
|
expect(result.taskId).toBe('icon-spritesheet-task-1');
|
|
expect(result.sliceWarning).toEqual({
|
|
code: 'insufficient-connected-components',
|
|
reason: '连通域数量不足',
|
|
});
|
|
expect(requestJsonMock).toHaveBeenCalledWith(
|
|
'/api/editor/icon-spritesheets/generations',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
referenceId: 'editor-resource-spec',
|
|
referenceImageSrcs: ['/generated-images/editor/icon-ref.png'],
|
|
iconDescriptions: ['返回按钮', '设置按钮'],
|
|
model: 'gemini-3.1-flash-image-preview',
|
|
assetLabel: '冒险游戏图标',
|
|
}),
|
|
}),
|
|
'生成图标素材失败',
|
|
expect.objectContaining({
|
|
timeoutMs: 1_200_000,
|
|
retry: editorRetryOptionsExpectation,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('rejects icon descriptions outside the item and aggregate contracts before requesting', async () => {
|
|
await expect(
|
|
generateEditorIconSpritesheet({
|
|
referenceId: 'editor-resource-spec',
|
|
iconDescriptions: ['图'.repeat(EDITOR_ICON_DESCRIPTION_MAX_CHARS + 1)],
|
|
}),
|
|
).rejects.toThrow(`不能超过 ${EDITOR_ICON_DESCRIPTION_MAX_CHARS} 个字符`);
|
|
|
|
await expect(
|
|
generateEditorIconSpritesheet({
|
|
referenceId: 'editor-resource-spec',
|
|
iconDescriptions: [
|
|
'图'.repeat(EDITOR_ICON_DESCRIPTION_MAX_CHARS),
|
|
...Array.from({ length: 10 }, () =>
|
|
'图'.repeat(EDITOR_ICON_DESCRIPTION_MAX_CHARS),
|
|
),
|
|
],
|
|
}),
|
|
).rejects.toThrow(
|
|
`合计不能超过 ${EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_CHARS} 个字符`,
|
|
);
|
|
|
|
await expect(
|
|
generateEditorIconSpritesheet({
|
|
referenceId: 'editor-resource-spec',
|
|
iconDescriptions: Array.from({ length: 8 }, () =>
|
|
'😀'.repeat(EDITOR_ICON_DESCRIPTION_MAX_CHARS),
|
|
),
|
|
}),
|
|
).rejects.toThrow(
|
|
`合计不能超过 ${EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_UTF8_BYTES} 个 UTF-8 字节`,
|
|
);
|
|
|
|
expect(requestJsonMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('passes image model options to icon spritesheet generation', async () => {
|
|
requestJsonMock.mockResolvedValueOnce({
|
|
spritesheetImageSrc: 'data:image/png;base64,sheet',
|
|
spritesheetWidth: 1024,
|
|
spritesheetHeight: 1024,
|
|
iconImageSrcs: [],
|
|
prompt: '图标素材 prompt',
|
|
actualPrompt: '图标素材 prompt',
|
|
model: 'gpt-image-2',
|
|
provider: 'VectorEngine',
|
|
taskId: 'icon-spritesheet-task-2',
|
|
});
|
|
|
|
await generateEditorIconSpritesheet({
|
|
referenceId: 'editor-resource-spec',
|
|
iconDescriptions: ['返回按钮'],
|
|
model: 'gpt-image-2',
|
|
screenColor: '#E6D8FF',
|
|
segModel: 'anime-seg',
|
|
style: 'none',
|
|
aspectRatio: '1:1',
|
|
imageSize: '2K',
|
|
projectId: 'editor-project-1',
|
|
assetFolderId: 'project',
|
|
generationInputs: {
|
|
fields: [{ title: '素材描述', value: '返回按钮' }],
|
|
references: [],
|
|
},
|
|
});
|
|
|
|
expect(requestJsonMock).toHaveBeenCalledWith(
|
|
'/api/editor/icon-spritesheets/generations',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
referenceId: 'editor-resource-spec',
|
|
iconDescriptions: ['返回按钮'],
|
|
model: 'gpt-image-2',
|
|
screenColor: '#E6D8FF',
|
|
segModel: 'anime-seg',
|
|
style: 'none',
|
|
aspectRatio: '1:1',
|
|
imageSize: '2K',
|
|
projectId: 'editor-project-1',
|
|
generationInputs: {
|
|
fields: [{ title: '素材描述', value: '返回按钮' }],
|
|
references: [],
|
|
},
|
|
assetFolderId: 'project',
|
|
}),
|
|
}),
|
|
'生成图标素材失败',
|
|
expect.objectContaining({
|
|
timeoutMs: 1_200_000,
|
|
retry: editorRetryOptionsExpectation,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('splits an existing icon spritesheet into project assets', async () => {
|
|
const project = {
|
|
projectId: 'editor-project-1',
|
|
title: '未命名画布',
|
|
canvas: {
|
|
canvasId: 'editor-project-1:canvas:default',
|
|
projectId: 'editor-project-1',
|
|
title: '默认画布',
|
|
viewport: { x: 0, y: 0, scale: 1 },
|
|
layers: [],
|
|
updatedAt: '2026-07-13T00:00:00.000Z',
|
|
},
|
|
viewport: { x: 0, y: 0, scale: 1 },
|
|
layers: [],
|
|
resources: [],
|
|
updatedAt: '2026-07-13T00:00:00.000Z',
|
|
};
|
|
requestJsonMock.mockResolvedValueOnce({
|
|
iconImageSrcs: [
|
|
{
|
|
name: '素材 1',
|
|
imageSrc: '/generated/atlas/asset-001.png',
|
|
width: 128,
|
|
height: 128,
|
|
},
|
|
],
|
|
project,
|
|
});
|
|
|
|
const result = await splitEditorIconSpritesheet({
|
|
projectId: 'editor-project-1',
|
|
sourceLayerId: 'layer-atlas-1',
|
|
sourceResourceId: 'resource-atlas-1',
|
|
assetFolderId: 'project',
|
|
canvasCompletion: {
|
|
title: '拆分图集',
|
|
placeholder: {
|
|
x: 120,
|
|
y: 80,
|
|
width: 360,
|
|
height: 360,
|
|
originalWidth: 1024,
|
|
originalHeight: 1024,
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(result.project).toBe(project);
|
|
expect(requestJsonMock).toHaveBeenCalledWith(
|
|
'/api/editor/icon-spritesheets/slices',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
projectId: 'editor-project-1',
|
|
sourceLayerId: 'layer-atlas-1',
|
|
sourceResourceId: 'resource-atlas-1',
|
|
assetFolderId: 'project',
|
|
canvasCompletion: {
|
|
title: '拆分图集',
|
|
placeholder: {
|
|
x: 120,
|
|
y: 80,
|
|
width: 360,
|
|
height: 360,
|
|
originalWidth: 1024,
|
|
originalHeight: 1024,
|
|
},
|
|
},
|
|
}),
|
|
}),
|
|
'拆分图集失败',
|
|
);
|
|
});
|
|
|
|
it('extracts UI design assets through the dedicated backend BFF', async () => {
|
|
requestJsonMock.mockResolvedValueOnce({
|
|
spritesheetImageSrc: 'data:image/png;base64,ui-sheet',
|
|
spritesheetWidth: 1024,
|
|
spritesheetHeight: 1024,
|
|
iconImageSrcs: [
|
|
{
|
|
name: '素材 1',
|
|
imageSrc: 'data:image/png;base64,asset-1',
|
|
width: 128,
|
|
height: 128,
|
|
},
|
|
],
|
|
prompt:
|
|
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯色背景 中度天蓝 #7FB3FF / RGB(127,179,255),方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板或阴影。',
|
|
actualPrompt:
|
|
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯色背景 中度天蓝 #7FB3FF / RGB(127,179,255),方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板或阴影。',
|
|
model: 'gpt-image-2',
|
|
provider: 'VectorEngine',
|
|
taskId: 'ui-asset-extraction-1',
|
|
});
|
|
|
|
const result = await extractEditorUiDesignAssets({
|
|
sourceImageSrc: '/generated-images/editor/ui-design.png',
|
|
model: 'gpt-image-2',
|
|
screenColor: '#7FB3FF',
|
|
segModel: 'anime-seg',
|
|
referenceImageSrcs: ['/generated-images/editor/ui-ref.png'],
|
|
projectId: 'editor-project-1',
|
|
assetFolderId: 'project',
|
|
spritesheetLabel: '战斗UI设计图 素材图集',
|
|
aspectRatio: '1:1',
|
|
imageSize: '2K',
|
|
generationInputs: {
|
|
fields: [{ title: '来源', value: '战斗UI设计图' }],
|
|
references: [],
|
|
},
|
|
});
|
|
|
|
expect(result.taskId).toBe('ui-asset-extraction-1');
|
|
expect(requestJsonMock).toHaveBeenCalledWith(
|
|
'/api/editor/ui-designs/assets/extractions',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
sourceImageSrc: '/generated-images/editor/ui-design.png',
|
|
model: 'gpt-image-2',
|
|
screenColor: '#7FB3FF',
|
|
segModel: 'anime-seg',
|
|
referenceImageSrcs: ['/generated-images/editor/ui-ref.png'],
|
|
projectId: 'editor-project-1',
|
|
generationInputs: {
|
|
fields: [{ title: '来源', value: '战斗UI设计图' }],
|
|
references: [],
|
|
},
|
|
assetFolderId: 'project',
|
|
spritesheetLabel: '战斗UI设计图 素材图集',
|
|
aspectRatio: '1:1',
|
|
imageSize: '2K',
|
|
}),
|
|
}),
|
|
'提取UI设计图素材失败',
|
|
expect.objectContaining({
|
|
timeoutMs: 1_200_000,
|
|
retry: editorRetryOptionsExpectation,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('passes spec generation size and kind to the backend BFF', async () => {
|
|
requestJsonMock.mockResolvedValueOnce({
|
|
imageSrc: 'data:image/png;base64,spec',
|
|
width: 2048,
|
|
height: 1152,
|
|
sourceType: 'generated',
|
|
prompt: '生成规范图',
|
|
actualPrompt: '生成规范图',
|
|
model: 'gpt-image-2',
|
|
provider: 'VectorEngine',
|
|
taskId: 'vector-spec-1',
|
|
});
|
|
|
|
const result = await generateEditorImage({
|
|
prompt: '生成规范图',
|
|
size: '2048x1152',
|
|
kind: 'spec',
|
|
model: 'gpt-image-2',
|
|
});
|
|
|
|
expect(result.width).toBe(2048);
|
|
expect(requestJsonMock).toHaveBeenCalledWith(
|
|
'/api/editor/images/generations',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
prompt: '生成规范图',
|
|
size: '2048x1152',
|
|
kind: 'spec',
|
|
model: 'gpt-image-2',
|
|
}),
|
|
}),
|
|
'生成图片失败',
|
|
expect.objectContaining({
|
|
timeoutMs: 1_200_000,
|
|
retry: editorRetryOptionsExpectation,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('uses dedicated icon spec refine endpoints', async () => {
|
|
requestJsonMock
|
|
.mockResolvedValueOnce({ playSetting: '优化玩法' })
|
|
.mockResolvedValueOnce({ artStyle: '优化画风' });
|
|
|
|
await expect(refineEditorIconSpecPlaySetting('原玩法')).resolves.toBe(
|
|
'优化玩法',
|
|
);
|
|
await expect(refineEditorIconSpecArtStyle('原画风')).resolves.toBe(
|
|
'优化画风',
|
|
);
|
|
expect(requestJsonMock.mock.calls[0]).toEqual([
|
|
'/api/editor/llm/icon-specs/refine-game-play',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
body: JSON.stringify({ playSetting: '原玩法' }),
|
|
}),
|
|
'优化玩法设定失败',
|
|
]);
|
|
expect(requestJsonMock.mock.calls[1]).toEqual([
|
|
'/api/editor/llm/icon-specs/refine-art-style',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
body: JSON.stringify({ artStyle: '原画风' }),
|
|
}),
|
|
'优化美术风格失败',
|
|
]);
|
|
});
|
|
|
|
it('enforces icon spec prompt length before and after refine requests', async () => {
|
|
const overLimit = '玩'.repeat(EDITOR_ICON_SPEC_PROMPT_MAX_LENGTH + 1);
|
|
|
|
await expect(refineEditorIconSpecPlaySetting(overLimit)).rejects.toThrow(
|
|
'玩法设定不能超过 200 个字符',
|
|
);
|
|
await expect(refineEditorIconSpecArtStyle(overLimit)).rejects.toThrow(
|
|
'美术风格不能超过 200 个字符',
|
|
);
|
|
expect(requestJsonMock).not.toHaveBeenCalled();
|
|
|
|
requestJsonMock.mockResolvedValueOnce({ playSetting: overLimit });
|
|
await expect(refineEditorIconSpecPlaySetting('原玩法')).rejects.toThrow(
|
|
'玩法设定不能超过 200 个字符',
|
|
);
|
|
});
|
|
|
|
it('normalizes icon spec fields and rejects blank values before requesting', async () => {
|
|
await expect(refineEditorIconSpecPlaySetting(' \n ')).rejects.toThrow(
|
|
'玩法设定不能为空',
|
|
);
|
|
await expect(
|
|
generateEditorIconSpec({
|
|
playSetting: '回合制',
|
|
artStyle: ' ',
|
|
}),
|
|
).rejects.toThrow('美术风格不能为空');
|
|
expect(requestJsonMock).not.toHaveBeenCalled();
|
|
|
|
requestJsonMock.mockResolvedValueOnce({ playSetting: ' 优化玩法 ' });
|
|
await expect(refineEditorIconSpecPlaySetting(' 原玩法 ')).resolves.toBe(
|
|
'优化玩法',
|
|
);
|
|
expect(requestJsonMock).toHaveBeenCalledWith(
|
|
'/api/editor/llm/icon-specs/refine-game-play',
|
|
expect.objectContaining({
|
|
body: JSON.stringify({ playSetting: '原玩法' }),
|
|
}),
|
|
'优化玩法设定失败',
|
|
);
|
|
});
|
|
|
|
it('rejects overlong icon spec generation fields before dispatch', async () => {
|
|
await expect(
|
|
generateEditorIconSpec({
|
|
playSetting: '玩'.repeat(EDITOR_ICON_SPEC_PROMPT_MAX_LENGTH + 1),
|
|
artStyle: '低多边形',
|
|
}),
|
|
).rejects.toThrow('玩法设定不能超过 200 个字符');
|
|
expect(requestJsonMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('submits icon spec business fields without a client prompt or image kind', async () => {
|
|
requestJsonMock.mockResolvedValueOnce({
|
|
imageSrc: 'data:image/png;base64,spec',
|
|
width: 2048,
|
|
height: 1152,
|
|
});
|
|
|
|
await generateEditorIconSpec({
|
|
playSetting: '回合制占点',
|
|
artStyle: '低多边形',
|
|
referenceId: 'resource-reference-1',
|
|
projectId: 'editor-project-1',
|
|
generationInputs: {
|
|
fields: [],
|
|
references: [
|
|
{
|
|
title: '参考图',
|
|
label: '参考图',
|
|
refType: 'project-resource',
|
|
refId: 'resource-reference-1',
|
|
},
|
|
],
|
|
},
|
|
assetFolderId: 'project',
|
|
assetLabel: '图标规范 1',
|
|
sourceResourceId: 'resource-source-1',
|
|
});
|
|
|
|
const body = JSON.parse(requestJsonMock.mock.calls[0]?.[1]?.body as string);
|
|
expect(body).toEqual({
|
|
playSetting: '回合制占点',
|
|
artStyle: '低多边形',
|
|
referenceId: 'resource-reference-1',
|
|
projectId: 'editor-project-1',
|
|
generationInputs: {
|
|
fields: [],
|
|
references: [
|
|
{
|
|
title: '参考图',
|
|
label: '参考图',
|
|
refType: 'project-resource',
|
|
refId: 'resource-reference-1',
|
|
},
|
|
],
|
|
},
|
|
assetFolderId: 'project',
|
|
assetLabel: '图标规范 1',
|
|
sourceResourceId: 'resource-source-1',
|
|
});
|
|
expect(body).not.toHaveProperty('prompt');
|
|
expect(body).not.toHaveProperty('kind');
|
|
expect(body).not.toHaveProperty('assetKind');
|
|
expect(requestJsonMock.mock.calls[0]?.[3]).toEqual({
|
|
timeoutMs: 1_200_000,
|
|
});
|
|
});
|
|
|
|
it('passes publication material generation kind and size to the backend BFF', async () => {
|
|
requestJsonMock.mockResolvedValueOnce({
|
|
imageSrc: 'data:image/png;base64,publication',
|
|
width: 720,
|
|
height: 1280,
|
|
sourceType: 'generated',
|
|
prompt: '宣发素材 prompt',
|
|
actualPrompt: '宣发素材 prompt',
|
|
model: 'gpt-image-2',
|
|
provider: 'VectorEngine',
|
|
taskId: 'vector-publication-1',
|
|
});
|
|
|
|
const result = await generateEditorImage({
|
|
prompt: '宣发素材 prompt',
|
|
size: '720x1280',
|
|
kind: 'publication-material',
|
|
aspectRatio: '9:16',
|
|
imageSize: '1K',
|
|
referenceImageSrcs: ['/generated-images/editor/publication-ref.png'],
|
|
});
|
|
|
|
expect(result.height).toBe(1280);
|
|
expect(requestJsonMock).toHaveBeenCalledWith(
|
|
'/api/editor/images/generations',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
prompt: '宣发素材 prompt',
|
|
size: '720x1280',
|
|
kind: 'publication-material',
|
|
aspectRatio: '9:16',
|
|
imageSize: '1K',
|
|
referenceImageSrcs: ['/generated-images/editor/publication-ref.png'],
|
|
}),
|
|
}),
|
|
'生成图片失败',
|
|
expect.objectContaining({
|
|
timeoutMs: 1_200_000,
|
|
retry: editorRetryOptionsExpectation,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('generates editor character animations through the backend BFF', async () => {
|
|
requestJsonMock.mockResolvedValueOnce({
|
|
taskId: 'character-animation-1',
|
|
model: 'seedance2.0-fast',
|
|
prompt: '生成游戏角色动画\n动作描述:\n待机',
|
|
previewVideoPath: '/generated-character-drafts/editor/preview.mp4',
|
|
frames: [
|
|
{
|
|
imageSrc: '/generated-character-drafts/editor/frame01.png',
|
|
width: 1024,
|
|
height: 1024,
|
|
},
|
|
],
|
|
frameCount: 32,
|
|
durationSeconds: 4,
|
|
fps: 8,
|
|
});
|
|
|
|
const result = await generateEditorCharacterAnimation({
|
|
sourceLayerId: 'layer-character',
|
|
sourceImageSrc: '/generated-images/editor/character.png',
|
|
sourceWidth: 1024,
|
|
sourceHeight: 1024,
|
|
promptText: '待机',
|
|
resolution: '480p',
|
|
ratio: 'same',
|
|
frameCount: 32,
|
|
durationSeconds: 4,
|
|
model: 'seedance2.0-fast',
|
|
assetLabel: '勇者待机',
|
|
});
|
|
|
|
expect(result.taskId).toBe('character-animation-1');
|
|
expect(requestJsonMock).toHaveBeenCalledWith(
|
|
'/api/editor/character-animations/generations',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
sourceLayerId: 'layer-character',
|
|
sourceImageSrc: '/generated-images/editor/character.png',
|
|
sourceWidth: 1024,
|
|
sourceHeight: 1024,
|
|
promptText: '待机',
|
|
resolution: '480p',
|
|
ratio: 'same',
|
|
frameCount: 32,
|
|
durationSeconds: 4,
|
|
model: 'seedance2.0-fast',
|
|
assetLabel: '勇者待机',
|
|
}),
|
|
}),
|
|
'生成角色动画失败',
|
|
expect.objectContaining({
|
|
timeoutMs: 1_200_000,
|
|
retry: editorRetryOptionsExpectation,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('generates editor videos through the backend BFF', async () => {
|
|
requestJsonMock.mockResolvedValueOnce({
|
|
videoSrc: '/generated-editor-videos/video-task-1/preview.mp4',
|
|
width: 1280,
|
|
height: 720,
|
|
sourceType: 'generated',
|
|
prompt: '让角色向镜头挥手',
|
|
actualPrompt: '让角色向镜头挥手',
|
|
model: 'kling3.0-omni',
|
|
provider: 'VectorEngine',
|
|
taskId: 'video-task-1',
|
|
durationSeconds: 5,
|
|
resolution: '480p',
|
|
thumbnailSrc: '/generated-editor-videos/video-task-1/cover.png',
|
|
asset: {
|
|
assetId: 'asset-video-1',
|
|
folderId: 'user-1:asset-folder:project',
|
|
label: '生成视频 1',
|
|
imageSrc: '/generated-editor-videos/video-task-1/preview.mp4',
|
|
thumbnailSrc: '/generated-editor-videos/video-task-1/cover.png',
|
|
width: 1280,
|
|
height: 720,
|
|
sourceType: 'generated',
|
|
assetKind: 'video',
|
|
},
|
|
});
|
|
|
|
const result = await generateEditorVideo({
|
|
prompt: '让角色向镜头挥手',
|
|
model: 'kling3.0-omni',
|
|
aspectRatio: '16:9',
|
|
durationSeconds: 5,
|
|
resolution: '480p',
|
|
mode: 'std',
|
|
sound: 'off',
|
|
assetFolderId: 'project',
|
|
assetLabel: '生成视频 1',
|
|
});
|
|
|
|
expect(result.taskId).toBe('video-task-1');
|
|
expect(result.thumbnailSrc).toBe(
|
|
'/generated-editor-videos/video-task-1/cover.png',
|
|
);
|
|
expect(result.asset?.assetId).toBe('asset-video-1');
|
|
expect(result.asset?.thumbnailSrc).toBe(
|
|
'/generated-editor-videos/video-task-1/cover.png',
|
|
);
|
|
expect(requestJsonMock).toHaveBeenCalledWith(
|
|
'/api/editor/videos/generations',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
prompt: '让角色向镜头挥手',
|
|
model: 'kling3.0-omni',
|
|
aspectRatio: '16:9',
|
|
durationSeconds: 5,
|
|
resolution: '480p',
|
|
mode: 'std',
|
|
sound: 'off',
|
|
webSearchEnabled: true,
|
|
assetFolderId: 'project',
|
|
assetLabel: '生成视频 1',
|
|
}),
|
|
}),
|
|
'生成视频失败',
|
|
expect.objectContaining({
|
|
timeoutMs: 1_200_000,
|
|
retry: editorRetryOptionsExpectation,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('passes seedance video multimodal references through the backend BFF', async () => {
|
|
requestJsonMock.mockResolvedValueOnce({
|
|
videoSrc: '/generated-editor-videos/video-task-2/preview.mp4',
|
|
width: 1280,
|
|
height: 720,
|
|
sourceType: 'generated',
|
|
prompt: '参考素材生成视频',
|
|
actualPrompt: '参考素材生成视频',
|
|
model: 'seedance2.0-fast',
|
|
provider: 'VectorEngine',
|
|
taskId: 'video-task-2',
|
|
durationSeconds: 5,
|
|
resolution: '720p',
|
|
});
|
|
|
|
await generateEditorVideo({
|
|
prompt: '参考素材生成视频',
|
|
model: 'seedance2.0-fast',
|
|
aspectRatio: '16:9',
|
|
durationSeconds: 5,
|
|
resolution: '720p',
|
|
mode: 'std',
|
|
sound: 'off',
|
|
referenceImageSrcs: ['/generated-images/editor/video-reference.png'],
|
|
referenceVideoSrcs: [
|
|
'generated-character-drafts/editor/seedance-references/video/video.mp4',
|
|
],
|
|
referenceAudioSrcs: [
|
|
'generated-character-drafts/editor/seedance-references/audio/audio.mp3',
|
|
],
|
|
});
|
|
|
|
expect(requestJsonMock).toHaveBeenCalledWith(
|
|
'/api/editor/videos/generations',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
prompt: '参考素材生成视频',
|
|
model: 'seedance2.0-fast',
|
|
aspectRatio: '16:9',
|
|
durationSeconds: 5,
|
|
resolution: '720p',
|
|
mode: 'std',
|
|
sound: 'off',
|
|
webSearchEnabled: true,
|
|
referenceImageSrcs: ['/generated-images/editor/video-reference.png'],
|
|
referenceVideoSrcs: [
|
|
'generated-character-drafts/editor/seedance-references/video/video.mp4',
|
|
],
|
|
referenceAudioSrcs: [
|
|
'generated-character-drafts/editor/seedance-references/audio/audio.mp3',
|
|
],
|
|
}),
|
|
}),
|
|
'生成视频失败',
|
|
expect.objectContaining({ timeoutMs: 1_200_000 }),
|
|
);
|
|
});
|
|
|
|
it('generates editor sound effects through the backend BFF', async () => {
|
|
requestJsonMock.mockResolvedValueOnce({
|
|
audioSrc: '/generated-character-drafts/editor-audios/sfx.mp3',
|
|
width: 420,
|
|
height: 120,
|
|
sourceType: 'generated',
|
|
prompt: '金币掉落叮当声',
|
|
actualPrompt: 'A bright coin pickup chime',
|
|
model: 'eleven_text_to_sound_v2',
|
|
taskId: 'sound-task-1',
|
|
audioKind: 'sound-effect',
|
|
asset: {
|
|
assetId: 'asset-sound-1',
|
|
folderId: 'user-1:asset-folder:project',
|
|
label: '游戏音效 1',
|
|
imageSrc: '/generated-character-drafts/editor-audios/sfx.mp3',
|
|
width: 420,
|
|
height: 120,
|
|
sourceType: 'generated',
|
|
assetKind: 'sound-effect',
|
|
},
|
|
});
|
|
|
|
const result = await generateEditorSoundEffect({
|
|
prompt: '金币掉落叮当声',
|
|
model: 'eleven_text_to_sound_v2',
|
|
duration: 7.5,
|
|
loop: true,
|
|
assetFolderId: 'project',
|
|
assetLabel: '游戏音效 1',
|
|
});
|
|
|
|
expect(result.taskId).toBe('sound-task-1');
|
|
expect(result.asset?.assetId).toBe('asset-sound-1');
|
|
expect(requestJsonMock).toHaveBeenCalledWith(
|
|
'/api/editor/audios/sound-effects/generations',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
prompt: '金币掉落叮当声',
|
|
model: 'eleven_text_to_sound_v2',
|
|
duration: 7.5,
|
|
loop: true,
|
|
assetFolderId: 'project',
|
|
assetLabel: '游戏音效 1',
|
|
}),
|
|
}),
|
|
'生成游戏音效失败',
|
|
{ timeoutMs: 1_200_000 },
|
|
);
|
|
});
|
|
|
|
it('canonicalizes omitted duration to null and omitted loop to false', async () => {
|
|
requestJsonMock.mockResolvedValueOnce({
|
|
audioSrc: '/generated-character-drafts/editor-audios/sfx-null.mp3',
|
|
width: 420,
|
|
height: 120,
|
|
sourceType: 'generated',
|
|
prompt: '按钮确认短促音',
|
|
actualPrompt: 'A short confirmation click',
|
|
model: 'eleven_text_to_sound_v2',
|
|
taskId: 'sound-task-null',
|
|
audioKind: 'sound-effect',
|
|
});
|
|
|
|
await generateEditorSoundEffect({
|
|
prompt: '按钮确认短促音',
|
|
model: 'eleven_text_to_sound_v2',
|
|
});
|
|
|
|
expect(requestJsonMock).toHaveBeenCalledWith(
|
|
'/api/editor/audios/sound-effects/generations',
|
|
expect.objectContaining({
|
|
body: JSON.stringify({
|
|
prompt: '按钮确认短促音',
|
|
model: 'eleven_text_to_sound_v2',
|
|
duration: null,
|
|
loop: false,
|
|
}),
|
|
}),
|
|
'生成游戏音效失败',
|
|
expect.any(Object),
|
|
);
|
|
});
|
|
|
|
it('accepts the 0.5 and 30 second sound effect duration boundaries', async () => {
|
|
requestJsonMock.mockResolvedValue({});
|
|
|
|
for (const duration of [0.5, 30]) {
|
|
await generateEditorSoundEffect({
|
|
prompt: '按钮确认短促音',
|
|
model: 'eleven_text_to_sound_v2',
|
|
duration,
|
|
});
|
|
}
|
|
|
|
expect(requestJsonMock).toHaveBeenNthCalledWith(
|
|
1,
|
|
'/api/editor/audios/sound-effects/generations',
|
|
expect.objectContaining({
|
|
body: JSON.stringify({
|
|
prompt: '按钮确认短促音',
|
|
model: 'eleven_text_to_sound_v2',
|
|
duration: 0.5,
|
|
loop: false,
|
|
}),
|
|
}),
|
|
'生成游戏音效失败',
|
|
expect.any(Object),
|
|
);
|
|
expect(requestJsonMock).toHaveBeenNthCalledWith(
|
|
2,
|
|
'/api/editor/audios/sound-effects/generations',
|
|
expect.objectContaining({
|
|
body: JSON.stringify({
|
|
prompt: '按钮确认短促音',
|
|
model: 'eleven_text_to_sound_v2',
|
|
duration: 30,
|
|
loop: false,
|
|
}),
|
|
}),
|
|
'生成游戏音效失败',
|
|
expect.any(Object),
|
|
);
|
|
});
|
|
|
|
it('rejects invalid sound effect durations before the request', async () => {
|
|
for (const duration of [
|
|
0.49,
|
|
30.01,
|
|
Number.NaN,
|
|
Number.POSITIVE_INFINITY,
|
|
]) {
|
|
await expect(
|
|
generateEditorSoundEffect({
|
|
prompt: '按钮确认短促音',
|
|
model: 'eleven_text_to_sound_v2',
|
|
duration,
|
|
}),
|
|
).rejects.toThrow('游戏音效时长必须在 0.5-30 秒之间');
|
|
}
|
|
|
|
expect(requestJsonMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('generates editor background music through the backend BFF', async () => {
|
|
const representativeComplexPromptCase =
|
|
backgroundMusicPromptCanonicalizationCases.find(
|
|
({ name }) => name === 'representative-complex',
|
|
);
|
|
if (!representativeComplexPromptCase) {
|
|
throw new Error('missing representative-complex prompt fixture');
|
|
}
|
|
const canonicalPrompt = representativeComplexPromptCase.prompt;
|
|
requestJsonMock.mockResolvedValueOnce({
|
|
audioSrc: '/generated-character-drafts/editor-audios/bgm.mp3',
|
|
width: 420,
|
|
height: 120,
|
|
sourceType: 'generated',
|
|
prompt: canonicalPrompt,
|
|
actualPrompt: canonicalPrompt,
|
|
model: 'chirp-v4',
|
|
taskId: 'music-task-1',
|
|
audioKind: 'background-music',
|
|
});
|
|
|
|
const result = await generateEditorBackgroundMusic({
|
|
gptDescriptionPrompt: canonicalPrompt,
|
|
makeInstrumental: true,
|
|
assetFolderId: 'project',
|
|
assetLabel: '游戏背景音乐 1',
|
|
});
|
|
|
|
expect(result.taskId).toBe('music-task-1');
|
|
expect(requestJsonMock).toHaveBeenCalledWith(
|
|
'/api/editor/audios/background-music/generations',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
gptDescriptionPrompt: canonicalPrompt,
|
|
makeInstrumental: true,
|
|
assetFolderId: 'project',
|
|
assetLabel: '游戏背景音乐 1',
|
|
}),
|
|
}),
|
|
'生成游戏背景音乐失败',
|
|
{
|
|
timeoutMs: 1_200_000,
|
|
},
|
|
);
|
|
});
|
|
|
|
it('forwards background music transport failures without configuring retries', async () => {
|
|
requestJsonMock.mockRejectedValueOnce(new Error('transport failed'));
|
|
|
|
await expect(
|
|
generateEditorBackgroundMusic({
|
|
gptDescriptionPrompt: '森林冒险背景音乐',
|
|
makeInstrumental: true,
|
|
}),
|
|
).rejects.toThrow('transport failed');
|
|
|
|
expect(requestJsonMock.mock.calls[0]?.[3]).toEqual({
|
|
timeoutMs: 1_200_000,
|
|
});
|
|
});
|
|
|
|
it('completes a background music prompt through the authenticated internal BFF', async () => {
|
|
requestJsonMock.mockResolvedValueOnce({
|
|
prompt: '轻快明亮的森林冒险背景音乐',
|
|
charCount: 13,
|
|
});
|
|
|
|
const result = await completeEditorBackgroundMusicPrompt({
|
|
currentPrompt: '森林冒险',
|
|
targetChars: 180,
|
|
maxChars: 200,
|
|
} as Parameters<typeof completeEditorBackgroundMusicPrompt>[0] & {
|
|
targetChars: number;
|
|
maxChars: number;
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
prompt: '轻快明亮的森林冒险背景音乐',
|
|
charCount: 13,
|
|
});
|
|
expect(requestJsonMock).toHaveBeenCalledWith(
|
|
'/api/editor/audios/background-music/prompts/completions',
|
|
{
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
currentPrompt: '森林冒险',
|
|
}),
|
|
signal: undefined,
|
|
},
|
|
'AI 补全背景音乐提示词失败',
|
|
promptAssistRequestOptions,
|
|
);
|
|
expect(EDITOR_BACKGROUND_MUSIC_PROMPT_ASSIST_TIMEOUT_MS).toBe(180_000);
|
|
expect(requestJsonMock.mock.calls[0]).toHaveLength(4);
|
|
expect(requestJsonMock.mock.calls[0][3]).toEqual(
|
|
promptAssistRequestOptions,
|
|
);
|
|
});
|
|
|
|
it('simplifies a background music prompt without forwarding client-controlled limits', async () => {
|
|
requestJsonMock.mockResolvedValueOnce({
|
|
prompt: '紧张推进的战斗背景音乐',
|
|
charCount: 11,
|
|
});
|
|
|
|
const result = await simplifyEditorBackgroundMusicPrompt({
|
|
currentPrompt: '紧张推进、铜管与鼓点交织的战斗背景音乐'.repeat(10),
|
|
targetChars: 170,
|
|
maxChars: 200,
|
|
model: 'client-must-not-control',
|
|
} as Parameters<typeof simplifyEditorBackgroundMusicPrompt>[0] & {
|
|
targetChars: number;
|
|
maxChars: number;
|
|
model: string;
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
prompt: '紧张推进的战斗背景音乐',
|
|
charCount: 11,
|
|
});
|
|
expect(requestJsonMock).toHaveBeenCalledWith(
|
|
'/api/editor/audios/background-music/prompts/simplifications',
|
|
{
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
currentPrompt: '紧张推进、铜管与鼓点交织的战斗背景音乐'.repeat(10),
|
|
}),
|
|
signal: undefined,
|
|
},
|
|
'简化背景音乐提示词失败',
|
|
promptAssistRequestOptions,
|
|
);
|
|
expect(requestJsonMock.mock.calls[0]).toHaveLength(4);
|
|
expect(requestJsonMock.mock.calls[0][3]).toEqual(
|
|
promptAssistRequestOptions,
|
|
);
|
|
});
|
|
|
|
it('optimizes an SFX prompt through the authenticated internal BFF without retries or client-controlled limits', async () => {
|
|
requestJsonMock.mockResolvedValueOnce({
|
|
prompt: '清脆明亮的金币落地声',
|
|
charCount: 10,
|
|
});
|
|
const controller = new AbortController();
|
|
|
|
const result = await optimizeEditorSoundEffectPrompt(
|
|
{
|
|
currentPrompt: '金币落地声',
|
|
targetChars: 2048,
|
|
model: 'client-must-not-control',
|
|
} as Parameters<typeof optimizeEditorSoundEffectPrompt>[0] & {
|
|
targetChars: number;
|
|
model: string;
|
|
},
|
|
{ signal: controller.signal },
|
|
);
|
|
|
|
expect(result).toEqual({
|
|
prompt: '清脆明亮的金币落地声',
|
|
charCount: 10,
|
|
});
|
|
expect(requestJsonMock).toHaveBeenCalledWith(
|
|
'/api/editor/audios/sound-effects/prompts/optimizations',
|
|
{
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ currentPrompt: '金币落地声' }),
|
|
signal: controller.signal,
|
|
},
|
|
'优化游戏音效提示词失败',
|
|
{ timeoutMs: EDITOR_SOUND_EFFECT_PROMPT_OPTIMIZE_TIMEOUT_MS },
|
|
);
|
|
expect(EDITOR_SOUND_EFFECT_PROMPT_OPTIMIZE_TIMEOUT_MS).toBe(180_000);
|
|
expect(requestJsonMock.mock.calls[0]).toHaveLength(4);
|
|
});
|
|
|
|
it.each([
|
|
[
|
|
'completion',
|
|
completeEditorBackgroundMusicPrompt,
|
|
'/api/editor/audios/background-music/prompts/completions',
|
|
'AI 补全背景音乐提示词失败',
|
|
],
|
|
[
|
|
'simplification',
|
|
simplifyEditorBackgroundMusicPrompt,
|
|
'/api/editor/audios/background-music/prompts/simplifications',
|
|
'简化背景音乐提示词失败',
|
|
],
|
|
] as const)(
|
|
'forwards the AbortSignal for background music prompt %s',
|
|
async (_label, requestPromptAssist, path, fallbackMessage) => {
|
|
const controller = new AbortController();
|
|
const abortReason = new Error('prompt assist cancelled');
|
|
requestJsonMock.mockImplementationOnce(
|
|
(_url: string, init: RequestInit) =>
|
|
new Promise((_resolve, reject) => {
|
|
init.signal?.addEventListener(
|
|
'abort',
|
|
() => reject(init.signal?.reason),
|
|
{ once: true },
|
|
);
|
|
}),
|
|
);
|
|
|
|
const request = requestPromptAssist(
|
|
{ currentPrompt: '森林冒险' },
|
|
{ signal: controller.signal },
|
|
);
|
|
controller.abort(abortReason);
|
|
|
|
await expect(request).rejects.toBe(abortReason);
|
|
expect(requestJsonMock).toHaveBeenCalledWith(
|
|
path,
|
|
expect.objectContaining({
|
|
signal: controller.signal,
|
|
body: JSON.stringify({ currentPrompt: '森林冒险' }),
|
|
}),
|
|
fallbackMessage,
|
|
promptAssistRequestOptions,
|
|
);
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
[
|
|
'completion',
|
|
completeEditorBackgroundMusicPrompt,
|
|
'AI 补全背景音乐提示词失败',
|
|
],
|
|
[
|
|
'simplification',
|
|
simplifyEditorBackgroundMusicPrompt,
|
|
'简化背景音乐提示词失败',
|
|
],
|
|
] as const)(
|
|
'preserves the v1 API error from background music prompt %s',
|
|
async (_label, requestPromptAssist, fallbackMessage) => {
|
|
const apiError = Object.assign(new Error('登录状态已失效'), {
|
|
name: 'ApiClientError',
|
|
status: 401,
|
|
code: 'UNAUTHORIZED',
|
|
});
|
|
requestJsonMock.mockRejectedValueOnce(apiError);
|
|
|
|
await expect(
|
|
requestPromptAssist({ currentPrompt: '森林冒险' }),
|
|
).rejects.toBe(apiError);
|
|
expect(requestJsonMock).toHaveBeenCalledWith(
|
|
expect.stringMatching(
|
|
/^\/api\/editor\/audios\/background-music\/prompts\//,
|
|
),
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
body: JSON.stringify({ currentPrompt: '森林冒险' }),
|
|
}),
|
|
fallbackMessage,
|
|
promptAssistRequestOptions,
|
|
);
|
|
expect(requestJsonMock.mock.calls[0]).toHaveLength(4);
|
|
},
|
|
);
|
|
|
|
it('edits editor images through the backend BFF', async () => {
|
|
requestJsonMock.mockResolvedValueOnce({
|
|
imageSrc: 'data:image/png;base64,edited',
|
|
width: 1024,
|
|
height: 1024,
|
|
sourceType: 'generated',
|
|
prompt: '把画面改成黄昏光线',
|
|
actualPrompt: '把画面改成黄昏光线',
|
|
model: 'gpt-image-2',
|
|
provider: 'VectorEngine',
|
|
taskId: 'vector-edit-1',
|
|
});
|
|
|
|
const result = await editEditorImage({
|
|
prompt: '把画面改成黄昏光线',
|
|
sourceReferenceId: 'resource-character-1',
|
|
size: '1024x1024',
|
|
model: 'gpt-image-2',
|
|
aspectRatio: '1:1',
|
|
imageSize: '1K',
|
|
referenceImageSrcs: ['/generated-images/editor/style.png'],
|
|
projectId: 'editor-project-1',
|
|
assetFolderId: 'project',
|
|
assetLabel: '角色形象 修改结果',
|
|
targetLayerId: 'layer-character-1',
|
|
generationInputs: {
|
|
fields: [{ title: '修改提示词', value: '把画面改成黄昏光线' }],
|
|
references: [],
|
|
},
|
|
});
|
|
|
|
expect(result.taskId).toBe('vector-edit-1');
|
|
expect(requestJsonMock).toHaveBeenCalledWith(
|
|
'/api/editor/images/edits',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
prompt: '把画面改成黄昏光线',
|
|
sourceReferenceId: 'resource-character-1',
|
|
size: '1024x1024',
|
|
model: 'gpt-image-2',
|
|
aspectRatio: '1:1',
|
|
imageSize: '1K',
|
|
referenceImageSrcs: ['/generated-images/editor/style.png'],
|
|
projectId: 'editor-project-1',
|
|
generationInputs: {
|
|
fields: [{ title: '修改提示词', value: '把画面改成黄昏光线' }],
|
|
references: [],
|
|
},
|
|
assetFolderId: 'project',
|
|
assetLabel: '角色形象 修改结果',
|
|
targetLayerId: 'layer-character-1',
|
|
}),
|
|
}),
|
|
'修改图片失败',
|
|
expect.objectContaining({
|
|
timeoutMs: 1_200_000,
|
|
retry: editorRetryOptionsExpectation,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('passes canvas completion context to background removal', async () => {
|
|
requestJsonMock.mockResolvedValueOnce({
|
|
queueState: {
|
|
operationId: 'background-removal-1',
|
|
status: 'queued',
|
|
phaseLabel: '排队中',
|
|
phaseDetail: '排队中',
|
|
progress: 0,
|
|
updatedAtMicros: 1,
|
|
},
|
|
});
|
|
|
|
const response = await removeEditorImageBackground({
|
|
sourceImageSrc: '/generated-images/editor/source.png',
|
|
projectId: 'editor-project-1',
|
|
targetLayerId: 'layer-source',
|
|
canvasCompletion: {
|
|
dialogId: 'generation-dialog-1',
|
|
title: '源图 去背景',
|
|
placeholder: {
|
|
x: 120,
|
|
y: 140,
|
|
width: 320,
|
|
height: 240,
|
|
originalWidth: 320,
|
|
originalHeight: 240,
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(response.queueState.operationId).toBe('background-removal-1');
|
|
|
|
expect(requestJsonMock).toHaveBeenCalledWith(
|
|
'/api/editor/images/background-removals',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
sourceImageSrc: '/generated-images/editor/source.png',
|
|
projectId: 'editor-project-1',
|
|
targetLayerId: 'layer-source',
|
|
canvasCompletion: {
|
|
dialogId: 'generation-dialog-1',
|
|
title: '源图 去背景',
|
|
placeholder: {
|
|
x: 120,
|
|
y: 140,
|
|
width: 320,
|
|
height: 240,
|
|
originalWidth: 320,
|
|
originalHeight: 240,
|
|
},
|
|
},
|
|
}),
|
|
}),
|
|
'去除背景失败',
|
|
expect.objectContaining({
|
|
timeoutMs: 1_200_000,
|
|
retry: editorRetryOptionsExpectation,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('submits a stable perfect-pixel source without unsafe POST retries', async () => {
|
|
requestJsonMock.mockResolvedValueOnce({
|
|
imageSrc: '/generated-images/editor/perfect-pixel.png',
|
|
objectKey: 'generated-images/editor/perfect-pixel.png',
|
|
assetObjectId: 'asset-object-perfect-pixel',
|
|
width: 1024,
|
|
height: 768,
|
|
sourceType: 'generated',
|
|
taskId: 'perfect-pixel-1',
|
|
elapsedMs: 321,
|
|
provider: 'Genarrative',
|
|
resource: { resourceId: 'resource-perfect-pixel' },
|
|
asset: { assetId: 'asset-perfect-pixel' },
|
|
project: null,
|
|
});
|
|
|
|
const result = await snapEditorImageToPixelArt({
|
|
sourceImageSrc: 'generated-images/editor/source.png',
|
|
projectId: 'editor-project-1',
|
|
sourceResourceId: 'resource-source',
|
|
assetKind: 'character',
|
|
generationInputs: {
|
|
fields: [{ title: '角色设定', value: '红发骑士' }],
|
|
references: [],
|
|
},
|
|
assetFolderId: 'project',
|
|
assetLabel: '源图 · 完美像素',
|
|
canvasCompletion: {
|
|
dialogId: 'generation-dialog-perfect-pixel',
|
|
title: '源图 · 完美像素',
|
|
placeholder: {
|
|
x: 472,
|
|
y: 140,
|
|
width: 320,
|
|
height: 240,
|
|
originalWidth: 320,
|
|
originalHeight: 240,
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(result.taskId).toBe('perfect-pixel-1');
|
|
expect(requestJsonMock).toHaveBeenCalledWith(
|
|
'/api/editor/images/pixel-art-snaps',
|
|
{
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
sourceImageSrc: 'generated-images/editor/source.png',
|
|
projectId: 'editor-project-1',
|
|
sourceResourceId: 'resource-source',
|
|
assetKind: 'character',
|
|
generationInputs: {
|
|
fields: [{ title: '角色设定', value: '红发骑士' }],
|
|
references: [],
|
|
},
|
|
assetFolderId: 'project',
|
|
assetLabel: '源图 · 完美像素',
|
|
canvasCompletion: {
|
|
dialogId: 'generation-dialog-perfect-pixel',
|
|
title: '源图 · 完美像素',
|
|
placeholder: {
|
|
x: 472,
|
|
y: 140,
|
|
width: 320,
|
|
height: 240,
|
|
originalWidth: 320,
|
|
originalHeight: 240,
|
|
},
|
|
},
|
|
}),
|
|
},
|
|
'完美像素处理失败',
|
|
{ timeoutMs: 120_000 },
|
|
);
|
|
});
|
|
|
|
it('rejects inline perfect-pixel media before sending the request', async () => {
|
|
await expect(
|
|
snapEditorImageToPixelArt({
|
|
sourceImageSrc: 'data:image/png;base64,source',
|
|
projectId: 'editor-project-1',
|
|
canvasCompletion: {
|
|
dialogId: 'generation-dialog-perfect-pixel',
|
|
title: '源图 · 完美像素',
|
|
placeholder: {
|
|
x: 0,
|
|
y: 0,
|
|
width: 320,
|
|
height: 240,
|
|
originalWidth: 320,
|
|
originalHeight: 240,
|
|
},
|
|
},
|
|
}),
|
|
).rejects.toThrow('待完美像素处理图片必须先上传 OSS');
|
|
expect(requestJsonMock).not.toHaveBeenCalled();
|
|
});
|
|
});
|