diff --git a/tests/unit/domain/test_template_clip_converter.py b/tests/unit/domain/test_template_clip_converter.py new file mode 100755 index 000000000..d82228924 --- /dev/null +++ b/tests/unit/domain/test_template_clip_converter.py @@ -0,0 +1,496 @@ +"""模板片段转换器单测. + +纯函数模块,覆盖:枚举安全解析、config过滤、 +clip→template转换、snapshot双向转换、名称校验。 +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from packages.domain.template_clip_config import ( + ClipType, + TemplateClipConfig, + TransitionEffect, +) +from packages.domain.template_clip_converter import ( + clip_config_to_snapshot, + clip_configs_to_snapshots, + clip_to_template_clip_config, + clips_to_template_clip_configs, + filter_clip_config, + filter_plan_config_to_template, + safe_parse_clip_type, + safe_parse_transition_effect, + snapshot_to_template_clip_config, + snapshots_to_template_clip_configs, + validate_template_name, +) + + +class TestSafeParseTransitionEffect: + def test_enum_passthrough(self): + result = safe_parse_transition_effect(TransitionEffect.FADE) + assert result == TransitionEffect.FADE + assert isinstance(result, TransitionEffect) + + def test_valid_string(self): + result = safe_parse_transition_effect("fade") + assert result == TransitionEffect.FADE + + def test_cut_string(self): + result = safe_parse_transition_effect("cut") + assert result == TransitionEffect.CUT + + def test_invalid_string_returns_default(self): + result = safe_parse_transition_effect("invalid_effect") + assert result == TransitionEffect.CUT # 默认 + + def test_invalid_string_custom_default(self): + result = safe_parse_transition_effect("bad", default=TransitionEffect.DISSOLVE) + assert result == TransitionEffect.DISSOLVE + + def test_none_returns_default(self): + result = safe_parse_transition_effect(None) + assert result == TransitionEffect.CUT + + def test_int_value_returns_default(self): + result = safe_parse_transition_effect(123) + assert result == TransitionEffect.CUT + + def test_empty_string_returns_default(self): + result = safe_parse_transition_effect("") + assert result == TransitionEffect.CUT + + +class TestSafeParseClipType: + def test_enum_passthrough(self): + result = safe_parse_clip_type(ClipType.SUBTITLE) + assert result == ClipType.SUBTITLE + assert isinstance(result, ClipType) + + def test_valid_string_main(self): + result = safe_parse_clip_type("main") + assert result == ClipType.MAIN + + def test_valid_string_text(self): + result = safe_parse_clip_type("subtitle") + assert result == ClipType.SUBTITLE + + def test_invalid_string_returns_default(self): + result = safe_parse_clip_type("unknown_type") + assert result == ClipType.MAIN + + def test_invalid_string_custom_default(self): + result = safe_parse_clip_type("bad", default=ClipType.TITLE) + assert result == ClipType.TITLE + + def test_none_returns_default(self): + result = safe_parse_clip_type(None) + assert result == ClipType.MAIN + + def test_dict_returns_default(self): + result = safe_parse_clip_type({"key": "val"}) + assert result == ClipType.MAIN + + +class TestFilterClipConfig: + def test_none_config(self): + result = filter_clip_config(None) + assert result == {} + + def test_empty_dict(self): + result = filter_clip_config({}) + assert result == {} + + def test_basic_config_passthrough(self): + cfg = {"font_size": 24, "color": "red"} + result = filter_clip_config(cfg) + assert result == {"font_size": 24, "color": "red"} + + def test_filters_asset_info(self): + cfg = {"font_size": 24, "asset_info": {"id": "123"}} + result = filter_clip_config(cfg) + assert "asset_info" not in result + assert result["font_size"] == 24 + + def test_filters_source_asset_id(self): + cfg = {"source_asset_id": "asset_1", "text_key": "hi"} + result = filter_clip_config(cfg) + assert "source_asset_id" not in result + assert result["text_key"] == "hi" + + def test_playback_speed_added_when_not_one(self): + result = filter_clip_config({}, playback_speed=1.5) + assert result["playback_speed"] == 1.5 + + def test_playback_speed_one_not_added(self): + result = filter_clip_config({}, playback_speed=1.0) + assert "playback_speed" not in result + + def test_playback_speed_none_not_added(self): + result = filter_clip_config({}, playback_speed=None) + assert "playback_speed" not in result + + def test_playback_speed_config_takes_priority(self): + """clip_config中的playback_speed会覆盖参数传入的(因为update在后面).""" + cfg = {"playback_speed": 0.5, "other": "val"} + result = filter_clip_config(cfg, playback_speed=2.0) + assert result["playback_speed"] == 0.5 # config里的覆盖参数的 + assert result["other"] == "val" + + def test_custom_skip_keys(self): + cfg = {"keep_me": 1, "drop_me": 2, "also_drop": 3} + skip = frozenset({"drop_me", "also_drop"}) + result = filter_clip_config(cfg, skip_keys=skip) + assert result == {"keep_me": 1} + + def test_does_not_mutate_input(self): + cfg = {"a": 1, "asset_info": "x"} + original = dict(cfg) + filter_clip_config(cfg) + assert cfg == original # 原dict不变 + + +class TestFilterPlanConfigToTemplate: + def test_none_config(self): + result = filter_plan_config_to_template(None) + assert result == {} + + def test_empty_dict(self): + result = filter_plan_config_to_template({}) + assert result == {} + + def test_keeps_template_fields(self): + cfg = {"title": "My Template", "aspect_ratio": "9:16"} + result = filter_plan_config_to_template(cfg) + assert result == cfg + + def test_filters_runtime_fields(self): + cfg = { + "title": "T", + "is_template_draft": True, + "asset_ids": ["a1"], + "source_edit_plan_id": "ep1", + "generation_task_id": "gt1", + } + result = filter_plan_config_to_template(cfg) + assert "is_template_draft" not in result + assert "asset_ids" not in result + assert "source_edit_plan_id" not in result + assert "generation_task_id" not in result + assert result["title"] == "T" + + def test_custom_skip_keys(self): + cfg = {"keep": 1, "skip_a": 2, "skip_b": 3} + skip = frozenset({"skip_a", "skip_b"}) + result = filter_plan_config_to_template(cfg, skip_keys=skip) + assert result == {"keep": 1} + + +class TestClipToTemplateClipConfig: + @dataclass + class FakeClip: + clip_type: str = "main" + order: int = 0 + duration: float = 5.0 + text_content: str = "" + transition_effect: str = "cut" + playback_speed: float | None = None + config: dict | None = None + + def test_basic_conversion(self): + clip = self.FakeClip( + clip_type="subtitle", + order=2, + duration=3.5, + text_content="Hello", + transition_effect="fade", + ) + result = clip_to_template_clip_config("tpl_1", clip) + assert isinstance(result, TemplateClipConfig) + assert result.template_id == "tpl_1" + assert result.clip_type == ClipType.SUBTITLE + assert result.order == 2 + assert result.min_duration == 3.5 + assert result.max_duration == 3.5 + assert result.text_template == "Hello" + assert result.transition_effect == TransitionEffect.FADE + + def test_duration_fixed_min_max_equal(self): + """转换后 min_duration == max_duration == clip.duration.""" + clip = self.FakeClip(duration=7.2) + result = clip_to_template_clip_config("t1", clip) + assert result.min_duration == 7.2 + assert result.max_duration == 7.2 + + def test_zero_duration(self): + clip = self.FakeClip(duration=0.0) + result = clip_to_template_clip_config("t1", clip) + assert result.min_duration == 0.0 + assert result.max_duration == 0.0 + + def test_none_duration_defaults_to_zero(self): + clip = self.FakeClip() + clip.duration = None # type: ignore + result = clip_to_template_clip_config("t1", clip) + assert result.min_duration == 0.0 + assert result.max_duration == 0.0 + + def test_empty_text_content_becomes_empty_string(self): + clip = self.FakeClip(text_content="") + result = clip_to_template_clip_config("t1", clip) + assert result.text_template == "" + + def test_none_text_content_becomes_empty_string(self): + clip = self.FakeClip() + clip.text_content = None # type: ignore + result = clip_to_template_clip_config("t1", clip) + assert result.text_template == "" + + def test_playback_speed_in_config(self): + clip = self.FakeClip(playback_speed=1.5, config={"font": "bold"}) + result = clip_to_template_clip_config("t1", clip) + assert result.config["playback_speed"] == 1.5 + assert result.config["font"] == "bold" + + def test_playback_speed_one_not_in_config(self): + clip = self.FakeClip(playback_speed=1.0) + result = clip_to_template_clip_config("t1", clip) + assert "playback_speed" not in result.config + + def test_config_asset_info_filtered(self): + clip = self.FakeClip(config={"text_key": "hi", "asset_info": {"id": "a"}}) + result = clip_to_template_clip_config("t1", clip) + assert "asset_info" not in result.config + assert result.config["text_key"] == "hi" + + def test_invalid_clip_type_falls_back(self): + clip = self.FakeClip(clip_type="invalid_type") + result = clip_to_template_clip_config("t1", clip) + assert result.clip_type == ClipType.MAIN + + def test_missing_attributes(self): + """对象没有某些属性时使用默认值.""" + + class MinimalClip: + pass + + result = clip_to_template_clip_config("t1", MinimalClip()) + assert result.clip_type == ClipType.MAIN + assert result.order == 0 + assert result.min_duration == 0.0 + assert result.text_template == "" + assert result.transition_effect == TransitionEffect.CUT + + +class TestClipsToTemplateClipConfigs: + def test_empty_list(self): + result = clips_to_template_clip_configs("t1", []) + assert result == [] + + def test_multiple_clips(self): + clip_a = TestClipToTemplateClipConfig.FakeClip(clip_type="subtitle", order=0, duration=3.0, text_content="A") + clip_b = TestClipToTemplateClipConfig.FakeClip(clip_type="title", order=1, duration=5.0, text_content="") + result = clips_to_template_clip_configs("t1", [clip_a, clip_b]) + assert len(result) == 2 + assert result[0].clip_type == ClipType.SUBTITLE + assert result[0].order == 0 + assert result[1].clip_type == ClipType.TITLE + assert result[1].order == 1 + assert all(isinstance(r, TemplateClipConfig) for r in result) + + +class TestClipConfigToSnapshot: + def test_basic_snapshot(self): + cfg = TemplateClipConfig.create( + template_id="t1", + clip_type=ClipType.SUBTITLE, + order=2, + min_duration=3.0, + max_duration=5.0, + text_template="Hello", + transition_effect=TransitionEffect.FADE, + config={"font_size": 20}, + ) + snap = clip_config_to_snapshot(cfg) + assert snap["clip_type"] == "subtitle" + assert snap["order"] == 2 + assert snap["min_duration"] == 3.0 + assert snap["max_duration"] == 5.0 + assert snap["text_template"] == "Hello" + assert snap["transition_effect"] == "fade" + assert snap["config"] == {"font_size": 20} + + def test_enum_values_are_strings(self): + cfg = TemplateClipConfig.create(template_id="t1", clip_type=ClipType.MAIN, order=0) + snap = clip_config_to_snapshot(cfg) + assert snap["clip_type"] == "main" + assert isinstance(snap["clip_type"], str) + assert snap["transition_effect"] == "cut" + assert isinstance(snap["transition_effect"], str) + + def test_config_is_copy_not_reference(self): + config = {"key": "val"} + cfg = TemplateClipConfig.create(template_id="t1", clip_type=ClipType.MAIN, order=0, config=config) + snap = clip_config_to_snapshot(cfg) + snap["config"]["key"] = "changed" + assert config["key"] == "val" # 原config不变 + + def test_empty_config(self): + cfg = TemplateClipConfig.create(template_id="t1", clip_type=ClipType.MAIN, order=0, config={}) + snap = clip_config_to_snapshot(cfg) + assert snap["config"] == {} + + def test_none_text_becomes_empty(self): + cfg = TemplateClipConfig.create(template_id="t1", clip_type=ClipType.MAIN, order=0) + cfg.text_template = None # type: ignore + snap = clip_config_to_snapshot(cfg) + assert snap["text_template"] == "" + + +class TestClipConfigsToSnapshots: + def test_empty_list(self): + assert clip_configs_to_snapshots([]) == [] + + def test_multiple_configs(self): + cfg1 = TemplateClipConfig.create( + template_id="t1", + clip_type=ClipType.SUBTITLE, + order=0, + min_duration=2.0, + max_duration=2.0, + ) + cfg2 = TemplateClipConfig.create( + template_id="t1", + clip_type=ClipType.TITLE, + order=1, + min_duration=3.0, + max_duration=3.0, + ) + snaps = clip_configs_to_snapshots([cfg1, cfg2]) + assert len(snaps) == 2 + assert snaps[0]["clip_type"] == "subtitle" + assert snaps[1]["clip_type"] == "title" + + +class TestSnapshotToTemplateClipConfig: + def test_basic_conversion(self): + snap = { + "clip_type": "subtitle", + "order": 3, + "min_duration": 2.5, + "max_duration": 4.5, + "text_template": "World", + "transition_effect": "dissolve", + "config": {"color": "blue"}, + } + result = snapshot_to_template_clip_config("tpl_2", snap) + assert isinstance(result, TemplateClipConfig) + assert result.template_id == "tpl_2" + assert result.clip_type == ClipType.SUBTITLE + assert result.order == 3 + assert result.min_duration == 2.5 + assert result.max_duration == 4.5 + assert result.text_template == "World" + assert result.transition_effect == TransitionEffect.DISSOLVE + assert result.config == {"color": "blue"} + + def test_empty_snapshot_uses_defaults(self): + result = snapshot_to_template_clip_config("t1", {}) + assert result.clip_type == ClipType.MAIN + assert result.order == 0 + assert result.min_duration == 0.0 + assert result.max_duration == 0.0 + assert result.text_template == "" + assert result.transition_effect == TransitionEffect.CUT + assert result.config == {} + + def test_invalid_clip_type_defaults(self): + snap = {"clip_type": "unknown"} + result = snapshot_to_template_clip_config("t1", snap) + assert result.clip_type == ClipType.MAIN + + def test_invalid_transition_defaults(self): + snap = {"transition_effect": "bad_effect"} + result = snapshot_to_template_clip_config("t1", snap) + assert result.transition_effect == TransitionEffect.CUT + + def test_none_config_becomes_empty(self): + snap = {"config": None} + result = snapshot_to_template_clip_config("t1", snap) + assert result.config == {} + + +class TestSnapshotsToTemplateClipConfigs: + def test_empty_list(self): + result = snapshots_to_template_clip_configs("t1", []) + assert result == [] + + def test_multiple_snapshots(self): + snaps = [ + {"clip_type": "subtitle", "order": 0, "text_template": "A"}, + {"clip_type": "title", "order": 1}, + ] + result = snapshots_to_template_clip_configs("t1", snaps) + assert len(result) == 2 + assert result[0].clip_type == ClipType.SUBTITLE + assert result[0].text_template == "A" + assert result[1].clip_type == ClipType.TITLE + + +class TestRoundTrip: + """clip → config → snapshot → config 双向转换一致性.""" + + def test_snapshot_config_round_trip(self): + original = TemplateClipConfig.create( + template_id="t1", + clip_type=ClipType.SUBTITLE, + order=5, + min_duration=3.0, + max_duration=6.0, + text_template="Round trip", + transition_effect=TransitionEffect.FADE, + config={"key": "value"}, + ) + snap = clip_config_to_snapshot(original) + restored = snapshot_to_template_clip_config("t1", snap) + assert restored.clip_type == original.clip_type + assert restored.order == original.order + assert restored.min_duration == original.min_duration + assert restored.max_duration == original.max_duration + assert restored.text_template == original.text_template + assert restored.transition_effect == original.transition_effect + assert restored.config == original.config + + +class TestValidateTemplateName: + def test_valid_name(self): + assert validate_template_name("我的模板") == "我的模板" + + def test_strips_whitespace(self): + assert validate_template_name(" Hello ") == "Hello" + + def test_empty_string_raises(self): + try: + validate_template_name("") + except ValueError as e: + assert "不能为空" in str(e) + else: + raise AssertionError("expected ValueError") + + def test_whitespace_only_raises(self): + try: + validate_template_name(" ") + except ValueError as e: + assert "不能为空" in str(e) + else: + raise AssertionError("expected ValueError") + + def test_none_raises(self): + try: + validate_template_name(None) + except ValueError as e: + assert "不能为空" in str(e) + else: + raise AssertionError("expected ValueError")