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 = [];