erplibre/test/test_todo_menu.py
Mathieu Benoit 3b2f7fa3d6 [ADD] test: apparier l'affichage et le dispatch du menu QEMU
Le menu Execute avait ce garde-fou, le menu QEMU non — et c'est lui qui vient
de renumérer, l'émulateur Android s'insérant avant « List available images ».
Sa forme est pire à l'œil : une liste de dictionnaires où les « section » ne
consomment pas de numéro, donc le décalage ne se voit pas en lisant.

Le test relit les deux écritures et les apparie, puis une table dit où chaque
entrée doit mener. Inverser les deux derniers crans du dispatch le fait bien
tomber, en nommant l'entrée fautive.

--- EN ---

The Execute menu had this guard, the QEMU menu did not — and the QEMU one is
what just got renumbered, the Android emulator slotting in before "List
available images". Its shape is worse to eyeball: a list of dicts where
"section" entries consume no number, so a shift does not show when reading.

The test reads both spellings and pairs them, then a table states where each
entry must lead. Swapping the last two dispatch branches does make it fail,
naming the offending entry.

Assisted-by: Claude Opus 5
2026-08-23 02:07:42 -04:00

298 lines
11 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_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 TestQemuMenuNumbering(unittest.TestCase):
"""Le menu QEMU : même piège, autre forme.
Il ne s'écrit pas en f-string mais en liste de dictionnaires, où seules les
entrées « prompt_description » consomment un numéro — les « section » sont
des titres. Le décalage y est donc encore moins visible à l'œil : insérer
une entrée avant la dernière renumérote tout ce qui suit, et le dispatch ne
proteste pas. C'est arrivé en ajoutant l'émulateur Android avant
« List available images ».
"""
RE_ENTRY = re.compile(
r'"(section|prompt_description)": t\(\s*\n?\s*"([^"]+)"'
)
RE_DISPATCH_CALL = re.compile(
r'(?:el)?if status == "(\d+)":\s*\n\s*(?:status = )?self\.(\w+)\('
)
def setUp(self):
source = TODO_PY.read_text(encoding="utf-8")
start = source.index("def prompt_execute_qemu(self):")
end = source.index("def _qemu_tunnel_menu(self):", 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), 10)
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]
)
# 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",
}
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 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()