From 3a182f2b0dc86e2a6f987b0259b1c5c2c2f280dd Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 19 Aug 2026 04:05:03 -0400 Subject: [PATCH] [ADD] migration: reach the state screen from the statistics too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The statistics screen answers « what was removed, and why ». The state screen answers « where are we, and what broke ». Two neighbouring questions asked at the same moment: separating them by a menu entry rather than by two commands to remember is what makes them usable. Same letter as the prompts. One that changes meaning from one screen to the next is not learned, it is looked up — and nobody looks it up. Adding it surfaced two ways that screen could not survive auto-run. The interface question is the FIRST one asked after auto-run is switched on, and it was a bare input(): an unattended migration stopped there before it had begun. The statistics menu was one too. And the state screen writes the progression before reading it — but the statistics screen runs before the progression is even loaded, so that write failed on an attribute that does not exist yet. --- FR --- [ADD] migration : atteindre l'état depuis les statistiques aussi L'écran de statistiques répond « qu'a-t-on supprimé, et pourquoi ». Celui de l'état répond « où en est-on, et qu'est-ce qui a cassé ». Deux questions voisines, posées au même moment : les séparer par une entrée de menu plutôt que par deux commandes à retenir est ce qui les rend utilisables. La même lettre que dans les invites. Une lettre qui change de sens d'un écran à l'autre ne s'apprend pas, elle se cherche — et personne ne la cherche. L'ajout a révélé deux façons dont cet écran ne survivait pas au mode auto : la question d'interface, PREMIÈRE posée après l'activation, était un input() nu, et le menu des statistiques aussi. Et l'écran d'état écrit la progression avant de la lire, alors que les statistiques s'ouvrent avant même qu'elle ne soit chargée. Assisted-by: Claude Opus 5 --- script/todo/todo_i18n.py | 4 + script/todo/todo_upgrade.py | 24 ++++- test/test_migration_status.py | 159 ++++++++++++++++++++++++++++++++++ 3 files changed, 184 insertions(+), 3 deletions(-) diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 934ff08..da1a738 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -5603,6 +5603,10 @@ TRANSLATIONS = { " t = afficher l'état de la migration)", "en": "(b = go back a step, t = show the migration state)", }, + "Migration state (full screen)": { + "fr": "État de la migration (plein écran)", + "en": "Migration state (full screen)", + }, "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 ee71f70..8956ead 100755 --- a/script/todo/todo_upgrade.py +++ b/script/todo/todo_upgrade.py @@ -494,7 +494,11 @@ class TodoUpgrade: print(f" [3] {t('Migration statistics (read-only)')}") print(f" [0] {t('Cancel')}") print(f" {t('(change the default in TODO > Configuration)')}") - answer = input(t("Choice (0-3, default 1): ")).strip() + # `auto_ask` et non `input` : c'est la PREMIÈRE question posée + # après l'activation de l'auto-exécution, et un input() nu y + # arrêtait la migration avant même qu'elle ne commence. Méthode + # statique, d'où l'appel direct plutôt que `self.ask`. + answer = auto_ask.ask(t("Choice (0-3, default 1): ")).strip() return {"0": None, "2": "cli", "3": "stats"}.get(answer, "tui") @staticmethod @@ -573,10 +577,19 @@ class TodoUpgrade: print(f" [3] {t('COW views: snapshots and differences')}") print(f" [4] {t('Recorded decisions (journal)')}") print(f" [5] {t('Executed commands (last 30)')}") + print(f" [t] {t('Migration state (full screen)')}") print(f" [0] {t('Back')}") - answer = input(f"💬 {t('Your choice')} : ").strip() + # `self.ask` : cet écran est atteint DEPUIS execute_odoo_upgrade, + # donc après la question d'auto-exécution. Un input() nu y + # arrêtait une migration automatique sans rien signaler. + answer = self.ask(f"💬 {t('Your choice')} : ").strip().lower() if answer in ("", "0"): return + if answer == "t": + # La MÊME lettre que dans les invites : une lettre qui + # change de sens d'un écran à l'autre ne s'apprend pas. + self.show_migration_status() + continue if answer == "1": for version, detail in sorted(stats["uninstall"].items()): print(f"\n── {version - 1}.0 → {version}.0 ──") @@ -848,7 +861,12 @@ class TodoUpgrade: le journal, et le journal est justement ce que cet écran montre. Regarder l'état polluerait alors l'état. """ - self.write_config() + # Écrire seulement si l'on a quelque chose EN MÉMOIRE : l'écran + # de statistiques est ouvert avant que la progression ne soit + # chargée, et `write_config` y échouait sur un attribut absent. + # Sans rien en mémoire, le fichier sur disque est déjà la vérité. + if getattr(self, "dct_progression", None): + self.write_config() subprocess.call( f"{PYTHON_BIN} ./script/todo/migration_status.py", shell=True, diff --git a/test/test_migration_status.py b/test/test_migration_status.py index d335719..7e91c53 100644 --- a/test/test_migration_status.py +++ b/test/test_migration_status.py @@ -307,6 +307,40 @@ class TestLookingIsNotAnswering(Base): source.index("write_config"), source.index("subprocess.call") ) + def test_it_survives_a_screen_opened_before_anything_is_loaded(self): + """`show_stats` tourne AVANT que la progression ne soit en mémoire. + + Elle est ouverte tout au début d'`execute_odoo_upgrade`, avant même + le choix du fichier. Y appeler `write_config` échouait alors sur un + attribut qui n'existe pas encore — et l'écran d'état, ouvert depuis + là, aurait planté au lieu de s'afficher. + """ + import script.todo.todo_upgrade as tu + + obj = TodoUpgrade.__new__(TodoUpgrade) + appels = [] + obj.write_config = lambda: appels.append("écrit") + original = tu.subprocess.call + tu.subprocess.call = lambda *a, **kw: 0 + self.addCleanup(setattr, tu.subprocess, "call", original) + obj.show_migration_status() + self.assertEqual(appels, [], "rien en mémoire, rien à écrire") + + def test_but_it_does_write_what_it_has(self): + # Sans rien en mémoire le fichier est déjà la vérité ; avec quelque + # chose, il ne l'est plus tant qu'on ne l'a pas écrit. + import script.todo.todo_upgrade as tu + + obj = TodoUpgrade.__new__(TodoUpgrade) + obj.dct_progression = {"config_database_name": "db"} + appels = [] + obj.write_config = lambda: appels.append("écrit") + original = tu.subprocess.call + tu.subprocess.call = lambda *a, **kw: 0 + self.addCleanup(setattr, tu.subprocess, "call", original) + obj.show_migration_status() + self.assertEqual(appels, ["écrit"]) + def test_looking_does_not_pollute_the_journal(self): # `run_on_terminal` consigne ce qu'il lance, et le journal est # justement ce que cet écran montre. @@ -319,6 +353,131 @@ class TestLookingIsNotAnswering(Base): self.assertNotIn("self.run_on_terminal(", source) +class TestTheStatisticsScreenOffersIt(Base): + """L'écran de statistiques répond « qu'a-t-on supprimé, et pourquoi ». + + L'état, lui, répond « où en est-on, et qu'est-ce qui a cassé ». Deux + questions voisines, posées au même moment : les séparer par un menu + plutôt que par deux commandes à retenir est ce qui les rend + utilisables. + """ + + def stats(self, lst_answer): + obj = TodoUpgrade.__new__(TodoUpgrade) + self.vu = [] + obj.show_migration_status = lambda: self.vu.append("ouvert") + reponses = iter(lst_answer) + obj.ask = lambda prompt, default="": next(reponses) + obj.print_stats = staticmethod(lambda ctx, stats: None) + obj.read_progression = staticmethod( + lambda: {"config_database_name": "db"} + ) + obj.resume_context = lambda dct: {"steps": [], "versions": []} + return obj + + def test_t_opens_the_full_screen(self): + import script.todo.todo_upgrade as tu + + obj = self.stats(["t", "0"]) + original_exists = tu.os.path.exists + tu.os.path.exists = lambda path: True + self.addCleanup(setattr, tu.os.path, "exists", original_exists) + import script.todo.migration_stats as ms + + original = ms.compute + ms.compute = lambda *a, **kw: { + "uninstall": {}, + "journal": {"comments": [], "commands": []}, + "origin_count": 0, + "evolution": [], + "removed_total": 0, + "missing": [], + "duplicate": [], + "fixes": [], + "cow": [], + "delay": "0s", + } + self.addCleanup(setattr, ms, "compute", original) + with redirect_stdout(io.StringIO()): + obj.show_stats() + self.assertEqual(self.vu, ["ouvert"]) + + def test_the_menu_announces_it(self): + import inspect + + source = inspect.getsource(TodoUpgrade.show_stats) + self.assertIn("Migration state (full screen)", source) + self.assertIn('answer == "t"', source) + + def test_it_is_the_SAME_letter_as_the_prompts(self): + # Une lettre qui change de sens d'un écran à l'autre ne s'apprend + # pas. Les deux endroits doivent tester la même. + import inspect + + self.assertIn( + 'reponse == "t"', inspect.getsource(TodoUpgrade.ask_gate) + ) + self.assertIn( + 'answer == "t"', inspect.getsource(TodoUpgrade.show_stats) + ) + + def test_looking_returns_to_the_statistics(self): + # Comme dans les invites : regarder n'est pas répondre. + import inspect + + source = inspect.getsource(TodoUpgrade.show_stats) + debut = source.index('answer == "t"') + self.assertIn("continue", source[debut : debut + 400]) + + +class TestNothingOnThatScreenCanHang(Base): + """L'écran est atteint APRÈS la question d'auto-exécution. + + `ask_ui` est la toute première question posée ensuite, et elle était un + `input()` nu : une migration automatique s'arrêtait là, avant même + d'avoir commencé, sans que rien ne le signale. + """ + + def test_the_interface_question_goes_through_the_timer(self): + import inspect + + source = inspect.getsource(TodoUpgrade.ask_ui) + self.assertIn("auto_ask.ask(", source) + # L'APPEL, pas le mot : le commentaire du correctif nomme lui-même + # ce qu'il remplace, et le chercher à l'aveugle se déclenche sur la + # prose. Deuxième fois que ce piège se referme sur moi. + self.assertNotIn("= input(", source) + + def test_the_statistics_menu_too(self): + import inspect + + source = inspect.getsource(TodoUpgrade.show_stats) + self.assertIn("self.ask(", source) + self.assertNotIn("= input(", source) + + def test_the_default_interface_is_still_the_form(self): + # Le défaut ne doit pas changer en passant par le lecteur temporisé. + import script.todo.todo_prefs as prefs + + original = prefs.get + prefs.get = lambda key: "ask" + self.addCleanup(setattr, prefs, "get", original) + from script.todo import auto_ask + + avant = os.environ.pop(auto_ask.ENV_ENABLED, None) + if avant is not None: + self.addCleanup( + os.environ.__setitem__, auto_ask.ENV_ENABLED, avant + ) + import builtins + + original_input = builtins.input + builtins.input = lambda prompt="": "" + self.addCleanup(setattr, builtins, "input", original_input) + with redirect_stdout(io.StringIO()): + self.assertEqual(TodoUpgrade.ask_ui(), "tui") + + class TestWhatGetsRecorded(Base): def upgrade(self): obj = TodoUpgrade.__new__(TodoUpgrade)