Commit f2995405 authored by Kourser's avatar Kourser
Browse files

Outillage : rendre le cahier de test en page autonome

Le cahier vit en Markdown dans le dépôt ; cette commande en produit une
page lisible — sommaire fixe, lignes de vigilance surlignées, imprimable,
thème clair et sombre.

    pnpm cahier:rendu

Conversion volontairement partielle : titres, tableaux, listes, blocs de
code et marques de niveau caractère. C'est tout ce que le cahier
utilise, et un convertisseur complet serait une dépendance de plus pour
rien.

La page produite n'est pas versionnée : elle se régénère, et deux copies
d'un même texte finissent toujours par diverger.

Les globales de Node sont déclarées à ESLint pour ces scripts : ils
tournent hors de l'application, et sans cela « console » et « process »
passent pour des variables inventées.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 5c904b3e
Loading
Loading
Loading
Loading
+3 −0
Changes for .gitignore: 3 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -9,3 +9,6 @@ coverage/
.DS_Store
apps/api/prisma/*.db
.env.sauvegarde-*

# Rendu du cahier de test : régénéré par scripts/cahier-de-test/rendre.mjs
docs/cahier-de-test.html
+16 −0
Changes for eslint.config.mjs: 16 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -16,6 +16,22 @@ export default tseslint.config(
  },
  eslint.configs.recommended,
  ...tseslint.configs.recommended,
  {
    // Outils du dépôt : scripts Node autonomes, exécutés hors de l'application.
    // Ils tournent dans Node, pas dans un navigateur : les globales du runtime
    // doivent être déclarées, sinon `console` et `process` passent pour des
    // variables inventées.
    files: ['scripts/**/*.mjs'],
    languageOptions: {
      globals: {
        Buffer: 'readonly',
        console: 'readonly',
        fetch: 'readonly',
        process: 'readonly',
        setTimeout: 'readonly',
      },
    },
  },
  {
    languageOptions: {
      ecmaVersion: 2022,
+3 −1
Changes for package.json: 3 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -19,7 +19,9 @@
    "typecheck": "pnpm -r typecheck",
    "db:generate": "pnpm --filter @enkadenn/api db:generate",
    "db:migrate": "pnpm --filter @enkadenn/api db:migrate",
    "db:seed": "pnpm --filter @enkadenn/api db:seed"
    "db:seed": "pnpm --filter @enkadenn/api db:seed",
    "recette": "node scripts/recette/executer.mjs",
    "cahier:rendu": "node scripts/cahier-de-test/rendre.mjs"
  },
  "devDependencies": {
    "@eslint/js": "9.14.0",

scripts/README.md

0 → 100644
+70 −0
Changes for scripts/README.md: 70 added lines, 0 removed lines.
Original line number Diff line number Diff line
# Outils du dépôt

Deux outils, sans dépendance : Node seul suffit.

## Recette fonctionnelle

Exécute les cas du [cahier de test](../docs/cahier-de-test.md) contre une
instance en marche, et rend un verdict par cas.

```bash
node scripts/recette/executer.mjs            # tous les lots
node scripts/recette/executer.mjs 02 04      # seulement ceux-là
```

**Prérequis** — une instance servie et chargée du jeu de démonstration :

```bash
docker compose up -d && docker compose exec app node_modules/.bin/ts-node apps/api/prisma/seed.ts
```

| Lot | Ce qu'il éprouve |
|---|---|
| `01-public` | Écrans publics, cadre de la console, cloisonnement des rôles |
| `02-exercices` | Cycle de vie complet, duplication, exports, rapport |
| `03-joueur` | Espace joueur, import d'annuaire, invitations |
| `04-kits` | Éditeur de kit et imports CSV, vérification des accès, écran de salle |
| `05-instance` | Console d'instance, inscription, bac à sable, suspension, observation |

**Ce que la recette laisse derrière elle** — rien, sauf deux choses qui ne se
défont pas depuis l'API :

- le **second facteur** de l'administrateur d'instance, que le lot 05 enrôle
  faute d'en connaître un. Le remettre à zéro :

  ```bash
  docker compose exec app node apps/api/dist/scripts/reset-mfa.js --email=root@enkadenn.local --confirm
  ```

  Pour l'éviter, fournir un secret existant : `RECETTE_TOTP=… node scripts/recette/executer.mjs`.

- l'**organisation éphémère** née de la session de découverte, que le produit
  purge de lui-même à son échéance et que la console ne liste pas.

**Réglages** — `RECETTE_BASE` (défaut `http://localhost:3000/api`),
`RECETTE_MOT_DE_PASSE`, `RECETTE_ROOT`, `RECETTE_TOTP`.

**À savoir** — le plafonnement des connexions (5 par adresse, 10 par IP, toutes
les 15 minutes) se déclenche quand la recette est relancée coup sur coup. Les
compteurs vivent en mémoire du processus :

```bash
docker compose restart app
```

Deux lots patientent volontairement : le second facteur attend une fenêtre de
code neuve, et la suspension attend les 30 secondes du cache d'état des
organisations. Comptez environ deux minutes pour le lot 05.

## Rendu du cahier de test

Transforme `docs/cahier-de-test.md` en page HTML autonome — sommaire fixe,
lignes de vigilance surlignées, imprimable, thème clair et sombre.

```bash
node scripts/cahier-de-test/rendre.mjs                 # écrit docs/cahier-de-test.html
node scripts/cahier-de-test/rendre.mjs /tmp/page.html  # ailleurs
```

Le Markdown reste la source : régénérer la page après chaque modification du
cahier, sinon les deux divergent.
+336 −0
Changes for scripts/cahier-de-test/rendre.mjs: 336 added lines, 0 removed lines.
Original line number Diff line number Diff line
/**
 * Rend `docs/cahier-de-test.md` en une page HTML autonome.
 *
 * Le Markdown du dépôt reste la source ; cette page en est une lecture, à
 * régénérer après chaque modification du cahier — sinon les deux divergent.
 *
 *   node scripts/cahier-de-test/rendre.mjs [chemin/de/sortie.html]
 *
 * Conversion volontairement partielle : titres, tableaux, listes, blocs de code
 * et marques de niveau caractère. C'est tout ce que le cahier utilise, et un
 * convertisseur complet serait une dépendance de plus pour rien.
 */
import { readFileSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

const RACINE = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
const SOURCE = resolve(RACINE, 'docs/cahier-de-test.md');
const SORTIE = process.argv[2]
  ? resolve(process.cwd(), process.argv[2])
  : resolve(RACINE, 'docs/cahier-de-test.html');

const echapper = (t) =>
  t.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');

/** Marques de niveau caractère : code, gras, italique, lien. */
function enligne(texte) {
  return echapper(texte)
    .replace(/`([^`]+)`/g, '<code>$1</code>')
    .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
    .replace(/(^|[^*])\*([^*]+)\*(?!\*)/g, '$1<em>$2</em>')
    .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
}

/** Libellé de sommaire : les marques Markdown n'y ont rien à faire. */
const texteSeul = (t) =>
  echapper(t.replace(/\*\*?([^*]+)\*\*?/g, '$1').replace(/`([^`]+)`/g, '$1').trim());

function convertir(lignes) {
  const sortie = [];
  const sommaire = [];
  let i = 0;
  let dansCode = false;

  while (i < lignes.length) {
    const l = lignes[i];

    if (l.startsWith('```')) {
      sortie.push(dansCode ? '</code></pre>' : '<pre><code>');
      dansCode = !dansCode;
      i += 1;
      continue;
    }
    if (dansCode) {
      sortie.push(echapper(l));
      i += 1;
      continue;
    }

    if (l.startsWith('# ')) {
      sortie.push(`<h1>${enligne(l.slice(2))}</h1>`);
    } else if (l.startsWith('## ')) {
      const titre = l.slice(3);
      const num = /^(\d+)\.\s+(.*)$/.exec(titre);
      const rang = num ? num[1] : '';
      const etiquette = num ? num[2] : titre;
      const ident = `s${rang || sommaire.length + 1}`;
      sommaire.push({ ident, rang, etiquette });
      sortie.push(`<h2 id="${ident}"><span class="num">${rang}</span>${enligne(etiquette)}</h2>`);
    } else if (l.startsWith('### ')) {
      sortie.push(`<h3>${enligne(l.slice(4))}</h3>`);
    } else if (l.trim() === '---') {
      // Les filets du Markdown sont rendus par la bordure des titres.
    } else if (l.startsWith('|')) {
      const bloc = [];
      while (i < lignes.length && lignes[i].startsWith('|')) {
        bloc.push(lignes[i]);
        i += 1;
      }
      const cellules = bloc.map((r) => r.replace(/^\||\|$/g, '').split('|').map((c) => c.trim()));
      const entete = cellules[0];
      const separateur = cellules.length > 1 && /^[-: ]+$/.test(cellules[1].join(''));
      const corps = separateur ? cellules.slice(2) : cellules.slice(1);
      sortie.push('<div class="tableau"><table>');
      if (entete.some(Boolean)) {
        sortie.push(`<thead><tr>${entete.map((c) => `<th>${enligne(c)}</th>`).join('')}</tr></thead>`);
      }
      sortie.push('<tbody>');
      for (const r of corps) {
        // « ⚠️ » marque un point de vigilance : la mise en forme le dit, le
        // pictogramme disparaît.
        const vigilance = r.some((c) => c.includes('⚠'));
        const propres = r.map((c) => c.replace(/⚠️?/g, '').trim());
        sortie.push(
          `<tr${vigilance ? ' class="vigilance"' : ''}>` +
            propres.map((c) => `<td>${enligne(c)}</td>`).join('') +
            '</tr>',
        );
      }
      sortie.push('</tbody></table></div>');
      continue;
    } else if (l.startsWith('- ')) {
      const bloc = [];
      while (i < lignes.length && lignes[i].startsWith('- ')) {
        bloc.push(lignes[i].slice(2));
        i += 1;
      }
      sortie.push(`<ul>${bloc.map((x) => `<li>${enligne(x)}</li>`).join('')}</ul>`);
      continue;
    } else if (l.trim()) {
      sortie.push(`<p>${enligne(l)}</p>`);
    }
    i += 1;
  }
  return { sortie, sommaire };
}

const CSS = `
:root {
  --encre: #131a22;
  --encre-douce: #55606d;
  --papier: #f6f7f9;
  --surface: #ffffff;
  --trait: #dde2e8;
  --trait-fort: #c3ccd6;
  --accent: #0f6b6b;
  --accent-doux: #e4efee;
  --vigilance: #9a5410;
  --vigilance-fond: #fbf3e8;
  --ombre: 0 1px 2px rgba(19, 26, 34, .05);
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --encre: #dfe5ec;
    --encre-douce: #94a1ae;
    --papier: #0d1218;
    --surface: #141b23;
    --trait: #26303b;
    --trait-fort: #3a4859;
    --accent: #5cb8b0;
    --accent-doux: #16292b;
    --vigilance: #d69a55;
    --vigilance-fond: #241c11;
    --ombre: none;
  }
}
:root[data-theme="dark"] {
  --encre: #dfe5ec;
  --encre-douce: #94a1ae;
  --papier: #0d1218;
  --surface: #141b23;
  --trait: #26303b;
  --trait-fort: #3a4859;
  --accent: #5cb8b0;
  --accent-doux: #16292b;
  --vigilance: #d69a55;
  --vigilance-fond: #241c11;
  --ombre: none;
}
* { box-sizing: border-box; }
body {
  margin: 0;
  background: var(--papier);
  color: var(--encre);
  font-family: 'IBM Plex Sans', system-ui, -apple-system, sans-serif;
  font-size: 15px;
  line-height: 1.6;
}
.page {
  max-width: 1180px;
  margin: 0 auto;
  padding: 40px 24px 96px;
  display: grid;
  grid-template-columns: 208px minmax(0, 1fr);
  gap: 44px;
  align-items: start;
}
nav.sommaire {
  position: sticky;
  top: 24px;
  max-height: calc(100vh - 48px);
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  font-size: 13px;
  border-left: 2px solid var(--trait);
  padding-left: 14px;
}
nav.sommaire a { color: var(--encre-douce); text-decoration: none; padding: 3px 0; display: flex; gap: 9px; }
nav.sommaire a:hover { color: var(--accent); }
nav.sommaire .n {
  font-family: 'IBM Plex Mono', ui-monospace, monospace;
  color: var(--trait-fort);
  min-width: 15px;
  font-size: 12px;
  font-variant-numeric: tabular-nums;
}
main { min-width: 0; }
h1 {
  font-family: 'IBM Plex Serif', Georgia, serif;
  font-size: 34px;
  font-weight: 600;
  letter-spacing: -.015em;
  margin: 0 0 8px;
  text-wrap: balance;
}
h2 {
  font-family: 'IBM Plex Serif', Georgia, serif;
  font-size: 21px;
  font-weight: 600;
  margin: 54px 0 14px;
  padding-top: 15px;
  border-top: 1px solid var(--trait);
  display: flex;
  gap: 12px;
  align-items: baseline;
  text-wrap: balance;
  scroll-margin-top: 20px;
}
h2 .num { font-family: 'IBM Plex Mono', ui-monospace, monospace; font-size: 13px; color: var(--accent); font-weight: 500; }
h3 { font-size: 15px; font-weight: 600; margin: 30px 0 10px; }
p { margin: 0 0 12px; max-width: 68ch; }
ul { margin: 0 0 14px; padding-left: 20px; max-width: 68ch; }
li { margin-bottom: 5px; }
a { color: var(--accent); }
code {
  font-family: 'IBM Plex Mono', ui-monospace, monospace;
  font-size: .88em;
  background: var(--accent-doux);
  padding: 1px 5px;
  border-radius: 3px;
}
pre {
  background: var(--surface);
  border: 1px solid var(--trait);
  border-radius: 6px;
  padding: 13px 16px;
  overflow-x: auto;
  margin: 0 0 16px;
}
pre code { background: none; padding: 0; font-size: 13px; line-height: 1.7; }
.tableau { overflow-x: auto; margin: 0 0 20px; }
table {
  border-collapse: collapse;
  width: 100%;
  font-size: 13.5px;
  background: var(--surface);
  border: 1px solid var(--trait);
  border-radius: 6px;
  box-shadow: var(--ombre);
}
th {
  text-align: left;
  font-size: 11px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: .07em;
  color: var(--encre-douce);
  padding: 9px 14px;
  border-bottom: 1px solid var(--trait-fort);
  white-space: nowrap;
}
td { padding: 10px 14px; border-top: 1px solid var(--trait); vertical-align: top; }
tbody tr:first-child td { border-top: none; }
td:first-child { font-weight: 500; }
tr.vigilance td { background: var(--vigilance-fond); }
tr.vigilance td:first-child { box-shadow: inset 3px 0 0 var(--vigilance); }
.legende { display: flex; align-items: center; gap: 10px; font-size: 13px; color: var(--encre-douce); margin: 4px 0 26px; }
.legende .puce {
  width: 26px;
  height: 14px;
  background: var(--vigilance-fond);
  box-shadow: inset 3px 0 0 var(--vigilance);
  border: 1px solid var(--trait);
  border-radius: 2px;
  flex: none;
}
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
@media (max-width: 900px) {
  .page { grid-template-columns: minmax(0, 1fr); gap: 26px; padding: 28px 16px 64px; }
  nav.sommaire { position: static; max-height: none; }
  h1 { font-size: 28px; }
}
@media print {
  nav.sommaire { display: none; }
  .page { grid-template-columns: 1fr; padding: 0; }
  h2 { break-after: avoid; }
  tr { break-inside: avoid; }
}
@media (prefers-reduced-motion: reduce) { * { animation: none !important; transition: none !important; } }
`;

const { sortie, sommaire } = convertir(readFileSync(SOURCE, 'utf8').split('\n'));
const nav = sommaire
  .map(({ ident, rang, etiquette }) =>
    `<a href="#${ident}"><span class="n">${rang}</span>${texteSeul(etiquette)}</a>`)
  .join('\n');

// Le titre, l'accroche et le tableau d'identification ouvrent la page ; la
// légende des lignes de vigilance se glisse juste après.
const coupe = sortie.findIndex((x) => x.endsWith('</table></div>')) + 1;
const legende =
  '<p class="legende"><span class="puce"></span> Ligne de vigilance : comportement ' +
  "dont l'absence a déjà été un défaut constaté.</p>";

writeFileSync(
  SORTIE,
  `<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Cahier de recette Enkadenn</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Serif:wght@500;600&display=swap" />
<style>${CSS}</style>
</head>
<body>
<div class="page">
  <nav class="sommaire" aria-label="Sommaire">
${nav}
  </nav>
  <main>
${sortie.slice(0, coupe).join('\n')}
${legende}
${sortie.slice(coupe).join('\n')}
  </main>
</div>
</body>
</html>
`,
  'utf8',
);

console.log(`Cahier rendu : ${SORTIE} (${sommaire.length} sections)`);