2026-08-10 03:10:50 -04:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
# © 2026 TechnoLibre (http://www.technolibre.ca)
|
|
|
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
|
|
|
"""La fenêtre de diagnostic, touche `l` : la fin de `~/.erplibre/mail.log`,
|
|
|
|
|
et les erreurs de synchronisation de la session en cours — sans quitter le
|
|
|
|
|
client pour aller les lire dans un fichier.
|
|
|
|
|
|
|
|
|
|
Le piège que ce fichier vérifie explicitement : une fenêtre qui s'ouvre VIDE
|
|
|
|
|
reproduit exactement la plainte qui justifie son existence (« j'ai une
|
|
|
|
|
erreur, mais aucun log »). Chaque état — absent, vide, illisible, aucune
|
|
|
|
|
erreur de session — doit se dire en toutes lettres.
|
|
|
|
|
"""
|
[REF] format : passer l'outillage et les tests sous ruff
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
2026-09-24 13:30:31 -04:00
|
|
|
|
2026-08-10 03:10:50 -04:00
|
|
|
import os
|
|
|
|
|
import tempfile
|
|
|
|
|
import unittest
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
from script.todo.mail.accounts import account_from_preset
|
|
|
|
|
from script.todo.mail.store import Store
|
|
|
|
|
from script.todo.mail.tui import Session, read_log_tail
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestReadLogTail(unittest.TestCase):
|
|
|
|
|
"""`read_log_tail` est une fonction pure (aucun import Textual) : elle
|
|
|
|
|
se teste seule, sans monter d'écran."""
|
|
|
|
|
|
|
|
|
|
def setUp(self):
|
|
|
|
|
self.tmp = tempfile.TemporaryDirectory()
|
|
|
|
|
self.base = Path(self.tmp.name)
|
|
|
|
|
|
|
|
|
|
def tearDown(self):
|
|
|
|
|
# Un fichier laissé à 0o000 empêcherait parfois le nettoyage du
|
|
|
|
|
# dossier temporaire selon le système de fichiers : on restaure les
|
|
|
|
|
# droits avant de nettoyer, plutôt que de dépendre de ce détail.
|
|
|
|
|
for entry in self.base.glob("*"):
|
|
|
|
|
entry.chmod(0o644)
|
|
|
|
|
self.tmp.cleanup()
|
|
|
|
|
|
|
|
|
|
def test_missing_file_returns_explicit_message(self):
|
|
|
|
|
from script.todo.todo_i18n import t
|
|
|
|
|
|
|
|
|
|
lines, message = read_log_tail(self.base / "absent.log")
|
|
|
|
|
self.assertEqual(lines, [])
|
|
|
|
|
self.assertEqual(message, t("mail_log_missing"))
|
|
|
|
|
|
|
|
|
|
def test_empty_file_returns_explicit_message(self):
|
|
|
|
|
from script.todo.todo_i18n import t
|
|
|
|
|
|
|
|
|
|
path = self.base / "mail.log"
|
|
|
|
|
path.write_bytes(b"")
|
|
|
|
|
|
|
|
|
|
lines, message = read_log_tail(path)
|
|
|
|
|
self.assertEqual(lines, [])
|
|
|
|
|
self.assertEqual(message, t("mail_log_empty"))
|
|
|
|
|
|
|
|
|
|
def test_unreadable_file_returns_explicit_message(self):
|
|
|
|
|
from script.todo.todo_i18n import t
|
|
|
|
|
|
|
|
|
|
if os.geteuid() == 0:
|
|
|
|
|
self.skipTest(
|
|
|
|
|
"racine : les permissions de fichier ne bloquent rien"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
path = self.base / "mail.log"
|
|
|
|
|
path.write_text("2026-08-04 boum\n")
|
|
|
|
|
path.chmod(0o000)
|
|
|
|
|
|
|
|
|
|
lines, message = read_log_tail(path)
|
|
|
|
|
self.assertEqual(lines, [])
|
|
|
|
|
self.assertTrue(message.startswith(t("mail_log_unreadable")))
|
|
|
|
|
|
|
|
|
|
def test_tail_shows_only_the_last_lines(self):
|
|
|
|
|
path = self.base / "mail.log"
|
|
|
|
|
path.write_text("".join(f"ligne {i}\n" for i in range(1, 501)))
|
|
|
|
|
|
|
|
|
|
lines, message = read_log_tail(path, max_lines=50)
|
|
|
|
|
|
|
|
|
|
self.assertEqual(message, "")
|
|
|
|
|
self.assertEqual(len(lines), 50)
|
|
|
|
|
self.assertEqual(lines[0], "ligne 451")
|
|
|
|
|
self.assertEqual(lines[-1], "ligne 500")
|
|
|
|
|
|
|
|
|
|
def test_does_not_read_the_whole_file(self):
|
|
|
|
|
"""La contrainte centrale : un journal qui grossit sans borne ne
|
|
|
|
|
doit jamais être chargé en entier pour n'en montrer que la fin.
|
|
|
|
|
Vérifié en espionnant les octets RÉELLEMENT lus sur le fichier
|
|
|
|
|
cible, pas en devinant depuis le résultat."""
|
|
|
|
|
import builtins
|
|
|
|
|
|
|
|
|
|
path = self.base / "big.log"
|
|
|
|
|
line = ("x" * 100) + "\n"
|
|
|
|
|
with open(path, "w", encoding="utf-8") as handle:
|
|
|
|
|
for _ in range(50_000):
|
|
|
|
|
handle.write(line)
|
|
|
|
|
total_size = path.stat().st_size
|
|
|
|
|
self.assertGreater(total_size, 4_000_000)
|
|
|
|
|
|
|
|
|
|
read_sizes = []
|
|
|
|
|
orig_open = builtins.open
|
|
|
|
|
|
|
|
|
|
def spying_open(*args, **kwargs):
|
|
|
|
|
handle = orig_open(*args, **kwargs)
|
|
|
|
|
if args and args[0] == path:
|
|
|
|
|
orig_read = handle.read
|
|
|
|
|
|
|
|
|
|
def spying_read(n=-1, *a, **k):
|
|
|
|
|
data = orig_read(n, *a, **k)
|
|
|
|
|
read_sizes.append(len(data))
|
|
|
|
|
return data
|
|
|
|
|
|
|
|
|
|
handle.read = spying_read
|
|
|
|
|
return handle
|
|
|
|
|
|
|
|
|
|
builtins.open = spying_open
|
|
|
|
|
try:
|
|
|
|
|
lines, message = read_log_tail(path, max_lines=20)
|
|
|
|
|
finally:
|
|
|
|
|
builtins.open = orig_open
|
|
|
|
|
|
|
|
|
|
self.assertEqual(message, "")
|
|
|
|
|
self.assertEqual(len(lines), 20)
|
|
|
|
|
self.assertLess(sum(read_sizes), total_size)
|
|
|
|
|
|
|
|
|
|
def test_survives_non_utf8_bytes(self):
|
|
|
|
|
"""Un vrai journal peut contenir des octets qui ne sont pas de
|
|
|
|
|
l'UTF-8 valide (encodage local du serveur distant reproduit tel
|
|
|
|
|
quel dans un message d'exception, par exemple) : ça ne doit jamais
|
|
|
|
|
faire lever la lecture, seulement remplacer ce qui ne se décode
|
|
|
|
|
pas."""
|
|
|
|
|
path = self.base / "mail.log"
|
|
|
|
|
with open(path, "wb") as handle:
|
|
|
|
|
handle.write(b"2026-08-04 avant\n")
|
|
|
|
|
handle.write(b"\xff\xfe pas de l'utf-8 valide\n")
|
|
|
|
|
handle.write(b"2026-08-04 apres\n")
|
|
|
|
|
|
|
|
|
|
lines, message = read_log_tail(path)
|
|
|
|
|
|
|
|
|
|
self.assertEqual(message, "")
|
|
|
|
|
self.assertEqual(lines[0], "2026-08-04 avant")
|
|
|
|
|
self.assertEqual(lines[-1], "2026-08-04 apres")
|
|
|
|
|
|
|
|
|
|
def test_survives_a_multiline_traceback(self):
|
|
|
|
|
"""Une vraie panne écrit une trace Python sur plusieurs lignes, pas
|
|
|
|
|
une ligne bien propre : la dernière ligne de la trace doit rester
|
|
|
|
|
visible dans la fin du journal."""
|
|
|
|
|
traceback_text = (
|
|
|
|
|
"2026-08-04 12:00:00 script.todo.mail.imap_sync ERROR sync a échoué\n"
|
|
|
|
|
"Traceback (most recent call last):\n"
|
|
|
|
|
' File "imap_sync.py", line 140, in sync\n'
|
|
|
|
|
" folder = self._sync_folder(info)\n"
|
|
|
|
|
"OSError: 530 refus du serveur\n"
|
|
|
|
|
)
|
|
|
|
|
path = self.base / "mail.log"
|
|
|
|
|
path.write_text(traceback_text)
|
|
|
|
|
|
|
|
|
|
lines, message = read_log_tail(path, max_lines=10)
|
|
|
|
|
|
|
|
|
|
self.assertEqual(message, "")
|
|
|
|
|
self.assertEqual(lines[-1], "OSError: 530 refus du serveur")
|
|
|
|
|
self.assertIn("Traceback (most recent call last):", lines)
|
|
|
|
|
|
|
|
|
|
class _FakeStatResult:
|
|
|
|
|
def __init__(self, size):
|
|
|
|
|
self.st_size = size
|
|
|
|
|
|
|
|
|
|
class _ShrunkAfterStatPath:
|
|
|
|
|
"""Simule une rotation de journal : `stat()` rapporte encore
|
|
|
|
|
l'ANCIENNE taille (non nulle), mais le fichier est déjà vide au
|
|
|
|
|
moment où `open()` puis `read()` s'exécutent — la lecture par blocs
|
|
|
|
|
rend alors `b""`, sans qu'aucune des deux gardes précédentes
|
|
|
|
|
(`exists()`, `size == 0`) ne l'ait vu venir."""
|
|
|
|
|
|
|
|
|
|
def exists(self):
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
def stat(self):
|
|
|
|
|
return TestReadLogTail._FakeStatResult(1000)
|
|
|
|
|
|
|
|
|
|
def test_a_file_truncated_between_stat_and_read_is_treated_as_empty(self):
|
|
|
|
|
"""`size > 0` au moment du `stat()` ne garantit RIEN sur ce que la
|
|
|
|
|
lecture rendra ensuite : un journal peut être tronqué entre les deux
|
|
|
|
|
(rotation de journal, notamment) — exactement le cas qu'une revue a
|
|
|
|
|
retrouvé après qu'une passe précédente eut, à tort, jugé ce garde-fou
|
|
|
|
|
mort."""
|
|
|
|
|
import builtins
|
|
|
|
|
|
|
|
|
|
from script.todo.todo_i18n import t
|
|
|
|
|
|
|
|
|
|
fake_path = self._ShrunkAfterStatPath()
|
|
|
|
|
orig_open = builtins.open
|
|
|
|
|
|
|
|
|
|
def spying_open(file, *args, **kwargs):
|
|
|
|
|
if file is fake_path:
|
|
|
|
|
import io
|
|
|
|
|
|
|
|
|
|
return io.BytesIO(b"")
|
|
|
|
|
return orig_open(file, *args, **kwargs)
|
|
|
|
|
|
|
|
|
|
builtins.open = spying_open
|
|
|
|
|
try:
|
|
|
|
|
lines, message = read_log_tail(fake_path)
|
|
|
|
|
finally:
|
|
|
|
|
builtins.open = orig_open
|
|
|
|
|
|
|
|
|
|
self.assertEqual(lines, [])
|
|
|
|
|
self.assertEqual(message, t("mail_log_empty"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class FakeTransport:
|
|
|
|
|
def list_folders(self):
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
def logout(self):
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class LogScreenCase(unittest.IsolatedAsyncioTestCase):
|
|
|
|
|
"""Monte `MailApp` pour de vrai, `$HOME` détourné vers un dossier
|
|
|
|
|
jetable — comme `test_mail_tui_account.py` : `on_mount` lit
|
|
|
|
|
`todo_prefs`, qui crée `~/.erplibre` s'il est absent, et le chemin du
|
|
|
|
|
journal (`menu.mail_log_path()`) EST sous `~/.erplibre` : sans ce
|
|
|
|
|
détournement, monter l'écran toucherait la vraie machine ET risquerait
|
|
|
|
|
de lire le vrai journal de l'utilisateur, qui contient les réponses de
|
|
|
|
|
son serveur de courriel.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
|
|
|
|
|
def _log_path(self) -> Path:
|
|
|
|
|
from script.todo.mail.menu import mail_log_path
|
|
|
|
|
|
|
|
|
|
return mail_log_path()
|
|
|
|
|
|
|
|
|
|
class _FakeSyncer:
|
|
|
|
|
def __init__(self, transport, errors=None):
|
|
|
|
|
self.transport = transport
|
|
|
|
|
self._errors = errors or []
|
|
|
|
|
|
|
|
|
|
def sync(self, progress=None):
|
|
|
|
|
from types import SimpleNamespace
|
|
|
|
|
|
|
|
|
|
return SimpleNamespace(
|
|
|
|
|
new_messages=0, errors=list(self._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
|
|
|
|
|
|
|
|
|
|
async def _mounted_app(self, session=None):
|
|
|
|
|
import textual.app
|
|
|
|
|
|
|
|
|
|
from script.todo.mail.tui import run_tui
|
|
|
|
|
|
|
|
|
|
sessions = [session] if session is not None else []
|
|
|
|
|
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 TestLogScreenOpensAndCloses(LogScreenCase):
|
|
|
|
|
async def test_l_opens_the_log_screen(self):
|
|
|
|
|
from textual.screen import ModalScreen
|
|
|
|
|
|
|
|
|
|
app = await self._mounted_app()
|
|
|
|
|
async with app.run_test() as pilot:
|
|
|
|
|
await app.workers.wait_for_complete()
|
|
|
|
|
await pilot.press("l")
|
|
|
|
|
await pilot.pause()
|
|
|
|
|
|
|
|
|
|
self.assertIsInstance(app.screen, ModalScreen)
|
|
|
|
|
|
|
|
|
|
async def test_escape_closes_it_without_disturbing_the_mail_list(self):
|
|
|
|
|
from textual.screen import ModalScreen
|
|
|
|
|
from textual.widgets import DataTable
|
|
|
|
|
|
|
|
|
|
session = Session(
|
|
|
|
|
self.account,
|
|
|
|
|
self.store,
|
|
|
|
|
self._FakeSyncer(FakeTransport()),
|
|
|
|
|
password="hunter2",
|
|
|
|
|
)
|
|
|
|
|
self.store.upsert_folder("INBOX", "INBOX", "inbox")
|
|
|
|
|
|
|
|
|
|
app = await self._mounted_app(session)
|
|
|
|
|
async with app.run_test() as pilot:
|
|
|
|
|
await app.workers.wait_for_complete()
|
|
|
|
|
table_before = app.query_one("#list", DataTable)
|
|
|
|
|
rows_before = table_before.row_count
|
|
|
|
|
|
|
|
|
|
await pilot.press("l")
|
|
|
|
|
await pilot.pause()
|
|
|
|
|
self.assertIsInstance(app.screen, ModalScreen)
|
|
|
|
|
|
|
|
|
|
await pilot.press("escape")
|
|
|
|
|
await pilot.pause()
|
|
|
|
|
|
|
|
|
|
self.assertNotIsInstance(app.screen, ModalScreen)
|
|
|
|
|
table_after = app.query_one("#list", DataTable)
|
|
|
|
|
self.assertEqual(table_after.row_count, rows_before)
|
|
|
|
|
|
|
|
|
|
async def test_binding_is_translated(self):
|
|
|
|
|
from script.todo.todo_i18n import t
|
|
|
|
|
|
|
|
|
|
app = await self._mounted_app()
|
|
|
|
|
binding = next(b for key, b in app._bindings if key == "l")
|
|
|
|
|
self.assertEqual(binding.description, t("mail_log_binding"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestLogScreenTailContent(LogScreenCase):
|
|
|
|
|
async def test_missing_log_file_says_so_explicitly(self):
|
|
|
|
|
from textual.widgets import Log
|
|
|
|
|
|
|
|
|
|
from script.todo.todo_i18n import t
|
|
|
|
|
|
|
|
|
|
app = await self._mounted_app()
|
|
|
|
|
async with app.run_test() as pilot:
|
|
|
|
|
await app.workers.wait_for_complete()
|
|
|
|
|
await pilot.press("l")
|
|
|
|
|
await pilot.pause()
|
|
|
|
|
|
|
|
|
|
log_widget = app.screen.query_one("#log_tail", Log)
|
|
|
|
|
text = "\n".join(str(line) for line in log_widget.lines)
|
|
|
|
|
self.assertIn(t("mail_log_missing"), text)
|
|
|
|
|
|
|
|
|
|
async def test_tail_of_a_real_log_file_is_shown(self):
|
|
|
|
|
from textual.widgets import Log
|
|
|
|
|
|
|
|
|
|
log_path = self._log_path()
|
|
|
|
|
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
log_path.write_text("".join(f"ligne {i}\n" for i in range(1, 301)))
|
|
|
|
|
|
|
|
|
|
app = await self._mounted_app()
|
|
|
|
|
async with app.run_test() as pilot:
|
|
|
|
|
await app.workers.wait_for_complete()
|
|
|
|
|
await pilot.press("l")
|
|
|
|
|
await pilot.pause()
|
|
|
|
|
|
|
|
|
|
log_widget = app.screen.query_one("#log_tail", Log)
|
|
|
|
|
text = "\n".join(str(line) for line in log_widget.lines)
|
|
|
|
|
self.assertIn("ligne 300", text)
|
|
|
|
|
self.assertNotIn("ligne 1\n", text + "\n")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestLogScreenSessionErrors(LogScreenCase):
|
|
|
|
|
async def test_errors_from_the_last_sync_are_shown(self):
|
|
|
|
|
from textual.widgets import Static
|
|
|
|
|
|
|
|
|
|
session = Session(
|
|
|
|
|
self.account,
|
|
|
|
|
self.store,
|
|
|
|
|
self._FakeSyncer(
|
|
|
|
|
FakeTransport(), errors=["Archives : 501 refus du serveur"]
|
|
|
|
|
),
|
|
|
|
|
password="hunter2",
|
|
|
|
|
)
|
|
|
|
|
app = await self._mounted_app(session)
|
|
|
|
|
async with app.run_test() as pilot:
|
|
|
|
|
await app.workers.wait_for_complete()
|
|
|
|
|
app._sync([session])
|
|
|
|
|
await pilot.pause()
|
|
|
|
|
|
|
|
|
|
await pilot.press("l")
|
|
|
|
|
await pilot.pause()
|
|
|
|
|
|
|
|
|
|
errors_widget = app.screen.query_one("#log_errors", Static)
|
|
|
|
|
self.assertIn(
|
|
|
|
|
"Archives : 501 refus du serveur",
|
|
|
|
|
str(errors_widget.render()),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
async def test_no_session_errors_says_so_explicitly(self):
|
|
|
|
|
from textual.widgets import Static
|
|
|
|
|
|
|
|
|
|
from script.todo.todo_i18n import t
|
|
|
|
|
|
|
|
|
|
session = Session(
|
|
|
|
|
self.account,
|
|
|
|
|
self.store,
|
|
|
|
|
self._FakeSyncer(FakeTransport(), errors=[]),
|
|
|
|
|
password="hunter2",
|
|
|
|
|
)
|
|
|
|
|
app = await self._mounted_app(session)
|
|
|
|
|
async with app.run_test() as pilot:
|
|
|
|
|
await app.workers.wait_for_complete()
|
|
|
|
|
app._sync([session])
|
|
|
|
|
await pilot.pause()
|
|
|
|
|
|
|
|
|
|
await pilot.press("l")
|
|
|
|
|
await pilot.pause()
|
|
|
|
|
|
|
|
|
|
errors_widget = app.screen.query_one("#log_errors", Static)
|
|
|
|
|
self.assertIn(t("mail_log_no_errors"), str(errors_widget.render()))
|
|
|
|
|
|
|
|
|
|
async def test_a_later_clean_sync_clears_a_previous_error(self):
|
|
|
|
|
"""« la dernière synchronisation » : une erreur d'il y a deux
|
|
|
|
|
passes ne doit pas rester affichée comme si elle était toujours
|
|
|
|
|
d'actualité."""
|
|
|
|
|
from textual.widgets import Static
|
|
|
|
|
|
|
|
|
|
from script.todo.todo_i18n import t
|
|
|
|
|
|
|
|
|
|
session = Session(
|
|
|
|
|
self.account,
|
|
|
|
|
self.store,
|
|
|
|
|
self._FakeSyncer(FakeTransport(), errors=["INBOX : 501 refus"]),
|
|
|
|
|
password="hunter2",
|
|
|
|
|
)
|
|
|
|
|
app = await self._mounted_app(session)
|
|
|
|
|
async with app.run_test() as pilot:
|
|
|
|
|
await app.workers.wait_for_complete()
|
|
|
|
|
app._sync([session])
|
|
|
|
|
await pilot.pause()
|
|
|
|
|
|
|
|
|
|
# Deuxième synchronisation, propre cette fois.
|
|
|
|
|
session.syncer = self._FakeSyncer(FakeTransport(), errors=[])
|
|
|
|
|
app._sync([session])
|
|
|
|
|
await pilot.pause()
|
|
|
|
|
|
|
|
|
|
await pilot.press("l")
|
|
|
|
|
await pilot.pause()
|
|
|
|
|
|
|
|
|
|
errors_widget = app.screen.query_one("#log_errors", Static)
|
|
|
|
|
text = str(errors_widget.render())
|
|
|
|
|
self.assertIn(t("mail_log_no_errors"), text)
|
|
|
|
|
self.assertNotIn("INBOX : 501 refus", text)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestLogScreenTotalSyncFailure(LogScreenCase):
|
|
|
|
|
"""`session.sync()` peut lever DIRECTEMENT (connexion totalement
|
|
|
|
|
perdue), pas seulement rendre un `report.errors` : c'est la panne la
|
|
|
|
|
plus grave, celle qu'un utilisateur ouvrirait précisément cette fenêtre
|
|
|
|
|
pour diagnostiquer — un vrai `imaplib.IMAP4.abort ... Broken pipe` a été
|
|
|
|
|
trouvé dans un journal réel après une telle panne. Le statut affiché au
|
|
|
|
|
moment de la panne est éphémère (le prochain message l'efface) ; `l`
|
|
|
|
|
existe pour regarder APRÈS coup, donc cette panne doit rester lisible
|
|
|
|
|
dans la fenêtre, pas seulement dans la barre de statut du moment."""
|
|
|
|
|
|
|
|
|
|
class _FakeSyncerThatRaises:
|
|
|
|
|
def __init__(self, transport):
|
|
|
|
|
self.transport = transport
|
|
|
|
|
|
|
|
|
|
def sync(self, progress=None):
|
|
|
|
|
raise OSError("imaplib.IMAP4.abort ... Broken pipe")
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
async def test_total_failure_is_shown_in_the_window(self):
|
|
|
|
|
from textual.widgets import Static
|
|
|
|
|
|
|
|
|
|
session = Session(
|
|
|
|
|
self.account,
|
|
|
|
|
self.store,
|
|
|
|
|
self._FakeSyncerThatRaises(FakeTransport()),
|
|
|
|
|
password="hunter2",
|
|
|
|
|
)
|
|
|
|
|
app = await self._mounted_app(session)
|
|
|
|
|
async with app.run_test() as pilot:
|
|
|
|
|
await app.workers.wait_for_complete()
|
|
|
|
|
app._sync([session])
|
|
|
|
|
await pilot.pause()
|
|
|
|
|
|
|
|
|
|
await pilot.press("l")
|
|
|
|
|
await pilot.pause()
|
|
|
|
|
|
|
|
|
|
errors_widget = app.screen.query_one("#log_errors", Static)
|
|
|
|
|
self.assertIn(
|
|
|
|
|
"imaplib.IMAP4.abort ... Broken pipe",
|
|
|
|
|
str(errors_widget.render()),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
unittest.main()
|