Le formateur de ce dépôt est ruff depuis qu'il remplace black, qui ne connaît aucune cible au-delà de py313 ; ce passage applique sa norme à l'arbre entier, d'un coup, pour qu'aucun commit de fond n'ait à porter du style. L'écart tient presque entièrement aux chaînes coupées à la main que ruff recolle quand elles tiennent sur une ligne, et aux « with » multiples qu'il regroupe : aucune valeur ne change, et les clés de traduction non plus. Vérifié : la suite unitaire reste verte après le passage, et le contrôle de syntaxe ne signale rien. --- EN --- This repository's formatter is ruff since it replaced black, which knows no target beyond py313; this pass applies its standard to the whole tree at once, so that no substantive commit has to carry style. The difference is almost entirely the hand-split strings ruff joins back when they fit on one line, and the multiple "with" it merges: no value changes, nor do the translation keys. Checked: the unit suite stays green after the pass, and the syntax check reports nothing. Assisted-by: Claude Opus 5
320 lines
10 KiB
Python
320 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
# © 2026 TechnoLibre (http://www.technolibre.ca)
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
|
|
import ast
|
|
import collections
|
|
import os
|
|
import tempfile
|
|
import unittest
|
|
from unittest.mock import patch
|
|
|
|
from script.todo import todo_i18n
|
|
|
|
|
|
class TestTranslations(unittest.TestCase):
|
|
"""Test TRANSLATIONS dictionary integrity."""
|
|
|
|
def test_all_entries_have_fr_and_en(self):
|
|
for key, entry in todo_i18n.TRANSLATIONS.items():
|
|
self.assertIn("fr", entry, f"Key '{key}' missing 'fr' translation")
|
|
self.assertIn("en", entry, f"Key '{key}' missing 'en' translation")
|
|
|
|
def test_no_empty_translations(self):
|
|
for key, entry in todo_i18n.TRANSLATIONS.items():
|
|
for lang in ("fr", "en"):
|
|
self.assertTrue(
|
|
len(entry[lang]) > 0,
|
|
f"Key '{key}' has empty '{lang}' translation",
|
|
)
|
|
|
|
def test_translations_not_empty(self):
|
|
self.assertGreater(len(todo_i18n.TRANSLATIONS), 0)
|
|
|
|
|
|
class TestT(unittest.TestCase):
|
|
"""Test t() translation function."""
|
|
|
|
def setUp(self):
|
|
todo_i18n._current_lang = None
|
|
|
|
def tearDown(self):
|
|
todo_i18n._current_lang = None
|
|
|
|
def test_returns_french_when_lang_fr(self):
|
|
todo_i18n._current_lang = "fr"
|
|
result = todo_i18n.t("Quit")
|
|
self.assertEqual(result, "Quitter")
|
|
|
|
def test_returns_english_when_lang_en(self):
|
|
todo_i18n._current_lang = "en"
|
|
result = todo_i18n.t("Quit")
|
|
self.assertEqual(result, "Quit")
|
|
|
|
def test_unknown_key_returns_key(self):
|
|
todo_i18n._current_lang = "fr"
|
|
result = todo_i18n.t("nonexistent_key_xyz")
|
|
self.assertEqual(result, "nonexistent_key_xyz")
|
|
|
|
def test_fallback_to_fr_if_lang_missing(self):
|
|
todo_i18n._current_lang = "de"
|
|
result = todo_i18n.t("Quit")
|
|
self.assertEqual(result, "Quitter")
|
|
|
|
|
|
class TestGetLang(unittest.TestCase):
|
|
"""Test get_lang() function."""
|
|
|
|
def setUp(self):
|
|
todo_i18n._current_lang = None
|
|
|
|
def tearDown(self):
|
|
todo_i18n._current_lang = None
|
|
|
|
def test_returns_cached_lang(self):
|
|
todo_i18n._current_lang = "en"
|
|
result = todo_i18n.get_lang()
|
|
self.assertEqual(result, "en")
|
|
|
|
def test_reads_from_env_var_sh(self):
|
|
with tempfile.NamedTemporaryFile(
|
|
mode="w", suffix=".sh", delete=False
|
|
) as f:
|
|
f.write('EL_LANG="en"\n')
|
|
f.flush()
|
|
try:
|
|
with patch.object(todo_i18n, "ENV_VAR_FILE", f.name):
|
|
result = todo_i18n.get_lang()
|
|
self.assertEqual(result, "en")
|
|
finally:
|
|
os.unlink(f.name)
|
|
|
|
def test_reads_unquoted_lang(self):
|
|
with tempfile.NamedTemporaryFile(
|
|
mode="w", suffix=".sh", delete=False
|
|
) as f:
|
|
f.write("EL_LANG=fr\n")
|
|
f.flush()
|
|
try:
|
|
with patch.object(todo_i18n, "ENV_VAR_FILE", f.name):
|
|
result = todo_i18n.get_lang()
|
|
self.assertEqual(result, "fr")
|
|
finally:
|
|
os.unlink(f.name)
|
|
|
|
def test_env_variable_fallback(self):
|
|
with (
|
|
patch.object(
|
|
todo_i18n,
|
|
"ENV_VAR_FILE",
|
|
"/nonexistent/path",
|
|
),
|
|
patch.dict(os.environ, {"EL_LANG": "en"}),
|
|
):
|
|
result = todo_i18n.get_lang()
|
|
self.assertEqual(result, "en")
|
|
|
|
def test_default_is_fr(self):
|
|
with (
|
|
patch.object(
|
|
todo_i18n,
|
|
"ENV_VAR_FILE",
|
|
"/nonexistent/path",
|
|
),
|
|
patch.dict(os.environ, {}, clear=True),
|
|
):
|
|
result = todo_i18n.get_lang()
|
|
self.assertEqual(result, "fr")
|
|
|
|
def test_invalid_lang_in_file_falls_through(self):
|
|
with tempfile.NamedTemporaryFile(
|
|
mode="w", suffix=".sh", delete=False
|
|
) as f:
|
|
f.write('EL_LANG="de"\n')
|
|
f.flush()
|
|
try:
|
|
with (
|
|
patch.object(todo_i18n, "ENV_VAR_FILE", f.name),
|
|
patch.dict(os.environ, {}, clear=True),
|
|
):
|
|
result = todo_i18n.get_lang()
|
|
self.assertEqual(result, "fr")
|
|
finally:
|
|
os.unlink(f.name)
|
|
|
|
|
|
class TestSetLang(unittest.TestCase):
|
|
"""Test set_lang() function."""
|
|
|
|
def setUp(self):
|
|
todo_i18n._current_lang = None
|
|
|
|
def tearDown(self):
|
|
todo_i18n._current_lang = None
|
|
|
|
def test_sets_current_lang(self):
|
|
# Détourner ENV_VAR_FILE comme le font les trois tests suivants :
|
|
# `set_lang()` PERSISTE, et sans ce détournement celui-ci écrivait
|
|
# dans le ./env_var.sh du dépôt, suivi par git.
|
|
with patch.object(todo_i18n, "ENV_VAR_FILE", "/nonexistent/path"):
|
|
todo_i18n.set_lang("en")
|
|
self.assertEqual(todo_i18n._current_lang, "en")
|
|
|
|
def test_persists_to_file_update(self):
|
|
with tempfile.NamedTemporaryFile(
|
|
mode="w", suffix=".sh", delete=False
|
|
) as f:
|
|
f.write('EL_LANG="fr"\nOTHER=value\n')
|
|
f.flush()
|
|
try:
|
|
with patch.object(todo_i18n, "ENV_VAR_FILE", f.name):
|
|
todo_i18n.set_lang("en")
|
|
with open(f.name) as rf:
|
|
content = rf.read()
|
|
self.assertIn('EL_LANG="en"', content)
|
|
self.assertIn("OTHER=value", content)
|
|
finally:
|
|
os.unlink(f.name)
|
|
|
|
def test_persists_to_file_append(self):
|
|
with tempfile.NamedTemporaryFile(
|
|
mode="w", suffix=".sh", delete=False
|
|
) as f:
|
|
f.write("SOME_VAR=123\n")
|
|
f.flush()
|
|
try:
|
|
with patch.object(todo_i18n, "ENV_VAR_FILE", f.name):
|
|
todo_i18n.set_lang("en")
|
|
with open(f.name) as rf:
|
|
content = rf.read()
|
|
self.assertIn('EL_LANG="en"', content)
|
|
self.assertIn("SOME_VAR=123", content)
|
|
finally:
|
|
os.unlink(f.name)
|
|
|
|
def test_nonexistent_file_no_crash(self):
|
|
with patch.object(
|
|
todo_i18n,
|
|
"ENV_VAR_FILE",
|
|
"/nonexistent/path",
|
|
):
|
|
todo_i18n.set_lang("en")
|
|
self.assertEqual(todo_i18n._current_lang, "en")
|
|
|
|
|
|
class TestLangIsConfigured(unittest.TestCase):
|
|
"""Test lang_is_configured() function."""
|
|
|
|
def test_returns_true_when_configured(self):
|
|
with tempfile.NamedTemporaryFile(
|
|
mode="w", suffix=".sh", delete=False
|
|
) as f:
|
|
f.write('EL_LANG="fr"\n')
|
|
f.flush()
|
|
try:
|
|
with patch.object(todo_i18n, "ENV_VAR_FILE", f.name):
|
|
result = todo_i18n.lang_is_configured()
|
|
self.assertTrue(result)
|
|
finally:
|
|
os.unlink(f.name)
|
|
|
|
def test_returns_false_when_not_configured(self):
|
|
with tempfile.NamedTemporaryFile(
|
|
mode="w", suffix=".sh", delete=False
|
|
) as f:
|
|
f.write("SOME_VAR=123\n")
|
|
f.flush()
|
|
try:
|
|
with patch.object(todo_i18n, "ENV_VAR_FILE", f.name):
|
|
result = todo_i18n.lang_is_configured()
|
|
self.assertFalse(result)
|
|
finally:
|
|
os.unlink(f.name)
|
|
|
|
def test_returns_false_when_file_missing(self):
|
|
with patch.object(
|
|
todo_i18n,
|
|
"ENV_VAR_FILE",
|
|
"/nonexistent/path",
|
|
):
|
|
result = todo_i18n.lang_is_configured()
|
|
self.assertFalse(result)
|
|
|
|
|
|
class TestAucuneCleRepetee(unittest.TestCase):
|
|
"""Une clé répétée écrase la précédente EN SILENCE.
|
|
|
|
Le dictionnaire est un littéral Python de plusieurs milliers d'entrées :
|
|
rien n'avertit, rien ne lève, et la deuxième définition gagne. Le prix
|
|
s'est déjà payé une fois en étiquette de menu principal — la traduction
|
|
lue n'était pas celle qu'on venait d'écrire, et le fichier montrait la
|
|
bonne à qui la cherchait.
|
|
|
|
Le contrôle lit l'ARBRE et non le dictionnaire construit : une fois
|
|
construit, le doublon a déjà disparu, et il n'y a plus rien à voir. C'est
|
|
la seule façon de poser la question.
|
|
"""
|
|
|
|
def _cles(self):
|
|
chemin = os.path.join(
|
|
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
|
"script",
|
|
"todo",
|
|
"todo_i18n.py",
|
|
)
|
|
with open(chemin, encoding="utf-8") as fichier:
|
|
arbre = ast.parse(fichier.read())
|
|
litteral = None
|
|
for noeud in ast.walk(arbre):
|
|
if isinstance(noeud, ast.Assign) and any(
|
|
isinstance(c, ast.Name) and c.id == "TRANSLATIONS"
|
|
for c in noeud.targets
|
|
):
|
|
litteral = noeud.value
|
|
self.assertIsInstance(litteral, ast.Dict)
|
|
return [
|
|
c.value
|
|
for c in litteral.keys
|
|
if isinstance(c, ast.Constant) and isinstance(c.value, str)
|
|
]
|
|
|
|
def test_the_literal_was_actually_read(self):
|
|
"""Sinon un dictionnaire vide passerait le test suivant."""
|
|
self.assertGreater(len(self._cles()), 2000)
|
|
|
|
def test_no_key_is_declared_twice(self):
|
|
compte = collections.Counter(self._cles())
|
|
doublons = sorted(k for k, n in compte.items() if n > 1)
|
|
self.assertEqual(
|
|
doublons,
|
|
[],
|
|
"clés déclarées deux fois : la seconde écrase la première",
|
|
)
|
|
|
|
def test_every_key_survives_the_build(self):
|
|
"""Le compte du littéral et celui du dictionnaire s'accordent.
|
|
|
|
C'est la même vérité dite autrement, et elle tombe d'elle-même le
|
|
jour où une clé se répète."""
|
|
self.assertEqual(len(self._cles()), len(todo_i18n.TRANSLATIONS))
|
|
|
|
|
|
class TestChaqueCleDitSesDeuxLangues(unittest.TestCase):
|
|
"""Une clé sans « fr » ou sans « en » ne lève rien au chargement.
|
|
|
|
t() retombe alors sur la clé elle-même, si bien que l'écran montre
|
|
l'anglais à qui a choisi le français, ou l'inverse, sans que rien ne le
|
|
signale.
|
|
"""
|
|
|
|
def test_every_key_carries_fr_and_en(self):
|
|
manques = sorted(
|
|
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, [], "traductions incomplètes")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|