"""Tests for Telegram inline keyboard clarify buttons. Mirrors test_telegram_approval_buttons.py for the new ``send_clarify`` and ``cl:`` callback dispatch added in feat/clarify-gateway-buttons. """ import asyncio import os import sys from pathlib import Path from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch import pytest # --------------------------------------------------------------------------- # Ensure the repo root is importable # --------------------------------------------------------------------------- _repo = str(Path(__file__).resolve().parents[2]) if _repo not in sys.path: sys.path.insert(0, _repo) # --------------------------------------------------------------------------- # Minimal Telegram mock so TelegramAdapter can be imported (mirrors # test_telegram_approval_buttons.py) # --------------------------------------------------------------------------- def _ensure_telegram_mock(): if "telegram" in sys.modules and hasattr(sys.modules["telegram"], "__file__"): return mod = MagicMock() mod.ext.ContextTypes.DEFAULT_TYPE = type(None) mod.constants.ParseMode.MARKDOWN = "Markdown" mod.constants.ParseMode.MARKDOWN_V2 = "MarkdownV2" mod.constants.ParseMode.HTML = "HTML" mod.constants.ChatType.PRIVATE = "private" mod.constants.ChatType.GROUP = "group" mod.constants.ChatType.SUPERGROUP = "supergroup" mod.constants.ChatType.CHANNEL = "channel" mod.error.NetworkError = type("NetworkError", (OSError,), {}) mod.error.TimedOut = type("TimedOut", (OSError,), {}) mod.error.BadRequest = type("BadRequest", (Exception,), {}) for name in ("telegram", "telegram.ext", "telegram.constants", "telegram.request"): sys.modules.setdefault(name, mod) sys.modules.setdefault("telegram.error", mod.error) _ensure_telegram_mock() from gateway.platforms.telegram import TelegramAdapter from gateway.config import Platform, PlatformConfig def _make_adapter(extra=None): config = PlatformConfig(enabled=True, token="test-token", extra=extra or {}) adapter = TelegramAdapter(config) adapter._bot = AsyncMock() adapter._app = MagicMock() return adapter def _clear_clarify_state(): from tools import clarify_gateway as cm with cm._lock: cm._entries.clear() cm._session_index.clear() cm._notify_cbs.clear() # =========================================================================== # send_clarify — render # =========================================================================== class TestTelegramSendClarify: """Verify the rendered prompt has buttons or none, and stores state.""" def setup_method(self): _clear_clarify_state() @pytest.mark.asyncio async def test_multi_choice_renders_buttons_and_other(self): adapter = _make_adapter() mock_msg = MagicMock() mock_msg.message_id = 100 adapter._bot.send_message = AsyncMock(return_value=mock_msg) result = await adapter.send_clarify( chat_id="12345", question="Which option?", choices=["alpha", "beta", "gamma"], clarify_id="cid1", session_key="sk1", ) assert result.success is True assert result.message_id == "100" kwargs = adapter._bot.send_message.call_args[1] assert kwargs["chat_id"] == 12345 assert "Which option?" in kwargs["text"] # InlineKeyboardMarkup with N+1 buttons (3 choices + Other) markup = kwargs["reply_markup"] assert markup is not None # Mocked InlineKeyboardMarkup — just verify it was constructed # with rows. We check state instead of poking the mock structure. assert "cid1" in adapter._clarify_state assert adapter._clarify_state["cid1"] == "sk1" @pytest.mark.asyncio async def test_open_ended_no_keyboard(self): adapter = _make_adapter() mock_msg = MagicMock() mock_msg.message_id = 101 adapter._bot.send_message = AsyncMock(return_value=mock_msg) result = await adapter.send_clarify( chat_id="12345", question="What is your name?", choices=None, clarify_id="cid2", session_key="sk2", ) assert result.success is True kwargs = adapter._bot.send_message.call_args[1] # No reply_markup means no buttons — open-ended path assert "reply_markup" not in kwargs assert "What is your name?" in kwargs["text"] assert adapter._clarify_state["cid2"] == "sk2" @pytest.mark.asyncio async def test_not_connected(self): adapter = _make_adapter() adapter._bot = None result = await adapter.send_clarify( chat_id="12345", question="?", choices=["a"], clarify_id="cid3", session_key="sk3", ) assert result.success is False @pytest.mark.asyncio async def test_truncates_long_choice_label(self): adapter = _make_adapter() mock_msg = MagicMock() mock_msg.message_id = 102 adapter._bot.send_message = AsyncMock(return_value=mock_msg) long_choice = "x" * 200 # > 60 char cap result = await adapter.send_clarify( chat_id="12345", question="?", choices=[long_choice], clarify_id="cid4", session_key="sk4", ) assert result.success is True # The truncation logic replaces with "..." past 57 chars; we don't # inspect the mock's button labels directly (auto-MagicMock), but # we can verify the call didn't raise on absurdly long input. @pytest.mark.asyncio async def test_html_escapes_question(self): adapter = _make_adapter() mock_msg = MagicMock() mock_msg.message_id = 103 adapter._bot.send_message = AsyncMock(return_value=mock_msg) await adapter.send_clarify( chat_id="12345", question="", choices=["x"], clarify_id="cid5", session_key="sk5", ) kwargs = adapter._bot.send_message.call_args[1] # Must NOT contain raw