[ADD] analyse: i, u et t — installés, en cours, applications
Trois filtres sous la main plutôt qu'un cycle à parcourir. « u » est le seul qui montrait quelque chose d'invisible jusqu'ici : les états de passage — to install, to upgrade, to remove — qu'Odoo traverse et ne devrait pas garder. Mesuré sur test_neutralize_upgrade_13 en pleine migration : 22 modules figés en « to remove ». L'en-tête les annonce, le rapport texte les nomme avec leur état, et « non installés » ne les ramasse plus : un module en « to remove » EST encore installé, il s'en va. La même touche fait l'aller et le retour. --- EN --- Three filters under the hand rather than a cycle to walk. « u » is the only one showing something invisible until now: the transient states — to install, to upgrade, to remove — that Odoo passes through and should not keep. Measured on test_neutralize_upgrade_13 mid-migration: 22 modules frozen in « to remove ». The header announces them, the text report names them with their state, and « not installed » no longer collects them: a module in « to remove » IS still installed, it is on its way out. The same key goes and returns. Assisted-by: Claude Opus 5
This commit is contained in:
parent
3c10ca2eb2
commit
bcd7f9ae88
4 changed files with 291 additions and 6 deletions
|
|
@ -45,12 +45,26 @@ except Exception: # pragma: no cover - repli si i18n indisponible
|
|||
# résultat faux et visible plutôt qu'une boucle sans fin.
|
||||
PROFONDEUR_MAX = 64
|
||||
|
||||
FILTRES = ("all", "installed", "absent", "broken")
|
||||
FILTRES = (
|
||||
"all",
|
||||
"installed",
|
||||
"pending",
|
||||
"application",
|
||||
"absent",
|
||||
"broken",
|
||||
)
|
||||
|
||||
# Ce qui compte comme « en place ». « to upgrade » l'est : le module est
|
||||
# chargé, il sera seulement rejoué. « to install » ne l'est pas encore.
|
||||
ETATS_PRESENTS = ("installed", "to upgrade")
|
||||
|
||||
# Les états qu'Odoo traverse et ne devrait pas garder. Un module qui y
|
||||
# reste est une opération inachevée : mesuré sur test_neutralize_upgrade_13
|
||||
# en pleine migration, 22 modules figés en « to remove ». Ils ne sont plus
|
||||
# tout à fait installés et pas encore partis, donc aucun autre filtre ne
|
||||
# les montre pour ce qu'ils sont.
|
||||
ETATS_EN_COURS = ("to install", "to upgrade", "to remove")
|
||||
|
||||
|
||||
def reverse(depend):
|
||||
"""{module: [ce qui dépend de lui]} à partir de {module: [dépendances]}.
|
||||
|
|
@ -89,6 +103,11 @@ def present(etat):
|
|||
return etat in ETATS_PRESENTS
|
||||
|
||||
|
||||
def pending(etat):
|
||||
"""L'état est-il un passage, plutôt qu'une destination ?"""
|
||||
return etat in ETATS_EN_COURS
|
||||
|
||||
|
||||
def broken(recensement, depend):
|
||||
"""[(module, dépendance, raison)] — ce qui est installé sur du vide.
|
||||
|
||||
|
|
@ -176,7 +195,17 @@ def rows(rapport, filtre="all"):
|
|||
etat = rapport["modules"][nom][0]
|
||||
if filtre == "installed" and not present(etat):
|
||||
continue
|
||||
if filtre == "absent" and present(etat):
|
||||
# « non installés » exclut aussi les états de passage : un module
|
||||
# en « to remove » EST encore installé, il s'en va. Le ranger avec
|
||||
# ce qu'on pourrait installer était trompeur ; il a sa touche.
|
||||
if filtre == "absent" and (present(etat) or pending(etat)):
|
||||
continue
|
||||
if filtre == "pending" and not pending(etat):
|
||||
continue
|
||||
# `application` est le drapeau « App » d'Odoo : ces modules-là
|
||||
# sont les racines, ceux qu'on installe pour de vrai — les autres
|
||||
# arrivent derrière eux.
|
||||
if filtre == "application" and not rapport["modules"][nom][2]:
|
||||
continue
|
||||
if filtre == "broken" and nom not in cassants:
|
||||
continue
|
||||
|
|
@ -296,11 +325,19 @@ def head_text(rapport):
|
|||
)
|
||||
total = len(rapport["modules"])
|
||||
casses = len(rapport.get("broken", []))
|
||||
en_cours = sum(
|
||||
1 for infos in rapport["modules"].values() if pending(infos[0])
|
||||
)
|
||||
texte = (
|
||||
f"📦 {rapport['database']}"
|
||||
f" ({t('Odoo')} {rapport.get('version') or '?'})"
|
||||
f" · {installes}/{total} {t('modules installed')}"
|
||||
)
|
||||
if en_cours:
|
||||
# Annoncé dans l'en-tête et non seulement derrière une touche :
|
||||
# une opération inachevée explique des symptômes qu'on chercherait
|
||||
# ailleurs pendant une heure.
|
||||
texte += f" · ⏳ {en_cours} {t('in progress')}"
|
||||
if casses:
|
||||
texte += f" · ❌ {casses} {t('broken dependency(ies)')}"
|
||||
return texte
|
||||
|
|
@ -326,6 +363,18 @@ def render_text(rapport, limit=0, cap=0):
|
|||
lignes.append(f" → {', '.join(amont[: limit or None])}")
|
||||
if cap and len(lst_row) > cap:
|
||||
lignes.append(f" … {len(lst_row) - cap} {t('more')}")
|
||||
lst_en_cours = [
|
||||
nom
|
||||
for nom, infos in sorted(rapport["modules"].items())
|
||||
if pending(infos[0])
|
||||
]
|
||||
if lst_en_cours:
|
||||
lignes.append("")
|
||||
lignes.append(f"⏳ {t('left in a transient state')}")
|
||||
for nom in lst_en_cours[: limit or None]:
|
||||
lignes.append(f" {nom} ({rapport['modules'][nom][0]})")
|
||||
if limit and len(lst_en_cours) > limit:
|
||||
lignes.append(f" … {len(lst_en_cours) - limit} {t('more')}")
|
||||
if rapport.get("broken"):
|
||||
lignes.append("")
|
||||
lignes.append(f"❌ {t('installed on missing dependencies')}")
|
||||
|
|
|
|||
|
|
@ -43,6 +43,8 @@ def filter_label(filtre):
|
|||
return {
|
||||
"all": t("all modules"),
|
||||
"installed": t("installed only"),
|
||||
"pending": t("in progress"),
|
||||
"application": t("apps only"),
|
||||
"absent": t("not installed"),
|
||||
"broken": t("broken dependencies"),
|
||||
}.get(filtre, filtre)
|
||||
|
|
@ -80,6 +82,16 @@ def next_mode(mode):
|
|||
return suite[(suite.index(courant) + 1) % len(suite)]
|
||||
|
||||
|
||||
def toggle_filter(courant, cible):
|
||||
"""Aller à `cible` — ou en revenir si l'on y est déjà.
|
||||
|
||||
Une touche qui ne fait qu'ALLER piège : arrivé sur « en cours », il
|
||||
faudrait se souvenir de quelle autre touche ramène à la liste
|
||||
entière. La même touche fait donc l'aller et le retour.
|
||||
"""
|
||||
return "all" if courant == cible else cible
|
||||
|
||||
|
||||
def next_filter(filtre):
|
||||
"""Le filtre suivant, en boucle."""
|
||||
suite = dependency.FILTRES
|
||||
|
|
@ -161,6 +173,9 @@ def build_app(rapport):
|
|||
("escape", "leave", t("Quit")),
|
||||
("d", "cycle_detail", t("Dependencies")),
|
||||
("f", "cycle_filter", t("Filter")),
|
||||
("i", "set_filter('installed')", t("Installed")),
|
||||
("u", "set_filter('pending')", t("In progress")),
|
||||
("t", "set_filter('application')", t("Apps")),
|
||||
("slash", "find", t("Search")),
|
||||
]
|
||||
|
||||
|
|
@ -237,6 +252,11 @@ def build_app(rapport):
|
|||
self.filtre = next_filter(self.filtre)
|
||||
self._fill()
|
||||
|
||||
def action_set_filter(self, cible):
|
||||
"""Sauter droit à un filtre. `f` reste là pour les parcourir."""
|
||||
self.filtre = toggle_filter(self.filtre, cible)
|
||||
self._fill()
|
||||
|
||||
def action_find(self):
|
||||
champ = self.query_one("#find", Input)
|
||||
champ.add_class("visible")
|
||||
|
|
|
|||
|
|
@ -6346,6 +6346,30 @@ TRANSLATIONS = {
|
|||
"fr": "dépendances cassées",
|
||||
"en": "broken dependencies",
|
||||
},
|
||||
"in progress": {
|
||||
"fr": "en cours",
|
||||
"en": "in progress",
|
||||
},
|
||||
"apps only": {
|
||||
"fr": "applications seulement",
|
||||
"en": "apps only",
|
||||
},
|
||||
"left in a transient state": {
|
||||
"fr": "laissés dans un état de passage",
|
||||
"en": "left in a transient state",
|
||||
},
|
||||
"Installed": {
|
||||
"fr": "Installés",
|
||||
"en": "Installed",
|
||||
},
|
||||
"In progress": {
|
||||
"fr": "En cours",
|
||||
"en": "In progress",
|
||||
},
|
||||
"Apps": {
|
||||
"fr": "Applications",
|
||||
"en": "Apps",
|
||||
},
|
||||
"Dependencies": {
|
||||
"fr": "Dépendances",
|
||||
"en": "Dependencies",
|
||||
|
|
|
|||
|
|
@ -22,10 +22,10 @@ from script.analyse import check_module_dependency as dep # noqa: E402
|
|||
from script.analyse import check_module_dependency_tui as tui # noqa: E402
|
||||
|
||||
|
||||
def base(modules, depend=None):
|
||||
def base(modules, depend=None, apps=()):
|
||||
"""Un rapport minimal. `modules` : {nom: état}."""
|
||||
recensement = {
|
||||
nom: (etat, f"Résumé {nom}", False, "TechnoLibre")
|
||||
nom: (etat, f"Résumé {nom}", nom in apps, "TechnoLibre")
|
||||
for nom, etat in modules.items()
|
||||
}
|
||||
depend = (
|
||||
|
|
@ -202,6 +202,135 @@ class TestRows(unittest.TestCase):
|
|||
dep.rows(self.rapport(), filtre)
|
||||
|
||||
|
||||
class TestTheTransientStates(unittest.TestCase):
|
||||
"""Un module qui reste en chemin est une opération inachevée.
|
||||
|
||||
Mesuré sur test_neutralize_upgrade_13, en pleine migration :
|
||||
22 modules figés en « to remove ». Aucun autre filtre ne les montrait
|
||||
pour ce qu'ils sont.
|
||||
"""
|
||||
|
||||
def test_the_three_transient_states_are_pending(self):
|
||||
for etat in ("to install", "to upgrade", "to remove"):
|
||||
self.assertTrue(dep.pending(etat), etat)
|
||||
|
||||
def test_a_settled_state_is_not(self):
|
||||
for etat in ("installed", "uninstalled", "uninstallable"):
|
||||
self.assertFalse(dep.pending(etat), etat)
|
||||
|
||||
def rapport(self):
|
||||
return base(
|
||||
{
|
||||
"zebre": "installed",
|
||||
"alpha": "to remove",
|
||||
"milieu": "to install",
|
||||
"bravo": "uninstalled",
|
||||
"charlie": "to upgrade",
|
||||
}
|
||||
)
|
||||
|
||||
def test_the_pending_filter_shows_exactly_those(self):
|
||||
self.assertEqual(
|
||||
[r["name"] for r in dep.rows(self.rapport(), "pending")],
|
||||
["alpha", "charlie", "milieu"],
|
||||
)
|
||||
|
||||
def test_a_module_on_its_way_out_is_not_listed_as_absent(self):
|
||||
# « to remove » EST encore installé ; le ranger avec ce qu'on
|
||||
# pourrait installer envoyait chercher au mauvais endroit.
|
||||
self.assertEqual(
|
||||
[r["name"] for r in dep.rows(self.rapport(), "absent")], ["bravo"]
|
||||
)
|
||||
|
||||
def test_to_upgrade_is_both_installed_and_pending(self):
|
||||
# Il est chargé ET il attend : les deux sont vrais, et les taire
|
||||
# ferait disparaître un module d'une des deux vues.
|
||||
noms = [r["name"] for r in dep.rows(self.rapport(), "installed")]
|
||||
self.assertIn("charlie", noms)
|
||||
self.assertIn(
|
||||
"charlie", [r["name"] for r in dep.rows(self.rapport(), "pending")]
|
||||
)
|
||||
|
||||
def test_the_header_announces_them(self):
|
||||
self.assertIn("3 en cours", dep.head_text(self.rapport()))
|
||||
|
||||
def test_the_header_stays_quiet_when_there_are_none(self):
|
||||
self.assertNotIn("en cours", dep.head_text(base({"a": "installed"})))
|
||||
|
||||
def test_the_text_report_lists_them_with_their_state(self):
|
||||
texte = "\n".join(dep.render_text(self.rapport()))
|
||||
self.assertIn("alpha (to remove)", texte)
|
||||
self.assertIn("milieu (to install)", texte)
|
||||
|
||||
def bloc_en_cours(self, lignes):
|
||||
"""Les lignes qui suivent le titre « état de passage »."""
|
||||
debut = next(i for i, x in enumerate(lignes) if "état de passage" in x)
|
||||
suite = []
|
||||
for ligne in lignes[debut + 1 :]:
|
||||
if not ligne.startswith(" "):
|
||||
break
|
||||
suite.append(ligne)
|
||||
return suite
|
||||
|
||||
def test_the_text_report_bounds_that_list_too(self):
|
||||
# Trois en cours, borne à deux : deux nommés, et le troisième
|
||||
# ANNONCÉ. Compter les lignes, pas chercher un chiffre au hasard.
|
||||
suite = self.bloc_en_cours(dep.render_text(self.rapport(), limit=2))
|
||||
nommes = [x for x in suite if x.strip().endswith(")")]
|
||||
self.assertEqual(len(nommes), 2)
|
||||
self.assertEqual(suite[-1].strip(), "… 1 de plus")
|
||||
|
||||
def test_without_a_limit_they_are_all_named(self):
|
||||
suite = self.bloc_en_cours(dep.render_text(self.rapport()))
|
||||
self.assertEqual(len(suite), 3)
|
||||
self.assertFalse(any("de plus" in x for x in suite))
|
||||
|
||||
|
||||
class TestTheApplicationFilter(unittest.TestCase):
|
||||
def rapport(self):
|
||||
return base(
|
||||
{
|
||||
"zebre": "installed",
|
||||
"alpha": "installed",
|
||||
"milieu": "uninstalled",
|
||||
},
|
||||
apps=("zebre", "milieu"),
|
||||
)
|
||||
|
||||
def test_it_keeps_the_apps_whatever_their_state(self):
|
||||
# Un module applicatif non installé reste applicatif : filtrer
|
||||
# sur « App » et sur « installé » sont deux questions.
|
||||
self.assertEqual(
|
||||
[r["name"] for r in dep.rows(self.rapport(), "application")],
|
||||
["milieu", "zebre"],
|
||||
)
|
||||
|
||||
def test_a_plain_module_is_left_out(self):
|
||||
self.assertNotIn(
|
||||
"alpha",
|
||||
[r["name"] for r in dep.rows(self.rapport(), "application")],
|
||||
)
|
||||
|
||||
def test_the_pane_says_whether_it_is_one(self):
|
||||
self.assertIn("oui", dep.pane_text(self.rapport(), "zebre"))
|
||||
self.assertIn("non", dep.pane_text(self.rapport(), "alpha"))
|
||||
|
||||
|
||||
class TestToggleFilter(unittest.TestCase):
|
||||
def test_the_same_key_goes_and_comes_back(self):
|
||||
# Sans le retour, il faudrait se souvenir de quelle AUTRE touche
|
||||
# ramène à la liste entière.
|
||||
self.assertEqual(tui.toggle_filter("all", "pending"), "pending")
|
||||
self.assertEqual(tui.toggle_filter("pending", "pending"), "all")
|
||||
|
||||
def test_another_key_replaces_rather_than_returns(self):
|
||||
self.assertEqual(tui.toggle_filter("installed", "pending"), "pending")
|
||||
|
||||
def test_every_direct_filter_is_a_known_one(self):
|
||||
for cible in ("installed", "pending", "application"):
|
||||
self.assertIn(cible, dep.FILTRES)
|
||||
|
||||
|
||||
class TestListing(unittest.TestCase):
|
||||
def rapport(self):
|
||||
return base(
|
||||
|
|
@ -524,10 +653,23 @@ class TestTheScreenActuallyDrives(unittest.TestCase):
|
|||
},
|
||||
)
|
||||
|
||||
def conduire(self, scenario):
|
||||
def rapport_etats(self):
|
||||
"""Un jeu qui porte les trois catégories des touches i / u / t."""
|
||||
return base(
|
||||
{
|
||||
"sale": "installed",
|
||||
"mail": "installed",
|
||||
"muk_theme": "to remove",
|
||||
"stock": "to install",
|
||||
"vieux": "uninstalled",
|
||||
},
|
||||
apps=("sale", "stock"),
|
||||
)
|
||||
|
||||
def conduire(self, scenario, rapport=None):
|
||||
import asyncio
|
||||
|
||||
app = tui.build_app(self.rapport())
|
||||
app = tui.build_app(rapport or self.rapport())
|
||||
|
||||
async def piloter():
|
||||
async with app.run_test() as pilote:
|
||||
|
|
@ -594,6 +736,56 @@ class TestTheScreenActuallyDrives(unittest.TestCase):
|
|||
# ne peut plus rien faire après avoir cherché.
|
||||
self.assertEqual(vus["mode"], dep.DETAILS[0])
|
||||
|
||||
def test_i_u_and_t_each_jump_straight_to_their_filter(self):
|
||||
vus = {}
|
||||
|
||||
async def scenario(app, pilote):
|
||||
for touche in ("i", "u", "t"):
|
||||
await pilote.press(touche)
|
||||
vus[touche] = (
|
||||
app.filtre,
|
||||
sorted(row["name"] for row in app.lst_row),
|
||||
)
|
||||
await pilote.press(touche)
|
||||
vus[touche + "_retour"] = app.filtre
|
||||
|
||||
self.conduire(scenario, self.rapport_etats())
|
||||
self.assertEqual(vus["i"], ("installed", ["mail", "sale"]))
|
||||
self.assertEqual(vus["u"], ("pending", ["muk_theme", "stock"]))
|
||||
self.assertEqual(vus["t"], ("application", ["sale", "stock"]))
|
||||
for touche in ("i", "u", "t"):
|
||||
self.assertEqual(vus[touche + "_retour"], "all")
|
||||
|
||||
def test_a_second_key_replaces_the_filter_rather_than_stacking(self):
|
||||
vus = {}
|
||||
|
||||
async def scenario(app, pilote):
|
||||
await pilote.press("i")
|
||||
await pilote.press("u")
|
||||
vus["filtre"] = app.filtre
|
||||
vus["lignes"] = app.query_one("#left").row_count
|
||||
|
||||
self.conduire(scenario, self.rapport_etats())
|
||||
self.assertEqual(vus["filtre"], "pending")
|
||||
self.assertEqual(vus["lignes"], 2)
|
||||
|
||||
def test_those_letters_are_typed_not_obeyed_while_searching(self):
|
||||
# « i », « u » et « t » sont des lettres avant d'être des
|
||||
# raccourcis : dans le champ de recherche, elles s'écrivent.
|
||||
vus = {}
|
||||
|
||||
async def scenario(app, pilote):
|
||||
await pilote.press("slash")
|
||||
for lettre in "it":
|
||||
await pilote.press(lettre)
|
||||
await pilote.pause()
|
||||
vus["motif"] = app.motif
|
||||
vus["filtre"] = app.filtre
|
||||
|
||||
self.conduire(scenario, self.rapport_etats())
|
||||
self.assertEqual(vus["motif"], "it")
|
||||
self.assertEqual(vus["filtre"], "all")
|
||||
|
||||
def test_escape_closes_the_search_before_it_closes_the_screen(self):
|
||||
vus = {}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue