diff --git a/script/analyse/check_migration_quality.py b/script/analyse/check_migration_quality.py index b1c5f62..6028593 100755 --- a/script/analyse/check_migration_quality.py +++ b/script/analyse/check_migration_quality.py @@ -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), diff --git a/script/analyse/check_migration_quality_tui.py b/script/analyse/check_migration_quality_tui.py index 4328936..fc38097 100644 --- a/script/analyse/check_migration_quality_tui.py +++ b/script/analyse/check_migration_quality_tui.py @@ -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): diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index c8c45d3..0706323 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -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?", diff --git a/test/test_check_migration_quality.py b/test/test_check_migration_quality.py index 5f5522f..9cb8403 100644 --- a/test/test_check_migration_quality.py +++ b/test/test_check_migration_quality.py @@ -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."""