Merge branch 'stage/garde_fous_i18n'

[ADD] garde-fous : refuser une clé i18n double, borner le guide à 80

3 commits. Deux défauts qui ne se signalent pas d'eux-mêmes, et que rien
n'empêchait de revenir.
Une clé de traduction écrite deux fois fait afficher à un menu la traduction
d'un autre : Python bâtit un dictionnaire littéral de haut en bas et garde la
dernière, sans un mot. Le contrôle lit le fichier par son ARBRE, la collision
pouvant s'écrire sur une seule ligne où une expression régulière ne la voit
pas. Vert sur l'état actuel : aucune clé n'est en double aujourd'hui.
Le guide de connexion tenait dans 80 colonnes nu et en rendait 103 une fois
la VM équipée, là où l'épreuve de largeur ne regardait que le guide nu. C'est
la mise en page qui porte la règle désormais. 6706 tests.

--- EN ---

3 commits. Two defects that do not report themselves, and that nothing
prevented from coming back.
A translation key written twice makes one menu show another's translation:
Python builds a literal dictionary top to bottom and keeps the last one,
without a word. The check reads the file by its TREE, the collision being
writable on a single line where a regular expression does not see it. Green
on the current state: no key is duplicated today.
The connection guide fitted in 80 columns bare and rendered 103 once the VM
was equipped, where the width check looked only at the bare guide. The layout
now carries the rule. 6706 tests.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-09-18 03:38:01 -04:00
commit d13fad3cce
6 changed files with 217 additions and 15 deletions

View file

@ -226,6 +226,7 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- A download cache mirror refused for lack of space names its threshold and its measurement. It echoed a field every ordinary caller leaves at zero — « less than 0 B free on disk » announces no threshold and does not say what was measured
- A downloaded image is checked against the sum its publisher ships, for every distribution that publishes one and WITHOUT asking. The check existed behind a flag and for Ubuntu only, so the other images arrived with nothing looking at them. Six now enter, read off the repositories rather than guessed: Debian publishes sha512 where everything else is sha256, the RHEL families name the file « CHECKSUM », Rocky writes the BSD form, and Arch and openSUSE ship a sum per image. An unreachable sums file no longer stops a deployment — that is an availability failure — while a mismatch still stops everything and removes the image
- The locale a deployed VM is given applies on NixOS. cloud-init applies it through locale-gen and update-locale, absent there, and the VM kept the distribution's default — « fr_CA.UTF-8 » asked for, « en_US.UTF-8 » obtained. Both it and the timezone are declared by the module now, and neither is imposed on a NixOS one already had
- The connection guide fits an 80-column terminal even on a VM carrying its tools and a desktop, where it rendered 103 columns wide: the frame overflowed, the terminal wrapped wherever it liked, and the two-column alignment — the only thing making the guide readable at a glance — was lost. A gloss now wraps under its column, and moves below its command when that command leaves no room. The layout carries the rule, not the length of the texts, which would have held only until the next tool
<!-- [fr] -->
@ -280,6 +281,7 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- Un miroir du cache de téléchargement refusé faute de place nomme son seuil et sa mesure. Il reprenait un champ que tout appelant ordinaire laisse à zéro — « moins de 0 o libres sur le disque » n'annonce aucun seuil et ne dit pas ce qui a été mesuré
- Une image téléchargée est vérifiée contre la somme que son éditeur publie, pour toute distribution qui en publie une et SANS le demander. La vérification existait sous un drapeau et pour Ubuntu seulement, si bien que les autres images arrivaient sans que rien ne les regarde. Six y entrent, relevées sur les dépôts plutôt que devinées : Debian publie du sha512 quand tout le reste est en sha256, les familles RHEL nomment le fichier « CHECKSUM », Rocky l'écrit en forme BSD, et Arch comme openSUSE posent une somme par image. Un fichier de sommes injoignable n'arrête plus un déploiement — c'est une panne de disponibilité — quand un écart arrête toujours tout et supprime l'image
- La locale qu'une VM déployée reçoit s'applique sur NixOS. cloud-init l'applique par locale-gen et update-locale, absents là-bas, et la VM gardait le défaut de la distribution — « fr_CA.UTF-8 » demandé, « en_US.UTF-8 » obtenu. Elle et le fuseau sont désormais déclarés par le module, et ni l'un ni l'autre n'est imposé à une NixOS qu'on avait déjà
- Le guide de connexion tient dans un terminal de 80 colonnes même sur une VM qui porte ses outils et un bureau, là où il en rendait 103 : le cadre débordait, le terminal repliait où il voulait, et l'alignement en deux colonnes — seule chose qui rend le guide lisible d'un coup d'œil — se perdait. La glose se replie désormais sous sa colonne, et passe sous sa commande quand celle-ci ne laisse plus de quoi écrire. C'est la mise en page qui porte la règle, non la longueur des textes, qui n'aurait tenu que jusqu'au prochain outil
<!-- [en] -->
## Removed

View file

@ -130,6 +130,7 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- Un miroir du cache de téléchargement refusé faute de place nomme son seuil et sa mesure. Il reprenait un champ que tout appelant ordinaire laisse à zéro — « moins de 0 o libres sur le disque » n'annonce aucun seuil et ne dit pas ce qui a été mesuré
- Une image téléchargée est vérifiée contre la somme que son éditeur publie, pour toute distribution qui en publie une et SANS le demander. La vérification existait sous un drapeau et pour Ubuntu seulement, si bien que les autres images arrivaient sans que rien ne les regarde. Six y entrent, relevées sur les dépôts plutôt que devinées : Debian publie du sha512 quand tout le reste est en sha256, les familles RHEL nomment le fichier « CHECKSUM », Rocky l'écrit en forme BSD, et Arch comme openSUSE posent une somme par image. Un fichier de sommes injoignable n'arrête plus un déploiement — c'est une panne de disponibilité — quand un écart arrête toujours tout et supprime l'image
- La locale qu'une VM déployée reçoit s'applique sur NixOS. cloud-init l'applique par locale-gen et update-locale, absents là-bas, et la VM gardait le défaut de la distribution — « fr_CA.UTF-8 » demandé, « en_US.UTF-8 » obtenu. Elle et le fuseau sont désormais déclarés par le module, et ni l'un ni l'autre n'est imposé à une NixOS qu'on avait déjà
- Le guide de connexion tient dans un terminal de 80 colonnes même sur une VM qui porte ses outils et un bureau, là où il en rendait 103 : le cadre débordait, le terminal repliait où il voulait, et l'alignement en deux colonnes — seule chose qui rend le guide lisible d'un coup d'œil — se perdait. La glose se replie désormais sous sa colonne, et passe sous sa commande quand celle-ci ne laisse plus de quoi écrire. C'est la mise en page qui porte la règle, non la longueur des textes, qui n'aurait tenu que jusqu'au prochain outil
## Retiré

View file

@ -130,6 +130,7 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- A download cache mirror refused for lack of space names its threshold and its measurement. It echoed a field every ordinary caller leaves at zero — « less than 0 B free on disk » announces no threshold and does not say what was measured
- A downloaded image is checked against the sum its publisher ships, for every distribution that publishes one and WITHOUT asking. The check existed behind a flag and for Ubuntu only, so the other images arrived with nothing looking at them. Six now enter, read off the repositories rather than guessed: Debian publishes sha512 where everything else is sha256, the RHEL families name the file « CHECKSUM », Rocky writes the BSD form, and Arch and openSUSE ship a sum per image. An unreachable sums file no longer stops a deployment — that is an availability failure — while a mismatch still stops everything and removes the image
- The locale a deployed VM is given applies on NixOS. cloud-init applies it through locale-gen and update-locale, absent there, and the VM kept the distribution's default — « fr_CA.UTF-8 » asked for, « en_US.UTF-8 » obtained. Both it and the timezone are declared by the module now, and neither is imposed on a NixOS one already had
- The connection guide fits an 80-column terminal even on a VM carrying its tools and a desktop, where it rendered 103 columns wide: the frame overflowed, the terminal wrapped wherever it liked, and the two-column alignment — the only thing making the guide readable at a glance — was lost. A gloss now wraps under its column, and moves below its command when that command leaves no room. The layout carries the rule, not the length of the texts, which would have held only until the next tool
## Removed

View file

@ -2785,10 +2785,50 @@ def motd_block(
"""
out = [f" {title}"]
for cmd, gloss_fr, gloss_en in rows:
out.append(f" {cmd.ljust(col)}{_pick((gloss_fr, gloss_en), lang)}")
out += _lignes_glose(cmd, _pick((gloss_fr, gloss_en), lang), col)
return out
# La largeur du cadre, bordure comprise. Un guide plus large qu'un terminal
# ordinaire se replie tout seul, n'importe où, et l'alignement en deux
# colonnes — la seule chose qui le rend lisible d'un coup d'œil — disparaît.
MOTD_MAX_WIDTH = 80
# Ce qui reste au contenu : build_motd ajoute quatre colonnes de cadre.
MOTD_TEXT_WIDTH = MOTD_MAX_WIDTH - 4
# En deçà, replier la glose en colonne donnerait des bribes de trois mots.
MOTD_GLOSE_MIN = 24
def _lignes_glose(cmd: str, glose: str, col: int) -> list[str]:
"""Les lignes d'UNE entrée, repliées pour tenir dans MOTD_MAX_WIDTH.
DEUX MISES EN PAGE, et la place restante décide. Tant qu'il reste de quoi
écrire, la glose garde sa colonne et ses suites s'alignent dessous :
c'est cet alignement qui fait lire un bloc d'un coup d'œil. Quand la
commande est longue au point qu'il ne reste plus rien, la garder
donnerait des bribes — la glose passe alors sous sa commande, indentée de
six, et retrouve toute la largeur.
L'INDENTATION APPARTIENT À CETTE FONCTION. La laisser à l'appelant, qui
alignait toute suite sur la tête, annulait le second cas : la glose
repliée repartait à la colonne d'où elle venait d'être chassée.
Une glose vide ne rend que la commande : le bloc du système en a.
"""
tete = f" {cmd.ljust(col)}"
if not glose.strip():
return [tete.rstrip()]
place = MOTD_TEXT_WIDTH - len(tete)
if place >= MOTD_GLOSE_MIN:
morceaux = textwrap.wrap(glose, place)
return [tete + morceaux[0]] + [
" " * len(tete) + m for m in morceaux[1:]
]
return [tete.rstrip()] + [
" " + m for m in textwrap.wrap(glose, MOTD_TEXT_WIDTH - 6)
]
def build_motd(
distro: str,
version: str,

View file

@ -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()

View file

@ -149,24 +149,38 @@ class TestMotdErplibreSection(unittest.TestCase):
class TestMotdLayout(unittest.TestCase):
def test_never_wider_than_a_standard_terminal(self):
"""Le guide ENTIER, outils et bureau compris.
L'épreuve ne portait que sur le guide nu, et laissait donc passer ce
qu'elle existait pour interdire : une VM équipée rendait 103 colonnes
— le cadre débordait, le terminal repliait où il voulait, et
l'alignement en deux colonnes, seule chose qui rend un guide lisible
d'un coup d'œil, disparaissait.
"""
outils = tuple(dq.TOOL_GUIDE)
self.assertTrue(outils, "catalogue d'outils vide")
for distro, version, arch in COMBOS:
for lang in ("fr", "en"):
for el_dir in ("", "~/git/erplibre", "/opt/erplibre"):
motd = dq.build_motd(
distro,
version,
arch,
lang,
el_dir,
"install_odoo_18" if el_dir else "",
"vim" if el_dir else "",
)
for line in motd.splitlines():
self.assertLessEqual(
len(line),
TERM_WIDTH,
f"{distro} {version} {lang} {el_dir} : {line}",
for equipee in (False, True):
motd = dq.build_motd(
distro,
version,
arch,
lang,
el_dir,
"install_odoo_18" if el_dir else "",
"vim" if el_dir else "",
desktop=equipee,
tools=outils if equipee else (),
)
for line in motd.splitlines():
self.assertLessEqual(
len(line),
TERM_WIDTH,
f"{distro} {version} {lang} {el_dir} "
f"outils={equipee} : {line}",
)
def test_the_frame_is_never_narrower_than_what_it_frames(self):
for distro, version, arch in COMBOS:
@ -666,5 +680,66 @@ class LesOutilsPosesSAnnoncent(unittest.TestCase):
self.assertEqual("nixanywhere", parts("arm64", False))
class LaGloseSeReplieAuLieuDeDeborder(unittest.TestCase):
"""Ce qui arrive à une entrée trop longue pour 80 colonnes.
Raccourcir les textes aurait tenu jusqu'au prochain outil ajouté ; c'est
la MISE EN PAGE qui porte désormais la règle, et elle vaut pour ce qui
n'est pas encore écrit.
"""
def _lignes(self, cmd, glose, col=None):
return dq._lignes_glose(cmd, glose, col if col else len(cmd) + 2)
def test_a_short_entry_stays_on_one_line(self):
"""Replier ce qui tient serait une régression à soi seul."""
lignes = self._lignes("make todo", "menu ERPLibre")
self.assertEqual(len(lignes), 1)
self.assertIn("make todo", lignes[0])
self.assertIn("menu ERPLibre", lignes[0])
def test_a_long_gloss_keeps_its_column(self):
"""La suite s'aligne sous la glose, pas sous la commande : c'est
l'alignement qui fait lire un bloc d'un coup d'œil."""
lignes = self._lignes("npm start", "a " * 40, col=20)
self.assertGreater(len(lignes), 1)
debut = lignes[0].index("a a")
for suite in lignes[1:]:
self.assertEqual(suite[:debut], " " * debut, suite)
self.assertTrue(suite[debut:].strip())
def test_a_command_that_leaves_no_room_pushes_the_gloss_under_it(self):
"""Au-delà d'un seuil, garder la colonne donnerait des bribes de
trois mots. La glose passe sous sa commande et retrouve la largeur."""
longue = "dbus-run-session -- gnome-extensions enable <uuid>"
lignes = self._lignes(longue, "activer depuis ssh")
self.assertEqual(lignes[0].strip(), longue)
self.assertEqual(lignes[1], " activer depuis ssh")
def test_an_empty_gloss_yields_the_command_alone(self):
"""Une entrée sans glose ne doit pas rendre une ligne d'espaces."""
lignes = self._lignes("hostname -I", "")
self.assertEqual(lignes, [" hostname -I"])
def test_no_produced_line_exceeds_the_budget(self):
"""L'invariant, sur des entrées fabriquées bien pires que celles du
catalogue : quoi qu'on lui donne, rien ne dépasse."""
for cmd, glose in (
("a", "z " * 80),
("x" * 60, "y " * 40),
("x" * 70, "court"),
("court", ""),
):
with self.subTest(cmd=cmd[:12]):
for ligne in self._lignes(cmd, glose):
self.assertLessEqual(len(ligne), dq.MOTD_TEXT_WIDTH, ligne)
def test_the_budget_leaves_room_for_the_frame(self):
"""build_motd ajoute quatre colonnes de cadre : un budget égal à la
largeur ferait déborder le cadre de ce qu'il encadre."""
self.assertEqual(dq.MOTD_TEXT_WIDTH, dq.MOTD_MAX_WIDTH - 4)
self.assertEqual(dq.MOTD_MAX_WIDTH, TERM_WIDTH)
if __name__ == "__main__":
unittest.main()