e83a0b86f8
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 2s
CI/CD Pipeline / Check push changed paths (pull_request) 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 / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
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 3s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (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 / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 9s
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 / PR Build API Image (pull_request) Successful in 35s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 38s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
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 / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 58s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 55s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m5s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m18s
CI/CD Pipeline / Validate - Style (push) Successful in 2m31s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m30s
CI/CD Pipeline / Integration Tests (push) Successful in 2m35s
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m7s
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
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m15s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m30s
CI/CD Pipeline / Validate - Security (push) Successful in 4m34s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m37s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m54s
AI Code Review / AI Code Review (pull_request) Successful in 6m19s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m57s
CI/CD Pipeline / Unit Tests (push) Successful in 8m33s
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 / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Failing after 4m48s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
433 lines
14 KiB
Python
Executable File
433 lines
14 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):
|
||
"""#1749:duration > actual 仍取目标段长 duration(素材短走末帧冻结,不钳制)."""
|
||
assert clip_effective_duration(10.0, 5.0) == 10.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):
|
||
"""#1749:effective=目标段长 10.0(不再钳 actual),2 倍速 → 10/2 = 5.0."""
|
||
assert clip_adjusted_duration(10.0, 4.0, 2.0) == 5.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
|
||
# #1749:逐处转场口径——cut 零重叠,非 cut 才扣 transition_duration
|
||
transition_effect: str = "cut"
|
||
transition_duration: float = 0.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, transition_effect="fade", transition_duration=0.5),
|
||
],
|
||
),
|
||
]
|
||
# #1749:总10s - 1个非cut转场 * 0.5s = 9.5s(cut 零重叠)
|
||
assert estimate_total_duration(layers, transition_duration=0.5) == 9.5
|
||
|
||
def test_cut_clips_have_zero_overlap(self):
|
||
layers = [
|
||
FakeLayer(
|
||
role="main",
|
||
clips=[
|
||
FakeClip(duration=5.0),
|
||
FakeClip(duration=5.0),
|
||
],
|
||
),
|
||
]
|
||
# #1749:cut 转场零重叠 → 总时长 10s,即使传了全局 transition_duration
|
||
assert estimate_total_duration(layers, transition_duration=0.5) == 10.0
|
||
|
||
def test_transition_with_many_clips(self):
|
||
layers = [
|
||
FakeLayer(
|
||
role="main",
|
||
clips=[FakeClip(duration=3.0) for _ in range(5)],
|
||
),
|
||
]
|
||
# #1749:全 cut 零重叠 → 5个3s = 15s
|
||
assert estimate_total_duration(layers, transition_duration=0.5) == 15.0
|
||
|
||
def test_non_cut_transitions_with_many_clips(self):
|
||
layers = [
|
||
FakeLayer(
|
||
role="main",
|
||
clips=[FakeClip(duration=3.0, transition_effect="fade", transition_duration=0.5) for _ in range(5)],
|
||
),
|
||
]
|
||
# 5个3s = 15s,4个非cut转场 * 0.5s = 2s,总13s(#1749 逐处重叠)
|
||
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
|