Files
xiaoxia-saas/tests/unit/test_audio_merger.py
CI Bot 3921a657e8
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 52s
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 / Validate - Type Check (mypy) (push) Successful in 1m56s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m7s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m7s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 54s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 3m20s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 6m27s
CI/CD Pipeline / Unit Tests (push) Failing after 8m24s
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 16m1s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m14s
CI/CD Pipeline / Integration Tests (push) Successful in 3m28s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 21s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m26s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m34s
style: auto-format with black + isort + prettier
2026-07-24 08:47:07 +00:00

224 lines
8.3 KiB
Python
Executable File

"""音频合并器单元测试."""
from __future__ import annotations
import os
import tempfile
from unittest.mock import MagicMock, patch
import pytest
from packages.application.tts_job.audio_merger import AudioMergeError, AudioMerger
@pytest.fixture
def sample_audio_dir():
"""创建临时目录,放几个模拟音频文件"""
tmpdir = tempfile.mkdtemp()
files = []
for i in range(3):
fpath = os.path.join(tmpdir, f"part{i}.mp3")
with open(fpath, "wb") as f:
f.write(f"audio_data_{i}".encode() * 100)
files.append(fpath)
yield files
import shutil
shutil.rmtree(tmpdir, ignore_errors=True)
class TestAudioMerger:
"""AudioMerger 测试"""
def test_empty_list_raises_error(self):
"""空列表抛出 AudioMergeError"""
merger = AudioMerger()
with pytest.raises(AudioMergeError, match="没有可合并的音频文件"):
merger.merge([])
def test_single_file_returns_content(self, sample_audio_dir):
"""单文件直接返回文件内容"""
merger = AudioMerger()
result = merger.merge([sample_audio_dir[0]])
with open(sample_audio_dir[0], "rb") as f:
expected = f.read()
assert result == expected
def test_single_file_no_ffmpeg_needed(self, sample_audio_dir):
"""单文件不需要调用 FFmpeg"""
with patch("packages.application.tts_job.audio_merger.run_ffmpeg") as mock_ffmpeg:
merger = AudioMerger()
merger.merge([sample_audio_dir[0]])
mock_ffmpeg.assert_not_called()
def test_merge_multiple_files(self, sample_audio_dir):
"""多文件合并调用 FFmpeg"""
with (
patch("packages.application.tts_job.audio_merger.run_ffmpeg") as mock_ffmpeg,
patch("packages.application.tts_job.audio_merger.FFMPEG_BIN", "ffmpeg"),
):
# 模拟 FFmpeg 成功:在 output_path 写点数据
def fake_run_ffmpeg(cmd, timeout=120):
output_idx = cmd.index("-c") + 2 # -c copy 后面是 output_path
output_path = cmd[-1]
with open(output_path, "wb") as f:
f.write(b"merged_audio_data")
return MagicMock(stdout=b"", stderr=b"")
mock_ffmpeg.side_effect = fake_run_ffmpeg
merger = AudioMerger()
result = merger.merge(sample_audio_dir)
assert result == b"merged_audio_data"
mock_ffmpeg.assert_called_once()
def test_merge_concat_list_generated(self, sample_audio_dir):
"""生成正确的 concat demuxer 列表文件"""
import subprocess
with (
patch("packages.application.tts_job.audio_merger.run_ffmpeg") as mock_ffmpeg,
patch("packages.application.tts_job.audio_merger.FFMPEG_BIN", "ffmpeg"),
):
captured_list_content = []
def fake_run_ffmpeg(cmd, timeout=120):
# 找到 -i 参数后面的文件路径
# 命令结构: ffmpeg -y -f concat -safe 0 -i LIST_PATH -c copy OUTPUT
for i, arg in enumerate(cmd):
if arg == "-i" and i + 1 < len(cmd):
list_path = cmd[i + 1]
if list_path.endswith(".txt"):
with open(list_path, "r") as f:
captured_list_content.append(f.read())
break
# 写输出文件
output_path = cmd[-1]
with open(output_path, "wb") as f:
f.write(b"fake")
return MagicMock(stdout=b"", stderr=b"")
mock_ffmpeg.side_effect = fake_run_ffmpeg
merger = AudioMerger()
merger.merge(sample_audio_dir, output_format="mp3")
# 检查列表文件包含所有输入文件
assert len(captured_list_content) == 1
list_content = captured_list_content[0]
for fpath in sample_audio_dir:
assert fpath in list_content.replace("'\\''", "'")
def test_merge_ffmpeg_failure_raises(self, sample_audio_dir):
"""FFmpeg 失败抛出 AudioMergeError"""
from subprocess import CalledProcessError
with (
patch("packages.application.tts_job.audio_merger.run_ffmpeg") as mock_ffmpeg,
patch("packages.application.tts_job.audio_merger.FFMPEG_BIN", "ffmpeg"),
):
mock_ffmpeg.side_effect = CalledProcessError(returncode=1, cmd=["ffmpeg"], stderr=b"error message")
merger = AudioMerger()
with pytest.raises(AudioMergeError, match="FFmpeg 合并失败"):
merger.merge(sample_audio_dir)
def test_merge_timeout_raises(self, sample_audio_dir):
"""合并超时抛出 AudioMergeError"""
from subprocess import TimeoutExpired
with (
patch("packages.application.tts_job.audio_merger.run_ffmpeg") as mock_ffmpeg,
patch("packages.application.tts_job.audio_merger.FFMPEG_BIN", "ffmpeg"),
):
mock_ffmpeg.side_effect = TimeoutExpired(cmd=["ffmpeg"], timeout=120)
merger = AudioMerger()
with pytest.raises(AudioMergeError, match="超时"):
merger.merge(sample_audio_dir)
def test_merge_cleanup_temp_dir(self, sample_audio_dir):
"""合并完成后清理临时目录"""
with (
patch("packages.application.tts_job.audio_merger.run_ffmpeg") as mock_ffmpeg,
patch("packages.application.tts_job.audio_merger.FFMPEG_BIN", "ffmpeg"),
patch("packages.application.tts_job.audio_merger.shutil.rmtree") as mock_rmtree,
):
def fake_run_ffmpeg(cmd, timeout=120):
output_path = cmd[-1]
with open(output_path, "wb") as f:
f.write(b"data")
return MagicMock()
mock_ffmpeg.side_effect = fake_run_ffmpeg
merger = AudioMerger()
merger.merge(sample_audio_dir)
mock_rmtree.assert_called_once()
# 第一个参数是临时目录路径
temp_dir_path = mock_rmtree.call_args[0][0]
assert "tts_merge_" in temp_dir_path
def test_merge_cleanup_on_error(self, sample_audio_dir):
"""合并失败也清理临时目录"""
with (
patch("packages.application.tts_job.audio_merger.run_ffmpeg") as mock_ffmpeg,
patch("packages.application.tts_job.audio_merger.FFMPEG_BIN", "ffmpeg"),
patch("packages.application.tts_job.audio_merger.shutil.rmtree") as mock_rmtree,
):
from subprocess import CalledProcessError
mock_ffmpeg.side_effect = CalledProcessError(1, ["ffmpeg"])
merger = AudioMerger()
try:
merger.merge(sample_audio_dir)
except AudioMergeError:
pass
mock_rmtree.assert_called_once()
def test_merge_custom_output_format(self, sample_audio_dir):
"""自定义输出格式"""
with (
patch("packages.application.tts_job.audio_merger.run_ffmpeg") as mock_ffmpeg,
patch("packages.application.tts_job.audio_merger.FFMPEG_BIN", "ffmpeg"),
):
def fake_run_ffmpeg(cmd, timeout=120):
output_path = cmd[-1]
assert output_path.endswith(".wav")
with open(output_path, "wb") as f:
f.write(b"data")
return MagicMock()
mock_ffmpeg.side_effect = fake_run_ffmpeg
merger = AudioMerger()
merger.merge(sample_audio_dir, output_format="wav")
def test_merge_two_files(self, sample_audio_dir):
"""两个文件合并"""
with (
patch("packages.application.tts_job.audio_merger.run_ffmpeg") as mock_ffmpeg,
patch("packages.application.tts_job.audio_merger.FFMPEG_BIN", "ffmpeg"),
):
def fake_run_ffmpeg(cmd, timeout=120):
output_path = cmd[-1]
with open(output_path, "wb") as f:
f.write(b"two_files_merged")
return MagicMock()
mock_ffmpeg.side_effect = fake_run_ffmpeg
merger = AudioMerger()
result = merger.merge(sample_audio_dir[:2])
assert result == b"two_files_merged"