"""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 session = MagicMock() mock_session = MagicMock() session.query.return_value = mock_session filter_result = MagicMock() mock_session.filter.return_value = filter_result filter_result.first.return_value = mock_template 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() mock_session = MagicMock() session.query.return_value = mock_session filter_result = MagicMock() mock_session.filter.return_value = filter_result filter_result.first.return_value = None 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" 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_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 == {}