From 270aedd790779b9438e89645669c34da93747895 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 17 Sep 2026 09:31:44 -0400 Subject: [PATCH] =?UTF-8?q?[ADD]=20i18n=20:=20refuser=20une=20cl=C3=A9=20d?= =?UTF-8?q?e=20traduction=20=C3=A9crite=20deux=20fois?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Python bâtit un dictionnaire littéral de haut en bas : la dernière occurrence gagne. Une clé posée deux fois fait donc qu'un menu affiche la traduction d'un autre, et rien ne le signale — ni au chargement, ni à l'exécution, ni au test qui exerce le premier menu. Le contrôle lit le fichier par son ARBRE et non par une expression régulière, laquelle n'attrapait que la forme multiligne quand la collision peut s'écrire sur une seule. Vert sur l'état actuel, donc aucune clé en double aujourd'hui ; éprouvé sur les deux formes, qu'il refuse. --- EN --- Python builds a literal dictionary top to bottom: the last occurrence wins. A key written twice therefore makes one menu show another's translation, and nothing reports it — not at load, not at run, not in the test exercising the first menu. The check reads the file by its TREE rather than by a regular expression, which only caught the multi-line form when the collision can be written on one. Green on the current state, so no duplicate key today; exercised on both forms, which it refuses. Assisted-by: Claude Opus 5 --- test/test_i18n_duplicates.py | 83 ++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 test/test_i18n_duplicates.py diff --git a/test/test_i18n_duplicates.py b/test/test_i18n_duplicates.py new file mode 100644 index 0000000..8439f17 --- /dev/null +++ b/test/test_i18n_duplicates.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Une clé de traduction en double écrase la précédente, sans un mot. + +Python construit un dictionnaire littéral de haut en bas : la dernière +occurrence gagne. Une clé posée deux fois avec des valeurs différentes fait +donc qu'un menu affiche la traduction d'un autre, et rien ne le signale — ni +au chargement, ni à l'exécution, ni au test qui exerce le premier menu. + +C'est arrivé : « running » et « stopped » existaient déjà, et des clés +homonymes ajoutées pour un service ont été avalées par elles. Le symptôme +était un « Service : en cours » là où le fichier disait « actif ». + +Le contrôle lit le fichier par son ARBRE et non par une expression +régulière : la mienne n'attrapait que la forme multiligne, et la collision +était écrite sur une seule ligne. +""" + +import ast +import collections +import sys +import unittest +from pathlib import Path + +RACINE = Path(__file__).resolve().parent.parent +I18N = RACINE / "script" / "todo" / "todo_i18n.py" +sys.path.insert(0, str(RACINE / "script" / "todo")) + + +def cles_litterales(): + """Les clés du dictionnaire TRANSLATIONS, dans l'ordre du fichier. + + Par l'arbre syntaxique : une clé écrite sur une ligne et une clé écrite + sur plusieurs sont le même nœud, alors qu'elles n'ont pas la même forme + dans le texte. + """ + arbre = ast.parse(I18N.read_text(encoding="utf-8")) + for noeud in ast.walk(arbre): + if not isinstance(noeud, ast.Assign): + continue + cibles = [c.id for c in noeud.targets if isinstance(c, ast.Name)] + if "TRANSLATIONS" not in cibles: + continue + if not isinstance(noeud.value, ast.Dict): + continue + return [ + k.value + for k in noeud.value.keys + if isinstance(k, ast.Constant) and isinstance(k.value, str) + ] + raise AssertionError("TRANSLATIONS introuvable") + + +class TestClesUniques(unittest.TestCase): + def test_aucune_cle_nest_ecrite_deux_fois(self): + compte = collections.Counter(cles_litterales()) + doubles = sorted(k for k, n in compte.items() if n > 1) + self.assertEqual( + doubles, + [], + "clés en double — la dernière écrase les précédentes, sans un " + f"mot : {doubles}", + ) + + def test_le_controle_lit_bien_quelque_chose(self): + """Un test qui ne trouve aucune clé passerait toujours.""" + self.assertGreater(len(cles_litterales()), 1000) + + def test_chaque_cle_porte_les_deux_langues(self): + import todo_i18n + + manques = [ + k + for k, v in todo_i18n.TRANSLATIONS.items() + if not isinstance(v, dict) or "fr" not in v or "en" not in v + ] + self.assertEqual(manques, [], f"traductions incomplètes : {manques}") + + +if __name__ == "__main__": + unittest.main()