7f3c462617
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m1s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 56s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Failing after 1m30s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 46s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m57s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m51s
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m40s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 2m32s
CI/CD Pipeline / Build Staging API Image (push) Successful in 13m22s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m54s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 31s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 2m13s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 5m3s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Failing after 1h12m17s
756 lines
26 KiB
Python
Executable File
756 lines
26 KiB
Python
Executable File
"""Deep unit tests for asset_scoring.py — multi-dimensional scoring + diverse selection.
|
||
|
||
深度覆盖:
|
||
- score_resolution: 10+ 边界情况
|
||
- score_duration: 10+ 边界情况
|
||
- score_bitrate: 10+ 边界情况
|
||
- calculate_total_score: 加权验证
|
||
- score_asset_detail: 完整评分流程
|
||
- diverse_selection: 各种分桶场景
|
||
- filter_candidates: 各种过滤条件
|
||
- 数据类 + 常量
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass
|
||
from typing import Optional
|
||
|
||
import pytest
|
||
|
||
from packages.domain.asset_scoring import (
|
||
MEDIUM_BUCKET_MAX,
|
||
MIN_QUALITY_SCORE,
|
||
OPTIMAL_DURATION_MAX,
|
||
OPTIMAL_DURATION_MIN,
|
||
SHORT_BUCKET_MAX,
|
||
TARGET_HEIGHT,
|
||
TARGET_WIDTH,
|
||
WEIGHT_BITRATE,
|
||
WEIGHT_DURATION,
|
||
WEIGHT_QUALITY,
|
||
WEIGHT_RESOLUTION,
|
||
AssetScoreDetail,
|
||
SmartSelectResult,
|
||
_bucket_by_duration,
|
||
calculate_total_score,
|
||
diverse_selection,
|
||
filter_candidates,
|
||
score_asset_detail,
|
||
score_bitrate,
|
||
score_duration,
|
||
score_resolution,
|
||
)
|
||
|
||
# ── 辅助:模拟 asset 对象 ────────────────────────────────────────────────────
|
||
|
||
|
||
class MockStatus:
|
||
def __init__(self, value: str):
|
||
self.value = value
|
||
|
||
|
||
@dataclass
|
||
class MockAsset:
|
||
id: str = "asset_001"
|
||
status: Any = None
|
||
mime_type: str = "video/mp4"
|
||
quality_score: Optional[float] = None
|
||
width: Optional[int] = None
|
||
height: Optional[int] = None
|
||
duration: Optional[float] = None
|
||
file_size: int = 0
|
||
|
||
def __post_init__(self):
|
||
if self.status is None:
|
||
self.status = MockStatus("ready")
|
||
|
||
|
||
# ── 常量测试 ────────────────────────────────────────────────────────────────
|
||
|
||
|
||
class TestConstants:
|
||
def test_weights_sum_to_one(self):
|
||
total = WEIGHT_QUALITY + WEIGHT_RESOLUTION + WEIGHT_DURATION + WEIGHT_BITRATE
|
||
assert abs(total - 1.0) < 0.001
|
||
|
||
def test_target_resolution_1080p(self):
|
||
assert TARGET_WIDTH == 1920
|
||
assert TARGET_HEIGHT == 1080
|
||
|
||
def test_bucket_thresholds(self):
|
||
assert SHORT_BUCKET_MAX == 5.0
|
||
assert MEDIUM_BUCKET_MAX == 15.0
|
||
assert SHORT_BUCKET_MAX < MEDIUM_BUCKET_MAX
|
||
|
||
def test_optimal_duration_range(self):
|
||
assert OPTIMAL_DURATION_MIN == 3.0
|
||
assert OPTIMAL_DURATION_MAX == 30.0
|
||
assert OPTIMAL_DURATION_MIN < OPTIMAL_DURATION_MAX
|
||
|
||
def test_min_quality_score(self):
|
||
assert MIN_QUALITY_SCORE == 30.0
|
||
|
||
|
||
# ── 数据类测试 ──────────────────────────────────────────────────────────────
|
||
|
||
|
||
class TestDataClasses:
|
||
def test_asset_score_detail_defaults(self):
|
||
detail = AssetScoreDetail(
|
||
asset_id="a1",
|
||
total_score=0.8,
|
||
quality_score=0.7,
|
||
resolution_score=0.9,
|
||
duration_score=0.85,
|
||
bitrate_score=0.75,
|
||
duration=10.0,
|
||
)
|
||
assert detail.asset_id == "a1"
|
||
assert detail.total_score == 0.8
|
||
assert detail.duration == 10.0
|
||
|
||
def test_smart_select_result_defaults(self):
|
||
result = SmartSelectResult(
|
||
selected_ids=["a1", "a2"],
|
||
total_candidates=10,
|
||
filtered_out=3,
|
||
avg_score=0.75,
|
||
)
|
||
assert result.selected_ids == ["a1", "a2"]
|
||
assert result.details == []
|
||
assert result.total_candidates == 10
|
||
|
||
def test_smart_select_result_with_details(self):
|
||
detail = AssetScoreDetail(
|
||
asset_id="a1",
|
||
total_score=0.9,
|
||
quality_score=0.8,
|
||
resolution_score=0.95,
|
||
duration_score=0.9,
|
||
bitrate_score=0.85,
|
||
duration=5.0,
|
||
)
|
||
result = SmartSelectResult(
|
||
selected_ids=["a1"],
|
||
total_candidates=5,
|
||
filtered_out=0,
|
||
avg_score=0.9,
|
||
details=[detail],
|
||
)
|
||
assert len(result.details) == 1
|
||
assert result.details[0].asset_id == "a1"
|
||
|
||
|
||
# ── score_resolution ────────────────────────────────────────────────────────
|
||
|
||
|
||
class TestScoreResolution:
|
||
def test_exact_target_1080p(self):
|
||
score = score_resolution(1920, 1080)
|
||
assert score == 1.0
|
||
|
||
def test_4k_full_score(self):
|
||
score = score_resolution(3840, 2160)
|
||
assert score == 1.0
|
||
|
||
def test_higher_than_target_full_score(self):
|
||
score = score_resolution(2560, 1440)
|
||
assert score == 1.0
|
||
|
||
def test_720p_lower(self):
|
||
score = score_resolution(1280, 720)
|
||
# 720p 像素 = 921600, 1080p = 2073600
|
||
# ratio = 0.444, score = 0.3 + 0.7 * 0.444 = 0.611
|
||
assert 0.5 < score < 0.75
|
||
|
||
def test_480p_much_lower(self):
|
||
score = score_resolution(854, 480)
|
||
# 480p = 409,920 pixels, ratio = 0.197
|
||
# score = 0.3 + 0.7 * 0.197 = 0.438
|
||
assert 0.3 < score < 0.5
|
||
|
||
def test_none_width(self):
|
||
score = score_resolution(None, 1080)
|
||
assert score == 0.5
|
||
|
||
def test_none_height(self):
|
||
score = score_resolution(1920, None)
|
||
assert score == 0.5
|
||
|
||
def test_both_none(self):
|
||
score = score_resolution(None, None)
|
||
assert score == 0.5
|
||
|
||
def test_zero_width(self):
|
||
score = score_resolution(0, 1080)
|
||
assert score == 0.5
|
||
|
||
def test_zero_height(self):
|
||
score = score_resolution(1920, 0)
|
||
assert score == 0.5
|
||
|
||
def test_negative_width(self):
|
||
score = score_resolution(-100, 1080)
|
||
assert score == 0.5
|
||
|
||
def test_very_low_res_floor(self):
|
||
score = score_resolution(100, 100)
|
||
# 10000 pixels, ratio = 0.0048, score = 0.3 + 0.7*0.0048 = 0.303
|
||
# 但最低不低于 0.1
|
||
assert score >= 0.1
|
||
assert score < 0.5
|
||
|
||
def test_custom_target_resolution(self):
|
||
score = score_resolution(1280, 720, target_width=1280, target_height=720)
|
||
assert score == 1.0
|
||
|
||
def test_sd_resolution(self):
|
||
score = score_resolution(640, 480)
|
||
# VGA = 307,200, ratio = 0.148
|
||
assert score > 0.1
|
||
|
||
|
||
# ── score_duration ──────────────────────────────────────────────────────────
|
||
|
||
|
||
class TestScoreDuration:
|
||
def test_none_duration(self):
|
||
assert score_duration(None) == 0.5
|
||
|
||
def test_zero_duration(self):
|
||
assert score_duration(0.0) == 0.5
|
||
|
||
def test_negative_duration(self):
|
||
assert score_duration(-5.0) == 0.5
|
||
|
||
def test_optimal_lower_bound(self):
|
||
assert score_duration(OPTIMAL_DURATION_MIN) == 1.0
|
||
|
||
def test_optimal_upper_bound(self):
|
||
assert score_duration(OPTIMAL_DURATION_MAX) == 1.0
|
||
|
||
def test_optimal_middle(self):
|
||
assert score_duration(10.0) == 1.0
|
||
|
||
def test_below_optimal_short(self):
|
||
score = score_duration(1.5)
|
||
# ratio = 1.5/3 = 0.5, score = 0.3 + 0.7*0.5 = 0.65
|
||
assert score == pytest.approx(0.65, rel=1e-3)
|
||
|
||
def test_very_short_approaches_03(self):
|
||
score = score_duration(0.1)
|
||
# ratio = 0.1/3 = 0.033, score = 0.3 + 0.7*0.033 = 0.323
|
||
assert 0.3 < score < 0.4
|
||
|
||
def test_just_below_optimal(self):
|
||
score = score_duration(2.9)
|
||
assert score < 1.0
|
||
assert score > 0.9
|
||
|
||
def test_above_optimal_slightly(self):
|
||
score = score_duration(35.0)
|
||
# excess = 5, penalty = 5/10 * 0.1 = 0.05, score = 0.95
|
||
assert score == pytest.approx(0.95, rel=1e-3)
|
||
|
||
def test_above_optimal_moderate(self):
|
||
score = score_duration(60.0)
|
||
# excess = 30, penalty = 30/10 * 0.1 = 0.3, score = 0.7
|
||
assert score == pytest.approx(0.7, rel=1e-3)
|
||
|
||
def test_very_long_floor(self):
|
||
score = score_duration(1000.0)
|
||
# excess = 970, penalty = 970/10 * 0.1 = 9.7, capped at 0.8
|
||
# score = max(0.2, 1.0 - 0.8) = 0.2
|
||
assert score == 0.2
|
||
|
||
def test_1_second(self):
|
||
score = score_duration(1.0)
|
||
# ratio = 1/3 = 0.333, score = 0.3 + 0.7*0.333 = 0.533
|
||
assert score == pytest.approx(0.3 + 0.7 * (1.0 / 3.0), rel=1e-3)
|
||
|
||
|
||
# ── score_bitrate ───────────────────────────────────────────────────────────
|
||
|
||
|
||
class TestScoreBitrate:
|
||
def test_no_file_size(self):
|
||
assert score_bitrate(0, 10.0) == 0.5
|
||
|
||
def test_no_duration(self):
|
||
assert score_bitrate(1_000_000, None) == 0.5
|
||
|
||
def test_zero_duration(self):
|
||
assert score_bitrate(1_000_000, 0.0) == 0.5
|
||
|
||
def test_negative_duration(self):
|
||
assert score_bitrate(1_000_000, -5.0) == 0.5
|
||
|
||
def test_optimal_low_end(self):
|
||
# 2 Mbps for 10s = 2.5 MB
|
||
file_size = int(2_000_000 * 10 / 8)
|
||
score = score_bitrate(file_size, 10.0)
|
||
assert score == 1.0
|
||
|
||
def test_optimal_high_end(self):
|
||
# 8 Mbps for 10s = 10 MB
|
||
file_size = int(8_000_000 * 10 / 8)
|
||
score = score_bitrate(file_size, 10.0)
|
||
assert score == 1.0
|
||
|
||
def test_optimal_middle(self):
|
||
# 5 Mbps for 10s = 6.25 MB
|
||
file_size = int(5_000_000 * 10 / 8)
|
||
score = score_bitrate(file_size, 10.0)
|
||
assert score == 1.0
|
||
|
||
def test_low_bitrate(self):
|
||
# 1 Mbps for 10s = 1.25 MB
|
||
file_size = int(1_000_000 * 10 / 8)
|
||
score = score_bitrate(file_size, 10.0)
|
||
# ratio = 1/2 = 0.5, score = 0.3 + 0.7*0.5 = 0.65
|
||
assert score == pytest.approx(0.65, rel=1e-2)
|
||
|
||
def test_very_low_bitrate(self):
|
||
# 100 kbps for 10s = 125 KB
|
||
file_size = int(100_000 * 10 / 8)
|
||
score = score_bitrate(file_size, 10.0)
|
||
# ratio = 0.05, score = 0.3 + 0.7*0.05 = 0.335
|
||
assert 0.3 < score < 0.5
|
||
|
||
def test_high_bitrate_slightly(self):
|
||
# 10 Mbps (just above 8Mbps)
|
||
file_size = int(10_000_000 * 10 / 8)
|
||
score = score_bitrate(file_size, 10.0)
|
||
# excess ratio = 10/8 - 1 = 0.25, penalty = min(0.5, 0.25*0.2) = 0.05
|
||
# score = max(0.5, 1.0 - 0.05) = 0.95
|
||
assert score == pytest.approx(0.95, rel=1e-2)
|
||
|
||
def test_very_high_bitrate_floor(self):
|
||
# 100 Mbps
|
||
file_size = int(100_000_000 * 10 / 8)
|
||
score = score_bitrate(file_size, 10.0)
|
||
# excess ratio = 100/8 - 1 = 11.5, penalty = min(0.5, 11.5*0.2) = 0.5
|
||
# score = max(0.5, 1.0 - 0.5) = 0.5
|
||
assert score == 0.5
|
||
|
||
def test_1mbps_file_10s(self):
|
||
file_size = 1_000_000 # 1 MB
|
||
score = score_bitrate(file_size, 10.0)
|
||
# bitrate = 8*1M/10 = 0.8 Mbps
|
||
assert 0.3 < score < 0.7
|
||
|
||
|
||
# ── calculate_total_score ───────────────────────────────────────────────────
|
||
|
||
|
||
class TestCalculateTotalScore:
|
||
def test_perfect_score(self):
|
||
total = calculate_total_score(1.0, 1.0, 1.0, 1.0)
|
||
assert total == 1.0
|
||
|
||
def test_zero_score(self):
|
||
total = calculate_total_score(0.0, 0.0, 0.0, 0.0)
|
||
assert total == 0.0
|
||
|
||
def test_weighted_sum(self):
|
||
# 各维度不同分数
|
||
q, r, d, b = 0.8, 0.6, 0.9, 0.7
|
||
expected = WEIGHT_QUALITY * q + WEIGHT_RESOLUTION * r + WEIGHT_DURATION * d + WEIGHT_BITRATE * b
|
||
total = calculate_total_score(q, r, d, b)
|
||
assert total == pytest.approx(expected, rel=1e-4)
|
||
|
||
def test_quality_dominates(self):
|
||
# 质量分权重最高(0.5),变化影响最大
|
||
base = calculate_total_score(0.5, 0.5, 0.5, 0.5)
|
||
quality_up = calculate_total_score(1.0, 0.5, 0.5, 0.5)
|
||
resolution_up = calculate_total_score(0.5, 1.0, 0.5, 0.5)
|
||
# 质量分变化带来的差异最大
|
||
assert (quality_up - base) > (resolution_up - base)
|
||
|
||
def test_rounded_to_4_decimals(self):
|
||
# 1/3 这样的无限小数应该被截断
|
||
total = calculate_total_score(1 / 3, 1 / 3, 1 / 3, 1 / 3)
|
||
assert len(str(total).split(".")[-1]) <= 4
|
||
|
||
|
||
# ── score_asset_detail ──────────────────────────────────────────────────────
|
||
|
||
|
||
class TestScoreAssetDetail:
|
||
def test_full_asset(self):
|
||
detail = score_asset_detail(
|
||
asset_id="test_001",
|
||
quality=80.0,
|
||
width=1920,
|
||
height=1080,
|
||
duration=10.0,
|
||
file_size=5_000_000,
|
||
)
|
||
assert detail.asset_id == "test_001"
|
||
assert detail.quality_score == pytest.approx(0.8, rel=1e-3)
|
||
assert detail.resolution_score == 1.0
|
||
assert detail.duration_score == 1.0
|
||
assert 0.0 < detail.total_score <= 1.0
|
||
assert detail.duration == 10.0
|
||
|
||
def test_no_quality_default_05(self):
|
||
detail = score_asset_detail(
|
||
asset_id="a1",
|
||
quality=None,
|
||
width=1920,
|
||
height=1080,
|
||
duration=10.0,
|
||
file_size=5_000_000,
|
||
)
|
||
assert detail.quality_score == 0.5
|
||
|
||
def test_quality_100_is_1_0(self):
|
||
detail = score_asset_detail(
|
||
asset_id="a1",
|
||
quality=100.0,
|
||
width=1920,
|
||
height=1080,
|
||
duration=10.0,
|
||
file_size=5_000_000,
|
||
)
|
||
assert detail.quality_score == 1.0
|
||
|
||
def test_quality_zero_is_zero(self):
|
||
detail = score_asset_detail(
|
||
asset_id="a1",
|
||
quality=0.0,
|
||
width=1920,
|
||
height=1080,
|
||
duration=10.0,
|
||
file_size=5_000_000,
|
||
)
|
||
assert detail.quality_score == 0.0
|
||
|
||
def test_all_unknown_medium_score(self):
|
||
detail = score_asset_detail(
|
||
asset_id="a1",
|
||
quality=None,
|
||
width=None,
|
||
height=None,
|
||
duration=None,
|
||
file_size=0,
|
||
)
|
||
# 全部未知:质量0.5,分辨率0.5,时长0.5,码率0.5
|
||
assert detail.total_score == pytest.approx(0.5, rel=1e-3)
|
||
|
||
def test_custom_target_resolution(self):
|
||
detail = score_asset_detail(
|
||
asset_id="a1",
|
||
quality=100.0,
|
||
width=1280,
|
||
height=720,
|
||
duration=10.0,
|
||
file_size=5_000_000,
|
||
target_width=1280,
|
||
target_height=720,
|
||
)
|
||
assert detail.resolution_score == 1.0
|
||
|
||
def test_scores_are_rounded(self):
|
||
detail = score_asset_detail(
|
||
asset_id="a1",
|
||
quality=33.3,
|
||
width=854,
|
||
height=480,
|
||
duration=1.5,
|
||
file_size=1_000_000,
|
||
)
|
||
# 所有分数字符串长度不超过 0.xxxx 格式
|
||
for attr in ["quality_score", "resolution_score", "duration_score", "bitrate_score", "total_score"]:
|
||
val = getattr(detail, attr)
|
||
assert isinstance(val, float)
|
||
|
||
|
||
# ── _bucket_by_duration ─────────────────────────────────────────────────────
|
||
|
||
|
||
class TestBucketByDuration:
|
||
def test_short_bucket(self):
|
||
d = AssetScoreDetail("a", 0.5, 0.5, 0.5, 0.5, 0.5, 3.0)
|
||
assert _bucket_by_duration(d) == "short"
|
||
|
||
def test_short_bucket_boundary(self):
|
||
d = AssetScoreDetail("a", 0.5, 0.5, 0.5, 0.5, 0.5, 4.9)
|
||
assert _bucket_by_duration(d) == "short"
|
||
|
||
def test_medium_bucket(self):
|
||
d = AssetScoreDetail("a", 0.5, 0.5, 0.5, 0.5, 0.5, 10.0)
|
||
assert _bucket_by_duration(d) == "medium"
|
||
|
||
def test_medium_lower_boundary(self):
|
||
d = AssetScoreDetail("a", 0.5, 0.5, 0.5, 0.5, 0.5, SHORT_BUCKET_MAX)
|
||
assert _bucket_by_duration(d) == "medium"
|
||
|
||
def test_medium_upper_boundary(self):
|
||
d = AssetScoreDetail("a", 0.5, 0.5, 0.5, 0.5, 0.5, 14.9)
|
||
assert _bucket_by_duration(d) == "medium"
|
||
|
||
def test_long_bucket(self):
|
||
d = AssetScoreDetail("a", 0.5, 0.5, 0.5, 0.5, 0.5, 20.0)
|
||
assert _bucket_by_duration(d) == "long"
|
||
|
||
def test_long_lower_boundary(self):
|
||
d = AssetScoreDetail("a", 0.5, 0.5, 0.5, 0.5, 0.5, MEDIUM_BUCKET_MAX)
|
||
assert _bucket_by_duration(d) == "long"
|
||
|
||
def test_none_duration(self):
|
||
d = AssetScoreDetail("a", 0.5, 0.5, 0.5, 0.5, 0.5, None)
|
||
assert _bucket_by_duration(d) == "unknown"
|
||
|
||
|
||
# ── diverse_selection ───────────────────────────────────────────────────────
|
||
|
||
|
||
def _make_scored(items: list[tuple[str, float, float]]) -> list[AssetScoreDetail]:
|
||
"""构造评分列表: (asset_id, total_score, duration)"""
|
||
return [
|
||
AssetScoreDetail(
|
||
asset_id=aid,
|
||
total_score=score,
|
||
quality_score=score,
|
||
resolution_score=score,
|
||
duration_score=score,
|
||
bitrate_score=score,
|
||
duration=dur,
|
||
)
|
||
for aid, score, dur in items
|
||
]
|
||
|
||
|
||
class TestDiverseSelection:
|
||
def test_empty_input(self):
|
||
result = diverse_selection([], 5)
|
||
assert result == []
|
||
|
||
def test_zero_count(self):
|
||
scored = _make_scored([("a1", 0.9, 10.0)])
|
||
result = diverse_selection(scored, 0)
|
||
assert result == []
|
||
|
||
def test_negative_count(self):
|
||
scored = _make_scored([("a1", 0.9, 10.0)])
|
||
result = diverse_selection(scored, -1)
|
||
assert result == []
|
||
|
||
def test_fewer_than_count(self):
|
||
scored = _make_scored([("a1", 0.9, 10.0)])
|
||
result = diverse_selection(scored, 5)
|
||
assert len(result) == 1
|
||
|
||
def test_mixed_buckets_diversity(self):
|
||
# 3短 + 3中 + 3长,取6个
|
||
items = [
|
||
("s1", 0.9, 2.0),
|
||
("s2", 0.8, 3.0),
|
||
("s3", 0.7, 4.0),
|
||
("m1", 0.95, 8.0),
|
||
("m2", 0.85, 10.0),
|
||
("m3", 0.75, 12.0),
|
||
("l1", 0.92, 20.0),
|
||
("l2", 0.82, 25.0),
|
||
("l3", 0.72, 30.0),
|
||
]
|
||
scored = _make_scored(items)
|
||
scored.sort(key=lambda d: d.total_score, reverse=True)
|
||
result = diverse_selection(scored, 6)
|
||
assert len(result) == 6
|
||
# 每个桶至少1个(base_quota = max(1, 6//3) = 2)
|
||
ids = [r.asset_id for r in result]
|
||
short_count = sum(1 for r in result if r.duration and r.duration < SHORT_BUCKET_MAX)
|
||
medium_count = sum(1 for r in result if r.duration and SHORT_BUCKET_MAX <= r.duration < MEDIUM_BUCKET_MAX)
|
||
long_count = sum(1 for r in result if r.duration and r.duration >= MEDIUM_BUCKET_MAX)
|
||
assert short_count >= 1
|
||
assert medium_count >= 1
|
||
assert long_count >= 1
|
||
|
||
def test_all_short_fallback_to_global(self):
|
||
items = [("s1", 0.9, 2.0), ("s2", 0.8, 3.0), ("s3", 0.7, 4.0)]
|
||
scored = _make_scored(items)
|
||
result = diverse_selection(scored, 3)
|
||
assert len(result) == 3
|
||
# 都是短素材,只能取短的
|
||
assert all(r.duration and r.duration < SHORT_BUCKET_MAX for r in result)
|
||
|
||
def test_sorted_by_score_descending(self):
|
||
items = [("a1", 0.5, 10.0), ("a2", 0.9, 10.0), ("a3", 0.7, 10.0)]
|
||
scored = _make_scored(items)
|
||
scored.sort(key=lambda d: d.total_score, reverse=True)
|
||
result = diverse_selection(scored, 3)
|
||
assert len(result) == 3
|
||
assert result[0].total_score >= result[1].total_score >= result[2].total_score
|
||
|
||
def test_count_one_each_bucket(self):
|
||
# count=3, base_quota=max(1,1)=1,每桶1个共3个
|
||
items = [
|
||
("s1", 0.9, 2.0),
|
||
("m1", 0.95, 8.0),
|
||
("l1", 0.92, 20.0),
|
||
]
|
||
scored = _make_scored(items)
|
||
scored.sort(key=lambda d: d.total_score, reverse=True)
|
||
result = diverse_selection(scored, 3)
|
||
assert len(result) == 3
|
||
# 每桶1个
|
||
assert any(r.duration and r.duration < SHORT_BUCKET_MAX for r in result)
|
||
assert any(r.duration and SHORT_BUCKET_MAX <= r.duration < MEDIUM_BUCKET_MAX for r in result)
|
||
assert any(r.duration and r.duration >= MEDIUM_BUCKET_MAX for r in result)
|
||
|
||
def test_unknown_duration_used_last(self):
|
||
items = [
|
||
("u1", 0.99, None), # 分最高但未知
|
||
("s1", 0.9, 2.0),
|
||
("m1", 0.8, 10.0),
|
||
("l1", 0.7, 20.0),
|
||
]
|
||
scored = _make_scored(items)
|
||
scored.sort(key=lambda d: d.total_score, reverse=True)
|
||
result = diverse_selection(scored, 3)
|
||
# 前3个应该是三个已知桶各一个
|
||
ids = [r.asset_id for r in result]
|
||
# u1 不应该在前3(因为 unknown 桶最后才用)
|
||
assert "s1" in ids
|
||
assert "m1" in ids
|
||
assert "l1" in ids
|
||
|
||
def test_no_duplicates(self):
|
||
items = [("s1", 0.9, 2.0), ("s2", 0.8, 3.0)]
|
||
scored = _make_scored(items)
|
||
result = diverse_selection(scored, 5)
|
||
ids = [r.asset_id for r in result]
|
||
assert len(ids) == len(set(ids))
|
||
|
||
def test_many_more_than_count(self):
|
||
# 30个素材,取6个
|
||
items = []
|
||
for i in range(10):
|
||
items.append((f"s{i}", 0.9 - i * 0.05, 2.0 + i * 0.2))
|
||
items.append((f"m{i}", 0.9 - i * 0.03, 6.0 + i * 0.8))
|
||
items.append((f"l{i}", 0.9 - i * 0.04, 16.0 + i * 1.5))
|
||
scored = _make_scored(items)
|
||
scored.sort(key=lambda d: d.total_score, reverse=True)
|
||
result = diverse_selection(scored, 6)
|
||
assert len(result) == 6
|
||
# 有多样性
|
||
durations = [r.duration for r in result]
|
||
short = sum(1 for d in durations if d and d < SHORT_BUCKET_MAX)
|
||
medium = sum(1 for d in durations if d and SHORT_BUCKET_MAX <= d < MEDIUM_BUCKET_MAX)
|
||
long_ = sum(1 for d in durations if d and d >= MEDIUM_BUCKET_MAX)
|
||
assert short >= 1
|
||
assert medium >= 1
|
||
assert long_ >= 1
|
||
|
||
|
||
# ── filter_candidates ───────────────────────────────────────────────────────
|
||
|
||
|
||
class TestFilterCandidates:
|
||
def test_empty_list(self):
|
||
candidates, filtered = filter_candidates([])
|
||
assert candidates == []
|
||
assert filtered == 0
|
||
|
||
def test_ready_video_passes(self):
|
||
assets = [MockAsset(id="a1", status=MockStatus("ready"), mime_type="video/mp4")]
|
||
candidates, filtered = filter_candidates(assets)
|
||
assert len(candidates) == 1
|
||
assert filtered == 0
|
||
|
||
def test_non_ready_filtered(self):
|
||
assets = [
|
||
MockAsset(id="a1", status=MockStatus("processing"), mime_type="video/mp4"),
|
||
MockAsset(id="a2", status=MockStatus("ready"), mime_type="video/mp4"),
|
||
]
|
||
candidates, filtered = filter_candidates(assets)
|
||
assert len(candidates) == 1
|
||
assert candidates[0].id == "a2"
|
||
assert filtered == 0 # 非ready不算filtered_out(filtered_out只算质量分过滤的)
|
||
|
||
def test_non_video_filtered(self):
|
||
assets = [
|
||
MockAsset(id="a1", mime_type="image/jpeg"),
|
||
MockAsset(id="a2", mime_type="video/mp4"),
|
||
]
|
||
candidates, filtered = filter_candidates(assets)
|
||
assert len(candidates) == 1
|
||
assert candidates[0].id == "a2"
|
||
|
||
def test_low_quality_filtered(self):
|
||
assets = [
|
||
MockAsset(id="low", quality_score=20.0),
|
||
MockAsset(id="high", quality_score=80.0),
|
||
]
|
||
candidates, filtered = filter_candidates(assets, min_quality_score=30.0)
|
||
assert len(candidates) == 1
|
||
assert candidates[0].id == "high"
|
||
assert filtered == 1
|
||
|
||
def test_quality_none_passes(self):
|
||
assets = [MockAsset(id="a1", quality_score=None)]
|
||
candidates, filtered = filter_candidates(assets)
|
||
assert len(candidates) == 1
|
||
assert filtered == 0
|
||
|
||
def test_quality_exact_min_passes(self):
|
||
assets = [MockAsset(id="a1", quality_score=30.0)]
|
||
candidates, filtered = filter_candidates(assets, min_quality_score=30.0)
|
||
assert len(candidates) == 1
|
||
assert filtered == 0
|
||
|
||
def test_string_status(self):
|
||
# status 是字符串不是 Enum
|
||
@dataclass
|
||
class StrAsset:
|
||
id: str = "a1"
|
||
status: str = "ready"
|
||
mime_type: str = "video/mp4"
|
||
quality_score: float = 80.0
|
||
width: int = 1920
|
||
height: int = 1080
|
||
duration: float = 10.0
|
||
file_size: int = 5_000_000
|
||
|
||
assets = [StrAsset()]
|
||
candidates, filtered = filter_candidates(assets)
|
||
assert len(candidates) == 1
|
||
|
||
def test_empty_mime_type(self):
|
||
assets = [MockAsset(id="a1", mime_type="")]
|
||
candidates, filtered = filter_candidates(assets)
|
||
assert len(candidates) == 0
|
||
|
||
def test_none_mime_type(self):
|
||
# mime_type 是 None
|
||
@dataclass
|
||
class NoneMimeAsset:
|
||
id: str = "a1"
|
||
status: Any = None
|
||
mime_type: str | None = None
|
||
quality_score: float = 80.0
|
||
width: int = 1920
|
||
height: int = 1080
|
||
duration: float = 10.0
|
||
file_size: int = 5_000_000
|
||
|
||
def __post_init__(self):
|
||
if self.status is None:
|
||
self.status = MockStatus("ready")
|
||
|
||
assets = [NoneMimeAsset()]
|
||
candidates, filtered = filter_candidates(assets)
|
||
assert len(candidates) == 0
|
||
|
||
def test_custom_min_quality(self):
|
||
assets = [
|
||
MockAsset(id="low", quality_score=40.0),
|
||
MockAsset(id="high", quality_score=60.0),
|
||
]
|
||
candidates, filtered = filter_candidates(assets, min_quality_score=50.0)
|
||
assert len(candidates) == 1
|
||
assert filtered == 1
|