406 lines
15 KiB
Python
406 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)
|
||
|
|
"""Dispositions de volets commutables (touche `v`) : `columns` (défaut,
|
||
|
|
dossiers | liste | aperçu), `split` (dossiers à gauche ; liste au-dessus de
|
||
|
|
l'aperçu) et `stacked` (les trois empilés).
|
||
|
|
|
||
|
|
`resolve_layout`/`next_layout` sont des fonctions pures, testées sans écran.
|
||
|
|
Le reste — la classe CSS réellement posée sur `#panes`, la persistance dans
|
||
|
|
`todo_prefs`, et surtout la NON-perturbation de ce que l'utilisateur regarde
|
||
|
|
— n'a de sens que sur l'application montée pour de vrai, comme
|
||
|
|
`test_mail_tui_refresh.py`.
|
||
|
|
"""
|
||
|
|
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 MessageMeta, Store
|
||
|
|
from script.todo.mail.tui import (
|
||
|
|
MAIL_LAYOUTS,
|
||
|
|
Session,
|
||
|
|
next_layout,
|
||
|
|
resolve_layout,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
class TestResolveLayout(unittest.TestCase):
|
||
|
|
def test_known_value_is_kept(self):
|
||
|
|
self.assertEqual(resolve_layout("split"), "split")
|
||
|
|
|
||
|
|
def test_unknown_value_falls_back_to_columns(self):
|
||
|
|
self.assertEqual(resolve_layout("bogus"), "columns")
|
||
|
|
|
||
|
|
def test_empty_value_falls_back_to_columns(self):
|
||
|
|
self.assertEqual(resolve_layout(""), "columns")
|
||
|
|
|
||
|
|
def test_none_falls_back_to_columns(self):
|
||
|
|
self.assertEqual(resolve_layout(None), "columns")
|
||
|
|
|
||
|
|
|
||
|
|
class TestNextLayout(unittest.TestCase):
|
||
|
|
def test_cycles_in_declared_order(self):
|
||
|
|
ids = [layout_id for layout_id, _ in MAIL_LAYOUTS]
|
||
|
|
self.assertEqual(ids, ["columns", "split", "stacked"])
|
||
|
|
self.assertEqual(next_layout("columns"), "split")
|
||
|
|
self.assertEqual(next_layout("split"), "stacked")
|
||
|
|
|
||
|
|
def test_wraps_around_after_the_last(self):
|
||
|
|
self.assertEqual(next_layout("stacked"), "columns")
|
||
|
|
|
||
|
|
def test_an_invalid_current_value_starts_the_cycle_from_the_first(self):
|
||
|
|
self.assertEqual(next_layout("bogus"), "split")
|
||
|
|
|
||
|
|
|
||
|
|
class LayoutCase(unittest.IsolatedAsyncioTestCase):
|
||
|
|
"""Monte `MailApp` pour de vrai, `$HOME` détourné — même motif que
|
||
|
|
`test_mail_tui_refresh.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()
|
||
|
|
# Pas de syncer : ces tests portent sur la disposition et la
|
||
|
|
# persistance de l'état affiché, jamais sur le réseau.
|
||
|
|
self.session = Session(self.account, self.store, None, password="x")
|
||
|
|
|
||
|
|
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 _seed_two_messages(self):
|
||
|
|
fid = self.store.upsert_folder("INBOX", "INBOX", "inbox")
|
||
|
|
self.store.upsert_messages(
|
||
|
|
fid,
|
||
|
|
[
|
||
|
|
MessageMeta(
|
||
|
|
uid=1,
|
||
|
|
date=1_000,
|
||
|
|
size=10,
|
||
|
|
flags="",
|
||
|
|
msgid="<1@x.ca>",
|
||
|
|
frm="a@x.ca",
|
||
|
|
to="moi@x.ca",
|
||
|
|
subject="Un",
|
||
|
|
snippet="",
|
||
|
|
),
|
||
|
|
MessageMeta(
|
||
|
|
uid=2,
|
||
|
|
date=2_000,
|
||
|
|
size=10,
|
||
|
|
flags="",
|
||
|
|
msgid="<2@x.ca>",
|
||
|
|
frm="b@x.ca",
|
||
|
|
to="moi@x.ca",
|
||
|
|
subject="Deux",
|
||
|
|
snippet="",
|
||
|
|
),
|
||
|
|
],
|
||
|
|
)
|
||
|
|
|
||
|
|
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 TestDefaultLayout(LayoutCase):
|
||
|
|
async def test_columns_is_the_default_on_first_run(self):
|
||
|
|
app = await self._mounted_app()
|
||
|
|
async with app.run_test() as pilot:
|
||
|
|
await app.workers.wait_for_complete()
|
||
|
|
await pilot.pause()
|
||
|
|
panes = app.query_one("#panes")
|
||
|
|
self.assertTrue(panes.has_class("layout-columns"))
|
||
|
|
self.assertEqual(app.mail_layout, "columns")
|
||
|
|
|
||
|
|
|
||
|
|
class TestCycleLayout(LayoutCase):
|
||
|
|
async def test_v_cycles_through_every_layout_and_wraps(self):
|
||
|
|
app = await self._mounted_app()
|
||
|
|
async with app.run_test() as pilot:
|
||
|
|
await app.workers.wait_for_complete()
|
||
|
|
await pilot.pause()
|
||
|
|
panes = app.query_one("#panes")
|
||
|
|
|
||
|
|
await pilot.press("v")
|
||
|
|
await pilot.pause()
|
||
|
|
self.assertTrue(panes.has_class("layout-split"))
|
||
|
|
self.assertFalse(panes.has_class("layout-columns"))
|
||
|
|
|
||
|
|
await pilot.press("v")
|
||
|
|
await pilot.pause()
|
||
|
|
self.assertTrue(panes.has_class("layout-stacked"))
|
||
|
|
self.assertFalse(panes.has_class("layout-split"))
|
||
|
|
|
||
|
|
await pilot.press("v")
|
||
|
|
await pilot.pause()
|
||
|
|
self.assertTrue(panes.has_class("layout-columns"))
|
||
|
|
self.assertFalse(panes.has_class("layout-stacked"))
|
||
|
|
|
||
|
|
async def test_the_binding_is_translated_in_the_footer(self):
|
||
|
|
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.pause()
|
||
|
|
active = app.screen.active_bindings["v"]
|
||
|
|
self.assertEqual(
|
||
|
|
active.binding.description, t("mail_layout_binding")
|
||
|
|
)
|
||
|
|
|
||
|
|
async def test_switching_reports_the_new_layout_translated(self):
|
||
|
|
from textual.widgets import Static
|
||
|
|
|
||
|
|
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.pause()
|
||
|
|
|
||
|
|
await pilot.press("v")
|
||
|
|
await pilot.pause()
|
||
|
|
|
||
|
|
status = app.query_one("#status", Static)
|
||
|
|
self.assertIn(t("mail_layout_split"), str(status.content))
|
||
|
|
|
||
|
|
|
||
|
|
class TestLayoutPersistence(LayoutCase):
|
||
|
|
async def test_the_choice_is_written_to_preferences(self):
|
||
|
|
from script.todo import todo_prefs
|
||
|
|
|
||
|
|
app = await self._mounted_app()
|
||
|
|
async with app.run_test() as pilot:
|
||
|
|
await app.workers.wait_for_complete()
|
||
|
|
await pilot.pause()
|
||
|
|
await pilot.press("v")
|
||
|
|
await pilot.pause()
|
||
|
|
|
||
|
|
self.assertEqual(todo_prefs.get("mail_layout"), "split")
|
||
|
|
|
||
|
|
async def test_a_fresh_mount_reads_back_the_stored_layout(self):
|
||
|
|
from script.todo import todo_prefs
|
||
|
|
|
||
|
|
todo_prefs.set("mail_layout", "stacked")
|
||
|
|
app = await self._mounted_app()
|
||
|
|
async with app.run_test() as pilot:
|
||
|
|
await app.workers.wait_for_complete()
|
||
|
|
await pilot.pause()
|
||
|
|
self.assertEqual(app.mail_layout, "stacked")
|
||
|
|
self.assertTrue(
|
||
|
|
app.query_one("#panes").has_class("layout-stacked")
|
||
|
|
)
|
||
|
|
|
||
|
|
async def test_a_corrupt_stored_value_falls_back_to_columns(self):
|
||
|
|
from script.todo import todo_prefs
|
||
|
|
|
||
|
|
todo_prefs.set("mail_layout", "not-a-real-layout")
|
||
|
|
app = await self._mounted_app()
|
||
|
|
async with app.run_test() as pilot:
|
||
|
|
await app.workers.wait_for_complete()
|
||
|
|
await pilot.pause()
|
||
|
|
self.assertEqual(app.mail_layout, "columns")
|
||
|
|
self.assertTrue(
|
||
|
|
app.query_one("#panes").has_class("layout-columns")
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
class TestLayoutSwitchPreservesState(LayoutCase):
|
||
|
|
"""La propriété qui compte le plus : rien de ce que l'utilisateur
|
||
|
|
regarde ne doit bouger quand la disposition change — seule la classe
|
||
|
|
CSS de `#panes` doit changer.
|
||
|
|
"""
|
||
|
|
|
||
|
|
async def test_folder_selection_survives_a_switch(self):
|
||
|
|
self._seed_two_messages()
|
||
|
|
app = await self._mounted_app()
|
||
|
|
async with app.run_test() as pilot:
|
||
|
|
await app.workers.wait_for_complete()
|
||
|
|
await pilot.pause()
|
||
|
|
ref_before = app.current_ref
|
||
|
|
|
||
|
|
await pilot.press("v")
|
||
|
|
await pilot.pause()
|
||
|
|
|
||
|
|
self.assertIs(app.current_ref, ref_before)
|
||
|
|
|
||
|
|
async def test_the_highlighted_message_survives_a_switch(self):
|
||
|
|
from textual.widgets import DataTable
|
||
|
|
|
||
|
|
self._seed_two_messages()
|
||
|
|
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=1)
|
||
|
|
await pilot.pause()
|
||
|
|
highlighted_before = app.current_meta()
|
||
|
|
self.assertIsNotNone(highlighted_before)
|
||
|
|
|
||
|
|
await pilot.press("v")
|
||
|
|
await pilot.pause()
|
||
|
|
|
||
|
|
self.assertEqual(app.current_meta().uid, highlighted_before.uid)
|
||
|
|
self.assertEqual(table.cursor_row, 1)
|
||
|
|
|
||
|
|
async def test_the_search_filter_survives_a_switch(self):
|
||
|
|
self._seed_two_messages()
|
||
|
|
app = await self._mounted_app()
|
||
|
|
async with app.run_test() as pilot:
|
||
|
|
await app.workers.wait_for_complete()
|
||
|
|
await pilot.pause()
|
||
|
|
|
||
|
|
app.query = "Deux"
|
||
|
|
app.refresh_list()
|
||
|
|
from textual.widgets import DataTable
|
||
|
|
|
||
|
|
table = app.query_one("#list", DataTable)
|
||
|
|
self.assertEqual(table.row_count, 1)
|
||
|
|
|
||
|
|
await pilot.press("v")
|
||
|
|
await pilot.pause()
|
||
|
|
|
||
|
|
self.assertEqual(app.query, "Deux")
|
||
|
|
table = app.query_one("#list", DataTable)
|
||
|
|
self.assertEqual(table.row_count, 1)
|
||
|
|
|
||
|
|
async def test_fullscreen_state_survives_a_switch(self):
|
||
|
|
self._seed_two_messages()
|
||
|
|
app = await self._mounted_app()
|
||
|
|
async with app.run_test() as pilot:
|
||
|
|
await app.workers.wait_for_complete()
|
||
|
|
await pilot.pause()
|
||
|
|
|
||
|
|
panes = app.query_one("#panes")
|
||
|
|
panes.add_class("fullscreen")
|
||
|
|
|
||
|
|
await pilot.press("v")
|
||
|
|
await pilot.pause()
|
||
|
|
|
||
|
|
self.assertTrue(panes.has_class("fullscreen"))
|
||
|
|
self.assertTrue(panes.has_class("layout-split"))
|
||
|
|
|
||
|
|
async def test_fullscreen_toggle_still_works_in_every_layout(self):
|
||
|
|
"""`enter` lui-même n'atteint `MailApp` que lorsque le focus n'est
|
||
|
|
ni sur `#folders` (Tree) ni sur `#list` (DataTable) — les deux lient
|
||
|
|
déjà `enter` à `select_cursor`, et Textual donne priorité à la
|
||
|
|
liaison la plus proche du focus (voir le commentaire de
|
||
|
|
`_SearchInput`) — un fait préexistant, sans rapport avec les
|
||
|
|
dispositions. On passe donc par l'action elle-même pour entrer en
|
||
|
|
plein écran, symétriquement à `escape` (jamais intercepté par ces
|
||
|
|
deux widgets), qui lui reste testé au clavier.
|
||
|
|
"""
|
||
|
|
self._seed_two_messages()
|
||
|
|
app = await self._mounted_app()
|
||
|
|
async with app.run_test() as pilot:
|
||
|
|
await app.workers.wait_for_complete()
|
||
|
|
await pilot.pause()
|
||
|
|
|
||
|
|
await pilot.press("v") # split
|
||
|
|
await pilot.pause()
|
||
|
|
|
||
|
|
app.action_toggle_fullscreen()
|
||
|
|
await pilot.pause()
|
||
|
|
panes = app.query_one("#panes")
|
||
|
|
self.assertTrue(panes.has_class("fullscreen"))
|
||
|
|
self.assertTrue(panes.has_class("layout-split"))
|
||
|
|
|
||
|
|
await pilot.press("escape")
|
||
|
|
await pilot.pause()
|
||
|
|
self.assertFalse(panes.has_class("fullscreen"))
|
||
|
|
|
||
|
|
|
||
|
|
class TestFullscreenFillsThePanes(LayoutCase):
|
||
|
|
"""Ce que la revue a trouvé, et qu'aucune assertion sur la classe
|
||
|
|
`fullscreen` ne peut voir : `.fullscreen #folders, .fullscreen #list`
|
||
|
|
n'effaçait que les WIDGETS, pas leurs conteneurs enveloppants
|
||
|
|
(`#list_pane`, `#right`), qui gardaient la taille que leur donne le
|
||
|
|
bloc CSS de la disposition active — l'aperçu ne prenait donc jamais
|
||
|
|
tout l'écran. En `columns` cela laissait ~40 % de l'écran vide (le
|
||
|
|
conteneur de liste, toujours large de 2fr) ; en `split`/`stacked`,
|
||
|
|
~49 % (le conteneur de liste, toujours haut de 1fr). Il faut donc
|
||
|
|
MESURER la région réellement occupée par `#preview`, pas seulement
|
||
|
|
lire une classe CSS.
|
||
|
|
"""
|
||
|
|
|
||
|
|
async def test_the_preview_fills_the_panes_area_in_every_layout(self):
|
||
|
|
app = await self._mounted_app()
|
||
|
|
async with app.run_test() as pilot:
|
||
|
|
await app.workers.wait_for_complete()
|
||
|
|
await pilot.pause()
|
||
|
|
|
||
|
|
panes = app.query_one("#panes")
|
||
|
|
preview = app.query_one("#preview")
|
||
|
|
list_pane = app.query_one("#list_pane")
|
||
|
|
folders = app.query_one("#folders")
|
||
|
|
|
||
|
|
for layout_id, _ in MAIL_LAYOUTS:
|
||
|
|
while app.mail_layout != layout_id:
|
||
|
|
await pilot.press("v")
|
||
|
|
await pilot.pause()
|
||
|
|
|
||
|
|
panes.add_class("fullscreen")
|
||
|
|
await pilot.pause()
|
||
|
|
|
||
|
|
# La preuve qui compte : l'aperçu occupe TOUTE la région de
|
||
|
|
# `#panes`, pas seulement une fraction — c'est le contraire
|
||
|
|
# exact de ce qui a été mesuré avant le correctif (largeur
|
||
|
|
# ou hauteur de l'aperçu strictement inférieure à celle de
|
||
|
|
# `#panes`).
|
||
|
|
self.assertEqual(
|
||
|
|
preview.region,
|
||
|
|
panes.region,
|
||
|
|
f"disposition {layout_id!r} : aperçu {preview.region},"
|
||
|
|
f" attendu {panes.region}",
|
||
|
|
)
|
||
|
|
# Les deux conteneurs masqués n'occupent plus rien du tout
|
||
|
|
# (et pas seulement leur CONTENU) — c'est précisément ce que
|
||
|
|
# `#list_pane` corrige par rapport à l'ancien `#list`.
|
||
|
|
self.assertEqual(list_pane.region.width, 0)
|
||
|
|
self.assertEqual(list_pane.region.height, 0)
|
||
|
|
self.assertEqual(folders.region.width, 0)
|
||
|
|
self.assertEqual(folders.region.height, 0)
|
||
|
|
|
||
|
|
panes.remove_class("fullscreen")
|
||
|
|
await pilot.pause()
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
unittest.main()
|