Commit 23d80749 authored by Kourser's avatar Kourser
Browse files

Une bibliothèque de formes vide n'est pas une préparation

Le tableau blanc savait tenir une bibliothèque, et celle d'une organisation
neuve était vide. Or elle est une préparation : elle sert le jour J si elle a
été constituée avant, et rien n'est constitué avant si tout est à dessiner. Une
cellule qui cartographie son SI à 23 h ne commence pas par inventer un symbole
de pare-feu.

Dix-sept formes sont livrées, en deux jeux. Le système d'information — serveur,
poste, base de données, application, annuaire, sauvegarde, pare-feu, réseau,
service externe, site. Et le vocabulaire du produit lui-même — fait constaté,
hypothèse, décision, action, jalon, tiers, cellule : un tableau finit toujours
par en porter, et les dessiner avec les mots du registre évite qu'il dise autre
chose que la main courante.

── Un vocabulaire, pas une iconographie

Rectangle pour une machine, cylindre pour ce qui stocke, ellipse pour ce qui
est ailleurs, losange pour ce qui bifurque, et le nom écrit au centre. La
couleur ne sert qu'à ce qui garde et à ce qui est dehors.

C'est la même prudence qui avait fait écarter le type « frame » dans ce
module : sans navigateur pour éprouver le rendu, une forme mal formée laisse la
bibliothèque vide — le défaut qu'on corrige, repeint. Un rectangle et une
étiquette s'affichent à coup sûr ; un icône dessiné à l'aveugle, on n'en sait
rien.

── Posées une fois

À la première lecture, et l'existence de la ligne dit que l'organisation a été
servie : ce qu'elle en retire reste retiré. Une bibliothèque volontairement
vidée le demeure, là où une injection permanente ferait revenir des formes que
quelqu'un avait décidé d'ôter. Les organisations existantes en profitent sans
migration.

── Un contrôle qui ne prouvait rien

Il testait la présence d'une clé sur chaque élément. Une clé valant « undefined »
la satisfaisait, alors que JSON.stringify la supprime en route vers la base et
vers le canevas. Il éprouve désormais l'objet après aller-retour JSON,
c'est-à-dire ce qui arrive réellement.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 6128d211
Loading
Loading
Loading
Loading
+28 −1
Changes for apps/api/src/domaine/bibliotheque.ts: 28 added lines, 1 removed line.
Original line number Diff line number Diff line
import type { Sql } from "../db/client.js";
import { bibliothequeLivree } from "@kastell/shared";

/**
 * La bibliothèque de formes du tableau blanc (EF-1002a).
@@ -21,10 +22,36 @@ import type { Sql } from "../db/client.js";
/** Assez pour un jeu de symboles complet ; pas de quoi verser un dossier entier. */
export const PLAFOND_OCTETS = 2 * 1024 * 1024;

/**
 * Ce que l'organisation a dans sa bibliothèque — et ce qu'on y pose la
 * première fois (EF-1002a).
 *
 * Elle partait vide, alors qu'elle est une préparation : elle sert le jour J
 * si elle a été constituée avant, et rien n'est constitué avant si tout est à
 * dessiner. Les formes livrées sont donc posées à la première lecture.
 *
 * Posées, et non ajoutées à chaque lecture : l'existence de la ligne dit que
 * l'organisation a été servie. Ce qu'elle en retire ensuite reste retiré —
 * une bibliothèque volontairement vidée le demeure, là où une injection
 * permanente ferait revenir des formes que quelqu'un avait décidé d'ôter.
 *
 * L'écriture se fait à la lecture, ce qui n'est pas élégant. L'alternative
 * serait de semer à la création de l'organisation, et de rattraper les
 * existantes par une migration : c'est-à-dire le même JSON dans deux endroits
 * dont l'un est du SQL figé, qui cesserait de suivre les formes le jour où
 * elles changent.
 */
export async function lire(sql: Sql, organisationId: string): Promise<unknown[]> {
  const [b] = await sql<{ items: unknown[] }[]>`
    select items from bibliotheque_tableau where organisation_id = ${organisationId}`;
  return b?.items ?? [];
  if (b) return b.items;

  const livrees = bibliothequeLivree();
  await sql`
    insert into bibliotheque_tableau (organisation_id, items)
    values (${organisationId}, ${sql.json(livrees as never)})
    on conflict (organisation_id) do nothing`;
  return livrees;
}

/** Lue depuis une crise : le canevas ne connaît que celle-ci. */
+48 −1
Changes for apps/api/src/verification/tableaux.ts: 48 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -4,6 +4,7 @@ import type { Sql } from "../db/client.js";
import * as comptes from "../auth/comptes.js";
import { ouvrirCrise } from "../domaine/crise.js";
import * as tableaux from "../domaine/tableaux.js";
import { bibliothequeLivree } from "@kastell/shared";
import * as bibliotheque from "../domaine/bibliotheque.js";
import { verifierEnBase } from "../noyau/integrite.js";
import { MODELES_TABLEAU } from "@kastell/shared";
@@ -67,7 +68,53 @@ export async function verifierTableaux(
  // ajoutait disparaissait au rechargement.
  r.titre("Bibliothèque de formes");

  r.verifie("EF-1002a", "une organisation neuve n'en a pas, et cela se dit sans erreur",
  // ── Ce qui est livré (EF-1002a) ──────────────────────────────────────────
  //
  // Elle partait vide, alors qu'elle est une préparation : elle sert le jour J
  // si elle a été constituée avant, et rien ne l'est si tout est à dessiner.
  // Après un aller-retour JSON, parce que c'est ce qui atteint la base et le
  // navigateur : une clé présente mais « undefined » disparaît en chemin, et
  // se lirait ici comme présente si l'on inspectait l'objet en mémoire.
  const livree = JSON.parse(JSON.stringify(bibliothequeLivree())) as unknown[];
  r.verifie("EF-1002a", `${livree.length} formes sont livrées, pas une page blanche`,
    livree.length >= 15);

  /**
   * Le canevas ne dit rien d'une forme mal formée : il l'ignore.
   *
   * C'est ce qui rend ce contrôle nécessaire plutôt que décoratif. Une clé
   * oubliée sur un élément ne casse rien, ne journalise rien, et la
   * bibliothèque s'ouvre simplement plus courte que prévu — le défaut d'origine,
   * repeint.
   */
  const requises = ["id", "type", "x", "y", "width", "height", "seed", "version",
    "versionNonce", "strokeColor", "backgroundColor", "opacity", "isDeleted"];
  const mal: string[] = [];
  for (const item of livree as { id: string; name: string; elements: Record<string,
    unknown>[] }[]) {
    if (!item.id || !item.name || item.elements.length === 0) mal.push(item.id ?? "(sans id)");
    for (const el of item.elements) {
      for (const cle of requises) {
        if (!(cle in el) || el[cle] === undefined) mal.push(`${item.id}.${cle}`);
      }
      if (el["type"] === "text" && typeof el["text"] !== "string") {
        mal.push(`${item.id}.text`);
      }
    }
  }
  r.verifie("EF-1002a", "chaque forme porte un nom et des éléments complets",
    mal.length === 0, mal.slice(0, 4).join(", ") || "toutes complètes");
  // Une étiquette par forme : sans elle, dix rectangles gris ne se distinguent
  // pas, et la bibliothèque ne sert à rien.
  r.verifie("EF-1002a", "et chacune porte son nom écrit, pas seulement un contour",
    (livree as { name: string; elements: { type?: unknown; text?: unknown }[] }[])
      .every((i) => i.elements.some((e) => e.type === "text" && e.text === i.name)));

  r.verifie("EF-1002a", "une organisation neuve les trouve d'emblée",
    (await bibliotheque.lirePourCrise(sql, crise.criseId)).length === livree.length);
  // Posées une fois, et non réinjectées : ce qu'on en retire reste retiré.
  await bibliotheque.poserDepuisCrise(sql, crise.criseId, [], compteId);
  r.verifie("EF-1002a", "vidée volontairement, elle le reste",
    (await bibliotheque.lirePourCrise(sql, crise.criseId)).length === 0);

  const formes = [
+132 −0
Changes for packages/shared/src/tableaux.ts: 132 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -164,3 +164,135 @@ export function elementsDuModele(m: Modele, prefixe: string): Record<string, unk
  });
  return elements;
}

/**
 * Bibliothèques livrées (EF-1002a).
 *
 * La bibliothèque d'une organisation partait vide. Or elle est une
 * préparation : elle sert le jour J si elle a été constituée avant, et rien
 * n'est constitué avant si tout est à dessiner. Une cellule qui cartographie
 * son SI à 23 h ne va pas commencer par inventer un symbole de pare-feu.
 *
 * Ce qui est livré n'est donc pas une iconographie mais un vocabulaire : les
 * noms qu'une cellule pose sur un tableau, chacun avec une forme qui le
 * distingue à l'œil. Rectangle pour ce qui est une machine, cylindre pour ce
 * qui stocke, ellipse pour ce qui est ailleurs, losange pour ce qui bifurque.
 *
 * Dessinées en primitives, pour la même raison qui a fait écarter le type
 * « frame » plus haut : je n'ai pas de navigateur pour éprouver le rendu, et
 * une forme mal formée laisserait la bibliothèque vide — c'est-à-dire le
 * défaut qu'on corrige. Un rectangle et une étiquette s'affichent à coup sûr.
 *
 * Elles ne sont posées qu'une fois, à la première lecture, et deviennent alors
 * celles de l'organisation : ce qu'elle en retire reste retiré.
 */

export interface FormeLivree {
  nom: string;
  corps: "rectangle" | "ellipse" | "diamond";
  /** Le disque du dessus d'un cylindre : ce qui stocke se reconnaît à cela. */
  cylindre?: boolean;
  trait: string;
}

/** Gris pour l'infrastructure : la couleur est réservée à l'état, pas au type. */
const NEUTRE = "#495057";
const DEHORS = "#1971c2";
const GARDE = "#c2255c";

export const FORMES_SI: FormeLivree[] = [
  { nom: "Serveur", corps: "rectangle", trait: NEUTRE },
  { nom: "Poste de travail", corps: "rectangle", trait: NEUTRE },
  { nom: "Base de données", corps: "rectangle", cylindre: true, trait: NEUTRE },
  { nom: "Application métier", corps: "rectangle", trait: NEUTRE },
  { nom: "Annuaire", corps: "rectangle", cylindre: true, trait: NEUTRE },
  { nom: "Sauvegarde", corps: "rectangle", cylindre: true, trait: NEUTRE },
  { nom: "Pare-feu", corps: "rectangle", trait: GARDE },
  { nom: "Réseau", corps: "rectangle", trait: GARDE },
  { nom: "Service externe", corps: "ellipse", trait: DEHORS },
  { nom: "Site", corps: "ellipse", trait: NEUTRE },
];

/**
 * Le vocabulaire du produit lui-même.
 *
 * Un tableau de crise finit toujours par porter des décisions, des hypothèses
 * et des jalons — sur la chronologie comme sur l'arbre d'attaque. Les dessiner
 * avec les mêmes formes que le registre les nomme évite qu'un tableau relu en
 * retour d'expérience dise autre chose que la main courante.
 */
export const FORMES_CONDUITE: FormeLivree[] = [
  { nom: "Fait constaté", corps: "rectangle", trait: NEUTRE },
  { nom: "Hypothèse", corps: "rectangle", trait: "#e8590c" },
  { nom: "Décision", corps: "diamond", trait: "#2b8a3e" },
  { nom: "Action", corps: "rectangle", trait: "#1971c2" },
  { nom: "Jalon", corps: "ellipse", trait: NEUTRE },
  { nom: "Tiers", corps: "ellipse", trait: DEHORS },
  { nom: "Cellule", corps: "rectangle", trait: NEUTRE },
];

const LARGEUR = 160;
const HAUTEUR = 80;

/** Une forme : son corps, son éventuel disque, et son étiquette au centre. */
function elementsDeForme(f: FormeLivree, cle: string): Record<string, unknown>[] {
  const base = {
    ...COMMUN,
    strokeColor: f.trait,
    strokeStyle: "solid",
    strokeWidth: 2,
  };
  const hautCorps = f.cylindre ? 18 : 0;
  const elements: Record<string, unknown>[] = [{
    ...base,
    id: `${cle}-c`, type: f.corps,
    x: 0, y: hautCorps, width: LARGEUR, height: HAUTEUR - hautCorps,
    roundness: f.corps === "rectangle" ? { type: 3 } : null,
    seed: graine(`${cle}-c`), versionNonce: graine(`${cle}-cn`),
  }];
  if (f.cylindre) {
    elements.push({
      ...base,
      id: `${cle}-d`, type: "ellipse",
      x: 0, y: 0, width: LARGEUR, height: 34,
      roundness: null,
      seed: graine(`${cle}-d`), versionNonce: graine(`${cle}-dn`),
    });
  }
  elements.push({
    ...base,
    id: `${cle}-t`, type: "text",
    x: 8, y: hautCorps + (HAUTEUR - hautCorps) / 2 - 10,
    width: LARGEUR - 16, height: 20,
    roundness: null,
    seed: graine(`${cle}-t`), versionNonce: graine(`${cle}-tn`),
    text: f.nom, originalText: f.nom,
    fontSize: 16, fontFamily: 1, textAlign: "center", verticalAlign: "middle",
    lineHeight: 1.25, containerId: null,
  });
  return elements;
}

/**
 * Ce qu'une organisation neuve trouve dans sa bibliothèque.
 *
 * La forme est celle qu'attend le canevas : une liste d'objets portant un
 * identifiant, un nom et leurs éléments. « unpublished » parce qu'elles ne
 * viennent d'aucune bibliothèque publique — les publier serait prétendre
 * qu'elles sont validées par quelqu'un d'autre que nous.
 */
export function bibliothequeLivree(): Record<string, unknown>[] {
  return [...FORMES_SI, ...FORMES_CONDUITE].map((f) => {
    const cle = `kastell-${f.nom.toLowerCase().replace(/[^a-z]+/g, "-")}`;
    return {
      id: cle,
      status: "unpublished",
      name: f.nom,
      // Une date fixe, et non « maintenant » : deux instances doivent poser la
      // même bibliothèque, sans quoi rien ne se compare d'un déploiement à
      // l'autre.
      created: 0,
      elements: elementsDeForme(f, cle),
    };
  });
}