053b00634a
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 3s
CI/CD Pipeline / Check push changed paths (push) Successful in 4s
CI/CD Pipeline / Build Staging API Image (push) Successful in 23s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 23s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 26s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 49s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m32s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m38s
CI/CD Pipeline / Integration Tests (push) Successful in 3m50s
CI/CD Pipeline / Validate - Style (push) Successful in 4m2s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m9s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m43s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 4m26s
CI/CD Pipeline / Unit Tests (push) Successful in 8m55s
CI/CD Pipeline / Validate - Security (push) Successful in 9m7s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Failing after 82h52m10s
CI/CD Pipeline / Deploy Production (push) Failing after 82h52m6s
CI/CD Pipeline / Frontend Lint (push) Failing after 83h1m19s
CI/CD Pipeline / PR Build Web Image (push) Failing after 83h0m59s
CI/CD Pipeline / PR Build API Image (push) Failing after 83h0m59s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 83h0m28s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 83h0m28s
CI/CD Pipeline / CI Gate (push) Failing after 82h51m46s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 83h1m1s
CI/CD Pipeline / Canary Release to Production (push) Failing after 82h51m42s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 82h51m46s
CI/CD Pipeline / Build Production API Image (push) Failing after 82h51m46s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 83h0m59s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 83h0m28s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
170 lines
5.7 KiB
Python
170 lines
5.7 KiB
Python
"""AI 数字人 对口型 TTS 预合成接口(#1845)单元测试 — 覆盖 LipsyncService.preview_tts 成功/失败路径.
|
||
|
||
直接调用 LipsyncService.preview_tts(),mock CosyVoiceService / safe_download_bytes / ffprobe,
|
||
验证返回结构、错误码、与共享 sentence_timings 工具的协作。
|
||
"""
|
||
|
||
import os
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
import pytest
|
||
|
||
os.environ.setdefault("JWT_SECRET_KEY", "dev-secret-key-for-testing")
|
||
|
||
|
||
def _make_service(
|
||
*,
|
||
cosyvoice=None,
|
||
download_bytes=b"FAKE_MP3_DATA",
|
||
download_error=None,
|
||
ffprobe_duration=5.0,
|
||
timings_result=None,
|
||
):
|
||
"""构造 LipsyncService 并把 CosyVoiceService/safe_download_bytes/probe/compute 全部 mock 掉。"""
|
||
from app.services.lipsync_service import LipsyncService
|
||
|
||
db = MagicMock()
|
||
|
||
# 构造唯一的 cosyvoice mock 实例,便于断言
|
||
_cosy_inst = MagicMock()
|
||
if cosyvoice is None:
|
||
_cosy_inst.submit_synthesize_task.return_value = {"audio_url": "https://cosy.example.com/tts.mp3"}
|
||
elif isinstance(cosyvoice, Exception):
|
||
_cosy_inst.submit_synthesize_task.side_effect = cosyvoice
|
||
else:
|
||
_cosy_inst.submit_synthesize_task.return_value = cosyvoice
|
||
|
||
def _fake_get_cosyvoice(self): # noqa: ARG001
|
||
return _cosy_inst
|
||
|
||
def _fake_resolve_voice_id(self, voice_id, user_id): # noqa: ARG001
|
||
return voice_id
|
||
|
||
svc = LipsyncService(db=db, client=MagicMock(), voice_clone_repo=MagicMock())
|
||
svc._cosyvoice = _cosy_inst
|
||
patch.object(LipsyncService, "_get_cosyvoice", _fake_get_cosyvoice).start()
|
||
patch.object(LipsyncService, "_resolve_voice_id", _fake_resolve_voice_id).start()
|
||
|
||
# mock safe_download_bytes
|
||
if download_error is not None:
|
||
patch(
|
||
"app.services.lipsync_service.safe_download_bytes",
|
||
side_effect=download_error,
|
||
).start()
|
||
else:
|
||
patch(
|
||
"app.services.lipsync_service.safe_download_bytes",
|
||
return_value=download_bytes,
|
||
).start()
|
||
|
||
# mock probe_audio_duration(patch 到 lipsync_service 模块的命名空间)
|
||
patch(
|
||
"app.services.lipsync_service.probe_audio_duration",
|
||
return_value=ffprobe_duration,
|
||
).start()
|
||
|
||
# mock compute_sentence_timings
|
||
default_timings = [
|
||
{"index": 0, "text": "你好", "start_time": 0.0, "end_time": 1.5},
|
||
{"index": 1, "text": "世界", "start_time": 1.5, "end_time": 5.0},
|
||
]
|
||
patch(
|
||
"app.services.lipsync_service.compute_sentence_timings",
|
||
return_value=timings_result if timings_result is not None else default_timings,
|
||
).start()
|
||
|
||
svc.__dict__["_test_cosy"] = _cosy_inst
|
||
return svc
|
||
|
||
|
||
def test_preview_tts_success():
|
||
"""正常路径:TTS 合成成功 → 下载 → ffprobe → 计算 timings,返回完整结构。"""
|
||
svc = _make_service(ffprobe_duration=5.0)
|
||
try:
|
||
result = svc.preview_tts(
|
||
user_id="user-1",
|
||
voice_id="longxiaochun",
|
||
script_text="你好,世界",
|
||
speed=1.0,
|
||
emotion="natural",
|
||
)
|
||
assert result["audio_url"] == "https://cosy.example.com/tts.mp3"
|
||
assert result["duration"] == 5.0
|
||
assert isinstance(result["sentence_timings"], list)
|
||
assert len(result["sentence_timings"]) == 2
|
||
assert result["sentence_timings"][0]["text"] == "你好"
|
||
cosy = svc.__dict__["_test_cosy"]
|
||
cosy.submit_synthesize_task.assert_called_once()
|
||
kwargs = cosy.submit_synthesize_task.call_args.kwargs
|
||
assert kwargs["text"] == "你好,世界"
|
||
assert kwargs["voice_id"] == "longxiaochun"
|
||
finally:
|
||
patch.stopall()
|
||
|
||
|
||
def test_preview_tts_cosyvoice_error():
|
||
"""CosyVoice 抛错:应该包装成 MediaKitError 抛出。"""
|
||
from app.services.mediakit_client import MediaKitError
|
||
|
||
from packages.application.cosyvoice_service import CosyVoiceError
|
||
|
||
svc = _make_service(cosyvoice=CosyVoiceError("cosyvoice down"))
|
||
try:
|
||
with pytest.raises(MediaKitError):
|
||
svc.preview_tts(
|
||
user_id="user-1",
|
||
voice_id="longxiaochun",
|
||
script_text="你好",
|
||
)
|
||
finally:
|
||
patch.stopall()
|
||
|
||
|
||
def test_preview_tts_download_fail_still_returns_url():
|
||
"""音频下载失败:不抛错,返回 audio_url + 空 timings,前端仍能继续(降级)。"""
|
||
svc = _make_service(download_error=RuntimeError("network down"))
|
||
try:
|
||
result = svc.preview_tts(
|
||
user_id="user-1",
|
||
voice_id="longxiaochun",
|
||
script_text="你好,世界",
|
||
)
|
||
assert result["audio_url"] == "https://cosy.example.com/tts.mp3"
|
||
assert result["duration"] == 0.0
|
||
assert result["sentence_timings"] == []
|
||
finally:
|
||
patch.stopall()
|
||
|
||
|
||
def test_preview_tts_ffprobe_zero_duration():
|
||
"""ffprobe 返回 0:timings 为空,不抛错。"""
|
||
svc = _make_service(ffprobe_duration=0.0)
|
||
try:
|
||
result = svc.preview_tts(
|
||
user_id="user-1",
|
||
voice_id="longxiaochun",
|
||
script_text="你好",
|
||
)
|
||
assert result["audio_url"]
|
||
assert result["duration"] == 0.0
|
||
assert result["sentence_timings"] == []
|
||
finally:
|
||
patch.stopall()
|
||
|
||
|
||
def test_preview_tts_no_audio_url_in_response():
|
||
"""CosyVoice 返回无 audio_url:抛 MediaKitError TTSNoAudio。"""
|
||
from app.services.mediakit_client import MediaKitError
|
||
|
||
svc = _make_service(cosyvoice={"audio_url": ""})
|
||
try:
|
||
with pytest.raises(MediaKitError) as exc_info:
|
||
svc.preview_tts(
|
||
user_id="user-1",
|
||
voice_id="longxiaochun",
|
||
script_text="你好",
|
||
)
|
||
assert exc_info.value.code == "TTSNoAudio"
|
||
finally:
|
||
patch.stopall()
|