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>
380 lines
13 KiB
Python
Executable File
380 lines
13 KiB
Python
Executable File
"""render_layer_utils 模块单元测试."""
|
||
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass, field
|
||
from typing import Any
|
||
|
||
import pytest
|
||
|
||
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,
|
||
)
|
||
|
||
# ── 辅助数据类 ──────────────────────────────────────────────────────────────
|
||
|
||
|
||
@dataclass
|
||
class FakeClip:
|
||
duration: float = 0.0
|
||
actual_duration: float = 0.0
|
||
playback_speed: Any = 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 TestConstants:
|
||
def test_layer_z_index_has_expected_keys(self):
|
||
assert set(LAYER_Z_INDEX.keys()) == {
|
||
"background",
|
||
"broll",
|
||
"main",
|
||
"overlay",
|
||
"corner_voice",
|
||
"audio",
|
||
}
|
||
|
||
def test_layer_z_index_ordering(self):
|
||
assert LAYER_Z_INDEX["background"] < LAYER_Z_INDEX["main"]
|
||
assert LAYER_Z_INDEX["main"] == LAYER_Z_INDEX["broll"]
|
||
assert LAYER_Z_INDEX["overlay"] > LAYER_Z_INDEX["main"]
|
||
assert LAYER_Z_INDEX["corner_voice"] > LAYER_Z_INDEX["main"]
|
||
assert LAYER_Z_INDEX["audio"] > LAYER_Z_INDEX["overlay"]
|
||
|
||
def test_pip_default_scale_positive(self):
|
||
assert 0 < PIP_DEFAULT_SCALE < 1
|
||
|
||
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 "overlay" not in MAIN_LAYER_ROLES
|
||
|
||
|
||
# ── resolve_layer_role ──────────────────────────────────────────────────────
|
||
|
||
|
||
class TestResolveLayerRole:
|
||
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_maps_to_overlay(self):
|
||
assert resolve_layer_role("overlay") == "overlay"
|
||
|
||
def test_corner_voice_maps_to_corner_voice(self):
|
||
assert resolve_layer_role("corner_voice") == "corner_voice"
|
||
|
||
def test_background_maps_to_background(self):
|
||
assert resolve_layer_role("background") == "background"
|
||
|
||
def test_b_roll_maps_to_broll(self):
|
||
assert resolve_layer_role("b_roll") == "broll"
|
||
|
||
def test_main_defaults_to_main(self):
|
||
assert resolve_layer_role("main") == "main"
|
||
|
||
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_other_role_stays_main(self):
|
||
assert resolve_layer_role("main", {"role": "overlay"}) == "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_type_defaults_to_main(self):
|
||
assert resolve_layer_role("unknown_type") == "main"
|
||
|
||
|
||
# ── get_layer_z_index ──────────────────────────────────────────────────────
|
||
|
||
|
||
class TestGetLayerZIndex:
|
||
def test_known_roles(self):
|
||
for role, expected in LAYER_Z_INDEX.items():
|
||
assert get_layer_z_index(role) == expected
|
||
|
||
def test_unknown_role_returns_zero(self):
|
||
assert get_layer_z_index("nonexistent") == 0
|
||
|
||
def test_empty_string_returns_zero(self):
|
||
assert get_layer_z_index("") == 0
|
||
|
||
|
||
# ── clip_effective_duration ────────────────────────────────────────────────
|
||
|
||
|
||
class TestClipEffectiveDuration:
|
||
def test_explicit_duration_no_actual(self):
|
||
assert clip_effective_duration(5.0) == 5.0
|
||
|
||
def test_explicit_duration_with_shorter_actual(self):
|
||
"""#1749:duration>actual 仍返回目标段长(短素材末帧冻结铺满,不钳制)。"""
|
||
assert clip_effective_duration(5.0, 3.0) == 5.0
|
||
|
||
def test_explicit_duration_with_longer_actual(self):
|
||
assert clip_effective_duration(5.0, 10.0) == 5.0
|
||
|
||
def test_zero_duration_uses_actual(self):
|
||
assert clip_effective_duration(0, 8.0) == 8.0
|
||
|
||
def test_negative_duration_uses_actual(self):
|
||
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
|
||
|
||
def test_no_args_returns_zero(self):
|
||
assert clip_effective_duration(0) == 0.0
|
||
|
||
def test_equal_duration_and_actual(self):
|
||
assert clip_effective_duration(5.0, 5.0) == 5.0
|
||
|
||
|
||
# ── clip_playback_speed ────────────────────────────────────────────────────
|
||
|
||
|
||
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_defaults_to_one(self):
|
||
assert clip_playback_speed(0) == 1.0
|
||
|
||
def test_negative_speed_defaults_to_one(self):
|
||
assert clip_playback_speed(-1.0) == 1.0
|
||
|
||
def test_none_defaults_to_one(self):
|
||
assert clip_playback_speed(None) == 1.0
|
||
|
||
def test_string_defaults_to_one(self):
|
||
assert clip_playback_speed("fast") == 1.0
|
||
|
||
def test_int_speed(self):
|
||
assert clip_playback_speed(2) == 2.0
|
||
|
||
|
||
# ── clip_adjusted_duration ─────────────────────────────────────────────────
|
||
|
||
|
||
class TestClipAdjustedDuration:
|
||
def test_normal_speed_same_as_effective(self):
|
||
assert clip_adjusted_duration(5.0, 10.0, 1.0) == 5.0
|
||
|
||
def test_double_speed_half_duration(self):
|
||
assert clip_adjusted_duration(10.0, 10.0, 2.0) == pytest.approx(5.0)
|
||
|
||
def test_half_speed_double_duration(self):
|
||
assert clip_adjusted_duration(5.0, 10.0, 0.5) == pytest.approx(10.0)
|
||
|
||
def test_invalid_speed_uses_default(self):
|
||
assert clip_adjusted_duration(5.0, 10.0, 0) == 5.0
|
||
|
||
def test_zero_duration(self):
|
||
assert clip_adjusted_duration(0, 0, 1.0) == 0.0
|
||
|
||
def test_actual_duration_only(self):
|
||
assert clip_adjusted_duration(0, 8.0, 1.0) == 8.0
|
||
|
||
def test_actual_duration_only_with_speed(self):
|
||
assert clip_adjusted_duration(0, 8.0, 2.0) == pytest.approx(4.0)
|
||
|
||
def test_very_close_to_normal_speed(self):
|
||
# 1.0000001 应该被认为接近 1.0,不做除法
|
||
result = clip_adjusted_duration(5.0, 10.0, 1.0 + 1e-10)
|
||
assert result == 5.0
|
||
|
||
|
||
# ── estimate_total_duration ────────────────────────────────────────────────
|
||
|
||
|
||
class TestEstimateTotalDuration:
|
||
def test_empty_layers(self):
|
||
assert estimate_total_duration([]) == 0.0
|
||
|
||
def test_no_main_layer(self):
|
||
layers = [FakeLayer(role="overlay", clips=[FakeClip(duration=5.0)])]
|
||
assert estimate_total_duration(layers) == 0.0
|
||
|
||
def test_single_clip_main_layer(self):
|
||
layers = [FakeLayer(role="main", clips=[FakeClip(duration=5.0)])]
|
||
assert estimate_total_duration(layers) == pytest.approx(5.0)
|
||
|
||
def test_multiple_clips_no_transition(self):
|
||
layers = [
|
||
FakeLayer(
|
||
role="main",
|
||
clips=[
|
||
FakeClip(duration=3.0),
|
||
FakeClip(duration=2.0),
|
||
FakeClip(duration=5.0),
|
||
],
|
||
)
|
||
]
|
||
assert estimate_total_duration(layers) == pytest.approx(10.0)
|
||
|
||
def test_multiple_clips_with_transition(self):
|
||
layers = [
|
||
FakeLayer(
|
||
role="main",
|
||
clips=[
|
||
FakeClip(duration=3.0),
|
||
FakeClip(duration=2.0),
|
||
FakeClip(duration=5.0),
|
||
],
|
||
)
|
||
]
|
||
# #1749:全 cut 零重叠 → 3 + 2 + 5 = 10.0
|
||
assert estimate_total_duration(layers, transition_duration=0.5) == pytest.approx(10.0)
|
||
|
||
def test_non_cut_clips_deduct_transition_per_clip(self):
|
||
layers = [
|
||
FakeLayer(
|
||
role="main",
|
||
clips=[
|
||
FakeClip(duration=3.0, transition_effect="fade", transition_duration=0.5),
|
||
FakeClip(duration=2.0, transition_effect="fade", transition_duration=0.5),
|
||
FakeClip(duration=5.0, transition_effect="fade", transition_duration=0.5),
|
||
],
|
||
),
|
||
]
|
||
# #1749:逐处非cut转场重叠 → 3 + 2 + 5 - 2 * 0.5 = 9.0
|
||
assert estimate_total_duration(layers, transition_duration=0.5) == pytest.approx(9.0)
|
||
|
||
def test_prefers_main_over_broll(self):
|
||
layers = [
|
||
FakeLayer(role="broll", clips=[FakeClip(duration=10.0)]),
|
||
FakeLayer(role="main", clips=[FakeClip(duration=5.0)]),
|
||
]
|
||
assert estimate_total_duration(layers) == pytest.approx(5.0)
|
||
|
||
def test_prefers_broll_over_background(self):
|
||
layers = [
|
||
FakeLayer(role="background", clips=[FakeClip(duration=10.0)]),
|
||
FakeLayer(role="broll", clips=[FakeClip(duration=5.0)]),
|
||
]
|
||
assert estimate_total_duration(layers) == pytest.approx(5.0)
|
||
|
||
def test_main_layer_empty_clips(self):
|
||
layers = [FakeLayer(role="main", clips=[])]
|
||
assert estimate_total_duration(layers) == 0.0
|
||
|
||
def test_minimum_duration(self):
|
||
layers = [
|
||
FakeLayer(
|
||
role="main",
|
||
clips=[
|
||
FakeClip(duration=0.01),
|
||
FakeClip(duration=0.01),
|
||
],
|
||
)
|
||
]
|
||
result = estimate_total_duration(layers, transition_duration=0.5)
|
||
assert result >= 0.1
|
||
|
||
def test_with_playback_speed(self):
|
||
layers = [
|
||
FakeLayer(
|
||
role="main",
|
||
clips=[
|
||
FakeClip(duration=10.0, playback_speed=2.0),
|
||
FakeClip(duration=10.0, playback_speed=0.5),
|
||
],
|
||
)
|
||
]
|
||
# 5 + 20 = 25
|
||
assert estimate_total_duration(layers) == pytest.approx(25.0)
|
||
|
||
|
||
# ── can_pass_through ──────────────────────────────────────────────────────
|
||
|
||
|
||
class TestCanPassThrough:
|
||
def test_single_main_clip_no_effects(self):
|
||
layers = [FakeLayer(role="main", clips=[FakeClip(duration=5.0)])]
|
||
assert can_pass_through(layers) is True
|
||
|
||
def test_single_broll_clip(self):
|
||
layers = [FakeLayer(role="broll", clips=[FakeClip(duration=5.0)])]
|
||
assert can_pass_through(layers) is True
|
||
|
||
def test_single_background_clip(self):
|
||
layers = [FakeLayer(role="background", clips=[FakeClip(duration=5.0)])]
|
||
assert can_pass_through(layers) is True
|
||
|
||
def test_multiple_layers(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_overlay_layer(self):
|
||
layers = [FakeLayer(role="overlay", clips=[FakeClip(duration=5.0)])]
|
||
assert can_pass_through(layers) is False
|
||
|
||
def test_multiple_clips_in_layer(self):
|
||
layers = [
|
||
FakeLayer(
|
||
role="main",
|
||
clips=[
|
||
FakeClip(duration=3.0),
|
||
FakeClip(duration=2.0),
|
||
],
|
||
)
|
||
]
|
||
assert can_pass_through(layers) is False
|
||
|
||
def test_with_stickers(self):
|
||
layers = [FakeLayer(role="main", clips=[FakeClip(duration=5.0)])]
|
||
assert can_pass_through(layers, has_stickers=True) is False
|
||
|
||
def test_with_watermark(self):
|
||
layers = [FakeLayer(role="main", clips=[FakeClip(duration=5.0)])]
|
||
assert can_pass_through(layers, has_watermark=True) is False
|
||
|
||
def test_with_stickers_and_watermark(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_layer_list(self):
|
||
assert can_pass_through([]) is False
|
||
|
||
def test_audio_layer_only(self):
|
||
layers = [FakeLayer(role="audio", clips=[FakeClip(duration=5.0)])]
|
||
assert can_pass_through(layers) is False
|