erplibre/test/test_prompt_suffix.py

142 lines
5.1 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Le deux-points d'une invite est écrit une fois, pas deux.
`click.prompt` ajoute son propre suffixe — « : » suivi d'un espace — à
l'étiquette qu'on lui donne. Une étiquette qui porte déjà son deux-points
sortait donc en « Commande :: », et ce menu-là est le plus vu du logiciel.
Le contrôle lit l'ARBRE de tout `script/**/*.py` et RÉSOUT l'étiquette dans
les deux langues, parce que la ponctuation n'est pas la même : le français
met une espace avant le deux-points, l'anglais non, et une seule des deux
traductions peut porter la marque. Chercher dans le texte du code ne verrait
que la clé anglaise.
Deux suffixes explicites sont acceptés, et le choix se déduit de l'étiquette
elle-même : `" "` quand elle finit par un deux-points nu, `""` quand elle
porte déjà l'espace qui suit. Toute autre valeur est une décision à écrire
ici avec sa raison.
La couverture est PARTIELLE et le reste : une étiquette calculée — variable,
f-string, concaténation — n'est pas lisible dans l'arbre, et le contrôle
l'ignore plutôt que de l'approximer. Il voit les étiquettes littérales, qui
sont celles où le deux-points s'écrit à la main.
"""
import ast
import pathlib
import unittest
from script.todo.todo_i18n import TRANSLATIONS
RACINE = pathlib.Path(__file__).resolve().parents[1]
def _etiquette(noeud):
"""La clé de l'étiquette d'un `click.prompt`, ou None.
Reconnaît `t("…")` et la chaîne nue. Une étiquette calculée — variable,
f-string, concaténation — rend None : ce contrôle ne devine pas ce qu'il
ne peut pas lire, et le dire est plus honnête que de l'approximer."""
if not noeud.args:
return None
premier = noeud.args[0]
if (
isinstance(premier, ast.Call)
and getattr(premier.func, "id", "") == "t"
and premier.args
and isinstance(premier.args[0], ast.Constant)
and isinstance(premier.args[0].value, str)
):
return premier.args[0].value
if isinstance(premier, ast.Constant) and isinstance(premier.value, str):
return premier.value
return None
def _invites():
"""[(fichier:ligne, clé, suffixe explicite ou None)] de tout le paquet."""
trouves = []
for chemin in sorted(RACINE.glob("script/**/*.py")):
try:
arbre = ast.parse(chemin.read_text(encoding="utf-8"))
except SyntaxError:
continue
for noeud in ast.walk(arbre):
if not isinstance(noeud, ast.Call):
continue
if getattr(noeud.func, "attr", "") != "prompt":
continue
cle = _etiquette(noeud)
if cle is None:
continue
suffixe = None
for mot in noeud.keywords:
if mot.arg == "prompt_suffix" and isinstance(
mot.value, ast.Constant
):
suffixe = mot.value.value
ou = f"{chemin.relative_to(RACINE)}:{noeud.lineno}"
trouves.append((ou, cle, suffixe))
return trouves
def _finit_par_deux_points(cle):
"""Vrai si l'étiquette finit par un deux-points dans UNE des langues."""
for langue in ("fr", "en"):
rendu = TRANSLATIONS.get(cle, {}).get(langue, cle)
if rendu.rstrip().endswith(":"):
return True
return False
class TestLesInvitesFurentTrouvees(unittest.TestCase):
"""Sans ceci, une recherche cassée rendrait tous les tests verts."""
def test_the_search_finds_prompts(self):
self.assertGreater(len(_invites()), 20)
def test_the_main_menu_prompt_is_among_them(self):
cles = {cle for _, cle, _ in _invites()}
self.assertIn("Command:", cles)
class TestAucunDeuxPointsDouble(unittest.TestCase):
def test_every_colon_label_passes_its_suffix(self):
fautives = [
f"{ou} — {cle!r}"
for ou, cle, suffixe in _invites()
if _finit_par_deux_points(cle) and suffixe is None
]
self.assertEqual(
fautives,
[],
"click ajoute « : » : ces invites en afficheraient deux",
)
def test_the_suffix_matches_the_label(self):
"""L'espace est fourni une fois : par l'étiquette ou par le suffixe."""
mauvais = []
for ou, cle, suffixe in _invites():
if suffixe is None:
continue
francais = TRANSLATIONS.get(cle, {}).get("fr", cle)
attendu = "" if francais.endswith(": ") else " "
if suffixe != attendu:
mauvais.append(f"{ou} — {suffixe!r} au lieu de {attendu!r}")
self.assertEqual(mauvais, [])
def test_a_label_without_a_colon_leaves_click_alone(self):
"""Le suffixe par défaut est ce qui ponctue les autres invites."""
inutiles = [
f"{ou} — {cle!r}"
for ou, cle, suffixe in _invites()
if suffixe is not None and not _finit_par_deux_points(cle)
]
self.assertEqual(inutiles, [])
if __name__ == "__main__":
unittest.main()