Files
xiaoxia-saas/tests/unit/test_viral_video_p0.py
T
CI Bot eda3a3a540
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 2s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m54s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 19s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 10s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m33s
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 Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API 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
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m4s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 35s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 2m38s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 4m15s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 4m49s
AI Code Review / AI Code Review (pull_request) Successful in 6m48s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 7m46s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 11m53s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m43s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 19m21s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
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
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 35s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 4m43s
style: auto-format with black + isort + ruff + prettier [skip ci-format-check]
2026-10-01 06:02:55 +00:00

310 lines
13 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.
"""#2106 P0 修复单测:Seedance 对接、image_analysis 持久化、TTS Path 统一、BGM/MuseTalk 跳过。"""
from __future__ import annotations
import sys
from pathlib import Path as _Path
# worker 容器 PYTHONPATH 包含 apps/worker(worker 侧代码使用顶层包名 services/、viral_video/)
_WORKER_ROOT = _Path(__file__).resolve().parents[2] / "apps" / "worker"
if str(_WORKER_ROOT) not in sys.path:
sys.path.insert(0, str(_WORKER_ROOT))
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from packages.domain.viral_video import ViralVideoJob, ViralVideoStatus
@pytest.fixture
def mock_job():
return ViralVideoJob(
user_id="user-001",
images=["https://img.com/1.jpg"],
industry="美妆",
duration=15,
user_copy_text="测试文案",
fusion_level="ai_polish",
)
# ── P0-2: _step_video_analysis import 路径 ──────────────────────────
class TestVideoAnalysisImport:
def test_no_reference_returns_none(self, mock_job):
from apps.worker.worker_app.tasks.viral_video import _step_video_analysis
mock_job.reference_video_url = ""
assert _step_video_analysis(mock_job) is None
def test_with_reference_returns_dict_or_none(self, mock_job):
"""有参考视频 URL 时,不管分析成功/失败/占位,返回 dict(不抛异常)。"""
from apps.worker.worker_app.tasks.viral_video import _step_video_analysis
mock_job.reference_video_url = "https://example.com/ref.mp4"
result = _step_video_analysis(mock_job)
# 允许占位/失败/真实返回,但绝不能抛异常
assert result is None or isinstance(result, dict)
# ── P0-3: image_analysis 字段 ─────────────────────────────────────
class TestImageAnalysisField:
def test_default_none(self):
job = ViralVideoJob(user_id="u1")
assert job.image_analysis is None
def test_persist_and_read(self, mock_job):
mock_job.image_analysis = {"products": [{"name": "口红"}]}
assert mock_job.image_analysis["products"][0]["name"] == "口红"
# ── P0-1: storyboard 规范化 ────────────────────────────────────────
class TestScriptGenerationV16:
"""v1.6 编导分镜脚本生成相关纯函数测试。"""
def test_fallback_script_has_required_fields(self, mock_job):
from apps.worker.worker_app.tasks.viral_video import _fallback_script
out = _fallback_script(mock_job)
assert isinstance(out, dict)
assert "overview" in out
assert "shots" in out
assert "voiceover_script" in out
assert "hard_constraints" in out
assert "negative_prompts" in out
assert out["overview"]["total_duration"] == mock_job.duration
assert out["final_copy"] == out["voiceover_script"]
assert len(out["shots"]) >= 1
def test_safe_json_loads_parses_fenced_code(self):
from apps.worker.worker_app.tasks.viral_video import _safe_json_loads
fenced = '```json\n{"voiceover_script": "你好", "shots": []}\n```'
out = _safe_json_loads(fenced)
assert out is not None
assert out["voiceover_script"] == "你好"
def test_safe_json_loads_handles_none(self):
from apps.worker.worker_app.tasks.viral_video import _safe_json_loads
assert _safe_json_loads(None) is None
assert _safe_json_loads("not json") is None
def test_validate_normalize_fills_defaults(self, mock_job):
from apps.worker.worker_app.tasks.viral_video import _validate_and_normalize_script
raw = {"voiceover_script": "你好", "shots": [{"scene_and_dialogue": "测试"}]}
out = _validate_and_normalize_script(raw, mock_job)
assert out["voiceover_script"] == "你好"
assert len(out["shots"]) == 1
assert out["shots"][0]["shot_type_angle_movement"]
assert out["overview"]["total_duration"] == mock_job.duration
def test_assemble_seedance_prompt_contains_sections(self, mock_job):
from apps.worker.worker_app.tasks.viral_video import _assemble_seedance_prompt
cr = {
"overview": {"theme": "测试", "total_duration": 15, "aspect_ratio": "9:16"},
"scene_and_lighting": "明亮",
"shots": [
{
"time_range": "0-15秒",
"shot_type_angle_movement": "中景",
"scene_and_dialogue": "你好",
"action_details": "展示",
"audio_bgm": "BGM",
"transition": "结束",
"reference_image_index": 0,
}
],
"hard_constraints": ["无字幕"],
"negative_prompts": ["水印"],
}
p = _assemble_seedance_prompt(cr, mock_job)
for key in ("【视频总览】", "【场景与光线】", "【逐镜头时间轴】", "【硬性约束】", "【负面提示词】"):
assert key in p
# ── P1: TTS 返回 Path|None ────────────────────────────────────────
class TestTTSPath:
def test_tts_returns_none_on_import_error(self, mock_job):
"""get_tts_service 抛 ImportError 时 _step_tts 返回 None。"""
from apps.worker.worker_app.tasks import viral_video as vv
with patch("apps.worker.services.tts_service_factory.get_tts_service", side_effect=ImportError("no tts")):
assert vv._step_tts(mock_job, "文案") is None
def test_tts_returns_none_when_path_not_exists(self, mock_job, tmp_path):
from apps.worker.worker_app.tasks import viral_video as vv
fake_service = MagicMock()
fake_service.synthesize.return_value = str(tmp_path / "not_exist.mp3")
with patch("apps.worker.services.tts_service_factory.get_tts_service", return_value=fake_service):
assert vv._step_tts(mock_job, "文案") is None
def test_tts_returns_path_when_exists(self, mock_job, tmp_path):
from apps.worker.worker_app.tasks import viral_video as vv
audio = tmp_path / "voice.mp3"
audio.write_bytes(b"ID3fake")
fake_service = MagicMock()
fake_service.synthesize.return_value = audio
with patch("apps.worker.services.tts_service_factory.get_tts_service", return_value=fake_service):
result = vv._step_tts(mock_job, "文案")
# Bug #2110: 校验传入了 voice_id+format=mp3
call_kwargs = fake_service.synthesize.call_args.kwargs
assert call_kwargs.get("format") == "mp3"
assert isinstance(result, Path)
assert result.exists()
# ── P1: BGM 跳过 / MuseTalk 无 persona 跳过 ───────────────────────
class TestDurationClamp:
"""v1.6 mark_copy_generated 派生字段 + duration clamp。"""
def test_mark_copy_generated_derives_fields(self):
job = ViralVideoJob(user_id="u1", duration=15)
cr = {
"overview": {"theme": "x", "total_duration": 15, "aspect_ratio": "9:16"},
"scene_and_lighting": "亮",
"shots": [{"time_range": "0-15秒", "scene_and_dialogue": "对白"}],
"voiceover_script": "你好",
"hard_constraints": [],
"negative_prompts": [],
}
job.mark_copy_generated(cr)
assert job.copy_result is cr
assert job.generated_copy_text == "你好"
assert job.storyboard == cr["shots"]
assert job.effective_copy_text == "你好"
# ── P0-1: call_video_generation 参数构造 ──────────────────────────
class TestCallVideoGeneration:
def test_returns_none_when_client_unavailable(self):
from packages.shared.ai_service import call_video_generation
with patch("packages.shared.ai_service.get_doubao_client") as mock_get:
mock_client = MagicMock()
mock_client.is_available = False
mock_get.return_value = mock_client
assert call_video_generation("prompt") is None
def test_delegates_to_client(self, tmp_path):
from packages.shared.ai_service import call_video_generation
out = tmp_path / "v.mp4"
out.write_bytes(b"fake")
with patch("packages.shared.ai_service.get_doubao_client") as mock_get:
mock_client = MagicMock()
mock_client.is_available = True
mock_client.video_generation.return_value = str(out)
mock_get.return_value = mock_client
result = call_video_generation(prompt="测试", image_url="https://img/x.jpg", duration=5, ratio="9:16")
assert result == str(out)
mock_client.video_generation.assert_called_once()
kwargs = mock_client.video_generation.call_args.kwargs
assert kwargs["prompt"] == "测试"
assert kwargs["image_url"] == "https://img/x.jpg"
assert kwargs["duration"] == 5
assert kwargs["generate_audio"] is True
# ── P0-1: _step_render 占位片段生成 ──────────────────────────────
class TestCallVideoGenerationV16:
"""v1.6 call_video_generation 透传 reference_audios/reference_images 等参数到 client。"""
def test_passes_reference_params_to_client(self, tmp_path):
from packages.shared.ai_service import call_video_generation
out = tmp_path / "v.mp4"
out.write_bytes(b"fake")
with patch("packages.shared.ai_service.get_doubao_client") as mock_get:
mock_client = MagicMock()
mock_client.is_available = True
mock_client.video_generation.return_value = str(out)
mock_get.return_value = mock_client
result = call_video_generation(
prompt="测试",
image_url="https://img/x.jpg",
duration=15,
ratio="9:16",
reference_images=["https://img/r1.jpg"],
reference_audios=["https://oss/tts.mp3"],
reference_videos=["https://oss/ref.mp4"],
generate_audio=True,
model="doubao-seedance-2-5-260628",
)
assert result == str(out)
kwargs = mock_client.video_generation.call_args.kwargs
# 首帧模式不传 ratio(Bug #2110)
assert "ratio" not in kwargs
assert kwargs["image_url"] == "https://img/x.jpg"
assert kwargs["reference_audios"] == ["https://oss/tts.mp3"]
assert kwargs["reference_images"] == ["https://img/r1.jpg"]
assert kwargs["reference_videos"] == ["https://oss/ref.mp4"]
assert kwargs["generate_audio"] is True
assert kwargs["model"] == "doubao-seedance-2-5-260628"
def test_ratio_passed_when_no_image(self, tmp_path):
from packages.shared.ai_service import call_video_generation
out = tmp_path / "v.mp4"
out.write_bytes(b"fake")
with patch("packages.shared.ai_service.get_doubao_client") as mock_get:
mock_client = MagicMock()
mock_client.is_available = True
mock_client.video_generation.return_value = str(out)
mock_get.return_value = mock_client
call_video_generation(prompt="测试", duration=10, ratio="16:9")
kwargs = mock_client.video_generation.call_args.kwargs
assert kwargs["ratio"] == "16:9"
assert kwargs["image_url"] is None
# ── P0-1: DoubaoClient.video_generation 在不可用时返回 None ───────
class TestDoubaoClientVideoGen:
def test_unavailable_returns_none(self):
from packages.shared.ai_client import DoubaoClient
client = DoubaoClient.__new__(DoubaoClient)
client.api_key = "" # is_available -> False
assert client.video_generation("prompt") is None
# ── P0-3: resume 从 job 读 image_analysis ────────────────────────
class TestResumeReadsImageAnalysis:
def test_resume_uses_persisted_image_analysis(self):
"""resume/render pipeline 应从 job.image_analysis 读(v1.5 _run_render_pipeline 共享渲染逻辑)。"""
import inspect
from apps.worker.worker_app.tasks import viral_video as vv
# v1.5 改造后 resume 委托给 _run_render_pipeline,那里读取 job.image_analysis
src = inspect.getsource(vv._run_render_pipeline)
assert "job.image_analysis" in src
assert "image_analysis" in src
# resume 本身应该调用 _run_render_pipeline
resume_src = inspect.getsource(vv.resume_viral_video_pipeline)
assert "_run_render_pipeline" in resume_src