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