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
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
525 lines
20 KiB
Python
Executable File
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
|