2026-03-10 02:49:03 -04:00
|
|
|
#!/usr/bin/env python3
|
2026-03-11 23:15:07 -04:00
|
|
|
# © 2026 TechnoLibre (http://www.technolibre.ca)
|
2026-03-10 02:49:03 -04:00
|
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
|
|
|
|
[FIX] i18n : retirer trois clés déclarées deux fois, et l'interdire
Trois clés étaient déclarées deux fois dans un littéral de trois mille
entrées. Rien n'avertit et rien ne lève : la seconde gagne, et le prix s'est
déjà payé en étiquette de menu, la traduction lue n'étant pas celle qu'on
venait d'écrire au premier endroit. Les trois portaient la même traduction des
deux côtés, donc aucun écran ne change ; c'est le piège qui part.
Le contrôle passe de « pas plus de trois » à « aucune », et il vit désormais
avec les tests d'internationalisation plutôt qu'avec ceux d'un menu. Il lit
l'ARBRE : une fois le dictionnaire construit, le doublon a déjà disparu et il
n'y a plus rien à demander.
--- EN ---
Three keys were declared twice in a literal of three thousand entries.
Nothing warns and nothing raises: the second wins, and the price was already
paid on a menu label, the translation read not being the one just written at
the first place. All three carried the same translation on both sides, so no
screen changes; what goes is the trap.
The check moves from "no more than three" to "none at all", and it now lives
with the internationalisation tests rather than with a menu's. It reads the
TREE: once the dictionary is built the duplicate is already gone, and there is
nothing left to ask.
Assisted-by: Claude Opus 5
2026-09-09 04:44:19 -04:00
|
|
|
import ast
|
|
|
|
|
import collections
|
2026-03-10 02:49:03 -04:00
|
|
|
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):
|
2026-08-10 03:10:50 -04:00
|
|
|
todo_i18n._current_lang = "fr"
|
|
|
|
|
result = todo_i18n.t("Quit")
|
2026-03-10 02:49:03 -04:00
|
|
|
self.assertEqual(result, "Quitter")
|
|
|
|
|
|
|
|
|
|
def test_returns_english_when_lang_en(self):
|
2026-08-10 03:10:50 -04:00
|
|
|
todo_i18n._current_lang = "en"
|
|
|
|
|
result = todo_i18n.t("Quit")
|
2026-03-10 02:49:03 -04:00
|
|
|
self.assertEqual(result, "Quit")
|
|
|
|
|
|
|
|
|
|
def test_unknown_key_returns_key(self):
|
2026-08-10 03:10:50 -04:00
|
|
|
todo_i18n._current_lang = "fr"
|
2026-03-10 02:49:03 -04:00
|
|
|
result = todo_i18n.t("nonexistent_key_xyz")
|
|
|
|
|
self.assertEqual(result, "nonexistent_key_xyz")
|
|
|
|
|
|
|
|
|
|
def test_fallback_to_fr_if_lang_missing(self):
|
2026-08-10 03:10:50 -04:00
|
|
|
todo_i18n._current_lang = "de"
|
|
|
|
|
result = todo_i18n.t("Quit")
|
2026-03-10 02:49:03 -04:00
|
|
|
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:
|
2026-08-10 03:10:50 -04:00
|
|
|
with patch.object(todo_i18n, "ENV_VAR_FILE", f.name):
|
2026-03-10 02:49:03 -04:00
|
|
|
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:
|
2026-08-10 03:10:50 -04:00
|
|
|
with patch.object(todo_i18n, "ENV_VAR_FILE", f.name):
|
2026-03-10 02:49:03 -04:00
|
|
|
result = todo_i18n.get_lang()
|
|
|
|
|
self.assertEqual(result, "fr")
|
|
|
|
|
finally:
|
|
|
|
|
os.unlink(f.name)
|
|
|
|
|
|
|
|
|
|
def test_env_variable_fallback(self):
|
[REF] format : passer l'outillage et les tests sous ruff
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
2026-09-24 13:30:31 -04:00
|
|
|
with (
|
|
|
|
|
patch.object(
|
|
|
|
|
todo_i18n,
|
|
|
|
|
"ENV_VAR_FILE",
|
|
|
|
|
"/nonexistent/path",
|
|
|
|
|
),
|
|
|
|
|
patch.dict(os.environ, {"EL_LANG": "en"}),
|
|
|
|
|
):
|
2026-03-10 02:49:03 -04:00
|
|
|
result = todo_i18n.get_lang()
|
|
|
|
|
self.assertEqual(result, "en")
|
|
|
|
|
|
|
|
|
|
def test_default_is_fr(self):
|
[REF] format : passer l'outillage et les tests sous ruff
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
2026-09-24 13:30:31 -04:00
|
|
|
with (
|
|
|
|
|
patch.object(
|
|
|
|
|
todo_i18n,
|
|
|
|
|
"ENV_VAR_FILE",
|
|
|
|
|
"/nonexistent/path",
|
|
|
|
|
),
|
|
|
|
|
patch.dict(os.environ, {}, clear=True),
|
|
|
|
|
):
|
2026-03-10 02:49:03 -04:00
|
|
|
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:
|
[REF] format : passer l'outillage et les tests sous ruff
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
2026-09-24 13:30:31 -04:00
|
|
|
with (
|
|
|
|
|
patch.object(todo_i18n, "ENV_VAR_FILE", f.name),
|
|
|
|
|
patch.dict(os.environ, {}, clear=True),
|
|
|
|
|
):
|
2026-03-10 02:49:03 -04:00
|
|
|
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):
|
2026-08-10 03:10:50 -04:00
|
|
|
# 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")
|
2026-03-10 02:49:03 -04:00
|
|
|
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:
|
2026-08-10 03:10:50 -04:00
|
|
|
with patch.object(todo_i18n, "ENV_VAR_FILE", f.name):
|
2026-03-10 02:49:03 -04:00
|
|
|
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:
|
2026-08-10 03:10:50 -04:00
|
|
|
with patch.object(todo_i18n, "ENV_VAR_FILE", f.name):
|
2026-03-10 02:49:03 -04:00
|
|
|
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,
|
2026-03-10 03:45:11 -04:00
|
|
|
"ENV_VAR_FILE",
|
2026-03-10 02:49:03 -04:00
|
|
|
"/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:
|
2026-08-10 03:10:50 -04:00
|
|
|
with patch.object(todo_i18n, "ENV_VAR_FILE", f.name):
|
2026-03-10 02:49:03 -04:00
|
|
|
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:
|
2026-08-10 03:10:50 -04:00
|
|
|
with patch.object(todo_i18n, "ENV_VAR_FILE", f.name):
|
2026-03-10 02:49:03 -04:00
|
|
|
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,
|
2026-03-10 03:45:11 -04:00
|
|
|
"ENV_VAR_FILE",
|
2026-03-10 02:49:03 -04:00
|
|
|
"/nonexistent/path",
|
|
|
|
|
):
|
|
|
|
|
result = todo_i18n.lang_is_configured()
|
|
|
|
|
self.assertFalse(result)
|
|
|
|
|
|
|
|
|
|
|
[FIX] i18n : retirer trois clés déclarées deux fois, et l'interdire
Trois clés étaient déclarées deux fois dans un littéral de trois mille
entrées. Rien n'avertit et rien ne lève : la seconde gagne, et le prix s'est
déjà payé en étiquette de menu, la traduction lue n'étant pas celle qu'on
venait d'écrire au premier endroit. Les trois portaient la même traduction des
deux côtés, donc aucun écran ne change ; c'est le piège qui part.
Le contrôle passe de « pas plus de trois » à « aucune », et il vit désormais
avec les tests d'internationalisation plutôt qu'avec ceux d'un menu. Il lit
l'ARBRE : une fois le dictionnaire construit, le doublon a déjà disparu et il
n'y a plus rien à demander.
--- EN ---
Three keys were declared twice in a literal of three thousand entries.
Nothing warns and nothing raises: the second wins, and the price was already
paid on a menu label, the translation read not being the one just written at
the first place. All three carried the same translation on both sides, so no
screen changes; what goes is the trap.
The check moves from "no more than three" to "none at all", and it now lives
with the internationalisation tests rather than with a menu's. It reads the
TREE: once the dictionary is built the duplicate is already gone, and there is
nothing left to ask.
Assisted-by: Claude Opus 5
2026-09-09 04:44:19 -04:00
|
|
|
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))
|
|
|
|
|
|
|
|
|
|
|
[ADD] cache qemu : messages du binaire en anglais ou en français
Le binaire ne parlait que français alors que le menu suit EL_LANG : un
opérateur anglophone lisait un statut qu'il ne comprenait pas. Chaque message
humain passe par T(), dont la clé EST le texte français, et un catalogue
anglais le traduit ; la langue vient de --lang, puis d'EL_LANG, le français
restant le défaut. Le menu demande le français pour ce qu'il RELIT, ses
motifs ne connaissant que cette langue. Un test refuse un message sans
traduction, une traduction orpheline et un verbe de format qui diverge.
--- EN ---
The binary spoke French only while the menu follows EL_LANG: an English
reader got a status he could not read. Every human message goes through T(),
whose key IS the French text, and an English catalogue translates it; the
language comes from --lang, then EL_LANG, French remaining the default. The
menu asks for French for what it READS BACK, its patterns knowing that
language alone. A test refuses a message without translation, an orphan
translation, and a format verb that drifts.
Assisted-by: Claude Opus 5
2026-09-16 02:59:01 -04:00
|
|
|
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")
|
|
|
|
|
|
|
|
|
|
|
2026-03-10 02:49:03 -04:00
|
|
|
if __name__ == "__main__":
|
|
|
|
|
unittest.main()
|