"""路由器 → 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