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
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>
352 lines
13 KiB
Python
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"]
|