#!/usr/bin/env python3 # © 2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import os import tempfile import unittest from contextlib import contextmanager from pathlib import Path from unittest.mock import patch from script.todo.mail.accounts import account_from_preset from script.todo.mail.smtp_send import SmtpError, build_message from script.todo.mail.store import MessageMeta, Store from script.todo.mail.tui import ( MailboxRef, Session, append_attachment_path, deliver, edit_in_external_editor, parse_paths, parse_recipients, resolve_sent_folder, ) class TestParseRecipients(unittest.TestCase): def test_single(self): self.assertEqual(parse_recipients("a@y.ca"), ["a@y.ca"]) def test_comma_separated(self): self.assertEqual( parse_recipients("a@y.ca, b@y.ca"), ["a@y.ca", "b@y.ca"] ) def test_semicolon_also_works(self): self.assertEqual( parse_recipients("a@y.ca; b@y.ca"), ["a@y.ca", "b@y.ca"] ) def test_keeps_display_names(self): self.assertEqual( parse_recipients("Alice , b@y.ca"), ["Alice ", "b@y.ca"], ) def test_drops_empty_fragments(self): self.assertEqual( parse_recipients("a@y.ca,, ,b@y.ca"), ["a@y.ca", "b@y.ca"] ) def test_empty_string(self): self.assertEqual(parse_recipients(""), []) class TestParsePaths(unittest.TestCase): """Contrairement aux destinataires, les chemins de pièces jointes ne se séparent QUE par un point-virgule : une virgule est légale dans un nom de fichier (`Facture, T3.pdf`).""" def test_single(self): self.assertEqual(parse_paths("a.pdf"), ["a.pdf"]) def test_comma_in_a_filename_survives(self): self.assertEqual(parse_paths("Facture, T3.pdf"), ["Facture, T3.pdf"]) def test_semicolon_separates(self): self.assertEqual(parse_paths("a.pdf; b.pdf"), ["a.pdf", "b.pdf"]) def test_double_quoted_path_is_unquoted(self): self.assertEqual(parse_paths('"a, b.pdf"'), ["a, b.pdf"]) def test_single_quoted_path_is_unquoted(self): self.assertEqual(parse_paths("'a.pdf'"), ["a.pdf"]) def test_drops_empty_fragments(self): self.assertEqual(parse_paths("a.pdf;; ;b.pdf"), ["a.pdf", "b.pdf"]) def test_empty_string(self): self.assertEqual(parse_paths(""), []) class TestAppendAttachmentPath(unittest.TestCase): """`append_attachment_path` est la seule logique testable du bouton Parcourir : le reste (suspendre Textual, ouvrir urwid) a besoin d'un écran monté ou d'un vrai terminal, voir `TestBrowseFilesButton`.""" def test_empty_field_gets_just_the_path(self): self.assertEqual(append_attachment_path("", "a.pdf"), "a.pdf") def test_appends_after_an_existing_path(self): self.assertEqual( append_attachment_path("a.pdf", "b.pdf"), "a.pdf; b.pdf" ) def test_field_already_ending_in_separator_is_not_doubled(self): self.assertEqual( append_attachment_path("a.pdf;", "b.pdf"), "a.pdf; b.pdf" ) def test_no_leading_separator_on_an_empty_field(self): self.assertFalse(append_attachment_path("", "a.pdf").startswith(";")) def test_none_is_treated_as_empty(self): self.assertEqual(append_attachment_path(None, "a.pdf"), "a.pdf") class TestExternalEditor(unittest.TestCase): def test_returns_what_the_editor_wrote(self): def runner(cmd): path = Path(cmd[-1]) path.write_text("écrit dans vim") return 0 self.assertEqual( edit_in_external_editor("départ", editor="vim", runner=runner), "écrit dans vim", ) def test_seeds_the_file_with_the_current_text(self): seen = {} def runner(cmd): seen["contenu"] = Path(cmd[-1]).read_text() return 0 edit_in_external_editor("brouillon", editor="vim", runner=runner) self.assertEqual(seen["contenu"], "brouillon") def test_non_zero_exit_keeps_the_original(self): def runner(cmd): Path(cmd[-1]).write_text("ignoré") return 1 self.assertEqual( edit_in_external_editor("départ", editor="vim", runner=runner), "départ", ) def test_missing_editor_keeps_the_original(self): def runner(cmd): raise FileNotFoundError("pas d'éditeur") self.assertEqual( edit_in_external_editor("départ", editor="absent", runner=runner), "départ", ) def test_temp_file_is_removed(self): seen = {} def runner(cmd): seen["chemin"] = Path(cmd[-1]) return 0 edit_in_external_editor("x", editor="vim", runner=runner) self.assertFalse(seen["chemin"].exists()) class _TransportQuiAccepte: """Le transport par défaut du harnais : il enregistre ce qu'on lui dépose, ce qui permet aussi de le relire quand un test s'y intéresse.""" def __init__(self): self.appended = [] def append(self, folder, raw, flags): self.appended.append((folder, raw, flags)) # Sentinelle : distingue « le test n'a rien demandé » de « le test veut # explicitement aucun transport ». `None` ne peut pas servir aux deux. _ACCEPTE_TOUT = object() class DeliverCase(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.account = account_from_preset("perso", "moi@x.ca", "generic") self.store = Store( self.account, mode="clear", base=Path(self.tmp.name) ) self.store.open() self.msg = build_message(self.account, "a@y.ca", "Devis", "Bonjour") def tearDown(self): self.store.close() self.tmp.cleanup() def session(self, online=True, transport=_ACCEPTE_TOUT): """Le transport par défaut ACCEPTE l'APPEND. Il valait `None` auparavant, ce qui faisait échouer l'APPEND sur un AttributeError : tout test qui ne s'intéressait pas au dépôt dans Envoyés empruntait donc le chemin d'ÉCHEC sans le savoir, et `test_sends_and_reports` — le test du chemin heureux — a passé des semaines à vérifier l'inverse de son nom. En production ce cas n'existe pas : `Syncer` n'est construit qu'avec un transport vivant (`tui.py`, `Syncer(store, connect_fn(...))`) et `deliver` refuse déjà les sessions hors ligne. Le défaut du harnais décrivait donc un état impossible. Rester sans transport est toujours possible — `transport=None` — mais doit être DÉLIBÉRÉ. """ class FakeSyncer: def __init__(self, transport): self.transport = transport self.synced = [] def sync_one(self, folder_name): self.synced.append(folder_name) if transport is _ACCEPTE_TOUT: transport = _TransportQuiAccepte() syncer = FakeSyncer(transport) if online else None return Session(self.account, self.store, syncer) class TestResolveSentFolder(DeliverCase): """Le préréglage (`account.sent_folder`) n'est qu'une supposition : le serveur annonce lui-même son dossier Envoyés par l'attribut `\\Sent`, que `imap_transport.parse_list_line` traduit en `role="sent"` et que `Syncer._sync_folder` enregistre via `store.upsert_folder`. Une fois ce rôle connu, il doit l'emporter sur la supposition.""" def test_uses_the_role_the_server_announced(self): self.store.upsert_folder("INBOX.Sent", "Envoyés", "sent") self.assertEqual(resolve_sent_folder(self.session()), "INBOX.Sent") def test_falls_back_to_the_preset_without_a_sent_role(self): self.store.upsert_folder("INBOX", "INBOX", "inbox") self.assertEqual( resolve_sent_folder(self.session()), self.account.sent_folder ) def test_falls_back_when_the_store_has_no_folder_yet(self): self.assertEqual( resolve_sent_folder(self.session()), self.account.sent_folder ) def test_falls_back_when_the_store_is_none(self): session = Session(self.account, None, None) self.assertEqual( resolve_sent_folder(session), self.account.sent_folder ) def test_falls_back_when_the_store_read_raises(self): """Round 2 de la revue : un cache verrouillé ou corrompu ne doit JAMAIS faire lever cette fonction. Sans ça, l'exception s'échapperait de `deliver()` (l'appel se fait AVANT son `try`), remonterait à `ComposeScreen.action_send`, et un envoi SMTP déjà réussi se lirait comme un échec d'ENVOI — exactement la double-envoi que la conception de `deliver` existe pour empêcher.""" class BrokenStore: def folders(self): raise OSError("base verrouillée") session = Session(self.account, BrokenStore(), None) self.assertEqual( resolve_sent_folder(session), self.account.sent_folder ) class TestDeliver(DeliverCase): def test_sends_and_reports(self): """Le chemin HEUREUX, et il faut vraiment l'emprunter. Ce test a longtemps parcouru le chemin d'ÉCHEC : le harnais fournissait un transport `None`, l'APPEND partait en AttributeError, et l'unique assertion — « a@y.ca » dans le statut — était vraie du statut d'échec comme de celui du succès. Les deux assertions du bas sont ce qui distingue les deux, et donc ce qui protège encore si le défaut du harnais redevenait cassé. """ sent = [] status = deliver( self.session(), self.msg, send_fn=lambda acc, m, tr: sent.append(m) or ["a@y.ca"], ) self.assertEqual(len(sent), 1) self.assertIn("a@y.ca", status) # `⚠` et le balisage rouge sont posés par le chemin d'échec seul, et # ne dépendent pas de la langue — contrairement au texte traduit. self.assertNotIn("⚠", status) self.assertNotIn("[b red]", status) def test_appends_to_the_sent_folder(self): class FakeTransport: def __init__(self): self.appended = [] def append(self, folder, raw, flags): self.appended.append((folder, flags)) transport = FakeTransport() deliver( self.session(transport=transport), self.msg, send_fn=lambda acc, m, tr: ["a@y.ca"], ) self.assertEqual(transport.appended[0][0], self.account.sent_folder) self.assertIn("\\Seen", transport.appended[0][1]) def test_appends_to_the_resolved_folder_not_the_preset(self): """La couture qui compte : `resolve_sent_folder` seul ne suffit pas à prouver que `deliver` s'en sert réellement pour l'APPEND.""" class FakeTransport: def __init__(self): self.appended = [] def append(self, folder, raw, flags): self.appended.append((folder, flags)) self.store.upsert_folder("INBOX.Sent", "Envoyés", "sent") self.assertNotEqual("INBOX.Sent", self.account.sent_folder) transport = FakeTransport() deliver( self.session(transport=transport), self.msg, send_fn=lambda acc, m, tr: ["a@y.ca"], ) self.assertEqual(transport.appended[0][0], "INBOX.Sent") def test_append_failure_does_not_lose_the_send(self): """Le message est parti : un APPEND raté ne doit pas se lire comme un échec.""" class BrokenTransport: def append(self, folder, raw, flags): raise OSError("dossier Envoyés introuvable") status = deliver( self.session(transport=BrokenTransport()), self.msg, send_fn=lambda acc, m, tr: ["a@y.ca"], ) self.assertIn("a@y.ca", status) def test_append_failure_is_unmistakable_and_logged(self): """Round 17 : l'échec ne doit plus se lire comme un simple suffixe en bout de ligne — il doit ressortir (préfixe, mis en évidence) et laisser une trace dans le journal.""" class BrokenTransport: def append(self, folder, raw, flags): raise OSError("dossier Envoyés introuvable") with self.assertLogs("script.todo.mail.tui", level="ERROR"): status = deliver( self.session(transport=BrokenTransport()), self.msg, send_fn=lambda acc, m, tr: ["a@y.ca"], ) self.assertIn("a@y.ca", status) self.assertTrue(status.startswith("[b red]")) self.assertIn("dossier Envoyés introuvable", status) def test_offline_session_refuses(self): with self.assertRaises(SmtpError): deliver(self.session(online=False), self.msg) def test_send_failure_is_propagated(self): def boom(acc, m, tr): raise SmtpError("550 refus") with self.assertRaises(SmtpError): deliver(self.session(), self.msg, send_fn=boom) def test_successful_append_triggers_a_targeted_sync_of_sent(self): """Design (`docs/superpowers/specs/2026-08-02-email-tui-design.md`, ligne 308) : « écriture locale immédiate pour qu'il apparaisse sans attendre la sync ». Pas de ligne fabriquée — une sync ciblée sur Envoyés, puisque c'est le serveur qui attribue l'UID.""" class FakeTransport: def append(self, folder, raw, flags): pass session = self.session(transport=FakeTransport()) deliver(session, self.msg, send_fn=lambda acc, m, tr: ["a@y.ca"]) self.assertEqual(session.syncer.synced, [self.account.sent_folder]) def test_targeted_sync_uses_the_resolved_folder_not_the_preset(self): class FakeTransport: def append(self, folder, raw, flags): pass self.store.upsert_folder("INBOX.Sent", "Envoyés", "sent") self.assertNotEqual("INBOX.Sent", self.account.sent_folder) session = self.session(transport=FakeTransport()) deliver(session, self.msg, send_fn=lambda acc, m, tr: ["a@y.ca"]) self.assertEqual(session.syncer.synced, ["INBOX.Sent"]) def test_failed_append_does_not_trigger_a_sync(self): """Rien à synchroniser : l'APPEND n'a pas eu lieu.""" class BrokenTransport: def append(self, folder, raw, flags): raise OSError("dossier Envoyés introuvable") session = self.session(transport=BrokenTransport()) deliver(session, self.msg, send_fn=lambda acc, m, tr: ["a@y.ca"]) self.assertEqual(session.syncer.synced, []) def test_appended_copy_has_no_bcc_header(self): """Le Cci ne doit pas fuir par la copie qui part par IMAP. `send()` retire déjà `X-ERPLibre-Bcc` avant l'envoi SMTP, mais la copie déposée dans Envoyés part par IMAP : sans `without_bcc`, le Cci redeviendrait un en-tête lisible sur le serveur. """ class FakeTransport: def __init__(self): self.appended = [] def append(self, folder, raw, flags): self.appended.append(raw) msg = build_message( self.account, "a@y.ca", "Devis", "Bonjour", bcc="secret@y.ca" ) transport = FakeTransport() deliver( self.session(transport=transport), msg, send_fn=lambda acc, m, tr: ["a@y.ca"], ) self.assertNotIn(b"X-ERPLibre-Bcc", transport.appended[0]) self.assertNotIn(b"secret@y.ca", transport.appended[0]) class TestComposeScreenMounted(unittest.IsolatedAsyncioTestCase): """Monte l'écran de composition pour de vrai, via `run_test()`. `MailApp` et `ComposeScreen` sont des classes locales à `run_tui` : rien ne les expose. On capte l'instance en interceptant le PREMIER `App.__init__` appelé pendant `run_tui(run_app=False, ...)` — un monkeypatch entièrement contenu à ce test, restauré dans un `finally`. `on_mount` lit aussi les préférences via `todo_prefs`, qui crée `~/.erplibre` s'il est absent : sans détourner `$HOME` vers un répertoire jetable, monter l'écran pour de vrai toucherait la machine. """ def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.account = account_from_preset("perso", "moi@x.ca", "generic") self.store = Store( self.account, mode="clear", base=Path(self.tmp.name) ) self.store.open() self.fake_home = tempfile.TemporaryDirectory() self._old_home = os.environ.get("HOME") os.environ["HOME"] = self.fake_home.name def tearDown(self): if self._old_home is None: os.environ.pop("HOME", None) else: os.environ["HOME"] = self._old_home self.fake_home.cleanup() self.store.close() self.tmp.cleanup() class _FakeSyncer: def __init__(self, transport): self.transport = transport def sync(self, progress=None): from types import SimpleNamespace return SimpleNamespace(new_messages=0, errors=[], purged=[]) def sync_one(self, folder_name): from types import SimpleNamespace return SimpleNamespace(new_messages=0, errors=[], folders=1) def fetch_body(self, folder, uid): return None class _FakeIMAPTransport: def __init__(self): self.appended = [] def append(self, folder, raw, flags): self.appended.append((folder, raw, flags)) class _FakeSMTPTransport: def quit(self): pass async def _mounted_app(self, imap_transport): import textual.app from script.todo.mail.tui import run_tui session = Session( self.account, self.store, self._FakeSyncer(imap_transport), password="hunter2", ) captured = [] orig_init = textual.app.App.__init__ def capturing_init(app_self, *a, **kw): orig_init(app_self, *a, **kw) captured.append(app_self) textual.app.App.__init__ = capturing_init try: run_tui(run_app=False, sessions=[session]) finally: textual.app.App.__init__ = orig_init return captured[-1] async def test_send_failure_keeps_the_screen_up_with_the_error(self): from textual.screen import ModalScreen from textual.widgets import Input, Static, TextArea import script.todo.mail.smtp_send as smtp_send_mod app = await self._mounted_app(self._FakeIMAPTransport()) orig_connect, orig_send = smtp_send_mod.connect, smtp_send_mod.send smtp_send_mod.connect = lambda account, password: ( self._FakeSMTPTransport() ) def boom(account, msg, transport): raise SmtpError("550 refus") smtp_send_mod.send = boom try: async with app.run_test() as pilot: await pilot.press("c") await pilot.pause() self.assertIsInstance(app.screen, ModalScreen) app.screen.query_one("#to", Input).value = "dest@example.com" app.screen.query_one("#subject", Input).value = "Sujet" app.screen.query_one("#body", TextArea).text = "Corps" await pilot.press("ctrl+s") await pilot.pause() # Le brouillon reste à l'écran, avec l'erreur du serveur. self.assertIsInstance(app.screen, ModalScreen) status = app.screen.query_one("#compose_status", Static) self.assertIn("550 refus", str(status.content)) finally: smtp_send_mod.connect = orig_connect smtp_send_mod.send = orig_send async def test_send_success_dismisses_and_files_a_copy(self): from textual.screen import ModalScreen from textual.widgets import Input, Static, TextArea import script.todo.mail.smtp_send as smtp_send_mod imap_transport = self._FakeIMAPTransport() app = await self._mounted_app(imap_transport) orig_connect, orig_send = smtp_send_mod.connect, smtp_send_mod.send smtp_send_mod.connect = lambda account, password: ( self._FakeSMTPTransport() ) smtp_send_mod.send = lambda account, msg, transport: [ "dest@example.com" ] try: async with app.run_test() as pilot: await pilot.press("c") await pilot.pause() app.screen.query_one("#to", Input).value = "dest@example.com" app.screen.query_one("#subject", Input).value = "Sujet" app.screen.query_one("#body", TextArea).text = "Corps" await pilot.press("ctrl+s") await pilot.pause() # L'écran s'est refermé ; le statut principal confirme l'envoi. self.assertNotIsInstance(app.screen, ModalScreen) status = app.query_one("#status", Static) self.assertIn("dest@example.com", str(status.content)) self.assertEqual( imap_transport.appended[0][0], self.account.sent_folder ) finally: smtp_send_mod.connect = orig_connect smtp_send_mod.send = orig_send async def test_forward_attaches_the_original_message(self): """Régression : `_open_with_original` construisait un brouillon de transfert portant le message original en pièce jointe `message/rfc822`, mais ne passait que `subject`/`body` à `ComposeScreen` — `action_send` reconstruit le message depuis le formulaire, donc l'original ne partait JAMAIS. Ce test regarde le message réellement remis à `send()`, pas les valeurs par défaut du formulaire : c'est la couture que Task 12 a trouvée trouée. `_original_message()` est fixée directement : sa propre logique (cache, réseau) est déjà couverte ailleurs, et n'est pas la couture visée ici. """ from textual.widgets import Input import script.todo.mail.smtp_send as smtp_send_mod original = build_message( self.account, "quelqu.un@ailleurs.ca", "Rapport trimestriel", "Contenu original à transférer", ) session = Session( self.account, self.store, self._FakeSyncer(self._FakeIMAPTransport()), password="hunter2", ) app = await self._mounted_app(self._FakeIMAPTransport()) app._original_message = lambda: (session, original) captured = {} def capture_send(account, msg, transport): captured["msg"] = msg return ["dest@example.com"] orig_connect, orig_send = smtp_send_mod.connect, smtp_send_mod.send smtp_send_mod.connect = lambda account, password: ( self._FakeSMTPTransport() ) smtp_send_mod.send = capture_send try: async with app.run_test() as pilot: await pilot.press("f") await pilot.pause() app.screen.query_one("#to", Input).value = "dest@example.com" await pilot.press("ctrl+s") await pilot.pause() finally: smtp_send_mod.connect = orig_connect smtp_send_mod.send = orig_send self.assertIn("msg", captured) sent = captured["msg"] attachment_types = [ part.get_content_type() for part in sent.iter_attachments() ] self.assertIn("message/rfc822", attachment_types) forwarded = next( part for part in sent.iter_attachments() if part.get_content_type() == "message/rfc822" ).get_payload(0) self.assertEqual(forwarded["Subject"], "Rapport trimestriel") class TestExternalEditorSuspendsTerminal(TestComposeScreenMounted): """`vim`/`nano` tourne par `subprocess` et a besoin du terminal — Textual le tient encore et continue d'y dessiner tant qu'on ne le lui a pas repris. Ce test ne peut pas vérifier la reprise RÉELLE du terminal (il faudrait un vrai tty, voir le rapport pour la vérification manuelle) : il vérifie seulement que `App.suspend()` est entré AVANT l'appel à l'éditeur, pas après ni pas du tout.""" async def test_suspend_wraps_the_editor_call(self): from textual.widgets import TextArea import script.todo.mail.tui as tui_mod app = await self._mounted_app(self._FakeIMAPTransport()) order = [] @contextmanager def fake_suspend(self_app): order.append("suspend") yield def fake_editor(text): order.append("editor") return "nouveau texte" orig_editor = tui_mod.edit_in_external_editor tui_mod.edit_in_external_editor = fake_editor try: async with app.run_test() as pilot: await pilot.press("c") await pilot.pause() # Une vraie frappe de touche, focus laissé où `c` l'a mis # (le champ « À » via `Input`) : `ctrl+e` est un accord de # contrôle, pas un caractère imprimable, donc `Input` ne le # capture pas pour l'insérer — contrairement à l'ancien `e` # nu, qu'un widget de texte avale avant qu'il n'atteigne la # liaison de touche de l'écran (constaté par un essai # isolé). C'est justement ce que corrige `ctrl+e`. with patch.object(type(app), "suspend", fake_suspend): await pilot.press("ctrl+e") await pilot.pause() self.assertEqual( app.screen.query_one("#body", TextArea).text, "nouveau texte", ) finally: tui_mod.edit_in_external_editor = orig_editor self.assertEqual(order, ["suspend", "editor"]) async def test_ctrl_e_reaches_the_binding_with_focus_on_the_body(self): """Le bug rapporté : `e` nu ne se déclenchait QUE si le focus se trouvait par hasard sur un bouton, jamais depuis la zone de texte du corps — le widget de texte avale le caractère imprimable avant qu'il n'atteigne la liaison. `ctrl+e` n'est pas un caractère imprimable : il doit déclencher l'éditeur même avec le focus sur `#body`.""" from textual.widgets import TextArea import script.todo.mail.tui as tui_mod app = await self._mounted_app(self._FakeIMAPTransport()) calls = [] @contextmanager def fake_suspend(self_app): yield def fake_editor(text): calls.append(text) return text orig_editor = tui_mod.edit_in_external_editor tui_mod.edit_in_external_editor = fake_editor try: async with app.run_test() as pilot: await pilot.press("c") await pilot.pause() body = app.screen.query_one("#body", TextArea) body.focus() await pilot.pause() with patch.object(type(app), "suspend", fake_suspend): await pilot.press("ctrl+e") await pilot.pause() finally: tui_mod.edit_in_external_editor = orig_editor self.assertEqual(len(calls), 1) class TestBrowseFilesButton(TestComposeScreenMounted): """Le bouton Parcourir ouvre `todo_file_browser.FileBrowser` sous `App.suspend()` — urwid et Textual ne peuvent pas se partager le terminal. Le sélecteur urwid lui-même n'est pas testable ici (il a besoin d'un vrai terminal) : voir le rapport pour la vérification manuelle.""" async def test_suspends_and_appends_the_chosen_path(self): from textual.widgets import Button, Input import script.todo.todo_file_browser as browser_mod app = await self._mounted_app(self._FakeIMAPTransport()) order = [] @contextmanager def fake_suspend(self_app): order.append("suspend") yield class FakeFileBrowser: def __init__(self, initial_path, callback): order.append("constructed") self.initial_path = initial_path self.callback = callback def run_main_frame(self): # `urwid.MainLoop.run()` avale `ExitMainLoop` (`with # suppress(ExitMainLoop): self._run()`) — c'est ce qui rend # normal, dans le vrai composant, l'appel à # `todo_file_browser.exit_program()` que fait le callback # réel. Le reproduire ici est nécessaire : sans ça, ce faux # laisserait l'exception s'échapper, ce qu'un VRAI # `FileBrowser` ne ferait jamais. import urwid order.append("run") try: self.callback("/chosen/devis.pdf") except urwid.ExitMainLoop: pass async with app.run_test() as pilot: await pilot.press("c") await pilot.pause() with ( patch.object(type(app), "suspend", fake_suspend), patch.object(browser_mod, "FileBrowser", FakeFileBrowser), ): app.screen.query_one("#browse_files", Button).press() await pilot.pause() self.assertEqual( app.screen.query_one("#files", Input).value, "/chosen/devis.pdf", ) self.assertEqual(order, ["suspend", "constructed", "run"]) async def test_a_failing_browser_leaves_the_draft_intact(self): """Un sélecteur qui échoue à s'ouvrir ne doit PAS emporter le brouillon : perdre un brouillon à cause d'un sélecteur cassé serait pire que ne pas avoir de sélecteur du tout.""" from textual.widgets import Button, Input, Static import script.todo.todo_file_browser as browser_mod app = await self._mounted_app(self._FakeIMAPTransport()) @contextmanager def broken_suspend(self_app): raise RuntimeError("terminal incompatible") yield # pragma: no cover - jamais atteint async with app.run_test() as pilot: await pilot.press("c") await pilot.pause() app.screen.query_one("#subject", Input).value = "Ne pas perdre" with patch.object(type(app), "suspend", broken_suspend): app.screen.query_one("#browse_files", Button).press() await pilot.pause() # Le brouillon est toujours là, et l'écran ne s'est pas fermé. self.assertEqual( app.screen.query_one("#subject", Input).value, "Ne pas perdre", ) status = app.screen.query_one("#compose_status", Static) self.assertIn("terminal incompatible", str(status.content)) class TestSyncSurfacesResync(TestComposeScreenMounted): """`Syncer.sync()` rend `report.purged` (dossiers vidés parce que le serveur a changé l'UIDVALIDITY) : `MailApp._sync` doit le montrer, pas seulement le calculer — sinon l'utilisateur ne sait jamais qu'une resynchronisation complète a eu lieu.""" class _FakeSyncerWithPurge: def __init__(self, transport): self.transport = transport def sync(self, progress=None): from types import SimpleNamespace return SimpleNamespace(new_messages=2, errors=[], purged=["INBOX"]) def fetch_body(self, folder, uid): return None async def test_purged_folders_are_shown_in_the_status(self): from textual.widgets import Static session = Session( self.account, self.store, self._FakeSyncerWithPurge(self._FakeIMAPTransport()), password="hunter2", ) app = await self._mounted_app(self._FakeIMAPTransport()) async with app.run_test() as pilot: # `on_mount` lance déjà sa propre synchronisation en tâche de # fond : l'attendre évite une course avec l'appel explicite # ci-dessous, qui écraserait sinon le statut au hasard. await app.workers.wait_for_complete() app._sync([session]) await pilot.pause() status = app.query_one("#status", Static) self.assertIn("INBOX", str(status.content)) class TestSyncSurfacesErrors(TestComposeScreenMounted): """`report.errors` porte le texte exact de l'échec (`"dossier : exc"`, voir `imap_sync.Syncer.sync`) : avant ce correctif, `MailApp._sync` n'en affichait que le COMPTE (`"— 1 erreurs"`), perdant le texte que l'utilisateur aurait besoin de lire pour diagnostiquer quoi que ce soit.""" class _FakeSyncerWithErrors: def __init__(self, transport): self.transport = transport def sync(self, progress=None): from types import SimpleNamespace return SimpleNamespace( new_messages=0, errors=["Archives : 501 refus du serveur"], purged=[], ) def fetch_body(self, folder, uid): return None async def test_the_error_text_is_shown_not_just_the_count(self): from textual.widgets import Static session = Session( self.account, self.store, self._FakeSyncerWithErrors(self._FakeIMAPTransport()), password="hunter2", ) app = await self._mounted_app(self._FakeIMAPTransport()) async with app.run_test() as pilot: await app.workers.wait_for_complete() app._sync([session]) await pilot.pause() status = app.query_one("#status", Static) self.assertIn( "Archives : 501 refus du serveur", str(status.content) ) async def test_additional_errors_are_counted_alongside_the_first(self): from textual.widgets import Static class _FakeSyncerWithManyErrors: def __init__(self, transport): self.transport = transport def sync(self, progress=None): from types import SimpleNamespace return SimpleNamespace( new_messages=0, errors=["INBOX : 501 refus", "Archives : 502 refus"], purged=[], ) def fetch_body(self, folder, uid): return None session = Session( self.account, self.store, _FakeSyncerWithManyErrors(self._FakeIMAPTransport()), password="hunter2", ) app = await self._mounted_app(self._FakeIMAPTransport()) async with app.run_test() as pilot: await app.workers.wait_for_complete() app._sync([session]) await pilot.pause() status = app.query_one("#status", Static) text = str(status.content) self.assertIn("INBOX : 501 refus", text) self.assertIn("+1", text) class TestSyncLogsTotalFailure(TestComposeScreenMounted): """Quand `session.sync()` lève directement (connexion totalement perdue, pas un simple dossier récalcitrant), `MailApp._sync` affichait déjà le message d'erreur — mais sans jamais le journaliser.""" class _FakeSyncerThatRaises: def __init__(self, transport): self.transport = transport def sync(self, progress=None): raise OSError("connexion perdue") def fetch_body(self, folder, uid): return None async def test_total_failure_is_logged(self): session = Session( self.account, self.store, self._FakeSyncerThatRaises(self._FakeIMAPTransport()), password="hunter2", ) app = await self._mounted_app(self._FakeIMAPTransport()) async with app.run_test() as pilot: await app.workers.wait_for_complete() with self.assertLogs("script.todo.mail.tui", level="ERROR"): app._sync([session]) await pilot.pause() class TestSyncSerializesAccess(TestComposeScreenMounted): """L'auto-refresh et un `r`/`R` manuel lancent chacun `_sync` via `run_worker(thread=True)`, avec `exclusive=False` : deux passes peuvent donc tourner en vrais threads en même temps, et `imaplib` n'est pas thread-safe. `_sync_lock` doit les sérialiser — l'annulation ne joue aucun rôle ici, elle ne s'applique pas aux workers de type thread.""" class _SlowFakeSyncer: """Incrémente un compteur partagé pendant `sync()` : si deux passes s'exécutent en même temps, le compteur dépasse 1 au moins une fois.""" def __init__(self, transport, active, guard, overlap): self.transport = transport self._active = active self._guard = guard self._overlap = overlap def sync(self, progress=None): import time from types import SimpleNamespace with self._guard: self._active["n"] += 1 if self._active["n"] > 1: self._overlap.set() time.sleep(0.05) with self._guard: self._active["n"] -= 1 return SimpleNamespace(new_messages=0, errors=[], purged=[]) def fetch_body(self, folder, uid): return None async def test_concurrent_syncs_are_serialized(self): import asyncio import threading overlap = threading.Event() active = {"n": 0} guard = threading.Lock() session_a = Session( self.account, self.store, self._SlowFakeSyncer( self._FakeIMAPTransport(), active, guard, overlap ), password="hunter2", ) session_b = Session( self.account, self.store, self._SlowFakeSyncer( self._FakeIMAPTransport(), active, guard, overlap ), password="hunter2", ) app = await self._mounted_app(self._FakeIMAPTransport()) async with app.run_test() as pilot: await app.workers.wait_for_complete() t1 = threading.Thread(target=app._sync, args=([session_a],)) t2 = threading.Thread(target=app._sync, args=([session_b],)) t1.start() t2.start() # Une attente asynchrone, pas `Thread.join()` : bloquer la # boucle d'évènements empêcherait `call_from_thread` (utilisé par # `set_status` depuis ces threads) de jamais s'exécuter. while t1.is_alive() or t2.is_alive(): await asyncio.sleep(0.01) await pilot.pause() self.assertFalse(overlap.is_set()) class TestPreviewShowsFullDate(TestComposeScreenMounted): """`format_date` reste compact pour la colonne de la liste — l'aperçu d'un message doit montrer la date PLEINE, sans avoir à deviner l'année ou le jour à partir de la date du jour.""" async def test_header_shows_the_full_send_date(self): from textual.widgets import Static from script.todo.mail.tui_text import format_date_full meta = MessageMeta( uid=1, date=1785580860, size=100, flags="", msgid="<1@x.ca>", frm="Alice ", to="moi@x.ca", subject="Devis", snippet="", ) ref = MailboxRef( account_name=self.account.name, folder_name="INBOX", display="INBOX", unseen=0, ) app = await self._mounted_app(self._FakeIMAPTransport()) async with app.run_test() as pilot: await app.workers.wait_for_complete() # `current_meta`/`current_ref` sont la couture éprouvée ailleurs # dans ce fichier (voir `test_forward_attaches_the_original_message`) : # elle isole ce test du mécanisme de curseur du `DataTable`, qui # n'est pas ce que ce test vise à vérifier. app.current_ref = ref app.current_meta = lambda: meta app.show_preview() await pilot.pause() preview = app.query_one("#preview", Static) self.assertIn(format_date_full(meta.date), str(preview.content)) class TestPreviewNeverParsesTheMessageAsMarkup(TestComposeScreenMounted): """Signalé sur un VRAI courriel (une infolettre Netflix) : le corps portait un jeton de suivi entre crochets, que Textual analysait comme une balise — `MarkupError`, et le message devenait illisible. Expéditeur, sujet et corps viennent du message, donc de n'importe qui. `escape()` ne suffit pas : il laisse ces formes intactes. Seul un `Text` n'est jamais analysé. """ JETON = ( "[&g=ef7085be-63eb-4622&MESSAGE_GUID=ef7085be&trkId=13710079" "&msg_token=EQIAmQABAYEAE9uAN9GfY9%2FDdS8pu2y7FrNuD7H%3D]" ) async def _apercu( self, frm="Netflix ", subject="Grand Theft Auto VI", corps="", ): from textual.widgets import Static meta = MessageMeta( uid=1, date=1785580860, size=100, flags="", msgid="<1@x.ca>", frm=frm, to="moi@x.ca", subject=subject, snippet="", ) ref = MailboxRef( account_name=self.account.name, folder_name="INBOX", display="INBOX", unseen=0, ) brut = ( b"From: x@y.ca\r\nSubject: s\r\n" b"Content-Type: text/plain; charset=utf-8\r\n\r\n" + corps.encode("utf-8") ) app = await self._mounted_app(self._FakeIMAPTransport()) async with app.run_test() as pilot: await app.workers.wait_for_complete() app.current_ref = ref app.current_meta = lambda: meta app.session_for(self.account.name).syncer.fetch_body = ( lambda f, u: brut ) app.show_preview() await pilot.pause() return str(app.query_one("#preview", Static).content) async def test_a_bracket_token_in_the_body_is_shown_not_parsed(self): rendu = await self._apercu(corps=f"Bonjour {self.JETON} fin") self.assertIn("msg_token", rendu) async def test_a_bracket_token_in_the_subject_is_harmless(self): """Le sujet passait par la même chaîne de balisage que le corps.""" rendu = await self._apercu(subject=f"Promo {self.JETON}") self.assertIn("Promo", rendu) async def test_a_bracket_token_in_the_sender_is_harmless(self): rendu = await self._apercu(frm=f"Pub {self.JETON} ") self.assertIn("Pub", rendu) async def test_the_labels_are_still_emphasised(self): """Le contrôle qui empêche la correction paresseuse : tout rendre littéral en supprimant le gras passerait les tests ci-dessus.""" from textual.widgets import Static meta = MessageMeta( uid=1, date=1785580860, size=100, flags="", msgid="<1@x.ca>", frm="a@y.ca", to="moi@x.ca", subject="Devis", snippet="", ) ref = MailboxRef( account_name=self.account.name, folder_name="INBOX", display="INBOX", unseen=0, ) app = await self._mounted_app(self._FakeIMAPTransport()) async with app.run_test() as pilot: await app.workers.wait_for_complete() app.current_ref = ref app.current_meta = lambda: meta app.show_preview() await pilot.pause() contenu = app.query_one("#preview", Static).content styles = [str(s.style) for s in contenu.spans] self.assertTrue( any("bold" in s for s in styles), f"aucun libellé en gras : {styles}", ) class TestSearchClear(TestComposeScreenMounted): """Le champ de recherche (`/`) n'avait aucun moyen de se vider : ni bouton, ni raccourci. Le bouton ✕ et Échap doivent vider `self.query` EN PLUS du champ — sinon la liste resterait filtrée par une requête devenue invisible, pire que pas de bouton du tout.""" def setUp(self): super().setUp() folder_id = self.store.upsert_folder("INBOX", "INBOX", "inbox") self.store.upsert_messages( folder_id, [ MessageMeta( uid=1, date=1785580860, size=10, flags="", msgid="<1@x.ca>", frm="Alice ", to="moi@x.ca", subject="Devis", snippet="", ), MessageMeta( uid=2, date=1785580860, size=10, flags="", msgid="<2@x.ca>", frm="Bob ", to="moi@x.ca", subject="CR réunion", snippet="", ), ], ) async def test_clear_button_restores_the_full_list(self): from textual.widgets import Button, DataTable, Input app = await self._mounted_app(self._FakeIMAPTransport()) async with app.run_test() as pilot: await app.workers.wait_for_complete() await pilot.press("slash") for ch in "devis": await pilot.press(ch) await pilot.pause() table = app.query_one("#list", DataTable) self.assertEqual(app.query, "devis") self.assertEqual(table.row_count, 1) app.query_one("#search_clear", Button).press() await pilot.pause() # La couture qui compte : pas seulement le champ vidé, mais # `self.query` aussi — sinon la liste resterait filtrée par # une requête devenue invisible. self.assertEqual(app.query, "") self.assertEqual(app.query_one("#search", Input).value, "") self.assertEqual(table.row_count, 2) async def test_escape_clears_the_search_when_it_has_focus(self): app = await self._mounted_app(self._FakeIMAPTransport()) async with app.run_test() as pilot: from textual.widgets import DataTable, Input await app.workers.wait_for_complete() app.query_one("#panes").add_class("fullscreen") await pilot.press("slash") for ch in "devis": await pilot.press(ch) await pilot.pause() self.assertEqual(app.query, "devis") await pilot.press("escape") await pilot.pause() self.assertEqual(app.query, "") self.assertEqual(app.query_one("#search", Input).value, "") table = app.query_one("#list", DataTable) self.assertEqual(table.row_count, 2) # Échap a été consommé par le vidage de la recherche, pas par # la sortie du plein écran. self.assertTrue(app.query_one("#panes").has_class("fullscreen")) async def test_escape_elsewhere_still_leaves_fullscreen(self): app = await self._mounted_app(self._FakeIMAPTransport()) async with app.run_test() as pilot: await app.workers.wait_for_complete() app.query_one("#panes").add_class("fullscreen") await pilot.pause() await pilot.press("escape") await pilot.pause() self.assertFalse(app.query_one("#panes").has_class("fullscreen")) async def test_clearing_the_search_is_discoverable_in_the_footer(self): """Round 2 de la revue : le bouton ✕ a un `tooltip`, mais Textual ne déclenche les tooltips que sur `MouseMove` (vérifié dans `screen.py`) — aucun clavier n'y mène. Le vrai chemin accessible est la description traduite qu'affiche le pied d'écran, et SEULEMENT pendant que le champ a le focus (`Screen.active_bindings`, que `Footer` lit directement).""" from textual.widgets import Input from script.todo.todo_i18n import t app = await self._mounted_app(self._FakeIMAPTransport()) async with app.run_test() as pilot: await app.workers.wait_for_complete() # Hors du champ : la liaison Échap de l'appli reste celle # d'avant, masquée — inchangée par ce correctif. outside = app.screen.active_bindings["escape"] self.assertFalse(outside.binding.show) await pilot.press("slash") await pilot.pause() # Dans le champ : une AUTRE liaison Échap, traduite et visible, # prend le dessus — c'est elle que le pied d'écran affiche. focused = app.screen.active_bindings["escape"] self.assertTrue(focused.binding.show) self.assertEqual( focused.binding.description, t("mail_search_clear") ) self.assertIsInstance(app.focused, Input) async def test_clear_search_does_not_refresh_the_list_twice(self): """`clear_search` met `self.query` à jour ET rafraîchit tout de suite ; vider le champ poste aussi un `Input.Changed`, que `on_input_changed` aurait traité une seconde fois sans son garde-fou (`event.value == self.query`).""" app = await self._mounted_app(self._FakeIMAPTransport()) async with app.run_test() as pilot: await app.workers.wait_for_complete() await pilot.press("slash") for ch in "devis": await pilot.press(ch) await pilot.pause() calls = [] original = app.refresh_list def counting_refresh_list(): calls.append(1) original() app.refresh_list = counting_refresh_list app.clear_search() await pilot.pause() self.assertEqual(len(calls), 1) if __name__ == "__main__": unittest.main()