from dataclasses import FrozenInstanceError """domain 层剩余小模块单测 - bgm_utils/exceptions/editing_mode/recipe/template/template_version/template_clip_config/preset_bgm/preset_voices/title_library/voice_library""" import pytest from packages.domain.bgm_utils import merge_bgm_config from packages.domain.editing_mode import EditingMode from packages.domain.exceptions import ( DomainError, NotFoundError, QuotaExceededError, ValidationError, ) from packages.domain.preset_bgm import ( PRESET_BGM_LIBRARY, PresetBGM, get_preset_bgm, list_preset_bgm_by_style, search_preset_bgm, ) from packages.domain.preset_voices import ( PRESET_VOICES, PresetVoice, get_preset_voice_by_id, get_preset_voices, is_preset_voice, ) from packages.domain.recipe import Recipe, RecipeItem from packages.domain.template import Template, TemplateCategory, TemplateSegment from packages.domain.template_clip_config import ( ClipType, TemplateClipConfig, TransitionEffect, ) from packages.domain.template_version import EditTemplateVersion from packages.domain.title_library import TitleLibraryItem from packages.domain.voice_library import VoiceLibraryItem # ── EditingMode ─────────────────────────────────────────────────────────────── class TestEditingMode: """EditingMode 枚举测试""" def test_all_modes_exist(self): assert EditingMode.ONE_TAKE == "one_take" assert EditingMode.PIP == "pip" assert EditingMode.VOICE_OVER == "voice_over" assert EditingMode.VOICE_PIP == "voice_pip" def test_str_enum(self): assert isinstance(EditingMode.ONE_TAKE, str) def test_four_modes(self): assert len(EditingMode) == 4 # ── Exceptions ──────────────────────────────────────────────────────────────── class TestDomainExceptions: """异常类测试""" def test_domain_error_base(self): err = DomainError("test") assert isinstance(err, Exception) assert str(err) == "test" def test_not_found_error(self): err = NotFoundError("not found") assert isinstance(err, DomainError) assert str(err) == "not found" def test_validation_error(self): err = ValidationError("invalid") assert isinstance(err, DomainError) assert str(err) == "invalid" def test_quota_exceeded_error(self): err = QuotaExceededError(dimension="storage", limit=100, used=150) assert isinstance(err, DomainError) assert err.dimension == "storage" assert err.limit == 100 assert err.used == 150 assert "storage" in str(err) assert "150/100" in str(err) def test_not_found_is_domain_error(self): assert issubclass(NotFoundError, DomainError) def test_validation_is_domain_error(self): assert issubclass(ValidationError, DomainError) def test_quota_exceeded_is_domain_error(self): assert issubclass(QuotaExceededError, DomainError) # ── BGM Utils ──────────────────────────────────────────────────────────────── class TestMergeBgmConfig: """merge_bgm_config 函数测试""" def test_user_empty_returns_template_copy(self): template = {"enabled": True, "volume": 0.5, "source": "library"} result = merge_bgm_config(template, {}) assert result == template # 确保是拷贝不是引用 result["volume"] = 0.9 assert template["volume"] == 0.5 def test_template_empty_returns_user_copy(self): user = {"enabled": False, "volume": 0.3} result = merge_bgm_config({}, user) assert result == user def test_user_none_returns_template(self): template = {"enabled": True, "volume": 0.5} result = merge_bgm_config(template, None) # type: ignore assert result == template def test_template_none_returns_user(self): user = {"enabled": True, "volume": 0.5} result = merge_bgm_config(None, user) # type: ignore assert result == user def test_user_overrides_template(self): template = {"volume": 0.3, "source": "library", "asset_id": "tpl-1"} user = {"volume": 0.8, "asset_id": "user-1"} result = merge_bgm_config(template, user) assert result["volume"] == 0.8 assert result["asset_id"] == "user-1" assert result["source"] == "library" # 模板保留 def test_enabled_special_handling_user_not_set(self): """用户没传 enabled 时保留模板的 enabled""" template = {"enabled": True, "volume": 0.5} user = {"volume": 0.8} result = merge_bgm_config(template, user) assert result["enabled"] is True # 保留模板值 def test_enabled_user_explicit_false(self): """用户显式传了 enabled=False 则覆盖""" template = {"enabled": True, "volume": 0.5} user = {"enabled": False} result = merge_bgm_config(template, user) assert result["enabled"] is False def test_enabled_user_explicit_true(self): """用户显式传了 enabled=True 则覆盖""" template = {"enabled": False, "volume": 0.5} user = {"enabled": True} result = merge_bgm_config(template, user) assert result["enabled"] is True def test_full_merge(self): """完整合并场景""" template = { "enabled": True, "source": "library", "volume": 0.3, "fade_in": 0.0, "fade_out": 0.0, "loop_enabled": True, } user = { "volume": 0.7, "asset_id": "my-bgm", "fade_in": 1.0, } result = merge_bgm_config(template, user) assert result["enabled"] is True # 保留模板 assert result["volume"] == 0.7 # 用户覆盖 assert result["source"] == "library" # 模板保留 assert result["asset_id"] == "my-bgm" # 用户新增 assert result["fade_in"] == 1.0 # 用户覆盖 assert result["fade_out"] == 0.0 # 模板保留 assert result["loop_enabled"] is True # 模板保留 # ── Recipe ─────────────────────────────────────────────────────────────────── class TestRecipe: """Recipe / RecipeItem 测试""" def test_recipe_item_create(self): item = RecipeItem(id="item-1", recipe_id="r1", item_type="asset", item_id="a1") assert item.id == "item-1" assert item.recipe_id == "r1" assert item.item_type == "asset" assert item.item_id == "a1" assert item.position == 0 assert item.metadata_ == {} def test_recipe_create(self): recipe = Recipe(id="r1", user_id="u1", name="我的配方") assert recipe.id == "r1" assert recipe.user_id == "u1" assert recipe.name == "我的配方" assert recipe.description == "" assert recipe.items == [] assert recipe.is_active is True assert recipe.generation_params == {} assert recipe.created_at is not None def test_recipe_with_items(self): items = [ RecipeItem(id="i1", recipe_id="r1", item_type="asset", item_id="a1", position=0), RecipeItem(id="i2", recipe_id="r1", item_type="title", item_id="t1", position=1), ] recipe = Recipe(id="r1", user_id="u1", name="test", items=items) assert len(recipe.items) == 2 assert recipe.items[0].item_type == "asset" assert recipe.items[1].position == 1 def test_recipe_items_independent_list(self): """不同 recipe 的 items 是独立列表""" r1 = Recipe(id="r1", user_id="u1", name="r1") r2 = Recipe(id="r2", user_id="u1", name="r2") assert r1.items is not r2.items # ── Template ───────────────────────────────────────────────────────────────── class TestTemplate: """Template 相关实体测试""" def test_template_segment(self): seg = TemplateSegment( id="seg-1", template_id="t1", segment_order=0, duration_min=3.0, duration_max=5.0, ) assert seg.id == "seg-1" assert seg.template_id == "t1" assert seg.segment_order == 0 assert seg.duration_min == 3.0 assert seg.duration_max == 5.0 assert seg.material_type is None assert seg.created_at is not None def test_template_create(self): tpl = Template( id="t1", user_id="u1", name="口播模板", mode="voice_over", ) assert tpl.id == "t1" assert tpl.user_id == "u1" assert tpl.name == "口播模板" assert tpl.mode == "voice_over" assert tpl.category == "" assert tpl.tags == [] assert tpl.segments == [] assert tpl.is_active is True assert tpl.estimated_duration == 0.0 def test_template_with_segments(self): segs = [ TemplateSegment(id="s1", template_id="t1", segment_order=0, duration_min=2, duration_max=4), TemplateSegment(id="s2", template_id="t1", segment_order=1, duration_min=5, duration_max=8), ] tpl = Template(id="t1", user_id="u1", name="test", mode="one_take", segments=segs) assert len(tpl.segments) == 2 assert tpl.segments[0].segment_order == 0 def test_template_category(self): cat = TemplateCategory(id="cat-1", user_id="u1", name="热门") assert cat.id == "cat-1" assert cat.user_id == "u1" assert cat.name == "热门" assert cat.created_at is not None # ── TemplateVersion ────────────────────────────────────────────────────────── class TestEditTemplateVersion: """EditTemplateVersion 测试""" def test_create_basic(self): v = EditTemplateVersion.create(template_id="t1", version=1) assert v.id assert len(v.id) == 32 assert v.template_id == "t1" assert v.version == 1 assert v.name == "" assert v.editing_mode == "one_take" assert v.config == {} assert v.clip_configs == [] assert v.change_note == "" assert v.published_by == "" def test_create_with_details(self): config = {"title": {"size": 36}} clips = [{"clip_type": "intro"}, {"clip_type": "outro"}] v = EditTemplateVersion.create( template_id="t1", version=2, name="V2 优化版", editing_mode="voice_over", config=config, clip_configs=clips, change_note="优化了节奏", published_by="user-1", ) assert v.version == 2 assert v.name == "V2 优化版" assert v.editing_mode == "voice_over" assert v.config == config assert v.clip_configs == clips assert v.change_note == "优化了节奏" assert v.published_by == "user-1" def test_create_none_config_defaults_empty(self): v = EditTemplateVersion.create(template_id="t1", version=1, config=None) assert v.config == {} def test_create_none_clip_configs_defaults_empty(self): v = EditTemplateVersion.create(template_id="t1", version=1, clip_configs=None) assert v.clip_configs == [] # ── TemplateClipConfig ─────────────────────────────────────────────────────── class TestTemplateClipConfig: """TemplateClipConfig + 枚举测试""" def test_clip_type_values(self): assert ClipType.INTRO == "intro" assert ClipType.MAIN == "main" assert ClipType.TRANSITION == "transition" assert ClipType.OUTRO == "outro" assert ClipType.TITLE == "title" assert ClipType.SUBTITLE == "subtitle" def test_clip_type_count(self): assert len(ClipType) == 6 def test_transition_effect_values(self): assert TransitionEffect.CUT == "cut" assert TransitionEffect.FADE == "fade" assert TransitionEffect.SLIDE_LEFT == "slide_left" assert TransitionEffect.SLIDE_RIGHT == "slide_right" assert TransitionEffect.DISSOLVE == "dissolve" assert TransitionEffect.WIPE == "wipe" def test_transition_effect_count(self): assert len(TransitionEffect) == 6 def test_template_clip_config(self): clip = TemplateClipConfig( id="clip-1", template_id="t1", clip_type=ClipType.MAIN, order=1, min_duration=2.0, max_duration=5.0, ) assert clip.id == "clip-1" assert clip.template_id == "t1" assert clip.clip_type == ClipType.MAIN assert clip.order == 1 assert clip.min_duration == 2.0 assert clip.max_duration == 5.0 def test_str_enum(self): assert isinstance(ClipType.INTRO, str) assert isinstance(TransitionEffect.FADE, str) # ── PresetBGM ──────────────────────────────────────────────────────────────── class TestPresetBGM: """PresetBGM + 查询函数测试""" def test_preset_bgm_create(self): bgm = PresetBGM( id="bgm_test_001", name="测试音乐", style="upbeat", duration=120.0, ) assert bgm.id == "bgm_test_001" assert bgm.name == "测试音乐" assert bgm.style == "upbeat" assert bgm.duration == 120.0 assert bgm.artist == "" assert bgm.tags == [] assert bgm.audio_url == "" def test_preset_bgm_frozen(self): bgm = PresetBGM(id="t1", name="t", style="x", duration=10.0) with pytest.raises(FrozenInstanceError): bgm.name = "改名" def test_library_not_empty(self): assert len(PRESET_BGM_LIBRARY) > 0 def test_all_presets_have_required_fields(self): for bgm in PRESET_BGM_LIBRARY: assert bgm.id assert bgm.name assert bgm.style assert bgm.duration > 0 def test_get_preset_bgm_existing(self): first = PRESET_BGM_LIBRARY[0] result = get_preset_bgm(first.id) assert result is not None assert result.id == first.id def test_get_preset_bgm_nonexistent(self): assert get_preset_bgm("nonexistent_bgm") is None def test_list_preset_bgm_by_style(self): upbeat = list_preset_bgm_by_style("upbeat") assert len(upbeat) > 0 assert all(b.style == "upbeat" for b in upbeat) def test_list_preset_bgm_by_style_empty(self): result = list_preset_bgm_by_style("nonexistent_style") assert result == [] def test_search_preset_bgm_by_name(self): result = search_preset_bgm("阳光") assert len(result) >= 1 assert any("阳光" in b.name for b in result) def test_search_preset_bgm_by_tag(self): result = search_preset_bgm("轻快") assert len(result) >= 1 assert any(any("轻快" in t for t in b.tags) for b in result) def test_search_preset_bgm_empty_result(self): result = search_preset_bgm("xyz_not_exist_keyword") assert result == [] # ── PresetVoices ───────────────────────────────────────────────────────────── class TestPresetVoices: """PresetVoice + 查询函数测试""" def test_preset_voice_create(self): v = PresetVoice( voice_id="test_voice", name="测试音色", description="测试用", gender="female", ) assert v.voice_id == "test_voice" assert v.name == "测试音色" assert v.gender == "female" assert v.language == "zh-CN" assert v.preview_url == "" assert v.tags is None def test_preset_voice_to_dict(self): v = PresetVoice( voice_id="v1", name="音色1", description="desc", gender="male", language="zh-CN", tags=["温柔", "男声"], ) d = v.to_dict() assert d["voice_id"] == "v1" assert d["name"] == "音色1" assert d["description"] == "desc" assert d["gender"] == "male" assert d["language"] == "zh-CN" assert d["tags"] == ["温柔", "男声"] def test_preset_voice_to_dict_no_tags(self): v = PresetVoice(voice_id="v1", name="t", description="d", gender="female") d = v.to_dict() assert d["tags"] == [] def test_preset_voice_frozen(self): v = PresetVoice(voice_id="v1", name="t", description="d", gender="female") with pytest.raises(FrozenInstanceError): v.name = "改名" def test_preset_voices_list_not_empty(self): assert len(PRESET_VOICES) > 0 def test_get_preset_voices(self): voices = get_preset_voices() assert len(voices) == len(PRESET_VOICES) assert all(isinstance(v, PresetVoice) for v in voices) def test_get_preset_voice_by_id_existing(self): first = PRESET_VOICES[0] result = get_preset_voice_by_id(first.voice_id) assert result is not None assert result.voice_id == first.voice_id def test_get_preset_voice_by_id_nonexistent(self): assert get_preset_voice_by_id("nonexistent_voice") is None def test_is_preset_voice_true(self): first = PRESET_VOICES[0] assert is_preset_voice(first.voice_id) is True def test_is_preset_voice_false(self): assert is_preset_voice("fake_voice_id") is False # ── TitleLibrary ───────────────────────────────────────────────────────────── class TestTitleLibraryItem: """TitleLibraryItem 测试""" def test_create_basic(self): item = TitleLibraryItem(id="t1", user_id="u1", name="标题1", text="这是标题文案") assert item.id == "t1" assert item.user_id == "u1" assert item.name == "标题1" assert item.text == "这是标题文案" assert item.category == "default" assert item.tags == [] assert item.usage_count == 0 assert item.is_active is True assert item.created_at is not None def test_create_with_details(self): item = TitleLibraryItem( id="t1", user_id="u1", name="爆款标题", text="三个方法教你...", category="爆款", description="高点击率", tags=["热门", "干货"], usage_count=100, ) assert item.category == "爆款" assert item.description == "高点击率" assert item.tags == ["热门", "干货"] assert item.usage_count == 100 # ── VoiceLibrary ───────────────────────────────────────────────────────────── class TestVoiceLibraryItem: """VoiceLibraryItem 测试""" def test_create_basic(self): item = VoiceLibraryItem(id="v1", user_id="u1", name="我的配音") assert item.id == "v1" assert item.user_id == "u1" assert item.name == "我的配音" assert item.text == "" assert item.voice_provider == "" assert item.duration == 0 assert item.status == "completed" assert item.project_id is None assert item.tags == [] assert item.created_at is not None def test_create_with_details(self): item = VoiceLibraryItem( id="v1", user_id="u1", name="产品介绍", text="大家好,今天给大家介绍...", voice_provider="cosyvoice", voice_id="voice_001", voice_name="温柔女声", audio_url="http://cdn/audio.mp3", duration=30.5, file_size=102400, status="processing", project_id="proj-1", tags=["产品", "介绍"], ) assert item.voice_provider == "cosyvoice" assert item.voice_id == "voice_001" assert item.audio_url == "http://cdn/audio.mp3" assert item.duration == 30.5 assert item.file_size == 102400 assert item.status == "processing" assert item.project_id == "proj-1" assert item.tags == ["产品", "介绍"]