erplibre/test/test_todo_menu.py

512 lines
20 KiB
Python
Raw Normal View History

#!/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())
[ADD] proxmox : suivre une VM distante, changer son état, étendre le menu Les trois manques restants, demandés. Les colonnes vivantes du suivi — écrit par seconde, RAM, disque — venaient de virsh, qui ne connaît pas les VM d'un hôte Proxmox : elles restaient vides, et le relevé d'état les déclarait même « effacées », ce qui éteignait le reste. L'hôte sait tout cela en un appel (« pvesh get /cluster/resources »), et le relevé prend la forme de celui de virsh pour que rien en aval ne distingue la source. Un appel par hôte, mis en cache cinq secondes : une poignée de main ssh coûte 1 s, virsh 0,03 s. « Lister les VM » propose maintenant d'en changer l'état, comme QEMU/KVM — éteindre proprement avant de couper le courant, l'ordre le dit. Et le menu accepte les commandes ajoutées par todo.json. Enfin « models » manquait aux traductions : le rapport de migration sortait « 812 models » en français. --- EN --- The three remaining gaps, as asked. The dashboard's live columns — written per second, RAM, disk — came from virsh, which knows nothing of a Proxmox host's VMs: they stayed empty, and the state probe even declared them "gone", which switched off the rest. The host knows all of it in one call ("pvesh get /cluster/resources"), and the reading takes virsh's own shape so nothing downstream tells the sources apart. One call per host, cached five seconds: an ssh handshake costs 1 s, virsh 0.03 s. "List VMs" now offers to change their state, like QEMU/KVM — clean shutdown before pulling the plug, the order says so. And the menu accepts the commands todo.json adds. Finally "models" was missing from the translations: the migration report printed "812 models" in French. Assisted-by: Claude Opus 5
2026-08-24 05:11:58 -04:00
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",
}
[ADD] analyse: ausculter une base qui n'est pas ici Les analyses existaient ; le chemin d'AVANT manquait. La base d'un client est dans un zip, derrière une URL, ou vivante sur un serveur. « Restant de migration » a dû être écrit : check_migration_quality compare les bases de PALIER et exige le journal de progression — devant une sauvegarde isolée, ni l'un ni l'autre n'existe. Ses compteurs évidents ont été écartés après mesure. Comparés à la base d'ORIGINE : champs sans colonne 25 → 72, modèles sans table 90 → 158. Vingt-cinq et quatre-vingt-dix AVANT toute migration : du bruit. Ne restent que les constats faux en eux-mêmes, 0 avant, non nuls après — 9 langues au drapeau NULL, 68 tables m2m absentes, 414 index doublés. Le passe-plat RPC n'accepte que la lecture. psql l'obtient du serveur ; une session RPC n'a rien d'équivalent, et la liste blanche est donc appliquée dans le passe-plat, pas chez l'appelant. --- EN --- The analyses existed; the path BEFORE them did not. A customer database sits in a zip, behind a URL, or live on a server. « Migration leftovers » had to be written: check_migration_quality compares STEP databases and needs the progression log — facing a lone backup, neither exists. Its obvious counters were dropped after measuring. Against the ORIGINAL database: fields with no column 25 → 72, models with no table 90 → 158. Twenty-five and ninety BEFORE any migration: noise. Only what is wrong in itself remains, 0 before and non-zero after — 9 languages with a NULL flag, 68 missing m2m tables, 414 duplicated indexes. The RPC proxy only reads. psql gets that from the server; an RPC session has no equivalent, so the allowlist lives in the proxy, not the caller. Assisted-by: Claude Opus 5
2026-08-24 07:21:37 -04:00
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 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()