Ctrl K
ComunidadGuías…

test_messages_traduits.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""Aucun message en français écrit en dur hors des tables de traduction.

Issue #121 : la page était traduite, mais une centaine de messages construits
par le serveur (« module occupé », « session Blink occupée »...) restaient en
français pour tout le monde, et justement quand quelque chose allait mal.
Ils passent tous par une table bilingue {"fr": ..., "en": ...} et msg() ;
ce test empêche qu'un nouveau s'écrive de nouveau en dur.

La détection est volontairement simple : une lettre accentuée, ou un petit
mot très français dans une phrase. Elle ne voit pas tout (« Fusion » passe),
mais elle attrape l'immense majorité des cas réels, qui sont des phrases.
"""
import ast
import re
import string
import unittest
from pathlib import Path

RACINE = Path(__file__).resolve().parent

# Outils de développement et de CI : leur sortie ne s'adresse qu'au mainteneur.
OUTILS = {
    "build.py", "build_blinkpy_win7.py", "build_support.py", "build_xr_tester.py",
    "deploy.py", "docs.py", "smoketest.py", "tests.py", "verify_win7_bundle.py",
    "webrtc_probe.py",
}

# Constantes déjà bilingues par construction, ou traduites ailleurs : la page
# web a son propre dictionnaire (I18N), et VERBES porte une paire fr/en par
# verbe (Verbe(groupe, fr, en)).
CONSTANTES_EXCLUES = {"PAGE", "VERBES"}

# Chaînes françaises légitimes : ce ne sont pas des messages.
EXCEPTIONS = {
    # Reconnaît un message d'erreur de blinkpy dans l'une ou l'autre langue.
    ("diagnostic_xr.py", "pas compatible"),
    ("diagnostic_xr.py", "pas activ"),
    ("diagnostic_xr.py", "pas actif"),
    # Contenu d'un fichier marqueur que personne ne lit, seule sa présence compte.
    ("maj.py", "Répertoire temporaire de mise à jour.\n"),
}

# Les guillemets « » ne s'écrivent qu'en français (l'anglais prend “ ”).
ACCENTS = re.compile(r"[éèêëàâùûüçôöîïœÉÈÊÀÂÇÔΫ»]")
MOTS = re.compile(r"\b(le|la|les|de|des|du|une|est|pas|aucun|aucune|sans|avec|pour|dans|"
                  r"inconnu|inconnue|invalide|introuvable|absent|absente)\b",
                  re.IGNORECASE)


def _modules():
    return sorted(chemin for chemin in RACINE.glob("*.py")
                  if not chemin.name.startswith("test_") and chemin.name not in OUTILS)


def _tables_bilingues(arbre):
    """Dictionnaires littéraux dont les clés sont exactement "fr" et "en"."""
    for noeud in ast.walk(arbre):
        if isinstance(noeud, ast.Dict):
            cles = {cle.value for cle in noeud.keys if isinstance(cle, ast.Constant)}
            if cles == {"fr", "en"} and len(noeud.keys) == 2:
                yield noeud


def _a_ignorer(arbre):
    ignores = set()
    for noeud in _tables_bilingues(arbre):
        ignores.update(id(sous) for sous in ast.walk(noeud))
    for noeud in ast.walk(arbre):
        if isinstance(noeud, ast.Assign) and any(
                isinstance(cible, ast.Name) and cible.id in CONSTANTES_EXCLUES
                for cible in noeud.targets):
            ignores.update(id(sous) for sous in ast.walk(noeud))
        # Docstrings et chaînes isolées (commentaires en forme de chaîne).
        if isinstance(noeud, ast.Expr) and isinstance(noeud.value, ast.Constant):
            ignores.add(id(noeud.value))
    return ignores


def _francais(texte: str) -> bool:
    # Un caractère isolé (table de translittération des noms de caméra,
    # issue #55) n'est jamais un message.
    if len(texte.strip()) < 3:
        return False
    return bool(ACCENTS.search(texte)) or (" " in texte.strip() and bool(MOTS.search(texte)))


def _champs(modele: str) -> set:
    return {nom for _, nom, _, _ in string.Formatter().parse(modele) if nom}


class MessagesTraduits(unittest.TestCase):
    def test_aucun_message_francais_en_dur(self):
        trouves = []
        for chemin in _modules():
            arbre = ast.parse(chemin.read_text(encoding="utf-8"))
            ignores = _a_ignorer(arbre)
            for noeud in ast.walk(arbre):
                if (isinstance(noeud, ast.Constant) and isinstance(noeud.value, str)
                        and id(noeud) not in ignores and _francais(noeud.value)
                        and (chemin.name, noeud.value) not in EXCEPTIONS):
                    trouves.append(f"{chemin.name}:{noeud.lineno}: {noeud.value!r}")
        self.assertEqual(trouves, [], "\nMessages à passer par une table fr/en et msg() :\n"
                         + "\n".join(trouves))

    def test_tables_completes_dans_les_deux_langues(self):
        """Chaque clé existe en français et en anglais, avec les mêmes
        {valeurs} : une traduction qui en oublie une lèverait KeyError au
        moment précis où le message doit s'afficher."""
        ecarts = []
        for chemin in _modules():
            arbre = ast.parse(chemin.read_text(encoding="utf-8"))
            for table in _tables_bilingues(arbre):
                langues = {}
                for cle, valeur in zip(table.keys, table.values):
                    if not isinstance(valeur, ast.Dict):
                        continue
                    langues[cle.value] = {
                        k.value: v.value for k, v in zip(valeur.keys, valeur.values)
                        if isinstance(k, ast.Constant) and isinstance(v, ast.Constant)
                        and isinstance(v.value, str)}
                if set(langues) != {"fr", "en"}:
                    continue
                fr, en = langues["fr"], langues["en"]
                for cle in sorted(set(fr) ^ set(en)):
                    ecarts.append(f"{chemin.name}:{table.lineno}: « {cle} » dans une seule langue")
                for cle in sorted(set(fr) & set(en)):
                    if _champs(fr[cle]) != _champs(en[cle]):
                        ecarts.append(f"{chemin.name}:{table.lineno}: « {cle} » "
                                      f"{sorted(_champs(fr[cle]))} != {sorted(_champs(en[cle]))}")
        self.assertEqual(ecarts, [], "\n" + "\n".join(ecarts))


if __name__ == "__main__":
    unittest.main()