diff --git a/tests/unit/domain/test_intro_outro_config.py b/tests/unit/domain/test_intro_outro_config.py new file mode 100755 index 000000000..ca9ac4026 --- /dev/null +++ b/tests/unit/domain/test_intro_outro_config.py @@ -0,0 +1,593 @@ +"""intro_outro_config 片头片尾配置单测.""" + +import pytest +from domain.intro_outro_config import ( + INTRO_OUTRO_TYPE_FOLLOW, + INTRO_OUTRO_TYPE_NONE, + INTRO_OUTRO_TYPE_TEXT, + INTRO_OUTRO_TYPE_VIDEO, + TRANSITION_FADE, + TRANSITION_SLIDE, + TRANSITION_WIPE, + IntroOutroConfig, +) + +# ── 常量测试 ────────────────────────────────────────────────────────────────── + + +class TestConstants: + """模块常量""" + + def test_type_constants(self): + assert INTRO_OUTRO_TYPE_NONE == "none" + assert INTRO_OUTRO_TYPE_VIDEO == "video" + assert INTRO_OUTRO_TYPE_TEXT == "text" + assert INTRO_OUTRO_TYPE_FOLLOW == "follow" + + def test_transition_constants(self): + assert TRANSITION_FADE == "fade" + assert TRANSITION_SLIDE == "slide" + assert TRANSITION_WIPE == "wipe" + + +# ── 默认值与基础属性 ────────────────────────────────────────────────────────── + + +class TestDefaultConfig: + """IntroOutroConfig 默认值""" + + def test_default_not_enabled(self): + c = IntroOutroConfig() + assert c.enabled is False + + def test_default_intro(self): + c = IntroOutroConfig() + assert c.intro_type == INTRO_OUTRO_TYPE_NONE + assert c.intro_video_path == "" + assert c.intro_duration == 3.0 + assert c.intro_background == "#000000" + assert c.intro_title == "" + assert c.intro_subtitle == "" + assert c.intro_title_color == "white" + assert c.intro_title_size == 48 + assert c.intro_subtitle_color == "gray" + assert c.intro_subtitle_size == 24 + + def test_default_outro(self): + c = IntroOutroConfig() + assert c.outro_type == INTRO_OUTRO_TYPE_NONE + assert c.outro_video_path == "" + assert c.outro_duration == 3.0 + assert c.outro_background == "#000000" + assert c.outro_title == "感谢观看" + assert c.outro_subtitle == "点赞关注不迷路" + assert c.outro_title_color == "white" + assert c.outro_title_size == 48 + assert c.outro_subtitle_color == "gray" + assert c.outro_subtitle_size == 24 + + def test_default_transition(self): + c = IntroOutroConfig() + assert c.transition_effect == TRANSITION_FADE + assert c.transition_duration == 0.5 + + +# ── from_dict 构造 ─────────────────────────────────────────────────────────── + + +class TestFromDict: + """from_dict 工厂方法""" + + def test_none_returns_default(self): + c = IntroOutroConfig.from_dict(None) + assert c.enabled is False + + def test_empty_dict_returns_default(self): + c = IntroOutroConfig.from_dict({}) + assert c.enabled is False + + def test_enabled_false_returns_default(self): + c = IntroOutroConfig.from_dict({"enabled": False}) + assert c.enabled is False + + def test_minimal_enabled(self): + c = IntroOutroConfig.from_dict({"enabled": True}) + assert c.enabled is True + assert c.intro_type == INTRO_OUTRO_TYPE_NONE + assert c.outro_type == INTRO_OUTRO_TYPE_NONE + + def test_intro_video(self): + c = IntroOutroConfig.from_dict( + { + "enabled": True, + "intro": { + "type": "video", + "video_path": "/tmp/intro.mp4", + "duration": 5.0, + }, + } + ) + assert c.intro_type == INTRO_OUTRO_TYPE_VIDEO + assert c.intro_video_path == "/tmp/intro.mp4" + assert c.intro_duration == 5.0 + + def test_intro_text(self): + c = IntroOutroConfig.from_dict( + { + "enabled": True, + "intro": { + "type": "text", + "title": "欢迎来到", + "subtitle": "我的频道", + "background": "#ffffff", + "title_color": "black", + "title_size": 64, + "subtitle_color": "darkgray", + "subtitle_size": 32, + }, + } + ) + assert c.intro_type == INTRO_OUTRO_TYPE_TEXT + assert c.intro_title == "欢迎来到" + assert c.intro_subtitle == "我的频道" + assert c.intro_background == "#ffffff" + assert c.intro_title_size == 64 + assert c.intro_subtitle_size == 32 + + def test_outro_text(self): + c = IntroOutroConfig.from_dict( + { + "enabled": True, + "outro": { + "type": "text", + "title": "再见", + "subtitle": "下次见", + "title_size": 56, + }, + } + ) + assert c.outro_type == INTRO_OUTRO_TYPE_TEXT + assert c.outro_title == "再见" + assert c.outro_subtitle == "下次见" + assert c.outro_title_size == 56 + + def test_outro_follow_type(self): + c = IntroOutroConfig.from_dict( + { + "enabled": True, + "outro": {"type": "follow", "title": "关注我"}, + } + ) + assert c.outro_type == INTRO_OUTRO_TYPE_FOLLOW + assert c.outro_title == "关注我" + + def test_outro_video(self): + c = IntroOutroConfig.from_dict( + { + "enabled": True, + "outro": { + "type": "video", + "video_path": "/tmp/outro.mp4", + "duration": 4.0, + }, + } + ) + assert c.outro_type == INTRO_OUTRO_TYPE_VIDEO + assert c.outro_video_path == "/tmp/outro.mp4" + assert c.outro_duration == 4.0 + + def test_transition_config(self): + c = IntroOutroConfig.from_dict( + { + "enabled": True, + "transition": "slide", + "transition_duration": 1.0, + } + ) + assert c.transition_effect == TRANSITION_SLIDE + assert c.transition_duration == 1.0 + + def test_video_field_alias(self): + # video 字段兼容(video_path 和 video 都能用) + c = IntroOutroConfig.from_dict( + { + "enabled": True, + "intro": {"type": "video", "video": "old_path.mp4"}, + } + ) + assert c.intro_video_path == "old_path.mp4" + + def test_video_path_preferred_over_video(self): + c = IntroOutroConfig.from_dict( + { + "enabled": True, + "intro": {"type": "video", "video_path": "new.mp4", "video": "old.mp4"}, + } + ) + assert c.intro_video_path == "new.mp4" + + def test_invalid_duration_falls_back(self): + c = IntroOutroConfig.from_dict( + { + "enabled": True, + "intro": {"duration": "abc"}, + } + ) + assert c.intro_duration == 3.0 + + def test_invalid_size_falls_back(self): + c = IntroOutroConfig.from_dict( + { + "enabled": True, + "intro": {"title_size": "not_a_number"}, + } + ) + assert c.intro_title_size == 48 + + def test_none_intro_outro(self): + c = IntroOutroConfig.from_dict({"enabled": True, "intro": None, "outro": None}) + assert c.intro_type == INTRO_OUTRO_TYPE_NONE + assert c.outro_type == INTRO_OUTRO_TYPE_NONE + + def test_empty_title_defaults_for_outro(self): + # outro title 为空时回退到默认值 + c = IntroOutroConfig.from_dict( + { + "enabled": True, + "outro": {"type": "text", "title": ""}, + } + ) + assert c.outro_title == "感谢观看" + + def test_empty_subtitle_defaults_for_outro(self): + c = IntroOutroConfig.from_dict( + { + "enabled": True, + "outro": {"subtitle": ""}, + } + ) + assert c.outro_subtitle == "点赞关注不迷路" + + def test_combined_full_config(self): + c = IntroOutroConfig.from_dict( + { + "enabled": True, + "intro": { + "type": "text", + "title": "片头标题", + "subtitle": "片头副标题", + "background": "#123456", + "duration": 2.5, + "title_size": 72, + }, + "outro": { + "type": "video", + "video_path": "/outro.mp4", + "duration": 4.0, + }, + "transition": "wipe", + "transition_duration": 0.8, + } + ) + assert c.intro_title == "片头标题" + assert c.intro_duration == 2.5 + assert c.outro_type == "video" + assert c.outro_video_path == "/outro.mp4" + assert c.transition_effect == "wipe" + assert c.transition_duration == 0.8 + + def test_does_not_mutate_input(self): + data = {"enabled": True, "intro": {"type": "text", "title": "test"}} + data_copy = { + "enabled": True, + "intro": {"type": "text", "title": "test"}, + } + IntroOutroConfig.from_dict(data) + assert data == data_copy + + +# ── has_intro / has_outro 属性 ─────────────────────────────────────────────── + + +class TestHasIntroOutro: + """has_intro / has_outro 属性""" + + def test_disabled_no_intro(self): + c = IntroOutroConfig() + assert c.has_intro is False + + def test_disabled_no_outro(self): + c = IntroOutroConfig() + assert c.has_outro is False + + def test_enabled_none_type_no_intro(self): + c = IntroOutroConfig(enabled=True, intro_type=INTRO_OUTRO_TYPE_NONE) + assert c.has_intro is False + + def test_video_intro_has_intro(self): + c = IntroOutroConfig( + enabled=True, + intro_type=INTRO_OUTRO_TYPE_VIDEO, + intro_video_path="/x.mp4", + ) + assert c.has_intro is True + + def test_text_intro_has_intro(self): + c = IntroOutroConfig( + enabled=True, + intro_type=INTRO_OUTRO_TYPE_TEXT, + intro_title="Hi", + ) + assert c.has_intro is True + + def test_video_outro_has_outro(self): + c = IntroOutroConfig( + enabled=True, + outro_type=INTRO_OUTRO_TYPE_VIDEO, + outro_video_path="/x.mp4", + ) + assert c.has_outro is True + + def test_text_outro_has_outro(self): + c = IntroOutroConfig( + enabled=True, + outro_type=INTRO_OUTRO_TYPE_TEXT, + outro_title="Bye", + ) + assert c.has_outro is True + + def test_follow_outro_has_outro(self): + c = IntroOutroConfig( + enabled=True, + outro_type=INTRO_OUTRO_TYPE_FOLLOW, + outro_title="关注", + ) + assert c.has_outro is True + + def test_follow_type_no_intro(self): + # follow 只是片尾类型,片头不支持 + c = IntroOutroConfig(enabled=True, intro_type=INTRO_OUTRO_TYPE_FOLLOW) + assert c.has_intro is False + + +# ── total_extra_duration ───────────────────────────────────────────────────── + + +class TestTotalExtraDuration: + """total_extra_duration 属性""" + + def test_disabled_zero(self): + c = IntroOutroConfig() + assert c.total_extra_duration == 0.0 + + def test_only_intro(self): + c = IntroOutroConfig( + enabled=True, + intro_type=INTRO_OUTRO_TYPE_TEXT, + intro_title="Hi", + intro_duration=3.0, + ) + assert c.total_extra_duration == 3.0 + + def test_only_outro(self): + c = IntroOutroConfig( + enabled=True, + outro_type=INTRO_OUTRO_TYPE_TEXT, + outro_title="Bye", + outro_duration=4.0, + ) + assert c.total_extra_duration == 4.0 + + def test_both_intro_outro(self): + c = IntroOutroConfig( + enabled=True, + intro_type=INTRO_OUTRO_TYPE_VIDEO, + intro_video_path="/i.mp4", + intro_duration=2.5, + outro_type=INTRO_OUTRO_TYPE_VIDEO, + outro_video_path="/o.mp4", + outro_duration=3.5, + ) + assert c.total_extra_duration == 6.0 + + def test_zero_duration_not_counted(self): + c = IntroOutroConfig( + enabled=True, + intro_type=INTRO_OUTRO_TYPE_TEXT, + intro_title="Hi", + intro_duration=0.0, + outro_type=INTRO_OUTRO_TYPE_TEXT, + outro_title="Bye", + outro_duration=0.0, + ) + assert c.total_extra_duration == 0.0 + + def test_negative_duration_not_counted(self): + c = IntroOutroConfig( + enabled=True, + intro_type=INTRO_OUTRO_TYPE_TEXT, + intro_title="Hi", + intro_duration=-1.0, + ) + assert c.total_extra_duration == 0.0 + + +# ── validate 校验 ──────────────────────────────────────────────────────────── + + +class TestValidate: + """validate 方法""" + + def test_disabled_always_valid(self): + c = IntroOutroConfig() + valid, msg = c.validate() + assert valid is True + assert msg == "" + + def test_none_types_valid(self): + c = IntroOutroConfig(enabled=True) + valid, msg = c.validate() + assert valid is True + + def test_invalid_intro_type(self): + c = IntroOutroConfig(enabled=True, intro_type="invalid") + valid, msg = c.validate() + assert valid is False + assert "片头类型" in msg + + def test_invalid_outro_type(self): + c = IntroOutroConfig(enabled=True, outro_type="invalid") + valid, msg = c.validate() + assert valid is False + assert "片尾类型" in msg + + def test_video_intro_no_path(self): + c = IntroOutroConfig( + enabled=True, + intro_type=INTRO_OUTRO_TYPE_VIDEO, + intro_video_path="", + ) + valid, msg = c.validate() + assert valid is False + assert "video_path" in msg + + def test_video_intro_with_path_valid(self): + c = IntroOutroConfig( + enabled=True, + intro_type=INTRO_OUTRO_TYPE_VIDEO, + intro_video_path="/path.mp4", + ) + valid, _ = c.validate() + assert valid is True + + def test_text_intro_no_title(self): + c = IntroOutroConfig( + enabled=True, + intro_type=INTRO_OUTRO_TYPE_TEXT, + intro_title="", + ) + valid, msg = c.validate() + assert valid is False + assert "title" in msg + + def test_text_intro_with_title_valid(self): + c = IntroOutroConfig( + enabled=True, + intro_type=INTRO_OUTRO_TYPE_TEXT, + intro_title="Hi", + ) + valid, _ = c.validate() + assert valid is True + + def test_video_outro_no_path(self): + c = IntroOutroConfig( + enabled=True, + outro_type=INTRO_OUTRO_TYPE_VIDEO, + outro_video_path="", + ) + valid, msg = c.validate() + assert valid is False + assert "video_path" in msg + + def test_text_outro_no_title(self): + c = IntroOutroConfig( + enabled=True, + outro_type=INTRO_OUTRO_TYPE_TEXT, + outro_title="", + ) + valid, msg = c.validate() + assert valid is False + assert "title" in msg + + def test_follow_outro_no_title(self): + c = IntroOutroConfig( + enabled=True, + outro_type=INTRO_OUTRO_TYPE_FOLLOW, + outro_title="", + ) + valid, msg = c.validate() + assert valid is False + assert "title" in msg + + def test_follow_outro_with_title_valid(self): + c = IntroOutroConfig( + enabled=True, + outro_type=INTRO_OUTRO_TYPE_FOLLOW, + outro_title="关注", + ) + valid, _ = c.validate() + assert valid is True + + def test_zero_intro_duration_invalid(self): + c = IntroOutroConfig( + enabled=True, + intro_type=INTRO_OUTRO_TYPE_TEXT, + intro_title="Hi", + intro_duration=0.0, + ) + valid, msg = c.validate() + assert valid is False + assert "片头时长" in msg + + def test_negative_outro_duration_invalid(self): + c = IntroOutroConfig( + enabled=True, + outro_type=INTRO_OUTRO_TYPE_TEXT, + outro_title="Bye", + outro_duration=-1.0, + ) + valid, msg = c.validate() + assert valid is False + assert "片尾时长" in msg + + def test_negative_transition_duration_invalid(self): + c = IntroOutroConfig(enabled=True, transition_duration=-0.5) + valid, msg = c.validate() + assert valid is False + assert "转场" in msg + + def test_zero_transition_valid(self): + c = IntroOutroConfig(enabled=True, transition_duration=0.0) + valid, _ = c.validate() + assert valid is True + + def test_zero_title_size_invalid(self): + c = IntroOutroConfig( + enabled=True, + intro_type=INTRO_OUTRO_TYPE_TEXT, + intro_title="Hi", + intro_title_size=0, + ) + valid, msg = c.validate() + assert valid is False + assert "标题字号" in msg + + def test_negative_subtitle_size_invalid(self): + c = IntroOutroConfig( + enabled=True, + outro_type=INTRO_OUTRO_TYPE_TEXT, + outro_title="Bye", + outro_subtitle_size=-1, + ) + valid, msg = c.validate() + assert valid is False + assert "副标题字号" in msg + + def test_full_valid_config(self): + c = IntroOutroConfig( + enabled=True, + intro_type=INTRO_OUTRO_TYPE_TEXT, + intro_title="Hi", + intro_duration=3.0, + intro_title_size=48, + intro_subtitle_size=24, + outro_type=INTRO_OUTRO_TYPE_TEXT, + outro_title="Bye", + outro_duration=3.0, + outro_title_size=48, + outro_subtitle_size=24, + transition_duration=0.5, + ) + valid, msg = c.validate() + assert valid is True + assert msg == ""