[ADD] interface: application shell — banner, sections, theme, about

The window showed only a banner; it now holds the application. App mounts
the session, the facade and the generation, settles the working folder
and lists its events before it reports ready, re-reading the list once a
question is answered or a refused determination succeeds. The banner
carries version, mode and the event list command; an open event shows its
sections in their first form; the theme follows the system at each start
and the operator's choice lasts the session; À propos names the build.
Errors are collected for a report, and Electron removes the session's
locks at exit.
Checked: 2189 node, 193 browser, 60 node-long; the Electron start check.

--- FR ---

[ADD] interface : coquille — bandeau, sections, thème, À propos

La fenêtre ne montrait qu'un bandeau ; elle tient désormais l'application.
App monte la séance, la façade et la génération, règle le dossier de
travail et liste ses événements avant de se dire prête, et relit la liste
une fois une question répondue ou une détermination refusée réussie. Le
bandeau porte version, mode et la commande de la liste ; un événement
ouvert montre ses sections dans leur première forme ; le thème suit le
système à chaque démarrage et le choix de l'opérateur vaut pour la séance ;
À propos nomme la construction. Les erreurs se collectent pour un
signalement, et Electron efface à la sortie les verrous de la séance.
Vérifié : 2189 node, 193 navigateur, 60 node-long ; démarrage d'Electron.

Assisted-by: Claude Opus 5.5
This commit is contained in:
Mathieu Benoit 2026-10-07 07:22:05 -04:00
parent 876ea0f2ce
commit 5fbbab6477
39 changed files with 2770 additions and 141 deletions

View file

@ -337,7 +337,8 @@ export function processusVivant(pid, signaler = (cible) => process.kill(cible, 0
* @param {number} [reglages.pid]
* @param {string} [reglages.hote]
* @param {() => Date} [reglages.maintenant]
* @returns {Object<string, Function>} les primitives, par nom
* @returns {Object<string, Function>} les primitives, par nom, et
* verrousTenus(), qui n'en est pas une
*/
export function creerFichiers({
emplacements,
@ -364,6 +365,9 @@ export function creerFichiers({
const demarrageDe = demarrage ?? lireDemarrage;
// Le démarrage du processus courant, lu une fois qu'il est connu.
let demarrageCourant = null;
// Les verrous que verrouiller a pris et que deverrouiller n'a pas rendus :
// chemin absolu → séance, que verrousTenus rend au processus principal.
const tenus = new Map();
// Ce que désigne un chemin relatif à une racine : ses segments, sa racine
// et sa cible en absolu, et le dossier que nomme un échec — celui qui porte
@ -857,6 +861,7 @@ export function creerFichiers({
await effacer(loc.absolu).catch(() => {});
throw erreur;
}
tenus.set(loc.absolu, seance);
return { pris: true };
}
} catch (erreur) {
@ -875,15 +880,28 @@ export function creerFichiers({
try {
texte = (await fs.readFile(loc.absolu)).toString('utf8');
} catch (erreur) {
if (ABSENCE_EN_LECTURE.has(erreur?.code)) return;
if (ABSENCE_EN_LECTURE.has(erreur?.code)) {
tenus.delete(loc.absolu);
return;
}
throw erreur;
}
if (analyserVerrou(texte)?.seance === seance) await effacer(loc.absolu);
if (analyserVerrou(texte)?.seance === seance) {
await effacer(loc.absolu);
tenus.delete(loc.absolu);
}
} catch (erreur) {
throw echecEcriture(loc, erreur);
}
},
// Hors des primitives, que servir ne publie pas : les verrous pris et
// pas rendus, [{ chemin, seance }], chemin absolu, dans l'ordre de leur
// prise. Le processus principal les efface à sa sortie.
verrousTenus() {
return [...tenus].map(([chemin, seance]) => ({ chemin, seance }));
},
// shell.openPath rend '' quand l'explorateur s'ouvre, et sinon la raison
// de l'échec.
async ouvrirDansExplorateur(racine) {

View file

@ -4,7 +4,9 @@
// Processus principal de la coquille Electron (§ 13.1) : une fenêtre qui
// charge la page construite par Vite dans www/, et le système de fichiers de
// la plateforme (electron/fichiers.js), offert à la page par un canal IPC
// par primitive, que electron/preload.cjs expose sous window.gtt.fichiers.
// par primitive, que electron/preload.cjs expose sous window.gtt.fichiers ;
// un canal de plus, gtt:copier, remet un texte au presse-papiers du système
// (window.gtt.copier).
//
// La page n'a ni Node ni accès au disque : isolation de contexte active,
// intégration de Node coupée, rendu en bac à sable. Elle n'atteint le
@ -12,8 +14,8 @@
// une racine que ce processus connaît et un chemin relatif à elle. Un nom
// importé interprété comme du balisage reste ainsi une faute d'affichage, et
// non un accès au disque.
import { app, BrowserWindow, dialog, ipcMain, Menu, shell } from 'electron';
import { mkdtempSync, rmSync } from 'node:fs';
import { app, BrowserWindow, clipboard, dialog, ipcMain, Menu, shell } from 'electron';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { readdir, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
@ -75,6 +77,28 @@ const fichiers = creerFichiers({
});
servir(fichiers, (canal, gestionnaire) => ipcMain.handle(canal, gestionnaire));
// Le presse-papiers : le texte reçu, une chaîne, remplace son contenu.
ipcMain.handle('gtt:copier', (_evenement, texte) => {
if (typeof texte !== 'string') throw new TypeError("le texte à copier n'est pas une chaîne");
clipboard.writeText(texte);
});
// Les verrous que la page a pris et pas rendus s'effacent à la sortie (§ 8.8) :
// la page ne survit pas au processus principal, et une fermeture de la fenêtre
// ne lui laisse pas le temps de les rendre elle-même. Chacun se relit, de
// façon synchrone comme le retrait du profil — Electron n'attend aucune
// promesse d'un écouteur de sortie —, et ne s'efface que s'il porte encore la
// séance qui l'a pris : un verrou repris par une autre séance reste.
app.on('quit', () => {
for (const { chemin, seance } of fichiers.verrousTenus()) {
try {
if (JSON.parse(readFileSync(chemin, 'utf8'))?.seance === seance) rmSync(chemin, { force: true });
} catch {
// Absent, illisible ou inaccessible : rien à rendre.
}
}
});
function creerFenetre() {
fenetre = new BrowserWindow({
width: 1366,

View file

@ -2,11 +2,13 @@
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Préchargement de la coquille (§ 13.1) : le seul pont entre la page et le
// processus principal. Il expose window.gtt.fichiers et rien d'autre : une
// fonction par primitive du système de fichiers, qui invoque le canal de
// cette primitive, gtt:fichiers:<primitive>, et rend la réponse du processus
// principal (electron/fichiers.js). La page ne reçoit jamais ipcRenderer
// lui-même, ni une fonction qui invoquerait un canal de son choix.
// processus principal. Il expose window.gtt et rien d'autre : sous
// gtt.fichiers, une fonction par primitive du système de fichiers, qui
// invoque le canal de cette primitive, gtt:fichiers:<primitive>, et rend la
// réponse du processus principal (electron/fichiers.js) ; gtt.copier(texte),
// qui invoque le canal gtt:copier et remet le texte au presse-papiers du
// système. La page ne reçoit jamais ipcRenderer lui-même, ni une fonction
// qui invoquerait un canal de son choix.
//
// Un préchargement exécuté en bac à sable est un script CommonJS, d'où
// l'extension .cjs dans un paquet de type module. Son require ne connaît
@ -42,4 +44,6 @@ for (const primitive of PRIMITIVES) {
fichiers[primitive] = (...parametres) => ipcRenderer.invoke(canal, ...parametres);
}
contextBridge.exposeInMainWorld('gtt', { fichiers });
const copier = (texte) => ipcRenderer.invoke('gtt:copier', texte);
contextBridge.exposeInMainWorld('gtt', { fichiers, copier });

View file

@ -7,6 +7,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- Le titre est posé par l'application, depuis ses libellés (§ 14.6). -->
<title></title>
<!-- Le collecteur d'erreurs (§ 19.2), script classique copié tel quel
depuis public/ : il s'exécute avant le module d'entrée et en
recueille aussi les erreurs d'évaluation. -->
<script src="./collecteur.js"></script>
<script type="module" src="/src/main.js"></script>
</head>
<body></body>

47
public/collecteur.js Normal file
View file

@ -0,0 +1,47 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le collecteur d'erreurs de la page (§ 19.2) : un script classique, que
// index.html charge avant le module d'entrée, et qui accumule dans
// globalThis.__gttErreurs ce qu'aucun pilote ne lit de façon portable dans la
// console — une erreur non rattrapée, une promesse rejetée sans
// gestionnaire, chaque appel de console.error —, dans l'ordre de leur
// arrivée. Le pilote relit le tableau à chaque étape et échoue sur sa
// première entrée.
//
// Une entrée est { type, message } : type vaut 'error', 'unhandledrejection'
// ou 'console.error' ; message est le texte de l'erreur, la raison du rejet,
// ou les arguments de console.error joints par une espace. Le script ne
// s'installe qu'une fois par page : chargé de nouveau, il garde le tableau
// et l'enveloppe déjà posés.
(function installer() {
'use strict';
if (Array.isArray(globalThis.__gttErreurs)) return;
var erreurs = [];
globalThis.__gttErreurs = erreurs;
// Le texte d'une valeur levée, rejetée ou passée à console.error : le
// message d'une erreur, la chaîne elle-même, sinon sa conversion.
function texte(valeur) {
if (valeur instanceof Error) return valeur.message;
try {
return String(valeur);
} catch (_) {
return Object.prototype.toString.call(valeur);
}
}
globalThis.addEventListener('error', function (evenement) {
erreurs.push({ type: 'error', message: evenement.error !== undefined && evenement.error !== null ? texte(evenement.error) : String(evenement.message) });
});
globalThis.addEventListener('unhandledrejection', function (evenement) {
erreurs.push({ type: 'unhandledrejection', message: texte(evenement.reason) });
});
var original = console.error;
console.error = function () {
var morceaux = [];
for (var i = 0; i < arguments.length; i += 1) morceaux.push(texte(arguments[i]));
erreurs.push({ type: 'console.error', message: morceaux.join(' ') });
return original.apply(console, arguments);
};
})();

View file

@ -2,13 +2,25 @@
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Essai de démarrage : construit la page, lance la vraie coquille Electron
// par Playwright, attend sa fenêtre, lit le titre et le bandeau, puis ferme.
// Il prouve ce que la coquille factice des épreuves (test/coquille.test.js)
// ne peut pas dire : qu'Electron démarre sur ce système, avec ses
// bibliothèques, et y charge la page.
// par Playwright, attend sa fenêtre, lit le titre, le bandeau et le premier
// écran, puis ferme. Il prouve ce que la coquille factice des épreuves
// (test/coquille.test.js) ne peut pas dire : qu'Electron démarre sur ce
// système, avec ses bibliothèques, y charge la page, et que l'application y
// monte jusqu'à la liste des événements.
//
// node scripts/essai_demarrage.js
//
// Le titre de la fenêtre doit valoir le nom suivi de la version affichée, et
// le bandeau — qui porte aussi provenance, thème, mode et « À propos » —
// contenir la version affichée.
//
// La coquille se lance comme publiée par le lanceur portable dans un dossier
// neuf, créé sous node_modules/.cache/ et retiré en sortant : la sonde de la
// détermination y écrit son dossier de travail. Sans lui, elle créerait le
// dossier du produit dans les vrais Documents de la machine ; et un dossier
// sous le dossier temporaire du système relève des données applicatives
// (§ 8.6, point 2), qui renvoient aux mêmes Documents.
//
// Sous Linux sans session graphique, Electron se lance sur la plateforme
// Ozone « headless » de Chromium : la fenêtre existe et la page s'y charge
// et s'y met en page, sans serveur d'affichage. Sous root, ou avec
@ -16,6 +28,8 @@
// d'utilisateur — un conteneur —, Electron se lance sans son bac à sable.
// Sort à 0 sur un démarrage conforme, à 1 sinon.
import { execFileSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { _electron } from 'playwright';
import { VERSION } from '../src/version.genere.js';
@ -35,28 +49,43 @@ execFileSync('npm', ['run', '--silent', 'build'], {
shell: process.platform === 'win32',
});
const cache = join(RACINE, 'node_modules', '.cache');
mkdirSync(cache, { recursive: true });
const publie = mkdtempSync(join(cache, 'essai-demarrage-'));
const attendu = titreAvecVersion(VERSION.affichee);
const application = await _electron.launch({
args: [
...(sansBacASable ? ['--no-sandbox'] : []),
...(sansEcran ? ['--ozone-platform=headless'] : []),
'.',
],
cwd: RACINE,
timeout: ATTENTE_MS,
});
let conforme = false;
try {
const fenetre = await application.firstWindow({ timeout: ATTENTE_MS });
await fenetre.locator('header.bandeau .titre').waitFor({ timeout: ATTENTE_MS });
const titre = await fenetre.title();
const bandeau = (await fenetre.locator('header.bandeau .titre').textContent()).trim();
conforme = titre === attendu && bandeau === attendu;
console.log(`Titre de la fenêtre : ${titre}`);
console.log(`Bandeau : ${bandeau}`);
if (!conforme) console.error(`Attendu : ${attendu}`);
const application = await _electron.launch({
args: [
...(sansBacASable ? ['--no-sandbox'] : []),
...(sansEcran ? ['--ozone-platform=headless'] : []),
'.',
],
cwd: RACINE,
env: { ...process.env, PORTABLE_EXECUTABLE_DIR: publie },
timeout: ATTENTE_MS,
});
try {
const fenetre = await application.firstWindow({ timeout: ATTENTE_MS });
// La fenêtre de la plateforme headless peut n'avoir qu'un pixel de côté :
// le bandeau s'y voit par sa largeur minimale, la liste n'y est que
// présente.
await fenetre.locator('[data-bandeau]').waitFor({ timeout: ATTENTE_MS });
await fenetre.locator('[data-ecran="accueil"]').waitFor({ state: 'attached', timeout: ATTENTE_MS });
const titre = await fenetre.title();
const bandeau = (await fenetre.locator('[data-bandeau]').textContent()).replace(/\s+/g, ' ').trim();
const erreurs = await fenetre.evaluate(() => globalThis.__gttErreurs ?? null);
conforme = titre === attendu && bandeau.includes(VERSION.affichee) && Array.isArray(erreurs) && erreurs.length === 0;
console.log(`Titre de la fenêtre : ${titre}`);
console.log(`Bandeau : ${bandeau}`);
console.log(`Erreurs recueillies : ${JSON.stringify(erreurs)}`);
if (!conforme) console.error(`Attendu : titre « ${attendu} », bandeau portant ${VERSION.affichee}, collecteur posé et vide`);
} finally {
await application.close();
}
} finally {
await application.close();
rmSync(publie, { recursive: true, force: true });
}
console.log(conforme ? 'Démarrage conforme.' : 'Démarrage non conforme.');
process.exit(conforme ? 0 : 1);

View file

@ -170,11 +170,13 @@ const EN_TETE_LICENCE = [
/**
* Contenu exact de src/version.genere.js pour une version affichée :
* l'en-tête de licence, une ligne vide, puis la constante VERSION sous ses
* formes affichée et technique. Le module ne porte que la version ; la
* provenance de la construction vient du mode de Vite.
* formes affichée, technique et Windows — celle que le panneau « À propos »
* met en regard de l'affichée (§ 18.6), sans la recalculer. Le module ne
* porte que la version ; la provenance de la construction vient du mode de
* Vite.
*/
export function engendrerModule(versionAffichee) {
const { affichee, technique } = deriver(versionAffichee);
const { affichee, technique, windows } = deriver(versionAffichee);
return [
...EN_TETE_LICENCE,
'',
@ -182,6 +184,7 @@ export function engendrerModule(versionAffichee) {
'export const VERSION = Object.freeze({',
` affichee: '${affichee}',`,
` technique: '${technique}',`,
` windows: '${windows}',`,
'});',
'',
].join('\n');

View file

@ -16,7 +16,7 @@ import {
} from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { describe, test } from '../test/lanceur.js';
import {
controler,
@ -242,6 +242,7 @@ const MODULE = [
'export const VERSION = Object.freeze({',
" affichee: '2026.10.05.01',",
" technique: '2026.1005.1',",
" windows: '2026.1005.1.0',",
'});',
'',
].join('\n');
@ -772,6 +773,20 @@ describe('version : engendrement', () => {
assert.equal(engendrerModule(VERSION), MODULE);
});
test('le module engendré porte les formes affichée, technique et Windows de deriver, et elles seules', async () => {
const dossier = mkdtempSync(join(tmpdir(), 'version-module-'));
try {
const chemin = join(dossier, 'version.genere.js');
writeFileSync(chemin, engendrerModule('2026.10.06.02'));
const { VERSION: engendree } = await import(pathToFileURL(chemin).href);
const { affichee, technique, windows } = deriver('2026.10.06.02');
assert.deepEqual(engendree, { affichee, technique, windows });
assert.ok(Object.isFrozen(engendree));
} finally {
rmSync(dossier, { recursive: true, force: true });
}
});
test('engendrer réécrit le module, package.json et les deux champs du verrou', () =>
surArbre((racine) => {
ecrire(racine, 'version.json', '{ "version": "2026.10.06.02" }\n');

View file

@ -0,0 +1,76 @@
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// Le panneau « À propos » (§ 18.6) : le bloc d'une ligne que l'opérateur
// copie dans un courriel, tel que blocAPropos l'a composé, et sa copie dans
// le presse-papiers de la plateforme. « Copier » remet exactement le texte
// affiché ; l'issue de la copie se lit sous les commandes.
import { getContext } from 'svelte';
const t = getContext('t');
let { bloc, pressePapiers, fermer } = $props();
// null avant toute copie, puis 'faite' ou 'refusee'.
let issue = $state(null);
async function copier() {
try {
await pressePapiers.copier(bloc);
issue = 'faite';
} catch {
issue = 'refusee';
}
}
</script>
<section class="apropos" data-ecran="apropos">
<h1>{t('coquille.apropos.titre')}</h1>
<p class="bloc" data-bloc>{bloc}</p>
<div class="commandes">
<button type="button" data-commande="copier" onclick={copier}>{t('coquille.apropos.copier')}</button>
<button type="button" data-commande="fermerApropos" onclick={fermer}>{t('coquille.apropos.fermer')}</button>
</div>
{#if issue === 'faite'}
<p role="status">{t('coquille.apropos.copie')}</p>
{:else if issue === 'refusee'}
<p role="status" class="refus" data-refus="copier">{t('coquille.apropos.copieRefusee')}</p>
{/if}
</section>
<style>
.bloc {
padding: var(--espace-2) var(--espace-3);
border: 1px solid var(--couleur-page-filet);
border-radius: var(--rayon-1);
font-family: monospace;
user-select: all;
overflow-wrap: anywhere;
}
.commandes {
display: flex;
flex-wrap: wrap;
gap: var(--espace-2);
}
button {
padding: var(--espace-1) var(--espace-3);
border: 1px solid var(--couleur-page-filet);
border-radius: var(--rayon-1);
background-color: var(--couleur-page-fond);
color: var(--couleur-page-texte);
font: inherit;
cursor: pointer;
}
button:focus-visible {
outline: 2px solid var(--couleur-focus);
outline-offset: 2px;
}
.refus {
color: var(--couleur-refus);
}
</style>

View file

@ -1,27 +1,40 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuve de rendu du bandeau d'application (§ 8.4, § 18.6), dans un vrai
// moteur de rendu. Le test interroge le rendu, jamais la présence d'une
// classe (§ 14.2) : la visibilité de chaque élément, la boîte où la mise en
// page le pose et l'élément qu'atteint le centre de cette boîte, le style
// calculé, le texte, le titre de la fenêtre. Il ne lit aucun pixel : le
// contraste se calcule sur les couleurs calculées, qui ne sont les couleurs
// peintes que sans opacité partielle, filtre ni mode de fusion — ce qu'une
// épreuve vérifie dans l'ascendance de chaque texte (§ 14.5). Les épreuves de
// visibilité, de contraste et d'opacité passent par les deux thèmes, que
// l'attribut data-theme de <html> choisit (§ 19.2). Les mesures viennent du
// module partagé test/navigateur/rendu.js, dont le contraste est celui de
// La coquille de l'application dans un vrai moteur de rendu : le titre de la
// fenêtre et le bandeau (§ 8.4, § 18.6), « Liste des événements » (§ 8.1),
// le panneau « À propos », le réglage de thème (§ 19.2), le collecteur
// d'erreurs, l'injection des plateformes (§ 13.4), l'ordre du montage, et la
// fermeture sans question (§ 8.4, § 8.8). Chaque épreuve monte App par
// monterApp (test/navigateur/montage.js), sur le système de fichiers simulé.
//
// Le test interroge le rendu, jamais la présence d'une classe (§ 14.2) : la
// visibilité de chaque élément, la boîte où la mise en page le pose et
// l'élément qu'atteint le centre de cette boîte, le style calculé, le texte,
// le titre de la fenêtre. Il ne lit aucun pixel : le contraste se calcule sur
// les couleurs calculées, qui ne sont les couleurs peintes que sans opacité
// partielle, filtre ni mode de fusion — ce qu'une épreuve vérifie dans
// l'ascendance de chaque texte (§ 14.5). Les épreuves de visibilité, de
// contraste et d'opacité passent par les deux thèmes, que l'attribut
// data-theme de <html> choisit (§ 19.2). Les mesures viennent du module
// partagé test/navigateur/rendu.js, dont le contraste est celui de
// palette.js, seule formule des WCAG du dépôt.
//
// Le projet navigateur n'importe pas node:assert/strict : côté navigateur,
// Vite le remplace par un module qui lève une erreur à tout accès. Il emploie
// expect, et prend test et describe chez Vitest plutôt que dans
// test/lanceur.js, dont le repli vers node:test ne vaut que pour la série node.
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
import { flushSync, mount, unmount } from 'svelte';
import App from './App.svelte';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { cdp } from 'vitest/browser';
import { flushSync } from 'svelte';
import { TABLES_APPLICATION } from '../application/libelles.js';
import { creerTraduction } from '../application/traduction.js';
import { LIVREES } from '../demo/livrees.js';
import { FORMAT } from '../stockage/document.js';
import { ErreurStockage } from '../stockage/erreurs.js';
import { VERSION } from '../version.genere.js';
import COLLECTEUR from '../../public/collecteur.js?raw';
import { monterApp } from '../../test/navigateur/montage.js';
import {
attenuations,
composantes,
@ -33,47 +46,676 @@ import {
sousTheme,
visible,
} from '../../test/navigateur/rendu.js';
import { blocAPropos } from './apropos.js';
import { TABLES_INTERFACE, libelleProvenance } from './libelles.js';
import { SECTION_OUVERTURE } from './evenement/sections.js';
import { cleDeMode } from './mode.js';
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
const TRANSPARENT = 'rgba(0, 0, 0, 0)';
const THEMES = ['clair', 'sombre'];
let composant;
// Un événement d'épreuve : le fichier d'état d'une démonstration livrée, son
// nom remplacé par un nom inventé, au format donné.
const PETITE = LIVREES.find(({ cle }) => cle === 'petite').texte;
const CONFLIT = LIVREES.find(({ cle }) => cle === 'petite-conflit').texte;
function evenement(nom, { texte = PETITE, format = FORMAT } = {}) {
const renomme = texte.replace(/"nom":"[^"]*"/, `"nom":${JSON.stringify(nom)}`);
const auFormat = renomme.replace(`"format":${FORMAT}`, `"format":${format}`);
if (!renomme.includes(JSON.stringify(nom)) || !auFormat.includes(`"format":${format}`)) {
throw new Error("le fichier d'épreuve ne se compose pas");
}
return auFormat;
}
const fichier = (base, nom, options) => ({ racine: 'documents', chemin: `${base}.gtt.json`, texte: evenement(nom, options) });
const verrouDe = (m, base) => m.fs.contenu('documents', `${base}.gtt.verrou`);
// Les montages de l'épreuve en cours, démontés après elle ; le collecteur
// n'a rien reçu pendant l'épreuve, sauf quand elle le veut.
let montes = [];
let erreursAdmises = false;
let erreursAvant = 0;
async function monter(options) {
const m = await monterApp(options);
montes.push(m);
return m;
}
beforeEach(() => {
composant = mount(App, { target: document.body });
flushSync();
erreursAvant = globalThis.__gttErreurs?.length ?? 0;
erreursAdmises = false;
});
afterEach(() => {
unmount(composant);
for (const m of montes.reverse()) m.demonter();
montes = [];
vi.restoreAllMocks();
const recues = globalThis.__gttErreurs?.slice(erreursAvant) ?? [];
if (!erreursAdmises) expect(recues, 'erreurs reçues par le collecteur').toEqual([]);
});
function bandeau() {
return document.body.querySelector('header.bandeau');
const dans = (m, selecteur) => m.racine.querySelector(selecteur);
const bandeau = (m) => dans(m, '[data-bandeau]');
const libelleDeMode = (m) => dans(m, '[data-mode-libelle="bandeau"]');
const ecran = (m) => dans(m, '[data-ecran]');
// Un montage dont l'événement « Bal des Grisailles » est ouvert, en lecture.
async function avecEvenementOuvert(options = {}) {
const m = await monter({ fichiers: [fichier('bal', 'Bal des Grisailles')], ...options });
await m.seance.ouvrir('bal');
flushSync();
return m;
}
// Le bandeau, puis chacun de ses porteurs de texte.
function bandeauEtTextes() {
return [{ element: bandeau(), pseudo: null }, ...porteursDeTexte(bandeau())];
}
const bandeauEtTextes = (m) => [{ element: bandeau(m), pseudo: null }, ...porteursDeTexte(bandeau(m))];
describe("bandeau d'application", () => {
test('existe et porte la version affichée', () => {
expect(bandeau()).not.toBeNull();
expect(bandeau().textContent).toContain(VERSION.affichee);
describe('titre et bandeau (§ 8.4, § 18.6)', () => {
test('le titre de la fenêtre vaut exactement le nom suivi de la version affichée', async () => {
await monter();
expect(document.title).toBe(`Gestion table tournante Libre — ${VERSION.affichee}`);
});
test('le bandeau et chacun de ses textes sont visibles dans la fenêtre, dans les deux thèmes (§ 14.2, § 18.6)', async () => {
test('le bandeau est visible, dans la fenêtre, et porte la version affichée et la provenance ; sans événement, aucun libellé de mode', async () => {
const m = await monter();
expect(visible(bandeau(m))).toBe(true);
expect(bandeau(m).textContent).toContain(VERSION.affichee);
expect(bandeau(m).textContent).toContain(libelleProvenance('test'));
expect(libelleDeMode(m)).toBeNull();
});
test('un événement en lecture, en écriture, puis bloqué : le libellé de mode de chaque état, de la clé que rend cleDeMode', async () => {
const m = await avecEvenementOuvert();
const attendus = [];
const relever = () => {
flushSync();
const cle = cleDeMode(m.seance.etat());
attendus.push(cle);
expect(visible(libelleDeMode(m))).toBe(true);
expect(libelleDeMode(m).textContent).toBe(t(cle));
};
relever();
await m.seance.passerEnEcriture();
relever();
await m.seance.executer('changerEtat', { etat: 'bloque' });
relever();
expect(attendus).toEqual(['coquille.mode.lecture', 'coquille.mode.ecriture', 'coquille.mode.bloque']);
});
test('le bandeau ne défile pas : la page défilée jusqu’en bas, il reste dans la fenêtre et son centre l’atteint', async () => {
const m = await monter();
const haut = document.createElement('div');
haut.style.height = '3000px';
ecran(m).append(haut);
window.scrollTo(0, document.documentElement.scrollHeight);
expect(window.scrollY).toBeGreaterThan(0);
const boite = bandeau(m).getBoundingClientRect();
expect(boite.top).toBeGreaterThanOrEqual(0);
expect(boite.bottom).toBeLessThanOrEqual(window.innerHeight);
const atteint = document.elementFromPoint(boite.left + boite.width / 2, boite.top + boite.height / 2);
expect(bandeau(m).contains(atteint)).toBe(true);
window.scrollTo(0, 0);
});
test('construction de documentation : la marque remplace le numéro ; en livraison, le numéro', async () => {
const documentation = await monter({ provenance: 'documentation' });
expect(bandeau(documentation).textContent).not.toContain(VERSION.affichee);
expect(bandeau(documentation).textContent).toContain(t('coquille.version.documentation'));
const accueil = (m) => dans(m, '[data-ecran="accueil"]');
expect(accueil(documentation).textContent).not.toContain(VERSION.affichee);
expect(accueil(documentation).textContent).toContain(t('coquille.version.documentation'));
documentation.demonter();
montes = [];
const livraison = await monter({ provenance: 'livraison' });
expect(bandeau(livraison).textContent).toContain(VERSION.affichee);
expect(bandeau(livraison).textContent).toContain(libelleProvenance('livraison'));
expect(accueil(livraison).textContent).toContain(VERSION.affichee);
expect(accueil(livraison).textContent).not.toContain(t('coquille.version.documentation'));
});
});
describe('annonce de format (§ 8.8, § 18.6)', () => {
const annonce = (m) => dans(m, '[data-avertissement="annonceFormat"]');
test('un fichier au format FORMAT + 1 : « ce fichier s’ouvre en lecture seule » dans le bandeau', async () => {
const m = await monter({ fichiers: [fichier('bal', 'Bal des Grisailles', { format: FORMAT + 1 })] });
await m.seance.ouvrir('bal');
flushSync();
expect(bandeau(m).contains(annonce(m))).toBe(true);
expect(annonce(m).textContent).toBe(t('coquille.format.plusRecent'));
expect(annonce(m).textContent).toMatch(/s.ouvre en lecture seule/);
});
test('au format courant : aucune annonce', async () => {
const m = await avecEvenementOuvert();
expect(annonce(m)).toBeNull();
});
test('formatCourant à FORMAT + 1, fichier au format FORMAT : « ce fichier se convertira… », et l’ouverture n’ajoute rien au relevé', async () => {
const m = await monter({ formatCourant: FORMAT + 1, fichiers: [fichier('bal', 'Bal des Grisailles')] });
const avant = m.trace.length;
await m.seance.ouvrir('bal');
flushSync();
expect(annonce(m).textContent).toBe(t('coquille.format.plusAncien'));
expect(annonce(m).textContent).toMatch(/se convertira à votre première modification/);
expect(m.trace.length).toBe(avant);
});
});
describe('« Liste des événements » (§ 8.1, § 8.4)', () => {
const commande = (m) => dans(m, '[data-commande="liste"]');
test('absente sans événement ouvert', async () => {
const m = await monter();
expect(commande(m)).toBeNull();
});
test('un événement en écriture, la commande cliquée : l’accueil revient, sans fenêtre ni question, le verrou rendu, aucun libellé de mode', async () => {
const fenetres = ['confirm', 'alert', 'prompt'].map((nom) => vi.spyOn(window, nom));
const m = await avecEvenementOuvert();
await m.seance.passerEnEcriture();
expect(verrouDe(m, 'bal')).not.toBeNull();
flushSync();
expect(visible(commande(m))).toBe(true);
commande(m).click();
await vi.waitFor(() => expect(m.seance.etat().evenement).toBeNull());
flushSync();
expect(ecran(m).dataset.ecran).toBe('accueil');
expect(verrouDe(m, 'bal')).toBeNull();
expect(libelleDeMode(m)).toBeNull();
for (const fenetre of fenetres) expect(fenetre).not.toHaveBeenCalled();
});
test('sous un support rompu, la séance refuse de fermer : le refus s’affiche à côté de la commande, et l’événement reste ouvert', async () => {
const m = await avecEvenementOuvert();
await m.seance.passerEnEcriture();
m.fs.pannes.ecriture('documents');
await expect(m.seance.executer('ajouterParticipant', { champs: { nom: 'Ombrelle', prenom: 'Iris' } })).rejects.toMatchObject({
code: 'ECRITURE',
});
expect(m.seance.etat().supportRompu).not.toBeNull();
const leve = await m.seance.fermer().then(
() => null,
(erreur) => erreur,
);
expect(leve?.code).toBe('SUPPORT_ROMPU_NON_SAUVE');
flushSync();
commande(m).click();
const refus = await vi.waitFor(() => {
const element = dans(m, '[data-refus="liste"]');
expect(element).not.toBeNull();
return element;
});
const remede = leve.details.remede;
expect(remede.geste).toBe('ecrireAilleurs');
const cleRemede = `communs.remede.ecrireAilleurs.${remede.racine ? 'documents' : 'choisi'}`;
expect(refus.textContent).toContain(t(leve.code, leve.details));
const commandeRemede = refus.querySelector('[data-commande="ecrireAilleurs"]');
expect(commandeRemede?.textContent).toBe(t(cleRemede));
expect(visible(refus)).toBe(true);
expect(visible(commandeRemede)).toBe(true);
// Posé dans le bandeau, le refus garde sa lisibilité : chaque texte tient
// 4,5:1 sur chaque couleur peinte sous lui, dans les deux thèmes.
for (const theme of THEMES) {
await sousTheme(theme, () => {
for (const porteur of porteursDeTexte(refus)) {
const lieu = `${theme} : ${designation(porteur)}`;
const texte = couleurTexte(porteur);
for (const fond of fondPeintSous(porteur)) {
expect(contraste(texte, fond), `${lieu} sur ${fond.join(', ')}`).toBeGreaterThanOrEqual(4.5);
}
}
});
}
expect(m.seance.etat().evenement?.base).toBe('bal');
expect(ecran(m).dataset.ecran).toBe('evenement');
});
});
describe('la liste relue', () => {
test('un événement chargé puis fermé : la liste revenue le porte', async () => {
const m = await monter();
expect(m.racine.querySelectorAll('[data-evenement]').length).toBe(0);
const { base } = await m.facade.chargerDemonstration('petite');
flushSync();
expect(ecran(m).dataset.ecran).toBe('evenement');
dans(m, '[data-commande="liste"]').click();
await vi.waitFor(() => expect(m.seance.etat().evenement).toBeNull());
await vi.waitFor(() => expect(m.racine.querySelectorAll('[data-evenement]').length).toBe(1));
expect(dans(m, '[data-evenement]').dataset.evenement).toBe(base);
});
test('une relecture refusée : le refus traduit tient lieu de liste, sans ligne d’avant ; la relecture suivante le retire', async () => {
const m = await monter({ fichiers: [fichier('bal', 'Bal des Grisailles')] });
expect(m.racine.querySelectorAll('[data-evenement]').length).toBe(1);
expect(dans(m, '[data-refus="accueil"]')).toBeNull();
// L'ouverture relève aussi le dossier : le relevé ne se refuse qu'après
// elle.
await m.seance.ouvrir('bal');
const refuse = new ErreurStockage('LECTURE', { chemin: '', cause: 'EACCES' });
const lister = m.fs.lister;
m.fs.lister = async () => {
throw refuse;
};
await m.seance.fermer();
const refus = await vi.waitFor(() => {
const element = dans(m, '[data-refus="accueil"]');
expect(element).not.toBeNull();
return element;
});
expect(refus.textContent).toBe(t(refuse.code, refuse.details));
expect(visible(refus)).toBe(true);
expect(m.racine.querySelectorAll('[data-evenement]').length).toBe(0);
m.fs.lister = lister;
await m.seance.ouvrir('bal');
await m.seance.fermer();
await vi.waitFor(() => expect(m.racine.querySelectorAll('[data-evenement]').length).toBe(1));
expect(dans(m, '[data-refus="accueil"]')).toBeNull();
});
test('une détermination refusée : pret se résout, et l’accueil montre le refus traduit, sans ligne', async () => {
// Un disque dont le relevé d'un dossier se refuse : la détermination du
// dossier de travail, qui relève le dossier retenu, rejette.
const refuse = new ErreurStockage('LECTURE', { chemin: '', cause: 'EACCES' });
class DisqueIllisible extends Map {
values() {
throw refuse;
}
}
const m = await monter({ systeme: { disque: new DisqueIllisible() } });
expect(ecran(m)?.dataset.ecran).toBe('accueil');
const refus = dans(m, '[data-refus="accueil"]');
expect(refus?.textContent).toBe(t(refuse.code, refuse.details));
expect(visible(refus)).toBe(true);
expect(m.racine.querySelectorAll('[data-evenement]').length).toBe(0);
});
test('une détermination refusée au montage, puis réussie par un nouveau demarrer : la liste se lit, et le refus part', async () => {
// Un disque dont seul le premier relevé se refuse : la façade ne garde
// rien d'une détermination refusée, et le demarrer suivant la refait.
const refuse = new ErreurStockage('LECTURE', { chemin: '', cause: 'EACCES' });
let releves = 0;
class DisqueUneFoisIllisible extends Map {
values() {
releves += 1;
if (releves === 1) throw refuse;
return super.values();
}
}
const m = await monter({ systeme: { disque: new DisqueUneFoisIllisible() } });
expect(dans(m, '[data-refus="accueil"]')?.textContent).toBe(t(refuse.code, refuse.details));
await m.facade.demarrer();
await vi.waitFor(() => expect(dans(m, '[data-refus="accueil"]')).toBeNull());
});
test('deux lectures qui se chevauchent : la plus ancienne, finie la dernière, ne remet pas sa liste', async () => {
const m = await monter({ fichiers: [fichier('bal', 'Bal des Grisailles')] });
expect(m.racine.querySelectorAll('[data-evenement]').length).toBe(1);
// La première lecture d'après la fermeture reste suspendue sur le
// verrou de « bal », après avoir relevé le dossier.
let liberer;
const barriere = new Promise((resoudre) => {
liberer = resoudre;
});
let suspendue = null;
const lireTexte = m.fs.lireTexte;
m.fs.lireTexte = (racine, chemin) => {
if (chemin === 'bal.gtt.verrou' && suspendue === null) {
suspendue = barriere.then(() => lireTexte.call(m.fs, racine, chemin));
return suspendue;
}
return lireTexte.call(m.fs, racine, chemin);
};
await m.seance.ouvrir('bal');
await m.seance.fermer();
await vi.waitFor(() => expect(suspendue).not.toBeNull());
// Un second événement paraît ; une seconde fermeture relit la liste, qui
// le porte.
m.fs.deposer('documents', 'club.gtt.json', evenement('Club des Merles', { texte: CONFLIT }));
await m.seance.ouvrir('bal');
await m.seance.fermer();
await vi.waitFor(() => expect(m.racine.querySelectorAll('[data-evenement]').length).toBe(2));
liberer();
await suspendue.catch(() => undefined);
// Le verrou est la dernière lecture de la liste suspendue : une image
// plus tard, elle a rendu ses lignes.
await new Promise((resoudre) => requestAnimationFrame(() => resoudre()));
flushSync();
expect(m.racine.querySelectorAll('[data-evenement]').length).toBe(2);
});
});
describe('l’événement ouvert et sa navigation', () => {
test('chaque ouverture remet la navigation à son départ : la section d’ouverture, montrée', async () => {
const m = await avecEvenementOuvert();
expect(m.ui.etat().section).toBe(SECTION_OUVERTURE);
expect(dans(m, `[data-section="${SECTION_OUVERTURE}"]`)).not.toBeNull();
dans(m, '[data-commande="section"][data-cible="tables"]').click();
flushSync();
expect(m.ui.etat().section).toBe('tables');
expect(dans(m, '[data-section="tables"]')).not.toBeNull();
dans(m, '[data-commande="liste"]').click();
await vi.waitFor(() => expect(m.seance.etat().evenement).toBeNull());
await m.seance.ouvrir('bal');
flushSync();
expect(m.ui.etat().section).toBe(SECTION_OUVERTURE);
expect(dans(m, '[data-section]').dataset.section).toBe(SECTION_OUVERTURE);
});
});
describe('« À propos » (§ 18.6)', () => {
const panneau = (m) => dans(m, '[data-ecran="apropos"]');
test('le panneau s’ouvre depuis le bandeau sur la liste puis sur un événement ; « Copier » remet exactement le bloc affiché', async () => {
const m = await monter({ fichiers: [fichier('bal', 'Bal des Grisailles')] });
const attendu = blocAPropos({ version: VERSION, plateforme: m.fs.nature, provenance: 'test', format: FORMAT }, t);
dans(m, '[data-commande="apropos"]').click();
flushSync();
expect(visible(panneau(m))).toBe(true);
const bloc = dans(m, '[data-bloc]');
expect(bloc.textContent).toBe(attendu);
dans(m, '[data-commande="copier"]').click();
await vi.waitFor(() => expect(m.presse.copies).toEqual([bloc.textContent]));
dans(m, '[data-commande="fermerApropos"]').click();
flushSync();
expect(ecran(m).dataset.ecran).toBe('accueil');
await m.seance.ouvrir('bal');
flushSync();
expect(ecran(m).dataset.ecran).toBe('evenement');
dans(m, '[data-commande="apropos"]').click();
flushSync();
expect(visible(panneau(m))).toBe(true);
expect(dans(m, '[data-bloc]').textContent).toBe(attendu);
});
});
describe('thème (§ 19.2)', () => {
const preferer = (valeur) =>
cdp().send('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-color-scheme', value: valeur }] });
const themePose = () => document.documentElement.getAttribute('data-theme');
test('(a) monté sans réglage, préférence sombre du système : thème sombre', async () => {
await preferer('dark');
try {
expect(window.matchMedia('(prefers-color-scheme: dark)').matches).toBe(true);
const m = await monter({ theme: null });
flushSync();
expect(themePose()).toBe('sombre');
expect(dans(m, '[data-commande="theme"][data-theme-choix="sombre"]').getAttribute('aria-pressed')).toBe('true');
} finally {
await preferer('');
}
});
test('(b) le réglage basculé à clair ne survit pas : un second montage sur le même disque repart de la préférence, et rien n’est écrit au stockage du navigateur', async () => {
const ecritures = vi.spyOn(Storage.prototype, 'setItem');
await preferer('dark');
try {
const premier = await monter({ theme: null });
dans(premier, '[data-commande="theme"][data-theme-choix="clair"]').click();
flushSync();
expect(themePose()).toBe('clair');
premier.demonter();
montes = [];
await monter({ theme: null, systeme: { disque: premier.disque } });
flushSync();
expect(themePose()).toBe('sombre');
expect(ecritures).not.toHaveBeenCalled();
} finally {
await preferer('');
}
});
test('(c) basculer le réglage change les couleurs calculées du corps et du bandeau, sans rechargement', async () => {
const m = await monter();
const couleurs = () =>
[document.body, bandeau(m)].map((el) => {
const style = getComputedStyle(el);
return `${style.color} sur ${style.backgroundColor}`;
});
const clair = couleurs();
const boutonSombre = dans(m, '[data-commande="theme"][data-theme-choix="sombre"]');
expect(boutonSombre.getAttribute('aria-pressed')).toBe('false');
boutonSombre.click();
flushSync();
expect(themePose()).toBe('sombre');
expect(boutonSombre.getAttribute('aria-pressed')).toBe('true');
const sombre = couleurs();
expect(sombre[0]).not.toBe(clair[0]);
expect(sombre[1]).not.toBe(clair[1]);
});
});
describe('collecteur d’erreurs (§ 19.2)', () => {
// Un cadre neuf dont la page charge le collecteur, puis le contenu donné.
async function cadre(contenu) {
const element = document.createElement('iframe');
const charge = new Promise((resoudre) => element.addEventListener('load', resoudre, { once: true }));
element.srcdoc = `<!doctype html><html><head><script>${COLLECTEUR}</` + `script>${contenu}</head><body><button id="b"></button></body></html>`;
document.body.append(element);
await charge;
return element;
}
// Exécute du code dans la page du cadre, par un script classique inséré.
function executer(element, code) {
const doc = element.contentDocument;
const script = doc.createElement('script');
script.textContent = code;
doc.body.append(script);
}
test('une erreur à l’évaluation d’un module chargé après lui est au tableau', async () => {
const element = await cadre(`<script type="module">throw new Error('évaluation du module');</` + 'script>');
try {
const erreurs = element.contentWindow.__gttErreurs;
expect(erreurs.map(({ type }) => type)).toEqual(['error']);
expect(erreurs[0].message).toContain('évaluation du module');
} finally {
element.remove();
}
});
test('une erreur levée dans un gestionnaire, une promesse rejetée sans gestionnaire, un console.error : trois entrées, dans cet ordre', async () => {
const element = await cadre('');
try {
const erreurs = element.contentWindow.__gttErreurs;
expect(erreurs).toEqual([]);
executer(element, "document.getElementById('b').addEventListener('click', () => { throw new Error('gestionnaire'); }); document.getElementById('b').click();");
expect(erreurs.length).toBe(1);
executer(element, "Promise.reject(new Error('rejet'));");
await vi.waitFor(() => expect(erreurs.length).toBe(2));
executer(element, "console.error('console', 1);");
expect(erreurs.map(({ type }) => type)).toEqual(['error', 'unhandledrejection', 'console.error']);
expect(erreurs.map(({ message }) => message)).toEqual([
expect.stringContaining('gestionnaire'),
expect.stringContaining('rejet'),
'console 1',
]);
} finally {
element.remove();
}
});
test('sous monterApp, un console.error paraît dans erreurs()', async () => {
erreursAdmises = true;
const m = await monter();
expect(m.erreurs()).toEqual([]);
console.error('épreuve du collecteur');
expect(m.erreurs()).toEqual([{ type: 'console.error', message: 'épreuve du collecteur' }]);
});
});
describe('injection des plateformes (§ 13.4)', () => {
test('deux événements au dossier : ni OPFS ni pont, et le montage n’écrit que la sonde du dossier retenu', async () => {
const opfs = vi.spyOn(navigator.storage, 'getDirectory');
const acces = [];
const pont = new Proxy({}, {
get(_cible, cle) {
acces.push(String(cle));
return undefined;
},
});
window.gtt = pont;
try {
const m = await monter({
fichiers: [fichier('bal', 'Bal des Grisailles'), fichier('club', 'Club des Merles', { texte: CONFLIT })],
});
expect(m.racine.querySelectorAll('[data-evenement]').length).toBe(2);
expect(opfs).not.toHaveBeenCalled();
expect(acces).toEqual([]);
expect(m.trace).toEqual([['sonder', 'documents', '']]);
} finally {
delete window.gtt;
}
});
test('deux montages simultanés, chacun dans son conteneur, ne se mêlent pas', async () => {
const a = await monter({ fichiers: [fichier('bal', 'Bal des Grisailles')] });
const b = await monter({ fichiers: [fichier('club', 'Club des Merles', { texte: CONFLIT })] });
expect(a.racine).not.toBe(b.racine);
expect(a.fs).not.toBe(b.fs);
expect(a.seance).not.toBe(b.seance);
expect(a.racine.textContent).toContain('Bal des Grisailles');
expect(b.racine.textContent).toContain('Club des Merles');
expect(a.racine.textContent).not.toContain('Club des Merles');
expect(b.racine.textContent).not.toContain('Bal des Grisailles');
await a.seance.ouvrir('bal');
flushSync();
expect(libelleDeMode(a)).not.toBeNull();
expect(libelleDeMode(b)).toBeNull();
expect(b.seance.etat().evenement).toBeNull();
});
});
describe('le montage d’épreuve', () => {
test('aImporter : le dialogue rend chaque fichier, dans l’ordre, puis null ; les identifiants portent le rang du montage, distincts d’un montage à l’autre', async () => {
const premier = { nom: 'invites.csv', octets: new Uint8Array([0x6e, 0x6f, 0x6d]) };
const second = { nom: 'accueil.csv', octets: new Uint8Array([0x41]) };
const a = await monter({ aImporter: [premier, second] });
expect(await a.facade.choisirCsv()).toBe(premier);
expect(await a.facade.choisirCsv()).toBe(second);
expect(await a.facade.choisirCsv()).toBeNull();
const b = await monter();
expect(await b.facade.choisirCsv()).toBeNull();
expect(a.seance.id).toMatch(/^epreuve-\d+-\d+$/);
expect(b.seance.id).toMatch(/^epreuve-\d+-\d+$/);
expect(a.seance.id.split('-')[1]).not.toBe(b.seance.id.split('-')[1]);
});
});
describe('l’ordre du montage', () => {
test('non publié, Documents non inscriptible : pret se résout, la façade rend sa question, la séance n’a pas de racine', async () => {
const m = await monter({ systeme: { sonde: { documents: 'EROFS' } } });
// Lu dès pret, avant tout appel de l'épreuve à la façade : la
// détermination est celle qu'App a faite.
expect(m.trace).toContainEqual(['sonder', 'documents', '']);
expect(m.seance.etat().racine).toBeNull();
expect(ecran(m)).not.toBeNull();
const { dossier } = await m.facade.demarrer();
expect(dossier.question?.code).toBe('DOCUMENTS_NON_INSCRIPTIBLE');
expect(m.seance.etat().racine).toBeNull();
});
test('sans question : dès pret, la racine de la séance est celle du dossier retenu', async () => {
const m = await monter();
// Lu dès pret, avant tout appel de l'épreuve à la façade : la racine
// est celle qu'App a fait poser.
expect(m.trace).toEqual([['sonder', 'documents', '']]);
const posee = m.seance.etat().racine;
expect(posee?.id).toBe('documents');
const { dossier } = await m.facade.demarrer();
expect(dossier.question).toBeNull();
expect(m.seance.etat().racine).toEqual(dossier.racine);
expect(m.seance.etat().racine).toBe(posee);
});
test('sans question : dès pret, le premier écran porte la liste du dossier retenu', async () => {
// Lu aussitôt après pret, sans attente : pret ne se résout qu'une fois
// les lignes de la liste rendues.
const m = await monter({
fichiers: [fichier('bal', 'Bal des Grisailles'), fichier('club', 'Club des Merles', { texte: CONFLIT })],
});
const lignes = [...m.racine.querySelectorAll('[data-evenement]')];
expect(lignes.length).toBe(2);
expect(lignes.map((ligne) => ligne.dataset.evenement).sort()).toEqual(['bal', 'club']);
expect(m.racine.textContent).toContain('Bal des Grisailles');
expect(m.racine.textContent).toContain('Club des Merles');
});
test('Documents non inscriptible : dès pret, l’accueil est rendu sans aucune ligne, la question attend', async () => {
const m = await monter({ systeme: { sonde: { documents: 'EROFS' } }, fichiers: [fichier('bal', 'Bal des Grisailles')] });
expect(ecran(m)?.dataset.ecran).toBe('accueil');
expect(m.racine.querySelectorAll('[data-evenement]').length).toBe(0);
expect(m.racine.querySelectorAll('[data-demonstration]').length).toBeGreaterThan(0);
// Une lecture de la liste pendant que la question attend serait refusée
// (QUESTION_EN_ATTENTE), et ce refus s'afficherait à la place des lignes.
expect(dans(m, '[data-refus="accueil"]')).toBeNull();
});
test('Documents non inscriptible, « continuer » répondu : la liste est lue après la réponse, ses lignes rendues, sans refus', async () => {
const m = await monter({ systeme: { sonde: { documents: 'EROFS' } }, fichiers: [fichier('bal', 'Bal des Grisailles')] });
// Chaque lecture de la liste passe par la façade de ce montage ; une
// lecture faite pendant que la question attend se voit à son refus.
const issues = [];
const lister = m.facade.lister;
vi.spyOn(m.facade, 'lister').mockImplementation(async () => {
try {
const lues = await lister();
issues.push('lue');
return lues;
} catch (erreur) {
issues.push(erreur?.code ?? 'faute');
throw erreur;
}
});
await m.facade.repondre('continuer');
await vi.waitFor(() => expect(m.racine.querySelectorAll('[data-evenement]').length).toBe(1));
expect(dans(m, '[data-evenement]').dataset.evenement).toBe('bal');
expect(dans(m, '[data-refus="accueil"]')).toBeNull();
expect(issues).not.toContain('QUESTION_EN_ATTENTE');
expect(issues).toContain('lue');
});
});
describe('fermer sans question (§ 8.1, § 8.4, § 8.8)', () => {
test('aucun écouteur beforeunload ; au pagehide, la séance se ferme et rend son verrou', async () => {
const ecouteurs = vi.spyOn(window, 'addEventListener');
const m = await avecEvenementOuvert();
const poses = ecouteurs.mock.calls.map(([type]) => type);
expect(poses).toContain('pagehide');
expect(poses).not.toContain('beforeunload');
await m.seance.passerEnEcriture();
expect(verrouDe(m, 'bal')).not.toBeNull();
window.dispatchEvent(new PageTransitionEvent('pagehide', { persisted: false }));
await vi.waitFor(() => expect(m.seance.etat().evenement).toBeNull());
expect(verrouDe(m, 'bal')).toBeNull();
});
});
describe('rendu du bandeau, dans les deux thèmes (§ 14.2, § 14.5)', () => {
// Un événement ouvert en écriture : le bandeau porte alors tout ce qu'il
// peut porter — libellé de mode, « Liste des événements », thèmes, « À
// propos ».
async function bandeauComplet() {
const m = await avecEvenementOuvert();
await m.seance.passerEnEcriture();
flushSync();
expect(libelleDeMode(m)).not.toBeNull();
return m;
}
test('le bandeau et chacun de ses textes sont visibles dans la fenêtre', async () => {
// Le texte, le titre et le style calculé se relisent sur un élément qui
// ne peint rien. visible interroge donc la mise en page : checkVisibility,
// une boîte non vide dans la fenêtre, et le centre de cette boîte qui
// atteint l'élément ou l'un de ses descendants sans qu'un élément peint
// au-dessus y peigne — un élément recouvert, ou rogné par un ancêtre,
// échoue.
const m = await bandeauComplet();
for (const theme of THEMES) {
await sousTheme(theme, () => {
const porteurs = bandeauEtTextes();
expect(porteurs.length).toBeGreaterThan(1);
const porteurs = bandeauEtTextes(m);
expect(porteurs.length).toBeGreaterThan(4);
for (const porteur of porteurs) {
expect(visible(porteur), `${theme} : ${designation(porteur)}`).toBe(true);
}
@ -81,29 +723,20 @@ describe("bandeau d'application", () => {
}
});
test('porte la provenance de la construction', () => {
expect(import.meta.env.MODE).toBe('test');
expect(bandeau().textContent).toContain('épreuve');
});
test('le titre de la fenêtre porte le nom et la version affichée', () => {
expect(document.title).toBe(
`Gestion table tournante Libre — ${VERSION.affichee}`,
);
});
test('le texte et le fond du bandeau ont une couleur calculée', () => {
const style = getComputedStyle(bandeau());
test('le texte et le fond du bandeau ont une couleur calculée', async () => {
const m = await bandeauComplet();
const style = getComputedStyle(bandeau(m));
expect(style.color).not.toBe(TRANSPARENT);
expect(style.backgroundColor).not.toBe(TRANSPARENT);
});
test('chaque texte du bandeau tient 4,5:1 sur chaque couleur peinte sous lui, dans les deux thèmes (§ 14.5)', async () => {
test('chaque texte du bandeau tient 4,5:1 sur chaque couleur peinte sous lui', async () => {
const m = await bandeauComplet();
const couleursParTheme = [];
for (const theme of THEMES) {
const couleurs = await sousTheme(theme, () => {
const textes = porteursDeTexte(bandeau());
expect(textes.length).toBeGreaterThan(0);
const textes = porteursDeTexte(bandeau(m));
expect(textes.length).toBeGreaterThan(4);
for (const porteur of textes) {
const lieu = `${theme} : ${designation(porteur)}`;
const texte = couleurTexte(porteur);
@ -114,7 +747,7 @@ describe("bandeau d'application", () => {
expect(contraste(texte, fond), `${lieu} sur ${fond.join(', ')}`).toBeGreaterThanOrEqual(4.5);
}
}
return [document.body, bandeau()].map((el) => {
return [document.body, bandeau(m)].map((el) => {
const style = getComputedStyle(el);
return `${composantes(style.color)} sur ${composantes(style.backgroundColor)}`;
});
@ -129,14 +762,15 @@ describe("bandeau d'application", () => {
).not.toEqual(couleursParTheme[0]);
});
test("aucune opacité partielle, aucun filtre, masque ni mode de fusion sous un texte, dans les deux thèmes (§ 14.5)", async () => {
test('aucune opacité partielle, aucun filtre, masque ni mode de fusion sous un texte', async () => {
// opacity et filter ne s'héritent pas : posés sur un ancêtre, ils changent
// le pixel peint sans changer le style calculé du texte. attenuations
// relit donc chaque niveau de l'ascendance, jusqu'à la racine du document.
const m = await bandeauComplet();
for (const theme of THEMES) {
await sousTheme(theme, () => {
const textes = porteursDeTexte(bandeau());
expect(textes.length).toBeGreaterThan(0);
const textes = porteursDeTexte(bandeau(m));
expect(textes.length).toBeGreaterThan(4);
for (const porteur of textes) {
const fautes = attenuations(porteur).map(
(f) => `${designation({ element: f.element, pseudo: f.pseudo })} ${f.propriete}: ${f.valeur}`,

View file

@ -2,27 +2,276 @@
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// Racine de l'interface. Le bandeau d'application, présent sur tout écran,
// porte le nom, la version affichée et la provenance de la construction ;
// le nom suivi de la version affichée devient aussi le titre de la fenêtre
// (§ 8.4, § 18.6). La provenance vient du mode de Vite, que pose la
// commande de construction.
// Racine de l'interface (§ 13.4). Ses propriétés sont les dépendances de
// l'application — système de fichiers, horloge, exécuteur de génération,
// presse-papiers, identifiants, langue, provenance, démonstrations, format
// courant, version du produit, et en option le thème de départ — que
// src/main.js construit pour la machine et monterApp pour une épreuve : App
// ne choisit aucune plateforme.
//
// À son initialisation, App crée une fois chacun la séance, sans racine,
// le contrôleur de génération et la façade, et les donne aux composants par
// le contexte de Svelte — clés 'seance', 'facade', 'generation', 'ui', 't',
// 'format', 'horloge' et 'relireListe' —, posé de façon synchrone avant que
// la détermination du dossier de travail, asynchrone, ne rende. La façade
// pose la racine de la séance par changerRacine : aussitôt sans question,
// après la réponse sinon. App lit la liste du dossier de travail par la
// façade et la donne à l'accueil, une fois la détermination réglée — sans
// question au démarrage, après la réponse sinon : pret se résout quand la
// détermination est faite, la liste lue quand aucune question n'attend, et
// le premier écran — les lignes de la liste, ou la question — rendu.
//
// L'écran suit la séance : la liste sans événement ouvert, l'événement
// sinon ; le panneau « À propos » les remplace tant qu'il est ouvert. Chaque
// ouverture d'un événement remet l'état de navigation à son départ. Le
// bandeau, présent sur tout écran, ne défile pas : un écran plus haut que
// la fenêtre défile sous lui.
//
// Le thème part de la propriété themeInitial, ou de la préférence du
// système lue au montage ; le réglage de l'opérateur le change pour la
// séance, en mémoire, posé en data-theme sur <html> (§ 19.2). À la sortie
// de la page (pagehide), la séance ferme l'événement et rend son verrou,
// sans question ; aucun beforeunload ne retient la page (§ 8.4).
import './jetons.css';
import { onDestroy, setContext, tick, untrack } from 'svelte';
import { creerFacade } from '../application/evenements.js';
import { creerFormat } from '../application/format.js';
import { creerControleurGeneration } from '../application/generation.js';
import { TABLES_APPLICATION } from '../application/libelles.js';
import { creerSeance } from '../application/seance.js';
import { creerTraduction } from '../application/traduction.js';
import { VERSION } from '../version.genere.js';
import { libelleProvenance, titreAvecVersion } from './libelles.js';
import APropos from './APropos.svelte';
import Accueil from './accueil/Accueil.svelte';
import Bandeau from './Bandeau.svelte';
import Evenement from './evenement/Evenement.svelte';
import { blocAPropos, provenanceVisible, versionVisible } from './apropos.js';
import { creerEtatUi } from './etat_ui.js';
import { TABLES_INTERFACE } from './libelles.js';
import { themeInitial as themeDuSysteme } from './theme.js';
const titre = titreAvecVersion(VERSION.affichee);
const provenance = libelleProvenance(import.meta.env.MODE);
// Les dépendances se lisent une fois, au montage : App crée ses services
// une seule fois, et un changement de propriété ne les recrée pas.
const dependances = $props();
const {
fs,
horloge,
executeur,
pressePapiers,
identifiant,
langue = 'fr',
provenance,
demonstrations,
formatCourant,
produitVersion,
themeInitial,
} = untrack(() => dependances);
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE], { langue });
const format = creerFormat(t);
const seanceApp = creerSeance({
fs,
horloge,
identifiant,
produitVersion,
planifier: horloge.planifier,
annuler: horloge.annuler,
formatCourant,
});
const generationApp = creerControleurGeneration({ seance: seanceApp, executeur, horloge, produitVersion });
const facadeApp = creerFacade({
fs,
horloge,
identifiant,
produitVersion,
demonstrations,
ouvert: () => seanceApp.etat().evenement?.base ?? null,
ouvrir: (base) => seanceApp.ouvrir(base),
fermer: () => seanceApp.fermer(),
creer: (champs) => seanceApp.creer(champs),
racine: () => seanceApp.etat().racine,
changerRacine: (racine) => seanceApp.changerRacine(racine),
});
// La façade que reçoivent les composants, et que rend l'export facade :
// celle de creerFacade, dont demarrer et repondre relisent la liste une
// fois la détermination réglée sans question. La façade appelle
// changerRacine avant de retirer sa question, si bien qu'une lecture
// lancée de ce rappel serait refusée (QUESTION_EN_ATTENTE). Une
// détermination refusée ne laisse rien : le demarrer suivant la refait, et
// relit alors la liste.
const facadeContexte = {
...facadeApp,
demarrer: async () => {
const issue = await facadeApp.demarrer();
if (issue.dossier.question === null) await relireListe();
return issue;
},
repondre: async (choix) => {
const issue = await facadeApp.repondre(choix);
if (issue.dossier.question === null) await relireListe();
return issue;
},
};
const uiApp = creerEtatUi();
setContext('seance', seanceApp);
setContext('facade', facadeContexte);
setContext('relireListe', () => relireListe());
setContext('generation', generationApp);
setContext('ui', uiApp);
setContext('t', t);
setContext('format', format);
setContext('horloge', horloge);
// Un refus de la séance ou de la façade porte un code, que l'écran
// traduit ; toute autre erreur est une faute, qui remonte.
const estRefus = (erreur) => typeof erreur?.code === 'string';
const refusDe = (erreur) => ({ code: erreur.code, details: erreur.details });
// La liste du dossier de travail, que l'accueil montre, et le refus qui en
// tient lieu quand la façade refuse de la lire : une liste refusée ne
// montre plus les lignes d'une lecture d'avant. lecture est la promesse de
// la dernière lecture lancée, que preparer attend ; quand deux lectures se
// chevauchent, seule la dernière pose ce qu'elle lit. La lecture passe par
// facadeContexte, la façade que voient les composants et les épreuves.
let lignes = $state.raw([]);
let refusLecture = $state.raw(null);
let lecture = Promise.resolve();
let lectures = 0;
async function lireListe() {
lectures += 1;
const rang = lectures;
try {
const lues = await facadeContexte.lister();
if (rang !== lectures) return;
lignes = lues;
refusLecture = null;
} catch (erreur) {
if (!estRefus(erreur)) throw erreur;
if (rang !== lectures) return;
lignes = [];
refusLecture = refusDe(erreur);
}
}
// Lance une lecture de la liste, qui devient la dernière, et rend sa
// promesse. L'accueil l'appelle par le contexte après toute opération qui
// change le dossier de travail.
function relireListe() {
lecture = lireListe();
return lecture;
}
// L'état observable de la séance, et la base de l'événement ouvert : une
// base qui change vers un événement remet la navigation à son départ. Le
// refus de « Liste des événements » tient jusqu'au changement suivant.
// La liste se relit à chaque fermeture d'un événement, qui a pu en créer,
// en changer ou écrire ailleurs ; elle se lit aussi quand la détermination
// se règle — preparer sans question, repondre de facadeContexte après la
// réponse.
let etatSeance = $state.raw(seanceApp.etat());
let refusListe = $state.raw(null);
let baseOuverte = null;
const desabonner = seanceApp.subscribe((etat) => {
const base = etat.evenement?.base ?? null;
if (base !== null && base !== baseOuverte) uiApp.reinitialiser();
const fermeture = baseOuverte !== null && base === null;
baseOuverte = base;
etatSeance = etat;
refusListe = null;
if (fermeture) relireListe();
});
onDestroy(desabonner);
let theme = $state(themeInitial ?? themeDuSysteme(window.matchMedia('(prefers-color-scheme: dark)').matches));
$effect(() => {
document.documentElement.setAttribute('data-theme', theme);
});
const titre = t('coquille.titre', { version: produitVersion });
const version = versionVisible(provenance, produitVersion, t);
const provenanceAffichee = provenanceVisible(provenance, t);
const bloc = blocAPropos({ version: VERSION, plateforme: fs.nature, provenance, format: formatCourant }, t);
// « Liste des événements » : ferme l'événement, sans question ; sous un
// support rompu, la séance le refuse, et le refus s'affiche à côté de la
// commande.
async function revenirALaListe() {
try {
await seanceApp.fermer();
} catch (erreur) {
if (!estRefus(erreur)) throw erreur;
refusListe = refusDe(erreur);
}
}
// La page se retire : la séance ferme l'événement et rend son verrou. Sous
// un support rompu, elle le refuse et rien ne s'écrit.
function quitterLaPage() {
seanceApp.fermer().catch((erreur) => {
if (!estRefus(erreur)) throw erreur;
});
}
// La détermination faite sans question, la liste du dossier retenu se
// lit ; une question laisse l'accueil sans lignes, jusqu'à la réponse. Un
// refus de la détermination s'affiche à la place de la liste.
async function preparer() {
try {
await facadeContexte.demarrer();
} catch (erreur) {
if (!estRefus(erreur)) throw erreur;
refusLecture = refusDe(erreur);
}
await lecture;
await tick();
}
/** Résolue quand la détermination du dossier de travail est faite et le
* premier écran rendu : les lignes de la liste lue, ou la question. */
export const pret = preparer();
/** La séance de ce montage. */
export function seance() {
return seanceApp;
}
/** Le contrôleur de génération de ce montage. */
export function generation() {
return generationApp;
}
/** La façade des événements de ce montage. */
export const facade = facadeContexte;
/** L'état de navigation de ce montage. */
export const ui = uiApp;
</script>
<svelte:window onpagehide={quitterLaPage} />
<svelte:head>
<title>{titre}</title>
</svelte:head>
<header class="bandeau">
<span class="titre">{titre}</span>
<span class="provenance">{provenance}</span>
</header>
<div class="app">
<Bandeau
etat={etatSeance}
{version}
provenance={provenanceAffichee}
{theme}
{refusListe}
choisirTheme={(choix) => (theme = choix)}
ouvrirApropos={() => uiApp.ouvrirApropos()}
{revenirALaListe}
/>
<main class="ecran">
{#if $uiApp.apropos}
<APropos {bloc} {pressePapiers} fermer={() => uiApp.fermerApropos()} />
{:else if etatSeance.evenement === null}
<Accueil {provenance} version={produitVersion} {demonstrations} {lignes} {refusLecture} />
{:else}
<Evenement />
{/if}
</main>
</div>
<style>
:global(body) {
@ -32,21 +281,14 @@
font-family: var(--police-interface);
}
.bandeau {
.app {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--espace-3);
padding: var(--espace-2) var(--espace-4);
border-bottom: 1px solid var(--couleur-bandeau-filet);
background-color: var(--couleur-bandeau-fond);
color: var(--couleur-bandeau-texte);
font-size: var(--taille-texte-bandeau);
flex-direction: column;
min-height: 100vh;
}
.provenance {
padding: 0 var(--espace-2);
border: 1px solid var(--couleur-bandeau-filet);
border-radius: var(--rayon-1);
.ecran {
flex: 1;
padding: var(--espace-4);
}
</style>

View file

@ -0,0 +1,138 @@
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// Le bandeau d'application, présent sur tout écran (§ 8.4, § 18.6) : le
// nom, la version — ou la marque de provenance d'une construction de
// documentation —, la provenance, et, quand un événement est ouvert, le
// libellé de mode, l'annonce de format du fichier et la commande « Liste
// des événements ». Il porte aussi le réglage de thème, deux commandes à
// aria-pressed, et l'ouverture du panneau « À propos ». Collé en haut de la
// fenêtre, il ne défile pas.
//
// Le bandeau ne décide de rien : App lui donne l'état de la séance, la
// version et la provenance telles qu'elles s'affichent, le thème et les
// gestes à appeler ; mode.js choisit les clés du libellé de mode et de
// l'annonce. Le refus de « Liste des événements » passe par la primitive
// Refus, qui pose son texte et son remède en Commande — écrire ailleurs
// sous un support rompu.
import { getContext } from 'svelte';
import { cleAnnonceFormat, cleDeMode } from './mode.js';
import Refus from './primitives/Refus.svelte';
import { THEMES, cleDeTheme } from './theme.js';
const t = getContext('t');
let { etat, version, provenance, theme, refusListe, choisirTheme, ouvrirApropos, revenirALaListe } = $props();
const cleMode = $derived(cleDeMode(etat));
const cleAnnonce = $derived(cleAnnonceFormat(etat));
</script>
<header class="bandeau" data-bandeau>
<span class="nom">{t('coquille.nom')}</span>
<span class="version">{version}</span>
<span class="provenance">{provenance}</span>
{#if cleMode !== null}
<span class="mode" data-mode-libelle="bandeau">{t(cleMode)}</span>
{/if}
{#if cleAnnonce !== null}
<span class="annonce" data-avertissement="annonceFormat">{t(cleAnnonce)}</span>
{/if}
<span class="commandes">
{#if etat.evenement !== null}
<button type="button" data-commande="liste" onclick={revenirALaListe}>{t('coquille.liste')}</button>
{#if refusListe !== null}
<div class="refus-liste" data-refus="liste">
<Refus refus={refusListe} />
</div>
{/if}
{/if}
<span class="themes" role="group" aria-label={t('coquille.theme.titre')}>
{#each THEMES as choix (choix)}
<button
type="button"
data-commande="theme"
data-theme-choix={choix}
aria-pressed={theme === choix}
onclick={() => choisirTheme(choix)}>{t(cleDeTheme(choix))}</button
>
{/each}
</span>
<button type="button" data-commande="apropos" onclick={ouvrirApropos}>{t('coquille.apropos.titre')}</button>
</span>
</header>
<style>
.bandeau {
position: sticky;
top: 0;
z-index: 1;
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--espace-2) var(--espace-3);
padding: var(--espace-2) var(--espace-4);
border-bottom: 1px solid var(--couleur-bandeau-filet);
background-color: var(--couleur-bandeau-fond);
color: var(--couleur-bandeau-texte);
font-size: var(--taille-texte-bandeau);
}
.provenance,
.mode,
.annonce {
padding: 0 var(--espace-2);
border: 1px solid var(--couleur-bandeau-filet);
border-radius: var(--rayon-1);
}
.mode {
font-weight: bold;
}
/* Le refus garde les couleurs de la page, sur lesquelles sa couleur et
celles de sa commande tiennent leur contraste : le fond du bandeau ne
passe pas sous lui. */
.refus-liste {
border-radius: var(--rayon-1);
background-color: var(--couleur-page-fond);
color: var(--couleur-page-texte);
}
.commandes,
.themes {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--espace-2);
}
.commandes {
margin-left: auto;
}
button {
padding: var(--espace-1) var(--espace-2);
border: 1px solid var(--couleur-bandeau-filet);
border-radius: var(--rayon-1);
background-color: var(--couleur-bandeau-fond);
color: var(--couleur-bandeau-texte);
font: inherit;
cursor: pointer;
}
/* Le thème retenu se distingue par sa forme autant que par ses couleurs :
texte souligné et gras, couleurs du bandeau inversées. */
button[aria-pressed='true'] {
background-color: var(--couleur-bandeau-texte);
color: var(--couleur-bandeau-fond);
font-weight: bold;
text-decoration: underline;
}
button:focus-visible {
outline: 2px solid var(--couleur-focus);
outline-offset: 2px;
}
</style>

View file

@ -0,0 +1,99 @@
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// Le premier écran sous sa forme minimale (§ 8.1, § 18.6) : son titre, la
// version — ou la marque d'une construction de documentation —, puis une
// commande par événement du dossier de travail, qui l'ouvre en lecture, et
// une par démonstration, qui la charge. Les lignes viennent d'App, qui lit
// la liste par la façade une fois la détermination réglée : l'accueil n'en
// lit rien lui-même, et une question en attente les laisse vides jusqu'à
// la réponse. Un geste qui change le dossier de travail — importer,
// copier, renommer, supprimer — demande ensuite à App de relire la liste,
// par la fonction relireListe du contexte. refusLecture est le refus qu'App a
// reçu en lisant la liste ; le refus d'un geste de l'accueil le précède,
// jusqu'au geste suivant. Chacun s'affiche par son code traduit.
import { getContext } from 'svelte';
import { versionVisible } from '../apropos.js';
const t = getContext('t');
const facade = getContext('facade');
const seance = getContext('seance');
let { provenance, version, demonstrations, lignes, refusLecture } = $props();
let refusGeste = $state.raw(null);
const refus = $derived(refusGeste ?? refusLecture);
const estRefus = (erreur) => typeof erreur?.code === 'string';
// Exécute un geste ; un refus s'affiche, toute autre erreur remonte.
async function tenter(geste) {
refusGeste = null;
try {
await geste();
} catch (erreur) {
if (!estRefus(erreur)) throw erreur;
refusGeste = { code: erreur.code, details: erreur.details };
}
}
</script>
<section class="accueil" data-ecran="accueil">
<h1>{t('coquille.liste')}</h1>
<p class="version">
<span>{t('coquille.version.libelle')}</span>
<span>{versionVisible(provenance, version, t)}</span>
</p>
{#if refus !== null}
<p class="refus" data-refus="accueil">{t(refus.code, refus.details)}</p>
{/if}
<ul class="entrees">
{#each lignes as ligne (ligne.base)}
<li>
<button type="button" data-evenement={ligne.base} onclick={() => tenter(() => seance.ouvrir(ligne.base))}
>{ligne.nom ?? ligne.base}</button
>
</li>
{/each}
{#each demonstrations as demonstration (demonstration.cle)}
<li>
<button
type="button"
data-demonstration={demonstration.cle}
onclick={() => tenter(() => facade.chargerDemonstration(demonstration.cle))}>{demonstration.nom}</button
>
</li>
{/each}
</ul>
</section>
<style>
.entrees {
display: flex;
flex-direction: column;
gap: var(--espace-2);
padding: 0;
list-style: none;
}
button {
padding: var(--espace-1) var(--espace-3);
border: 1px solid var(--couleur-page-filet);
border-radius: var(--rayon-1);
background-color: var(--couleur-page-fond);
color: var(--couleur-page-texte);
font: inherit;
text-align: start;
cursor: pointer;
}
button:focus-visible {
outline: 2px solid var(--couleur-focus);
outline-offset: 2px;
}
.refus {
color: var(--couleur-refus);
}
</style>

61
src/interface/apropos.js Normal file
View file

@ -0,0 +1,61 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le panneau « À propos » et la version à l'écran (§ 18.6). Le bloc tient en
// une ligne, que l'opérateur copie dans un courriel : il porte les deux
// formes de la version, l'affichée et celle que montrent les propriétés du
// fichier sous Windows, que scripts/version.js engendre. Sa composition est
// une seule clé de la table de la coquille : la langue témoin l'encadre une
// fois, en entier.
import { aUnLibelle } from './libelles.js';
/**
* Bloc d'une ligne du panneau « À propos » (§ 18.6) : nom, version affichée,
* version Windows, plateforme, provenance, version de format, séparés par
* « · ». La provenance s'y lit sous son libellé, ou sous son propre nom
* quand le mode n'en a pas ; la plateforme sous sa nature.
*
* @param {Object} champs
* @param {{affichee: string, windows: string}} champs.version VERSION
* @param {string} champs.plateforme la nature du système de fichiers
* @param {string} champs.provenance le mode de construction de Vite
* @param {number} champs.format la version de format des fichiers
* @param {(cle: string, details?: Object) => string} t
* @returns {string} sans saut de ligne
*/
export function blocAPropos({ version, plateforme, provenance, format }, t) {
return t('coquille.apropos.bloc', {
affichee: version.affichee,
windows: version.windows,
plateforme,
provenance,
format,
});
}
/**
* Ce qui tient lieu de version à l'écran : la marque de provenance dans une
* construction de documentation, dont les captures d'écran ne changent pas
* d'une livraison à l'autre (§ 19.4) ; la version affichée sinon.
*
* @param {string} provenance le mode de construction de Vite
* @param {string} version la version affichée
* @param {(cle: string) => string} t
* @returns {string}
*/
export function versionVisible(provenance, version, t) {
return provenance === 'documentation' ? t('coquille.version.documentation') : version;
}
/**
* La provenance à l'écran : le libellé du mode de construction par la
* traduction reçue, ou le mode lui-même quand il n'en a pas — celui d'un
* « vite build » lancé sans --mode —, qui s'affiche alors comme une donnée.
*
* @param {string} provenance le mode de construction de Vite
* @param {(cle: string) => string} t
* @returns {string}
*/
export function provenanceVisible(provenance, t) {
return aUnLibelle(provenance) ? t(`coquille.provenance.${provenance}`) : provenance;
}

View file

@ -0,0 +1,89 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le bloc « À propos » (§ 18.6) : une ligne que l'opérateur copie dans un
// courriel — nom, version affichée, version Windows, plateforme, provenance,
// version de format —, et ce qui tient lieu de version à l'écran, la marque
// de provenance dans une construction de documentation (§ 19.4).
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { TABLES_APPLICATION } from '../application/libelles.js';
import { MARQUE_TEMOIN, creerTraduction } from '../application/traduction.js';
import { FORMAT } from '../stockage/document.js';
import { VERSION } from '../version.genere.js';
import { blocAPropos, provenanceVisible, versionVisible } from './apropos.js';
import { TABLES_INTERFACE, libelleProvenance } from './libelles.js';
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
const temoin = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE], { langue: 'temoin' }).t;
describe('blocAPropos (§ 18.6)', () => {
test('une ligne : nom, version affichée, version Windows, plateforme, provenance, format, séparés par « · »', () => {
assert.match(VERSION.windows ?? '', /^\d+\.\d+\.\d+\.0$/, 'VERSION.windows engendré');
const bloc = blocAPropos({ version: VERSION, plateforme: 'electron', provenance: 'livraison', format: FORMAT }, t);
assert.equal(typeof bloc, 'string');
assert.doesNotMatch(bloc, /[\n\r\u{2028}\u{2029}]/u);
assert.deepEqual(bloc.split(' \u{00B7} '), [
t('coquille.nom'),
VERSION.affichee,
VERSION.windows,
'electron',
libelleProvenance('livraison'),
t('coquille.apropos.format', { format: FORMAT }),
]);
assert.ok(bloc.includes(String(FORMAT)));
});
test('chaque plateforme et chaque provenance s’y lisent ; un mode sans libellé sous son propre nom', () => {
const cas = [
['web', 'developpement', libelleProvenance('developpement')],
['epreuve', 'test', libelleProvenance('test')],
['electron', 'documentation', libelleProvenance('documentation')],
['web', 'production', 'production'],
];
assert.ok(cas.length > 0);
for (const [plateforme, provenance, libelle] of cas) {
const champs = blocAPropos({ version: VERSION, plateforme, provenance, format: FORMAT }, t).split(' \u{00B7} ');
assert.equal(champs[3], plateforme, provenance);
assert.equal(champs[4], libelle, provenance);
}
});
test('en langue témoin, le bloc entier est encadré une fois', () => {
const bloc = blocAPropos({ version: VERSION, plateforme: 'web', provenance: 'test', format: FORMAT }, temoin);
const { ouverture, fermeture } = MARQUE_TEMOIN;
assert.ok(bloc.startsWith(ouverture) && bloc.endsWith(fermeture), bloc);
assert.equal(bloc.split(ouverture).length, 2, bloc);
assert.equal(bloc.split(fermeture).length, 2, bloc);
});
});
describe('versionVisible (§ 18.6, § 19.4)', () => {
test('la version affichée, hors d’une construction de documentation', () => {
for (const provenance of ['developpement', 'livraison', 'test']) {
assert.equal(versionVisible(provenance, VERSION.affichee, t), VERSION.affichee, provenance);
}
});
test('la marque de provenance, sans le numéro, dans une construction de documentation', () => {
const marque = versionVisible('documentation', VERSION.affichee, t);
assert.equal(marque, t('coquille.version.documentation'));
assert.ok(!marque.includes(VERSION.affichee), marque);
});
});
describe('provenanceVisible (§ 18.6)', () => {
test('le libellé du mode par la traduction reçue ; un mode sans libellé sous son propre nom', () => {
for (const mode of ['developpement', 'livraison', 'documentation', 'test']) {
assert.equal(provenanceVisible(mode, t), libelleProvenance(mode), mode);
}
assert.equal(provenanceVisible('production', t), 'production');
assert.equal(provenanceVisible('constructor', t), 'constructor');
});
test('en langue témoin, le libellé est encadré, le mode sans libellé non : il est une donnée', () => {
const { ouverture, fermeture } = MARQUE_TEMOIN;
assert.equal(provenanceVisible('test', temoin), `${ouverture}${libelleProvenance('test')}${fermeture}`);
assert.equal(provenanceVisible('production', temoin), 'production');
});
});

View file

@ -0,0 +1,13 @@
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// La section du diagnostic sous sa forme minimale : son titre, tiré des libellés de la coquille.
import { getContext } from 'svelte';
const t = getContext('t');
</script>
<section data-section="diagnostic">
<h2>{t('coquille.section.diagnostic')}</h2>
</section>

122
src/interface/etat_ui.js Normal file
View file

@ -0,0 +1,122 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// L'état de navigation de l'interface : la section ouverte, le panneau « À
// propos », le tour montré, la personne mise en évidence, la proposition
// demandée au plan, le texte d'un import en attente, les listes de noms, le
// plein écran, la place sélectionnée. Regarder n'écrit jamais (§ 8.4) : cet
// état ne reçoit ni la séance, ni la façade, ni un système de fichiers, et
// rien de ce qu'il tient ne quitte la mémoire.
//
// Il suit le contrat des magasins de Svelte : subscribe(fn) appelle fn(etat)
// tout de suite, puis à chaque nouvel état ; un état est figé, et une
// transition qui ne change rien n'en publie pas de nouveau. L'écran — liste
// ou événement — ne s'y lit pas : il suit la séance.
import { SECTION_OUVERTURE, SECTIONS } from './evenement/sections.js';
/**
* @typedef {Object} EtatUi
* @property {string} section l'une de SECTIONS
* @property {boolean} apropos le panneau « À propos » est ouvert
* @property {number} tour le tour montré, entier positif
* @property {number|null} miseEnEvidence l'identifiant de la personne mise en évidence
* @property {number|null} proposition la proposition demandée au plan
* @property {null|{texte: string, origine: string}} importEnAttente
* @property {boolean} listes les listes de noms sont montrées
* @property {boolean} pleinEcran
* @property {Object|null} selection la place sélectionnée
*/
// L'état de navigation d'un événement qui s'ouvre, panneau « À propos »
// excepté : celui-ci ne tient pas à l'événement.
const DEPART = Object.freeze({
section: SECTION_OUVERTURE,
tour: 1,
miseEnEvidence: null,
proposition: null,
importEnAttente: null,
listes: true,
pleinEcran: false,
selection: null,
});
/**
* Un état de navigation neuf, à son départ.
*
* @returns {{subscribe: Function, etat: () => EtatUi, allerA: Function,
* reinitialiser: Function, ouvrirApropos: Function, fermerApropos: Function,
* montrerTour: Function, mettreEnEvidence: Function,
* montrerProposition: Function, ouvrirImport: Function,
* prendreImport: () => null|{texte: string, origine: string},
* basculerListes: Function, basculerPleinEcran: Function,
* selectionner: Function}}
*/
export function creerEtatUi() {
let etat = Object.freeze({ ...DEPART, apropos: false });
const abonnes = new Set();
// Publie l'état que donnent les changements, s'il diffère du courant sur
// l'une de ses propriétés.
function changer(changements) {
const suivant = { ...etat, ...changements };
if (Object.keys(suivant).every((cle) => Object.is(suivant[cle], etat[cle]))) return;
etat = Object.freeze(suivant);
for (const fn of [...abonnes]) fn(etat);
}
return {
subscribe(fn) {
abonnes.add(fn);
fn(etat);
return () => {
abonnes.delete(fn);
};
},
etat: () => etat,
/** Ouvre une section ; RangeError hors de SECTIONS. */
allerA(section) {
if (!SECTIONS.includes(section)) throw new RangeError(`section inconnue : ${JSON.stringify(section)}`);
changer({ section });
},
/** Le départ d'un événement qui s'ouvre ; le panneau « À propos » reste tel. */
reinitialiser() {
changer(DEPART);
},
ouvrirApropos() {
changer({ apropos: true });
},
fermerApropos() {
changer({ apropos: false });
},
/** Montre le tour r, entier positif ; RangeError sinon. */
montrerTour(r) {
if (!Number.isInteger(r) || r < 1) throw new RangeError(`tour : entier positif attendu, reçu ${String(r)}`);
changer({ tour: r });
},
mettreEnEvidence(id) {
changer({ miseEnEvidence: id });
},
montrerProposition(id) {
changer({ proposition: id });
},
/** Garde un texte à importer et ouvre la section de l'import. */
ouvrirImport({ texte, origine }) {
changer({ importEnAttente: Object.freeze({ texte, origine }), section: 'import' });
},
/** Rend le texte en attente, une fois, puis null. */
prendreImport() {
const enAttente = etat.importEnAttente;
changer({ importEnAttente: null });
return enAttente;
},
basculerListes() {
changer({ listes: !etat.listes });
},
basculerPleinEcran() {
changer({ pleinEcran: !etat.pleinEcran });
},
selectionner(place) {
changer({ selection: place });
},
};
}

View file

@ -0,0 +1,153 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// L'état de navigation de l'interface : chaque transition, le contrat des
// magasins de Svelte, et l'absence de toute écriture — l'état ne connaît pas
// la séance, et regarder n'écrit jamais (§ 8.2, § 8.4).
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { describe, test } from '../../test/lanceur.js';
import { creerEtatUi } from './etat_ui.js';
import { SECTION_OUVERTURE, SECTIONS } from './evenement/sections.js';
const DEPART = {
section: SECTION_OUVERTURE,
apropos: false,
tour: 1,
miseEnEvidence: null,
proposition: null,
importEnAttente: null,
listes: true,
pleinEcran: false,
selection: null,
};
// Un état et le relevé de ce que reçoit son abonné.
function suivi() {
const ui = creerEtatUi();
const recus = [];
const desabonner = ui.subscribe((etat) => recus.push(etat));
return { ui, recus, desabonner };
}
describe('état de navigation : départ et magasin', () => {
test('le départ : la section d’ouverture, le tour 1, rien de choisi, les listes montrées', () => {
assert.deepEqual(creerEtatUi().etat(), DEPART);
});
test('subscribe rend l’état tout de suite, puis chaque nouvel état, figé ; désabonné, plus rien', () => {
const { ui, recus, desabonner } = suivi();
assert.deepEqual(recus, [DEPART]);
ui.allerA('tables');
assert.equal(recus.length, 2);
assert.equal(recus[1], ui.etat());
assert.ok(Object.isFrozen(recus[1]));
assert.throws(() => {
recus[1].section = 'plan';
}, TypeError);
desabonner();
ui.allerA('plan');
assert.equal(recus.length, 2);
});
test('l’état ne reçoit rien à sa création : il ne connaît ni la séance, ni la façade, ni un système de fichiers', () => {
assert.equal(creerEtatUi.length, 0);
const source = readFileSync(new URL('./etat_ui.js', import.meta.url), 'utf8');
// Le dernier mot de chaque ligne d'import : le module qu'elle charge.
const imports = source
.split('\n')
.filter((ligne) => ligne.startsWith('import '))
.map((ligne) => ligne.slice(ligne.lastIndexOf(' ') + 1));
assert.deepEqual(imports, ["'./evenement/sections.js';"]);
});
});
describe('état de navigation : chaque transition', () => {
test('allerA mène à chaque section ; hors de SECTIONS, RangeError sans rien changer', () => {
const ui = creerEtatUi();
assert.ok(SECTIONS.length > 0);
for (const section of SECTIONS) {
ui.allerA(section);
assert.deepEqual(ui.etat(), { ...DEPART, section });
}
const avant = ui.etat();
for (const faute of ['accueil', 'apropos', '', undefined, 'Plan', 'constructor']) {
assert.throws(() => ui.allerA(faute), RangeError, String(faute));
}
assert.equal(ui.etat(), avant);
});
test('reinitialiser rend l’état de départ, le panneau « À propos » excepté', () => {
const ui = creerEtatUi();
ui.allerA('generation');
ui.montrerTour(3);
ui.mettreEnEvidence(7);
ui.montrerProposition(2);
ui.ouvrirImport({ texte: 'nom\nOmbrelle', origine: 'liste.csv' });
ui.basculerListes();
ui.basculerPleinEcran();
ui.selectionner({ table: 1, siege: 2 });
ui.ouvrirApropos();
ui.reinitialiser();
assert.deepEqual(ui.etat(), { ...DEPART, apropos: true });
assert.equal(ui.etat().section, SECTION_OUVERTURE);
});
test('ouvrirApropos et fermerApropos', () => {
const ui = creerEtatUi();
ui.ouvrirApropos();
assert.deepEqual(ui.etat(), { ...DEPART, apropos: true });
ui.fermerApropos();
assert.deepEqual(ui.etat(), DEPART);
});
test('montrerTour prend un entier positif ; sinon RangeError', () => {
const ui = creerEtatUi();
ui.montrerTour(4);
assert.equal(ui.etat().tour, 4);
for (const faute of [0, -1, 1.5, Number.NaN, '2', null]) {
assert.throws(() => ui.montrerTour(faute), RangeError, String(faute));
}
assert.equal(ui.etat().tour, 4);
});
test('mettreEnEvidence, montrerProposition et selectionner posent puis retirent leur choix', () => {
const ui = creerEtatUi();
ui.mettreEnEvidence(12);
ui.montrerProposition(3);
ui.selectionner({ table: 2, siege: 1 });
assert.deepEqual(ui.etat(), { ...DEPART, miseEnEvidence: 12, proposition: 3, selection: { table: 2, siege: 1 } });
ui.mettreEnEvidence(null);
ui.montrerProposition(null);
ui.selectionner(null);
assert.deepEqual(ui.etat(), DEPART);
});
test('ouvrirImport garde le texte en attente et mène à la section import ; prendreImport le rend une fois', () => {
const ui = creerEtatUi();
const enAttente = { texte: 'nom;prénom\nPervenche;Théo', origine: 'invites.csv' };
ui.ouvrirImport(enAttente);
assert.deepEqual(ui.etat(), { ...DEPART, section: 'import', importEnAttente: enAttente });
assert.deepEqual(ui.prendreImport(), enAttente);
assert.equal(ui.etat().importEnAttente, null);
assert.equal(ui.prendreImport(), null);
});
test('basculerListes et basculerPleinEcran alternent', () => {
const ui = creerEtatUi();
ui.basculerListes();
ui.basculerPleinEcran();
assert.deepEqual(ui.etat(), { ...DEPART, listes: false, pleinEcran: true });
ui.basculerListes();
ui.basculerPleinEcran();
assert.deepEqual(ui.etat(), DEPART);
});
test('une transition qui ne change rien ne publie pas d’état neuf', () => {
const { ui, recus } = suivi();
ui.allerA(SECTION_OUVERTURE);
ui.fermerApropos();
ui.mettreEnEvidence(null);
assert.equal(recus.length, 1);
});
});

View file

@ -0,0 +1,79 @@
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// L'événement ouvert sous sa forme minimale : son nom, une commande par
// section, dans l'ordre du § 2.1, et la section ouverte de l'état de
// navigation. Aucune section n'en exige une autre.
import { getContext } from 'svelte';
import Diagnostic from '../diagnostic/Diagnostic.svelte';
import SectionGeneration from '../generation/SectionGeneration.svelte';
import SectionImport from '../import/SectionImport.svelte';
import SectionParticipants from '../participants/SectionParticipants.svelte';
import SectionPlan from '../plan/SectionPlan.svelte';
import SectionPropositions from '../propositions/SectionPropositions.svelte';
import SectionTables from '../tables/SectionTables.svelte';
import { SECTIONS, cleDeSection } from './sections.js';
const t = getContext('t');
const ui = getContext('ui');
const seance = getContext('seance');
// Le composant de chaque section.
const COMPOSANTS = new Map([
['import', SectionImport],
['participants', SectionParticipants],
['tables', SectionTables],
['plan', SectionPlan],
['diagnostic', Diagnostic],
['generation', SectionGeneration],
['propositions', SectionPropositions],
]);
const Section = $derived(COMPOSANTS.get($ui.section));
</script>
<div class="evenement" data-ecran="evenement">
<h1>{$seance.evenement?.nom}</h1>
<nav aria-label={t('coquille.navigation')}>
{#each SECTIONS as nom (nom)}
<button
type="button"
data-commande="section"
data-cible={nom}
aria-current={$ui.section === nom ? 'page' : undefined}
onclick={() => ui.allerA(nom)}>{t(cleDeSection(nom))}</button
>
{/each}
</nav>
<Section />
</div>
<style>
nav {
display: flex;
flex-wrap: wrap;
gap: var(--espace-2);
}
button {
padding: var(--espace-1) var(--espace-3);
border: 1px solid var(--couleur-page-filet);
border-radius: var(--rayon-1);
background-color: var(--couleur-page-fond);
color: var(--couleur-page-texte);
font: inherit;
cursor: pointer;
}
/* La section ouverte se distingue par sa forme : texte souligné et gras. */
button[aria-current='page'] {
font-weight: bold;
text-decoration: underline;
}
button:focus-visible {
outline: 2px solid var(--couleur-focus);
outline-offset: 2px;
}
</style>

View file

@ -0,0 +1,40 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les sections de l'événement ouvert. Aucune n'en exige une autre : la
// navigation les offre toutes, dans l'ordre du parcours de l'opérateur
// (§ 2.1), et la séance refuse elle-même ce qui ne peut pas se faire.
// Chaque section et la clé de son titre, qui la nomme aussi dans la
// navigation, dans l'ordre du § 2.1.
const CLES = new Map([
['import', 'coquille.section.import'],
['participants', 'coquille.section.participants'],
['tables', 'coquille.section.tables'],
['plan', 'coquille.section.plan'],
['diagnostic', 'coquille.section.diagnostic'],
['generation', 'coquille.section.generation'],
['propositions', 'coquille.section.propositions'],
]);
/** Les sections de l'itération, dans l'ordre du § 2.1 : import,
* participants, tables, plan, diagnostic, generation, propositions. Aucune
* n'en exige une autre. Le titre et le nom de navigation de chacune sont la
* clé coquille.section.<nom> de coquille.js. */
export const SECTIONS = Object.freeze([...CLES.keys()]);
/** La section qu'un événement montre à son ouverture. */
export const SECTION_OUVERTURE = 'plan';
/**
* La clé du titre d'une section, coquille.section.<nom>.
*
* @param {string} nom
* @returns {string}
* @throws {RangeError} hors de SECTIONS
*/
export function cleDeSection(nom) {
const cle = CLES.get(nom);
if (cle === undefined) throw new RangeError(`section inconnue : ${JSON.stringify(nom)}`);
return cle;
}

View file

@ -0,0 +1,40 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les sections de l'événement ouvert (§ 2.1) : sept noms distincts, dans
// l'ordre du parcours de l'opérateur, chacun nommé par sa clé
// coquille.section.<nom> de la table de la coquille ; la section d'ouverture
// en est une.
import assert from 'node:assert/strict';
import { describe, test } from '../../../test/lanceur.js';
import { TABLE as COQUILLE } from '../libelles/coquille.js';
import { SECTION_OUVERTURE, SECTIONS, cleDeSection } from './sections.js';
describe('sections de l’événement (§ 2.1)', () => {
test('sept noms distincts, dans l’ordre du parcours, figés', () => {
assert.deepEqual(SECTIONS, ['import', 'participants', 'tables', 'plan', 'diagnostic', 'generation', 'propositions']);
assert.equal(new Set(SECTIONS).size, 7);
assert.ok(Object.isFrozen(SECTIONS));
});
test('chaque section a sa clé coquille.section.<nom>, une chaîne non vide, que rend cleDeSection', () => {
assert.ok(SECTIONS.length > 0);
for (const nom of SECTIONS) {
assert.equal(cleDeSection(nom), `coquille.section.${nom}`);
const texte = COQUILLE.get(cleDeSection(nom));
assert.equal(typeof texte, 'string', nom);
assert.notEqual(texte.trim(), '', nom);
}
});
test('la section d’ouverture est le plan, l’une des sections', () => {
assert.equal(SECTION_OUVERTURE, 'plan');
assert.ok(SECTIONS.includes(SECTION_OUVERTURE));
});
test('cleDeSection lève RangeError hors de SECTIONS', () => {
for (const faute of ['accueil', '', 'Plan', 'constructor', undefined]) {
assert.throws(() => cleDeSection(faute), RangeError, String(faute));
}
});
});

View file

@ -0,0 +1,13 @@
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// La section de la génération sous sa forme minimale : son titre, tiré des libellés de la coquille.
import { getContext } from 'svelte';
const t = getContext('t');
</script>
<section data-section="generation">
<h2>{t('coquille.section.generation')}</h2>
</section>

View file

@ -0,0 +1,13 @@
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// La section de l'import sous sa forme minimale : son titre, tiré des libellés de la coquille.
import { getContext } from 'svelte';
const t = getContext('t');
</script>
<section data-section="import">
<h2>{t('coquille.section.import')}</h2>
</section>

View file

@ -1,9 +1,13 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les textes de la coquille (§ 18.6) : le nom de l'application, le titre du
// bandeau et de la fenêtre, et la provenance de la construction par mode de
// Vite. Un mode sans clé s'affiche sous son propre nom (libelles.js).
// Les textes de la coquille (§ 8.4, § 18.6, § 19.2) : le nom de
// l'application, le titre du bandeau et de la fenêtre, la provenance de la
// construction par mode de Vite, le libellé de mode, l'annonce de format, le
// réglage de thème, « Liste des événements », le panneau « À propos » et son
// bloc, et le titre de chaque section de l'événement, que la navigation et
// chaque section reprennent. Un mode sans clé s'affiche sous son propre nom
// (libelles.js). Chaque clé commence par « coquille. ».
export const TABLE = new Map([
['coquille.nom', 'Gestion table tournante Libre'],
['coquille.titre', ({ version }, texte) => `${texte('coquille.nom')} \u{2014} ${version}`],
@ -11,4 +15,60 @@ export const TABLE = new Map([
['coquille.provenance.livraison', 'livraison'],
['coquille.provenance.documentation', 'documentation'],
['coquille.provenance.test', 'épreuve'],
// La version à l'écran : le mot qui la précède au premier écran, et la
// marque qui la remplace dans une construction de documentation (§ 19.4).
['coquille.version.libelle', 'version'],
['coquille.version.documentation', 'construction de documentation'],
// Le libellé de mode, que rend cleDeMode (mode.js).
['coquille.mode.lecture', 'Lecture'],
['coquille.mode.ecriture', 'Écriture'],
['coquille.mode.bloque', 'Plan bloqué'],
// L'annonce de la version de format du fichier ouvert (§ 18.6).
['coquille.format.plusRecent', 'Ce fichier s\u{2019}ouvre en lecture seule'],
['coquille.format.plusAncien', 'Ce fichier se convertira à votre première modification'],
// Le réglage de thème (§ 19.2).
['coquille.theme.titre', 'Thème'],
['coquille.theme.clair', 'Clair'],
['coquille.theme.sombre', 'Sombre'],
['coquille.liste', 'Liste des événements'],
// Le panneau « À propos ». Le bloc : nom, version affichée, version
// Windows, plateforme, provenance — son libellé, ou le mode lui-même quand
// il n'en a pas —, version de format.
['coquille.apropos.titre', 'À propos'],
[
'coquille.apropos.bloc',
({ affichee, windows, plateforme, provenance, format }, texte) => {
const cle = `coquille.provenance.${provenance}`;
return [
texte('coquille.nom'),
affichee,
windows,
plateforme,
texte.existe(cle) ? texte(cle) : provenance,
texte('coquille.apropos.format', { format }),
].join(' \u{00B7} ');
},
],
['coquille.apropos.format', ({ format }) => `format ${format}`],
['coquille.apropos.copier', 'Copier'],
['coquille.apropos.fermer', 'Fermer'],
['coquille.apropos.copie', 'Bloc copié dans le presse-papiers'],
['coquille.apropos.copieRefusee', 'Le presse-papiers a refusé la copie'],
// Les sections de l'événement, dans l'ordre du § 2.1 (sections.js), et
// leur navigation.
['coquille.navigation', 'Sections de l\u{2019}événement'],
['coquille.section.import', 'Import'],
['coquille.section.participants', 'Participants'],
['coquille.section.tables', 'Tables et tours'],
['coquille.section.plan', 'Plan'],
['coquille.section.diagnostic', 'Diagnostic'],
['coquille.section.generation', 'Génération'],
['coquille.section.propositions', 'Propositions'],
]);

45
src/interface/mode.js Normal file
View file

@ -0,0 +1,45 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Ce que le bandeau dit de l'événement ouvert, en clés de la table de la
// coquille : le libellé de mode (§ 8.4, § 8.5), calculé ici une fois pour le
// bandeau et pour le cadre de mode, et l'annonce de la version de format du
// fichier (§ 18.6). Les deux se lisent dans l'état observable de la séance.
// La clé de chaque annonce de format de la séance.
const ANNONCES = new Map([
['plusRecent', 'coquille.format.plusRecent'],
['plusAncien', 'coquille.format.plusAncien'],
]);
/**
* La clé du libellé de mode : null sans événement ; 'coquille.mode.bloque'
* pour un plan bloqué, que la séance tient en lecture ;
* 'coquille.mode.lecture' ou 'coquille.mode.ecriture' sinon, le mode de la
* séance.
*
* @param {{mode: 'lecture'|'ecriture', evenement: null|{etat: string}}} etatSeance
* @returns {null|'coquille.mode.lecture'|'coquille.mode.ecriture'|'coquille.mode.bloque'}
*/
export function cleDeMode(etatSeance) {
if (etatSeance.evenement === null) return null;
if (etatSeance.evenement.etat === 'bloque') return 'coquille.mode.bloque';
return etatSeance.mode === 'ecriture' ? 'coquille.mode.ecriture' : 'coquille.mode.lecture';
}
/**
* La clé de l'annonce de format du bandeau : null sans événement ou quand le
* fichier est au format courant ; sinon celle de l'annonce de la séance,
* plus récent ou plus ancien.
*
* @param {{evenement: null|{annonceFormat: null|'plusRecent'|'plusAncien'}}} etatSeance
* @returns {null|'coquille.format.plusRecent'|'coquille.format.plusAncien'}
* @throws {RangeError} une annonce que la séance ne rend pas
*/
export function cleAnnonceFormat(etatSeance) {
const annonce = etatSeance.evenement?.annonceFormat ?? null;
if (annonce === null) return null;
const cle = ANNONCES.get(annonce);
if (cle === undefined) throw new RangeError(`annonce de format inconnue : ${JSON.stringify(annonce)}`);
return cle;
}

View file

@ -0,0 +1,66 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// La clé du libellé de mode (§ 8.4, § 8.5), que lisent le bandeau et le
// cadre de mode : aucune sans événement ; le plan bloqué avant le mode, qu'il
// force en lecture ; sinon le mode de la séance. Et la clé de l'annonce de
// format du bandeau (§ 18.6), une par annonce de la séance.
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { TABLE as COQUILLE } from './libelles/coquille.js';
import { cleAnnonceFormat, cleDeMode } from './mode.js';
// Un EtatSeance réduit à ce que lit cleDeMode.
const etat = (mode, etatDuPlan, annonceFormat = null) => ({
mode,
evenement: etatDuPlan === null ? null : { base: 'bal', id: 'evt-1', nom: 'Bal', etat: etatDuPlan, revision: 1, annonceFormat },
});
describe('cleDeMode (§ 8.4)', () => {
test('null sans événement, dans les deux modes', () => {
assert.equal(cleDeMode(etat('lecture', null)), null);
assert.equal(cleDeMode(etat('ecriture', null)), null);
});
test('un plan bloqué rend coquille.mode.bloque, quel que soit le mode', () => {
assert.equal(cleDeMode(etat('lecture', 'bloque')), 'coquille.mode.bloque');
assert.equal(cleDeMode(etat('ecriture', 'bloque')), 'coquille.mode.bloque');
});
test('sinon le mode de la séance, pour chaque état non bloqué', () => {
const etats = ['brouillon', 'propose', 'retenu'];
assert.ok(etats.length > 0);
for (const etatDuPlan of etats) {
assert.equal(cleDeMode(etat('lecture', etatDuPlan)), 'coquille.mode.lecture', etatDuPlan);
assert.equal(cleDeMode(etat('ecriture', etatDuPlan)), 'coquille.mode.ecriture', etatDuPlan);
}
});
test('chaque clé rendue a son texte dans la table de la coquille', () => {
for (const cle of ['coquille.mode.lecture', 'coquille.mode.ecriture', 'coquille.mode.bloque']) {
assert.equal(typeof COQUILLE.get(cle), 'string', cle);
}
});
});
describe('cleAnnonceFormat (§ 18.6)', () => {
test('null sans événement, ou au format courant', () => {
assert.equal(cleAnnonceFormat(etat('lecture', null)), null);
assert.equal(cleAnnonceFormat(etat('lecture', 'brouillon')), null);
});
test('une clé par annonce, chacune avec son texte', () => {
const cas = [
['plusRecent', 'coquille.format.plusRecent'],
['plusAncien', 'coquille.format.plusAncien'],
];
for (const [annonce, cle] of cas) {
assert.equal(cleAnnonceFormat(etat('lecture', 'brouillon', annonce)), cle, annonce);
assert.equal(typeof COQUILLE.get(cle), 'string', cle);
}
});
test('une annonce inconnue lève RangeError', () => {
assert.throws(() => cleAnnonceFormat(etat('lecture', 'brouillon', 'plusVieux')), RangeError);
});
});

View file

@ -0,0 +1,13 @@
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// La section des participants sous sa forme minimale : son titre, tiré des libellés de la coquille.
import { getContext } from 'svelte';
const t = getContext('t');
</script>
<section data-section="participants">
<h2>{t('coquille.section.participants')}</h2>
</section>

View file

@ -0,0 +1,13 @@
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// La section du plan sous sa forme minimale : son titre, tiré des libellés de la coquille.
import { getContext } from 'svelte';
const t = getContext('t');
</script>
<section data-section="plan">
<h2>{t('coquille.section.plan')}</h2>
</section>

View file

@ -0,0 +1,13 @@
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// La section des propositions sous sa forme minimale : son titre, tiré des libellés de la coquille.
import { getContext } from 'svelte';
const t = getContext('t');
</script>
<section data-section="propositions">
<h2>{t('coquille.section.propositions')}</h2>
</section>

View file

@ -0,0 +1,13 @@
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// La section des tables et des tours sous sa forme minimale : son titre, tiré des libellés de la coquille.
import { getContext } from 'svelte';
const t = getContext('t');
</script>
<section data-section="tables">
<h2>{t('coquille.section.tables')}</h2>
</section>

36
src/interface/theme.js Normal file
View file

@ -0,0 +1,36 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le réglage de thème (§ 19.2). Chaque lancement repart de la préférence du
// système ; le choix de l'opérateur vaut ensuite pour la séance, tenu en
// mémoire par App, qui le pose en data-theme sur <html> (jetons.css). Rien ne
// l'enregistre : ni fichier, ni stockage du navigateur.
// Chaque thème et la clé de son nom, dans l'ordre du réglage.
const CLES = new Map([
['clair', 'coquille.theme.clair'],
['sombre', 'coquille.theme.sombre'],
]);
/** Les deux thèmes, dans l'ordre du réglage. */
export const THEMES = Object.freeze([...CLES.keys()]);
/** Le thème d'un lancement : la préférence du système, à chaque lancement ; le
* réglage de l'opérateur vaut ensuite pour la séance, en mémoire, et rien ne
* l'enregistre. systemeSombre : la préférence sombre du système est posée. */
export function themeInitial(systemeSombre) {
return systemeSombre === true ? 'sombre' : 'clair';
}
/**
* La clé du nom d'un thème, coquille.theme.<nom>.
*
* @param {string} theme
* @returns {string}
* @throws {RangeError} hors de THEMES
*/
export function cleDeTheme(theme) {
const cle = CLES.get(theme);
if (cle === undefined) throw new RangeError(`thème inconnu : ${JSON.stringify(theme)}`);
return cle;
}

View file

@ -0,0 +1,32 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le thème d'un lancement (§ 19.2) : la préférence du système, lue à chaque
// lancement ; rien ne le garde d'une séance à l'autre.
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { TABLE as COQUILLE } from './libelles/coquille.js';
import { THEMES, cleDeTheme, themeInitial } from './theme.js';
describe('themeInitial (§ 19.2)', () => {
test('une préférence sombre donne le thème sombre, toute autre le clair', () => {
assert.equal(themeInitial(true), 'sombre');
assert.equal(themeInitial(false), 'clair');
});
test('les deux thèmes, dans l’ordre du réglage, figés', () => {
assert.deepEqual(THEMES, ['clair', 'sombre']);
assert.ok(Object.isFrozen(THEMES));
});
});
describe('cleDeTheme', () => {
test('chaque thème a sa clé coquille.theme.<nom>, avec son texte ; un autre lève RangeError', () => {
assert.ok(THEMES.length > 0);
for (const theme of THEMES) {
assert.equal(cleDeTheme(theme), `coquille.theme.${theme}`);
assert.equal(typeof COQUILLE.get(cleDeTheme(theme)), 'string', theme);
}
assert.throws(() => cleDeTheme('gris'), RangeError);
});
});

View file

@ -1,8 +1,54 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Point d'entrée de la page : monte l'interface dans le corps du document.
// Point d'entrée de la page : le seul module de src/ qui choisit la
// plateforme (§ 13.4). Sous la coquille Electron, le préchargement expose
// window.gtt — le système de fichiers du processus principal et la copie
// dans le presse-papiers ; sans lui, la page est la plateforme web, sur
// l'OPFS et le presse-papiers du navigateur. Il construit les dépendances
// d'App — l'horloge du système, l'exécuteur direct de la génération, les
// identifiants tirés par crypto.randomUUID, les démonstrations livrées
// nommées par le catalogue — et monte App dans le corps du document. Le
// thème de départ est laissé à App, qui lit la préférence du système.
import { mount } from 'svelte';
import { creerExecuteurDirect } from './application/generation.js';
import { creerHorlogeSysteme } from './application/horloge.js';
import { CATALOGUE } from './demo/catalogue.js';
import { LIVREES } from './demo/livrees.js';
import App from './interface/App.svelte';
import { FORMAT } from './stockage/document.js';
import { creerFichiersElectron } from './stockage/fichiers_electron.js';
import { creerFichiersWeb } from './stockage/fichiers_web.js';
import { VERSION } from './version.genere.js';
mount(App, { target: document.body });
const pont = window.gtt;
const sousElectron = pont !== undefined;
const fs = sousElectron ? creerFichiersElectron(pont.fichiers) : creerFichiersWeb();
const pressePapiers = sousElectron
? { copier: async (texte) => void (await pont.copier(texte)) }
: { copier: (texte) => navigator.clipboard.writeText(texte) };
// Chaque démonstration livrée, nommée par l'entrée de même clé du catalogue,
// dans l'ordre du catalogue.
const demonstrations = LIVREES.map(({ cle, texte }) => ({
cle,
nom: CATALOGUE.find((entree) => entree.cle === cle).nom,
texte,
}));
mount(App, {
target: document.body,
props: {
fs,
horloge: creerHorlogeSysteme(),
executeur: creerExecuteurDirect(),
pressePapiers,
identifiant: () => crypto.randomUUID(),
langue: 'fr',
provenance: import.meta.env.MODE,
demonstrations,
formatCourant: FORMAT,
produitVersion: VERSION.affichee,
},
});

View file

@ -5,4 +5,5 @@
export const VERSION = Object.freeze({
affichee: '2026.10.05.01',
technique: '2026.1005.1',
windows: '2026.1005.1.0',
});

View file

@ -4,9 +4,11 @@
// La coquille Electron (§ 13.1) : la fenêtre qu'ouvre le processus principal,
// la page qu'elle charge, le profil de Chromium hors d'AppData (§ 8.6), le
// pont étroit et nommé que le préchargement expose — une fonction par
// primitive du système de fichiers, un canal par fonction —, et ce que le
// processus principal y répond : emplacements, racines, dialogues,
// explorateur.
// primitive du système de fichiers, un canal par fonction, et la copie dans
// le presse-papiers —, ce que le processus principal y répond : emplacements,
// racines, dialogues, explorateur, presse-papiers ; et les verrous que la
// page a pris, effacés à la sortie (§ 8.8). La page elle-même, index.html,
// charge le collecteur d'erreurs avant son module d'entrée (§ 19.2).
//
// electron/main.js s'exécute sous Node, dans un processus à part, contre
// test/electron_factice.js, qui consigne ce que la coquille demande à
@ -29,6 +31,7 @@ const PRINCIPAL = join(RACINE, 'electron', 'main.js');
const PRECHARGEMENT = join(RACINE, 'electron', 'preload.cjs');
const NOM_PRODUIT = JSON.parse(readFileSync(join(RACINE, 'package.json'), 'utf8')).productName;
const CANAUX = PRIMITIVES.map((primitive) => `gtt:fichiers:${primitive}`);
const CANAL_COPIE = 'gtt:copier';
// Dossier que publie le lanceur portable, et LOCALAPPDATA, dans le scénario
// qui les donne. Aucun des deux n'est lu ni écrit.
@ -64,6 +67,17 @@ const PROFIL_VIVANT = `gtt-profil-${process.pid}-Ef34Gh`;
const AUTRE = 'gtt-profil-sans-pid';
const DOSSIERS_POSES = ['Documents', PROFIL_TERMINE, PROFIL_VIVANT, AUTRE];
// Le verrou qu'une autre séance tient dans les Documents du produit, posé
// avant le lancement : la sortie de la coquille ne le touche pas.
const VERROU_AUTRE = 'autre.gtt.verrou';
const CONTENU_AUTRE = {
seance: 'seance-voisine',
pid: PID_TERMINE,
hote: 'poste-voisin',
depuis: '2026-10-06T19:00:00-04:00',
demarrage: null,
};
// Exécute la coquille contre le factice dans un scénario, et rend son
// journal, son pid, le dossier temporaire qu'elle a reçu, et ce qu'elle y a
// laissé. Le répertoire courant est un dossier vide hors du projet : dans
@ -77,6 +91,7 @@ function executerLaCoquille(scenario) {
const ailleurs = mkdtempSync(join(tmpdir(), 'coquille-'));
const temporaire = mkdtempSync(join(tmpdir(), 'coquille-temp-'));
mkdirSync(join(temporaire, 'Documents', NOM_PRODUIT), { recursive: true });
writeFileSync(join(temporaire, 'Documents', NOM_PRODUIT, VERROU_AUTRE), `${JSON.stringify(CONTENU_AUTRE)}\n`);
for (const nom of [PROFIL_TERMINE, PROFIL_VIVANT, AUTRE]) {
mkdirSync(join(temporaire, nom, 'Session Storage'), { recursive: true });
writeFileSync(join(temporaire, nom, 'Local State'), '{}');
@ -156,13 +171,13 @@ describe('coquille : processus principal', () => {
]);
});
test('un canal gtt:fichiers:<primitive> par primitive, et aucun autre, chacun posé avant le chargement de la page', () => {
test('un canal gtt:fichiers:<primitive> par primitive, puis celui du presse-papiers, et aucun autre, chacun posé avant le chargement de la page', () => {
const { canaux, sequence } = journalDeLaCoquille();
assert.deepEqual(canaux, CANAUX);
assert.deepEqual(canaux, [...CANAUX, CANAL_COPIE]);
assert.equal(CANAUX.length, 17);
const charge = sequence.indexOf('loadFile');
assert.notEqual(charge, -1);
for (const canal of CANAUX) {
for (const canal of [...CANAUX, CANAL_COPIE]) {
const pose = sequence.indexOf(`ipcMain.handle:${canal}`);
assert.ok(pose !== -1 && pose < charge, `${canal} : ${sequence.join(', ')}`);
}
@ -188,6 +203,26 @@ describe('coquille : processus principal', () => {
assert.deepEqual(journal.profil, { avant: true, apres: false });
});
test("à la sortie, le verrou que la page a pris s'efface ; celui qu'une autre séance a repris depuis, et celui d'une autre séance, restent (§ 8.8)", () => {
for (const scenario of ['publie', 'absent']) {
const { prechargement, verrous } = journalDeLaCoquille(scenario);
assert.deepEqual(prechargement.verrou.reponse, { ok: true, valeur: { pris: true } }, scenario);
// Le second verrou de la page est au relevé de la coquille, mais son
// contenu porte désormais une autre séance : la sortie le relit et le
// laisse.
assert.deepEqual(prechargement.repris.reponse, { ok: true, valeur: { pris: true } }, scenario);
assert.deepEqual(
verrous,
{
page: { avant: true, apres: false },
repris: { avant: true, apres: true },
autre: { avant: true, apres: true },
},
scenario,
);
}
});
test("au démarrage, les profils des séances terminées se retirent : ni celui d'une séance vivante, ni un dossier sans pid", () => {
const { restes } = coquille();
assert.deepEqual(restes, DOSSIERS_POSES.filter((nom) => nom !== PROFIL_TERMINE).sort());
@ -195,11 +230,12 @@ describe('coquille : processus principal', () => {
});
describe('coquille : préchargement', () => {
test("n'expose que window.gtt.fichiers, une fonction par primitive, et ne requiert qu'electron", () => {
test("n'expose que window.gtt.fichiers, une fonction par primitive, et gtt.copier, une fonction ; ne requiert qu'electron", () => {
const { prechargement } = journalDeLaCoquille();
assert.equal(prechargement.erreur, undefined, prechargement.erreur);
assert.deepEqual(prechargement.requis, ['electron']);
assert.deepEqual(prechargement.exposes, [{ cle: 'gtt', membres: ['fichiers'] }]);
assert.deepEqual(prechargement.exposes, [{ cle: 'gtt', membres: ['copier', 'fichiers'] }]);
assert.equal(prechargement.copie.type, 'function');
assert.deepEqual(prechargement.fichiers, [...PRIMITIVES]);
assert.deepEqual(
prechargement.appels.map(({ membre, type }) => ({ membre, type })),
@ -213,7 +249,13 @@ describe('coquille : préchargement', () => {
journal.prechargement.appels.map(({ membre, canaux }) => ({ membre, canaux })),
PRIMITIVES.map((membre) => ({ membre, canaux: [`gtt:fichiers:${membre}`] })),
);
assert.deepEqual(journal.invocations, CANAUX);
// Après le pont : la copie, puis les deux verrous réels que prend la page.
assert.deepEqual(journal.invocations, [
...CANAUX,
CANAL_COPIE,
'gtt:fichiers:verrouiller',
'gtt:fichiers:verrouiller',
]);
// Une racine que le processus principal ne connaît pas est refusée par
// lui, en réponse et non en erreur.
for (const [membre, chemin] of [
@ -292,6 +334,16 @@ describe('coquille : préchargement', () => {
}
});
test('gtt.copier invoque le canal gtt:copier, une fois, et le presse-papiers reçoit le texte tel quel', () => {
const { prechargement, pressePapiers } = journalDeLaCoquille();
const { copie } = prechargement;
assert.deepEqual(copie.canaux, [CANAL_COPIE]);
assert.equal(copie.erreur, undefined, copie.erreur);
assert.equal(typeof copie.texte, 'string');
assert.notEqual(copie.texte, '');
assert.deepEqual(pressePapiers, [copie.texte]);
});
test("l'explorateur s'ouvre sur la racine que la page désigne", () => {
const journal = journalDeLaCoquille();
assert.deepEqual(journal.explorateur, [join(journal.chemins.documents, NOM_PRODUIT)]);
@ -299,3 +351,12 @@ describe('coquille : préchargement', () => {
assert.deepEqual(appelDe(journal, 'ouvrirDansExplorateur').reponse, { ok: true });
});
});
describe('coquille : la page', () => {
test("index.html charge le collecteur d'erreurs, script classique, avant le module d'entrée (§ 19.2)", () => {
const page = readFileSync(join(RACINE, 'index.html'), 'utf8');
const scripts = [...page.matchAll(/<script\b([^>]*)>/g)].map(([, attributs]) => attributs.trim());
assert.deepEqual(scripts, ['src="./collecteur.js"', 'type="module" src="/src/main.js"']);
assert.ok(existsSync(join(RACINE, 'public', 'collecteur.js')), 'public/collecteur.js absent');
});
});

View file

@ -11,9 +11,13 @@
// n'a plus rien à faire, le module joue le rôle de la page : il exécute
// electron/preload.cjs dans une fonction qui reçoit require, module et
// exports, et appelle chaque fonction du pont exposé, qui atteint les
// gestionnaires posés par le processus principal. Il joue ensuite la sortie
// de l'application — before-quit, will-quit, quit — puis imprime le journal
// en JSON sur la sortie standard.
// gestionnaires posés par le processus principal ; puis gtt.copier, avec un
// texte d'épreuve, et gtt.fichiers.verrouiller sur la racine documents, deux
// verrous réels que la page ne rend pas ; le contenu du second est récrit au
// nom d'une autre séance, comme après une reprise. Il joue ensuite la sortie
// de l'application — before-quit, will-quit, quit —, relève la présence de
// ces deux verrous et de celui d'une autre séance, autre.gtt.verrou, avant et
// après la sortie, puis imprime le journal en JSON sur la sortie standard.
//
// Seul require est restreint, à electron, comme dans un rendu en bac à
// sable. Les globales de Node du processus d'épreuve, dont process et Buffer,
@ -32,8 +36,9 @@
// désigne par TMPDIR, et par TEMP et TMP sous Windows —, et des Documents et
// un AppData qui y vivent ; app.getName, le nom de produit de package.json,
// comme Electron. Les dialogues rendent une annulation ; l'explorateur
// consigne le dossier qu'on lui demande d'ouvrir, et réussit.
import { existsSync, readFileSync } from 'node:fs';
// consigne le dossier qu'on lui demande d'ouvrir, et réussit ; le
// presse-papiers consigne chaque texte qu'il reçoit.
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { registerHooks } from 'node:module';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
@ -48,6 +53,17 @@ registerHooks({
});
const PRECHARGEMENT = fileURLToPath(new URL('../electron/preload.cjs', import.meta.url));
// Ce que la page copie, et les verrous qu'elle prend dans la racine
// documents, à côté de celui d'une autre séance que l'épreuve y pose avant le
// lancement. Le second verrou de la page est repris : son contenu porte
// ensuite SEANCE_REPRISE.
const TEXTE_COPIE = 'Bloc copié par la page factice';
const VERROU_PAGE = 'soiree.gtt.verrou';
const VERROU_REPRIS = 'reprise.gtt.verrou';
const SEANCE_PAGE = 'seance-page';
const SEANCE_REPRISE = 'seance-reprise';
const VERROU_AUTRE = 'autre.gtt.verrou';
const NOM_PRODUIT = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).productName;
// Ce que rend app.getPath, par nom.
@ -103,14 +119,21 @@ const journal = {
dialogues: [],
// Dossier de chaque shell.openPath.
explorateur: [],
// Texte de chaque clipboard.writeText.
pressePapiers: [],
// Réponse du gestionnaire d'ouverture de chaque fenêtre à une adresse
// externe.
ouvertures: [],
// Pour chaque écouteur de will-navigate : la navigation est-elle annulée ?
navigations: [],
// Ce que fait le préchargement : modules requis, objets exposés, membres
// de gtt.fichiers, et pour chacun les canaux qu'il invoque et sa réponse.
// de gtt.fichiers, et pour chacun les canaux qu'il invoque et sa réponse ;
// puis l'appel de gtt.copier et ceux des deux verrous de la page, de même.
prechargement: null,
// Présence du verrou de la page, de celui qu'une autre séance a repris et
// de celui de l'autre séance, avant puis après la sortie de l'application ;
// null quand la page n'a pas de racine documents.
verrous: null,
// Le dossier userData existe-t-il avant la sortie de l'application, puis
// après ; null sans app.setPath('userData').
profil: null,
@ -229,6 +252,12 @@ export const shell = {
},
};
export const clipboard = {
writeText(texte) {
journal.pressePapiers.push(texte);
},
};
// Côté page : ce que require('electron') rend au préchargement. invoke se
// consigne, atteint le gestionnaire posé par le processus principal pour ce
// canal, et rejette comme Electron quand il n'y en a pas.
@ -255,7 +284,7 @@ function electronDuRendu(exposes) {
// une erreur par son message.
async function appelerLePont(fichiers) {
const appels = [];
let racines;
let racines = null;
for (const membre of Object.keys(fichiers)) {
const avant = journal.invocations.length;
const appel = { membre, type: typeof fichiers[membre] };
@ -268,7 +297,21 @@ async function appelerLePont(fichiers) {
appel.canaux = journal.invocations.slice(avant);
appels.push(appel);
}
return appels;
return { appels, racines };
}
// Un appel de la page hors de la tournée du pont : sa réponse, ou le message
// de son erreur, et les canaux qu'il invoque.
async function appelerUne(fonction, parametres) {
const avant = journal.invocations.length;
const appel = {};
try {
appel.reponse = await fonction(...parametres);
} catch (erreur) {
appel.erreur = erreur.message;
}
appel.canaux = journal.invocations.slice(avant);
return appel;
}
async function jouerLePrechargement() {
@ -285,12 +328,35 @@ async function jouerLePrechargement() {
filename: PRECHARGEMENT,
})(requerir, module, module.exports);
const fichiers = exposes.find(({ cle }) => cle === 'gtt')?.api?.fichiers;
const gtt = exposes.find(({ cle }) => cle === 'gtt')?.api;
const fichiers = gtt?.fichiers;
const { appels, racines } = fichiers === undefined ? { appels: [], racines: null } : await appelerLePont(fichiers);
const copie = {
type: typeof gtt?.copier,
texte: TEXTE_COPIE,
...(typeof gtt?.copier === 'function' ? await appelerUne(gtt.copier, [TEXTE_COPIE]) : {}),
};
const documents = racines?.documents ?? null;
const verrou = documents === null || fichiers === undefined
? null
: await appelerUne(fichiers.verrouiller, [documents, VERROU_PAGE, SEANCE_PAGE]);
const repris = documents === null || fichiers === undefined
? null
: await appelerUne(fichiers.verrouiller, [documents, VERROU_REPRIS, SEANCE_PAGE]);
if (repris?.reponse?.ok === true) {
const chemin = join(documents.chemin, VERROU_REPRIS);
const contenu = JSON.parse(readFileSync(chemin, 'utf8'));
writeFileSync(chemin, `${JSON.stringify({ ...contenu, seance: SEANCE_REPRISE })}\n`);
}
return {
requis,
exposes: exposes.map(({ cle, api }) => ({ cle, membres: Object.keys(api).sort() })),
fichiers: fichiers === undefined ? null : Object.keys(fichiers),
appels: fichiers === undefined ? [] : await appelerLePont(fichiers),
appels,
copie,
verrou,
repris,
documents: documents?.chemin ?? null,
};
}
@ -320,8 +386,20 @@ async function jouerLaPage() {
}
const profil = journal.setPath.find(({ nom }) => nom === 'userData')?.chemin;
const avant = profil !== undefined && existsSync(profil);
const dossier = journal.prechargement?.documents ?? null;
const verrous = dossier === null ? null : [VERROU_PAGE, VERROU_REPRIS, VERROU_AUTRE].map((nom) => join(dossier, nom));
const presents = () => verrous.map((chemin) => existsSync(chemin));
const verrousAvant = verrous === null ? null : presents();
sortir();
journal.profil = profil === undefined ? null : { avant, apres: existsSync(profil) };
if (verrous !== null) {
const verrousApres = presents();
journal.verrous = {
page: { avant: verrousAvant[0], apres: verrousApres[0] },
repris: { avant: verrousAvant[1], apres: verrousApres[1] },
autre: { avant: verrousAvant[2], apres: verrousApres[2] },
};
}
process.stdout.write(`${JSON.stringify(journal)}\n`);
}

View file

@ -1749,7 +1749,8 @@ describe('page : src/stockage/fichiers_electron.js', () => {
test("à travers le vrai préchargement : chaque primitive de la page appelle son canal, et un refus de la coquille revient en ErreurStockage", async () => {
const { disque, fichiers } = preparer();
const { gtt, invoques } = parLePrechargement(fichiers);
assert.deepEqual(Object.keys(gtt), ['fichiers']);
assert.deepEqual(Object.keys(gtt), ['fichiers', 'copier']);
assert.equal(typeof gtt.copier, 'function');
assert.deepEqual(Object.keys(gtt.fichiers), [...PRIMITIVES]);
assert.ok(Object.values(gtt.fichiers).every((fonction) => typeof fonction === 'function'));

View file

@ -42,11 +42,12 @@ const COMMENTAIRE = new Map([
const cle = (chemin) => (COMMENTAIRE.has(basename(chemin)) ? basename(chemin) : extname(chemin));
// Périmètre : les fichiers de la racine et les arbres des sources, des
// épreuves, des scripts et de la coquille. Les dépendances et les sorties de
// épreuves, des scripts, de la coquille et des fichiers que Vite copie tels
// quels dans la page (public/). Les dépendances et les sorties de
// construction n'y entrent pas, ni les données d'épreuve, qui sont des
// données et non des sources. Le séparateur final borne l'exclusion au
// répertoire test/fixtures/ : un module test/fixtures.js reste une source.
const ARBRES = ['src', 'test', 'scripts', 'electron'];
const ARBRES = ['src', 'test', 'scripts', 'electron', 'public'];
const DONNEES_EPREUVE = join(RACINE, 'test', 'fixtures') + sep;
function fichiersSources() {
@ -123,6 +124,11 @@ describe('licence', () => {
assert.deepEqual(sansEnTete, []);
});
test('le périmètre couvre les fichiers que la page charge tels quels, sous public/', () => {
const relatifs = fichiersSources().map((chemin) => relative(RACINE, chemin).split(sep).join('/'));
assert.ok(relatifs.includes('public/collecteur.js'), 'public/collecteur.js hors du périmètre');
});
test("l'en-tête tient en deux lignes de commentaire du langage du fichier", () => {
const titulaire = '© 2026 TechnoLibre (http://www.technolibre.ca)';
// [extension, contenu, admis] : les formes admises ; puis des secondes

189
test/navigateur/montage.js Normal file
View file

@ -0,0 +1,189 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le montage de l'application dans les épreuves du navigateur (§ 13.4,
// § 14.10) : App reçoit des plateformes d'épreuve par ses propriétés, comme
// src/main.js lui donne celles de la machine — le système de fichiers simulé
// de test/fichiers_simules.js, l'horloge pilotée, l'exécuteur direct de la
// génération, un presse-papiers qui enregistre. Chaque montage vit dans son
// propre conteneur, ajouté au corps du document : deux montages simultanés
// ne partagent ni système, ni séance, ni identifiants.
//
// Le collecteur d'erreurs de la page (public/collecteur.js), que index.html
// charge avant le module d'entrée, ne l'est pas dans la page des épreuves :
// monterApp l'y installe, une fois par page, dans un <script> classique.
import { mount, unmount } from 'svelte';
import { creerExecuteurDirect } from '../../src/application/generation.js';
import { creerHorlogePilotee } from '../../src/application/horloge.js';
import { CATALOGUE } from '../../src/demo/catalogue.js';
import { LIVREES } from '../../src/demo/livrees.js';
import App from '../../src/interface/App.svelte';
import { FORMAT } from '../../src/stockage/document.js';
import { VERSION } from '../../src/version.genere.js';
import COLLECTEUR from '../../public/collecteur.js?raw';
import { creerFichiersSimules } from '../fichiers_simules.js';
/** Les primitives que le système simulé compte comme écritures, celles que
* tracer relève. */
export const PRIMITIVES_TRACEES = Object.freeze([
'sonder',
'ecrireAtomique',
'ajouterLigne',
'creerDossier',
'deplacer',
'supprimer',
'verrouiller',
'deverrouiller',
]);
// Les démonstrations du contrat de données : LIVREES, chacune nommée par
// l'entrée de même clé du catalogue, dans l'ordre du catalogue.
const DEMONSTRATIONS = LIVREES.map(({ cle, texte }) => ({
cle,
nom: CATALOGUE.find((entree) => entree.cle === cle).nom,
texte,
}));
// Le rang du dernier montage de la page : il préfixe les identifiants que
// tire chaque montage.
let montages = 0;
// Pose public/collecteur.js dans la page, une fois : un script classique
// inséré s'exécute aussitôt, dans le contexte de la page.
function installerCollecteur() {
if (globalThis.__gttErreurs !== undefined) return;
const script = document.createElement('script');
script.textContent = COLLECTEUR;
document.head.append(script);
}
/** Enveloppe les huit primitives d'écriture du système simulé — sonder,
* ecrireAtomique, ajouterLigne, creerDossier, deplacer, supprimer,
* verrouiller, deverrouiller — et rend leur relevé, dans l'ordre des appels :
* [primitive, racine.id, chemin], '' pour sonder, qui ne prend pas de chemin.
* Un appel se relève avant de s'exécuter, refusé ou non. */
export function tracer(fs) {
const trace = [];
for (const primitive of PRIMITIVES_TRACEES) {
const originale = fs[primitive];
fs[primitive] = (...parametres) => {
const [racine, chemin] = parametres;
trace.push([primitive, racine?.id, primitive === 'sonder' ? '' : chemin]);
return Reflect.apply(originale, fs, parametres);
};
}
return trace;
}
/**
* Monte App dans un conteneur neuf, ajouté au corps du document, sur des
* dépendances d'épreuve, et se résout quand app.pret l'est.
*
* @param {Object} [options]
* @param {Object} [options.systeme] les options de creerFichiersSimules —
* disque, executable, documents, nature, sonde, support, hote… ; le
* disque d'un montage précédent fait de ce montage un second processus
* @param {Array<{racine: 'documents'|'portable', chemin: string, texte: string}>} [options.fichiers]
* posés avant le montage, sans compter
* @param {Array<{nom: string, octets: Uint8Array}>} [options.aImporter] ce que
* rend fs.choisirFichierAImporter, dans l'ordre, puis null
* @param {Object} [options.horloge] une horloge pilotée à partager ; sinon
* une neuve, au départ par défaut
* @param {Object} [options.executeur] sinon l'exécuteur direct
* @param {'fr'|'temoin'} [options.langue]
* @param {'clair'|'sombre'|null} [options.theme] le thème posé au montage,
* comme le réglage de l'opérateur ; null : celui de la préférence du
* système
* @param {string} [options.provenance]
* @param {number} [options.formatCourant]
* @param {Array<{cle: string, nom: string, texte: string}>} [options.demonstrations]
* @returns {Promise<Object>} { app, racine, fs, disque, trace, horloge,
* presse, seance, facade, ui, erreurs(), demonter() } — seance est un
* accesseur de app.seance() ; presse.copies, les textes copiés ;
* erreurs(), ce que le collecteur a reçu depuis ce montage ; demonter()
* démonte App, retire le conteneur et rend à <html> son data-theme d'avant
*/
export async function monterApp({
systeme = {},
fichiers = [],
aImporter = [],
horloge,
executeur,
langue = 'fr',
theme = 'clair',
provenance = 'test',
formatCourant,
demonstrations,
} = {}) {
installerCollecteur();
montages += 1;
const numero = montages;
const premiereErreur = globalThis.__gttErreurs.length;
const disque = systeme.disque ?? new Map();
const fs = creerFichiersSimules({ ...systeme, disque });
for (const { racine: id, chemin, texte } of fichiers) fs.deposer(id, chemin, texte);
const enAttente = [...aImporter];
fs.choisirFichierAImporter = async () => enAttente.shift() ?? null;
const trace = tracer(fs);
const horlogeMontage = horloge ?? creerHorlogePilotee();
const copies = [];
const presse = {
copies,
async copier(texte) {
copies.push(texte);
},
};
let tirages = 0;
const identifiant = () => {
tirages += 1;
return `epreuve-${numero}-${tirages}`;
};
const racine = document.createElement('div');
racine.dataset.montage = String(numero);
document.body.append(racine);
const racineDocument = document.documentElement;
const themeAvant = racineDocument.getAttribute('data-theme');
const app = mount(App, {
target: racine,
props: {
fs,
horloge: horlogeMontage,
executeur: executeur ?? creerExecuteurDirect(),
pressePapiers: presse,
identifiant,
langue,
provenance,
demonstrations: demonstrations ?? DEMONSTRATIONS,
formatCourant: formatCourant ?? FORMAT,
produitVersion: VERSION.affichee,
...(theme === null ? {} : { themeInitial: theme }),
},
});
await app.pret;
return {
app,
racine,
fs,
disque,
trace,
horloge: horlogeMontage,
presse,
get seance() {
return app.seance();
},
facade: app.facade,
ui: app.ui,
erreurs: () => globalThis.__gttErreurs.slice(premiereErreur),
demonter() {
unmount(app);
racine.remove();
if (themeAvant === null) racineDocument.removeAttribute('data-theme');
else racineDocument.setAttribute('data-theme', themeAvant);
},
};
}