[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:
parent
4a90f9face
commit
c95cf2e9d8
6 changed files with 1372 additions and 18 deletions
|
|
@ -87,6 +87,170 @@ def read_progression(path=DEFAULT_PROGRESSION):
|
||||||
return {}
|
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):
|
def chain(dct):
|
||||||
"""[(version, base)] du départ à l'arrivée, dans l'ordre du parcours.
|
"""[(version, base)] du départ à l'arrivée, dans l'ordre du parcours.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ Deux assemblages sépareraient les deux vues, et l'on finirait par lire deux
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
sys.path.append(
|
sys.path.append(
|
||||||
|
|
@ -30,6 +31,13 @@ except Exception: # pragma: no cover - repli si i18n indisponible
|
||||||
return key
|
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 = """
|
CSS = """
|
||||||
Screen { layout: vertical; }
|
Screen { layout: vertical; }
|
||||||
#head { height: 3; padding: 0 1; background: $panel; color: $text; }
|
#head { height: 3; padding: 0 1; background: $panel; color: $text; }
|
||||||
|
|
@ -39,12 +47,17 @@ Screen { layout: vertical; }
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def rows(lst_snapshot):
|
def rows(lst_snapshot, dct=None):
|
||||||
"""Un palier par ligne, puis le bilan d'ensemble en dernier.
|
"""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
|
Le bilan APRÈS les paliers et non en tête : on descend la liste comme
|
||||||
vécu la migration, et la question « qu'est-ce qu'il en reste » se pose
|
on a vécu la migration, et la question « qu'est-ce qu'il en reste » se
|
||||||
une fois qu'on a vu le chemin.
|
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 = []
|
lst = []
|
||||||
presents = [x for x in lst_snapshot if x.get("exists")]
|
presents = [x for x in lst_snapshot if x.get("exists")]
|
||||||
|
|
@ -95,9 +108,129 @@ def rows(lst_snapshot):
|
||||||
"diff": quality.overall(lst_snapshot),
|
"diff": quality.overall(lst_snapshot),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
lst.extend(extra_rows(presents, dct))
|
||||||
return lst
|
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):
|
def head_text(lst_snapshot):
|
||||||
presents = [x for x in lst_snapshot if x.get("exists")]
|
presents = [x for x in lst_snapshot if x.get("exists")]
|
||||||
manquants = len(lst_snapshot) - len(presents)
|
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):
|
def pane_text(lst_snapshot, row, colour=False, mode=None):
|
||||||
"""Le détail du palier choisi, ou le bilan d'ensemble.
|
"""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:
|
if row is None:
|
||||||
return t("Nothing to show yet.")
|
return t("Nothing to show yet.")
|
||||||
|
if row["kind"] in EXTRA_KINDS:
|
||||||
|
return extra_pane(row, colour)
|
||||||
if row["kind"] == "missing":
|
if row["kind"] == "missing":
|
||||||
return f"⚠️ {row['data']['database']} : {t('database not found')}"
|
return f"⚠️ {row['data']['database']} : {t('database not found')}"
|
||||||
etat = row["data"]
|
etat = row["data"]
|
||||||
|
|
@ -222,6 +534,41 @@ def mode_label(mode):
|
||||||
return t(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):
|
def build_app(lst_snapshot):
|
||||||
"""Textual est importé ICI : le module reste testable sans lui."""
|
"""Textual est importé ICI : le module reste testable sans lui."""
|
||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
|
|
@ -235,6 +582,7 @@ def build_app(lst_snapshot):
|
||||||
("q,escape", "quit", t("Quit")),
|
("q,escape", "quit", t("Quit")),
|
||||||
("m", "toggle_missing", t("Missing files")),
|
("m", "toggle_missing", t("Missing files")),
|
||||||
("d", "cycle_detail", t("Details")),
|
("d", "cycle_detail", t("Details")),
|
||||||
|
("r,enter", "run_selected", t("Run")),
|
||||||
]
|
]
|
||||||
|
|
||||||
def __init__(self, lst_snapshot):
|
def __init__(self, lst_snapshot):
|
||||||
|
|
@ -305,6 +653,28 @@ def build_app(lst_snapshot):
|
||||||
self.mode = suite[(suite.index(courant) + 1) % len(suite)]
|
self.mode = suite[(suite.index(courant) + 1) % len(suite)]
|
||||||
self._show()
|
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):
|
def on_data_table_row_highlighted(self, event):
|
||||||
if event.data_table.id == "left" and self.lst_row:
|
if event.data_table.id == "left" and self.lst_row:
|
||||||
self.index = event.cursor_row
|
self.index = event.cursor_row
|
||||||
|
|
|
||||||
|
|
@ -74,10 +74,12 @@ except Exception: # pragma: no cover - repli si i18n indisponible
|
||||||
return key
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
from script.analyse import check_migration_quality as quality # noqa: E402
|
||||||
from script.analyse import lib_analyse # noqa: E402
|
from script.analyse import lib_analyse # noqa: E402
|
||||||
|
|
||||||
COULEURS = {
|
COULEURS = {
|
||||||
"broken": "\033[31m",
|
"broken": "\033[31m",
|
||||||
|
"step": "\033[36m",
|
||||||
"watch": "\033[33m",
|
"watch": "\033[33m",
|
||||||
"ok": "\033[32m",
|
"ok": "\033[32m",
|
||||||
"dim": "\033[90m",
|
"dim": "\033[90m",
|
||||||
|
|
@ -244,6 +246,83 @@ def judge(resultats):
|
||||||
return trouve, illisibles
|
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):
|
def render(database, resultats, version=None, colour=True):
|
||||||
"""Le rapport lisible. Chaque constat dit quoi lancer pour le réparer."""
|
"""Le rapport lisible. Chaque constat dit quoi lancer pour le réparer."""
|
||||||
trouve, illisibles = judge(resultats)
|
trouve, illisibles = judge(resultats)
|
||||||
|
|
@ -266,7 +345,7 @@ def render(database, resultats, version=None, colour=True):
|
||||||
colour,
|
colour,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return "\n".join(lignes)
|
return "\n".join(lignes + verdicts_block(database, colour)).rstrip()
|
||||||
|
|
||||||
for controle, combien in trouve:
|
for controle, combien in trouve:
|
||||||
icone = "❌" if controle["gravity"] == "broken" else "⚠"
|
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))
|
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):
|
def main(argv=None):
|
||||||
|
|
@ -329,6 +408,7 @@ def main(argv=None):
|
||||||
trouve, illisibles = judge(resultats)
|
trouve, illisibles = judge(resultats)
|
||||||
|
|
||||||
if args.json:
|
if args.json:
|
||||||
|
tous_verdicts, verdicts_ratés = verdicts(args.database)
|
||||||
print(
|
print(
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{
|
{
|
||||||
|
|
@ -337,6 +417,11 @@ def main(argv=None):
|
||||||
"checks": resultats,
|
"checks": resultats,
|
||||||
"found": [c["key"] for c, _ in trouve],
|
"found": [c["key"] for c, _ in trouve],
|
||||||
"unreadable": [c["key"] for c, _ in illisibles],
|
"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,
|
indent=2,
|
||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
|
|
|
||||||
|
|
@ -6977,6 +6977,209 @@ TRANSLATIONS = {
|
||||||
"fr": "Qualité de la migration, palier par palier",
|
"fr": "Qualité de la migration, palier par palier",
|
||||||
"en": "Migration quality, step by step",
|
"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": {
|
"database not found": {
|
||||||
"fr": "base introuvable",
|
"fr": "base introuvable",
|
||||||
"en": "database not found",
|
"en": "database not found",
|
||||||
|
|
|
||||||
|
|
@ -1025,26 +1025,71 @@ class TestItNeverWrites(Base):
|
||||||
def test_no_odoo_is_started(self):
|
def test_no_odoo_is_started(self):
|
||||||
# Six démarrages coûteraient une heure ET écriraient dans les
|
# Six démarrages coûteraient une heure ET écriraient dans les
|
||||||
# bases. L'inspection en SQL prend moins d'une demi-seconde.
|
# 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
|
import inspect
|
||||||
|
|
||||||
source = inspect.getsource(quality)
|
for nom, objet in vars(quality).items():
|
||||||
for interdit in ("odoo_bin", "run.sh", "--update", "-u all"):
|
if not inspect.isfunction(objet):
|
||||||
self.assertNotIn(interdit, source, interdit)
|
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):
|
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")]
|
lst = [snapshot(odoo="12.0"), snapshot(odoo="18.0")]
|
||||||
lignes = qtui.rows(lst)
|
lignes = qtui.rows(lst, {})
|
||||||
self.assertEqual(
|
debut = [x["kind"] for x in lignes][:3]
|
||||||
[x["kind"] for x in lignes], ["step", "step", "overall"]
|
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
|
# 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")]
|
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):
|
def test_a_missing_database_gets_its_own_row(self):
|
||||||
lignes = qtui.rows(
|
lignes = qtui.rows(
|
||||||
|
|
@ -1587,5 +1632,358 @@ class TestWhyAnAttachmentWentAway(Base):
|
||||||
self.assertNotEqual("ir_attachment", entree.get("table"))
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
||||||
|
|
@ -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.
|
d'analyse : c'est la faute que ce dépôt a déjà corrigée trois fois.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import unittest
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue