Files
xiaoxia-saas/tests/unit/test_viral_video_p0.py
T
xiaoxia 76e11cb2f9
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 / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
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 / 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 / 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 (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 17s
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 / 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 / PR Build Worker Image (pull_request) Successful in 1m9s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m10s
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 / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m7s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 30s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m45s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m55s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m17s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 3m43s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m0s
CI/CD Pipeline / Validate - Style (push) Successful in 4m12s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 4m9s
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 / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m13s
AI Code Review / AI Code Review (pull_request) Successful in 7m6s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m42s
CI/CD Pipeline / Validate - Security (push) Successful in 10m2s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m45s
CI/CD Pipeline / Unit Tests (push) Successful in 12m20s
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 / Staging API Integration Tests (push) Successful in 42m27s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
fix(viral-video) P0/P1: Seedance模型ID映射+很近全局替换+失败状态强落库 (#2157)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-10-03 16:51:25 +08:00

446 lines
19 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 = {
"video_path": str(out),
"usage": {"completion_tokens": 1000000},
}
mock_get.return_value = mock_client
result = call_video_generation(prompt="测试", image_url="https://img/x.jpg", duration=5, ratio="9:16")
assert result is not None and result["video_path"] == 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 = {
"video_path": str(out),
"usage": {"completion_tokens": 1500000},
}
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 is not None and result["video_path"] == str(out)
kwargs = mock_client.video_generation.call_args.kwargs
# 图生视频也必须传 ratio(避免首帧方图导致默认输出 1:1)
assert kwargs.get("ratio") == "9:16", f"ratio 应透传,got {kwargs.get('ratio')!r}"
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 = {"video_path": str(out), "usage": None}
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
# ============ #2157 _replace_henjin_everywhere 递归替换单测 ============
class TestReplaceHenjinEverywhere:
"""覆盖 #2157 P1:递归替换 copy_result 中所有层级的'很近'→'最近'。"""
def _import(self):
from apps.worker.worker_app.tasks.viral_video import _replace_henjin_everywhere
return _replace_henjin_everywhere
def test_plain_string_no_henjin(self):
fn = self._import()
assert fn("最近好物推荐") == "最近好物推荐"
assert fn("") == ""
assert fn(None) is None
assert fn(123) == 123
def test_string_with_henjin(self):
fn = self._import()
assert fn("很近是不是总觉得颈肩发僵") == "最近是不是总觉得颈肩发僵"
# 多次出现
assert fn("很近很近都很近") == "最近最近都最近"
def test_list_recursive(self):
fn = self._import()
out = fn(["很近a", "b", ["很近c", "d"]])
assert out == ["最近a", "b", ["最近c", "d"]]
def test_dict_recursive_nested(self):
fn = self._import()
obj = {
"overview": {"theme": "很近颈肩", "title": "x"},
"scene_and_lighting": "很近才好用",
"voiceover_script": "很近是不是",
"final_copy": "很近好物",
"shots": [
{"scene_and_dialogue": "很近第一镜", "action_details": "很近动作", "audio_bgm": "很近音乐"},
{"nested": {"deep": "很近深层"}},
],
"int_field": 42,
}
import json
out = fn(obj)
assert "很近" not in json.dumps(out, ensure_ascii=False)
assert out["overview"]["theme"] == "最近颈肩"
assert out["shots"][0]["scene_and_dialogue"] == "最近第一镜"
assert out["shots"][1]["nested"]["deep"] == "最近深层"
assert out["int_field"] == 42
class TestMarkFailedAndNotifySessionFallback:
"""#2157 P1:_mark_failed_and_notify 在原session失效时fallback到新SessionLocal。"""
def test_fallback_to_new_session_when_original_save_raises(self, tmp_path):
from unittest.mock import MagicMock, patch
from apps.worker.worker_app.tasks import viral_video as vv
job = MagicMock()
job.is_terminal = False
job.mark_failed = MagicMock()
# 原 session 保存抛异常
orig_session = MagicMock()
orig_repo = MagicMock()
def _raise(*a, **kw):
raise RuntimeError("session in rollback")
# 第一次调用_save_job抛异常,触发fallback
with patch.object(vv, "_save_job", side_effect=_raise):
fake_ssn = MagicMock()
fake_repo = MagicMock()
fake_job_in_db = MagicMock()
fake_job_in_db.is_terminal = False
fake_repo.get.return_value = fake_job_in_db
with patch.object(vv, "SessionLocal", return_value=fake_ssn):
with patch.object(vv, "SQLAlchemyViralVideoJobRepository", return_value=fake_repo):
with patch.object(vv, "_emit_progress") as mock_emit:
vv._mark_failed_and_notify("job-1", orig_session, orig_repo, job, "boom", stage="render")
# 原session上mark_failed被调用过
job.mark_failed.assert_called()
# fallback路径:新session上repo.get(job-1)被调用,且新job被mark_failed并commit
fake_repo.get.assert_called_with("job-1")
fake_job_in_db.mark_failed.assert_called_with("boom")
fake_repo.update.assert_called_with(fake_job_in_db)
fake_ssn.commit.assert_called()
fake_ssn.close.assert_called()
mock_emit.assert_called_once()
def test_original_session_happy_path_no_fallback(self):
from unittest.mock import MagicMock, patch
from apps.worker.worker_app.tasks import viral_video as vv
job = MagicMock()
job.is_terminal = False
session = MagicMock()
repo = MagicMock()
with patch.object(vv, "_save_job") as mock_save:
with patch.object(vv, "_emit_progress") as mock_emit:
# 不mock SessionLocal,如果fallback被错误触发会抛AttributeError
vv._mark_failed_and_notify("job-2", session, repo, job, "err", stage="copy")
job.mark_failed.assert_called_with("err")
mock_save.assert_called()
mock_emit.assert_called_once()
def test_terminal_job_not_marked(self):
from unittest.mock import MagicMock, patch
from apps.worker.worker_app.tasks import viral_video as vv
job = MagicMock()
job.is_terminal = True # 已终态
session = MagicMock()
repo = MagicMock()
with patch.object(vv, "_save_job") as mock_save:
with patch.object(vv, "_emit_progress"):
fake_ssn = MagicMock()
with patch.object(vv, "SessionLocal", return_value=fake_ssn):
with patch.object(vv, "SQLAlchemyViralVideoJobRepository") as mock_repo_cls:
vv._mark_failed_and_notify("job-3", session, repo, job, "x")
# 终态job不调用mark_failed
job.mark_failed.assert_not_called()
# 且因 job 已终态,_save_job 也不应被调用(marked=False 才fallback;但此处 job 非 None 且 is_terminal=True,marked 保持 False 进入 fallback)
# fallback路径会重新打开session,get到的job也是终态,不会update