erplibre/test/test_mail_tui_log.py
Mathieu Benoit cfbdd9406e [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 14:40:38 -04:00

515 lines
18 KiB
Python

#!/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.
"""
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()