c1763b995c
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 / PR Build API Image (push) 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 / Build Staging Web Image (pull_request) Has been skipped
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 / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 4s
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 / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 6s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 6s
CI/CD Pipeline / Check push changed paths (push) Successful in 7s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) 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 / Deploy Staging (Watchtower auto-deploy) (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 Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 30s
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 29s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
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 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 / CI Gate (pull_request) Successful in 5s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 25s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 29s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Successful in 28s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m5s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m23s
CI/CD Pipeline / Integration Tests (push) Successful in 2m21s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m2s
CI/CD Pipeline / Validate - Style (push) Successful in 2m52s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m16s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m35s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m26s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m16s
AI Code Review / AI Code Review (pull_request) Successful in 6m27s
CI/CD Pipeline / Validate - Security (push) Successful in 6m50s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 4m4s
CI/CD Pipeline / Unit Tests (push) Successful in 8m48s
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production API 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 / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
508 lines
23 KiB
Python
508 lines
23 KiB
Python
"""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_clones_three_variant_plans(self):
|
||
"""有源 plan 时,N=3 克隆 3 个独立变体 plan(预览全部克隆,不用源 plan)"""
|
||
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()
|
||
cloned_plan_ids = ["clone_1", "clone_2", "clone_3"]
|
||
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:
|
||
clone_results = [MagicMock(id=pid) for pid in cloned_plan_ids]
|
||
MockPlanSvc.return_value.clone_plan_for_variant.side_effect = clone_results
|
||
create_preview_generation_task(
|
||
_make_preview_request(preview_count=3),
|
||
authenticated_user=_make_user(),
|
||
generation_task_repository=repo,
|
||
db=MagicMock(),
|
||
)
|
||
# 克隆被调用 3 次
|
||
assert MockPlanSvc.return_value.clone_plan_for_variant.call_count == 3
|
||
# 每个任务关联到不同的克隆 plan
|
||
for i, task in enumerate(tasks):
|
||
assert task.source_edit_plan_id == cloned_plan_ids[i]
|
||
|
||
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_clone_failure_marks_all_failed(self):
|
||
"""克隆变体 plan 失败 → 已创建任务全部标记 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.clone_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)
|
||
|
||
|
||
# ════════════════════════════════════════════════════════════════════════════
|
||
# 批量正式生成:变体配置注入
|
||
# ════════════════════════════════════════════════════════════════════════════
|
||
|
||
|
||
class TestBatchGenerationVariantConfig:
|
||
"""POST /tasks count=N 时变体独立配置。"""
|
||
|
||
def _call_create_tasks(self, request, repo=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() 返回 None:不走兜底关联编辑计划
|
||
db = MagicMock()
|
||
db.query.return_value.filter.return_value.order_by.return_value.first.return_value = None
|
||
|
||
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
|
||
return t
|
||
|
||
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=3,
|
||
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 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)
|
||
return tasks[len(captured) - 1]
|
||
|
||
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=3,
|
||
voice_library_ids=["shared_voice"],
|
||
cover_urls=["http://shared"],
|
||
)
|
||
self._call_create_tasks(req)
|
||
assert all(c.voice_library_id == "shared_voice" for c in captured)
|
||
assert all(c.cover_url == "http://shared" for c in captured)
|
||
|
||
|
||
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"
|