Files
xiaoxia-saas/tests/unit/test_video_compose_service.py
T
CI Bot 1a57878f76
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 1m12s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 2m17s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 1m33s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 3m14s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
chore(backend): Phase 3 清理 — 未使用依赖删除 + pyflakes 警告清零 + 测试文件冗余清理
1. 未使用依赖清理:
   - 从 requirements-base.txt 移除 cryptography 和 pyOpenSSL

2. pyflakes 警告清零 (apps/ + packages/ + tests/):
   - 移除 17 处未使用的 import (F401)
   - 修复 26 处未使用的局部变量 (F841):
     * 有副作用的赋值转为裸调用
     * 无副作用的赋值直接删除
   - 修复 1 处未使用的异常变量 (F841)
   - 修复 1 处空 except 块

3. 测试文件冗余清理:
   - 删除 tests/integration/test_project_management.py (模块级 skip,测试不存在的模块)
   - 删除 tests/integration/fixtures/duplication_routes_fixed.py (未被引用)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 16:14:46 +08:00

618 lines
22 KiB
Python

"""VideoComposeService 单元测试.
使用 stub 仓储替代真实数据库,测试 FFmpeg 命令生成和校验逻辑。
"""
from __future__ import annotations
import sys
from pathlib import Path
from unittest import TestCase
# 修正 import 路径
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
from app.services.video_compose_service import (
ComposeCommand,
VideoComposeService,
_build_concat_filter,
_build_xfade_filter,
_chain_filters,
)
from packages.domain.edit_plan import EditPlanStatus
from packages.domain.edit_plan_clip import EditPlanClipStatus
# ── Stub 实体 ─────────────────────────────────────────────────────────────────
class _StubClip:
"""EditPlanClip 的轻量替身。"""
def __init__(
self,
clip_id: str = "clip-1",
plan_id: str = "plan-1",
clip_type: str = "main",
order: int = 0,
asset_id: str = "",
duration: float = 5.0,
start_time: float = 0.0,
transition_effect: str = "cut",
status: EditPlanClipStatus = EditPlanClipStatus.READY,
):
self.id = clip_id
self.plan_id = plan_id
self.clip_type = clip_type
self.order = order
self.asset_id = asset_id
self.duration = duration
self.start_time = start_time
self.transition_effect = transition_effect
self.status = status
self.template_clip_config_id = ""
self.text_content = ""
self.config = {}
class _StubPlan:
"""EditPlan 的轻量替身。"""
def __init__(
self,
plan_id: str = "plan-1",
status: EditPlanStatus = EditPlanStatus.EDITING,
):
self.id = plan_id
self.template_id = "tpl-1"
self.name = "测试计划"
self.status = status
self.total_duration = 0.0
self.config = {}
# ── Stub 仓储 ─────────────────────────────────────────────────────────────────
class _StubPlanRepo:
def __init__(self, plans: dict[str, _StubPlan] | None = None):
self._plans = plans or {}
def get(self, plan_id: str):
return self._plans.get(plan_id)
def list_by_project(self, *args, **kwargs):
return list(self._plans.values())
class _StubClipRepo:
def __init__(self, clips: list[_StubClip] | None = None):
self._clips = {c.id: c for c in (clips or [])}
self._by_plan: dict[str, list[_StubClip]] = {}
for c in clips or []:
self._by_plan.setdefault(c.plan_id, []).append(c)
def get(self, clip_id: str):
return self._clips.get(clip_id)
def list_by_plan(self, plan_id: str, skip: int = 0, limit: int = 100):
clips = self._by_plan.get(plan_id, [])
return clips[skip : skip + limit]
# ── 辅助工厂 ──────────────────────────────────────────────────────────────────
def _make_service(
plan: _StubPlan | None = None,
clips: list[_StubClip] | None = None,
) -> VideoComposeService:
"""创建注入 stub 仓储的 VideoComposeService。"""
svc = VideoComposeService.__new__(VideoComposeService)
svc._db = None # type: ignore[assignment]
svc._plan_repo = _StubPlanRepo({plan.id: plan} if plan else {}) # type: ignore[assignment]
svc._clip_repo = _StubClipRepo(clips or []) # type: ignore[assignment]
return svc
def _make_ready_clip(
clip_id: str = "clip-1",
plan_id: str = "plan-1",
order: int = 0,
duration: float = 5.0,
asset_id: str = "assets/video.mp4",
transition: str = "cut",
) -> _StubClip:
return _StubClip(
clip_id=clip_id,
plan_id=plan_id,
order=order,
asset_id=asset_id,
duration=duration,
transition_effect=transition,
status=EditPlanClipStatus.READY,
)
# ── 测试用例 ──────────────────────────────────────────────────────────────────
class TestValidateCompose(TestCase):
"""validate_compose 校验逻辑测试。"""
def test_plan_not_found(self):
"""计划不存在 → 校验失败。"""
svc = _make_service()
result = svc.validate_compose("nonexistent")
self.assertFalse(result.valid)
self.assertIn("剪辑计划不存在", result.errors[0])
def test_wrong_status(self):
"""计划状态不是 editing/rendering → 校验失败。"""
plan = _StubPlan(status=EditPlanStatus.DRAFT)
clips = [_make_ready_clip()]
svc = _make_service(plan, clips)
result = svc.validate_compose(plan.id)
self.assertFalse(result.valid)
self.assertTrue(any("状态不正确" in e for e in result.errors))
def test_no_clips(self):
"""计划没有片段 → 校验失败。"""
plan = _StubPlan(status=EditPlanStatus.EDITING)
svc = _make_service(plan, [])
result = svc.validate_compose(plan.id)
self.assertFalse(result.valid)
self.assertIn("计划没有任何片段", result.errors[0])
def test_no_ready_clips(self):
"""没有 ready 状态的片段 → 校验失败。"""
plan = _StubPlan(status=EditPlanStatus.EDITING)
clips = [
_StubClip(
clip_id="c1",
plan_id=plan.id,
status=EditPlanClipStatus.PENDING,
asset_id="a.mp4",
)
]
svc = _make_service(plan, clips)
result = svc.validate_compose(plan.id)
self.assertFalse(result.valid)
self.assertTrue(any("没有就绪" in e for e in result.errors))
def test_ready_clip_without_asset(self):
"""ready 片段没有 asset_id → 校验失败。"""
plan = _StubPlan(status=EditPlanStatus.EDITING)
clips = [
_StubClip(
clip_id="c1",
plan_id=plan.id,
status=EditPlanClipStatus.READY,
asset_id="",
)
]
svc = _make_service(plan, clips)
result = svc.validate_compose(plan.id)
self.assertFalse(result.valid)
self.assertTrue(any("没有分配素材" in e for e in result.errors))
def test_valid_single_clip(self):
"""单个 ready 片段 → 校验通过。"""
plan = _StubPlan(status=EditPlanStatus.EDITING)
clips = [_make_ready_clip(plan_id=plan.id)]
svc = _make_service(plan, clips)
result = svc.validate_compose(plan.id)
self.assertTrue(result.valid)
self.assertEqual(result.ready_clip_count, 1)
self.assertEqual(result.total_clip_count, 1)
self.assertEqual(len(result.errors), 0)
def test_valid_multiple_clips(self):
"""多个 ready 片段 → 校验通过。"""
plan = _StubPlan(status=EditPlanStatus.EDITING)
clips = [
_make_ready_clip(clip_id="c1", plan_id=plan.id, order=0),
_make_ready_clip(clip_id="c2", plan_id=plan.id, order=1),
_make_ready_clip(clip_id="c3", plan_id=plan.id, order=2),
]
svc = _make_service(plan, clips)
result = svc.validate_compose(plan.id)
self.assertTrue(result.valid)
self.assertEqual(result.ready_clip_count, 3)
def test_rendering_status_also_valid(self):
"""rendering 状态也允许合成。"""
plan = _StubPlan(status=EditPlanStatus.RENDERING)
clips = [_make_ready_clip(plan_id=plan.id)]
svc = _make_service(plan, clips)
result = svc.validate_compose(plan.id)
self.assertTrue(result.valid)
def test_mixed_statuses_with_pending_warning(self):
"""混合状态:ready + pending → 通过但有警告。"""
plan = _StubPlan(status=EditPlanStatus.EDITING)
clips = [
_make_ready_clip(clip_id="c1", plan_id=plan.id, order=0),
_StubClip(
clip_id="c2",
plan_id=plan.id,
order=1,
status=EditPlanClipStatus.PENDING,
asset_id="b.mp4",
),
]
svc = _make_service(plan, clips)
result = svc.validate_compose(plan.id)
self.assertTrue(result.valid)
self.assertEqual(result.ready_clip_count, 1)
self.assertEqual(result.total_clip_count, 2)
self.assertTrue(any("pending" in w for w in result.warnings))
class TestBuildComposeCommand(TestCase):
"""build_compose_command 命令生成测试。"""
def test_plan_not_found_raises(self):
"""计划不存在 → ValueError。"""
svc = _make_service()
with self.assertRaises(ValueError):
svc.build_compose_command("nonexistent", "/tmp/out.mp4")
def test_no_clips_raises(self):
"""没有片段 → ValueError。"""
plan = _StubPlan()
svc = _make_service(plan, [])
with self.assertRaises(ValueError):
svc.build_compose_command(plan.id, "/tmp/out.mp4")
def test_no_ready_clips_raises(self):
"""没有 ready 片段 → ValueError。"""
plan = _StubPlan()
clips = [
_StubClip(
clip_id="c1",
plan_id=plan.id,
status=EditPlanClipStatus.PENDING,
asset_id="a.mp4",
)
]
svc = _make_service(plan, clips)
with self.assertRaises(ValueError):
svc.build_compose_command(plan.id, "/tmp/out.mp4")
def test_single_clip_command(self):
"""单片段命令生成。"""
plan = _StubPlan()
clips = [_make_ready_clip(plan_id=plan.id, duration=10.0)]
svc = _make_service(plan, clips)
cmd = svc.build_compose_command(plan.id, "/tmp/out.mp4")
self.assertIsInstance(cmd, ComposeCommand)
self.assertEqual(cmd.input_paths, ["assets/video.mp4"])
self.assertEqual(cmd.output_path, "/tmp/out.mp4")
self.assertEqual(cmd.estimated_duration, 10.0)
self.assertEqual(len(cmd.clip_chains), 1)
self.assertIn("ffmpeg", cmd.command[0])
self.assertIn("-filter_complex", cmd.command)
def test_multi_clip_concat_command(self):
"""多片段 concat 命令生成。"""
plan = _StubPlan()
clips = [
_make_ready_clip(clip_id="c1", plan_id=plan.id, order=0, duration=5.0),
_make_ready_clip(clip_id="c2", plan_id=plan.id, order=1, duration=8.0),
]
svc = _make_service(plan, clips)
cmd = svc.build_compose_command(plan.id, "/tmp/out.mp4")
self.assertEqual(len(cmd.input_paths), 2)
self.assertEqual(cmd.estimated_duration, 13.0)
self.assertIn("concat", cmd.filter_complex)
self.assertIn("[outv]", cmd.filter_complex)
def test_multi_clip_xfade_command(self):
"""多片段 xfade 转场命令生成。"""
plan = _StubPlan()
clips = [
_make_ready_clip(clip_id="c1", plan_id=plan.id, order=0, duration=5.0, transition="fade"),
_make_ready_clip(clip_id="c2", plan_id=plan.id, order=1, duration=8.0, transition="cut"),
]
svc = _make_service(plan, clips)
cmd = svc.build_compose_command(plan.id, "/tmp/out.mp4")
self.assertEqual(len(cmd.input_paths), 2)
self.assertIn("xfade", cmd.filter_complex)
self.assertIn("transition=fade", cmd.filter_complex)
# 总时长应减去转场时长
self.assertLess(cmd.estimated_duration, 13.0)
def test_custom_output_params(self):
"""自定义输出参数。"""
plan = _StubPlan()
clips = [_make_ready_clip(plan_id=plan.id)]
svc = _make_service(plan, clips)
cmd = svc.build_compose_command(
plan.id,
"/tmp/out.mp4",
output_width=1920,
output_height=1080,
codec="libx265",
crf=28,
)
self.assertIn("-crf", cmd.command)
crf_idx = cmd.command.index("-crf")
self.assertEqual(cmd.command[crf_idx + 1], "28")
def test_filter_chain_contains_scale_and_crop(self):
"""滤镜链包含 scale 和 crop。"""
plan = _StubPlan()
clips = [_make_ready_clip(plan_id=plan.id)]
svc = _make_service(plan, clips)
cmd = svc.build_compose_command(plan.id, "/tmp/out.mp4")
chain = cmd.clip_chains[0]
filter_text = ",".join(chain.filters)
self.assertIn("scale=", filter_text)
self.assertIn("crop=", filter_text)
self.assertIn("trim=", filter_text)
def test_start_time_offset(self):
"""片段 start_time > 0 时生成 setpts 偏移。"""
plan = _StubPlan()
clips = [_make_ready_clip(plan_id=plan.id, duration=5.0)]
clips[0].start_time = 2.5
svc = _make_service(plan, clips)
cmd = svc.build_compose_command(plan.id, "/tmp/out.mp4")
chain = cmd.clip_chains[0]
filter_text = ",".join(chain.filters)
self.assertIn("2.5/TB", filter_text)
class TestBuildSingleClipCommand(TestCase):
"""build_single_clip_command 测试。"""
def test_clip_not_found_raises(self):
"""片段不存在 → ValueError。"""
svc = _make_service()
with self.assertRaises(ValueError):
svc.build_single_clip_command("nonexistent", "/tmp/out.mp4")
def test_no_asset_raises(self):
"""片段没有素材 → ValueError。"""
plan = _StubPlan()
clips = [_StubClip(clip_id="c1", plan_id=plan.id, asset_id="")]
svc = _make_service(plan, clips)
with self.assertRaises(ValueError):
svc.build_single_clip_command("c1", "/tmp/out.mp4")
def test_single_clip_preview(self):
"""单片段预览命令。"""
plan = _StubPlan()
clips = [_make_ready_clip(clip_id="c1", plan_id=plan.id)]
svc = _make_service(plan, clips)
cmd = svc.build_single_clip_command("c1", "/tmp/preview.mp4")
self.assertEqual(cmd.output_path, "/tmp/preview.mp4")
self.assertEqual(len(cmd.clip_chains), 1)
# filter_complex 字段是原始滤镜字符串(不含 [outv] 标签)
self.assertIn("scale=", cmd.filter_complex)
# 完整命令中包含 [outv]
self.assertIn("[outv]", " ".join(cmd.command))
class TestGetComposeStatus(TestCase):
"""get_compose_status 测试。"""
def test_plan_not_found_raises(self):
"""计划不存在 → ValueError。"""
svc = _make_service()
with self.assertRaises(ValueError):
svc.get_compose_status("nonexistent")
def test_status_summary(self):
"""状态摘要正确。"""
plan = _StubPlan(status=EditPlanStatus.EDITING)
clips = [
_make_ready_clip(clip_id="c1", plan_id=plan.id, order=0, duration=5.0),
_StubClip(
clip_id="c2",
plan_id=plan.id,
order=1,
status=EditPlanClipStatus.PENDING,
asset_id="b.mp4",
),
_StubClip(
clip_id="c3",
plan_id=plan.id,
order=2,
status=EditPlanClipStatus.RENDERED,
asset_id="c.mp4",
duration=3.0,
),
]
svc = _make_service(plan, clips)
status = svc.get_compose_status(plan.id)
self.assertEqual(status["plan_id"], plan.id)
self.assertEqual(status["plan_status"], "editing")
self.assertEqual(status["total_clips"], 3)
self.assertEqual(status["ready_clips"], 1)
self.assertEqual(status["pending_clips"], 1)
self.assertEqual(status["rendered_clips"], 1)
self.assertEqual(status["total_duration"], 13.0) # 5.0 + 5.0 + 3.0(所有有 duration 的片段)
self.assertTrue(status["can_compose"])
class TestChainFilters(TestCase):
"""_chain_filters 辅助函数测试。"""
def test_basic_chain(self):
"""基本滤镜链。"""
result = _chain_filters(["scale=1280:720", "crop=1280:720"], "v0")
self.assertEqual(result, "[0:v]scale=1280:720,crop=1280:720[v0]")
def test_empty_filters(self):
"""空滤镜列表。"""
result = _chain_filters([], "v0")
self.assertEqual(result, "[0:v][v0]")
class TestBuildConcatFilter(TestCase):
"""_build_concat_filter 测试。"""
def test_single_clip(self):
"""单片段 concat。"""
from app.services.video_compose_service import ClipFilterChain
chains = [
ClipFilterChain(
clip_id="c1",
input_index=0,
video_label="v0",
audio_label="a0",
filters=["scale=1280:720", "trim=0:5"],
duration=5.0,
)
]
filter_str, duration = _build_concat_filter(chains)
self.assertIn("concat=n=1", filter_str)
self.assertEqual(duration, 5.0)
def test_multi_clip(self):
"""多片段 concat。"""
from app.services.video_compose_service import ClipFilterChain
chains = [
ClipFilterChain(
clip_id="c1",
input_index=0,
video_label="v0",
audio_label=None,
filters=["scale=1280:720"],
duration=5.0,
),
ClipFilterChain(
clip_id="c2",
input_index=1,
video_label="v1",
audio_label=None,
filters=["scale=1280:720"],
duration=8.0,
),
]
filter_str, duration = _build_concat_filter(chains)
self.assertIn("concat=n=2:v=1:a=0[outv]", filter_str)
self.assertEqual(duration, 13.0)
class TestBuildXfadeFilter(TestCase):
"""_build_xfade_filter 测试。"""
def test_two_clips_with_fade(self):
"""两个片段 + fade 转场。"""
from app.services.video_compose_service import ClipFilterChain
chains = [
ClipFilterChain(
clip_id="c1",
input_index=0,
video_label="v0",
audio_label=None,
filters=["scale=1280:720"],
duration=5.0,
),
ClipFilterChain(
clip_id="c2",
input_index=1,
video_label="v1",
audio_label=None,
filters=["scale=1280:720"],
duration=8.0,
),
]
filter_str, duration = _build_xfade_filter(chains, transition_duration=0.5, transitions=["cut", "fade"])
self.assertIn("xfade=transition=fade", filter_str)
self.assertIn("duration=0.5", filter_str)
self.assertIn("[outv]", filter_str)
# 总时长 = 5 + 8 - 0.5 = 12.5
self.assertAlmostEqual(duration, 12.5, places=2)
def test_three_clips_chained_xfade(self):
"""三个片段链式 xfade。"""
from app.services.video_compose_service import ClipFilterChain
chains = [
ClipFilterChain(clip_id="c1", input_index=0, video_label="v0", audio_label=None, filters=[], duration=5.0),
ClipFilterChain(clip_id="c2", input_index=1, video_label="v1", audio_label=None, filters=[], duration=5.0),
ClipFilterChain(clip_id="c3", input_index=2, video_label="v2", audio_label=None, filters=[], duration=5.0),
]
filter_str, duration = _build_xfade_filter(
chains, transition_duration=0.5, transitions=["cut", "fade", "slide_left"]
)
self.assertIn("xfade=transition=fade", filter_str)
self.assertIn("xfade=transition=slideleft", filter_str)
# 总时长 = 15 - 0.5*2 = 14.0
self.assertAlmostEqual(duration, 14.0, places=2)
class TestHasAudioTitleSubtitleFix(TestCase):
"""P0 修复验证:title/subtitle 片段不应有音频流。"""
def _make_clip(self, clip_id, clip_type, **kwargs):
"""创建测试用 stub clip。"""
return _StubClip(clip_id=clip_id, clip_type=clip_type, **kwargs)
def test_title_clip_has_no_audio_label(self):
"""title 类型片段的 audio_label 应为 None。"""
clip = self._make_clip("c1", "title")
chain = VideoComposeService._build_clip_filter(clip, 0, 1280, 720, 25)
self.assertIsNone(chain.audio_label, "title 片段不应有音频标签")
def test_subtitle_clip_has_no_audio_label(self):
"""subtitle 类型片段的 audio_label 应为 None。"""
clip = self._make_clip("c1", "subtitle")
chain = VideoComposeService._build_clip_filter(clip, 0, 1280, 720, 25)
self.assertIsNone(chain.audio_label, "subtitle 片段不应有音频标签")
def test_main_clip_has_audio_label(self):
"""main 类型片段应有音频标签。"""
clip = self._make_clip("c1", "main")
chain = VideoComposeService._build_clip_filter(clip, 0, 1280, 720, 25)
self.assertEqual(chain.audio_label, "a0")
def test_intro_clip_has_audio_label(self):
"""intro 类型片段应有音频标签。"""
clip = self._make_clip("c1", "intro")
chain = VideoComposeService._build_clip_filter(clip, 0, 1280, 720, 25)
self.assertEqual(chain.audio_label, "a0")
def test_has_audio_false_when_only_title_subtitle(self):
"""当所有片段都是 title/subtitle 时,_has_audio 应返回 False。"""
chains = [
VideoComposeService._build_clip_filter(self._make_clip("c1", "title"), 0, 1280, 720, 25),
VideoComposeService._build_clip_filter(self._make_clip("c2", "subtitle"), 1, 1280, 720, 25),
]
self.assertFalse(VideoComposeService._has_audio(chains))
def test_has_audio_true_when_mixed_clips(self):
"""混合片段(含 main)时,_has_audio 应返回 True。"""
chains = [
VideoComposeService._build_clip_filter(self._make_clip("c1", "title"), 0, 1280, 720, 25),
VideoComposeService._build_clip_filter(self._make_clip("c2", "main"), 1, 1280, 720, 25),
]
self.assertTrue(VideoComposeService._has_audio(chains))
def test_empty_clip_type_has_audio(self):
"""clip_type 为空字符串时,应有音频标签(保守策略)。"""
clip = self._make_clip("c1", "")
chain = VideoComposeService._build_clip_filter(clip, 0, 1280, 720, 25)
self.assertEqual(chain.audio_label, "a0")
if __name__ == "__main__":
import unittest
unittest.main()