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