Commit 46717c59 authored by Jordan Grossemy's avatar Jordan Grossemy
Browse files

La campagne de test trouve ce que la mesure ne pouvait pas voir

Le cahier appliqué à l'application, session ouverte. Trente-six écrans balayés,
et cinq écarts dont deux que je croyais impossibles.

── L'espace de crise n'avait aucune navigation sous 900 px

Le plus grave, et il corrige une affirmation que j'ai écrite. En corrigeant
l'espace organisation au lot précédent, j'ai noté que « l'espace crise s'en
sortait par sa barre d'onglets, ce qui explique que le défaut ait tenu ».
C'était faux. La barre d'onglets est stylée dans la feuille — douze lignes — et
rendue nulle part. La commande d'ouverture du rail existait dans la coque de
crise, masquée par « display: none » depuis l'origine.

Vingt destinations inatteignables sur téléphone, dans l'espace qu'on ouvre
précisément le jour J. Le style mort est retiré, la commande apparaît sous 900
px comme dans l'autre coque, et se referme au premier choix. Vérifié à 848 px :
vingt-quatre destinations, cibles à quarante-quatre points, la destination
demandée s'affiche.

Deux garanties neuves l'auraient vu, et le verront désormais — une par coque.

── Quatre chaînes françaises dans une interface réglée en anglais

Le compteur annonçait cent pour cent. Il ne mesure que des littéraux, et ces
quatre-là n'en sont pas :

La gravité était rendue brute — « majeure », minuscule comprise — sur quatre
écrans et dans les résumés du journal. Les statuts d'action avaient leur table
de libellés depuis l'origine ; la gravité, non. Elle l'a désormais, et le
serveur la traduit aussi dans « Crise ouverte — test (Majeure) ».

Deux nœuds de texte coupés en leur milieu par une interpolation : « Aucune fiche
ne vise « {type} » dans le dossier gelé » et « Rectifiée par l'entrée #{seq} ».
Le motif « > texte < » ne les voit pas. J'ai cherché cette forme partout : il
n'en restait que ces deux.

Et le compte d'entrées de la main courante, composé par gabarit.

Une garantie neuve interdit d'afficher une énumération sans passer par ses
libellés — ce qui aurait attrapé la gravité.

── Deux champs sans nom accessible

La fenêtre anti-doublon des intégrations, et le sélecteur de langue de l'envoi
en masse. Nommés.

── Une garantie épinglée sur la lettre plutôt que sur l'intention

EF-1203 exigeait le texte exact « Gravité portée de majeure à critique ». Elle
passait donc pendant que la gravité s'affichait en code. Elle porte maintenant
sur ce qu'elle voulait dire : la phrase se lit, et aucun code d'énumération n'y
subsiste.

Reste une question de style que je laisse ouverte : « portée de Majeure à
Critique » est raide en français, où l'on écrirait la minuscule au milieu d'une
phrase. Les libellés servent aussi de pastilles, où la capitale est juste. Deux
formes seraient nécessaires pour bien faire les deux.

── Balayage sans écart

Trente-six écrans : titre de page partout, aucun bouton sans nom, aucun tableau
sans en-tête, aucun débordement horizontal à 1440 px. Les vingt-quatre
destinations de crise et les neuf de l'organisation.

Co-Authored-By: Claude (RCA)
parent d0510d6a
Loading
Loading
Loading
Loading
Loading
+17 −3
Changes for apps/api/src/noyau/formuler.ts: 17 added lines, 3 removed lines.
Original line number Diff line number Diff line
@@ -18,6 +18,7 @@
 * et qu'un second formateur finirait par en différer d'une minute.
 */
import { enFrancais, type Traduire } from "../langues/index.js";
import { LIBELLES_GRAVITE } from "@kastell/shared";

export function relatif(depuis: Date, quand: Date): string {
  const e = Math.max(0, quand.getTime() - depuis.getTime());
@@ -32,6 +33,19 @@ export function relatif(depuis: Date, quand: Date): string {
 * deux formulations pour un même fait finiraient par se contredire dans le
 * dossier de clôture.
 */
/**
 * La gravité, dite plutôt que codée.
 *
 * Le résumé était traduit et sa gravité substituée brute : « Crisis opened —
 * test (majeure) ». Une énumération se dit avec ses libellés, ici comme à
 * l'écran.
 */
function gravite(t: Traduire, code: string | undefined): string {
  if (!code) return "";
  const libelle = (LIBELLES_GRAVITE as Record<string, string>)[code];
  return libelle ? t(libelle) : code;
}

export function resumer(
  type: string, charge: Record<string, unknown>, t: Traduire = enFrancais,
): string {
@@ -39,10 +53,10 @@ export function resumer(
  switch (type) {
    case "crise.ouverte":
      return t("Crise ouverte — {intitule} ({gravite})", { intitule: c.intitule ?? "",
        gravite: c.gravite ?? "" });
        gravite: gravite(t, c.gravite) });
    case "crise.gravite_modifiee":
      return t("Gravité portée de {de} à {vers} — {motif}", { de: c.de ?? "",
        vers: c.vers ?? "", motif: c.motif ?? "" });
      return t("Gravité portée de {de} à {vers} — {motif}", { de: gravite(t, c.de),
        vers: gravite(t, c.vers), motif: c.motif ?? "" });
    case "crise.phase_modifiee":
      return t("Passage en phase « {vers} »", { vers: c.vers ?? "" });
    case "crise.close": return t("Crise close");
+10 −0
Changes for apps/api/src/verification/accessibilite.ts: 10 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -142,5 +142,15 @@ export async function verifierAccessibilite(r: Rapport): Promise<void> {
    const source = await readFile(`${web}${f}`, "utf8");
    r.verifie("EX-19", `la coque ${nom} offre un lien d'évitement vers son contenu`,
      source.includes('className="evitement"') && source.includes('<main id="scene"'));

    // Le rail se masque sous 900 px. Sans commande pour l'ouvrir, la coque n'a
    // plus aucune navigation — vingt destinations inatteignables sur téléphone.
    // La coque de crise était dans ce cas : la commande existait, masquée par
    // « display: none », et la feuille portait une barre d'onglets que rien ne
    // rendait. Deux campagnes de test ont été nécessaires pour le voir, la
    // première ayant conclu à tort que la barre d'onglets faisait le travail.
    r.verifie("EX-13", `la coque ${nom} garde une navigation sous 900 px`,
      /className="[^"]*ouvre-rail/.test(source)
      && !/ouvre-rail[^>]*display: "none"/.test(source));
  }
}
+23 −0
Changes for apps/api/src/verification/interface.ts: 23 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -370,6 +370,29 @@ export async function verifierInterface(r: Rapport): Promise<void> {
    srvManquantes.length === 0,
    srvManquantes.slice(0, 4).map((m) => `« ${m.slice(0, 40)} »`).join(", "));

  // ── Une énumération ne s'affiche pas brute (EX-21) ───────────────────────
  //
  // Le compteur ci-dessus ne voit que des littéraux. Une valeur venue du
  // serveur et rendue telle quelle lui échappe : « majeure » s'affichait en
  // toutes lettres, minuscule comprise, dans une interface réglée en anglais.
  // Deux énumérations ont leur table de libellés — la gravité et le statut
  // d'une action ; ce contrôle exige qu'on passe par elle.
  const brutes: string[] = [];
  for (const f of fichiers) {
    if (f.includes("/langues/")) continue;
    const texte = await readFile(f, "utf8");
    for (const m of texte.matchAll(/\{\s*(?:[\w.]+\.)?(gravite|statut)\s*\}/g)) {
      const debut = texte.lastIndexOf("\n", m.index!) + 1;
      const ligne = texte.slice(debut, texte.indexOf("\n", m.index!));
      // « className={`gravite ${c.gravite}`} » est une classe, « value={a.statut} »
      // la valeur d'un sélecteur : ni l'une ni l'autre ne s'affiche comme texte.
      if (/className|queryKey|key=|value=/.test(ligne)) continue;
      brutes.push(`${f.split("/").pop()} : ${ligne.trim().slice(0, 46)}`);
    }
  }
  r.verifie("EX-21", "aucune énumération n'est affichée sans passer par ses libellés",
    brutes.length === 0, brutes.slice(0, 3).join(" ; "));

  r.verifie("EX-21", "une clé absente retombe sur le français, pas sur un identifiant",
    noyau.includes("EN[fr] ?? fr"));
  r.verifie("EX-21", "la langue du document suit la langue choisie",
+11 −2
Changes for apps/api/src/verification/restitution.ts: 11 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -67,9 +67,18 @@ export async function verifierRestitution(
    moments.length >= 5 && moments.length < 20, `${moments.length} moments`);
  r.verifie("EB-14", "chacun est daté en relatif, comme la cellule compte",
    moments.every((m) => /^H\+\d{2}:\d{2}$/.test(m.relatif)));
  // Cette garantie était épinglée sur le rendu exact « de majeure à critique ».
  // La campagne de test a montré que la gravité y était substituée brute, code
  // et minuscule comprises, y compris dans une interface réglée en anglais. Le
  // résumé passe désormais par les libellés — et le contrôle porte sur
  // l'intention plutôt que sur la lettre : la phrase se lit, et aucun code
  // d'énumération n'y subsiste.
  const aggravation = moments.find((m) => m.type === "crise.gravite_modifiee")?.resume ?? "";
  r.verifie("EF-1203", "chacun se lit sans connaître le vocabulaire technique",
    moments.some((m) => m.resume.includes("Gravité portée de majeure à critique")),
    moments.find((m) => m.type === "crise.gravite_modifiee")?.resume);
    aggravation.includes("Gravité portée de")
    && aggravation.includes("Majeure") && aggravation.includes("Critique")
    && !/\b(majeure|critique|mineure|vigilance)\b/.test(aggravation),
    aggravation);

  // ── Comparaison de deux instants ─────────────────────────────────────────
  const comparaison = await cloture.comparer(sql, crise.criseId, seqAvantAggravation, 1e6);
+12 −3
Changes for apps/web/src/coque/Coque.tsx: 12 added lines, 3 removed lines.
Original line number Diff line number Diff line
@@ -319,8 +319,14 @@ export function Coque({ criseId }: { criseId: string }) {

      {/* ── Barre d’état : elle ne bouge jamais ─────────────────────────── */}
      <div className="barre-etat">
        <button className="discret petit" onClick={() => definirRailOuvert(!railOuvert)}
          aria-label={t("Cellules")} style={{ display: "none" }} />
        {/* Sous 900 px le rail est masqué. Cette commande était présente et
            masquée en dur — « display: none » — et la feuille de style porte
            une barre d'onglets que rien ne rend. L'espace de crise n'avait donc
            aucune navigation sur téléphone : vingt destinations inatteignables,
            dans l'espace qu'on ouvre précisément le jour J. Trouvé en appliquant
            le cahier de test à 596 px, après que j'avais affirmé le contraire. */}
        <button className="discret petit ouvre-rail" aria-label={t("Navigation")}
          aria-expanded={railOuvert} onClick={() => definirRailOuvert(!railOuvert)}>☰</button>
        <button className="marque" onClick={() => aller("/")}>
          Kastell
        </button>
@@ -424,8 +430,11 @@ export function Coque({ criseId }: { criseId: string }) {

      <div className="corps">
        {/* ── Rail : crise → cellule → module, jamais quatre niveaux ─────── */}
        {/* Se referme sur n'importe quel choix : en superposition, il resterait
            sinon devant l'écran qu'on vient de demander. */}
        <nav className={`rail${railOuvert ? " ouvert" : ""}`}
          aria-label={t("Navigation de la crise")}>
          aria-label={t("Navigation de la crise")}
          onClick={() => definirRailOuvert(false)}>
          <div className="groupe">
            <span className="etiq">{t("Cockpit et salons")}</span>
            <button className={`item${module === "cockpit" ? " actif" : ""}`}
Loading