Ten of the eighteen entries carried no pictogram, and the eye picks one out of such a list before it reads. Each takes the icon its siblings already use elsewhere in the tool — 📋 lists, 🧹 cleans, 📊 measures, 🔍 previews, 📥 downloads, 🗂 catalogues, 🖥 tunnels a desktop — so the same picture keeps meaning the same thing from one menu to the next. A table in the tests holds the pairing, in both languages, and checks the menu still shows the entries it freezes. --- FR --- Dix des dix-huit entrées ne portaient aucun pictogramme, et l'œil s'y repère avant de lire. Chacune reçoit l'icône que ses semblables portent déjà ailleurs dans l'outil — 📋 liste, 🧹 nettoie, 📊 mesure, 🔍 prévisualise, 📥 télécharge, 🗂 catalogue, 🖥 ouvre un bureau —, si bien qu'une même image garde le même sens d'un menu à l'autre. Un tableau dans les tests fige le couple, dans les deux langues, et vérifie que le menu affiche encore les entrées qu'il fige. Assisted-by: Claude Opus 5
682 lines
27 KiB
Python
682 lines
27 KiB
Python
#!/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",
|
||
"Transform data": "prompt_execute_transform",
|
||
"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.
|
||
|
||
Une entrée peut aussi porter sa destination dans « method » plutôt que
|
||
dans un « elif status » numéroté. Elle échappe alors à la renumérotation
|
||
par construction, et EXPECTED la vérifie contre cette clé.
|
||
|
||
À 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+)\("
|
||
)
|
||
# Une entrée qui porte sa destination dans « method » se dispatche seule,
|
||
# par le repli générique. Elle n'a pas de numéro dans le code, donc aucune
|
||
# renumérotation ne peut la désaligner : c'est le seul moyen de placer une
|
||
# entrée codée en dur APRÈS des entrées venues de la configuration, dont
|
||
# le nombre n'est pas connu à la lecture du source.
|
||
RE_SELF_DISPATCH = re.compile(
|
||
r'"prompt_description": t\(\s*\n?\s*"([^"]+)"\s*\)?,?\s*\n'
|
||
r'\s*"method": "(\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]
|
||
self.self_dispatch = dict(self.RE_SELF_DISPATCH.findall(self.body))
|
||
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.numbered = [
|
||
(n, label)
|
||
for n, label in self.shown
|
||
if label not in self.self_dispatch
|
||
]
|
||
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.numbered), 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.numbered], [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",
|
||
)
|
||
atteint = self.self_dispatch.get(label, dct.get(num))
|
||
self.assertEqual(
|
||
atteint,
|
||
self.EXPECTED[key],
|
||
f"[{num}] « {label} » mène à {atteint}"
|
||
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())
|
||
|
||
def test_self_dispatched_entries_name_a_real_method(self):
|
||
"""« method » est une chaîne : rien ne la relie au code sans ceci."""
|
||
from script.todo.todo import TODO
|
||
|
||
for label, method in self.self_dispatch.items():
|
||
self.assertTrue(
|
||
hasattr(TODO, method),
|
||
f"« {label} » mène à {method}, qui n'existe pas",
|
||
)
|
||
|
||
|
||
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 TestLesIconesDuMenuProxmox(unittest.TestCase):
|
||
"""L'icône vit DANS la chaîne traduite, et les deux langues la portent.
|
||
|
||
Une entrée sans icône se perd dans une liste de dix-huit : l'œil s'y
|
||
repère par le pictogramme avant de lire. Le même pictogramme dit la même
|
||
chose partout dans l'outil — 📋 liste, 🧹 nettoie, 📊 mesure — et ce
|
||
tableau est ce qui l'empêche de dériver d'un menu à l'autre.
|
||
"""
|
||
|
||
ICONES = {
|
||
"Deploy a VM on the Proxmox host": "🚀",
|
||
"Preview a deployment (dry-run, nothing sent)": "🔍",
|
||
"Download a cloud image on the host": "📥",
|
||
"Reopen install monitoring (last run / history)": "📈",
|
||
"List VMs (qm list)": "📋",
|
||
"Show a VM IP address": "🌐",
|
||
"Open the console on a VM": "🖥",
|
||
"Resize a VM disk": "📐",
|
||
"Delete VM(s)": "🗑",
|
||
"Clean up (orphan disks)": "🧹",
|
||
"Test a VM (open Odoo in a CLI browser)": "🧪",
|
||
"Statistics (host and VMs)": "📊",
|
||
"SSH configuration (~/.ssh/config, ProxyJump)": "🔑",
|
||
"Remote desktop tunnel (VNC/RDP over SSH)": "🖥",
|
||
"Android emulator (start, tunnel, scrcpy)": "📱",
|
||
"List available images and their specs": "🗂",
|
||
"Proxmox - example sequence (dry-run)": "🎬",
|
||
"Change the Proxmox host": "🔀",
|
||
"Host": "🏠",
|
||
}
|
||
|
||
def test_chaque_entree_porte_son_icone(self):
|
||
from script.todo import todo_i18n
|
||
|
||
for cle, icone in self.ICONES.items():
|
||
entree = todo_i18n.TRANSLATIONS[cle]
|
||
for langue in ("fr", "en"):
|
||
self.assertTrue(
|
||
entree[langue].startswith(icone),
|
||
f"« {cle} » ({langue}) ne commence pas par {icone} :"
|
||
f" {entree[langue]}",
|
||
)
|
||
|
||
def test_le_menu_affiche_bien_ces_entrees(self):
|
||
"""Le tableau ci-dessus ne vaut que s'il décrit le menu RÉEL : une
|
||
entrée renommée le laisserait figer une icône que personne ne voit."""
|
||
src = (TODO_DIR / "proxmox_menu.py").read_text(encoding="utf-8")
|
||
for cle in self.ICONES:
|
||
# La chaîne SEULE : une entrée longue s'écrit « t( » sur une
|
||
# ligne et sa chaîne sur la suivante, et chercher l'appel entier
|
||
# ne trouverait que les courtes.
|
||
self.assertIn(
|
||
f'"{cle}"', src, f"« {cle} » n'est plus dans le menu"
|
||
)
|
||
|
||
|
||
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",
|
||
"Recover files from a VM disk (libguestfs)": "_qemu_recover_files",
|
||
"Diagnostics (report to share)": "_qemu_diagnostics",
|
||
"Show the libvirt network state": "_qemu_network_status",
|
||
"Recreate the VM subnet": "_qemu_network_recreate",
|
||
}
|
||
|
||
|
||
class TestAnalyseMenuNumbering(MenuCoherence, unittest.TestCase):
|
||
"""Le menu Analyse, qui n'avait aucun garde.
|
||
|
||
Il en a pourtant besoin plus que les autres : ses entrées sont
|
||
regroupées en cinq sections, et une section ne consomme pas de numéro.
|
||
Ajouter « Instance » avant la dernière entrée décalait tout ce qui
|
||
suivait sans que rien ne proteste.
|
||
"""
|
||
|
||
SOURCE = TODO_DIR / "todo.py"
|
||
ENTRY = "def prompt_execute_analyse(self):"
|
||
END = "def execute_analyse_module_package(self):"
|
||
MINIMUM = 5
|
||
|
||
EXPECTED = {
|
||
"Tables and database size": "execute_analyse_schema_size",
|
||
"Customised views": "execute_analyse_view_custom",
|
||
"Studio and hand-made": "execute_analyse_custom_field",
|
||
"Quality of a migration": "execute_analyse_migration_quality",
|
||
"Modules missing": "execute_analyse_module_package",
|
||
"Dependencies between": "execute_analyse_module_dependency",
|
||
"Attachment files missing": "execute_analyse_filestore",
|
||
"Monitoring - a backup": "execute_analyse_monitoring",
|
||
}
|
||
|
||
|
||
class TestDatabaseMenuNumbering(MenuCoherence, unittest.TestCase):
|
||
"""Le menu Database, qui manie des bases entières.
|
||
|
||
Il n'avait aucun garde, et c'est celui où une renumérotation coûte le
|
||
plus cher : sa dernière entrée EFFACE une base. Insérer « Dupliquer »
|
||
avant elle décale « Effacer » de [4] à [5] — si le dispatch ne suit
|
||
pas, taper [4] efface au lieu de copier.
|
||
"""
|
||
|
||
SOURCE = TODO_DIR / "todo.py"
|
||
ENTRY = "def prompt_execute_database(self):"
|
||
END = "def prompt_execute_analyse(self):"
|
||
MINIMUM = 4
|
||
|
||
# Ce menu délègue à `self.db_manager.methode()`, pas à `self.methode()`.
|
||
# Le motif du socle ne voit que la forme courte : sans cette surcharge
|
||
# il lit ZÉRO dispatch et ne compare plus rien — un garde qui passe au
|
||
# vert sans rien garder.
|
||
RE_DISPATCH_CALL = re.compile(
|
||
r'(?:el)?if status == "(\d+)":\s*\n(?:\s*#.*\n)*'
|
||
r"\s*(?:status = )?self\.(?:\w+\.)*(\w+)\("
|
||
)
|
||
|
||
EXPECTED = {
|
||
"Create backup": "create_backup_from_database",
|
||
"Download database": "download_database_backup_cli",
|
||
"Restore from backup": "restore_from_database",
|
||
"Duplicate a database": "duplicate_database",
|
||
"Erase a database": "drop_database",
|
||
}
|
||
|
||
|
||
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 TestGitMenuNumbering(MenuCoherence, unittest.TestCase):
|
||
"""Le menu Git, le seul dont todo.json suit des entrées codées en dur.
|
||
|
||
Ses premières entrées sont écrites à la main, les suivantes viennent de
|
||
`git_from_makefile` et le repli générique les renumérote tout seul :
|
||
ajouter une entrée codée en dur pousse celles de todo.json d'un rang sans
|
||
que rien ne le dise. Une entrée codée en dur oubliée dans le dispatch
|
||
ferait lancer la commande du voisin sous le libellé attendu.
|
||
"""
|
||
|
||
SOURCE = TODO_DIR / "todo.py"
|
||
ENTRY = "def prompt_execute_git(self):"
|
||
END = "def _git_install_hooks(self):"
|
||
MINIMUM = 2
|
||
|
||
EXPECTED = {
|
||
"Local git server": "prompt_execute_git_local_server",
|
||
"Add a remote to a local repository": "_git_add_remote",
|
||
"Install git hooks": "_git_install_hooks",
|
||
"Set merge.conflictStyle": "_git_set_conflict_style",
|
||
"Install Starship on Shell": "_shell_install_starship",
|
||
"Install Claude Code": "_shell_install_claude_code",
|
||
"Install opencode": "_shell_install_opencode",
|
||
}
|
||
|
||
|
||
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.
|
||
|
||
Les menus se trouvent par ce qu'ils APPELLENT — `fill_help_info` ou
|
||
`_menu_header` — dans tout le paquet, et non par la table de répartition
|
||
du menu Exécution. Chercher là ne voyait que les sous-menus atteints
|
||
depuis cette table : un menu ouvert depuis ailleurs, ou défini dans un
|
||
mixin, n'était jamais examiné, et c'est ainsi que le sous-menu VPN a
|
||
passé le contrôle sans étiquette.
|
||
|
||
Cinq méthodes sont exemptées, et pour la même raison : ce sont des
|
||
ACTIONS qui posent une question — un choix de méthode d'installation, un
|
||
« aller plus loin » après un rapport — et non des écrans où l'on
|
||
navigue. Leur donner un segment mettrait une miette sur une invite
|
||
passagère.
|
||
"""
|
||
|
||
ECRANS_EXEMPTES = {
|
||
"_analyse_follow_up",
|
||
"rtk_install",
|
||
"generate_config_from_preconfiguration",
|
||
"debug_ide",
|
||
"execute_odoo_upgrade",
|
||
}
|
||
|
||
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)
|
||
|
||
@staticmethod
|
||
def _menus_du_paquet():
|
||
"""Toute méthode qui dessine un menu, dans tout script/todo/*.py.
|
||
|
||
Une méthode dessine un menu quand elle appelle `fill_help_info` ou
|
||
`_menu_header` : c'est par là que passe l'en-tête, donc c'est là que
|
||
l'étiquette manque ou non. Les deux fonctions elles-mêmes sortent."""
|
||
trouves = set()
|
||
for chemin in sorted(TODO_DIR.glob("*.py")):
|
||
arbre = ast.parse(chemin.read_text(encoding="utf-8"))
|
||
for noeud in ast.walk(arbre):
|
||
if not isinstance(noeud, ast.FunctionDef):
|
||
continue
|
||
appels = {
|
||
c.func.attr
|
||
for c in ast.walk(noeud)
|
||
if isinstance(c, ast.Call)
|
||
and isinstance(c.func, ast.Attribute)
|
||
}
|
||
if not {"fill_help_info", "_menu_header"} & appels:
|
||
continue
|
||
if noeud.name in ("fill_help_info", "_menu_header"):
|
||
continue
|
||
trouves.add(noeud.name)
|
||
return trouves
|
||
|
||
def test_the_package_menus_were_found(self):
|
||
"""Le détecteur voit bien des menus : sinon tout passerait."""
|
||
menus = self._menus_du_paquet()
|
||
self.assertIn("prompt_execute_qemu", menus)
|
||
self.assertIn("prompt_execute_vpn", menus)
|
||
self.assertGreater(len(menus), 20)
|
||
|
||
def test_no_new_menu_forgets_its_label(self):
|
||
missing = self._menus_du_paquet() - self.labels - self.ECRANS_EXEMPTES
|
||
self.assertEqual(
|
||
missing,
|
||
set(),
|
||
f"menus sans étiquette dans _MENU_LABELS : {sorted(missing)}",
|
||
)
|
||
|
||
def test_the_vpn_submenu_leaves_a_crumb(self):
|
||
"""Le cas nommé : il était le seul menu invisible au contrôle."""
|
||
self.assertIn("prompt_execute_vpn", self.labels)
|
||
|
||
def test_no_stale_exemption(self):
|
||
"""Une exemption qui ne nomme plus un menu est à retirer."""
|
||
fantomes = self.ECRANS_EXEMPTES - self._menus_du_paquet()
|
||
self.assertEqual(fantomes, set())
|
||
|
||
def test_an_exemption_is_never_also_labelled(self):
|
||
"""Exempter ET étiqueter dirait deux choses opposées du même écran."""
|
||
self.assertEqual(self.ECRANS_EXEMPTES & self.labels, set())
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|