From 6dff7aa304c674b223369f7b755e661a9bf307c6 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 19 Aug 2026 06:57:19 -0400 Subject: [PATCH] [ADD] migration: announce the countdown, and cycle three panel states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every prompt now carries « ⏱5s » before the question. One that looks like it waits forever does not invite you to step away, which is the whole point of auto-run. The same clock reports the answer afterwards: it announces the wait, then says what it decided. Enter on the auto-run question already meant no; it now says so, with an explicit default instead of a deduction. And the flag is cleared before asking — an ERPLIBRE_AUTO_EXECUTE inherited from the shell would have answered yes on someone's behalf, and that answer commits every later one. « p » cycles three states rather than toggling one: a plain toggle freed four lines, while what takes the room is the left COLUMN — on a server log every character of width counts. All → no summary → detail only, and back. The state is named in the sub-title, which stays visible in all three: a panel that vanishes without a word reads as a broken screen. --- FR --- [ADD] migration : annoncer le compte à rebours, et trois états de panneaux Chaque invite porte désormais « ⏱5s » devant la question. Une invite qui a l'air d'attendre indéfiniment n'invite pas à s'absenter, et c'est pourtant tout l'intérêt du mode auto. Le même chronomètre rend compte ensuite. Entrée sur la question d'auto-exécution voulait déjà dire non ; elle le DIT maintenant, par un défaut explicite plutôt qu'une déduction. Et le drapeau est effacé avant de demander : une variable héritée du shell aurait répondu « oui » à la place de quelqu'un. « p » enchaîne trois états au lieu d'en basculer un : ce qui prend la place, c'est la colonne de gauche. Tout → sans résumé → détail seul, et l'on revient. L'état est nommé dans le sous-titre. Assisted-by: Claude Opus 5 --- script/todo/auto_ask.py | 11 ++-- script/todo/migration_status_tui.py | 98 ++++++++++++++++++++++++----- script/todo/todo_i18n.py | 16 +++++ script/todo/todo_upgrade.py | 10 ++- test/test_migration_status.py | 75 +++++++++++++++++++++- test/test_prompt_defaults.py | 90 ++++++++++++++++++++++++++ 6 files changed, 274 insertions(+), 26 deletions(-) diff --git a/script/todo/auto_ask.py b/script/todo/auto_ask.py index d373ce7..19c02ce 100644 --- a/script/todo/auto_ask.py +++ b/script/todo/auto_ask.py @@ -86,12 +86,15 @@ def ask(prompt, default="", seconds=None): return input(prompt) or default import select - sys.stdout.write(prompt) + attente = delay() if seconds is None else seconds + # DIRE qu'on répondra tout seul, et dans combien de temps. Une invite + # qui a l'air d'attendre indéfiniment n'invite pas à s'absenter, et + # c'est pourtant tout l'intérêt du mode auto. Devant l'invite plutôt + # qu'après : elle se lit avant qu'on décide de partir. + sys.stdout.write(f"⏱{attente:g}s {prompt}") sys.stdout.flush() try: - ready, _, _ = select.select( - [sys.stdin], [], [], delay() if seconds is None else seconds - ) + ready, _, _ = select.select([sys.stdin], [], [], attente) except (OSError, ValueError): # stdin n'est pas sélectionnable : on ne devine pas, on demande. return input("") or default diff --git a/script/todo/migration_status_tui.py b/script/todo/migration_status_tui.py index 02acece..a8c0035 100644 --- a/script/todo/migration_status_tui.py +++ b/script/todo/migration_status_tui.py @@ -154,6 +154,64 @@ def pane_text(dct, row, colour=False, show_log=True): return "\n".join(lignes) +PANELS = ("all", "no-summary", "detail") + + +def panel_label(state): + """Le nom de l'état courant, tel qu'il s'affiche dans le sous-titre.""" + return { + "all": t("summary + list + detail"), + "no-summary": t("list + detail"), + "detail": t("detail only"), + }[PANELS[state % len(PANELS)]] + + +def panel_visibility(state): + """(résumé visible, liste visible) pour cet état. + + Trois états, du plus complet au plus dépouillé. Un simple bascule ne + libérait que quatre lignes ; ce qui prend la place, c'est la COLONNE de + gauche — et sur un journal de serveur, chaque caractère gagné en + largeur compte. + """ + etat = PANELS[state % len(PANELS)] + return etat == "all", etat != "detail" + + +def current_row(lst_row, index): + """La ligne choisie, ou None quand il n'y en a aucune.""" + return lst_row[index] if lst_row and 0 <= index < len(lst_row) else None + + +def reread(path): + """Relire la progression, ou None si l'on ne sait pas d'où.""" + return status.read(path) if path else None + + +def apply_panels(app): + """Poser la visibilité des panneaux, et NOMMER l'état choisi. + + Le sous-titre reste visible dans les trois : un panneau qui disparaît + sans un mot se lit comme un écran cassé. + """ + resume, liste = panel_visibility(app.panel_state) + app.query_one("#head").display = resume + app.query_one("#left").display = liste + app.sub_title = panel_label(app.panel_state) + + +def apply_width(app, delta): + """Déplacer la séparation, bornée des deux côtés. + + Une colonne de zéro ne se retrouve plus ; une qui mange tout l'écran ne + laisse rien à lire. + """ + app.left_width = max( + app.LEFT_MIN, min(app.LEFT_MAX, app.left_width + delta) + ) + app.query_one("#left").styles.width = app.left_width + + def build_app(dct, path=None): """Textual est importé ICI, pas au chargement du module. @@ -172,7 +230,7 @@ def build_app(dct, path=None): ("q,escape", "quit", t("Quit")), ("r", "refresh", t("Refresh")), ("l", "toggle_log", t("Logs")), - ("p", "toggle_head", t("Panel")), + ("p", "cycle_panels", t("Panels")), ("plus,equal", "wider", t("Wider")), ("minus,underscore", "narrower", t("Narrower")), ] @@ -189,6 +247,7 @@ def build_app(dct, path=None): self.index = 0 self.show_log = True self.left_width = 46 + self.panel_state = 0 def compose(self) -> ComposeResult: yield Header() @@ -202,6 +261,7 @@ def build_app(dct, path=None): def on_mount(self): self.title = t("Migration state") self._fill_table() + apply_panels(self) self._show() def _fill_table(self): @@ -214,7 +274,7 @@ def build_app(dct, path=None): self.query_one("#head", Static).update(head_text(self.dct)) def _show(self): - row = self.lst_row[self.index] if self.lst_row else None + row = current_row(self.lst_row, self.index) # `from_ansi` fait DEUX choses : il rend les couleurs, et il # traite le reste comme du texte LITTÉRAL. Sans lui, une # commande contenant « [1] » passait pour du balisage Rich et @@ -239,9 +299,10 @@ def build_app(dct, path=None): cela, il fallait le fermer et le rouvrir pour voir le palier suivant. """ - if not self.path: + neuf = reread(self.path) + if neuf is None: return - self.dct = status.read(self.path) + self.dct = neuf self.lst_row = rows(self.dct) self.index = min(self.index, max(0, len(self.lst_row) - 1)) self._fill_table() @@ -251,23 +312,26 @@ def build_app(dct, path=None): self.show_log = not self.show_log self._show() - def action_toggle_head(self): - head = self.query_one("#head", Static) - head.display = not head.display + def action_cycle_panels(self): + """Trois états, du plus complet au plus dépouillé. + + Un simple bascule ne libérait que quatre lignes. Ce qui prend + la place, c'est la COLONNE de gauche : sur un journal de + serveur, chaque caractère gagné en largeur compte. On enchaîne + donc tout → sans résumé → détail seul, et l'on revient. + + L'état est écrit dans le sous-titre, qui reste visible dans les + trois : un panneau qui disparaît sans un mot se lit comme un + écran cassé. + """ + self.panel_state = (self.panel_state + 1) % len(PANELS) + apply_panels(self) def action_wider(self): - self._resize(self.LEFT_STEP) + apply_width(self, self.LEFT_STEP) def action_narrower(self): - self._resize(-self.LEFT_STEP) - - def _resize(self, delta): - # Bornée des deux côtés : une colonne de zéro ne se retrouve - # plus, et une qui mange tout l'écran ne laisse rien à lire. - self.left_width = max( - self.LEFT_MIN, min(self.LEFT_MAX, self.left_width + delta) - ) - self.query_one("#left", DataTable).styles.width = self.left_width + apply_width(self, -self.LEFT_STEP) return StatusApp(dct, path) diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 9ba8a24..f11e2e9 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -5675,6 +5675,22 @@ TRANSLATIONS = { "fr": "durée", "en": "duration", }, + "Panels": { + "fr": "Panneaux", + "en": "Panels", + }, + "summary + list + detail": { + "fr": "résumé + liste + détail", + "en": "summary + list + detail", + }, + "list + detail": { + "fr": "liste + détail", + "en": "list + detail", + }, + "detail only": { + "fr": "détail seul", + "en": "detail only", + }, "Clean the database before testing the pages?": { "fr": "Nettoyer la base avant de tester les pages ?", "en": "Clean the database before testing the pages?", diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py index 426a8f6..72c9836 100755 --- a/script/todo/todo_upgrade.py +++ b/script/todo/todo_upgrade.py @@ -802,10 +802,16 @@ class TodoUpgrade: Posée AVANT le choix de version, donc avant la première décision : activée, elle vaut pour toutes les suivantes, celle-là comprise. """ + # L'auto-exécution ne s'active JAMAIS d'elle-même : on efface le + # drapeau avant de poser la question, sinon une variable héritée du + # shell ferait répondre « oui » à la place de quelqu'un — et cette + # réponse-là engage toutes les suivantes. + auto_ask.export(False) answer = ( - input( + auto_ask.ask( f"💬 {t('Auto-run: take the default answer after')}" - f" {self.AUTO_DELAY} {t('seconds?')} (y/N) : " + f" {self.AUTO_DELAY} {t('seconds?')} (y/N) : ", + default="n", ) .strip() .lower() diff --git a/test/test_migration_status.py b/test/test_migration_status.py index b27ed2b..9599455 100644 --- a/test/test_migration_status.py +++ b/test/test_migration_status.py @@ -1090,6 +1090,72 @@ class TestSeparatingCommandsFromLogs(Base): ) +class TestTheThreePanelStates(Base): + """Un simple bascule ne libérait que quatre lignes. + + Ce qui prend la place, c'est la COLONNE de gauche — et sur un journal + de serveur, chaque caractère gagné en largeur compte. D'où trois états, + du plus complet au plus dépouillé. + """ + + def test_the_first_state_shows_everything(self): + self.assertEqual(tui.panel_visibility(0), (True, True)) + + def test_the_second_drops_the_summary(self): + self.assertEqual(tui.panel_visibility(1), (False, True)) + + def test_the_third_leaves_the_detail_alone(self): + self.assertEqual(tui.panel_visibility(2), (False, False)) + + def test_it_comes_back_round(self): + # Sans retour, le troisième état serait un cul-de-sac : la liste + # cachée, on ne peut plus changer d'entrée. + self.assertEqual(tui.panel_visibility(3), tui.panel_visibility(0)) + + def test_there_are_exactly_three(self): + self.assertEqual(len(tui.PANELS), 3) + + def test_each_state_has_a_name(self): + # Un panneau qui disparaît sans un mot se lit comme un écran cassé. + noms = {tui.panel_label(i) for i in range(3)} + self.assertEqual(len(noms), 3) + for nom in noms: + self.assertTrue(nom.strip()) + + def test_p_is_bound_to_the_cycle(self): + app = tui.build_app(progression()) + action = [ + entree[1] for entree in app.BINDINGS if "p" in entree[0].split(",") + ] + self.assertEqual(action, ["cycle_panels"]) + + def test_the_name_is_shown_where_it_stays_visible(self): + # Le sous-titre est dans l'en-tête de Textual, qui reste affiché + # dans les trois états — contrairement au panneau de résumé. + import inspect + + source = inspect.getsource(tui.apply_panels) + self.assertIn("app.sub_title", source) + + +class TestTheGuardsThatCameOutOfTheClass(Base): + def test_no_row_selected_is_not_an_error(self): + self.assertIsNone(tui.current_row([], 0)) + + def test_an_index_past_the_end_is_not_an_error(self): + # Un rafraîchissement peut raccourcir la liste sous le curseur. + self.assertIsNone(tui.current_row([{"a": 1}], 5)) + + def test_rereading_without_a_path_yields_nothing(self): + self.assertIsNone(tui.reread(None)) + + def test_the_width_stays_between_its_bounds(self): + app = tui.build_app(progression()) + self.assertLess(app.LEFT_MIN, app.LEFT_MAX) + self.assertGreaterEqual(app.left_width, app.LEFT_MIN) + self.assertLessEqual(app.left_width, app.LEFT_MAX) + + class TestTheKeyboard(Base): """Ce que l'écran promet dans son pied de page doit exister.""" @@ -1137,9 +1203,12 @@ class TestTheKeyboard(Base): """ import inspect - source = inspect.getsource(tui.build_app) - self.assertIn("status.read(self.path)", source) - self.assertIn("self.lst_row = rows(self.dct)", source) + # La relecture est sortie de la classe pour la rendre testable : + # on vérifie donc les DEUX moitiés, la lecture et le remontage. + self.assertIn("status.read(path)", inspect.getsource(tui.reread)) + self.assertIn( + "self.lst_row = rows(self.dct)", inspect.getsource(tui.build_app) + ) def test_refreshing_without_a_path_does_nothing(self): app = tui.build_app(progression(), path=None) diff --git a/test/test_prompt_defaults.py b/test/test_prompt_defaults.py index 76427a3..b2e9a22 100644 --- a/test/test_prompt_defaults.py +++ b/test/test_prompt_defaults.py @@ -224,6 +224,96 @@ class TestTheCountdownSpeaksThePromptsLanguage(EnvCase): self.assertIn("Entrée", texte) +class TestTheQuestionThatCannotAnswerItself(EnvCase): + """Celle-là engage toutes les suivantes : elle ne se prend pas seule. + + Une variable `ERPLIBRE_AUTO_EXECUTE` héritée du shell ferait répondre + « oui » à la place de quelqu'un, et cette réponse-là décide de toutes + les autres pendant des heures. + """ + + def answer(self, typed): + import builtins + + obj = TodoUpgrade.__new__(TodoUpgrade) + original = builtins.input + builtins.input = lambda prompt="": typed + self.addCleanup(setattr, builtins, "input", original) + with contextlib.redirect_stdout(io.StringIO()): + obj.prompt_auto_execute() + return obj.auto_execute + + def test_enter_says_no(self): + self.assertFalse(self.answer("")) + + def test_the_default_is_written_down_not_deduced(self): + # `answer == "y"` donnait le bon résultat par accident ; un défaut + # explicite dit ce qu'on veut, et se relit. + import inspect + + source = inspect.getsource(TodoUpgrade.prompt_auto_execute) + self.assertIn('default="n"', source) + + def test_an_inherited_flag_cannot_switch_it_on(self): + os.environ[auto_ask.ENV_ENABLED] = "1" + os.environ[auto_ask.ENV_DELAY] = "5" + self.assertFalse(self.answer("")) + + def test_the_inherited_flag_is_cleared_before_asking(self): + import inspect + + source = inspect.getsource(TodoUpgrade.prompt_auto_execute) + self.assertLess( + source.index("auto_ask.export(False)"), + source.index("auto_ask.ask("), + ) + + def test_yes_still_says_yes(self): + self.assertTrue(self.answer("y")) + + +class TestEveryPromptAnnouncesTheCountdown(EnvCase): + """Une invite qui a l'air d'attendre indéfiniment n'invite pas à + s'absenter — et c'est pourtant tout l'intérêt du mode auto.""" + + def ask(self, default="d", seconds=5): + import select + + original = select.select + select.select = lambda r, w, x, t: ([], [], []) + self.addCleanup(setattr, select, "select", original) + auto_ask.export(True, seconds) + out = io.StringIO() + with contextlib.redirect_stdout(out): + auto_ask.ask("💬 q : ", default=default) + return out.getvalue() + + def test_the_delay_is_announced_before_the_question(self): + texte = self.ask() + self.assertIn("⏱5s", texte) + self.assertLess(texte.index("⏱5s"), texte.index("💬")) + + def test_it_shows_the_real_delay(self): + self.assertIn("⏱2s", self.ask(seconds=2)) + + def test_nothing_is_announced_outside_auto_mode(self): + import builtins + + auto_ask.export(False) + original = builtins.input + vu = [] + builtins.input = lambda prompt="": vu.append(prompt) or "x" + self.addCleanup(setattr, builtins, "input", original) + auto_ask.ask("💬 q : ", default="d") + self.assertNotIn("⏱", vu[0]) + + def test_the_clock_announces_AND_reports(self): + # Le même symbole des deux côtés : il annonce l'attente, puis rend + # compte de la réponse. Deux symboles feraient deux mécanismes. + texte = self.ask() + self.assertEqual(texte.count("⏱"), 2) + + class TestNoPromptOfTheMigrationCanHang(unittest.TestCase): """Un `input()` nu ne sait rien du mode auto : il attend, pour toujours.