« Il manque plein d'informations qu'il y avait avant » : l'écran de télémétrie construit son arbre en LISANT le code — un fichier, sa première classe. Depuis que les menus QEMU/KVM et Proxmox vivent dans des mixins, leurs colonnes avaient disparu de cet écran ; les commandes s'exécutaient toujours, mais on ne pouvait plus les lancer de là. L'arbre lit maintenant aussi les mixins, trouvés dans les imports de todo.py — un mixin ajouté demain apparaîtra sans qu'on y pense. Le menu Proxmox n'avait pas d'étiquette : son fil d'Ariane s'arrêtait deux niveaux plus haut, sur « Deploy ». Vérifié : 16 commandes QEMU/KVM et 18 Proxmox dans l'arbre, contre zéro et zéro. --- EN --- "A lot of information that used to be there is missing": the telemetry screen builds its tree by READING the code — one file, its first class. Since the QEMU/KVM and Proxmox menus moved into mixins, their columns had vanished from that screen; the commands still ran, but could no longer be launched from there. The tree now reads the mixins too, found in todo.py's own imports — a mixin added tomorrow shows up without anyone thinking about it. The Proxmox menu had no label: its breadcrumb stopped two levels up, at "Deploy". Verified: 16 QEMU/KVM commands and 18 Proxmox ones in the tree, against zero and zero. Assisted-by: Claude Opus 5
418 lines
16 KiB
Python
418 lines
16 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",
|
||
"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 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()
|