test(unit): 第69波 - 统一渲染+音频+适配器纯逻辑测试 (+53) #865

Merged
xiaoxia merged 1 commits from test/wave69-unified-render-audio-adapter-pure-fns into develop 2026-07-25 08:13:19 +08:00
3 changed files with 486 additions and 0 deletions
+154
View File
@@ -0,0 +1,154 @@
"""渲染适配器纯逻辑测试 — _parse_resolution 等纯函数."""
from __future__ import annotations
from pathlib import Path
import pytest
from video_processing.render_adapter import (
DEFAULT_OUTPUT_HEIGHT,
DEFAULT_OUTPUT_WIDTH,
RenderAdapterResult,
_parse_resolution,
)
class TestParseResolution:
"""_parse_resolution 分辨率字符串解析测试."""
def test_standard_format(self):
"""标准 宽x高 格式."""
w, h = _parse_resolution("1920x1080")
assert w == 1920
assert h == 1080
def test_portrait_format(self):
"""竖屏格式."""
w, h = _parse_resolution("1080x1920")
assert w == 1080
assert h == 1920
def test_lowercase_x(self):
"""小写x."""
w, h = _parse_resolution("1280x720")
assert w == 1280
assert h == 720
def test_uppercase_x_returns_default(self):
"""大写X不匹配小写x → 返回默认值(只支持小写x)."""
w, h = _parse_resolution("1280X720")
assert w == DEFAULT_OUTPUT_WIDTH
assert h == DEFAULT_OUTPUT_HEIGHT
def test_none_returns_default(self):
"""None返回默认值."""
w, h = _parse_resolution(None)
assert w == DEFAULT_OUTPUT_WIDTH
assert h == DEFAULT_OUTPUT_HEIGHT
def test_empty_string_returns_default(self):
"""空字符串返回默认值."""
w, h = _parse_resolution("")
assert w == DEFAULT_OUTPUT_WIDTH
assert h == DEFAULT_OUTPUT_HEIGHT
def test_no_x_returns_default(self):
"""没有x的字符串返回默认值."""
w, h = _parse_resolution("1080p")
assert w == DEFAULT_OUTPUT_WIDTH
assert h == DEFAULT_OUTPUT_HEIGHT
def test_invalid_width_returns_default(self):
"""宽度无效返回默认值."""
w, h = _parse_resolution("abcx1080")
assert w == DEFAULT_OUTPUT_WIDTH
assert h == DEFAULT_OUTPUT_HEIGHT
def test_invalid_height_returns_default(self):
"""高度无效返回默认值."""
w, h = _parse_resolution("1920xabc")
assert w == DEFAULT_OUTPUT_WIDTH
assert h == DEFAULT_OUTPUT_HEIGHT
def test_zero_width_returns_default(self):
"""宽度为0返回默认值."""
w, h = _parse_resolution("0x1080")
assert w == DEFAULT_OUTPUT_WIDTH
assert h == DEFAULT_OUTPUT_HEIGHT
def test_zero_height_returns_default(self):
"""高度为0返回默认值."""
w, h = _parse_resolution("1920x0")
assert w == DEFAULT_OUTPUT_WIDTH
assert h == DEFAULT_OUTPUT_HEIGHT
def test_negative_width_returns_default(self):
"""负宽度返回默认值."""
w, h = _parse_resolution("-100x1080")
assert w == DEFAULT_OUTPUT_WIDTH
assert h == DEFAULT_OUTPUT_HEIGHT
def test_with_spaces(self):
"""带空格的能正确strip."""
w, h = _parse_resolution(" 1920 x 1080 ")
assert w == 1920
assert h == 1080
def test_multiple_x_returns_default(self):
"""多个x的字符串解析失败 → 返回默认值."""
w, h = _parse_resolution("100x200x300")
# split("x", 1)后h部分是"200x300"int失败返回默认
assert w == DEFAULT_OUTPUT_WIDTH
assert h == DEFAULT_OUTPUT_HEIGHT
def test_square_resolution(self):
"""正方形分辨率."""
w, h = _parse_resolution("512x512")
assert w == 512
assert h == 512
def test_default_values_are_reasonable(self):
"""默认值合理(竖屏短视频)."""
assert DEFAULT_OUTPUT_WIDTH > 0
assert DEFAULT_OUTPUT_HEIGHT > 0
# 默认是竖屏 1080x1920
assert DEFAULT_OUTPUT_WIDTH == 1080
assert DEFAULT_OUTPUT_HEIGHT == 1920
class TestRenderAdapterResult:
"""RenderAdapterResult 数据结构测试."""
def test_failure_defaults(self):
"""失败结果默认值."""
result = RenderAdapterResult(success=False)
assert result.success is False
assert result.output_url == ""
assert result.output_path is None
assert result.thumbnail_url == ""
assert result.duration == 0.0
assert result.file_size == 0
assert result.width == 0
assert result.height == 0
def test_success_with_values(self):
"""成功结果带完整值."""
result = RenderAdapterResult(
success=True,
output_url="https://example.com/output.mp4",
output_path=Path("/tmp/output.mp4"),
thumbnail_url="https://example.com/thumb.jpg",
duration=30.5,
file_size=1024000,
width=1080,
height=1920,
)
assert result.success is True
assert result.output_url == "https://example.com/output.mp4"
assert result.output_path == Path("/tmp/output.mp4")
assert result.thumbnail_url == "https://example.com/thumb.jpg"
assert result.duration == pytest.approx(30.5)
assert result.file_size == 1024000
assert result.width == 1080
assert result.height == 1920
+156
View File
@@ -0,0 +1,156 @@
"""渲染音频纯逻辑测试 — clip_effective_duration + clip_has_audio缓存."""
from __future__ import annotations
from dataclasses import field
from pathlib import Path
from unittest.mock import patch
import pytest
from video_processing.render_audio import (
RenderContext,
clip_effective_duration,
clip_has_audio,
)
from video_processing.unified_render_service import ResolvedClip
def _make_ctx(work_dir: str = "/tmp") -> RenderContext:
"""创建测试用RenderContext."""
return RenderContext(work_dir=Path(work_dir), plan_id="test_plan")
def _make_clip(
*,
duration: float = 0.0,
actual_duration: float = 0.0,
local_path: str = "/tmp/test.mp4",
clip_type: str = "main",
clip_id: str = "c1",
asset_id: str = "a1",
order: int = 0,
) -> ResolvedClip:
"""快速创建测试用ResolvedClip."""
return ResolvedClip(
clip_id=clip_id,
asset_id=asset_id,
local_path=Path(local_path),
clip_type=clip_type,
order=order,
duration=duration,
actual_duration=actual_duration,
)
class TestClipEffectiveDuration:
"""clip_effective_duration 有效时长计算测试."""
def test_both_zero_returns_zero(self):
"""duration和actual_duration都是0 → 0."""
clip = _make_clip(duration=0, actual_duration=0)
assert clip_effective_duration(clip) == 0.0
def test_only_actual_duration(self):
"""只有actual_duration → 返回actual_duration."""
clip = _make_clip(duration=0, actual_duration=30.0)
assert clip_effective_duration(clip) == pytest.approx(30.0)
def test_duration_less_than_actual(self):
"""duration < actual → 返回duration(剪辑后的时长)."""
clip = _make_clip(duration=10.0, actual_duration=30.0)
assert clip_effective_duration(clip) == pytest.approx(10.0)
def test_duration_greater_than_actual(self):
"""duration > actual → 返回actual(不能超过素材时长)."""
clip = _make_clip(duration=50.0, actual_duration=30.0)
assert clip_effective_duration(clip) == pytest.approx(30.0)
def test_duration_equals_actual(self):
"""duration == actual → 返回该值."""
clip = _make_clip(duration=20.0, actual_duration=20.0)
assert clip_effective_duration(clip) == pytest.approx(20.0)
def test_no_actual_with_positive_duration(self):
"""actual_duration=0但duration>0 → 返回duration(还没probe时)."""
clip = _make_clip(duration=15.0, actual_duration=0.0)
assert clip_effective_duration(clip) == pytest.approx(15.0)
class TestClipHasAudio:
"""clip_has_audio 音频探测+缓存测试."""
def test_has_audio_true(self):
"""有音频时返回True."""
ctx = _make_ctx()
clip = _make_clip(local_path="/tmp/video1.mp4")
with patch("video_processing.render_audio.probe_has_audio", return_value=True):
result = clip_has_audio(ctx, clip)
assert result is True
def test_has_audio_false(self):
"""无音频时返回False."""
ctx = _make_ctx()
clip = _make_clip(local_path="/tmp/video2.mp4")
with patch("video_processing.render_audio.probe_has_audio", return_value=False):
result = clip_has_audio(ctx, clip)
assert result is False
def test_cache_avoids_reprobe(self):
"""同一个clip多次调用只probe一次(缓存生效)."""
ctx = _make_ctx()
clip = _make_clip(local_path="/tmp/cached.mp4")
call_count = 0
def fake_probe(path):
nonlocal call_count
call_count += 1
return True
with patch("video_processing.render_audio.probe_has_audio", side_effect=fake_probe):
result1 = clip_has_audio(ctx, clip)
result2 = clip_has_audio(ctx, clip)
result3 = clip_has_audio(ctx, clip)
assert result1 is True
assert result2 is True
assert result3 is True
assert call_count == 1 # 只调用了一次
def test_different_clips_both_probed(self):
"""不同clip各自probe一次."""
ctx = _make_ctx()
clip1 = _make_clip(clip_id="c1", local_path="/tmp/v1.mp4")
clip2 = _make_clip(clip_id="c2", local_path="/tmp/v2.mp4")
probe_call_count = 0
def fake_probe(path):
nonlocal probe_call_count
probe_call_count += 1
return "v1" in str(path)
with patch("video_processing.render_audio.probe_has_audio", side_effect=fake_probe):
r1 = clip_has_audio(ctx, clip1)
r2 = clip_has_audio(ctx, clip2)
assert r1 is True
assert r2 is False
assert probe_call_count == 2
class TestRenderContext:
"""RenderContext 渲染上下文测试."""
def test_default_noise_reduction_none(self):
"""默认无降噪配置."""
ctx = _make_ctx()
assert ctx.noise_reduction_config is None
def test_cache_starts_empty(self):
"""音频缓存初始为空."""
ctx = _make_ctx()
assert ctx._audio_cache == {}
+176
View File
@@ -0,0 +1,176 @@
"""统一渲染服务纯逻辑测试 — _resolve_layer_role + 图层配置 + 数据结构."""
from __future__ import annotations
from pathlib import Path
import pytest
from video_processing.unified_render_service import (
RenderLayer,
ResolvedClip,
_LAYER_Z_INDEX,
_PIP_SCALE,
_resolve_layer_role,
)
class TestResolveLayerRole:
"""_resolve_layer_role 图层角色映射测试."""
def test_intro_is_main(self):
"""intro片段 → main层."""
assert _resolve_layer_role("intro", {}) == "main"
def test_outro_is_main(self):
"""outro片段 → main层."""
assert _resolve_layer_role("outro", {}) == "main"
def test_overlay_is_overlay(self):
"""overlay片段 → overlay层."""
assert _resolve_layer_role("overlay", {}) == "overlay"
def test_corner_voice(self):
"""corner_voice片段 → corner_voice层."""
assert _resolve_layer_role("corner_voice", {}) == "corner_voice"
def test_background(self):
"""background片段 → background层."""
assert _resolve_layer_role("background", {}) == "background"
def test_b_roll(self):
"""b_roll片段 → broll层."""
assert _resolve_layer_role("b_roll", {}) == "broll"
def test_main_default(self):
"""main type默认 → main层."""
assert _resolve_layer_role("main", {}) == "main"
def test_main_with_broll_role(self):
"""main type + role=b_roll → broll层."""
assert _resolve_layer_role("main", {"role": "b_roll"}) == "broll"
def test_main_with_audio_role(self):
"""main type + role=audio → audio层."""
assert _resolve_layer_role("main", {"role": "audio"}) == "audio"
def test_unknown_type_falls_back_to_main(self):
"""未知类型 → main层."""
assert _resolve_layer_role("random_type", {}) == "main"
def test_intro_ignores_role(self):
"""intro/outro忽略role配置."""
assert _resolve_layer_role("intro", {"role": "b_roll"}) == "main"
assert _resolve_layer_role("outro", {"role": "overlay"}) == "main"
def test_empty_config(self):
"""空config不影响结果."""
assert _resolve_layer_role("main", {}) == "main"
def test_none_role(self):
"""role=None时走默认."""
assert _resolve_layer_role("main", {"role": None}) == "main"
class TestLayerZIndex:
"""_LAYER_Z_INDEX 图层层级配置测试."""
def test_background_lowest(self):
"""background在最底层."""
assert _LAYER_Z_INDEX["background"] == -1
def test_main_and_broll_same_level(self):
"""main和broll在同一层(z=0)."""
assert _LAYER_Z_INDEX["main"] == 0
assert _LAYER_Z_INDEX["broll"] == 0
def test_overlay_and_corner_voice_above(self):
"""overlay和corner_voice在z=1."""
assert _LAYER_Z_INDEX["overlay"] == 1
assert _LAYER_Z_INDEX["corner_voice"] == 1
def test_audio_highest(self):
"""audio在z=2(最高,因为音频不涉及z顺序但参与混音)."""
assert _LAYER_Z_INDEX["audio"] == 2
def test_pip_scale_is_positive(self):
"""PiP缩放比例为正数."""
assert _PIP_SCALE > 0
assert _PIP_SCALE < 1.0
class TestResolvedClip:
"""ResolvedClip 数据结构测试."""
def test_default_values(self):
"""默认值正确."""
clip = ResolvedClip(
clip_id="c1",
asset_id="a1",
local_path=Path("/tmp/test.mp4"),
clip_type="main",
order=0,
)
assert clip.start_time == 0.0
assert clip.duration == 0.0
assert clip.transition_effect == "cut"
assert clip.transition_duration == 0.0
assert clip.playback_speed == 1.0
assert clip.config == {}
assert clip.actual_duration == 0.0
assert clip.trim_config is None
def test_custom_values(self):
"""自定义值正确."""
clip = ResolvedClip(
clip_id="c2",
asset_id="a2",
local_path=Path("/tmp/video.mp4"),
clip_type="overlay",
order=1,
start_time=5.0,
duration=10.0,
transition_effect="fade",
transition_duration=0.5,
playback_speed=1.5,
)
assert clip.clip_id == "c2"
assert clip.asset_id == "a2"
assert clip.clip_type == "overlay"
assert clip.order == 1
assert clip.start_time == 5.0
assert clip.duration == 10.0
assert clip.transition_effect == "fade"
assert clip.transition_duration == 0.5
assert clip.playback_speed == 1.5
class TestRenderLayer:
"""RenderLayer 数据结构测试."""
def test_default_values(self):
"""默认值正确."""
layer = RenderLayer(role="main")
assert layer.clips == []
assert layer.z_index == 0
assert layer.opacity == 1.0
assert layer.position is None
def test_with_clips(self):
"""带片段的图层."""
clip = ResolvedClip(
clip_id="c1", asset_id="a1",
local_path=Path("/tmp/t.mp4"),
clip_type="main", order=0,
)
layer = RenderLayer(role="overlay", clips=[clip], z_index=1)
assert len(layer.clips) == 1
assert layer.z_index == 1
assert layer.role == "overlay"
def test_background_layer(self):
"""background图层配置."""
layer = RenderLayer(role="background", z_index=-1, opacity=1.0)
assert layer.role == "background"
assert layer.z_index == -1
assert layer.opacity == 1.0