"""Issue #1677 多视频批量生成 — 变体独立配置与批量预览/批量生成测试。 覆盖: - 批量预览:preview_count=N 一次创建 N 个独立任务,返回变体数组 - 变体克隆链路:N 个预览/正式任务各自关联独立克隆 plan - 变体独立配置:titles[]/voice_library_ids[]/cover_urls[] 按变体注入 - 长度校验:数组长度必须为 1 或 N(共用或独立),非法长度报错 - N=1 向后兼容:旧字段单值行为不变 """ from datetime import datetime, timezone from unittest.mock import MagicMock, patch import pytest from app.core.task_enqueue import GlobalQueueFull, UserPendingLimitExceeded from app.schemas.generation_task import ( BatchPreviewGenerationTaskResponse, CreateGenerationTaskRequest, CreatePreviewGenerationTaskRequest, ) from packages.domain import GenerationTask from packages.domain.generation_task import GenerationTaskStatus # ════════════════════════════════════════════════════════════════════════════ # 辅助构造 # ════════════════════════════════════════════════════════════════════════════ def _make_user(user_id="test_user_001"): mock_user = MagicMock() mock_user.id = user_id auth = MagicMock() auth.user = mock_user return auth def _make_task(task_id=None, status=GenerationTaskStatus.PENDING, source_plan_id=None): task = GenerationTask.create( project_id="", asset_library_id="", template_id="tpl_001", asset_ids=["asset_1"], ) if task_id: task.id = task_id task.status = status task.is_preview = True task.source_edit_plan_id = source_plan_id or "" task.voice_library_id = "" task.title_config = {} task.cover_url = "" return task def _make_preview_request(**kwargs): defaults = { "template_id": "tpl_001", "asset_ids": ["asset_1", "asset_2"], } defaults.update(kwargs) return CreatePreviewGenerationTaskRequest(**defaults) def _repo_mock(): repo = MagicMock() repo.count_pending_by_user.return_value = 0 repo.count_pending_total.return_value = 0 repo.get.side_effect = lambda tid: None return repo # ════════════════════════════════════════════════════════════════════════════ # Schema 校验:变体数组长度 # ════════════════════════════════════════════════════════════════════════════ class TestVariantArrayValidation: """变体数组字段长度校验。""" def test_preview_titles_length_matches_count(self): """titles 长度 = preview_count 合法""" req = _make_preview_request(preview_count=3, titles=["标题A", "标题B", "标题C"]) assert len(req.titles) == 3 def test_preview_titles_single_shared(self): """titles 长度 1 = 所有变体共用,合法""" req = _make_preview_request(preview_count=3, titles=["共用标题"]) assert req.titles == ["共用标题"] def test_preview_titles_wrong_length_raises(self): """titles 长度 2 与 preview_count=3 不匹配 → 报错""" with pytest.raises(ValueError, match="titles"): _make_preview_request(preview_count=3, titles=["A", "B"]) def test_preview_voice_ids_wrong_length_raises(self): """voice_library_ids 长度非法 → 报错""" from pydantic import ValidationError with pytest.raises(ValidationError, match="配音|视频数量"): _make_preview_request(preview_count=4, voice_library_ids=["v1", "v2"]) def test_preview_empty_arrays_ok(self): """空数组(回退单值字段)合法""" req = _make_preview_request(preview_count=3) assert req.titles == [] assert req.voice_library_ids == [] assert req.cover_urls == [] def test_generation_titles_length_matches_count(self): """正式生成 titles 长度 = count 合法""" req = CreateGenerationTaskRequest( template_id="tpl_1", asset_ids=["a1"], count=3, titles=["A", "B", "C"], ) assert len(req.titles) == 3 def test_generation_arrays_wrong_length_raises(self): """正式生成 cover_urls 长度与 count 不匹配 → 报错""" from pydantic import ValidationError with pytest.raises(ValidationError, match="cover_urls"): CreateGenerationTaskRequest( template_id="tpl_1", asset_ids=["a1"], count=3, cover_urls=["c1", "c2"], ) def test_generation_single_count_no_arrays(self): """N=1 且不传数组:完全旧行为""" req = CreateGenerationTaskRequest(template_id="tpl_1", asset_ids=["a1"]) assert req.count == 1 assert req.titles == [] assert req.voice_library_ids == [] assert req.cover_urls == [] # ════════════════════════════════════════════════════════════════════════════ # 批量预览路由 # ════════════════════════════════════════════════════════════════════════════ class TestBatchPreviewRoute: """POST /preview 批量变体。""" def test_preview_count_1_returns_single_item_array(self): """N=1 返回 items 长度 1 的批量响应(结构统一)""" from app.api.routes.generation_preview import create_preview_generation_task task = _make_task(task_id="task_1") repo = _repo_mock() with patch("app.api.routes.generation_preview.CreateGenerationTaskUseCase") as MockUC: MockUC.return_value.execute.return_value = task with patch("app.api.routes.generation_preview.safe_enqueue_generation_task", return_value=True): resp = create_preview_generation_task( _make_preview_request(preview_count=1), authenticated_user=_make_user(), generation_task_repository=repo, db=MagicMock(), ) assert isinstance(resp, BatchPreviewGenerationTaskResponse) assert resp.total == 1 assert len(resp.items) == 1 assert resp.items[0].task_id == "task_1" assert resp.items[0].variant_index == 0 def test_preview_count_3_creates_three_independent_tasks(self): """N=3 创建 3 个独立任务,返回 3 个变体,task_id 各不相同""" from app.api.routes.generation_preview import create_preview_generation_task tasks = [_make_task(task_id=f"task_{i}") for i in range(3)] repo = _repo_mock() with patch("app.api.routes.generation_preview.CreateGenerationTaskUseCase") as MockUC: MockUC.return_value.execute.side_effect = tasks with patch("app.api.routes.generation_preview.safe_enqueue_generation_task", return_value=True): resp = create_preview_generation_task( _make_preview_request(preview_count=3), authenticated_user=_make_user(), generation_task_repository=repo, db=MagicMock(), ) assert resp.total == 3 task_ids = [item.task_id for item in resp.items] assert task_ids == ["task_0", "task_1", "task_2"] assert len(set(task_ids)) == 3 for i, item in enumerate(resp.items): assert item.variant_index == i def test_preview_count_3_reselects_independent_variant_plans(self): """#1743:有源 plan 时 N=3,变体0保留源 plan,变体1/2 各自独立选片(reselect)。""" from app.api.routes.generation_preview import create_preview_generation_task tasks = [_make_task(task_id=f"task_{i}", source_plan_id="source_plan") for i in range(3)] repo = _repo_mock() reselect_plan_ids = ["reselect_1", "reselect_2"] with patch("app.api.routes.generation_preview.CreateGenerationTaskUseCase") as MockUC: MockUC.return_value.execute.side_effect = tasks with patch("app.api.routes.generation_preview.safe_enqueue_generation_task", return_value=True): with patch("app.services.edit_plan_service.EditPlanService") as MockPlanSvc: reselect_results = [MagicMock(id=pid) for pid in reselect_plan_ids] MockPlanSvc.return_value.reselect_plan_for_variant.side_effect = reselect_results MockPlanSvc.return_value.clone_plan_for_variant.return_value = MagicMock(id="clone_v0") MockPlanSvc.return_value.get_asset_durations.return_value = {} create_preview_generation_task( _make_preview_request(preview_count=3), authenticated_user=_make_user(), generation_task_repository=repo, db=MagicMock(), ) # #1749:变体0 clone(不污染源 plan);变体 1..N-1 各独立选片一次(共 2 次) assert MockPlanSvc.return_value.reselect_plan_for_variant.call_count == 2 MockPlanSvc.return_value.clone_plan_for_variant.assert_called_once() # 变体0 关联 clone plan;变体1/2 关联各自独立选出的 plan assert tasks[0].source_edit_plan_id == "clone_v0" assert tasks[1].source_edit_plan_id == "reselect_1" assert tasks[2].source_edit_plan_id == "reselect_2" def test_preview_variant_titles_injected_per_variant(self): """titles[] 按变体注入 title_config.text""" from app.api.routes.generation_preview import create_preview_generation_task tasks = [_make_task(task_id=f"task_{i}") for i in range(3)] repo = _repo_mock() captured_commands = [] with patch("app.api.routes.generation_preview.CreateGenerationTaskUseCase") as MockUC: def _execute(cmd): captured_commands.append(cmd) return tasks[len(captured_commands) - 1] MockUC.return_value.execute.side_effect = _execute with patch("app.api.routes.generation_preview.safe_enqueue_generation_task", return_value=True): create_preview_generation_task( _make_preview_request( preview_count=3, title_config={"font": "黑体", "position": "bottom"}, titles=["标题A", "标题B", "标题C"], ), authenticated_user=_make_user(), generation_task_repository=repo, db=MagicMock(), ) assert len(captured_commands) == 3 assert captured_commands[0].title_config["text"] == "标题A" assert captured_commands[1].title_config["text"] == "标题B" assert captured_commands[2].title_config["text"] == "标题C" # 样式全局共用 assert all(c.title_config["font"] == "黑体" for c in captured_commands) def test_preview_shared_title_when_single_length(self): """titles 长度 1 = 所有变体共用同一标题""" from app.api.routes.generation_preview import create_preview_generation_task tasks = [_make_task(task_id=f"task_{i}") for i in range(3)] repo = _repo_mock() captured = [] with patch("app.api.routes.generation_preview.CreateGenerationTaskUseCase") as MockUC: def _execute(cmd): captured.append(cmd) return tasks[len(captured) - 1] MockUC.return_value.execute.side_effect = _execute with patch("app.api.routes.generation_preview.safe_enqueue_generation_task", return_value=True): create_preview_generation_task( _make_preview_request(preview_count=3, titles=["共用标题"]), authenticated_user=_make_user(), generation_task_repository=repo, db=MagicMock(), ) assert all(c.title_config["text"] == "共用标题" for c in captured) def test_preview_independent_voice_per_variant(self): """voice_library_ids[] 按变体注入独立配音""" from app.api.routes.generation_preview import create_preview_generation_task tasks = [_make_task(task_id=f"task_{i}") for i in range(3)] repo = _repo_mock() captured = [] with patch("app.api.routes.generation_preview.CreateGenerationTaskUseCase") as MockUC: def _execute(cmd): captured.append(cmd) return tasks[len(captured) - 1] MockUC.return_value.execute.side_effect = _execute with patch("app.api.routes.generation_preview.safe_enqueue_generation_task", return_value=True): create_preview_generation_task( _make_preview_request( preview_count=3, voice_library_ids=["voice_a", "voice_b", "voice_c"], ), authenticated_user=_make_user(), generation_task_repository=repo, db=MagicMock(), ) assert [c.voice_library_id for c in captured] == ["voice_a", "voice_b", "voice_c"] def test_preview_voice_fallback_to_single_field(self): """voice_library_ids 为空时回退 voice_library_id 单值字段(向后兼容)""" from app.api.routes.generation_preview import create_preview_generation_task task = _make_task(task_id="task_1") repo = _repo_mock() captured = [] with patch("app.api.routes.generation_preview.CreateGenerationTaskUseCase") as MockUC: def _execute(cmd): captured.append(cmd) return task MockUC.return_value.execute.side_effect = _execute with patch("app.api.routes.generation_preview.safe_enqueue_generation_task", return_value=True): create_preview_generation_task( _make_preview_request(voice_library_id="legacy_voice"), authenticated_user=_make_user(), generation_task_repository=repo, db=MagicMock(), ) assert captured[0].voice_library_id == "legacy_voice" def test_preview_queue_limit_checks_total_count(self): """限流预检查按变体总数计:用户 pending + N 超限 → 429""" from app.api.routes.generation_preview import create_preview_generation_task from fastapi import HTTPException repo = MagicMock() repo.count_pending_by_user.return_value = 3 repo.count_pending_total.return_value = 0 with pytest.raises(HTTPException) as exc: create_preview_generation_task( _make_preview_request(preview_count=5), authenticated_user=_make_user(), generation_task_repository=repo, db=MagicMock(), ) assert exc.value.status_code == 429 def test_preview_reselect_failure_marks_all_failed(self): """#1743:变体独立选片(reselect)重试仍失败 → 已创建任务全部标记 failed 并 500""" from app.api.routes.generation_preview import create_preview_generation_task from fastapi import HTTPException tasks = [_make_task(task_id=f"task_{i}", source_plan_id="source_plan") for i in range(3)] repo = _repo_mock() with patch("app.api.routes.generation_preview.CreateGenerationTaskUseCase") as MockUC: MockUC.return_value.execute.side_effect = tasks with patch("app.services.edit_plan_service.EditPlanService") as MockPlanSvc: MockPlanSvc.return_value.reselect_plan_for_variant.side_effect = RuntimeError("db down") with pytest.raises(HTTPException) as exc: create_preview_generation_task( _make_preview_request(preview_count=3), authenticated_user=_make_user(), generation_task_repository=repo, db=MagicMock(), ) assert exc.value.status_code == 500 # 所有已创建任务都被标记 failed assert all(t.status == GenerationTaskStatus.FAILED for t in tasks) def test_preview_reselect_value_error_returns_400(self): """#1743:预览 count>1 reselect 素材不足(ValueError)→ 400,已建任务标 failed。""" from app.api.routes.generation_preview import create_preview_generation_task from fastapi import HTTPException tasks = [_make_task(task_id=f"task_{i}", source_plan_id="source_plan") for i in range(3)] repo = _repo_mock() with patch("app.api.routes.generation_preview.CreateGenerationTaskUseCase") as MockUC: MockUC.return_value.execute.side_effect = tasks with patch("app.api.routes.generation_preview.safe_enqueue_generation_task", return_value=True): with patch("app.services.edit_plan_service.EditPlanService") as MockPlanSvc: MockPlanSvc.return_value.reselect_plan_for_variant.side_effect = ValueError("素材池为空") with pytest.raises(HTTPException) as exc: create_preview_generation_task( _make_preview_request(preview_count=3), authenticated_user=_make_user(), generation_task_repository=repo, db=MagicMock(), ) assert exc.value.status_code == 400 assert "无法独立选片" in exc.value.detail assert all(t.status == GenerationTaskStatus.FAILED for t in tasks) def test_preview_reselect_retry_exhausted_returns_500(self): """#1743:预览 count>1 reselect 连续失败(非 ValueError)→ 500,已建任务标 failed。""" from app.api.routes.generation_preview import create_preview_generation_task from fastapi import HTTPException tasks = [_make_task(task_id=f"task_{i}", source_plan_id="source_plan") for i in range(3)] repo = _repo_mock() with patch("app.api.routes.generation_preview.CreateGenerationTaskUseCase") as MockUC: MockUC.return_value.execute.side_effect = tasks with patch("app.api.routes.generation_preview.safe_enqueue_generation_task", return_value=True): with patch("app.services.edit_plan_service.EditPlanService") as MockPlanSvc: MockPlanSvc.return_value.reselect_plan_for_variant.side_effect = RuntimeError("db down") with pytest.raises(HTTPException) as exc: create_preview_generation_task( _make_preview_request(preview_count=3), authenticated_user=_make_user(), generation_task_repository=repo, db=MagicMock(), ) assert exc.value.status_code == 500 assert all(t.status == GenerationTaskStatus.FAILED for t in tasks) def test_preview_count1_with_source_plan_clones(self): """#1743 零回归:预览 count=1 且有源 plan 仍走 clone(不 reselect)。""" from app.api.routes.generation_preview import create_preview_generation_task task = _make_task(task_id="task_1", source_plan_id="source_plan") repo = _repo_mock() with patch("app.api.routes.generation_preview.CreateGenerationTaskUseCase") as MockUC: MockUC.return_value.execute.return_value = task with patch("app.api.routes.generation_preview.safe_enqueue_generation_task", return_value=True): with patch("app.services.edit_plan_service.EditPlanService") as MockPlanSvc: MockPlanSvc.return_value.clone_plan_for_variant.return_value = MagicMock(id="clone_1") resp = create_preview_generation_task( _make_preview_request(preview_count=1), authenticated_user=_make_user(), generation_task_repository=repo, db=MagicMock(), ) assert resp.total == 1 MockPlanSvc.return_value.clone_plan_for_variant.assert_called_once() MockPlanSvc.return_value.reselect_plan_for_variant.assert_not_called() assert task.source_edit_plan_id == "clone_1" # ════════════════════════════════════════════════════════════════════════════ # 批量正式生成:变体配置注入 # ════════════════════════════════════════════════════════════════════════════ class TestBatchGenerationVariantConfig: """POST /tasks count=N 时变体独立配置。""" def _call_create_tasks(self, request, repo=None, db_latest_plan=None): from app.api.routes.generation_tasks import create_generation_task repo = repo or MagicMock() repo.count_pending_by_user.return_value = 0 repo.count_pending_total.return_value = 0 repo.update.return_value = None # 模板模式:asset_repository.find_by_id 返回 None(无 project 关联, # 纯模板模式 project_id/library_id 都为空),避免 MagicMock 属性污染 asset_repo = MagicMock() asset_repo.find_by_id.return_value = None # db.query().filter()...first():db_latest_plan 非空时模拟模板兜底查到最新 plan db = MagicMock() db.query.return_value.filter.return_value.order_by.return_value.first.return_value = db_latest_plan return create_generation_task( request, authenticated_user=_make_user(), generation_task_repository=repo, project_repository=MagicMock(), asset_library_repository=MagicMock(), asset_repository=asset_repo, db=db, ) def test_count_3_variant_titles_voices_covers_injected(self): """count=3:titles/voice_library_ids/cover_urls 按变体注入""" from app.api.routes import generation_tasks as routes tasks = [_make_task(task_id=f"gen_{i}") for i in range(3)] captured = [] with patch.object(routes, "CreateGenerationTaskUseCase") as MockUC: def _execute(cmd): captured.append(cmd) t = tasks[len(captured) - 1] t.title_config = cmd.title_config t.voice_library_id = cmd.voice_library_id t.cover_url = cmd.cover_url t.source_edit_plan_id = cmd.source_edit_plan_id # #1743:usecase 落库关联 plan return t MockUC.return_value.execute.side_effect = _execute with patch.object(routes, "safe_enqueue_generation_task", return_value=True): # #1743:count>1 必须有源 plan,变体 1..N-1 走 reselect 独立选片 with patch("app.services.edit_plan_service.EditPlanService") as MockPlanSvc: MockPlanSvc.return_value.reselect_plan_for_variant.side_effect = [ MagicMock(id="reselect_1"), MagicMock(id="reselect_2"), ] MockPlanSvc.return_value.clone_plan_for_variant.return_value = MagicMock(id="clone_v0") MockPlanSvc.return_value.get_asset_durations.return_value = {} req = CreateGenerationTaskRequest( template_id="tpl_1", asset_ids=["a1"], count=3, source_edit_plan_id="source_plan", title_config={"font": "宋体"}, titles=["成片标题1", "成片标题2", "成片标题3"], voice_library_ids=["v1", "v2", "v3"], cover_urls=["http://c1", "http://c2", "http://c3"], ) resp = self._call_create_tasks(req) # #1749:变体0 clone;变体1/2 reselect assert MockPlanSvc.return_value.reselect_plan_for_variant.call_count == 2 MockPlanSvc.return_value.clone_plan_for_variant.assert_called_once() assert resp.total == 3 assert [c.title_config["text"] for c in captured] == ["成片标题1", "成片标题2", "成片标题3"] assert [c.voice_library_id for c in captured] == ["v1", "v2", "v3"] assert [c.cover_url for c in captured] == ["http://c1", "http://c2", "http://c3"] # 样式共用 assert all(c.title_config["font"] == "宋体" for c in captured) def test_count_1_legacy_fields_unchanged(self): """N=1 不传数组:旧字段 voice_library_id/cover_url/title_config 行为不变""" from app.api.routes import generation_tasks as routes task = _make_task(task_id="gen_1") task.is_preview = False captured = [] with patch.object(routes, "CreateGenerationTaskUseCase") as MockUC: def _execute(cmd): captured.append(cmd) return task MockUC.return_value.execute.side_effect = _execute with patch.object(routes, "safe_enqueue_generation_task", return_value=True): req = CreateGenerationTaskRequest( template_id="tpl_1", asset_ids=["a1"], count=1, voice_library_id="legacy_voice", cover_url="http://legacy-cover", title_config={"text": "旧标题", "font": "黑体"}, ) resp = self._call_create_tasks(req) assert resp.total == 1 assert captured[0].voice_library_id == "legacy_voice" assert captured[0].cover_url == "http://legacy-cover" assert captured[0].title_config["text"] == "旧标题" def test_count_3_shared_single_value_arrays(self): """数组长度 1:3 个变体共用同一配音/封面""" from app.api.routes import generation_tasks as routes tasks = [_make_task(task_id=f"gen_{i}") for i in range(3)] captured = [] with patch.object(routes, "CreateGenerationTaskUseCase") as MockUC: def _execute(cmd): captured.append(cmd) t = tasks[len(captured) - 1] t.source_edit_plan_id = cmd.source_edit_plan_id # #1743:usecase 落库关联 plan return t MockUC.return_value.execute.side_effect = _execute with patch.object(routes, "safe_enqueue_generation_task", return_value=True): # #1743:count>1 必须有源 plan,变体 1..N-1 走 reselect 独立选片 with patch("app.services.edit_plan_service.EditPlanService") as MockPlanSvc: MockPlanSvc.return_value.reselect_plan_for_variant.side_effect = [ MagicMock(id="reselect_1"), MagicMock(id="reselect_2"), ] MockPlanSvc.return_value.clone_plan_for_variant.return_value = MagicMock(id="clone_v0") MockPlanSvc.return_value.get_asset_durations.return_value = {} req = CreateGenerationTaskRequest( template_id="tpl_1", asset_ids=["a1"], count=3, source_edit_plan_id="source_plan", voice_library_id="shared_voice", cover_urls=["http://shared"], ) self._call_create_tasks(req) assert MockPlanSvc.return_value.reselect_plan_for_variant.call_count == 2 assert all(c.voice_library_id == "shared_voice" for c in captured) assert all(c.cover_url == "http://shared" for c in captured) def test_count3_template_fallback_plan_used(self): """#1743:未传 source_edit_plan_id 时,模板兜底查到最新 plan 即作为批量源 plan。""" from app.api.routes import generation_tasks as routes tasks = [_make_task(task_id=f"gen_fb_{i}") for i in range(3)] def _execute(cmd): t = tasks[len([c for c in getattr(_execute, "caps", [])])] t.source_edit_plan_id = cmd.source_edit_plan_id _execute.caps.append(cmd) return t _execute.caps = [] latest = MagicMock(id="fallback_plan_id") req = CreateGenerationTaskRequest(template_id="tpl_1", asset_ids=["a1"], count=3) with patch.object(routes, "CreateGenerationTaskUseCase") as MockUC: MockUC.return_value.execute.side_effect = _execute with patch.object(routes, "safe_enqueue_generation_task", return_value=True): with patch("app.services.edit_plan_service.EditPlanService") as MockPlanSvc: MockPlanSvc.return_value.reselect_plan_for_variant.side_effect = [ MagicMock(id="reselect_1"), MagicMock(id="reselect_2"), ] MockPlanSvc.return_value.clone_plan_for_variant.return_value = MagicMock(id="clone_v0") MockPlanSvc.return_value.get_asset_durations.return_value = {} self._call_create_tasks(req, db_latest_plan=latest) # #1749:变体0 关联 clone plan(源为兜底 plan),变体1/2关联 reselect plan assert _execute.caps[0].source_edit_plan_id == "clone_v0" assert _execute.caps[1].source_edit_plan_id == "reselect_1" assert _execute.caps[2].source_edit_plan_id == "reselect_2" def test_count3_reselect_value_error_returns_400(self): """#1743:reselect 素材不足(ValueError)→ 400 明确报错,零任务入队。""" from app.api.routes import generation_tasks as routes from fastapi import HTTPException enqueue = MagicMock(return_value=True) req = CreateGenerationTaskRequest( template_id="tpl_1", asset_ids=["a1"], count=3, source_edit_plan_id="source_plan" ) with patch.object(routes, "CreateGenerationTaskUseCase") as MockUC: MockUC.return_value.execute.side_effect = lambda cmd: _make_task(task_id="should_not_run") with patch.object(routes, "safe_enqueue_generation_task", enqueue): with patch("app.services.edit_plan_service.EditPlanService") as MockPlanSvc: MockPlanSvc.return_value.reselect_plan_for_variant.side_effect = ValueError("素材池为空") with pytest.raises(HTTPException) as exc: self._call_create_tasks(req) assert exc.value.status_code == 400 assert "无法独立选片" in exc.value.detail enqueue.assert_not_called() def test_count3_reselect_retry_exhausted_returns_500(self): """#1743:reselect 连续 2 次都非 ValueError 失败 → 500,零任务入队。""" from app.api.routes import generation_tasks as routes from fastapi import HTTPException enqueue = MagicMock(return_value=True) req = CreateGenerationTaskRequest( template_id="tpl_1", asset_ids=["a1"], count=3, source_edit_plan_id="source_plan" ) with patch.object(routes, "CreateGenerationTaskUseCase") as MockUC: MockUC.return_value.execute.side_effect = lambda cmd: _make_task(task_id="should_not_run") with patch.object(routes, "safe_enqueue_generation_task", enqueue): with patch("app.services.edit_plan_service.EditPlanService") as MockPlanSvc: MockPlanSvc.return_value.reselect_plan_for_variant.side_effect = RuntimeError("db down") with pytest.raises(HTTPException) as exc: self._call_create_tasks(req) assert exc.value.status_code == 500 enqueue.assert_not_called() def test_count3_missing_plan_after_prebuild_raises_500(self): """#1743 兜底守卫:任务落库时 plan 丢失(usecase 未透传)→ 500,严禁静默共用。""" from app.api.routes import generation_tasks as routes from fastapi import HTTPException req = CreateGenerationTaskRequest( template_id="tpl_1", asset_ids=["a1"], count=3, source_edit_plan_id="source_plan" ) with patch.object(routes, "CreateGenerationTaskUseCase") as MockUC: # usecase 返回的任务 source_edit_plan_id 为空(模拟落库丢 plan) MockUC.return_value.execute.side_effect = lambda cmd: _make_task(task_id="lost_plan") with patch.object(routes, "safe_enqueue_generation_task", return_value=True): with patch("app.services.edit_plan_service.EditPlanService") as MockPlanSvc: MockPlanSvc.return_value.reselect_plan_for_variant.side_effect = [ MagicMock(id="reselect_1"), MagicMock(id="reselect_2"), ] with pytest.raises(HTTPException) as exc: self._call_create_tasks(req) assert exc.value.status_code == 500 assert "变体剪辑计划缺失" in exc.value.detail class TestVariantValueHelper: """_variant_value 取值逻辑。""" def test_empty_returns_fallback(self): from app.api.routes.generation_preview import _variant_value assert _variant_value([], 0, fallback="fb") == "fb" def test_single_length_shared(self): from app.api.routes.generation_preview import _variant_value assert _variant_value(["only"], 5) == "only" def test_indexed_access(self): from app.api.routes.generation_preview import _variant_value assert _variant_value(["a", "b", "c"], 1) == "b" def test_index_out_of_range_fallback(self): from app.api.routes.generation_preview import _variant_value assert _variant_value(["a", "b"], 9, fallback="x") == "x"