erplibre/test/test_prompt_suffix.py
Mathieu Benoit 19410e5b73 [FIX] todo : une invite n'écrit plus son deux-points deux fois
`click.prompt` ajoute son propre « : » à l'étiquette qu'on lui donne, et
quinze étiquettes le portaient déjà. Le menu le plus vu du logiciel demandait
donc « Commande :: », et sept invites de déploiement à distance affichaient
un deux-points suivi d'un autre.

Le suffixe est désormais explicite partout où l'étiquette ponctue elle-même,
et sa valeur se déduit de l'étiquette : rien quand elle porte déjà l'espace
qui suit, un espace quand le deux-points est nu. Un contrôle lit l'arbre et
résout les DEUX langues, le français mettant une espace avant le deux-points
là où l'anglais n'en met pas — chercher dans le texte du code ne verrait que
la clé anglaise.

--- EN ---

`click.prompt` adds its own ":" to the label it is given, and fifteen labels
already carried one. The most-seen menu of the software therefore asked
"Command:: ", and seven remote-deployment prompts showed a colon followed by
another.

The suffix is now explicit wherever the label punctuates itself, and its value
follows from the label: nothing when it already carries the trailing space, a
space when the colon is bare. A check reads the tree and resolves BOTH
languages, French putting a space before the colon where English does not —
searching the code text would only see the English key.

Assisted-by: Claude Opus 5
2026-09-09 07:35:15 -04:00

141 lines
5.1 KiB
Python

#!/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()