"""AudioMerger 单元测试 — P1 裸subprocess下沉验证. 验证 AudioMerger 使用 shared.ffmpeg_utils.run_ffmpeg 统一入口, 不再直接调用 subprocess.run。 """ from __future__ import annotations import os import subprocess import tempfile from unittest.mock import MagicMock, patch import pytest from application.tts_job.audio_merger import AudioMergeError, AudioMerger class TestAudioMergerUsesRunFfmpeg: """验证 AudioMerger 使用 run_ffmpeg 统一入口,而非裸 subprocess.""" def test_single_file_does_not_call_ffmpeg(self): """单文件时直接读取,不调用 FFmpeg.""" merger = AudioMerger() with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as f: f.write(b"fake audio data") path = f.name try: with patch("application.tts_job.audio_merger.run_ffmpeg") as mock_run: result = merger.merge([path]) mock_run.assert_not_called() assert result == b"fake audio data" finally: os.unlink(path) def test_multiple_files_calls_run_ffmpeg(self): """多文件时调用 run_ffmpeg 合并。""" merger = AudioMerger() paths = [] for i in range(2): f = tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) f.write(f"audio{i}".encode()) f.close() paths.append(f.name) try: with patch("application.tts_job.audio_merger.run_ffmpeg") as mock_run: # run_ffmpeg 成功返回,模拟合并完成 # 需要让 output_path 文件存在,否则 read 会报错 def fake_run_ffmpeg(cmd, **kwargs): # 找到 output_path(命令最后一个参数) output_path = cmd[-1] with open(output_path, "wb") as out: out.write(b"merged audio") return ("", "") mock_run.side_effect = fake_run_ffmpeg result = merger.merge(paths) mock_run.assert_called_once() call_args = mock_run.call_args[0][0] # 验证使用了 FFMPEG_BIN 而非硬编码 "ffmpeg" from shared.ffmpeg_utils import FFMPEG_BIN assert call_args[0] == FFMPEG_BIN # 验证使用 concat demuxer assert "concat" in call_args assert result == b"merged audio" finally: for p in paths: os.unlink(p) def test_ffmpeg_failure_raises_audio_merge_error(self): """FFmpeg 失败时抛出 AudioMergeError.""" merger = AudioMerger() paths = [] for i in range(2): f = tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) f.write(f"audio{i}".encode()) f.close() paths.append(f.name) try: with patch("application.tts_job.audio_merger.run_ffmpeg") as mock_run: mock_run.side_effect = subprocess.CalledProcessError( returncode=1, cmd=["ffmpeg"], stderr="concat error" ) with pytest.raises(AudioMergeError, match="FFmpeg 合并失败"): merger.merge(paths) mock_run.assert_called_once() finally: for p in paths: os.unlink(p) def test_ffmpeg_timeout_raises_audio_merge_error(self): """FFmpeg 超时时抛出 AudioMergeError.""" merger = AudioMerger() paths = [] for i in range(2): f = tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) f.write(f"audio{i}".encode()) f.close() paths.append(f.name) try: with patch("application.tts_job.audio_merger.run_ffmpeg") as mock_run: mock_run.side_effect = subprocess.TimeoutExpired(cmd=["ffmpeg"], timeout=120) with pytest.raises(AudioMergeError, match="超时"): merger.merge(paths) mock_run.assert_called_once() finally: for p in paths: os.unlink(p) def test_empty_list_raises_error(self): """空列表时直接抛错,不调用 ffmpeg.""" merger = AudioMerger() with patch("application.tts_job.audio_merger.run_ffmpeg") as mock_run: with pytest.raises(AudioMergeError, match="没有可合并的音频文件"): merger.merge([]) mock_run.assert_not_called() def test_no_direct_subprocess_import(self): """验证 audio_merger 模块不直接 import subprocess(通过模块源码检查)。""" import inspect import application.tts_job.audio_merger as am_module source = inspect.getsource(am_module) # 不应该有 "import subprocess" 整行 src_lines = [line.strip() for line in source.split("\n") if line.strip()] # 允许 from subprocess import CalledProcessError, TimeoutExpired(只导入异常类) # 不允许直接 import subprocess assert not any( line == "import subprocess" for line in src_lines ), "audio_merger.py 不应直接 import subprocess,应通过 run_ffmpeg 统一入口"