diff --git a/script/analyse/check_migration_quality.py b/script/analyse/check_migration_quality.py
new file mode 100755
index 0000000..ff08063
--- /dev/null
+++ b/script/analyse/check_migration_quality.py
@@ -0,0 +1,499 @@
+#!/usr/bin/env python3
+# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+
+"""Ce qu'une migration gagne et ce qu'elle perd, palier par palier.
+
+La question qu'on se pose après six paliers n'est pas « a-t-elle fini » —
+le journal le dit — mais « qu'est-ce qui a changé en chemin ». Un module
+désinstallé en 15 pour débloquer la mise à jour, une table vidée sans que
+personne ne le voie, deux cents vues apparues : rien de tout cela
+n'apparaît dans un journal qu'on lit ligne à ligne.
+
+Ce que l'outil compare
+----------------------
+Une migration laisse une base PAR PALIER — `x`, `x_upgrade_13`, … — et
+elles existent toutes encore. On les interroge donc côte à côte, en
+lecture seule, plutôt que de rejouer quoi que ce soit.
+
+Pourquoi pas en démarrant Odoo
+------------------------------
+Six démarrages coûteraient une heure, écriraient dans les bases et
+demanderaient de basculer le checkout à chaque palier. Mesuré : la même
+inspection en SQL prend moins d'une demi-seconde par base, et ne touche à
+rien. Ce qu'on y perd — les modèles abstraits, les champs calculés — ne
+se compare pas d'une version à l'autre de toute façon.
+
+Ce qui compte le plus
+---------------------
+Les LIGNES perdues. Un module en moins se voit ; une table qui passe de
+quatre mille lignes à zéro ne se voit nulle part. Un renommage de table
+entre deux versions s'y lit comme une perte suivie d'un gain : le rapport
+les rapproche quand le compte correspond, plutôt que de crier au loup.
+
+Codes de sortie : 0 rien à signaler, 1 des trouvailles, 2 l'outil a échoué.
+"""
+
+import json
+import os
+import subprocess
+import sys
+
+sys.path.append(
+ os.path.normpath(os.path.join(os.path.dirname(__file__), "..", ".."))
+)
+
+try:
+ from script.todo.todo_i18n import t
+except Exception: # pragma: no cover - repli si i18n indisponible
+
+ def t(key: str) -> str:
+ return key
+
+
+DEFAULT_PROGRESSION = ".venv.erplibre/odoo_database_migration_log.json"
+FILESTORE = os.path.join(
+ os.path.expanduser("~"), ".local", "share", "Odoo", "filestore"
+)
+SEP = "\x1f"
+
+
+def run_psql(database, sql):
+ """Interroger la base en lecture seule, garantie par le SERVEUR.
+
+ `default_transaction_read_only` n'est pas une promesse de l'outil :
+ PostgreSQL refusera l'écriture même si le SQL en contenait une. On
+ inspecte des bases de migration, parfois la seule copie qui reste.
+ """
+ env = os.environ.copy()
+ env["PGOPTIONS"] = "-c default_transaction_read_only=on"
+ env["PSQLRC"] = ""
+ done = subprocess.run(
+ ["psql", "-X", "-w", "-d", database, "-tAF", SEP, "-c", sql],
+ capture_output=True,
+ text=True,
+ env=env,
+ )
+ if done.returncode:
+ return None
+ return [ligne.split(SEP) for ligne in done.stdout.splitlines() if ligne]
+
+
+def read_progression(path=DEFAULT_PROGRESSION):
+ try:
+ with open(path, "r", encoding="utf-8") as handle:
+ return json.load(handle)
+ except (OSError, ValueError):
+ return {}
+
+
+def chain(dct):
+ """[(version, base)] du départ à l'arrivée, dans l'ordre du parcours.
+
+ Le nom des bases de palier suit la convention du pilote —
+ « _upgrade_ » — et la liste des paliers se déduit de la
+ cible et du nombre d'entrées `state_4_*_odoo_lst`. On ne devine donc
+ rien : on relit ce que la migration a écrit.
+ """
+ base = dct.get("config_database_name")
+ if not base:
+ return []
+ try:
+ cible = int(float(dct.get("target_odoo_version") or 0))
+ except (TypeError, ValueError):
+ return []
+ total = max(
+ [
+ len(valeur)
+ for cle, valeur in dct.items()
+ if cle.startswith("state_4_")
+ and cle.endswith("_odoo_lst")
+ and isinstance(valeur, list)
+ ]
+ or [0]
+ )
+ if not total or not cible:
+ return [(None, base)]
+ lst_version = list(range(cible - total + 1, cible + 1))
+ depart = lst_version[0] - 1
+ return [(depart, base)] + [
+ (version, f"{base}_upgrade_{version}") for version in lst_version
+ ]
+
+
+META_SQL = """
+SELECT 'odoo', latest_version FROM ir_module_module WHERE name = 'base'
+UNION ALL SELECT 'view', count(*)::text FROM ir_ui_view
+UNION ALL SELECT 'view_cow', count(*)::text FROM ir_ui_view
+ WHERE website_id IS NOT NULL
+UNION ALL SELECT 'menu', count(*)::text FROM ir_ui_menu
+UNION ALL SELECT 'action', count(*)::text FROM ir_act_window
+UNION ALL SELECT 'attachment', count(*)::text FROM ir_attachment
+UNION ALL SELECT 'attachment_stored', count(*)::text FROM ir_attachment
+ WHERE store_fname IS NOT NULL
+UNION ALL SELECT 'language', count(*)::text FROM res_lang WHERE active
+"""
+
+
+def table_counts(database):
+ """{table: lignes} pour toutes les tables. Une seule requête.
+
+ Construite côté serveur puis exécutée d'un bloc : huit cents requêtes
+ séparées coûteraient huit cents allers-retours, là où celle-ci prend
+ quatre dixièmes de seconde — mesuré sur une base de 890 tables.
+ """
+ fabrique = run_psql(
+ database,
+ "SELECT string_agg("
+ "format('SELECT %L t, count(*) n FROM %I', table_name, table_name),"
+ " ' UNION ALL ') FROM information_schema.tables"
+ " WHERE table_schema = 'public' AND table_type = 'BASE TABLE'",
+ )
+ if not fabrique or not fabrique[0][0]:
+ return {}
+ lignes = run_psql(database, fabrique[0][0])
+ if lignes is None:
+ return {}
+ return {
+ nom: int(nombre)
+ for nom, nombre in (ligne[:2] for ligne in lignes)
+ if nombre.isdigit()
+ }
+
+
+def missing_files(database, lst_store_fname):
+ """Les pièces jointes dont le FICHIER a disparu du filestore.
+
+ Une base peut référencer des milliers de pièces jointes dont le
+ contenu n'a pas suivi le clonage. Rien ne le signale : la page se
+ charge, l'image est vide.
+ """
+ racine = os.path.join(FILESTORE, database)
+ if not os.path.isdir(racine):
+ return None
+ return [
+ nom
+ for nom in lst_store_fname
+ if nom and not os.path.isfile(os.path.join(racine, nom))
+ ]
+
+
+def inspect(database):
+ """L'état d'une base, en lecture seule. `exists` False si absente."""
+ etat = {"database": database, "exists": False}
+ meta = run_psql(database, META_SQL)
+ if meta is None:
+ return etat
+ etat["exists"] = True
+ dct_meta = {ligne[0]: ligne[1] for ligne in meta if len(ligne) > 1}
+ etat["odoo"] = (dct_meta.get("odoo") or "?").rsplit(".", 2)[0]
+ for cle in (
+ "view",
+ "view_cow",
+ "menu",
+ "action",
+ "attachment",
+ "attachment_stored",
+ "language",
+ ):
+ etat[cle] = int(dct_meta.get(cle) or 0)
+
+ modules = run_psql(
+ database, "SELECT name, state FROM ir_module_module ORDER BY name"
+ )
+ etat["module"] = {
+ nom: statut for nom, statut in (m[:2] for m in modules or [])
+ }
+ etat["installed"] = sorted(
+ nom for nom, statut in etat["module"].items() if statut == "installed"
+ )
+ 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 modèles sans table : abstraits, mixins et vues SQL pour la
+ # plupart — mais leur NOMBRE qui bouge d'un palier à l'autre dit
+ # quelque chose, alors que la liste brute ne dit rien.
+ tables = set(etat["table"])
+ etat["model_without_table"] = [
+ m for m in etat["model"] if m.replace(".", "_") not in tables
+ ]
+
+ stockees = run_psql(
+ database,
+ "SELECT DISTINCT store_fname FROM ir_attachment"
+ " WHERE store_fname IS NOT NULL",
+ )
+ absents = missing_files(database, [ligne[0] for ligne in stockees or []])
+ etat["attachment_missing"] = None if absents is None else len(absents)
+ return etat
+
+
+def compare(avant, apres):
+ """Ce qui a été gagné et ce qui a été perdu entre deux paliers."""
+ if not avant.get("exists") or not apres.get("exists"):
+ return {"unavailable": True}
+ inst_avant, inst_apres = set(avant["installed"]), set(apres["installed"])
+ mod_avant, mod_apres = set(avant["model"]), set(apres["model"])
+ tbl_avant, tbl_apres = avant["table"], apres["table"]
+
+ lignes_perdues = []
+ for table, nombre in sorted(tbl_avant.items()):
+ reste = tbl_apres.get(table)
+ if reste is None and nombre:
+ lignes_perdues.append((table, nombre, 0))
+ elif reste is not None and reste < nombre:
+ lignes_perdues.append((table, nombre, reste))
+ lignes_gagnees = [
+ (table, tbl_avant.get(table, 0), nombre)
+ for table, nombre in sorted(tbl_apres.items())
+ if nombre > tbl_avant.get(table, 0)
+ ]
+ return {
+ "modules_lost": sorted(inst_avant - inst_apres),
+ "modules_gained": sorted(inst_apres - inst_avant),
+ "models_lost": sorted(mod_avant - mod_apres),
+ "models_gained": sorted(mod_apres - mod_avant),
+ "rows_lost": lignes_perdues,
+ "rows_gained": lignes_gagnees,
+ "renamed": probable_renames(lignes_perdues, lignes_gagnees),
+ "delta": {
+ cle: apres.get(cle, 0) - avant.get(cle, 0)
+ for cle in (
+ "view",
+ "view_cow",
+ "menu",
+ "action",
+ "attachment",
+ "language",
+ )
+ },
+ }
+
+
+def probable_renames(perdues, gagnees):
+ """Rapprocher une table disparue d'une table apparue au même compte.
+
+ Odoo renomme des tables entre versions — `mail_channel` est devenu
+ `discuss_channel` en 17. Sans ce rapprochement, chaque renommage se
+ lit comme une perte de données doublée d'une apparition, et l'on
+ cherche un dégât là où il n'y en a pas.
+ """
+ disparues = {
+ table: avant for table, avant, apres in perdues if apres == 0 and avant
+ }
+ apparues = {
+ table: apres for table, avant, apres in gagnees if avant == 0 and apres
+ }
+ couples = []
+ for table, nombre in sorted(disparues.items()):
+ for autre, combien in sorted(apparues.items()):
+ if combien == nombre and looks_renamed(table, autre):
+ couples.append((table, autre, nombre))
+ del apparues[autre]
+ break
+ return couples
+
+
+RENAME_RATIO = 0.75
+
+
+def looks_renamed(un, deux):
+ """Les deux noms se ressemblent-ils assez pour être le même sujet ?
+
+ Deux garde-fous ont été essayés et rejetés, mesurés sur une vraie
+ migration. Le seul nombre de lignes accouplait
+ `account_account_tag_account_tax_template_rel` à `dms_directory` : les
+ deux comptaient sept lignes. Un mot commun d'au moins cinq lettres
+ accouplait `cleanup_purge_wizard_menu` à
+ `cleanup_create_indexes_line` — « cleanup » ne dit rien.
+
+ La ressemblance d'ENSEMBLE tranche : `muk_dms_directory` et
+ `dms_directory` se ressemblent à 87 %, `website_redirect` et
+ `website_rewrite` à 83 %, tandis que les faux couples restent sous
+ 50 %.
+ """
+ import difflib
+
+ return difflib.SequenceMatcher(None, un, deux).ratio() >= RENAME_RATIO
+
+
+def survey(dct, echo=None):
+ """Inspecter toute la chaîne, du départ à l'arrivée."""
+ lst = []
+ for version, database in chain(dct):
+ if echo:
+ echo(f"{database} …")
+ etat = inspect(database)
+ etat["version"] = version
+ lst.append(etat)
+ return lst
+
+
+def overall(lst_snapshot):
+ """La comparaison du PREMIER au DERNIER palier.
+
+ Elle ne se déduit pas des comparaisons deux à deux : un module retiré
+ en 15 puis remis en 17 n'a rien perdu du tout, et l'addition des
+ étapes le compterait deux fois.
+ """
+ presents = [x for x in lst_snapshot if x.get("exists")]
+ if len(presents) < 2:
+ return {"unavailable": True}
+ return compare(presents[0], presents[-1])
+
+
+def render_text(lst_snapshot, colour=None, limit=8):
+ """Le rapport complet. C'est aussi le repli du plein écran."""
+ from script.todo.migration_status import paint, supports_colour
+
+ if colour is None:
+ colour = supports_colour()
+ lignes = [f"📐 {t('Migration quality, step by step')}"]
+ for etat in lst_snapshot:
+ if not etat.get("exists"):
+ lignes.append(
+ f" ⚠️ {etat['database']} : {t('database not found')}"
+ )
+ continue
+ lignes.append(
+ f" {paint(f'{etat['odoo']:<6}', 'step', colour)}"
+ f" {etat['database']:<34}"
+ f" {len(etat['installed']):>4} {t('modules')}"
+ f" · {len(etat['model']):>4} {t('models')}"
+ f" · {etat['view']:>5} {t('views')}"
+ f" · {etat['attachment']:>6} {t('attachments')}"
+ )
+ if etat.get("attachment_missing"):
+ lignes.append(
+ f" {paint('❌', 'fail', colour)}"
+ f" {etat['attachment_missing']}"
+ f" {t('attachment files missing from the filestore')}"
+ )
+
+ presents = [x for x in lst_snapshot if x.get("exists")]
+ for avant, apres in zip(presents, presents[1:]):
+ lignes.append(f"\n🔀 {avant['odoo']} → {apres['odoo']}")
+ lignes.extend(render_compare(compare(avant, apres), colour, limit))
+
+ lignes.append(
+ f"\n🏁 {t('From start to finish')} :"
+ f" {presents[0]['odoo'] if presents else '?'}"
+ f" → {presents[-1]['odoo'] if presents else '?'}"
+ )
+ lignes.extend(render_compare(overall(lst_snapshot), colour, limit))
+ return "\n".join(lignes)
+
+
+def render_compare(diff, colour, limit=8):
+ """Les gains et les pertes d'un palier, en quelques lignes."""
+ from script.todo.migration_status import paint
+
+ if diff.get("unavailable"):
+ return [f" {t('not comparable: a database is missing')}"]
+ lignes = []
+ for cle, symbole, teinte in (
+ ("modules_lost", "−", "fail"),
+ ("modules_gained", "+", "ok"),
+ ):
+ lst = diff[cle]
+ if lst:
+ lignes.append(
+ f" {paint(symbole, teinte, colour)} {len(lst)}"
+ f" {t('modules')} : {', '.join(lst[:limit])}"
+ + (" …" if len(lst) > limit else "")
+ )
+ for cle, symbole, teinte in (
+ ("models_lost", "−", "fail"),
+ ("models_gained", "+", "ok"),
+ ):
+ lst = diff[cle]
+ if lst:
+ lignes.append(
+ f" {paint(symbole, teinte, colour)} {len(lst)}"
+ f" {t('models')} : {', '.join(lst[:limit])}"
+ + (" …" if len(lst) > limit else "")
+ )
+ if diff["renamed"]:
+ lignes.append(
+ f" ↻ {len(diff['renamed'])} {t('probable table rename(s)')} :"
+ f" {', '.join(f'{a} → {b}' for a, b, _n in diff['renamed'][:4])}"
+ )
+ perdues = diff["rows_lost"]
+ if perdues:
+ # LE signal qui compte : un module en moins se voit, une table qui
+ # passe de quatre mille lignes à zéro ne se voit nulle part.
+ #
+ # Une table probablement renommée reste dans la LISTE, annotée. L'en
+ # retirer était le vrai danger : un rapprochement faux — et il y en
+ # a eu — aurait fait disparaître une perte réelle du rapport.
+ vers = {a: b for a, b, _n in diff["renamed"]}
+ lignes.append(
+ f" {paint('▼', 'fail', colour)} {len(perdues)}"
+ f" {t('table(s) lost rows')} :"
+ )
+ for table, avant, apres in perdues[:limit]:
+ note = (
+ f" ↻ {t('probably renamed to')} {vers[table]}"
+ if table in vers
+ else ""
+ )
+ lignes.append(f" {table:<40} {avant:>8} → {apres}{note}")
+ delta = diff["delta"]
+ lignes.append(
+ " "
+ + " ".join(
+ f"{cle} {valeur:+d}" for cle, valeur in delta.items() if valeur
+ )
+ or " ="
+ )
+ return lignes
+
+
+def main(argv=None):
+ import argparse
+
+ parser = argparse.ArgumentParser(
+ description=(
+ "Compare every database a migration left behind, step by step,"
+ " and report what was gained and what was lost."
+ )
+ )
+ parser.add_argument(
+ "-f",
+ "--file",
+ default=DEFAULT_PROGRESSION,
+ help="migration progression file",
+ )
+ parser.add_argument(
+ "--text",
+ action="store_true",
+ help="print the report instead of opening the full screen",
+ )
+ parser.add_argument("--limit", type=int, default=8)
+ config = parser.parse_args(argv)
+
+ dct = read_progression(config.file)
+ if not chain(dct):
+ print(f"ℹ️ {t('No migration in progress.')}")
+ return 0
+ lst = survey(dct, echo=lambda texte: print(f"⧖ {texte}", flush=True))
+ if not config.text:
+ try:
+ from script.analyse.check_migration_quality_tui import run_tui
+ except Exception:
+ run_tui = None
+ if run_tui and run_tui(lst):
+ return 0
+ print(render_text(lst, limit=config.limit))
+ manque = [x for x in lst if not x.get("exists")]
+ perdu = overall(lst)
+ trouvailles = bool(manque) or bool(
+ not perdu.get("unavailable")
+ and (perdu["modules_lost"] or perdu["rows_lost"])
+ )
+ return 1 if trouvailles else 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/script/analyse/check_migration_quality_tui.py b/script/analyse/check_migration_quality_tui.py
new file mode 100644
index 0000000..1f570a2
--- /dev/null
+++ b/script/analyse/check_migration_quality_tui.py
@@ -0,0 +1,225 @@
+#!/usr/bin/env python3
+# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+
+"""La qualité d'une migration, palier par palier, en plein écran.
+
+Le rapport texte dit tout d'un coup : sur six paliers il fait plusieurs
+centaines de lignes, et ce qu'on cherche — quel palier a perdu quoi — se
+trouve quelque part au milieu. Un écran qui se parcourt règle cela.
+
+Les données viennent de `check_migration_quality`, comme le rapport texte.
+Deux assemblages sépareraient les deux vues, et l'on finirait par lire deux
+états contradictoires de la même migration.
+"""
+
+import os
+import sys
+
+sys.path.append(
+ os.path.normpath(os.path.join(os.path.dirname(__file__), "..", ".."))
+)
+
+from script.analyse import check_migration_quality as quality # noqa: E402
+from script.todo import migration_status as status # noqa: E402
+
+try:
+ from script.todo.todo_i18n import t
+except Exception: # pragma: no cover - repli si i18n indisponible
+
+ def t(key: str) -> str:
+ return key
+
+
+CSS = """
+Screen { layout: vertical; }
+#head { height: 3; padding: 0 1; background: $panel; color: $text; }
+#body { height: 1fr; }
+#left { width: 40; border-right: solid $accent; }
+#pane { width: 1fr; padding: 0 1; }
+"""
+
+
+def rows(lst_snapshot):
+ """Un palier par ligne, puis le bilan d'ensemble en dernier.
+
+ Le bilan EN DERNIER et non en tête : on descend la liste comme on a
+ vécu la migration, et la question « qu'est-ce qu'il en reste » se pose
+ une fois qu'on a vu le chemin.
+ """
+ lst = []
+ presents = [x for x in lst_snapshot if x.get("exists")]
+ for index, etat in enumerate(lst_snapshot):
+ if not etat.get("exists"):
+ lst.append(
+ {
+ "kind": "missing",
+ "label": f"⚠️ {etat['database'][:30]}",
+ "detail": "",
+ "data": etat,
+ }
+ )
+ continue
+ precedent = None
+ rang = presents.index(etat)
+ if rang:
+ precedent = presents[rang - 1]
+ diff = quality.compare(precedent, etat) if precedent else None
+ perdu = (
+ 0
+ if diff is None or diff.get("unavailable")
+ else len(diff["rows_lost"])
+ )
+ lst.append(
+ {
+ "kind": "step",
+ "label": f"{etat['odoo']:<6} {etat['database'][:24]}",
+ "detail": str(perdu) if perdu else "",
+ "data": etat,
+ "diff": diff,
+ }
+ )
+ if len(presents) >= 2:
+ lst.append(
+ {
+ "kind": "overall",
+ "label": f"🏁 {presents[0]['odoo']} → {presents[-1]['odoo']}",
+ "detail": "",
+ "data": None,
+ "diff": quality.overall(lst_snapshot),
+ }
+ )
+ return lst
+
+
+def head_text(lst_snapshot):
+ presents = [x for x in lst_snapshot if x.get("exists")]
+ manquants = len(lst_snapshot) - len(presents)
+ if not presents:
+ return t("No migration in progress.")
+ return (
+ f"{presents[0]['database']} · {len(presents)} {t('steps')}"
+ f" · {presents[0]['odoo']} → {presents[-1]['odoo']}"
+ + (
+ f" · ⚠️ {manquants} {t('database not found')}"
+ if manquants
+ else ""
+ )
+ )
+
+
+def pane_text(lst_snapshot, row, colour=False):
+ """Le détail du palier choisi, ou le bilan d'ensemble."""
+ if row is None:
+ return t("Nothing to show yet.")
+ if row["kind"] == "missing":
+ return f"⚠️ {row['data']['database']} : {t('database not found')}"
+ lignes = []
+ etat = row["data"]
+ if etat:
+ lignes.append(
+ status.paint(f"{etat['odoo']} {etat['database']}", "step", colour)
+ )
+ lignes.append("")
+ for libelle, valeur in (
+ (t("modules"), len(etat["installed"])),
+ (t("models"), len(etat["model"])),
+ (t("views"), etat["view"]),
+ (" · COW", etat["view_cow"]),
+ (t("menus"), etat["menu"]),
+ (t("attachments"), etat["attachment"]),
+ ):
+ lignes.append(f" {libelle:<28} {valeur:>7}")
+ if etat.get("attachment_missing"):
+ lignes.append(
+ f" {status.paint('❌ ' + t('attachment files missing from'
+ ' the filestore'), 'fail', colour)}"
+ f" {etat['attachment_missing']}"
+ )
+ lignes.append("")
+ diff = row.get("diff")
+ if diff:
+ lignes.extend(quality.render_compare(diff, colour, limit=40))
+ return "\n".join(lignes)
+
+
+def build_app(lst_snapshot):
+ """Textual est importé ICI : le module reste testable sans lui."""
+ from rich.text import Text
+ from textual.app import App, ComposeResult
+ from textual.containers import Horizontal, VerticalScroll
+ from textual.widgets import DataTable, Footer, Header, Static
+
+ class QualityApp(App):
+ CSS = globals()["CSS"]
+ BINDINGS = [("q,escape", "quit", t("Quit"))]
+
+ def __init__(self, lst_snapshot):
+ super().__init__()
+ self.lst_snapshot = lst_snapshot
+ self.lst_row = rows(lst_snapshot)
+ self.index = 0
+
+ def compose(self) -> ComposeResult:
+ yield Header()
+ yield Static("", id="head")
+ with Horizontal(id="body"):
+ yield DataTable(id="left", cursor_type="row")
+ with VerticalScroll(id="pane"):
+ yield Static("", id="content")
+ yield Footer()
+
+ def on_mount(self):
+ self.title = t("Migration quality, step by step")
+ table = self.query_one("#left", DataTable)
+ table.add_columns(t("steps"), "▼")
+ for row in self.lst_row:
+ table.add_row(row["label"][:34], row["detail"])
+ self.query_one("#head", Static).update(
+ head_text(self.lst_snapshot)
+ )
+ self._show()
+
+ def _show(self):
+ row = (
+ self.lst_row[self.index]
+ if self.lst_row and self.index < len(self.lst_row)
+ else None
+ )
+ self.query_one("#content", Static).update(
+ Text.from_ansi(pane_text(self.lst_snapshot, row, colour=True))
+ )
+
+ def on_data_table_row_highlighted(self, event):
+ if event.data_table.id == "left" and self.lst_row:
+ self.index = event.cursor_row
+ self._show()
+
+ return QualityApp(lst_snapshot)
+
+
+def run_tui(lst_snapshot, run_app=True):
+ """Ouvrir l'écran. False si l'on n'a pas pu — et alors on DIT pourquoi."""
+ if not lst_snapshot:
+ return False
+ if not sys.stdout.isatty():
+ print(f"ℹ️ {t('Not a terminal: showing the text report instead.')}")
+ return False
+ try:
+ from script.todo import textual_setup
+ except Exception:
+ textual_setup = None
+ if textual_setup and not textual_setup.ensure():
+ return False
+ try:
+ app = build_app(lst_snapshot)
+ except ImportError:
+ print(
+ f"ℹ️ {t('Textual is missing from this interpreter:')}"
+ f" {sys.executable}"
+ )
+ return False
+ if not run_app:
+ return app
+ app.run()
+ return True
diff --git a/script/todo/migration_status_tui.py b/script/todo/migration_status_tui.py
index f823d2f..8311fb4 100644
--- a/script/todo/migration_status_tui.py
+++ b/script/todo/migration_status_tui.py
@@ -234,6 +234,34 @@ def apply_width(app, delta):
app.query_one("#left").styles.width = app.left_width
+def fill_table(app):
+ """(Re)construire la liste de gauche depuis les données courantes."""
+ table = app.query_one("#left")
+ table.clear(columns=True)
+ table.add_columns(t("Test results"), "#", "❌")
+ for row in app.lst_row:
+ table.add_row(row["label"][:38], row["detail"], row.get("severe", ""))
+ table.styles.width = app.left_width
+ app.query_one("#head").update(head_text(app.dct))
+
+
+def show_pane(app):
+ """Peindre le détail de la ligne choisie.
+
+ `from_ansi` fait DEUX choses : il rend les couleurs, et il traite le
+ reste comme du texte LITTÉRAL. Sans lui, une commande contenant
+ « [1] » passait pour du balisage Rich et disparaissait sans un mot.
+ """
+ from rich.text import Text
+
+ row = current_row(app.lst_row, app.index)
+ app.query_one("#content").update(
+ Text.from_ansi(
+ pane_text(app.dct, row, colour=True, show_log=app.show_log)
+ )
+ )
+
+
def build_app(dct, path=None):
"""Textual est importé ICI, pas au chargement du module.
@@ -241,7 +269,6 @@ def build_app(dct, path=None):
Textual, et c'est aussi ce qui permet à l'appelant de retomber sur le
rapport texte plutôt que d'échouer.
"""
- from rich.text import Text
from textual.app import App, ComposeResult
from textual.containers import Horizontal, VerticalScroll
from textual.widgets import DataTable, Footer, Header, Static
@@ -253,6 +280,7 @@ def build_app(dct, path=None):
("r", "refresh", t("Refresh")),
("l", "toggle_log", t("Logs")),
("p", "cycle_panels", t("Panels")),
+ ("k", "quality", t("Quality")),
("plus,equal", "wider", t("Wider")),
("minus,underscore", "narrower", t("Narrower")),
]
@@ -282,39 +310,14 @@ def build_app(dct, path=None):
def on_mount(self):
self.title = t("Migration state")
- self._fill_table()
+ fill_table(self)
apply_panels(self)
- self._show()
-
- def _fill_table(self):
- table = self.query_one("#left", DataTable)
- table.clear(columns=True)
- table.add_columns(t("Test results"), "#", "❌")
- for row in self.lst_row:
- table.add_row(
- row["label"][:38], row["detail"], row.get("severe", "")
- )
- table.styles.width = self.left_width
- self.query_one("#head", Static).update(head_text(self.dct))
-
- def _show(self):
- row = current_row(self.lst_row, self.index)
- # `from_ansi` fait DEUX choses : il rend les couleurs, et il
- # traite le reste comme du texte LITTÉRAL. Sans lui, une
- # commande contenant « [1] » passait pour du balisage Rich et
- # disparaissait de l'écran sans que rien ne le signale.
- self.query_one("#content", Static).update(
- Text.from_ansi(
- pane_text(
- self.dct, row, colour=True, show_log=self.show_log
- )
- )
- )
+ show_pane(self)
def on_data_table_row_highlighted(self, event):
if event.data_table.id == "left" and self.lst_row:
self.index = event.cursor_row
- self._show()
+ show_pane(self)
def action_refresh(self):
"""Relire le disque. La migration écrit PENDANT qu'on regarde.
@@ -329,12 +332,41 @@ def build_app(dct, path=None):
self.dct = neuf
self.lst_row = rows(self.dct)
self.index = min(self.index, max(0, len(self.lst_row) - 1))
- self._fill_table()
- self._show()
+ fill_table(self)
+ show_pane(self)
+
+ def action_quality(self):
+ """Ouvrir le rapport de qualité, sans quitter celui-ci.
+
+ Les deux écrans répondent à deux questions voisines : « où en
+ est-on » et « qu'a-t-on gagné ou perdu en chemin ». Les
+ séparer par une touche plutôt que par deux commandes à retenir
+ est ce qui les rend utilisables ensemble.
+
+ `suspend` rend le terminal à l'autre plein écran : deux
+ applications Textual ne peuvent pas peindre le même écran en
+ même temps.
+ """
+ from script.analyse import check_migration_quality as quality
+ from script.analyse.check_migration_quality_tui import (
+ run_tui as run_quality,
+ )
+
+ dct = quality.read_progression()
+ if not quality.chain(dct):
+ self.notify(t("No migration in progress."))
+ return
+ with self.suspend():
+ lst = quality.survey(
+ dct, echo=lambda texte: print(f"⧖ {texte}", flush=True)
+ )
+ if not run_quality(lst):
+ print(quality.render_text(lst))
+ input(f"💬 {t('press to continue')} : ")
def action_toggle_log(self):
self.show_log = not self.show_log
- self._show()
+ show_pane(self)
def action_cycle_panels(self):
"""Trois états, du plus complet au plus dépouillé.
diff --git a/script/todo/todo.py b/script/todo/todo.py
index ae148e7..4614ffa 100755
--- a/script/todo/todo.py
+++ b/script/todo/todo.py
@@ -7712,6 +7712,8 @@ class TODO:
)
},
{"prompt_description": t("Studio and hand-made x_ fields")},
+ {"section": t("Migration")},
+ {"prompt_description": t("Quality of a migration, step by step")},
]
help_info = self.fill_help_info(choices)
@@ -7726,9 +7728,41 @@ class TODO:
self.execute_analyse_view_custom()
elif status == "3":
self.execute_analyse_custom_field()
+ elif status == "4":
+ self.execute_analyse_migration_quality()
else:
print(t("Command not found !"))
+ def execute_analyse_migration_quality(self):
+ """Ce qu'une migration a gagné et perdu, palier par palier.
+
+ Appelé comme les autres analyses — même interpréteur, aucun
+ sous-processus — mais l'écran plein est ouvert par l'outil
+ lui-même, qui sait retomber sur son rapport texte s'il ne peut pas.
+
+ Lecture seule de bout en bout : les bases de palier sont parfois la
+ seule copie qui reste d'un état intermédiaire.
+ """
+ from script.analyse import check_migration_quality as quality
+
+ dct = quality.read_progression()
+ if not quality.chain(dct):
+ print(f"\nℹ️ {t('No migration in progress.')}")
+ return
+ try:
+ lst = quality.survey(
+ dct, echo=lambda texte: print(f"⧖ {texte}", flush=True)
+ )
+ except Exception as exc:
+ print(f"❌ {t('Analysis failed: ')}{exc}")
+ return
+ try:
+ from script.analyse.check_migration_quality_tui import run_tui
+ except Exception:
+ run_tui = None
+ if not (run_tui and run_tui(lst)):
+ print(quality.render_text(lst))
+
def _analyse_select_source(self):
"""(est_une_sauvegarde, cible), ou None si l'on renonce.
diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py
index f11e2e9..3ddea51 100644
--- a/script/todo/todo_i18n.py
+++ b/script/todo/todo_i18n.py
@@ -5691,6 +5691,62 @@ TRANSLATIONS = {
"fr": "détail seul",
"en": "detail only",
},
+ "errors in the log": {
+ "fr": "erreurs dans le journal",
+ "en": "errors in the log",
+ },
+ "Migration quality, step by step": {
+ "fr": "Qualité de la migration, palier par palier",
+ "en": "Migration quality, step by step",
+ },
+ "database not found": {
+ "fr": "base introuvable",
+ "en": "database not found",
+ },
+ "attachment files missing from the filestore": {
+ "fr": "fichiers de pièces jointes absents du filestore",
+ "en": "attachment files missing from the filestore",
+ },
+ "From start to finish": {
+ "fr": "Du départ à l'arrivée",
+ "en": "From start to finish",
+ },
+ "not comparable: a database is missing": {
+ "fr": "non comparable : une base manque",
+ "en": "not comparable: a database is missing",
+ },
+ "probable table rename(s)": {
+ "fr": "renommage(s) de table probable(s)",
+ "en": "probable table rename(s)",
+ },
+ "table(s) lost rows": {
+ "fr": "table(s) ont perdu des lignes",
+ "en": "table(s) lost rows",
+ },
+ "attachments": {
+ "fr": "pièces jointes",
+ "en": "attachments",
+ },
+ "probably renamed to": {
+ "fr": "probablement renommée en",
+ "en": "probably renamed to",
+ },
+ "steps": {
+ "fr": "paliers",
+ "en": "steps",
+ },
+ "Migration": {
+ "fr": "Migration",
+ "en": "Migration",
+ },
+ "Quality of a migration, step by step": {
+ "fr": "Qualité d'une migration, palier par palier",
+ "en": "Quality of a migration, step by step",
+ },
+ "Quality": {
+ "fr": "Qualité",
+ "en": "Quality",
+ },
"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
new file mode 100644
index 0000000..fd26953
--- /dev/null
+++ b/test/test_check_migration_quality.py
@@ -0,0 +1,399 @@
+#!/usr/bin/env python3
+# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+
+"""Ce qu'une migration gagne et ce qu'elle perd, palier par palier.
+
+La question qu'on se pose après six paliers n'est pas « a-t-elle fini » —
+le journal le dit — mais « qu'est-ce qui a changé en chemin ». Une
+migration laisse une base PAR PALIER, et elles existent toutes encore : on
+les compare côte à côte plutôt que de rejouer quoi que ce soit.
+
+Le point le plus délicat est le rapprochement des tables renommées. Deux
+garde-fous ont été essayés et rejetés SUR UNE VRAIE MIGRATION avant celui
+qui tient, et un faux rapprochement ne serait pas une coquetterie : il
+ferait DISPARAÎTRE une perte réelle du rapport. D'où la règle qui compte
+plus que tout ici — une perte est toujours listée, jamais retirée.
+"""
+
+import io
+import os
+import sys
+import unittest
+from contextlib import redirect_stdout
+
+REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+sys.path.insert(0, os.path.join(REPO, "script", "analyse"))
+
+from script.analyse import check_migration_quality as quality # noqa: E402
+from script.analyse import check_migration_quality_tui as qtui # noqa: E402
+from script.todo import todo_i18n # noqa: E402
+
+
+def snapshot(**override):
+ etat = {
+ "database": "db",
+ "exists": True,
+ "odoo": "13.0",
+ "version": 13,
+ "installed": ["account", "sale"],
+ "module": {"account": "installed", "sale": "installed"},
+ "model": ["account.move", "sale.order"],
+ "table": {"account_move": 100, "sale_order": 20},
+ "model_without_table": [],
+ "view": 2000,
+ "view_cow": 40,
+ "menu": 400,
+ "action": 300,
+ "attachment": 5000,
+ "attachment_stored": 4000,
+ "attachment_missing": 0,
+ "language": 2,
+ }
+ etat.update(override)
+ return etat
+
+
+class Base(unittest.TestCase):
+ def setUp(self):
+ self.addCleanup(
+ setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
+ )
+ todo_i18n._current_lang = "en"
+
+
+class TestFindingTheChain(Base):
+ """Les bases se déduisent de ce que la migration a ÉCRIT.
+
+ Le pilote nomme ses bases de palier « _upgrade_ » et
+ retient sa cible. Redemander ces noms serait redemander ce qu'on sait.
+ """
+
+ PROGRESSION = {
+ "config_database_name": "test_neutralize",
+ "target_odoo_version": "18.0",
+ "state_4_upgrade_odoo_lst": [[], [], [], [], [], []],
+ }
+
+ def test_it_walks_from_the_start_to_the_target(self):
+ lst = quality.chain(self.PROGRESSION)
+ self.assertEqual(
+ [version for version, _db in lst], [12, 13, 14, 15, 16, 17, 18]
+ )
+
+ def test_the_first_one_is_the_origin_database(self):
+ # Le palier 12 n'a pas de base « _upgrade_12 » : c'est la base
+ # d'origine, celle qu'on a restaurée.
+ self.assertEqual(
+ quality.chain(self.PROGRESSION)[0][1], "test_neutralize"
+ )
+
+ def test_the_others_follow_the_naming_of_the_driver(self):
+ noms = [db for _v, db in quality.chain(self.PROGRESSION)]
+ self.assertIn("test_neutralize_upgrade_13", noms)
+ self.assertIn("test_neutralize_upgrade_18", noms)
+
+ def test_a_migration_not_started_yields_the_origin_alone(self):
+ lst = quality.chain(
+ {"config_database_name": "db", "target_odoo_version": "18.0"}
+ )
+ self.assertEqual(lst, [(None, "db")])
+
+ def test_no_progression_yields_nothing(self):
+ self.assertEqual(quality.chain({}), [])
+
+
+class TestWhatIsGainedAndLost(Base):
+ def test_a_module_uninstalled_is_a_loss(self):
+ diff = quality.compare(
+ snapshot(installed=["account", "sale"]),
+ snapshot(installed=["account"]),
+ )
+ self.assertEqual(diff["modules_lost"], ["sale"])
+ self.assertEqual(diff["modules_gained"], [])
+
+ def test_a_model_that_appears_is_a_gain(self):
+ diff = quality.compare(
+ snapshot(model=["account.move"]),
+ snapshot(model=["account.move", "account.edi"]),
+ )
+ self.assertEqual(diff["models_gained"], ["account.edi"])
+
+ def test_a_table_that_empties_is_reported(self):
+ # LE signal : un module en moins se voit, une table qui passe de
+ # cent lignes à zéro ne se voit nulle part.
+ diff = quality.compare(
+ snapshot(table={"account_invoice": 651}),
+ snapshot(table={"account_invoice": 0}),
+ )
+ self.assertEqual(diff["rows_lost"], [("account_invoice", 651, 0)])
+
+ def test_a_table_that_disappears_counts_as_emptied(self):
+ diff = quality.compare(
+ snapshot(table={"account_invoice": 651}), snapshot(table={})
+ )
+ self.assertEqual(diff["rows_lost"], [("account_invoice", 651, 0)])
+
+ def test_an_empty_table_that_disappears_is_not_a_loss(self):
+ # Rien à perdre : le signaler noierait les vraies pertes.
+ diff = quality.compare(snapshot(table={"vide": 0}), snapshot(table={}))
+ self.assertEqual(diff["rows_lost"], [])
+
+ def test_counts_move_with_their_sign(self):
+ diff = quality.compare(snapshot(view=2000), snapshot(view=3733))
+ self.assertEqual(diff["delta"]["view"], 1733)
+
+ def test_a_missing_database_is_not_comparable(self):
+ diff = quality.compare(snapshot(), {"exists": False})
+ self.assertTrue(diff["unavailable"])
+
+
+class TestNotCryingWolfOnRenames(Base):
+ """Deux garde-fous rejetés sur une VRAIE migration avant celui-ci."""
+
+ def test_the_row_count_alone_is_not_enough(self):
+ # Il accouplait `account_account_tag_account_tax_template_rel` à
+ # `dms_directory` : les deux comptaient sept lignes.
+ self.assertFalse(
+ quality.looks_renamed(
+ "account_account_tag_account_tax_template_rel", "dms_directory"
+ )
+ )
+
+ def test_a_shared_word_is_not_enough_either(self):
+ # « cleanup » ne dit rien, « users » non plus.
+ self.assertFalse(
+ quality.looks_renamed(
+ "cleanup_purge_wizard_menu", "cleanup_create_indexes_line"
+ )
+ )
+ self.assertFalse(
+ quality.looks_renamed(
+ "digest_tip_res_users_rel", "project_allowed_portal_users_rel"
+ )
+ )
+
+ def test_real_renames_are_recognised(self):
+ for un, deux in (
+ ("muk_dms_directory", "dms_directory"),
+ ("website_redirect", "website_rewrite"),
+ (
+ "account_invoice_purchase_order_rel",
+ "account_move_purchase_order_rel",
+ ),
+ ):
+ self.assertTrue(quality.looks_renamed(un, deux), (un, deux))
+
+ def test_a_rename_needs_the_same_row_count(self):
+ # Un nom qui se ressemble mais un compte qui change n'est pas un
+ # renommage : c'est un renommage ET une perte, qu'on ne fusionne pas.
+ diff = quality.compare(
+ snapshot(table={"muk_dms_directory": 10}),
+ snapshot(table={"dms_directory": 4}),
+ )
+ self.assertEqual(diff["renamed"], [])
+
+ def test_a_renamed_table_is_STILL_listed_as_lost(self):
+ """La règle qui compte plus que tout ici.
+
+ Retirer une perte parce qu'on croit à un renommage, c'est faire
+ disparaître du rapport ce qu'on est venu y chercher — et le
+ rapprochement s'est déjà trompé.
+ """
+ diff = quality.compare(
+ snapshot(table={"muk_dms_directory": 7}),
+ snapshot(table={"dms_directory": 7}),
+ )
+ self.assertEqual(
+ diff["renamed"], [("muk_dms_directory", "dms_directory", 7)]
+ )
+ self.assertIn(("muk_dms_directory", 7, 0), diff["rows_lost"])
+
+ def test_the_report_says_it_is_only_probable(self):
+ diff = quality.compare(
+ snapshot(table={"muk_dms_directory": 7}),
+ snapshot(table={"dms_directory": 7}),
+ )
+ texte = "\n".join(quality.render_compare(diff, colour=False))
+ self.assertIn("muk_dms_directory", texte)
+ self.assertIn("probably renamed", texte)
+
+
+class TestTheOverallReport(Base):
+ def test_it_compares_the_ENDS_not_the_sum_of_steps(self):
+ """Un module retiré en 15 puis remis en 17 n'a rien perdu.
+
+ L'addition des comparaisons deux à deux le compterait deux fois,
+ une en perte et une en gain, et le bilan mentirait dans les deux
+ sens à la fois.
+ """
+ lst = [
+ snapshot(odoo="12.0", installed=["a", "b"]),
+ snapshot(odoo="15.0", installed=["a"]),
+ snapshot(odoo="18.0", installed=["a", "b"]),
+ ]
+ bilan = quality.overall(lst)
+ self.assertEqual(bilan["modules_lost"], [])
+ self.assertEqual(bilan["modules_gained"], [])
+
+ def test_it_needs_two_databases(self):
+ self.assertTrue(quality.overall([snapshot()])["unavailable"])
+
+ def test_missing_databases_are_skipped_not_fatal(self):
+ lst = [
+ snapshot(odoo="12.0", installed=["a"]),
+ {"database": "absente", "exists": False},
+ snapshot(odoo="18.0", installed=["a", "b"]),
+ ]
+ self.assertEqual(quality.overall(lst)["modules_gained"], ["b"])
+
+
+class TestTheReportItself(Base):
+ def test_every_step_is_listed(self):
+ texte = quality.render_text(
+ [
+ snapshot(odoo="12.0", database="a"),
+ snapshot(odoo="13.0", database="b"),
+ ],
+ colour=False,
+ )
+ self.assertIn("12.0", texte)
+ self.assertIn("13.0", texte)
+
+ def test_a_missing_database_is_named_not_hidden(self):
+ texte = quality.render_text(
+ [snapshot(), {"database": "absente", "exists": False}],
+ colour=False,
+ )
+ self.assertIn("absente", texte)
+ self.assertIn("not found", texte)
+
+ def test_missing_attachment_files_are_surfaced(self):
+ # La trouvaille faite à la main sur une vraie migration : 254
+ # fichiers absents du filestore, que rien ne signalait.
+ texte = quality.render_text(
+ [snapshot(attachment_missing=254)], colour=False
+ )
+ self.assertIn("254", texte)
+
+ def test_it_ends_with_the_start_to_finish_comparison(self):
+ texte = quality.render_text(
+ [snapshot(odoo="12.0"), snapshot(odoo="18.0")], colour=False
+ )
+ self.assertIn("From start to finish", texte)
+ self.assertLess(texte.index("12.0"), texte.index("From start"))
+
+
+class TestItNeverWrites(Base):
+ """Les bases de palier sont parfois la seule copie d'un état."""
+
+ def test_the_connection_is_read_only_at_the_server(self):
+ import inspect
+
+ source = inspect.getsource(quality.run_psql)
+ self.assertIn("default_transaction_read_only=on", source)
+
+ def test_no_odoo_is_started(self):
+ # Six démarrages coûteraient une heure ET écriraient dans les
+ # bases. L'inspection en SQL prend moins d'une demi-seconde.
+ import inspect
+
+ source = inspect.getsource(quality)
+ for interdit in ("odoo_bin", "run.sh", "--update", "-u all"):
+ self.assertNotIn(interdit, source, interdit)
+
+
+class TestTheFullScreen(Base):
+ def test_one_row_per_step_plus_the_overall(self):
+ lst = [snapshot(odoo="12.0"), snapshot(odoo="18.0")]
+ lignes = qtui.rows(lst)
+ self.assertEqual(
+ [x["kind"] for x in lignes], ["step", "step", "overall"]
+ )
+
+ def test_the_overall_comes_LAST(self):
+ # On descend la liste comme on a vécu la migration ; « qu'en
+ # reste-t-il » se pose une fois le chemin vu.
+ lst = [snapshot(odoo="12.0"), snapshot(odoo="18.0")]
+ self.assertEqual(qtui.rows(lst)[-1]["kind"], "overall")
+
+ def test_a_missing_database_gets_its_own_row(self):
+ lignes = qtui.rows(
+ [snapshot(), {"database": "absente", "exists": False}]
+ )
+ self.assertIn("missing", [x["kind"] for x in lignes])
+
+ def test_the_column_counts_the_lost_tables(self):
+ lst = [
+ snapshot(odoo="12.0", table={"t": 100}),
+ snapshot(odoo="13.0", table={"t": 0}),
+ ]
+ self.assertEqual(qtui.rows(lst)[1]["detail"], "1")
+
+ def test_a_step_that_lost_nothing_leaves_it_empty(self):
+ lst = [snapshot(odoo="12.0"), snapshot(odoo="13.0")]
+ self.assertEqual(qtui.rows(lst)[1]["detail"], "")
+
+ def test_the_pane_uses_the_same_comparison_as_the_text(self):
+ import inspect
+
+ self.assertIn(
+ "quality.render_compare", inspect.getsource(qtui.pane_text)
+ )
+
+ def test_a_pipe_is_explained_rather_than_silent(self):
+ out = io.StringIO()
+ with redirect_stdout(out):
+ self.assertFalse(qtui.run_tui([snapshot()]))
+ self.assertTrue(out.getvalue().strip())
+
+ def test_nothing_to_show_stays_silent(self):
+ out = io.StringIO()
+ with redirect_stdout(out):
+ self.assertFalse(qtui.run_tui([]))
+ self.assertEqual(out.getvalue(), "")
+
+
+class TestWhereItIsOffered(Base):
+ def test_the_analyse_menu_offers_it(self):
+ chemin = os.path.join(REPO, "script", "todo", "todo.py")
+ with open(chemin) as handle:
+ texte = handle.read()
+ self.assertIn("Quality of a migration, step by step", texte)
+ self.assertIn("execute_analyse_migration_quality", texte)
+
+ def test_the_existing_menu_numbers_did_not_move(self):
+ # Quelqu'un connaît « 1 », « 2 », « 3 » : les décaler pour insérer
+ # une entrée au milieu se paierait à chaque usage.
+ import inspect
+
+ from script.todo.todo import TODO
+
+ source = inspect.getsource(TODO.prompt_execute_analyse)
+ self.assertLess(
+ source.index("Tables and database size"),
+ source.index("Quality of a migration"),
+ )
+
+ def test_the_state_screen_offers_it_too(self):
+ import inspect
+
+ from script.todo import migration_status_tui as stui
+
+ source = inspect.getsource(stui.build_app)
+ self.assertIn('"k", "quality"', source)
+ self.assertIn("check_migration_quality", source)
+
+ def test_it_hands_the_terminal_over(self):
+ # Deux applications Textual ne peuvent pas peindre le même écran.
+ import inspect
+
+ from script.todo import migration_status_tui as stui
+
+ source = inspect.getsource(stui.build_app)
+ debut = source.index("def action_quality")
+ self.assertIn("self.suspend()", source[debut : debut + 1200])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_migration_status.py b/test/test_migration_status.py
index a724295..53ccef3 100644
--- a/test/test_migration_status.py
+++ b/test/test_migration_status.py
@@ -953,7 +953,9 @@ class TestTheFullScreenColoursToo(Base):
def test_the_screen_goes_through_from_ansi(self):
import inspect
- source = inspect.getsource(tui.build_app)
+ # Le rendu est sorti de la classe pour la rendre testable : c'est
+ # `show_pane` qui porte maintenant les deux propriétés.
+ source = inspect.getsource(tui.show_pane)
self.assertIn("Text.from_ansi", source)
self.assertIn("colour=True", source)