erplibre/test/test_mail_tui_text.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

295 lines
9.3 KiB
Python

#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
import datetime
import unittest
from script.todo.mail.store import MessageMeta
from script.todo.mail.tui_text import (
extract_body,
filter_messages,
format_date,
format_date_full,
format_size,
html_to_text,
is_unread,
short_addr,
truncate,
)
# 2026-08-01 10:41:00 UTC
NOW = 1785580860
def meta(
uid=1, subject="Devis", frm="Alice <alice@y.ca>", snippet="", flags=""
):
return MessageMeta(
uid=uid,
date=NOW,
size=100,
flags=flags,
msgid=f"<{uid}@x.ca>",
frm=frm,
to="moi@x.ca",
subject=subject,
snippet=snippet,
)
class TestHtmlToText(unittest.TestCase):
def test_strips_tags(self):
self.assertEqual(html_to_text("<p>Bonjour</p>"), "Bonjour")
def test_decodes_entities(self):
self.assertEqual(
html_to_text("<p>caf&eacute; &amp; th&eacute;</p>"), "café & thé"
)
def test_drops_script_and_style(self):
out = html_to_text(
"<style>p{color:red}</style><script>alert(1)</script><p>Salut</p>"
)
self.assertEqual(out, "Salut")
def test_br_becomes_newline(self):
self.assertEqual(html_to_text("a<br>b"), "a\nb")
def test_block_tags_separate_lines(self):
self.assertIn("\n", html_to_text("<div>a</div><div>b</div>"))
def test_collapses_blank_runs(self):
self.assertNotIn("\n\n\n", html_to_text("<p>a</p>\n\n\n\n\n<p>b</p>"))
def test_empty_input(self):
self.assertEqual(html_to_text(""), "")
class TestExtractBody(unittest.TestCase):
def test_plain_text(self):
text, atts = extract_body(b"Subject: S\r\n\r\nBonjour Alice")
self.assertEqual(text.strip(), "Bonjour Alice")
self.assertEqual(atts, [])
def test_prefers_plain_over_html(self):
raw = (
b'Content-Type: multipart/alternative; boundary="B"\r\n\r\n'
b"--B\r\nContent-Type: text/plain\r\n\r\nversion texte\r\n"
b"--B\r\nContent-Type: text/html\r\n\r\n<p>version html</p>\r\n"
b"--B--\r\n"
)
text, _ = extract_body(raw)
self.assertIn("version texte", text)
self.assertNotIn("html", text)
def test_falls_back_to_html(self):
raw = b"Content-Type: text/html\r\n\r\n<p>Bonjour <b>Alice</b></p>"
text, _ = extract_body(raw)
self.assertEqual(text.strip(), "Bonjour Alice")
def test_lists_attachments(self):
raw = (
b'Content-Type: multipart/mixed; boundary="B"\r\n\r\n'
b"--B\r\nContent-Type: text/plain\r\n\r\ncorps\r\n"
b"--B\r\nContent-Type: application/pdf\r\n"
b'Content-Disposition: attachment; filename="devis.pdf"\r\n\r\n'
b"%PDF\r\n--B--\r\n"
)
_, atts = extract_body(raw)
self.assertEqual([a.filename for a in atts], ["devis.pdf"])
self.assertEqual(atts[0].content_type, "application/pdf")
def test_attachment_without_filename_gets_one(self):
raw = (
b'Content-Type: multipart/mixed; boundary="B"\r\n\r\n'
b"--B\r\nContent-Type: text/plain\r\n\r\ncorps\r\n"
b"--B\r\nContent-Type: application/pdf\r\n"
b"Content-Disposition: attachment\r\n\r\n%PDF\r\n--B--\r\n"
)
_, atts = extract_body(raw)
self.assertTrue(atts[0].filename)
def test_broken_message_does_not_raise(self):
text, atts = extract_body(b"\x00\x01\x02 pas un courriel")
self.assertIsInstance(text, str)
self.assertIsInstance(atts, list)
def test_decodes_charset(self):
raw = (
b"Content-Type: text/plain; charset=iso-8859-1\r\n"
b"Content-Transfer-Encoding: 8bit\r\n\r\nCaf\xe9"
)
text, _ = extract_body(raw)
self.assertIn("Café", text)
def test_survives_unknown_8bit(self):
"""Étiquette réelle observée en usage, pas seulement un charset
inventé (voir `script/todo/mail/charset.py`)."""
raw = b"Content-Type: text/plain; charset=unknown-8bit\r\n\r\nBonjour"
text, _ = extract_body(raw)
self.assertIn("Bonjour", text)
class TestShortAddr(unittest.TestCase):
def test_display_name_wins(self):
self.assertEqual(
short_addr("Alice Tremblay <a@y.ca>"), "Alice Tremblay"
)
def test_bare_address(self):
self.assertEqual(short_addr("a@y.ca"), "a@y.ca")
def test_quoted_display_name(self):
self.assertEqual(
short_addr('"Tremblay, Alice" <a@y.ca>'), "Tremblay, Alice"
)
def test_empty(self):
self.assertEqual(short_addr(""), "")
def test_first_of_several(self):
self.assertEqual(short_addr("a@y.ca, b@y.ca"), "a@y.ca")
class TestTruncate(unittest.TestCase):
def test_short_text_untouched(self):
self.assertEqual(truncate("abc", 10), "abc")
def test_long_text_gets_ellipsis(self):
self.assertEqual(truncate("abcdefghij", 5), "abcd…")
def test_result_never_exceeds_width(self):
self.assertEqual(len(truncate("abcdefghij", 5)), 5)
def test_width_of_one(self):
self.assertEqual(truncate("abcdef", 1), "…")
def test_zero_width(self):
self.assertEqual(truncate("abc", 0), "")
class TestFormatDate(unittest.TestCase):
def test_today_shows_time(self):
self.assertRegex(format_date(NOW, NOW), r"^\d{2}:\d{2}$")
def test_this_year_shows_day_and_month(self):
self.assertRegex(format_date(NOW - 90 * 86400, NOW), r"^\d{2}-\d{2}$")
def test_older_shows_the_year(self):
self.assertRegex(
format_date(NOW - 800 * 86400, NOW), r"^\d{4}-\d{2}-\d{2}$"
)
def test_zero_is_blank(self):
self.assertEqual(format_date(0, NOW), "")
def test_absurd_epoch_is_blank_and_does_not_raise(self):
"""Le contrat du module : une date d'en-tête aberrante ne lève jamais."""
for hostile in (10**18, 2**63, -(10**18)):
self.assertEqual(format_date(hostile, NOW), "")
class TestFormatDateFull(unittest.TestCase):
"""`format_date` reste volontairement compact pour la liste ; l'aperçu
d'un message a besoin de la date COMPLÈTE, sans ambiguïté à elle seule.
Ne réutilise pas `format_date` : sa compacité est une propriété
voulue de la colonne, pas un raccourci disponible ailleurs.
"""
def test_known_epoch_renders_in_full(self):
# Calculé de la même façon que l'implémentation (heure locale) :
# un `assertEqual` sur une chaîne littérale dépendrait du fuseau
# horaire de la machine qui exécute le test.
expected = datetime.datetime.fromtimestamp(NOW).strftime(
"%Y-%m-%d %H:%M"
)
self.assertEqual(format_date_full(NOW), expected)
def test_zero_is_blank(self):
self.assertEqual(format_date_full(0), "")
def test_absurd_epoch_is_blank_and_does_not_raise(self):
"""Mêmes trois valeurs que `test_absurd_epoch_is_blank_and_does_not_raise`
de `TestFormatDate` : ce sont elles qui ont fait lever `format_date`
avant l'ajout de sa garde — `format_date_full` doit tenir la même
promesse."""
for hostile in (10**18, 2**63, -(10**18)):
self.assertEqual(format_date_full(hostile), "")
class TestFormatSize(unittest.TestCase):
def test_bytes(self):
self.assertEqual(format_size(512), "512 o")
def test_kilobytes(self):
self.assertEqual(format_size(2048), "2.0 ko")
def test_megabytes(self):
self.assertEqual(format_size(5 * 1024 * 1024), "5.0 Mo")
def test_zero(self):
self.assertEqual(format_size(0), "0 o")
class TestIsUnread(unittest.TestCase):
def test_no_flags_is_unread(self):
self.assertTrue(is_unread(""))
def test_seen_is_read(self):
self.assertFalse(is_unread("\\Seen"))
def test_seen_among_others(self):
self.assertFalse(is_unread("\\Answered \\Seen"))
def test_none_is_unread(self):
self.assertTrue(is_unread(None))
class TestFilterMessages(unittest.TestCase):
def setUp(self):
self.metas = [
meta(1, subject="Devis révisé", frm="Alice <a@y.ca>"),
meta(
2,
subject="CR réunion",
frm="Bob <b@y.ca>",
snippet="ordre du jour",
),
]
def test_empty_query_returns_all(self):
self.assertEqual(len(filter_messages(self.metas, "")), 2)
def test_matches_subject(self):
self.assertEqual(
[m.uid for m in filter_messages(self.metas, "devis")], [1]
)
def test_is_case_insensitive(self):
self.assertEqual(
[m.uid for m in filter_messages(self.metas, "DEVIS")], [1]
)
def test_matches_sender(self):
self.assertEqual(
[m.uid for m in filter_messages(self.metas, "bob")], [2]
)
def test_matches_snippet(self):
self.assertEqual(
[m.uid for m in filter_messages(self.metas, "ordre")], [2]
)
def test_accent_insensitive(self):
self.assertEqual(
[m.uid for m in filter_messages(self.metas, "revise")], [1]
)
def test_no_match(self):
self.assertEqual(filter_messages(self.metas, "zzz"), [])
if __name__ == "__main__":
unittest.main()