Files
life-echo/api/tests/test_ws_router_tts_this_turn_passthrough.py
Kevin ccdc4e4277 feat(i18n): persist language preference and thread through chat, memoir, TTS
- Add users.language_preference (Alembic 0018, default zh); capture at signup/SMS
  only; expose on auth and profile APIs
- Lite English prompts for chat and memoir; localized stage labels and agent
  names (Life Echo / 岁月知己)
- Tencent TTS: language-aware synthesis, ModelType=1 for 501004, English chunking
- WebSocket pipeline: emit all AGENT_RESPONSE segments when TTS cancels; INFO logs
  for tts_this_turn and TTS decisions; on-demand TTS logging
- Expo: device language on auth, i18n tiers/agent name, [SPLIT] streaming UX fixes
- Tests for migration, prompts, pipeline, router tts_this_turn, reply segments

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 16:16:49 +08:00

166 lines
5.9 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.
"""路由器 → pipeline 的 ``tts_this_turn`` 字段传递契约测试。
回归保护FE``app-expo/src/core/ws/client.ts`` 与
``app-expo/src/features/conversation/realtime-session.ts``)一律以蛇形 ``tts_this_turn``
作为 WebSocket payload 的 ``data`` 字段名;后端 ``ws/router.py`` 三个对话入口分支也按
``data.get("tts_this_turn")`` 取值并传递给 pipeline。
这里不依赖完整 WebSocket runtime只在两处验证契约
1. **数据形状契约**:用 FE 实际发送的 payload 形状snake_case key调用
``data.get("tts_this_turn")``,断言能取到 True同时驼峰 ``ttsThisTurn`` 应取不到,
防止后续有人把后端 key 改成驼峰。
2. **wrapper 透传契约**:直接驱动 ``process_persisted_user_segment_response``
(路由 TEXT / AUDIO_MESSAGE 分支唯一会调用的 pipeline 包装mock 内部 DB +
``process_user_message``,断言 ``tts_this_turn=True`` 被原样传到 ``process_user_message``。
"""
from __future__ import annotations
from contextlib import asynccontextmanager
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from app.features.conversation.ws import pipeline as ws_pipeline
# ── 数据形状契约 ────────────────────────────────────────────────
def test_router_text_payload_extracts_tts_this_turn_snake_case() -> None:
"""FE 发送 ``{type:'text', data:{text, tts_this_turn:true}}``,后端按蛇形读取。"""
fe_payload_data = {"text": "hi", "tts_this_turn": True}
assert bool(fe_payload_data.get("tts_this_turn")) is True
# 缺省 / 显式 false均应取到 False
assert bool({"text": "hi"}.get("tts_this_turn")) is False
assert bool({"text": "hi", "tts_this_turn": False}.get("tts_this_turn")) is False
# 驼峰回归保护FE 历史曾误传过驼峰,蛇形 get 必须取不到,避免静默吞掉
assert bool({"text": "hi", "ttsThisTurn": True}.get("tts_this_turn")) is False
def test_router_audio_segment_payload_extracts_tts_this_turn_snake_case() -> None:
"""FE ``sendAudioSegment`` 也按 ``data.tts_this_turn`` 输出,蛇形保持一致。"""
fe_payload_data = {
"audio_base64": "AAA",
"segment_index": 0,
"voice_session_id": "vs-1",
"is_last": True,
"duration": 3,
"tts_this_turn": True,
}
assert bool(fe_payload_data.get("tts_this_turn")) is True
# ── wrapper 透传契约 ────────────────────────────────────────────
@pytest.mark.asyncio
async def test_process_persisted_user_segment_response_passes_tts_this_turn(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""路由 TEXT 分支 → ``process_persisted_user_segment_response`` → ``process_user_message``。"""
captured: dict = {}
async def _spy_process_user_message(*args, **kwargs) -> None:
captured.update(kwargs)
# 兼容位置参数(实际调用是关键字调用,这里只为防御性记录)
captured["_args"] = args
monkeypatch.setattr(ws_pipeline, "process_user_message", _spy_process_user_message)
conversation = SimpleNamespace(
id="conv-1",
user_id="user-1",
deleted_at=None,
last_message_at=None,
)
user = SimpleNamespace(id="user-1", language_preference="zh")
segment = SimpleNamespace(
id="seg-1",
conversation_id="conv-1",
user_input_text="说说童年",
created_at=None,
)
fake_db = SimpleNamespace(
get=AsyncMock(
side_effect=lambda model, oid: {
"conv-1": conversation,
"user-1": user,
"seg-1": segment,
}.get(oid)
)
)
@asynccontextmanager
async def _fake_session_local():
yield fake_db
monkeypatch.setattr(ws_pipeline, "AsyncSessionLocal", _fake_session_local)
await ws_pipeline.process_persisted_user_segment_response(
conversation_id="conv-1",
user_id="user-1",
segment_id="seg-1",
tts_this_turn=True,
)
assert captured.get("tts_this_turn") is True, (
"router TEXT/AUDIO_MESSAGE 分支必须把 tts_this_turn=True 透传到 process_user_message"
)
assert captured.get("conversation_id") == "conv-1"
assert captured.get("user_message") == "说说童年"
@pytest.mark.asyncio
async def test_process_persisted_user_segment_response_default_tts_this_turn_false(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""缺省(未开本轮朗读)必须以 False 透传,不能被默默改成 True。"""
captured: dict = {}
async def _spy_process_user_message(*_args, **kwargs) -> None:
captured.update(kwargs)
monkeypatch.setattr(ws_pipeline, "process_user_message", _spy_process_user_message)
conversation = SimpleNamespace(
id="conv-2", user_id="user-2", deleted_at=None, last_message_at=None
)
user = SimpleNamespace(id="user-2", language_preference="zh")
segment = SimpleNamespace(
id="seg-2",
conversation_id="conv-2",
user_input_text="x",
created_at=None,
)
fake_db = SimpleNamespace(
get=AsyncMock(
side_effect=lambda model, oid: {
"conv-2": conversation,
"user-2": user,
"seg-2": segment,
}.get(oid)
)
)
@asynccontextmanager
async def _fake_session_local():
yield fake_db
monkeypatch.setattr(ws_pipeline, "AsyncSessionLocal", _fake_session_local)
await ws_pipeline.process_persisted_user_segment_response(
conversation_id="conv-2",
user_id="user-2",
segment_id="seg-2",
# 不传 tts_this_turn → 默认 False
)
assert captured.get("tts_this_turn") is False