"""#1766 转场位置与类型随机化测试 (packages/domain/transition_randomizer.py). 覆盖: - TRANSITION_POOL 定义(5 种效果) - generate_transition_plan:硬切比例 30%-50% - generate_transition_plan:转场时长 0.3s ~ 0.8s - generate_transition_plan:jitter 在 ±0.5s 范围内 - 不同 seed 产生不同转场序列 - 与片段时长协同:长片段间转场更长 - 边界情况:num_transitions=0、1 个片段 """ from __future__ import annotations import random import sys from pathlib import Path import pytest REPO_ROOT = Path(__file__).resolve().parents[2] for sub in ("packages", ""): p = str(REPO_ROOT / sub) if sub else str(REPO_ROOT) if p not in sys.path: sys.path.insert(0, p) from packages.domain import transition_randomizer as tr # noqa: E402 from packages.domain.transition_randomizer import ( # noqa: E402 CUT_RATIO_MAX, CUT_RATIO_MIN, HARD_CUT, TIMING_JITTER_MAX, TRANSITION_DURATION_MAX, TRANSITION_DURATION_MIN, TRANSITION_POOL, _cut_probability_for_pair, generate_transition_plan, ) class TestTransitionPool: """转场类型池定义测试。""" def test_pool_has_5_effects(self): assert len(TRANSITION_POOL) == 5 def test_pool_contains_expected_effects(self): assert "dissolve" in TRANSITION_POOL assert "zoomin" in TRANSITION_POOL assert "slideleft" in TRANSITION_POOL assert "wipeleft" in TRANSITION_POOL assert "fade" in TRANSITION_POOL def test_pool_does_not_contain_cut(self): assert HARD_CUT not in TRANSITION_POOL class TestConstants: """常量约束测试。""" def test_duration_range(self): assert TRANSITION_DURATION_MIN == 0.3 assert TRANSITION_DURATION_MAX == 0.8 def test_cut_ratio_range(self): assert CUT_RATIO_MIN == 0.3 assert CUT_RATIO_MAX == 0.5 def test_jitter_max(self): assert TIMING_JITTER_MAX == 0.5 class TestCutProbability: """硬切概率计算测试。""" def test_short_clips_higher_cut_prob(self): """短片段(<3s)→ 硬切概率偏高。""" prob = _cut_probability_for_pair(2.0, 2.5) assert prob > 0.4 # 高于基准 def test_long_clips_lower_cut_prob(self): """长片段(>6s)→ 硬切概率偏低。""" prob = _cut_probability_for_pair(8.0, 7.0) assert prob < 0.4 # 低于基准 def test_medium_clips_base_prob(self): """中等片段(3-6s)→ 基准概率。""" prob = _cut_probability_for_pair(4.0, 5.0) assert abs(prob - 0.4) < 1e-6 def test_probability_in_range(self): """概率始终在 [MIN, MAX] 范围内。""" for prev in [1.0, 3.0, 5.0, 8.0, 15.0]: for next_ in [1.0, 3.0, 5.0, 8.0, 15.0]: prob = _cut_probability_for_pair(prev, next_) assert CUT_RATIO_MIN <= prob <= CUT_RATIO_MAX class TestGenerateTransitionPlan: """generate_transition_plan 核心测试。""" def test_zero_transitions_returns_empty(self): assert generate_transition_plan(0) == [] def test_negative_transitions_returns_empty(self): assert generate_transition_plan(-1) == [] def test_returns_correct_count(self): plan = generate_transition_plan(5, rng=random.Random(42)) assert len(plan) == 5 def test_each_item_has_required_keys(self): plan = generate_transition_plan(3, rng=random.Random(42)) for item in plan: assert "effect" in item assert "duration" in item assert "jitter" in item def test_effects_are_valid(self): """所有 effect 要么是 cut 要么是 TRANSITION_POOL 中的。""" plan = generate_transition_plan(20, rng=random.Random(42)) valid_effects = set(TRANSITION_POOL) | {HARD_CUT} for item in plan: assert item["effect"] in valid_effects def test_hard_cut_ratio_in_range_many_samples(self): """100 个转场点,硬切比例在 30%-50%(统计保证)。""" plan = generate_transition_plan( 100, clip_durations=[5.0] * 101, rng=random.Random(42), ) num_cuts = sum(1 for item in plan if item["effect"] == HARD_CUT) ratio = num_cuts / len(plan) # 统计波动允许 ±10% 的宽松范围 assert 0.20 <= ratio <= 0.60, f"硬切比例 {ratio:.2%} 超出宽松范围" # 更严格的范围检查(±5%) assert CUT_RATIO_MIN - 0.05 <= ratio <= CUT_RATIO_MAX + 0.05, f"硬切比例 {ratio:.2%} 超出 [25%, 55%] 范围" def test_transition_duration_in_range(self): """非硬切转场的时长在 [0.3, 0.8] 范围内。""" plan = generate_transition_plan(30, rng=random.Random(42)) for item in plan: if item["effect"] != HARD_CUT: assert ( TRANSITION_DURATION_MIN <= item["duration"] <= TRANSITION_DURATION_MAX ), f"转场时长 {item['duration']} 超出 [{TRANSITION_DURATION_MIN}, {TRANSITION_DURATION_MAX}]" def test_cut_duration_is_zero(self): """硬切转场的时长必须为 0。""" plan = generate_transition_plan(20, rng=random.Random(42)) for item in plan: if item["effect"] == HARD_CUT: assert item["duration"] == 0.0 def test_jitter_in_range(self): """jitter 在 [-0.5, +0.5] 范围内。""" plan = generate_transition_plan(30, rng=random.Random(42)) for item in plan: assert -TIMING_JITTER_MAX <= item["jitter"] <= TIMING_JITTER_MAX, f"jitter {item['jitter']} 超出范围" def test_cut_jitter_is_zero(self): """硬切转场的 jitter 必须为 0。""" plan = generate_transition_plan(20, rng=random.Random(42)) for item in plan: if item["effect"] == HARD_CUT: assert item["jitter"] == 0.0 def test_different_seeds_produce_different_plans(self): """不同 seed 产生不同的转场序列(至少 2 组不同)。""" plans_seen = set() for seed in range(20): plan = generate_transition_plan(5, rng=random.Random(seed)) plan_sig = tuple((item["effect"], item["duration"]) for item in plan) plans_seen.add(plan_sig) assert len(plans_seen) >= 2, "20 个 seed 只产生 1 种转场序列" def test_same_seed_same_plan(self): """相同 seed 产生相同的转场序列(确定性)。""" plan1 = generate_transition_plan(5, rng=random.Random(42)) plan2 = generate_transition_plan(5, rng=random.Random(42)) assert plan1 == plan2 def test_long_clips_longer_transitions(self): """长片段(>6s)之间的转场倾向于比短片段更长。""" # 长片段 long_plan = generate_transition_plan( 20, clip_durations=[10.0] * 21, rng=random.Random(42), ) # 短片段 short_plan = generate_transition_plan( 20, clip_durations=[2.0] * 21, rng=random.Random(42), ) # 长片段的非硬切转场平均时长 long_durs = [item["duration"] for item in long_plan if item["effect"] != HARD_CUT] short_durs = [item["duration"] for item in short_plan if item["effect"] != HARD_CUT] if long_durs and short_durs: avg_long = sum(long_durs) / len(long_durs) avg_short = sum(short_durs) / len(short_durs) # 长片段平均转场时长 >= 短片段(协同节奏) assert avg_long >= avg_short * 0.95, f"长片段转场 {avg_long:.3f}s 不应显著短于短片段 {avg_short:.3f}s" def test_clip_durations_none_uses_default(self): """clip_durations=None 时使用默认值 5.0。""" plan = generate_transition_plan(3, rng=random.Random(42)) assert len(plan) == 3 def test_fewer_clip_durations_than_needed(self): """clip_durations 长度不足时用默认值补齐。""" plan = generate_transition_plan( 5, clip_durations=[4.0, 5.0], # 只需前 2 个 rng=random.Random(42), ) assert len(plan) == 5 class TestTransitionRandomizationIntegration: """转场随机化与变体生成集成测试。""" def test_reselect_produces_different_transitions(self): """多次 reselect_clips_for_variant 产生不同的转场序列。""" from packages.domain.variant_plan_selector import reselect_clips_for_variant source_clips = [ { "order": i, "asset_id": f"asset_{i}", "start_time": 0.0, "duration": 5.0, "clip_type": "main", "playback_speed": 1.0, "transition_effect": "cut", "transition_duration": 0.0, "text_content": "", "config": {}, } for i in range(4) ] asset_durations = {f"asset_{i}": 30.0 for i in range(4)} transition_seqs = set() for seed in range(5): rng = random.Random(seed) result = reselect_clips_for_variant( source_clips, list(asset_durations.keys()), asset_durations=asset_durations, rng=rng, ) seq = tuple( (c.get("transition_effect"), round(c.get("transition_duration", 0), 2)) for c in result if c.get("clip_type") == "main" ) transition_seqs.add(seq) assert len(transition_seqs) >= 2, f"5 个 seed 只产生 {len(transition_seqs)} 种转场序列" def test_reselect_preserves_non_main_transitions(self): """非 main 片段(intro/outro)的转场不被随机化。""" from packages.domain.variant_plan_selector import reselect_clips_for_variant source_clips = [ { "order": 0, "asset_id": "intro_asset", "start_time": 0.0, "duration": 3.0, "clip_type": "intro", "playback_speed": 1.0, "transition_effect": "fade", "transition_duration": 0.5, "text_content": "", "config": {}, }, { "order": 1, "asset_id": "a1", "start_time": 0.0, "duration": 5.0, "clip_type": "main", "playback_speed": 1.0, "transition_effect": "cut", "transition_duration": 0.0, "text_content": "", "config": {}, }, { "order": 2, "asset_id": "a2", "start_time": 0.0, "duration": 5.0, "clip_type": "main", "playback_speed": 1.0, "transition_effect": "cut", "transition_duration": 0.0, "text_content": "", "config": {}, }, ] asset_durations = {"intro_asset": 10.0, "a1": 30.0, "a2": 30.0} result = reselect_clips_for_variant( source_clips, list(asset_durations.keys()), asset_durations=asset_durations, rng=random.Random(42), ) # intro 片段的转场保持不变 intro_clip = next(c for c in result if c["clip_type"] == "intro") assert intro_clip["transition_effect"] == "fade" assert intro_clip["transition_duration"] == 0.5