From 5fbbab64777a55a17d4827f4a38702a4e3d9e780 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 7 Oct 2026 07:22:05 -0400 Subject: [PATCH] =?UTF-8?q?[ADD]=20interface:=20application=20shell=20?= =?UTF-8?q?=E2=80=94=20banner,=20sections,=20theme,=20about?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- electron/fichiers.js | 24 +- electron/main.js | 30 +- electron/preload.cjs | 16 +- index.html | 4 + public/collecteur.js | 47 ++ scripts/essai_demarrage.js | 73 +- scripts/version.js | 9 +- scripts/version.test.js | 17 +- src/interface/APropos.svelte | 76 ++ src/interface/App.navigateur.test.js | 736 ++++++++++++++++-- src/interface/App.svelte | 292 ++++++- src/interface/Bandeau.svelte | 138 ++++ src/interface/accueil/Accueil.svelte | 99 +++ src/interface/apropos.js | 61 ++ src/interface/apropos.test.js | 89 +++ src/interface/diagnostic/Diagnostic.svelte | 13 + src/interface/etat_ui.js | 122 +++ src/interface/etat_ui.test.js | 153 ++++ src/interface/evenement/Evenement.svelte | 79 ++ src/interface/evenement/sections.js | 40 + src/interface/evenement/sections.test.js | 40 + .../generation/SectionGeneration.svelte | 13 + src/interface/import/SectionImport.svelte | 13 + src/interface/libelles/coquille.js | 66 +- src/interface/mode.js | 45 ++ src/interface/mode.test.js | 66 ++ .../participants/SectionParticipants.svelte | 13 + src/interface/plan/SectionPlan.svelte | 13 + .../propositions/SectionPropositions.svelte | 13 + src/interface/tables/SectionTables.svelte | 13 + src/interface/theme.js | 36 + src/interface/theme.test.js | 32 + src/main.js | 50 +- src/version.genere.js | 1 + test/coquille.test.js | 79 +- test/electron_factice.js | 98 ++- test/fichiers_electron.test.js | 3 +- test/licence.test.js | 10 +- test/navigateur/montage.js | 189 +++++ 39 files changed, 2770 insertions(+), 141 deletions(-) create mode 100644 public/collecteur.js create mode 100644 src/interface/APropos.svelte create mode 100644 src/interface/Bandeau.svelte create mode 100644 src/interface/accueil/Accueil.svelte create mode 100644 src/interface/apropos.js create mode 100644 src/interface/apropos.test.js create mode 100644 src/interface/diagnostic/Diagnostic.svelte create mode 100644 src/interface/etat_ui.js create mode 100644 src/interface/etat_ui.test.js create mode 100644 src/interface/evenement/Evenement.svelte create mode 100644 src/interface/evenement/sections.js create mode 100644 src/interface/evenement/sections.test.js create mode 100644 src/interface/generation/SectionGeneration.svelte create mode 100644 src/interface/import/SectionImport.svelte create mode 100644 src/interface/mode.js create mode 100644 src/interface/mode.test.js create mode 100644 src/interface/participants/SectionParticipants.svelte create mode 100644 src/interface/plan/SectionPlan.svelte create mode 100644 src/interface/propositions/SectionPropositions.svelte create mode 100644 src/interface/tables/SectionTables.svelte create mode 100644 src/interface/theme.js create mode 100644 src/interface/theme.test.js create mode 100644 test/navigateur/montage.js diff --git a/electron/fichiers.js b/electron/fichiers.js index 24b4f8b..14717c9 100644 --- a/electron/fichiers.js +++ b/electron/fichiers.js @@ -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} les primitives, par nom + * @returns {Object} 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) { diff --git a/electron/main.js b/electron/main.js index 608a8cf..61bb988 100644 --- a/electron/main.js +++ b/electron/main.js @@ -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, diff --git a/electron/preload.cjs b/electron/preload.cjs index c087e1a..9c32b7d 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -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:, 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:, 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 }); diff --git a/index.html b/index.html index 4b15a74..a8fbac0 100644 --- a/index.html +++ b/index.html @@ -7,6 +7,10 @@ + + diff --git a/public/collecteur.js b/public/collecteur.js new file mode 100644 index 0000000..f578099 --- /dev/null +++ b/public/collecteur.js @@ -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); + }; +})(); diff --git a/scripts/essai_demarrage.js b/scripts/essai_demarrage.js index a09070a..1e8a223 100644 --- a/scripts/essai_demarrage.js +++ b/scripts/essai_demarrage.js @@ -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); diff --git a/scripts/version.js b/scripts/version.js index 97ca63c..883c719 100644 --- a/scripts/version.js +++ b/scripts/version.js @@ -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'); diff --git a/scripts/version.test.js b/scripts/version.test.js index 0a8afde..d772ab4 100644 --- a/scripts/version.test.js +++ b/scripts/version.test.js @@ -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'); diff --git a/src/interface/APropos.svelte b/src/interface/APropos.svelte new file mode 100644 index 0000000..336e7f4 --- /dev/null +++ b/src/interface/APropos.svelte @@ -0,0 +1,76 @@ + + + + +
+

{t('coquille.apropos.titre')}

+

{bloc}

+
+ + +
+ {#if issue === 'faite'} +

{t('coquille.apropos.copie')}

+ {:else if issue === 'refusee'} +

{t('coquille.apropos.copieRefusee')}

+ {/if} +
+ + diff --git a/src/interface/App.navigateur.test.js b/src/interface/App.navigateur.test.js index 9ec9233..67dd8bf 100644 --- a/src/interface/App.navigateur.test.js +++ b/src/interface/App.navigateur.test.js @@ -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 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 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 = ` + + {titre} -
- {titre} - {provenance} -
+
+ (theme = choix)} + ouvrirApropos={() => uiApp.ouvrirApropos()} + {revenirALaListe} + /> +
+ {#if $uiApp.apropos} + uiApp.fermerApropos()} /> + {:else if etatSeance.evenement === null} + + {:else} + + {/if} +
+
diff --git a/src/interface/Bandeau.svelte b/src/interface/Bandeau.svelte new file mode 100644 index 0000000..4d87e13 --- /dev/null +++ b/src/interface/Bandeau.svelte @@ -0,0 +1,138 @@ + + + + +
+ {t('coquille.nom')} + {version} + {provenance} + {#if cleMode !== null} + {t(cleMode)} + {/if} + {#if cleAnnonce !== null} + {t(cleAnnonce)} + {/if} + + {#if etat.evenement !== null} + + {#if refusListe !== null} +
+ +
+ {/if} + {/if} + + {#each THEMES as choix (choix)} + + {/each} + + +
+
+ + diff --git a/src/interface/accueil/Accueil.svelte b/src/interface/accueil/Accueil.svelte new file mode 100644 index 0000000..422e41f --- /dev/null +++ b/src/interface/accueil/Accueil.svelte @@ -0,0 +1,99 @@ + + + + +
+

{t('coquille.liste')}

+

+ {t('coquille.version.libelle')} + {versionVisible(provenance, version, t)} +

+ {#if refus !== null} +

{t(refus.code, refus.details)}

+ {/if} +
    + {#each lignes as ligne (ligne.base)} +
  • + +
  • + {/each} + {#each demonstrations as demonstration (demonstration.cle)} +
  • + +
  • + {/each} +
+
+ + diff --git a/src/interface/apropos.js b/src/interface/apropos.js new file mode 100644 index 0000000..f294f73 --- /dev/null +++ b/src/interface/apropos.js @@ -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; +} diff --git a/src/interface/apropos.test.js b/src/interface/apropos.test.js new file mode 100644 index 0000000..d97b233 --- /dev/null +++ b/src/interface/apropos.test.js @@ -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'); + }); +}); diff --git a/src/interface/diagnostic/Diagnostic.svelte b/src/interface/diagnostic/Diagnostic.svelte new file mode 100644 index 0000000..c1984d3 --- /dev/null +++ b/src/interface/diagnostic/Diagnostic.svelte @@ -0,0 +1,13 @@ + + + + +
+

{t('coquille.section.diagnostic')}

+
diff --git a/src/interface/etat_ui.js b/src/interface/etat_ui.js new file mode 100644 index 0000000..d987675 --- /dev/null +++ b/src/interface/etat_ui.js @@ -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 }); + }, + }; +} diff --git a/src/interface/etat_ui.test.js b/src/interface/etat_ui.test.js new file mode 100644 index 0000000..869c043 --- /dev/null +++ b/src/interface/etat_ui.test.js @@ -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); + }); +}); diff --git a/src/interface/evenement/Evenement.svelte b/src/interface/evenement/Evenement.svelte new file mode 100644 index 0000000..7082dec --- /dev/null +++ b/src/interface/evenement/Evenement.svelte @@ -0,0 +1,79 @@ + + + + +
+

{$seance.evenement?.nom}

+ +
+
+ + diff --git a/src/interface/evenement/sections.js b/src/interface/evenement/sections.js new file mode 100644 index 0000000..d71de1e --- /dev/null +++ b/src/interface/evenement/sections.js @@ -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. 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.. + * + * @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; +} diff --git a/src/interface/evenement/sections.test.js b/src/interface/evenement/sections.test.js new file mode 100644 index 0000000..0543ffe --- /dev/null +++ b/src/interface/evenement/sections.test.js @@ -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. 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., 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)); + } + }); +}); diff --git a/src/interface/generation/SectionGeneration.svelte b/src/interface/generation/SectionGeneration.svelte new file mode 100644 index 0000000..a122498 --- /dev/null +++ b/src/interface/generation/SectionGeneration.svelte @@ -0,0 +1,13 @@ + + + + +
+

{t('coquille.section.generation')}

+
diff --git a/src/interface/import/SectionImport.svelte b/src/interface/import/SectionImport.svelte new file mode 100644 index 0000000..8307797 --- /dev/null +++ b/src/interface/import/SectionImport.svelte @@ -0,0 +1,13 @@ + + + + +
+

{t('coquille.section.import')}

+
diff --git a/src/interface/libelles/coquille.js b/src/interface/libelles/coquille.js index 7dd0589..70d8704 100644 --- a/src/interface/libelles/coquille.js +++ b/src/interface/libelles/coquille.js @@ -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'], ]); diff --git a/src/interface/mode.js b/src/interface/mode.js new file mode 100644 index 0000000..a57ffe3 --- /dev/null +++ b/src/interface/mode.js @@ -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; +} diff --git a/src/interface/mode.test.js b/src/interface/mode.test.js new file mode 100644 index 0000000..ff42fe5 --- /dev/null +++ b/src/interface/mode.test.js @@ -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); + }); +}); diff --git a/src/interface/participants/SectionParticipants.svelte b/src/interface/participants/SectionParticipants.svelte new file mode 100644 index 0000000..8079fc6 --- /dev/null +++ b/src/interface/participants/SectionParticipants.svelte @@ -0,0 +1,13 @@ + + + + +
+

{t('coquille.section.participants')}

+
diff --git a/src/interface/plan/SectionPlan.svelte b/src/interface/plan/SectionPlan.svelte new file mode 100644 index 0000000..615fcd7 --- /dev/null +++ b/src/interface/plan/SectionPlan.svelte @@ -0,0 +1,13 @@ + + + + +
+

{t('coquille.section.plan')}

+
diff --git a/src/interface/propositions/SectionPropositions.svelte b/src/interface/propositions/SectionPropositions.svelte new file mode 100644 index 0000000..a1642ea --- /dev/null +++ b/src/interface/propositions/SectionPropositions.svelte @@ -0,0 +1,13 @@ + + + + +
+

{t('coquille.section.propositions')}

+
diff --git a/src/interface/tables/SectionTables.svelte b/src/interface/tables/SectionTables.svelte new file mode 100644 index 0000000..83a09a1 --- /dev/null +++ b/src/interface/tables/SectionTables.svelte @@ -0,0 +1,13 @@ + + + + +
+

{t('coquille.section.tables')}

+
diff --git a/src/interface/theme.js b/src/interface/theme.js new file mode 100644 index 0000000..e9bd6d1 --- /dev/null +++ b/src/interface/theme.js @@ -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 (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.. + * + * @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; +} diff --git a/src/interface/theme.test.js b/src/interface/theme.test.js new file mode 100644 index 0000000..d3a2e4e --- /dev/null +++ b/src/interface/theme.test.js @@ -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., 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); + }); +}); diff --git a/src/main.js b/src/main.js index cdbaa06..77e4cb0 100644 --- a/src/main.js +++ b/src/main.js @@ -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, + }, +}); diff --git a/src/version.genere.js b/src/version.genere.js index 438f81e..6540eda 100644 --- a/src/version.genere.js +++ b/src/version.genere.js @@ -5,4 +5,5 @@ export const VERSION = Object.freeze({ affichee: '2026.10.05.01', technique: '2026.1005.1', + windows: '2026.1005.1.0', }); diff --git a/test/coquille.test.js b/test/coquille.test.js index 50d72a0..87bdfa4 100644 --- a/test/coquille.test.js +++ b/test/coquille.test.js @@ -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: par primitive, et aucun autre, chacun posé avant le chargement de la page', () => { + test('un canal gtt:fichiers: 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(/]*)>/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'); + }); +}); diff --git a/test/electron_factice.js b/test/electron_factice.js index b1bb25d..c3a6510 100644 --- a/test/electron_factice.js +++ b/test/electron_factice.js @@ -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`); } diff --git a/test/fichiers_electron.test.js b/test/fichiers_electron.test.js index 47ac7cb..8064f99 100644 --- a/test/fichiers_electron.test.js +++ b/test/fichiers_electron.test.js @@ -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')); diff --git a/test/licence.test.js b/test/licence.test.js index f6cf892..fc7f392 100644 --- a/test/licence.test.js +++ b/test/licence.test.js @@ -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 diff --git a/test/navigateur/montage.js b/test/navigateur/montage.js new file mode 100644 index 0000000..0e9ba89 --- /dev/null +++ b/test/navigateur/montage.js @@ -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