test_presence.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337
  1. """Tests du statut Discord (nuit, durées, reprise après un redémarrage). Lancer depuis la racine du projet : python -m unittest"""
  2. import json
  3. import tempfile
  4. import unittest
  5. from datetime import datetime, timedelta, timezone
  6. from pathlib import Path
  7. from types import SimpleNamespace
  8. from unittest import mock
  9. from zoneinfo import ZoneInfo
  10. from bot.presence import Kind, PresenceManager, Status, format_duration
  11. from bot.presence_sources import Track
  12. UTC = timezone.utc
  13. PARIS = ZoneInfo("Europe/Paris")
  14. # Tous les types de statut activés (la nuit n'est jamais tirée au sort : pas de poids).
  15. WEIGHTS = {kind.value: 1 for kind in Kind if kind is not Kind.NIGHT}
  16. T0 = datetime(2026, 11, 3, 9, 0, tzinfo=UTC) # mardi 3 novembre 2026, 10 h à Paris
  17. def frozen_at(moment: datetime):
  18. """Fige l'heure vue par bot/presence.py (datetime.now) à moment, quelle que soit l'heure du PC."""
  19. class Frozen(datetime):
  20. @classmethod
  21. def now(cls, tz): # sans fuseau, l'heure dépendrait du PC : le module en donne toujours un
  22. return moment.astimezone(tz)
  23. return mock.patch("bot.presence.datetime", Frozen)
  24. def make_manager(folder: Path, night=None, weights=None) -> PresenceManager:
  25. settings = SimpleNamespace(
  26. presence_weights=WEIGHTS if weights is None else weights,
  27. history_file=folder / "conversation_history.json",
  28. deezer_playlists=(), steam_api_key=None,
  29. presence_night=night, presence_speed=1.0, timezone=PARIS,
  30. )
  31. return PresenceManager(SimpleNamespace(), SimpleNamespace(), settings)
  32. def paris(*args) -> datetime:
  33. return datetime(*args, tzinfo=PARIS)
  34. class IsNightTest(unittest.TestCase):
  35. def setUp(self):
  36. self._tmp = tempfile.TemporaryDirectory()
  37. def tearDown(self):
  38. self._tmp.cleanup()
  39. def is_night(self, night, hour: int, minute: int = 0) -> bool:
  40. return make_manager(Path(self._tmp.name), night=night).is_night(paris(2026, 11, 3, hour, minute))
  41. def test_no_night(self):
  42. for hour in range(24):
  43. self.assertFalse(self.is_night(None, hour))
  44. def test_night_within_the_day(self):
  45. self.assertFalse(self.is_night((1, 7), 0, 59))
  46. self.assertTrue(self.is_night((1, 7), 1)) # heure de début : déjà la nuit
  47. self.assertTrue(self.is_night((1, 7), 4))
  48. self.assertTrue(self.is_night((1, 7), 6, 59))
  49. self.assertFalse(self.is_night((1, 7), 7)) # heure de fin : déjà le jour
  50. self.assertFalse(self.is_night((1, 7), 12))
  51. def test_night_across_midnight(self):
  52. self.assertFalse(self.is_night((23, 6), 22, 59))
  53. self.assertTrue(self.is_night((23, 6), 23))
  54. self.assertTrue(self.is_night((23, 6), 0))
  55. self.assertTrue(self.is_night((23, 6), 5, 59))
  56. self.assertFalse(self.is_night((23, 6), 6))
  57. self.assertFalse(self.is_night((23, 6), 12))
  58. class NextHourTest(unittest.TestCase):
  59. def test_later_the_same_day(self):
  60. result = PresenceManager.next_hour(paris(2026, 11, 3, 10, 0), 14)
  61. self.assertEqual(result, datetime(2026, 11, 3, 13, 0, tzinfo=UTC))
  62. self.assertIs(result.tzinfo, UTC)
  63. def test_hour_already_passed(self):
  64. result = PresenceManager.next_hour(paris(2026, 11, 3, 10, 0), 6)
  65. self.assertEqual(result, datetime(2026, 11, 4, 5, 0, tzinfo=UTC))
  66. self.assertIs(result.tzinfo, UTC)
  67. def test_exact_hour_is_the_next_day(self):
  68. self.assertEqual(PresenceManager.next_hour(paris(2026, 11, 3, 6, 0), 6),
  69. datetime(2026, 11, 4, 5, 0, tzinfo=UTC))
  70. def test_just_before_the_hour(self):
  71. self.assertEqual(PresenceManager.next_hour(paris(2026, 11, 3, 5, 59, 59), 6),
  72. datetime(2026, 11, 3, 5, 0, tzinfo=UTC))
  73. def test_spring_forward(self):
  74. # Nuit du 28 au 29 mars 2026 : 2 h devient 3 h, 6 h du matin est à 4 h UTC (et non 5 h).
  75. self.assertEqual(PresenceManager.next_hour(paris(2026, 3, 28, 12, 0), 6),
  76. datetime(2026, 3, 29, 4, 0, tzinfo=UTC))
  77. self.assertEqual(PresenceManager.next_hour(paris(2026, 3, 29, 1, 30), 6),
  78. datetime(2026, 3, 29, 4, 0, tzinfo=UTC))
  79. def test_fall_back(self):
  80. # Nuit du 24 au 25 octobre 2026 : 3 h redevient 2 h, 6 h du matin est à 5 h UTC (et non 4 h).
  81. self.assertEqual(PresenceManager.next_hour(paris(2026, 10, 24, 12, 0), 6),
  82. datetime(2026, 10, 25, 5, 0, tzinfo=UTC))
  83. self.assertEqual(PresenceManager.next_hour(paris(2026, 10, 25, 1, 0), 6),
  84. datetime(2026, 10, 25, 5, 0, tzinfo=UTC))
  85. class FormatDurationTest(unittest.TestCase):
  86. def test_less_than_a_minute(self):
  87. self.assertEqual(format_duration(0), "1 min")
  88. self.assertEqual(format_duration(20), "1 min")
  89. def test_minutes(self):
  90. self.assertEqual(format_duration(60), "1 min")
  91. self.assertEqual(format_duration(25 * 60), "25 min")
  92. self.assertEqual(format_duration(59 * 60), "59 min")
  93. def test_whole_hours(self):
  94. self.assertEqual(format_duration(3600), "1 h")
  95. self.assertEqual(format_duration(2 * 3600), "2 h")
  96. def test_hours_and_minutes(self):
  97. self.assertEqual(format_duration(3600 + 5 * 60), "1 h 05")
  98. self.assertEqual(format_duration(2 * 3600 + 30 * 60), "2 h 30")
  99. class StatusDictTest(unittest.TestCase):
  100. def assert_round_trip(self, status: Status) -> None:
  101. data = json.loads(json.dumps(status.to_dict())) # tel qu'écrit dans presence_state.json
  102. self.assertEqual(Status.from_dict(data), status)
  103. def test_every_kind(self):
  104. for kind in Kind:
  105. with self.subTest(kind=kind):
  106. self.assert_round_trip(Status(kind))
  107. self.assert_round_trip(Status(kind, title="Un titre"))
  108. def test_anime_episode(self):
  109. self.assert_round_trip(Status(Kind.ANIME, title="Frieren", episode=7))
  110. def test_music_track(self):
  111. self.assert_round_trip(Status(Kind.MUSIC, track=Track("Clair de lune", "Debussy", 300)))
  112. def test_game_details(self):
  113. self.assert_round_trip(Status(Kind.GAME, title="Celeste", details="Un jeu de plateforme."))
  114. def test_empty_fields_left_out(self):
  115. self.assertEqual(Status(Kind.ONLINE).to_dict(), {"kind": "en_ligne"})
  116. class RestoreTest(unittest.IsolatedAsyncioTestCase):
  117. """presence_state.json relu au démarrage : le statut reprend là où il en serait."""
  118. def setUp(self):
  119. self._tmp = tempfile.TemporaryDirectory()
  120. self.folder = Path(self._tmp.name)
  121. self.state_file = self.folder / "presence_state.json"
  122. def tearDown(self):
  123. self._tmp.cleanup()
  124. def save(self, status: Status, minutes: float, program=(), saved_at=T0, **options) -> None:
  125. """Enregistre status (pour minutes à partir de saved_at, puis program) comme le ferait le bot."""
  126. manager = make_manager(self.folder, **options)
  127. manager.status = status
  128. manager.step_end = saved_at + timedelta(minutes=minutes)
  129. manager.program = [(step, seconds) for step, seconds in program]
  130. manager.status_end = manager.step_end + timedelta(seconds=sum(seconds for _, seconds in program))
  131. with frozen_at(saved_at):
  132. manager.save()
  133. def restore(self, now: datetime, **options) -> PresenceManager:
  134. manager = make_manager(self.folder, **options)
  135. with frozen_at(now):
  136. manager.restore()
  137. return manager
  138. def assert_fresh_start(self, manager: PresenceManager) -> None:
  139. """Rien de repris : la boucle tirera un nouveau statut à sa première étape."""
  140. self.assertEqual(manager.status, Status(Kind.ONLINE))
  141. self.assertEqual(manager.program, [])
  142. self.assertEqual(manager.step_end, datetime.min.replace(tzinfo=UTC))
  143. self.assertIsNone(manager.status_end)
  144. def episodes(self, *numbers: int) -> list:
  145. return [(Status(Kind.ANIME, title="Frieren", episode=n), 24 * 60) for n in numbers]
  146. def test_anime_moves_on_by_the_episodes_missed(self):
  147. # Épisode 3 jusqu'à +10 min, puis 4 (+34), 5 (+58) et 6 (+82) : à +40, c'est l'épisode 5.
  148. self.save(self.episodes(3)[0][0], 10, self.episodes(4, 5, 6))
  149. manager = self.restore(T0 + timedelta(minutes=40))
  150. self.assertEqual(manager.status, Status(Kind.ANIME, title="Frieren", episode=5))
  151. self.assertEqual(manager.program, self.episodes(6))
  152. self.assertEqual(manager.step_end, T0 + timedelta(minutes=58))
  153. self.assertEqual(manager.status_end, T0 + timedelta(minutes=82))
  154. def test_short_restart_keeps_the_current_step(self):
  155. self.save(self.episodes(3)[0][0], 10, self.episodes(4, 5))
  156. manager = self.restore(T0 + timedelta(minutes=5))
  157. self.assertEqual(manager.status.episode, 3)
  158. self.assertEqual(manager.program, self.episodes(4, 5))
  159. self.assertEqual(manager.step_end, T0 + timedelta(minutes=10))
  160. def test_game_details_restored(self):
  161. game = Status(Kind.GAME, title="Celeste", details="Un jeu de plateforme.")
  162. self.save(game, 60)
  163. self.assertEqual(self.restore(T0 + timedelta(minutes=30)).status, game)
  164. def test_file_written_by_the_bot(self):
  165. # Format du fichier tel qu'enregistré en production (musique : un morceau par étape).
  166. track = {"title": "Clair de lune", "artist": "Debussy", "duration": 300}
  167. self.state_file.write_text(json.dumps({
  168. "saved_at": "2026-11-03T09:00:00+00:00",
  169. "status": {"kind": "musique", "track": track},
  170. "step_end": "2026-11-03T09:02:00+00:00",
  171. "status_end": "2026-11-03T09:12:00+00:00",
  172. "program": [[{"kind": "musique", "track": {**track, "title": "Rêverie"}}, 300.0],
  173. [{"kind": "musique", "track": {**track, "title": "Arabesque"}}, 300.0]],
  174. "recent_custom": ["Je range ma chambre"],
  175. "recent_kinds": ["pensee", "musique"],
  176. }), encoding="utf-8")
  177. manager = self.restore(T0 + timedelta(minutes=4))
  178. self.assertEqual(manager.status, Status(Kind.MUSIC, track=Track("Rêverie", "Debussy", 300)))
  179. self.assertEqual([step.track.title for step, _ in manager.program], ["Arabesque"])
  180. self.assertEqual(manager.step_end, T0 + timedelta(minutes=7))
  181. self.assertEqual(list(manager.recent_custom), ["Je range ma chambre"])
  182. self.assertEqual(list(manager.recent_kinds), [Kind.THOUGHT, Kind.MUSIC])
  183. def test_finished_during_the_restart(self):
  184. self.save(self.episodes(3)[0][0], 10, self.episodes(4, 5))
  185. self.assert_fresh_start(self.restore(T0 + timedelta(hours=2)))
  186. def test_finished_exactly_at_restart(self):
  187. self.save(self.episodes(3)[0][0], 10, self.episodes(4))
  188. self.assert_fresh_start(self.restore(T0 + timedelta(minutes=34)))
  189. def test_recent_lists_kept_even_when_the_status_is_over(self):
  190. manager = make_manager(self.folder)
  191. manager.status = Status(Kind.THOUGHT, title="Je range ma chambre")
  192. manager.step_end = T0 + timedelta(minutes=10)
  193. manager.recent_custom.append("Je range ma chambre")
  194. manager.recent_kinds.extend([Kind.MUSIC, Kind.THOUGHT])
  195. with frozen_at(T0):
  196. manager.save()
  197. restored = self.restore(T0 + timedelta(hours=1))
  198. self.assert_fresh_start(restored)
  199. self.assertEqual(list(restored.recent_custom), ["Je range ma chambre"])
  200. self.assertEqual(list(restored.recent_kinds), [Kind.MUSIC, Kind.THOUGHT])
  201. def test_cut_by_the_night(self):
  202. # Partie commencée à 1 h (Paris) pour 6 h : la nuit de 2 h à 6 h l'a interrompue.
  203. saved_at = paris(2026, 11, 3, 1, 0).astimezone(UTC)
  204. game = Status(Kind.GAME, title="Celeste")
  205. self.save(game, 6 * 60, saved_at=saved_at, night=(2, 6))
  206. self.assert_fresh_start(self.restore(paris(2026, 11, 3, 6, 10), night=(2, 6)))
  207. # Sans nuit, ou redémarré avant 2 h, la partie continue.
  208. self.assertEqual(self.restore(paris(2026, 11, 3, 6, 10), night=None).status, game)
  209. self.assertEqual(self.restore(paris(2026, 11, 3, 1, 30), night=(2, 6)).status, game)
  210. def test_night_saved(self):
  211. self.save(Status(Kind.NIGHT), 60)
  212. self.assert_fresh_start(self.restore(T0 + timedelta(minutes=10)))
  213. def test_kind_disabled_since(self):
  214. self.save(Status(Kind.GAME, title="Celeste"), 60)
  215. self.assert_fresh_start(self.restore(T0 + timedelta(minutes=10), weights={**WEIGHTS, "jeu": 0}))
  216. without_games = {name: weight for name, weight in WEIGHTS.items() if name != "jeu"}
  217. self.assert_fresh_start(self.restore(T0 + timedelta(minutes=10), weights=without_games))
  218. def test_missing_file(self):
  219. with self.assertNoLogs("chatbot.presence", "WARNING"):
  220. manager = self.restore(T0)
  221. self.assert_fresh_start(manager)
  222. def test_unreadable_file(self):
  223. self.save(Status(Kind.GAME, title="Celeste"), 60)
  224. valid = json.loads(self.state_file.read_text(encoding="utf-8"))
  225. contents = {
  226. "vide": "",
  227. "tronqué": json.dumps(valid)[:-15],
  228. "liste": "[]",
  229. "sans statut": json.dumps({**valid, "status": None}),
  230. "type inconnu": json.dumps({**valid, "status": {"kind": "volant"}}),
  231. "morceau incomplet": json.dumps({**valid, "status": {"kind": "musique", "track": {"title": "x"}}}),
  232. "date illisible": json.dumps({**valid, "step_end": "demain"}),
  233. "date en nombre": json.dumps({**valid, "saved_at": 12}),
  234. "durée illisible": json.dumps({**valid, "program": [[{"kind": "jeu"}, "longtemps"]]}),
  235. "programme mal formé": json.dumps({**valid, "program": 5}),
  236. "statuts récents mal formés": json.dumps({**valid, "recent_custom": 5}),
  237. }
  238. for name, text in contents.items():
  239. with self.subTest(name):
  240. self.state_file.write_text(text, encoding="utf-8")
  241. with self.assertLogs("chatbot.presence", "WARNING"):
  242. manager = self.restore(T0 + timedelta(minutes=10))
  243. self.assertEqual(manager.status, Status(Kind.ONLINE))
  244. self.assertEqual(manager.program, [])
  245. def test_date_without_timezone(self):
  246. self.save(Status(Kind.GAME, title="Celeste"), 60)
  247. valid = json.loads(self.state_file.read_text(encoding="utf-8"))
  248. self.state_file.write_text(json.dumps({**valid, "step_end": "2026-11-03T10:00:00"}), encoding="utf-8")
  249. with self.assertLogs("chatbot.presence", "WARNING"):
  250. manager = self.restore(T0 + timedelta(minutes=10))
  251. self.assertEqual(manager.status, Status(Kind.ONLINE))
  252. async def step(self, manager: PresenceManager, now: datetime) -> tuple[mock.AsyncMock, mock.AsyncMock]:
  253. """Une étape de la boucle, sans tirage au sort ni Discord : renvoie (draw, set_status)."""
  254. draw = mock.AsyncMock(return_value=[(Status(Kind.ONLINE), 600)])
  255. set_status = mock.AsyncMock()
  256. with mock.patch.object(manager, "draw", draw), mock.patch.object(manager, "set_status", set_status), \
  257. frozen_at(now):
  258. await manager._step()
  259. return draw, set_status
  260. async def test_loop_goes_on_with_the_restored_program(self):
  261. self.save(self.episodes(3)[0][0], 10, self.episodes(4, 5))
  262. manager = self.restore(T0 + timedelta(minutes=20)) # épisode 4 jusqu'à +34
  263. draw, set_status = await self.step(manager, T0 + timedelta(minutes=20))
  264. draw.assert_not_called()
  265. set_status.assert_not_called()
  266. draw, set_status = await self.step(manager, T0 + timedelta(minutes=35))
  267. draw.assert_not_called()
  268. set_status.assert_awaited_once_with(Status(Kind.ANIME, title="Frieren", episode=5))
  269. async def test_loop_draws_after_a_finished_status(self):
  270. self.save(self.episodes(3)[0][0], 10)
  271. manager = self.restore(T0 + timedelta(hours=1))
  272. draw, set_status = await self.step(manager, T0 + timedelta(hours=1))
  273. draw.assert_awaited_once()
  274. set_status.assert_awaited_once_with(Status(Kind.ONLINE))
  275. if __name__ == "__main__":
  276. unittest.main()