#!/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_DIR = Path(__file__).resolve().parent.parent / "script" / "todo" TODO_PY = TODO_DIR / "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 MenuCoherence: """Socle : un menu écrit en liste de dictionnaires est-il cohérent ? Ce piège-là ne dépend pas du menu : seules les entrées « prompt_description » consomment un numéro (les « section » sont des titres), et le dispatch les renumérote à la main. Insérer une entrée avant la dernière décale tout ce qui suit sans que rien ne proteste — c'est arrivé en ajoutant l'émulateur Android avant « List available images ». Depuis que les menus vivent dans leurs propres fichiers (le refactor de todo.py), ce socle sert DEUX menus : QEMU/KVM et Proxmox. Un troisième n'aura qu'à déclarer ses quatre attributs. À déclarer par la sous-classe : SOURCE (le fichier), ENTRY (la ligne « def prompt_execute_… »), END (le membre suivant, qui borne la lecture) et EXPECTED (où mène chaque entrée, par le début de son libellé). """ SOURCE = None ENTRY = "" END = "" EXPECTED = {} MINIMUM = 10 RE_ENTRY = re.compile( r'"(section|prompt_description)": t\(\s*\n?\s*"([^"]+)"' ) # Les lignes de COMMENTAIRE entre le « elif » et l'appel sont sautées : # une entrée expliquée devenait invisible pour ce test, qui annonçait alors # « 18 affichées, 17 dispatchées » sans qu'aucune entrée ne manque. Un test # ne doit pas dépendre de l'endroit où quelqu'un met un commentaire. RE_DISPATCH_CALL = re.compile( r'(?:el)?if status == "(\d+)":\s*\n(?:\s*#.*\n)*' r"\s*(?:status = )?self\.(\w+)\(" ) def setUp(self): source = self.SOURCE.read_text(encoding="utf-8") start = source.index(self.ENTRY) end = source.index(self.END, start) self.body = source[start:end] num = 0 self.shown = [] for kind, label in self.RE_ENTRY.findall(self.body): if kind == "prompt_description": num += 1 self.shown.append((num, label)) self.dispatch = [ (int(n), m) for n, m in self.RE_DISPATCH_CALL.findall(self.body) ] def test_the_menu_was_actually_parsed(self): """Sur une liste vide, tout test passe : mieux vaut tomber ici.""" self.assertGreater(len(self.shown), self.MINIMUM) self.assertEqual(len(self.shown), len(self.dispatch)) def test_numbering_is_contiguous_from_one(self): self.assertEqual( [n for n, _ in self.shown], list(range(1, len(self.shown) + 1)), ) def test_every_shown_entry_has_the_matching_dispatch(self): self.assertEqual( [n for n, _ in self.shown], [n for n, _ in self.dispatch] ) def _key(self, label): for key in self.EXPECTED: if label.startswith(key): return key return label def test_every_entry_reaches_the_method_it_names(self): dct = dict(self.dispatch) for num, label in self.shown: key = self._key(label) self.assertIn( key, self.EXPECTED, f"entrée [{num}] « {label} » absente d'EXPECTED :" " déclarez où elle mène", ) self.assertEqual( dct.get(num), self.EXPECTED[key], f"[{num}] « {label} » mène à {dct.get(num)}" f" au lieu de {self.EXPECTED[key]}", ) def test_expected_table_has_no_stale_entry(self): keys = {self._key(label) for _, label in self.shown} self.assertEqual(set(self.EXPECTED) - keys, set()) class TestLaParitéProxmox(unittest.TestCase): """Deux manques signalés par l'audit du découpage, comblés. Le menu Proxmox n'offrait pas de changer l'état d'une VM (QEMU/KVM l'a dans « Lister les VM »), et n'acceptait pas les commandes ajoutées par todo.json — deux capacités que son vis-à-vis avait. """ @classmethod def setUpClass(cls): cls.src = (TODO_DIR / "proxmox_menu.py").read_text(encoding="utf-8") def test_the_list_offers_to_change_the_state(self): self.assertIn("_pve_change_state", self.src) import sys sys.argv = ["todo.py"] from script.todo.todo import TODO as CLASSE self.assertTrue(callable(CLASSE._pve_change_state)) def test_a_clean_shutdown_comes_before_pulling_the_plug(self): # « shutdown » laisse Odoo fermer ses connexions PostgreSQL ; « stop » # coupe le courant. L'ordre des choix est la seule chose qui le dit. self.assertLess( self.src.index("shutdown (clean)"), self.src.index("stop (pulls the plug)"), ) def test_the_menu_reads_its_extra_commands_from_todo_json(self): self.assertIn('get_config("proxmox_from_makefile")', self.src) # Et le dispatch sait les lancer, sections non comptées. self.assertIn("execute_from_configuration", self.src) class TestLArbreDesMenus(unittest.TestCase): """L'écran de télémétrie lit le CODE, pas la classe assemblée. `build_code_tree()` parse un fichier et n'y prend que la première classe. Depuis le découpage, les menus QEMU/KVM et Proxmox vivent dans des mixins : leur colonne avait disparu de cet écran — les commandes s'exécutaient toujours, mais on ne pouvait plus les lancer de là ni les lire. C'est ce que « il manque plein d'informations qu'il y avait avant » désignait. """ @classmethod def setUpClass(cls): from script.todo.todo_telemetry import build_code_tree cls.arbre = build_code_tree() def _noeud(self, libelle, noeud=None): noeud = noeud if noeud is not None else self.arbre if noeud.get("label") == libelle: return noeud for enfant in noeud.get("children") or []: trouve = self._noeud(libelle, enfant) if trouve: return trouve return None def test_the_tree_is_built_at_all(self): self.assertIsNotNone(self.arbre) def test_the_mixin_files_come_from_the_imports(self): # Lus dans les imports de todo.py : un mixin ajouté demain apparaît # sans qu'on pense à l'inscrire ici. from script.todo.todo_telemetry import _mixin_files noms = {f.name for f in _mixin_files(TODO_DIR / "todo.py")} self.assertIn("qemu_menu.py", noms) self.assertIn("proxmox_menu.py", noms) def test_the_qemu_column_carries_its_commands(self): noeud = self._noeud("QEMU/KVM") self.assertIsNotNone(noeud, "colonne QEMU/KVM absente de l'arbre") self.assertGreaterEqual(len(noeud.get("children") or []), 15) def test_the_proxmox_column_too(self): noeud = self._noeud("Proxmox VE") self.assertIsNotNone(noeud, "colonne Proxmox VE absente de l'arbre") self.assertGreaterEqual(len(noeud.get("children") or []), 15) def test_the_breadcrumb_names_the_proxmox_menu(self): # Sans étiquette, le fil d'Ariane sautait le menu Proxmox : on lisait # « TODO › Execute › Deploy » en étant deux niveaux plus bas. import sys sys.argv = ["todo.py"] from script.todo.todo import TODO as CLASSE self.assertIn("prompt_execute_proxmox", CLASSE._MENU_LABELS) class TestQemuMenuNumbering(MenuCoherence, unittest.TestCase): """Le menu QEMU/KVM, désormais dans script/todo/qemu_menu.py.""" SOURCE = TODO_DIR / "qemu_menu.py" ENTRY = "def prompt_execute_qemu(self):" END = "def _qemu_stats(self):" # Où mène chaque entrée, par le début de son libellé. Une renumérotation ne # touche PAS cette table ; ajouter une entrée l'exige, et c'est le seul # moment où quelqu'un doit dire où elle mène. EXPECTED = { "Deploy VM(s)": "_qemu_deploy", "Preview a deployment": "_qemu_deploy", "Download a cloud image only": "_qemu_download_image", "Reopen": "_qemu_reopen_monitor", "List VMs": "_qemu_list_vms", "Show a VM IP address": "_qemu_show_ip", "Open the console on a VM": "_qemu_console", "Resize a VM disk": "_qemu_resize_disk", "Delete VM(s)": "_qemu_delete_vm", "Clean up QEMU": "_qemu_cleanup", "Test": "_qemu_test_vm", "Statistics": "_qemu_stats", "SSH configuration": "_qemu_ssh_config_menu", "Remote desktop tunnel": "_qemu_tunnel_menu", "Android emulator": "_qemu_emulator_menu", "List available images": "_qemu_list_images", } class TestProxmoxMenuNumbering(MenuCoherence, unittest.TestCase): """Le menu Proxmox : dix-huit entrées, le même piège. Quatre d'entre elles mènent VOLONTAIREMENT à des méthodes du menu QEMU — c'est le même travail, et le refactor n'a pas dupliqué ce code. La table le dit noir sur blanc : si quelqu'un les recopiait un jour, ce test montrerait que la cible a changé. """ SOURCE = TODO_DIR / "proxmox_menu.py" ENTRY = "def prompt_execute_proxmox(self):" END = "def _pve_fetch_image(self):" MINIMUM = 15 EXPECTED = { "Deploy a VM on the Proxmox host": "_pve_deploy", "Preview a deployment": "_pve_deploy", "Download a cloud image on the host": "_pve_fetch_image", "Reopen": "_qemu_reopen_monitor", "List VMs (qm list)": "_pve_list", "Show a VM IP address": "_pve_vm_ip", "Open the console on a VM": "_pve_console", "Resize a VM disk": "_pve_resize", "Delete VM(s)": "_pve_delete", "Clean up (orphan disks)": "_pve_cleanup", "Test a VM": "_pve_test_vm", "Statistics (host and VMs)": "_pve_stats", "SSH configuration": "_pve_ssh_config", "Remote desktop tunnel": "_qemu_tunnel_menu", "Android emulator": "_qemu_emulator_menu", "List available images": "_qemu_list_images", "Proxmox - example sequence": "_pve_example", "Change the Proxmox host": "_pve_forget_host", } 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()