[FIX] iteration 3 review: seat targets, gestures, warnings, settings

The wheel no longer zooms during a gesture, so a drop lands under the
ghost. A seat is a target the size of its pitch. A floor drop refuses to
remove an every-tour reservation. A seat reservation the retained plan
does not hold is flagged at the seat, with its swap as remedy. DERIVE names
the person; capacities have an upper bound; Generate waits for a pending
setting; a worker that fails to load shows a refusal; § 5.3 constraints
get their switches. Commands are not re-entrant and a double click does not
confirm. Checked: 2755 node, 550 browser tests, from the index alone.

--- FR ---

[FIX] revue de l'itération 3 : cibles, gestes, avertissements, réglages

La molette ne zoome plus pendant un geste : le dépôt tombe sous le fantôme.
Un siège est une cible de la taille de son pas. Un dépôt sur le sol refuse
de retirer une réservation de tous les tours. Une réservation à un siège
que le retenu ne tient pas est relevée au siège, l'échange en remède. La
dérive nomme la personne ; les capacités ont une borne ; Générer attend un
réglage en cours ; un travailleur qui ne se charge pas donne un refus ; le
§ 5.3 a ses bascules. Les commandes ne sont pas ré-entrantes et un double
clic ne confirme pas. Vérifié : 2755 node, 550 navigateur, index seul.

Assisted-by: Claude Opus 5.5
This commit is contained in:
Mathieu Benoit 2026-10-07 17:50:35 -04:00
parent 03a6f73337
commit ddd587da87
82 changed files with 3177 additions and 306 deletions

View file

@ -13,11 +13,12 @@
// 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 { LIBRE, nombrePlacesManquantes, normaliser } from '../moteur/configuration.js';
import { verifierInvariants } from '../moteur/verification.js';
import { configurationDepuisCharge } from '../stockage/document.js';
import { capacite, configurationDepuisCharge } from '../stockage/document.js';
import { examiner, planDepuisFichier } from '../stockage/placements.js';
import { nomAffiche } from './personnes.js';
import { couvre, placesTenues, presents, reservationsActives } from './places.js';
const avertissement = (code, details) => ({ code, details });
@ -46,12 +47,139 @@ function raisonsParPlacement(examen) {
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) {
// Les réservations au siège que le retenu, sièges attribués, ne tient pas à
// ce siège alors que sa personne est assise à la même table : celles qui
// tiennent une chaise selon la règle de l'affichage (placesTenues), dans la
// capacité courante. Par tour, dans l'ordre de retenu.tables, par siège ;
// occupant, la personne présente sur ce siège, null pour une chaise vide ou
// celle d'un absent. Une personne assise à une autre table relève de la
// règle de la table, que verifierInvariants juge.
function siegesNonTenus(charge, retenu, instance) {
if (!retenu.siegesAttribues) return [];
const actives = reservationsActives(charge);
const ids = presents(charge);
const enregistrementDe = new Map(charge.tables.map((enregistrement) => [enregistrement.id, enregistrement]));
const violations = [];
const lus = Math.min(retenu.tours, instance.R, retenu.placement.length);
for (let tour = 1; tour <= lus; tour += 1) {
const couvrantes = actives.filter((reservation) => couvre(reservation, tour));
retenu.tables.forEach((idTable, rang) => {
const enregistrement = enregistrementDe.get(idTable);
if (enregistrement === undefined || retenu.tables.indexOf(idTable) !== rang) return;
const liste = retenu.placement[tour - 1].sieges[rang] ?? [];
const { tenu } = placesTenues(
couvrantes.filter(({ table }) => table === idTable),
capacite(charge, enregistrement),
);
for (const siege of [...tenu.keys()].sort((a, b) => a - b)) {
const { participant } = tenu.get(siege);
const assis = liste[siege - 1] ?? null;
if (assis === participant || !liste.includes(participant)) continue;
const occupant = assis !== null && ids.has(assis) ? assis : null;
violations.push({ code: 'RESERVATION', participant, table: idTable, tour, siege, occupant });
}
});
}
return violations;
}
/**
* Les violations d'un retenu lisible (fautes vide) contre l'instance de la
* charge (§ 14.12) : celles de verifierInvariants, la réserve admise — des
* surnuméraires versés en réserve ne violent rien —, complétées de deux
* réservations non tenues qu'il ne relève pas. Une personne que le retenu
* ne place pas à un tour, et qu'une réservation impose à une table à ce
* tour, reçoit RESERVATION {participant, table, tour} juste après son
* NON_ASSIS : ajoutée ou réintégrée depuis la retenue, elle n'est honorée
* nulle part. Sièges attribués, une réservation au siège que le retenu
* donne à un autre, sa personne assise à la même table, reçoit RESERVATION
* {participant, table, tour, siege, occupant} (siegesNonTenus), après les
* autres. Le retenu se juge tel quel, sans retouche (§ 5.8).
*
* @param {import('../stockage/types.js').Charge} charge
* @param {import('../stockage/types.js').Retenu} retenu
* @param {import('../moteur/types.js').Instance} instance celle de la charge
* @returns {Object[]} les violations, dans cet ordre
*/
export function violationsDuRetenu(charge, retenu, instance) {
const { R, indexDe, idsTables, fixe } = instance;
const violations = [];
for (const violation of verifierInvariants(instance, planDepuisFichier(retenu), { reserveAutorisee: true })) {
violations.push(violation);
if (violation.code !== 'NON_ASSIS' || violation.tour > R) continue;
const imposee = fixe[indexDe.get(violation.participant) * R + violation.tour - 1];
if (imposee !== LIBRE) {
violations.push({ code: 'RESERVATION', participant: violation.participant, table: idsTables[imposee], tour: violation.tour });
}
}
return [...violations, ...siegesNonTenus(charge, retenu, instance)];
}
// Les violations du retenu cohérent d'une charge (violationsDuRetenu). []
// sans retenu, pour un retenu fautif, ou sans instance.
function violationsCourantes(charge, examen, instance) {
if (charge.retenu === null || examen.retenu.fautes.length > 0 || instance === null) return [];
return verifierInvariants(instance, planDepuisFichier(charge.retenu), { reserveAutorisee: true });
return violationsDuRetenu(charge, charge.retenu, instance);
}
// Le nom affiché de la personne id dans la première charge qui la porte,
// ou null ; le numéro de la table id de même.
const nomDans = (id, charges) => {
for (const charge of charges) {
const trouvee = charge.participants.find((candidate) => candidate.id === id);
if (trouvee !== undefined) return nomAffiche(trouvee);
}
return null;
};
const numeroDans = (id, charges) => {
for (const charge of charges) {
const trouvee = charge.tables.find((candidate) => candidate.id === id);
if (trouvee !== undefined) return trouvee.numero;
}
return null;
};
/**
* Les raisons de dérive nommées (§ 9) : chacune garde ses champs, et gagne
* personne, le nom affiché de la personne qu'elle désigne, et numero, le
* numéro de la table qu'elle désigne, lus dans la première des charges qui
* les porte ; une personne ou une table qu'aucune ne porte garde son seul
* identifiant. Rend des objets neufs.
*
* @param {Object[]} raisons
* @param {...import('../stockage/types.js').Charge} charges
* @returns {Object[]}
*/
export function raisonsNommees(raisons, ...charges) {
return raisons.map((raison) => {
const nommee = { ...raison };
if (raison.participant !== undefined) {
const personne = nomDans(raison.participant, charges);
if (personne !== null) nommee.personne = personne;
}
if (raison.table !== undefined) {
const numero = numeroDans(raison.table, charges);
if (numero !== null) nommee.numero = numero;
}
return nommee;
});
}
/**
* L'examen d'une charge (examiner, S2), ses raisons de dérive nommées sur
* elle (raisonsNommees) : celles du retenu et de chaque proposition en
* dérive. Un objet neuf ; l'examen reçu ne change pas.
*
* @param {import('../stockage/placements.js').Examen} examen
* @param {import('../stockage/types.js').Charge} charge
* @returns {import('../stockage/placements.js').Examen}
*/
export function examenNomme(examen, charge) {
return {
...examen,
derives: examen.derives.map((derive) => ({ ...derive, raisons: raisonsNommees(derive.raisons, charge) })),
retenu: { ...examen.retenu, raisons: raisonsNommees(examen.retenu.raisons, charge) },
};
}
const memeRaison = (code, cle, valeur) => (raison) => raison.code === code && raison[cle] === valeur;
@ -78,7 +206,8 @@ function raisonQuiLaNomme(violation, raisons) {
* 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.
* ordinaire, à sa table ou à son siège, sa personne placée ou non
* (violationsDuRetenu) : 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
@ -88,7 +217,7 @@ function raisonQuiLaNomme(violation, raisons) {
export function invariantViole(charge, examen = examiner(charge)) {
const { instance } = instanceDe(charge);
const raisons = examen.retenu.raisons;
const violations = violationsDuRetenu(charge, examen, instance).filter(
const violations = violationsCourantes(charge, examen, instance).filter(
(violation) => raisonQuiLaNomme(violation, raisons) === undefined,
);
if (violations.length === 0) return null;
@ -110,15 +239,19 @@ export function invariantViole(charge, examen = examiner(charge)) {
* 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' ;
* ne portait pas, comparées champ pour champ, puis nommées sur la charge
* d'après, ou d'avant pour ce que le geste retire (raisonsNommees) ; les
* propositions par identifiant, puis le retenu, sous id 'retenu' ;
* RESERVATION_NON_HONOREE {participant, table, tour, personne, numero}
* 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 ; personne est le nom affiché du
* participant, numero celui de la table, lus sur la charge d'après, qui
* porte l'une et l'autre puisque l'instance qui les viole en vient.
* chaque violation RESERVATION de violationsDuRetenu(apres, apres.retenu,
* instance d'après) 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 ; personne est le nom affiché du participant,
* numero celui de la table, lus sur la charge d'après, qui porte l'une et
* l'autre puisque l'instance qui les viole en vient. Au siège, les
* détails portent en plus siege et occupant, ceux de la violation : le
* geste qui la lève est l'échange de la personne avec l'occupant
* (echangerDansRetenu), ou, chaise vide, son déplacement au siège.
*
* @param {import('../stockage/types.js').Charge} avant
* @param {import('../stockage/types.js').Charge} apres
@ -145,19 +278,24 @@ export function avertissementsApres(avant, apres) {
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 (nouvelles.length > 0) touchees.push({ id, raisons: raisonsNommees(nouvelles, apres, avant) });
}
if (touchees.length > 0) rendus.push(avertissement('DERIVE_NOUVELLE', { touchees }));
const reservations = (charge, examen, inst) =>
violationsDuRetenu(charge, examen, inst).filter(({ code }) => code === 'RESERVATION');
violationsCourantes(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);
for (const violation of reservations(apres, examenApres, instance)) {
const { participant, table, tour, siege = null } = violation;
const dejaLa = tenues.some(
(connue) =>
connue.participant === participant && connue.table === table && connue.tour === tour && (connue.siege ?? null) === siege,
);
if (dejaLa) continue;
const personne = nomAffiche(apres.participants.find(({ id }) => id === participant));
const { numero } = apres.tables.find(({ id }) => id === table);
rendus.push(avertissement('RESERVATION_NON_HONOREE', { participant, table, tour, personne, numero }));
const auSiege = siege === null ? {} : { siege, occupant: violation.occupant };
rendus.push(avertissement('RESERVATION_NON_HONOREE', { participant, table, tour, personne, numero, ...auSiege }));
}
return rendus;
}

View file

@ -11,8 +11,9 @@ 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 { examiner } from '../stockage/placements.js';
import { VERSION } from '../version.genere.js';
import { avertissementsApres, invariantViole } from './avertissements.js';
import { avertissementsApres, examenNomme, invariantViole } from './avertissements.js';
const personne = (id, nom, exclu = false) => ({
id,
@ -184,7 +185,7 @@ 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 }];
const raisons = [{ code: 'PARTICIPANT_EXCLU', participant: 3, personne: 'Pervenche' }];
assert.deepEqual(avertissementsApres(avant, apres), [
{
code: 'DERIVE_NOUVELLE',
@ -196,14 +197,14 @@ describe('avertissements : la dérive que le geste ajoute (§ 9, § 8.9)', () =>
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 }];
const raisons = [{ code: 'PARTICIPANT_EXCLU', participant: 4, personne: 'Lacasse' }];
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 }];
const changee = [{ code: 'CAPACITE_CHANGEE', table: 1, avant: 2, maintenant: 4, numero: 1 }];
assert.deepEqual(avertissementsApres(trois, quatre), [
{
code: 'DERIVE_NOUVELLE',
@ -218,13 +219,63 @@ describe('avertissements : la dérive que le geste ajoute (§ 9, § 8.9)', () =>
charge.participants[2].exclu = true;
reserverGrisaille(charge);
});
const raisons = [{ code: 'PARTICIPANT_EXCLU', participant: 3 }];
const raisons = [{ code: 'PARTICIPANT_EXCLU', participant: 3, personne: 'Pervenche' }];
assert.deepEqual(avertissementsApres(avant, apres), [
{ code: 'DERIVE_NOUVELLE', details: { touchees: [{ id: 1, raisons }, { id: 2, raisons }] } },
]);
});
});
describe('avertissements : la dérive nomme les personnes et les tables (§ 9)', () => {
test("une personne se nomme par son nom affiché, une table par son numéro ; celles que le geste retire, par la charge d'avant", () => {
const avant = avec(petiteSoiree(), (charge) => {
charge.tables[1].numero = 7;
charge.participants[1].prenom = 'Aubépine';
});
const supprimees = avec(avant, (charge) => {
charge.participants = charge.participants.filter(({ id }) => id !== 2);
charge.reservations = charge.reservations.filter(({ participant }) => participant !== 2);
charge.tables = charge.tables.filter(({ id }) => id !== 2);
});
const raisons = [
{ code: 'PARTICIPANT_SUPPRIME', participant: 2, personne: 'Aubépine Grisaille' },
{ code: 'TABLE_SUPPRIMEE', table: 2, numero: 7 },
];
const [derive] = avertissementsApres(avant, supprimees).filter(({ code }) => code === 'DERIVE_NOUVELLE');
assert.deepEqual(derive.details.touchees, [{ id: 1, raisons }, { id: 2, raisons }, { id: 'retenu', raisons }]);
const ajoutees = avec(avant, (charge) => {
charge.participants.push(personne(5, 'Ancolie'));
charge.tables.push({ ...table(3, null), numero: 9 });
charge.prochainsIds.participant = 6;
charge.prochainsIds.table = 4;
});
const [nouvelle] = avertissementsApres(avant, ajoutees).filter(({ code }) => code === 'DERIVE_NOUVELLE');
assert.deepEqual(nouvelle.details.touchees[0].raisons, [
{ code: 'PARTICIPANT_NON_PLACE', participant: 5, personne: 'Ancolie' },
{ code: 'TABLE_AJOUTEE', table: 3, numero: 9 },
]);
});
test("examenNomme nomme chaque raison de l'examen sur la charge, sans toucher l'examen reçu ; une personne ou une table que la charge n'a plus garde son identifiant", () => {
const charge = avec(petiteSoiree(), (copie) => {
copie.participants[2].exclu = true;
copie.participants.pop();
});
const examen = examiner(charge);
const copie = structuredClone(examen);
const nomme = examenNomme(examen, charge);
assert.deepEqual(examen, copie);
const attendues = [
{ code: 'PARTICIPANT_EXCLU', participant: 3, personne: 'Pervenche' },
{ code: 'PARTICIPANT_SUPPRIME', participant: 4 },
];
assert.deepEqual(nomme.retenu.raisons, attendues);
assert.deepEqual(nomme.derives.map(({ raisons }) => raisons), [attendues, attendues]);
assert.deepEqual(nomme.fautives, examen.fautives);
});
});
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, personne, numero}', () => {
const avant = petiteSoiree();
@ -332,3 +383,110 @@ describe('avertissements : les violations du retenu hors de la dérive (§ 14.12
assert.equal(invariantViole(incoherente), null);
});
});
// Huit personnes, deux tables de quatre, un tour, sièges attribués : le
// retenu assied 1 à 4 aux sièges 1 à 4 de la table 1, et 5 à 8 à la table 2.
function soireeAuxSieges() {
const charge = creerCharge({ id: 'evt-sieges', nom: 'Soirée des Chaises', siegesParDefaut: 4, tours: 1 });
const ids = [1, 2, 3, 4, 5, 6, 7, 8];
charge.participants = ids.map((id) => personne(id, `Convive ${id}`));
charge.tables = [table(1, null), table(2, null)];
charge.reglages.attribuerSieges = true;
const plan = { tables: [1, 2], capacites: [4, 4], tours: 1, participants: ids, placement: [{ sieges: [[1, 2, 3, 4], [5, 6, 7, 8]], reserve: [] }] };
charge.propositions = [
{ id: 1, graine: 1, arret: 40, historique: 1, produitVersion: VERSION.affichee, siegesAttribues: true, ...structuredClone(plan) },
];
charge.retenu = { proposition: 1, siegesAttribues: true, ...structuredClone(plan) };
charge.prochainsIds = { participant: 9, table: 3, proposition: 2 };
charge.evenement.etat = 'retenu';
return charge;
}
const reserverAuSiege = (participant, idTable, siege) => (charge) =>
charge.reservations.push({ participant, table: idTable, siege, portee: 'tous', tour: null });
const deCode = (code) => (avertissements) => avertissements.filter((avertissement) => avertissement.code === code);
describe("avertissements : la réservation que le retenu n'honore pas au siège (§ 5.8, § 14.12)", () => {
test('sièges attribués, une réservation au siège 3 que le retenu donne à un autre, la personne assise à la même table : RESERVATION_NON_HONOREE au siège, avec son occupant', () => {
const avant = soireeAuxSieges();
const apres = avec(avant, reserverAuSiege(2, 1, 3));
assert.deepEqual(avertissementsApres(avant, apres), [
{
code: 'RESERVATION_NON_HONOREE',
details: { participant: 2, table: 1, tour: 1, personne: 'Convive 2', numero: 1, siege: 3, occupant: 3 },
},
]);
assert.deepEqual(invariantViole(apres), {
code: 'INVARIANT_VIOLE',
details: { proposition: 1, violations: [{ code: 'RESERVATION', participant: 2, table: 1, tour: 1, siege: 3, occupant: 3 }] },
});
assert.deepEqual(avertissementsApres(apres, avec(apres, (charge) => (charge.participants[0].nom = 'Parasol'))), []);
});
test("la chaise réservée vide : l'occupant est null", () => {
const avant = avec(soireeAuxSieges(), (charge) => {
charge.retenu.placement[0].sieges[0] = [1, 2, null, 4];
charge.retenu.placement[0].reserve = [3];
});
const apres = avec(avant, reserverAuSiege(2, 1, 3));
assert.deepEqual(deCode('RESERVATION_NON_HONOREE')(avertissementsApres(avant, apres)), [
{
code: 'RESERVATION_NON_HONOREE',
details: { participant: 2, table: 1, tour: 1, personne: 'Convive 2', numero: 1, siege: 3, occupant: null },
},
]);
});
test("tenue à son siège, réservée sans siège, ou sans attribution des sièges : rien au siège", () => {
const avant = soireeAuxSieges();
assert.deepEqual(avertissementsApres(avant, avec(avant, reserverAuSiege(3, 1, 3))), []);
assert.deepEqual(avertissementsApres(avant, avec(avant, reserverAuSiege(2, 1, null))), []);
const sansAttribution = avec(avant, (charge) => {
charge.reglages.attribuerSieges = false;
charge.retenu.siegesAttribues = false;
charge.propositions[0].siegesAttribues = false;
});
assert.deepEqual(avertissementsApres(sansAttribution, avec(sansAttribution, reserverAuSiege(2, 1, 3))), []);
assert.equal(invariantViole(avec(sansAttribution, reserverAuSiege(2, 1, 3))), null);
});
test('deux réservations au même siège : la première dans l’ordre canonique le tient, la seconde se lit sans siège', () => {
const avant = soireeAuxSieges();
const apres = avec(avant, (charge) => {
reserverAuSiege(3, 1, 3)(charge);
reserverAuSiege(4, 1, 3)(charge);
});
assert.deepEqual(avertissementsApres(avant, apres), []);
});
});
describe("avertissements : la réservation d'une personne que le retenu ne place pas (§ 9, § 14.12)", () => {
test("une personne ajoutée après la retenue et réservée : RESERVATION_NON_HONOREE, en plus de la dérive qui la dit non placée ; INVARIANT_VIOLE la garde", () => {
const avant = soireeAuxSieges();
const apres = avec(avant, (charge) => {
charge.participants.push(personne(9, 'Ancolie'));
charge.prochainsIds.participant = 10;
reserverAuSiege(9, 2, null)(charge);
});
const rendus = avertissementsApres(avant, apres);
assert.deepEqual(codes(rendus), ['PLACES_MANQUANTES', 'DERIVE_NOUVELLE', 'RESERVATION_NON_HONOREE']);
assert.deepEqual(deCode('RESERVATION_NON_HONOREE')(rendus)[0].details, {
participant: 9,
table: 2,
tour: 1,
personne: 'Ancolie',
numero: 2,
});
assert.deepEqual(invariantViole(apres).details.violations, [{ code: 'RESERVATION', participant: 9, table: 2, tour: 1 }]);
});
test("non réservée, la personne ajoutée ne relève que de la dérive", () => {
const avant = soireeAuxSieges();
const apres = avec(avant, (charge) => {
charge.participants.push(personne(9, 'Ancolie'));
charge.prochainsIds.participant = 10;
});
assert.deepEqual(deCode('RESERVATION_NON_HONOREE')(avertissementsApres(avant, apres)), []);
assert.equal(invariantViole(apres), null);
});
});

View file

@ -9,7 +9,7 @@
// controlerCapacites (capacite.js), le seul contrôle. Aucune fonction ne
// modifie ce qu'elle reçoit.
import { ordreDesReservations } from '../csv/import.js';
import { capacite } from '../stockage/document.js';
import { SIEGES_MAX, capacite } from '../stockage/document.js';
import { controlerCapacites } from './capacite.js';
import { presents } from './places.js';
@ -46,8 +46,10 @@ const identifiants = (placements, touche) =>
* Chaque table dont la capacité change se contrôle comme une saisie au champ
* (§ 6.2) : refus, le premier de chaque table qui refuse, {table, code,
* details}, par ordre de charge.tables ; avertissements, de même, ceux des
* autres — SURNUMERAIRES, AU_DELA_LISIBLE. Moins de 2 places refusent pour
* l'événement entier : un seul refus, MOINS_DE_DEUX, table null.
* autres — SURNUMERAIRES, AU_DELA_LISIBLE. Moins de 2 places, ou plus de
* SIEGES_MAX, refusent pour l'événement entier, qu'une table suive le
* défaut ou non : un seul refus, MOINS_DE_DEUX ou TROP_DE_PLACES {max},
* table null.
*
* @param {import('../stockage/types.js').Charge} charge
* @param {number} sieges
@ -64,10 +66,11 @@ export function bilanChangementDefaut(charge, sieges, { realigner = false } = {}
const changements = new Map(
charge.tables.filter((table) => apresDe(table) !== capacite(charge, table)).map((table) => [table.id, apresDe(table)]),
);
const controle =
sieges < 2
? { refus: [{ table: null, code: 'MOINS_DE_DEUX', details: { remede: null } }], avertissements: [] }
: controlerCapacites(charge, changements, { origine: 'champ' });
const pourLEvenement = (code, details) => ({ refus: [{ table: null, code, details: { ...details, remede: null } }], avertissements: [] });
let controle;
if (sieges < 2) controle = pourLEvenement('MOINS_DE_DEUX', {});
else if (sieges > SIEGES_MAX) controle = pourLEvenement('TROP_DE_PLACES', { max: SIEGES_MAX });
else controle = controlerCapacites(charge, changements, { origine: 'champ' });
return {
suivent: charge.tables.filter((table) => table.sieges === null).length,
avant: charge.evenement.siegesParDefaut,

View file

@ -8,7 +8,7 @@
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { nombrePlaces, normaliser } from '../moteur/configuration.js';
import { configurationDepuisCharge, creerCharge } from '../stockage/document.js';
import { SIEGES_MAX, configurationDepuisCharge, creerCharge } from '../stockage/document.js';
import { bilanChangementDefaut, bilanSuppressionTable, bilanTours } from './bilans.js';
import { COMMANDES } from './commandes.js';
@ -125,6 +125,15 @@ describe('bilans : changer le défaut (§ 6.1)', () => {
assert.deepEqual(bilanChangementDefaut(charge, 1).refus, [{ table: null, code: 'MOINS_DE_DEUX', details: { remede: null } }]);
});
test("au-delà de SIEGES_MAX, un seul refus pour l'événement, TROP_DE_PLACES, même quand aucune table ne suit le défaut", () => {
const refus = [{ table: null, code: 'TROP_DE_PLACES', details: { max: SIEGES_MAX, remede: null } }];
const suivent = chargeDe({ tables: [{ id: 1 }, { id: 2 }], n: 4 });
assert.deepEqual(bilanChangementDefaut(suivent, SIEGES_MAX + 1).refus, refus);
const surchargees = chargeDe({ tables: [{ id: 1, sieges: 6 }], n: 4 });
assert.deepEqual(bilanChangementDefaut(surchargees, SIEGES_MAX + 1).refus, refus);
assert.deepEqual(bilanChangementDefaut(suivent, SIEGES_MAX).refus, []);
});
test('une baisse sous un retenu occupé avertit SURNUMERAIRES : deux personnes de la table 3 au tour 2', () => {
const charge = chargeDe({ tables: [{ id: 1 }, { id: 2 }, { id: 3 }], n: 18 });
const suite = (de, a) => Array.from({ length: a - de + 1 }, (_, rang) => de + rang);

View file

@ -9,7 +9,8 @@
//
// Un refus a la forme { code, details }, le remède dans details.remede. Ils
// se contrôlent dans cet ordre, et le premier seul est rendu :
// MOINS_DE_DEUX ; SOUS_RESERVATIONS, sous le plus grand compte de
// MOINS_DE_DEUX ; TROP_DE_PLACES, au-delà de SIEGES_MAX, la borne que
// l'analyse du fichier pose aussi ; SOUS_RESERVATIONS, sous le plus grand compte de
// reservationsParTour ; SIEGES_RETIRES, un siège au-delà de la capacité qui
// porte un titre ou une réservation, suspendue comprise — l'analyse du
// fichier refuse un tel siège (§ 6.2) — ; SOUS_OCCUPES, pour la poignée, sous
@ -37,7 +38,7 @@
// fonction ne modifie ce qu'elle reçoit.
import { ordreDesReservations } from '../csv/import.js';
import { constante } from '../geometrie/constantes.js';
import { capacite } from '../stockage/document.js';
import { SIEGES_MAX, capacite } from '../stockage/document.js';
import { fautes } from '../stockage/placements.js';
import { couvre, etatPlaces, presents, repartirSieges, reservationsActives } from './places.js';
import { reservationsParTour } from './pose.js';
@ -148,6 +149,7 @@ function occupation(charge, idTable) {
// Le premier refus d'une capacité, ou null.
function refusDeCapacite(charge, idTable, sieges, origine) {
if (sieges < 2) return refusDe('MOINS_DE_DEUX', {}, null);
if (sieges > SIEGES_MAX) return refusDe('TROP_DE_PLACES', { max: SIEGES_MAX }, null);
const reservees = maximum(reservationsParTour(charge, idTable));
if (sieges < reservees.n) {
return refusDe('SOUS_RESERVATIONS', { table: idTable, ...reservees }, afficherTour(reservees.tour));
@ -379,7 +381,8 @@ export function controlerCapacites(charge, changements, { origine }) {
* verse aucune ; puis AU_DELA_LISIBLE {seuil} au-delà de
* SEUIL_SIEGES_LISIBLES places.
*
* Refus : MOINS_DE_DEUX {}, remède null ; SOUS_RESERVATIONS {table, n,
* Refus : MOINS_DE_DEUX {}, remède null ; TROP_DE_PLACES {max}, au-delà de
* SIEGES_MAX, remède null ; SOUS_RESERVATIONS {table, n,
* tour} et SOUS_OCCUPES {table, n, tour}, tour le premier qui atteint n,
* remède { geste: 'afficherTour', tour, proposition: null } ;
* SIEGES_RETIRES {table, sieges, titres?} — chaque siège retiré qui porte un

View file

@ -10,7 +10,7 @@ import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { constante } from '../geometrie/constantes.js';
import { serialiser } from '../stockage/canonique.js';
import { analyser, creerCharge } from '../stockage/document.js';
import { SIEGES_MAX, analyser, creerCharge } from '../stockage/document.js';
import { controlerCapacite, lireEntier } from './capacite.js';
import { COMMANDES } from './commandes.js';
import { libelle } from './libelles.js';
@ -96,6 +96,17 @@ describe("capacité : les refus d'une capacité (§ 6.2)", () => {
assert.equal(champ(charge, 2).refus, null);
});
test('au-delà de SIEGES_MAX places : TROP_DE_PLACES {max}, remède null, au champ comme à la poignée ; SIEGES_MAX passe, et la charge se relit', () => {
const charge = chargeUneTable();
for (const sieges of [SIEGES_MAX + 1, 99_999_999_999, 2 ** 32]) {
assert.deepEqual(champ(charge, sieges).refus, { code: 'TROP_DE_PLACES', details: { max: SIEGES_MAX, remede: null } });
assert.deepEqual(poignee(charge, sieges).refus, { code: 'TROP_DE_PLACES', details: { max: SIEGES_MAX, remede: null } });
}
assert.equal(champ(charge, SIEGES_MAX).refus, null);
const { charge: rendue } = COMMANDES.get('changerPlacesTable')(charge, { id: 1, sieges: String(SIEGES_MAX), origine: 'champ' });
assert.doesNotThrow(() => analyser(serialiser(rendue, { revision: 1, produitVersion: 'essai' })));
});
test('sous les réservations : le plus grand compte de reservationsParTour, au premier tour qui l\'atteint', () => {
const cinq = chargeUneTable({ reservations: [1, 2, 3, 4, 5].map((id) => resa(id, 1, null)) });
assert.deepEqual(champ(cinq, 4).refus, {

View file

@ -22,7 +22,7 @@
// proposition ne se réécrit jamais : elle dérive (§ 8.9, § 9). Supprimer une
// table et changer les tours laissent propositions et retenu en dérive.
import { caseLibre } from '../geometrie/tables.js';
import { SCHEMA, capacite } from '../stockage/document.js';
import { SCHEMA, TOURS_MAX, capacite } from '../stockage/document.js';
import { fautes } from '../stockage/placements.js';
import { bilanChangementDefaut } from './bilans.js';
import { controlerCapacite, lireEntier, retenuAuxCapacites } from './capacite.js';
@ -214,7 +214,8 @@ function changerDefaut(charge, { sieges: saisie, realigner } = {}) {
/**
* changerTours {tours} (§ 2.1 étape 5, § 4.3) : le nombre de tours, lu par
* lireEntier ; MOINS_D_UN_TOUR {tours} sous 1, remède null. Retirer des tours
* lireEntier ; MOINS_D_UN_TOUR {tours} sous 1, TROP_DE_TOURS {max} au-delà
* de TOURS_MAX, la borne que l'analyse pose aussi, remède null. Retirer des tours
* détruit les réservations « tour désigné » au-delà, dans la même entrée ;
* la portée « tous » reste, symbolique. Propositions et retenu restent, en
* dérive TOURS_CHANGES, jamais tronqués.
@ -223,6 +224,7 @@ function changerTours(charge, { tours: saisie } = {}) {
exigerModifiable(charge);
const tours = entierSaisi(saisie);
if (tours < 1) throw refus('MOINS_D_UN_TOUR', { tours });
if (tours > TOURS_MAX) throw refus('TROP_DE_TOURS', { max: TOURS_MAX });
const suivante = structuredClone(charge);
suivante.evenement.tours = tours;
suivante.reservations = suivante.reservations.filter(({ portee, tour }) => portee === 'tous' || tour <= tours);

View file

@ -19,7 +19,7 @@ import { STATUT, nombrePlacesManquantes, normaliser } from '../moteur/configurat
import { ErreurConfiguration } from '../moteur/erreurs.js';
import { serialiser } from '../stockage/canonique.js';
import { creer as creerDansLeDepot } from '../stockage/depot.js';
import { analyser, capacite, configurationDepuisCharge, creerCharge } from '../stockage/document.js';
import { SIEGES_MAX, TOURS_MAX, analyser, capacite, configurationDepuisCharge, creerCharge } from '../stockage/document.js';
import { lireJournal } from '../stockage/journal.js';
import { SUFFIXES } from '../stockage/noms.js';
import { derive, examiner, fautes } from '../stockage/placements.js';
@ -737,7 +737,11 @@ describe("mobilier : les places d'une table, par le champ et par la poignée (§
});
const surnumeraires = attendus.map(([, , personnes], rang) => ({ code: 'SURNUMERAIRES', details: { table: 1, tour: rang + 1, personnes } }));
assert.deepEqual(avertissements, surnumeraires, nom);
assert.equal(invariantViole(apres), null, nom);
// La réduction n'ajoute aucune violation : un siège réservé que le
// retenu donnait déjà à un autre peut le rester, rien d'autre.
const violations = (etat) => (invariantViole(etat)?.details.violations ?? []).map((v) => JSON.stringify(v));
const deja = violations(charge);
assert.deepEqual(violations(apres).filter((v) => !deja.includes(v)), [], nom);
// La réduction n'ajoute aucune raison de dérive ; un exclu garde la sienne.
assert.deepEqual(derive(apres.retenu, apres), derive(charge.retenu, charge), nom);
assert.deepEqual(fautes(apres.retenu), [], nom);
@ -942,6 +946,29 @@ describe('mobilier : les tours (§ 2.1 étape 5, § 4.3)', () => {
assert.equal(ecritures(), avant);
assert.equal(seance.charge.evenement.tours, 2);
});
test('au-delà de TOURS_MAX : TROP_DE_TOURS {max}, remède null, sans écriture ; TOURS_MAX passe', async () => {
const { seance, ecritures } = await seanceSur(chargeDe({ tables: [{ id: 1 }], n: 2, tours: 2 }));
const avant = ecritures();
for (const tours of [String(TOURS_MAX + 1), '99999999999']) {
await refuseAsync(seance.executer('changerTours', { tours }), 'TROP_DE_TOURS', { max: TOURS_MAX, remede: null });
}
assert.equal(ecritures(), avant);
await seance.executer('changerTours', { tours: String(TOURS_MAX) });
assert.equal(seance.charge.evenement.tours, TOURS_MAX);
});
test('au-delà de SIEGES_MAX, le défaut comme une table refusent TROP_DE_PLACES {max}, remède null, sans rien écrire', async () => {
const { seance, ecritures } = await seanceSur(chargeDe({ tables: [{ id: 1 }], n: 2, tours: 2 }));
const avant = ecritures();
await refuseAsync(seance.executer('changerDefaut', { sieges: '1000000', realigner: false }), 'TROP_DE_PLACES', { max: SIEGES_MAX, remede: null });
await refuseAsync(
seance.executer('changerPlacesTable', { id: 1, sieges: '99999999999', origine: 'champ' }),
'TROP_DE_PLACES',
{ max: SIEGES_MAX, remede: null },
);
assert.equal(ecritures(), avant);
});
});
describe('mobilier : les réglages (§ 5.3, § 15.4)', () => {

View file

@ -27,9 +27,9 @@
// 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 { fautes } from '../stockage/placements.js';
import { violationsDuRetenu } from './avertissements.js';
import { refus } from './erreurs.js';
import { libelle } from './libelles.js';
import { conversionsPossibles } from './listes_travail.js';
@ -418,18 +418,21 @@ function rangDeTable(retenu, idTable, places) {
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]);
// Clé d'une violation : ses champs, dans un ordre fixe ; l'occupant d'un
// siège réservé n'y entre pas, la réservation non tenue reste la même.
const cleDeViolation = ({ code, participant, table, tour, detail, siege }) =>
JSON.stringify([code, participant, table, tour, detail, siege]);
// Les violations de verifierInvariants que suivant ajoute au retenu
// precedent, la réserve étant une place du plan (§ 12.6) : celles dont la
// Les violations du retenu (violationsDuRetenu, celles de verifierInvariants,
// la réserve étant une place du plan, et les réservations non tenues qu'il
// ne relève pas) que suivant ajoute au retenu precedent : 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.
// personne une fois, et 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 juger = (retenu) => violationsDuRetenu(charge, retenu, instance);
const connues = new Set(juger(precedent).map(cleDeViolation));
return juger(suivant).filter((violation) => !connues.has(cleDeViolation(violation)));
}
@ -477,8 +480,9 @@ function numeroDuLieu(charge, tour, id) {
/**
* 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
* libère. Sièges attribués, au siège donné — sans siège, celui que sa
* réservation lui tient à cette table, sinon la première chaise libre que
* ne tient pas la réservation d'un autre —, 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
@ -517,8 +521,25 @@ function deplacerDansRetenu(charge, { tour, participant, table: idTable, siege =
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;
// Sans siège, la chaise que la réservation de la personne lui tient à
// cette table, sinon la première chaise libre — vide ou d'un absent —
// que ne tient pas la réservation active d'un autre : le contrôle de
// pose ne juge que le siège donné.
const tenues = siegesTenus(charge, tour, idTable);
const sienne = [...tenues.keys()].find((numero) => tenues.get(numero) === participant);
const libre = (numero) => {
const assis = liste[numero - 1] ?? null;
return (assis === null || !ids.has(assis)) && (!tenues.has(numero) || tenues.get(numero) === participant);
};
if (sienne !== undefined && (liste[sienne - 1] ?? null) === participant) {
throw refus('SANS_EFFET', { commande: 'deplacerDansRetenu' });
}
if (sienne !== undefined && libre(sienne)) {
cible = sienne;
} else {
cible = 1;
while (cible <= places && !libre(cible)) cible += 1;
}
if (cible > places) throw refus('TABLE_PLEINE', { table: idTable, tour, capacite: places }, changerPlaces(idTable, places));
}
const occupant = liste[cible - 1] ?? null;
@ -572,6 +593,13 @@ function deplacerDansRetenu(charge, { tour, participant, table: idTable, siege =
};
}
// Les sièges de la table idTable que tient au tour une réservation active,
// tels que le plan les montre (etatPlaces) : Map siège → titulaire.
function siegesTenus(charge, tour, idTable) {
const { chaises } = etatPlaces(charge, { tour }).tables.find(({ id }) => id === idTable);
return new Map(chaises.filter(({ reservation }) => reservation !== null).map(({ siege, reservation }) => [siege, reservation.participant]));
}
// 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

View file

@ -764,6 +764,53 @@ describe('commandes des places : le vérificateur d’invariants du retenu (§ 1
});
});
describe('commandes des places : le dépôt sans siège et les réservations au siège (§ 5.9, § 8.9)', () => {
// À chaque tour, la table 4 assied 1, 2, 3 aux sièges 1 à 3, la table 7
// assied 4 à 11, et 12 attend en réserve.
const tours = [1, 2, 3].map(() => [[1, 2, 3], [4, 5, 6, 7, 8, 9, 10, 11]]);
test('sièges attribués, un dépôt sans siège saute la chaise que tient la réservation active d’un autre', () => {
const charge = figer(chargeRetenue({ tours, reservations: [resa(9, 5, 4)] }));
const { charge: apres } = executer('deplacerDansRetenu', charge, { tour: 1, participant: 12, table: 5, siege: null });
assert.deepEqual(listes(apres, 1)[0], [1, 2, 3, null, 12]);
const chaise4 = etatPlaces(apres, { tour: 1 }).tables[0].chaises[3];
assert.deepEqual([chaise4.occupant, chaise4.reservation?.participant], [null, 9]);
});
test('sièges attribués, sans siège, la personne réservée à un siège de la table y va ; déjà assise sur lui, SANS_EFFET', () => {
const charge = figer(chargeRetenue({ tours, reservations: [resa(10, 5, 6)] }));
const { charge: apres } = executer('deplacerDansRetenu', charge, { tour: 1, participant: 10, table: 5, siege: null });
assert.deepEqual(listes(apres, 1)[0], [1, 2, 3, null, null, 10]);
refuse(() => executer('deplacerDansRetenu', apres, { tour: 1, participant: 10, table: 5, siege: null }), 'SANS_EFFET');
});
test('une personne ajoutée après la retenue et réservée à une table : déposée à cette table au tour 2, elle y est assise, sans INVARIANT_VIOLE', () => {
const charge = chargeRetenue();
charge.participants.push(personne(13));
charge.prochainsIds.participant = 14;
charge.reservations.push(resa(13, 5, null));
const { charge: apres } = executer('deplacerDansRetenu', charge, { tour: 2, participant: 13, table: 5, siege: null });
assert.deepEqual(listes(apres, 2)[0], [1, 8, 9, 10, 11, 12, 13]);
assert.deepEqual(reserveDe(apres, 1), [13]);
});
test('sièges attribués, désigner au siège 3 la personne assise au siège 2 : le retenu reste tel quel, RESERVATION_NON_HONOREE au siège, et son remède, l’échange, la lève', () => {
const charge = chargeRetenue();
const { charge: designee } = appliquerCommande('designer', charge, { participant: 2, table: 5, siege: 3, libelle: 'Animation' });
assert.deepEqual(designee.retenu, charge.retenu);
// Au tour 2, le retenu assied 2 à la table 7 : la réservation n'y est
// pas tenue à sa table ; aux tours 1 et 3, à la table 4, mais au siège 2.
const nonTenues = (avant, apres) =>
avertissementsApres(avant, apres)
.filter(({ code }) => code === 'RESERVATION_NON_HONOREE')
.map(({ details }) => [details.tour, details.siege ?? null, details.occupant ?? null]);
assert.deepEqual(nonTenues(charge, designee), [[2, null, null], [1, 3, 3], [3, 3, 3]]);
const { charge: echangee } = executer('echangerDansRetenu', designee, { tour: 1, a: 2, b: 3 });
assert.deepEqual(listes(echangee, 1)[0], [1, 3, 2, 4, 5, 6]);
assert.deepEqual(nonTenues(charge, echangee), [[2, null, null], [3, 3, 3]]);
});
});
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.
@ -894,6 +941,8 @@ describe('commandes des places : les textes de leurs codes (§ 14.6)', () => {
['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']],
// Le refus du dépôt sur le sol (src/interface/plan/depot.js), dont le texte vit ici.
['RESERVATION_TOUS_LES_TOURS', [{ participant: 1, table: 3 }, 'tous les tours']],
]);
test('chaque code de la table des places a son texte, composé de ses détails', () => {
@ -906,6 +955,12 @@ describe('commandes des places : les textes de leurs codes (§ 14.6)', () => {
assert.ok(!texte.includes('undefined'), `${code} : ${texte}`);
}
assert.ok(libelle('PLACE_RESERVEE', { siege: 3, tour: null, portee: 'tous' }).includes('à tous les tours'));
// Le refus du dépôt sur le sol dit ce que le sol ne fait pas, sans rien
// promettre du glissement vers une autre chaise : deplacerReservation y
// garde la portée « tous », et la déplace à chaque tour.
const tous = libelle('RESERVATION_TOUS_LES_TOURS', { participant: 1, table: 3, remede: null });
assert.ok(tous.includes('hors des tables'), tous);
assert.ok(!tous.includes('ne change que'), tous);
assert.equal(libelle('places.titreParDefaut'), 'animateur');
});
});

View file

@ -10,9 +10,14 @@
// ← { type: 'avancement', fait, total } remis aux abonnés
// ← { type: 'resultat', propositions } résout le résultat
// ← { type: 'erreur', code, details } rejette par ErreurGeneration
// ← { type: 'faute', message, pile } rejette par une Error, une faute
// ✕ canal d'erreur du travailleur rejette par TRAVAILLEUR_EN_ECHEC
//
// Le travailleur poste ses fautes de calcul en message : son canal d'erreur
// ne porte que l'échec de son script, qui ne se charge ou ne se compile pas.
//
// Chaque lancer prend un travailleur neuf de la fabrique et le termine à
// l'issue : résultat, erreur, faute du travailleur ou annulation. Terminé,
// l'issue : résultat, erreur, faute, échec du script ou annulation. Terminé,
// le travailleur ne remet plus rien : un message déjà en file à cet instant
// ne parvient ni aux abonnés ni au résultat. L'annulation est terminate(),
// dans le même tour : immédiate, sans résultat partiel. La fabrique est un
@ -32,8 +37,14 @@ import { ErreurAnnulee, ErreurGeneration } from './generation.js';
* lancer(configuration, reglages) rend une Execution : avancement →
* surAvancement ; resultat → la promesse résolue aux propositions reçues,
* puis terminate() ; erreur {code, details} → rejet par
* ErreurGeneration(code, details) ; faute du travailleur, son canal
* d'erreur, → rejet par une Error sans code, qui remonte comme une faute ;
* ErreurGeneration(code, details) ; faute {message, pile} → rejet par
* une Error sans code, dont le message porte celui du travailleur et la
* pile la sienne : elle remonte comme une faute, comme sous l'exécuteur
* direct ; canal d'erreur du travailleur — son script ne se charge ou ne
* se compile pas —, → rejet par ErreurGeneration('TRAVAILLEUR_EN_ECHEC',
* { remede: null }), l'événement traité : aucun geste de la page ne
* relance un travailleur que le navigateur refuse, et le texte du refus
* nomme sa cause (§ 5.9) ;
* annuler() → terminate() aussitôt, rejet par ErreurAnnulee, plus aucun
* avancement. Après l'issue, annuler() ne fait rien.
*/
@ -69,11 +80,22 @@ export function creerExecuteurTravailleur(fabrique) {
} else if (data.type === 'erreur') {
rejeter(new ErreurGeneration(data.code, data.details));
terminer();
} else if (data.type === 'faute') {
const faute = new Error(`travailleur de génération : ${data.message}`);
faute.stack = `${faute.stack}\n${data.pile}`;
rejeter(faute);
terminer();
}
};
// Le script du travailleur ne se charge ou ne se compile pas : le refus
// TRAVAILLEUR_EN_ECHEC, que la commande « Générer » pose à côté
// d'elle, jamais le message du moteur d'exécution. L'événement est
// traité : le navigateur ne le signale pas en plus comme une faute de
// la page. Une faute du calcul arrive en message, jamais ici.
travailleur.onerror = (evenement) => {
evenement?.preventDefault?.();
if (termine) return;
rejeter(new Error(`travailleur de génération : ${evenement?.message ?? 'faute sans message'}`));
rejeter(new ErreurGeneration('TRAVAILLEUR_EN_ECHEC', { remede: null }));
terminer();
};
travailleur.postMessage({ type: 'lancer', configuration, reglages });

View file

@ -158,17 +158,51 @@ describe('exécuteur du travailleur (§ 5.10)', () => {
assert.equal(travailleur.terminaisons, 1, 'une seconde annulation ne termine rien de plus');
});
test('le canal d’erreur du travailleur : rejet par une erreur sans code, le travailleur terminé', async () => {
test('le canal d’erreur du travailleur — un script qui ne se charge ou ne se compile pas : rejet par ErreurGeneration TRAVAILLEUR_EN_ECHEC, remède null, sans le message ; l’événement est traité, le travailleur terminé', async () => {
const { fabrique, travailleurs } = fabriqueFactice();
const execution = creerExecuteurTravailleur(fabrique).lancer(CONFIGURATION, REGLAGES);
const [travailleur] = travailleurs;
assert.equal(typeof travailleur.onerror, 'function');
travailleur.onerror({ message: 'faute d’épreuve' });
const evenement = {
message: 'faute d’épreuve',
traite: false,
preventDefault() {
this.traite = true;
},
};
travailleur.onerror(evenement);
const lue = await issue(execution.resultat);
assert.equal(lue.etat, 'rejetee');
assert.ok(lue.erreur instanceof Error);
assert.ok(lue.erreur instanceof ErreurGeneration, String(lue.erreur));
assert.equal(lue.erreur.code, 'TRAVAILLEUR_EN_ECHEC');
assert.deepEqual(lue.erreur.details, { remede: null });
assert.doesNotMatch(JSON.stringify(lue.erreur.details), /épreuve/);
assert.equal(evenement.traite, true);
assert.equal(travailleur.terminaisons, 1);
// Un événement d'erreur sans preventDefault, comme le rendrait une portée factice, ne lève pas.
const seconde = creerExecuteurTravailleur(fabrique).lancer(CONFIGURATION, REGLAGES);
travailleurs[1].onerror({});
assert.equal((await issue(seconde.resultat)).erreur.code, 'TRAVAILLEUR_EN_ECHEC');
});
test('une faute pendant le calcul, postée { type: faute, message, pile } : rejet par une Error sans code, qui porte le message et la pile du travailleur ; aucun refus, le travailleur terminé, la suite ignorée', async () => {
const { fabrique, travailleurs } = fabriqueFactice();
const execution = creerExecuteurTravailleur(fabrique).lancer(CONFIGURATION, REGLAGES);
const avancements = [];
execution.surAvancement((fait, total) => avancements.push([fait, total]));
const [travailleur] = travailleurs;
travailleur.poster({ type: 'faute', message: 'TypeError: invariant d’épreuve rompu', pile: 'TypeError: invariant d’épreuve rompu\n at calcul (épreuve.js:1:1)' });
travailleur.poster({ type: 'avancement', fait: 1, total: 2 });
travailleur.poster({ type: 'resultat', propositions: [] });
const lue = await issue(execution.resultat);
assert.equal(lue.etat, 'rejetee');
assert.ok(lue.erreur instanceof Error, String(lue.erreur));
assert.ok(!(lue.erreur instanceof ErreurGeneration), 'une faute n’est pas un refus');
assert.ok(!(lue.erreur instanceof ErreurAnnulee));
assert.equal(lue.erreur.code, undefined);
assert.match(lue.erreur.message, /faute d’épreuve/);
assert.match(lue.erreur.message, /TypeError: invariant d’épreuve rompu/);
assert.match(lue.erreur.stack, /at calcul \(épreuve\.js:1:1\)/);
assert.deepEqual(avancements, []);
assert.equal(travailleur.terminaisons, 1);
});
});

View file

@ -16,12 +16,17 @@
// versErreurGeneration, le code et
// les détails d'une
// ErreurConfiguration
// ← { type: 'faute', message, pile } une erreur que versErreurGeneration
// ne convertit pas : une faute de
// programmation, son texte et sa
// pile, que l'exécuteur relève
// comme une faute
//
// L'annulation est terminate() du côté de l'interface : immédiate, sans
// résultat partiel ; le travailleur ne lit aucun signal. Un message d'un
// autre type ne lance rien. Une erreur que versErreurGeneration ne convertit
// pas est relevée, et le travailleur la signale par son propre canal
// d'erreur.
// autre type ne lance rien. Le gestionnaire ne lève jamais : le canal
// d'erreur du travailleur ne porte ainsi que l'échec de son script — qui ne
// se charge ou ne se compile pas —, et l'exécuteur ne confond pas les deux.
import { rechercher } from '../moteur/recherche.js';
import { ErreurGeneration, exigerReglages, versErreurGeneration } from './generation.js';
@ -42,16 +47,19 @@ export function brancherTravailleur(portee) {
progressions += 1;
if (progressions % CADENCE_AVANCEMENT === 0) portee.postMessage({ type: 'avancement', fait, total });
};
let propositions;
try {
exigerReglages(data.reglages);
propositions = rechercher(data.configuration, data.reglages, { progression });
const propositions = rechercher(data.configuration, data.reglages, { progression });
portee.postMessage({ type: 'resultat', propositions });
} catch (erreur) {
const convertie = versErreurGeneration(erreur);
if (!(convertie instanceof ErreurGeneration)) throw convertie;
portee.postMessage({ type: 'erreur', code: convertie.code, details: convertie.details });
return;
if (convertie instanceof ErreurGeneration) {
portee.postMessage({ type: 'erreur', code: convertie.code, details: convertie.details });
} else {
const message = String(convertie);
const pile = typeof convertie?.stack === 'string' ? convertie.stack : message;
portee.postMessage({ type: 'faute', message, pile });
}
}
portee.postMessage({ type: 'resultat', propositions });
};
}

View file

@ -57,6 +57,18 @@ describe('travailleur de génération (§ 5.10)', () => {
assert.deepEqual(postes, [{ type: 'erreur', code: 'REGLAGE_HORS_DOMAINE', details: { reglage: 'graine', remede: null } }]);
});
test('une faute que versErreurGeneration ne convertit pas poste { type: faute, message, pile }, sans lever : le canal d’erreur du travailleur reste au script qui ne se charge pas', () => {
const { postes, envoyer } = porteeFactice();
assert.doesNotThrow(() => envoyer({ type: 'lancer', configuration: null, reglages: { graine: 1, nombre: 1, arret: 10 } }));
assert.equal(postes.length, 1);
const [faute] = postes;
assert.deepEqual(Object.keys(faute).sort(), ['message', 'pile', 'type']);
assert.equal(faute.type, 'faute');
assert.match(faute.message, /^TypeError: /);
assert.equal(typeof faute.pile, 'string');
assert.match(faute.pile, /TypeError/);
});
test("un message d'un autre type ne lance rien", () => {
const { postes, envoyer } = porteeFactice();
envoyer({ type: 'inconnu' });

View file

@ -36,9 +36,11 @@ import { CODES, TABLES_APPLICATION, libelle } from './libelles.js';
const RACINE = fileURLToPath(new URL('../..', import.meta.url));
// Les arbres balayés, et les fichiers qui portent une table de codes.
// Les arbres balayés, et les fichiers qui portent une table de codes : le
// dépôt du plan lève un refus que la commande n'a pas à lever, et son texte
// vit ici, avec ceux des commandes des places.
const ARBRES = ['src/stockage', 'src/csv', 'src/application'];
const TABLES = ['src/stockage/types.js', 'src/csv/erreurs.js'];
const TABLES = ['src/stockage/types.js', 'src/csv/erreurs.js', 'src/interface/plan/depot.js'];
// L'assemblage et les tables des textes n'émettent rien : leurs clés sont ce
// qu'on éprouve, et un texte qui en compose un autre le nomme sans l'émettre.
const TABLE_DES_TEXTES = join('src', 'application', 'libelles.js');
@ -411,6 +413,7 @@ const EXEMPLES = new Map([
['DEPASSEMENT', { index: 4, participant: 9 }],
// Génération
['GENERATION_EN_COURS', { remede: null }],
['TRAVAILLEUR_EN_ECHEC', { remede: null }],
['AUCUN_PARTICIPANT', { remede: { geste: 'ouvrirSection', section: 'participants' } }],
['AUCUNE_TABLE', { remede: { geste: 'ouvrirSection', section: 'tables' } }],
[
@ -493,6 +496,7 @@ const EXEMPLES = new Map([
['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 }],
['RESERVATION_TOUS_LES_TOURS', { participant: 1, table: 3, 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 }],
@ -507,6 +511,8 @@ const EXEMPLES = new Map([
['NON_ENTIER', { saisie: '8abc', remede: null }],
['MOINS_DE_DEUX', { remede: null }],
['MOINS_D_UN_TOUR', { tours: 0, remede: null }],
['TROP_DE_PLACES', { max: 1000, remede: null }],
['TROP_DE_TOURS', { max: 100, remede: null }],
['SOUS_RESERVATIONS', { table: 2, n: 7, tour: 1, remede: { geste: 'afficherTour', tour: 1, proposition: null } }],
['SOUS_OCCUPES', { table: 2, n: 7, tour: 3, remede: { geste: 'afficherTour', tour: 3, proposition: null } }],
[
@ -604,6 +610,42 @@ describe('libellés : les textes (§ 14.6, § 8.2)', () => {
);
});
test('RESERVATION_NON_HONOREE au siège nomme le siège et ce qu’il porte', () => {
assert.equal(
libelle('RESERVATION_NON_HONOREE', { participant: 4, table: 5, tour: 1, personne: 'Iris Ombrelle', numero: 3, siege: 2, occupant: 7 }),
"Le placement retenu n'honore pas la réservation de Iris Ombrelle au siège 2 de la table 3 au tour 1 : " +
"il l'assied à un autre siège de cette table, et donne le siège 2 à une autre personne.",
);
assert.equal(
libelle('RESERVATION_NON_HONOREE', { participant: 4, table: 5, tour: 1, siege: 2, occupant: null }),
"Le placement retenu n'honore pas la réservation de la personne n° 4 au siège 2 de la table d'identifiant 5 au tour 1 : " +
"il l'assied à un autre siège de cette table, et laisse le siège 2 vide.",
);
});
test('une raison de dérive nomme la personne et la table quand ses détails les portent, sinon leurs identifiants', () => {
const nommees = [
['PARTICIPANT_EXCLU', { participant: 14, personne: 'Iris Ombrelle' }, 'Iris Ombrelle est exclue depuis.'],
['PARTICIPANT_SUPPRIME', { participant: 14, personne: 'Iris Ombrelle' }, 'Iris Ombrelle a été supprimée depuis.'],
[
'PARTICIPANT_NON_PLACE',
{ participant: 14, personne: 'Iris Ombrelle' },
"Iris Ombrelle, ajoutée ou réintégrée depuis, n'y est pas placée.",
],
['TABLE_SUPPRIMEE', { table: 9, numero: 3 }, 'La table 3 a été supprimée depuis.'],
['TABLE_AJOUTEE', { table: 9, numero: 3 }, 'La table 3 a été ajoutée depuis.'],
['CAPACITE_CHANGEE', { table: 9, numero: 3, avant: 8, maintenant: 6 }, 'La table 3 est passée de 8 à 6 places.'],
];
assert.equal(nommees.length, 6);
for (const [code, details, attendu] of nommees) {
assert.equal(libelle(code, details), attendu, code);
const { personne, numero, ...nus } = details;
assert.doesNotMatch(libelle(code, nus), /Iris|table 3/, code);
assert.match(libelle(code, nus), personne === undefined ? /identifiant 9/ : /n° 14/, code);
void numero;
}
});
test('le séparateur introuvable nomme la ligne et la cause ; le guillemet ouvert tombe dès la ligne 2', () => {
const nombre = libelle('SEPARATEUR_INTROUVABLE', { separateur: ';', ligne: 3, cause: 'NOMBRE_DE_CHAMPS' });
assert.match(nombre, /la ligne 3 n'a pas le nombre de champs de l'en-tête/);
@ -899,6 +941,7 @@ describe('libellés : les codes du moteur et de la génération (§ 14.6, § 5.7
['MODE_LECTURE', 'seance'],
['SUPPORT_ROMPU', 'seance'],
['GENERATION_EN_COURS', 'generation'],
['TRAVAILLEUR_EN_ECHEC', 'generation'],
['AUCUN_PARTICIPANT', 'generation'],
['AUCUNE_TABLE', 'generation'],
['SURRESERVATION', 'moteur'],
@ -908,7 +951,7 @@ describe('libellés : les codes du moteur et de la génération (§ 14.6, § 5.7
['GENERATION_INVARIANT_VIOLE', 'generation'],
['COMPTEUR_SATURE', 'stockage'],
];
assert.equal(REFUS.length, 15);
assert.equal(REFUS.length, 16);
for (const [code, table] of REFUS) {
assert.ok(parTable.get(table).has(code), `${code} hors de ${table}.js`);
assert.ok(libelle(code, EXEMPLES.get(code)).length > 0, code);

View file

@ -62,6 +62,14 @@ export const TABLE = new Map([
() => "Aucune personne à placer : la liste des participants est vide, ou chacun en est exclu.",
],
['AUCUNE_TABLE', () => "Aucune table : la génération place les personnes autour des tables de l'événement."],
// Le travailleur de génération n'a pas démarré, ou s'est arrêté sans
// résultat : rien n'est enregistré, et aucun geste de la page ne le relance.
[
'TRAVAILLEUR_EN_ECHEC',
() =>
"Le calcul hors de la page n'a pas pu se faire : le navigateur n'a pas démarré le travailleur de génération, " +
"ou l'a arrêté sans résultat. Rien n'est enregistré ; relancer le logiciel le recharge.",
],
[
'CONFIRMATION_REQUISE',
({ estimation }, texte) =>

View file

@ -41,6 +41,8 @@ export const TABLE = new Map([
() => "Une table compte au moins 2 places : seule à sa table, une personne ne rencontrerait personne.",
],
['MOINS_D_UN_TOUR', () => 'Une soirée compte au moins un tour.'],
['TROP_DE_PLACES', ({ max }) => `Une table compte au plus ${max} places : le plan ne dessinerait pas plus de chaises.`],
['TROP_DE_TOURS', ({ max }) => `Une soirée compte au plus ${max} tours.`],
// --- Les refus d'une capacité (§ 6.2, § 7.1) -------------------------------
[

View file

@ -74,6 +74,12 @@ export const TABLE = new Map([
({ 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.`],
[
'RESERVATION_TOUS_LES_TOURS',
() =>
'Cette personne est réservée ici pour tous les tours : la déposer hors des tables ne retire pas sa réservation. ' +
'« Retirer la réservation » la retire de tous les tours.',
],
// --- Les libellés figés du journal (§ 8.2) ---------------------------------
[

View file

@ -117,11 +117,22 @@ export const TABLE = new Map([
// La personne se nomme par personne, et la table par numero, quand les
// détails les portent (avertissementsApres les lit sur la charge) ; une
// violation d'INVARIANT_VIOLE, qui ne les porte pas, garde les identifiants.
// Au siège, siege et occupant disent la chaise réservée et ce qu'elle
// porte : la personne est assise à la même table, à un autre siège.
[
'RESERVATION_NON_HONOREE',
({ participant, table, tour, personne = null, numero = null }) =>
`Le placement retenu n'honore pas la réservation de ${personne ?? `la personne n° ${participant}`} ` +
`à la ${numero === null ? `table d'identifiant ${table}` : `table ${numero}`} au tour ${tour} : il la place ailleurs.`,
({ participant, table, tour, personne = null, numero = null, siege, occupant = null }) => {
const qui = personne ?? `la personne n° ${participant}`;
const ou = numero === null ? `table d'identifiant ${table}` : `table ${numero}`;
if (siege === undefined) {
return `Le placement retenu n'honore pas la réservation de ${qui} à la ${ou} au tour ${tour} : il la place ailleurs.`;
}
const chaise = occupant === null ? `laisse le siège ${siege} vide` : `donne le siège ${siege} à une autre personne`;
return (
`Le placement retenu n'honore pas la réservation de ${qui} au siège ${siege} de la ${ou} au tour ${tour} : ` +
`il l'assied à un autre siège de cette table, et ${chaise}.`
);
},
],
[
'INVARIANT_VIOLE',

View file

@ -48,6 +48,11 @@ function touchees({ ids, retenu }) {
return { texte: capitale(enumeration(parties)), pluriel: (retenu ? 1 : 0) + ids.length > 1 };
}
// La personne d'une raison de dérive, par son nom affiché quand les détails
// le portent ; la table, par son numéro de même.
const laPersonne = ({ participant, personne = null }) => personne ?? `La personne n° ${participant}`;
const laTable = ({ table, numero = null }) => (numero === null ? `La table d'identifiant ${table}` : `La table ${numero}`);
export const TABLE = new Map([
// --- Le système de fichiers et le dépôt (src/stockage/types.js) ------------
[
@ -175,18 +180,17 @@ export const TABLE = new Map([
['MANQUANT', ({ tour, participant }) => `Au tour ${tour}, la personne n° ${participant} n'est placée nulle part.`],
['IDENTIFIANT_REPETE', () => 'Une autre proposition porte déjà ce numéro.'],
['IDENTIFIANT_HORS_COMPTEUR', () => 'Ce numéro de proposition dépasse le compteur du fichier.'],
['PARTICIPANT_EXCLU', ({ participant }) => `La personne n° ${participant} est exclue depuis.`],
['PARTICIPANT_SUPPRIME', ({ participant }) => `La personne n° ${participant} a été supprimée depuis.`],
[
'PARTICIPANT_NON_PLACE',
({ participant }) => `La personne n° ${participant}, ajoutée ou réintégrée depuis, n'y est pas placée.`,
],
['TABLE_SUPPRIMEE', ({ table }) => `La table d'identifiant ${table} a été supprimée depuis.`],
['TABLE_AJOUTEE', ({ table }) => `La table d'identifiant ${table} a été ajoutée depuis.`],
// Une raison de dérive nomme la personne par personne et la table par
// numero quand ses détails les portent (raisonsNommees, de l'application) ;
// sinon par leur identifiant.
['PARTICIPANT_EXCLU', (details) => `${laPersonne(details)} est exclue depuis.`],
['PARTICIPANT_SUPPRIME', (details) => `${laPersonne(details)} a été supprimée depuis.`],
['PARTICIPANT_NON_PLACE', (details) => `${laPersonne(details)}, ajoutée ou réintégrée depuis, n'y est pas placée.`],
['TABLE_SUPPRIMEE', (details) => `${laTable(details)} a été supprimée depuis.`],
['TABLE_AJOUTEE', (details) => `${laTable(details)} a été ajoutée depuis.`],
[
'CAPACITE_CHANGEE',
({ table, avant, maintenant }) =>
`La table d'identifiant ${table} est passée de ${avant} à ${compte(maintenant, 'place', 'places')}.`,
({ avant, maintenant, ...details }) => `${laTable(details)} est passée de ${avant} à ${compte(maintenant, 'place', 'places')}.`,
],
['TOURS_CHANGES', ({ avant, maintenant }) => `Le nombre de tours est passé de ${avant} à ${maintenant}.`],

View file

@ -138,13 +138,20 @@ function planEvalue(charge, instance, placement) {
}
}
// 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) {
/**
* Les places que les réservations actives couvrantes d'une table tiennent,
* la règle de l'affichage : 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 ; siegeDe, Map personne → son siège tenu ; 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.
*
* @param {Object[]} couvrantes les réservations actives qui couvrent le tour à cette table, ordre canonique
* @param {number} places la capacité courante de la table
* @returns {{tenu: Map<number, Object>, siegeDe: Map<number, number>, sansSiege: Map<number, Object>}}
*/
export function placesTenues(couvrantes, places) {
const tenu = new Map();
const siegeDe = new Map();
const sansSiege = new Map();
@ -163,13 +170,17 @@ function placesTenues(couvrantes, places) {
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) {
// Le remplissage et les marques d'une chaise (§ 7.6) : une réservation
// porte le cadenas fermé, l'anneau en plus pour la portée « tous les
// tours » ; la chaise est pleine quand le placement la tient, hachurée — à
// pourvoir — quand il la laisse vide ou y assied une autre personne. Le
// cadenas ouvert reste au titre seul, hachuré quand rien ne le pourvoit à ce
// tour : la forme distingue toujours une réservation d'un titre.
function aspect(reservation, occupant, titre) {
if (reservation !== null) {
return { remplissage: 'plein', marques: reservation.portee === 'tous' ? ['cadenasFerme', 'anneau'] : ['cadenasFerme'] };
const anneau = reservation.portee === 'tous' ? ['anneau'] : [];
const remplissage = reservation.participant === occupant ? 'plein' : 'hachure';
return { remplissage, marques: ['cadenasFerme', ...anneau] };
}
if (titre !== null) return { remplissage: titre.pourvu ? 'normal' : 'hachure', marques: ['cadenasOuvert'] };
return { remplissage: 'normal', marques: [] };
@ -184,7 +195,11 @@ function aspect(reservation, titre) {
* 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,
* réservées à la table passent avant les autres. Sièges attribués, la
* source se lit telle quelle : la réservation qui tient un siège que la
* liste donne à une autre personne, ou laisse vide, se lit sur la chaise
* sans la remplir — hachure et cadenas fermé —, et sa personne est là où
* la liste l'assied. 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
@ -271,7 +286,7 @@ export function etatPlaces(charge, { tour, proposition = 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 { siege, occupant, reservation, titre, ...aspect(reservation, occupant, titre) };
});
return {

View file

@ -85,8 +85,10 @@ const PARFAIT = [
[[4, 7, 12], [2, 8, 10], [3, 5, 11], [1, 6, 9]],
];
const DOUZE = Array.from({ length: 12 }, (_, rang) => rang + 1);
// Chaque appel rend des listes neuves : une épreuve qui modifie son plan ne
// touche pas PARFAIT.
const planParfait = (siegesAttribues = false) =>
planFichier({ tables: [1, 2, 3, 4], capacites: [3, 3, 3, 3], participants: DOUZE, tours: PARFAIT, siegesAttribues });
planFichier({ tables: [1, 2, 3, 4], capacites: [3, 3, 3, 3], participants: DOUZE, tours: structuredClone(PARFAIT), siegesAttribues });
// La petite démonstration, son plan parfait proposé sous l'identifiant 1, et
// retenu quand retenu est vrai.
@ -460,6 +462,58 @@ describe('etatPlaces : titres, réservations et marques (§ 4.1, § 4.4, § 7.6)
});
});
describe('etatPlaces : la réservation au siège que le placement ne tient pas (§ 5.8, § 7.6, § 14.12)', () => {
// La petite démonstration, son plan parfait retenu sous des sièges
// attribués : au tour 1, la table 1 assied 1, 2, 3 aux sièges 1 à 3.
function auxSieges() {
const charge = petite();
charge.reglages.attribuerSieges = true;
charge.propositions = [propositionDe(1, planParfait(true))];
charge.prochainsIds.proposition = 2;
charge.retenu = retenuDe(1, planParfait(true));
charge.evenement.etat = 'retenu';
return charge;
}
test("la chaise réservée à 2, qu'occupe 3 : la réservation se lit, mais ne la tient pas — hachure et cadenas fermé, l'anneau de sa portée ; la chaise de 2 reste sans marque", () => {
const charge = auxSieges();
charge.reservations.push(resa(2, 1, 3));
charge.titres.push({ table: 1, siege: 3, libelle: 'Animation' });
const [, deux, trois] = etatPlaces(charge, { tour: 1 }).tables[0].chaises;
assert.equal(trois.occupant, 3);
assert.deepEqual(trois.reservation, { participant: 2, portee: 'tous', tour: null });
assert.deepEqual([trois.remplissage, trois.marques], ['hachure', ['cadenasFerme', 'anneau']]);
assert.deepEqual([deux.occupant, deux.reservation, deux.remplissage, deux.marques], [2, null, 'normal', []]);
});
test('vide, la chaise réservée ne se tient pas non plus ; tenue par sa personne, elle est pleine', () => {
const vide = auxSieges();
vide.retenu.placement[0].sieges[0] = [1, 2];
vide.retenu.placement[0].reserve = [3];
vide.reservations.push(resa(2, 1, 3, 1));
const chaise = etatPlaces(vide, { tour: 1 }).tables[0].chaises[2];
assert.deepEqual([chaise.occupant, chaise.reservation?.participant, chaise.remplissage, chaise.marques], [null, 2, 'hachure', ['cadenasFerme']]);
const tenue = auxSieges();
tenue.reservations.push(resa(3, 1, 3));
const pleine = etatPlaces(tenue, { tour: 1 }).tables[0].chaises[2];
assert.deepEqual([pleine.occupant, pleine.remplissage, pleine.marques], [3, 'plein', ['cadenasFerme', 'anneau']]);
});
test("une réservation d'un tour que la chaise vide ne tient pas, et un titre que rien ne pourvoit : deux formes, cadenas fermé d'un côté, ouvert de l'autre (§ 7.6)", () => {
const charge = auxSieges();
charge.retenu.placement[0].sieges[0] = [1, 2];
charge.retenu.placement[0].reserve = [3];
charge.reservations.push(resa(2, 1, 3, 1));
charge.titres.push({ table: 2, siege: 1, libelle: 'Accueil' });
const etat = etatPlaces(charge, { tour: 1 });
const reservee = etat.tables[0].chaises[2];
const titree = etat.tables[1].chaises[0];
assert.deepEqual([reservee.reservation?.participant, reservee.titre, reservee.remplissage, reservee.marques], [2, null, 'hachure', ['cadenasFerme']]);
assert.deepEqual([titree.reservation, titree.titre, titree.remplissage, titree.marques], [null, { libelle: 'Accueil', pourvu: false }, 'hachure', ['cadenasOuvert']]);
});
});
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 });

View file

@ -105,6 +105,8 @@ const REMEDE_NUL_ADMIS = new Map([
['MOINS_DE_DEUX', 'la valeur se corrige dans le champ même, dont le texte dit la valeur attendue'],
['NON_ENTIER', 'la valeur se corrige dans le champ même, dont le texte dit la valeur attendue'],
['MOINS_D_UN_TOUR', 'la valeur se corrige dans le champ même, dont le texte dit la valeur attendue'],
['TROP_DE_PLACES', 'la valeur se corrige dans le champ même, dont le texte dit la valeur attendue'],
['TROP_DE_TOURS', 'la valeur se corrige dans le champ même, dont le texte dit la valeur attendue'],
['LIBELLE_VIDE', 'la valeur se corrige dans le champ même, dont le texte dit la valeur attendue'],
['NOM_REQUIS', 'le nom se saisit dans le champ même'],
['PARTICIPANT_INCONNU', "une personne retirée entre-temps : aucun geste ne la rend"],
@ -893,24 +895,41 @@ describe('registre : un exemple sans effet est refusé SANS_EFFET {commande}, sa
// --- Le vérificateur d'invariants -------------------------------------------------
describe('registre : le vérificateur d’invariants juge les retouches et l’ouverture (§ 14.12, § 14.15)', () => {
test("une retouche qui assiérait à un tour plus de personnes qu'une table n'a de places est refusée, sans écriture", async () => {
test("une retouche que seul le vérificateur refuse : retirer du tour la personne que sa réservation tient à sa table lève INVARIANT_VIOLE, les seules violations nouvelles, sans écriture", async () => {
const ouvert = await ouvrirCopie((await departsPrets()).temoin, { ecriture: true });
const { fs, seance } = ouvert;
const { charge } = seance;
// La personne 1, désignée « tous les tours » à la table 1 : le retenu
// l'y assied, et le retrait du tour n'a aucun contrôle de pose.
assert.ok(charge.reservations.some(({ participant, table, portee }) => participant === 1 && table === 1 && portee === 'tous'));
assert.ok(charge.retenu.placement[0].sieges[charge.retenu.tables.indexOf(1)].includes(1));
const ecritures = fs.compteurs.ecritures;
const erreur = await refusDe(seance.executer('retirerDuTour', { tour: 1, participant: 1 }));
assert.notEqual(erreur, null, 'accepté');
assert.equal(erreur.code, 'INVARIANT_VIOLE', erreur.message);
assert.equal(erreur.details.proposition, charge.retenu.proposition);
const retire = structuredClone(charge);
const etage = retire.retenu.placement[0];
etage.sieges = etage.sieges.map((liste) => liste.filter((id) => id !== 1));
etage.reserve = [...etage.reserve, 1].sort(croissant);
assert.deepEqual(erreur.details.violations, violationsNouvelles(charge, retire));
assert.deepEqual(erreur.details.violations, [{ code: 'RESERVATION', participant: 1, table: 1, tour: 1 }]);
assert.equal(fs.compteurs.ecritures, ecritures);
});
test("le dépôt sur une table pleine se refuse au contrôle de pose de la retouche, TABLE_PLEINE et son remède, avant le vérificateur, sans écriture", async () => {
const ouvert = await ouvrirCopie((await departsPrets()).temoin, { ecriture: true });
const { fs, seance } = ouvert;
const { charge } = seance;
const personne = libresA(charge, 1, 2)[0];
const rang = charge.retenu.tables.indexOf(3);
assert.equal(charge.retenu.placement[0].sieges[rang].length, tableDe(charge, 3).sieges ?? charge.evenement.siegesParDefaut);
const places = tableDe(charge, 3).sieges ?? charge.evenement.siegesParDefaut;
assert.equal(charge.retenu.placement[0].sieges[rang].length, places);
const ecritures = fs.compteurs.ecritures;
const erreur = await refusDe(seance.executer('deplacerDansRetenu', { tour: 1, participant: personne, table: 3, siege: null }));
assert.notEqual(erreur, null, 'accepté');
// Un refus du contrôle de pose de la retouche, ou du vérificateur ; une
// faute du code ou du stockage n'en est pas un.
assert.ok(['TABLE_PLEINE', 'INVARIANT_VIOLE'].includes(erreur.code), erreur.message);
assert.ok(Object.hasOwn(erreur.details, 'remede'), erreur.message);
if (erreur.code === 'INVARIANT_VIOLE') {
assert.equal(erreur.details.proposition, charge.retenu.proposition);
assert.ok(erreur.details.violations.length > 0);
}
assert.equal(erreur.code, 'TABLE_PLEINE', erreur.message);
assert.deepEqual(erreur.details.remede, { commande: 'changerPlacesTable', arguments: { id: 3, sieges: places + 1, origine: 'champ' } });
assert.equal(fs.compteurs.ecritures, ecritures);
});

View file

@ -90,7 +90,7 @@ import { ErreurStockage } from '../stockage/erreurs.js';
import { fil, reconstruire } from '../stockage/journal.js';
import { SUFFIXES } from '../stockage/noms.js';
import { avecCadrage, cadrageDe, creerEcrivainDiffere, ecrireReglages, lireReglages } from '../stockage/reglages_locaux.js';
import { avertissementsApres, invariantViole } from './avertissements.js';
import { avertissementsApres, examenNomme, invariantViole } from './avertissements.js';
import { appliquerCommande, chargeNeuve, formeNommeeDe } from './commandes.js';
import { libelle } from './libelles.js';
@ -245,13 +245,14 @@ export function creerSeance({
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.
// Ouvert ne change jamais, ses copies se font une fois. L'examen de l'état
// nomme ses raisons de dérive sur la charge (examenNomme).
const parOuvert = new WeakMap();
function figesDe(ouvert) {
let figes = parOuvert.get(ouvert);
if (figes === undefined) {
const copie = structuredClone({ charge: ouvert.charge, examen: ouvert.examen });
const copie = structuredClone({ charge: ouvert.charge, examen: examenNomme(ouvert.examen, ouvert.charge) });
figes = { ...figer(copie), invariant: figer(invariantViole(ouvert.charge, ouvert.examen)) };
parOuvert.set(ouvert, figes);
}
@ -1245,6 +1246,13 @@ export function creerSeance({
* lecture, admise dans tout mode, qui n'écrit rien.
*/
formeNommee: (cible) => enFile(async () => formeNommeeDe(exigerOuvert().charge, cible)),
/**
* Une promesse résolue quand les appels déjà en file ont fini, réussis
* ou refusés : ce qu'un lecteur de etat() lit ensuite porte leurs
* écritures. Rien ne s'écrit ni ne se publie, et rien ne s'ajoute à la
* file ; elle ne rejette jamais.
*/
attendre: () => file,
// Les gestes et les calculs ouverts suspendent le compte d'inactivité ;
// ils s'emboîtent, et un fini de trop ne compte pas.

View file

@ -745,7 +745,7 @@ describe('séance : un geste, une entrée, un état (§ 2.1, § 8.2, § 10.1)',
await seance.executer('retenirProposition', { id: 1 });
const plus = apercevoir('nom\nEpicéa\n', { participants: seance.charge.participants });
const enRetenu = await seance.executer('importerParticipants', { apercu: plus, mode: 'ajouter' });
const nonPlace = [{ code: 'PARTICIPANT_NON_PLACE', participant: 8 }];
const nonPlace = [{ code: 'PARTICIPANT_NON_PLACE', participant: 8, personne: 'Epicéa' }];
assert.deepEqual(enRetenu.avertissements, [
{ code: 'IMPORT_EN_RETENU', details: {} },
{ code: 'PLACES_MANQUANTES', details: { placesManquantes: 1 } },
@ -2455,14 +2455,14 @@ describe("séance observable : les avertissements d'un geste (§ 5.9, § 9, A2)"
await seance.executer('enregistrerGeneration', generer(seance.charge));
await seance.executer('retenirProposition', { id: 1 });
const exclusion = await seance.executer('exclureParticipant', { id: 3 });
const raisons = [{ code: 'PARTICIPANT_EXCLU', participant: 3 }];
const raisons = [{ code: 'PARTICIPANT_EXCLU', participant: 3, personne: 'Théo Pervenche' }];
assert.deepEqual(exclusion.avertissements, [
{ code: 'DERIVE_NOUVELLE', details: { touchees: [{ id: 1, raisons }, { id: 2, raisons }, { id: 'retenu', raisons }] } },
]);
const suivant = await seance.executer('modifierParticipant', { id: 4, champs: { nom: 'Lacasse-Ombrelle' } });
assert.deepEqual(suivant.avertissements, []);
const autre = await seance.executer('exclureParticipant', { id: 2 });
const nouvelle = [{ code: 'PARTICIPANT_EXCLU', participant: 2 }];
const nouvelle = [{ code: 'PARTICIPANT_EXCLU', participant: 2, personne: 'Grisaille' }];
assert.deepEqual(autre.avertissements, [
{ code: 'DERIVE_NOUVELLE', details: { touchees: [{ id: 1, raisons: nouvelle }, { id: 2, raisons: nouvelle }, { id: 'retenu', raisons: nouvelle }] } },
]);
@ -2734,6 +2734,51 @@ describe('séance observable : fautives et examen (§ 8.9, A2)', () => {
});
});
describe('séance : attendre la file (§ 5.10, § 8.4)', () => {
test("attendre se résout quand les appels déjà en file ont fini, réussis ou refusés, sans rien écrire ni publier de plus", async () => {
const { seance, fs } = await ouvertSur();
await seance.passerEnEcriture();
const ecrit = seance.executer('ajouterParticipant', { champs: { nom: 'Ancolie' } });
const refuse = seance.executer('ajouterParticipant', { champs: { nom: '' } }).catch((erreur) => erreur);
const avant = seance.charge.participants.length;
const ecritures = fs.compteurs.ecritures;
let publications = 0;
seance.subscribe(() => {
publications += 1;
});
publications = 0;
await seance.attendre();
assert.equal(seance.charge.participants.length, avant + 1);
assert.equal((await refuse).code !== undefined, true);
await ecrit;
const apres = publications;
const ecrituresApres = fs.compteurs.ecritures;
await seance.attendre();
assert.equal(publications, apres, 'attendre ne publie rien');
assert.equal(fs.compteurs.ecritures, ecrituresApres, 'attendre n’écrit rien');
assert.ok(ecrituresApres > ecritures);
});
});
describe('séance observable : la dérive nommée (§ 9, A2)', () => {
test("l'examen de l'état nomme chaque raison de dérive — la personne par son nom, la table par son numéro —, quand celui de l'Ouvert garde les identifiants", async () => {
const charge = avecRetenu(chargeEssai());
charge.participants[2].exclu = true;
charge.tables[1].numero = 7;
charge.tables[1].sieges = 3;
const { seance } = await ouvertSur(charge);
const { raisons } = seance.etat().examen.retenu;
assert.deepEqual(raisons, [
{ code: 'CAPACITE_CHANGEE', table: 2, avant: 2, maintenant: 3, numero: 7 },
{ code: 'PARTICIPANT_EXCLU', participant: 3, personne: 'Théo Pervenche' },
]);
assert.deepEqual(
seance.ouvert.examen.retenu.raisons,
raisons.map(({ personne, numero, ...nue }) => nue),
);
});
});
describe('séance observable : les invariants du retenu (§ 14.12, § 14.15, A2)', () => {
test("un retenu qui laisse une réservation d'un tour non honorée s'ouvre avec INVARIANT_VIOLE, sans les violations que la dérive nomme, sans écriture ; de même après défaire, refaire et revenir", async () => {
const fs = creerFichiersSimules();

View file

@ -38,10 +38,13 @@ export function regimeCibles(k, geometrie) {
* de la peinture : la première dont la surface peinte contient le point
* décide. En régime table, la surface est l'emprise — disque d'une ronde,
* carré d'une carrée — et la table est désignée. En régime sièges, elle est
* le plateau et les chaises : un point dans une chaise désigne son siège ;
* un point sur le plateau ne désigne rien, et cache les chaises des tables
* peintes dessous ; entre les chaises, le point passe aux tables de dessous.
* Deux chaises d'une même table ne se recouvrent pas.
* le plateau et les cibles des sièges : un point à rayonCible au plus du
* centre d'une chaise — max(RAYON_CHAISE, pas / 2), la cible a la taille du
* pas — désigne son siège ; un point sur le plateau hors de toute cible ne
* désigne rien, et cache les chaises des tables peintes dessous ; hors des
* cibles et du plateau, le point passe aux tables de dessous. Deux cibles
* d'une même table se touchent au plus : sur leur bord commun, le plus petit
* siège l'emporte.
*
* @param {{x: number, y: number}} point cm
* @param {import('./tables.js').GeometrieTable[]} geometries dans l'ordre du dessin
@ -55,7 +58,7 @@ export function designer(point, geometries, k) {
if (dans(point, geometrie, geometrie.rayonEmprise)) return { table: geometrie.id };
continue;
}
const chaise = geometrie.chaises.find((c) => Math.hypot(point.x - c.x, point.y - c.y) <= c.rayon);
const chaise = geometrie.chaises.find((c) => Math.hypot(point.x - c.x, point.y - c.y) <= geometrie.rayonCible);
if (chaise !== undefined) return { table: geometrie.id, siege: chaise.siege };
if (dans(point, geometrie, geometrie.demiTaille)) return null;
}

View file

@ -17,6 +17,33 @@ const table = (id, forme, x, y) => ({ id, numero: id, sieges: null, forme, posit
const milieu = (a, b) => ({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 });
// Écart sous le bord d'une cible, en cm.
const EPS = 0.01;
// Un point entre deux chaises voisines d'une table : leur milieu, éloigné de
// 4 cm de la droite qui les joint, du côté opposé au centre de la table. Sur
// un côté de carrée à sièges espacés du pas, le milieu est au bord des deux
// cibles ; éloigné, il est hors des deux et reste dans l'emprise. Quand le
// milieu est le centre — deux chaises opposées, de part et d'autre d'un côté
// sans siège —, le point est sur ce côté, à la distance de la première
// chaise plus 4 cm, du côté où tourne la première dans le sens horaire de
// l'écran.
function entre(g, a, b) {
const m = milieu(a, b);
const longueur = Math.hypot(b.x - a.x, b.y - a.y);
let nx = -(b.y - a.y) / longueur;
let ny = (b.x - a.x) / longueur;
const vers = { x: m.x - g.centre.x, y: m.y - g.centre.y };
const degenere = Math.hypot(vers.x, vers.y) < 1e-6;
const reference = degenere ? { x: -(a.y - g.centre.y), y: a.x - g.centre.x } : vers;
if (nx * reference.x + ny * reference.y < 0) {
nx = -nx;
ny = -ny;
}
const ecart = degenere ? Math.hypot(a.x - g.centre.x, a.y - g.centre.y) + 4 : 4;
return { x: m.x + nx * ecart, y: m.y + ny * ecart };
}
describe('regimeCibles (§ 7.3)', () => {
test('le seuil est de 24 px', () => {
assert.equal(CIBLE_MIN_PX, 24);
@ -65,9 +92,16 @@ describe('l’emprise d’une carrée est un carré', () => {
assert.equal(surLeSol(point, geometries), false);
});
test('(140, 0) est le sol', () => {
test('(140, 0) : hors de l’emprise, mais à 28 cm du siège 5, en (112, 0), dans sa cible de rayon pas/2 = 30', () => {
const point = { x: 140, y: 0 };
assert.equal(designer(point, geometries, 0.2), null);
assert.deepEqual(designer(point, geometries, 1), { table: 7, siege: 5 });
assert.equal(surLeSol(point, geometries), true);
});
test('(150, 0) est le sol', () => {
const point = { x: 150, y: 0 };
assert.equal(designer(point, geometries, 0.2), null);
assert.equal(designer(point, geometries, 1), null);
assert.equal(surLeSol(point, geometries), true);
});
@ -112,20 +146,42 @@ describe('le siège désigné est celui que le dessin pose (§ 7.3, § 14.10)',
assert.equal(chaises, ATTENDUES[nom]);
});
test(`${nom}, k = 1 : ni le milieu de deux chaises voisines, ni le centre d’une table ne désigne rien`, () => {
let milieux = 0;
test(`${nom}, k = 1 : ni un point entre deux chaises voisines, à plus de pas/2 de chacune, ni le centre d’une table ne désigne rien`, () => {
let points = 0;
for (const g of geometries) {
const n = g.chaises.length;
for (let i = 0; i < n; i += 1) {
const point = milieu(g.chaises[i], g.chaises[(i + 1) % n]);
const point = entre(g, g.chaises[i], g.chaises[(i + 1) % n]);
for (const voisine of [g.chaises[i], g.chaises[(i + 1) % n]]) {
assert.ok(Math.hypot(point.x - voisine.x, point.y - voisine.y) > g.pas / 2, `table ${g.id}, siège ${voisine.siege}`);
}
assert.equal(designer(point, geometries, 1), null, `table ${g.id}, sièges ${i + 1} et ${((i + 1) % n) + 1}`);
assert.equal(surLeSol(point, geometries), false);
milieux += 1;
points += 1;
}
assert.equal(designer(g.centre, geometries, 1), null, `centre de la table ${g.id}`);
assert.equal(surLeSol(g.centre, geometries), false);
}
assert.equal(milieux, ATTENDUES[nom]);
assert.equal(points, ATTENDUES[nom]);
});
test(`${nom}, au seuil k = 24/pas de chaque table : un point à pas/2 − ε d’une chaise, vers sa voisine, la désigne`, () => {
let points = 0;
for (const g of geometries) {
const k = (CIBLE_MIN_PX / g.pas) * (1 + 1e-12);
assert.equal(regimeCibles(k, g), 'sieges');
const n = g.chaises.length;
for (let i = 0; i < n; i += 1) {
const chaise = g.chaises[i];
const voisine = g.chaises[(i + 1) % n];
const d = Math.hypot(voisine.x - chaise.x, voisine.y - chaise.y);
const portee = g.pas / 2 - EPS;
const point = { x: chaise.x + ((voisine.x - chaise.x) * portee) / d, y: chaise.y + ((voisine.y - chaise.y) * portee) / d };
assert.deepEqual(designer(point, [g], k), { table: g.id, siege: chaise.siege }, `table ${g.id}, siège ${chaise.siege}`);
points += 1;
}
}
assert.equal(points, ATTENDUES[nom]);
});
test(`${nom} : un point à 10 m de tout est le sol`, () => {

View file

@ -21,6 +21,8 @@ import { constante } from './constantes.js';
* @property {number} pas distance entre deux sièges voisins, au bord du plateau
* @property {Array<{siege: number, x: number, y: number, rayon: number}>} chaises sièges 1 à places
* @property {{x: number, y: number}} poignee centre du disque de la poignée
* @property {number} rayonCible rayon du disque, centré sur une chaise, où un point désigne
* son siège en régime sièges : max(RAYON_CHAISE, pas / 2)
* @property {number} rayonEmprise demiTaille + 2 × RAYON_CHAISE : rayon du disque d'emprise
* (ronde) ou demi-côté du carré d'emprise (carrée)
* @property {{x: number, y: number}} origineListe coin haut gauche du bloc de noms
@ -61,42 +63,37 @@ const COTES = [
{ depart: { x: -1, y: 1 }, direction: { x: 0, y: -1 }, normale: { x: -1, y: 0 } },
];
// Lève RangeError sur un nombre de places qui n'est pas un entier ≥ 1, ou un
// diamètre ou côté saisi qui n'est pas un nombre fini > 0 : la géométrie
// d'une table se calcule sans valeur de repli.
function exigerPlaces(places, saisi) {
// Lève RangeError sur un nombre de places qui n'est pas un entier ≥ 1 : la
// géométrie d'une table se calcule sans valeur de repli.
function exigerPlaces(places) {
if (!Number.isInteger(places) || places < 1) {
throw new RangeError(`nombre de places entier ≥ 1 attendu, reçu ${JSON.stringify(places)}`);
}
if (saisi !== null && !(Number.isFinite(saisi) && saisi > 0)) {
throw new RangeError(`dimension saisie > 0 attendue, reçu ${JSON.stringify(saisi)}`);
}
}
/**
* Diamètre d'une table ronde (§ 7.7) : max(70, places × 60 / π), ou le
* diamètre saisi, qui l'emporte.
* Diamètre d'une table ronde (§ 7.7) : max(70, places × 60 / π). Le format 1
* ne porte aucun diamètre saisi : la formule dessine toute table, et le pas
* qu'en tire geometrieTable ne connaît qu'elle.
*
* @param {number} places entier ≥ 1
* @param {{saisi?: number|null}} [options]
* @returns {number} cm
*/
export function diametre(places, { saisi = null } = {}) {
exigerPlaces(places, saisi);
return saisi ?? Math.max(PLANCHER_DIAMETRE, (places * LARGEUR_PAR_PLACE) / Math.PI);
export function diametre(places) {
exigerPlaces(places);
return Math.max(PLANCHER_DIAMETRE, (places * LARGEUR_PAR_PLACE) / Math.PI);
}
/**
* Côté d'une table carrée : max(70, ⌈places / 4⌉ × 60) — le côté le plus
* garni porte ⌈places / 4⌉ sièges —, ou le côté saisi, qui l'emporte.
* garni porte ⌈places / 4⌉ sièges.
*
* @param {number} places entier ≥ 1
* @param {{saisi?: number|null}} [options]
* @returns {number} cm
*/
export function cote(places, { saisi = null } = {}) {
exigerPlaces(places, saisi);
return saisi ?? Math.max(PLANCHER_DIAMETRE, Math.ceil(places / 4) * LARGEUR_PAR_PLACE);
export function cote(places) {
exigerPlaces(places);
return Math.max(PLANCHER_DIAMETRE, Math.ceil(places / 4) * LARGEUR_PAR_PLACE);
}
// Chaises d'une ronde : sur le cercle de rayon demiTaille + RAYON_CHAISE,
@ -144,6 +141,12 @@ function chaisesCarree(centre, demiTaille, places) {
* sous le seuil. Le pas d'une carrée est son côté divisé par le nombre de
* sièges du côté le plus garni.
*
* La cible d'un siège est un disque de rayon max(RAYON_CHAISE, pas / 2)
* centré sur sa chaise : au zoom où le régime des cibles passe aux sièges,
* k × pas ≥ 24 px (designation.js), elle mesure au moins 24 px à l'écran.
* Deux chaises d'une même table sont à un pas au moins l'une de l'autre :
* deux cibles se touchent au plus, sans se recouvrir.
*
* La poignée est un disque de rayon RAYON_POIGNEE sur la diagonale à +45°
* (vers le bas à droite), son bord à ECART_POIGNEE de l'emprise : son centre
* est à rayonEmprise + 23 cm du centre d'une ronde, à rayonEmprise × √2 + 23
@ -178,6 +181,7 @@ export function geometrieTable(table, places) {
demiTaille,
pas,
chaises,
rayonCible: Math.max(RAYON_CHAISE, pas / 2),
poignee: { x: centre.x + decalage, y: centre.y + decalage },
rayonEmprise,
origineListe: { x: centre.x + rayonEmprise + ECART_LISTE, y: centre.y - demiTaille },
@ -185,9 +189,13 @@ export function geometrieTable(table, places) {
}
/**
* Rectangle englobant des emprises : pour chaque table, le carré de
* demi-côté rayonEmprise centré sur elle, qui circonscrit le disque d'une
* ronde et égale l'emprise d'une carrée.
* Rectangle englobant de ce que le dessin pose de chaque table et qui se
* pointe : le carré de demi-côté rayonEmprise centré sur elle — il
* circonscrit le disque d'une ronde et égale l'emprise d'une carrée —, le
* carré circonscrit à la cible de chaque siège, qui déborde l'emprise de
* pas / 2 − RAYON_CHAISE, et celui du disque de la poignée, posée au-delà du
* coin d'une carrée. L'ajustement (vue.js) le fait tenir dans le cadre :
* toute cible et toute poignée y restent visibles.
*
* @param {GeometrieTable[]} geometries
* @returns {{minX: number, minY: number, maxX: number, maxY: number}|null} null sans table
@ -198,11 +206,16 @@ export function englobant(geometries) {
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const { centre, rayonEmprise } of geometries) {
minX = Math.min(minX, centre.x - rayonEmprise);
minY = Math.min(minY, centre.y - rayonEmprise);
maxX = Math.max(maxX, centre.x + rayonEmprise);
maxY = Math.max(maxY, centre.y + rayonEmprise);
const etendre = ({ x, y }, demi) => {
minX = Math.min(minX, x - demi);
minY = Math.min(minY, y - demi);
maxX = Math.max(maxX, x + demi);
maxY = Math.max(maxY, y + demi);
};
for (const { centre, rayonEmprise, chaises, rayonCible, poignee } of geometries) {
etendre(centre, rayonEmprise);
for (const chaise of chaises) etendre(chaise, rayonCible);
etendre(poignee, RAYON_POIGNEE);
}
return { minX, minY, maxX, maxY };
}

View file

@ -11,6 +11,7 @@ import { describe, test } from '../../test/lanceur.js';
import { constante } from './constantes.js';
import { surLeSol } from './designation.js';
import { TAILLE_TEXTE_LISTE_CM } from './texte.js';
import { MARGE_AJUSTEMENT_PX, ajuster, ecranDepuisDessin } from './vue.js';
import {
ECART_LISTE,
ECART_POIGNEE,
@ -114,9 +115,9 @@ describe('diametre (§ 7.7)', () => {
assert.equal(Math.round(diametre(10)), 191);
});
test('un diamètre saisi l’emporte', () => {
assert.equal(diametre(8, { saisi: 180 }), 180);
assert.equal(diametre(2, { saisi: 50 }), 50);
test('une seule formule : le format 1 ne porte aucun diamètre saisi, et une option ne change rien', () => {
assert.equal(diametre(8, { saisi: 180 }), diametre(8));
assert.equal(diametre(2, { saisi: 50 }), 70);
});
});
@ -128,8 +129,8 @@ describe('cote', () => {
assert.equal(cote(9), 180);
});
test('un côté saisi l’emporte', () => {
assert.equal(cote(8, { saisi: 150 }), 150);
test('une seule formule : une option ne change pas le côté', () => {
assert.equal(cote(8, { saisi: 150 }), 120);
});
});
@ -283,13 +284,34 @@ describe('la poignée ne touche aucune chaise (§ 7.1)', () => {
});
});
describe('la cible d’un siège a la taille du pas (§ 7.3)', () => {
test('de 2 à 40 places, deux formes : rayon max(RAYON_CHAISE, pas/2) ; deux cibles ne se recouvrent pas ; le centre de la poignée n’est dans aucune', () => {
let tables = 0;
for (const forme of ['ronde', 'carree']) {
for (let places = 2; places <= 40; places += 1) {
const g = geometrieTable(table(1, forme, 10, -20), places);
assert.equal(g.rayonCible, Math.max(RAYON_CHAISE, g.pas / 2), `${forme} de ${places}`);
for (let i = 0; i < places; i += 1) {
for (let j = i + 1; j < places; j += 1) {
const d = distance(g.chaises[i], g.chaises[j]);
assert.ok(d >= 2 * g.rayonCible - EPSILON, `${forme} de ${places}, sièges ${i + 1} et ${j + 1} à ${d} cm`);
}
assert.ok(distance(g.poignee, g.chaises[i]) > g.rayonCible, `${forme} de ${places}, poignée et siège ${i + 1}`);
}
tables += 1;
}
}
assert.equal(tables, 78);
});
});
describe('aucune rotation (§ 7.1)', () => {
test('la géométrie ne porte aucun angle et une seule poignée', () => {
for (const forme of ['ronde', 'carree']) {
const g = geometrieTable(table(1, forme, 0, 0), 8);
assert.deepEqual(
Object.keys(g).sort(),
['centre', 'chaises', 'demiTaille', 'forme', 'id', 'numero', 'origineListe', 'pas', 'poignee', 'rayonEmprise'],
['centre', 'chaises', 'demiTaille', 'forme', 'id', 'numero', 'origineListe', 'pas', 'poignee', 'rayonCible', 'rayonEmprise'],
);
assert.deepEqual(Object.keys(g.poignee).sort(), ['x', 'y']);
for (const chaise of g.chaises) assert.deepEqual(Object.keys(chaise).sort(), ['rayon', 'siege', 'x', 'y']);
@ -304,33 +326,64 @@ describe('geometrieTable refuse une table qu’elle ne sait pas dessiner', () =>
assert.throws(() => geometrieTable(table(1, 'carree', 0, 0), 2.5), RangeError);
});
test('diametre et cote : places invalides, dimension saisie nulle ou non finie', () => {
test('diametre et cote : places invalides', () => {
assert.throws(() => diametre(0), RangeError);
assert.throws(() => cote(1.5), RangeError);
assert.throws(() => diametre(8, { saisi: 0 }), RangeError);
assert.throws(() => cote(8, { saisi: Number.NaN }), RangeError);
assert.throws(() => diametre(8, { saisi: Infinity }), RangeError);
assert.throws(() => diametre(Number.NaN), RangeError);
});
});
describe('englobant', () => {
describe('englobant : tout ce que le dessin pose de pointable', () => {
test('null sans table', () => {
assert.equal(englobant([]), null);
});
test('deux tables : min et max exacts de leurs emprises', () => {
// Ronde de 8 en (300, 600), emprise 240/π + 44 ; carrée de 8 en
// (−100, 50), emprise 104.
const r = 240 / Math.PI + 44;
test('deux tables : min et max exacts de leurs emprises, des cibles de leurs sièges et de leurs poignées', () => {
// Ronde de 8 en (300, 600) : chaises à r + 22 du centre, r = 240/π,
// cibles de rayon pas/2 = 30, soit r + 52 de chaque côté — la poignée,
// à (r + 67)/√2 + 15 ≈ 116,4 sur chaque axe, reste dedans. Carrée de 8
// en (−100, 50) : emprise 104 ; chaises de gauche en x = −182 et du haut
// en y = −32, cibles de rayon 30 : −212 et −62.
const r = 240 / Math.PI;
const e = englobant([
geometrieTable(table(1, 'ronde', 300, 600), 8),
geometrieTable(table(2, 'carree', -100, 50), 8),
]);
assert.deepEqual(Object.keys(e).sort(), ['maxX', 'maxY', 'minX', 'minY']);
proche(e.minX, -204, 'minX');
proche(e.minY, -54, 'minY');
proche(e.maxX, 300 + r, 'maxX');
proche(e.maxY, 600 + r, 'maxY');
proche(e.minX, -212, 'minX');
proche(e.minY, -62, 'minY');
proche(e.maxX, 300 + r + 52, 'maxX');
proche(e.maxY, 600 + r + 52, 'maxY');
});
test('une carrée seule : la poignée, au-delà du coin de l’emprise, borne le bas et la droite', () => {
// Carrée de 8 en (−100, 50) : centre de la poignée en (4 + 23/√2,
// 154 + 23/√2), son bord 15 cm plus loin ; les cibles du haut et de la
// gauche bornent l'autre coin.
const e = englobant([geometrieTable(table(2, 'carree', -100, 50), 8)]);
proche(e.minX, -212, 'minX');
proche(e.minY, -62, 'minY');
proche(e.maxX, 4 + 23 / Math.SQRT2 + RAYON_POIGNEE, 'maxX');
proche(e.maxY, 154 + 23 / Math.SQRT2 + RAYON_POIGNEE, 'maxY');
});
test('après ajuster, sur une carrée seule de 4 et de 8, le disque de la poignée et chaque cible tiennent dans le cadre moins ses marges', () => {
const cadre = { gauche: 0, haut: 0, largeur: 1366, hauteur: 700 };
const dedans = (vue, { x, y }, rayon, libelle) => {
const p = ecranDepuisDessin(vue, cadre, x, y);
const r = rayon * vue.k;
assert.ok(p.x - r >= MARGE_AJUSTEMENT_PX - EPSILON && p.x + r <= cadre.largeur - MARGE_AJUSTEMENT_PX + EPSILON, `${libelle}, x`);
assert.ok(p.y - r >= MARGE_AJUSTEMENT_PX - EPSILON && p.y + r <= cadre.hauteur - MARGE_AJUSTEMENT_PX + EPSILON, `${libelle}, y`);
};
let disques = 0;
for (const places of [4, 8]) {
const g = geometrieTable(table(1, 'carree', 0, 0), places);
const vue = ajuster(englobant([g]), cadre);
dedans(vue, g.poignee, RAYON_POIGNEE, `carrée de ${places}, poignée`);
for (const chaise of g.chaises) dedans(vue, chaise, g.rayonCible, `carrée de ${places}, siège ${chaise.siege}`);
disques += 1 + g.chaises.length;
}
assert.equal(disques, 14);
});
});

View file

@ -5,9 +5,11 @@
// Racine de l'interface (§ 13.4). Ses propriétés sont les dépendances de
// l'application — système de fichiers, horloge, exécuteur de génération,
// presse-papiers, identifiants, langue, provenance, démonstrations, format
// courant, version du produit, et en option le thème de départ — que
// src/main.js construit pour la machine et monterApp pour une épreuve : App
// ne choisit aucune plateforme.
// courant, version du produit et sa forme Windows, et en option le thème
// de départ — que src/main.js construit pour la machine et monterApp pour
// une épreuve : App ne choisit aucune plateforme, et ne lit la version que
// dans ses propriétés, la même au bandeau, au titre, à l'accueil et dans
// le bloc « À propos ».
//
// À son initialisation, App crée une fois chacun la séance, sans racine,
// le contrôleur de génération et la façade, et les donne aux composants par
@ -40,7 +42,6 @@
import { TABLES_APPLICATION } from '../application/libelles.js';
import { creerSeance } from '../application/seance.js';
import { creerTraduction } from '../application/traduction.js';
import { VERSION } from '../version.genere.js';
import APropos from './APropos.svelte';
import Accueil from './accueil/Accueil.svelte';
import Bandeau from './Bandeau.svelte';
@ -64,11 +65,19 @@
demonstrations,
formatCourant,
produitVersion,
versionWindows,
themeInitial,
} = untrack(() => dependances);
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE], { langue });
const format = creerFormat(t);
// Le bloc « À propos » se compose au montage, avant toute séance : une
// forme de la version que les dépendances ne portent pas lève RangeError
// ici, et App ne se monte pas avec un bloc copiable faux (§ 18.6).
const bloc = blocAPropos(
{ version: { affichee: produitVersion, windows: versionWindows }, plateforme: fs.nature, provenance, format: formatCourant },
t,
);
const seanceApp = creerSeance({
fs,
horloge,
@ -192,7 +201,6 @@
const titre = t('coquille.titre', { version: produitVersion });
const version = versionVisible(provenance, produitVersion, t);
const provenanceAffichee = provenanceVisible(provenance, t);
const bloc = blocAPropos({ version: VERSION, plateforme: fs.nature, provenance, format: formatCourant }, t);
// « Liste des événements » : ferme l'événement, sans question ; sous un
// support rompu, la séance le refuse, et le refus s'affiche à côté de la

View file

@ -184,6 +184,22 @@ async function creerParLeFormulaire(m, nom, date = '') {
await cliquer(dans(m, '[data-commande="creer"]'));
}
// Retient les ajouts de ligne du système simulé — le journal s'écrit avant
// l'état — jusqu'à liberer() : l'opération lancée reste en cours, comme sur
// un support lent, sans délai ni minuterie.
function retenirEcritures(m) {
const ajouterLigne = m.fs.ajouterLigne;
let liberer;
const porte = new Promise((resoudre) => {
liberer = resoudre;
});
m.fs.ajouterLigne = async (...parametres) => {
await porte;
return ajouterLigne.apply(m.fs, parametres);
};
return () => liberer();
}
// Aucune fenêtre ni boîte de dialogue dans le document.
function aucuneFenetre() {
expect(document.querySelectorAll('[role=dialog], [role=alertdialog], dialog[open]').length).toBe(0);
@ -949,6 +965,55 @@ describe('les opérations de fichiers (§ 8.7)', () => {
// --- Les démonstrations (§ 15) -------------------------------------------------------------
describe('un geste en cours ne se relance pas (§ 8.7, § 15)', () => {
test('créer : une seconde soumission pendant l’écriture ne crée rien ; « Créer » se dit occupé', async () => {
const m = await monter();
const liberer = retenirEcritures(m);
saisir(dans(m, '[data-champ="nom"]'), 'Soirée des Merles');
const creer = dans(m, '[data-commande="creer"]');
await cliquer(creer);
expect(creer.getAttribute('aria-busy')).toBe('true');
expect(creer.getAttribute('aria-disabled')).toBe('true');
await cliquer(creer);
dans(m, '[data-champ="nom"]').form.requestSubmit();
await tick();
liberer();
await attendreEvenement(m, ({ nom }) => nom === 'Soirée des Merles');
await revenirALaListe(m, 1);
expect(lignes(m)).toHaveLength(1);
});
test('une démonstration cliquée deux fois pendant son chargement n’en charge qu’une', async () => {
const m = await monter();
const nom = CATALOGUE.find(({ cle }) => cle === 'petite').nom;
const liberer = retenirEcritures(m);
const bouton = dans(m, '[data-demonstration="petite"]');
await cliquer(bouton);
expect(bouton.getAttribute('aria-busy')).toBe('true');
await cliquer(bouton);
liberer();
await attendreEvenement(m, (e) => e.nom === `${nom} 1`);
await revenirALaListe(m, 1);
expect(lignes(m).map((ligne) => colonne(ligne, 'nom'))).toEqual([`${nom} 1`]);
});
test('copier : une seconde validation pendant l’écriture ne fait pas de seconde copie', async () => {
const m = await monter({ fichiers: [fichier(`bal${SUFFIXES.etat}`, texteRenomme('Bal des Grisailles'))] });
const ligne = ligneDe(m, 'bal');
await cliquer(ligne.querySelector('[data-commande="copier"]'));
saisir(ligne.querySelector('[data-champ="nomCopie"]'), 'Bal des Grisailles, variante');
const liberer = retenirEcritures(m);
const valider = ligne.querySelector('[data-commande="validerCopie"]');
await cliquer(valider);
expect(valider.getAttribute('aria-busy')).toBe('true');
await cliquer(valider);
liberer();
await attendreEvenement(m, ({ nom }) => nom === 'Bal des Grisailles, variante');
await revenirALaListe(m, 2);
expect(lignes(m)).toHaveLength(2);
});
});
describe('les démonstrations (§ 15)', () => {
test('deux chargements : « Petite démonstration 1 » puis « … 2 », en lecture, la première intacte', async () => {
const m = await monter();

View file

@ -22,7 +22,9 @@
// Un état qui ne s'ouvre pas (ETAT_ILLISIBLE) mène à la récupération, qui
// tient lieu de liste jusqu'à son retour. Chaque refus s'affiche, traduit,
// là où son geste a eu lieu, sans remède : sur la liste, aucun événement
// n'est ouvert pour l'exécuter (refusDeLaListe).
// n'est ouvert pour l'exécuter (refusDeLaListe). Un geste qui écrit ne se
// relance pas pendant qu'il s'écrit : la création, comme chaque commande,
// ignore une seconde demande et se dit occupée.
import { getContext, onDestroy, untrack } from 'svelte';
import { versionVisible } from '../apropos.js';
import Commande from '../primitives/Commande.svelte';
@ -59,6 +61,9 @@
let nom = $state('');
let date = $state('');
let refusCreation = $state.raw(null);
// Vrai pendant l'écriture d'une création : une seconde soumission ne
// crée rien, et « Créer » se dit occupé.
let creationEnCours = $state(false);
const champRefuse = $derived(champDuRefus(refusCreation));
const question = $derived(issue?.dossier.question ?? null);
@ -181,13 +186,17 @@
async function creer(evenement) {
evenement.preventDefault();
if (creationEnCours) return;
refusCreation = null;
creationEnCours = true;
try {
await surLaListe(() => facade.creer({ nom, date }));
} catch (refus) {
if (typeof refus?.code !== 'string') throw refus;
refusCreation = refus;
return;
} finally {
creationEnCours = false;
}
apresOperation();
}
@ -254,7 +263,12 @@
{/if}
</div>
<div class="commandes">
<button type="submit" data-commande="creer">{t('accueil.creation.creer')}</button>
<button
type="submit"
data-commande="creer"
aria-busy={creationEnCours ? 'true' : undefined}
aria-disabled={creationEnCours ? 'true' : undefined}>{t('accueil.creation.creer')}</button
>
{#if refusCreation !== null && champRefuse === null}
<Refus refus={refusCreation} />
{/if}
@ -347,4 +361,10 @@
font-size: var(--taille-texte);
cursor: pointer;
}
button[aria-disabled='true'] {
border-style: dashed;
color: var(--couleur-page-texte-secondaire);
cursor: progress;
}
</style>

View file

@ -10,6 +10,10 @@
//
// demonstrations : [{ cle, nom }] ; charger(cle) : le geste de l'accueil ;
// refus : { cle, refus } du dernier échec, ou null.
//
// Un chargement en cours ne se relance pas : tant que charger n'a pas
// rendu la main, un clic sur une démonstration ne fait rien, et celle qui
// se charge porte aria-busy et aria-disabled.
import { getContext } from 'svelte';
import Refus from '../primitives/Refus.svelte';
@ -17,6 +21,19 @@
const t = getContext('t');
const uid = $props.id();
// La clé de la démonstration qui se charge, ou null.
let enCours = $state(null);
async function choisir(cle) {
if (enCours !== null) return;
enCours = cle;
try {
await charger(cle);
} finally {
enCours = null;
}
}
</script>
<section class="demonstrations" aria-labelledby={`${uid}-titre`}>
@ -25,7 +42,13 @@
<ul>
{#each demonstrations as { cle, nom } (cle)}
<li>
<button type="button" data-demonstration={cle} onclick={() => charger(cle)}>{nom}</button>
<button
type="button"
data-demonstration={cle}
aria-busy={enCours === cle ? 'true' : undefined}
aria-disabled={enCours === cle ? 'true' : undefined}
onclick={() => choisir(cle)}>{nom}</button
>
{#if refus !== null && refus.cle === cle}
<Refus refus={refus.refus} />
{/if}
@ -81,4 +104,10 @@
outline: 2px solid var(--couleur-focus);
outline-offset: 2px;
}
button[aria-disabled='true'] {
border-style: dashed;
color: var(--couleur-page-texte-secondaire);
cursor: progress;
}
</style>

View file

@ -10,7 +10,10 @@
// la corbeille et se confirme en place, par un second clic : aucune
// fenêtre. Chaque refus s'affiche contre le champ ou à côté de sa
// commande, sans remède (surLaListe) ; apresOperation() suit chaque
// opération réussie.
// opération réussie. Une validation en cours ne se relance pas : une
// seconde soumission pendant l'écriture ne fait rien, et le bouton de
// validation se dit occupé ; supprimer passe par la Commande, qui a la
// même garde.
import { getContext } from 'svelte';
import Commande from '../primitives/Commande.svelte';
import Refus from '../primitives/Refus.svelte';
@ -26,6 +29,8 @@
// texte }, ou null ; et son refus.
let saisie = $state(null);
let refus = $state.raw(null);
// Vrai pendant l'écriture d'une copie ou d'un renommage.
let validationEnCours = $state(false);
const surLeChamp = $derived(champDuRefus(refus) === 'nom');
function ouvrirSaisie(operation) {
@ -46,14 +51,18 @@
async function valider(evenement) {
evenement.preventDefault();
if (validationEnCours) return;
refus = null;
const { operation, texte } = saisie;
validationEnCours = true;
try {
await surLaListe(() => GESTES[operation](texte));
} catch (erreur) {
if (typeof erreur?.code !== 'string') throw erreur;
refus = erreur;
return;
} finally {
validationEnCours = false;
}
fermerSaisie();
apresOperation();
@ -87,7 +96,12 @@
{/if}
</div>
<div class="commandes">
<button type="submit" data-commande={saisie.commande}>{t(saisie.valider)}</button>
<button
type="submit"
data-commande={saisie.commande}
aria-busy={validationEnCours ? 'true' : undefined}
aria-disabled={validationEnCours ? 'true' : undefined}>{t(saisie.valider)}</button
>
<button type="button" data-commande="annuler" onclick={fermerSaisie}>{t('accueil.operation.annuler')}</button>
{#if refus !== null && !surLeChamp}
<Refus {refus} />
@ -149,4 +163,10 @@
outline: 2px solid var(--couleur-focus);
outline-offset: 2px;
}
button[aria-disabled='true'] {
border-style: dashed;
color: var(--couleur-page-texte-secondaire);
cursor: progress;
}
</style>

View file

@ -16,14 +16,24 @@ import { aUnLibelle } from './libelles.js';
* quand le mode n'en a pas ; la plateforme sous sa nature.
*
* @param {Object} champs
* @param {{affichee: string, windows: string}} champs.version VERSION
* @param {{affichee: string, windows: string}} champs.version les deux formes
* de la version, celles que reçoit App (produitVersion, versionWindows)
* @param {string} champs.plateforme la nature du système de fichiers
* @param {string} champs.provenance le mode de construction de Vite
* @param {number} champs.format la version de format des fichiers
* @param {(cle: string, details?: Object) => string} t
* @returns {string} sans saut de ligne
* @throws {RangeError} une forme de la version absente, vide ou qui n'est
* pas une chaîne, en la nommant : une dépendance d'App oubliée
* échoue au montage, plutôt que de donner un bloc copiable faux
*/
export function blocAPropos({ version, plateforme, provenance, format }, t) {
for (const forme of ['affichee', 'windows']) {
const valeur = version[forme];
if (typeof valeur !== 'string' || valeur === '') {
throw new RangeError(`À propos : la version ${forme} manque (${JSON.stringify(valeur) ?? 'undefined'})`);
}
}
return t('coquille.apropos.bloc', {
affichee: version.affichee,
windows: version.windows,

View file

@ -6,6 +6,7 @@
// version de format —, et ce qui tient lieu de version à l'écran, la marque
// de provenance dans une construction de documentation (§ 19.4).
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { describe, test } from '../../test/lanceur.js';
import { TABLES_APPLICATION } from '../application/libelles.js';
import { MARQUE_TEMOIN, creerTraduction } from '../application/traduction.js';
@ -49,6 +50,23 @@ describe('blocAPropos (§ 18.6)', () => {
}
});
test('une forme de la version absente, vide ou qui n’est pas une chaîne lève RangeError en la nommant : jamais un bloc copiable faux', () => {
const fautes = [undefined, null, '', 7];
assert.equal(fautes.length, 4);
for (const faute of fautes) {
for (const [forme, version] of [
['windows', { affichee: VERSION.affichee, windows: faute }],
['affichee', { affichee: faute, windows: VERSION.windows }],
]) {
assert.throws(
() => blocAPropos({ version, plateforme: 'web', provenance: 'test', format: FORMAT }, t),
(erreur) => erreur instanceof RangeError && erreur.message.includes(forme),
`${forme} ${String(faute)}`,
);
}
}
});
test('en langue témoin, le bloc entier est encadré une fois', () => {
const bloc = blocAPropos({ version: VERSION, plateforme: 'web', provenance: 'test', format: FORMAT }, temoin);
const { ouverture, fermeture } = MARQUE_TEMOIN;
@ -87,3 +105,17 @@ describe('provenanceVisible (§ 18.6)', () => {
assert.equal(provenanceVisible('production', temoin), 'production');
});
});
describe('la version de l’interface vient des dépendances (§ 13.4, § 18.6)', () => {
test('App.svelte ne lit pas la version engendrée : la version affichée et la forme Windows arrivent par ses propriétés, produitVersion et versionWindows', () => {
const source = readFileSync(new URL('./App.svelte', import.meta.url), 'utf8');
const imports = [...source.matchAll(/^\s*import\s[^;]*?from\s+'([^']+)'/gmu)].map(([, chemin]) => chemin);
assert.ok(imports.length > 0, 'des imports relevés');
assert.deepEqual(
imports.filter((chemin) => chemin.includes('version.genere')),
[],
);
assert.match(source, /\bversionWindows\b/u);
assert.match(source, /blocAPropos\(\s*\{\s*version:\s*\{\s*affichee:\s*produitVersion,\s*windows:\s*versionWindows\s*\}/u);
});
});

View file

@ -5,7 +5,8 @@
// propos », le tour montré, la personne mise en évidence, la proposition
// demandée au plan, le texte d'un import en attente, les listes de noms, le
// plein écran, la place sélectionnée, la fiche ouverte, le choix en attente
// du plan et le refus posé sur une place. Regarder n'écrit jamais (§ 8.4) : cet
// du plan, le refus posé sur une place et l'appartenance collante du
// formulaire d'ajout. Ce qu'il tient survit au démontage des sections. Regarder n'écrit jamais (§ 8.4) : cet
// état ne reçoit ni la séance, ni la façade, ni un système de fichiers, et
// rien de ce qu'il tient ne quitte la mémoire.
//
@ -24,7 +25,7 @@ import { SECTION_OUVERTURE, SECTIONS } from './evenement/sections.js';
* @property {number|null} proposition la proposition demandée au plan
* @property {null|{texte: string, origine: string}} importEnAttente
* @property {boolean} listes les listes de noms sont montrées
* @property {boolean} pleinEcran
* @property {boolean} pleinEcran vrai seulement sur la section du plan
* @property {Object|null} selection la place sélectionnée
* @property {number|null} fiche l'identifiant de la personne dont la fiche est ouverte
* @property {null|{genre: 'personne'|'reservation', participant: number,
@ -38,6 +39,8 @@ import { SECTION_OUVERTURE, SECTIONS } from './evenement/sections.js';
* avertissements: Object[]}} pose
* le refus, ou les avertissements, posés sur une place du plan, que
* Plan.svelte montre sur la place (§ 5.9)
* @property {string} appartenance l'appartenance collante du formulaire d'ajout
* des participants : celle du dernier ajout (§ 10.2), '' au départ
*/
// L'état de navigation d'un événement qui s'ouvre, panneau « À propos »
@ -54,6 +57,7 @@ const DEPART = Object.freeze({
fiche: null,
choix: null,
pose: null,
appartenance: '',
});
const GENRES_CHOIX = new Set(['personne', 'reservation']);
@ -98,16 +102,19 @@ function choixValide(choix) {
* prendreImport: () => null|{texte: string, origine: string},
* basculerListes: Function, basculerPleinEcran: Function,
* selectionner: Function, ouvrirFiche: Function, choisir: Function,
* poserSurPlace: Function}}
* poserSurPlace: Function, garderAppartenance: Function}}
*/
export function creerEtatUi() {
let etat = Object.freeze({ ...DEPART, apropos: false });
const abonnes = new Set();
// Publie l'état que donnent les changements, s'il diffère du courant sur
// l'une de ses propriétés.
// l'une de ses propriétés. Le plein écran ne vaut que pour le plan : hors
// de lui, il tombe, quelle que soit la transition qui quitte le plan, et
// la navigation des sections, que le plein écran efface, revient.
function changer(changements) {
const suivant = { ...etat, ...changements };
if (suivant.section !== 'plan') suivant.pleinEcran = false;
if (Object.keys(suivant).every((cle) => Object.is(suivant[cle], etat[cle]))) return;
etat = Object.freeze(suivant);
for (const fn of [...abonnes]) fn(etat);
@ -189,6 +196,12 @@ export function creerEtatUi() {
}
changer({ pose: Object.freeze({ place, refus, avertissements }) });
},
/** Garde l'appartenance collante du formulaire d'ajout, une chaîne ;
* RangeError sinon. */
garderAppartenance(texte) {
if (typeof texte !== 'string') throw new RangeError(`appartenance : chaîne attendue, reçu ${String(texte)}`);
changer({ appartenance: texte });
},
/** Ouvre la fiche de la personne id, entier positif, dans la section des
* participants ; null referme la fiche, la section reste. RangeError
* sinon. */

View file

@ -23,6 +23,7 @@ const DEPART = {
fiche: null,
choix: null,
pose: null,
appartenance: '',
};
// Un état et le relevé de ce que reçoit son abonné.
@ -149,6 +150,29 @@ describe('état de navigation : chaque transition', () => {
assert.deepEqual(ui.etat(), DEPART);
});
test('le plein écran ne vaut que pour le plan : quitter le plan le retire, par allerA, ouvrirFiche ou ouvrirImport ; hors du plan, il ne se pose pas', () => {
const quitters = [
...SECTIONS.filter((section) => section !== 'plan').map((section) => [`allerA ${section}`, (ui) => ui.allerA(section)]),
['ouvrirFiche', (ui) => ui.ouvrirFiche(4)],
['ouvrirImport', (ui) => ui.ouvrirImport({ texte: 'nom\nOmbrelle', origine: 'collage' })],
];
assert.ok(SECTIONS.includes('plan'));
assert.equal(quitters.length, SECTIONS.length - 1 + 2);
for (const [nom, quitter] of quitters) {
const ui = creerEtatUi();
ui.allerA('plan');
ui.basculerPleinEcran();
assert.equal(ui.etat().pleinEcran, true, nom);
quitter(ui);
assert.notEqual(ui.etat().section, 'plan', nom);
assert.equal(ui.etat().pleinEcran, false, nom);
ui.basculerPleinEcran();
assert.equal(ui.etat().pleinEcran, false, `${nom} : hors du plan, la bascule ne pose rien`);
ui.allerA('plan');
assert.equal(ui.etat().pleinEcran, false, `${nom} : revenu au plan, la tête est là`);
}
});
test('ouvrirFiche(id) mène à la section participants et ouvre la fiche ; ouvrirFiche(null) la referme, la section reste', () => {
const ui = creerEtatUi();
ui.allerA('plan');
@ -169,6 +193,22 @@ describe('état de navigation : chaque transition', () => {
assert.equal(ui.etat(), avant);
});
test('garderAppartenance tient l’appartenance collante du formulaire d’ajout (§ 10.2) : elle survit au changement de section, et un autre événement repart vide', () => {
const ui = creerEtatUi();
ui.allerA('participants');
ui.garderAppartenance('Club des Merles');
assert.equal(ui.etat().appartenance, 'Club des Merles');
ui.allerA('plan');
ui.allerA('participants');
ui.ouvrirApropos();
ui.fermerApropos();
assert.equal(ui.etat().appartenance, 'Club des Merles');
ui.reinitialiser();
assert.equal(ui.etat().appartenance, '');
for (const faute of [null, undefined, 3, {}]) assert.throws(() => ui.garderAppartenance(faute), RangeError, String(faute));
assert.equal(ui.etat().appartenance, '');
});
test('une transition qui ne change rien ne publie pas d’état neuf', () => {
const { ui, recus } = suivi();
ui.allerA(SECTION_OUVERTURE);

View file

@ -6,7 +6,8 @@
// l'ordre de la séance (avertissementsOrdonnes, modele.js) : chacun par le
// texte de son code, puis ce qu'il nomme — les propositions écartées, leur
// numéro et leurs fautes ; les placements en dérive et leurs raisons —,
// puis son remède en Commande, par actionRemede (primitives/commande.js).
// puis son remède en Commande, par actionRemede (primitives/commande.js),
// confirmé en place quand actionRemede le demande — « Débloquer ».
// Un avertissement n'empêche rien.
import { getContext } from 'svelte';
import Commande from '../primitives/Commande.svelte';
@ -62,7 +63,13 @@
</ul>
{/if}
{#if action !== null}
<Commande cle={action.cle} details={action.details} nom={action.nom} executer={action.executer} />
<Commande
cle={action.cle}
details={action.details}
nom={action.nom}
confirmer={action.confirmer}
executer={action.executer}
/>
{/if}
</div>
{/each}

View file

@ -19,6 +19,20 @@
// cadre avant que les annonces ne dépassent leur première ligne, sauf
// devant un support rompu, dont les issues restent en vue.
//
// Sous une tête, et hors d'un support rompu, les annonces se posent à
// côté d'elle, dans la place que sa navigation laisse à droite : leur zone
// prend la hauteur de la tête et défile dedans, si bien qu'un
// avertissement ne retire aucune hauteur à la surface, ni au plan qu'elle
// porte. Une fenêtre trop étroite pour les deux les passe à la ligne, et
// la zone garde alors sa hauteur minimale.
//
// La tête se rend en un seul point du DOM, quelle que soit la place des
// annonces : seules les annonces passent de côté à dessous quand une
// annonce vient, s'en va, ou que le support se rompt. Un rendu de la tête
// dans deux blocs la démonterait à chacun de ces passages — la Commande
// en cours détruite, le refus posé à côté du geste perdu (§ 2.1), le
// focus du clavier rendu au corps de la page.
//
// La place fixe porte la commande que rend commandeFixe (modele.js) :
// « Modifier », « Débloquer » — confirmé en place, par la Commande d'I2 —,
// « Revenir en lecture », ou, pour un format plus récent, l'explication du
@ -50,6 +64,8 @@
const commande = $derived(commandeFixe($seance));
const verrou = $derived($seance.mode === 'lecture' ? $seance.verrouAutre : null);
const reprise = $derived(repriseOfferte($seance));
// Les annonces se posent à côté de la tête, ou dessous.
const aCote = $derived(tete !== null && annonces !== null && $seance.supportRompu === null);
</script>
<div class="cadre" data-mode={forme} data-marque={MARQUE_DE_LA_FORME[forme]} class:tirets={forme === 'tirets'} class:double={forme === 'double'}>
@ -105,12 +121,21 @@
{/if}
</div>
{#if tete !== null}
<div class="tete">
{@render tete()}
<div class="tete" class:cote={aCote}>
<div class="contenu">
{@render tete()}
</div>
{#if aCote}
<div class="place">
<div class="annonces" data-annonces>
{@render annonces()}
</div>
</div>
{/if}
</div>
{/if}
{#if annonces !== null}
<div class="annonces" class:rompu={$seance.supportRompu !== null} data-annonces>
{#if annonces !== null && !aCote}
<div class="annonces seules" class:rompu={$seance.supportRompu !== null} data-annonces>
{@render annonces()}
</div>
{/if}
@ -215,22 +240,66 @@
border-bottom: 1px solid var(--couleur-page-filet);
}
/* Les annonces ne prennent jamais plus du quart du cadre, et cèdent
d'abord leur hauteur à la surface : leur facteur de rétrécissement,
mille fois celui de la surface, leur fait absorber presque tout le
manque de place, jusqu'à leur hauteur minimale : un avertissement
d'une ligne en entier, et le haut du suivant, qui dit que la zone en
porte d'autres. Au-delà de leur hauteur, elles défilent dans leur
zone. */
/* Les annonces défilent dans leur zone, au-delà de sa hauteur. */
.annonces {
display: flex;
flex: 0 1000 auto;
flex-direction: column;
gap: var(--espace-2);
min-height: 3rem;
max-height: 25%;
overflow-y: auto;
padding: var(--espace-2) var(--espace-3);
}
/* À côté de la tête : la tête passe en ligne, son contenu à gauche, la
place des annonces à droite. La place ne donne à la ligne que sa
hauteur minimale ; la zone des annonces la remplit, posée en absolu,
et ne l'agrandit jamais : la ligne a la hauteur du contenu de la tête.
La place prend une part fixe de la largeur, et le contenu le reste :
la zone ne bouge pas d'une section à l'autre, quand le libellé de la
section ouverte, en gras, change la largeur de la navigation. Sous
30rem de contenu, la place passe à la ligne. */
.tete.cote {
flex-flow: row wrap;
align-items: stretch;
gap: 0 var(--espace-3);
padding-right: 0;
padding-block: 0;
}
.contenu {
display: flex;
flex-direction: column;
gap: var(--espace-2);
}
.cote > .contenu {
flex: 1 1 0;
min-width: min(100%, 30rem);
padding-block: var(--espace-2);
}
.place {
position: relative;
flex: 0 0 32%;
min-width: 16rem;
min-height: 3rem;
border-left: 1px solid var(--couleur-page-filet);
}
.place > .annonces {
position: absolute;
inset: 0;
}
/* Seules — sans tête, en plein écran, ou devant un support rompu —, les
annonces ne prennent jamais plus du quart du cadre, et cèdent d'abord
leur hauteur à la surface : leur facteur de rétrécissement, mille fois
celui de la surface, leur fait absorber presque tout le manque de
place, jusqu'à leur hauteur minimale : un avertissement d'une ligne en
entier, et le haut du suivant, qui dit que la zone en porte d'autres. */
.annonces.seules {
flex: 0 1000 auto;
min-height: 3rem;
max-height: 25%;
border-bottom: 1px solid var(--couleur-page-filet);
}

View file

@ -18,6 +18,7 @@
// du support rompu. Le texte attendu vient de la même traduction que celle
// de l'application ; les noms d'épreuve sont inventés.
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
import { cdp } from 'vitest/browser';
import { flushSync } from 'svelte';
import { TABLES_APPLICATION, libelle } from '../../application/libelles.js';
import { INACTIVITE_MS } from '../../application/seance.js';
@ -87,6 +88,38 @@ const auCentre = (element) => {
return document.elementFromPoint(left + width / 2, top + height / 2);
};
// Un appui réel annoncé par Chromium comme le rang-ième clic d'une série
// (clickCount) : son événement click porte detail rang. cible est un élément,
// visé en son centre, ou le point que rend l'appui précédent de la série. Les
// coordonnées du cadre de l'épreuve passent dans la fenêtre de la page qui le
// porte, d'un cadre à son parent, comme dans test/navigateur/entrees.js.
async function cliquerEnSerie(cible, rang) {
let { x, y } = cible;
if (cible instanceof Element) {
const { left, top, width, height } = cible.getBoundingClientRect();
[x, y] = [left + width / 2, top + height / 2];
for (let fenetre = window; fenetre !== fenetre.top; fenetre = fenetre.parent) {
const porteur = fenetre.frameElement;
const boite = porteur.getBoundingClientRect();
const style = fenetre.parent.getComputedStyle(porteur);
const echelle = porteur.offsetWidth > 0 ? boite.width / porteur.offsetWidth : 1;
x = boite.left + (porteur.clientLeft + parseFloat(style.paddingLeft) + x) * echelle;
y = boite.top + (porteur.clientTop + parseFloat(style.paddingTop) + y) * echelle;
}
}
await cdp().send('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', buttons: 1, clickCount: rang });
await cdp().send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', buttons: 0, clickCount: rang });
flushSync();
return { x, y };
}
// Attend que la file de la séance ait passé ce qui s'y trouve, par une
// lecture admise dans tout mode, qui n'écrit rien.
async function viderFile(m) {
await m.seance.formeNommee(null).catch(() => undefined);
flushSync();
}
// Le refus posé à côté d'un bouton de Commande : dans son conteneur.
const refusPres = (bouton, code) => bouton.parentElement.querySelector(`[data-refus="${code}"]`);
@ -214,6 +247,27 @@ describe('la place fixe (§ 8.4, § 8.5)', () => {
expect(coin(fixe(m, 'debloquer'))).toEqual(coins[0]);
expect(visible(fixe(m, 'debloquer'))).toBe(true);
});
test('un double-clic sur « Modifier » passe en écriture et y reste : le second clic de la série tombe sur « Revenir en lecture », venu à sa place, qui ne l’exécute pas', async () => {
const m = await neuf();
const avant = m.trace.length;
const clics = [];
m.racine.addEventListener('click', (evenement) => clics.push([evenement.target.dataset.commande, evenement.detail]), true);
const point = await cliquerEnSerie(fixe(m, 'modifier'), 1);
await attendre(() => expect(fixe(m, 'lecture')).not.toBeNull());
await viderFile(m);
await cliquerEnSerie(point, 2);
await viderFile(m);
expect(clics).toEqual([
['modifier', 1],
['lecture', 2],
]);
expect(m.seance.etat().mode).toBe('ecriture');
expect(fixe(m, 'lecture')).not.toBeNull();
const verrous = m.trace.slice(avant).filter(([primitive]) => primitive === 'verrouiller' || primitive === 'deverrouiller');
expect(verrous.map(([primitive]) => primitive)).toEqual(['verrouiller']);
expect(fichier(m, SUFFIXES.verrou)).not.toBeNull();
});
});
describe('un nom long ou de glyphes larges (point d’attention 3)', () => {
@ -480,6 +534,25 @@ describe('défaire, refaire (§ 8.3)', () => {
await attendre(() => expect(historique(m, 'refaire')).toBeNull());
});
test('un double-clic sur « Défaire » rend un seul geste : le second clic de la série, reçu une fois le premier retour écrit, n’en défait pas un autre', async () => {
const m = await neuf();
await m.seance.passerEnEcriture();
for (const nom of NOMS.slice(0, 3)) await m.seance.executer('ajouterParticipant', { champs: { nom } });
flushSync();
const { entrees } = journalDe(m);
const point = await cliquerEnSerie(historique(m, 'defaire'), 1);
await attendre(() => expect(nombre(m)).toBe(2));
await viderFile(m);
expect(historique(m, 'defaire').getAttribute('aria-busy')).toBeNull();
await cliquerEnSerie(point, 2);
await viderFile(m);
expect(nombre(m)).toBe(2);
expect(journalDe(m).entrees.length).toBe(entrees.length + 1);
// Un clic simple ensuite défait de nouveau.
await cliquerEnSerie(historique(m, 'defaire'), 1);
await attendre(() => expect(nombre(m)).toBe(1));
});
test('un retour de 5 à 2, puis « Défaire » : la cible est le prédécesseur de 2 sur le fil, non la ligne précédente', async () => {
const m = await neuf();
await m.seance.passerEnEcriture();
@ -557,6 +630,36 @@ describe('les annonces d’ouverture (§ 8.9, § 9)', () => {
await attendre(() => expect(m.ui.etat().section).toBe('propositions'));
});
test('en plein écran, le remède d’une dérive mène à sa section, et la navigation des sections y revient : le plein écran ne vaut que pour le plan', async () => {
const m = await neuf();
await garnir(m);
await generer(m);
await m.seance.executer('changerTours', { tours: EVENEMENT.tours + 1 });
m.ui.allerA('plan');
flushSync();
expect(tous(m, '[data-commande="section"]')).toHaveLength(SECTIONS.length);
dans(m, '[data-commande="pleinEcran"]').click();
flushSync();
expect(tous(m, '[data-commande="section"]')).toHaveLength(0);
const remede = await attendre(() => {
const trouve = dans(m, '[data-annonces] [data-avertissement="DERIVE_NOUVELLE"] [data-commande="ouvrirSection"]');
expect(trouve).not.toBeNull();
return trouve;
});
remede.click();
await attendre(() => expect(m.ui.etat().section).not.toBe('plan'));
flushSync();
const navigation = tous(m, '[data-commande="section"]');
expect(navigation).toHaveLength(SECTIONS.length);
for (const bouton of navigation) expect(visible(bouton), bouton.dataset.cible).toBe(true);
navigation.find((bouton) => bouton.dataset.cible === 'tables').click();
flushSync();
expect(dans(m, '[data-section]').dataset.section).toBe('tables');
m.ui.allerA('plan');
flushSync();
expect(tous(m, '[data-commande="section"]')).toHaveLength(SECTIONS.length);
});
test('un retenu fautif : son message vient en premier et offre « Ouvrir la version précédente », qui l’ouvre en lecture sans rien écrire', async () => {
const m = await neuf();
await garnir(m);
@ -663,6 +766,51 @@ describe('l’écran tient dans la fenêtre, avertissements compris (§ 8.4)', (
}
});
test('à 1 366 × 700, les avertissements de dérive se posent à côté de la tête : le plan garde toute sa hauteur, et ses chaises au zoom ajusté', async () => {
const m = await monter();
await m.seance.creer({ nom: 'Bal des Grisailles', date: null, siegesParDefaut: 8, tours: 3 });
await m.seance.passerEnEcriture();
for (let n = 0; n < 18; n += 1) await m.seance.executer('ajouterParticipant', { champs: { nom: `${NOMS[n % NOMS.length]} ${n + 1}` } });
for (let n = 0; n < 3; n += 1) await m.seance.executer('creerTable', { forme: 'ronde' });
await generer(m);
m.ui.allerA('plan');
flushSync();
const ajuster = async () => {
dans(m, '[data-commande="ajuster"]').click();
flushSync();
await new Promise((resoudre) => requestAnimationFrame(resoudre));
flushSync();
};
await ajuster();
expect(dans(m, '[data-annonces]')).toBeNull();
const hauteurSeule = dans(m, '[data-plan]').getBoundingClientRect().height;
expect(hauteurSeule).toBeGreaterThan(0);
// Trois gestes admis sur le retenu, chacun une dérive annoncée.
await m.seance.executer('ajouterParticipant', { champs: { nom: 'Rouvraie' } });
await m.seance.executer('creerTable', { forme: 'ronde' });
await m.seance.executer('changerTours', { tours: 4 });
flushSync();
await ajuster();
const montres = tous(m, '[data-annonces] [data-avertissement]');
expect(montres.length).toBeGreaterThan(0);
// La zone se voit, et le premier avertissement s'ouvre dans elle.
const zone = dans(m, '[data-annonces]');
expect(visible(zone)).toBe(true);
const [boiteZone, premier] = [zone.getBoundingClientRect(), montres[0].getBoundingClientRect()];
expect(premier.top).toBeGreaterThanOrEqual(boiteZone.top);
expect(premier.top).toBeLessThan(boiteZone.bottom - 16);
expect(document.documentElement.scrollHeight).toBeLessThanOrEqual(innerHeight);
expect(dans(m, '[data-plan]').getBoundingClientRect().height).toBeGreaterThanOrEqual(hauteurSeule - 1);
const tables = tous(m, '[data-plan] [data-table]');
expect(tables.length).toBe(4);
expect(tous(m, '[data-plan] [data-siege]').length).toBe(4 * 8);
// La navigation et la place fixe restent atteintes.
const navigation = tous(m, '[data-commande="section"]');
expect(navigation).toHaveLength(SECTIONS.length);
for (const bouton of navigation) expect(visible(bouton), bouton.dataset.cible).toBe(true);
expect(fixe(m, 'lecture').contains(auCentre(fixe(m, 'lecture')))).toBe(true);
});
test('à 1 366 × 700, un support rompu et trois avertissements et plus : chaque issue du support rompu se voit sans défiler, la page ne défile pas, et les avertissements défilent sous lui', async () => {
const m = await neuf();
await garnir(m);
@ -758,7 +906,12 @@ describe('la dérive (§ 9)', () => {
expect(nouvelle.details.touchees.map(({ id }) => id)).toContain('retenu');
const element = avertissement(m, 'DERIVE_NOUVELLE');
expect(element.textContent).toContain(t('DERIVE_NOUVELLE', nouvelle.details));
expect(element.textContent).toContain(t('PARTICIPANT_EXCLU', { participant: exclue }));
// La raison telle que la séance la rend, détails compris : son texte
// est celui que l'avertissement affiche.
const { raisons } = nouvelle.details.touchees.find(({ id }) => id === 'retenu');
const exclusion = raisons.find(({ code }) => code === 'PARTICIPANT_EXCLU');
expect(exclusion.participant).toBe(exclue);
expect(element.textContent).toContain(t('PARTICIPANT_EXCLU', exclusion));
await m.seance.executer('ajouterParticipant', { champs: { nom: 'Rouvraie' } });
expect(m.seance.etat().charge.participants.length).toBe(NOMS.length + 1);
});
@ -790,6 +943,43 @@ describe('la dérive (§ 9)', () => {
});
});
describe('la tête reste en place quand les annonces vont et viennent (§ 2.1, § 8.4)', () => {
test('une dérive affichée, l’écriture refusée : « Bloqué » cliqué reste le même élément, et porte à côté de lui le refus ECRITURE', async () => {
const m = await neuf();
await garnir(m);
await generer(m);
await m.seance.executer('changerTours', { tours: EVENEMENT.tours + 1 });
flushSync();
expect(avertissement(m, 'DERIVE_NOUVELLE')).not.toBeNull();
m.fs.pannes.ecriture('documents');
const avant = boutonEtat(m, 'bloque');
avant.click();
await attendre(() => expect(m.seance.etat().supportRompu).not.toBeNull());
await viderFile(m);
// Le support rompu fait passer les annonces sous la tête : le bouton du
// geste reste celui qu'a reçu le clic, et le refus se pose à côté de lui.
expect(boutonEtat(m, 'bloque')).toBe(avant);
expect(avant.isConnected).toBe(true);
await attendre(() => expect(refusPres(avant, 'ECRITURE')).not.toBeNull());
expect(visible(refusPres(avant, 'ECRITURE'))).toBe(true);
});
test('le premier avertissement venu, le focus du clavier reste sur le bouton de la tête qui le portait', async () => {
const m = await neuf();
await garnir(m);
await generer(m);
expect(dans(m, '[data-annonces]')).toBeNull();
const bouton = dans(m, '[data-commande="section"][data-cible="plan"]');
bouton.focus();
expect(document.activeElement).toBe(bouton);
await m.seance.executer('changerTours', { tours: EVENEMENT.tours + 1 });
flushSync();
expect(avertissement(m, 'DERIVE_NOUVELLE')).not.toBeNull();
expect(dans(m, '[data-commande="section"][data-cible="plan"]')).toBe(bouton);
expect(document.activeElement).toBe(bouton);
});
});
describe('le verrou d’une autre séance (§ 8.8)', () => {
test('le second montage ouvre en lecture ; son « Modifier » est refusé, la séance et l’instant affichés ; le premier coupé, il reprend le verrou du mort', async () => {
const premier = await neuf();

View file

@ -93,6 +93,19 @@ const REMEDES = new Map([
['PLACES_MANQUANTES', Object.freeze({ geste: 'ouvrirSection', section: 'tables' })],
]);
// Le remède d'un avertissement : celui de REMEDES, sauf une réservation que
// le retenu ne tient pas à son siège (details.siege), que lève l'échange de
// sa personne avec l'occupant, ou, chaise vide, son déplacement au siège.
function remedeDe(code, details) {
if (code === 'RESERVATION_NON_HONOREE' && details.siege !== undefined) {
const { tour, participant, table, siege, occupant } = details;
return occupant === null
? { commande: 'deplacerDansRetenu', arguments: { tour, participant, table, siege } }
: { commande: 'echangerDansRetenu', arguments: { tour, a: participant, b: occupant } };
}
return REMEDES.get(code) ?? null;
}
// Ce que l'examen de la séance dit de chaque placement que nomme une dérive
// {ids, retenu} : le retenu d'abord, quand la dérive le nomme, puis chaque
// proposition de ids, dans l'ordre de ids, avec ses raisons. Un placement
@ -126,7 +139,7 @@ function derivesNommees({ ids = [], retenu = false }, examen) {
*/
export function avertissementsOrdonnes(etatSeance) {
return etatSeance.avertissements.map(({ code, details }) => {
const rendu = { code, details, remede: REMEDES.get(code) ?? null };
const rendu = { code, details, remede: remedeDe(code, details) };
if (code === 'PROPOSITIONS_FAUTIVES') rendu.fautives = etatSeance.fautives;
if (code === 'DERIVE') rendu.derives = derivesNommees(details, etatSeance.examen);
return rendu;

View file

@ -139,6 +139,7 @@ function executeurAMain() {
const dans = (m, selecteur) => m.racine.querySelector(selecteur);
const section = (m) => dans(m, '[data-section="generation"]');
const champ = (m, cle) => section(m).querySelector(`input[data-champ="${cle}"]`);
const bascule = (m, cle) => section(m).querySelector(`input[type="checkbox"][data-champ="${cle}"]`);
const etiquette = (m, cle) => section(m).querySelector(`label[for="${champ(m, cle).id}"]`);
const generer = (m) => section(m).querySelector('[data-commande="generer"]');
const refusPres = (element, code) => element.parentElement.querySelector(`[data-refus="${code}"]`);
@ -278,7 +279,7 @@ async function regler(m, reglages) {
describe('les réglages (§ 5.7, § 5.10)', () => {
test('exécuteur direct : quatre champs, chacun son libellé et son unité ; l’historique pré-rempli à 1 000 ; aucun champ de durée', async () => {
const m = await demonstration('petite', { executeur: creerExecuteurDirect() });
const entrees = [...section(m).querySelectorAll('input')];
const entrees = [...section(m).querySelectorAll('input:not([type="checkbox"])')];
expect(entrees.length).toBe(4);
expect(entrees.map((entree) => entree.dataset.champ)).toEqual(CHAMPS);
for (const cle of CHAMPS) {
@ -370,6 +371,137 @@ describe('les réglages (§ 5.7, § 5.10)', () => {
});
});
describe('les contraintes et l’attribution des sièges (§ 5.3)', () => {
// Les cinq bascules, dans l'ordre du § 5.3.
const BASCULES = ['separerAppartenances', 'nouveauxVoisins', 'nouvelleTable', 'varierAppartenances', 'attribuerSieges'];
const NOMS_DU_SPEC = [
'Séparer les appartenances',
'Nouveaux voisins à chaque tour',
'Nouvelle table à chaque tour',
'Varier les appartenances rencontrées',
'Attribuer les sièges',
];
test('direct : cinq bascules sous leur nom du § 5.3 ; chacune, une entrée, son réglage seul', async () => {
const m = await demonstration('petite', { executeur: creerExecuteurDirect() });
const cases = [...section(m).querySelectorAll('input[type="checkbox"]')];
expect(cases.map((caseACocher) => caseACocher.dataset.champ)).toEqual(BASCULES);
BASCULES.forEach((cle, rang) => {
expect(etiquette(m, cle).textContent).toBe(t(`generation.contrainte.${cle}`));
expect(t(`generation.contrainte.${cle}`)).toBe(NOMS_DU_SPEC[rang]);
expect(bascule(m, cle).checked, cle).toBe(charge(m).reglages[cle]);
});
for (const cle of BASCULES) {
const avant = structuredClone(charge(m).reglages);
const r0 = revision(m);
const entrees = journalDe(m).entrees.length;
await cliquer(m, bascule(m, cle));
expect(revision(m), cle).toBe(r0 + 1);
expect(journalDe(m).entrees.length, cle).toBe(entrees + 1);
expect(charge(m).reglages, cle).toEqual({ ...avant, [cle]: !avant[cle] });
expect(bascule(m, cle).checked, cle).toBe(!avant[cle]);
}
// La barre d'espace bascule aussi, par le même geste.
const cle = BASCULES[0];
const avant = charge(m).reglages[cle];
bascule(m, cle).focus();
await touche(' ');
await passer(m);
expect(charge(m).reglages[cle]).toBe(!avant);
expect(bascule(m, cle).checked).toBe(!avant);
});
test('direct : bascule refusée en lecture (« Modifier ») et sur un plan bloqué (« Débloquer »), rien d’écrit', async () => {
const m = await demonstration('petite', { executeur: creerExecuteurDirect(), ecriture: false });
const cle = 'attribuerSieges';
const valeur = charge(m).reglages[cle];
const ecritures = m.trace.length;
await cliquer(m, bascule(m, cle));
const lecture = refusPres(bascule(m, cle), 'MODE_LECTURE');
expect(lecture).not.toBeNull();
expect(lecture.querySelector('[data-commande="modifier"]').textContent).toBe(t('communs.remede.modifier'));
expect(bascule(m, cle).checked).toBe(valeur);
expect(charge(m).reglages[cle]).toBe(valeur);
expect(m.trace.length).toBe(ecritures);
await m.seance.passerEnEcriture();
await m.seance.executer('changerEtat', { etat: 'bloque' });
flushSync();
const ecrituresBloque = m.trace.length;
await cliquer(m, bascule(m, 'nouvelleTable'));
const bloque = refusPres(bascule(m, 'nouvelleTable'), 'LECTURE_SEULE');
expect(bloque).not.toBeNull();
expect(bloque.querySelector('[data-commande="debloquer"]').textContent).toBe(t('communs.remede.debloquer'));
expect(bascule(m, 'nouvelleTable').checked).toBe(charge(m).reglages.nouvelleTable);
expect(m.trace.length).toBe(ecrituresBloque);
expect(fenetre()).toBeNull();
});
});
describe('« Générer » derrière la file de la séance (§ 5.10, § 8.2)', () => {
test('relevé, disque lent : « Générer » pressé en quittant le compte d’arrêt part après son écriture, sur l’arrêt saisi', async () => {
const { executeur, lancements } = releve(creerExecuteurDirect());
const m = await demonstration('petite', { executeur });
// Le disque tient chaque écriture jusqu'à ce que l'épreuve la libère.
const tenues = [];
for (const nom of ['ecrireAtomique', 'ajouterLigne']) {
const primitive = m.fs[nom];
m.fs[nom] = (...args) =>
new Promise((resoudre, rejeter) => {
tenues.push(() => Promise.resolve(primitive.apply(m.fs, args)).then(resoudre, rejeter));
});
}
const arret = champ(m, 'generation.arret');
await cliquer(m, arret);
arret.select();
for (const caractere of '1000') await touche(caractere);
const bouton = generer(m);
bouton.scrollIntoView({ block: 'center' });
const { left, top, width, height } = bouton.getBoundingClientRect();
await pointer('presser', left + width / 2, top + height / 2);
await pointer('relacher', left + width / 2, top + height / 2);
await images(5);
expect(tenues.length, 'une écriture tenue').toBeGreaterThan(0);
expect(lancements, 'rien ne part avant l’écriture du réglage').toEqual([]);
for (let image = 0; lancements.length === 0; image += 1) {
expect(image, 'images avant le lancement').toBeLessThan(600);
for (const liberer of tenues.splice(0)) liberer();
await images(1);
}
expect(lancements[0].reglages.arret).toBe(1000);
expect(charge(m).reglages.generation.arret).toBe(1000);
// Les écritures de l'enregistrement passent, et l'issue s'affiche.
for (let image = 0; dans(m, '[data-issue]') === null; image += 1) {
expect(image, 'images avant l’issue').toBeLessThan(600);
for (const liberer of tenues.splice(0)) liberer();
await images(1);
}
});
});
describe('le travailleur qui ne démarre pas (§ 5.9, § 5.10)', () => {
test('travailleur qui ne se charge pas : TRAVAILLEUR_EN_ECHEC près de « Générer », rien d’écrit', async () => {
const adresse = URL.createObjectURL(new Blob(['ceci ( ne se compile pas'], { type: 'text/javascript' }));
const suivi = { travailleurs: 0 };
const executeur = creerExecuteurTravailleur(() => {
suivi.travailleurs += 1;
return new Worker(adresse);
});
const m = await demonstration('petite', { executeur });
const ecritures = m.trace.length;
await cliquer(m, generer(m));
await attendre(() => expect(refusPres(generer(m), 'TRAVAILLEUR_EN_ECHEC')).not.toBeNull());
const refus = refusPres(generer(m), 'TRAVAILLEUR_EN_ECHEC');
expect(refus.querySelector('p').textContent).toBe(t('TRAVAILLEUR_EN_ECHEC', { remede: null }));
expect(suivi.travailleurs).toBe(1);
expect(enCours(m)).toBe(false);
expect(section(m).getAttribute('aria-busy')).toBe('false');
expect(m.trace.length).toBe(ecritures);
expect(fenetre()).toBeNull();
URL.revokeObjectURL(adresse);
});
});
describe('le travailleur (§ 5.7, § 5.10, § 15.5)', () => {
test('exécuteur du travailleur : petite démonstration, graine écrite — plans, graines dérivées et identifiants égaux à rechercher appelé ici', async () => {
const { executeur: travailleur, suivi } = executeurTravailleur();

View file

@ -18,6 +18,14 @@
// en lecture, que le parent tient (graine, liée) et contrôle au clic de
// « Générer ». Son refus, refusGraine, se pose contre elle ; une frappe
// appelle saisirGraine, qui le retire.
//
// Les quatre contraintes et l'attribution des sièges (§ 5.3) sont des
// cases à cocher, chacune sous son nom du spec : un clic, ou la barre
// d'espace, exécute modifierReglage {cle, valeur} — une entrée par
// bascule. À l'issue du geste, la case montre la valeur de la charge,
// écrite ou inchangée. Un refus de la séance — lecture, plan
// bloqué, support rompu — se pose à côté de la case, avec son remède, et
// rien ne s'écrit.
import { getContext } from 'svelte';
import Champ from '../primitives/Champ.svelte';
import Refus from '../primitives/Refus.svelte';
@ -45,6 +53,36 @@
};
const ecrire = (nom) => (valeur) => seance.executer('modifierReglage', { cle: `generation.${nom}`, valeur });
// Les bascules, dans l'ordre du § 5.3 : la clé du réglage et celle de son
// libellé.
const BASCULES = [
{ cle: 'separerAppartenances', etiquette: 'generation.contrainte.separerAppartenances' },
{ cle: 'nouveauxVoisins', etiquette: 'generation.contrainte.nouveauxVoisins' },
{ cle: 'nouvelleTable', etiquette: 'generation.contrainte.nouvelleTable' },
{ cle: 'varierAppartenances', etiquette: 'generation.contrainte.varierAppartenances' },
{ cle: 'attribuerSieges', etiquette: 'generation.contrainte.attribuerSieges' },
];
// Le refus de la dernière bascule, { cle, refus }, posé à côté d'elle.
let refusBascule = $state.raw(null);
// Le clic sur une case : la séance écrit la valeur contraire de celle de
// la charge ; à l'issue, la case montre celle de la charge, écrite ou
// inchangée. Annuler le clic ne suffirait pas : la séance peut avoir écrit
// avant que le navigateur rétablisse la case, à la fin de l'événement.
async function basculer(evenement, cle) {
const caseACocher = evenement.currentTarget;
refusBascule = null;
try {
await seance.executer('modifierReglage', { cle, valeur: !charge.reglages[cle] });
} catch (erreur) {
if (typeof erreur?.code !== 'string') throw erreur;
refusBascule = { cle, refus: { code: erreur.code, details: erreur.details ?? {} } };
} finally {
caseACocher.checked = charge.reglages[cle];
}
}
</script>
<div class="reglages" data-reglages>
@ -74,6 +112,24 @@
/>
{/each}
</div>
<fieldset class="bascules">
<legend>{t('generation.contraintes.titre')}</legend>
{#each BASCULES as { cle, etiquette } (cle)}
<div class="bascule">
<input
id={`${uid}-${cle}`}
type="checkbox"
data-champ={cle}
checked={charge.reglages[cle]}
onclick={(evenement) => basculer(evenement, cle)}
/>
<label for={`${uid}-${cle}`}>{t(etiquette)}</label>
{#if refusBascule !== null && refusBascule.cle === cle}
<Refus refus={refusBascule.refus} lever={() => (refusBascule = null)} />
{/if}
</div>
{/each}
</fieldset>
{#if mesure === null}
<p data-duree>{t('generation.duree.inconnue')}</p>
{:else}
@ -122,6 +178,32 @@
border-color: var(--couleur-refus);
}
.bascules {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: var(--espace-2) var(--espace-4);
margin: 0;
padding: var(--espace-2) var(--espace-3);
border: 1px solid var(--couleur-page-filet);
border-radius: var(--rayon-1);
}
legend {
padding: 0 var(--espace-1);
}
.bascule {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--espace-1);
}
.bascule input {
margin: 0;
}
p {
margin: 0;
}

View file

@ -85,12 +85,17 @@
}
// Lance une génération sur la graine saisie et les réglages de la charge ;
// confirmee : la réponse à la question d'une génération longue.
// confirmee : la réponse à la question d'une génération longue. Les
// réglages se lisent quand la file de la séance a passé ce qui s'y
// trouve : un réglage quitté par le clic même s'écrit d'abord, et la
// génération part sur lui, quelle que soit la lenteur du disque.
async function lancer(confirmee) {
refusGraine = null;
issue = null;
if (!confirmee) question = null;
const { reglages, refus } = generation.valider({ ...generation.reglagesProposes(), graine });
const saisie = graine;
await seance.attendre();
const { reglages, refus } = generation.valider({ ...generation.reglagesProposes(), graine: saisie });
const deGraine = refus.find(({ champ }) => champ === 'graine');
if (deGraine !== undefined) {
refusGraine = { code: deGraine.code, details: deGraine.details };

View file

@ -34,6 +34,7 @@ import { creerCharge } from '../../stockage/document.js';
import { lireJournal } from '../../stockage/journal.js';
import { SUFFIXES } from '../../stockage/noms.js';
import { VERSION } from '../../version.genere.js';
import { pointer } from '../../../test/navigateur/entrees.js';
import { monterApp } from '../../../test/navigateur/montage.js';
import { visible } from '../../../test/navigateur/rendu.js';
import urlCasLimites from '../../../exemples/participants_cas_limites.csv?url';
@ -551,6 +552,41 @@ describe('une seule entrée, des doublons signalés, une mise à jour qui n’ef
expect(etats.length).toBe(1);
});
test('deux clics réels sur « Importer » pendant l’écriture d’un support lent : la liste s’importe une fois, une entrée du journal', async () => {
const e = await ouvrir({ prepare: (seance) => seance.executer('creerTable', { forme: 'ronde' }) });
await coller(e, 'nom;prenom\nOmbrelle;Iris\nPervenche;Théo\nLacasse;Ondine');
dans(e, '[data-champ="mode"] input[value="ajouter"]').click();
flushSync();
const entreesAvant = entrees(e);
// Le journal du système simulé retient ses ajouts de ligne jusqu'à
// liberer() : l'import reste en cours d'écriture.
const ajouterLigne = e.m.fs.ajouterLigne;
let liberer;
const porte = new Promise((resoudre) => {
liberer = resoudre;
});
e.m.fs.ajouterLigne = async (...parametres) => {
await porte;
return ajouterLigne.apply(e.m.fs, parametres);
};
const bouton = commande(e, 'importer');
bouton.scrollIntoView({ block: 'center' });
const boite = bouton.getBoundingClientRect();
const [x, y] = [boite.left + boite.width / 2, boite.top + boite.height / 2];
for (let clic = 0; clic < 2; clic += 1) {
await pointer('presser', x, y);
await pointer('relacher', x, y);
flushSync();
}
await vi.waitFor(() => expect(imports(e).length).toBeGreaterThan(0));
expect(imports(e)).toHaveLength(1);
liberer();
await vi.waitFor(() => expect(dans(e, '[data-rapport]')).not.toBeNull());
await e.m.seance.formeNommee(null).catch(() => undefined);
expect(e.m.seance.etat().charge.participants.map(({ nom }) => nom)).toEqual(['Ombrelle', 'Pervenche', 'Lacasse']);
expect(entrees(e)).toBe(entreesAvant + 1);
});
test('deux lignes au même triplet et une égale à un existant : deux doublons ; en mode Ajouter, choisi, trois personnes de plus, d’identifiants distincts', async () => {
const e = await ouvrir({ participants: [personne(1, 'Ombrelle', 'Iris', { appartenance: 'Grisaille' })] });
await choisir(

View file

@ -26,6 +26,14 @@ export const TABLE = new Map([
['generation.reglage.arret', "Compte d'arrêt (mouvements évalués par proposition)"],
['generation.reglage.historique', "Historique d'acceptation (longueur, en mouvements)"],
// --- Les contraintes et l'attribution des sièges, sous leur nom du § 5.3 ---
['generation.contraintes.titre', 'Contraintes de la génération'],
['generation.contrainte.separerAppartenances', 'Séparer les appartenances'],
['generation.contrainte.nouveauxVoisins', 'Nouveaux voisins à chaque tour'],
['generation.contrainte.nouvelleTable', 'Nouvelle table à chaque tour'],
['generation.contrainte.varierAppartenances', 'Varier les appartenances rencontrées'],
['generation.contrainte.attribuerSieges', 'Attribuer les sièges'],
// --- La durée du dernier calcul de cette séance (§ 5.10) --------------------
[
'generation.duree.inconnue',

View file

@ -81,6 +81,7 @@ export const TABLE = new Map([
['participants.fiche.itineraire', 'Placement retenu, tour par tour'],
['participants.fiche.tour', 'Tour'],
['participants.fiche.enReserve', 'en réserve'],
['participants.fiche.horsRetenu', 'hors du placement retenu'],
['participants.fiche.siegeNonAttribue', 'non attribué'],
['participants.fiche.aucunRetenu', 'Aucun placement retenu ne la nomme.'],

View file

@ -84,12 +84,24 @@ export const TABLE = new Map([
['plan.vue.listes', 'Listes de noms'],
['plan.vue.pleinEcran', 'Plein écran'],
['plan.vue.dessin', 'Plan de salle'],
// Le placement que le plan montre, et la commande qui quitte une
// proposition montrée.
['plan.source.retenu', 'Placement retenu'],
['plan.source.reservations', 'Réservations'],
['plan.source.retenuFautif', 'Réservations : le placement retenu ne se lit pas'],
['plan.source.propositionReservations', ({ proposition }) => `Proposition n° ${proposition} : un geste y pose une réservation`],
['plan.source.propositionVueSeule', ({ proposition }) => `Proposition n° ${proposition}, vue seule`],
['plan.source.revenirRetenu', 'Revenir au placement retenu'],
['plan.source.revenirReservations', 'Revenir aux réservations'],
// --- Les gestes du pointeur (§ 5.8, § 7.1, § 7.3) -------------------------
// Le fantôme d'une personne glissée dit ce que le geste modifie : l'état
// des données, non le geste (§ 5.8). Le nom de la personne est une donnée,
// dans son propre nœud.
// des données, non le geste (§ 5.8) ; une personne réservée déplace sa
// réservation, dont il dit la portée. Le nom de la personne est une
// donnée, dans son propre nœud.
['plan.fantome.reservations', 'pose une réservation'],
['plan.fantome.reservationTour', 'déplace sa réservation de ce tour'],
['plan.fantome.reservationTous', 'déplace sa réservation, à tous les tours'],
['plan.fantome.retenu', 'déplace dans le placement retenu'],
['plan.fantome.vueSeule', 'proposition en vue seule : rien ne change'],
// La place où un refus ou un avertissement du plan se pose.
@ -163,6 +175,7 @@ export const TABLE = new Map([
// --- La mise en évidence d'une personne (§ 7.1, § 8.4) ----------------------
// Le nom de la personne mise en évidence est une donnée dans son nœud.
['plan.evidence.personne', 'Mise en évidence : '],
['plan.evidence.absente', 'une personne qui n\u{2019}est plus dans la liste'],
['plan.evidence.oter', 'Ôter la mise en évidence'],
['plan.evidence.voir', 'Voir sur le plan'],

View file

@ -4,7 +4,8 @@
<script>
// La fiche d'une personne à l'écran (§ 4, § 4.2, § 7.4) : nom, prénom,
// appartenance, courriel, titre pressenti, notes, statut calculé, ses
// réservations, et sa table et son siège à chaque tour du retenu. La fiche
// réservations, et sa table et son siège à chaque tour du retenu ; un
// tour que le retenu ne porte pas se dit hors du retenu. La fiche
// replie, ne tronque jamais : un nom de deux cents caractères s'y lit
// entier, sur autant de lignes qu'il en faut. Chaque donnée occupe son
// propre nœud de texte, et rien n'y est interprété comme du balisage.
@ -115,6 +116,8 @@
<td>{etape.tour}</td>
{#if etape.reserve}
<td colspan="2">{t('participants.fiche.enReserve')}</td>
{:else if etape.horsRetenu}
<td colspan="2">{t('participants.fiche.horsRetenu')}</td>
{:else}
<td>{etape.numero}</td>
<td>

View file

@ -10,23 +10,37 @@
// refus — le mode, l'état du plan — se pose contre « Ajouter ».
//
// L'appartenance est collante : après un ajout, chaque champ se vide sauf
// elle. Une appartenance qui ne diffère d'une existante que par la casse,
// elle, et l'état de navigation la garde (garderAppartenance) : le
// formulaire remonté — retour d'une autre section, fermeture d'« À propos »
// — repart d'elle. Une appartenance qui ne diffère d'une existante que par la casse,
// les accents ou les blancs (orthographeExistante, grille.js) est signalée
// contre son champ, avec l'orthographe existante à reprendre d'un clic ;
// rien ne fusionne seul.
//
// Une saisie non envoyée — un champ rempli, l'appartenance exceptée — est
// un geste ouvert (geste.js), qui suspend le compte d'inactivité.
import { getContext, onDestroy, tick } from 'svelte';
//
// L'envoi n'est pas ré-entrant : tant que l'ajout s'écrit, une seconde
// soumission ne fait rien, et « Ajouter » porte aria-busy et
// aria-disabled. Le formulaire envoie une copie des champs ; l'ajout
// abouti, il ne vide que ceux qui portent encore la valeur envoyée : ce
// que l'opérateur tape pendant l'écriture, la personne suivante, reste.
// Le focus revient au nom quand rien n'a été tapé entre-temps.
//
// Les notes sont une zone de texte, qui garde les sauts de ligne :
// Maj+Entrée y insère un saut de ligne, Entrée seule envoie
// (entreeValide, grille.js).
import { getContext, onDestroy, tick, untrack } from 'svelte';
import { cleNormalisee } from '../../application/csv.js';
import { creerGeste } from '../primitives/geste.js';
import Refus from '../primitives/Refus.svelte';
import { cleDeColonne, orthographeExistante } from './grille.js';
import { cleDeColonne, entreeValide, orthographeExistante } from './grille.js';
let { participants } = $props();
const t = getContext('t');
const seance = getContext('seance');
const ui = getContext('ui');
const uid = $props.id();
const geste = creerGeste(seance);
@ -35,10 +49,20 @@
// Ceux qu'un ajout vide : tous, l'appartenance exceptée.
const EPHEMERES = CHAMPS.filter((cle) => cle !== 'appartenance');
let valeurs = $state({ nom: '', prenom: '', appartenance: '', courriel: '', titrePressenti: '', notes: '' });
let valeurs = $state({
nom: '',
prenom: '',
appartenance: untrack(() => ui.etat().appartenance),
courriel: '',
titrePressenti: '',
notes: '',
});
let refusNom = $state.raw(null);
let refusAjout = $state.raw(null);
let entreeNom = $state(null);
let formulaire;
// Vrai pendant l'écriture d'un ajout.
let envoiEnCours = $state(false);
const orthographe = $derived(orthographeExistante(valeurs.appartenance, participants, cleNormalisee));
const idRefusNom = `${uid}-refus-nom`;
@ -55,6 +79,7 @@
async function ajouter(evenement) {
evenement.preventDefault();
if (envoiEnCours) return;
refusNom = null;
refusAjout = null;
const champs = {
@ -65,6 +90,7 @@
titrePressenti: valeurs.titrePressenti,
notes: valeurs.notes,
};
envoiEnCours = true;
try {
await seance.executer('ajouterParticipant', { champs });
} catch (erreur) {
@ -73,12 +99,25 @@
if (refus.code === 'NOM_REQUIS') refusNom = refus;
else refusAjout = refus;
return;
} finally {
envoiEnCours = false;
}
valeurs = { nom: '', prenom: '', appartenance: valeurs.appartenance, courriel: '', titrePressenti: '', notes: '' };
ui.garderAppartenance(champs.appartenance);
const intacts = EPHEMERES.filter((cle) => valeurs[cle] === champs[cle]);
for (const cle of intacts) valeurs[cle] = '';
if (intacts.length < EPHEMERES.length) return;
await tick();
entreeNom?.focus();
}
// Entrée dans les notes envoie le formulaire ; Maj+Entrée y garde le
// saut de ligne de la zone de texte.
function surToucheNotes(evenement) {
if (evenement.key !== 'Enter' || !entreeValide({ maj: evenement.shiftKey, multiligne: true })) return;
evenement.preventDefault();
formulaire.requestSubmit();
}
function reprendre() {
valeurs.appartenance = orthographe;
}
@ -86,7 +125,7 @@
onDestroy(() => geste.liberer());
</script>
<form novalidate aria-labelledby={`${uid}-titre`} onsubmit={ajouter}>
<form novalidate aria-labelledby={`${uid}-titre`} onsubmit={ajouter} bind:this={formulaire}>
<h3 id={`${uid}-titre`}>{t('participants.formulaire.titre')}</h3>
<div class="champs">
{#each CHAMPS as cle (cle)}
@ -122,6 +161,8 @@
</button>
</div>
{/if}
{:else if cle === 'notes'}
<textarea id={`${uid}-${cle}`} rows="2" data-champ={cle} bind:value={valeurs.notes} onkeydown={surToucheNotes}></textarea>
{:else}
<input id={`${uid}-${cle}`} type="text" data-champ={cle} bind:value={valeurs[cle]} />
{/if}
@ -129,7 +170,12 @@
{/each}
</div>
<div class="issue">
<button type="submit" data-commande="ajouter">{t('participants.formulaire.ajouter')}</button>
<button
type="submit"
data-commande="ajouter"
aria-busy={envoiEnCours ? 'true' : undefined}
aria-disabled={envoiEnCours ? 'true' : undefined}>{t('participants.formulaire.ajouter')}</button
>
{#if refusAjout !== null}
<Refus refus={refusAjout} lever={() => (refusAjout = null)} />
{/if}
@ -162,7 +208,8 @@
max-width: 18rem;
}
input {
input,
textarea {
padding: var(--espace-1) var(--espace-2);
border: 1px solid var(--couleur-page-filet);
border-radius: var(--rayon-1);
@ -177,6 +224,7 @@
}
input:focus-visible,
textarea:focus-visible,
button:focus-visible {
outline: 2px solid var(--couleur-focus);
outline-offset: 1px;
@ -218,4 +266,10 @@
font-size: var(--taille-texte);
cursor: pointer;
}
button[aria-disabled='true'] {
border-style: dashed;
color: var(--couleur-page-texte-secondaire);
cursor: progress;
}
</style>

View file

@ -13,7 +13,8 @@
// machine de champ.js tient : Entrée, Tab ou la sortie du champ valident,
// Échap rend la valeur enregistrée. Les notes s'éditent dans une zone de
// texte, qui garde leurs sauts de ligne ; un champ d'une ligne les
// retirerait de toute note retouchée. Une validation qui change la valeur
// retirerait de toute note retouchée. Maj+Entrée y insère un saut de
// ligne, Entrée seule valide (entreeValide). Une validation qui change la valeur
// exécute modifierParticipant par la séance, sur ce seul champ ; une
// saisie qui se lit comme la valeur enregistrée — en NFC, sans blancs de
// bord — n'exécute rien. Entrée sur la cellule d'exclusion exclut ou
@ -34,7 +35,7 @@
import Commande from '../primitives/Commande.svelte';
import { creerGeste } from '../primitives/geste.js';
import Refus from '../primitives/Refus.svelte';
import { COLONNES, celluleSuivante, cleDeColonne } from './grille.js';
import { COLONNES, celluleSuivante, cleDeColonne, entreeValide } from './grille.js';
let { lignes, tri = $bindable(null), supprimer } = $props();
@ -179,6 +180,7 @@
evenement.preventDefault();
abandonner();
} else if (touche === 'Enter') {
if (!entreeValide({ maj: evenement.shiftKey, multiligne: edition.colonne === 'notes' })) return;
evenement.preventDefault();
valider();
} else if (touche === 'Tab') {

View file

@ -180,6 +180,15 @@ async function majTab() {
await cdp().send('Input.dispatchKeyEvent', { type: 'keyUp', ...commun });
}
// Maj+Entrée réel, le saut de ligne d'une zone de texte ; 8 est le masque
// de Maj.
async function majEntree() {
const commun = { key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13, modifiers: 8 };
await cdp().send('Input.dispatchKeyEvent', { type: 'keyDown', ...commun, text: '\r', unmodifiedText: '\r' });
await cdp().send('Input.dispatchKeyEvent', { type: 'keyUp', ...commun });
await rendu();
}
// La valeur de la fiche que nomme l'étiquette de clé donnée.
function valeurDeFiche(m, cle) {
const etiquette = [...fiche(m).querySelectorAll('dt')].find((dt) => dt.textContent === t(cle));
@ -199,6 +208,23 @@ function ecart(a, b) {
return Math.max(dx, dy);
}
// Retient les ajouts de ligne au journal du système simulé jusqu'à
// liberer() : l'écriture d'un geste reste en cours, comme sur un support
// lent, sans délai ni minuterie. L'application lit la primitive sur fs au
// moment du geste.
function retenirJournal(m) {
const ajouterLigne = m.fs.ajouterLigne;
let liberer;
const porte = new Promise((resoudre) => {
liberer = resoudre;
});
m.fs.ajouterLigne = async (...parametres) => {
await porte;
return ajouterLigne.apply(m.fs, parametres);
};
return () => liberer();
}
const aucuneFenetre = () => expect(document.querySelectorAll('dialog[open], [role=dialog], [role=alertdialog]').length).toBe(0);
describe('la saisie (§ 4, § 10.2)', () => {
@ -227,6 +253,53 @@ describe('la saisie (§ 4, § 10.2)', () => {
expect(participants(m).at(-1).nom).toBe('Ombr\u{00E9}lle');
});
test('une seconde Entrée pendant l’écriture n’ajoute rien ; ce qui est tapé pendant l’écriture reste, ce qui a été envoyé se vide (§ 10.2)', async () => {
const m = await evenementOuvert();
const avant = entrees(m).length;
const liberer = retenirJournal(m);
const ajout = () => section(m).querySelector('form [data-commande="ajouter"]');
expect(ajout()).not.toBeNull();
const styleRepos = getComputedStyle(ajout()).borderTopStyle;
expect(ajout().getAttribute('aria-busy')).toBeNull();
expect(ajout().getAttribute('aria-disabled')).toBeNull();
await saisir(champ(m, 'nom'), 'Ombrelle');
await saisir(champ(m, 'notes'), 'arrive tard');
champ(m, 'nom').focus();
await touche('Enter');
await rendu();
// L'écriture retenue : « Ajouter » se dit occupé, d'un filet tireté.
expect(ajout().getAttribute('aria-busy')).toBe('true');
expect(ajout().getAttribute('aria-disabled')).toBe('true');
expect(getComputedStyle(ajout()).borderTopStyle).toBe('dashed');
expect(getComputedStyle(ajout()).borderTopStyle).not.toBe(styleRepos);
await touche('Enter');
await rendu();
// La personne suivante se tape pendant l'écriture de la première.
await saisir(champ(m, 'prenom'), 'Théo');
await saisir(champ(m, 'nom'), 'Pervenche');
liberer();
await vi.waitFor(() => expect(participants(m)).toHaveLength(1));
await attendreSeance(m);
await rendu();
expect(ajout().getAttribute('aria-busy')).toBeNull();
expect(ajout().getAttribute('aria-disabled')).toBeNull();
expect(getComputedStyle(ajout()).borderTopStyle).toBe(styleRepos);
expect(participants(m)).toHaveLength(1);
expect(participants(m)[0]).toMatchObject({ nom: 'Ombrelle', prenom: null, notes: 'arrive tard' });
expect(entrees(m)).toHaveLength(avant + 1);
expect(champ(m, 'nom').value).toBe('Pervenche');
expect(champ(m, 'prenom').value).toBe('Théo');
expect(champ(m, 'notes').value).toBe('');
// La saisie gardée s'envoie à son tour.
champ(m, 'nom').focus();
await touche('Enter');
await attendreSeance(m);
expect(participants(m).map((p) => [p.nom, p.prenom])).toEqual([
['Ombrelle', null],
['Pervenche', 'Théo'],
]);
});
test('nom vide : « ␠␠ » pose sur le champ aria-invalid et un message visible qui le jouxte ; aucune fenêtre, zéro écriture', async () => {
const m = await evenementOuvert();
const trace = m.trace.length;
@ -262,6 +335,28 @@ describe('la saisie (§ 4, § 10.2)', () => {
expect(participants(m).at(-1).appartenance).toBeNull();
});
test('l’appartenance collante survit à un aller-retour par le plan et par « À propos » ; un autre événement repart d’un champ vide', async () => {
const m = await evenementOuvert();
await ajouter(m, { nom: 'Ombrelle', appartenance: CLUB });
m.ui.allerA('plan');
await rendu();
m.ui.allerA('participants');
await rendu();
expect(champ(m, 'appartenance').value).toBe(CLUB);
m.ui.ouvrirApropos();
await rendu();
m.ui.fermerApropos();
await rendu();
expect(champ(m, 'appartenance').value).toBe(CLUB);
await ajouter(m, { nom: 'Pervenche' });
expect(participants(m).at(-1).appartenance).toBe(CLUB);
await m.seance.fermer();
await m.seance.creer({ nom: 'Bal des Grisailles', date: null, siegesParDefaut: 8, tours: 2 });
m.ui.allerA('participants');
await rendu();
expect(champ(m, 'appartenance').value).toBe('');
});
test('une appartenance qui ne diffère d’une existante que par la casse et les blancs est signalée sur le champ, l’orthographe existante à reprendre ; rien ne fusionne seul', async () => {
const m = await evenementOuvert({ personnes: [{ nom: 'Ombrelle', prenom: 'Iris', appartenance: CLUB }] });
const trace = m.trace.length;
@ -410,6 +505,34 @@ describe('la grille (§ 10.2, § 19.3)', () => {
expect(participants(m).find((p) => p.id === id).notes).toBe('arrive tard\nrepart tôt x');
});
test('une note saisie sur deux lignes : Maj+Entrée insère le saut de ligne, dans la grille comme au formulaire ; Entrée valide (§ 10.2, § 10.3)', async () => {
const m = await evenementOuvert();
// Au formulaire : la note de deux lignes, puis Entrée dans les notes envoie.
await saisir(champ(m, 'nom'), 'Lacasse');
champ(m, 'notes').focus();
for (const caractere of 'a') await touche(caractere);
await majEntree();
for (const caractere of 'b') await touche(caractere);
await touche('Enter');
await attendreSeance(m);
expect(participants(m)).toHaveLength(1);
expect(participants(m)[0].notes).toBe('a\nb');
// Dans la grille : la cellule des notes, éditée, reçoit une troisième ligne.
const id = idDe(m, 'Lacasse');
const avant = entrees(m).length;
cellule(m, id, 'notes').focus();
await touche('Enter');
await rendu();
await majEntree();
for (const caractere of 'c') await touche(caractere);
await rendu();
expect(entrees(m)).toHaveLength(avant);
await touche('Enter');
await attendreSeance(m);
expect(entrees(m)).toHaveLength(avant + 1);
expect(participants(m)[0].notes).toBe('a\nb\nc');
});
test('trier par nom, croissant puis décroissant, filtrer « merles » : l’ordre et les lignes suivent ; rien ne s’écrit, en écriture comme en lecture', async () => {
const m = await evenementOuvert({ personnes: TROIS });
const parNom = (sens) =>
@ -668,6 +791,34 @@ describe('la fiche (§ 4.2, § 7.4)', () => {
}
});
test('retenu à 3 tours, l’événement passé à 4 : la fiche garde les tours du retenu, et dit le quatrième hors du retenu (§ 9)', async () => {
const m = await evenementOuvert({ sieges: 3, tours: 3, tables: 2, personnes: NOMS.slice(0, 6).map((nom) => ({ nom })) });
await m.seance.executer('modifierReglage', { cle: 'attribuerSieges', valeur: true });
await m.app.generation().generer({ graine: 1, nombre: 1, arret: 50, historique: 1000 });
const [proposition] = m.seance.etat().charge.propositions;
await m.seance.executer('retenirProposition', { id: proposition.id });
await m.seance.executer('changerTours', { tours: 4 });
await rendu();
const charge = m.seance.etat().charge;
expect(charge.evenement.tours).toBe(4);
expect(charge.retenu.placement).toHaveLength(3);
const forme = formeNommee(charge.retenu, charge);
expect(charge.participants).toHaveLength(6);
for (const { id: personne } of charge.participants) {
m.ui.ouvrirFiche(personne);
await rendu();
expect(fiche(m).textContent).not.toContain(t('participants.fiche.aucunRetenu'));
const tableau = tableauDeFiche(m, 'participants.fiche.itineraire');
expect(tableau).toBeDefined();
const lus = [...tableau.tBodies[0].rows].map((rangee) => [...rangee.cells].map((c) => c.textContent.trim()));
const attendus = forme
.filter((l) => l.participant === personne)
.map((l) => (l.reserve ? [String(l.tour), t('participants.fiche.enReserve')] : [String(l.tour), String(l.numero), String(l.siege)]));
expect(attendus).toHaveLength(3);
expect(lus).toEqual([...attendus, ['4', t('participants.fiche.horsRetenu')]]);
}
});
test('un nom de deux cents caractères se lit entier sur la fiche, replié, sans marque ni débordement', async () => {
const long = 'Ombrelle'.repeat(25);
expect(long).toHaveLength(200);

View file

@ -179,20 +179,27 @@ export function orthographeExistante(saisie, participants, cleNormalisee) {
* L'itinéraire d'une personne dans le retenu, pour la fiche : à chaque
* tour, de 1 à tours, la table (numéro affiché) et le siège — null quand le
* retenu n'attribue pas les sièges —, ou la réserve. Lu dans etatDuTour(r)
* = etatPlaces(charge, {tour: r}), jamais recalculé ; null quand la source
* n'y est pas 'retenu' à l'un des tours, ou que la personne n'y est ni
* = etatPlaces(charge, {tour: r}), jamais recalculé. Un tour dont la source
* n'est pas 'retenu' — le retenu gardé à moins de tours que l'événement
* n'en compte, après une hausse des tours (§ 9) — se dit hors du retenu,
* et les tours que le retenu porte restent. null quand le tour 1 n'a pas le
* retenu pour source, ou que la personne n'est, à un tour du retenu, ni
* assise ni en réserve — une exclue, absente de tout tour.
*
* @param {number} id
* @param {number} tours
* @param {(tour: number) => Object} etatDuTour l'EtatPlaces du tour
* @returns {Array<{tour: number, numero: number, siege: number|null}|{tour: number, reserve: true}>|null}
* @returns {Array<{tour: number, numero: number, siege: number|null}|{tour: number, reserve: true}|{tour: number, horsRetenu: true}>|null}
*/
export function itineraire(id, tours, etatDuTour) {
const etapes = [];
for (let tour = 1; tour <= tours; tour += 1) {
const etat = etatDuTour(tour);
if (etat.source !== 'retenu') return null;
if (etat.source !== 'retenu') {
if (tour === 1) return null;
etapes.push({ tour, horsRetenu: true });
continue;
}
let etape = null;
for (const table of etat.tables) {
const chaise = table.chaises.find(({ occupant }) => occupant === id);
@ -208,6 +215,18 @@ export function itineraire(id, tours, etatDuTour) {
return etapes;
}
/**
* Vrai quand Entrée valide la saisie : toujours, sauf Maj+Entrée dans une
* zone de texte — les notes —, qui y insère un saut de ligne.
*
* @param {{maj: boolean, multiligne: boolean}} touche maj : Maj tenue ;
* multiligne : la saisie est une zone de texte
* @returns {boolean}
*/
export function entreeValide({ maj, multiligne }) {
return !(maj && multiligne);
}
/**
* Les réservations d'une personne, pour la fiche et la fenêtre de
* suppression : dans l'ordre de la charge, chacune avec le numéro affiché

View file

@ -14,6 +14,7 @@ import {
celluleSuivante,
cleDeColonne,
cleDeStatut,
entreeValide,
itineraire,
lignesGrille,
nomExport,
@ -218,17 +219,36 @@ describe("itineraire : la personne dans le retenu, tour par tour (§ 7.4)", () =
assert.deepEqual(itineraire(7, 1, (r) => etats.get(r)), [{ tour: 1, numero: 4, siege: null }]);
});
test('source reservations ou proposition, à un tour : null', () => {
test('un tour que le retenu ne porte pas — tours haussés après la retenue — se dit hors du retenu ; les tours du retenu restent (§ 9)', () => {
const retenu = etat('retenu', true, [tableDe(4, [7])], []);
for (const source of ['reservations', 'proposition']) {
const etats = new Map([
[1, retenu],
[2, etat(source, true, [tableDe(4, [7])], [])],
[2, retenu],
[3, etat('retenu', true, [tableDe(4, [5])], [7])],
[4, etat(source, true, [tableDe(4, [7])], [])],
]);
assert.equal(itineraire(7, 2, (r) => etats.get(r)), null, source);
assert.deepEqual(
itineraire(7, 4, (r) => etats.get(r)),
[
{ tour: 1, numero: 4, siege: 1 },
{ tour: 2, numero: 4, siege: 1 },
{ tour: 3, reserve: true },
{ tour: 4, horsRetenu: true },
],
source,
);
}
});
test('sans retenu au tour 1 : null, quelle que soit la source des tours suivants', () => {
const etats = new Map([
[1, etat('reservations', true, [tableDe(4, [7])], [])],
[2, etat('retenu', true, [tableDe(4, [7])], [])],
]);
assert.equal(itineraire(7, 2, (r) => etats.get(r)), null);
});
test("une personne qu'aucune chaise ne reçoit et qui n'attend pas — exclue — : null", () => {
const etats = new Map([[1, etat('retenu', true, [tableDe(4, [5])], [6])]]);
assert.equal(itineraire(7, 1, (r) => etats.get(r)), null);
@ -303,3 +323,18 @@ describe("l'ordre de la grille est celui de l'export (§ 10.2)", () => {
assert.throws(() => cleDeStatut('exclu'), RangeError);
});
});
describe('entreeValide : Entrée valide, Maj+Entrée garde le saut de ligne des notes (§ 10.2, § 10.3)', () => {
test('Entrée seule valide, dans un champ d’une ligne comme dans une zone de texte', () => {
assert.equal(entreeValide({ maj: false, multiligne: false }), true);
assert.equal(entreeValide({ maj: false, multiligne: true }), true);
});
test('Maj+Entrée ne valide pas une zone de texte : elle y insère un saut de ligne', () => {
assert.equal(entreeValide({ maj: true, multiligne: true }), false);
});
test('Maj+Entrée valide un champ d’une ligne, qui n’a pas de saut de ligne', () => {
assert.equal(entreeValide({ maj: true, multiligne: false }), true);
});
});

View file

@ -14,11 +14,18 @@
// evidence : la personne mise en évidence, {id, nom} — nom null pour une
// personne que la charge n'a plus —, ou null. La barre la nomme et offre
// d'ôter la mise en évidence, que la personne soit assise au tour montré
// ou non ; le menu de la personne la pose (MenuPersonne).
// ou non ; le menu de la personne la pose (MenuPersonne). Une personne que
// la charge n'a plus se dit telle, sans nom.
//
// source : ce que la barre dit du placement montré, que rend sourceMontree
// (vue_plan.js) — {libelle, revenir} — : la barre le nomme, [data-source],
// et, quand une proposition est demandée, offre de ne plus la montrer
// (ui.montrerProposition(null)), sous le libellé revenir. Ni l'un ni
// l'autre n'écrit l'événement.
import { getContext } from 'svelte';
import { toursOfferts } from './vue_plan.js';
let { tour, tours, zoomer, ajuster, evidence = null } = $props();
let { tour, tours, zoomer, ajuster, evidence = null, source } = $props();
const t = getContext('t');
const ui = getContext('ui');
@ -27,6 +34,12 @@
</script>
<div class="barre-vue" role="toolbar" aria-label={t('plan.vue.barre')}>
<p class="source" data-source>{t(source.libelle.cle, source.libelle.details)}</p>
{#if source.revenir !== null}
<button type="button" data-commande="quitterProposition" onclick={() => ui.montrerProposition(null)}
>{t(source.revenir)}</button
>
{/if}
<label class="tour">
<span>{t('plan.vue.tour')}</span>
<select data-champ="tour" value={tour} onchange={(evenement) => ui.montrerTour(Number(evenement.currentTarget.value))}>
@ -46,7 +59,9 @@
>
{#if evidence !== null}
<p class="evidence" data-evidence={evidence.id}>
<span>{t('plan.evidence.personne')}</span>{#if evidence.nom !== null}<span>{evidence.nom}</span>{/if}
<span>{t('plan.evidence.personne')}</span>{#if evidence.nom !== null}<span>{evidence.nom}</span>{:else}<span
>{t('plan.evidence.absente')}</span
>{/if}
<button type="button" data-commande="oterEvidence" onclick={() => ui.mettreEnEvidence(null)}>{t('plan.evidence.oter')}</button>
</p>
{/if}
@ -60,6 +75,11 @@
gap: var(--espace-2);
}
.source {
margin: 0;
font-weight: 700;
}
.evidence {
display: flex;
align-items: center;

View file

@ -753,6 +753,39 @@ describe('les seuils de rendu (§ 7.3, § 17 point 6, § 19.10)', () => {
});
describe('dessin et désignation (§ 7.3, § 13.5)', () => {
test('au seuil k = 24/pas de chaque table, la cible d’un siège a la taille du pas : à pas/2 − 1 px de sa chaise, vers sa voisine, le DOM et designer désignent la chaise ; à pas/2 + 1 px, ils désignent la même place', () => {
const charge = chargeDeTables([3, 8, 8, 12], { pas: 800, formes: ['ronde', 'ronde', 'carree', 'carree'] });
let points = 0;
for (const id of [1, 2, 3, 4]) {
const geometrie = modeleDe(charge, { k: 1 }).tables[id - 1].geometrie;
const k = (24 / geometrie.pas) * (1 + 1e-9);
const vue = { k, tx: innerWidth / 2 - k * geometrie.centre.x, ty: innerHeight / 2 };
const modele = modeleDe(charge, { k, listes: false });
expect(modele.tables[id - 1].regime).toBe('sieges');
const geometries = modele.tables.map(({ geometrie: g }) => g);
const { cadre, ecran } = monterPlan(modele, vue);
const { chaises } = geometries[id - 1];
chaises.forEach((chaise, i) => {
const voisine = chaises[(i + 1) % chaises.length];
const c = ecran(chaise.x, chaise.y);
const v = ecran(voisine.x, voisine.y);
const d = Math.hypot(v.x - c.x, v.y - c.y);
for (const [ecart, attendu] of [[-1, `${id}:${chaise.siege}`], [1, undefined]]) {
const portee = (geometrie.pas / 2) * k + ecart;
const p = { x: c.x + ((v.x - c.x) * portee) / d, y: c.y + ((v.y - c.y) * portee) / d };
const parDom = document.elementFromPoint(p.x, p.y)?.closest('[data-siege]')?.dataset.siege ?? null;
const designe = designer(dessinDepuisEcran(vue, cadre, p.x, p.y), geometries, k);
const parGeometrie = designe !== null && designe.siege !== undefined ? `${designe.table}:${designe.siege}` : null;
expect(parDom, `table ${id}, siège ${chaise.siege}, écart ${ecart}`).toBe(parGeometrie);
if (attendu !== undefined) expect(parDom, `table ${id}, siège ${chaise.siege}`).toBe(attendu);
points += 1;
}
});
demonterTout();
}
expect(points).toBe(2 * (3 + 8 + 8 + 12));
});
test('le centre rendu de chaque siège est sa position ; aux points témoins, le DOM et designer désignent la même place ; une liste ne masque aucune chaise', () => {
// La table 2 si près que la liste de la table 1 passe sur ses chaises de
// gauche.

View file

@ -10,7 +10,8 @@
// écrit son attribut transform à chaque image, directement, et le montre
// par la propriété fantome : le nom de la personne, dans son propre nœud,
// puis le libellé de ce que le geste modifie — la clé que cleFantome
// (gestes.js) tire de la cible de l'état des places. Caché, il ne porte
// (gestes.js) tire de la cible de l'état des places et de la portée de la
// réservation qui tient la personne. Caché, il ne porte
// rien ; montré ou non, il ne reçoit pas le pointeur.
//
// fantome : {nom, cle} | null ; element : le <g>, lié pour Plan.svelte.

View file

@ -36,7 +36,7 @@ import { lireJournal } from '../../stockage/journal.js';
import { SUFFIXES } from '../../stockage/noms.js';
import { VERSION } from '../../version.genere.js';
import TEXTE_HUIT from '../../../test/fixtures/plan/huit_tables_retenu.gtt.json?raw';
import { fixerCadre, glisser, pointer, touche } from '../../../test/navigateur/entrees.js';
import { fixerCadre, glisser, molette, pointer, touche } from '../../../test/navigateur/entrees.js';
import { monterApp } from '../../../test/navigateur/montage.js';
import { cadreDepuisRect, englobantDuTour } from './vue_plan.js';
@ -731,6 +731,39 @@ describe('les gestes d’une personne (§ 7.1, § 8.2, § 5.8)', () => {
});
});
describe('un refus posé sur une place ne couvre pas la cible suivante (§ 5.9, § 7.3)', () => {
test('un appui sur une chaise que couvre le texte du refus atteint le plan : le refus se ferme, la chaise se sélectionne', async () => {
const m = await ouvrir(huitRetenue());
m.ui.montrerProposition(1);
flushSync();
await images(2);
await glisser(pointChaise(m, 3, 1), pointChaise(m, 7, 1));
const place = dans(m, '[data-siege="7:1"]');
await attendre(() => expect(decrit(place)).not.toBeNull());
const refus = decrit(place);
const boite = refus.getBoundingClientRect();
// Les chaises dont le centre tombe dans la boîte du refus, hors de ses
// boutons.
const couvertes = tous(m, '[data-siege]')
.map((chaise) => {
const [table, siege] = chaise.dataset.siege.split(':').map(Number);
return { chaise, point: pointChaise(m, table, siege) };
})
.filter(({ point: { x, y } }) => x > boite.left + 2 && x < boite.right - 2 && y > boite.top + 2 && y < boite.bottom - 2)
.filter(({ point: { x, y } }) => document.elementFromPoint(x, y)?.closest('button') === null);
expect(couvertes.length).toBeGreaterThan(0);
const { chaise, point } = couvertes[0];
const avant = fichiers(m);
await pointer('presser', point.x, point.y);
await pointer('relacher', point.x, point.y);
await images(2);
expect(decrit(place)).toBeNull();
const [table, siege] = chaise.dataset.siege.split(':').map(Number);
expect(m.ui.etat().selection).toEqual({ table, siege });
await rienEcrit(m, avant);
});
});
// --- Glisser une table (§ 7.1, § 9) ---------------------------------------------
describe('glisser une table (§ 7.1, § 9)', () => {
@ -1009,6 +1042,54 @@ describe('un clic n’est pas un glisser, la lecture, l’inactivité (§ 8.4)',
});
});
describe('regarder pendant un geste et regarder sans appuyer (§ 7.2, § 7.3, § 8.4)', () => {
test('la molette pendant le glissement d’une personne ne change pas la vue : la commande nomme la chaise sous le pointeur au relâcher', async () => {
const m = await ouvrir(huitRetenue());
m.ui.montrerTour(2);
flushSync();
await avecVue(m, { k: 0.5, tx: 150, ty: 60 });
const commandes = releverCommandes(m);
const r = fichiers(m).revision;
const depart = pointChaise(m, 4, 1);
await presserEtBouger(depart, decale(depart, 30, 0));
const vue = groupe(m).getAttribute('transform');
const cadre = cadreDe(m);
await molette(cadre.gauche + cadre.largeur / 2, cadre.haut + cadre.hauteur / 2, -300);
await images(3);
expect(groupe(m).getAttribute('transform')).toBe(vue);
const vers = pointChaise(m, 7, 3);
await pointer('bouger', vers.x, vers.y);
await uneImage();
await pointer('relacher', vers.x, vers.y);
await uneEntree(m, r);
expect(commandes).toEqual([{ nom: 'deplacerDansRetenu', args: { tour: 2, participant: 1, table: 7, siege: 3 } }]);
});
test('la molette, « Zoom + », « Ajuster » et le changement de tour ne relancent pas le compte d’inactivité : le retour en lecture tombe à son heure', async () => {
const m = await ouvrir(huitRetenue());
expect(m.seance.etat().mode).toBe('ecriture');
m.horloge.avancer(INACTIVITE_MS - 1000);
await images(2);
const transform = groupe(m).getAttribute('transform');
const cadre = cadreDe(m);
await molette(cadre.gauche + cadre.largeur / 2, cadre.haut + cadre.hauteur / 2, -100);
await attendre(() => expect(groupe(m).getAttribute('transform')).not.toBe(transform));
dans(m, '[data-commande="zoomer"]').click();
flushSync();
dans(m, '[data-commande="ajuster"]').click();
flushSync();
const select = dans(m, '[data-champ="tour"]');
select.value = '2';
select.dispatchEvent(new Event('change', { bubbles: true }));
flushSync();
expect(m.ui.etat().tour).toBe(2);
await images(2);
expect(m.seance.etat().mode).toBe('ecriture');
m.horloge.avancer(1001);
await attendre(() => expect(m.seance.etat().mode).toBe('lecture'));
});
});
// L'écart d'une poignée tirée de trois places, au zoom relu.
const pasDeLecture = (m) => ((3 * 60) / (2 * Math.PI)) * vueLue(m).k / Math.SQRT2;

View file

@ -17,6 +17,8 @@
// réservations « tour » à une table qu'une seule « tous les tours »
// remplacerait. « Convertir » l'exécute ; « Ignorer » la retire de la
// liste jusqu'au prochain montage, sans rien écrire.
// Chaque ligne de titre a pour clé son rang : deux titres égaux à une
// même place restent deux lignes.
// Le libellé d'un titre, le titre pressenti et le nom d'une personne sont
// des données, chacun dans son nœud. Échap, sur le panneau ou l'un de ses
// contrôles, oublie le choix en attente. Le panneau lit 'seance' et 'ui'
@ -74,7 +76,7 @@
{#if charge !== null}
<h4 id="{uid}-titres">{t('plan.listesTravail.titres')}</h4>
<ul data-liste-travail="titres" aria-labelledby="{uid}-titres">
{#each titres as titre (`${titre.table}:${titre.siege}:${titre.libelle}`)}
{#each titres as titre, rang (rang)}
<li>
<button type="button" data-titre="{titre.table}:{titre.siege}" onclick={(evenement) => allerAuTitre(evenement, titre)}
><span>{t('plan.listesTravail.titreNonPourvu', { numero: titre.numero })}</span><span class="donnee"

View file

@ -23,9 +23,12 @@
// Le projet navigateur emploie expect, et test et describe de Vitest
// (§ 14.8).
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
import { flushSync } from 'svelte';
import { flushSync, mount, unmount } from 'svelte';
import { get, writable } from 'svelte/store';
import { appliquerCommande } from '../../application/commandes.js';
import { TABLES_APPLICATION } from '../../application/libelles.js';
import { etatPlaces } from '../../application/places.js';
import { creerTraduction } from '../../application/traduction.js';
import { constante } from '../../geometrie/constantes.js';
import { surLeSol } from '../../geometrie/designation.js';
import { geometrieTable } from '../../geometrie/tables.js';
@ -39,6 +42,8 @@ import TEXTE_HUIT from '../../../test/fixtures/plan/huit_tables_retenu.gtt.json?
import TEXTE_CONFLIT from '../../../test/fixtures/plan/conflit_table_un.gtt.json?raw';
import { fixerCadre, glisser, pointer, touche, toucher } from '../../../test/navigateur/entrees.js';
import { monterApp } from '../../../test/navigateur/montage.js';
import { TABLES_INTERFACE } from '../libelles.js';
import ListesTravail from './ListesTravail.svelte';
import { cadreDepuisRect, englobantDuTour } from './vue_plan.js';
const BASE = 'soiree';
@ -292,6 +297,34 @@ async function presserBouton(element) {
await cliquerPoint({ x: boite.left + boite.width / 2, y: boite.top + boite.height / 2 });
}
// Presse un bouton par le vrai pointeur, et dit s'il a reçu l'appui : un
// bouton que le pointeur traverse laisse l'appui à ce qui est dessous.
async function presseRecue(element) {
let recu = false;
const noter = () => {
recu = true;
};
element.addEventListener('pointerdown', noter);
try {
await presserBouton(element);
} finally {
element.removeEventListener('pointerdown', noter);
}
return recu;
}
// Presse au point depuis et avance jusqu'au point vers sans relâcher : le
// fantôme reste montré.
async function presserEtBouger(depuis, vers, { pas = 6 } = {}) {
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 images(1);
}
flushSync();
}
// Clique un bouton par son clic HTML, puis laisse la séance écrire.
async function cliquer(element) {
expect(element, 'bouton présent').not.toBeNull();
@ -888,6 +921,163 @@ describe('le menu fait ce que fait le geste (§ 7.1, § 10.2)', () => {
});
});
// --- Deux titres identiques à une même place (§ 5.8) -----------------------------
describe('les listes de travail tiennent deux titres identiques à une même place (§ 5.8)', () => {
test('deux titres « animateur » au siège 2 de la table 1, rendus par titresNonPourvus : deux lignes, aucune erreur de rendu', () => {
// Le panneau est monté seul, sur une séance d'épreuve qui porte la
// charge telle quelle : une charge que l'analyse admettrait ou non, le
// rendu ne doit pas tomber sur elle.
const charge = sansProposition();
charge.titres = [
{ table: 1, siege: 2, libelle: 'animateur' },
{ table: 1, siege: 2, libelle: 'animateur' },
];
const seance = writable({ charge });
const etatUi = writable({ choix: null, selection: null });
const ui = { subscribe: etatUi.subscribe, etat: () => get(etatUi), selectionner: () => {}, choisir: () => {} };
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
const conteneur = document.createElement('div');
document.body.prepend(conteneur);
const contexte = new Map([['t', t], ['seance', seance], ['ui', ui]]);
let composant = null;
try {
composant = mount(ListesTravail, { target: conteneur, context: contexte });
flushSync();
const lignes = [...conteneur.querySelectorAll('[data-liste-travail="titres"] [data-titre="1:2"]')];
expect(lignes).toHaveLength(2);
expect(lignes.map(texteDe)).toEqual(['table 1 : titre animateur non pourvu', 'table 1 : titre animateur non pourvu']);
} finally {
if (composant !== null) unmount(composant);
conteneur.remove();
}
});
});
// --- Le plan dit le placement qu'il montre (§ 2.1 étape 11, § 5.8) ----------------
describe('le plan dit le placement qu’il montre, et revient d’une proposition (§ 2.1 étape 11, § 5.8)', () => {
const source = (m) => texteDe(dans(m, '[data-source]'));
const reserve = (m) => [...dans(m, '[data-reserve]').querySelectorAll('[data-participant]')].map((e) => Number(e.dataset.participant));
test('avant la retenue : « Voir » une proposition, puis revenir aux réservations depuis le plan ; la réserve liste de nouveau les non-réservés', async () => {
const m = await ouvrir(huitAvantRetenue());
expect(source(m)).toBe('Réservations');
expect(dans(m, '[data-commande="quitterProposition"]')).toBeNull();
const nonReserves = reserve(m);
expect(nonReserves).toHaveLength(charge(m).participants.length);
m.ui.montrerProposition(1);
flushSync();
await images(2);
expect(source(m)).toBe('Proposition n° 1 : un geste y pose une réservation');
expect(reserve(m)).not.toEqual(nonReserves);
const quitter = dans(m, '[data-commande="quitterProposition"]');
expect(texteDe(quitter)).toBe('Revenir aux réservations');
await presserBouton(quitter);
flushSync();
await images(2);
expect(m.ui.etat().proposition).toBeNull();
expect(source(m)).toBe('Réservations');
expect(reserve(m)).toEqual(nonReserves);
expect(dans(m, '[data-commande="quitterProposition"]')).toBeNull();
});
test('après la retenue : la proposition montrée se dit en vue seule, et la commande revient au placement retenu', async () => {
const m = await ouvrir(huitRetenue());
expect(source(m)).toBe('Placement retenu');
m.ui.montrerProposition(1);
flushSync();
await images(2);
expect(source(m)).toBe('Proposition n° 1, vue seule');
const quitter = dans(m, '[data-commande="quitterProposition"]');
expect(texteDe(quitter)).toBe('Revenir au placement retenu');
await presserBouton(quitter);
flushSync();
await images(2);
expect(m.ui.etat().proposition).toBeNull();
expect(source(m)).toBe('Placement retenu');
});
});
// --- Un geste à un tour ne change pas les autres tours (§ 7.1, § 5.8) -----------
describe('une réservation « tous les tours » ne se retire pas par un geste (§ 7.1)', () => {
test('sol ou « Retirer du tour » : refus sur la place, rien d’écrit ; le remède la retire ; « ce tour » se retire par le sol', async () => {
const base = executer(
executer(sansProposition(), 'reserver', { participant: IRIS, table: 3, siege: 1, portee: 'tous', tour: null }),
'reserver',
{ participant: THEO, table: 4, siege: 2, portee: 'tour', tour: 1 },
);
const m = await ouvrir(base);
expect(etatMontre(m).cible).toBe('reservations');
const place = () => chaiseEl(m, 3, 1);
const refusSur = () => decrit(place())?.querySelector('[data-refus="RESERVATION_TOUS_LES_TOURS"]') ?? null;
let avant = fichiers(m);
await glisser(pointChaise(m, 3, 1), pointSol(m));
await attendre(() => expect(refusSur()).not.toBeNull());
expect(texteDe(decrit(place()))).toContain('tous les tours');
expect(texteDe(decrit(place()))).toContain(`Table ${charge(m).tables.find(({ id }) => id === 3).numero}, siège 1`);
await rienEcrit(m, avant);
expect(charge(m).reservations).toEqual(base.reservations);
// Le message laisse passer le pointeur, ses boutons le prennent : « Fermer »,
// pressé par le vrai pointeur, reçoit l'appui et retire la pose.
const fermer = decrit(place()).querySelector('button.fermer');
expect(fermer).not.toBeNull();
expect(await presseRecue(fermer)).toBe(true);
await attendre(() => expect(refusSur()).toBeNull());
expect(m.ui.etat().pose).toBeNull();
await rienEcrit(m, avant);
await activerSiegeAuClavier(m, 3, 1);
await presserBouton(dans(m, `[data-menu="${IRIS}"] [data-commande="retirerDuTour"]`));
await attendre(() => expect(refusSur()).not.toBeNull());
await rienEcrit(m, avant);
// Le remède, pressé par le vrai pointeur : il reçoit l'appui, et sa
// commande écrit une entrée.
const remede = refusSur().querySelector('[data-commande="retirerReservation"]');
expect(remede).not.toBeNull();
const r = fichiers(m).revision;
expect(await presseRecue(remede)).toBe(true);
await uneEntree(m, r);
expect(charge(m).reservations).toEqual([{ participant: THEO, table: 4, siege: 2, portee: 'tour', tour: 1 }]);
avant = fichiers(m);
await glisser(pointChaise(m, 4, 2), pointSol(m));
await uneEntree(m, avant.revision);
expect(charge(m).reservations).toEqual([]);
});
test('une personne réservée glissée vers une chaise libre : le fantôme dit qu’elle déplace sa réservation, et sa portée ; « tous les tours » se déplace à chaque tour', async () => {
const base = executer(
executer(sansProposition(), 'reserver', { participant: IRIS, table: 3, siege: 1, portee: 'tous', tour: null }),
'reserver',
{ participant: THEO, table: 4, siege: 2, portee: 'tour', tour: 1 },
);
const m = await ouvrir(base);
expect(etatMontre(m).cible).toBe('reservations');
const etatDuFantome = () => texteDe(dans(m, '[data-fantome] [data-partie="etat"]'));
let r = fichiers(m).revision;
let vers = pointChaise(m, 4, 3);
await presserEtBouger(pointChaise(m, 3, 1), vers);
expect(etatDuFantome()).toBe('déplace sa réservation, à tous les tours');
await pointer('relacher', vers.x, vers.y);
await uneEntree(m, r);
expect(charge(m).reservations).toContainEqual({ participant: IRIS, table: 4, siege: 3, portee: 'tous', tour: null });
r = fichiers(m).revision;
vers = pointChaise(m, 4, 4);
await presserEtBouger(pointChaise(m, 4, 2), vers);
expect(etatDuFantome()).toBe('déplace sa réservation de ce tour');
await pointer('relacher', vers.x, vers.y);
await uneEntree(m, r);
expect(charge(m).reservations).toContainEqual({ participant: THEO, table: 4, siege: 4, portee: 'tour', tour: 1 });
});
});
// --- Réserver, titrer, désigner (§ 4, § 2.1 étape 6, § 5.8) ---------------------
describe('réserver, titrer, désigner depuis le panneau (§ 4, § 2.1 étape 6)', () => {
@ -1457,6 +1647,21 @@ describe('la mise en évidence d’une personne : posée et ôtée par un geste,
expect(m.seance.etat().mode).toBe('lecture');
});
test('une personne mise en évidence puis supprimée : la barre dit qu’elle n’est plus dans la liste, et offre toujours d’ôter la mise en évidence', async () => {
const m = await ouvrir(huitRetenue());
const personne = etatMontre(m).tables[1].chaises[0].occupant;
m.ui.mettreEnEvidence(personne);
flushSync();
await m.seance.executer('supprimerParticipant', { id: personne });
flushSync();
await images(2);
const barre = dans(m, '[data-evidence]');
expect(barre).not.toBeNull();
expect(texteDe(barre)).toContain('une personne qui n’est plus dans la liste');
await cliquer(barre.querySelector('[data-commande="oterEvidence"]'));
expect(dans(m, '[data-evidence]')).toBeNull();
});
test('depuis sa ligne de la grille des participants : « Voir sur le plan » ouvre le plan, sa chaise marquée ; rien d’écrit', async () => {
const m = await ouvrir(huitRetenue(), { mode: 'lecture' });
const avant = fichiers(m);

View file

@ -44,9 +44,10 @@
// aucune mise en page ; pointerup achève, par au plus une commande de la
// séance ; pointercancel, Échap — écouté sur document, en phase de
// capture, le temps du geste — et le passage en lecture (surAnnulation)
// restaurent. Pendant le glissement, le composant écrit l'attribut
// transform directement — groupe de la vue, groupe d'une table et ses
// frères, fantôme — et ne confie le résultat au modèle qu'au relâcher :
// restaurent. Pendant un geste, la molette ne zoome pas : le geste se
// juge sur la vue de l'appui. Pendant le glissement, le composant écrit
// l'attribut transform directement — groupe de la vue, groupe d'une table
// et ses frères, fantôme — et ne confie le résultat au modèle qu'au relâcher :
// aucun élément capturant n'est remplacé. La poignée passe son nombre au
// modèle de dessin (option poignee), sans rien écrire. Un refus — du
// dépôt, du contrôle de la poignée, de la commande — se pose sur la place
@ -93,7 +94,7 @@
import BandeauPlan from './BandeauPlan.svelte';
import BarreVue from './BarreVue.svelte';
import { toucheDuPlan, vueQuiMontre } from './clavier.js';
import { deposer } from './depot.js';
import { deposer, reservationTenue } from './depot.js';
import EchellePlan from './EchellePlan.svelte';
import Fantome from './Fantome.svelte';
import { REPOS, cleFantome, geste, memoriser } from './gestes.js';
@ -105,6 +106,7 @@
cadreDepuisRect,
englobantDuTour,
nomDeCharge,
sourceMontree,
vueApresCadre,
vueInitiale,
zoomerBouton,
@ -156,6 +158,8 @@
const pose = $derived($ui.pose);
// La personne mise en évidence, que la barre de vue nomme : {id, nom}, nom
// null pour une personne que la charge n'a plus ; null sans mise en évidence.
// Le placement que le plan montre, que la barre de vue nomme.
const source = $derived(sourceMontree(etat, $ui.proposition, charge.retenu !== null));
const evidence = $derived($ui.miseEnEvidence === null ? null : { id: $ui.miseEnEvidence, nom: nomDe($ui.miseEnEvidence) ?? null });
// L'élément actif que le dessin marque : sous un focus venu du clavier.
const actifMontre = $derived(aLeFocus && auClavier ? actif : null);
@ -381,6 +385,12 @@
// retirerait le transform d'un second glissement de la même table.
let commandeEnCours = false;
// Vrai de l'appui d'un geste à son relâcher ou à son annulation : la
// machine juge le geste entier sur la vue relevée à l'appui, et la
// molette ne zoome pas pendant ce temps — le point du relâcher se
// convertirait dans un autre repère que celui où le fantôme le montre.
let gesteEnCours = false;
// Confie la commande d'un geste à la séance ; un refus se pose sur la
// place. La cible apres se restaure une fois la charge rendue : la table
// se dessine alors à sa nouvelle position, ou revient.
@ -424,6 +434,7 @@
});
const surMolette = (evenement) => {
evenement.preventDefault();
if (gesteEnCours) return;
poserVue(zoomerMolette(vue, englobantPlan, cadreCourant(), evenement));
};
observateur.observe(element);
@ -459,6 +470,7 @@
function appliquer({ etat: suivant, effets }) {
const pointeur = etatGeste.pointeur;
etatGeste = suivant;
gesteEnCours = suivant.phase !== 'repos';
// Un appui devenu glissement — au-delà du seuil, que la machine juge —
// n'est plus une pression longue.
if (suivant.phase === 'glisse') oublierPression();
@ -483,7 +495,10 @@
else for (const cible of elementsDeTable(effet.cible.table)) cible.setAttribute('transform', effet.transform);
break;
case 'montrerFantome':
fantome = { nom: nomDe(effet.participant), cle: cleFantome(etat.cible) };
fantome = {
nom: nomDe(effet.participant),
cle: cleFantome(etat.cible, reservationTenue(etat, effet.participant, effet.depuis)?.portee ?? null),
};
break;
case 'montrerPlaces':
poignee = { table: effet.table, places: effet.places };
@ -681,6 +696,7 @@
oublierImage();
document.removeEventListener('keydown', surTouche, true);
if (etatGeste.phase !== 'repos') seance.finirGeste();
gesteEnCours = false;
};
});
@ -696,7 +712,7 @@
</script>
<div class="plan">
<BarreVue {tour} {tours} {zoomer} {ajuster} {evidence} />
<BarreVue {tour} {tours} {zoomer} {ajuster} {evidence} {source} />
<div class="surface" data-surface bind:this={surface}>
<BandeauPlan bandeau={modele === null ? [] : modele.bandeau} />
<!-- Le plan est un composant interactif à deux dimensions, que le

View file

@ -11,6 +11,11 @@
// chacun avec la commande de son remède, puis chaque avertissement. La
// place le désigne par aria-describedby, vers id.
//
// Posé sur le plan, le message couvre d'autres places : seuls ses
// contrôles reçoivent le pointeur. Un appui sur le texte va au plan
// dessous, dont l'appui retire la pose et saisit ce qui est visé — le
// refus se ferme au geste suivant sans lui prendre sa cible.
//
// pose : {id, numero, siege|null, refus|null, avertissements, x, y} — x et y
// en px de la surface ; fermer : retire la pose ; gestes : ceux qu'un
// remède emprunte à l'écran — le choix d'une place sur le plan —, que Refus
@ -42,6 +47,7 @@
.refus-place {
position: absolute;
z-index: 1;
pointer-events: none;
display: flex;
flex-direction: column;
align-items: flex-start;
@ -57,6 +63,10 @@
font-size: var(--taille-texte);
}
.refus-place :global(button) {
pointer-events: auto;
}
p {
margin: 0;
}

View file

@ -13,9 +13,12 @@
// En régime table, les chaises ne se dessinent pas : l'emprise entière se
// peint au fond de la chaise et devient la seule cible, sous le plateau.
// En régime sièges, chaque chaise est un groupe [data-siege="table:siège"]
// dont seul le disque reçoit le pointeur : ses marques, comme la pastille,
// ont pointer-events: none, et la cible d'une chaise est le disque même que
// la désignation lit (designation.js).
// dont la cible reçoit le pointeur : un disque sans peinture, de rayon
// rayonCible — max(RAYON_CHAISE, pas / 2), le disque même que la
// désignation lit (designation.js) —, dernier enfant du groupe, au-dessus
// de la chaise et de ses marques ; les marques, comme la pastille, ont
// pointer-events: none. La cible ne porte aucune marque et ne peint rien :
// le premier disque du groupe reste la chaise.
//
// Une table en conflit, quand le plan signale, porte un contour épaissi
// (contourConflit) et une trame (trameConflit) par-dessus son plateau ; sa
@ -139,6 +142,7 @@
{#if chaise.focus}
<rect class="focus" data-marque="focus" {...carreAutour(chaise, chaise.rayon + ECART_FOCUS)} />
{/if}
<circle class="cible" cx={chaise.x} cy={chaise.y} r={table.geometrie.rayonCible} />
</g>
{/each}
</g>
@ -197,6 +201,14 @@
pointer-events: none;
}
/* La cible d'un siège : aucune peinture, le pointeur la reçoit tout de
même. */
.cible {
fill: none;
stroke: none;
pointer-events: all;
}
.chaise {
fill: var(--couleur-chaise-fond);
stroke: var(--couleur-chaise-trait);

View file

@ -9,6 +9,16 @@
// le geste modifie, et la commande juge la pose et la capacité.
import { designer, surLeSol as horsDesTables } from '../../geometrie/designation.js';
/**
* Codes d'un refus que le dépôt lève lui-même, leur texte dans
* src/application/libelles/places.js :
* RESERVATION_TOUS_LES_TOURS {participant, table, remede} : sous la cible
* 'reservations', une personne que sa réservation « tous les tours » tient
* à sa chaise, déposée hors des tables ; remède { commande:
* 'retirerReservation', arguments: {participant, table, portee: 'tous',
* tour: null} }.
*/
// La chaise d'une table de l'état des places, ou undefined.
function chaiseDe(etatPlaces, { table, siege }) {
return etatPlaces.tables.find(({ id }) => id === table)?.chaises[siege - 1];
@ -23,15 +33,38 @@ function premierLibre(etatPlaces, table, libre) {
return chaise === undefined ? null : chaise.siege;
}
/**
* La réservation qui tient une personne à sa chaise d'origine, telle que
* l'état des places la porte — {participant, portee, tour} —, sinon null :
* pas de chaise d'origine, une chaise que rien ne tient, ou que la
* réservation d'une autre tient. Le dépôt et le libellé du fantôme la lisent
* ici tous deux.
*
* @param {Object} etatPlaces EtatPlaces du tour affiché
* @param {number} personne
* @param {{table: number, siege: number}|null} depuis
* @returns {{participant: number, portee: 'tous'|'tour', tour: number|null}|null}
*/
export function reservationTenue(etatPlaces, personne, depuis) {
const reservation = depuis === null ? null : (chaiseDe(etatPlaces, depuis)?.reservation ?? null);
return reservation?.participant === personne ? reservation : null;
}
/**
* La commande d'un dépôt de personne, lue sur etatPlaces.cible (contrat,
* « Source ») :
* - 'reservations' — avant la retenue, ou à un tour que le retenu ne porte
* pas — : une personne que sa chaise d'origine tient réservée déplace sa
* réservation (deplacerReservation), qui garde portée et tour ; une
* réservation (deplacerReservation), qui garde portée et tour — le
* fantôme le dit pendant le geste, « tous les tours » compris ; une
* autre reçoit une réservation de portée « tour » au tour affiché
* (reserver) ; sur le sol, la réservation glissée part
* (retirerReservation), et rien pour une personne non réservée. Un siège
* (reserver) ; sur le sol, la réservation « ce tour » glissée part
* (retirerReservation), et rien pour une personne non réservée. Une
* réservation « tous les tours » ne part pas : un geste fait à un tour ne
* change pas les autres tours en silence, et une réservation se défait
* par sa propre commande (§ 7.1) ; le dépôt rend le refus
* RESERVATION_TOUS_LES_TOURS {participant, table}, dont le remède est ce
* retrait, de portée « tous ». Un siège
* qu'occupe une personne non réservée de la proposition montrée vaut un
* siège libre : la proposition reste identique, et dérive ;
* - 'retenu' : un siège libre → deplacerDansRetenu, un siège occupé par une
@ -69,11 +102,12 @@ export function commandeDeDepot({ etatPlaces, tour, personne, depuis, cible, sur
return { commande: 'deplacerDansRetenu', arguments: { tour, participant: personne, table: cible.table, siege } };
}
const origine = depuis === null ? undefined : chaiseDe(etatPlaces, depuis);
const tenue = origine?.reservation?.participant === personne ? origine.reservation : null;
const tenue = reservationTenue(etatPlaces, personne, depuis);
if (cible === null) {
if (tenue === null) return null;
return { commande: 'retirerReservation', arguments: { participant: personne, table: depuis.table, portee: tenue.portee, tour: tenue.tour } };
const retrait = { commande: 'retirerReservation', arguments: { participant: personne, table: depuis.table, portee: tenue.portee, tour: tenue.tour } };
if (tenue.portee !== 'tous') return retrait;
return { refus: { code: 'RESERVATION_TOUS_LES_TOURS', details: { participant: personne, table: depuis.table, remede: retrait } } };
}
const siege = surSiege ? cible.siege : premierLibre(etatPlaces, cible.table, ({ reservation }) => reservation === null);
if (tenue !== null) {

View file

@ -14,7 +14,7 @@ import { appliquerCommande } from '../../application/commandes.js';
import { etatPlaces } from '../../application/places.js';
import { geometrieTable } from '../../geometrie/tables.js';
import { creerCharge } from '../../stockage/document.js';
import { commandeDeDepot, deposer } from './depot.js';
import { commandeDeDepot, deposer, reservationTenue } from './depot.js';
const NOMS = [
['Ombrelle', 'Iris'],
@ -114,6 +114,39 @@ describe('commandeDeDepot sous la cible « reservations » : avant la retenue (
});
});
test('reservationTenue : la réservation qui tient la personne à sa chaise d’origine, sinon null', () => {
// Le fantôme et le dépôt la lisent de la même fonction : le libellé du
// geste dit la portée que deplacerReservation garde.
const tous = executer(chargeDepot(), 'reserver', { participant: 1, table: 1, siege: 1, portee: 'tous', tour: null });
const etat = etatPlaces(tous, { tour: 2, proposition: 1 });
assert.deepEqual(reservationTenue(etat, 1, { table: 1, siege: 1 }), { participant: 1, portee: 'tous', tour: null });
assert.equal(reservationTenue(etat, 2, { table: 1, siege: 1 }), null);
assert.equal(reservationTenue(etat, 1, { table: 2, siege: 1 }), null);
assert.equal(reservationTenue(etat, 1, null), null);
const tour = executer(chargeDepot(), 'reserver', { participant: 1, table: 1, siege: 1, portee: 'tour', tour: 1 });
assert.deepEqual(reservationTenue(etatPlaces(tour, { tour: 1, proposition: 1 }), 1, { table: 1, siege: 1 }), {
participant: 1,
portee: 'tour',
tour: 1,
});
});
test('sur le sol, une réservation « tous les tours » ne se retire pas : { refus } RESERVATION_TOUS_LES_TOURS, son remède retirerReservation de portée « tous », que la charge exécute', () => {
// Un geste fait à un tour ne change jamais en silence les autres tours :
// la réservation se défait par sa propre commande (§ 7.1).
const charge = executer(chargeDepot(), 'reserver', { participant: 1, table: 1, siege: 1, portee: 'tous', tour: null });
const remede = { commande: 'retirerReservation', arguments: { participant: 1, table: 1, portee: 'tous', tour: null } };
for (const tour of [1, 2]) {
const etat = etatPlaces(charge, { tour, proposition: 1 });
assert.equal(etat.cible, 'reservations');
assert.deepEqual(depot(etat, { table: 1, siege: 1 }, null, true), {
refus: { code: 'RESERVATION_TOUS_LES_TOURS', details: { participant: 1, table: 1, remede } },
});
}
const apres = executer(charge, remede.commande, remede.arguments);
assert.deepEqual(apres.reservations, []);
});
test('sur le sol → retirerReservation pour une personne réservée ; null pour une personne qui ne l’est pas', () => {
const charge = executer(chargeDepot(), 'reserver', { participant: 1, table: 1, siege: 1, portee: 'tour', tour: 1 });
const etat = etatPlaces(charge, { tour: 1, proposition: 1 });

View file

@ -232,17 +232,34 @@ const CLES_FANTOME = new Map([
[null, 'plan.fantome.vueSeule'],
]);
// Sous la cible 'reservations', la clé d'une personne que sa réservation
// tient à sa chaise, par portée : son dépôt sur une chaise est
// deplacerReservation, qui garde la portée.
const CLES_RESERVATION_TENUE = new Map([
['tour', 'plan.fantome.reservationTour'],
['tous', 'plan.fantome.reservationTous'],
]);
/**
* La clé du libellé que le fantôme porte pendant le glissement d'une
* personne (§ 5.8) : « pose une réservation » sous la cible 'reservations',
* « déplace dans le placement retenu » sous 'retenu', la vue seule sous une
* cible null. Une autre valeur lève : elle ne prend aucun libellé par défaut.
* personne (§ 5.8, § 7.1) : sous la cible 'reservations', « pose une
* réservation » pour une personne que rien ne tient, sinon le déplacement
* de sa réservation et sa portée — « tous les tours » s'y dit ; « déplace
* dans le placement retenu » sous 'retenu' ; la vue seule sous une cible
* null. Une cible ou une portée hors du contrat lève : elle ne prend aucun
* libellé par défaut.
*
* @param {'reservations'|'retenu'|null} cible EtatPlaces.cible
* @param {'tous'|'tour'|null} [portee] la portée de la réservation qui tient
* la personne à sa chaise d'origine (reservationTenue de depot.js), ou null
* @returns {string}
*/
export function cleFantome(cible) {
export function cleFantome(cible, portee = null) {
if (!CLES_FANTOME.has(cible)) throw new TypeError(`cible du fantôme hors du contrat : ${JSON.stringify(cible)}`);
if (portee !== null && !CLES_RESERVATION_TENUE.has(portee)) {
throw new TypeError(`portée du fantôme hors du contrat : ${JSON.stringify(portee)}`);
}
if (cible === 'reservations' && portee !== null) return CLES_RESERVATION_TENUE.get(portee);
return CLES_FANTOME.get(cible);
}

View file

@ -331,6 +331,23 @@ describe('les libellés d’un geste : le fantôme et la place d’une pose', ()
for (const cible of ['reservations', 'retenu', null]) assert.equal(LIBELLES.has(cleFantome(cible)), true);
});
test('cleFantome : sous les réservations, une personne réservée déplace sa réservation, et le libellé en dit la portée', () => {
// Le dépôt d'une personne réservée exécute deplacerReservation, qui garde
// la portée : « tous les tours » se dit pendant le geste (§ 5.8, § 7.1).
assert.equal(cleFantome('reservations', null), 'plan.fantome.reservations');
assert.equal(cleFantome('reservations', 'tour'), 'plan.fantome.reservationTour');
assert.equal(cleFantome('reservations', 'tous'), 'plan.fantome.reservationTous');
for (const portee of [null, 'tour', 'tous']) {
assert.equal(cleFantome('retenu', portee), 'plan.fantome.retenu');
assert.equal(cleFantome(null, portee), 'plan.fantome.vueSeule');
assert.equal(LIBELLES.has(cleFantome('reservations', portee)), true);
}
assert.match(LIBELLES.get(cleFantome('reservations', 'tous')), /tous les tours/);
for (const portee of ['Tous', 'tours', '', 0]) {
assert.throws(() => cleFantome('reservations', portee), { name: 'TypeError', message: /^portée du fantôme hors du contrat/ });
}
});
test('cleFantome : une cible hors du contrat lève, au lieu de se dire « vue seule »', () => {
for (const cible of ['reservation', 'Retenu', undefined, '', 0]) {
assert.throws(() => cleFantome(cible), { name: 'TypeError', message: /^cible du fantôme hors du contrat/ });

View file

@ -127,3 +127,36 @@ export function nomDeCharge(charge) {
const noms = new Map(charge.participants.map((participant) => [participant.id, nomAffiche(participant)]));
return (id) => noms.get(id);
}
/**
* Ce que la barre de vue dit du placement que le plan montre (§ 2.1, étape
* 11 ; § 5.8), lu sur l'état des places du tour : le placement retenu, les
* seules réservations — ou un retenu qui ne se lit pas, sous lequel le plan
* montre les réservations —, une proposition dont les gestes posent des
* réservations (avant la retenue), ou une proposition en vue seule (après).
* Quand une proposition est demandée (ui.proposition), la barre offre de ne
* plus la montrer : revenir au retenu quand il existe, aux réservations
* sinon ; une proposition demandée qu'un tour ne porte pas laisse le plan
* sur sa source réelle, et la commande reste offerte.
*
* @param {{source: string, proposition: number|null, cible: string|null, refus: Object|null}} etat
* EtatPlaces du tour affiché
* @param {number|null} demandee la proposition demandée au plan
* @param {boolean} retenu la charge porte un placement retenu
* @returns {{libelle: {cle: string, details: Object}, revenir: string|null}}
* revenir : la clé du libellé de la commande, null sans proposition demandée
* @throws {TypeError} pour une source hors du contrat
*/
export function sourceMontree(etat, demandee, retenu) {
const revenir = demandee === null ? null : retenu ? 'plan.source.revenirRetenu' : 'plan.source.revenirReservations';
const avec = (cle, details = {}) => ({ libelle: { cle, details }, revenir });
if (etat.source === 'retenu') return avec('plan.source.retenu');
if (etat.source === 'proposition') {
const cle = etat.cible === 'reservations' ? 'plan.source.propositionReservations' : 'plan.source.propositionVueSeule';
return avec(cle, { proposition: etat.proposition });
}
if (etat.source === 'reservations') {
return avec(etat.refus?.code === 'RETENU_FAUTIF' ? 'plan.source.retenuFautif' : 'plan.source.reservations');
}
throw new TypeError(`source de l’état des places hors du contrat : ${JSON.stringify(etat.source)}`);
}

View file

@ -27,6 +27,7 @@ import {
cadreDepuisRect,
englobantDuTour,
nomDeCharge,
sourceMontree,
tourAffiche,
toursOfferts,
vueApresCadre,
@ -249,3 +250,47 @@ describe('l’englobant d’un tour, le tour montré, les noms', () => {
assert.equal(nomDe(2), 'Grisaille');
});
});
describe('sourceMontree : ce que la barre de vue dit du placement montré (§ 2.1 étape 11, § 5.8)', () => {
// Les champs d'EtatPlaces que la barre lit : source, proposition, cible, refus.
const etat = (source, { proposition = null, cible = null, refus = null } = {}) => ({ source, proposition, cible, refus });
test('le retenu, les réservations, un retenu illisible : un libellé chacun, aucune commande sans proposition demandée', () => {
assert.deepEqual(sourceMontree(etat('retenu', { cible: 'retenu' }), null, true), {
libelle: { cle: 'plan.source.retenu', details: {} },
revenir: null,
});
assert.deepEqual(sourceMontree(etat('reservations', { cible: 'reservations' }), null, false), {
libelle: { cle: 'plan.source.reservations', details: {} },
revenir: null,
});
const fautif = etat('reservations', { refus: { code: 'RETENU_FAUTIF', details: {} } });
assert.deepEqual(sourceMontree(fautif, null, true).libelle, { cle: 'plan.source.retenuFautif', details: {} });
});
test('une proposition montrée avant la retenue : ses gestes posent des réservations ; la commande revient aux réservations', () => {
assert.deepEqual(sourceMontree(etat('proposition', { proposition: 3, cible: 'reservations' }), 3, false), {
libelle: { cle: 'plan.source.propositionReservations', details: { proposition: 3 } },
revenir: 'plan.source.revenirReservations',
});
});
test('une proposition montrée après la retenue : vue seule ; la commande revient au retenu', () => {
const refus = { code: 'PROPOSITION_MONTREE', details: { proposition: 2, remede: null } };
assert.deepEqual(sourceMontree(etat('proposition', { proposition: 2, refus }), 2, true), {
libelle: { cle: 'plan.source.propositionVueSeule', details: { proposition: 2 } },
revenir: 'plan.source.revenirRetenu',
});
});
test('une proposition demandée qu’un tour ne porte pas : le plan dit sa source réelle, et la commande reste offerte', () => {
assert.deepEqual(sourceMontree(etat('retenu', { cible: 'retenu' }), 2, true), {
libelle: { cle: 'plan.source.retenu', details: {} },
revenir: 'plan.source.revenirRetenu',
});
});
test('une source hors du contrat lève', () => {
assert.throws(() => sourceMontree(etat('autre'), null, false), TypeError);
});
});

View file

@ -7,7 +7,17 @@
// libellé de confirmation, un premier clic arme la commande — son libellé
// devient celui de confirmer — et seul un second exécute ; armée, elle se
// désarme au terme de DELAI_CONFIRMATION_MS, par l'horloge du contexte.
// La machine est celle de commande.js.
// La machine est celle de commande.js : le clic répété d'une série
// — detail supérieur à 1 — est sans effet (§ 8.5). Le clavier n'en porte
// pas : un appui d'Entrée ou d'Espace que la répétition automatique émet
// — keydown dont repeat est vrai — voit son action par défaut empêchée, et
// n'active pas le bouton. Une touche tenue n'active la commande qu'une
// fois, et ne confirme jamais celle qu'elle vient d'armer.
//
// Pendant qu'une exécution qui rend une promesse s'achève, la commande est
// occupée : un clic ne fait rien, et le bouton porte aria-busy et
// aria-disabled, dessiné d'un filet tireté. Une exécution sans promesse
// rend la commande aussitôt.
//
// Un refus — une erreur qui porte un code — s'affiche à côté du bouton,
// avec son remède, et le bouton reste actif : jamais un bouton grisé muet.
@ -27,6 +37,7 @@
const machine = creerMachineCommande({ confirmer: untrack(() => confirmer) });
let armee = $state(false);
let enCours = $state(false);
let refus = $state(null);
let minuterie = null;
@ -45,9 +56,10 @@
armee = machine.armee;
}
async function cliquer() {
const { action } = machine.cliquer();
async function cliquer(evenement) {
const { action } = machine.cliquer({ repete: evenement.detail > 1 });
armee = machine.armee;
if (action === 'aucune') return;
if (action === 'armer') {
minuterie = horloge.planifier(expirer, DELAI_CONFIRMATION_MS);
return;
@ -55,18 +67,36 @@
desarmer();
refus = null;
try {
await executer();
const rendu = executer();
if (typeof rendu?.then === 'function') {
enCours = true;
await rendu;
}
} catch (erreur) {
if (typeof erreur?.code !== 'string') throw erreur;
refus = { code: erreur.code, details: erreur.details ?? {} };
} finally {
machine.terminer();
enCours = false;
}
}
function surTouche(evenement) {
if (evenement.repeat && (evenement.key === 'Enter' || evenement.key === ' ')) evenement.preventDefault();
}
onDestroy(desarmer);
</script>
<div class="commande">
<button type="button" data-commande={designation} onclick={cliquer}>{libelle}</button>
<button
type="button"
data-commande={designation}
aria-busy={enCours ? 'true' : undefined}
aria-disabled={enCours ? 'true' : undefined}
onkeydown={surTouche}
onclick={cliquer}>{libelle}</button
>
{#if refus !== null}
<Refus {refus} lever={() => (refus = null)} {gestes} />
{/if}
@ -91,6 +121,12 @@
cursor: pointer;
}
button[aria-disabled='true'] {
border-style: dashed;
color: var(--couleur-page-texte-secondaire);
cursor: progress;
}
button:focus-visible {
outline: 2px solid var(--couleur-focus);
outline-offset: 2px;

View file

@ -8,7 +8,19 @@
// confirmer s'arme au premier clic — son libellé devient celui de la
// confirmation — et n'exécute qu'au second ; le composant la désarme au
// terme de DELAI_CONFIRMATION_MS, par l'horloge qu'il reçoit. Aucun chemin
// n'exécute une commande à confirmer en un clic.
// n'exécute une commande à confirmer en un clic (§ 8.5).
//
// Le clic répété d'une série — celui dont le compte de clics, detail,
// dépasse 1 — est sans effet sur toute commande : il n'exécute, n'arme ni ne
// confirme rien. Le second clic d'un double-clic tombe souvent sur une autre
// commande que le premier, celle qu'a montée l'exécution à la même place
// — « Revenir en lecture » à la place de « Modifier » —, et il n'est jamais
// une seconde exécution voulue : « Défaire » double-cliqué ne rend qu'un
// geste. Armée, une commande le reste ; un clic simple la confirme.
//
// Une commande n'est pas ré-entrante : de l'exécution lancée à terminer(),
// que le composant appelle quand l'action rend la main, tout clic est sans
// effet. Un second clic pendant une écriture lente ne relance rien.
//
// Un remède (contrat « Refus et avertissements ») est null, une commande
// exécutable telle quelle, { commande, arguments }, ou un geste,
@ -58,24 +70,37 @@ export const SECTIONS_REMEDE = Object.freeze([
*
* @param {{confirmer?: string|null}} [options] la clé du libellé de
* confirmation ; null pour une commande qui exécute au premier clic
* @returns {{readonly armee: boolean, cliquer: () => ({action: 'armer'}|{action: 'executer'}), expirer: () => void}}
* cliquer arme une commande à confirmer désarmée, et exécute sinon, en la
* désarmant ; expirer la désarme.
* @returns {{readonly armee: boolean, readonly enCours: boolean,
* cliquer: (clic?: {repete?: boolean}) => ({action: 'armer'}|{action: 'executer'}|{action: 'aucune'}),
* terminer: () => void, expirer: () => void}}
* cliquer ne fait rien pendant une exécution, ni pour le clic répété
* d'une série (repete vrai) ; sinon il arme une commande à confirmer
* désarmée, et exécute, en la désarmant et en la marquant en cours ;
* terminer la rend aux clics ; expirer la désarme.
*/
export function creerMachineCommande({ confirmer = null } = {}) {
let armee = false;
let enCours = false;
return {
get armee() {
return armee;
},
cliquer() {
get enCours() {
return enCours;
},
cliquer({ repete = false } = {}) {
if (enCours || repete) return { action: 'aucune' };
if (confirmer !== null && !armee) {
armee = true;
return { action: 'armer' };
}
armee = false;
enCours = true;
return { action: 'executer' };
},
terminer() {
enCours = false;
},
expirer() {
armee = false;
},

View file

@ -65,10 +65,12 @@ describe('commande : la machine', () => {
assert.equal(DELAI_CONFIRMATION_MS, 10_000);
});
test('sans confirmer, chaque clic exécute, et la commande ne s’arme jamais', () => {
test('sans confirmer, chaque clic exécute une fois l’exécution précédente terminée, et la commande ne s’arme jamais', () => {
const commande = creerMachineCommande();
assert.deepEqual(commande.cliquer(), { action: 'executer' });
commande.terminer();
assert.deepEqual(commande.cliquer(), { action: 'executer' });
commande.terminer();
assert.equal(commande.armee, false);
});
@ -79,9 +81,55 @@ describe('commande : la machine', () => {
assert.equal(commande.armee, true);
assert.deepEqual(commande.cliquer(), { action: 'executer' });
assert.equal(commande.armee, false);
commande.terminer();
assert.deepEqual(commande.cliquer(), { action: 'armer' }, 'après exécution, le clic suivant arme de nouveau');
});
test('pendant l’exécution, un clic ne fait rien : ni seconde exécution, ni armement ; terminer() la rend', () => {
const simple = creerMachineCommande();
assert.deepEqual(simple.cliquer(), { action: 'executer' });
assert.equal(simple.enCours, true);
assert.deepEqual(simple.cliquer(), { action: 'aucune' });
assert.deepEqual(simple.cliquer({ repete: true }), { action: 'aucune' });
simple.terminer();
assert.equal(simple.enCours, false);
assert.deepEqual(simple.cliquer(), { action: 'executer' });
const confirmee = creerMachineCommande({ confirmer: CONFIRMATION_DEBLOCAGE });
confirmee.cliquer();
assert.deepEqual(confirmee.cliquer(), { action: 'executer' });
assert.deepEqual(confirmee.cliquer(), { action: 'aucune' }, 'occupée, le clic ne la réarme pas');
assert.equal(confirmee.armee, false);
confirmee.terminer();
assert.deepEqual(confirmee.cliquer(), { action: 'armer' });
});
test('armée, le clic répété d’un double-clic ne confirme pas : elle reste armée, et un clic simple ensuite exécute (§ 8.5)', () => {
const commande = creerMachineCommande({ confirmer: CONFIRMATION_DEBLOCAGE });
assert.deepEqual(commande.cliquer({ repete: false }), { action: 'armer' });
assert.deepEqual(commande.cliquer({ repete: true }), { action: 'aucune' });
assert.equal(commande.armee, true);
assert.equal(commande.enCours, false);
assert.deepEqual(commande.cliquer({ repete: true }), { action: 'aucune' }, 'un triple clic non plus');
assert.deepEqual(commande.cliquer({ repete: false }), { action: 'executer' });
assert.equal(commande.armee, false);
});
test('le clic répété d’une série n’exécute jamais, avec ou sans confirmer : il ne fait rien, et le clic simple suivant exécute', () => {
const simple = creerMachineCommande();
assert.deepEqual(simple.cliquer({ repete: true }), { action: 'aucune' }, 'reçu sans le premier clic de sa série');
assert.equal(simple.enCours, false);
assert.deepEqual(simple.cliquer({ repete: false }), { action: 'executer' });
simple.terminer();
assert.deepEqual(simple.cliquer({ repete: true }), { action: 'aucune' }, 'le second clic d’un double-clic');
assert.deepEqual(simple.cliquer({ repete: true }), { action: 'aucune' }, 'le troisième');
assert.deepEqual(simple.cliquer(), { action: 'executer' });
const confirmee = creerMachineCommande({ confirmer: CONFIRMATION_DEBLOCAGE });
assert.deepEqual(confirmee.cliquer({ repete: true }), { action: 'aucune' }, 'désarmée, le clic répété ne l’arme pas');
assert.equal(confirmee.armee, false);
});
test('armée puis expirer() : désarmée, et le clic suivant arme de nouveau, sans exécuter', () => {
const commande = creerMachineCommande({ confirmer: CONFIRMATION_DEBLOCAGE });
commande.cliquer();

View file

@ -12,6 +12,7 @@
// Le projet navigateur emploie expect, et test et describe de Vitest
// (§ 14.8).
import { afterEach, describe, expect, test, vi } from 'vitest';
import { cdp } from 'vitest/browser';
import { flushSync, mount, tick, unmount } from 'svelte';
import '../jetons.css';
import { TABLES_APPLICATION } from '../../application/libelles.js';
@ -132,6 +133,41 @@ async function cliquerAilleurs() {
await tick();
}
// Un double-clic réel au centre de l'élément : deux appuis, le second
// annoncé par Chromium comme le deuxième clic d'une série (clickCount 2),
// si bien que son événement click porte detail 2. Les coordonnées du cadre
// de l'épreuve passent dans la fenêtre de la page qui le porte, d'un cadre
// à son parent, comme dans test/navigateur/entrees.js.
async function doubleCliquer(element) {
let { x, y } = centre(element);
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;
x = boite.left + (cadre.clientLeft + parseFloat(style.paddingLeft) + x) * echelle;
y = boite.top + (cadre.clientTop + parseFloat(style.paddingTop) + y) * echelle;
}
for (const clickCount of [1, 2]) {
await cdp().send('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', buttons: 1, clickCount });
await cdp().send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', buttons: 0, clickCount });
}
await tick();
}
// Une touche Entrée tenue : un premier appui, puis repetitions appuis que
// Chromium marque répétés (autoRepeat, d'où repeat vrai sur keydown), comme
// la répétition automatique du clavier, et un seul relâcher. Les événements
// vont à l'élément qui a le focus.
async function tenirEntree(repetitions) {
const entree = { key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13 };
const appui = { type: 'keyDown', ...entree, text: '\r', unmodifiedText: '\r' };
await cdp().send('Input.dispatchKeyEvent', appui);
for (let n = 0; n < repetitions; n += 1) await cdp().send('Input.dispatchKeyEvent', { ...appui, autoRepeat: true });
await cdp().send('Input.dispatchKeyEvent', { type: 'keyUp', ...entree });
await tick();
}
async function taper(texte) {
for (const caractere of texte) await touche(caractere);
await tick();
@ -539,6 +575,39 @@ describe('le remède « Débloquer » (§ 8.5)', () => {
expect(montage.seance.etat().evenement.etat).toBe('bloque');
});
test('un double-clic arme la commande sans débloquer : elle reste armée, rien d’écrit ; un clic simple ensuite débloque', async () => {
const montage = await preparer({ bloque: true });
const { message } = await refusAuPremierCaractere2(montage);
const bouton = message.querySelector('button');
const ecritures = montage.fs.compteurs.ecritures;
const clics = [];
bouton.addEventListener('click', (evenement) => clics.push(evenement.detail));
await doubleCliquer(bouton);
expect(clics).toEqual([1, 2]);
expect(bouton.textContent.trim()).toBe('Confirmer le déblocage');
expect(montage.fs.compteurs.ecritures).toBe(ecritures);
expect(montage.seance.etat().evenement.etat).toBe('bloque');
await cliquer(bouton);
await vi.waitFor(() => expect(montage.seance.etat().evenement.etat).not.toBe('bloque'));
});
test('Entrée tenue arme la commande sans débloquer : les appuis répétés du clavier ne confirment pas, rien d’écrit ; un appui simple ensuite débloque', async () => {
const montage = await preparer({ bloque: true });
const { message } = await refusAuPremierCaractere2(montage);
const bouton = message.querySelector('button');
const ecritures = montage.fs.compteurs.ecritures;
const appuis = [];
bouton.addEventListener('keydown', (evenement) => appuis.push(evenement.repeat));
bouton.focus();
await tenirEntree(3);
expect(montage.seance.etat().evenement.etat).toBe('bloque');
expect(montage.fs.compteurs.ecritures).toBe(ecritures);
expect(bouton.textContent.trim()).toBe('Confirmer le déblocage');
expect(appuis).toEqual([false, true, true, true]);
await tenirEntree(0);
await vi.waitFor(() => expect(montage.seance.etat().evenement.etat).not.toBe('bloque'));
});
// Le refus d'un plan bloqué posé contre le champ par une frappe.
async function refusAuPremierCaractere2(montage) {
const { cible, entree } = monterChamp(montage);
@ -824,6 +893,7 @@ describe('fenêtre à confirmer (§ 2.1)', () => {
const message = fenetre.querySelector('[data-refus]');
expect(message.getAttribute('data-refus')).toBe('MODE_LECTURE');
expect(visible(message)).toBe(true);
expect(fenetre.querySelectorAll('.issues button')).toHaveLength(2);
for (const bouton of fenetre.querySelectorAll('.issues button')) expect(bouton.getAttribute('aria-disabled')).toBeNull();
expect(montage.seance.etat().charge.participants).toHaveLength(1);
await cliquer(fenetre.querySelector('[data-commande="renoncer"]'));
@ -870,6 +940,7 @@ describe('fenêtre à confirmer (§ 2.1)', () => {
expect(fenetre.open).toBe(true);
expect(fenetre.matches(':modal')).toBe(true);
expect(fenetre.getAttribute('aria-busy')).toBe('true');
expect(fenetre.querySelectorAll('.issues button')).toHaveLength(2);
for (const bouton of fenetre.querySelectorAll('.issues button')) expect(bouton.getAttribute('aria-disabled')).toBe('true');
expect(montage.seance.etat().charge.participants).toHaveLength(1);
}
@ -905,6 +976,7 @@ describe('fenêtre à confirmer (§ 2.1)', () => {
expect(fenetre.matches(':modal')).toBe(true);
expect(fenetre.querySelector('[data-refus]').getAttribute('data-refus')).toBe('MODE_LECTURE');
expect(fenetre.getAttribute('aria-busy')).toBeNull();
expect(fenetre.querySelectorAll('.issues button')).toHaveLength(2);
for (const bouton of fenetre.querySelectorAll('.issues button')) expect(bouton.getAttribute('aria-disabled')).toBeNull();
expect(appels).toEqual(['confirmer']);
await cliquer(fenetre.querySelector('[data-commande="renoncer"]'));
@ -979,6 +1051,52 @@ describe('commande (§ 2.1)', () => {
await vi.waitFor(() => expect(montage.seance.etat().charge.participants).toHaveLength(2));
});
test('pendant son exécution, un second clic réel ne relance rien : le bouton se dit occupé ; finie, un clic l’exécute de nouveau', async () => {
const montage = await preparer();
let appels = 0;
let liberer;
const cible = monter(
Commande,
{
cle: 'communs.remede.ajouterParticipant',
executer: () => {
appels += 1;
return new Promise((resoudre) => {
liberer = resoudre;
});
},
},
montage.contexte,
);
const bouton = cible.querySelector('button[data-commande="ajouterParticipant"]');
const styleRepos = getComputedStyle(bouton).borderTopStyle;
await cliquer(bouton);
expect(appels).toBe(1);
expect(bouton.getAttribute('aria-busy')).toBe('true');
expect(bouton.getAttribute('aria-disabled')).toBe('true');
expect(getComputedStyle(bouton).borderTopStyle).not.toBe(styleRepos);
await cliquer(bouton);
await doubleCliquer(bouton);
expect(appels).toBe(1);
liberer();
await vi.waitFor(() => expect(bouton.getAttribute('aria-busy')).toBeNull());
expect(bouton.getAttribute('aria-disabled')).toBeNull();
await cliquer(bouton);
expect(appels).toBe(2);
liberer();
});
test('une exécution sans promesse rend aussitôt la commande : deux clics de suite, deux exécutions', async () => {
const montage = await preparer();
let appels = 0;
const cible = monter(Commande, { cle: 'communs.commande.lecture', executer: () => void (appels += 1) }, montage.contexte);
const bouton = cible.querySelector('button');
bouton.click();
bouton.click();
expect(appels).toBe(2);
expect(bouton.getAttribute('aria-busy')).toBeNull();
});
test('sans refus, son libellé est celui de sa clé, et data-commande le dernier segment de la clé', async () => {
const montage = await preparer();
const cible = monter(Commande, { cle: 'communs.commande.lecture', executer: () => undefined }, montage.contexte);

View file

@ -242,6 +242,42 @@ describe('une table créée (§ 4, § 6.1)', () => {
});
});
describe('un geste en cours ne se relance pas (§ 2.1)', () => {
test('« Créer une table » cliquée deux fois pendant l’écriture d’un support lent : une table, une entrée', async () => {
const m = await preparer(chargeDe({ n: 2 }));
const r0 = revision(m);
// Le système simulé retient ses écritures jusqu'à liberer() : le geste
// reste en cours d'écriture. Le premier geste crée le journal, par une
// écriture entière ; les suivants y ajoutent une ligne.
let liberer;
const porte = new Promise((resoudre) => {
liberer = resoudre;
});
for (const primitive of ['ajouterLigne', 'ecrireAtomique']) {
const ecrire = m.fs[primitive];
m.fs[primitive] = async (...parametres) => {
await porte;
return ecrire.apply(m.fs, parametres);
};
}
const bouton = commande(section(m), 'creerTableRonde');
bouton.scrollIntoView({ block: 'center' });
const boite = bouton.getBoundingClientRect();
const [x, y] = [boite.left + boite.width / 2, boite.top + boite.height / 2];
for (let clic = 0; clic < 2; clic += 1) {
await pointer('presser', x, y);
await pointer('relacher', x, y);
flushSync();
}
expect(bouton.getAttribute('aria-busy')).toBe('true');
liberer();
await attendre(m);
expect(charge(m).tables).toHaveLength(1);
expect(revision(m)).toBe(r0 + 1);
expect(bouton.getAttribute('aria-busy')).toBeNull();
});
});
describe('l’état, pas seulement la valeur (§ 6.1, § 19.3 05)', () => {
test('deux tables à 8, l’une suit le défaut, l’autre surchargée à 8 : les états diffèrent, seule la seconde offre « Réaligner » ; réalignée, son état égale celui de la première', async () => {
const m = await preparer(chargeDe({ tables: [{ id: 1 }, { id: 2, sieges: 8 }], n: 4 }));

View file

@ -57,5 +57,6 @@ mount(App, {
demonstrations,
formatCourant: FORMAT,
produitVersion: VERSION.affichee,
versionWindows: VERSION.windows,
},
});

View file

@ -5,15 +5,17 @@
// par table, sommées sur un plan entier, rendent les chiffres de la mesure.
// Σ des paires séparer = collisionsCumulees ; Σ des personnes nouvelle table
// = totalRetoursChoisis ; paires distinctes nouveaux voisins =
// rencontresRepetees.choisies. D'abord les propositions d'une génération sur
// la grande démonstration, puis cent plans tirés sur de petites
// configurations, chacun de sa propre graine.
// rencontresRepetees.choisies. Les deux membres lisent le même parcours du
// plan (parcourirTours) : chacun se confronte aussi à un oracle écrit ici,
// sans le moteur, qui tient les règles d'exemption des réservations. D'abord
// les propositions d'une génération sur la grande démonstration, puis cent
// plans tirés sur de petites configurations, chacun de sa propre graine.
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { CATALOGUE } from '../demo/catalogue.js';
import { FLUX, creerPcg32 } from '../demo/prng.js';
import { conflitsParTable } from './conflits.js';
import { LIBRE, indexerPlan, normaliser, planDepuisIndex } from './configuration.js';
import { LIBRE, SANS_GROUPE, indexerPlan, normaliser, planDepuisIndex } from './configuration.js';
import { mesurer } from './indicateurs.js';
import { rechercher } from './recherche.js';
import { verifierInvariants } from './verification.js';
@ -31,7 +33,8 @@ const PLANS_ACCEPTES_MIN = 90;
// graines écrites ci-dessus : 100 plans acceptés ; 69 dont les trois totaux
// sont non nuls ; 32 où une paire réunie librement se retrouve à une table où
// les deux sont réservés ; 41 où une personne assise librement à une table y
// revient réservée.
// revient réservée. Les deux derniers comptent les cas où les règles
// d'exemption des réservations décident, que l'oracle juge.
const TOUS_NON_NULS_MIN = 55;
const PAIRE_PUIS_RESERVEE_MIN = 25;
const PERSONNE_PUIS_RESERVEE_MIN = 32;
@ -57,7 +60,47 @@ function sommer(conflits) {
return { separer, nouvelleTable, nouveauxVoisins: revues.size };
}
// Les écarts entre les sommes par table et la mesure du même plan.
// L'oracle des trois sommes d'un plan qui assied chacun, écrit sans le
// moteur, par les règles de l'itération 1. Séparer : chaque paire de même
// appartenance réunie, à chaque tour. Nouveaux voisins : une paire réunie
// au moins deux fois est choisie quand une de ses rencontres a lieu hors
// d'une table où les deux sont réservés. Nouvelle table : pour chaque
// personne et chaque table qu'elle visite m fois, dont f réservée, m − 1
// retours, dont max(0, f − 1) imposés, le reste choisi.
function oracle({ N, T, R, groupe, fixe }, tableDe) {
let separer = 0;
let nouveauxVoisins = 0;
for (let a = 0; a < N; a += 1) {
for (let b = a + 1; b < N; b += 1) {
let rencontres = 0;
let horsReservation = false;
for (let r = 0; r < R; r += 1) {
const t = tableDe[a * R + r];
if (t !== tableDe[b * R + r]) continue;
rencontres += 1;
if (groupe[a] !== SANS_GROUPE && groupe[a] === groupe[b]) separer += 1;
if (fixe[a * R + r] !== t || fixe[b * R + r] !== t) horsReservation = true;
}
if (rencontres >= 2 && horsReservation) nouveauxVoisins += 1;
}
}
let nouvelleTable = 0;
for (let p = 0; p < N; p += 1) {
const visites = new Int32Array(T);
const reservees = new Int32Array(T);
for (let r = 0; r < R; r += 1) {
const t = tableDe[p * R + r];
visites[t] += 1;
if (fixe[p * R + r] === t) reservees[t] += 1;
}
for (let t = 0; t < T; t += 1) {
if (visites[t] > 0) nouvelleTable += visites[t] - 1 - Math.max(0, reservees[t] - 1);
}
}
return { separer, nouvelleTable, nouveauxVoisins };
}
// Les écarts entre les sommes par table, la mesure du même plan et l'oracle.
function ecarts(nom, instance, tableDe) {
const sommes = sommer(conflitsParTable(instance, tableDe));
const mesures = mesurer(instance, tableDe);
@ -66,11 +109,15 @@ function ecarts(nom, instance, tableDe) {
nouvelleTable: mesures.totalRetoursChoisis,
nouveauxVoisins: mesures.rencontresRepetees.choisies,
};
const devine = oracle(instance, tableDe);
const fautes = [];
for (const cle of ['separer', 'nouvelleTable', 'nouveauxVoisins']) {
if (sommes[cle] !== attendu[cle]) {
fautes.push(`${nom} : ${cle} ${sommes[cle]} par table, ${attendu[cle]} mesurés`);
}
if (attendu[cle] !== devine[cle]) {
fautes.push(`${nom} : ${cle} ${attendu[cle]} mesurés, ${devine[cle]} par l'oracle`);
}
}
return { fautes, sommes };
}

View file

@ -433,7 +433,8 @@ const porteUnRetenuAbime = (charge) =>
// enregistrement, 1 et 2 en prennent la variante ; un booléen garde un
// candidat. Les réservations et les titres qui ne tiennent plus dans la
// charge s'écartent, comme la commande qui retire leur personne ou leur
// table : la charge reste un fichier que l'analyse admet. Une à trois
// table, et un titre répété à l'identique aussi : la charge reste un
// fichier que l'analyse admet. Une à trois
// propositions ; le retenu, null ou le plan d'une proposition, abîmé d'une
// des façons d'ABIMER_LE_RETENU quand abime en désigne une. Renversées, les
// collections arrivent hors de l'ordre canonique.
@ -455,7 +456,15 @@ function construire(vivier, choisir) {
(r.tour === null || r.tour <= evenement.tours),
)
.map(copie);
const titres = vivier.titres.filter((t, rang) => choisir('titres', rang) && tient(t.table, t.siege)).map(copie);
// Un titre répété à l'identique, que l'analyse refuse, ne se garde qu'une fois.
const vus = new Set();
const nouveau = ({ table, siege, libelle }) => {
const cle = JSON.stringify([table, siege, libelle]);
if (vus.has(cle)) return false;
vus.add(cle);
return true;
};
const titres = vivier.titres.filter((t, rang) => choisir('titres', rang) && tient(t.table, t.siege) && nouveau(t)).map(copie);
const propositions = variantes('propositions');
if (propositions.length === 0) propositions.push(copie(vivier.propositions[0][0]));
const retenu = choisir('retenu') < 3 ? null : retenuDe(vivier.propositions[choisir('retenu') - 3][0]);

View file

@ -18,7 +18,8 @@
// tout le reste parce qu'il dit comment lire le reste (FORME s'il manque,
// FORMAT_INCONNU s'il n'est pas un entier ≥ 1) ; la forme de chaque champ,
// dans l'ordre du schéma puis des rangs (FORME) ; les identifiants des
// participants et des tables, et le tour des réservations (FORME) ; les
// participants et des tables, le tour des réservations, et les titres
// répétés à l'identique (FORME) ; les
// comptes de la saisie dans l'en-tête — participants, tables, réservations,
// titres — (COMPTES) ; les références (REFERENCE). Les comptes des
// propositions et du retenu ne refusent jamais le fichier (§ 8.9, point 3) :
@ -46,6 +47,25 @@ import { ErreurStockage } from './erreurs.js';
/** Version de format que ce code écrit (§ 8.8). */
export const FORMAT = 1;
/**
* Le plus grand nombre de places d'une table, au défaut comme en surcharge
* (§ 6.2). Le plan dessine une chaise par place, et l'état des places en
* construit une par place et par table à chaque rendu : une capacité saisie
* au-delà rendrait l'événement inaffichable sans rien apporter — à 60 cm par
* place (§ 7.7), 1 000 places font une table ronde de 191 m de diamètre.
* L'analyse refuse au-delà, et les commandes refusent la saisie.
*/
export const SIEGES_MAX = 1000;
/**
* Le plus grand nombre de tours d'une soirée (§ 2.1, étape 5). Le moteur
* réserve une case par participant et par tour dans ses tableaux, et le
* placement écrit une liste par table et par tour : cent tours de vingt
* minutes durent plus d'un jour. L'analyse refuse au-delà, et la commande
* refuse la saisie.
*/
export const TOURS_MAX = 100;
/**
* Réglages de génération d'une charge neuve : cinq propositions de 200 000
* mouvements, valeurs de départ non mesurées (§ 5.10), et l'historique
@ -152,8 +172,8 @@ const EVENEMENT = objet([
['id', CHAINE],
['nom', CHAINE],
['date', DATE_OU_NUL],
['siegesParDefaut', entier(2)],
['tours', entier(1)],
['siegesParDefaut', entier(2, SIEGES_MAX)],
['tours', entier(1, TOURS_MAX)],
['unite', parmi('cm')],
['etat', parmi('brouillon', 'propose', 'retenu', 'bloque')],
['filiation', FILIATION],
@ -182,7 +202,7 @@ const PARTICIPANT = objet([
const TABLE = objet([
['id', IDENTIFIANT],
['numero', entier(1)],
['sieges', entier(2, undefined, { nul: true })],
['sieges', entier(2, SIEGES_MAX, { nul: true })],
['forme', parmi('ronde', 'carree')],
['position', objet([['x', NOMBRE], ['y', NOMBRE]])],
]);
@ -511,6 +531,19 @@ function examinerIdentifiants({ prochainsIds, participants, tables }, faute) {
}
}
// Titres : deux titres de même table, même siège et même libellé
// s'écriraient pareil et ne se distingueraient sur aucun écran ; le second
// est fautif. Deux libellés à une même place restent admis (§ 4.1).
// faute(chemin) lève l'écart.
function examinerTitres({ titres }, faute) {
const vus = new Set();
titres.forEach(({ table, siege, libelle }, rang) => {
const cle = JSON.stringify([table, siege, libelle]);
if (vus.has(cle)) faute(`charge.titres[${rang}]`);
vus.add(cle);
});
}
// Tour de chaque réservation : null pour la portée « tous », de 1 à R pour
// la portée « tour » ; faute(chemin) lève l'écart.
function examinerTours({ evenement, reservations }, faute) {
@ -611,6 +644,7 @@ export function analyser(texte) {
const { entete, charge } = fichier;
examinerIdentifiants(charge, faute);
examinerTours(charge, faute);
examinerTitres(charge, faute);
const manquants = examinerComptes(entete.comptes, charge, plus);
examinerReferences(charge, plus);
return { entete, charge, formatPlusRecent, manquants };

View file

@ -21,6 +21,8 @@ import {
FORMAT,
GENERATION_PAR_DEFAUT,
SCHEMA,
SIEGES_MAX,
TOURS_MAX,
analyser,
capacite,
configurationDepuisCharge,
@ -404,6 +406,43 @@ describe('analyser : les refus que le plan nomme (§ 8.8)', () => {
});
});
describe('analyser : les bornes des places et des tours, et un titre par place et par libellé (§ 6.2, § 4.1)', () => {
test('SIEGES_MAX places et TOURS_MAX tours se lisent ; un de plus, au défaut, à une table ou aux tours, est FORME à son chemin', () => {
assert.equal(SIEGES_MAX, 1000);
assert.equal(TOURS_MAX, 100);
const admis = [
abime((d) => { d.charge.evenement.siegesParDefaut = SIEGES_MAX; }),
abime((d) => { d.charge.tables[1].sieges = SIEGES_MAX; }),
abime((d) => { d.charge.evenement.tours = TOURS_MAX; }),
];
for (const texte of admis) assert.doesNotThrow(() => analyser(texte));
assert.deepEqual(ecartsDeRefus([
['défaut au-delà', abime((d) => { d.charge.evenement.siegesParDefaut = SIEGES_MAX + 1; }), 'FORME', 'charge.evenement.siegesParDefaut'],
['table au-delà', abime((d) => { d.charge.tables[1].sieges = SIEGES_MAX + 1; }), 'FORME', 'charge.tables[1].sieges'],
['tours au-delà', abime((d) => { d.charge.evenement.tours = TOURS_MAX + 1; }), 'FORME', 'charge.evenement.tours'],
['défaut à 2^32', abime((d) => { d.charge.evenement.siegesParDefaut = 2 ** 32; }), 'FORME', 'charge.evenement.siegesParDefaut'],
]), []);
});
test('deux titres identiques à une même place sont FORME au second ; deux libellés distincts à la même place se lisent', () => {
const doublon = { table: 3, siege: 4, libelle: 'animation' };
assert.deepEqual(ecartsDeRefus([
['titre répété', abime((d) => {
d.charge.titres.push({ ...doublon });
d.entete.comptes.titres = 2;
}), 'FORME', 'charge.titres[1]'],
['titre répété, rangé avant un autre', abime((d) => {
d.charge.titres = [{ ...doublon }, { table: 1, siege: 1, libelle: 'accueil' }, { ...doublon }];
d.entete.comptes.titres = 3;
}), 'FORME', 'charge.titres[2]'],
]), []);
assert.doesNotThrow(() => analyser(abime((d) => {
d.charge.titres.push({ ...doublon, libelle: 'accueil' });
d.entete.comptes.titres = 2;
})));
});
});
describe('analyser : texte vide, JSON, racine et format', () => {
test('chaque refus porte sa raison et son chemin ; null quand aucun élément ne se désigne', () => {
const valide = JSON.stringify(documentValide());

View file

@ -160,6 +160,7 @@ export async function monterApp({
demonstrations: demonstrations ?? DEMONSTRATIONS,
formatCourant: formatCourant ?? FORMAT,
produitVersion: VERSION.affichee,
versionWindows: VERSION.windows,
...(theme === null ? {} : { themeInitial: theme }),
},
});

View file

@ -30,8 +30,9 @@ const PROCESSUS = { pool: SERIES.pool, maxWorkers: SERIES.maxWorkers };
// Les épreuves node qui lisent les composants de src/interface par le
// système de fichiers — chaînes, couleurs, en-tête, jetons lus, écouteurs du
// plan, version affichée, graphe des couches —, et celle qui importe
// App.svelte pour en voir l'échec sous node.
// plan, version affichée, graphe des couches, version qu'App reçoit par ses
// propriétés —, et celle qui importe App.svelte pour en voir l'échec sous
// node.
const RELANCES_D_UN_COMPOSANT = Object.freeze([
'test/arborescence.test.js',
'test/sources_interface.test.js',
@ -40,6 +41,7 @@ const RELANCES_D_UN_COMPOSANT = Object.freeze([
'test/licence.test.js',
'src/interface/palette.test.js',
'src/interface/plan/ecouteurs.test.js',
'src/interface/apropos.test.js',
]);
// Les épreuves node qui lisent la feuille des jetons par le système de
@ -89,35 +91,44 @@ export default defineConfig({
},
},
// Sous surveillance, une épreuve se relance quand un module de son graphe
// d'imports change. Les fichiers des motifs se lisent hors de tout
// graphe : le contrôle de version (§ 18.5) lit les premiers par le
// système de fichiers ; la coquille s'exécute dans un processus à part,
// la configuration d'electron-builder se charge par un require natif et
// lit capacitor.config.json, et le script de construction se lance
// depuis une copie. Les composants de src/interface et la feuille des
// jetons se lisent comme du texte par les gardes qui les balaient ;
// sous node, sans greffon Svelte, aucun graphe ne les parcourt. Chaque
// motif rattache ses fichiers aux épreuves qui les lisent, et au
// contrôle de version quand son point 6 les balaie. Un fichier qui
// répond à un motif ne relance que les épreuves nommées, et plus celles
// qui l'importeraient : un motif relance donc toute épreuve qui importe
// l'un de ses fichiers — test/fichiers_electron.test.js importe
// electron/fichiers.js, test/projets.test.js importe App.svelte —, et ne
// nomme aucune table de libellés, que le graphe suffit à suivre.
// test/arborescence.test.js garde cette règle, et relève toute épreuve
// de la série node dont le texte lit un composant ou une feuille de
// style de src/interface sans que son motif la relance. L'option se lit au
// niveau du lanceur : posée dans un projet, elle est ignorée sans
// avertissement.
// d'imports change. Les fichiers des motifs se lisent hors de tout graphe :
// le contrôle de version (§ 18.5) lit les premiers par le système de
// fichiers ; la coquille s'exécute dans un processus à part, la
// configuration d'electron-builder se charge par un require natif et lit
// capacitor.config.json, et le script de construction se lance depuis une
// copie ; le point d'entrée de la page et le montage des épreuves du
// navigateur, qu'aucune épreuve node n'importe, se lisent par le disque, la
// langue et les démonstrations qu'ils passent à App comprises
// (test/livrable.test.js). Les composants de src/interface et la feuille des
// jetons se lisent comme du texte par les gardes qui les balaient ; sous
// node, sans greffon Svelte, aucun graphe ne les parcourt. Chaque motif
// rattache ses fichiers aux épreuves qui les lisent, et au contrôle de
// version quand son point 6 les balaie. Un fichier qui répond à un motif ne
// relance que les épreuves nommées, et plus celles qui l'importeraient : un
// motif relance donc toute épreuve de la série node qui importe l'un de ses
// fichiers — test/fichiers_electron.test.js importe electron/fichiers.js,
// test/projets.test.js importe App.svelte —, et ne nomme aucune table de
// libellés, que le graphe suffit à suivre. test/arborescence.test.js garde
// cette règle, et relève toute épreuve de la série node dont le texte lit un
// composant ou une feuille de style de src/interface sans que son motif la
// relance. Les motifs ne servent que la série node, la seule que la commande
// surveillée lance (§ 14.4) : sous une surveillance du projet navigateur,
// lancée à la main, un composant ou la feuille des jetons modifiés ne
// relancent aucune épreuve du navigateur, que le motif tait ; elles se
// relancent à la main. L'option se lit au niveau du lanceur : posée dans un
// projet, elle est ignorée sans avertissement.
watchTriggerPatterns: [
{ pattern: /(^|\/)(version\.json|package-lock\.json|CHANGELOG\.md|README\.md|GUIDE-[^/]*\.md)$/,
testsToRun: () => 'scripts/version.test.js' },
{ pattern: /(^|\/)electron\/[^/]+$/,
testsToRun: () => ['test/coquille.test.js', 'test/fichiers_electron.test.js', 'test/livrable.test.js', 'scripts/version.test.js'] },
testsToRun: () => ['test/coquille.test.js', 'test/fichiers_electron.test.js', 'test/livrable.test.js',
'scripts/essai_demarrage.test.js', 'scripts/version.test.js'] },
{ pattern: /(^|\/)test\/electron_factice\.js$/,
testsToRun: () => ['test/coquille.test.js', 'scripts/version.test.js'] },
{ pattern: /(^|\/)(electron-builder\.config\.cjs|capacitor\.config\.json)$/,
testsToRun: () => ['test/livrable.test.js', 'scripts/construire_windows.test.js', 'scripts/version.test.js'] },
{ pattern: /(^|\/)(src\/main\.js|test\/navigateur\/montage\.js)$/,
testsToRun: () => ['test/livrable.test.js', 'test/arborescence.test.js', 'scripts/version.test.js'] },
{ pattern: /(^|\/)scripts\/construire_windows\.sh$/,
testsToRun: () => ['scripts/construire_windows.test.js', 'scripts/version.test.js'] },
{ pattern: /(^|\/)src\/interface\/.+\.svelte$/,