Compare commits
2 commits
82e57f47df
...
abae4fa9b8
| Author | SHA1 | Date | |
|---|---|---|---|
| abae4fa9b8 | |||
| f76c5dede5 |
31 changed files with 9024 additions and 282 deletions
157
src/application/avertissements.js
Normal file
157
src/application/avertissements.js
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Les avertissements qu'un geste fait naître (§ 5.9, § 9, § 14.12), calculés
|
||||||
|
// de la charge d'avant à celle d'après, et les violations du retenu que la
|
||||||
|
// séance montre tant que la charge les porte. Un avertissement n'empêche
|
||||||
|
// rien : il dit ce que le geste a changé, avec le nombre.
|
||||||
|
//
|
||||||
|
// Aucun calcul ne lève sur un placement. Les raisons de dérive se lisent sur
|
||||||
|
// examiner (S2), qui les rend pour tout placement cohérent, dérivé compris ;
|
||||||
|
// le retenu ne se juge contre l'instance par verifierInvariants que quand
|
||||||
|
// examiner ne lui trouve aucune faute, et que la configuration se normalise.
|
||||||
|
// Ni indexerPlan ni derive n'entrent ici : le premier lève sur un plan qui ne
|
||||||
|
// décrit plus la configuration, le second sur un placement fautif.
|
||||||
|
import { ErreurConfiguration } from '../moteur/erreurs.js';
|
||||||
|
import { nombrePlacesManquantes, normaliser } from '../moteur/configuration.js';
|
||||||
|
import { verifierInvariants } from '../moteur/verification.js';
|
||||||
|
import { configurationDepuisCharge } from '../stockage/document.js';
|
||||||
|
import { examiner, planDepuisFichier } from '../stockage/placements.js';
|
||||||
|
|
||||||
|
const avertissement = (code, details) => ({ code, details });
|
||||||
|
|
||||||
|
// L'instance de la charge, ou l'ErreurConfiguration qui la refuse :
|
||||||
|
// { instance } ou { refus }. Une autre erreur remonte.
|
||||||
|
function instanceDe(charge) {
|
||||||
|
try {
|
||||||
|
return { instance: normaliser(configurationDepuisCharge(charge)), refus: null };
|
||||||
|
} catch (erreur) {
|
||||||
|
if (!(erreur instanceof ErreurConfiguration)) throw erreur;
|
||||||
|
return { instance: null, refus: erreur };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deux objets plats égaux champ pour champ : mêmes clés, mêmes valeurs.
|
||||||
|
function memesChamps(a, b) {
|
||||||
|
const cles = Object.keys(a);
|
||||||
|
return cles.length === Object.keys(b).length && cles.every((cle) => Object.hasOwn(b, cle) && Object.is(a[cle], b[cle]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Les raisons de dérive de chaque placement d'un examen : les propositions
|
||||||
|
// par identifiant croissant, puis le retenu sous la clé 'retenu'.
|
||||||
|
function raisonsParPlacement(examen) {
|
||||||
|
const raisons = new Map(examen.derives.map(({ id, raisons: liste }) => [id, liste]));
|
||||||
|
raisons.set('retenu', examen.retenu.raisons);
|
||||||
|
return raisons;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Les violations du retenu cohérent d'une charge, contre son instance, la
|
||||||
|
// réserve admise : des surnuméraires versés en réserve ne violent rien.
|
||||||
|
// [] sans retenu, pour un retenu fautif, ou sans instance.
|
||||||
|
function violationsDuRetenu(charge, examen, instance) {
|
||||||
|
if (charge.retenu === null || examen.retenu.fautes.length > 0 || instance === null) return [];
|
||||||
|
return verifierInvariants(instance, planDepuisFichier(charge.retenu), { reserveAutorisee: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const memeRaison = (code, cle, valeur) => (raison) => raison.code === code && raison[cle] === valeur;
|
||||||
|
|
||||||
|
// La raison de dérive qui nomme déjà la violation, ou undefined : la
|
||||||
|
// personne exclue, supprimée ou non placée ; la table supprimée, ajoutée, ou
|
||||||
|
// dont la capacité a changé ; le nombre de tours changé.
|
||||||
|
function raisonQuiLaNomme(violation, raisons) {
|
||||||
|
const { code, participant, table, detail } = violation;
|
||||||
|
if (code === 'EXCLU_PLACE') return raisons.find(memeRaison('PARTICIPANT_EXCLU', 'participant', participant));
|
||||||
|
if (code === 'INCONNU') return raisons.find(memeRaison('PARTICIPANT_SUPPRIME', 'participant', participant));
|
||||||
|
if (code === 'NON_ASSIS') return raisons.find(memeRaison('PARTICIPANT_NON_PLACE', 'participant', participant));
|
||||||
|
if (code === 'CAPACITE') return raisons.find(memeRaison('CAPACITE_CHANGEE', 'table', table));
|
||||||
|
if (code === 'TOURS') return raisons.find((raison) => raison.code === 'TOURS_CHANGES');
|
||||||
|
if (code === 'TABLE_INCONNUE' && detail === 'inconnue') return raisons.find(memeRaison('TABLE_SUPPRIMEE', 'table', table));
|
||||||
|
if (code === 'TABLE_INCONNUE' && detail === 'absente') return raisons.find(memeRaison('TABLE_AJOUTEE', 'table', table));
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Les violations du retenu de la charge que la dérive ne nomme pas déjà
|
||||||
|
* (§ 14.12, § 14.15) : INVARIANT_VIOLE {proposition, violations}, la
|
||||||
|
* proposition d'origine du retenu et les violations de verifierInvariants
|
||||||
|
* dans leur ordre, réserve admise ; null quand il n'en reste aucune, sans
|
||||||
|
* retenu, pour un retenu fautif, ou sur une configuration qui ne se
|
||||||
|
* normalise pas. Une réservation que le retenu ne tient pas en est le cas
|
||||||
|
* ordinaire : la dérive ne compare pas les réservations.
|
||||||
|
*
|
||||||
|
* @param {import('../stockage/types.js').Charge} charge
|
||||||
|
* @param {import('../stockage/placements.js').Examen} [examen] celui de la
|
||||||
|
* charge, examiner(charge) quand il manque
|
||||||
|
* @returns {{code: 'INVARIANT_VIOLE', details: {proposition: number, violations: object[]}}|null}
|
||||||
|
*/
|
||||||
|
export function invariantViole(charge, examen = examiner(charge)) {
|
||||||
|
const { instance } = instanceDe(charge);
|
||||||
|
const raisons = examen.retenu.raisons;
|
||||||
|
const violations = violationsDuRetenu(charge, examen, instance).filter(
|
||||||
|
(violation) => raisonQuiLaNomme(violation, raisons) === undefined,
|
||||||
|
);
|
||||||
|
if (violations.length === 0) return null;
|
||||||
|
return avertissement('INVARIANT_VIOLE', { proposition: charge.retenu.proposition, violations });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Avertissements d'un geste, de la charge d'avant à celle d'après (§ 5.9,
|
||||||
|
* § 9), dans cet ordre. Aucun ne lève sur un placement : les raisons se
|
||||||
|
* lisent sur examiner(avant) et examiner(apres) (S2), et planDepuisFichier
|
||||||
|
* ne reçoit le retenu que quand examiner(…).retenu.fautes est vide.
|
||||||
|
*
|
||||||
|
* CONFIGURATION_INCOHERENTE {code, details} normaliser(configurationDepuisCharge(apres))
|
||||||
|
* lève, et celle d'avant se normalisait ; PLACES_MANQUANTES et
|
||||||
|
* RESERVATION_NON_HONOREE, qui lisent l'instance d'après, ne se calculent
|
||||||
|
* alors pas ;
|
||||||
|
* PLACES_MANQUANTES {placesManquantes} nombrePlacesManquantes de l'instance
|
||||||
|
* d'après, non nul et plus grand que celui d'avant (0 quand avant ne se
|
||||||
|
* normalisait pas) ;
|
||||||
|
* DERIVE_NOUVELLE {touchees: [{id|'retenu', raisons}]} par placement, les
|
||||||
|
* raisons d'examiner(apres) — derives, retenu.raisons — qu'examiner(avant)
|
||||||
|
* ne portait pas, comparées champ pour champ ; les propositions par
|
||||||
|
* identifiant, puis le retenu, sous id 'retenu' ;
|
||||||
|
* RESERVATION_NON_HONOREE {participant, table, tour} chaque violation
|
||||||
|
* RESERVATION de verifierInvariants(instance d'après,
|
||||||
|
* planDepuisFichier(apres.retenu)) absente du même calcul sur avant, qui
|
||||||
|
* n'en compte aucune quand son retenu est nul ou fautif, ou que sa
|
||||||
|
* configuration ne se normalisait pas.
|
||||||
|
*
|
||||||
|
* @param {import('../stockage/types.js').Charge} avant
|
||||||
|
* @param {import('../stockage/types.js').Charge} apres
|
||||||
|
* @returns {Array<{code: string, details: Object}>}
|
||||||
|
*/
|
||||||
|
export function avertissementsApres(avant, apres) {
|
||||||
|
const rendus = [];
|
||||||
|
const examenAvant = examiner(avant);
|
||||||
|
const examenApres = examiner(apres);
|
||||||
|
const instanceAvant = instanceDe(avant).instance;
|
||||||
|
const { instance, refus } = instanceDe(apres);
|
||||||
|
|
||||||
|
if (refus !== null && instanceAvant !== null) {
|
||||||
|
rendus.push(avertissement('CONFIGURATION_INCOHERENTE', { code: refus.code, details: refus.details }));
|
||||||
|
}
|
||||||
|
if (instance !== null) {
|
||||||
|
const manque = nombrePlacesManquantes(instance);
|
||||||
|
const manqueAvant = instanceAvant === null ? 0 : nombrePlacesManquantes(instanceAvant);
|
||||||
|
if (manque > 0 && manque > manqueAvant) rendus.push(avertissement('PLACES_MANQUANTES', { placesManquantes: manque }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const anciennes = raisonsParPlacement(examenAvant);
|
||||||
|
const touchees = [];
|
||||||
|
for (const [id, raisons] of raisonsParPlacement(examenApres)) {
|
||||||
|
const connues = anciennes.get(id) ?? [];
|
||||||
|
const nouvelles = raisons.filter((raison) => !connues.some((connue) => memesChamps(connue, raison)));
|
||||||
|
if (nouvelles.length > 0) touchees.push({ id, raisons: nouvelles });
|
||||||
|
}
|
||||||
|
if (touchees.length > 0) rendus.push(avertissement('DERIVE_NOUVELLE', { touchees }));
|
||||||
|
|
||||||
|
const reservations = (charge, examen, inst) =>
|
||||||
|
violationsDuRetenu(charge, examen, inst).filter(({ code }) => code === 'RESERVATION');
|
||||||
|
const tenues = reservations(avant, examenAvant, instanceAvant);
|
||||||
|
for (const { participant, table, tour } of reservations(apres, examenApres, instance)) {
|
||||||
|
const dejaLa = tenues.some((connue) => connue.participant === participant && connue.table === table && connue.tour === tour);
|
||||||
|
if (!dejaLa) rendus.push(avertissement('RESERVATION_NON_HONOREE', { participant, table, tour }));
|
||||||
|
}
|
||||||
|
return rendus;
|
||||||
|
}
|
||||||
323
src/application/avertissements.test.js
Normal file
323
src/application/avertissements.test.js
Normal file
|
|
@ -0,0 +1,323 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Épreuves des avertissements d'un geste (§ 5.9, § 9, § 14.12), sur des
|
||||||
|
// charges construites à la main : le manque de places qui croît, la
|
||||||
|
// configuration qui ne se normalise plus, la dérive qu'un geste ajoute, la
|
||||||
|
// réservation que le retenu n'honore plus, et les violations du retenu qui
|
||||||
|
// subsistent hors de la dérive. La valeur attendue du manque vient du moteur
|
||||||
|
// lui-même, sur la configuration rendue. Les noms d'épreuve sont inventés.
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { describe, test } from '../../test/lanceur.js';
|
||||||
|
import { nombrePlacesManquantes, normaliser } from '../moteur/configuration.js';
|
||||||
|
import { configurationDepuisCharge, creerCharge } from '../stockage/document.js';
|
||||||
|
import { VERSION } from '../version.genere.js';
|
||||||
|
import { avertissementsApres, invariantViole } from './avertissements.js';
|
||||||
|
|
||||||
|
const personne = (id, nom, exclu = false) => ({
|
||||||
|
id,
|
||||||
|
nom,
|
||||||
|
prenom: null,
|
||||||
|
appartenance: null,
|
||||||
|
courriel: null,
|
||||||
|
titrePressenti: null,
|
||||||
|
notes: null,
|
||||||
|
exclu,
|
||||||
|
});
|
||||||
|
|
||||||
|
const table = (id, sieges) => ({ id, numero: id, sieges, forme: 'ronde', position: { x: 300 * (id - 1), y: 0 } });
|
||||||
|
|
||||||
|
// Une copie modifiée : celle reçue ne change pas.
|
||||||
|
function avec(charge, modifier) {
|
||||||
|
const copie = structuredClone(charge);
|
||||||
|
modifier(copie);
|
||||||
|
return copie;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ajoute à la charge les convives de rang premier à dernier, inclus.
|
||||||
|
function ajouterConvives(charge, premier, dernier) {
|
||||||
|
for (let rang = premier; rang <= dernier; rang += 1) charge.participants.push(personne(rang, `Convive ${rang}`));
|
||||||
|
charge.prochainsIds.participant = dernier + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deux tables de huit et seize convives : la salle est pleine.
|
||||||
|
function sallePleine() {
|
||||||
|
const charge = creerCharge({ id: 'evt-merles', nom: 'Soirée des Merles', siegesParDefaut: 8, tours: 2 });
|
||||||
|
charge.tables = [table(1, 8), table(2, 8)];
|
||||||
|
charge.prochainsIds = { participant: 1, table: 3, proposition: 1 };
|
||||||
|
ajouterConvives(charge, 1, 16);
|
||||||
|
return charge;
|
||||||
|
}
|
||||||
|
|
||||||
|
const manqueDu = (charge) => nombrePlacesManquantes(normaliser(configurationDepuisCharge(charge)));
|
||||||
|
|
||||||
|
// Quatre personnes, deux tables de deux, deux tours ; Iris Ombrelle réservée
|
||||||
|
// à la table 1 pour tous les tours. Le placement de la proposition 1 : Iris
|
||||||
|
// et Grisaille à la table 1 au premier tour, Iris et Pervenche au second.
|
||||||
|
const PLACEMENT = [
|
||||||
|
{ sieges: [[1, 2], [3, 4]], reserve: [] },
|
||||||
|
{ sieges: [[1, 3], [2, 4]], reserve: [] },
|
||||||
|
];
|
||||||
|
const plan = () => ({ tables: [1, 2], capacites: [2, 2], tours: 2, participants: [1, 2, 3, 4], placement: structuredClone(PLACEMENT) });
|
||||||
|
const proposition = (id) => ({
|
||||||
|
id,
|
||||||
|
graine: id,
|
||||||
|
arret: 40,
|
||||||
|
historique: 1,
|
||||||
|
produitVersion: VERSION.affichee,
|
||||||
|
siegesAttribues: false,
|
||||||
|
...plan(),
|
||||||
|
});
|
||||||
|
|
||||||
|
function petiteSoiree({ retenu = true } = {}) {
|
||||||
|
const charge = creerCharge({ id: 'evt-lucioles', nom: 'Soirée des Lucioles', siegesParDefaut: 2, tours: 2 });
|
||||||
|
charge.participants = [
|
||||||
|
personne(1, 'Ombrelle'),
|
||||||
|
personne(2, 'Grisaille'),
|
||||||
|
personne(3, 'Pervenche'),
|
||||||
|
personne(4, 'Lacasse'),
|
||||||
|
];
|
||||||
|
charge.tables = [table(1, null), table(2, null)];
|
||||||
|
charge.reservations = [{ participant: 1, table: 1, siege: null, portee: 'tous', tour: null }];
|
||||||
|
charge.propositions = [proposition(1), proposition(2)];
|
||||||
|
charge.retenu = retenu ? { proposition: 1, siegesAttribues: false, ...plan() } : null;
|
||||||
|
charge.prochainsIds = { participant: 5, table: 3, proposition: 3 };
|
||||||
|
charge.evenement.etat = retenu ? 'retenu' : 'propose';
|
||||||
|
return charge;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grisaille réservée à la table 2 au premier tour : le placement l'assoit à
|
||||||
|
// la table 1 à ce tour.
|
||||||
|
const reserverGrisaille = (charge) =>
|
||||||
|
charge.reservations.push({ participant: 2, table: 2, siege: null, portee: 'tour', tour: 1 });
|
||||||
|
|
||||||
|
// Le retenu se contredit : la personne 2 en réserve au premier tour, alors
|
||||||
|
// qu'elle y est déjà assise (DOUBLON).
|
||||||
|
const rendreFautif = (charge) => {
|
||||||
|
charge.retenu.placement[0].reserve = [2];
|
||||||
|
};
|
||||||
|
|
||||||
|
const codes = (avertissements) => avertissements.map(({ code }) => code);
|
||||||
|
|
||||||
|
describe('avertissements : le manque de places (§ 5.9)', () => {
|
||||||
|
test('la dix-septième personne : PLACES_MANQUANTES {placesManquantes: 1}, le nombre du moteur', () => {
|
||||||
|
const avant = sallePleine();
|
||||||
|
const apres = avec(avant, (charge) => ajouterConvives(charge, 17, 17));
|
||||||
|
assert.equal(manqueDu(avant), 0);
|
||||||
|
const rendu = avertissementsApres(avant, apres);
|
||||||
|
assert.deepEqual(rendu, [{ code: 'PLACES_MANQUANTES', details: { placesManquantes: 1 } }]);
|
||||||
|
assert.equal(rendu[0].details.placesManquantes, manqueDu(apres));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('trois de plus : {placesManquantes: 4}, le compte de la configuration rendue', () => {
|
||||||
|
const avant = avec(sallePleine(), (charge) => ajouterConvives(charge, 17, 17));
|
||||||
|
const apres = avec(avant, (charge) => ajouterConvives(charge, 18, 20));
|
||||||
|
const rendu = avertissementsApres(avant, apres);
|
||||||
|
assert.deepEqual(rendu, [{ code: 'PLACES_MANQUANTES', details: { placesManquantes: 4 } }]);
|
||||||
|
assert.equal(manqueDu(apres), 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un geste qui ne fait pas croître le manque n'en dit rien", () => {
|
||||||
|
const pleine = sallePleine();
|
||||||
|
const manque = avec(pleine, (charge) => ajouterConvives(charge, 17, 18));
|
||||||
|
const cas = [
|
||||||
|
[pleine, avec(pleine, (charge) => (charge.participants[0].nom = 'Ancolie'))],
|
||||||
|
[manque, avec(manque, (charge) => (charge.participants[0].nom = 'Ancolie'))],
|
||||||
|
[manque, avec(manque, (charge) => charge.participants.pop())],
|
||||||
|
[manque, avec(manque, (charge) => (charge.tables[0].sieges = 10))],
|
||||||
|
[manque, avec(manque, (charge) => (charge.participants[16].exclu = true))],
|
||||||
|
];
|
||||||
|
assert.equal(cas.length, 5);
|
||||||
|
for (const [avant, apres] of cas) assert.deepEqual(avertissementsApres(avant, apres), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ôter une table, ou baisser une capacité, fait croître le manque et le dit', () => {
|
||||||
|
const pleine = sallePleine();
|
||||||
|
assert.deepEqual(avertissementsApres(pleine, avec(pleine, (charge) => charge.tables.pop())), [
|
||||||
|
{ code: 'PLACES_MANQUANTES', details: { placesManquantes: 8 } },
|
||||||
|
]);
|
||||||
|
assert.deepEqual(avertissementsApres(pleine, avec(pleine, (charge) => (charge.tables[1].sieges = 6))), [
|
||||||
|
{ code: 'PLACES_MANQUANTES', details: { placesManquantes: 2 } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('avertissements : une configuration qui ne se normalise plus (§ 5.9)', () => {
|
||||||
|
// Iris réservée aussi à la table 2 au premier tour : deux tables au même
|
||||||
|
// tour, RESERVATION_CONFLIT.
|
||||||
|
const enConflit = (charge) =>
|
||||||
|
charge.reservations.push({ participant: 1, table: 2, siege: null, portee: 'tour', tour: 1 });
|
||||||
|
|
||||||
|
test("la configuration rendue lève : CONFIGURATION_INCOHERENTE porte le code et les détails du moteur, et le manque ne se calcule pas", () => {
|
||||||
|
const avant = sallePleine();
|
||||||
|
avant.reservations = [{ participant: 1, table: 1, siege: null, portee: 'tous', tour: null }];
|
||||||
|
const apres = avec(avant, (charge) => {
|
||||||
|
enConflit(charge);
|
||||||
|
ajouterConvives(charge, 17, 19);
|
||||||
|
});
|
||||||
|
assert.deepEqual(avertissementsApres(avant, apres), [
|
||||||
|
{
|
||||||
|
code: 'CONFIGURATION_INCOHERENTE',
|
||||||
|
details: { code: 'RESERVATION_CONFLIT', details: { participant: 1, tour: 1, tables: [1, 2] } },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("déjà incohérente avant le geste : rien ne se répète ; redevenue cohérente, le manque se compte depuis zéro", () => {
|
||||||
|
const avant = sallePleine();
|
||||||
|
avant.reservations = [{ participant: 1, table: 1, siege: null, portee: 'tous', tour: null }];
|
||||||
|
enConflit(avant);
|
||||||
|
const encore = avec(avant, (charge) => ajouterConvives(charge, 17, 17));
|
||||||
|
assert.deepEqual(avertissementsApres(avant, encore), []);
|
||||||
|
const reparee = avec(encore, (charge) => charge.reservations.pop());
|
||||||
|
assert.deepEqual(avertissementsApres(encore, reparee), [{ code: 'PLACES_MANQUANTES', details: { placesManquantes: 1 } }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("une configuration hors de sa forme n'est pas une incohérence : la TypeError du moteur remonte", () => {
|
||||||
|
const avant = sallePleine();
|
||||||
|
const apres = avec(avant, (charge) => (charge.participants[0].id = 'un'));
|
||||||
|
assert.throws(() => avertissementsApres(avant, apres), TypeError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('avertissements : la dérive que le geste ajoute (§ 9, § 8.9)', () => {
|
||||||
|
test('exclure une personne : DERIVE_NOUVELLE nomme chaque proposition par identifiant, puis le retenu, et la raison', () => {
|
||||||
|
const avant = petiteSoiree();
|
||||||
|
const apres = avec(avant, (charge) => (charge.participants[2].exclu = true));
|
||||||
|
const raisons = [{ code: 'PARTICIPANT_EXCLU', participant: 3 }];
|
||||||
|
assert.deepEqual(avertissementsApres(avant, apres), [
|
||||||
|
{
|
||||||
|
code: 'DERIVE_NOUVELLE',
|
||||||
|
details: { touchees: [{ id: 1, raisons }, { id: 2, raisons }, { id: 'retenu', raisons }] },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("une raison que le placement portait déjà ne se redit pas ; une raison au champ changé est nouvelle", () => {
|
||||||
|
const une = avec(petiteSoiree(), (charge) => (charge.participants[2].exclu = true));
|
||||||
|
const deux = avec(une, (charge) => (charge.participants[3].exclu = true));
|
||||||
|
const raisons = [{ code: 'PARTICIPANT_EXCLU', participant: 4 }];
|
||||||
|
assert.deepEqual(avertissementsApres(une, deux), [
|
||||||
|
{ code: 'DERIVE_NOUVELLE', details: { touchees: [{ id: 1, raisons }, { id: 2, raisons }, { id: 'retenu', raisons }] } },
|
||||||
|
]);
|
||||||
|
assert.deepEqual(avertissementsApres(deux, avec(deux, (charge) => (charge.participants[0].nom = 'Parasol'))), []);
|
||||||
|
const trois = avec(petiteSoiree(), (charge) => (charge.tables[0].sieges = 3));
|
||||||
|
const quatre = avec(trois, (charge) => (charge.tables[0].sieges = 4));
|
||||||
|
const changee = [{ code: 'CAPACITE_CHANGEE', table: 1, avant: 2, maintenant: 4 }];
|
||||||
|
assert.deepEqual(avertissementsApres(trois, quatre), [
|
||||||
|
{
|
||||||
|
code: 'DERIVE_NOUVELLE',
|
||||||
|
details: { touchees: [{ id: 1, raisons: changee }, { id: 2, raisons: changee }, { id: 'retenu', raisons: changee }] },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un retenu fautif ne dit rien de lui : ni dérive, ni réservation non honorée, et rien ne lève", () => {
|
||||||
|
const avant = avec(petiteSoiree(), rendreFautif);
|
||||||
|
const apres = avec(avant, (charge) => {
|
||||||
|
charge.participants[2].exclu = true;
|
||||||
|
reserverGrisaille(charge);
|
||||||
|
});
|
||||||
|
const raisons = [{ code: 'PARTICIPANT_EXCLU', participant: 3 }];
|
||||||
|
assert.deepEqual(avertissementsApres(avant, apres), [
|
||||||
|
{ code: 'DERIVE_NOUVELLE', details: { touchees: [{ id: 1, raisons }, { id: 2, raisons }] } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("avertissements : la réservation que le retenu n'honore plus (§ 5.8, § 14.12)", () => {
|
||||||
|
test('une réservation posée après la retenue, que le retenu ne tient pas : RESERVATION_NON_HONOREE {participant, table, tour}', () => {
|
||||||
|
const avant = petiteSoiree();
|
||||||
|
const apres = avec(avant, reserverGrisaille);
|
||||||
|
assert.deepEqual(avertissementsApres(avant, apres), [
|
||||||
|
{ code: 'RESERVATION_NON_HONOREE', details: { participant: 2, table: 2, tour: 1 } },
|
||||||
|
]);
|
||||||
|
assert.deepEqual(avertissementsApres(apres, avec(apres, (charge) => (charge.participants[0].nom = 'Parasol'))), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("retenir une proposition qui ne tient pas une réservation : le retenu nul d'avant n'en comptait aucune", () => {
|
||||||
|
const avant = avec(petiteSoiree({ retenu: false }), reserverGrisaille);
|
||||||
|
const apres = avec(avant, (charge) => {
|
||||||
|
charge.retenu = { proposition: 1, siegesAttribues: false, ...plan() };
|
||||||
|
charge.evenement.etat = 'retenu';
|
||||||
|
});
|
||||||
|
assert.deepEqual(avertissementsApres(avant, apres), [
|
||||||
|
{ code: 'RESERVATION_NON_HONOREE', details: { participant: 2, table: 2, tour: 1 } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("le retenu redevenu cohérent compte ses violations contre un avant fautif, qui n'en comptait aucune", () => {
|
||||||
|
const avant = avec(avec(petiteSoiree(), reserverGrisaille), rendreFautif);
|
||||||
|
const apres = avec(avant, (charge) => (charge.retenu.placement[0].reserve = []));
|
||||||
|
assert.deepEqual(avertissementsApres(avant, apres), [
|
||||||
|
{ code: 'RESERVATION_NON_HONOREE', details: { participant: 2, table: 2, tour: 1 } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("l'ordre du contrat : le manque, puis la dérive, puis la réservation non honorée", () => {
|
||||||
|
const avant = petiteSoiree();
|
||||||
|
const apres = avec(avant, (charge) => {
|
||||||
|
charge.participants.push(personne(5, 'Ancolie'));
|
||||||
|
charge.prochainsIds.participant = 6;
|
||||||
|
reserverGrisaille(charge);
|
||||||
|
});
|
||||||
|
assert.deepEqual(codes(avertissementsApres(avant, apres)), ['PLACES_MANQUANTES', 'DERIVE_NOUVELLE', 'RESERVATION_NON_HONOREE']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('avertissements : les violations du retenu hors de la dérive (§ 14.12, § 14.15)', () => {
|
||||||
|
test('une réservation non honorée : INVARIANT_VIOLE {proposition, violations}, sans les violations que la dérive nomme', () => {
|
||||||
|
const charge = avec(petiteSoiree(), (copie) => {
|
||||||
|
reserverGrisaille(copie);
|
||||||
|
copie.participants[3].exclu = true;
|
||||||
|
});
|
||||||
|
assert.deepEqual(invariantViole(charge), {
|
||||||
|
code: 'INVARIANT_VIOLE',
|
||||||
|
details: { proposition: 1, violations: [{ code: 'RESERVATION', participant: 2, table: 2, tour: 1 }] },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("chaque violation que la dérive nomme se tait : exclus, supprimés, non placés, tables, capacités, tours", () => {
|
||||||
|
const cas = [
|
||||||
|
(charge) => (charge.participants[3].exclu = true),
|
||||||
|
(charge) => charge.participants.pop(),
|
||||||
|
(charge) => {
|
||||||
|
charge.participants.push(personne(5, 'Ancolie'));
|
||||||
|
charge.prochainsIds.participant = 6;
|
||||||
|
},
|
||||||
|
(charge) => {
|
||||||
|
charge.tables.push(table(3, null));
|
||||||
|
charge.prochainsIds.table = 4;
|
||||||
|
},
|
||||||
|
(charge) => {
|
||||||
|
charge.tables[1].sieges = 2;
|
||||||
|
charge.retenu.capacites = [2, 3];
|
||||||
|
for (const tour of charge.retenu.placement) tour.sieges = [[1], [2, 3, 4]];
|
||||||
|
},
|
||||||
|
(charge) => {
|
||||||
|
charge.tables[1].id = 3;
|
||||||
|
charge.prochainsIds.table = 4;
|
||||||
|
},
|
||||||
|
(charge) => (charge.evenement.tours = 3),
|
||||||
|
];
|
||||||
|
assert.equal(cas.length, 7);
|
||||||
|
for (const modifier of cas) assert.equal(invariantViole(avec(petiteSoiree(), modifier)), null, String(modifier));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un retenu aux surnuméraires versés en réserve est sans violation ; sans retenu, ou fautif, ou sur une configuration incohérente, rien', () => {
|
||||||
|
const reserve = avec(petiteSoiree(), (charge) => {
|
||||||
|
charge.participants.push(personne(5, 'Ancolie'));
|
||||||
|
charge.prochainsIds.participant = 6;
|
||||||
|
charge.retenu.participants = [1, 2, 3, 4, 5];
|
||||||
|
for (const tour of charge.retenu.placement) tour.reserve = [5];
|
||||||
|
});
|
||||||
|
assert.equal(invariantViole(reserve), null);
|
||||||
|
assert.equal(invariantViole(petiteSoiree({ retenu: false })), null);
|
||||||
|
assert.equal(invariantViole(avec(avec(petiteSoiree(), reserverGrisaille), rendreFautif)), null);
|
||||||
|
const incoherente = avec(petiteSoiree(), (charge) => {
|
||||||
|
reserverGrisaille(charge);
|
||||||
|
charge.reservations.push({ participant: 2, table: 1, siege: null, portee: 'tour', tour: 1 });
|
||||||
|
});
|
||||||
|
assert.equal(invariantViole(incoherente), null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -35,6 +35,8 @@ import { COMMANDES_PLACES } from './commandes_places.js';
|
||||||
import { ErreurCommande, refus } from './erreurs.js';
|
import { ErreurCommande, refus } from './erreurs.js';
|
||||||
import { libelle } from './libelles.js';
|
import { libelle } from './libelles.js';
|
||||||
import { nomAffiche } from './personnes.js';
|
import { nomAffiche } from './personnes.js';
|
||||||
|
import { couvre, repartirSieges, reservationsActives } from './places.js';
|
||||||
|
import { controlerReintegration, leverRefus } from './pose.js';
|
||||||
|
|
||||||
// Le refus d'une commande vit dans erreurs.js, le nom affiché d'une personne
|
// Le refus d'une commande vit dans erreurs.js, le nom affiché d'une personne
|
||||||
// dans personnes.js : les importateurs de ce module les y trouvent encore.
|
// dans personnes.js : les importateurs de ce module les y trouvent encore.
|
||||||
|
|
@ -211,11 +213,18 @@ function exclureParticipant(charge, { id }) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** reintegrerParticipant {id} (§ 4.4) : ses réservations et ses titres reviennent, RESERVATIONS_RETABLIES. */
|
/**
|
||||||
|
* reintegrerParticipant {id} (§ 4.4) : ses réservations et ses titres
|
||||||
|
* reviennent, RESERVATIONS_RETABLIES. Ses réservations suspendues se
|
||||||
|
* contrôlent comme une pose (controlerReintegration, § 5.9) : le premier
|
||||||
|
* refus se lève, SURRESERVATION ou PLACE_RESERVEE, les suivants dans
|
||||||
|
* details.autres.
|
||||||
|
*/
|
||||||
function reintegrerParticipant(charge, { id }) {
|
function reintegrerParticipant(charge, { id }) {
|
||||||
exigerModifiable(charge);
|
exigerModifiable(charge);
|
||||||
const participant = participantDe(charge, id);
|
const participant = participantDe(charge, id);
|
||||||
if (!participant.exclu) throw refus('SANS_EFFET', { commande: 'reintegrerParticipant' });
|
if (!participant.exclu) throw refus('SANS_EFFET', { commande: 'reintegrerParticipant' });
|
||||||
|
leverRefus(controlerReintegration(charge, id));
|
||||||
const suivante = avecParticipant(charge, id, (copie) => ({ ...copie, exclu: false }));
|
const suivante = avecParticipant(charge, id, (copie) => ({ ...copie, exclu: false }));
|
||||||
const bilan = bilanExclusion(charge, suivante, participant);
|
const bilan = bilanExclusion(charge, suivante, participant);
|
||||||
return {
|
return {
|
||||||
|
|
@ -330,13 +339,45 @@ function debloquer(charge) {
|
||||||
return { charge: avecEtat(charge, etat), libelle: libelle('ENTREE_DEBLOQUER', { etat }), avertissements: [] };
|
return { charge: avecEtat(charge, etat), libelle: libelle('ENTREE_DEBLOQUER', { etat }), avertissements: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Le plan du moteur rangé par siège quand les sièges sont attribués (§ 4,
|
||||||
|
// § 5.3, § 8.9) : chaque liste de table de chaque tour par repartirSieges —
|
||||||
|
// la personne que sa réservation active, à un siège, couvrant ce tour à
|
||||||
|
// cette table, y prend ce rang —, les null de fin retirés. Le moteur ignore
|
||||||
|
// les sièges : sans ce rangement, la liste garde l'ordre du moteur,
|
||||||
|
// croissant par identifiant. Les places sont celles de la configuration sur
|
||||||
|
// laquelle le moteur a calculé, qui n'assied jamais plus : une table réduite
|
||||||
|
// ou supprimée depuis ne change pas le rangement, et la proposition
|
||||||
|
// s'enregistre avec sa dérive.
|
||||||
|
function rangerParSiege(charge, plan, configuration) {
|
||||||
|
const places = new Map(configuration.tables.map(({ id, capacite: sieges }) => [id, sieges]));
|
||||||
|
const assises = reservationsActives(charge).filter(({ siege }) => siege !== null);
|
||||||
|
return {
|
||||||
|
...plan,
|
||||||
|
tours: plan.tours.map((listes, rang) =>
|
||||||
|
listes.map((liste, i) => {
|
||||||
|
const idTable = plan.tables[i];
|
||||||
|
const siegeDe = new Map();
|
||||||
|
for (const reservation of assises) {
|
||||||
|
if (reservation.table === idTable && couvre(reservation, rang + 1) && !siegeDe.has(reservation.participant)) {
|
||||||
|
siegeDe.set(reservation.participant, reservation.siege);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const rangee = repartirSieges(liste, siegeDe, places.get(idTable) ?? 0);
|
||||||
|
while (rangee.length > 0 && rangee.at(-1) === null) rangee.pop();
|
||||||
|
return rangee;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* enregistrerGeneration {propositions, configuration, produitVersion} (§ 5.7,
|
* enregistrerGeneration {propositions, configuration, produitVersion} (§ 5.7,
|
||||||
* § 8.9) : les propositions du moteur, mises en forme de fichier
|
* § 8.9) : les propositions du moteur, mises en forme de fichier
|
||||||
* (versFichier) sous les identifiants qui suivent prochainsIds.proposition —
|
* (versFichier) sous les identifiants qui suivent prochainsIds.proposition —
|
||||||
* la proposition d'identifiant k du moteur prend prochainsIds.proposition +
|
* la proposition d'identifiant k du moteur prend prochainsIds.proposition +
|
||||||
* k − 1 —, s'ajoutent aux précédentes, et le compteur passe au-delà de la
|
* k − 1 —, s'ajoutent aux précédentes, et le compteur passe au-delà de la
|
||||||
* dernière. Un brouillon passe à proposé.
|
* dernière. Sièges attribués, chaque liste se range d'abord par siège
|
||||||
|
* (rangerParSiege). Un brouillon passe à proposé.
|
||||||
*/
|
*/
|
||||||
function enregistrerGeneration(charge, { propositions, configuration, produitVersion }) {
|
function enregistrerGeneration(charge, { propositions, configuration, produitVersion }) {
|
||||||
exigerModifiable(charge);
|
exigerModifiable(charge);
|
||||||
|
|
@ -350,7 +391,10 @@ function enregistrerGeneration(charge, { propositions, configuration, produitVer
|
||||||
const options = { produitVersion, attribuerSieges: charge.reglages.attribuerSieges, decalage };
|
const options = { produitVersion, attribuerSieges: charge.reglages.attribuerSieges, decalage };
|
||||||
let fichiers;
|
let fichiers;
|
||||||
try {
|
try {
|
||||||
fichiers = propositions.map((proposition) => versFichier(proposition, configuration, options));
|
fichiers = propositions.map((proposition) => {
|
||||||
|
const rangee = options.attribuerSieges ? { ...proposition, plan: rangerParSiege(charge, proposition.plan, configuration) } : proposition;
|
||||||
|
return versFichier(rangee, configuration, options);
|
||||||
|
});
|
||||||
} catch (erreur) {
|
} catch (erreur) {
|
||||||
if (erreur instanceof ErreurStockage && erreur.code === 'COMPTEUR_SATURE') {
|
if (erreur instanceof ErreurStockage && erreur.code === 'COMPTEUR_SATURE') {
|
||||||
throw refus('COMPTEUR_SATURE', erreur.details);
|
throw refus('COMPTEUR_SATURE', erreur.details);
|
||||||
|
|
|
||||||
|
|
@ -96,20 +96,32 @@ const executer = (nom, charge, args) => COMMANDES.get(nom)(charge, args);
|
||||||
const CSV_DEUX = 'nom;prenom;appartenance\nAncolie;Bastien;Club des Merles\nBruyère;Capucine;\n';
|
const CSV_DEUX = 'nom;prenom;appartenance\nAncolie;Bastien;Club des Merles\nBruyère;Capucine;\n';
|
||||||
|
|
||||||
describe('commandes : le registre (§ 8.2)', () => {
|
describe('commandes : le registre (§ 8.2)', () => {
|
||||||
test('le registre porte les onze commandes du plan, chacune une fonction', () => {
|
test("le registre porte les onze commandes d'A1, les dix des places et celles du mobilier, chacune une fonction", () => {
|
||||||
assert.deepEqual([...COMMANDES.keys()].sort(), [
|
const attendues = [
|
||||||
'ajouterParticipant',
|
'ajouterParticipant',
|
||||||
'changerEtat',
|
'changerEtat',
|
||||||
|
'convertirEnTous',
|
||||||
'debloquer',
|
'debloquer',
|
||||||
|
'deplacerDansRetenu',
|
||||||
|
'deplacerReservation',
|
||||||
|
'designer',
|
||||||
|
'echangerDansRetenu',
|
||||||
'effacerPropositions',
|
'effacerPropositions',
|
||||||
'enregistrerGeneration',
|
'enregistrerGeneration',
|
||||||
'exclureParticipant',
|
'exclureParticipant',
|
||||||
'importerParticipants',
|
'importerParticipants',
|
||||||
'modifierParticipant',
|
'modifierParticipant',
|
||||||
|
'poserTitre',
|
||||||
'reintegrerParticipant',
|
'reintegrerParticipant',
|
||||||
|
'reserver',
|
||||||
'retenirProposition',
|
'retenirProposition',
|
||||||
|
'retirerDuTour',
|
||||||
|
'retirerReservation',
|
||||||
|
'retirerTitre',
|
||||||
'supprimerParticipant',
|
'supprimerParticipant',
|
||||||
]);
|
];
|
||||||
|
assert.equal(attendues.length, 21);
|
||||||
|
assert.deepEqual([...COMMANDES.keys()].sort(), [...attendues, ...COMMANDES_MOBILIER.keys()].sort());
|
||||||
for (const commande of COMMANDES.values()) assert.equal(typeof commande, 'function');
|
for (const commande of COMMANDES.values()) assert.equal(typeof commande, 'function');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -565,9 +577,41 @@ describe("commandes : la charge d'un événement neuf (§ 8.1)", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('commandes : la charge reçue ne change jamais (§ 13.2)', () => {
|
describe('commandes : la charge reçue ne change jamais (§ 13.2)', () => {
|
||||||
// Arguments d'exemple de chaque commande sur la charge riche ; debloquer
|
// Au tour 1 du retenu de la charge riche : le voisin d'Iris à sa table, et
|
||||||
// reçoit la même charge, bloquée.
|
// une personne de l'autre table, avec cette table. Lus dans le retenu, qui
|
||||||
|
// vient du moteur.
|
||||||
|
function auTour1({ retenu }) {
|
||||||
|
const { sieges } = retenu.placement[0];
|
||||||
|
const rang = sieges.findIndex((liste) => liste.includes(1));
|
||||||
|
assert.equal(sieges.length, 2);
|
||||||
|
return {
|
||||||
|
voisin: sieges[rang].find((id) => id !== null && id !== 1),
|
||||||
|
autre: sieges[1 - rang].find((id) => id !== null),
|
||||||
|
tableAutre: retenu.tables[1 - rang],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// La charge que reçoit chaque commande : la charge riche, sauf debloquer,
|
||||||
|
// qui la reçoit bloquée ; convertirEnTous, qui y trouve Théo réservé à la
|
||||||
|
// table 2 à chaque tour, en portée « tour » ; deplacerDansRetenu, qui y
|
||||||
|
// trouve l'autre personne du tour 1 retirée dans la réserve.
|
||||||
|
function recues(charge) {
|
||||||
|
const { autre } = auTour1(charge);
|
||||||
|
const reservee = structuredClone(charge);
|
||||||
|
reservee.reservations.push(
|
||||||
|
{ participant: 3, table: 2, siege: null, portee: 'tour', tour: 1 },
|
||||||
|
{ participant: 3, table: 2, siege: null, portee: 'tour', tour: 2 },
|
||||||
|
);
|
||||||
|
return new Map([
|
||||||
|
['debloquer', { ...charge, evenement: { ...charge.evenement, etat: 'bloque' } }],
|
||||||
|
['convertirEnTous', reservee],
|
||||||
|
['deplacerDansRetenu', executer('retirerDuTour', charge, { tour: 1, participant: autre }).charge],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arguments d'exemple de chaque commande sur la charge qu'elle reçoit.
|
||||||
function exemples(charge) {
|
function exemples(charge) {
|
||||||
|
const { voisin, autre, tableAutre } = auTour1(charge);
|
||||||
return new Map([
|
return new Map([
|
||||||
['ajouterParticipant', { champs: { nom: 'Bruyère' } }],
|
['ajouterParticipant', { champs: { nom: 'Bruyère' } }],
|
||||||
['modifierParticipant', { id: 4, champs: { courriel: 'o@exemple.test' } }],
|
['modifierParticipant', { id: 4, champs: { courriel: 'o@exemple.test' } }],
|
||||||
|
|
@ -580,15 +624,26 @@ describe('commandes : la charge reçue ne change jamais (§ 13.2)', () => {
|
||||||
['enregistrerGeneration', generer(charge, 1, 5)],
|
['enregistrerGeneration', generer(charge, 1, 5)],
|
||||||
['retenirProposition', { id: 2 }],
|
['retenirProposition', { id: 2 }],
|
||||||
['effacerPropositions', {}],
|
['effacerPropositions', {}],
|
||||||
|
['reserver', { participant: 4, table: 2, siege: null, portee: 'tour', tour: 1 }],
|
||||||
|
['retirerReservation', { participant: 1, table: 1, portee: 'tous', tour: null }],
|
||||||
|
['deplacerReservation', { participant: 1, de: { table: 1, portee: 'tous', tour: null }, vers: { table: 2, siege: null } }],
|
||||||
|
['poserTitre', { table: 2, siege: 1, libelle: 'accueil' }],
|
||||||
|
['retirerTitre', { table: 1, siege: 1 }],
|
||||||
|
['designer', { participant: 3, table: 2, siege: 2, libelle: 'accueil' }],
|
||||||
|
['convertirEnTous', { participant: 3, table: 2 }],
|
||||||
|
['deplacerDansRetenu', { tour: 1, participant: autre, table: tableAutre, siege: null }],
|
||||||
|
['echangerDansRetenu', { tour: 1, a: voisin, b: autre }],
|
||||||
|
['retirerDuTour', { tour: 1, participant: autre }],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
test("aucune commande ne modifie la charge qu'elle reçoit", () => {
|
test("aucune commande ne modifie la charge qu'elle reçoit", () => {
|
||||||
const charge = chargeRiche();
|
const charge = chargeRiche();
|
||||||
const args = exemples(charge);
|
const args = exemples(charge);
|
||||||
|
const particulieres = recues(charge);
|
||||||
assert.deepEqual([...args.keys()].sort(), [...COMMANDES.keys()].sort());
|
assert.deepEqual([...args.keys()].sort(), [...COMMANDES.keys()].sort());
|
||||||
for (const [nom, commande] of COMMANDES) {
|
for (const [nom, commande] of COMMANDES) {
|
||||||
const recue = nom === 'debloquer' ? { ...charge, evenement: { ...charge.evenement, etat: 'bloque' } } : charge;
|
const recue = particulieres.get(nom) ?? charge;
|
||||||
const copie = structuredClone(recue);
|
const copie = structuredClone(recue);
|
||||||
const rendu = commande(figer(recue), args.get(nom));
|
const rendu = commande(figer(recue), args.get(nom));
|
||||||
assert.deepEqual(recue, copie, nom);
|
assert.deepEqual(recue, copie, nom);
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,714 @@
|
||||||
// Le registre des commandes des places — réservations, titres, pose (§ 5.8,
|
// Le registre des commandes des places — réservations, titres, pose (§ 5.8,
|
||||||
// § 5.9, § 8.2), que commandes.js réunit aux autres. Ses imports n'atteignent
|
// § 5.9, § 8.2), que commandes.js réunit aux autres. Ses imports n'atteignent
|
||||||
// jamais commandes.js : le registre réuni dépend de lui, non l'inverse.
|
// jamais commandes.js : le registre réuni dépend de lui, non l'inverse.
|
||||||
|
//
|
||||||
|
// Chaque commande suit le contrat de commandes.js : une fonction pure
|
||||||
|
// (charge, arguments) → { charge, libelle, avertissements }, la charge reçue
|
||||||
|
// intacte ; un refus lève ErreurCommande(code, details), details.remede
|
||||||
|
// compris ; un argument de forme fausse, une table inconnue, un retenu absent
|
||||||
|
// lèvent TypeError, que l'interface n'offre jamais. Un plan bloqué refuse
|
||||||
|
// tout (PLAN_BLOQUE). Les poses se contrôlent par controlerPose (pose.js),
|
||||||
|
// qui rend tous les refus ; la commande lève le premier et porte les autres
|
||||||
|
// dans details.autres.
|
||||||
|
//
|
||||||
|
// Avant la retenue, un geste de personne pose ou déplace une réservation ;
|
||||||
|
// après, il modifie le retenu, au tour visé seulement, jamais la proposition
|
||||||
|
// d'où il vient (§ 5.8). Sièges attribués, le siège est le rang dans la
|
||||||
|
// liste, une chaise vide s'y écrit null et une liste ne finit jamais par
|
||||||
|
// null : un départ libère sa chaise, et tout autre siège reste le même
|
||||||
|
// (§ 8.9). Sans attribution, la liste reste triée par identifiant, et chaque
|
||||||
|
// geste verse d'abord en réserve les présents que le plan ne montre pas
|
||||||
|
// assis aux tables qu'il touche (reduireAuxAssises) : la liste vaut ce que
|
||||||
|
// l'affichage assied. Chaque geste lit les places comme le plan les montre
|
||||||
|
// (placeDe). Une retouche qui ajoute une violation de verifierInvariants au
|
||||||
|
// retenu reçu est refusée (INVARIANT_VIOLE) ; une violation déjà présente,
|
||||||
|
// celle d'une dérive (§ 9), ne bloque rien.
|
||||||
|
import { ordreDesReservations, pourvoyeursDesTitres } from '../csv/import.js';
|
||||||
|
import { verifierInvariants } from '../moteur/verification.js';
|
||||||
|
import { capacite } from '../stockage/document.js';
|
||||||
|
import { fautes, planDepuisFichier } from '../stockage/placements.js';
|
||||||
|
import { refus } from './erreurs.js';
|
||||||
|
import { libelle } from './libelles.js';
|
||||||
|
import { conversionsPossibles } from './listes_travail.js';
|
||||||
|
import { nomAffiche } from './personnes.js';
|
||||||
|
import { etatPlaces, instanceDe, presents } from './places.js';
|
||||||
|
import { controlerPose, leverRefus } from './pose.js';
|
||||||
|
|
||||||
|
const croissant = (a, b) => a - b;
|
||||||
|
|
||||||
|
// --- Les contrôles communs ---------------------------------------------------
|
||||||
|
|
||||||
|
// Un plan bloqué refuse toute modification ; débloquer seul le lève (§ 9).
|
||||||
|
function exigerModifiable(charge) {
|
||||||
|
if (charge.evenement.etat === 'bloque') throw refus('PLAN_BLOQUE', {}, { geste: 'debloquer' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Le participant d'identifiant id, ou PARTICIPANT_INCONNU.
|
||||||
|
function personneDe(charge, id) {
|
||||||
|
if (!Number.isSafeInteger(id)) throw new TypeError(`participant : entier attendu, reçu ${JSON.stringify(id)}`);
|
||||||
|
const personne = charge.participants.find((candidat) => candidat.id === id);
|
||||||
|
if (personne === undefined) throw refus('PARTICIPANT_INCONNU', { id });
|
||||||
|
return personne;
|
||||||
|
}
|
||||||
|
|
||||||
|
// L'enregistrement de la table d'identifiant id : une table inconnue est
|
||||||
|
// une faute de l'appelant.
|
||||||
|
function tableDe(charge, id) {
|
||||||
|
const table = charge.tables.find((candidate) => candidate.id === id);
|
||||||
|
if (table === undefined) throw new TypeError(`table inconnue : ${JSON.stringify(id)}`);
|
||||||
|
return table;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Un siège donné : null, ou un entier ≥ 1.
|
||||||
|
function exigerSiege(siege) {
|
||||||
|
if (siege !== null && !(Number.isSafeInteger(siege) && siege >= 1)) {
|
||||||
|
throw new TypeError(`siege : null ou entier ≥ 1 attendu, reçu ${JSON.stringify(siege)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Une portée et son tour : « tous » sans tour, « tour » de 1 à R.
|
||||||
|
function exigerPortee(charge, portee, tour) {
|
||||||
|
if (portee === 'tous' && tour === null) return;
|
||||||
|
if (portee === 'tour' && Number.isSafeInteger(tour) && tour >= 1 && tour <= charge.evenement.tours) return;
|
||||||
|
throw new TypeError(`portée ${JSON.stringify(portee)}, tour ${JSON.stringify(tour)} : « tous » sans tour ou « tour » de 1 à R attendus`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Le libellé d'un titre saisi : en NFC, ses blancs de bord retirés ;
|
||||||
|
// LIBELLE_VIDE quand il ne reste rien.
|
||||||
|
function libelleSaisi(valeur) {
|
||||||
|
if (typeof valeur !== 'string') throw new TypeError(`libelle : chaîne attendue, reçu ${JSON.stringify(valeur)}`);
|
||||||
|
const texte = valeur.normalize('NFC').trim();
|
||||||
|
if (texte === '') throw refus('LIBELLE_VIDE');
|
||||||
|
return texte;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vrai quand la réservation r est celle que désigne d pour ce participant :
|
||||||
|
// sa table, sa portée, son tour.
|
||||||
|
const designee = (r, participant, d) =>
|
||||||
|
r.participant === participant && r.table === d.table && r.portee === d.portee && r.tour === (d.portee === 'tous' ? null : d.tour);
|
||||||
|
|
||||||
|
// Le premier titre de la place dans l'ordre canonique, ou undefined.
|
||||||
|
const titreDe = (charge, idTable, siege) =>
|
||||||
|
charge.titres
|
||||||
|
.filter((titre) => titre.table === idTable && titre.siege === siege)
|
||||||
|
.sort((a, b) => (a.libelle < b.libelle ? -1 : a.libelle > b.libelle ? 1 : 0))[0];
|
||||||
|
|
||||||
|
// Le remède d'une table qui manque de places : une de plus que places, par
|
||||||
|
// la saisie de son champ.
|
||||||
|
const changerPlaces = (idTable, places) => ({
|
||||||
|
commande: 'changerPlacesTable',
|
||||||
|
arguments: { id: idTable, sieges: places + 1, origine: 'champ' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Le plus petit siège de la table, de 1 à places, qu'aucun titre ne porte ;
|
||||||
|
// AUCUN_SIEGE_SANS_TITRE quand tous en portent un.
|
||||||
|
function siegeSansTitre(charge, idTable, places) {
|
||||||
|
const titres = new Set(charge.titres.filter(({ table }) => table === idTable).map(({ siege }) => siege));
|
||||||
|
for (let siege = 1; siege <= places; siege += 1) if (!titres.has(siege)) return siege;
|
||||||
|
throw refus('AUCUN_SIEGE_SANS_TITRE', { table: idTable, capacite: places }, changerPlaces(idTable, places));
|
||||||
|
}
|
||||||
|
|
||||||
|
// SIEGE_INEXISTANT pour un titre au-delà de la capacité : un titre garde
|
||||||
|
// toujours un siège de sa table.
|
||||||
|
function exigerSiegeDeTitre(idTable, siege, places) {
|
||||||
|
if (siege > places) {
|
||||||
|
throw refus('SIEGE_INEXISTANT', { table: idTable, siege, capacite: places }, {
|
||||||
|
commande: 'changerPlacesTable',
|
||||||
|
arguments: { id: idTable, sieges: siege, origine: 'champ' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Les avertissements des signaux d'une pose acceptée.
|
||||||
|
const avertissementsDe = (signaux) => signaux.map(({ code, table }) => ({ code, details: { table } }));
|
||||||
|
|
||||||
|
// --- Les réservations (§ 4, § 5.9) -------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* reserver {participant, table, siege|null, portee: 'tous'|'tour', tour|null}
|
||||||
|
* (§ 4, § 5.9) : une réservation de plus, contrôlée comme une pose. Sans
|
||||||
|
* attribution des sièges, un siège au-delà de la capacité s'enregistre null :
|
||||||
|
* la place est la table. Une réservation déjà là : SANS_EFFET.
|
||||||
|
*/
|
||||||
|
function reserver(charge, { participant, table: idTable, siege = null, portee, tour = null } = {}) {
|
||||||
|
exigerModifiable(charge);
|
||||||
|
const personne = personneDe(charge, participant);
|
||||||
|
const table = tableDe(charge, idTable);
|
||||||
|
exigerSiege(siege);
|
||||||
|
exigerPortee(charge, portee, tour);
|
||||||
|
const places = capacite(charge, table);
|
||||||
|
const siegeEcrit = !charge.reglages.attribuerSieges && siege !== null && siege > places ? null : siege;
|
||||||
|
const nouvelle = { participant, table: idTable, siege: siegeEcrit, portee, tour };
|
||||||
|
if (charge.reservations.some((r) => ordreDesReservations(r, nouvelle) === 0)) {
|
||||||
|
throw refus('SANS_EFFET', { commande: 'reserver' });
|
||||||
|
}
|
||||||
|
const { refus: refusPose, signaux } = controlerPose(charge, nouvelle);
|
||||||
|
leverRefus(refusPose);
|
||||||
|
const suivante = structuredClone(charge);
|
||||||
|
suivante.reservations.push(nouvelle);
|
||||||
|
return {
|
||||||
|
charge: suivante,
|
||||||
|
libelle: libelle('ENTREE_RESERVER', { personne: nomAffiche(personne), numero: table.numero, siege: siegeEcrit, portee, tour }),
|
||||||
|
avertissements: avertissementsDe(signaux),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Les réservations du participant que désigne { table, portee, tour }, ou
|
||||||
|
// RESERVATION_ABSENTE.
|
||||||
|
function reservationsDesignees(charge, participant, designation) {
|
||||||
|
exigerPortee(charge, designation.portee, designation.tour ?? null);
|
||||||
|
const trouvees = charge.reservations.filter((r) => designee(r, participant, designation));
|
||||||
|
if (trouvees.length === 0) {
|
||||||
|
const { table, portee, tour = null } = designation;
|
||||||
|
throw refus('RESERVATION_ABSENTE', { participant, table, portee, tour });
|
||||||
|
}
|
||||||
|
return trouvees;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** retirerReservation {participant, table, portee, tour|null} : la réservation ainsi désignée part. */
|
||||||
|
function retirerReservation(charge, { participant, table: idTable, portee, tour = null } = {}) {
|
||||||
|
exigerModifiable(charge);
|
||||||
|
const personne = personneDe(charge, participant);
|
||||||
|
const table = tableDe(charge, idTable);
|
||||||
|
const designation = { table: idTable, portee, tour };
|
||||||
|
reservationsDesignees(charge, participant, designation);
|
||||||
|
const suivante = structuredClone(charge);
|
||||||
|
suivante.reservations = suivante.reservations.filter((r) => !designee(r, participant, designation));
|
||||||
|
return {
|
||||||
|
charge: suivante,
|
||||||
|
libelle: libelle('ENTREE_RETIRER_RESERVATION', { personne: nomAffiche(personne), numero: table.numero, portee, tour }),
|
||||||
|
avertissements: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* deplacerReservation {participant, de: {table, portee, tour|null}, vers:
|
||||||
|
* {table, siege|null}} (§ 5.9) : la réservation désignée change de place et
|
||||||
|
* garde sa portée ; la pose se contrôle sans elle. Elle garde son rang dans
|
||||||
|
* la liste des réservations.
|
||||||
|
*/
|
||||||
|
function deplacerReservation(charge, { participant, de, vers } = {}) {
|
||||||
|
exigerModifiable(charge);
|
||||||
|
const personne = personneDe(charge, participant);
|
||||||
|
const depart = tableDe(charge, de.table);
|
||||||
|
const arrivee = tableDe(charge, vers.table);
|
||||||
|
const siege = vers.siege ?? null;
|
||||||
|
exigerSiege(siege);
|
||||||
|
const [premiere] = reservationsDesignees(charge, participant, de).sort(ordreDesReservations);
|
||||||
|
const places = capacite(charge, arrivee);
|
||||||
|
const siegeEcrit = !charge.reglages.attribuerSieges && siege !== null && siege > places ? null : siege;
|
||||||
|
const deplacee = { participant, table: vers.table, siege: siegeEcrit, portee: premiere.portee, tour: premiere.tour };
|
||||||
|
const { refus: refusPose, signaux } = controlerPose(charge, { ...deplacee, depuis: de });
|
||||||
|
leverRefus(refusPose);
|
||||||
|
const suivante = structuredClone(charge);
|
||||||
|
let remplacee = false;
|
||||||
|
suivante.reservations = suivante.reservations.flatMap((r) => {
|
||||||
|
if (!designee(r, participant, de)) return [r];
|
||||||
|
if (remplacee) return [];
|
||||||
|
remplacee = true;
|
||||||
|
return [deplacee];
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
charge: suivante,
|
||||||
|
libelle: libelle('ENTREE_DEPLACER_RESERVATION', {
|
||||||
|
personne: nomAffiche(personne),
|
||||||
|
de: depart.numero,
|
||||||
|
vers: arrivee.numero,
|
||||||
|
siege: siegeEcrit,
|
||||||
|
portee: premiere.portee,
|
||||||
|
tour: premiere.tour,
|
||||||
|
}),
|
||||||
|
avertissements: avertissementsDe(signaux),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* convertirEnTous {participant, table} (§ 4.2) : les R réservations « tour »
|
||||||
|
* de la personne à cette table deviennent une seule « tous les tours », au
|
||||||
|
* siège qu'elles portaient toutes, sans siège sinon. CONVERSION_IMPOSSIBLE
|
||||||
|
* hors de conversionsPossibles.
|
||||||
|
*/
|
||||||
|
function convertirEnTous(charge, { participant, table: idTable } = {}) {
|
||||||
|
exigerModifiable(charge);
|
||||||
|
const personne = personneDe(charge, participant);
|
||||||
|
const table = tableDe(charge, idTable);
|
||||||
|
if (!conversionsPossibles(charge).some((c) => c.participant === participant && c.table === idTable)) {
|
||||||
|
throw refus('CONVERSION_IMPOSSIBLE', { participant, table: idTable });
|
||||||
|
}
|
||||||
|
const parTour = (r) => r.participant === participant && r.table === idTable && r.portee === 'tour';
|
||||||
|
const sieges = new Set(charge.reservations.filter(parTour).map(({ siege }) => siege));
|
||||||
|
const siege = sieges.size === 1 ? [...sieges][0] : null;
|
||||||
|
const suivante = structuredClone(charge);
|
||||||
|
suivante.reservations = suivante.reservations.filter((r) => !parTour(r));
|
||||||
|
suivante.reservations.push({ participant, table: idTable, siege, portee: 'tous', tour: null });
|
||||||
|
return {
|
||||||
|
charge: suivante,
|
||||||
|
libelle: libelle('ENTREE_CONVERTIR_EN_TOUS', { personne: nomAffiche(personne), numero: table.numero }),
|
||||||
|
avertissements: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Les titres et la désignation (§ 4, § 4.1, § 2.1 étape 6) ---------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* poserTitre {table, siege|null, libelle} (§ 4.1) : un titre à une place.
|
||||||
|
* Sans siège, le plus petit qui n'en porte pas. Une place titrée refuse
|
||||||
|
* PLACE_TITREE, remède le retrait de son titre.
|
||||||
|
*/
|
||||||
|
function poserTitre(charge, { table: idTable, siege = null, libelle: saisi } = {}) {
|
||||||
|
exigerModifiable(charge);
|
||||||
|
const table = tableDe(charge, idTable);
|
||||||
|
exigerSiege(siege);
|
||||||
|
const texte = libelleSaisi(saisi);
|
||||||
|
const places = capacite(charge, table);
|
||||||
|
const place = siege ?? siegeSansTitre(charge, idTable, places);
|
||||||
|
exigerSiegeDeTitre(idTable, place, places);
|
||||||
|
const titre = titreDe(charge, idTable, place);
|
||||||
|
if (titre !== undefined) {
|
||||||
|
throw refus('PLACE_TITREE', { table: idTable, siege: place, libelle: titre.libelle }, {
|
||||||
|
commande: 'retirerTitre',
|
||||||
|
arguments: { table: idTable, siege: place },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const suivante = structuredClone(charge);
|
||||||
|
suivante.titres.push({ table: idTable, siege: place, libelle: texte });
|
||||||
|
return {
|
||||||
|
charge: suivante,
|
||||||
|
libelle: libelle('ENTREE_POSER_TITRE', { libelle: texte, numero: table.numero, siege: place }),
|
||||||
|
avertissements: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** retirerTitre {table, siege} : le premier titre de la place part ; TITRE_ABSENT sans lui. */
|
||||||
|
function retirerTitre(charge, { table: idTable, siege } = {}) {
|
||||||
|
exigerModifiable(charge);
|
||||||
|
const table = tableDe(charge, idTable);
|
||||||
|
const titre = titreDe(charge, idTable, siege);
|
||||||
|
if (titre === undefined) throw refus('TITRE_ABSENT', { table: idTable, siege });
|
||||||
|
const suivante = structuredClone(charge);
|
||||||
|
const rang = charge.titres.indexOf(titre);
|
||||||
|
suivante.titres.splice(rang, 1);
|
||||||
|
return {
|
||||||
|
charge: suivante,
|
||||||
|
libelle: libelle('ENTREE_RETIRER_TITRE', { libelle: titre.libelle, numero: table.numero, siege }),
|
||||||
|
avertissements: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* designer {participant, table, siege|null, libelle} (§ 2.1 étape 6, § 5.8) :
|
||||||
|
* une réservation « tous les tours » et un titre au même siège, en une
|
||||||
|
* entrée. Sans siège, la place du premier titre de la table que rien ne
|
||||||
|
* pourvoit pour la soirée, sinon le plus petit siège sans titre. Un titre
|
||||||
|
* déjà posé à cette place est pourvu, et aucun second ne s'ajoute.
|
||||||
|
*/
|
||||||
|
function designer(charge, { participant, table: idTable, siege = null, libelle: saisi } = {}) {
|
||||||
|
exigerModifiable(charge);
|
||||||
|
const personne = personneDe(charge, participant);
|
||||||
|
const table = tableDe(charge, idTable);
|
||||||
|
exigerSiege(siege);
|
||||||
|
const texte = libelleSaisi(saisi);
|
||||||
|
const places = capacite(charge, table);
|
||||||
|
let place = siege;
|
||||||
|
if (place === null) {
|
||||||
|
const pourvoyeurs = pourvoyeursDesTitres(charge);
|
||||||
|
const libres = charge.titres
|
||||||
|
.map((titre, rang) => ({ titre, rang }))
|
||||||
|
.filter(({ titre, rang }) => titre.table === idTable && pourvoyeurs[rang].soiree === null)
|
||||||
|
.sort((a, b) => a.titre.siege - b.titre.siege);
|
||||||
|
place = libres.length > 0 ? libres[0].titre.siege : siegeSansTitre(charge, idTable, places);
|
||||||
|
}
|
||||||
|
exigerSiegeDeTitre(idTable, place, places);
|
||||||
|
const reservation = { participant, table: idTable, siege: place, portee: 'tous', tour: null };
|
||||||
|
const titre = titreDe(charge, idTable, place);
|
||||||
|
const dejaReservee = charge.reservations.some((r) => ordreDesReservations(r, reservation) === 0);
|
||||||
|
if (dejaReservee && titre !== undefined) throw refus('SANS_EFFET', { commande: 'designer' });
|
||||||
|
const { refus: refusPose, signaux } = controlerPose(charge, reservation);
|
||||||
|
leverRefus(refusPose);
|
||||||
|
const suivante = structuredClone(charge);
|
||||||
|
if (!dejaReservee) suivante.reservations.push(reservation);
|
||||||
|
if (titre === undefined) suivante.titres.push({ table: idTable, siege: place, libelle: texte });
|
||||||
|
return {
|
||||||
|
charge: suivante,
|
||||||
|
libelle: libelle('ENTREE_DESIGNER', {
|
||||||
|
personne: nomAffiche(personne),
|
||||||
|
numero: table.numero,
|
||||||
|
siege: place,
|
||||||
|
libelle: titre?.libelle ?? texte,
|
||||||
|
}),
|
||||||
|
avertissements: avertissementsDe(signaux),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Le retenu (§ 7.1, § 8.9, § 9) -------------------------------------------
|
||||||
|
|
||||||
|
// Le retenu de la charge à ce tour, lisible : sans retenu, ou à un tour qu'il
|
||||||
|
// ne porte pas, TypeError — la cible d'etatPlaces ne l'offre jamais ; fautif,
|
||||||
|
// RETENU_FAUTIF, remède la version précédente du fichier (§ 8.8).
|
||||||
|
function exigerRetenu(charge, tour) {
|
||||||
|
const { retenu } = charge;
|
||||||
|
if (retenu === null) throw new TypeError('retenu : aucun placement retenu');
|
||||||
|
if (fautes(retenu).length > 0) throw refus('RETENU_FAUTIF', {}, { geste: 'ouvrirSecours', source: 'precedent' });
|
||||||
|
if (!Number.isSafeInteger(tour) || tour < 1 || tour > retenu.tours) {
|
||||||
|
throw new TypeError(`tour : de 1 à ${retenu.tours} attendu, reçu ${JSON.stringify(tour)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Où le retenu place id à ce tour : { table: rang de la liste, rang } à une
|
||||||
|
// table, 'reserve', ou null quand il ne le déclare pas.
|
||||||
|
function lieuDans(retenu, tour, id) {
|
||||||
|
if (!retenu.participants.includes(id)) return null;
|
||||||
|
const { sieges } = retenu.placement[tour - 1];
|
||||||
|
for (let table = 0; table < sieges.length; table += 1) {
|
||||||
|
const rang = sieges[table].indexOf(id);
|
||||||
|
if (rang >= 0) return { table, rang };
|
||||||
|
}
|
||||||
|
return 'reserve';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retire les null de fin d'une liste, en place.
|
||||||
|
function sansNullDeFin(liste) {
|
||||||
|
while (liste.length > 0 && liste.at(-1) === null) liste.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Déclare id dans le retenu qui ne le place pas encore : parmi ses
|
||||||
|
// participants, et en réserve à chaque tour — l'opérateur seul décide où il
|
||||||
|
// s'assied (§ 9).
|
||||||
|
function declarer(retenu, id) {
|
||||||
|
retenu.participants = [...retenu.participants, id].sort(croissant);
|
||||||
|
for (const tour of retenu.placement) tour.reserve = [...tour.reserve, id].sort(croissant);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ôte id de sa place au tour : sa chaise devient vide sous des sièges
|
||||||
|
// attribués, la liste se resserre sinon ; ou il quitte la réserve.
|
||||||
|
function oter(retenu, tour, id) {
|
||||||
|
const lieu = lieuDans(retenu, tour, id);
|
||||||
|
const etage = retenu.placement[tour - 1];
|
||||||
|
if (lieu === 'reserve') {
|
||||||
|
etage.reserve = etage.reserve.filter((autre) => autre !== id);
|
||||||
|
} else if (lieu !== null) {
|
||||||
|
const liste = etage.sieges[lieu.table];
|
||||||
|
if (retenu.siegesAttribues) {
|
||||||
|
liste[lieu.rang] = null;
|
||||||
|
sansNullDeFin(liste);
|
||||||
|
} else {
|
||||||
|
liste.splice(lieu.rang, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verse id dans la réserve du tour.
|
||||||
|
function enReserve(retenu, tour, id) {
|
||||||
|
const etage = retenu.placement[tour - 1];
|
||||||
|
etage.reserve = [...etage.reserve, id].sort(croissant);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Le rang de la table idTable dans le retenu, la table ajoutée vide à chaque
|
||||||
|
// tour quand il ne la déclare pas encore — une table créée après la
|
||||||
|
// retenue (A4 l'y ajoute aussi).
|
||||||
|
function rangDeTable(retenu, idTable, places) {
|
||||||
|
const rang = retenu.tables.indexOf(idTable);
|
||||||
|
if (rang >= 0) return rang;
|
||||||
|
retenu.tables.push(idTable);
|
||||||
|
retenu.capacites.push(places);
|
||||||
|
for (const tour of retenu.placement) tour.sieges.push([]);
|
||||||
|
return retenu.tables.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clé d'une violation : ses champs, dans un ordre fixe.
|
||||||
|
const cleDeViolation = ({ code, participant, table, tour, detail }) => JSON.stringify([code, participant, table, tour, detail]);
|
||||||
|
|
||||||
|
// Les violations de verifierInvariants que suivant ajoute au retenu
|
||||||
|
// precedent, la réserve étant une place du plan (§ 12.6) : celles dont la
|
||||||
|
// clé n'y apparaît pas. Un retenu lisible déclare chaque table et chaque
|
||||||
|
// personne une fois, et verifierInvariants n'y rend jamais deux violations
|
||||||
|
// de même clé. [] quand la configuration ne se normalise pas.
|
||||||
|
function violationsNouvelles(charge, precedent, suivant) {
|
||||||
|
const instance = instanceDe(charge);
|
||||||
|
if (instance === null) return [];
|
||||||
|
const juger = (retenu) => verifierInvariants(instance, planDepuisFichier(retenu), { reserveAutorisee: true });
|
||||||
|
const connues = new Set(juger(precedent).map(cleDeViolation));
|
||||||
|
return juger(suivant).filter((violation) => !connues.has(cleDeViolation(violation)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// La charge suivante d'une retouche, ou INVARIANT_VIOLE quand elle ajoute
|
||||||
|
// une violation au retenu reçu. Le versement en réserve des personnes que le
|
||||||
|
// plan n'assied pas (reduireAuxAssises, versLaReserveAffichee) n'en ajoute
|
||||||
|
// aucune : l'affichage assied d'abord, à leur table, les personnes qu'elle
|
||||||
|
// réserve, et une capacité ne descend jamais sous ses réservations (§ 6.2).
|
||||||
|
function retouche(charge, suivante) {
|
||||||
|
const violations = violationsNouvelles(charge, charge.retenu, suivante.retenu);
|
||||||
|
if (violations.length > 0) {
|
||||||
|
throw refus('INVARIANT_VIOLE', { proposition: charge.retenu.proposition, violations });
|
||||||
|
}
|
||||||
|
return suivante;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Le numéro de la table où le retenu place id à ce tour, null en réserve,
|
||||||
|
// hors du retenu, ou à une table que la charge n'a plus.
|
||||||
|
function numeroDuLieu(charge, tour, id) {
|
||||||
|
const lieu = lieuDans(charge.retenu, tour, id);
|
||||||
|
if (lieu === null || lieu === 'reserve') return null;
|
||||||
|
return charge.tables.find(({ id: idTable }) => idTable === charge.retenu.tables[lieu.table])?.numero ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* deplacerDansRetenu {tour, participant, table, siege|null} (§ 7.1, § 9) :
|
||||||
|
* la personne s'assied à cette table au tour donné, et sa place d'avant se
|
||||||
|
* libère. Sièges attribués, au siège donné — sans siège, la première chaise
|
||||||
|
* libre —, les rangs sautés à null ; la chaise d'un présent refuse
|
||||||
|
* SIEGE_OCCUPE, remède l'échange ; celle d'un absent — exclu, ou que la
|
||||||
|
* charge n'a plus — l'envoie en réserve. Sans attribution, la liste reste
|
||||||
|
* triée ; une table pleine de présents refuse TABLE_PLEINE, et, pleine
|
||||||
|
* d'une liste qui porte un absent, envoie le plus petit en réserve. Une
|
||||||
|
* personne que le retenu ne place pas y entre, en réserve aux autres tours.
|
||||||
|
* Le lieu de départ est celui que le plan du tour montre (placeDe) : une
|
||||||
|
* personne qu'il ne montre pas assise part de la réserve.
|
||||||
|
*/
|
||||||
|
function deplacerDansRetenu(charge, { tour, participant, table: idTable, siege = null } = {}) {
|
||||||
|
exigerModifiable(charge);
|
||||||
|
exigerRetenu(charge, tour);
|
||||||
|
const personne = personneDe(charge, participant);
|
||||||
|
const table = tableDe(charge, idTable);
|
||||||
|
exigerSiege(siege);
|
||||||
|
const places = capacite(charge, table);
|
||||||
|
if (siege !== null && siege > places) throw new TypeError(`siege : de 1 à ${places} attendu, reçu ${siege}`);
|
||||||
|
leverRefus(controlerPose(charge, { participant, table: idTable, siege, portee: 'tour', tour, retenu: true }).refus);
|
||||||
|
|
||||||
|
const ids = presents(charge);
|
||||||
|
const assisesDuTour = assisesAffichees(charge, tour);
|
||||||
|
const place = placeDe(charge, tour, participant, assisesDuTour);
|
||||||
|
const de = place === null ? null : numeroDuLieu(charge, tour, participant);
|
||||||
|
const memeTable = place !== null && place.table === idTable;
|
||||||
|
const suivante = structuredClone(charge);
|
||||||
|
const { retenu } = suivante;
|
||||||
|
const rangTable = rangDeTable(retenu, idTable, places);
|
||||||
|
if (lieuDans(retenu, tour, participant) === null) declarer(retenu, participant);
|
||||||
|
// La table d'arrivée n'a rien à verser : une liste qui porte plus de
|
||||||
|
// présents que de places refuse TABLE_PLEINE, et une autre les assied tous.
|
||||||
|
reduireAuxAssises(retenu, tour, rangsDesTables(retenu, tour, [participant]), assisesDuTour, ids);
|
||||||
|
versLaReserveAffichee(retenu, tour, participant, place);
|
||||||
|
const etage = retenu.placement[tour - 1];
|
||||||
|
let siegeLibelle = null;
|
||||||
|
|
||||||
|
if (retenu.siegesAttribues) {
|
||||||
|
const liste = etage.sieges[rangTable];
|
||||||
|
let cible = siege;
|
||||||
|
if (cible === null) {
|
||||||
|
cible = 1;
|
||||||
|
while (cible <= places && cible <= liste.length && liste[cible - 1] !== null && ids.has(liste[cible - 1])) cible += 1;
|
||||||
|
if (cible > places) throw refus('TABLE_PLEINE', { table: idTable, tour, capacite: places }, changerPlaces(idTable, places));
|
||||||
|
}
|
||||||
|
const occupant = liste[cible - 1] ?? null;
|
||||||
|
if (occupant === participant) throw refus('SANS_EFFET', { commande: 'deplacerDansRetenu' });
|
||||||
|
if (occupant !== null && ids.has(occupant)) {
|
||||||
|
throw refus('SIEGE_OCCUPE', { table: idTable, siege: cible, tour, occupant }, {
|
||||||
|
commande: 'echangerDansRetenu',
|
||||||
|
arguments: { tour, a: participant, b: occupant },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (occupant !== null) {
|
||||||
|
liste[cible - 1] = null;
|
||||||
|
enReserve(retenu, tour, occupant);
|
||||||
|
}
|
||||||
|
oter(retenu, tour, participant);
|
||||||
|
const garnie = etage.sieges[rangTable];
|
||||||
|
while (garnie.length < cible) garnie.push(null);
|
||||||
|
garnie[cible - 1] = participant;
|
||||||
|
sansNullDeFin(garnie);
|
||||||
|
if (garnie.length > retenu.capacites[rangTable]) retenu.capacites[rangTable] = places;
|
||||||
|
if (memeTable) siegeLibelle = cible;
|
||||||
|
} else {
|
||||||
|
const liste = etage.sieges[rangTable];
|
||||||
|
if (liste.includes(participant)) throw refus('SANS_EFFET', { commande: 'deplacerDansRetenu' });
|
||||||
|
const assises = liste.filter((id) => ids.has(id)).length;
|
||||||
|
if (assises >= places) throw refus('TABLE_PLEINE', { table: idTable, tour, capacite: places }, changerPlaces(idTable, places));
|
||||||
|
if (liste.length >= places) {
|
||||||
|
const absent = liste.filter((id) => !ids.has(id)).sort(croissant)[0];
|
||||||
|
if (absent !== undefined) {
|
||||||
|
liste.splice(liste.indexOf(absent), 1);
|
||||||
|
enReserve(retenu, tour, absent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
oter(retenu, tour, participant);
|
||||||
|
const garnie = etage.sieges[rangTable];
|
||||||
|
garnie.push(participant);
|
||||||
|
garnie.sort(croissant);
|
||||||
|
if (garnie.length > retenu.capacites[rangTable]) retenu.capacites[rangTable] = places;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
charge: retouche(charge, suivante),
|
||||||
|
libelle: libelle('ENTREE_DEPLACER_DANS_RETENU', {
|
||||||
|
personne: nomAffiche(personne),
|
||||||
|
de,
|
||||||
|
vers: table.numero,
|
||||||
|
siege: siegeLibelle,
|
||||||
|
tour,
|
||||||
|
}),
|
||||||
|
avertissements: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Les personnes que le plan du tour montre assises (etatPlaces, source le
|
||||||
|
// retenu). Une place du retenu à une table que la charge n'a plus, ou à un
|
||||||
|
// rang au-delà de sa capacité courante, n'y figure pas : elle se lit comme
|
||||||
|
// la réserve.
|
||||||
|
function assisesAffichees(charge, tour) {
|
||||||
|
const assises = new Set();
|
||||||
|
for (const { chaises } of etatPlaces(charge, { tour }).tables) {
|
||||||
|
for (const { occupant } of chaises) if (occupant !== null) assises.add(occupant);
|
||||||
|
}
|
||||||
|
return assises;
|
||||||
|
}
|
||||||
|
|
||||||
|
// La place qu'une personne occupe au tour telle que le plan la montre :
|
||||||
|
// { table, siege }, ou null quand le plan ne l'assied pas — en réserve, hors
|
||||||
|
// du retenu, ou absente d'assises (assisesAffichees) : à une table que la
|
||||||
|
// charge n'a plus, à un rang au-delà de la capacité courante, ou, sans
|
||||||
|
// attribution, parmi les présents qu'une liste porte au-delà des places. Le
|
||||||
|
// siège est le rang sous des sièges attribués ; sans attribution, celui de sa
|
||||||
|
// réservation à un siège à cette table, null sans elle. Les gestes du retenu
|
||||||
|
// y lisent le lieu de départ, et la pose de l'autre sur elle s'y contrôle.
|
||||||
|
function placeDe(charge, tour, id, assises) {
|
||||||
|
const lieu = lieuDans(charge.retenu, tour, id);
|
||||||
|
if (lieu === null || lieu === 'reserve' || !assises.has(id)) return null;
|
||||||
|
const idTable = charge.retenu.tables[lieu.table];
|
||||||
|
if (charge.retenu.siegesAttribues) return { table: idTable, siege: lieu.rang + 1 };
|
||||||
|
const tenue = charge.reservations.find(
|
||||||
|
(r) => r.participant === id && r.table === idTable && r.siege !== null && (r.portee === 'tous' || r.tour === tour),
|
||||||
|
);
|
||||||
|
return { table: idTable, siege: tenue?.siege ?? null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verse dans la réserve du tour une personne que le retenu range à une table
|
||||||
|
// alors que le plan ne la montre pas assise (place null, de placeDe) : sa
|
||||||
|
// liste la perd, et le geste part de la réserve, comme l'affichage. Personne
|
||||||
|
// n'entre ainsi dans la liste d'une table que la charge n'a plus.
|
||||||
|
function versLaReserveAffichee(retenu, tour, id, place) {
|
||||||
|
const lieu = lieuDans(retenu, tour, id);
|
||||||
|
if (place === null && lieu !== null && lieu !== 'reserve') {
|
||||||
|
oter(retenu, tour, id);
|
||||||
|
enReserve(retenu, tour, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sans attribution, verse dans la réserve du tour chaque présent (ids) que
|
||||||
|
// le plan ne montre pas assis (assises) aux tables des rangs donnés : leur
|
||||||
|
// liste vaut alors ce que l'affichage y assied, et le tri qui suit le geste
|
||||||
|
// n'assied personne d'autre que ceux qu'il nomme. Sous des sièges attribués,
|
||||||
|
// le rang est le siège et ne glisse pas : rien ne change.
|
||||||
|
function reduireAuxAssises(retenu, tour, rangs, assises, ids) {
|
||||||
|
if (retenu.siegesAttribues) return;
|
||||||
|
const etage = retenu.placement[tour - 1];
|
||||||
|
for (const rang of rangs) {
|
||||||
|
for (const id of etage.sieges[rang].filter((autre) => ids.has(autre) && !assises.has(autre))) {
|
||||||
|
oter(retenu, tour, id);
|
||||||
|
enReserve(retenu, tour, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Les rangs des tables où le retenu place ces personnes au tour.
|
||||||
|
function rangsDesTables(retenu, tour, personnes) {
|
||||||
|
return personnes.map((id) => lieuDans(retenu, tour, id)).filter((lieu) => lieu !== null && lieu !== 'reserve').map(({ table }) => table);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* echangerDansRetenu {tour, a, b} (§ 7.1) : a prend la place de b et b celle
|
||||||
|
* de a, au tour donné ; une place peut être la réserve. Chaque arrivée se
|
||||||
|
* contrôle comme un dépôt : PLACE_RESERVEE, RESERVEE_AILLEURS. Une personne
|
||||||
|
* que le retenu ne place pas y entre, en réserve aux autres tours.
|
||||||
|
*/
|
||||||
|
function echangerDansRetenu(charge, { tour, a, b } = {}) {
|
||||||
|
exigerModifiable(charge);
|
||||||
|
exigerRetenu(charge, tour);
|
||||||
|
if (a === b) throw new TypeError('echangerDansRetenu : deux personnes distinctes attendues');
|
||||||
|
const personneA = personneDe(charge, a);
|
||||||
|
const personneB = personneDe(charge, b);
|
||||||
|
for (const personne of [personneA, personneB]) {
|
||||||
|
if (personne.exclu) {
|
||||||
|
throw refus('PERSONNE_EXCLUE', { participant: personne.id }, { commande: 'reintegrerParticipant', arguments: { id: personne.id } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const assises = assisesAffichees(charge, tour);
|
||||||
|
const placeA = placeDe(charge, tour, a, assises);
|
||||||
|
const placeB = placeDe(charge, tour, b, assises);
|
||||||
|
if (placeA === null && placeB === null) throw refus('SANS_EFFET', { commande: 'echangerDansRetenu' });
|
||||||
|
const poses = [];
|
||||||
|
if (placeB !== null) poses.push(...controlerPose(charge, { participant: a, ...placeB, portee: 'tour', tour, retenu: true }).refus);
|
||||||
|
if (placeA !== null) poses.push(...controlerPose(charge, { participant: b, ...placeA, portee: 'tour', tour, retenu: true }).refus);
|
||||||
|
leverRefus(poses);
|
||||||
|
|
||||||
|
const deA = placeA === null ? null : numeroDuLieu(charge, tour, a);
|
||||||
|
const deB = placeB === null ? null : numeroDuLieu(charge, tour, b);
|
||||||
|
const suivante = structuredClone(charge);
|
||||||
|
const { retenu } = suivante;
|
||||||
|
for (const id of [a, b]) if (lieuDans(retenu, tour, id) === null) declarer(retenu, id);
|
||||||
|
reduireAuxAssises(retenu, tour, rangsDesTables(retenu, tour, [a, b]), assises, presents(charge));
|
||||||
|
// L'autre remplace dans la réserve une personne que le plan n'y montre pas assise.
|
||||||
|
versLaReserveAffichee(retenu, tour, a, placeA);
|
||||||
|
versLaReserveAffichee(retenu, tour, b, placeB);
|
||||||
|
const lieuA = lieuDans(retenu, tour, a);
|
||||||
|
const lieuB = lieuDans(retenu, tour, b);
|
||||||
|
const etage = retenu.placement[tour - 1];
|
||||||
|
const poser = (lieu, id) => {
|
||||||
|
if (lieu === 'reserve') enReserve(retenu, tour, id);
|
||||||
|
else etage.sieges[lieu.table][lieu.rang] = id;
|
||||||
|
};
|
||||||
|
if (lieuA === 'reserve') etage.reserve = etage.reserve.filter((id) => id !== a);
|
||||||
|
if (lieuB === 'reserve') etage.reserve = etage.reserve.filter((id) => id !== b);
|
||||||
|
poser(lieuB, a);
|
||||||
|
poser(lieuA, b);
|
||||||
|
if (!retenu.siegesAttribues) for (const liste of etage.sieges) liste.sort(croissant);
|
||||||
|
|
||||||
|
return {
|
||||||
|
charge: retouche(charge, suivante),
|
||||||
|
libelle: libelle('ENTREE_ECHANGER_DANS_RETENU', { a: nomAffiche(personneA), deA, b: nomAffiche(personneB), deB, tour }),
|
||||||
|
avertissements: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* retirerDuTour {tour, participant} (§ 7.1) : la personne quitte sa table au
|
||||||
|
* tour donné et attend dans la réserve de ce tour ; sa chaise se libère.
|
||||||
|
* Une personne que le plan du tour ne montre pas assise (placeDe) — en
|
||||||
|
* réserve, hors du retenu, à une table que la charge n'a plus ou à un rang
|
||||||
|
* au-delà de sa capacité courante — refuse SANS_EFFET.
|
||||||
|
*/
|
||||||
|
function retirerDuTour(charge, { tour, participant } = {}) {
|
||||||
|
exigerModifiable(charge);
|
||||||
|
exigerRetenu(charge, tour);
|
||||||
|
const personne = personneDe(charge, participant);
|
||||||
|
if (personne.exclu) {
|
||||||
|
throw refus('PERSONNE_EXCLUE', { participant }, { commande: 'reintegrerParticipant', arguments: { id: participant } });
|
||||||
|
}
|
||||||
|
const assises = assisesAffichees(charge, tour);
|
||||||
|
if (placeDe(charge, tour, participant, assises) === null) {
|
||||||
|
throw refus('SANS_EFFET', { commande: 'retirerDuTour' });
|
||||||
|
}
|
||||||
|
const numero = numeroDuLieu(charge, tour, participant);
|
||||||
|
const suivante = structuredClone(charge);
|
||||||
|
reduireAuxAssises(suivante.retenu, tour, rangsDesTables(suivante.retenu, tour, [participant]), assises, presents(charge));
|
||||||
|
oter(suivante.retenu, tour, participant);
|
||||||
|
enReserve(suivante.retenu, tour, participant);
|
||||||
|
return {
|
||||||
|
charge: retouche(charge, suivante),
|
||||||
|
libelle: libelle('ENTREE_RETIRER_DU_TOUR', { personne: nomAffiche(personne), numero, tour }),
|
||||||
|
avertissements: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** @type {Map<string, (charge: Object, args: Object) => Object>} */
|
/** @type {Map<string, (charge: Object, args: Object) => Object>} */
|
||||||
export const COMMANDES_PLACES = new Map();
|
export const COMMANDES_PLACES = new Map([
|
||||||
|
['reserver', reserver],
|
||||||
|
['retirerReservation', retirerReservation],
|
||||||
|
['deplacerReservation', deplacerReservation],
|
||||||
|
['poserTitre', poserTitre],
|
||||||
|
['retirerTitre', retirerTitre],
|
||||||
|
['designer', designer],
|
||||||
|
['convertirEnTous', convertirEnTous],
|
||||||
|
['deplacerDansRetenu', deplacerDansRetenu],
|
||||||
|
['echangerDansRetenu', echangerDansRetenu],
|
||||||
|
['retirerDuTour', retirerDuTour],
|
||||||
|
]);
|
||||||
|
|
|
||||||
899
src/application/commandes_places.test.js
Normal file
899
src/application/commandes_places.test.js
Normal file
|
|
@ -0,0 +1,899 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Épreuves des commandes des places (§ 4, § 4.1, § 5.8, § 5.9, § 7.1, § 8.2,
|
||||||
|
// § 8.9, § 9, § 14.12) : réservations, titres, désignation, conversion,
|
||||||
|
// retouches du retenu et leur vérificateur d'invariants, rangement par siège
|
||||||
|
// d'une génération. Les noms d'épreuve sont inventés ; la version vient du
|
||||||
|
// module engendré.
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { describe, test } from '../../test/lanceur.js';
|
||||||
|
import { FLUX, creerPcg32 } from '../demo/prng.js';
|
||||||
|
import { normaliser } from '../moteur/configuration.js';
|
||||||
|
import { rechercher } from '../moteur/recherche.js';
|
||||||
|
import { serialiser } from '../stockage/canonique.js';
|
||||||
|
import { analyser, configurationDepuisCharge, creerCharge } from '../stockage/document.js';
|
||||||
|
import { derive, fautes } from '../stockage/placements.js';
|
||||||
|
import { VERSION } from '../version.genere.js';
|
||||||
|
import { avertissementsApres } from './avertissements.js';
|
||||||
|
import { COMMANDES, appliquerCommande } from './commandes.js';
|
||||||
|
import { COMMANDES_PLACES } from './commandes_places.js';
|
||||||
|
import { ErreurCommande } from './erreurs.js';
|
||||||
|
import { libelle } from './libelles.js';
|
||||||
|
import { TABLE as TEXTES_DES_PLACES } from './libelles/places.js';
|
||||||
|
import { conversionsPossibles } from './listes_travail.js';
|
||||||
|
import { etatPlaces } from './places.js';
|
||||||
|
|
||||||
|
const executer = (nom, charge, args) => COMMANDES.get(nom)(charge, args);
|
||||||
|
|
||||||
|
const NOMS = [
|
||||||
|
['Ombrelle', 'Iris'],
|
||||||
|
['Pervenche', 'Théo'],
|
||||||
|
['Lacasse', 'Ondine'],
|
||||||
|
['Grisaille', null],
|
||||||
|
['Bruyère', 'Anouk'],
|
||||||
|
['Sarcelle', 'Capucine'],
|
||||||
|
['Ancolie', 'Amandine'],
|
||||||
|
['Mélèze', 'Basile'],
|
||||||
|
['Quenouille', 'Hortense'],
|
||||||
|
['Pivoine', 'Gaspard'],
|
||||||
|
['Aubépine', 'Colombe'],
|
||||||
|
['Lavande', 'Ysolde'],
|
||||||
|
['Zéphyrin', 'Lucioles'],
|
||||||
|
];
|
||||||
|
|
||||||
|
const personne = (id, autres = {}) => ({
|
||||||
|
id,
|
||||||
|
nom: NOMS[(id - 1) % NOMS.length][0],
|
||||||
|
prenom: NOMS[(id - 1) % NOMS.length][1],
|
||||||
|
appartenance: null,
|
||||||
|
courriel: null,
|
||||||
|
titrePressenti: null,
|
||||||
|
notes: null,
|
||||||
|
exclu: false,
|
||||||
|
...autres,
|
||||||
|
});
|
||||||
|
|
||||||
|
const resa = (participant, idTable, siege, tour = null) => ({
|
||||||
|
participant,
|
||||||
|
table: idTable,
|
||||||
|
siege,
|
||||||
|
portee: tour === null ? 'tous' : 'tour',
|
||||||
|
tour,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Le refus d'une ErreurCommande levée par appel, de ce code ; ses détails
|
||||||
|
// contiennent ceux donnés.
|
||||||
|
function refuse(appel, code, attendus = {}) {
|
||||||
|
let leve = null;
|
||||||
|
try {
|
||||||
|
appel();
|
||||||
|
} catch (erreur) {
|
||||||
|
leve = erreur;
|
||||||
|
}
|
||||||
|
assert.ok(leve instanceof ErreurCommande, `ErreurCommande ${code} attendue, reçu ${leve}`);
|
||||||
|
assert.equal(leve.code, code, leve.message);
|
||||||
|
for (const [cle, valeur] of Object.entries(attendus)) assert.deepEqual(leve.details[cle], valeur, cle);
|
||||||
|
return leve;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gèle une valeur en profondeur : une commande qui la modifierait lèverait.
|
||||||
|
function figer(valeur) {
|
||||||
|
if (valeur !== null && typeof valeur === 'object' && !Object.isFrozen(valeur)) {
|
||||||
|
Object.freeze(valeur);
|
||||||
|
for (const element of Object.values(valeur)) figer(element);
|
||||||
|
}
|
||||||
|
return valeur;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Un plan de fichier ; la réserve de chaque tour porte les déclarés qu'aucune
|
||||||
|
// liste n'assied.
|
||||||
|
function planFichier({ tables, capacites, participants, tours, siegesAttribues }) {
|
||||||
|
return {
|
||||||
|
siegesAttribues,
|
||||||
|
tables,
|
||||||
|
capacites,
|
||||||
|
tours: tours.length,
|
||||||
|
participants,
|
||||||
|
placement: tours.map((listes) => {
|
||||||
|
const assis = new Set(listes.flat());
|
||||||
|
return { sieges: listes, reserve: participants.filter((id) => !assis.has(id)) };
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const DOUZE = Array.from({ length: 12 }, (_, rang) => rang + 1);
|
||||||
|
|
||||||
|
// Douze personnes, trois tours, les tables numérotées 4 et 7 d'identifiants
|
||||||
|
// 5 et 8, de huit places ; la proposition 1, retenue, que tours donne.
|
||||||
|
function chargeRetenue({ siegesAttribues = true, tours, capacite = 8, reservations = [] } = {}) {
|
||||||
|
const charge = creerCharge({ id: 'evt-retenu', nom: 'Soirée des Lucioles', siegesParDefaut: capacite, tours: 3 });
|
||||||
|
charge.reglages.attribuerSieges = siegesAttribues;
|
||||||
|
charge.participants = DOUZE.map((id) => personne(id));
|
||||||
|
charge.tables = [
|
||||||
|
{ id: 5, numero: 4, sieges: null, forme: 'ronde', position: { x: 0, y: 0 } },
|
||||||
|
{ id: 8, numero: 7, sieges: null, forme: 'carree', position: { x: 400, y: 0 } },
|
||||||
|
];
|
||||||
|
charge.reservations = reservations;
|
||||||
|
const plan = planFichier({
|
||||||
|
tables: [5, 8],
|
||||||
|
capacites: [capacite, capacite],
|
||||||
|
participants: DOUZE,
|
||||||
|
siegesAttribues,
|
||||||
|
tours: tours ?? [
|
||||||
|
[[1, 2, 3, 4, 5, 6], [7, 8, 9, 10, 11, 12]],
|
||||||
|
[[1, 8, 9, 10, 11, 12], [7, 2, 3, 4, 5, 6]],
|
||||||
|
[[1, 2, 3, 7, 8, 9], [4, 5, 6, 10, 11, 12]],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
charge.propositions = [{ id: 1, graine: 48272, arret: 200000, historique: 1000, produitVersion: VERSION.affichee, ...plan }];
|
||||||
|
charge.retenu = { proposition: 1, ...structuredClone(plan) };
|
||||||
|
charge.evenement.etat = 'retenu';
|
||||||
|
charge.prochainsIds = { participant: 13, table: 9, proposition: 2 };
|
||||||
|
return charge;
|
||||||
|
}
|
||||||
|
|
||||||
|
const listes = (charge, tour) => charge.retenu.placement[tour - 1].sieges;
|
||||||
|
const reserveDe = (charge, tour) => charge.retenu.placement[tour - 1].reserve;
|
||||||
|
|
||||||
|
describe('commandes des places : le registre (§ 8.2)', () => {
|
||||||
|
test('COMMANDES_PLACES porte les dix commandes des places, réunies dans COMMANDES', () => {
|
||||||
|
assert.deepEqual([...COMMANDES_PLACES.keys()].sort(), [
|
||||||
|
'convertirEnTous',
|
||||||
|
'deplacerDansRetenu',
|
||||||
|
'deplacerReservation',
|
||||||
|
'designer',
|
||||||
|
'echangerDansRetenu',
|
||||||
|
'poserTitre',
|
||||||
|
'reserver',
|
||||||
|
'retirerDuTour',
|
||||||
|
'retirerReservation',
|
||||||
|
'retirerTitre',
|
||||||
|
]);
|
||||||
|
for (const [nom, commande] of COMMANDES_PLACES) assert.equal(COMMANDES.get(nom), commande);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un plan bloqué refuse chaque commande des places, remède débloquer', () => {
|
||||||
|
const charge = chargeRetenue();
|
||||||
|
charge.evenement.etat = 'bloque';
|
||||||
|
const essais = [
|
||||||
|
['reserver', { participant: 1, table: 5, siege: 1, portee: 'tous', tour: null }],
|
||||||
|
['retirerReservation', { participant: 1, table: 5, portee: 'tous', tour: null }],
|
||||||
|
['deplacerReservation', { participant: 1, de: { table: 5, portee: 'tous', tour: null }, vers: { table: 8, siege: 1 } }],
|
||||||
|
['poserTitre', { table: 5, siege: 1, libelle: 'animation' }],
|
||||||
|
['retirerTitre', { table: 5, siege: 1 }],
|
||||||
|
['designer', { participant: 1, table: 5, siege: 1, libelle: 'animation' }],
|
||||||
|
['convertirEnTous', { participant: 1, table: 5 }],
|
||||||
|
['deplacerDansRetenu', { tour: 1, participant: 1, table: 8, siege: 7 }],
|
||||||
|
['echangerDansRetenu', { tour: 1, a: 1, b: 7 }],
|
||||||
|
['retirerDuTour', { tour: 1, participant: 1 }],
|
||||||
|
];
|
||||||
|
assert.equal(essais.length, COMMANDES_PLACES.size);
|
||||||
|
for (const [nom, args] of essais) refuse(() => executer(nom, charge, args), 'PLAN_BLOQUE', { remede: { geste: 'debloquer' } });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('commandes des places : réservations et libellés figés (§ 5.9, § 8.2)', () => {
|
||||||
|
test('reserver : la réservation, et le libellé qui nomme la personne et le numéro de la table', () => {
|
||||||
|
const charge = chargeRetenue();
|
||||||
|
const { charge: apres, libelle, avertissements } = executer('reserver', charge, {
|
||||||
|
participant: 2,
|
||||||
|
table: 5,
|
||||||
|
siege: 2,
|
||||||
|
portee: 'tour',
|
||||||
|
tour: 2,
|
||||||
|
});
|
||||||
|
assert.equal(libelle, 'Réservé Théo Pervenche à la table 4, siège 2, tour 2');
|
||||||
|
assert.deepEqual(apres.reservations, [resa(2, 5, 2, 2)]);
|
||||||
|
assert.deepEqual(avertissements, []);
|
||||||
|
assert.equal(
|
||||||
|
executer('reserver', charge, { participant: 4, table: 8, siege: null, portee: 'tous', tour: null }).libelle,
|
||||||
|
'Réservé Grisaille à la table 7, tous les tours',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reserver une réservation qui existe déjà : SANS_EFFET ; une table gelée avertit', () => {
|
||||||
|
const charge = chargeRetenue({ capacite: 2, tours: [[[1, 2], [3, 4]], [[1, 3], [2, 4]], [[1, 4], [2, 3]]] });
|
||||||
|
const une = executer('reserver', charge, { participant: 1, table: 5, siege: null, portee: 'tous', tour: null }).charge;
|
||||||
|
refuse(() => executer('reserver', une, { participant: 1, table: 5, siege: null, portee: 'tous', tour: null }), 'SANS_EFFET', {
|
||||||
|
commande: 'reserver',
|
||||||
|
});
|
||||||
|
const { avertissements } = executer('reserver', une, { participant: 2, table: 5, siege: null, portee: 'tous', tour: null });
|
||||||
|
assert.deepEqual(avertissements, [{ code: 'TABLE_GELEE', details: { table: 5 } }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('retirer, déplacer : la réservation désignée par sa table et sa portée ; une réservation absente est refusée', () => {
|
||||||
|
const charge = chargeRetenue({ reservations: [resa(1, 5, 1), resa(2, 8, 2, 2)] });
|
||||||
|
const retiree = executer('retirerReservation', charge, { participant: 2, table: 8, portee: 'tour', tour: 2 });
|
||||||
|
assert.deepEqual(retiree.charge.reservations, [resa(1, 5, 1)]);
|
||||||
|
assert.equal(retiree.libelle, 'Réservation retirée : Théo Pervenche, table 7, tour 2');
|
||||||
|
refuse(() => executer('retirerReservation', charge, { participant: 2, table: 8, portee: 'tous', tour: null }), 'RESERVATION_ABSENTE', {
|
||||||
|
participant: 2,
|
||||||
|
remede: null,
|
||||||
|
});
|
||||||
|
const deplacee = executer('deplacerReservation', charge, {
|
||||||
|
participant: 1,
|
||||||
|
de: { table: 5, portee: 'tous', tour: null },
|
||||||
|
vers: { table: 8, siege: 3 },
|
||||||
|
});
|
||||||
|
assert.deepEqual(deplacee.charge.reservations, [resa(1, 8, 3), resa(2, 8, 2, 2)]);
|
||||||
|
assert.equal(deplacee.libelle, 'Réservation déplacée : Iris Ombrelle, de la table 4 à la table 7, siège 3, tous les tours');
|
||||||
|
refuse(
|
||||||
|
() => executer('deplacerReservation', charge, { participant: 1, de: { table: 5, portee: 'tous', tour: null }, vers: { table: 8, siege: 2 } }),
|
||||||
|
'PLACE_RESERVEE',
|
||||||
|
{ titulaire: 2 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('commandes des places : titres et désignation (§ 4, § 4.1, § 2.1 étape 6)', () => {
|
||||||
|
// Deux tables, l'une de 2 places, trois tours, sans retenu.
|
||||||
|
function chargeTitres(attribuerSieges = false) {
|
||||||
|
const charge = creerCharge({ id: 'evt-titres', nom: 'Soirée des Lucioles', siegesParDefaut: 6, tours: 3 });
|
||||||
|
charge.reglages.attribuerSieges = attribuerSieges;
|
||||||
|
charge.participants = [1, 2, 3, 4].map((id) => personne(id));
|
||||||
|
charge.tables = [
|
||||||
|
{ id: 1, numero: 1, sieges: 2, forme: 'ronde', position: { x: 0, y: 0 } },
|
||||||
|
{ id: 3, numero: 3, sieges: null, forme: 'ronde', position: { x: 400, y: 0 } },
|
||||||
|
];
|
||||||
|
charge.prochainsIds = { participant: 5, table: 4, proposition: 1 };
|
||||||
|
return charge;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('désigner Iris à la table 3 : une réservation « tous » et un titre au même siège, une entrée', () => {
|
||||||
|
const { charge, libelle } = executer('designer', chargeTitres(true), { participant: 1, table: 3, siege: 2, libelle: 'animatrice' });
|
||||||
|
assert.deepEqual(charge.reservations, [resa(1, 3, 2)]);
|
||||||
|
assert.deepEqual(charge.titres, [{ table: 3, siege: 2, libelle: 'animatrice' }]);
|
||||||
|
assert.equal(libelle, 'Désigné Iris Ombrelle à la table 3, siège 2 : « animatrice »');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un titre non pourvu déjà posé à cette place est pourvu, aucun second', () => {
|
||||||
|
const titree = executer('poserTitre', chargeTitres(), { table: 3, siege: 4, libelle: 'hôte' }).charge;
|
||||||
|
const { charge } = executer('designer', titree, { participant: 2, table: 3, siege: null, libelle: 'animateur' });
|
||||||
|
assert.deepEqual(charge.titres, [{ table: 3, siege: 4, libelle: 'hôte' }]);
|
||||||
|
assert.deepEqual(charge.reservations, [resa(2, 3, 4)]);
|
||||||
|
const deuxieme = executer('designer', charge, { participant: 3, table: 3, siege: null, libelle: 'veille' }).charge;
|
||||||
|
assert.deepEqual(deuxieme.titres, [{ table: 3, siege: 4, libelle: 'hôte' }, { table: 3, siege: 1, libelle: 'veille' }]);
|
||||||
|
assert.deepEqual(deuxieme.reservations.at(-1), resa(3, 3, 1));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('poserTitre : vide refusé LIBELLE_VIDE sans écriture ; NFC sans blancs de bord ; place titrée PLACE_TITREE', () => {
|
||||||
|
const charge = figer(chargeTitres(true));
|
||||||
|
for (const libelle of ['', ' ']) {
|
||||||
|
refuse(() => executer('poserTitre', charge, { table: 3, siege: 1, libelle }), 'LIBELLE_VIDE', { remede: null });
|
||||||
|
refuse(() => executer('designer', charge, { participant: 1, table: 3, siege: 1, libelle }), 'LIBELLE_VIDE', { remede: null });
|
||||||
|
}
|
||||||
|
const nfd = ' Ho\u{0302}tesse\u{00A0}';
|
||||||
|
const { charge: titree, libelle } = executer('poserTitre', charge, { table: 3, siege: 1, libelle: nfd });
|
||||||
|
assert.deepEqual(titree.titres, [{ table: 3, siege: 1, libelle: 'H\u{00F4}tesse' }]);
|
||||||
|
assert.equal(libelle, 'Posé le titre « H\u{00F4}tesse » à la table 3, siège 1');
|
||||||
|
refuse(() => executer('poserTitre', titree, { table: 3, siege: 1, libelle: 'hôte' }), 'PLACE_TITREE', {
|
||||||
|
table: 3,
|
||||||
|
siege: 1,
|
||||||
|
libelle: 'H\u{00F4}tesse',
|
||||||
|
remede: { commande: 'retirerTitre', arguments: { table: 3, siege: 1 } },
|
||||||
|
});
|
||||||
|
refuse(() => executer('poserTitre', titree, { table: 3, siege: 7, libelle: 'hôte' }), 'SIEGE_INEXISTANT', { siege: 7, capacite: 6 });
|
||||||
|
const retire = executer('retirerTitre', titree, { table: 3, siege: 1 });
|
||||||
|
assert.deepEqual(retire.charge.titres, []);
|
||||||
|
assert.equal(retire.libelle, 'Retiré le titre « H\u{00F4}tesse » de la table 3, siège 1');
|
||||||
|
refuse(() => executer('retirerTitre', retire.charge, { table: 3, siege: 1 }), 'TITRE_ABSENT', { remede: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mode table : un titre sans siège prend le plus petit sans titre ; une table de 2 aux deux sièges titrés refuse AUCUN_SIEGE_SANS_TITRE', () => {
|
||||||
|
let charge = chargeTitres();
|
||||||
|
charge = executer('poserTitre', charge, { table: 1, siege: 2, libelle: 'accueil' }).charge;
|
||||||
|
charge = executer('poserTitre', charge, { table: 1, siege: null, libelle: 'animation' }).charge;
|
||||||
|
assert.deepEqual(charge.titres.at(-1), { table: 1, siege: 1, libelle: 'animation' });
|
||||||
|
refuse(() => executer('poserTitre', charge, { table: 1, siege: null, libelle: 'veille' }), 'AUCUN_SIEGE_SANS_TITRE', {
|
||||||
|
table: 1,
|
||||||
|
capacite: 2,
|
||||||
|
remede: { commande: 'changerPlacesTable', arguments: { id: 1, sieges: 3, origine: 'champ' } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('convertirEnTous : refusé sans les R réservations « tour » à une table ; libellé', () => {
|
||||||
|
let charge = chargeTitres();
|
||||||
|
refuse(() => executer('convertirEnTous', charge, { participant: 1, table: 3 }), 'CONVERSION_IMPOSSIBLE', { remede: null });
|
||||||
|
for (const tour of [1, 2, 3]) charge = executer('reserver', charge, { participant: 1, table: 3, siege: 5, portee: 'tour', tour }).charge;
|
||||||
|
const { charge: convertie, libelle } = executer('convertirEnTous', charge, { participant: 1, table: 3 });
|
||||||
|
assert.deepEqual(convertie.reservations, [resa(1, 3, 5)]);
|
||||||
|
assert.equal(libelle, 'Converti en « tous les tours » : Iris Ombrelle, table 3');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('commandes des places : les invariants des réservations (§ 5.9, § 14.12)', () => {
|
||||||
|
// Σ (c_t − k_t) − n de l'instance, et ses ancrés par table.
|
||||||
|
const ecart = ({ capacite, ancresParTable, n }) =>
|
||||||
|
capacite.reduce((somme, c) => somme + c, 0) - ancresParTable.reduce((somme, k) => somme + k, 0) - n;
|
||||||
|
|
||||||
|
// Une petite configuration tirée : deux à quatre tables de 2 à 5 places,
|
||||||
|
// un à trois tours, trois à douze personnes dont parfois une exclue.
|
||||||
|
function configurationTiree(rng) {
|
||||||
|
const tours = 1 + rng.borne(3);
|
||||||
|
const charge = creerCharge({ id: 'evt-grille', nom: 'Soirée des Lucioles', siegesParDefaut: 2 + rng.borne(4), tours });
|
||||||
|
charge.reglages.attribuerSieges = rng.borne(2) === 1;
|
||||||
|
const nombre = 3 + rng.borne(10);
|
||||||
|
charge.participants = Array.from({ length: nombre }, (_, rang) => personne(rang + 1, { exclu: rng.borne(6) === 0 }));
|
||||||
|
const tables = 2 + rng.borne(3);
|
||||||
|
charge.tables = Array.from({ length: tables }, (_, rang) => ({
|
||||||
|
id: rang + 1,
|
||||||
|
numero: rang + 1,
|
||||||
|
sieges: rng.borne(2) === 0 ? null : 2 + rng.borne(4),
|
||||||
|
forme: 'ronde',
|
||||||
|
position: { x: 300 * rang, y: 0 },
|
||||||
|
}));
|
||||||
|
charge.prochainsIds = { participant: nombre + 1, table: tables + 1, proposition: 1 };
|
||||||
|
return charge;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Une commande de réservation tirée sur la charge, et les tables qu'elle
|
||||||
|
// touche : arguments valides en forme, que la commande accepte ou refuse.
|
||||||
|
function commandeTiree(rng, charge) {
|
||||||
|
const R = charge.evenement.tours;
|
||||||
|
const participant = 1 + rng.borne(charge.participants.length);
|
||||||
|
const idTable = 1 + rng.borne(charge.tables.length);
|
||||||
|
const siege = rng.borne(3) === 0 ? null : 1 + rng.borne(5);
|
||||||
|
const portee = rng.borne(2) === 0 ? 'tous' : 'tour';
|
||||||
|
const tour = portee === 'tous' ? null : 1 + rng.borne(R);
|
||||||
|
const existante = charge.reservations.length > 0 ? charge.reservations[rng.borne(charge.reservations.length)] : null;
|
||||||
|
const choix = rng.borne(6);
|
||||||
|
if (choix <= 1 || existante === null) return ['reserver', { participant, table: idTable, siege, portee, tour }, [idTable]];
|
||||||
|
if (choix === 2) {
|
||||||
|
const { participant: p, table: t, portee: po, tour: to } = existante;
|
||||||
|
return ['retirerReservation', { participant: p, table: t, portee: po, tour: to }, [t]];
|
||||||
|
}
|
||||||
|
if (choix === 3) {
|
||||||
|
const { participant: p, table: t, portee: po, tour: to } = existante;
|
||||||
|
return ['deplacerReservation', { participant: p, de: { table: t, portee: po, tour: to }, vers: { table: idTable, siege } }, [t, idTable]];
|
||||||
|
}
|
||||||
|
if (choix === 4) {
|
||||||
|
const possibles = conversionsPossibles(charge);
|
||||||
|
const cible = possibles.length > 0 ? possibles[rng.borne(possibles.length)] : { participant, table: idTable };
|
||||||
|
return ['convertirEnTous', cible, [cible.table]];
|
||||||
|
}
|
||||||
|
return ['designer', { participant, table: idTable, siege, libelle: 'animation' }, [idTable]];
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Σ (c_t − k_t) − n ne change pas ; la charge rendue se relit et se normalise ; les ancrés ne bougent qu’aux tables touchées', () => {
|
||||||
|
assert.ok(!Object.values(FLUX).includes(7), 'le flux 7 est hors de FLUX');
|
||||||
|
const rng = creerPcg32(48271, 7);
|
||||||
|
let acceptees = 0;
|
||||||
|
const parCommande = new Map();
|
||||||
|
for (let essai = 0; essai < 300; essai += 1) {
|
||||||
|
let charge = configurationTiree(rng);
|
||||||
|
for (let geste = 0; geste < 6; geste += 1) {
|
||||||
|
const avant = normaliser(configurationDepuisCharge(charge));
|
||||||
|
const [nom, args, touchees] = commandeTiree(rng, charge);
|
||||||
|
let rendu;
|
||||||
|
try {
|
||||||
|
rendu = appliquerCommande(nom, charge, args);
|
||||||
|
} catch (erreur) {
|
||||||
|
if (erreur instanceof ErreurCommande) continue;
|
||||||
|
throw erreur;
|
||||||
|
}
|
||||||
|
acceptees += 1;
|
||||||
|
parCommande.set(nom, (parCommande.get(nom) ?? 0) + 1);
|
||||||
|
const texte = serialiser(rendu.charge, { revision: 1, produitVersion: 'essai' });
|
||||||
|
assert.doesNotThrow(() => analyser(texte), `${nom} ${JSON.stringify(args)}`);
|
||||||
|
const apres = normaliser(configurationDepuisCharge(rendu.charge));
|
||||||
|
assert.equal(ecart(apres), ecart(avant), `${nom} ${JSON.stringify(args)}`);
|
||||||
|
avant.ancresParTable.forEach((k, t) => {
|
||||||
|
const delta = apres.ancresParTable[t] - k;
|
||||||
|
const idTable = avant.idsTables[t];
|
||||||
|
if (nom === 'convertirEnTous' || !touchees.includes(idTable)) assert.equal(delta, 0, `${nom} table ${idTable}`);
|
||||||
|
else assert.ok(Math.abs(delta) <= 1, `${nom} table ${idTable} : ${delta}`);
|
||||||
|
});
|
||||||
|
charge = rendu.charge;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.ok(acceptees >= 200, `${acceptees} gestes acceptés`);
|
||||||
|
for (const nom of ['reserver', 'retirerReservation', 'deplacerReservation', 'convertirEnTous', 'designer']) {
|
||||||
|
assert.ok((parCommande.get(nom) ?? 0) > 0, `${nom} jamais accepté`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('commandes des places : le retenu (§ 7.1, § 8.2, § 8.9)', () => {
|
||||||
|
test('au tour 2, déplacer, échanger, retirer ne changent que le tour 2 du retenu, jamais la proposition', () => {
|
||||||
|
const charge = figer(chargeRetenue());
|
||||||
|
const deplace = executer('deplacerDansRetenu', charge, { tour: 2, participant: 1, table: 8, siege: 7 });
|
||||||
|
assert.equal(deplace.libelle, 'Déplacé Iris Ombrelle de la table 4 à la table 7, tour 2');
|
||||||
|
assert.deepEqual(listes(deplace.charge, 2), [[null, 8, 9, 10, 11, 12], [7, 2, 3, 4, 5, 6, 1]]);
|
||||||
|
const echange = executer('echangerDansRetenu', charge, { tour: 2, a: 1, b: 2 });
|
||||||
|
assert.deepEqual(listes(echange.charge, 2), [[2, 8, 9, 10, 11, 12], [7, 1, 3, 4, 5, 6]]);
|
||||||
|
assert.equal(echange.libelle, 'Échangé Iris Ombrelle (table 4) et Théo Pervenche (table 7), tour 2');
|
||||||
|
const retire = executer('retirerDuTour', charge, { tour: 2, participant: 9 });
|
||||||
|
assert.equal(retire.libelle, 'Retiré Hortense Quenouille de la table 4, tour 2');
|
||||||
|
assert.deepEqual(reserveDe(retire.charge, 2), [9]);
|
||||||
|
assert.deepEqual(etatPlaces(retire.charge, { tour: 2 }).reserve, [9]);
|
||||||
|
for (const tour of [1, 3]) assert.deepEqual(etatPlaces(retire.charge, { tour }).reserve, []);
|
||||||
|
for (const { charge: apres } of [deplace, echange, retire]) {
|
||||||
|
assert.deepEqual(apres.propositions, charge.propositions);
|
||||||
|
for (const tour of [1, 3]) assert.deepEqual(apres.retenu.placement[tour - 1], charge.retenu.placement[tour - 1]);
|
||||||
|
assert.deepEqual(fautes(apres.retenu), []);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sièges attribués : un départ laisse sa chaise vide, et tout autre siège reste le même', () => {
|
||||||
|
const charge = chargeRetenue();
|
||||||
|
const sans3 = executer('retirerDuTour', charge, { tour: 1, participant: 3 }).charge;
|
||||||
|
assert.deepEqual(listes(sans3, 1)[0], [1, 2, null, 4, 5, 6]);
|
||||||
|
const sans6 = executer('retirerDuTour', sans3, { tour: 1, participant: 6 }).charge;
|
||||||
|
assert.deepEqual(listes(sans6, 1)[0], [1, 2, null, 4, 5]);
|
||||||
|
assert.deepEqual(fautes(sans6.retenu), []);
|
||||||
|
const retour = executer('deplacerDansRetenu', sans6, { tour: 1, participant: 12, table: 5, siege: 3 }).charge;
|
||||||
|
assert.deepEqual(listes(retour, 1), [[1, 2, 12, 4, 5], [7, 8, 9, 10, 11]]);
|
||||||
|
// La personne qui quitte sa table pour une autre libère aussi sa chaise.
|
||||||
|
const quitte = executer('deplacerDansRetenu', charge, { tour: 1, participant: 9, table: 5, siege: 7 }).charge;
|
||||||
|
assert.deepEqual(listes(quitte, 1), [[1, 2, 3, 4, 5, 6, 9], [7, 8, null, 10, 11, 12]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sièges attribués : un dépôt sur la chaise 8 d’une table de 8 qui en tient cinq, les rangs sautés à null', () => {
|
||||||
|
const charge = chargeRetenue({
|
||||||
|
tours: [
|
||||||
|
[[1, 2, 3, 4, 5], [6, 7, 8, 9, 10, 11, 12]],
|
||||||
|
[[1, 8, 9, 10, 11, 12], [7, 2, 3, 4, 5, 6]],
|
||||||
|
[[1, 2, 3, 7, 8, 9], [4, 5, 6, 10, 11, 12]],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const { charge: apres } = executer('deplacerDansRetenu', charge, { tour: 1, participant: 6, table: 5, siege: 8 });
|
||||||
|
assert.deepEqual(listes(apres, 1), [[1, 2, 3, 4, 5, null, null, 6], [null, 7, 8, 9, 10, 11, 12]]);
|
||||||
|
assert.deepEqual(fautes(apres.retenu), []);
|
||||||
|
refuse(() => executer('deplacerDansRetenu', charge, { tour: 1, participant: 6, table: 5, siege: 2 }), 'SIEGE_OCCUPE', {
|
||||||
|
table: 5,
|
||||||
|
siege: 2,
|
||||||
|
tour: 1,
|
||||||
|
occupant: 2,
|
||||||
|
remede: { commande: 'echangerDansRetenu', arguments: { tour: 1, a: 6, b: 2 } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sans attribution : la liste reste triée, une table pleine de présents refuse TABLE_PLEINE', () => {
|
||||||
|
const charge = figer(chargeRetenue({ siegesAttribues: false, capacite: 6 }));
|
||||||
|
// La table 4 tient six présents : refusée ; libérée d'un, elle reçoit.
|
||||||
|
refuse(() => executer('deplacerDansRetenu', charge, { tour: 1, participant: 12, table: 5, siege: null }), 'TABLE_PLEINE', {
|
||||||
|
table: 5,
|
||||||
|
tour: 1,
|
||||||
|
capacite: 6,
|
||||||
|
remede: { commande: 'changerPlacesTable', arguments: { id: 5, sieges: 7, origine: 'champ' } },
|
||||||
|
});
|
||||||
|
const libre = executer('retirerDuTour', charge, { tour: 1, participant: 2 }).charge;
|
||||||
|
assert.deepEqual(listes(libre, 1)[0], [1, 3, 4, 5, 6]);
|
||||||
|
const recue = executer('deplacerDansRetenu', libre, { tour: 1, participant: 12, table: 5, siege: null }).charge;
|
||||||
|
assert.deepEqual(listes(recue, 1), [[1, 3, 4, 5, 6, 12], [7, 8, 9, 10, 11]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('une retouche qui assiérait cinq personnes à une table de 4 au tour 2 est refusée, sans écriture', () => {
|
||||||
|
const charge = figer(
|
||||||
|
chargeRetenue({
|
||||||
|
siegesAttribues: false,
|
||||||
|
capacite: 4,
|
||||||
|
tours: [
|
||||||
|
[[1, 2, 3, 4], [5, 6, 7, 8]],
|
||||||
|
[[1, 2, 3, 4], [5, 6, 7, 8]],
|
||||||
|
[[1, 2, 3, 4], [5, 6, 7, 8]],
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
refuse(() => appliquerCommande('deplacerDansRetenu', charge, { tour: 2, participant: 9, table: 5, siege: null }), 'TABLE_PLEINE');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('la proposition montrée n’est jamais la cible : sans retenu, ou à un tour qu’il ne porte pas, une retouche est une faute du code', () => {
|
||||||
|
const sans = chargeRetenue();
|
||||||
|
sans.retenu = null;
|
||||||
|
assert.throws(() => executer('retirerDuTour', sans, { tour: 1, participant: 1 }), TypeError);
|
||||||
|
assert.throws(() => executer('retirerDuTour', chargeRetenue(), { tour: 4, participant: 1 }), TypeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un retenu fautif refuse ses retouches RETENU_FAUTIF, remède la version précédente', () => {
|
||||||
|
const charge = chargeRetenue();
|
||||||
|
charge.retenu.placement[0].reserve = [1];
|
||||||
|
for (const [nom, args] of [
|
||||||
|
['deplacerDansRetenu', { tour: 2, participant: 1, table: 8, siege: 7 }],
|
||||||
|
['echangerDansRetenu', { tour: 2, a: 1, b: 2 }],
|
||||||
|
['retirerDuTour', { tour: 2, participant: 1 }],
|
||||||
|
]) {
|
||||||
|
refuse(() => executer(nom, charge, args), 'RETENU_FAUTIF', { remede: { geste: 'ouvrirSecours', source: 'precedent' } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('une place réservée à B : refusée PLACE_RESERVEE par dépôt, puis par échange', () => {
|
||||||
|
const charge = chargeRetenue({ reservations: [resa(2, 8, 8, 2), resa(7, 8, 1)] });
|
||||||
|
refuse(() => executer('deplacerDansRetenu', charge, { tour: 2, participant: 9, table: 8, siege: 8 }), 'PLACE_RESERVEE', {
|
||||||
|
titulaire: 2,
|
||||||
|
table: 8,
|
||||||
|
siege: 8,
|
||||||
|
});
|
||||||
|
refuse(() => executer('echangerDansRetenu', charge, { tour: 2, a: 9, b: 7 }), 'PLACE_RESERVEE', { titulaire: 7, siege: 1 });
|
||||||
|
// Contre sa propre réservation : RESERVEE_AILLEURS.
|
||||||
|
refuse(() => executer('deplacerDansRetenu', charge, { tour: 2, participant: 7, table: 5, siege: 7 }), 'RESERVEE_AILLEURS', {
|
||||||
|
participant: 7,
|
||||||
|
tour: 2,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('commandes des places : le retenu et la dérive (§ 9, § 4.4)', () => {
|
||||||
|
test('une personne ajoutée après la retenue attend en réserve ; déplacée au tour 2, elle y est assise et attend aux autres', () => {
|
||||||
|
const charge = chargeRetenue();
|
||||||
|
charge.participants.push(personne(13));
|
||||||
|
charge.prochainsIds.participant = 14;
|
||||||
|
for (const tour of [1, 2, 3]) assert.ok(etatPlaces(charge, { tour }).reserve.includes(13));
|
||||||
|
const { charge: apres, libelle } = executer('deplacerDansRetenu', charge, { tour: 2, participant: 13, table: 8, siege: 8 });
|
||||||
|
assert.equal(libelle, 'Déplacé Lucioles Zéphyrin de la réserve à la table 7, tour 2');
|
||||||
|
assert.deepEqual(listes(apres, 2)[1], [7, 2, 3, 4, 5, 6, null, 13]);
|
||||||
|
assert.deepEqual(reserveDe(apres, 1), [13]);
|
||||||
|
assert.deepEqual(reserveDe(apres, 3), [13]);
|
||||||
|
assert.ok(apres.retenu.participants.includes(13));
|
||||||
|
assert.deepEqual(fautes(apres.retenu), []);
|
||||||
|
assert.ok(!derive(apres.retenu, apres).some(({ participant }) => participant === 13));
|
||||||
|
assert.equal(etatPlaces(apres, { tour: 2 }).tables[1].chaises[7].occupant, 13);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Iris, exclue après la retenue, au siège 3 du tour 2 : y déposer Théo l’envoie en réserve, sans renuméroter personne', () => {
|
||||||
|
const charge = chargeRetenue({
|
||||||
|
tours: [
|
||||||
|
[[1, 2, 3, 4, 5, 6], [7, 8, 9, 10, 11, 12]],
|
||||||
|
[[8, 9, 1, 10, 11, 12], [7, 2, 3, 4, 5, 6]],
|
||||||
|
[[1, 2, 3, 7, 8, 9], [4, 5, 6, 10, 11, 12]],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
charge.participants[0].exclu = true;
|
||||||
|
const { charge: apres } = executer('deplacerDansRetenu', charge, { tour: 2, participant: 2, table: 5, siege: 3 });
|
||||||
|
assert.deepEqual(listes(apres, 2), [[8, 9, 2, 10, 11, 12], [7, null, 3, 4, 5, 6]]);
|
||||||
|
assert.deepEqual(reserveDe(apres, 2), [1]);
|
||||||
|
refuse(() => executer('deplacerDansRetenu', charge, { tour: 2, participant: 1, table: 8, siege: 8 }), 'PERSONNE_EXCLUE', {
|
||||||
|
participant: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sans attribution, une table pleine qui porte un exclu reçoit le dépôt ; l’exclu au plus petit identifiant va en réserve', () => {
|
||||||
|
const charge = chargeRetenue({ siegesAttribues: false, capacite: 6 });
|
||||||
|
charge.participants[1].exclu = true;
|
||||||
|
charge.participants[3].exclu = true;
|
||||||
|
const { charge: apres } = executer('deplacerDansRetenu', charge, { tour: 1, participant: 12, table: 5, siege: null });
|
||||||
|
assert.deepEqual(listes(apres, 1), [[1, 3, 4, 5, 6, 12], [7, 8, 9, 10, 11]]);
|
||||||
|
assert.deepEqual(reserveDe(apres, 1), [2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('une table créée après la retenue, écrite comme creerTable la laissera, reçoit un dépôt', () => {
|
||||||
|
const charge = chargeRetenue();
|
||||||
|
charge.tables.push({ id: 9, numero: 9, sieges: null, forme: 'ronde', position: { x: 800, y: 0 } });
|
||||||
|
charge.prochainsIds.table = 10;
|
||||||
|
charge.retenu.tables.push(9);
|
||||||
|
charge.retenu.capacites.push(8);
|
||||||
|
for (const tour of charge.retenu.placement) tour.sieges.push([]);
|
||||||
|
const { charge: apres } = executer('deplacerDansRetenu', charge, { tour: 2, participant: 4, table: 9, siege: 1 });
|
||||||
|
assert.deepEqual(listes(apres, 2), [[1, 8, 9, 10, 11, 12], [7, 2, 3, null, 5, 6], [4]]);
|
||||||
|
assert.deepEqual(fautes(apres.retenu), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('une table supprimée après la retenue : ses occupants attendent en réserve, et le remède de SIEGE_OCCUPE s’exécute tel quel', () => {
|
||||||
|
const charge = chargeRetenue();
|
||||||
|
charge.tables = charge.tables.filter(({ id }) => id !== 8);
|
||||||
|
assert.deepEqual(etatPlaces(charge, { tour: 1 }).reserve, [7, 8, 9, 10, 11, 12]);
|
||||||
|
const occupe = refuse(() => executer('deplacerDansRetenu', charge, { tour: 1, participant: 7, table: 5, siege: 1 }), 'SIEGE_OCCUPE', {
|
||||||
|
occupant: 1,
|
||||||
|
});
|
||||||
|
const { commande, arguments: args } = occupe.details.remede;
|
||||||
|
assert.deepEqual([commande, args], ['echangerDansRetenu', { tour: 1, a: 7, b: 1 }]);
|
||||||
|
const { charge: apres } = executer(commande, charge, args);
|
||||||
|
// 7 prend la chaise 1 ; 1 attend en réserve, et personne n'entre dans la
|
||||||
|
// liste de la table que la charge n'a plus.
|
||||||
|
assert.deepEqual(listes(apres, 1), [[7, 2, 3, 4, 5, 6], [null, 8, 9, 10, 11, 12]]);
|
||||||
|
assert.deepEqual(reserveDe(apres, 1), [1]);
|
||||||
|
assert.deepEqual(listes(apres, 2), listes(charge, 2));
|
||||||
|
const etat = etatPlaces(apres, { tour: 1 });
|
||||||
|
assert.equal(etat.tables.length, 1);
|
||||||
|
assert.equal(etat.tables[0].chaises[0].occupant, 7);
|
||||||
|
assert.deepEqual(etat.reserve, [1, 8, 9, 10, 11, 12]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('une table réduite après la retenue : un rang au-delà de sa capacité se lit comme la réserve, dans l’échange aussi', () => {
|
||||||
|
const charge = chargeRetenue();
|
||||||
|
charge.tables[0].sieges = 4;
|
||||||
|
assert.deepEqual(etatPlaces(charge, { tour: 1 }).reserve, [5, 6]);
|
||||||
|
const occupe = refuse(() => executer('deplacerDansRetenu', charge, { tour: 1, participant: 5, table: 5, siege: 1 }), 'SIEGE_OCCUPE', {
|
||||||
|
occupant: 1,
|
||||||
|
});
|
||||||
|
const { charge: apres } = executer(occupe.details.remede.commande, charge, occupe.details.remede.arguments);
|
||||||
|
assert.deepEqual(listes(apres, 1)[0], [5, 2, 3, 4, null, 6]);
|
||||||
|
assert.deepEqual(reserveDe(apres, 1), [1]);
|
||||||
|
assert.deepEqual(etatPlaces(apres, { tour: 1 }).reserve, [1, 6]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sans attribution, une table réduite après la retenue : une personne que le plan montre en réserve s’y trouve pour déplacer et retirer', () => {
|
||||||
|
const charge = chargeRetenue({ siegesAttribues: false });
|
||||||
|
charge.tables[0].sieges = 4;
|
||||||
|
assert.deepEqual(etatPlaces(charge, { tour: 1 }).reserve, [5, 6]);
|
||||||
|
// La table affichée assied quatre présents : pleine, et non SANS_EFFET.
|
||||||
|
const pleine = refuse(() => executer('deplacerDansRetenu', charge, { tour: 1, participant: 6, table: 5, siege: null }), 'TABLE_PLEINE', {
|
||||||
|
table: 5,
|
||||||
|
tour: 1,
|
||||||
|
capacite: 4,
|
||||||
|
});
|
||||||
|
assert.deepEqual(pleine.details.remede, { commande: 'changerPlacesTable', arguments: { id: 5, sieges: 5, origine: 'champ' } });
|
||||||
|
// Déjà en réserve à l'affichage : retirer du tour n'a pas d'effet.
|
||||||
|
refuse(() => executer('retirerDuTour', charge, { tour: 1, participant: 6 }), 'SANS_EFFET');
|
||||||
|
// Vers une autre table, elle part de la réserve.
|
||||||
|
const { charge: apres, libelle } = executer('deplacerDansRetenu', charge, { tour: 1, participant: 6, table: 8, siege: null });
|
||||||
|
assert.equal(libelle, 'Déplacé Capucine Sarcelle de la réserve à la table 7, tour 1');
|
||||||
|
assert.deepEqual(listes(apres, 1), [[1, 2, 3, 4], [6, 7, 8, 9, 10, 11, 12]]);
|
||||||
|
assert.deepEqual(reserveDe(apres, 1), [5]);
|
||||||
|
assert.deepEqual(etatPlaces(apres, { tour: 1 }).reserve, [5]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sans attribution, une table réduite après la retenue : chaque geste verse d’abord en réserve ceux que le plan n’y montre pas, et n’assied que ceux qu’il nomme', () => {
|
||||||
|
const charge = chargeRetenue({ siegesAttribues: false });
|
||||||
|
charge.tables[0].sieges = 4;
|
||||||
|
figer(charge);
|
||||||
|
const occupants = (apres) => etatPlaces(apres, { tour: 1 }).tables.map(({ chaises }) => chaises.map(({ occupant }) => occupant));
|
||||||
|
assert.deepEqual(occupants(charge), [
|
||||||
|
[1, 2, 3, 4],
|
||||||
|
[7, 8, 9, 10, 11, 12, null, null],
|
||||||
|
]);
|
||||||
|
assert.deepEqual(etatPlaces(charge, { tour: 1 }).reserve, [5, 6]);
|
||||||
|
|
||||||
|
// 6, en réserve à l'affichage, prend la chaise de 2 ; 5 reste en réserve.
|
||||||
|
const echange = executer('echangerDansRetenu', charge, { tour: 1, a: 6, b: 2 }).charge;
|
||||||
|
assert.deepEqual(occupants(echange), [
|
||||||
|
[1, 3, 4, 6],
|
||||||
|
[7, 8, 9, 10, 11, 12, null, null],
|
||||||
|
]);
|
||||||
|
assert.deepEqual(etatPlaces(echange, { tour: 1 }).reserve, [2, 5]);
|
||||||
|
assert.deepEqual(listes(echange, 1), [[1, 3, 4, 6], [7, 8, 9, 10, 11, 12]]);
|
||||||
|
assert.deepEqual(reserveDe(echange, 1), [2, 5]);
|
||||||
|
|
||||||
|
// 7, venu d'une autre table, prend la chaise de 2 et s'y voit assis.
|
||||||
|
const croise = executer('echangerDansRetenu', charge, { tour: 1, a: 7, b: 2 }).charge;
|
||||||
|
assert.deepEqual(occupants(croise), [
|
||||||
|
[1, 3, 4, 7],
|
||||||
|
[2, 8, 9, 10, 11, 12, null, null],
|
||||||
|
]);
|
||||||
|
assert.deepEqual(etatPlaces(croise, { tour: 1 }).reserve, [5, 6]);
|
||||||
|
|
||||||
|
// 2, de la table réduite, prend la chaise de 7 : la table de a se verse
|
||||||
|
// aussi, et 7 s'assied à la chaise de 2 sans que 5 la lui dispute.
|
||||||
|
const inverse = executer('echangerDansRetenu', charge, { tour: 1, a: 2, b: 7 }).charge;
|
||||||
|
assert.deepEqual(occupants(inverse), [
|
||||||
|
[1, 3, 4, 7],
|
||||||
|
[2, 8, 9, 10, 11, 12, null, null],
|
||||||
|
]);
|
||||||
|
assert.deepEqual(etatPlaces(inverse, { tour: 1 }).reserve, [5, 6]);
|
||||||
|
assert.deepEqual(listes(inverse, 1), [[1, 3, 4, 7], [2, 8, 9, 10, 11, 12]]);
|
||||||
|
|
||||||
|
// Le départ de 2 libère sa chaise : 5 ne s'y assied pas.
|
||||||
|
const retire = executer('retirerDuTour', charge, { tour: 1, participant: 2 }).charge;
|
||||||
|
assert.deepEqual(occupants(retire), [
|
||||||
|
[1, 3, 4, null],
|
||||||
|
[7, 8, 9, 10, 11, 12, null, null],
|
||||||
|
]);
|
||||||
|
assert.deepEqual(etatPlaces(retire, { tour: 1 }).reserve, [2, 5, 6]);
|
||||||
|
assert.deepEqual(listes(retire, 1), [[1, 3, 4], [7, 8, 9, 10, 11, 12]]);
|
||||||
|
|
||||||
|
const deplace = executer('deplacerDansRetenu', charge, { tour: 1, participant: 2, table: 8, siege: null }).charge;
|
||||||
|
assert.deepEqual(occupants(deplace), [
|
||||||
|
[1, 3, 4, null],
|
||||||
|
[2, 7, 8, 9, 10, 11, 12, null],
|
||||||
|
]);
|
||||||
|
assert.deepEqual(etatPlaces(deplace, { tour: 1 }).reserve, [5, 6]);
|
||||||
|
assert.deepEqual(listes(deplace, 1), [[1, 3, 4], [2, 7, 8, 9, 10, 11, 12]]);
|
||||||
|
|
||||||
|
for (const apres of [echange, croise, inverse, retire, deplace]) {
|
||||||
|
assert.deepEqual(fautes(apres.retenu), []);
|
||||||
|
for (const tour of [2, 3]) assert.deepEqual(apres.retenu.placement[tour - 1], charge.retenu.placement[tour - 1]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sans attribution, une table réduite : la personne réservée à cette table y reste assise, et un autre départ ne la verse pas en réserve', () => {
|
||||||
|
// 6, réservé à la table 4 sans siège, est au-delà de ses quatre places
|
||||||
|
// dans la liste : l'affichage l'assied avant 4 et 5, sans réservation.
|
||||||
|
const charge = chargeRetenue({ siegesAttribues: false, reservations: [resa(6, 5, null)] });
|
||||||
|
charge.tables[0].sieges = 4;
|
||||||
|
figer(charge);
|
||||||
|
const occupantsTable4 = (etat) => etat.tables[0].chaises.map(({ occupant }) => occupant);
|
||||||
|
assert.deepEqual(occupantsTable4(etatPlaces(charge, { tour: 1 })), [1, 2, 3, 6]);
|
||||||
|
assert.deepEqual(etatPlaces(charge, { tour: 1 }).reserve, [4, 5]);
|
||||||
|
const { charge: apres } = executer('retirerDuTour', charge, { tour: 1, participant: 2 });
|
||||||
|
// Seule la chaise que 2 libère reste vide.
|
||||||
|
assert.deepEqual(occupantsTable4(etatPlaces(apres, { tour: 1 })), [1, 3, 6, null]);
|
||||||
|
assert.deepEqual(listes(apres, 1), [[1, 3, 6], [7, 8, 9, 10, 11, 12]]);
|
||||||
|
assert.deepEqual(reserveDe(apres, 1), [2, 4, 5]);
|
||||||
|
assert.deepEqual(etatPlaces(apres, { tour: 1 }).reserve, [2, 4, 5]);
|
||||||
|
assert.deepEqual(
|
||||||
|
avertissementsApres(charge, apres).filter(({ code }) => code === 'RESERVATION_NON_HONOREE'),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sièges attribués, une table réduite après la retenue : le rang au-delà de sa capacité part de la réserve vers une chaise libre', () => {
|
||||||
|
const charge = chargeRetenue({
|
||||||
|
tours: [
|
||||||
|
[[1, null, 3, 4, 5, 6], [7, 8, 9, 10, 11, 12]],
|
||||||
|
[[1, 8, 9, 10, 11, 12], [7, 2, 3, 4, 5, 6]],
|
||||||
|
[[1, 2, 3, 7, 8, 9], [4, 5, 6, 10, 11, 12]],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
charge.tables[0].sieges = 4;
|
||||||
|
assert.deepEqual(etatPlaces(charge, { tour: 1 }).reserve, [2, 5, 6]);
|
||||||
|
refuse(() => executer('retirerDuTour', charge, { tour: 1, participant: 5 }), 'SANS_EFFET');
|
||||||
|
const { charge: apres, libelle } = executer('deplacerDansRetenu', charge, { tour: 1, participant: 5, table: 5, siege: 2 });
|
||||||
|
assert.equal(libelle, 'Déplacé Anouk Bruyère de la réserve à la table 4, tour 1');
|
||||||
|
assert.deepEqual(listes(apres, 1)[0], [1, 5, 3, 4, null, 6]);
|
||||||
|
assert.deepEqual(reserveDe(apres, 1), [2]);
|
||||||
|
assert.deepEqual(etatPlaces(apres, { tour: 1 }).reserve, [2, 6]);
|
||||||
|
assert.deepEqual(fautes(apres.retenu), []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('commandes des places : le vérificateur d’invariants du retenu (§ 14.12, § 14.15)', () => {
|
||||||
|
test('retirer du tour une personne réservée à sa table : INVARIANT_VIOLE, les seules violations nouvelles, sans écriture', () => {
|
||||||
|
// Le retenu reçu porte déjà une violation : 7, réservé à la table 4 au
|
||||||
|
// tour 2, y est assis à la table 7 ; elle n'entre pas dans le refus.
|
||||||
|
const charge = figer(chargeRetenue({ reservations: [resa(4, 5, null, 1), resa(7, 5, null, 2)] }));
|
||||||
|
refuse(() => executer('retirerDuTour', charge, { tour: 1, participant: 4 }), 'INVARIANT_VIOLE', {
|
||||||
|
proposition: 1,
|
||||||
|
violations: [{ code: 'RESERVATION', participant: 4, table: 5, tour: 1 }],
|
||||||
|
remede: null,
|
||||||
|
});
|
||||||
|
const { charge: apres } = executer('retirerDuTour', charge, { tour: 1, participant: 5 });
|
||||||
|
assert.deepEqual(reserveDe(apres, 1), [5]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('une violation déjà présente, celle d’une dérive, ne bloque pas la retouche suivante', () => {
|
||||||
|
const charge = chargeRetenue({ reservations: [resa(4, 8, null, 1)] });
|
||||||
|
charge.participants.push(personne(13));
|
||||||
|
charge.prochainsIds.participant = 14;
|
||||||
|
const { charge: apres } = executer('retirerDuTour', charge, { tour: 2, participant: 9 });
|
||||||
|
assert.deepEqual(reserveDe(apres, 2), [9]);
|
||||||
|
const { charge: ensuite } = executer('echangerDansRetenu', apres, { tour: 2, a: 9, b: 10 });
|
||||||
|
assert.deepEqual(reserveDe(ensuite, 2), [10]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('enregistrerGeneration : les sièges réservés dans une génération (§ 4, § 5.3, § 8.9)', () => {
|
||||||
|
// Douze personnes, deux tables, Théo réservé « tous » au siège siege de la
|
||||||
|
// table 2, sièges attribués.
|
||||||
|
function chargeGeneration({ siege, capacite = 8, attribuerSieges = true }) {
|
||||||
|
const charge = creerCharge({ id: 'evt-generation', nom: 'Soirée des Lucioles', siegesParDefaut: capacite, tours: 3 });
|
||||||
|
charge.reglages.attribuerSieges = attribuerSieges;
|
||||||
|
charge.participants = DOUZE.map((id) => personne(id));
|
||||||
|
charge.tables = [
|
||||||
|
{ id: 1, numero: 1, sieges: null, forme: 'ronde', position: { x: 0, y: 0 } },
|
||||||
|
{ id: 2, numero: 2, sieges: null, forme: 'ronde', position: { x: 400, y: 0 } },
|
||||||
|
];
|
||||||
|
charge.reservations = [resa(2, 2, siege)];
|
||||||
|
charge.prochainsIds = { participant: 13, table: 3, proposition: 1 };
|
||||||
|
return charge;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Théo, réservé au siège 3 de la table 2, y prend le rang 3 à chaque tour de chaque proposition', () => {
|
||||||
|
const charge = chargeGeneration({ siege: 3 });
|
||||||
|
const configuration = configurationDepuisCharge(charge);
|
||||||
|
const propositions = rechercher(configuration, { graine: 7, arret: 60, nombre: 2 });
|
||||||
|
const { charge: apres } = executer('enregistrerGeneration', charge, { propositions, configuration, produitVersion: VERSION.affichee });
|
||||||
|
assert.equal(apres.propositions.length, 2);
|
||||||
|
for (const proposition of apres.propositions) {
|
||||||
|
assert.deepEqual(fautes(proposition), []);
|
||||||
|
const rang = proposition.tables.indexOf(2);
|
||||||
|
assert.equal(proposition.placement.length, 3);
|
||||||
|
for (const { sieges } of proposition.placement) {
|
||||||
|
assert.equal(sieges[rang][2], 2);
|
||||||
|
assert.notEqual(sieges[rang].at(-1), null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('réservé au siège 8 d’une table de 8 qui en assied six : au rang 8, les rangs 6 et 7 à null', () => {
|
||||||
|
const charge = chargeGeneration({ siege: 8 });
|
||||||
|
const configuration = configurationDepuisCharge(charge);
|
||||||
|
const tour = { tables: [1, 2], tours: [[1, 3, 4, 5, 6, 7], [12, 2, 8, 11, 9, 10]] };
|
||||||
|
const proposition = {
|
||||||
|
id: 1,
|
||||||
|
graine: 48271,
|
||||||
|
arret: 60,
|
||||||
|
historique: 1000,
|
||||||
|
plan: { tables: tour.tables, tours: [tour.tours, tour.tours, tour.tours], reserves: [[], [], []] },
|
||||||
|
};
|
||||||
|
const { charge: apres } = executer('enregistrerGeneration', charge, { propositions: [proposition], configuration, produitVersion: VERSION.affichee });
|
||||||
|
assert.equal(apres.propositions[0].placement.length, 3);
|
||||||
|
for (const { sieges } of apres.propositions[0].placement) {
|
||||||
|
assert.deepEqual(sieges, [[1, 3, 4, 5, 6, 7], [8, 9, 10, 11, 12, null, null, 2]]);
|
||||||
|
}
|
||||||
|
// Sans attribution, la liste reste triée par identifiant.
|
||||||
|
const libre = chargeGeneration({ siege: 8, attribuerSieges: false });
|
||||||
|
const { charge: triee } = executer('enregistrerGeneration', libre, {
|
||||||
|
propositions: [proposition],
|
||||||
|
configuration: configurationDepuisCharge(libre),
|
||||||
|
produitVersion: VERSION.affichee,
|
||||||
|
});
|
||||||
|
assert.deepEqual(triee.propositions[0].placement[0].sieges, [[1, 3, 4, 5, 6, 7], [2, 8, 9, 10, 11, 12]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('une table supprimée et une capacité réduite pendant la génération : la proposition s’enregistre, rangée sur la configuration, en dérive', () => {
|
||||||
|
const charge = chargeGeneration({ siege: 3 });
|
||||||
|
charge.tables.push({ id: 3, numero: 3, sieges: null, forme: 'ronde', position: { x: 800, y: 0 } });
|
||||||
|
charge.prochainsIds.table = 4;
|
||||||
|
const configuration = configurationDepuisCharge(charge);
|
||||||
|
const tours = [[1, 3, 4], [5, 6, 2, 7, 8, 9, 10, 11], [12]];
|
||||||
|
const proposition = {
|
||||||
|
id: 1,
|
||||||
|
graine: 48271,
|
||||||
|
arret: 60,
|
||||||
|
historique: 1000,
|
||||||
|
plan: { tables: [1, 2, 3], tours: [tours, tours, tours], reserves: [[], [], []] },
|
||||||
|
};
|
||||||
|
// Entre la recherche et l'enregistrement : la table 3 disparaît, la
|
||||||
|
// table 2 passe à six places alors que la proposition y en assied huit.
|
||||||
|
charge.tables = charge.tables.filter(({ id }) => id !== 3);
|
||||||
|
charge.tables[1].sieges = 6;
|
||||||
|
const { charge: apres } = executer('enregistrerGeneration', charge, { propositions: [proposition], configuration, produitVersion: VERSION.affichee });
|
||||||
|
assert.equal(apres.propositions.length, 1);
|
||||||
|
const [enregistree] = apres.propositions;
|
||||||
|
assert.deepEqual(fautes(enregistree), []);
|
||||||
|
assert.equal(enregistree.placement.length, 3);
|
||||||
|
for (const { sieges } of enregistree.placement) assert.deepEqual(sieges, [[1, 3, 4], [5, 6, 2, 7, 8, 9, 10, 11], [12]]);
|
||||||
|
assert.ok(derive(enregistree, apres).length > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('une table agrandie pendant la génération, puis réservée à un siège nouveau : le rangement suit la capacité de la configuration', () => {
|
||||||
|
const charge = chargeGeneration({ siege: null });
|
||||||
|
charge.reservations = [];
|
||||||
|
const configuration = configurationDepuisCharge(charge);
|
||||||
|
const tours = [[1, 2, 3, 4, 5, 6], [7, 8, 9, 10, 11, 12]];
|
||||||
|
const proposition = {
|
||||||
|
id: 1,
|
||||||
|
graine: 48271,
|
||||||
|
arret: 60,
|
||||||
|
historique: 1000,
|
||||||
|
plan: { tables: [1, 2], tours: [tours, tours, tours], reserves: [[], [], []] },
|
||||||
|
};
|
||||||
|
// Entre la recherche et l'enregistrement : la table 2 passe à dix places,
|
||||||
|
// et 7 y est réservé au siège 9, au-delà des huit de la configuration.
|
||||||
|
charge.tables[1].sieges = 10;
|
||||||
|
charge.reservations = [resa(7, 2, 9)];
|
||||||
|
const { charge: apres } = executer('enregistrerGeneration', charge, { propositions: [proposition], configuration, produitVersion: VERSION.affichee });
|
||||||
|
assert.equal(apres.propositions.length, 1);
|
||||||
|
const [enregistree] = apres.propositions;
|
||||||
|
assert.deepEqual(fautes(enregistree), []);
|
||||||
|
assert.equal(enregistree.placement.length, 3);
|
||||||
|
// Le siège 9 n'existe pas dans la configuration : 7 se range par identifiant.
|
||||||
|
for (const { sieges } of enregistree.placement) assert.deepEqual(sieges, tours);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('commandes des places : les textes de leurs codes (§ 14.6)', () => {
|
||||||
|
// Détails d'exemple de chaque code de libelles/places.js, hors libellés
|
||||||
|
// figés, que les commandes composent ci-dessus.
|
||||||
|
const EXEMPLES = new Map([
|
||||||
|
['SIEGE_INEXISTANT', [{ table: 2, siege: 9, capacite: 8 }, '9']],
|
||||||
|
['DEUX_PLACES_MEME_TOUR', [{ participant: 1, tour: 2, autre: { table: 1, siege: null, portee: 'tous', tour: null } }, 'tour 2']],
|
||||||
|
['PERSONNE_EXCLUE', [{ participant: 3 }, 'exclue']],
|
||||||
|
['PLACE_RESERVEE', [{ table: 2, siege: 3, tour: 2, titulaire: 2, portee: 'tour' }, 'au tour 2']],
|
||||||
|
['RESERVEE_AILLEURS', [{ participant: 1, tour: 2, reservation: { table: 1, siege: null, portee: 'tous', tour: null } }, 'tour 2']],
|
||||||
|
['TABLE_GELEE', [{ table: 1 }, 'réservées']],
|
||||||
|
['LIBELLE_VIDE', [{}, 'vide']],
|
||||||
|
['PLACE_TITREE', [{ table: 3, siege: 1, libelle: 'hôte' }, '« hôte »']],
|
||||||
|
['AUCUN_SIEGE_SANS_TITRE', [{ table: 1, capacite: 2 }, '2 sièges']],
|
||||||
|
['TITRE_ABSENT', [{ table: 3, siege: 1 }, 'siège 1']],
|
||||||
|
['RESERVATION_ABSENTE', [{ participant: 1, table: 3, portee: 'tour', tour: 2 }, 'réservation']],
|
||||||
|
['CONVERSION_IMPOSSIBLE', [{ participant: 1, table: 3 }, 'convertir']],
|
||||||
|
['PROPOSITION_MONTREE', [{ proposition: 4 }, 'n° 4']],
|
||||||
|
['SIEGE_OCCUPE', [{ table: 5, siege: 2, tour: 1, occupant: 2 }, 'siège 2']],
|
||||||
|
['TABLE_PLEINE', [{ table: 5, tour: 1, capacite: 6 }, '6 places']],
|
||||||
|
]);
|
||||||
|
|
||||||
|
test('chaque code de la table des places a son texte, composé de ses détails', () => {
|
||||||
|
const codes = [...TEXTES_DES_PLACES.keys()].filter((cle) => /^[A-Z][A-Z0-9_]*$/.test(cle) && !cle.startsWith('ENTREE_'));
|
||||||
|
assert.ok(codes.length > 0);
|
||||||
|
assert.deepEqual(codes.sort(), [...EXEMPLES.keys()].sort());
|
||||||
|
for (const [code, [details, attendu]] of EXEMPLES) {
|
||||||
|
const texte = libelle(code, { ...details, remede: null });
|
||||||
|
assert.ok(texte.includes(attendu), `${code} : ${texte}`);
|
||||||
|
assert.ok(!texte.includes('undefined'), `${code} : ${texte}`);
|
||||||
|
}
|
||||||
|
assert.ok(libelle('PLACE_RESERVEE', { siege: 3, tour: null, portee: 'tous' }).includes('à tous les tours'));
|
||||||
|
assert.equal(libelle('places.titreParDefaut'), 'animateur');
|
||||||
|
});
|
||||||
|
});
|
||||||
491
src/application/evenements.js
Normal file
491
src/application/evenements.js
Normal file
|
|
@ -0,0 +1,491 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// La façade des événements (§ 8.1, § 8.6, § 8.7, § 8.8, § 15) : la
|
||||||
|
// détermination du dossier de travail, une fois par séance, ses avis et ses
|
||||||
|
// questions ; la liste des événements ; les opérations de fichiers de la
|
||||||
|
// liste — créer, charger une démonstration, copier, renommer, supprimer,
|
||||||
|
// importer — et les dialogues de fichiers, relayés tels quels. Elle est, avec
|
||||||
|
// la séance, le seul écrivain que l'interface atteint.
|
||||||
|
//
|
||||||
|
// La façade ne connaît pas la séance : elle reçoit de quoi savoir quel
|
||||||
|
// événement est ouvert, l'ouvrir, le fermer, en créer un, lire et poser le
|
||||||
|
// dossier de travail, et appelle chacun au moment du geste. Une primitive du
|
||||||
|
// système de fichiers se lit sur fs à l'appel, jamais à la création : une
|
||||||
|
// épreuve la remplace après le montage.
|
||||||
|
//
|
||||||
|
// Lister ne fait que lire. Une opération qui écrit dérive ses refus lisibles
|
||||||
|
// sans écrire — nom vide, plan bloqué, format plus récent — avant toute
|
||||||
|
// écriture, sonde de verrou comprise. Renommer et supprimer ferment d'abord
|
||||||
|
// l'événement visé quand il est ouvert : la séance rend son verrou, et la
|
||||||
|
// façade sonde alors celui du disque sous son propre identifiant de séance,
|
||||||
|
// tiré une fois à sa création ; une séance vivante, ou dont la vie ne se
|
||||||
|
// prouve pas, qui tient l'événement le refuse. Un refus qui suit la fermeture
|
||||||
|
// rouvre l'événement, en lecture, tel qu'il était.
|
||||||
|
//
|
||||||
|
// Les libellés figés qu'elle écrit au journal — copie, renommage, chargement
|
||||||
|
// d'une démonstration, jalon de création, reprise — se composent par
|
||||||
|
// libelle, en français ; aucune autre chaîne visible n'en sort : refus, avis
|
||||||
|
// et questions sont des codes et leurs détails.
|
||||||
|
import { deverrouiller, lister as listerDepot, ouvrir as ouvrirDepot, verrouiller } from '../stockage/depot.js';
|
||||||
|
import { determinerDossierTravail } from '../stockage/dossier_travail.js';
|
||||||
|
import { ErreurStockage } from '../stockage/erreurs.js';
|
||||||
|
import { decoderJournal, lireJournal } from '../stockage/journal.js';
|
||||||
|
import { SUFFIXES } from '../stockage/noms.js';
|
||||||
|
import {
|
||||||
|
chargerDemonstration as chargerSurDisque,
|
||||||
|
copier as copierSurDisque,
|
||||||
|
importer,
|
||||||
|
importerDepuis,
|
||||||
|
libelleDeLInstant,
|
||||||
|
listerCorbeille,
|
||||||
|
renommer as renommerSurDisque,
|
||||||
|
supprimer as supprimerSurDisque,
|
||||||
|
} from '../stockage/operations.js';
|
||||||
|
import { lireReglages } from '../stockage/reglages_locaux.js';
|
||||||
|
import { ErreurCommande, refus } from './erreurs.js';
|
||||||
|
import { libelle } from './libelles.js';
|
||||||
|
|
||||||
|
/** Sièges par défaut et tours d'un événement neuf (§ 2.1, étapes 4 et 5). */
|
||||||
|
export const SIEGES_PAR_DEFAUT = 8;
|
||||||
|
export const TOURS_PAR_DEFAUT = 4;
|
||||||
|
|
||||||
|
// Les réponses qu'admet chaque question du dossier de travail (§ 8.6).
|
||||||
|
const REPONSES = new Map([
|
||||||
|
['PORTABLE_NON_INSCRIPTIBLE', ['reessayer', 'documents']],
|
||||||
|
['DOCUMENTS_NON_INSCRIPTIBLE', ['reessayer', 'continuer']],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Ce que rend la façade d'un verrou présent qui ne se lit pas.
|
||||||
|
const VERROU_ILLISIBLE = Object.freeze({ seance: null, depuis: null });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {import('../stockage/systeme_fichiers.js').Racine} Racine
|
||||||
|
* @typedef {import('../stockage/dossier_travail.js').DossierTravail} DossierTravail
|
||||||
|
* @typedef {{code: string, details: Object}} Avis
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Les avis d'une détermination, dans cet ordre : la raison du repli en
|
||||||
|
// Documents, les avertissements de la règle, puis celui des réglages locaux
|
||||||
|
// illisibles, quand il y en a un.
|
||||||
|
function avisDe(dossier, avertissementReglages) {
|
||||||
|
const avis = [];
|
||||||
|
if (dossier.raison !== null) {
|
||||||
|
avis.push({ code: dossier.raison, details: dossier.raison === 'SONDE_ECHOUEE' ? { cause: dossier.cause } : {} });
|
||||||
|
}
|
||||||
|
for (const code of dossier.avertissements) avis.push({ code, details: {} });
|
||||||
|
if (avertissementReglages !== null) avis.push(avertissementReglages);
|
||||||
|
return avis;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Un nom saisi : en NFC, ses blancs de bout retirés ; vide, NOM_REQUIS. Ce
|
||||||
|
// qui n'est pas une chaîne est une faute du code.
|
||||||
|
function nomSaisi(nom) {
|
||||||
|
if (typeof nom !== 'string') throw new TypeError(`nom : chaîne attendue, reçu ${JSON.stringify(nom)}`);
|
||||||
|
const texte = nom.normalize('NFC').trim();
|
||||||
|
if (texte === '') throw refus('NOM_REQUIS');
|
||||||
|
return texte;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Le chemin affichable, en entier, du chemin relatif d'une racine : ses
|
||||||
|
// segments joints au séparateur du système.
|
||||||
|
function cheminAffiche(racine, separateur, relatif) {
|
||||||
|
const tete = racine.chemin.endsWith(separateur) ? racine.chemin : racine.chemin + separateur;
|
||||||
|
return tete + relatif.split('/').join(separateur);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Un refus de la couche qui le lève : une erreur dont l'interface lit le
|
||||||
|
// code et les détails. Toute autre erreur est une faute ou une panne.
|
||||||
|
const estRefus = (erreur) => erreur instanceof ErreurStockage || erreur instanceof ErreurCommande;
|
||||||
|
|
||||||
|
// Le refus d'un événement qu'aucun geste de la liste ne doit réécrire : un
|
||||||
|
// format plus récent, puis un plan bloqué, que « Débloquer » rend
|
||||||
|
// modifiable.
|
||||||
|
function exigerModifiable({ formatPlusRecent, charge }) {
|
||||||
|
if (formatPlusRecent) throw new ErreurStockage('LECTURE_SEULE', { raison: 'FORMAT_PLUS_RECENT', remede: null });
|
||||||
|
if (charge.evenement.etat === 'bloque') {
|
||||||
|
throw new ErreurStockage('LECTURE_SEULE', { raison: 'BLOQUE', remede: { geste: 'debloquer' } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// L'horodatage de la dernière entrée du journal de base, tel que l'horloge
|
||||||
|
// de l'application qui l'a écrite l'a noté (§ 19.4) ; null sans journal,
|
||||||
|
// sans entrée lisible, ou quand le fichier ne se lit pas. Un journal dont une
|
||||||
|
// ligne n'est pas de l'UTF-8 se lit jusqu'à elle, comme à l'ouverture.
|
||||||
|
async function derniereModification(fs, racine, base) {
|
||||||
|
if (base === null) return null;
|
||||||
|
let texte;
|
||||||
|
try {
|
||||||
|
texte = await fs.lireTexte(racine, base + SUFFIXES.journal);
|
||||||
|
} catch (erreur) {
|
||||||
|
if (!(erreur instanceof ErreurStockage)) throw erreur;
|
||||||
|
if (erreur.code !== 'LECTURE' || erreur.details.cause !== 'UTF8_INVALIDE') return null;
|
||||||
|
texte = decoderJournal(erreur.details.octets).texte;
|
||||||
|
}
|
||||||
|
if (texte === null) return null;
|
||||||
|
return lireJournal(texte).entrees.at(-1)?.horodatage ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pourquoi l'état d'un résumé illisible ne s'ouvre pas : les détails de
|
||||||
|
// l'ETAT_ILLISIBLE qu'ouvrir lève — raison, chemin, format quand il en nomme
|
||||||
|
// un, et les secours lisibles —, sans rien écrire. Un état redevenu lisible,
|
||||||
|
// ou disparu, depuis la liste du dépôt garde la raison du résumé, sans
|
||||||
|
// secours.
|
||||||
|
async function illisibleDe(fs, racine, { base, illisible }) {
|
||||||
|
if (illisible === null) return null;
|
||||||
|
try {
|
||||||
|
await ouvrirDepot(fs, racine, base);
|
||||||
|
} catch (erreur) {
|
||||||
|
if (!(erreur instanceof ErreurStockage)) throw erreur;
|
||||||
|
if (erreur.code === 'ETAT_ILLISIBLE') return { ...erreur.details };
|
||||||
|
}
|
||||||
|
return { raison: illisible, secours: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Le .gtt.verrou de base, lu sans rien écrire : sa séance et l'instant de sa
|
||||||
|
// prise ; null quand il manque ; seance et depuis null quand il ne se lit
|
||||||
|
// pas. Seule une sonde, qui écrit, dit si sa séance vit.
|
||||||
|
async function verrouDe(fs, racine, base) {
|
||||||
|
let texte;
|
||||||
|
try {
|
||||||
|
texte = await fs.lireTexte(racine, base + SUFFIXES.verrou);
|
||||||
|
} catch (erreur) {
|
||||||
|
if (!(erreur instanceof ErreurStockage)) throw erreur;
|
||||||
|
return erreur.code === 'ABSENT' ? null : { ...VERROU_ILLISIBLE };
|
||||||
|
}
|
||||||
|
let contenu;
|
||||||
|
try {
|
||||||
|
contenu = JSON.parse(texte);
|
||||||
|
} catch {
|
||||||
|
contenu = null;
|
||||||
|
}
|
||||||
|
const lu = contenu !== null && typeof contenu === 'object' && typeof contenu.seance === 'string' && typeof contenu.depuis === 'string';
|
||||||
|
return lu ? { seance: contenu.seance, depuis: contenu.depuis } : { ...VERROU_ILLISIBLE };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* La façade des événements, branchée par App sur la séance (I1).
|
||||||
|
*
|
||||||
|
* @param {Object} parametres
|
||||||
|
* @param {import('../stockage/systeme_fichiers.js').SystemeFichiers} parametres.fs
|
||||||
|
* @param {import('./horloge.js').Horloge} parametres.horloge
|
||||||
|
* @param {() => string} parametres.identifiant tiré une fois à la création,
|
||||||
|
* pour la séance sous laquelle la façade sonde les verrous, puis une
|
||||||
|
* fois par événement qu'elle copie ou charge
|
||||||
|
* @param {string} parametres.produitVersion
|
||||||
|
* @param {Array<{cle: string, nom: string, texte: string}>} parametres.demonstrations
|
||||||
|
* @param {() => string|null} parametres.ouvert base de l'événement ouvert
|
||||||
|
* @param {(base: string) => Promise<unknown>} parametres.ouvrir en lecture
|
||||||
|
* @param {() => Promise<unknown>} parametres.fermer
|
||||||
|
* @param {(champs: Object) => Promise<{base: string}>} parametres.creer
|
||||||
|
* celle de la séance, qui juge nom et date et ouvre l'événement créé
|
||||||
|
* @param {() => Racine|null} parametres.racine le dossier de travail
|
||||||
|
* @param {(racine: Racine) => Promise<unknown>} parametres.changerRacine
|
||||||
|
*/
|
||||||
|
export function creerFacade({
|
||||||
|
fs,
|
||||||
|
horloge,
|
||||||
|
identifiant,
|
||||||
|
produitVersion,
|
||||||
|
demonstrations,
|
||||||
|
ouvert,
|
||||||
|
ouvrir,
|
||||||
|
fermer,
|
||||||
|
creer,
|
||||||
|
racine,
|
||||||
|
changerRacine,
|
||||||
|
}) {
|
||||||
|
const seance = identifiant();
|
||||||
|
const libelleReprise = libelle('ENTREE_REPRISE');
|
||||||
|
const nomJalon = libelle('JALON_CREATION');
|
||||||
|
|
||||||
|
// resultat : la dernière détermination aboutie, {dossier, avis} ; enCours :
|
||||||
|
// la promesse de celle qui se fait, null sinon.
|
||||||
|
let resultat = null;
|
||||||
|
let enCours = null;
|
||||||
|
|
||||||
|
// La règle du § 8.6, puis les réglages locaux du dossier retenu, lus une
|
||||||
|
// fois ; la racine se pose quand aucune question n'attend. Un rejet ne
|
||||||
|
// retient rien.
|
||||||
|
async function determiner() {
|
||||||
|
const dossier = await determinerDossierTravail(fs);
|
||||||
|
const { avertissement } = await lireReglages(fs, dossier.racine);
|
||||||
|
const issue = { dossier, avis: avisDe(dossier, avertissement) };
|
||||||
|
if (dossier.question === null) await changerRacine(dossier.racine);
|
||||||
|
resultat = issue;
|
||||||
|
return issue;
|
||||||
|
}
|
||||||
|
|
||||||
|
function demarrer() {
|
||||||
|
if (resultat !== null) return Promise.resolve(resultat);
|
||||||
|
if (enCours === null) {
|
||||||
|
enCours = determiner().finally(() => {
|
||||||
|
enCours = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return enCours;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Le dossier de travail d'une opération : la détermination en cours
|
||||||
|
// attendue ; QUESTION_EN_ATTENTE tant qu'une question attend sa réponse.
|
||||||
|
async function exigerDossier() {
|
||||||
|
if (enCours !== null) await enCours.catch(() => undefined);
|
||||||
|
const question = resultat?.dossier.question ?? null;
|
||||||
|
if (question !== null) throw refus('QUESTION_EN_ATTENTE', { question: question.code });
|
||||||
|
const courante = racine();
|
||||||
|
if (courante === null) throw new RangeError("façade : aucun dossier de travail, demarrer n'a pas abouti");
|
||||||
|
return courante;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function repondre(choix) {
|
||||||
|
const question = resultat?.dossier.question ?? null;
|
||||||
|
if (question === null) throw new RangeError('repondre : aucune question du dossier de travail n’attend');
|
||||||
|
if (!REPONSES.get(question.code).includes(choix)) {
|
||||||
|
throw new RangeError(`repondre : ${JSON.stringify(choix)} ne répond pas à ${question.code}`);
|
||||||
|
}
|
||||||
|
if (choix === 'reessayer') {
|
||||||
|
resultat = null;
|
||||||
|
return demarrer();
|
||||||
|
}
|
||||||
|
const { dossier, avis } = resultat;
|
||||||
|
let importe = null;
|
||||||
|
let suite = { ...dossier, question: null };
|
||||||
|
if (choix === 'documents') {
|
||||||
|
// data/ illisible, compte null : rien à copier.
|
||||||
|
const bases = question.evenements === null ? [] : (await listerDepot(fs, question.racine)).map(({ base }) => base);
|
||||||
|
importe = await importerDepuis(fs, question.racine, dossier.racine, bases);
|
||||||
|
suite = { ...suite, autre: null };
|
||||||
|
}
|
||||||
|
await changerRacine(suite.racine);
|
||||||
|
resultat = { dossier: suite, avis };
|
||||||
|
return importe === null ? resultat : { ...resultat, importe };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function lister() {
|
||||||
|
const courante = await exigerDossier();
|
||||||
|
const resumes = await listerDepot(fs, courante);
|
||||||
|
const { separateur } = await fs.emplacements();
|
||||||
|
const lignes = [];
|
||||||
|
for (const resume of resumes) {
|
||||||
|
const { base, id, doublon } = resume;
|
||||||
|
lignes.push({
|
||||||
|
base,
|
||||||
|
id,
|
||||||
|
nom: resume.nom,
|
||||||
|
date: resume.date,
|
||||||
|
etat: resume.etat,
|
||||||
|
participants: resume.participants,
|
||||||
|
derniereModification: await derniereModification(fs, courante, resume.journal),
|
||||||
|
chemin: cheminAffiche(courante, separateur, base + SUFFIXES.etat),
|
||||||
|
illisible: await illisibleDe(fs, courante, resume),
|
||||||
|
journalSeul: resume.illisible === 'ABSENT',
|
||||||
|
doublonAvec: doublon
|
||||||
|
? resumes.filter((autre) => autre !== resume && autre.doublon && autre.id === id).map((autre) => autre.base)
|
||||||
|
: [],
|
||||||
|
verrou: await verrouDe(fs, courante, base),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return lignes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Le verrou de l'événement cible pris sous la séance de la façade. Le sien,
|
||||||
|
// resté d'un rendu refusé, est déjà pris ; celui d'une séance morte
|
||||||
|
// (vivant faux) s'efface en son nom, puis se prend ; tout autre lève
|
||||||
|
// VERROU_PRIS, sans rien changer.
|
||||||
|
async function prendreVerrou(cible) {
|
||||||
|
let verrou = await verrouiller(cible, seance);
|
||||||
|
if (!verrou.pris && verrou.seance === seance) return;
|
||||||
|
if (!verrou.pris && verrou.vivant === false) {
|
||||||
|
await deverrouiller(cible, verrou.seance);
|
||||||
|
verrou = await verrouiller(cible, seance);
|
||||||
|
}
|
||||||
|
if (!verrou.pris) {
|
||||||
|
const { seance: tenue, depuis, vivant } = verrou;
|
||||||
|
throw new ErreurStockage('VERROU_PRIS', { seance: tenue, depuis, vivant, remede: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Après le refus d'une opération qui a fermé l'événement base, le rouvre en
|
||||||
|
// lecture ; une panne, qui n'est pas un refus, laisse tout. Un échec de
|
||||||
|
// cette réouverture ne masque pas le refus.
|
||||||
|
async function rouvrirApresRefus(base, etaitOuvert, erreur) {
|
||||||
|
if (etaitOuvert && estRefus(erreur)) await ouvrir(base).catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function creerEvenement({ nom, date = null }) {
|
||||||
|
await exigerDossier();
|
||||||
|
return creer({ nom, date, siegesParDefaut: SIEGES_PAR_DEFAUT, tours: TOURS_PAR_DEFAUT });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function chargerDemonstration(cle) {
|
||||||
|
const demonstration = demonstrations.find((candidate) => candidate.cle === cle);
|
||||||
|
if (demonstration === undefined) throw new RangeError(`aucune démonstration de clé ${JSON.stringify(cle)}`);
|
||||||
|
const courante = await exigerDossier();
|
||||||
|
const charge = await chargerSurDisque(fs, courante, demonstration.texte, {
|
||||||
|
id: identifiant(),
|
||||||
|
libelle: libelle('ENTREE_DEMONSTRATION', { nom: demonstration.nom }),
|
||||||
|
nomJalon,
|
||||||
|
horodatage: horloge.horodatage(),
|
||||||
|
produitVersion,
|
||||||
|
});
|
||||||
|
await ouvrir(charge.base);
|
||||||
|
return { base: charge.base };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copier(base, { nom }) {
|
||||||
|
const texte = nomSaisi(nom);
|
||||||
|
const courante = await exigerDossier();
|
||||||
|
const source = await ouvrirDepot(fs, courante, base);
|
||||||
|
// L'instant copié, par la règle même qui l'inscrit à la filiation.
|
||||||
|
const instant = libelleDeLInstant(source, libelleReprise);
|
||||||
|
const copie = await copierSurDisque(source, {
|
||||||
|
id: identifiant(),
|
||||||
|
nom: texte,
|
||||||
|
libelle: libelle('ENTREE_COPIE', { source: source.charge.evenement.nom, instant }),
|
||||||
|
nomJalon,
|
||||||
|
horodatage: horloge.horodatage(),
|
||||||
|
produitVersion,
|
||||||
|
libelleReprise,
|
||||||
|
});
|
||||||
|
await ouvrir(copie.base);
|
||||||
|
return { base: copie.base };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renommer(base, { nom }) {
|
||||||
|
const texte = nomSaisi(nom);
|
||||||
|
const courante = await exigerDossier();
|
||||||
|
exigerModifiable(await ouvrirDepot(fs, courante, base));
|
||||||
|
const etaitOuvert = ouvert() === base;
|
||||||
|
if (etaitOuvert) await fermer();
|
||||||
|
const cible = { fs, racine: courante, base };
|
||||||
|
let renomme;
|
||||||
|
try {
|
||||||
|
await prendreVerrou(cible);
|
||||||
|
try {
|
||||||
|
// Relu sous le verrou : une autre séance a pu l'écrire depuis.
|
||||||
|
const source = await ouvrirDepot(fs, courante, base);
|
||||||
|
exigerModifiable(source);
|
||||||
|
renomme = await renommerSurDisque(source, {
|
||||||
|
nom: texte,
|
||||||
|
libelle: libelle('ENTREE_RENOMMER', { ancien: source.charge.evenement.nom, nom: texte }),
|
||||||
|
horodatage: horloge.horodatage(),
|
||||||
|
produitVersion,
|
||||||
|
libelleReprise,
|
||||||
|
});
|
||||||
|
} catch (erreur) {
|
||||||
|
await deverrouiller(cible, seance).catch(() => undefined);
|
||||||
|
throw erreur;
|
||||||
|
}
|
||||||
|
} catch (erreur) {
|
||||||
|
await rouvrirApresRefus(base, etaitOuvert, erreur);
|
||||||
|
throw erreur;
|
||||||
|
}
|
||||||
|
// Le renommage fait, un verrou qui ne se rend pas le dit sans le défaire.
|
||||||
|
const avertissements = [];
|
||||||
|
try {
|
||||||
|
await deverrouiller(cible, seance);
|
||||||
|
} catch (erreur) {
|
||||||
|
if (!(erreur instanceof ErreurStockage)) throw erreur;
|
||||||
|
avertissements.push({ code: 'VERROU_NON_RENDU', details: erreur.details });
|
||||||
|
}
|
||||||
|
if (etaitOuvert) await ouvrir(renomme.base);
|
||||||
|
return { base: renomme.base, avertissements };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function supprimer(base) {
|
||||||
|
const courante = await exigerDossier();
|
||||||
|
const etaitOuvert = ouvert() === base;
|
||||||
|
if (etaitOuvert) await fermer();
|
||||||
|
try {
|
||||||
|
return await supprimerSurDisque(fs, courante, base, { horodatage: horloge.horodatage(), seance });
|
||||||
|
} catch (erreur) {
|
||||||
|
await rouvrirApresRefus(base, etaitOuvert, erreur);
|
||||||
|
if (erreur instanceof ErreurStockage && erreur.code === 'VERROU_PRIS') {
|
||||||
|
throw new ErreurStockage('VERROU_PRIS', { ...erreur.details, remede: null });
|
||||||
|
}
|
||||||
|
throw erreur;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function corbeille() {
|
||||||
|
const courante = await exigerDossier();
|
||||||
|
const { separateur } = await fs.emplacements();
|
||||||
|
return (await listerCorbeille(fs, courante)).map(({ dossier, fichiers }) => ({
|
||||||
|
dossier,
|
||||||
|
chemin: cheminAffiche(courante, separateur, dossier),
|
||||||
|
fichiers,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importerFichier() {
|
||||||
|
const courante = await exigerDossier();
|
||||||
|
const choisi = await fs.choisirFichierAImporter();
|
||||||
|
if (choisi === null) return null;
|
||||||
|
return importer(fs, courante, choisi.octets);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importerDepuisAutre() {
|
||||||
|
const vers = await exigerDossier();
|
||||||
|
const autre = resultat.dossier.autre;
|
||||||
|
if (autre === null) throw new RangeError("importerDepuisAutre : l'autre emplacement ne propose rien");
|
||||||
|
const bases = (await listerDepot(fs, autre.racine)).map(({ base }) => base);
|
||||||
|
const importe = await importerDepuis(fs, autre.racine, vers, bases);
|
||||||
|
resultat = { ...resultat, dossier: { ...resultat.dossier, autre: null } };
|
||||||
|
return importe;
|
||||||
|
}
|
||||||
|
|
||||||
|
function explorateur() {
|
||||||
|
return fs.nature === 'web'
|
||||||
|
? { disponible: false, raison: 'EXPLORATEUR_ABSENT' }
|
||||||
|
: { disponible: true, raison: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ouvrirExplorateur() {
|
||||||
|
if (fs.nature === 'web') throw refus('EXPLORATEUR_ABSENT');
|
||||||
|
return fs.ouvrirDansExplorateur(await exigerDossier());
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* La détermination du dossier de travail, une fois par séance (§ 8.6) :
|
||||||
|
* { dossier, avis }, le même tant qu'aucune réponse ne la refait. Une
|
||||||
|
* détermination qui rejette ne retient rien, et l'appel suivant la refait.
|
||||||
|
* @returns {Promise<{dossier: DossierTravail, avis: Avis[]}>}
|
||||||
|
*/
|
||||||
|
demarrer,
|
||||||
|
/**
|
||||||
|
* La réponse à la question du dossier de travail : comme demarrer ;
|
||||||
|
* 'documents' rend aussi importe, ce que la copie de data/ a importé et
|
||||||
|
* refusé (importerDepuis). RangeError sans question, ou pour un choix
|
||||||
|
* qu'elle n'admet pas.
|
||||||
|
* @param {'reessayer'|'documents'|'continuer'} choix
|
||||||
|
*/
|
||||||
|
repondre,
|
||||||
|
/** Les lignes de la liste (§ 8.1), dans l'ordre du dépôt ; n'écrit rien. */
|
||||||
|
lister,
|
||||||
|
/** Crée et ouvre en lecture l'événement { nom, date } (§ 2.1, étape 1). */
|
||||||
|
creer: creerEvenement,
|
||||||
|
/** Charge la démonstration de clé donnée sous « ‹nom› N », ouverte en lecture (§ 15). */
|
||||||
|
chargerDemonstration,
|
||||||
|
/** Copie l'événement base sous le nom donné, et ouvre la copie en lecture (§ 8.7). */
|
||||||
|
copier,
|
||||||
|
/** Renomme l'événement base ; ouvert, il se rouvre en lecture sous sa nouvelle base (§ 8.7). */
|
||||||
|
renommer,
|
||||||
|
/** Déplace les fichiers de l'événement base dans la corbeille datée (§ 8.7). */
|
||||||
|
supprimer,
|
||||||
|
/** Le contenu de la corbeille : [{ dossier, chemin, fichiers }] (§ 8.7). */
|
||||||
|
corbeille,
|
||||||
|
/** Importe le fichier d'événement que l'opérateur choisit → { base }, ou null (§ 8.7). */
|
||||||
|
importerFichier,
|
||||||
|
/** Copie dans le dossier de travail les événements de l'autre emplacement (§ 8.6). */
|
||||||
|
importerDepuisAutre,
|
||||||
|
/** Le dialogue de choix d'un fichier, relayé : { nom, octets } ou null. */
|
||||||
|
choisirCsv: () => fs.choisirFichierAImporter(),
|
||||||
|
/** Le dialogue d'enregistrement, relayé : le chemin choisi, ou null. */
|
||||||
|
enregistrerSous: (nomPropose, octets) => fs.enregistrerSous(nomPropose, octets),
|
||||||
|
/** { disponible, raison } : raison EXPLORATEUR_ABSENT sous web (§ 8.6). */
|
||||||
|
explorateur,
|
||||||
|
/** Montre le dossier de travail dans l'explorateur du système. */
|
||||||
|
ouvrirExplorateur,
|
||||||
|
};
|
||||||
|
}
|
||||||
1115
src/application/evenements.test.js
Normal file
1115
src/application/evenements.test.js
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -308,6 +308,32 @@ const EXEMPLES = new Map([
|
||||||
['PLAN_NON_BLOQUE', { remede: null }],
|
['PLAN_NON_BLOQUE', { remede: null }],
|
||||||
['JALON_NON_ECRIT', { chemin: 'Soirée des Lucioles.gtt-journal.jsonl', dossier: 'E:\\data', cause: 'EROFS' }],
|
['JALON_NON_ECRIT', { chemin: 'Soirée des Lucioles.gtt-journal.jsonl', dossier: 'E:\\data', cause: 'EROFS' }],
|
||||||
['SANS_EFFET', { commande: 'modifierParticipant', remede: null }],
|
['SANS_EFFET', { commande: 'modifierParticipant', remede: null }],
|
||||||
|
['RETOUR_AUTOMATIQUE', {}],
|
||||||
|
[
|
||||||
|
'DERIVE_NOUVELLE',
|
||||||
|
{
|
||||||
|
touchees: [
|
||||||
|
{ id: 'retenu', raisons: [{ code: 'TABLE_SUPPRIMEE', table: 2 }] },
|
||||||
|
{ id: 3, raisons: [{ code: 'CAPACITE_CHANGEE', table: 1, avant: 8, maintenant: 6 }] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
['RESERVATION_NON_HONOREE', { participant: 4, table: 5, tour: 1 }],
|
||||||
|
[
|
||||||
|
'INVARIANT_VIOLE',
|
||||||
|
{
|
||||||
|
proposition: 1,
|
||||||
|
violations: [
|
||||||
|
{ code: 'RESERVATION', participant: 4, table: 5, tour: 1 },
|
||||||
|
{ code: 'CAPACITE', table: 5, tour: 2 },
|
||||||
|
],
|
||||||
|
remede: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
['CONFIGURATION_INCOHERENTE', { code: 'SURRESERVATION', details: { table: 2, tour: 1, reservees: 9, capacite: 8 } }],
|
||||||
|
// Moteur
|
||||||
|
['PLACES_MANQUANTES', { placesManquantes: 3 }],
|
||||||
|
['SURRESERVATION', { table: 2, tour: 1, reservees: 9, capacite: 8, remede: null }],
|
||||||
// Refus et avertissements des commandes
|
// Refus et avertissements des commandes
|
||||||
['NOM_REQUIS', { remede: null }],
|
['NOM_REQUIS', { remede: null }],
|
||||||
['DATE_INVALIDE', { date: '2031-02-30', remede: null }],
|
['DATE_INVALIDE', { date: '2031-02-30', remede: null }],
|
||||||
|
|
@ -321,6 +347,9 @@ const EXEMPLES = new Map([
|
||||||
['RESERVATIONS_RETABLIES', { participant: 1, personne: 'Iris Ombrelle', reservations: 2, titres: 1 }],
|
['RESERVATIONS_RETABLIES', { participant: 1, personne: 'Iris Ombrelle', reservations: 2, titres: 1 }],
|
||||||
['RESERVATIONS_RETIREES', { participant: 1, personne: 'Iris Ombrelle', reservations: 1, titres: 1 }],
|
['RESERVATIONS_RETIREES', { participant: 1, personne: 'Iris Ombrelle', reservations: 1, titres: 1 }],
|
||||||
['IMPORT_EN_RETENU', {}],
|
['IMPORT_EN_RETENU', {}],
|
||||||
|
// Façade des événements
|
||||||
|
['QUESTION_EN_ATTENTE', { question: 'PORTABLE_NON_INSCRIPTIBLE', remede: null }],
|
||||||
|
['EXPLORATEUR_ABSENT', { remede: null }],
|
||||||
// Libellés figés du journal
|
// Libellés figés du journal
|
||||||
['ENTREE_CREATION', { nom: "Soirée d'essai" }],
|
['ENTREE_CREATION', { nom: "Soirée d'essai" }],
|
||||||
['JALON_CREATION', {}],
|
['JALON_CREATION', {}],
|
||||||
|
|
@ -338,9 +367,38 @@ const EXEMPLES = new Map([
|
||||||
['ENTREE_IMPORTER_PARTICIPANTS', { mode: 'ajouter', ajoutes: 12, misAJour: 0, retires: 0 }],
|
['ENTREE_IMPORTER_PARTICIPANTS', { mode: 'ajouter', ajoutes: 12, misAJour: 0, retires: 0 }],
|
||||||
['ENTREE_CHANGER_ETAT', { etat: 'bloque' }],
|
['ENTREE_CHANGER_ETAT', { etat: 'bloque' }],
|
||||||
['ENTREE_DEBLOQUER', { etat: 'retenu' }],
|
['ENTREE_DEBLOQUER', { etat: 'retenu' }],
|
||||||
|
['ENTREE_COPIE', { source: 'Soirée des Lucioles', instant: "Création de l'événement « Soirée des Lucioles »" }],
|
||||||
|
['ENTREE_RENOMMER', { ancien: 'Soirée des Lucioles', nom: 'Soirée des Cerfs' }],
|
||||||
|
['ENTREE_DEMONSTRATION', { nom: 'Petite démonstration' }],
|
||||||
['ENTREE_ENREGISTRER_GENERATION', { n: 5, premier: 3, dernier: 7 }],
|
['ENTREE_ENREGISTRER_GENERATION', { n: 5, premier: 3, dernier: 7 }],
|
||||||
['ENTREE_RETENIR_PROPOSITION', { id: 3 }],
|
['ENTREE_RETENIR_PROPOSITION', { id: 3 }],
|
||||||
['ENTREE_EFFACER_PROPOSITIONS', { n: 4, gardee: 2 }],
|
['ENTREE_EFFACER_PROPOSITIONS', { n: 4, gardee: 2 }],
|
||||||
|
// Places : refus d'une pose, titres, réservations, retenu
|
||||||
|
['SIEGE_INEXISTANT', { table: 2, siege: 9, capacite: 8, remede: null }],
|
||||||
|
['DEUX_PLACES_MEME_TOUR', { participant: 1, tour: 2, autre: { table: 1, siege: null, portee: 'tous', tour: null }, remede: null }],
|
||||||
|
['PERSONNE_EXCLUE', { participant: 3, remede: null }],
|
||||||
|
['PLACE_RESERVEE', { table: 2, siege: 3, tour: 2, titulaire: 2, portee: 'tour', remede: null }],
|
||||||
|
['RESERVEE_AILLEURS', { participant: 1, tour: 2, reservation: { table: 1, siege: null, portee: 'tous', tour: null }, remede: null }],
|
||||||
|
['TABLE_GELEE', { table: 1 }],
|
||||||
|
['LIBELLE_VIDE', { remede: null }],
|
||||||
|
['PLACE_TITREE', { table: 3, siege: 1, libelle: 'accueil', remede: null }],
|
||||||
|
['AUCUN_SIEGE_SANS_TITRE', { table: 1, capacite: 2, remede: null }],
|
||||||
|
['TITRE_ABSENT', { table: 3, siege: 1, remede: null }],
|
||||||
|
['RESERVATION_ABSENTE', { participant: 1, table: 3, portee: 'tour', tour: 2, remede: null }],
|
||||||
|
['CONVERSION_IMPOSSIBLE', { participant: 1, table: 3, remede: null }],
|
||||||
|
['PROPOSITION_MONTREE', { proposition: 4, remede: null }],
|
||||||
|
['SIEGE_OCCUPE', { table: 5, siege: 2, tour: 1, occupant: 2, remede: null }],
|
||||||
|
['TABLE_PLEINE', { table: 5, tour: 1, capacite: 6, remede: null }],
|
||||||
|
['ENTREE_RESERVER', { personne: 'Théo Pervenche', numero: 4, siege: 2, portee: 'tour', tour: 2 }],
|
||||||
|
['ENTREE_RETIRER_RESERVATION', { personne: 'Théo Pervenche', numero: 4, portee: 'tous', tour: null }],
|
||||||
|
['ENTREE_DEPLACER_RESERVATION', { personne: 'Théo Pervenche', de: 4, vers: 7, siege: null, portee: 'tour', tour: 2 }],
|
||||||
|
['ENTREE_POSER_TITRE', { libelle: 'accueil', numero: 3, siege: 1 }],
|
||||||
|
['ENTREE_RETIRER_TITRE', { libelle: 'accueil', numero: 3, siege: 1 }],
|
||||||
|
['ENTREE_DESIGNER', { personne: 'Iris Ombrelle', numero: 3, siege: 1, libelle: 'accueil' }],
|
||||||
|
['ENTREE_CONVERTIR_EN_TOUS', { personne: 'Iris Ombrelle', numero: 3 }],
|
||||||
|
['ENTREE_DEPLACER_DANS_RETENU', { personne: 'Iris Ombrelle', de: 4, vers: 7, siege: null, tour: 2 }],
|
||||||
|
['ENTREE_ECHANGER_DANS_RETENU', { a: 'Iris Ombrelle', deA: 4, b: 'Théo Pervenche', deB: null, tour: 2 }],
|
||||||
|
['ENTREE_RETIRER_DU_TOUR', { personne: 'Iris Ombrelle', numero: 4, tour: 2 }],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Un texte composé ne laisse paraître ni valeur absente, ni booléen, ni
|
// Un texte composé ne laisse paraître ni valeur absente, ni booléen, ni
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,9 @@
|
||||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
// Les textes de la création d'un événement, de ses états et du déblocage
|
// Les textes de la création d'un événement, de ses états et du déblocage
|
||||||
// (§ 8.1, § 9, § 14.6) : refus et libellés figés. Un état inconnu lève
|
// (§ 8.1, § 9, § 14.6), et ceux de la façade des événements (§ 8.6, § 8.7,
|
||||||
// RangeError, au lieu de se taire.
|
// § 15) : refus et libellés figés. Un état inconnu lève RangeError, au lieu
|
||||||
|
// de se taire.
|
||||||
import { cite, nomEtat } from './aides.js';
|
import { cite, nomEtat } from './aides.js';
|
||||||
|
|
||||||
export const TABLE = new Map([
|
export const TABLE = new Map([
|
||||||
|
|
@ -15,8 +16,25 @@ export const TABLE = new Map([
|
||||||
['AUCUNE_PROPOSITION', () => "Aucune proposition n'existe : l'état « Proposé » suit une génération."],
|
['AUCUNE_PROPOSITION', () => "Aucune proposition n'existe : l'état « Proposé » suit une génération."],
|
||||||
['AUCUN_RETENU', () => "Aucun placement n'est retenu : l'état « Retenu » suit le choix d'une proposition."],
|
['AUCUN_RETENU', () => "Aucun placement n'est retenu : l'état « Retenu » suit le choix d'une proposition."],
|
||||||
|
|
||||||
|
// --- La façade des événements (§ 8.6) --------------------------------------
|
||||||
|
// question nomme le code de la question du dossier de travail qui attend.
|
||||||
|
[
|
||||||
|
'QUESTION_EN_ATTENTE',
|
||||||
|
() => "Le dossier de travail attend une réponse : la liste des événements s'ouvre une fois la question répondue.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'EXPLORATEUR_ABSENT',
|
||||||
|
() => "Sous la plateforme web, aucun explorateur de fichiers ne montre le dossier de travail : il vit dans le navigateur.",
|
||||||
|
],
|
||||||
|
|
||||||
// --- Les libellés figés du journal (§ 8.2, § 8.3) --------------------------
|
// --- Les libellés figés du journal (§ 8.2, § 8.3) --------------------------
|
||||||
['ENTREE_CREATION', ({ nom }) => `Création de l'événement ${cite(nom)}`],
|
['ENTREE_CREATION', ({ nom }) => `Création de l'événement ${cite(nom)}`],
|
||||||
['ENTREE_CHANGER_ETAT', ({ etat }) => `Plan passé à l'état ${cite(nomEtat(etat))}`],
|
['ENTREE_CHANGER_ETAT', ({ etat }) => `Plan passé à l'état ${cite(nomEtat(etat))}`],
|
||||||
['ENTREE_DEBLOQUER', ({ etat }) => `Plan débloqué, état ${cite(nomEtat(etat))}`],
|
['ENTREE_DEBLOQUER', ({ etat }) => `Plan débloqué, état ${cite(nomEtat(etat))}`],
|
||||||
|
// source : le nom de l'événement copié ; instant : le libellé de l'instant
|
||||||
|
// copié (§ 8.7).
|
||||||
|
['ENTREE_COPIE', ({ source, instant }) => `Créée par copie de ${cite(source)}, à l'instant ${cite(instant)}`],
|
||||||
|
['ENTREE_RENOMMER', ({ ancien, nom }) => `Renommé de ${cite(ancien)} en ${cite(nom)}`],
|
||||||
|
// nom : celui de la démonstration au catalogue, sans rang (§ 15).
|
||||||
|
['ENTREE_DEMONSTRATION', ({ nom }) => `Chargement de la démonstration ${cite(nom)}`],
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -3,4 +3,21 @@
|
||||||
|
|
||||||
// Les textes des codes du moteur qui n'ont pas leur table ailleurs, et les
|
// Les textes des codes du moteur qui n'ont pas leur table ailleurs, et les
|
||||||
// libellés de ses critères (§ 5, § 14.6).
|
// libellés de ses critères (§ 5, § 14.6).
|
||||||
export const TABLE = new Map();
|
import { compte } from './aides.js';
|
||||||
|
|
||||||
|
export const TABLE = new Map([
|
||||||
|
// Une salle trop petite : avertissement d'un geste qui fait croître le
|
||||||
|
// manque (A2), refus de la génération (A6) ; un seul texte pour les deux.
|
||||||
|
[
|
||||||
|
'PLACES_MANQUANTES',
|
||||||
|
({ placesManquantes }) =>
|
||||||
|
`Il manque ${compte(placesManquantes, 'place', 'places')} à chaque tour pour asseoir toutes les personnes : ` +
|
||||||
|
'ajouter des tables ou des sièges, ou exclure des personnes, rend la génération possible.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'SURRESERVATION',
|
||||||
|
({ table, tour, reservees, capacite }) =>
|
||||||
|
`La table d'identifiant ${table} reçoit ${compte(reservees, 'personne réservée', 'personnes réservées')} ` +
|
||||||
|
`au tour ${tour}, pour ${compte(capacite, 'place', 'places')}.`,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
|
||||||
|
|
@ -2,5 +2,131 @@
|
||||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
// Les textes des commandes des places — réservations, titres, pose (§ 5.8,
|
// Les textes des commandes des places — réservations, titres, pose (§ 5.8,
|
||||||
// § 5.9, § 14.6).
|
// § 5.9, § 8.2, § 14.6) : leurs refus, leurs signaux, leurs libellés figés,
|
||||||
export const TABLE = new Map();
|
// et le libellé de titre proposé à la désignation. Les détails désignent
|
||||||
|
// tables et personnes par identifiant : un refus se pose sur la place qu'il
|
||||||
|
// concerne (§ 5.9), et son texte ne les nomme pas. Un libellé figé reçoit le
|
||||||
|
// nom affiché et le numéro de table que la commande lit dans la charge.
|
||||||
|
// SURRESERVATION garde le texte du moteur (libelles/moteur.js), et
|
||||||
|
// INVARIANT_VIOLE celui de la séance (libelles/seance.js).
|
||||||
|
import { cite } from './aides.js';
|
||||||
|
|
||||||
|
// La portée d'une réservation dans un libellé figé.
|
||||||
|
const portee = ({ portee: genre, tour }) => (genre === 'tous' ? 'tous les tours' : `tour ${tour}`);
|
||||||
|
|
||||||
|
// Une place nommée par un détail de libellé : la table d'un numéro, ou la
|
||||||
|
// réserve quand le numéro est null.
|
||||||
|
const lieu = (numero) => (numero === null ? 'réserve' : `table ${numero}`);
|
||||||
|
|
||||||
|
export const TABLE = new Map([
|
||||||
|
// --- Les refus d'une pose (§ 5.9) -----------------------------------------
|
||||||
|
[
|
||||||
|
'SIEGE_INEXISTANT',
|
||||||
|
({ siege, capacite }) => `La table n'a que ${capacite} sièges : le siège ${siege} n'existe pas.`,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'DEUX_PLACES_MEME_TOUR',
|
||||||
|
({ tour }) => `Cette personne a déjà une place au tour ${tour} : elle n'en occupe qu'une par tour.`,
|
||||||
|
],
|
||||||
|
['PERSONNE_EXCLUE', () => "Cette personne est exclue : elle n'est placée nulle part tant qu'elle n'est pas réintégrée."],
|
||||||
|
[
|
||||||
|
'PLACE_RESERVEE',
|
||||||
|
({ siege, tour, portee: genre }) =>
|
||||||
|
`Le siège ${siege} est réservé à une autre personne ${genre === 'tous' ? 'à tous les tours' : `au tour ${tour}`} : ` +
|
||||||
|
'déplacer sa réservation libère la place.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'RESERVEE_AILLEURS',
|
||||||
|
({ tour }) => `Cette personne est réservée à une autre place au tour ${tour} : sa réservation se déplace d'abord.`,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'TABLE_GELEE',
|
||||||
|
() => "Toutes les places de cette table sont réservées pour la soirée : la génération n'y placera personne.",
|
||||||
|
],
|
||||||
|
|
||||||
|
// --- Les titres (§ 4, § 4.1) -----------------------------------------------
|
||||||
|
['LIBELLE_VIDE', () => 'Le libellé du titre est vide.'],
|
||||||
|
[
|
||||||
|
'PLACE_TITREE',
|
||||||
|
({ siege, libelle }) => `Le siège ${siege} porte déjà le titre ${cite(libelle)} : une place porte un seul titre.`,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'AUCUN_SIEGE_SANS_TITRE',
|
||||||
|
({ capacite }) => `Les ${capacite} sièges de la table portent déjà un titre.`,
|
||||||
|
],
|
||||||
|
['TITRE_ABSENT', ({ siege }) => `Le siège ${siege} ne porte aucun titre.`],
|
||||||
|
|
||||||
|
// --- Les réservations (§ 4, § 4.2) -----------------------------------------
|
||||||
|
['RESERVATION_ABSENTE', () => "Cette réservation n'existe pas."],
|
||||||
|
[
|
||||||
|
'CONVERSION_IMPOSSIBLE',
|
||||||
|
() => "Ces réservations ne couvrent pas chaque tour à cette table : il n'y a rien à convertir.",
|
||||||
|
],
|
||||||
|
|
||||||
|
// --- Le retenu (§ 5.8, § 7.1) ----------------------------------------------
|
||||||
|
[
|
||||||
|
'PROPOSITION_MONTREE',
|
||||||
|
({ proposition }) =>
|
||||||
|
`La proposition n° ${proposition} se montre en vue seule : seul le placement retenu se modifie.`,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'SIEGE_OCCUPE',
|
||||||
|
({ siege, tour }) => `Le siège ${siege} est occupé au tour ${tour} : déposer la personne sur l'autre les échange.`,
|
||||||
|
],
|
||||||
|
['TABLE_PLEINE', ({ capacite, tour }) => `La table est pleine au tour ${tour} : ses ${capacite} places sont occupées.`],
|
||||||
|
|
||||||
|
// --- Les libellés figés du journal (§ 8.2) ---------------------------------
|
||||||
|
[
|
||||||
|
'ENTREE_RESERVER',
|
||||||
|
({ personne, numero, siege, ...reservation }) =>
|
||||||
|
`Réservé ${personne} à la table ${numero}${siege === null ? '' : `, siège ${siege}`}, ${portee(reservation)}`,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'ENTREE_RETIRER_RESERVATION',
|
||||||
|
({ personne, numero, ...reservation }) => `Réservation retirée : ${personne}, table ${numero}, ${portee(reservation)}`,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'ENTREE_DEPLACER_RESERVATION',
|
||||||
|
({ personne, de, vers, siege, ...reservation }) =>
|
||||||
|
`Réservation déplacée : ${personne}, de la table ${de} à la table ${vers}` +
|
||||||
|
`${siege === null ? '' : `, siège ${siege}`}, ${portee(reservation)}`,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'ENTREE_POSER_TITRE',
|
||||||
|
({ libelle, numero, siege }) => `Posé le titre ${cite(libelle)} à la table ${numero}, siège ${siege}`,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'ENTREE_RETIRER_TITRE',
|
||||||
|
({ libelle, numero, siege }) => `Retiré le titre ${cite(libelle)} de la table ${numero}, siège ${siege}`,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'ENTREE_DESIGNER',
|
||||||
|
({ personne, numero, siege, libelle }) => `Désigné ${personne} à la table ${numero}, siège ${siege} : ${cite(libelle)}`,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'ENTREE_CONVERTIR_EN_TOUS',
|
||||||
|
({ personne, numero }) => `Converti en ${cite('tous les tours')} : ${personne}, table ${numero}`,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'ENTREE_DEPLACER_DANS_RETENU',
|
||||||
|
// siege n'est donné que pour un déplacement dans la même table.
|
||||||
|
({ personne, de, vers, siege, tour }) =>
|
||||||
|
siege !== null
|
||||||
|
? `Déplacé ${personne} au siège ${siege} de la table ${vers}, tour ${tour}`
|
||||||
|
: `Déplacé ${personne} de la ${lieu(de)} à la table ${vers}, tour ${tour}`,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'ENTREE_ECHANGER_DANS_RETENU',
|
||||||
|
({ a, deA, b, deB, tour }) => `Échangé ${a} (${lieu(deA)}) et ${b} (${lieu(deB)}), tour ${tour}`,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'ENTREE_RETIRER_DU_TOUR',
|
||||||
|
({ personne, numero, tour }) =>
|
||||||
|
numero === null ? `Retiré ${personne} du tour ${tour}` : `Retiré ${personne} de la table ${numero}, tour ${tour}`,
|
||||||
|
],
|
||||||
|
|
||||||
|
// --- Les libellés de l'application ----------------------------------------
|
||||||
|
// Le libellé de titre proposé à la désignation quand rien d'autre ne le
|
||||||
|
// suggère : il devient une donnée du fichier, en français (§ 8.2).
|
||||||
|
['places.titreParDefaut', 'animateur'],
|
||||||
|
]);
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,31 @@
|
||||||
// figés que la séance écrit au journal : jalons, reprise, retours. Un libellé
|
// figés que la séance écrit au journal : jalons, reprise, retours. Un libellé
|
||||||
// d'entrée se compose une fois, à l'écriture du geste, et le journal en garde
|
// d'entrée se compose une fois, à l'écriture du geste, et le journal en garde
|
||||||
// le texte, jamais le code (§ 8.2).
|
// le texte, jamais le code (§ 8.2).
|
||||||
import { causeEntreParentheses, cite, depuisQuand, nomEtat, ouEcrire } from './aides.js';
|
import {
|
||||||
|
capitale,
|
||||||
|
causeEntreParentheses,
|
||||||
|
cite,
|
||||||
|
compte,
|
||||||
|
depuisQuand,
|
||||||
|
enumeration,
|
||||||
|
nomEtat,
|
||||||
|
ouEcrire,
|
||||||
|
phraseDeRaison,
|
||||||
|
} from './aides.js';
|
||||||
|
|
||||||
|
// Le placement qu'une dérive touche : le retenu, ou une proposition par son
|
||||||
|
// numéro.
|
||||||
|
const nomDuPlacement = (id) => (id === 'retenu' ? 'le placement retenu' : `la proposition n° ${id}`);
|
||||||
|
|
||||||
|
// Les raisons d'un placement, chacune par son texte.
|
||||||
|
const phrasesDe = (raisons, texte) => raisons.map((raison) => phraseDeRaison(raison.code, raison, texte)).join(' ');
|
||||||
|
|
||||||
|
// Une violation du retenu : la réservation qu'il ne tient pas par le texte
|
||||||
|
// de RESERVATION_NON_HONOREE, toute autre par le texte de son code.
|
||||||
|
const phraseDeViolation = (violation, texte) =>
|
||||||
|
violation.code === 'RESERVATION'
|
||||||
|
? texte('RESERVATION_NON_HONOREE', violation)
|
||||||
|
: phraseDeRaison(violation.code, violation, texte);
|
||||||
|
|
||||||
export const TABLE = new Map([
|
export const TABLE = new Map([
|
||||||
// --- La séance (§ 8.3, § 8.4, § 8.5, § 8.6, § 8.8) -------------------------
|
// --- La séance (§ 8.3, § 8.4, § 8.5, § 8.6, § 8.8) -------------------------
|
||||||
|
|
@ -70,6 +94,45 @@ export const TABLE = new Map([
|
||||||
'VERROU_REPRIS',
|
'VERROU_REPRIS',
|
||||||
({ depuis }, texte) => `Le verrou qu'une séance interrompue de ce poste avait laissé${depuisQuand(depuis, texte)} est repris.`,
|
({ depuis }, texte) => `Le verrou qu'une séance interrompue de ce poste avait laissé${depuisQuand(depuis, texte)} est repris.`,
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'RETOUR_AUTOMATIQUE',
|
||||||
|
() =>
|
||||||
|
"Faute d'activité, l'événement est revenu seul en lecture ; chaque geste achevé était déjà enregistré. " +
|
||||||
|
'« Modifier » permet de reprendre.',
|
||||||
|
],
|
||||||
|
|
||||||
|
// --- Ce qu'un geste fait naître (§ 5.9, § 9, § 14.12) ----------------------
|
||||||
|
[
|
||||||
|
'DERIVE_NOUVELLE',
|
||||||
|
({ touchees }, texte) => {
|
||||||
|
const noms = touchees.map(({ id }) => nomDuPlacement(id));
|
||||||
|
const accord = touchees.length > 1 ? 'ne décrivent plus' : 'ne décrit plus';
|
||||||
|
const parPlacement = touchees.map(({ id, raisons }) => `${capitale(nomDuPlacement(id))} : ${phrasesDe(raisons, texte)}`);
|
||||||
|
return (
|
||||||
|
`Après ce geste, ${enumeration(noms)} ${accord} la liste ou les tables actuelles, et rien n'est effacé. ` +
|
||||||
|
parPlacement.join(' ')
|
||||||
|
);
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'RESERVATION_NON_HONOREE',
|
||||||
|
({ participant, table, tour }) =>
|
||||||
|
`Le placement retenu n'honore pas la réservation de la personne n° ${participant} ` +
|
||||||
|
`à la table d'identifiant ${table} au tour ${tour} : il la place ailleurs.`,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'INVARIANT_VIOLE',
|
||||||
|
({ proposition, violations }, texte) =>
|
||||||
|
`Le placement retenu, issu de la proposition n° ${proposition}, enfreint ` +
|
||||||
|
`${compte(violations.length, 'règle', 'règles')} que la dérive ne dit pas. ` +
|
||||||
|
violations.map((violation) => phraseDeViolation(violation, texte)).join(' '),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'CONFIGURATION_INCOHERENTE',
|
||||||
|
({ code, details }, texte) =>
|
||||||
|
`Après ce geste, la configuration ne se tient plus : ${phraseDeRaison(code, details, texte)} ` +
|
||||||
|
'Le diagnostic et la génération la refusent tant que rien ne la corrige.',
|
||||||
|
],
|
||||||
[
|
[
|
||||||
'VERROU_NON_RENDU',
|
'VERROU_NON_RENDU',
|
||||||
({ cause }, texte) =>
|
({ cause }, texte) =>
|
||||||
|
|
|
||||||
101
src/application/listes_travail.js
Normal file
101
src/application/listes_travail.js
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Les listes de travail (§ 5.8, § 4.1, § 4.2, § 2.1 étape 6) : le travail qui
|
||||||
|
// reste, montré plutôt que fait. Les titres de place que rien ne pourvoit
|
||||||
|
// pour la soirée, les personnes au titre pressenti que rien ne réserve, les
|
||||||
|
// conversions que le logiciel propose sans les faire, et les comptes de
|
||||||
|
// titres d'une table. Un titre pourvu se lit selon la seule règle,
|
||||||
|
// pourvoyeursDesTitres : pour la soirée, par une réservation « tous les
|
||||||
|
// tours » ; une réservation d'un tour désigné ne le pourvoit qu'à ce tour,
|
||||||
|
// et le titre reste dans la liste. Aucune fonction ne modifie la charge.
|
||||||
|
import { pourvoyeursDesTitres } from '../csv/import.js';
|
||||||
|
import { presents } from './places.js';
|
||||||
|
|
||||||
|
// Deux chaînes, comparées unité UTF-16 par unité.
|
||||||
|
const comparerTextes = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Les titres que rien ne pourvoit pour la soirée (§ 4.1), chacun avec les
|
||||||
|
* tours où une réservation d'un tour désigné le pourvoit, croissants ; par
|
||||||
|
* numéro de table, puis par siège et par libellé, l'identifiant de table
|
||||||
|
* départageant deux numéros égaux.
|
||||||
|
*
|
||||||
|
* @param {import('../stockage/types.js').Charge} charge
|
||||||
|
* @returns {Array<{table: number, numero: number, siege: number, libelle: string, pourvuAuxTours: number[]}>}
|
||||||
|
*/
|
||||||
|
export function titresNonPourvus(charge) {
|
||||||
|
const numeroDe = new Map(charge.tables.map(({ id, numero }) => [id, numero]));
|
||||||
|
const pourvoyeurs = pourvoyeursDesTitres(charge);
|
||||||
|
return charge.titres
|
||||||
|
.map((titre, rang) => ({ titre, ...pourvoyeurs[rang] }))
|
||||||
|
.filter(({ soiree }) => soiree === null)
|
||||||
|
.map(({ titre: { table, siege, libelle }, tours }) => ({
|
||||||
|
table,
|
||||||
|
numero: numeroDe.get(table),
|
||||||
|
siege,
|
||||||
|
libelle,
|
||||||
|
pourvuAuxTours: tours.flatMap((reservation, rang) => (reservation === null ? [] : [rang + 1])),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.numero - b.numero || a.table - b.table || a.siege - b.siege || comparerTextes(a.libelle, b.libelle));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Les personnes non exclues qui portent un titre pressenti et qu'aucune
|
||||||
|
* réservation ne place encore (§ 5.8), par identifiant croissant.
|
||||||
|
*
|
||||||
|
* @param {import('../stockage/types.js').Charge} charge
|
||||||
|
* @returns {Array<{participant: number, titrePressenti: string}>}
|
||||||
|
*/
|
||||||
|
export function pressentisNonReserves(charge) {
|
||||||
|
const reservees = new Set(charge.reservations.map(({ participant }) => participant));
|
||||||
|
return charge.participants
|
||||||
|
.filter(({ id, exclu, titrePressenti }) => !exclu && titrePressenti !== null && !reservees.has(id))
|
||||||
|
.map(({ id, titrePressenti }) => ({ participant: id, titrePressenti }))
|
||||||
|
.sort((a, b) => a.participant - b.participant);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Les conversions proposées (§ 4.2) : une personne non exclue dont les
|
||||||
|
* réservations « tour désigné » à une même table couvrent chacun des R tours,
|
||||||
|
* et qui n'y tient aucune réservation « tous » — R réservations qu'une seule
|
||||||
|
* de portée « tous les tours » remplacerait. Par personne, puis par table.
|
||||||
|
*
|
||||||
|
* @param {import('../stockage/types.js').Charge} charge
|
||||||
|
* @returns {Array<{participant: number, table: number}>}
|
||||||
|
*/
|
||||||
|
export function conversionsPossibles(charge) {
|
||||||
|
const ids = presents(charge);
|
||||||
|
const R = charge.evenement.tours;
|
||||||
|
const toursDe = new Map();
|
||||||
|
const tous = new Set();
|
||||||
|
for (const { participant, table, portee, tour } of charge.reservations) {
|
||||||
|
if (!ids.has(participant)) continue;
|
||||||
|
const cle = `${participant}:${table}`;
|
||||||
|
if (portee === 'tous') {
|
||||||
|
tous.add(cle);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!toursDe.has(cle)) toursDe.set(cle, { participant, table, tours: new Set() });
|
||||||
|
toursDe.get(cle).tours.add(tour);
|
||||||
|
}
|
||||||
|
return [...toursDe.entries()]
|
||||||
|
.filter(([cle, { tours }]) => !tous.has(cle) && tours.size === R)
|
||||||
|
.map(([, { participant, table }]) => ({ participant, table }))
|
||||||
|
.sort((a, b) => a.participant - b.participant || a.table - b.table);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Les titres d'une table et ceux qu'une réservation « tous les tours »
|
||||||
|
* pourvoit pour la soirée (§ 2.1 étape 6) ; deux titres sur une même place
|
||||||
|
* comptent deux.
|
||||||
|
*
|
||||||
|
* @param {import('../stockage/types.js').Charge} charge
|
||||||
|
* @param {number} idTable
|
||||||
|
* @returns {{titres: number, pourvus: number}}
|
||||||
|
*/
|
||||||
|
export function comptesTitres(charge, idTable) {
|
||||||
|
const pourvoyeurs = pourvoyeursDesTitres(charge);
|
||||||
|
const rangs = charge.titres.flatMap(({ table }, rang) => (table === idTable ? [rang] : []));
|
||||||
|
return { titres: rangs.length, pourvus: rangs.filter((rang) => pourvoyeurs[rang].soiree !== null).length };
|
||||||
|
}
|
||||||
163
src/application/listes_travail.test.js
Normal file
163
src/application/listes_travail.test.js
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Épreuves des listes de travail (§ 4, § 4.1, § 4.2, § 5.8, § 2.1 étape 6) :
|
||||||
|
// les titres que rien ne pourvoit, les pressentis non réservés, les
|
||||||
|
// conversions proposées et les comptes de titres d'une table. Les noms
|
||||||
|
// d'épreuve sont inventés.
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { describe, test } from '../../test/lanceur.js';
|
||||||
|
import { apercevoir } from '../csv/apercu.js';
|
||||||
|
import { appliquerImport, bilanRemplacement } from '../csv/import.js';
|
||||||
|
import { creerCharge } from '../stockage/document.js';
|
||||||
|
import { COMMANDES } from './commandes.js';
|
||||||
|
import { comptesTitres, conversionsPossibles, pressentisNonReserves, titresNonPourvus } from './listes_travail.js';
|
||||||
|
|
||||||
|
const executer = (nom, charge, args) => COMMANDES.get(nom)(charge, args);
|
||||||
|
|
||||||
|
const personne = (id, nom, prenom = null, autres = {}) => ({
|
||||||
|
id,
|
||||||
|
nom,
|
||||||
|
prenom,
|
||||||
|
appartenance: null,
|
||||||
|
courriel: null,
|
||||||
|
titrePressenti: null,
|
||||||
|
notes: null,
|
||||||
|
exclu: false,
|
||||||
|
...autres,
|
||||||
|
});
|
||||||
|
|
||||||
|
const table = (id, numero = id) => ({ id, numero, sieges: null, forme: 'ronde', position: { x: 300 * id, y: 0 } });
|
||||||
|
|
||||||
|
const resa = (participant, idTable, siege, tour = null) => ({
|
||||||
|
participant,
|
||||||
|
table: idTable,
|
||||||
|
siege,
|
||||||
|
portee: tour === null ? 'tous' : 'tour',
|
||||||
|
tour,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Trois tours, quatre tables de 6 numérotées comme leurs identifiants, cinq
|
||||||
|
// personnes ; le mode table sauf demande contraire.
|
||||||
|
function chargeDe({ attribuerSieges = false, reservations = [], titres = [] } = {}) {
|
||||||
|
const charge = creerCharge({ id: 'evt-listes', nom: 'Soirée des Lucioles', siegesParDefaut: 6, tours: 3 });
|
||||||
|
charge.reglages.attribuerSieges = attribuerSieges;
|
||||||
|
charge.participants = [
|
||||||
|
personne(1, 'Ombrelle', 'Iris'),
|
||||||
|
personne(2, 'Pervenche', 'Théo', { titrePressenti: 'animateur' }),
|
||||||
|
personne(3, 'Lacasse', 'Ondine', { titrePressenti: 'hôtesse' }),
|
||||||
|
personne(4, 'Grisaille'),
|
||||||
|
personne(5, 'Bruyère', 'Anouk', { titrePressenti: 'accueil', exclu: true }),
|
||||||
|
];
|
||||||
|
charge.tables = [table(1), table(2), table(3), table(4)];
|
||||||
|
charge.reservations = reservations;
|
||||||
|
charge.titres = titres;
|
||||||
|
charge.prochainsIds = { participant: 6, table: 5, proposition: 1 };
|
||||||
|
return charge;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('titresNonPourvus et comptesTitres (§ 4.1, § 2.1 étape 6)', () => {
|
||||||
|
test('mode table : trois titres à la table 2 et une désignation → trois titres, un pourvu, deux à pourvoir', () => {
|
||||||
|
let charge = chargeDe();
|
||||||
|
for (const libelle of ['animation', 'accueil', 'veille']) {
|
||||||
|
charge = executer('poserTitre', charge, { table: 2, siege: null, libelle }).charge;
|
||||||
|
}
|
||||||
|
assert.deepEqual(
|
||||||
|
charge.titres.map(({ siege }) => siege),
|
||||||
|
[1, 2, 3],
|
||||||
|
);
|
||||||
|
charge = executer('designer', charge, { participant: 1, table: 2, siege: null, libelle: 'animation' }).charge;
|
||||||
|
assert.deepEqual(comptesTitres(charge, 2), { titres: 3, pourvus: 1 });
|
||||||
|
assert.deepEqual(comptesTitres(charge, 1), { titres: 0, pourvus: 0 });
|
||||||
|
const deLaTable = titresNonPourvus(charge).filter(({ table: t }) => t === 2);
|
||||||
|
assert.deepEqual(deLaTable, [
|
||||||
|
{ table: 2, numero: 2, siege: 2, libelle: 'accueil', pourvuAuxTours: [] },
|
||||||
|
{ table: 2, numero: 2, siege: 3, libelle: 'veille', pourvuAuxTours: [] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('une réservation d’un tour désigné pourvoit le titre à ce tour seulement : il reste dans la liste', () => {
|
||||||
|
const charge = chargeDe({ reservations: [resa(4, 3, 1, 2)], titres: [{ table: 3, siege: 1, libelle: 'animation' }] });
|
||||||
|
assert.deepEqual(titresNonPourvus(charge), [{ table: 3, numero: 3, siege: 1, libelle: 'animation', pourvuAuxTours: [2] }]);
|
||||||
|
assert.deepEqual(comptesTitres(charge, 3), { titres: 1, pourvus: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('les titres se rangent par numéro de table, puis par siège', () => {
|
||||||
|
const charge = chargeDe({
|
||||||
|
titres: [
|
||||||
|
{ table: 1, siege: 2, libelle: 'accueil' },
|
||||||
|
{ table: 4, siege: 1, libelle: 'animation' },
|
||||||
|
{ table: 1, siege: 1, libelle: 'animation' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
charge.tables[3].numero = 1;
|
||||||
|
charge.tables[0].numero = 4;
|
||||||
|
assert.deepEqual(
|
||||||
|
titresNonPourvus(charge).map(({ table: t, siege }) => [t, siege]),
|
||||||
|
[[4, 1], [1, 1], [1, 2]],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un titre pourvu, une règle : en mode table, une réservation au siège 5 ne pourvoit pas le titre du siège 1', () => {
|
||||||
|
const charge = chargeDe({ reservations: [resa(1, 2, 5)], titres: [{ table: 2, siege: 1, libelle: 'animation' }] });
|
||||||
|
assert.equal(bilanRemplacement(charge).titresPourvus, 0);
|
||||||
|
assert.deepEqual(
|
||||||
|
titresNonPourvus(charge).map(({ table: t, siege }) => [t, siege]),
|
||||||
|
[[2, 1]],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('le titre reste : retirer, déplacer, supprimer ou exclure la personne désignée laisse le titre, non pourvu', () => {
|
||||||
|
const designee = executer('designer', chargeDe(), { participant: 1, table: 3, siege: null, libelle: 'animation' }).charge;
|
||||||
|
assert.deepEqual(titresNonPourvus(designee), []);
|
||||||
|
const variantes = [
|
||||||
|
['retirerReservation', { participant: 1, table: 3, portee: 'tous', tour: null }],
|
||||||
|
['deplacerReservation', { participant: 1, de: { table: 3, portee: 'tous', tour: null }, vers: { table: 4, siege: null } }],
|
||||||
|
['supprimerParticipant', { id: 1 }],
|
||||||
|
['exclureParticipant', { id: 1 }],
|
||||||
|
];
|
||||||
|
assert.equal(variantes.length, 4);
|
||||||
|
for (const [nom, args] of variantes) {
|
||||||
|
const { charge } = executer(nom, designee, args);
|
||||||
|
assert.deepEqual(charge.titres, designee.titres, nom);
|
||||||
|
assert.deepEqual(
|
||||||
|
titresNonPourvus(charge).map(({ table: t }) => t),
|
||||||
|
[3],
|
||||||
|
nom,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pressentisNonReserves et conversionsPossibles (§ 4, § 4.2, § 5.8)', () => {
|
||||||
|
test('les pressentis que rien ne réserve, exclus omis', () => {
|
||||||
|
const charge = chargeDe({ reservations: [resa(3, 1, null, 2)] });
|
||||||
|
assert.deepEqual(pressentisNonReserves(charge), [{ participant: 2, titrePressenti: 'animateur' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('titre pressenti importé : ni réservation ni titre, la liste des titres inchangée, la personne dans les pressentis', () => {
|
||||||
|
const charge = chargeDe({ titres: [{ table: 1, siege: 1, libelle: 'animation' }] });
|
||||||
|
charge.participants = charge.participants.filter(({ titrePressenti }) => titrePressenti === null);
|
||||||
|
const texte = 'nom;prenom;titre_pressenti\nSarcelle;Capucine;animatrice\n';
|
||||||
|
const { charge: apres } = appliquerImport(charge, apercevoir(texte, { participants: charge.participants }), 'ajouter');
|
||||||
|
assert.deepEqual(apres.reservations, charge.reservations);
|
||||||
|
assert.deepEqual(apres.titres, charge.titres);
|
||||||
|
assert.deepEqual(titresNonPourvus(apres), titresNonPourvus(charge));
|
||||||
|
assert.deepEqual(pressentisNonReserves(apres), [{ participant: 6, titrePressenti: 'animatrice' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('la R-ième réservation « tour » à une même table propose la conversion, sans rien changer ; convertirEnTous en fait une seule « tous »', () => {
|
||||||
|
const charge = chargeDe({ reservations: [resa(2, 1, null, 1), resa(2, 1, null, 2), resa(3, 1, null, 1), resa(3, 2, null, 2)] });
|
||||||
|
assert.deepEqual(conversionsPossibles(charge), []);
|
||||||
|
const { charge: troisieme } = executer('reserver', charge, { participant: 2, table: 1, siege: null, portee: 'tour', tour: 3 });
|
||||||
|
const avant = structuredClone(troisieme);
|
||||||
|
assert.deepEqual(conversionsPossibles(troisieme), [{ participant: 2, table: 1 }]);
|
||||||
|
assert.deepEqual(troisieme, avant);
|
||||||
|
const { charge: convertie } = executer('convertirEnTous', troisieme, { participant: 2, table: 1 });
|
||||||
|
assert.deepEqual(
|
||||||
|
convertie.reservations.filter(({ participant }) => participant === 2),
|
||||||
|
[resa(2, 1, null)],
|
||||||
|
);
|
||||||
|
assert.deepEqual(conversionsPossibles(convertie), []);
|
||||||
|
});
|
||||||
|
});
|
||||||
354
src/application/places.js
Normal file
354
src/application/places.js
Normal file
|
|
@ -0,0 +1,354 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// L'état des places d'un tour (§ 5.8, § 7.5, § 7.6, § 8.9, § 9) : ce que le
|
||||||
|
// plan dessine et ce qu'un geste de personne modifie, lu dans la charge sans
|
||||||
|
// rien recalculer du moteur que ses conflits. Aussi la seule répartition par
|
||||||
|
// siège (repartirSieges), le libellé proposé à la désignation et les statuts
|
||||||
|
// calculés (§ 4.2). Aucune fonction ne modifie ce qu'elle reçoit.
|
||||||
|
//
|
||||||
|
// Une réservation est active quand sa personne est dans la charge et n'est
|
||||||
|
// pas exclue : celle d'une exclue est suspendue (§ 4.4) et ne tient aucune
|
||||||
|
// place. Un titre se lit pourvu selon la règle de pourvoyeursDesTitres, la
|
||||||
|
// seule (src/csv/import.js).
|
||||||
|
import { ordreDesReservations, pourvoyeursDesTitres } from '../csv/import.js';
|
||||||
|
import { STATUT, indexerPlan, normaliser } from '../moteur/configuration.js';
|
||||||
|
import { conflitsParTable } from '../moteur/conflits.js';
|
||||||
|
import { ErreurConfiguration } from '../moteur/erreurs.js';
|
||||||
|
import { capacite, configurationDepuisCharge } from '../stockage/document.js';
|
||||||
|
import { derive, fautes, planDepuisFichier } from '../stockage/placements.js';
|
||||||
|
import { libelle } from './libelles.js';
|
||||||
|
|
||||||
|
const croissant = (a, b) => a - b;
|
||||||
|
// Deux chaînes, comparées unité UTF-16 par unité.
|
||||||
|
const comparerTextes = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
||||||
|
|
||||||
|
/** Vrai quand la réservation couvre le tour r : « tous », ou son tour. */
|
||||||
|
export const couvre = ({ portee, tour }, r) => portee === 'tous' || tour === r;
|
||||||
|
|
||||||
|
/** Les identifiants des personnes non exclues de la charge. */
|
||||||
|
export const presents = (charge) => new Set(charge.participants.filter(({ exclu }) => !exclu).map(({ id }) => id));
|
||||||
|
|
||||||
|
/** Les réservations actives — personne présente et non exclue —, dans l'ordre canonique. */
|
||||||
|
export function reservationsActives(charge) {
|
||||||
|
const ids = presents(charge);
|
||||||
|
return charge.reservations.filter(({ participant }) => ids.has(participant)).sort(ordreDesReservations);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* L'instance du moteur de la charge, ou null quand la configuration ne se
|
||||||
|
* normalise pas (ErreurConfiguration) ; toute autre erreur passe.
|
||||||
|
*/
|
||||||
|
export function instanceDe(charge) {
|
||||||
|
try {
|
||||||
|
return normaliser(configurationDepuisCharge(charge));
|
||||||
|
} catch (erreur) {
|
||||||
|
if (erreur instanceof ErreurConfiguration) return null;
|
||||||
|
throw erreur;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* La seule répartition par siège : la personne réservée au siège s ≤ places
|
||||||
|
* (siegeDe : Map id → s ; deux au même siège, le plus petit identifiant) au
|
||||||
|
* rang s, les autres aux rangs libres par identifiant croissant, null aux
|
||||||
|
* rangs restés libres. Quand les autres dépassent les rangs libres, les
|
||||||
|
* prioritaires y entrent d'abord, puis les plus petits identifiants : les
|
||||||
|
* plus grands identifiants non prioritaires restent dehors. etatPlaces la
|
||||||
|
* sollicite avec places = capacité, et pour prioritaires les personnes dont
|
||||||
|
* une réservation couvrante tient cette table ; une génération à sièges
|
||||||
|
* attribués avec places = capacité, puis retire les null de fin : ceux du
|
||||||
|
* milieu sont les chaises vides du fichier.
|
||||||
|
*
|
||||||
|
* @param {number[]} personnes
|
||||||
|
* @param {Map<number, number|null>} siegeDe
|
||||||
|
* @param {number} places
|
||||||
|
* @param {Set<number>} [prioritaires] vide par défaut
|
||||||
|
* @returns {Array<number|null>} places rangs
|
||||||
|
*/
|
||||||
|
export function repartirSieges(personnes, siegeDe, places, prioritaires = new Set()) {
|
||||||
|
const rangs = Array.from({ length: places }, () => null);
|
||||||
|
const placees = new Set();
|
||||||
|
const siegeValide = (id) => {
|
||||||
|
const siege = siegeDe.get(id);
|
||||||
|
return Number.isInteger(siege) && siege >= 1 && siege <= places;
|
||||||
|
};
|
||||||
|
for (const id of personnes.filter(siegeValide).sort(croissant)) {
|
||||||
|
const rang = siegeDe.get(id) - 1;
|
||||||
|
if (rangs[rang] === null) {
|
||||||
|
rangs[rang] = id;
|
||||||
|
placees.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const libres = rangs.filter((occupant) => occupant === null).length;
|
||||||
|
const autres = personnes.filter((id) => !placees.has(id)).sort(croissant);
|
||||||
|
const elues = [...autres.filter((id) => prioritaires.has(id)), ...autres.filter((id) => !prioritaires.has(id))]
|
||||||
|
.slice(0, libres)
|
||||||
|
.sort(croissant);
|
||||||
|
let suivante = 0;
|
||||||
|
for (let rang = 0; rang < places && suivante < elues.length; rang += 1) {
|
||||||
|
if (rangs[rang] === null) {
|
||||||
|
rangs[rang] = elues[suivante];
|
||||||
|
suivante += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rangs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// La source d'un tour (contrat de données, « Source ») : la proposition
|
||||||
|
// demandée quand elle existe, se lit (fautes vide) et porte ce tour ; sinon
|
||||||
|
// le retenu, s'il se lit et porte ce tour ; sinon les seules réservations.
|
||||||
|
// cible et refus disent ce qu'un geste de personne y modifie.
|
||||||
|
function sourceDuTour(charge, tour, proposition) {
|
||||||
|
const { retenu } = charge;
|
||||||
|
const retenuFautif = retenu !== null && fautes(retenu).length > 0;
|
||||||
|
const porte = (placement) => fautes(placement).length === 0 && placement.tours >= tour;
|
||||||
|
const demandee = proposition === null ? undefined : charge.propositions.find(({ id }) => id === proposition);
|
||||||
|
if (demandee !== undefined && porte(demandee)) {
|
||||||
|
if (retenu === null) return { source: 'proposition', placement: demandee, cible: 'reservations', refus: null };
|
||||||
|
const remede = { geste: 'afficherTour', tour, proposition: null };
|
||||||
|
return {
|
||||||
|
source: 'proposition',
|
||||||
|
placement: demandee,
|
||||||
|
cible: null,
|
||||||
|
refus: { code: 'PROPOSITION_MONTREE', details: { proposition: demandee.id, remede } },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (retenu !== null && !retenuFautif && porte(retenu)) return { source: 'retenu', placement: retenu, cible: 'retenu', refus: null };
|
||||||
|
if (retenuFautif) {
|
||||||
|
const remede = { geste: 'ouvrirSecours', source: 'precedent' };
|
||||||
|
return { source: 'reservations', placement: null, cible: null, refus: { code: 'RETENU_FAUTIF', details: { remede } } };
|
||||||
|
}
|
||||||
|
return { source: 'reservations', placement: null, cible: 'reservations', refus: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Le plan indexé dont se lisent les conflits du tour, ou null quand ils ne
|
||||||
|
// s'évaluent pas : la configuration ne se normalise pas, ou la source est en
|
||||||
|
// dérive. Sans source, la rangée des réservations de l'instance
|
||||||
|
// (instance.fixe) se lit comme le plan qui n'assied que les réservés.
|
||||||
|
function planEvalue(charge, instance, placement) {
|
||||||
|
if (instance === null) return null;
|
||||||
|
if (placement === null) return instance.fixe;
|
||||||
|
if (derive(placement, charge).length > 0) return null;
|
||||||
|
try {
|
||||||
|
return indexerPlan(instance, planDepuisFichier(placement));
|
||||||
|
} catch (erreur) {
|
||||||
|
if (erreur instanceof ErreurConfiguration) return null;
|
||||||
|
throw erreur;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Les places que les réservations actives couvrantes tiennent à une table :
|
||||||
|
// tenu, Map siège → réservation, la première dans l'ordre canonique de
|
||||||
|
// chaque siège dans la capacité, une personne n'en tenant qu'un ; sansSiege,
|
||||||
|
// Map personne → réservation, pour chaque autre personne réservée à la
|
||||||
|
// table — sans siège, ou dont le siège est pris : elle se lit comme une
|
||||||
|
// réservation sans siège.
|
||||||
|
function placesTenues(couvrantes, places) {
|
||||||
|
const tenu = new Map();
|
||||||
|
const siegeDe = new Map();
|
||||||
|
const sansSiege = new Map();
|
||||||
|
for (const reservation of couvrantes) {
|
||||||
|
const { siege, participant } = reservation;
|
||||||
|
if (siege !== null && siege <= places && !tenu.has(siege) && !siegeDe.has(participant)) {
|
||||||
|
tenu.set(siege, reservation);
|
||||||
|
siegeDe.set(participant, siege);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const reservation of couvrantes) {
|
||||||
|
if (!siegeDe.has(reservation.participant) && !sansSiege.has(reservation.participant)) {
|
||||||
|
sansSiege.set(reservation.participant, reservation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { tenu, siegeDe, sansSiege };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Le remplissage et les marques d'une chaise (§ 7.6) : plein et cadenas
|
||||||
|
// fermé quand une réservation tient la place, l'anneau en plus pour la portée
|
||||||
|
// « tous les tours » ; sinon cadenas ouvert pour un titre, hachure quand rien
|
||||||
|
// ne le pourvoit à ce tour.
|
||||||
|
function aspect(reservation, titre) {
|
||||||
|
if (reservation !== null) {
|
||||||
|
return { remplissage: 'plein', marques: reservation.portee === 'tous' ? ['cadenasFerme', 'anneau'] : ['cadenasFerme'] };
|
||||||
|
}
|
||||||
|
if (titre !== null) return { remplissage: titre.pourvu ? 'normal' : 'hachure', marques: ['cadenasOuvert'] };
|
||||||
|
return { remplissage: 'normal', marques: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* L'état des places d'un tour (contrat de données, EtatPlaces) : la source
|
||||||
|
* — la proposition demandée, le retenu, ou les seules réservations —, ce
|
||||||
|
* qu'un geste de personne y modifie, chaque table de la charge dans son
|
||||||
|
* ordre avec ses chaises, et la réserve. Sièges attribués, la chaise s porte
|
||||||
|
* le rang s de la liste de la source ; sinon, et sous les seules
|
||||||
|
* réservations, repartirSieges répartit les occupants de la table, chacun à
|
||||||
|
* son siège réservé, une personne réservée sans siège qui pourvoit un titre
|
||||||
|
* au siège de ce titre ; quand la liste dépasse la capacité, les personnes
|
||||||
|
* réservées à la table passent avant les autres. Un exclu, une personne que la charge n'a plus,
|
||||||
|
* n'occupe rien et n'attend pas ; une personne présente qu'aucune chaise ne
|
||||||
|
* reçoit attend en réserve. Les conflits sont ceux de conflitsParTable au
|
||||||
|
* tour, null quand la source est en dérive ou que la configuration ne se
|
||||||
|
* normalise pas.
|
||||||
|
*
|
||||||
|
* Ne lève sur aucune charge que l'analyse admet, pour tout tour de 1 à R.
|
||||||
|
*
|
||||||
|
* @param {import('../stockage/types.js').Charge} charge
|
||||||
|
* @param {{tour: number, proposition?: number|null}} demande
|
||||||
|
* @returns {Object} EtatPlaces
|
||||||
|
* @throws {TypeError} un tour hors de 1 à R
|
||||||
|
*/
|
||||||
|
export function etatPlaces(charge, { tour, proposition = null }) {
|
||||||
|
if (!Number.isInteger(tour) || tour < 1 || tour > charge.evenement.tours) {
|
||||||
|
throw new TypeError(`etatPlaces : tour de 1 à ${charge.evenement.tours} attendu, reçu ${JSON.stringify(tour)}`);
|
||||||
|
}
|
||||||
|
const { source, placement, cible, refus } = sourceDuTour(charge, tour, proposition);
|
||||||
|
const siegesAttribues = placement === null ? charge.reglages.attribuerSieges : placement.siegesAttribues;
|
||||||
|
const ids = presents(charge);
|
||||||
|
const instance = instanceDe(charge);
|
||||||
|
const tableDe = planEvalue(charge, instance, placement);
|
||||||
|
const conflitsDe = new Map(
|
||||||
|
tableDe === null ? [] : conflitsParTable(instance, tableDe)[tour - 1].map(({ table, nombre, regles }) => [table, { nombre, regles }]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { titres } = charge;
|
||||||
|
const pourvoyeurs = pourvoyeursDesTitres(charge);
|
||||||
|
const ordreDesTitres = titres
|
||||||
|
.map((_, rang) => rang)
|
||||||
|
.sort((a, b) => titres[a].table - titres[b].table || titres[a].siege - titres[b].siege || comparerTextes(titres[a].libelle, titres[b].libelle));
|
||||||
|
const titreDePlace = new Map();
|
||||||
|
for (const rang of ordreDesTitres) {
|
||||||
|
const cle = `${titres[rang].table}:${titres[rang].siege}`;
|
||||||
|
if (!titreDePlace.has(cle)) titreDePlace.set(cle, rang);
|
||||||
|
}
|
||||||
|
const pourvuAuTour = (rang) => pourvoyeurs[rang].tours[tour - 1] !== null;
|
||||||
|
const couvrantes = reservationsActives(charge).filter((reservation) => couvre(reservation, tour));
|
||||||
|
|
||||||
|
const assis = new Set();
|
||||||
|
const tables = charge.tables.map((enregistrement) => {
|
||||||
|
const { id, numero, forme, position } = enregistrement;
|
||||||
|
const places = capacite(charge, enregistrement);
|
||||||
|
const { tenu, siegeDe, sansSiege } = placesTenues(
|
||||||
|
couvrantes.filter(({ table }) => table === id),
|
||||||
|
places,
|
||||||
|
);
|
||||||
|
const titresDeLaTable = ordreDesTitres.filter((rang) => titres[rang].table === id);
|
||||||
|
// Une personne réservée sans siège qui pourvoit un titre s'assied au
|
||||||
|
// siège de ce titre, quand nul ne le tient.
|
||||||
|
const pris = new Set(siegeDe.values());
|
||||||
|
const siegesVoulus = new Map(siegeDe);
|
||||||
|
for (const rang of titresDeLaTable) {
|
||||||
|
const pourvoyeur = pourvoyeurs[rang].tours[tour - 1];
|
||||||
|
const { siege } = titres[rang];
|
||||||
|
if (pourvoyeur === null || !sansSiege.has(pourvoyeur.participant) || siegesVoulus.has(pourvoyeur.participant)) continue;
|
||||||
|
if (pris.has(siege) || siege > places) continue;
|
||||||
|
siegesVoulus.set(pourvoyeur.participant, siege);
|
||||||
|
pris.add(siege);
|
||||||
|
}
|
||||||
|
|
||||||
|
let occupants;
|
||||||
|
if (placement === null) {
|
||||||
|
const reserves = [...siegeDe.keys(), ...sansSiege.keys()].filter((personne) => !assis.has(personne));
|
||||||
|
occupants = repartirSieges(reserves, siegesVoulus, places);
|
||||||
|
} else {
|
||||||
|
const rang = placement.tables.indexOf(id);
|
||||||
|
const liste = rang < 0 ? [] : placement.placement[tour - 1].sieges[rang];
|
||||||
|
const lisible = (personne) => personne !== null && ids.has(personne) && !assis.has(personne);
|
||||||
|
if (placement.siegesAttribues) {
|
||||||
|
occupants = Array.from({ length: places }, (_, i) => (i < liste.length && lisible(liste[i]) ? liste[i] : null));
|
||||||
|
} else {
|
||||||
|
// Les personnes que réserve cette table s'assoient avant les autres :
|
||||||
|
// une capacité réduite, jamais sous ses réservations (§ 6.2), n'en
|
||||||
|
// verse aucune en réserve.
|
||||||
|
const reservees = new Set([...siegeDe.keys(), ...sansSiege.keys()]);
|
||||||
|
occupants = repartirSieges(liste.filter(lisible), siegesVoulus, places, reservees);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const personne of occupants) if (personne !== null) assis.add(personne);
|
||||||
|
|
||||||
|
const chaises = occupants.map((occupant, rang) => {
|
||||||
|
const siege = rang + 1;
|
||||||
|
const tenue = tenu.get(siege) ?? (occupant !== null && sansSiege.has(occupant) ? sansSiege.get(occupant) : null);
|
||||||
|
const reservation = tenue === null ? null : { participant: tenue.participant, portee: tenue.portee, tour: tenue.tour };
|
||||||
|
const rangTitre = titreDePlace.get(`${id}:${siege}`);
|
||||||
|
const titre = rangTitre === undefined ? null : { libelle: titres[rangTitre].libelle, pourvu: pourvuAuTour(rangTitre) };
|
||||||
|
return { siege, occupant, reservation, titre, ...aspect(reservation, titre) };
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
numero,
|
||||||
|
forme,
|
||||||
|
position: { x: position.x, y: position.y },
|
||||||
|
capacite: places,
|
||||||
|
surchargee: enregistrement.sieges !== null,
|
||||||
|
gelee: instance !== null && instance.ancresParTable[instance.indexTableDe.get(id)] === places,
|
||||||
|
titres: { titres: titresDeLaTable.length, pourvus: titresDeLaTable.filter(pourvuAuTour).length },
|
||||||
|
conflits: tableDe === null ? null : conflitsDe.get(id),
|
||||||
|
chaises,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
tour,
|
||||||
|
source,
|
||||||
|
proposition: source === 'proposition' ? placement.id : null,
|
||||||
|
siegesAttribues,
|
||||||
|
tables,
|
||||||
|
reserve: [...ids].filter((personne) => !assis.has(personne)).sort(croissant),
|
||||||
|
evalue: tableDe !== null,
|
||||||
|
cible,
|
||||||
|
refus,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Le libellé proposé à la désignation de participant (§ 4, § 2.1 étape 6) :
|
||||||
|
* son titre pressenti ; à défaut, le libellé le plus fréquent des titres de
|
||||||
|
* la charge, le plus petit par unités UTF-16 à égalité ; à défaut, le
|
||||||
|
* français de la clé places.titreParDefaut. Il devient une donnée du
|
||||||
|
* fichier : il se lit par libelle, jamais par la traduction de l'écran.
|
||||||
|
*
|
||||||
|
* @param {import('../stockage/types.js').Charge} charge
|
||||||
|
* @param {number} participant identifiant
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function libellePropose(charge, participant) {
|
||||||
|
const personne = charge.participants.find(({ id }) => id === participant);
|
||||||
|
if (personne !== undefined && personne.titrePressenti !== null) return personne.titrePressenti;
|
||||||
|
const comptes = new Map();
|
||||||
|
for (const { libelle: texte } of charge.titres) comptes.set(texte, (comptes.get(texte) ?? 0) + 1);
|
||||||
|
let meilleur = null;
|
||||||
|
for (const [texte, nombre] of comptes) {
|
||||||
|
const ecart = meilleur === null ? 1 : nombre - comptes.get(meilleur);
|
||||||
|
if (ecart > 0 || (ecart === 0 && comparerTextes(texte, meilleur) < 0)) meilleur = texte;
|
||||||
|
}
|
||||||
|
return meilleur ?? libelle('places.titreParDefaut');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Le nom de chaque statut du moteur.
|
||||||
|
const NOMS_DES_STATUTS = new Map([
|
||||||
|
[STATUT.ANCRE, 'ancre'],
|
||||||
|
[STATUT.PARTIELLEMENT_FIXE, 'partiel'],
|
||||||
|
[STATUT.MOBILE, 'mobile'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Le statut calculé de chaque participant non exclu (§ 4.2), lu dans
|
||||||
|
* l'instance du moteur : ancré, partiellement fixé ou mobile ; un exclu n'a
|
||||||
|
* pas d'entrée. Une configuration qui ne se normalise pas rend son code dans
|
||||||
|
* erreur, et aucun statut, au lieu de lever.
|
||||||
|
*
|
||||||
|
* @param {import('../stockage/types.js').Charge} charge
|
||||||
|
* @returns {{parId: Map<number, 'ancre'|'partiel'|'mobile'>, erreur: string|null}}
|
||||||
|
*/
|
||||||
|
export function statuts(charge) {
|
||||||
|
let instance;
|
||||||
|
try {
|
||||||
|
instance = normaliser(configurationDepuisCharge(charge));
|
||||||
|
} catch (erreur) {
|
||||||
|
if (erreur instanceof ErreurConfiguration) return { parId: new Map(), erreur: erreur.code };
|
||||||
|
throw erreur;
|
||||||
|
}
|
||||||
|
const parId = new Map(instance.ids.map((id, p) => [id, NOMS_DES_STATUTS.get(instance.statut[p])]));
|
||||||
|
return { parId, erreur: null };
|
||||||
|
}
|
||||||
522
src/application/places.test.js
Normal file
522
src/application/places.test.js
Normal file
|
|
@ -0,0 +1,522 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Épreuves de l'état des places d'un tour (§ 5.8, § 7.5, § 7.6, § 8.9, § 9),
|
||||||
|
// de la répartition par siège, du libellé proposé à la désignation et des
|
||||||
|
// statuts calculés (§ 4.2). Les noms d'épreuve sont inventés ; la petite
|
||||||
|
// démonstration livrée se lit telle quelle, et la version vient du module
|
||||||
|
// engendré.
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { describe, test } from '../../test/lanceur.js';
|
||||||
|
import { STATUT, indexerPlan, normaliser } from '../moteur/configuration.js';
|
||||||
|
import { conflitsParTable } from '../moteur/conflits.js';
|
||||||
|
import { analyser, configurationDepuisCharge, creerCharge } from '../stockage/document.js';
|
||||||
|
import { planDepuisFichier } from '../stockage/placements.js';
|
||||||
|
import { VERSION } from '../version.genere.js';
|
||||||
|
import { COMMANDES } from './commandes.js';
|
||||||
|
import { titresNonPourvus } from './listes_travail.js';
|
||||||
|
import { etatPlaces, libellePropose, repartirSieges, statuts } from './places.js';
|
||||||
|
|
||||||
|
const executer = (nom, charge, args) => COMMANDES.get(nom)(charge, args);
|
||||||
|
|
||||||
|
const personne = (id, nom, prenom = null, autres = {}) => ({
|
||||||
|
id,
|
||||||
|
nom,
|
||||||
|
prenom,
|
||||||
|
appartenance: null,
|
||||||
|
courriel: null,
|
||||||
|
titrePressenti: null,
|
||||||
|
notes: null,
|
||||||
|
exclu: false,
|
||||||
|
...autres,
|
||||||
|
});
|
||||||
|
|
||||||
|
const table = (id, sieges = null, numero = id) => ({ id, numero, sieges, forme: 'ronde', position: { x: 300 * id, y: 0 } });
|
||||||
|
|
||||||
|
const resa = (participant, idTable, siege, tour = null) => ({
|
||||||
|
participant,
|
||||||
|
table: idTable,
|
||||||
|
siege,
|
||||||
|
portee: tour === null ? 'tous' : 'tour',
|
||||||
|
tour,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Un plan de fichier de ces tables et capacités, dont chaque tour est donné
|
||||||
|
// par ses listes ; la réserve de chaque tour porte les déclarés qu'aucune
|
||||||
|
// liste n'assied.
|
||||||
|
function planFichier({ tables, capacites, participants, tours, siegesAttribues = false }) {
|
||||||
|
return {
|
||||||
|
siegesAttribues,
|
||||||
|
tables,
|
||||||
|
capacites,
|
||||||
|
tours: tours.length,
|
||||||
|
participants,
|
||||||
|
placement: tours.map((listes) => {
|
||||||
|
const assis = new Set(listes.flat());
|
||||||
|
return { sieges: listes, reserve: participants.filter((id) => !assis.has(id)) };
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const propositionDe = (id, plan) => ({
|
||||||
|
id,
|
||||||
|
graine: 48271 + id,
|
||||||
|
arret: 200000,
|
||||||
|
historique: 1000,
|
||||||
|
produitVersion: VERSION.affichee,
|
||||||
|
...plan,
|
||||||
|
});
|
||||||
|
|
||||||
|
const retenuDe = (proposition, plan) => ({ proposition, ...plan });
|
||||||
|
|
||||||
|
// --- La petite démonstration et un plan parfait ---------------------------
|
||||||
|
|
||||||
|
// La petite démonstration livrée : douze personnes, quatre tables de 3,
|
||||||
|
// quatre tours, aucune réservation.
|
||||||
|
const petite = () => analyser(readFileSync(new URL('../demo/livrees/petite.gtt.json', import.meta.url), 'utf8')).charge;
|
||||||
|
|
||||||
|
// Un plan qui sépare les trois appartenances, ne réunit jamais deux fois les
|
||||||
|
// mêmes voisins et n'assied personne deux fois à la même table.
|
||||||
|
const PARFAIT = [
|
||||||
|
[[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]],
|
||||||
|
[[5, 9, 10], [1, 7, 11], [2, 6, 12], [3, 4, 8]],
|
||||||
|
[[6, 8, 11], [3, 9, 12], [1, 4, 10], [2, 5, 7]],
|
||||||
|
[[4, 7, 12], [2, 8, 10], [3, 5, 11], [1, 6, 9]],
|
||||||
|
];
|
||||||
|
const DOUZE = Array.from({ length: 12 }, (_, rang) => rang + 1);
|
||||||
|
const planParfait = (siegesAttribues = false) =>
|
||||||
|
planFichier({ tables: [1, 2, 3, 4], capacites: [3, 3, 3, 3], participants: DOUZE, tours: PARFAIT, siegesAttribues });
|
||||||
|
|
||||||
|
// La petite démonstration, son plan parfait proposé sous l'identifiant 1, et
|
||||||
|
// retenu quand retenu est vrai.
|
||||||
|
function petiteRetenue({ retenu = true } = {}) {
|
||||||
|
const charge = petite();
|
||||||
|
charge.propositions = [propositionDe(1, planParfait())];
|
||||||
|
charge.prochainsIds.proposition = 2;
|
||||||
|
charge.retenu = retenu ? retenuDe(1, planParfait()) : null;
|
||||||
|
charge.evenement.etat = retenu ? 'retenu' : 'propose';
|
||||||
|
return charge;
|
||||||
|
}
|
||||||
|
|
||||||
|
const occupants = (etat, rang) => etat.tables[rang].chaises.map(({ occupant }) => occupant);
|
||||||
|
|
||||||
|
// Les conflits que le moteur lit sur ce placement, tour par tour.
|
||||||
|
function conflitsDuMoteur(charge, placement) {
|
||||||
|
const instance = normaliser(configurationDepuisCharge(charge));
|
||||||
|
return conflitsParTable(instance, indexerPlan(instance, planDepuisFichier(placement)));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('repartirSieges : la seule répartition par siège', () => {
|
||||||
|
test('la personne réservée au siège s prend le rang s, les autres les rangs libres par identifiant croissant', () => {
|
||||||
|
assert.deepEqual(repartirSieges([4, 9, 2], new Map([[9, 1]]), 4), [9, 2, 4, null]);
|
||||||
|
assert.deepEqual(repartirSieges([], new Map(), 3), [null, null, null]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deux au même siège : le plus petit identifiant ; un siège au-delà des places ne compte pas ; trop de monde : les plus grands restent dehors', () => {
|
||||||
|
assert.deepEqual(repartirSieges([7, 3, 5], new Map([[7, 2], [3, 2]]), 3), [5, 3, 7]);
|
||||||
|
assert.deepEqual(repartirSieges([1, 2], new Map([[2, 9]]), 3), [1, 2, null]);
|
||||||
|
assert.deepEqual(repartirSieges([6, 1, 4, 3], new Map([[6, 3]]), 3), [1, 3, 6]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('les prioritaires prennent les rangs libres avant les autres, puis tous se rangent par identifiant croissant', () => {
|
||||||
|
assert.deepEqual(repartirSieges([1, 2, 3, 4, 5, 6], new Map(), 4, new Set([6])), [1, 2, 3, 6]);
|
||||||
|
assert.deepEqual(repartirSieges([5, 2, 8, 1], new Map([[8, 1]]), 3, new Set([5])), [8, 1, 5]);
|
||||||
|
assert.deepEqual(repartirSieges([9, 4, 7], new Map(), 2, new Set([9, 7])), [7, 9]);
|
||||||
|
assert.deepEqual(repartirSieges([3, 1], new Map(), 4, new Set([3])), [1, 3, null, null]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('etatPlaces : la source et la cible (§ 5.8, § 8.9)', () => {
|
||||||
|
test('plan parfait en retenu : occupants 1, 2, 3 aux sièges 1 à 3 de la table 1 ; cible retenu ; réserve vide ; zéro conflit', () => {
|
||||||
|
const charge = petiteRetenue();
|
||||||
|
const etat = etatPlaces(charge, { tour: 1 });
|
||||||
|
assert.equal(etat.tour, 1);
|
||||||
|
assert.equal(etat.source, 'retenu');
|
||||||
|
assert.equal(etat.proposition, null);
|
||||||
|
assert.equal(etat.siegesAttribues, false);
|
||||||
|
assert.equal(etat.cible, 'retenu');
|
||||||
|
assert.equal(etat.refus, null);
|
||||||
|
assert.equal(etat.evalue, true);
|
||||||
|
assert.deepEqual(etat.reserve, []);
|
||||||
|
assert.equal(etat.tables.length, 4);
|
||||||
|
assert.deepEqual(
|
||||||
|
etat.tables[0].chaises.map(({ siege, occupant }) => [siege, occupant]),
|
||||||
|
[[1, 1], [2, 2], [3, 3]],
|
||||||
|
);
|
||||||
|
for (const { conflits } of etat.tables) assert.equal(conflits.nombre, 0);
|
||||||
|
assert.deepEqual(
|
||||||
|
etat.tables.map(({ id, numero, capacite, surchargee, gelee }) => [id, numero, capacite, surchargee, gelee]),
|
||||||
|
[[1, 1, 3, false, false], [2, 2, 3, false, false], [3, 3, 3, false, false], [4, 4, 3, false, false]],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sans retenu ni proposition : les réservations seules ; la réserve porte les douze, moins une réservée à ce tour', () => {
|
||||||
|
const charge = petite();
|
||||||
|
const etat = etatPlaces(charge, { tour: 1 });
|
||||||
|
assert.equal(etat.source, 'reservations');
|
||||||
|
assert.equal(etat.cible, 'reservations');
|
||||||
|
assert.equal(etat.siegesAttribues, false);
|
||||||
|
assert.deepEqual(etat.reserve, DOUZE);
|
||||||
|
charge.reservations = [resa(3, 2, null, 1)];
|
||||||
|
const tour1 = etatPlaces(charge, { tour: 1 });
|
||||||
|
assert.deepEqual(tour1.reserve, DOUZE.filter((id) => id !== 3));
|
||||||
|
assert.deepEqual(occupants(tour1, 1), [3, null, null]);
|
||||||
|
assert.deepEqual(tour1.tables[1].chaises[0].reservation, { participant: 3, portee: 'tour', tour: 1 });
|
||||||
|
assert.deepEqual(etatPlaces(charge, { tour: 2 }).reserve, DOUZE);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('une proposition demandée se montre, avec ou sans retenu ; après la retenue, en vue seule', () => {
|
||||||
|
const avant = petiteRetenue({ retenu: false });
|
||||||
|
const montree = etatPlaces(avant, { tour: 2, proposition: 1 });
|
||||||
|
assert.equal(montree.source, 'proposition');
|
||||||
|
assert.equal(montree.proposition, 1);
|
||||||
|
assert.equal(montree.cible, 'reservations');
|
||||||
|
assert.equal(montree.refus, null);
|
||||||
|
assert.deepEqual(occupants(montree, 0), [5, 9, 10]);
|
||||||
|
assert.equal(etatPlaces(avant, { tour: 2 }).source, 'reservations');
|
||||||
|
|
||||||
|
const apres = etatPlaces(petiteRetenue(), { tour: 2, proposition: 1 });
|
||||||
|
assert.equal(apres.source, 'proposition');
|
||||||
|
assert.equal(apres.cible, null);
|
||||||
|
assert.deepEqual(apres.refus, {
|
||||||
|
code: 'PROPOSITION_MONTREE',
|
||||||
|
details: { proposition: 1, remede: { geste: 'afficherTour', tour: 2, proposition: null } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('les conflits de chaque table sont ceux de conflitsParTable sur le même plan, à chaque tour', () => {
|
||||||
|
const charge = petiteRetenue();
|
||||||
|
// Un plan imparfait : la table 1 réunit deux fois les mêmes voisins.
|
||||||
|
const tours = PARFAIT.map((listes) => listes.map((liste) => [...liste]));
|
||||||
|
tours[1] = [[1, 2, 10], [5, 7, 11], [6, 9, 12], [3, 4, 8]];
|
||||||
|
charge.retenu = retenuDe(1, planFichier({ tables: [1, 2, 3, 4], capacites: [3, 3, 3, 3], participants: DOUZE, tours }));
|
||||||
|
const attendus = conflitsDuMoteur(charge, charge.retenu);
|
||||||
|
let compte = 0;
|
||||||
|
for (let tour = 1; tour <= 4; tour += 1) {
|
||||||
|
const etat = etatPlaces(charge, { tour });
|
||||||
|
etat.tables.forEach(({ id, conflits }, rang) => {
|
||||||
|
assert.deepEqual(conflits, { nombre: attendus[tour - 1][rang].nombre, regles: attendus[tour - 1][rang].regles }, `tour ${tour}, table ${id}`);
|
||||||
|
compte += conflits.nombre;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
assert.ok(compte > 0, 'le plan imparfait porte des conflits');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sans source, les conflits sont ceux des seules réservations', () => {
|
||||||
|
const charge = petite();
|
||||||
|
// 1 et 5 sont de la même appartenance : réservés à la même table.
|
||||||
|
charge.reservations = [resa(1, 1, null), resa(5, 1, null, 2)];
|
||||||
|
const etat = etatPlaces(charge, { tour: 2 });
|
||||||
|
assert.equal(etat.evalue, true);
|
||||||
|
const instance = normaliser(configurationDepuisCharge(charge));
|
||||||
|
assert.deepEqual(etat.tables[0].conflits, (({ nombre, regles }) => ({ nombre, regles }))(conflitsParTable(instance, instance.fixe)[1][0]));
|
||||||
|
assert.ok(etat.tables[0].conflits.nombre > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('une source en dérive : evalue faux, conflits null ; un exclu assis ne paraît ni sur sa chaise ni en réserve', () => {
|
||||||
|
const charge = petiteRetenue();
|
||||||
|
charge.participants[4].exclu = true;
|
||||||
|
for (let tour = 1; tour <= 4; tour += 1) {
|
||||||
|
const etat = etatPlaces(charge, { tour });
|
||||||
|
assert.equal(etat.evalue, false);
|
||||||
|
assert.equal(etat.tables.length, 4);
|
||||||
|
for (const { conflits } of etat.tables) assert.equal(conflits, null);
|
||||||
|
assert.ok(!etat.reserve.includes(5));
|
||||||
|
assert.ok(etat.tables.every(({ chaises }) => chaises.every(({ occupant }) => occupant !== 5)));
|
||||||
|
}
|
||||||
|
assert.deepEqual(occupants(etatPlaces(charge, { tour: 1 }), 1), [4, 6, null]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sièges non attribués : une personne réservée à un siège s’y affiche, sous le retenu comme sous une proposition ; les autres prennent les chaises libres par identifiant croissant', () => {
|
||||||
|
for (const [retenu, proposition, source] of [
|
||||||
|
[true, null, 'retenu'],
|
||||||
|
[false, 1, 'proposition'],
|
||||||
|
]) {
|
||||||
|
const charge = petiteRetenue({ retenu });
|
||||||
|
// 2, assis à la table 1 au tour 1, y est réservé « tous » au siège 3.
|
||||||
|
charge.reservations = [resa(2, 1, 3)];
|
||||||
|
const etat = etatPlaces(charge, { tour: 1, proposition });
|
||||||
|
assert.equal(etat.source, source);
|
||||||
|
assert.equal(etat.siegesAttribues, false);
|
||||||
|
assert.deepEqual(occupants(etat, 0), [1, 3, 2]);
|
||||||
|
const chaise = etat.tables[0].chaises[2];
|
||||||
|
assert.equal(chaise.remplissage, 'plein');
|
||||||
|
assert.deepEqual(chaise.marques, ['cadenasFerme', 'anneau']);
|
||||||
|
assert.equal(etat.tables[0].chaises[0].remplissage, 'normal');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sièges attribués : l'ordre d'une liste du retenu fait l'ordre des chaises, chaises vides comprises", () => {
|
||||||
|
const charge = petiteRetenue();
|
||||||
|
const tours = PARFAIT.map((listes) => listes.map((liste) => [...liste]));
|
||||||
|
tours[0][0] = [3, 1, 2];
|
||||||
|
tours[0][1] = [null, 6, 4];
|
||||||
|
tours[0][2] = [9, null, 5];
|
||||||
|
tours[0][3] = [7, 8];
|
||||||
|
const plan = planFichier({ tables: [1, 2, 3, 4], capacites: [3, 3, 3, 3], participants: DOUZE, tours, siegesAttribues: true });
|
||||||
|
plan.placement[0].reserve = [10, 11, 12];
|
||||||
|
charge.retenu = retenuDe(1, plan);
|
||||||
|
const etat = etatPlaces(charge, { tour: 1 });
|
||||||
|
assert.equal(etat.siegesAttribues, true);
|
||||||
|
assert.deepEqual(occupants(etat, 0), [3, 1, 2]);
|
||||||
|
assert.deepEqual(occupants(etat, 1), [null, 6, 4]);
|
||||||
|
assert.deepEqual(occupants(etat, 2), [9, null, 5]);
|
||||||
|
assert.deepEqual(occupants(etat, 3), [7, 8, null]);
|
||||||
|
assert.deepEqual(etat.reserve, [10, 11, 12]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('etatPlaces : les sources qui ne décrivent plus la configuration (§ 8.9, § 9)', () => {
|
||||||
|
test('un retenu de trois tours sous R = 4 : au tour 4, source et cible réservations', () => {
|
||||||
|
const charge = petiteRetenue();
|
||||||
|
charge.retenu = retenuDe(1, planFichier({ tables: [1, 2, 3, 4], capacites: [3, 3, 3, 3], participants: DOUZE, tours: PARFAIT.slice(0, 3) }));
|
||||||
|
const etat = etatPlaces(charge, { tour: 4 });
|
||||||
|
assert.equal(etat.source, 'reservations');
|
||||||
|
assert.equal(etat.cible, 'reservations');
|
||||||
|
assert.equal(etatPlaces(charge, { tour: 3 }).source, 'retenu');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un retenu qui déclare une table supprimée : ses occupants en réserve, evalue faux', () => {
|
||||||
|
const charge = petiteRetenue();
|
||||||
|
charge.tables = charge.tables.filter(({ id }) => id !== 4);
|
||||||
|
const etat = etatPlaces(charge, { tour: 1 });
|
||||||
|
assert.equal(etat.source, 'retenu');
|
||||||
|
assert.equal(etat.evalue, false);
|
||||||
|
assert.equal(etat.tables.length, 3);
|
||||||
|
assert.deepEqual(etat.reserve, [10, 11, 12]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('une proposition qui assied huit personnes à une table ramenée à 6 : deux dans la réserve', () => {
|
||||||
|
const charge = creerCharge({ id: 'evt-huit', nom: 'Soirée des Merles', siegesParDefaut: 8, tours: 1 });
|
||||||
|
charge.participants = Array.from({ length: 10 }, (_, rang) => personne(rang + 1, `Merle ${rang + 1}`));
|
||||||
|
charge.tables = [table(1, 6), table(2)];
|
||||||
|
charge.prochainsIds = { participant: 11, table: 3, proposition: 2 };
|
||||||
|
charge.propositions = [
|
||||||
|
propositionDe(1, planFichier({ tables: [1, 2], capacites: [8, 8], participants: charge.participants.map(({ id }) => id), tours: [[[1, 2, 3, 4, 5, 6, 7, 8], [9, 10]]] })),
|
||||||
|
];
|
||||||
|
const etat = etatPlaces(charge, { tour: 1, proposition: 1 });
|
||||||
|
assert.equal(etat.source, 'proposition');
|
||||||
|
assert.deepEqual(occupants(etat, 0), [1, 2, 3, 4, 5, 6]);
|
||||||
|
assert.deepEqual(etat.reserve, [7, 8]);
|
||||||
|
assert.equal(etat.evalue, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sans attribution, une table ramenée à 6 qui en porte huit : la personne réservée à cette table s’assied avant les autres', () => {
|
||||||
|
const charge = creerCharge({ id: 'evt-huit', nom: 'Soirée des Merles', siegesParDefaut: 8, tours: 1 });
|
||||||
|
charge.participants = Array.from({ length: 10 }, (_, rang) => personne(rang + 1, `Merle ${rang + 1}`));
|
||||||
|
charge.tables = [table(1, 6), table(2)];
|
||||||
|
charge.reservations = [resa(8, 1, null)];
|
||||||
|
charge.prochainsIds = { participant: 11, table: 3, proposition: 2 };
|
||||||
|
const plan = planFichier({ tables: [1, 2], capacites: [8, 8], participants: charge.participants.map(({ id }) => id), tours: [[[1, 2, 3, 4, 5, 6, 7, 8], [9, 10]]] });
|
||||||
|
charge.propositions = [propositionDe(1, plan)];
|
||||||
|
for (const etat of [etatPlaces(charge, { tour: 1, proposition: 1 }), etatPlaces({ ...charge, retenu: retenuDe(1, plan), evenement: { ...charge.evenement, etat: 'retenu' } }, { tour: 1 })]) {
|
||||||
|
assert.deepEqual(occupants(etat, 0), [1, 2, 3, 4, 5, 8]);
|
||||||
|
assert.deepEqual(etat.reserve, [6, 7]);
|
||||||
|
assert.equal(etat.tables[0].chaises[5].remplissage, 'plein');
|
||||||
|
assert.deepEqual(etat.tables[0].chaises[5].marques, ['cadenasFerme', 'anneau']);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un retenu fautif : source réservations, cible null, refus RETENU_FAUTIF, et etatPlaces ne lève à aucun tour', () => {
|
||||||
|
const charge = petiteRetenue();
|
||||||
|
charge.retenu.placement[0].sieges[0] = [1, 2, 3, 4];
|
||||||
|
for (let tour = 1; tour <= 4; tour += 1) {
|
||||||
|
const etat = etatPlaces(charge, { tour });
|
||||||
|
assert.equal(etat.source, 'reservations');
|
||||||
|
assert.equal(etat.cible, null);
|
||||||
|
assert.deepEqual(etat.refus, { code: 'RETENU_FAUTIF', details: { remede: { geste: 'ouvrirSecours', source: 'precedent' } } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('une proposition demandée qui n’existe pas ou se contredit ne se montre pas', () => {
|
||||||
|
const charge = petiteRetenue({ retenu: false });
|
||||||
|
assert.equal(etatPlaces(charge, { tour: 1, proposition: 9 }).source, 'reservations');
|
||||||
|
charge.propositions[0].placement[0].reserve = [1];
|
||||||
|
assert.equal(etatPlaces(charge, { tour: 1, proposition: 1 }).source, 'reservations');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('etatPlaces : titres, réservations et marques (§ 4.1, § 4.4, § 7.6)', () => {
|
||||||
|
// Cinq tables de 4, mode table ; Grisaille (4) et les autres sans réservation.
|
||||||
|
function chargeTitres({ attribuerSieges = false } = {}) {
|
||||||
|
const charge = creerCharge({ id: 'evt-titres', nom: 'Soirée des Lucioles', siegesParDefaut: 4, tours: 2 });
|
||||||
|
charge.reglages.attribuerSieges = attribuerSieges;
|
||||||
|
charge.participants = [
|
||||||
|
personne(1, 'Ombrelle', 'Iris'),
|
||||||
|
personne(2, 'Pervenche', 'Théo'),
|
||||||
|
personne(3, 'Lacasse', 'Ondine'),
|
||||||
|
personne(4, 'Grisaille'),
|
||||||
|
personne(5, 'Bruyère', 'Anouk'),
|
||||||
|
];
|
||||||
|
charge.tables = [1, 2, 3, 4, 5].map((id) => table(id));
|
||||||
|
charge.prochainsIds = { participant: 6, table: 6, proposition: 1 };
|
||||||
|
return charge;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('pourvu par une réservation seulement : le retenu qui assied Grisaille à la table 5 ne pourvoit pas son titre', () => {
|
||||||
|
const charge = chargeTitres();
|
||||||
|
charge.titres = [{ table: 5, siege: 1, libelle: 'animation' }];
|
||||||
|
const plan = planFichier({ tables: [1, 2, 3, 4, 5], capacites: [4, 4, 4, 4, 4], participants: [1, 2, 3, 4, 5], tours: [[[1], [2], [3], [5], [4]], [[2], [3], [5], [1], [4]]] });
|
||||||
|
charge.propositions = [propositionDe(1, plan)];
|
||||||
|
charge.retenu = retenuDe(1, plan);
|
||||||
|
charge.prochainsIds.proposition = 2;
|
||||||
|
const chaise = etatPlaces(charge, { tour: 1 }).tables[4].chaises[0];
|
||||||
|
assert.equal(chaise.occupant, 4);
|
||||||
|
assert.deepEqual(chaise.titre, { libelle: 'animation', pourvu: false });
|
||||||
|
assert.equal(chaise.reservation, null);
|
||||||
|
assert.equal(chaise.remplissage, 'hachure');
|
||||||
|
assert.deepEqual(chaise.marques, ['cadenasOuvert']);
|
||||||
|
assert.deepEqual(
|
||||||
|
titresNonPourvus(charge).map(({ table: t }) => t),
|
||||||
|
[5],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exclusion : Iris désignée puis exclue laisse ses réservations, son titre redevient non pourvu ; réintégrée, il l’est de nouveau', () => {
|
||||||
|
const designee = executer('designer', chargeTitres(), { participant: 1, table: 3, siege: null, libelle: 'animatrice' }).charge;
|
||||||
|
const exclue = executer('exclureParticipant', designee, { id: 1 }).charge;
|
||||||
|
assert.deepEqual(exclue.reservations, designee.reservations);
|
||||||
|
const sansIris = etatPlaces(exclue, { tour: 1 }).tables[2];
|
||||||
|
assert.deepEqual(sansIris.titres, { titres: 1, pourvus: 0 });
|
||||||
|
assert.deepEqual(
|
||||||
|
[sansIris.chaises[0].remplissage, sansIris.chaises[0].marques, sansIris.chaises[0].titre.pourvu],
|
||||||
|
['hachure', ['cadenasOuvert'], false],
|
||||||
|
);
|
||||||
|
const reintegree = executer('reintegrerParticipant', exclue, { id: 1 }).charge;
|
||||||
|
const avecIris = etatPlaces(reintegree, { tour: 1 }).tables[2];
|
||||||
|
assert.deepEqual(avecIris.titres, { titres: 1, pourvus: 1 });
|
||||||
|
assert.deepEqual(
|
||||||
|
[avecIris.chaises[0].occupant, avecIris.chaises[0].remplissage, avecIris.chaises[0].marques, avecIris.chaises[0].titre.pourvu],
|
||||||
|
[1, 'plein', ['cadenasFerme', 'anneau'], true],
|
||||||
|
);
|
||||||
|
assert.deepEqual(avecIris.chaises[0].reservation, { participant: 1, portee: 'tous', tour: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deux titres sur une place : la chaise montre le premier dans l’ordre canonique, la table en compte deux', () => {
|
||||||
|
const charge = chargeTitres();
|
||||||
|
charge.titres = [
|
||||||
|
{ table: 2, siege: 2, libelle: 'hôte' },
|
||||||
|
{ table: 2, siege: 2, libelle: 'animation' },
|
||||||
|
];
|
||||||
|
const deux = etatPlaces(charge, { tour: 1 }).tables[1];
|
||||||
|
assert.equal(deux.chaises[1].titre.libelle, 'animation');
|
||||||
|
assert.equal(deux.titres.titres, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deux réservations de personnes différentes au même siège et au même tour : la chaise porte la première, la seconde au premier siège libre', () => {
|
||||||
|
const charge = chargeTitres({ attribuerSieges: true });
|
||||||
|
charge.reservations = [resa(3, 1, 2, 1), resa(2, 1, 2)];
|
||||||
|
const premiere = etatPlaces(charge, { tour: 1 }).tables[0];
|
||||||
|
assert.deepEqual(
|
||||||
|
premiere.chaises.map(({ occupant }) => occupant),
|
||||||
|
[3, 2, null, null],
|
||||||
|
);
|
||||||
|
assert.deepEqual(premiere.chaises[1].reservation, { participant: 2, portee: 'tous', tour: null });
|
||||||
|
assert.deepEqual(premiere.chaises[0].reservation, { participant: 3, portee: 'tour', tour: 1 });
|
||||||
|
assert.deepEqual(premiere.chaises[0].marques, ['cadenasFerme']);
|
||||||
|
// Au tour 2, seule la réservation « tous » tient le siège 2.
|
||||||
|
assert.deepEqual(
|
||||||
|
etatPlaces(charge, { tour: 2 }).tables[0].chaises.map(({ occupant }) => occupant),
|
||||||
|
[null, 2, null, null],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mode table : la personne réservée sans siège qui pourvoit un titre s’affiche sur sa chaise', () => {
|
||||||
|
const charge = chargeTitres();
|
||||||
|
charge.titres = [{ table: 1, siege: 3, libelle: 'animation' }];
|
||||||
|
charge.reservations = [resa(2, 1, null)];
|
||||||
|
const premiere = etatPlaces(charge, { tour: 1 }).tables[0];
|
||||||
|
assert.deepEqual(
|
||||||
|
premiere.chaises.map(({ occupant }) => occupant),
|
||||||
|
[null, null, 2, null],
|
||||||
|
);
|
||||||
|
assert.deepEqual([premiere.chaises[2].remplissage, premiere.chaises[2].titre.pourvu], ['plein', true]);
|
||||||
|
assert.deepEqual(premiere.titres, { titres: 1, pourvus: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('une table gelée : k_t = c_t', () => {
|
||||||
|
const charge = chargeTitres();
|
||||||
|
charge.reservations = [1, 2, 3, 4].map((id) => resa(id, 2, null));
|
||||||
|
const etat = etatPlaces(charge, { tour: 1 });
|
||||||
|
assert.deepEqual(
|
||||||
|
etat.tables.map(({ gelee }) => gelee),
|
||||||
|
[false, true, false, false, false],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("le marqueur de chaise vide : la liste [12, null, 7] du retenu assied 12 au siège 1 et 7 au siège 3", () => {
|
||||||
|
const charge = petiteRetenue();
|
||||||
|
charge.tables = [table(1, 4), table(2, 4), table(3, 4), table(4, 4)];
|
||||||
|
const plan = planFichier({
|
||||||
|
tables: [1, 2, 3, 4],
|
||||||
|
capacites: [4, 4, 4, 4],
|
||||||
|
participants: DOUZE,
|
||||||
|
tours: [[[12, null, 7], [1, 2, 3], [4, 5, 6], [8, 9, 10, 11]], ...PARFAIT.slice(1)],
|
||||||
|
siegesAttribues: true,
|
||||||
|
});
|
||||||
|
charge.retenu = retenuDe(1, plan);
|
||||||
|
assert.deepEqual(occupants(etatPlaces(charge, { tour: 1 }), 0), [12, null, 7, null]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('libellePropose et statuts (§ 4, § 4.2)', () => {
|
||||||
|
test('le titre pressenti, puis le libellé le plus fréquent, le plus petit à égalité, puis « animateur », jamais marqué', () => {
|
||||||
|
const charge = creerCharge({ id: 'evt-libelles', nom: 'Soirée des Lucioles', siegesParDefaut: 4, tours: 2 });
|
||||||
|
charge.participants = [personne(1, 'Ombrelle', 'Iris', { titrePressenti: 'hôtesse' }), personne(2, 'Pervenche', 'Théo')];
|
||||||
|
charge.tables = [table(1), table(2)];
|
||||||
|
charge.prochainsIds = { participant: 3, table: 3, proposition: 1 };
|
||||||
|
assert.equal(libellePropose(charge, 2), 'animateur');
|
||||||
|
assert.equal(libellePropose(charge, 1), 'hôtesse');
|
||||||
|
charge.titres = [
|
||||||
|
{ table: 1, siege: 1, libelle: 'veilleur' },
|
||||||
|
{ table: 1, siege: 2, libelle: 'accueil' },
|
||||||
|
{ table: 2, siege: 1, libelle: 'veilleur' },
|
||||||
|
{ table: 2, siege: 2, libelle: 'accueil' },
|
||||||
|
{ table: 2, siege: 3, libelle: 'Zélie' },
|
||||||
|
];
|
||||||
|
assert.equal(libellePropose(charge, 2), 'accueil');
|
||||||
|
charge.titres.push({ table: 1, siege: 3, libelle: 'veilleur' });
|
||||||
|
assert.equal(libellePropose(charge, 2), 'veilleur');
|
||||||
|
assert.equal(libellePropose(charge, 1), 'hôtesse');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ancré, ancré, partiellement fixé, mobile ; un exclu sans entrée ; chacun égal au statut du moteur', () => {
|
||||||
|
const charge = creerCharge({ id: 'evt-statuts', nom: 'Soirée des Lucioles', siegesParDefaut: 4, tours: 3 });
|
||||||
|
charge.participants = [
|
||||||
|
personne(1, 'Ombrelle', 'Iris'),
|
||||||
|
personne(2, 'Pervenche', 'Théo'),
|
||||||
|
personne(3, 'Lacasse', 'Ondine'),
|
||||||
|
personne(4, 'Grisaille'),
|
||||||
|
personne(5, 'Bruyère', 'Anouk', { exclu: true }),
|
||||||
|
];
|
||||||
|
charge.tables = [table(1), table(2)];
|
||||||
|
charge.prochainsIds = { participant: 6, table: 3, proposition: 1 };
|
||||||
|
charge.reservations = [
|
||||||
|
resa(1, 1, null),
|
||||||
|
resa(2, 1, null, 1), resa(2, 1, null, 2), resa(2, 1, null, 3),
|
||||||
|
resa(3, 1, null, 1), resa(3, 2, null, 2),
|
||||||
|
resa(5, 2, null),
|
||||||
|
];
|
||||||
|
const { parId, erreur } = statuts(charge);
|
||||||
|
assert.equal(erreur, null);
|
||||||
|
assert.deepEqual([...parId.entries()], [[1, 'ancre'], [2, 'ancre'], [3, 'partiel'], [4, 'mobile']]);
|
||||||
|
const instance = normaliser(configurationDepuisCharge(charge));
|
||||||
|
const NOMS = new Map([[STATUT.ANCRE, 'ancre'], [STATUT.PARTIELLEMENT_FIXE, 'partiel'], [STATUT.MOBILE, 'mobile']]);
|
||||||
|
assert.ok(parId.size > 0);
|
||||||
|
for (const [id, statut] of parId) assert.equal(statut, NOMS.get(instance.statut[instance.indexDe.get(id)]));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('une configuration qui ne se normalise pas rend son code au lieu de lever', () => {
|
||||||
|
const charge = creerCharge({ id: 'evt-statuts', nom: 'Soirée des Lucioles', siegesParDefaut: 2, tours: 1 });
|
||||||
|
charge.participants = [personne(1, 'Ombrelle', 'Iris'), personne(2, 'Pervenche', 'Théo'), personne(3, 'Grisaille')];
|
||||||
|
charge.tables = [table(1)];
|
||||||
|
charge.prochainsIds = { participant: 4, table: 2, proposition: 1 };
|
||||||
|
charge.reservations = [1, 2, 3].map((id) => resa(id, 1, null));
|
||||||
|
const { parId, erreur } = statuts(charge);
|
||||||
|
assert.equal(erreur, 'SURRESERVATION');
|
||||||
|
assert.equal(parId.size, 0);
|
||||||
|
assert.doesNotThrow(() => etatPlaces(charge, { tour: 1 }));
|
||||||
|
assert.equal(etatPlaces(charge, { tour: 1 }).evalue, false);
|
||||||
|
});
|
||||||
|
});
|
||||||
258
src/application/pose.js
Normal file
258
src/application/pose.js
Normal file
|
|
@ -0,0 +1,258 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Le contrôle de pose (§ 5.9) : ce que le logiciel refuse au moment du geste,
|
||||||
|
// avant toute écriture, et le seul — les commandes l'appellent, aucun
|
||||||
|
// composant ne le refait (§ 13.2). Le moteur ignore sièges et titres et
|
||||||
|
// s'arrête au premier refus par une exception ; controlerPose rend tous les
|
||||||
|
// refus d'une pose, chacun avec le remède qui le lève, écrit en entier : une
|
||||||
|
// commande et ses arguments, exécutables tels quels, ou un geste.
|
||||||
|
//
|
||||||
|
// Un refus a la forme { code, details }, le remède dans details.remede.
|
||||||
|
// Plusieurs se rangent dans l'ordre SURRESERVATION, SIEGE_INEXISTANT,
|
||||||
|
// DEUX_PLACES_MEME_TOUR, PERSONNE_EXCLUE, PLACE_RESERVEE, RESERVEE_AILLEURS ;
|
||||||
|
// une commande lève le premier et porte les suivants dans details.autres
|
||||||
|
// (leverRefus). Une réservation suspendue — celle d'une personne exclue —
|
||||||
|
// ne compte nulle part : une pose peut prendre sa place (§ 4.4), et sa
|
||||||
|
// réintégration se contrôle comme une pose (controlerReintegration). Aucune
|
||||||
|
// fonction ne modifie ce qu'elle reçoit.
|
||||||
|
import { ordreDesReservations } from '../csv/import.js';
|
||||||
|
import { capacite } from '../stockage/document.js';
|
||||||
|
import { ErreurCommande } from './erreurs.js';
|
||||||
|
import { couvre, instanceDe, reservationsActives } from './places.js';
|
||||||
|
|
||||||
|
// Un refus : son code, ses détails, et le remède parmi eux.
|
||||||
|
const refusDe = (code, details, remede) => ({ code, details: { ...details, remede } });
|
||||||
|
|
||||||
|
// Le remède d'une capacité trop petite : la table portée à sieges places,
|
||||||
|
// par la saisie de son champ.
|
||||||
|
const changerPlaces = (table, sieges) => ({ commande: 'changerPlacesTable', arguments: { id: table, sieges, origine: 'champ' } });
|
||||||
|
|
||||||
|
// Ce qui désigne une réservation dans une commande : sa table, sa portée, son
|
||||||
|
// tour, null pour la portée « tous ».
|
||||||
|
const designation = ({ table, portee, tour }) => ({ table, portee, tour: portee === 'tous' ? null : tour });
|
||||||
|
|
||||||
|
// Vrai quand la réservation r est celle que désigne d, pour ce participant.
|
||||||
|
const designee = (r, participant, d) =>
|
||||||
|
r.participant === participant && r.table === d.table && r.portee === d.portee && r.tour === (d.portee === 'tous' ? null : d.tour);
|
||||||
|
|
||||||
|
// Les tours qu'une réservation couvre, croissants.
|
||||||
|
function toursCouverts(charge, { portee, tour }) {
|
||||||
|
if (portee === 'tous') return Array.from({ length: charge.evenement.tours }, (_, rang) => rang + 1);
|
||||||
|
return [tour];
|
||||||
|
}
|
||||||
|
|
||||||
|
// L'enregistrement de la table d'identifiant id ; une table inconnue est une
|
||||||
|
// faute de l'appelant, que l'interface n'offre jamais.
|
||||||
|
function tableDe(charge, id) {
|
||||||
|
const table = charge.tables.find((candidate) => candidate.id === id);
|
||||||
|
if (table === undefined) throw new TypeError(`table inconnue : ${JSON.stringify(id)}`);
|
||||||
|
return table;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Les personnes distinctes dont une réservation de la liste couvre le tour
|
||||||
|
// à cette table.
|
||||||
|
const reserveesAuTour = (actives, idTable, tour) =>
|
||||||
|
new Set(actives.filter((r) => r.table === idTable && couvre(r, tour)).map(({ participant }) => participant));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [r − 1] : les personnes distinctes non exclues dont une réservation couvre
|
||||||
|
* le tour r à cette table — le compte de SURRESERVATION du moteur, sans
|
||||||
|
* lever.
|
||||||
|
*
|
||||||
|
* @param {import('../stockage/types.js').Charge} charge
|
||||||
|
* @param {number} idTable
|
||||||
|
* @returns {number[]} R comptes
|
||||||
|
*/
|
||||||
|
export function reservationsParTour(charge, idTable) {
|
||||||
|
const actives = reservationsActives(charge);
|
||||||
|
return Array.from({ length: charge.evenement.tours }, (_, rang) => reserveesAuTour(actives, idTable, rang + 1).size);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contrôle d'une pose (§ 5.9), sans rien écrire ; pose : { participant,
|
||||||
|
* table, siege, portee, tour, depuis?, retenu? } — depuis : la réservation
|
||||||
|
* que la pose déplace, { table, portee, tour }, qui ne compte plus ;
|
||||||
|
* retenu : vrai pour un dépôt dans le placement retenu au tour donné, qui ne
|
||||||
|
* pose aucune réservation. Chaque remède s'écrit en entier. Rend tous les
|
||||||
|
* refus, dans l'ordre du contrat :
|
||||||
|
* - SURRESERVATION {table, tour, reservees, capacite} : le code et les
|
||||||
|
* détails de l'ErreurConfiguration du moteur, reservees compté après la
|
||||||
|
* pose, au premier tour couvert qui déborde ; remède la table portée à
|
||||||
|
* reservees places ;
|
||||||
|
* - SIEGE_INEXISTANT {table, siege, capacite} : sièges attribués seulement ;
|
||||||
|
* remède la table portée à siege places ;
|
||||||
|
* - DEUX_PLACES_MEME_TOUR {participant, tour, autre} : une autre réservation
|
||||||
|
* active de la personne couvre un tour de la pose, à une autre table, ou à
|
||||||
|
* un autre siège de la même ; remède le déplacement de cette réservation
|
||||||
|
* vers la place de la pose ;
|
||||||
|
* - PERSONNE_EXCLUE {participant} : remède la réintégration ;
|
||||||
|
* - PLACE_RESERVEE {table, siege, tour, titulaire, portee} : la réservation
|
||||||
|
* active d'une autre personne tient ce siège à un tour de la pose, dans les
|
||||||
|
* deux modes ; remède le choix d'une place pour la réservation de la
|
||||||
|
* titulaire ;
|
||||||
|
* - RESERVEE_AILLEURS {participant, tour, reservation} : dans le retenu, la
|
||||||
|
* personne est réservée à ce tour à une autre table — ou, sièges du retenu
|
||||||
|
* attribués, à un autre siège ; remède le déplacement de sa réservation
|
||||||
|
* vers le dépôt.
|
||||||
|
* Les trois premiers ne valent que pour une réservation. signaux porte
|
||||||
|
* TABLE_GELEE {table} quand, la pose faite, les ancrés de la table en
|
||||||
|
* occupent toutes les places (k_t = c_t) : accepté, mais rarement voulu.
|
||||||
|
*
|
||||||
|
* @param {import('../stockage/types.js').Charge} charge
|
||||||
|
* @param {Object} pose
|
||||||
|
* @returns {{refus: Array<{code: string, details: Object}>, signaux: Array<{code: 'TABLE_GELEE', table: number}>}}
|
||||||
|
*/
|
||||||
|
export function controlerPose(charge, pose) {
|
||||||
|
const { participant, table: idTable, siege = null, portee, tour = null, depuis = null, retenu = false } = pose;
|
||||||
|
const places = capacite(charge, tableDe(charge, idTable));
|
||||||
|
const exclu = charge.participants.some(({ id, exclu: estExclu }) => id === participant && estExclu);
|
||||||
|
const couverts = retenu ? [tour] : toursCouverts(charge, { portee, tour });
|
||||||
|
const actives = reservationsActives(charge).filter((r) => depuis === null || !designee(r, participant, depuis));
|
||||||
|
const premierCommun = (r) => couverts.find((t) => couvre(r, t));
|
||||||
|
const refus = [];
|
||||||
|
|
||||||
|
if (!retenu) {
|
||||||
|
for (const t of couverts) {
|
||||||
|
const reservees = reserveesAuTour(actives, idTable, t);
|
||||||
|
if (!exclu) reservees.add(participant);
|
||||||
|
if (reservees.size > places) {
|
||||||
|
const details = { table: idTable, tour: t, reservees: reservees.size, capacite: places };
|
||||||
|
refus.push(refusDe('SURRESERVATION', details, changerPlaces(idTable, reservees.size)));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (charge.reglages.attribuerSieges && siege !== null && siege > places) {
|
||||||
|
refus.push(refusDe('SIEGE_INEXISTANT', { table: idTable, siege, capacite: places }, changerPlaces(idTable, siege)));
|
||||||
|
}
|
||||||
|
const autre = actives.find(
|
||||||
|
(r) =>
|
||||||
|
r.participant === participant &&
|
||||||
|
premierCommun(r) !== undefined &&
|
||||||
|
(r.table !== idTable || (r.siege !== null && siege !== null && r.siege !== siege)),
|
||||||
|
);
|
||||||
|
if (autre !== undefined) {
|
||||||
|
const remede = {
|
||||||
|
commande: 'deplacerReservation',
|
||||||
|
arguments: { participant, de: designation(autre), vers: { table: idTable, siege } },
|
||||||
|
};
|
||||||
|
const details = {
|
||||||
|
participant,
|
||||||
|
tour: premierCommun(autre),
|
||||||
|
autre: { table: autre.table, siege: autre.siege, portee: autre.portee, tour: autre.tour },
|
||||||
|
};
|
||||||
|
refus.push(refusDe('DEUX_PLACES_MEME_TOUR', details, remede));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exclu) {
|
||||||
|
refus.push(refusDe('PERSONNE_EXCLUE', { participant }, { commande: 'reintegrerParticipant', arguments: { id: participant } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (siege !== null) {
|
||||||
|
const titulaire = actives.find(
|
||||||
|
(r) => r.participant !== participant && r.table === idTable && r.siege === siege && premierCommun(r) !== undefined,
|
||||||
|
);
|
||||||
|
if (titulaire !== undefined) refus.push(placeReservee(titulaire, premierCommun(titulaire)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (retenu && !exclu) {
|
||||||
|
const attribues = charge.retenu?.siegesAttribues === true;
|
||||||
|
const propre = actives.find(
|
||||||
|
(r) =>
|
||||||
|
r.participant === participant &&
|
||||||
|
couvre(r, tour) &&
|
||||||
|
(r.table !== idTable || (attribues && r.siege !== null && siege !== null && r.siege !== siege)),
|
||||||
|
);
|
||||||
|
if (propre !== undefined) {
|
||||||
|
const remede = {
|
||||||
|
commande: 'deplacerReservation',
|
||||||
|
arguments: { participant, de: designation(propre), vers: { table: idTable, siege } },
|
||||||
|
};
|
||||||
|
const reservation = { table: propre.table, siege: propre.siege, portee: propre.portee, tour: propre.tour };
|
||||||
|
refus.push(refusDe('RESERVEE_AILLEURS', { participant, tour, reservation }, remede));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { refus, signaux: retenu ? [] : signauxDe(charge, pose, places) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// PLACE_RESERVEE de la réservation r d'une titulaire, au tour donné : le
|
||||||
|
// remède fait choisir une place sur le plan, l'écrit dans
|
||||||
|
// puis.arguments.vers, puis déplace la réservation de la titulaire.
|
||||||
|
function placeReservee(r, tour) {
|
||||||
|
const remede = {
|
||||||
|
geste: 'choisirPlace',
|
||||||
|
puis: { commande: 'deplacerReservation', arguments: { participant: r.participant, de: designation(r) } },
|
||||||
|
};
|
||||||
|
return refusDe('PLACE_RESERVEE', { table: r.table, siege: r.siege, tour, titulaire: r.participant, portee: r.portee }, remede);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TABLE_GELEE quand la charge où la pose serait faite se normalise et que
|
||||||
|
// les ancrés de la table en occupent toutes les places.
|
||||||
|
function signauxDe(charge, { participant, table: idTable, siege = null, portee, tour = null, depuis = null }, places) {
|
||||||
|
const gardees = charge.reservations.filter((r) => depuis === null || !designee(r, participant, depuis));
|
||||||
|
const posee = { participant, table: idTable, siege, portee, tour: portee === 'tous' ? null : tour };
|
||||||
|
const instance = instanceDe({ ...charge, reservations: [...gardees, posee] });
|
||||||
|
if (instance === null || instance.ancresParTable[instance.indexTableDe.get(idTable)] !== places) return [];
|
||||||
|
return [{ code: 'TABLE_GELEE', table: idTable }];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Les refus de la réintégration de id (§ 4.4, § 5.9) : ses réservations
|
||||||
|
* suspendues, posées contre les actives. SURRESERVATION quand une table ne
|
||||||
|
* peut plus les recevoir, au premier tour qui déborde, une fois par table,
|
||||||
|
* dans l'ordre de ses réservations ; puis PLACE_RESERVEE, une par
|
||||||
|
* réservation à un siège qu'une autre personne a pris depuis à un tour
|
||||||
|
* qu'elle couvre. [] quand rien ne s'y oppose, et pour une personne qui
|
||||||
|
* n'est pas exclue.
|
||||||
|
*
|
||||||
|
* @param {import('../stockage/types.js').Charge} charge
|
||||||
|
* @param {number} id
|
||||||
|
* @returns {Array<{code: string, details: Object}>}
|
||||||
|
*/
|
||||||
|
export function controlerReintegration(charge, id) {
|
||||||
|
if (!charge.participants.some((personne) => personne.id === id && personne.exclu)) return [];
|
||||||
|
const apres = {
|
||||||
|
...charge,
|
||||||
|
participants: charge.participants.map((personne) => (personne.id === id ? { ...personne, exclu: false } : personne)),
|
||||||
|
};
|
||||||
|
const siennes = charge.reservations.filter(({ participant }) => participant === id).sort(ordreDesReservations);
|
||||||
|
const refus = [];
|
||||||
|
const vues = new Set();
|
||||||
|
for (const { table: idTable } of siennes) {
|
||||||
|
if (vues.has(idTable)) continue;
|
||||||
|
vues.add(idTable);
|
||||||
|
const places = capacite(apres, tableDe(apres, idTable));
|
||||||
|
const comptes = reservationsParTour(apres, idTable);
|
||||||
|
const rang = comptes.findIndex((n) => n > places);
|
||||||
|
if (rang >= 0) {
|
||||||
|
const details = { table: idTable, tour: rang + 1, reservees: comptes[rang], capacite: places };
|
||||||
|
refus.push(refusDe('SURRESERVATION', details, changerPlaces(idTable, comptes[rang])));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const actives = reservationsActives(charge);
|
||||||
|
for (const sienne of siennes) {
|
||||||
|
if (sienne.siege === null) continue;
|
||||||
|
const couverts = toursCouverts(charge, sienne);
|
||||||
|
const autre = actives.find(
|
||||||
|
(r) => r.table === sienne.table && r.siege === sienne.siege && couverts.some((t) => couvre(r, t)),
|
||||||
|
);
|
||||||
|
if (autre !== undefined) refus.push(placeReservee(autre, couverts.find((t) => couvre(autre, t))));
|
||||||
|
}
|
||||||
|
return refus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lève le premier refus d'une liste, en ErreurCommande de son code et de ses
|
||||||
|
* détails, remède compris, les suivants dans details.autres ; ne fait rien
|
||||||
|
* d'une liste vide.
|
||||||
|
*
|
||||||
|
* @param {Array<{code: string, details: Object}>} refus
|
||||||
|
* @throws {ErreurCommande}
|
||||||
|
*/
|
||||||
|
export function leverRefus(refus) {
|
||||||
|
if (refus.length === 0) return;
|
||||||
|
const [premier, ...autres] = refus;
|
||||||
|
throw new ErreurCommande(premier.code, { ...premier.details, autres });
|
||||||
|
}
|
||||||
351
src/application/pose.test.js
Normal file
351
src/application/pose.test.js
Normal file
|
|
@ -0,0 +1,351 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Épreuves du contrôle de pose (§ 5.9, § 4.4) : chaque refus d'une pose, son
|
||||||
|
// remède écrit en entier, le cumul de plusieurs refus, le signal d'une table
|
||||||
|
// gelée, le compte des réservations par tour et le contrôle d'une
|
||||||
|
// réintégration. Les noms d'épreuve sont inventés.
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { describe, test } from '../../test/lanceur.js';
|
||||||
|
import { normaliser } from '../moteur/configuration.js';
|
||||||
|
import { ErreurConfiguration } from '../moteur/erreurs.js';
|
||||||
|
import { configurationDepuisCharge, creerCharge } from '../stockage/document.js';
|
||||||
|
import { COMMANDES } from './commandes.js';
|
||||||
|
import { ErreurCommande } from './erreurs.js';
|
||||||
|
import { controlerPose, controlerReintegration, reservationsParTour } from './pose.js';
|
||||||
|
|
||||||
|
const NOMS = [
|
||||||
|
['Ombrelle', 'Iris'],
|
||||||
|
['Pervenche', 'Théo'],
|
||||||
|
['Lacasse', 'Ondine'],
|
||||||
|
['Grisaille', null],
|
||||||
|
['Bruyère', 'Anouk'],
|
||||||
|
['Sarcelle', 'Capucine'],
|
||||||
|
['Ancolie', 'Amandine'],
|
||||||
|
['Mélèze', 'Basile'],
|
||||||
|
['Quenouille', 'Hortense'],
|
||||||
|
['Pivoine', 'Gaspard'],
|
||||||
|
];
|
||||||
|
|
||||||
|
const personne = (id, autres = {}) => ({
|
||||||
|
id,
|
||||||
|
nom: NOMS[(id - 1) % NOMS.length][0],
|
||||||
|
prenom: NOMS[(id - 1) % NOMS.length][1],
|
||||||
|
appartenance: null,
|
||||||
|
courriel: null,
|
||||||
|
titrePressenti: null,
|
||||||
|
notes: null,
|
||||||
|
exclu: false,
|
||||||
|
...autres,
|
||||||
|
});
|
||||||
|
|
||||||
|
const table = (id, sieges = null) => ({ id, numero: id, sieges, forme: 'ronde', position: { x: 300 * id, y: 0 } });
|
||||||
|
|
||||||
|
// Réservation « tous les tours » quand tour est null, du tour donné sinon.
|
||||||
|
const resa = (participant, idTable, siege, tour = null) => ({
|
||||||
|
participant,
|
||||||
|
table: idTable,
|
||||||
|
siege,
|
||||||
|
portee: tour === null ? 'tous' : 'tour',
|
||||||
|
tour,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Une charge de R tours, siegesParDefaut places par défaut, qui porte ce
|
||||||
|
// qu'on lui donne ; les compteurs dépassent chaque identifiant donné.
|
||||||
|
function chargeDe({
|
||||||
|
participants = 10,
|
||||||
|
exclus = [],
|
||||||
|
tables = [table(1), table(2, 8), table(3)],
|
||||||
|
reservations = [],
|
||||||
|
titres = [],
|
||||||
|
tours = 4,
|
||||||
|
sieges = 4,
|
||||||
|
attribuerSieges = false,
|
||||||
|
} = {}) {
|
||||||
|
const charge = creerCharge({ id: 'evt-pose', nom: 'Soirée des Lucioles', siegesParDefaut: sieges, tours });
|
||||||
|
charge.reglages.attribuerSieges = attribuerSieges;
|
||||||
|
charge.participants = Array.from({ length: participants }, (_, rang) =>
|
||||||
|
personne(rang + 1, { exclu: exclus.includes(rang + 1) }),
|
||||||
|
);
|
||||||
|
charge.tables = tables;
|
||||||
|
charge.reservations = reservations;
|
||||||
|
charge.titres = titres;
|
||||||
|
charge.prochainsIds = { participant: participants + 1, table: 10, proposition: 1 };
|
||||||
|
return charge;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Le refus d'une ErreurCommande levée par appel, de ce code ; ses détails
|
||||||
|
// contiennent ceux donnés.
|
||||||
|
function refuse(appel, code, attendus = {}) {
|
||||||
|
assert.throws(appel, (erreur) => {
|
||||||
|
assert.ok(erreur instanceof ErreurCommande, `ErreurCommande attendue, reçu ${erreur}`);
|
||||||
|
assert.equal(erreur.code, code);
|
||||||
|
for (const [cle, valeur] of Object.entries(attendus)) assert.deepEqual(erreur.details[cle], valeur, cle);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const executer = (nom, charge, args) => COMMANDES.get(nom)(charge, args);
|
||||||
|
|
||||||
|
// La charge où la pose serait faite : la réservation ajoutée telle quelle.
|
||||||
|
const avecPose = (charge, { participant, table: idTable, siege, portee, tour }) => ({
|
||||||
|
...charge,
|
||||||
|
reservations: [...charge.reservations, { participant, table: idTable, siege, portee, tour }],
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('controlerPose : les refus de pose (§ 5.9)', () => {
|
||||||
|
test('quatre réservations « tous » à une table de 4 : la cinquième SURRESERVATION aux détails du moteur, la quatrième gelée', () => {
|
||||||
|
const quatre = chargeDe({ reservations: [1, 2, 3, 4].map((id) => resa(id, 1, null)) });
|
||||||
|
const pose = { participant: 5, table: 1, siege: null, portee: 'tous', tour: null };
|
||||||
|
const { refus, signaux } = controlerPose(quatre, pose);
|
||||||
|
assert.equal(refus.length, 1);
|
||||||
|
assert.equal(refus[0].code, 'SURRESERVATION');
|
||||||
|
const { remede, ...details } = refus[0].details;
|
||||||
|
assert.deepEqual(details, { table: 1, tour: 1, reservees: 5, capacite: 4 });
|
||||||
|
assert.deepEqual(remede, { commande: 'changerPlacesTable', arguments: { id: 1, sieges: 5, origine: 'champ' } });
|
||||||
|
let duMoteur = null;
|
||||||
|
try {
|
||||||
|
normaliser(configurationDepuisCharge(avecPose(quatre, pose)));
|
||||||
|
} catch (erreur) {
|
||||||
|
duMoteur = erreur;
|
||||||
|
}
|
||||||
|
assert.ok(duMoteur instanceof ErreurConfiguration);
|
||||||
|
assert.equal(duMoteur.code, 'SURRESERVATION');
|
||||||
|
assert.deepEqual(duMoteur.details, details);
|
||||||
|
assert.deepEqual(signaux, []);
|
||||||
|
|
||||||
|
const trois = chargeDe({ reservations: [1, 2, 3].map((id) => resa(id, 1, null)) });
|
||||||
|
const quatrieme = controlerPose(trois, { participant: 4, table: 1, siege: null, portee: 'tous', tour: null });
|
||||||
|
assert.deepEqual(quatrieme, { refus: [], signaux: [{ code: 'TABLE_GELEE', table: 1 }] });
|
||||||
|
assert.deepEqual(reservationsParTour(quatre, 1), [4, 4, 4, 4]);
|
||||||
|
assert.deepEqual(reservationsParTour(quatre, 2), [0, 0, 0, 0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reservationsParTour compte les personnes distinctes non exclues dont une réservation couvre le tour", () => {
|
||||||
|
const charge = chargeDe({
|
||||||
|
exclus: [4],
|
||||||
|
reservations: [resa(1, 1, null), resa(1, 1, 2, 3), resa(2, 1, null, 2), resa(3, 1, null, 2), resa(4, 1, null)],
|
||||||
|
});
|
||||||
|
assert.deepEqual(reservationsParTour(charge, 1), [1, 3, 1, 1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sièges attribués, le siège 9 d’une table de 8 : SIEGE_INEXISTANT ; sans attribution, aucun', () => {
|
||||||
|
const pose = { participant: 1, table: 2, siege: 9, portee: 'tous', tour: null };
|
||||||
|
const { refus } = controlerPose(chargeDe({ attribuerSieges: true }), pose);
|
||||||
|
assert.deepEqual(refus, [
|
||||||
|
{
|
||||||
|
code: 'SIEGE_INEXISTANT',
|
||||||
|
details: {
|
||||||
|
table: 2,
|
||||||
|
siege: 9,
|
||||||
|
capacite: 8,
|
||||||
|
remede: { commande: 'changerPlacesTable', arguments: { id: 2, sieges: 9, origine: 'champ' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
assert.deepEqual(controlerPose(chargeDe(), pose).refus, []);
|
||||||
|
const { charge } = executer('reserver', chargeDe(), { participant: 1, table: 2, siege: 9, portee: 'tous', tour: null });
|
||||||
|
assert.deepEqual(charge.reservations, [resa(1, 2, null)]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('« tous » à la table 1 puis le tour 2 à la table 2 : DEUX_PLACES_MEME_TOUR, et son remède déplace la première', () => {
|
||||||
|
const charge = chargeDe({ reservations: [resa(1, 1, null)] });
|
||||||
|
const { refus } = controlerPose(charge, { participant: 1, table: 2, siege: 4, portee: 'tour', tour: 2 });
|
||||||
|
assert.deepEqual(refus, [
|
||||||
|
{
|
||||||
|
code: 'DEUX_PLACES_MEME_TOUR',
|
||||||
|
details: {
|
||||||
|
participant: 1,
|
||||||
|
tour: 2,
|
||||||
|
autre: { table: 1, siege: null, portee: 'tous', tour: null },
|
||||||
|
remede: {
|
||||||
|
commande: 'deplacerReservation',
|
||||||
|
arguments: { participant: 1, de: { table: 1, portee: 'tous', tour: null }, vers: { table: 2, siege: 4 } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("deux sièges d'une même table au même tour : DEUX_PLACES_MEME_TOUR, que normaliser accepterait", () => {
|
||||||
|
const charge = chargeDe({ attribuerSieges: true, reservations: [resa(1, 1, 1, 3)] });
|
||||||
|
const pose = { participant: 1, table: 1, siege: 2, portee: 'tour', tour: 3 };
|
||||||
|
assert.doesNotThrow(() => normaliser(configurationDepuisCharge(avecPose(charge, pose))));
|
||||||
|
const { refus } = controlerPose(charge, pose);
|
||||||
|
assert.deepEqual(
|
||||||
|
refus.map(({ code, details }) => [code, details.tour, details.autre]),
|
||||||
|
[['DEUX_PLACES_MEME_TOUR', 3, { table: 1, siege: 1, portee: 'tour', tour: 3 }]],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('déplacer sa propre réservation ne la compte pas contre la pose (depuis)', () => {
|
||||||
|
const charge = chargeDe({ reservations: [1, 2, 3, 4].map((id) => resa(id, 1, null)) });
|
||||||
|
const pose = { participant: 4, table: 2, siege: null, portee: 'tous', tour: null, depuis: { table: 1, portee: 'tous', tour: null } };
|
||||||
|
assert.deepEqual(controlerPose(charge, pose), { refus: [], signaux: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('une personne exclue : PERSONNE_EXCLUE, remède la réintégration', () => {
|
||||||
|
const { refus } = controlerPose(chargeDe({ exclus: [3] }), { participant: 3, table: 1, siege: null, portee: 'tour', tour: 1 });
|
||||||
|
assert.deepEqual(refus, [
|
||||||
|
{
|
||||||
|
code: 'PERSONNE_EXCLUE',
|
||||||
|
details: { participant: 3, remede: { commande: 'reintegrerParticipant', arguments: { id: 3 } } },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("une place réservée à une autre : PLACE_RESERVEE, remède choisir une place pour la réservation de la titulaire", () => {
|
||||||
|
const charge = chargeDe({ attribuerSieges: true, reservations: [resa(2, 2, 3, 2)] });
|
||||||
|
const { refus } = controlerPose(charge, { participant: 1, table: 2, siege: 3, portee: 'tous', tour: null });
|
||||||
|
assert.deepEqual(refus, [
|
||||||
|
{
|
||||||
|
code: 'PLACE_RESERVEE',
|
||||||
|
details: {
|
||||||
|
table: 2,
|
||||||
|
siege: 3,
|
||||||
|
tour: 2,
|
||||||
|
titulaire: 2,
|
||||||
|
portee: 'tour',
|
||||||
|
remede: {
|
||||||
|
geste: 'choisirPlace',
|
||||||
|
puis: { commande: 'deplacerReservation', arguments: { participant: 2, de: { table: 2, portee: 'tour', tour: 2 } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
// Sans attribution des sièges, la place réservée reste refusée.
|
||||||
|
const sansAttribution = { ...charge, reglages: { ...charge.reglages, attribuerSieges: false } };
|
||||||
|
assert.deepEqual(
|
||||||
|
controlerPose(sansAttribution, { participant: 1, table: 2, siege: 3, portee: 'tour', tour: 2 }).refus.map(({ code }) => code),
|
||||||
|
['PLACE_RESERVEE'],
|
||||||
|
);
|
||||||
|
// Une titulaire « tous » : le remède désigne sa réservation sans tour.
|
||||||
|
const tous = chargeDe({ attribuerSieges: true, reservations: [resa(2, 2, 3)] });
|
||||||
|
const [place] = controlerPose(tous, { participant: 1, table: 2, siege: 3, portee: 'tour', tour: 4 }).refus;
|
||||||
|
assert.deepEqual(place.details.remede.puis.arguments.de, { table: 2, portee: 'tous', tour: null });
|
||||||
|
assert.equal(place.details.portee, 'tous');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('une réservation suspendue ne compte pas : sa place et son siège sont libres pour une pose', () => {
|
||||||
|
const charge = chargeDe({ attribuerSieges: true, exclus: [2], reservations: [resa(2, 1, 1), resa(3, 1, 2), resa(4, 1, 3), resa(5, 1, 4)] });
|
||||||
|
assert.deepEqual(controlerPose(charge, { participant: 1, table: 1, siege: 1, portee: 'tous', tour: null }).refus, []);
|
||||||
|
assert.deepEqual(reservationsParTour(charge, 1), [3, 3, 3, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dans le retenu, une personne réservée ailleurs : RESERVEE_AILLEURS, remède le déplacement de sa réservation', () => {
|
||||||
|
const charge = chargeDe({ reservations: [resa(1, 1, null)] });
|
||||||
|
const { refus } = controlerPose(charge, { participant: 1, table: 2, siege: 5, portee: 'tour', tour: 2, retenu: true });
|
||||||
|
assert.deepEqual(refus, [
|
||||||
|
{
|
||||||
|
code: 'RESERVEE_AILLEURS',
|
||||||
|
details: {
|
||||||
|
participant: 1,
|
||||||
|
tour: 2,
|
||||||
|
reservation: { table: 1, siege: null, portee: 'tous', tour: null },
|
||||||
|
remede: {
|
||||||
|
commande: 'deplacerReservation',
|
||||||
|
arguments: { participant: 1, de: { table: 1, portee: 'tous', tour: null }, vers: { table: 2, siege: 5 } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
// Le retenu ne pose aucune réservation : ni surréservation ni double place.
|
||||||
|
const pleine = chargeDe({ reservations: [2, 3, 4, 5].map((id) => resa(id, 1, null)) });
|
||||||
|
assert.deepEqual(controlerPose(pleine, { participant: 1, table: 1, siege: null, portee: 'tour', tour: 1, retenu: true }), {
|
||||||
|
refus: [],
|
||||||
|
signaux: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('une pose qui cumule des refus les rend tous, dans l’ordre du contrat, chacun avec un remède non nul', () => {
|
||||||
|
const charge = chargeDe({
|
||||||
|
attribuerSieges: true,
|
||||||
|
exclus: [1],
|
||||||
|
reservations: [resa(1, 3, 1, 2), resa(2, 2, 3, 2), ...[3, 4, 5, 6, 7, 8, 9].map((id) => resa(id, 2, null))],
|
||||||
|
});
|
||||||
|
const { refus } = controlerPose(charge, { participant: 1, table: 2, siege: 9, portee: 'tour', tour: 2 });
|
||||||
|
assert.deepEqual(
|
||||||
|
refus.map(({ code }) => code),
|
||||||
|
['SIEGE_INEXISTANT', 'PERSONNE_EXCLUE'],
|
||||||
|
);
|
||||||
|
const cumul = chargeDe({ attribuerSieges: true, exclus: [1], reservations: [resa(2, 2, 3, 2)] });
|
||||||
|
const deux = controlerPose(cumul, { participant: 1, table: 2, siege: 3, portee: 'tour', tour: 2 }).refus;
|
||||||
|
assert.deepEqual(
|
||||||
|
deux.map(({ code }) => code),
|
||||||
|
['PERSONNE_EXCLUE', 'PLACE_RESERVEE'],
|
||||||
|
);
|
||||||
|
const actif = chargeDe({ attribuerSieges: true, reservations: [resa(1, 1, null), resa(2, 2, 3, 2), ...[3, 4, 5, 6, 7, 8, 9].map((id) => resa(id, 2, null))] });
|
||||||
|
const tous = controlerPose(actif, { participant: 1, table: 2, siege: 3, portee: 'tour', tour: 2 }).refus;
|
||||||
|
assert.deepEqual(
|
||||||
|
tous.map(({ code }) => code),
|
||||||
|
['SURRESERVATION', 'DEUX_PLACES_MEME_TOUR', 'PLACE_RESERVEE'],
|
||||||
|
);
|
||||||
|
for (const { details } of [...refus, ...deux, ...tous]) {
|
||||||
|
assert.ok(details.remede !== null && typeof details.remede === 'object', 'remède non nul');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('une commande lève le premier refus et porte les suivants dans details.autres', () => {
|
||||||
|
const charge = chargeDe({ attribuerSieges: true, exclus: [1], reservations: [resa(2, 2, 3, 2)] });
|
||||||
|
refuse(() => executer('reserver', charge, { participant: 1, table: 2, siege: 3, portee: 'tour', tour: 2 }), 'PERSONNE_EXCLUE', {
|
||||||
|
participant: 1,
|
||||||
|
autres: controlerPose(charge, { participant: 1, table: 2, siege: 3, portee: 'tour', tour: 2 }).refus.slice(1),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('controlerReintegration : la place d’une suspendue (§ 4.4, § 5.9)', () => {
|
||||||
|
// Sièges attribués : Iris désignée au siège 1 de la table 3, puis exclue.
|
||||||
|
function irisDesigneeExclue() {
|
||||||
|
const charge = chargeDe({ attribuerSieges: true });
|
||||||
|
const designee = executer('designer', charge, { participant: 1, table: 3, siege: 1, libelle: 'animatrice' }).charge;
|
||||||
|
return executer('exclureParticipant', designee, { id: 1 }).charge;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('désigner Théo à la place d’Iris exclue passe ; réintégrer Iris est refusé PLACE_RESERVEE, sans rien écrire', () => {
|
||||||
|
const exclue = irisDesigneeExclue();
|
||||||
|
const avecTheo = executer('designer', exclue, { participant: 2, table: 3, siege: 1, libelle: 'animatrice' }).charge;
|
||||||
|
assert.deepEqual(
|
||||||
|
avecTheo.reservations.filter(({ table: t }) => t === 3),
|
||||||
|
[resa(1, 3, 1), resa(2, 3, 1)],
|
||||||
|
);
|
||||||
|
const avant = structuredClone(avecTheo);
|
||||||
|
assert.deepEqual(
|
||||||
|
controlerReintegration(avecTheo, 1).map(({ code, details }) => [code, details.table, details.siege, details.titulaire]),
|
||||||
|
[['PLACE_RESERVEE', 3, 1, 2]],
|
||||||
|
);
|
||||||
|
refuse(() => executer('reintegrerParticipant', avecTheo, { id: 1 }), 'PLACE_RESERVEE', {
|
||||||
|
table: 3,
|
||||||
|
siege: 1,
|
||||||
|
titulaire: 2,
|
||||||
|
autres: [],
|
||||||
|
});
|
||||||
|
assert.deepEqual(avecTheo, avant);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('une table de 4 dont une réservation est suspendue reçoit une cinquième ; réintégrer est alors refusé SURRESERVATION', () => {
|
||||||
|
const charge = chargeDe({ exclus: [1], reservations: [1, 2, 3, 4].map((id) => resa(id, 1, null)) });
|
||||||
|
const cinq = executer('reserver', charge, { participant: 5, table: 1, siege: null, portee: 'tous', tour: null }).charge;
|
||||||
|
assert.deepEqual(
|
||||||
|
controlerReintegration(cinq, 1).map(({ code, details: { remede, ...details } }) => [code, details]),
|
||||||
|
[['SURRESERVATION', { table: 1, tour: 1, reservees: 5, capacite: 4 }]],
|
||||||
|
);
|
||||||
|
refuse(() => executer('reintegrerParticipant', cinq, { id: 1 }), 'SURRESERVATION', { table: 1, reservees: 5 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sans conflit, la réintégration passe et rend les réservations', () => {
|
||||||
|
const exclue = irisDesigneeExclue();
|
||||||
|
assert.deepEqual(controlerReintegration(exclue, 1), []);
|
||||||
|
const { charge, avertissements } = executer('reintegrerParticipant', exclue, { id: 1 });
|
||||||
|
assert.equal(charge.participants[0].exclu, false);
|
||||||
|
assert.deepEqual(charge.reservations, exclue.reservations);
|
||||||
|
assert.deepEqual(
|
||||||
|
avertissements.map(({ code }) => code),
|
||||||
|
['RESERVATIONS_RETABLIES'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('une personne présente n’a rien à contrôler', () => {
|
||||||
|
assert.deepEqual(controlerReintegration(chargeDe({ reservations: [resa(1, 1, null)] }), 1), []);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -52,7 +52,22 @@
|
||||||
//
|
//
|
||||||
// Les avertissements de la séance sont ceux de l'Ouvert courant — ceux de
|
// Les avertissements de la séance sont ceux de l'Ouvert courant — ceux de
|
||||||
// l'ouverture, puis ceux que le dépôt recalcule après chaque écriture —,
|
// l'ouverture, puis ceux que le dépôt recalcule après chaque écriture —,
|
||||||
// suivis de ceux du dernier geste.
|
// puis INVARIANT_VIOLE, tant que le retenu de la charge enfreint une règle
|
||||||
|
// que la dérive ne nomme pas, puis ceux du dernier geste — ceux d'un passage
|
||||||
|
// en écriture, ceux de la commande, puis ceux que le geste fait naître
|
||||||
|
// (avertissementsApres) —, puis RETOUR_AUTOMATIQUE après un retour en
|
||||||
|
// lecture au terme de l'inactivité, jusqu'au passage en écriture suivant.
|
||||||
|
//
|
||||||
|
// Chaque refus porte son remède, details.remede : le geste qui le lève, ou
|
||||||
|
// null. Le refus qu'un geste modifiant recevrait maintenant se lit dans
|
||||||
|
// l'état observable, calculé par la même fonction que celle qui refuse
|
||||||
|
// (exigerEcriture) : aucun composant ne refait ce contrôle.
|
||||||
|
//
|
||||||
|
// La séance est observable : etat() rend un instantané figé en profondeur,
|
||||||
|
// neuf à chaque changement et le même entre deux, et subscribe suit le
|
||||||
|
// contrat des magasins de Svelte. Le cadrage de l'événement ouvert se range
|
||||||
|
// hors du fichier, dans reglages_locaux.json, par un écrivain différé
|
||||||
|
// (§ 8.5) : regarder n'écrit jamais l'événement.
|
||||||
//
|
//
|
||||||
// Les appels qui lisent ou écrivent l'événement passent l'un après l'autre,
|
// Les appels qui lisent ou écrivent l'événement passent l'un après l'autre,
|
||||||
// dans l'ordre de leur appel : chacun part de l'Ouvert que le précédent a
|
// dans l'ordre de leur appel : chacun part de l'Ouvert que le précédent a
|
||||||
|
|
@ -70,17 +85,24 @@ import {
|
||||||
ouvrirSecours as ouvrirSecoursSurDisque,
|
ouvrirSecours as ouvrirSecoursSurDisque,
|
||||||
verrouiller,
|
verrouiller,
|
||||||
} from '../stockage/depot.js';
|
} from '../stockage/depot.js';
|
||||||
|
import { FORMAT, analyser } from '../stockage/document.js';
|
||||||
import { ErreurStockage } from '../stockage/erreurs.js';
|
import { ErreurStockage } from '../stockage/erreurs.js';
|
||||||
import { fil, reconstruire } from '../stockage/journal.js';
|
import { fil, reconstruire } from '../stockage/journal.js';
|
||||||
|
import { avecCadrage, cadrageDe, creerEcrivainDiffere, ecrireReglages, lireReglages } from '../stockage/reglages_locaux.js';
|
||||||
|
import { avertissementsApres, invariantViole } from './avertissements.js';
|
||||||
import { appliquerCommande, chargeNeuve, formeNommeeDe } from './commandes.js';
|
import { appliquerCommande, chargeNeuve, formeNommeeDe } from './commandes.js';
|
||||||
import { libelle } from './libelles.js';
|
import { libelle } from './libelles.js';
|
||||||
|
|
||||||
/** Inactivité au terme de laquelle la séance revient seule en lecture (§ 8.4). */
|
/** Inactivité au terme de laquelle la séance revient seule en lecture (§ 8.4). */
|
||||||
export const INACTIVITE_MS = 10 * 60 * 1000;
|
export const INACTIVITE_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
|
/** Délai de l'écriture différée du cadrage dans reglages_locaux.json (§ 8.5). */
|
||||||
|
export const DELAI_REGLAGES_MS = 1000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Le refus d'un geste par la séance : son code et ses détails. VERROU_PRIS
|
* Le refus d'un geste par la séance : son code et ses détails, dont le
|
||||||
* et LECTURE_SEULE, codes du contrat du stockage, se lèvent en
|
* remède, details.remede, null quand les détails n'en donnent pas.
|
||||||
|
* VERROU_PRIS et LECTURE_SEULE, codes du contrat du stockage, se lèvent en
|
||||||
* ErreurStockage. Le texte affiché vient de la table des libellés (§ 14.6).
|
* ErreurStockage. Le texte affiché vient de la table des libellés (§ 14.6).
|
||||||
*/
|
*/
|
||||||
export class ErreurSeance extends Error {
|
export class ErreurSeance extends Error {
|
||||||
|
|
@ -89,9 +111,10 @@ export class ErreurSeance extends Error {
|
||||||
* @param {Object} [details]
|
* @param {Object} [details]
|
||||||
*/
|
*/
|
||||||
constructor(code, details = {}) {
|
constructor(code, details = {}) {
|
||||||
super(`${code} ${JSON.stringify(details)}`);
|
const complets = Object.hasOwn(details, 'remede') ? details : { ...details, remede: null };
|
||||||
|
super(`${code} ${JSON.stringify(complets)}`);
|
||||||
this.code = code;
|
this.code = code;
|
||||||
this.details = details;
|
this.details = complets;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ErreurSeance.prototype.name = 'ErreurSeance';
|
ErreurSeance.prototype.name = 'ErreurSeance';
|
||||||
|
|
@ -101,6 +124,30 @@ const avertissement = (code, details) => ({ code, details });
|
||||||
|
|
||||||
const estBloque = (ouvert) => ouvert.charge.evenement.etat === 'bloque';
|
const estBloque = (ouvert) => ouvert.charge.evenement.etat === 'bloque';
|
||||||
|
|
||||||
|
// Les refus de lecture seule de la séance, et leur remède : aucun pour un
|
||||||
|
// format plus récent ; débloquer un plan bloqué ; ouvrir le .precedent d'un
|
||||||
|
// état aux octets hors UTF-8, que son premier geste met de côté (§ 8.8).
|
||||||
|
const refusFormat = () => new ErreurStockage('LECTURE_SEULE', { raison: 'FORMAT', remede: null });
|
||||||
|
const refusBloque = () => new ErreurStockage('LECTURE_SEULE', { raison: 'BLOQUE', remede: { geste: 'debloquer' } });
|
||||||
|
const refusEncodage = () =>
|
||||||
|
new ErreurStockage('LECTURE_SEULE', { raison: 'ENCODAGE', remede: { geste: 'ouvrirSecours', source: 'precedent' } });
|
||||||
|
|
||||||
|
// Deux racines désignent le même dossier.
|
||||||
|
const memeRacine = (a, b) => a?.id === b?.id && a?.chemin === b?.chemin;
|
||||||
|
|
||||||
|
// Fige un objet et tout ce qu'il atteint. Un objet déjà figé est laissé
|
||||||
|
// tel quel : l'état observable ne reçoit que des copies neuves
|
||||||
|
// (structuredClone) ou des objets déjà passés par cette fonction.
|
||||||
|
function figer(valeur) {
|
||||||
|
if (valeur === null || typeof valeur !== 'object' || Object.isFrozen(valeur)) return valeur;
|
||||||
|
for (const cle of Object.keys(valeur)) figer(valeur[cle]);
|
||||||
|
return Object.freeze(valeur);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Le format qu'annonce l'en-tête de l'état sur le disque, null sans texte :
|
||||||
|
// texteEtat d'un Ouvert est toujours lisible quand il n'est pas null.
|
||||||
|
const formatDe = (texteEtat) => (texteEtat === null ? null : analyser(texteEtat).entete.format);
|
||||||
|
|
||||||
// La révision de la dernière entrée du journal de l'Ouvert, null sans
|
// La révision de la dernière entrée du journal de l'Ouvert, null sans
|
||||||
// journal.
|
// journal.
|
||||||
const derniereEntree = ({ journal }) => journal?.entrees.at(-1)?.revision ?? null;
|
const derniereEntree = ({ journal }) => journal?.entrees.at(-1)?.revision ?? null;
|
||||||
|
|
@ -121,8 +168,10 @@ function libelleDuRetour(sens, instant) {
|
||||||
*
|
*
|
||||||
* @param {Object} parametres
|
* @param {Object} parametres
|
||||||
* @param {import('../stockage/systeme_fichiers.js').SystemeFichiers} parametres.fs
|
* @param {import('../stockage/systeme_fichiers.js').SystemeFichiers} parametres.fs
|
||||||
* @param {import('../stockage/systeme_fichiers.js').Racine} parametres.racine
|
* @param {import('../stockage/systeme_fichiers.js').Racine|null} [parametres.racine]
|
||||||
* le dossier de travail que la règle du § 8.6 a déterminé
|
* le dossier de travail que la règle du § 8.6 a déterminé ; null
|
||||||
|
* jusqu'à changerRacine, la séance ne le lisant qu'à ouvrir, créer et
|
||||||
|
* écrire ailleurs
|
||||||
* @param {{horodatage: () => string}} parametres.horloge
|
* @param {{horodatage: () => string}} parametres.horloge
|
||||||
* horodatage rend AAAA-MM-JJTHH:MM:SS±HH:MM
|
* horodatage rend AAAA-MM-JJTHH:MM:SS±HH:MM
|
||||||
* @param {() => string} parametres.identifiant tiré une fois pour le verrou
|
* @param {() => string} parametres.identifiant tiré une fois pour le verrou
|
||||||
|
|
@ -132,16 +181,23 @@ function libelleDuRetour(sens, instant) {
|
||||||
* arme une minuterie ; rend ce qu'annuler reçoit
|
* arme une minuterie ; rend ce qu'annuler reçoit
|
||||||
* @param {(minuterie: unknown) => void} parametres.annuler
|
* @param {(minuterie: unknown) => void} parametres.annuler
|
||||||
* @param {number} [parametres.inactiviteMs]
|
* @param {number} [parametres.inactiviteMs]
|
||||||
|
* @param {number} [parametres.formatCourant] le format au-dessous duquel un
|
||||||
|
* fichier ouvert s'annonce plus ancien ; il ne règle que l'annonce : le
|
||||||
|
* stockage n'écrit que FORMAT
|
||||||
|
* @param {number} [parametres.delaiReglagesMs] délai de l'écriture différée
|
||||||
|
* du cadrage
|
||||||
*/
|
*/
|
||||||
export function creerSeance({
|
export function creerSeance({
|
||||||
fs,
|
fs,
|
||||||
racine,
|
racine = null,
|
||||||
horloge,
|
horloge,
|
||||||
identifiant,
|
identifiant,
|
||||||
produitVersion,
|
produitVersion,
|
||||||
planifier,
|
planifier,
|
||||||
annuler,
|
annuler,
|
||||||
inactiviteMs = INACTIVITE_MS,
|
inactiviteMs = INACTIVITE_MS,
|
||||||
|
formatCourant = FORMAT,
|
||||||
|
delaiReglagesMs = DELAI_REGLAGES_MS,
|
||||||
}) {
|
}) {
|
||||||
const id = identifiant();
|
const id = identifiant();
|
||||||
const libelleReprise = libelle('ENTREE_REPRISE');
|
const libelleReprise = libelle('ENTREE_REPRISE');
|
||||||
|
|
@ -154,7 +210,12 @@ export function creerSeance({
|
||||||
// l'écriture qui a rompu le support, {echec, chemin, dossier, cause} ;
|
// l'écriture qui a rompu le support, {echec, chemin, dossier, cause} ;
|
||||||
// verrouAutre : le verrou d'une autre séance qui a refusé le dernier
|
// verrouAutre : le verrou d'une autre séance qui a refusé le dernier
|
||||||
// passage en écriture ; gestes, calculs : ceux qui sont ouverts, qui
|
// passage en écriture ; gestes, calculs : ceux qui sont ouverts, qui
|
||||||
// suspendent le compte d'inactivité ; minuterie : celle qu'il a armée.
|
// suspendent le compte d'inactivité ; minuterie : celle qu'il a armée ;
|
||||||
|
// ailleurs : la racine documents à proposer quand le support rompu en est
|
||||||
|
// une autre ; fautives : les propositions que l'ouverture a écartées ;
|
||||||
|
// formatLu : le format de l'état relu, null dès qu'un geste l'a écrit ;
|
||||||
|
// retourAutomatique : le dernier retour en lecture est celui de
|
||||||
|
// l'inactivité ; cadrage : celui de l'événement ouvert, lu ou demandé.
|
||||||
const etat = {
|
const etat = {
|
||||||
racine,
|
racine,
|
||||||
ouvert: null,
|
ouvert: null,
|
||||||
|
|
@ -163,18 +224,166 @@ export function creerSeance({
|
||||||
verrouTenu: false,
|
verrouTenu: false,
|
||||||
supportRompu: false,
|
supportRompu: false,
|
||||||
refusSupport: null,
|
refusSupport: null,
|
||||||
|
ailleurs: null,
|
||||||
verrouAutre: null,
|
verrouAutre: null,
|
||||||
avertissementsGeste: [],
|
avertissementsGeste: [],
|
||||||
gestes: 0,
|
gestes: 0,
|
||||||
calculs: 0,
|
calculs: 0,
|
||||||
minuterie: null,
|
minuterie: null,
|
||||||
|
fautives: [],
|
||||||
|
formatLu: null,
|
||||||
|
retourAutomatique: false,
|
||||||
|
cadrage: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- L'observation (A2) -----------------------------------------------------
|
||||||
|
|
||||||
|
// Les abonnés de subscribe, ceux de surAnnulation, et l'instantané rendu
|
||||||
|
// depuis le dernier changement, null quand il est à refaire.
|
||||||
|
const abonnes = new Set();
|
||||||
|
const annulations = new Set();
|
||||||
|
let instantane = null;
|
||||||
|
// Par Ouvert, sa charge et son examen figés, et INVARIANT_VIOLE : un
|
||||||
|
// Ouvert ne change jamais, ses copies se font une fois.
|
||||||
|
const parOuvert = new WeakMap();
|
||||||
|
|
||||||
|
function figesDe(ouvert) {
|
||||||
|
let figes = parOuvert.get(ouvert);
|
||||||
|
if (figes === undefined) {
|
||||||
|
const copie = structuredClone({ charge: ouvert.charge, examen: ouvert.examen });
|
||||||
|
figes = { ...figer(copie), invariant: figer(invariantViole(ouvert.charge, ouvert.examen)) };
|
||||||
|
parOuvert.set(ouvert, figes);
|
||||||
|
}
|
||||||
|
return figes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Le refus qu'un geste modifiant recevrait maintenant : celui
|
||||||
|
// qu'exigerEcriture lève, {code, details} ; null sans événement, ou quand
|
||||||
|
// le geste passerait.
|
||||||
|
function refusCourant() {
|
||||||
|
if (etat.ouvert === null) return null;
|
||||||
|
try {
|
||||||
|
exigerEcriture();
|
||||||
|
return null;
|
||||||
|
} catch (erreur) {
|
||||||
|
return { code: erreur.code, details: erreur.details };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// L'événement de l'état observable : sa base, son identifiant, son nom, son
|
||||||
|
// état, sa révision, et l'annonce du format.
|
||||||
|
function evenementDe(ouvert) {
|
||||||
|
const { id: idEvenement, nom, etat: etatDuPlan } = ouvert.charge.evenement;
|
||||||
|
let annonceFormat = null;
|
||||||
|
if (ouvert.formatPlusRecent) annonceFormat = 'plusRecent';
|
||||||
|
else if (etat.formatLu !== null && etat.formatLu < formatCourant) annonceFormat = 'plusAncien';
|
||||||
|
return { base: ouvert.base, id: idEvenement, nom, etat: etatDuPlan, revision: ouvert.revision, annonceFormat };
|
||||||
|
}
|
||||||
|
|
||||||
|
// L'état observable, construit à neuf (contrat EtatSeance).
|
||||||
|
function construire() {
|
||||||
|
const { ouvert } = etat;
|
||||||
|
const figes = ouvert === null ? null : figesDe(ouvert);
|
||||||
|
const avertissements = [
|
||||||
|
...(ouvert?.avertissements ?? []),
|
||||||
|
...(figes?.invariant ? [figes.invariant] : []),
|
||||||
|
...etat.avertissementsGeste,
|
||||||
|
...(etat.retourAutomatique ? [avertissement('RETOUR_AUTOMATIQUE', {})] : []),
|
||||||
|
];
|
||||||
|
const petit = structuredClone({
|
||||||
|
racine: etat.racine,
|
||||||
|
mode: etat.mode,
|
||||||
|
evenement: ouvert === null ? null : evenementDe(ouvert),
|
||||||
|
fautives: etat.fautives,
|
||||||
|
avertissements,
|
||||||
|
refus: refusCourant(),
|
||||||
|
retours: retoursDe(ouvert),
|
||||||
|
supportRompu: etat.supportRompu ? { ...etat.refusSupport, ailleurs: etat.ailleurs } : null,
|
||||||
|
calculEnCours: etat.calculs > 0,
|
||||||
|
verrouAutre: etat.verrouAutre,
|
||||||
|
});
|
||||||
|
return figer({
|
||||||
|
racine: petit.racine,
|
||||||
|
mode: petit.mode,
|
||||||
|
evenement: petit.evenement,
|
||||||
|
charge: figes?.charge ?? null,
|
||||||
|
examen: figes?.examen ?? null,
|
||||||
|
fautives: petit.fautives,
|
||||||
|
avertissements: petit.avertissements,
|
||||||
|
refus: petit.refus,
|
||||||
|
retours: petit.retours,
|
||||||
|
supportRompu: petit.supportRompu,
|
||||||
|
calculEnCours: petit.calculEnCours,
|
||||||
|
verrouAutre: petit.verrouAutre,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function lireEtat() {
|
||||||
|
if (instantane === null) instantane = construire();
|
||||||
|
return instantane;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Un changement a pu avoir lieu : l'instantané se refait, et chaque abonné
|
||||||
|
// reçoit le nouveau.
|
||||||
|
function publier() {
|
||||||
|
instantane = null;
|
||||||
|
if (abonnes.size === 0) return;
|
||||||
|
const courant = lireEtat();
|
||||||
|
for (const abonne of [...abonnes]) abonne(courant);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Le passage en lecture, explicite ou automatique, et la fermeture :
|
||||||
|
// chaque fonction de surAnnulation est appelée, dans l'ordre des
|
||||||
|
// abonnements.
|
||||||
|
function annoncerAnnulation() {
|
||||||
|
for (const fonction of [...annulations]) fonction();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Le cadrage (§ 8.5) -----------------------------------------------------
|
||||||
|
|
||||||
|
// Écrit le cadrage demandé dans les réglages de sa racine, relus au moment
|
||||||
|
// d'écrire : un cadrage qu'une autre séance y a posé entre-temps reste.
|
||||||
|
// Des réglages que la lecture ne prend pas (REGLAGES_ILLISIBLES : lecture
|
||||||
|
// refusée, JSON ou forme fautifs) ne s'écrasent pas des défauts, qui
|
||||||
|
// perdraient le papier et les autres cadrages : le refus lève, et
|
||||||
|
// l'écrivain garde le cadrage en attente pour la demande suivante.
|
||||||
|
async function ecrireCadrage({ racine: dossier, evenement, cadrage }) {
|
||||||
|
const { reglages, avertissement } = await lireReglages(fs, dossier);
|
||||||
|
if (avertissement !== null) throw new ErreurSeance(avertissement.code, avertissement.details);
|
||||||
|
await ecrireReglages(fs, dossier, avecCadrage(reglages, evenement, cadrage));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Le refus d'une écriture lancée par la minuterie se tait : le cadrage
|
||||||
|
// n'est qu'une commodité, et l'écrivain le réessaie à la demande suivante.
|
||||||
|
const ecrivain = creerEcrivainDiffere({ ecrire: ecrireCadrage, planifier, annuler, delaiMs: delaiReglagesMs });
|
||||||
|
|
||||||
|
// Le cadrage mémorisé de l'événement ouvert, relu dans les réglages de la
|
||||||
|
// racine ; null quand ils n'en portent pas, et pour des réglages que la
|
||||||
|
// lecture ne prend pas — la façade annonce REGLAGES_ILLISIBLES (A3).
|
||||||
|
async function cadrageMemorise(ouvert) {
|
||||||
|
try {
|
||||||
|
const { reglages } = await lireReglages(fs, ouvert.racine);
|
||||||
|
return cadrageDe(reglages, ouvert.charge.evenement.id);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Écrit aussitôt le cadrage en attente ; un refus se tait.
|
||||||
|
async function viderCadrage() {
|
||||||
|
try {
|
||||||
|
await ecrivain.vider();
|
||||||
|
} catch {
|
||||||
|
// Le cadrage refusé reste en attente de l'écrivain.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// La file des appels qui lisent ou écrivent l'événement : chacun part
|
// La file des appels qui lisent ou écrivent l'événement : chacun part
|
||||||
// quand le précédent a fini, réussi ou non.
|
// quand le précédent a fini, réussi ou non, et publie l'état qu'il laisse
|
||||||
|
// avant que son appelant reprenne la main.
|
||||||
let file = Promise.resolve();
|
let file = Promise.resolve();
|
||||||
function enFile(travail) {
|
function enFile(travail) {
|
||||||
const suite = file.then(travail);
|
const suite = file.then(travail).finally(publier);
|
||||||
file = suite.then(
|
file = suite.then(
|
||||||
() => undefined,
|
() => undefined,
|
||||||
() => undefined,
|
() => undefined,
|
||||||
|
|
@ -201,23 +410,28 @@ export function creerSeance({
|
||||||
|
|
||||||
// Le terme d'une minuterie, à son tour dans la file : celle qui est
|
// Le terme d'une minuterie, à son tour dans la file : celle qui est
|
||||||
// toujours armée ramène la séance en lecture, tant que rien n'est ouvert.
|
// toujours armée ramène la séance en lecture, tant que rien n'est ouvert.
|
||||||
|
// Le retour automatique se dit par RETOUR_AUTOMATIQUE, que le passage en
|
||||||
|
// écriture suivant retire.
|
||||||
async function expirer(minuterie) {
|
async function expirer(minuterie) {
|
||||||
if (etat.minuterie !== minuterie) return;
|
if (etat.minuterie !== minuterie) return;
|
||||||
etat.minuterie = null;
|
etat.minuterie = null;
|
||||||
if (etat.mode !== 'ecriture' || etat.gestes > 0 || etat.calculs > 0) return;
|
if (etat.mode !== 'ecriture' || etat.gestes > 0 || etat.calculs > 0) return;
|
||||||
await quitterEcriture();
|
await quitterEcriture();
|
||||||
|
etat.retourAutomatique = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Le verrou (§ 8.8) -----------------------------------------------------
|
// --- Le verrou (§ 8.8) -----------------------------------------------------
|
||||||
|
|
||||||
// Prend le verrou de l'événement de l'Ouvert. Le verrou de cette séance,
|
// Prend le verrou de l'événement de l'Ouvert. Le verrou de cette séance,
|
||||||
// resté d'un rendu refusé, est le sien. Celui d'une séance morte de ce
|
// resté d'un rendu refusé, est le sien. Celui d'une séance morte de ce
|
||||||
// poste (vivant faux) se rend en son nom, puis se reprend : VERROU_REPRIS.
|
// poste (vivant faux) se rend en son nom, puis se reprend : VERROU_REPRIS
|
||||||
// Celui d'une séance vivante, d'un autre poste ou illisible ne se reprend
|
// {seance, depuis}. Celui d'une séance vivante, d'un autre poste (vivant
|
||||||
// pas : VERROU_PRIS, que verrouAutre garde. Un système sans verrou le rend
|
// null) ou illisible ne se reprend pas : VERROU_PRIS {seance, depuis,
|
||||||
// toujours pris. Un support qui refuse d'écrire le verrou, ou d'effacer
|
// vivant}, remède null, que verrouAutre garde. Un système sans verrou le
|
||||||
// celui du mort, rompt le support sous l'échec VERROU_NON_ECRIT, et son
|
// rend toujours pris. Un support qui refuse d'écrire le verrou, ou
|
||||||
// ECRITURE remonte (§ 8.6). Rend les avertissements.
|
// d'effacer celui du mort, rompt le support sous l'échec
|
||||||
|
// VERROU_NON_ECRIT, et son ECRITURE remonte (§ 8.6). Rend les
|
||||||
|
// avertissements.
|
||||||
async function prendreVerrou(ouvert) {
|
async function prendreVerrou(ouvert) {
|
||||||
let verrou;
|
let verrou;
|
||||||
const avertissements = [];
|
const avertissements = [];
|
||||||
|
|
@ -233,13 +447,13 @@ export function creerSeance({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (erreur) {
|
} catch (erreur) {
|
||||||
if (erreur instanceof ErreurStockage && erreur.code === 'ECRITURE') rompre(erreur, ouvert, 'VERROU_NON_ECRIT');
|
if (erreur instanceof ErreurStockage && erreur.code === 'ECRITURE') await rompre(erreur, ouvert, 'VERROU_NON_ECRIT');
|
||||||
throw erreur;
|
throw erreur;
|
||||||
}
|
}
|
||||||
if (!verrou.pris) {
|
if (!verrou.pris) {
|
||||||
const { seance, depuis, vivant } = verrou;
|
const { seance, depuis, vivant } = verrou;
|
||||||
etat.verrouAutre = { seance, depuis, vivant };
|
etat.verrouAutre = { seance, depuis, vivant };
|
||||||
throw new ErreurStockage('VERROU_PRIS', { seance, depuis, vivant });
|
throw new ErreurStockage('VERROU_PRIS', { seance, depuis, vivant, remede: null });
|
||||||
}
|
}
|
||||||
etat.verrouAutre = null;
|
etat.verrouAutre = null;
|
||||||
etat.verrouTenu = true;
|
etat.verrouTenu = true;
|
||||||
|
|
@ -282,9 +496,7 @@ export function creerSeance({
|
||||||
return { frais: await ouvrirSurDisque(systeme, dossier, base), source: null };
|
return { frais: await ouvrirSurDisque(systeme, dossier, base), source: null };
|
||||||
} catch (erreur) {
|
} catch (erreur) {
|
||||||
const illisible = erreur instanceof ErreurStockage && erreur.code === 'ETAT_ILLISIBLE';
|
const illisible = erreur instanceof ErreurStockage && erreur.code === 'ETAT_ILLISIBLE';
|
||||||
if (illisible && erreur.details.raison === 'ENCODAGE') {
|
if (illisible && erreur.details.raison === 'ENCODAGE') throw refusEncodage();
|
||||||
throw new ErreurStockage('LECTURE_SEULE', { raison: 'ENCODAGE' });
|
|
||||||
}
|
|
||||||
throw erreur;
|
throw erreur;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -322,18 +534,20 @@ export function creerSeance({
|
||||||
const { frais, source } = relu;
|
const { frais, source } = relu;
|
||||||
etat.ouvert = frais;
|
etat.ouvert = frais;
|
||||||
etat.source = source;
|
etat.source = source;
|
||||||
|
etat.formatLu = formatDe(frais.texteEtat);
|
||||||
const change =
|
const change =
|
||||||
frais.revision !== ouvert.revision || serialiserCharge(frais.charge) !== serialiserCharge(ouvert.charge);
|
frais.revision !== ouvert.revision || serialiserCharge(frais.charge) !== serialiserCharge(ouvert.charge);
|
||||||
return change ? [avertissement('EVENEMENT_CHANGE', { revision: frais.revision })] : [];
|
return change ? [avertissement('EVENEMENT_CHANGE', { revision: frais.revision })] : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Le retour en lecture : le compte s'arrête, et le verrou se rend ; le
|
// Le retour en lecture : le compte s'arrête, le verrou se rend, et
|
||||||
// retour explicite annule en plus le geste ouvert. Les avertissements du
|
// surAnnulation l'annonce ; le retour explicite annule en plus le geste
|
||||||
// rendu s'ajoutent à ceux du dernier geste.
|
// ouvert. Les avertissements du rendu s'ajoutent à ceux du dernier geste.
|
||||||
async function quitterEcriture({ explicite = false } = {}) {
|
async function quitterEcriture({ explicite = false } = {}) {
|
||||||
suspendre();
|
suspendre();
|
||||||
if (explicite) etat.gestes = 0;
|
if (explicite) etat.gestes = 0;
|
||||||
etat.mode = 'lecture';
|
etat.mode = 'lecture';
|
||||||
|
annoncerAnnulation();
|
||||||
const avertissements = await rendreVerrou(etat.ouvert);
|
const avertissements = await rendreVerrou(etat.ouvert);
|
||||||
etat.avertissementsGeste = [...etat.avertissementsGeste, ...avertissements];
|
etat.avertissementsGeste = [...etat.avertissementsGeste, ...avertissements];
|
||||||
return avertissements;
|
return avertissements;
|
||||||
|
|
@ -346,26 +560,32 @@ export function creerSeance({
|
||||||
return etat.ouvert;
|
return etat.ouvert;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Le remède d'un support rompu : écrire ailleurs, dans la racine documents
|
||||||
|
// quand le support rompu en est une autre ; null, l'opérateur désigne le
|
||||||
|
// dossier.
|
||||||
|
const versAilleurs = () => ({ geste: 'ecrireAilleurs', racine: etat.ailleurs });
|
||||||
|
|
||||||
function exigerSupport() {
|
function exigerSupport() {
|
||||||
if (etat.supportRompu) throw new ErreurSeance('SUPPORT_ROMPU', { ...etat.refusSupport });
|
if (etat.supportRompu) throw new ErreurSeance('SUPPORT_ROMPU', { ...etat.refusSupport, remede: versAilleurs() });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Quitter l'événement — ouvrir, créer, fermer — quand le support est
|
// Quitter l'événement — ouvrir, créer, fermer, changer de racine — quand
|
||||||
// rompu perdrait la seule copie qu'écrire ailleurs peut sauver : refusé,
|
// le support est rompu perdrait la seule copie qu'écrire ailleurs peut
|
||||||
// sauf sur abandon explicite.
|
// sauver : refusé, sauf sur abandon explicite.
|
||||||
function exigerSauve({ abandonner = false } = {}) {
|
function exigerSauve({ abandonner = false } = {}) {
|
||||||
if (etat.supportRompu && abandonner !== true) {
|
if (etat.supportRompu && abandonner !== true) {
|
||||||
throw new ErreurSeance('SUPPORT_ROMPU_NON_SAUVE', { ...etat.refusSupport });
|
throw new ErreurSeance('SUPPORT_ROMPU_NON_SAUVE', { ...etat.refusSupport, remede: versAilleurs() });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// L'Ouvert, quand l'événement admet une écriture ; sinon le refus, dans
|
// L'Ouvert, quand l'événement admet une écriture ; sinon le refus, dans
|
||||||
// l'ordre de l'en-tête du module.
|
// l'ordre de l'en-tête du module, son remède compris. Le refus courant de
|
||||||
|
// l'état observable vient d'ici (refusCourant).
|
||||||
function exigerEcriture() {
|
function exigerEcriture() {
|
||||||
const ouvert = exigerOuvert();
|
const ouvert = exigerOuvert();
|
||||||
if (ouvert.formatPlusRecent) throw new ErreurStockage('LECTURE_SEULE', { raison: 'FORMAT' });
|
if (ouvert.formatPlusRecent) throw refusFormat();
|
||||||
if (estBloque(ouvert)) throw new ErreurStockage('LECTURE_SEULE', { raison: 'BLOQUE' });
|
if (estBloque(ouvert)) throw refusBloque();
|
||||||
if (etat.mode === 'lecture') throw new ErreurSeance('MODE_LECTURE');
|
if (etat.mode === 'lecture') throw new ErreurSeance('MODE_LECTURE', { remede: { geste: 'modifier' } });
|
||||||
exigerSupport();
|
exigerSupport();
|
||||||
return ouvert;
|
return ouvert;
|
||||||
}
|
}
|
||||||
|
|
@ -377,24 +597,37 @@ export function creerSeance({
|
||||||
// que le support n'a pas reçu : par défaut le code de l'erreur, la ligne
|
// que le support n'a pas reçu : par défaut le code de l'erreur, la ligne
|
||||||
// ou l'état d'un geste ; JALON_NON_ECRIT, le jalon d'un geste enregistré ;
|
// ou l'état d'un geste ; JALON_NON_ECRIT, le jalon d'un geste enregistré ;
|
||||||
// VERROU_NON_ECRIT, le verrou d'un passage en écriture.
|
// VERROU_NON_ECRIT, le verrou d'un passage en écriture.
|
||||||
function rompre(erreur, ouvert, echec = erreur.code) {
|
// La racine documents, que le remède propose, se demande ensuite au
|
||||||
|
// système : null quand le support rompu est elle, ou que le système ne la
|
||||||
|
// dit pas.
|
||||||
|
async function rompre(erreur, ouvert, echec = erreur.code) {
|
||||||
const { chemin, dossier, cause } = erreur.details;
|
const { chemin, dossier, cause } = erreur.details;
|
||||||
etat.ouvert = ouvert;
|
etat.ouvert = ouvert;
|
||||||
etat.supportRompu = true;
|
etat.supportRompu = true;
|
||||||
etat.refusSupport = { echec, chemin, dossier, cause };
|
etat.refusSupport = { echec, chemin, dossier, cause };
|
||||||
|
etat.ailleurs = null;
|
||||||
|
try {
|
||||||
|
const { documents } = await fs.racines();
|
||||||
|
etat.ailleurs = memeRacine(documents, ouvert.racine) ? null : documents;
|
||||||
|
} catch {
|
||||||
|
etat.ailleurs = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Une écriture du dépôt depuis l'Ouvert reçu ; ce qu'elle rend devient
|
// Une écriture du dépôt depuis l'Ouvert reçu ; ce qu'elle rend devient
|
||||||
// l'Ouvert de la séance. ECRITURE rompt le support en gardant l'Ouvert
|
// l'Ouvert de la séance. ECRITURE rompt le support en gardant l'Ouvert
|
||||||
// reçu, que rien n'a changé ; ETAT_NON_ECRIT, en gardant l'Ouvert avancé de
|
// reçu, que rien n'a changé ; ETAT_NON_ECRIT, en gardant l'Ouvert avancé de
|
||||||
// ses détails, son geste au journal. Le refus remonte.
|
// ses détails, son geste au journal. Le refus remonte.
|
||||||
|
// Une écriture réussie porte l'état au format FORMAT : l'annonce d'un
|
||||||
|
// format plus ancien tombe.
|
||||||
async function ecrire(ouvert, ecriture) {
|
async function ecrire(ouvert, ecriture) {
|
||||||
try {
|
try {
|
||||||
etat.ouvert = await ecriture(ouvert);
|
etat.ouvert = await ecriture(ouvert);
|
||||||
|
etat.formatLu = null;
|
||||||
return etat.ouvert;
|
return etat.ouvert;
|
||||||
} catch (erreur) {
|
} catch (erreur) {
|
||||||
if (erreur instanceof ErreurStockage && erreur.code === 'ECRITURE') rompre(erreur, ouvert);
|
if (erreur instanceof ErreurStockage && erreur.code === 'ECRITURE') await rompre(erreur, ouvert);
|
||||||
if (erreur instanceof ErreurStockage && erreur.code === 'ETAT_NON_ECRIT') rompre(erreur, erreur.details.ouvert);
|
if (erreur instanceof ErreurStockage && erreur.code === 'ETAT_NON_ECRIT') await rompre(erreur, erreur.details.ouvert);
|
||||||
throw erreur;
|
throw erreur;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -423,7 +656,7 @@ export function creerSeance({
|
||||||
etat.ouvert = await nommerSurDisque(ouvert, { nom, ...commun });
|
etat.ouvert = await nommerSurDisque(ouvert, { nom, ...commun });
|
||||||
} catch (erreur) {
|
} catch (erreur) {
|
||||||
if (!(erreur instanceof ErreurStockage && erreur.code === 'ECRITURE')) throw erreur;
|
if (!(erreur instanceof ErreurStockage && erreur.code === 'ECRITURE')) throw erreur;
|
||||||
rompre(erreur, ouvert, 'JALON_NON_ECRIT');
|
await rompre(erreur, ouvert, 'JALON_NON_ECRIT');
|
||||||
const { chemin, dossier, cause } = erreur.details;
|
const { chemin, dossier, cause } = erreur.details;
|
||||||
suite.push(avertissement('JALON_NON_ECRIT', { chemin, dossier, cause }));
|
suite.push(avertissement('JALON_NON_ECRIT', { chemin, dossier, cause }));
|
||||||
}
|
}
|
||||||
|
|
@ -461,20 +694,28 @@ export function creerSeance({
|
||||||
avertissements.push(...(await relireSousVerrou(ouvert)));
|
avertissements.push(...(await relireSousVerrou(ouvert)));
|
||||||
depart = etat.ouvert;
|
depart = etat.ouvert;
|
||||||
try {
|
try {
|
||||||
if (depart.formatPlusRecent) throw new ErreurStockage('LECTURE_SEULE', { raison: 'FORMAT' });
|
if (depart.formatPlusRecent) throw refusFormat();
|
||||||
resultat = appliquerCommande('debloquer', depart.charge, args);
|
resultat = appliquerCommande('debloquer', depart.charge, args);
|
||||||
} catch (erreur) {
|
} catch (erreur) {
|
||||||
etat.avertissementsGeste = [...avertissements, ...(await rendreVerrou(depart))];
|
etat.avertissementsGeste = [...avertissements, ...(await rendreVerrou(depart))];
|
||||||
throw erreur;
|
throw erreur;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
etat.mode = 'ecriture';
|
entrerEnEcriture();
|
||||||
return ecrireGeste(depart, { ...resultat, avertissements: [...avertissements, ...resultat.avertissements] });
|
return ecrireGeste(depart, { ...resultat, avertissements: [...avertissements, ...resultat.avertissements] });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// La séance passe en écriture : le retour automatique précédent ne se dit
|
||||||
|
// plus.
|
||||||
|
function entrerEnEcriture() {
|
||||||
|
etat.mode = 'ecriture';
|
||||||
|
etat.retourAutomatique = false;
|
||||||
|
}
|
||||||
|
|
||||||
// Un geste nommé, sans son suivi : le refus, ou le libellé et ce que la
|
// Un geste nommé, sans son suivi : le refus, ou le libellé et ce que la
|
||||||
// commande ajoute. Un rendu au libellé null n'écrit rien : la séance rend
|
// commande ajoute, puis ce que le geste fait naître, de la charge d'avant à
|
||||||
// son résumé, le libellé null.
|
// celle qu'il écrit (avertissementsApres). Un rendu au libellé null
|
||||||
|
// n'écrit rien : la séance rend son résumé, le libellé null.
|
||||||
async function geste(nom, args) {
|
async function geste(nom, args) {
|
||||||
const ouvert = exigerOuvert();
|
const ouvert = exigerOuvert();
|
||||||
if (nom === 'debloquer' && !ouvert.formatPlusRecent && estBloque(ouvert)) {
|
if (nom === 'debloquer' && !ouvert.formatPlusRecent && estBloque(ouvert)) {
|
||||||
|
|
@ -487,7 +728,8 @@ export function creerSeance({
|
||||||
return { libelle: null, resume: resultat.resume };
|
return { libelle: null, resume: resultat.resume };
|
||||||
}
|
}
|
||||||
const jalonAvant = nom === 'enregistrerGeneration' ? libelle('JALON_AVANT_GENERATION') : null;
|
const jalonAvant = nom === 'enregistrerGeneration' ? libelle('JALON_AVANT_GENERATION') : null;
|
||||||
const texte = await ecrireGeste(ouvert, resultat, { jalonAvant });
|
const avertissements = [...resultat.avertissements, ...avertissementsApres(ouvert.charge, resultat.charge)];
|
||||||
|
const texte = await ecrireGeste(ouvert, { ...resultat, avertissements }, { jalonAvant });
|
||||||
return resultat.resume === undefined ? { libelle: texte } : { libelle: texte, resume: resultat.resume };
|
return resultat.resume === undefined ? { libelle: texte } : { libelle: texte, resume: resultat.resume };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -505,6 +747,14 @@ export function creerSeance({
|
||||||
|
|
||||||
// --- Les retours (§ 8.3) -----------------------------------------------------
|
// --- Les retours (§ 8.3) -----------------------------------------------------
|
||||||
|
|
||||||
|
// {defaire, refaire} : les retours qui existent sur le fil de l'Ouvert ;
|
||||||
|
// faux sans événement.
|
||||||
|
function retoursDe(ouvert) {
|
||||||
|
if (ouvert === null) return { defaire: false, refaire: false };
|
||||||
|
const { cibleDefaire, cibleRefaire } = filDe(ouvert);
|
||||||
|
return { defaire: cibleDefaire !== null, refaire: cibleRefaire !== null };
|
||||||
|
}
|
||||||
|
|
||||||
// Le fil de l'Ouvert : les cibles de défaire et de refaire, null quand
|
// Le fil de l'Ouvert : les cibles de défaire et de refaire, null quand
|
||||||
// elles n'existent pas, et le libellé de l'instant courant. Quand le
|
// elles n'existent pas, et le libellé de l'instant courant. Quand le
|
||||||
// journal finit sur une autre révision que la charge ouverte, ou manque, la
|
// journal finit sur une autre révision que la charge ouverte, ou manque, la
|
||||||
|
|
@ -580,11 +830,14 @@ export function creerSeance({
|
||||||
|
|
||||||
// --- Ouvrir, créer, fermer (§ 8.1, § 8.4) ----------------------------------------
|
// --- Ouvrir, créer, fermer (§ 8.1, § 8.4) ----------------------------------------
|
||||||
|
|
||||||
// Ferme l'événement ouvert : le compte s'arrête, le verrou se rend, et la
|
// Ferme l'événement ouvert : le compte s'arrête, le cadrage en attente
|
||||||
// séance oublie l'événement. Rend les avertissements du rendu.
|
// s'écrit, le verrou se rend, surAnnulation l'annonce, et la séance oublie
|
||||||
|
// l'événement. Rend les avertissements du rendu.
|
||||||
async function fermerEvenement() {
|
async function fermerEvenement() {
|
||||||
suspendre();
|
suspendre();
|
||||||
|
await viderCadrage();
|
||||||
const avertissements = etat.ouvert === null ? [] : await rendreVerrou(etat.ouvert);
|
const avertissements = etat.ouvert === null ? [] : await rendreVerrou(etat.ouvert);
|
||||||
|
if (etat.ouvert !== null) annoncerAnnulation();
|
||||||
Object.assign(etat, {
|
Object.assign(etat, {
|
||||||
ouvert: null,
|
ouvert: null,
|
||||||
source: null,
|
source: null,
|
||||||
|
|
@ -592,22 +845,32 @@ export function creerSeance({
|
||||||
verrouTenu: false,
|
verrouTenu: false,
|
||||||
supportRompu: false,
|
supportRompu: false,
|
||||||
refusSupport: null,
|
refusSupport: null,
|
||||||
|
ailleurs: null,
|
||||||
verrouAutre: null,
|
verrouAutre: null,
|
||||||
avertissementsGeste: [],
|
avertissementsGeste: [],
|
||||||
gestes: 0,
|
gestes: 0,
|
||||||
calculs: 0,
|
calculs: 0,
|
||||||
|
fautives: [],
|
||||||
|
formatLu: null,
|
||||||
|
retourAutomatique: false,
|
||||||
|
cadrage: null,
|
||||||
});
|
});
|
||||||
return avertissements;
|
return avertissements;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ouvre en lecture l'Ouvert que rend lire, sans rien écrire, après avoir
|
// Ouvre en lecture l'Ouvert que rend lire, sans rien écrire, après avoir
|
||||||
// fermé le précédent ; source dit d'où il se relit (relireSousVerrou).
|
// fermé le précédent ; source dit d'où il se relit (relireSousVerrou).
|
||||||
|
// L'ouverture garde les propositions qu'elle écarte, le format de l'état
|
||||||
|
// lu et le cadrage mémorisé de l'événement.
|
||||||
async function ouvrirAvec(lire, source, quitter) {
|
async function ouvrirAvec(lire, source, quitter) {
|
||||||
exigerSauve(quitter);
|
exigerSauve(quitter);
|
||||||
await fermerEvenement();
|
await fermerEvenement();
|
||||||
const ouvert = await lire();
|
const ouvert = await lire();
|
||||||
etat.ouvert = ouvert;
|
etat.ouvert = ouvert;
|
||||||
etat.source = source;
|
etat.source = source;
|
||||||
|
etat.fautives = ouvert.examen.fautives;
|
||||||
|
etat.formatLu = formatDe(ouvert.texteEtat);
|
||||||
|
etat.cadrage = await cadrageMemorise(ouvert);
|
||||||
return { base: ouvert.base, avertissements: ouvert.avertissements };
|
return { base: ouvert.base, avertissements: ouvert.avertissements };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -631,7 +894,7 @@ export function creerSeance({
|
||||||
try {
|
try {
|
||||||
etat.ouvert = await creerSurDisque(fs, etat.racine, charge, options);
|
etat.ouvert = await creerSurDisque(fs, etat.racine, charge, options);
|
||||||
} catch (erreur) {
|
} catch (erreur) {
|
||||||
if (erreur instanceof ErreurStockage && erreur.code === 'ETAT_NON_ECRIT') rompre(erreur, erreur.details.ouvert);
|
if (erreur instanceof ErreurStockage && erreur.code === 'ETAT_NON_ECRIT') await rompre(erreur, erreur.details.ouvert);
|
||||||
throw erreur;
|
throw erreur;
|
||||||
}
|
}
|
||||||
return { base: etat.ouvert.base };
|
return { base: etat.ouvert.base };
|
||||||
|
|
@ -644,21 +907,21 @@ export function creerSeance({
|
||||||
// et la séance reste en lecture sur ce qu'elle a relu, le refus levé.
|
// et la séance reste en lecture sur ce qu'elle a relu, le refus levé.
|
||||||
async function passerEnEcriture() {
|
async function passerEnEcriture() {
|
||||||
const ouvert = exigerOuvert();
|
const ouvert = exigerOuvert();
|
||||||
if (ouvert.formatPlusRecent) throw new ErreurStockage('LECTURE_SEULE', { raison: 'FORMAT' });
|
if (ouvert.formatPlusRecent) throw refusFormat();
|
||||||
if (estBloque(ouvert)) throw new ErreurStockage('LECTURE_SEULE', { raison: 'BLOQUE' });
|
if (estBloque(ouvert)) throw refusBloque();
|
||||||
if (etat.mode === 'ecriture') return { avertissements: [] };
|
if (etat.mode === 'ecriture') return { avertissements: [] };
|
||||||
exigerSupport();
|
exigerSupport();
|
||||||
const avertissements = await prendreVerrou(ouvert);
|
const avertissements = await prendreVerrou(ouvert);
|
||||||
avertissements.push(...(await relireSousVerrou(ouvert)));
|
avertissements.push(...(await relireSousVerrou(ouvert)));
|
||||||
const relu = etat.ouvert;
|
const relu = etat.ouvert;
|
||||||
let refus = null;
|
let refus = null;
|
||||||
if (relu.formatPlusRecent) refus = new ErreurStockage('LECTURE_SEULE', { raison: 'FORMAT' });
|
if (relu.formatPlusRecent) refus = refusFormat();
|
||||||
else if (estBloque(relu)) refus = new ErreurStockage('LECTURE_SEULE', { raison: 'BLOQUE' });
|
else if (estBloque(relu)) refus = refusBloque();
|
||||||
if (refus !== null) {
|
if (refus !== null) {
|
||||||
etat.avertissementsGeste = [...avertissements, ...(await rendreVerrou(relu))];
|
etat.avertissementsGeste = [...avertissements, ...(await rendreVerrou(relu))];
|
||||||
throw refus;
|
throw refus;
|
||||||
}
|
}
|
||||||
etat.mode = 'ecriture';
|
entrerEnEcriture();
|
||||||
etat.avertissementsGeste = avertissements;
|
etat.avertissementsGeste = avertissements;
|
||||||
armer();
|
armer();
|
||||||
return { avertissements };
|
return { avertissements };
|
||||||
|
|
@ -723,10 +986,15 @@ export function creerSeance({
|
||||||
// type du support de la racine nouvelle, et SUPPORT_AMOVIBLE {dossier}
|
// type du support de la racine nouvelle, et SUPPORT_AMOVIBLE {dossier}
|
||||||
// annonce la garantie affaiblie d'un support amovible, comme la règle du
|
// annonce la garantie affaiblie d'un support amovible, comme la règle du
|
||||||
// dossier de travail au démarrage (§ 8.8).
|
// dossier de travail au démarrage (§ 8.8).
|
||||||
async function ecrireAilleurs(nouvelle) {
|
//
|
||||||
|
// Sans racine, le dossier se demande à l'opérateur par fs.choisirDossier ;
|
||||||
|
// son annulation ne change rien, et rend null.
|
||||||
|
async function ecrireAilleurs(designee) {
|
||||||
const ouvert = exigerOuvert();
|
const ouvert = exigerOuvert();
|
||||||
if (ouvert.formatPlusRecent) throw new ErreurStockage('LECTURE_SEULE', { raison: 'FORMAT' });
|
if (ouvert.formatPlusRecent) throw refusFormat();
|
||||||
if (!etat.supportRompu) throw new ErreurSeance('SUPPORT_INTACT');
|
if (!etat.supportRompu) throw new ErreurSeance('SUPPORT_INTACT');
|
||||||
|
const nouvelle = designee ?? (await fs.choisirDossier());
|
||||||
|
if (nouvelle === null) return null;
|
||||||
const present = await dejaPresent(nouvelle, ouvert.charge.evenement.id);
|
const present = await dejaPresent(nouvelle, ouvert.charge.evenement.id);
|
||||||
if (present !== null) throw new ErreurStockage('IDENTIFIANT_PRESENT', { id: present.id, base: present.base });
|
if (present !== null) throw new ErreurStockage('IDENTIFIANT_PRESENT', { id: present.id, base: present.base });
|
||||||
const options = { existantes: [], libelleReprise, horodatage: horloge.horodatage() };
|
const options = { existantes: [], libelleReprise, horodatage: horloge.horodatage() };
|
||||||
|
|
@ -738,13 +1006,21 @@ export function creerSeance({
|
||||||
suspendre();
|
suspendre();
|
||||||
etat.mode = 'lecture';
|
etat.mode = 'lecture';
|
||||||
etat.racine = nouvelle;
|
etat.racine = nouvelle;
|
||||||
rompre(erreur, erreur.details.ouvert);
|
await rompre(erreur, erreur.details.ouvert);
|
||||||
etat.avertissementsGeste = await rendreVerrou(ouvert);
|
etat.avertissementsGeste = await rendreVerrou(ouvert);
|
||||||
}
|
}
|
||||||
throw erreur;
|
throw erreur;
|
||||||
}
|
}
|
||||||
const avertissements = await rendreVerrou(ouvert);
|
const avertissements = await rendreVerrou(ouvert);
|
||||||
Object.assign(etat, { ouvert: ailleurs, source: null, racine: nouvelle, supportRompu: false, refusSupport: null });
|
Object.assign(etat, {
|
||||||
|
ouvert: ailleurs,
|
||||||
|
source: null,
|
||||||
|
racine: nouvelle,
|
||||||
|
supportRompu: false,
|
||||||
|
refusSupport: null,
|
||||||
|
ailleurs: null,
|
||||||
|
formatLu: null,
|
||||||
|
});
|
||||||
if (etat.mode === 'ecriture') {
|
if (etat.mode === 'ecriture') {
|
||||||
try {
|
try {
|
||||||
avertissements.push(...(await prendreVerrou(ailleurs)));
|
avertissements.push(...(await prendreVerrou(ailleurs)));
|
||||||
|
|
@ -806,15 +1082,13 @@ export function creerSeance({
|
||||||
get verrouAutre() {
|
get verrouAutre() {
|
||||||
return etat.verrouAutre;
|
return etat.verrouAutre;
|
||||||
},
|
},
|
||||||
/** Ceux de l'Ouvert courant, recalculés à chaque écriture, puis ceux du dernier geste. */
|
/** Ceux de l'état observable, dans son ordre (etat().avertissements). */
|
||||||
get avertissements() {
|
get avertissements() {
|
||||||
return [...(etat.ouvert?.avertissements ?? []), ...etat.avertissementsGeste];
|
return [...lireEtat().avertissements];
|
||||||
},
|
},
|
||||||
/** {defaire, refaire} : les retours qui existent sur le fil courant (§ 8.3). */
|
/** {defaire, refaire} : les retours qui existent sur le fil courant (§ 8.3). */
|
||||||
get retours() {
|
get retours() {
|
||||||
if (etat.ouvert === null) return { defaire: false, refaire: false };
|
return retoursDe(etat.ouvert);
|
||||||
const { cibleDefaire, cibleRefaire } = filDe(etat.ouvert);
|
|
||||||
return { defaire: cibleDefaire !== null, refaire: cibleRefaire !== null };
|
|
||||||
},
|
},
|
||||||
get gesteEnCours() {
|
get gesteEnCours() {
|
||||||
return etat.gestes > 0;
|
return etat.gestes > 0;
|
||||||
|
|
@ -823,6 +1097,55 @@ export function creerSeance({
|
||||||
return etat.calculs > 0;
|
return etat.calculs > 0;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* L'état observable (EtatSeance), figé en profondeur : le même objet
|
||||||
|
* entre deux changements, un neuf après chacun.
|
||||||
|
*/
|
||||||
|
etat: () => lireEtat(),
|
||||||
|
/**
|
||||||
|
* Le contrat des magasins de Svelte : fn(etat) tout de suite, puis à
|
||||||
|
* chaque changement ; rend la fonction qui désabonne.
|
||||||
|
*/
|
||||||
|
subscribe(fn) {
|
||||||
|
abonnes.add(fn);
|
||||||
|
fn(lireEtat());
|
||||||
|
return () => {
|
||||||
|
abonnes.delete(fn);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* fn() au passage en lecture, explicite ou automatique, et à la
|
||||||
|
* fermeture de l'événement : le geste en cours s'annule. Rend la
|
||||||
|
* fonction qui désabonne.
|
||||||
|
*/
|
||||||
|
surAnnulation(fn) {
|
||||||
|
annulations.add(fn);
|
||||||
|
return () => {
|
||||||
|
annulations.delete(fn);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
/** {k, tx, ty} : le cadrage de l'événement ouvert, mémorisé ou demandé ; null sinon. */
|
||||||
|
cadrage: () => (etat.cadrage === null ? null : { ...etat.cadrage }),
|
||||||
|
/**
|
||||||
|
* Demande l'écriture différée du cadrage de l'événement ouvert dans
|
||||||
|
* reglages_locaux.json (§ 8.5), vivante dans tout mode : l'état et le
|
||||||
|
* journal ne s'écrivent pas. Une vue hors de sa règle — k nul, négatif
|
||||||
|
* ou non fini, translation non finie — n'est pas demandée ; sans
|
||||||
|
* événement, rien.
|
||||||
|
*/
|
||||||
|
demanderCadrage(vue) {
|
||||||
|
if (etat.ouvert === null) return;
|
||||||
|
const evenement = etat.ouvert.charge.evenement.id;
|
||||||
|
const cadrage = { k: vue?.k, tx: vue?.tx, ty: vue?.ty };
|
||||||
|
try {
|
||||||
|
avecCadrage({ cadrages: [] }, evenement, cadrage);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
etat.cadrage = cadrage;
|
||||||
|
ecrivain.demander({ racine: etat.ouvert.racine, evenement, cadrage });
|
||||||
|
},
|
||||||
|
|
||||||
// quitter.abandonner : vrai pour quitter l'événement d'un support rompu
|
// quitter.abandonner : vrai pour quitter l'événement d'un support rompu
|
||||||
// (exigerSauve).
|
// (exigerSauve).
|
||||||
ouvrir: (base, quitter) => enFile(() => ouvrirAvec(() => ouvrirSurDisque(fs, etat.racine, base), null, quitter)),
|
ouvrir: (base, quitter) => enFile(() => ouvrirAvec(() => ouvrirSurDisque(fs, etat.racine, base), null, quitter)),
|
||||||
|
|
@ -834,6 +1157,18 @@ export function creerSeance({
|
||||||
exigerSauve(quitter);
|
exigerSauve(quitter);
|
||||||
return { avertissements: await fermerEvenement() };
|
return { avertissements: await fermerEvenement() };
|
||||||
}),
|
}),
|
||||||
|
/**
|
||||||
|
* Ferme l'événement ouvert, comme fermer, puis fait de racine le dossier
|
||||||
|
* de travail, où la séance ouvre et crée désormais ; rien ne s'écrit
|
||||||
|
* sans événement ouvert.
|
||||||
|
*/
|
||||||
|
changerRacine: (nouvelle, quitter) =>
|
||||||
|
enFile(async () => {
|
||||||
|
exigerSauve(quitter);
|
||||||
|
const avertissements = await fermerEvenement();
|
||||||
|
etat.racine = nouvelle;
|
||||||
|
return { avertissements };
|
||||||
|
}),
|
||||||
passerEnEcriture: () => enFile(passerEnEcriture),
|
passerEnEcriture: () => enFile(passerEnEcriture),
|
||||||
passerEnLecture: () => enFile(passerEnLecture),
|
passerEnLecture: () => enFile(passerEnLecture),
|
||||||
executer: (nom, args = {}) => enFile(() => suivi(() => geste(nom, args))),
|
executer: (nom, args = {}) => enFile(() => suivi(() => geste(nom, args))),
|
||||||
|
|
@ -862,10 +1197,12 @@ export function creerSeance({
|
||||||
debuterCalcul() {
|
debuterCalcul() {
|
||||||
etat.calculs += 1;
|
etat.calculs += 1;
|
||||||
suspendre();
|
suspendre();
|
||||||
|
publier();
|
||||||
},
|
},
|
||||||
finirCalcul() {
|
finirCalcul() {
|
||||||
etat.calculs = Math.max(0, etat.calculs - 1);
|
etat.calculs = Math.max(0, etat.calculs - 1);
|
||||||
armer();
|
armer();
|
||||||
|
publier();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -37,7 +37,7 @@ import { choisirSeparateur, decouper, enregistrementVide } from './lecture.js';
|
||||||
import { cleNormalisee } from './normalisation.js';
|
import { cleNormalisee } from './normalisation.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Modes de l'import (§ 10.1), que l'aperçu et l'import reçoivent tous deux.
|
* Modes de l'import, que l'aperçu et l'import reçoivent tous deux (§ 10.1).
|
||||||
*
|
*
|
||||||
* @type {readonly string[]}
|
* @type {readonly string[]}
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -67,43 +67,122 @@ const ENTETE_MOTIF = 'motif';
|
||||||
|
|
||||||
const parLigne = (a, b) => a.ligne - b.ligne;
|
const parLigne = (a, b) => a.ligne - b.ligne;
|
||||||
|
|
||||||
// Titres pourvus (§ 4.1, § 4.4). Un titre n'est pourvu que par la réservation
|
// L'ordre canonique des réservations, celui du schéma du fichier d'état :
|
||||||
// d'une personne non exclue, sur sa place, à un tour au moins : la
|
// participant, table, la portée « tous » avant « tour », puis le tour et le
|
||||||
// réservation d'une personne exclue est suspendue. La place se lit selon le
|
// siège, null en tête. Il départage, partout où la règle des titres et
|
||||||
// réglage attribuerSieges (§ 2.1, étape 6). Vrai, c'est un siège : une
|
// l'état des places choisissent entre deux réservations.
|
||||||
// réservation sans siège n'y pourvoit aucun titre. Faux, c'est la table, dont
|
const rangDePortee = (portee) => (portee === 'tous' ? 0 : 1);
|
||||||
// les titres ne se distinguent pas : à un tour donné, une table de k titres
|
const nulEnTete = (a, b) => (a === b ? 0 : a === null ? -1 : b === null ? 1 : a - b);
|
||||||
// en a autant de pourvus que de personnes non exclues qui y sont réservées à
|
export const ordreDesReservations = (a, b) =>
|
||||||
// ce tour, k au plus, et ses titres pourvus sont ceux du tour qui en pourvoit
|
a.participant - b.participant ||
|
||||||
// le plus.
|
a.table - b.table ||
|
||||||
function titresPourvus({ evenement, reglages, participants, reservations, titres }) {
|
rangDePortee(a.portee) - rangDePortee(b.portee) ||
|
||||||
const presents = new Set(participants.filter(({ exclu }) => !exclu).map(({ id }) => id));
|
nulEnTete(a.tour, b.tour) ||
|
||||||
const actives = reservations.filter(({ participant }) => presents.has(participant));
|
nulEnTete(a.siege, b.siege);
|
||||||
if (reglages.attribuerSieges) {
|
|
||||||
// Un titre porte toujours un siège : la place d'une réservation sans
|
// Deux chaînes, comparées unité UTF-16 par unité.
|
||||||
// siège, [table, null], n'est celle d'aucun titre.
|
const comparerTextes = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
||||||
const place = (table, siege) => JSON.stringify([table, siege]);
|
|
||||||
const occupees = new Set(actives.map(({ table, siege }) => place(table, siege)));
|
// Les titres d'une table, par rang dans charge.titres, rangés par siège puis
|
||||||
return titres.filter(({ table, siege }) => occupees.has(place(table, siege))).length;
|
// par libellé : l'ordre où les réservations sans siège les pourvoient.
|
||||||
|
function titresParTable(titres) {
|
||||||
|
const parTable = new Map();
|
||||||
|
titres.forEach((titre, rang) => {
|
||||||
|
if (!parTable.has(titre.table)) parTable.set(titre.table, []);
|
||||||
|
parTable.get(titre.table).push(rang);
|
||||||
|
});
|
||||||
|
for (const rangs of parTable.values()) {
|
||||||
|
rangs.sort((a, b) => titres[a].siege - titres[b].siege || comparerTextes(titres[a].libelle, titres[b].libelle) || a - b);
|
||||||
}
|
}
|
||||||
const titresParTable = new Map();
|
return parTable;
|
||||||
for (const { table } of titres) titresParTable.set(table, (titresParTable.get(table) ?? 0) + 1);
|
|
||||||
let pourvus = 0;
|
|
||||||
for (const [table, nombre] of titresParTable) {
|
|
||||||
let auMieux = 0;
|
|
||||||
for (let tour = 1; tour <= evenement.tours; tour += 1) {
|
|
||||||
const reserves = new Set(
|
|
||||||
actives
|
|
||||||
.filter((r) => r.table === table && (r.portee === 'tous' || r.tour === tour))
|
|
||||||
.map(({ participant }) => participant),
|
|
||||||
);
|
|
||||||
auMieux = Math.max(auMieux, Math.min(nombre, reserves.size));
|
|
||||||
}
|
|
||||||
pourvus += auMieux;
|
|
||||||
}
|
|
||||||
return pourvus;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Répartit entre les titres d'une table — rangs, dans leur ordre — les
|
||||||
|
// réservations couvrantes, déjà rangées dans l'ordre canonique, et rend
|
||||||
|
// Map rang → réservation qui le pourvoit. D'abord chaque réservation à un
|
||||||
|
// siège pourvoit les titres de ce siège encore libres. Puis, en mode table
|
||||||
|
// seulement, chaque personne qu'aucune réservation à un siège de cette table
|
||||||
|
// ne place pourvoit le premier titre resté libre : celles qui tiennent une
|
||||||
|
// réservation « tous » d'abord, par identifiant croissant, puis les autres ;
|
||||||
|
// une personne en pourvoit un au plus. Sous des sièges attribués, une
|
||||||
|
// réservation sans siège ne pourvoit rien : un titre porte toujours un siège.
|
||||||
|
function repartirTitres(rangs, titres, couvrantes, attribuerSieges) {
|
||||||
|
const pris = new Map();
|
||||||
|
const assises = new Set();
|
||||||
|
for (const reservation of couvrantes) {
|
||||||
|
if (reservation.siege === null) continue;
|
||||||
|
assises.add(reservation.participant);
|
||||||
|
for (const rang of rangs) {
|
||||||
|
if (!pris.has(rang) && titres[rang].siege === reservation.siege) pris.set(rang, reservation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (attribuerSieges) return pris;
|
||||||
|
const sansSiege = new Map();
|
||||||
|
for (const reservation of couvrantes) {
|
||||||
|
if (reservation.siege !== null || assises.has(reservation.participant)) continue;
|
||||||
|
const deja = sansSiege.get(reservation.participant);
|
||||||
|
if (deja === undefined || (deja.portee !== 'tous' && reservation.portee === 'tous')) {
|
||||||
|
sansSiege.set(reservation.participant, reservation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ordre = [...sansSiege.values()].sort(
|
||||||
|
(a, b) => rangDePortee(a.portee) - rangDePortee(b.portee) || a.participant - b.participant,
|
||||||
|
);
|
||||||
|
const libres = rangs.filter((rang) => !pris.has(rang));
|
||||||
|
ordre.forEach((reservation, i) => {
|
||||||
|
if (i < libres.length) pris.set(libres[i], reservation);
|
||||||
|
});
|
||||||
|
return pris;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* La règle d'un titre pourvu (§ 4.1, § 4.4, § 2.1 étape 6), seule
|
||||||
|
* implémentation : bilanRemplacement en compte les titres, l'application en
|
||||||
|
* tire ses listes de travail et l'état de ses places. Un titre n'est pourvu
|
||||||
|
* que par la réservation d'une personne non exclue — la réservation d'une
|
||||||
|
* exclue est suspendue — qui tient sa place : même table, et même siège
|
||||||
|
* quand la réservation en porte un. Sous des sièges attribués, une
|
||||||
|
* réservation sans siège ne pourvoit aucun titre. En mode table, une
|
||||||
|
* réservation sans siège pourvoit au plus un titre de sa table, dans l'ordre
|
||||||
|
* des sièges des titres, après ceux que les réservations à un siège
|
||||||
|
* pourvoient (repartirTitres).
|
||||||
|
*
|
||||||
|
* Rend, rang à rang de charge.titres, { soiree, tours } : soiree, la
|
||||||
|
* réservation « tous les tours » qui le pourvoit pour la soirée, null sans
|
||||||
|
* elle — la répartition des seules réservations « tous », la même à chaque
|
||||||
|
* tour ; tours[r − 1], la réservation qui le pourvoit au tour r, parmi
|
||||||
|
* toutes celles qui couvrent ce tour, null sans elle. Une réservation d'un
|
||||||
|
* tour désigné pourvoit le titre à ce tour seulement. La charge n'est pas
|
||||||
|
* modifiée.
|
||||||
|
*
|
||||||
|
* @param {import('../stockage/types.js').Charge} charge
|
||||||
|
* @returns {Array<{soiree: Object|null, tours: Array<Object|null>}>}
|
||||||
|
*/
|
||||||
|
export function pourvoyeursDesTitres({ evenement, reglages, participants, reservations, titres }) {
|
||||||
|
const presents = new Set(participants.filter(({ exclu }) => !exclu).map(({ id }) => id));
|
||||||
|
const actives = reservations.filter(({ participant }) => presents.has(participant)).sort(ordreDesReservations);
|
||||||
|
const rendus = titres.map(() => ({ soiree: null, tours: Array.from({ length: evenement.tours }, () => null) }));
|
||||||
|
for (const [idTable, rangs] of titresParTable(titres)) {
|
||||||
|
const aLaTable = actives.filter(({ table }) => table === idTable);
|
||||||
|
const tous = aLaTable.filter(({ portee }) => portee === 'tous');
|
||||||
|
for (const [rang, reservation] of repartirTitres(rangs, titres, tous, reglages.attribuerSieges)) {
|
||||||
|
rendus[rang].soiree = reservation;
|
||||||
|
}
|
||||||
|
for (let tour = 1; tour <= evenement.tours; tour += 1) {
|
||||||
|
const couvrantes = aLaTable.filter((r) => r.portee === 'tous' || r.tour === tour);
|
||||||
|
for (const [rang, reservation] of repartirTitres(rangs, titres, couvrantes, reglages.attribuerSieges)) {
|
||||||
|
rendus[rang].tours[tour - 1] = reservation;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rendus;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Titres pourvus à un tour au moins (§ 4.1, § 4.4) : ceux qu'un
|
||||||
|
// remplacement laisserait non pourvus.
|
||||||
|
const titresPourvus = (charge) =>
|
||||||
|
pourvoyeursDesTitres(charge).filter(({ soiree, tours }) => soiree !== null || tours.some((r) => r !== null)).length;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ce que remplacer détruirait, à annoncer avant d'agir (§ 10.1) : « 260
|
* Ce que remplacer détruirait, à annoncer avant d'agir (§ 10.1) : « 260
|
||||||
* personnes seront supprimées » laisse croire qu'on ne perd que des noms.
|
* personnes seront supprimées » laisse croire qu'on ne perd que des noms.
|
||||||
|
|
@ -114,10 +193,9 @@ function titresPourvus({ evenement, reglages, participants, reservations, titres
|
||||||
* - reservations : leurs réservations, suspendues comprises, soit toutes :
|
* - reservations : leurs réservations, suspendues comprises, soit toutes :
|
||||||
* chaque réservation d'une charge que l'analyse admet est celle d'un
|
* chaque réservation d'une charge que l'analyse admet est celle d'un
|
||||||
* participant ;
|
* participant ;
|
||||||
* - titresPourvus : les titres qu'une réservation pourvoit, et que le
|
* - titresPourvus : les titres qu'une réservation pourvoit à un tour au
|
||||||
* remplacement laisse non pourvus — par la réservation d'une personne non
|
* moins, et que le remplacement laisse non pourvus, selon la règle de
|
||||||
* exclue, sur son siège quand les sièges sont attribués, sur sa table
|
* pourvoyeursDesTitres ;
|
||||||
* sinon, à un tour au moins ;
|
|
||||||
* - propositions : les propositions qui placent au moins une personne
|
* - propositions : les propositions qui placent au moins une personne
|
||||||
* retirée. Le retenu n'y entre pas : l'import en état retenu porte
|
* retirée. Le retenu n'y entre pas : l'import en état retenu porte
|
||||||
* l'avertissement de dérive.
|
* l'avertissement de dérive.
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import { VERSION } from '../version.genere.js';
|
||||||
import { apercevoir } from './apercu.js';
|
import { apercevoir } from './apercu.js';
|
||||||
import { decoder } from './encodage.js';
|
import { decoder } from './encodage.js';
|
||||||
import { ErreurCsv } from './erreurs.js';
|
import { ErreurCsv } from './erreurs.js';
|
||||||
import { appliquerImport, bilanRemplacement, exporterRefus } from './import.js';
|
import { appliquerImport, bilanRemplacement, exporterRefus, pourvoyeursDesTitres } from './import.js';
|
||||||
import { decouper } from './lecture.js';
|
import { decouper } from './lecture.js';
|
||||||
|
|
||||||
const MARQUE = '\u{FEFF}';
|
const MARQUE = '\u{FEFF}';
|
||||||
|
|
@ -553,6 +553,36 @@ describe('appliquerImport : remplacer, et ce que remplacer annonce (§ 10.1)', (
|
||||||
assert.equal(bilanRemplacement(chargeGarnie()).titresPourvus, 3);
|
assert.equal(bilanRemplacement(chargeGarnie()).titresPourvus, 3);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('lecture par table : une réservation qui porte un siège ne pourvoit que le titre de ce siège', () => {
|
||||||
|
const charge = chargeDe({
|
||||||
|
participants: [personne(1, 'Ombrelle', 'Iris'), personne(2, 'Pervenche', 'Théo')],
|
||||||
|
tables: [table(1)],
|
||||||
|
reservations: [reservation(1, 1, 5)],
|
||||||
|
titres: [titre(1, 1)],
|
||||||
|
});
|
||||||
|
charge.evenement.siegesParDefaut = 6;
|
||||||
|
assert.equal(bilanRemplacement(charge).titresPourvus, 0);
|
||||||
|
charge.reservations.push(reservation(2, 1, null));
|
||||||
|
assert.equal(bilanRemplacement(charge).titresPourvus, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lecture par table, à un tour : les réservations à un siège pourvoient leur titre, puis chaque personne sans siège un des titres restants', () => {
|
||||||
|
const charge = chargeDe({
|
||||||
|
participants: [personne(1, 'Ombrelle', 'Iris'), personne(2, 'Pervenche', 'Théo'), personne(3, 'Lacasse', 'Ondine')],
|
||||||
|
tables: [table(1)],
|
||||||
|
reservations: [reservation(1, 1, null), reservation(2, 1, 1, 2)],
|
||||||
|
titres: [titre(1, 1), titre(1, 2), titre(1, 3)],
|
||||||
|
});
|
||||||
|
// Tour 1 : Iris pourvoit le premier titre ; tour 2 : Théo le titre de
|
||||||
|
// son siège, Iris le suivant. Deux titres pourvus à un tour au moins.
|
||||||
|
assert.deepEqual(pourvoyeursDesTitres(charge).map(({ soiree, tours }) => [soiree?.participant ?? null, tours.map((r) => r?.participant ?? null)]), [
|
||||||
|
[1, [1, 2]],
|
||||||
|
[null, [null, 1]],
|
||||||
|
[null, [null, null]],
|
||||||
|
]);
|
||||||
|
assert.equal(bilanRemplacement(charge).titresPourvus, 2);
|
||||||
|
});
|
||||||
|
|
||||||
test("une proposition qui ne place aucune des personnes de la liste n'est pas touchée", () => {
|
test("une proposition qui ne place aucune des personnes de la liste n'est pas touchée", () => {
|
||||||
const charge = chargeDe({
|
const charge = chargeDe({
|
||||||
participants: [personne(1, 'Ombrelle'), personne(2, 'Pervenche')],
|
participants: [personne(1, 'Ombrelle'), personne(2, 'Pervenche')],
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,8 @@
|
||||||
// peintes que sans opacité partielle, filtre ni mode de fusion — ce qu'une
|
// peintes que sans opacité partielle, filtre ni mode de fusion — ce qu'une
|
||||||
// épreuve vérifie dans l'ascendance de chaque texte (§ 14.5). Les épreuves de
|
// épreuve vérifie dans l'ascendance de chaque texte (§ 14.5). Les épreuves de
|
||||||
// visibilité, de contraste et d'opacité passent par les deux thèmes, que
|
// visibilité, de contraste et d'opacité passent par les deux thèmes, que
|
||||||
// l'attribut data-theme de <html> choisit (§ 19.2). Le contraste est celui de
|
// l'attribut data-theme de <html> choisit (§ 19.2). Les mesures viennent du
|
||||||
|
// module partagé test/navigateur/rendu.js, dont le contraste est celui de
|
||||||
// palette.js, seule formule des WCAG du dépôt.
|
// palette.js, seule formule des WCAG du dépôt.
|
||||||
//
|
//
|
||||||
// Le projet navigateur n'importe pas node:assert/strict : côté navigateur,
|
// Le projet navigateur n'importe pas node:assert/strict : côté navigateur,
|
||||||
|
|
@ -20,79 +21,22 @@
|
||||||
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
|
||||||
import { flushSync, mount, unmount } from 'svelte';
|
import { flushSync, mount, unmount } from 'svelte';
|
||||||
import App from './App.svelte';
|
import App from './App.svelte';
|
||||||
import { contraste } from './palette.js';
|
|
||||||
import { VERSION } from '../version.genere.js';
|
import { VERSION } from '../version.genere.js';
|
||||||
|
import {
|
||||||
|
attenuations,
|
||||||
|
composantes,
|
||||||
|
contraste,
|
||||||
|
couleurTexte,
|
||||||
|
designation,
|
||||||
|
fondPeintSous,
|
||||||
|
porteursDeTexte,
|
||||||
|
sousTheme,
|
||||||
|
visible,
|
||||||
|
} from '../../test/navigateur/rendu.js';
|
||||||
|
|
||||||
const TRANSPARENT = 'rgba(0, 0, 0, 0)';
|
const TRANSPARENT = 'rgba(0, 0, 0, 0)';
|
||||||
const THEMES = ['clair', 'sombre'];
|
const THEMES = ['clair', 'sombre'];
|
||||||
|
|
||||||
// Composantes [r, g, b, a] d'une couleur calculée. Chromium la rend sous la
|
|
||||||
// forme « rgb(r, g, b) » ou « rgba(r, g, b, a) » quand la source est une
|
|
||||||
// couleur sRGB ; toute autre forme fait échouer l'épreuve plutôt que de la
|
|
||||||
// laisser passer sans mesure.
|
|
||||||
function composantes(couleur) {
|
|
||||||
const m = /^rgba?\((\d+), (\d+), (\d+)(?:, ([\d.]+))?\)$/.exec(couleur);
|
|
||||||
if (m === null) {
|
|
||||||
throw new Error(`couleur calculée illisible : ${couleur}`);
|
|
||||||
}
|
|
||||||
const alpha = m[4] === undefined ? 1 : Number(m[4]);
|
|
||||||
return [Number(m[1]), Number(m[2]), Number(m[3]), alpha];
|
|
||||||
}
|
|
||||||
|
|
||||||
// L'élément, puis chacun de ses ancêtres jusqu'à la racine du document.
|
|
||||||
function ascendance(element) {
|
|
||||||
const chaine = [];
|
|
||||||
for (let n = element; n !== null; n = n.parentElement) {
|
|
||||||
chaine.push(n);
|
|
||||||
}
|
|
||||||
return chaine;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fond peint sous un élément : le premier fond non transparent de son
|
|
||||||
// ascendance, ou null.
|
|
||||||
function fondPeint(element) {
|
|
||||||
for (const n of ascendance(element)) {
|
|
||||||
const fond = composantes(getComputedStyle(n).backgroundColor);
|
|
||||||
if (fond[3] !== 0) {
|
|
||||||
return fond;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Éléments du sous-arbre qui portent eux-mêmes un nœud de texte non vide.
|
|
||||||
function porteursDeTexte(racine) {
|
|
||||||
return [racine, ...racine.querySelectorAll('*')].filter((el) =>
|
|
||||||
[...el.childNodes].some(
|
|
||||||
(n) => n.nodeType === Node.TEXT_NODE && n.textContent.trim() !== '',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nom lisible d'un élément dans un message d'échec.
|
|
||||||
function designation(element) {
|
|
||||||
const classe = element.getAttribute('class');
|
|
||||||
return classe ? `${element.localName}.${classe}` : element.localName;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exécute mesure() sous le thème demandé, posé par data-theme sur <html>, et
|
|
||||||
// rend sa valeur. L'attribut retrouve ensuite sa valeur d'avant, ou son
|
|
||||||
// absence, même quand mesure() lève : le test suivant part du même document.
|
|
||||||
async function sousTheme(theme, mesure) {
|
|
||||||
const racine = document.documentElement;
|
|
||||||
const avant = racine.getAttribute('data-theme');
|
|
||||||
racine.setAttribute('data-theme', theme);
|
|
||||||
try {
|
|
||||||
return await mesure();
|
|
||||||
} finally {
|
|
||||||
if (avant === null) {
|
|
||||||
racine.removeAttribute('data-theme');
|
|
||||||
} else {
|
|
||||||
racine.setAttribute('data-theme', avant);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let composant;
|
let composant;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|
@ -108,9 +52,9 @@ function bandeau() {
|
||||||
return document.body.querySelector('header.bandeau');
|
return document.body.querySelector('header.bandeau');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Le bandeau, puis chacun des éléments qui y portent un texte.
|
// Le bandeau, puis chacun de ses porteurs de texte.
|
||||||
function bandeauEtTextes() {
|
function bandeauEtTextes() {
|
||||||
return [...new Set([bandeau(), ...porteursDeTexte(bandeau())])];
|
return [{ element: bandeau(), pseudo: null }, ...porteursDeTexte(bandeau())];
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("bandeau d'application", () => {
|
describe("bandeau d'application", () => {
|
||||||
|
|
@ -121,38 +65,17 @@ describe("bandeau d'application", () => {
|
||||||
|
|
||||||
test('le bandeau et chacun de ses textes sont visibles dans la fenêtre, dans les deux thèmes (§ 14.2, § 18.6)', async () => {
|
test('le bandeau et chacun de ses textes sont visibles dans la fenêtre, dans les deux thèmes (§ 14.2, § 18.6)', async () => {
|
||||||
// Le texte, le titre et le style calculé se relisent sur un élément qui
|
// Le texte, le titre et le style calculé se relisent sur un élément qui
|
||||||
// ne peint rien. L'épreuve interroge donc la mise en page : chaque élément
|
// ne peint rien. visible interroge donc la mise en page : checkVisibility,
|
||||||
// est visible pour checkVisibility (display, visibility, opacité nulle,
|
// une boîte non vide dans la fenêtre, et le centre de cette boîte qui
|
||||||
// content-visibility), sa boîte est non vide et tient dans la fenêtre, et
|
// atteint l'élément ou l'un de ses descendants sans qu'un élément peint
|
||||||
// le point central de cette boîte atteint l'élément ou l'un de ses
|
// au-dessus y peigne — un élément recouvert, ou rogné par un ancêtre,
|
||||||
// descendants — un élément recouvert, ou rogné par un ancêtre, laisse le
|
// échoue.
|
||||||
// point à un autre. elementFromPoint suit l'atteinte du pointeur : un
|
|
||||||
// élément en pointer-events: none n'y répond pas, et l'épreuve le refuse.
|
|
||||||
for (const theme of THEMES) {
|
for (const theme of THEMES) {
|
||||||
await sousTheme(theme, () => {
|
await sousTheme(theme, () => {
|
||||||
const elements = bandeauEtTextes();
|
const porteurs = bandeauEtTextes();
|
||||||
expect(elements.length).toBeGreaterThan(1);
|
expect(porteurs.length).toBeGreaterThan(1);
|
||||||
for (const element of elements) {
|
for (const porteur of porteurs) {
|
||||||
const lieu = `${theme} : ${designation(element)}`;
|
expect(visible(porteur), `${theme} : ${designation(porteur)}`).toBe(true);
|
||||||
expect(
|
|
||||||
element.checkVisibility({ opacityProperty: true, visibilityProperty: true }),
|
|
||||||
`${lieu} : checkVisibility`,
|
|
||||||
).toBe(true);
|
|
||||||
const boite = element.getBoundingClientRect();
|
|
||||||
expect(boite.width, `${lieu} : largeur`).toBeGreaterThan(0);
|
|
||||||
expect(boite.height, `${lieu} : hauteur`).toBeGreaterThan(0);
|
|
||||||
expect(boite.left, `${lieu} : bord gauche`).toBeGreaterThanOrEqual(0);
|
|
||||||
expect(boite.top, `${lieu} : bord haut`).toBeGreaterThanOrEqual(0);
|
|
||||||
expect(boite.right, `${lieu} : bord droit`).toBeLessThanOrEqual(innerWidth);
|
|
||||||
expect(boite.bottom, `${lieu} : bord bas`).toBeLessThanOrEqual(innerHeight);
|
|
||||||
const atteint = document.elementFromPoint(
|
|
||||||
boite.left + boite.width / 2,
|
|
||||||
boite.top + boite.height / 2,
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
element.contains(atteint),
|
|
||||||
`${lieu} : le centre de sa boîte atteint ${atteint === null ? 'aucun élément' : designation(atteint)}`,
|
|
||||||
).toBe(true);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -175,24 +98,25 @@ describe("bandeau d'application", () => {
|
||||||
expect(style.backgroundColor).not.toBe(TRANSPARENT);
|
expect(style.backgroundColor).not.toBe(TRANSPARENT);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('chaque texte du bandeau tient 4,5:1 sur un fond opaque, dans les deux thèmes (§ 14.5)', async () => {
|
test('chaque texte du bandeau tient 4,5:1 sur chaque couleur peinte sous lui, dans les deux thèmes (§ 14.5)', async () => {
|
||||||
const couleursParTheme = [];
|
const couleursParTheme = [];
|
||||||
for (const theme of THEMES) {
|
for (const theme of THEMES) {
|
||||||
const couleurs = await sousTheme(theme, () => {
|
const couleurs = await sousTheme(theme, () => {
|
||||||
const textes = porteursDeTexte(bandeau());
|
const textes = porteursDeTexte(bandeau());
|
||||||
expect(textes.length).toBeGreaterThan(0);
|
expect(textes.length).toBeGreaterThan(0);
|
||||||
for (const element of textes) {
|
for (const porteur of textes) {
|
||||||
const lieu = `${theme} : ${designation(element)}`;
|
const lieu = `${theme} : ${designation(porteur)}`;
|
||||||
const texte = composantes(getComputedStyle(element).color);
|
const texte = couleurTexte(porteur);
|
||||||
const fond = fondPeint(element);
|
const fonds = fondPeintSous(porteur);
|
||||||
expect(fond, lieu).not.toBeNull();
|
expect(fonds.length, `${lieu} : aucun fond opaque`).toBeGreaterThan(0);
|
||||||
expect(texte[3], lieu).toBe(1);
|
expect(texte[3], lieu).toBe(1);
|
||||||
expect(fond[3], lieu).toBe(1);
|
for (const fond of fonds) {
|
||||||
expect(contraste(texte, fond), lieu).toBeGreaterThanOrEqual(4.5);
|
expect(contraste(texte, fond), `${lieu} sur ${fond.join(', ')}`).toBeGreaterThanOrEqual(4.5);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return [document.body, bandeau()].map((el) => {
|
return [document.body, bandeau()].map((el) => {
|
||||||
const style = getComputedStyle(el);
|
const style = getComputedStyle(el);
|
||||||
return `${style.color} sur ${style.backgroundColor}`;
|
return `${composantes(style.color)} sur ${composantes(style.backgroundColor)}`;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
couleursParTheme.push(couleurs);
|
couleursParTheme.push(couleurs);
|
||||||
|
|
@ -205,23 +129,19 @@ describe("bandeau d'application", () => {
|
||||||
).not.toEqual(couleursParTheme[0]);
|
).not.toEqual(couleursParTheme[0]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("aucune opacité partielle, aucun filtre ni mode de fusion sous un texte, dans les deux thèmes (§ 14.5)", async () => {
|
test("aucune opacité partielle, aucun filtre, masque ni mode de fusion sous un texte, dans les deux thèmes (§ 14.5)", async () => {
|
||||||
// opacity et filter ne s'héritent pas : posés sur un ancêtre, ils changent
|
// opacity et filter ne s'héritent pas : posés sur un ancêtre, ils changent
|
||||||
// le pixel peint sans changer le style calculé du texte. Chaque niveau de
|
// le pixel peint sans changer le style calculé du texte. attenuations
|
||||||
// l'ascendance est donc relu, jusqu'à la racine du document.
|
// relit donc chaque niveau de l'ascendance, jusqu'à la racine du document.
|
||||||
for (const theme of THEMES) {
|
for (const theme of THEMES) {
|
||||||
await sousTheme(theme, () => {
|
await sousTheme(theme, () => {
|
||||||
const textes = porteursDeTexte(bandeau());
|
const textes = porteursDeTexte(bandeau());
|
||||||
expect(textes.length).toBeGreaterThan(0);
|
expect(textes.length).toBeGreaterThan(0);
|
||||||
for (const element of textes) {
|
for (const porteur of textes) {
|
||||||
for (const n of ascendance(element)) {
|
const fautes = attenuations(porteur).map(
|
||||||
const style = getComputedStyle(n);
|
(f) => `${designation({ element: f.element, pseudo: f.pseudo })} ${f.propriete}: ${f.valeur}`,
|
||||||
const lieu = `${theme} : ${designation(n)} sous ${designation(element)}`;
|
);
|
||||||
expect(style.opacity, lieu).toBe('1');
|
expect(fautes, `${theme} : ${designation(porteur)}`).toEqual([]);
|
||||||
expect(style.filter, lieu).toBe('none');
|
|
||||||
expect(style.backdropFilter, lieu).toBe('none');
|
|
||||||
expect(style.mixBlendMode, lieu).toBe('normal');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -113,11 +113,20 @@ function exigerSeance(seance) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Libellé de l'instant que restitue l'Ouvert : celui de l'entrée de sa
|
/**
|
||||||
// révision au journal ; sans elle — un état sans journal, ou que le journal
|
* Libellé de l'instant que restitue l'Ouvert : celui de l'entrée de sa
|
||||||
// ne rejoint pas —, libelleReprise, le texte qu'une écriture donnerait à
|
* révision au journal ; sans elle — un état sans journal, ou que le journal
|
||||||
// l'entrée de reprise de cet état. TypeError quand il le faut et qu'il manque.
|
* ne rejoint pas —, libelleReprise, le texte qu'une écriture donnerait à
|
||||||
function libelleDeLInstant({ journal, revision }, libelleReprise) {
|
* l'entrée de reprise de cet état. La filiation qu'écrit copier le porte, et
|
||||||
|
* l'appelant qui compose le libellé de l'entrée de copie le lit ici, avant
|
||||||
|
* l'appel.
|
||||||
|
*
|
||||||
|
* @param {{journal: Object|null, revision: number}} ouvert
|
||||||
|
* @param {string} [libelleReprise]
|
||||||
|
* @returns {string}
|
||||||
|
* @throws {TypeError} quand il le faut et qu'il manque
|
||||||
|
*/
|
||||||
|
export function libelleDeLInstant({ journal, revision }, libelleReprise) {
|
||||||
const libelle = journal?.entrees.find((entree) => entree.revision === revision)?.libelle ?? libelleReprise;
|
const libelle = journal?.entrees.find((entree) => entree.revision === revision)?.libelle ?? libelleReprise;
|
||||||
if (typeof libelle !== 'string' || libelle === '') {
|
if (typeof libelle !== 'string' || libelle === '') {
|
||||||
throw new TypeError(`copier : la révision ${revision} n'a pas d'entrée au journal, libelleReprise requis`);
|
throw new TypeError(`copier : la révision ${revision} n'a pas d'entrée au journal, libelleReprise requis`);
|
||||||
|
|
|
||||||
177
test/navigateur/entrees.js
Normal file
177
test/navigateur/entrees.js
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Entrées que Chromium traite comme réelles (§ 7.3), et le cadre d'affichage
|
||||||
|
// (§ 19.4). Les événements passent par le protocole de débogage de Chromium,
|
||||||
|
// Input.dispatch… sur la page qui porte le cadre de l'épreuve : ils
|
||||||
|
// traversent le test d'atteinte, la capture du pointeur et la conversion du
|
||||||
|
// toucher en pointeur comme ceux d'un périphérique, et portent isTrusted
|
||||||
|
// vrai. Un événement construit par new PointerEvent ne pose pas de capture.
|
||||||
|
//
|
||||||
|
// Les coordonnées reçues sont celles du cadre de l'épreuve — clientX et
|
||||||
|
// clientY de l'élément visé. CDP les attend dans la fenêtre de la page :
|
||||||
|
// chaque appel relit, d'un cadre à son parent, le rectangle du cadre et
|
||||||
|
// l'échelle qu'une transformation lui donne, et convertit au moment de
|
||||||
|
// l'envoi. Défiler la page de l'épreuve ne change rien : un point du cadre
|
||||||
|
// reste un point de sa fenêtre.
|
||||||
|
import { cdp, page } from 'vitest/browser';
|
||||||
|
|
||||||
|
// Boutons de la souris : le masque de buttons et le nom que CDP attend.
|
||||||
|
const BOUTONS = [
|
||||||
|
[1, 'left'],
|
||||||
|
[2, 'right'],
|
||||||
|
[4, 'middle'],
|
||||||
|
];
|
||||||
|
|
||||||
|
const GENRES = { souris: 'mouse', stylet: 'pen' };
|
||||||
|
|
||||||
|
const TYPES_POINTEUR = { presser: 'mousePressed', bouger: 'mouseMoved', relacher: 'mouseReleased' };
|
||||||
|
|
||||||
|
const TYPES_TOUCHER = { presser: 'touchStart', bouger: 'touchMove', relacher: 'touchEnd', annuler: 'touchCancel' };
|
||||||
|
|
||||||
|
// Touches nommées : key, code et code de touche virtuelle ; text pour celles
|
||||||
|
// qui saisissent un caractère.
|
||||||
|
const TOUCHES = {
|
||||||
|
Escape: { code: 'Escape', touche: 27 },
|
||||||
|
Enter: { code: 'Enter', touche: 13, texte: '\r' },
|
||||||
|
Tab: { code: 'Tab', touche: 9 },
|
||||||
|
' ': { code: 'Space', touche: 32, texte: ' ' },
|
||||||
|
Backspace: { code: 'Backspace', touche: 8 },
|
||||||
|
Delete: { code: 'Delete', touche: 46 },
|
||||||
|
Home: { code: 'Home', touche: 36 },
|
||||||
|
End: { code: 'End', touche: 35 },
|
||||||
|
PageUp: { code: 'PageUp', touche: 33 },
|
||||||
|
PageDown: { code: 'PageDown', touche: 34 },
|
||||||
|
ArrowLeft: { code: 'ArrowLeft', touche: 37 },
|
||||||
|
ArrowUp: { code: 'ArrowUp', touche: 38 },
|
||||||
|
ArrowRight: { code: 'ArrowRight', touche: 39 },
|
||||||
|
ArrowDown: { code: 'ArrowDown', touche: 40 },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Boutons tenus enfoncés depuis le dernier presser, rendus par relacher.
|
||||||
|
let boutonsEnfonces = 0;
|
||||||
|
|
||||||
|
/** Point de la fenêtre de la page qui porte le cadre de l'épreuve, pour le
|
||||||
|
* point (x, y) de la fenêtre de ce cadre. D'un cadre à son parent : la
|
||||||
|
* boîte de l'<iframe> dans le parent, sa bordure et son retrait, et
|
||||||
|
* l'échelle que pose une transformation, rapport de la largeur rendue à la
|
||||||
|
* largeur de mise en page. */
|
||||||
|
function versPage(x, y) {
|
||||||
|
let px = x;
|
||||||
|
let py = y;
|
||||||
|
for (let fenetre = window; fenetre !== fenetre.top; fenetre = fenetre.parent) {
|
||||||
|
const cadre = fenetre.frameElement;
|
||||||
|
const boite = cadre.getBoundingClientRect();
|
||||||
|
const style = fenetre.parent.getComputedStyle(cadre);
|
||||||
|
const echelle = cadre.offsetWidth > 0 ? boite.width / cadre.offsetWidth : 1;
|
||||||
|
px = boite.left + (cadre.clientLeft + parseFloat(style.paddingLeft) + px) * echelle;
|
||||||
|
py = boite.top + (cadre.clientTop + parseFloat(style.paddingTop) + py) * echelle;
|
||||||
|
}
|
||||||
|
return { x: px, y: py };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Le bouton CDP d'un masque : le premier enfoncé, 'none' sans aucun.
|
||||||
|
function bouton(masque) {
|
||||||
|
return BOUTONS.find(([bit]) => (masque & bit) !== 0)?.[1] ?? 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Un événement de pointeur réel au point (x, y) du cadre. type : 'presser',
|
||||||
|
* 'bouger' ou 'relacher'. boutons, le masque de buttons : par défaut, 1 au
|
||||||
|
* presser, les boutons tenus pour bouger et relacher. genre : 'souris' ou
|
||||||
|
* 'stylet' ; le doigt passe par toucher. */
|
||||||
|
export async function pointer(type, x, y, { boutons, genre = 'souris' } = {}) {
|
||||||
|
const typeCdp = TYPES_POINTEUR[type];
|
||||||
|
if (typeCdp === undefined) throw new Error(`pointer : type inconnu « ${type} »`);
|
||||||
|
const pointerType = GENRES[genre];
|
||||||
|
if (pointerType === undefined) throw new Error(`pointer : genre inconnu « ${genre} »`);
|
||||||
|
const masque = boutons ?? (type === 'presser' ? 1 : boutonsEnfonces);
|
||||||
|
const { x: px, y: py } = versPage(x, y);
|
||||||
|
const evenement = { type: typeCdp, x: px, y: py, pointerType, button: bouton(masque), buttons: masque };
|
||||||
|
if (type === 'presser') {
|
||||||
|
evenement.clickCount = 1;
|
||||||
|
boutonsEnfonces = masque;
|
||||||
|
} else if (type === 'relacher') {
|
||||||
|
evenement.clickCount = 1;
|
||||||
|
evenement.buttons = 0;
|
||||||
|
boutonsEnfonces = 0;
|
||||||
|
}
|
||||||
|
await cdp().send('Input.dispatchMouseEvent', evenement);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Presse au point depuis, avance en pas mouvements égaux jusqu'au point vers,
|
||||||
|
* et relâche au point vers. Chaque envoi attend que Chromium ait traité
|
||||||
|
* l'événement : chaque pas livre son propre pointermove, que la page ne
|
||||||
|
* reçoit pas regroupé avec le suivant. */
|
||||||
|
export async function glisser(depuis, vers, { pas = 10 } = {}) {
|
||||||
|
await pointer('presser', depuis.x, depuis.y);
|
||||||
|
for (let i = 1; i <= pas; i += 1) {
|
||||||
|
const t = i / pas;
|
||||||
|
await pointer('bouger', depuis.x + (vers.x - depuis.x) * t, depuis.y + (vers.y - depuis.y) * t);
|
||||||
|
}
|
||||||
|
await pointer('relacher', vers.x, vers.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Un cran de molette au point (x, y) du cadre, deltaY en pixels. */
|
||||||
|
export async function molette(x, y, deltaY) {
|
||||||
|
const { x: px, y: py } = versPage(x, y);
|
||||||
|
await cdp().send('Input.dispatchMouseEvent', { type: 'mouseWheel', x: px, y: py, deltaX: 0, deltaY });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Un événement tactile réel. type : 'presser', 'bouger', 'relacher' ou
|
||||||
|
* 'annuler' ; points : [{x, y, id?}] dans le cadre. CDP veut au moins un
|
||||||
|
* point pour presser et bouger, aucun pour relacher et annuler, qui lèvent
|
||||||
|
* les doigts posés. */
|
||||||
|
export async function toucher(type, points) {
|
||||||
|
const typeCdp = TYPES_TOUCHER[type];
|
||||||
|
if (typeCdp === undefined) throw new Error(`toucher : type inconnu « ${type} »`);
|
||||||
|
const levee = type === 'relacher' || type === 'annuler';
|
||||||
|
if (levee !== (points.length === 0)) {
|
||||||
|
throw new Error(`toucher : ${type} veut ${levee ? 'aucun point' : 'au moins un point'}, reçu ${points.length}`);
|
||||||
|
}
|
||||||
|
const touchPoints = points.map(({ x, y, id = 0 }) => ({ ...versPage(x, y), id }));
|
||||||
|
await cdp().send('Input.dispatchTouchEvent', { type: typeCdp, touchPoints });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Presse puis relâche une touche nommée par sa valeur de key ('Escape',
|
||||||
|
* 'Enter', 'ArrowDown'…) ou un caractère imprimable. Les événements vont au
|
||||||
|
* document qui a le focus. Une touche inconnue lève en la nommant. */
|
||||||
|
export async function touche(cle) {
|
||||||
|
const connue = TOUCHES[cle];
|
||||||
|
const caractere = connue === undefined && [...cle].length === 1;
|
||||||
|
if (connue === undefined && !caractere) throw new Error(`touche inconnue : « ${cle} »`);
|
||||||
|
const { code, touche: virtuelle, texte } = connue ?? { code: '', touche: 0, texte: cle };
|
||||||
|
const commun = { key: cle, code, windowsVirtualKeyCode: virtuelle, nativeVirtualKeyCode: virtuelle };
|
||||||
|
await cdp().send('Input.dispatchKeyEvent', {
|
||||||
|
type: texte === undefined ? 'rawKeyDown' : 'keyDown',
|
||||||
|
...commun,
|
||||||
|
...(texte === undefined ? {} : { text: texte, unmodifiedText: texte }),
|
||||||
|
});
|
||||||
|
await cdp().send('Input.dispatchKeyEvent', { type: 'keyUp', ...commun });
|
||||||
|
}
|
||||||
|
|
||||||
|
// innerWidth et innerHeight du cadre. Les lire force la mise en page du
|
||||||
|
// parent : la taille que poser vient de donner à l'<iframe> s'y lit déjà.
|
||||||
|
function relire() {
|
||||||
|
return { largeur: innerWidth, hauteur: innerHeight };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pose le cadre d'affichage à largeur × hauteur (§ 19.4). poser(largeur,
|
||||||
|
* hauteur) le dimensionne — par défaut page.viewport de vitest/browser —,
|
||||||
|
* innerWidth et innerHeight se relisent, puis, s'ils s'écartent des valeurs
|
||||||
|
* visées, poser reçoit les valeurs visées augmentées de l'écart relevé, et
|
||||||
|
* le cadre se relit. Un écart restant lève en nommant les valeurs visées et
|
||||||
|
* obtenues. Rend { largeur, hauteur } relues. */
|
||||||
|
export async function fixerCadre(largeur, hauteur, { poser = (l, h) => page.viewport(l, h) } = {}) {
|
||||||
|
await poser(largeur, hauteur);
|
||||||
|
let lu = relire();
|
||||||
|
if (lu.largeur !== largeur || lu.hauteur !== hauteur) {
|
||||||
|
await poser(largeur + (largeur - lu.largeur), hauteur + (hauteur - lu.hauteur));
|
||||||
|
lu = relire();
|
||||||
|
}
|
||||||
|
if (lu.largeur !== largeur || lu.hauteur !== hauteur) {
|
||||||
|
throw new Error(
|
||||||
|
`cadre visé ${largeur} × ${hauteur}, obtenu ${lu.largeur} × ${lu.hauteur} (innerWidth × innerHeight)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return lu;
|
||||||
|
}
|
||||||
264
test/navigateur/entrees.navigateur.test.js
Normal file
264
test/navigateur/entrees.navigateur.test.js
Normal file
|
|
@ -0,0 +1,264 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Épreuves des entrées réelles (§ 7.3) et du cadre d'affichage (§ 19.4).
|
||||||
|
// Les entrées passent par CDP : Chromium les traite comme celles d'un
|
||||||
|
// périphérique, chaque événement relevé porte isTrusted vrai, et la capture
|
||||||
|
// du pointeur se pose — ce qu'un événement construit par la page n'obtient
|
||||||
|
// pas. Les coordonnées sont celles du cadre de l'épreuve.
|
||||||
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'vitest';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
import { fixerCadre, glisser, molette, pointer, touche, toucher } from './entrees.js';
|
||||||
|
|
||||||
|
const SVG = 'http://www.w3.org/2000/svg';
|
||||||
|
|
||||||
|
let scene;
|
||||||
|
let cadreInitial;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
cadreInitial = { largeur: innerWidth, hauteur: innerHeight };
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await page.viewport(cadreInitial.largeur, cadreInitial.hauteur);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
scrollTo(0, 0);
|
||||||
|
scene = document.createElement('div');
|
||||||
|
document.body.append(scene);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
scene.remove();
|
||||||
|
scrollTo(0, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Un plan d'épreuve : un <svg> en touch-action: none, posé à 30 px du bord,
|
||||||
|
// qui porte deux rectangles côte à côte.
|
||||||
|
function monterPlan() {
|
||||||
|
scene.innerHTML =
|
||||||
|
`<svg xmlns="${SVG}" data-plan width="300" height="160" ` +
|
||||||
|
'style="display: block; margin: 30px; touch-action: none">' +
|
||||||
|
'<rect data-a x="0" y="0" width="150" height="160" fill="rgb(200, 220, 240)"/>' +
|
||||||
|
'<rect data-b x="150" y="0" width="150" height="160" fill="rgb(240, 220, 200)"/></svg>';
|
||||||
|
return scene.querySelector('[data-plan]');
|
||||||
|
}
|
||||||
|
|
||||||
|
function centre(element) {
|
||||||
|
const boite = element.getBoundingClientRect();
|
||||||
|
return { x: boite.left + boite.width / 2, y: boite.top + boite.height / 2 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relève les événements des types donnés sur la cible, dans leur ordre.
|
||||||
|
function relever(cible, types) {
|
||||||
|
const releves = [];
|
||||||
|
for (const type of types) {
|
||||||
|
cible.addEventListener(type, (e) => releves.push(e));
|
||||||
|
}
|
||||||
|
return releves;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('pointeur', () => {
|
||||||
|
test('un presser réel permet setPointerCapture sur la racine <svg>', async () => {
|
||||||
|
const plan = monterPlan();
|
||||||
|
let capture = null;
|
||||||
|
plan.addEventListener('pointerdown', (e) => {
|
||||||
|
plan.setPointerCapture(e.pointerId);
|
||||||
|
capture = { isTrusted: e.isTrusted, tenue: plan.hasPointerCapture(e.pointerId) };
|
||||||
|
});
|
||||||
|
const { x, y } = centre(plan.querySelector('[data-a]'));
|
||||||
|
await pointer('presser', x, y);
|
||||||
|
await pointer('relacher', x, y);
|
||||||
|
expect(capture).toEqual({ isTrusted: true, tenue: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un pointerdown construit par la page ne permet pas la capture', () => {
|
||||||
|
const plan = monterPlan();
|
||||||
|
let capture = null;
|
||||||
|
plan.addEventListener('pointerdown', (e) => {
|
||||||
|
try {
|
||||||
|
plan.setPointerCapture(e.pointerId);
|
||||||
|
} catch {
|
||||||
|
// Un identifiant qu'aucun pointeur actif ne porte lève NotFoundError.
|
||||||
|
}
|
||||||
|
capture = { isTrusted: e.isTrusted, tenue: plan.hasPointerCapture(e.pointerId) };
|
||||||
|
});
|
||||||
|
const { x, y } = centre(plan.querySelector('[data-a]'));
|
||||||
|
plan.querySelector('[data-a]').dispatchEvent(
|
||||||
|
new PointerEvent('pointerdown', { bubbles: true, pointerId: 1, clientX: x, clientY: y, buttons: 1 }),
|
||||||
|
);
|
||||||
|
expect(capture).toEqual({ isTrusted: false, tenue: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const defile of [0, 400]) {
|
||||||
|
test(`le centre d'un élément, pointé, l'atteint comme cible — page défilée de ${defile} px`, async () => {
|
||||||
|
const plan = monterPlan();
|
||||||
|
// Un espace sous le plan rend la page défilable, puis le plan descend
|
||||||
|
// sous la marge défilée.
|
||||||
|
plan.style.marginTop = `${30 + defile}px`;
|
||||||
|
plan.style.marginBottom = '2000px';
|
||||||
|
scrollTo(0, defile);
|
||||||
|
expect(scrollY).toBe(defile);
|
||||||
|
const releves = relever(plan, ['pointerdown', 'pointerup']);
|
||||||
|
for (const cible of [plan.querySelector('[data-a]'), plan.querySelector('[data-b]')]) {
|
||||||
|
const { x, y } = centre(cible);
|
||||||
|
await pointer('presser', x, y);
|
||||||
|
await pointer('relacher', x, y);
|
||||||
|
}
|
||||||
|
expect(releves.map((e) => [e.type, e.target.getAttribute('data-a') !== null ? 'a' : 'b', e.isTrusted])).toEqual([
|
||||||
|
['pointerdown', 'a', true],
|
||||||
|
['pointerup', 'a', true],
|
||||||
|
['pointerdown', 'b', true],
|
||||||
|
['pointerup', 'b', true],
|
||||||
|
]);
|
||||||
|
const { x, y } = centre(plan.querySelector('[data-b]'));
|
||||||
|
expect(releves[2].clientX).toBeCloseTo(x, 0);
|
||||||
|
expect(releves[2].clientY).toBeCloseTo(y, 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("le cadre de l'épreuve décalé et réduit dans sa page : le centre visé reste atteint", async () => {
|
||||||
|
// Le cadre de l'épreuve est une <iframe> de la page que pilote CDP ;
|
||||||
|
// l'épreuve la déplace, la réduit et lui pose une bordure et un retrait
|
||||||
|
// le temps de pointer, puis lui rend son style : les coordonnées du cadre
|
||||||
|
// se convertissent à chaque appel, bordure et retrait compris.
|
||||||
|
const iframe = window.frameElement;
|
||||||
|
expect(iframe).not.toBeNull();
|
||||||
|
const avant = iframe.getAttribute('style');
|
||||||
|
iframe.style.transformOrigin = '0 0';
|
||||||
|
iframe.style.transform = 'translate(37px, 23px) scale(0.5)';
|
||||||
|
iframe.style.border = '5px solid';
|
||||||
|
iframe.style.padding = '7px';
|
||||||
|
try {
|
||||||
|
const plan = monterPlan();
|
||||||
|
const releves = relever(plan, ['pointerdown', 'pointerup']);
|
||||||
|
const { x, y } = centre(plan.querySelector('[data-b]'));
|
||||||
|
await pointer('presser', x, y);
|
||||||
|
await pointer('relacher', x, y);
|
||||||
|
expect(releves.map((e) => [e.type, e.target.getAttribute('data-b') !== null])).toEqual([
|
||||||
|
['pointerdown', true],
|
||||||
|
['pointerup', true],
|
||||||
|
]);
|
||||||
|
expect(releves[0].clientX).toBeCloseTo(x, 0);
|
||||||
|
expect(releves[0].clientY).toBeCloseTo(y, 0);
|
||||||
|
} finally {
|
||||||
|
if (avant === null) iframe.removeAttribute('style');
|
||||||
|
else iframe.setAttribute('style', avant);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('le genre stylet livre pointerType pen, et boutons choisit le bouton pressé', async () => {
|
||||||
|
const plan = monterPlan();
|
||||||
|
plan.addEventListener('contextmenu', (e) => e.preventDefault());
|
||||||
|
const releves = relever(plan, ['pointerdown', 'pointerup']);
|
||||||
|
const { x, y } = centre(plan.querySelector('[data-a]'));
|
||||||
|
await pointer('presser', x, y, { genre: 'stylet' });
|
||||||
|
await pointer('relacher', x, y, { genre: 'stylet' });
|
||||||
|
await pointer('presser', x, y, { boutons: 2 });
|
||||||
|
await pointer('relacher', x, y);
|
||||||
|
expect(releves.map((e) => [e.type, e.pointerType, e.button, e.buttons])).toEqual([
|
||||||
|
['pointerdown', 'pen', 0, 1],
|
||||||
|
['pointerup', 'pen', 0, 0],
|
||||||
|
['pointerdown', 'mouse', 2, 2],
|
||||||
|
['pointerup', 'mouse', 2, 0],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('molette livre un wheel au point, deltaY intact', async () => {
|
||||||
|
const plan = monterPlan();
|
||||||
|
const releves = relever(plan, ['wheel']);
|
||||||
|
plan.addEventListener('wheel', (e) => e.preventDefault(), { passive: false });
|
||||||
|
const { x, y } = centre(plan.querySelector('[data-b]'));
|
||||||
|
await molette(x, y, 125);
|
||||||
|
await molette(x, y, -40);
|
||||||
|
expect(releves.length).toBe(2);
|
||||||
|
expect(releves.map((e) => [e.deltaY, e.isTrusted])).toEqual([
|
||||||
|
[125, true],
|
||||||
|
[-40, true],
|
||||||
|
]);
|
||||||
|
expect(releves[0].target.getAttribute('data-b')).not.toBeNull();
|
||||||
|
expect(releves[0].clientX).toBeCloseTo(x, 0);
|
||||||
|
expect(releves[0].clientY).toBeCloseTo(y, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("glisser sous capture livre un pointermove par pas, croissants, puis un pointerup au point d'arrivée", async () => {
|
||||||
|
const plan = monterPlan();
|
||||||
|
plan.addEventListener('pointerdown', (e) => plan.setPointerCapture(e.pointerId));
|
||||||
|
const releves = relever(plan, ['pointerdown', 'pointermove', 'pointerup']);
|
||||||
|
const depuis = centre(plan.querySelector('[data-a]'));
|
||||||
|
// L'arrivée sort du plan : la capture garde les événements sur la racine.
|
||||||
|
const vers = { x: depuis.x + 360, y: depuis.y + 40 };
|
||||||
|
await glisser(depuis, vers, { pas: 6 });
|
||||||
|
const mouvements = releves.filter((e) => e.type === 'pointermove');
|
||||||
|
expect(releves[0].type).toBe('pointerdown');
|
||||||
|
expect(mouvements.length).toBe(6);
|
||||||
|
for (let i = 1; i < mouvements.length; i += 1) {
|
||||||
|
expect(mouvements[i].clientX).toBeGreaterThan(mouvements[i - 1].clientX);
|
||||||
|
}
|
||||||
|
const fin = releves.at(-1);
|
||||||
|
expect(fin.type).toBe('pointerup');
|
||||||
|
expect(fin.clientX).toBeCloseTo(vers.x, 0);
|
||||||
|
expect(fin.clientY).toBeCloseTo(vers.y, 0);
|
||||||
|
expect(releves.every((e) => e.isTrusted)).toBe(true);
|
||||||
|
expect(plan.hasPointerCapture(releves[0].pointerId)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toucher sur un élément en touch-action: none livre pointerdown puis pointerup, de type touch', async () => {
|
||||||
|
const plan = monterPlan();
|
||||||
|
const releves = relever(plan, ['pointerdown', 'pointerup', 'pointercancel']);
|
||||||
|
const { x, y } = centre(plan.querySelector('[data-b]'));
|
||||||
|
await toucher('presser', [{ x, y }]);
|
||||||
|
// L'intervalle d'une pression longue se pilote ici par l'horloge de
|
||||||
|
// l'application : aucune attente réelle entre les deux.
|
||||||
|
await toucher('relacher', []);
|
||||||
|
expect(releves.map((e) => [e.type, e.pointerType, e.isTrusted])).toEqual([
|
||||||
|
['pointerdown', 'touch', true],
|
||||||
|
['pointerup', 'touch', true],
|
||||||
|
]);
|
||||||
|
expect(releves[0].target.getAttribute('data-b')).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('clavier', () => {
|
||||||
|
test("touche('Escape') livre un keydown de key 'Escape'", async () => {
|
||||||
|
const releves = relever(document, ['keydown', 'keyup']);
|
||||||
|
await touche('Escape');
|
||||||
|
expect(releves.map((e) => [e.type, e.key, e.isTrusted])).toEqual([
|
||||||
|
['keydown', 'Escape', true],
|
||||||
|
['keyup', 'Escape', true],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('une touche inconnue lève en la nommant', async () => {
|
||||||
|
await expect(touche('Hyperespace')).rejects.toThrow(/Hyperespace/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('le cadre (§ 19.4)', () => {
|
||||||
|
test('fixerCadre(1366, 700) pose innerWidth et innerHeight', async () => {
|
||||||
|
await fixerCadre(1366, 700);
|
||||||
|
expect([innerWidth, innerHeight]).toEqual([1366, 700]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un poser qui retranche 17 px à la hauteur, comme une barre, est corrigé', async () => {
|
||||||
|
await fixerCadre(1000, 600);
|
||||||
|
const poses = [];
|
||||||
|
const poser = async (largeur, hauteur) => {
|
||||||
|
poses.push([largeur, hauteur]);
|
||||||
|
await page.viewport(largeur, hauteur - 17);
|
||||||
|
};
|
||||||
|
await fixerCadre(1366, 700, { poser });
|
||||||
|
expect([innerWidth, innerHeight]).toEqual([1366, 700]);
|
||||||
|
expect(poses).toEqual([
|
||||||
|
[1366, 700],
|
||||||
|
[1366, 717],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un poser sans effet lève en nommant la hauteur visée et la hauteur relue', async () => {
|
||||||
|
await fixerCadre(1000, 600);
|
||||||
|
const poser = async () => {};
|
||||||
|
await expect(fixerCadre(1366, 700, { poser })).rejects.toThrow(/1366 × 700.*1000 × 600/);
|
||||||
|
});
|
||||||
|
});
|
||||||
593
test/navigateur/rendu.js
Normal file
593
test/navigateur/rendu.js
Normal file
|
|
@ -0,0 +1,593 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Aides de rendu des épreuves du navigateur (§ 14.2, § 14.5). Elles lisent ce
|
||||||
|
// que le moteur de rendu a résolu — style calculé, boîte, élément qu'atteint
|
||||||
|
// un point — et jamais la présence d'une classe. Elles ne lisent aucun pixel :
|
||||||
|
// le contraste se calcule sur les couleurs calculées, qui ne sont les couleurs
|
||||||
|
// peintes que sans opacité partielle, filtre, masque ni mode de fusion, ce
|
||||||
|
// qu'attenuations relève sur toute l'ascendance d'un texte.
|
||||||
|
//
|
||||||
|
// Une couleur est un tableau [r, g, b, a] : composantes sRGB de 0 à 255,
|
||||||
|
// alpha de 0 à 1, la forme de palette.js.
|
||||||
|
//
|
||||||
|
// Un porteur de texte est { element, pseudo } : un élément qui porte lui-même
|
||||||
|
// un nœud de texte non vide (pseudo null), ou l'un de ses pseudo-éléments
|
||||||
|
// ::before et ::after dont content produit un texte. Les aides qui reçoivent
|
||||||
|
// un porteur acceptent aussi un élément nu, lu comme { element, pseudo: null }.
|
||||||
|
//
|
||||||
|
// Ce que les aides ne décèlent pas. Le centre de la boîte tient lieu de
|
||||||
|
// l'élément : un élément dont ce centre tombe hors de sa peinture — un anneau,
|
||||||
|
// un trait coudé — est dit hors d'atteinte (« n'atteint pas »). La lecture
|
||||||
|
// s'ancre à la première entrée d'elementsFromPoint que la cible contient : un
|
||||||
|
// descendant hissé au-dessus d'un recouvrement l'y ancre, et ce recouvrement
|
||||||
|
// échappe à visible et se lit comme fond peint dessous. elementsFromPoint ne
|
||||||
|
// rend jamais un pseudo-élément : sa peinture se lit sur son hôte. Un
|
||||||
|
// pseudo-élément à texte fait couvrir toute la boîte de l'hôte, un
|
||||||
|
// pseudo-élément sans texte ne couvre rien, et aucun fond de pseudo-élément
|
||||||
|
// ne se lit sous le texte d'un autre élément. Une boîte HTML se juge
|
||||||
|
// entière : son fond, son texte ou son image couvrent tout point de la boîte,
|
||||||
|
// coin arrondi ou zone sans glyphe compris. isPointInFill et isPointInStroke
|
||||||
|
// ignorent clip-path : une forme rognée au point y compte comme peinte.
|
||||||
|
|
||||||
|
/** Le rapport de contraste des WCAG, seule implémentation du dépôt. */
|
||||||
|
export { contraste } from '../../src/interface/palette.js';
|
||||||
|
|
||||||
|
const PSEUDOS = ['::before', '::after'];
|
||||||
|
|
||||||
|
// Éléments dont le texte ne se peint jamais : code, feuille, gabarit, et les
|
||||||
|
// textes de description d'un SVG, lus par l'accessibilité seule.
|
||||||
|
const TEXTES_NON_PEINTS = new Set(['script', 'style', 'template', 'noscript', 'title', 'desc', 'metadata']);
|
||||||
|
|
||||||
|
// Conteneurs SVG dont les formes ne se peignent qu'à travers une référence :
|
||||||
|
// une forme qu'ils portent n'est jamais « dessous » un élément du dessin.
|
||||||
|
const CONTENEURS_DE_REFERENCE = 'defs, pattern, clipPath, mask, marker, symbol, linearGradient, radialGradient';
|
||||||
|
|
||||||
|
// Valeurs calculées qui laissent la couleur peinte égale à la couleur résolue.
|
||||||
|
const NEUTRES = [
|
||||||
|
['opacity', '1'],
|
||||||
|
['fill-opacity', '1'],
|
||||||
|
['stroke-opacity', '1'],
|
||||||
|
['filter', 'none'],
|
||||||
|
['backdrop-filter', 'none'],
|
||||||
|
['mask-image', 'none'],
|
||||||
|
['mix-blend-mode', 'normal'],
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Composantes [r, g, b, a] d'une couleur calculée. Chromium rend une couleur
|
||||||
|
* sRGB sous la forme « rgb(r, g, b) » ou « rgba(r, g, b, a) » ; toute autre
|
||||||
|
* forme lève, plutôt que de laisser passer une épreuve sans mesure. */
|
||||||
|
export function composantes(couleur) {
|
||||||
|
const m = /^rgba?\(([\d.]+), ([\d.]+), ([\d.]+)(?:, ([\d.]+))?\)$/.exec(couleur);
|
||||||
|
if (m === null) {
|
||||||
|
throw new Error(`couleur calculée illisible : « ${couleur} »`);
|
||||||
|
}
|
||||||
|
return [Number(m[1]), Number(m[2]), Number(m[3]), m[4] === undefined ? 1 : Number(m[4])];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** L'élément, puis chacun de ses ancêtres jusqu'à la racine du document ;
|
||||||
|
* parentElement relie aussi un élément SVG à son parent. */
|
||||||
|
export function ascendance(element) {
|
||||||
|
const chaine = [];
|
||||||
|
for (let n = element; n !== null; n = n.parentElement) {
|
||||||
|
chaine.push(n);
|
||||||
|
}
|
||||||
|
return chaine;
|
||||||
|
}
|
||||||
|
|
||||||
|
// { element, pseudo } d'un porteur ou d'un élément nu.
|
||||||
|
function porteur(cible) {
|
||||||
|
return cible instanceof Element ? { element: cible, pseudo: null } : cible;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nom lisible d'un élément ou d'un porteur dans un message d'échec : la
|
||||||
|
* balise, chaque attribut data-… dans l'ordre de l'élément, chaque classe,
|
||||||
|
* puis le pseudo-élément. */
|
||||||
|
export function designation(cible) {
|
||||||
|
const { element, pseudo } = porteur(cible);
|
||||||
|
const donnees = [...element.attributes]
|
||||||
|
.filter((a) => a.name.startsWith('data-'))
|
||||||
|
.map((a) => (a.value === '' ? `[${a.name}]` : `[${a.name}="${a.value}"]`))
|
||||||
|
.join('');
|
||||||
|
const classes = [...element.classList].map((c) => `.${c}`).join('');
|
||||||
|
return `${element.localName}${donnees}${classes}${pseudo ?? ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Le content calculé d'un pseudo-élément produit-il un texte ? « none » et
|
||||||
|
// « normal » n'en créent pas ; une chaîne littérale vide ou faite de blancs
|
||||||
|
// crée une boîte sans texte à lire. Toute autre valeur — compteur, attr(),
|
||||||
|
// image — compte comme texte.
|
||||||
|
function contenuTextuel(element, pseudo) {
|
||||||
|
const contenu = getComputedStyle(element, pseudo).content;
|
||||||
|
if (contenu === 'none' || contenu === 'normal') return false;
|
||||||
|
const litteral = /^"((?:[^"\\]|\\.)*)"$/.exec(contenu) ?? /^'((?:[^'\\]|\\.)*)'$/.exec(contenu);
|
||||||
|
return litteral === null || litteral[1].trim() !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// L'élément porte-t-il lui-même un nœud de texte non vide ?
|
||||||
|
function porteTexte(element) {
|
||||||
|
return [...element.childNodes].some((n) => n.nodeType === Node.TEXT_NODE && n.textContent.trim() !== '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Porteurs de texte du sous-arbre de racine, racine comprise, dans l'ordre
|
||||||
|
* du document : pour chaque élément HTML, son ::before à content, l'élément
|
||||||
|
* s'il porte un nœud de texte non vide, puis son ::after à content. Un
|
||||||
|
* élément SVG ne porte pas de pseudo-élément ; les éléments dont le texte ne
|
||||||
|
* se peint jamais (script, style, title…) sont écartés. */
|
||||||
|
export function porteursDeTexte(racine) {
|
||||||
|
const porteurs = [];
|
||||||
|
for (const element of [racine, ...racine.querySelectorAll('*')]) {
|
||||||
|
if (TEXTES_NON_PEINTS.has(element.localName)) continue;
|
||||||
|
const html = element instanceof HTMLElement;
|
||||||
|
if (html && contenuTextuel(element, '::before')) porteurs.push({ element, pseudo: '::before' });
|
||||||
|
if (porteTexte(element)) porteurs.push({ element, pseudo: null });
|
||||||
|
if (html && contenuTextuel(element, '::after')) porteurs.push({ element, pseudo: '::after' });
|
||||||
|
}
|
||||||
|
return porteurs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Couleur du texte d'un porteur : color d'un élément HTML ou d'un
|
||||||
|
* pseudo-élément, fill d'un texte SVG. Un fill qui n'est pas une couleur —
|
||||||
|
* none, un motif — lève par composantes. */
|
||||||
|
export function couleurTexte(cible) {
|
||||||
|
const { element, pseudo } = porteur(cible);
|
||||||
|
const style = getComputedStyle(element, pseudo);
|
||||||
|
return composantes(element instanceof SVGElement ? style.fill : style.color);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Une couleur peint-elle quelque chose ? Un alpha nul ne peint rien.
|
||||||
|
const peint = (couleur) => couleur[3] !== 0;
|
||||||
|
|
||||||
|
// Ajoute les couleurs à la liste, sans doublon, dans leur ordre.
|
||||||
|
function ajouter(liste, couleurs) {
|
||||||
|
for (const c of couleurs) {
|
||||||
|
if (!liste.some((d) => d.join() === c.join())) liste.push(c);
|
||||||
|
}
|
||||||
|
return liste;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEGRADES = new Set(['linearGradient', 'radialGradient']);
|
||||||
|
|
||||||
|
// Un serveur de peinture porte-t-il lui-même ce qu'il peint : des <stop> pour
|
||||||
|
// un dégradé, des éléments enfants pour un motif ?
|
||||||
|
function porteSonContenu(serveur) {
|
||||||
|
return DEGRADES.has(serveur.localName)
|
||||||
|
? serveur.querySelector(':scope > stop') !== null
|
||||||
|
: serveur.firstElementChild !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Élément que désigne une peinture url(#id), lue dans le document de element,
|
||||||
|
// ou null pour une couleur. Un dégradé sans <stop> ou un motif sans enfant
|
||||||
|
// hérite ceux du serveur que désigne son href (ou xlink:href) : la chaîne se
|
||||||
|
// suit jusqu'au premier serveur qui porte son contenu, ou jusqu'au dernier,
|
||||||
|
// qui ne peint alors rien. Une référence vers un élément absent, ou une
|
||||||
|
// chaîne qui boucle, lève.
|
||||||
|
function serveurDePeinture(valeur, element) {
|
||||||
|
const reference = /^url\("?#([^")]+)"?\)/.exec(valeur);
|
||||||
|
if (reference === null) return null;
|
||||||
|
const doc = element.ownerDocument;
|
||||||
|
let serveur = doc.getElementById(reference[1]);
|
||||||
|
if (serveur === null) {
|
||||||
|
throw new Error(`peinture ${valeur} : aucun élément #${reference[1]}`);
|
||||||
|
}
|
||||||
|
const vus = new Set([serveur]);
|
||||||
|
while (!porteSonContenu(serveur)) {
|
||||||
|
const lien = serveur.getAttribute('href') ?? serveur.getAttributeNS('http://www.w3.org/1999/xlink', 'href');
|
||||||
|
if (lien === null || !lien.startsWith('#')) break;
|
||||||
|
const suivant = doc.getElementById(lien.slice(1));
|
||||||
|
if (suivant === null) throw new Error(`peinture ${valeur} : #${serveur.id} hérite de ${lien}, absent`);
|
||||||
|
if (vus.has(suivant)) throw new Error(`peinture ${valeur} : la chaîne href de #${serveur.id} boucle`);
|
||||||
|
vus.add(suivant);
|
||||||
|
serveur = suivant;
|
||||||
|
}
|
||||||
|
return serveur;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Éléments d'un motif qui peignent : formes et textes, hors display none. Un
|
||||||
|
// groupe <g> porte un fill calculé, noir par défaut, qu'il ne peint pas ; il
|
||||||
|
// est écarté, ses formes sont lues.
|
||||||
|
function peintresDuMotif(motif) {
|
||||||
|
return [...motif.querySelectorAll('*')].filter(
|
||||||
|
(n) =>
|
||||||
|
(n instanceof SVGGeometryElement || n instanceof SVGTextContentElement) &&
|
||||||
|
n.checkVisibility({ visibilityProperty: true }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Couleurs que peint une valeur calculée de fill ou de stroke : none ne peint
|
||||||
|
// rien ; url(#id) désigne un motif, dont les formes et les textes peignent par
|
||||||
|
// leurs propres fill et stroke, ou un dégradé, qui peint ses stop-color ;
|
||||||
|
// sinon, une couleur. Une référence vers un élément d'un autre genre lève.
|
||||||
|
function couleursDeLaPeinture(valeur, element) {
|
||||||
|
if (valeur === 'none') return [];
|
||||||
|
const serveur = serveurDePeinture(valeur, element);
|
||||||
|
if (serveur === null) {
|
||||||
|
const couleur = composantes(valeur);
|
||||||
|
return peint(couleur) ? [couleur] : [];
|
||||||
|
}
|
||||||
|
if (serveur.localName === 'pattern') {
|
||||||
|
const couleurs = [];
|
||||||
|
for (const peintre of peintresDuMotif(serveur)) {
|
||||||
|
const { fill, stroke } = couleursPeintes(peintre);
|
||||||
|
ajouter(couleurs, [...fill, ...stroke]);
|
||||||
|
}
|
||||||
|
return couleurs;
|
||||||
|
}
|
||||||
|
if (DEGRADES.has(serveur.localName)) {
|
||||||
|
return ajouter(
|
||||||
|
[],
|
||||||
|
[...serveur.querySelectorAll('stop')]
|
||||||
|
.map((arret) => composantes(getComputedStyle(arret).stopColor))
|
||||||
|
.filter(peint),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new Error(`peinture ${valeur} : #${serveur.id} est un <${serveur.localName}>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** fill et stroke calculés d'une forme SVG, chacun en liste de couleurs, sans
|
||||||
|
* doublon : vide pour none ou une couleur transparente ; un motif url(#…)
|
||||||
|
* rend les couleurs que peignent ses enfants, un dégradé ses arrêts. */
|
||||||
|
export function couleursPeintes(element) {
|
||||||
|
const style = getComputedStyle(element);
|
||||||
|
return {
|
||||||
|
fill: couleursDeLaPeinture(style.fill, element),
|
||||||
|
stroke: couleursDeLaPeinture(style.stroke, element),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fond propre d'un élément ou d'un pseudo-élément, s'il est opaque, sinon
|
||||||
|
// null. Une image de fond se peint par-dessus la couleur de fond, et ses
|
||||||
|
// couleurs ne se lisent pas dans le style calculé : elle lève en nommant son
|
||||||
|
// porteur, plutôt que de laisser mesurer une couleur que l'écran ne montre pas.
|
||||||
|
function fondOpaque(element, pseudo = null) {
|
||||||
|
const style = getComputedStyle(element, pseudo);
|
||||||
|
if (style.backgroundImage !== 'none') {
|
||||||
|
throw new Error(`fond ${designation({ element, pseudo })} : background-image ${style.backgroundImage} ne se mesure pas`);
|
||||||
|
}
|
||||||
|
const fond = composantes(style.backgroundColor);
|
||||||
|
return fond[3] === 1 ? fond : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Propriétés qui composent un élément avec ce qui est dessous : non neutres
|
||||||
|
// sur un fond ou sur l'un de ses ancêtres, sa couleur calculée n'est plus
|
||||||
|
// celle de l'écran.
|
||||||
|
const COMPOSITION = ['opacity', 'filter', 'mask-image', 'mix-blend-mode'];
|
||||||
|
|
||||||
|
const NEUTRE = new Map(NEUTRES);
|
||||||
|
|
||||||
|
// Fautes { element, pseudo, propriete, valeur } des propriétés nommées dont la
|
||||||
|
// valeur calculée sur element n'est pas neutre.
|
||||||
|
function fautesSur(element, proprietes) {
|
||||||
|
const style = getComputedStyle(element);
|
||||||
|
return proprietes
|
||||||
|
.filter((propriete) => style.getPropertyValue(propriete) !== NEUTRE.get(propriete))
|
||||||
|
.map((propriete) => ({ element, pseudo: null, propriete, valeur: style.getPropertyValue(propriete) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fautes de composition d'un fond : sur lui et sur chacun de ses ancêtres qui
|
||||||
|
// ne contiennent pas le texte mesuré. Un ancêtre commun compose le texte et
|
||||||
|
// son fond ensemble ; attenuations(texte) le relève déjà.
|
||||||
|
function voiles(fond, texte) {
|
||||||
|
return ascendance(fond)
|
||||||
|
.filter((n) => !n.contains(texte))
|
||||||
|
.flatMap((n) => fautesSur(n, COMPOSITION));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fautes qui rendent translucide la peinture fill ou stroke d'un élément SVG :
|
||||||
|
// les voiles de l'élément, son fill-opacity ou stroke-opacity — hérités, ils se
|
||||||
|
// lisent sur l'élément même —, une couleur d'alpha inférieur à 1 ; pour un
|
||||||
|
// motif, les mêmes fautes sur chaque forme qui y peint ; pour un dégradé, un
|
||||||
|
// arrêt translucide.
|
||||||
|
function translucidite(element, quoi, texte) {
|
||||||
|
const style = getComputedStyle(element);
|
||||||
|
const valeur = style.getPropertyValue(quoi);
|
||||||
|
const fautes = [...voiles(element, texte), ...fautesSur(element, [`${quoi}-opacity`])];
|
||||||
|
const serveur = serveurDePeinture(valeur, element);
|
||||||
|
if (serveur === null) {
|
||||||
|
if (composantes(valeur)[3] !== 1) fautes.push({ element, pseudo: null, propriete: quoi, valeur });
|
||||||
|
} else if (serveur.localName === 'pattern') {
|
||||||
|
for (const peintre of peintresDuMotif(serveur)) {
|
||||||
|
const { fill, stroke } = couleursPeintes(peintre);
|
||||||
|
if (fill.length > 0) fautes.push(...translucidite(peintre, 'fill', texte));
|
||||||
|
if (stroke.length > 0) fautes.push(...translucidite(peintre, 'stroke', texte));
|
||||||
|
}
|
||||||
|
} else if (DEGRADES.has(serveur.localName)) {
|
||||||
|
for (const arret of serveur.querySelectorAll('stop')) {
|
||||||
|
const { stopColor, stopOpacity } = getComputedStyle(arret);
|
||||||
|
if (composantes(stopColor)[3] !== 1) fautes.push({ element: arret, pseudo: null, propriete: 'stop-color', valeur: stopColor });
|
||||||
|
if (stopOpacity !== '1') fautes.push({ element: arret, pseudo: null, propriete: 'stop-opacity', valeur: stopOpacity });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fautes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lève quand le fond retenu n'est pas opaque à l'écran, en nommant le fond
|
||||||
|
// et chaque faute : une couleur composée avec ce qui est dessous ne se mesure
|
||||||
|
// pas sur sa valeur calculée (§ 14.5).
|
||||||
|
function exigerOpaque(fond, fautes, texte) {
|
||||||
|
if (fautes.length === 0) return;
|
||||||
|
const liste = fautes.map((f) => `${designation(f)} ${f.propriete}: ${f.valeur}`).join(' ; ');
|
||||||
|
throw new Error(`fond ${designation(fond)} translucide sous ${designation(texte)} : ${liste}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Peinture, 'stroke' ou 'fill', qu'une forme SVG pose au point donné en
|
||||||
|
// coordonnées de la fenêtre, ou null quand elle n'y peint rien : forme en
|
||||||
|
// display none, cachée ou d'opacité nulle, elle ou un ancêtre
|
||||||
|
// (checkVisibility) ; peinture absente, transparente ou d'opacité nulle ;
|
||||||
|
// point hors du trait et du remplissage. Le point passe en coordonnées
|
||||||
|
// propres de la forme par l'inverse de getScreenCTM, que le moteur de rendu
|
||||||
|
// tient pour exact (§ 7.2) ; isPointInStroke et isPointInFill ignorent
|
||||||
|
// pointer-events. Le trait se peint sur le remplissage et se teste d'abord.
|
||||||
|
function peintureAuPoint(forme, centre) {
|
||||||
|
if (!forme.checkVisibility({ opacityProperty: true, visibilityProperty: true })) return null;
|
||||||
|
const point = centre.matrixTransform(forme.getScreenCTM().inverse());
|
||||||
|
const couleurs = couleursPeintes(forme);
|
||||||
|
const style = getComputedStyle(forme);
|
||||||
|
for (const quoi of ['stroke', 'fill']) {
|
||||||
|
if (couleurs[quoi].length === 0 || Number(style.getPropertyValue(`${quoi}-opacity`)) === 0) continue;
|
||||||
|
if (quoi === 'stroke' ? forme.isPointInStroke(point) : forme.isPointInFill(point)) return quoi;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Couleurs qu'une forme SVG peint au point, ou null quand elle n'y peint
|
||||||
|
// rien ; lève quand la peinture retenue est translucide à l'écran.
|
||||||
|
function fondDeForme(forme, centre, texte) {
|
||||||
|
const quoi = peintureAuPoint(forme, centre);
|
||||||
|
if (quoi === null) return null;
|
||||||
|
exigerOpaque(forme, translucidite(forme, quoi, texte), texte);
|
||||||
|
return couleursPeintes(forme)[quoi];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Un <use> peint une copie de l'élément qu'il désigne, que les aides ne
|
||||||
|
// lisent pas : celui qui peint au point lève en se nommant, plutôt que de
|
||||||
|
// laisser rendre ce qui est sous lui.
|
||||||
|
function refuserUse(use, texte) {
|
||||||
|
return new Error(`fond sous ${designation(texte)} : ${designation(use)} peint une copie par référence, que l'aide ne lit pas`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Le <use> peint-il au point ? Sa boîte, dans son propre repère, contient le
|
||||||
|
// point, et il n'est ni caché ni d'opacité nulle.
|
||||||
|
function useAuPoint(use, centre) {
|
||||||
|
if (!use.checkVisibility({ opacityProperty: true, visibilityProperty: true })) return false;
|
||||||
|
const point = centre.matrixTransform(use.getScreenCTM().inverse());
|
||||||
|
const b = use.getBBox();
|
||||||
|
return point.x >= b.x && point.x <= b.x + b.width && point.y >= b.y && point.y <= b.y + b.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Centre de la boîte d'un élément, en coordonnées de la fenêtre.
|
||||||
|
function centreDe(element) {
|
||||||
|
const boite = element.getBoundingClientRect();
|
||||||
|
return new DOMPoint(boite.left + boite.width / 2, boite.top + boite.height / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feuille qui rend chaque élément atteignable le temps d'une lecture :
|
||||||
|
// elementsFromPoint suit l'atteinte du pointeur, et un élément en
|
||||||
|
// pointer-events: none n'en est pas moins peint.
|
||||||
|
const ATTEIGNABLE = '*, *::before, *::after { pointer-events: auto !important; }';
|
||||||
|
|
||||||
|
// Éléments présents au point centre, en coordonnées de la fenêtre, du plus
|
||||||
|
// haut au plus bas dans l'ordre de peinture — cascade, positionnement et
|
||||||
|
// dessins SVG compris —, pointer-events ignoré. Un point hors de la fenêtre
|
||||||
|
// n'atteint rien et lève, au nom de texte.
|
||||||
|
function elementsAuPoint(doc, centre, texte) {
|
||||||
|
const fenetre = doc.defaultView;
|
||||||
|
if (centre.x < 0 || centre.y < 0 || centre.x >= fenetre.innerWidth || centre.y >= fenetre.innerHeight) {
|
||||||
|
throw new Error(`fond sous ${designation(texte)} : centre (${centre.x}, ${centre.y}) hors de la fenêtre`);
|
||||||
|
}
|
||||||
|
const feuille = doc.createElement('style');
|
||||||
|
feuille.textContent = ATTEIGNABLE;
|
||||||
|
doc.head.append(feuille);
|
||||||
|
try {
|
||||||
|
return doc.elementsFromPoint(centre.x, centre.y);
|
||||||
|
} finally {
|
||||||
|
feuille.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Premier fond opaque peint au point centre sous racine. Parmi les éléments
|
||||||
|
// présents au point, seuls comptent la première entrée que racine contient et
|
||||||
|
// celles qui la suivent : ce qui la précède se peint au-dessus d'elle. Les
|
||||||
|
// descendants de racine sont écartés, racine elle-même non — le fond CSS d'un
|
||||||
|
// <svg> extérieur se lit. Un élément d'opacité nulle, lui ou un ancêtre, ne
|
||||||
|
// peint rien et se saute. Une forme SVG rend sa peinture au point, un <use>
|
||||||
|
// lève, les autres éléments internes d'un SVG ne peignent pas de fond ; un
|
||||||
|
// élément HTML ou un <svg> extérieur rend son fond opaque. Le fond retenu doit
|
||||||
|
// être opaque à l'écran, sinon lève ; une racine que le point n'atteint pas
|
||||||
|
// lève aussi. Rend [] quand rien d'opaque n'est dessous.
|
||||||
|
function fondAuPoint(centre, racine, texte) {
|
||||||
|
const atteints = elementsAuPoint(racine.ownerDocument, centre, texte);
|
||||||
|
const debut = atteints.findIndex((n) => racine.contains(n));
|
||||||
|
if (debut === -1) {
|
||||||
|
throw new Error(`fond sous ${designation(texte)} : le centre (${centre.x}, ${centre.y}) n'atteint pas ${designation(racine)}`);
|
||||||
|
}
|
||||||
|
for (const n of atteints.slice(debut)) {
|
||||||
|
if (n !== racine && racine.contains(n)) continue;
|
||||||
|
if (!n.checkVisibility({ opacityProperty: true })) continue;
|
||||||
|
if (n instanceof SVGGeometryElement) {
|
||||||
|
const couleurs = fondDeForme(n, centre, texte);
|
||||||
|
if (couleurs !== null) return couleurs;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (n instanceof SVGUseElement) throw refuserUse(n, texte);
|
||||||
|
if (n instanceof SVGElement && n.ownerSVGElement !== null) continue;
|
||||||
|
const fond = fondOpaque(n);
|
||||||
|
if (fond !== null) {
|
||||||
|
exigerOpaque(n, voiles(n, texte), texte);
|
||||||
|
return [fond];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Le <svg> le plus extérieur qui contient element.
|
||||||
|
function svgExterieur(element) {
|
||||||
|
let svg = element.ownerSVGElement ?? element;
|
||||||
|
while (svg.ownerSVGElement !== null) svg = svg.ownerSVGElement;
|
||||||
|
return svg;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Couleurs peintes sous un élément SVG, au centre de sa boîte : les formes
|
||||||
|
// et les <use> qui le précèdent dans l'ordre du document, hors de lui et de
|
||||||
|
// ses descendants et hors des conteneurs de référence, se peignent avant lui ;
|
||||||
|
// de la dernière à la première, la première forme qui peint au point rend ses
|
||||||
|
// couleurs, un <use> qui peint au point lève. Sans rien dessous dans le
|
||||||
|
// dessin, ce qui est peint au même point sous le <svg> extérieur, son propre
|
||||||
|
// fond CSS compris.
|
||||||
|
function fondSvg(element) {
|
||||||
|
const centre = centreDe(element);
|
||||||
|
const svg = svgExterieur(element);
|
||||||
|
const formes = [...svg.querySelectorAll('*')].filter(
|
||||||
|
(n) =>
|
||||||
|
(n instanceof SVGGeometryElement || n instanceof SVGUseElement) &&
|
||||||
|
n !== element &&
|
||||||
|
!element.contains(n) &&
|
||||||
|
n.closest(CONTENEURS_DE_REFERENCE) === null &&
|
||||||
|
(n.compareDocumentPosition(element) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0,
|
||||||
|
);
|
||||||
|
for (const forme of formes.reverse()) {
|
||||||
|
if (forme instanceof SVGUseElement) {
|
||||||
|
if (useAuPoint(forme, centre)) throw refuserUse(forme, element);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const couleurs = fondDeForme(forme, centre, element);
|
||||||
|
if (couleurs !== null) return couleurs;
|
||||||
|
}
|
||||||
|
return fondAuPoint(centre, svg, element);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Couleurs peintes sous le contenu d'un porteur ou d'un élément, au centre
|
||||||
|
* de sa boîte. Un élément HTML : son propre fond s'il est opaque — son texte
|
||||||
|
* se peint dessus —, sinon le premier fond opaque peint dessous à ce point,
|
||||||
|
* ses descendants exceptés : un élément HTML frère ou cousin, ou le trait ou
|
||||||
|
* le remplissage d'une forme SVG ; ce qui se peint au-dessus de lui ne compte
|
||||||
|
* pas, en pointer-events: none ou non. Un pseudo-élément : son fond opaque,
|
||||||
|
* sinon celui de son hôte. Un élément SVG, lui et ses descendants exceptés :
|
||||||
|
* le trait ou le remplissage de la forme qui le précède dessous — les
|
||||||
|
* couleurs d'un motif, ou d'un dégradé, href suivi —, ou ce qui est peint
|
||||||
|
* sous le dessin, fond CSS du <svg> extérieur compris. Une forme ou un fond
|
||||||
|
* qui ne peint rien — caché, opacité nulle, peinture d'opacité nulle — est
|
||||||
|
* sauté. Rend [] quand rien d'opaque n'est dessous. Lève quand le fond retenu
|
||||||
|
* est translucide à l'écran (opacity, fill-opacity, stroke-opacity, alpha,
|
||||||
|
* filter, masque, fusion, sur lui ou ses ancêtres propres), quand une image
|
||||||
|
* de fond ou un <use> se peint entre le texte et le fond retenu, quand le
|
||||||
|
* centre est hors de la fenêtre, ou quand il n'atteint pas l'élément. */
|
||||||
|
export function fondPeintSous(cible) {
|
||||||
|
const { element, pseudo } = porteur(cible);
|
||||||
|
if (pseudo !== null) {
|
||||||
|
const fond = fondOpaque(element, pseudo);
|
||||||
|
return fond !== null ? [fond] : fondPeintSous(element);
|
||||||
|
}
|
||||||
|
if (element instanceof SVGElement && element.ownerSVGElement !== null) return fondSvg(element);
|
||||||
|
const fond = fondOpaque(element);
|
||||||
|
if (fond !== null) return [fond];
|
||||||
|
return fondAuPoint(centreDe(element), element, element);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fautes de neutralité d'un style : chaque propriété de NEUTRES dont la
|
||||||
|
// valeur calculée n'est pas la valeur neutre.
|
||||||
|
function fautesDe(element, pseudo) {
|
||||||
|
const style = getComputedStyle(element, pseudo);
|
||||||
|
return NEUTRES.filter(([propriete, neutre]) => style.getPropertyValue(propriete) !== neutre).map(
|
||||||
|
([propriete]) => ({ element, pseudo, propriete, valeur: style.getPropertyValue(propriete) }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Propriétés qui font différer le pixel peint de la couleur calculée :
|
||||||
|
* opacity, fill-opacity et stroke-opacity différents de 1 ; filter,
|
||||||
|
* backdrop-filter et mask-image différents de none ; mix-blend-mode différent
|
||||||
|
* de normal. Relevées sur le pseudo-élément, puis sur l'élément et chacun de
|
||||||
|
* ses ancêtres : opacity et filter ne s'héritent pas, et changent le pixel
|
||||||
|
* sans changer le style calculé du texte. fill-opacity s'hérite : posé sur un
|
||||||
|
* groupe, il se relève sur le groupe et sur chaque niveau qui en hérite.
|
||||||
|
* → [{ element, pseudo, propriete, valeur }], vide quand tout est neutre. */
|
||||||
|
export function attenuations(cible) {
|
||||||
|
const { element, pseudo } = porteur(cible);
|
||||||
|
return [
|
||||||
|
...(pseudo !== null ? fautesDe(element, pseudo) : []),
|
||||||
|
...ascendance(element).flatMap((n) => fautesDe(n, null)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Éléments dont le contenu se peint sans qu'un style le dise : image, média,
|
||||||
|
// dessin, cadre, champ.
|
||||||
|
const REMPLACES = new Set(['img', 'video', 'canvas', 'iframe', 'embed', 'object', 'input', 'textarea', 'select']);
|
||||||
|
|
||||||
|
// Un élément présent au point y peint-il quelque chose qui couvre ce qui est
|
||||||
|
// dessous ? Non s'il est caché ou d'opacité nulle, lui ou un ancêtre. Une
|
||||||
|
// forme SVG : une peinture au point (peintureAuPoint). Un autre élément
|
||||||
|
// interne d'un SVG — texte, <use>, image — : oui, son contenu est au point.
|
||||||
|
// Un élément HTML ou un <svg> extérieur : un fond d'alpha non nul, une image
|
||||||
|
// de fond, un nœud de texte propre, un pseudo-élément à texte, ou un élément
|
||||||
|
// remplacé ; une boîte sans rien de cela, qui ne sert qu'à la mise en page,
|
||||||
|
// ne couvre rien. Bordure, ombre et contour ne se lisent pas.
|
||||||
|
function peintAuPoint(n, centre) {
|
||||||
|
if (!n.checkVisibility({ opacityProperty: true, visibilityProperty: true })) return false;
|
||||||
|
if (n instanceof SVGGeometryElement) return peintureAuPoint(n, centre) !== null;
|
||||||
|
if (n instanceof SVGElement && n.ownerSVGElement !== null) return true;
|
||||||
|
const style = getComputedStyle(n);
|
||||||
|
return (
|
||||||
|
composantes(style.backgroundColor)[3] !== 0 ||
|
||||||
|
style.backgroundImage !== 'none' ||
|
||||||
|
porteTexte(n) ||
|
||||||
|
(n instanceof HTMLElement && PSEUDOS.some((pseudo) => contenuTextuel(n, pseudo))) ||
|
||||||
|
REMPLACES.has(n.localName)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** true quand la cible se voit dans la fenêtre, sinon la raison, en texte.
|
||||||
|
* Un élément : checkVisibility (display, visibility, opacité nulle,
|
||||||
|
* content-visibility), une boîte non vide qui tient dans la fenêtre, puis
|
||||||
|
* l'occlusion au centre de cette boîte. Les éléments présents au point se
|
||||||
|
* lisent pointer-events ignoré — un élément en pointer-events: none se voit,
|
||||||
|
* et un recouvrement en pointer-events: none cache ce qu'il couvre. Le centre
|
||||||
|
* doit atteindre l'élément ou l'un de ses descendants — un élément rogné par
|
||||||
|
* un ancêtre le laisse à un autre —, et aucun élément peint au-dessus ne doit
|
||||||
|
* y peindre (peintAuPoint) : une boîte de mise en page vide, une zone
|
||||||
|
* d'atteinte d'opacité nulle ne cachent rien. Un pseudo-élément : son hôte,
|
||||||
|
* puis son display, sa visibility et son content. */
|
||||||
|
export function visible(cible) {
|
||||||
|
const { element, pseudo } = porteur(cible);
|
||||||
|
if (pseudo !== null) {
|
||||||
|
const hote = visible(element);
|
||||||
|
if (hote !== true) return `hôte : ${hote}`;
|
||||||
|
const style = getComputedStyle(element, pseudo);
|
||||||
|
if (style.display === 'none') return `${pseudo} : display none`;
|
||||||
|
if (style.visibility !== 'visible') return `${pseudo} : visibility ${style.visibility}`;
|
||||||
|
if (!contenuTextuel(element, pseudo)) return `${pseudo} : content ${style.content}`;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!element.checkVisibility({ opacityProperty: true, visibilityProperty: true })) {
|
||||||
|
return 'checkVisibility faux';
|
||||||
|
}
|
||||||
|
const b = element.getBoundingClientRect();
|
||||||
|
if (!(b.width > 0 && b.height > 0)) return `boîte vide : ${b.width} × ${b.height}`;
|
||||||
|
if (b.left < 0 || b.top < 0 || b.right > innerWidth || b.bottom > innerHeight) {
|
||||||
|
return `hors de la fenêtre ${innerWidth} × ${innerHeight} : boîte (${b.left}, ${b.top}) – (${b.right}, ${b.bottom})`;
|
||||||
|
}
|
||||||
|
const centre = centreDe(element);
|
||||||
|
const atteints = elementsAuPoint(element.ownerDocument, centre, element);
|
||||||
|
const rang = atteints.findIndex((n) => element.contains(n));
|
||||||
|
if (rang === -1) return `le centre de sa boîte n'atteint pas ${designation(element)} : rogné, ou hors de son dessin`;
|
||||||
|
const couvrant = atteints.slice(0, rang).find((n) => peintAuPoint(n, centre));
|
||||||
|
if (couvrant !== undefined) return `le centre de sa boîte est couvert par ${designation(couvrant)}`;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pose data-theme ('clair'|'sombre') sur <html>, attend mesure(), puis rend
|
||||||
|
* à l'attribut sa valeur d'avant, ou son absence, même quand mesure lève :
|
||||||
|
* l'épreuve suivante part du même document. Rend la valeur de mesure. */
|
||||||
|
export async function sousTheme(theme, mesure) {
|
||||||
|
const racine = document.documentElement;
|
||||||
|
const avant = racine.getAttribute('data-theme');
|
||||||
|
racine.setAttribute('data-theme', theme);
|
||||||
|
try {
|
||||||
|
return await mesure();
|
||||||
|
} finally {
|
||||||
|
if (avant === null) {
|
||||||
|
racine.removeAttribute('data-theme');
|
||||||
|
} else {
|
||||||
|
racine.setAttribute('data-theme', avant);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
648
test/navigateur/rendu.navigateur.test.js
Normal file
648
test/navigateur/rendu.navigateur.test.js
Normal file
|
|
@ -0,0 +1,648 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Épreuves des aides de rendu (§ 14.2, § 14.5) dans un vrai moteur de rendu.
|
||||||
|
// Chaque épreuve monte un petit fragment HTML ou SVG aux couleurs inventées,
|
||||||
|
// lit ce que l'aide en rend et le confronte à ce que le fragment déclare. Le
|
||||||
|
// projet navigateur emploie expect, test et describe de Vitest.
|
||||||
|
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
|
||||||
|
import {
|
||||||
|
ascendance,
|
||||||
|
attenuations,
|
||||||
|
composantes,
|
||||||
|
contraste,
|
||||||
|
couleursPeintes,
|
||||||
|
couleurTexte,
|
||||||
|
designation,
|
||||||
|
fondPeintSous,
|
||||||
|
porteursDeTexte,
|
||||||
|
sousTheme,
|
||||||
|
visible,
|
||||||
|
} from './rendu.js';
|
||||||
|
import { contraste as contrastePalette } from '../../src/interface/palette.js';
|
||||||
|
|
||||||
|
const SVG = 'http://www.w3.org/2000/svg';
|
||||||
|
|
||||||
|
let scene;
|
||||||
|
let feuille;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
scene = document.createElement('div');
|
||||||
|
scene.setAttribute('data-scene', '');
|
||||||
|
document.body.append(scene);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
scene.remove();
|
||||||
|
feuille?.remove();
|
||||||
|
feuille = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Monte le fragment dans la scène et rend la scène.
|
||||||
|
function monter(html) {
|
||||||
|
scene.innerHTML = html;
|
||||||
|
return scene;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pose une feuille de style dans <head> : un pseudo-élément ne se déclare
|
||||||
|
// que par une règle.
|
||||||
|
function regles(texte) {
|
||||||
|
feuille = document.createElement('style');
|
||||||
|
feuille.textContent = texte;
|
||||||
|
document.head.append(feuille);
|
||||||
|
}
|
||||||
|
|
||||||
|
const $ = (selecteur) => scene.querySelector(selecteur);
|
||||||
|
|
||||||
|
describe('composantes', () => {
|
||||||
|
test('lit rgb(…) et rgba(…)', () => {
|
||||||
|
expect(composantes('rgb(12, 34, 56)')).toEqual([12, 34, 56, 1]);
|
||||||
|
expect(composantes('rgba(12, 34, 56, 0.5)')).toEqual([12, 34, 56, 0.5]);
|
||||||
|
expect(composantes('rgba(0, 0, 0, 0)')).toEqual([0, 0, 0, 0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lève sur une forme inconnue', () => {
|
||||||
|
for (const forme of ['hsl(0 0% 0%)', 'none', 'url("#motif")', 'rouge', '']) {
|
||||||
|
expect(() => composantes(forme), forme).toThrow(/couleur/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('contraste est celui de palette.js', () => {
|
||||||
|
expect(contraste).toBe(contrastePalette);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ascendance et designation', () => {
|
||||||
|
test("l'ascendance d'un élément SVG remonte jusqu'à <html>", () => {
|
||||||
|
monter(`<svg xmlns="${SVG}"><g><text x="5" y="15">Iris</text></g></svg>`);
|
||||||
|
const chaine = ascendance($('text'));
|
||||||
|
expect(chaine.map((n) => n.localName).slice(0, 4)).toEqual(['text', 'g', 'svg', 'div']);
|
||||||
|
expect(chaine.at(-1)).toBe(document.documentElement);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('designation nomme la balise, les attributs data-… et les classes', () => {
|
||||||
|
monter('<span class="nom gras" data-participant="7" data-actif>Théo</span><b>x</b>');
|
||||||
|
expect(designation($('span'))).toBe('span[data-participant="7"][data-actif].nom.gras');
|
||||||
|
expect(designation($('b'))).toBe('b');
|
||||||
|
expect(designation({ element: $('span'), pseudo: '::before' })).toBe(
|
||||||
|
'span[data-participant="7"][data-actif].nom.gras::before',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('porteursDeTexte', () => {
|
||||||
|
test('rend les éléments à texte non vide, HTML et SVG, dans leur ordre', () => {
|
||||||
|
monter(
|
||||||
|
`<p>Ondine <em>Lacasse</em></p><div> <span> </span></div>` +
|
||||||
|
`<svg xmlns="${SVG}"><title>bulle</title><text x="0" y="10">Grisaille</text></svg>`,
|
||||||
|
);
|
||||||
|
const porteurs = porteursDeTexte(scene);
|
||||||
|
expect(porteurs.length).toBe(3);
|
||||||
|
expect(porteurs.map((p) => p.element.localName)).toEqual(['p', 'em', 'text']);
|
||||||
|
expect(porteurs.every((p) => p.pseudo === null)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rend un ::before à content comme porteur, pas un ::after à content vide', () => {
|
||||||
|
regles(
|
||||||
|
'[data-scene] .epreuve-marque::before { content: "→ "; }' +
|
||||||
|
'[data-scene] .epreuve-marque::after { content: ""; }',
|
||||||
|
);
|
||||||
|
monter('<span class="epreuve-marque"></span>');
|
||||||
|
expect(porteursDeTexte(scene)).toEqual([{ element: $('span'), pseudo: '::before' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un pseudo-élément dont content n'est qu'un blanc n'est pas un porteur", () => {
|
||||||
|
regles(
|
||||||
|
'[data-scene] .epreuve-blanc::before { content: " "; }' +
|
||||||
|
'[data-scene] .epreuve-blanc::after { content: "\\a0"; }',
|
||||||
|
);
|
||||||
|
monter('<span class="epreuve-blanc">a</span>');
|
||||||
|
expect(porteursDeTexte(scene)).toEqual([{ element: $('span'), pseudo: null }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('couleurTexte', () => {
|
||||||
|
test("lit color d'un élément HTML et fill d'un <text> SVG", () => {
|
||||||
|
monter(
|
||||||
|
`<span style="color: rgb(40, 50, 60)">Iris</span>` +
|
||||||
|
`<svg xmlns="${SVG}"><text x="0" y="10" fill="rgb(70, 80, 90)" style="color: rgb(1, 1, 1)">Ombrelle</text></svg>`,
|
||||||
|
);
|
||||||
|
expect(couleurTexte({ element: $('span'), pseudo: null })).toEqual([40, 50, 60, 1]);
|
||||||
|
expect(couleurTexte({ element: $('text'), pseudo: null })).toEqual([70, 80, 90, 1]);
|
||||||
|
expect(couleurTexte($('span'))).toEqual([40, 50, 60, 1]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('couleursPeintes', () => {
|
||||||
|
test('un motif de hachure rend les deux couleurs que peignent ses enfants', () => {
|
||||||
|
monter(
|
||||||
|
`<svg xmlns="${SVG}" width="80" height="80"><defs>` +
|
||||||
|
`<pattern id="epreuve-hachure" width="8" height="8" patternUnits="userSpaceOnUse">` +
|
||||||
|
`<rect width="8" height="8" fill="rgb(250, 240, 200)"/>` +
|
||||||
|
`<path d="M0 8 L8 0" fill="none" stroke="rgb(20, 30, 90)"/>` +
|
||||||
|
`</pattern></defs>` +
|
||||||
|
`<rect data-forme width="80" height="80" fill="url(#epreuve-hachure)" stroke="rgb(1, 2, 3)"/></svg>`,
|
||||||
|
);
|
||||||
|
expect(couleursPeintes($('[data-forme]'))).toEqual({
|
||||||
|
fill: [
|
||||||
|
[250, 240, 200, 1],
|
||||||
|
[20, 30, 90, 1],
|
||||||
|
],
|
||||||
|
stroke: [[1, 2, 3, 1]],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un remplissage plein rend sa couleur, none et transparent ne rendent rien', () => {
|
||||||
|
monter(
|
||||||
|
`<svg xmlns="${SVG}"><circle data-a r="5" cx="5" cy="5" fill="rgb(9, 8, 7)"/>` +
|
||||||
|
`<circle data-b r="5" cx="5" cy="5" fill="none" stroke="transparent"/></svg>`,
|
||||||
|
);
|
||||||
|
expect(couleursPeintes($('[data-a]'))).toEqual({ fill: [[9, 8, 7, 1]], stroke: [] });
|
||||||
|
expect(couleursPeintes($('[data-b]'))).toEqual({ fill: [], stroke: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('une hachure groupée dans un <g> : les couleurs de ses formes, pas le noir du groupe', () => {
|
||||||
|
monter(
|
||||||
|
`<svg xmlns="${SVG}" width="80" height="80"><defs>` +
|
||||||
|
`<pattern id="epreuve-hachure-groupee" width="8" height="8" patternUnits="userSpaceOnUse"><g>` +
|
||||||
|
`<rect width="8" height="8" fill="rgb(250, 240, 200)"/>` +
|
||||||
|
`<path d="M0 8 L8 0" fill="none" stroke="rgb(20, 30, 90)"/>` +
|
||||||
|
`<rect width="2" height="2" fill="rgb(7, 7, 7)" style="display: none"/>` +
|
||||||
|
`</g></pattern></defs>` +
|
||||||
|
`<rect data-forme width="80" height="80" fill="url(#epreuve-hachure-groupee)"/></svg>`,
|
||||||
|
);
|
||||||
|
expect(couleursPeintes($('[data-forme]')).fill).toEqual([
|
||||||
|
[250, 240, 200, 1],
|
||||||
|
[20, 30, 90, 1],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fondPeintSous', () => {
|
||||||
|
test("un cercle posé sur un rectangle rempli : le remplissage du rectangle", () => {
|
||||||
|
monter(
|
||||||
|
`<div style="background: rgb(255, 255, 255)"><svg xmlns="${SVG}" width="200" height="100">` +
|
||||||
|
`<rect width="200" height="100" fill="rgb(200, 220, 240)"/>` +
|
||||||
|
`<circle data-pastille cx="100" cy="50" r="20" fill="rgb(10, 10, 10)"/></svg></div>`,
|
||||||
|
);
|
||||||
|
expect(fondPeintSous($('[data-pastille]'))).toEqual([[200, 220, 240, 1]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un texte SVG sur un rectangle hachuré : chaque couleur du motif', () => {
|
||||||
|
monter(
|
||||||
|
`<svg xmlns="${SVG}" width="200" height="100"><defs>` +
|
||||||
|
`<pattern id="epreuve-hachure-fond" width="8" height="8" patternUnits="userSpaceOnUse">` +
|
||||||
|
`<rect width="8" height="8" fill="rgb(250, 240, 200)"/>` +
|
||||||
|
`<path d="M0 8 L8 0" fill="none" stroke="rgb(20, 30, 90)"/></pattern></defs>` +
|
||||||
|
`<rect x="10" y="10" width="180" height="80" fill="url(#epreuve-hachure-fond)"/>` +
|
||||||
|
`<text x="40" y="55" fill="rgb(0, 0, 0)">Pervenche</text></svg>`,
|
||||||
|
);
|
||||||
|
expect(fondPeintSous({ element: $('text'), pseudo: null })).toEqual([
|
||||||
|
[250, 240, 200, 1],
|
||||||
|
[20, 30, 90, 1],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('seules comptent les formes qui précèdent la cible : un rectangle peint après elle ne la porte pas', () => {
|
||||||
|
monter(
|
||||||
|
`<svg xmlns="${SVG}" width="200" height="100">` +
|
||||||
|
`<rect data-a width="200" height="100" fill="rgb(200, 220, 240)"/>` +
|
||||||
|
`<circle data-pastille cx="100" cy="50" r="20" fill="rgb(10, 10, 10)"/>` +
|
||||||
|
`<rect data-b width="200" height="100" fill="rgb(120, 40, 60)"/></svg>`,
|
||||||
|
);
|
||||||
|
expect(fondPeintSous($('[data-pastille]'))).toEqual([[200, 220, 240, 1]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Chaque cas rend translucide la forme posée sur le rectangle noir ; la
|
||||||
|
// couleur peinte n'est plus la couleur calculée, et l'aide lève en nommant
|
||||||
|
// la forme plutôt que de rendre un blanc que l'écran ne montre pas.
|
||||||
|
const TRANSLUCIDES = [
|
||||||
|
['fill-opacity', 'fill="rgb(255, 255, 255)" fill-opacity="0.2"', '', /fill-opacity/],
|
||||||
|
['opacity', 'fill="rgb(255, 255, 255)" opacity="0.2"', '', /opacity/],
|
||||||
|
['un alpha de couleur', 'fill="rgba(255, 255, 255, 0.2)"', '', /fill/],
|
||||||
|
["l'opacity d'un groupe", 'fill="rgb(255, 255, 255)"', 'opacity="0.2"', /opacity/],
|
||||||
|
];
|
||||||
|
for (const [nom, attributs, groupe, attendu] of TRANSLUCIDES) {
|
||||||
|
test(`une forme translucide (${nom}) sur une forme opaque : lève en nommant la forme`, () => {
|
||||||
|
monter(
|
||||||
|
`<svg xmlns="${SVG}" width="200" height="100">` +
|
||||||
|
`<rect width="200" height="100" fill="rgb(0, 0, 0)"/>` +
|
||||||
|
`<g ${groupe}><rect data-voile width="200" height="100" ${attributs}/></g>` +
|
||||||
|
`<text x="60" y="55" fill="rgb(0, 0, 0)">Iris</text></svg>`,
|
||||||
|
);
|
||||||
|
expect(() => fondPeintSous($('text'))).toThrow(attendu);
|
||||||
|
expect(() => fondPeintSous($('text'))).toThrow(/rect\[data-voile\]/);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("un motif dont une forme est translucide, un dégradé à arrêt translucide : lève en les nommant", () => {
|
||||||
|
monter(
|
||||||
|
`<svg xmlns="${SVG}" width="200" height="200"><defs>` +
|
||||||
|
`<pattern id="epreuve-motif-voile" width="8" height="8" patternUnits="userSpaceOnUse">` +
|
||||||
|
`<rect data-maille width="8" height="8" fill="rgb(250, 240, 200)" fill-opacity="0.5"/></pattern>` +
|
||||||
|
`<linearGradient id="epreuve-degrade-voile"><stop offset="0" stop-color="rgb(250, 240, 200)"/>` +
|
||||||
|
`<stop offset="1" stop-color="rgb(20, 30, 90)" stop-opacity="0.5"/></linearGradient></defs>` +
|
||||||
|
`<rect width="200" height="100" fill="url(#epreuve-motif-voile)"/>` +
|
||||||
|
`<rect y="100" width="200" height="100" fill="url(#epreuve-degrade-voile)"/>` +
|
||||||
|
`<text data-haut x="60" y="55" fill="rgb(0, 0, 0)">Iris</text>` +
|
||||||
|
`<text data-bas x="60" y="155" fill="rgb(0, 0, 0)">Théo</text></svg>`,
|
||||||
|
);
|
||||||
|
expect(() => fondPeintSous($('[data-haut]'))).toThrow(/rect\[data-maille\] fill-opacity: 0\.5/);
|
||||||
|
expect(() => fondPeintSous($('[data-bas]'))).toThrow(/stop stop-opacity: 0\.5/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un élément SVG sans forme dessous : le premier fond HTML opaque', () => {
|
||||||
|
monter(
|
||||||
|
`<div style="background: rgb(230, 240, 250)"><div><svg xmlns="${SVG}" width="200" height="100">` +
|
||||||
|
`<rect width="40" height="40" fill="rgb(1, 1, 1)"/>` +
|
||||||
|
`<text x="100" y="60">Grisaille</text></svg></div></div>`,
|
||||||
|
);
|
||||||
|
expect(fondPeintSous($('text'))).toEqual([[230, 240, 250, 1]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un texte HTML sans fond : le premier fond opaque de son ascendance", () => {
|
||||||
|
monter(
|
||||||
|
'<div style="background: rgb(250, 250, 240)"><p style="background: rgba(0, 0, 0, 0.5)">' +
|
||||||
|
'<span>Club des Merles</span></p></div>',
|
||||||
|
);
|
||||||
|
expect(fondPeintSous($('span'))).toEqual([[250, 250, 240, 1]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un texte HTML posé sur un rect SVG frère : le remplissage du rect, pas le fond de la surface', () => {
|
||||||
|
monter(
|
||||||
|
'<div style="background: rgb(255, 255, 255); position: relative; height: 100px">' +
|
||||||
|
`<svg xmlns="${SVG}" width="200" height="100" style="display: block"><rect width="200" height="100" fill="rgb(0, 0, 0)"/></svg>` +
|
||||||
|
'<span style="position: absolute; left: 10px; top: 10px">Iris</span></div>',
|
||||||
|
);
|
||||||
|
expect(fondPeintSous($('span'))).toEqual([[0, 0, 0, 1]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un texte HTML sur un div frère absolu : le fond du frère, même en pointer-events: none', () => {
|
||||||
|
monter(
|
||||||
|
'<div style="background: rgb(255, 255, 255); position: relative; height: 100px">' +
|
||||||
|
'<div data-plateau style="position: absolute; inset: 0; background: rgb(0, 0, 0); pointer-events: none"></div>' +
|
||||||
|
'<span style="position: absolute; left: 10px; top: 10px; pointer-events: none">Iris</span></div>',
|
||||||
|
);
|
||||||
|
expect(fondPeintSous($('span'))).toEqual([[0, 0, 0, 1]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un texte HTML sur un frère opaque mais à opacity 0,2 : lève en nommant le frère', () => {
|
||||||
|
monter(
|
||||||
|
'<div style="background: rgb(255, 255, 255); position: relative; height: 100px">' +
|
||||||
|
'<div data-plateau style="position: absolute; inset: 0; background: rgb(0, 0, 0); opacity: 0.2"></div>' +
|
||||||
|
'<span style="position: absolute; left: 10px; top: 10px">Iris</span></div>',
|
||||||
|
);
|
||||||
|
expect(() => fondPeintSous($('span'))).toThrow(/div\[data-plateau\].*opacity/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un texte HTML hors de la fenêtre : lève, rien ne se lit sous un point hors champ', () => {
|
||||||
|
monter('<span style="position: fixed; left: -500px; top: 0">Iris</span>');
|
||||||
|
expect(() => fondPeintSous($('span'))).toThrow(/fenêtre/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("le fond CSS du <svg> extérieur, sous un texte SVG sans forme dessous", () => {
|
||||||
|
monter(
|
||||||
|
`<div style="background: rgb(255, 255, 255)"><svg xmlns="${SVG}" width="200" height="100" ` +
|
||||||
|
`style="display: block; background: rgb(0, 0, 0)"><text x="60" y="55" fill="rgb(0, 0, 0)">Iris</text></svg></div>`,
|
||||||
|
);
|
||||||
|
expect(fondPeintSous($('text'))).toEqual([[0, 0, 0, 1]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un recouvrement peint après un texte HTML, en pointer-events: none, n'est pas dessous", () => {
|
||||||
|
monter(
|
||||||
|
'<div style="background: rgb(255, 255, 255); position: relative; height: 100px">' +
|
||||||
|
'<span style="position: absolute; left: 10px; top: 10px">Iris</span>' +
|
||||||
|
'<div data-voile style="position: absolute; inset: 0; background: rgb(0, 0, 0); pointer-events: none"></div></div>',
|
||||||
|
);
|
||||||
|
expect(fondPeintSous($('span'))).toEqual([[255, 255, 255, 1]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un recouvrement HTML peint après le dessin n'est pas sous un texte SVG", () => {
|
||||||
|
monter(
|
||||||
|
'<div style="background: rgb(255, 255, 255); position: relative; height: 100px">' +
|
||||||
|
`<svg xmlns="${SVG}" width="200" height="100" style="display: block"><text x="60" y="55">Iris</text></svg>` +
|
||||||
|
'<div data-voile style="position: absolute; inset: 0; background: rgb(0, 0, 0); pointer-events: none"></div></div>',
|
||||||
|
);
|
||||||
|
expect(fondPeintSous($('text'))).toEqual([[255, 255, 255, 1]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un descendant à fond opaque qui couvre le centre d'un texte HTML n'est pas dessous", () => {
|
||||||
|
monter(
|
||||||
|
'<div style="background: rgb(255, 255, 255)"><p style="position: relative">Iris' +
|
||||||
|
'<b style="position: absolute; inset: 0; background: rgb(0, 0, 0)"></b></p></div>',
|
||||||
|
);
|
||||||
|
expect(fondPeintSous($('p'))).toEqual([[255, 255, 255, 1]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("une forme peinte après un texte SVG sans forme dessous n'est pas dessous", () => {
|
||||||
|
monter(
|
||||||
|
`<div style="background: rgb(255, 255, 255)"><svg xmlns="${SVG}" width="200" height="100" style="display: block">` +
|
||||||
|
`<text x="60" y="55">Iris</text><rect width="200" height="100" fill="rgb(0, 0, 0)"/></svg></div>`,
|
||||||
|
);
|
||||||
|
expect(fondPeintSous($('text'))).toEqual([[255, 255, 255, 1]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un texte que son centre n'atteint pas : lève en le nommant", () => {
|
||||||
|
monter('<div style="background: rgb(255, 255, 255)"><span data-cache style="visibility: hidden">Iris</span></div>');
|
||||||
|
expect(() => fondPeintSous($('span'))).toThrow(/span\[data-cache\].*n'atteint pas/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Chaque cas pose, entre le rectangle clair et le texte, une forme qui ne
|
||||||
|
// peint rien au centre : l'aide la saute et rend le rectangle clair.
|
||||||
|
const SANS_PEINTURE = [
|
||||||
|
['opacity 0', '<rect width="200" height="100" fill="rgb(0, 0, 0)" opacity="0"/>'],
|
||||||
|
['fill-opacity 0', '<rect width="200" height="100" fill="rgb(0, 0, 0)" fill-opacity="0"/>'],
|
||||||
|
['visibility hidden', '<rect width="200" height="100" fill="rgb(0, 0, 0)" visibility="hidden"/>'],
|
||||||
|
['maille d’un <pattern>', '<pattern id="epreuve-maille" width="200" height="100" patternUnits="userSpaceOnUse"><rect width="200" height="100" fill="rgb(0, 0, 0)"/></pattern>'],
|
||||||
|
];
|
||||||
|
for (const [nom, forme] of SANS_PEINTURE) {
|
||||||
|
test(`une forme qui ne peint rien au centre (${nom}) n'est pas dessous`, () => {
|
||||||
|
monter(
|
||||||
|
`<svg xmlns="${SVG}" width="200" height="100"><rect width="200" height="100" fill="rgb(200, 220, 240)"/>` +
|
||||||
|
`${forme}<text x="60" y="55" fill="rgb(0, 0, 0)">Iris</text></svg>`,
|
||||||
|
);
|
||||||
|
expect(fondPeintSous($('text'))).toEqual([[200, 220, 240, 1]]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("un frère HTML à opacity 0 sous un texte HTML ne peint rien", () => {
|
||||||
|
monter(
|
||||||
|
'<div style="background: rgb(255, 255, 255); position: relative; height: 100px">' +
|
||||||
|
'<div style="position: absolute; inset: 0; background: rgb(0, 0, 0); opacity: 0"></div>' +
|
||||||
|
'<span style="position: absolute; left: 10px; top: 10px">Iris</span></div>',
|
||||||
|
);
|
||||||
|
expect(fondPeintSous($('span'))).toEqual([[255, 255, 255, 1]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('une forme peinte par <use> sous un texte SVG : lève en nommant le <use>', () => {
|
||||||
|
monter(
|
||||||
|
`<div style="background: rgb(255, 255, 255)"><svg xmlns="${SVG}" width="200" height="100">` +
|
||||||
|
`<defs><rect id="epreuve-plateau" width="200" height="100" fill="rgb(0, 0, 0)"/></defs>` +
|
||||||
|
`<use data-plateau href="#epreuve-plateau"/><text x="60" y="55" fill="rgb(255, 255, 255)">Iris</text></svg></div>`,
|
||||||
|
);
|
||||||
|
expect(() => fondPeintSous($('text'))).toThrow(/use\[data-plateau\]/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un texte HTML posé sur un <use> : lève en nommant le <use>', () => {
|
||||||
|
monter(
|
||||||
|
'<div style="background: rgb(255, 255, 255); position: relative; height: 100px">' +
|
||||||
|
`<svg xmlns="${SVG}" width="200" height="100" style="display: block">` +
|
||||||
|
`<defs><rect id="epreuve-plateau-html" width="200" height="100" fill="rgb(0, 0, 0)"/></defs>` +
|
||||||
|
`<use data-plateau href="#epreuve-plateau-html"/></svg>` +
|
||||||
|
'<span style="position: absolute; left: 10px; top: 10px">Iris</span></div>',
|
||||||
|
);
|
||||||
|
expect(() => fondPeintSous($('span'))).toThrow(/use\[data-plateau\]/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un dégradé et un motif qui héritent leurs arrêts ou leurs formes par href', () => {
|
||||||
|
monter(
|
||||||
|
`<svg xmlns="${SVG}" width="200" height="200"><defs>` +
|
||||||
|
`<linearGradient id="epreuve-g0"><stop stop-color="rgb(0, 0, 0)"/><stop offset="1" stop-color="rgb(10, 10, 10)"/></linearGradient>` +
|
||||||
|
`<linearGradient id="epreuve-g1" href="#epreuve-g0"/>` +
|
||||||
|
`<pattern id="epreuve-p0" width="8" height="8" patternUnits="userSpaceOnUse"><rect width="8" height="8" fill="rgb(20, 20, 20)"/></pattern>` +
|
||||||
|
`<pattern id="epreuve-p1" href="#epreuve-p0"/></defs>` +
|
||||||
|
`<rect width="200" height="200" fill="rgb(255, 255, 255)"/>` +
|
||||||
|
`<rect width="200" height="100" fill="url(#epreuve-g1)"/>` +
|
||||||
|
`<rect y="100" width="200" height="100" fill="url(#epreuve-p1)"/>` +
|
||||||
|
`<text data-haut x="60" y="55">Iris</text><text data-bas x="60" y="155">Théo</text></svg>`,
|
||||||
|
);
|
||||||
|
expect(fondPeintSous($('[data-haut]'))).toEqual([
|
||||||
|
[0, 0, 0, 1],
|
||||||
|
[10, 10, 10, 1],
|
||||||
|
]);
|
||||||
|
expect(fondPeintSous($('[data-bas]'))).toEqual([[20, 20, 20, 1]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('une image de fond sur le chemin du fond retenu, ou sur la cible : lève en nommant son porteur', () => {
|
||||||
|
monter(
|
||||||
|
'<div style="background: rgb(255, 255, 255)"><p data-degrade style="background-image: linear-gradient(rgb(0, 0, 0), rgb(0, 0, 0))">' +
|
||||||
|
'<span>Iris</span></p>' +
|
||||||
|
'<button style="background: rgb(30, 60, 90) linear-gradient(rgb(0, 0, 0), rgb(0, 0, 0))">Retenir</button></div>',
|
||||||
|
);
|
||||||
|
expect(() => fondPeintSous($('span'))).toThrow(/p\[data-degrade\].*background-image/);
|
||||||
|
expect(() => fondPeintSous($('button'))).toThrow(/button.*background-image/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un bouton à fond opaque : son propre fond', () => {
|
||||||
|
monter(
|
||||||
|
'<div style="background: rgb(250, 250, 240)">' +
|
||||||
|
'<button style="background: rgb(30, 60, 90); color: rgb(255, 255, 255)">Retenir</button></div>',
|
||||||
|
);
|
||||||
|
expect(fondPeintSous($('button'))).toEqual([[30, 60, 90, 1]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sans aucun fond opaque, la liste est vide", async () => {
|
||||||
|
// L'épreuve retire le fond de <html> et de <body> le temps de la mesure.
|
||||||
|
const avant = [document.documentElement.style.background, document.body.style.background];
|
||||||
|
document.documentElement.style.background = 'transparent';
|
||||||
|
document.body.style.background = 'transparent';
|
||||||
|
try {
|
||||||
|
monter('<span>Iris</span>');
|
||||||
|
expect(fondPeintSous($('span'))).toEqual([]);
|
||||||
|
} finally {
|
||||||
|
[document.documentElement.style.background, document.body.style.background] = avant;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pseudo-éléments', () => {
|
||||||
|
test('couleurTexte, fondPeintSous et attenuations lisent le ::before, pas son hôte', () => {
|
||||||
|
regles(
|
||||||
|
'[data-scene] .epreuve-avant::before { content: "★"; color: rgb(10, 20, 30); opacity: 0.6; }' +
|
||||||
|
'[data-scene] .epreuve-propre::before { content: "☆"; color: rgb(10, 20, 30); background: rgb(90, 80, 70); }',
|
||||||
|
);
|
||||||
|
monter(
|
||||||
|
'<span class="epreuve-avant" style="background: rgb(240, 230, 220); color: rgb(5, 5, 5)"></span>' +
|
||||||
|
'<span class="epreuve-propre" style="background: rgb(240, 230, 220)"></span>',
|
||||||
|
);
|
||||||
|
const [avant, propre] = porteursDeTexte(scene);
|
||||||
|
expect(avant).toEqual({ element: $('.epreuve-avant'), pseudo: '::before' });
|
||||||
|
expect(couleurTexte(avant)).toEqual([10, 20, 30, 1]);
|
||||||
|
expect(fondPeintSous(avant)).toEqual([[240, 230, 220, 1]]);
|
||||||
|
expect(fondPeintSous(propre)).toEqual([[90, 80, 70, 1]]);
|
||||||
|
expect(attenuations(avant)).toEqual([
|
||||||
|
{ element: $('.epreuve-avant'), pseudo: '::before', propriete: 'opacity', valeur: '0.6' },
|
||||||
|
]);
|
||||||
|
expect(attenuations(propre)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('attenuations', () => {
|
||||||
|
test('un texte sans atténuation : aucune faute', () => {
|
||||||
|
monter('<p><span>Ondine</span></p>');
|
||||||
|
expect(attenuations($('span'))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Chaque cas pose une propriété sur un ancêtre ou sur le texte, et attend
|
||||||
|
// la faute sur l'élément qui la porte.
|
||||||
|
const CAS = [
|
||||||
|
['opacity', 'opacity: 0.6', '0.6'],
|
||||||
|
['filter', 'filter: grayscale(1)', 'grayscale(1)'],
|
||||||
|
['backdrop-filter', 'backdrop-filter: blur(2px)', 'blur(2px)'],
|
||||||
|
['mask-image', 'mask-image: linear-gradient(black, black)', 'linear-gradient(rgb(0, 0, 0), rgb(0, 0, 0))'],
|
||||||
|
['mix-blend-mode', 'mix-blend-mode: multiply', 'multiply'],
|
||||||
|
];
|
||||||
|
for (const [propriete, declaration, valeur] of CAS) {
|
||||||
|
test(`relève ${propriete} posé sur un ancêtre`, () => {
|
||||||
|
monter(`<section style="${declaration}"><p><span>Iris</span></p></section>`);
|
||||||
|
expect(attenuations($('span'))).toEqual([
|
||||||
|
{ element: $('section'), pseudo: null, propriete, valeur },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("relève un fill-opacity de 0,5 sur le groupe d'un texte SVG, et sur ce qui en hérite", () => {
|
||||||
|
monter(`<svg xmlns="${SVG}"><g fill-opacity="0.5"><text x="0" y="10">Théo</text></g></svg>`);
|
||||||
|
const fautes = attenuations($('text'));
|
||||||
|
expect(fautes).toContainEqual({ element: $('g'), pseudo: null, propriete: 'fill-opacity', valeur: '0.5' });
|
||||||
|
expect(fautes.every((f) => f.propriete === 'fill-opacity')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('relève un stroke-opacity', () => {
|
||||||
|
monter(`<svg xmlns="${SVG}"><circle cx="5" cy="5" r="4" stroke="rgb(0, 0, 0)" stroke-opacity="0.4"/></svg>`);
|
||||||
|
expect(attenuations($('circle'))).toContainEqual({
|
||||||
|
element: $('circle'),
|
||||||
|
pseudo: null,
|
||||||
|
propriete: 'stroke-opacity',
|
||||||
|
valeur: '0.4',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('visible', () => {
|
||||||
|
test('un élément visible, puis caché, recouvert, hors de la fenêtre', () => {
|
||||||
|
monter(
|
||||||
|
'<div style="position: relative; width: 200px; height: 40px">' +
|
||||||
|
'<span data-a>Iris</span>' +
|
||||||
|
'<span data-b style="display: none">Théo</span>' +
|
||||||
|
'<span data-c style="position: absolute; left: 120px; top: 0">Ondine</span>' +
|
||||||
|
'<span data-d style="position: absolute; left: 0; top: 0; width: 200px; height: 40px; background: rgb(1, 1, 1)"></span>' +
|
||||||
|
'<span data-e style="position: fixed; left: -500px; top: 0">Grisaille</span>' +
|
||||||
|
'</div>',
|
||||||
|
);
|
||||||
|
expect(visible($('[data-a]'))).toMatch(/centre/);
|
||||||
|
expect(visible($('[data-b]'))).toMatch(/checkVisibility/);
|
||||||
|
expect(visible($('[data-c]'))).toMatch(/centre/);
|
||||||
|
expect(visible($('[data-e]'))).toMatch(/fenêtre/);
|
||||||
|
$('[data-d]').remove();
|
||||||
|
expect(visible($('[data-a]'))).toBe(true);
|
||||||
|
expect(visible($('[data-c]'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un élément en pointer-events: none se voit, comme la pastille du plan', () => {
|
||||||
|
monter(
|
||||||
|
`<svg xmlns="${SVG}" width="200" height="100"><rect width="200" height="100" fill="rgb(200, 220, 240)"/>` +
|
||||||
|
`<text data-pastille x="60" y="55" style="pointer-events: none">3</text></svg>`,
|
||||||
|
);
|
||||||
|
expect(visible($('text'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un recouvrement opaque en pointer-events: none cache le texte dessous, et se nomme", () => {
|
||||||
|
monter(
|
||||||
|
'<div style="position: relative; width: 200px; height: 40px">' +
|
||||||
|
'<span>Iris</span>' +
|
||||||
|
'<div data-fantome style="position: absolute; inset: 0; background: rgb(1, 1, 1); pointer-events: none"></div></div>',
|
||||||
|
);
|
||||||
|
expect(visible($('span'))).toMatch(/couvert par div\[data-fantome\]/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Recouvrements qui ne peignent rien au centre : ils ne cachent pas le texte.
|
||||||
|
const TRANSPARENTS = [
|
||||||
|
['une boîte HTML sans fond ni texte', '<div style="position: absolute; inset: 0"></div>'],
|
||||||
|
['une boîte HTML à opacity 0', '<div style="position: absolute; inset: 0; background: rgb(1, 1, 1); opacity: 0"></div>'],
|
||||||
|
[
|
||||||
|
'une zone d’atteinte SVG à fill-opacity 0',
|
||||||
|
`<svg xmlns="${SVG}" width="200" height="40" style="position: absolute; left: 0; top: 0"><rect width="200" height="40" fill="rgb(1, 1, 1)" fill-opacity="0"/></svg>`,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
for (const [nom, recouvrement] of TRANSPARENTS) {
|
||||||
|
test(`${nom} posée sur le texte ne le cache pas`, () => {
|
||||||
|
monter(`<div style="position: relative; width: 200px; height: 40px"><span>Iris</span>${recouvrement}</div>`);
|
||||||
|
expect(visible($('span'))).toBe(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recouvrements qui peignent au centre : chacun cache le texte.
|
||||||
|
const OPAQUES = [
|
||||||
|
['une boîte HTML qui porte un texte', '<div style="position: absolute; inset: 0">Théo</div>'],
|
||||||
|
[
|
||||||
|
'une boîte HTML à image de fond',
|
||||||
|
'<div style="position: absolute; inset: 0; background-image: linear-gradient(rgb(1, 1, 1), rgb(1, 1, 1))"></div>',
|
||||||
|
],
|
||||||
|
['une image sans fond', '<img alt="" style="position: absolute; left: 0; top: 0; width: 200px; height: 40px">'],
|
||||||
|
[
|
||||||
|
'une forme SVG remplie',
|
||||||
|
`<svg xmlns="${SVG}" width="200" height="40" style="position: absolute; left: 0; top: 0"><rect width="200" height="40" fill="rgb(1, 1, 1)"/></svg>`,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
for (const [nom, recouvrement] of OPAQUES) {
|
||||||
|
test(`${nom} posée sur le texte le cache`, () => {
|
||||||
|
monter(`<div style="position: relative; width: 200px; height: 40px"><span>Iris</span>${recouvrement}</div>`);
|
||||||
|
expect(visible($('span'))).toMatch(/couvert/);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('une boîte sans fond dont un pseudo-élément porte un texte, posée sur le texte, le cache', () => {
|
||||||
|
regles('[data-scene] .epreuve-etiquette::before { content: "Théo"; }');
|
||||||
|
monter(
|
||||||
|
'<div style="position: relative; width: 200px; height: 40px"><span>Iris</span>' +
|
||||||
|
'<div class="epreuve-etiquette" style="position: absolute; inset: 0"></div></div>',
|
||||||
|
);
|
||||||
|
expect(visible($('span'))).toMatch(/couvert par div\.epreuve-etiquette/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un élément rogné par son ancêtre au centre de sa boîte : le centre ne l'atteint pas", () => {
|
||||||
|
monter(
|
||||||
|
'<div style="position: relative; width: 100px; height: 40px; overflow: hidden">' +
|
||||||
|
'<span style="position: absolute; left: 60px; top: 0; width: 100px">Iris</span></div>',
|
||||||
|
);
|
||||||
|
expect(visible($('span'))).toMatch(/n'atteint pas/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un pseudo-élément : son hôte, son display et son content", () => {
|
||||||
|
regles(
|
||||||
|
'[data-scene] .epreuve-vu::before { content: "★"; }' +
|
||||||
|
'[data-scene] .epreuve-cache::before { content: "★"; display: none; }',
|
||||||
|
);
|
||||||
|
monter('<span class="epreuve-vu">Iris</span> <span class="epreuve-cache">Théo</span>');
|
||||||
|
expect(visible({ element: $('.epreuve-vu'), pseudo: '::before' })).toBe(true);
|
||||||
|
expect(visible({ element: $('.epreuve-cache'), pseudo: '::before' })).toMatch(/display/);
|
||||||
|
expect(visible({ element: $('.epreuve-vu'), pseudo: '::after' })).toMatch(/content/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sousTheme', () => {
|
||||||
|
const racine = document.documentElement;
|
||||||
|
|
||||||
|
test("pose le thème pendant la mesure, puis rend 'clair' qu'il portait", async () => {
|
||||||
|
racine.setAttribute('data-theme', 'clair');
|
||||||
|
try {
|
||||||
|
const lu = await sousTheme('sombre', () => racine.getAttribute('data-theme'));
|
||||||
|
expect(lu).toBe('sombre');
|
||||||
|
expect(racine.getAttribute('data-theme')).toBe('clair');
|
||||||
|
} finally {
|
||||||
|
racine.removeAttribute('data-theme');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("laisse l'attribut absent quand il l'était", async () => {
|
||||||
|
racine.removeAttribute('data-theme');
|
||||||
|
await sousTheme('sombre', () => {
|
||||||
|
expect(racine.getAttribute('data-theme')).toBe('sombre');
|
||||||
|
});
|
||||||
|
expect(racine.hasAttribute('data-theme')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rend la valeur d’avant, ou son absence, même quand la mesure lève', async () => {
|
||||||
|
racine.removeAttribute('data-theme');
|
||||||
|
await expect(sousTheme('sombre', () => { throw new Error('mesure manquée'); })).rejects.toThrow('mesure manquée');
|
||||||
|
expect(racine.hasAttribute('data-theme')).toBe(false);
|
||||||
|
racine.setAttribute('data-theme', 'clair');
|
||||||
|
try {
|
||||||
|
await expect(sousTheme('sombre', async () => { throw new Error('mesure manquée'); })).rejects.toThrow('mesure manquée');
|
||||||
|
expect(racine.getAttribute('data-theme')).toBe('clair');
|
||||||
|
} finally {
|
||||||
|
racine.removeAttribute('data-theme');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -13,7 +13,8 @@
|
||||||
// navigateur un vrai moteur de rendu. Le projet étend vite.config.js : un
|
// navigateur un vrai moteur de rendu. Le projet étend vite.config.js : un
|
||||||
// module s'y résout par les mêmes greffons et réglages que dans la
|
// module s'y résout par les mêmes greffons et réglages que dans la
|
||||||
// construction du livrable, sans seconde configuration à tenir
|
// construction du livrable, sans seconde configuration à tenir
|
||||||
// d'accord avec la première.
|
// d'accord avec la première. Il joue les épreuves de src/ et
|
||||||
|
// celles de l'outillage partagé de test/navigateur/.
|
||||||
import { defineConfig } from 'vitest/config';
|
import { defineConfig } from 'vitest/config';
|
||||||
import { playwright } from '@vitest/browser-playwright';
|
import { playwright } from '@vitest/browser-playwright';
|
||||||
import { optionsChromium } from './test/chromium.js';
|
import { optionsChromium } from './test/chromium.js';
|
||||||
|
|
@ -95,7 +96,7 @@ export default defineConfig({
|
||||||
{ extends: './vite.config.js',
|
{ extends: './vite.config.js',
|
||||||
test: {
|
test: {
|
||||||
name: 'navigateur',
|
name: 'navigateur',
|
||||||
include: ['src/**/*.navigateur.test.js'],
|
include: ['src/**/*.navigateur.test.js', 'test/**/*.navigateur.test.js'],
|
||||||
browser: { enabled: true, headless: true, provider: playwright(optionsChromium(process.env)),
|
browser: { enabled: true, headless: true, provider: playwright(optionsChromium(process.env)),
|
||||||
instances: [{ browser: 'chromium' }] } } },
|
instances: [{ browser: 'chromium' }] } } },
|
||||||
],
|
],
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue