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
310 lines
13 KiB
Python
310 lines
13 KiB
Python
"""#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
|