[ADD] migration: make Enter mean the answer you always give
Auto-run promised to "take the default after five seconds", but every default was EMPTY: it took nothing. Worse, half the prompts of a migration are asked by separate processes — the theme uninstaller, the stale-SCSS detector, the smoke test. They knew nothing of auto-run and waited forever for a keystroke that never came. The countdown now lives in one file and travels through the ENVIRONMENT, the only channel a fork shares. Enter takes the default everywhere, not only under auto-run: a prompt that prints (Y/n) and does otherwise is worse than no prompt. Every text was rewritten to say what Enter does, and every default kept an explicit way out. Two defaults now write. Both are tenable only because the backup runs first, and a test locks that ORDER rather than trusting a promise. --- FR --- [ADD] migration : faire d'Entrée la réponse qu'on donne toujours L'auto-exécution promettait « le défaut après cinq secondes », mais tous les défauts étaient VIDES : elle ne prenait rien. Pire, la moitié des invites d'une migration sont posées par d'autres processus — le désinstalleur de thème, le détecteur de SCSS figé, le test de fumée. Ils ignoraient l'auto-exécution et attendaient sans fin une frappe. Le compte à rebours tient désormais dans un seul fichier et voyage par l'ENVIRONNEMENT, le seul canal qu'un fork partage. Entrée vaut le défaut partout, pas seulement en auto : une invite qui affiche (Y/n) et fait l'inverse est pire que pas d'invite. Chaque texte dit ce que fait Entrée, et chaque défaut garde une issue explicite. Deux défauts écrivent. Ils ne tiennent que parce que la sauvegarde passe d'abord, et un test verrouille cet ORDRE plutôt qu'une promesse. Assisted-by: Claude Opus 5
This commit is contained in:
parent
e52d36277b
commit
95a6ba558e
13 changed files with 804 additions and 87 deletions
|
|
@ -37,6 +37,12 @@ except Exception: # pragma: no cover - repli si i18n indisponible
|
|||
return key
|
||||
|
||||
|
||||
try:
|
||||
from script.todo import auto_ask
|
||||
except Exception: # pragma: no cover - repli si le pilote est absent
|
||||
auto_ask = None
|
||||
|
||||
|
||||
def can_ask():
|
||||
"""Peut-on poser une question ICI ?
|
||||
|
||||
|
|
@ -199,14 +205,32 @@ def delete_attachments(database, lst_row, config_path="./config.conf"):
|
|||
return done.returncode, done.stdout + done.stderr
|
||||
|
||||
|
||||
def prompt(database, theme, attachments, views, config_path, ask=input):
|
||||
"""Garder ou effacer. « Garder » par défaut, et la sauvegarde d'abord."""
|
||||
DEFAULT_ANSWER = "d"
|
||||
|
||||
|
||||
def prompt(database, theme, attachments, views, config_path, ask=None):
|
||||
"""Effacer ou garder. Effacer par défaut, et la sauvegarde D'ABORD.
|
||||
|
||||
Ce qui rend ce défaut tenable, c'est l'ordre : le contenu part dans un
|
||||
fichier avant que la base ne soit touchée. Sans cette sauvegarde, le
|
||||
défaut aurait dû rester « garder » — on ne fait pas d'une décision
|
||||
irréversible la réponse que l'on obtient en ne répondant pas.
|
||||
"""
|
||||
if not attachments:
|
||||
return False
|
||||
if ask is None:
|
||||
# Lancé à part par la migration : sans lecteur temporisé, cette
|
||||
# question arrêtait net une exécution automatique.
|
||||
ask = (
|
||||
auto_ask.make_ask(DEFAULT_ANSWER)
|
||||
if auto_ask
|
||||
else (lambda prompt="": input(prompt) or DEFAULT_ANSWER)
|
||||
)
|
||||
answer = (
|
||||
ask(
|
||||
f"💬 {t('Delete these leftovers, or keep them?')}"
|
||||
f" ({t('Enter = keep')}, d = {t('delete, after saving them')}) : "
|
||||
f" ({t('Enter = delete, after saving them')},"
|
||||
f" k = {t('keep')}) : "
|
||||
)
|
||||
.strip()
|
||||
.lower()
|
||||
|
|
|
|||
|
|
@ -64,6 +64,12 @@ except Exception: # pragma: no cover - repli si i18n indisponible
|
|||
return key
|
||||
|
||||
|
||||
try:
|
||||
from script.todo import auto_ask
|
||||
except Exception: # pragma: no cover - repli si le pilote est absent
|
||||
auto_ask = None
|
||||
|
||||
|
||||
def can_ask():
|
||||
"""Peut-on poser une question ICI ?
|
||||
|
||||
|
|
@ -471,27 +477,41 @@ def render(lst_finding, database, version_dir):
|
|||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def prompt(lst_finding, database, config_path="./config.conf", ask=input):
|
||||
def prompt(lst_finding, database, config_path="./config.conf", ask=None):
|
||||
"""Montrer, puis proposer de corriger. Rend True si l'on a écrit.
|
||||
|
||||
Répondre « oui, réinitialise » sans avoir vu l'écart, c'est accepter de
|
||||
perdre on ne sait quoi. L'invite revient donc après chaque lecture :
|
||||
regarder ne répond pas à la question.
|
||||
|
||||
Le défaut suit ce que le checkout SAIT faire : réinitialiser quand
|
||||
`reset_asset` existe, rien du tout avant la 13.0. Un défaut « a » là où
|
||||
la remise à zéro n'existe pas ferait boucler l'invite sur elle-même.
|
||||
"""
|
||||
odoo_dir = running_odoo_dir()
|
||||
can_reset = reset_supported(odoo_dir)
|
||||
if not can_reset:
|
||||
print(too_early_message(odoo_dir, database))
|
||||
defaut = "a" if can_reset else "n"
|
||||
if ask is None:
|
||||
ask = (
|
||||
auto_ask.make_ask(defaut)
|
||||
if auto_ask
|
||||
else (lambda prompt="": input(prompt) or defaut)
|
||||
)
|
||||
while True:
|
||||
choix = (
|
||||
f" a = {t('reset them onto the module file')}" if can_reset else ""
|
||||
f" {t('Enter = reset them onto the module file')},"
|
||||
if can_reset
|
||||
else ""
|
||||
)
|
||||
answer = (
|
||||
ask(
|
||||
f"💬 {t('What do you want to do with these customizations?')}"
|
||||
f" ({t('Enter = nothing')},"
|
||||
f" ({choix}"
|
||||
f" v = {t('what the copy changed')},"
|
||||
f" w = {t('full screen')}{choix}) : "
|
||||
f" w = {t('full screen')},"
|
||||
f" n = {t('nothing')}) : "
|
||||
)
|
||||
.strip()
|
||||
.lower()
|
||||
|
|
|
|||
|
|
@ -54,6 +54,12 @@ except Exception: # pragma: no cover - repli si i18n indisponible
|
|||
return key
|
||||
|
||||
|
||||
try:
|
||||
from script.todo import auto_ask
|
||||
except Exception: # pragma: no cover - repli si le pilote est absent
|
||||
auto_ask = None
|
||||
|
||||
|
||||
def can_ask():
|
||||
"""Peut-on poser une question ICI ?
|
||||
|
||||
|
|
@ -367,7 +373,10 @@ def apply_reset(database, lst_key):
|
|||
return done.returncode, done.stdout + done.stderr
|
||||
|
||||
|
||||
def prompt(database, lst_failure, lst_key, ask=input):
|
||||
DEFAULT_ANSWER = "a"
|
||||
|
||||
|
||||
def prompt(database, lst_failure, lst_key, ask=None):
|
||||
"""Proposer de corriger, puis dire ce qu'il reste. Rend les clés traitées.
|
||||
|
||||
Détecter sans offrir le geste, c'est laisser relever des identifiants
|
||||
|
|
@ -377,6 +386,14 @@ def prompt(database, lst_failure, lst_key, ask=input):
|
|||
if not lst_key:
|
||||
print(f"ℹ -> {t('No parent view named: nothing to offer.')}")
|
||||
return []
|
||||
if ask is None:
|
||||
# Lancé à part par la migration : sans lecteur temporisé, cette
|
||||
# question arrêtait net une exécution automatique.
|
||||
ask = (
|
||||
auto_ask.make_ask(DEFAULT_ANSWER)
|
||||
if auto_ask
|
||||
else (lambda prompt="": input(prompt) or DEFAULT_ANSWER)
|
||||
)
|
||||
print(f"\n✨ {t('Copies to reset onto their module view')} :")
|
||||
for index, key in enumerate(lst_key, start=1):
|
||||
print(f" [{index}] {key}")
|
||||
|
|
@ -384,13 +401,15 @@ def prompt(database, lst_failure, lst_key, ask=input):
|
|||
answer = (
|
||||
ask(
|
||||
f"💬 {t('Which one(s) to reset?')}"
|
||||
f" ({t('numbers separated by commas, a = all, empty =')}"
|
||||
f" ({t('numbers separated by commas, Enter = all, n =')}"
|
||||
f" {t('nothing')}) : "
|
||||
)
|
||||
.strip()
|
||||
.lower()
|
||||
)
|
||||
if not answer:
|
||||
# « n », et non plus le vide : Entrée vaut « toutes » maintenant, et une
|
||||
# sortie sans mot pour dire non serait une sortie sans issue.
|
||||
if not answer or answer == "n":
|
||||
print(f"ℹ -> {t('Kept. Nothing was reset.')}")
|
||||
return []
|
||||
if answer == "a":
|
||||
|
|
|
|||
107
script/todo/auto_ask.py
Normal file
107
script/todo/auto_ask.py
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
#!/usr/bin/env python3
|
||||
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||
|
||||
"""Lire une réponse, ou prendre le défaut au bout du délai.
|
||||
|
||||
Le mode auto de la migration ne vaut que si TOUTES les invites le
|
||||
connaissent. Or six d'entre elles vivent dans des outils lancés en
|
||||
sous-processus — le désinstalleur de thème, le détecteur de SCSS figé, le
|
||||
test de fumée. Elles ne partagent ni l'objet du pilote ni sa mémoire ; sans
|
||||
rien de commun, elles attendraient indéfiniment une frappe qui ne vient
|
||||
pas, et l'automatisation s'arrêterait là sans rien dire.
|
||||
|
||||
Ce qui traverse un `fork`, c'est l'environnement. Le pilote y pose
|
||||
`ERPLIBRE_AUTO_EXECUTE`, chaque outil le lit, et il n'existe qu'une seule
|
||||
implémentation du compte à rebours.
|
||||
|
||||
`select` plutôt qu'un fil ou une alarme : la question est « quelque chose
|
||||
est-il LISIBLE maintenant », et il faut pouvoir renoncer. Un fil laisserait
|
||||
derrière lui un `input()` toujours bloqué, qui volerait la frappe suivante.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
ENV_ENABLED = "ERPLIBRE_AUTO_EXECUTE"
|
||||
ENV_DELAY = "ERPLIBRE_AUTO_DELAY"
|
||||
DEFAULT_DELAY = 5
|
||||
|
||||
|
||||
def enabled():
|
||||
"""Le mode auto est-il actif pour CE processus ?"""
|
||||
return os.environ.get(ENV_ENABLED, "") == "1"
|
||||
|
||||
|
||||
def delay():
|
||||
"""Combien de secondes attendre. Une valeur illisible ne bloque rien."""
|
||||
try:
|
||||
value = float(os.environ.get(ENV_DELAY, DEFAULT_DELAY))
|
||||
except (TypeError, ValueError):
|
||||
return DEFAULT_DELAY
|
||||
# Un délai nul ou négatif rendrait la reprise en main impossible : on
|
||||
# ne peut pas interrompre ce qui ne laisse aucune fenêtre.
|
||||
return value if value > 0 else DEFAULT_DELAY
|
||||
|
||||
|
||||
def export(on, seconds=None):
|
||||
"""Poser le mode auto dans l'environnement, pour tout ce qu'on lancera.
|
||||
|
||||
C'est le seul canal qu'un sous-processus partage avec nous. Le retirer
|
||||
quand on l'éteint, plutôt que d'y écrire « 0 », évite qu'un reste de
|
||||
session précédente décide à notre place.
|
||||
"""
|
||||
if on:
|
||||
os.environ[ENV_ENABLED] = "1"
|
||||
os.environ[ENV_DELAY] = str(
|
||||
seconds if seconds is not None else DEFAULT_DELAY
|
||||
)
|
||||
else:
|
||||
os.environ.pop(ENV_ENABLED, None)
|
||||
os.environ.pop(ENV_DELAY, None)
|
||||
|
||||
|
||||
def ask(prompt, default="", seconds=None):
|
||||
"""Poser la question. Rendre `default` si rien n'arrive à temps.
|
||||
|
||||
Hors mode auto, c'est un `input()` ordinaire — sauf qu'une réponse vide
|
||||
vaut le défaut, comme partout ailleurs en ligne de commande. C'est ce
|
||||
qui permet d'écrire l'attendu dans la question : « (Y/n) » doit dire la
|
||||
vérité pour la personne qui appuie sur Entrée, pas seulement pour le
|
||||
compte à rebours.
|
||||
"""
|
||||
if not enabled():
|
||||
return input(prompt) or default
|
||||
import select
|
||||
|
||||
sys.stdout.write(prompt)
|
||||
sys.stdout.flush()
|
||||
try:
|
||||
ready, _, _ = select.select(
|
||||
[sys.stdin], [], [], delay() if seconds is None else seconds
|
||||
)
|
||||
except (OSError, ValueError):
|
||||
# stdin n'est pas sélectionnable : on ne devine pas, on demande.
|
||||
return input("") or default
|
||||
if ready:
|
||||
answer = sys.stdin.readline().rstrip("\n")
|
||||
# Une réponse VIDE vaut « prends le défaut » : c'est tout le propos
|
||||
# du mode auto. Un stdin fermé — exécution non interactive — est
|
||||
# lisible tout de suite et rend justement une ligne vide ; sans
|
||||
# ceci, le défaut ne servirait jamais là.
|
||||
return answer or default
|
||||
print(f" ⏱ → {default or '(default)'}")
|
||||
return default
|
||||
|
||||
|
||||
def make_ask(default, seconds=None):
|
||||
"""Un `ask` à un seul argument, pour les outils qui en injectent un.
|
||||
|
||||
Ils appellent `ask(question)` sans savoir ce qu'est un défaut : celui-ci
|
||||
appartient à l'outil, pas à l'appelant, et se fige donc ici.
|
||||
"""
|
||||
|
||||
def ask_one(prompt=""):
|
||||
return ask(prompt, default=default, seconds=seconds)
|
||||
|
||||
return ask_one
|
||||
|
|
@ -5242,6 +5242,50 @@ TRANSLATIONS = {
|
|||
"fr": "rien n'a été modifié",
|
||||
"en": "nothing was changed",
|
||||
},
|
||||
"Enter = 1": {
|
||||
"fr": "Entrée = 1",
|
||||
"en": "Enter = 1",
|
||||
},
|
||||
"Enter = all, n = none": {
|
||||
"fr": "Entrée = tous, n = aucun",
|
||||
"en": "Enter = all, n = none",
|
||||
},
|
||||
"Enter = delete, after saving them": {
|
||||
"fr": "Entrée = effacer, après les avoir sauvegardés",
|
||||
"en": "Enter = delete, after saving them",
|
||||
},
|
||||
"Enter = neutralize now, reversible": {
|
||||
"fr": "Entrée = neutraliser maintenant, réversible",
|
||||
"en": "Enter = neutralize now, reversible",
|
||||
},
|
||||
"Enter = reset them onto the module file": {
|
||||
"fr": "Entrée = les réinitialiser sur le fichier du module",
|
||||
"en": "Enter = reset them onto the module file",
|
||||
},
|
||||
"Enter upgrades all addons of the": {
|
||||
"fr": "Entrée met à jour tous les modules de la",
|
||||
"en": "Enter upgrades all addons of the",
|
||||
},
|
||||
"Open it at EVERY version bump, stop asking": {
|
||||
"fr": "L'ouvrir à CHAQUE palier de version, ne plus demander",
|
||||
"en": "Open it at EVERY version bump, stop asking",
|
||||
},
|
||||
"Testing every bump, as chosen earlier.": {
|
||||
"fr": "Test à chaque palier, comme choisi plus tôt.",
|
||||
"en": "Testing every bump, as chosen earlier.",
|
||||
},
|
||||
"numbers separated by commas, Enter = all, n =": {
|
||||
"fr": "numéros séparés par des virgules, Entrée = toutes, n =",
|
||||
"en": "numbers separated by commas, Enter = all, n =",
|
||||
},
|
||||
"decide at the version bump": {
|
||||
"fr": "décider au palier de version",
|
||||
"en": "decide at the version bump",
|
||||
},
|
||||
"keep": {
|
||||
"fr": "garder",
|
||||
"en": "keep",
|
||||
},
|
||||
"Clean the database before testing the pages?": {
|
||||
"fr": "Nettoyer la base avant de tester les pages ?",
|
||||
"en": "Clean the database before testing the pages?",
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import zipfile
|
|||
from uuid import uuid4
|
||||
|
||||
|
||||
from script.todo import todo_file_browser
|
||||
from script.todo import auto_ask, todo_file_browser
|
||||
from script.todo.version_manager import get_odoo_version
|
||||
|
||||
try:
|
||||
|
|
@ -781,6 +781,12 @@ class TodoUpgrade:
|
|||
.lower()
|
||||
)
|
||||
self.auto_execute = answer == "y"
|
||||
# Dans l'ENVIRONNEMENT, car c'est le seul canal qu'un sous-processus
|
||||
# partage avec nous : la moitié des invites d'une migration sont
|
||||
# posées par des outils lancés à part — désinstalleur de thème,
|
||||
# détecteur de SCSS figé — et sans cela elles attendraient une
|
||||
# frappe qui ne vient jamais.
|
||||
auto_ask.export(self.auto_execute, self.AUTO_DELAY)
|
||||
if self.auto_execute:
|
||||
print(
|
||||
f"⏱ {t('Auto-run on: each prompt waits')}"
|
||||
|
|
@ -788,34 +794,20 @@ class TodoUpgrade:
|
|||
)
|
||||
|
||||
def ask(self, prompt, default=""):
|
||||
"""Lire une réponse ; en mode auto, rendre le défaut après le délai.
|
||||
"""Lire une réponse ; rendre le défaut si rien n'arrive.
|
||||
|
||||
`select` plutôt qu'un fil ou une alarme : on veut savoir si quelque
|
||||
chose est LISIBLE, et rendre la main sinon. Un fil laisserait un
|
||||
`input()` bloqué derrière lui, qui volerait la frappe suivante.
|
||||
Le compte à rebours vit dans `auto_ask`, pas ici : les outils lancés
|
||||
en sous-processus posent EUX AUSSI des questions, et une deuxième
|
||||
implémentation aurait dérivé de celle-ci sans que rien ne le dise.
|
||||
|
||||
L'export à chaque appel n'est pas de la précaution : l'environnement
|
||||
est le SEUL canal qui traverse un `fork`. Sans lui, un outil lancé
|
||||
plus bas attendrait indéfiniment une frappe qui ne vient pas.
|
||||
"""
|
||||
if not getattr(self, "auto_execute", False):
|
||||
return input(prompt)
|
||||
import select
|
||||
auto_ask.export(getattr(self, "auto_execute", False), self.AUTO_DELAY)
|
||||
return auto_ask.ask(prompt, default=default, seconds=self.AUTO_DELAY)
|
||||
|
||||
sys.stdout.write(prompt)
|
||||
sys.stdout.flush()
|
||||
try:
|
||||
ready, _, _ = select.select([sys.stdin], [], [], self.AUTO_DELAY)
|
||||
except (OSError, ValueError):
|
||||
# stdin n'est pas sélectionnable : on ne devine pas, on demande.
|
||||
return input("")
|
||||
if ready:
|
||||
answer = sys.stdin.readline().rstrip("\n")
|
||||
# Une réponse VIDE vaut « prends le défaut » : c'est tout le
|
||||
# propos du mode auto. Un stdin fermé — exécution non
|
||||
# interactive — est lisible tout de suite et rend justement
|
||||
# une ligne vide ; sans ceci, le défaut ne servirait jamais là.
|
||||
return answer or default
|
||||
print(f" ⏱ → {default or t('(default)')}")
|
||||
return default
|
||||
|
||||
def ask_gate(self, prompt):
|
||||
def ask_gate(self, prompt, default=""):
|
||||
"""Une invite d'attente, avec une porte de sortie vers l'arrière.
|
||||
|
||||
Ces invites ne demandent qu'à continuer. Quand on s'aperçoit à ce
|
||||
|
|
@ -825,7 +817,7 @@ class TodoUpgrade:
|
|||
il rembobine l'état, l'écrit, et s'arrête en disant quoi relancer.
|
||||
"""
|
||||
while True:
|
||||
answer = self.ask(prompt)
|
||||
answer = self.ask(prompt, default=default)
|
||||
if (answer or "").strip().lower() != "b":
|
||||
return answer
|
||||
if self.rewind_to_chosen_step():
|
||||
|
|
@ -2126,7 +2118,9 @@ class TodoUpgrade:
|
|||
self.ask(
|
||||
f"💬 {t('List the missing modules to delete,')}"
|
||||
f" {t('separated by commas. The others will be')}"
|
||||
f" {t('migrated')} : "
|
||||
f" {t('migrated')}"
|
||||
f" ({t('Enter = all, n = none')}) : ",
|
||||
default="a",
|
||||
)
|
||||
.strip()
|
||||
.lower()
|
||||
|
|
@ -2134,6 +2128,11 @@ class TodoUpgrade:
|
|||
|
||||
is_delete_all = False
|
||||
|
||||
# « n » saute le bloc ENTIER, ajouts par version compris :
|
||||
# Entrée valant « toutes », il faut un mot pour dire non,
|
||||
# et il doit vraiment ne rien supprimer.
|
||||
if want_continue == "n":
|
||||
want_continue = ""
|
||||
if want_continue:
|
||||
lst_want_continue = [
|
||||
a.strip() for a in want_continue.split(",")
|
||||
|
|
@ -2618,8 +2617,9 @@ class TodoUpgrade:
|
|||
self.ask(
|
||||
f"💬 {t('Do you want to upgrade all')}"
|
||||
f"{str_wait_next_version} ?"
|
||||
f" {t('Press y/Y to upgrade all addons of the')}"
|
||||
f" {t('database')} : "
|
||||
f" (Y/n, {t('Enter upgrades all addons of the')}"
|
||||
f" {t('database')}) : ",
|
||||
default="y",
|
||||
)
|
||||
.strip()
|
||||
.lower()
|
||||
|
|
@ -2647,14 +2647,31 @@ class TodoUpgrade:
|
|||
self.prompt_smoke_public_url(database_name_upgrade)
|
||||
|
||||
print(f"[y] {t('Open the server with Selenium')}")
|
||||
status = (
|
||||
self.ask(
|
||||
f"💬 {t('Do you want to test this upgrade? Choose')}"
|
||||
f" {t('or press enter to ignore it')} : "
|
||||
print(f"[a] {t('Open it at EVERY version bump, stop asking')}")
|
||||
# Une migration traverse jusqu'à six paliers. Répondre « y »
|
||||
# à chacun oblige à rester devant ; « a » est la réponse
|
||||
# qu'on donnait de toute façon six fois de suite, dite une
|
||||
# seule fois — et retenue, donc valable après une reprise.
|
||||
if self.dct_progression.get("state_4_selenium_every_bump"):
|
||||
status = "y"
|
||||
print(
|
||||
f"ℹ -> {t('Testing every bump, as chosen earlier.')}"
|
||||
)
|
||||
.strip()
|
||||
.lower()
|
||||
)
|
||||
else:
|
||||
status = (
|
||||
self.ask(
|
||||
f"💬 {t('Do you want to test this upgrade? Choose')}"
|
||||
f" {t('or press enter to ignore it')} : "
|
||||
)
|
||||
.strip()
|
||||
.lower()
|
||||
)
|
||||
if status == "a":
|
||||
self.dct_progression["state_4_selenium_every_bump"] = (
|
||||
True
|
||||
)
|
||||
self.write_config()
|
||||
status = "y"
|
||||
"make repo_show_status"
|
||||
if status == "y":
|
||||
self.todo.prompt_execute_selenium_and_run_db(
|
||||
|
|
@ -2873,7 +2890,8 @@ class TodoUpgrade:
|
|||
answer = (
|
||||
self.ask_gate(
|
||||
f"💬 {t('Uninstall them properly before migrating?')}"
|
||||
f" (y/N, {t('(b = go back to a previous step)')}) : "
|
||||
f" (Y/n, {t('(b = go back to a previous step)')}) : ",
|
||||
default="y",
|
||||
)
|
||||
.strip()
|
||||
.lower()
|
||||
|
|
@ -2931,13 +2949,16 @@ class TodoUpgrade:
|
|||
answer = (
|
||||
self.ask_gate(
|
||||
f"💬 {t('Which one(s) to reset onto the module view?')}"
|
||||
f" ({t('numbers separated by commas, a = all, empty =')}"
|
||||
f" {t('nothing')}) : "
|
||||
f" ({t('numbers separated by commas, Enter = all, n =')}"
|
||||
f" {t('nothing')}) : ",
|
||||
default="a",
|
||||
)
|
||||
.strip()
|
||||
.lower()
|
||||
)
|
||||
if not answer:
|
||||
# « n », et non plus le vide : Entrée vaut « toutes » maintenant, et
|
||||
# une sortie sans mot pour dire non serait une sortie sans issue.
|
||||
if not answer or answer == "n":
|
||||
print(f"ℹ -> {t('Kept. Nothing was reset.')}")
|
||||
return
|
||||
if answer == "a":
|
||||
|
|
@ -2970,7 +2991,8 @@ class TodoUpgrade:
|
|||
answer = (
|
||||
self.ask_gate(
|
||||
f"💬 {t('Clean the database before testing the pages?')}"
|
||||
f" (y/N, {t('(b = go back to a previous step)')}) : "
|
||||
f" (Y/n, {t('(b = go back to a previous step)')}) : ",
|
||||
default="y",
|
||||
)
|
||||
.strip()
|
||||
.lower()
|
||||
|
|
@ -3007,7 +3029,8 @@ class TodoUpgrade:
|
|||
answer = (
|
||||
self.ask_gate(
|
||||
f"💬 {t('Request every public URL of this database now?')}"
|
||||
f" (y/N, {t('(b = go back to a previous step)')}) : "
|
||||
f" (Y/n, {t('(b = go back to a previous step)')}) : ",
|
||||
default="y",
|
||||
)
|
||||
.strip()
|
||||
.lower()
|
||||
|
|
@ -3071,14 +3094,18 @@ class TodoUpgrade:
|
|||
f" -d {database_name} -t odoo{next_version}.0"
|
||||
)
|
||||
while True:
|
||||
# `self.ask`, pas `input` : cette invite boucle, et un `input`
|
||||
# nu ici arrêtait net une migration automatique — sans rien
|
||||
# afficher, puisque la question attendait déjà.
|
||||
answer = (
|
||||
input(
|
||||
self.ask(
|
||||
f"💬 {t('What do you want to do with these COW copies?')}"
|
||||
f" ({t('Enter = decide at the version bump')},"
|
||||
f" ({t('Enter = neutralize now, reversible')},"
|
||||
f" v = {t('what each copy holds')},"
|
||||
f" s = {t('why it breaks')},"
|
||||
f" w = {t('full screen')},"
|
||||
f" a = {t('neutralize now, reversible')}) : "
|
||||
f" n = {t('decide at the version bump')}) : ",
|
||||
default="a",
|
||||
)
|
||||
.strip()
|
||||
.lower()
|
||||
|
|
@ -3131,10 +3158,11 @@ class TodoUpgrade:
|
|||
# rien dans l'avertissement ne permet de les distinguer.
|
||||
while True:
|
||||
answer = (
|
||||
input(
|
||||
self.ask(
|
||||
"💬 Neutralize these copies so the upgrade can proceed?"
|
||||
" Their arch is kept and the change is reversible."
|
||||
" (Y/n, v = view the differences, w = full screen) : "
|
||||
" (Y/n, v = view the differences, w = full screen) : ",
|
||||
default="y",
|
||||
)
|
||||
.strip()
|
||||
.lower()
|
||||
|
|
@ -3290,7 +3318,9 @@ class TodoUpgrade:
|
|||
)
|
||||
print(f" [2] {t('Try the whole list anyway (it will fail)')}")
|
||||
print(f" [3] {t('Uninstall nothing, continue')}")
|
||||
answer = input(f"💬 {t('Your choice')} : ").strip()
|
||||
answer = self.ask(
|
||||
f"💬 {t('Your choice')} ({t('Enter = 1')}) : ", default="1"
|
||||
).strip()
|
||||
if answer == "2":
|
||||
return lst_present + lst_missing
|
||||
if answer == "3" or not lst_present:
|
||||
|
|
@ -3483,8 +3513,11 @@ class TodoUpgrade:
|
|||
)
|
||||
print(f"[3] {t('Reset one of them onto its module view')}")
|
||||
print(f"[4] {t('Browse the differences full screen')}")
|
||||
# `self.ask` : une migration automatique s'arrêtait ICI,
|
||||
# sur une invite qui ne demande qu'à continuer, et restait
|
||||
# bloquée sans que rien ne le signale.
|
||||
wait_status = (
|
||||
input(
|
||||
self.ask(
|
||||
f"💬 {t('Error detected, press enter to continue or')}"
|
||||
f" ctrl+c {t('to stop')} : "
|
||||
)
|
||||
|
|
|
|||
|
|
@ -37,8 +37,36 @@ def upgrade(auto, delay=0.2):
|
|||
return obj
|
||||
|
||||
|
||||
class TestTheTimedRead(unittest.TestCase):
|
||||
class EnvCase(unittest.TestCase):
|
||||
"""Rendre l'environnement comme on l'a trouvé.
|
||||
|
||||
`ask` pose le mode auto dans l'environnement — c'est ainsi qu'il atteint
|
||||
les outils lancés à part. Le laisser posé le ferait fuir dans TOUTE la
|
||||
suite : une invite sans rapport prendrait son défaut au lieu d'attendre,
|
||||
et le test qui échouerait ne serait pas celui qui a fauté.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
from script.todo import auto_ask
|
||||
|
||||
avant = {
|
||||
key: os.environ.get(key)
|
||||
for key in (auto_ask.ENV_ENABLED, auto_ask.ENV_DELAY)
|
||||
}
|
||||
|
||||
def remettre():
|
||||
for key, value in avant.items():
|
||||
if value is None:
|
||||
os.environ.pop(key, None)
|
||||
else:
|
||||
os.environ[key] = value
|
||||
|
||||
self.addCleanup(remettre)
|
||||
|
||||
|
||||
class TestTheTimedRead(EnvCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.original = sys.stdin
|
||||
self.addCleanup(setattr, sys, "stdin", self.original)
|
||||
|
||||
|
|
@ -129,8 +157,9 @@ class TestTheTimedRead(unittest.TestCase):
|
|||
self.assertEqual(got, "demandé")
|
||||
|
||||
|
||||
class TestTheQuestionThatEnablesIt(unittest.TestCase):
|
||||
class TestTheQuestionThatEnablesIt(EnvCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.addCleanup(
|
||||
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
|
||||
)
|
||||
|
|
@ -166,6 +195,23 @@ class TestTheQuestionThatEnablesIt(unittest.TestCase):
|
|||
def test_the_delay_is_five_seconds(self):
|
||||
self.assertEqual(TodoUpgrade.AUTO_DELAY, 5)
|
||||
|
||||
def test_yes_also_arms_the_tools_launched_apart(self):
|
||||
# La moitié des invites d'une migration sont posées par d'autres
|
||||
# processus. Sans cette variable, ils attendraient une frappe qui
|
||||
# ne vient jamais — et l'automatisation s'arrêterait là, en
|
||||
# silence, puisque la question a bien été posée.
|
||||
from script.todo import auto_ask
|
||||
|
||||
self.answer("y")
|
||||
self.assertEqual(os.environ.get(auto_ask.ENV_ENABLED), "1")
|
||||
|
||||
def test_no_leaves_nothing_behind(self):
|
||||
from script.todo import auto_ask
|
||||
|
||||
os.environ[auto_ask.ENV_ENABLED] = "1"
|
||||
self.answer("")
|
||||
self.assertNotIn(auto_ask.ENV_ENABLED, os.environ)
|
||||
|
||||
|
||||
class TestWhereItIsAsked(unittest.TestCase):
|
||||
def source(self):
|
||||
|
|
|
|||
|
|
@ -577,8 +577,8 @@ class TestTheMigrationRunsItBeforeTheSmokeTest(unittest.TestCase):
|
|||
self.assertIn("run_on_terminal", source)
|
||||
self.assertNotIn("todo_upgrade_execute", source)
|
||||
|
||||
def test_the_default_cleans_nothing(self):
|
||||
# Cela ÉCRIT en base : ce n'est pas à la migration de le décider.
|
||||
def test_saying_no_cleans_nothing(self):
|
||||
# Le défaut ne retire pas le choix : il ne fait qu'en proposer un.
|
||||
import contextlib
|
||||
import io
|
||||
|
||||
|
|
@ -595,11 +595,32 @@ class TestTheMigrationRunsItBeforeTheSmokeTest(unittest.TestCase):
|
|||
upgrade.write_config = lambda: None
|
||||
lst_cmd = []
|
||||
upgrade.run_on_terminal = lambda cmd: lst_cmd.append(cmd) or 0
|
||||
upgrade.ask_gate = lambda prompt: ""
|
||||
upgrade.ask_gate = lambda prompt, default="": "n" or default
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
upgrade.prompt_database_cleanup("db")
|
||||
self.assertEqual(lst_cmd, [])
|
||||
|
||||
def test_the_default_cleans(self):
|
||||
# Entrée nettoie : le nettoyage précède les tests de fumée, et une
|
||||
# base encombrée de tables mortes fait échouer des pages pour une
|
||||
# raison qui n'a rien à voir avec la migration.
|
||||
import contextlib
|
||||
import io
|
||||
|
||||
from script.todo.todo_upgrade import TodoUpgrade
|
||||
|
||||
upgrade = TodoUpgrade.__new__(TodoUpgrade)
|
||||
upgrade.dct_progression = {}
|
||||
upgrade.lst_command_executed = []
|
||||
upgrade.write_config = lambda: None
|
||||
lst_cmd = []
|
||||
upgrade.run_on_terminal = lambda cmd: lst_cmd.append(cmd) or 0
|
||||
upgrade.ask_gate = lambda prompt, default="": "" or default
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
upgrade.prompt_database_cleanup("db")
|
||||
self.assertEqual(len(lst_cmd), 1)
|
||||
self.assertIn("database_cleanup.py", lst_cmd[0])
|
||||
|
||||
def test_yes_runs_it_on_that_database(self):
|
||||
from script.todo.todo_upgrade import TodoUpgrade
|
||||
|
||||
|
|
@ -609,7 +630,7 @@ class TestTheMigrationRunsItBeforeTheSmokeTest(unittest.TestCase):
|
|||
upgrade.write_config = lambda: None
|
||||
lst_cmd = []
|
||||
upgrade.run_on_terminal = lambda cmd: lst_cmd.append(cmd) or 0
|
||||
upgrade.ask_gate = lambda prompt: "y"
|
||||
upgrade.ask_gate = lambda prompt, default="": "y" or default
|
||||
upgrade.prompt_database_cleanup("db_upgrade_18")
|
||||
self.assertEqual(len(lst_cmd), 1)
|
||||
self.assertIn("database_cleanup.py", lst_cmd[0])
|
||||
|
|
|
|||
357
test/test_prompt_defaults.py
Normal file
357
test/test_prompt_defaults.py
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
#!/usr/bin/env python3
|
||||
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||
|
||||
"""Ce que fait Entrée, et ce que fait le silence.
|
||||
|
||||
Une migration pose une quarantaine de questions dont la réponse était
|
||||
presque toujours la même. Le mode auto promettait de les prendre « par
|
||||
défaut » — sauf que le défaut était VIDE partout : il ne faisait rien, et
|
||||
la moitié des invites vivent de toute façon dans des outils lancés à part,
|
||||
qui ne savaient rien du mode auto et attendaient une frappe pour toujours.
|
||||
|
||||
Deux exigences, donc, et elles sont indissociables :
|
||||
|
||||
- ce que fait Entrée doit être ÉCRIT dans la question. Une invite qui
|
||||
annonce « y/N » et désinstalle est pire que pas d'invite du tout ;
|
||||
- aucune invite du chemin de migration ne doit rester un `input()` nu. Ce
|
||||
n'est pas une faute qu'on commet exprès, c'est une faute qu'on commet
|
||||
par habitude — d'où le test qui refuse la construction elle-même.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import contextlib
|
||||
import io
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, os.path.join(REPO, "script", "odoo", "migration"))
|
||||
sys.path.insert(0, os.path.join(REPO, "script", "addons"))
|
||||
|
||||
from script.todo import auto_ask # noqa: E402
|
||||
from script.todo.todo_upgrade import TodoUpgrade # noqa: E402
|
||||
|
||||
|
||||
class EnvCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
avant = {
|
||||
key: os.environ.get(key)
|
||||
for key in (auto_ask.ENV_ENABLED, auto_ask.ENV_DELAY)
|
||||
}
|
||||
|
||||
def remettre():
|
||||
for key, value in avant.items():
|
||||
if value is None:
|
||||
os.environ.pop(key, None)
|
||||
else:
|
||||
os.environ[key] = value
|
||||
|
||||
self.addCleanup(remettre)
|
||||
auto_ask.export(False)
|
||||
|
||||
|
||||
class TestTheChannelThatCrossesAFork(EnvCase):
|
||||
"""L'environnement, parce que c'est le SEUL canal partagé.
|
||||
|
||||
Le désinstalleur de thème, le détecteur de SCSS figé et le test de
|
||||
fumée sont des processus séparés. Ils ne voient ni l'objet du pilote ni
|
||||
sa mémoire ; sans une variable d'environnement, ils attendraient une
|
||||
frappe qui ne vient jamais, et l'automatisation s'arrêterait là — sans
|
||||
message, puisque la question, elle, a bien été posée.
|
||||
"""
|
||||
|
||||
def test_export_puts_it_where_a_child_will_read_it(self):
|
||||
auto_ask.export(True, 3)
|
||||
self.assertEqual(os.environ[auto_ask.ENV_ENABLED], "1")
|
||||
self.assertEqual(os.environ[auto_ask.ENV_DELAY], "3")
|
||||
self.assertTrue(auto_ask.enabled())
|
||||
|
||||
def test_turning_it_off_REMOVES_it(self):
|
||||
# Y écrire « 0 » laisserait un reste de session précédente décider à
|
||||
# notre place : ce qui n'existe pas ne peut pas se tromper.
|
||||
auto_ask.export(True, 3)
|
||||
auto_ask.export(False)
|
||||
self.assertNotIn(auto_ask.ENV_ENABLED, os.environ)
|
||||
self.assertFalse(auto_ask.enabled())
|
||||
|
||||
def test_a_real_child_process_takes_the_default(self):
|
||||
# LE test qui compte : un vrai sous-processus, un vrai stdin fermé.
|
||||
code = (
|
||||
"import sys; sys.path.insert(0, %r);"
|
||||
"from script.todo import auto_ask;"
|
||||
"print(auto_ask.ask('q : ', default='d'))" % REPO
|
||||
)
|
||||
env = dict(os.environ)
|
||||
env[auto_ask.ENV_ENABLED] = "1"
|
||||
env[auto_ask.ENV_DELAY] = "1"
|
||||
done = subprocess.run(
|
||||
[sys.executable, "-c", code],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
stdin=subprocess.DEVNULL,
|
||||
timeout=30,
|
||||
cwd=REPO,
|
||||
env=env,
|
||||
)
|
||||
self.assertEqual(done.returncode, 0, done.stderr)
|
||||
self.assertIn("d", done.stdout.strip().splitlines()[-1])
|
||||
|
||||
def test_a_child_without_the_flag_still_asks(self):
|
||||
# Hors mode auto, on ne décide à la place de personne : le tuyau
|
||||
# fermé rend une ligne vide, et le défaut n'arrive que là.
|
||||
code = (
|
||||
"import sys; sys.path.insert(0, %r);"
|
||||
"from script.todo import auto_ask;"
|
||||
"print('RÉPONSE=' + auto_ask.ask('q : ', default='d'))" % REPO
|
||||
)
|
||||
done = subprocess.run(
|
||||
[sys.executable, "-c", code],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
input="k\n",
|
||||
timeout=30,
|
||||
cwd=REPO,
|
||||
)
|
||||
self.assertIn("RÉPONSE=k", done.stdout)
|
||||
|
||||
def test_a_broken_delay_does_not_freeze_anything(self):
|
||||
os.environ[auto_ask.ENV_DELAY] = "plus tard"
|
||||
self.assertEqual(auto_ask.delay(), auto_ask.DEFAULT_DELAY)
|
||||
|
||||
def test_a_zero_delay_is_refused(self):
|
||||
# Sans fenêtre, on ne peut pas reprendre la main : « auto » ne veut
|
||||
# pas dire « sans recours ».
|
||||
os.environ[auto_ask.ENV_DELAY] = "0"
|
||||
self.assertEqual(auto_ask.delay(), auto_ask.DEFAULT_DELAY)
|
||||
|
||||
|
||||
class TestEnterMeansTheDefault(EnvCase):
|
||||
"""Hors mode auto AUSSI : sinon la question mentirait à qui la lit."""
|
||||
|
||||
def test_an_empty_answer_takes_the_default(self):
|
||||
import builtins
|
||||
|
||||
original = builtins.input
|
||||
builtins.input = lambda prompt="": ""
|
||||
self.addCleanup(setattr, builtins, "input", original)
|
||||
self.assertEqual(auto_ask.ask("q : ", default="y"), "y")
|
||||
|
||||
def test_a_typed_answer_wins(self):
|
||||
import builtins
|
||||
|
||||
original = builtins.input
|
||||
builtins.input = lambda prompt="": "n"
|
||||
self.addCleanup(setattr, builtins, "input", original)
|
||||
self.assertEqual(auto_ask.ask("q : ", default="y"), "n")
|
||||
|
||||
def test_the_driver_exports_it_on_every_ask(self):
|
||||
# Un outil lancé plus bas doit voir le mode auto même si la question
|
||||
# d'activation date d'une reprise précédente.
|
||||
import builtins
|
||||
|
||||
original = builtins.input
|
||||
builtins.input = lambda prompt="": ""
|
||||
self.addCleanup(setattr, builtins, "input", original)
|
||||
obj = TodoUpgrade.__new__(TodoUpgrade)
|
||||
obj.auto_execute = True
|
||||
obj.AUTO_DELAY = 0.2
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
obj.ask("q : ", default="y")
|
||||
self.assertEqual(os.environ.get(auto_ask.ENV_ENABLED), "1")
|
||||
|
||||
|
||||
class TestNoPromptOfTheMigrationCanHang(unittest.TestCase):
|
||||
"""Un `input()` nu ne sait rien du mode auto : il attend, pour toujours.
|
||||
|
||||
Vécu à l'échelle du fichier : deux invites avaient échappé au premier
|
||||
passage — la prédiction COW et le choix de désinstallation — parce que
|
||||
le garde-fou ne regardait qu'`execute_odoo_upgrade`. On regarde
|
||||
désormais TOUTES les méthodes du chemin de migration.
|
||||
"""
|
||||
|
||||
METHODES = (
|
||||
"execute_odoo_upgrade",
|
||||
"prompt_cow_prediction",
|
||||
"prompt_uninstall_theme",
|
||||
"prompt_reset_stale_cow_views",
|
||||
"prompt_database_cleanup",
|
||||
"prompt_smoke_public_url",
|
||||
"split_present_missing",
|
||||
"prompt_uninstall_missing",
|
||||
"todo_upgrade_execute",
|
||||
)
|
||||
|
||||
def bare_inputs(self, nom):
|
||||
chemin = os.path.join(REPO, "script", "todo", "todo_upgrade.py")
|
||||
with open(chemin) as handle:
|
||||
tree = ast.parse(handle.read())
|
||||
cible = [
|
||||
node
|
||||
for node in ast.walk(tree)
|
||||
if isinstance(node, ast.FunctionDef) and node.name == nom
|
||||
]
|
||||
self.assertEqual(len(cible), 1, f"méthode introuvable : {nom}")
|
||||
return [
|
||||
node.lineno
|
||||
for node in ast.walk(cible[0])
|
||||
if isinstance(node, ast.Call)
|
||||
and getattr(node.func, "id", "") == "input"
|
||||
]
|
||||
|
||||
def test_none_of_them_asks_outside_the_timer(self):
|
||||
coupables = {
|
||||
nom: self.bare_inputs(nom)
|
||||
for nom in self.METHODES
|
||||
if self.bare_inputs(nom)
|
||||
}
|
||||
self.assertEqual(coupables, {}, "invites hors du mode auto")
|
||||
|
||||
|
||||
class TestWhatEnterDoesIsWritten(unittest.TestCase):
|
||||
"""La question doit dire la vérité à qui appuie sur Entrée."""
|
||||
|
||||
def source(self, nom):
|
||||
import inspect
|
||||
|
||||
return inspect.getsource(getattr(TodoUpgrade, nom))
|
||||
|
||||
def test_the_theme_prompt_announces_Y(self):
|
||||
source = self.source("prompt_uninstall_theme")
|
||||
self.assertIn("(Y/n,", source)
|
||||
self.assertIn('default="y"', source)
|
||||
|
||||
def test_the_cleanup_prompt_announces_Y(self):
|
||||
source = self.source("prompt_database_cleanup")
|
||||
self.assertIn("(Y/n,", source)
|
||||
self.assertIn('default="y"', source)
|
||||
|
||||
def test_the_smoke_prompt_announces_Y(self):
|
||||
source = self.source("prompt_smoke_public_url")
|
||||
self.assertIn("(Y/n,", source)
|
||||
self.assertIn('default="y"', source)
|
||||
|
||||
def test_the_reset_prompt_says_enter_is_all(self):
|
||||
source = self.source("prompt_reset_stale_cow_views")
|
||||
self.assertIn("Enter = all", source)
|
||||
self.assertIn('default="a"', source)
|
||||
|
||||
def test_the_cow_prediction_says_enter_neutralizes(self):
|
||||
source = self.source("prompt_cow_prediction")
|
||||
self.assertIn("Enter = neutralize now", source)
|
||||
self.assertIn('default="a"', source)
|
||||
|
||||
def test_the_uninstall_strategy_defaults_to_one(self):
|
||||
source = self.source("prompt_uninstall_missing")
|
||||
self.assertIn("Enter = 1", source)
|
||||
self.assertIn('default="1"', source)
|
||||
|
||||
def test_every_default_has_a_way_to_say_no(self):
|
||||
# Un défaut qui agit sans issue n'est plus un défaut, c'est un ordre.
|
||||
for nom, refus in (
|
||||
("prompt_reset_stale_cow_views", '== "n"'),
|
||||
("prompt_uninstall_missing", '== "3"'),
|
||||
("prompt_cow_prediction", "n = "),
|
||||
):
|
||||
self.assertIn(refus, self.source(nom), nom)
|
||||
|
||||
|
||||
class TestTheToolsLaunchedApart(unittest.TestCase):
|
||||
def test_the_leftovers_default_to_deleting(self):
|
||||
import theme_leftover
|
||||
|
||||
self.assertEqual(theme_leftover.DEFAULT_ANSWER, "d")
|
||||
|
||||
def test_the_leftovers_are_saved_BEFORE_being_deleted(self):
|
||||
# C'est ce qui rend ce défaut tenable. Sans la sauvegarde, il aurait
|
||||
# dû rester « garder » : on ne fait pas d'une décision irréversible
|
||||
# la réponse qu'on obtient en ne répondant pas.
|
||||
import inspect
|
||||
|
||||
import theme_leftover
|
||||
|
||||
source = inspect.getsource(theme_leftover.prompt)
|
||||
self.assertLess(
|
||||
source.index("backup_attachments"),
|
||||
source.index("delete_attachments"),
|
||||
)
|
||||
|
||||
def test_the_leftovers_prompt_says_enter_deletes(self):
|
||||
import inspect
|
||||
|
||||
import theme_leftover
|
||||
|
||||
source = inspect.getsource(theme_leftover.prompt)
|
||||
self.assertIn("Enter = delete", source)
|
||||
self.assertIn("k = ", source)
|
||||
|
||||
def test_the_smoke_reset_defaults_to_all(self):
|
||||
import smoke_public_url
|
||||
|
||||
self.assertEqual(smoke_public_url.DEFAULT_ANSWER, "a")
|
||||
|
||||
def test_the_scss_default_follows_what_the_checkout_can_do(self):
|
||||
# « a » là où `reset_asset` n'existe pas (avant la 13.0) ferait
|
||||
# boucler l'invite sur elle-même : le défaut suit la capacité.
|
||||
import inspect
|
||||
|
||||
import check_stale_scss
|
||||
|
||||
source = inspect.getsource(check_stale_scss.prompt)
|
||||
self.assertIn('defaut = "a" if can_reset else "n"', source)
|
||||
|
||||
def test_each_of_them_can_run_without_a_driver(self):
|
||||
# Le repli compte : ces outils se lancent aussi à la main, et une
|
||||
# ImportError les rendrait inutilisables hors migration.
|
||||
import check_stale_scss
|
||||
import smoke_public_url
|
||||
import theme_leftover
|
||||
|
||||
for module in (theme_leftover, smoke_public_url, check_stale_scss):
|
||||
import inspect
|
||||
|
||||
self.assertIn(
|
||||
"auto_ask = None",
|
||||
inspect.getsource(module),
|
||||
module.__name__,
|
||||
)
|
||||
|
||||
|
||||
class TestTestingEveryBump(unittest.TestCase):
|
||||
"""« a » dit une fois ce qu'on répétait six fois."""
|
||||
|
||||
def source(self):
|
||||
import inspect
|
||||
|
||||
return inspect.getsource(TodoUpgrade.execute_odoo_upgrade)
|
||||
|
||||
def test_the_option_is_offered(self):
|
||||
self.assertIn("Open it at EVERY version bump", self.source())
|
||||
|
||||
def test_it_is_remembered_in_the_progression(self):
|
||||
# Sinon une reprise après interruption reposerait la question, et
|
||||
# l'automatisation s'arrêterait au premier palier suivant.
|
||||
source = self.source()
|
||||
self.assertIn("state_4_selenium_every_bump", source)
|
||||
self.assertIn("self.write_config()", source)
|
||||
|
||||
def test_the_memory_stops_the_question(self):
|
||||
source = self.source()
|
||||
debut = source.index("state_4_selenium_every_bump")
|
||||
fenetre = source[debut : debut + 400]
|
||||
self.assertIn('status = "y"', fenetre)
|
||||
|
||||
def test_it_belongs_to_step_four(self):
|
||||
# Le nom porte sa propriété : un retour avant l'étape 4 doit
|
||||
# l'effacer, comme tout ce que l'étape 4 a décidé.
|
||||
from script.todo.todo_upgrade import STEP_PREFIX_RE
|
||||
|
||||
match = STEP_PREFIX_RE.match("state_4_selenium_every_bump")
|
||||
self.assertIsNotNone(match)
|
||||
self.assertEqual(match.group(1), "4")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -45,7 +45,9 @@ class PromptCase(unittest.TestCase):
|
|||
upgrade.stale_cow_keys = lambda db: lst_key
|
||||
lst_cmd = []
|
||||
upgrade.run_on_terminal = lambda cmd: lst_cmd.append(cmd) or 0
|
||||
upgrade.ask_gate = lambda prompt: answer
|
||||
# Le doublon HONORE le défaut, comme le vrai `ask_gate` : sinon
|
||||
# les tests de défaut ne testeraient que le doublon.
|
||||
upgrade.ask_gate = lambda prompt, default="": answer or default
|
||||
out = io.StringIO()
|
||||
with contextlib.redirect_stdout(out):
|
||||
upgrade.prompt_reset_stale_cow_views("db")
|
||||
|
|
@ -88,10 +90,17 @@ class TestWhatEachAnswerRuns(PromptCase):
|
|||
lst_cmd, _ = self.run_prompt("a")
|
||||
self.assertIn("--reset all", lst_cmd[0])
|
||||
|
||||
def test_empty_resets_nothing(self):
|
||||
# Par défaut on ne touche à rien : une copie porte parfois une vraie
|
||||
# personnalisation, et la réinitialiser la jette.
|
||||
lst_cmd, text = self.run_prompt("")
|
||||
def test_enter_resets_them_all(self):
|
||||
# Entrée les prend TOUTES : elles ne sont dans cette liste que
|
||||
# parce qu'un enfant n'y trouve plus son ancrage, et l'outil sauve
|
||||
# ce que chaque copie portait avant de la réinitialiser.
|
||||
lst_cmd, _text = self.run_prompt("")
|
||||
self.assertEqual(len(lst_cmd), 1)
|
||||
self.assertIn("--reset all", lst_cmd[0])
|
||||
|
||||
def test_n_resets_nothing(self):
|
||||
# Le défaut ne retire pas le choix : il ne fait qu'en proposer un.
|
||||
lst_cmd, text = self.run_prompt("n")
|
||||
self.assertEqual(lst_cmd, [])
|
||||
self.assertIn("Kept", text)
|
||||
|
||||
|
|
|
|||
|
|
@ -414,14 +414,21 @@ class TestTheMigrationOffersIt(unittest.TestCase):
|
|||
upgrade.write_config = lambda: None
|
||||
lst_cmd = []
|
||||
upgrade.run_on_terminal = lambda cmd: lst_cmd.append(cmd) or 0
|
||||
upgrade.ask_gate = lambda prompt: answer
|
||||
# Le doublon HONORE le défaut, comme le vrai `ask_gate`.
|
||||
upgrade.ask_gate = lambda prompt, default="": answer or default
|
||||
upgrade.prompt_smoke_public_url("db_upgrade_13")
|
||||
return lst_cmd
|
||||
|
||||
def test_the_default_runs_nothing(self):
|
||||
# Cela démarre un serveur et peut durer : pas à chaque palier sans
|
||||
# qu'on l'ait demandé.
|
||||
self.assertEqual(self.run_prompt(""), [])
|
||||
def test_the_default_runs_it(self):
|
||||
# Une page cassée qu'on ne demande pas reste cassée. Le coût est
|
||||
# quelques minutes de serveur ; le prix de l'ignorer est de
|
||||
# découvrir le 500 six paliers plus loin.
|
||||
lst_cmd = self.run_prompt("")
|
||||
self.assertEqual(len(lst_cmd), 1)
|
||||
self.assertIn("smoke_public_url.py", lst_cmd[0])
|
||||
|
||||
def test_saying_no_still_skips_it(self):
|
||||
self.assertEqual(self.run_prompt("n"), [])
|
||||
|
||||
def test_yes_runs_it_on_the_upgraded_database(self):
|
||||
lst_cmd = self.run_prompt("y")
|
||||
|
|
@ -476,7 +483,7 @@ class TestTheMigrationOffersIt(unittest.TestCase):
|
|||
upgrade.lst_command_executed = []
|
||||
upgrade.write_config = lambda: None
|
||||
upgrade.run_on_terminal = lambda cmd: 0
|
||||
upgrade.ask_gate = lambda prompt: ""
|
||||
upgrade.ask_gate = lambda prompt, default="": "n"
|
||||
out = io.StringIO()
|
||||
with contextlib.redirect_stdout(out):
|
||||
upgrade.prompt_smoke_public_url("db", baseline=True)
|
||||
|
|
|
|||
|
|
@ -60,24 +60,33 @@ class PromptCase(unittest.TestCase):
|
|||
|
||||
|
||||
class TestWhatEachAnswerRuns(PromptCase):
|
||||
def test_enter_runs_nothing_and_says_so(self):
|
||||
lst_cmd, text = self.run_prompt([""])
|
||||
def test_enter_neutralizes_now(self):
|
||||
# Entrée neutralise : c'est réversible — l'arch est conservée — et
|
||||
# ce qu'on neutralise ici vaut pour TOUS les paliers, au lieu
|
||||
# d'attendre des dizaines de minutes pour redécider à chaque fois.
|
||||
lst_cmd, _text = self.run_prompt([""])
|
||||
self.assertEqual(len(lst_cmd), 1)
|
||||
self.assertIn("--apply", lst_cmd[0])
|
||||
|
||||
def test_n_runs_nothing_and_says_so(self):
|
||||
# Le défaut ne retire pas le choix : il ne fait qu'en proposer un.
|
||||
lst_cmd, text = self.run_prompt(["n"])
|
||||
self.assertEqual(lst_cmd, [])
|
||||
self.assertIn("version bump", text)
|
||||
|
||||
def test_v_shows_what_the_copies_hold(self):
|
||||
lst_cmd, _ = self.run_prompt(["v", ""])
|
||||
lst_cmd, _ = self.run_prompt(["v", "n"])
|
||||
self.assertEqual(len(lst_cmd), 1)
|
||||
self.assertIn("cow_drift.py", lst_cmd[0])
|
||||
self.assertIn("-d db -t odoo13.0", lst_cmd[0])
|
||||
self.assertNotIn("--shape", lst_cmd[0])
|
||||
|
||||
def test_s_shows_why_it_breaks(self):
|
||||
lst_cmd, _ = self.run_prompt(["s", ""])
|
||||
lst_cmd, _ = self.run_prompt(["s", "n"])
|
||||
self.assertTrue(lst_cmd[0].endswith("--shape"))
|
||||
|
||||
def test_w_opens_the_full_screen_view(self):
|
||||
lst_cmd, _ = self.run_prompt(["w", ""])
|
||||
lst_cmd, _ = self.run_prompt(["w", "n"])
|
||||
self.assertTrue(lst_cmd[0].endswith("--tui"))
|
||||
|
||||
def test_looking_does_not_answer_the_question(self):
|
||||
|
|
|
|||
|
|
@ -157,7 +157,9 @@ class TestTheMigrationOffersIt(unittest.TestCase):
|
|||
# Le désinstalleur ne passe PLUS par l'exécuteur qui capture : il
|
||||
# pose une question, et un tube la rendrait invisible.
|
||||
obj.run_on_terminal = lambda cmd: self.lst_cmd.append(cmd) or 0
|
||||
obj.ask_gate = lambda prompt: answer
|
||||
# Le doublon HONORE le défaut, comme le vrai `ask_gate` : sinon
|
||||
# les tests de défaut ne testeraient que le doublon.
|
||||
obj.ask_gate = lambda prompt, default="": answer or default
|
||||
return obj
|
||||
|
||||
def run_prompt(self, lst_theme, answer):
|
||||
|
|
@ -170,13 +172,32 @@ class TestTheMigrationOffersIt(unittest.TestCase):
|
|||
obj.prompt_uninstall_theme("db")
|
||||
return self.lst_cmd, out.getvalue()
|
||||
|
||||
def test_the_default_answer_uninstalls_nothing(self):
|
||||
# « Entrée » ne doit RIEN faire : la migration ne décide pas de
|
||||
# l'apparence d'un site à la place de son propriétaire.
|
||||
lst_cmd, text = self.run_prompt(["theme_technolibre"], "")
|
||||
def test_the_default_answer_uninstalls_them(self):
|
||||
# « Entrée » désinstalle : c'est la réponse qu'on donnait à chaque
|
||||
# migration, et un thème traversé sans être retiré est justement ce
|
||||
# qui casse au palier suivant.
|
||||
lst_cmd, _text = self.run_prompt(["theme_technolibre"], "")
|
||||
self.assertEqual(len(lst_cmd), 1)
|
||||
self.assertIn("uninstall_addons_theme.sh", lst_cmd[0])
|
||||
|
||||
def test_saying_no_still_keeps_them(self):
|
||||
# Le défaut ne doit pas retirer le choix : il ne fait qu'en proposer
|
||||
# un. Sans cette issue, la question ne serait plus une question.
|
||||
lst_cmd, text = self.run_prompt(["theme_technolibre"], "n")
|
||||
self.assertEqual(lst_cmd, [])
|
||||
self.assertIn("Kept", text)
|
||||
|
||||
def test_the_question_says_that_enter_uninstalls(self):
|
||||
# Une invite qui annonce « y/N » et fait l'inverse est pire que pas
|
||||
# d'invite du tout.
|
||||
import inspect
|
||||
|
||||
from script.todo.todo_upgrade import TodoUpgrade
|
||||
|
||||
source = inspect.getsource(TodoUpgrade.prompt_uninstall_theme)
|
||||
self.assertIn("(Y/n,", source)
|
||||
self.assertNotIn("(y/N,", source)
|
||||
|
||||
def test_no_theme_means_no_question(self):
|
||||
lst_cmd, text = self.run_prompt([], "y")
|
||||
self.assertEqual(lst_cmd, [])
|
||||
|
|
|
|||
Loading…
Reference in a new issue