[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
2026-08-17 20:47:17 -04:00
|
|
|
#!/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")
|
|
|
|
|
|
|
|
|
|
|
[FIX] migration: the countdown names the answer the prompt offered
« ⏱ → d » named a reply that was not on the menu. The prompt reads
« Enter = delete, after saving them, k = keep » and mentions « d »
nowhere, so the line asking to be trusted with a decision described it in
a vocabulary the reader had never been given.
The decision itself was right — Enter deletes, and that is what happened.
Only its report was written from the inside. It now says Enter, in the
migration's language, with the value in brackets for whoever wants the
precision. What the caller receives is unchanged.
--- FR ---
[FIX] migration : le compte à rebours nomme la réponse que l'invite offrait
« ⏱ → d » nommait une réponse qui n'était pas au menu. L'invite annonce
« Entrée = effacer, après les avoir sauvegardés, k = garder » et ne
mentionne « d » nulle part : la ligne qui demandait qu'on lui confie une
décision la décrivait donc dans un vocabulaire jamais montré.
La décision, elle, était juste — Entrée efface, et c'est ce qui a eu lieu.
Seul son compte rendu était écrit du dedans. Il dit maintenant Entrée,
dans la langue de la migration, la valeur entre parenthèses pour qui veut
la précision. Ce que l'appelant reçoit ne change pas.
Assisted-by: Claude Opus 5
2026-08-19 05:16:48 -04:00
|
|
|
class TestTheCountdownSpeaksThePromptsLanguage(EnvCase):
|
|
|
|
|
"""« ⏱ → d » nommait une réponse qui n'était PAS au menu.
|
|
|
|
|
|
|
|
|
|
L'invite annonce « Entrée = effacer, k = garder » : elle ne mentionne
|
|
|
|
|
« d » nulle part. Afficher la valeur brute obligeait donc à deviner ce
|
|
|
|
|
que l'outil venait de décider — alors que la décision était exactement
|
|
|
|
|
celle qu'Entrée promettait.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def setUp(self):
|
|
|
|
|
super().setUp()
|
|
|
|
|
from script.todo import todo_i18n
|
|
|
|
|
|
|
|
|
|
self.addCleanup(
|
|
|
|
|
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
|
|
|
|
|
)
|
|
|
|
|
todo_i18n._current_lang = "en"
|
|
|
|
|
|
|
|
|
|
def tick(self, default):
|
|
|
|
|
"""Forcer le chemin du DÉLAI, celui qui affiche la ligne."""
|
|
|
|
|
import select
|
|
|
|
|
|
|
|
|
|
original = select.select
|
|
|
|
|
select.select = lambda r, w, x, t: ([], [], [])
|
|
|
|
|
self.addCleanup(setattr, select, "select", original)
|
|
|
|
|
auto_ask.export(True, 0.01)
|
|
|
|
|
out = io.StringIO()
|
|
|
|
|
with contextlib.redirect_stdout(out):
|
|
|
|
|
valeur = auto_ask.ask("q : ", default=default)
|
|
|
|
|
return valeur, out.getvalue()
|
|
|
|
|
|
|
|
|
|
def test_it_names_what_the_prompt_offered(self):
|
|
|
|
|
_valeur, texte = self.tick("d")
|
|
|
|
|
self.assertIn("Enter", texte)
|
|
|
|
|
|
|
|
|
|
def test_the_raw_value_stays_for_precision(self):
|
|
|
|
|
# La nommer reste utile : deux invites voisines peuvent avoir des
|
|
|
|
|
# défauts différents, et l'on relit ces lignes après coup.
|
|
|
|
|
_valeur, texte = self.tick("d")
|
|
|
|
|
self.assertIn("(d)", texte)
|
|
|
|
|
|
|
|
|
|
def test_an_empty_default_shows_no_parentheses(self):
|
|
|
|
|
# « Entrée () » n'aurait aucun sens : il n'y a rien à préciser.
|
|
|
|
|
_valeur, texte = self.tick("")
|
|
|
|
|
self.assertIn("Enter", texte)
|
|
|
|
|
self.assertNotIn("(", texte)
|
|
|
|
|
|
|
|
|
|
def test_the_answer_itself_is_unchanged(self):
|
|
|
|
|
# L'affichage seul change : ce que la migration reçoit reste la
|
|
|
|
|
# valeur, sinon toutes les invites changeraient de comportement.
|
|
|
|
|
valeur, _texte = self.tick("d")
|
|
|
|
|
self.assertEqual(valeur, "d")
|
|
|
|
|
|
|
|
|
|
def test_it_follows_the_language_of_the_migration(self):
|
|
|
|
|
from script.todo import todo_i18n
|
|
|
|
|
|
|
|
|
|
todo_i18n._current_lang = "fr"
|
|
|
|
|
_valeur, texte = self.tick("d")
|
|
|
|
|
self.assertIn("Entrée", texte)
|
|
|
|
|
|
|
|
|
|
|
2026-08-27 06:26:26 -04:00
|
|
|
class TestHowLongTheCountdownLasts(EnvCase):
|
|
|
|
|
"""Cinq secondes ne suffisaient pas à LIRE la question.
|
|
|
|
|
|
|
|
|
|
Le compte à rebours n'existe pas pour aller vite : il existe pour
|
|
|
|
|
qu'on puisse s'absenter. Trop court, il fait l'inverse — on répond par
|
|
|
|
|
réflexe, ou l'on subit un défaut qu'on n'a pas eu le temps de lire.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def test_the_default_leaves_time_to_read_and_decide(self):
|
|
|
|
|
self.assertGreaterEqual(auto_ask.DEFAULT_DELAY, 15)
|
|
|
|
|
|
|
|
|
|
def test_the_environment_still_wins(self):
|
|
|
|
|
# C'est le seul canal qui traverse un fork : une valeur posée par
|
|
|
|
|
# le pilote doit primer sur celle écrite ici.
|
|
|
|
|
os.environ[auto_ask.ENV_DELAY] = "3"
|
|
|
|
|
self.assertEqual(3, auto_ask.delay())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestTheDatabaseNameTheFileSuggests(unittest.TestCase):
|
|
|
|
|
"""« test » ne disait rien de ce qu'on migrait.
|
|
|
|
|
|
|
|
|
|
Trois migrations de suite portaient le même nom, et retrouver
|
|
|
|
|
laquelle avait échoué demandait de relire le journal. Le fichier de
|
|
|
|
|
sauvegarde porte déjà le nom du client — c'est la seule information
|
|
|
|
|
disponible au moment où la question se pose.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def nom(self, chemin, **kw):
|
|
|
|
|
from script.todo.todo_upgrade import database_name_from_file
|
|
|
|
|
|
|
|
|
|
return database_name_from_file(chemin, **kw)
|
|
|
|
|
|
|
|
|
|
def test_the_zip_extension_goes_away(self):
|
[FIX] conventions : retirer un nom d'organisation des exemples
Un nom d'organisation servait d'exemple dans une docstring et dans quatre
tests — nom de base, chemin de compte, préfixe de copie. La règle du dépôt
l'interdit partout hors de `private/`, et ces fichiers suivent le dépôt en
amont : sur un fork rendu public, l'exemple le devient aussi.
Un test le fige pour toujours, et trois des cinq sites servaient justement à
démontrer qu'un chemin de compte ou un nom est refusé. Une valeur inventée
démontre aussi bien, et celle-ci ne paraît nulle part ailleurs. La docstring
perd son exemple : il n'illustrait que « long », et la phrase le dit
maintenant. Le détecteur du dépôt ne voit pas cette classe — il n'annonçait
rien ici.
--- EN ---
An organisation's name served as an example in one docstring and in four
tests — a database name, an account path, a copy prefix. The repository's rule
forbids it anywhere outside `private/`, and these files follow the repository
upstream: on a fork made public, the example becomes public too.
A test freezes it forever, and three of the five sites existed precisely to
demonstrate that an account path or a name is refused. An invented value
demonstrates just as well, and this one appears nowhere else. The docstring
loses its example: it only illustrated "long", which the sentence now says.
The repository's detector does not see this class — it reported nothing here.
Assisted-by: Claude Opus 5
2026-09-09 04:34:46 -04:00
|
|
|
self.assertEqual("garance3", self.nom("image_db/garance3.zip"))
|
2026-08-27 06:26:26 -04:00
|
|
|
|
|
|
|
|
def test_an_uppercase_extension_goes_away_too(self):
|
|
|
|
|
self.assertEqual("client", self.nom("image_db/CLIENT.ZIP"))
|
|
|
|
|
|
|
|
|
|
def test_a_real_backup_name_survives_whole(self):
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
"technolibre_2026_08_26_02h41m23s",
|
|
|
|
|
self.nom("image_db/technolibre_2026-08-26_02h41m23s.zip"),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_a_file_without_extension_still_names_a_base(self):
|
|
|
|
|
# Une sauvegarde peut être posée sans « .zip » ; elle s'ouvre
|
|
|
|
|
# quand même comme une archive.
|
|
|
|
|
self.assertEqual("technolibre", self.nom("image_db/technolibre"))
|
|
|
|
|
|
|
|
|
|
def test_spaces_and_punctuation_become_underscores(self):
|
|
|
|
|
# Le nom finit dans un createdb et dans des noms de fichiers.
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
"client_final_2026", self.nom("image_db/Client Final (2026).zip")
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_it_never_starts_with_a_digit(self):
|
|
|
|
|
# PostgreSQL refuse un identifiant nu qui commence par un chiffre.
|
|
|
|
|
nom = self.nom("image_db/2026-backup.zip")
|
|
|
|
|
self.assertFalse(nom[0].isdigit(), nom)
|
|
|
|
|
|
|
|
|
|
def test_nothing_usable_falls_back_to_the_default(self):
|
|
|
|
|
for chemin in ("", None, "image_db/.zip", "image_db/---.zip"):
|
|
|
|
|
self.assertEqual("test", self.nom(chemin))
|
|
|
|
|
|
|
|
|
|
def test_a_long_name_leaves_room_for_the_suffixes(self):
|
|
|
|
|
# Le pilote ajoute « _neutralize » puis « _upgrade_<version> ».
|
|
|
|
|
# PostgreSQL tronque à 63 octets : sans marge, deux paliers
|
|
|
|
|
# finiraient sur le MÊME nom et le second écraserait le premier.
|
|
|
|
|
from script.todo.todo_upgrade import database_name_from_file
|
|
|
|
|
|
|
|
|
|
nom = database_name_from_file("image_db/" + "a" * 90 + ".zip")
|
|
|
|
|
complet = nom + "_neutralize_upgrade_18"
|
|
|
|
|
self.assertLessEqual(len(complet), 63, complet)
|
|
|
|
|
|
|
|
|
|
def test_it_does_not_end_on_a_separator(self):
|
|
|
|
|
nom = self.nom("image_db/" + "a" * 40 + "-suite.zip")
|
|
|
|
|
self.assertFalse(nom.endswith("_"), nom)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestTheMigrationUsesThatName(unittest.TestCase):
|
|
|
|
|
def test_the_prompt_offers_what_the_file_suggests(self):
|
|
|
|
|
import inspect
|
|
|
|
|
|
|
|
|
|
from script.todo.todo_upgrade import TodoUpgrade
|
|
|
|
|
|
|
|
|
|
source = inspect.getsource(TodoUpgrade.execute_odoo_upgrade)
|
|
|
|
|
pose = source.index("database_name_from_file(")
|
|
|
|
|
invite = source.index("Which database name do you want to work with?")
|
|
|
|
|
# Il faut le calculer AVANT de le proposer.
|
|
|
|
|
self.assertLess(pose, invite)
|
|
|
|
|
|
|
|
|
|
def test_a_remote_download_keeps_the_name_the_server_gave(self):
|
|
|
|
|
# Le serveur sait mieux que le nom du fichier téléchargé.
|
|
|
|
|
import inspect
|
|
|
|
|
|
|
|
|
|
from script.todo.todo_upgrade import TodoUpgrade
|
|
|
|
|
|
|
|
|
|
source = inspect.getsource(TodoUpgrade.execute_odoo_upgrade)
|
|
|
|
|
fenetre = source[
|
[REF] format : passer l'outillage et les tests sous ruff
Le formateur de ce dépôt est ruff depuis qu'il remplace black, qui ne connaît
aucune cible au-delà de py313 ; ce passage applique sa norme à l'arbre entier,
d'un coup, pour qu'aucun commit de fond n'ait à porter du style. L'écart tient
presque entièrement aux chaînes coupées à la main que ruff recolle quand elles
tiennent sur une ligne, et aux « with » multiples qu'il regroupe : aucune
valeur ne change, et les clés de traduction non plus.
Vérifié : la suite unitaire reste verte après le passage, et le contrôle de
syntaxe ne signale rien.
--- EN ---
This repository's formatter is ruff since it replaced black, which knows no
target beyond py313; this pass applies its standard to the whole tree at once,
so that no substantive commit has to carry style. The difference is almost
entirely the hand-split strings ruff joins back when they fit on one line, and
the multiple "with" it merges: no value changes, nor do the translation keys.
Checked: the unit suite stays green after the pass, and the syntax check
reports nothing.
Assisted-by: Claude Opus 5
2026-09-24 13:30:31 -04:00
|
|
|
source.index("database_name_from_file(") - 400 : source.index(
|
|
|
|
|
"database_name_from_file("
|
|
|
|
|
)
|
2026-08-27 06:26:26 -04:00
|
|
|
]
|
|
|
|
|
self.assertIn('default_database_name == "test"', fenetre)
|
|
|
|
|
|
|
|
|
|
|
[ADD] migration: announce the countdown, and cycle three panel states
Every prompt now carries « ⏱5s » before the question. One that looks like
it waits forever does not invite you to step away, which is the whole
point of auto-run. The same clock reports the answer afterwards: it
announces the wait, then says what it decided.
Enter on the auto-run question already meant no; it now says so, with an
explicit default instead of a deduction. And the flag is cleared before
asking — an ERPLIBRE_AUTO_EXECUTE inherited from the shell would have
answered yes on someone's behalf, and that answer commits every later one.
« p » cycles three states rather than toggling one: a plain toggle freed
four lines, while what takes the room is the left COLUMN — on a server log
every character of width counts. All → no summary → detail only, and back.
The state is named in the sub-title, which stays visible in all three: a
panel that vanishes without a word reads as a broken screen.
--- FR ---
[ADD] migration : annoncer le compte à rebours, et trois états de panneaux
Chaque invite porte désormais « ⏱5s » devant la question. Une invite qui a
l'air d'attendre indéfiniment n'invite pas à s'absenter, et c'est pourtant
tout l'intérêt du mode auto. Le même chronomètre rend compte ensuite.
Entrée sur la question d'auto-exécution voulait déjà dire non ; elle le
DIT maintenant, par un défaut explicite plutôt qu'une déduction. Et le
drapeau est effacé avant de demander : une variable héritée du shell
aurait répondu « oui » à la place de quelqu'un.
« p » enchaîne trois états au lieu d'en basculer un : ce qui prend la
place, c'est la colonne de gauche. Tout → sans résumé → détail seul, et
l'on revient. L'état est nommé dans le sous-titre.
Assisted-by: Claude Opus 5
2026-08-19 06:57:19 -04:00
|
|
|
class TestTheQuestionThatCannotAnswerItself(EnvCase):
|
|
|
|
|
"""Celle-là engage toutes les suivantes : elle ne se prend pas seule.
|
|
|
|
|
|
|
|
|
|
Une variable `ERPLIBRE_AUTO_EXECUTE` héritée du shell ferait répondre
|
|
|
|
|
« oui » à la place de quelqu'un, et cette réponse-là décide de toutes
|
|
|
|
|
les autres pendant des heures.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def answer(self, typed):
|
|
|
|
|
import builtins
|
|
|
|
|
|
|
|
|
|
obj = TodoUpgrade.__new__(TodoUpgrade)
|
|
|
|
|
original = builtins.input
|
|
|
|
|
builtins.input = lambda prompt="": typed
|
|
|
|
|
self.addCleanup(setattr, builtins, "input", original)
|
|
|
|
|
with contextlib.redirect_stdout(io.StringIO()):
|
|
|
|
|
obj.prompt_auto_execute()
|
|
|
|
|
return obj.auto_execute
|
|
|
|
|
|
|
|
|
|
def test_enter_says_no(self):
|
|
|
|
|
self.assertFalse(self.answer(""))
|
|
|
|
|
|
|
|
|
|
def test_the_default_is_written_down_not_deduced(self):
|
|
|
|
|
# `answer == "y"` donnait le bon résultat par accident ; un défaut
|
|
|
|
|
# explicite dit ce qu'on veut, et se relit.
|
|
|
|
|
import inspect
|
|
|
|
|
|
|
|
|
|
source = inspect.getsource(TodoUpgrade.prompt_auto_execute)
|
|
|
|
|
self.assertIn('default="n"', source)
|
|
|
|
|
|
|
|
|
|
def test_an_inherited_flag_cannot_switch_it_on(self):
|
|
|
|
|
os.environ[auto_ask.ENV_ENABLED] = "1"
|
|
|
|
|
os.environ[auto_ask.ENV_DELAY] = "5"
|
|
|
|
|
self.assertFalse(self.answer(""))
|
|
|
|
|
|
|
|
|
|
def test_the_inherited_flag_is_cleared_before_asking(self):
|
|
|
|
|
import inspect
|
|
|
|
|
|
|
|
|
|
source = inspect.getsource(TodoUpgrade.prompt_auto_execute)
|
|
|
|
|
self.assertLess(
|
|
|
|
|
source.index("auto_ask.export(False)"),
|
|
|
|
|
source.index("auto_ask.ask("),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_yes_still_says_yes(self):
|
|
|
|
|
self.assertTrue(self.answer("y"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestEveryPromptAnnouncesTheCountdown(EnvCase):
|
|
|
|
|
"""Une invite qui a l'air d'attendre indéfiniment n'invite pas à
|
|
|
|
|
s'absenter — et c'est pourtant tout l'intérêt du mode auto."""
|
|
|
|
|
|
|
|
|
|
def ask(self, default="d", seconds=5):
|
|
|
|
|
import select
|
|
|
|
|
|
|
|
|
|
original = select.select
|
|
|
|
|
select.select = lambda r, w, x, t: ([], [], [])
|
|
|
|
|
self.addCleanup(setattr, select, "select", original)
|
|
|
|
|
auto_ask.export(True, seconds)
|
|
|
|
|
out = io.StringIO()
|
|
|
|
|
with contextlib.redirect_stdout(out):
|
|
|
|
|
auto_ask.ask("💬 q : ", default=default)
|
|
|
|
|
return out.getvalue()
|
|
|
|
|
|
|
|
|
|
def test_the_delay_is_announced_before_the_question(self):
|
|
|
|
|
texte = self.ask()
|
|
|
|
|
self.assertIn("⏱5s", texte)
|
|
|
|
|
self.assertLess(texte.index("⏱5s"), texte.index("💬"))
|
|
|
|
|
|
|
|
|
|
def test_it_shows_the_real_delay(self):
|
|
|
|
|
self.assertIn("⏱2s", self.ask(seconds=2))
|
|
|
|
|
|
|
|
|
|
def test_nothing_is_announced_outside_auto_mode(self):
|
|
|
|
|
import builtins
|
|
|
|
|
|
|
|
|
|
auto_ask.export(False)
|
|
|
|
|
original = builtins.input
|
|
|
|
|
vu = []
|
|
|
|
|
builtins.input = lambda prompt="": vu.append(prompt) or "x"
|
|
|
|
|
self.addCleanup(setattr, builtins, "input", original)
|
|
|
|
|
auto_ask.ask("💬 q : ", default="d")
|
|
|
|
|
self.assertNotIn("⏱", vu[0])
|
|
|
|
|
|
|
|
|
|
def test_the_clock_announces_AND_reports(self):
|
|
|
|
|
# Le même symbole des deux côtés : il annonce l'attente, puis rend
|
|
|
|
|
# compte de la réponse. Deux symboles feraient deux mécanismes.
|
|
|
|
|
texte = self.ask()
|
|
|
|
|
self.assertEqual(texte.count("⏱"), 2)
|
|
|
|
|
|
|
|
|
|
|
[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
2026-08-17 20:47:17 -04:00
|
|
|
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",
|
[FIX] migration: the module-upgrade step no longer waits forever
Six « press to continue » prompts on the module-code migration were bare
input() calls. Auto-run never reached them, so it stopped there and said
nothing — the question had been asked, after all. They now go through the
timed reader, where Enter and the countdown mean the same thing.
They were English too, on a path the rest of which speaks the system
language.
The guard that forbids a bare input() only looked at a handful of
methods, and internal_module_upgrade was not among them — which is
exactly how these six survived the first sweep. It is now covered, and
the module menu's own prompts are documented as deliberately blocking:
they ask for a module name, a path, a version, and « » after five
seconds would be a wrong answer, not a convenience.
--- FR ---
[FIX] migration : l'étape de migration de code n'attend plus indéfiniment
Six invites « appuyez pour continuer » de la migration de code étaient des
input() nus. L'auto-exécution ne les atteignait pas : elle s'arrêtait là
sans rien dire — la question avait bien été posée. Elles passent
désormais par le lecteur temporisé, où Entrée et le compte à rebours font
la même chose.
Elles étaient aussi en anglais, sur un chemin dont tout le reste parle la
langue du système.
Le garde-fou qui interdit les input() nus ne regardait qu'une poignée de
méthodes, et internal_module_upgrade n'en faisait pas partie — c'est
ainsi que ces six-là avaient survécu. Il la couvre maintenant, et les
invites du menu de modules sont documentées comme bloquantes à dessein :
elles demandent un nom, un chemin, une version.
Assisted-by: Claude Opus 5
2026-08-18 02:36:44 -04:00
|
|
|
# Appelée DEPUIS execute_odoo_upgrade : c'est par elle que le
|
|
|
|
|
# « validez que le dépôt est prêt » arrivait, et il bloquait.
|
|
|
|
|
"internal_module_upgrade",
|
[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
2026-08-17 20:47:17 -04:00
|
|
|
"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")
|
|
|
|
|
|
|
|
|
|
|
[FIX] migration: the module-upgrade step no longer waits forever
Six « press to continue » prompts on the module-code migration were bare
input() calls. Auto-run never reached them, so it stopped there and said
nothing — the question had been asked, after all. They now go through the
timed reader, where Enter and the countdown mean the same thing.
They were English too, on a path the rest of which speaks the system
language.
The guard that forbids a bare input() only looked at a handful of
methods, and internal_module_upgrade was not among them — which is
exactly how these six survived the first sweep. It is now covered, and
the module menu's own prompts are documented as deliberately blocking:
they ask for a module name, a path, a version, and « » after five
seconds would be a wrong answer, not a convenience.
--- FR ---
[FIX] migration : l'étape de migration de code n'attend plus indéfiniment
Six invites « appuyez pour continuer » de la migration de code étaient des
input() nus. L'auto-exécution ne les atteignait pas : elle s'arrêtait là
sans rien dire — la question avait bien été posée. Elles passent
désormais par le lecteur temporisé, où Entrée et le compte à rebours font
la même chose.
Elles étaient aussi en anglais, sur un chemin dont tout le reste parle la
langue du système.
Le garde-fou qui interdit les input() nus ne regardait qu'une poignée de
méthodes, et internal_module_upgrade n'en faisait pas partie — c'est
ainsi que ces six-là avaient survécu. Il la couvre maintenant, et les
invites du menu de modules sont documentées comme bloquantes à dessein :
elles demandent un nom, un chemin, une version.
Assisted-by: Claude Opus 5
2026-08-18 02:36:44 -04:00
|
|
|
class TestWhereBlockingIsStillRight(unittest.TestCase):
|
|
|
|
|
"""Tout ne doit pas prendre un défaut : certaines questions n'en ont pas.
|
|
|
|
|
|
|
|
|
|
`execute_module_upgrade` demande un nom de module, un chemin, une
|
|
|
|
|
version de départ. Rendre « » au bout de cinq secondes n'y serait pas
|
|
|
|
|
une commodité mais une réponse fausse. Ces invites restent bloquantes,
|
|
|
|
|
et c'est correct — à une condition, vérifiée ici : qu'aucune migration
|
|
|
|
|
automatique ne passe par elles.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def test_the_module_menu_is_not_on_the_automatic_path(self):
|
|
|
|
|
import inspect
|
|
|
|
|
|
|
|
|
|
source = inspect.getsource(TodoUpgrade.execute_odoo_upgrade)
|
|
|
|
|
self.assertNotIn("execute_module_upgrade", source)
|
|
|
|
|
|
|
|
|
|
def test_but_the_step_it_shares_IS(self):
|
|
|
|
|
# `internal_module_upgrade` est appelée des DEUX côtés : par le
|
|
|
|
|
# menu et par la migration. C'est ce qui la rend obligatoire.
|
|
|
|
|
import inspect
|
|
|
|
|
|
|
|
|
|
source = inspect.getsource(TodoUpgrade.execute_odoo_upgrade)
|
|
|
|
|
self.assertIn("internal_module_upgrade", source)
|
|
|
|
|
|
|
|
|
|
def test_its_prompts_ask_for_values_with_no_default(self):
|
|
|
|
|
# Si l'un d'eux devenait un oui/non, il faudrait le faire passer
|
|
|
|
|
# par le lecteur temporisé comme les autres.
|
|
|
|
|
import inspect
|
|
|
|
|
|
|
|
|
|
source = inspect.getsource(TodoUpgrade.execute_module_upgrade)
|
|
|
|
|
for question in ("Module name", "Path", "From odoo version"):
|
|
|
|
|
self.assertIn(question, source)
|
|
|
|
|
|
|
|
|
|
|
[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
2026-08-17 20:47:17 -04:00
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
[FIX] migration: Enter exports the backup at the end
After several hours of migration the archive is what one wanted anyway,
and the prompt asked for it with an empty default: pressing Enter — or
letting auto-run answer — walked past the only artifact of the whole run.
The three-way answer moved into a function of its own, because the third
way is the one that traps. « n » refuses, « y » or Enter takes the
timestamped name, and EVERYTHING ELSE is a filename. So « non.zip » is a
file, not a refusal: comparing the whole answer rather than its first
letter is what keeps it one.
--- FR ---
[FIX] migration : Entrée exporte la sauvegarde à la fin
Au bout de plusieurs heures de migration, l'archive est ce qu'on voulait
de toute façon, et l'invite la proposait avec un défaut vide : appuyer sur
Entrée — ou laisser l'auto-exécution répondre — passait à côté du seul
artefact de toute la course.
La réponse à trois sens est devenue une fonction à part, car c'est le
troisième qui piège. « n » refuse, « y » ou Entrée prend le nom horodaté,
et TOUT LE RESTE est un nom de fichier. « non.zip » est donc un fichier et
non un refus : comparer la réponse entière plutôt que sa première lettre
est ce qui le garantit.
Assisted-by: Claude Opus 5
2026-08-19 04:20:13 -04:00
|
|
|
class TestTheBackupAtTheEnd(unittest.TestCase):
|
|
|
|
|
"""Au bout de plusieurs heures, l'archive est ce qu'on voulait.
|
|
|
|
|
|
|
|
|
|
Trois réponses, trois sens, et le troisième est celui qui piège : « n »
|
|
|
|
|
refuse, « y » ou Entrée prend le nom horodaté, et TOUT LE RESTE est un
|
|
|
|
|
nom de fichier. « non.zip » est donc un fichier, pas un refus.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
TPL = "./odoo_bin.sh db --backup --database x --restore_image"
|
|
|
|
|
DEFAUT = "./odoo_bin.sh db --backup --database x --restore_image x_f_2026"
|
|
|
|
|
|
|
|
|
|
def cmd(self, answer):
|
|
|
|
|
return TodoUpgrade.backup_command(answer, self.TPL, self.DEFAUT)
|
|
|
|
|
|
|
|
|
|
def test_yes_takes_the_timestamped_name(self):
|
|
|
|
|
self.assertEqual(self.cmd("y"), self.DEFAUT)
|
|
|
|
|
|
|
|
|
|
def test_case_does_not_matter(self):
|
|
|
|
|
self.assertEqual(self.cmd("Y"), self.DEFAUT)
|
|
|
|
|
|
|
|
|
|
def test_n_refuses(self):
|
|
|
|
|
# Le défaut ne retire pas le choix : il ne fait qu'en proposer un.
|
|
|
|
|
self.assertEqual(self.cmd("n"), "")
|
|
|
|
|
self.assertEqual(self.cmd("N"), "")
|
|
|
|
|
|
|
|
|
|
def test_anything_else_is_a_filename(self):
|
|
|
|
|
self.assertTrue(self.cmd("archive.zip").endswith(" archive.zip"))
|
|
|
|
|
|
|
|
|
|
def test_a_filename_that_STARTS_with_n_is_still_a_filename(self):
|
|
|
|
|
# Le piège : comparer un début de mot au lieu de la réponse
|
|
|
|
|
# entière ferait passer « non.zip » pour un refus, et l'on
|
|
|
|
|
# perdrait l'archive d'une migration de plusieurs heures.
|
|
|
|
|
self.assertTrue(self.cmd("non.zip").endswith(" non.zip"))
|
|
|
|
|
|
|
|
|
|
def test_blanks_are_not_a_filename(self):
|
|
|
|
|
self.assertEqual(self.cmd(" "), "")
|
|
|
|
|
|
|
|
|
|
def test_none_does_not_crash(self):
|
|
|
|
|
self.assertEqual(self.cmd(None), "")
|
|
|
|
|
|
|
|
|
|
def test_enter_backs_up_because_the_default_says_so(self):
|
|
|
|
|
import inspect
|
|
|
|
|
|
|
|
|
|
source = inspect.getsource(TodoUpgrade.execute_odoo_upgrade)
|
|
|
|
|
debut = source.index("Export a backup?")
|
|
|
|
|
fenetre = source[debut : debut + 200]
|
|
|
|
|
self.assertIn('default="y"', fenetre)
|
|
|
|
|
self.assertIn("(Y/n,", fenetre)
|
|
|
|
|
|
|
|
|
|
|
[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
2026-08-17 20:47:17 -04:00
|
|
|
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()
|