Files
xiaoxia-saas/tests/unit/test_1677_batch_variants.py
T
xiaoxia 0469272bd6
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 9s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 22s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 38s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m59s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 33s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m23s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m24s
CI/CD Pipeline / Integration Tests (push) Successful in 2m40s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m31s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m16s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 3s
CI/CD Pipeline / Validate - Style (push) Successful in 3m6s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 47s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m42s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m48s
CI/CD Pipeline / Validate - Security (push) Successful in 5m28s
AI Code Review / AI Code Review (pull_request) Failing after 5m35s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m5s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m22s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 5m6s
CI/CD Pipeline / Unit Tests (push) Successful in 8m54s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
fix(#1743): 批量变体独立选片——完整重跑单视频选片+批次20%重叠避让+查重超阈重渲+封面独立 (#1745)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-06 18:07:24 +08:00

689 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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="voice_library_ids"):
_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
create_preview_generation_task(
_make_preview_request(preview_count=3),
authenticated_user=_make_user(),
generation_task_repository=repo,
db=MagicMock(),
)
# 变体 1..N-1 各独立选片一次(共 2 次);count>1 不再走 clone
assert MockPlanSvc.return_value.reselect_plan_for_variant.call_count == 2
MockPlanSvc.return_value.clone_plan_for_variant.assert_not_called()
# 变体0保留源 plan;变体1/2 关联各自独立选出的 plan
assert tasks[0].source_edit_plan_id == "source_plan"
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=3titles/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 # #1743usecase 落库关联 plan
return t
MockUC.return_value.execute.side_effect = _execute
with patch.object(routes, "safe_enqueue_generation_task", return_value=True):
# #1743count>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"),
]
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)
assert MockPlanSvc.return_value.reselect_plan_for_variant.call_count == 2
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):
"""数组长度 13 个变体共用同一配音/封面"""
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 # #1743usecase 落库关联 plan
return t
MockUC.return_value.execute.side_effect = _execute
with patch.object(routes, "safe_enqueue_generation_task", return_value=True):
# #1743count>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"),
]
req = CreateGenerationTaskRequest(
template_id="tpl_1",
asset_ids=["a1"],
count=3,
source_edit_plan_id="source_plan",
voice_library_ids=["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"),
]
self._call_create_tasks(req, db_latest_plan=latest)
# 兜底 plan 被用作源;变体0关联兜底 plan,变体1/2关联 reselect plan
assert _execute.caps[0].source_edit_plan_id == "fallback_plan_id"
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):
"""#1743reselect 素材不足(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):
"""#1743reselect 连续 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"