erplibre/test/test_todo_upgrade_repairs.py

206 lines
7.6 KiB
Python
Raw Normal View History

[ADD] migration: brancher les deux réparations qui ne tournaient jamais 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
2026-08-24 22:55:21 -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)
"""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()