From 416d91a88f42090a71036a9c8d524dd7baccdd9c Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Fri, 9 Oct 2026 17:11:48 -0400 Subject: [PATCH] [ADD] badges: prepare, produce and save the sheet from the session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The operator needs the badge sheet as a file next to the event, built from what the session shows at that moment. The session prepares an announcement (sheets per paper, counts, namesakes, missing glyphs, draft), then produces the PDF through a single deferred import of the PDF code and writes it atomically; under the web build it downloads instead. A gesture started during production waits its turn. A proposal sheet is always a draft, and an unwritable file or a missing paper choice gives its refusal with a remedy. Checked: red first; 3670 node, 707 browser and 101 long tests green from the index alone. --- FR --- [ADD] badges : préparer, produire et déposer la planche depuis la séance L'opérateur a besoin de la planche en fichier, à côté de l'événement, tirée de ce que la séance montre à cet instant. La séance prépare une annonce (planches par papier, comptes, homonymes, glyphes absents, brouillon), puis produit le PDF par un seul import différé du code PDF et l'écrit de façon atomique ; sous web, elle le télécharge. Un geste lancé pendant la production attend son tour. Une planche tirée d'une proposition est toujours un brouillon, et un fichier non écrit ou un papier non choisi donnent leur refus avec un remède. Vérifié : rouge d'abord ; 3670 node, 707 navigateur et 101 longues vertes depuis l'index seul. Assisted-by: Claude Opus 5.5 --- src/application/libelles.test.js | 2 + src/application/libelles/planche.js | 10 +- src/application/planche_seance.js | 246 ++++++++++++ src/application/planche_seance.test.js | 461 ++++++++++++++++++++++ src/application/seance.js | 42 ++ src/application/seance.test.js | 80 +++- src/interface/App.svelte | 4 + src/interface/libelles/communs.js | 2 + src/interface/primitives/commande.js | 15 +- src/interface/primitives/commande.test.js | 27 +- 10 files changed, 881 insertions(+), 8 deletions(-) create mode 100644 src/application/planche_seance.js create mode 100644 src/application/planche_seance.test.js diff --git a/src/application/libelles.test.js b/src/application/libelles.test.js index 9b9fd29..b83156d 100644 --- a/src/application/libelles.test.js +++ b/src/application/libelles.test.js @@ -551,6 +551,8 @@ const EXEMPLES = new Map([ ], ['PLANCHE_TROP_DE_TOURS', { tours: 40, remede: null }], ['PLANCHE_NOM_TROP_LONG', { participant: 3, personne: 'Ondine Lacasse', remede: { geste: 'ouvrirSection', section: 'participants' } }], + ['PLANCHE_NON_ECRITE', { chemin: 'Soirée des Lucioles.badges_A4_feuille_2031-05-17_20-15-07.pdf', dossier: 'E:\\data', cause: 'EROFS', remede: { geste: 'enregistrerSous' } }], + ['PAPIER_REQUIS', { remede: { geste: 'choisirPapier' } }], // Sorties ['IMPRESSION_ECHEC', { cause: 'failed', remede: null }], ]); diff --git a/src/application/libelles/planche.js b/src/application/libelles/planche.js index b5f73a4..24a7528 100644 --- a/src/application/libelles/planche.js +++ b/src/application/libelles/planche.js @@ -9,7 +9,7 @@ // seule forme, sans horloge ni langue du système. Le nom d'un papier arrive // dans les détails : cette table n'en écrit aucun. import { lireHorodatage } from '../format.js'; -import { compte, enumeration, nomDans, phraseDeRaison } from './aides.js'; +import { compte, enumeration, nomDans, ouEcrire, phraseDeRaison } from './aides.js'; // Les impositions de la planche (§ 11.6), comme la feuille de contrôle les // nomme. @@ -69,6 +69,14 @@ export const TABLE = new Map([ 'la planche ne se produit pas, plutôt que de tronquer ses tours.', ], + // --- Les refus de la production (§ 8.5, § 11.6, décision 13) ------------- + [ + 'PLANCHE_NON_ECRITE', + (details, texte) => + `La planche n'a pas pu s'écrire : ${ouEcrire(details, texte)}. « Enregistrer sous… » la dépose ailleurs.`, + ], + ['PAPIER_REQUIS', () => "Choisissez le papier de l'imprimante avant de produire la planche : l'imposition en dépend."], + // --- Les lignes de tour d'un badge (§ 11.4, § 11.5) ------------------------ // Une ligne : le tour, ou « Toute la soirée » pour un itinéraire constant, // puis la place de la personne. diff --git a/src/application/planche_seance.js b/src/application/planche_seance.js new file mode 100644 index 0000000..7e1e19e --- /dev/null +++ b/src/application/planche_seance.js @@ -0,0 +1,246 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// La planche de badges dans la séance (§ 11.2, § 11.6, § 11.8) : préparer +// l'annonce, produire le PDF et le déposer, l'enregistrer ailleurs. Sortir +// n'est pas modifier (§ 8.4, § 9) : rien ici n'écrit le fichier d'état ni le +// journal, ne prend de verrou ni ne lit le mode de la séance ; la seule +// écriture est celle du PDF, à côté des fichiers d'événement, ou son +// téléchargement sous web, où le dossier de travail est invisible à +// l'opérateur (décision 13). Une écriture du PDF refusée se dit +// PLANCHE_NON_ECRITE, remède « Enregistrer sous… », sans toucher au support +// de l'événement. +// +// La production vit dans src/pdf, chargé par un seul import() différé : le +// paquet n'embarque pdf-lib qu'au premier besoin, et cette couche ne voit +// jamais un objet de pdf-lib, seulement des octets. La police arrive en +// octets, par la fonction que la séance reçoit, chargée une fois. +// +// Une demande désigne la source — le retenu, ou une proposition par son +// identifiant, toujours BROUILLON (Q3) —, l'imposition, l'ordre de la pile +// et, pour une réédition, la sélection. Les refus s'ordonnent : ceux du +// placement (refusPlanche), puis ceux de la mise en page, nommant la +// personne du badge refusé, puis le papier à choisir. +import { PAPIERS } from '../geometrie/papiers.js'; +import { nombrePlanches } from '../pdf/imposition.js'; +import { ErreurStockage } from '../stockage/erreurs.js'; +import { nomPlanche } from '../stockage/noms.js'; +import { ErreurCommande, refus } from './erreurs.js'; +import { libelle } from './libelles.js'; +import { IMPOSITIONS, ORDRES, modelePlanche, refusPlanche, refusProduction, textesControle } from './planche.js'; +import { estBrouillon } from './sorties.js'; + +// La production du PDF, chargée au premier appel ; le module se garde en +// cache, comme tout module chargé. +const chargerProduction = () => import('../pdf/planche.js'); + +/** + * Ce que la séance lit de l'événement ouvert, à son tour dans la file de ses + * appels. + * + * @typedef {Object} LecturePlanche + * @property {import('../stockage/types.js').Charge} charge + * @property {string} base la base des fichiers de l'événement + * @property {import('../stockage/systeme_fichiers.js').SystemeFichiers} fs + * @property {import('../stockage/systeme_fichiers.js').Racine} racine celle des fichiers de l'événement + * @property {'A4'|'Lettre'|null} papier celui des réglages locaux, null sans choix + * + * @typedef {Object} DemandePlanche + * @property {'retenu'|'proposition'} [source] 'retenu' par défaut + * @property {number|null} [proposition] l'identifiant de la proposition imprimée + * @property {'feuille'|'pile'} [imposition] 'feuille' par défaut + * @property {'alphabetique'|'table'} [ordre] 'alphabetique' par défaut + * @property {number[]|null} [selection] les personnes à rééditer ; null : toutes + * + * @typedef {Object} Annonce + * @property {null|{code: string, details: Object}} refus le premier refus, remède dans les détails + * @property {'A4'|'Lettre'|null} papier + * @property {null|{participants: number, exclus: number, produits: number}} comptes + * @property {null|Object} planches par papier, le nombre de planches + * @property {Array<{nom: string, ids: number[]}>} homonymes + * @property {string[]} absents les caractères sans glyphe, à leur première rencontre + * @property {boolean} brouillon + * @property {string|null} nom le nom du fichier ; null sans papier + */ + +// Le refus du papier à choisir : rien ne se produit sans lui (§ 11.2). Un +// objet neuf à chaque refus. +const papierRequis = () => ({ code: 'PAPIER_REQUIS', details: { remede: { geste: 'choisirPapier' } } }); + +// La demande lue, ses défauts posés ; une imposition ou un ordre hors de sa +// liste est une faute du code, levée avant tout refus du placement. La +// source et la sélection se contrôlent où elles se lisent (planche.js). +function demandeLue({ source = 'retenu', proposition = null, imposition = 'feuille', ordre = 'alphabetique', selection = null } = {}) { + if (!IMPOSITIONS.includes(imposition)) { + throw new TypeError(`imposition : ${IMPOSITIONS.join(' ou ')} attendue, reçu ${JSON.stringify(imposition)}`); + } + if (!ORDRES.includes(ordre)) throw new TypeError(`ordre : ${ORDRES.join(' ou ')} attendu, reçu ${JSON.stringify(ordre)}`); + return { source, proposition, imposition, ordre, selection }; +} + +// Le nombre de planches de n badges, pour chaque papier : l'annonce les +// donne tous deux avant que le papier ne soit choisi (§ 11.2). +const planchesDe = (n) => Object.fromEntries(Object.keys(PAPIERS).map((papier) => [papier, nombrePlanches(n, papier)])); + +/** + * Les opérations de la planche d'une séance. + * + * @param {Object} parametres + * @param {(() => Promise<{regulier: Uint8Array, gras: Uint8Array}>)|null} parametres.polices + * charge les octets de la police embarquée ; appelée au premier + * besoin, puis gardée — un chargement refusé se réessaie au suivant + * @param {string|null} parametres.provenance le mode de construction + * @param {string} parametres.version la version affichée + * @returns {{preparer: (lecture: LecturePlanche, demande?: DemandePlanche) => Promise, + * produire: (lecture: LecturePlanche, demande?: DemandePlanche) => Promise<{chemin: string|null, nom: string}>, + * enregistrerSous: (lecture: LecturePlanche, demande?: DemandePlanche) => Promise}} + */ +export function creerPlanches({ polices, provenance, version }) { + let octetsPolices = null; + + // Les octets de la police, chargés une fois ; une promesse rejetée ne se + // garde pas. + function lesPolices() { + if (typeof polices !== 'function') throw new TypeError('polices : la fonction qui charge la police embarquée attendue'); + if (octetsPolices === null) { + octetsPolices = Promise.resolve().then(polices); + octetsPolices.catch(() => { + octetsPolices = null; + }); + } + return octetsPolices; + } + + // L'étude d'une demande : le refus du placement, ou le modèle, son + // analyse par la mise en page et le refus qu'elle porte. Un refus de mise + // en page reçoit la personne du badge refusé, que src/pdf ne connaît pas. + // Rien ne s'écrit. + async function etudier({ charge, papier }, demande) { + const { source, proposition, ordre, selection, imposition } = demandeLue(demande); + const rejet = refusPlanche(charge, { source, proposition }); + if (rejet !== null) return { refus: rejet, modele: null, absents: [], papier, imposition, source }; + if (typeof provenance !== 'string') throw new TypeError('provenance : le mode de construction attendu'); + const modele = modelePlanche(charge, { source, proposition, ordre, selection, provenance, version }); + const octets = await lesPolices(); + const { analyserPlanche } = await chargerProduction(); + const analyse = await analyserPlanche({ modele, polices: octets }); + let leRefus = null; + if (analyse.refus !== null) { + const { code, details, rang } = analyse.refus; + const badge = modele.badges[rang]; + const personne = code === 'PLANCHE_NOM_TROP_LONG' ? { participant: badge.participant, personne: badge.nom } : {}; + leRefus = refusProduction(code, { ...details, ...personne }); + } else if (papier === null) { + leRefus = papierRequis(); + } + return { refus: leRefus, modele, absents: analyse.absents, papier, imposition, source }; + } + + // Le nom du fichier d'une étude, null sans papier. + const nomDe = (base, { modele, papier, imposition }) => + papier === null ? null : nomPlanche(base, { papier, imposition, horodatage: modele.horodatage, reedition: modele.reedition }); + + // Les octets de la planche d'une demande, et son nom ; le refus de + // l'étude lève, ErreurCommande, avant que rien ne se produise. + async function octetsDe(lecture, demande) { + const etude = await etudier(lecture, demande); + if (etude.refus !== null) throw new ErreurCommande(etude.refus.code, etude.refus.details); + const { modele, papier, imposition, absents } = etude; + const planches = nombrePlanches(modele.badges.length, papier); + const controle = textesControle(modele, { papier, imposition, planches, absents }); + const { produirePlanche } = await chargerProduction(); + const octets = await produirePlanche({ + modele, + papier, + imposition, + polices: await lesPolices(), + controle, + filigrane: libelle('planche.brouillon'), + }); + return { octets, nom: nomDe(lecture.base, etude) }; + } + + return { + /** + * L'annonce de la planche, avant de produire (§ 11.2, § 11.5, § 11.8, + * décision 12). Un refus du placement rend l'annonce sans modèle : + * comptes, planches et nom null, ni homonymes ni absents, le brouillon + * de la source. Sans papier, refus PAPIER_REQUIS et nom null, les + * planches des deux papiers données. Rien ne s'écrit. + * + * @throws {TypeError} une demande hors de son domaine, une séance sans + * polices ou sans provenance + * @throws {ErreurCommande} PROPOSITION_INCONNUE, PARTICIPANT_INCONNU, + * PERSONNE_EXCLUE (modelePlanche) + */ + async preparer(lecture, demande) { + const etude = await etudier(lecture, demande); + const { charge, papier } = lecture; + if (etude.modele === null) { + return { + refus: etude.refus, + papier, + comptes: null, + planches: null, + homonymes: [], + absents: [], + brouillon: estBrouillon(charge, { source: etude.source, tour: 'tous' }), + nom: null, + }; + } + const { modele } = etude; + return { + refus: etude.refus, + papier, + comptes: modele.comptes, + planches: planchesDe(modele.badges.length), + homonymes: modele.homonymes, + absents: etude.absents, + brouillon: modele.brouillon, + nom: nomDe(lecture.base, etude), + }; + }, + + /** + * Produit la planche et la dépose (§ 11.6) : à côté des fichiers + * d'événement, par une écriture atomique, sous electron et sur le + * système d'épreuve, et rend son chemin en entier ; sous web, par + * enregistrerSous, le téléchargement, et chemin null. Le refus de + * l'annonce lève, sans rien écrire. Une écriture refusée par le support + * lève PLANCHE_NON_ECRITE {chemin, dossier, cause}, remède + * « Enregistrer sous… » : le support de l'événement n'en est pas rompu. + * + * @throws {ErreurCommande} le refus de l'annonce ; PLANCHE_NON_ECRITE + */ + async produire(lecture, demande) { + const { octets, nom } = await octetsDe(lecture, demande); + const { fs, racine } = lecture; + if (fs.nature === 'web') { + await fs.enregistrerSous(nom, octets); + return { chemin: null, nom }; + } + try { + await fs.ecrireOctetsAtomique(racine, nom, octets); + } catch (erreur) { + if (!(erreur instanceof ErreurStockage && erreur.code === 'ECRITURE')) throw erreur; + const { chemin, dossier, cause } = erreur.details; + throw refus('PLANCHE_NON_ECRITE', { chemin, dossier, cause }, { geste: 'enregistrerSous' }); + } + const { separateur } = await fs.emplacements(); + const tete = racine.chemin.endsWith(separateur) ? racine.chemin : racine.chemin + separateur; + return { chemin: tete + nom, nom }; + }, + + /** + * Produit la planche et la confie au dialogue d'enregistrement de la + * plateforme, sous le nom de la planche : le remède d'une écriture + * refusée. Rend le chemin choisi, null quand l'opérateur annule. + * + * @throws {ErreurCommande} le refus de l'annonce + */ + async enregistrerSous(lecture, demande) { + const { octets, nom } = await octetsDe(lecture, demande); + return lecture.fs.enregistrerSous(nom, octets); + }, + }; +} diff --git a/src/application/planche_seance.test.js b/src/application/planche_seance.test.js new file mode 100644 index 0000000..00cf022 --- /dev/null +++ b/src/application/planche_seance.test.js @@ -0,0 +1,461 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// La planche dans la séance (§ 11.2, § 11.6, § 11.8, § 8.4, § 8.6, § 9) : +// préparer annonce, produire dépose le PDF à côté des fichiers d'événement +// — ou le télécharge sous web —, enregistrer sous le confie au dialogue. +// Sortir n'est pas modifier (contrainte 24) : en lecture, sur un plan +// bloqué, sous un format plus récent, la trace des écritures du système +// d'épreuve ne porte que le .pdf ; un refus n'écrit rien ; l'écriture du PDF +// refusée se dit PLANCHE_NON_ECRITE, sans rompre le support de l'événement. +// +// La série surveillée borne le coût (§ 14.4, contrainte 31) : douze +// productions de PDF au plus dans ce fichier, sur une soirée de quatre +// personnes — trois pour les trois modes, trois pour la stabilité des +// octets, une réédition, deux refus d'écriture et l'enregistrement sous, +// une sous web, une proposition. Celle sous web sort en Lettre, en pile, +// par table : sur deux badges par planche, l'imposition et l'ordre demandés +// se lisent dans la place de chaque nom. La réédition porte un caractère +// sans glyphe, que sa feuille de contrôle nomme. Le témoin de 257 ne +// s'analyse que pour son annonce. Les noms sont inventés ; la version vient +// du module engendré. +import assert from 'node:assert/strict'; +import { creerFichiersSimules } from '../../test/fichiers_simules.js'; +import { H_PROPOSITION, H_RETENU, personne, soiree, table } from '../../test/fixtures/badges/cas.js'; +import { FORME_TEMOIN, chargeTemoin } from '../../test/fixtures/badges/temoin.js'; +import { describe, test } from '../../test/lanceur.js'; +import { lirePdf } from '../../test/pdf.js'; +import { lirePolices } from '../../test/polices.js'; +import { serialiser } from '../stockage/canonique.js'; +import { creer as creerDansLeDepot } from '../stockage/depot.js'; +import { FORMAT } from '../stockage/document.js'; +import { SUFFIXES, nomPlanche } from '../stockage/noms.js'; +import { VERSION } from '../version.genere.js'; +import { libelle } from './libelles.js'; +import { creerSeance } from './seance.js'; + +const V1 = VERSION.affichee; +const DOCUMENTS = 'D:\\Soirees\\Gestion table tournante Libre'; +const DOC = { id: 'documents', chemin: DOCUMENTS }; +const BASE = 'Soirée des Lucioles'; +const POLICES = lirePolices(); +const FILIGRANE = libelle('planche.brouillon'); + +// Les primitives qui écrivent, et enregistrerSous, que la trace consigne. +const ECRITURES = [ + 'sonder', + 'ecrireAtomique', + 'ecrireOctetsAtomique', + 'ajouterLigne', + 'creerDossier', + 'deplacer', + 'supprimer', + 'verrouiller', + 'deverrouiller', + 'enregistrerSous', +]; + +// Enveloppe chaque primitive d'écriture du système : la trace reçoit +// [primitive, chemin], le nom proposé pour enregistrerSous. +function tracer(fs) { + const trace = []; + for (const nom of ECRITURES) { + const primitive = fs[nom]; + fs[nom] = (...args) => { + trace.push([nom, nom === 'enregistrerSous' ? args[0] : args[1]]); + return primitive(...args); + }; + } + return trace; +} + +// Horloge d'épreuve : une minute de plus à chaque lecture, le matin de la +// soirée — jamais l'instant d'un placement construit. +function creerHorloge() { + let n = 0; + return { + horodatage() { + n += 1; + return `2031-05-17T08:${String(n).padStart(2, '0')}:00-04:00`; + }, + }; +} + +// Quatre personnes, deux tables de deux places, deux tours ; le retenu vient +// de la proposition 1, la proposition 2 en est une autre. +const PLAN = [{ sieges: [[1, 2], [3, 4]] }, { sieges: [[1, 3], [2, 4]] }]; +const AUTRE_PLAN = [{ sieges: [[1, 4], [2, 3]] }, { sieges: [[1, 2], [3, 4]] }]; +function chargeLucioles({ personnes = null, ...champs } = {}) { + return soiree({ + personnes: personnes ?? [ + personne(1, 'Iris', 'Ombrelle', { appartenance: 'Club des Merles' }), + personne(2, 'Théo', 'Pervenche'), + personne(3, 'Ondine', 'Lacasse'), + personne(4, null, 'Grisaille'), + ], + tables: [table(1, 1, 2), table(2, 2, 2)], + plan: PLAN, + propositions: [ + [1, PLAN], + [2, AUTRE_PLAN], + ], + ...champs, + }); +} + +// Le nom de la planche de la soirée d'essai. +const nomDe = ({ papier = 'A4', imposition = 'feuille', horodatage = H_RETENU, reedition = null } = {}) => + nomPlanche(BASE, { papier, imposition, horodatage, reedition }); + +// Pose l'événement de la charge sur le disque, par le dépôt ; format : +// celui qu'annonce l'en-tête de l'état, FORMAT par défaut. +async function deposer(fs, charge, { format = FORMAT } = {}) { + fs.deposerDossier('documents'); + if (format === FORMAT) { + await creerDansLeDepot(fs, DOC, charge, { + libelle: libelle('ENTREE_CREATION', { nom: charge.evenement.nom }), + nomJalon: libelle('JALON_CREATION'), + horodatage: '2031-05-17T07:00:00-04:00', + produitVersion: V1, + }); + return; + } + const texte = serialiser(charge, { revision: 1, produitVersion: V1 }); + fs.deposer('documents', `${BASE}${SUFFIXES.etat}`, texte.replace(`"format":${FORMAT}`, `"format":${format}`)); +} + +// Une séance sur l'événement de la charge, ouvert en lecture, le papier +// choisi quand il est donné ; la trace part ensuite, vide. +async function monter(charge = chargeLucioles(), { papier = 'A4', fs = creerFichiersSimules({ documents: DOCUMENTS }), format } = {}) { + await deposer(fs, charge, { format }); + let tirage = 0; + const seance = creerSeance({ + fs, + racine: DOC, + horloge: creerHorloge(), + identifiant: () => { + tirage += 1; + return `seance-${tirage}`; + }, + produitVersion: V1, + planifier: () => null, + annuler: () => {}, + polices: async () => POLICES, + provenance: 'production', + }); + if (papier !== null) await seance.choisirPapier(papier); + await seance.ouvrir(BASE); + const trace = tracer(fs); + return { fs, seance, trace }; +} + +// Le refus attendu d'une promesse : son code, et ses détails quand ils sont +// donnés. +async function refusee(promesse, code, details) { + await assert.rejects(promesse, (erreur) => { + assert.equal(erreur.code, code, erreur.message); + if (details !== undefined) assert.deepEqual(erreur.details, details); + return true; + }); +} + +// Les textes d'une page du PDF relu, et si l'un d'eux s'écrit au trait. +const textesDe = (page) => page.textes.map(({ texte }) => texte); + +// Les noms d'une page de badges, parmi ceux donnés, du haut vers le bas puis +// de gauche à droite : l'ordre des cellules sur la feuille. Dans les boîtes +// que rend lirePdf, y croît vers le pied de la page. +const nomsDe = (page, noms) => + page.textes + .filter(({ texte }) => noms.includes(texte)) + .sort((a, b) => a.y - b.y || a.x - b.x) + .map(({ texte }) => texte); +const auTrait = (pdf) => pdf.pages.some((page) => page.modesTexte.includes(1)); + +describe('planche dans la séance : sortir n’est pas modifier (contrainte 24, § 8.4, § 9)', () => { + test('produire en lecture, sur un plan bloqué, sous un format plus récent : la trace ne porte que le .pdf, déposé à côté de l’événement', async () => { + const cas = [ + ['lecture', chargeLucioles(), {}], + ['bloqué', chargeLucioles({ etat: 'bloque' }), {}], + ['format plus récent', chargeLucioles(), { format: FORMAT + 1 }], + ]; + let retenu = null; + for (const [nom, charge, options] of cas) { + const { fs, seance, trace } = await monter(charge, options); + const revision = seance.etat().evenement.revision; + const attendu = nomDe(); + const annonce = await seance.preparerPlanche(); + assert.deepEqual( + annonce, + { + refus: null, + papier: 'A4', + comptes: { participants: 4, exclus: 0, produits: 4 }, + planches: { A4: 1, Lettre: 2 }, + homonymes: [], + absents: [], + brouillon: false, + nom: attendu, + }, + nom, + ); + assert.deepEqual(trace, [], `${nom} : préparer écrit`); + const rendu = await seance.produirePlanche(); + assert.deepEqual(rendu, { chemin: `${DOCUMENTS}\\${attendu}`, nom: attendu }, nom); + assert.deepEqual(trace, [['ecrireOctetsAtomique', attendu]], nom); + assert.equal(seance.mode, 'lecture', nom); + assert.equal(seance.etat().evenement.revision, revision, nom); + const octets = fs.octets('documents', attendu); + assert.equal(new TextDecoder().decode(octets.subarray(0, 5)), '%PDF-', nom); + retenu ??= octets; + } + // Le retenu d'un plan retenu ne porte pas le filigrane. + const pdf = await lirePdf(retenu); + assert.ok(!pdf.pages.some((page) => textesDe(page).includes(FILIGRANE))); + assert.equal(auTrait(pdf), false); + }); + + test('sans papier choisi : PAPIER_REQUIS avant toute écriture ; choisi, le nom et l’annonce le portent, et une autre séance de la racine ne le redemande pas', async () => { + const { fs, seance, trace } = await monter(chargeLucioles(), { papier: null }); + const requis = { code: 'PAPIER_REQUIS', details: { remede: { geste: 'choisirPapier' } } }; + const annonce = await seance.preparerPlanche(); + assert.deepEqual([annonce.refus, annonce.papier, annonce.nom], [requis, null, null]); + assert.deepEqual(annonce.planches, { A4: 1, Lettre: 2 }); + await refusee(seance.produirePlanche(), 'PAPIER_REQUIS', requis.details); + assert.deepEqual(trace, []); + + await seance.choisirPapier('Lettre'); + trace.length = 0; + const lettre = await seance.preparerPlanche({ imposition: 'pile' }); + assert.deepEqual([lettre.refus, lettre.papier, lettre.nom], [null, 'Lettre', nomDe({ papier: 'Lettre', imposition: 'pile' })]); + const autre = creerSeance({ + fs, + racine: DOC, + horloge: creerHorloge(), + identifiant: () => 'seance-autre', + produitVersion: V1, + planifier: () => null, + annuler: () => {}, + polices: async () => POLICES, + provenance: 'production', + }); + await autre.ouvrir(BASE); + assert.equal((await autre.preparerPlanche()).papier, 'Lettre'); + assert.deepEqual(trace, []); + }); + + test('une demande hors de son domaine lève TypeError ; une sélection inconnue, PARTICIPANT_INCONNU ; sans événement, AUCUN_EVENEMENT — rien ne s’écrit', async () => { + const { seance, trace } = await monter(); + await assert.rejects(seance.preparerPlanche({ imposition: 'rouleau' }), TypeError); + await assert.rejects(seance.produirePlanche({ ordre: 'hasard' }), TypeError); + await assert.rejects(seance.preparerPlanche({ source: 'reservations' }), TypeError); + await refusee(seance.produirePlanche({ selection: [3, 99] }), 'PARTICIPANT_INCONNU'); + await refusee(seance.preparerPlanche({ source: 'proposition', proposition: 7 }), 'PROPOSITION_INCONNUE'); + await seance.fermer(); + await refusee(seance.preparerPlanche(), 'AUCUN_EVENEMENT'); + await refusee(seance.enregistrerPlancheSous(), 'AUCUN_EVENEMENT'); + assert.deepEqual(trace, []); + }); +}); + +describe('planche dans la séance : l’annonce (§ 11.2, § 11.5, § 11.8, décision 12)', () => { + test('le témoin : 65 planches A4 et 129 Lettre, ses comptes, aucun homonyme', async () => { + const { seance, trace } = await monter(chargeTemoin()); + const annonce = await seance.preparerPlanche(); + const { participants, exclus, produits, planchesA4, planchesLettre } = FORME_TEMOIN; + assert.deepEqual(annonce.planches, { A4: planchesA4, Lettre: planchesLettre }); + assert.deepEqual(annonce.comptes, { participants, exclus, produits }); + assert.deepEqual([annonce.refus, annonce.homonymes, annonce.absents, annonce.brouillon], [null, [], [], false]); + assert.deepEqual(trace, []); + }); + + test('les homonymes que rien ne sépare sont nommés ; un nom à glyphe absent, « Ondine 李 », nomme le caractère', async () => { + const charge = chargeLucioles({ + personnes: [ + personne(1, 'Iris', 'Ombrelle', { appartenance: 'Club des Merles' }), + personne(2, 'Iris', 'Ombrelle', { appartenance: 'Club des Merles' }), + personne(3, 'Ondine', '李'), + personne(4, null, 'Grisaille'), + ], + }); + const { seance } = await monter(charge); + const annonce = await seance.preparerPlanche(); + assert.equal(annonce.refus, null); + assert.deepEqual(annonce.homonymes, [{ nom: 'Iris Ombrelle', ids: [1, 2] }]); + assert.deepEqual(annonce.absents, ['李']); + }); + + test('une proposition s’annonce BROUILLON, sous son propre horodatage ; sans placement, PLANCHE_SANS_PLACEMENT et rien du modèle', async () => { + const { seance } = await monter(); + const proposee = await seance.preparerPlanche({ source: 'proposition', proposition: 2 }); + assert.deepEqual([proposee.refus, proposee.brouillon, proposee.nom], [null, true, nomDe({ horodatage: H_PROPOSITION })]); + + const sans = await monter(chargeLucioles({ plan: null, etat: 'propose' })); + const annonce = await sans.seance.preparerPlanche(); + assert.deepEqual(annonce, { + refus: { code: 'PLANCHE_SANS_PLACEMENT', details: { remede: { geste: 'ouvrirSection', section: 'propositions' } } }, + papier: 'A4', + comptes: null, + planches: null, + homonymes: [], + absents: [], + brouillon: true, + nom: null, + }); + await refusee(sans.seance.produirePlanche(), 'PLANCHE_SANS_PLACEMENT'); + await assert.rejects(sans.seance.preparerPlanche({ ordre: 'hasard' }), TypeError); + assert.deepEqual(sans.trace, []); + }); + + test('dérive : PLANCHE_DERIVE nomme la personne, avant le papier, et rien ne s’écrit', async () => { + const charge = chargeLucioles(); + charge.participants.push(personne(5, 'Aube', 'Sarcelle')); + charge.prochainsIds.participant = 6; + const { seance, trace } = await monter(charge, { papier: null }); + const annonce = await seance.preparerPlanche(); + assert.equal(annonce.refus.code, 'PLANCHE_DERIVE'); + assert.deepEqual(annonce.refus.details.raisons, [{ code: 'PARTICIPANT_NON_PLACE', participant: 5, personne: 'Aube Sarcelle' }]); + await refusee(seance.produirePlanche(), 'PLANCHE_DERIVE'); + assert.deepEqual(trace, []); + }); + + test('un nom trop long pour son badge : PLANCHE_NOM_TROP_LONG nomme la personne du badge refusé, pas celle de son rang dans la liste', async () => { + const long = `Pervenche${'w'.repeat(1990)}`; + const charge = chargeLucioles(); + charge.participants[1].nom = long; + const { seance, trace } = await monter(charge); + const details = { participant: 2, personne: `Théo ${long}`, remede: { geste: 'ouvrirSection', section: 'participants' } }; + const annonce = await seance.preparerPlanche(); + assert.deepEqual(annonce.refus, { code: 'PLANCHE_NOM_TROP_LONG', details }); + await refusee(seance.produirePlanche(), 'PLANCHE_NOM_TROP_LONG', details); + assert.deepEqual(trace, []); + }); +}); + +describe('planche dans la séance : les octets et le nom (§ 11.6, § 11.8)', () => { + test('deux productions du même placement : mêmes octets, même nom ; un geste lancé pendant la seconde attend son tour ; la retouche change le nom ; défaire rend le premier', async () => { + const { fs, seance, trace } = await monter(); + const premier = await seance.produirePlanche(); + const octetsPremiers = fs.octets('documents', premier.nom); + await seance.passerEnEcriture(); + trace.length = 0; + + const production = seance.produirePlanche(); + const geste = seance.executer('echangerDansRetenu', { tour: 1, a: 1, b: 3 }); + const [second] = await Promise.all([production, geste]); + assert.deepEqual(second, premier); + assert.deepEqual(fs.octets('documents', second.nom), octetsPremiers); + // Le PDF part d'abord, puis le geste écrit son entrée et son état. + assert.deepEqual(trace[0], ['ecrireOctetsAtomique', premier.nom]); + assert.deepEqual(trace[1], ['ajouterLigne', `${BASE}${SUFFIXES.journal}`]); + assert.ok(trace.slice(1).every(([primitive]) => primitive !== 'ecrireOctetsAtomique')); + + const retouche = await seance.preparerPlanche(); + const horodatage = seance.charge.retenu.horodatage; + assert.notEqual(horodatage, H_RETENU); + assert.equal(retouche.nom, nomDe({ horodatage })); + + await seance.defaire(); + const troisieme = await seance.produirePlanche(); + assert.deepEqual(troisieme, premier); + assert.deepEqual(fs.octets('documents', troisieme.nom), octetsPremiers); + }); + + test('une réédition de trois badges : un autre nom, « -3 », et la feuille de contrôle dit « Réédition : 3 badges » et nomme le caractère sans glyphe d’« Ondine 李 »', async () => { + const charge = chargeLucioles(); + charge.participants[2].nom = '李'; + const { fs, seance } = await monter(charge); + const rendu = await seance.produirePlanche({ selection: [3, 1, 4] }); + assert.equal(rendu.nom, nomDe({ reedition: { ids: [3, 1, 4] } })); + assert.match(rendu.nom, /\.badges-3_A4_feuille_/); + const pdf = await lirePdf(fs.octets('documents', rendu.nom)); + assert.ok(textesDe(pdf.pages[0]).includes(libelle('planche.controle.reedition', { n: 3 })), textesDe(pdf.pages[0]).join(' | ')); + assert.ok(textesDe(pdf.pages[0]).includes('Réédition : 3 badges')); + const absents = libelle('planche.controle.absents', { points: ['U+674E'] }); + assert.ok(textesDe(pdf.pages[0]).includes(absents), textesDe(pdf.pages[0]).join(' | ')); + assert.equal(pdf.pages.length, 2); + }); + + test('une proposition produite porte le mot du filigrane au trait ; sa feuille de contrôle aussi', async () => { + const { fs, seance } = await monter(); + const rendu = await seance.produirePlanche({ source: 'proposition', proposition: 2 }); + assert.equal(rendu.nom, nomDe({ horodatage: H_PROPOSITION })); + const pdf = await lirePdf(fs.octets('documents', rendu.nom)); + assert.ok(pdf.pages.every((page) => textesDe(page).includes(FILIGRANE))); + assert.equal(auTrait(pdf), true); + }); +}); + +describe('planche dans la séance : l’écriture refusée, enregistrer sous, le web (§ 8.6, décision 13)', () => { + test('un renommage refusé : PLANCHE_NON_ECRITE nomme le fichier, le dossier et la cause, offre « Enregistrer sous… », et le support de l’événement reste intact', async () => { + const { fs, seance, trace } = await monter(); + fs.pannes.renommage('documents'); + const details = { chemin: nomDe(), dossier: DOCUMENTS, cause: 'EBUSY', remede: { geste: 'enregistrerSous' } }; + await refusee(seance.produirePlanche(), 'PLANCHE_NON_ECRITE', details); + assert.equal(seance.supportRompu, false); + assert.equal(fs.octets('documents', nomDe()), null); + assert.deepEqual(trace, [['ecrireOctetsAtomique', nomDe()]]); + assert.match(libelle('PLANCHE_NON_ECRITE', details), /Enregistrer sous/); + }); + + test('le support rompu : préparer passe, produire se refuse PLANCHE_NON_ECRITE sans toucher au refus du support, et enregistrerPlancheSous passe par le dialogue', async () => { + const { fs, seance, trace } = await monter(); + await seance.passerEnEcriture(); + fs.pannes.ecriture('documents'); + await assert.rejects(seance.executer('echangerDansRetenu', { tour: 1, a: 1, b: 3 })); + assert.equal(seance.supportRompu, true); + const refusSupport = seance.refusSupport; + trace.length = 0; + + assert.equal((await seance.preparerPlanche()).refus, null); + await refusee(seance.produirePlanche(), 'PLANCHE_NON_ECRITE', { + chemin: nomDe(), + dossier: DOCUMENTS, + cause: 'EROFS', + remede: { geste: 'enregistrerSous' }, + }); + assert.deepEqual(seance.refusSupport, refusSupport); + + const recus = []; + fs.enregistrerSous = async (nom, octets) => { + recus.push([nom, octets]); + return 'E:\\Copies\\planche.pdf'; + }; + assert.equal(await seance.enregistrerPlancheSous(), 'E:\\Copies\\planche.pdf'); + assert.deepEqual(recus.map(([nom]) => nom), [nomDe()]); + assert.ok(recus[0][1] instanceof Uint8Array); + assert.equal(new TextDecoder().decode(recus[0][1].subarray(0, 5)), '%PDF-'); + assert.deepEqual(trace.map(([primitive]) => primitive), ['ecrireOctetsAtomique']); + }); + + test('sous web, produire passe par enregistrerSous : le téléchargement, chemin null, rien dans le dossier ; en Lettre, en pile, par table, chaque nom à sa place', async () => { + const fs = creerFichiersSimules({ documents: DOCUMENTS, nature: 'web' }); + const { seance, trace } = await monter(chargeLucioles(), { fs, papier: 'Lettre' }); + const recus = []; + fs.enregistrerSous = async (nom, octets) => { + recus.push([nom, octets]); + return nom; + }; + const attendu = nomDe({ papier: 'Lettre', imposition: 'pile' }); + const rendu = await seance.produirePlanche({ imposition: 'pile', ordre: 'table' }); + assert.deepEqual(rendu, { chemin: null, nom: attendu }); + assert.match(attendu, /_Lettre_pile_/); + assert.deepEqual(trace, []); + assert.equal(recus.length, 1); + assert.equal(recus[0][0], attendu); + assert.equal(fs.octets('documents', attendu), null); + // Par table, le tour 1 donne Ombrelle, Pervenche (table 1), puis + // Grisaille, Lacasse (table 2) ; en pile sur deux planches, la planche p + // porte les badges p et p + 2 de cet ordre. + const pdf = await lirePdf(recus[0][1]); + const noms = ['Iris Ombrelle', 'Théo Pervenche', 'Ondine Lacasse', 'Grisaille']; + assert.equal(pdf.pages.length, 3); + assert.deepEqual( + pdf.pages.slice(1).map((page) => nomsDe(page, noms)), + [ + ['Iris Ombrelle', 'Grisaille'], + ['Théo Pervenche', 'Ondine Lacasse'], + ], + ); + }); +}); diff --git a/src/application/seance.js b/src/application/seance.js index 9bae452..c8506fe 100644 --- a/src/application/seance.js +++ b/src/application/seance.js @@ -75,6 +75,11 @@ // Chaque geste lit l'horloge une fois : l'instant de son entrée au journal // date aussi le placement qu'il change (§ 8.9, § 11.8). // +// La planche de badges se prépare, se produit et s'enregistre ailleurs dans +// tout mode (planche_seance.js) : elle lit l'événement ouvert sans le verrou +// d'écriture, à son tour dans la file des appels, et n'écrit que son PDF +// (§ 11.6, § 8.4, § 9). +// // Les appels qui lisent ou écrivent l'événement passent l'un après l'autre, // dans l'ordre de leur appel : chacun part de l'Ouvert que le précédent a // laissé. L'horloge et les minuteries entrent en paramètre : la séance ne lit @@ -99,6 +104,7 @@ import { avecCadrage, avecPapier, cadrageDe, creerEcrivainDiffere, ecrireReglage import { avertissementsApres, examenNomme, invariantViole } from './avertissements.js'; import { appliquerCommande, chargeNeuve, formeNommeeDe } from './commandes.js'; import { libelle } from './libelles.js'; +import { creerPlanches } from './planche_seance.js'; /** Inactivité au terme de laquelle la séance revient seule en lecture (§ 8.4). */ export const INACTIVITE_MS = 10 * 60 * 1000; @@ -193,6 +199,12 @@ function libelleDuRetour(sens, instant) { * stockage n'écrit que FORMAT * @param {number} [parametres.delaiReglagesMs] délai de l'écriture différée * du cadrage + * @param {(() => Promise<{regulier: Uint8Array, gras: Uint8Array}>)|null} [parametres.polices] + * charge les octets de la police que la planche embarque ; null : la + * planche se refuse TypeError + * @param {string|null} [parametres.provenance] le mode de construction, + * que la feuille de contrôle lit pour la version visible ; null : la + * planche se refuse TypeError */ export function creerSeance({ fs, @@ -205,8 +217,11 @@ export function creerSeance({ inactiviteMs = INACTIVITE_MS, formatCourant = FORMAT, delaiReglagesMs = DELAI_REGLAGES_MS, + polices = null, + provenance = null, }) { const id = identifiant(); + const planches = creerPlanches({ polices, provenance, version: produitVersion }); const libelleReprise = libelle('ENTREE_REPRISE'); // L'état de la séance. ouvert : l'Ouvert courant, null sans événement ; @@ -1196,6 +1211,17 @@ export function creerSeance({ } } + // Ce que la planche lit de l'événement ouvert, à son tour dans la file des + // appels, une fois faites les écritures de réglages déjà demandées : le + // papier qu'un choix vient de poser compte. AUCUN_EVENEMENT sans + // événement. + async function lecturePlanche() { + exigerOuvert(); + await fileReglages; + const { charge, base, fs: systeme, racine: dossier } = exigerOuvert(); + return { charge, base, fs: systeme, racine: dossier, papier: etat.papier }; + } + // Le papier de la racine reçue se lit à son tour dans la file des appels, // puis dans celle des réglages, comme à un changement de racine. if (racine !== null) enFile(() => relirePapier(racine)); @@ -1368,6 +1394,22 @@ export function creerSeance({ * lecture, admise dans tout mode, qui n'écrit rien. */ formeNommee: (cible) => enFile(async () => formeNommeeDe(exigerOuvert().charge, cible)), + /** + * L'annonce de la planche de badges (planche_seance.js), avant de + * produire : une lecture, admise dans tout mode, qui n'écrit rien. + */ + preparerPlanche: (demande) => enFile(async () => planches.preparer(await lecturePlanche(), demande)), + /** + * Produit la planche et dépose son PDF à côté des fichiers d'événement, + * ou le télécharge sous web : {chemin, nom}. Admis dans tout mode ; + * l'état et le journal ne s'écrivent pas. + */ + produirePlanche: (demande) => enFile(async () => planches.produire(await lecturePlanche(), demande)), + /** + * Produit la planche et la confie au dialogue d'enregistrement : le + * chemin choisi, null quand l'opérateur annule. + */ + enregistrerPlancheSous: (demande) => enFile(async () => planches.enregistrerSous(await lecturePlanche(), demande)), /** * Une promesse résolue quand les appels déjà en file ont fini, réussis * ou refusés : ce qu'un lecteur de etat() lit ensuite porte leurs diff --git a/src/application/seance.test.js b/src/application/seance.test.js index 80b887a..d021035 100644 --- a/src/application/seance.test.js +++ b/src/application/seance.test.js @@ -12,7 +12,9 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { creerFichiersSimules } from '../../test/fichiers_simules.js'; +import { H_RETENU, personne as personneBadge, soiree, table as tableBadge } from '../../test/fixtures/badges/cas.js'; import { describe, test } from '../../test/lanceur.js'; +import { lirePolices } from '../../test/polices.js'; import { apercevoir } from '../csv/apercu.js'; import { nombrePlacesManquantes, normaliser } from '../moteur/configuration.js'; import { rechercher } from '../moteur/recherche.js'; @@ -21,7 +23,7 @@ import { creer as creerDansLeDepot, ouvrir as ouvrirDansLeDepot } from '../stock import { FORMAT, analyser, configurationDepuisCharge, creerCharge, etatDeduit } from '../stockage/document.js'; import { ErreurStockage } from '../stockage/erreurs.js'; import { ligneEntree, ligneOuverture, lireJournal, reconstruire } from '../stockage/journal.js'; -import { SUFFIXES } from '../stockage/noms.js'; +import { SUFFIXES, nomPlanche } from '../stockage/noms.js'; import { FICHIER_REGLAGES } from '../stockage/reglages_locaux.js'; import { VERSION } from '../version.genere.js'; import { libelle } from './libelles.js'; @@ -3133,3 +3135,79 @@ describe('séance : le papier de la machine (§ 8.5, § 11.2, Q1)', () => { assert.equal(seance.etat().papier, 'Lettre'); }); }); + +describe('séance : la planche, câblée dans la file des appels (§ 11.6, § 13.4)', () => { + // Une soirée retenue de quatre personnes sur deux tables de deux places, + // deux tours, le retenu daté de H_RETENU. + const PLAN = [{ sieges: [[1, 2], [3, 4]] }, { sieges: [[1, 3], [2, 4]] }]; + const chargeRetenue = () => + soiree({ + personnes: [ + personneBadge(1, 'Iris', 'Ombrelle'), + personneBadge(2, 'Théo', 'Pervenche'), + personneBadge(3, 'Ondine', 'Lacasse'), + personneBadge(4, null, 'Grisaille'), + ], + tables: [tableBadge(1, 1, 2), tableBadge(2, 2, 2)], + plan: PLAN, + propositions: [[1, PLAN]], + }); + + test('preparerPlanche lancé derrière un geste lit la charge que le geste laisse ; la police se charge une fois par séance', async () => { + const fs = creerFichiersSimules(); + await deposerEssai(fs, chargeRetenue()); + let chargements = 0; + const polices = async () => { + chargements += 1; + return lirePolices(); + }; + const { seance } = monter({ fs, polices, provenance: 'production' }); + await seance.choisirPapier('A4'); + await seance.ouvrir(BASE); + await seance.passerEnEcriture(); + const geste = seance.executer('echangerDansRetenu', { tour: 1, a: 1, b: 3 }); + const annonce = seance.preparerPlanche(); + await geste; + const horodatage = seance.charge.retenu.horodatage; + assert.notEqual(horodatage, H_RETENU); + const attendu = nomPlanche(BASE, { papier: 'A4', imposition: 'feuille', horodatage, reedition: null }); + assert.equal((await annonce).nom, attendu); + assert.equal((await seance.preparerPlanche()).nom, attendu); + assert.equal(chargements, 1); + }); + + test('un papier choisi sans être attendu compte pour la planche demandée juste après : la planche attend les écritures de réglages déjà demandées', async () => { + const fs = creerFichiersSimules(); + await deposerEssai(fs, chargeRetenue()); + const { seance } = monter({ fs, polices: async () => lirePolices(), provenance: 'production' }); + await seance.ouvrir(BASE); + const choix = seance.choisirPapier('Lettre'); + const annonce = await seance.preparerPlanche(); + await choix; + assert.deepEqual([annonce.refus, annonce.papier], [null, 'Lettre']); + }); + + test('sans police ni provenance, préparer lève TypeError ; un chargement refusé remonte, et le suivant réessaie', async () => { + const fs = creerFichiersSimules(); + await deposerEssai(fs, chargeRetenue()); + const sansPolices = monter({ fs, provenance: 'production' }).seance; + await sansPolices.ouvrir(BASE); + await assert.rejects(sansPolices.preparerPlanche(), (erreur) => erreur instanceof TypeError && /polices/.test(erreur.message)); + const sansProvenance = monter({ fs, prefixe: 'sans-provenance', polices: async () => lirePolices() }).seance; + await sansProvenance.ouvrir(BASE); + await assert.rejects(sansProvenance.preparerPlanche(), TypeError); + + let essais = 0; + const polices = async () => { + essais += 1; + if (essais === 1) throw new Error('police illisible'); + return lirePolices(); + }; + const { seance } = monter({ fs, prefixe: 'reessai', polices, provenance: 'production' }); + await seance.ouvrir(BASE); + await assert.rejects(seance.preparerPlanche(), /police illisible/); + const annonce = await seance.preparerPlanche(); + assert.equal(annonce.refus.code, 'PAPIER_REQUIS'); + assert.equal(essais, 2); + }); +}); diff --git a/src/interface/App.svelte b/src/interface/App.svelte index 44a8ccc..2a89737 100644 --- a/src/interface/App.svelte +++ b/src/interface/App.svelte @@ -103,6 +103,10 @@ planifier: horloge.planifier, annuler: horloge.annuler, formatCourant, + // La planche de badges embarque la police, et sa feuille de contrôle lit + // la provenance pour la version visible (§ 11.4, § 19.4). + polices, + provenance, }); const generationApp = creerControleurGeneration({ seance: seanceApp, executeur, horloge, produitVersion }); const facadeApp = creerFacade({ diff --git a/src/interface/libelles/communs.js b/src/interface/libelles/communs.js index cbd902a..bb821fc 100644 --- a/src/interface/libelles/communs.js +++ b/src/interface/libelles/communs.js @@ -37,6 +37,8 @@ export const TABLE = new Map([ ['communs.remede.reprendreVerrou', 'Reprendre le verrou'], ['communs.remede.afficherTour', ({ tour }) => `Afficher le tour ${tour}`], ['communs.remede.choisirPlace', 'Choisir une place'], + ['communs.remede.enregistrerSous', 'Enregistrer sous\u{2026}'], + ['communs.remede.choisirPapier', 'Choisir le papier'], ['communs.remede.ouvrirSecours.precedent', meme('communs.commande.ouvrirPrecedent')], ['communs.remede.ouvrirSecours.journal', meme('communs.commande.ouvrirJournal')], ['communs.remede.ecrireAilleurs.documents', meme('communs.commande.ecrireDocuments')], diff --git a/src/interface/primitives/commande.js b/src/interface/primitives/commande.js index 0eec7ba..0197ccc 100644 --- a/src/interface/primitives/commande.js +++ b/src/interface/primitives/commande.js @@ -30,7 +30,9 @@ // d'ecrireAilleurs — documents quand elle est donnée, choisi quand // l'opérateur la désigne —, la section d'ouvrirSection. Son action passe par // la séance, par l'état de navigation pour ce qui ne fait que montrer, ou -// par un geste que l'écran fournit : choisirPlace se choisit sur le plan. +// par un geste que l'écran fournit : choisirPlace se choisit sur le plan, +// enregistrerSous reprend la planche que l'écran a demandée, choisirPapier +// pose la question du papier. import { COMMANDES } from '../../application/commandes.js'; @@ -49,6 +51,8 @@ export const GESTES = Object.freeze([ 'choisirPlace', 'ouvrirSecours', 'ecrireAilleurs', + 'enregistrerSous', + 'choisirPapier', ]); /** Les sources d'un secours (seance.ouvrirSecours). */ @@ -171,10 +175,11 @@ function executionDuGeste(remede, seance, ui) { /** * L'action qu'offre un remède : de quoi poser une Commande à côté du refus. * Un geste que gestes fournit passe par lui, avant tout autre chemin ; - * choisirPlace n'a pas d'autre chemin. Un remède de commande s'exécute par - * seance.executer, tel quel ; modifier, debloquer, ouvrirSecours et - * ecrireAilleurs par la méthode de la séance de même nom — passerEnEcriture - * pour modifier, la base de l'événement ouvert pour ouvrirSecours ; + * choisirPlace, enregistrerSous et choisirPapier n'en ont pas d'autre. Un + * remède de commande s'exécute par seance.executer, tel quel ; modifier, + * debloquer, ouvrirSecours et ecrireAilleurs par la méthode de la séance de + * même nom — passerEnEcriture pour modifier, la base de l'événement ouvert + * pour ouvrirSecours ; * afficherTour et ouvrirSection par l'état de navigation, ui. * * @param {Object|null} remede diff --git a/src/interface/primitives/commande.test.js b/src/interface/primitives/commande.test.js index 73dd733..95e2d6f 100644 --- a/src/interface/primitives/commande.test.js +++ b/src/interface/primitives/commande.test.js @@ -186,6 +186,7 @@ describe('commande : les clés des remèdes, par énumération (§ 14.6)', () => test('chaque geste du contrat, chaque variante comprise, a son libellé', () => { assert.deepEqual([...GESTES], [ 'modifier', 'debloquer', 'afficherTour', 'ouvrirSection', 'choisirPlace', 'ouvrirSecours', 'ecrireAilleurs', + 'enregistrerSous', 'choisirPapier', ]); assert.deepEqual([...SOURCES_SECOURS], ['precedent', 'journal']); assert.deepEqual([...SECTIONS_REMEDE], ['import', 'participants', 'tables', 'plan', 'diagnostic', 'generation', 'propositions']); @@ -198,8 +199,10 @@ describe('commande : les clés des remèdes, par énumération (§ 14.6)', () => ...SOURCES_SECOURS.map((source) => ({ geste: 'ouvrirSecours', source })), { geste: 'ecrireAilleurs', racine: RACINE }, { geste: 'ecrireAilleurs', racine: null }, + { geste: 'enregistrerSous' }, + { geste: 'choisirPapier' }, ]; - assert.equal(remedes.length, 15); + assert.equal(remedes.length, 17); const textes = remedes.map((remede) => { const { cle, details } = libelleRemede(remede); return t(cle, details); @@ -209,6 +212,13 @@ describe('commande : les clés des remèdes, par énumération (§ 14.6)', () => assert.equal(new Set(textes).size, textes.length, 'deux variantes ne se confondent pas'); }); + test('les remèdes de la planche : enregistrerSous et choisirPapier ont leur clé, sans variante', () => { + assert.deepEqual(libelleRemede({ geste: 'enregistrerSous' }), { cle: 'communs.remede.enregistrerSous', details: {} }); + assert.deepEqual(libelleRemede({ geste: 'choisirPapier' }), { cle: 'communs.remede.choisirPapier', details: {} }); + assert.equal(t('communs.remede.enregistrerSous'), 'Enregistrer sous\u{2026}'); + assert.equal(t('communs.remede.choisirPapier'), 'Choisir le papier'); + }); + test('reprendreVerrou, offert à côté de VERROU_PRIS, a sa clé', () => { assert.equal(t('communs.remede.reprendreVerrou'), 'Reprendre le verrou'); }); @@ -225,6 +235,8 @@ describe('commande : les clés des remèdes, par énumération (§ 14.6)', () => ...SOURCES_SECOURS.map((source) => libelleRemede({ geste: 'ouvrirSecours', source }).cle), libelleRemede({ geste: 'ecrireAilleurs', racine: RACINE }).cle, libelleRemede({ geste: 'ecrireAilleurs', racine: null }).cle, + libelleRemede({ geste: 'enregistrerSous' }).cle, + libelleRemede({ geste: 'choisirPapier' }).cle, ]); const posees = [...COMMUNS.keys()].filter((cle) => cle.startsWith('communs.remede.')); assert.ok(posees.length > 0); @@ -312,6 +324,19 @@ describe('commande : l’action d’un remède', () => { assert.deepEqual(seance.appels, []); }); + test('enregistrerSous et choisirPapier passent par les gestes de l’écran, qui sait la planche et le papier ; sans eux, aucune action', () => { + const seance = seanceEnregistreuse(); + const recus = []; + for (const geste of ['enregistrerSous', 'choisirPapier']) { + assert.equal(actionRemede({ geste }, { seance, ui: uiEnregistreur() }), null, geste); + const action = actionRemede({ geste }, { seance, gestes: { [geste]: (remede) => recus.push(remede) } }); + assert.deepEqual([action.nom, action.cle, action.confirmer], [geste, `communs.remede.${geste}`, null]); + action.executer(); + } + assert.deepEqual(recus, [{ geste: 'enregistrerSous' }, { geste: 'choisirPapier' }]); + assert.deepEqual(seance.appels, []); + }); + test('choisirPlace, et tout geste que l’écran fournit, passent par gestes ; sans lui, choisirPlace n’offre aucune action', () => { const seance = seanceEnregistreuse(); const recus = [];