366 lines
12 KiB
TypeScript
366 lines
12 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
|
||
import { ApiClientError } from '../../services/apiClient';
|
||
import type {
|
||
CanvasGenerationDialogState,
|
||
CanvasLayer,
|
||
} from './ImageCanvasEditorTypes';
|
||
import {
|
||
BACKGROUND_MUSIC_MODEL_SUNO,
|
||
buildCharacterGenerationInputs,
|
||
buildEditGenerationInputs,
|
||
buildIconGenerationInputs,
|
||
buildImageGenerationInputs,
|
||
buildQuickEditGenerationInputs,
|
||
buildQuickEditModelOptions,
|
||
buildSoundEffectGenerationInputs,
|
||
buildSpecGenerationInputs,
|
||
buildSpecPrompt,
|
||
calculateCharacterAnimationPrice,
|
||
calculateEditorBackgroundMusicPrice,
|
||
calculateEditorIconSpritesheetPrice,
|
||
calculateEditorImageGenerationPrice,
|
||
calculateEditorImageModelPrice,
|
||
calculateEditorSoundEffectPrice,
|
||
calculateEditorSpecGenerationPrice,
|
||
calculateEditorUiDesignPrice,
|
||
calculateEditorVideoPrice,
|
||
CHARACTER_ANIMATION_MODEL,
|
||
DEFAULT_IMAGE_MODEL,
|
||
DEFAULT_SOUND_EFFECT_MODEL,
|
||
DEFAULT_VIDEO_MODEL,
|
||
EDITOR_BACKGROUND_MUSIC_MODEL_MUD_POINT_CONFIG,
|
||
EDITOR_CHARACTER_ANIMATION_MODEL_MUD_POINT_CONFIG,
|
||
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG,
|
||
EDITOR_IMAGE_MODEL_OPTIONS,
|
||
EDITOR_SOUND_EFFECT_MODEL_MUD_POINT_CONFIG,
|
||
EDITOR_SOUND_EFFECT_MODEL_OPTIONS,
|
||
EDITOR_SPEC_MODEL_MUD_POINT_CONFIG,
|
||
EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG,
|
||
EDITOR_VIDEO_MODEL_OPTIONS,
|
||
getGenerationFrameAriaLabel,
|
||
getGenerationFrameLabel,
|
||
IMAGE_MODEL_GPT_IMAGE_2,
|
||
normalizeQuickEditPromptSourceReference,
|
||
resolveCharacterAnimationSourceImageSrc,
|
||
resolveImageGenerationErrorMessage,
|
||
VIDEO_MODEL_KLING_3,
|
||
VIDEO_MODEL_KLING_3_OMNI,
|
||
VIDEO_MODEL_SEEDANCE_2,
|
||
VIDEO_MODEL_SEEDANCE_2_FAST,
|
||
VIDEO_MODEL_VEO_3_1,
|
||
VIDEO_MODEL_VEO_3_1_FAST,
|
||
} from './ImageCanvasGenerationModel';
|
||
|
||
describe('ImageCanvasGenerationModel', () => {
|
||
it('为所有编辑器生成模型提供显式定价配置', () => {
|
||
expect(
|
||
EDITOR_IMAGE_MODEL_OPTIONS.map((option) => option.value).filter(
|
||
(model) => !(model in EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG),
|
||
),
|
||
).toEqual([]);
|
||
expect(
|
||
EDITOR_VIDEO_MODEL_OPTIONS.map((option) => option.value).filter(
|
||
(model) => !(model in EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG),
|
||
),
|
||
).toEqual([]);
|
||
expect(
|
||
EDITOR_SOUND_EFFECT_MODEL_OPTIONS.map((option) => option.value).filter(
|
||
(model) => !(model in EDITOR_SOUND_EFFECT_MODEL_MUD_POINT_CONFIG),
|
||
),
|
||
).toEqual([]);
|
||
expect(EDITOR_SPEC_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2]).toBe(5);
|
||
expect(
|
||
EDITOR_CHARACTER_ANIMATION_MODEL_MUD_POINT_CONFIG[CHARACTER_ANIMATION_MODEL],
|
||
).toBeTruthy();
|
||
expect(
|
||
EDITOR_BACKGROUND_MUSIC_MODEL_MUD_POINT_CONFIG[BACKGROUND_MUSIC_MODEL_SUNO],
|
||
).toBe(5);
|
||
// 中文注释: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('所有生成按钮价格都从模型定价配置推导', () => {
|
||
expect(calculateEditorImageModelPrice(DEFAULT_IMAGE_MODEL)).toBe(
|
||
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[DEFAULT_IMAGE_MODEL],
|
||
);
|
||
expect(calculateEditorImageModelPrice(IMAGE_MODEL_GPT_IMAGE_2)).toBe(20);
|
||
expect(
|
||
calculateEditorImageGenerationPrice({
|
||
kind: 'character',
|
||
model: IMAGE_MODEL_GPT_IMAGE_2,
|
||
}),
|
||
).toBe(EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2]);
|
||
expect(calculateEditorIconSpritesheetPrice(DEFAULT_IMAGE_MODEL)).toBe(
|
||
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[DEFAULT_IMAGE_MODEL],
|
||
);
|
||
expect(calculateEditorUiDesignPrice(IMAGE_MODEL_GPT_IMAGE_2)).toBe(
|
||
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2],
|
||
);
|
||
expect(calculateEditorSpecGenerationPrice(IMAGE_MODEL_GPT_IMAGE_2)).toBe(
|
||
EDITOR_SPEC_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2],
|
||
);
|
||
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_CHARACTER_ANIMATION_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('builds user-facing generation input snapshots instead of backend prompts', () => {
|
||
expect(buildImageGenerationInputs(' 一张明亮主视觉 ')).toEqual({
|
||
fields: [{ title: '生成提示词', value: '一张明亮主视觉' }],
|
||
references: [],
|
||
});
|
||
|
||
expect(
|
||
buildSpecGenerationInputs('character', {
|
||
playSetting: '平台跳跃',
|
||
artStyle: '像素风',
|
||
bodyRatio: '3',
|
||
characterView: '右向三分之二侧身',
|
||
customPrompt: '',
|
||
}),
|
||
).toEqual({
|
||
fields: [
|
||
{ title: '玩法设定', value: '平台跳跃' },
|
||
{ title: '美术风格', value: '像素风' },
|
||
{ title: '头身比', value: '3' },
|
||
{ title: '角色视角', value: '右向三分之二侧身' },
|
||
],
|
||
references: [],
|
||
});
|
||
});
|
||
|
||
it('builds character, icon and edit reference snapshots', () => {
|
||
const sourceLayer = buildSourceLayer();
|
||
|
||
expect(
|
||
buildCharacterGenerationInputs(
|
||
'主角骑士',
|
||
{ id: 'spec', label: '角色规范', src: '/spec.png' },
|
||
[{ id: 'ref-1', label: '盔甲参考', src: '/armor.png' }],
|
||
),
|
||
).toEqual({
|
||
fields: [{ title: '角色设定', value: '主角骑士' }],
|
||
references: [
|
||
{ title: '角色规范', label: '角色规范', src: '/spec.png' },
|
||
{ title: '常规参考图 1', label: '盔甲参考', src: '/armor.png' },
|
||
],
|
||
});
|
||
|
||
expect(
|
||
buildIconGenerationInputs(['返回按钮', '设置按钮'], {
|
||
id: 'icon-spec',
|
||
label: '图标规范',
|
||
src: '/icon-spec.png',
|
||
}),
|
||
).toEqual({
|
||
fields: [
|
||
{ title: '素材描述', value: '返回按钮\n设置按钮' },
|
||
],
|
||
references: [
|
||
{ title: '图标规范', label: '图标规范', src: '/icon-spec.png' },
|
||
],
|
||
});
|
||
|
||
expect(
|
||
buildEditGenerationInputs('修改要求', '换成夜晚', sourceLayer),
|
||
).toEqual({
|
||
fields: [{ title: '修改要求', value: '换成夜晚' }],
|
||
references: [
|
||
{ title: '参考图', label: '原图', src: '/source.png' },
|
||
],
|
||
});
|
||
expect(
|
||
buildQuickEditGenerationInputs('快速编辑提示词', '让图2下雨', sourceLayer, [
|
||
{ id: 'ref-1', label: '天空', src: '/sky.png' },
|
||
]),
|
||
).toEqual({
|
||
fields: [{ title: '快速编辑提示词', value: '让图2下雨' }],
|
||
references: [
|
||
{ title: '参考图 1', label: '天空', src: '/sky.png' },
|
||
{ title: '参考图 2', label: '原图', src: '/source.png' },
|
||
],
|
||
});
|
||
});
|
||
|
||
it('rewrites quick edit source image references to the final image index', () => {
|
||
expect(normalizeQuickEditPromptSourceReference('把图1改成夜晚', 3)).toBe(
|
||
'把图3改成夜晚',
|
||
);
|
||
expect(normalizeQuickEditPromptSourceReference('参考原图增加暖光', 2)).toBe(
|
||
'参考图2增加暖光',
|
||
);
|
||
expect(normalizeQuickEditPromptSourceReference('保留图1构图', 1)).toBe(
|
||
'保留图1构图',
|
||
);
|
||
expect(
|
||
normalizeQuickEditPromptSourceReference('保持当前图片构图', 1),
|
||
).toBe('保持图1构图');
|
||
expect(
|
||
normalizeQuickEditPromptSourceReference('不要影响图10内容', 2),
|
||
).toBe('不要影响图10内容');
|
||
});
|
||
|
||
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: DEFAULT_IMAGE_MODEL },
|
||
{ label: 'gpt-image-2', value: 'gpt-image-2' },
|
||
]);
|
||
expect(buildQuickEditModelOptions('nanobanana2')).toEqual([
|
||
{ label: 'nanobanana2', value: DEFAULT_IMAGE_MODEL },
|
||
{ label: 'gpt-image-2', value: 'gpt-image-2' },
|
||
]);
|
||
});
|
||
|
||
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' },
|
||
),
|
||
).toEqual({
|
||
fields: [{ title: '自定义规范提示词', value: '生成一张怪兽规范图' }],
|
||
references: [{ title: '参考图', label: '参考.png', src: '/ref.png' }],
|
||
});
|
||
});
|
||
|
||
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('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({
|
||
fields: [
|
||
{ title: 'prompt', value: '金币掉落叮当声' },
|
||
{ title: 'model', value: 'audio1.0' },
|
||
{ title: 'duration', value: '7秒' },
|
||
],
|
||
references: [],
|
||
});
|
||
expect(
|
||
resolveImageGenerationErrorMessage(
|
||
new ApiClientError({
|
||
message: '未授权访问(requestId: one)',
|
||
status: 401,
|
||
code: 'UNAUTHORIZED',
|
||
}),
|
||
),
|
||
).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',
|
||
};
|
||
}
|