From ddd587da87bb8374ba253569818a08335af3ae81 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 7 Oct 2026 17:50:35 -0400 Subject: [PATCH] [FIX] iteration 3 review: seat targets, gestures, warnings, settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/application/avertissements.js | 182 +++++++++++++-- src/application/avertissements.test.js | 168 +++++++++++++- src/application/bilans.js | 17 +- src/application/bilans.test.js | 11 +- src/application/capacite.js | 9 +- src/application/capacite.test.js | 13 +- src/application/commandes_mobilier.js | 6 +- src/application/commandes_mobilier.test.js | 31 ++- src/application/commandes_places.js | 54 +++-- src/application/commandes_places.test.js | 55 +++++ src/application/executeur_travailleur.js | 30 ++- src/application/executeur_travailleur.test.js | 42 +++- src/application/generation_travailleur.js | 26 ++- .../generation_travailleur.test.js | 12 + src/application/libelles.test.js | 49 ++++- src/application/libelles/generation.js | 8 + src/application/libelles/mobilier.js | 2 + src/application/libelles/places.js | 6 + src/application/libelles/seance.js | 17 +- src/application/libelles/stockage.js | 24 +- src/application/places.js | 45 ++-- src/application/places.test.js | 56 ++++- src/application/registre.test.js | 39 +++- src/application/seance.js | 14 +- src/application/seance.test.js | 51 ++++- src/geometrie/designation.js | 13 +- src/geometrie/designation.test.js | 68 +++++- src/geometrie/tables.js | 65 +++--- src/geometrie/tables.test.js | 91 ++++++-- src/interface/App.svelte | 18 +- .../accueil/Accueil.navigateur.test.js | 65 ++++++ src/interface/accueil/Accueil.svelte | 24 +- src/interface/accueil/Demonstrations.svelte | 31 ++- src/interface/accueil/Operations.svelte | 24 +- src/interface/apropos.js | 12 +- src/interface/apropos.test.js | 32 +++ src/interface/etat_ui.js | 21 +- src/interface/etat_ui.test.js | 40 ++++ src/interface/evenement/Avertissements.svelte | 11 +- src/interface/evenement/CadreMode.svelte | 97 ++++++-- .../evenement/Evenement.navigateur.test.js | 192 +++++++++++++++- src/interface/evenement/modele.js | 15 +- .../generation/Generation.navigateur.test.js | 134 +++++++++++- src/interface/generation/Reglages.svelte | 82 +++++++ .../generation/SectionGeneration.svelte | 9 +- .../import/Import.navigateur.test.js | 36 +++ src/interface/libelles/generation.js | 8 + src/interface/libelles/participants.js | 1 + src/interface/libelles/plan.js | 17 +- src/interface/participants/Fiche.svelte | 5 +- src/interface/participants/Formulaire.svelte | 70 +++++- src/interface/participants/Grille.svelte | 6 +- .../Participants.navigateur.test.js | 151 +++++++++++++ src/interface/participants/grille.js | 27 ++- src/interface/participants/grille.test.js | 41 +++- src/interface/plan/BarreVue.svelte | 26 ++- src/interface/plan/Dessin.navigateur.test.js | 33 +++ src/interface/plan/Fantome.svelte | 3 +- src/interface/plan/Gestes.navigateur.test.js | 83 ++++++- src/interface/plan/ListesTravail.svelte | 4 +- .../plan/Panneaux.navigateur.test.js | 207 +++++++++++++++++- src/interface/plan/Plan.svelte | 28 ++- src/interface/plan/RefusPlace.svelte | 10 + src/interface/plan/TablePlan.svelte | 18 +- src/interface/plan/depot.js | 46 +++- src/interface/plan/depot.test.js | 35 ++- src/interface/plan/gestes.js | 25 ++- src/interface/plan/gestes.test.js | 17 ++ src/interface/plan/vue_plan.js | 33 +++ src/interface/plan/vue_plan.test.js | 45 ++++ src/interface/primitives/Commande.svelte | 46 +++- src/interface/primitives/commande.js | 35 ++- src/interface/primitives/commande.test.js | 50 ++++- .../primitives/primitives.navigateur.test.js | 118 ++++++++++ .../tables/Tables.navigateur.test.js | 36 +++ src/main.js | 1 + src/moteur/conflits.long.test.js | 59 ++++- src/stockage/correctifs.test.js | 13 +- src/stockage/document.js | 42 +++- src/stockage/document.test.js | 39 ++++ test/navigateur/montage.js | 1 + vitest.config.js | 57 +++-- 82 files changed, 3177 insertions(+), 306 deletions(-) diff --git a/src/application/avertissements.js b/src/application/avertissements.js index f0c72b2..e574ed0 100644 --- a/src/application/avertissements.js +++ b/src/application/avertissements.js @@ -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; } diff --git a/src/application/avertissements.test.js b/src/application/avertissements.test.js index 353cde6..52c2f17 100644 --- a/src/application/avertissements.test.js +++ b/src/application/avertissements.test.js @@ -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); + }); +}); diff --git a/src/application/bilans.js b/src/application/bilans.js index 361e6bc..57b50f2 100644 --- a/src/application/bilans.js +++ b/src/application/bilans.js @@ -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, diff --git a/src/application/bilans.test.js b/src/application/bilans.test.js index 8c383b0..dc913b7 100644 --- a/src/application/bilans.test.js +++ b/src/application/bilans.test.js @@ -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); diff --git a/src/application/capacite.js b/src/application/capacite.js index 980fec5..b127b0c 100644 --- a/src/application/capacite.js +++ b/src/application/capacite.js @@ -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 diff --git a/src/application/capacite.test.js b/src/application/capacite.test.js index 2f95487..4994dc2 100644 --- a/src/application/capacite.test.js +++ b/src/application/capacite.test.js @@ -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, { diff --git a/src/application/commandes_mobilier.js b/src/application/commandes_mobilier.js index eaa31bb..e6f56c4 100644 --- a/src/application/commandes_mobilier.js +++ b/src/application/commandes_mobilier.js @@ -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); diff --git a/src/application/commandes_mobilier.test.js b/src/application/commandes_mobilier.test.js index 35b76e8..f59e364 100644 --- a/src/application/commandes_mobilier.test.js +++ b/src/application/commandes_mobilier.test.js @@ -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)', () => { diff --git a/src/application/commandes_places.js b/src/application/commandes_places.js index 41e310b..cbcefa3 100644 --- a/src/application/commandes_places.js +++ b/src/application/commandes_places.js @@ -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 diff --git a/src/application/commandes_places.test.js b/src/application/commandes_places.test.js index 831187c..1a5b181 100644 --- a/src/application/commandes_places.test.js +++ b/src/application/commandes_places.test.js @@ -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'); }); }); diff --git a/src/application/executeur_travailleur.js b/src/application/executeur_travailleur.js index 98bc9cb..209f22e 100644 --- a/src/application/executeur_travailleur.js +++ b/src/application/executeur_travailleur.js @@ -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 }); diff --git a/src/application/executeur_travailleur.test.js b/src/application/executeur_travailleur.test.js index a2653cf..cd974b6 100644 --- a/src/application/executeur_travailleur.test.js +++ b/src/application/executeur_travailleur.test.js @@ -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); }); }); diff --git a/src/application/generation_travailleur.js b/src/application/generation_travailleur.js index 8781b0d..b7d5c6e 100644 --- a/src/application/generation_travailleur.js +++ b/src/application/generation_travailleur.js @@ -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 }); }; } diff --git a/src/application/generation_travailleur.test.js b/src/application/generation_travailleur.test.js index 42ae551..a7e8cfe 100644 --- a/src/application/generation_travailleur.test.js +++ b/src/application/generation_travailleur.test.js @@ -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' }); diff --git a/src/application/libelles.test.js b/src/application/libelles.test.js index fe602a0..c91780b 100644 --- a/src/application/libelles.test.js +++ b/src/application/libelles.test.js @@ -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); diff --git a/src/application/libelles/generation.js b/src/application/libelles/generation.js index 78f4188..9c37f94 100644 --- a/src/application/libelles/generation.js +++ b/src/application/libelles/generation.js @@ -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) => diff --git a/src/application/libelles/mobilier.js b/src/application/libelles/mobilier.js index 441284c..b3f1efc 100644 --- a/src/application/libelles/mobilier.js +++ b/src/application/libelles/mobilier.js @@ -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) ------------------------------- [ diff --git a/src/application/libelles/places.js b/src/application/libelles/places.js index 2e24bfe..b5e6008 100644 --- a/src/application/libelles/places.js +++ b/src/application/libelles/places.js @@ -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) --------------------------------- [ diff --git a/src/application/libelles/seance.js b/src/application/libelles/seance.js index c26849a..2c1700f 100644 --- a/src/application/libelles/seance.js +++ b/src/application/libelles/seance.js @@ -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', diff --git a/src/application/libelles/stockage.js b/src/application/libelles/stockage.js index 9f1cb78..5148b3b 100644 --- a/src/application/libelles/stockage.js +++ b/src/application/libelles/stockage.js @@ -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}.`], diff --git a/src/application/places.js b/src/application/places.js index 91dbcec..04c770f 100644 --- a/src/application/places.js +++ b/src/application/places.js @@ -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, siegeDe: Map, sansSiege: Map}} + */ +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 { diff --git a/src/application/places.test.js b/src/application/places.test.js index 9948a76..6f2f7d9 100644 --- a/src/application/places.test.js +++ b/src/application/places.test.js @@ -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 }); diff --git a/src/application/registre.test.js b/src/application/registre.test.js index 7678fe5..3bbda53 100644 --- a/src/application/registre.test.js +++ b/src/application/registre.test.js @@ -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); }); diff --git a/src/application/seance.js b/src/application/seance.js index 7443138..3012061 100644 --- a/src/application/seance.js +++ b/src/application/seance.js @@ -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. diff --git a/src/application/seance.test.js b/src/application/seance.test.js index 7ff7834..69efcdf 100644 --- a/src/application/seance.test.js +++ b/src/application/seance.test.js @@ -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(); diff --git a/src/geometrie/designation.js b/src/geometrie/designation.js index 0ab13c9..54c1985 100644 --- a/src/geometrie/designation.js +++ b/src/geometrie/designation.js @@ -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; } diff --git a/src/geometrie/designation.test.js b/src/geometrie/designation.test.js index 0a9f296..0e005a5 100644 --- a/src/geometrie/designation.test.js +++ b/src/geometrie/designation.test.js @@ -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`, () => { diff --git a/src/geometrie/tables.js b/src/geometrie/tables.js index 483ad38..147d839 100644 --- a/src/geometrie/tables.js +++ b/src/geometrie/tables.js @@ -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 }; } diff --git a/src/geometrie/tables.test.js b/src/geometrie/tables.test.js index 2f34a4b..5951715 100644 --- a/src/geometrie/tables.test.js +++ b/src/geometrie/tables.test.js @@ -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); }); }); diff --git a/src/interface/App.svelte b/src/interface/App.svelte index a92cde8..2065a72 100644 --- a/src/interface/App.svelte +++ b/src/interface/App.svelte @@ -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 diff --git a/src/interface/accueil/Accueil.navigateur.test.js b/src/interface/accueil/Accueil.navigateur.test.js index 909a047..7658dee 100644 --- a/src/interface/accueil/Accueil.navigateur.test.js +++ b/src/interface/accueil/Accueil.navigateur.test.js @@ -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(); diff --git a/src/interface/accueil/Accueil.svelte b/src/interface/accueil/Accueil.svelte index 6dd7b80..9ce0165 100644 --- a/src/interface/accueil/Accueil.svelte +++ b/src/interface/accueil/Accueil.svelte @@ -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}
- + {#if refusCreation !== null && champRefuse === null} {/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; + } diff --git a/src/interface/accueil/Demonstrations.svelte b/src/interface/accueil/Demonstrations.svelte index 8db1069..98d9386 100644 --- a/src/interface/accueil/Demonstrations.svelte +++ b/src/interface/accueil/Demonstrations.svelte @@ -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; + } + }
@@ -25,7 +42,13 @@
    {#each demonstrations as { cle, nom } (cle)}
  • - + {#if refus !== null && refus.cle === cle} {/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; + } diff --git a/src/interface/accueil/Operations.svelte b/src/interface/accueil/Operations.svelte index 15606d7..8146f11 100644 --- a/src/interface/accueil/Operations.svelte +++ b/src/interface/accueil/Operations.svelte @@ -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}
- + {#if refus !== null && !surLeChamp} @@ -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; + } diff --git a/src/interface/apropos.js b/src/interface/apropos.js index f294f73..7d64f4a 100644 --- a/src/interface/apropos.js +++ b/src/interface/apropos.js @@ -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, diff --git a/src/interface/apropos.test.js b/src/interface/apropos.test.js index d97b233..7e8351b 100644 --- a/src/interface/apropos.test.js +++ b/src/interface/apropos.test.js @@ -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); + }); +}); diff --git a/src/interface/etat_ui.js b/src/interface/etat_ui.js index ef2cca2..546b9d0 100644 --- a/src/interface/etat_ui.js +++ b/src/interface/etat_ui.js @@ -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. */ diff --git a/src/interface/etat_ui.test.js b/src/interface/etat_ui.test.js index 083dcf2..361cfdb 100644 --- a/src/interface/etat_ui.test.js +++ b/src/interface/etat_ui.test.js @@ -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); diff --git a/src/interface/evenement/Avertissements.svelte b/src/interface/evenement/Avertissements.svelte index 72a62ea..750dfb2 100644 --- a/src/interface/evenement/Avertissements.svelte +++ b/src/interface/evenement/Avertissements.svelte @@ -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 @@ {/if} {#if action !== null} - + {/if}
{/each} diff --git a/src/interface/evenement/CadreMode.svelte b/src/interface/evenement/CadreMode.svelte index 4ff3533..7e2649b 100644 --- a/src/interface/evenement/CadreMode.svelte +++ b/src/interface/evenement/CadreMode.svelte @@ -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);
@@ -105,12 +121,21 @@ {/if}
{#if tete !== null} -
- {@render tete()} +
+
+ {@render tete()} +
+ {#if aCote} +
+
+ {@render annonces()} +
+
+ {/if}
{/if} - {#if annonces !== null} -
+ {#if annonces !== null && !aCote} +
{@render annonces()}
{/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); } diff --git a/src/interface/evenement/Evenement.navigateur.test.js b/src/interface/evenement/Evenement.navigateur.test.js index 84b594d..059c178 100644 --- a/src/interface/evenement/Evenement.navigateur.test.js +++ b/src/interface/evenement/Evenement.navigateur.test.js @@ -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(); diff --git a/src/interface/evenement/modele.js b/src/interface/evenement/modele.js index 44176d8..4f85e90 100644 --- a/src/interface/evenement/modele.js +++ b/src/interface/evenement/modele.js @@ -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; diff --git a/src/interface/generation/Generation.navigateur.test.js b/src/interface/generation/Generation.navigateur.test.js index 232ce78..936b57d 100644 --- a/src/interface/generation/Generation.navigateur.test.js +++ b/src/interface/generation/Generation.navigateur.test.js @@ -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(); diff --git a/src/interface/generation/Reglages.svelte b/src/interface/generation/Reglages.svelte index 02d1efa..b018f41 100644 --- a/src/interface/generation/Reglages.svelte +++ b/src/interface/generation/Reglages.svelte @@ -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]; + } + }
@@ -74,6 +112,24 @@ /> {/each}
+
+ {t('generation.contraintes.titre')} + {#each BASCULES as { cle, etiquette } (cle)} +
+ basculer(evenement, cle)} + /> + + {#if refusBascule !== null && refusBascule.cle === cle} + (refusBascule = null)} /> + {/if} +
+ {/each} +
{#if mesure === null}

{t('generation.duree.inconnue')}

{: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; } diff --git a/src/interface/generation/SectionGeneration.svelte b/src/interface/generation/SectionGeneration.svelte index 52beb11..d5e07fd 100644 --- a/src/interface/generation/SectionGeneration.svelte +++ b/src/interface/generation/SectionGeneration.svelte @@ -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 }; diff --git a/src/interface/import/Import.navigateur.test.js b/src/interface/import/Import.navigateur.test.js index 884c3e4..fb0ac9c 100644 --- a/src/interface/import/Import.navigateur.test.js +++ b/src/interface/import/Import.navigateur.test.js @@ -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( diff --git a/src/interface/libelles/generation.js b/src/interface/libelles/generation.js index f626e80..ccdb873 100644 --- a/src/interface/libelles/generation.js +++ b/src/interface/libelles/generation.js @@ -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', diff --git a/src/interface/libelles/participants.js b/src/interface/libelles/participants.js index da54374..37438e8 100644 --- a/src/interface/libelles/participants.js +++ b/src/interface/libelles/participants.js @@ -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.'], diff --git a/src/interface/libelles/plan.js b/src/interface/libelles/plan.js index 45f2a37..8a70e84 100644 --- a/src/interface/libelles/plan.js +++ b/src/interface/libelles/plan.js @@ -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'], diff --git a/src/interface/participants/Fiche.svelte b/src/interface/participants/Fiche.svelte index b9b5304..4af63e5 100644 --- a/src/interface/participants/Fiche.svelte +++ b/src/interface/participants/Fiche.svelte @@ -4,7 +4,8 @@ -
+

{t('participants.formulaire.titre')}

{#each CHAMPS as cle (cle)} @@ -122,6 +161,8 @@
{/if} + {:else if cle === 'notes'} + {:else} {/if} @@ -129,7 +170,12 @@ {/each}
- + {#if refusAjout !== null} (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; + } diff --git a/src/interface/participants/Grille.svelte b/src/interface/participants/Grille.svelte index 270a21e..8499ae0 100644 --- a/src/interface/participants/Grille.svelte +++ b/src/interface/participants/Grille.svelte @@ -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') { diff --git a/src/interface/participants/Participants.navigateur.test.js b/src/interface/participants/Participants.navigateur.test.js index f8ab24d..1d5db15 100644 --- a/src/interface/participants/Participants.navigateur.test.js +++ b/src/interface/participants/Participants.navigateur.test.js @@ -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); diff --git a/src/interface/participants/grille.js b/src/interface/participants/grille.js index e930330..bbb64d1 100644 --- a/src/interface/participants/grille.js +++ b/src/interface/participants/grille.js @@ -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é diff --git a/src/interface/participants/grille.test.js b/src/interface/participants/grille.test.js index 031bb71..6b6a085 100644 --- a/src/interface/participants/grille.test.js +++ b/src/interface/participants/grille.test.js @@ -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); + }); +}); diff --git a/src/interface/plan/BarreVue.svelte b/src/interface/plan/BarreVue.svelte index 5f647bc..8790cb3 100644 --- a/src/interface/plan/BarreVue.svelte +++ b/src/interface/plan/BarreVue.svelte @@ -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 @@