Files
xiaoxia-saas/tests/unit/test_smart_match.py
T
saas-backend-agent 6cdb70bb61
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 14s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 15s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m22s
AI Code Review / AI Code Review (pull_request) Successful in 1m31s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 1m35s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 1m53s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m54s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m49s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 3m7s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m31s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 14m41s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 9s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 7s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 1m30s
test(#1743): 修复 smart-match 随机噪声引发的确定性排序断言
CI Unit Tests 暴露(沙箱 httpx2 无法 collect test_smart_match*.py 未本地复现):
- test_smart_match_fallback test_limit_backfills: second(quality=40)/third(30)
  质量项仅差 4 分 < 噪声 20,third 可翻到首位 → second 质量提至 90(差 24>20),
  200 seeds 验证零翻转
- test_smart_match test_returns_sorted_by_score_descending / test_image_assets:
  smart_select_assets 直接调用处注入零噪声 rng(_ZeroNoiseRandom),验证纯评分排序
- 验证生产噪声行为:大分差素材排名稳定、同分素材组合随机
2026-09-06 19:58:59 +08:00

549 lines
20 KiB
Python
Executable File

"""Tests for packages/domain/smart_match.py — 统一智能选素材算法。"""
import random
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from typing import Any
import pytest
from packages.domain.smart_match import (
SCORE_RANDOM_NOISE_MAX,
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)
class _ZeroNoiseRandom(random.Random):
"""零噪声随机源:uniform(0, NOISE_MAX) 恒返回 0,使「按评分降序」类断言确定可复现。
smart_select_assets 生产环境注入随机噪声(同分素材每次选出不同组合,#1743);
验证纯评分排序的单测用本随机源消除排序随机性。
"""
def uniform(self, a, b):
if a == 0.0 and b == SCORE_RANDOM_NOISE_MAX:
return 0.0
return super().uniform(a, b)
_ZERO_NOISE = _ZeroNoiseRandom(0)
# ── 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, rng=_ZERO_NOISE)
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", rng=_ZERO_NOISE)
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"]
# P0 回归:扁平结构——素材字段在 item 顶层,无 asset 包装层
for item in data["items"]:
assert item["id"]
assert "asset" not in item
assert item["mime_type"].startswith("video/")
assert "usable" in item
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]["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