200 lines
7.3 KiB
Python
200 lines
7.3 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 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()
|