#!/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", "Transform data": "prompt_execute_transform", "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. Une entrée peut aussi porter sa destination dans « method » plutôt que dans un « elif status » numéroté. Elle échappe alors à la renumérotation par construction, et EXPECTED la vérifie contre cette clé. À 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+)\(" ) # Une entrée qui porte sa destination dans « method » se dispatche seule, # par le repli générique. Elle n'a pas de numéro dans le code, donc aucune # renumérotation ne peut la désaligner : c'est le seul moyen de placer une # entrée codée en dur APRÈS des entrées venues de la configuration, dont # le nombre n'est pas connu à la lecture du source. RE_SELF_DISPATCH = re.compile( r'"prompt_description": t\(\s*\n?\s*"([^"]+)"\s*\)?,?\s*\n' r'\s*"method": "(\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] self.self_dispatch = dict(self.RE_SELF_DISPATCH.findall(self.body)) 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.numbered = [ (n, label) for n, label in self.shown if label not in self.self_dispatch ] 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.numbered), 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.numbered], [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", ) atteint = self.self_dispatch.get(label, dct.get(num)) self.assertEqual( atteint, self.EXPECTED[key], f"[{num}] « {label} » mène à {atteint}" 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()) def test_self_dispatched_entries_name_a_real_method(self): """« method » est une chaîne : rien ne la relie au code sans ceci.""" from script.todo.todo import TODO for label, method in self.self_dispatch.items(): self.assertTrue( hasattr(TODO, method), f"« {label} » mène à {method}, qui n'existe pas", ) 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 TestLesIconesDuMenuProxmox(unittest.TestCase): """L'icône vit DANS la chaîne traduite, et les deux langues la portent. Une entrée sans icône se perd dans une liste de dix-huit : l'œil s'y repère par le pictogramme avant de lire. Le même pictogramme dit la même chose partout dans l'outil — 📋 liste, 🧹 nettoie, 📊 mesure — et ce tableau est ce qui l'empêche de dériver d'un menu à l'autre. """ ICONES = { "Deploy a VM on the Proxmox host": "🚀", "Preview a deployment (dry-run, nothing sent)": "🔍", "Download a cloud image on the host": "📥", "Reopen install monitoring (last run / history)": "📈", "List VMs (qm list)": "📋", "Show a VM IP address": "🌐", "Open the console on a VM": "🖥", "Resize a VM disk": "📐", "Delete VM(s)": "🗑", "Clean up (orphan disks)": "🧹", "Test a VM (open Odoo in a CLI browser)": "🧪", "Statistics (host and VMs)": "📊", "SSH configuration (~/.ssh/config, ProxyJump)": "🔑", "Remote desktop tunnel (VNC/RDP over SSH)": "🖥", "Android emulator (start, tunnel, scrcpy)": "📱", "List available images and their specs": "🗂", "Proxmox - example sequence (dry-run)": "🎬", "Change the Proxmox host": "🔀", "Host": "🏠", } def test_chaque_entree_porte_son_icone(self): from script.todo import todo_i18n for cle, icone in self.ICONES.items(): entree = todo_i18n.TRANSLATIONS[cle] for langue in ("fr", "en"): self.assertTrue( entree[langue].startswith(icone), f"« {cle} » ({langue}) ne commence pas par {icone} :" f" {entree[langue]}", ) def test_le_menu_affiche_bien_ces_entrees(self): """Le tableau ci-dessus ne vaut que s'il décrit le menu RÉEL : une entrée renommée le laisserait figer une icône que personne ne voit.""" src = (TODO_DIR / "proxmox_menu.py").read_text(encoding="utf-8") for cle in self.ICONES: # La chaîne SEULE : une entrée longue s'écrit « t( » sur une # ligne et sa chaîne sur la suivante, et chercher l'appel entier # ne trouverait que les courtes. self.assertIn( f'"{cle}"', src, f"« {cle} » n'est plus dans le menu" ) 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", "Recover files from a VM disk (libguestfs)": "_qemu_recover_files", "Diagnostics (report to share)": "_qemu_diagnostics", "Show the libvirt network state": "_qemu_network_status", "Recreate the VM subnet": "_qemu_network_recreate", } 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 TestGitMenuNumbering(MenuCoherence, unittest.TestCase): """Le menu Git, le seul dont todo.json suit des entrées codées en dur. Ses premières entrées sont écrites à la main, les suivantes viennent de `git_from_makefile` et le repli générique les renumérote tout seul : ajouter une entrée codée en dur pousse celles de todo.json d'un rang sans que rien ne le dise. Une entrée codée en dur oubliée dans le dispatch ferait lancer la commande du voisin sous le libellé attendu. """ SOURCE = TODO_DIR / "todo.py" ENTRY = "def prompt_execute_git(self):" END = "def _git_install_hooks(self):" MINIMUM = 2 EXPECTED = { "Local git server": "prompt_execute_git_local_server", "Add a remote to a local repository": "_git_add_remote", "Install git hooks": "_git_install_hooks", "Set merge.conflictStyle": "_git_set_conflict_style", "Install Starship on Shell": "_shell_install_starship", "Install Claude Code": "_shell_install_claude_code", "Install opencode": "_shell_install_opencode", } 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. Les menus se trouvent par ce qu'ils APPELLENT — `fill_help_info` ou `_menu_header` — dans tout le paquet, et non par la table de répartition du menu Exécution. Chercher là ne voyait que les sous-menus atteints depuis cette table : un menu ouvert depuis ailleurs, ou défini dans un mixin, n'était jamais examiné, et c'est ainsi que le sous-menu VPN a passé le contrôle sans étiquette. Cinq méthodes sont exemptées, et pour la même raison : ce sont des ACTIONS qui posent une question — un choix de méthode d'installation, un « aller plus loin » après un rapport — et non des écrans où l'on navigue. Leur donner un segment mettrait une miette sur une invite passagère. """ ECRANS_EXEMPTES = { "_analyse_follow_up", "rtk_install", "generate_config_from_preconfiguration", "debug_ide", "execute_odoo_upgrade", } 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) @staticmethod def _menus_du_paquet(): """Toute méthode qui dessine un menu, dans tout script/todo/*.py. Une méthode dessine un menu quand elle appelle `fill_help_info` ou `_menu_header` : c'est par là que passe l'en-tête, donc c'est là que l'étiquette manque ou non. Les deux fonctions elles-mêmes sortent.""" trouves = set() for chemin in sorted(TODO_DIR.glob("*.py")): arbre = ast.parse(chemin.read_text(encoding="utf-8")) for noeud in ast.walk(arbre): if not isinstance(noeud, ast.FunctionDef): continue appels = { c.func.attr for c in ast.walk(noeud) if isinstance(c, ast.Call) and isinstance(c.func, ast.Attribute) } if not {"fill_help_info", "_menu_header"} & appels: continue if noeud.name in ("fill_help_info", "_menu_header"): continue trouves.add(noeud.name) return trouves def test_the_package_menus_were_found(self): """Le détecteur voit bien des menus : sinon tout passerait.""" menus = self._menus_du_paquet() self.assertIn("prompt_execute_qemu", menus) self.assertIn("prompt_execute_vpn", menus) self.assertGreater(len(menus), 20) def test_no_new_menu_forgets_its_label(self): missing = self._menus_du_paquet() - self.labels - self.ECRANS_EXEMPTES self.assertEqual( missing, set(), f"menus sans étiquette dans _MENU_LABELS : {sorted(missing)}", ) def test_the_vpn_submenu_leaves_a_crumb(self): """Le cas nommé : il était le seul menu invisible au contrôle.""" self.assertIn("prompt_execute_vpn", self.labels) def test_no_stale_exemption(self): """Une exemption qui ne nomme plus un menu est à retirer.""" fantomes = self.ECRANS_EXEMPTES - self._menus_du_paquet() self.assertEqual(fantomes, set()) def test_an_exemption_is_never_also_labelled(self): """Exempter ET étiqueter dirait deux choses opposées du même écran.""" self.assertEqual(self.ECRANS_EXEMPTES & self.labels, set()) if __name__ == "__main__": unittest.main()