From fb02d60054684bf96ffbb3c4662c9dd67f471e08 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Sat, 25 Jul 2026 01:02:35 +0800 Subject: [PATCH] =?UTF-8?q?test(unit):=20=E7=AC=AC69=E6=B3=A2=20-=20?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E6=B8=B2=E6=9F=93+=E9=9F=B3=E9=A2=91+?= =?UTF-8?q?=E9=80=82=E9=85=8D=E5=99=A8=E7=BA=AF=E9=80=BB=E8=BE=91=E6=B5=8B?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - unified_render_service: _resolve_layer_role图层映射 + _LAYER_Z_INDEX层级 + ResolvedClip/RenderLayer数据结构 - render_audio: clip_effective_duration时长计算 + clip_has_audio缓存机制 + RenderContext - render_adapter: _parse_resolution解析(16种边界) + RenderAdapterResult数据结构 +53 tests 全绿 --- tests/unit/test_render_adapter_pure.py | 154 ++++++++++++++++++++++ tests/unit/test_render_audio_pure.py | 156 ++++++++++++++++++++++ tests/unit/test_unified_render_pure.py | 176 +++++++++++++++++++++++++ 3 files changed, 486 insertions(+) create mode 100755 tests/unit/test_render_adapter_pure.py create mode 100755 tests/unit/test_render_audio_pure.py create mode 100755 tests/unit/test_unified_render_pure.py diff --git a/tests/unit/test_render_adapter_pure.py b/tests/unit/test_render_adapter_pure.py new file mode 100755 index 000000000..921f71d65 --- /dev/null +++ b/tests/unit/test_render_adapter_pure.py @@ -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 diff --git a/tests/unit/test_render_audio_pure.py b/tests/unit/test_render_audio_pure.py new file mode 100755 index 000000000..02f39142d --- /dev/null +++ b/tests/unit/test_render_audio_pure.py @@ -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 == {} diff --git a/tests/unit/test_unified_render_pure.py b/tests/unit/test_unified_render_pure.py new file mode 100755 index 000000000..d5615da7b --- /dev/null +++ b/tests/unit/test_unified_render_pure.py @@ -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 -- 2.54.0