Le formateur de ce dépôt est ruff depuis qu'il remplace black, qui ne connaît aucune cible au-delà de py313 ; ce passage applique sa norme à l'arbre entier, d'un coup, pour qu'aucun commit de fond n'ait à porter du style. L'écart tient presque entièrement aux chaînes coupées à la main que ruff recolle quand elles tiennent sur une ligne, et aux « with » multiples qu'il regroupe : aucune valeur ne change, et les clés de traduction non plus. Vérifié : la suite unitaire reste verte après le passage, et le contrôle de syntaxe ne signale rien. --- EN --- This repository's formatter is ruff since it replaced black, which knows no target beyond py313; this pass applies its standard to the whole tree at once, so that no substantive commit has to carry style. The difference is almost entirely the hand-split strings ruff joins back when they fit on one line, and the multiple "with" it merges: no value changes, nor do the translation keys. Checked: the unit suite stays green after the pass, and the syntax check reports nothing. Assisted-by: Claude Opus 5
390 lines
15 KiB
Python
390 lines
15 KiB
Python
#!/usr/bin/env python3
|
|
# © 2026 TechnoLibre (http://www.technolibre.ca)
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
"""Un message livré par une synchronisation ou un envoi doit apparaître dans
|
|
la liste SANS redémarrer le client — le bug réel qui a mené à cette tâche.
|
|
|
|
L'APPEND et la synchronisation fonctionnaient déjà : après un redémarrage, le
|
|
message est là. C'était donc l'ÉCRAN qui restait périmé — `reload_folders()`
|
|
ne rafraîchissait la liste de messages que quand AUCUN dossier n'était
|
|
sélectionné, or un dossier est toujours déjà ouvert en pratique. Chaque test
|
|
ci-dessous fait tourner le VRAI chemin (`_sync`, ou l'écran de composition
|
|
via `ctrl+s`) plutôt que d'appeler `refresh_current_folder()` directement :
|
|
un test qui ne franchit pas ce seuil ne prouverait rien sur le bug observé.
|
|
"""
|
|
|
|
import os
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
from script.todo.mail.accounts import account_from_preset
|
|
from script.todo.mail.imap_sync import (
|
|
FolderInfo,
|
|
HeaderInfo,
|
|
SelectInfo,
|
|
Syncer,
|
|
)
|
|
from script.todo.mail.store import MessageMeta, Store
|
|
from script.todo.mail.tui import Session
|
|
|
|
|
|
class FakeImapTransport:
|
|
"""Assez de protocole IMAP en mémoire pour faire tourner le VRAI
|
|
`Syncer` (copie allégée de celle de `test_mail_sync.py`) : la garantie
|
|
que ces tests prouvent qu'une synchronisation RÉELLE rafraîchit l'écran,
|
|
pas une simulation qui écrirait directement dans le store."""
|
|
|
|
def __init__(self):
|
|
self.folders = {}
|
|
self.selected = None
|
|
self.appended = []
|
|
|
|
def add(self, folder, uid, subject="Sujet", date=None, flags=""):
|
|
f = self.folders.setdefault(folder, {"uidvalidity": 1, "messages": {}})
|
|
f["messages"][uid] = HeaderInfo(
|
|
uid=uid,
|
|
date=date if date is not None else 1_700_000_000 + uid,
|
|
size=100,
|
|
flags=flags,
|
|
msgid=f"<{uid}@x.ca>",
|
|
frm="eux@x.ca",
|
|
to="moi@x.ca",
|
|
subject=subject,
|
|
)
|
|
|
|
def list_folders(self):
|
|
return [FolderInfo(name=n) for n in sorted(self.folders)]
|
|
|
|
def select(self, folder):
|
|
self.selected = folder
|
|
f = self.folders.setdefault(folder, {"uidvalidity": 1, "messages": {}})
|
|
uids = list(f["messages"])
|
|
return SelectInfo(
|
|
uidvalidity=f["uidvalidity"],
|
|
uidnext=(max(uids) + 1) if uids else 1,
|
|
exists=len(uids),
|
|
)
|
|
|
|
def search_uids(self, since_uid):
|
|
f = self.folders[self.selected]
|
|
return sorted(u for u in f["messages"] if u >= since_uid)
|
|
|
|
def fetch_headers(self, uids):
|
|
f = self.folders[self.selected]
|
|
return [f["messages"][u] for u in uids if u in f["messages"]]
|
|
|
|
def fetch_flags(self, uids):
|
|
f = self.folders[self.selected]
|
|
return [
|
|
(u, f["messages"][u].flags) for u in uids if u in f["messages"]
|
|
]
|
|
|
|
def append(self, folder, raw, flags):
|
|
self.appended.append((folder, raw, flags))
|
|
|
|
def logout(self):
|
|
pass
|
|
|
|
|
|
class RefreshCase(unittest.IsolatedAsyncioTestCase):
|
|
"""Monte `MailApp` pour de vrai, `$HOME` détourné — comme
|
|
`test_mail_tui_log.py` : `on_mount` lit `todo_prefs`, qui crée
|
|
`~/.erplibre` s'il est absent."""
|
|
|
|
def setUp(self):
|
|
self.fake_home = tempfile.TemporaryDirectory()
|
|
self._old_home = os.environ.get("HOME")
|
|
os.environ["HOME"] = self.fake_home.name
|
|
|
|
self.cache_dir = tempfile.TemporaryDirectory()
|
|
self.account = account_from_preset("perso", "moi@x.ca", "generic")
|
|
self.account.cache_mode = "clear"
|
|
self.store = Store(
|
|
self.account, mode="clear", base=Path(self.cache_dir.name)
|
|
)
|
|
self.store.open()
|
|
self.imap = FakeImapTransport()
|
|
self.syncer = Syncer(self.store, self.imap)
|
|
self.session = Session(
|
|
self.account, self.store, self.syncer, password="hunter2"
|
|
)
|
|
|
|
def tearDown(self):
|
|
self.store.close()
|
|
if self._old_home is None:
|
|
os.environ.pop("HOME", None)
|
|
else:
|
|
os.environ["HOME"] = self._old_home
|
|
self.fake_home.cleanup()
|
|
self.cache_dir.cleanup()
|
|
|
|
async def _mounted_app(self, sessions=None):
|
|
import textual.app
|
|
|
|
from script.todo.mail.tui import run_tui
|
|
|
|
sessions = sessions if sessions is not None else [self.session]
|
|
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=sessions)
|
|
finally:
|
|
textual.app.App.__init__ = orig_init
|
|
return captured[-1]
|
|
|
|
|
|
class TestSyncRefreshesTheOpenFolder(RefreshCase):
|
|
async def test_a_message_that_arrives_during_sync_appears_without_restart(
|
|
self,
|
|
):
|
|
from textual.widgets import DataTable
|
|
|
|
self.imap.add("INBOX", 1, subject="Ancien")
|
|
app = await self._mounted_app()
|
|
async with app.run_test() as pilot:
|
|
await app.workers.wait_for_complete()
|
|
await pilot.pause()
|
|
|
|
table = app.query_one("#list", DataTable)
|
|
self.assertEqual(table.row_count, 1)
|
|
# Le dossier est déjà ouvert — l'état exact où
|
|
# `reload_folders()` ratait le rafraîchissement de la liste.
|
|
self.assertIsNotNone(app.current_ref)
|
|
|
|
self.imap.add("INBOX", 2, subject="Nouveau", date=1_800_000_000)
|
|
app._sync([self.session])
|
|
await pilot.pause()
|
|
|
|
table = app.query_one("#list", DataTable)
|
|
self.assertEqual(table.row_count, 2)
|
|
subjects = [table.get_row_at(i)[2] for i in range(table.row_count)]
|
|
self.assertIn("Nouveau", subjects)
|
|
|
|
|
|
class TestSyncPreservesCursor(RefreshCase):
|
|
async def test_cursor_follows_the_same_message_across_a_refresh(self):
|
|
from textual.widgets import DataTable
|
|
|
|
self.imap.add("INBOX", 1, subject="Un", date=1_000)
|
|
self.imap.add("INBOX", 2, subject="Deux", date=2_000)
|
|
self.imap.add("INBOX", 3, subject="Trois", date=3_000)
|
|
app = await self._mounted_app()
|
|
async with app.run_test() as pilot:
|
|
await app.workers.wait_for_complete()
|
|
await pilot.pause()
|
|
|
|
table = app.query_one("#list", DataTable)
|
|
self.assertEqual(table.row_count, 3)
|
|
# Trié par date décroissante : Trois, Deux, Un.
|
|
table.move_cursor(row=1)
|
|
await pilot.pause()
|
|
highlighted = app.current_meta()
|
|
self.assertEqual(highlighted.subject, "Deux")
|
|
|
|
# Un message plus récent arrive : il se glisse EN TÊTE de
|
|
# liste — sans un suivi par UID, l'index 1 resterait
|
|
# sélectionné mais pointerait sur un autre message.
|
|
self.imap.add("INBOX", 4, subject="Quatre", date=4_000)
|
|
app._sync([self.session])
|
|
await pilot.pause()
|
|
|
|
self.assertEqual(table.row_count, 4)
|
|
still_highlighted = app.current_meta()
|
|
self.assertEqual(still_highlighted.subject, "Deux")
|
|
|
|
async def test_cursor_falls_back_sensibly_when_the_message_is_gone(self):
|
|
from textual.widgets import DataTable
|
|
|
|
self.imap.add("INBOX", 1, subject="Un", date=1_000)
|
|
self.imap.add("INBOX", 2, subject="Deux", date=2_000)
|
|
app = await self._mounted_app()
|
|
async with app.run_test() as pilot:
|
|
await app.workers.wait_for_complete()
|
|
await pilot.pause()
|
|
|
|
table = app.query_one("#list", DataTable)
|
|
table.move_cursor(row=0)
|
|
await pilot.pause()
|
|
self.assertEqual(app.current_meta().subject, "Deux")
|
|
|
|
# UIDVALIDITY change côté serveur (boîte recréée/renumérotée) :
|
|
# le VRAI `Syncer` vide alors le dossier (`purge_folder`) avant
|
|
# de le repeupler — le seul mécanisme réel par lequel un
|
|
# message connu peut disparaître du cache. « Deux » ne revient
|
|
# pas ; « Trois » prend sa place.
|
|
self.imap.folders["INBOX"]["uidvalidity"] = 2
|
|
del self.imap.folders["INBOX"]["messages"][2]
|
|
self.imap.add("INBOX", 3, subject="Trois", date=3_000)
|
|
app._sync([self.session])
|
|
await pilot.pause()
|
|
|
|
# Ne doit pas lever, et doit retomber sur un état affichable —
|
|
# celui que `DataTable.clear()` laisse déjà : en tête de liste.
|
|
# Vérifié par le CONTENU, pas seulement le compte de lignes :
|
|
# sans rafraîchissement du tout, la liste resterait Un + Deux
|
|
# (même compte de lignes, même index 0) — seul le contenu
|
|
# trahit une liste réellement rechargée.
|
|
table = app.query_one("#list", DataTable)
|
|
self.assertEqual(table.row_count, 2) # Un + Trois
|
|
self.assertEqual(table.cursor_row, 0)
|
|
self.assertEqual(table.get_row_at(0)[2], "Trois")
|
|
|
|
|
|
class TestSyncPreservesSearchFilter(RefreshCase):
|
|
async def test_filtered_out_messages_stay_hidden_after_a_refresh(self):
|
|
from textual.widgets import DataTable
|
|
|
|
self.imap.add("INBOX", 1, subject="Alpha")
|
|
app = await self._mounted_app()
|
|
async with app.run_test() as pilot:
|
|
await app.workers.wait_for_complete()
|
|
await pilot.pause()
|
|
|
|
app.query = "Alpha"
|
|
app.refresh_list()
|
|
table = app.query_one("#list", DataTable)
|
|
self.assertEqual(table.row_count, 1)
|
|
|
|
self.imap.add("INBOX", 2, subject="Beta")
|
|
app._sync([self.session])
|
|
await pilot.pause()
|
|
|
|
# Le cache SOUS le filtre doit avoir bougé — sinon ce test ne
|
|
# prouverait rien sur le rafraîchissement lui-même, seulement
|
|
# que « Beta » reste caché, vrai aussi bien quand rien ne se
|
|
# rafraîchit du tout.
|
|
self.assertEqual(len(app.metas), 2)
|
|
table = app.query_one("#list", DataTable)
|
|
self.assertEqual(table.row_count, 1)
|
|
self.assertEqual(table.get_row_at(0)[2], "Alpha")
|
|
|
|
|
|
class TestRefreshWithNoFolderSelected(RefreshCase):
|
|
async def test_does_not_raise_when_nothing_is_selected(self):
|
|
app = await self._mounted_app(sessions=[])
|
|
async with app.run_test() as pilot:
|
|
await app.workers.wait_for_complete()
|
|
await pilot.pause()
|
|
|
|
self.assertIsNone(app.current_ref)
|
|
app.refresh_current_folder() # ne doit pas lever
|
|
|
|
|
|
class TestSendRefreshesTheOpenFolder(RefreshCase):
|
|
"""`deliver()` classe une copie dans Envoyés via `sync_one` après un
|
|
envoi réussi (tâche 19) — sans le correctif de cette tâche, l'écran
|
|
reste périmé si Envoyés est déjà ouvert au moment d'envoyer, exactement
|
|
comme pour `_sync`."""
|
|
|
|
class _FakeSentSyncOne:
|
|
"""`sync_one` réel écrit dans le store après l'APPEND — cette
|
|
version fait la même écriture, sans re-simuler tout un serveur
|
|
IMAP : seul le point sous test compte ici, le rafraîchissement de
|
|
l'écran une fois le store à jour."""
|
|
|
|
def __init__(self, store, transport):
|
|
self.store = store
|
|
self.transport = transport
|
|
self.sync_one_calls = []
|
|
|
|
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
|
|
|
|
self.sync_one_calls.append(folder_name)
|
|
fid = self.store.upsert_folder(folder_name, folder_name, "sent")
|
|
self.store.upsert_messages(
|
|
fid,
|
|
[
|
|
MessageMeta(
|
|
uid=1,
|
|
date=1_700_000_000,
|
|
size=10,
|
|
flags="\\Seen",
|
|
msgid="<sent@x.ca>",
|
|
frm="moi@x.ca",
|
|
to="dest@example.com",
|
|
subject="Sujet",
|
|
snippet="",
|
|
)
|
|
],
|
|
)
|
|
return SimpleNamespace(new_messages=1, errors=[], folders=1)
|
|
|
|
def fetch_body(self, folder, uid):
|
|
return None
|
|
|
|
class _FakeSMTPTransport:
|
|
def quit(self):
|
|
pass
|
|
|
|
async def test_the_sent_folder_refreshes_after_sending(self):
|
|
from textual.screen import ModalScreen
|
|
from textual.widgets import DataTable, Input, TextArea
|
|
|
|
import script.todo.mail.smtp_send as smtp_send_mod
|
|
|
|
# Envoyés déjà connu du cache, comme après une synchronisation
|
|
# antérieure — le scénario du rapport : l'utilisateur regarde déjà
|
|
# ce dossier au moment d'envoyer.
|
|
self.store.upsert_folder(
|
|
self.account.sent_folder, self.account.sent_folder, "sent"
|
|
)
|
|
fake_syncer = self._FakeSentSyncOne(self.store, self.imap)
|
|
session = Session(
|
|
self.account, self.store, fake_syncer, password="hunter2"
|
|
)
|
|
|
|
app = await self._mounted_app(sessions=[session])
|
|
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 app.workers.wait_for_complete()
|
|
await pilot.pause()
|
|
|
|
self.assertEqual(
|
|
app.current_ref.folder_name, self.account.sent_folder
|
|
)
|
|
table = app.query_one("#list", DataTable)
|
|
self.assertEqual(table.row_count, 0)
|
|
|
|
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()
|
|
|
|
self.assertNotIsInstance(app.screen, ModalScreen)
|
|
self.assertEqual(
|
|
fake_syncer.sync_one_calls, [self.account.sent_folder]
|
|
)
|
|
|
|
table = app.query_one("#list", DataTable)
|
|
self.assertEqual(table.row_count, 1)
|
|
finally:
|
|
smtp_send_mod.connect = orig_connect
|
|
smtp_send_mod.send = orig_send
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|