Files
xiaoxia-saas/tests/unit/test_auto_clip_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

352 lines
13 KiB
Python

"""AutoClipService 单元测试."""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
from dataclasses import dataclass, field
from enum import Enum
from typing import Any
import pytest
from app.services.auto_clip_service import AutoClipService
# ── Stub 实体 ─────────────────────────────────────────────────────────────────
class _ClipStatus(str, Enum):
PENDING = "pending"
READY = "ready"
class _ClipType(str, Enum):
INTRO = "intro"
MAIN = "main"
TRANSITION = "transition"
OUTRO = "outro"
@dataclass
class _StubClip:
id: str
plan_id: str
template_clip_config_id: str | None = None
clip_type: _ClipType = _ClipType.MAIN
order: int = 0
asset_id: str | None = None
status: _ClipStatus = _ClipStatus.PENDING
@property
def has_asset(self) -> bool:
return self.asset_id is not None
def assign_asset(self, asset_id: str) -> None:
self.asset_id = asset_id
def mark_ready(self) -> None:
if self.status == _ClipStatus.PENDING:
self.status = _ClipStatus.READY
@dataclass
class _StubAsset:
id: str
quality_score: float | None = 80.0
duration: float | None = 10.0
metadata: dict[str, Any] = field(default_factory=dict)
@dataclass
class _StubConfig:
id: str
template_id: str
clip_type: _ClipType = _ClipType.MAIN
order: int = 0
min_duration: float | None = None
max_duration: float | None = None
material_requirements: dict[str, Any] = field(default_factory=dict)
@dataclass
class _StubPlan:
id: str
template_id: str = "tpl-001"
# ── Stub 仓储 ─────────────────────────────────────────────────────────────────
class _StubPlanRepo:
def __init__(self, plan: _StubPlan | None = None) -> None:
self._plan = plan
def get(self, plan_id: str) -> _StubPlan | None:
return self._plan if self._plan and self._plan.id == plan_id else None
class _StubClipRepo:
def __init__(self, clips: list[_StubClip] | None = None) -> None:
self._clips = clips or []
self.updated: list[_StubClip] = []
def get(self, clip_id: str) -> _StubClip | None:
for c in self._clips:
if c.id == clip_id:
return c
return None
def list_by_plan(self, plan_id: str) -> list[_StubClip]:
return [c for c in self._clips if c.plan_id == plan_id]
def update(self, clip: _StubClip) -> _StubClip:
self.updated.append(clip)
return clip
class _StubConfigRepo:
def __init__(self, configs: list[_StubConfig] | None = None) -> None:
self._configs = configs or []
def get(self, config_id: str) -> _StubConfig | None:
for c in self._configs:
if c.id == config_id:
return c
return None
def list_by_template(self, template_id: str, **_: Any) -> list[_StubConfig]:
return [c for c in self._configs if c.template_id == template_id]
class _StubAssetRepo:
def __init__(self, candidates: list[_StubAsset] | None = None) -> None:
self._candidates = candidates or []
self.last_query: dict[str, Any] = {}
def search_candidates(self, project_id: str, **kwargs: Any) -> list[_StubAsset]:
self.last_query = {"project_id": project_id, **kwargs}
return list(self._candidates)
# ── 构造 Service (注入 stub) ──────────────────────────────────────────────────
def _make_service(
plan_repo: _StubPlanRepo,
clip_repo: _StubClipRepo,
config_repo: _StubConfigRepo,
asset_repo: _StubAssetRepo,
) -> AutoClipService:
"""创建注入 stub 仓储的 AutoClipService(绕过 __init__)。"""
svc = AutoClipService.__new__(AutoClipService)
svc._plan_repo = plan_repo # type: ignore[assignment]
svc._clip_repo = clip_repo # type: ignore[assignment]
svc._config_repo = config_repo # type: ignore[assignment]
svc._asset_repo = asset_repo # type: ignore[assignment]
return svc
# ── 测试:auto_select_assets ──────────────────────────────────────────────────
class TestAutoSelectAssets:
def test_plan_not_found_raises(self) -> None:
svc = _make_service(
_StubPlanRepo(None),
_StubClipRepo(),
_StubConfigRepo(),
_StubAssetRepo(),
)
with pytest.raises(ValueError, match="剪辑计划不存在"):
svc.auto_select_assets("bad-id", "proj-1")
def test_no_clips_returns_empty(self) -> None:
plan = _StubPlan(id="plan-1", template_id="tpl-1")
svc = _make_service(
_StubPlanRepo(plan),
_StubClipRepo([]),
_StubConfigRepo(),
_StubAssetRepo(),
)
result = svc.auto_select_assets("plan-1", "proj-1")
assert result.total_clips == 0
assert result.assigned_clips == 0
assert result.unassigned_clips == 0
def test_assigns_best_candidate(self) -> None:
plan = _StubPlan(id="plan-1", template_id="tpl-1")
config = _StubConfig(
id="cfg-1",
template_id="tpl-1",
clip_type=_ClipType.MAIN,
min_duration=8.0,
max_duration=12.0,
material_requirements={"type": "video", "category": "scenic"},
)
clip = _StubClip(id="clip-1", plan_id="plan-1", template_clip_config_id="cfg-1")
# 两个候选,第一个质量分更高
assets = [
_StubAsset(id="a1", quality_score=90.0, duration=10.0, metadata={"category": "scenic"}),
_StubAsset(id="a2", quality_score=60.0, duration=10.0, metadata={"category": "scenic"}),
]
svc = _make_service(
_StubPlanRepo(plan),
_StubClipRepo([clip]),
_StubConfigRepo([config]),
_StubAssetRepo(assets),
)
result = svc.auto_select_assets("plan-1", "proj-1")
assert result.assigned_clips == 1
assert result.details[0].assigned_asset_id == "a1"
assert clip.asset_id == "a1"
assert clip.status == _ClipStatus.READY
def test_no_candidates_marks_unassigned(self) -> None:
plan = _StubPlan(id="plan-1", template_id="tpl-1")
config = _StubConfig(id="cfg-1", template_id="tpl-1")
clip = _StubClip(id="clip-1", plan_id="plan-1", template_clip_config_id="cfg-1")
svc = _make_service(
_StubPlanRepo(plan),
_StubClipRepo([clip]),
_StubConfigRepo([config]),
_StubAssetRepo([]), # 无候选
)
result = svc.auto_select_assets("plan-1", "proj-1")
assert result.assigned_clips == 0
assert result.unassigned_clips == 1
assert result.details[0].assigned_asset_id is None
assert "无符合条件" in result.details[0].reason
# ── 测试:select_for_clip ─────────────────────────────────────────────────────
class TestSelectForClip:
def test_clip_not_found_raises(self) -> None:
svc = _make_service(
_StubPlanRepo(None),
_StubClipRepo(),
_StubConfigRepo(),
_StubAssetRepo(),
)
with pytest.raises(ValueError, match="片段不存在"):
svc.select_for_clip("bad-id", "proj-1")
def test_assigns_without_config(self) -> None:
"""片段没有关联 config 时仍可分配(无筛选条件)。"""
clip = _StubClip(id="clip-1", plan_id="plan-1", template_clip_config_id=None)
assets = [_StubAsset(id="a1", quality_score=70.0, duration=5.0)]
svc = _make_service(
_StubPlanRepo(None),
_StubClipRepo([clip]),
_StubConfigRepo(),
_StubAssetRepo(assets),
)
detail = svc.select_for_clip("clip-1", "proj-1")
assert detail.assigned_asset_id == "a1"
# ── 测试:评分逻辑 ────────────────────────────────────────────────────────────
class TestScoring:
def test_high_quality_wins(self) -> None:
a = _StubAsset(id="a", quality_score=95.0, duration=10.0, metadata={"category": "scenic"})
b = _StubAsset(id="b", quality_score=50.0, duration=10.0, metadata={"category": "scenic"})
sa = AutoClipService._score_candidate(a, target_duration=10.0, target_category="scenic")
sb = AutoClipService._score_candidate(b, target_duration=10.0, target_category="scenic")
assert sa > sb
def test_duration_match_beats_mismatch(self) -> None:
a = _StubAsset(id="a", quality_score=80.0, duration=10.0, metadata={})
b = _StubAsset(id="b", quality_score=80.0, duration=30.0, metadata={})
sa = AutoClipService._score_candidate(a, target_duration=10.0, target_category=None)
sb = AutoClipService._score_candidate(b, target_duration=10.0, target_category=None)
assert sa > sb
def test_category_match_beats_mismatch(self) -> None:
a = _StubAsset(id="a", quality_score=80.0, duration=10.0, metadata={"category": "scenic"})
b = _StubAsset(id="b", quality_score=80.0, duration=10.0, metadata={"category": "tech"})
sa = AutoClipService._score_candidate(a, target_duration=10.0, target_category="scenic")
sb = AutoClipService._score_candidate(b, target_duration=10.0, target_category="scenic")
assert sa > sb
def test_no_target_category_all_get_full_classification(self) -> None:
a = _StubAsset(id="a", quality_score=80.0, duration=10.0, metadata={})
score = AutoClipService._score_candidate(a, target_duration=10.0, target_category=None)
# classification_score = 1.0 when no target
assert score == pytest.approx(0.5 * 0.8 + 0.3 * 1.0 + 0.2 * 1.0)
def test_no_quality_defaults_to_half(self) -> None:
a = _StubAsset(id="a", quality_score=None, duration=10.0, metadata={})
score = AutoClipService._score_candidate(a, target_duration=None, target_category=None)
assert score == pytest.approx(0.5 * 0.5 + 0.3 * 0.5 + 0.2 * 1.0)
# ── 测试:解析素材需求 ────────────────────────────────────────────────────────
class TestParseMaterialRequirements:
def test_none_config_returns_empty(self) -> None:
assert AutoClipService._parse_material_requirements(None) == {}
def test_extracts_file_type(self) -> None:
config = _StubConfig(
id="c1",
template_id="t1",
material_requirements={"type": "video"},
)
result = AutoClipService._parse_material_requirements(config)
assert result["file_type"] == "video"
def test_extracts_min_quality(self) -> None:
config = _StubConfig(
id="c1",
template_id="t1",
material_requirements={"min_quality_score": 60},
)
result = AutoClipService._parse_material_requirements(config)
assert result["min_quality_score"] == 60.0
def test_extracts_category(self) -> None:
config = _StubConfig(
id="c1",
template_id="t1",
material_requirements={"category": "scenic"},
)
result = AutoClipService._parse_material_requirements(config)
assert result["classification_category"] == "scenic"
def test_invalid_category_ignored(self) -> None:
config = _StubConfig(
id="c1",
template_id="t1",
material_requirements={"category": "nonexistent"},
)
result = AutoClipService._parse_material_requirements(config)
assert "classification_category" not in result
def test_duration_range(self) -> None:
config = _StubConfig(
id="c1",
template_id="t1",
min_duration=5.0,
max_duration=15.0,
material_requirements={},
)
result = AutoClipService._parse_material_requirements(config)
assert result["min_duration"] == 5.0
assert result["max_duration"] == 15.0
assert result["target_duration"] == 10.0
def test_tags_extracted(self) -> None:
config = _StubConfig(
id="c1",
template_id="t1",
material_requirements={"tags": ["outdoor", "sunset"]},
)
result = AutoClipService._parse_material_requirements(config)
assert result["tags"] == ["outdoor", "sunset"]