- 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>
166 lines
5.9 KiB
Python
166 lines
5.9 KiB
Python
"""路由器 → 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
|