"""Echo-reply text mirror: VoiceSession.on_segment_done forwards Claude's reply back into the originating text channel, chunked to Discord's 2000-char limit, gated on mirror_enabled, and resilient to send failures. The pipeline calls router.route_message via the injected `router_route_message` seam so tests can drive the reply text without monkey-patching modules or invoking the real Claude subprocess. """ from __future__ import annotations import asyncio from unittest.mock import AsyncMock, MagicMock import pytest from src.voice.pipeline import VoiceSession def _make_text_channel(send_mock: AsyncMock) -> MagicMock: tc = MagicMock(name="text_channel") tc.send = send_mock return tc def _make_session( *, reply_text: str, text_channel, mirror_enabled: bool = True, ) -> VoiceSession: bot = MagicMock(name="bot") bot.get_channel = MagicMock(return_value=text_channel) bot.get_user = MagicMock(return_value=None) ttsq = MagicMock(name="ttsq") ttsq.push_text = MagicMock() ttsq.clear = MagicMock() route_mock = MagicMock(name="route_message", return_value=(reply_text, False)) return VoiceSession( text_channel_id=1001, voice_channel_id=2002, guild_id=42, voice_client=MagicMock(name="voice_client"), bot=bot, ttsq=ttsq, whitelist=set(), record_enabled=False, mirror_enabled=mirror_enabled, transcripts_jsonl_path=None, loop=asyncio.get_event_loop_policy().new_event_loop(), router_route_message=route_mock, ) def _reply_chunks(send_mock: AsyncMock) -> list[str]: # Drop the user-mirror call (starts with the 🎤 microphone emoji); the # rest are reply chunks. return [ call.args[0] for call in send_mock.call_args_list if not call.args[0].startswith("\U0001f3a4") ] @pytest.mark.asyncio async def test_long_reply_splits_into_multiple_chunks(): long_reply = "răspuns lung " * 200 # ~2600 chars → ≥2 chunks at 2000-char limit send_mock = AsyncMock(name="text_send") text_channel = _make_text_channel(send_mock) session = _make_session(reply_text=long_reply, text_channel=text_channel) await session.on_segment_done(speaker_id=123, text="salut", no_speech_prob=0.1) chunks = _reply_chunks(send_mock) assert len(chunks) >= 2 assert "".join(chunks).replace("\n", "").strip().startswith("răspuns lung") @pytest.mark.asyncio async def test_empty_reply_emits_no_reply_chunks(): send_mock = AsyncMock(name="text_send") text_channel = _make_text_channel(send_mock) session = _make_session(reply_text="", text_channel=text_channel) await session.on_segment_done(speaker_id=123, text="salut", no_speech_prob=0.1) assert _reply_chunks(send_mock) == [] @pytest.mark.asyncio async def test_whitespace_only_reply_emits_no_reply_chunks(): send_mock = AsyncMock(name="text_send") text_channel = _make_text_channel(send_mock) session = _make_session(reply_text=" \n\t ", text_channel=text_channel) await session.on_segment_done(speaker_id=123, text="salut", no_speech_prob=0.1) assert _reply_chunks(send_mock) == [] @pytest.mark.asyncio async def test_mirror_disabled_sends_nothing(): send_mock = AsyncMock(name="text_send") text_channel = _make_text_channel(send_mock) session = _make_session( reply_text="orice răspuns", text_channel=text_channel, mirror_enabled=False, ) await session.on_segment_done(speaker_id=123, text="salut", no_speech_prob=0.1) assert send_mock.call_count == 0 @pytest.mark.asyncio async def test_send_failure_is_swallowed(caplog): send_mock = AsyncMock(name="text_send", side_effect=RuntimeError("discord 500")) text_channel = _make_text_channel(send_mock) session = _make_session(reply_text="răspuns scurt", text_channel=text_channel) with caplog.at_level("WARNING"): # Must not raise — both user-mirror and reply-mirror trap exceptions. await session.on_segment_done(speaker_id=123, text="salut", no_speech_prob=0.1) # At least one warning was logged for a mirror send failure. assert any("mirror" in rec.message.lower() for rec in caplog.records)