From c95cf2e9d8b56135e43a6fbd3dbd55b08f06ae50 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 26 Aug 2026 07:46:32 -0400 Subject: [PATCH] =?UTF-8?q?[ADD]=20qualit=C3=A9=20de=20migration=20:=20ver?= =?UTF-8?q?dicts,=20sources,=20revue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le rapport comparait les paliers sans dire si la migration avait réussi, alors que les verdicts dorment déjà dans lst_event du journal de progression : des contrôles en échec y restent sans remonter nulle part. Trois sections s'ajoutent sous les paliers : les verdicts, rattachés au palier ODOO et non au compteur du pilote, décalé d'un rang ; où vivent les traces, car config.conf laisse logfile= vide et la sortie d'Odoo meurt avec le terminal ; et la revue, six étapes lançables par « r ». Le contrôle de résidus porte la même section sans toucher son code de sortie : un verdict vient du fichier, pas de la base. --- EN --- The report compared the tiers without saying whether the migration had succeeded, while the verdicts already sit in lst_event of the progression file: failed checks stay there and surface nowhere. Three sections are added below the tiers: the verdicts, tied to the ODOO tier and not to the driver counter, which is off by one; where the traces live, since config.conf leaves logfile= empty and Odoo's output dies with the terminal; and the review, six steps runnable with "r". The residue check carries the same section without touching its exit code: a verdict comes from the file, not from the database. Assisted-by: Claude Opus 5 (cherry picked from commit b05e0333c4b94d58eb794f09a2459e41d826c655) --- script/analyse/check_migration_quality.py | 164 +++++++ script/analyse/check_migration_quality_tui.py | 380 +++++++++++++++- script/analyse/check_migration_residue.py | 89 +++- script/todo/todo_i18n.py | 203 +++++++++ test/test_check_migration_quality.py | 420 +++++++++++++++++- test/test_monitoring.py | 134 ++++++ 6 files changed, 1372 insertions(+), 18 deletions(-) diff --git a/script/analyse/check_migration_quality.py b/script/analyse/check_migration_quality.py index 5dba90a..d9c4c17 100755 --- a/script/analyse/check_migration_quality.py +++ b/script/analyse/check_migration_quality.py @@ -87,6 +87,170 @@ def read_progression(path=DEFAULT_PROGRESSION): return {} +# Où la migration laisse une trace, et ce qu'on y trouve. Les DEUX +# chemins sont montrés à l'écran même quand le second n'existe pas : sans +# `logfile` dans config.conf, Odoo écrit sur le terminal et sa sortie est +# perdue à la fermeture. Ne rien dire laisserait chercher un fichier qui +# n'a jamais été écrit. +JOURNAL_ODOO = "log/odoo.log" +MOTIFS_ERREUR = ("CRITICAL", "ERROR", "Traceback") + + +def read_events(dct): + """Les verdicts que la migration a laissés, dans l'ordre où ils sont venus. + + `command_executed` dit ce qui a été LANCÉ ; `lst_event` dit ce que + cela a RENDU. C'est la seule trace persistante d'un échec : la sortie + d'Odoo, elle, part sur le terminal et disparaît avec lui. + """ + lst = [] + for brut in dct.get("lst_event") or []: + if not isinstance(brut, dict): + continue + try: + statut = int(brut.get("status") or 0) + except (TypeError, ValueError): + statut = 0 + lst.append( + { + "at": str(brut.get("at") or ""), + "step": str(brut.get("step") or ""), + "kind": str(brut.get("kind") or ""), + "name": str(brut.get("name") or ""), + "status": statut, + "detail": str(brut.get("detail") or ""), + } + ) + return lst + + +def failures(events): + """Ceux qui n'ont pas rendu zéro, et seulement ceux-là. + + On garde `kind == "test"` : les entrées `command` à 1 appartiennent au + dialogue du pilote — « voulez-vous effacer le module manquant » — et + signaler un choix comme un échec ferait ignorer la liste entière. + """ + return [e for e in events if e["status"] and e["kind"] == "test"] + + +def event_database(event): + """La base sur laquelle ce verdict portait, lue dans sa commande.""" + for mot in event.get("detail", "").split(): + if mot.startswith("test_") or "_upgrade_" in mot: + return mot + morceaux = event.get("detail", "").split("-d ") + if len(morceaux) > 1: + return morceaux[1].split()[0] + return "" + + +def event_step(event, database=""): + """Le palier Odoo que ce verdict concernait — « 14 », « 18 ». + + Le nom de la base le porte — « …_upgrade_14 » — et c'est plus sûr que + le champ `step`, dont la numérotation (« 4.1.I ») compte les ÉTAPES du + pilote et non les versions d'Odoo : elles sont décalées d'un rang. + """ + base = database or event_database(event) + if "_upgrade_" in base: + suffixe = base.rsplit("_upgrade_", 1)[1] + if suffixe.isdigit(): + return suffixe + return (event.get("step") or "").split(" ")[0][:5] + + +def log_sources(path=DEFAULT_PROGRESSION, journal=JOURNAL_ODOO): + """[(rôle, chemin, existe, ce qu'on y lit)] — la carte des traces. + + Documenter les chemins EST la fonctionnalité : il a fallu une question + pour découvrir que les verdicts vivaient dans `lst_event`, et que la + sortie d'Odoo n'était écrite nulle part. + """ + return [ + ( + t("Migration progression"), + path, + os.path.isfile(path), + t("steps, commands and their verdicts (lst_event)"), + ), + ( + t("Odoo log"), + journal, + os.path.isfile(journal), + t("set logfile= in config.conf, else output is lost"), + ), + ] + + +def scan_log(path=JOURNAL_ODOO, limite=12): + """{motif: compte} et les dernières lignes fautives d'un journal Odoo. + + On lit la QUEUE : un journal de migration pèse des dizaines de + mégaoctets et ce qu'on cherche est ce qui a échoué en dernier. + """ + rapport = { + "path": path, + "exists": os.path.isfile(path), + "counts": {}, + "lines": [], + } + if not rapport["exists"]: + return rapport + try: + with open(path, "rb") as handle: + handle.seek(0, os.SEEK_END) + debut = max(0, handle.tell() - 2_000_000) + handle.seek(debut) + queue = handle.read().decode("utf-8", "replace") + except OSError: + return rapport + for motif in MOTIFS_ERREUR: + rapport["counts"][motif] = queue.count(motif) + for ligne in queue.splitlines(): + if any(motif in ligne for motif in MOTIFS_ERREUR): + rapport["lines"].append(ligne.strip()[:200]) + rapport["lines"] = rapport["lines"][-limite:] + return rapport + + +# La revue : ce qu'on vérifie, dans l'ordre, et ce que chaque étape prouve. +# Elle est ÉCRITE ici plutôt que dans une documentation à côté, parce +# qu'une liste de contrôle qu'il faut aller chercher n'est pas suivie. +REVUE = ( + ( + "Did the migration reach the end?", + "lst_event, and the six state_4_*_lst flags at 6/6", + None, + ), + ( + "Does Odoo load the database?", + "./odoo_bin.sh shell -c ./config.conf -d {db} --no-http", + "shell", + ), + ( + "What did the migration leave behind?", + "script/analyse/check_migration_residue.py -d {db}", + "residue", + ), + ( + "Is this copy safe to open?", + "script/analyse/check_instance_state.py -d {db} --expect copy", + "state", + ), + ( + "Do the public pages still answer?", + "script/odoo/migration/smoke_public_url.py -d {db}", + "smoke", + ), + ( + "Is anything left that cleanup could not drop?", + "script/odoo/migration/database_cleanup.py -d {db}", + "cleanup", + ), +) + + def chain(dct): """[(version, base)] du départ à l'arrivée, dans l'ordre du parcours. diff --git a/script/analyse/check_migration_quality_tui.py b/script/analyse/check_migration_quality_tui.py index fc38097..c56a1da 100644 --- a/script/analyse/check_migration_quality_tui.py +++ b/script/analyse/check_migration_quality_tui.py @@ -13,6 +13,7 @@ Deux assemblages sépareraient les deux vues, et l'on finirait par lire deux """ import os +import subprocess import sys sys.path.append( @@ -30,6 +31,13 @@ except Exception: # pragma: no cover - repli si i18n indisponible return key +# Les commandes se lancent depuis la RACINE : les chemins qu'elles portent +# — « script/odoo/migration/… » — y sont relatifs, et les lancer d'ailleurs +# les rend introuvables. +REPO_ROOT = os.path.normpath( + os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..") +) + CSS = """ Screen { layout: vertical; } #head { height: 3; padding: 0 1; background: $panel; color: $text; } @@ -39,12 +47,17 @@ Screen { layout: vertical; } """ -def rows(lst_snapshot): - """Un palier par ligne, puis le bilan d'ensemble en dernier. +def rows(lst_snapshot, dct=None): + """Un palier par ligne, le bilan, puis les trois sections de revue. - 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. + Le bilan APRÈS les paliers 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. Ce qui suit — verdicts, où lire, + quoi vérifier — répond à « et maintenant ». + + `dct` se passe pour éviter la lecture du fichier de progression : + autrement la liste dépend de ce qu'une migration a laissé sur CE + poste, et deux exécutions ne rendent pas la même chose. """ lst = [] presents = [x for x in lst_snapshot if x.get("exists")] @@ -95,9 +108,129 @@ def rows(lst_snapshot): "diff": quality.overall(lst_snapshot), } ) + lst.extend(extra_rows(presents, dct)) return lst +def extra_rows(presents, dct=None): + """Les trois sections sous les paliers : succès, validation, revue. + + Elles vivent dans la MÊME table que les paliers plutôt que dans un + second panneau : on descend la colonne une seule fois, et l'ordre dit + la démarche — ce que la migration a rendu, où le lire, quoi vérifier + ensuite. + + Un séparateur n'est PAS sélectionnable au sens où il n'affiche qu'un + titre ; le laisser dans la liste garde l'index de la table aligné sur + celui des lignes, ce qu'une liste filtrée perdrait. + """ + dct = quality.read_progression() if dct is None else dct + cible = presents[-1]["database"] if presents else "" + lst = [] + + # ── Succès ── + evenements = quality.read_events(dct) + ratés = quality.failures(evenements) + lst.append( + { + "kind": "header", + "label": f"── {t('Verdicts')} ──", + "detail": str(len(ratés)) if ratés else "✅", + } + ) + if not evenements: + lst.append( + { + "kind": "verdict-none", + "label": f" {t('no verdict recorded')}", + "detail": "", + } + ) + for event in ratés: + base = quality.event_database(event) + # Le PALIER dans le libellé : « smoke_public_url » quatre fois de + # suite ne dit pas lequel a échoué, et c'est la seule chose qu'on + # veut savoir en parcourant la colonne. + palier = quality.event_step(event, base) + lst.append( + { + "kind": "verdict", + "label": f" ❌ {palier:<5} {event['name'][:18]}", + "detail": "▶", + "event": event, + "database": base or cible, + "command": rerun_command(event, base or cible), + } + ) + if evenements and not ratés: + lst.append( + { + "kind": "verdict-ok", + "label": f" ✅ {len(evenements)} {t('checks, all passed')}", + "detail": "", + } + ) + + # ── Validation ── + lst.append( + {"kind": "header", "label": f"── {t('Validation')} ──", "detail": ""} + ) + for role, chemin, existe, _quoi in quality.log_sources(): + lst.append( + { + "kind": "source", + "label": f" {'📄' if existe else '∅'} {role[:24]}", + "detail": "", + "path": chemin, + } + ) + lst.append( + { + "kind": "logscan", + "label": f" 🔎 {t('errors in the log')}", + "detail": "", + } + ) + + # ── Revue ── + lst.append( + {"kind": "header", "label": f"── {t('Review')} ──", "detail": ""} + ) + for rang, (question, commande, clef) in enumerate(quality.REVUE, 1): + lst.append( + { + "kind": "review", + "label": f" {rang}. {t(question)[:26]}", + "detail": "▶" if clef else "", + "question": question, + "command": ( + commande.format(db=cible) if cible and clef else "" + ), + } + ) + return lst + + +def rerun_command(event, database): + """La commande qui rejoue ce verdict, ou "" si on ne sait pas la refaire. + + On la RECONSTRUIT depuis le nom de l'outil plutôt que de rejouer le + `detail` tel quel : celui-ci porte le chemin d'un venv de palier, qui + n'est plus celui du checkout courant. + """ + if not database: + return "" + outils = { + "smoke_public_url": "script/odoo/migration/smoke_public_url.py", + "database_cleanup": "script/odoo/migration/database_cleanup.py", + "check_hidden_models": "script/odoo/migration/check_hidden_models.py", + } + chemin = outils.get(event.get("name", "")) + if not chemin: + return "" + return f"{chemin} -d {database}" + + def head_text(lst_snapshot): presents = [x for x in lst_snapshot if x.get("exists")] manquants = len(lst_snapshot) - len(presents) @@ -159,6 +292,183 @@ def statistics(etat, precedent): ] +EXTRA_KINDS = ( + "header", + "verdict", + "verdict-none", + "verdict-ok", + "source", + "logscan", + "review", +) + + +def extra_pane(row, colour=False): + """Le panneau des trois sections ajoutées sous les paliers.""" + genre = row["kind"] + if genre == "header": + return t("Pick a line below.") + if genre == "verdict": + return verdict_pane(row, colour) + if genre == "verdict-none": + return "\n".join( + [ + t("The migration recorded no verdict."), + "", + t("Verdicts live in lst_event of the progression file,"), + t("not in command_executed, which only lists what ran."), + ] + ) + if genre == "verdict-ok": + return t("Every recorded check returned zero.") + if genre == "source": + return source_pane(row, colour) + if genre == "logscan": + return logscan_pane(colour) + if genre == "review": + return review_pane(row, colour) + return "" + + +def verdict_pane(row, colour=False): + """Ce qu'un verdict raté dit, et ce que son code de retour signifie.""" + event = row.get("event") or {} + lignes = [ + status.paint(f"❌ {event.get('name')}", "fail", colour), + "", + # Le palier est la version d'ODOO, pas le compteur du pilote : + # « 4.1.I » désigne la première étape du quatrième bloc, et la + # migration en est alors au palier 14. Les afficher tous deux sous + # le même mot faisait lire « palier 4.1 », qui n'existe pas. + f" {t('step'):<10} {quality.event_step(event)}" + f" ({event.get('step')})", + f" {t('when'):<10} {event.get('at')[:19]}", + f" {t('status'):<10} {event.get('status')}", + "", + f" {t('what it ran')}", + f" {event.get('detail', '')[:150]}", + "", + ] + sens = { + "smoke_public_url": t( + "1 means a public page failed — not merely a finding." + ), + "database_cleanup": t( + "1 means leftovers remain that it could not drop." + ), + }.get(event.get("name"), "") + if sens: + lignes.append(f" {sens}") + lignes.append("") + if row.get("command"): + lignes.append( + status.paint( + f" ▶ {t('press r to run it again')}", "step", colour + ) + ) + lignes.append(f" {row['command']}") + lignes.append("") + lignes.append( + f" {t('The screen steps aside, the test takes the terminal,')}" + ) + lignes.append(f" {t('and you come back with Enter.')}") + else: + lignes.append(f" {t('No known way to replay this one.')}") + return "\n".join(lignes) + + +def source_pane(row, colour=False): + """Où la migration laisse ses traces, et ce que chacune contient.""" + lignes = [status.paint(t("Where the traces live"), "step", colour), ""] + for role, chemin, existe, quoi in quality.log_sources(): + marque = "📄" if existe else "∅" + teinte = "ok" if existe else "warn" + lignes.append(f" {marque} {status.paint(role, teinte, colour)}") + lignes.append(f" {chemin}") + lignes.append(f" {quoi}") + if not existe: + lignes.append(f" {t('missing: nothing was written here')}") + lignes.append("") + lignes.append(f" {t('To read the verdicts by hand:')}") + lignes.append(" python3 - <<'PY'") + lignes.append(" import json, io") + lignes.append( + " d = json.load(io.open(" + f"'{quality.DEFAULT_PROGRESSION}', encoding='utf-8'))" + ) + lignes.append(" for e in d['lst_event']:") + lignes.append(" if e.get('status'):") + lignes.append(" print(e['step'], e['name'], e['status'])") + lignes.append(" PY") + return "\n".join(lignes) + + +def logscan_pane(colour=False): + """Les erreurs du journal d'Odoo — ou pourquoi il n'y en a pas.""" + rapport = quality.scan_log() + lignes = [status.paint(t("Errors in the Odoo log"), "step", colour), ""] + lignes.append(f" {rapport['path']}") + if not rapport["exists"]: + lignes.append("") + lignes.append( + status.paint( + f" ∅ {t('This file does not exist.')}", "warn", colour + ) + ) + lignes.append("") + lignes.append( + f" {t('config.conf has an empty logfile=, so Odoo writes to')}" + ) + lignes.append( + f" {t('the terminal and its output dies with it. To keep it:')}" + ) + lignes.append("") + lignes.append( + f" logfile = {os.path.abspath(quality.JOURNAL_ODOO)}" + ) + return "\n".join(lignes) + lignes.append("") + for motif, combien in rapport["counts"].items(): + teinte = "fail" if combien else "ok" + lignes.append( + f" {status.paint(motif.ljust(10), teinte, colour)} {combien}" + ) + if rapport["lines"]: + lignes.append("") + lignes.append(f" {t('last offending lines')}") + for ligne in rapport["lines"]: + lignes.append(f" {ligne}") + return "\n".join(lignes) + + +def review_pane(row, colour=False): + """Une étape de la revue : ce qu'elle prouve, et ce qu'elle ne prouve pas.""" + lignes = [ + status.paint(t(row.get("question", "")), "step", colour), + "", + ] + if row.get("command"): + lignes.append(f" {row['command']}") + lignes.append("") + lignes.append( + status.paint(f" ▶ {t('press r to run it')}", "step", colour) + ) + else: + lignes.append(f" {t('Read it in the Verdicts section above.')}") + lignes.append("") + lignes.append(f" {t('What none of these can see')}") + lignes.append( + f" {t('They all read the DATABASE. A module that kept a')}" + ) + lignes.append( + f" {t('pre-18 view type, or an image URL Odoo no longer')}" + ) + lignes.append( + f" {t('accepts, breaks at runtime on a perfectly sound one.')}" + ) + return "\n".join(lignes) + + def pane_text(lst_snapshot, row, colour=False, mode=None): """Le détail du palier choisi, ou le bilan d'ensemble. @@ -168,6 +478,8 @@ def pane_text(lst_snapshot, row, colour=False, mode=None): """ if row is None: return t("Nothing to show yet.") + if row["kind"] in EXTRA_KINDS: + return extra_pane(row, colour) if row["kind"] == "missing": return f"⚠️ {row['data']['database']} : {t('database not found')}" etat = row["data"] @@ -222,6 +534,41 @@ def mode_label(mode): return t(mode) +def run_in_terminal(command, wait=True): + """Rendre le terminal au test, puis le reprendre. (code, a_tourné). + + L'écran DOIT s'effacer : `smoke_public_url` monte une instance Odoo et + écrit des centaines de lignes ; les capturer les cacherait, et les + laisser passer par-dessus la TUI la déchirerait. Textual sait se + retirer — `suspend()` chez l'appelant — et c'est le seul moment où ce + processus peut lancer autre chose sans se battre pour l'affichage. + + L'attente d'Entrée n'est pas une politesse : sans elle, l'écran se + reconstruit par-dessus le résultat avant qu'on ait pu le lire. + """ + if not command: + return None, False + argv = command.split() + if argv[0].endswith(".py"): + argv = [sys.executable] + argv + print() + print(f"▶ {' '.join(argv)}") + print("─" * 72) + try: + code = subprocess.run(argv, cwd=REPO_ROOT).returncode + except OSError as exc: + print(f"❌ {exc}") + code = None + print("─" * 72) + print(f"↩ {t('exit code:')} {code}") + if wait: + try: + input(t("Press Enter to go back to the screen…")) + except (EOFError, KeyboardInterrupt): + pass + return code, True + + def build_app(lst_snapshot): """Textual est importé ICI : le module reste testable sans lui.""" from rich.text import Text @@ -235,6 +582,7 @@ def build_app(lst_snapshot): ("q,escape", "quit", t("Quit")), ("m", "toggle_missing", t("Missing files")), ("d", "cycle_detail", t("Details")), + ("r,enter", "run_selected", t("Run")), ] def __init__(self, lst_snapshot): @@ -305,6 +653,28 @@ def build_app(lst_snapshot): self.mode = suite[(suite.index(courant) + 1) % len(suite)] self._show() + def action_run_selected(self): + """Rejouer le test de la ligne choisie, hors de l'écran. + + `suspend()` rend le terminal pour de bon : le test peut monter + son instance Odoo et écrire ce qu'il veut. Rien n'est capturé, + donc rien n'est caché. + """ + row = ( + self.lst_row[self.index] + if self.lst_row and self.index < len(self.lst_row) + else None + ) + commande = row.get("command") if row else "" + if not commande: + # Une ligne sans commande n'est pas une erreur : la plupart + # n'en ont pas. Un bip dit « rien ici » sans interrompre. + self.bell() + return + with self.suspend(): + run_in_terminal(commande) + self.refresh() + def on_data_table_row_highlighted(self, event): if event.data_table.id == "left" and self.lst_row: self.index = event.cursor_row diff --git a/script/analyse/check_migration_residue.py b/script/analyse/check_migration_residue.py index b2320ea..d75f75c 100755 --- a/script/analyse/check_migration_residue.py +++ b/script/analyse/check_migration_residue.py @@ -74,10 +74,12 @@ except Exception: # pragma: no cover - repli si i18n indisponible return key +from script.analyse import check_migration_quality as quality # noqa: E402 from script.analyse import lib_analyse # noqa: E402 COULEURS = { "broken": "\033[31m", + "step": "\033[36m", "watch": "\033[33m", "ok": "\033[32m", "dim": "\033[90m", @@ -244,6 +246,83 @@ def judge(resultats): return trouve, illisibles +def famille(nom): + """La base d'origine dont ce nom est un palier. + + « test_neutralize_upgrade_14 » et « …_upgrade_18 » sont deux paliers + de la MÊME migration. Interroger la base 18 doit montrer l'échec du + palier 14 : c'est le seul endroit où il subsiste. + """ + return nom.rsplit("_upgrade_", 1)[0] if "_upgrade_" in nom else nom + + +def verdicts(database, path=None): + """(tous, ratés) pour cette base — lus dans le FICHIER, pas en SQL. + + Un test de fumée qui échoue ne laisse aucune trace en base : rien + n'est écrit, rien n'est cassé, la requête suivante répond. Les + contrôles ci-dessus sont donc structurellement aveugles à ce type + d'échec, et c'était la moitié de ce qu'une migration peut rater. + """ + dct = quality.read_progression(path or quality.DEFAULT_PROGRESSION) + lignee = famille(database) + tous = [ + e + for e in quality.read_events(dct) + if famille(quality.event_database(e)) == lignee + ] + return tous, quality.failures(tous) + + +def verdicts_block(database, colour=True, path=None): + """La section « Verdicts », ou rien du tout s'il n'y en a pas. + + Silencieuse quand le fichier n'existe pas : devant la sauvegarde d'un + client, il n'y a jamais eu de migration locale, et annoncer l'absence + d'un fichier qu'on n'attendait pas ne renseigne personne. + """ + chemin = path or quality.DEFAULT_PROGRESSION + tous, ratés = verdicts(database, chemin) + if not tous: + return [] + lignes = [ + "", + paint(f"🚦 {t('Verdicts the migration recorded')}", "step", colour), + ] + lignes.append(paint(f" {t('recorded in')} {chemin}", "dim", colour)) + lignes.append("") + if not ratés: + lignes.append( + paint( + f"✅ {str(len(tous)).rjust(6)} {t('checks, all passed')}", + "ok", + colour, + ) + ) + for event in ratés: + palier = quality.event_step(event) + lignes.append( + paint( + f"❌ {palier.rjust(6)} {event['name']}", + "broken", + colour, + ) + ) + lignes.append( + paint(f" {event['detail'][:120]}", "dim", colour) + ) + lignes.append("") + lignes.append( + paint( + f" {t('These come from the file, not the database:')}" + f" {t('the exit code ignores them.')}", + "dim", + colour, + ) + ) + return lignes + + def render(database, resultats, version=None, colour=True): """Le rapport lisible. Chaque constat dit quoi lancer pour le réparer.""" trouve, illisibles = judge(resultats) @@ -266,7 +345,7 @@ def render(database, resultats, version=None, colour=True): colour, ) ) - return "\n".join(lignes) + return "\n".join(lignes + verdicts_block(database, colour)).rstrip() for controle, combien in trouve: icone = "❌" if controle["gravity"] == "broken" else "⚠" @@ -295,7 +374,7 @@ def render(database, resultats, version=None, colour=True): ) ) lignes.append(paint(f" {erreur}", "dim", colour)) - return "\n".join(lignes).rstrip() + return "\n".join(lignes + verdicts_block(database, colour)).rstrip() def main(argv=None): @@ -329,6 +408,7 @@ def main(argv=None): trouve, illisibles = judge(resultats) if args.json: + tous_verdicts, verdicts_ratés = verdicts(args.database) print( json.dumps( { @@ -337,6 +417,11 @@ def main(argv=None): "checks": resultats, "found": [c["key"] for c, _ in trouve], "unreadable": [c["key"] for c, _ in illisibles], + "verdicts": tous_verdicts, + "verdicts_failed": [ + {"step": quality.event_step(e), "name": e["name"]} + for e in verdicts_ratés + ], }, indent=2, ensure_ascii=False, diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index f7feb41..43e644a 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -6977,6 +6977,209 @@ TRANSLATIONS = { "fr": "Qualité de la migration, palier par palier", "en": "Migration quality, step by step", }, + # --- Écran de qualité : Verdicts, Validation, Revue --- + "Verdicts the migration recorded": { + "fr": "Verdicts enregistrés par la migration", + "en": "Verdicts the migration recorded", + }, + "recorded in": { + "fr": "consignés dans", + "en": "recorded in", + }, + "These come from the file, not the database:": { + "fr": "Ils viennent du fichier, non de la base :", + "en": "These come from the file, not the database:", + }, + "the exit code ignores them.": { + "fr": "le code de sortie ne les compte pas.", + "en": "the exit code ignores them.", + }, + # Les six questions de la revue : t() les reçoit par + # variable, un scan des littéraux ne les voyait pas. + "Did the migration reach the end?": { + "fr": "La migration est-elle allée jusqu'au bout ?", + "en": "Did the migration reach the end?", + }, + "Does Odoo load the database?": { + "fr": "Odoo charge-t-il la base ?", + "en": "Does Odoo load the database?", + }, + "What did the migration leave behind?": { + "fr": "Qu'est-ce que la migration a laissé derrière ?", + "en": "What did the migration leave behind?", + }, + "Is this copy safe to open?": { + "fr": "Cette copie est-elle sans danger à ouvrir ?", + "en": "Is this copy safe to open?", + }, + "Do the public pages still answer?": { + "fr": "Les pages publiques répondent-elles encore ?", + "en": "Do the public pages still answer?", + }, + "Is anything left that cleanup could not drop?": { + "fr": "Reste-t-il ce que le nettoyage n'a pu supprimer ?", + "en": "Is anything left that cleanup could not drop?", + }, + "Verdicts": { + "fr": "Verdicts", + "en": "Verdicts", + }, + "Validation": { + "fr": "Validation", + "en": "Validation", + }, + "Review": { + "fr": "Revue de la migration", + "en": "Review", + }, + "no verdict recorded": { + "fr": "aucun verdict enregistré", + "en": "no verdict recorded", + }, + "checks, all passed": { + "fr": "tests, tous réussis", + "en": "checks, all passed", + }, + "step": { + "fr": "palier", + "en": "step", + }, + "when": { + "fr": "quand", + "en": "when", + }, + "status": { + "fr": "statut", + "en": "status", + }, + "what it ran": { + "fr": "ce qu'il a lancé", + "en": "what it ran", + }, + "1 means a public page failed — not merely a finding.": { + "fr": "1 signifie qu'une page publique a échoué — pas un simple constat.", + "en": "1 means a public page failed — not merely a finding.", + }, + "1 means leftovers remain that it could not drop.": { + "fr": "1 signifie qu'il reste des objets qu'il n'a pu supprimer.", + "en": "1 means leftovers remain that it could not drop.", + }, + "press r to run it again": { + "fr": "appuie sur r pour le relancer", + "en": "press r to run it again", + }, + "The screen steps aside, the test takes the terminal,": { + "fr": "L'écran s'efface, le test prend le terminal,", + "en": "The screen steps aside, the test takes the terminal,", + }, + "and you come back with Enter.": { + "fr": "et tu reviens avec Entrée.", + "en": "and you come back with Enter.", + }, + "No known way to replay this one.": { + "fr": "Aucun moyen connu de rejouer celui-ci.", + "en": "No known way to replay this one.", + }, + "The migration recorded no verdict.": { + "fr": "La migration n'a enregistré aucun verdict.", + "en": "The migration recorded no verdict.", + }, + "Every recorded check returned zero.": { + "fr": "Chaque test enregistré a rendu zéro.", + "en": "Every recorded check returned zero.", + }, + "Where the traces live": { + "fr": "Où vivent les traces", + "en": "Where the traces live", + }, + "missing: nothing was written here": { + "fr": "absent : rien n'a été écrit ici", + "en": "missing: nothing was written here", + }, + "To read the verdicts by hand:": { + "fr": "Pour lire les verdicts à la main :", + "en": "To read the verdicts by hand:", + }, + "Verdicts live in lst_event of the progression file,": { + "fr": "Les verdicts vivent dans lst_event du fichier de", + "en": "Verdicts live in lst_event of the progression file,", + }, + "not in command_executed, which only lists what ran.": { + "fr": "progression — command_executed ne liste que l'exécuté.", + "en": "not in command_executed, which only lists what ran.", + }, + "Errors in the Odoo log": { + "fr": "Erreurs dans le journal d'Odoo", + "en": "Errors in the Odoo log", + }, + "This file does not exist.": { + "fr": "Ce fichier n'existe pas.", + "en": "This file does not exist.", + }, + "config.conf has an empty logfile=, so Odoo writes to": { + "fr": "config.conf a un logfile= vide : Odoo écrit dans le", + "en": "config.conf has an empty logfile=, so Odoo writes to", + }, + "the terminal and its output dies with it. To keep it:": { + "fr": "terminal et sa sortie meurt avec lui. Pour la garder :", + "en": "the terminal and its output dies with it. To keep it:", + }, + "last offending lines": { + "fr": "dernières lignes fautives", + "en": "last offending lines", + }, + "press r to run it": { + "fr": "appuie sur r pour le lancer", + "en": "press r to run it", + }, + "Read it in the Verdicts section above.": { + "fr": "À lire dans la section Verdicts ci-dessus.", + "en": "Read it in the Verdicts section above.", + }, + "What none of these can see": { + "fr": "Ce qu'aucun d'eux ne voit", + "en": "What none of these can see", + }, + "They all read the DATABASE. A module that kept a": { + "fr": "Tous lisent la BASE. Un module resté sur un type de", + "en": "They all read the DATABASE. A module that kept a", + }, + "pre-18 view type, or an image URL Odoo no longer": { + "fr": "vue d'avant 18, ou une URL d'image qu'Odoo", + "en": "pre-18 view type, or an image URL Odoo no longer", + }, + "accepts, breaks at runtime on a perfectly sound one.": { + "fr": "refuse désormais, casse à l'exécution sur une base saine.", + "en": "accepts, breaks at runtime on a perfectly sound one.", + }, + "exit code:": { + "fr": "code de sortie :", + "en": "exit code:", + }, + "Press Enter to go back to the screen…": { + "fr": "Appuie sur Entrée pour revenir à l'écran…", + "en": "Press Enter to go back to the screen…", + }, + "Pick a line below.": { + "fr": "Choisis une ligne ci-dessous.", + "en": "Pick a line below.", + }, + "Migration progression": { + "fr": "Progression de la migration", + "en": "Migration progression", + }, + "Odoo log": { + "fr": "Journal d'Odoo", + "en": "Odoo log", + }, + "steps, commands and their verdicts (lst_event)": { + "fr": "paliers, commandes et leurs verdicts (lst_event)", + "en": "steps, commands and their verdicts (lst_event)", + }, + "set logfile= in config.conf, else output is lost": { + "fr": "poser logfile= dans config.conf, sinon la sortie est perdue", + "en": "set logfile= in config.conf, else output is lost", + }, "database not found": { "fr": "base introuvable", "en": "database not found", diff --git a/test/test_check_migration_quality.py b/test/test_check_migration_quality.py index 283c72f..5aed2be 100644 --- a/test/test_check_migration_quality.py +++ b/test/test_check_migration_quality.py @@ -1025,26 +1025,71 @@ class TestItNeverWrites(Base): 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. + # + # La garde porte sur le CODE, fonction par fonction, et non sur le + # texte du fichier : la revue CITE « ./odoo_bin.sh shell » comme + # étape à faire soi-même, et interdire le mot interdirait de le + # nommer. Ce qu'on veut garantir est que rien ici ne le LANCE. import inspect - source = inspect.getsource(quality) - for interdit in ("odoo_bin", "run.sh", "--update", "-u all"): - self.assertNotIn(interdit, source, interdit) + for nom, objet in vars(quality).items(): + if not inspect.isfunction(objet): + continue + if objet.__module__ != quality.__name__: + continue + source = inspect.getsource(objet) + for interdit in ("odoo_bin", "run.sh", "--update", "-u all"): + self.assertNotIn(interdit, source, f"{nom} → {interdit}") + + def test_the_only_process_it_launches_is_psql(self): + # Complément du précédent : une donnée peut nommer un programme, + # un `subprocess.run` le lance. Il n'y en a qu'un, et c'est psql. + import ast + import inspect + + arbre = ast.parse(inspect.getsource(quality)) + lances = [ + n + for n in ast.walk(arbre) + if isinstance(n, ast.Call) + and isinstance(n.func, ast.Attribute) + and n.func.attr == "run" + and getattr(n.func.value, "id", "") == "subprocess" + ] + self.assertEqual(1, len(lances)) + premier = lances[0].args[0] + self.assertEqual("psql", premier.elts[0].value) class TestTheFullScreen(Base): - def test_one_row_per_step_plus_the_overall(self): + def test_one_row_per_step_then_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"] - ) + lignes = qtui.rows(lst, {}) + debut = [x["kind"] for x in lignes][:3] + self.assertEqual(debut, ["step", "step", "overall"]) - def test_the_overall_comes_LAST(self): + def test_the_overall_closes_the_steps(self): # On descend la liste comme on a vécu la migration ; « qu'en - # reste-t-il » se pose une fois le chemin vu. + # reste-t-il » se pose une fois le chemin vu. Ce qui suit — les + # verdicts, où les lire, quoi vérifier — répond à « et après ». lst = [snapshot(odoo="12.0"), snapshot(odoo="18.0")] - self.assertEqual(qtui.rows(lst)[-1]["kind"], "overall") + genres = [x["kind"] for x in qtui.rows(lst, {})] + rang = genres.index("overall") + self.assertEqual(set(genres[:rang]), {"step"}) + self.assertNotIn("step", genres[rang:]) + + def test_the_review_sections_come_after_the_overall(self): + lst = [snapshot(odoo="12.0"), snapshot(odoo="18.0")] + genres = [x["kind"] for x in qtui.rows(lst, {})] + apres = set(genres[genres.index("overall") + 1 :]) + self.assertTrue(apres <= set(qtui.EXTRA_KINDS) | {"header"}, apres) + + def test_the_screen_does_not_depend_on_this_machine(self): + # Sans `dct`, la liste refléterait ce qu'une migration a laissé + # sur CE poste : deux exécutions ne rendraient pas la même chose. + lst = [snapshot(odoo="12.0"), snapshot(odoo="18.0")] + avec = qtui.rows(lst, {"lst_event": []}) + self.assertNotIn("verdict", [x["kind"] for x in avec]) def test_a_missing_database_gets_its_own_row(self): lignes = qtui.rows( @@ -1587,5 +1632,358 @@ class TestWhyAnAttachmentWentAway(Base): self.assertNotEqual("ir_attachment", entree.get("table")) +def evenement(**champs): + """Un événement du journal de progression, forme réelle.""" + brut = { + "at": "2026-08-26 03:19:59.846453", + "step": "4.1.I - Migrate database", + "kind": "test", + "name": "smoke_public_url", + "status": 1, + "detail": ".venv.erplibre/bin/python3" + " ./script/odoo/migration/smoke_public_url.py" + " -d test_neutralize_upgrade_14 --internal-required", + } + brut.update(champs) + return brut + + +class TestTheVerdictsAreReadFromTheFile(Base): + """`lst_event` est la SEULE trace persistante d'un échec. + + `command_executed` ne dit que ce qui a été lancé. La sortie d'Odoo, + elle, part sur le terminal et meurt avec lui. + """ + + def test_a_status_written_as_text_still_counts(self): + # Le pilote écrit parfois le code en chaîne ; le comparer à zéro + # sans le convertir ferait passer "0" pour un échec. + lus = quality.read_events({"lst_event": [evenement(status="1")]}) + self.assertEqual(1, lus[0]["status"]) + + def test_a_zero_written_as_text_is_not_a_failure(self): + lus = quality.read_events({"lst_event": [evenement(status="0")]}) + self.assertEqual([], quality.failures(lus)) + + def test_an_unreadable_status_is_read_as_success(self): + # Mieux vaut taire un verdict illisible que crier un faux échec. + lus = quality.read_events({"lst_event": [evenement(status="oui")]}) + self.assertEqual(0, lus[0]["status"]) + + def test_an_entry_that_is_not_a_record_is_skipped(self): + lus = quality.read_events({"lst_event": ["cassé", evenement()]}) + self.assertEqual(1, len(lus)) + + def test_no_list_at_all_reads_as_no_verdict(self): + self.assertEqual([], quality.read_events({})) + self.assertEqual([], quality.read_events({"lst_event": None})) + + def test_a_dialogue_answer_is_not_a_failed_test(self): + # Les entrées `command` à 1 sont les réponses du pilote — « veux-tu + # effacer le module manquant ». Les compter comme des échecs + # noierait les vrais sous une liste qu'on cesse de lire. + lus = quality.read_events( + { + "lst_event": [ + evenement(kind="command", name="./odoo_bin.sh db --clone"), + evenement(kind="test"), + ] + } + ) + ratés = quality.failures(lus) + self.assertEqual(["smoke_public_url"], [e["name"] for e in ratés]) + + +class TestWhichStepAVerdictBelongsTo(Base): + def test_the_database_is_read_from_the_command(self): + self.assertEqual( + "test_neutralize_upgrade_14", + quality.event_database(evenement()), + ) + + def test_a_command_without_database_yields_nothing(self): + self.assertEqual("", quality.event_database(evenement(detail=""))) + + def test_the_step_comes_from_the_database_not_the_counter(self): + # `step` compte les ÉTAPES du pilote — « 4.1.I » — et non les + # versions d'Odoo : les deux sont décalées d'un rang. Afficher + # « 4.1 » à côté de « palier » nommerait la mauvaise version. + self.assertEqual("14", quality.event_step(evenement())) + + def test_without_a_step_database_it_falls_back_to_the_counter(self): + seul = evenement(detail="", step="4.1.I - Migrate database") + self.assertEqual("4.1.I", quality.event_step(seul)) + + +class TestTheMapOfTraces(Base): + """Documenter les chemins EST la fonctionnalité.""" + + def test_both_places_are_named_even_when_one_is_empty(self): + sources = quality.log_sources( + path="rien.json", journal="rien_non_plus.log" + ) + self.assertEqual(2, len(sources)) + for _role, _chemin, existe, _quoi in sources: + self.assertFalse(existe) + + def test_the_missing_journal_is_shown_not_hidden(self): + # Le taire laisserait chercher un fichier qu'Odoo n'a jamais + # écrit, faute de `logfile=` dans config.conf. + chemins = [c for _r, c, _e, _q in quality.log_sources()] + self.assertIn(quality.JOURNAL_ODOO, chemins) + + +class TestScanningTheOdooLog(Base): + def setUp(self): + super().setUp() + import tempfile + + self.dossier = tempfile.mkdtemp() + self.addCleanup(__import__("shutil").rmtree, self.dossier) + self.journal = os.path.join(self.dossier, "odoo.log") + + def ecrire(self, texte): + with io.open(self.journal, "w", encoding="utf-8") as handle: + handle.write(texte) + + def test_a_missing_file_is_not_an_error(self): + rapport = quality.scan_log(os.path.join(self.dossier, "absent.log")) + self.assertFalse(rapport["exists"]) + self.assertEqual([], rapport["lines"]) + + def test_each_pattern_is_counted(self): + self.ecrire("INFO ok\nERROR boum\nCRITICAL pire\nTraceback (x)\n") + rapport = quality.scan_log(self.journal) + for motif in quality.MOTIFS_ERREUR: + self.assertEqual(1, rapport["counts"][motif], motif) + + def test_a_clean_log_counts_zero_and_shows_nothing(self): + self.ecrire("INFO tout va bien\nINFO encore\n") + rapport = quality.scan_log(self.journal) + self.assertEqual([], rapport["lines"]) + self.assertEqual(0, rapport["counts"]["ERROR"]) + + def test_only_the_last_lines_are_kept(self): + # Un journal de migration pèse des dizaines de mégaoctets ; ce + # qu'on cherche est ce qui a échoué en DERNIER. + self.ecrire("".join("ERROR %d\n" % i for i in range(50))) + rapport = quality.scan_log(self.journal, limite=3) + self.assertEqual(3, len(rapport["lines"])) + self.assertIn("ERROR 49", rapport["lines"][-1]) + + +class TestTheReviewChecklist(Base): + """Une liste de contrôle qu'il faut aller chercher n'est pas suivie.""" + + def test_every_step_names_a_question(self): + for question, _commande, _clef in quality.REVUE: + self.assertTrue(question.endswith("?"), question) + + def test_a_runnable_step_carries_the_database_placeholder(self): + for _q, commande, clef in quality.REVUE: + if clef: + self.assertIn("{db}", commande, commande) + + def test_the_first_step_has_nothing_to_run(self): + # « La migration est-elle allée jusqu'au bout » se lit dans le + # fichier ; aucune commande ne le rejoue. + self.assertIsNone(quality.REVUE[0][2]) + + +class TestTheThreeExtraSections(Base): + """Elles vivent dans la MÊME table que les paliers.""" + + def entetes(self, lst): + return [r["label"] for r in lst if r["kind"] == "header"] + + def test_the_sections_come_in_the_order_of_the_work(self): + lst = qtui.extra_rows([], {}) + self.assertEqual( + [ + f"── {qtui.t('Verdicts')} ──", + f"── {qtui.t('Validation')} ──", + f"── {qtui.t('Review')} ──", + ], + self.entetes(lst), + ) + + def test_no_verdict_at_all_says_so(self): + lst = qtui.extra_rows([], {}) + self.assertIn("verdict-none", [r["kind"] for r in lst]) + + def test_every_check_passing_is_stated_not_left_blank(self): + lst = qtui.extra_rows([], {"lst_event": [evenement(status=0)]}) + ok = [r for r in lst if r["kind"] == "verdict-ok"] + self.assertEqual(1, len(ok)) + self.assertIn("1", ok[0]["label"]) + + def test_a_failed_verdict_names_its_step_in_the_label(self): + # « smoke_public_url » quatre fois de suite ne dit pas lequel a + # échoué, et c'est la seule chose qu'on veut savoir. + lst = qtui.extra_rows([], {"lst_event": [evenement()]}) + ligne = [r for r in lst if r["kind"] == "verdict"][0] + self.assertIn("14", ligne["label"]) + self.assertIn("smoke_public_url", ligne["label"]) + + def test_a_failed_verdict_offers_a_way_to_replay_it(self): + lst = qtui.extra_rows([], {"lst_event": [evenement()]}) + ligne = [r for r in lst if r["kind"] == "verdict"][0] + self.assertEqual("▶", ligne["detail"]) + self.assertTrue(ligne["command"]) + + def test_the_header_counts_the_failures(self): + lst = qtui.extra_rows([], {"lst_event": [evenement(), evenement()]}) + self.assertEqual("2", self.premier_entete(lst)["detail"]) + + def premier_entete(self, lst): + return [r for r in lst if r["kind"] == "header"][0] + + def test_a_clean_run_shows_a_tick_not_a_zero(self): + lst = qtui.extra_rows([], {"lst_event": [evenement(status=0)]}) + self.assertEqual("✅", self.premier_entete(lst)["detail"]) + + def test_review_steps_target_the_last_database(self): + presents = [ + {"database": "base_upgrade_17", "exists": True}, + {"database": "base_upgrade_18", "exists": True}, + ] + lst = qtui.extra_rows(presents, {}) + commandes = [ + r["command"] for r in lst if r["kind"] == "review" and r["command"] + ] + self.assertTrue(commandes) + for commande in commandes: + self.assertIn("base_upgrade_18", commande) + self.assertNotIn("{db}", commande) + + def test_without_a_database_no_review_step_can_be_run(self): + lst = qtui.extra_rows([], {}) + for ligne in [r for r in lst if r["kind"] == "review"]: + self.assertEqual("", ligne["command"]) + + +class TestTheVerdictPanel(Base): + def panneau(self, **champs): + lst = qtui.extra_rows([], {"lst_event": [evenement(**champs)]}) + ligne = [r for r in lst if r["kind"] == "verdict"][0] + return qtui.extra_pane(ligne) + + def test_the_step_shown_is_the_odoo_version(self): + # « 4.1.I » est la première étape du quatrième bloc du pilote ; la + # migration en est alors au palier 14. Afficher le compteur sous + # le mot « palier » faisait lire une version qui n'existe pas. + texte = self.panneau() + self.assertIn(f"{qtui.t('step'):<10} 14", texte) + + def test_the_pilot_counter_is_still_there_in_brackets(self): + # Il sert à retrouver l'entrée dans le journal ; le perdre + # obligerait à compter les étapes à la main. + self.assertIn("(4.1.I - Migrate database)", self.panneau()) + + def test_what_a_status_of_one_means_is_spelled_out(self): + # « status 1 » ne dit pas si c'est un constat ou une panne. + texte = self.panneau() + self.assertIn( + qtui.t("1 means a public page failed — not merely a finding."), + texte, + ) + + def test_a_tool_we_cannot_replay_says_so(self): + texte = self.panneau(name="un_outil_inconnu") + self.assertIn(qtui.t("No known way to replay this one."), texte) + self.assertNotIn(qtui.t("press r to run it again"), texte) + + +class TestReplayingAVerdict(Base): + def test_a_known_tool_is_rebuilt_from_its_name(self): + self.assertEqual( + "script/odoo/migration/smoke_public_url.py -d base", + qtui.rerun_command(evenement(), "base"), + ) + + def test_the_recorded_command_is_not_replayed_as_is(self): + # Le `detail` porte le venv du PALIER — « .venv.erplibre/bin/python3 » + # d'alors — qui n'est plus celui du checkout courant. + commande = qtui.rerun_command(evenement(), "base") + self.assertNotIn(".venv", commande) + self.assertNotIn("--internal-required", commande) + + def test_an_unknown_tool_offers_nothing(self): + self.assertEqual( + "", qtui.rerun_command(evenement(name="autre_chose"), "base") + ) + + def test_without_a_database_there_is_nothing_to_run(self): + self.assertEqual("", qtui.rerun_command(evenement(), "")) + + +class TestRunningATestFromTheScreen(Base): + """Le test prend le terminal, puis le rend.""" + + def setUp(self): + super().setUp() + self.appels = [] + + class FauxRetour: + returncode = 0 + + def faux_run(argv, cwd=None): + self.appels.append((argv, cwd)) + return FauxRetour() + + faux = type("M", (), {"run": staticmethod(faux_run)}) + self.addCleanup(setattr, qtui, "subprocess", qtui.subprocess) + qtui.subprocess = faux + + def lancer(self, commande, wait=False): + tampon = io.StringIO() + with redirect_stdout(tampon): + resultat = qtui.run_in_terminal(commande, wait=wait) + return resultat, tampon.getvalue() + + def test_nothing_to_run_runs_nothing(self): + (code, tourné), _sortie = self.lancer("") + self.assertIsNone(code) + self.assertFalse(tourné) + self.assertEqual([], self.appels) + + def test_a_python_tool_runs_with_this_checkout_interpreter(self): + # Le lancer avec le python du système le priverait des dépendances + # d'ERPLibre : l'outil échouerait à l'import, pas sur la base. + self.lancer("script/odoo/migration/smoke_public_url.py -d base") + argv, _cwd = self.appels[0] + self.assertEqual(sys.executable, argv[0]) + + def test_it_runs_from_the_repository_root(self): + # Les chemins que la commande porte y sont relatifs ; ailleurs, + # ils sont introuvables. + self.lancer("script/odoo/migration/smoke_public_url.py -d base") + _argv, cwd = self.appels[0] + self.assertEqual(qtui.REPO_ROOT, cwd) + self.assertTrue( + os.path.isdir(os.path.join(cwd, "script", "odoo", "migration")) + ) + + def test_a_plain_command_keeps_its_own_program(self): + self.lancer("./odoo_bin.sh shell -d base") + argv, _cwd = self.appels[0] + self.assertEqual("./odoo_bin.sh", argv[0]) + + def test_the_exit_code_is_shown_not_swallowed(self): + (code, _t), sortie = self.lancer("./odoo_bin.sh shell") + self.assertEqual(0, code) + self.assertIn(qtui.t("exit code:"), sortie) + + def test_a_missing_program_is_reported_not_raised(self): + def explose(argv, cwd=None): + raise OSError("No such file or directory") + + qtui.subprocess = type("M", (), {"run": staticmethod(explose)}) + (code, tourné), sortie = self.lancer("absent.sh") + self.assertIsNone(code) + self.assertTrue(tourné) + self.assertIn("No such file", sortie) + + if __name__ == "__main__": unittest.main() diff --git a/test/test_monitoring.py b/test/test_monitoring.py index fbba336..a4ceb31 100644 --- a/test/test_monitoring.py +++ b/test/test_monitoring.py @@ -19,6 +19,7 @@ le DIRE. Une analyse muette qu'on prend pour rassurante est pire que pas d'analyse : c'est la faute que ce dépôt a déjà corrigée trois fois. """ +import io import os import sys import unittest @@ -434,5 +435,138 @@ class TestTheRepairAsksItsOwnDetector(unittest.TestCase): ) +def verdict(**champs): + """Un événement du journal de progression, forme réelle.""" + brut = { + "at": "2026-08-26 03:19:59.846453", + "step": "4.1.I - Migrate database", + "kind": "test", + "name": "smoke_public_url", + "status": 1, + "detail": ".venv.erplibre/bin/python3" + " ./script/odoo/migration/smoke_public_url.py" + " -d test_neutralize_upgrade_14 --internal-required", + } + brut.update(champs) + return brut + + +class TestTheVerdictsSection(unittest.TestCase): + """Ce que les contrôles SQL ne peuvent structurellement pas voir. + + Un test de fumée qui échoue n'écrit rien en base : rien n'est cassé, + la requête suivante répond. Aucun contrôle lisant la base ne le + retrouvera jamais — et c'était la moitié de ce qu'une migration peut + rater. + """ + + def setUp(self): + import json + import shutil + import tempfile + + self.dossier = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.dossier) + self.chemin = os.path.join(self.dossier, "progression.json") + self.json = json + + def ecrire(self, evenements): + with io.open(self.chemin, "w", encoding="utf-8") as handle: + self.json.dump({"lst_event": evenements}, handle) + return self.chemin + + def test_two_steps_of_one_migration_are_the_same_lineage(self): + # Interroger la base 18 doit montrer l'échec du palier 14 : c'est + # le seul endroit où il subsiste. + self.assertEqual( + residue.famille("test_neutralize_upgrade_14"), + residue.famille("test_neutralize_upgrade_18"), + ) + + def test_a_plain_name_is_its_own_lineage(self): + self.assertEqual("copy_chezlepro3", residue.famille("copy_chezlepro3")) + + def test_another_migration_verdicts_are_not_shown(self): + # Deux migrations partagent le fichier. Attribuer l'échec de + # l'une à l'autre enverrait chercher une panne qui n'existe pas. + chemin = self.ecrire([verdict()]) + _tous, ratés = residue.verdicts("autre_client_upgrade_18", chemin) + self.assertEqual([], ratés) + + def test_the_failure_of_an_earlier_step_is_shown(self): + chemin = self.ecrire([verdict()]) + _tous, ratés = residue.verdicts("test_neutralize_upgrade_18", chemin) + self.assertEqual(["smoke_public_url"], [e["name"] for e in ratés]) + + def test_a_step_is_named_by_its_odoo_version(self): + chemin = self.ecrire([verdict()]) + texte = "\n".join( + residue.verdicts_block("test_neutralize_upgrade_18", False, chemin) + ) + self.assertIn("14", texte) + self.assertIn("smoke_public_url", texte) + + def test_no_file_says_nothing_at_all(self): + # Devant la sauvegarde d'un client, il n'y a jamais eu de + # migration locale : annoncer l'absence d'un fichier qu'on + # n'attendait pas ne renseigne personne. + absent = os.path.join(self.dossier, "jamais_ecrit.json") + self.assertEqual([], residue.verdicts_block("base", False, absent)) + + def test_all_green_is_stated_not_left_silent(self): + chemin = self.ecrire([verdict(status=0), verdict(status=0)]) + texte = "\n".join( + residue.verdicts_block("test_neutralize_upgrade_18", False, chemin) + ) + self.assertIn("2", texte) + self.assertIn(residue.t("checks, all passed"), texte) + + def test_the_report_says_these_are_not_from_the_database(self): + # Sans cette phrase, un verdict d'il y a quatre paliers se lirait + # comme un défaut présent de la base qu'on a sous les yeux. + chemin = self.ecrire([verdict()]) + texte = "\n".join( + residue.verdicts_block("test_neutralize_upgrade_18", False, chemin) + ) + self.assertIn( + residue.t("These come from the file, not the database:"), texte + ) + + def test_a_past_verdict_does_not_become_a_finding(self): + # Le code de sortie dit « la BASE porte un défaut ». Y compter un + # verdict passé rendrait 1 pour toujours, et le pilote traiterait + # une base saine comme cassée à chaque appel. + vide = {c["key"]: 0 for c in residue.CONTROLES} + trouve, _illisibles = residue.judge(vide) + self.assertEqual([], trouve) + + def test_the_clean_report_still_carries_the_verdicts(self): + # Le cas qui compte : aucun résidu en base, et pourtant un test + # de fumée a échoué en chemin. Le rapport « rien trouvé » ne doit + # pas être le dernier mot. + chemin = self.ecrire([verdict()]) + ancien = residue.quality.DEFAULT_PROGRESSION + residue.quality.DEFAULT_PROGRESSION = chemin + self.addCleanup( + setattr, residue.quality, "DEFAULT_PROGRESSION", ancien + ) + vide = {c["key"]: 0 for c in residue.CONTROLES} + texte = residue.render( + "test_neutralize_upgrade_18", vide, colour=False + ) + self.assertIn(residue.t("None of the checks found anything."), texte) + self.assertIn("smoke_public_url", texte) + + def test_the_colour_it_asks_for_exists(self): + # `paint` retombe sur une chaîne vide suivie d'un RESET quand le + # genre est inconnu : cela n'annule rien et ne teinte rien. + chemin = self.ecrire([verdict()]) + for ligne in residue.verdicts_block( + "test_neutralize_upgrade_18", True, chemin + ): + if ligne and ligne.endswith(residue.RESET): + self.assertNotEqual(residue.RESET, ligne.strip()) + + if __name__ == "__main__": unittest.main()