From 5baee626c9998134120b760e9796fefcb1a09804 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Tue, 28 Jul 2026 10:45:45 +0800 Subject: [PATCH] test(wave145): add template_clip_converter unit tests (+79) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - safe_parse_transition_effect: 枚举透传、有效/无效字符串、None、自定义默认、全枚举覆盖 - safe_parse_clip_type: 同上,7个用例 - _enum_value: 枚举/字符串/数字/None - filter_clip_config: None、playback_speed边界(1.0/≠1.0/None)、asset_info/source_asset_id过滤、自定义skip_keys、不可变性 - filter_plan_config_to_template: None、空、运行时字段过滤、自定义skip_keys - clip_to_template_clip_config: 基础转换、各类型、固定时长、text→text_template、config传递/过滤、缺属性默认 - clips_to_template_clip_configs: 空列表、多片段 - clip_config_to_snapshot: 枚举转字符串、空文案、None文案、config副本、None config、缺属性 - clip_configs_to_snapshots: 空列表、多配置 - snapshot_to_template_clip_config: 基础转换、缺字段默认、无效类型安全回退、config副本 - snapshots_to_template_clip_configs: 空列表、多快照 - validate_template_name: 正常、去空格/换行、空/None/纯空白、单字符、长名称 - 往返转换: snapshot→config→snapshot、config→snapshot→config 数据一致 --- tests/unit/test_template_clip_converter.py | 888 ++++++++++++++------- 1 file changed, 593 insertions(+), 295 deletions(-) diff --git a/tests/unit/test_template_clip_converter.py b/tests/unit/test_template_clip_converter.py index 6ef9c4651..e5afff919 100755 --- a/tests/unit/test_template_clip_converter.py +++ b/tests/unit/test_template_clip_converter.py @@ -1,14 +1,19 @@ -"""template_clip_converter 模块单元测试.""" +"""template_clip_converter 模板片段转换器单元测试.""" from __future__ import annotations from dataclasses import dataclass -from typing import Any +from types import SimpleNamespace import pytest -from packages.domain.template_clip_config import ClipType, TransitionEffect +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, @@ -22,459 +27,752 @@ from packages.domain.template_clip_converter import ( 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 + """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_defaults_to_cut(self): - assert safe_parse_transition_effect("invalid_effect") == TransitionEffect.CUT + def test_invalid_string_returns_default(self): + """无效字符串返回默认值.""" + result = safe_parse_transition_effect("invalid_effect") + assert result == TransitionEffect.CUT # 默认CUT - def test_none_defaults_to_cut(self): - assert safe_parse_transition_effect(None) == TransitionEffect.CUT + def test_none_returns_default(self): + """None返回默认值.""" + result = safe_parse_transition_effect(None) + assert result == TransitionEffect.CUT def test_custom_default(self): - assert safe_parse_transition_effect("bad", default=TransitionEffect.FADE) == TransitionEffect.FADE + """自定义默认值.""" + result = safe_parse_transition_effect("bad", default=TransitionEffect.FADE) + assert result == TransitionEffect.FADE - def test_int_value(self): - assert safe_parse_transition_effect(123) == TransitionEffect.CUT + def test_int_returns_default(self): + """数字类型返回默认.""" + result = safe_parse_transition_effect(123) + assert result == TransitionEffect.CUT - -# ── safe_parse_clip_type ──────────────────────────────────────────────────── + def test_all_valid_strings(self): + """所有有效字符串都能解析.""" + for te in TransitionEffect: + result = safe_parse_transition_effect(te.value) + assert result == te class TestSafeParseClipType: - def test_enum_value_passthrough(self): - assert safe_parse_clip_type(ClipType.INTRO) == ClipType.INTRO + """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_defaults_to_main(self): - assert safe_parse_clip_type("invalid_type") == ClipType.MAIN + def test_invalid_string_returns_default(self): + """无效字符串返回默认值.""" + result = safe_parse_clip_type("invalid_type") + assert result == ClipType.MAIN # 默认MAIN - def test_none_defaults_to_main(self): - assert safe_parse_clip_type(None) == ClipType.MAIN + def test_none_returns_default(self): + """None返回默认值.""" + result = safe_parse_clip_type(None) + assert result == ClipType.MAIN def test_custom_default(self): - assert safe_parse_clip_type("bad", default=ClipType.OUTRO) == ClipType.OUTRO + """自定义默认值.""" + result = safe_parse_clip_type("bad", default=ClipType.TRANSITION) + assert result == ClipType.TRANSITION - def test_int_value(self): - assert safe_parse_clip_type(42) == ClipType.MAIN + def test_all_valid_strings(self): + """所有有效字符串都能解析.""" + for ct in ClipType: + result = safe_parse_clip_type(ct.value) + assert result == ct -# ── filter_clip_config ────────────────────────────────────────────────────── +# ── _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_empty_config_no_speed(self): - result = filter_clip_config({}) - assert result == {} - - def test_playback_speed_added_when_not_default(self): + 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_playback_speed_skipped_when_default(self): - result = filter_clip_config(None, playback_speed=1.0) - assert result == {} + 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_playback_speed_none_skipped(self): - result = filter_clip_config(None, playback_speed=None) - assert result == {} + 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_config_merged(self): - result = filter_clip_config({"filter": "vintage", "intensity": 0.5}) - assert result == {"filter": "vintage", "intensity": 0.5} + 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_asset_info_removed(self): - result = filter_clip_config({"asset_info": {"name": "test.mp4"}, "filter": "vintage"}) + 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["filter"] == "vintage" + assert result["a"] == 1 + assert result["b"] == 2 - def test_source_asset_id_removed(self): - result = filter_clip_config({"source_asset_id": "abc123", "filter": "vintage"}) + 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["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 优先级更高 + assert result["other"] == "val" 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 覆盖默认 + """自定义跳过字段.""" + 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 ────────────────────────────────────────── +# ── filter_plan_config_to_template 测试 ────────────────────────────────────── class TestFilterPlanConfigToTemplate: + """filter_plan_config_to_template 测试.""" + def test_none_config(self): - assert filter_plan_config_to_template(None) == {} + """None config返回空dict.""" + result = filter_plan_config_to_template(None) + assert result == {} def test_empty_config(self): - assert filter_plan_config_to_template({}) == {} + """空dict返回空.""" + result = filter_plan_config_to_template({}) + assert result == {} - 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): + def test_removes_runtime_fields(self): + """移除运行时字段.""" config = { - "editing_mode": "pip", - "resolution": "720p", - "duration": 30, - "style": "cinematic", + "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 result == 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): - 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 + """自定义跳过字段.""" + 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 ──────────────────────────────────────────── +# ── 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 = 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" + """基础转换.""" + 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 == "Hello world" - assert result.transition_effect == TransitionEffect.DISSOLVE + assert result.text_template == "默认文案" - 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) + 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_duration_none_defaults_zero(self): - clip = FakeClip(duration=None) - result = clip_to_template_clip_config("tmpl_001", clip) + 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("tmpl_001", []) + """空列表返回空列表.""" + result = clips_to_template_clip_configs("t1", []) 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), + 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("tmpl_001", clips) + 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 all(r.template_id == "tmpl_001" for r in result) + assert result[2].order == 2 + assert all(r.template_id == "tpl_multi" for r in result) -# ── clip_config_to_snapshot ──────────────────────────────────────────────── +# ── clip_config_to_snapshot 测试 ──────────────────────────────────────────── class TestClipConfigToSnapshot: + """clip_config_to_snapshot 测试.""" + def test_basic_snapshot(self): - cfg = FakeClipConfig( + """基础快照转换.""" + cfg = TemplateClipConfig.create( + template_id="t1", clip_type="main", order=1, - min_duration=2.0, + min_duration=3.0, max_duration=5.0, text_template="hello", - transition_effect="dissolve", - config={"filter": "vintage"}, + 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"] == 2.0 + assert snap["min_duration"] == 3.0 assert snap["max_duration"] == 5.0 assert snap["text_template"] == "hello" - assert snap["transition_effect"] == "dissolve" - assert snap["config"] == {"filter": "vintage"} + assert snap["transition_effect"] == "fade" + assert snap["config"] == {"key": "val"} - def test_enum_values_converted_to_strings(self): - cfg = FakeClipConfig( - clip_type=ClipType.INTRO, - transition_effect=TransitionEffect.FADE, + 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"] == "intro" - assert snap["transition_effect"] == "fade" + assert snap["clip_type"] == "title" + assert isinstance(snap["clip_type"], str) + assert snap["transition_effect"] == "dissolve" + assert isinstance(snap["transition_effect"], str) - def test_none_text_template_becomes_empty(self): - cfg = FakeClipConfig(text_template=None) + 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_config_becomes_empty_dict(self): - cfg = FakeClipConfig(config=None) + 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_config_is_copy_not_reference(self): - original = {"key": "value"} - cfg = FakeClipConfig(config=original) + 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) - snap["config"]["key"] = "modified" - assert original["key"] == "value" + 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 = [ - FakeClipConfig(clip_type="intro", order=0), - FakeClipConfig(clip_type="main", order=1), + 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), ] - result = clip_configs_to_snapshots(configs) - assert len(result) == 2 - assert result[0]["clip_type"] == "intro" - assert result[1]["order"] == 1 + 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 ──────────────────────────────────────── +# ── 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"} + """snapshot_to_template_clip_config 测试.""" - def test_missing_fields_get_defaults(self): - result = snapshot_to_template_clip_config("tmpl_001", {}) + 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 + assert result.transition_effect == TransitionEffect.CUT # 默认cut assert result.config == {} - def test_invalid_type_falls_back(self): - snap = {"clip_type": "invalid"} - result = snapshot_to_template_clip_config("tmpl_001", snap) + 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_falls_back(self): - snap = {"transition_effect": "invalid"} - result = snapshot_to_template_clip_config("tmpl_001", snap) + 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_dict(self): + def test_none_config_becomes_empty(self): + """config为None时是空dict.""" snap = {"config": None} - result = snapshot_to_template_clip_config("tmpl_001", snap) + 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): - assert snapshots_to_template_clip_configs("tmpl_001", []) == [] + """空列表返回空列表.""" + result = snapshots_to_template_clip_configs("t1", []) + assert result == [] def test_multiple_snapshots(self): + """多个快照转换.""" snaps = [ - {"clip_type": "intro", "order": 0}, - {"clip_type": "outro", "order": 2}, + {"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("tmpl_001", snaps) - assert len(result) == 2 + 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.OUTRO - assert all(r.template_id == "tmpl_001" for r in result) + 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) -# ── 往返一致性测试 ────────────────────────────────────────────────────────── - - -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 ────────────────────────────────────────────────── +# ── validate_template_name 测试 ────────────────────────────────────────────── class TestValidateTemplateName: - def test_valid_name(self): - assert validate_template_name("My Template") == "My Template" + """validate_template_name 测试.""" + + def test_normal_name(self): + """正常名称.""" + assert validate_template_name("我的模板") == "我的模板" def test_strips_whitespace(self): - assert validate_template_name(" Hello ") == "Hello" + """去除首尾空格.""" + 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="名称不能为空"): + """空字符串抛错.""" + 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="名称不能为空"): + """纯空白抛错.""" + with pytest.raises(ValueError, match="不能为空"): validate_template_name(" ") - def test_none_raises(self): - with pytest.raises(ValueError, match="名称不能为空"): - validate_template_name(None) + 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 -- 2.54.0