From 270aedd790779b9438e89645669c34da93747895 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 17 Sep 2026 09:31:44 -0400 Subject: [PATCH 1/3] =?UTF-8?q?[ADD]=20i18n=20:=20refuser=20une=20cl=C3=A9?= =?UTF-8?q?=20de=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() From ce1bf5f4d172f40291d894b657b8449227303d22 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 17 Sep 2026 09:31:44 -0400 Subject: [PATCH 2/3] =?UTF-8?q?[FIX]=20motd=20:=20replier=20la=20glose=20a?= =?UTF-8?q?u=20lieu=20de=20d=C3=A9border=20de=2080=20colonnes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le guide tenait dans 80 colonnes nu, et rendait 103 dès qu'une VM portait ses outils et un bureau. Le cadre débordait alors, 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. La mise en page porte désormais la règle, plutôt que la longueur des textes qui n'aurait tenu que jusqu'au prochain outil : la glose se replie sous sa colonne, et passe sous sa commande quand celle-ci ne laisse plus de quoi écrire. L'épreuve de largeur ne couvrait que le guide nu ; elle couvre le guide équipé, qui est celui où le débordement vivait. --- EN --- The guide fitted in 80 columns bare, and rendered 103 as soon as a VM carried its tools and a desktop. The frame then overflowed, the terminal wrapped wherever it liked, and the two-column alignment — the only thing making a guide readable at a glance — was gone. The layout now carries the rule, rather than the length of texts which would have held only until the next tool: a gloss wraps under its column, and moves below its command when that command leaves no room. The width check covered only the bare guide; it now covers the equipped one, which is where the overflow lived. Assisted-by: Claude Opus 5 --- script/qemu/deploy_qemu.py | 42 ++++++++++++++- test/test_qemu_motd.py | 103 ++++++++++++++++++++++++++++++++----- 2 files changed, 130 insertions(+), 15 deletions(-) diff --git a/script/qemu/deploy_qemu.py b/script/qemu/deploy_qemu.py index 75755bc..c7d13fc 100755 --- a/script/qemu/deploy_qemu.py +++ b/script/qemu/deploy_qemu.py @@ -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, diff --git a/test/test_qemu_motd.py b/test/test_qemu_motd.py index 3ecb272..ba16ef2 100644 --- a/test/test_qemu_motd.py +++ b/test/test_qemu_motd.py @@ -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 " + 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() From bd15982420afea81ba3bb7a208361aaaf3216860 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Fri, 18 Sep 2026 02:41:06 -0400 Subject: [PATCH 3/3] =?UTF-8?q?[UPD]=20changelog=20:=20le=20guide=20de=20c?= =?UTF-8?q?onnexion=20born=C3=A9=20=C3=A0=2080=20colonnes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ce que le guide fait désormais se voit à chaque ouverture de session sur une VM équipée : un lecteur qui choisit de mettre à jour doit le lire. Le garde-fou i18n n'a pas d'entrée : aucune clé n'est en double aujourd'hui, donc rien ne change pour qui utilise le produit. Il empêche un retour, il ne corrige pas un symptôme. --- EN --- What the guide now does shows at every login on an equipped VM: a reader choosing whether to upgrade has to read it. The i18n guard gets no entry: no key is duplicated today, so nothing changes for whoever uses the product. It prevents a return, it does not fix a symptom. Assisted-by: Claude Opus 5 --- CHANGELOG.base.md | 2 ++ CHANGELOG.fr.md | 1 + CHANGELOG.md | 1 + 3 files changed, 4 insertions(+) diff --git a/CHANGELOG.base.md b/CHANGELOG.base.md index 0561996..5e40af8 100644 --- a/CHANGELOG.base.md +++ b/CHANGELOG.base.md @@ -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 @@ -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 ## Removed diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index a03a037..17790e9 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -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é diff --git a/CHANGELOG.md b/CHANGELOG.md index 55fe4b0..24bed68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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