erplibre/test/test_todo_menu.py
Mathieu Benoit b1c1095977 [ADD] analyse: inspect an Odoo database without restoring it
A read-only toolkit answering « what is in this database, and what will
break on a version bump »: schema size and tables no model claims,
customised views compared with their module source, and the x_ fields
Studio left behind. It reads a backup zip directly, so a 40 GB dump needs
no restore, and it reaches every answer from the TODO menu.

--- FR ---

Une boîte à outils en lecture seule qui répond à « qu'y a-t-il dans cette
base, et qu'est-ce qui cassera à la montée de version » : taille du schéma
et tables qu'aucun modèle ne réclame, vues personnalisées comparées à leur
source, champs x_ laissés par Studio. Elle lit un zip de sauvegarde tel
quel, donc sans restaurer, et tout s'atteint depuis le menu TODO.

Assisted-by: Claude Opus 5
2026-08-10 03:10:50 -04:00

199 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()