Files
xiaoxia-saas/tests/unit/test_smart_match.py
T
xiaoxia e27a48f0e0
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
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 - Migration (alembic) (push) Successful in 1m16s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m38s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m44s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m17s
CI/CD Pipeline / Validate - Code Quality (push) Successful in 3m29s
CI/CD Pipeline / Integration Tests (push) Successful in 1m40s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 6m29s
CI/CD Pipeline / Unit Tests (push) Successful in 8m8s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 14m13s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 47s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 47s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 2m8s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m59s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
feat: implement POST /assets/smart-match endpoint (#1242)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-05 09:51:01 +08:00

525 lines
20 KiB
Python
Executable File

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