Selaa lähdekoodia

Tests du statut Discord, des pièces jointes, des emojis et des modèles

Nouveaux tests, sans changement du code :
- tests/test_presence.py : nuit (PRESENCE_NIGHT), prochain passage à une heure
  (changements d'heure compris), durées affichées, sauvegarde des statuts, et
  reprise après un redémarrage (presence_state.json) avec une heure figée.
- tests/test_attachments_prep.py : préparation des images (réduction,
  conversion, compression), vérification des PDF, décodage des fichiers texte.
- tests/test_emojis_models.py : emojis du serveur (:nom: <-> code Discord) et
  noms des modèles Claude.

Un test est marqué expectedFailure : une date sans fuseau dans
presence_state.json fait planter restore au démarrage (TypeError hors du try).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Johnounet 7 tuntia sitten
vanhempi
sitoutus
ef4ec44da8
3 muutettua tiedostoa jossa 674 lisäystä ja 0 poistoa
  1. 197 0
      tests/test_attachments_prep.py
  2. 136 0
      tests/test_emojis_models.py
  3. 341 0
      tests/test_presence.py

+ 197 - 0
tests/test_attachments_prep.py

@@ -0,0 +1,197 @@
+"""Tests de la préparation des pièces jointes (images, PDF, fichiers texte). Lancer depuis la racine du projet : python -m unittest"""
+
+import codecs
+import io
+import random
+import unittest
+from unittest import mock
+
+from PIL import Image
+from pypdf import PdfWriter
+
+from bot import images
+from bot.images import MAX_DIMENSION, ImageError, prepare_image
+from bot.pdf_files import PdfError, prepare_pdf
+from bot.text_files import TextFileError, decode_text_file
+
+
+def encode(img: Image.Image, fmt: str, **options) -> bytes:
+    buffer = io.BytesIO()
+    img.save(buffer, format=fmt, **options)
+    return buffer.getvalue()
+
+
+def noise(size: tuple[int, int], mode: str = "RGB") -> Image.Image:
+    """Image de bruit (toujours le même) : presque impossible à compresser."""
+    channels = len(mode)
+    return Image.frombytes(mode, size, random.Random(0).randbytes(size[0] * size[1] * channels))
+
+
+def opened(data: bytes) -> Image.Image:
+    img = Image.open(io.BytesIO(data))
+    img.load()
+    return img
+
+
+class PrepareImageTest(unittest.TestCase):
+    def test_small_jpeg_and_png_sent_as_is(self):
+        for fmt, media_type in (("JPEG", "image/jpeg"), ("PNG", "image/png"),
+                                ("GIF", "image/gif"), ("WEBP", "image/webp")):
+            with self.subTest(fmt):
+                data = encode(Image.new("RGB", (200, 100), "red"), fmt)
+                result = prepare_image(data)
+                self.assertEqual(result.media_type, media_type)
+                self.assertIs(result.data, data)
+
+    def test_exactly_max_dimension_sent_as_is(self):
+        data = encode(Image.new("RGB", (MAX_DIMENSION, 100), "red"), "PNG")
+        self.assertIs(prepare_image(data).data, data)
+
+    def test_large_png_reduced(self):
+        result = prepare_image(encode(Image.new("RGB", (3136, 1000), "blue"), "PNG"))
+        self.assertEqual(result.media_type, "image/png")  # sans perte : le texte des captures reste net
+        self.assertEqual(opened(result.data).size, (MAX_DIMENSION, 500))
+
+    def test_large_jpeg_reduced(self):
+        result = prepare_image(encode(Image.new("RGB", (1000, 3136), "green"), "JPEG"))
+        self.assertEqual(result.media_type, "image/jpeg")
+        self.assertEqual(opened(result.data).size, (500, MAX_DIMENSION))
+
+    def test_unsupported_formats_converted(self):
+        for fmt in ("BMP", "TIFF"):
+            with self.subTest(fmt):
+                result = prepare_image(encode(Image.new("RGB", (200, 100), "red"), fmt))
+                self.assertEqual(result.media_type, "image/png")
+                img = opened(result.data)
+                self.assertEqual((img.format, img.size), ("PNG", (200, 100)))
+
+    def test_transparency_kept(self):
+        result = prepare_image(encode(Image.new("RGBA", (3000, 100), (255, 0, 0, 0)), "PNG"))
+        self.assertEqual(result.media_type, "image/png")
+        self.assertEqual(opened(result.data).mode, "RGBA")
+
+    def test_heavy_png_becomes_jpeg(self):
+        # Limite de l'API abaissée : le PNG la dépasse, le JPEG (avec perte) passe en dessous.
+        img = noise((300, 300))
+        png, jpeg = encode(img, "PNG", optimize=True), encode(img, "JPEG", quality=45, optimize=True)
+        self.assertLess(len(jpeg), len(png))
+        with mock.patch.object(images, "API_MAX_BYTES", (len(png) + len(jpeg)) // 2):
+            result = prepare_image(encode(img, "PNG"))
+        self.assertEqual(result.media_type, "image/jpeg")
+        self.assertLessEqual(len(result.data), (len(png) + len(jpeg)) // 2)
+
+    def test_heavy_transparent_png_on_white(self):
+        img = noise((300, 300), "RGBA")
+        with mock.patch.object(images, "API_MAX_BYTES", len(encode(img, "PNG", optimize=True)) // 2):
+            result = prepare_image(encode(img, "PNG"))
+        self.assertEqual(result.media_type, "image/jpeg")
+        self.assertEqual(opened(result.data).mode, "RGB")
+
+    def test_too_heavy_even_compressed(self):
+        with mock.patch.object(images, "API_MAX_BYTES", 1000), self.assertRaises(ImageError) as ctx:
+            prepare_image(encode(noise((300, 300)), "PNG"))
+        self.assertIn("compresser suffisamment", ctx.exception.user_message)
+
+    def test_not_an_image(self):
+        for data in (b"", b"bonjour, ceci n'est pas une image", encode(Image.new("RGB", (50, 50)), "PNG")[:40]):
+            with self.subTest(data=data[:10]), self.assertRaises(ImageError) as ctx:
+                prepare_image(data)
+            self.assertIn("Je n'arrive pas à lire cette image", ctx.exception.user_message)
+
+    def test_too_many_pixels(self):
+        # Seuil abaissé pour ne pas fabriquer une vraie image géante.
+        with mock.patch.object(Image, "MAX_IMAGE_PIXELS", 1000), self.assertRaises(ImageError) as ctx:
+            prepare_image(encode(Image.new("RGB", (100, 100)), "PNG"))
+        self.assertIn("beaucoup trop grande", ctx.exception.user_message)
+
+
+def pdf(pages: int, password: str | None = None, owner_password: str | None = None) -> bytes:
+    writer = PdfWriter()
+    for _ in range(pages):
+        writer.add_blank_page(width=200, height=200)
+    if password is not None:
+        writer.encrypt(user_password=password, owner_password=owner_password or "propriétaire")
+    buffer = io.BytesIO()
+    writer.write(buffer)
+    return buffer.getvalue()
+
+
+class PreparePdfTest(unittest.TestCase):
+    def test_valid_pdf(self):
+        data = pdf(3)
+        result = prepare_pdf("cours.pdf", data, max_pages=3)
+        self.assertTrue(result.is_pdf)
+        self.assertEqual(result.name, "cours.pdf")
+        self.assertIs(result.data, data)
+
+    def test_too_many_pages(self):
+        with self.assertRaises(PdfError) as ctx:
+            prepare_pdf("cours.pdf", pdf(4), max_pages=3)
+        self.assertEqual(ctx.exception.user_message,
+                         "Ce PDF a 4 pages : je n'en lis que 3 au maximum. Envoie-moi seulement les pages utiles.")
+
+    def test_no_page(self):
+        with self.assertRaises(PdfError) as ctx:
+            prepare_pdf("vide.pdf", pdf(0), max_pages=3)
+        self.assertIn("aucune page", ctx.exception.user_message)
+
+    def test_password(self):
+        with self.assertRaises(PdfError) as ctx:
+            prepare_pdf("secret.pdf", pdf(1, password="secret"), max_pages=3)
+        self.assertIn("mot de passe", ctx.exception.user_message)
+
+    def test_owner_password_only(self):
+        # Chiffré sans mot de passe d'ouverture (seulement des droits limités) : lisible.
+        self.assertTrue(prepare_pdf("protégé.pdf", pdf(1, password=""), max_pages=3).is_pdf)
+
+    def test_not_a_pdf(self):
+        for data in (b"", b"bonjour, ceci n'est pas un PDF", encode(Image.new("RGB", (50, 50)), "PNG")):
+            with self.subTest(data=data[:10]), self.assertLogs("chatbot.pdf", "INFO"), \
+                    self.assertRaises(PdfError) as ctx:
+                prepare_pdf("faux.pdf", data, max_pages=3)
+            self.assertEqual(ctx.exception.user_message, "Je n'arrive pas à lire ce PDF.")
+
+
+class DecodeTextFileTest(unittest.TestCase):
+    def test_utf8(self):
+        self.assertEqual(decode_text_file("Été à Noël 😀".encode("utf-8")), "Été à Noël 😀")
+
+    def test_utf8_with_bom(self):
+        self.assertEqual(decode_text_file(codecs.BOM_UTF8 + "Été".encode("utf-8")), "Été")
+
+    def test_windows_1252(self):
+        # « ANSI » du Bloc-notes : pas de l'UTF-8 valide, lu en Windows-1252.
+        self.assertEqual(decode_text_file("Été, 5 € œuvre".encode("cp1252")), "Été, 5 € œuvre")
+
+    def test_bytes_undefined_in_windows_1252(self):
+        self.assertEqual(decode_text_file(b"caf\xe9 \x81"), "café �")
+
+    def test_utf16_with_bom(self):
+        for encoding in ("utf-16-le", "utf-16-be"):
+            with self.subTest(encoding):
+                bom = codecs.BOM_UTF16_LE if encoding == "utf-16-le" else codecs.BOM_UTF16_BE
+                self.assertEqual(decode_text_file(bom + "Été".encode(encoding)), "Été")
+
+    def test_broken_utf16(self):
+        with self.assertRaises(TextFileError) as ctx:
+            decode_text_file(codecs.BOM_UTF16_LE + b"a\x00b")  # nombre impair d'octets
+        self.assertIn("pas du texte lisible", ctx.exception.user_message)
+
+    def test_line_endings_and_spaces(self):
+        self.assertEqual(decode_text_file(b"\r\n  ligne 1\r\nligne 2\r\n\r\n"), "ligne 1\nligne 2")
+
+    def test_empty(self):
+        for data in (b"", b"  \r\n\t ", codecs.BOM_UTF8):
+            with self.subTest(data=data), self.assertRaises(TextFileError) as ctx:
+                decode_text_file(data)
+            self.assertIn("vide", ctx.exception.user_message)
+
+    def test_binary(self):
+        for data in (bytes(range(256)), encode(Image.new("RGB", (50, 50)), "PNG"), "texte".encode("utf-16-le")):
+            with self.subTest(data=data[:10]), self.assertRaises(TextFileError) as ctx:
+                decode_text_file(data)
+            self.assertIn("pas du texte lisible", ctx.exception.user_message)
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 136 - 0
tests/test_emojis_models.py

@@ -0,0 +1,136 @@
+"""Tests des emojis du serveur et des noms de modèles. Lancer depuis la racine du projet : python -m unittest"""
+
+import unittest
+from types import SimpleNamespace
+
+from bot.emojis import discord_emojis, emoji_names, short_emojis, usable_emojis
+from bot.models import display_name, is_known, thinking_off
+
+
+class FakeEmoji:
+    """Emoji personnalisé, écrit comme discord.Emoji (<:nom:id>, <a:nom:id> s'il est animé)."""
+
+    def __init__(self, name: str, id: int, animated: bool = False, available: bool = True, roles=()):
+        self.name, self.id, self.animated, self.available, self.roles = name, id, animated, available, list(roles)
+
+    def __str__(self) -> str:
+        return f"<{'a' if self.animated else ''}:{self.name}:{self.id}>"
+
+
+def guild(*emojis: FakeEmoji, roles=()) -> SimpleNamespace:
+    return SimpleNamespace(emojis=list(emojis), me=SimpleNamespace(roles=list(roles)))
+
+
+HAP = FakeEmoji("hap", 111)
+DANCE = FakeEmoji("dance", 222, animated=True)
+GUILD = guild(HAP, DANCE)
+
+
+class DiscordEmojisTest(unittest.TestCase):
+    def test_name_replaced(self):
+        self.assertEqual(discord_emojis("Coucou :hap: !", GUILD), "Coucou <:hap:111> !")
+
+    def test_animated(self):
+        self.assertEqual(discord_emojis(":dance:", GUILD), "<a:dance:222>")
+
+    def test_several_in_a_row(self):
+        self.assertEqual(discord_emojis(":hap::dance: :hap:", GUILD), "<:hap:111><a:dance:222> <:hap:111>")
+
+    def test_case_tolerated(self):
+        self.assertEqual(discord_emojis(":Hap: :DANCE:", GUILD), "<:hap:111> <a:dance:222>")
+
+    def test_unknown_name_kept(self):
+        self.assertEqual(discord_emojis(":inconnu: à 12:30:45", GUILD), ":inconnu: à 12:30:45")
+
+    def test_code_kept(self):
+        for text in ("```\n:hap:\n```", "```py\nprint(':hap:')\n```", "`:hap:`"):
+            with self.subTest(text):
+                self.assertEqual(discord_emojis(text, GUILD), text)
+        self.assertEqual(discord_emojis("`:hap:` donne :hap:", GUILD), "`:hap:` donne <:hap:111>")
+
+    def test_already_a_code(self):
+        self.assertEqual(discord_emojis("<:hap:111> <a:dance:222>", GUILD), "<:hap:111> <a:dance:222>")
+
+    def test_no_guild(self):
+        self.assertEqual(discord_emojis("Coucou :hap: !", None), "Coucou :hap: !")
+        self.assertEqual(discord_emojis("Coucou :hap: !", guild()), "Coucou :hap: !")
+
+    def test_unusable_emoji_not_replaced(self):
+        hidden = guild(FakeEmoji("hap", 111, available=False), FakeEmoji("vip", 333, roles=["role_vip"]))
+        self.assertEqual(discord_emojis(":hap: :vip:", hidden), ":hap: :vip:")
+
+
+class ShortEmojisTest(unittest.TestCase):
+    def test_codes_replaced_by_names(self):
+        self.assertEqual(short_emojis("Coucou <:hap:111> <a:dance:222> !"), "Coucou :hap: :dance: !")
+
+    def test_text_without_emoji_kept(self):
+        self.assertEqual(short_emojis("Coucou :hap: <@123> <#456>"), "Coucou :hap: <@123> <#456>")
+
+    def test_round_trip(self):
+        text = "Coucou :hap: et :dance: !"
+        self.assertEqual(short_emojis(discord_emojis(text, GUILD)), text)
+
+
+class UsableEmojisTest(unittest.TestCase):
+    def test_sorted_by_name(self):
+        zebra, apple, upper = FakeEmoji("zebre", 1), FakeEmoji("abricot", 2), FakeEmoji("Banane", 3)
+        self.assertEqual(usable_emojis(guild(zebra, apple, upper)), [apple, upper, zebra])
+        self.assertEqual(emoji_names(guild(zebra, apple, upper)), ["abricot", "Banane", "zebre"])
+
+    def test_unavailable_excluded(self):
+        self.assertEqual(emoji_names(guild(HAP, FakeEmoji("parti", 4, available=False))), ["hap"])
+
+    def test_roles(self):
+        vip = FakeEmoji("vip", 5, roles=["role_vip"])
+        self.assertEqual(emoji_names(guild(HAP, vip)), ["hap"])
+        self.assertEqual(emoji_names(guild(HAP, vip, roles=["role_autre"])), ["hap"])
+        self.assertEqual(emoji_names(guild(HAP, vip, roles=["role_autre", "role_vip"])), ["hap", "vip"])
+
+    def test_bot_not_a_member(self):
+        vip = FakeEmoji("vip", 5, roles=["role_vip"])
+        self.assertEqual(emoji_names(SimpleNamespace(emojis=[HAP, vip], me=None)), ["hap"])
+
+    def test_no_guild(self):
+        self.assertEqual(usable_emojis(None), [])
+        self.assertEqual(emoji_names(None), [])
+
+
+class ModelsTest(unittest.TestCase):
+    def test_is_known(self):
+        for model in ("claude-sonnet-5-5", "claude-haiku-5-5", "claude-opus-5-5"):
+            with self.subTest(model):
+                self.assertTrue(is_known(model))
+        for model in ("claude-sonnet-5-6", "claude-sonet-5-5", "gpt-4", ""):
+            with self.subTest(model):
+                self.assertFalse(is_known(model))
+
+    def test_display_name(self):
+        names = {
+            "claude-sonnet-5-5": "Sonnet 5.5",
+            "claude-opus-5-5": "Opus 5.5",
+            "claude-sonnet-4-5-20250929": "Sonnet 4.5",
+            "claude-opus-5": "Opus 5",
+            "claude-opus-4-20250514": "Opus 4",
+            "claude-haiku-4-10": "Haiku 4.10",
+        }
+        for model, name in names.items():
+            with self.subTest(model):
+                self.assertEqual(display_name(model), name)
+
+    def test_display_name_unusual_id_kept(self):
+        for model in ("claude-3-5-sonnet-20241022", "claude-sonnet-5-5-latest", "Claude-Sonnet-5-5",
+                      "claude-sonnet", "gpt-4", ""):
+            with self.subTest(model):
+                self.assertEqual(display_name(model), model)
+
+    def test_thinking_off(self):
+        self.assertEqual(thinking_off("claude-sonnet-5-5"), {"type": "between_tools"})
+        self.assertIsNone(thinking_off("claude-opus-5-5"))
+        self.assertEqual(thinking_off("claude-haiku-5-5"), {"type": "disabled"})
+        self.assertEqual(thinking_off("claude-opus-4-8"), {"type": "disabled"})  # modèle plus ancien
+        self.assertEqual(thinking_off("modele-inconnu"), {"type": "disabled"})
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 341 - 0
tests/test_presence.py

@@ -0,0 +1,341 @@
+"""Tests du statut Discord (nuit, durées, reprise après un redémarrage). Lancer depuis la racine du projet : python -m unittest"""
+
+import json
+import tempfile
+import unittest
+from datetime import datetime, timedelta, timezone
+from pathlib import Path
+from types import SimpleNamespace
+from unittest import mock
+from zoneinfo import ZoneInfo
+
+from bot.presence import Kind, PresenceManager, Status, format_duration
+from bot.presence_sources import Track
+
+UTC = timezone.utc
+PARIS = ZoneInfo("Europe/Paris")
+# Tous les types de statut activés (la nuit n'est jamais tirée au sort : pas de poids).
+WEIGHTS = {kind.value: 1 for kind in Kind if kind is not Kind.NIGHT}
+T0 = datetime(2026, 11, 3, 9, 0, tzinfo=UTC)  # mardi 3 novembre 2026, 10 h à Paris
+
+
+def frozen_at(moment: datetime):
+    """Fige l'heure vue par bot/presence.py (datetime.now) à moment, quelle que soit l'heure du PC."""
+    class Frozen(datetime):
+        @classmethod
+        def now(cls, tz):  # sans fuseau, l'heure dépendrait du PC : le module en donne toujours un
+            return moment.astimezone(tz)
+    return mock.patch("bot.presence.datetime", Frozen)
+
+
+def make_manager(folder: Path, night=None, weights=None) -> PresenceManager:
+    settings = SimpleNamespace(
+        presence_weights=WEIGHTS if weights is None else weights,
+        history_file=folder / "conversation_history.json",
+        deezer_playlists=(), steam_api_key=None,
+        presence_night=night, presence_speed=1.0, timezone=PARIS,
+    )
+    return PresenceManager(SimpleNamespace(), SimpleNamespace(), settings)
+
+
+def paris(*args) -> datetime:
+    return datetime(*args, tzinfo=PARIS)
+
+
+class IsNightTest(unittest.TestCase):
+    def setUp(self):
+        self._tmp = tempfile.TemporaryDirectory()
+
+    def tearDown(self):
+        self._tmp.cleanup()
+
+    def is_night(self, night, hour: int, minute: int = 0) -> bool:
+        return make_manager(Path(self._tmp.name), night=night).is_night(paris(2026, 11, 3, hour, minute))
+
+    def test_no_night(self):
+        for hour in range(24):
+            self.assertFalse(self.is_night(None, hour))
+
+    def test_night_within_the_day(self):
+        self.assertFalse(self.is_night((1, 7), 0, 59))
+        self.assertTrue(self.is_night((1, 7), 1))  # heure de début : déjà la nuit
+        self.assertTrue(self.is_night((1, 7), 4))
+        self.assertTrue(self.is_night((1, 7), 6, 59))
+        self.assertFalse(self.is_night((1, 7), 7))  # heure de fin : déjà le jour
+        self.assertFalse(self.is_night((1, 7), 12))
+
+    def test_night_across_midnight(self):
+        self.assertFalse(self.is_night((23, 6), 22, 59))
+        self.assertTrue(self.is_night((23, 6), 23))
+        self.assertTrue(self.is_night((23, 6), 0))
+        self.assertTrue(self.is_night((23, 6), 5, 59))
+        self.assertFalse(self.is_night((23, 6), 6))
+        self.assertFalse(self.is_night((23, 6), 12))
+
+
+class NextHourTest(unittest.TestCase):
+    def test_later_the_same_day(self):
+        result = PresenceManager.next_hour(paris(2026, 11, 3, 10, 0), 14)
+        self.assertEqual(result, datetime(2026, 11, 3, 13, 0, tzinfo=UTC))
+        self.assertIs(result.tzinfo, UTC)
+
+    def test_hour_already_passed(self):
+        result = PresenceManager.next_hour(paris(2026, 11, 3, 10, 0), 6)
+        self.assertEqual(result, datetime(2026, 11, 4, 5, 0, tzinfo=UTC))
+        self.assertIs(result.tzinfo, UTC)
+
+    def test_exact_hour_is_the_next_day(self):
+        self.assertEqual(PresenceManager.next_hour(paris(2026, 11, 3, 6, 0), 6),
+                         datetime(2026, 11, 4, 5, 0, tzinfo=UTC))
+
+    def test_just_before_the_hour(self):
+        self.assertEqual(PresenceManager.next_hour(paris(2026, 11, 3, 5, 59, 59), 6),
+                         datetime(2026, 11, 3, 5, 0, tzinfo=UTC))
+
+    def test_spring_forward(self):
+        # Nuit du 28 au 29 mars 2026 : 2 h devient 3 h, 6 h du matin est à 4 h UTC (et non 5 h).
+        self.assertEqual(PresenceManager.next_hour(paris(2026, 3, 28, 12, 0), 6),
+                         datetime(2026, 3, 29, 4, 0, tzinfo=UTC))
+        self.assertEqual(PresenceManager.next_hour(paris(2026, 3, 29, 1, 30), 6),
+                         datetime(2026, 3, 29, 4, 0, tzinfo=UTC))
+
+    def test_fall_back(self):
+        # Nuit du 24 au 25 octobre 2026 : 3 h redevient 2 h, 6 h du matin est à 5 h UTC (et non 4 h).
+        self.assertEqual(PresenceManager.next_hour(paris(2026, 10, 24, 12, 0), 6),
+                         datetime(2026, 10, 25, 5, 0, tzinfo=UTC))
+        self.assertEqual(PresenceManager.next_hour(paris(2026, 10, 25, 1, 0), 6),
+                         datetime(2026, 10, 25, 5, 0, tzinfo=UTC))
+
+
+class FormatDurationTest(unittest.TestCase):
+    def test_less_than_a_minute(self):
+        self.assertEqual(format_duration(0), "1 min")
+        self.assertEqual(format_duration(20), "1 min")
+
+    def test_minutes(self):
+        self.assertEqual(format_duration(60), "1 min")
+        self.assertEqual(format_duration(25 * 60), "25 min")
+        self.assertEqual(format_duration(59 * 60), "59 min")
+
+    def test_whole_hours(self):
+        self.assertEqual(format_duration(3600), "1 h")
+        self.assertEqual(format_duration(2 * 3600), "2 h")
+
+    def test_hours_and_minutes(self):
+        self.assertEqual(format_duration(3600 + 5 * 60), "1 h 05")
+        self.assertEqual(format_duration(2 * 3600 + 30 * 60), "2 h 30")
+
+
+class StatusDictTest(unittest.TestCase):
+    def assert_round_trip(self, status: Status) -> None:
+        data = json.loads(json.dumps(status.to_dict()))  # tel qu'écrit dans presence_state.json
+        self.assertEqual(Status.from_dict(data), status)
+
+    def test_every_kind(self):
+        for kind in Kind:
+            with self.subTest(kind=kind):
+                self.assert_round_trip(Status(kind))
+                self.assert_round_trip(Status(kind, title="Un titre"))
+
+    def test_anime_episode(self):
+        self.assert_round_trip(Status(Kind.ANIME, title="Frieren", episode=7))
+
+    def test_music_track(self):
+        self.assert_round_trip(Status(Kind.MUSIC, track=Track("Clair de lune", "Debussy", 300)))
+
+    def test_game_details(self):
+        self.assert_round_trip(Status(Kind.GAME, title="Celeste", details="Un jeu de plateforme."))
+
+    def test_empty_fields_left_out(self):
+        self.assertEqual(Status(Kind.ONLINE).to_dict(), {"kind": "en_ligne"})
+
+
+class RestoreTest(unittest.IsolatedAsyncioTestCase):
+    """presence_state.json relu au démarrage : le statut reprend là où il en serait."""
+
+    def setUp(self):
+        self._tmp = tempfile.TemporaryDirectory()
+        self.folder = Path(self._tmp.name)
+        self.state_file = self.folder / "presence_state.json"
+
+    def tearDown(self):
+        self._tmp.cleanup()
+
+    def save(self, status: Status, minutes: float, program=(), saved_at=T0, **options) -> None:
+        """Enregistre status (pour minutes à partir de saved_at, puis program) comme le ferait le bot."""
+        manager = make_manager(self.folder, **options)
+        manager.status = status
+        manager.step_end = saved_at + timedelta(minutes=minutes)
+        manager.program = [(step, seconds) for step, seconds in program]
+        manager.status_end = manager.step_end + timedelta(seconds=sum(seconds for _, seconds in program))
+        with frozen_at(saved_at):
+            manager.save()
+
+    def restore(self, now: datetime, **options) -> PresenceManager:
+        manager = make_manager(self.folder, **options)
+        with frozen_at(now):
+            manager.restore()
+        return manager
+
+    def assert_fresh_start(self, manager: PresenceManager) -> None:
+        """Rien de repris : la boucle tirera un nouveau statut à sa première étape."""
+        self.assertEqual(manager.status, Status(Kind.ONLINE))
+        self.assertEqual(manager.program, [])
+        self.assertEqual(manager.step_end, datetime.min.replace(tzinfo=UTC))
+        self.assertIsNone(manager.status_end)
+
+    def episodes(self, *numbers: int) -> list:
+        return [(Status(Kind.ANIME, title="Frieren", episode=n), 24 * 60) for n in numbers]
+
+    def test_anime_moves_on_by_the_episodes_missed(self):
+        # Épisode 3 jusqu'à +10 min, puis 4 (+34), 5 (+58) et 6 (+82) : à +40, c'est l'épisode 5.
+        self.save(self.episodes(3)[0][0], 10, self.episodes(4, 5, 6))
+        manager = self.restore(T0 + timedelta(minutes=40))
+        self.assertEqual(manager.status, Status(Kind.ANIME, title="Frieren", episode=5))
+        self.assertEqual(manager.program, self.episodes(6))
+        self.assertEqual(manager.step_end, T0 + timedelta(minutes=58))
+        self.assertEqual(manager.status_end, T0 + timedelta(minutes=82))
+
+    def test_short_restart_keeps_the_current_step(self):
+        self.save(self.episodes(3)[0][0], 10, self.episodes(4, 5))
+        manager = self.restore(T0 + timedelta(minutes=5))
+        self.assertEqual(manager.status.episode, 3)
+        self.assertEqual(manager.program, self.episodes(4, 5))
+        self.assertEqual(manager.step_end, T0 + timedelta(minutes=10))
+
+    def test_game_details_restored(self):
+        game = Status(Kind.GAME, title="Celeste", details="Un jeu de plateforme.")
+        self.save(game, 60)
+        self.assertEqual(self.restore(T0 + timedelta(minutes=30)).status, game)
+
+    def test_file_written_by_the_bot(self):
+        # Format du fichier tel qu'enregistré en production (musique : un morceau par étape).
+        track = {"title": "Clair de lune", "artist": "Debussy", "duration": 300}
+        self.state_file.write_text(json.dumps({
+            "saved_at": "2026-11-03T09:00:00+00:00",
+            "status": {"kind": "musique", "track": track},
+            "step_end": "2026-11-03T09:02:00+00:00",
+            "status_end": "2026-11-03T09:12:00+00:00",
+            "program": [[{"kind": "musique", "track": {**track, "title": "Rêverie"}}, 300.0],
+                        [{"kind": "musique", "track": {**track, "title": "Arabesque"}}, 300.0]],
+            "recent_custom": ["Je range ma chambre"],
+            "recent_kinds": ["pensee", "musique"],
+        }), encoding="utf-8")
+        manager = self.restore(T0 + timedelta(minutes=4))
+        self.assertEqual(manager.status, Status(Kind.MUSIC, track=Track("Rêverie", "Debussy", 300)))
+        self.assertEqual([step.track.title for step, _ in manager.program], ["Arabesque"])
+        self.assertEqual(manager.step_end, T0 + timedelta(minutes=7))
+        self.assertEqual(list(manager.recent_custom), ["Je range ma chambre"])
+        self.assertEqual(list(manager.recent_kinds), [Kind.THOUGHT, Kind.MUSIC])
+
+    def test_finished_during_the_restart(self):
+        self.save(self.episodes(3)[0][0], 10, self.episodes(4, 5))
+        self.assert_fresh_start(self.restore(T0 + timedelta(hours=2)))
+
+    def test_finished_exactly_at_restart(self):
+        self.save(self.episodes(3)[0][0], 10, self.episodes(4))
+        self.assert_fresh_start(self.restore(T0 + timedelta(minutes=34)))
+
+    def test_recent_lists_kept_even_when_the_status_is_over(self):
+        manager = make_manager(self.folder)
+        manager.status = Status(Kind.THOUGHT, title="Je range ma chambre")
+        manager.step_end = T0 + timedelta(minutes=10)
+        manager.recent_custom.append("Je range ma chambre")
+        manager.recent_kinds.extend([Kind.MUSIC, Kind.THOUGHT])
+        with frozen_at(T0):
+            manager.save()
+        restored = self.restore(T0 + timedelta(hours=1))
+        self.assert_fresh_start(restored)
+        self.assertEqual(list(restored.recent_custom), ["Je range ma chambre"])
+        self.assertEqual(list(restored.recent_kinds), [Kind.MUSIC, Kind.THOUGHT])
+
+    def test_cut_by_the_night(self):
+        # Partie commencée à 1 h (Paris) pour 6 h : la nuit de 2 h à 6 h l'a interrompue.
+        saved_at = paris(2026, 11, 3, 1, 0).astimezone(UTC)
+        game = Status(Kind.GAME, title="Celeste")
+        self.save(game, 6 * 60, saved_at=saved_at, night=(2, 6))
+        self.assert_fresh_start(self.restore(paris(2026, 11, 3, 6, 10), night=(2, 6)))
+        # Sans nuit, ou redémarré avant 2 h, la partie continue.
+        self.assertEqual(self.restore(paris(2026, 11, 3, 6, 10), night=None).status, game)
+        self.assertEqual(self.restore(paris(2026, 11, 3, 1, 30), night=(2, 6)).status, game)
+
+    def test_night_saved(self):
+        self.save(Status(Kind.NIGHT), 60)
+        self.assert_fresh_start(self.restore(T0 + timedelta(minutes=10)))
+
+    def test_kind_disabled_since(self):
+        self.save(Status(Kind.GAME, title="Celeste"), 60)
+        self.assert_fresh_start(self.restore(T0 + timedelta(minutes=10), weights={**WEIGHTS, "jeu": 0}))
+        without_games = {name: weight for name, weight in WEIGHTS.items() if name != "jeu"}
+        self.assert_fresh_start(self.restore(T0 + timedelta(minutes=10), weights=without_games))
+
+    def test_missing_file(self):
+        with self.assertNoLogs("chatbot.presence", "WARNING"):
+            manager = self.restore(T0)
+        self.assert_fresh_start(manager)
+
+    def test_unreadable_file(self):
+        self.save(Status(Kind.GAME, title="Celeste"), 60)
+        valid = json.loads(self.state_file.read_text(encoding="utf-8"))
+        contents = {
+            "vide": "",
+            "tronqué": json.dumps(valid)[:-15],
+            "liste": "[]",
+            "sans statut": json.dumps({**valid, "status": None}),
+            "type inconnu": json.dumps({**valid, "status": {"kind": "volant"}}),
+            "morceau incomplet": json.dumps({**valid, "status": {"kind": "musique", "track": {"title": "x"}}}),
+            "date illisible": json.dumps({**valid, "step_end": "demain"}),
+            "date en nombre": json.dumps({**valid, "saved_at": 12}),
+            "durée illisible": json.dumps({**valid, "program": [[{"kind": "jeu"}, "longtemps"]]}),
+            "programme mal formé": json.dumps({**valid, "program": 5}),
+            "statuts récents mal formés": json.dumps({**valid, "recent_custom": 5}),
+        }
+        for name, text in contents.items():
+            with self.subTest(name):
+                self.state_file.write_text(text, encoding="utf-8")
+                with self.assertLogs("chatbot.presence", "WARNING"):
+                    manager = self.restore(T0 + timedelta(minutes=10))
+                self.assertEqual(manager.status, Status(Kind.ONLINE))
+                self.assertEqual(manager.program, [])
+
+    @unittest.expectedFailure
+    def test_date_without_timezone(self):
+        # BUG : une date sans fuseau (« 2026-11-03T09:10:00 », fichier modifié à la main) est lue
+        # sans erreur, puis comparée à l'heure actuelle (avec fuseau) en dehors du try de restore :
+        # TypeError, et le bot ne démarre pas, au lieu d'ignorer le fichier comme un fichier illisible.
+        self.save(Status(Kind.GAME, title="Celeste"), 60)
+        valid = json.loads(self.state_file.read_text(encoding="utf-8"))
+        self.state_file.write_text(json.dumps({**valid, "step_end": "2026-11-03T10:00:00"}), encoding="utf-8")
+        with self.assertLogs("chatbot.presence", "WARNING"):
+            manager = self.restore(T0 + timedelta(minutes=10))
+        self.assertEqual(manager.status, Status(Kind.ONLINE))
+
+    async def step(self, manager: PresenceManager, now: datetime) -> tuple[mock.AsyncMock, mock.AsyncMock]:
+        """Une étape de la boucle, sans tirage au sort ni Discord : renvoie (draw, set_status)."""
+        draw = mock.AsyncMock(return_value=[(Status(Kind.ONLINE), 600)])
+        set_status = mock.AsyncMock()
+        with mock.patch.object(manager, "draw", draw), mock.patch.object(manager, "set_status", set_status), \
+                frozen_at(now):
+            await manager._step()
+        return draw, set_status
+
+    async def test_loop_goes_on_with_the_restored_program(self):
+        self.save(self.episodes(3)[0][0], 10, self.episodes(4, 5))
+        manager = self.restore(T0 + timedelta(minutes=20))  # épisode 4 jusqu'à +34
+        draw, set_status = await self.step(manager, T0 + timedelta(minutes=20))
+        draw.assert_not_called()
+        set_status.assert_not_called()
+        draw, set_status = await self.step(manager, T0 + timedelta(minutes=35))
+        draw.assert_not_called()
+        set_status.assert_awaited_once_with(Status(Kind.ANIME, title="Frieren", episode=5))
+
+    async def test_loop_draws_after_a_finished_status(self):
+        self.save(self.episodes(3)[0][0], 10)
+        manager = self.restore(T0 + timedelta(hours=1))
+        draw, set_status = await self.step(manager, T0 + timedelta(hours=1))
+        draw.assert_awaited_once()
+        set_status.assert_awaited_once_with(Status(Kind.ONLINE))
+
+
+if __name__ == "__main__":
+    unittest.main()