[ADD] badges: sheet model from the retained plan or a chosen proposal
Badges hang on guests' necks: their content must be exact and stable. The model builds one badge per non-excluded person, from the retained plan or, on demand, from a proposal, which always prints as a draft. Each round says table, seat when seats are assigned, and a title only where a reservation makes the person its holder there; a constant itinerary takes one line. Names sort by code point, never by locale; namesakes carry their id, and a reprint gives the same texts. Without any placement it refuses, pointing at the proposals. Checked: red first; 3343 node tests green from the index alone. --- FR --- [ADD] badges : modèle de planche, du retenu ou d'une proposition choisie Les badges pendent au cou des invités : leur contenu doit être exact et stable. Le modèle fait un badge par personne non exclue, depuis le retenu ou, sur demande, une proposition, toujours imprimée en brouillon. Chaque tour dit la table, le siège sous attribution, et un titre seulement là où une réservation en fait la personne titulaire ; un itinéraire constant tient en une ligne. Les noms se classent par points de code, jamais selon la locale ; les homonymes portent leur identifiant, et une réédition rend les mêmes textes. Sans aucun placement, il refuse et renvoie aux propositions. Vérifié : rouge d'abord ; 3343 node verts depuis l'index seul. Assisted-by: Claude Opus 5.5
This commit is contained in:
parent
270c27f7ed
commit
4e25e73a5d
7 changed files with 1514 additions and 3 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
128
src/application/libelles/planche.js
Normal file
128
src/application/libelles/planche.js
Normal file
|
|
@ -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)}`,
|
||||
],
|
||||
]);
|
||||
441
src/application/planche.js
Normal file
441
src/application/planche.js
Normal file
|
|
@ -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;
|
||||
}
|
||||
705
src/application/planche.test.js
Normal file
705
src/application/planche.test.js
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
149
test/fixtures/badges/cas.js
vendored
Normal file
149
test/fixtures/badges/cas.js
vendored
Normal file
|
|
@ -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<Array<number|null>>, 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;
|
||||
}
|
||||
75
test/fixtures/badges/temoin.js
vendored
Normal file
75
test/fixtures/badges/temoin.js
vendored
Normal file
|
|
@ -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;
|
||||
}
|
||||
Loading…
Reference in a new issue