"""plan_generator_utils 纯逻辑单测 — 第90波. 测试素材分配、clip_type映射、默认clip生成、配置转clip等纯函数。 不依赖 DB,使用领域对象直接构造。 """ 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, _calc_random_start_time, 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 = "plan1", order: int = 0) -> EditPlanClip: """创建一个 MAIN 类型的 clip.""" return EditPlanClip.create( plan_id=plan_id, clip_type=ClipType.MAIN.value, order=order, duration=5.0, ) def _make_clips(n: int, clip_type: str = "main") -> list[EditPlanClip]: """创建 n 个指定类型的 clip.""" return [ EditPlanClip.create( plan_id="plan1", clip_type=clip_type, order=i, duration=5.0, ) for i in range(n) ] def _collect_asset_ids(clips: list[EditPlanClip]) -> list[str]: """按顺序收集 clips 的 asset_id(空的跳过).""" return [c.asset_id for c in clips if c.asset_id] # ── distribute_assets: ONE_TAKE ───────────────────────────────────── class TestDistributeOneTake: """ONE_TAKE 模式素材分配.""" def test_equal_count(self): """素材数 == clip 数:一一对应.""" clips = _make_clips(3) distribute_assets(clips, ["a1", "a2", "a3"], 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_assets_than_clips(self): """素材多于 clip:多余的不用.""" clips = _make_clips(2) distribute_assets(clips, ["a1", "a2", "a3"], EditingMode.ONE_TAKE.value) assert clips[0].asset_id == "a1" assert clips[1].asset_id == "a2" def test_fewer_assets_than_clips(self): """素材少于 clip:后面的 clip 没素材.""" clips = _make_clips(5) distribute_assets(clips, ["a1", "a2"], EditingMode.ONE_TAKE.value) assert clips[0].asset_id == "a1" assert clips[1].asset_id == "a2" assert clips[2].asset_id == "" assert clips[3].asset_id == "" assert clips[4].asset_id == "" def test_empty_assets(self): """空素材列表:无分配.""" clips = _make_clips(3) distribute_assets(clips, [], EditingMode.ONE_TAKE.value) for c in clips: assert c.asset_id == "" def test_empty_clips(self): """空 clip 列表:不报错.""" distribute_assets([], ["a1"], EditingMode.ONE_TAKE.value) def test_only_main_clips_get_assigned(self): """只分配给 MAIN 类型 clip,其他类型不受影响.""" clips = _make_clips(2) + _make_clips(2, "intro") + _make_clips(2, "outro") distribute_assets(clips, ["a1", "a2", "a3", "a4"], EditingMode.ONE_TAKE.value) mains = [c for c in clips if c.clip_type == "main"] others = [c for c in clips if c.clip_type != "main"] assert mains[0].asset_id == "a1" assert mains[1].asset_id == "a2" for c in others: assert c.asset_id == "" # ── distribute_assets: PIP ────────────────────────────────────────── class TestDistributePip: """PIP 模式素材分配.""" def test_basic_pip_distribution(self): """第1个素材给 main,其余给 overlay.""" clips = _make_clips(1) + _make_clips(3, "overlay") distribute_assets(clips, ["a1", "a2", "a3", "a4"], EditingMode.PIP.value) mains = [c for c in clips if c.clip_type == "main"] overlays = [c for c in clips if c.clip_type == "overlay"] assert mains[0].asset_id == "a1" assert overlays[0].asset_id == "a2" assert overlays[1].asset_id == "a3" assert overlays[2].asset_id == "a4" def test_single_asset_only_main(self): """只有1个素材:只分配给 main,overlay 没素材.""" clips = _make_clips(1) + _make_clips(2, "overlay") distribute_assets(clips, ["a1"], EditingMode.PIP.value) mains = [c for c in clips if c.clip_type == "main"] overlays = [c for c in clips if c.clip_type == "overlay"] assert mains[0].asset_id == "a1" assert overlays[0].asset_id == "" assert overlays[1].asset_id == "" def test_more_overlays_than_assets(self): """overlay 多于剩余素材:后面的 overlay 没素材.""" clips = _make_clips(1) + _make_clips(5, "overlay") distribute_assets(clips, ["a1", "a2", "a3"], EditingMode.PIP.value) overlays = [c for c in clips if c.clip_type == "overlay"] assert overlays[0].asset_id == "a2" assert overlays[1].asset_id == "a3" assert overlays[2].asset_id == "" assert overlays[3].asset_id == "" assert overlays[4].asset_id == "" def test_no_main_clip(self): """没有 main clip:第1个素材没人拿,overlay 从第2个素材开始.""" clips = _make_clips(3, "overlay") distribute_assets(clips, ["a1", "a2", "a3"], EditingMode.PIP.value) overlays = [c for c in clips if c.clip_type == "overlay"] # PIP 逻辑:先给 main 分配第1个素材(没有 main 则跳过), # 剩余从第2个开始分配给 overlay assert overlays[0].asset_id == "a2" assert overlays[1].asset_id == "a3" assert overlays[2].asset_id == "" # ── distribute_assets: VOICE_OVER ─────────────────────────────────── class TestDistributeVoiceOver: """VOICE_OVER 模式素材分配.""" def test_voice_over_same_as_one_take(self): """VOICE_OVER 和 ONE_TAKE 分配策略相同:按顺序给 main.""" clips = _make_clips(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_voice_over_fewer_assets(self): """素材不足时,后面的 main clip 没素材.""" clips = _make_clips(5) distribute_assets(clips, ["a1"], EditingMode.VOICE_OVER.value) assert clips[0].asset_id == "a1" assert clips[1].asset_id == "" # ── distribute_assets: VOICE_PIP ──────────────────────────────────── class TestDistributeVoicePip: """VOICE_PIP 模式素材分配.""" def test_three_assets_full_distribution(self): """3个素材:background + corner_voice + b_roll 各一个.""" clips = _make_clips(1, "background") + _make_clips(1, "corner_voice") + _make_clips(1, "b_roll") distribute_assets(clips, ["a1", "a2", "a3"], EditingMode.VOICE_PIP.value) bgs = [c for c in clips if c.clip_type == "background"] voices = [c for c in clips if c.clip_type == "corner_voice"] brolls = [c for c in clips if c.clip_type == "b_roll"] assert bgs[0].asset_id == "a1" assert voices[0].asset_id == "a2" assert brolls[0].asset_id == "a3" def test_single_asset_only_background(self): """1个素材:只分配给 background.""" clips = _make_clips(1, "background") + _make_clips(1, "corner_voice") + _make_clips(2, "b_roll") distribute_assets(clips, ["a1"], EditingMode.VOICE_PIP.value) assert clips[0].asset_id == "a1" assert clips[1].asset_id == "" assert clips[2].asset_id == "" assert clips[3].asset_id == "" def test_two_assets_bg_and_voice(self): """2个素材:background + corner_voice.""" clips = _make_clips(1, "background") + _make_clips(1, "corner_voice") + _make_clips(2, "b_roll") distribute_assets(clips, ["a1", "a2"], EditingMode.VOICE_PIP.value) bgs = [c for c in clips if c.clip_type == "background"] voices = [c for c in clips if c.clip_type == "corner_voice"] brolls = [c for c in clips if c.clip_type == "b_roll"] assert bgs[0].asset_id == "a1" assert voices[0].asset_id == "a2" assert brolls[0].asset_id == "" def test_many_broll_clips(self): """多个 b_roll clip:按顺序分配剩余素材.""" clips = _make_clips(1, "background") + _make_clips(1, "corner_voice") + _make_clips(5, "b_roll") distribute_assets( clips, ["a1", "a2", "a3", "a4", "a5"], EditingMode.VOICE_PIP.value, ) brolls = [c for c in clips if c.clip_type == "b_roll"] assert brolls[0].asset_id == "a3" assert brolls[1].asset_id == "a4" assert brolls[2].asset_id == "a5" assert brolls[3].asset_id == "" assert brolls[4].asset_id == "" def test_missing_some_layer_clips(self): """缺少某些层的 clip 不影响其他层.""" # 没有 corner_voice,素材应该按顺序:bg 拿 a1,b_roll 从 a2 开始 clips = _make_clips(1, "background") + _make_clips(3, "b_roll") distribute_assets(clips, ["a1", "a2", "a3"], EditingMode.VOICE_PIP.value) bgs = [c for c in clips if c.clip_type == "background"] brolls = [c for c in clips if c.clip_type == "b_roll"] assert bgs[0].asset_id == "a1" # 没有 corner_voice,b_roll 从第2个素材开始 assert brolls[0].asset_id == "a2" assert brolls[1].asset_id == "a3" # ── distribute_assets: 边缘情况 ───────────────────────────────────── class TestDistributeEdgeCases: """素材分配边缘情况.""" def test_unknown_mode_falls_back_to_one_take(self): """未知模式退化为 ONE_TAKE.""" clips = _make_clips(3) distribute_assets(clips, ["a1", "a2", "a3"], "unknown_mode") assert clips[0].asset_id == "a1" assert clips[1].asset_id == "a2" assert clips[2].asset_id == "a3" def test_both_empty(self): """两边都空:不报错.""" distribute_assets([], [], EditingMode.ONE_TAKE.value) # ── map_clip_types_for_mode ───────────────────────────────────────── class TestMapClipTypesForMode: """clip_type 按模式映射.""" def test_one_take_unchanged(self): """ONE_TAKE 模式:main 保持 main.""" clips = _make_clips(5) map_clip_types_for_mode(clips, EditingMode.ONE_TAKE.value) for c in clips: assert c.clip_type == "main" def test_voice_over_unchanged(self): """VOICE_OVER 模式:main 保持 main.""" clips = _make_clips(5) map_clip_types_for_mode(clips, EditingMode.VOICE_OVER.value) for c in clips: assert c.clip_type == "main" def test_pip_first_main_stays_rest_become_overlay(self): """PIP 模式:第1个 main 保持,其余变 overlay.""" clips = _make_clips(5) map_clip_types_for_mode(clips, EditingMode.PIP.value) assert clips[0].clip_type == "main" assert clips[1].clip_type == "overlay" assert clips[2].clip_type == "overlay" assert clips[3].clip_type == "overlay" assert clips[4].clip_type == "overlay" def test_pip_single_main_unchanged(self): """PIP 模式只有1个 main:保持 main.""" clips = _make_clips(1) map_clip_types_for_mode(clips, EditingMode.PIP.value) assert clips[0].clip_type == "main" def test_voice_pip_three_types(self): """VOICE_PIP 模式:background + corner_voice + b_roll.""" clips = _make_clips(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_one_main(self): """VOICE_PIP 只有1个 main:变成 background.""" clips = _make_clips(1) map_clip_types_for_mode(clips, EditingMode.VOICE_PIP.value) assert clips[0].clip_type == "background" def test_voice_pip_two_mains(self): """VOICE_PIP 2个 main:background + corner_voice.""" clips = _make_clips(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_non_main_clips_unchanged(self): """非 MAIN 类型 clip 不受影响.""" clips = _make_clips(1, "intro") + _make_clips(3) + _make_clips(1, "outro") # main map_clip_types_for_mode(clips, EditingMode.PIP.value) assert clips[0].clip_type == "intro" assert clips[1].clip_type == "main" # 第1个 main assert clips[2].clip_type == "overlay" # 第2个 main → overlay assert clips[3].clip_type == "overlay" # 第3个 main → overlay assert clips[4].clip_type == "outro" def test_no_main_clips_noop(self): """没有 main clip:什么都不做.""" clips = _make_clips(3, "intro") original_types = [c.clip_type for c in clips] map_clip_types_for_mode(clips, EditingMode.PIP.value) assert [c.clip_type for c in clips] == original_types def test_empty_clips_noop(self): """空列表:不报错.""" map_clip_types_for_mode([], EditingMode.PIP.value) # ── generate_default_clips ────────────────────────────────────────── class TestGenerateDefaultClips: """默认 clip 生成.""" def test_one_take_normal(self): """ONE_TAKE:N 个 main clip.""" clips = generate_default_clips("plan1", EditingMode.ONE_TAKE.value, 5) assert len(clips) == 5 for c in clips: assert c.clip_type == "main" assert c.plan_id == "plan1" assert c.duration == DEFAULT_CLIP_DURATION # order 递增 for i in range(5): assert clips[i].order == i def test_pip_structure(self): """PIP:1个 main + (N-1)个 overlay.""" clips = generate_default_clips("plan1", EditingMode.PIP.value, 4) assert len(clips) == 4 assert clips[0].clip_type == "main" assert clips[1].clip_type == "overlay" assert clips[2].clip_type == "overlay" assert clips[3].clip_type == "overlay" assert clips[0].order == 0 assert clips[3].order == 3 def test_pip_single_asset(self): """PIP 只有1个素材:1个 main,没有 overlay.""" clips = generate_default_clips("plan1", EditingMode.PIP.value, 1) assert len(clips) == 1 assert clips[0].clip_type == "main" def test_voice_over_structure(self): """VOICE_OVER:N 个 main clip,带 b_roll 标记.""" clips = generate_default_clips("plan1", EditingMode.VOICE_OVER.value, 3) assert len(clips) == 3 for c in clips: assert c.clip_type == "main" assert c.config.get("role") == "b_roll" def test_voice_pip_three_layers(self): """VOICE_PIP:background + corner_voice + b_roll.""" clips = generate_default_clips("plan1", 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_voice_pip_single_asset(self): """VOICE_PIP 1个素材:只有 background.""" clips = generate_default_clips("plan1", EditingMode.VOICE_PIP.value, 1) assert len(clips) == 1 assert clips[0].clip_type == "background" def test_voice_pip_two_assets(self): """VOICE_PIP 2个素材:background + corner_voice.""" clips = generate_default_clips("plan1", EditingMode.VOICE_PIP.value, 2) assert len(clips) == 2 assert clips[0].clip_type == "background" assert clips[1].clip_type == "corner_voice" def test_zero_assets_at_least_one(self): """0 个素材:至少生成 1 个 clip.""" for mode in [ EditingMode.ONE_TAKE.value, EditingMode.PIP.value, EditingMode.VOICE_OVER.value, EditingMode.VOICE_PIP.value, ]: clips = generate_default_clips("plan1", mode, 0) assert len(clips) >= 1 def test_unknown_mode_falls_back(self): """未知模式退化为 ONE_TAKE 风格.""" clips = generate_default_clips("plan1", "unknown", 3) assert len(clips) == 3 for c in clips: assert c.clip_type == "main" def test_order_is_sequential(self): """所有模式下 order 都是从 0 开始连续递增.""" for mode in [ EditingMode.ONE_TAKE.value, EditingMode.PIP.value, EditingMode.VOICE_OVER.value, EditingMode.VOICE_PIP.value, ]: clips = generate_default_clips("plan1", mode, 5) for i, c in enumerate(clips): assert c.order == i # ── create_clips_from_configs ─────────────────────────────────────── class TestCreateClipsFromConfigs: """从模板配置创建 clips.""" def test_basic_creation(self): """基本创建:按 order 排序,属性正确传递.""" configs = [ TemplateClipConfig( id="cfg1", template_id="tpl1", clip_type=ClipType.MAIN, order=1, min_duration=3.0, max_duration=7.0, transition_effect="fade", ), TemplateClipConfig( id="cfg2", template_id="tpl1", clip_type=ClipType.INTRO, order=0, min_duration=2.0, max_duration=4.0, transition_effect="cut", ), ] clips = create_clips_from_configs("plan1", configs) assert len(clips) == 2 # 按 order 排序:intro(order=0) 在前,main(order=1) 在后 assert clips[0].clip_type == "intro" assert clips[1].clip_type == "main" assert clips[0].order == 0 assert clips[1].order == 1 assert clips[0].template_clip_config_id == "cfg2" assert clips[1].template_clip_config_id == "cfg1" def test_duration_average_of_min_max(self): """min_duration 和 max_duration 都有时,取平均值.""" configs = [ TemplateClipConfig( id="cfg1", template_id="tpl1", clip_type=ClipType.MAIN, order=0, min_duration=4.0, max_duration=6.0, ), ] clips = create_clips_from_configs("plan1", configs) assert clips[0].duration == 5.0 # (4+6)/2 def test_duration_only_min(self): """只有 min_duration 时,用 min_duration.""" configs = [ TemplateClipConfig( id="cfg1", template_id="tpl1", clip_type=ClipType.MAIN, order=0, min_duration=3.5, max_duration=0, ), ] clips = create_clips_from_configs("plan1", configs) assert clips[0].duration == 3.5 def test_duration_only_max(self): """只有 max_duration 时,用 max_duration.""" configs = [ TemplateClipConfig( id="cfg1", template_id="tpl1", clip_type=ClipType.MAIN, order=0, min_duration=0, max_duration=8.0, ), ] clips = create_clips_from_configs("plan1", configs) assert clips[0].duration == 8.0 def test_duration_default_when_both_zero(self): """都为 0 时用默认时长.""" configs = [ TemplateClipConfig( id="cfg1", template_id="tpl1", clip_type=ClipType.MAIN, order=0, min_duration=0, max_duration=0, ), ] clips = create_clips_from_configs("plan1", configs) assert clips[0].duration == DEFAULT_CLIP_DURATION def test_empty_configs_returns_empty(self): """空配置列表返回空列表.""" clips = create_clips_from_configs("plan1", []) assert clips == [] def test_plan_id_passed_through(self): """plan_id 正确传递给所有 clip.""" configs = [ TemplateClipConfig( id=f"cfg{i}", template_id="tpl1", clip_type=ClipType.MAIN, order=i, min_duration=3, max_duration=5, ) for i in range(3) ] clips = create_clips_from_configs("my_plan", configs) for c in clips: assert c.plan_id == "my_plan" def test_playback_speed_from_config(self): """playback_speed 从 config.playback_speed 读取.""" configs = [ TemplateClipConfig( id="cfg1", template_id="tpl1", clip_type=ClipType.MAIN, order=0, min_duration=3, max_duration=5, config={"playback_speed": 1.5}, ), ] clips = create_clips_from_configs("plan1", configs) assert clips[0].playback_speed == 1.5 def test_playback_speed_fallback_to_speed_ratio(self): """playback_speed 不存在时回退到 speed_ratio.""" configs = [ TemplateClipConfig( id="cfg1", template_id="tpl1", clip_type=ClipType.MAIN, order=0, min_duration=3, max_duration=5, config={"speed_ratio": 0.8}, ), ] clips = create_clips_from_configs("plan1", configs) assert clips[0].playback_speed == 0.8 def test_playback_speed_default_1(self): """没有 speed 配置时默认为 1.0.""" configs = [ TemplateClipConfig( id="cfg1", template_id="tpl1", clip_type=ClipType.MAIN, order=0, min_duration=3, max_duration=5, config={}, ), ] clips = create_clips_from_configs("plan1", configs) assert clips[0].playback_speed == 1.0 def test_playback_speed_none_falls_back(self): """playback_speed 为 None 时回退到 1.0.""" configs = [ TemplateClipConfig( id="cfg1", template_id="tpl1", clip_type=ClipType.MAIN, order=0, min_duration=3, max_duration=5, config={"playback_speed": None}, ), ] clips = create_clips_from_configs("plan1", configs) assert clips[0].playback_speed == 1.0 def test_transition_default_cut(self): """transition_effect 为空时默认为 cut.""" configs = [ TemplateClipConfig( id="cfg1", template_id="tpl1", clip_type=ClipType.MAIN, order=0, min_duration=3, max_duration=5, transition_effect=None, ), ] clips = create_clips_from_configs("plan1", configs) assert clips[0].transition_effect == "cut" # ── 常量导出 ──────────────────────────────────────────────────────── class TestConstants: """常量导出验证.""" def test_default_duration_value(self): """默认片段时长应为 5 秒.""" assert DEFAULT_CLIP_DURATION == 5.0 # ── 随机 start_time 与去重测试 ───────────────────────────────────────── class TestCalcRandomStartTime: """_calc_random_start_time 函数测试.""" def test_no_durations_returns_none(self): """asset_durations 为 None 时返回 None.""" result = _calc_random_start_time("a1", 5.0, None) assert result is None def test_asset_not_in_durations_returns_none(self): """素材不在 durations 映射中时返回 None.""" result = _calc_random_start_time("a_missing", 5.0, {"a1": 60.0}) assert result is None def test_zero_duration_returns_none(self): """素材时长为 0 时返回 None.""" result = _calc_random_start_time("a1", 5.0, {"a1": 0.0}) assert result is None def test_clip_longer_than_asset_returns_zero(self): """片段时长 >= 素材时长时返回 0.0.""" result = _calc_random_start_time("a1", 60.0, {"a1": 30.0}) assert result == 0.0 def test_returns_value_in_range(self): """返回值在 [0, max_start] 范围内.""" for _ in range(50): result = _calc_random_start_time("a1", 5.0, {"a1": 30.0}) assert result is not None assert 0.0 <= result <= 25.0 def test_avoids_used_segments(self): """生成的 start_time 不与已使用段重叠.""" used = {"a1": [(10.0, 15.0), (20.0, 25.0)]} for _ in range(100): result = _calc_random_start_time("a1", 3.0, {"a1": 30.0}, used) assert result is not None end = result + 3.0 # 不应与 [10,15) 重叠 assert not (result < 15.0 and 10.0 < end) # 不应与 [20,25) 重叠 assert not (result < 25.0 and 20.0 < end) def test_fallback_when_no_space(self): """所有空间都被占用时,回退到最后已使用段之后或 0.""" used = {"a1": [(0.0, 28.0)]} result = _calc_random_start_time("a1", 5.0, {"a1": 30.0}, used) assert result is not None # 应该返回 last_used_end (28.0) 但受限于 max_start (25.0),取 min assert result == 25.0 def test_no_used_segments_for_asset(self): """used_segments 中没有当前 asset_id 时正常随机.""" used = {"a2": [(10.0, 15.0)]} for _ in range(20): result = _calc_random_start_time("a1", 5.0, {"a1": 30.0}, used) assert result is not None assert 0.0 <= result <= 25.0 class TestDistributeAssetsOverlapAvoidance: """测试 distribute_assets 中同一素材被多次使用时的去重逻辑.""" def test_same_asset_reused_no_overlap(self): """同一素材分配给多个 clip 时,start_time 不重叠.""" # 2 个 clip 共享同一素材,素材时长足够 clips = [ EditPlanClip.create(plan_id="p1", clip_type=ClipType.MAIN.value, order=0, duration=5.0), EditPlanClip.create(plan_id="p1", clip_type=ClipType.MAIN.value, order=1, duration=5.0), ] asset_ids = ["a1", "a1"] # 同一素材用两次 durations = {"a1": 60.0} distribute_assets(clips, asset_ids, EditingMode.ONE_TAKE.value, asset_durations=durations) # 两个 clip 都应有 start_time assert clips[0].start_time is not None assert clips[1].start_time is not None # 两段不应重叠 s1, e1 = clips[0].start_time, clips[0].start_time + 5.0 s2, e2 = clips[1].start_time, clips[1].start_time + 5.0 assert not (s1 < e2 and s2 < e1), f"Segments overlap: [{s1},{e1}) and [{s2},{e2})" def test_different_assets_independent(self): """不同素材各自独立随机,互不影响.""" clips = [ EditPlanClip.create(plan_id="p1", clip_type=ClipType.MAIN.value, order=0, duration=5.0), EditPlanClip.create(plan_id="p1", clip_type=ClipType.MAIN.value, order=1, duration=5.0), ] asset_ids = ["a1", "a2"] durations = {"a1": 60.0, "a2": 60.0} distribute_assets(clips, asset_ids, EditingMode.ONE_TAKE.value, asset_durations=durations) assert clips[0].asset_id == "a1" assert clips[1].asset_id == "a2" assert clips[0].start_time is not None assert clips[1].start_time is not None def test_no_durations_still_works(self): """不传 asset_durations 时仍正常分配(start_time 为 None).""" clips = [ EditPlanClip.create(plan_id="p1", clip_type=ClipType.MAIN.value, order=0, duration=5.0), ] distribute_assets(clips, ["a1"], EditingMode.ONE_TAKE.value) assert clips[0].asset_id == "a1" # start_time 默认为 0.0(模型默认值) assert clips[0].start_time == 0.0 def test_voice_pip_overlap_avoidance(self): """VOICE_PIP 模式下同一素材多次使用时避免重叠.""" clips = [ EditPlanClip.create(plan_id="p1", clip_type="background", order=0, duration=5.0), EditPlanClip.create(plan_id="p1", clip_type="b_roll", order=1, duration=5.0), ] asset_ids = ["a1", "a1"] # 同一素材用两次 durations = {"a1": 60.0} distribute_assets(clips, asset_ids, EditingMode.VOICE_PIP.value, asset_durations=durations) assert clips[0].start_time is not None assert clips[1].start_time is not None s1, e1 = clips[0].start_time, clips[0].start_time + 5.0 s2, e2 = clips[1].start_time, clips[1].start_time + 5.0 assert not (s1 < e2 and s2 < e1), f"Segments overlap: [{s1},{e1}) and [{s2},{e2})"