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)