erplibre/test/test_reset_stale_cow_prompt.py
Mathieu Benoit 95a6ba558e [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-22 07:23:59 -04:00

167 lines
6.2 KiB
Python
Executable file

#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Choisir une copie à réinitialiser dans une LISTE, pas de mémoire.
L'aide affichée après une erreur disait « --reset <key> --apply » et laissait
retrouver la clé dans un diff de mille lignes. On la recopie, on se trompe
d'un caractère, et la commande ne fait RIEN sans le dire — une clé qui ne
correspond à aucune copie n'est pas une erreur pour l'outil.
D'où l'option [3] : elle demande les clés à l'outil, les numérote, et offre
« toutes ». Ces tests portent sur ce que chaque réponse lance.
"""
import os
import subprocess
import sys
import unittest
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
from script.todo import todo_i18n # noqa: E402
from script.todo.todo_upgrade import TodoUpgrade # noqa: E402
class PromptCase(unittest.TestCase):
def setUp(self):
self.addCleanup(
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
)
todo_i18n._current_lang = "en"
def run_prompt(self, answer, lst_key=None):
"""(commandes lancées, texte affiché) après cette réponse."""
import contextlib
import io
if lst_key is None:
lst_key = ["website_sale.product", "web.layout", "website_blog.x"]
upgrade = TodoUpgrade.__new__(TodoUpgrade)
upgrade.dct_progression = {}
upgrade.lst_command_executed = []
upgrade.write_config = lambda: None
upgrade.stale_cow_keys = lambda db: lst_key
lst_cmd = []
upgrade.run_on_terminal = lambda cmd: lst_cmd.append(cmd) or 0
# 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")
return lst_cmd, out.getvalue()
class TestTheMenu(PromptCase):
def test_every_key_is_numbered(self):
# C'est tout l'objet : on ne connaît pas les clés de tête.
_cmd, text = self.run_prompt("")
self.assertIn("[1] website_sale.product", text)
self.assertIn("[2] web.layout", text)
self.assertIn("[3] website_blog.x", text)
def test_all_is_offered(self):
_cmd, text = self.run_prompt("")
self.assertIn("[a]", text)
def test_nothing_drifted_asks_nothing(self):
lst_cmd, text = self.run_prompt("a", lst_key=[])
self.assertEqual(lst_cmd, [])
self.assertIn("No COW copy", text)
class TestWhatEachAnswerRuns(PromptCase):
def test_a_number_resets_that_key(self):
lst_cmd, _ = self.run_prompt("2")
self.assertEqual(len(lst_cmd), 1)
self.assertIn("--reset web.layout", lst_cmd[0])
self.assertIn("--apply", lst_cmd[0])
self.assertNotIn("website_sale", lst_cmd[0])
def test_several_numbers_reset_several_keys(self):
lst_cmd, _ = self.run_prompt("1,3")
self.assertIn("--reset website_sale.product", lst_cmd[0])
self.assertIn("--reset website_blog.x", lst_cmd[0])
self.assertNotIn("web.layout", lst_cmd[0])
def test_a_resets_them_all(self):
lst_cmd, _ = self.run_prompt("a")
self.assertIn("--reset all", lst_cmd[0])
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)
def test_an_out_of_range_number_resets_nothing(self):
# Le piège serait de tomber en silence sur « all » ou sur la
# première clé : on ne devine pas ce qui n'a pas été demandé.
lst_cmd, text = self.run_prompt("9")
self.assertEqual(lst_cmd, [])
self.assertIn("Unknown choice", text)
def test_garbage_resets_nothing(self):
lst_cmd, text = self.run_prompt("zzz")
self.assertEqual(lst_cmd, [])
self.assertIn("Unknown choice", text)
class TestTheKeysComeFromTheTool(unittest.TestCase):
"""La liste est demandée à l'outil, pas devinée."""
def test_list_keys_prints_only_keys(self):
script = os.path.join(
REPO, "script", "odoo", "migration", "reset_stale_cow_views.py"
)
with open(script) as handle:
source = handle.read()
self.assertIn("--list-keys", source)
self.assertIn('print(cow_view["key"])', source)
def test_a_dead_database_yields_no_key(self):
# Sans ce garde-fou, un menu vide se lirait comme « rien n'a dérivé ».
upgrade = TodoUpgrade.__new__(TodoUpgrade)
upgrade.dct_progression = {}
upgrade.lst_command_executed = []
upgrade.write_config = lambda: None
upgrade.todo_upgrade_execute = lambda cmd, **kw: (2, cmd, [])
self.assertEqual(upgrade.stale_cow_keys("nope"), [])
def test_the_error_prompt_offers_it(self):
import inspect
source = inspect.getsource(TodoUpgrade.todo_upgrade_execute)
self.assertIn("prompt_reset_stale_cow_views", source)
self.assertIn("Reset one of them onto its module view", source)
class TestTheToolAcceptsWhatWeSend(unittest.TestCase):
def test_reset_can_be_repeated_and_accepts_all(self):
done = subprocess.run(
[
sys.executable,
"./script/odoo/migration/reset_stale_cow_views.py",
"--help",
],
capture_output=True,
text=True,
cwd=REPO,
)
self.assertEqual(done.returncode, 0)
self.assertIn("--list-keys", done.stdout)
self.assertIn("--reset", done.stdout)
if __name__ == "__main__":
unittest.main()