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 @@