"""template_clip_converter 模块单元测试.""" from __future__ import annotations from dataclasses import dataclass from typing import Any import pytest from packages.domain.template_clip_config import ClipType, 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, ) # ── 辅助数据类 ────────────────────────────────────────────────────────────── @dataclass class FakeClip: """模拟剪辑计划片段对象.""" clip_type: Any = "main" order: int = 0 duration: float = 5.0 text_content: str = "" transition_effect: Any = "cut" playback_speed: float | None = None config: dict[str, Any] | None = None @dataclass class FakeClipConfig: """模拟模板片段配置对象.""" clip_type: Any = "main" order: int = 0 min_duration: float = 0.0 max_duration: float = 0.0 text_template: str = "" transition_effect: Any = "cut" config: dict[str, Any] | None = None # ── safe_parse_transition_effect ──────────────────────────────────────────── class TestSafeParseTransitionEffect: def test_enum_value_passthrough(self): assert safe_parse_transition_effect(TransitionEffect.DISSOLVE) == TransitionEffect.DISSOLVE def test_valid_string(self): assert safe_parse_transition_effect("dissolve") == TransitionEffect.DISSOLVE def test_invalid_string_defaults_to_cut(self): assert safe_parse_transition_effect("invalid_effect") == TransitionEffect.CUT def test_none_defaults_to_cut(self): assert safe_parse_transition_effect(None) == TransitionEffect.CUT def test_custom_default(self): assert safe_parse_transition_effect("bad", default=TransitionEffect.FADE) == TransitionEffect.FADE def test_int_value(self): assert safe_parse_transition_effect(123) == TransitionEffect.CUT # ── safe_parse_clip_type ──────────────────────────────────────────────────── class TestSafeParseClipType: def test_enum_value_passthrough(self): assert safe_parse_clip_type(ClipType.INTRO) == ClipType.INTRO def test_valid_string(self): assert safe_parse_clip_type("intro") == ClipType.INTRO def test_invalid_string_defaults_to_main(self): assert safe_parse_clip_type("invalid_type") == ClipType.MAIN def test_none_defaults_to_main(self): assert safe_parse_clip_type(None) == ClipType.MAIN def test_custom_default(self): assert safe_parse_clip_type("bad", default=ClipType.OUTRO) == ClipType.OUTRO def test_int_value(self): assert safe_parse_clip_type(42) == ClipType.MAIN # ── filter_clip_config ────────────────────────────────────────────────────── class TestFilterClipConfig: def test_none_config_no_speed(self): result = filter_clip_config(None) assert result == {} def test_empty_config_no_speed(self): result = filter_clip_config({}) assert result == {} def test_playback_speed_added_when_not_default(self): result = filter_clip_config(None, playback_speed=1.5) assert result == {"playback_speed": 1.5} def test_playback_speed_skipped_when_default(self): result = filter_clip_config(None, playback_speed=1.0) assert result == {} def test_playback_speed_none_skipped(self): result = filter_clip_config(None, playback_speed=None) assert result == {} def test_config_merged(self): result = filter_clip_config({"filter": "vintage", "intensity": 0.5}) assert result == {"filter": "vintage", "intensity": 0.5} def test_asset_info_removed(self): result = filter_clip_config({"asset_info": {"name": "test.mp4"}, "filter": "vintage"}) assert "asset_info" not in result assert result["filter"] == "vintage" def test_source_asset_id_removed(self): result = filter_clip_config({"source_asset_id": "abc123", "filter": "vintage"}) assert "source_asset_id" not in result assert result["filter"] == "vintage" def test_speed_overrides_config_playback_speed(self): result = filter_clip_config({"playback_speed": 2.0}, playback_speed=0.5) assert result["playback_speed"] == 2.0 # config 优先级更高 def test_custom_skip_keys(self): skip = frozenset({"custom_field"}) result = filter_clip_config( {"custom_field": "x", "asset_info": "keep_it"}, skip_keys=skip, ) assert "custom_field" not in result assert "asset_info" in result # 自定义 skip 覆盖默认 # ── filter_plan_config_to_template ────────────────────────────────────────── class TestFilterPlanConfigToTemplate: def test_none_config(self): assert filter_plan_config_to_template(None) == {} def test_empty_config(self): assert filter_plan_config_to_template({}) == {} def test_draft_flag_removed(self): result = filter_plan_config_to_template({"is_template_draft": True, "editing_mode": "one_take"}) assert "is_template_draft" not in result assert result["editing_mode"] == "one_take" def test_asset_ids_removed(self): result = filter_plan_config_to_template({"asset_ids": ["a", "b"], "resolution": "1080p"}) assert "asset_ids" not in result assert result["resolution"] == "1080p" def test_source_edit_plan_id_removed(self): result = filter_plan_config_to_template({"source_edit_plan_id": "plan123", "theme": "dark"}) assert "source_edit_plan_id" not in result assert result["theme"] == "dark" def test_generation_task_id_removed(self): result = filter_plan_config_to_template({"generation_task_id": "task123", "bgm": "on"}) assert "generation_task_id" not in result assert result["bgm"] == "on" def test_normal_fields_preserved(self): config = { "editing_mode": "pip", "resolution": "720p", "duration": 30, "style": "cinematic", } result = filter_plan_config_to_template(config) assert result == config def test_custom_skip_keys(self): skip = frozenset({"secret_field"}) result = filter_plan_config_to_template( {"secret_field": "x", "is_template_draft": "keep"}, skip_keys=skip, ) assert "secret_field" not in result assert "is_template_draft" in result # ── clip_to_template_clip_config ──────────────────────────────────────────── class TestClipToTemplateClipConfig: def test_basic_conversion(self): clip = FakeClip( clip_type="main", order=2, duration=3.5, text_content="Hello world", transition_effect="dissolve", ) result = clip_to_template_clip_config("tmpl_001", clip) assert result.template_id == "tmpl_001" assert result.clip_type == ClipType.MAIN assert result.order == 2 assert result.min_duration == 3.5 assert result.max_duration == 3.5 assert result.text_template == "Hello world" assert result.transition_effect == TransitionEffect.DISSOLVE def test_playback_speed_in_config(self): clip = FakeClip(playback_speed=2.0) result = clip_to_template_clip_config("tmpl_001", clip) assert result.config["playback_speed"] == 2.0 def test_default_speed_not_in_config(self): clip = FakeClip(playback_speed=1.0) result = clip_to_template_clip_config("tmpl_001", clip) assert "playback_speed" not in result.config def test_config_preserved_and_filtered(self): clip = FakeClip(config={"filter": "vintage", "asset_info": {"id": "x"}}) result = clip_to_template_clip_config("tmpl_001", clip) assert result.config["filter"] == "vintage" assert "asset_info" not in result.config def test_text_content_none_becomes_empty(self): clip = FakeClip(text_content=None) result = clip_to_template_clip_config("tmpl_001", clip) assert result.text_template == "" def test_invalid_type_falls_back(self): clip = FakeClip(clip_type="nonexistent") result = clip_to_template_clip_config("tmpl_001", clip) assert result.clip_type == ClipType.MAIN def test_invalid_transition_falls_back(self): clip = FakeClip(transition_effect="nonexistent") result = clip_to_template_clip_config("tmpl_001", clip) assert result.transition_effect == TransitionEffect.CUT def test_enum_type_input(self): clip = FakeClip(clip_type=ClipType.INTRO, transition_effect=TransitionEffect.FADE) result = clip_to_template_clip_config("tmpl_001", clip) assert result.clip_type == ClipType.INTRO assert result.transition_effect == TransitionEffect.FADE def test_duration_none_defaults_zero(self): clip = FakeClip(duration=None) result = clip_to_template_clip_config("tmpl_001", clip) assert result.min_duration == 0.0 assert result.max_duration == 0.0 class TestClipsToTemplateClipConfigs: def test_empty_list(self): result = clips_to_template_clip_configs("tmpl_001", []) assert result == [] def test_multiple_clips(self): clips = [ FakeClip(clip_type="intro", order=0, duration=2.0), FakeClip(clip_type="main", order=1, duration=5.0), FakeClip(clip_type="outro", order=2, duration=3.0), ] result = clips_to_template_clip_configs("tmpl_001", clips) assert len(result) == 3 assert result[0].clip_type == ClipType.INTRO assert result[1].clip_type == ClipType.MAIN assert result[2].clip_type == ClipType.OUTRO assert all(r.template_id == "tmpl_001" for r in result) # ── clip_config_to_snapshot ──────────────────────────────────────────────── class TestClipConfigToSnapshot: def test_basic_snapshot(self): cfg = FakeClipConfig( clip_type="main", order=1, min_duration=2.0, max_duration=5.0, text_template="hello", transition_effect="dissolve", config={"filter": "vintage"}, ) snap = clip_config_to_snapshot(cfg) assert snap["clip_type"] == "main" assert snap["order"] == 1 assert snap["min_duration"] == 2.0 assert snap["max_duration"] == 5.0 assert snap["text_template"] == "hello" assert snap["transition_effect"] == "dissolve" assert snap["config"] == {"filter": "vintage"} def test_enum_values_converted_to_strings(self): cfg = FakeClipConfig( clip_type=ClipType.INTRO, transition_effect=TransitionEffect.FADE, ) snap = clip_config_to_snapshot(cfg) assert snap["clip_type"] == "intro" assert snap["transition_effect"] == "fade" def test_none_text_template_becomes_empty(self): cfg = FakeClipConfig(text_template=None) snap = clip_config_to_snapshot(cfg) assert snap["text_template"] == "" def test_none_config_becomes_empty_dict(self): cfg = FakeClipConfig(config=None) snap = clip_config_to_snapshot(cfg) assert snap["config"] == {} def test_config_is_copy_not_reference(self): original = {"key": "value"} cfg = FakeClipConfig(config=original) snap = clip_config_to_snapshot(cfg) snap["config"]["key"] = "modified" assert original["key"] == "value" class TestClipConfigsToSnapshots: def test_empty_list(self): assert clip_configs_to_snapshots([]) == [] def test_multiple_configs(self): configs = [ FakeClipConfig(clip_type="intro", order=0), FakeClipConfig(clip_type="main", order=1), ] result = clip_configs_to_snapshots(configs) assert len(result) == 2 assert result[0]["clip_type"] == "intro" assert result[1]["order"] == 1 # ── snapshot_to_template_clip_config ──────────────────────────────────────── class TestSnapshotToTemplateClipConfig: def test_basic_conversion(self): snap = { "clip_type": "intro", "order": 2, "min_duration": 1.0, "max_duration": 3.0, "text_template": "hi", "transition_effect": "dissolve", "config": {"filter": "bw"}, } result = snapshot_to_template_clip_config("tmpl_001", snap) assert result.template_id == "tmpl_001" assert result.clip_type == ClipType.INTRO assert result.order == 2 assert result.min_duration == 1.0 assert result.max_duration == 3.0 assert result.text_template == "hi" assert result.transition_effect == TransitionEffect.DISSOLVE assert result.config == {"filter": "bw"} def test_missing_fields_get_defaults(self): result = snapshot_to_template_clip_config("tmpl_001", {}) 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_type_falls_back(self): snap = {"clip_type": "invalid"} result = snapshot_to_template_clip_config("tmpl_001", snap) assert result.clip_type == ClipType.MAIN def test_invalid_transition_falls_back(self): snap = {"transition_effect": "invalid"} result = snapshot_to_template_clip_config("tmpl_001", snap) assert result.transition_effect == TransitionEffect.CUT def test_none_config_becomes_empty_dict(self): snap = {"config": None} result = snapshot_to_template_clip_config("tmpl_001", snap) assert result.config == {} class TestSnapshotsToTemplateClipConfigs: def test_empty_list(self): assert snapshots_to_template_clip_configs("tmpl_001", []) == [] def test_multiple_snapshots(self): snaps = [ {"clip_type": "intro", "order": 0}, {"clip_type": "outro", "order": 2}, ] result = snapshots_to_template_clip_configs("tmpl_001", snaps) assert len(result) == 2 assert result[0].clip_type == ClipType.INTRO assert result[1].clip_type == ClipType.OUTRO assert all(r.template_id == "tmpl_001" for r in result) # ── 往返一致性测试 ────────────────────────────────────────────────────────── class TestRoundTrip: def test_snapshot_clip_config_roundtrip(self): """snapshot → TemplateClipConfig → snapshot 应保持一致.""" original = { "clip_type": "intro", "order": 3, "min_duration": 1.5, "max_duration": 4.0, "text_template": "test text", "transition_effect": "dissolve", "config": {"key": "value", "nested": {"a": 1}}, } cfg = snapshot_to_template_clip_config("tmpl_test", original) result = clip_config_to_snapshot(cfg) assert result == original def test_clip_to_config_to_snapshot(self): """clip → TemplateClipConfig → snapshot 的预期结果.""" clip = FakeClip( clip_type="main", order=1, duration=5.0, text_content="hello", transition_effect="fade", playback_speed=1.5, config={"filter": "vintage", "asset_info": "should_remove"}, ) cfg = clip_to_template_clip_config("tmpl_001", clip) snap = clip_config_to_snapshot(cfg) assert snap["clip_type"] == "main" assert snap["order"] == 1 assert snap["min_duration"] == 5.0 assert snap["max_duration"] == 5.0 assert snap["text_template"] == "hello" assert snap["transition_effect"] == "fade" assert snap["config"]["playback_speed"] == 1.5 assert snap["config"]["filter"] == "vintage" assert "asset_info" not in snap["config"] # ── validate_template_name ────────────────────────────────────────────────── class TestValidateTemplateName: def test_valid_name(self): assert validate_template_name("My Template") == "My Template" def test_strips_whitespace(self): assert validate_template_name(" Hello ") == "Hello" def test_empty_string_raises(self): with pytest.raises(ValueError, match="名称不能为空"): validate_template_name("") def test_whitespace_only_raises(self): with pytest.raises(ValueError, match="名称不能为空"): validate_template_name(" ") def test_none_raises(self): with pytest.raises(ValueError, match="名称不能为空"): validate_template_name(None)