diff --git a/src/application/libelles.js b/src/application/libelles.js index d0139dd..4adf724 100644 --- a/src/application/libelles.js +++ b/src/application/libelles.js @@ -26,6 +26,7 @@ import { TABLE as MOBILIER } from './libelles/mobilier.js'; import { TABLE as MOTEUR } from './libelles/moteur.js'; import { TABLE as PARTICIPANTS } from './libelles/participants.js'; import { TABLE as PLACES } from './libelles/places.js'; +import { TABLE as PLANCHE } from './libelles/planche.js'; import { TABLE as SEANCE } from './libelles/seance.js'; import { TABLE as SORTIES } from './libelles/sorties.js'; import { TABLE as STOCKAGE } from './libelles/stockage.js'; @@ -41,6 +42,7 @@ export const TABLES_APPLICATION = Object.freeze([ MOTEUR, PARTICIPANTS, PLACES, + PLANCHE, SEANCE, SORTIES, STOCKAGE, diff --git a/src/application/libelles.test.js b/src/application/libelles.test.js index 92ed150..2df7a4d 100644 --- a/src/application/libelles.test.js +++ b/src/application/libelles.test.js @@ -540,6 +540,17 @@ const EXEMPLES = new Map([ ['ENTREE_CHANGER_TOURS', { tours: 3, retirees: 2 }], ['ENTREE_MODIFIER_REGLAGE', { cle: 'separerAppartenances', valeur: false }], ['ENTREE_CHANGER_CIBLE_REDONDANCE', { cible: 1200 }], + // Planche de badges + ['PLANCHE_SANS_PLACEMENT', { remede: { geste: 'ouvrirSection', section: 'propositions' } }], + [ + 'PLANCHE_DERIVE', + { + raisons: [{ code: 'PARTICIPANT_NON_PLACE', participant: 6, personne: 'Ondine Grisaille' }], + remede: { geste: 'ouvrirSection', section: 'propositions' }, + }, + ], + ['PLANCHE_TROP_DE_TOURS', { tours: 40, remede: null }], + ['PLANCHE_NOM_TROP_LONG', { participant: 3, personne: 'Ondine Lacasse', remede: { geste: 'ouvrirSection', section: 'participants' } }], ]); // Un texte composé ne laisse paraître ni valeur absente, ni booléen, ni @@ -819,11 +830,11 @@ async function tablesDuDossier() { } describe('libellés : les tables par domaine (§ 14.6)', () => { - test('chaque module de libelles/, aides.js excepté, exporte TABLE, une Map ; TABLES_APPLICATION les assemble, onze, par ordre alphabétique', async () => { + test('chaque module de libelles/, aides.js excepté, exporte TABLE, une Map ; TABLES_APPLICATION les assemble, douze, par ordre alphabétique', async () => { const tables = await tablesDuDossier(); - assert.equal(tables.length, 11); + assert.equal(tables.length, 12); assert.deepEqual(tables.map(({ table }) => table), [ - 'csv', 'evenements', 'format', 'generation', 'mobilier', 'moteur', 'participants', 'places', 'seance', 'sorties', 'stockage', + 'csv', 'evenements', 'format', 'generation', 'mobilier', 'moteur', 'participants', 'places', 'planche', 'seance', 'sorties', 'stockage', ]); for (const { table, module } of tables) assert.ok(module.TABLE instanceof Map, table); assert.equal(TABLES_APPLICATION.length, tables.length); diff --git a/src/application/libelles/planche.js b/src/application/libelles/planche.js new file mode 100644 index 0000000..b5f73a4 --- /dev/null +++ b/src/application/libelles/planche.js @@ -0,0 +1,128 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Les textes de la planche de badges (§ 11.3 à § 11.8, contrainte 25) : les +// refus qui la gardent avant de produire, ce qu'un badge imprime — ses lignes +// de tour, son pied — et les lignes de la feuille de contrôle. Le papier les +// lit par libelle, jamais décorés par la langue témoin : un texte imprimé est +// une donnée. Un horodatage et une date se mettent en mots à partir de leur +// 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'; + +// Les impositions de la planche (§ 11.6), comme la feuille de contrôle les +// nomme. +const NOMS_DES_IMPOSITIONS = new Map([ + ['feuille', 'feuille par feuille'], + ['pile', 'en pile'], +]); + +// Une cote en millimètres, la virgule pour décimale. +const cote = (mm) => String(mm).replace('.', ','); + +// Deux chiffres, zéro de tête compris. +const deuxChiffres = (n) => String(n).padStart(2, '0'); + +// Une date AAAA-MM-JJ en lettres, par le texte de la mise en forme. +function dateEnLettres(date, texte) { + const [annee, mois, jour] = date.split('-').map(Number); + return texte('format.date', { annee, mois, jour }); +} + +// La personne d'un refus : son nom affiché quand les détails le portent, son +// identifiant sinon. +const laPersonne = ({ participant, personne }) => personne ?? `la personne n° ${participant}`; + +// Où la personne s'assied à une ligne : la table, puis le siège sous sièges +// attribués, puis le titre dont une réservation la fait titulaire ; « pas de +// table » quand elle est en réserve. +function place({ table, siege, titre }, texte) { + if (table === null) return texte('planche.pasDeTable'); + const auSiege = siege === null ? '' : texte('planche.siege', { siege }); + const avecTitre = titre === null ? '' : texte('planche.titre', { titre }); + return `table ${table}${auSiege}${avecTitre}`; +} + +export const TABLE = new Map([ + // --- Les refus de la planche (§ 11.5, § 11.8, Q3) -------------------------- + [ + 'PLANCHE_SANS_PLACEMENT', + () => "Aucun placement à imprimer : retenez une proposition, ou choisissez-en une parmi les propositions.", + ], + [ + 'PLANCHE_DERIVE', + ({ raisons }, texte) => + 'Le placement ne décrit plus la soirée, la planche ne se produit pas : ' + + raisons.map((raison) => phraseDeRaison(raison.code, raison, texte)).join(' '), + ], + [ + 'PLANCHE_TROP_DE_TOURS', + ({ tours }) => + `Les ${tours} tours ne tiennent pas sur un badge, même sur deux colonnes au plus petit corps : ` + + "la planche n'est pas produite, plutôt qu'une liste de tours tronquée.", + ], + [ + 'PLANCHE_NOM_TROP_LONG', + (details) => + `Le nom de ${laPersonne(details)} ne tient pas sur son badge, même replié sur trois lignes au plus petit corps : ` + + 'la planche ne se produit pas, plutôt que de tronquer ses tours.', + ], + + // --- 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. + ['planche.tour', (ligne, texte) => `Tour ${ligne.tour} — ${place(ligne, texte)}`], + ['planche.touteLaSoiree', (ligne, texte) => `Toute la soirée — ${place(ligne, texte)}`], + ['planche.pasDeTable', 'pas de table'], + ['planche.siege', ({ siege }) => `, siège ${siege}`], + ['planche.titre', ({ titre }) => ` — ${titre}`], + + // --- Le pied d'un badge, et la marque du brouillon (§ 11.4, § 11.7) ------- + // L'événement et sa date ; la date seule, quand le nom de l'événement + // quitte un pied trop chargé (décision 8). + [ + 'planche.pied.evenement', + ({ nom, date }, texte) => (date === null ? nom : `${nom}, ${dateEnLettres(date, texte)}`), + ], + ['planche.pied.date', ({ date }, texte) => dateEnLettres(date, texte)], + // L'horodatage du placement, à la seconde et à l'heure qu'il porte : deux + // retouches dans la même minute donnent deux pieds différents. + [ + 'planche.pied.horodatage', + ({ horodatage }, texte) => { + const { annee, mois, jour, heure, minute, seconde } = lireHorodatage(horodatage); + const temps = [heure, minute, seconde].map(deuxChiffres).join(':'); + return `Placement du ${texte('format.date', { annee, mois, jour })} à ${temps}`; + }, + ], + ['planche.pied.horodatageInconnu', 'Horodatage du placement inconnu'], + ['planche.pied.identifiant', ({ identifiant }) => `Identifiant n° ${identifiant}`], + ['planche.brouillon', 'BROUILLON'], + + // --- La feuille de contrôle (§ 11.3, § 11.5, § 11.8) ---------------------- + ['planche.controle.badge', ({ largeur, hauteur }) => `Badge : ${cote(largeur)} × ${cote(hauteur)} mm (A6)`], + [ + 'planche.controle.papier', + ({ papier, largeur, hauteur }) => `Papier : ${papier}, ${cote(largeur)} × ${cote(hauteur)} mm`, + ], + [ + 'planche.controle.imposition', + ({ imposition }) => `Imposition : ${nomDans(NOMS_DES_IMPOSITIONS, imposition, 'imposition')}`, + ], + ['planche.controle.planches', ({ planches }) => `Planches : ${planches}`], + [ + 'planche.controle.comptes', + ({ produits, participants, exclus }) => + `${compte(produits, 'badge', 'badges')} pour ${compte(participants, 'participant', 'participants')}` + + (exclus === 0 ? '' : `, ${compte(exclus, 'exclu', 'exclus')}`), + ], + ['planche.controle.evenement', (evenement, texte) => `Événement : ${texte('planche.pied.evenement', evenement)}`], + ['planche.controle.version', ({ version }) => `Version : ${version}`], + ['planche.controle.consigne', 'Imprimer en recto seul, à 100 %'], + ['planche.controle.reedition', ({ n }) => `Réédition : ${compte(n, 'badge', 'badges')}`], + [ + 'planche.controle.absents', + ({ points }) => `Caractères absents de la police, imprimés sans glyphe : ${enumeration(points)}`, + ], +]); diff --git a/src/application/planche.js b/src/application/planche.js new file mode 100644 index 0000000..d2e0b5c --- /dev/null +++ b/src/application/planche.js @@ -0,0 +1,441 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Le modèle de la planche de badges (§ 11.3 à § 11.8) : ce que chaque badge +// imprime, dans l'ordre de la pile, et les lignes de la feuille de contrôle. +// Le modèle se lit de la charge et rien d'autre — ni horloge, ni disque, ni +// tirage, ni langue du système — : deux appels sur la même charge rendent +// des modèles égaux, et un badge réédité égale celui de la première planche +// tant que le placement n'a pas bougé. Chaque texte se compose par libelle, +// en français, jamais décoré par la langue témoin (contrainte 25) ; la mise +// en page et le PDF (src/pdf) ne composent aucun mot. +// +// La planche imprime une source : le placement retenu, ou une proposition +// désignée par son identifiant (Q3), qui sort toujours en BROUILLON. Elle se +// refuse sans placement, sur un placement qui se contredit ou qui ne décrit +// plus la soirée, avant de produire (§ 11.8). +import { pourvoyeursDesTitres } from '../csv/import.js'; +import { cleNormalisee } from '../csv/normalisation.js'; +import { BADGE, PAPIERS } from '../geometrie/papiers.js'; +import { derive, fautes } from '../stockage/placements.js'; +import { raisonsNommees } from './avertissements.js'; +import { formeNommeeDe } from './commandes.js'; +import { ErreurCommande, refus } from './erreurs.js'; +import { libelle } from './libelles.js'; +import { nomAffiche } from './personnes.js'; +import { estBrouillon, horodatagePlacement } from './sorties.js'; +import { versionVisible } from './version.js'; + +/** + * @typedef {'feuille'|'pile'} Imposition + * @typedef {'alphabetique'|'table'} Ordre + * @typedef {Object} LigneTour + * @property {number|null} tour null : « Toute la soirée », un itinéraire + * constant sur R ≥ 2 tours + * @property {number|null} table le numéro affiché ; null : « pas de table » + * @property {number|null} siege sous sièges attribués seulement + * @property {string|null} titre le titre dont une réservation fait la + * personne titulaire à ce tour + * @typedef {Object} Badge + * @property {number} participant + * @property {string} nom le nom affiché, en NFC + * @property {string|null} appartenance en NFC ; null : la ligne n'existe pas + * @property {LigneTour[]} lignes + * @property {number|null} identifiant celui d'un homonyme que rien ne sépare + * @property {{nom: string, lignes: string[], appartenance: string|null, + * pied: string[], piedReduit: string[]}} textes + * @typedef {Object} ModelePlanche + * @property {Badge[]} badges dans l'ordre de la pile + * @property {{nom: string, date: string|null}} evenement + * @property {string|null} horodatage + * @property {boolean} brouillon + * @property {{participants: number, exclus: number, produits: number}} comptes + * @property {Array<{nom: string, ids: number[]}>} homonymes + * @property {null|{ids: number[]}} reedition + * @property {string} version + */ + +/** Les sources d'une planche (Q3). */ +export const SOURCES_PLANCHE = Object.freeze(['retenu', 'proposition']); +/** Les ordres de la pile (§ 11.6, décision 9). */ +export const ORDRES = Object.freeze(['alphabetique', 'table']); +/** Les impositions de la planche (§ 11.6). */ +export const IMPOSITIONS = Object.freeze(['feuille', 'pile']); + +// Les refus de la mise en page, que la production relaie (refusProduction). +const TROP_DE_TOURS = 'PLANCHE_TROP_DE_TOURS'; +const NOM_TROP_LONG = 'PLANCHE_NOM_TROP_LONG'; + +// Le remède des refus qui renvoient aux propositions : en choisir une, ou en +// produire de nouvelles. Un objet neuf à chaque refus. +const versPropositions = () => ({ geste: 'ouvrirSection', section: 'propositions' }); + +/** + * Compare deux chaînes point de code par point de code : -1, 0 ou 1. « < » + * compare des unités UTF-16, et range une lettre du plan astral — codée + * par une paire de substitution, de U+D800 à U+DFFF — avant un caractère de + * U+E000 à U+FFFF : l'ordre de la pile en dépendrait. Une chaîne qui en + * prolonge une autre vient après elle. + * + * @param {string} a + * @param {string} b + * @returns {-1|0|1} + * @throws {TypeError} un argument qui n'est pas une chaîne + */ +export function comparerPointsDeCode(a, b) { + if (typeof a !== 'string' || typeof b !== 'string') { + throw new TypeError(`comparerPointsDeCode : deux chaînes attendues, reçu ${typeof a} et ${typeof b}`); + } + // codePointAt lit, à l'indice d'une unité haute, le point de code entier : + // deux chaînes qui diffèrent par une paire se départagent là. À l'indice + // d'une unité basse, il ne se lit qu'après une unité haute égale, et deux + // unités basses se comparent alors comme leurs points de code. + for (let i = 0; i < a.length && i < b.length; i += 1) { + const [pa, pb] = [a.codePointAt(i), b.codePointAt(i)]; + if (pa !== pb) return pa < pb ? -1 : 1; + } + if (a.length === b.length) return 0; + return a.length < b.length ? -1 : 1; +} + +// La clé d'appartenance d'une personne : la clé normalisée, vide sans +// appartenance ou pour une appartenance faite de blancs. +const cleAppartenance = ({ appartenance }) => cleNormalisee(appartenance ?? ''); + +/** + * Les homonymes que rien ne sépare sur un badge (§ 11.4, décision 7) : les + * personnes non exclues de même nom affiché normalisé et de même + * appartenance normalisée, ou toutes deux sans appartenance. Un groupe par + * collision : nom, le nom affiché en NFC de sa personne d'identifiant le + * plus petit ; ids, croissants. Les groupes se rangent par clé de nom, puis + * d'appartenance, par points de code. Le calcul porte sur toute la soirée, + * jamais sur une sélection : un badge réédité garde son identifiant. + * + * @param {import('../stockage/types.js').Charge} charge + * @returns {Array<{nom: string, ids: number[]}>} + */ +export function homonymesDe(charge) { + const groupes = new Map(); + const triees = charge.participants.filter(({ exclu }) => !exclu).sort((a, b) => a.id - b.id); + for (const personne of triees) { + const cles = [cleNormalisee(nomAffiche(personne)), cleAppartenance(personne)]; + const cle = JSON.stringify(cles); + if (!groupes.has(cle)) groupes.set(cle, { cles, nom: nomAffiche(personne).normalize('NFC'), ids: [] }); + groupes.get(cle).ids.push(personne.id); + } + return [...groupes.values()] + .filter(({ ids }) => ids.length > 1) + .sort((a, b) => comparerPointsDeCode(a.cles[0], b.cles[0]) || comparerPointsDeCode(a.cles[1], b.cles[1])) + .map(({ nom, ids }) => ({ nom, ids })); +} + +// La source contrôlée et le placement qu'elle désigne : le retenu, null sans +// lui ; la proposition d'identifiant proposition, null quand aucune n'est +// désignée. +function placementDeSource(charge, source, proposition) { + if (!SOURCES_PLANCHE.includes(source)) { + throw new TypeError(`source : ${SOURCES_PLANCHE.join(' ou ')} attendue, reçu ${JSON.stringify(source)}`); + } + if (source === 'retenu') return charge.retenu; + if (proposition === null) return null; + if (!Number.isSafeInteger(proposition)) { + throw new TypeError(`proposition : identifiant entier ou null attendu, reçu ${JSON.stringify(proposition)}`); + } + const designee = charge.propositions.find(({ id }) => id === proposition); + if (designee === undefined) throw refus('PROPOSITION_INCONNUE', { id: proposition }); + return designee; +} + +/** + * Le refus qui garde la planche avant de produire (§ 11.8, Q3), le premier + * dans cet ordre : PLANCHE_SANS_PLACEMENT {} — la source retenu sans retenu, + * ou la source proposition sans proposition désignée ; RETENU_FAUTIF {}, + * relayé tel que les places le lèvent, quand le placement de la source se + * contredit ; PLANCHE_DERIVE {raisons}, les raisons de dérive de la source + * nommées (raisonsNommees) quand elle ne décrit plus la soirée. null quand + * la planche se produit. Les détails portent le remède. + * + * @param {import('../stockage/types.js').Charge} charge + * @param {{source?: 'retenu'|'proposition', proposition?: number|null}} [options] + * @returns {null|{code: string, details: Object}} + * @throws {TypeError} une source inconnue, un identifiant qui n'est ni un + * entier ni null + * @throws {ErreurCommande} PROPOSITION_INCONNUE {id} + */ +export function refusPlanche(charge, { source = 'retenu', proposition = null } = {}) { + const placement = placementDeSource(charge, source, proposition); + if (placement === null) return { code: 'PLANCHE_SANS_PLACEMENT', details: { remede: versPropositions() } }; + if (fautes(placement).length > 0) { + return { code: 'RETENU_FAUTIF', details: { remede: { geste: 'ouvrirSecours', source: 'precedent' } } }; + } + const raisons = derive(placement, charge); + if (raisons.length > 0) { + return { code: 'PLANCHE_DERIVE', details: { raisons: raisonsNommees(raisons, charge), remede: versPropositions() } }; + } + return null; +} + +/** + * Un refus de la mise en page (§ 11.5), que la production relaie avec le + * remède du contrat : PLANCHE_TROP_DE_TOURS {tours}, sans remède ; + * PLANCHE_NOM_TROP_LONG {participant}, vers la liste des participants, où + * le nom se corrige. Les détails reçus restent, personne comprise quand + * l'appelant la nomme. + * + * @param {string} code + * @param {Object} details + * @returns {{code: string, details: Object}} + * @throws {TypeError} un autre code + */ +export function refusProduction(code, details) { + if (code === TROP_DE_TOURS) return { code, details: { ...details, remede: null } }; + if (code === NOM_TROP_LONG) { + return { code, details: { ...details, remede: { geste: 'ouvrirSection', section: 'participants' } } }; + } + throw new TypeError(`refusProduction : ${TROP_DE_TOURS} ou ${NOM_TROP_LONG} attendu, reçu ${JSON.stringify(code)}`); +} + +// Où chaque personne s'assied à chaque tour du placement, lu de sa forme +// nommée : Map participant → tableau par tour de { table (identifiant), +// numero, siege } ; table et numero null dans la réserve. +function placesParTour(charge, cible, tours) { + const places = new Map(); + for (const { tour, table, numero, siege, participant } of formeNommeeDe(charge, cible).lignes) { + if (!places.has(participant)) places.set(participant, Array.from({ length: tours }, () => null)); + places.get(participant)[tour - 1] = { table, numero, siege }; + } + return places; +} + +// Les titres imprimés (§ 4.1, § 11.4, décision 11) : Map « participant/tour » +// → libellés, pour chaque titre dont une réservation fait la personne +// titulaire à ce tour (pourvoyeursDesTitres) ET que le placement assied à la +// table du titre — et à son siège quand il attribue les sièges. Un titre +// posé sur une chaise que le placement fait seulement occuper ne s'imprime +// pas : ce serait désigner le titulaire par tirage. +function titresImprimes(charge, places, siegesAttribues) { + const titres = new Map(); + pourvoyeursDesTitres(charge).forEach(({ tours }, rang) => { + const titre = charge.titres[rang]; + tours.forEach((reservation, indice) => { + if (reservation === null) return; + // La réservation d'une personne non exclue, que le placement cohérent + // et sans dérive assied à chaque tour. + const place = places.get(reservation.participant)[indice]; + if (place.table !== titre.table) return; + if (siegesAttribues && place.siege !== titre.siege) return; + const cle = `${reservation.participant}/${indice + 1}`; + titres.set(cle, [...(titres.get(cle) ?? []), titre.libelle.normalize('NFC')]); + }); + }); + return titres; +} + +// Les lignes de tour d'une personne : une par tour, ou une seule « Toute la +// soirée » quand R ≥ 2 et que table, siège et titre ne changent pas. +function lignesDe(id, places, titres) { + const lignes = places.get(id).map((place, indice) => ({ + tour: indice + 1, + table: place.numero, + siege: place.siege, + titre: titres.get(`${id}/${indice + 1}`)?.join(', ') ?? null, + })); + const [premiere] = lignes; + const constante = lignes.every( + ({ table, siege, titre }) => table === premiere.table && siege === premiere.siege && titre === premiere.titre, + ); + if (lignes.length >= 2 && constante) return [{ ...premiere, tour: null }]; + return lignes; +} + +// Les textes d'un badge (§ 11.4, décision 8), composés par libelle : le pied +// porte l'événement et sa date, l'horodatage, puis l'identifiant d'un +// homonyme ; piedReduit, le même sans le nom de l'événement, date gardée. +// Aucun ne porte la version, ni le tour. +function textesDe({ nom, appartenance, lignes, identifiant }, evenement, horodatage) { + const ligneHorodatage = + horodatage === null ? libelle('planche.pied.horodatageInconnu') : libelle('planche.pied.horodatage', { horodatage }); + const ligneIdentifiant = identifiant === null ? [] : [libelle('planche.pied.identifiant', { identifiant })]; + const ligneDate = evenement.date === null ? [] : [libelle('planche.pied.date', { date: evenement.date })]; + return { + nom, + lignes: lignes.map((ligne) => libelle(ligne.tour === null ? 'planche.touteLaSoiree' : 'planche.tour', ligne)), + appartenance, + pied: [libelle('planche.pied.evenement', evenement), ligneHorodatage, ...ligneIdentifiant], + piedReduit: [...ligneDate, ligneHorodatage, ...ligneIdentifiant], + }; +} + +// L'ordre alphabétique de la pile (§ 11.6, § 15.5) : la clé normalisée du +// nom, puis celle du prénom, par points de code, puis l'identifiant. +const ordreAlphabetique = (a, b) => + comparerPointsDeCode(cleNormalisee(a.personne.nom), cleNormalisee(b.personne.nom)) || + comparerPointsDeCode(cleNormalisee(a.personne.prenom ?? ''), cleNormalisee(b.personne.prenom ?? '')) || + a.personne.id - b.personne.id; + +// L'ordre par table (décision 9) : le numéro de la table du tour 1, la +// réserve de ce tour en dernier, l'identifiant de table pour deux numéros +// égaux, puis l'ordre alphabétique. +function ordreParTable(a, b) { + const [pa, pb] = [a.places[0], b.places[0]]; + const enReserve = (pa.table === null) - (pb.table === null); + if (enReserve !== 0) return enReserve; + if (pa.table !== null && pa.table !== pb.table) return pa.numero - pb.numero || pa.table - pb.table; + return ordreAlphabetique(a, b); +} + +// La sélection d'une réédition, contrôlée : null, ou des identifiants +// entiers, au moins un, sans doublon, chacun d'une personne non exclue. +function selectionLue(charge, selection) { + if (selection === null) return null; + const forme = + Array.isArray(selection) && + selection.length > 0 && + selection.every(Number.isSafeInteger) && + new Set(selection).size === selection.length; + if (!forme) throw new TypeError(`selection : null ou identifiants entiers distincts attendus, reçu ${JSON.stringify(selection)}`); + for (const id of selection) { + const personne = charge.participants.find((candidate) => candidate.id === id); + if (personne === undefined) throw refus('PARTICIPANT_INCONNU', { id }); + if (personne.exclu) { + throw refus('PERSONNE_EXCLUE', { participant: id }, { commande: 'reintegrerParticipant', arguments: { id } }); + } + } + return new Set(selection); +} + +/** + * Le modèle de la planche (§ 11.4 à § 11.8) : un badge par personne non + * exclue — ou par personne de la sélection, pour une réédition —, dans + * l'ordre demandé, ses lignes de tour lues du placement de la source, ses + * textes composés. horodatage : horodatagePlacement de la source ; + * brouillon : estBrouillon sur toute la soirée, toujours vrai pour une + * proposition ; version : versionVisible, que la feuille de contrôle + * imprime et le badge jamais. Les comptes sont ceux de la soirée, produits + * celui des badges du modèle. La charge n'est pas modifiée. + * + * @param {import('../stockage/types.js').Charge} charge + * @param {Object} options + * @param {'retenu'|'proposition'} [options.source] + * @param {number|null} [options.proposition] la proposition imprimée + * @param {Ordre} [options.ordre] + * @param {number[]|null} [options.selection] les personnes à rééditer + * @param {string} options.provenance le mode de construction + * @param {string} options.version la version affichée + * @returns {ModelePlanche} + * @throws {TypeError} une option hors de son domaine + * @throws {ErreurCommande} le refus de refusPlanche ; PROPOSITION_INCONNUE + * {id} ; PARTICIPANT_INCONNU {id} ou PERSONNE_EXCLUE {participant} + * pour une personne de la sélection + */ +export function modelePlanche( + charge, + { source = 'retenu', proposition = null, ordre = 'alphabetique', selection = null, provenance, version }, +) { + if (!ORDRES.includes(ordre)) throw new TypeError(`ordre : ${ORDRES.join(' ou ')} attendu, reçu ${JSON.stringify(ordre)}`); + if (typeof provenance !== 'string' || typeof version !== 'string') { + throw new TypeError('modelePlanche : provenance et version, deux chaînes, attendues'); + } + const rejet = refusPlanche(charge, { source, proposition }); + if (rejet !== null) throw new ErreurCommande(rejet.code, rejet.details); + const choisies = selectionLue(charge, selection); + + const placement = placementDeSource(charge, source, proposition); + const cible = source === 'retenu' ? { retenu: true } : { proposition }; + const places = placesParTour(charge, cible, placement.tours); + const titres = titresImprimes(charge, places, placement.siegesAttribues); + const homonymes = homonymesDe(charge); + const identifiants = new Set(homonymes.flatMap(({ ids }) => ids)); + const evenement = { nom: charge.evenement.nom.normalize('NFC'), date: charge.evenement.date }; + const horodatage = horodatagePlacement(charge, { source, proposition }); + + const retenues = charge.participants + .filter(({ exclu, id }) => !exclu && (choisies === null || choisies.has(id))) + .map((personne) => ({ personne, places: places.get(personne.id) })) + .sort(ordre === 'table' ? ordreParTable : ordreAlphabetique); + const badges = retenues.map(({ personne }) => { + const appartenance = cleAppartenance(personne) === '' ? null : personne.appartenance.normalize('NFC'); + const badge = { + participant: personne.id, + nom: nomAffiche(personne).normalize('NFC'), + appartenance, + lignes: lignesDe(personne.id, places, titres), + identifiant: identifiants.has(personne.id) ? personne.id : null, + }; + return { ...badge, textes: textesDe(badge, evenement, horodatage) }; + }); + + const exclus = charge.participants.filter(({ exclu }) => exclu).length; + return { + badges, + evenement, + horodatage, + brouillon: estBrouillon(charge, { source, tour: 'tous' }), + comptes: { participants: charge.participants.length, exclus, produits: badges.length }, + homonymes, + reedition: choisies === null ? null : { ids: badges.map(({ participant }) => participant) }, + version: versionVisible(provenance, version, libelle), + }; +} + +// Le point de code d'un caractère, écrit U+XXXX, quatre chiffres au moins. +const pointDeCode = (caractere) => `U+${caractere.codePointAt(0).toString(16).toUpperCase().padStart(4, '0')}`; + +// Un texte dont chaque caractère absent de la police s'écrit par son point +// de code : la police le dessinerait comme une case vide. +const sansAbsents = (texte, absents) => [...texte].map((c) => (absents.has(c) ? pointDeCode(c) : c)).join(''); + +/** + * Les lignes de la feuille de contrôle (§ 11.3, § 11.5, § 11.8), page 1 de + * la planche : les cotes du badge, le papier et ses cotes, l'imposition, le + * nombre de planches, le compte produit et le compte attendu, l'événement + * et sa date, l'horodatage du placement, la version, la consigne + * d'impression ; BROUILLON s'il y a lieu ; pour une réédition, son compte + * puis les noms réédités dans l'ordre de la pile ; les caractères absents + * de la police, quand il y en a, par leur point de code. Dans toutes les + * lignes, un caractère absent s'écrit « U+674E », jamais tel quel. + * + * @param {ModelePlanche} modele + * @param {Object} options + * @param {import('../geometrie/papiers.js').Papier} options.papier + * @param {Imposition} options.imposition + * @param {number} options.planches entier ≥ 0 + * @param {string[]} [options.absents] un caractère chacun, doublons admis + * @returns {string[]} + * @throws {TypeError} une option hors de son domaine + */ +export function textesControle(modele, { papier, imposition, planches, absents = [] }) { + if (typeof papier !== 'string' || !Object.hasOwn(PAPIERS, papier)) { + throw new TypeError(`papier : clé de PAPIERS attendue, reçu ${JSON.stringify(papier)}`); + } + if (!IMPOSITIONS.includes(imposition)) { + throw new TypeError(`imposition : ${IMPOSITIONS.join(' ou ')} attendue, reçu ${JSON.stringify(imposition)}`); + } + if (!Number.isSafeInteger(planches) || planches < 0) { + throw new TypeError(`planches : entier ≥ 0 attendu, reçu ${JSON.stringify(planches)}`); + } + const unCaractere = (c) => typeof c === 'string' && [...c].length === 1; + if (!Array.isArray(absents) || !absents.every(unCaractere)) { + throw new TypeError(`absents : caractères attendus, reçu ${JSON.stringify(absents)}`); + } + const { badges, evenement, horodatage, brouillon, comptes, reedition, version } = modele; + const lignes = [ + libelle('planche.controle.badge', BADGE), + libelle('planche.controle.papier', { papier, ...PAPIERS[papier] }), + libelle('planche.controle.imposition', { imposition }), + libelle('planche.controle.planches', { planches }), + libelle('planche.controle.comptes', comptes), + libelle('planche.controle.evenement', evenement), + horodatage === null ? libelle('planche.pied.horodatageInconnu') : libelle('planche.pied.horodatage', { horodatage }), + libelle('planche.controle.version', { version }), + libelle('planche.controle.consigne'), + ]; + if (brouillon) lignes.push(libelle('planche.brouillon')); + if (reedition !== null) { + lignes.push(libelle('planche.controle.reedition', { n: reedition.ids.length }), ...badges.map(({ textes }) => textes.nom)); + } + const uniques = new Set(absents); + const ecrites = lignes.map((ligne) => sansAbsents(ligne, uniques)); + if (uniques.size > 0) ecrites.push(libelle('planche.controle.absents', { points: [...uniques].map(pointDeCode) })); + return ecrites; +} diff --git a/src/application/planche.test.js b/src/application/planche.test.js new file mode 100644 index 0000000..aedb23a --- /dev/null +++ b/src/application/planche.test.js @@ -0,0 +1,705 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Épreuves du modèle de la planche de badges (§ 4.1, § 11.4 à § 11.8, +// § 15.1, § 15.5) : ce qu'un badge porte et ne porte pas, l'ordre de la pile +// par points de code, les homonymes que rien ne sépare, la source imprimée +// (le retenu ou une proposition, Q3), les refus avant de produire, le +// filigrane, l'horodatage et la version, la réédition identique, et les +// lignes de la feuille de contrôle. Les noms d'épreuve sont inventés, hors du +// réservoir des démonstrations ; la version vient du module engendré. +import assert from 'node:assert/strict'; +import { describe, test } from '../../test/lanceur.js'; +import { H_PROPOSITION, H_RETENU, H_RETOUCHE, personne, placementDe, soiree, table } from '../../test/fixtures/badges/cas.js'; +import { EXCLUS_TEMOIN, FORME_TEMOIN, chargeTemoin } from '../../test/fixtures/badges/temoin.js'; +import { cleNormalisee } from '../csv/normalisation.js'; +import { VERSION } from '../version.genere.js'; +import { ErreurCommande } from './erreurs.js'; +import { libelle } from './libelles.js'; +import { comparerPointsDeCode, homonymesDe, modelePlanche, refusPlanche, refusProduction, textesControle } from './planche.js'; +import { MARQUE_TEMOIN } from './traduction.js'; + +const PROVENANCE = 'developpement'; +const options = (autres = {}) => ({ provenance: PROVENANCE, version: VERSION.affichee, ...autres }); +const modele = (charge, autres) => modelePlanche(charge, options(autres)); + +const VERS_PROPOSITIONS = { geste: 'ouvrirSection', section: 'propositions' }; +const PIED_EVENEMENT = 'Soirée des Lucioles, 17 mai 2031'; +const PIED_DATE = '17 mai 2031'; +const PIED_HORODATAGE = 'Placement du 17 mai 2031 à 20:15:07'; + +// Le badge de la personne id dans un modèle. +const badgeDe = (m, id) => m.badges.find(({ participant }) => participant === id); +// Tous les textes d'un badge, à plat. +const textesDe = ({ textes }) => [textes.nom, ...textes.lignes, textes.appartenance ?? '', ...textes.pied, ...textes.piedReduit]; + +// Cinq personnes sur deux tables de deux places, deux tours : chaque tour +// laisse une personne en réserve — Théo au tour 2, Grisaille au tour 1. +function aReserve(autres = {}) { + return soiree({ + personnes: [ + personne(1, 'Iris', 'Ombrelle'), + personne(2, 'Théo', 'Pervenche'), + personne(3, 'Ondine', 'Lacasse'), + personne(4, 'Émile', 'Ombrelle'), + personne(5, null, 'Grisaille'), + ], + tables: [table(1, 7, 2), table(2, 3, 2)], + plan: [ + { sieges: [[1, 2], [3, 4]], reserve: [5] }, + { sieges: [[1, 5], [3, 4]], reserve: [2] }, + ], + ...autres, + }); +} + +// Deux personnes à la table 7 (identifiant 1), à chacun des tours. +const deuxATable7 = (tours, autres = {}) => + soiree({ + personnes: [personne(1, 'Iris', 'Ombrelle'), personne(2, 'Théo', 'Pervenche')], + tables: [table(1, 7, 2)], + plan: Array.from({ length: tours }, () => ({ sieges: [[1, 2]] })), + ...autres, + }); + +// Trois personnes à la table 7, sièges attribués, deux tours : Iris au +// siège 1 puis 2, Théo au siège 2 puis 1 ; un titre « animateur » au siège 1. +const titree = ({ reservations = [], tour2 = [2, 1, 3] } = {}) => + soiree({ + personnes: [personne(1, 'Iris', 'Ombrelle'), personne(2, 'Théo', 'Pervenche'), personne(3, 'Ondine', 'Lacasse')], + tables: [table(1, 7, 3)], + plan: [{ sieges: [[1, 2, 3]] }, { sieges: [tour2] }], + siegesAttribues: true, + titres: [{ table: 1, siege: 1, libelle: 'animateur' }], + reservations, + }); + +// Huit personnes sur deux tables de quatre, deux tours : deux « Théo +// Pervenche » sans appartenance, deux « Iris Ombrelle » de la même +// appartenance écrite autrement, deux « Ondine Lacasse » d'appartenances +// différentes, et deux autres. +function homonymes(autres = {}) { + return soiree({ + personnes: [ + personne(1, 'Théo', 'Pervenche'), + personne(2, 'Théo', 'Pervenche'), + personne(3, 'Iris', 'Ombrelle', { appartenance: 'Club des Merles' }), + personne(4, 'iris', 'OMBRELLE', { appartenance: ' club des MERLES ' }), + personne(5, 'Ondine', 'Lacasse', { appartenance: 'Grisaille' }), + personne(6, 'Ondine', 'Lacasse', { appartenance: 'Club des Merles' }), + personne(7, 'Émile', 'Ombrelle'), + personne(8, 'Ångel', 'Ombrelle'), + ], + tables: [table(1, 1, 4), table(2, 2, 4)], + plan: [ + { sieges: [[1, 2, 3, 4], [5, 6, 7, 8]] }, + { sieges: [[1, 3, 5, 7], [2, 4, 6, 8]] }, + ], + ...autres, + }); +} + +describe('planche : ce que porte un badge (§ 11.4, § 11.5, § 4.1)', () => { + test('un exclu n’a pas de badge et compte : le témoin donne 257 badges pour 260 participants, 3 exclus', () => { + const m = modele(chargeTemoin()); + assert.deepEqual(m.comptes, { participants: FORME_TEMOIN.participants, exclus: FORME_TEMOIN.exclus, produits: FORME_TEMOIN.produits }); + assert.equal(m.badges.length, 257); + for (const id of EXCLUS_TEMOIN) assert.equal(badgeDe(m, id), undefined, `exclu ${id}`); + assert.equal(new Set(m.badges.map(({ participant }) => participant)).size, 257); + assert.deepEqual(m.evenement, { nom: 'Soirée des Lucioles', date: '2031-05-17' }); + }); + + test('une personne en réserve à un tour lit « pas de table » à ce tour, en toutes lettres', () => { + const m = modele(aReserve()); + assert.deepEqual(badgeDe(m, 2).textes.lignes, ['Tour 1 — table 7', 'Tour 2 — pas de table']); + assert.deepEqual(badgeDe(m, 2).lignes, [ + { tour: 1, table: 7, siege: null, titre: null }, + { tour: 2, table: null, siege: null, titre: null }, + ]); + assert.deepEqual(badgeDe(m, 5).textes.lignes, ['Tour 1 — pas de table', 'Tour 2 — table 7']); + }); + + test('un itinéraire constant s’écrit en une ligne « Toute la soirée » à R ≥ 2 ; à R = 1, la ligne du tour', () => { + const quatre = modele(deuxATable7(4)); + assert.deepEqual(badgeDe(quatre, 1).textes.lignes, ['Toute la soirée — table 7']); + assert.deepEqual(badgeDe(quatre, 1).lignes, [{ tour: null, table: 7, siege: null, titre: null }]); + assert.deepEqual(badgeDe(modele(deuxATable7(1)), 1).textes.lignes, ['Tour 1 — table 7']); + assert.deepEqual(badgeDe(modele(aReserve()), 3).textes.lignes, ['Toute la soirée — table 3']); + }); + + test('le siège ne s’écrit que sous sièges attribués, compté chaises vides comprises', () => { + assert.deepEqual(badgeDe(modele(deuxATable7(1, { siegesAttribues: true })), 2).textes.lignes, ['Tour 1 — table 7, siège 2']); + assert.doesNotMatch(badgeDe(modele(deuxATable7(1)), 2).textes.lignes[0], /siège/); + const vide = soiree({ + personnes: [personne(1, 'Iris', 'Ombrelle'), personne(2, 'Théo', 'Pervenche')], + tables: [table(1, 7, 3)], + plan: [{ sieges: [[1, null, 2]] }], + siegesAttribues: true, + }); + assert.deepEqual(badgeDe(modele(vide), 2).textes.lignes, ['Tour 1 — table 7, siège 3']); + }); + + test('un titre posé sur un siège occupé sans réservation ne s’imprime pas (§ 4.1)', () => { + const m = modele(titree()); + assert.deepEqual(badgeDe(m, 1).textes.lignes, ['Tour 1 — table 7, siège 1', 'Tour 2 — table 7, siège 2']); + assert.deepEqual(badgeDe(m, 2).textes.lignes, ['Tour 1 — table 7, siège 2', 'Tour 2 — table 7, siège 1']); + for (const badge of m.badges) for (const texte of textesDe(badge)) assert.doesNotMatch(texte, /animateur/); + }); + + test('une réservation d’un seul tour qui fait la titulaire met le titre sur la ligne de ce tour, seule', () => { + const m = modele(titree({ reservations: [{ participant: 1, table: 1, siege: 1, portee: 'tour', tour: 1 }] })); + assert.deepEqual(badgeDe(m, 1).textes.lignes, ['Tour 1 — table 7, siège 1 — animateur', 'Tour 2 — table 7, siège 2']); + assert.deepEqual(badgeDe(m, 1).lignes[0], { tour: 1, table: 7, siege: 1, titre: 'animateur' }); + assert.deepEqual(badgeDe(m, 2).textes.lignes, ['Tour 1 — table 7, siège 2', 'Tour 2 — table 7, siège 1']); + // Assise au même siège aux deux tours, la titulaire d'un seul tour garde + // deux lignes : le titre change, l'itinéraire n'est pas constant. + const memeSiege = modele(titree({ reservations: [{ participant: 1, table: 1, siege: 1, portee: 'tour', tour: 1 }], tour2: [1, 2, 3] })); + assert.deepEqual(badgeDe(memeSiege, 1).textes.lignes, ['Tour 1 — table 7, siège 1 — animateur', 'Tour 2 — table 7, siège 1']); + assert.deepEqual(badgeDe(memeSiege, 1).lignes, [ + { tour: 1, table: 7, siege: 1, titre: 'animateur' }, + { tour: 2, table: 7, siege: 1, titre: null }, + ]); + }); + + test('une titulaire « tous les tours » que le retenu assied ailleurs à un tour n’y porte pas le titre ; assise au siège à chaque tour, une ligne', () => { + const tous = [{ participant: 1, table: 1, siege: 1, portee: 'tous', tour: null }]; + assert.deepEqual(badgeDe(modele(titree({ reservations: tous })), 1).textes.lignes, [ + 'Tour 1 — table 7, siège 1 — animateur', + 'Tour 2 — table 7, siège 2', + ]); + assert.deepEqual(badgeDe(modele(titree({ reservations: tous, tour2: [1, 2, 3] })), 1).textes.lignes, [ + 'Toute la soirée — table 7, siège 1 — animateur', + ]); + }); + + test('sans sièges attribués, une réservation sans siège fait la titulaire ; le tour où le retenu l’assied à une autre table, rien', () => { + const charge = soiree({ + personnes: [personne(1, 'Iris', 'Ombrelle'), personne(2, 'Théo', 'Pervenche'), personne(3, 'Ondine', 'Lacasse'), personne(4, null, 'Grisaille')], + tables: [table(1, 7, 2), table(2, 3, 2)], + plan: [{ sieges: [[1, 2], [3, 4]] }, { sieges: [[2, 3], [1, 4]] }], + titres: [{ table: 1, siege: 1, libelle: 'animatrice' }], + reservations: [{ participant: 1, table: 1, siege: null, portee: 'tous', tour: null }], + }); + const m = modele(charge); + assert.deepEqual(badgeDe(m, 1).textes.lignes, ['Tour 1 — table 7 — animatrice', 'Tour 2 — table 3']); + assert.deepEqual(badgeDe(m, 2).textes.lignes, ['Toute la soirée — table 7']); + }); + + test('aucun texte du badge ne porte le courriel, les notes, le titre pressenti, l’identifiant hors homonymie ni la version', () => { + const charge = chargeTemoin(); + const m = modele(charge); + let parcourus = 0; + for (const badge of m.badges) { + const { courriel, notes, titrePressenti } = charge.participants.find(({ id }) => id === badge.participant); + assert.equal(badge.identifiant, null); + const identifiant = libelle('planche.pied.identifiant', { identifiant: badge.participant }); + for (const texte of textesDe(badge)) { + parcourus += 1; + for (const interdit of [courriel, notes, titrePressenti, identifiant, VERSION.affichee]) { + assert.ok(!texte.includes(interdit), `${badge.participant} : « ${texte} » porte « ${interdit} »`); + } + } + } + assert.ok(parcourus > 257 * 4, `${parcourus} textes`); + assert.equal(m.version, VERSION.affichee); + }); + + test('le nom affiché et l’appartenance, en NFC ; sans appartenance, rien', () => { + const decomposee = soiree({ + personnes: [personne(1, 'Ze\u{0301}lie', 'Ombrelle', { appartenance: 'Cafe\u{0301} des Merles' }), personne(2, 'Théo', 'Pervenche')], + tables: [table(1, 7, 2)], + plan: [{ sieges: [[1, 2]] }], + }); + const m = modele(decomposee); + assert.equal(badgeDe(m, 1).nom, 'Z\u{00E9}lie Ombrelle'); + assert.equal(badgeDe(m, 1).textes.nom, 'Z\u{00E9}lie Ombrelle'); + assert.equal(badgeDe(m, 1).appartenance, 'Caf\u{00E9} des Merles'); + assert.equal(badgeDe(m, 1).textes.appartenance, 'Caf\u{00E9} des Merles'); + assert.equal(badgeDe(m, 2).appartenance, null); + assert.equal(badgeDe(m, 2).textes.appartenance, null); + }); + + test('le pied porte l’événement et sa date, puis l’horodatage ; piedReduit retire le nom de l’événement et garde la date', () => { + const badge = badgeDe(modele(aReserve()), 1); + assert.deepEqual(badge.textes.pied, [PIED_EVENEMENT, PIED_HORODATAGE]); + assert.deepEqual(badge.textes.piedReduit, [PIED_DATE, PIED_HORODATAGE]); + const sansDate = badgeDe(modele(aReserve({ date: null })), 1); + assert.deepEqual(sansDate.textes.pied, ['Soirée des Lucioles', PIED_HORODATAGE]); + assert.deepEqual(sansDate.textes.piedReduit, [PIED_HORODATAGE]); + }); +}); + +describe('planche : l’ordre de la pile (§ 11.6, § 15.5, décision 9)', () => { + test('comparerPointsDeCode compare par points de code, là où « < » compare des unités UTF-16', () => { + assert.equal(comparerPointsDeCode('a', 'b'), -1); + assert.equal(comparerPointsDeCode('b', 'a'), 1); + assert.equal(comparerPointsDeCode('', ''), 0); + assert.equal(comparerPointsDeCode('abc', 'abc'), 0); + assert.equal(comparerPointsDeCode('ab', 'abc'), -1); + assert.equal(comparerPointsDeCode('abc', 'ab'), 1); + assert.equal(comparerPointsDeCode('\u{1D49C}b', '\u{1D49C}c'), -1, 'un point astral commun, puis la différence'); + assert.equal(comparerPointsDeCode('\u{1D49C}', '\u{1D49C}'), 0); + const [pleine, script] = ['\u{FF41}', '\u{1D49C}']; + assert.equal(pleine < script, false); + assert.equal(comparerPointsDeCode(pleine, script), -1); + assert.equal(comparerPointsDeCode(script, pleine), 1); + for (const intrus of [null, undefined, 3]) assert.throws(() => comparerPointsDeCode(intrus, 'a'), TypeError); + }); + + // Dix personnes à une table, listées dans l'ordre inverse des + // identifiants : l'ordre rendu ne doit rien à celui de la liste. + const PERSONNES_ORDRE = [ + personne(1, 'Zélie', 'Ombrelle'), + personne(2, 'Émile', 'Ombrelle'), + personne(3, 'Eric', 'Ombrelle'), + personne(4, 'Ångel', 'Ombrelle'), + personne(5, 'Iris', 'Pervenche'), + personne(6, 'Iris', 'Pervenche'), + personne(7, 'Ondine', 'Lacasse'), + personne(8, 'Iris', '\u{0152}illet'), + personne(9, '\u{FF21}da', 'Ombrelle'), + personne(10, '\u{1D49C}da', 'Ombrelle'), + ]; + const ordonnee = () => + soiree({ + personnes: [...PERSONNES_ORDRE].reverse(), + tables: [table(1, 1, 10)], + plan: [{ sieges: [[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]] }], + }); + + test('l’ordre suit la clé normalisée du couple (nom, prénom), par points de code, départagé par identifiant — jamais localeCompare', () => { + const [cleA, cleScript] = ['\u{FF21}da', '\u{1D49C}da'].map(cleNormalisee); + assert.equal(cleA < cleScript, false, '« < » range la forme pleine après la lettre mathématique'); + assert.equal(comparerPointsDeCode(cleA, cleScript), -1, 'les points de code la rangent avant'); + // La collation française range « œillet » comme « oeillet », avant + // « ombrelle » ; l'ordre attendu ici le met après « pervenche ». La + // locale est nommée : sans elle, la précondition suit la langue du + // processus, et la collation suédoise range « œ » après « o ». + assert.ok(cleNormalisee('\u{0152}illet').localeCompare('ombrelle', 'fr') < 0); + const m = modele(ordonnee()); + assert.deepEqual(m.badges.map(({ participant }) => participant), [7, 4, 2, 3, 1, 9, 10, 5, 6, 8]); + }); + + test('l’ordre par table : la table du tour 1 par numéro, l’ordre alphabétique dans chacune, la réserve du tour 1 en dernier', () => { + const charge = soiree({ + personnes: [ + personne(1, 'Iris', 'Ombrelle'), + personne(2, 'Théo', 'Pervenche'), + personne(3, 'Ondine', 'Lacasse'), + personne(4, 'Émile', 'Ombrelle'), + personne(5, 'Ångel', 'Ombrelle'), + ], + tables: [table(1, 2, 2), table(2, 1, 2)], + plan: [{ sieges: [[1, 2], [4, 3]], reserve: [5] }], + }); + assert.deepEqual(modele(charge, { ordre: 'table' }).badges.map(({ participant }) => participant), [3, 4, 1, 2, 5]); + assert.deepEqual(modele(charge).badges.map(({ participant }) => participant), [3, 5, 4, 1, 2]); + }); + + test('sans prénom, la clé du prénom est vide : « Grisaille » seule vient avant « Ondine Grisaille », deux sans prénom par identifiant', () => { + const charge = soiree({ + personnes: [personne(1, 'Ondine', 'Grisaille'), personne(2, null, 'Grisaille'), personne(3, null, 'grisaille')], + tables: [table(1, 1, 3)], + plan: [{ sieges: [[1, 2, 3]] }], + }); + assert.deepEqual(modele(charge).badges.map(({ participant }) => participant), [2, 3, 1]); + }); + + test('par table, deux tables de même numéro se départagent par leur identifiant', () => { + const charge = soiree({ + personnes: [personne(1, 'Iris', 'Ombrelle'), personne(2, 'Théo', 'Pervenche'), personne(3, 'Ondine', 'Lacasse')], + tables: [table(1, 4, 2), table(2, 4, 2), table(3, 2, 2)], + plan: [{ sieges: [[2], [1], [3]] }], + }); + assert.deepEqual(modele(charge, { ordre: 'table' }).badges.map(({ participant }) => participant), [3, 2, 1]); + }); + + test('un ordre inconnu lève TypeError', () => { + for (const ordre of ['alpha', 'Table', null]) assert.throws(() => modele(aReserve(), { ordre }), TypeError, String(ordre)); + }); +}); + +describe('planche : les homonymes que rien ne sépare (§ 11.4, décision 7)', () => { + test('même nom normalisé, sans appartenance ou de même appartenance normalisée : nommés ; d’appartenances différentes, non', () => { + assert.deepEqual(homonymesDe(homonymes()), [ + { nom: 'Iris Ombrelle', ids: [3, 4] }, + { nom: 'Théo Pervenche', ids: [1, 2] }, + ]); + const m = modele(homonymes()); + assert.deepEqual(m.homonymes, homonymesDe(homonymes())); + for (const id of [1, 2, 3, 4]) { + const badge = badgeDe(m, id); + assert.equal(badge.identifiant, id); + assert.equal(badge.textes.pied.at(-1), `Identifiant n° ${id}`); + assert.equal(badge.textes.piedReduit.at(-1), `Identifiant n° ${id}`); + } + for (const id of [5, 6, 7, 8]) { + assert.equal(badgeDe(m, id).identifiant, null); + assert.equal(badgeDe(m, id).textes.pied.length, 2); + } + }); + + test('le nom imprimé décide : « Théo Pervenche » sans prénom rejoint « Théo » « Pervenche » ; un exclu ne compte pas', () => { + const charge = homonymes(); + // L'exclu porte le nom et l'appartenance du groupe [3, 4] : compté, il + // le rejoindrait. + charge.participants.push( + personne(9, null, 'Théo Pervenche'), + personne(10, 'Iris', 'Ombrelle', { appartenance: 'Club des Merles', exclu: true }), + ); + charge.retenu = { proposition: 1, ...placementDe(charge, [{ sieges: [[1, 2, 3, 4], [5, 6, 7, 8, 9]] }, { sieges: [[1, 3, 5, 7, 9], [2, 4, 6, 8]] }]) }; + charge.tables[1].sieges = 5; + charge.retenu.capacites = [4, 5]; + assert.deepEqual(homonymesDe(charge), [ + { nom: 'Iris Ombrelle', ids: [3, 4] }, + { nom: 'Théo Pervenche', ids: [1, 2, 9] }, + ]); + }); + + test('un présent seul de son nom à côté d’un homonyme exclu n’a pas d’identifiant au pied', () => { + const charge = soiree({ + personnes: [ + personne(1, 'Théo', 'Pervenche'), + personne(2, 'Théo', 'Pervenche', { exclu: true }), + personne(3, 'Iris', 'Ombrelle'), + ], + tables: [table(1, 7, 2)], + plan: [{ sieges: [[1, 3]] }], + }); + assert.deepEqual(homonymesDe(charge), []); + const m = modele(charge); + assert.deepEqual(m.homonymes, []); + assert.equal(badgeDe(m, 1).identifiant, null); + assert.deepEqual(badgeDe(m, 1).textes.pied, [PIED_EVENEMENT, PIED_HORODATAGE]); + assert.deepEqual(badgeDe(m, 1).textes.piedReduit, [PIED_DATE, PIED_HORODATAGE]); + }); + + test('deux collisions du même nom, d’appartenances différentes : deux groupes, rangés par appartenance normalisée', () => { + const charge = soiree({ + personnes: [ + personne(1, 'Ondine', 'Lacasse', { appartenance: 'Grisaille' }), + personne(2, 'Ondine', 'Lacasse', { appartenance: 'Club des Merles' }), + personne(3, 'Ondine', 'Lacasse', { appartenance: 'grisaille' }), + personne(4, 'Ondine', 'Lacasse', { appartenance: 'Club des merles' }), + ], + tables: [table(1, 1, 4)], + plan: [{ sieges: [[1, 2, 3, 4]] }], + }); + assert.deepEqual(homonymesDe(charge), [ + { nom: 'Ondine Lacasse', ids: [2, 4] }, + { nom: 'Ondine Lacasse', ids: [1, 3] }, + ]); + }); + + test('sans homonyme, une liste vide', () => { + assert.deepEqual(homonymesDe(aReserve()), []); + assert.deepEqual(modele(aReserve()).homonymes, []); + }); +}); + +describe('planche : la source imprimée, le retenu ou une proposition (Q3)', () => { + // Le retenu de aReserve, et la proposition 2 qui permute les tables au + // tour 2. + const avecProposition = (autres) => + aReserve({ + propositions: [ + [2, [ + { sieges: [[1, 2], [3, 4]], reserve: [5] }, + { sieges: [[3, 4], [1, 5]], reserve: [2] }, + ]], + ], + ...autres, + }); + + test('une proposition désignée donne ses badges, BROUILLON même sous l’état retenu, et son horodatage', () => { + const charge = avecProposition(); + const retenu = modele(charge); + const tiree = modele(charge, { source: 'proposition', proposition: 2 }); + assert.equal(retenu.brouillon, false); + assert.equal(tiree.brouillon, true); + assert.equal(retenu.horodatage, H_RETENU); + assert.equal(tiree.horodatage, H_PROPOSITION); + assert.deepEqual(badgeDe(tiree, 1).textes.lignes, ['Tour 1 — table 7', 'Tour 2 — table 3']); + assert.deepEqual(badgeDe(retenu, 1).textes.lignes, ['Toute la soirée — table 7']); + assert.notDeepEqual(tiree, retenu); + const sansChamp = avecProposition(); + delete sansChamp.propositions[0].horodatage; + assert.equal(modele(sansChamp, { source: 'proposition', proposition: 2 }).horodatage, null); + for (const etat of ['bloque', 'brouillon']) { + assert.equal(modele(avecProposition({ etat }), { source: 'proposition', proposition: 2 }).brouillon, true, etat); + } + }); + + test('une planche se tire d’une proposition sans retenu ; un identifiant inconnu lève PROPOSITION_INCONNUE ; une autre source, TypeError', () => { + const sansRetenu = avecProposition({ plan: null, etat: 'propose' }); + assert.equal(modele(sansRetenu, { source: 'proposition', proposition: 2 }).badges.length, 5); + assert.throws( + () => modele(avecProposition(), { source: 'proposition', proposition: 9 }), + (erreur) => erreur instanceof ErreurCommande && erreur.code === 'PROPOSITION_INCONNUE' && erreur.details.id === 9, + ); + for (const source of ['reservations', 'plan', null]) { + assert.throws(() => modele(avecProposition(), { source }), TypeError, String(source)); + assert.throws(() => refusPlanche(avecProposition(), { source }), TypeError, String(source)); + } + assert.throws(() => modele(avecProposition(), { source: 'proposition', proposition: '2' }), TypeError); + }); +}); + +describe('planche : les refus avant de produire (§ 11.8, Q3)', () => { + test('ni retenu ni proposition désignée : PLANCHE_SANS_PLACEMENT, remède les propositions', () => { + const attendu = { code: 'PLANCHE_SANS_PLACEMENT', details: { remede: VERS_PROPOSITIONS } }; + assert.deepEqual(refusPlanche(aReserve({ plan: null })), attendu); + assert.deepEqual(refusPlanche(aReserve(), { source: 'proposition', proposition: null }), attendu); + assert.throws( + () => modele(aReserve({ plan: null })), + (erreur) => erreur instanceof ErreurCommande && erreur.code === attendu.code && assert.deepEqual(erreur.details, attendu.details) === undefined, + ); + }); + + test('une source qui se contredit : RETENU_FAUTIF, tel que le relaient les places', () => { + const attendu = { code: 'RETENU_FAUTIF', details: { remede: { geste: 'ouvrirSecours', source: 'precedent' } } }; + const fautif = aReserve(); + fautif.retenu.placement[0].sieges[1] = [3, 3]; + assert.deepEqual(refusPlanche(fautif), attendu); + const proposee = aReserve({ propositions: [[2, [{ sieges: [[1, 2], [3, 4]], reserve: [5] }, { sieges: [[1, 5], [3, 4]], reserve: [2] }]]] }); + proposee.propositions[0].placement[1].reserve = []; + assert.deepEqual(refusPlanche(proposee, { source: 'proposition', proposition: 2 }), attendu); + assert.throws(() => modele(fautif), (erreur) => erreur instanceof ErreurCommande && erreur.code === 'RETENU_FAUTIF'); + }); + + test('une source en dérive — une personne ajoutée depuis — : PLANCHE_DERIVE, qui la nomme avant de produire', () => { + const charge = aReserve(); + charge.participants.push(personne(6, 'Ondine', 'Grisaille')); + const refus = refusPlanche(charge); + assert.deepEqual(refus, { + code: 'PLANCHE_DERIVE', + details: { raisons: [{ code: 'PARTICIPANT_NON_PLACE', participant: 6, personne: 'Ondine Grisaille' }], remede: VERS_PROPOSITIONS }, + }); + assert.match(libelle(refus.code, refus.details), /Ondine Grisaille/); + assert.throws( + () => modele(charge), + (erreur) => erreur instanceof ErreurCommande && erreur.code === 'PLANCHE_DERIVE' && erreur.details.raisons[0].personne === 'Ondine Grisaille', + ); + }); + + test('un retenu à 3 tours sur une soirée de 4 : PLANCHE_DERIVE, le nombre de tours nommé', () => { + const charge = deuxATable7(3); + charge.evenement.tours = 4; + assert.deepEqual(refusPlanche(charge)?.details.raisons, [{ code: 'TOURS_CHANGES', avant: 3, maintenant: 4 }]); + }); + + test('l’ordre des refus est épinglé : sans placement, puis fautif, puis dérive', () => { + const fautifEtDerive = aReserve(); + fautifEtDerive.retenu.placement[0].sieges[1] = [3, 3]; + fautifEtDerive.participants.push(personne(6, 'Ondine', 'Grisaille')); + assert.equal(refusPlanche(fautifEtDerive).code, 'RETENU_FAUTIF'); + const sansPlacementEtDerive = aReserve({ plan: null }); + sansPlacementEtDerive.participants.push(personne(6, 'Ondine', 'Grisaille')); + assert.equal(refusPlanche(sansPlacementEtDerive).code, 'PLANCHE_SANS_PLACEMENT'); + assert.equal(refusPlanche(aReserve()), null); + }); +}); + +describe('planche : brouillon, horodatage, version, réédition (§ 11.4, § 11.7, § 11.8)', () => { + test('brouillon selon estBrouillon : faux sous l’état retenu ou bloqué, vrai sous brouillon ou proposé avec un retenu', () => { + const lus = ['brouillon', 'propose', 'retenu', 'bloque'].map((etat) => [etat, modele(aReserve({ etat })).brouillon]); + assert.deepEqual(lus, [ + ['brouillon', true], + ['propose', true], + ['retenu', false], + ['bloque', false], + ]); + }); + + test('l’horodatage est celui du retenu ; sans le champ, null, et le pied dit l’horodatage inconnu', () => { + assert.equal(modele(aReserve()).horodatage, H_RETENU); + const ancien = modele(aReserve({ horodatage: null })); + assert.equal(ancien.horodatage, null); + assert.deepEqual(badgeDe(ancien, 1).textes.pied, [PIED_EVENEMENT, 'Horodatage du placement inconnu']); + }); + + test('la version visible, la marque de documentation dans une construction de documentation ; jamais sur un badge', () => { + assert.equal(modele(aReserve()).version, VERSION.affichee); + const documentation = modelePlanche(aReserve(), { provenance: 'documentation', version: VERSION.affichee }); + assert.equal(documentation.version, libelle('sorties.version.documentation')); + for (const badge of documentation.badges) for (const texte of textesDe(badge)) assert.ok(!texte.includes(documentation.version)); + assert.throws(() => modelePlanche(aReserve(), { provenance: PROVENANCE }), TypeError); + assert.throws(() => modelePlanche(aReserve(), { version: VERSION.affichee }), TypeError); + }); + + test('une réédition de trois personnes : reedition.ids, trois badges en ordre alphabétique, les comptes de la soirée gardés', () => { + const m = modele(aReserve(), { selection: [2, 5, 4] }); + assert.deepEqual(m.badges.map(({ participant }) => participant), [5, 4, 2]); + assert.deepEqual(m.reedition, { ids: [5, 4, 2] }); + assert.deepEqual(m.comptes, { participants: 5, exclus: 0, produits: 3 }); + assert.equal(modele(aReserve()).reedition, null); + }); + + test('une sélection vide, en double, d’identifiants non entiers lève TypeError ; une personne inconnue ou exclue, son refus', () => { + for (const selection of [[], [2, 2], [1.5], 'tous', [null]]) { + assert.throws(() => modele(aReserve(), { selection }), TypeError, JSON.stringify(selection)); + } + assert.throws( + () => modele(aReserve(), { selection: [1, 42] }), + (erreur) => erreur instanceof ErreurCommande && erreur.code === 'PARTICIPANT_INCONNU' && erreur.details.id === 42, + ); + const charge = chargeTemoin(); + assert.throws( + () => modele(charge, { selection: [EXCLUS_TEMOIN[0]] }), + (erreur) => erreur instanceof ErreurCommande && erreur.code === 'PERSONNE_EXCLUE' && erreur.details.participant === EXCLUS_TEMOIN[0], + ); + }); + + test('deux appels sur la même charge rendent des modèles égaux, sans la modifier', () => { + const charge = homonymes(); + const avant = structuredClone(charge); + assert.deepStrictEqual(modele(charge), modele(charge)); + assert.deepStrictEqual(modele(charge, { selection: [6, 1] }), modele(charge, { selection: [1, 6] })); + assert.deepStrictEqual(charge, avant); + }); +}); + +describe('planche : la réédition identique (§ 11.8)', () => { + test('les textes d’un badge réédité égalent ceux du modèle complet, identifiant d’homonyme compris', () => { + const complet = modele(homonymes()); + const reedite = modele(homonymes(), { selection: [2, 5, 7] }); + assert.equal(badgeDe(reedite, 2).identifiant, 2); + for (const badge of reedite.badges) assert.deepStrictEqual(badge, badgeDe(complet, badge.participant), String(badge.participant)); + assert.deepEqual(reedite.homonymes, complet.homonymes); + }); + + test('après une retouche du retenu, le pied diffère par l’horodatage, le reste est égal', () => { + const avant = modele(homonymes(), { selection: [2, 5, 7] }); + const retouchee = homonymes(); + retouchee.retenu.horodatage = H_RETOUCHE; + const tour2 = retouchee.retenu.placement[1].sieges; + [tour2[0][1], tour2[1][3]] = [8, 3]; + tour2[0].sort((a, b) => a - b); + tour2[1].sort((a, b) => a - b); + const apres = modele(retouchee, { selection: [2, 5, 7] }); + assert.notEqual(badgeDe(modele(retouchee), 3).textes.lignes.join(), badgeDe(modele(homonymes()), 3).textes.lignes.join()); + for (const badge of apres.badges) { + const ancien = badgeDe(avant, badge.participant); + const { pied, piedReduit, ...reste } = badge.textes; + const { pied: piedAncien, piedReduit: reduitAncien, ...resteAncien } = ancien.textes; + assert.deepStrictEqual(reste, resteAncien); + assert.deepEqual(pied.filter((ligne, rang) => ligne !== piedAncien[rang]), ['Placement du 17 mai 2031 à 21:42:30']); + assert.deepEqual(piedReduit.filter((ligne, rang) => ligne !== reduitAncien[rang]), ['Placement du 17 mai 2031 à 21:42:30']); + } + }); +}); + +describe('planche : la feuille de contrôle (§ 11.3, § 11.5, § 11.8)', () => { + const sansInsecable = (lignes) => lignes.map((ligne) => ligne.replace(/[\u{00A0}\u{202F}]/gu, ' ')); + + test('dimensions, papier, imposition, planches, comptes, événement, horodatage, version, consigne d’impression', () => { + const m = modele(chargeTemoin()); + const lignes = sansInsecable(textesControle(m, { papier: 'A4', imposition: 'feuille', planches: FORME_TEMOIN.planchesA4 })); + for (const attendue of [ + 'Badge : 105 × 148 mm (A6)', + 'Papier : A4, 210 × 297 mm', + 'Imposition : feuille par feuille', + 'Planches : 65', + '257 badges pour 260 participants, 3 exclus', + `Événement : ${PIED_EVENEMENT}`, + PIED_HORODATAGE, + `Version : ${VERSION.affichee}`, + 'Imprimer en recto seul, à 100 %', + ]) { + assert.ok(lignes.includes(attendue), `« ${attendue} » absente de ${JSON.stringify(lignes)}`); + } + assert.ok(!lignes.includes('BROUILLON')); + for (const ligne of lignes) { + assert.doesNotMatch(ligne, /\bundefined\b|\bnull\b|\bNaN\b/); + assert.ok(!ligne.includes(MARQUE_TEMOIN.ouverture), ligne); + } + }); + + test('l’autre papier et l’autre imposition se nomment ; un brouillon porte BROUILLON ; l’horodatage inconnu se dit', () => { + const m = modele(aReserve({ etat: 'brouillon', horodatage: null, date: null })); + const autre = Object.keys({ Lettre: 0 })[0]; + const lignes = sansInsecable(textesControle(m, { papier: autre, imposition: 'pile', planches: 3 })); + for (const attendue of [ + `Papier : ${autre}, 215,9 × 279,4 mm`, + 'Imposition : en pile', + 'Planches : 3', + '5 badges pour 5 participants', + 'Événement : Soirée des Lucioles', + 'Horodatage du placement inconnu', + 'BROUILLON', + ]) { + assert.ok(lignes.includes(attendue), `« ${attendue} » absente de ${JSON.stringify(lignes)}`); + } + }); + + test('une réédition s’annonce, « Réédition : 3 badges », puis nomme les trois dans l’ordre de la pile', () => { + const m = modele(aReserve(), { selection: [2, 5, 4] }); + const lignes = textesControle(m, { papier: 'A4', imposition: 'feuille', planches: 1 }); + const debut = lignes.indexOf('Réédition : 3 badges'); + assert.ok(debut >= 0, JSON.stringify(lignes)); + assert.deepEqual(lignes.slice(debut + 1, debut + 4), ['Grisaille', 'Émile Ombrelle', 'Théo Pervenche']); + assert.ok(lignes.includes('3 badges pour 5 participants')); + assert.equal(modele(aReserve(), { selection: [1] }).comptes.produits, 1); + assert.ok(textesControle(modele(aReserve(), { selection: [1] }), { papier: 'A4', imposition: 'feuille', planches: 1 }).includes('Réédition : 1 badge')); + }); + + test('chaque glyphe absent s’écrit « U+674E », point de code par point de code, et jamais tel quel', () => { + const charge = soiree({ + personnes: [personne(1, '\u{674E}', 'Ombrelle'), personne(2, 'Théo', 'Pervenche')], + tables: [table(1, 7, 2)], + plan: [{ sieges: [[1, 2]] }], + nom: 'Soirée \u{20000}', + }); + const m = modele(charge, { selection: [1] }); + const lignes = textesControle(m, { papier: 'A4', imposition: 'feuille', planches: 1, absents: ['\u{674E}', '\u{20000}', '\u{674E}'] }); + for (const ligne of lignes) { + assert.ok(!ligne.includes('\u{674E}'), ligne); + assert.ok(!ligne.includes('\u{20000}'), ligne); + } + assert.ok(lignes.includes('U+674E Ombrelle'), JSON.stringify(lignes)); + assert.ok(lignes.some((ligne) => ligne.startsWith('Événement : Soirée U+20000')), JSON.stringify(lignes)); + const annonce = lignes.find((ligne) => ligne.startsWith('Caractères absents')); + assert.match(annonce, /U\+674E et U\+20000$/); + assert.ok(!textesControle(m, { papier: 'A4', imposition: 'feuille', planches: 1 }).some((ligne) => ligne.startsWith('Caractères absents'))); + }); + + test('un papier, une imposition, un nombre de planches ou des absents hors de leur domaine lèvent TypeError', () => { + const m = modele(aReserve()); + const bon = { papier: 'A4', imposition: 'feuille', planches: 2 }; + for (const mauvais of [{ papier: 'A5' }, { papier: 'toString' }, { imposition: 'rame' }, { planches: -1 }, { planches: 1.5 }, { absents: 'x' }, { absents: [3] }]) { + assert.throws(() => textesControle(m, { ...bon, ...mauvais }), TypeError, JSON.stringify(mauvais)); + } + }); +}); + +describe('planche : les refus de la production et leurs textes (§ 11.5, § 11.8)', () => { + test('refusProduction relaie un refus de la mise en page avec le remède du contrat ; un autre code lève TypeError', () => { + assert.deepEqual(refusProduction('PLANCHE_TROP_DE_TOURS', { tours: 40 }), { + code: 'PLANCHE_TROP_DE_TOURS', + details: { tours: 40, remede: null }, + }); + assert.deepEqual(refusProduction('PLANCHE_NOM_TROP_LONG', { participant: 3, personne: 'Ondine Lacasse' }), { + code: 'PLANCHE_NOM_TROP_LONG', + details: { participant: 3, personne: 'Ondine Lacasse', remede: { geste: 'ouvrirSection', section: 'participants' } }, + }); + for (const code of ['PLANCHE_DERIVE', 'RETENU_FAUTIF', 'toString', null]) { + assert.throws(() => refusProduction(code, {}), TypeError, String(code)); + } + }); + + test('chaque refus de la planche se dit : le nombre de tours, la personne au nom trop long, chaque raison de la dérive', () => { + assert.match(libelle('PLANCHE_TROP_DE_TOURS', { tours: 40, remede: null }), /\b40 tours\b/); + assert.match(libelle('PLANCHE_NOM_TROP_LONG', { participant: 3, personne: 'Ondine Lacasse' }), /Ondine Lacasse/); + assert.match(libelle('PLANCHE_NOM_TROP_LONG', { participant: 3 }), /n° 3\b/); + const derive = libelle('PLANCHE_DERIVE', { + raisons: [ + { code: 'PARTICIPANT_NON_PLACE', participant: 6, personne: 'Ondine Grisaille' }, + { code: 'TOURS_CHANGES', avant: 3, maintenant: 4 }, + ], + remede: VERS_PROPOSITIONS, + }); + assert.match(derive, /Ondine Grisaille/); + assert.match(derive, /de 3 à 4/); + assert.ok(libelle('PLANCHE_SANS_PLACEMENT', { remede: VERS_PROPOSITIONS }).length > 0); + }); +}); diff --git a/test/fixtures/badges/cas.js b/test/fixtures/badges/cas.js new file mode 100644 index 0000000..7fd2afb --- /dev/null +++ b/test/fixtures/badges/cas.js @@ -0,0 +1,149 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Les petites charges des épreuves de la planche (§ 11.4 à § 11.8) : une +// soirée construite par ses personnes, ses tables et un plan écrit tour par +// tour, sous la forme positionnelle du fichier d'état (§ 8.9). Le plan d'un +// tour se donne comme le fichier l'écrit — une liste par table, dans l'ordre +// de charge.tables, et la réserve — ; sans attribution des sièges, chaque +// liste se trie par identifiant, comme le veut le format. Chaque appel rend +// une charge neuve, que l'épreuve modifie à son gré. Les noms sont inventés, +// hors du réservoir des démonstrations ; la version vient du module engendré. +import { creerCharge } from '../../../src/stockage/document.js'; +import { VERSION } from '../../../src/version.genere.js'; + +/** L'horodatage du retenu des soirées construites, et celui d'une retouche. */ +export const H_RETENU = '2031-05-17T20:15:07-04:00'; +export const H_RETOUCHE = '2031-05-17T21:42:30-04:00'; +/** L'horodatage des propositions construites. */ +export const H_PROPOSITION = '2031-05-17T19:05:00-04:00'; + +/** + * Une personne de la charge : identifiant, prénom (null admis), nom ; + * appartenance, courriel, titre pressenti et notes à null, non exclue, + * sauf ce que champs remplace. + * + * @param {number} id + * @param {string|null} prenom + * @param {string} nom + * @param {Object} [champs] + * @returns {import('../../../src/stockage/types.js').Participant} + */ +export function personne(id, prenom, nom, champs = {}) { + return { + id, + nom, + prenom, + appartenance: null, + courriel: null, + titrePressenti: null, + notes: null, + exclu: false, + ...champs, + }; +} + +/** + * Une table : son identifiant, son numéro affiché, ses sièges (null : le + * défaut de la soirée), ronde, posée en rangée par identifiant. + * + * @param {number} id + * @param {number} numero + * @param {number|null} [sieges] + */ +export function table(id, numero, sieges = null) { + return { id, numero, sieges, forme: 'ronde', position: { x: 300 * (id - 1), y: 0 } }; +} + +// La capacité courante d'une table de la charge. +const capaciteDe = (charge, { sieges }) => sieges ?? charge.evenement.siegesParDefaut; + +/** + * La forme positionnelle d'un plan sur les tables de la charge : tables et + * capacités de charge.tables, dans leur ordre ; un tour par élément de plan, + * { sieges: (number|null)[][], reserve?: number[] } ; les participants, ceux + * que le premier tour écrit, triés. Sans attribution, chaque liste se trie + * par identifiant ; avec, elle garde l'ordre des sièges et ses null. + * + * @param {Object} charge + * @param {Array<{sieges: Array>, reserve?: number[]}>} plan + * @param {{siegesAttribues?: boolean}} [options] + */ +export function placementDe(charge, plan, { siegesAttribues = false } = {}) { + const placement = plan.map(({ sieges, reserve = [] }) => ({ + sieges: sieges.map((liste) => (siegesAttribues ? [...liste] : [...liste].sort((a, b) => a - b))), + reserve: [...reserve].sort((a, b) => a - b), + })); + const premier = placement[0]; + const participants = [...premier.sieges.flat(), ...premier.reserve].filter((id) => id !== null).sort((a, b) => a - b); + return { + siegesAttribues, + tables: charge.tables.map(({ id }) => id), + capacites: charge.tables.map((enregistrement) => capaciteDe(charge, enregistrement)), + tours: plan.length, + participants, + placement, + }; +} + +/** + * Une proposition de la charge, d'identifiant id, sur ce plan ; datée de + * horodatage, sans le champ quand il vaut null — un fichier écrit avant le + * champ. + */ +export function proposition(charge, id, plan, { siegesAttribues = false, horodatage = H_PROPOSITION } = {}) { + const p = { + id, + graine: id, + arret: 40, + historique: 500, + produitVersion: VERSION.affichee, + ...placementDe(charge, plan, { siegesAttribues }), + }; + return horodatage === null ? p : { ...p, horodatage }; +} + +/** + * Une soirée construite. personnes et tables sont les listes de la charge + * (personne, table) ; plan, le plan du retenu, null pour une soirée sans + * retenu ; propositions, des paires [id, plan] ; le retenu vient de la + * proposition 1, daté de horodatage (null : sans le champ). Les + * compteurs passent au-delà de chaque identifiant posé. + * + * @returns {import('../../../src/stockage/types.js').Charge} + */ +export function soiree({ + personnes, + tables, + plan = null, + siegesParDefaut = 8, + siegesAttribues = false, + etat = 'retenu', + horodatage = H_RETENU, + nom = 'Soirée des Lucioles', + date = '2031-05-17', + reservations = [], + titres = [], + propositions = [], +}) { + const tours = plan?.length ?? propositions[0]?.[1].length ?? 1; + const charge = creerCharge({ id: 'evt-lucioles', nom, date, siegesParDefaut, tours }); + charge.evenement.etat = etat; + charge.reglages.attribuerSieges = siegesAttribues; + charge.participants = personnes; + charge.tables = tables; + charge.reservations = reservations; + charge.titres = titres; + charge.propositions = propositions.map(([id, leur]) => proposition(charge, id, leur, { siegesAttribues })); + const ids = [1, ...propositions.map(([id]) => id)]; + charge.prochainsIds = { + participant: Math.max(0, ...personnes.map(({ id }) => id)) + 1, + table: Math.max(0, ...tables.map(({ id }) => id)) + 1, + proposition: Math.max(...ids) + 1, + }; + if (plan !== null) { + const retenu = { proposition: 1, ...placementDe(charge, plan, { siegesAttribues }) }; + charge.retenu = horodatage === null ? retenu : { ...retenu, horodatage }; + } + return charge; +} diff --git a/test/fixtures/badges/temoin.js b/test/fixtures/badges/temoin.js new file mode 100644 index 0000000..38597e2 --- /dev/null +++ b/test/fixtures/badges/temoin.js @@ -0,0 +1,75 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Le témoin de la planche (§ 15.1, § 11.5) : 260 personnes inventées, +// « Témoin 001 » à « Témoin 260 », dont 3 exclues, sur la salle de la grande +// démonstration — 33 tables, 29 de 8 sièges et 4 de 7 —, 4 tours, et un +// placement retenu, daté, sur un plan retenu. Les 257 badges qu'il produit +// donnent 65 planches A4 et 129 planches Lettre (§ 15.1). +// +// Chaque personne porte un courriel, des notes et un titre pressenti, que le +// badge ne doit jamais imprimer (§ 11.4), et une personne sur cinq une +// appartenance. Le plan du tour r range les 257 présentes à partir du rang +// 64 × (r − 1), en boucle, et remplit les tables dans l'ordre de leurs +// identifiants : chaque tour est une partition, les trois sièges libres +// tombent à la dernière table. Aucune réservation, aucun titre. Chaque appel +// rend une charge neuve. +import { creerCharge } from '../../../src/stockage/document.js'; +import { H_RETENU, personne, placementDe, table } from './cas.js'; + +/** Les comptes du témoin, que les épreuves lisent plutôt que de les écrire. */ +export const FORME_TEMOIN = Object.freeze({ + participants: 260, + exclus: 3, + produits: 257, + tables: 33, + tours: 4, + planchesA4: 65, + planchesLettre: 129, +}); + +/** Les identifiants des trois personnes exclues. */ +export const EXCLUS_TEMOIN = Object.freeze([50, 150, 250]); + +const DECALAGE_PAR_TOUR = 64; + +/** Le numéro d'une personne du témoin, sur trois chiffres. */ +export const numeroTemoin = (id) => String(id).padStart(3, '0'); + +/** + * La charge du témoin, état retenu, retenu daté de H_RETENU. + * + * @returns {import('../../../src/stockage/types.js').Charge} + */ +export function chargeTemoin() { + const { participants, tables, tours } = FORME_TEMOIN; + const charge = creerCharge({ id: 'evt-temoin', nom: 'Soirée des Lucioles', date: '2031-05-17', siegesParDefaut: 8, tours }); + charge.evenement.etat = 'retenu'; + charge.participants = Array.from({ length: participants }, (_, rang) => { + const id = rang + 1; + const n = numeroTemoin(id); + return personne(id, null, `Témoin ${n}`, { + appartenance: id % 5 === 0 ? 'Club des Merles' : null, + courriel: `temoin${n}@exemple.test`, + titrePressenti: `pressenti ${n}`, + notes: `note confidentielle ${n}`, + exclu: EXCLUS_TEMOIN.includes(id), + }); + }); + charge.tables = Array.from({ length: tables }, (_, rang) => table(rang + 1, rang + 1, rang < 29 ? null : 7)); + const presents = charge.participants.filter(({ exclu }) => !exclu).map(({ id }) => id); + const plan = Array.from({ length: tours }, (_, r) => { + const debut = (DECALAGE_PAR_TOUR * r) % presents.length; + const ordre = [...presents.slice(debut), ...presents.slice(0, debut)]; + let rang = 0; + const sieges = charge.tables.map((enregistrement) => { + const liste = ordre.slice(rang, rang + (enregistrement.sieges ?? 8)); + rang += liste.length; + return liste; + }); + return { sieges }; + }); + charge.prochainsIds = { participant: participants + 1, table: tables + 1, proposition: 2 }; + charge.retenu = { proposition: 1, ...placementDe(charge, plan), horodatage: H_RETENU }; + return charge; +}