5ab090a0ce
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
447 lines
16 KiB
Python
Executable File
447 lines
16 KiB
Python
Executable File
"""P2 WebSocket 流式合成单元测试。
|
||
|
||
覆盖:
|
||
- TTSStreamingService 流式合成逻辑
|
||
- 短文本流式合成
|
||
- 长文本分段流式合成
|
||
- 错误处理
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from unittest.mock import AsyncMock, MagicMock, patch
|
||
|
||
import pytest
|
||
|
||
from packages.application.cosyvoice_service import CosyVoiceError, CosyVoiceService
|
||
from packages.application.tts_job.streaming_service import (
|
||
TTSStreamingService,
|
||
)
|
||
|
||
|
||
class MockWebSocket:
|
||
"""Mock WebSocket for testing."""
|
||
|
||
def __init__(self):
|
||
self.sent_json = []
|
||
self.sent_bytes = []
|
||
self.accepted = False
|
||
|
||
async def accept(self):
|
||
self.accepted = True
|
||
|
||
async def send_json(self, data):
|
||
self.sent_json.append(data)
|
||
|
||
async def send_bytes(self, data):
|
||
self.sent_bytes.append(data)
|
||
|
||
async def receive_json(self):
|
||
return {}
|
||
|
||
|
||
class TestTTSStreamingService:
|
||
"""测试 TTS 流式合成服务。"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_empty_text_returns_error(self):
|
||
"""空文本返回错误。"""
|
||
cosyvoice = MagicMock(spec=CosyVoiceService)
|
||
service = TTSStreamingService(cosyvoice)
|
||
|
||
ws = MockWebSocket()
|
||
params = {"text": "", "voice_id": "test_voice"}
|
||
|
||
await service.synthesize_and_stream(ws, params)
|
||
|
||
assert len(ws.sent_json) == 1
|
||
assert ws.sent_json[0]["type"] == "error"
|
||
assert "文本不能为空" in ws.sent_json[0]["message"]
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_text_too_long_returns_error(self):
|
||
"""超长文本返回错误。"""
|
||
cosyvoice = MagicMock(spec=CosyVoiceService)
|
||
service = TTSStreamingService(cosyvoice)
|
||
|
||
ws = MockWebSocket()
|
||
params = {"text": "x" * 10001, "voice_id": "test_voice"}
|
||
|
||
await service.synthesize_and_stream(ws, params)
|
||
|
||
assert len(ws.sent_json) == 1
|
||
assert ws.sent_json[0]["type"] == "error"
|
||
assert "文本过长" in ws.sent_json[0]["message"]
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_short_text_stream_success(self):
|
||
"""短文本流式合成成功。"""
|
||
cosyvoice = MagicMock(spec=CosyVoiceService)
|
||
cosyvoice.submit_synthesize_task.return_value = {
|
||
"task_id": "task_1",
|
||
"audio_url": "https://temp.com/audio.mp3",
|
||
"duration": 5.0,
|
||
"file_size": 10000,
|
||
}
|
||
|
||
service = TTSStreamingService(cosyvoice)
|
||
|
||
ws = MockWebSocket()
|
||
params = {"text": "测试文本", "voice_id": "test_voice", "format": "mp3"}
|
||
|
||
with patch.object(service, "_download_audio", return_value=b"fake audio data"):
|
||
await service.synthesize_and_stream(ws, params)
|
||
|
||
# 验证发送了 started 帧
|
||
assert ws.sent_json[0]["type"] == "started"
|
||
assert ws.sent_json[0]["segment_count"] == 1
|
||
|
||
# 验证发送了二进制音频数据
|
||
assert len(ws.sent_bytes) > 0
|
||
|
||
# 验证发送了 done 帧
|
||
assert ws.sent_json[-1]["type"] == "done"
|
||
assert ws.sent_json[-1]["format"] == "mp3"
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_short_text_cosyvoice_error(self):
|
||
"""短文本合成时 CosyVoice 报错。"""
|
||
cosyvoice = MagicMock(spec=CosyVoiceService)
|
||
cosyvoice.submit_synthesize_task.side_effect = CosyVoiceError("API error")
|
||
|
||
service = TTSStreamingService(cosyvoice)
|
||
|
||
ws = MockWebSocket()
|
||
params = {"text": "测试文本", "voice_id": "test_voice"}
|
||
|
||
await service.synthesize_and_stream(ws, params)
|
||
|
||
# 验证发送了 started 帧和 error 帧
|
||
assert len(ws.sent_json) == 2
|
||
assert ws.sent_json[0]["type"] == "started"
|
||
assert ws.sent_json[1]["type"] == "error"
|
||
assert "API error" in ws.sent_json[1]["message"]
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_short_text_no_audio_url(self):
|
||
"""短文本合成未返回 audio_url。"""
|
||
cosyvoice = MagicMock(spec=CosyVoiceService)
|
||
cosyvoice.submit_synthesize_task.return_value = {
|
||
"task_id": "task_1",
|
||
"audio_url": "",
|
||
}
|
||
|
||
service = TTSStreamingService(cosyvoice)
|
||
|
||
ws = MockWebSocket()
|
||
params = {"text": "测试文本", "voice_id": "test_voice"}
|
||
|
||
await service.synthesize_and_stream(ws, params)
|
||
|
||
# 验证发送了 started 帧和 error 帧
|
||
assert len(ws.sent_json) == 2
|
||
assert ws.sent_json[0]["type"] == "started"
|
||
assert ws.sent_json[1]["type"] == "error"
|
||
assert "未返回音频 URL" in ws.sent_json[1]["message"]
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_long_text_stream_success(self):
|
||
"""长文本分段流式合成成功。"""
|
||
cosyvoice = MagicMock(spec=CosyVoiceService)
|
||
# 每个分段都返回 audio_url
|
||
cosyvoice.submit_synthesize_task.side_effect = [
|
||
{"task_id": "", "audio_url": "https://temp.com/seg1.mp3", "duration": 2.0},
|
||
{"task_id": "", "audio_url": "https://temp.com/seg2.mp3", "duration": 3.0},
|
||
]
|
||
|
||
service = TTSStreamingService(cosyvoice)
|
||
|
||
ws = MockWebSocket()
|
||
# 超过 500 字的文本
|
||
params = {"text": "x" * 600, "voice_id": "test_voice", "format": "mp3"}
|
||
|
||
with patch.object(service, "_download_audio", return_value=b"segment audio"):
|
||
await service.synthesize_and_stream(ws, params)
|
||
|
||
# 验证发送了 started 帧(多段)
|
||
assert ws.sent_json[0]["type"] == "started"
|
||
assert ws.sent_json[0]["segment_count"] >= 2
|
||
|
||
# 验证发送了 segment_done 帧
|
||
segment_done_count = sum(1 for msg in ws.sent_json if msg["type"] == "segment_done")
|
||
assert segment_done_count >= 2
|
||
|
||
# 验证发送了 done 帧
|
||
assert ws.sent_json[-1]["type"] == "done"
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_long_text_segment_failure(self):
|
||
"""长文本分段合成失败。"""
|
||
cosyvoice = MagicMock(spec=CosyVoiceService)
|
||
cosyvoice.submit_synthesize_task.side_effect = CosyVoiceError("Segment error")
|
||
|
||
service = TTSStreamingService(cosyvoice)
|
||
|
||
ws = MockWebSocket()
|
||
params = {"text": "x" * 600, "voice_id": "test_voice"}
|
||
|
||
await service.synthesize_and_stream(ws, params)
|
||
|
||
# 验证发送了错误帧
|
||
error_msgs = [msg for msg in ws.sent_json if msg["type"] == "error"]
|
||
assert len(error_msgs) > 0
|
||
assert "合成失败" in error_msgs[0]["message"]
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_stream_audio_chunks(self):
|
||
"""音频分块推送。"""
|
||
cosyvoice = MagicMock(spec=CosyVoiceService)
|
||
service = TTSStreamingService(cosyvoice)
|
||
|
||
ws = MockWebSocket()
|
||
audio_data = b"x" * 10000 # 10KB
|
||
|
||
total = await service._stream_audio_chunks(ws, audio_data)
|
||
|
||
assert total == 10000
|
||
# 验证分块发送(4KB per chunk)
|
||
assert len(ws.sent_bytes) == 3 # 4096 + 4096 + 1808
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_send_json_suppresses_exceptions(self):
|
||
"""_send_json 抑制异常。"""
|
||
cosyvoice = MagicMock(spec=CosyVoiceService)
|
||
service = TTSStreamingService(cosyvoice)
|
||
|
||
ws = MockWebSocket()
|
||
ws.send_json = AsyncMock(side_effect=Exception("Send failed"))
|
||
|
||
# 不应该抛出异常
|
||
await service._send_json(ws, {"type": "test"})
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_download_audio(self):
|
||
"""下载音频数据(SSRF防护走safe_download_bytes,mock掉安全层)。"""
|
||
cosyvoice = MagicMock(spec=CosyVoiceService)
|
||
service = TTSStreamingService(cosyvoice)
|
||
|
||
with patch("packages.application.tts_job.streaming_service.safe_download_bytes") as mock_download:
|
||
mock_download.return_value = b"audio data"
|
||
|
||
result = service._download_audio("https://example.com/audio.mp3")
|
||
|
||
assert result == b"audio data"
|
||
mock_download.assert_called_once()
|
||
|
||
|
||
class TestTTSStreamingEdgeCases:
|
||
"""流式合成边界测试."""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_exactly_500_chars_uses_short_text(self):
|
||
"""刚好500字走短文本路径."""
|
||
cosyvoice = MagicMock(spec=CosyVoiceService)
|
||
cosyvoice.submit_synthesize_task.return_value = {
|
||
"audio_url": "https://temp.com/audio.mp3",
|
||
"duration": 5.0,
|
||
}
|
||
service = TTSStreamingService(cosyvoice)
|
||
ws = MockWebSocket()
|
||
params = {"text": "x" * 500, "voice_id": "test_voice"}
|
||
|
||
with patch.object(service, "_download_audio", return_value=b"audio"):
|
||
await service.synthesize_and_stream(ws, params)
|
||
|
||
# 短文本只有1个segment
|
||
assert ws.sent_json[0]["type"] == "started"
|
||
assert ws.sent_json[0]["segment_count"] == 1
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_501_chars_uses_long_text(self):
|
||
"""501字走长文本分段路径."""
|
||
cosyvoice = MagicMock(spec=CosyVoiceService)
|
||
cosyvoice.submit_synthesize_task.return_value = {
|
||
"audio_url": "https://temp.com/audio.mp3",
|
||
"duration": 2.0,
|
||
}
|
||
service = TTSStreamingService(cosyvoice)
|
||
ws = MockWebSocket()
|
||
params = {"text": "x" * 501, "voice_id": "test_voice"}
|
||
|
||
with patch.object(service, "_download_audio", return_value=b"audio"):
|
||
await service.synthesize_and_stream(ws, params)
|
||
|
||
# 长文本segment_count > 1
|
||
assert ws.sent_json[0]["type"] == "started"
|
||
assert ws.sent_json[0]["segment_count"] >= 2
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_short_text_speed_param_passed(self):
|
||
"""短文本合成时速度参数正确传递."""
|
||
cosyvoice = MagicMock(spec=CosyVoiceService)
|
||
cosyvoice.submit_synthesize_task.return_value = {
|
||
"audio_url": "https://temp.com/audio.mp3",
|
||
"duration": 3.0,
|
||
}
|
||
service = TTSStreamingService(cosyvoice)
|
||
ws = MockWebSocket()
|
||
params = {"text": "测试", "voice_id": "v1", "speed": 1.5, "format": "wav"}
|
||
|
||
with patch.object(service, "_download_audio", return_value=b"audio"):
|
||
await service.synthesize_and_stream(ws, params)
|
||
|
||
cosyvoice.submit_synthesize_task.assert_called_once()
|
||
call_kwargs = cosyvoice.submit_synthesize_task.call_args.kwargs
|
||
assert call_kwargs["speed"] == 1.5
|
||
assert call_kwargs["format"] == "wav"
|
||
assert call_kwargs["voice_id"] == "v1"
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_short_text_sample_rate_param(self):
|
||
"""短文本合成时采样率参数传递."""
|
||
cosyvoice = MagicMock(spec=CosyVoiceService)
|
||
cosyvoice.submit_synthesize_task.return_value = {
|
||
"audio_url": "https://temp.com/audio.mp3",
|
||
"duration": 1.0,
|
||
}
|
||
service = TTSStreamingService(cosyvoice)
|
||
ws = MockWebSocket()
|
||
params = {"text": "测试", "voice_id": "v1", "sample_rate": 44100}
|
||
|
||
with patch.object(service, "_download_audio", return_value=b"audio"):
|
||
await service.synthesize_and_stream(ws, params)
|
||
|
||
call_kwargs = cosyvoice.submit_synthesize_task.call_args.kwargs
|
||
assert call_kwargs["sample_rate"] == 44100
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_single_chunk_audio(self):
|
||
"""小于4KB的音频只发1块."""
|
||
cosyvoice = MagicMock(spec=CosyVoiceService)
|
||
service = TTSStreamingService(cosyvoice)
|
||
ws = MockWebSocket()
|
||
audio_data = b"x" * 1000 # 1KB < 4KB
|
||
|
||
total = await service._stream_audio_chunks(ws, audio_data)
|
||
assert total == 1000
|
||
assert len(ws.sent_bytes) == 1
|
||
assert ws.sent_bytes[0] == audio_data
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_exact_chunk_size_audio(self):
|
||
"""刚好4KB的音频只发1块."""
|
||
cosyvoice = MagicMock(spec=CosyVoiceService)
|
||
service = TTSStreamingService(cosyvoice)
|
||
ws = MockWebSocket()
|
||
audio_data = b"x" * 4096
|
||
|
||
total = await service._stream_audio_chunks(ws, audio_data)
|
||
assert total == 4096
|
||
assert len(ws.sent_bytes) == 1
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_empty_audio_chunks(self):
|
||
"""空音频数据不发送任何块."""
|
||
cosyvoice = MagicMock(spec=CosyVoiceService)
|
||
service = TTSStreamingService(cosyvoice)
|
||
ws = MockWebSocket()
|
||
|
||
total = await service._stream_audio_chunks(ws, b"")
|
||
assert total == 0
|
||
assert len(ws.sent_bytes) == 0
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_short_text_unexpected_exception(self):
|
||
"""短文本合成时非预期异常捕获."""
|
||
cosyvoice = MagicMock(spec=CosyVoiceService)
|
||
cosyvoice.submit_synthesize_task.side_effect = RuntimeError("Unexpected error")
|
||
|
||
service = TTSStreamingService(cosyvoice)
|
||
ws = MockWebSocket()
|
||
params = {"text": "测试", "voice_id": "v1"}
|
||
|
||
await service.synthesize_and_stream(ws, params)
|
||
|
||
assert ws.sent_json[-1]["type"] == "error"
|
||
assert "合成失败" in ws.sent_json[-1]["message"]
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_short_text_download_failure(self):
|
||
"""短文本音频下载失败."""
|
||
cosyvoice = MagicMock(spec=CosyVoiceService)
|
||
cosyvoice.submit_synthesize_task.return_value = {
|
||
"audio_url": "https://temp.com/audio.mp3",
|
||
"duration": 1.0,
|
||
}
|
||
service = TTSStreamingService(cosyvoice)
|
||
ws = MockWebSocket()
|
||
params = {"text": "测试", "voice_id": "v1"}
|
||
|
||
with patch.object(service, "_download_audio", side_effect=Exception("Download failed")):
|
||
await service.synthesize_and_stream(ws, params)
|
||
|
||
assert ws.sent_json[-1]["type"] == "error"
|
||
assert "音频推送失败" in ws.sent_json[-1]["message"]
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_long_text_segment_count_matches_split(self):
|
||
"""长文本分段数量与split_text结果一致."""
|
||
from packages.application.tts_job.text_splitter import split_text
|
||
|
||
text = "x" * 1200
|
||
segments = split_text(text, max_chars=500)
|
||
expected_count = len(segments)
|
||
|
||
cosyvoice = MagicMock(spec=CosyVoiceService)
|
||
cosyvoice.submit_synthesize_task.return_value = {
|
||
"audio_url": "https://temp.com/a.mp3",
|
||
"duration": 1.0,
|
||
}
|
||
service = TTSStreamingService(cosyvoice)
|
||
ws = MockWebSocket()
|
||
params = {"text": text, "voice_id": "v1"}
|
||
|
||
with patch.object(service, "_download_audio", return_value=b"audio"):
|
||
await service.synthesize_and_stream(ws, params)
|
||
|
||
assert ws.sent_json[0]["segment_count"] == expected_count
|
||
segment_done = sum(1 for m in ws.sent_json if m["type"] == "segment_done")
|
||
assert segment_done == expected_count
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_long_text_total_bytes_accumulated(self):
|
||
"""长文本总字节数正确累加."""
|
||
cosyvoice = MagicMock(spec=CosyVoiceService)
|
||
cosyvoice.submit_synthesize_task.return_value = {
|
||
"audio_url": "https://temp.com/a.mp3",
|
||
"duration": 1.0,
|
||
}
|
||
service = TTSStreamingService(cosyvoice)
|
||
ws = MockWebSocket()
|
||
params = {"text": "x" * 600, "voice_id": "v1"}
|
||
|
||
audio_chunk = b"x" * 5000
|
||
with patch.object(service, "_download_audio", return_value=audio_chunk):
|
||
await service.synthesize_and_stream(ws, params)
|
||
|
||
# done帧中file_size应为分段数 * 每段大小
|
||
done_msg = ws.sent_json[-1]
|
||
assert done_msg["type"] == "done"
|
||
segment_count = ws.sent_json[0]["segment_count"]
|
||
assert done_msg["file_size"] == segment_count * 5000
|
||
|
||
def test_download_audio_passes_purpose_and_mime(self):
|
||
"""_download_audio正确传递参数给safe_download_bytes."""
|
||
cosyvoice = MagicMock(spec=CosyVoiceService)
|
||
service = TTSStreamingService(cosyvoice)
|
||
|
||
with patch("packages.application.tts_job.streaming_service.safe_download_bytes") as mock:
|
||
mock.return_value = b"data"
|
||
service._download_audio("https://example.com/a.wav")
|
||
|
||
mock.assert_called_once()
|
||
kwargs = mock.call_args.kwargs
|
||
assert kwargs["purpose"] == "tts_streaming_download"
|
||
assert kwargs["timeout"] == 60.0
|
||
assert "allowed_mime_types" in kwargs
|