6e676a07f7
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Successful in 7m34s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m15s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m30s
CI/CD Pipeline / Unit Tests (push) Successful in 10m51s
CI/CD Pipeline / Integration Tests (push) Successful in 4m28s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m23s
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 / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 4m42s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m3s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 12m48s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m37s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 3m8s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 6m17s
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 / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 39s
CI/CD Pipeline / CI Gate (push) Has been skipped
407 lines
13 KiB
Python
Executable File
407 lines
13 KiB
Python
Executable File
"""渲染图层工具函数单测.
|
||
|
||
纯函数模块,覆盖:图层角色映射、z_index、
|
||
clip时长计算、总时长估算、直通判断。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass, field
|
||
|
||
from packages.domain.render_layer_utils import (
|
||
LAYER_Z_INDEX,
|
||
MAIN_LAYER_ROLES,
|
||
PIP_DEFAULT_SCALE,
|
||
can_pass_through,
|
||
clip_adjusted_duration,
|
||
clip_effective_duration,
|
||
clip_playback_speed,
|
||
estimate_total_duration,
|
||
get_layer_z_index,
|
||
resolve_layer_role,
|
||
)
|
||
|
||
|
||
class TestConstants:
|
||
def test_layer_z_index_keys(self):
|
||
assert "background" in LAYER_Z_INDEX
|
||
assert "broll" in LAYER_Z_INDEX
|
||
assert "main" in LAYER_Z_INDEX
|
||
assert "overlay" in LAYER_Z_INDEX
|
||
assert "corner_voice" in LAYER_Z_INDEX
|
||
assert "audio" in LAYER_Z_INDEX
|
||
|
||
def test_layer_z_index_values(self):
|
||
assert LAYER_Z_INDEX["background"] == -1
|
||
assert LAYER_Z_INDEX["broll"] == 0
|
||
assert LAYER_Z_INDEX["main"] == 0
|
||
assert LAYER_Z_INDEX["overlay"] == 1
|
||
assert LAYER_Z_INDEX["corner_voice"] == 1
|
||
assert LAYER_Z_INDEX["audio"] == 2
|
||
|
||
def test_pip_default_scale(self):
|
||
assert PIP_DEFAULT_SCALE == 0.25
|
||
|
||
def test_main_layer_roles(self):
|
||
assert "main" in MAIN_LAYER_ROLES
|
||
assert "broll" in MAIN_LAYER_ROLES
|
||
assert "background" in MAIN_LAYER_ROLES
|
||
assert len(MAIN_LAYER_ROLES) == 3
|
||
|
||
|
||
class TestResolveLayerRole:
|
||
def test_main_default(self):
|
||
assert resolve_layer_role("main") == "main"
|
||
|
||
def test_intro_maps_to_main(self):
|
||
assert resolve_layer_role("intro") == "main"
|
||
|
||
def test_outro_maps_to_main(self):
|
||
assert resolve_layer_role("outro") == "main"
|
||
|
||
def test_overlay(self):
|
||
assert resolve_layer_role("overlay") == "overlay"
|
||
|
||
def test_corner_voice(self):
|
||
assert resolve_layer_role("corner_voice") == "corner_voice"
|
||
|
||
def test_background(self):
|
||
assert resolve_layer_role("background") == "background"
|
||
|
||
def test_b_roll(self):
|
||
assert resolve_layer_role("b_roll") == "broll"
|
||
|
||
def test_main_with_b_roll_role(self):
|
||
assert resolve_layer_role("main", {"role": "b_roll"}) == "broll"
|
||
|
||
def test_main_with_audio_role(self):
|
||
assert resolve_layer_role("main", {"role": "audio"}) == "audio"
|
||
|
||
def test_main_with_unknown_role(self):
|
||
assert resolve_layer_role("main", {"role": "something"}) == "main"
|
||
|
||
def test_overlay_ignores_config_role(self):
|
||
"""overlay类型不受config.role影响."""
|
||
assert resolve_layer_role("overlay", {"role": "b_roll"}) == "overlay"
|
||
|
||
def test_intro_ignores_config_role(self):
|
||
"""intro类型不受config.role影响."""
|
||
assert resolve_layer_role("intro", {"role": "audio"}) == "main"
|
||
|
||
def test_none_config(self):
|
||
assert resolve_layer_role("main", None) == "main"
|
||
|
||
def test_empty_config(self):
|
||
assert resolve_layer_role("main", {}) == "main"
|
||
|
||
def test_unknown_clip_type_defaults_to_main(self):
|
||
"""未知clip_type走default分支返回main."""
|
||
assert resolve_layer_role("unknown_type") == "main"
|
||
|
||
|
||
class TestGetLayerZIndex:
|
||
def test_background(self):
|
||
assert get_layer_z_index("background") == -1
|
||
|
||
def test_main(self):
|
||
assert get_layer_z_index("main") == 0
|
||
|
||
def test_broll(self):
|
||
assert get_layer_z_index("broll") == 0
|
||
|
||
def test_overlay(self):
|
||
assert get_layer_z_index("overlay") == 1
|
||
|
||
def test_corner_voice(self):
|
||
assert get_layer_z_index("corner_voice") == 1
|
||
|
||
def test_audio(self):
|
||
assert get_layer_z_index("audio") == 2
|
||
|
||
def test_unknown_returns_zero(self):
|
||
assert get_layer_z_index("unknown_role") == 0
|
||
|
||
def test_empty_string_returns_zero(self):
|
||
assert get_layer_z_index("") == 0
|
||
|
||
|
||
class TestClipEffectiveDuration:
|
||
def test_duration_only(self):
|
||
"""只有duration,没有actual,用duration."""
|
||
assert clip_effective_duration(5.0) == 5.0
|
||
|
||
def test_duration_less_than_actual(self):
|
||
"""duration < actual,取duration."""
|
||
assert clip_effective_duration(3.0, 5.0) == 3.0
|
||
|
||
def test_duration_greater_than_actual(self):
|
||
"""duration > actual,取actual."""
|
||
assert clip_effective_duration(10.0, 5.0) == 5.0
|
||
|
||
def test_duration_equal_to_actual(self):
|
||
assert clip_effective_duration(5.0, 5.0) == 5.0
|
||
|
||
def test_zero_duration_with_actual(self):
|
||
"""duration=0表示使用完整素材,取actual."""
|
||
assert clip_effective_duration(0.0, 8.0) == 8.0
|
||
|
||
def test_negative_duration_with_actual(self):
|
||
"""duration<0也取actual."""
|
||
assert clip_effective_duration(-1.0, 8.0) == 8.0
|
||
|
||
def test_zero_duration_zero_actual(self):
|
||
assert clip_effective_duration(0.0, 0.0) == 0.0
|
||
|
||
def test_zero_actual_uses_duration(self):
|
||
"""actual=0时,duration>0就用duration."""
|
||
assert clip_effective_duration(5.0, 0.0) == 5.0
|
||
|
||
def test_both_zero(self):
|
||
assert clip_effective_duration(0.0) == 0.0
|
||
|
||
|
||
class TestClipPlaybackSpeed:
|
||
def test_normal_speed(self):
|
||
assert clip_playback_speed(1.0) == 1.0
|
||
|
||
def test_fast_speed(self):
|
||
assert clip_playback_speed(2.0) == 2.0
|
||
|
||
def test_slow_speed(self):
|
||
assert clip_playback_speed(0.5) == 0.5
|
||
|
||
def test_zero_speed_fallback(self):
|
||
assert clip_playback_speed(0) == 1.0
|
||
|
||
def test_negative_speed_fallback(self):
|
||
assert clip_playback_speed(-1.0) == 1.0
|
||
|
||
def test_string_fallback(self):
|
||
assert clip_playback_speed("fast") == 1.0
|
||
|
||
def test_none_fallback(self):
|
||
assert clip_playback_speed(None) == 1.0
|
||
|
||
def test_int_speed(self):
|
||
assert clip_playback_speed(2) == 2.0
|
||
|
||
def test_list_fallback(self):
|
||
assert clip_playback_speed([1, 2]) == 1.0
|
||
|
||
def test_dict_fallback(self):
|
||
assert clip_playback_speed({"speed": 2}) == 1.0
|
||
|
||
|
||
class TestClipAdjustedDuration:
|
||
def test_normal_speed_no_change(self):
|
||
assert clip_adjusted_duration(5.0, 10.0, 1.0) == 5.0
|
||
|
||
def test_double_speed_half_duration(self):
|
||
assert clip_adjusted_duration(4.0, 10.0, 2.0) == 2.0
|
||
|
||
def test_half_speed_double_duration(self):
|
||
assert clip_adjusted_duration(4.0, 10.0, 0.5) == 8.0
|
||
|
||
def test_uses_effective_duration(self):
|
||
"""duration>actual时取actual,再调速."""
|
||
assert clip_adjusted_duration(10.0, 4.0, 2.0) == 2.0
|
||
|
||
def test_default_speed(self):
|
||
assert clip_adjusted_duration(5.0, 10.0) == 5.0
|
||
|
||
def test_invalid_speed_fallback(self):
|
||
assert clip_adjusted_duration(5.0, 10.0, "bad") == 5.0
|
||
|
||
def test_zero_speed_fallback(self):
|
||
assert clip_adjusted_duration(5.0, 10.0, 0) == 5.0
|
||
|
||
def test_very_close_to_one_speed(self):
|
||
"""速度接近1.0时直接返回base,不做除法."""
|
||
result = clip_adjusted_duration(5.0, 10.0, 1.0000001)
|
||
assert result == 5.0
|
||
|
||
def test_zero_duration_zero_actual(self):
|
||
assert clip_adjusted_duration(0.0, 0.0, 1.0) == 0.0
|
||
|
||
|
||
@dataclass
|
||
class FakeClip:
|
||
duration: float = 0.0
|
||
actual_duration: float = 0.0
|
||
playback_speed: float = 1.0
|
||
|
||
|
||
@dataclass
|
||
class FakeLayer:
|
||
role: str = "main"
|
||
clips: list[FakeClip] = field(default_factory=list)
|
||
|
||
|
||
class TestEstimateTotalDuration:
|
||
def test_single_main_layer_single_clip(self):
|
||
layers = [
|
||
FakeLayer(role="main", clips=[FakeClip(duration=5.0, actual_duration=10.0)]),
|
||
]
|
||
assert estimate_total_duration(layers) == 5.0
|
||
|
||
def test_single_main_layer_multiple_clips(self):
|
||
layers = [
|
||
FakeLayer(
|
||
role="main",
|
||
clips=[
|
||
FakeClip(duration=3.0, actual_duration=5.0),
|
||
FakeClip(duration=2.0, actual_duration=4.0),
|
||
],
|
||
),
|
||
]
|
||
assert estimate_total_duration(layers) == 5.0
|
||
|
||
def test_with_transition_duration(self):
|
||
layers = [
|
||
FakeLayer(
|
||
role="main",
|
||
clips=[
|
||
FakeClip(duration=5.0),
|
||
FakeClip(duration=5.0),
|
||
],
|
||
),
|
||
]
|
||
# 总10s - 1个转场 * 0.5s = 9.5s
|
||
assert estimate_total_duration(layers, transition_duration=0.5) == 9.5
|
||
|
||
def test_transition_with_many_clips(self):
|
||
layers = [
|
||
FakeLayer(
|
||
role="main",
|
||
clips=[FakeClip(duration=3.0) for _ in range(5)],
|
||
),
|
||
]
|
||
# 5个3s = 15s,4个转场 * 0.5s = 2s,总13s
|
||
assert estimate_total_duration(layers, transition_duration=0.5) == 13.0
|
||
|
||
def test_prefers_main_over_broll(self):
|
||
"""main图层优先级高于broll."""
|
||
layers = [
|
||
FakeLayer(role="broll", clips=[FakeClip(duration=10.0)]),
|
||
FakeLayer(role="main", clips=[FakeClip(duration=5.0)]),
|
||
]
|
||
assert estimate_total_duration(layers) == 5.0
|
||
|
||
def test_prefers_broll_over_background(self):
|
||
layers = [
|
||
FakeLayer(role="background", clips=[FakeClip(duration=20.0)]),
|
||
FakeLayer(role="broll", clips=[FakeClip(duration=10.0)]),
|
||
]
|
||
assert estimate_total_duration(layers) == 10.0
|
||
|
||
def test_no_main_layer(self):
|
||
layers = [
|
||
FakeLayer(role="overlay", clips=[FakeClip(duration=5.0)]),
|
||
]
|
||
# 没有主图层,返回0
|
||
assert estimate_total_duration(layers) == 0.0
|
||
|
||
def test_empty_layers(self):
|
||
assert estimate_total_duration([]) == 0.0
|
||
|
||
def test_main_layer_no_clips(self):
|
||
layers = [FakeLayer(role="main", clips=[])]
|
||
assert estimate_total_duration(layers) == 0.0
|
||
|
||
def test_minimum_total_duration(self):
|
||
"""总时长最小为0.1s."""
|
||
layers = [
|
||
FakeLayer(role="main", clips=[FakeClip(duration=0.01, actual_duration=0.01)]),
|
||
]
|
||
# 转场把总时长减到接近0时,会被钳制到0.1
|
||
result = estimate_total_duration(layers, transition_duration=10.0)
|
||
assert result == 0.1
|
||
|
||
def test_with_playback_speed(self):
|
||
layers = [
|
||
FakeLayer(
|
||
role="main",
|
||
clips=[FakeClip(duration=4.0, playback_speed=2.0)],
|
||
),
|
||
]
|
||
# 4s / 2x = 2s
|
||
assert estimate_total_duration(layers) == 2.0
|
||
|
||
def test_multiple_layers_picks_first_main(self):
|
||
layers = [
|
||
FakeLayer(role="overlay", clips=[FakeClip(duration=1.0)]),
|
||
FakeLayer(role="main", clips=[FakeClip(duration=5.0)]),
|
||
FakeLayer(role="main", clips=[FakeClip(duration=10.0)]), # 不看这个
|
||
]
|
||
assert estimate_total_duration(layers) == 5.0
|
||
|
||
|
||
class TestCanPassThrough:
|
||
def test_single_main_layer_single_clip(self):
|
||
layers = [
|
||
FakeLayer(role="main", clips=[FakeClip(duration=5.0)]),
|
||
]
|
||
assert can_pass_through(layers) is True
|
||
|
||
def test_single_broll_layer_single_clip(self):
|
||
layers = [
|
||
FakeLayer(role="broll", clips=[FakeClip(duration=5.0)]),
|
||
]
|
||
assert can_pass_through(layers) is True
|
||
|
||
def test_single_background_layer_single_clip(self):
|
||
layers = [
|
||
FakeLayer(role="background", clips=[FakeClip(duration=5.0)]),
|
||
]
|
||
assert can_pass_through(layers) is True
|
||
|
||
def test_multiple_layers_false(self):
|
||
layers = [
|
||
FakeLayer(role="main", clips=[FakeClip(duration=5.0)]),
|
||
FakeLayer(role="overlay", clips=[FakeClip(duration=3.0)]),
|
||
]
|
||
assert can_pass_through(layers) is False
|
||
|
||
def test_single_layer_multiple_clips_false(self):
|
||
layers = [
|
||
FakeLayer(
|
||
role="main",
|
||
clips=[FakeClip(duration=3.0), FakeClip(duration=2.0)],
|
||
),
|
||
]
|
||
assert can_pass_through(layers) is False
|
||
|
||
def test_overlay_layer_false(self):
|
||
"""overlay不是主图层角色."""
|
||
layers = [
|
||
FakeLayer(role="overlay", clips=[FakeClip(duration=5.0)]),
|
||
]
|
||
assert can_pass_through(layers) is False
|
||
|
||
def test_has_stickers_false(self):
|
||
layers = [
|
||
FakeLayer(role="main", clips=[FakeClip(duration=5.0)]),
|
||
]
|
||
assert can_pass_through(layers, has_stickers=True) is False
|
||
|
||
def test_has_watermark_false(self):
|
||
layers = [
|
||
FakeLayer(role="main", clips=[FakeClip(duration=5.0)]),
|
||
]
|
||
assert can_pass_through(layers, has_watermark=True) is False
|
||
|
||
def test_both_stickers_and_watermark_false(self):
|
||
layers = [
|
||
FakeLayer(role="main", clips=[FakeClip(duration=5.0)]),
|
||
]
|
||
assert can_pass_through(layers, has_stickers=True, has_watermark=True) is False
|
||
|
||
def test_empty_layers_false(self):
|
||
assert can_pass_through([]) is False
|
||
|
||
def test_corner_voice_layer_false(self):
|
||
layers = [
|
||
FakeLayer(role="corner_voice", clips=[FakeClip(duration=5.0)]),
|
||
]
|
||
assert can_pass_through(layers) is False
|