"""Tests for packages/domain/smart_match.py — 统一智能选素材算法。""" from dataclasses import dataclass, field from datetime import datetime, timedelta, timezone from typing import Any import pytest from packages.domain.smart_match import ( SmartMatchResult, _diversity_select, _duration_bucket, score_asset, smart_select_assets, ) # ── Fixtures ───────────────────────────────────────────────────────────────── @dataclass class FakeAsset: """Minimal Asset-like object for testing.""" id: str project_id: str = "proj-1" library_id: str = "lib-1" name: str = "test" storage_key: str = "key" mime_type: str = "video/mp4" file_size: int = 1000 duration: float | None = None width: int | None = 1080 height: int | None = 1920 quality_score: float | None = None status: str = "ready" metadata: dict = field(default_factory=dict) created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) @property def file_type(self) -> str: if "/" in self.mime_type: return self.mime_type.split("/")[0] return self.mime_type NOW = datetime(2026, 8, 5, 12, 0, 0, tzinfo=timezone.utc) # ── score_asset tests ──────────────────────────────────────────────────────── class TestScoreAsset: def test_high_quality_asset_scores_high(self): asset = FakeAsset(id="a1", quality_score=95, duration=15) score, breakdown = score_asset(asset, now=NOW) assert score > 70 assert "quality" in breakdown assert "duration" in breakdown assert "recency" in breakdown assert "unused" in breakdown def test_low_quality_asset_scores_lower(self): asset_good = FakeAsset(id="a1", quality_score=95, duration=15) asset_bad = FakeAsset( id="a2", quality_score=20, duration=15, created_at=NOW - timedelta(days=60), metadata={"generation_use_count": 10}, ) score_good, _ = score_asset(asset_good, now=NOW) score_bad, _ = score_asset(asset_bad, now=NOW) assert score_bad < score_good def test_no_quality_score_defaults_to_50(self): asset = FakeAsset(id="a1", quality_score=None, duration=15) score, breakdown = score_asset(asset, now=NOW) # quality component should be 50 * 0.4 = 20 assert breakdown["quality"] == pytest.approx(20.0, abs=0.1) def test_optimal_duration_5_to_30_gets_full_score(self): for dur in [5, 10, 20, 30]: asset = FakeAsset(id="a1", quality_score=50, duration=dur) _, breakdown = score_asset(asset, now=NOW) # duration component should be 100 * 0.3 = 30 assert breakdown["duration"] == pytest.approx(30.0, abs=0.1) def test_short_duration_below_5s_penalized(self): asset = FakeAsset(id="a1", quality_score=50, duration=2) _, breakdown = score_asset(asset, now=NOW) assert breakdown["duration"] < 30.0 def test_long_duration_above_30s_penalized(self): asset = FakeAsset(id="a1", quality_score=50, duration=120) _, breakdown = score_asset(asset, now=NOW) assert breakdown["duration"] < 30.0 def test_zero_duration_gives_moderate_score(self): asset = FakeAsset(id="a1", quality_score=50, duration=0) _, breakdown = score_asset(asset, now=NOW) # duration_fitness = 30.0, component = 30 * 0.3 = 9 assert breakdown["duration"] == pytest.approx(9.0, abs=0.1) def test_unused_asset_gets_full_bonus(self): asset = FakeAsset(id="a1", quality_score=50, duration=15, metadata={}) _, breakdown = score_asset(asset, now=NOW) assert breakdown["unused"] == pytest.approx(10.0, abs=0.1) def test_used_asset_gets_reduced_bonus(self): asset = FakeAsset(id="a1", quality_score=50, duration=15, metadata={"generation_use_count": 5}) _, breakdown = score_asset(asset, now=NOW) assert breakdown["unused"] == pytest.approx(3.0, abs=0.1) def test_dirty_metadata_use_count_string_does_not_crash(self): """int() conversion of non-numeric metadata should not raise, should default to 0.""" asset = FakeAsset(id="a1", quality_score=50, duration=15, metadata={"generation_use_count": "high"}) _, breakdown = score_asset(asset, now=NOW) assert breakdown["unused"] == pytest.approx(10.0, abs=0.1) # use_count=0 → unused_score=100 → 100*0.1=10 def test_recent_asset_scores_higher_recency(self): asset = FakeAsset(id="a1", quality_score=50, duration=15, created_at=NOW - timedelta(days=1)) _, breakdown = score_asset(asset, now=NOW) assert breakdown["recency"] > 15 # > 75% of max 20 def test_old_asset_scores_lower_recency(self): asset = FakeAsset(id="a1", quality_score=50, duration=15, created_at=NOW - timedelta(days=60)) _, breakdown = score_asset(asset, now=NOW) assert breakdown["recency"] < 5 # heavily decayed # ── _duration_bucket tests ─────────────────────────────────────────────────── class TestDurationBucket: def test_short(self): assert _duration_bucket(5) == "short" assert _duration_bucket(9.9) == "short" def test_medium(self): assert _duration_bucket(10) == "medium" assert _duration_bucket(30) == "medium" def test_long(self): assert _duration_bucket(31) == "long" assert _duration_bucket(120) == "long" def test_unknown(self): assert _duration_bucket(None) == "unknown" assert _duration_bucket(0) == "unknown" assert _duration_bucket(-1) == "unknown" # ── smart_select_assets tests ──────────────────────────────────────────────── class TestSmartSelectAssets: def test_filters_non_ready_assets(self): assets = [ FakeAsset(id="a1", status="ready", quality_score=80, duration=15), FakeAsset(id="a2", status="uploading", quality_score=90, duration=15), FakeAsset(id="a3", status="error", quality_score=70, duration=15), ] results = smart_select_assets(assets) assert len(results) == 1 assert results[0].asset.id == "a1" def test_filters_by_kind(self): assets = [ FakeAsset(id="a1", mime_type="video/mp4", quality_score=80, duration=15), FakeAsset(id="a2", mime_type="image/png", quality_score=90, duration=0), FakeAsset(id="a3", mime_type="audio/mp3", quality_score=70, duration=30), ] results = smart_select_assets(assets, kind="video") assert len(results) == 1 assert results[0].asset.id == "a1" def test_respects_limit(self): assets = [FakeAsset(id=f"a{i}", quality_score=50 + i, duration=15) for i in range(20)] results = smart_select_assets(assets, limit=5) assert len(results) == 5 def test_returns_sorted_by_score_descending(self): assets = [ FakeAsset(id="low", quality_score=20, duration=15), FakeAsset(id="high", quality_score=95, duration=15), FakeAsset(id="mid", quality_score=60, duration=15), ] results = smart_select_assets(assets) scores = [r.score for r in results] assert scores == sorted(scores, reverse=True) assert results[0].asset.id == "high" def test_empty_list_returns_empty(self): assert smart_select_assets([]) == [] def test_all_non_ready_returns_empty(self): assets = [FakeAsset(id="a1", status="uploading")] assert smart_select_assets(assets) == [] def test_diversity_select_balances_duration_buckets(self): """When limit is less than total, diversity select should pick from multiple buckets.""" assets = [] # 10 short clips for i in range(10): assets.append(FakeAsset(id=f"s{i}", quality_score=80, duration=5)) # 10 medium clips for i in range(10): assets.append(FakeAsset(id=f"m{i}", quality_score=80, duration=20)) # 10 long clips for i in range(10): assets.append(FakeAsset(id=f"l{i}", quality_score=80, duration=60)) results = smart_select_assets(assets, limit=6) assert len(results) == 6 # Should have items from multiple buckets buckets = {_duration_bucket(r.asset.duration) for r in results} assert len(buckets) >= 2 # at least 2 different duration buckets def test_no_limit_returns_all(self): assets = [FakeAsset(id=f"a{i}", quality_score=50 + i, duration=15) for i in range(10)] results = smart_select_assets(assets, limit=None) assert len(results) == 10 def test_score_includes_breakdown(self): asset = FakeAsset(id="a1", quality_score=80, duration=15, metadata={}) results = smart_select_assets([asset]) assert len(results) == 1 r = results[0] assert r.score > 0 assert set(r.breakdown.keys()) == {"quality", "duration", "recency", "unused"} def test_image_assets_can_be_selected(self): assets = [ FakeAsset(id="img1", mime_type="image/jpeg", quality_score=90, duration=None), FakeAsset(id="img2", mime_type="image/png", quality_score=70, duration=None), ] results = smart_select_assets(assets, kind="image") assert len(results) == 2 assert results[0].asset.id == "img1" def test_str_enum_status_handled(self): """Test that StrEnum-like status objects are handled correctly.""" class StrEnumLike: def __init__(self, value): self.value = value asset = FakeAsset(id="a1", quality_score=80, duration=15) asset.status = StrEnumLike("ready") results = smart_select_assets([asset]) assert len(results) == 1 # ── _diversity_select tests ────────────────────────────────────────────────── class TestDiversitySelect: def test_picks_from_all_buckets(self): results = [ SmartMatchResult(asset=FakeAsset(id="s1", duration=5), score=90), SmartMatchResult(asset=FakeAsset(id="s2", duration=3), score=85), SmartMatchResult(asset=FakeAsset(id="m1", duration=20), score=80), SmartMatchResult(asset=FakeAsset(id="l1", duration=60), score=75), ] selected = _diversity_select(results, limit=3) assert len(selected) == 3 ids = {r.asset.id for r in selected} # Should have at least one from short, medium, long assert "s1" in ids or "s2" in ids assert "m1" in ids assert "l1" in ids def test_limit_larger_than_input_returns_all(self): results = [ SmartMatchResult(asset=FakeAsset(id="a1", duration=5), score=90), ] selected = _diversity_select(results, limit=10) assert len(selected) == 1 # ── API endpoint tests ─────────────────────────────────────────────────────── import os import sys from pathlib import Path from unittest.mock import MagicMock os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing") os.environ.setdefault("DATABASE_URL", "sqlite:///test.db") from fastapi import FastAPI from fastapi.testclient import TestClient sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api")) from app.api.routes.assets import router from app.auth import AuthenticatedUser, get_current_user from app.core.storage import get_storage_service from app.dependencies import ( get_asset_library_repository, get_asset_repository, get_project_repository, ) from packages.domain import ( Asset, AssetLibrary, AssetLibraryKind, AssetStatus, ClassificationStatus, Project, User, ) class _StubProjectRepo: def __init__(self, projects): self._projects = projects def find_by_id(self, pid): return self._projects.get(pid) class _StubAssetLibraryRepo: def __init__(self, libraries): self._libraries = libraries def get(self, lid): return self._libraries.get(lid) class _StubAssetRepo: def __init__(self, assets): self._assets = assets def find_by_library(self, lid, skip=0, limit=100, status=None): result = [a for a in self._assets if a.library_id == lid] if status: result = [a for a in result if (a.status.value if hasattr(a.status, "value") else a.status) in status] return result[skip : skip + limit] def find_by_library_and_file_type(self, lid, file_type, skip=0, limit=100, status=None): result = [a for a in self._assets if a.library_id == lid and a.file_type == file_type] if status: result = [a for a in result if (a.status.value if hasattr(a.status, "value") else a.status) in status] return result[skip : skip + limit] def _make_app(asset_repo, lib_repo, proj_repo): app = FastAPI() app.include_router(router, prefix="/assets") fake_user = MagicMock() fake_user.user = User(id="user-1", email="test@test.com", display_name="Test") app.dependency_overrides[get_current_user] = lambda: AuthenticatedUser(user=fake_user.user) app.dependency_overrides[get_asset_repository] = lambda: asset_repo app.dependency_overrides[get_asset_library_repository] = lambda: lib_repo app.dependency_overrides[get_project_repository] = lambda: proj_repo app.dependency_overrides[get_storage_service] = lambda: MagicMock() return app def _make_test_data(): project = Project(id="proj-1", name="Test", owner_user_id="user-1") library = AssetLibrary( id="lib-1", project_id="proj-1", name="Videos", kind=AssetLibraryKind.VIDEO, ) assets = [ Asset.create( project_id="proj-1", library_id="lib-1", name="v1.mp4", storage_key="k1", mime_type="video/mp4", quality_score=90, duration=15, status=AssetStatus.READY, ), Asset.create( project_id="proj-1", library_id="lib-1", name="v2.mp4", storage_key="k2", mime_type="video/mp4", quality_score=50, duration=25, status=AssetStatus.READY, ), Asset.create( project_id="proj-1", library_id="lib-1", name="v3.mp4", storage_key="k3", mime_type="video/mp4", quality_score=30, duration=60, status=AssetStatus.READY, ), ] return project, library, assets class TestSmartMatchEndpoint: def test_returns_scored_items(self): project, library, assets = _make_test_data() app = _make_app( _StubAssetRepo(assets), _StubAssetLibraryRepo({"lib-1": library}), _StubProjectRepo({"proj-1": project}), ) client = TestClient(app) resp = client.post("/assets/smart-match", json={"library_id": "lib-1"}) assert resp.status_code == 200, f"Got {resp.status_code}: {resp.text}" data = resp.json() assert len(data["items"]) == 3 assert data["total_candidates"] == 3 # Sorted by score descending scores = [item["score"] for item in data["items"]] assert scores == sorted(scores, reverse=True) # Each item has breakdown for item in data["items"]: assert "quality" in item["breakdown"] assert "duration" in item["breakdown"] def test_limit_parameter(self): project, library, assets = _make_test_data() app = _make_app( _StubAssetRepo(assets), _StubAssetLibraryRepo({"lib-1": library}), _StubProjectRepo({"proj-1": project}), ) client = TestClient(app) resp = client.post("/assets/smart-match", json={"library_id": "lib-1", "limit": 2}) assert resp.status_code == 200 data = resp.json() assert len(data["items"]) == 2 assert data["total_candidates"] == 3 def test_kind_filter(self): project, library, assets = _make_test_data() # Add an image asset img_asset = Asset.create( project_id="proj-1", library_id="lib-1", name="img.png", storage_key="k4", mime_type="image/png", quality_score=95, status=AssetStatus.READY, ) assets.append(img_asset) app = _make_app( _StubAssetRepo(assets), _StubAssetLibraryRepo({"lib-1": library}), _StubProjectRepo({"proj-1": project}), ) client = TestClient(app) resp = client.post("/assets/smart-match", json={"library_id": "lib-1", "kind": "image"}) assert resp.status_code == 200 data = resp.json() assert len(data["items"]) == 1 assert data["items"][0]["asset"]["mime_type"] == "image/png" # total_candidates should only count filtered-by-kind assets (1 image, not 3 videos) assert data["total_candidates"] == 1 def test_kind_filter_video_total_candidates(self): """Verify total_candidates reflects kind filtering, not total assets.""" project, library, assets = _make_test_data() img_asset = Asset.create( project_id="proj-1", library_id="lib-1", name="img.png", storage_key="k4", mime_type="image/png", quality_score=95, status=AssetStatus.READY, ) assets.append(img_asset) app = _make_app( _StubAssetRepo(assets), _StubAssetLibraryRepo({"lib-1": library}), _StubProjectRepo({"proj-1": project}), ) client = TestClient(app) resp = client.post("/assets/smart-match", json={"library_id": "lib-1", "kind": "video"}) assert resp.status_code == 200 data = resp.json() assert len(data["items"]) == 3 # total_candidates = 3 videos only, not 4 (3 videos + 1 image) assert data["total_candidates"] == 3 def test_library_not_found_returns_404(self): app = _make_app( _StubAssetRepo([]), _StubAssetLibraryRepo({}), _StubProjectRepo({}), ) client = TestClient(app) resp = client.post("/assets/smart-match", json={"library_id": "nonexistent"}) assert resp.status_code == 404 def test_empty_library_returns_empty_items(self): project = Project(id="proj-1", name="Test", owner_user_id="user-1") library = AssetLibrary( id="lib-1", project_id="proj-1", name="Empty", kind=AssetLibraryKind.VIDEO, ) app = _make_app( _StubAssetRepo([]), _StubAssetLibraryRepo({"lib-1": library}), _StubProjectRepo({"proj-1": project}), ) client = TestClient(app) resp = client.post("/assets/smart-match", json={"library_id": "lib-1"}) assert resp.status_code == 200 data = resp.json() assert data["items"] == [] assert data["total_candidates"] == 0