96b06cc39b
新增按action统一解码生成输入参数 将失效与不兼容参数回落到当前默认值 恢复和再次提交共用规范V2结果并展示告警 记录原地收紧决策与Review处理结果 补充历史模型和非法参数回归测试
946 lines
28 KiB
TypeScript
946 lines
28 KiB
TypeScript
import { afterEach, describe, expect, it } from 'vitest';
|
||
|
||
import { ApiClientError } from '../../services/apiClient';
|
||
import type {
|
||
CanvasGenerationDialogState,
|
||
CanvasLayer,
|
||
} from './ImageCanvasEditorTypes';
|
||
import {
|
||
applyEditorGenerationPricingConfig,
|
||
BACKGROUND_MUSIC_MODEL_SUNO,
|
||
buildCharacterGenerationInputs,
|
||
buildEditGenerationInputs,
|
||
buildIconGenerationInputs,
|
||
buildImageGenerationInputs,
|
||
buildQuickEditGenerationInputs,
|
||
buildQuickEditModelOptions,
|
||
buildSoundEffectGenerationInputs,
|
||
buildSpecGenerationInputs,
|
||
buildSpecPrompt,
|
||
buildUiDesignAssetExtractionGenerationInputs,
|
||
buildVideoGenerationInputs,
|
||
calculateCharacterAnimationPrice,
|
||
calculateEditorBackgroundMusicPrice,
|
||
calculateEditorIconSpritesheetPrice,
|
||
calculateEditorImageGenerationPrice,
|
||
calculateEditorImageModelPrice,
|
||
calculateEditorSoundEffectPrice,
|
||
calculateEditorSpecGenerationPrice,
|
||
calculateEditorUiDesignPrice,
|
||
calculateEditorVideoPrice,
|
||
canOpenRedrawPanel,
|
||
CANVAS_GENERATION_PARAMETER_FALLBACK_WARNING,
|
||
CHARACTER_ANIMATION_MODEL,
|
||
decodeCanvasGenerationInputs,
|
||
DEFAULT_IMAGE_MODEL,
|
||
DEFAULT_SOUND_EFFECT_MODEL,
|
||
DEFAULT_VIDEO_MODEL,
|
||
EDITOR_BACKGROUND_MUSIC_MODEL_MUD_POINT_CONFIG,
|
||
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG,
|
||
EDITOR_IMAGE_MODEL_OPTIONS,
|
||
EDITOR_MODEL_MUD_POINT_CONFIG,
|
||
EDITOR_SOUND_EFFECT_MODEL_MUD_POINT_CONFIG,
|
||
EDITOR_SOUND_EFFECT_MODEL_OPTIONS,
|
||
EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG,
|
||
EDITOR_VIDEO_MODEL_OPTIONS,
|
||
getGenerationFrameAriaLabel,
|
||
getGenerationFrameLabel,
|
||
IMAGE_MODEL_GPT_IMAGE_2,
|
||
IMAGE_MODEL_NANOBANANA2,
|
||
isNormalizedCanvasGenerationInputs,
|
||
isQuickEditUnsupportedAssetKind,
|
||
resolveCharacterAnimationSourceImageSrc,
|
||
resolveEditorImageGenerationPixelSize,
|
||
resolveImageGenerationErrorMessage,
|
||
VIDEO_MODEL_KLING_3,
|
||
VIDEO_MODEL_KLING_3_OMNI,
|
||
VIDEO_MODEL_SEEDANCE_2,
|
||
VIDEO_MODEL_VEO_3_1,
|
||
VIDEO_MODEL_VEO_3_1_FAST,
|
||
} from './ImageCanvasGenerationModel';
|
||
|
||
describe('ImageCanvasGenerationModel', () => {
|
||
afterEach(() => {
|
||
applyEditorGenerationPricingConfig({
|
||
models: EDITOR_MODEL_MUD_POINT_CONFIG,
|
||
});
|
||
});
|
||
|
||
it('为所有编辑器生成模型提供显式定价配置', () => {
|
||
expect(
|
||
EDITOR_IMAGE_MODEL_OPTIONS.map((option) => option.value).filter(
|
||
(model) => !(model in EDITOR_MODEL_MUD_POINT_CONFIG),
|
||
),
|
||
).toEqual([]);
|
||
expect(
|
||
EDITOR_VIDEO_MODEL_OPTIONS.map((option) => option.value).filter(
|
||
(model) => !(model in EDITOR_MODEL_MUD_POINT_CONFIG),
|
||
),
|
||
).toEqual([]);
|
||
expect(
|
||
EDITOR_SOUND_EFFECT_MODEL_OPTIONS.map((option) => option.value).filter(
|
||
(model) => !(model in EDITOR_MODEL_MUD_POINT_CONFIG),
|
||
),
|
||
).toEqual([]);
|
||
expect(
|
||
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2]['1K'],
|
||
).toBe(3);
|
||
expect(
|
||
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2]['2K'],
|
||
).toBe(5);
|
||
expect(
|
||
EDITOR_MODEL_MUD_POINT_CONFIG[CHARACTER_ANIMATION_MODEL],
|
||
).toBeTruthy();
|
||
expect(
|
||
EDITOR_SOUND_EFFECT_MODEL_MUD_POINT_CONFIG[DEFAULT_SOUND_EFFECT_MODEL],
|
||
).toBe(5);
|
||
expect(
|
||
EDITOR_BACKGROUND_MUSIC_MODEL_MUD_POINT_CONFIG[
|
||
BACKGROUND_MUSIC_MODEL_SUNO
|
||
],
|
||
).toBe(12);
|
||
// 中文注释:Veo 已从前端入口移除,但客户端类型和后端兼容层仍保留,定价表也必须覆盖,避免旧布局回放或测试请求价格漂移。
|
||
expect(
|
||
EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[VIDEO_MODEL_VEO_3_1],
|
||
).toBeTruthy();
|
||
expect(
|
||
EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[VIDEO_MODEL_VEO_3_1_FAST],
|
||
).toBeTruthy();
|
||
});
|
||
|
||
it.each([
|
||
[IMAGE_MODEL_NANOBANANA2, '1K', '1:1', 1024, 1024],
|
||
[IMAGE_MODEL_NANOBANANA2, '2K', '2:3', 1365, 2048],
|
||
[IMAGE_MODEL_GPT_IMAGE_2, '1K', '1:1', 1024, 1024],
|
||
[IMAGE_MODEL_GPT_IMAGE_2, '1K', '4:3', 1024, 768],
|
||
[IMAGE_MODEL_GPT_IMAGE_2, '1K', '3:2', 1024, 683],
|
||
[IMAGE_MODEL_GPT_IMAGE_2, '1K', '2:3', 683, 1024],
|
||
[IMAGE_MODEL_GPT_IMAGE_2, '1K', '9:16', 576, 1024],
|
||
[IMAGE_MODEL_GPT_IMAGE_2, '1K', '16:9', 1024, 576],
|
||
[IMAGE_MODEL_GPT_IMAGE_2, '2K', '1:1', 2048, 2048],
|
||
[IMAGE_MODEL_GPT_IMAGE_2, '2K', '4:3', 2048, 1536],
|
||
[IMAGE_MODEL_GPT_IMAGE_2, '2K', '3:2', 2048, 1365],
|
||
[IMAGE_MODEL_GPT_IMAGE_2, '2K', '2:3', 1365, 2048],
|
||
[IMAGE_MODEL_GPT_IMAGE_2, '2K', '9:16', 1152, 2048],
|
||
[IMAGE_MODEL_GPT_IMAGE_2, '2K', '16:9', 2048, 1152],
|
||
] as const)(
|
||
'%s 的 %s %s 占位尺寸与统一业务交付尺寸一致',
|
||
(model, imageSize, aspectRatio, width, height) => {
|
||
expect(
|
||
resolveEditorImageGenerationPixelSize({
|
||
model,
|
||
aspectRatio,
|
||
imageSize,
|
||
}),
|
||
).toEqual({ width, height });
|
||
},
|
||
);
|
||
|
||
it('所有生成按钮价格都从模型定价配置推导', () => {
|
||
expect(calculateEditorImageModelPrice(DEFAULT_IMAGE_MODEL)).toBe(
|
||
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[DEFAULT_IMAGE_MODEL]['1K'],
|
||
);
|
||
expect(calculateEditorImageModelPrice(IMAGE_MODEL_GPT_IMAGE_2, '1K')).toBe(
|
||
3,
|
||
);
|
||
expect(calculateEditorImageModelPrice(IMAGE_MODEL_GPT_IMAGE_2, '2K')).toBe(
|
||
5,
|
||
);
|
||
expect(
|
||
calculateEditorImageGenerationPrice({
|
||
kind: 'character',
|
||
model: IMAGE_MODEL_GPT_IMAGE_2,
|
||
imageSize: '1K',
|
||
}),
|
||
).toBe(EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2]['1K']);
|
||
expect(calculateEditorIconSpritesheetPrice(DEFAULT_IMAGE_MODEL)).toBe(
|
||
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[DEFAULT_IMAGE_MODEL]['1K'],
|
||
);
|
||
expect(calculateEditorUiDesignPrice(IMAGE_MODEL_GPT_IMAGE_2, '2K')).toBe(
|
||
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2]['2K'],
|
||
);
|
||
expect(calculateEditorSpecGenerationPrice(IMAGE_MODEL_GPT_IMAGE_2)).toBe(
|
||
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2]['2K'],
|
||
);
|
||
expect(calculateEditorVideoPrice(DEFAULT_VIDEO_MODEL, '720p', 5)).toBe(
|
||
EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[DEFAULT_VIDEO_MODEL]['720p'] * 5,
|
||
);
|
||
expect(calculateEditorVideoPrice(VIDEO_MODEL_SEEDANCE_2, '480p', 4)).toBe(
|
||
EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[VIDEO_MODEL_SEEDANCE_2]['480p'] * 4,
|
||
);
|
||
expect(calculateEditorVideoPrice(VIDEO_MODEL_KLING_3, '480p', 4)).toBe(
|
||
EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[VIDEO_MODEL_KLING_3]['480p'] * 4,
|
||
);
|
||
expect(calculateEditorVideoPrice(VIDEO_MODEL_KLING_3, '720p', 5)).toBe(150);
|
||
expect(calculateEditorVideoPrice(VIDEO_MODEL_KLING_3_OMNI, '720p', 5)).toBe(
|
||
200,
|
||
);
|
||
expect(
|
||
calculateCharacterAnimationPrice(CHARACTER_ANIMATION_MODEL, '720p', 6),
|
||
).toBe(
|
||
EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[CHARACTER_ANIMATION_MODEL]['720p'] *
|
||
6,
|
||
);
|
||
expect(calculateEditorSoundEffectPrice(DEFAULT_SOUND_EFFECT_MODEL)).toBe(
|
||
EDITOR_SOUND_EFFECT_MODEL_MUD_POINT_CONFIG[DEFAULT_SOUND_EFFECT_MODEL],
|
||
);
|
||
expect(
|
||
calculateEditorBackgroundMusicPrice(BACKGROUND_MUSIC_MODEL_SUNO),
|
||
).toBe(
|
||
EDITOR_BACKGROUND_MUSIC_MODEL_MUD_POINT_CONFIG[
|
||
BACKGROUND_MUSIC_MODEL_SUNO
|
||
],
|
||
);
|
||
});
|
||
|
||
it('支持用后端下发的模型定价覆盖按钮泥点', () => {
|
||
applyEditorGenerationPricingConfig({
|
||
models: {
|
||
...EDITOR_MODEL_MUD_POINT_CONFIG,
|
||
[IMAGE_MODEL_GPT_IMAGE_2]: {
|
||
unit: 'perGeneration',
|
||
prices: {
|
||
'1K': 29,
|
||
'2K': 58,
|
||
},
|
||
},
|
||
[VIDEO_MODEL_SEEDANCE_2]: {
|
||
unit: 'perSecond',
|
||
prices: {
|
||
...EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[VIDEO_MODEL_SEEDANCE_2],
|
||
'720p': 26,
|
||
},
|
||
},
|
||
[DEFAULT_SOUND_EFFECT_MODEL]: {
|
||
unit: 'perGeneration',
|
||
price: 15,
|
||
},
|
||
[BACKGROUND_MUSIC_MODEL_SUNO]: {
|
||
unit: 'perGeneration',
|
||
price: 9,
|
||
},
|
||
},
|
||
});
|
||
|
||
expect(calculateEditorImageModelPrice(IMAGE_MODEL_GPT_IMAGE_2, '1K')).toBe(
|
||
29,
|
||
);
|
||
expect(calculateEditorSpecGenerationPrice(IMAGE_MODEL_GPT_IMAGE_2)).toBe(
|
||
58,
|
||
);
|
||
expect(calculateEditorVideoPrice(VIDEO_MODEL_SEEDANCE_2, '720p', 5)).toBe(
|
||
130,
|
||
);
|
||
expect(calculateEditorSoundEffectPrice(DEFAULT_SOUND_EFFECT_MODEL)).toBe(
|
||
15,
|
||
);
|
||
expect(
|
||
calculateEditorBackgroundMusicPrice(BACKGROUND_MUSIC_MODEL_SUNO),
|
||
).toBe(9);
|
||
});
|
||
|
||
it('builds user-facing generation input snapshots instead of backend prompts', () => {
|
||
expect(buildImageGenerationInputs(' 一张明亮主视觉 ')).toEqual({
|
||
version: 2,
|
||
action: 'image.generate',
|
||
fields: [
|
||
{ id: 'prompt', title: '生成提示词', value: '一张明亮主视觉' },
|
||
{
|
||
id: 'model',
|
||
title: '模型',
|
||
value: 'gemini-3.1-flash-image-preview',
|
||
},
|
||
{ id: 'style', title: '风格', value: 'none' },
|
||
{ id: 'aspectRatio', title: '比例', value: '1:1' },
|
||
{ id: 'imageSize', title: '尺寸', value: '1K' },
|
||
],
|
||
references: [],
|
||
});
|
||
|
||
expect(
|
||
buildSpecGenerationInputs('character', {
|
||
playSetting: '平台跳跃',
|
||
artStyle: '像素风',
|
||
bodyRatio: '3',
|
||
characterView: '右向三分之二侧身',
|
||
customPrompt: '',
|
||
}),
|
||
).toEqual(expect.objectContaining({
|
||
version: 2,
|
||
action: 'spec.generate',
|
||
fields: [
|
||
{ id: 'specType', title: '规范类型', value: 'character' },
|
||
{ id: 'playSetting', title: '玩法设定', value: '平台跳跃' },
|
||
{ id: 'artStyle', title: '美术风格', value: '像素风' },
|
||
{ id: 'bodyRatio', title: '头身比', value: '3' },
|
||
{ id: 'characterView', title: '角色视角', value: '右向三分之二侧身' },
|
||
],
|
||
references: [],
|
||
}));
|
||
});
|
||
|
||
it('builds character, icon and edit reference snapshots', () => {
|
||
const sourceLayer = buildSourceLayer();
|
||
|
||
expect(
|
||
buildCharacterGenerationInputs(
|
||
'主角骑士',
|
||
{
|
||
id: 'spec',
|
||
label: '角色规范',
|
||
src: '/spec.png',
|
||
resourceId: 'resource-spec',
|
||
},
|
||
[
|
||
{
|
||
id: 'ref-1',
|
||
label: '盔甲参考',
|
||
src: '/armor.png',
|
||
resourceId: 'resource-armor',
|
||
},
|
||
],
|
||
),
|
||
).toEqual(expect.objectContaining({
|
||
version: 2,
|
||
action: 'character.generate',
|
||
fields: expect.arrayContaining([
|
||
{ id: 'prompt', title: '角色设定', value: '主角骑士' },
|
||
]),
|
||
references: [
|
||
{
|
||
id: 'specReference',
|
||
title: '角色规范',
|
||
label: '角色规范',
|
||
refType: 'project-resource',
|
||
refId: 'resource-spec',
|
||
},
|
||
{
|
||
id: 'reference',
|
||
title: '常规参考图 1',
|
||
label: '盔甲参考',
|
||
refType: 'project-resource',
|
||
refId: 'resource-armor',
|
||
},
|
||
],
|
||
}));
|
||
|
||
expect(
|
||
buildIconGenerationInputs(['返回按钮', '设置按钮'], {
|
||
id: 'icon-spec',
|
||
label: '图标规范',
|
||
src: '/icon-spec.png',
|
||
resourceId: 'resource-icon-spec',
|
||
}),
|
||
).toEqual(expect.objectContaining({
|
||
version: 2,
|
||
action: 'icon.generate',
|
||
fields: expect.arrayContaining([
|
||
{ id: 'prompt', title: '素材描述', value: '返回按钮\n设置按钮' },
|
||
]),
|
||
references: [
|
||
{
|
||
id: 'specReference',
|
||
title: '图标规范',
|
||
label: '图标规范',
|
||
refType: 'project-resource',
|
||
refId: 'resource-icon-spec',
|
||
},
|
||
],
|
||
}));
|
||
|
||
expect(
|
||
buildEditGenerationInputs('修改要求', '换成夜晚', sourceLayer),
|
||
).toEqual(expect.objectContaining({
|
||
version: 2,
|
||
action: 'image.edit',
|
||
fields: expect.arrayContaining([
|
||
{ id: 'prompt', title: '修改要求', value: '换成夜晚' },
|
||
]),
|
||
references: [
|
||
{
|
||
id: 'source',
|
||
title: '参考图',
|
||
label: '原图',
|
||
refType: 'project-resource',
|
||
refId: 'resource-source',
|
||
},
|
||
],
|
||
}));
|
||
expect(
|
||
buildUiDesignAssetExtractionGenerationInputs(sourceLayer, [
|
||
{
|
||
id: 'ref-ui',
|
||
label: 'UI风格参考',
|
||
src: '/ui-style.png',
|
||
resourceId: 'resource-ui-style',
|
||
},
|
||
]),
|
||
).toEqual(expect.objectContaining({
|
||
version: 2,
|
||
action: 'ui-design.extract-assets',
|
||
fields: expect.arrayContaining([
|
||
expect.objectContaining({
|
||
id: 'prompt',
|
||
title: '提取提示词',
|
||
}),
|
||
]),
|
||
references: [
|
||
{
|
||
id: 'source',
|
||
title: 'UI设计图',
|
||
label: '原图',
|
||
refType: 'project-resource',
|
||
refId: 'resource-source',
|
||
},
|
||
{
|
||
id: 'reference',
|
||
title: '参考图 1',
|
||
label: 'UI风格参考',
|
||
refType: 'project-resource',
|
||
refId: 'resource-ui-style',
|
||
},
|
||
],
|
||
}));
|
||
expect(
|
||
buildQuickEditGenerationInputs(
|
||
'快速编辑提示词',
|
||
'让图2下雨',
|
||
sourceLayer,
|
||
[
|
||
{
|
||
id: 'ref-1',
|
||
label: '天空',
|
||
src: '/sky.png',
|
||
resourceId: 'resource-sky',
|
||
},
|
||
],
|
||
),
|
||
).toEqual(expect.objectContaining({
|
||
version: 2,
|
||
action: 'image.edit',
|
||
fields: expect.arrayContaining([
|
||
{ id: 'prompt', title: '快速编辑提示词', value: '让图2下雨' },
|
||
]),
|
||
references: [
|
||
{
|
||
id: 'source',
|
||
title: '原图',
|
||
label: '原图',
|
||
refType: 'project-resource',
|
||
refId: 'resource-source',
|
||
},
|
||
{
|
||
id: 'reference',
|
||
title: '参考图 1',
|
||
label: '天空',
|
||
refType: 'project-resource',
|
||
refId: 'resource-sky',
|
||
},
|
||
],
|
||
}));
|
||
});
|
||
|
||
it('only exposes redraw for executable generation metadata', () => {
|
||
const generatedLayer = {
|
||
...buildSourceLayer(),
|
||
sourceType: 'generated' as const,
|
||
};
|
||
|
||
expect(
|
||
canOpenRedrawPanel({
|
||
...generatedLayer,
|
||
generationInputs: buildImageGenerationInputs('生成城堡'),
|
||
}),
|
||
).toBe(true);
|
||
expect(
|
||
canOpenRedrawPanel({
|
||
...generatedLayer,
|
||
generationInputs: {
|
||
fields: [{ title: '生成提示词', value: '旧版城堡' }],
|
||
references: [],
|
||
},
|
||
}),
|
||
).toBe(true);
|
||
expect(
|
||
canOpenRedrawPanel({
|
||
...generatedLayer,
|
||
generationInputs: null,
|
||
}),
|
||
).toBe(false);
|
||
expect(
|
||
canOpenRedrawPanel({
|
||
...generatedLayer,
|
||
generationInputs: {
|
||
version: 2,
|
||
fields: [{ title: '生成提示词', value: '公开展示文本' }],
|
||
references: [],
|
||
},
|
||
}),
|
||
).toBe(false);
|
||
expect(
|
||
canOpenRedrawPanel({
|
||
...generatedLayer,
|
||
generationInputs: {
|
||
version: 2,
|
||
action: 'unknown.generate' as never,
|
||
fields: [{ id: 'prompt', title: '提示词', value: '未知动作' }],
|
||
references: [],
|
||
},
|
||
}),
|
||
).toBe(false);
|
||
expect(
|
||
canOpenRedrawPanel({
|
||
...generatedLayer,
|
||
sourceType: 'uploaded',
|
||
generationInputs: buildImageGenerationInputs('上传文件'),
|
||
}),
|
||
).toBe(false);
|
||
});
|
||
|
||
it('rejects malformed persisted generation metadata without throwing', () => {
|
||
expect(
|
||
isNormalizedCanvasGenerationInputs({
|
||
version: 2,
|
||
action: 'image.generate',
|
||
fields: [{ id: 42, title: '生成提示词', value: '城堡' } as never],
|
||
references: [],
|
||
}),
|
||
).toBe(false);
|
||
expect(
|
||
isNormalizedCanvasGenerationInputs({
|
||
version: 2,
|
||
action: 'image.generate',
|
||
fields: [{ id: 'prompt', title: '生成提示词', value: '城堡' }],
|
||
references: [
|
||
{
|
||
id: 'source',
|
||
title: '原图',
|
||
refType: 'project-resource',
|
||
refId: 7,
|
||
} as never,
|
||
],
|
||
}),
|
||
).toBe(false);
|
||
expect(() =>
|
||
canOpenRedrawPanel({
|
||
...buildSourceLayer(),
|
||
sourceType: 'generated',
|
||
generationInputs: {
|
||
fields: [{ title: 42, value: '旧数据' } as never],
|
||
references: [],
|
||
},
|
||
}),
|
||
).not.toThrow();
|
||
});
|
||
|
||
it('stores the video web-search setting in reusable metadata', () => {
|
||
expect(
|
||
buildVideoGenerationInputs('镜头推进', [], {
|
||
webSearchEnabled: false,
|
||
}).fields,
|
||
).toContainEqual({
|
||
id: 'webSearchEnabled',
|
||
title: '联网搜索',
|
||
value: false,
|
||
});
|
||
});
|
||
|
||
it('keeps valid V2 recipes and supported image aliases canonical', () => {
|
||
const valid = decodeCanvasGenerationInputs(
|
||
buildImageGenerationInputs('森林城堡', [], {
|
||
model: IMAGE_MODEL_GPT_IMAGE_2,
|
||
aspectRatio: '16:9',
|
||
imageSize: '2K',
|
||
}),
|
||
);
|
||
expect(valid).toMatchObject({ ok: true, warnings: [] });
|
||
|
||
const alias = decodeCanvasGenerationInputs({
|
||
version: 2,
|
||
action: 'image.generate',
|
||
fields: [
|
||
{ id: 'prompt', title: '生成提示词', value: '像素村庄' },
|
||
{ id: 'model', title: '模型', value: 'nanobanana2' },
|
||
{ id: 'aspectRatio', title: '比例', value: '4:3' },
|
||
{ id: 'imageSize', title: '尺寸', value: '0.5K' },
|
||
],
|
||
references: [],
|
||
});
|
||
expect(alias).toMatchObject({ ok: true, warnings: [] });
|
||
expect(
|
||
alias.ok
|
||
? alias.inputs.fields.find((field) => field.id === 'model')?.value
|
||
: null,
|
||
).toBe(IMAGE_MODEL_NANOBANANA2);
|
||
});
|
||
|
||
it('falls invalid image and workflow parameters back within V2', () => {
|
||
const image = decodeCanvasGenerationInputs({
|
||
version: 2,
|
||
action: 'image.generate',
|
||
fields: [
|
||
{ id: 'prompt', title: '生成提示词', value: '森林城堡' },
|
||
{ id: 'model', title: '模型', value: 'removed-image-model' },
|
||
{ id: 'style', title: '风格', value: 'oil' },
|
||
{ id: 'aspectRatio', title: '比例', value: '7:5' },
|
||
{ id: 'imageSize', title: '尺寸', value: '8K' },
|
||
],
|
||
references: [],
|
||
});
|
||
expect(image.ok).toBe(true);
|
||
if (!image.ok) {
|
||
throw new Error('图片 V2 解码失败');
|
||
}
|
||
expect(
|
||
Object.fromEntries(
|
||
image.inputs.fields.map((field) => [field.id, field.value]),
|
||
),
|
||
).toMatchObject({
|
||
model: DEFAULT_IMAGE_MODEL,
|
||
style: 'none',
|
||
aspectRatio: '1:1',
|
||
imageSize: '1K',
|
||
});
|
||
expect(image.warnings).toEqual(
|
||
expect.arrayContaining([
|
||
expect.objectContaining({ fieldIds: ['model'] }),
|
||
expect.objectContaining({ fieldIds: ['style'] }),
|
||
expect.objectContaining({ fieldIds: ['aspectRatio'] }),
|
||
expect.objectContaining({ fieldIds: ['imageSize'] }),
|
||
]),
|
||
);
|
||
expect(image.warnings[0]?.message).toBe(
|
||
CANVAS_GENERATION_PARAMETER_FALLBACK_WARNING,
|
||
);
|
||
|
||
const workflow = decodeCanvasGenerationInputs({
|
||
version: 2,
|
||
action: 'publication.generate',
|
||
fields: [
|
||
{ id: 'workflowId', title: '宣发类型', value: 'retired-workflow' },
|
||
],
|
||
references: [],
|
||
});
|
||
expect(workflow).toMatchObject({
|
||
ok: true,
|
||
inputs: {
|
||
version: 2,
|
||
action: 'publication.generate',
|
||
fields: [
|
||
{
|
||
id: 'workflowId',
|
||
title: '宣发类型',
|
||
value: 'publication-cover-image',
|
||
},
|
||
],
|
||
},
|
||
warnings: [expect.objectContaining({ fieldIds: ['workflowId'] })],
|
||
});
|
||
});
|
||
|
||
it('falls retired and incompatible video parameters back to current defaults', () => {
|
||
const decoded = decodeCanvasGenerationInputs({
|
||
version: 2,
|
||
action: 'video.generate',
|
||
fields: [
|
||
{ id: 'prompt', title: '视频描述', value: '追逐镜头' },
|
||
{ id: 'model', title: '模型', value: 'veo3.1' },
|
||
{ id: 'aspectRatio', title: '比例', value: '7:5' },
|
||
{ id: 'resolution', title: '清晰度', value: '1080p' },
|
||
{ id: 'durationSeconds', title: '时长', value: 99 },
|
||
{ id: 'sound', title: '声音', value: 'surround' },
|
||
{ id: 'webSearchEnabled', title: '联网搜索', value: 'yes' },
|
||
],
|
||
references: [],
|
||
});
|
||
expect(decoded.ok).toBe(true);
|
||
if (!decoded.ok) {
|
||
throw new Error('视频 V2 解码失败');
|
||
}
|
||
expect(
|
||
Object.fromEntries(
|
||
decoded.inputs.fields.map((field) => [field.id, field.value]),
|
||
),
|
||
).toMatchObject({
|
||
model: DEFAULT_VIDEO_MODEL,
|
||
aspectRatio: '16:9',
|
||
resolution: '480p',
|
||
durationSeconds: 4,
|
||
sound: 'on',
|
||
webSearchEnabled: true,
|
||
});
|
||
expect(decoded.warnings.map((warning) => warning.fieldIds)).toEqual(
|
||
expect.arrayContaining([
|
||
['model'],
|
||
['aspectRatio'],
|
||
['resolution'],
|
||
['durationSeconds'],
|
||
['sound'],
|
||
['webSearchEnabled'],
|
||
]),
|
||
);
|
||
});
|
||
|
||
it('falls invalid sound and character animation parameters back to complete defaults', () => {
|
||
const sound = decodeCanvasGenerationInputs({
|
||
version: 2,
|
||
action: 'audio.sound-effect.generate',
|
||
fields: [
|
||
{ id: 'prompt', title: 'prompt', value: '点击音' },
|
||
{ id: 'model', title: 'model', value: 'retired-audio-model' },
|
||
{ id: 'durationSeconds', title: 'duration', value: 11 },
|
||
],
|
||
references: [],
|
||
});
|
||
expect(sound).toMatchObject({
|
||
ok: true,
|
||
inputs: {
|
||
fields: expect.arrayContaining([
|
||
expect.objectContaining({ id: 'model', value: 'audio1.0' }),
|
||
expect.objectContaining({ id: 'durationSeconds', value: 5 }),
|
||
]),
|
||
},
|
||
});
|
||
|
||
const animation = decodeCanvasGenerationInputs({
|
||
version: 2,
|
||
action: 'character-animation.generate',
|
||
fields: [
|
||
{ id: 'prompt', title: '动作描述', value: '挥手' },
|
||
{ id: 'resolution', title: '清晰度', value: '1080p' },
|
||
{ id: 'ratio', title: '比例', value: '7:5' },
|
||
{ id: 'frameCount', title: '帧数', value: 40 },
|
||
{ id: 'durationSeconds', title: '时长', value: 6 },
|
||
],
|
||
references: [],
|
||
});
|
||
expect(animation).toMatchObject({
|
||
ok: true,
|
||
inputs: {
|
||
fields: expect.arrayContaining([
|
||
expect.objectContaining({ id: 'resolution', value: '480p' }),
|
||
expect.objectContaining({ id: 'ratio', value: 'same' }),
|
||
expect.objectContaining({ id: 'frameCount', value: 32 }),
|
||
expect.objectContaining({ id: 'durationSeconds', value: 4 }),
|
||
]),
|
||
},
|
||
warnings: expect.arrayContaining([
|
||
expect.objectContaining({ fieldIds: ['resolution'] }),
|
||
expect.objectContaining({ fieldIds: ['ratio'] }),
|
||
expect.objectContaining({
|
||
fieldIds: ['frameCount', 'durationSeconds'],
|
||
}),
|
||
]),
|
||
});
|
||
});
|
||
|
||
it('keeps generated prompts and quick edit options stable', () => {
|
||
const prompt = buildSpecPrompt('ui', {
|
||
playSetting: '消除玩法',
|
||
artStyle: '清爽卡通',
|
||
bodyRatio: '3',
|
||
characterView: '',
|
||
customPrompt: '',
|
||
});
|
||
|
||
expect(prompt).toContain('生成一张完整游戏UI规范汇总设定展板');
|
||
expect(prompt).toContain('玩法设定:消除玩法');
|
||
expect(
|
||
buildSpecPrompt('custom', { ...blankSpecValues, customPrompt: '自定义' }),
|
||
).toBe('自定义');
|
||
expect(buildQuickEditModelOptions('nano-banana')).toEqual([
|
||
{
|
||
label: 'nanobanana2',
|
||
value: 'gemini-3.1-flash-image-preview',
|
||
},
|
||
{ label: 'gpt-image-2', value: 'gpt-image-2' },
|
||
]);
|
||
expect(buildQuickEditModelOptions('nanobanana2')).toEqual([
|
||
{
|
||
label: 'nanobanana2',
|
||
value: 'gemini-3.1-flash-image-preview',
|
||
},
|
||
{ label: 'gpt-image-2', value: 'gpt-image-2' },
|
||
]);
|
||
expect(
|
||
isQuickEditUnsupportedAssetKind({
|
||
...buildSourceLayer(),
|
||
assetKind: 'icon',
|
||
}),
|
||
).toBe(true);
|
||
expect(
|
||
isQuickEditUnsupportedAssetKind({
|
||
...buildSourceLayer(),
|
||
assetKind: 'icon-spritesheet',
|
||
}),
|
||
).toBe(false);
|
||
});
|
||
|
||
it('adds reference image semantics and snapshots for spec generation references', () => {
|
||
const prompt = buildSpecPrompt(
|
||
'ui',
|
||
{
|
||
playSetting: '消除玩法',
|
||
artStyle: '清爽卡通',
|
||
bodyRatio: '3',
|
||
characterView: '',
|
||
customPrompt: '',
|
||
},
|
||
true,
|
||
);
|
||
|
||
expect(prompt).toContain('参考图生成规范');
|
||
expect(prompt).toContain('参考图1');
|
||
expect(prompt).toContain('生成一张完整游戏UI规范汇总设定展板');
|
||
expect(
|
||
buildSpecPrompt(
|
||
'custom',
|
||
{ ...blankSpecValues, customPrompt: '生成一张怪兽规范图' },
|
||
true,
|
||
),
|
||
).toContain('生成一张怪兽规范图');
|
||
expect(
|
||
buildSpecGenerationInputs(
|
||
'custom',
|
||
{ ...blankSpecValues, customPrompt: '生成一张怪兽规范图' },
|
||
{
|
||
id: 'spec-ref',
|
||
label: '参考.png',
|
||
src: '/ref.png',
|
||
resourceId: 'resource-ref',
|
||
},
|
||
),
|
||
).toEqual({
|
||
version: 2,
|
||
action: 'spec.generate',
|
||
fields: [
|
||
{ id: 'specType', title: '规范类型', value: 'custom' },
|
||
{
|
||
id: 'customPrompt',
|
||
title: '自定义规范提示词',
|
||
value: '生成一张怪兽规范图',
|
||
},
|
||
],
|
||
references: [
|
||
{
|
||
id: 'reference',
|
||
title: '参考图',
|
||
label: '参考.png',
|
||
refType: 'project-resource',
|
||
refId: 'resource-ref',
|
||
},
|
||
],
|
||
});
|
||
});
|
||
|
||
it('uses objectKey for character animation references before falling back to src', () => {
|
||
expect(
|
||
resolveCharacterAnimationSourceImageSrc({
|
||
...buildSourceLayer(),
|
||
objectKey: 'generated/character.png',
|
||
}),
|
||
).toBe('generated/character.png');
|
||
expect(resolveCharacterAnimationSourceImageSrc(buildSourceLayer())).toBe(
|
||
'/source.png',
|
||
);
|
||
});
|
||
|
||
it('uses the extracted transparent frame when reusing a character animation sequence layer', () => {
|
||
expect(
|
||
resolveCharacterAnimationSourceImageSrc({
|
||
...buildSourceLayer(),
|
||
assetKind: 'character-animation',
|
||
mediaType: 'image-sequence',
|
||
src: '/generated-character-drafts/editor/frame01.png',
|
||
thumbnailSrc: '/generated-character-drafts/editor/frame01.png',
|
||
}),
|
||
).toBe('/generated-character-drafts/editor/frame01.png');
|
||
});
|
||
|
||
it('maps generation dialog mode and authorization errors to user-facing copy', () => {
|
||
const iconDialog: CanvasGenerationDialogState = {
|
||
id: 'dialog-icon',
|
||
mode: 'icon',
|
||
prompt: '',
|
||
status: 'idle',
|
||
};
|
||
|
||
expect(getGenerationFrameAriaLabel(iconDialog)).toBe('图标素材生成占位图');
|
||
expect(getGenerationFrameLabel(iconDialog)).toBe('Icon Generator');
|
||
expect(
|
||
getGenerationFrameAriaLabel({
|
||
...iconDialog,
|
||
mode: 'quick-edit',
|
||
}),
|
||
).toBe('快速编辑生成占位图');
|
||
expect(getGenerationFrameLabel({ ...iconDialog, mode: 'quick-edit' })).toBe(
|
||
'Quick Edit Generator',
|
||
);
|
||
expect(
|
||
buildSoundEffectGenerationInputs('金币掉落叮当声', 'audio1.0', 7),
|
||
).toEqual({
|
||
version: 2,
|
||
action: 'audio.sound-effect.generate',
|
||
fields: [
|
||
{ id: 'prompt', title: 'prompt', value: '金币掉落叮当声' },
|
||
{ id: 'model', title: 'model', value: 'audio1.0' },
|
||
{ id: 'durationSeconds', title: 'duration', value: 7 },
|
||
],
|
||
references: [],
|
||
});
|
||
expect(
|
||
resolveImageGenerationErrorMessage(
|
||
new ApiClientError({
|
||
message: '未授权访问(requestId: one)',
|
||
status: 401,
|
||
code: 'UNAUTHORIZED',
|
||
}),
|
||
),
|
||
).toBe('请先登录后再生成图片');
|
||
expect(
|
||
resolveImageGenerationErrorMessage(
|
||
new ApiClientError({
|
||
message: '请求冲突(requestId: wallet-one)',
|
||
status: 409,
|
||
code: 'CONFLICT',
|
||
details: { provider: 'profile-wallet' },
|
||
}),
|
||
),
|
||
).toBe('泥点余额不足,请充值后再生成');
|
||
expect(
|
||
resolveImageGenerationErrorMessage(
|
||
new ApiClientError({
|
||
message: '请求冲突(requestId: conflict-one)',
|
||
status: 409,
|
||
code: 'CONFLICT',
|
||
}),
|
||
),
|
||
).toBe('生成任务未完成,请稍后重试');
|
||
});
|
||
});
|
||
|
||
const blankSpecValues = {
|
||
playSetting: '',
|
||
artStyle: '',
|
||
bodyRatio: '3',
|
||
characterView: '',
|
||
customPrompt: '',
|
||
};
|
||
|
||
function buildSourceLayer(): CanvasLayer {
|
||
return {
|
||
id: 'layer-source',
|
||
resourceId: 'resource-source',
|
||
title: '原图',
|
||
src: '/source.png',
|
||
x: 0,
|
||
y: 0,
|
||
width: 512,
|
||
height: 512,
|
||
originalWidth: 512,
|
||
originalHeight: 512,
|
||
zIndex: 1,
|
||
sourceType: 'uploaded',
|
||
};
|
||
}
|