[UPD] migration: let click carry the default, computed from the menu
click now receives the rank of the highest version, so it prints « [6] » and returns « 6 » on Enter. The answer follows the normal path — the special case for an empty line is gone, and with it a second behaviour to keep in agreement with the first. The rank is computed, never written down: 6 is where 18.0 sits today, and one more version in the catalogue moves it. A test replays a catalogue with 19.0 and expects [7]. --- FR --- [UPD] migration : laisser click porter le défaut, calculé sur le menu click reçoit désormais le rang de la version la plus haute : il affiche « [6] » et rend « 6 » sur Entrée. La réponse suit le chemin normal — le cas particulier de la ligne vide disparaît, et avec lui un second comportement à tenir d'accord avec le premier. Le rang est calculé, jamais écrit : 6 est la place de 18.0 aujourd'hui, et une version de plus au catalogue la déplace. Un test rejoue un catalogue avec 19.0 et attend [7]. Assisted-by: Claude Opus 5 (cherry picked from commit 5f0b0110d610187298e0129e45cef28e14bba092)
This commit is contained in:
parent
c67324a223
commit
dc4940089b
2 changed files with 157 additions and 71 deletions
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
Loading…
Reference in a new issue