1
0

test_mentions.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231
  1. """Tests des mentions hors du salon dédié : une rafale ne lance pas un appel par message.
  2. Lancer depuis la racine du projet : python -m unittest"""
  3. import asyncio
  4. import contextlib
  5. import unittest
  6. from unittest import mock
  7. from bot.discord_bot import ChatBot
  8. from bot.media import PDF_MEDIA_TYPE, ImageData
  9. CHANNEL_ID = 20
  10. def fake_bot() -> mock.Mock:
  11. bot = mock.Mock()
  12. bot._mentions = {}
  13. bot._waiters = {}
  14. bot.available.return_value = True
  15. lock = asyncio.Lock()
  16. bot.history.lock.return_value = lock
  17. bot.answer_mentions = lambda channel, late=None: ChatBot.answer_mentions(bot, channel, late)
  18. bot.answer_mention = lambda mentions, late=None: ChatBot.answer_mention(bot, mentions, late)
  19. bot.mention_part = lambda message: ChatBot.mention_part(bot, message)
  20. bot.mention_context = lambda channel, ids, before=None: ChatBot.mention_context(bot, channel, ids, before)
  21. bot.settings.context_message_limit = 20
  22. bot.settings.pdf_max_pages = 100
  23. bot.presence_note.return_value = None
  24. bot.timestamp = lambda message: "03/11/2026 18:00"
  25. bot.read_reply = mock.AsyncMock(return_value=(None, []))
  26. bot.member_notes = mock.AsyncMock(return_value="")
  27. bot.claude.ask = mock.AsyncMock(return_value=mock.Mock(text="Coucou"))
  28. bot.send_answer = mock.AsyncMock()
  29. bot.safe_reply = mock.AsyncMock()
  30. return bot
  31. def fake_mention(message_id: int, text: str, channel: mock.Mock) -> mock.Mock:
  32. message = mock.Mock(id=message_id, clean_content=f"@Rika {text}", attachments=[], stickers=[])
  33. message.channel = channel
  34. message.guild.me.display_name = "Rika"
  35. message.author.display_name = message.author.name = f"membre{message_id}"
  36. return message
  37. def fake_channel(history: list) -> mock.Mock:
  38. channel = mock.Mock(id=CHANNEL_ID)
  39. channel.name = "général"
  40. async def read(limit, before):
  41. for msg in reversed([m for m in history if before is None or m.id < before.id][-limit:]):
  42. yield msg
  43. channel.history = read
  44. return channel
  45. @mock.patch("bot.discord_bot.typing", lambda channel: contextlib.nullcontext())
  46. @mock.patch("bot.discord_bot.emoji_names", lambda guild: [])
  47. class MentionTest(unittest.IsolatedAsyncioTestCase):
  48. async def test_burst_grouped(self):
  49. # 10 mentions en rafale : la première part seule, les 9 autres attendent puis partent ensemble.
  50. bot = fake_bot()
  51. calls, release = [], asyncio.Event()
  52. async def answer(mentions, late=None):
  53. calls.append([m.id for m in mentions])
  54. await release.wait()
  55. bot.answer_mention = answer
  56. channel = mock.Mock(id=CHANNEL_ID)
  57. tasks = [asyncio.create_task(ChatBot.handle_mention(bot, mock.Mock(id=1, channel=channel)))]
  58. await asyncio.sleep(0)
  59. tasks += [asyncio.create_task(ChatBot.handle_mention(bot, mock.Mock(id=i, channel=channel)))
  60. for i in range(2, 11)]
  61. await asyncio.sleep(0)
  62. release.set()
  63. await asyncio.gather(*tasks)
  64. self.assertEqual(calls, [[1], list(range(2, 11))])
  65. self.assertEqual(bot._mentions, {})
  66. async def test_one_call_for_grouped_mentions(self):
  67. bot = fake_bot()
  68. history = []
  69. channel = fake_channel(history)
  70. history += [mock.Mock(id=1, clean_content="salut tout le monde", attachments=[], stickers=[]),
  71. fake_mention(2, "ça va ?", channel),
  72. mock.Mock(id=3, clean_content="il dort", attachments=[], stickers=[]),
  73. fake_mention(4, "réveille-toi", channel)]
  74. for msg in history:
  75. msg.author.display_name = msg.author.name = f"membre{msg.id}"
  76. bot._mentions[CHANNEL_ID] = [history[3], history[1]]
  77. await ChatBot.answer_mentions(bot, channel)
  78. bot.claude.ask.assert_awaited_once()
  79. parts = bot.claude.ask.await_args.args[1]
  80. self.assertEqual(len(parts), 2) # une partie par mention, dans l'ordre
  81. context, second = parts[0][0], parts[1][0]
  82. self.assertIn("salut tout le monde", context)
  83. self.assertIn("il dort", context)
  84. self.assertNotIn("membre2 : ", context) # la mention n'est pas recopiée dans le contexte
  85. self.assertTrue(context.endswith("membre2 te mentionne et écrit : ça va ?"))
  86. self.assertEqual(second, "03/11/2026 18:00, membre4 te mentionne et écrit : réveille-toi")
  87. bot.member_notes.assert_awaited_once_with([history[1], history[3]])
  88. bot.send_answer.assert_awaited_once_with(history[3], "Coucou", reply=False)
  89. async def test_grouped_answer_sees_previous_answer(self):
  90. # La réponse à la 1re mention est postée après les mentions regroupées : elle doit être dans le contexte.
  91. bot = fake_bot()
  92. history = []
  93. channel = fake_channel(history)
  94. history += [fake_mention(1, "tu fais quoi ce soir ?", channel), fake_mention(2, "?", channel),
  95. fake_mention(3, "réponds !", channel),
  96. mock.Mock(id=4, clean_content="Je regarde un film !", attachments=[], stickers=[])]
  97. history[3].author.display_name = history[3].author.name = "Rika"
  98. await ChatBot.answer_mention(bot, history[1:3])
  99. self.assertIn("Rika : Je regarde un film !", bot.claude.ask.await_args.args[1][0][0])
  100. async def test_late_context_stops_at_last_mention(self):
  101. # En retard, la discussion qui a suivi les mentions ne remplace pas celle qui les entoure.
  102. bot = fake_bot()
  103. history = []
  104. channel = fake_channel(history)
  105. history += [mock.Mock(id=1, clean_content="avant", attachments=[], stickers=[]),
  106. fake_mention(2, "tu es là ?", channel),
  107. mock.Mock(id=3, clean_content="après", attachments=[], stickers=[])]
  108. for msg in history:
  109. msg.author.display_name = msg.author.name = f"membre{msg.id}"
  110. bot.presence.late_note.return_value = "(en retard)"
  111. await ChatBot.answer_mention(bot, [history[1]], late=mock.Mock())
  112. context = bot.claude.ask.await_args.args[1][0][0]
  113. self.assertIn("avant", context)
  114. self.assertNotIn("après", context)
  115. async def test_refused_attachment_drops_only_that_mention(self):
  116. bot = fake_bot()
  117. channel = fake_channel([])
  118. first, second = fake_mention(1, "regarde", channel), fake_mention(2, "et ça ?", channel)
  119. bot.mention_part = mock.AsyncMock(side_effect=[None, ("texte", [])])
  120. await ChatBot.answer_mention(bot, [first, second])
  121. self.assertEqual(bot.claude.ask.await_args.args[1], [("texte", [])])
  122. bot.member_notes.assert_awaited_once_with([second])
  123. async def test_heavy_mentions_split(self):
  124. # Deux mentions avec chacune un PDF de 15 Mo : trop lourdes pour une seule requête, elles reçoivent
  125. # deux réponses à la suite, et la seconde voit la première dans le contexte.
  126. bot = fake_bot()
  127. history = []
  128. channel = fake_channel(history)
  129. history += [mock.Mock(id=1, clean_content="salut", attachments=[], stickers=[]),
  130. fake_mention(2, "lis ça", channel), fake_mention(3, "et ça", channel)]
  131. history[0].author.display_name = history[0].author.name = "membre1"
  132. heavy = ImageData(PDF_MEDIA_TYPE, bytes(15_000_000), name="doc.pdf")
  133. bot.mention_part = mock.AsyncMock(side_effect=[("lis ça", [heavy]), ("et ça", [heavy])])
  134. async def post(message, text, reply=False):
  135. answer = mock.Mock(id=len(history) + 1, clean_content=text, attachments=[], stickers=[])
  136. answer.author.display_name = answer.author.name = "Rika"
  137. history.append(answer)
  138. bot.send_answer = mock.AsyncMock(side_effect=post)
  139. await ChatBot.answer_mention(bot, history[1:3])
  140. first, second = (call.args[1] for call in bot.claude.ask.await_args_list)
  141. self.assertEqual([len(first), len(second)], [1, 1])
  142. self.assertIn("membre1 : salut", first[0][0])
  143. self.assertTrue(first[0][0].endswith("lis ça"))
  144. self.assertNotIn("Rika : Coucou", first[0][0])
  145. self.assertIn("Rika : Coucou", second[0][0])
  146. self.assertTrue(second[0][0].endswith("et ça"))
  147. self.assertEqual(bot.member_notes.await_args_list, [mock.call([history[1]]), mock.call([history[2]])])
  148. self.assertEqual(bot.send_answer.await_args_list,
  149. [mock.call(history[1], "Coucou", reply=True), mock.call(history[2], "Coucou", reply=True)])
  150. def heavy_split_setup(self, late: bool):
  151. """Deux mentions trop lourdes pour une seule requête, suivies d'un message d'un membre ;
  152. chaque réponse du bot est ajoutée au salon."""
  153. bot = fake_bot()
  154. history = []
  155. channel = fake_channel(history)
  156. history += [fake_mention(1, "lis ça", channel), fake_mention(2, "et ça", channel)]
  157. heavy = ImageData(PDF_MEDIA_TYPE, bytes(15_000_000), name="doc.pdf")
  158. bot.mention_part = mock.AsyncMock(side_effect=[("lis ça", [heavy]), ("et ça", [heavy])])
  159. bot.presence.late_note.return_value = "(en retard)"
  160. for message in history:
  161. message.guild.me.display_name = message.guild.me.name = "Rika"
  162. if late:
  163. history.append(mock.Mock(id=3, clean_content="après", attachments=[], stickers=[]))
  164. history[2].author.display_name = history[2].author.name = "membre3"
  165. answers = iter(["Réponse un", "Réponse deux"])
  166. bot.claude.ask = mock.AsyncMock(side_effect=lambda *a, **k: mock.Mock(text=next(answers)))
  167. async def post(message, text, reply=False):
  168. answer = mock.Mock(id=len(history) + 1, clean_content=text, attachments=[], stickers=[])
  169. answer.author.display_name = answer.author.name = "Rika"
  170. history.append(answer)
  171. bot.send_answer = mock.AsyncMock(side_effect=post)
  172. return bot, history[:2]
  173. async def test_heavy_split_next_group_sees_answered_mention(self):
  174. # La 2e réponse voit la 1re mention (déjà traitée) comme un message normal, et la réponse du bot.
  175. bot, mentions = self.heavy_split_setup(late=False)
  176. await ChatBot.answer_mention(bot, mentions)
  177. first, second = (call.args[1][0][0] for call in bot.claude.ask.await_args_list)
  178. self.assertNotIn("membre1 : ", first)
  179. self.assertNotIn("membre2 : ", first)
  180. self.assertIn("membre1 : @Rika lis ça", second)
  181. self.assertNotIn("membre2 : ", second) # la mention en cours reste décrite à part
  182. self.assertIn("Rika : Réponse un", second)
  183. async def test_late_heavy_split_next_group_sees_previous_answer(self):
  184. # En retard, le contexte s'arrête à la dernière mention, mais la 2e réponse voit quand même
  185. # la 1re mention et la réponse que le bot vient d'y faire.
  186. bot, mentions = self.heavy_split_setup(late=True)
  187. await ChatBot.answer_mention(bot, mentions, late=mock.Mock())
  188. first, second = (call.args[1][0][0] for call in bot.claude.ask.await_args_list)
  189. self.assertNotIn("Réponse un", first)
  190. self.assertIn("membre1 : @Rika lis ça", second)
  191. self.assertIn("Rika : Réponse un", second)
  192. self.assertNotIn("membre2 : ", second)
  193. self.assertNotIn("après", second)
  194. self.assertLess(second.index("membre1 : @Rika lis ça"), second.index("Rika : Réponse un"))
  195. async def test_all_refused_no_call(self):
  196. bot = fake_bot()
  197. channel = fake_channel([])
  198. bot.mention_part = mock.AsyncMock(return_value=None)
  199. await ChatBot.answer_mention(bot, [fake_mention(1, "regarde", channel)])
  200. bot.claude.ask.assert_not_awaited()
  201. bot.send_answer.assert_not_awaited()
  202. if __name__ == "__main__":
  203. unittest.main()