gestion_table_tournante_libre/src/application/planche_seance.test.js

462 lines
21 KiB
JavaScript
Raw Normal View History

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