diff --git a/script/analyse/check_module_dependency.py b/script/analyse/check_module_dependency.py index 93f9ff1..8113cb1 100755 --- a/script/analyse/check_module_dependency.py +++ b/script/analyse/check_module_dependency.py @@ -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')}") diff --git a/script/analyse/check_module_dependency_tui.py b/script/analyse/check_module_dependency_tui.py index a5e7f81..37c0f8a 100644 --- a/script/analyse/check_module_dependency_tui.py +++ b/script/analyse/check_module_dependency_tui.py @@ -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") diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index e71c5d9..4fd87eb 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -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", diff --git a/test/test_check_module_dependency.py b/test/test_check_module_dependency.py index 596c797..826ca18 100644 --- a/test/test_check_module_dependency.py +++ b/test/test_check_module_dependency.py @@ -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 = {}