"""Issue GitHub #6 (2026-09-13) : les messages imprimés par les commandes (download, login, merge...) restaient tout en français, contrairement à la page web qui suit déjà la langue choisie. `runtime.traduire()` leur donne le même mécanisme bilingue que LIBELLES dans tray.py, indexé par `runtime.lire_langue()` (le fichier `blink_langue.txt` laissé par le dernier chargement de la page). blink_auth.py, blink_cli.py (en entier), blink_models.py, blink_engine.py, merge_daily.py, maj.py et autostart.py sont les modules convertis jusqu'ici ; tous sauf blink_auth.py et autostart.py (aucune collision avec `_`, alias `_()` gardé) nomment leur alias local `msg()` plutôt que `_()` puisqu'ils utilisent déjà `_` comme variable jetable ailleurs (`for _, p in lances`, etc.).""" from __future__ import annotations import contextlib import io import os import sys import tempfile import unittest import zipfile from pathlib import Path from unittest import mock import runtime import blink_auth import blink_cli import blink_models import blink_engine import merge_daily import maj import autostart import smoketest import watch import serve class TestTraduireLibelles(unittest.TestCase): def setUp(self): self._dossier = tempfile.TemporaryDirectory(prefix="blink_i18n_test_") self._ancien_home = os.environ.get("BLINK_HOME") os.environ["BLINK_HOME"] = self._dossier.name def tearDown(self): if self._ancien_home is None: os.environ.pop("BLINK_HOME", None) else: os.environ["BLINK_HOME"] = self._ancien_home self._dossier.cleanup() def _regler_langue(self, code: str) -> None: (Path(self._dossier.name) / runtime.LANGUE).write_text(code, encoding="utf-8") def test_defaut_francais_sans_fichier_langue(self): self.assertEqual(runtime.lire_langue(), "fr") self.assertEqual(blink_auth._("session_invalide"), "La session enregistrée n'est plus valide.") def test_bascule_en_anglais(self): self._regler_langue("en") self.assertEqual(blink_auth._("session_invalide"), "The saved session is no longer valid.") def test_formatage_avec_valeurs(self): self._regler_langue("en") self.assertEqual(blink_auth._("code_refuse", remaining=2), "Code rejected. 2 attempt(s) left.") self._regler_langue("fr") self.assertEqual(blink_auth._("code_refuse", remaining=2), "Code refusé. 2 tentative(s) restante(s).") def test_toutes_les_cles_existent_dans_les_deux_langues(self): self.assertEqual(set(blink_auth.LIBELLES["fr"]), set(blink_auth.LIBELLES["en"])) def test_blink_cli_toutes_les_cles_existent_dans_les_deux_langues(self): self.assertEqual(set(blink_cli.LIBELLES["fr"]), set(blink_cli.LIBELLES["en"])) def test_blink_cli_msg_bascule_et_formate(self): self._regler_langue("en") self.assertEqual( blink_cli.msg("sync_module_ligne", nom="Garage", sync_id=1, network_id=2), "- Garage (ID 1, network 2)") self._regler_langue("fr") self.assertEqual( blink_cli.msg("sync_module_ligne", nom="Garage", sync_id=1, network_id=2), "- Garage (ID 1, réseau 2)") def test_blink_cli_pluriel_arrete_verbe(self): self._regler_langue("en") self.assertEqual(blink_cli.msg("arrete_verbe_code", verbe="download", code=1), "Stopped: download (code 1)") self.assertEqual(blink_cli.msg("arrete_verbe_normal", verbe="download"), "Stopped: download (normal exit)") self._regler_langue("fr") self.assertEqual(blink_cli.msg("arrete_verbe_normal", verbe="download"), "Arrêté : download (fin normale)") def test_blink_models_toutes_les_cles_existent_dans_les_deux_langues(self): self.assertEqual(set(blink_models.LIBELLES["fr"]), set(blink_models.LIBELLES["en"])) def test_blink_models_human_size_suit_la_langue(self): self._regler_langue("fr") self.assertEqual(blink_models.human_size(1024), "1.0 Kio") self._regler_langue("en") self.assertEqual(blink_models.human_size(1024), "1.0 KiB") def test_blink_models_categorie_bascule(self): self._regler_langue("en") self.assertEqual(blink_models.msg("cat_reseau"), "network") self._regler_langue("fr") self.assertEqual(blink_models.msg("cat_reseau"), "réseau") def test_blink_engine_toutes_les_cles_existent_dans_les_deux_langues(self): self.assertEqual(set(blink_engine.LIBELLES["fr"]), set(blink_engine.LIBELLES["en"])) def test_blink_engine_msg_bascule_et_formate(self): self._regler_langue("en") self.assertEqual(blink_engine.msg("nouveaux_clips", n=3), "\nNew clips: 3") self._regler_langue("fr") self.assertEqual(blink_engine.msg("nouveaux_clips", n=3), "\nNouveaux clips : 3") def test_blink_engine_notification_singulier_pluriel(self): self._regler_langue("en") self.assertEqual(blink_engine.msg("notif_corps_singulier", n=1), "1 new clip downloaded. Click to open.") self.assertEqual(blink_engine.msg("notif_corps_pluriel", n=3), "3 new clips downloaded. Click to open.") def test_merge_daily_toutes_les_cles_existent_dans_les_deux_langues(self): self.assertEqual(set(merge_daily.LIBELLES["fr"]), set(merge_daily.LIBELLES["en"])) def test_merge_daily_msg_bascule_et_formate(self): self._regler_langue("en") self.assertEqual(merge_daily.msg("mp4_invalide"), "FFmpeg did not produce a valid MP4") self.assertEqual( merge_daily.msg("jour_deja_a_jour", nom="Garage_2026-09-01.mp4", clips=5), "Already up to date: Garage_2026-09-01.mp4 (5 clip(s))") self._regler_langue("fr") self.assertEqual(merge_daily.msg("mp4_invalide"), "FFmpeg n'a pas produit un MP4 valide") def test_merge_daily_labels_periode(self): self._regler_langue("en") self.assertEqual(merge_daily.msg("label_hebdomadaires"), "Weekly") self.assertEqual(merge_daily.msg("label_mensuelles"), "Monthly") self._regler_langue("fr") self.assertEqual(merge_daily.msg("label_hebdomadaires"), "Hebdomadaires") self.assertEqual(merge_daily.msg("label_mensuelles"), "Mensuelles") def test_maj_toutes_les_cles_existent_dans_les_deux_langues(self): self.assertEqual(set(maj.LIBELLES["fr"]), set(maj.LIBELLES["en"])) def test_maj_msg_bascule_et_formate(self): self._regler_langue("en") # Les refus d'archive viennent du commun (clé, valeurs) et redeviennent, # par maj._en_langue_courante, l'OSError au texte de la langue de la page. with tempfile.TemporaryDirectory() as dossier: archive = Path(dossier) / "mauvaise.zip" with zipfile.ZipFile(archive, "w") as sortie: sortie.writestr("../../etc/passwd", b"x") with self.assertRaises(OSError) as erreur: maj._extraire(archive, Path(dossier) / "contenu") self.assertEqual(str(erreur.exception), "Dangerous path in the archive: '../../etc/passwd'") self._regler_langue("fr") with self.assertRaises(OSError) as erreur: maj._extraire(archive, Path(dossier) / "contenu2") self.assertEqual(str(erreur.exception), "Chemin dangereux dans l'archive : '../../etc/passwd'") self._regler_langue("en") self.assertEqual(maj.msg("deja_a_jour", version="0.12.20"), "blink2video 0.12.20 is up to date.") self._regler_langue("fr") self.assertEqual(maj.msg("deja_a_jour", version="0.12.20"), "blink2video 0.12.20 est à jour.") def test_autostart_toutes_les_cles_existent_dans_les_deux_langues(self): self.assertEqual(set(autostart.LIBELLES["fr"]), set(autostart.LIBELLES["en"])) def test_autostart_bascule_et_formate(self): self._regler_langue("en") self.assertEqual(autostart._("demarrage_retire", cible="foo.lnk"), "Autostart removed: foo.lnk") self.assertEqual(autostart._("intitule_compte", intitule="Startup shortcuts", n=2), "Startup shortcuts: 2") self._regler_langue("fr") self.assertEqual(autostart._("demarrage_deja_absent", cible="foo.lnk"), "Démarrage automatique déjà absent : foo.lnk") def test_smoketest_toutes_les_cles_existent_dans_les_deux_langues(self): self.assertEqual(set(smoketest.LIBELLES["fr"]), set(smoketest.LIBELLES["en"])) def test_smoketest_bascule_et_formate(self): self._regler_langue("en") self.assertEqual(smoketest._("clips_compte_detail", n=5, m=1), "5 clip(s), 1 excluded") self.assertEqual(smoketest._("marque_echec"), "FAIL ") self._regler_langue("fr") self.assertEqual(smoketest._("clips_compte_detail", n=5, m=1), "5 clip(s) dont 1 écarté(s)") self.assertEqual(smoketest._("marque_echec"), "ECHEC") def test_watch_toutes_les_cles_existent_dans_les_deux_langues(self): self.assertEqual(set(watch.MESSAGES["fr"]), set(watch.MESSAGES["en"])) def test_watch_msg_deja_existant_continue_de_fonctionner(self): # _msg() deleguait deja lui-meme a MESSAGES[lire_langue()] avant ce # chantier ; verifie que le refactor vers runtime.traduire() ne casse # pas les cles deja en place (notifications d'alerte reelles). self._regler_langue("en") self.assertEqual(watch._msg("camera_hors_ligne", nom="Garage"), 'Camera "Garage" offline.') self._regler_langue("fr") self.assertEqual(watch._msg("camera_hors_ligne", nom="Garage"), "Caméra « Garage » hors ligne.") def test_watch_nouvelles_cles_bascule_et_formate(self): self._regler_langue("en") self.assertEqual(watch._msg("alerte_ligne", ligne="Garage hors ligne."), "ALERT Garage hors ligne.") self.assertEqual(watch._msg("aucune"), "none") self._regler_langue("fr") self.assertEqual(watch._msg("cameras_en_sourdine"), "Caméras en sourdine :") def test_serve_toutes_les_cles_existent_dans_les_deux_langues(self): self.assertEqual(set(serve.LIBELLES["fr"]), set(serve.LIBELLES["en"])) def test_serve_msg_bascule_et_formate(self): self._regler_langue("en") self.assertEqual(serve.msg("erreur_generique", erreur="ffmpeg introuvable"), "Error: ffmpeg introuvable") self.assertEqual( serve.msg("echec_ecoute_port", port=8765, erreur="port deja utilise"), "Could not listen on port 8765: port deja utilise") self._regler_langue("fr") self.assertEqual(serve.msg("erreur_generique", erreur="ffmpeg introuvable"), "Erreur : ffmpeg introuvable") def test_maj_restauration_incomplete_garde_le_message_traduit(self): self._regler_langue("en") try: raise maj.RestaurationIncomplete( maj.msg("maj_precedente_non_finalisee", marqueur="MARQUEUR")) except maj.RestaurationIncomplete as erreur: # Issue #95 : le message dit aussi quel fichier supprimer (le marqueur, nomme). self.assertTrue(str(erreur).startswith( "Previous update not finalized: backups and preparation kept.")) self.assertIn("delete the file MARQUEUR", str(erreur)) def test_issue_95_une_mise_a_jour_interrompue_est_reprise_sans_rien_supprimer(self): # « I did a manual install of the latest version but now every time I try to update I # get "Previous update not finalized". What do I need to delete? » : plus rien. Le # nettoyage ne purge pas la sauvegarde tant que la permutation interrompue n'est pas # reprise, et la permutation suivante la reprend sans toucher a la sauvegarde. self._regler_langue("en") with tempfile.TemporaryDirectory() as dossier: racine = Path(dossier) installe, neuf = racine / "installe", racine / "neuf" installe.mkdir() (neuf / "_internal").mkdir(parents=True) (neuf / "blink2video.exe").write_bytes(b"neuf") (neuf / "_internal" / "lib").write_bytes(b"lib-neuve") (installe / maj.MARQUEUR_PERMUTATION).write_text("{}", encoding="utf-8") (installe / "blink2video.exe.ancien").write_bytes(b"ancienne version") (installe / "blink2video.exe").write_bytes(b"a moitie") maj._nettoyer(installe) # aucun refus self.assertEqual((installe / "blink2video.exe.ancien").read_bytes(), b"ancienne version") with contextlib.redirect_stdout(io.StringIO()): self.assertTrue(maj._permuter(neuf, installe)) self.assertEqual((installe / "blink2video.exe").read_bytes(), b"neuf") self.assertFalse((installe / maj.MARQUEUR_PERMUTATION).exists()) maj._nettoyer(installe) # menage de la suivante self.assertFalse((installe / "blink2video.exe.ancien").exists()) self.assertFalse((installe / "blink2video.exe.reprise").exists()) # Issue #16 : les messages propres à runtime.py (bootstrap, --loop) étaient # restés hors traduction. def test_runtime_toutes_les_cles_existent_dans_les_deux_langues(self): self.assertEqual(set(runtime._LIBELLES_RUNTIME["fr"]), set(runtime._LIBELLES_RUNTIME["en"])) def test_runtime_bascule_et_formate(self): self._regler_langue("en") self.assertEqual(runtime._msg("repetition", minutes=5), "Repeating every 5 min. Ctrl+C to stop.") def test_aide_principale_liste_les_verbes_dans_la_langue_de_la_page(self): # parse_args() lisait toujours Verbe.fr : l'aide principale restait en # français même en anglais, alors que Verbe.en existait déjà. self._regler_langue("en") sortie = io.StringIO() with mock.patch.object(sys, "argv", ["blink2video", "--help"]), \ contextlib.redirect_stdout(sortie), self.assertRaises(SystemExit): blink_cli.parse_args() aide = sortie.getvalue() self.assertIn(runtime.VERBES["login"].en, aide) self.assertNotIn(runtime.VERBES["login"].fr, aide) self.assertIn("Getting started:", aide) if __name__ == "__main__": unittest.main()