4251970b49
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
260 lines
9.4 KiB
Python
Executable File
260 lines
9.4 KiB
Python
Executable File
"""VoiceExtractor 纯逻辑单测 — 命令构建 + 边界用例.
|
|
|
|
通过 mock run_ffmpeg 验证 FFmpeg 命令参数是否正确,
|
|
不实际执行 FFmpeg,确保测试轻量快速。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
from worker_app.tasks.voice_extraction import VoiceExtractor
|
|
|
|
|
|
class TestVoiceExtractorExtractVoiceCommand:
|
|
"""extract_voice 命令构建测试."""
|
|
|
|
def test_default_params_correct_command(self):
|
|
"""默认参数下 FFmpeg 命令正确."""
|
|
extractor = VoiceExtractor()
|
|
with patch.object(VoiceExtractor, "_run_ffmpeg") as mock_run:
|
|
result = extractor.extract_voice("/tmp/input.mp4", "/tmp/output.mp3")
|
|
|
|
assert result == "/tmp/output.mp3"
|
|
mock_run.assert_called_once()
|
|
cmd = mock_run.call_args[0][0]
|
|
|
|
# 基本结构验证
|
|
assert cmd[0] == "ffmpeg"
|
|
assert "-y" in cmd
|
|
assert cmd[cmd.index("-i") + 1] == "/tmp/input.mp4"
|
|
assert "-vn" in cmd # 无视频流
|
|
assert cmd[-1] == "/tmp/output.mp3"
|
|
|
|
# 音频滤镜验证
|
|
af_idx = cmd.index("-af")
|
|
af_value = cmd[af_idx + 1]
|
|
assert "highpass=f=200" in af_value
|
|
assert "afftdn=bn=20" in af_value
|
|
assert "bandpass=f=300:width_type=h:width=3000" in af_value
|
|
assert "loudnorm" in af_value
|
|
|
|
# 编码验证
|
|
assert "libmp3lame" in cmd
|
|
assert "-q:a" in cmd
|
|
assert cmd[cmd.index("-q:a") + 1] == "2"
|
|
|
|
def test_custom_highpass(self):
|
|
"""自定义 highpass 频率."""
|
|
extractor = VoiceExtractor()
|
|
with patch.object(VoiceExtractor, "_run_ffmpeg") as mock_run:
|
|
extractor.extract_voice("/tmp/in.mp4", "/tmp/out.mp3", highpass=500)
|
|
|
|
cmd = mock_run.call_args[0][0]
|
|
af_value = cmd[cmd.index("-af") + 1]
|
|
assert "highpass=f=500" in af_value
|
|
|
|
def test_custom_bandpass_freq(self):
|
|
"""自定义 bandpass 中心频率."""
|
|
extractor = VoiceExtractor()
|
|
with patch.object(VoiceExtractor, "_run_ffmpeg") as mock_run:
|
|
extractor.extract_voice("/tmp/in.mp4", "/tmp/out.mp3", bandpass_freq=500)
|
|
|
|
cmd = mock_run.call_args[0][0]
|
|
af_value = cmd[cmd.index("-af") + 1]
|
|
assert "bandpass=f=500:" in af_value
|
|
|
|
def test_custom_bandpass_width(self):
|
|
"""自定义 bandpass 宽度."""
|
|
extractor = VoiceExtractor()
|
|
with patch.object(VoiceExtractor, "_run_ffmpeg") as mock_run:
|
|
extractor.extract_voice("/tmp/in.mp4", "/tmp/out.mp3", bandpass_width=5000)
|
|
|
|
cmd = mock_run.call_args[0][0]
|
|
af_value = cmd[cmd.index("-af") + 1]
|
|
assert "width=5000" in af_value
|
|
|
|
def test_custom_noise_reduction(self):
|
|
"""自定义降噪强度."""
|
|
extractor = VoiceExtractor()
|
|
with patch.object(VoiceExtractor, "_run_ffmpeg") as mock_run:
|
|
extractor.extract_voice("/tmp/in.mp4", "/tmp/out.mp3", noise_reduction=30)
|
|
|
|
cmd = mock_run.call_args[0][0]
|
|
af_value = cmd[cmd.index("-af") + 1]
|
|
assert "afftdn=bn=30" in af_value
|
|
|
|
def test_filter_order_is_correct(self):
|
|
"""滤镜顺序:highpass → 降噪 → bandpass → loudnorm."""
|
|
extractor = VoiceExtractor()
|
|
with patch.object(VoiceExtractor, "_run_ffmpeg") as mock_run:
|
|
extractor.extract_voice("/tmp/in.mp4", "/tmp/out.mp3")
|
|
|
|
cmd = mock_run.call_args[0][0]
|
|
af_value = cmd[cmd.index("-af") + 1]
|
|
|
|
hp_pos = af_value.index("highpass")
|
|
dn_pos = af_value.index("afftdn")
|
|
bp_pos = af_value.index("bandpass")
|
|
ln_pos = af_value.index("loudnorm")
|
|
|
|
assert hp_pos < dn_pos < bp_pos < ln_pos
|
|
|
|
def test_creates_output_directory(self, tmp_path):
|
|
"""输出目录不存在时自动创建."""
|
|
out_dir = tmp_path / "nested" / "deep"
|
|
out_file = out_dir / "voice.mp3"
|
|
|
|
extractor = VoiceExtractor()
|
|
with patch.object(VoiceExtractor, "_run_ffmpeg"):
|
|
extractor.extract_voice("/tmp/in.mp4", str(out_file))
|
|
|
|
assert out_dir.exists()
|
|
assert out_dir.is_dir()
|
|
|
|
def test_returns_output_path(self):
|
|
"""返回值为输出路径."""
|
|
extractor = VoiceExtractor()
|
|
with patch.object(VoiceExtractor, "_run_ffmpeg"):
|
|
result = extractor.extract_voice("/tmp/in.mp4", "/tmp/voice.mp3")
|
|
|
|
assert result == "/tmp/voice.mp3"
|
|
|
|
|
|
class TestVoiceExtractorExtractBackgroundCommand:
|
|
"""extract_background 命令构建测试."""
|
|
|
|
def test_default_params_correct_command(self):
|
|
"""默认参数下 FFmpeg 命令正确."""
|
|
extractor = VoiceExtractor()
|
|
with patch.object(VoiceExtractor, "_run_ffmpeg") as mock_run:
|
|
result = extractor.extract_background("/tmp/input.mp4", "/tmp/output.mp3")
|
|
|
|
assert result == "/tmp/output.mp3"
|
|
mock_run.assert_called_once()
|
|
cmd = mock_run.call_args[0][0]
|
|
|
|
# 基本结构
|
|
assert cmd[0] == "ffmpeg"
|
|
assert "-y" in cmd
|
|
assert cmd[cmd.index("-i") + 1] == "/tmp/input.mp4"
|
|
assert "-vn" in cmd
|
|
assert cmd[-1] == "/tmp/output.mp3"
|
|
|
|
# 音频滤镜
|
|
af_idx = cmd.index("-af")
|
|
af_value = cmd[af_idx + 1]
|
|
assert "lowpass=f=200" in af_value
|
|
assert "loudnorm" in af_value
|
|
|
|
# 编码
|
|
assert "libmp3lame" in cmd
|
|
|
|
def test_custom_lowpass_freq(self):
|
|
"""自定义 lowpass 频率."""
|
|
extractor = VoiceExtractor()
|
|
with patch.object(VoiceExtractor, "_run_ffmpeg") as mock_run:
|
|
extractor.extract_background("/tmp/in.mp4", "/tmp/out.mp3", lowpass=500)
|
|
|
|
cmd = mock_run.call_args[0][0]
|
|
af_value = cmd[cmd.index("-af") + 1]
|
|
assert "lowpass=f=500" in af_value
|
|
|
|
def test_filter_order_background(self):
|
|
"""背景音滤镜顺序:lowpass → loudnorm."""
|
|
extractor = VoiceExtractor()
|
|
with patch.object(VoiceExtractor, "_run_ffmpeg") as mock_run:
|
|
extractor.extract_background("/tmp/in.mp4", "/tmp/out.mp3")
|
|
|
|
cmd = mock_run.call_args[0][0]
|
|
af_value = cmd[cmd.index("-af") + 1]
|
|
|
|
lp_pos = af_value.index("lowpass")
|
|
ln_pos = af_value.index("loudnorm")
|
|
assert lp_pos < ln_pos
|
|
|
|
def test_background_creates_output_directory(self, tmp_path):
|
|
"""背景音输出目录不存在时自动创建."""
|
|
out_dir = tmp_path / "bgm" / "tracks"
|
|
out_file = out_dir / "bg.mp3"
|
|
|
|
extractor = VoiceExtractor()
|
|
with patch.object(VoiceExtractor, "_run_ffmpeg"):
|
|
extractor.extract_background("/tmp/in.mp4", str(out_file))
|
|
|
|
assert out_dir.exists()
|
|
|
|
|
|
class TestVoiceExtractorEdgeCases:
|
|
"""边界情况测试."""
|
|
|
|
def test_zero_highpass(self):
|
|
"""highpass=0 时的行为(极端低值)."""
|
|
extractor = VoiceExtractor()
|
|
with patch.object(VoiceExtractor, "_run_ffmpeg") as mock_run:
|
|
extractor.extract_voice("/tmp/in.mp4", "/tmp/out.mp3", highpass=0)
|
|
|
|
cmd = mock_run.call_args[0][0]
|
|
af_value = cmd[cmd.index("-af") + 1]
|
|
assert "highpass=f=0" in af_value
|
|
|
|
def test_zero_bandpass_freq(self):
|
|
"""bandpass_freq=0 时的极端情况."""
|
|
extractor = VoiceExtractor()
|
|
with patch.object(VoiceExtractor, "_run_ffmpeg") as mock_run:
|
|
extractor.extract_voice("/tmp/in.mp4", "/tmp/out.mp3", bandpass_freq=0)
|
|
|
|
cmd = mock_run.call_args[0][0]
|
|
af_value = cmd[cmd.index("-af") + 1]
|
|
assert "bandpass=f=0:" in af_value
|
|
|
|
def test_very_high_noise_reduction(self):
|
|
"""极高降噪强度."""
|
|
extractor = VoiceExtractor()
|
|
with patch.object(VoiceExtractor, "_run_ffmpeg") as mock_run:
|
|
extractor.extract_voice("/tmp/in.mp4", "/tmp/out.mp3", noise_reduction=100)
|
|
|
|
cmd = mock_run.call_args[0][0]
|
|
af_value = cmd[cmd.index("-af") + 1]
|
|
assert "afftdn=bn=100" in af_value
|
|
|
|
def test_negative_lowpass_allowed(self):
|
|
"""lowpass 负值(由调用方保证合法性,函数不做校验)."""
|
|
extractor = VoiceExtractor()
|
|
with patch.object(VoiceExtractor, "_run_ffmpeg") as mock_run:
|
|
extractor.extract_background("/tmp/in.mp4", "/tmp/out.mp3", lowpass=-10)
|
|
|
|
cmd = mock_run.call_args[0][0]
|
|
af_value = cmd[cmd.index("-af") + 1]
|
|
assert "lowpass=f=-10" in af_value
|
|
|
|
def test_run_ffmpeg_propagates_error(self):
|
|
"""_run_ffmpeg 抛出异常时向上传递."""
|
|
extractor = VoiceExtractor()
|
|
with patch.object(VoiceExtractor, "_run_ffmpeg", side_effect=RuntimeError("FFmpeg failed")):
|
|
with pytest.raises(RuntimeError, match="FFmpeg failed"):
|
|
extractor.extract_voice("/tmp/in.mp4", "/tmp/out.mp3")
|
|
|
|
def test_voice_extractor_is_static_method(self):
|
|
"""_run_ffmpeg 是静态方法,可在类上直接调用."""
|
|
# 验证 VoiceExtractor 可以直接实例化(无需参数)
|
|
extractor = VoiceExtractor()
|
|
assert extractor is not None
|
|
|
|
def test_multiple_extractions_same_instance(self):
|
|
"""同一个实例可多次执行提取."""
|
|
extractor = VoiceExtractor()
|
|
call_count = 0
|
|
|
|
def fake_run(cmd):
|
|
nonlocal call_count
|
|
call_count += 1
|
|
|
|
with patch.object(VoiceExtractor, "_run_ffmpeg", side_effect=fake_run):
|
|
extractor.extract_voice("/tmp/a.mp4", "/tmp/a_voice.mp3")
|
|
extractor.extract_background("/tmp/a.mp4", "/tmp/a_bg.mp3")
|
|
|
|
assert call_count == 2
|