import { afterEach, describe, expect, it } from 'vitest'; import { ApiClientError } from '../../services/apiClient'; import type { CanvasGenerationDialogState, CanvasLayer, } from './ImageCanvasEditorTypes'; import { BACKGROUND_MUSIC_MODEL_SUNO, applyEditorGenerationPricingConfig, buildCharacterGenerationInputs, buildEditGenerationInputs, buildIconGenerationInputs, buildImageGenerationInputs, buildQuickEditGenerationInputs, buildQuickEditModelOptions, buildSoundEffectGenerationInputs, buildSpecGenerationInputs, buildSpecPrompt, buildUiDesignAssetExtractionGenerationInputs, 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_IMAGE_MODEL_MUD_POINT_CONFIG, EDITOR_MODEL_MUD_POINT_CONFIG, EDITOR_IMAGE_MODEL_OPTIONS, 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, resolveCharacterAnimationSourceImageSrc, 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('所有生成按钮价格都从模型定价配置推导', () => { 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({ 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', resourceId: 'resource-spec', }, [ { id: 'ref-1', label: '盔甲参考', src: '/armor.png', resourceId: 'resource-armor', }, ], ), ).toEqual({ fields: [{ title: '角色设定', value: '主角骑士' }], references: [ { title: '角色规范', label: '角色规范', refType: 'project-resource', refId: 'resource-spec', }, { title: '常规参考图 1', label: '盔甲参考', refType: 'project-resource', refId: 'resource-armor', }, ], }); expect( buildIconGenerationInputs(['返回按钮', '设置按钮'], { id: 'icon-spec', label: '图标规范', src: '/icon-spec.png', resourceId: 'resource-icon-spec', }), ).toEqual({ fields: [{ title: '素材描述', value: '返回按钮\n设置按钮' }], references: [ { title: '图标规范', label: '图标规范', refType: 'project-resource', refId: 'resource-icon-spec', }, ], }); expect( buildEditGenerationInputs('修改要求', '换成夜晚', sourceLayer), ).toEqual({ fields: [{ title: '修改要求', value: '换成夜晚' }], references: [ { 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({ fields: [ { title: '提取提示词', value: '仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。', }, ], references: [ { title: 'UI设计图', label: '原图', refType: 'project-resource', refId: 'resource-source', }, { 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({ fields: [{ title: '快速编辑提示词', value: '让图2下雨' }], references: [ { title: '原图', label: '原图', refType: 'project-resource', refId: 'resource-source', }, { title: '参考图 1', label: '天空', refType: 'project-resource', refId: 'resource-sky', }, ], }); }); 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' }, ]); }); 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({ fields: [{ title: '自定义规范提示词', value: '生成一张怪兽规范图' }], references: [ { 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({ 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('请先登录后再生成图片'); 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', }; }