[ADD] migration quality: biggest losses first, and the full lists behind « d »

Fifty-seven losses are read from the top, not in alphabetical order. They
are now sorted by rows LOST, not by rows there before: a table of ten
thousand that drops two matters less than one of a thousand that empties.

« d » cycles the pane through the complete lists — modules, models,
fields, COW copies, tables — and back to the summary. The summary cuts at
eight entries and is right to; but when one is looking for whether ONE
module survived, the truncated list does not answer, and that is exactly
when it is needed.

Two inventories were missing and both matter. FIELDS: a lost field is a
lost column of data, finer than a model, which can survive emptied of
half of its own. COW COPIES by key: it is the key one resets, and by the
key one finds them again across versions. On a real 12 → 18 they read
11 036 → 15 069 fields and 64 → 131 copies, of which 28 disappeared —
customisations nothing was reporting until now.

One mode replaces two flags: « show missing files » and « show the model
list » cannot both be true, and two booleans let that impossible state be
written. The mode is named in the sub-title, because a pane that changes
without saying why reads as a broken screen.

--- FR ---

[ADD] qualité de migration : les plus grosses pertes d'abord, les listes derrière « d »

Cinquante-sept pertes se lisent par le haut. Elles sont triées sur le
volume PERDU, non sur le volume présent avant : une table de dix mille qui
en perd deux compte moins qu'une de mille qui se vide.

« d » fait défiler le panneau à travers les listes entières — modules,
modèles, champs, copies COW, tables — puis revient au résumé.

Deux inventaires manquaient. Les CHAMPS : un champ perdu est une colonne
de données perdue. Les COPIES COW par leur clé : sur une vraie 12 → 18,
28 ont disparu — des personnalisations que rien ne signalait.

Un seul mode remplace deux drapeaux, qui laissaient écrire un état
impossible. Il est nommé dans le sous-titre.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-08-20 03:47:02 -04:00
parent e75bc585ba
commit 53fa936168
4 changed files with 354 additions and 8 deletions

View file

@ -210,6 +210,22 @@ def inspect(database):
modeles = run_psql(database, "SELECT model FROM ir_model ORDER BY model")
etat["model"] = sorted(m[0] for m in modeles or [])
etat["table"] = table_counts(database)
# Les CHAMPS : un champ perdu est une colonne de données perdue, et
# c'est plus fin qu'un modèle — un modèle qui survit peut avoir été
# vidé de la moitié de ses champs sans que rien ne le dise.
champs = run_psql(
database,
"SELECT model || '.' || name FROM ir_model_fields ORDER BY 1",
)
etat["field"] = sorted(ligne[0] for ligne in champs or [])
# Les copies COW par leur CLÉ : c'est elle qu'on réinitialise, et
# c'est par elle qu'on les retrouve d'une version à l'autre.
copies = run_psql(
database,
"SELECT coalesce(key, 'id:' || id::text) FROM ir_ui_view"
" WHERE website_id IS NOT NULL ORDER BY 1",
)
etat["cow"] = sorted(ligne[0] for ligne in copies or [])
# Les modèles sans table : abstraits, mixins et vues SQL pour la
# plupart — mais leur NOMBRE qui bouge d'un palier à l'autre dit
@ -276,6 +292,58 @@ def missing_detail(database, lst_store_fname, limit=400):
]
DETAILS = ("modules", "models", "fields", "cow", "tables")
def render_detail(diff, categorie, colour=False):
"""La liste ENTIÈRE d'une catégorie, sans troncature.
Le résumé coupe à huit entrées, et il a raison : personne ne lit
trois cent soixante-quinze noms de modèles en passant. Mais quand on
cherche si UN module précis a survécu, la liste tronquée ne répond
pas — et c'est justement là qu'on a besoin d'elle.
"""
from script.todo.migration_status import paint
if diff is None or diff.get("unavailable"):
return t("not comparable: a database is missing")
if categorie == "tables":
lignes = [f"── {t('table(s) lost rows')} ──"]
vers = {a: b for a, b, _n in diff["renamed"]}
for table, avant, apres, connu in diff["rows_lost"]:
note = ""
if connu and connu["into"]:
note = f" → {connu['into']}"
elif connu:
note = f" {t('retired from the database')}"
elif table in vers:
note = f" ↻ {vers[table]}"
teinte = "dim" if connu else "fail"
lignes.append(
f" {paint(f'{avant - apres:>8}', teinte, colour)}"
f" {table:<46} {avant} → {apres}{note}"
)
return "\n".join(lignes)
perdus = diff.get(f"{categorie}_lost") or []
gagnes = diff.get(f"{categorie}_gained") or []
lignes = []
# « − 28 copies COW » plutôt que « 28 copies COW perdus » : le signe
# porte déjà le sens, et l'accord d'un participe avec une catégorie
# dont le genre change d'une langue à l'autre ne se traduit pas.
for lst, symbole, teinte in (
(perdus, "−", "fail"),
(gagnes, "+", "ok"),
):
lignes.append(
f"── {paint(symbole, teinte, colour)} {len(lst)}"
f" {t(categorie)} ──"
)
lignes.extend(f" {nom}" for nom in lst)
lignes.append("")
return "\n".join(lignes)
def render_missing(etat, colour=False, limit=60):
"""Ce qui manque, groupé d'abord, détaillé ensuite.
@ -521,7 +589,18 @@ def compare(avant, apres):
else None
)
lignes_perdues[index] = (table, debut, fin, {**connu, "gained": recue})
champ_avant = set(avant.get("field") or [])
champ_apres = set(apres.get("field") or [])
cow_avant = set(avant.get("cow") or [])
cow_apres = set(apres.get("cow") or [])
# Les plus grosses pertes EN TÊTE : on attaque une liste de
# cinquante-sept par le haut, pas par ordre alphabétique.
lignes_perdues.sort(key=lambda item: -(item[1] - item[2]))
return {
"fields_lost": sorted(champ_avant - champ_apres),
"fields_gained": sorted(champ_apres - champ_avant),
"cow_lost": sorted(cow_avant - cow_apres),
"cow_gained": sorted(cow_apres - cow_avant),
"modules_lost": sorted(inst_avant - inst_apres),
"modules_gained": sorted(inst_apres - inst_avant),
"models_lost": sorted(mod_avant - mod_apres),

View file

@ -159,17 +159,24 @@ def statistics(etat, precedent):
]
def pane_text(lst_snapshot, row, colour=False, show_missing=False):
"""Le détail du palier choisi, ou le bilan d'ensemble."""
def pane_text(lst_snapshot, row, colour=False, mode=None):
"""Le détail du palier choisi, ou le bilan d'ensemble.
UN seul mode, et non deux drapeaux : « montre les fichiers absents »
et « montre la liste des modèles » ne peuvent pas être vrais en même
temps, et deux booléens laissaient écrire cet état impossible.
"""
if row is None:
return t("Nothing to show yet.")
if row["kind"] == "missing":
return f"⚠️ {row['data']['database']} : {t('database not found')}"
etat = row["data"]
if show_missing:
if mode == "missing":
if not etat:
return t("Pick a step to see its missing files.")
return quality.render_missing(etat, colour)
if mode in quality.DETAILS:
return quality.render_detail(row.get("diff"), mode, colour)
lignes = []
if etat:
lignes.append(
@ -202,6 +209,19 @@ def pane_text(lst_snapshot, row, colour=False, show_missing=False):
return "\n".join(lignes)
def mode_label(mode):
"""Le nom du mode courant, pour le sous-titre.
Un panneau qui change de contenu sans dire pourquoi se lit comme un
écran cassé — et avec sept modes, on ne devine pas.
"""
if mode is None:
return t("summary")
if mode == "missing":
return t("Missing files")
return t(mode)
def build_app(lst_snapshot):
"""Textual est importé ICI : le module reste testable sans lui."""
from rich.text import Text
@ -214,6 +234,7 @@ def build_app(lst_snapshot):
BINDINGS = [
("q,escape", "quit", t("Quit")),
("m", "toggle_missing", t("Missing files")),
("d", "cycle_detail", t("Details")),
]
def __init__(self, lst_snapshot):
@ -221,7 +242,7 @@ def build_app(lst_snapshot):
self.lst_snapshot = lst_snapshot
self.lst_row = rows(lst_snapshot)
self.index = 0
self.show_missing = False
self.mode = None
def compose(self) -> ComposeResult:
yield Header()
@ -244,6 +265,7 @@ def build_app(lst_snapshot):
self._show()
def _show(self):
self.sub_title = mode_label(self.mode)
row = (
self.lst_row[self.index]
if self.lst_row and self.index < len(self.lst_row)
@ -255,7 +277,7 @@ def build_app(lst_snapshot):
self.lst_snapshot,
row,
colour=True,
show_missing=self.show_missing,
mode=self.mode,
)
)
)
@ -267,7 +289,20 @@ def build_app(lst_snapshot):
base allongerait un parcours qui tient en quatre secondes, pour
une information qu'on ne regarde qu'en la demandant.
"""
self.show_missing = not self.show_missing
self.mode = None if self.mode == "missing" else "missing"
self._show()
def action_cycle_detail(self):
"""Parcourir les listes ENTIÈRES, une catégorie à la fois.
Le résumé coupe à huit entrées et il a raison ; mais quand on
cherche si UN module précis a survécu, la liste tronquée ne
répond pas. On enchaîne donc résumé → modules → modèles →
champs → copies COW → tables, et l'on revient.
"""
suite = (None,) + quality.DETAILS
courant = self.mode if self.mode in suite else None
self.mode = suite[(suite.index(courant) + 1) % len(suite)]
self._show()
def on_data_table_row_highlighted(self, event):

View file

@ -5803,6 +5803,34 @@ TRANSLATIONS = {
"fr": "là-bas",
"en": "there",
},
"lost": {
"fr": "perdus",
"en": "lost",
},
"gained": {
"fr": "gagnés",
"en": "gained",
},
"fields": {
"fr": "champs",
"en": "fields",
},
"cow": {
"fr": "copies COW",
"en": "cow",
},
"tables": {
"fr": "tables",
"en": "tables",
},
"Details": {
"fr": "Détails",
"en": "Details",
},
"summary": {
"fr": "résumé",
"en": "summary",
},
"Clean the database before testing the pages?": {
"fr": "Nettoyer la base avant de tester les pages ?",
"en": "Clean the database before testing the pages?",

View file

@ -633,8 +633,8 @@ class TestTheMissingFilesButton(Base):
"previous": None,
"diff": None,
}
chiffres = qtui.pane_text([], row, show_missing=False)
liste = qtui.pane_text([], row, show_missing=True)
chiffres = qtui.pane_text([], row, mode=None)
liste = qtui.pane_text([], row, mode="missing")
self.assertIn("modules", chiffres)
self.assertNotIn("modules", liste)
@ -719,6 +719,210 @@ class TestTheMenuEntryLooksLikeItsNeighbours(Base):
self.assertIn("📐", inspect.getsource(quality.render_text))
class TestAttackingTheListFromTheTop(Base):
"""Cinquante-sept pertes se parcourent par le haut, pas par ordre
alphabétique : la plus grosse est celle qu'on veut voir en premier."""
def test_the_biggest_loss_comes_first(self):
# Des noms dont l'ordre alphabétique CONTREDIT celui des volumes :
# sinon un tri par nom passerait pour un tri par volume, et le
# test ne prouverait rien.
diff = quality.compare(
snapshot(
odoo="12.0",
table={
"aaa_petite": 10,
"zzz_enorme": 5000,
"mmm_moyenne": 100,
},
),
snapshot(odoo="18.0", table={}),
)
self.assertEqual(
[x[0] for x in diff["rows_lost"]],
["zzz_enorme", "mmm_moyenne", "aaa_petite"],
)
def test_it_sorts_on_what_was_LOST_not_on_what_was_there(self):
# Une table de dix mille lignes qui en perd deux compte moins
# qu'une table de mille qui se vide.
diff = quality.compare(
snapshot(odoo="12.0", table={"grosse": 10000, "vidée": 1000}),
snapshot(odoo="18.0", table={"grosse": 9998}),
)
self.assertEqual(
[x[0] for x in diff["rows_lost"]], ["vidée", "grosse"]
)
class TestTheFullLists(Base):
"""Le résumé coupe à huit entrées, et il a raison.
Mais quand on cherche si UN module précis a survécu, la liste
tronquée ne répond pas — et c'est justement là qu'on en a besoin.
"""
def diff(self):
return quality.compare(
snapshot(
odoo="12.0",
installed=["a", "b"],
model=["m.un", "m.deux"],
field=["m.un.x", "m.un.y"],
cow=["site.vue"],
table={"t": 10},
),
snapshot(
odoo="18.0",
installed=["a", "c"],
model=["m.un", "m.trois"],
field=["m.un.x", "m.trois.z"],
cow=["site.autre"],
table={},
),
)
def test_fields_are_inventoried(self):
# Un champ perdu est une colonne de données perdue — plus fin
# qu'un modèle, qui peut survivre vidé de la moitié des siens.
diff = self.diff()
self.assertEqual(diff["fields_lost"], ["m.un.y"])
self.assertEqual(diff["fields_gained"], ["m.trois.z"])
def test_cow_copies_are_inventoried_by_key(self):
# C'est la clé qu'on réinitialise, et par elle qu'on les retrouve.
diff = self.diff()
self.assertEqual(diff["cow_lost"], ["site.vue"])
self.assertEqual(diff["cow_gained"], ["site.autre"])
def test_every_category_renders_in_full(self):
for categorie in quality.DETAILS:
texte = quality.render_detail(self.diff(), categorie)
self.assertTrue(texte.strip(), categorie)
def test_nothing_is_truncated(self):
# C'est tout le propos : le résumé coupe, la liste entière non.
diff = quality.compare(
snapshot(odoo="12.0", model=[f"m.{i}" for i in range(200)]),
snapshot(odoo="18.0", model=[]),
)
texte = quality.render_detail(diff, "models")
self.assertIn("m.199", texte)
self.assertNotIn("…", texte)
def test_the_table_list_keeps_the_volume_order(self):
diff = quality.compare(
snapshot(odoo="12.0", table={"petite": 1, "enorme": 900}),
snapshot(odoo="18.0", table={}),
)
texte = quality.render_detail(diff, "tables")
self.assertLess(texte.index("enorme"), texte.index("petite"))
def test_an_unavailable_comparison_says_so(self):
self.assertIn("not comparable", quality.render_detail(None, "models"))
def test_the_headings_carry_no_participle(self):
"""« 28 copies COW perdus » ne s'accorde pas, et ne se traduit pas.
Le signe porte déjà le sens ; un participe devrait s'accorder avec
une catégorie dont le genre change d'une langue à l'autre.
"""
texte = quality.render_detail(self.diff(), "cow")
self.assertIn("−", texte)
self.assertNotIn("perdus", texte)
class TestTheInventoryItself(Base):
"""La comparaison était testée, la COLLECTE ne l'était pas.
Vider l'inventaire des champs dans `inspect` passait inaperçu, parce
que tous les tests fabriquaient leurs instantanés à la main. Un test
qui ne touche jamais le code réel ne garde rien.
"""
def repondre(self, sql):
if "ir_model_fields" in sql:
return [["res.partner.name"], ["res.partner.email"]]
if "website_id IS NOT NULL" in sql:
return [["site.vue"]]
if "FROM ir_model " in sql or sql.strip().endswith("ORDER BY model"):
return [["res.partner"]]
if "ir_module_module" in sql:
return [["base", "installed"]]
if "latest_version" in sql:
return [["odoo", "18.0.1.3"]]
return []
def inspecter(self):
for nom, remplacant in (
("run_psql", lambda db, sql: self.repondre(sql)),
("missing_files", lambda db, lst: []),
("table_counts", lambda db: {}),
):
original = getattr(quality, nom)
setattr(quality, nom, remplacant)
self.addCleanup(setattr, quality, nom, original)
return quality.inspect("db")
def test_the_fields_are_collected(self):
self.assertEqual(
self.inspecter()["field"],
["res.partner.email", "res.partner.name"],
)
def test_the_cow_copies_are_collected(self):
self.assertEqual(self.inspecter()["cow"], ["site.vue"])
def test_a_cow_copy_without_a_key_is_still_named(self):
# Une copie sans clé existe quand même ; la taire ferait un
# inventaire qui ment sur son propre compte.
import inspect
self.assertIn("'id:' || id::text", inspect.getsource(quality.inspect))
class TestTheDetailButton(Base):
def test_d_is_bound(self):
app = qtui.build_app([snapshot()])
touches = {
touche
for entree in app.BINDINGS
for touche in entree[0].split(",")
}
self.assertIn("d", touches)
def test_the_cycle_visits_every_category_and_comes_back(self):
suite = (None,) + quality.DETAILS
self.assertEqual(len(suite), 6)
self.assertEqual(suite[len(suite) % len(suite)], None)
def test_ONE_mode_not_two_flags(self):
"""« fichiers absents » et « liste des modèles » ne peuvent pas
être vrais en même temps ; deux booléens laissaient écrire cet
état impossible."""
import inspect
source = inspect.getsource(qtui.build_app)
self.assertIn("self.mode", source)
self.assertNotIn("self.show_missing", source)
def test_every_mode_has_a_name(self):
# Un panneau qui change sans dire pourquoi se lit comme un écran
# cassé, et avec sept modes on ne devine pas.
noms = {
qtui.mode_label(m) for m in (None, "missing") + quality.DETAILS
}
self.assertEqual(len(noms), 7)
for nom in noms:
self.assertTrue(nom.strip())
def test_the_name_is_shown_where_it_stays_visible(self):
import inspect
source = inspect.getsource(qtui.build_app)
self.assertIn("self.sub_title = mode_label", source)
class TestItNeverWrites(Base):
"""Les bases de palier sont parfois la seule copie d'un état."""