"""P2 WebSocket 流式合成单元测试。 覆盖: - TTSStreamingService 流式合成逻辑 - 短文本流式合成 - 长文本分段流式合成 - 错误处理 """ from __future__ import annotations import asyncio 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 ( TTSStreamingError, 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): """下载音频数据。""" cosyvoice = MagicMock(spec=CosyVoiceService) service = TTSStreamingService(cosyvoice) with patch("packages.application.tts_job.streaming_service.httpx") as mock_httpx: mock_resp = MagicMock() mock_resp.content = b"audio data" mock_resp.raise_for_status.return_value = None mock_httpx.get.return_value = mock_resp result = service._download_audio("https://example.com/audio.mp3") assert result == b"audio data" mock_httpx.get.assert_called_once()