#!/usr/bin/env python3 # © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) """Le menu Execute : les numéros affichés mènent-ils où ils le disent ? Le menu est écrit deux fois — une f-string qui affiche « [7] … », et une chaîne d'`elif status == "7"` qui dispatche. Rien ne les reliait : insérer une entrée au milieu oblige à décaler les deux à la main, et une seule erreur envoie l'utilisateur dans le mauvais écran sans que rien ne proteste. Ce test relit les deux et les apparie. Il ne juge pas le contenu du menu : ajouter, retirer ou réordonner reste libre, tant que l'affichage et le dispatch racontent la même histoire. """ import ast import re import unittest from pathlib import Path TODO_PY = ( Path(__file__).resolve().parent.parent / "script" / "todo" / "todo.py" ) # « [12] {t("Deploy - …")} » en début de ligne, dans la f-string du menu. RE_SHOWN = re.compile(r'^\[(\d+)\] \{t\("([^"]+)"\)\}', re.M) # « elif status == "12": » suivi de « status = self.prompt_execute_deploy() » RE_DISPATCH = re.compile( r'elif status == "(\d+)":\s*\n\s*status = self\.(\w+)\(\)' ) def prompt_execute_source(): """Le corps de prompt_execute(), affichage et dispatch compris.""" source = TODO_PY.read_text(encoding="utf-8") start = source.index("def prompt_execute(self):") end = source.index("def prompt_install(self):", start) return source[start:end] class TestExecuteMenuNumbering(unittest.TestCase): def setUp(self): self.body = prompt_execute_source() # [0] Retour est traité par le « if status == "0" » qui précède la # chaîne d'elif : il s'affiche mais n'a pas de branche de dispatch. self.shown = [ (int(num), label) for num, label in RE_SHOWN.findall(self.body) if num != "0" ] self.dispatch = [ (int(num), method) for num, method in RE_DISPATCH.findall(self.body) ] def test_the_menu_was_actually_parsed(self): # Si la forme du menu change, ce test doit tomber ici plutôt que de # déclarer « tout va bien » sur une liste vide. self.assertGreater(len(self.shown), 5) self.assertEqual(len(self.shown), len(self.dispatch)) def test_zero_is_handled_before_the_elif_chain(self): self.assertIn('if status == "0":', self.body) def test_numbering_is_contiguous_from_one(self): numbers = [num for num, _ in self.shown] self.assertEqual(numbers, list(range(1, len(numbers) + 1))) def test_every_shown_entry_has_the_matching_dispatch(self): self.assertEqual( [num for num, _ in self.shown], [num for num, _ in self.dispatch], ) def test_no_dispatch_branch_is_unreachable(self): shown = {num for num, _ in self.shown} for num, method in self.dispatch: self.assertIn( num, shown, f"la branche [{num}] -> {method} n'est affichée nulle part", ) # Chaque entrée du menu et la méthode qu'elle DOIT atteindre, par le début # de son libellé. Sans cette table, le test ne vérifie que l'alignement des # numéros — et laisse passer le défaut même qu'une renumérotation produit : # une entrée qui garde son rang mais atterrit dans le mauvais écran. # # Une renumérotation, l'opération risquée, ne touche PAS cette table. Ajouter # ou retirer une entrée demande d'y toucher, et c'est voulu : c'est le seul # moment où quelqu'un doit dire où mène la nouvelle entrée. EXPECTED = { "Code": "prompt_execute_code", "Config": "prompt_execute_config", "Run": "prompt_execute_instance", "Test": "prompt_execute_test", "Process": "prompt_execute_process", "Database": "prompt_execute_database", "Analyse": "prompt_execute_analyse", "Git": "prompt_execute_git", "Doc": "prompt_execute_doc", "GPT code": "prompt_execute_gpt_code", "Automation": "prompt_execute_function", "Deploy": "prompt_execute_deploy", "Network": "prompt_execute_network", "Security": "prompt_execute_security", "Language": "_change_language", } def _entry_key(self, label): """« Doc - Documentation search » -> « Doc ».""" return label.split(" - ", 1)[0].strip() def test_every_entry_reaches_the_method_it_names(self): dct_dispatch = dict(self.dispatch) for num, label in self.shown: key = self._entry_key(label) self.assertIn( key, self.EXPECTED, f"entrée [{num}] « {label} » absente de EXPECTED :" " déclarez où elle mène", ) self.assertEqual( dct_dispatch.get(num), self.EXPECTED[key], f"[{num}] « {label} » mène à" f" {dct_dispatch.get(num)} au lieu de {self.EXPECTED[key]}", ) def test_expected_table_has_no_stale_entry(self): # Une entrée retirée du menu doit sortir d'EXPECTED, sinon la table # devient un cimetière qui ne protège plus rien. shown_keys = {self._entry_key(label) for _, label in self.shown} self.assertEqual(set(self.EXPECTED) - shown_keys, set()) class TestMenuLabels(unittest.TestCase): """Toute méthode de menu doit avoir son étiquette de fil d'Ariane. Sans elle, `_menu_header` n'affiche pas le segment et `todo_telemetry.build_code_tree` traite le menu comme une COMMANDE : il apparaît en feuille, sous son nom de méthode brut. Trois menus en souffrent déjà — la liste est figée ici pour que le nombre ne grandisse pas, pas pour bénir ce qu'elle contient. """ KNOWN_MISSING = { "prompt_execute_test", "prompt_execute_network", "prompt_execute_security", } def setUp(self): source = TODO_PY.read_text(encoding="utf-8") tree = ast.parse(source) cls = next(n for n in ast.walk(tree) if isinstance(n, ast.ClassDef)) self.labels = set() for node in cls.body: if not isinstance(node, ast.Assign): continue if not any( isinstance(tg, ast.Name) and tg.id == "_MENU_LABELS" for tg in node.targets ): continue self.labels = { key.value for key in node.value.keys if isinstance(key, ast.Constant) } self.dispatched = { method for _, method in RE_DISPATCH.findall(prompt_execute_source()) } def test_menu_labels_was_parsed(self): self.assertIn("prompt_execute", self.labels) def test_analyse_menu_has_a_breadcrumb_label(self): self.assertIn("prompt_execute_analyse", self.labels) def test_no_new_menu_forgets_its_label(self): submenus = { method for method in self.dispatched if method.startswith("prompt_execute_") } missing = submenus - self.labels - self.KNOWN_MISSING self.assertEqual( missing, set(), f"menus sans étiquette dans _MENU_LABELS : {sorted(missing)}", ) if __name__ == "__main__": unittest.main()