"""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 from unittest.mock import MagicMock import pytest from app.services.auto_clip_service import AutoClipService, ClipAssignDetail # ── 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"]