test: P3-1 第二十二波 TTS音频合并+流式服务单元测试 14个
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 18s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 49s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 32s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 36s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 42s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m14s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 32s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 1m20s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Successful in 4m29s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 3m0s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 7m31s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 13m42s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 40s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m45s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 18s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 49s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 32s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 36s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 42s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m14s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 32s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 1m20s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Successful in 4m29s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 3m0s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 7m31s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 13m42s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Failing after 40s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m45s
- AudioMerger 7个(空列表/单文件/多文件/wav格式/异常/临时目录清理) - TTSStreamingService 7个(空文本/过长文本/短文本成功/合成失败/无audio_url/分块推送/send_json容错) - 合计 14 个测试全部通过
This commit is contained in:
Executable
+296
@@ -0,0 +1,296 @@
|
||||
"""
|
||||
TTS 相关单元测试(第二十二波)
|
||||
|
||||
覆盖:
|
||||
- AudioMerger (空列表/单文件/多文件合并/格式/异常)
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.application.tts_job.audio_merger import (
|
||||
AudioMergeError,
|
||||
AudioMerger,
|
||||
)
|
||||
|
||||
|
||||
def _make_silence(duration: float = 0.5, sample_rate: int = 22050, fmt: str = "mp3") -> str:
|
||||
"""生成一段静音音频文件,返回路径。"""
|
||||
tmp = tempfile.NamedTemporaryFile(suffix=f".{fmt}", delete=False)
|
||||
tmp.close()
|
||||
cmd = [
|
||||
"ffmpeg", "-y", "-f", "lavfi",
|
||||
"-i", f"anullsrc=r={sample_rate}:cl=mono",
|
||||
"-t", str(duration),
|
||||
"-q:a", "9",
|
||||
tmp.name,
|
||||
]
|
||||
subprocess.run(cmd, capture_output=True, check=True)
|
||||
return tmp.name
|
||||
|
||||
|
||||
class TestAudioMerger:
|
||||
"""AudioMerger 音频合并器"""
|
||||
|
||||
def test_empty_list_raises(self):
|
||||
"""空列表抛错"""
|
||||
merger = AudioMerger()
|
||||
with pytest.raises(AudioMergeError, match="没有可合并"):
|
||||
merger.merge([])
|
||||
|
||||
def test_single_file_returns_content(self):
|
||||
"""单个文件直接返回内容"""
|
||||
path = _make_silence(duration=0.3)
|
||||
try:
|
||||
merger = AudioMerger()
|
||||
result = merger.merge([path])
|
||||
assert isinstance(result, bytes)
|
||||
assert len(result) > 100 # 应该有有效数据
|
||||
# 应该和文件本身一致
|
||||
with open(path, "rb") as f:
|
||||
original = f.read()
|
||||
assert result == original
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_two_files_merged(self):
|
||||
"""两个文件合并"""
|
||||
p1 = _make_silence(duration=0.3)
|
||||
p2 = _make_silence(duration=0.4)
|
||||
try:
|
||||
merger = AudioMerger()
|
||||
result = merger.merge([p1, p2])
|
||||
assert isinstance(result, bytes)
|
||||
assert len(result) > 200 # 合并后应该有数据
|
||||
# 写出来用 ffprobe 验证时长
|
||||
tmp = tempfile.NamedTemporaryFile(suffix=".mp3", delete=False)
|
||||
tmp.write(result)
|
||||
tmp.close()
|
||||
try:
|
||||
probe = subprocess.run(
|
||||
["ffprobe", "-v", "error", "-show_entries", "format=duration",
|
||||
"-of", "default=noprint_wrappers=1:nokey=1", tmp.name],
|
||||
capture_output=True, text=True, check=True,
|
||||
)
|
||||
duration = float(probe.stdout.strip())
|
||||
# 0.3 + 0.4 = 0.7 秒左右,允许一定误差
|
||||
assert 0.5 < duration < 1.0
|
||||
finally:
|
||||
os.unlink(tmp.name)
|
||||
finally:
|
||||
os.unlink(p1)
|
||||
os.unlink(p2)
|
||||
|
||||
def test_three_files_merged(self):
|
||||
"""三个文件合并"""
|
||||
paths = [_make_silence(duration=0.2) for _ in range(3)]
|
||||
try:
|
||||
merger = AudioMerger()
|
||||
result = merger.merge(paths)
|
||||
assert isinstance(result, bytes)
|
||||
assert len(result) > 200
|
||||
finally:
|
||||
for p in paths:
|
||||
os.unlink(p)
|
||||
|
||||
def test_wav_format(self):
|
||||
"""wav 格式合并"""
|
||||
p1 = _make_silence(duration=0.2, fmt="wav")
|
||||
p2 = _make_silence(duration=0.2, fmt="wav")
|
||||
try:
|
||||
merger = AudioMerger()
|
||||
result = merger.merge([p1, p2], output_format="wav")
|
||||
assert isinstance(result, bytes)
|
||||
# WAV 头部以 RIFF 开头
|
||||
assert result[:4] == b"RIFF"
|
||||
finally:
|
||||
os.unlink(p1)
|
||||
os.unlink(p2)
|
||||
|
||||
def test_nonexistent_file_raises(self):
|
||||
"""不存在的文件会抛错"""
|
||||
merger = AudioMerger()
|
||||
with pytest.raises(AudioMergeError):
|
||||
merger.merge(["/nonexistent/path/a.mp3", "/nonexistent/path/b.mp3"])
|
||||
|
||||
def test_cleanup_temp_dir(self):
|
||||
"""临时目录会被清理"""
|
||||
p1 = _make_silence(duration=0.2)
|
||||
p2 = _make_silence(duration=0.2)
|
||||
try:
|
||||
import tempfile as _tf
|
||||
before = set(os.listdir(_tf.gettempdir()))
|
||||
merger = AudioMerger()
|
||||
merger.merge([p1, p2])
|
||||
after = set(os.listdir(_tf.gettempdir()))
|
||||
# 不应该残留 tts_merge_ 前缀的目录
|
||||
new_items = after - before
|
||||
tts_items = [i for i in new_items if i.startswith("tts_merge_")]
|
||||
assert len(tts_items) == 0, f"残留临时目录: {tts_items}"
|
||||
finally:
|
||||
os.unlink(p1)
|
||||
os.unlink(p2)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# TTSStreamingService - 入口路由与边界
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestTTSStreamingServiceRouting:
|
||||
"""TTSStreamingService 入口路由与边界条件"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_text_returns_error(self):
|
||||
"""空文本返回错误"""
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from packages.application.tts_job.streaming_service import TTSStreamingService
|
||||
|
||||
mock_cosy = MagicMock()
|
||||
svc = TTSStreamingService(cosyvoice_service=mock_cosy)
|
||||
ws = AsyncMock()
|
||||
|
||||
await svc.synthesize_and_stream(ws, {"text": ""})
|
||||
|
||||
ws.send_json.assert_called_once()
|
||||
call_args = ws.send_json.call_args[0][0]
|
||||
assert call_args["type"] == "error"
|
||||
assert "不能为空" in call_args["message"]
|
||||
# 不应该调用 cosyvoice
|
||||
mock_cosy.submit_synthesize_task.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_text_too_long_returns_error(self):
|
||||
"""文本过长返回错误"""
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from packages.application.tts_job.streaming_service import (
|
||||
_MAX_TEXT_LENGTH,
|
||||
TTSStreamingService,
|
||||
)
|
||||
|
||||
mock_cosy = MagicMock()
|
||||
svc = TTSStreamingService(cosyvoice_service=mock_cosy)
|
||||
ws = AsyncMock()
|
||||
|
||||
long_text = "a" * (_MAX_TEXT_LENGTH + 1)
|
||||
await svc.synthesize_and_stream(ws, {"text": long_text})
|
||||
|
||||
ws.send_json.assert_called_once()
|
||||
call_args = ws.send_json.call_args[0][0]
|
||||
assert call_args["type"] == "error"
|
||||
assert "过长" in call_args["message"]
|
||||
mock_cosy.submit_synthesize_task.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_short_text_routes_to_short_path(self):
|
||||
"""短文本走短文本路径(单段合成)"""
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from packages.application.tts_job.streaming_service import TTSStreamingService
|
||||
|
||||
mock_cosy = MagicMock()
|
||||
mock_cosy.submit_synthesize_task.return_value = {
|
||||
"audio_url": "https://example.com/audio.mp3",
|
||||
"duration": 3.5,
|
||||
}
|
||||
svc = TTSStreamingService(cosyvoice_service=mock_cosy)
|
||||
ws = AsyncMock()
|
||||
|
||||
# mock 掉音频下载
|
||||
fake_audio = b"fake_audio_data" * 100
|
||||
with patch.object(svc, "_download_audio", return_value=fake_audio):
|
||||
await svc.synthesize_and_stream(ws, {"text": "你好世界", "voice_id": "v1"})
|
||||
|
||||
# 应该调用了 cosy
|
||||
mock_cosy.submit_synthesize_task.assert_called_once()
|
||||
# 应该有 started 和 done 消息
|
||||
msg_types = [c[0][0]["type"] for c in ws.send_json.call_args_list]
|
||||
assert "started" in msg_types
|
||||
assert "done" in msg_types
|
||||
# 应该有音频分块发送
|
||||
assert ws.send_bytes.call_count > 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_short_text_cosy_error(self):
|
||||
"""短文本合成失败返回错误"""
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from packages.application.cosyvoice_service import CosyVoiceError
|
||||
from packages.application.tts_job.streaming_service import TTSStreamingService
|
||||
|
||||
mock_cosy = MagicMock()
|
||||
mock_cosy.submit_synthesize_task.side_effect = CosyVoiceError("音色不存在")
|
||||
svc = TTSStreamingService(cosyvoice_service=mock_cosy)
|
||||
ws = AsyncMock()
|
||||
|
||||
await svc.synthesize_and_stream(ws, {"text": "你好", "voice_id": "v-bad"})
|
||||
|
||||
# 最后一条消息应该是 error
|
||||
last_msg = ws.send_json.call_args_list[-1][0][0]
|
||||
assert last_msg["type"] == "error"
|
||||
assert "音色不存在" in last_msg["message"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_short_text_no_audio_url(self):
|
||||
"""合成结果没有 audio_url 返回错误"""
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from packages.application.tts_job.streaming_service import TTSStreamingService
|
||||
|
||||
mock_cosy = MagicMock()
|
||||
mock_cosy.submit_synthesize_task.return_value = {"duration": 1.0} # 没有 audio_url
|
||||
svc = TTSStreamingService(cosyvoice_service=mock_cosy)
|
||||
ws = AsyncMock()
|
||||
|
||||
await svc.synthesize_and_stream(ws, {"text": "你好"})
|
||||
|
||||
last_msg = ws.send_json.call_args_list[-1][0][0]
|
||||
assert last_msg["type"] == "error"
|
||||
assert "音频 URL" in last_msg["message"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_audio_chunks_returns_total(self):
|
||||
"""_stream_audio_chunks 返回正确字节数,分块正确"""
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from packages.application.tts_job.streaming_service import (
|
||||
_AUDIO_CHUNK_SIZE,
|
||||
TTSStreamingService,
|
||||
)
|
||||
|
||||
mock_cosy = MagicMock()
|
||||
svc = TTSStreamingService(cosyvoice_service=mock_cosy)
|
||||
ws = AsyncMock()
|
||||
|
||||
# 生成 10000 字节的假音频
|
||||
audio_data = b"x" * 10000
|
||||
total = await svc._stream_audio_chunks(ws, audio_data)
|
||||
|
||||
assert total == 10000
|
||||
# 应该分 ceil(10000/4096) = 3 块
|
||||
expected_chunks = (10000 + _AUDIO_CHUNK_SIZE - 1) // _AUDIO_CHUNK_SIZE
|
||||
assert ws.send_bytes.call_count == expected_chunks
|
||||
# 验证所有块拼接起来等于原数据
|
||||
all_bytes = b"".join(c[0][0] for c in ws.send_bytes.call_args_list)
|
||||
assert all_bytes == audio_data
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_json_handles_error(self):
|
||||
"""_send_json 发送失败不抛出异常"""
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from packages.application.tts_job.streaming_service import TTSStreamingService
|
||||
|
||||
mock_cosy = MagicMock()
|
||||
svc = TTSStreamingService(cosyvoice_service=mock_cosy)
|
||||
ws = AsyncMock()
|
||||
ws.send_json.side_effect = Exception("连接已断开")
|
||||
|
||||
# 不应该抛异常
|
||||
await svc._send_json(ws, {"type": "done"})
|
||||
ws.send_json.assert_called_once()
|
||||
Reference in New Issue
Block a user