Files
xiaoxia-saas/tests/unit/test_tts_streaming.py
T
CI Bot 1a57878f76
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 1m12s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 2m17s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 1m33s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 3m14s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
chore(backend): Phase 3 清理 — 未使用依赖删除 + pyflakes 警告清零 + 测试文件冗余清理
1. 未使用依赖清理:
   - 从 requirements-base.txt 移除 cryptography 和 pyOpenSSL

2. pyflakes 警告清零 (apps/ + packages/ + tests/):
   - 移除 17 处未使用的 import (F401)
   - 修复 26 处未使用的局部变量 (F841):
     * 有副作用的赋值转为裸调用
     * 无副作用的赋值直接删除
   - 修复 1 处未使用的异常变量 (F841)
   - 修复 1 处空 except 块

3. 测试文件冗余清理:
   - 删除 tests/integration/test_project_management.py (模块级 skip,测试不存在的模块)
   - 删除 tests/integration/fixtures/duplication_routes_fixed.py (未被引用)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 16:14:46 +08:00

238 lines
7.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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):
"""下载音频数据。"""
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()