[ADD] verdicts : tous les paliers, leur journal, et la bascule

L'écran de qualité ne listait que les échecs, quand la question devant
une base migrée est « qu'a-t-on vérifié » : les quatorze verdicts
s'affichent, de la 12 à la 18, seule façon de voir qu'un échec a été
rattrapé à un palier plus haut. Le panneau ne portait que la commande ;
il montre le passage du journal d'étape qui l'entoure, garde par tee ce
qu'il lance lui-même et le relit sans relancer. La sortie de l'outil,
elle, part sur le terminal : un tube ferait renoncer les pleins écrans.
Relancer un test d'un autre palier ouvrait la base avec la mauvaise
version, qui y écrit avant d'échouer ; l'écran demande avant de basculer.

--- EN ---

The quality screen listed failures only, when the question in front of a
migrated database is "what did we check": all fourteen verdicts now show,
12 through 18, the only way to see that a failure at one tier was
recovered higher up. The panel carried only the command; it shows the
step-log passage around it, keeps by tee what it runs itself and re-reads
that without rerunning. The tool output goes to the terminal: a pipe
would make full-screen tools give up. Replaying a test from another tier
opened the database with the wrong version, which writes before it
fails; the screen asks before switching the checkout.

Assisted-by: Claude Opus 5
(cherry picked from commit 2d460b7c777d39a887dabfe7cf5405864c6c3f8c)
This commit is contained in:
Mathieu Benoit 2026-08-27 04:36:03 -04:00
parent 8fe0bafe10
commit 1f606fed00
6 changed files with 1063 additions and 47 deletions

View file

@ -36,6 +36,7 @@ Codes de sortie : 0 rien à signaler, 1 des trouvailles, 2 l'outil a échoué.
import json import json
import os import os
import re
import subprocess import subprocess
import sys import sys
@ -134,6 +135,115 @@ def failures(events):
return [e for e in events if e["status"] and e["kind"] == "test"] return [e for e in events if e["status"] and e["kind"] == "test"]
FICHIER_VERSION = ".odoo-version"
def checkout_version(racine=None):
"""La version d'Odoo sur laquelle le checkout est posé, ou None.
Un entier, pas « 18.0 » : c'est ce que porte le nom des bases de
palier et ce que la chaîne de migration rend, et deux formes du même
fait finissent toujours par diverger.
"""
base = racine or os.path.dirname(
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
)
try:
with open(
os.path.join(base, FICHIER_VERSION), encoding="utf-8"
) as handle:
return int(float(handle.read().strip()))
except (OSError, ValueError):
return None
def verdicts(events):
"""TOUS les verdicts de test, réussis compris.
N'afficher que les échecs répondait à « qu'est-ce qui a raté » et à
rien d'autre. Or la question qu'on se pose devant une base migrée est
« qu'a-t-on vérifié », et un test réussi au palier 17 est une preuve
au même titre qu'un raté au 14 — c'est même la seule façon de voir
qu'un échec du 14 a été RATTRAPÉ ensuite.
"""
return [e for e in events if e["kind"] == "test"]
def version_of(database, dct):
"""La version d'Odoo de cette base, lue dans la chaîne de la migration.
Plus sûr que le suffixe du nom : la base de DÉPART n'en porte pas —
« test_neutralize » ne dit pas 12.0 — et son palier retombait alors
sur le compteur du pilote, qui affichait « 2 ».
"""
for version, base in chain(dct):
if base == database:
return version
return None
# Ce que la migration écrit dans le journal d'une étape autour d'un test :
#
# [2026-08-26 03:19:44.166204] $ .venv…/python3 ./script/…/smoke.py -d …
# [2026-08-26 03:19:59.846406] -> 1
# [2026-08-26 03:19:59.847489] [test] smoke_public_url -> 1
#
# Entre le « $ » et le « -> », RIEN : mesuré sur trois exécutions du même
# test, la sortie de l'outil n'est pas capturée. Ce qui explique l'échec
# est donc AVANT, dans ce qu'Odoo écrivait juste avant qu'on le teste —
# et c'est pour cela que l'extrait remonte, au lieu de descendre.
MARQUEUR_COMMANDE = "] $ "
MOTIF_HORODATAGE = re.compile(r"^\[(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d)")
def _horodatage(ligne):
"""La seconde portée par cette ligne de journal, ou ""."""
found = MOTIF_HORODATAGE.match(ligne)
return found.group(1) if found else ""
def event_tool(event):
"""Le script que ce verdict a lancé — « smoke_public_url.py »."""
for mot in (event.get("detail") or "").split():
if mot.endswith(".py"):
return os.path.basename(mot)
return ""
def event_excerpt(lignes, event, avant=18):
"""(extrait, rang de la commande) — le passage qui entoure ce verdict.
Quand la migration a été rejouée, le même test apparaît plusieurs fois
dans le même fichier. On retient donc l'occurrence la plus PROCHE de
l'horodatage du verdict, et non la dernière : la dernière appartient
peut-être à une exécution qui n'est pas celle qu'on regarde.
"""
outil = event_tool(event)
if not outil or not lignes:
return [], None
base = event_database(event)
quand = (event.get("at") or "")[:19]
candidats = [
rang
for rang, ligne in enumerate(lignes)
if MARQUEUR_COMMANDE in ligne
and outil in ligne
and (not base or base in ligne)
]
if not candidats:
return [], None
avant_le_verdict = [
rang for rang in candidats if _horodatage(lignes[rang]) <= quand
]
rang = (avant_le_verdict or candidats)[-1]
fin = rang
for suivant in range(rang, min(rang + 400, len(lignes))):
if "[test] " in lignes[suivant] and event["name"] in lignes[suivant]:
fin = suivant
break
return lignes[max(0, rang - avant) : fin + 1], rang
def event_database(event): def event_database(event):
"""La base sur laquelle ce verdict portait, lue dans sa commande.""" """La base sur laquelle ce verdict portait, lue dans sa commande."""
for mot in event.get("detail", "").split(): for mot in event.get("detail", "").split():

View file

@ -12,7 +12,9 @@ Deux assemblages sépareraient les deux vues, et l'on finirait par lire deux
états contradictoires de la même migration. états contradictoires de la même migration.
""" """
import io
import os import os
import shlex
import subprocess import subprocess
import sys import sys
@ -128,17 +130,20 @@ def extra_rows(presents, dct=None):
cible = presents[-1]["database"] if presents else "" cible = presents[-1]["database"] if presents else ""
lst = [] lst = []
# ── Succès ── # ── Verdicts ──
evenements = quality.read_events(dct) evenements = quality.read_events(dct)
tous = quality.verdicts(evenements)
ratés = quality.failures(evenements) ratés = quality.failures(evenements)
lst.append( lst.append(
{ {
"kind": "header", "kind": "header",
"label": f"── {t('Verdicts')} ──", "label": f"── {t('Verdicts')} ──",
"detail": str(len(ratés)) if ratés else "✅", "detail": (
f"{len(ratés)}/{len(tous)}" if ratés else f"✅ {len(tous)}"
),
} }
) )
if not evenements: if not tous:
lst.append( lst.append(
{ {
"kind": "verdict-none", "kind": "verdict-none",
@ -146,28 +151,30 @@ def extra_rows(presents, dct=None):
"detail": "", "detail": "",
} }
) )
for event in ratés: for event in tous:
base = quality.event_database(event) base = quality.event_database(event)
# Le PALIER dans le libellé : « smoke_public_url » quatre fois de # Le PALIER dans le libellé : « smoke_public_url » sept fois de
# suite ne dit pas lequel a échoué, et c'est la seule chose qu'on # suite ne dit pas lequel on regarde, et c'est la seule chose
# veut savoir en parcourant la colonne. # qu'on veut savoir en parcourant la colonne. Il vient de la
palier = quality.event_step(event, base) # CHAÎNE de la migration, car la base de départ ne le porte pas
# dans son nom — « test_neutralize » ne dit pas 12.0.
version = quality.version_of(base, dct)
palier = str(version) if version else quality.event_step(event, base)
icone = "❌" if event["status"] else "✅"
lst.append( lst.append(
{ {
"kind": "verdict", "kind": "verdict",
"label": f" ❌ {palier:<5} {event['name'][:18]}", "label": f" {icone} {palier:<5} {event['name'][:18]}",
"detail": "▶", "detail": "▶",
"event": event, "event": event,
"database": base or cible, "database": base or cible,
"step": palier,
"log": status.step_log_path(dct, event.get("step")),
"command": rerun_command(event, base or cible), "command": rerun_command(event, base or cible),
} "switch": switch_needed(base or cible, dct),
) "capture": run_log_path(
if evenements and not ratés: "verdict_%s_%s" % (event["name"], base or cible)
lst.append( ),
{
"kind": "verdict-ok",
"label": f" ✅ {len(evenements)} {t('checks, all passed')}",
"detail": "",
} }
) )
@ -208,6 +215,19 @@ def extra_rows(presents, dct=None):
if clef and (cible or "{db}" not in commande) if clef and (cible or "{db}" not in commande)
else "" else ""
), ),
# Les étapes qui nomment une base ont le même écueil que
# les verdicts : le checkout doit être au bon palier.
"switch": (
switch_needed(cible, dct) if "{db}" in commande else None
),
# Le « shell » est un REPL : le passer par un tube lui
# ferait perdre son invite. Les sept autres écrivent et
# s'arrêtent, on peut donc en garder une copie.
"capture": (
run_log_path(f"review_{clef}")
if clef and clef != "shell"
else None
),
} }
) )
return lst return lst
@ -298,7 +318,6 @@ EXTRA_KINDS = (
"header", "header",
"verdict", "verdict",
"verdict-none", "verdict-none",
"verdict-ok",
"source", "source",
"logscan", "logscan",
"review", "review",
@ -321,8 +340,6 @@ def extra_pane(row, colour=False):
t("not in command_executed, which only lists what ran."), t("not in command_executed, which only lists what ran."),
] ]
) )
if genre == "verdict-ok":
return t("Every recorded check returned zero.")
if genre == "source": if genre == "source":
return source_pane(row, colour) return source_pane(row, colour)
if genre == "logscan": if genre == "logscan":
@ -332,11 +349,95 @@ def extra_pane(row, colour=False):
return "" return ""
def verdict_pane(row, colour=False): def log_excerpt_lines(row, colour=False):
"""Ce qu'un verdict raté dit, et ce que son code de retour signifie.""" """Le passage du journal d'étape qui entoure ce verdict.
event = row.get("event") or {}
Ce que le journal contient VRAIMENT, dit sans détour : la commande et
son code, pas la sortie de l'outil. Le pilote l'explique — ce qui
passe par `run_on_terminal` n'a pas de sortie capturable, un tube y
ferait renoncer les pleins écrans. Ce qui précède la commande est donc
tout ce qu'on a, et c'est déjà ce qu'Odoo écrivait au moment du test.
Le taire enverrait chercher un fichier plus complet qui n'existe pas.
"""
chemin = row.get("log")
if not chemin:
return [
status.paint(f" ── {t('step log')} ──", "step", colour),
f" {t('no log file for this step')}",
"",
]
try:
with io.open(chemin, encoding="utf-8", errors="replace") as handle:
brut = handle.read().splitlines()
except OSError:
return []
extrait, _rang = quality.event_excerpt(brut, row.get("event") or {})
lignes = [ lignes = [
status.paint(f"❌ {event.get('name')}", "fail", colour), status.paint(f" ── {t('step log')} ──", "step", colour),
status.paint(f" {chemin}", "dim", colour),
status.paint(f" {len(brut)} {t('lines in all')}", "dim", colour),
"",
]
if not extrait:
lignes.append(f" {t('this verdict is not in it')}")
lignes.append("")
return lignes
for ligne in extrait:
lignes.append(f" {ligne[:150]}")
lignes.append("")
lignes.append(
status.paint(
f" {t('the tool output is not in there: it goes to the')}",
"dim",
colour,
)
)
lignes.append(
status.paint(
f" {t('terminal, which a pipe would make full-screen tools')}",
"dim",
colour,
)
)
lignes.append(
status.paint(
f" {t('give up. What precedes is what Odoo was writing.')}",
"dim",
colour,
)
)
lignes.append("")
return lignes
def switch_needed(database, dct):
"""(version, cible make) si le checkout n'est pas au palier de cette base.
Lancer un outil sur une base d'un autre palier n'est pas seulement
inutile : Odoo ÉCRIT dedans avant d'échouer. Le test de fumée s'en
garde déjà et sort en 2 ; l'écran, lui, peut proposer la bascule au
lieu de laisser relancer trois fois la même erreur.
"""
version = quality.version_of(database, dct)
if not version:
return None
courante = quality.checkout_version()
if not courante or courante == version:
return None
return (version, "switch_odoo_%d" % version)
def verdict_pane(row, colour=False):
"""Ce qu'un verdict dit, ce que son code signifie, et ce qu'on en lit."""
event = row.get("event") or {}
rate = bool(event.get("status"))
lignes = [
status.paint(
f"{'❌' if rate else '✅'} {event.get('name')}",
"fail" if rate else "ok",
colour,
),
"", "",
# Le palier est la version d'ODOO, pas le compteur du pilote : # 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 # « 4.1.I » désigne la première étape du quatrième bloc, et la
@ -359,9 +460,26 @@ def verdict_pane(row, colour=False):
"1 means leftovers remain that it could not drop." "1 means leftovers remain that it could not drop."
), ),
}.get(event.get("name"), "") }.get(event.get("name"), "")
if sens: if sens and rate:
lignes.append(f" {sens}") lignes.append(f" {sens}")
lignes.append("") lignes.append("")
lignes.extend(log_excerpt_lines(row, colour))
lignes.extend(run_log_lines(row, colour))
if row.get("command") and row.get("switch"):
version, cible = row["switch"]
lignes.append(
status.paint(
f" ⚠ {t('the checkout is on Odoo')}"
f" {quality.checkout_version()}.0,"
f" {t('this database is on')} {version}.0",
"warn",
colour,
)
)
lignes.append(
f" {t('r offers to run')} « make {cible} » {t('first')}"
)
lignes.append("")
if row.get("command"): if row.get("command"):
lignes.append( lignes.append(
status.paint( status.paint(
@ -443,6 +561,40 @@ def logscan_pane(colour=False):
return "\n".join(lignes) return "\n".join(lignes)
def run_log_lines(row, colour=False):
"""Ce que la dernière exécution de cette ligne a écrit, ou l'invitation.
Le panneau défile — c'est un VerticalScroll — donc on n'ampute pas :
montrer vingt lignes d'un rapport qui en fait deux cents obligerait à
le relancer dans un terminal pour lire la suite, et l'écran n'aurait
servi qu'à donner envie.
"""
chemin = row.get("capture")
if not chemin:
return []
lst, total = read_run_log(chemin)
if not lst:
if not row.get("command"):
return []
return [
status.paint(f" ── {t('last run')} ──", "step", colour),
status.paint(
f" {t('never run from here yet')}", "dim", colour
),
"",
]
lignes = [
status.paint(f" ── {t('last run')} ──", "step", colour),
status.paint(f" {chemin}", "dim", colour),
status.paint(f" {total} {t('lines in all')}", "dim", colour),
"",
]
for ligne in lst:
lignes.append(f" {ligne}")
lignes.append("")
return lignes
def review_pane(row, colour=False): def review_pane(row, colour=False):
"""Une étape de la revue : ce qu'elle prouve, et ce qu'elle ne prouve pas.""" """Une étape de la revue : ce qu'elle prouve, et ce qu'elle ne prouve pas."""
lignes = [ lignes = [
@ -457,7 +609,7 @@ def review_pane(row, colour=False):
) )
else: else:
lignes.append(f" {t('Read it in the Verdicts section above.')}") lignes.append(f" {t('Read it in the Verdicts section above.')}")
lignes.append("") lignes.extend(run_log_lines(row, colour))
lignes.append(f" {t('What none of these can see')}") lignes.append(f" {t('What none of these can see')}")
lignes.append( lignes.append(
f" {t('They all read the DATABASE. A module that kept a')}" f" {t('They all read the DATABASE. A module that kept a')}"
@ -536,7 +688,94 @@ def mode_label(mode):
return t(mode) return t(mode)
def run_in_terminal(command, wait=True): # La sortie d'une exécution lancée DEPUIS l'écran, gardée pour être
# relue. Celle des tests de la migration ne l'était pas — le pilote lance
# par `run_on_terminal`, qui n'a pas de sortie capturable — mais ce que
# l'on lance soi-même, on peut le garder.
REVIEW_LOG_DIR = os.path.join(".venv.erplibre", "screen_log")
def run_log_path(clef):
"""Le fichier où garder la sortie de cette commande, ou None.
Une clé par ligne de l'écran, et non par commande : deux exécutions
de la même étape doivent se remplacer, pas s'empiler. On veut « ce
que ça donne maintenant », jamais un historique qu'il faudrait trier.
"""
if not clef:
return None
propre = "".join(c if c.isalnum() or c in "._-" else "_" for c in clef)
return os.path.join(REPO_ROOT, REVIEW_LOG_DIR, propre + ".log")
def read_run_log(chemin, limite=4000):
"""(lignes, total) de la derniere execution, ou ([], 0)."""
if not chemin:
return [], 0
try:
with io.open(chemin, encoding="utf-8", errors="replace") as handle:
lst = handle.read().splitlines()
except OSError:
return [], 0
return lst[-limite:], len(lst)
def ask_yes(question, defaut=True):
"""Poser une question fermée dans le terminal rendu. Entrée = défaut.
`input()` et non une boîte de dialogue : on est DÉJÀ sorti de l'écran
quand la question se pose, et y rentrer pour un oui/non ferait
clignoter tout l'affichage entre deux commandes.
"""
suffixe = " [O/n] " if defaut else " [o/N] "
try:
reponse = input(question + suffixe).strip().lower()
except (EOFError, KeyboardInterrupt):
return False
if not reponse:
return defaut
return reponse[0] in ("o", "y")
def run_with_switch(command, switch, wait=True, capture=None):
"""Lancer, en proposant d'abord de basculer le checkout s'il le faut.
Sans cela on relance trois fois la même erreur : le test de fumée
refuse d'ouvrir une base 16 avec un checkout 18 — « l'ouvrir avec la
mauvaise version y écrit avant d'échouer » — et rend 2 sans rien
faire. La bascule est la seule suite possible, autant la proposer.
On DEMANDE au lieu de basculer d'office : `make switch_odoo_16` change
le checkout entier, et quelqu'un peut être en train d'y travailler.
"""
if switch:
version, cible = switch
print()
print(
status.paint(
f"⚠ {t('the checkout is on Odoo')}"
f" {quality.checkout_version()}.0,"
f" {t('this database is on')} {version}.0",
"warn",
True,
)
)
print(
f" {t('opening it with the wrong version writes before it fails.')}"
)
print()
if not ask_yes(f" {t('run')} « make {cible} » {t('first?')}"):
print(f" {t('left as is — the test will refuse and return 2.')}")
else:
code, _tourne = run_in_terminal(f"make {cible}", wait=False)
if code:
print(status.paint(f" ✗ make {cible} → {code}", "fail", True))
if not ask_yes(f" {t('run the test anyway?')}", defaut=False):
return None, False
return run_in_terminal(command, wait=wait, capture=capture)
def run_in_terminal(command, wait=True, capture=None):
"""Rendre le terminal au test, puis le reprendre. (code, a_tourné). """Rendre le terminal au test, puis le reprendre. (code, a_tourné).
L'écran DOIT s'effacer : `smoke_public_url` monte une instance Odoo et L'écran DOIT s'effacer : `smoke_public_url` monte une instance Odoo et
@ -556,6 +795,21 @@ def run_in_terminal(command, wait=True):
print() print()
print(f"▶ {' '.join(argv)}") print(f"▶ {' '.join(argv)}")
print("─" * 72) print("─" * 72)
if capture:
# `tee` garde une copie SANS rien cacher : la sortie continue
# d'aller au terminal en direct. Le prix est la couleur — les
# outils la coupent quand stdout n'est plus un terminal — et
# c'est un bon prix pour un journal qu'on relira dans l'écran.
# `pipefail` pour que le code rendu soit celui de la commande,
# et non celui de `tee`, qui réussit toujours.
os.makedirs(os.path.dirname(capture), exist_ok=True)
argv = [
"bash",
"-o",
"pipefail",
"-c",
"%s 2>&1 | tee %s" % (shlex.join(argv), shlex.quote(capture)),
]
try: try:
code = subprocess.run(argv, cwd=REPO_ROOT).returncode code = subprocess.run(argv, cwd=REPO_ROOT).returncode
except OSError as exc: except OSError as exc:
@ -585,6 +839,11 @@ def build_app(lst_snapshot):
("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")), ("r,enter", "run_selected", t("Run")),
# La table garde les flèches et pgup/pgdn pour ses lignes ;
# ces deux-là ne lui appartiennent pas et restent donc
# disponibles pour le panneau.
("ctrl+u", "pane_up", t("Pane up")),
("ctrl+d", "pane_down", t("Pane down")),
] ]
def __init__(self, lst_snapshot): def __init__(self, lst_snapshot):
@ -674,9 +933,17 @@ def build_app(lst_snapshot):
self.bell() self.bell()
return return
with self.suspend(): with self.suspend():
run_in_terminal(commande) run_with_switch(
commande, row.get("switch"), capture=row.get("capture")
)
self.refresh() self.refresh()
def action_pane_up(self):
self.query_one("#pane", VerticalScroll).scroll_page_up()
def action_pane_down(self):
self.query_one("#pane", VerticalScroll).scroll_page_down()
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

View file

@ -96,6 +96,7 @@ except Exception: # pragma: no cover - repli si i18n indisponible
from script.analyse import check_migration_quality as quality # noqa: E402 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
from script.todo import migration_status as status # noqa: E402
COULEURS = { COULEURS = {
"broken": "\033[31m", "broken": "\033[31m",
@ -285,7 +286,36 @@ def verdicts(database, path=None):
return tous, quality.failures(tous) return tous, quality.failures(tous)
def verdicts_block(database, colour=True, path=None): def extrait_du_journal(dct, event, colour=True, avant=6):
"""Ce que le journal d'étape montre autour de ce verdict.
La commande seule ne dit pas POURQUOI. Le journal, lui, garde ce
qu'Odoo écrivait au moment du test — et c'est tout ce qu'on a : la
sortie de l'outil n'y est pas, parce qu'elle passe par le terminal,
qu'un tube ferait renoncer aux outils en plein écran. Le pilote le
documente à l'endroit où il l'écrit.
Silencieux quand il n'y a rien à montrer : une ligne « pas de
journal » par verdict noierait les quatre qui comptent.
"""
chemin = status.step_log_path(dct, event.get("step"))
if not chemin or avant <= 0:
return []
try:
with open(chemin, "r", encoding="utf-8", errors="replace") as handle:
brut = handle.read().splitlines()
except OSError:
return []
extrait, _rang = quality.event_excerpt(brut, event, avant=avant)
if not extrait:
return []
lignes = [paint(f" {chemin}", "dim", colour)]
for ligne in extrait:
lignes.append(paint(f" {ligne[:150]}", "dim", colour))
return lignes
def verdicts_block(database, colour=True, path=None, lignes_avant=6):
"""La section « Verdicts », ou rien du tout s'il n'y en a pas. """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 Silencieuse quand le fichier n'existe pas : devant la sauvegarde d'un
@ -310,8 +340,10 @@ def verdicts_block(database, colour=True, path=None):
colour, colour,
) )
) )
dct = quality.read_progression(chemin)
for event in ratés: for event in ratés:
palier = quality.event_step(event) version = quality.version_of(quality.event_database(event), dct)
palier = str(version) if version else quality.event_step(event)
lignes.append( lignes.append(
paint( paint(
f"❌ {palier.rjust(6)} {event['name']}", f"❌ {palier.rjust(6)} {event['name']}",
@ -322,6 +354,7 @@ def verdicts_block(database, colour=True, path=None):
lignes.append( lignes.append(
paint(f" {event['detail'][:120]}", "dim", colour) paint(f" {event['detail'][:120]}", "dim", colour)
) )
lignes.extend(extrait_du_journal(dct, event, colour, lignes_avant))
lignes.append("") lignes.append("")
lignes.append( lignes.append(
paint( paint(
@ -331,6 +364,15 @@ def verdicts_block(database, colour=True, path=None):
colour, colour,
) )
) )
if ratés and lignes_avant > 0:
lignes.append(
paint(
f" {t('the tool output is not in the step log: it goes')}"
f" {t('to the terminal and dies with it.')}",
"dim",
colour,
)
)
return lignes return lignes

View file

@ -3499,8 +3499,8 @@ TRANSLATIONS = {
"en": "vendors document two, not more.", "en": "vendors document two, not more.",
}, },
"Measured at level 4: 36x slower, then a frozen kernel.": { "Measured at level 4: 36x slower, then a frozen kernel.": {
"fr": "Mesuré au niveau 4 : 36x plus lent, puis noyau gelé.", "fr": "Mesuré au niveau 4 : 36x plus lent, et 12 vCPU gelaient.",
"en": "Measured at level 4: 36x slower, then a frozen kernel.", "en": "Measured at level 4: 36x slower, and 12 vCPU froze.",
}, },
"vCPU capped to": { "vCPU capped to": {
"fr": "vCPU borné à", "fr": "vCPU borné à",
@ -6978,6 +6978,95 @@ TRANSLATIONS = {
"en": "Migration quality, step by step", "en": "Migration quality, step by step",
}, },
# --- Écran de qualité : Verdicts, Validation, Revue --- # --- Écran de qualité : Verdicts, Validation, Revue ---
# --- Journal d'étape et bascule de version ---
"last run": {
"fr": "dernière exécution",
"en": "last run",
},
"never run from here yet": {
"fr": "jamais lancée depuis ici",
"en": "never run from here yet",
},
"Pane up": {
"fr": "Panneau ↑",
"en": "Pane up",
},
"Pane down": {
"fr": "Panneau ↓",
"en": "Pane down",
},
"the tool output is not in the step log: it goes": {
"fr": "la sortie de l'outil n'est pas dans le journal d'étape : elle part",
"en": "the tool output is not in the step log: it goes",
},
"to the terminal and dies with it.": {
"fr": "sur le terminal et meurt avec lui.",
"en": "to the terminal and dies with it.",
},
"r offers to run": {
"fr": "r propose de lancer",
"en": "r offers to run",
},
"first": {
"fr": "d'abord",
"en": "first",
},
"step log": {
"fr": "journal de l'étape",
"en": "step log",
},
"no log file for this step": {
"fr": "aucun journal pour cette étape",
"en": "no log file for this step",
},
"lines in all": {
"fr": "lignes en tout",
"en": "lines in all",
},
"this verdict is not in it": {
"fr": "ce verdict n'y figure pas",
"en": "this verdict is not in it",
},
"the tool output is not in there: it goes to the": {
"fr": "la sortie de l'outil n'y est pas : elle part sur le",
"en": "the tool output is not in there: it goes to the",
},
"terminal, which a pipe would make full-screen tools": {
"fr": "terminal, qu'un tube ferait renoncer aux outils en",
"en": "terminal, which a pipe would make full-screen tools",
},
"give up. What precedes is what Odoo was writing.": {
"fr": "plein écran. Ce qui précède est ce qu'Odoo écrivait.",
"en": "give up. What precedes is what Odoo was writing.",
},
"the checkout is on Odoo": {
"fr": "le checkout est sur Odoo",
"en": "the checkout is on Odoo",
},
"this database is on": {
"fr": "cette base est en",
"en": "this database is on",
},
"opening it with the wrong version writes before it fails.": {
"fr": "l'ouvrir avec la mauvaise version y écrit avant d'échouer.",
"en": "opening it with the wrong version writes before it fails.",
},
"run": {
"fr": "lancer",
"en": "run",
},
"first?": {
"fr": "d'abord ?",
"en": "first?",
},
"left as is — the test will refuse and return 2.": {
"fr": "laissé tel quel — le test refusera et rendra 2.",
"en": "left as is — the test will refuse and return 2.",
},
"run the test anyway?": {
"fr": "lancer le test quand même ?",
"en": "run the test anyway?",
},
# --- Trous dans les manifestes Google Repo --- # --- Trous dans les manifestes Google Repo ---
# --- Type de vue « tree » resté dans un module --- # --- Type de vue « tree » resté dans un module ---
"Is any module still on a pre-18 view type?": { "Is any module still on a pre-18 view type?": {

View file

@ -1832,12 +1832,6 @@ class TestTheThreeExtraSections(Base):
lst = qtui.extra_rows([], {}) lst = qtui.extra_rows([], {})
self.assertIn("verdict-none", [r["kind"] for r in lst]) 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): def test_a_failed_verdict_names_its_step_in_the_label(self):
# « smoke_public_url » quatre fois de suite ne dit pas lequel a # « smoke_public_url » quatre fois de suite ne dit pas lequel a
# échoué, et c'est la seule chose qu'on veut savoir. # échoué, et c'est la seule chose qu'on veut savoir.
@ -1852,17 +1846,9 @@ class TestTheThreeExtraSections(Base):
self.assertEqual("▶", ligne["detail"]) self.assertEqual("▶", ligne["detail"])
self.assertTrue(ligne["command"]) 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): def premier_entete(self, lst):
return [r for r in lst if r["kind"] == "header"][0] 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): def test_review_steps_target_the_last_database(self):
presents = [ presents = [
{"database": "base_upgrade_17", "exists": True}, {"database": "base_upgrade_17", "exists": True},
@ -1902,6 +1888,398 @@ class TestTheThreeExtraSections(Base):
self.assertEqual("", ligne["command"], ligne["question"]) self.assertEqual("", ligne["command"], ligne["question"])
class TestEveryVerdictIsListed(Base):
"""N'afficher que les échecs répondait à une autre question.
Devant une base migrée on se demande « qu'a-t-on vérifié », pas
seulement « qu'est-ce qui a raté ». Et c'est la seule façon de voir
qu'un échec du palier 14 a été RATTRAPÉ au 17.
"""
def journal(self, *statuts):
lst = []
for rang, statut in enumerate(statuts):
version = 13 + rang
lst.append(
evenement(
status=statut,
detail=(
"./script/odoo/migration/smoke_public_url.py"
" -d base_upgrade_%d" % version
),
)
)
return {
"lst_event": lst,
"config_database_name": "base",
"target_odoo_version": "18.0",
"state_4_upgrade_odoo_lst": [1, 2, 3, 4, 5, 6],
}
def test_a_passing_verdict_gets_its_own_line(self):
lst = qtui.extra_rows([], self.journal(0, 0))
lignes = [r for r in lst if r["kind"] == "verdict"]
self.assertEqual(2, len(lignes))
for ligne in lignes:
self.assertIn("✅", ligne["label"])
def test_a_failure_and_a_success_sit_side_by_side(self):
lst = qtui.extra_rows([], self.journal(1, 0))
icones = [
"❌" if "❌" in r["label"] else "✅"
for r in lst
if r["kind"] == "verdict"
]
self.assertEqual(["❌", "✅"], icones)
def test_the_header_counts_failures_over_the_total(self):
lst = qtui.extra_rows([], self.journal(1, 0, 0))
entete = [r for r in lst if r["kind"] == "header"][0]
self.assertEqual("1/3", entete["detail"])
def test_an_all_green_run_says_how_many(self):
lst = qtui.extra_rows([], self.journal(0, 0, 0))
entete = [r for r in lst if r["kind"] == "header"][0]
self.assertIn("3", entete["detail"])
self.assertIn("✅", entete["detail"])
def test_the_starting_database_gets_its_real_version(self):
# Elle ne porte pas « _upgrade_ » dans son nom : le palier
# retombait alors sur le compteur du pilote et affichait « 2 ».
dct = {
"lst_event": [
evenement(
status=0,
step="2 - Update all addons",
detail="./script/odoo/migration/smoke_public_url.py -d base",
)
],
"config_database_name": "base",
"target_odoo_version": "18.0",
"state_4_upgrade_odoo_lst": [1, 2, 3, 4, 5, 6],
}
ligne = [
r for r in qtui.extra_rows([], dct) if r["kind"] == "verdict"
][0]
self.assertIn("12", ligne["label"])
self.assertNotIn(" 2 ", ligne["label"])
def test_a_dialogue_answer_is_still_not_a_verdict(self):
# Les entrées `command` à 1 sont les réponses du pilote ; les
# lister ferait sept faux échecs par migration.
dct = self.journal(0)
dct["lst_event"].append(
evenement(
kind="command", name="./odoo_bin.sh db --clone", status=1
)
)
lignes = [
r for r in qtui.extra_rows([], dct) if r["kind"] == "verdict"
]
self.assertEqual(1, len(lignes))
class TestTheStepLogInThePanel(Base):
"""Ce que le journal contient vraiment, dit sans détour."""
def setUp(self):
super().setUp()
import shutil
import tempfile
self.dossier = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, self.dossier)
def journal(self, lignes):
chemin = os.path.join(self.dossier, "etape.log")
with io.open(chemin, "w", encoding="utf-8") as handle:
handle.write("\n".join(lignes) + "\n")
return chemin
def ligne_verdict(self, chemin, **champs):
return {
"kind": "verdict",
"label": "",
"event": evenement(**champs),
"log": chemin,
"command": "script/odoo/migration/smoke_public_url.py -d x",
}
def test_the_passage_around_the_verdict_is_shown(self):
chemin = self.journal(
[
"2026-08-26 03:19:00,000 INFO odoo: avant",
"[2026-08-26 03:19:44.166204] $ ./script/odoo/migration/"
"smoke_public_url.py -d test_neutralize_upgrade_14",
"[2026-08-26 03:19:59.846406] -> 1",
"[2026-08-26 03:19:59.847489] [test] smoke_public_url -> 1",
"2026-08-26 03:20:00,000 INFO odoo: après",
]
)
texte = qtui.extra_pane(self.ligne_verdict(chemin))
self.assertIn("INFO odoo: avant", texte)
self.assertIn("[test] smoke_public_url -> 1", texte)
self.assertNotIn("INFO odoo: après", texte)
def test_it_says_the_tool_output_is_absent(self):
# Sans cela on cherche un fichier plus complet qui n'existe pas :
# ce qui passe par run_on_terminal n'a pas de sortie capturable.
chemin = self.journal(
[
"[2026-08-26 03:19:44.166204] $ ./script/odoo/migration/"
"smoke_public_url.py -d test_neutralize_upgrade_14",
"[2026-08-26 03:19:59.847489] [test] smoke_public_url -> 1",
]
)
texte = qtui.extra_pane(self.ligne_verdict(chemin))
self.assertIn(
qtui.t("the tool output is not in there: it goes to the"), texte
)
def test_the_path_and_the_size_are_documented(self):
chemin = self.journal(["a"] * 40)
texte = qtui.extra_pane(self.ligne_verdict(chemin))
self.assertIn(chemin, texte)
self.assertIn("40", texte)
def test_no_log_at_all_says_so(self):
ligne = self.ligne_verdict(None)
ligne["log"] = None
texte = qtui.extra_pane(ligne)
self.assertIn(qtui.t("no log file for this step"), texte)
def test_a_verdict_absent_from_the_log_says_so(self):
chemin = self.journal(["rien à voir", "vraiment rien"])
texte = qtui.extra_pane(self.ligne_verdict(chemin))
self.assertIn(qtui.t("this verdict is not in it"), texte)
def test_the_tool_is_read_from_the_command_not_from_the_name(self):
# Le nom du test se retrouve dans d'AUTRES lignes du journal — une
# cible make qui le mentionne, par exemple. Chercher le nom au
# lieu du script y accroche la mauvaise commande, et l'extrait
# montre alors un passage qui n'a rien à voir.
chemin = self.journal(
[
"[2026-08-26 03:19:44.000000] $ ./script/odoo/migration/"
"database_cleanup.py -d test_neutralize_upgrade_14",
"[2026-08-26 03:19:50.000000] [test] database_cleanup -> 0",
"[2026-08-26 03:19:52.000000] $ make database_cleanup_all",
"[2026-08-26 03:19:55.000000] -> 0",
]
)
with io.open(chemin, encoding="utf-8") as handle:
brut = handle.read().splitlines()
_extrait, rang = quality.event_excerpt(
brut,
evenement(
name="database_cleanup",
at="2026-08-26 03:19:56.000000",
detail="./script/odoo/migration/database_cleanup.py"
" -d test_neutralize_upgrade_14",
),
)
self.assertEqual(0, rang)
def test_a_verdict_launched_by_no_script_matches_nothing(self):
# Sans « .py » dans la commande on ne sait pas quoi chercher ;
# deviner accrocherait la première ligne venue.
chemin = self.journal(
["[2026-08-26 03:19:44.000000] $ make quelque_chose"]
)
with io.open(chemin, encoding="utf-8") as handle:
brut = handle.read().splitlines()
_extrait, rang = quality.event_excerpt(
brut, evenement(name="quelque_chose", detail="make quelque_chose")
)
self.assertIsNone(rang)
def test_a_replayed_migration_shows_the_matching_run(self):
# Le même test apparaît plusieurs fois : la dernière occurrence
# peut appartenir à une exécution qui n'est pas celle qu'on
# regarde. On retient celle qui précède l'horodatage du verdict.
chemin = self.journal(
[
"[2026-08-23 00:43:59.331479] $ ./script/odoo/migration/"
"smoke_public_url.py -d test_neutralize_upgrade_14",
"[2026-08-23 00:44:19.776150] [test] smoke_public_url -> 1",
"premiere execution finie",
"[2026-08-26 03:19:44.166204] $ ./script/odoo/migration/"
"smoke_public_url.py -d test_neutralize_upgrade_14",
"[2026-08-26 03:19:59.847489] [test] smoke_public_url -> 1",
]
)
with io.open(chemin, encoding="utf-8") as handle:
brut = handle.read().splitlines()
_extrait, rang = quality.event_excerpt(
brut, evenement(at="2026-08-23 00:44:19.776150")
)
self.assertEqual(0, rang)
class TestKeepingWhatARunWrote(Base):
"""Ce que la migration ne pouvait pas garder, l'écran le peut.
Le pilote lance par `run_on_terminal`, qui n'a pas de sortie
capturable — un tube y ferait renoncer les pleins écrans. Mais ce que
l'on lance SOI-MÊME depuis l'écran, on peut en garder une copie, et
la relire sans relancer.
"""
def setUp(self):
super().setUp()
import shutil
import tempfile
self.dossier = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, self.dossier)
def fichier(self, contenu):
chemin = os.path.join(self.dossier, "run.log")
with io.open(chemin, "w", encoding="utf-8") as handle:
handle.write(contenu)
return chemin
def test_one_file_per_screen_line_not_per_run(self):
# Deux exécutions de la même étape se remplacent : on veut « ce
# que ça donne maintenant », pas un historique à trier.
self.assertEqual(
qtui.run_log_path("review_residue"),
qtui.run_log_path("review_residue"),
)
self.assertNotEqual(
qtui.run_log_path("review_residue"),
qtui.run_log_path("review_state"),
)
def test_a_key_that_could_escape_the_directory_cannot(self):
chemin = os.path.realpath(qtui.run_log_path("../../etc/passwd"))
dossier = os.path.realpath(
os.path.join(qtui.REPO_ROOT, qtui.REVIEW_LOG_DIR)
)
self.assertEqual(dossier, os.path.dirname(chemin))
self.assertNotIn(os.sep, os.path.basename(chemin))
def test_no_key_means_no_file(self):
self.assertIsNone(qtui.run_log_path(""))
def test_a_missing_file_reads_as_nothing(self):
lst, total = qtui.read_run_log(
os.path.join(self.dossier, "jamais_ecrit.log")
)
self.assertEqual(([], 0), (lst, total))
def test_the_total_is_told_even_when_the_tail_is_kept(self):
# Montrer la fin SANS dire qu'on en cache se lit « il manque des
# lignes ».
chemin = self.fichier("\n".join("l%d" % i for i in range(50)))
lst, total = qtui.read_run_log(chemin, limite=10)
self.assertEqual(10, len(lst))
self.assertEqual(50, total)
def test_the_whole_output_is_shown_not_a_sample(self):
# Le panneau DÉFILE : amputer obligerait à relancer dans un
# terminal pour lire la suite.
chemin = self.fichier("\n".join("ligne %d" % i for i in range(300)))
row = {
"kind": "review",
"question": "Does Odoo load the database?",
"command": "x",
"capture": chemin,
}
texte = qtui.extra_pane(row)
self.assertIn("ligne 0", texte)
self.assertIn("ligne 299", texte)
def test_a_line_never_run_says_so(self):
row = {
"kind": "review",
"question": "Does Odoo load the database?",
"command": "x",
"capture": os.path.join(self.dossier, "absent.log"),
}
self.assertIn(qtui.t("never run from here yet"), qtui.extra_pane(row))
def test_a_line_with_nothing_to_run_promises_no_log(self):
row = {
"kind": "review",
"question": "Did the migration reach the end?",
"command": "",
"capture": os.path.join(self.dossier, "absent.log"),
}
self.assertNotIn(
qtui.t("never run from here yet"), qtui.extra_pane(row)
)
def test_the_repl_step_is_not_captured(self):
# Le passer par un tube lui ferait perdre son invite.
lignes = qtui.rows([snapshot(odoo="18.0")], {})
shell = [
row
for row in lignes
if row["kind"] == "review"
and "shell" in (row.get("command") or "")
]
self.assertTrue(shell)
self.assertIsNone(shell[0].get("capture"))
def test_every_other_runnable_step_is_captured(self):
lignes = qtui.rows([snapshot(odoo="18.0")], {})
lancables = [
row
for row in lignes
if row["kind"] == "review" and row.get("command")
]
self.assertTrue(lancables)
sans = [r for r in lancables if not r.get("capture")]
self.assertEqual(1, len(sans), [r.get("command") for r in sans])
class TestSwitchingTheCheckoutFirst(Base):
"""Lancer un outil sur une base d'un autre palier ÉCRIT dedans."""
def dct(self):
return {
"config_database_name": "base",
"target_odoo_version": "18.0",
"state_4_upgrade_odoo_lst": [1, 2, 3, 4, 5, 6],
}
def test_the_same_version_needs_no_switch(self):
courante = quality.checkout_version()
self.assertIsNone(
qtui.switch_needed("base_upgrade_%d" % courante, self.dct())
)
def test_another_tier_names_its_make_target(self):
courante = quality.checkout_version()
autre = 16 if courante != 16 else 15
besoin = qtui.switch_needed("base_upgrade_%d" % autre, self.dct())
self.assertEqual((autre, "switch_odoo_%d" % autre), besoin)
def test_a_database_outside_the_chain_asks_for_nothing(self):
# On ne sait pas à quel palier elle est : proposer une bascule au
# hasard basculerait le checkout pour rien.
self.assertIsNone(qtui.switch_needed("une_autre_base", self.dct()))
def test_the_panel_warns_before_the_key_not_after(self):
courante = quality.checkout_version()
autre = 16 if courante != 16 else 15
ligne = {
"kind": "verdict",
"label": "",
"event": evenement(),
"log": None,
"command": "script/odoo/migration/smoke_public_url.py -d x",
"switch": (autre, "switch_odoo_%d" % autre),
}
texte = qtui.extra_pane(ligne)
self.assertIn("switch_odoo_%d" % autre, texte)
self.assertLess(
texte.index("switch_odoo_%d" % autre),
texte.index(qtui.t("press r to run it again")),
)
class TestTheVerdictPanel(Base): class TestTheVerdictPanel(Base):
def panneau(self, **champs): def panneau(self, **champs):
lst = qtui.extra_rows([], {"lst_event": [evenement(**champs)]}) lst = qtui.extra_rows([], {"lst_event": [evenement(**champs)]})

View file

@ -450,6 +450,135 @@ def verdict(**champs):
return brut return brut
class TestTheStepLogInTheReport(unittest.TestCase):
"""La commande seule ne dit pas POURQUOI."""
def setUp(self):
import json
import shutil
import tempfile
self.dossier = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, self.dossier)
self.json = json
self.progression = os.path.join(self.dossier, "progression.json")
# `step_log_path` cherche sous private/odoo/migration/<base>/step_log
self.ancien = residue.status.PATH_MIGRATION_PRIVATE
residue.status.PATH_MIGRATION_PRIVATE = os.path.join(
self.dossier, "private"
)
self.addCleanup(
setattr,
residue.status,
"PATH_MIGRATION_PRIVATE",
self.ancien,
)
def ecrire_progression(self, evenements):
with io.open(self.progression, "w", encoding="utf-8") as handle:
self.json.dump(
{
"lst_event": evenements,
"config_database_name": "test_neutralize",
"target_odoo_version": "18.0",
"state_4_upgrade_odoo_lst": [1, 2, 3, 4, 5, 6],
},
handle,
)
return self.progression
def ecrire_journal(self, step, lignes):
dossier = os.path.join(
residue.status.PATH_MIGRATION_PRIVATE,
"test_neutralize",
"step_log",
)
os.makedirs(dossier, exist_ok=True)
chemin = os.path.join(dossier, residue.status.step_slug(step) + ".log")
with io.open(chemin, "w", encoding="utf-8") as handle:
handle.write("\n".join(lignes) + "\n")
return chemin
def bloc(self, **kw):
return "\n".join(
residue.verdicts_block(
"test_neutralize_upgrade_14", False, self.progression, **kw
)
)
def test_the_passage_around_the_failure_is_shown(self):
self.ecrire_progression([verdict()])
self.ecrire_journal(
"4.1.I - Migrate database",
[
"odoo: ce qui precedait",
"[2026-08-26 03:19:44.166204] $ .venv.erplibre/bin/python3"
" ./script/odoo/migration/smoke_public_url.py"
" -d test_neutralize_upgrade_14 --internal-required",
"[2026-08-26 03:19:59.847489] [test] smoke_public_url -> 1",
"odoo: ce qui suivait",
],
)
texte = self.bloc()
self.assertIn("odoo: ce qui precedait", texte)
self.assertIn("[test] smoke_public_url -> 1", texte)
self.assertNotIn("odoo: ce qui suivait", texte)
def test_the_log_path_is_named(self):
self.ecrire_progression([verdict()])
chemin = self.ecrire_journal(
"4.1.I - Migrate database",
[
"[2026-08-26 03:19:44.166204] $ ./script/odoo/migration/"
"smoke_public_url.py -d test_neutralize_upgrade_14",
"[2026-08-26 03:19:59.847489] [test] smoke_public_url -> 1",
],
)
self.assertIn(chemin, self.bloc())
def test_no_step_log_stays_silent(self):
# Une ligne « pas de journal » par verdict noierait ceux qui
# comptent.
self.ecrire_progression([verdict()])
texte = self.bloc()
self.assertIn("smoke_public_url", texte)
self.assertNotIn("step_log", texte)
def test_asking_for_none_shows_none(self):
self.ecrire_progression([verdict()])
self.ecrire_journal(
"4.1.I - Migrate database",
[
"[2026-08-26 03:19:44.166204] $ ./script/odoo/migration/"
"smoke_public_url.py -d test_neutralize_upgrade_14",
"[2026-08-26 03:19:59.847489] [test] smoke_public_url -> 1",
],
)
self.assertNotIn("[test]", self.bloc(lignes_avant=0))
def test_it_says_the_tool_output_is_elsewhere(self):
# Sans cela on cherche dans le journal une sortie qui n'y a
# jamais été écrite.
self.ecrire_progression([verdict()])
self.assertIn(
residue.t("the tool output is not in the step log: it goes"),
self.bloc(),
)
def test_an_all_green_run_does_not_carry_that_warning(self):
self.ecrire_progression([verdict(status=0)])
self.assertNotIn(
residue.t("the tool output is not in the step log: it goes"),
self.bloc(),
)
def test_the_step_shown_is_the_odoo_version(self):
# « 4.1.I » est le compteur du pilote ; la migration en est au 14.
self.ecrire_progression([verdict()])
texte = self.bloc()
self.assertIn("14 smoke_public_url", texte)
class TestTheControlsThemselves(unittest.TestCase): class TestTheControlsThemselves(unittest.TestCase):
"""Un contrôle retiré ne doit pas survivre dans les tests. """Un contrôle retiré ne doit pas survivre dans les tests.
@ -464,7 +593,8 @@ class TestTheControlsThemselves(unittest.TestCase):
import re import re
connues = {c["key"] for c in residue.CONTROLES} connues = {c["key"] for c in residue.CONTROLES}
source = io.open(__file__, encoding="utf-8").read() with io.open(__file__, encoding="utf-8") as handle:
source = handle.read()
nommees = set(re.findall(r'resultats\["([a-z_]+)"\]', source)) nommees = set(re.findall(r'resultats\["([a-z_]+)"\]', source))
nommees |= set(re.findall(r"dict\(vide, ([a-z_]+)=", source)) nommees |= set(re.findall(r"dict\(vide, ([a-z_]+)=", source))
self.assertTrue(nommees) self.assertTrue(nommees)