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
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>
238 lines
7.8 KiB
Python
238 lines
7.8 KiB
Python
"""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()
|