[ADD] migration: « t » shows where the migration stands
A migration crosses six bumps, runs hundreds of commands and lasts hours.
The journal said what had been LAUNCHED; it never said what came of it.
Three hours in, one reads two hundred command lines without knowing which
one failed, nor what the smoke test concluded.
« v » could not carry this: it already means « view the differences » in
three prompts, and a letter meaning two things is worse than a letter
meaning nothing. « t » was free, and it is the same everywhere — one
shared string carries both shortcuts, so no prompt can drift.
Looking is not answering: the same question comes back afterwards.
What was missing was the data. Failures and tool verdicts are now
recorded with the step they happened in, bounded so a progression file
cannot grow without end. A tool rerun after a repair keeps its LAST
verdict and the count of its runs — showing both without distinction
would read a repair as a lasting failure.
--- FR ---
[ADD] migration : « t » montre où en est la migration
Une migration traverse six paliers, lance des centaines de commandes et
dure des heures. Le journal disait ce qui avait été LANCÉ, jamais ce que
cela avait donné. Trois heures plus tard on relit deux cents lignes sans
savoir laquelle a échoué, ni ce que le test de fumée a conclu.
« v » ne pouvait pas porter cela : il veut déjà dire « voir les
différences » dans trois invites, et une lettre qui signifie deux choses
est pire qu'une lettre qui ne signifie rien. « t » était libre, et il est
le même partout — une seule chaîne porte les deux raccourcis.
Regarder n'est pas répondre : la même question revient ensuite.
Ce qui manquait, c'étaient les données. Les échecs et les verdicts
d'outils sont désormais retenus avec l'étape où ils se sont produits.
Assisted-by: Claude Opus 5
2026-08-19 03:53:56 -04:00
|
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
|
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
|
|
|
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
|
|
|
|
|
|
|
|
|
|
"""« Où en est-on, et qu'est-ce qui a cassé ? »
|
|
|
|
|
|
|
|
|
|
|
|
Une migration traverse six paliers, lance des centaines de commandes et
|
|
|
|
|
|
dure des heures. Le journal existant dit ce qui a été LANCÉ ; il ne dit
|
|
|
|
|
|
jamais ce que cela a donné. Trois heures plus tard on relit deux cents
|
|
|
|
|
|
lignes de commandes sans savoir laquelle a échoué, ni ce que le test de
|
|
|
|
|
|
fumée a conclu.
|
|
|
|
|
|
|
|
|
|
|
|
Deux choses se vérifient ici, et la seconde est la moins évidente :
|
|
|
|
|
|
|
|
|
|
|
|
- un outil relancé APRÈS correction a deux verdicts contradictoires dans
|
|
|
|
|
|
le journal, et c'est le dernier qui décrit la base telle qu'elle est.
|
|
|
|
|
|
Les afficher tous les deux sans les distinguer ferait lire une
|
|
|
|
|
|
réparation comme un échec persistant ;
|
|
|
|
|
|
- l'écran ne doit RIEN toucher. On l'ouvre en pleine migration, souvent
|
|
|
|
|
|
pendant qu'un serveur tourne.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
import io
|
|
|
|
|
|
import os
|
[ADD] migration: keep the logs on disk, one file per step
The state screen showed « no tool has run yet » after closing and
reopening. It was reading the progression file, and that file is archived
and reset when a migration restarts — so everything it knew vanished at
the exact moment one wants to understand why the restart was needed.
Each step now has its own log file, appended never overwritten, and the
failures and tool verdicts go to an append-only JSONL beside them. Both
outlive the progression, and the screen reads disk first, memory second,
without counting the overlap twice.
The command output itself is captured where every line already passes,
in the executor's read loop: the terminal still shows it live and nothing
about the run changes. What goes through the real terminal cannot be
captured — a pipe there makes full screens refuse, that lesson is paid —
so those keep at least their command and their exit code.
--- FR ---
[ADD] migration : garder les journaux sur disque, un fichier par étape
L'écran d'état affichait « aucun outil n'a encore tourné » après une
fermeture. Il lisait le fichier de progression, or celui-ci est archivé
puis remis à zéro quand on recommence : tout ce qu'il savait disparaissait
au moment précis où l'on cherche pourquoi il a fallu recommencer.
Chaque étape a désormais son fichier, en ajout et jamais en écrasement, et
les échecs comme les verdicts d'outils vont dans un JSONL à côté. Les deux
survivent à la progression, et l'écran lit le disque d'abord.
La sortie des commandes est captée là où chaque ligne passe déjà, dans la
boucle de lecture de l'exécuteur : le terminal la montre toujours en
direct. Ce qui passe par le vrai terminal n'est pas captable — un tube y
ferait renoncer les pleins écrans — et garde au moins son verdict.
Assisted-by: Claude Opus 5
2026-08-19 04:32:25 -04:00
|
|
|
|
import shutil
|
[ADD] migration: « t » shows where the migration stands
A migration crosses six bumps, runs hundreds of commands and lasts hours.
The journal said what had been LAUNCHED; it never said what came of it.
Three hours in, one reads two hundred command lines without knowing which
one failed, nor what the smoke test concluded.
« v » could not carry this: it already means « view the differences » in
three prompts, and a letter meaning two things is worse than a letter
meaning nothing. « t » was free, and it is the same everywhere — one
shared string carries both shortcuts, so no prompt can drift.
Looking is not answering: the same question comes back afterwards.
What was missing was the data. Failures and tool verdicts are now
recorded with the step they happened in, bounded so a progression file
cannot grow without end. A tool rerun after a repair keeps its LAST
verdict and the count of its runs — showing both without distinction
would read a repair as a lasting failure.
--- FR ---
[ADD] migration : « t » montre où en est la migration
Une migration traverse six paliers, lance des centaines de commandes et
dure des heures. Le journal disait ce qui avait été LANCÉ, jamais ce que
cela avait donné. Trois heures plus tard on relit deux cents lignes sans
savoir laquelle a échoué, ni ce que le test de fumée a conclu.
« v » ne pouvait pas porter cela : il veut déjà dire « voir les
différences » dans trois invites, et une lettre qui signifie deux choses
est pire qu'une lettre qui ne signifie rien. « t » était libre, et il est
le même partout — une seule chaîne porte les deux raccourcis.
Regarder n'est pas répondre : la même question revient ensuite.
Ce qui manquait, c'étaient les données. Les échecs et les verdicts
d'outils sont désormais retenus avec l'étape où ils se sont produits.
Assisted-by: Claude Opus 5
2026-08-19 03:53:56 -04:00
|
|
|
|
import unittest
|
|
|
|
|
|
from contextlib import redirect_stdout
|
|
|
|
|
|
|
|
|
|
|
|
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
|
|
|
|
|
|
|
|
from script.todo import migration_status as status # noqa: E402
|
|
|
|
|
|
from script.todo import migration_status_tui as tui # noqa: E402
|
|
|
|
|
|
from script.todo import todo_i18n # noqa: E402
|
|
|
|
|
|
from script.todo.todo_upgrade import TodoUpgrade # noqa: E402
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def progression(**override):
|
|
|
|
|
|
dct = {
|
|
|
|
|
|
"migration_file": "/a/b/technolibre_2026.zip",
|
|
|
|
|
|
"config_database_name": "test_neutralize",
|
|
|
|
|
|
"date_create": "2026-08-18 03:25:25",
|
|
|
|
|
|
"date_update": "2026-08-18 06:38:55",
|
|
|
|
|
|
"command_executed": [
|
|
|
|
|
|
"# 0 - Inspect zip",
|
|
|
|
|
|
"make switch_odoo_12",
|
|
|
|
|
|
"# 2 - Succeed update all addons",
|
|
|
|
|
|
"./script/addons/update_addons_all.sh test",
|
|
|
|
|
|
"./script/odoo/migration/check_cow_views.py -d test",
|
|
|
|
|
|
],
|
|
|
|
|
|
"lst_event": [
|
|
|
|
|
|
{
|
|
|
|
|
|
"at": "05:01",
|
|
|
|
|
|
"step": "2 - Succeed update all addons",
|
|
|
|
|
|
"kind": "command",
|
|
|
|
|
|
"name": "update_addons_all.sh test",
|
|
|
|
|
|
"status": 1,
|
|
|
|
|
|
"detail": "",
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"at": "05:20",
|
|
|
|
|
|
"step": "2 - Succeed update all addons",
|
|
|
|
|
|
"kind": "test",
|
|
|
|
|
|
"name": "smoke_public_url",
|
|
|
|
|
|
"status": 2,
|
|
|
|
|
|
"detail": "",
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"at": "05:44",
|
|
|
|
|
|
"step": "2 - Succeed update all addons",
|
|
|
|
|
|
"kind": "test",
|
|
|
|
|
|
"name": "smoke_public_url",
|
|
|
|
|
|
"status": 0,
|
|
|
|
|
|
"detail": "",
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
}
|
|
|
|
|
|
dct.update(override)
|
|
|
|
|
|
return dct
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Base(unittest.TestCase):
|
|
|
|
|
|
def setUp(self):
|
|
|
|
|
|
self.addCleanup(
|
|
|
|
|
|
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
|
|
|
|
|
|
)
|
|
|
|
|
|
todo_i18n._current_lang = "en"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestCuttingTheJournalByStep(Base):
|
|
|
|
|
|
def test_the_step_headers_are_the_cut(self):
|
|
|
|
|
|
# On réutilise le marquage « # » que la migration dépose déjà.
|
|
|
|
|
|
# En inventer un second qui divergerait serait pire que rien.
|
|
|
|
|
|
lst = status.journal_by_step(progression())
|
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
|
[s["step"] for s in lst],
|
|
|
|
|
|
["0 - Inspect zip", "2 - Succeed update all addons"],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_commands_land_under_their_step(self):
|
|
|
|
|
|
lst = status.journal_by_step(progression())
|
|
|
|
|
|
self.assertEqual(len(lst[1]["lst_cmd"]), 2)
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_command_before_any_header_is_not_lost(self):
|
|
|
|
|
|
dct = progression(command_executed=["make quelque_chose"])
|
|
|
|
|
|
lst = status.journal_by_step(dct)
|
|
|
|
|
|
self.assertEqual(len(lst), 1)
|
|
|
|
|
|
self.assertEqual(lst[0]["lst_cmd"], ["make quelque_chose"])
|
|
|
|
|
|
|
|
|
|
|
|
def test_an_empty_progression_yields_nothing(self):
|
|
|
|
|
|
self.assertEqual(status.journal_by_step({}), [])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestARepairMustNotReadAsAFailure(Base):
|
|
|
|
|
|
"""LE point délicat du résumé.
|
|
|
|
|
|
|
|
|
|
|
|
`smoke_public_url` a rendu 2 puis 0 : il a échoué, on a réparé, il est
|
|
|
|
|
|
repassé. Montrer les deux verdicts côte à côte ferait conclure que la
|
|
|
|
|
|
base est toujours cassée.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_LAST_verdict_wins(self):
|
|
|
|
|
|
lst = status.tests_summary(progression())
|
|
|
|
|
|
smoke = [x for x in lst if x["name"] == "smoke_public_url"][0]
|
|
|
|
|
|
self.assertEqual(smoke["status"], 0)
|
|
|
|
|
|
|
[FIX] migration state: a test result belongs to a step, not to the run
You were right that it read as global — and the cause ran deeper than the
display. Twenty step headers go through add_comment_progression against
seven through print_step, and the whole version-bump loop uses only the
first. The current step was set in the other one, so every tool verdict
of every bump carried a stale step.
The header itself now becomes the step, at the one place the journal is
already cut on. And the summary groups by (step, tool) instead of by tool
alone: a migration runs the smoke test at EVERY bump, and one line said
« smoke_public_url ✅ » while bump 14 had passed and bump 17 had fallen.
Within a step the last verdict still wins, with its run count — that is a
repair, not two bumps. The order is the journal's, because sorting step
names puts « 4.10 » before « 4.2 ».
--- FR ---
[FIX] état de migration : un résultat de test appartient à une étape
Vous aviez raison, cela se lisait comme global — et la cause était plus
profonde que l'affichage. Vingt en-têtes d'étape passent par
add_comment_progression contre sept par print_step, et toute la boucle des
paliers n'utilise que la première. L'étape courante était posée dans
l'autre : chaque verdict d'outil portait donc une étape périmée.
L'en-tête devient désormais l'étape, au seul endroit où le journal est
déjà découpé. Et le résumé groupe par (étape, outil) : une migration lance
le test de fumée à CHAQUE palier, et une seule ligne annonçait
« smoke_public_url ✅ » quand le palier 14 était passé et le 17 tombé.
Dans une étape, le dernier verdict l'emporte toujours — c'est une
réparation, pas deux paliers.
Assisted-by: Claude Opus 5
2026-08-19 06:00:21 -04:00
|
|
|
|
def test_the_verdicts_are_kept_PER_STEP(self):
|
|
|
|
|
|
"""La demande, et le défaut qu'elle a révélé.
|
|
|
|
|
|
|
|
|
|
|
|
Une migration lance le test de fumée à CHAQUE palier. Regrouper sur
|
|
|
|
|
|
le seul nom d'outil n'en laissait qu'une ligne : on lisait
|
|
|
|
|
|
« smoke_public_url ✅ » sans voir que le palier 14 était passé et
|
|
|
|
|
|
le 17 tombé.
|
|
|
|
|
|
"""
|
|
|
|
|
|
dct = progression(
|
|
|
|
|
|
lst_event=[
|
|
|
|
|
|
{
|
|
|
|
|
|
"at": "1",
|
|
|
|
|
|
"step": "4.1 - v14",
|
|
|
|
|
|
"kind": "test",
|
|
|
|
|
|
"name": "smoke_public_url",
|
|
|
|
|
|
"status": 0,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"at": "2",
|
|
|
|
|
|
"step": "4.2 - v15",
|
|
|
|
|
|
"kind": "test",
|
|
|
|
|
|
"name": "smoke_public_url",
|
|
|
|
|
|
"status": 2,
|
|
|
|
|
|
},
|
|
|
|
|
|
]
|
|
|
|
|
|
)
|
|
|
|
|
|
lst = status.tests_summary(dct)
|
|
|
|
|
|
self.assertEqual(len(lst), 2)
|
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
|
[(x["step"], x["status"]) for x in lst],
|
|
|
|
|
|
[("4.1 - v14", 0), ("4.2 - v15", 2)],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_repair_within_a_step_still_collapses(self):
|
|
|
|
|
|
# Deux verdicts pour le MÊME palier : c'est une réparation, pas
|
|
|
|
|
|
# deux paliers. Le second décrit la base telle qu'elle est.
|
|
|
|
|
|
dct = progression(
|
|
|
|
|
|
lst_event=[
|
|
|
|
|
|
{
|
|
|
|
|
|
"at": "1",
|
|
|
|
|
|
"step": "4.2 - v15",
|
|
|
|
|
|
"kind": "test",
|
|
|
|
|
|
"name": "smoke_public_url",
|
|
|
|
|
|
"status": 2,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"at": "2",
|
|
|
|
|
|
"step": "4.2 - v15",
|
|
|
|
|
|
"kind": "test",
|
|
|
|
|
|
"name": "smoke_public_url",
|
|
|
|
|
|
"status": 0,
|
|
|
|
|
|
},
|
|
|
|
|
|
]
|
|
|
|
|
|
)
|
|
|
|
|
|
lst = status.tests_summary(dct)
|
|
|
|
|
|
self.assertEqual(len(lst), 1)
|
|
|
|
|
|
self.assertEqual(lst[0]["status"], 0)
|
|
|
|
|
|
self.assertEqual(lst[0]["runs"], 2)
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_order_is_the_migration_s_own(self):
|
|
|
|
|
|
# Trier les étapes par leur nom mettrait « 4.10 » avant « 4.2 ».
|
|
|
|
|
|
dct = progression(
|
|
|
|
|
|
lst_event=[
|
|
|
|
|
|
{
|
|
|
|
|
|
"at": "1",
|
|
|
|
|
|
"step": "4.2 - v15",
|
|
|
|
|
|
"kind": "test",
|
|
|
|
|
|
"name": "outil",
|
|
|
|
|
|
"status": 0,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"at": "2",
|
|
|
|
|
|
"step": "4.10 - v18",
|
|
|
|
|
|
"kind": "test",
|
|
|
|
|
|
"name": "outil",
|
|
|
|
|
|
"status": 0,
|
|
|
|
|
|
},
|
|
|
|
|
|
]
|
|
|
|
|
|
)
|
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
|
[etape for etape, _lst in status.tests_by_step(dct)],
|
|
|
|
|
|
["4.2 - v15", "4.10 - v18"],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_report_shows_the_step_number(self):
|
|
|
|
|
|
dct = progression(
|
|
|
|
|
|
lst_event=[
|
|
|
|
|
|
{
|
|
|
|
|
|
"at": "1",
|
|
|
|
|
|
"step": "4.1 - Ready to work with version 14",
|
|
|
|
|
|
"kind": "test",
|
|
|
|
|
|
"name": "database_cleanup",
|
|
|
|
|
|
"status": 0,
|
|
|
|
|
|
},
|
|
|
|
|
|
]
|
|
|
|
|
|
)
|
|
|
|
|
|
texte = status.render_text(dct, colour=False)
|
|
|
|
|
|
self.assertIn("4.1 - Ready to work with version 14", texte)
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_full_screen_shows_it_too(self):
|
|
|
|
|
|
dct = progression(
|
|
|
|
|
|
lst_event=[
|
|
|
|
|
|
{
|
|
|
|
|
|
"at": "1",
|
|
|
|
|
|
"step": "4.1 - v14",
|
|
|
|
|
|
"kind": "test",
|
|
|
|
|
|
"name": "smoke_public_url",
|
|
|
|
|
|
"status": 0,
|
|
|
|
|
|
},
|
|
|
|
|
|
]
|
|
|
|
|
|
)
|
|
|
|
|
|
ligne = [x for x in tui.rows(dct) if x["kind"] == "test"][0]
|
|
|
|
|
|
self.assertIn("4.1", ligne["label"])
|
|
|
|
|
|
|
[ADD] migration: « t » shows where the migration stands
A migration crosses six bumps, runs hundreds of commands and lasts hours.
The journal said what had been LAUNCHED; it never said what came of it.
Three hours in, one reads two hundred command lines without knowing which
one failed, nor what the smoke test concluded.
« v » could not carry this: it already means « view the differences » in
three prompts, and a letter meaning two things is worse than a letter
meaning nothing. « t » was free, and it is the same everywhere — one
shared string carries both shortcuts, so no prompt can drift.
Looking is not answering: the same question comes back afterwards.
What was missing was the data. Failures and tool verdicts are now
recorded with the step they happened in, bounded so a progression file
cannot grow without end. A tool rerun after a repair keeps its LAST
verdict and the count of its runs — showing both without distinction
would read a repair as a lasting failure.
--- FR ---
[ADD] migration : « t » montre où en est la migration
Une migration traverse six paliers, lance des centaines de commandes et
dure des heures. Le journal disait ce qui avait été LANCÉ, jamais ce que
cela avait donné. Trois heures plus tard on relit deux cents lignes sans
savoir laquelle a échoué, ni ce que le test de fumée a conclu.
« v » ne pouvait pas porter cela : il veut déjà dire « voir les
différences » dans trois invites, et une lettre qui signifie deux choses
est pire qu'une lettre qui ne signifie rien. « t » était libre, et il est
le même partout — une seule chaîne porte les deux raccourcis.
Regarder n'est pas répondre : la même question revient ensuite.
Ce qui manquait, c'étaient les données. Les échecs et les verdicts
d'outils sont désormais retenus avec l'étape où ils se sont produits.
Assisted-by: Claude Opus 5
2026-08-19 03:53:56 -04:00
|
|
|
|
def test_but_the_earlier_runs_are_still_counted(self):
|
|
|
|
|
|
# Les taire ferait croire à un premier essai réussi, et l'on
|
|
|
|
|
|
# perdrait la trace de ce qui a demandé une réparation.
|
|
|
|
|
|
lst = status.tests_summary(progression())
|
|
|
|
|
|
smoke = [x for x in lst if x["name"] == "smoke_public_url"][0]
|
|
|
|
|
|
self.assertEqual(smoke["runs"], 2)
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_report_shows_both_facts(self):
|
|
|
|
|
|
text = status.render_text(progression())
|
|
|
|
|
|
self.assertIn("smoke_public_url", text)
|
|
|
|
|
|
self.assertIn("2 runs", text)
|
|
|
|
|
|
self.assertIn("nothing to report", text)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestTheExitCodeConvention(Base):
|
|
|
|
|
|
"""0 rien, 1 des trouvailles, 2 l'outil a échoué — partout la même."""
|
|
|
|
|
|
|
|
|
|
|
|
def test_zero_is_not_alarming(self):
|
|
|
|
|
|
self.assertEqual(status.verdict(0)[1], "nothing to report")
|
|
|
|
|
|
|
|
|
|
|
|
def test_one_is_findings_not_a_crash(self):
|
|
|
|
|
|
# Le dire « échec » inquiéterait pour rien : 1 veut dire qu'il y a
|
|
|
|
|
|
# quelque chose à regarder, ce qui est le but de l'outil.
|
|
|
|
|
|
self.assertEqual(status.verdict(1)[1], "findings to look at")
|
|
|
|
|
|
|
|
|
|
|
|
def test_two_is_the_tool_itself(self):
|
|
|
|
|
|
self.assertEqual(status.verdict(2)[1], "the tool itself failed")
|
|
|
|
|
|
|
|
|
|
|
|
def test_anything_else_is_admitted_as_unknown(self):
|
|
|
|
|
|
self.assertEqual(status.verdict(77)[1], "unknown result")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestFailedCommands(Base):
|
|
|
|
|
|
def test_they_are_listed_newest_first(self):
|
|
|
|
|
|
dct = progression()
|
|
|
|
|
|
dct["lst_event"].append(
|
|
|
|
|
|
{
|
|
|
|
|
|
"at": "06:00",
|
|
|
|
|
|
"step": "3",
|
|
|
|
|
|
"kind": "command",
|
|
|
|
|
|
"name": "récente",
|
|
|
|
|
|
"status": 1,
|
|
|
|
|
|
"detail": "",
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
self.assertEqual(status.failures(dct)[0]["name"], "récente")
|
|
|
|
|
|
|
|
|
|
|
|
def test_they_carry_the_step_they_happened_in(self):
|
|
|
|
|
|
# Sans l'étape, il faut relire tout le journal pour savoir OÙ.
|
|
|
|
|
|
self.assertIn(
|
|
|
|
|
|
"2 - Succeed update all addons",
|
|
|
|
|
|
status.failures(progression())[0]["step"],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def test_tests_are_not_mixed_in_with_them(self):
|
|
|
|
|
|
self.assertEqual(len(status.failures(progression())), 1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestItTouchesNothing(Base):
|
|
|
|
|
|
"""On l'ouvre en pleine migration, souvent serveur allumé."""
|
|
|
|
|
|
|
|
|
|
|
|
def test_reading_a_missing_file_is_not_an_error(self):
|
|
|
|
|
|
self.assertEqual(status.read("/nexiste/pas.json"), {})
|
|
|
|
|
|
|
|
|
|
|
|
def test_reading_a_broken_file_is_not_an_error(self):
|
|
|
|
|
|
import tempfile
|
|
|
|
|
|
|
|
|
|
|
|
chemin = os.path.join(tempfile.gettempdir(), "erplibre_casse.json")
|
|
|
|
|
|
with open(chemin, "w") as handle:
|
|
|
|
|
|
handle.write("{ pas du json")
|
|
|
|
|
|
self.addCleanup(os.remove, chemin)
|
|
|
|
|
|
self.assertEqual(status.read(chemin), {})
|
|
|
|
|
|
|
|
|
|
|
|
def test_nothing_in_the_module_writes_or_connects(self):
|
|
|
|
|
|
import inspect
|
|
|
|
|
|
|
|
|
|
|
|
source = inspect.getsource(status)
|
|
|
|
|
|
for interdit in ("psql", "subprocess", "odoo_bin", 'open(.*, "w")'):
|
|
|
|
|
|
self.assertNotIn(interdit, source, interdit)
|
|
|
|
|
|
|
|
|
|
|
|
def test_an_empty_progression_says_so_rather_than_crashing(self):
|
|
|
|
|
|
self.assertIn("No migration in progress", status.render_text({}))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestTheFullScreenShowsTheSameThing(Base):
|
|
|
|
|
|
def test_it_reads_the_same_assembly_as_the_text(self):
|
|
|
|
|
|
# Deux assemblages sépareraient les deux vues, et l'on finirait
|
|
|
|
|
|
# par lire deux états contradictoires de la même migration.
|
|
|
|
|
|
import inspect
|
|
|
|
|
|
|
|
|
|
|
|
self.assertIn("import migration_status", inspect.getsource(tui))
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_tests_come_before_the_steps(self):
|
|
|
|
|
|
# C'est la question qu'on se pose en ouvrant cet écran.
|
|
|
|
|
|
lst = tui.rows(progression())
|
|
|
|
|
|
self.assertEqual(lst[0]["kind"], "test")
|
|
|
|
|
|
self.assertTrue(any(x["kind"] == "step" for x in lst))
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_head_never_hides_what_failed(self):
|
|
|
|
|
|
texte = tui.head_text(progression())
|
|
|
|
|
|
self.assertIn("test_neutralize", texte)
|
|
|
|
|
|
self.assertIn("1", texte)
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_step_pane_names_its_own_failures(self):
|
|
|
|
|
|
lst = tui.rows(progression())
|
|
|
|
|
|
etape = [x for x in lst if x["kind"] == "step"][1]
|
|
|
|
|
|
self.assertIn(
|
|
|
|
|
|
"update_addons_all.sh test", tui.pane_text(progression(), etape)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_test_pane_gives_the_exit_code(self):
|
|
|
|
|
|
lst = tui.rows(progression())
|
|
|
|
|
|
texte = tui.pane_text(progression(), lst[0])
|
|
|
|
|
|
self.assertIn("smoke_public_url", texte)
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_pipe_is_explained_rather_than_silent(self):
|
|
|
|
|
|
out = io.StringIO()
|
|
|
|
|
|
with redirect_stdout(out):
|
|
|
|
|
|
self.assertFalse(tui.run_tui(progression()))
|
|
|
|
|
|
self.assertTrue(out.getvalue().strip())
|
|
|
|
|
|
|
|
|
|
|
|
def test_nothing_to_show_stays_silent(self):
|
|
|
|
|
|
out = io.StringIO()
|
|
|
|
|
|
with redirect_stdout(out):
|
|
|
|
|
|
self.assertFalse(tui.run_tui({}))
|
|
|
|
|
|
self.assertEqual(out.getvalue(), "")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestTheLetterIsFreeAndUniform(Base):
|
|
|
|
|
|
"""« v » était pris : il veut dire « voir les différences » ailleurs."""
|
|
|
|
|
|
|
|
|
|
|
|
def test_v_is_taken_by_the_diff_prompts(self):
|
|
|
|
|
|
import inspect
|
|
|
|
|
|
|
|
|
|
|
|
source = inspect.getsource(TodoUpgrade.prompt_cow_prediction)
|
|
|
|
|
|
self.assertIn('"v"', source)
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_state_letter_is_t_everywhere(self):
|
|
|
|
|
|
import inspect
|
|
|
|
|
|
|
|
|
|
|
|
source = inspect.getsource(TodoUpgrade.ask_gate)
|
|
|
|
|
|
self.assertIn('reponse == "t"', source)
|
|
|
|
|
|
|
|
|
|
|
|
def test_every_gate_prompt_announces_it(self):
|
|
|
|
|
|
chemin = os.path.join(REPO, "script", "todo", "todo_upgrade.py")
|
|
|
|
|
|
with open(chemin) as handle:
|
|
|
|
|
|
texte = handle.read()
|
|
|
|
|
|
# Une seule chaîne porte les deux raccourcis : les annoncer
|
|
|
|
|
|
# séparément les laisserait diverger d'une invite à l'autre.
|
|
|
|
|
|
self.assertGreaterEqual(texte.count("t = show the migration state"), 1)
|
|
|
|
|
|
self.assertNotIn("(b = go back to a previous step)", texte)
|
|
|
|
|
|
|
|
|
|
|
|
def test_t_collides_with_no_other_answer(self):
|
|
|
|
|
|
import inspect
|
|
|
|
|
|
|
|
|
|
|
|
for methode in (
|
|
|
|
|
|
"prompt_uninstall_theme",
|
|
|
|
|
|
"prompt_database_cleanup",
|
|
|
|
|
|
"prompt_smoke_public_url",
|
|
|
|
|
|
"prompt_reset_stale_cow_views",
|
|
|
|
|
|
):
|
|
|
|
|
|
source = inspect.getsource(getattr(TodoUpgrade, methode))
|
|
|
|
|
|
self.assertNotIn('== "t"', source, methode)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestLookingIsNotAnswering(Base):
|
|
|
|
|
|
def test_the_state_reopens_the_same_question(self):
|
|
|
|
|
|
obj = TodoUpgrade.__new__(TodoUpgrade)
|
|
|
|
|
|
vu = []
|
|
|
|
|
|
obj.show_migration_status = lambda: vu.append("ouvert")
|
|
|
|
|
|
reponses = iter(["t", "t", "y"])
|
|
|
|
|
|
obj.ask = lambda prompt, default="": next(reponses)
|
|
|
|
|
|
self.assertEqual(obj.ask_gate("q : "), "y")
|
|
|
|
|
|
self.assertEqual(len(vu), 2)
|
|
|
|
|
|
|
|
|
|
|
|
def test_it_writes_before_it_reads(self):
|
|
|
|
|
|
# L'écran lit le FICHIER de progression : ce qui vient de se passer
|
|
|
|
|
|
# n'y serait pas encore.
|
|
|
|
|
|
import inspect
|
|
|
|
|
|
|
|
|
|
|
|
source = inspect.getsource(TodoUpgrade.show_migration_status)
|
|
|
|
|
|
self.assertLess(
|
|
|
|
|
|
source.index("write_config"), source.index("subprocess.call")
|
|
|
|
|
|
)
|
|
|
|
|
|
|
[ADD] migration: reach the state screen from the statistics too
The statistics screen answers « what was removed, and why ». The state
screen answers « where are we, and what broke ». Two neighbouring
questions asked at the same moment: separating them by a menu entry
rather than by two commands to remember is what makes them usable.
Same letter as the prompts. One that changes meaning from one screen to
the next is not learned, it is looked up — and nobody looks it up.
Adding it surfaced two ways that screen could not survive auto-run. The
interface question is the FIRST one asked after auto-run is switched on,
and it was a bare input(): an unattended migration stopped there before
it had begun. The statistics menu was one too. And the state screen
writes the progression before reading it — but the statistics screen runs
before the progression is even loaded, so that write failed on an
attribute that does not exist yet.
--- FR ---
[ADD] migration : atteindre l'état depuis les statistiques aussi
L'écran de statistiques répond « qu'a-t-on supprimé, et pourquoi ». Celui
de l'état répond « où en est-on, et qu'est-ce qui a cassé ». Deux
questions voisines, posées au même moment : les séparer par une entrée de
menu plutôt que par deux commandes à retenir est ce qui les rend
utilisables.
La même lettre que dans les invites. Une lettre qui change de sens d'un
écran à l'autre ne s'apprend pas, elle se cherche — et personne ne la
cherche.
L'ajout a révélé deux façons dont cet écran ne survivait pas au mode
auto : la question d'interface, PREMIÈRE posée après l'activation, était
un input() nu, et le menu des statistiques aussi. Et l'écran d'état écrit
la progression avant de la lire, alors que les statistiques s'ouvrent
avant même qu'elle ne soit chargée.
Assisted-by: Claude Opus 5
2026-08-19 04:05:03 -04:00
|
|
|
|
def test_it_survives_a_screen_opened_before_anything_is_loaded(self):
|
|
|
|
|
|
"""`show_stats` tourne AVANT que la progression ne soit en mémoire.
|
|
|
|
|
|
|
|
|
|
|
|
Elle est ouverte tout au début d'`execute_odoo_upgrade`, avant même
|
|
|
|
|
|
le choix du fichier. Y appeler `write_config` échouait alors sur un
|
|
|
|
|
|
attribut qui n'existe pas encore — et l'écran d'état, ouvert depuis
|
|
|
|
|
|
là, aurait planté au lieu de s'afficher.
|
|
|
|
|
|
"""
|
|
|
|
|
|
import script.todo.todo_upgrade as tu
|
|
|
|
|
|
|
|
|
|
|
|
obj = TodoUpgrade.__new__(TodoUpgrade)
|
|
|
|
|
|
appels = []
|
|
|
|
|
|
obj.write_config = lambda: appels.append("écrit")
|
|
|
|
|
|
original = tu.subprocess.call
|
|
|
|
|
|
tu.subprocess.call = lambda *a, **kw: 0
|
|
|
|
|
|
self.addCleanup(setattr, tu.subprocess, "call", original)
|
|
|
|
|
|
obj.show_migration_status()
|
|
|
|
|
|
self.assertEqual(appels, [], "rien en mémoire, rien à écrire")
|
|
|
|
|
|
|
|
|
|
|
|
def test_but_it_does_write_what_it_has(self):
|
|
|
|
|
|
# Sans rien en mémoire le fichier est déjà la vérité ; avec quelque
|
|
|
|
|
|
# chose, il ne l'est plus tant qu'on ne l'a pas écrit.
|
|
|
|
|
|
import script.todo.todo_upgrade as tu
|
|
|
|
|
|
|
|
|
|
|
|
obj = TodoUpgrade.__new__(TodoUpgrade)
|
|
|
|
|
|
obj.dct_progression = {"config_database_name": "db"}
|
|
|
|
|
|
appels = []
|
|
|
|
|
|
obj.write_config = lambda: appels.append("écrit")
|
|
|
|
|
|
original = tu.subprocess.call
|
|
|
|
|
|
tu.subprocess.call = lambda *a, **kw: 0
|
|
|
|
|
|
self.addCleanup(setattr, tu.subprocess, "call", original)
|
|
|
|
|
|
obj.show_migration_status()
|
|
|
|
|
|
self.assertEqual(appels, ["écrit"])
|
|
|
|
|
|
|
[ADD] migration: « t » shows where the migration stands
A migration crosses six bumps, runs hundreds of commands and lasts hours.
The journal said what had been LAUNCHED; it never said what came of it.
Three hours in, one reads two hundred command lines without knowing which
one failed, nor what the smoke test concluded.
« v » could not carry this: it already means « view the differences » in
three prompts, and a letter meaning two things is worse than a letter
meaning nothing. « t » was free, and it is the same everywhere — one
shared string carries both shortcuts, so no prompt can drift.
Looking is not answering: the same question comes back afterwards.
What was missing was the data. Failures and tool verdicts are now
recorded with the step they happened in, bounded so a progression file
cannot grow without end. A tool rerun after a repair keeps its LAST
verdict and the count of its runs — showing both without distinction
would read a repair as a lasting failure.
--- FR ---
[ADD] migration : « t » montre où en est la migration
Une migration traverse six paliers, lance des centaines de commandes et
dure des heures. Le journal disait ce qui avait été LANCÉ, jamais ce que
cela avait donné. Trois heures plus tard on relit deux cents lignes sans
savoir laquelle a échoué, ni ce que le test de fumée a conclu.
« v » ne pouvait pas porter cela : il veut déjà dire « voir les
différences » dans trois invites, et une lettre qui signifie deux choses
est pire qu'une lettre qui ne signifie rien. « t » était libre, et il est
le même partout — une seule chaîne porte les deux raccourcis.
Regarder n'est pas répondre : la même question revient ensuite.
Ce qui manquait, c'étaient les données. Les échecs et les verdicts
d'outils sont désormais retenus avec l'étape où ils se sont produits.
Assisted-by: Claude Opus 5
2026-08-19 03:53:56 -04:00
|
|
|
|
def test_looking_does_not_pollute_the_journal(self):
|
|
|
|
|
|
# `run_on_terminal` consigne ce qu'il lance, et le journal est
|
|
|
|
|
|
# justement ce que cet écran montre.
|
|
|
|
|
|
import inspect
|
|
|
|
|
|
|
|
|
|
|
|
source = inspect.getsource(TodoUpgrade.show_migration_status)
|
|
|
|
|
|
# L'APPEL, pas le mot : le commentaire du correctif nomme lui-même
|
|
|
|
|
|
# ce qu'il faut éviter, et le chercher à l'aveugle se déclenchait
|
|
|
|
|
|
# sur la prose plutôt que sur le code.
|
|
|
|
|
|
self.assertNotIn("self.run_on_terminal(", source)
|
|
|
|
|
|
|
|
|
|
|
|
|
[ADD] migration: keep the logs on disk, one file per step
The state screen showed « no tool has run yet » after closing and
reopening. It was reading the progression file, and that file is archived
and reset when a migration restarts — so everything it knew vanished at
the exact moment one wants to understand why the restart was needed.
Each step now has its own log file, appended never overwritten, and the
failures and tool verdicts go to an append-only JSONL beside them. Both
outlive the progression, and the screen reads disk first, memory second,
without counting the overlap twice.
The command output itself is captured where every line already passes,
in the executor's read loop: the terminal still shows it live and nothing
about the run changes. What goes through the real terminal cannot be
captured — a pipe there makes full screens refuse, that lesson is paid —
so those keep at least their command and their exit code.
--- FR ---
[ADD] migration : garder les journaux sur disque, un fichier par étape
L'écran d'état affichait « aucun outil n'a encore tourné » après une
fermeture. Il lisait le fichier de progression, or celui-ci est archivé
puis remis à zéro quand on recommence : tout ce qu'il savait disparaissait
au moment précis où l'on cherche pourquoi il a fallu recommencer.
Chaque étape a désormais son fichier, en ajout et jamais en écrasement, et
les échecs comme les verdicts d'outils vont dans un JSONL à côté. Les deux
survivent à la progression, et l'écran lit le disque d'abord.
La sortie des commandes est captée là où chaque ligne passe déjà, dans la
boucle de lecture de l'exécuteur : le terminal la montre toujours en
direct. Ce qui passe par le vrai terminal n'est pas captable — un tube y
ferait renoncer les pleins écrans — et garde au moins son verdict.
Assisted-by: Claude Opus 5
2026-08-19 04:32:25 -04:00
|
|
|
|
class DiskCase(Base):
|
|
|
|
|
|
"""Un répertoire jetable : les chemins de journal sont RELATIFS."""
|
|
|
|
|
|
|
|
|
|
|
|
def setUp(self):
|
|
|
|
|
|
super().setUp()
|
|
|
|
|
|
import tempfile
|
|
|
|
|
|
|
|
|
|
|
|
self.dossier = tempfile.mkdtemp(prefix="erplibre_essai_")
|
|
|
|
|
|
avant = os.getcwd()
|
|
|
|
|
|
os.chdir(self.dossier)
|
|
|
|
|
|
self.addCleanup(shutil.rmtree, self.dossier, True)
|
|
|
|
|
|
self.addCleanup(os.chdir, avant)
|
|
|
|
|
|
|
|
|
|
|
|
def upgrade(self, database="essai_db"):
|
|
|
|
|
|
obj = TodoUpgrade.__new__(TodoUpgrade)
|
|
|
|
|
|
obj.dct_progression = {"config_database_name": database}
|
|
|
|
|
|
obj.lst_command_executed = []
|
|
|
|
|
|
obj.write_config = lambda: None
|
|
|
|
|
|
return obj
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestWhatSurvivesClosingTheTool(DiskCase):
|
|
|
|
|
|
"""Le fichier de progression est ARCHIVÉ puis remis à zéro.
|
|
|
|
|
|
|
|
|
|
|
|
Recommencer une migration effaçait donc tout ce que l'écran d'état
|
|
|
|
|
|
savait — au moment précis où l'on cherche à comprendre pourquoi il a
|
|
|
|
|
|
fallu recommencer. Le journal permanent, lui, ne fait que s'allonger.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
def test_events_are_found_again_with_nothing_in_memory(self):
|
|
|
|
|
|
obj = self.upgrade()
|
|
|
|
|
|
obj.print_step("4.2.I - Migrate database")
|
|
|
|
|
|
obj.record_event("command", "update_addons_all.sh", 1)
|
|
|
|
|
|
obj.record_event("test", "smoke_public_url", 0)
|
|
|
|
|
|
obj.close_step_log()
|
|
|
|
|
|
# Une progression NEUVE : c'est l'état après réouverture.
|
|
|
|
|
|
neuf = {"config_database_name": "essai_db"}
|
|
|
|
|
|
lst = status.merge_events(neuf)
|
|
|
|
|
|
self.assertEqual(len(lst), 2)
|
|
|
|
|
|
self.assertEqual(lst[0]["name"], "update_addons_all.sh")
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_step_survives_with_them(self):
|
|
|
|
|
|
# Un événement sans étape oblige à relire tout le journal pour
|
|
|
|
|
|
# savoir OÙ il s'est produit.
|
|
|
|
|
|
obj = self.upgrade()
|
|
|
|
|
|
obj.print_step("4.2.I - Migrate database")
|
|
|
|
|
|
obj.record_event("test", "database_cleanup", 1)
|
|
|
|
|
|
obj.close_step_log()
|
|
|
|
|
|
lst = status.merge_events({"config_database_name": "essai_db"})
|
|
|
|
|
|
self.assertEqual(lst[0]["step"], "4.2.I - Migrate database")
|
|
|
|
|
|
|
|
|
|
|
|
def test_memory_and_disk_are_not_counted_twice(self):
|
|
|
|
|
|
obj = self.upgrade()
|
|
|
|
|
|
obj.print_step("2 - Succeed update all addons")
|
|
|
|
|
|
obj.record_event("test", "smoke_public_url", 0)
|
|
|
|
|
|
obj.close_step_log()
|
|
|
|
|
|
# `obj.dct_progression` porte DÉJÀ l'événement : les deux sources se
|
|
|
|
|
|
# recouvrent, et les additionner le montrerait en double.
|
|
|
|
|
|
self.assertEqual(len(status.merge_events(obj.dct_progression)), 1)
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_truncated_line_does_not_lose_the_others(self):
|
|
|
|
|
|
# Une écriture interrompue laisse une ligne tronquée ; refuser le
|
|
|
|
|
|
# fichier en bloc perdrait tout pour une seule ligne.
|
|
|
|
|
|
obj = self.upgrade()
|
|
|
|
|
|
obj.print_step("1 - Import database from zip")
|
|
|
|
|
|
obj.record_event("test", "premier", 0)
|
|
|
|
|
|
obj.close_step_log()
|
|
|
|
|
|
chemin = os.path.join(
|
|
|
|
|
|
"private",
|
|
|
|
|
|
"odoo",
|
|
|
|
|
|
"migration",
|
|
|
|
|
|
"essai_db",
|
|
|
|
|
|
"step_log",
|
|
|
|
|
|
"events.jsonl",
|
|
|
|
|
|
)
|
|
|
|
|
|
with open(chemin, "a") as handle:
|
|
|
|
|
|
handle.write('{"at": "x", "name": "coup\n')
|
|
|
|
|
|
obj.record_event("test", "dernier", 0)
|
|
|
|
|
|
noms = [
|
|
|
|
|
|
x["name"]
|
|
|
|
|
|
for x in status.merge_events({"config_database_name": "essai_db"})
|
|
|
|
|
|
]
|
|
|
|
|
|
self.assertIn("premier", noms)
|
|
|
|
|
|
self.assertIn("dernier", noms)
|
|
|
|
|
|
|
[FIX] migration state: a test result belongs to a step, not to the run
You were right that it read as global — and the cause ran deeper than the
display. Twenty step headers go through add_comment_progression against
seven through print_step, and the whole version-bump loop uses only the
first. The current step was set in the other one, so every tool verdict
of every bump carried a stale step.
The header itself now becomes the step, at the one place the journal is
already cut on. And the summary groups by (step, tool) instead of by tool
alone: a migration runs the smoke test at EVERY bump, and one line said
« smoke_public_url ✅ » while bump 14 had passed and bump 17 had fallen.
Within a step the last verdict still wins, with its run count — that is a
repair, not two bumps. The order is the journal's, because sorting step
names puts « 4.10 » before « 4.2 ».
--- FR ---
[FIX] état de migration : un résultat de test appartient à une étape
Vous aviez raison, cela se lisait comme global — et la cause était plus
profonde que l'affichage. Vingt en-têtes d'étape passent par
add_comment_progression contre sept par print_step, et toute la boucle des
paliers n'utilise que la première. L'étape courante était posée dans
l'autre : chaque verdict d'outil portait donc une étape périmée.
L'en-tête devient désormais l'étape, au seul endroit où le journal est
déjà découpé. Et le résumé groupe par (étape, outil) : une migration lance
le test de fumée à CHAQUE palier, et une seule ligne annonçait
« smoke_public_url ✅ » quand le palier 14 était passé et le 17 tombé.
Dans une étape, le dernier verdict l'emporte toujours — c'est une
réparation, pas deux paliers.
Assisted-by: Claude Opus 5
2026-08-19 06:00:21 -04:00
|
|
|
|
def test_a_step_header_becomes_the_current_step(self):
|
|
|
|
|
|
"""La cause racine de « ça semble global ».
|
|
|
|
|
|
|
|
|
|
|
|
Vingt en-têtes d'étape passent par `add_comment_progression` contre
|
|
|
|
|
|
sept par `print_step`, et toute la boucle des paliers n'utilise que
|
|
|
|
|
|
la première. Ne poser l'étape courante que dans l'autre laissait
|
|
|
|
|
|
chaque verdict estampillé d'une étape périmée.
|
|
|
|
|
|
"""
|
|
|
|
|
|
obj = self.upgrade()
|
|
|
|
|
|
obj.add_comment_progression("4.2 - Ready to work with version 15")
|
|
|
|
|
|
obj.record_event("test", "smoke_public_url", 0)
|
|
|
|
|
|
obj.close_step_log()
|
|
|
|
|
|
lst = status.merge_events({"config_database_name": "essai_db"})
|
|
|
|
|
|
self.assertEqual(lst[0]["step"], "4.2 - Ready to work with version 15")
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_step_header_opens_its_log_too(self):
|
|
|
|
|
|
# Même raison : sans cela, la sortie des commandes d'un palier
|
|
|
|
|
|
# allait dans le fichier de l'étape d'AVANT.
|
|
|
|
|
|
obj = self.upgrade()
|
|
|
|
|
|
obj.add_comment_progression("4.2 - Migrate database")
|
|
|
|
|
|
obj.note_step_log("quelque chose")
|
|
|
|
|
|
obj.close_step_log()
|
|
|
|
|
|
self.assertIsNotNone(
|
|
|
|
|
|
status.step_log_path(
|
|
|
|
|
|
{"config_database_name": "essai_db"}, "4.2 - Migrate database"
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def test_two_bumps_do_not_share_a_step(self):
|
|
|
|
|
|
# Le symptôme exact : six paliers, un seul nom d'étape.
|
|
|
|
|
|
obj = self.upgrade()
|
|
|
|
|
|
for palier in ("4.1 - version 14", "4.2 - version 15"):
|
|
|
|
|
|
obj.add_comment_progression(palier)
|
|
|
|
|
|
obj.record_event("test", "smoke_public_url", 0)
|
|
|
|
|
|
obj.close_step_log()
|
|
|
|
|
|
lst = status.merge_events({"config_database_name": "essai_db"})
|
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
|
[x["step"] for x in lst], ["4.1 - version 14", "4.2 - version 15"]
|
|
|
|
|
|
)
|
|
|
|
|
|
|
[ADD] migration state: l, p, r and the two resize keys
Four keys, and one bug behind them. « l » separates the commands from the
server log: the first says what was LAUNCHED, the second what it
ANSWERED, and an update writing tens of thousands of lines buries three
commands. « p » hides the summary panel, « - » and « + » move the split,
« r » re-reads the disk — the migration writes while one watches, and
closing the screen to see the next bump is what one ended up doing.
The « missing logs » had two causes, both real. A tail was shown without
saying it was one; it now names how many lines are hidden. And the first
two steps run before the database is named, so their logs landed under
« sans-nom » — outside the migration they belong to, invisible from the
screen. They are brought back the moment the name is known, appended
never overwritten.
The step 4 bug was already fixed: its log held all six bumps because only
print_step opened one, and the bump loop never calls it.
--- FR ---
[ADD] état de migration : l, p, r et les deux touches de taille
Quatre touches, et un défaut derrière. « l » sépare les commandes du
journal : la première dit ce qui a été LANCÉ, le second ce que cela a
RÉPONDU, et une mise à jour qui écrit des dizaines de milliers de lignes
enterre trois commandes. « p » cache le panneau de résumé, « - » et « + »
déplacent la séparation, « r » relit le disque — la migration écrit
pendant qu'on regarde.
Les « logs manquants » avaient deux causes. On montrait une fin sans dire
que c'en était une ; le nombre de lignes cachées est désormais nommé. Et
les deux premières étapes tournent avant que la base ne soit nommée :
leurs journaux atterrissaient sous « sans-nom », hors de la migration à
laquelle ils appartiennent. Ils la rejoignent dès que le nom est connu.
Assisted-by: Claude Opus 5
2026-08-19 06:23:20 -04:00
|
|
|
|
def test_logs_written_before_the_name_are_brought_back(self):
|
|
|
|
|
|
"""Les deux premières étapes tournent avant qu'on nomme la base.
|
|
|
|
|
|
|
|
|
|
|
|
Leurs journaux atterrissaient sous « sans-nom », c'est-à-dire hors
|
|
|
|
|
|
de la migration à laquelle ils appartiennent : mesuré sur la VM,
|
|
|
|
|
|
deux fichiers invisibles depuis l'écran d'état, et l'on cherchait
|
|
|
|
|
|
des logs manquants qui étaient simplement à côté.
|
|
|
|
|
|
"""
|
|
|
|
|
|
from script.todo import todo_upgrade as tu
|
|
|
|
|
|
|
|
|
|
|
|
anonyme = TodoUpgrade.__new__(TodoUpgrade)
|
|
|
|
|
|
anonyme.dct_progression = {}
|
|
|
|
|
|
anonyme.lst_command_executed = []
|
|
|
|
|
|
anonyme.write_config = lambda: None
|
|
|
|
|
|
anonyme.add_comment_progression("0 - Inspect zip")
|
|
|
|
|
|
anonyme.note_step_log("avant le nom")
|
|
|
|
|
|
anonyme.close_step_log()
|
|
|
|
|
|
self.assertTrue(
|
|
|
|
|
|
os.path.isdir(
|
|
|
|
|
|
os.path.join(
|
|
|
|
|
|
"private",
|
|
|
|
|
|
"odoo",
|
|
|
|
|
|
"migration",
|
|
|
|
|
|
tu.UNNAMED_MIGRATION,
|
|
|
|
|
|
"step_log",
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
# La base prend son nom : les journaux doivent la rejoindre.
|
|
|
|
|
|
nomme = self.upgrade()
|
|
|
|
|
|
nomme.log_dir()
|
|
|
|
|
|
tail, _total = status.step_log_tail(
|
|
|
|
|
|
{"config_database_name": "essai_db"}, "0 - Inspect zip"
|
|
|
|
|
|
)
|
|
|
|
|
|
self.assertIn("avant le nom", "\n".join(tail))
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_unnamed_folder_is_left_empty_behind(self):
|
|
|
|
|
|
from script.todo import todo_upgrade as tu
|
|
|
|
|
|
|
|
|
|
|
|
anonyme = TodoUpgrade.__new__(TodoUpgrade)
|
|
|
|
|
|
anonyme.dct_progression = {}
|
|
|
|
|
|
anonyme.lst_command_executed = []
|
|
|
|
|
|
anonyme.write_config = lambda: None
|
|
|
|
|
|
anonyme.add_comment_progression("0 - Inspect zip")
|
|
|
|
|
|
anonyme.note_step_log("x")
|
|
|
|
|
|
anonyme.close_step_log()
|
|
|
|
|
|
self.upgrade().log_dir()
|
|
|
|
|
|
self.assertFalse(
|
|
|
|
|
|
os.path.isdir(
|
|
|
|
|
|
os.path.join(
|
|
|
|
|
|
"private",
|
|
|
|
|
|
"odoo",
|
|
|
|
|
|
"migration",
|
|
|
|
|
|
tu.UNNAMED_MIGRATION,
|
|
|
|
|
|
"step_log",
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def test_an_existing_file_is_APPENDED_to_not_replaced(self):
|
|
|
|
|
|
# Une reprise peut avoir écrit des deux côtés ; écraser perdrait
|
|
|
|
|
|
# le premier passage.
|
|
|
|
|
|
nomme = self.upgrade()
|
|
|
|
|
|
nomme.add_comment_progression("0 - Inspect zip")
|
|
|
|
|
|
nomme.note_step_log("déjà là")
|
|
|
|
|
|
nomme.close_step_log()
|
|
|
|
|
|
anonyme = TodoUpgrade.__new__(TodoUpgrade)
|
|
|
|
|
|
anonyme.dct_progression = {}
|
|
|
|
|
|
anonyme.lst_command_executed = []
|
|
|
|
|
|
anonyme.write_config = lambda: None
|
|
|
|
|
|
anonyme.add_comment_progression("0 - Inspect zip")
|
|
|
|
|
|
anonyme.note_step_log("venu de sans-nom")
|
|
|
|
|
|
anonyme.close_step_log()
|
|
|
|
|
|
neuf = self.upgrade()
|
|
|
|
|
|
neuf.log_dir()
|
|
|
|
|
|
texte = "\n".join(
|
|
|
|
|
|
status.step_log_tail(
|
|
|
|
|
|
{"config_database_name": "essai_db"}, "0 - Inspect zip"
|
|
|
|
|
|
)[0]
|
|
|
|
|
|
)
|
|
|
|
|
|
self.assertIn("déjà là", texte)
|
|
|
|
|
|
self.assertIn("venu de sans-nom", texte)
|
|
|
|
|
|
|
[ADD] migration: keep the logs on disk, one file per step
The state screen showed « no tool has run yet » after closing and
reopening. It was reading the progression file, and that file is archived
and reset when a migration restarts — so everything it knew vanished at
the exact moment one wants to understand why the restart was needed.
Each step now has its own log file, appended never overwritten, and the
failures and tool verdicts go to an append-only JSONL beside them. Both
outlive the progression, and the screen reads disk first, memory second,
without counting the overlap twice.
The command output itself is captured where every line already passes,
in the executor's read loop: the terminal still shows it live and nothing
about the run changes. What goes through the real terminal cannot be
captured — a pipe there makes full screens refuse, that lesson is paid —
so those keep at least their command and their exit code.
--- FR ---
[ADD] migration : garder les journaux sur disque, un fichier par étape
L'écran d'état affichait « aucun outil n'a encore tourné » après une
fermeture. Il lisait le fichier de progression, or celui-ci est archivé
puis remis à zéro quand on recommence : tout ce qu'il savait disparaissait
au moment précis où l'on cherche pourquoi il a fallu recommencer.
Chaque étape a désormais son fichier, en ajout et jamais en écrasement, et
les échecs comme les verdicts d'outils vont dans un JSONL à côté. Les deux
survivent à la progression, et l'écran lit le disque d'abord.
La sortie des commandes est captée là où chaque ligne passe déjà, dans la
boucle de lecture de l'exécuteur : le terminal la montre toujours en
direct. Ce qui passe par le vrai terminal n'est pas captable — un tube y
ferait renoncer les pleins écrans — et garde au moins son verdict.
Assisted-by: Claude Opus 5
2026-08-19 04:32:25 -04:00
|
|
|
|
def test_a_migration_without_a_database_writes_nowhere(self):
|
|
|
|
|
|
obj = self.upgrade(database=None)
|
|
|
|
|
|
obj.dct_progression = {}
|
|
|
|
|
|
obj.print_step("0 - Inspect zip")
|
|
|
|
|
|
obj.record_event("test", "x", 0)
|
|
|
|
|
|
obj.close_step_log()
|
|
|
|
|
|
# Rien ne doit planter, et rien ne doit se perdre ailleurs.
|
|
|
|
|
|
self.assertEqual(len(obj.dct_progression["lst_event"]), 1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestTheStepLogs(DiskCase):
|
|
|
|
|
|
def test_each_step_gets_its_own_file(self):
|
|
|
|
|
|
obj = self.upgrade()
|
|
|
|
|
|
for etape in ("0 - Inspect zip", "4.2.C - Install module"):
|
|
|
|
|
|
obj.print_step(etape)
|
|
|
|
|
|
obj.note_step_log("quelque chose")
|
|
|
|
|
|
obj.close_step_log()
|
|
|
|
|
|
dossier = os.path.join(
|
|
|
|
|
|
"private", "odoo", "migration", "essai_db", "step_log"
|
|
|
|
|
|
)
|
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
|
sorted(x for x in os.listdir(dossier) if x.endswith(".log")),
|
|
|
|
|
|
["0_inspect-zip.log", "4.2.c_install-module.log"],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_numbered_prefix_keeps_them_in_order(self):
|
|
|
|
|
|
# Un `ls` trié est la première chose qu'on fait dans ce répertoire.
|
|
|
|
|
|
self.assertTrue(
|
|
|
|
|
|
status.step_slug("4.2.C - Install module").startswith("4.2.c")
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def test_replaying_a_step_ADDS_to_what_was_known(self):
|
|
|
|
|
|
# Une étape rejouée après un retour en arrière ne doit pas effacer
|
|
|
|
|
|
# l'historique : c'est justement ce qu'on vient relire.
|
|
|
|
|
|
obj = self.upgrade()
|
|
|
|
|
|
obj.print_step("2 - Succeed update all addons")
|
|
|
|
|
|
obj.note_step_log("premier passage")
|
|
|
|
|
|
obj.close_step_log()
|
|
|
|
|
|
obj.print_step("2 - Succeed update all addons")
|
|
|
|
|
|
obj.note_step_log("second passage")
|
|
|
|
|
|
obj.close_step_log()
|
[ADD] migration state: l, p, r and the two resize keys
Four keys, and one bug behind them. « l » separates the commands from the
server log: the first says what was LAUNCHED, the second what it
ANSWERED, and an update writing tens of thousands of lines buries three
commands. « p » hides the summary panel, « - » and « + » move the split,
« r » re-reads the disk — the migration writes while one watches, and
closing the screen to see the next bump is what one ended up doing.
The « missing logs » had two causes, both real. A tail was shown without
saying it was one; it now names how many lines are hidden. And the first
two steps run before the database is named, so their logs landed under
« sans-nom » — outside the migration they belong to, invisible from the
screen. They are brought back the moment the name is known, appended
never overwritten.
The step 4 bug was already fixed: its log held all six bumps because only
print_step opened one, and the bump loop never calls it.
--- FR ---
[ADD] état de migration : l, p, r et les deux touches de taille
Quatre touches, et un défaut derrière. « l » sépare les commandes du
journal : la première dit ce qui a été LANCÉ, le second ce que cela a
RÉPONDU, et une mise à jour qui écrit des dizaines de milliers de lignes
enterre trois commandes. « p » cache le panneau de résumé, « - » et « + »
déplacent la séparation, « r » relit le disque — la migration écrit
pendant qu'on regarde.
Les « logs manquants » avaient deux causes. On montrait une fin sans dire
que c'en était une ; le nombre de lignes cachées est désormais nommé. Et
les deux premières étapes tournent avant que la base ne soit nommée :
leurs journaux atterrissaient sous « sans-nom », hors de la migration à
laquelle ils appartiennent. Ils la rejoignent dès que le nom est connu.
Assisted-by: Claude Opus 5
2026-08-19 06:23:20 -04:00
|
|
|
|
tail, _total = status.step_log_tail(
|
[ADD] migration: keep the logs on disk, one file per step
The state screen showed « no tool has run yet » after closing and
reopening. It was reading the progression file, and that file is archived
and reset when a migration restarts — so everything it knew vanished at
the exact moment one wants to understand why the restart was needed.
Each step now has its own log file, appended never overwritten, and the
failures and tool verdicts go to an append-only JSONL beside them. Both
outlive the progression, and the screen reads disk first, memory second,
without counting the overlap twice.
The command output itself is captured where every line already passes,
in the executor's read loop: the terminal still shows it live and nothing
about the run changes. What goes through the real terminal cannot be
captured — a pipe there makes full screens refuse, that lesson is paid —
so those keep at least their command and their exit code.
--- FR ---
[ADD] migration : garder les journaux sur disque, un fichier par étape
L'écran d'état affichait « aucun outil n'a encore tourné » après une
fermeture. Il lisait le fichier de progression, or celui-ci est archivé
puis remis à zéro quand on recommence : tout ce qu'il savait disparaissait
au moment précis où l'on cherche pourquoi il a fallu recommencer.
Chaque étape a désormais son fichier, en ajout et jamais en écrasement, et
les échecs comme les verdicts d'outils vont dans un JSONL à côté. Les deux
survivent à la progression, et l'écran lit le disque d'abord.
La sortie des commandes est captée là où chaque ligne passe déjà, dans la
boucle de lecture de l'exécuteur : le terminal la montre toujours en
direct. Ce qui passe par le vrai terminal n'est pas captable — un tube y
ferait renoncer les pleins écrans — et garde au moins son verdict.
Assisted-by: Claude Opus 5
2026-08-19 04:32:25 -04:00
|
|
|
|
{"config_database_name": "essai_db"},
|
|
|
|
|
|
"2 - Succeed update all addons",
|
|
|
|
|
|
)
|
|
|
|
|
|
texte = "\n".join(tail)
|
|
|
|
|
|
self.assertIn("premier passage", texte)
|
|
|
|
|
|
self.assertIn("second passage", texte)
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_command_and_its_verdict_are_kept(self):
|
|
|
|
|
|
# `run_on_terminal` n'a PAS de sortie capturable — un tube y ferait
|
|
|
|
|
|
# renoncer les pleins écrans. On garde au moins ces deux-là.
|
|
|
|
|
|
obj = self.upgrade()
|
|
|
|
|
|
obj.print_step("3 - Clean up database")
|
|
|
|
|
|
obj.run_on_terminal("true")
|
|
|
|
|
|
obj.close_step_log()
|
|
|
|
|
|
texte = "\n".join(
|
|
|
|
|
|
status.step_log_tail(
|
|
|
|
|
|
{"config_database_name": "essai_db"}, "3 - Clean up database"
|
[ADD] migration state: l, p, r and the two resize keys
Four keys, and one bug behind them. « l » separates the commands from the
server log: the first says what was LAUNCHED, the second what it
ANSWERED, and an update writing tens of thousands of lines buries three
commands. « p » hides the summary panel, « - » and « + » move the split,
« r » re-reads the disk — the migration writes while one watches, and
closing the screen to see the next bump is what one ended up doing.
The « missing logs » had two causes, both real. A tail was shown without
saying it was one; it now names how many lines are hidden. And the first
two steps run before the database is named, so their logs landed under
« sans-nom » — outside the migration they belong to, invisible from the
screen. They are brought back the moment the name is known, appended
never overwritten.
The step 4 bug was already fixed: its log held all six bumps because only
print_step opened one, and the bump loop never calls it.
--- FR ---
[ADD] état de migration : l, p, r et les deux touches de taille
Quatre touches, et un défaut derrière. « l » sépare les commandes du
journal : la première dit ce qui a été LANCÉ, le second ce que cela a
RÉPONDU, et une mise à jour qui écrit des dizaines de milliers de lignes
enterre trois commandes. « p » cache le panneau de résumé, « - » et « + »
déplacent la séparation, « r » relit le disque — la migration écrit
pendant qu'on regarde.
Les « logs manquants » avaient deux causes. On montrait une fin sans dire
que c'en était une ; le nombre de lignes cachées est désormais nommé. Et
les deux premières étapes tournent avant que la base ne soit nommée :
leurs journaux atterrissaient sous « sans-nom », hors de la migration à
laquelle ils appartiennent. Ils la rejoignent dès que le nom est connu.
Assisted-by: Claude Opus 5
2026-08-19 06:23:20 -04:00
|
|
|
|
)[0]
|
[ADD] migration: keep the logs on disk, one file per step
The state screen showed « no tool has run yet » after closing and
reopening. It was reading the progression file, and that file is archived
and reset when a migration restarts — so everything it knew vanished at
the exact moment one wants to understand why the restart was needed.
Each step now has its own log file, appended never overwritten, and the
failures and tool verdicts go to an append-only JSONL beside them. Both
outlive the progression, and the screen reads disk first, memory second,
without counting the overlap twice.
The command output itself is captured where every line already passes,
in the executor's read loop: the terminal still shows it live and nothing
about the run changes. What goes through the real terminal cannot be
captured — a pipe there makes full screens refuse, that lesson is paid —
so those keep at least their command and their exit code.
--- FR ---
[ADD] migration : garder les journaux sur disque, un fichier par étape
L'écran d'état affichait « aucun outil n'a encore tourné » après une
fermeture. Il lisait le fichier de progression, or celui-ci est archivé
puis remis à zéro quand on recommence : tout ce qu'il savait disparaissait
au moment précis où l'on cherche pourquoi il a fallu recommencer.
Chaque étape a désormais son fichier, en ajout et jamais en écrasement, et
les échecs comme les verdicts d'outils vont dans un JSONL à côté. Les deux
survivent à la progression, et l'écran lit le disque d'abord.
La sortie des commandes est captée là où chaque ligne passe déjà, dans la
boucle de lecture de l'exécuteur : le terminal la montre toujours en
direct. Ce qui passe par le vrai terminal n'est pas captable — un tube y
ferait renoncer les pleins écrans — et garde au moins son verdict.
Assisted-by: Claude Opus 5
2026-08-19 04:32:25 -04:00
|
|
|
|
)
|
|
|
|
|
|
self.assertIn("$ true", texte)
|
|
|
|
|
|
self.assertIn("-> 0", texte)
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_step_never_run_has_no_file(self):
|
|
|
|
|
|
self.assertIsNone(
|
|
|
|
|
|
status.step_log_path(
|
|
|
|
|
|
{"config_database_name": "essai_db"}, "9 - jamais"
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_name_is_computed_in_ONE_place(self):
|
|
|
|
|
|
# Deux formules dériveraient, et l'écran chercherait un fichier que
|
|
|
|
|
|
# personne n'écrit — sans rien signaler, puisqu'un fichier absent
|
|
|
|
|
|
# se lit comme une étape sans journal.
|
|
|
|
|
|
self.assertIs(TodoUpgrade.step_slug, status.step_slug)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestTheCommandOutputItself(DiskCase):
|
|
|
|
|
|
"""Ce qui manquait vraiment : ce que les commandes ont RÉPONDU."""
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_lines_land_in_the_step_log(self):
|
|
|
|
|
|
from script.execute import execute as ex
|
|
|
|
|
|
|
|
|
|
|
|
obj = self.upgrade()
|
|
|
|
|
|
obj.execute = ex.Execute()
|
|
|
|
|
|
obj.print_step("2 - Succeed update all addons")
|
|
|
|
|
|
with redirect_stdout(io.StringIO()):
|
|
|
|
|
|
obj.todo_upgrade_execute(
|
|
|
|
|
|
"echo première && echo seconde >&2", wait_at_error=False
|
|
|
|
|
|
)
|
|
|
|
|
|
obj.close_step_log()
|
|
|
|
|
|
texte = "\n".join(
|
|
|
|
|
|
status.step_log_tail(
|
|
|
|
|
|
{"config_database_name": "essai_db"},
|
|
|
|
|
|
"2 - Succeed update all addons",
|
[ADD] migration state: l, p, r and the two resize keys
Four keys, and one bug behind them. « l » separates the commands from the
server log: the first says what was LAUNCHED, the second what it
ANSWERED, and an update writing tens of thousands of lines buries three
commands. « p » hides the summary panel, « - » and « + » move the split,
« r » re-reads the disk — the migration writes while one watches, and
closing the screen to see the next bump is what one ended up doing.
The « missing logs » had two causes, both real. A tail was shown without
saying it was one; it now names how many lines are hidden. And the first
two steps run before the database is named, so their logs landed under
« sans-nom » — outside the migration they belong to, invisible from the
screen. They are brought back the moment the name is known, appended
never overwritten.
The step 4 bug was already fixed: its log held all six bumps because only
print_step opened one, and the bump loop never calls it.
--- FR ---
[ADD] état de migration : l, p, r et les deux touches de taille
Quatre touches, et un défaut derrière. « l » sépare les commandes du
journal : la première dit ce qui a été LANCÉ, le second ce que cela a
RÉPONDU, et une mise à jour qui écrit des dizaines de milliers de lignes
enterre trois commandes. « p » cache le panneau de résumé, « - » et « + »
déplacent la séparation, « r » relit le disque — la migration écrit
pendant qu'on regarde.
Les « logs manquants » avaient deux causes. On montrait une fin sans dire
que c'en était une ; le nombre de lignes cachées est désormais nommé. Et
les deux premières étapes tournent avant que la base ne soit nommée :
leurs journaux atterrissaient sous « sans-nom », hors de la migration à
laquelle ils appartiennent. Ils la rejoignent dès que le nom est connu.
Assisted-by: Claude Opus 5
2026-08-19 06:23:20 -04:00
|
|
|
|
)[0]
|
[ADD] migration: keep the logs on disk, one file per step
The state screen showed « no tool has run yet » after closing and
reopening. It was reading the progression file, and that file is archived
and reset when a migration restarts — so everything it knew vanished at
the exact moment one wants to understand why the restart was needed.
Each step now has its own log file, appended never overwritten, and the
failures and tool verdicts go to an append-only JSONL beside them. Both
outlive the progression, and the screen reads disk first, memory second,
without counting the overlap twice.
The command output itself is captured where every line already passes,
in the executor's read loop: the terminal still shows it live and nothing
about the run changes. What goes through the real terminal cannot be
captured — a pipe there makes full screens refuse, that lesson is paid —
so those keep at least their command and their exit code.
--- FR ---
[ADD] migration : garder les journaux sur disque, un fichier par étape
L'écran d'état affichait « aucun outil n'a encore tourné » après une
fermeture. Il lisait le fichier de progression, or celui-ci est archivé
puis remis à zéro quand on recommence : tout ce qu'il savait disparaissait
au moment précis où l'on cherche pourquoi il a fallu recommencer.
Chaque étape a désormais son fichier, en ajout et jamais en écrasement, et
les échecs comme les verdicts d'outils vont dans un JSONL à côté. Les deux
survivent à la progression, et l'écran lit le disque d'abord.
La sortie des commandes est captée là où chaque ligne passe déjà, dans la
boucle de lecture de l'exécuteur : le terminal la montre toujours en
direct. Ce qui passe par le vrai terminal n'est pas captable — un tube y
ferait renoncer les pleins écrans — et garde au moins son verdict.
Assisted-by: Claude Opus 5
2026-08-19 04:32:25 -04:00
|
|
|
|
)
|
|
|
|
|
|
self.assertIn("première", texte)
|
|
|
|
|
|
# stderr aussi : c'est là que les erreurs d'Odoo se trouvent.
|
|
|
|
|
|
self.assertIn("seconde", texte)
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_broken_sink_never_breaks_the_command(self):
|
|
|
|
|
|
# Journaliser est un service rendu, pas une condition de marche.
|
|
|
|
|
|
from script.execute import execute as ex
|
|
|
|
|
|
|
|
|
|
|
|
class PuitsCasse:
|
|
|
|
|
|
def write(self, texte):
|
|
|
|
|
|
raise OSError("disque plein")
|
|
|
|
|
|
|
|
|
|
|
|
moteur = ex.Execute()
|
|
|
|
|
|
moteur.log_sink = PuitsCasse()
|
|
|
|
|
|
with redirect_stdout(io.StringIO()):
|
|
|
|
|
|
status_code = moteur.exec_command_live(
|
|
|
|
|
|
"echo bonjour", source_erplibre=False, quiet=True
|
|
|
|
|
|
)
|
|
|
|
|
|
self.assertEqual(status_code, 0)
|
|
|
|
|
|
|
|
|
|
|
|
def test_nothing_is_logged_without_a_step(self):
|
|
|
|
|
|
from script.execute import execute as ex
|
|
|
|
|
|
|
|
|
|
|
|
moteur = ex.Execute()
|
|
|
|
|
|
self.assertIsNone(getattr(moteur, "log_sink", None))
|
|
|
|
|
|
|
|
|
|
|
|
|
[ADD] migration state: colour the commands, and know when not to
A list of commands in flat text blends into its own headings the moment it
outgrows the screen — which is exactly when it gets read. Commands are now
cyan, steps bold blue, failed ones red, and each verdict wears the colour
of its meaning: 1 is amber, because « there is something to look at » is
not a crash and painting it red worries for nothing.
Colour is refused three times over, and each refusal has cost someone: a
log file full of escape codes, a grep that finds nothing, a terminal that
prints them as text. A pipe is not a screen, and NO_COLOR is honoured.
The full screen goes through Text.from_ansi, which does two things: it
renders the colours, and it takes the rest literally. Textual parses Rich
markup, so a command containing « [1] » was being eaten as a tag and
vanished from the pane without a word. That was true before the colour.
--- FR ---
[ADD] état de migration : colorer les commandes, et savoir s'en abstenir
Une liste de commandes en texte plat se confond avec ses titres dès
qu'elle dépasse l'écran — et c'est justement quand elle le dépasse qu'on
la lit. Les commandes sont en cyan, les étapes en bleu gras, celles qui
ont échoué en rouge, et chaque verdict porte la couleur de son sens : 1
est ambre, car « il y a quelque chose à regarder » n'est pas une panne.
La couleur se refuse trois fois, et chaque refus a coûté à quelqu'un : un
fichier de journal truffé de codes d'échappement, un grep qui ne trouve
plus rien, un terminal qui les affiche en clair.
Le plein écran passe par Text.from_ansi, qui rend les couleurs ET prend le
reste au pied de la lettre : une commande contenant « [1] » était avalée
comme du balisage Rich et disparaissait sans un mot.
Assisted-by: Claude Opus 5
2026-08-19 05:36:49 -04:00
|
|
|
|
class TestColour(Base):
|
|
|
|
|
|
"""Distinguer d'un coup d'œil ce qui a été LANCÉ du reste.
|
|
|
|
|
|
|
|
|
|
|
|
Une liste de commandes en texte plat se confond avec ses titres dès
|
|
|
|
|
|
qu'elle dépasse l'écran, et c'est justement quand elle le dépasse qu'on
|
|
|
|
|
|
la lit.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
def rapport(self, colour):
|
|
|
|
|
|
dct = progression()
|
|
|
|
|
|
return status.render_text(dct, colour=colour)
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_commands_are_coloured(self):
|
|
|
|
|
|
texte = self.rapport(True)
|
|
|
|
|
|
self.assertIn(status.ANSI["cmd"], texte)
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_verdict_wears_the_colour_of_its_meaning(self):
|
|
|
|
|
|
# 1 n'est pas une panne : c'est « il y a quelque chose à regarder ».
|
|
|
|
|
|
# Le peindre en rouge inquiéterait pour rien.
|
|
|
|
|
|
self.assertEqual(status.VERDICT_COLOUR[0], "ok")
|
|
|
|
|
|
self.assertEqual(status.VERDICT_COLOUR[1], "warn")
|
|
|
|
|
|
self.assertEqual(status.VERDICT_COLOUR[2], "fail")
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_failed_command_is_red(self):
|
|
|
|
|
|
self.assertIn(status.ANSI["fail"], self.rapport(True))
|
|
|
|
|
|
|
|
|
|
|
|
def test_every_colour_is_closed(self):
|
|
|
|
|
|
# Une séquence ouverte teinte tout ce qui suit, y compris l'invite
|
|
|
|
|
|
# du terminal une fois l'outil terminé.
|
|
|
|
|
|
texte = self.rapport(True)
|
|
|
|
|
|
ouvertures = sum(texte.count(code) for code in status.ANSI.values())
|
|
|
|
|
|
self.assertEqual(texte.count(status.RESET), ouvertures)
|
|
|
|
|
|
|
|
|
|
|
|
def test_plain_output_carries_no_escape_at_all(self):
|
|
|
|
|
|
self.assertNotIn("\033", self.rapport(False))
|
|
|
|
|
|
|
|
|
|
|
|
def test_paint_returns_the_text_untouched_when_off(self):
|
|
|
|
|
|
self.assertEqual(status.paint("make", "cmd", False), "make")
|
|
|
|
|
|
|
|
|
|
|
|
def test_an_unknown_colour_never_invents_one(self):
|
|
|
|
|
|
self.assertEqual(status.paint("make", "mauve", True), "make")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestWhenColourWouldBeAMistake(Base):
|
|
|
|
|
|
"""Un tube n'est pas un écran, et chacun de ces refus a coûté.
|
|
|
|
|
|
|
|
|
|
|
|
Un fichier de journal truffé de codes d'échappement, un `grep` qui ne
|
|
|
|
|
|
trouve plus rien, un terminal qui les affiche en clair.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
def setUp(self):
|
|
|
|
|
|
super().setUp()
|
|
|
|
|
|
self.avant = {cle: os.environ.get(cle) for cle in ("NO_COLOR", "TERM")}
|
|
|
|
|
|
|
|
|
|
|
|
def remettre():
|
|
|
|
|
|
for cle, valeur in self.avant.items():
|
|
|
|
|
|
if valeur is None:
|
|
|
|
|
|
os.environ.pop(cle, None)
|
|
|
|
|
|
else:
|
|
|
|
|
|
os.environ[cle] = valeur
|
|
|
|
|
|
|
|
|
|
|
|
self.addCleanup(remettre)
|
|
|
|
|
|
|
|
|
|
|
|
class FauxEcran:
|
|
|
|
|
|
def __init__(self, tty):
|
|
|
|
|
|
self.tty = tty
|
|
|
|
|
|
|
|
|
|
|
|
def isatty(self):
|
|
|
|
|
|
return self.tty
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_pipe_gets_no_colour(self):
|
|
|
|
|
|
os.environ["TERM"] = "xterm"
|
|
|
|
|
|
os.environ.pop("NO_COLOR", None)
|
|
|
|
|
|
self.assertFalse(status.supports_colour(self.FauxEcran(False)))
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_terminal_does(self):
|
|
|
|
|
|
os.environ["TERM"] = "xterm"
|
|
|
|
|
|
os.environ.pop("NO_COLOR", None)
|
|
|
|
|
|
self.assertTrue(status.supports_colour(self.FauxEcran(True)))
|
|
|
|
|
|
|
|
|
|
|
|
def test_NO_COLOR_is_honoured(self):
|
|
|
|
|
|
# Convention respectée par la plupart des outils : la contredire
|
|
|
|
|
|
# oblige à nettoyer une sortie à la main.
|
|
|
|
|
|
os.environ["TERM"] = "xterm"
|
|
|
|
|
|
os.environ["NO_COLOR"] = "1"
|
|
|
|
|
|
self.assertFalse(status.supports_colour(self.FauxEcran(True)))
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_dumb_terminal_gets_none(self):
|
|
|
|
|
|
os.environ.pop("NO_COLOR", None)
|
|
|
|
|
|
os.environ["TERM"] = "dumb"
|
|
|
|
|
|
self.assertFalse(status.supports_colour(self.FauxEcran(True)))
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_stream_that_cannot_answer_gets_none(self):
|
|
|
|
|
|
os.environ["TERM"] = "xterm"
|
|
|
|
|
|
os.environ.pop("NO_COLOR", None)
|
|
|
|
|
|
|
|
|
|
|
|
class Muet:
|
|
|
|
|
|
def isatty(self):
|
|
|
|
|
|
raise OSError("fermé")
|
|
|
|
|
|
|
|
|
|
|
|
self.assertFalse(status.supports_colour(Muet()))
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_report_decides_by_itself_when_not_told(self):
|
|
|
|
|
|
import inspect
|
|
|
|
|
|
|
|
|
|
|
|
source = inspect.getsource(status.render_text)
|
|
|
|
|
|
self.assertIn("colour = supports_colour()", source)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestTheFullScreenColoursToo(Base):
|
|
|
|
|
|
def test_the_pane_can_be_coloured(self):
|
|
|
|
|
|
lst = tui.rows(progression())
|
|
|
|
|
|
etape = [x for x in lst if x["kind"] == "step"][0]
|
|
|
|
|
|
self.assertIn(
|
|
|
|
|
|
status.ANSI["cmd"],
|
|
|
|
|
|
tui.pane_text(progression(), etape, colour=True),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def test_it_stays_plain_by_default(self):
|
|
|
|
|
|
# `pane_text` sert aussi aux tests et au repli texte.
|
|
|
|
|
|
lst = tui.rows(progression())
|
|
|
|
|
|
etape = [x for x in lst if x["kind"] == "step"][0]
|
|
|
|
|
|
self.assertNotIn("\033", tui.pane_text(progression(), etape))
|
|
|
|
|
|
|
|
|
|
|
|
def test_rich_decodes_the_colours_instead_of_showing_them(self):
|
|
|
|
|
|
from rich.text import Text
|
|
|
|
|
|
|
|
|
|
|
|
lst = tui.rows(progression())
|
|
|
|
|
|
etape = [x for x in lst if x["kind"] == "step"][0]
|
|
|
|
|
|
texte = Text.from_ansi(
|
|
|
|
|
|
tui.pane_text(progression(), etape, colour=True)
|
|
|
|
|
|
)
|
|
|
|
|
|
self.assertNotIn("\033", texte.plain)
|
|
|
|
|
|
self.assertTrue(texte.spans)
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_bracket_in_a_command_is_NOT_eaten_as_markup(self):
|
|
|
|
|
|
# Piège latent d'avant la couleur : Textual interprète le balisage
|
|
|
|
|
|
# Rich, et « [1] » disparaissait sans que rien ne le signale.
|
|
|
|
|
|
from rich.text import Text
|
|
|
|
|
|
|
|
|
|
|
|
dct = progression(
|
|
|
|
|
|
command_executed=["# 2 - Update", "make config [1] et [/bold]"]
|
|
|
|
|
|
)
|
|
|
|
|
|
lst = tui.rows(dct)
|
|
|
|
|
|
etape = [x for x in lst if x["kind"] == "step"][0]
|
|
|
|
|
|
texte = Text.from_ansi(tui.pane_text(dct, etape, colour=True))
|
|
|
|
|
|
self.assertIn("[1]", texte.plain)
|
|
|
|
|
|
self.assertIn("[/bold]", texte.plain)
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_screen_goes_through_from_ansi(self):
|
|
|
|
|
|
import inspect
|
|
|
|
|
|
|
[ADD] analyse: what a migration gained and lost, step by step
A migration leaves one database per step and they all still exist, so the
tool compares them side by side rather than replaying anything. Six Odoo
starts would cost an hour, write to the databases and need the checkout
switched at each step; the same inspection in SQL takes under half a
second per database and touches nothing. Measured on a real migration:
seven databases surveyed in 3.8 seconds.
It reports what each step gained and lost — modules, models, views, menus,
attachments — and ends on the comparison of the ENDS, which is not the sum
of the steps: a module dropped at 15 and put back at 17 lost nothing, and
adding the steps would count it twice.
The signal that matters is rows. A module removed is visible; a table
going from four thousand rows to zero is visible nowhere.
Two rename heuristics were tried and rejected on real data before the one
that holds. Row count alone paired an account tag table with dms_directory
— both had seven rows. A shared five-letter word paired two cleanup
tables. Overall name similarity separates them. And a renamed table stays
in the loss list, annotated: removing it was the real danger, since a
wrong pairing would have hidden a genuine loss.
--- FR ---
[ADD] analyse : ce qu'une migration a gagné et perdu, palier par palier
Une migration laisse une base par palier et elles existent toutes encore :
l'outil les compare côte à côte plutôt que de rejouer quoi que ce soit.
Six démarrages d'Odoo coûteraient une heure et écriraient dans les bases ;
la même inspection en SQL prend moins d'une demi-seconde par base. Mesuré
sur une vraie migration : sept bases inspectées en 3,8 secondes.
Le bilan final compare les EXTRÉMITÉS, ce qui n'est pas la somme des
paliers : un module retiré en 15 puis remis en 17 n'a rien perdu.
Le signal qui compte, ce sont les lignes. Un module en moins se voit ; une
table qui passe de quatre mille lignes à zéro ne se voit nulle part.
Deux rapprochements de renommage ont été essayés et rejetés sur des
données réelles. Et une table renommée reste dans la liste des pertes,
annotée : l'en retirer était le vrai danger.
Assisted-by: Claude Opus 5
2026-08-19 08:21:12 -04:00
|
|
|
|
# Le rendu est sorti de la classe pour la rendre testable : c'est
|
|
|
|
|
|
# `show_pane` qui porte maintenant les deux propriétés.
|
|
|
|
|
|
source = inspect.getsource(tui.show_pane)
|
[ADD] migration state: colour the commands, and know when not to
A list of commands in flat text blends into its own headings the moment it
outgrows the screen — which is exactly when it gets read. Commands are now
cyan, steps bold blue, failed ones red, and each verdict wears the colour
of its meaning: 1 is amber, because « there is something to look at » is
not a crash and painting it red worries for nothing.
Colour is refused three times over, and each refusal has cost someone: a
log file full of escape codes, a grep that finds nothing, a terminal that
prints them as text. A pipe is not a screen, and NO_COLOR is honoured.
The full screen goes through Text.from_ansi, which does two things: it
renders the colours, and it takes the rest literally. Textual parses Rich
markup, so a command containing « [1] » was being eaten as a tag and
vanished from the pane without a word. That was true before the colour.
--- FR ---
[ADD] état de migration : colorer les commandes, et savoir s'en abstenir
Une liste de commandes en texte plat se confond avec ses titres dès
qu'elle dépasse l'écran — et c'est justement quand elle le dépasse qu'on
la lit. Les commandes sont en cyan, les étapes en bleu gras, celles qui
ont échoué en rouge, et chaque verdict porte la couleur de son sens : 1
est ambre, car « il y a quelque chose à regarder » n'est pas une panne.
La couleur se refuse trois fois, et chaque refus a coûté à quelqu'un : un
fichier de journal truffé de codes d'échappement, un grep qui ne trouve
plus rien, un terminal qui les affiche en clair.
Le plein écran passe par Text.from_ansi, qui rend les couleurs ET prend le
reste au pied de la lettre : une commande contenant « [1] » était avalée
comme du balisage Rich et disparaissait sans un mot.
Assisted-by: Claude Opus 5
2026-08-19 05:36:49 -04:00
|
|
|
|
self.assertIn("Text.from_ansi", source)
|
|
|
|
|
|
self.assertIn("colour=True", source)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-19 06:37:14 -04:00
|
|
|
|
class TestHowLongItTook(Base):
|
|
|
|
|
|
"""Du premier écrit à la dernière écriture du journal.
|
|
|
|
|
|
|
|
|
|
|
|
La progression est réécrite après chaque geste, donc sa date de mise à
|
|
|
|
|
|
jour EST la fin — ou l'instant présent si la migration tourne encore.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
def dct(self, debut, fin):
|
|
|
|
|
|
return progression(date_create=debut, date_update=fin)
|
|
|
|
|
|
|
|
|
|
|
|
def test_it_counts_from_the_start_to_the_last_write(self):
|
|
|
|
|
|
info = status.overview(
|
|
|
|
|
|
self.dct("2026-08-19 09:06:27", "2026-08-19 10:19:18")
|
|
|
|
|
|
)
|
|
|
|
|
|
self.assertEqual(info["elapsed"], "1 h 12 min")
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_short_run_is_given_in_minutes(self):
|
|
|
|
|
|
info = status.overview(
|
|
|
|
|
|
self.dct("2026-08-19 09:06:00", "2026-08-19 09:41:00")
|
|
|
|
|
|
)
|
|
|
|
|
|
self.assertEqual(info["elapsed"], "35 min")
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_long_run_is_given_in_days(self):
|
|
|
|
|
|
info = status.overview(
|
|
|
|
|
|
self.dct("2026-08-17 09:00:00", "2026-08-19 11:30:00")
|
|
|
|
|
|
)
|
|
|
|
|
|
self.assertIn("j", info["elapsed"])
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_missing_date_never_invents_a_duration(self):
|
|
|
|
|
|
self.assertEqual(status.overview(self.dct(None, None))["elapsed"], "?")
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_report_shows_it(self):
|
|
|
|
|
|
texte = status.render_text(
|
|
|
|
|
|
self.dct("2026-08-19 09:06:27", "2026-08-19 10:19:18"),
|
|
|
|
|
|
colour=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
self.assertIn("1 h 12 min", texte)
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_full_screen_shows_it_too(self):
|
|
|
|
|
|
texte = tui.head_text(
|
|
|
|
|
|
self.dct("2026-08-19 09:06:27", "2026-08-19 10:19:18")
|
|
|
|
|
|
)
|
|
|
|
|
|
self.assertIn("1 h 12 min", texte)
|
|
|
|
|
|
|
|
|
|
|
|
def test_it_is_computed_in_ONE_place(self):
|
|
|
|
|
|
# L'écran de statistiques porte déjà ce calcul. Deux formules
|
|
|
|
|
|
# donneraient deux durées pour la même migration selon l'écran
|
|
|
|
|
|
# qu'on ouvre.
|
|
|
|
|
|
import inspect
|
|
|
|
|
|
|
|
|
|
|
|
source = inspect.getsource(status.elapsed)
|
|
|
|
|
|
self.assertIn(
|
|
|
|
|
|
"from script.todo.migration_stats import fmt_delay", source
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
[ADD] migration state: read the server log so nobody has to
A step log reaches thirteen megabytes — measured on a real migration.
Nobody reads that, and the question one asks in front of it is two words
long: where did it go wrong? The screen now answers it.
Each step carries its count of ERROR and CRITICAL, and the pane lists the
DISTINCT messages with how many times each occurred. Forty-eight
« Model X has no table » is one problem seen forty-eight times, not
forty-eight problems, and the raw list buries everything else. WARNING is
left out of the count: a migration produces thousands, and a total that
includes them means nothing.
Each message keeps its logger and its DATABASE, which is what tells the
six bumps apart — they shared one file before a step opened its own.
Measured: thirteen megabytes scanned in 0.09 s, seventy occurrences
reduced to fifteen messages, and cached on (size, mtime) because the
screen repaints on every keystroke.
--- FR ---
[ADD] état de migration : lire le journal pour que personne n'ait à le faire
Un journal d'étape atteint treize mégaoctets — mesuré. Personne ne le
lit, et la question qu'on se pose devant lui tient en deux mots : où
est-ce que ça a mal tourné ? L'écran y répond désormais.
Chaque étape porte son compte d'ERROR et de CRITICAL, et le panneau liste
les messages DISTINCTS avec leur nombre. Quarante-huit fois « Model X has
no table » est un problème vu quarante-huit fois, pas quarante-huit
problèmes. Les WARNING sont hors du compte : une migration en produit des
milliers.
Chaque message garde son logger et sa BASE, ce qui distingue les six
paliers — ils partageaient un fichier avant qu'une étape n'ouvre le sien.
Mesuré : treize mégaoctets analysés en 0,09 s, soixante-dix occurrences
ramenées à quinze messages.
Assisted-by: Claude Opus 5
2026-08-19 07:37:40 -04:00
|
|
|
|
class TestReadingTheLogForTheUser(DiskCase):
|
|
|
|
|
|
"""Compter les erreurs à la place de quelqu'un.
|
|
|
|
|
|
|
|
|
|
|
|
Un journal d'étape atteint treize mégaoctets — mesuré. Personne ne le
|
|
|
|
|
|
lit, et la question qu'on se pose devant lui tient en deux mots : où
|
|
|
|
|
|
est-ce que ça a mal tourné ? C'est cette question-là que l'écran doit
|
|
|
|
|
|
savoir répondre sans qu'on ouvre le fichier.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
JOURNAL = (
|
|
|
|
|
|
"2026-08-19 09:21:07,074 132948 ERROR base13"
|
|
|
|
|
|
" odoo.tools.translate: couldn't read translation file\n"
|
|
|
|
|
|
"Traceback (most recent call last):\n"
|
|
|
|
|
|
"2026-08-19 09:34:27,088 133771 ERROR base14"
|
|
|
|
|
|
" odoo.modules.registry: Model account.bank.statement.import"
|
|
|
|
|
|
" has no table.\n"
|
|
|
|
|
|
"2026-08-19 09:34:28,088 133771 ERROR base14"
|
|
|
|
|
|
" odoo.modules.registry: Model account.bank.statement.import"
|
|
|
|
|
|
" has no table.\n"
|
|
|
|
|
|
"2026-08-19 09:44:51,959 134592 CRITICAL base15"
|
|
|
|
|
|
" odoo.service.server: Failed to initialize database.\n"
|
|
|
|
|
|
"2026-08-19 09:44:52,000 134592 WARNING base15"
|
|
|
|
|
|
" odoo.schema: unable to add constraint\n"
|
|
|
|
|
|
"2026-08-19 09:44:53,000 134592 INFO base15"
|
|
|
|
|
|
" odoo.modules.loading: Modules loaded.\n"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def poser(self, contenu=None, etape="4 - Upgrade"):
|
|
|
|
|
|
obj = self.upgrade()
|
|
|
|
|
|
obj.add_comment_progression(etape)
|
|
|
|
|
|
obj.close_step_log()
|
|
|
|
|
|
chemin = os.path.join(
|
|
|
|
|
|
"private",
|
|
|
|
|
|
"odoo",
|
|
|
|
|
|
"migration",
|
|
|
|
|
|
"essai_db",
|
|
|
|
|
|
"step_log",
|
|
|
|
|
|
status.step_slug(etape) + ".log",
|
|
|
|
|
|
)
|
|
|
|
|
|
with open(chemin, "w") as handle:
|
|
|
|
|
|
handle.write(contenu if contenu is not None else self.JOURNAL)
|
|
|
|
|
|
# La progression de l'objet, pas un dict nu : c'est elle qui porte
|
|
|
|
|
|
# `command_executed`, donc le découpage par étape.
|
|
|
|
|
|
return obj.dct_progression
|
|
|
|
|
|
|
|
|
|
|
|
def test_it_counts_the_severities(self):
|
|
|
|
|
|
scan = status.step_log_scan(self.poser(), "4 - Upgrade")
|
|
|
|
|
|
self.assertEqual(scan["count"]["ERROR"], 3)
|
|
|
|
|
|
self.assertEqual(scan["count"]["CRITICAL"], 1)
|
|
|
|
|
|
self.assertEqual(scan["count"]["WARNING"], 1)
|
|
|
|
|
|
self.assertEqual(scan["count"]["TRACEBACK"], 1)
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_severe_ones_are_summed(self):
|
|
|
|
|
|
# ERROR et CRITICAL, pas WARNING : une migration en produit des
|
|
|
|
|
|
# milliers, et un compte qui les inclut ne veut plus rien dire.
|
|
|
|
|
|
scan = status.step_log_scan(self.poser(), "4 - Upgrade")
|
|
|
|
|
|
self.assertEqual(status.severe_count(scan), 4)
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_same_message_is_counted_ONCE(self):
|
|
|
|
|
|
# Quarante-huit fois « Model X has no table » est UN problème vu
|
|
|
|
|
|
# quarante-huit fois, pas quarante-huit problèmes.
|
|
|
|
|
|
scan = status.step_log_scan(self.poser(), "4 - Upgrade")
|
|
|
|
|
|
modele = [e for e in scan["errors"] if "has no table" in e["message"]]
|
|
|
|
|
|
self.assertEqual(len(modele), 1)
|
|
|
|
|
|
self.assertEqual(modele[0]["times"], 2)
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_loudest_comes_first(self):
|
|
|
|
|
|
scan = status.step_log_scan(self.poser(), "4 - Upgrade")
|
|
|
|
|
|
self.assertEqual(scan["errors"][0]["times"], 2)
|
|
|
|
|
|
|
|
|
|
|
|
def test_each_error_carries_its_database(self):
|
|
|
|
|
|
# Six paliers ont pu écrire dans le même fichier : sans la base,
|
|
|
|
|
|
# on ne sait pas lequel a souffert.
|
|
|
|
|
|
scan = status.step_log_scan(self.poser(), "4 - Upgrade")
|
|
|
|
|
|
bases = {e["database"] for e in scan["errors"]}
|
|
|
|
|
|
self.assertEqual(bases, {"base13", "base14", "base15"})
|
|
|
|
|
|
|
|
|
|
|
|
def test_it_carries_the_logger_too(self):
|
|
|
|
|
|
scan = status.step_log_scan(self.poser(), "4 - Upgrade")
|
|
|
|
|
|
loggers = {e["logger"] for e in scan["errors"]}
|
|
|
|
|
|
self.assertIn("odoo.tools.translate", loggers)
|
|
|
|
|
|
|
|
|
|
|
|
def test_warnings_are_never_listed_as_errors(self):
|
|
|
|
|
|
scan = status.step_log_scan(self.poser(), "4 - Upgrade")
|
|
|
|
|
|
self.assertNotIn(
|
|
|
|
|
|
"unable to add constraint",
|
|
|
|
|
|
" ".join(e["message"] for e in scan["errors"]),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_step_without_a_log_counts_zero(self):
|
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
|
status.severe_count(
|
|
|
|
|
|
status.step_log_scan(
|
|
|
|
|
|
{"config_database_name": "essai_db"}, "9 - rien"
|
|
|
|
|
|
)
|
|
|
|
|
|
),
|
|
|
|
|
|
0,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_healthy_log_shows_no_alarm(self):
|
|
|
|
|
|
dct = self.poser(
|
|
|
|
|
|
"2026-08-19 09:00:00,000 1 INFO db odoo.modules: Modules loaded.\n"
|
|
|
|
|
|
)
|
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
|
status.severe_count(status.step_log_scan(dct, "4 - Upgrade")), 0
|
|
|
|
|
|
)
|
|
|
|
|
|
# La LIGNE d'étape, pas tout le rapport : celui-ci porte toujours
|
|
|
|
|
|
# l'en-tête « Commandes en échec », qui a le même symbole.
|
|
|
|
|
|
etapes = status.render_text(dct, colour=False).split("step by step")[1]
|
|
|
|
|
|
self.assertNotIn("❌", etapes)
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_report_names_the_count_and_the_top_errors(self):
|
|
|
|
|
|
texte = status.render_text(self.poser(), colour=False)
|
|
|
|
|
|
self.assertIn("❌ 4", texte)
|
|
|
|
|
|
self.assertIn("has no table", texte)
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_full_screen_lists_them_with_their_source(self):
|
|
|
|
|
|
dct = self.poser()
|
|
|
|
|
|
etape = [x for x in tui.rows(dct) if x["kind"] == "step"][0]
|
|
|
|
|
|
texte = tui.pane_text(dct, etape, show_log=False)
|
|
|
|
|
|
self.assertIn("×2", texte)
|
|
|
|
|
|
self.assertIn("odoo.modules.registry", texte)
|
|
|
|
|
|
self.assertIn("base14", texte)
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_left_column_shows_the_count(self):
|
|
|
|
|
|
dct = self.poser()
|
|
|
|
|
|
etape = [x for x in tui.rows(dct) if x["kind"] == "step"][0]
|
|
|
|
|
|
self.assertEqual(etape["severe"], "4")
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_clean_step_leaves_the_column_empty(self):
|
|
|
|
|
|
# Un « 0 » dans chaque ligne n'apprend rien et occupe la place.
|
|
|
|
|
|
dct = self.poser(
|
|
|
|
|
|
"2026-08-19 09:00:00,000 1 INFO db odoo.modules: ok\n"
|
|
|
|
|
|
)
|
|
|
|
|
|
etape = [x for x in tui.rows(dct) if x["kind"] == "step"][0]
|
|
|
|
|
|
self.assertEqual(etape["severe"], "")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestReadingItTwiceIsFree(DiskCase):
|
|
|
|
|
|
"""L'écran se redessine à chaque touche.
|
|
|
|
|
|
|
|
|
|
|
|
Relire treize mégaoctets à chaque frappe rendrait l'écran inutilisable.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_second_read_does_not_touch_the_disk(self):
|
|
|
|
|
|
obj = self.upgrade()
|
|
|
|
|
|
obj.add_comment_progression("4 - Upgrade")
|
|
|
|
|
|
obj.close_step_log()
|
|
|
|
|
|
chemin = os.path.join(
|
|
|
|
|
|
"private",
|
|
|
|
|
|
"odoo",
|
|
|
|
|
|
"migration",
|
|
|
|
|
|
"essai_db",
|
|
|
|
|
|
"step_log",
|
|
|
|
|
|
status.step_slug("4 - Upgrade") + ".log",
|
|
|
|
|
|
)
|
|
|
|
|
|
with open(chemin, "w") as handle:
|
|
|
|
|
|
handle.write("2026-08-19 09:00:00,000 1 ERROR db odoo.x: boum\n")
|
|
|
|
|
|
dct = {"config_database_name": "essai_db"}
|
|
|
|
|
|
premier = status.step_log_scan(dct, "4 - Upgrade")
|
|
|
|
|
|
lectures = []
|
|
|
|
|
|
vrai_open = open
|
|
|
|
|
|
|
|
|
|
|
|
def compter(*args, **kwargs):
|
|
|
|
|
|
lectures.append(args[0])
|
|
|
|
|
|
return vrai_open(*args, **kwargs)
|
|
|
|
|
|
|
|
|
|
|
|
import builtins
|
|
|
|
|
|
|
|
|
|
|
|
builtins.open = compter
|
|
|
|
|
|
self.addCleanup(setattr, builtins, "open", vrai_open)
|
|
|
|
|
|
second = status.step_log_scan(dct, "4 - Upgrade")
|
|
|
|
|
|
self.assertEqual(premier, second)
|
|
|
|
|
|
self.assertEqual([x for x in lectures if str(x).endswith(".log")], [])
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_changed_file_IS_read_again(self):
|
|
|
|
|
|
# Sinon « r » ne rafraîchirait rien : la migration écrit pendant
|
|
|
|
|
|
# qu'on regarde.
|
|
|
|
|
|
obj = self.upgrade()
|
|
|
|
|
|
obj.add_comment_progression("4 - Upgrade")
|
|
|
|
|
|
obj.close_step_log()
|
|
|
|
|
|
chemin = os.path.join(
|
|
|
|
|
|
"private",
|
|
|
|
|
|
"odoo",
|
|
|
|
|
|
"migration",
|
|
|
|
|
|
"essai_db",
|
|
|
|
|
|
"step_log",
|
|
|
|
|
|
status.step_slug("4 - Upgrade") + ".log",
|
|
|
|
|
|
)
|
|
|
|
|
|
dct = {"config_database_name": "essai_db"}
|
|
|
|
|
|
with open(chemin, "w") as handle:
|
|
|
|
|
|
handle.write("2026-08-19 09:00:00,000 1 ERROR db odoo.x: un\n")
|
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
|
status.severe_count(status.step_log_scan(dct, "4 - Upgrade")), 1
|
|
|
|
|
|
)
|
|
|
|
|
|
with open(chemin, "a") as handle:
|
|
|
|
|
|
handle.write("2026-08-19 09:00:01,000 1 ERROR db odoo.y: deux\n")
|
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
|
status.severe_count(status.step_log_scan(dct, "4 - Upgrade")), 2
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
[ADD] migration state: l, p, r and the two resize keys
Four keys, and one bug behind them. « l » separates the commands from the
server log: the first says what was LAUNCHED, the second what it
ANSWERED, and an update writing tens of thousands of lines buries three
commands. « p » hides the summary panel, « - » and « + » move the split,
« r » re-reads the disk — the migration writes while one watches, and
closing the screen to see the next bump is what one ended up doing.
The « missing logs » had two causes, both real. A tail was shown without
saying it was one; it now names how many lines are hidden. And the first
two steps run before the database is named, so their logs landed under
« sans-nom » — outside the migration they belong to, invisible from the
screen. They are brought back the moment the name is known, appended
never overwritten.
The step 4 bug was already fixed: its log held all six bumps because only
print_step opened one, and the bump loop never calls it.
--- FR ---
[ADD] état de migration : l, p, r et les deux touches de taille
Quatre touches, et un défaut derrière. « l » sépare les commandes du
journal : la première dit ce qui a été LANCÉ, le second ce que cela a
RÉPONDU, et une mise à jour qui écrit des dizaines de milliers de lignes
enterre trois commandes. « p » cache le panneau de résumé, « - » et « + »
déplacent la séparation, « r » relit le disque — la migration écrit
pendant qu'on regarde.
Les « logs manquants » avaient deux causes. On montrait une fin sans dire
que c'en était une ; le nombre de lignes cachées est désormais nommé. Et
les deux premières étapes tournent avant que la base ne soit nommée :
leurs journaux atterrissaient sous « sans-nom », hors de la migration à
laquelle ils appartiennent. Ils la rejoignent dès que le nom est connu.
Assisted-by: Claude Opus 5
2026-08-19 06:23:20 -04:00
|
|
|
|
class TestSeparatingCommandsFromLogs(Base):
|
|
|
|
|
|
"""Les deux mélangés dans un même panneau se confondent.
|
|
|
|
|
|
|
|
|
|
|
|
La liste des commandes dit ce qui a été LANCÉ ; le journal dit ce que
|
|
|
|
|
|
cela a RÉPONDU. Ce sont deux lectures différentes, et l'une noie
|
|
|
|
|
|
l'autre : un `update_addons_all` écrit des dizaines de milliers de
|
|
|
|
|
|
lignes au-dessus desquelles trois commandes disparaissent.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
def dct(self):
|
|
|
|
|
|
return progression(command_executed=["# 2 - Update", "./run.sh -d db"])
|
|
|
|
|
|
|
|
|
|
|
|
def etape(self, dct):
|
|
|
|
|
|
return [x for x in tui.rows(dct) if x["kind"] == "step"][0]
|
|
|
|
|
|
|
|
|
|
|
|
def test_hiding_the_log_keeps_the_commands(self):
|
|
|
|
|
|
dct = self.dct()
|
|
|
|
|
|
texte = tui.pane_text(dct, self.etape(dct), show_log=False)
|
|
|
|
|
|
self.assertIn("./run.sh -d db", texte)
|
|
|
|
|
|
|
|
|
|
|
|
def test_hiding_it_never_hides_it_SILENTLY(self):
|
|
|
|
|
|
# Un panneau qui se vide sans un mot se lit comme « il n'y a rien »,
|
|
|
|
|
|
# ce qui est exactement le contraire de ce qui vient de se passer.
|
|
|
|
|
|
import tempfile
|
|
|
|
|
|
|
|
|
|
|
|
dossier = tempfile.mkdtemp(prefix="erplibre_essai_")
|
|
|
|
|
|
avant = os.getcwd()
|
|
|
|
|
|
os.chdir(dossier)
|
|
|
|
|
|
self.addCleanup(shutil.rmtree, dossier, True)
|
|
|
|
|
|
self.addCleanup(os.chdir, avant)
|
|
|
|
|
|
chemin = os.path.join("private", "odoo", "migration", "db", "step_log")
|
|
|
|
|
|
os.makedirs(chemin)
|
|
|
|
|
|
with open(
|
|
|
|
|
|
os.path.join(chemin, status.step_slug("2 - Update") + ".log"), "w"
|
|
|
|
|
|
) as handle:
|
|
|
|
|
|
handle.write("\n".join(f"ligne {i}" for i in range(50)))
|
|
|
|
|
|
dct = self.dct()
|
|
|
|
|
|
dct["config_database_name"] = "db"
|
|
|
|
|
|
texte = tui.pane_text(dct, self.etape(dct), show_log=False)
|
|
|
|
|
|
self.assertIn("50", texte)
|
|
|
|
|
|
self.assertNotIn("ligne 49", texte)
|
|
|
|
|
|
|
|
|
|
|
|
def test_showing_it_says_how_much_is_cut(self):
|
|
|
|
|
|
# « il manque des logs » venait de là : on montrait la fin sans
|
|
|
|
|
|
# dire qu'on cachait le début.
|
|
|
|
|
|
lst, total = ([f"l{i}" for i in range(400)], 12843)
|
|
|
|
|
|
self.assertLess(len(lst), total)
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_tail_reports_the_total(self):
|
|
|
|
|
|
import tempfile
|
|
|
|
|
|
|
|
|
|
|
|
dossier = tempfile.mkdtemp(prefix="erplibre_essai_")
|
|
|
|
|
|
avant = os.getcwd()
|
|
|
|
|
|
os.chdir(dossier)
|
|
|
|
|
|
self.addCleanup(shutil.rmtree, dossier, True)
|
|
|
|
|
|
self.addCleanup(os.chdir, avant)
|
|
|
|
|
|
chemin = os.path.join("private", "odoo", "migration", "db", "step_log")
|
|
|
|
|
|
os.makedirs(chemin)
|
|
|
|
|
|
with open(
|
|
|
|
|
|
os.path.join(chemin, status.step_slug("2 - Update") + ".log"), "w"
|
|
|
|
|
|
) as handle:
|
|
|
|
|
|
handle.write("\n".join(f"ligne {i}" for i in range(1000)))
|
|
|
|
|
|
lst, total = status.step_log_tail(
|
|
|
|
|
|
{"config_database_name": "db"}, "2 - Update", lines=400
|
|
|
|
|
|
)
|
|
|
|
|
|
self.assertEqual(total, 1000)
|
|
|
|
|
|
self.assertEqual(len(lst), 400)
|
|
|
|
|
|
self.assertEqual(lst[-1], "ligne 999")
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_step_without_a_log_reports_zero(self):
|
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
|
status.step_log_tail({"config_database_name": "db"}, "9 - rien"),
|
|
|
|
|
|
([], 0),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
[ADD] migration: announce the countdown, and cycle three panel states
Every prompt now carries « ⏱5s » before the question. One that looks like
it waits forever does not invite you to step away, which is the whole
point of auto-run. The same clock reports the answer afterwards: it
announces the wait, then says what it decided.
Enter on the auto-run question already meant no; it now says so, with an
explicit default instead of a deduction. And the flag is cleared before
asking — an ERPLIBRE_AUTO_EXECUTE inherited from the shell would have
answered yes on someone's behalf, and that answer commits every later one.
« p » cycles three states rather than toggling one: a plain toggle freed
four lines, while what takes the room is the left COLUMN — on a server log
every character of width counts. All → no summary → detail only, and back.
The state is named in the sub-title, which stays visible in all three: a
panel that vanishes without a word reads as a broken screen.
--- FR ---
[ADD] migration : annoncer le compte à rebours, et trois états de panneaux
Chaque invite porte désormais « ⏱5s » devant la question. Une invite qui a
l'air d'attendre indéfiniment n'invite pas à s'absenter, et c'est pourtant
tout l'intérêt du mode auto. Le même chronomètre rend compte ensuite.
Entrée sur la question d'auto-exécution voulait déjà dire non ; elle le
DIT maintenant, par un défaut explicite plutôt qu'une déduction. Et le
drapeau est effacé avant de demander : une variable héritée du shell
aurait répondu « oui » à la place de quelqu'un.
« p » enchaîne trois états au lieu d'en basculer un : ce qui prend la
place, c'est la colonne de gauche. Tout → sans résumé → détail seul, et
l'on revient. L'état est nommé dans le sous-titre.
Assisted-by: Claude Opus 5
2026-08-19 06:57:19 -04:00
|
|
|
|
class TestTheThreePanelStates(Base):
|
|
|
|
|
|
"""Un simple bascule ne libérait que quatre lignes.
|
|
|
|
|
|
|
|
|
|
|
|
Ce qui prend la place, c'est la COLONNE de gauche — et sur un journal
|
|
|
|
|
|
de serveur, chaque caractère gagné en largeur compte. D'où trois états,
|
|
|
|
|
|
du plus complet au plus dépouillé.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_first_state_shows_everything(self):
|
|
|
|
|
|
self.assertEqual(tui.panel_visibility(0), (True, True))
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_second_drops_the_summary(self):
|
|
|
|
|
|
self.assertEqual(tui.panel_visibility(1), (False, True))
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_third_leaves_the_detail_alone(self):
|
|
|
|
|
|
self.assertEqual(tui.panel_visibility(2), (False, False))
|
|
|
|
|
|
|
|
|
|
|
|
def test_it_comes_back_round(self):
|
|
|
|
|
|
# Sans retour, le troisième état serait un cul-de-sac : la liste
|
|
|
|
|
|
# cachée, on ne peut plus changer d'entrée.
|
|
|
|
|
|
self.assertEqual(tui.panel_visibility(3), tui.panel_visibility(0))
|
|
|
|
|
|
|
|
|
|
|
|
def test_there_are_exactly_three(self):
|
|
|
|
|
|
self.assertEqual(len(tui.PANELS), 3)
|
|
|
|
|
|
|
|
|
|
|
|
def test_each_state_has_a_name(self):
|
|
|
|
|
|
# Un panneau qui disparaît sans un mot se lit comme un écran cassé.
|
|
|
|
|
|
noms = {tui.panel_label(i) for i in range(3)}
|
|
|
|
|
|
self.assertEqual(len(noms), 3)
|
|
|
|
|
|
for nom in noms:
|
|
|
|
|
|
self.assertTrue(nom.strip())
|
|
|
|
|
|
|
|
|
|
|
|
def test_p_is_bound_to_the_cycle(self):
|
|
|
|
|
|
app = tui.build_app(progression())
|
|
|
|
|
|
action = [
|
|
|
|
|
|
entree[1] for entree in app.BINDINGS if "p" in entree[0].split(",")
|
|
|
|
|
|
]
|
|
|
|
|
|
self.assertEqual(action, ["cycle_panels"])
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_name_is_shown_where_it_stays_visible(self):
|
|
|
|
|
|
# Le sous-titre est dans l'en-tête de Textual, qui reste affiché
|
|
|
|
|
|
# dans les trois états — contrairement au panneau de résumé.
|
|
|
|
|
|
import inspect
|
|
|
|
|
|
|
|
|
|
|
|
source = inspect.getsource(tui.apply_panels)
|
|
|
|
|
|
self.assertIn("app.sub_title", source)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestTheGuardsThatCameOutOfTheClass(Base):
|
|
|
|
|
|
def test_no_row_selected_is_not_an_error(self):
|
|
|
|
|
|
self.assertIsNone(tui.current_row([], 0))
|
|
|
|
|
|
|
|
|
|
|
|
def test_an_index_past_the_end_is_not_an_error(self):
|
|
|
|
|
|
# Un rafraîchissement peut raccourcir la liste sous le curseur.
|
|
|
|
|
|
self.assertIsNone(tui.current_row([{"a": 1}], 5))
|
|
|
|
|
|
|
|
|
|
|
|
def test_rereading_without_a_path_yields_nothing(self):
|
|
|
|
|
|
self.assertIsNone(tui.reread(None))
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_width_stays_between_its_bounds(self):
|
|
|
|
|
|
app = tui.build_app(progression())
|
|
|
|
|
|
self.assertLess(app.LEFT_MIN, app.LEFT_MAX)
|
|
|
|
|
|
self.assertGreaterEqual(app.left_width, app.LEFT_MIN)
|
|
|
|
|
|
self.assertLessEqual(app.left_width, app.LEFT_MAX)
|
|
|
|
|
|
|
|
|
|
|
|
|
[ADD] migration state: l, p, r and the two resize keys
Four keys, and one bug behind them. « l » separates the commands from the
server log: the first says what was LAUNCHED, the second what it
ANSWERED, and an update writing tens of thousands of lines buries three
commands. « p » hides the summary panel, « - » and « + » move the split,
« r » re-reads the disk — the migration writes while one watches, and
closing the screen to see the next bump is what one ended up doing.
The « missing logs » had two causes, both real. A tail was shown without
saying it was one; it now names how many lines are hidden. And the first
two steps run before the database is named, so their logs landed under
« sans-nom » — outside the migration they belong to, invisible from the
screen. They are brought back the moment the name is known, appended
never overwritten.
The step 4 bug was already fixed: its log held all six bumps because only
print_step opened one, and the bump loop never calls it.
--- FR ---
[ADD] état de migration : l, p, r et les deux touches de taille
Quatre touches, et un défaut derrière. « l » sépare les commandes du
journal : la première dit ce qui a été LANCÉ, le second ce que cela a
RÉPONDU, et une mise à jour qui écrit des dizaines de milliers de lignes
enterre trois commandes. « p » cache le panneau de résumé, « - » et « + »
déplacent la séparation, « r » relit le disque — la migration écrit
pendant qu'on regarde.
Les « logs manquants » avaient deux causes. On montrait une fin sans dire
que c'en était une ; le nombre de lignes cachées est désormais nommé. Et
les deux premières étapes tournent avant que la base ne soit nommée :
leurs journaux atterrissaient sous « sans-nom », hors de la migration à
laquelle ils appartiennent. Ils la rejoignent dès que le nom est connu.
Assisted-by: Claude Opus 5
2026-08-19 06:23:20 -04:00
|
|
|
|
class TestTheKeyboard(Base):
|
|
|
|
|
|
"""Ce que l'écran promet dans son pied de page doit exister."""
|
|
|
|
|
|
|
|
|
|
|
|
def app(self):
|
|
|
|
|
|
return tui.build_app(progression(), path="/un/chemin.json")
|
|
|
|
|
|
|
|
|
|
|
|
def touches(self):
|
|
|
|
|
|
return {
|
|
|
|
|
|
touche
|
|
|
|
|
|
for entree in self.app().BINDINGS
|
|
|
|
|
|
for touche in entree[0].split(",")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
def test_every_promised_key_is_bound(self):
|
|
|
|
|
|
attendues = {"q", "escape", "r", "l", "p", "plus", "minus"}
|
|
|
|
|
|
self.assertTrue(attendues <= self.touches(), self.touches())
|
|
|
|
|
|
|
|
|
|
|
|
def test_each_binding_has_an_action_that_exists(self):
|
|
|
|
|
|
# Un raccourci annoncé dont l'action manque échoue à la frappe,
|
|
|
|
|
|
# c'est-à-dire au pire moment.
|
|
|
|
|
|
app = self.app()
|
|
|
|
|
|
for _touches, action, _libelle in app.BINDINGS:
|
|
|
|
|
|
self.assertTrue(hasattr(app, f"action_{action}"), action)
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_width_is_bounded_on_both_sides(self):
|
|
|
|
|
|
# Une colonne de zéro ne se retrouve plus ; une qui mange tout
|
|
|
|
|
|
# l'écran ne laisse rien à lire.
|
|
|
|
|
|
app = self.app()
|
|
|
|
|
|
for _ in range(50):
|
|
|
|
|
|
app.left_width = max(
|
|
|
|
|
|
app.LEFT_MIN, min(app.LEFT_MAX, app.left_width - app.LEFT_STEP)
|
|
|
|
|
|
)
|
|
|
|
|
|
self.assertEqual(app.left_width, app.LEFT_MIN)
|
|
|
|
|
|
for _ in range(50):
|
|
|
|
|
|
app.left_width = max(
|
|
|
|
|
|
app.LEFT_MIN, min(app.LEFT_MAX, app.left_width + app.LEFT_STEP)
|
|
|
|
|
|
)
|
|
|
|
|
|
self.assertEqual(app.left_width, app.LEFT_MAX)
|
|
|
|
|
|
|
|
|
|
|
|
def test_refreshing_rereads_the_disk(self):
|
|
|
|
|
|
"""La migration ÉCRIT pendant qu'on regarde.
|
|
|
|
|
|
|
|
|
|
|
|
Sans cela il fallait fermer et rouvrir l'écran pour voir le palier
|
|
|
|
|
|
suivant — sur une migration de plusieurs heures, on le fait.
|
|
|
|
|
|
"""
|
|
|
|
|
|
import inspect
|
|
|
|
|
|
|
[ADD] migration: announce the countdown, and cycle three panel states
Every prompt now carries « ⏱5s » before the question. One that looks like
it waits forever does not invite you to step away, which is the whole
point of auto-run. The same clock reports the answer afterwards: it
announces the wait, then says what it decided.
Enter on the auto-run question already meant no; it now says so, with an
explicit default instead of a deduction. And the flag is cleared before
asking — an ERPLIBRE_AUTO_EXECUTE inherited from the shell would have
answered yes on someone's behalf, and that answer commits every later one.
« p » cycles three states rather than toggling one: a plain toggle freed
four lines, while what takes the room is the left COLUMN — on a server log
every character of width counts. All → no summary → detail only, and back.
The state is named in the sub-title, which stays visible in all three: a
panel that vanishes without a word reads as a broken screen.
--- FR ---
[ADD] migration : annoncer le compte à rebours, et trois états de panneaux
Chaque invite porte désormais « ⏱5s » devant la question. Une invite qui a
l'air d'attendre indéfiniment n'invite pas à s'absenter, et c'est pourtant
tout l'intérêt du mode auto. Le même chronomètre rend compte ensuite.
Entrée sur la question d'auto-exécution voulait déjà dire non ; elle le
DIT maintenant, par un défaut explicite plutôt qu'une déduction. Et le
drapeau est effacé avant de demander : une variable héritée du shell
aurait répondu « oui » à la place de quelqu'un.
« p » enchaîne trois états au lieu d'en basculer un : ce qui prend la
place, c'est la colonne de gauche. Tout → sans résumé → détail seul, et
l'on revient. L'état est nommé dans le sous-titre.
Assisted-by: Claude Opus 5
2026-08-19 06:57:19 -04:00
|
|
|
|
# La relecture est sortie de la classe pour la rendre testable :
|
|
|
|
|
|
# on vérifie donc les DEUX moitiés, la lecture et le remontage.
|
|
|
|
|
|
self.assertIn("status.read(path)", inspect.getsource(tui.reread))
|
|
|
|
|
|
self.assertIn(
|
|
|
|
|
|
"self.lst_row = rows(self.dct)", inspect.getsource(tui.build_app)
|
|
|
|
|
|
)
|
[ADD] migration state: l, p, r and the two resize keys
Four keys, and one bug behind them. « l » separates the commands from the
server log: the first says what was LAUNCHED, the second what it
ANSWERED, and an update writing tens of thousands of lines buries three
commands. « p » hides the summary panel, « - » and « + » move the split,
« r » re-reads the disk — the migration writes while one watches, and
closing the screen to see the next bump is what one ended up doing.
The « missing logs » had two causes, both real. A tail was shown without
saying it was one; it now names how many lines are hidden. And the first
two steps run before the database is named, so their logs landed under
« sans-nom » — outside the migration they belong to, invisible from the
screen. They are brought back the moment the name is known, appended
never overwritten.
The step 4 bug was already fixed: its log held all six bumps because only
print_step opened one, and the bump loop never calls it.
--- FR ---
[ADD] état de migration : l, p, r et les deux touches de taille
Quatre touches, et un défaut derrière. « l » sépare les commandes du
journal : la première dit ce qui a été LANCÉ, le second ce que cela a
RÉPONDU, et une mise à jour qui écrit des dizaines de milliers de lignes
enterre trois commandes. « p » cache le panneau de résumé, « - » et « + »
déplacent la séparation, « r » relit le disque — la migration écrit
pendant qu'on regarde.
Les « logs manquants » avaient deux causes. On montrait une fin sans dire
que c'en était une ; le nombre de lignes cachées est désormais nommé. Et
les deux premières étapes tournent avant que la base ne soit nommée :
leurs journaux atterrissaient sous « sans-nom », hors de la migration à
laquelle ils appartiennent. Ils la rejoignent dès que le nom est connu.
Assisted-by: Claude Opus 5
2026-08-19 06:23:20 -04:00
|
|
|
|
|
|
|
|
|
|
def test_refreshing_without_a_path_does_nothing(self):
|
|
|
|
|
|
app = tui.build_app(progression(), path=None)
|
|
|
|
|
|
app.action_refresh() # ne doit pas lever
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_log_toggle_flips(self):
|
|
|
|
|
|
app = self.app()
|
|
|
|
|
|
self.assertTrue(app.show_log)
|
|
|
|
|
|
|
|
|
|
|
|
|
[ADD] migration: reach the state screen from the statistics too
The statistics screen answers « what was removed, and why ». The state
screen answers « where are we, and what broke ». Two neighbouring
questions asked at the same moment: separating them by a menu entry
rather than by two commands to remember is what makes them usable.
Same letter as the prompts. One that changes meaning from one screen to
the next is not learned, it is looked up — and nobody looks it up.
Adding it surfaced two ways that screen could not survive auto-run. The
interface question is the FIRST one asked after auto-run is switched on,
and it was a bare input(): an unattended migration stopped there before
it had begun. The statistics menu was one too. And the state screen
writes the progression before reading it — but the statistics screen runs
before the progression is even loaded, so that write failed on an
attribute that does not exist yet.
--- FR ---
[ADD] migration : atteindre l'état depuis les statistiques aussi
L'écran de statistiques répond « qu'a-t-on supprimé, et pourquoi ». Celui
de l'état répond « où en est-on, et qu'est-ce qui a cassé ». Deux
questions voisines, posées au même moment : les séparer par une entrée de
menu plutôt que par deux commandes à retenir est ce qui les rend
utilisables.
La même lettre que dans les invites. Une lettre qui change de sens d'un
écran à l'autre ne s'apprend pas, elle se cherche — et personne ne la
cherche.
L'ajout a révélé deux façons dont cet écran ne survivait pas au mode
auto : la question d'interface, PREMIÈRE posée après l'activation, était
un input() nu, et le menu des statistiques aussi. Et l'écran d'état écrit
la progression avant de la lire, alors que les statistiques s'ouvrent
avant même qu'elle ne soit chargée.
Assisted-by: Claude Opus 5
2026-08-19 04:05:03 -04:00
|
|
|
|
class TestTheStatisticsScreenOffersIt(Base):
|
|
|
|
|
|
"""L'écran de statistiques répond « qu'a-t-on supprimé, et pourquoi ».
|
|
|
|
|
|
|
|
|
|
|
|
L'état, lui, répond « où en est-on, et qu'est-ce qui a cassé ». Deux
|
|
|
|
|
|
questions voisines, posées au même moment : les séparer par un menu
|
|
|
|
|
|
plutôt que par deux commandes à retenir est ce qui les rend
|
|
|
|
|
|
utilisables.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
def stats(self, lst_answer):
|
|
|
|
|
|
obj = TodoUpgrade.__new__(TodoUpgrade)
|
|
|
|
|
|
self.vu = []
|
|
|
|
|
|
obj.show_migration_status = lambda: self.vu.append("ouvert")
|
|
|
|
|
|
reponses = iter(lst_answer)
|
|
|
|
|
|
obj.ask = lambda prompt, default="": next(reponses)
|
|
|
|
|
|
obj.print_stats = staticmethod(lambda ctx, stats: None)
|
|
|
|
|
|
obj.read_progression = staticmethod(
|
|
|
|
|
|
lambda: {"config_database_name": "db"}
|
|
|
|
|
|
)
|
|
|
|
|
|
obj.resume_context = lambda dct: {"steps": [], "versions": []}
|
|
|
|
|
|
return obj
|
|
|
|
|
|
|
|
|
|
|
|
def test_t_opens_the_full_screen(self):
|
|
|
|
|
|
import script.todo.todo_upgrade as tu
|
|
|
|
|
|
|
|
|
|
|
|
obj = self.stats(["t", "0"])
|
|
|
|
|
|
original_exists = tu.os.path.exists
|
|
|
|
|
|
tu.os.path.exists = lambda path: True
|
|
|
|
|
|
self.addCleanup(setattr, tu.os.path, "exists", original_exists)
|
|
|
|
|
|
import script.todo.migration_stats as ms
|
|
|
|
|
|
|
|
|
|
|
|
original = ms.compute
|
|
|
|
|
|
ms.compute = lambda *a, **kw: {
|
|
|
|
|
|
"uninstall": {},
|
|
|
|
|
|
"journal": {"comments": [], "commands": []},
|
|
|
|
|
|
"origin_count": 0,
|
|
|
|
|
|
"evolution": [],
|
|
|
|
|
|
"removed_total": 0,
|
|
|
|
|
|
"missing": [],
|
|
|
|
|
|
"duplicate": [],
|
|
|
|
|
|
"fixes": [],
|
|
|
|
|
|
"cow": [],
|
|
|
|
|
|
"delay": "0s",
|
|
|
|
|
|
}
|
|
|
|
|
|
self.addCleanup(setattr, ms, "compute", original)
|
|
|
|
|
|
with redirect_stdout(io.StringIO()):
|
|
|
|
|
|
obj.show_stats()
|
|
|
|
|
|
self.assertEqual(self.vu, ["ouvert"])
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_menu_announces_it(self):
|
|
|
|
|
|
import inspect
|
|
|
|
|
|
|
|
|
|
|
|
source = inspect.getsource(TodoUpgrade.show_stats)
|
|
|
|
|
|
self.assertIn("Migration state (full screen)", source)
|
|
|
|
|
|
self.assertIn('answer == "t"', source)
|
|
|
|
|
|
|
|
|
|
|
|
def test_it_is_the_SAME_letter_as_the_prompts(self):
|
|
|
|
|
|
# Une lettre qui change de sens d'un écran à l'autre ne s'apprend
|
|
|
|
|
|
# pas. Les deux endroits doivent tester la même.
|
|
|
|
|
|
import inspect
|
|
|
|
|
|
|
|
|
|
|
|
self.assertIn(
|
|
|
|
|
|
'reponse == "t"', inspect.getsource(TodoUpgrade.ask_gate)
|
|
|
|
|
|
)
|
|
|
|
|
|
self.assertIn(
|
|
|
|
|
|
'answer == "t"', inspect.getsource(TodoUpgrade.show_stats)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def test_looking_returns_to_the_statistics(self):
|
|
|
|
|
|
# Comme dans les invites : regarder n'est pas répondre.
|
|
|
|
|
|
import inspect
|
|
|
|
|
|
|
|
|
|
|
|
source = inspect.getsource(TodoUpgrade.show_stats)
|
|
|
|
|
|
debut = source.index('answer == "t"')
|
|
|
|
|
|
self.assertIn("continue", source[debut : debut + 400])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestNothingOnThatScreenCanHang(Base):
|
|
|
|
|
|
"""L'écran est atteint APRÈS la question d'auto-exécution.
|
|
|
|
|
|
|
|
|
|
|
|
`ask_ui` est la toute première question posée ensuite, et elle était un
|
|
|
|
|
|
`input()` nu : une migration automatique s'arrêtait là, avant même
|
|
|
|
|
|
d'avoir commencé, sans que rien ne le signale.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_interface_question_goes_through_the_timer(self):
|
|
|
|
|
|
import inspect
|
|
|
|
|
|
|
|
|
|
|
|
source = inspect.getsource(TodoUpgrade.ask_ui)
|
|
|
|
|
|
self.assertIn("auto_ask.ask(", source)
|
|
|
|
|
|
# L'APPEL, pas le mot : le commentaire du correctif nomme lui-même
|
|
|
|
|
|
# ce qu'il remplace, et le chercher à l'aveugle se déclenche sur la
|
|
|
|
|
|
# prose. Deuxième fois que ce piège se referme sur moi.
|
|
|
|
|
|
self.assertNotIn("= input(", source)
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_statistics_menu_too(self):
|
|
|
|
|
|
import inspect
|
|
|
|
|
|
|
|
|
|
|
|
source = inspect.getsource(TodoUpgrade.show_stats)
|
|
|
|
|
|
self.assertIn("self.ask(", source)
|
|
|
|
|
|
self.assertNotIn("= input(", source)
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_default_interface_is_still_the_form(self):
|
|
|
|
|
|
# Le défaut ne doit pas changer en passant par le lecteur temporisé.
|
|
|
|
|
|
import script.todo.todo_prefs as prefs
|
|
|
|
|
|
|
|
|
|
|
|
original = prefs.get
|
|
|
|
|
|
prefs.get = lambda key: "ask"
|
|
|
|
|
|
self.addCleanup(setattr, prefs, "get", original)
|
|
|
|
|
|
from script.todo import auto_ask
|
|
|
|
|
|
|
|
|
|
|
|
avant = os.environ.pop(auto_ask.ENV_ENABLED, None)
|
|
|
|
|
|
if avant is not None:
|
|
|
|
|
|
self.addCleanup(
|
|
|
|
|
|
os.environ.__setitem__, auto_ask.ENV_ENABLED, avant
|
|
|
|
|
|
)
|
|
|
|
|
|
import builtins
|
|
|
|
|
|
|
|
|
|
|
|
original_input = builtins.input
|
|
|
|
|
|
builtins.input = lambda prompt="": ""
|
|
|
|
|
|
self.addCleanup(setattr, builtins, "input", original_input)
|
|
|
|
|
|
with redirect_stdout(io.StringIO()):
|
|
|
|
|
|
self.assertEqual(TodoUpgrade.ask_ui(), "tui")
|
|
|
|
|
|
|
|
|
|
|
|
|
[ADD] migration: « t » shows where the migration stands
A migration crosses six bumps, runs hundreds of commands and lasts hours.
The journal said what had been LAUNCHED; it never said what came of it.
Three hours in, one reads two hundred command lines without knowing which
one failed, nor what the smoke test concluded.
« v » could not carry this: it already means « view the differences » in
three prompts, and a letter meaning two things is worse than a letter
meaning nothing. « t » was free, and it is the same everywhere — one
shared string carries both shortcuts, so no prompt can drift.
Looking is not answering: the same question comes back afterwards.
What was missing was the data. Failures and tool verdicts are now
recorded with the step they happened in, bounded so a progression file
cannot grow without end. A tool rerun after a repair keeps its LAST
verdict and the count of its runs — showing both without distinction
would read a repair as a lasting failure.
--- FR ---
[ADD] migration : « t » montre où en est la migration
Une migration traverse six paliers, lance des centaines de commandes et
dure des heures. Le journal disait ce qui avait été LANCÉ, jamais ce que
cela avait donné. Trois heures plus tard on relit deux cents lignes sans
savoir laquelle a échoué, ni ce que le test de fumée a conclu.
« v » ne pouvait pas porter cela : il veut déjà dire « voir les
différences » dans trois invites, et une lettre qui signifie deux choses
est pire qu'une lettre qui ne signifie rien. « t » était libre, et il est
le même partout — une seule chaîne porte les deux raccourcis.
Regarder n'est pas répondre : la même question revient ensuite.
Ce qui manquait, c'étaient les données. Les échecs et les verdicts
d'outils sont désormais retenus avec l'étape où ils se sont produits.
Assisted-by: Claude Opus 5
2026-08-19 03:53:56 -04:00
|
|
|
|
class TestWhatGetsRecorded(Base):
|
|
|
|
|
|
def upgrade(self):
|
|
|
|
|
|
obj = TodoUpgrade.__new__(TodoUpgrade)
|
|
|
|
|
|
obj.dct_progression = {}
|
|
|
|
|
|
obj.lst_command_executed = []
|
|
|
|
|
|
obj.write_config = lambda: None
|
|
|
|
|
|
obj.current_step = "4.2.I - Migrate database"
|
|
|
|
|
|
return obj
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_tool_run_keeps_its_verdict(self):
|
|
|
|
|
|
obj = self.upgrade()
|
|
|
|
|
|
obj.run_on_terminal = lambda cmd: 1
|
|
|
|
|
|
self.assertEqual(obj.run_tool("smoke_public_url", "cmd"), 1)
|
|
|
|
|
|
event = obj.dct_progression["lst_event"][0]
|
|
|
|
|
|
self.assertEqual(event["kind"], "test")
|
|
|
|
|
|
self.assertEqual(event["status"], 1)
|
|
|
|
|
|
self.assertEqual(event["step"], "4.2.I - Migrate database")
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_list_is_bounded(self):
|
|
|
|
|
|
# Une migration lance des centaines de commandes ; un fichier qui
|
|
|
|
|
|
# enfle sans limite coûte plus cher à écrire qu'à lire.
|
|
|
|
|
|
obj = self.upgrade()
|
|
|
|
|
|
for index in range(TodoUpgrade.MAX_EVENT + 25):
|
|
|
|
|
|
obj.record_event("test", f"outil{index}", 0)
|
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
|
len(obj.dct_progression["lst_event"]), TodoUpgrade.MAX_EVENT
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_newest_survive_the_trimming(self):
|
|
|
|
|
|
obj = self.upgrade()
|
|
|
|
|
|
for index in range(TodoUpgrade.MAX_EVENT + 3):
|
|
|
|
|
|
obj.record_event("test", f"outil{index}", 0)
|
|
|
|
|
|
dernier = obj.dct_progression["lst_event"][-1]
|
|
|
|
|
|
self.assertEqual(dernier["name"], f"outil{TodoUpgrade.MAX_EVENT + 2}")
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_step_is_captured_when_it_is_printed(self):
|
|
|
|
|
|
import inspect
|
|
|
|
|
|
|
|
|
|
|
|
source = inspect.getsource(TodoUpgrade.print_step)
|
|
|
|
|
|
self.assertIn("self.current_step = msg", source)
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_failing_command_is_recorded_before_the_prompt(self):
|
|
|
|
|
|
# Si l'on répond ctrl+c, l'échec doit tout de même figurer dans
|
|
|
|
|
|
# l'état : c'est précisément celui qu'on cherchera en revenant.
|
|
|
|
|
|
import inspect
|
|
|
|
|
|
|
[ADD] migration: repair views whose stored type contradicts their parent
Odoo refused to load a database: it validated a <search> arch with tree
rules. The view is not a COW copy -- the COW tools were right to say
they had nothing to reset -- its stored `type` simply lies.
Nothing would ever have fixed it. In ir_ui_view.py, `type` is filled in
`create`, and only when absent; `write` never recomputes it. A wrong
value stays wrong, and `-u module` fails on the validation it causes
before it could rewrite anything.
Plain SQL, no ORM: the registry is what will not load, and a repair that
needed Odoo to fix what stops Odoo would be useless.
The rule is Odoo's own -- an inherited view takes its parent's type --
and it holds: zero disagreement on a pristine 18 install and on four
migrated databases, exactly one on the database that refused to load.
--- FR ---
Odoo refusait de charger une base : il validait un arch <search> avec
les règles d'un tree. La vue n'est pas une copie COW — les outils COW
avaient raison de dire qu'ils n'avaient rien à réinitialiser — c'est son
`type` stocké qui ment.
Rien ne l'aurait jamais réparé. Dans ir_ui_view.py, `type` est rempli
dans `create`, et seulement s'il est absent ; `write` ne le recalcule
jamais. Une valeur fausse le reste, et `-u module` échoue sur la
validation qu'elle provoque avant de pouvoir réécrire quoi que ce soit.
En SQL, sans ORM : c'est le registre qui ne charge plus, et une
réparation qui aurait besoin d'Odoo ne servirait à rien.
La règle est celle d'Odoo — une vue héritée prend le type de son parent
— et elle tient : zéro écart sur une 18 neuve et sur quatre bases
migrées, exactement un sur celle qui refusait de charger.
Assisted-by: Claude Opus 5
2026-08-22 00:46:42 -04:00
|
|
|
|
# Le menu d'erreur vit dans `_prompt_on_error`, extrait de
|
|
|
|
|
|
# `todo_upgrade_execute` quand celui-ci a passé le seuil de
|
|
|
|
|
|
# complexité. Lire les deux : c'est le CHEMIN d'erreur qu'on
|
|
|
|
|
|
# éprouve, pas une méthode en particulier.
|
|
|
|
|
|
source = inspect.getsource(
|
|
|
|
|
|
TodoUpgrade.todo_upgrade_execute
|
|
|
|
|
|
) + inspect.getsource(TodoUpgrade._prompt_on_error)
|
[ADD] migration: « t » shows where the migration stands
A migration crosses six bumps, runs hundreds of commands and lasts hours.
The journal said what had been LAUNCHED; it never said what came of it.
Three hours in, one reads two hundred command lines without knowing which
one failed, nor what the smoke test concluded.
« v » could not carry this: it already means « view the differences » in
three prompts, and a letter meaning two things is worse than a letter
meaning nothing. « t » was free, and it is the same everywhere — one
shared string carries both shortcuts, so no prompt can drift.
Looking is not answering: the same question comes back afterwards.
What was missing was the data. Failures and tool verdicts are now
recorded with the step they happened in, bounded so a progression file
cannot grow without end. A tool rerun after a repair keeps its LAST
verdict and the count of its runs — showing both without distinction
would read a repair as a lasting failure.
--- FR ---
[ADD] migration : « t » montre où en est la migration
Une migration traverse six paliers, lance des centaines de commandes et
dure des heures. Le journal disait ce qui avait été LANCÉ, jamais ce que
cela avait donné. Trois heures plus tard on relit deux cents lignes sans
savoir laquelle a échoué, ni ce que le test de fumée a conclu.
« v » ne pouvait pas porter cela : il veut déjà dire « voir les
différences » dans trois invites, et une lettre qui signifie deux choses
est pire qu'une lettre qui ne signifie rien. « t » était libre, et il est
le même partout — une seule chaîne porte les deux raccourcis.
Regarder n'est pas répondre : la même question revient ensuite.
Ce qui manquait, c'étaient les données. Les échecs et les verdicts
d'outils sont désormais retenus avec l'étape où ils se sont produits.
Assisted-by: Claude Opus 5
2026-08-19 03:53:56 -04:00
|
|
|
|
self.assertLess(
|
|
|
|
|
|
source.index('record_event("command"'),
|
|
|
|
|
|
source.index("Error detected"),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
|
unittest.main()
|