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