"""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