Commit 3bd78b67 authored by Jordan Grossemy's avatar Jordan Grossemy
Browse files

Lot 6 : l'interface est bilingue, et la mesure l'est devenue aussi

165 chaînes sans accent enveloppées, 54 libellés marqués au niveau du module,
170 traductions neuves. Le compteur passe à 1 637 sur 1 637 : cent pour cent,
sans reste.

── Un marqueur, parce que « t » est un crochet

« t » lit la langue dans le contexte React : il ne peut donc pas s'appeler au
niveau d'un module. Or les libellés de rôles, les catalogues de tiers, les noms
d'états et les questions du retour d'expérience vivent dans des constantes
déclarées là, hors de tout composant.

« traduisible(fr) » ne fait rien qu'annoncer : elle rend le français tel quel.
Son intérêt est d'être visible — le compteur la lit comme un appel à « t », le
dictionnaire doit donc porter la clé, et au rendu le composant enveloppe la
valeur d'un « t(…) » qui, lui, connaît la langue. Vingt-huit points de lecture
ont été enveloppés en conséquence ; un marqueur sans cela serait une promesse
sans suite.

── Trois erreurs de ma part, reprises

Ma règle « une constante de colonne zéro n'a pas accès à t » était fausse pour
« const poids = (o, t: Traduire) => … » : « t » y est un paramètre. Quatorze
appels convertis à tort, puis rendus — et le reste du fichier corrigé à part.

« noter(t("sans-reponse")) » : j'avais enveloppé une valeur de données, ce qui
aurait comparé une chaîne anglaise à un type. Déballé, et la mesure exige
désormais une espace ou une capitale pour les chaînes sans accent.

Et « fontFamily: 'JetBrains Mono' » comptait pour du texte à traduire.

── La mesure, corrigée une dernière fois

Le motif « > … < » suppose qu'un nœud de texte finit sur une balise. « Kastell —
logiciel libre sous{" "} » finit sur une accolade, et vingt phrases du même
genre — « demandé le », « Date de sortie », « Ouvert le » — étaient invisibles.
La seconde borne demande plus de prudence, une déclaration de type finissant
aussi sur une accolade : on exige une phrase, avec une espace, un mot-outil
français et rien qui ressemble à une signature.

── Le cliquet est retiré

Dix-sept, vingt-et-un, trente-neuf, cinquante-six, soixante-dix-neuf,
quatre-vingt-huit, cent. Il a servi de liste de tâches et encaissé quatre fois
la correction d'une mesure qui se trompait. EX-21 demande une interface
bilingue, pas une interface presque bilingue : la garantie exige donc zéro
chaîne nue, et nomme ce qui manquerait.

Ce qui reste, et qui ne relève pas de cette méthode : les cent quatre-vingt-trois
chaînes que le serveur produit — libellés de l'indicateur de préparation,
messages d'erreur des routes, résumés d'événements du journal. Elles arrivent en
français dans une interface réglée en anglais.

Co-Authored-By: Claude (RCA)
parent 28180403
Loading
Loading
Loading
Loading
Loading
+43 −10
Changes for apps/api/src/verification/interface.ts: 43 added lines, 10 removed lines.
Original line number Diff line number Diff line
@@ -106,7 +106,11 @@ export async function verifierInterface(r: Rapport): Promise<void> {
    const texte = await readFile(f, "utf8");
    // Une longue phrase est coupée en littéraux concaténés pour tenir dans la
    // largeur du fichier : la clé est leur recollement, pas le premier morceau.
    for (const m of texte.matchAll(/\bt\(\s*((?:"(?:[^"\\]|\\.)*"\s*\+?\s*)+)/g)) {
    // « traduisible(…) » marque une chaîne au niveau d'un module, où « t » ne
    // peut pas s'appeler : elle compte comme employée, et le dictionnaire doit
    // donc la porter — sans quoi le marqueur serait une promesse sans suite.
    for (const m of texte.matchAll(
      /\b(?:t|traduisible)\(\s*((?:"(?:[^"\\]|\\.)*"\s*\+?\s*)+)/g)) {
      const morceaux = [...m[1]!.matchAll(/"((?:[^"\\]|\\.)*)"/g)].map((x) => x[1]!);
      employees.add(morceaux.join("").replace(/\\"/g, '"'));
    }
@@ -152,7 +156,18 @@ export async function verifierInterface(r: Rapport): Promise<void> {
  for (const f of fichiers) {
    if (f.includes("/langues/")) continue;
    const texte = await readFile(f, "utf8");
    for (const m of texte.matchAll(/>([^<>{}]*[A-Za-zÀ-ÿ][^<>{}]*)</g)) {
    // Un nœud de texte ne se termine pas toujours sur une balise : « Kastell —
    // logiciel libre sous{" "} » finit sur une accolade. Cette seconde borne
    // demande plus de prudence — une déclaration de type finit aussi sur une
    // accolade — d'où l'exigence d'une phrase : une espace, un mot-outil
    // français, et rien qui ressemble à une signature.
    const versAccolade = [...texte.matchAll(/>([^<>{}]*[A-Za-zÀ-ÿ][^<>{}]*)\{/g)]
      .filter((m) => {
        const v = m[1]!.replace(/\s+/g, " ").trim();
        return v.includes(" ") && /\b(le|la|les|de|du|des|sous|sur|dans|pour|et|ou|un|une)\b/i
          .test(v) && !/[:()[\]=;]/.test(v);
      });
    for (const m of [...texte.matchAll(/>([^<>{}]*[A-Za-zÀ-ÿ][^<>{}]*)</g), ...versAccolade]) {
      const brut = m[1]!.replace(/\s+/g, " ").trim();
      // Un commentaire cite parfois des chevrons — « l:<nom> » — que le motif
      // prend pour des balises. Et « m.seq » ou « (e: React.ChangeEvent » sont
@@ -191,7 +206,7 @@ export async function verifierInterface(r: Rapport): Promise<void> {
  // Les noms de classes de la feuille de style ne sont pas du texte.
  const CLASSE =
    /\b(discret|petit|pousse|accent|pastille|ligne|carte|etiq|crit|alerte|neutre|sans-point|ok|mono|aide|scene|contenu|champ|pile|grille|vide|item|actif|fait|valeur|nombres|noreferrer|noopener)\b/;
  const APPEL_T = /\bt\(\s*(?:"(?:[^"\\\n]|\\.)*"\s*\+?\s*)+\)?/g;
  const APPEL_T = /\b(?:t|traduisible)\(\s*(?:"(?:[^"\\\n]|\\.)*"\s*\+?\s*)+\)?/g;
  for (const f of fichiers) {
    if (f.includes("/langues/")) continue;
    const texte = await readFile(f, "utf8");
@@ -204,10 +219,20 @@ export async function verifierInterface(r: Rapport): Promise<void> {
      // Un commentaire n'est pas de l'interface ; « throw new Error » non plus,
      // qui s'adresse à celui qui lit la console, pas à la cellule.
      if (avant.trimStart().startsWith("*") || avant.includes("//")) continue;
      if (avant.includes("throw new Error(") || avant.includes("console.")) continue;
      // Un « throw new Error » peut tenir sur plusieurs lignes : c'est la
      // phrase entière qu'il faut écarter, pas la première ligne seulement.
      const amont = texte.slice(Math.max(0, i - 400), i);
      if (/throw new Error\((?:[^)]|\n)*$/.test(amont) || avant.includes("console.")) continue;
      // « fontFamily: 'JetBrains Mono' » est une valeur de style, et
      // « chemin: "reponse" | "sans-reponse" » un littéral de type.
      if (/fontFamily|font-family|fontWeight/.test(avant)) continue;
      if (/:\s*"[\w-]+"\s*\|\s*$|\|\s*$/.test(avant)) continue;
      const valeur = m[1]!.trim();
      if (valeur.length < 3) continue;
      const accentue = /[àâäéèêëîïôöùûü眒]/.test(valeur);
      // Sans accent, on exige aussi une espace ou une capitale : « sans-reponse »
      // est une valeur de données, pas une phrase.
      if (!accentue && !valeur.includes(" ") && !/^[A-ZÀ-Ý]/.test(valeur)) continue;
      if (!accentue && (CLASSE.test(valeur)
        || (!MOT_FRANCAIS.test(valeur) && !valeur.includes(" "))
        || /className\s*[=:]\s*$|queryKey|`/.test(avant))) continue;
@@ -232,14 +257,22 @@ export async function verifierInterface(r: Rapport): Promise<void> {
  // cinquante-six, puis soixante-dix-neuf. Il ne se baisse jamais. Le jour où
  // il atteindra cent, il deviendra une exigence simple — d'ici là, il dit
  // exactement ce qui reste, et empêche que cela reparte à l'envers.
  const PLANCHER = 88;
  // Le cliquet a fait son office. Parti de dix-sept pour cent, il a servi de
  // liste de tâches lot après lot — vingt-et-un, trente-neuf, cinquante-six,
  // soixante-dix-neuf, quatre-vingt-huit — en se relevant sans jamais se
  // baisser, et en encaissant trois fois la correction d'une mesure qui se
  // trompait : les génériques comptés pour du texte, le texte hors du rendu
  // oublié, puis les chaînes sans accent.
  //
  // Il est arrivé à cent. EX-21 demande une interface bilingue, pas une
  // interface presque bilingue : l'exigence est donc simple désormais, et ce
  // qui manquerait est nommé, pour qu'aucune chaîne neuve ne se glisse en
  // français sans qu'on la voie.
  r.verifie("EX-21",
    `${employees.size} chaînes visibles sur ${visibles} passent par la traduction`
    + ` (${couverture} %, plancher ${PLANCHER} %)`,
    couverture >= PLANCHER,
    couverture < PLANCHER
      ? `recul de ${PLANCHER - couverture} points ; il reste ${nues.size} chaînes`
      : "");
    + ` (${couverture} %)`,
    nues.size === 0,
    [...nues].slice(0, 4).map((n) => `« ${n.slice(0, 40)} »`).join(", "));

  // Un texte non traduit s'affiche en français : c'est le repli voulu, jamais
  // un identifiant technique illisible sous pression.
+1 −1
Changes for apps/web/src/coque/ChoixPorteur.tsx: 1 added line, 1 removed line.
Original line number Diff line number Diff line
@@ -89,7 +89,7 @@ export function ChoixPorteur({ criseId, surChoix, libelleInitial }: {
          ) : null
        ))}
        {(data?.hors_cellule ?? []).length > 0 && (
          <optgroup label="Hors cellule">
          <optgroup label={t("Hors cellule")}>
            {data!.hors_cellule.map((p) => (
              <option
                key={p.compte_id ?? `nom:${p.nom}`}
+5 −5
Changes for apps/web/src/coque/Consigner.tsx: 5 added lines, 5 removed lines.
Original line number Diff line number Diff line
import { useEffect, useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { api, ErreurApi } from "../api.js";
import { useT } from "../langues/index.js";
import { useT, traduisible } from "../langues/index.js";

/**
 * Ce que l'on consigne à la main.
@@ -14,8 +14,8 @@ import { useT } from "../langues/index.js";
 */
const GENRES = [
  { cle: "fait", libelle: "Fait" },
  { cle: "decision", libelle: "Décision" },
  { cle: "hypothese", libelle: "Hypothèse" },
  { cle: "decision", libelle: traduisible("Décision") },
  { cle: "hypothese", libelle: traduisible("Hypothèse") },
  { cle: "alerte", libelle: "Alerte" },
  { cle: "communication", libelle: "Communication" },
];
@@ -69,7 +69,7 @@ export function Consigner({
      await client.invalidateQueries({ queryKey: ["cockpit", criseId] });
      fermer();
    } catch (e) {
      definirErreur(e instanceof ErreurApi ? e.message : "Consignation impossible.");
      definirErreur(e instanceof ErreurApi ? e.message : t("Consignation impossible."));
      definirOccupe(false);
    }
  }
@@ -88,7 +88,7 @@ export function Consigner({
          <button key={g.cle} type="button"
            className={genre === g.cle ? "actif" : ""}
            onClick={() => definirGenre(g.cle)}>
            {g.libelle}
            {t(g.libelle)}
          </button>
        ))}
      </div>
+1 −1
Changes for apps/web/src/coque/Coque.tsx: 1 added line, 1 removed line.
Original line number Diff line number Diff line
@@ -168,7 +168,7 @@ export function Coque({ criseId }: { criseId: string }) {
      // Mise à jour fonctionnelle, et non depuis une valeur capturée : c'est la
      // règle qui manquait, et son absence remettait la fenêtre qu'on venait de
      // fermer.
      const titre = String(d.charge?.["auteur_nom"] ?? "Message direct");
      const titre = String(d.charge?.["auteur_nom"] ?? t("Message direct"));
      definirFenetres((liste) => (
        liste.some((f) => f.conversationId === conversation)
          ? liste.map((f) => (f.conversationId === conversation
+1 −1
Changes for apps/web/src/coque/DockChat.tsx: 1 added line, 1 removed line.
Original line number Diff line number Diff line
@@ -134,7 +134,7 @@ function Conversation({ criseId, fenetre, basculer, reduire, lue, fermer }: {
            portée, fermer l'écarte. Confondre les deux fait perdre le fil
            qu'on voulait seulement mettre de côté. */}
        <button className="reduire"
          aria-label={fenetre.repliee ? "Agrandir la conversation" : t("Réduire la conversation")}
          aria-label={fenetre.repliee ? t("Agrandir la conversation") : t("Réduire la conversation")}
          title={fenetre.repliee ? "Agrandir" : t("Réduire")}
          onClick={() => (fenetre.repliee ? basculer(id) : reduire(id))}>
          {fenetre.repliee ? "▴" : "▾"}
Loading