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

1049 lines
40 KiB
Python

#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Redimensionnement des volets (`+`/`-`/`0`) et bascule plein écran (`z`).
Comme `test_mail_tui_layout.py` : les fonctions pures (`clamp_pane_size`,
`resolve_pane_sizes`) se testent sans écran ; tout le reste — ce qui est
réellement posé sur les widgets, la persistance, la non-perturbation des
autres dispositions — n'a de sens que sur l'application montée pour de
vrai.
"""
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 (
MAIL_LAYOUTS,
PANE_SIZE_MIN,
Session,
clamp_pane_size,
resolve_pane_sizes,
)
class TestClampPaneSize(unittest.TestCase):
def test_a_value_within_bounds_is_kept(self):
self.assertEqual(clamp_pane_size(30, 80), 30)
def test_a_value_below_the_minimum_is_raised_to_it(self):
self.assertEqual(clamp_pane_size(1, 80, minimum=4), 4)
def test_a_negative_value_is_raised_to_the_minimum(self):
self.assertEqual(clamp_pane_size(-50, 80, minimum=4), 4)
def test_a_value_that_would_crush_the_sibling_is_lowered(self):
# total=80, minimum=4 : le voisin doit garder au moins 4 -> plafond 76
self.assertEqual(clamp_pane_size(9999, 80, minimum=4), 76)
def test_total_none_cannot_be_bounded(self):
self.assertIsNone(clamp_pane_size(30, None))
def test_total_zero_or_negative_cannot_be_bounded(self):
self.assertIsNone(clamp_pane_size(30, 0))
self.assertIsNone(clamp_pane_size(30, -10))
def test_default_minimum_is_the_module_constant(self):
self.assertEqual(clamp_pane_size(1, 80), PANE_SIZE_MIN)
def test_sibling_minimum_defaults_to_minimum(self):
# sibling_minimum omis == sibling_minimum=minimum : comportement
# inchangé pour tout appelant qui ne le précise pas.
self.assertEqual(
clamp_pane_size(9999, 80, minimum=4),
clamp_pane_size(9999, 80, minimum=4, sibling_minimum=4),
)
def test_a_larger_sibling_minimum_lowers_the_ceiling_further(self):
# total=80, minimum=4, sibling_minimum=8 (le voisin est lui-même un
# conteneur à deux enfants) -> plafond 72, pas 76.
self.assertEqual(
clamp_pane_size(9999, 80, minimum=4, sibling_minimum=8), 72
)
class TestResolvePaneSizes(unittest.TestCase):
def test_missing_store_yields_nothing(self):
self.assertEqual(resolve_pane_sizes({}, "columns"), {})
def test_none_store_yields_nothing(self):
self.assertEqual(resolve_pane_sizes(None, "columns"), {})
def test_store_not_a_dict_yields_nothing(self):
self.assertEqual(resolve_pane_sizes("bogus", "columns"), {})
def test_layout_absent_from_store_yields_nothing(self):
self.assertEqual(
resolve_pane_sizes({"split": {"folders": 30}}, "columns"), {}
)
def test_per_layout_entry_not_a_dict_yields_nothing(self):
self.assertEqual(
resolve_pane_sizes({"columns": "bogus"}, "columns"), {}
)
def test_valid_entries_are_kept(self):
stored = {"columns": {"folders": 30, "list_pane": 22}}
self.assertEqual(
resolve_pane_sizes(stored, "columns"),
{"folders": 30, "list_pane": 22},
)
def test_a_zero_or_negative_slot_value_is_dropped(self):
stored = {"columns": {"folders": 0, "list_pane": -5}}
self.assertEqual(resolve_pane_sizes(stored, "columns"), {})
def test_a_non_numeric_slot_value_is_dropped(self):
stored = {"columns": {"folders": "wide", "list_pane": None}}
self.assertEqual(resolve_pane_sizes(stored, "columns"), {})
def test_a_boolean_slot_value_is_dropped(self):
# bool est une sous-classe d'int en Python -- True/False ne sont
# jamais des tailles valides, un piège classique à garder fermé.
stored = {"columns": {"folders": True}}
self.assertEqual(resolve_pane_sizes(stored, "columns"), {})
def test_an_unknown_slot_key_is_ignored(self):
stored = {"columns": {"folders": 30, "bogus_slot": 99}}
self.assertEqual(
resolve_pane_sizes(stored, "columns"), {"folders": 30}
)
def test_a_float_slot_value_is_kept_as_int(self):
stored = {"columns": {"folders": 30.7}}
result = resolve_pane_sizes(stored, "columns")
self.assertEqual(result["folders"], 30)
self.assertIsInstance(result["folders"], int)
class ResizeCase(unittest.IsolatedAsyncioTestCase):
"""Monte `MailApp` pour de vrai, `$HOME` détourné — même motif que
`test_mail_tui_layout.py`.
"""
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()
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 _fresh_session(self):
"""Une DEUXIÈME session sur le MÊME cache disque — pas
`self.session` réutilisée : `MailApp.on_unmount` ferme la session
(donc le `Store`) quand `run_test()` démonte l'appli, si bien que
réutiliser le même objet `Session` pour un second montage
planterait sur un cache déjà fermé. Un vrai redémarrage rouvre le
cache depuis le DISQUE ; ceci en est le double fidèle.
"""
store = Store(
self.account, mode="clear", base=Path(self.cache_dir.name)
)
store.open()
return Session(self.account, store, None, password="x")
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 TestFocusedPaneSlot(ResizeCase):
async def test_folders_has_focus_on_first_mount(self):
from textual.widgets import Tree
app = await self._mounted_app()
async with app.run_test() as pilot:
await app.workers.wait_for_complete()
await pilot.pause()
self.assertIsInstance(app.focused, Tree)
self.assertEqual(app._focused_pane_slot(), "folders")
async def test_the_list_maps_to_the_list_pane_slot(self):
from textual.widgets import DataTable
app = await self._mounted_app()
async with app.run_test() as pilot:
await app.workers.wait_for_complete()
await pilot.pause()
app.query_one("#list", DataTable).focus()
await pilot.pause()
self.assertEqual(app._focused_pane_slot(), "list_pane")
async def test_the_search_input_maps_to_the_list_pane_slot(self):
app = await self._mounted_app()
async with app.run_test() as pilot:
await app.workers.wait_for_complete()
await pilot.pause()
app.action_focus_search()
await pilot.pause()
self.assertEqual(app._focused_pane_slot(), "list_pane")
class TestGrowShrinkColumns(ResizeCase):
"""Disposition par défaut : `#folders`/`#right` se partagent la LARGEUR
de `#panes`, `#list_pane`/`#preview` la largeur de `#right`.
"""
async def test_growing_the_focused_list_widens_it_and_narrows_preview(
self,
):
from textual.widgets import DataTable
app = await self._mounted_app()
async with app.run_test() as pilot:
await app.workers.wait_for_complete()
await pilot.pause()
list_pane = app.query_one("#list_pane")
preview = app.query_one("#preview")
width_before = list_pane.region.width
preview_before = preview.region.width
app.query_one("#list", DataTable).focus()
await pilot.pause()
await pilot.press("+")
await pilot.pause()
self.assertEqual(list_pane.region.width, width_before + 4)
self.assertEqual(preview.region.width, preview_before - 4)
async def test_shrinking_the_focused_list_narrows_it_and_widens_preview(
self,
):
from textual.widgets import DataTable
app = await self._mounted_app()
async with app.run_test() as pilot:
await app.workers.wait_for_complete()
await pilot.pause()
list_pane = app.query_one("#list_pane")
preview = app.query_one("#preview")
width_before = list_pane.region.width
preview_before = preview.region.width
app.query_one("#list", DataTable).focus()
await pilot.pause()
await pilot.press("-")
await pilot.pause()
self.assertEqual(list_pane.region.width, width_before - 4)
self.assertEqual(preview.region.width, preview_before + 4)
async def test_growing_the_focused_folders_widens_it_and_narrows_right(
self,
):
from textual.widgets import Tree
app = await self._mounted_app()
async with app.run_test() as pilot:
await app.workers.wait_for_complete()
await pilot.pause()
folders = app.query_one("#folders")
right = app.query_one("#right")
self.assertIsInstance(app.focused, Tree)
width_before = folders.region.width
right_before = right.region.width
await pilot.press("+")
await pilot.pause()
self.assertEqual(folders.region.width, width_before + 4)
self.assertEqual(right.region.width, right_before - 4)
async def test_shrinking_stops_at_the_minimum(self):
from textual.widgets import DataTable
app = await self._mounted_app()
async with app.run_test() as pilot:
await app.workers.wait_for_complete()
await pilot.pause()
app.query_one("#list", DataTable).focus()
await pilot.pause()
for _ in range(30):
await pilot.press("-")
await pilot.pause()
list_pane = app.query_one("#list_pane")
self.assertEqual(list_pane.region.width, PANE_SIZE_MIN)
# Une pression de plus ne descend pas sous le plancher.
await pilot.press("-")
await pilot.pause()
self.assertEqual(list_pane.region.width, PANE_SIZE_MIN)
async def test_growing_is_bounded_so_the_sibling_keeps_the_minimum(self):
from textual.widgets import DataTable
app = await self._mounted_app()
async with app.run_test() as pilot:
await app.workers.wait_for_complete()
await pilot.pause()
app.query_one("#list", DataTable).focus()
await pilot.pause()
for _ in range(60):
await pilot.press("+")
await pilot.pause()
preview = app.query_one("#preview")
self.assertEqual(preview.region.width, PANE_SIZE_MIN)
async def test_no_op_when_focus_is_outside_any_resizable_slot(self):
"""`#preview` n'est pas focalisable aujourd'hui, donc ce chemin
n'est pas atteignable en pratique -- mais `_focused_pane_slot`
doit rendre `None` plutôt que planter si le focus n'est ni dans
`#folders` ni dans `#list_pane`, pour rester correct si un futur
volet focalisable s'ajoute ailleurs.
"""
app = await self._mounted_app()
async with app.run_test() as pilot:
await app.workers.wait_for_complete()
await pilot.pause()
app.set_focus(None)
await pilot.pause()
self.assertIsNone(app._focused_pane_slot())
# Et l'action elle-même ne lève pas.
app.action_grow_pane()
app.action_shrink_pane()
class TestResetPaneSizes(ResizeCase):
async def test_reset_restores_the_layout_defaults(self):
from textual.widgets import DataTable
app = await self._mounted_app()
async with app.run_test() as pilot:
await app.workers.wait_for_complete()
await pilot.pause()
list_pane = app.query_one("#list_pane")
width_before = list_pane.region.width
app.query_one("#list", DataTable).focus()
await pilot.pause()
await pilot.press("+")
await pilot.press("+")
await pilot.pause()
self.assertNotEqual(list_pane.region.width, width_before)
await pilot.press("0")
await pilot.pause()
self.assertEqual(list_pane.region.width, width_before)
async def test_reset_clears_the_stored_customization(self):
from textual.widgets import DataTable
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()
app.query_one("#list", DataTable).focus()
await pilot.pause()
await pilot.press("+")
await pilot.pause()
self.assertIn("columns", todo_prefs.get("mail_pane_sizes", {}))
await pilot.press("0")
await pilot.pause()
sizes = todo_prefs.get("mail_pane_sizes", {})
self.assertEqual(sizes.get("columns", {}), {})
class TestPaneSizePersistence(ResizeCase):
async def test_a_customized_size_survives_a_restart(self):
from textual.widgets import DataTable
app = await self._mounted_app()
async with app.run_test() as pilot:
await app.workers.wait_for_complete()
await pilot.pause()
app.query_one("#list", DataTable).focus()
await pilot.pause()
await pilot.press("+")
await pilot.press("+")
await pilot.pause()
grown_width = app.query_one("#list_pane").region.width
app2 = await self._mounted_app(sessions=[self._fresh_session()])
async with app2.run_test() as pilot:
await app2.workers.wait_for_complete()
await pilot.pause()
self.assertEqual(
app2.query_one("#list_pane").region.width, grown_width
)
async def test_a_corrupt_stored_size_falls_back_without_raising(self):
from script.todo import todo_prefs
todo_prefs.set(
"mail_pane_sizes", {"columns": {"folders": "not-a-size"}}
)
app = await self._mounted_app()
async with app.run_test() as pilot:
await app.workers.wait_for_complete()
await pilot.pause()
# Ne lève pas, et retombe sur la valeur de la feuille de style
# (28, la même qu'avant cette tâche).
self.assertEqual(app.query_one("#folders").region.width, 28)
async def test_an_absurdly_large_stored_size_is_clamped(self):
from script.todo import todo_prefs
todo_prefs.set("mail_pane_sizes", {"columns": {"folders": 99999}})
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")
folders = app.query_one("#folders")
self.assertLessEqual(
folders.region.width, panes.region.width - PANE_SIZE_MIN
)
async def test_resizing_one_layout_does_not_disturb_another(self):
from textual.widgets import DataTable
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()
app.query_one("#list", DataTable).focus()
await pilot.pause()
await pilot.press("+")
await pilot.press("+")
await pilot.pause()
await pilot.press("v") # -> split
await pilot.pause()
sizes = todo_prefs.get("mail_pane_sizes", {})
self.assertIn("columns", sizes)
self.assertNotIn("split", sizes)
class TestFullscreenKey(ResizeCase):
"""`enter` est lié à `action_toggle_fullscreen` sur `MailApp`, mais
`Tree`/`DataTable` lient déjà `enter` eux-mêmes (`select_cursor`) et
gagnent toujours -- Textual donne priorité à la liaison la plus proche
du nœud focalisé (`App._check_bindings`, chaîne `focused.ancestors_with_self`,
voir le commentaire de `_SearchInput`). Comme l'un des deux a TOUJOURS le
focus par défaut, `enter` n'atteint donc jamais `MailApp` en pratique --
`z` (libre, non revendiqué par `Tree`/`DataTable`/`Input`) le remplace.
"""
async def test_z_toggles_fullscreen_while_folders_has_focus(self):
from textual.widgets import Tree
app = await self._mounted_app()
async with app.run_test() as pilot:
await app.workers.wait_for_complete()
await pilot.pause()
self.assertIsInstance(app.focused, Tree)
panes = app.query_one("#panes")
preview = app.query_one("#preview")
await pilot.press("z")
await pilot.pause()
self.assertTrue(panes.has_class("fullscreen"))
self.assertEqual(preview.region, panes.region)
await pilot.press("escape")
await pilot.pause()
self.assertFalse(panes.has_class("fullscreen"))
async def test_z_toggles_fullscreen_while_the_list_has_focus(self):
from textual.widgets import DataTable
app = await self._mounted_app()
async with app.run_test() as pilot:
await app.workers.wait_for_complete()
await pilot.pause()
app.query_one("#list", DataTable).focus()
await pilot.pause()
panes = app.query_one("#panes")
await pilot.press("z")
await pilot.pause()
self.assertTrue(panes.has_class("fullscreen"))
async def test_enter_no_longer_claims_a_binding_at_the_app_level(self):
"""`enter` reste lié à `Tree`/`DataTable` eux-mêmes (sélection) --
mais `MailApp` ne le revendique plus pour le plein écran, pour ne
pas laisser un pied d'écran annoncer une touche qui, depuis l'état
focalisé par défaut, ne fait jamais rien.
"""
app = await self._mounted_app()
async with app.run_test() as pilot:
await app.workers.wait_for_complete()
await pilot.pause()
bindings = app._bindings.key_to_bindings
self.assertNotIn("enter", bindings)
self.assertIn("z", bindings)
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["z"]
self.assertEqual(
active.binding.description, t("mail_fullscreen_binding")
)
class TestTerminalResizeRespectsMinimum(ResizeCase):
"""Fix round : un volet grandi, puis un TERMINAL rétréci sans qu'aucune
touche ne soit pressée, ne repassait par aucune des actions qui
bornent — la surcharge en ligne restait figée à l'ancienne valeur, et
le voisin s'écrasait jusqu'à zéro, sans qu'aucune touche ne puisse le
récupérer (le plafond du volet écrasé se calcule alors contre SA
PROPRE région, déjà nulle). `on_resize` reborne maintenant contre
l'espace RÉELLEMENT disponible à chaque redimensionnement du terminal
— mesuré ici, jamais une classe ni une variable interne.
"""
async def test_shrinking_the_terminal_keeps_every_pane_above_minimum(
self,
):
app = await self._mounted_app()
async with app.run_test(size=(80, 24)) as pilot:
await app.workers.wait_for_complete()
await pilot.pause()
panes = app.query_one("#panes")
folders = app.query_one("#folders")
right = app.query_one("#right")
list_pane = app.query_one("#list_pane")
preview = app.query_one("#preview")
for layout_id, _ in MAIL_LAYOUTS:
while app.mail_layout != layout_id:
await pilot.press("v")
await pilot.pause()
# Grandit le volet dossiers (focus par défaut) près du
# maximum permis par le terminal 80x24 de départ.
for _ in range(20):
await pilot.press("+")
await pilot.pause()
# 40x18 (aire de #panes ~15) : assez pour satisfaire les
# planchers des TROIS volets même en « stacked », le pire
# cas (folders>=4 ET #right>=8, puisque #right y héberge à
# son tour list_pane/preview le long du MÊME axe -- voir
# `_PANE_SIBLING_MIN`) ; en dessous de ~15, ce ne serait
# plus une question de correctif mais de terminal
# physiquement trop petit pour les trois planchers à la
# fois, hors de portée de tout redimensionnement de volet.
await pilot.resize_terminal(40, 18)
await pilot.pause()
await pilot.pause() # laisse le correctif différé tourner
folders_dim = app._pane_dimension(panes)
list_dim = app._pane_dimension(right)
for widget, dim, name in (
(folders, folders_dim, "folders"),
(right, folders_dim, "right"),
(list_pane, list_dim, "list_pane"),
(preview, list_dim, "preview"),
):
measured = getattr(widget.region, dim)
self.assertGreaterEqual(
measured,
PANE_SIZE_MIN,
f"disposition {layout_id!r} : {name}.{dim} ="
f" {measured}, attendu >= {PANE_SIZE_MIN}",
)
# Repart d'un terminal et de tailles propres avant la
# disposition suivante.
await pilot.resize_terminal(80, 24)
await pilot.pause()
await pilot.pause()
await pilot.press("0")
await pilot.pause()
async def test_growing_the_terminal_back_restores_the_stored_size(self):
from textual.widgets import DataTable
app = await self._mounted_app()
async with app.run_test(size=(80, 24)) as pilot:
await app.workers.wait_for_complete()
await pilot.pause()
app.query_one("#list", DataTable).focus()
await pilot.pause()
for _ in range(3):
await pilot.press("+")
await pilot.pause()
list_pane = app.query_one("#list_pane")
grown_width = list_pane.region.width
await pilot.resize_terminal(40, 24)
await pilot.pause()
await pilot.pause()
self.assertLess(list_pane.region.width, grown_width)
await pilot.resize_terminal(80, 24)
await pilot.pause()
await pilot.pause()
# L'INTENTION (stockée, jamais écrasée par le rétrécissement
# temporaire) revient dès que la place existe de nouveau.
self.assertEqual(list_pane.region.width, grown_width)
class TestReSettlingDoesNotStarveTheUncustomizedSibling(ResizeCase):
"""Round 3 : `_apply_pane_size_for_slot` lisait `pane.region` juste
après son PROPRE `self._clear_pane_size(slot)`, dans le MÊME appel --
cette région est pré-rafraîchissement, exactement la même classe de
bogue déjà corrigée pour la lecture de `#right`. Reproduit en poussant
`#folders` à son plafond (11 pressions, `#right` = 8, `list_pane` déjà
correctement à 4), puis en redéclenchant `_apply_pane_size_for_slot`
une seconde fois SANS rien changer à `#folders` -- soit par une
pression `+` de plus (déjà au plafond, donc sans effet sur `#folders`
lui-même), soit par un redimensionnement du terminal. `list_pane`
retombait alors à 3 et y restait, PAS transitoirement.
N'affirme jamais un nombre précis (ce serait passer par parité, comme
le test initial de ce correctif qui ne reproduisait pas ce cas) :
seulement que chaque volet reste >= `PANE_SIZE_MIN`.
"""
async def _grow_folders_to_ceiling(self, pilot):
for _ in range(11):
await pilot.press("+")
await pilot.pause()
def _assert_every_pane_at_or_above_minimum(self, app):
folders = app.query_one("#folders")
right = app.query_one("#right")
list_pane = app.query_one("#list_pane")
preview = app.query_one("#preview")
for widget, name in (
(folders, "folders"),
(right, "right"),
(list_pane, "list_pane"),
(preview, "preview"),
):
self.assertGreaterEqual(
widget.region.width,
PANE_SIZE_MIN,
f"{name}.width = {widget.region.width}, attendu >="
f" {PANE_SIZE_MIN}",
)
async def test_an_extra_no_op_grow_does_not_starve_list_pane(self):
app = await self._mounted_app()
async with app.run_test(size=(80, 24)) as pilot:
await app.workers.wait_for_complete()
await pilot.pause()
await self._grow_folders_to_ceiling(pilot)
# #folders est déjà à son plafond : cette pression ne le
# change PAS, mais redéclenche quand même la correction de
# list_pane.
await pilot.press("+")
await pilot.pause()
self._assert_every_pane_at_or_above_minimum(app)
async def test_a_clean_resize_after_reaching_the_ceiling_does_not_starve_list_pane(
self,
):
app = await self._mounted_app()
async with app.run_test(size=(80, 24)) as pilot:
await app.workers.wait_for_complete()
await pilot.pause()
await self._grow_folders_to_ceiling(pilot)
await pilot.resize_terminal(81, 24)
await pilot.pause()
await pilot.pause()
self._assert_every_pane_at_or_above_minimum(app)
async def test_a_stored_list_pane_size_is_recapped_when_folders_grows(
self,
):
"""Ce que `then=` sert encore à garantir, une fois le plancher
confié à la feuille de style : la taille STOCKÉE de `list_pane` doit
être re-bornée contre le `#right` qui RESTE après l'agrandissement
de `#folders`, pas contre celui d'avant.
Mesuré par le remplissage EXACT de `#right` par ses trois enfants :
une taille bornée contre un `#right` périmé les fait déborder, ce
qu'aucune assertion de plancher ne verrait — `min-width` maintient
alors chaque volet au-dessus de son plancher pendant que la somme
dépasse le conteneur.
"""
from textual.widgets import DataTable, Tree
app = await self._mounted_app()
async with app.run_test(size=(80, 24)) as pilot:
await app.workers.wait_for_complete()
await pilot.pause()
# Une taille de `list_pane` VOULUE par l'utilisateur, donc
# stockée -- sans elle, rien à re-borner ici.
app.query_one("#list", DataTable).focus()
await pilot.pause()
for _ in range(3):
await pilot.press("+")
await pilot.pause()
app.query_one("#folders", Tree).focus()
await pilot.pause()
await self._grow_folders_to_ceiling(pilot)
right = app.query_one("#right")
list_pane = app.query_one("#list_pane")
list_splitter = app.query_one("#list_splitter")
preview = app.query_one("#preview")
self.assertEqual(
list_pane.region.width
+ list_splitter.region.width
+ preview.region.width,
right.region.width,
f"list_pane {list_pane.region.width} + barre"
f" {list_splitter.region.width} + preview"
f" {preview.region.width} != #right"
f" {right.region.width}",
)
self._assert_every_pane_at_or_above_minimum(app)
class _PaneThatMisreportsItsRegion:
"""Un volet qui MENT sur sa propre région, et note qu'on la lui a
demandée. Tout le reste (`styles` compris) passe au vrai widget, si bien
qu'une taille posée à travers ce mandataire arrive réellement sur
l'écran.
Sert à prouver une propriété STRUCTURELLE plutôt qu'à rejouer un
scénario : la taille d'un volet ne doit dépendre en RIEN de la région de
ce volet, parce qu'à l'instant où elle serait lue elle est encore
pré-rafraîchissement (voir `_apply_pane_size_for_slot`). Une propriété
ne se teste pas en attendant qu'une course se produise — 3 % des
exécutions, la raison pour laquelle ce bogue a survécu à trois
corrections.
"""
# TOUTES les façons d'obtenir la géométrie RENDUE d'un widget, pas la
# seule qui a servi au bogue : n'intercepter que `region` interdirait
# une ORTHOGRAPHE, pas la classe -- `pane.size` rentrerait par la
# fenêtre et les 46 tests resteraient verts.
_MEASURED = frozenset(
{
"region",
"size",
"content_region",
"content_size",
"outer_size",
"container_size",
"virtual_size",
"window_region",
"scrollable_content_region",
}
)
def __init__(self, widget, lie, reads):
self._widget = widget
self._lie = lie
self._reads = reads
def __getattr__(self, name):
# `_widget`/`_lie`/`_reads` sont des attributs d'instance : la
# recherche normale les trouve, `__getattr__` n'est jamais appelé
# pour eux.
if name in self._MEASURED:
self._reads.append(f"{self._widget.id}.{name}")
return self._lie
return getattr(self._widget, name)
class TestPaneSizingIgnoresThePanesOwnRegion(ResizeCase):
"""Tâche 27. `_settle` prenait la région du volet comme base de bornage
quand rien n'était stocké — une région que son PROPRE
`_clear_pane_size`, deux lignes plus haut, venait d'invalider. Un
`call_after_refresh` rendait la lecture juste presque toujours ; quand
elle ne l'était pas, la base valait l'ancienne surcharge, le bornage
retombait dessus, la branche « rien à corriger » sautait l'écriture, et
`list_pane` restait DÉFINITIVEMENT à la part que la feuille de style lui
donne (`2fr` de `#right`, soit 3 cellules).
Les deux tests ci-dessous rendent ce cas DÉTERMINISTE.
"""
async def _grow_folders_to_ceiling(self, pilot):
for _ in range(11):
await pilot.press("+")
await pilot.pause()
def _assert_every_pane_at_or_above_minimum(self, app):
for name in ("#folders", "#right", "#list_pane", "#preview"):
measured = app.query_one(name).region.width
self.assertGreaterEqual(
measured,
PANE_SIZE_MIN,
f"{name}.width = {measured}, attendu >= {PANE_SIZE_MIN}",
)
def _lie_about_pane_regions(self, app, lie, reads, calls):
"""Détourne `_pane_widgets` pour rendre un volet menteur, et note
CHAQUE appel — les appels servent de contrôle positif : sans eux, un
test qui n'affirme qu'une absence de lecture passerait tout aussi
bien si le réglage des tailles n'avait pas tourné du tout.
"""
original = app._pane_widgets
def _patched(slot: str):
calls.append(slot)
pane, parent = original(slot)
return _PaneThatMisreportsItsRegion(pane, lie, reads), parent
app._pane_widgets = _patched
async def test_the_floor_holds_even_when_the_pane_misreports_its_size(
self,
):
"""Le volet rapporte EXACTEMENT le plancher : sous l'ancien code,
`clamped == current` faisait sauter l'écriture et la feuille de
style reprenait la main avec 3 cellules. Le plancher ne se mesure
plus (`_PANE_MIN_CSS`), donc mentir ne peut plus l'abaisser.
"""
from textual.geometry import Region
app = await self._mounted_app()
async with app.run_test(size=(80, 24)) as pilot:
await app.workers.wait_for_complete()
await pilot.pause()
await self._grow_folders_to_ceiling(pilot)
reads, calls = [], []
self._lie_about_pane_regions(
app,
Region(0, 0, PANE_SIZE_MIN, PANE_SIZE_MIN),
reads,
calls,
)
await pilot.resize_terminal(81, 24)
await pilot.pause()
await pilot.pause()
self.assertTrue(
calls, "le réglage des tailles n'a pas tourné du tout"
)
self._assert_every_pane_at_or_above_minimum(app)
async def test_settling_never_reads_the_region_of_the_pane_it_sizes(self):
"""La propriété elle-même, pas une de ses conséquences : aucune
lecture, donc rien à lire trop tôt. Pour qu'une lecture périmée
revienne, il faudrait la réintroduire ici — ce test l'interdit.
"""
from textual.geometry import Region
app = await self._mounted_app()
async with app.run_test(size=(80, 24)) as pilot:
await app.workers.wait_for_complete()
await pilot.pause()
await self._grow_folders_to_ceiling(pilot)
reads, calls = [], []
# Un redimensionnement du terminal, pas une touche `+`/`-` :
# `_resize_focused_pane` lit LÉGITIMEMENT la région du volet (la
# taille vive que l'utilisateur veut incrémenter), et cette
# lecture-là ne suit aucun effacement.
self._lie_about_pane_regions(
app, Region(0, 0, 999, 999), reads, calls
)
await pilot.resize_terminal(81, 24)
await pilot.pause()
await pilot.pause()
self.assertTrue(
calls, "le réglage des tailles n'a pas tourné du tout"
)
self.assertEqual(
reads,
[],
"le réglage des tailles a lu la région des volets"
f" {reads} — une valeur encore pré-rafraîchissement",
)
# Et le mensonge n'a rien déréglé : la preuve que le chemin
# exercé est bien le chemin mesuré.
self._assert_every_pane_at_or_above_minimum(app)
class TestSmallTerminalsKeepEveryPaneOnScreen(ResizeCase):
"""Un volet qui DÉBORDE de son conteneur n'est pas seulement mal
dimensionné : il n'est jamais composité. Aucune barre de défilement
n'apparaît, `Tab` ne l'atteint pas, et rien à l'écran ne dit qu'il
existe. C'est arrivé en « stacked » dès 80x20, dans l'état PAR DÉFAUT,
sans qu'aucun test le voie : tous ceux qui approchent ces tailles
pressent `+` d'abord, et une taille de `#folders` stockée réserve
justement à `#right` de quoi tenir.
`resolve_fraction_unit` (`_resolve.py:190-214`) épingle à son minimum
tout enfant `fr` qui descendrait sous lui et le RETIRE du réservoir ;
quand tous les frères `fr` s'épinglent, `1fr` vaut TOUT l'espace
restant et chacun reçoit la totalité. Deux volets de 7 dans un
conteneur de 8.
Ces tests mesurent donc le REMPLISSAGE EXACT et la COMPOSITION, pas des
planchers : un plancher tenu par un volet hors écran est tenu pour
rien. Ils cassent aussi si Textual changeait la soustraction
`border-box` de `_resolve_extrema`, qui décide ce que valent ces
tailles.
"""
# 13 lignes est le plancher réel de « stacked » : `#folders` ne descend
# pas sous `PANE_SIZE_MIN`, `#right` a besoin d'au moins `PANE_SIZE_MIN`
# + la barre pour que `#preview` garde une ligne, et `#panes` perd
# l'en-tête, l'état et le pied. En dessous, aucune disposition des
# trois volets ne tient — ce n'est plus un défaut de bornage.
SMALL_SIZES = ((80, 20), (80, 18), (80, 16), (80, 14), (40, 18))
def _assert_panes_fit_and_paint(self, app, label):
panes = app.query_one("#panes")
right = app.query_one("#right")
outer = app._pane_dimension(panes)
inner = app._pane_dimension(right)
def measure(selector, dimension):
return getattr(app.query_one(selector).region, dimension)
outer_sum = (
measure("#folders", outer)
+ measure("#folders_splitter", outer)
+ measure("#right", outer)
)
self.assertEqual(
outer_sum,
measure("#panes", outer),
f"{label} : #folders + barre + #right = {outer_sum} !="
f" #panes {measure('#panes', outer)} ({outer})",
)
inner_sum = (
measure("#list_pane", inner)
+ measure("#list_splitter", inner)
+ measure("#preview", inner)
)
self.assertEqual(
inner_sum,
measure("#right", inner),
f"{label} : #list_pane + barre + #preview = {inner_sum} !="
f" #right {measure('#right', inner)} ({inner})",
)
# Le remplissage exact ne suffit pas à prouver qu'on VOIT les
# volets : c'est le compositeur qui décide ce qui est peint.
visible = app.screen._compositor.visible_widgets
for selector in ("#folders", "#list_pane", "#preview"):
self.assertIn(
app.query_one(selector),
visible,
f"{label} : {selector} n'est pas composité — hors écran,"
" sans barre de défilement ni accès au clavier",
)
async def test_a_fresh_launch_at_80x20_paints_every_pane(self):
"""Le scénario exact du signalement : premier lancement, aucune
touche, une disposition par démarrage — pas un redimensionnement
depuis une taille plus grande, qui n'emprunte pas le même chemin de
montage.
"""
from script.todo import todo_prefs
for layout_id, _ in MAIL_LAYOUTS:
todo_prefs.set("mail_layout", layout_id)
app = await self._mounted_app(sessions=[self._fresh_session()])
async with app.run_test(size=(80, 20)) as pilot:
await app.workers.wait_for_complete()
await pilot.pause()
await pilot.pause()
self.assertEqual(app.mail_layout, layout_id)
self._assert_panes_fit_and_paint(
app, f"lancement 80x20 en {layout_id!r}"
)
async def test_no_pane_is_pushed_off_screen_at_small_sizes(self):
"""La même garantie sur une plage de tailles et les trois
dispositions, toujours SANS personnalisation — c'est l'absence de
taille stockée qui déclenchait le défaut.
"""
app = await self._mounted_app()
async with app.run_test(size=(80, 24)) as pilot:
await app.workers.wait_for_complete()
await pilot.pause()
for width, height in self.SMALL_SIZES:
await pilot.resize_terminal(width, height)
await pilot.pause()
await pilot.pause()
for layout_id, _ in MAIL_LAYOUTS:
while app.mail_layout != layout_id:
await pilot.press("v")
await pilot.pause()
await pilot.pause()
self._assert_panes_fit_and_paint(
app, f"{width}x{height} en {layout_id!r}"
)
if __name__ == "__main__":
unittest.main()