test_request_limits.py 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166
  1. """Tests du budget de pièces jointes par requête : des messages arrivés pendant une réponse, trop lourds
  2. ensemble, partent dans plusieurs appels à Claude. Lancer depuis la racine du projet : python -m unittest"""
  3. import asyncio
  4. import base64
  5. import contextlib
  6. import unittest
  7. from types import SimpleNamespace
  8. from unittest import mock
  9. import anthropic
  10. from bot.claude_client import ClaudeClient, ClaudeError
  11. from bot.discord_bot import ChatBot, PendingMessage
  12. from bot.media import PDF_MEDIA_TYPE, ImageData, base64_size, fit_in_request
  13. CHANNEL_ID = 10
  14. def fake_bot() -> mock.Mock:
  15. bot = mock.Mock()
  16. bot._pending = {}
  17. bot.history.lock.return_value = asyncio.Lock()
  18. bot.history.get.return_value = []
  19. bot.history.add_exchange = mock.AsyncMock()
  20. bot.presence_note.return_value = None
  21. bot.member_notes = mock.AsyncMock(return_value="")
  22. bot.claude.ask = mock.AsyncMock(return_value=mock.Mock(text="Réponse", web_used=False))
  23. bot.send_answer = mock.AsyncMock()
  24. bot.safe_reply = mock.AsyncMock()
  25. return bot
  26. def pdf(megabytes: float, pages: int = 1) -> ImageData:
  27. return ImageData(PDF_MEDIA_TYPE, bytes(int(megabytes * 1_000_000)), name="doc.pdf", pages=pages)
  28. def image() -> ImageData:
  29. return ImageData("image/png", bytes(1000))
  30. def pending(message_id: int, channel: mock.Mock, images: list[ImageData]) -> PendingMessage:
  31. message = mock.Mock(id=message_id, channel=channel)
  32. line = {"id": message_id, "date": "03/11/2026 18:00", "author": f"membre{message_id}",
  33. "content": f"message {message_id}"}
  34. return PendingMessage(message, line, "", images)
  35. def asked_texts(bot: mock.Mock) -> list[list[str]]:
  36. """Textes envoyés à chaque appel à Claude."""
  37. return [[text for text, _ in call.args[1]] for call in bot.claude.ask.await_args_list]
  38. def text(message_id: int) -> str:
  39. return f"03/11/2026 18:00, membre{message_id} a écrit : message {message_id}"
  40. class BudgetTest(unittest.TestCase):
  41. def test_base64_size(self):
  42. for size in (0, 1, 2, 3, 4, 1000, 15_000_001):
  43. with self.subTest(size=size):
  44. self.assertEqual(base64_size(size), len(base64.standard_b64encode(bytes(size))))
  45. def test_light_messages_together(self):
  46. self.assertEqual(fit_in_request([[pdf(5)], [image(), image()], [], [pdf(5)]]), 4)
  47. def test_heavy_messages_apart(self):
  48. # 15 Mo bruts = 20 Mo en base64 : deux ne tiennent pas dans les 24 Mo du budget.
  49. self.assertEqual(fit_in_request([[pdf(15)], [pdf(15)], [pdf(1)]]), 1)
  50. self.assertEqual(fit_in_request([[pdf(15)], [pdf(1)], [pdf(15)]]), 2)
  51. def test_first_message_always_goes(self):
  52. # Un PDF de 20 Mo (26,7 Mo en base64) dépasse le budget, mais part quand même, seul.
  53. self.assertEqual(fit_in_request([[pdf(20)]]), 1)
  54. self.assertEqual(fit_in_request([[pdf(20)], []]), 1)
  55. def test_page_limit(self):
  56. self.assertEqual(fit_in_request([[pdf(1, pages=40)], [pdf(1, pages=40)], [pdf(1, pages=40)]]), 2)
  57. self.assertEqual(fit_in_request([[pdf(1, pages=100)], [pdf(1, pages=1)]]), 1)
  58. # Une image compte pour une page : 100 au plus par requête.
  59. self.assertEqual(fit_in_request([[image()] * 4] * 30), 25)
  60. @mock.patch("bot.discord_bot.typing", lambda channel: contextlib.nullcontext())
  61. @mock.patch("bot.discord_bot.emoji_names", lambda guild: [])
  62. class MainChannelTest(unittest.IsolatedAsyncioTestCase):
  63. async def test_heavy_messages_split(self):
  64. # Deux PDF de 15 Mo (40 Mo en base64) : trop lourds pour partir dans la même requête.
  65. bot = fake_bot()
  66. channel = mock.Mock(id=CHANNEL_ID)
  67. bot._pending[CHANNEL_ID] = [pending(1, channel, [pdf(15)]), pending(2, channel, [pdf(15)])]
  68. await ChatBot.answer_main_channel(bot, channel)
  69. self.assertEqual(asked_texts(bot), [[text(1)], [text(2)]])
  70. self.assertEqual(bot.history.add_exchange.await_count, 2)
  71. self.assertEqual([call.args[0].id for call in bot.send_answer.await_args_list], [1, 2])
  72. # Réponse découpée : chacune cite son message, pour qu'on sache à quoi elle répond.
  73. self.assertEqual([call.kwargs["reply"] for call in bot.send_answer.await_args_list], [True, True])
  74. self.assertEqual(bot._pending[CHANNEL_ID], [])
  75. async def test_light_messages_grouped(self):
  76. bot = fake_bot()
  77. channel = mock.Mock(id=CHANNEL_ID)
  78. bot._pending[CHANNEL_ID] = [pending(2, channel, [pdf(5)]), pending(1, channel, [pdf(5)])]
  79. await ChatBot.answer_main_channel(bot, channel)
  80. self.assertEqual(asked_texts(bot), [[text(1), text(2)]])
  81. bot.send_answer.assert_awaited_once_with(mock.ANY, mock.ANY, reply=False)
  82. async def test_page_limit(self):
  83. # 3 PDF de 40 pages : les deux premiers ensemble (80 pages), le troisième ensuite.
  84. bot = fake_bot()
  85. channel = mock.Mock(id=CHANNEL_ID)
  86. bot._pending[CHANNEL_ID] = [pending(i, channel, [pdf(1, pages=40)]) for i in (1, 2, 3)]
  87. await ChatBot.answer_main_channel(bot, channel)
  88. self.assertEqual(asked_texts(bot), [[text(1), text(2)], [text(3)]])
  89. async def test_error_does_not_stop_the_rest(self):
  90. bot = fake_bot()
  91. channel = mock.Mock(id=CHANNEL_ID)
  92. first, second = pending(1, channel, [pdf(15)]), pending(2, channel, [pdf(15)])
  93. bot._pending[CHANNEL_ID] = [first, second]
  94. bot.claude.ask.side_effect = [ClaudeError("Erreur."), mock.Mock(text="Réponse", web_used=False)]
  95. await ChatBot.answer_main_channel(bot, channel)
  96. bot.safe_reply.assert_awaited_once_with(first.message, "Erreur.")
  97. bot.send_answer.assert_awaited_once_with(second.message, "Réponse", reply=True)
  98. bot.history.add_exchange.assert_awaited_once()
  99. self.assertEqual(bot.forget_waiting.call_args_list, [mock.call(first.message), mock.call(second.message)])
  100. async def test_deferred_answer_handles_all(self):
  101. # Absent : une seule tâche attend pour tous les messages du salon, et doit répondre à chacun.
  102. bot = fake_bot()
  103. bot._waiters = {}
  104. bot.presence.wait_turn = mock.AsyncMock()
  105. bot.presence.late_note.return_value = "[en retard]"
  106. bot.answer_main_channel = lambda channel, late=None: ChatBot.answer_main_channel(bot, channel, late)
  107. bot._answer_when_possible = lambda *args: ChatBot._answer_when_possible(bot, *args)
  108. channel = mock.Mock(id=CHANNEL_ID)
  109. for message_id in (1, 2, 3):
  110. waiting = pending(message_id, channel, [pdf(15)])
  111. bot._pending.setdefault(CHANNEL_ID, []).append(waiting)
  112. ChatBot.answer_later(bot, waiting.message, bot.answer_main_channel)
  113. await bot._waiters[CHANNEL_ID]
  114. # Chaque réponse rappelle qu'elle arrive en retard, et la garde en mémoire.
  115. self.assertEqual(asked_texts(bot), [["[en retard]", text(i)] for i in (1, 2, 3)])
  116. for call in bot.history.add_exchange.await_args_list:
  117. self.assertEqual(call.args[1][0], {"content": "[en retard]"})
  118. self.assertEqual([call.args[0].id for call in bot.send_answer.await_args_list], [1, 2, 3])
  119. self.assertEqual(bot._pending[CHANNEL_ID], [])
  120. self.assertEqual(bot._waiters, {})
  121. class TooLargeTest(unittest.IsolatedAsyncioTestCase):
  122. async def test_clear_message(self):
  123. error = anthropic.RequestTooLargeError("trop lourd", response=mock.Mock(status_code=413, headers={}),
  124. body={"error": {"type": "request_too_large"}})
  125. client = ClaudeClient.__new__(ClaudeClient) # sans __init__ : pas de client Anthropic à créer
  126. client.settings = SimpleNamespace(api_timeout=1)
  127. client._client = SimpleNamespace(messages=SimpleNamespace(create=mock.AsyncMock(side_effect=error)))
  128. with self.assertLogs("chatbot.claude", "ERROR"), self.assertRaises(ClaudeError) as ctx:
  129. await client._call({}, {"pdf"})
  130. self.assertEqual(ctx.exception.user_message,
  131. "Ces fichiers sont trop lourds pour moi tous ensemble, renvoie-les un par un.")
  132. if __name__ == "__main__":
  133. unittest.main()