fix_duplicate_index et restore_config_defaults étaient écrits, éprouvés, et absents du pilote. Chaque migration refabriquait donc ses index redondants et reperdait sa liste de prix. Mesuré sur DEUX chaînes 12 → 18 indépendantes, même base source : 68 relations orphelines, 9 langues au drapeau NULL, une liste de prix absente, 376 paires d'index en double — les mêmes nombres des deux côtés. Ce n'est pas un accident d'exécution, c'est le chemin lui-même. Les index à partir du palier 17 : avant, la convention n'a pas changé. Les réglages au DERNIER palier : l'outil charge le registre, et seul l'état final compte. Les deux en wait_at_error=False — avec --apply, le code 1 dit « il en reste », pas « je suis tombé », et cela n'arrête pas six paliers. --- EN --- fix_duplicate_index and restore_config_defaults were written, proven, and absent from the driver. Every migration therefore rebuilt its redundant indexes and lost its default pricelist again. Measured on TWO independent 12 → 18 chains from the same source: 68 orphan relations, 9 languages with a NULL flag, one missing pricelist, 376 duplicate index pairs — the same numbers on both. Not a fluke of one run: the path itself. Indexes from step 17 onward: before that the convention had not changed. Config defaults at the LAST step: the tool loads the registry, and only the final state matters. Both with wait_at_error=False — with --apply, exit 1 means "some remain", not "I crashed", and that must not halt six steps. Assisted-by: Claude Opus 5
205 lines
7.6 KiB
Python
205 lines
7.6 KiB
Python
#!/usr/bin/env python3
|
|
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
|
|
"""Deux réparations existaient et ne tournaient jamais.
|
|
|
|
`fix_duplicate_index.py` et `restore_config_defaults.py` étaient écrits,
|
|
éprouvés sur copie, et absents du pilote. Chaque migration refabriquait
|
|
donc ses index redondants et reperdait sa liste de prix par défaut —
|
|
mesuré à l'identique sur DEUX chaînes 12 → 18 indépendantes : 414 index
|
|
et une liste manquante dans l'une comme dans l'autre.
|
|
|
|
Un outil qui existe sans être appelé est le défaut le plus discret de ce
|
|
dépôt : rien n'échoue, rien ne l'écrit, et l'on croit le problème réglé
|
|
parce qu'on se souvient d'avoir écrit le correctif. Ce fichier vérifie
|
|
donc les DEUX choses — que les méthodes se comportent bien, et qu'elles
|
|
sont réellement appelées depuis la boucle des paliers.
|
|
|
|
Les docstrings de ces méthodes CITENT `wait_at_error=False` pour
|
|
l'expliquer. Un test qui lirait le texte du fichier le trouverait là et
|
|
passerait au vert sans que l'argument soit passé nulle part. On lit donc
|
|
l'arbre syntaxique, jamais le texte.
|
|
"""
|
|
|
|
import ast
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
from script.todo.todo_upgrade import TodoUpgrade
|
|
|
|
PILOTE = (
|
|
Path(__file__).resolve().parent.parent
|
|
/ "script"
|
|
/ "todo"
|
|
/ "todo_upgrade.py"
|
|
)
|
|
|
|
|
|
def arbre_du_pilote():
|
|
"""L'AST du pilote. `read_text` referme le fichier."""
|
|
return ast.parse(PILOTE.read_text(encoding="utf-8"))
|
|
|
|
|
|
class FauxPilote:
|
|
"""Un pilote qui note ce qu'on lui demande d'exécuter."""
|
|
|
|
def __init__(self):
|
|
self.appels = []
|
|
|
|
def todo_upgrade_execute(self, cmd, wait_at_error=True, **kwargs):
|
|
self.appels.append((cmd, wait_at_error))
|
|
return 0
|
|
|
|
|
|
class TestWhenTheIndexRepairRuns(unittest.TestCase):
|
|
def _lancer(self, version):
|
|
faux = FauxPilote()
|
|
TodoUpgrade.drop_duplicate_index(faux, "ma_base", version)
|
|
return faux.appels
|
|
|
|
def test_it_stays_quiet_before_odoo_17(self):
|
|
"""La convention n'a pas changé avant : il ne trouverait rien."""
|
|
for version in (13, 14, 15, 16):
|
|
self.assertEqual(self._lancer(version), [], version)
|
|
|
|
def test_it_runs_from_17_onward(self):
|
|
for version in (17, 18):
|
|
self.assertEqual(len(self._lancer(version)), 1, version)
|
|
|
|
def test_it_repairs_rather_than_reports(self):
|
|
cmd, _ = self._lancer(18)[0]
|
|
self.assertIn("--apply", cmd)
|
|
self.assertIn("fix_duplicate_index.py", cmd)
|
|
self.assertIn("-d ma_base", cmd)
|
|
|
|
def test_a_leftover_index_does_not_halt_the_migration(self):
|
|
"""Avec --apply, le code 1 veut dire « il en reste », pas « échec »."""
|
|
_, wait_at_error = self._lancer(18)[0]
|
|
self.assertFalse(wait_at_error)
|
|
|
|
|
|
class TestWhenTheDefaultsRepairRuns(unittest.TestCase):
|
|
def _lancer(self, dernier):
|
|
faux = FauxPilote()
|
|
TodoUpgrade.restore_config_defaults(faux, "ma_base", dernier)
|
|
return faux.appels
|
|
|
|
def test_it_waits_for_the_last_step(self):
|
|
"""Il charge le registre Odoo : six fois pour rien coûterait cher."""
|
|
self.assertEqual(self._lancer(False), [])
|
|
|
|
def test_it_runs_on_the_last_step(self):
|
|
self.assertEqual(len(self._lancer(True)), 1)
|
|
|
|
def test_it_repairs_rather_than_reports(self):
|
|
cmd, wait_at_error = self._lancer(True)[0]
|
|
self.assertIn("--apply", cmd)
|
|
self.assertIn("restore_config_defaults.py", cmd)
|
|
self.assertFalse(wait_at_error)
|
|
|
|
|
|
class TestTheyAreActuallyCalled(unittest.TestCase):
|
|
"""Le défaut visé : une méthode écrite que personne n'appelle."""
|
|
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
cls.arbre = arbre_du_pilote()
|
|
cls.appels = {}
|
|
for noeud in ast.walk(cls.arbre):
|
|
if isinstance(noeud, ast.Call) and isinstance(
|
|
noeud.func, ast.Attribute
|
|
):
|
|
cls.appels.setdefault(noeud.func.attr, []).append(noeud)
|
|
|
|
def _boucle_des_paliers(self):
|
|
for noeud in ast.walk(self.arbre):
|
|
if (
|
|
isinstance(noeud, ast.For)
|
|
and isinstance(noeud.iter, ast.Call)
|
|
and getattr(noeud.iter.func, "id", "") == "enumerate"
|
|
and [
|
|
t.id
|
|
for t in ast.walk(noeud.target)
|
|
if isinstance(t, ast.Name)
|
|
]
|
|
== ["index", "next_version"]
|
|
):
|
|
return noeud
|
|
raise AssertionError("boucle des paliers introuvable")
|
|
|
|
def test_both_repairs_are_called_from_the_tier_loop(self):
|
|
boucle = self._boucle_des_paliers()
|
|
for nom in ("drop_duplicate_index", "restore_config_defaults"):
|
|
dedans = [
|
|
n
|
|
for n in self.appels.get(nom, [])
|
|
if boucle.lineno < n.lineno < boucle.end_lineno
|
|
]
|
|
self.assertEqual(len(dedans), 1, nom)
|
|
|
|
def test_the_index_repair_comes_after_the_cleanup(self):
|
|
"""Le nettoyage supprime des colonnes, donc leurs index avec."""
|
|
nettoyage = self.appels["prompt_database_cleanup"][-1].lineno
|
|
index = self.appels["drop_duplicate_index"][0].lineno
|
|
fumee = self.appels["prompt_smoke_public_url"][-1].lineno
|
|
self.assertLess(nettoyage, index)
|
|
self.assertLess(index, fumee)
|
|
|
|
def test_the_defaults_repair_runs_before_the_smoke_test(self):
|
|
"""Le test de fumée doit voir une base cohérente."""
|
|
defauts = self.appels["restore_config_defaults"][0].lineno
|
|
fumee = self.appels["prompt_smoke_public_url"][-1].lineno
|
|
self.assertLess(defauts, fumee)
|
|
|
|
def test_the_last_step_is_computed_from_the_loop_itself(self):
|
|
"""`is_last` doit venir de la liste, pas d'un numéro écrit en dur."""
|
|
appel = self.appels["restore_config_defaults"][0]
|
|
source = ast.dump(appel)
|
|
self.assertIn("lst_next_version", source)
|
|
self.assertNotIn("Constant(value=18)", source)
|
|
|
|
|
|
class TestTheDocstringTrapIsNotWhatWeRead(unittest.TestCase):
|
|
"""La preuve que ce fichier ne se paie pas de mots.
|
|
|
|
`wait_at_error=False` apparaît dans les docstrings des deux méthodes.
|
|
Si l'argument disparaissait des APPELS, un test textuel resterait vert.
|
|
"""
|
|
|
|
def test_the_docstrings_do_mention_it(self):
|
|
for nom in ("drop_duplicate_index", "restore_config_defaults"):
|
|
methode = getattr(TodoUpgrade, nom)
|
|
self.assertIn("wait_at_error=False", methode.__doc__ or "", nom)
|
|
|
|
def test_and_yet_the_keyword_is_really_passed(self):
|
|
arbre = arbre_du_pilote()
|
|
for nom in ("drop_duplicate_index", "restore_config_defaults"):
|
|
corps = None
|
|
for noeud in ast.walk(arbre):
|
|
if isinstance(noeud, ast.FunctionDef) and noeud.name == nom:
|
|
corps = noeud
|
|
self.assertIsNotNone(corps, nom)
|
|
# Le corps SANS sa docstring : c'est là que doit vivre l'argument.
|
|
sans_texte = [
|
|
n
|
|
for n in corps.body
|
|
if not (
|
|
isinstance(n, ast.Expr)
|
|
and isinstance(n.value, ast.Constant)
|
|
and isinstance(n.value.value, str)
|
|
)
|
|
]
|
|
trouve = False
|
|
for noeud in sans_texte:
|
|
for interne in ast.walk(noeud):
|
|
if isinstance(interne, ast.keyword) and (
|
|
interne.arg == "wait_at_error"
|
|
):
|
|
self.assertIs(interne.value.value, False, nom)
|
|
trouve = True
|
|
self.assertTrue(trouve, nom)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|