[ADD] qualité de migration : verdicts, sources, revue

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)
This commit is contained in:
Mathieu Benoit 2026-08-26 07:46:32 -04:00
parent 4a90f9face
commit c95cf2e9d8
6 changed files with 1372 additions and 18 deletions

View file

@ -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.

View file

@ -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

View file

@ -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,

View file

@ -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",

View file

@ -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()

View file

@ -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()