53fb25efcf
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check push changed paths (push) Successful in 19s
CI/CD Pipeline / Build Staging API Image (push) Successful in 41s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 48s
CI/CD Pipeline / Integration Tests (push) Successful in 3m10s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m17s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 3m30s
CI/CD Pipeline / Validate - Style (push) Successful in 4m17s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 59s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 5m33s
CI/CD Pipeline / Validate - Security (push) Successful in 7m12s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m38s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m13s
CI/CD Pipeline / Unit Tests (push) Successful in 10m11s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Failing after 26h14m3s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 26h24m21s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 26h19m47s
CI/CD Pipeline / PR Build Web Image (push) Failing after 26h23m44s
CI/CD Pipeline / PR Build API Image (push) Failing after 26h23m44s
CI/CD Pipeline / Deploy Production (push) Failing after 26h13m23s
CI/CD Pipeline / Build Production Web Image (push) Failing after 26h13m26s
CI/CD Pipeline / CI Gate (push) Failing after 26h13m25s
CI/CD Pipeline / Build Production API Image (push) Failing after 26h13m26s
CI/CD Pipeline / Canary Release to Production (push) Failing after 26h13m23s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 26h19m46s
CI/CD Pipeline / Frontend Lint (push) Failing after 26h23m37s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 26h23m45s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 26h19m46s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
143 lines
6.4 KiB
Python
143 lines
6.4 KiB
Python
"""#1743 smart-match 排序随机噪声测试。
|
|
|
|
smart_select_assets 排序注入 0~SCORE_RANDOM_NOISE_MAX 随机噪声后:
|
|
- 同分/近分素材每次调用选出的组合与顺序不同(修复"每次只选同样几个素材")
|
|
- 分差 > 噪声上限的高质量素材保持稳定优先级
|
|
- r.score 始终为无噪声原始分;噪声只影响排序
|
|
- 同一次调用内排序与多样性分桶使用一致噪声(结果稳定可复现)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import random
|
|
import sys
|
|
from dataclasses import dataclass, field
|
|
from datetime import UTC, datetime, timezone
|
|
from pathlib import Path
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
for sub in ("packages",):
|
|
p = str(REPO_ROOT / sub)
|
|
if p not in sys.path:
|
|
sys.path.insert(0, p)
|
|
|
|
from packages.domain.smart_match import ( # noqa: E402
|
|
SCORE_RANDOM_NOISE_MAX,
|
|
score_asset,
|
|
smart_select_assets,
|
|
)
|
|
|
|
NOW = datetime(2026, 9, 6, 12, 0, 0, tzinfo=UTC)
|
|
|
|
|
|
@dataclass
|
|
class FakeAsset:
|
|
id: str
|
|
duration: float = 15.0
|
|
quality_score: float | None = None # None → 按 50 计,模拟 staging 真实情况
|
|
status: str = "ready"
|
|
metadata: dict = field(default_factory=dict)
|
|
created_at: datetime = NOW
|
|
|
|
@property
|
|
def file_type(self) -> str:
|
|
return "video"
|
|
|
|
|
|
def _make_tied_assets(n: int) -> list[FakeAsset]:
|
|
"""构造 n 个综合得分完全相同的素材(quality NULL 按 50 + 时长 15s 满分 + 同创建时间)。"""
|
|
return [FakeAsset(id=f"a{i}", duration=15.0, quality_score=None) for i in range(n)]
|
|
|
|
|
|
class TestScoreNoiseInjection:
|
|
def test_tied_assets_same_raw_score(self):
|
|
"""前置校验:同分素材原始得分确实一致。"""
|
|
assets = _make_tied_assets(6)
|
|
scores = {score_asset(a, now=NOW)[0] for a in assets}
|
|
assert len(scores) == 1, f"测试前提不成立:同分素材得分不一致 {scores}"
|
|
|
|
def test_tied_assets_order_varies_across_calls(self):
|
|
"""同分素材:不同随机种子选出的顺序/组合不同(核心修复点)。"""
|
|
orderings = set()
|
|
for seed in range(8):
|
|
results = smart_select_assets(_make_tied_assets(8), rng=random.Random(seed))
|
|
orderings.add(tuple(r.asset.id for r in results))
|
|
# 8 个不同种子应产生多种不同排序(若零随机噪声则只有 1 种)
|
|
assert len(orderings) >= 4, f"同分素材排序几乎不变: {len(orderings)} 种"
|
|
|
|
def test_tied_assets_top_n_varies_with_limit(self):
|
|
"""同分素材 + limit 截断:不同种子选出的 Top-N 组合不同。"""
|
|
top_sets = set()
|
|
for seed in range(10):
|
|
results = smart_select_assets(_make_tied_assets(10), limit=3, rng=random.Random(seed))
|
|
top_sets.add(frozenset(r.asset.id for r in results))
|
|
assert len(top_sets) >= 4, f"Top-N 组合几乎不变: {len(top_sets)} 种"
|
|
|
|
def test_large_score_gap_keeps_priority(self):
|
|
"""分差 > 噪声上限(20)时:低质素材即使噪声拉满也排不到高质素材前面。"""
|
|
# 高质:quality=100 → quality_component=40;低质:quality=0 → 0,仅质量项就差 40 分
|
|
high = [FakeAsset(id="high", quality_score=100, duration=15.0)]
|
|
low = [FakeAsset(id=f"low{i}", quality_score=0, duration=15.0) for i in range(6)]
|
|
for seed in range(20):
|
|
results = smart_select_assets(high + low, limit=3, rng=random.Random(seed))
|
|
assert results[0].asset.id == "high", f"seed={seed} 低质素材靠噪声排到首位"
|
|
|
|
def test_score_field_is_raw_without_noise(self):
|
|
"""r.score 始终是无噪声原始分(噪声只影响排序,不污染返回分值)。"""
|
|
from datetime import datetime as _dt
|
|
from unittest.mock import patch
|
|
|
|
assets = _make_tied_assets(5)
|
|
raw_scores = {score_asset(a, now=NOW)[0] for a in assets}
|
|
# 冻结 datetime.now,与 score_asset 用的 NOW 一致(recency 时变分数需稳定)
|
|
with patch("packages.domain.smart_match.datetime") as mock_dt:
|
|
mock_dt.now.return_value = NOW
|
|
# 保留 datetime 构造函数行为(如 datetime(...) 调用)
|
|
mock_dt.side_effect = _dt
|
|
results = smart_select_assets(assets, rng=random.Random(42))
|
|
for r in results:
|
|
assert r.score in raw_scores, f"score={r.score} 不在 raw_scores={raw_scores}"
|
|
|
|
def test_rng_deterministic_same_seed(self):
|
|
"""同一种子多次调用结果完全一致(可复现,测试可依赖)。"""
|
|
run = lambda: tuple( # noqa: E731
|
|
r.asset.id for r in smart_select_assets(_make_tied_assets(8), rng=random.Random(123))
|
|
)
|
|
assert run() == run()
|
|
|
|
def test_diversity_bucket_respects_noise(self):
|
|
"""多样性分桶路径(候选数 > limit):同分素材跨种子入选组合不同。"""
|
|
# 构造短/中/长三档同分素材各 4 个,limit=6 触发分桶轮询
|
|
assets = []
|
|
for i in range(4):
|
|
assets.append(FakeAsset(id=f"short{i}", duration=6.0))
|
|
for i in range(4):
|
|
assets.append(FakeAsset(id=f"med{i}", duration=15.0))
|
|
for i in range(4):
|
|
assets.append(FakeAsset(id=f"long{i}", duration=45.0))
|
|
# 同档内时长接近 → 得分接近
|
|
combos = set()
|
|
for seed in range(10):
|
|
results = smart_select_assets(assets, limit=6, rng=random.Random(seed))
|
|
combos.add(frozenset(r.asset.id for r in results))
|
|
assert len(combos) >= 3, f"分桶选取组合几乎不变: {len(combos)} 种"
|
|
|
|
def test_noise_constant_matches_from_assets(self):
|
|
"""噪声上限与 from-assets 片段分配的 SCORE_RANDOM_NOISE_MAX 同源(20 分)。"""
|
|
assert SCORE_RANDOM_NOISE_MAX == 20.0
|
|
|
|
def test_returns_all_when_no_limit(self):
|
|
"""无 limit 时返回全部候选(噪声只改顺序,不丢素材)。"""
|
|
assets = _make_tied_assets(7)
|
|
results = smart_select_assets(assets, rng=random.Random(1))
|
|
assert len(results) == 7
|
|
assert {r.asset.id for r in results} == {a.id for a in assets}
|
|
|
|
def test_non_ready_assets_excluded_before_noise(self):
|
|
"""非 ready 素材不参与排序(噪声不影响状态过滤)。"""
|
|
assets = _make_tied_assets(4)
|
|
assets[0].status = "processing"
|
|
results = smart_select_assets(assets, rng=random.Random(1))
|
|
assert all(r.asset.status == "ready" for r in results)
|
|
assert len(results) == 3
|