44224cfaf6
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 4s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 7s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 5s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 10s
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 Lint (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
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 / PR Build Web Image (pull_request) Successful in 36s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 38s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 44s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
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 / Build Staging API Image (push) Successful in 28s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 23s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 29s
CI/CD Pipeline / CI Gate (pull_request) Successful in 18s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (push) Successful in 1m43s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m57s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m0s
CI/CD Pipeline / Integration Tests (push) Successful in 2m3s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m5s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m7s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m10s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 4m56s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (push) Successful in 5m13s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m24s
AI Code Review / AI Code Review (pull_request) Successful in 6m35s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m33s
CI/CD Pipeline / Unit Tests (push) Failing after 9m24s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web 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 / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
320 lines
12 KiB
Python
320 lines
12 KiB
Python
"""#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
|