test_text_utils.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239
  1. """Tests du découpage des messages et du retrait de la réflexion. Lancer depuis la racine du projet : python -m unittest"""
  2. import threading
  3. import unittest
  4. from bot.text_utils import DISCORD_LIMIT, split_message, strip_reasoning
  5. # Presque un message entier : la ligne suivante, même courte, passe au morceau d'après.
  6. FULL = "a" * 1990 + "\n" + "b" * 5
  7. def code_block(language: str, count: int) -> str:
  8. """Un bloc de code de `count` lignes numérotées, fermé."""
  9. lines = "\n".join(f"print('ligne {i} du bloc')" for i in range(count))
  10. return f"```{language}\n{lines}\n```"
  11. class SplitMessageTest(unittest.TestCase):
  12. def assert_valid(self, chunks: list[str], limit: int = DISCORD_LIMIT) -> None:
  13. """Aucun morceau vide, aucun morceau au-delà de la limite, et chaque bloc de code refermé."""
  14. for chunk in chunks:
  15. self.assertTrue(chunk.strip(), chunks)
  16. self.assertLessEqual(len(chunk), limit, chunk)
  17. fences = [line for line in chunk.split("\n") if line.lstrip().startswith("```")]
  18. self.assertEqual(len(fences) % 2, 0, chunk)
  19. def test_short_text_unchanged(self):
  20. self.assertEqual(split_message("Bonjour !"), ["Bonjour !"])
  21. self.assertEqual(split_message("a" * DISCORD_LIMIT), ["a" * DISCORD_LIMIT])
  22. def test_surrounding_spaces_removed(self):
  23. self.assertEqual(split_message(" \n Bonjour !\n\n"), ["Bonjour !"])
  24. def test_empty_text(self):
  25. self.assertEqual(split_message(""), [])
  26. self.assertEqual(split_message(" \n\n "), [])
  27. def test_cut_between_lines(self):
  28. lines = [f"Ligne numéro {i}, avec un peu de texte pour la remplir." for i in range(100)]
  29. chunks = split_message("\n".join(lines))
  30. self.assertGreater(len(chunks), 1)
  31. self.assert_valid(chunks)
  32. # Aucune ligne n'est coupée en deux : on retrouve exactement les lignes de départ.
  33. self.assertEqual([line for chunk in chunks for line in chunk.split("\n")], lines)
  34. def test_code_block_closed_and_reopened(self):
  35. text = "Voici le code :\n" + code_block("python", 150) + "\nEt voilà."
  36. chunks = split_message(text)
  37. self.assertGreater(len(chunks), 1)
  38. self.assert_valid(chunks)
  39. self.assertTrue(chunks[0].endswith("\n```"))
  40. for chunk in chunks[1:]:
  41. self.assertTrue(chunk.startswith("```python\n"), chunk[:30])
  42. # Une fois les ``` ajoutés retirés, le code est entier et dans l'ordre.
  43. code = [line for chunk in chunks for line in chunk.split("\n") if line.startswith("print(")]
  44. self.assertEqual(code, [f"print('ligne {i} du bloc')" for i in range(150)])
  45. def test_code_block_without_language(self):
  46. chunks = split_message(code_block("", 150))
  47. self.assert_valid(chunks)
  48. for chunk in chunks:
  49. self.assertTrue(chunk.startswith("```\nprint("), chunk[:30])
  50. def test_closed_block_not_closed_again(self):
  51. # Le bloc se ferme dans le dernier morceau : rien n'est ajouté après lui.
  52. text = FULL + "\n" + code_block("js", 3) + "\nFin du message."
  53. self.assertEqual(split_message(text), [FULL, code_block("js", 3) + "\nFin du message."])
  54. def test_text_ending_with_closed_block(self):
  55. text = FULL + "\n" + code_block("js", 3)
  56. self.assertEqual(split_message(text), [FULL, code_block("js", 3)])
  57. def test_unclosed_block_left_as_is(self):
  58. # Un bloc que le modèle n'a pas fermé ne l'est pas artificiellement dans le dernier morceau.
  59. text = FULL + "\n```python\nprint('oups')"
  60. self.assertEqual(split_message(text), [FULL, "```python\nprint('oups')"])
  61. def test_no_chunk_with_reopened_fence_only(self):
  62. # Le bloc se ferme juste au début d'un nouveau morceau : pas de morceau « ```python » seul.
  63. text = code_block("python", 150)
  64. chunks = split_message(text)
  65. self.assert_valid(chunks)
  66. self.assertNotIn("```python", chunks)
  67. def test_long_line_cut_at_space(self):
  68. words = [f"mot{i}" for i in range(1000)]
  69. chunks = split_message(" ".join(words))
  70. self.assertGreater(len(chunks), 1)
  71. self.assert_valid(chunks)
  72. # Aucun mot n'est coupé en deux.
  73. self.assertEqual([word for chunk in chunks for word in chunk.split()], words)
  74. def test_long_line_without_space_cut_clean(self):
  75. text = "x" * 5000
  76. chunks = split_message(text)
  77. self.assert_valid(chunks)
  78. self.assertEqual("".join(chunks), text)
  79. def test_space_too_early_ignored(self):
  80. # Le seul espace est tout au début : couper là ferait un morceau minuscule, la coupe est nette.
  81. text = "ab " + "x" * 3000
  82. chunks = split_message(text)
  83. self.assert_valid(chunks)
  84. self.assertGreater(len(chunks[0]), DISCORD_LIMIT // 2)
  85. self.assertEqual("".join(chunks).replace(" ", ""), text.replace(" ", ""))
  86. def test_long_line_inside_code_block(self):
  87. text = "```python\n" + "y" * 4500 + "\n```"
  88. chunks = split_message(text)
  89. self.assert_valid(chunks)
  90. for chunk in chunks:
  91. self.assertTrue(chunk.startswith("```python\n"), chunk[:30])
  92. self.assertTrue(chunk.endswith("\n```"), chunk[-30:])
  93. self.assertEqual("".join(c.removeprefix("```python\n").removesuffix("\n```") for c in chunks), "y" * 4500)
  94. def test_small_limit(self):
  95. text = ("Un peu de texte avant le code.\n" + code_block("python", 12) + "\n"
  96. + "un mot " * 30 + "\n" + "z" * 120 + "\nFin.")
  97. for limit in (40, 60, 100):
  98. with self.subTest(limit=limit):
  99. chunks = split_message(text, limit)
  100. self.assertGreater(len(chunks), 1)
  101. self.assert_valid(chunks, limit)
  102. def test_opening_fence_at_end_of_chunk(self):
  103. # La ligne d'ouverture d'un bloc (```py) qui remplit presque le morceau passe au morceau suivant,
  104. # au lieu de laisser un bloc vide « ```py\n``` » qui pourrait dépasser la limite.
  105. for before in range(1990, 2000):
  106. with self.subTest(before=before):
  107. text = "a" * before + "\n```py\n" + "b" * 10 + "\nfin\n```"
  108. chunks = split_message(text)
  109. self.assert_valid(chunks)
  110. self.assertFalse(chunks[0].endswith("```py\n```"), chunks[0][-20:])
  111. def test_closing_fence_at_end_of_chunk(self):
  112. # La ligne qui ferme un bloc (```) entre dans un morceau presque plein : elle n'a pas à garder
  113. # la place d'une fermeture artificielle, sinon le morceau suivant commence par un bloc vide.
  114. for last in range(980, 992):
  115. with self.subTest(last=last):
  116. text = "```py\n" + "a" * 1000 + "\n" + "b" * last + "\n```\nSuite du message."
  117. chunks = split_message(text)
  118. self.assert_valid(chunks)
  119. self.assertFalse(any(chunk.startswith("```py\n```") for chunk in chunks), chunks[-1][:20])
  120. def test_long_closing_fence_line(self):
  121. # Une ligne de fermeture trop longue est découpée : le bloc reste ouvert entre ses morceaux,
  122. # qui doivent donc garder la place de le refermer.
  123. for after in range(20, 60):
  124. with self.subTest(after=after):
  125. text = "```py\n" + "a" * 5 + "\n```" + "x" * after + " fin\nSuite."
  126. for chunk in split_message(text, 40):
  127. self.assertLessEqual(len(chunk), 40, chunk)
  128. def split_in_time(self, text: str, limit: int, delay: float = 2) -> list[str]:
  129. """Lance split_message dans un fil à part : une boucle infinie fait échouer le test au lieu de le bloquer.
  130. Le fil est « daemon » pour ne pas empêcher la fin des tests s'il ne s'arrête jamais."""
  131. result: list[list[str]] = []
  132. thread = threading.Thread(target=lambda: result.append(split_message(text, limit)), daemon=True)
  133. thread.start()
  134. thread.join(delay)
  135. if thread.is_alive():
  136. self.fail(f"split_message ne rend pas la main en {delay} s (limite {limit})")
  137. return result[0]
  138. def test_long_opening_fence_line(self):
  139. # Une ligne qui ouvre un bloc puis continue sur des centaines de caractères (« ```py » suivi de
  140. # texte) : seul « ```py » est rouvert au début des morceaux suivants, et le découpage se termine.
  141. for limit, size in ((DISCORD_LIMIT, 1000), (40, 23)):
  142. openings = {
  143. "un long mot": "```py " + "w" * (2 * size),
  144. "avec des espaces": "```py " + "w " * size,
  145. "sans aucun espace": "```" + "w" * (3 * size),
  146. }
  147. for name, opening in openings.items():
  148. with self.subTest(limit=limit, ouverture=name):
  149. chunks = self.split_in_time(opening + "\ncode\n```\nfin", limit)
  150. self.assert_valid(chunks, limit)
  151. self.assertTrue(any("code" in chunk.split("\n") for chunk in chunks), chunks)
  152. self.assertTrue(chunks[-1].endswith("fin"), chunks[-1])
  153. # Un bloc rouvert ne reprend que « ```py » (ou « ``` »), pas le texte de la ligne d'ouverture.
  154. for chunk in chunks[1:]:
  155. first = chunk.split("\n")[0]
  156. if first.startswith("```"):
  157. self.assertLessEqual(len(first), len("```py"), chunk)
  158. def test_limit_too_small_for_fence(self):
  159. # Une limite plus petite que « ```python » et sa fermeture ne peut pas être tenue, mais le
  160. # découpage doit quand même se terminer, sans rien perdre du code (la ligne « ```python » elle-même
  161. # est coupée lettre par lettre, d'où « python » devant).
  162. chunks = self.split_in_time("```python\n" + "abcdef" * 3 + "\n```", 8)
  163. self.assertEqual("".join(line for chunk in chunks for line in chunk.split("\n") if "`" not in line),
  164. "python" + "abcdef" * 3)
  165. class StripReasoningTest(unittest.TestCase):
  166. def test_think_block_at_start_removed(self):
  167. self.assertEqual(strip_reasoning("<think>Il faut être poli.</think>\n\nBonjour !"), "Bonjour !")
  168. def test_other_tags_removed(self):
  169. for tag in ("think", "thinking", "reasoning", "function_calls", "invoke"):
  170. with self.subTest(tag=tag):
  171. self.assertEqual(strip_reasoning(f"<{tag}>réflexion</{tag}>Réponse"), "Réponse")
  172. def test_tag_with_attributes_and_case(self):
  173. text = '<INVOKE name="web_search">\n<parameter>météo</parameter>\n</invoke> Il fait beau.'
  174. self.assertEqual(strip_reasoning(text), "Il fait beau.")
  175. def test_multiline_block_and_leading_spaces(self):
  176. self.assertEqual(strip_reasoning("\n <thinking>\nune\ndeux\n</thinking>\nSalut"), "Salut")
  177. def test_several_blocks_at_start(self):
  178. text = "<think>un</think>\n<function_calls>appel</function_calls>\nRéponse"
  179. self.assertEqual(strip_reasoning(text), "Réponse")
  180. def test_tag_later_in_text_kept(self):
  181. text = "Les modèles écrivent parfois <think>ceci</think> avant de répondre."
  182. self.assertEqual(strip_reasoning(text), text)
  183. def test_tag_in_code_kept(self):
  184. text = "Exemple :\n```xml\n<thinking>texte</thinking>\n```"
  185. self.assertEqual(strip_reasoning(text), text)
  186. def test_unclosed_block_kept(self):
  187. # Sans balise de fin, rien n'est retiré : on ne sait pas où la réflexion s'arrête.
  188. self.assertEqual(strip_reasoning("<think>réflexion sans fin"), "<think>réflexion sans fin")
  189. def test_mismatched_tags_kept(self):
  190. self.assertEqual(strip_reasoning("<think>a</thinking> b"), "<think>a</thinking> b")
  191. def test_only_reasoning(self):
  192. self.assertEqual(strip_reasoning("<think>rien d'autre</think> "), "")
  193. def test_plain_text_stripped(self):
  194. self.assertEqual(strip_reasoning(" Bonjour !\n"), "Bonjour !")
  195. if __name__ == "__main__":
  196. unittest.main()