Files
xiaoxia-saas/tests/unit/domain/test_render_layer_utils.py
xiaoxia 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
fix(#1749): 批量三连修:配音严格守卫/配音时长分配+末帧冻结/素材级去重 + variant-plans 接口 (#1752)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-07 09:22:34 +08:00

433 lines
14 KiB
Python
Executable File
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.
"""渲染图层工具函数单测.
纯函数模块,覆盖:图层角色映射、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):
"""#1749duration > 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):
"""#1749effective=目标段长 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.5scut 零重叠)
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),
],
),
]
# #1749cut 转场零重叠 → 总时长 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 = 15s4个非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