Files
xiaoxia-saas/tests/unit/test_oneclick_gen_p0_fixes.py
T
xiaoxia cd7e75a845
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
fix(#633): 修复一键生成转场时长不生效 - _VirtualClip缺transition_duration字段 (#738)
2026-07-22 22:42:14 +08:00

608 lines
24 KiB
Python
Executable File
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.
"""P0/P1 修复单元测试 — 一键生成 P0 问题 + P1 校验.
覆盖:
P0-1: _download_library_assets 双模式查询(asset_library_id / project_id
P0-2: OSS 上传失败抛异常 + URL 可访问性校验
P0-3: FFmpeg 失败时完整 stderr 日志
P1: template_id 存在性校验 + asset_ids 归属校验
"""
from __future__ import annotations
import logging
import subprocess
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
# 添加 worker app 到 sys.path
_WORKER_ROOT = Path(__file__).resolve().parents[2] / "apps" / "worker"
if str(_WORKER_ROOT) not in sys.path:
sys.path.insert(0, str(_WORKER_ROOT))
# ── P0-1: _download_library_assets ────────────────────────────────────────────
class TestDownloadLibraryAssets:
"""P0-1: 素材下载双模式 + 错误处理."""
def _make_asset(self, id_: str, file_url: str, project_id: str = "p1", library_id: str = "lib1"):
mock = MagicMock()
mock.id = id_
mock.file_url = file_url
mock.name = f"asset_{id_}"
mock.project_id = project_id
mock.asset_library_id = library_id
return mock
@patch("worker_app.tasks.generation.SessionLocal")
@patch("worker_app.tasks.generation.download_asset")
def test_asset_library_mode(self, mock_download, mock_session_factory):
"""素材库模式:按 asset_library_id 查询."""
from worker_app.tasks.generation import _download_library_assets
session = MagicMock()
mock_session_factory.return_value = session
query = MagicMock()
session.query.return_value = query
filter_result = MagicMock()
query.filter.return_value = filter_result
in_filter = MagicMock()
filter_result.filter.return_value = in_filter
assets = [self._make_asset("a1", "video/a1.mp4")]
in_filter.order_by.return_value.all.return_value = assets
mock_download.return_value = True
with patch("worker_app.tasks.generation.AssetModel", create=True):
result = _download_library_assets(
Path("/tmp/test"),
asset_library_id="lib1",
)
assert len(result) == 1
mock_download.assert_called_once()
@patch("worker_app.tasks.generation.SessionLocal")
@patch("worker_app.tasks.generation.download_asset")
def test_project_mode(self, mock_download, mock_session_factory):
"""项目级模式:asset_library_id 为空时按 project_id 查询."""
from worker_app.tasks.generation import _download_library_assets
session = MagicMock()
mock_session_factory.return_value = session
query = MagicMock()
session.query.return_value = query
filter_result = MagicMock()
query.filter.return_value = filter_result
proj_filter = MagicMock()
filter_result.filter.return_value = proj_filter
assets = [self._make_asset("a1", "video/a1.mp4", project_id="proj1")]
proj_filter.order_by.return_value.all.return_value = assets
mock_download.return_value = True
result = _download_library_assets(
Path("/tmp/test"),
project_id="proj1",
)
assert len(result) == 1
def test_both_empty_raises(self):
"""asset_library_id 和 project_id 都为空时抛 ValueError."""
from worker_app.tasks.generation import _download_library_assets
with pytest.raises(ValueError, match="至少需要提供一个"):
_download_library_assets(Path("/tmp/test"))
@patch("worker_app.tasks.generation.SessionLocal")
def test_no_assets_found_raises(self, mock_session_factory):
"""查不到素材时抛 RuntimeError."""
from worker_app.tasks.generation import _download_library_assets
session = MagicMock()
mock_session_factory.return_value = session
query = MagicMock()
session.query.return_value = query
filter_result = MagicMock()
query.filter.return_value = filter_result
in_filter = MagicMock()
filter_result.filter.return_value = in_filter
in_filter.order_by.return_value.all.return_value = []
with pytest.raises(RuntimeError, match="未找到视频素材"):
_download_library_assets(
Path("/tmp/test"),
asset_library_id="lib1",
)
@patch("worker_app.tasks.generation.SessionLocal")
@patch("worker_app.tasks.generation.download_asset")
def test_all_asset_ids_fail_raises(self, mock_download, mock_session_factory):
"""指定 asset_ids 但全部下载失败时抛 RuntimeError."""
from worker_app.tasks.generation import _download_library_assets
session = MagicMock()
mock_session_factory.return_value = session
query = MagicMock()
session.query.return_value = query
filter_result = MagicMock()
query.filter.return_value = filter_result
id_filter = MagicMock()
filter_result.filter.return_value = id_filter
assets = [self._make_asset("a1", "video/a1.mp4")]
id_filter.order_by.return_value.all.return_value = assets
mock_download.return_value = False # 全部下载失败
with pytest.raises(RuntimeError, match="素材下载失败"):
_download_library_assets(
Path("/tmp/test"),
asset_library_id="lib1",
asset_ids=["a1"],
)
# ── P0-2: OSS 上传 + URL 校验 ────────────────────────────────────────────────
class TestOSSUploadAndVerify:
"""P0-2: OSS 上传失败抛异常 + URL 可访问性校验."""
def test_verify_url_accessible_success(self):
"""URL 可访问时返回 True."""
from worker_app.tasks.generation import _verify_url_accessible
mock_response = MagicMock()
mock_response.status = 200
mock_response.__enter__ = MagicMock(return_value=mock_response)
mock_response.__exit__ = MagicMock(return_value=False)
with patch("urllib.request.OpenerDirector.open", return_value=mock_response):
assert _verify_url_accessible("https://example.com/test.mp4") is True
def test_verify_url_accessible_failure(self):
"""URL 不可访问时返回 False."""
from worker_app.tasks.generation import _verify_url_accessible
with patch("urllib.request.OpenerDirector.open", side_effect=Exception("connection refused")):
assert _verify_url_accessible("https://example.com/test.mp4") is False
def test_verify_url_404(self):
"""URL 返回 404 时返回 False."""
from worker_app.tasks.generation import _verify_url_accessible
mock_response = MagicMock()
mock_response.status = 404
mock_response.__enter__ = MagicMock(return_value=mock_response)
mock_response.__exit__ = MagicMock(return_value=False)
with patch("urllib.request.OpenerDirector.open", return_value=mock_response):
assert _verify_url_accessible("https://example.com/test.mp4") is False
# ── P0-3: FFmpeg stderr 日志 ─────────────────────────────────────────────────
class TestFFmpegStderrLogging:
"""P0-3: FFmpeg 失败时完整 stderr 打到日志."""
def test_run_ffmpeg_logs_stderr_on_failure(self, caplog):
"""run_ffmpeg 失败时记录 stderr 到日志."""
from video_processing.ffmpeg_utils import run_ffmpeg
error = subprocess.CalledProcessError(
returncode=183,
cmd=["ffmpeg", "-y", "-i", "input.mp4", "output.mp4"],
output="",
stderr="Error message from ffmpeg: filter graph error details here",
)
with patch("subprocess.run", side_effect=error):
with caplog.at_level(logging.ERROR):
with pytest.raises(subprocess.CalledProcessError):
run_ffmpeg(["ffmpeg", "-y", "-i", "input.mp4", "output.mp4"])
assert "FFmpeg 命令失败" in caplog.text
assert "exit_code=183" in caplog.text
assert "filter graph error" in caplog.text
def test_run_ffmpeg_success(self):
"""run_ffmpeg 成功时正常返回."""
from video_processing.ffmpeg_utils import run_ffmpeg
mock_result = MagicMock()
mock_result.stdout = "output"
mock_result.stderr = ""
with patch("subprocess.run", return_value=mock_result):
stdout, stderr = run_ffmpeg(["ffmpeg", "-version"])
assert stdout == "output"
assert stderr == ""
# ── P1: template_id + asset_ids 校验 ─────────────────────────────────────────
class TestP1Validations:
"""P1: template_id 存在性校验 + asset_ids 归属校验."""
def test_validate_template_exists_success(self):
"""模板存在时不抛异常."""
from worker_app.tasks.generation import _validate_template_exists
mock_template = MagicMock()
mock_template.id = "tmpl_001"
mock_template.name = "Test Template"
mock_template.is_active = True
mock_template.status = "active"
mock_template.version = 1
session = MagicMock()
# EditTemplateModel 查询返回 None(走旧模板系统 fallback
edit_query = MagicMock()
edit_filter = MagicMock()
edit_query.filter.return_value = edit_filter
edit_filter.first.return_value = None
# TemplateModel 查询返回 mock_template
old_query = MagicMock()
old_filter = MagicMock()
old_query.filter.return_value = old_filter
old_filter.first.return_value = mock_template
def _query_side_effect(model):
# 根据 model 类型返回不同的 query mock
name = getattr(model, "__name__", "")
if "EditTemplate" in name:
return edit_query
return old_query
session.query.side_effect = _query_side_effect
with patch("worker_app.tasks.generation.SessionLocal", return_value=session):
_validate_template_exists("tmpl_001") # 不抛异常
def test_validate_template_exists_not_found(self):
"""模板不存在时抛 ValueError."""
from worker_app.tasks.generation import _validate_template_exists
session = MagicMock()
# 两个系统查询都返回 None
def _query_side_effect(model):
q = MagicMock()
f = MagicMock()
q.filter.return_value = f
f.first.return_value = None
return q
session.query.side_effect = _query_side_effect
with patch("worker_app.tasks.generation.SessionLocal", return_value=session):
with pytest.raises(ValueError, match="模板不存在"):
_validate_template_exists("tmpl_nonexistent")
# ── P1: 一键生成 clip 级效果层映射 ───────────────────────────────────────────
class TestTemplateClipEffectMapping:
"""P1: 模板 clip 级效果层映射到一键生成素材 clips."""
def _make_virtual_clip(self, idx: int, clip_type: str = "main", config: dict | None = None):
from dataclasses import dataclass, field
_clip_type_val = clip_type
@dataclass
class FakeClip:
id: str = f"vc_{idx:03d}"
plan_id: str = "task_001"
clip_type: str = _clip_type_val
order: int = idx
asset_id: str = f"asset_{idx}"
duration: float = 5.0
transition_effect: str = "cut"
transition_duration: float = 0.0
playback_speed: float = 1.0
config: dict = field(default_factory=dict)
return FakeClip(config=config or {})
def _make_template_clip_config(self, clip_type: str = "main", transition: str = "cut", config: dict | None = None):
mock = MagicMock()
mock.clip_type = clip_type
mock.transition_effect = transition
mock.config = config or {}
mock.default_duration = 3.0
mock.text_template = ""
return mock
def test_transition_effect_mapped(self):
"""转场效果正确映射到素材 clips."""
from worker_app.tasks.generation import _apply_template_clip_effects
clips = [self._make_virtual_clip(i) for i in range(3)]
clip_configs = [
self._make_template_clip_config("main", transition="fade"),
self._make_template_clip_config("main", transition="dissolve"),
]
_apply_template_clip_effects(clips, clip_configs, "one_take")
# 前两个按顺序映射,第三个用最后一个模板配置
assert clips[0].transition_effect == "fade"
assert clips[1].transition_effect == "dissolve"
assert clips[2].transition_effect == "dissolve" # 复用最后一个
def test_color_grade_mapped(self):
"""滤镜配置正确映射到 clip.config.color_grade."""
from worker_app.tasks.generation import _apply_template_clip_effects
clips = [self._make_virtual_clip(i) for i in range(2)]
clip_configs = [
self._make_template_clip_config(
"main", config={"color_grade": {"enabled": True, "filter": "vintage", "brightness": 0.1}}
),
]
_apply_template_clip_effects(clips, clip_configs, "one_take")
assert clips[0].config["color_grade"]["filter"] == "vintage"
assert clips[0].config["color_grade"]["brightness"] == 0.1
# 第二个素材复用第一个模板配置
assert clips[1].config["color_grade"]["filter"] == "vintage"
def test_existing_config_preserved(self):
"""已有 clip.config 内容(如 role)被保留."""
from worker_app.tasks.generation import _apply_template_clip_effects
clips = [self._make_virtual_clip(0, config={"role": "b_roll"})]
clip_configs = [
self._make_template_clip_config("main", config={"color_grade": {"enabled": True, "filter": "warm"}}),
]
_apply_template_clip_effects(clips, clip_configs, "voice_over")
assert clips[0].config["role"] == "b_roll" # 保留原有配置
assert clips[0].config["color_grade"]["filter"] == "warm" # 新增滤镜配置
def test_empty_clip_configs_no_change(self):
"""空模板配置时 clips 保持不变."""
from worker_app.tasks.generation import _apply_template_clip_effects
clips = [self._make_virtual_clip(i) for i in range(2)]
_apply_template_clip_effects(clips, [], "one_take")
assert clips[0].transition_effect == "cut"
assert clips[1].transition_effect == "cut"
def test_cut_transition_not_overwritten(self):
"""模板转场为 cut 时不覆盖(保持默认)."""
from worker_app.tasks.generation import _apply_template_clip_effects
clips = [self._make_virtual_clip(0)]
clips[0].transition_effect = "fade" # 已有非默认值
clip_configs = [
self._make_template_clip_config("main", transition="cut"),
]
_apply_template_clip_effects(clips, clip_configs, "one_take")
# 模板是 cut 时,保留原有值(避免无意义覆盖)
assert clips[0].transition_effect == "fade"
def test_transition_duration_mapped(self):
"""转场时长(transition_duration)从模板 config 正确映射到 clip."""
from worker_app.tasks.generation import _apply_template_clip_effects
clips = [self._make_virtual_clip(i) for i in range(3)]
clip_configs = [
self._make_template_clip_config("main", transition="fade", config={"transition_duration": 0.8}),
self._make_template_clip_config("main", transition="dissolve", config={"transition_duration": 1.2}),
]
_apply_template_clip_effects(clips, clip_configs, "one_take")
# 前两个按顺序映射,第三个复用最后一个
assert clips[0].transition_effect == "fade"
assert clips[0].transition_duration == 0.8
assert clips[1].transition_effect == "dissolve"
assert clips[1].transition_duration == 1.2
assert clips[2].transition_effect == "dissolve"
assert clips[2].transition_duration == 1.2
def test_transition_duration_ignored_for_cut(self):
"""模板转场为 cut 时,transition_duration 不生效(保持默认0."""
from worker_app.tasks.generation import _apply_template_clip_effects
clips = [self._make_virtual_clip(0)]
clip_configs = [
self._make_template_clip_config("main", transition="cut", config={"transition_duration": 0.5}),
]
_apply_template_clip_effects(clips, clip_configs, "one_take")
# cut 转场不映射,transition_duration 也不应用
assert clips[0].transition_duration == 0.0
def test_transition_duration_invalid_value_skipped(self):
"""transition_duration 为无效值时安全跳过."""
from worker_app.tasks.generation import _apply_template_clip_effects
clips = [self._make_virtual_clip(0)]
clip_configs = [
self._make_template_clip_config("main", transition="fade", config={"transition_duration": "abc"}),
]
_apply_template_clip_effects(clips, clip_configs, "one_take")
assert clips[0].transition_effect == "fade"
assert clips[0].transition_duration == 0.0 # 无效值保持默认
def test_intro_outro_extracted(self):
"""intro/outro 类型 clip_config 正确提取为 plan 级 intro_outro 配置."""
from worker_app.tasks.generation import _extract_intro_outro_from_clip_configs
clip_configs = [
self._make_template_clip_config("intro", config={"intro_type": "text", "intro_text_color": "#ffffff"}),
self._make_template_clip_config("main"),
self._make_template_clip_config("outro", config={"outro_type": "follow", "outro_follow_text": "关注我们"}),
]
# 设置 intro/outro 的 text_template
clip_configs[0].text_template = "精彩视频"
clip_configs[0].default_duration = 2.5
result = _extract_intro_outro_from_clip_configs(clip_configs)
assert result["has_intro"] is True
assert result["intro_type"] == "text"
assert result["intro_text"] == "精彩视频"
assert result["intro_duration"] == 2.5
assert result["intro_text_color"] == "#ffffff"
assert result["has_outro"] is True
assert result["outro_type"] == "follow"
assert result["outro_follow_text"] == "关注我们"
def test_intro_outro_empty_when_none(self):
"""没有 intro/outro 时返回空 dict."""
from worker_app.tasks.generation import _extract_intro_outro_from_clip_configs
clip_configs = [
self._make_template_clip_config("main"),
self._make_template_clip_config("main"),
]
result = _extract_intro_outro_from_clip_configs(clip_configs)
assert result == {}
class TestTemplatePlanConfigLoading:
"""验证从模板加载 plan 级配置(BGM、字幕、标题)的逻辑。"""
def _mock_template(
self,
title_config=None,
subtitle_config=None,
bgm_config=None,
is_active=True,
):
template = MagicMock()
template.id = "tmpl_001"
template.name = "Test Template"
template.is_active = is_active
template.title_config = title_config or {}
template.subtitle_config = subtitle_config or {}
template.bgm_config = bgm_config or {}
return template
def _mock_session(self, template):
session = MagicMock()
# EditTemplateModel 查询返回 None(走旧模板系统 fallback
edit_query = MagicMock()
edit_filter = MagicMock()
edit_query.filter.return_value = edit_filter
edit_filter.first.return_value = None
# TemplateModel 查询返回 template(旧模板系统)
old_query = MagicMock()
old_filter = MagicMock()
old_query.filter.return_value = old_filter
old_filter.first.return_value = template
def _query_side_effect(model):
name = getattr(model, "__name__", "")
if "EditTemplate" in name:
return edit_query
return old_query
session.query.side_effect = _query_side_effect
return session
def test_load_template_config_assembles_three_fields(self):
"""模板的三个独立字段正确组装成 plan.config 格式。"""
from worker_app.tasks.generation import _load_template_plan_config
title_cfg = {"enabled": True, "text": "我的标题", "font_size": 36}
subtitle_cfg = {"enabled": True, "auto_generated": True, "language": "zh"}
bgm_cfg = {"enabled": True, "preset_id": "bgm-001", "volume": 0.5}
template = self._mock_template(
title_config=title_cfg,
subtitle_config=subtitle_cfg,
bgm_config=bgm_cfg,
)
session = self._mock_session(template)
with patch("worker_app.tasks.generation.SessionLocal", return_value=session):
result = _load_template_plan_config("tmpl_001")
assert result["title"] == title_cfg
assert result["subtitle"] == subtitle_cfg
assert result["bgm"] == bgm_cfg
def test_load_template_config_empty_template_returns_empty(self):
"""模板三个字段都为空时返回空 dict。"""
from worker_app.tasks.generation import _load_template_plan_config
template = self._mock_template()
session = self._mock_session(template)
with patch("worker_app.tasks.generation.SessionLocal", return_value=session):
result = _load_template_plan_config("tmpl_001")
assert result == {}
def test_load_template_config_only_bgm(self):
"""只有 BGM 配置时只返回 bgm 字段。"""
from worker_app.tasks.generation import _load_template_plan_config
bgm_cfg = {"enabled": True, "audio_url": "https://example.com/bgm.mp3"}
template = self._mock_template(bgm_config=bgm_cfg)
session = self._mock_session(template)
with patch("worker_app.tasks.generation.SessionLocal", return_value=session):
result = _load_template_plan_config("tmpl_001")
assert "bgm" in result
assert result["bgm"] == bgm_cfg
assert "title" not in result
assert "subtitle" not in result
def test_load_template_config_empty_template_id(self):
"""空 template_id 直接返回空 dict。"""
from worker_app.tasks.generation import _load_template_plan_config
result = _load_template_plan_config("")
assert result == {}
result = _load_template_plan_config(None)
assert result == {}
def test_load_template_config_not_found_returns_empty(self):
"""模板不存在时返回空 dict,不抛异常。"""
from worker_app.tasks.generation import _load_template_plan_config
session = MagicMock()
def _query_side_effect(model):
q = MagicMock()
f = MagicMock()
q.filter.return_value = f
f.first.return_value = None
return q
session.query.side_effect = _query_side_effect
with patch("worker_app.tasks.generation.SessionLocal", return_value=session):
result = _load_template_plan_config("tmpl_nonexist")
assert result == {}