2026-08-16 03:39:37 -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)
|
|
|
|
|
|
|
|
|
|
"""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 = []
|
[ADD] migration : garder la sortie des tests, par pseudo-terminal
Le journal d'étape notait la commande et son code de retour, jamais ce
qu'elle avait écrit : l'écran d'analyse ne pouvait rien montrer d'un
échec. Un tube aurait capturé et changé le programme — smoke_public_url
appelle can_ask(), qui exige stdin ET stdout sur un terminal, et derrière
un tube il cesse en silence d'offrir la réparation des vues COW. Un
pseudo-terminal lève le dilemme : l'enfant voit un vrai terminal, la
réponse tapée lui parvient, le code de retour survit. Neuf exécutions y
passent, dont check_hidden_models, qui tournait sans verdict retenu ;
deux restent dehors, pty.spawn naît en 0×0 et un plein écran s'y perdrait.
--- EN ---
The step log recorded the command and its exit code, never what it
wrote: the analysis screen could show nothing of a failure. A pipe would
have captured and changed the program — smoke_public_url calls can_ask(),
which requires stdin AND stdout to be terminals, and behind a pipe it
silently stops offering the COW view repair. A pty settles it: the child
sees a real terminal, a typed answer reaches it, the exit code survives.
Nine runs go through it, including check_hidden_models, which ran with no
verdict recorded; two stay out, pty.spawn starts at 0×0 and a full-screen
app would lay out on nothing.
Assisted-by: Claude Opus 5
(cherry picked from commit 81e9227502a0d16e6409338cf958495a5f8e3827)
2026-08-27 05:54:24 -04:00
|
|
|
upgrade.run_captured = lambda cmd: lst_cmd.append(cmd) or 0
|
[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
|
|
|
# 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
|
2026-08-16 03:39:37 -04:00
|
|
|
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])
|
|
|
|
|
|
[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
|
|
|
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")
|
2026-08-16 03:39:37 -04:00
|
|
|
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
|
|
|
|
|
|
[ADD] migration: repair views whose stored type contradicts their parent
Odoo refused to load a database: it validated a <search> arch with tree
rules. The view is not a COW copy -- the COW tools were right to say
they had nothing to reset -- its stored `type` simply lies.
Nothing would ever have fixed it. In ir_ui_view.py, `type` is filled in
`create`, and only when absent; `write` never recomputes it. A wrong
value stays wrong, and `-u module` fails on the validation it causes
before it could rewrite anything.
Plain SQL, no ORM: the registry is what will not load, and a repair that
needed Odoo to fix what stops Odoo would be useless.
The rule is Odoo's own -- an inherited view takes its parent's type --
and it holds: zero disagreement on a pristine 18 install and on four
migrated databases, exactly one on the database that refused to load.
--- FR ---
Odoo refusait de charger une base : il validait un arch <search> avec
les règles d'un tree. La vue n'est pas une copie COW — les outils COW
avaient raison de dire qu'ils n'avaient rien à réinitialiser — c'est son
`type` stocké qui ment.
Rien ne l'aurait jamais réparé. Dans ir_ui_view.py, `type` est rempli
dans `create`, et seulement s'il est absent ; `write` ne le recalcule
jamais. Une valeur fausse le reste, et `-u module` échoue sur la
validation qu'elle provoque avant de pouvoir réécrire quoi que ce soit.
En SQL, sans ORM : c'est le registre qui ne charge plus, et une
réparation qui aurait besoin d'Odoo ne servirait à rien.
La règle est celle d'Odoo — une vue héritée prend le type de son parent
— et elle tient : zéro écart sur une 18 neuve et sur quatre bases
migrées, exactement un sur celle qui refusait de charger.
Assisted-by: Claude Opus 5
2026-08-22 00:46:42 -04:00
|
|
|
# Le menu d'erreur vit dans `_prompt_on_error`, extrait de
|
|
|
|
|
# `todo_upgrade_execute` quand celui-ci a passé le seuil de
|
|
|
|
|
# complexité. Lire les deux : c'est le CHEMIN d'erreur qu'on
|
|
|
|
|
# éprouve, pas une méthode en particulier.
|
|
|
|
|
source = inspect.getsource(
|
|
|
|
|
TodoUpgrade.todo_upgrade_execute
|
|
|
|
|
) + inspect.getsource(TodoUpgrade._prompt_on_error)
|
2026-08-16 03:39:37 -04:00
|
|
|
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()
|