"""#1749 EditPlanService 新增方法单元测试。 覆盖 get_asset_durations / apply_voice_duration_to_plan / ensure_variant_plans。 """ from __future__ import annotations from unittest.mock import MagicMock, patch import pytest # ── helpers ─────────────────────────────────────────────────────────────────── def _make_svc(): """构造 EditPlanService,mock 掉 DB/repos。""" from app.services.edit_plan_service import EditPlanService db = MagicMock() svc = EditPlanService.__new__(EditPlanService) svc._plan_repo = MagicMock() svc._clip_repo = MagicMock() svc._clip_repo.session = db svc._generation_task_repo = MagicMock() return svc, db def _make_clip( order=0, asset_id="a1", start_time=0.0, duration=5.0, clip_type="main", transition_effect="cut", transition_duration=0.0, playback_speed=1.0, text_content="", config=None, ): c = MagicMock() c.order = order c.asset_id = asset_id c.start_time = start_time c.duration = duration c.clip_type = clip_type c.transition_effect = transition_effect c.transition_duration = transition_duration c.playback_speed = playback_speed c.text_content = text_content c.config = config or {} return c # ═══════════════════════════════════════════════════════════════════════════════ # get_asset_durations # ═══════════════════════════════════════════════════════════════════════════════ class TestGetAssetDurations: def test_empty_ids_returns_empty(self): svc, _db = _make_svc() assert svc.get_asset_durations([]) == {} assert svc.get_asset_durations(None) == {} def test_dedup_and_query(self): svc, db = _make_svc() m1 = MagicMock() m1.id = "a1" m1.duration = 10.5 m2 = MagicMock() m2.id = "a2" m2.duration = None # -> 0.0 db.query.return_value.filter.return_value.all.return_value = [m1, m2] result = svc.get_asset_durations(["a1", "a2", "a1"]) assert result == {"a1": 10.5, "a2": 0.0} def test_bad_duration_type_returns_zero(self): svc, db = _make_svc() m = MagicMock() m.id = "a1" m.duration = "not-a-number" db.query.return_value.filter.return_value.all.return_value = [m] result = svc.get_asset_durations(["a1"]) assert result == {"a1": 0.0} # ═══════════════════════════════════════════════════════════════════════════════ # apply_voice_duration_to_plan # ═══════════════════════════════════════════════════════════════════════════════ class TestApplyVoiceDurationToPlan: def test_voice_le_zero_returns_none(self): svc, _ = _make_svc() assert svc.apply_voice_duration_to_plan("p1", 0) is None assert svc.apply_voice_duration_to_plan("p1", -5) is None def test_plan_not_found_returns_none(self): svc, _ = _make_svc() svc.get_plan = MagicMock(return_value=None) assert svc.apply_voice_duration_to_plan("p1", 30.0) is None def test_no_clips_returns_none(self): svc, _ = _make_svc() plan = MagicMock() svc.get_plan = MagicMock(return_value=plan) svc._clip_repo.list_by_plan.return_value = [] assert svc.apply_voice_duration_to_plan("p1", 30.0) is None def test_happy_path_two_clips(self): svc, db = _make_svc() plan = MagicMock() plan.total_duration = 0.0 svc.get_plan = MagicMock(return_value=plan) clips = [ _make_clip(order=0, asset_id="a1", start_time=0.0), _make_clip(order=1, asset_id="a2", start_time=0.0), ] svc._clip_repo.list_by_plan.return_value = clips # get_asset_durations: a1=100s, a2=50s m1 = MagicMock(id="a1", duration=100.0) m2 = MagicMock(id="a2", duration=50.0) db.query.return_value.filter.return_value.all.return_value = [m1, m2] svc.replace_all_clips_transactional = MagicMock(return_value=2) result = svc.apply_voice_duration_to_plan("p1", 30.0) assert result is plan svc.replace_all_clips_transactional.assert_called_once() def test_invalid_voice_returns_none(self): svc, _ = _make_svc() assert svc.apply_voice_duration_to_plan("p1", "bad") is None # ═══════════════════════════════════════════════════════════════════════════════ # ensure_variant_plans # ═══════════════════════════════════════════════════════════════════════════════ class TestEnsureVariantPlans: def _setup_svc(self): svc, _ = _make_svc() plan0 = MagicMock() plan0.id = "plan_v0" svc.clone_plan_for_variant = MagicMock(return_value=plan0) plan1 = MagicMock() plan1.id = "plan_v1" svc.reselect_plan_for_variant = MagicMock(return_value=plan1) svc.apply_voice_duration_to_plan = MagicMock(return_value=plan0) return svc def test_count_1_no_voice(self): svc = self._setup_svc() result = svc.ensure_variant_plans("src", 1, ["a1", "a2"]) assert result == ["plan_v0"] svc.clone_plan_for_variant.assert_called_once() svc.apply_voice_duration_to_plan.assert_not_called() def test_count_1_with_voice(self): svc = self._setup_svc() result = svc.ensure_variant_plans("src", 1, ["a1"], voice_durations=[30.0]) assert result == ["plan_v0"] svc.apply_voice_duration_to_plan.assert_called_once_with("plan_v0", 30.0) def test_count_3_with_voice(self): svc = self._setup_svc() svc.reselect_plan_for_variant = MagicMock( side_effect=[ MagicMock(id="plan_v1"), MagicMock(id="plan_v2"), ] ) result = svc.ensure_variant_plans("src", 3, ["a1", "a2"], voice_durations=[30.0, 20.0, 15.0]) assert len(result) == 3 assert result[0] == "plan_v0" # v0 配音 svc.apply_voice_duration_to_plan.assert_called_once_with("plan_v0", 30.0) # reselect 被调 2 次 assert svc.reselect_plan_for_variant.call_count == 2 def test_voice_apply_failure_not_blocking(self): svc = self._setup_svc() svc.apply_voice_duration_to_plan.side_effect = RuntimeError("boom") result = svc.ensure_variant_plans("src", 1, ["a1"], voice_durations=[30.0]) assert result == ["plan_v0"]