Files
xiaoxia-saas/tests/unit/test_template_clip_converter.py

481 lines
18 KiB
Python
Executable File

"""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)