"""clone_plan_for_variant 单元测试(Task G 验收项:批量 N 条视频片段独立)。 验证: - 同一源 plan 克隆 3 次产出 3 个不同 plan_id,各自片段起点不同 - 源 plan 的片段不被修改 - 模板/config/时长结构被复制 - 复用占比闸门触发时保留原起点(不重复抽取) - 源 plan 无片段时抛出 ValueError """ from __future__ import annotations import os import sys from pathlib import Path from unittest.mock import MagicMock, patch os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing") os.environ.setdefault("DATABASE_URL", "sqlite:///test.db") import pytest sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api")) sys.path.insert(0, str(Path(__file__).resolve().parent)) # tests/unit,便于复用同目录 stub # 复用 test_edit_plan_service 里的内存 stub 仓储 from test_edit_plan_service import ( # noqa: E402 StubEditPlanClipRepository, StubEditPlanRepository, _make_service, ) from packages.domain.edit_plan_clip import EditPlanClip @pytest.fixture def svc_with_source(): """构造带源 plan + 3 个片段的 service(stub 仓储)。""" svc = _make_service() # clone 用 self._clip_repo.session 拿 db;stub 无 session,补一个 MagicMock svc._clip_repo.session = MagicMock() source = svc.create_plan(template_id="tpl-001", name="源计划", total_duration=15.0) for i in range(3): clip = EditPlanClip.create( plan_id=source.id, clip_type="main", order=i, asset_id=f"a{i % 2 + 1}", # a1, a2, a1 start_time=float(i * 5), duration=5.0, ) svc._clip_repo.create(clip) return svc, source def _clone_with_fake_calc(svc, source, starts, *, used=None): """用受控的 calc 起点列表执行一次克隆。 starts: 每次 _calc_random_start_time 返回的起点(按片段顺序)。 返回 (new_plan, replace_all 调用的 clips_data, calc 调用记录)。 """ calc_calls: list[dict] = [] def fake_calc(asset_id, clip_duration, durations, used_segments, on_exhausted=None): idx = len(calc_calls) calc_calls.append({"asset_id": asset_id, "clip_duration": clip_duration, "on_exhausted": on_exhausted}) return starts[idx] with ( patch( "app.services.edit_plan_service.get_used_segments", return_value=used or {}, ), patch( "app.services.edit_plan_service.make_reuse_callback", return_value=lambda aid, d: None, ), patch( "app.services.edit_plan_service.record_used_segments", return_value=None, ), patch( "packages.domain.plan_generator_utils._calc_random_start_time", side_effect=fake_calc, ), patch.object(svc, "replace_all_clips_transactional", return_value=3) as mock_replace, patch( "packages.adapters.sqlalchemy_impl.models.AssetModel", create=True, ) as mock_asset_model, ): # db.query(AssetModel).filter(...).all() → 返回带 duration 的 mock 素材 m1 = MagicMock(id="a1") m1.duration = 60.0 m2 = MagicMock(id="a2") m2.duration = 60.0 svc._clip_repo.session.query.return_value.filter.return_value.all.return_value = [m1, m2] new_plan = svc.clone_plan_for_variant(source.id, created_by_user_id="u1", name_suffix="变体") clips_data = mock_replace.call_args.args[1] return new_plan, clips_data, calc_calls class TestClonePlanForVariant: def test_three_clones_produce_distinct_plans_and_starts(self, svc_with_source): """克隆 3 次:3 个不同 plan_id,片段起点互不相同(Task G 验收)。""" svc, source = svc_with_source start_sets = [ [10.0, 20.0, 30.0], [11.0, 21.0, 31.0], [12.0, 22.0, 32.0], ] plans = [] all_clips = [] for starts in start_sets: new_plan, clips_data, _ = _clone_with_fake_calc(svc, source, starts) plans.append(new_plan) all_clips.append(clips_data) # 3 个不同 plan_id,且都不等于源 plan plan_ids = {p.id for p in plans} assert len(plan_ids) == 3 assert source.id not in plan_ids # 每次克隆的起点各自不同 for clips_data, starts in zip(all_clips, start_sets, strict=True): assert [c["start_time"] for c in clips_data] == starts # 三次克隆的起点集合互不相同 assert {tuple(c["start_time"] for c in clips) for clips in all_clips} == { (10.0, 20.0, 30.0), (11.0, 21.0, 31.0), (12.0, 22.0, 32.0), } def test_source_plan_not_modified(self, svc_with_source): """克隆不修改源 plan 及其片段(保留用户手动编辑)。""" svc, source = svc_with_source source_clips_before = sorted( [(c.order, c.asset_id, c.start_time, c.duration) for c in svc._clip_repo.list_by_plan(source.id)] ) source_name_before = source.name _clone_with_fake_calc(svc, source, [9.0, 19.0, 29.0]) _clone_with_fake_calc(svc, source, [8.0, 18.0, 28.0]) source_clips_after = sorted( [(c.order, c.asset_id, c.start_time, c.duration) for c in svc._clip_repo.list_by_plan(source.id)] ) assert source_clips_after == source_clips_before assert svc._plan_repo.get(source.id).name == source_name_before def test_clone_copies_structure(self, svc_with_source): """克隆复制 template_id / config / total_duration / 片段素材与时长。""" svc, source = svc_with_source source.config = {"mode": "ONE_TAKE"} new_plan, clips_data, _ = _clone_with_fake_calc(svc, source, [10.0, 20.0, 30.0]) assert new_plan.template_id == source.template_id assert new_plan.total_duration == source.total_duration assert new_plan.config == {"mode": "ONE_TAKE"} assert "变体" in new_plan.name # 片段素材与时长结构保持 assert [c["asset_id"] for c in clips_data] == ["a1", "a2", "a1"] assert all(c["duration"] == 5.0 for c in clips_data) assert [c["order"] for c in clips_data] == [0, 1, 2] def test_clone_uses_reuse_callback(self, svc_with_source): """克隆时 calc 传入了 on_exhausted 受控复用回调(耗尽时复用而非清空历史)。""" svc, source = svc_with_source _, _, calc_calls = _clone_with_fake_calc(svc, source, [10.0, 20.0, 30.0]) assert len(calc_calls) == 3 for call in calc_calls: assert call["on_exhausted"] is not None def test_clone_ratio_blocked_keeps_original_start(self, svc_with_source): """复用占比闸门触发(calc 返回 None)时保留源片段原起点。""" svc, source = svc_with_source # 第 3 个片段 calc 返回 None(模拟复用占比超 15% 拒绝复用) new_plan, clips_data, _ = _clone_with_fake_calc(svc, source, [10.0, 20.0, None]) # type: ignore[list-item] starts = [c["start_time"] for c in clips_data] assert starts[0] == 10.0 assert starts[1] == 20.0 # 第 3 片段保留源起点(源 order=2 → start_time=10.0) assert starts[2] == 10.0 def test_clone_empty_source_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="无片段"): svc.clone_plan_for_variant(empty.id, name_suffix="变体") def test_clone_nonexistent_source_raises(self): """源 plan 不存在时抛出 ValueError。""" svc = _make_service() svc._clip_repo.session = MagicMock() with pytest.raises(ValueError, match="不存在"): svc.clone_plan_for_variant("no-such-plan", name_suffix="变体")