gestion_table_tournante_libre/src/application/planche_seance.test.js
Mathieu Benoit 416d91a88f [ADD] badges: prepare, produce and save the sheet from the session
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
2026-10-09 17:11:48 -04:00

461 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// © 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'],
],
);
});
});