test(wave175): template_clip_converter 模板片段转换 +64测 #1137

Closed
xiaoxia wants to merge 1 commits from test/wave175-template-clip-converter into develop
+496
View File
@@ -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")