gestion_table_tournante_libre/src/interface/plan/modele.test.js
Mathieu Benoit 811cd16b76 [ADD] interface: plan keyboard, tooltip, side panels, person menu, lists
Every gesture the pointer offers on the plan is reachable from the
keyboard (§ 7.3): one stop enters the plan, arrows move between tables and
seats, and the table and place panels choose a seat and keep the focus.
A tooltip names who sits where; a per-person menu moves, assigns or
reserves without dragging; the reserve and the work lists choose a person
to designate on the plan. Escape, a tour change and the executed command
forget the choice, and a click never moves the view.
Checked: 2537 node, 466 browser (one expected failure kept), 60 node-long.

--- FR ---

[ADD] interface : clavier du plan, infobulle, panneaux, menu, listes

Chaque geste que le pointeur offre sur le plan s'atteint au clavier
(§ 7.3) : un seul arrêt entre dans le plan, les flèches vont de table en
table et de siège en siège, et les panneaux de table et de place
choisissent un siège en gardant le focus. Une infobulle nomme qui est
assis où ; un menu par personne déplace, assigne ou réserve sans glisser ;
la réserve et les listes de travail choisissent une personne à désigner
sur le plan. Échap, un changement de tour et la commande exécutée
oublient le choix, et un clic ne déplace jamais la vue.
Vérifié : 2537 node, 466 navigateur (un échec attendu gardé), 60 node-long.

Assisted-by: Claude Opus 5.5
2026-10-07 11:08:59 -04:00

1017 lines
50 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves du modèle de dessin du plan (§ 7.3, § 7.4, § 7.5, § 7.6, § 7.7),
// sans DOM. Le plan lit les conflits de l'état des places, jamais ne les
// recalcule ; l'épreuve, elle, importe le moteur et le stockage pour
// construire ses données et obtenir la valeur attendue (§ 19.1). Les noms
// d'épreuve sont inventés ; la petite démonstration livrée se lit, et sa
// variante se dérive, en mémoire depuis texteLivre.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { describe, test } from '../../../test/lanceur.js';
import { TABLES_APPLICATION } from '../../application/libelles.js';
import { etatPlaces, instanceDe } from '../../application/places.js';
import { creerTraduction } from '../../application/traduction.js';
import { texteLivre } from '../../demo/livrees.js';
import { constante } from '../../geometrie/constantes.js';
import { regimeCibles } from '../../geometrie/designation.js';
import { geometrieTable } from '../../geometrie/tables.js';
import { indexerPlan } from '../../moteur/configuration.js';
import { conflitsParTable } from '../../moteur/conflits.js';
import { diagnostiquer } from '../../moteur/diagnostic.js';
import { rechercher } from '../../moteur/recherche.js';
import { serialiser } from '../../stockage/canonique.js';
import { analyser, configurationDepuisCharge, creerCharge } from '../../stockage/document.js';
import { planDepuisFichier, versFichier } from '../../stockage/placements.js';
import { TABLES_INTERFACE } from '../libelles.js';
import {
LARGEUR_ECHELLE_PX,
actionsPersonne,
actionsPlace,
ajusterLigne,
detailConflits,
executerSurPlace,
formesTable,
libelleLongueur,
ligneListe,
modeleDessin,
ordonneesLignes,
partiesLigne,
texteLigne,
textesLigne,
} from './modele.js';
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
const K_LISTES = constante('K_LISTES');
// --- Les configurations d'épreuve -------------------------------------------
// Douze noms inventés : prénom (null pour un nom seul) et nom.
const NOMS = [
['Iris', 'Ombrelle'],
['Théo', 'Pervenche'],
['Ondine', 'Lacasse'],
[null, 'Grisaille'],
['Basile', 'Tourmaline'],
['Capucine', 'Arrosoir'],
['Aurèle', 'Cerfeuil'],
['Mélisande', 'Bruyère'],
['Firmin', 'Galet'],
['Clélia', 'Mistral'],
['Anatole', 'Houblon'],
['Solveig', 'Sarcelle'],
];
const personne = (id, nom, prenom, appartenance) => ({
id,
nom,
prenom,
appartenance,
courriel: null,
titrePressenti: null,
notes: null,
exclu: false,
});
// Les propositions de rechercher(configuration, {graine, nombre, arret:
// 4000}), passées par versFichier et posées dans la charge ; le compteur
// passe au-delà du dernier identifiant, et le plan est proposé.
function avecPropositions(charge, { graine = 7, nombre = 1 } = {}) {
const configuration = configurationDepuisCharge(charge);
const propositions = rechercher(configuration, { graine, nombre, arret: 4000 });
charge.propositions = propositions.map((p) =>
versFichier(p, configuration, { produitVersion: 'epreuve', attribuerSieges: false, decalage: 0 }),
);
charge.prochainsIds.proposition = nombre + 1;
charge.evenement.etat = 'propose';
return charge;
}
/** Douze personnes en deux appartenances de six — une seule avec uneSeule —,
* quatre tables rondes de 3, quatre tours, séparation active, et une
* proposition de rechercher(…, {graine: 7, nombre: 1, arret: 4000}). */
function toutesEnConflit({ uneSeule = false } = {}) {
const charge = creerCharge({ id: 'epreuve:toutes-en-conflit', nom: 'Toutes en conflit', siegesParDefaut: 3, tours: 4 });
charge.participants = NOMS.map(([prenom, nom], i) =>
personne(i + 1, nom, prenom, uneSeule || i % 2 === 0 ? 'Club des Merles' : 'Cercle des Hérons'),
);
charge.tables = [1, 2, 3, 4].map((id) => ({ id, numero: id, sieges: null, forme: 'ronde', position: { x: 300 * (id - 1), y: 0 } }));
charge.prochainsIds = { participant: 13, table: 5, proposition: 1 };
return avecPropositions(charge);
}
const FIXTURE = new URL('../../../test/fixtures/plan/toutes_en_conflit.gtt.json', import.meta.url);
// La petite démonstration livrée, relue en mémoire.
const petite = (cle = 'petite') => analyser(texteLivre(cle)).charge;
// nomDe sur les participants de la charge : prénom et nom, ou le nom seul.
const nomsDe = (charge) => {
const noms = new Map(charge.participants.map(({ id, nom, prenom }) => [id, prenom === null ? nom : `${prenom} ${nom}`]));
return (id) => noms.get(id);
};
const options = (charge, autres = {}) => ({ k: 1, listes: true, miseEnEvidence: null, nomDe: nomsDe(charge), ...autres });
// Une copie de l'état des places où la table d'identifiant donné est saine.
function avecTableSaine(etat, id) {
return { ...etat, tables: etat.tables.map((table) => (table.id === id ? { ...table, conflits: { nombre: 0, regles: [] } } : table)) };
}
// --- Les conflits se lisent ---------------------------------------------------
describe('modeleDessin : les conflits se lisent, jamais ne se recalculent (§ 7.5)', () => {
test('petite démonstration, conflit inévitable : tables signalées et pastilles de chaque tour égalent conflitsParTable', () => {
const charge = avecPropositions(petite('petite-conflit'));
const instance = instanceDe(charge);
const attendus = conflitsParTable(instance, indexerPlan(instance, planDepuisFichier(charge.propositions[0])));
let signalees = 0;
for (let tour = 1; tour <= charge.evenement.tours; tour += 1) {
const etat = etatPlaces(charge, { tour, proposition: 1 });
const modele = modeleDessin(etat, options(charge));
assert.equal(modele.tables.length, 4);
const toutes = attendus[tour - 1].every(({ nombre }) => nombre > 0);
assert.equal(modele.signalerConflits, !toutes, `tour ${tour}`);
const nombreDe = new Map(attendus[tour - 1].map(({ table, nombre }) => [table, nombre]));
for (const table of modele.tables) {
const lu = etat.tables.find(({ id }) => id === table.id).conflits;
assert.equal(table.pastille, lu.nombre);
assert.equal(table.pastille, nombreDe.get(table.id), `tour ${tour}, table ${table.id}`);
assert.equal(table.conflit, lu.nombre > 0);
if (table.conflit && modele.signalerConflits) signalees += 1;
}
}
assert.ok(signalees > 0, 'la variante conflit signale au moins une table, à un tour où une autre est saine');
});
test('conflits null — non évalués — : ni conflit, ni pastille', () => {
const charge = petite();
const etat = etatPlaces(charge, { tour: 1 });
const sansEvaluation = { ...etat, evalue: false, tables: etat.tables.map((table) => ({ ...table, conflits: null })) };
const modele = modeleDessin(sansEvaluation, options(charge));
assert.equal(modele.tables.length, 4);
for (const table of modele.tables) {
assert.equal(table.conflit, false);
assert.equal(table.pastille, 0);
assert.deepEqual(table.nomAccessible.details.conflits, null);
}
assert.equal(modele.signalerConflits, true);
assert.deepEqual(modele.bandeau, []);
});
test('toutes en conflit : le fichier d\u{2019}épreuve se relit, et le plan cesse de signaler', () => {
const texte = readFileSync(FIXTURE, 'utf8');
const attendu = toutesEnConflit();
assert.equal(texte, serialiser(attendu, { revision: 1, produitVersion: 'epreuve' }), 'le fichier est la configuration construite');
const { charge } = analyser(texte);
for (let tour = 1; tour <= charge.evenement.tours; tour += 1) {
const etat = etatPlaces(charge, { tour, proposition: 1 });
const modele = modeleDessin(etat, options(charge));
assert.equal(modele.tables.length, 4);
assert.equal(modele.signalerConflits, false);
for (const table of modele.tables) {
assert.ok(table.conflit);
assert.ok(table.pastille > 0);
assert.equal(table.pastille, etat.tables.find(({ id }) => id === table.id).conflits.nombre);
}
assert.deepEqual(modele.bandeau, [{ cle: 'plan.bandeau.toutesEnConflit', details: {} }]);
const sain = modeleDessin(avecTableSaine(etat, 2), options(charge));
assert.equal(sain.signalerConflits, true, 'une table saine rétablit le signalement');
assert.deepEqual(sain.bandeau, []);
assert.deepEqual(
sain.tables.map(({ id, conflit }) => [id, conflit]),
[
[1, true],
[2, false],
[3, true],
[4, true],
],
);
}
});
test('aucune table : aucun dessin, un bandeau vide', () => {
const charge = creerCharge({ id: 'epreuve:vide', nom: 'Vide', siegesParDefaut: 6, tours: 1 });
const modele = modeleDessin(etatPlaces(charge, { tour: 1 }), options(charge));
assert.deepEqual(modele.tables, []);
assert.deepEqual(modele.bandeau, []);
assert.equal(modele.signalerConflits, true);
});
});
// --- Le bandeau énonce le diagnostic -------------------------------------------
describe('modeleDessin : le bandeau énonce le diagnostic (§ 7.5)', () => {
const lire = (charge) => etatPlaces(charge, { tour: 1, proposition: 1 });
test('deux appartenances de six : la phrase, collisionPartout, puis un plancher par groupe, chiffres du moteur', () => {
const charge = toutesEnConflit();
const diagnostic = diagnostiquer(configurationDepuisCharge(charge));
assert.equal(diagnostic.collisionPartout, true);
assert.equal(diagnostic.uneSeuleAppartenance, false);
assert.equal(diagnostic.collisions.length, 2);
const { bandeau } = modeleDessin(lire(charge), options(charge, { diagnostic }));
assert.deepEqual(bandeau, [
{ cle: 'plan.bandeau.toutesEnConflit', details: {} },
{ cle: 'plan.bandeau.collisionPartout', details: {} },
...diagnostic.collisions.map(({ groupe, effectif, plancherCumulees }) => ({
cle: 'plan.bandeau.plancherGroupe',
details: { groupe, effectif, plancherCumulees },
})),
]);
assert.deepEqual(
bandeau.slice(2).map(({ details }) => details.effectif),
[6, 6],
);
for (const { cle, details } of bandeau) assert.equal(typeof t(cle, details), 'string');
});
test('une seule appartenance : l\u{2019}entrée uneSeuleAppartenance, sans plancher par groupe', () => {
const charge = toutesEnConflit({ uneSeule: true });
const diagnostic = diagnostiquer(configurationDepuisCharge(charge));
assert.equal(diagnostic.uneSeuleAppartenance, true);
const { bandeau, signalerConflits } = modeleDessin(lire(charge), options(charge, { diagnostic }));
assert.equal(signalerConflits, false);
assert.deepEqual(
bandeau.map(({ cle }) => cle),
[
'plan.bandeau.toutesEnConflit',
...(diagnostic.collisionPartout ? ['plan.bandeau.collisionPartout'] : []),
'plan.bandeau.uneSeuleAppartenance',
],
);
});
test('un diagnostic sans plancher : la phrase, puis l\u{2019}entrée qui le dit', () => {
const charge = toutesEnConflit();
const diagnostic = { collisions: [], uneSeuleAppartenance: false, collisionPartout: false };
const { bandeau } = modeleDessin(lire(charge), options(charge, { diagnostic }));
assert.deepEqual(bandeau, [
{ cle: 'plan.bandeau.toutesEnConflit', details: {} },
{ cle: 'plan.bandeau.sansPlancher', details: {} },
]);
});
test('diagnostic null : la phrase seule', () => {
const charge = toutesEnConflit();
const { bandeau } = modeleDessin(lire(charge), options(charge, { diagnostic: null }));
assert.deepEqual(bandeau, [{ cle: 'plan.bandeau.toutesEnConflit', details: {} }]);
});
test('la petite démonstration : collisionPartout faux, son entrée absente', () => {
const diagnostic = diagnostiquer(configurationDepuisCharge(petite()));
assert.equal(diagnostic.collisionPartout, false);
const charge = toutesEnConflit();
const { bandeau } = modeleDessin(lire(charge), options(charge, { diagnostic }));
assert.equal(bandeau[0].cle, 'plan.bandeau.toutesEnConflit');
assert.ok(!bandeau.some(({ cle }) => cle === 'plan.bandeau.collisionPartout'));
});
test('une table saine : le bandeau reste vide, diagnostic donné ou non', () => {
const charge = toutesEnConflit();
const diagnostic = diagnostiquer(configurationDepuisCharge(charge));
const { bandeau } = modeleDessin(avecTableSaine(lire(charge), 3), options(charge, { diagnostic }));
assert.deepEqual(bandeau, []);
});
});
// --- La source --------------------------------------------------------------------
describe('modeleDessin : les lignes nomment les occupants de la source (contrat)', () => {
test('proposition 2 demandée → ses occupants ; sans demande → ceux du retenu, tiré de la proposition 1', () => {
const charge = avecPropositions(petite(), { graine: 11, nombre: 2 });
const [premiere] = charge.propositions;
const { siegesAttribues, tables, capacites, tours, participants, placement } = structuredClone(premiere);
charge.retenu = { proposition: 1, siegesAttribues, tables, capacites, tours, participants, placement };
charge.evenement.etat = 'retenu';
const nomDe = nomsDe(charge);
// Les noms des lignes de chaque table, dans l'ordre des tables.
const nomsDesLignes = (etat) => modeleDessin(etat, options(charge)).tables.map(({ lignes }) => lignes.map(({ nom }) => nom));
// Les noms qu'une proposition assied au tour 1, table par table.
const nomsDeLaSource = (p) => charge.tables.map(({ id }) => p.placement[0].sieges[p.tables.indexOf(id)].map(nomDe).sort());
const montree = etatPlaces(charge, { tour: 1, proposition: 2 });
assert.equal(montree.source, 'proposition');
const retenu = etatPlaces(charge, { tour: 1 });
assert.equal(retenu.source, 'retenu');
const deLaDeux = nomsDesLignes(montree).map((noms) => [...noms].sort());
const duRetenu = nomsDesLignes(retenu).map((noms) => [...noms].sort());
assert.deepEqual(deLaDeux, nomsDeLaSource(charge.propositions[1]));
assert.deepEqual(duRetenu, nomsDeLaSource(charge.retenu));
assert.notDeepEqual(deLaDeux, duRetenu, 'les deux sources assoient autrement au tour 1');
});
});
// --- Les lignes de la liste --------------------------------------------------------
// Une chaise de l'état des places, comme etatPlaces la rend.
const chaise = (siege, { occupant = null, reservation = null, titre = null } = {}) => ({
siege,
occupant,
reservation,
titre,
remplissage: 'normal',
marques: [],
});
const NOMS_LIGNES = new Map([
[1, 'Iris Ombrelle'],
[2, 'Théo Pervenche'],
[3, 'Ondine Lacasse'],
[4, 'Grisaille'],
]);
const nomDeLigne = (id) => NOMS_LIGNES.get(id);
const animateur = (pourvu) => ({ libelle: 'animateur', pourvu });
// Les six formes de la décision : chaise, ligne attendue, texte attendu.
const SIX_FORMES = [
[
chaise(1, { occupant: 1, reservation: { participant: 1, portee: 'tous', tour: null }, titre: animateur(true) }),
{ numero: 1, etat: { cle: 'plan.ligne.reserveeTous', details: {} }, titulaire: null, nom: 'Iris Ombrelle', titre: animateur(true) },
'1. (réservée, tous les tours) Iris Ombrelle \u{2014} animateur',
],
[
chaise(2, { occupant: 2, reservation: { participant: 2, portee: 'tour', tour: 2 } }),
{ numero: 2, etat: { cle: 'plan.ligne.reserveeTour', details: { tour: 2 } }, titulaire: null, nom: 'Théo Pervenche', titre: null },
'2. (réservée, tour 2) Théo Pervenche',
],
[chaise(3, { occupant: 3 }), { numero: 3, etat: null, titulaire: null, nom: 'Ondine Lacasse', titre: null }, '3. Ondine Lacasse'],
[
chaise(4, { occupant: 4, titre: animateur(false) }),
{ numero: 4, etat: null, titulaire: null, nom: 'Grisaille', titre: animateur(false) },
'4. Grisaille \u{2014} titre animateur non pourvu',
],
[chaise(5), { numero: 5, etat: { cle: 'plan.ligne.libre', details: {} }, titulaire: null, nom: null, titre: null }, '5. (libre)'],
[
chaise(6, { titre: animateur(false) }),
{ numero: 6, etat: { cle: 'plan.ligne.libre', details: {} }, titulaire: null, nom: null, titre: animateur(false) },
'6. (libre) \u{2014} titre animateur non pourvu',
],
];
// Le texte entier d'une ligne : ses parties dans l'ordre, les absentes omises.
const joindre = ({ tete, titulaire, suite, nom, avantTitre, titre, apresTitre }) =>
[tete, titulaire, suite, nom, avantTitre, titre, apresTitre].filter((partie) => partie !== null).join('');
describe('ligneListe et textesLigne : les six formes (§ 7.6)', () => {
test('chaque forme, exactement, titre valant {libelle, pourvu}', () => {
assert.equal(SIX_FORMES.length, 6);
for (const [entree, ligne, texte] of SIX_FORMES) {
assert.deepEqual(ligneListe(entree, nomDeLigne), ligne);
assert.equal(joindre(textesLigne(ligne, t)), texte);
}
});
test('le titre est sa propre partie, le libellé seul ; le nom aussi', () => {
const textes = textesLigne(SIX_FORMES[0][1], t);
assert.equal(textes.nom, 'Iris Ombrelle');
assert.equal(textes.titre, 'animateur');
assert.equal(textes.tete, '1. (réservée, tous les tours) ');
assert.equal(textes.teteSansNom, '1. (réservée, tous les tours)');
assert.equal(textesLigne(SIX_FORMES[2][1], t).titre, null);
});
});
// Mesures factices : une unité par unité de code, 1,5 en italique.
const droit = (chaine) => chaine.length;
const italique = (chaine) => 1.5 * chaine.length;
const MARQUE = '\u{2026}';
const largeurDe = (ligne) =>
droit(`${ligne.tete}${ligne.titulaire ?? ''}${ligne.suite ?? ''}${ligne.nom ?? ''}${ligne.avantTitre ?? ''}`) +
italique(ligne.titre ?? '') +
droit(ligne.apresTitre ?? '');
// --- Une place réservée qu'occupe une autre personne -----------------------------------
describe('ligneListe : la réservation d\u{2019}une autre personne que l\u{2019}occupant (§ 7.6)', () => {
test('petite démonstration, une réservation posée après la proposition : la ligne nomme sa titulaire, puis l\u{2019}occupant', () => {
const charge = petite();
const configuration = configurationDepuisCharge(charge);
const [proposition] = rechercher(configuration, { graine: 7, nombre: 1, arret: 1000 });
charge.propositions = [versFichier(proposition, configuration, { produitVersion: 'epreuve', attribuerSieges: false, decalage: 0 })];
charge.prochainsIds.proposition = 2;
charge.evenement.etat = 'propose';
charge.reservations.push({ participant: 1, table: 1, siege: 2, portee: 'tous', tour: null });
const etat = etatPlaces(charge, { tour: 1, proposition: 1 });
const chaise2 = etat.tables[0].chaises[1];
assert.equal(chaise2.reservation.participant, 1);
assert.equal(etat.tables[0].chaises.some(({ occupant }) => occupant === 1), false, 'la proposition n\u{2019}assied pas la titulaire à la table 1');
assert.notEqual(chaise2.occupant, null);
const nomDe = nomsDe(charge);
const titulaire = nomDe(1);
const occupant = nomDe(chaise2.occupant);
const ligne = ligneListe(chaise2, nomDe);
assert.deepEqual(ligne, {
numero: 2,
etat: { cle: 'plan.ligne.reserveeA', details: { portee: 'tous', tour: null } },
titulaire,
nom: occupant,
titre: null,
});
assert.deepEqual(modeleDessin(etat, options(charge)).tables[0].lignes[1], ligne);
const textes = textesLigne(ligne, t);
assert.equal(joindre(textes), `2. (réservée à ${titulaire}, tous les tours) ${occupant}`);
// La titulaire et l'occupant occupent chacun leur partie, donc leur tspan.
const parties = partiesLigne(ajusterLigne(textes, { mesurer: droit, mesurerItalique: italique, largeur: 1000, marque: MARQUE }));
assert.deepEqual(
parties.filter(({ partie }) => partie === 'titulaire' || partie === 'nom'),
[
{ partie: 'titulaire', texte: titulaire },
{ partie: 'nom', texte: occupant },
],
);
});
test('réservée au seul tour 3 pour une autre personne, la place vide : « 2. (réservée à …, tour 3) »', () => {
const ligne = ligneListe(chaise(2, { reservation: { participant: 1, portee: 'tour', tour: 3 } }), nomDeLigne);
assert.deepEqual(ligne, {
numero: 2,
etat: { cle: 'plan.ligne.reserveeA', details: { portee: 'tour', tour: 3 } },
titulaire: 'Iris Ombrelle',
nom: null,
titre: null,
});
assert.equal(joindre(textesLigne(ligne, t)), '2. (réservée à Iris Ombrelle, tour 3)');
});
test('trop étroite : le nom de l\u{2019}occupant s\u{2019}abrège, puis tombe ; la titulaire s\u{2019}abrège, dans sa partie', () => {
const entree = textesLigne(
ligneListe(chaise(2, { occupant: 2, reservation: { participant: 1, portee: 'tous', tour: null } }), nomDeLigne),
t,
);
const pleine = joindre(entree).length;
const abregee = ajusterLigne(entree, { mesurer: droit, mesurerItalique: italique, largeur: pleine - 3, marque: MARQUE });
assert.equal(abregee.titulaire, 'Iris Ombrelle');
assert.ok(abregee.nom.endsWith(MARQUE));
assert.ok(largeurDe(abregee) <= pleine - 3);
const sansNom = ajusterLigne(entree, { mesurer: droit, mesurerItalique: italique, largeur: 40, marque: MARQUE });
assert.equal(sansNom.nom, null);
assert.ok(sansNom.titulaire.endsWith(MARQUE), sansNom.titulaire);
assert.equal(sansNom.suite, entree.suiteSansNom);
assert.ok(largeurDe(sansNom) <= 40, `${largeurDe(sansNom)}`);
});
});
// --- La ligne entière tient dans la largeur du bloc ------------------------------------
describe('ajusterLigne : la ligne entière tient (§ 7.4)', () => {
const textes = (nom, titre = null, pourvu = true) =>
textesLigne({ numero: 1, etat: null, nom, titre: titre === null ? null : { libelle: titre, pourvu } }, t);
test('une ligne qui tient reste entière', () => {
const entree = textes('Iris Ombrelle', 'animateur');
const ligne = ajusterLigne(entree, { mesurer: droit, mesurerItalique: italique, largeur: 100, marque: MARQUE });
assert.deepEqual(ligne, {
tete: entree.tete,
titulaire: null,
suite: null,
nom: entree.nom,
avantTitre: entree.avantTitre,
titre: entree.titre,
apresTitre: null,
});
});
test('le nom s\u{2019}abrège à la largeur que lui laissent les autres parties, mesurées chacune dans son style', () => {
const entree = textes('W'.repeat(200), 'animateur');
// tête « 1. » + espace : 3 ; « — » entouré : 3 ; titre en italique : 13,5.
const ligne = ajusterLigne(entree, { mesurer: droit, mesurerItalique: italique, largeur: 40, marque: MARQUE });
assert.equal(ligne.titre, 'animateur');
assert.ok(ligne.nom.endsWith(MARQUE));
assert.ok(largeurDe(ligne) <= 40);
assert.ok(largeurDe(ligne) > 39, `le nom prend toute la place restante : ${largeurDe(ligne)}`);
});
test('sans place pour la marque, le nom tombe et le titre s\u{2019}abrège', () => {
const entree = textes('Iris Ombrelle', 'a'.repeat(30), false);
const ligne = ajusterLigne(entree, { mesurer: droit, mesurerItalique: italique, largeur: 40, marque: MARQUE });
assert.equal(ligne.nom, null);
assert.equal(ligne.tete, entree.teteSansNom);
assert.ok(ligne.titre.endsWith(MARQUE));
assert.equal(ligne.apresTitre, entree.apresTitre);
assert.ok(largeurDe(ligne) <= 40);
});
test('sans place pour le titre, il tombe avec ce qui l\u{2019}entoure ; une tête trop large s\u{2019}abrège', () => {
const entree = textesLigne(
{ numero: 1, etat: { cle: 'plan.ligne.reserveeTous', details: {} }, nom: 'Iris Ombrelle', titre: { libelle: 'animateur', pourvu: true } },
t,
);
const etroite = ajusterLigne(entree, { mesurer: droit, mesurerItalique: italique, largeur: 20, marque: MARQUE });
assert.deepEqual([etroite.nom, etroite.avantTitre, etroite.titre, etroite.apresTitre], [null, null, null, null]);
assert.ok(etroite.tete.startsWith('1. (r'));
assert.ok(etroite.tete.endsWith(MARQUE));
assert.ok(largeurDe(etroite) <= 20);
});
test('chaque ligne d\u{2019}un bloc ajusté tient, quelle que soit la largeur', () => {
const lignes = SIX_FORMES.map(([, ligne]) => textesLigne(ligne, t));
assert.equal(lignes.length, 6);
for (const largeur of [5, 12, 20, 40, 80, 200]) {
for (const entree of lignes) {
const ligne = ajusterLigne(entree, { mesurer: droit, mesurerItalique: italique, largeur, marque: MARQUE });
assert.ok(largeurDe(ligne) <= largeur, `${joindre(entree)} à ${largeur} : ${largeurDe(ligne)}`);
}
}
});
});
describe('ordonneesLignes : une ligne sous la précédente', () => {
test('la première ligne sous le haut du bloc, d\u{2019}une hauteur de ligne ; puis une hauteur de plus chacune', () => {
assert.deepEqual(ordonneesLignes(-50, 3, 20), [-30, -10, 10]);
assert.deepEqual(ordonneesLignes(0, 0, 20), []);
});
});
// --- Régime, listes, marques, bords ------------------------------------------------------
// Une charge de tables rondes de capacités données, des personnes inventées.
function chargeDeTables(capacites, { tours = 1 } = {}) {
const charge = creerCharge({ id: 'epreuve:tables', nom: 'Tables', siegesParDefaut: 8, tours });
charge.tables = capacites.map((sieges, i) => ({
id: i + 1,
numero: i + 1,
sieges,
forme: i % 2 === 0 ? 'ronde' : 'carree',
position: { x: 400 * i, y: 0 },
}));
charge.participants = NOMS.map(([prenom, nom], i) => personne(i + 1, nom, prenom, null));
charge.prochainsIds = { participant: 13, table: capacites.length + 1, proposition: 1 };
return charge;
}
describe('modeleDessin : le dessin d\u{2019}une table (§ 7.3, § 7.6, § 17 point 6)', () => {
test('géométrie de geometrieTable ; chaises dessinées en régime sièges, aucune en régime table', () => {
const charge = chargeDeTables([8, 12, 2]);
const etat = etatPlaces(charge, { tour: 1 });
for (const k of [0.39, 0.41, 2]) {
const modele = modeleDessin(etat, options(charge, { k }));
assert.equal(modele.tables.length, 3);
for (const table of modele.tables) {
const enregistrement = charge.tables.find(({ id }) => id === table.id);
const geometrie = geometrieTable(enregistrement, etat.tables.find(({ id }) => id === table.id).capacite);
assert.deepEqual(table.geometrie, geometrie);
assert.equal(table.regime, regimeCibles(k, geometrie));
if (table.regime === 'table') assert.deepEqual(table.chaises, []);
else {
assert.equal(table.chaises.length, geometrie.chaises.length);
table.chaises.forEach((dessin, i) => {
const { siege, x, y, rayon } = geometrie.chaises[i];
assert.deepEqual({ siege: dessin.siege, x: dessin.x, y: dessin.y, rayon: dessin.rayon }, { siege, x, y, rayon });
});
}
}
}
const regimes = (k) => modeleDessin(etat, options(charge, { k })).tables.map(({ regime }) => regime);
assert.deepEqual(regimes(0.39), ['table', 'table', 'sieges']);
assert.deepEqual(regimes(0.41), ['sieges', 'sieges', 'sieges']);
});
test('listes rendues : demandées et k ≥ K_LISTES', () => {
const charge = chargeDeTables([8]);
const etat = etatPlaces(charge, { tour: 1 });
assert.equal(modeleDessin(etat, options(charge, { k: K_LISTES - 0.01 })).listesRendues, false);
assert.equal(modeleDessin(etat, options(charge, { k: K_LISTES })).listesRendues, true);
assert.equal(modeleDessin(etat, options(charge, { k: K_LISTES + 0.01 })).listesRendues, true);
assert.equal(modeleDessin(etat, options(charge, { k: 5, listes: false })).listesRendues, false);
assert.equal('largeurListe' in modeleDessin(etat, options(charge)), false, 'la largeur du bloc est celle du registre');
});
test('une ligne par siège, de 1 à la capacité, quel que soit le régime', () => {
const charge = chargeDeTables([8, 2]);
const etat = etatPlaces(charge, { tour: 1 });
const modele = modeleDessin(etat, options(charge, { k: 0.1 }));
assert.deepEqual(
modele.tables.map(({ lignes }) => lignes.map(({ numero }) => numero)),
[
[1, 2, 3, 4, 5, 6, 7, 8],
[1, 2],
],
);
});
test('remplissage, marques et mise en évidence reprennent l\u{2019}état des places, au tour montré', () => {
const charge = chargeDeTables([8], { tours: 2 });
charge.reservations = [
{ participant: 1, table: 1, siege: 1, portee: 'tous', tour: null },
{ participant: 2, table: 1, siege: 3, portee: 'tour', tour: 1 },
];
charge.titres = [
{ table: 1, siege: 1, libelle: 'animateur' },
{ table: 1, siege: 2, libelle: 'animateur' },
];
for (const tour of [1, 2]) {
const etat = etatPlaces(charge, { tour });
const modele = modeleDessin(etat, options(charge, { miseEnEvidence: 1 }));
const [table] = modele.tables;
assert.equal(table.chaises.length, 8);
table.chaises.forEach((dessin, i) => {
const lue = etat.tables[0].chaises[i];
assert.equal(dessin.remplissage, lue.remplissage);
assert.deepEqual(dessin.marques, lue.marques);
assert.equal(dessin.miseEnEvidence, lue.occupant === 1);
});
assert.equal(table.chaises.filter(({ miseEnEvidence }) => miseEnEvidence).length, 1);
}
const [tour1] = modeleDessin(etatPlaces(charge, { tour: 1 }), options(charge)).tables;
assert.deepEqual(tour1.chaises.slice(0, 3).map(({ marques }) => marques), [['cadenasFerme', 'anneau'], ['cadenasOuvert'], ['cadenasFerme']]);
assert.deepEqual(tour1.chaises.slice(0, 3).map(({ remplissage }) => remplissage), ['plein', 'hachure', 'plein']);
assert.ok(tour1.chaises.every(({ miseEnEvidence }) => miseEnEvidence === false), 'aucune mise en évidence demandée');
});
test('une table gelée le dit dans son nom accessible et en tête de sa liste ; une surchargée, dans son nom', () => {
const charge = chargeDeTables([2, 3]);
charge.reservations = [
{ participant: 1, table: 1, siege: 1, portee: 'tous', tour: null },
{ participant: 2, table: 1, siege: 2, portee: 'tous', tour: null },
];
charge.evenement.siegesParDefaut = 3;
const etat = etatPlaces(charge, { tour: 1 });
assert.equal(etat.tables[0].gelee, true);
const [gelee, autre] = modeleDessin(etat, options(charge)).tables;
assert.equal(gelee.gelee, true);
assert.deepEqual(gelee.enTete, { cle: 'plan.liste.gelee', details: {} });
assert.equal(gelee.nomAccessible.cle, 'plan.table.nom');
assert.match(t(gelee.nomAccessible.cle, gelee.nomAccessible.details), /gelée/);
assert.match(t(gelee.nomAccessible.cle, gelee.nomAccessible.details), /surchargée/);
assert.equal(autre.gelee, false);
assert.equal(autre.enTete, null);
assert.doesNotMatch(t(autre.nomAccessible.cle, autre.nomAccessible.details), /gelée/);
assert.match(t(autre.nomAccessible.cle, autre.nomAccessible.details), /^Table 2, 3 places, .*surchargée$/);
});
test('sièges non attribués, deux titres et une réservation « tous » sans siège : la réservée au siège 1, pourvue ; « 2. (libre) — titre … non pourvu »', () => {
const charge = chargeDeTables([4]);
charge.participants = charge.participants.slice(0, 1);
charge.prochainsIds.participant = 2;
charge.reservations = [{ participant: 1, table: 1, siege: null, portee: 'tous', tour: null }];
charge.titres = [
{ table: 1, siege: 1, libelle: 'animateur' },
{ table: 1, siege: 2, libelle: 'animateur' },
];
const etat = etatPlaces(charge, { tour: 1 });
const [table] = modeleDessin(etat, options(charge)).tables;
const textes = table.lignes.map((ligne) => joindre(textesLigne(ligne, t)));
assert.deepEqual(textes, [
'1. (réservée, tous les tours) Iris Ombrelle \u{2014} animateur',
'2. (libre) \u{2014} titre animateur non pourvu',
'3. (libre)',
'4. (libre)',
]);
});
});
describe('libelleLongueur : la longueur de la barre en mots (§ 7.7)', () => {
test('cm sous le mètre, m sous le kilomètre, km au-delà ; décimales d\u{2019}une longueur sous l\u{2019}unité', () => {
assert.deepEqual(libelleLongueur(50), { valeur: 50, decimales: 0, cle: 'plan.echelle.centimetres' });
assert.deepEqual(libelleLongueur(200), { valeur: 2, decimales: 0, cle: 'plan.echelle.metres' });
assert.deepEqual(libelleLongueur(500000), { valeur: 5, decimales: 0, cle: 'plan.echelle.kilometres' });
assert.deepEqual(libelleLongueur(0.5), { valeur: 0.5, decimales: 1, cle: 'plan.echelle.centimetres' });
assert.deepEqual(libelleLongueur(0.02), { valeur: 0.02, decimales: 2, cle: 'plan.echelle.centimetres' });
assert.deepEqual(libelleLongueur(0.1), { valeur: 0.1, decimales: 1, cle: 'plan.echelle.centimetres' });
assert.deepEqual(libelleLongueur(0.01), { valeur: 0.01, decimales: 2, cle: 'plan.echelle.centimetres' });
assert.deepEqual(libelleLongueur(1), { valeur: 1, decimales: 0, cle: 'plan.echelle.centimetres' });
assert.ok(LARGEUR_ECHELLE_PX > 0);
});
});
describe('formesTable : les formes d\u{2019}une table, en attributs SVG (§ 7.1, § 7.7)', () => {
test('ronde : disques du plateau et de l\u{2019}emprise ; carrée : carrés aux côtés parallèles aux axes', () => {
const ronde = geometrieTable({ id: 1, numero: 1, forme: 'ronde', position: { x: 100, y: -40 } }, 8);
const formes = formesTable(ronde);
assert.equal(formes.ronde, true);
assert.deepEqual(formes.plateau, { cx: 100, cy: -40, r: ronde.demiTaille });
assert.deepEqual(formes.emprise, { cx: 100, cy: -40, r: ronde.rayonEmprise });
const carree = geometrieTable({ id: 2, numero: 2, forme: 'carree', position: { x: 0, y: 0 } }, 12);
const deCarree = formesTable(carree);
assert.equal(deCarree.ronde, false);
assert.deepEqual(deCarree.plateau, { x: -90, y: -90, width: 180, height: 180 });
assert.deepEqual(deCarree.emprise, {
x: -carree.rayonEmprise,
y: -carree.rayonEmprise,
width: 2 * carree.rayonEmprise,
height: 2 * carree.rayonEmprise,
});
});
test('la pastille au centre du plateau ; la marque de gel dans le plateau, au-dessus de la pastille', () => {
for (const places of [2, 8, 16]) {
const geometrie = geometrieTable({ id: 1, numero: 1, forme: 'ronde', position: { x: 10, y: 20 } }, places);
const { pastille, gel } = formesTable(geometrie);
assert.deepEqual(pastille, { x: 10, y: 20 });
assert.equal(gel.x, 10);
assert.ok(gel.y < 20 && gel.y > 20 - geometrie.demiTaille, `${places} places : ${gel.y}`);
}
});
});
describe('partiesLigne : les tspans d\u{2019}une ligne, dans l\u{2019}ordre', () => {
test('chaque partie présente, nommée ; les absentes omises', () => {
const ligne = { tete: '1. ', titulaire: null, suite: null, nom: 'Iris Ombrelle', avantTitre: ' \u{2014} ', titre: 'animateur', apresTitre: null };
assert.deepEqual(partiesLigne(ligne), [
{ partie: 'tete', texte: '1. ' },
{ partie: 'nom', texte: 'Iris Ombrelle' },
{ partie: 'avantTitre', texte: ' \u{2014} ' },
{ partie: 'titre', texte: 'animateur' },
]);
assert.deepEqual(partiesLigne({ tete: '5. (libre)', titulaire: null, suite: null, nom: null, avantTitre: null, titre: null, apresTitre: null }), [
{ partie: 'tete', texte: '5. (libre)' },
]);
});
});
// --- L'option poignee (I10) ------------------------------------------------------
describe('modeleDessin : l\u{2019}option poignee dessine une table au nombre tiré, sans toucher l\u{2019}état (§ 7.1)', () => {
test('sans option, ou poignee null : chaque table à sa capacité', () => {
const charge = petite();
const etat = etatPlaces(charge, { tour: 1 });
const sans = modeleDessin(etat, options(charge));
assert.deepEqual(modeleDessin(etat, options(charge, { poignee: null })), sans);
assert.equal(sans.tables.length, 4);
for (const table of sans.tables) assert.equal(table.lignes.length, etat.tables.find(({ id }) => id === table.id).capacite);
});
test('tirée vers plus : la table se dessine à places, chaises 1 à places, celles au-delà de sa capacité libres', () => {
const charge = petite();
const etat = etatPlaces(charge, { tour: 1 });
const avant = JSON.stringify(etat);
const cible = etat.tables[1];
const capacite = cible.capacite;
const places = capacite + 3;
const modele = modeleDessin(etat, options(charge, { poignee: { table: cible.id, places } }));
assert.equal(JSON.stringify(etat), avant, 'l\u{2019}état des places n\u{2019}est pas touché');
const tiree = modele.tables[1];
assert.equal(tiree.id, cible.id);
assert.deepEqual(tiree.geometrie, geometrieTable(cible, places));
assert.equal(tiree.chaises.length, places);
assert.deepEqual(tiree.chaises.map(({ siege }) => siege), Array.from({ length: places }, (_, i) => i + 1));
for (const chaise of tiree.chaises.slice(capacite)) {
assert.equal(chaise.remplissage, 'normal');
assert.deepEqual(chaise.marques, []);
assert.equal(chaise.miseEnEvidence, false);
}
assert.equal(tiree.lignes.length, places);
for (const ligne of tiree.lignes.slice(capacite)) {
assert.deepEqual(ligne.etat, { cle: 'plan.ligne.libre', details: {} });
assert.equal(ligne.nom, null);
}
assert.deepEqual(tiree.lignes.slice(0, capacite), cible.chaises.map((chaise) => ligneListe(chaise, nomsDe(charge))));
assert.equal(tiree.nomAccessible.details.places, places);
const autres = modeleDessin(etat, options(charge));
for (const rang of [0, 2, 3]) assert.deepEqual(modele.tables[rang], autres.tables[rang], `table de rang ${rang} inchangée`);
});
test('tirée vers moins : chaises et lignes 1 à places ; le régime se lit sur la géométrie tirée', () => {
const charge = petite();
const etat = etatPlaces(charge, { tour: 1 });
const cible = etat.tables[0];
const modele = modeleDessin(etat, options(charge, { poignee: { table: cible.id, places: 2 } }));
const tiree = modele.tables[0];
assert.equal(tiree.chaises.length, 2);
assert.equal(tiree.lignes.length, 2);
assert.equal(tiree.geometrie.chaises.length, 2);
for (const k of [0.2, 0.5, 1]) {
const auZoom = modeleDessin(etat, options(charge, { k, poignee: { table: cible.id, places: 30 } })).tables[0];
assert.equal(auZoom.regime, regimeCibles(k, geometrieTable(cible, 30)), `k = ${k}`);
}
});
});
// --- La sélection et l'élément actif (I11) ---------------------------------------
describe('modeleDessin : les options selection et actif marquent leur chaise, ou leur table, et elle seule (§ 7.1, § 7.3)', () => {
// Les porteurs d'une marque : 'table:<id>' ou 'siege:<id>:<siège>'.
function porteurs(modele, marque) {
const trouves = [];
for (const table of modele.tables) {
if (table[marque]) trouves.push(`table:${table.id}`);
for (const chaise of table.chaises) if (chaise[marque]) trouves.push(`siege:${table.id}:${chaise.siege}`);
}
return trouves;
}
test('sans option, ni sélection ni focus ; chaque table et chaque chaise porte les deux drapeaux, faux', () => {
const charge = petite();
const modele = modeleDessin(etatPlaces(charge, { tour: 1 }), options(charge));
assert.equal(modele.tables.length, 4);
let chaises = 0;
for (const table of modele.tables) {
assert.equal(table.selection, false);
assert.equal(table.focus, false);
for (const chaise of table.chaises) {
chaises += 1;
assert.equal(chaise.selection, false);
assert.equal(chaise.focus, false);
}
}
assert.ok(chaises > 0);
});
test('régime sièges : un siège marque sa chaise, une place sans siège marque sa table', () => {
const charge = petite();
const etat = etatPlaces(charge, { tour: 1 });
const [, deuxieme, troisieme] = etat.tables;
const modele = modeleDessin(etat, options(charge, { selection: { table: deuxieme.id, siege: 2 }, actif: { table: troisieme.id, siege: null } }));
assert.equal(modele.tables[1].regime, 'sieges');
assert.deepEqual(porteurs(modele, 'selection'), [`siege:${deuxieme.id}:2`]);
assert.deepEqual(porteurs(modele, 'focus'), [`table:${troisieme.id}`]);
const memePlace = modeleDessin(etat, options(charge, { selection: { table: deuxieme.id, siege: 1 }, actif: { table: deuxieme.id, siege: 1 } }));
assert.deepEqual(porteurs(memePlace, 'selection'), [`siege:${deuxieme.id}:1`]);
assert.deepEqual(porteurs(memePlace, 'focus'), [`siege:${deuxieme.id}:1`]);
});
test('régime table : un siège, que nulle chaise ne dessine, marque sa table', () => {
const charge = petite();
const etat = etatPlaces(charge, { tour: 1 });
const cible = etat.tables[0];
const modele = modeleDessin(etat, options(charge, { k: 0.2, selection: { table: cible.id, siege: 2 }, actif: { table: cible.id, siege: 3 } }));
assert.equal(modele.tables[0].regime, 'table');
assert.deepEqual(porteurs(modele, 'selection'), [`table:${cible.id}`]);
assert.deepEqual(porteurs(modele, 'focus'), [`table:${cible.id}`]);
});
test('une place d’une table absente ne marque rien', () => {
const charge = petite();
const modele = modeleDessin(etatPlaces(charge, { tour: 1 }), options(charge, { selection: { table: 99, siege: 1 }, actif: { table: 99, siege: null } }));
assert.deepEqual(porteurs(modele, 'selection'), []);
assert.deepEqual(porteurs(modele, 'focus'), []);
});
});
// --- Le détail d'une table en conflit (I11) --------------------------------------
describe('detailConflits : le détail que rend le moteur, nommé (§ 7.1, § 7.3)', () => {
test('petite démonstration en conflit : chaque règle violée, ses paires ou ses personnes, dans l’ordre du moteur', () => {
const charge = avecPropositions(petite('petite-conflit'));
const nomDe = nomsDe(charge);
let vus = 0;
for (let tour = 1; tour <= charge.evenement.tours; tour += 1) {
for (const table of etatPlaces(charge, { tour, proposition: 1 }).tables) {
const detail = detailConflits(table, nomDe);
if (table.conflits.nombre === 0) {
assert.equal(detail, null);
continue;
}
vus += 1;
assert.deepEqual(detail, {
numero: table.numero,
nombre: table.conflits.nombre,
regles: table.conflits.regles.map((regle) => ({
cle: `plan.infobulle.${regle.regle}`,
groupes: (regle.paires ?? regle.personnes.map((id) => [id])).map((groupe) => groupe.map(nomDe)),
})),
});
for (const { cle } of detail.regles) assert.ok(typeof t(cle) === 'string' && t(cle).length > 0, cle);
}
}
assert.ok(vus > 0, 'au moins une table en conflit');
});
test('conflits non évalués, ou nuls : null', () => {
const charge = petite();
const [table] = etatPlaces(charge, { tour: 1 }).tables;
assert.equal(detailConflits({ ...table, conflits: null }, nomsDe(charge)), null);
assert.equal(detailConflits({ ...table, conflits: { nombre: 0, regles: [] } }, nomsDe(charge)), null);
});
});
// --- Le texte entier d'une ligne, et les actions des panneaux (I11) --------------
describe('texteLigne : la ligne d’une chaise en un texte, nom accessible du siège', () => {
const nomDe = (id) => ({ 1: 'Iris Ombrelle', 2: 'Théo Pervenche' })[id];
const chaise = (autres) => ({ siege: 3, occupant: null, reservation: null, titre: null, ...autres });
test('les parties de textesLigne, bout à bout', () => {
const cas = [
[chaise({}), '3. (libre)'],
[chaise({ occupant: 1 }), '3. Iris Ombrelle'],
[
chaise({ occupant: 1, reservation: { participant: 1, portee: 'tous', tour: null }, titre: { libelle: 'animateur', pourvu: true } }),
'3. (réservée, tous les tours) Iris Ombrelle \u{2014} animateur',
],
[chaise({ titre: { libelle: 'hôte', pourvu: false } }), '3. (libre) \u{2014} titre hôte non pourvu'],
[chaise({ occupant: 2, reservation: { participant: 1, portee: 'tour', tour: 2 } }), '3. (réservée à Iris Ombrelle, tour 2) Théo Pervenche'],
];
for (const [entree, attendu] of cas) assert.equal(texteLigne(ligneListe(entree, nomDe), t), attendu);
});
});
describe('actionsPlace : ce qu’offre le panneau d’une place (§ 7.1, § 2.1 étape 6)', () => {
const libre = { siege: 1, occupant: null, reservation: null, titre: null };
const tenue = { siege: 2, occupant: 1, reservation: { participant: 1, portee: 'tous', tour: null }, titre: { libelle: 'hôte', pourvu: true } };
const reservation = { genre: 'reservation', participant: 1, depuis: { table: 1, siege: 2 }, de: { table: 1, portee: 'tous', tour: null } };
test('une place libre : assigner, réserver, donner un titre, désigner — exactement', () => {
assert.deepEqual(actionsPlace(libre, null), ['assigner', 'reserver', 'donnerTitre', 'designer']);
assert.deepEqual(actionsPlace(libre, { genre: 'personne', participant: 2, depuis: null }), ['assigner', 'reserver', 'donnerTitre', 'designer']);
});
test('une place réservée et titrée : retirer la réservation, retirer le titre ; une personne choisie y ajoute assigner, que la pose refusera', () => {
assert.deepEqual(actionsPlace(tenue, null), ['retirerReservation', 'retirerTitre']);
assert.deepEqual(actionsPlace(tenue, reservation), ['retirerReservation', 'retirerTitre', 'deplacerReservationIci']);
assert.deepEqual(actionsPlace(tenue, { genre: 'personne', participant: 2, depuis: null }), ['assigner', 'retirerReservation', 'retirerTitre']);
});
test('une place titrée, non tenue : assigner, réserver, désigner, retirer le titre', () => {
assert.deepEqual(actionsPlace({ ...libre, titre: { libelle: 'hôte', pourvu: false } }, null), ['assigner', 'reserver', 'designer', 'retirerTitre']);
});
test('une réservation choisie ajoute « Déplacer la réservation ici »', () => {
assert.deepEqual(actionsPlace(libre, reservation), ['assigner', 'reserver', 'donnerTitre', 'designer', 'deplacerReservationIci']);
});
});
describe('actionsPersonne : le menu d’une personne (§ 7.1)', () => {
const assise = { siege: 2, occupant: 4, reservation: null, titre: null };
const retrait = { commande: 'retirerDuTour', arguments: { tour: 1, participant: 4 } };
test('assise, non réservée : déplacer, échanger, retirer du tour, réserver ici, exclure, ouvrir la fiche', () => {
assert.deepEqual(actionsPersonne({ exclu: false, chaise: assise, retrait }), [
'deplacer',
'echanger',
'retirerDuTour',
'reserverIci',
'exclure',
'ouvrirFiche',
]);
});
test('assise sur sa réservation : déplacer sa réservation, plus de « réserver ici » ; sans retrait possible, pas de « retirer du tour »', () => {
const tenue = { ...assise, reservation: { participant: 4, portee: 'tous', tour: null } };
assert.deepEqual(actionsPersonne({ exclu: false, chaise: tenue, retrait: null }), ['deplacer', 'echanger', 'deplacerReservation', 'exclure', 'ouvrirFiche']);
});
test('hors du dessin — réserve, exclue — : déplacer, puis exclure ou réintégrer, et la fiche', () => {
assert.deepEqual(actionsPersonne({ exclu: false, chaise: null, retrait: null }), ['deplacer', 'exclure', 'ouvrirFiche']);
assert.deepEqual(actionsPersonne({ exclu: true, chaise: null, retrait: null }), ['deplacer', 'reintegrer', 'ouvrirFiche']);
});
});
describe('executerSurPlace : la commande d’un panneau, son refus posé sur la place (§ 5.9)', () => {
// Une séance et un état de navigation d'épreuve, qui relèvent leurs appels.
function dependances(executer) {
const appels = [];
return {
appels,
seance: { executer: async (nom, args) => (appels.push(['executer', nom, args]), executer(nom, args)) },
ui: {
choisir: (choix) => appels.push(['choisir', choix]),
poserSurPlace: (pose) => appels.push(['poserSurPlace', pose]),
},
};
}
const refus = (code, details = {}) => Object.assign(new Error(code), { code, details });
const place = { table: 2, siege: 3 };
test('réussie : rend le rendu de la séance, oublie le choix en attente et retire la pose', async () => {
const d = dependances(() => ({ libelle: 'ok', avertissements: [] }));
assert.deepEqual(await executerSurPlace(d, place, 'reserver', { participant: 1 }), { libelle: 'ok', avertissements: [] });
assert.deepEqual(d.appels, [['executer', 'reserver', { participant: 1 }], ['choisir', null], ['poserSurPlace', null]]);
});
test('refusée : le refus, code et détails, posé sur la place ; rien d’autre ; null', async () => {
const d = dependances(() => {
throw refus('PLACE_RESERVEE', { table: 2, siege: 3 });
});
assert.equal(await executerSurPlace(d, place, 'reserver', {}), null);
assert.deepEqual(d.appels.slice(1), [
['poserSurPlace', { place, refus: { code: 'PLACE_RESERVEE', details: { table: 2, siege: 3 } }, avertissements: [] }],
]);
});
test('un code local, une place absente, ou une erreur sans code remontent à l’appelant', async () => {
for (const [lieu, erreur, options] of [
[place, refus('LIBELLE_VIDE'), { locaux: ['LIBELLE_VIDE'] }],
[null, refus('SANS_EFFET'), {}],
[place, new TypeError('défaut'), {}],
]) {
const d = dependances(() => {
throw erreur;
});
await assert.rejects(executerSurPlace(d, lieu, 'poserTitre', {}, options), (recue) => recue === erreur);
assert.deepEqual(d.appels.map(([nom]) => nom), ['executer']);
}
});
});
describe('les clés que les panneaux composent existent (§ 14.6)', () => {
test('plan.action.<action>, plan.menu.<action>, plan.infobulle.<règle> : chaque valeur possible a sa clé', () => {
const tenue = { siege: 1, occupant: 1, reservation: { participant: 1, portee: 'tous', tour: null }, titre: { libelle: 'hôte', pourvu: true } };
const libre = { siege: 1, occupant: null, reservation: null, titre: null };
const reservation = { genre: 'reservation', participant: 1, depuis: null, de: { table: 1, portee: 'tous', tour: null } };
const actions = new Set([...actionsPlace(libre, reservation), ...actionsPlace(tenue, { genre: 'personne', participant: 2, depuis: null })]);
const menu = new Set([
...actionsPersonne({ exclu: false, chaise: { ...libre, occupant: 4 }, retrait: {} }),
...actionsPersonne({ exclu: true, chaise: tenue, retrait: {} }),
]);
assert.equal(actions.size, 7);
assert.equal(menu.size, 8);
for (const action of actions) assert.equal(typeof t(`plan.action.${action}`), 'string', action);
for (const action of menu) assert.equal(typeof t(`plan.menu.${action}`), 'string', action);
for (const regle of ['separerAppartenances', 'nouveauxVoisins', 'nouvelleTable']) assert.equal(typeof t(`plan.infobulle.${regle}`), 'string');
});
});