Compare commits

...

2 commits

Author SHA1 Message Date
abae4fa9b8 [ADD] application: observable session, events facade, places and seats
The screens of iteration 3 need state they can subscribe to and commands
that keep the plan sound. The session follows the Svelte store contract,
unblocks a plan, cancels a gesture, keeps the framing, and orders the
warnings of a gesture. The facade lists events, settles the working folder
and runs copy, rename, import and delete. Places cover reservations, titles,
designation and the retained plan: each gesture reads seats as the display
shows them, reserved people keep their table, and a person removed frees
her chair while every other seat stays.
Checked: 1973 node, 127 browser, 60 node-long tests; coverage floors kept.

--- FR ---

[ADD] application : séance observable, façade des événements, places

Les écrans de l'itération 3 exigent un état auquel s'abonner et des
commandes qui gardent le plan sain. La séance suit le contrat des magasins
de Svelte, débloque un plan, annule un geste, garde le cadrage et ordonne
les avertissements d'un geste. La façade liste les événements, règle le
dossier de travail et mène copie, renommage, import et suppression. Les
places couvrent réservations, titres, désignation et plan retenu : chaque
geste lit les sièges comme l'écran les montre, une personne réservée garde
sa table, et une personne retirée libère sa chaise sans déplacer les autres.
Vérifié : 1973 node, 127 navigateur, 60 node-long ; seuils de couverture.

Assisted-by: Claude Opus 5.5
2026-10-07 04:14:24 -04:00
f76c5dede5 [ADD] tests: browser tooling — reachable entries, real colours, contrast
The interface tests must judge what an operator sees, not what the markup
claims: a class can sit on an element that paints nothing (§ 14.2).
test/navigateur/ gives them shared helpers: entering the app as a user
does, reading the colour actually painted under a text — the topmost
opaque background below it, an outer <svg> background included — and
deciding visibility by geometry, style and occlusion rather than by
hit-testing, so overlays in pointer-events: none stay visible. The header
names what the helpers do not detect.
Checked: 1776 node and 127 browser tests.

--- FR ---

[ADD] épreuves : outillage du navigateur — entrées, couleurs, contraste

Les épreuves de l'interface doivent juger ce que voit l'opérateur, non ce
que prétend le balisage : une classe peut se poser sur un élément qui ne
peint rien (§ 14.2). test/navigateur/ leur donne des aides communes :
entrer dans l'application comme un utilisateur, lire la couleur réellement
peinte sous un texte — le premier fond opaque sous lui, celui d'un <svg>
extérieur compris — et juger la visibilité par la géométrie, le style et
l'occultation plutôt que par l'atteinte du pointeur. L'en-tête nomme ce
que les aides ne détectent pas.
Vérifié : 1776 node et 127 navigateur.

Assisted-by: Claude Opus 5.5
2026-10-07 04:13:34 -04:00
31 changed files with 9024 additions and 282 deletions

View 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;
}

View 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);
});
});

View file

@ -35,6 +35,8 @@ import { COMMANDES_PLACES } from './commandes_places.js';
import { ErreurCommande, refus } from './erreurs.js';
import { libelle } from './libelles.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
// 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 }) {
exigerModifiable(charge);
const participant = participantDe(charge, id);
if (!participant.exclu) throw refus('SANS_EFFET', { commande: 'reintegrerParticipant' });
leverRefus(controlerReintegration(charge, id));
const suivante = avecParticipant(charge, id, (copie) => ({ ...copie, exclu: false }));
const bilan = bilanExclusion(charge, suivante, participant);
return {
@ -330,13 +339,45 @@ function debloquer(charge) {
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,
* § 8.9) : les propositions du moteur, mises en forme de fichier
* (versFichier) sous les identifiants qui suivent 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
* 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 }) {
exigerModifiable(charge);
@ -350,7 +391,10 @@ function enregistrerGeneration(charge, { propositions, configuration, produitVer
const options = { produitVersion, attribuerSieges: charge.reglages.attribuerSieges, decalage };
let fichiers;
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) {
if (erreur instanceof ErreurStockage && erreur.code === 'COMPTEUR_SATURE') {
throw refus('COMPTEUR_SATURE', erreur.details);

View file

@ -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';
describe('commandes : le registre (§ 8.2)', () => {
test('le registre porte les onze commandes du plan, chacune une fonction', () => {
assert.deepEqual([...COMMANDES.keys()].sort(), [
test("le registre porte les onze commandes d'A1, les dix des places et celles du mobilier, chacune une fonction", () => {
const attendues = [
'ajouterParticipant',
'changerEtat',
'convertirEnTous',
'debloquer',
'deplacerDansRetenu',
'deplacerReservation',
'designer',
'echangerDansRetenu',
'effacerPropositions',
'enregistrerGeneration',
'exclureParticipant',
'importerParticipants',
'modifierParticipant',
'poserTitre',
'reintegrerParticipant',
'reserver',
'retenirProposition',
'retirerDuTour',
'retirerReservation',
'retirerTitre',
'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');
});
@ -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)', () => {
// Arguments d'exemple de chaque commande sur la charge riche ; debloquer
// reçoit la même charge, bloquée.
// Au tour 1 du retenu de la charge riche : le voisin d'Iris à sa table, et
// 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) {
const { voisin, autre, tableAutre } = auTour1(charge);
return new Map([
['ajouterParticipant', { champs: { nom: 'Bruyère' } }],
['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)],
['retenirProposition', { id: 2 }],
['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", () => {
const charge = chargeRiche();
const args = exemples(charge);
const particulieres = recues(charge);
assert.deepEqual([...args.keys()].sort(), [...COMMANDES.keys()].sort());
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 rendu = commande(figer(recue), args.get(nom));
assert.deepEqual(recue, copie, nom);

View file

@ -4,6 +4,714 @@
// 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
// 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>} */
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],
]);

View 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');
});
});

View 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,
};
}

File diff suppressed because it is too large Load diff

View file

@ -308,6 +308,32 @@ const EXEMPLES = new Map([
['PLAN_NON_BLOQUE', { remede: null }],
['JALON_NON_ECRIT', { chemin: 'Soirée des Lucioles.gtt-journal.jsonl', dossier: 'E:\\data', cause: 'EROFS' }],
['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
['NOM_REQUIS', { 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_RETIREES', { participant: 1, personne: 'Iris Ombrelle', reservations: 1, titres: 1 }],
['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
['ENTREE_CREATION', { nom: "Soirée d'essai" }],
['JALON_CREATION', {}],
@ -338,9 +367,38 @@ const EXEMPLES = new Map([
['ENTREE_IMPORTER_PARTICIPANTS', { mode: 'ajouter', ajoutes: 12, misAJour: 0, retires: 0 }],
['ENTREE_CHANGER_ETAT', { etat: 'bloque' }],
['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_RETENIR_PROPOSITION', { id: 3 }],
['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

View file

@ -2,8 +2,9 @@
// 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
// (§ 8.1, § 9, § 14.6) : refus et libellés figés. Un état inconnu lève
// RangeError, au lieu de se taire.
// (§ 8.1, § 9, § 14.6), et ceux de la façade des événements (§ 8.6, § 8.7,
// § 15) : refus et libellés figés. Un état inconnu lève RangeError, au lieu
// de se taire.
import { cite, nomEtat } from './aides.js';
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."],
['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) --------------------------
['ENTREE_CREATION', ({ nom }) => `Création de l'événement ${cite(nom)}`],
['ENTREE_CHANGER_ETAT', ({ etat }) => `Plan passé à l'é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)}`],
]);

View file

@ -3,4 +3,21 @@
// Les textes des codes du moteur qui n'ont pas leur table ailleurs, et les
// 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')}.`,
],
]);

View file

@ -2,5 +2,131 @@
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les textes des commandes des places — réservations, titres, pose (§ 5.8,
// § 5.9, § 14.6).
export const TABLE = new Map();
// § 5.9, § 8.2, § 14.6) : leurs refus, leurs signaux, leurs libellés figés,
// 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'],
]);

View file

@ -5,7 +5,31 @@
// 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
// 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([
// --- La séance (§ 8.3, § 8.4, § 8.5, § 8.6, § 8.8) -------------------------
@ -70,6 +94,45 @@ export const TABLE = new Map([
'VERROU_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',
({ cause }, texte) =>

View 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 };
}

View 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
View 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 };
}

View 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
View 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 });
}

View 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), []);
});
});

View file

@ -52,7 +52,22 @@
//
// 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 —,
// 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,
// 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,
verrouiller,
} from '../stockage/depot.js';
import { FORMAT, analyser } from '../stockage/document.js';
import { ErreurStockage } from '../stockage/erreurs.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 { libelle } from './libelles.js';
/** Inactivité au terme de laquelle la séance revient seule en lecture (§ 8.4). */
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
* et LECTURE_SEULE, codes du contrat du stockage, se lèvent en
* Le refus d'un geste par la séance : son code et ses détails, dont le
* 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).
*/
export class ErreurSeance extends Error {
@ -89,9 +111,10 @@ export class ErreurSeance extends Error {
* @param {Object} [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.details = details;
this.details = complets;
}
}
ErreurSeance.prototype.name = 'ErreurSeance';
@ -101,6 +124,30 @@ const avertissement = (code, details) => ({ code, details });
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
// journal.
const derniereEntree = ({ journal }) => journal?.entrees.at(-1)?.revision ?? null;
@ -121,8 +168,10 @@ function libelleDuRetour(sens, instant) {
*
* @param {Object} parametres
* @param {import('../stockage/systeme_fichiers.js').SystemeFichiers} parametres.fs
* @param {import('../stockage/systeme_fichiers.js').Racine} parametres.racine
* le dossier de travail que la règle du § 8.6 a déterminé
* @param {import('../stockage/systeme_fichiers.js').Racine|null} [parametres.racine]
* 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
* horodatage rend AAAA-MM-JJTHH:MM:SS±HH:MM
* @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
* @param {(minuterie: unknown) => void} parametres.annuler
* @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({
fs,
racine,
racine = null,
horloge,
identifiant,
produitVersion,
planifier,
annuler,
inactiviteMs = INACTIVITE_MS,
formatCourant = FORMAT,
delaiReglagesMs = DELAI_REGLAGES_MS,
}) {
const id = identifiant();
const libelleReprise = libelle('ENTREE_REPRISE');
@ -154,7 +210,12 @@ export function creerSeance({
// l'écriture qui a rompu le support, {echec, chemin, dossier, cause} ;
// verrouAutre : le verrou d'une autre séance qui a refusé le dernier
// 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 = {
racine,
ouvert: null,
@ -163,18 +224,166 @@ export function creerSeance({
verrouTenu: false,
supportRompu: false,
refusSupport: null,
ailleurs: null,
verrouAutre: null,
avertissementsGeste: [],
gestes: 0,
calculs: 0,
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
// 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();
function enFile(travail) {
const suite = file.then(travail);
const suite = file.then(travail).finally(publier);
file = suite.then(
() => undefined,
() => undefined,
@ -201,23 +410,28 @@ export function creerSeance({
// 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.
// Le retour automatique se dit par RETOUR_AUTOMATIQUE, que le passage en
// écriture suivant retire.
async function expirer(minuterie) {
if (etat.minuterie !== minuterie) return;
etat.minuterie = null;
if (etat.mode !== 'ecriture' || etat.gestes > 0 || etat.calculs > 0) return;
await quitterEcriture();
etat.retourAutomatique = true;
}
// --- Le verrou (§ 8.8) -----------------------------------------------------
// 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
// 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
// pas : VERROU_PRIS, que verrouAutre garde. Un système sans verrou le rend
// toujours pris. Un support qui refuse d'écrire le verrou, ou d'effacer
// celui du mort, rompt le support sous l'échec VERROU_NON_ECRIT, et son
// ECRITURE remonte (§ 8.6). Rend les avertissements.
// poste (vivant faux) se rend en son nom, puis se reprend : VERROU_REPRIS
// {seance, depuis}. Celui d'une séance vivante, d'un autre poste (vivant
// null) ou illisible ne se reprend pas : VERROU_PRIS {seance, depuis,
// vivant}, remède null, que verrouAutre garde. Un système sans verrou le
// rend toujours pris. Un support qui refuse d'écrire le verrou, ou
// 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) {
let verrou;
const avertissements = [];
@ -233,13 +447,13 @@ export function creerSeance({
}
}
} 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;
}
if (!verrou.pris) {
const { seance, depuis, vivant } = verrou;
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.verrouTenu = true;
@ -282,9 +496,7 @@ export function creerSeance({
return { frais: await ouvrirSurDisque(systeme, dossier, base), source: null };
} catch (erreur) {
const illisible = erreur instanceof ErreurStockage && erreur.code === 'ETAT_ILLISIBLE';
if (illisible && erreur.details.raison === 'ENCODAGE') {
throw new ErreurStockage('LECTURE_SEULE', { raison: 'ENCODAGE' });
}
if (illisible && erreur.details.raison === 'ENCODAGE') throw refusEncodage();
throw erreur;
}
}
@ -322,18 +534,20 @@ export function creerSeance({
const { frais, source } = relu;
etat.ouvert = frais;
etat.source = source;
etat.formatLu = formatDe(frais.texteEtat);
const change =
frais.revision !== ouvert.revision || serialiserCharge(frais.charge) !== serialiserCharge(ouvert.charge);
return change ? [avertissement('EVENEMENT_CHANGE', { revision: frais.revision })] : [];
}
// Le retour en lecture : le compte s'arrête, et le verrou se rend ; le
// retour explicite annule en plus le geste ouvert. Les avertissements du
// rendu s'ajoutent à ceux du dernier geste.
// Le retour en lecture : le compte s'arrête, le verrou se rend, et
// surAnnulation l'annonce ; le retour explicite annule en plus le geste
// ouvert. Les avertissements du rendu s'ajoutent à ceux du dernier geste.
async function quitterEcriture({ explicite = false } = {}) {
suspendre();
if (explicite) etat.gestes = 0;
etat.mode = 'lecture';
annoncerAnnulation();
const avertissements = await rendreVerrou(etat.ouvert);
etat.avertissementsGeste = [...etat.avertissementsGeste, ...avertissements];
return avertissements;
@ -346,26 +560,32 @@ export function creerSeance({
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() {
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
// rompu perdrait la seule copie qu'écrire ailleurs peut sauver : refusé,
// sauf sur abandon explicite.
// Quitter l'événement — ouvrir, créer, fermer, changer de racine — quand
// le support est rompu perdrait la seule copie qu'écrire ailleurs peut
// sauver : refusé, sauf sur abandon explicite.
function exigerSauve({ abandonner = false } = {}) {
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'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() {
const ouvert = exigerOuvert();
if (ouvert.formatPlusRecent) throw new ErreurStockage('LECTURE_SEULE', { raison: 'FORMAT' });
if (estBloque(ouvert)) throw new ErreurStockage('LECTURE_SEULE', { raison: 'BLOQUE' });
if (etat.mode === 'lecture') throw new ErreurSeance('MODE_LECTURE');
if (ouvert.formatPlusRecent) throw refusFormat();
if (estBloque(ouvert)) throw refusBloque();
if (etat.mode === 'lecture') throw new ErreurSeance('MODE_LECTURE', { remede: { geste: 'modifier' } });
exigerSupport();
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
// 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.
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;
etat.ouvert = ouvert;
etat.supportRompu = true;
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
// 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
// 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) {
try {
etat.ouvert = await ecriture(ouvert);
etat.formatLu = null;
return etat.ouvert;
} catch (erreur) {
if (erreur instanceof ErreurStockage && erreur.code === 'ECRITURE') rompre(erreur, ouvert);
if (erreur instanceof ErreurStockage && erreur.code === 'ETAT_NON_ECRIT') rompre(erreur, erreur.details.ouvert);
if (erreur instanceof ErreurStockage && erreur.code === 'ECRITURE') await rompre(erreur, ouvert);
if (erreur instanceof ErreurStockage && erreur.code === 'ETAT_NON_ECRIT') await rompre(erreur, erreur.details.ouvert);
throw erreur;
}
}
@ -423,7 +656,7 @@ export function creerSeance({
etat.ouvert = await nommerSurDisque(ouvert, { nom, ...commun });
} catch (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;
suite.push(avertissement('JALON_NON_ECRIT', { chemin, dossier, cause }));
}
@ -461,20 +694,28 @@ export function creerSeance({
avertissements.push(...(await relireSousVerrou(ouvert)));
depart = etat.ouvert;
try {
if (depart.formatPlusRecent) throw new ErreurStockage('LECTURE_SEULE', { raison: 'FORMAT' });
if (depart.formatPlusRecent) throw refusFormat();
resultat = appliquerCommande('debloquer', depart.charge, args);
} catch (erreur) {
etat.avertissementsGeste = [...avertissements, ...(await rendreVerrou(depart))];
throw erreur;
}
}
etat.mode = 'ecriture';
entrerEnEcriture();
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
// commande ajoute. Un rendu au libellé null n'écrit rien : la séance rend
// son résumé, le libellé null.
// commande ajoute, puis ce que le geste fait naître, de la charge d'avant à
// 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) {
const ouvert = exigerOuvert();
if (nom === 'debloquer' && !ouvert.formatPlusRecent && estBloque(ouvert)) {
@ -487,7 +728,8 @@ export function creerSeance({
return { libelle: null, resume: resultat.resume };
}
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 };
}
@ -505,6 +747,14 @@ export function creerSeance({
// --- 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
// 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
@ -580,11 +830,14 @@ export function creerSeance({
// --- Ouvrir, créer, fermer (§ 8.1, § 8.4) ----------------------------------------
// Ferme l'événement ouvert : le compte s'arrête, le verrou se rend, et la
// séance oublie l'événement. Rend les avertissements du rendu.
// Ferme l'événement ouvert : le compte s'arrête, le cadrage en attente
// 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() {
suspendre();
await viderCadrage();
const avertissements = etat.ouvert === null ? [] : await rendreVerrou(etat.ouvert);
if (etat.ouvert !== null) annoncerAnnulation();
Object.assign(etat, {
ouvert: null,
source: null,
@ -592,22 +845,32 @@ export function creerSeance({
verrouTenu: false,
supportRompu: false,
refusSupport: null,
ailleurs: null,
verrouAutre: null,
avertissementsGeste: [],
gestes: 0,
calculs: 0,
fautives: [],
formatLu: null,
retourAutomatique: false,
cadrage: null,
});
return avertissements;
}
// 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).
// 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) {
exigerSauve(quitter);
await fermerEvenement();
const ouvert = await lire();
etat.ouvert = ouvert;
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 };
}
@ -631,7 +894,7 @@ export function creerSeance({
try {
etat.ouvert = await creerSurDisque(fs, etat.racine, charge, options);
} 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;
}
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é.
async function passerEnEcriture() {
const ouvert = exigerOuvert();
if (ouvert.formatPlusRecent) throw new ErreurStockage('LECTURE_SEULE', { raison: 'FORMAT' });
if (estBloque(ouvert)) throw new ErreurStockage('LECTURE_SEULE', { raison: 'BLOQUE' });
if (ouvert.formatPlusRecent) throw refusFormat();
if (estBloque(ouvert)) throw refusBloque();
if (etat.mode === 'ecriture') return { avertissements: [] };
exigerSupport();
const avertissements = await prendreVerrou(ouvert);
avertissements.push(...(await relireSousVerrou(ouvert)));
const relu = etat.ouvert;
let refus = null;
if (relu.formatPlusRecent) refus = new ErreurStockage('LECTURE_SEULE', { raison: 'FORMAT' });
else if (estBloque(relu)) refus = new ErreurStockage('LECTURE_SEULE', { raison: 'BLOQUE' });
if (relu.formatPlusRecent) refus = refusFormat();
else if (estBloque(relu)) refus = refusBloque();
if (refus !== null) {
etat.avertissementsGeste = [...avertissements, ...(await rendreVerrou(relu))];
throw refus;
}
etat.mode = 'ecriture';
entrerEnEcriture();
etat.avertissementsGeste = avertissements;
armer();
return { avertissements };
@ -723,10 +986,15 @@ export function creerSeance({
// type du support de la racine nouvelle, et SUPPORT_AMOVIBLE {dossier}
// annonce la garantie affaiblie d'un support amovible, comme la règle du
// 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();
if (ouvert.formatPlusRecent) throw new ErreurStockage('LECTURE_SEULE', { raison: 'FORMAT' });
if (ouvert.formatPlusRecent) throw refusFormat();
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);
if (present !== null) throw new ErreurStockage('IDENTIFIANT_PRESENT', { id: present.id, base: present.base });
const options = { existantes: [], libelleReprise, horodatage: horloge.horodatage() };
@ -738,13 +1006,21 @@ export function creerSeance({
suspendre();
etat.mode = 'lecture';
etat.racine = nouvelle;
rompre(erreur, erreur.details.ouvert);
await rompre(erreur, erreur.details.ouvert);
etat.avertissementsGeste = await rendreVerrou(ouvert);
}
throw erreur;
}
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') {
try {
avertissements.push(...(await prendreVerrou(ailleurs)));
@ -806,15 +1082,13 @@ export function creerSeance({
get 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() {
return [...(etat.ouvert?.avertissements ?? []), ...etat.avertissementsGeste];
return [...lireEtat().avertissements];
},
/** {defaire, refaire} : les retours qui existent sur le fil courant (§ 8.3). */
get retours() {
if (etat.ouvert === null) return { defaire: false, refaire: false };
const { cibleDefaire, cibleRefaire } = filDe(etat.ouvert);
return { defaire: cibleDefaire !== null, refaire: cibleRefaire !== null };
return retoursDe(etat.ouvert);
},
get gesteEnCours() {
return etat.gestes > 0;
@ -823,6 +1097,55 @@ export function creerSeance({
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
// (exigerSauve).
ouvrir: (base, quitter) => enFile(() => ouvrirAvec(() => ouvrirSurDisque(fs, etat.racine, base), null, quitter)),
@ -834,6 +1157,18 @@ export function creerSeance({
exigerSauve(quitter);
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),
passerEnLecture: () => enFile(passerEnLecture),
executer: (nom, args = {}) => enFile(() => suivi(() => geste(nom, args))),
@ -862,10 +1197,12 @@ export function creerSeance({
debuterCalcul() {
etat.calculs += 1;
suspendre();
publier();
},
finirCalcul() {
etat.calculs = Math.max(0, etat.calculs - 1);
armer();
publier();
},
};
}

File diff suppressed because it is too large Load diff

View file

@ -37,7 +37,7 @@ import { choisirSeparateur, decouper, enregistrementVide } from './lecture.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[]}
*/

View file

@ -67,43 +67,122 @@ const ENTETE_MOTIF = 'motif';
const parLigne = (a, b) => a.ligne - b.ligne;
// Titres pourvus (§ 4.1, § 4.4). Un titre n'est pourvu que par la réservation
// d'une personne non exclue, sur sa place, à un tour au moins : la
// réservation d'une personne exclue est suspendue. La place se lit selon le
// réglage attribuerSieges (§ 2.1, étape 6). Vrai, c'est un siège : une
// réservation sans siège n'y pourvoit aucun titre. Faux, c'est la table, dont
// les titres ne se distinguent pas : à un tour donné, une table de k titres
// en a autant de pourvus que de personnes non exclues qui y sont réservées à
// ce tour, k au plus, et ses titres pourvus sont ceux du tour qui en pourvoit
// le plus.
function titresPourvus({ evenement, reglages, participants, reservations, titres }) {
const presents = new Set(participants.filter(({ exclu }) => !exclu).map(({ id }) => id));
const actives = reservations.filter(({ participant }) => presents.has(participant));
if (reglages.attribuerSieges) {
// Un titre porte toujours un siège : la place d'une réservation sans
// siège, [table, null], n'est celle d'aucun titre.
const place = (table, siege) => JSON.stringify([table, siege]);
const occupees = new Set(actives.map(({ table, siege }) => place(table, siege)));
return titres.filter(({ table, siege }) => occupees.has(place(table, siege))).length;
// L'ordre canonique des réservations, celui du schéma du fichier d'état :
// participant, table, la portée « tous » avant « tour », puis le tour et le
// siège, null en tête. Il départage, partout où la règle des titres et
// l'état des places choisissent entre deux réservations.
const rangDePortee = (portee) => (portee === 'tous' ? 0 : 1);
const nulEnTete = (a, b) => (a === b ? 0 : a === null ? -1 : b === null ? 1 : a - b);
export const ordreDesReservations = (a, b) =>
a.participant - b.participant ||
a.table - b.table ||
rangDePortee(a.portee) - rangDePortee(b.portee) ||
nulEnTete(a.tour, b.tour) ||
nulEnTete(a.siege, b.siege);
// Deux chaînes, comparées unité UTF-16 par unité.
const comparerTextes = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
// Les titres d'une table, par rang dans charge.titres, rangés par siège puis
// 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();
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;
return parTable;
}
// 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
* 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 :
* chaque réservation d'une charge que l'analyse admet est celle d'un
* participant ;
* - titresPourvus : les titres qu'une réservation pourvoit, et que le
* remplacement laisse non pourvus — par la réservation d'une personne non
* exclue, sur son siège quand les sièges sont attribués, sur sa table
* sinon, à un tour au moins ;
* - titresPourvus : les titres qu'une réservation pourvoit à un tour au
* moins, et que le remplacement laisse non pourvus, selon la règle de
* pourvoyeursDesTitres ;
* - propositions : les propositions qui placent au moins une personne
* retirée. Le retenu n'y entre pas : l'import en état retenu porte
* l'avertissement de dérive.

View file

@ -14,7 +14,7 @@ import { VERSION } from '../version.genere.js';
import { apercevoir } from './apercu.js';
import { decoder } from './encodage.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';
const MARQUE = '\u{FEFF}';
@ -553,6 +553,36 @@ describe('appliquerImport : remplacer, et ce que remplacer annonce (§ 10.1)', (
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", () => {
const charge = chargeDe({
participants: [personne(1, 'Ombrelle'), personne(2, 'Pervenche')],

View file

@ -10,7 +10,8 @@
// 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
// 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.
//
// 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 { flushSync, mount, unmount } from 'svelte';
import App from './App.svelte';
import { contraste } from './palette.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 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;
beforeEach(() => {
@ -108,9 +52,9 @@ function 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() {
return [...new Set([bandeau(), ...porteursDeTexte(bandeau())])];
return [{ element: bandeau(), pseudo: null }, ...porteursDeTexte(bandeau())];
}
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 () => {
// 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
// est visible pour checkVisibility (display, visibility, opacité nulle,
// content-visibility), sa boîte est non vide et tient dans la fenêtre, et
// le point central de cette boîte atteint l'élément ou l'un de ses
// descendants — un élément recouvert, ou rogné par un ancêtre, laisse le
// 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.
// ne peint rien. visible interroge donc la mise en page : checkVisibility,
// une boîte non vide dans la fenêtre, et le centre de cette boîte qui
// atteint l'élément ou l'un de ses descendants sans qu'un élément peint
// au-dessus y peigne — un élément recouvert, ou rogné par un ancêtre,
// échoue.
for (const theme of THEMES) {
await sousTheme(theme, () => {
const elements = bandeauEtTextes();
expect(elements.length).toBeGreaterThan(1);
for (const element of elements) {
const lieu = `${theme} : ${designation(element)}`;
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);
const porteurs = bandeauEtTextes();
expect(porteurs.length).toBeGreaterThan(1);
for (const porteur of porteurs) {
expect(visible(porteur), `${theme} : ${designation(porteur)}`).toBe(true);
}
});
}
@ -175,24 +98,25 @@ describe("bandeau d'application", () => {
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 = [];
for (const theme of THEMES) {
const couleurs = await sousTheme(theme, () => {
const textes = porteursDeTexte(bandeau());
expect(textes.length).toBeGreaterThan(0);
for (const element of textes) {
const lieu = `${theme} : ${designation(element)}`;
const texte = composantes(getComputedStyle(element).color);
const fond = fondPeint(element);
expect(fond, lieu).not.toBeNull();
for (const porteur of textes) {
const lieu = `${theme} : ${designation(porteur)}`;
const texte = couleurTexte(porteur);
const fonds = fondPeintSous(porteur);
expect(fonds.length, `${lieu} : aucun fond opaque`).toBeGreaterThan(0);
expect(texte[3], lieu).toBe(1);
expect(fond[3], lieu).toBe(1);
expect(contraste(texte, fond), lieu).toBeGreaterThanOrEqual(4.5);
for (const fond of fonds) {
expect(contraste(texte, fond), `${lieu} sur ${fond.join(', ')}`).toBeGreaterThanOrEqual(4.5);
}
}
return [document.body, bandeau()].map((el) => {
const style = getComputedStyle(el);
return `${style.color} sur ${style.backgroundColor}`;
return `${composantes(style.color)} sur ${composantes(style.backgroundColor)}`;
});
});
couleursParTheme.push(couleurs);
@ -205,23 +129,19 @@ describe("bandeau d'application", () => {
).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
// le pixel peint sans changer le style calculé du texte. Chaque niveau de
// l'ascendance est donc relu, jusqu'à la racine du document.
// le pixel peint sans changer le style calculé du texte. attenuations
// relit donc chaque niveau de l'ascendance, jusqu'à la racine du document.
for (const theme of THEMES) {
await sousTheme(theme, () => {
const textes = porteursDeTexte(bandeau());
expect(textes.length).toBeGreaterThan(0);
for (const element of textes) {
for (const n of ascendance(element)) {
const style = getComputedStyle(n);
const lieu = `${theme} : ${designation(n)} sous ${designation(element)}`;
expect(style.opacity, lieu).toBe('1');
expect(style.filter, lieu).toBe('none');
expect(style.backdropFilter, lieu).toBe('none');
expect(style.mixBlendMode, lieu).toBe('normal');
}
for (const porteur of textes) {
const fautes = attenuations(porteur).map(
(f) => `${designation({ element: f.element, pseudo: f.pseudo })} ${f.propriete}: ${f.valeur}`,
);
expect(fautes, `${theme} : ${designation(porteur)}`).toEqual([]);
}
});
}

View file

@ -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
// ne rejoint pas —, libelleReprise, le texte qu'une écriture donnerait à
// l'entrée de reprise de cet état. TypeError quand il le faut et qu'il manque.
function libelleDeLInstant({ journal, revision }, libelleReprise) {
/**
* 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
* ne rejoint pas —, libelleReprise, le texte qu'une écriture donnerait à
* 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;
if (typeof libelle !== 'string' || libelle === '') {
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
View 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;
}

View 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
View 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);
}
}
}

View 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');
}
});
});

View file

@ -13,7 +13,8 @@
// 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
// 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 { playwright } from '@vitest/browser-playwright';
import { optionsChromium } from './test/chromium.js';
@ -95,7 +96,7 @@ export default defineConfig({
{ extends: './vite.config.js',
test: {
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)),
instances: [{ browser: 'chromium' }] } } },
],