[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:
parent
8fe0bafe10
commit
1f606fed00
6 changed files with 1063 additions and 47 deletions
|
|
@ -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():
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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?": {
|
||||||
|
|
|
||||||
|
|
@ -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)]})
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue