"""#1743 EditPlanService.reselect_plan_for_variant 服务层测试。 与 clone_plan_for_variant(只重算起点、素材/顺序不变)不同,reselect 完整重跑 单视频选片:素材池 shuffle + main 片段顺序洗牌 + 起点重选 + 批次 20% 重叠避让。 覆盖: - 独立 plan:新 plan_id 与源不同、命名带后缀、模板/config 复制 - 新片段经 replace_all_clips_transactional 落库,素材/起点与源 plan 存在差异 - 源 plan 区间作为批次避让初始对象;record_used_segments 随新片段写回 - 源 plan 无片段 → ValueError(不创建同源变体) """ from __future__ import annotations import os import sys from pathlib import Path from unittest.mock import MagicMock, patch import pytest os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing") os.environ.setdefault("DATABASE_URL", "sqlite:///test.db") REPO_ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(REPO_ROOT / "apps" / "api")) sys.path.insert(0, str(Path(__file__).resolve().parent)) # tests/unit from test_edit_plan_service import StubEditPlanClipRepository, StubEditPlanRepository, _make_service # noqa: E402 from packages.domain.edit_plan_clip import EditPlanClip # noqa: E402 @pytest.fixture def svc_with_source(): """带源 plan(4 个 main 片段,素材 a1/a2/a3/a4)+ 2 个批次候选素材的 service。""" svc = _make_service() svc._clip_repo.session = MagicMock() source = svc.create_plan(template_id="tpl-001", name="9/6-草稿", total_duration=20.0, config={"title": "源配置"}) for i, aid in enumerate(["a1", "a2", "a3", "a4"]): clip = EditPlanClip.create( plan_id=source.id, clip_type="main", order=i, asset_id=aid, start_time=float(i * 5), duration=5.0, text_content=f"文案{i}", ) svc._clip_repo.create(clip) return svc, source def _patch_deps(svc, durations): """统一 patch reselect 的 DB/素材/历史区间依赖。返回 (patches, mock_replace)。""" asset_models = [] for aid, dur in durations.items(): m = MagicMock(id=aid) m.duration = dur m.metadata = None # extract_scene_points_from_metadata(None) → 无场景点 asset_models.append(m) mock_replace = patch.object(svc, "replace_all_clips_transactional", return_value=4) patches = [ patch("app.services.edit_plan_service.get_used_segments", return_value={}), patch("app.services.edit_plan_service.record_used_segments", return_value=None), patch("packages.domain.plan_generator_utils.extract_scene_points_from_metadata", return_value=[]), patch("packages.adapters.sqlalchemy_impl.models.AssetModel", create=True), mock_replace, ] started = [] for p in patches: started.append(p.start()) # started[-1] 是 replace_all 的 MagicMock mock_replace_obj = started[-1] # db.query(AssetModel).filter(...).all() → 带 duration 的素材 mock svc._clip_repo.session.query.return_value.filter.return_value.all.return_value = asset_models return patches, mock_replace_obj class TestReselectPlanForVariant: def test_creates_independent_plan_with_different_clips(self, svc_with_source): """reselect 产出新 plan(id/名称不同),片段素材或起点与源 plan 存在差异。""" import random svc, source = svc_with_source durations = {"a1": 300.0, "a2": 300.0, "a3": 300.0, "a4": 300.0, "a5": 300.0, "a6": 300.0} patches, mock_replace = _patch_deps(svc, durations) try: new_plan = svc.reselect_plan_for_variant( source.id, ["a5", "a6"], # 批次素材并入素材池 created_by_user_id="u-1", name_suffix="批量2", rng=random.Random(42), ) finally: for p in reversed(patches): p.stop() # 新 plan 独立、归属/模板/config 复制 assert new_plan.id != source.id assert "批量2" in new_plan.name assert new_plan.template_id == "tpl-001" assert new_plan.config == {"title": "源配置"} assert new_plan.created_by_user_id == "u-1" # 落库片段数 == 源片段数,且 order 对齐 clips_data = mock_replace.call_args.args[1] assert len(clips_data) == 4 assert [c["order"] for c in clips_data] == [0, 1, 2, 3] # 与源 plan 对比:素材序列或起点必须存在差异(降重核心——不是克隆) source_pairs = [(c.asset_id, round(float(c.start_time), 2)) for c in svc._clip_repo.list_by_plan(source.id)] new_pairs = [(c["asset_id"], round(float(c["start_time"]), 2)) for c in clips_data] assert new_pairs != source_pairs, f"reselect 片段应与源 plan 不同,实际相同: {new_pairs}" # 素材全部来自素材池(源 a1-a4 ∪ 批次 a5-a6) for c in clips_data: assert c["asset_id"] in durations def test_record_used_segments_called_per_clip(self, svc_with_source): """每个新片段区间调用 record_used_segments 写回(跨变体/跨任务避让链路)。""" import random svc, source = svc_with_source durations = {"a1": 300.0, "a2": 300.0, "a3": 300.0, "a4": 300.0, "a5": 300.0} asset_models = [] for aid in durations: m = MagicMock(id=aid) m.duration = durations[aid] m.metadata = None asset_models.append(m) svc._clip_repo.session.query.return_value.filter.return_value.all.return_value = asset_models started = [ patch("app.services.edit_plan_service.get_used_segments", return_value={}).start(), patch("packages.domain.plan_generator_utils.extract_scene_points_from_metadata", return_value=[]).start(), patch("packages.adapters.sqlalchemy_impl.models.AssetModel", create=True).start(), patch.object(svc, "replace_all_clips_transactional", return_value=4).start(), ] mock_record = patch("app.services.edit_plan_service.record_used_segments", return_value=None).start() try: svc.reselect_plan_for_variant( source.id, ["a5"], created_by_user_id="u-1", name_suffix="批量2", rng=random.Random(5) ) finally: patch.stopall() assert mock_record.call_count == 4, "4 个片段应各写一次 used_segment" for call in mock_record.call_args_list: args = call.args assert args[1] in durations, f"asset_id {args[1]} 不在素材池" # asset_id assert args[3] > args[2], "区间 end 应大于 start" # end > start assert args[4], "new plan_id 应非空" def test_source_clips_seed_batch_avoidance(self, svc_with_source): """源 plan 片段区间进入批次避让集:与源完全同区间的起点重叠率应超限被避开。""" import random svc, source = svc_with_source # 素材池只有源素材(极端小池),时长充足 durations = {"a1": 600.0, "a2": 600.0, "a3": 600.0, "a4": 600.0} patches, mock_replace = _patch_deps(svc, durations) try: new_plan = svc.reselect_plan_for_variant( source.id, [], created_by_user_id="u-1", name_suffix="批量2", rng=random.Random(99) ) finally: for p in reversed(patches): p.stop() clips_data = mock_replace.call_args.args[1] source_clips = svc._clip_repo.list_by_plan(source.id) source_by_asset = {} for c in source_clips: source_by_asset.setdefault(c.asset_id, []).append( (float(c.start_time), float(c.start_time) + float(c.duration)) ) # 同素材新片段与源区间的重叠占比均 ≤20% from packages.domain.variant_plan_selector import _clip_overlap_ratio for c in clips_data: ratio = _clip_overlap_ratio(c["asset_id"], float(c["start_time"]), float(c["duration"]), source_by_asset) assert ( ratio <= 0.20 + 1e-6 ), f"变体片段与源 plan 同素材区间重叠超限: asset={c['asset_id']} ratio={ratio:.2%}" assert new_plan.id != source.id def test_source_plan_without_clips_raises(self): """源 plan 无片段 → ValueError(明确报错,不产出同源变体)。""" svc = _make_service() svc._clip_repo.session = MagicMock() empty = svc.create_plan(template_id="tpl-x", name="空计划") with pytest.raises(ValueError, match="源 plan 无片段"): svc.reselect_plan_for_variant(empty.id, ["a1"], created_by_user_id="u-1", name_suffix="变体") def test_missing_source_plan_raises(self): """源 plan 不存在 → get_plan_or_raise 抛错。""" svc = _make_service() svc._clip_repo.session = MagicMock() with pytest.raises((ValueError, KeyError, LookupError)): # get_plan_or_raise 抛错 svc.reselect_plan_for_variant("not-exist-plan", ["a1"], created_by_user_id="u-1", name_suffix="变体")