diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py index 2f12db1..232dbf9 100755 --- a/script/todo/todo_upgrade.py +++ b/script/todo/todo_upgrade.py @@ -1248,16 +1248,30 @@ class TodoUpgrade: if "target_odoo_version" in self.dct_progression: odoo_target_version = self.dct_progression["target_odoo_version"] else: - # La plus haute, pas la dernière de la liste : l'ordre - # d'affichage n'est pas une garantie, la comparaison en est une. - default_version = ( - max( - lst_odoo_version, - key=lambda a: float(a.get("prompt_description")), - ).get("prompt_description") - if lst_odoo_version - else None - ) + # Le défaut est le NUMÉRO de la version la plus haute, calculé + # sur la liste réellement affichée : « 6 » n'est pas une + # constante, c'est le rang de 18.0 aujourd'hui. Une version de + # plus dans le catalogue le déplace, et l'écrire en dur ferait + # choisir 18.0 quand l'écran propose 19.0. + # + # La plus haute se COMPARE, elle ne se lit pas au bout de la + # liste : l'ordre d'affichage n'est pas une garantie, et « 9.0 » + # se trie après « 18.0 » en chaînes. + default_index = None + default_version = None + if lst_odoo_version: + default_index = ( + max( + range(len(lst_odoo_version)), + key=lambda i: float( + lst_odoo_version[i].get("prompt_description") + ), + ) + + 1 + ) + default_version = lst_odoo_version[default_index - 1].get( + "prompt_description" + ) print( f"💬 {t('Which version do you want to upgrade to?')}" + ( @@ -1269,15 +1283,14 @@ class TodoUpgrade: odoo_target_version = None cmd_no_found = True while cmd_no_found: - # `default=""` est nécessaire : sans lui, click redemande sur - # une ligne vide sans rien dire, et Entrée n'atteint jamais - # ce code — la valeur par défaut n'existait qu'en intention. + # click affiche « [6] » et rend « 6 » sur Entrée : le chemin + # normal traite la réponse, sans cas particulier à tenir + # d'accord avec lui. status = click.prompt( - help_info, default="", show_default=False + help_info, + default=str(default_index) if default_index else "", + show_default=bool(default_index), ) - if not str(status).strip() and default_version: - odoo_target_version = default_version - break try: int_cmd = int(status) if 0 < int_cmd <= len(lst_odoo_version): diff --git a/test/test_target_version_default.py b/test/test_target_version_default.py index 89a09f6..5be81d1 100755 --- a/test/test_target_version_default.py +++ b/test/test_target_version_default.py @@ -4,14 +4,18 @@ """« Vers quelle version migrer ? » : Entrée doit viser la plus haute. -C'est le choix qu'on fait presque toujours — migrer, c'est aller au bout — -et il fallait le taper. Pire : `click.prompt` sans `default` REDEMANDE sur -une ligne vide, sans rien afficher. Entrée n'atteignait donc aucun code ; -une valeur par défaut ajoutée sans ce `default=""` n'aurait jamais servi et -rien ne l'aurait signalé. +C'est le choix qu'on fait presque toujours — migrer, c'est aller au bout — et +il fallait le taper. `click.prompt` reçoit donc un défaut : il affiche +« [6] » et rend « 6 » sur Entrée, si bien que la réponse suit le chemin +normal, sans second comportement à tenir d'accord avec le premier. -La plus haute se calcule, elle ne se lit pas au bout de la liste : l'ordre -d'affichage n'est pas une garantie, la comparaison en est une. +Deux pièges, tous deux vérifiés ici : + +- SANS `default`, click redemande en silence sur une ligne vide. Une valeur + par défaut ajoutée sans lui n'aurait jamais été atteinte, et rien ne + l'aurait signalé. +- « 6 » est le rang de 18.0 AUJOURD'HUI. Une version de plus au catalogue le + déplace ; écrit en dur, il ferait choisir 18.0 quand l'écran propose 19.0. """ import ast @@ -24,83 +28,152 @@ REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) from script.todo.todo_upgrade import TodoUpgrade # noqa: E402 +def prompt_call(source): + """Le voisinage de l'appel à click.prompt, où se joue le défaut.""" + start = source.index("click.prompt(") + return source[start : start + 240] + + class TestTheSource(unittest.TestCase): """Le choix se joue dans une boucle interactive de mille lignes. - On vérifie donc les décisions à la source : que le défaut soit calculé - par comparaison, et que click puisse rendre une ligne vide. + On vérifie donc les décisions là où elles s'écrivent : que click reçoive + un défaut, qu'il soit calculé, et que la question dise la version. """ def source(self): return inspect.getsource(TodoUpgrade.execute_odoo_upgrade) - def test_click_can_return_an_empty_line(self): - # SANS cela, click redemande en silence et Entrée n'arrive jamais - # jusqu'au code qui applique le défaut. - source = self.source() - appel = source.index("click.prompt(") - fenetre = source[appel : appel + 160] - self.assertIn('default=""', fenetre) + def test_click_receives_a_default(self): + # Sans lui, Entrée n'atteint aucun code : click redemande, muet. + window = prompt_call(self.source()) + self.assertIn("default=", window) + self.assertIn("default_index", window) - def test_the_default_is_the_highest_not_the_last(self): + def test_the_default_is_not_written_by_hand(self): + window = prompt_call(self.source()) + self.assertNotIn('default="6"', window) + self.assertNotIn("default=6", window) + + def test_the_rank_is_computed_by_comparison(self): + # Lire le dernier élément marcherait par chance : l'ordre + # d'affichage n'est pas une garantie. source = self.source() self.assertIn("max(", source) - self.assertIn('float(a.get("prompt_description"))', source) + self.assertIn("range(len(lst_odoo_version))", source) - def test_the_default_is_announced(self): - # Un défaut qu'on ne montre pas est un défaut que personne n'utilise. - self.assertIn("Enter =", self.source()) + def test_an_empty_catalogue_shows_no_default(self): + # show_default sur une chaîne vide afficherait « [] », qui se lit + # comme un choix possible. + self.assertIn( + "show_default=bool(default_index)", prompt_call(self.source()) + ) + + def test_the_question_announces_the_version_not_the_rank(self): + # click montre « [6] » ; seul le texte dit que c'est 18.0. + source = self.source() + self.assertIn("Enter =", source) + self.assertIn("default_version", source) + + def test_no_special_case_survives(self): + # click rendant « 6 », le chemin normal suffit. Un cas particulier + # de plus serait un second comportement à maintenir. + self.assertNotIn("if not str(status).strip()", self.source()) -class TestTheChoiceItself(unittest.TestCase): - """Le calcul du défaut, extrait et rejoué sur des listes réelles.""" +class TestTheRankItself(unittest.TestCase): + """Le calcul du défaut, rejoué sur des catalogues réels.""" - def highest(self, lst_version): + def rank(self, lst_version): + """Le rang affiché de la plus haute version, comme le code le fait.""" lst_odoo_version = [{"prompt_description": v} for v in lst_version] + if not lst_odoo_version: + return None return ( max( - lst_odoo_version, - key=lambda a: float(a.get("prompt_description")), - ).get("prompt_description") - if lst_odoo_version - else None + range(len(lst_odoo_version)), + key=lambda i: float( + lst_odoo_version[i].get("prompt_description") + ), + ) + + 1 ) def test_the_case_reported(self): - # Base en 12.0 : la liste proposée va de 13.0 à 18.0. + # Base en 12.0 : la liste proposée va de 13.0 à 18.0, donc [6]. self.assertEqual( - self.highest(["13.0", "14.0", "15.0", "16.0", "17.0", "18.0"]), - "18.0", + self.rank(["13.0", "14.0", "15.0", "16.0", "17.0", "18.0"]), 6 ) - def test_an_unsorted_list_still_gives_the_highest(self): - # Prendre le dernier élément marcherait par chance ; comparer marche. - self.assertEqual(self.highest(["18.0", "13.0", "16.0"]), "18.0") + def test_it_follows_a_new_version(self): + # LE point : le jour où 19.0 entre au catalogue, le défaut devient + # [7] sans que personne ne touche au code. + self.assertEqual( + self.rank( + ["13.0", "14.0", "15.0", "16.0", "17.0", "18.0", "19.0"] + ), + 7, + ) + + def test_an_unsorted_list_points_at_the_highest(self): + self.assertEqual(self.rank(["18.0", "13.0", "16.0"]), 1) + + def test_versions_compare_as_numbers(self): + # « 9.0 » se trie après « 18.0 » en chaînes : la comparaison + # numérique est ce qui évite de proposer une version antérieure. + self.assertEqual(self.rank(["9.0", "18.0"]), 2) def test_a_single_choice(self): - self.assertEqual(self.highest(["18.0"]), "18.0") + self.assertEqual(self.rank(["18.0"]), 1) - def test_no_choice_at_all_has_no_default(self): - # Une base déjà à la version maximale : proposer un défaut - # inexistant ferait planter là où il n'y a simplement rien à faire. - self.assertIsNone(self.highest([])) - - def test_two_digit_versions_compare_as_numbers(self): - # « 9.0 » > « 18.0 » en tri de chaînes : la comparaison numérique - # est ce qui évite de proposer une version antérieure. - self.assertEqual(self.highest(["9.0", "18.0"]), "18.0") + def test_no_choice_at_all_has_no_rank(self): + # Une base déjà à la version maximale : pas de défaut plutôt qu'une + # erreur là où il n'y a rien à faire. + self.assertIsNone(self.rank([])) -class TestTheGuardIsReachable(unittest.TestCase): - def test_the_empty_answer_is_handled_before_the_int(self): - # int("") lève ValueError et retomberait sur « Commande non - # trouvée » : le cas vide doit être traité AVANT. - source = inspect.getsource(TodoUpgrade.execute_odoo_upgrade) - vide = source.index("if not str(status).strip()") - conversion = source.index("int_cmd = int(status)") - self.assertLess(vide, conversion) +class TestClickReallyReturnsIt(unittest.TestCase): + """Ce que click fait vraiment, mesuré et non supposé.""" - def test_the_module_still_parses(self): + def answer(self, typed, default): + import io + import sys + + import click + + original = sys.stdin + sys.stdin = io.StringIO(typed) + try: + return click.prompt( + "", default=default, show_default=True, prompt_suffix="" + ) + finally: + sys.stdin = original + + def test_enter_returns_the_default(self): + self.assertEqual(self.answer("\n", "6"), "6") + + def test_a_typed_value_wins(self): + self.assertEqual(self.answer("2\n", "6"), "2") + + def test_without_a_default_enter_is_swallowed(self): + # La raison d'être du `default` : ici click redemande, et c'est la + # SECONDE ligne qui revient. Entrée n'aurait rien déclenché. + import io + import sys + + import click + + original = sys.stdin + sys.stdin = io.StringIO("\n2\n") + try: + self.assertEqual(click.prompt("", prompt_suffix=""), "2") + finally: + sys.stdin = original + + +class TestTheModuleStillParses(unittest.TestCase): + def test_it_parses(self): path = os.path.join(REPO, "script", "todo", "todo_upgrade.py") with open(path) as handle: ast.parse(handle.read())