Files
xiaoxia-saas/tests/unit/test_1970_assembly_schema.py
T
xiaoxia 797a220d43
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 (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
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 (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
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 / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
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 / PR Build Worker Image (pull_request) Successful in 45s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 49s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m15s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m59s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 3m10s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 3m31s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 3m44s
AI Code Review / AI Code Review (pull_request) Successful in 6m28s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 7m4s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 9m47s
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 / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 7m32s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 21s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 38s
feat(#1970): 新 API 字段 + 叙事模式 PR3 - assembly_mode/script_id/tts_*/video_ratio
- CreateGenerationTaskRequest 新增 assembly_mode(random 默认/narrative)、
  script_id、tts_voice_id、tts_voice_source(preset/clone)、video_ratio;
  模型校验:narrative 必须带 script_id+tts_voice_id,比例枚举白名单
- 叙事模式入队前同步完成 TTS:读文案(归属校验) → 解析音色(preset/clone)
  → 复用 tts_job workflow 同步合成(失败 4xx 不入队,积分按 ai_voice 口径扣退)
  → 转存配音库 audio asset,voice_library_id 指向它,下游渲染零改动
- narrative_match 纯模块:文案 tags 与素材标签归一化求交集,命中池优先
  + smart_match 评分,不足/无匹配自动降级现有随机逻辑(行为与旧版一致)
- video_ratio→输出分辨率映射(9:16/16:9/1:1/3:4/4:3),显式分辨率优先
- writeback 落 assembly_mode/script_id/video_ratio 便于追溯
- one_take/pip/voice_pip 标记 deprecated(不删代码),voice_over 保留
- 新增 53 个测试;全量 tests/unit 15736 passed / 28 skipped
2026-09-18 07:17:20 +08:00

219 lines
7.8 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.
"""#1970 PR3 schema 校验 + 路由辅助函数测试。"""
from __future__ import annotations
from dataclasses import dataclass, field
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from app.api.routes import generation_tasks as gt
from app.schemas.generation_task import CreateGenerationTaskRequest
from pydantic import ValidationError
# ── schema ─────────────────────────────────────────────────────────────────
def _base_payload(**overrides):
payload = dict(
template_id="tpl1",
asset_ids=["a1", "a2"],
duration=30,
title_text="t",
editing_mode="voice_over",
)
payload.update(overrides)
return payload
class TestAssemblySchema:
def test_defaults(self):
req = CreateGenerationTaskRequest(**_base_payload())
assert req.assembly_mode == "random"
assert req.script_id == ""
assert req.tts_voice_id == ""
assert req.tts_voice_source == "preset"
assert req.video_ratio == "" # 空串=沿用模板默认(前端新流程显式传 9:16)
assert req.dedup_enabled is True
def test_narrative_accepts_fields(self):
req = CreateGenerationTaskRequest(
**_base_payload(
assembly_mode="narrative",
script_id="s1",
tts_voice_id="longxiaochun",
tts_voice_source="clone",
video_ratio="16:9",
)
)
assert req.assembly_mode == "narrative"
assert req.script_id == "s1"
def test_bad_assembly_mode_rejected(self):
with pytest.raises(ValidationError):
CreateGenerationTaskRequest(**_base_payload(assembly_mode="movie"))
def test_bad_voice_source_rejected(self):
with pytest.raises(ValidationError):
CreateGenerationTaskRequest(**_base_payload(tts_voice_source="elevenlabs"))
def test_bad_video_ratio_rejected(self):
with pytest.raises(ValidationError):
CreateGenerationTaskRequest(**_base_payload(video_ratio="4:5"))
def test_narrative_without_script_rejected(self):
with pytest.raises(ValidationError) as ei:
CreateGenerationTaskRequest(**_base_payload(assembly_mode="narrative"))
assert "script_id" in str(ei.value)
def test_narrative_without_voice_rejected(self):
with pytest.raises(ValidationError) as ei:
CreateGenerationTaskRequest(**_base_payload(assembly_mode="narrative", script_id="s1"))
assert "tts_voice_id" in str(ei.value)
def test_random_mode_ignores_script_absence(self):
req = CreateGenerationTaskRequest(**_base_payload())
assert req.assembly_mode == "random"
# ── _select_assets_from_library 的叙事分支 ─────────────────────────────────
@dataclass
class _Asset:
id: str
status: object = field(default_factory=lambda: SimpleNamespace(value="ready"))
mime_type: str = "video/mp4"
tags: list[str] = field(default_factory=list)
tag_ids: list[str] = field(default_factory=list)
file_type: str = "video"
quality_score: float | None = None
duration: float = 8.0
created_at: object = None
metadata: dict = field(default_factory=dict)
class TestNarrativeSelectInRoute:
def test_narrative_tags_prioritize_matched(self):
assets = [
_Asset("a1", tags=["工厂"]),
_Asset("a2", tags=["旅游"]),
_Asset("a3", tags=["工厂"]),
]
picked = gt._select_assets_from_library(assets, mode="all", count=2, script_tags=["工厂"])
assert set(picked) == {"a1", "a3"}
def test_narrative_no_match_falls_back_to_full_pool(self):
assets = [_Asset("a1", tags=["工厂"]), _Asset("a2", tags=["旅游"])]
picked = gt._select_assets_from_library(assets, mode="all", count=2, script_tags=["美食"])
assert set(picked) == {"a1", "a2"}
def test_tag_ids_via_index(self):
assets = [_Asset("a1", tag_ids=["t1"]), _Asset("a2", tag_ids=["t2"])]
picked = gt._select_assets_from_library(
assets,
mode="all",
count=1,
script_tags=["教程"],
tag_names_by_id={"a1": ["教程"], "a2": ["旅游"]},
)
assert picked == ["a1"]
def test_no_script_tags_smart_path_unchanged(self):
assets = [_Asset("a1"), _Asset("a2")]
picked = gt._select_assets_from_library(assets, mode="smart", count=1)
assert picked # 非空即可,评分逻辑由 smart_match 自己的测试覆盖
# ── _load_asset_tag_names(DB 替身) ────────────────────────────────────────
class _FakeRow:
def __init__(self, **kw):
self.__dict__.update(kw)
class _FakeQuery:
def __init__(self, rows):
self._rows = rows
def filter(self, *a, **k):
return self
def all(self):
return self._rows
class _FakeDb:
def __init__(self, name_rows, link_rows):
self._maps = {
"names": name_rows,
"links": link_rows,
}
def query(self, *cols):
# _load_asset_tag_names 两次查询:第一次取 (id, name),第二次取 (asset_id, tag_id)
keys = tuple(getattr(c, "key", None) for c in cols)
if keys and keys[0] == "id":
return _FakeQuery(self._maps["names"])
return _FakeQuery(self._maps["links"])
@dataclass
class _TagIdAsset:
id: str
tag_ids: list[str]
class TestLoadAssetTagNames:
def test_builds_index(self):
assets = [_TagIdAsset("a1", ["t1", "t2"]), _TagIdAsset("a2", ["t2"])]
db = _FakeDb(
name_rows=[_FakeRow(id="t1", name="工厂"), _FakeRow(id="t2", name="带货")],
link_rows=[
("a1", "t1"),
("a1", "t2"),
("a2", "t2"),
],
)
idx = gt._load_asset_tag_names(db, assets, "u1")
assert idx == {"a1": ["工厂", "带货"], "a2": ["带货"]}
def test_no_tag_ids_returns_empty(self):
assert gt._load_asset_tag_names(_FakeDb([], []), [_TagIdAsset("a1", [])], "u1") == {}
def test_query_failure_degrades_empty(self):
class BoomQuery:
def filter(self, *a, **k):
raise RuntimeError("db down")
class BoomDb:
def query(self, *a):
return BoomQuery()
idx = gt._load_asset_tag_names(BoomDb(), [_TagIdAsset("a1", ["t1"])], "u1")
assert idx == {}
# ── _resolve_output_dimensions ─────────────────────────────────────────────
class TestResolveOutputDimensions:
def _req(self, ratio="", width=1280, height=720):
return CreateGenerationTaskRequest(**_base_payload(video_ratio=ratio, output_width=width, output_height=height))
def test_known_ratios(self):
assert gt._resolve_output_dimensions(self._req("9:16")) == (1080, 1920)
assert gt._resolve_output_dimensions(self._req("16:9")) == (1920, 1080)
assert gt._resolve_output_dimensions(self._req("1:1")) == (1080, 1080)
assert gt._resolve_output_dimensions(self._req("4:3")) == (1440, 1080)
assert gt._resolve_output_dimensions(self._req("3:4")) == (1080, 1440)
def test_old_call_default_kept_when_no_ratio(self):
assert gt._resolve_output_dimensions(self._req("")) == (1280, 720)
def test_explicit_dimensions_take_precedence(self):
# 非旧默认值(720p)的显式分辨率优先于 ratio 映射
req = self._req("9:16", width=1440, height=2560)
assert gt._resolve_output_dimensions(req) == (1440, 2560)