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
238 lines
7.2 KiB
Go
238 lines
7.2 KiB
Go
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
|
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
|
|
package main
|
|
|
|
import (
|
|
"go/ast"
|
|
"go/parser"
|
|
"go/token"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// Les tests lisent les messages en français, langue du code source : une
|
|
// langue héritée de l'environnement de qui les lance les ferait échouer sur
|
|
// une chaîne traduite.
|
|
func TestMain(m *testing.M) {
|
|
definirLangue(LangueFr)
|
|
os.Exit(m.Run())
|
|
}
|
|
|
|
// clesT rend le texte de chaque appel T("…") des fichiers du paquet, tests
|
|
// exclus, avec sa position. Un argument qui n'est pas un littéral est une
|
|
// erreur : le catalogue ne pourrait pas le traduire.
|
|
func clesT(t *testing.T) map[string]string {
|
|
t.Helper()
|
|
fichiers, err := filepath.Glob("*.go")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fs := token.NewFileSet()
|
|
cles := map[string]string{}
|
|
for _, f := range fichiers {
|
|
if strings.HasSuffix(f, "_test.go") {
|
|
continue
|
|
}
|
|
af, err := parser.ParseFile(fs, f, nil, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ast.Inspect(af, func(n ast.Node) bool {
|
|
c, ok := n.(*ast.CallExpr)
|
|
if !ok {
|
|
return true
|
|
}
|
|
id, ok := c.Fun.(*ast.Ident)
|
|
if !ok || id.Name != "T" || len(c.Args) != 1 {
|
|
return true
|
|
}
|
|
texte, ok := litteralT(c.Args[0])
|
|
pos := fs.Position(c.Pos()).String()
|
|
if !ok {
|
|
// Une conversion de valeur connue — T(string(gran)) — se
|
|
// vérifie par son propre test ; tout autre argument non
|
|
// littéral est refusé.
|
|
if conv, estConv := c.Args[0].(*ast.CallExpr); estConv {
|
|
if fn, ok := conv.Fun.(*ast.Ident); ok && fn.Name == "string" {
|
|
return true
|
|
}
|
|
}
|
|
t.Errorf("%s : T() sur un texte non littéral", pos)
|
|
return true
|
|
}
|
|
cles[texte] = pos
|
|
return true
|
|
})
|
|
}
|
|
return cles
|
|
}
|
|
|
|
func litteralT(e ast.Expr) (string, bool) {
|
|
switch v := e.(type) {
|
|
case *ast.BasicLit:
|
|
if v.Kind != token.STRING {
|
|
return "", false
|
|
}
|
|
s, err := strconv.Unquote(v.Value)
|
|
return s, err == nil
|
|
case *ast.BinaryExpr:
|
|
if v.Op != token.ADD {
|
|
return "", false
|
|
}
|
|
a, ok1 := litteralT(v.X)
|
|
b, ok2 := litteralT(v.Y)
|
|
return a + b, ok1 && ok2
|
|
case *ast.ParenExpr:
|
|
return litteralT(v.X)
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// Un message enveloppé sans traduction sortirait en français dans un binaire
|
|
// réglé en anglais, sans que rien ne le signale.
|
|
func TestChaqueMessageATraductionAnglaise(t *testing.T) {
|
|
cles := clesT(t)
|
|
if len(cles) < 100 {
|
|
t.Fatalf("%d appels T() trouvés : le contrôle ne lit pas le code", len(cles))
|
|
}
|
|
var manquants []string
|
|
for texte, pos := range cles {
|
|
if _, ok := anglais[texte]; !ok {
|
|
manquants = append(manquants, pos+" "+strconv.Quote(texte))
|
|
}
|
|
}
|
|
sort.Strings(manquants)
|
|
for _, m := range manquants {
|
|
t.Errorf("sans traduction : %s", m)
|
|
}
|
|
}
|
|
|
|
// Une entrée qu'aucun appel n'emploie est un message retiré, ou une clé qui
|
|
// ne correspond plus au texte réécrit : sa traduction ne sortirait jamais.
|
|
func TestAucuneTraductionOrpheline(t *testing.T) {
|
|
cles := clesT(t)
|
|
// Clés employées par conversion : les granularités affichées.
|
|
for _, g := range []Granularite{ParJour, ParSemaine, ParMois} {
|
|
cles[string(g)] = "granularité"
|
|
}
|
|
var orphelines []string
|
|
for texte := range anglais {
|
|
if _, ok := cles[texte]; !ok {
|
|
orphelines = append(orphelines, strconv.Quote(texte))
|
|
}
|
|
}
|
|
sort.Strings(orphelines)
|
|
for _, o := range orphelines {
|
|
t.Errorf("traduction sans appel : %s", o)
|
|
}
|
|
}
|
|
|
|
var verbeFormat = regexp.MustCompile(`%[-+# 0]*(?:\d+|\*)?(?:\.(?:\d+|\*))?[a-zA-Z%]`)
|
|
|
|
// Une traduction qui perd, ajoute ou déplace un verbe de format décale les
|
|
// valeurs : « %d VM » rendu « %s VM » écrit « %!s(int=3) ».
|
|
func TestLesTraductionsGardentLeursVerbes(t *testing.T) {
|
|
for fr, en := range anglais {
|
|
a, b := verbeFormat.FindAllString(fr, -1), verbeFormat.FindAllString(en, -1)
|
|
if strings.Join(a, " ") != strings.Join(b, " ") {
|
|
t.Errorf("verbes %v ≠ %v pour %q", a, b, fr)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLaLangueSeResoutDansLOrdre(t *testing.T) {
|
|
env := func(v string) func(string) string {
|
|
return func(k string) string {
|
|
if k == "EL_LANG" {
|
|
return v
|
|
}
|
|
return ""
|
|
}
|
|
}
|
|
cas := []struct {
|
|
nom string
|
|
args []string
|
|
elLang string
|
|
veut string
|
|
}{
|
|
{"défaut", nil, "", LangueFr},
|
|
{"EL_LANG anglais", nil, "en", LangueEn},
|
|
{"EL_LANG régional", nil, "en_CA.UTF-8", LangueEn},
|
|
{"langue inconnue", nil, "de", LangueFr},
|
|
{"--lang espacé", []string{"--status", "--lang", "en"}, "", LangueEn},
|
|
{"-lang=", []string{"-lang=en"}, "", LangueEn},
|
|
{"--lang= l'emporte sur EL_LANG", []string{"--lang=fr"}, "en", LangueFr},
|
|
{"--lang sans valeur", []string{"--lang"}, "en", LangueEn},
|
|
{"après --, ce n'est plus une option", []string{"--", "--lang", "en"}, "", LangueFr},
|
|
{"une valeur n'est pas une option", []string{"--age-by", "lang"}, "", LangueFr},
|
|
}
|
|
for _, c := range cas {
|
|
if got := langueDemandee(c.args, env(c.elLang)); got != c.veut {
|
|
t.Errorf("%s : %q, attendu %q", c.nom, got, c.veut)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestUnMessageSortEnAnglais(t *testing.T) {
|
|
defer definirLangue(LangueFr)
|
|
definirLangue(LangueEn)
|
|
if got := T("écoutes : http %d, tls %d"); got != "listeners: http %d, tls %d" {
|
|
t.Errorf("anglais : %q", got)
|
|
}
|
|
if got := T("texte que le catalogue ignore"); got != "texte que le catalogue ignore" {
|
|
t.Errorf("un texte inconnu doit sortir tel quel, pas %q", got)
|
|
}
|
|
definirLangue(LangueFr)
|
|
if got := T("écoutes : http %d, tls %d"); got != "écoutes : http %d, tls %d" {
|
|
t.Errorf("français : %q", got)
|
|
}
|
|
}
|
|
|
|
// Le Python du menu lit ces libellés de --status et cette phrase du journal
|
|
// dans les DEUX langues : une traduction qui s'en écarte le rend aveugle.
|
|
func TestLesLibellesLusParLeMenuRestentReconnaissables(t *testing.T) {
|
|
attendus := map[string]string{
|
|
"autorité : %s\n": "authority",
|
|
"empreinte : %s\n": "fingerprint",
|
|
"exceptions : aucune\n": "exceptions",
|
|
"dépôts git : %d en miroir, %s\n": "git repos",
|
|
"tunnel opaque retenu pour %s (%d échec(s)) : %v": "opaque tunnel kept for %s (",
|
|
}
|
|
for fr, prefixe := range attendus {
|
|
if !strings.HasPrefix(anglais[fr], prefixe) {
|
|
t.Errorf("%q traduit en %q, qui ne commence plus par %q",
|
|
fr, anglais[fr], prefixe)
|
|
}
|
|
}
|
|
}
|
|
|
|
// L'option doit être DÉCLARÉE : flag.Parse refuse une option inconnue, et
|
|
// chaque appel du menu qui la passe échouerait alors avant de rien dire.
|
|
// Le binaire est construit et lancé pour de vrai, sur un cache vide.
|
|
func TestLeBinaireAccepteLangEtParleAnglais(t *testing.T) {
|
|
if _, err := exec.LookPath("go"); err != nil {
|
|
t.Skip("go absent du PATH")
|
|
}
|
|
dir := t.TempDir()
|
|
bin := filepath.Join(dir, "cache")
|
|
if out, err := exec.Command("go", "build", "-o", bin, ".").CombinedOutput(); err != nil {
|
|
t.Fatalf("construction : %v\n%s", err, out)
|
|
}
|
|
cmd := exec.Command(bin, "--lang", "en", "--status", "--cache-dir", filepath.Join(dir, "vide"),
|
|
"--ca-dir", filepath.Join(dir, "ca"))
|
|
cmd.Env = append(os.Environ(), "EL_LANG=fr")
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("--lang en --status : %v\n%s", err, out)
|
|
}
|
|
if !strings.Contains(string(out), "directory :") {
|
|
t.Errorf("sortie non anglaise malgré --lang en :\n%s", out)
|
|
}
|
|
}
|