Files
xiaoxia-saas/tests/unit/test_bgm_pool.py
xiaoxia 98cf571ab3
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) 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 / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 3s
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 4s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 9s
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (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 / PR Build API Image (pull_request) Successful in 30s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 51s
CI/CD Pipeline / Integration Tests (push) Successful in 1m45s
CI/CD Pipeline / Build Staging API Image (push) Successful in 34s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m49s
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 / Build Staging Worker Image (push) Successful in 42s
CI/CD Pipeline / Validate - Style (push) Successful in 2m34s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m20s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 55s
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
feat: #1767 BGM 池差异化分配,打破变体间音频指纹一致性 (#1785)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-08 10:12:52 +08:00

282 lines
11 KiB
Python

"""BGM 池差异化分配单元测试 (Issue #1767).
覆盖:
- BGM 池定义(10 首,覆盖 4 种风格)
- 风格匹配:get_bgm_pool_candidates 按 mood 筛选
- 变体分配:select_bgm_from_pool 基于 seed 可复现选择
- 批量分配:allocate_bgm_pool_for_variants 确保不同变体不同 BGM
- 段落差异化:generate_bgm_segment_offset 生成不同偏移
- 音量微调:generate_bgm_volume_adjust ±3dB
- 边界条件:空配置/未启用 BGM/未知 mood
"""
from __future__ import annotations
import pytest
from packages.domain.bgm_pool import (
BGM_POOL,
STYLE_TO_MOOD,
BGMPoolEntry,
allocate_bgm_pool_for_variants,
generate_bgm_segment_offset,
generate_bgm_volume_adjust,
get_bgm_pool_candidates,
select_bgm_from_pool,
)
class TestBGMPoolDefinition:
"""BGM 池定义测试。"""
def test_pool_has_at_least_10_entries(self):
"""BGM 池至少 10 首(满足 5-10 首需求)。"""
assert len(BGM_POOL) >= 10
def test_all_entries_have_required_fields(self):
"""每条 BGM 池条目都有 id/preset_id/mood/duration。"""
for entry in BGM_POOL:
assert entry.id, "Missing id"
assert entry.preset_id, f"Missing preset_id in {entry.id}"
assert entry.mood, f"Missing mood in {entry.id}"
assert entry.duration > 0, f"Duration must be > 0 in {entry.id}"
def test_pool_covers_multiple_moods(self):
"""池覆盖至少 3 种不同 mood。"""
moods = {e.mood for e in BGM_POOL}
assert len(moods) >= 3, f"Expected >= 3 moods, got {moods}"
def test_each_mood_has_at_least_2_entries(self):
"""每种 mood 至少有 2 首(保证差异化有意义)。"""
mood_counts: dict[str, int] = {}
for entry in BGM_POOL:
mood_counts[entry.mood] = mood_counts.get(entry.mood, 0) + 1
for mood, count in mood_counts.items():
assert count >= 2, f"Mood '{mood}' only has {count} entries (need >= 2)"
class TestGetBGMPoolCandidates:
"""风格匹配测试。"""
def test_no_mood_returns_full_pool(self):
"""不指定 mood 时返回全池。"""
candidates = get_bgm_pool_candidates(None)
assert len(candidates) == len(BGM_POOL)
def test_matching_mood_filters(self):
"""指定已知 mood 时返回同 mood 条目。"""
candidates = get_bgm_pool_candidates("upbeat")
assert all(c.mood == "upbeat" for c in candidates)
assert len(candidates) >= 2
def test_unknown_mood_returns_full_pool(self):
"""未知 mood 降级返回全池。"""
candidates = get_bgm_pool_candidates("nonexistent_style")
# 如果没有匹配到同 mood 的(>=2),降级全池
assert len(candidates) >= 2
def test_style_to_mood_mapping(self):
"""STYLE_TO_MOOD 覆盖所有 preset_bgm 的 style。"""
assert "upbeat" in STYLE_TO_MOOD
assert "relax" in STYLE_TO_MOOD
assert "tech" in STYLE_TO_MOOD
assert "commerce" in STYLE_TO_MOOD
class TestSelectBGMPool:
"""变体 BGM 选择测试。"""
def test_same_seed_same_result(self):
"""相同 seed 返回相同 BGM(可复现)。"""
result1 = select_bgm_from_pool(42)
result2 = select_bgm_from_pool(42)
assert result1.id == result2.id
def test_different_seeds_may_differ(self):
"""不同 seed 可能返回不同 BGM。"""
results = set()
for seed in range(50):
entry = select_bgm_from_pool(seed)
results.add(entry.id)
assert len(results) >= 3, "Expected >= 3 different BGMs from 50 seeds"
def test_respects_candidates_filter(self):
"""传入候选池时只从中选择。"""
candidates = [e for e in BGM_POOL if e.mood == "tech"]
for seed in range(20):
entry = select_bgm_from_pool(seed, candidates)
assert entry.mood == "tech"
class TestBGMSegmentOffset:
"""段落差异化测试(策略二)。"""
def test_offset_non_negative(self):
"""偏移 >= 0。"""
for seed in range(50):
offset = generate_bgm_segment_offset(seed, 120.0)
assert offset >= 0.0
def test_offset_bounded(self):
"""偏移 <= min(30s, duration * 0.3)。"""
for seed in range(50):
duration = 100.0
max_expected = min(30.0, duration * 0.3)
offset = generate_bgm_segment_offset(seed, duration)
assert offset <= max_expected + 0.1 # 容差
def test_different_seeds_different_offsets(self):
"""不同 seed 产生不同偏移(统计验证)。"""
offsets = set()
for seed in range(30):
offsets.add(generate_bgm_segment_offset(seed, 120.0))
assert len(offsets) >= 3, "Expected >= 3 distinct offsets"
def test_offset_is_quantized(self):
"""偏移是 5s 的整数倍。"""
for seed in range(20):
offset = generate_bgm_segment_offset(seed, 120.0)
assert offset % 5.0 == 0.0
def test_short_bgm_zero_offset(self):
"""极短 BGM 偏移为 0。"""
offset = generate_bgm_segment_offset(42, 5.0)
# max_offset = min(30, 5*0.3) = 1.5, steps = int(1.5//5) = 0 → return 0
assert offset == 0.0
def test_offset_different_from_bgm_selection(self):
"""偏移的 seed 序列与 BGM 选择的 seed 序列不同(加素数偏移)。"""
# 同一 seed,偏移和 BGM 选择应该独立
bgm = select_bgm_from_pool(42)
offset = generate_bgm_segment_offset(42, 120.0)
# 只是验证能正常运行,不直接断言独立性(统计测试需要大样本)
assert isinstance(offset, float)
class TestBGMVolumeAdjust:
"""音量微调测试(策略三)。"""
def test_volume_adjust_in_range(self):
"""音量调整在 -3 ~ +3 dB 范围内。"""
for seed in range(50):
adj = generate_bgm_volume_adjust(seed)
assert -3.0 <= adj <= 3.0
assert adj == int(adj) # 整数 dB 步进
def test_different_seeds_different_volumes(self):
"""不同 seed 产生不同音量调整值。"""
values = set()
for seed in range(50):
values.add(generate_bgm_volume_adjust(seed))
assert len(values) >= 3, "Expected >= 3 distinct volume values"
def test_includes_zero(self):
"""音量调整值集合包含 0(不变)。"""
values = {generate_bgm_volume_adjust(seed) for seed in range(100)}
assert 0.0 in values
class TestAllocateBGMPoolForVariants:
"""批量分配入口测试。"""
def test_disabled_bgm_returns_empty(self):
"""源 BGM 未启用时返回空列表。"""
result = allocate_bgm_pool_for_variants({"enabled": False}, [1, 2, 3])
assert result == []
def test_empty_config_returns_empty(self):
"""空配置返回空列表。"""
result = allocate_bgm_pool_for_variants({}, [1, 2, 3])
assert result == []
def test_empty_seeds_returns_empty(self):
"""无变体时返回空列表。"""
result = allocate_bgm_pool_for_variants({"enabled": True, "preset_id": "bgm_upbeat_001"}, [])
assert result == []
def test_returns_correct_count(self):
"""返回与 variant_seeds 等长的列表。"""
config = {"enabled": True, "preset_id": "bgm_upbeat_001"}
seeds = [100, 200, 300, 400]
result = allocate_bgm_pool_for_variants(config, seeds)
assert len(result) == 4
def test_each_entry_has_required_keys(self):
"""每项都包含必要字段。"""
config = {"enabled": True, "preset_id": "bgm_upbeat_001"}
seeds = [100, 200, 300]
result = allocate_bgm_pool_for_variants(config, seeds)
for entry in result:
assert "preset_id" in entry
assert "audio_offset" in entry
assert "volume_adjust_db" in entry
assert "bgm_pool_entry_id" in entry
assert "bgm_pool_mood" in entry
def test_batch_3_variants_at_least_2_different_bgm(self):
"""批量 3 个变体,至少 2 个不同 BGM。"""
config = {"enabled": True, "preset_id": "bgm_upbeat_001"}
seeds = [100, 200, 300]
result = allocate_bgm_pool_for_variants(config, seeds)
bgm_ids = {r["bgm_pool_entry_id"] for r in result}
assert len(bgm_ids) >= 2, f"Expected >= 2 different BGMs, got {bgm_ids}"
def test_style_matching_with_preset_id(self):
"""源 BGM 有 preset_id 时按风格筛选。"""
config = {"enabled": True, "preset_id": "bgm_tech_001"} # tech 风格
seeds = [100, 200, 300]
result = allocate_bgm_pool_for_variants(config, seeds)
# tech mood 至少有 2 首,所以应该筛选到 tech
for entry in result:
assert entry["bgm_pool_mood"] == "tech"
def test_reproducible_with_same_seeds(self):
"""相同 seeds 产生相同分配(可复现)。"""
config = {"enabled": True, "preset_id": "bgm_upbeat_001"}
seeds = [42, 100, 200]
result1 = allocate_bgm_pool_for_variants(config, seeds)
result2 = allocate_bgm_pool_for_variants(config, seeds)
for r1, r2 in zip(result1, result2, strict=False):
assert r1["bgm_pool_entry_id"] == r2["bgm_pool_entry_id"]
assert r1["audio_offset"] == r2["audio_offset"]
assert r1["volume_adjust_db"] == r2["volume_adjust_db"]
class TestIssue1767Acceptance:
"""Issue #1767 验收测试。"""
def test_batch_3_videos_bgm_different_or_segment_different(self):
"""批量生成 3 个视频,BGM 不同或起始段落不同。"""
config = {"enabled": True, "preset_id": "bgm_upbeat_001"}
seeds = [100, 200, 300]
result = allocate_bgm_pool_for_variants(config, seeds)
# 检查:BGM 不同 或 段落偏移不同
unique_combos = set()
for r in result:
combo = (r["bgm_pool_entry_id"], r["audio_offset"])
unique_combos.add(combo)
assert len(unique_combos) >= 2, f"Expected >= 2 unique (bgm, offset) combos, got {unique_combos}"
def test_bgm_volume_micro_adjust_doesnt_affect_voice_clarity(self):
"""BGM 音量微调在 ±3dB 内,不影响配音清晰度。"""
config = {"enabled": True, "preset_id": "bgm_upbeat_001"}
seeds = [100, 200, 300]
result = allocate_bgm_pool_for_variants(config, seeds)
for r in result:
# ±3dB 是安全的微调范围,不会让 BGM 盖过配音
assert abs(r["volume_adjust_db"]) <= 3.0
def test_single_video_mode_unaffected(self):
"""单视频模式不受影响(不走池分配)。"""
# 单视频不调用 allocate_bgm_pool_for_variants
# 只要不主动调用,就不会改变行为
# 这个测试验证函数签名和行为不会意外影响单视频
config = {"enabled": True, "preset_id": "bgm_upbeat_001"}
result = allocate_bgm_pool_for_variants(config, [42]) # 单变体
assert len(result) == 1
# 单项分配仍然有完整配置(不影响功能,只是差异化)
assert "preset_id" in result[0]