소스 검색

Tests de la mémoire des conversations et du découpage des messages

tests/test_history.py couvre ConversationHistory : échanges et rechargement
depuis le fichier, coupe de MAX_HISTORY_LENGTH et sa marge, messages modifiés
et supprimés (et leurs citations), reset, fichier illisible mis de côté sans
écraser les copies précédentes, conversion des formats v1 (Mistral) et v2 avec
copie de l'ancien fichier ; ainsi que render_line et images_marker.

tests/test_text_utils.py couvre split_message et strip_reasoning.
test_opening_fence_at_end_of_chunk est marqué expectedFailure : un morceau qui
se termine par la ligne d'ouverture d'un bloc de code peut dépasser la limite
de 3 caractères et finir par un bloc vide.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Johnounet 7 시간 전
부모
커밋
de28959dbb
2개의 변경된 파일과 622개의 추가작업 그리고 0개의 파일을 삭제
  1. 442 0
      tests/test_history.py
  2. 180 0
      tests/test_text_utils.py

+ 442 - 0
tests/test_history.py

@@ -0,0 +1,442 @@
+"""Tests de la mémoire des conversations. Lancer depuis la racine du projet : python -m unittest"""
+
+import json
+import tempfile
+import unittest
+from datetime import datetime
+from pathlib import Path
+from unittest import mock
+
+from bot.history import (DELETED_MARKER, DELETED_REPLY, EDITED_MARKER, FILE_VERSION, IMAGE_MARKER, WEB_MARKER,
+                         ConversationHistory, images_marker, render_line)
+
+CHANNEL = 123
+DATE = "lundi 2 novembre 2026 à 14:05"
+# Heure figée pour les copies de secours : leur nom ne dépend pas de l'heure de la machine.
+NOW = datetime(2026, 1, 2, 3, 4, 5)
+STAMP = "2026-01-02_03-04-05"
+
+
+def line(message_id: int, content: str, author: str = "Bob", **extra) -> dict:
+    """Une ligne de la mémoire pour un message Discord, comme le bot la construit."""
+    return {"id": message_id, "date": DATE, "author": author, "content": content, **extra}
+
+
+def said(content: str, author: str = "Bob", **extra) -> str:
+    """Texte rendu d'une ligne simple."""
+    return render_line({"date": DATE, "author": author, "content": content, **extra})
+
+
+class HistoryTestCase(unittest.IsolatedAsyncioTestCase):
+    def setUp(self):
+        self._tmp = tempfile.TemporaryDirectory()
+        self.root = Path(self._tmp.name)
+        self.path = self.root / "conversation_history.json"
+        patcher = mock.patch("bot.history.datetime")
+        self.addCleanup(patcher.stop)
+        patcher.start().now.return_value = NOW
+
+    def tearDown(self):
+        self._tmp.cleanup()
+
+    def load(self, max_length: int = 10) -> ConversationHistory:
+        return ConversationHistory(self.path, max_length)
+
+    def write(self, raw) -> bytes:
+        """Écrit un fichier d'historique (objet JSON ou texte brut) et renvoie son contenu exact."""
+        text = raw if isinstance(raw, str) else json.dumps(raw, ensure_ascii=False)
+        self.path.write_text(text, encoding="utf-8")
+        return self.path.read_bytes()
+
+    def other_files(self) -> list[str]:
+        """Noms des fichiers du dossier, à part l'historique lui-même."""
+        return sorted(p.name for p in self.root.iterdir() if p != self.path)
+
+    async def exchanges(self, history: ConversationHistory, count: int, start: int = 0) -> None:
+        for i in range(start, start + count):
+            await history.add_exchange(CHANNEL, [line(i, f"question {i}")], f"réponse {i}")
+
+
+class ExchangeTest(HistoryTestCase):
+    async def test_get_format(self):
+        history = self.load()
+        await history.add_exchange(CHANNEL, [line(1, "Salut"), line(2, "ça va ?", author="Alice")], "Oui !")
+        self.assertEqual(history.get(CHANNEL), [
+            {"role": "user", "content": f"{DATE}, Bob a écrit : Salut\n{DATE}, Alice a écrit : ça va ?"},
+            {"role": "assistant", "content": "Oui !"},
+        ])
+
+    async def test_web_search_marked(self):
+        history = self.load()
+        await history.add_exchange(CHANNEL, [line(1, "Quel temps fait-il ?")], "Il pleut.", web_used=True)
+        self.assertEqual(history.get(CHANNEL)[0]["content"], f"{said('Quel temps fait-il ?')} {WEB_MARKER}")
+
+    async def test_unknown_channel_empty(self):
+        self.assertEqual(self.load().get(CHANNEL), [])
+        self.assertFalse(self.path.exists())
+
+    async def test_channel_id_as_text_or_number(self):
+        history = self.load()
+        await history.add_exchange(CHANNEL, [line(1, "Salut")], "Bonjour")
+        self.assertEqual(history.get(str(CHANNEL)), history.get(CHANNEL))
+
+    async def test_channels_kept_apart(self):
+        history = self.load()
+        await history.add_exchange(1, [line(1, "dans le salon 1")], "réponse 1")
+        await history.add_exchange(2, [line(2, "dans le salon 2")], "réponse 2")
+        self.assertEqual(history.get(1)[1]["content"], "réponse 1")
+        self.assertEqual(history.get(2)[1]["content"], "réponse 2")
+
+    async def test_reloaded_from_file(self):
+        history = self.load()
+        await history.add_exchange(CHANNEL, [line(1, "Salut", attachments=[IMAGE_MARKER])], "Bonjour")
+        await history.add_exchange(CHANNEL, [line(2, "Et la météo ?", reply_to=1, reply="en réponse à Bob : Salut")],
+                                   "Il pleut.", web_used=True)
+        await history.add_exchange(42, [{"id": 3, "date": DATE, "author": "Alice", "forward": "de #général : coucou"}],
+                                   "Merci")
+        await history.edit(CHANNEL, 1, "Salut à tous")
+        await history.delete(CHANNEL, {2})
+
+        reloaded = self.load()
+        self.assertEqual(reloaded.get(CHANNEL), history.get(CHANNEL))
+        self.assertEqual(reloaded.get(42), history.get(42))
+        self.assertEqual(json.loads(self.path.read_text(encoding="utf-8"))["version"], FILE_VERSION)
+        self.assertEqual(self.other_files(), [])  # ni copie, ni fichier temporaire
+
+
+class TrimTest(HistoryTestCase):
+    """MAX_HISTORY_LENGTH : au moins N messages gardés, jusqu'à la moitié de plus avant d'y revenir d'un coup."""
+
+    async def test_grows_then_cut_back(self):
+        history = self.load(max_length=10)  # marge de 4 messages (la moitié, en échanges entiers)
+        await self.exchanges(history, 7)
+        messages = history.get(CHANNEL)
+        self.assertEqual(len(messages), 14)
+        self.assertEqual(messages[0]["content"], said("question 0"))  # le début n'a pas bougé
+
+        await self.exchanges(history, 1, start=7)
+        messages = history.get(CHANNEL)
+        self.assertEqual(len(messages), 10)
+        self.assertEqual(messages[0], {"role": "user", "content": said("question 3")})
+        self.assertEqual(messages[-1], {"role": "assistant", "content": "réponse 7"})
+
+    async def test_margin_for_each_length(self):
+        # La marge est la moitié de N, arrondie à un nombre pair de messages, et au moins un échange.
+        for max_length, margin in ((2, 2), (4, 2), (6, 2), (8, 4), (10, 4), (20, 10)):
+            with self.subTest(max_length=max_length):
+                self.path.unlink(missing_ok=True)
+                history = self.load(max_length)
+                sizes = []
+                for i in range(3 * (max_length + margin)):
+                    await self.exchanges(history, 1, start=i)
+                    sizes.append(len(history.get(CHANNEL)))
+                self.assertEqual(max(sizes), max_length + margin)
+                first_cut = sizes.index(max_length + margin) + 1
+                self.assertEqual(sizes[first_cut], max_length)
+                self.assertGreaterEqual(min(sizes[first_cut:]), max_length)
+
+    async def test_trimmed_on_load(self):
+        history = self.load(max_length=20)
+        await self.exchanges(history, 10)
+        messages = self.load(max_length=10).get(CHANNEL)
+        self.assertEqual(messages, history.get(CHANNEL)[-10:])
+
+    async def test_starts_with_user_message(self):
+        # Un fichier dont le salon commence par une réponse du bot : elle est retirée au chargement.
+        self.write({"version": FILE_VERSION, "channels": {str(CHANNEL): [
+            {"role": "assistant", "content": "réponse orpheline"},
+            {"role": "user", "lines": [line(1, "Salut")]},
+            {"role": "assistant", "content": "Bonjour"},
+        ]}})
+        self.assertEqual(self.load().get(CHANNEL), [
+            {"role": "user", "content": said("Salut")}, {"role": "assistant", "content": "Bonjour"}])
+
+
+class EditTest(HistoryTestCase):
+    async def asyncSetUp(self):
+        self.history = self.load()
+        await self.history.add_exchange(CHANNEL, [line(1, "Salut"), line(2, "Ça va ?")], "Oui !")
+
+    async def test_edited_message(self):
+        self.assertTrue(await self.history.edit(CHANNEL, 1, "Salut à tous"))
+        expected = f"{said('Salut à tous')} {EDITED_MARKER}\n{said('Ça va ?')}"
+        self.assertEqual(self.history.get(CHANNEL)[0]["content"], expected)
+        self.assertEqual(self.load().get(CHANNEL)[0]["content"], expected)  # sauvegardé
+
+    async def test_same_text_not_marked(self):
+        # Le texte n'a pas changé (une pièce jointe retirée, par exemple) : rien n'est marqué.
+        self.assertFalse(await self.history.edit(CHANNEL, 1, "Salut"))
+        self.assertNotIn(EDITED_MARKER, self.history.get(CHANNEL)[0]["content"])
+
+    async def test_unknown_message(self):
+        before = self.path.read_bytes()
+        self.assertFalse(await self.history.edit(CHANNEL, 999, "nouveau"))
+        self.assertEqual(self.path.read_bytes(), before)
+
+    async def test_unknown_channel(self):
+        self.assertFalse(await self.history.edit(456, 1, "nouveau"))
+        self.assertEqual(self.history.get(456), [])
+
+    async def test_deleted_message_not_edited(self):
+        await self.history.delete(CHANNEL, {1})
+        self.assertFalse(await self.history.edit(CHANNEL, 1, "revenu"))
+        self.assertNotIn("revenu", self.history.get(CHANNEL)[0]["content"])
+
+    async def test_forward_not_edited(self):
+        await self.history.add_exchange(CHANNEL, [{"id": 3, "date": DATE, "author": "Bob", "forward": "de #a : b"}],
+                                        "Vu")
+        self.assertFalse(await self.history.edit(CHANNEL, 3, "texte"))
+
+
+class DeleteTest(HistoryTestCase):
+    async def asyncSetUp(self):
+        self.history = self.load()
+        await self.history.add_exchange(CHANNEL, [line(1, "Mon secret", attachments=[IMAGE_MARKER])], "Noté !")
+        await self.history.add_exchange(
+            CHANNEL, [line(2, "Tu as vu ?", author="Alice", reply_to=1, reply="en réponse à Bob : Mon secret")],
+            "Oui")
+
+    async def test_deleted_message(self):
+        self.assertTrue(await self.history.delete(CHANNEL, {1}))
+        messages = self.history.get(CHANNEL)
+        self.assertEqual(messages[0]["content"], f"{DATE}, Bob a écrit : {DELETED_MARKER}")
+        self.assertEqual(messages[1]["content"], "Noté !")  # la réponse du bot reste
+
+    async def test_reply_to_deleted_message(self):
+        await self.history.delete(CHANNEL, {1})
+        self.assertEqual(self.history.get(CHANNEL)[2]["content"],
+                         f"{DATE}, Alice a écrit ({DELETED_REPLY}) : Tu as vu ?")
+
+    async def test_nothing_left_in_file(self):
+        await self.history.delete(CHANNEL, {1})
+        text = self.path.read_text(encoding="utf-8")
+        self.assertNotIn("secret", text)
+        self.assertNotIn(IMAGE_MARKER, text)
+        self.assertEqual(self.load().get(CHANNEL), self.history.get(CHANNEL))
+
+    async def test_several_at_once(self):
+        self.assertTrue(await self.history.delete(CHANNEL, {1, 2}))
+        messages = self.history.get(CHANNEL)
+        self.assertEqual(messages[0]["content"], f"{DATE}, Bob a écrit : {DELETED_MARKER}")
+        self.assertEqual(messages[2]["content"], f"{DATE}, Alice a écrit : {DELETED_MARKER}")
+
+    async def test_already_deleted(self):
+        await self.history.delete(CHANNEL, {1})
+        before = self.path.read_bytes()
+        self.assertFalse(await self.history.delete(CHANNEL, {1}))
+        self.assertEqual(self.path.read_bytes(), before)
+
+    async def test_unknown_message(self):
+        self.assertFalse(await self.history.delete(CHANNEL, {999}))
+
+    async def test_unknown_channel(self):
+        self.assertFalse(await self.history.delete(456, {1}))
+        self.assertEqual(self.history.get(456), [])
+
+
+class ResetTest(HistoryTestCase):
+    async def test_channel_emptied(self):
+        history = self.load()
+        await history.add_exchange(CHANNEL, [line(1, "Salut")], "Bonjour")
+        await history.add_exchange(456, [line(2, "Ailleurs")], "Ici")
+        await history.reset(CHANNEL)
+        self.assertEqual(history.get(CHANNEL), [])
+        self.assertEqual(len(history.get(456)), 2)  # les autres salons ne bougent pas
+        reloaded = self.load()
+        self.assertEqual(reloaded.get(CHANNEL), [])
+        self.assertEqual(len(reloaded.get(456)), 2)
+
+
+class BrokenFileTest(HistoryTestCase):
+    """Fichier illisible : mis de côté sous un nom daté, et le bot repart d'une mémoire vide."""
+
+    backup_name = f"conversation_history.json.corrompu-{STAMP}"
+
+    async def assert_set_aside(self, content: bytes) -> None:
+        with self.assertLogs("chatbot.history", "ERROR"):
+            history = self.load()
+        self.assertEqual(history.get(CHANNEL), [])
+        self.assertFalse(self.path.exists())
+        backup = self.root / self.backup_name
+        self.assertEqual(backup.read_bytes(), content)
+        # La sauvegarde suivante écrit un nouveau fichier, sans toucher à la copie.
+        await history.add_exchange(CHANNEL, [line(1, "Salut")], "Bonjour")
+        self.assertEqual(json.loads(self.path.read_text(encoding="utf-8"))["version"], FILE_VERSION)
+        self.assertEqual(backup.read_bytes(), content)
+
+    async def test_invalid_json(self):
+        text = json.dumps({"version": FILE_VERSION, "channels": {str(CHANNEL): []}})
+        await self.assert_set_aside(self.write(text[:-5]))
+
+    async def test_wrong_encoding(self):
+        self.path.write_bytes('{"version": 3, "channels": {"123": [{"role": "user", "content": "été"}]}}'
+                              .encode("latin-1"))
+        await self.assert_set_aside(self.path.read_bytes())
+
+    async def test_not_a_dictionary(self):
+        await self.assert_set_aside(self.write([1, 2, 3]))
+
+    async def test_channels_not_a_dictionary(self):
+        await self.assert_set_aside(self.write({"version": FILE_VERSION, "channels": ["123"]}))
+
+    async def test_older_copy_kept(self):
+        older = self.root / "conversation_history.json.corrompu-2025-12-31_23-59-59"
+        older.write_text("ancienne copie", encoding="utf-8")
+        await self.assert_set_aside(self.write("pas du JSON"))
+        self.assertEqual(older.read_text(encoding="utf-8"), "ancienne copie")
+        self.assertEqual(self.other_files(), [older.name, self.backup_name])
+
+
+class OldFormatTest(HistoryTestCase):
+    """Fichiers des versions précédentes : convertis, et une copie de l'ancien est gardée."""
+
+    def assert_old_copy(self, version: int, content: bytes) -> None:
+        self.assertEqual(self.other_files(), [f"conversation_history.json.v{version}-{STAMP}"])
+        self.assertEqual((self.root / self.other_files()[0]).read_bytes(), content)
+        self.assertEqual(self.path.read_bytes(), content)  # rien n'est réécrit avant la prochaine sauvegarde
+
+    async def test_version_2(self):
+        content = self.write({"version": 2, "channels": {str(CHANNEL): [
+            {"role": "user", "content": f"{DATE}, Bob a écrit : Regarde [image jointe]"},
+            {"role": "assistant", "content": "<think>C'est un chat.</think>\nJoli chat !"},
+        ]}})
+        with self.assertLogs("chatbot.history", "INFO"):
+            history = self.load()
+        self.assertEqual(history.get(CHANNEL), [
+            {"role": "user", "content": f"{DATE}, Bob a écrit : Regarde {IMAGE_MARKER}"},
+            {"role": "assistant", "content": "Joli chat !"},
+        ])
+        self.assert_old_copy(2, content)
+
+    async def test_mistral_format(self):
+        # Premier format, sans numéro de version : un dictionnaire par salon, contenu en blocs.
+        content = self.write({str(CHANNEL): {"messages": [
+            {"role": "system", "content": "Tu es Rika."},
+            {"role": "user", "content": [
+                {"type": "text", "text": "Bob : regarde"},
+                {"type": "image_url", "image_url": {"url": "https://cdn.discordapp.com/chat.png"}},
+            ]},
+            {"role": "assistant", "content": [{"type": "text", "text": "Joli chat !"}]},
+        ]}})
+        with self.assertLogs("chatbot.history", "INFO"):
+            history = self.load()
+        self.assertEqual(history.get(CHANNEL), [
+            {"role": "user", "content": f"Bob : regarde {IMAGE_MARKER}"},
+            {"role": "assistant", "content": "Joli chat !"},
+        ])
+        self.assert_old_copy(1, content)
+
+    async def test_rendered_text_unchanged(self):
+        # Le texte envoyé à Claude reste le même après la conversion : le cache de prompt est conservé.
+        user = f"{DATE}, Bob a écrit : Quel temps fait-il ? {IMAGE_MARKER} {WEB_MARKER}"
+        self.write({"version": 2, "channels": {str(CHANNEL): [
+            {"role": "user", "content": user}, {"role": "assistant", "content": "Il pleut."}]}})
+        with self.assertLogs("chatbot.history", "INFO"):
+            history = self.load()
+        self.assertEqual(history.get(CHANNEL), [
+            {"role": "user", "content": user}, {"role": "assistant", "content": "Il pleut."}])
+
+    async def test_same_role_merged(self):
+        self.write({"version": 2, "channels": {str(CHANNEL): [
+            {"role": "user", "content": "un"}, {"role": "user", "content": "deux"},
+            {"role": "assistant", "content": "trois"}, {"role": "assistant", "content": "quatre"},
+        ]}})
+        with self.assertLogs("chatbot.history", "INFO"):
+            history = self.load()
+        self.assertEqual(history.get(CHANNEL), [
+            {"role": "user", "content": "un\ndeux"}, {"role": "assistant", "content": "trois\nquatre"}])
+
+    async def test_unreadable_messages_dropped(self):
+        self.write({"version": 2, "channels": {str(CHANNEL): [
+            "pas un message", {"role": "tool", "content": "résultat"}, {"role": "user", "content": "  "},
+            {"role": "user", "content": None}, {"role": "user", "content": [{"type": "audio"}, "bloc"]},
+            {"role": "user", "content": "Salut"},
+            {"role": "assistant", "content": "<think>rien d'autre</think>"},
+            {"role": "assistant", "content": "Bonjour"},
+        ], "456": "pas une liste"}})
+        with self.assertLogs("chatbot.history", "INFO"):
+            history = self.load()
+        self.assertEqual(history.get(CHANNEL), [
+            {"role": "user", "content": "Salut"}, {"role": "assistant", "content": "Bonjour"}])
+        self.assertEqual(history.get(456), [])
+
+    async def test_converted_file_saved_in_current_format(self):
+        self.write({"version": 2, "channels": {str(CHANNEL): [
+            {"role": "user", "content": "Salut"}, {"role": "assistant", "content": "Bonjour"}]}})
+        with self.assertLogs("chatbot.history", "INFO"):
+            history = self.load()
+        await history.add_exchange(CHANNEL, [line(1, "Encore moi")], "Re")
+        self.assertEqual(json.loads(self.path.read_text(encoding="utf-8"))["version"], FILE_VERSION)
+        with self.assertNoLogs("chatbot.history"):
+            reloaded = self.load()  # plus de conversion, ni de nouvelle copie
+        self.assertEqual(reloaded.get(CHANNEL), history.get(CHANNEL))
+        self.assertEqual(len(self.other_files()), 1)
+
+    async def test_current_format_not_copied(self):
+        self.write({"version": FILE_VERSION, "channels": {str(CHANNEL): [
+            {"role": "user", "lines": [line(1, "Salut")]}, {"role": "assistant", "content": "Bonjour"}]}})
+        with self.assertNoLogs("chatbot.history"):
+            history = self.load()
+        self.assertEqual(len(history.get(CHANNEL)), 2)
+        self.assertEqual(self.other_files(), [])
+
+    async def test_unreadable_lines_dropped(self):
+        # Lignes abîmées dans un fichier au format actuel : seules les lignes lisibles restent.
+        self.write({"version": FILE_VERSION, "channels": {str(CHANNEL): [
+            {"role": "user", "lines": ["texte", {"author": "Bob", "content": "sans date"}, {"content": ""}]},
+            {"role": "user", "lines": [line(1, "Salut"), {"id": 2, "date": DATE, "author": "Bob"}]},
+            {"role": "user", "lines": [line(3, "Re")], "web": True},
+            {"role": "assistant", "content": "Bonjour"},
+        ]}})
+        self.assertEqual(self.load().get(CHANNEL), [
+            {"role": "user", "content": f"{said('Salut')}\n{said('Re')} {WEB_MARKER}"},
+            {"role": "assistant", "content": "Bonjour"},
+        ])
+
+
+class RenderLineTest(unittest.TestCase):
+    def test_bot_note(self):
+        self.assertEqual(render_line({"content": "[le bot a changé de statut]"}), "[le bot a changé de statut]")
+
+    def test_message(self):
+        self.assertEqual(render_line(line(1, "Salut")), f"{DATE}, Bob a écrit : Salut")
+
+    def test_reply(self):
+        self.assertEqual(render_line(line(1, "Oui", reply_to=9, reply="en réponse à Alice : Ça va ?")),
+                         f"{DATE}, Bob a écrit (en réponse à Alice : Ça va ?) : Oui")
+        self.assertEqual(render_line(line(1, "Oui", reply="")), f"{DATE}, Bob a écrit : Oui")
+
+    def test_forward(self):
+        forward = {"id": 1, "date": DATE, "author": "Bob", "forward": "de Alice dans #général : coucou"}
+        self.assertEqual(render_line(forward), f"{DATE}, Bob a transféré un message de Alice dans #général : coucou")
+
+    def test_edited(self):
+        self.assertEqual(render_line(line(1, "Salut", edited=True)), f"{DATE}, Bob a écrit : Salut {EDITED_MARKER}")
+
+    def test_deleted(self):
+        deleted = {"id": 1, "date": DATE, "author": "Bob", "deleted": True}
+        self.assertEqual(render_line(deleted), f"{DATE}, Bob a écrit : {DELETED_MARKER}")
+
+    def test_attachments(self):
+        rendered = render_line(line(1, "Regarde", edited=True, attachments=[IMAGE_MARKER, "[fichier notes.txt]"]))
+        self.assertEqual(rendered, f"{DATE}, Bob a écrit : Regarde {EDITED_MARKER} {IMAGE_MARKER} [fichier notes.txt]")
+
+    def test_forward_with_attachments(self):
+        forward = {"id": 1, "date": DATE, "author": "Bob", "forward": "de #a : vu", "attachments": [IMAGE_MARKER]}
+        self.assertEqual(render_line(forward), f"{DATE}, Bob a transféré un message de #a : vu {IMAGE_MARKER}")
+
+
+class ImagesMarkerTest(unittest.TestCase):
+    def test_one_image(self):
+        self.assertEqual(images_marker(1), IMAGE_MARKER)
+
+    def test_several_images(self):
+        for count in (2, 5):
+            with self.subTest(count=count):
+                marker = images_marker(count)
+                self.assertTrue(marker.startswith(f"[{count} images jointes : "), marker)
+                self.assertIn("elles ne sont plus conservées", marker)
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 180 - 0
tests/test_text_utils.py

@@ -0,0 +1,180 @@
+"""Tests du découpage des messages et du retrait de la réflexion. Lancer depuis la racine du projet : python -m unittest"""
+
+import unittest
+
+from bot.text_utils import DISCORD_LIMIT, split_message, strip_reasoning
+
+# Presque un message entier : la ligne suivante, même courte, passe au morceau d'après.
+FULL = "a" * 1990 + "\n" + "b" * 5
+
+
+def code_block(language: str, count: int) -> str:
+    """Un bloc de code de `count` lignes numérotées, fermé."""
+    lines = "\n".join(f"print('ligne {i} du bloc')" for i in range(count))
+    return f"```{language}\n{lines}\n```"
+
+
+class SplitMessageTest(unittest.TestCase):
+    def assert_valid(self, chunks: list[str], limit: int = DISCORD_LIMIT) -> None:
+        """Aucun morceau vide, aucun morceau au-delà de la limite, et chaque bloc de code refermé."""
+        for chunk in chunks:
+            self.assertTrue(chunk.strip(), chunks)
+            self.assertLessEqual(len(chunk), limit, chunk)
+            fences = [line for line in chunk.split("\n") if line.lstrip().startswith("```")]
+            self.assertEqual(len(fences) % 2, 0, chunk)
+
+    def test_short_text_unchanged(self):
+        self.assertEqual(split_message("Bonjour !"), ["Bonjour !"])
+        self.assertEqual(split_message("a" * DISCORD_LIMIT), ["a" * DISCORD_LIMIT])
+
+    def test_surrounding_spaces_removed(self):
+        self.assertEqual(split_message("  \n Bonjour !\n\n"), ["Bonjour !"])
+
+    def test_empty_text(self):
+        self.assertEqual(split_message(""), [])
+        self.assertEqual(split_message(" \n\n "), [])
+
+    def test_cut_between_lines(self):
+        lines = [f"Ligne numéro {i}, avec un peu de texte pour la remplir." for i in range(100)]
+        chunks = split_message("\n".join(lines))
+        self.assertGreater(len(chunks), 1)
+        self.assert_valid(chunks)
+        # Aucune ligne n'est coupée en deux : on retrouve exactement les lignes de départ.
+        self.assertEqual([line for chunk in chunks for line in chunk.split("\n")], lines)
+
+    def test_code_block_closed_and_reopened(self):
+        text = "Voici le code :\n" + code_block("python", 150) + "\nEt voilà."
+        chunks = split_message(text)
+        self.assertGreater(len(chunks), 1)
+        self.assert_valid(chunks)
+        self.assertTrue(chunks[0].endswith("\n```"))
+        for chunk in chunks[1:]:
+            self.assertTrue(chunk.startswith("```python\n"), chunk[:30])
+        # Une fois les ``` ajoutés retirés, le code est entier et dans l'ordre.
+        code = [line for chunk in chunks for line in chunk.split("\n") if line.startswith("print(")]
+        self.assertEqual(code, [f"print('ligne {i} du bloc')" for i in range(150)])
+
+    def test_code_block_without_language(self):
+        chunks = split_message(code_block("", 150))
+        self.assert_valid(chunks)
+        for chunk in chunks:
+            self.assertTrue(chunk.startswith("```\nprint("), chunk[:30])
+
+    def test_closed_block_not_closed_again(self):
+        # Le bloc se ferme dans le dernier morceau : rien n'est ajouté après lui.
+        text = FULL + "\n" + code_block("js", 3) + "\nFin du message."
+        self.assertEqual(split_message(text), [FULL, code_block("js", 3) + "\nFin du message."])
+
+    def test_text_ending_with_closed_block(self):
+        text = FULL + "\n" + code_block("js", 3)
+        self.assertEqual(split_message(text), [FULL, code_block("js", 3)])
+
+    def test_unclosed_block_left_as_is(self):
+        # Un bloc que le modèle n'a pas fermé ne l'est pas artificiellement dans le dernier morceau.
+        text = FULL + "\n```python\nprint('oups')"
+        self.assertEqual(split_message(text), [FULL, "```python\nprint('oups')"])
+
+    def test_no_chunk_with_reopened_fence_only(self):
+        # Le bloc se ferme juste au début d'un nouveau morceau : pas de morceau « ```python » seul.
+        text = code_block("python", 150)
+        chunks = split_message(text)
+        self.assert_valid(chunks)
+        self.assertNotIn("```python", chunks)
+
+    def test_long_line_cut_at_space(self):
+        words = [f"mot{i}" for i in range(1000)]
+        chunks = split_message(" ".join(words))
+        self.assertGreater(len(chunks), 1)
+        self.assert_valid(chunks)
+        # Aucun mot n'est coupé en deux.
+        self.assertEqual([word for chunk in chunks for word in chunk.split()], words)
+
+    def test_long_line_without_space_cut_clean(self):
+        text = "x" * 5000
+        chunks = split_message(text)
+        self.assert_valid(chunks)
+        self.assertEqual("".join(chunks), text)
+
+    def test_space_too_early_ignored(self):
+        # Le seul espace est tout au début : couper là ferait un morceau minuscule, la coupe est nette.
+        text = "ab " + "x" * 3000
+        chunks = split_message(text)
+        self.assert_valid(chunks)
+        self.assertGreater(len(chunks[0]), DISCORD_LIMIT // 2)
+        self.assertEqual("".join(chunks).replace(" ", ""), text.replace(" ", ""))
+
+    def test_long_line_inside_code_block(self):
+        text = "```python\n" + "y" * 4500 + "\n```"
+        chunks = split_message(text)
+        self.assert_valid(chunks)
+        for chunk in chunks:
+            self.assertTrue(chunk.startswith("```python\n"), chunk[:30])
+            self.assertTrue(chunk.endswith("\n```"), chunk[-30:])
+        self.assertEqual("".join(c.removeprefix("```python\n").removesuffix("\n```") for c in chunks), "y" * 4500)
+
+    def test_small_limit(self):
+        text = ("Un peu de texte avant le code.\n" + code_block("python", 12) + "\n"
+                + "un mot " * 30 + "\n" + "z" * 120 + "\nFin.")
+        for limit in (40, 60, 100):
+            with self.subTest(limit=limit):
+                chunks = split_message(text, limit)
+                self.assertGreater(len(chunks), 1)
+                self.assert_valid(chunks, limit)
+
+    @unittest.expectedFailure
+    def test_opening_fence_at_end_of_chunk(self):
+        # BUG : quand la ligne d'ouverture d'un bloc (```py) remplit le morceau à 1, 2 ou 3 caractères
+        # près, le morceau se termine par un bloc vide « ```py\n``` » qui dépasse la limite de 3 caractères
+        # au plus : Discord refuserait ce message. La ligne d'ouverture devrait passer au morceau suivant.
+        for before in range(1990, 2000):
+            with self.subTest(before=before):
+                text = "a" * before + "\n```py\n" + "b" * 10 + "\nfin\n```"
+                chunks = split_message(text)
+                self.assert_valid(chunks)
+                self.assertFalse(chunks[0].endswith("```py\n```"), chunks[0][-20:])
+
+
+class StripReasoningTest(unittest.TestCase):
+    def test_think_block_at_start_removed(self):
+        self.assertEqual(strip_reasoning("<think>Il faut être poli.</think>\n\nBonjour !"), "Bonjour !")
+
+    def test_other_tags_removed(self):
+        for tag in ("think", "thinking", "reasoning", "function_calls", "invoke"):
+            with self.subTest(tag=tag):
+                self.assertEqual(strip_reasoning(f"<{tag}>réflexion</{tag}>Réponse"), "Réponse")
+
+    def test_tag_with_attributes_and_case(self):
+        text = '<INVOKE name="web_search">\n<parameter>météo</parameter>\n</invoke> Il fait beau.'
+        self.assertEqual(strip_reasoning(text), "Il fait beau.")
+
+    def test_multiline_block_and_leading_spaces(self):
+        self.assertEqual(strip_reasoning("\n  <thinking>\nune\ndeux\n</thinking>\nSalut"), "Salut")
+
+    def test_several_blocks_at_start(self):
+        text = "<think>un</think>\n<function_calls>appel</function_calls>\nRéponse"
+        self.assertEqual(strip_reasoning(text), "Réponse")
+
+    def test_tag_later_in_text_kept(self):
+        text = "Les modèles écrivent parfois <think>ceci</think> avant de répondre."
+        self.assertEqual(strip_reasoning(text), text)
+
+    def test_tag_in_code_kept(self):
+        text = "Exemple :\n```xml\n<thinking>texte</thinking>\n```"
+        self.assertEqual(strip_reasoning(text), text)
+
+    def test_unclosed_block_kept(self):
+        # Sans balise de fin, rien n'est retiré : on ne sait pas où la réflexion s'arrête.
+        self.assertEqual(strip_reasoning("<think>réflexion sans fin"), "<think>réflexion sans fin")
+
+    def test_mismatched_tags_kept(self):
+        self.assertEqual(strip_reasoning("<think>a</thinking> b"), "<think>a</thinking> b")
+
+    def test_only_reasoning(self):
+        self.assertEqual(strip_reasoning("<think>rien d'autre</think>  "), "")
+
+    def test_plain_text_stripped(self):
+        self.assertEqual(strip_reasoning("  Bonjour !\n"), "Bonjour !")
+
+
+if __name__ == "__main__":
+    unittest.main()