Files
xiaoxia-saas/tests/unit/test_transition_randomizer_1766.py
xiaoxia 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
feat: 转场位置与类型随机化 (#1766) (#1786)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-08 10:17:35 +08:00

320 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""#1766 转场位置与类型随机化测试 (packages/domain/transition_randomizer.py).
覆盖:
- TRANSITION_POOL 定义(5 种效果)
- generate_transition_plan:硬切比例 30%-50%
- generate_transition_plan:转场时长 0.3s ~ 0.8s
- generate_transition_planjitter 在 ±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