diff --git a/tests/unit/domain/test_plan_generator_utils.py b/tests/unit/domain/test_plan_generator_utils.py new file mode 100755 index 000000000..23106c8d5 --- /dev/null +++ b/tests/unit/domain/test_plan_generator_utils.py @@ -0,0 +1,537 @@ +"""plan_generator_utils 单元测试 - wave166 + +覆盖: +- distribute_assets 素材分配(4种模式 + 边界) +- map_clip_types_for_mode clip类型映射(4种模式) +- generate_default_clips 默认片段生成(4种模式 + 边界) +- create_clips_from_configs 从模板配置创建 +""" + +import pytest + +from packages.domain.edit_plan_clip import EditPlanClip +from packages.domain.editing_mode import EditingMode +from packages.domain.plan_generator_utils import ( + DEFAULT_CLIP_DURATION, + create_clips_from_configs, + distribute_assets, + generate_default_clips, + map_clip_types_for_mode, +) +from packages.domain.template_clip_config import ClipType, TemplateClipConfig + +# ============================================================ +# 辅助函数 +# ============================================================ + + +def _make_main_clip(plan_id: str, order: int = 0) -> EditPlanClip: + return EditPlanClip.create( + plan_id=plan_id, + clip_type=ClipType.MAIN.value, + order=order, + duration=5.0, + ) + + +def _make_clips(plan_id: str, count: int, clip_type: str = "main") -> list[EditPlanClip]: + return [ + EditPlanClip.create( + plan_id=plan_id, + clip_type=clip_type, + order=i, + duration=5.0, + ) + for i in range(count) + ] + + +# ============================================================ +# distribute_assets - ONE_TAKE +# ============================================================ + + +class TestDistributeOneTake: + def test_equal_count(self): + clips = _make_clips("p1", 3) + assets = ["a1", "a2", "a3"] + distribute_assets(clips, assets, EditingMode.ONE_TAKE.value) + assert clips[0].asset_id == "a1" + assert clips[1].asset_id == "a2" + assert clips[2].asset_id == "a3" + + def test_more_clips_than_assets(self): + clips = _make_clips("p1", 5) + assets = ["a1", "a2"] + distribute_assets(clips, assets, EditingMode.ONE_TAKE.value) + assert clips[0].asset_id == "a1" + assert clips[1].asset_id == "a2" + assert clips[2].asset_id == "" # 没分配到 + + def test_more_assets_than_clips(self): + clips = _make_clips("p1", 2) + assets = ["a1", "a2", "a3"] + distribute_assets(clips, assets, EditingMode.ONE_TAKE.value) + assert clips[0].asset_id == "a1" + assert clips[1].asset_id == "a2" + + def test_empty_assets(self): + clips = _make_clips("p1", 3) + distribute_assets(clips, [], EditingMode.ONE_TAKE.value) + for c in clips: + assert c.asset_id == "" + + def test_empty_clips(self): + # 不报错即可 + distribute_assets([], ["a1", "a2"], EditingMode.ONE_TAKE.value) + + def test_only_main_clips_get_assigned(self): + # intro/outro 不应该被分配 + clips = [] + clips.append(EditPlanClip.create("p1", clip_type="intro", order=0, duration=3.0)) + clips.append(_make_main_clip("p1", order=1)) + clips.append(EditPlanClip.create("p1", clip_type="outro", order=2, duration=3.0)) + assets = ["a1"] + distribute_assets(clips, assets, EditingMode.ONE_TAKE.value) + assert clips[0].asset_id == "" # intro 无 + assert clips[1].asset_id == "a1" # main 有 + assert clips[2].asset_id == "" # outro 无 + + +# ============================================================ +# distribute_assets - PIP +# ============================================================ + + +class TestDistributePip: + def test_first_asset_to_main(self): + clips = _make_clips("p1", 3) + # 第一个main是背景,其余改为overlay + map_clip_types_for_mode(clips, EditingMode.PIP.value) + assets = ["a1", "a2", "a3"] + distribute_assets(clips, assets, EditingMode.PIP.value) + assert clips[0].asset_id == "a1" # main → 背景 + assert clips[1].asset_id == "a2" # overlay + assert clips[2].asset_id == "a3" # overlay + + def test_single_asset(self): + clips = _make_clips("p1", 1) + map_clip_types_for_mode(clips, EditingMode.PIP.value) + assets = ["a1"] + distribute_assets(clips, assets, EditingMode.PIP.value) + assert clips[0].asset_id == "a1" + + def test_only_main_clip_with_no_overlays(self): + clips = _make_clips("p1", 1) + map_clip_types_for_mode(clips, EditingMode.PIP.value) + assets = ["a1", "a2", "a3"] # 多余素材 + distribute_assets(clips, assets, EditingMode.PIP.value) + assert clips[0].asset_id == "a1" + + +# ============================================================ +# distribute_assets - VOICE_OVER +# ============================================================ + + +class TestDistributeVoiceOver: + def test_assets_to_main_clips(self): + clips = _make_clips("p1", 3) + assets = ["a1", "a2", "a3"] + distribute_assets(clips, assets, EditingMode.VOICE_OVER.value) + assert clips[0].asset_id == "a1" + assert clips[1].asset_id == "a2" + assert clips[2].asset_id == "a3" + + def test_more_clips_than_assets(self): + clips = _make_clips("p1", 5) + assets = ["a1", "a2"] + distribute_assets(clips, assets, EditingMode.VOICE_OVER.value) + assert clips[0].asset_id == "a1" + assert clips[1].asset_id == "a2" + assert clips[2].asset_id == "" + + +# ============================================================ +# distribute_assets - VOICE_PIP +# ============================================================ + + +class TestDistributeVoicePip: + def test_three_assets_three_roles(self): + clips = _make_clips("p1", 3) + map_clip_types_for_mode(clips, EditingMode.VOICE_PIP.value) + assets = ["a1", "a2", "a3"] + distribute_assets(clips, assets, EditingMode.VOICE_PIP.value) + assert clips[0].clip_type == "background" + assert clips[0].asset_id == "a1" + assert clips[1].clip_type == "corner_voice" + assert clips[1].asset_id == "a2" + assert clips[2].clip_type == "b_roll" + assert clips[2].asset_id == "a3" + + def test_single_asset(self): + clips = _make_clips("p1", 1) + map_clip_types_for_mode(clips, EditingMode.VOICE_PIP.value) + assets = ["a1"] + distribute_assets(clips, assets, EditingMode.VOICE_PIP.value) + assert clips[0].clip_type == "background" + assert clips[0].asset_id == "a1" + + def test_two_assets(self): + clips = _make_clips("p1", 2) + map_clip_types_for_mode(clips, EditingMode.VOICE_PIP.value) + assets = ["a1", "a2"] + distribute_assets(clips, assets, EditingMode.VOICE_PIP.value) + assert clips[0].asset_id == "a1" + assert clips[1].asset_id == "a2" + + +# ============================================================ +# distribute_assets - 边界情况 +# ============================================================ + + +class TestDistributeEdgeCases: + def test_unknown_mode_falls_back_to_one_take(self): + clips = _make_clips("p1", 2) + assets = ["a1", "a2"] + distribute_assets(clips, assets, "unknown_mode") + assert clips[0].asset_id == "a1" + assert clips[1].asset_id == "a2" + + def test_none_clips_no_crash(self): + # 空列表 + distribute_assets([], ["a1"], EditingMode.ONE_TAKE.value) + + def test_none_assets_no_crash(self): + clips = _make_clips("p1", 2) + distribute_assets(clips, [], EditingMode.ONE_TAKE.value) + for c in clips: + assert c.asset_id == "" + + +# ============================================================ +# map_clip_types_for_mode +# ============================================================ + + +class TestMapClipTypesForMode: + def test_one_take_unchanged(self): + clips = _make_clips("p1", 3) + original_types = [c.clip_type for c in clips] + map_clip_types_for_mode(clips, EditingMode.ONE_TAKE.value) + assert [c.clip_type for c in clips] == original_types + + def test_voice_over_unchanged(self): + clips = _make_clips("p1", 3) + map_clip_types_for_mode(clips, EditingMode.VOICE_OVER.value) + for c in clips: + assert c.clip_type == ClipType.MAIN.value + + def test_pip_first_stays_main_rest_overlay(self): + clips = _make_clips("p1", 4) + map_clip_types_for_mode(clips, EditingMode.PIP.value) + assert clips[0].clip_type == ClipType.MAIN.value + assert clips[1].clip_type == "overlay" + assert clips[2].clip_type == "overlay" + assert clips[3].clip_type == "overlay" + + def test_pip_single_clip_stays_main(self): + clips = _make_clips("p1", 1) + map_clip_types_for_mode(clips, EditingMode.PIP.value) + assert clips[0].clip_type == ClipType.MAIN.value + + def test_voice_pip_mapping(self): + clips = _make_clips("p1", 5) + map_clip_types_for_mode(clips, EditingMode.VOICE_PIP.value) + assert clips[0].clip_type == "background" + assert clips[1].clip_type == "corner_voice" + assert clips[2].clip_type == "b_roll" + assert clips[3].clip_type == "b_roll" + assert clips[4].clip_type == "b_roll" + + def test_voice_pip_two_clips(self): + clips = _make_clips("p1", 2) + map_clip_types_for_mode(clips, EditingMode.VOICE_PIP.value) + assert clips[0].clip_type == "background" + assert clips[1].clip_type == "corner_voice" + + def test_voice_pip_single_clip(self): + clips = _make_clips("p1", 1) + map_clip_types_for_mode(clips, EditingMode.VOICE_PIP.value) + assert clips[0].clip_type == "background" + + def test_non_main_clips_unchanged(self): + clips = [ + EditPlanClip.create("p1", clip_type="intro", order=0, duration=3.0), + _make_main_clip("p1", order=1), + EditPlanClip.create("p1", clip_type="outro", order=2, duration=3.0), + ] + map_clip_types_for_mode(clips, EditingMode.VOICE_PIP.value) + assert clips[0].clip_type == "intro" + assert clips[1].clip_type == "background" # main 被改了 + assert clips[2].clip_type == "outro" + + def test_empty_clips_no_error(self): + map_clip_types_for_mode([], EditingMode.PIP.value) + + def test_no_main_clips_no_error(self): + clips = [ + EditPlanClip.create("p1", clip_type="intro", order=0, duration=3.0), + ] + map_clip_types_for_mode(clips, EditingMode.PIP.value) + assert clips[0].clip_type == "intro" + + +# ============================================================ +# generate_default_clips +# ============================================================ + + +class TestGenerateDefaultClipsOneTake: + def test_basic(self): + clips = generate_default_clips("p1", EditingMode.ONE_TAKE.value, 3) + assert len(clips) == 3 + for c in clips: + assert c.clip_type == ClipType.MAIN.value + assert c.plan_id == "p1" + + def test_order_sequential(self): + clips = generate_default_clips("p1", EditingMode.ONE_TAKE.value, 5) + for i, c in enumerate(clips): + assert c.order == i + + def test_zero_assets_at_least_one(self): + clips = generate_default_clips("p1", EditingMode.ONE_TAKE.value, 0) + assert len(clips) == 1 + + def test_negative_assets_at_least_one(self): + clips = generate_default_clips("p1", EditingMode.ONE_TAKE.value, -5) + assert len(clips) == 1 + + def test_default_duration(self): + clips = generate_default_clips("p1", EditingMode.ONE_TAKE.value, 1) + assert clips[0].duration == DEFAULT_CLIP_DURATION + + +class TestGenerateDefaultClipsPip: + def test_one_asset(self): + clips = generate_default_clips("p1", EditingMode.PIP.value, 1) + assert len(clips) == 1 + assert clips[0].clip_type == ClipType.MAIN.value + + def test_three_assets(self): + clips = generate_default_clips("p1", EditingMode.PIP.value, 3) + assert len(clips) == 3 + assert clips[0].clip_type == ClipType.MAIN.value + assert clips[1].clip_type == "overlay" + assert clips[2].clip_type == "overlay" + + def test_zero_assets(self): + clips = generate_default_clips("p1", EditingMode.PIP.value, 0) + assert len(clips) >= 1 + assert clips[0].clip_type == ClipType.MAIN.value + + +class TestGenerateDefaultClipsVoiceOver: + def test_basic(self): + clips = generate_default_clips("p1", EditingMode.VOICE_OVER.value, 3) + assert len(clips) == 3 + for c in clips: + assert c.clip_type == ClipType.MAIN.value + + def test_has_b_roll_config(self): + clips = generate_default_clips("p1", EditingMode.VOICE_OVER.value, 2) + # VOICE_OVER 标记 role=b_roll + assert clips[0].config.get("role") == "b_roll" + + +class TestGenerateDefaultClipsVoicePip: + def test_one_asset(self): + clips = generate_default_clips("p1", EditingMode.VOICE_PIP.value, 1) + assert len(clips) == 1 + assert clips[0].clip_type == "background" + + def test_two_assets(self): + clips = generate_default_clips("p1", EditingMode.VOICE_PIP.value, 2) + assert len(clips) == 2 + assert clips[0].clip_type == "background" + assert clips[1].clip_type == "corner_voice" + + def test_five_assets(self): + clips = generate_default_clips("p1", EditingMode.VOICE_PIP.value, 5) + assert len(clips) == 5 + assert clips[0].clip_type == "background" + assert clips[1].clip_type == "corner_voice" + assert clips[2].clip_type == "b_roll" + assert clips[3].clip_type == "b_roll" + assert clips[4].clip_type == "b_roll" + + def test_zero_assets(self): + clips = generate_default_clips("p1", EditingMode.VOICE_PIP.value, 0) + assert len(clips) >= 1 + assert clips[0].clip_type == "background" + + +class TestGenerateDefaultClipsUnknownMode: + def test_falls_back_to_one_take(self): + clips = generate_default_clips("p1", "unknown_mode", 3) + assert len(clips) == 3 + for c in clips: + assert c.clip_type == ClipType.MAIN.value + + +# ============================================================ +# create_clips_from_configs +# ============================================================ + + +def _make_template_config( + cfg_id: str, + order: int, + clip_type: ClipType = ClipType.MAIN, + min_dur: float = 0, + max_dur: float = 0, +) -> TemplateClipConfig: + return TemplateClipConfig( + id=cfg_id, + template_id="t1", + clip_type=clip_type, + order=order, + min_duration=min_dur, + max_duration=max_dur, + transition_effect="cut", + config={}, + ) + + +class TestCreateClipsFromConfigs: + def test_empty_configs(self): + result = create_clips_from_configs("p1", []) + assert result == [] + + def test_single_config(self): + configs = [_make_template_config("c1", 0, min_dur=3.0, max_dur=7.0)] + result = create_clips_from_configs("p1", configs) + assert len(result) == 1 + assert result[0].plan_id == "p1" + assert result[0].template_clip_config_id == "c1" + # 平均时长 = (3+7)/2 = 5.0 + assert result[0].duration == pytest.approx(5.0) + + def test_duration_min_only(self): + configs = [_make_template_config("c1", 0, min_dur=4.0)] + result = create_clips_from_configs("p1", configs) + assert result[0].duration == 4.0 + + def test_duration_max_only(self): + configs = [_make_template_config("c1", 0, max_dur=6.0)] + result = create_clips_from_configs("p1", configs) + assert result[0].duration == 6.0 + + def test_duration_default_when_no_bounds(self): + configs = [_make_template_config("c1", 0)] + result = create_clips_from_configs("p1", configs) + assert result[0].duration == DEFAULT_CLIP_DURATION + + def test_sorted_by_order(self): + configs = [ + _make_template_config("c_third", 2), + _make_template_config("c_first", 0), + _make_template_config("c_second", 1), + ] + result = create_clips_from_configs("p1", configs) + assert len(result) == 3 + assert result[0].template_clip_config_id == "c_first" + assert result[1].template_clip_config_id == "c_second" + assert result[2].template_clip_config_id == "c_third" + assert result[0].order == 0 + assert result[1].order == 1 + assert result[2].order == 2 + + def test_clip_type_preserved(self): + configs = [ + TemplateClipConfig( + id="c_intro", + template_id="t1", + clip_type=ClipType.INTRO, + order=0, + min_duration=3.0, + max_duration=3.0, + transition_effect="cut", + config={}, + ), + TemplateClipConfig( + id="c_main", + template_id="t1", + clip_type=ClipType.MAIN, + order=1, + min_duration=5.0, + max_duration=5.0, + transition_effect="cut", + config={}, + ), + ] + result = create_clips_from_configs("p1", configs) + assert result[0].clip_type == ClipType.INTRO.value + assert result[1].clip_type == ClipType.MAIN.value + + def test_playback_speed_from_config(self): + configs = [ + TemplateClipConfig( + id="c1", + template_id="t1", + clip_type=ClipType.MAIN, + order=0, + min_duration=5.0, + max_duration=5.0, + transition_effect="cut", + config={"playback_speed": 1.5}, + ) + ] + result = create_clips_from_configs("p1", configs) + assert result[0].playback_speed == pytest.approx(1.5) + + def test_speed_ratio_fallback(self): + # 兼容 speed_ratio 字段名 + configs = [ + TemplateClipConfig( + id="c1", + template_id="t1", + clip_type=ClipType.MAIN, + order=0, + min_duration=5.0, + max_duration=5.0, + transition_effect="cut", + config={"speed_ratio": 0.8}, + ) + ] + result = create_clips_from_configs("p1", configs) + assert result[0].playback_speed == pytest.approx(0.8) + + def test_default_playback_speed(self): + configs = [_make_template_config("c1", 0, min_dur=5.0, max_dur=5.0)] + result = create_clips_from_configs("p1", configs) + assert result[0].playback_speed == pytest.approx(1.0) + + def test_transition_effect_preserved(self): + configs = [ + TemplateClipConfig( + id="c1", + template_id="t1", + clip_type=ClipType.MAIN, + order=0, + min_duration=5.0, + max_duration=5.0, + transition_effect="fade", + config={}, + ) + ] + result = create_clips_from_configs("p1", configs) + assert result[0].transition_effect == "fade" + + def test_returns_edit_plan_clip_objects(self): + configs = [_make_template_config("c1", 0, min_dur=3.0, max_dur=5.0)] + result = create_clips_from_configs("p1", configs) + assert isinstance(result[0], EditPlanClip)