"""template_clip_converter 模板片段转换器单元测试.""" from __future__ import annotations from dataclasses import dataclass from types import SimpleNamespace import pytest from packages.domain.template_clip_config import ( ClipType, TemplateClipConfig, TransitionEffect, ) from packages.domain.template_clip_converter import ( _enum_value, 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: """safe_parse_transition_effect 测试.""" 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): """有效字符串解析.""" assert safe_parse_transition_effect("fade") == TransitionEffect.FADE assert safe_parse_transition_effect("cut") == TransitionEffect.CUT assert safe_parse_transition_effect("dissolve") == TransitionEffect.DISSOLVE def test_invalid_string_returns_default(self): """无效字符串返回默认值.""" result = safe_parse_transition_effect("invalid_effect") assert result == TransitionEffect.CUT # 默认CUT def test_none_returns_default(self): """None返回默认值.""" result = safe_parse_transition_effect(None) assert result == TransitionEffect.CUT def test_custom_default(self): """自定义默认值.""" result = safe_parse_transition_effect("bad", default=TransitionEffect.FADE) assert result == TransitionEffect.FADE def test_int_returns_default(self): """数字类型返回默认.""" result = safe_parse_transition_effect(123) assert result == TransitionEffect.CUT def test_all_valid_strings(self): """所有有效字符串都能解析.""" for te in TransitionEffect: result = safe_parse_transition_effect(te.value) assert result == te class TestSafeParseClipType: """safe_parse_clip_type 测试.""" def test_enum_passthrough(self): """传入枚举直接返回.""" result = safe_parse_clip_type(ClipType.INTRO) assert result == ClipType.INTRO assert isinstance(result, ClipType) def test_valid_string(self): """有效字符串解析.""" assert safe_parse_clip_type("intro") == ClipType.INTRO assert safe_parse_clip_type("main") == ClipType.MAIN assert safe_parse_clip_type("outro") == ClipType.OUTRO assert safe_parse_clip_type("title") == ClipType.TITLE def test_invalid_string_returns_default(self): """无效字符串返回默认值.""" result = safe_parse_clip_type("invalid_type") assert result == ClipType.MAIN # 默认MAIN def test_none_returns_default(self): """None返回默认值.""" result = safe_parse_clip_type(None) assert result == ClipType.MAIN def test_custom_default(self): """自定义默认值.""" result = safe_parse_clip_type("bad", default=ClipType.TRANSITION) assert result == ClipType.TRANSITION def test_all_valid_strings(self): """所有有效字符串都能解析.""" for ct in ClipType: result = safe_parse_clip_type(ct.value) assert result == ct # ── _enum_value 内部函数测试 ───────────────────────────────────────────────── class TestEnumValue: """_enum_value 内部工具函数测试.""" def test_enum_value(self): """枚举返回value.""" assert _enum_value(TransitionEffect.FADE) == "fade" assert _enum_value(ClipType.MAIN) == "main" def test_string_passthrough(self): """字符串直接返回.""" assert _enum_value("hello") == "hello" def test_int_passthrough(self): """数字直接返回.""" assert _enum_value(42) == 42 def test_none_passthrough(self): """None直接返回.""" assert _enum_value(None) is None # ── filter_clip_config 测试 ────────────────────────────────────────────────── class TestFilterClipConfig: """filter_clip_config 测试.""" def test_none_config_no_speed(self): """None config + 无playback_speed.""" result = filter_clip_config(None) assert result == {} def test_none_config_with_speed(self): """None config + 有playback_speed.""" result = filter_clip_config(None, playback_speed=1.5) assert result == {"playback_speed": 1.5} def test_speed_equals_one_not_included(self): """playback_speed=1.0不写入.""" result = filter_clip_config({"key": "val"}, playback_speed=1.0) assert "playback_speed" not in result assert result["key"] == "val" def test_speed_not_one_included(self): """playback_speed≠1.0写入.""" result = filter_clip_config({"key": "val"}, playback_speed=0.8) assert result["playback_speed"] == 0.8 assert result["key"] == "val" def test_speed_none_not_included(self): """playback_speed=None不写入.""" result = filter_clip_config({"key": "val"}, playback_speed=None) assert "playback_speed" not in result def test_removes_asset_info(self): """移除asset_info字段.""" config = {"a": 1, "asset_info": {"name": "x"}, "b": 2} result = filter_clip_config(config) assert "asset_info" not in result assert result["a"] == 1 assert result["b"] == 2 def test_removes_source_asset_id(self): """移除source_asset_id字段.""" config = {"source_asset_id": "abc", "other": "val"} result = filter_clip_config(config) assert "source_asset_id" not in result assert result["other"] == "val" def test_custom_skip_keys(self): """自定义跳过字段.""" config = {"keep": "yes", "skip_me": "no", "also_skip": "no"} skip = frozenset({"skip_me", "also_skip"}) result = filter_clip_config(config, skip_keys=skip) assert "keep" in result assert "skip_me" not in result assert "also_skip" not in result def test_empty_config(self): """空dict config.""" result = filter_clip_config({}) assert result == {} def test_does_not_mutate_original(self): """不修改原始config.""" original = {"a": 1, "asset_info": "x"} original_copy = dict(original) filter_clip_config(original) assert original == original_copy def test_speed_overrides_config(self): """playback_speed 覆盖 clip_config 中的值.""" result = filter_clip_config({"playback_speed": 2.0}, playback_speed=0.5) # clip_config是后面合并的,所以会覆盖前面的playback_speed assert result["playback_speed"] == 2.0 # ── filter_plan_config_to_template 测试 ────────────────────────────────────── class TestFilterPlanConfigToTemplate: """filter_plan_config_to_template 测试.""" def test_none_config(self): """None config返回空dict.""" result = filter_plan_config_to_template(None) assert result == {} def test_empty_config(self): """空dict返回空.""" result = filter_plan_config_to_template({}) assert result == {} def test_removes_runtime_fields(self): """移除运行时字段.""" config = { "keep": "yes", "is_template_draft": True, "asset_ids": ["a", "b"], "source_edit_plan_id": "plan_123", "generation_task_id": "task_456", "another_field": "also_keep", } result = filter_plan_config_to_template(config) assert "keep" in result assert "another_field" in result 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 def test_custom_skip_keys(self): """自定义跳过字段.""" config = {"keep": "yes", "skip_a": "no", "skip_b": "no"} skip = frozenset({"skip_a", "skip_b"}) result = filter_plan_config_to_template(config, skip_keys=skip) assert "keep" in result assert "skip_a" not in result assert "skip_b" not in result def test_preserves_values(self): """保留字段的值不变.""" config = {"title": "测试", "duration": 10.5, "items": [1, 2, 3]} result = filter_plan_config_to_template(config) assert result["title"] == "测试" assert result["duration"] == 10.5 assert result["items"] == [1, 2, 3] # ── clip_to_template_clip_config 测试 ──────────────────────────────────────── class TestClipToTemplateClipConfig: """clip_to_template_clip_config 测试.""" def _make_clip(self, **kwargs): """创建模拟clip对象.""" defaults = { "clip_type": "main", "order": 1, "duration": 5.0, "text_content": "默认文案", "transition_effect": "cut", "playback_speed": 1.0, "config": {}, } defaults.update(kwargs) return SimpleNamespace(**defaults) def test_basic_conversion(self): """基础转换.""" clip = self._make_clip(order=2, duration=3.5) result = clip_to_template_clip_config("tpl_001", clip) assert isinstance(result, TemplateClipConfig) assert result.template_id == "tpl_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 == "默认文案" def test_intro_clip_type(self): """intro类型转换.""" clip = self._make_clip(clip_type="intro", order=0) result = clip_to_template_clip_config("t1", clip) assert result.clip_type == ClipType.INTRO def test_transition_effect(self): """转场效果转换.""" clip = self._make_clip(transition_effect="fade") result = clip_to_template_clip_config("t1", clip) assert result.transition_effect == TransitionEffect.FADE def test_fixed_duration(self): """固定时长:min=max=duration.""" clip = self._make_clip(duration=7.2) result = clip_to_template_clip_config("t1", clip) assert result.min_duration == 7.2 assert result.max_duration == 7.2 assert result.min_duration == result.max_duration def test_zero_duration(self): """duration为0.""" clip = self._make_clip(duration=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(self): """duration为None时默认0.""" clip = self._make_clip(duration=None) result = clip_to_template_clip_config("t1", clip) assert result.min_duration == 0.0 def test_text_content_mapped_to_text_template(self): """text_content → text_template.""" clip = self._make_clip(text_content="你好世界") result = clip_to_template_clip_config("t1", clip) assert result.text_template == "你好世界" def test_none_text_content(self): """text_content为None时默认空字符串.""" clip = self._make_clip(text_content=None) result = clip_to_template_clip_config("t1", clip) assert result.text_template == "" def test_playback_speed_in_config(self): """非1.0的playback_speed存入config.""" clip = self._make_clip(playback_speed=1.5) result = clip_to_template_clip_config("t1", clip) assert result.config["playback_speed"] == 1.5 def test_playback_speed_one_not_in_config(self): """playback_speed=1.0不存入config.""" clip = self._make_clip(playback_speed=1.0) result = clip_to_template_clip_config("t1", clip) assert "playback_speed" not in result.config def test_config_passed_through(self): """clip.config传递到结果.""" clip = self._make_clip(config={"font_size": 24, "color": "red"}) result = clip_to_template_clip_config("t1", clip) assert result.config["font_size"] == 24 assert result.config["color"] == "red" def test_config_filters_asset_fields(self): """config中过滤素材字段.""" clip = self._make_clip(config={"asset_info": {"x": 1}, "keep": "yes"}) result = clip_to_template_clip_config("t1", clip) assert "asset_info" not in result.config assert result.config["keep"] == "yes" def test_invalid_clip_type_defaults_to_main(self): """无效clip_type安全解析为main.""" clip = self._make_clip(clip_type="unknown_type") result = clip_to_template_clip_config("t1", clip) assert result.clip_type == ClipType.MAIN def test_clip_without_attributes(self): """缺少属性的对象使用默认值.""" @dataclass class MinimalClip: order: int = 0 clip = MinimalClip(order=5) result = clip_to_template_clip_config("t1", clip) assert result.clip_type == ClipType.MAIN # 默认 assert result.order == 5 assert result.text_template == "" assert result.transition_effect == TransitionEffect.CUT class TestClipsToTemplateClipConfigs: """clips_to_template_clip_configs 批量转换测试.""" def test_empty_list(self): """空列表返回空列表.""" result = clips_to_template_clip_configs("t1", []) assert result == [] def test_multiple_clips(self): """多个片段转换.""" clips = [ SimpleNamespace( clip_type="intro", order=0, duration=2.0, text_content="", transition_effect="fade", playback_speed=1.0, config={}, ), SimpleNamespace( clip_type="main", order=1, duration=5.0, text_content="主体", transition_effect="cut", playback_speed=1.0, config={}, ), SimpleNamespace( clip_type="outro", order=2, duration=3.0, text_content="结束", transition_effect="fade", playback_speed=1.0, config={}, ), ] result = clips_to_template_clip_configs("tpl_multi", clips) assert len(result) == 3 assert result[0].clip_type == ClipType.INTRO assert result[0].order == 0 assert result[1].clip_type == ClipType.MAIN assert result[1].order == 1 assert result[2].clip_type == ClipType.OUTRO assert result[2].order == 2 assert all(r.template_id == "tpl_multi" for r in result) # ── clip_config_to_snapshot 测试 ──────────────────────────────────────────── class TestClipConfigToSnapshot: """clip_config_to_snapshot 测试.""" def test_basic_snapshot(self): """基础快照转换.""" cfg = TemplateClipConfig.create( template_id="t1", clip_type="main", order=1, min_duration=3.0, max_duration=5.0, text_template="hello", transition_effect="fade", config={"key": "val"}, ) snap = clip_config_to_snapshot(cfg) assert snap["clip_type"] == "main" assert snap["order"] == 1 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"] == {"key": "val"} def test_enum_values_in_snapshot(self): """枚举转为字符串value.""" cfg = TemplateClipConfig.create( template_id="t1", clip_type=ClipType.TITLE, order=0, transition_effect=TransitionEffect.DISSOLVE, ) snap = clip_config_to_snapshot(cfg) assert snap["clip_type"] == "title" assert isinstance(snap["clip_type"], str) assert snap["transition_effect"] == "dissolve" assert isinstance(snap["transition_effect"], str) def test_empty_text_template(self): """空文案模板.""" cfg = TemplateClipConfig.create( template_id="t1", clip_type="main", order=0, text_template="", ) snap = clip_config_to_snapshot(cfg) assert snap["text_template"] == "" def test_none_text_template_becomes_empty(self): """text_template为None时返回空串.""" # TemplateClipConfig的text_template默认是空串,模拟一个有None属性的对象 obj = SimpleNamespace( clip_type=ClipType.MAIN, order=0, min_duration=1.0, max_duration=2.0, text_template=None, transition_effect=TransitionEffect.CUT, config={}, ) snap = clip_config_to_snapshot(obj) assert snap["text_template"] == "" def test_config_is_copy(self): """config是副本,不是原对象引用.""" original_config = {"a": 1} cfg = TemplateClipConfig.create( template_id="t1", clip_type="main", order=0, config=original_config, ) snap = clip_config_to_snapshot(cfg) snap["config"]["b"] = 2 assert "b" not in cfg.config assert original_config.get("b") is None def test_none_config_becomes_empty(self): """config为None时返回空dict.""" obj = SimpleNamespace( clip_type="main", order=0, min_duration=0, max_duration=0, text_template="", transition_effect="cut", config=None, ) snap = clip_config_to_snapshot(obj) assert snap["config"] == {} def test_default_values_for_missing_attrs(self): """缺少属性时用默认值.""" obj = SimpleNamespace() snap = clip_config_to_snapshot(obj) assert snap["clip_type"] is None # _enum_value(None)=None assert snap["order"] == 0 assert snap["min_duration"] == 0.0 assert snap["max_duration"] == 0.0 assert snap["text_template"] == "" assert snap["transition_effect"] is None assert snap["config"] == {} def test_snapshot_keys(self): """快照包含所有预期字段.""" cfg = TemplateClipConfig.create( template_id="t1", clip_type="main", order=0, ) snap = clip_config_to_snapshot(cfg) expected_keys = { "clip_type", "order", "min_duration", "max_duration", "text_template", "transition_effect", "config", } assert set(snap.keys()) == expected_keys class TestClipConfigsToSnapshots: """clip_configs_to_snapshots 批量测试.""" def test_empty_list(self): """空列表返回空列表.""" assert clip_configs_to_snapshots([]) == [] def test_multiple_configs(self): """多个配置转换.""" configs = [ TemplateClipConfig.create("t1", "intro", 0, min_duration=2.0, max_duration=2.0), TemplateClipConfig.create("t1", "main", 1, min_duration=3.0, max_duration=5.0), TemplateClipConfig.create("t1", "outro", 2, min_duration=1.0, max_duration=1.0), ] snaps = clip_configs_to_snapshots(configs) assert len(snaps) == 3 assert snaps[0]["clip_type"] == "intro" assert snaps[1]["clip_type"] == "main" assert snaps[2]["clip_type"] == "outro" # ── snapshot_to_template_clip_config 测试 ──────────────────────────────────── class TestSnapshotToTemplateClipConfig: """snapshot_to_template_clip_config 测试.""" def test_basic_conversion(self): """基础快照→配置转换.""" snap = { "clip_type": "main", "order": 2, "min_duration": 3.0, "max_duration": 5.0, "text_template": "hello", "transition_effect": "fade", "config": {"key": "val"}, } result = snapshot_to_template_clip_config("tpl_new", snap) assert isinstance(result, TemplateClipConfig) assert result.template_id == "tpl_new" assert result.clip_type == ClipType.MAIN assert result.order == 2 assert result.min_duration == 3.0 assert result.max_duration == 5.0 assert result.text_template == "hello" assert result.transition_effect == TransitionEffect.FADE assert result.config == {"key": "val"} def test_defaults_for_missing_keys(self): """缺失字段使用默认值.""" snap: dict = {} result = snapshot_to_template_clip_config("t1", snap) assert result.clip_type == ClipType.MAIN # 默认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 # 默认cut assert result.config == {} def test_intro_type(self): """intro类型解析.""" snap = {"clip_type": "intro", "order": 0} result = snapshot_to_template_clip_config("t1", snap) assert result.clip_type == ClipType.INTRO def test_invalid_clip_type_defaults(self): """无效clip_type安全回退到main.""" snap = {"clip_type": "weird_type", "order": 0} result = snapshot_to_template_clip_config("t1", snap) assert result.clip_type == ClipType.MAIN def test_invalid_transition_defaults(self): """无效transition_effect安全回退到cut.""" snap = {"transition_effect": "invalid_fx"} result = snapshot_to_template_clip_config("t1", snap) assert result.transition_effect == TransitionEffect.CUT def test_none_config_becomes_empty(self): """config为None时是空dict.""" snap = {"config": None} result = snapshot_to_template_clip_config("t1", snap) assert result.config == {} def test_config_is_copy(self): """config是副本.""" original_config = {"a": 1} snap = {"config": original_config} result = snapshot_to_template_clip_config("t1", snap) result.config["b"] = 2 assert "b" not in original_config class TestSnapshotsToTemplateClipConfigs: """snapshots_to_template_clip_configs 批量测试.""" def test_empty_list(self): """空列表返回空列表.""" result = snapshots_to_template_clip_configs("t1", []) assert result == [] def test_multiple_snapshots(self): """多个快照转换.""" snaps = [ {"clip_type": "intro", "order": 0, "min_duration": 2.0, "max_duration": 2.0}, {"clip_type": "main", "order": 1, "min_duration": 5.0, "max_duration": 5.0}, {"clip_type": "outro", "order": 2, "min_duration": 3.0, "max_duration": 3.0}, ] result = snapshots_to_template_clip_configs("tpl_multi", snaps) 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 == "tpl_multi" for r in result) # ── validate_template_name 测试 ────────────────────────────────────────────── class TestValidateTemplateName: """validate_template_name 测试.""" def test_normal_name(self): """正常名称.""" assert validate_template_name("我的模板") == "我的模板" def test_strips_whitespace(self): """去除首尾空格.""" assert validate_template_name(" 测试模板 ") == "测试模板" def test_strips_newlines(self): """去除换行.""" assert validate_template_name("\n模板名\n") == "模板名" def test_empty_string_raises(self): """空字符串抛错.""" with pytest.raises(ValueError, match="不能为空"): validate_template_name("") def test_none_raises(self): """None抛错.""" with pytest.raises(ValueError, match="不能为空"): validate_template_name(None) def test_whitespace_only_raises(self): """纯空白抛错.""" with pytest.raises(ValueError, match="不能为空"): validate_template_name(" ") def test_newline_only_raises(self): """纯换行抛错.""" with pytest.raises(ValueError, match="不能为空"): validate_template_name("\n\t\r") def test_single_char_name(self): """单字符名称有效.""" assert validate_template_name("A") == "A" def test_long_name_preserved(self): """长名称保留(长度限制由调用方控制).""" long_name = "A" * 200 assert validate_template_name(long_name) == long_name # ── 往返转换测试 ────────────────────────────────────────────────────────────── class TestRoundTripConversions: """往返转换一致性测试.""" def test_snapshot_round_trip(self): """snapshot → config → snapshot 数据一致.""" original = { "clip_type": "main", "order": 5, "min_duration": 2.5, "max_duration": 7.5, "text_template": "测试文案", "transition_effect": "dissolve", "config": {"speed": 1.2, "theme": "dark"}, } cfg = snapshot_to_template_clip_config("t1", original) snap = clip_config_to_snapshot(cfg) assert snap["clip_type"] == original["clip_type"] assert snap["order"] == original["order"] assert snap["min_duration"] == original["min_duration"] assert snap["max_duration"] == original["max_duration"] assert snap["text_template"] == original["text_template"] assert snap["transition_effect"] == original["transition_effect"] assert snap["config"] == original["config"] def test_config_snapshot_round_trip(self): """config → snapshot → config 数据一致.""" cfg = TemplateClipConfig.create( template_id="t1", clip_type="title", order=0, min_duration=3.0, max_duration=3.0, text_template="标题", transition_effect="wipe", config={"font_size": 32}, ) snap = clip_config_to_snapshot(cfg) restored = snapshot_to_template_clip_config("t1", snap) assert restored.clip_type == cfg.clip_type assert restored.order == cfg.order assert restored.min_duration == cfg.min_duration assert restored.max_duration == cfg.max_duration assert restored.text_template == cfg.text_template assert restored.transition_effect == cfg.transition_effect assert restored.config == cfg.config