481 lines
18 KiB
Python
Executable File
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)
|