From a2812cddb9b8c74e360faacaca81ee650b62363b Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 7 Oct 2026 15:05:01 -0400 Subject: [PATCH] [FIX] plan: named warning, one places state, highlight, table spacing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unheld-reservation warning names the person and the table number instead of their identifiers. The places state is computed once per change and handed to the side panels, not once per panel. A person can be highlighted from their menu or from the grid, and the view bar names them. A created table takes a cell a full pitch away from every table, its own capacity included, so no name list runs over a neighbour's chairs. The rendering guards fail on an empty walk and also check focus and selection. Checked: 2692 node, 496 browser, 60 long tests, from the index alone. --- FR --- [FIX] plan : avertissement nommé, état des places unique, évidence, pas L'avertissement de réservation non honorée nomme la personne et le numéro de la table plutôt que leurs identifiants. L'état des places se calcule une fois par changement et passe aux panneaux, non une fois par panneau. Une personne se met en évidence par son menu ou depuis la grille, et la barre de vue la nomme. Une table créée prend une case à un pas plein de chaque table, sa capacité comprise : aucune liste ne court sur les chaises voisines. Les gardes du rendu échouent sur un parcours vide et éprouvent aussi le focus et la sélection. Vérifié : 2692 node, 496 navigateur, 60. Assisted-by: Claude Opus 5.5 --- scripts/engendrer_livrables.js | 37 ++--- src/application/avertissements.js | 14 +- src/application/avertissements.test.js | 19 ++- src/application/commandes_mobilier.js | 8 +- src/application/commandes_mobilier.test.js | 113 ++++++++++++- src/application/libelles.test.js | 11 ++ src/application/libelles/seance.js | 9 +- src/geometrie/tables.js | 72 +++++++-- src/geometrie/tables.test.js | 153 ++++++++++++++++-- src/interface/libelles/plan.js | 8 + src/interface/participants/Grille.svelte | 11 ++ src/interface/plan/BarreVue.svelte | 20 ++- src/interface/plan/Dessin.navigateur.test.js | 117 +++++++++++++- src/interface/plan/MenuPersonne.svelte | 18 ++- src/interface/plan/PanneauPlace.svelte | 16 +- src/interface/plan/PanneauTable.svelte | 12 +- .../plan/Panneaux.navigateur.test.js | 108 +++++++++++-- src/interface/plan/Plan.svelte | 15 +- src/interface/plan/Reserve.svelte | 12 +- src/interface/plan/SectionPlan.svelte | 30 ++-- src/interface/plan/Vue.navigateur.test.js | 17 +- src/interface/plan/modele.js | 15 +- src/interface/plan/modele.test.js | 35 ++-- src/interface/transversal.navigateur.test.js | 38 ++++- test/navigateur/ecrans.js | 12 +- 25 files changed, 761 insertions(+), 159 deletions(-) diff --git a/scripts/engendrer_livrables.js b/scripts/engendrer_livrables.js index 9637f22..adf9a49 100644 --- a/scripts/engendrer_livrables.js +++ b/scripts/engendrer_livrables.js @@ -28,11 +28,13 @@ // plus grande à égalité ; une table d'une autre capacité porte la sienne // en surcharge (§ 6.1) ; // - les tables rondes, rangées en grille dans l'ordre de la configuration, -// rangée par rangée, leurs rangées espacées de 300 cm ; la grille a la -// plus petite largeur dont le carré contient toutes les tables, et seule -// sa dernière rangée est incomplète. Ses colonnes s'écartent assez pour -// que le bloc de noms d'une table, de toute sa largeur, tienne entre deux -// colonnes : aucune liste ne passe sur une autre table (§ 7.4) ; +// rangée par rangée ; la grille a la plus petite largeur dont le carré +// contient toutes les tables, et seule sa dernière rangée est incomplète. +// Son pas est ecartsGrille (src/geometrie/tables.js), celui des tables que +// l'opérateur crée : ses colonnes s'écartent assez pour que le bloc de +// noms d'une table, de toute sa largeur, tienne entre deux colonnes — +// aucune liste ne passe sur une autre table (§ 7.4) —, ses rangées de +// 300 cm au moins ; // - les contraintes de la configuration, les sièges non attribués, la // génération par défaut de la recherche ; // - chaque réservation, celle d'un animateur, au siège 1 de sa table pour @@ -49,8 +51,7 @@ import { decoder } from '../src/csv/encodage.js'; import { SEPARATEUR, ecrireCsv, exporterParticipants } from '../src/csv/export.js'; import { decouper } from '../src/csv/lecture.js'; import { CATALOGUE } from '../src/demo/catalogue.js'; -import { constante } from '../src/geometrie/constantes.js'; -import { ECART_LISTE, geometrieTable } from '../src/geometrie/tables.js'; +import { ecartsGrille } from '../src/geometrie/tables.js'; import { serialiser, serialiserCharge } from '../src/stockage/canonique.js'; import { analyser, creerCharge } from '../src/stockage/document.js'; import { VERSION } from '../src/version.genere.js'; @@ -70,9 +71,6 @@ const DEMONSTRATIONS_EN_CSV = ['petite', 'grande']; // Séparateur de la variante windows-1252 (§ 10.3). const VIRGULE = ','; -// Écart entre les centres de deux rangées de la grille, en cm. -const ECART_RANGEES_CM = 300; - // Le siège de l'animateur, et le titre qu'il porte. const SIEGE_ANIMATEUR = 1; const ANIMATION = 'animation'; @@ -100,21 +98,6 @@ function colonnesDe(nombre) { return colonnes; } -// Écart entre les centres de deux colonnes de la grille, en cm entiers : le -// bloc de noms d'une table commence à ECART_LISTE de son emprise et s'étend -// sur LARGEUR_BLOC_NOMS_CM, où toute ligne s'abrège ; il s'achève à -// ECART_LISTE de l'emprise de la colonne suivante, pour la plus grande -// emprise des capacités données. Aucune emprise, d'aucune rangée, ne -// rencontre ainsi la bande du bloc. -function ecartColonnes(capacites) { - const emprise = Math.max( - ...capacites.map( - (places) => geometrieTable({ id: 0, numero: 0, forme: 'ronde', position: { x: 0, y: 0 } }, places).rayonEmprise, - ), - ); - return Math.ceil(2 * emprise + 2 * ECART_LISTE + constante('LARGEUR_BLOC_NOMS_CM')); -} - // Plus grand identifiant d'une liste d'enregistrements ; 0 pour une liste // vide. const plusGrandId = (enregistrements) => enregistrements.reduce((plus, { id }) => Math.max(plus, id), 0); @@ -153,7 +136,7 @@ export function chargeDeDemonstration({ cle, nom, construire }) { const animateurs = animateursDe(reservations, cle); const reserves = new Set(animateurs.values()); const colonnes = colonnesDe(tables.length); - const ecart = ecartColonnes(tables.map(({ capacite }) => capacite)); + const pas = ecartsGrille(tables.map(({ capacite }) => ({ forme: 'ronde', places: capacite }))); const { separerAppartenances, nouveauxVoisins, nouvelleTable, varierAppartenances } = contraintes; return { ...neuve, @@ -174,7 +157,7 @@ export function chargeDeDemonstration({ cle, nom, construire }) { numero, sieges: capacite === siegesParDefaut ? null : capacite, forme: 'ronde', - position: { x: (rang % colonnes) * ecart, y: Math.floor(rang / colonnes) * ECART_RANGEES_CM }, + position: { x: (rang % colonnes) * pas.colonnes, y: Math.floor(rang / colonnes) * pas.rangees }, })), reservations: [...animateurs].map(([table, participant]) => ({ participant, diff --git a/src/application/avertissements.js b/src/application/avertissements.js index f4385c3..f0c72b2 100644 --- a/src/application/avertissements.js +++ b/src/application/avertissements.js @@ -17,6 +17,7 @@ import { nombrePlacesManquantes, normaliser } from '../moteur/configuration.js'; import { verifierInvariants } from '../moteur/verification.js'; import { configurationDepuisCharge } from '../stockage/document.js'; import { examiner, planDepuisFichier } from '../stockage/placements.js'; +import { nomAffiche } from './personnes.js'; const avertissement = (code, details) => ({ code, details }); @@ -111,11 +112,13 @@ export function invariantViole(charge, examen = examiner(charge)) { * raisons d'examiner(apres) — derives, retenu.raisons — qu'examiner(avant) * ne portait pas, comparées champ pour champ ; les propositions par * identifiant, puis le retenu, sous id 'retenu' ; - * RESERVATION_NON_HONOREE {participant, table, tour} chaque violation - * RESERVATION de verifierInvariants(instance d'après, + * 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. + * 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. * * @param {import('../stockage/types.js').Charge} avant * @param {import('../stockage/types.js').Charge} apres @@ -151,7 +154,10 @@ export function avertissementsApres(avant, apres) { const tenues = reservations(avant, examenAvant, instanceAvant); for (const { participant, table, tour } of reservations(apres, examenApres, instance)) { const dejaLa = tenues.some((connue) => connue.participant === participant && connue.table === table && connue.tour === tour); - if (!dejaLa) rendus.push(avertissement('RESERVATION_NON_HONOREE', { participant, table, tour })); + 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 })); } return rendus; } diff --git a/src/application/avertissements.test.js b/src/application/avertissements.test.js index e87d02b..353cde6 100644 --- a/src/application/avertissements.test.js +++ b/src/application/avertissements.test.js @@ -226,15 +226,26 @@ describe('avertissements : la dérive que le geste ajoute (§ 9, § 8.9)', () => }); 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}', () => { + 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(); const apres = avec(avant, reserverGrisaille); assert.deepEqual(avertissementsApres(avant, apres), [ - { code: 'RESERVATION_NON_HONOREE', details: { participant: 2, table: 2, tour: 1 } }, + { code: 'RESERVATION_NON_HONOREE', details: { participant: 2, table: 2, tour: 1, personne: 'Grisaille', numero: 2 } }, ]); assert.deepEqual(avertissementsApres(apres, avec(apres, (charge) => (charge.participants[0].nom = 'Parasol'))), []); }); + test('la personne se nomme par son nom affiché, et la table par son numéro, distinct de son identifiant', () => { + const avant = avec(petiteSoiree(), (charge) => { + charge.tables[1].numero = 7; + charge.participants[1].prenom = 'Aubépine'; + }); + const apres = avec(avant, reserverGrisaille); + assert.deepEqual(avertissementsApres(avant, apres), [ + { code: 'RESERVATION_NON_HONOREE', details: { participant: 2, table: 2, tour: 1, personne: 'Aubépine Grisaille', numero: 7 } }, + ]); + }); + test("retenir une proposition qui ne tient pas une réservation : le retenu nul d'avant n'en comptait aucune", () => { const avant = avec(petiteSoiree({ retenu: false }), reserverGrisaille); const apres = avec(avant, (charge) => { @@ -242,7 +253,7 @@ describe("avertissements : la réservation que le retenu n'honore plus (§ 5.8, charge.evenement.etat = 'retenu'; }); assert.deepEqual(avertissementsApres(avant, apres), [ - { code: 'RESERVATION_NON_HONOREE', details: { participant: 2, table: 2, tour: 1 } }, + { code: 'RESERVATION_NON_HONOREE', details: { participant: 2, table: 2, tour: 1, personne: 'Grisaille', numero: 2 } }, ]); }); @@ -250,7 +261,7 @@ describe("avertissements : la réservation que le retenu n'honore plus (§ 5.8, const avant = avec(avec(petiteSoiree(), reserverGrisaille), rendreFautif); const apres = avec(avant, (charge) => (charge.retenu.placement[0].reserve = [])); assert.deepEqual(avertissementsApres(avant, apres), [ - { code: 'RESERVATION_NON_HONOREE', details: { participant: 2, table: 2, tour: 1 } }, + { code: 'RESERVATION_NON_HONOREE', details: { participant: 2, table: 2, tour: 1, personne: 'Grisaille', numero: 2 } }, ]); }); diff --git a/src/application/commandes_mobilier.js b/src/application/commandes_mobilier.js index e8648e4..eaa31bb 100644 --- a/src/application/commandes_mobilier.js +++ b/src/application/commandes_mobilier.js @@ -91,8 +91,9 @@ function exigerCapacite(charge, id, sieges, origine) { /** * creerTable {forme} (§ 4, § 6.1) : une table qui suit le défaut, d'identifiant * prochainsIds.table, numérotée après la plus grande, posée sur la première - * case libre (caseLibre). Sous un retenu lisible, elle y entre avec sa - * capacité et une liste vide à chaque tour. + * case libre (caseLibre) de la grille des démonstrations, au pas que donnent + * les tables posées, chacune à sa capacité, et la nouvelle. Sous un retenu + * lisible, elle y entre avec sa capacité et une liste vide à chaque tour. */ function creerTable(charge, { forme } = {}) { exigerModifiable(charge); @@ -100,7 +101,8 @@ function creerTable(charge, { forme } = {}) { const id = charge.prochainsIds.table; if (id >= IDENTIFIANT_MAX) throw refus('COMPTEUR_SATURE', { compteur: 'table' }); const numero = Math.max(0, ...charge.tables.map((table) => table.numero)) + 1; - const position = caseLibre(charge.tables.map((table) => table.position)); + const posees = charge.tables.map((table) => ({ position: table.position, forme: table.forme, places: capacite(charge, table) })); + const position = caseLibre(posees, { forme, places: charge.evenement.siegesParDefaut }); const suivante = structuredClone(charge); suivante.tables.push({ id, numero, sieges: null, forme, position }); suivante.prochainsIds.table = id + 1; diff --git a/src/application/commandes_mobilier.test.js b/src/application/commandes_mobilier.test.js index 7049fa5..35b76e8 100644 --- a/src/application/commandes_mobilier.test.js +++ b/src/application/commandes_mobilier.test.js @@ -11,7 +11,10 @@ import assert from 'node:assert/strict'; import { creerFichiersSimules } from '../../test/fichiers_simules.js'; import { describe, test } from '../../test/lanceur.js'; -import { caseLibre } from '../geometrie/tables.js'; +import { texteLivre } from '../demo/livrees.js'; +import { constante } from '../geometrie/constantes.js'; +import { caseLibre, geometrieTable } from '../geometrie/tables.js'; +import { TAILLE_TEXTE_LISTE_CM } from '../geometrie/texte.js'; import { STATUT, nombrePlacesManquantes, normaliser } from '../moteur/configuration.js'; import { ErreurConfiguration } from '../moteur/erreurs.js'; import { serialiser } from '../stockage/canonique.js'; @@ -118,6 +121,45 @@ function seNormalise(charge) { } } +// Hauteur de ligne d'une liste que suppose l'épreuve des bandes : 1,25 corps, +// au-dessus de la hauteur d'encre du gabarit sur les polices courantes ; le +// dessin la mesure au navigateur. +const HAUTEUR_LIGNE_SUPPOSEE = 1.25 * TAILLE_TEXTE_LISTE_CM; + +// Les fautes « une liste passe sur une autre table » d'une charge : pour +// chaque paire ordonnée de tables, la bande du bloc de noms de la première — +// LARGEUR_BLOC_NOMS_CM de large depuis origineListe, capacité + 1 lignes de +// haut, en-tête d'une table gelée compris — recoupe le carré d'emprise de la +// seconde, bords exclus. Avec avec, seules les paires où figure la table +// d'identifiant avec sont examinées. Rend les fautes et le nombre de paires +// examinées. +function fautesBandes(charge, { avec = null } = {}) { + const largeur = constante('LARGEUR_BLOC_NOMS_CM'); + const geometries = charge.tables.map((table) => ({ g: geometrieTable(table, capacite(charge, table)), places: capacite(charge, table) })); + const fautes = []; + let paires = 0; + for (const { g, places } of geometries) { + const bande = { + gauche: g.origineListe.x, + droite: g.origineListe.x + largeur, + haut: g.origineListe.y, + bas: g.origineListe.y + (places + 1) * HAUTEUR_LIGNE_SUPPOSEE, + }; + for (const { g: autre } of geometries) { + if (autre === g || (avec !== null && g.id !== avec && autre.id !== avec)) continue; + paires += 1; + const e = autre.rayonEmprise; + const recoupe = + autre.centre.x + e > bande.gauche && + autre.centre.x - e < bande.droite && + autre.centre.y + e > bande.haut && + autre.centre.y - e < bande.bas; + if (recoupe) fautes.push(`bloc de la table ${g.id} (${g.centre.x}, ${g.centre.y}) sur la table ${autre.id} (${autre.centre.x}, ${autre.centre.y})`); + } + } + return { fautes, paires }; +} + // Une commande acceptée, sur une copie gelée de la charge : la charge rendue // se relit, et se normalise quand la charge reçue se normalisait. function accepter(nom, charge, args) { @@ -222,6 +264,56 @@ describe('mobilier : le registre (§ 8.2)', () => { }); describe('mobilier : créer, déplacer et supprimer une table (§ 4, § 6.1, § 9)', () => { + test('une table créée se pose sur la grille des démonstrations : le pas tient compte des emprises et du bloc de noms, aucune liste ne passe sur une autre table', () => { + let charge = chargeDe(); + for (let i = 0; i < 5; i += 1) charge = accepter('creerTable', charge, { forme: 'ronde' }).charge; + // Rondes de 8 : colonnes à 813 cm, rangées à 300 (ecartsGrille). + assert.deepEqual( + charge.tables.map(({ position }) => [position.x, position.y]), + [[0, 0], [813, 0], [0, 300], [813, 300], [1626, 0]], + ); + // La bande du bloc de noms de chaque table, de toute sa largeur, ne + // recoupe l'emprise d'aucune autre. + assert.deepEqual(fautesBandes(charge), { fautes: [], paires: 5 * 4 }); + // Une table passée à 16 places : le pas devient 966 × 430 ; toute case + // à moins d'un pas d'une table posée, sur les deux axes, est prise, la + // suivante est (1932, 430) : la table créée ne porte sa liste sur aucune + // table, et aucune liste ne passe sur elle. La table 5 agrandie reçoit + // la liste de sa voisine, ce que la création ne change pas. + charge = accepter('changerPlacesTable', charge, { id: 5, sieges: 16, origine: 'champ' }).charge; + charge = accepter('creerTable', charge, { forme: 'carree' }).charge; + assert.deepEqual(charge.tables.at(-1).position, { x: 1932, y: 430 }); + assert.deepEqual(fautesBandes(charge, { avec: charge.tables.at(-1).id }), { fautes: [], paires: 2 * 5 }); + }); + + test('dans la grande démonstration, la table 1 passée à 16 places : la table créée au pas 966 × 430 évite toute liste, (2898, 1720)', () => { + // La case (4830, 0) est à 765 cm de la table (4065, 0), dont la liste, + // de 4221,4 à 4721,4, couvrirait la table nouvelle, de 4709,6 à 4950,4 : + // elle est prise, comme toute case à moins d'un pas d'une table posée. + // La rangée 1720 est à 220 cm de la dernière rangée, de trois tables en + // 0, 813 et 1626 : la case (2898, 1720) est la première libre. Seules + // les paires où figure la table créée sont examinées : la table 1 + // agrandie porte sa liste sur sa voisine, ce que la création ne change + // pas. + let charge = analyser(texteLivre('grande')).charge; + const premiere = charge.tables.find(({ numero }) => numero === 1); + assert.deepEqual(premiere.position, { x: 0, y: 0 }); + charge = accepter('changerPlacesTable', charge, { id: premiere.id, sieges: 16, origine: 'champ' }).charge; + charge = accepter('creerTable', charge, { forme: 'ronde' }).charge; + assert.deepEqual(charge.tables.at(-1).position, { x: 2898, y: 1720 }); + const creee = charge.tables.at(-1).id; + assert.deepEqual(fautesBandes(charge, { avec: creee }), { fautes: [], paires: 2 * 33 }); + }); + + test('créée dans la grande démonstration, une table se pose sur la case suivante de sa grille, (2439, 1500)', () => { + // 33 rondes de 8 sur six colonnes à 813 cm et des rangées à 300 cm ; la + // trente-quatrième case, rang 33, est la colonne 3 de la rangée 5. + const grande = analyser(texteLivre('grande')).charge; + assert.equal(grande.tables.length, 33); + const { charge } = accepter('creerTable', grande, { forme: 'ronde' }); + assert.deepEqual(charge.tables.at(-1).position, { x: 2439, y: 1500 }); + }); + test("les identifiants ne se réutilisent jamais ; le numéro suit le plus grand, et le libellé nomme le numéro", () => { let charge = chargeDe(); for (let i = 0; i < 3; i += 1) charge = accepter('creerTable', charge, { forme: 'ronde' }).charge; @@ -234,7 +326,10 @@ describe('mobilier : créer, déplacer et supprimer une table (§ 4, § 6.1, § numero: 3, sieges: null, forme: 'carree', - position: caseLibre(charge.tables.map(({ position }) => position)), + position: caseLibre( + charge.tables.map((table) => ({ position: table.position, forme: table.forme, places: capacite(charge, table) })), + { forme: 'carree', places: charge.evenement.siegesParDefaut }, + ), }); assert.equal(cree.charge.prochainsIds.table, 5); const deplace = accepter('deplacerTable', cree.charge, { id: 4, position: { x: 612.4, y: -0.4 } }); @@ -272,6 +367,20 @@ describe('mobilier : créer, déplacer et supprimer une table (§ 4, § 6.1, § assert.equal(capacite(dix, dix.tables[0]), 10); }); + test('une table créée se pose au pas de sa propre capacité, le défaut : sous un défaut de 16, à côté d’une ronde de 8, en (966, 0) et non en (813, 0)', () => { + // Une ronde de 8 posée en (0, 0) : la grille a deux colonnes, et la case + // (0, 0) est prise. Le pas se calcule sur la plus grande emprise, celle + // de la table nouvelle à 16 places : 966 cm entre colonnes, là où deux + // rondes de 8 n'en demandent que 813. + const charge = chargeDe({ tables: [{ id: 1, sieges: 8 }], defaut: 16 }); + charge.tables[0].position = { x: 0, y: 0 }; + const { charge: apres } = accepter('creerTable', charge, { forme: 'ronde' }); + const creee = apres.tables.at(-1); + assert.equal(capacite(apres, creee), 16); + assert.deepEqual(creee.position, { x: 966, y: 0 }); + assert.deepEqual(fautesBandes(apres), { fautes: [], paires: 2 }); + }); + test('une table créée après la retenue entre au retenu, vide à chaque tour ; les propositions dérivent TABLE_AJOUTEE', () => { const charge = chargeDe({ tables: [{ id: 1 }, { id: 2 }], n: 6 }); const listes = [[[1, 2, 3], [4, 5, 6]], [[1, 4, 5], [2, 3, 6]]]; diff --git a/src/application/libelles.test.js b/src/application/libelles.test.js index b3ad8d2..fe602a0 100644 --- a/src/application/libelles.test.js +++ b/src/application/libelles.test.js @@ -593,6 +593,17 @@ describe('libellés : les textes (§ 14.6, § 8.2)', () => { ); }); + test('RESERVATION_NON_HONOREE nomme la personne et le numéro de la table quand ils sont donnés, sinon leurs identifiants', () => { + assert.equal( + libelle('RESERVATION_NON_HONOREE', { participant: 4, table: 5, tour: 1, personne: 'Iris Ombrelle', numero: 3 }), + "Le placement retenu n'honore pas la réservation de Iris Ombrelle à la table 3 au tour 1 : il la place ailleurs.", + ); + assert.equal( + libelle('RESERVATION_NON_HONOREE', { participant: 4, table: 5, tour: 1 }), + "Le placement retenu n'honore pas la réservation de la personne n° 4 à la table d'identifiant 5 au tour 1 : il la place ailleurs.", + ); + }); + 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/); diff --git a/src/application/libelles/seance.js b/src/application/libelles/seance.js index 2d543ca..c26849a 100644 --- a/src/application/libelles/seance.js +++ b/src/application/libelles/seance.js @@ -114,11 +114,14 @@ 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. [ 'RESERVATION_NON_HONOREE', - ({ participant, table, tour }) => - `Le placement retenu n'honore pas la réservation de la personne n° ${participant} ` + - `à la table d'identifiant ${table} au tour ${tour} : il la place ailleurs.`, + ({ 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.`, ], [ 'INVARIANT_VIOLE', diff --git a/src/geometrie/tables.js b/src/geometrie/tables.js index e418136..483ad38 100644 --- a/src/geometrie/tables.js +++ b/src/geometrie/tables.js @@ -7,7 +7,9 @@ // dessin pose les chaises par geometrieTable et la désignation d'un siège // (designation.js) lit les mêmes chaises : une seule implémentation, que le // dessin et le dépôt partagent. Aucune table ne tourne : la géométrie ne -// porte aucun angle. +// porte aucun angle. La grille où se posent les tables des démonstrations et +// celles que l'opérateur crée a un seul pas, ecartsGrille. +import { constante } from './constantes.js'; /** * @typedef {Object} GeometrieTable en cm @@ -40,8 +42,9 @@ export const ECART_POIGNEE = 8; * 26 cm d'elle, plus la moitié de son trait ; le bloc commence au-delà, et * aucune marque ne couvre la liste. */ export const ECART_LISTE = 36; -/** Distance entre deux cases de la grille des tables créées, en cm. */ -export const PAS_GRILLE = 300; +/** Écart minimal entre les centres de deux rangées de la grille des tables, + * en cm. */ +export const ECART_RANGEES = 300; // Accroissement du rayon d'une ronde pour une place de plus, au-dessus du // plancher : le périmètre croît de LARGEUR_PAR_PLACE, le rayon de 60 / 2π. @@ -204,26 +207,63 @@ export function englobant(geometries) { return { minX, minY, maxX, maxY }; } +/** + * Le pas de la grille des tables, en cm entiers, pour la plus grande emprise + * E des tables données, chacune de sa forme et de son nombre de places : + * entre deux colonnes, ⌈2E + 2 × ECART_LISTE + LARGEUR_BLOC_NOMS_CM⌉ — le bloc + * de noms d'une table, à ECART_LISTE de son emprise et large de + * LARGEUR_BLOC_NOMS_CM, où toute ligne s'abrège, s'achève à ECART_LISTE de + * l'emprise de la colonne suivante : aucune liste ne passe sur une autre + * table de la grille, d'aucune rangée ; entre deux rangées, + * max(ECART_RANGEES, ⌈2E + ECART_LISTE⌉) — deux emprises voisines restent à + * ECART_LISTE l'une de l'autre, la marge du carré de focus. + * + * @param {Array<{forme: 'ronde'|'carree', places: number}>} tables au moins une + * @returns {{colonnes: number, rangees: number}} + */ +export function ecartsGrille(tables) { + const emprise = Math.max( + ...tables.map(({ forme, places }) => geometrieTable({ id: 0, numero: 0, forme, position: { x: 0, y: 0 } }, places).rayonEmprise), + ); + return { + colonnes: Math.ceil(2 * emprise + 2 * ECART_LISTE + constante('LARGEUR_BLOC_NOMS_CM')), + rangees: Math.max(ECART_RANGEES, Math.ceil(2 * emprise + ECART_LISTE)), + }; +} + /** * Position d'une table créée : la première case libre, ligne par ligne, d'une - * grille de PAS_GRILLE cm à ⌈√(T + 1)⌉ colonnes, T = centres.length, partant - * de (0, 0) ; une case est prise quand un centre est à PAS_GRILLE / 2 ou - * moins. centres : les positions de charge.tables, que l'appelant passe sans - * construire de géométrie. + * grille à ⌈√(T + 1)⌉ colonnes, T = tables.length, partant de (0, 0), au pas + * d'ecartsGrille des tables posées et de la nouvelle — le pas des + * démonstrations (scripts/engendrer_livrables.js). Une case est prise quand + * une table posée est à moins d'un pas de son centre sur chacun des deux + * axes, bornes exclues. Une table à un pas de colonne ou plus a son emprise + * hors de la bande du bloc de noms de la table nouvelle, et la sienne + * s'achève avant l'emprise nouvelle, à ECART_LISTE au moins, quelle que soit + * la rangée : c'est le calcul d'ecartsGrille, fait sur la plus grande + * emprise des deux. Une table à un pas de rangée ou plus a son emprise à + * ECART_LISTE au moins de l'emprise nouvelle. Ainsi une table déplacée hors + * de la grille, ou une grille dont le pas a crû depuis qu'elle a été posée, + * ne reçoit pas la liste de la table nouvelle et ne lui porte pas la sienne + * à travers une colonne. * - * La recherche s'arrête : un centre est à PAS_GRILLE / 2 ou moins d'au plus - * deux cases, le plus petit disque qui contient trois cases ayant un rayon - * d'au moins PAS_GRILLE / √2 ; l'une des 2T + 1 premières cases est donc - * libre. + * La recherche s'arrête : un intervalle ouvert de largeur 2 × pas contient + * au plus deux multiples du pas, et une table prend donc au plus quatre + * cases ; l'une des 4T + 1 premières cases est libre. * - * @param {Array<{x: number, y: number}>} centres + * @param {Array<{position: {x: number, y: number}, forme: 'ronde'|'carree', places: number}>} tables + * les tables posées, chacune à sa capacité courante + * @param {{forme: 'ronde'|'carree', places: number}} nouvelle la table à poser * @returns {{x: number, y: number}} */ -export function caseLibre(centres) { - const colonnes = Math.ceil(Math.sqrt(centres.length + 1)); +export function caseLibre(tables, nouvelle) { + const { colonnes: pasX, rangees: pasY } = ecartsGrille([...tables, nouvelle]); + const colonnes = Math.ceil(Math.sqrt(tables.length + 1)); for (let rang = 0; ; rang += 1) { - const candidate = { x: (rang % colonnes) * PAS_GRILLE, y: Math.floor(rang / colonnes) * PAS_GRILLE }; - const prise = centres.some(({ x, y }) => Math.hypot(x - candidate.x, y - candidate.y) <= PAS_GRILLE / 2); + const candidate = { x: (rang % colonnes) * pasX, y: Math.floor(rang / colonnes) * pasY }; + const prise = tables.some( + ({ position: { x, y } }) => Math.abs(x - candidate.x) < pasX && Math.abs(y - candidate.y) < pasY, + ); if (!prise) return candidate; } } diff --git a/src/geometrie/tables.test.js b/src/geometrie/tables.test.js index befc550..2f34a4b 100644 --- a/src/geometrie/tables.test.js +++ b/src/geometrie/tables.test.js @@ -8,18 +8,21 @@ // attendues se calculent à la main dans le commentaire qui les précède. import assert from 'node:assert/strict'; 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 { ECART_LISTE, ECART_POIGNEE, + ECART_RANGEES, LARGEUR_PAR_PLACE, - PAS_GRILLE, PLANCHER_DIAMETRE, RAYON_CHAISE, RAYON_POIGNEE, caseLibre, cote, diametre, + ecartsGrille, englobant, geometrieTable, placesSousPoignee, @@ -42,6 +45,48 @@ const distance = (a, b) => Math.hypot(a.x - b.x, a.y - b.y); // Une table du fichier d'état : identifiant, numéro, forme et centre. const table = (id, forme, x, y) => ({ id, numero: id, sieges: null, forme, position: { x, y } }); +// Hauteur de ligne d'une liste que suppose l'épreuve des bandes : 1,25 corps, +// au-dessus de la hauteur d'encre du gabarit sur les polices courantes. Le +// dessin la mesure au navigateur ; le module de géométrie ne la connaît pas. +const HAUTEUR_LIGNE_SUPPOSEE = 1.25 * TAILLE_TEXTE_LISTE_CM; + +// Les fautes « une liste passe sur une autre table » d'un ensemble de tables +// posées {position, forme, places} : pour chaque paire ordonnée, la bande du +// bloc de noms de la première — LARGEUR_BLOC_NOMS_CM de large depuis +// origineListe, places + 1 lignes de haut, en-tête d'une table gelée +// compris — recoupe le carré d'emprise de la seconde, bords exclus. Avec +// avec, seules les paires où figure la table de ce rang, compté depuis 1, +// sont examinées. Rend les fautes et le nombre de paires examinées. +function fautesBandes(posees, { avec = null } = {}) { + const largeur = constante('LARGEUR_BLOC_NOMS_CM'); + const geometries = posees.map(({ position, forme, places }, rang) => ({ + g: geometrieTable(table(rang + 1, forme, position.x, position.y), places), + places, + })); + const fautes = []; + let paires = 0; + for (const { g, places } of geometries) { + const bande = { + gauche: g.origineListe.x, + droite: g.origineListe.x + largeur, + haut: g.origineListe.y, + bas: g.origineListe.y + (places + 1) * HAUTEUR_LIGNE_SUPPOSEE, + }; + for (const { g: autre } of geometries) { + if (autre === g || (avec !== null && g.id !== avec && autre.id !== avec)) continue; + paires += 1; + const e = autre.rayonEmprise; + const recoupe = + autre.centre.x + e > bande.gauche && + autre.centre.x - e < bande.droite && + autre.centre.y + e > bande.haut && + autre.centre.y - e < bande.bas; + if (recoupe) fautes.push(`bloc de la table ${g.id} (${g.centre.x}, ${g.centre.y}) sur la table ${autre.id} (${autre.centre.x}, ${autre.centre.y})`); + } + } + return { fautes, paires }; +} + describe('constantes', () => { test('les valeurs du contrat', () => { assert.equal(PLANCHER_DIAMETRE, 70); @@ -50,7 +95,7 @@ describe('constantes', () => { assert.equal(RAYON_POIGNEE, 15); assert.equal(ECART_POIGNEE, 8); assert.equal(ECART_LISTE, 36); - assert.equal(PAS_GRILLE, 300); + assert.equal(ECART_RANGEES, 300); }); }); @@ -289,27 +334,105 @@ describe('englobant', () => { }); }); -describe('caseLibre', () => { - test('sans table, l’origine', () => { - assert.deepEqual(caseLibre([]), { x: 0, y: 0 }); +describe('ecartsGrille : le pas d\u{2019}une grille de tables (§ 7.4, § 7.7)', () => { + // colonnes = ⌈2 × emprise + 2 × ECART_LISTE + LARGEUR_BLOC_NOMS_CM⌉ ; + // rangées = max(ECART_RANGEES, ⌈2 × emprise + ECART_LISTE⌉), l'emprise la + // plus grande des tables données. LARGEUR_BLOC_NOMS_CM vaut 500. + test('la largeur du bloc de noms est celle du registre', () => { + assert.equal(constante('LARGEUR_BLOC_NOMS_CM'), 500); }); - test('deux centres en (0, 0) et (300, 0) : deux colonnes, la case suivante est (0, 300)', () => { - assert.deepEqual(caseLibre([{ x: 0, y: 0 }, { x: 300, y: 0 }]), { x: 0, y: 300 }); + test('ronde de 3 : emprise 79, colonnes 158 + 72 + 500 = 730, rangées 300', () => { + assert.deepEqual(ecartsGrille([{ forme: 'ronde', places: 3 }]), { colonnes: 730, rangees: 300 }); }); - test('un centre en (150, 0) prend les deux premières cases', () => { - assert.deepEqual(caseLibre([{ x: 150, y: 0 }]), { x: 0, y: 300 }); + test('ronde de 8 : emprise 120,39, colonnes ⌈812,79⌉ = 813, rangées 300', () => { + // 2 × 120,3944 + 72 + 500 = 812,789 → 813 ; 2 × 120,3944 + 36 = 276,8 → 300. + assert.deepEqual(ecartsGrille([{ forme: 'ronde', places: 8 }]), { colonnes: 813, rangees: 300 }); }); - test('un centre à plus de PAS_GRILLE / 2 laisse la case libre', () => { - assert.deepEqual(caseLibre([{ x: 150.001, y: 0 }]), { x: 0, y: 0 }); + test('carrée de 8 : côté 120, emprise 104, colonnes 780, rangées 300', () => { + assert.deepEqual(ecartsGrille([{ forme: 'carree', places: 8 }]), { colonnes: 780, rangees: 300 }); }); - test('quatre centres sur 2 × 2 : trois colonnes, la case suivante est (600, 0)', () => { - const centres = [{ x: 0, y: 0 }, { x: 300, y: 0 }, { x: 0, y: 300 }, { x: 300, y: 300 }]; - assert.deepEqual(caseLibre(centres.slice(0, 3)), { x: 300, y: 300 }); - assert.deepEqual(caseLibre(centres), { x: 600, y: 0 }); + test('la plus grande emprise décide : une ronde de 16 parmi des rondes de 8 → colonnes 966, rangées 430', () => { + // diamètre 16 × 60 / π = 305,577 ; emprise 152,789 + 44 = 196,789 ; + // 393,578 + 572 = 965,578 → 966 ; 393,578 + 36 = 429,578 → 430. + const tables = [{ forme: 'ronde', places: 8 }, { forme: 'ronde', places: 16 }, { forme: 'carree', places: 8 }]; + assert.deepEqual(ecartsGrille(tables), { colonnes: 966, rangees: 430 }); + }); +}); + +describe('caseLibre : la place d\u{2019}une table créée, sur la grille des démonstrations', () => { + const huit = { forme: 'ronde', places: 8 }; + const posee = (x, y, autres = huit) => ({ position: { x, y }, ...autres }); + + test('sans table, l\u{2019}origine', () => { + assert.deepEqual(caseLibre([], huit), { x: 0, y: 0 }); + }); + + test('deux rondes de 8 en (0, 0) et (813, 0) : deux colonnes, la case suivante est (0, 300)', () => { + assert.deepEqual(caseLibre([posee(0, 0), posee(813, 0)], huit), { x: 0, y: 300 }); + }); + + test('un centre à moins d\u{2019}un pas de colonne, (406,6 ; 0) ou (812,9 ; 0), prend les deux premières cases ; à 813, la première est libre', () => { + // À 406,6 de (0, 0), le bloc de la table nouvelle, de 156,4 à 656,4, + // couvrirait l'emprise posée, de 286,2 à 527 : la case est refusée. + assert.deepEqual(caseLibre([posee(406.6, 0)], huit), { x: 0, y: 300 }); + assert.deepEqual(caseLibre([posee(812.9, 0)], huit), { x: 0, y: 300 }); + assert.deepEqual(caseLibre([posee(813, 0)], huit), { x: 0, y: 0 }); + }); + + test('un centre à moins d\u{2019}un pas de rangée, (0 ; 150,1) ou (0 ; 299,9), prend la case (0, 0) ; à 300, elle est libre', () => { + assert.deepEqual(caseLibre([posee(0, 150.1)], huit), { x: 813, y: 0 }); + assert.deepEqual(caseLibre([posee(0, 299.9)], huit), { x: 813, y: 0 }); + assert.deepEqual(caseLibre([posee(0, 300)], huit), { x: 0, y: 0 }); + }); + + test('une table déplacée en (330, 0) prend aussi (813, 0) : la case suivante est (0, 300), et aucun bloc ne passe sur l\u{2019}autre table', () => { + // En (813, 0), à 483 cm, la liste de la table déplacée, de 486,4 à + // 986,4, couvrirait la table nouvelle, de 692,6 à 933,4. + const posees = [posee(330, 0)]; + const position = caseLibre(posees, huit); + assert.deepEqual(position, { x: 0, y: 300 }); + assert.deepEqual(fautesBandes([...posees, posee(position.x, position.y)]), { fautes: [], paires: 2 }); + }); + + test('l\u{2019}épreuve des bandes voit la faute : une ronde de 8 en (330, 0) et une autre en (813, 0)', () => { + assert.equal(fautesBandes([posee(330, 0), posee(813, 0)]).fautes.length, 1); + }); + + test('une ronde de 8 agrandie à 16 au milieu de rondes de 8 : la table créée au nouveau pas ne porte ni ne subit aucune liste', () => { + // Vingt rondes de 8 sur cinq colonnes au pas 813 × 300, la première + // passée à 16 places : le pas devient 966 × 430, sur cinq colonnes. La + // case (3864, 0) est à 612 cm de la table (3252, 0), dont la liste, de + // 3408,4 à 3908,4, couvrirait la table nouvelle, de 3743,6 à 3984,4 : + // elle est prise, comme toute case à moins d'un pas d'une table posée, + // jusqu'à la rangée 1720, à 820 cm de la dernière. Seules les paires où + // figure la table nouvelle sont examinées : la table agrandie porte sa + // liste sur ses voisines, ce que la création ne change pas. + const posees = Array.from({ length: 20 }, (_, rang) => posee((rang % 5) * 813, Math.floor(rang / 5) * 300)); + posees[0] = posee(0, 0, { forme: 'ronde', places: 16 }); + const position = caseLibre(posees, huit); + assert.deepEqual(position, { x: 0, y: 1720 }); + assert.deepEqual(fautesBandes([...posees, posee(position.x, position.y)], { avec: 21 }), { fautes: [], paires: 2 * 20 }); + }); + + test('le pas suit la table à créer et les tables posées : une ronde de 16 posée écarte la case suivante à 966', () => { + assert.deepEqual(caseLibre([posee(0, 0, { forme: 'ronde', places: 16 })], huit), { x: 966, y: 0 }); + assert.deepEqual(caseLibre([posee(0, 0)], { forme: 'ronde', places: 16 }), { x: 966, y: 0 }); + }); + + test('trente-trois rondes de 8 créées une à une : les cases de la grille des démonstrations, et aucun bloc de noms ne passe sur une autre table', () => { + const posees = []; + for (let n = 0; n < 33; n += 1) posees.push(posee(...Object.values(caseLibre(posees, huit)))); + assert.equal(posees.length, 33); + assert.equal(new Set(posees.map(({ position: { x, y } }) => `${x},${y}`)).size, 33); + for (const { position } of posees) { + assert.equal(position.x % 813, 0, `x ${position.x}`); + assert.equal(position.y % 300, 0, `y ${position.y}`); + } + assert.deepEqual(fautesBandes(posees), { fautes: [], paires: 33 * 32 }); }); }); diff --git a/src/interface/libelles/plan.js b/src/interface/libelles/plan.js index dbedf2f..45f2a37 100644 --- a/src/interface/libelles/plan.js +++ b/src/interface/libelles/plan.js @@ -156,8 +156,16 @@ export const TABLE = new Map([ ['plan.menu.deplacerReservation', 'Déplacer sa réservation'], ['plan.menu.exclure', 'Exclure'], ['plan.menu.reintegrer', 'Réintégrer'], + ['plan.menu.mettreEnEvidence', 'Mettre en évidence'], + ['plan.menu.oterEvidence', 'Ôter la mise en évidence'], ['plan.menu.ouvrirFiche', 'Ouvrir la fiche'], + // --- 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.oter', 'Ôter la mise en évidence'], + ['plan.evidence.voir', 'Voir sur le plan'], + // --- Les listes de travail (§ 5.8, § 4.1, § 4.2) --------------------------- // Le libellé d'un titre, le titre pressenti et le nom d'une personne sont // des données, chacun dans son nœud, entre ces textes. diff --git a/src/interface/participants/Grille.svelte b/src/interface/participants/Grille.svelte index 4bdf346..270a21e 100644 --- a/src/interface/participants/Grille.svelte +++ b/src/interface/participants/Grille.svelte @@ -21,6 +21,9 @@ // lieu, avec son remède : celui de la séance, lu dans etat().refus avant // d'ouvrir l'édition, ou celui de la commande, la saisie gardée. // + // « Voir sur le plan » met la personne en évidence et ouvre le plan, au + // tour montré (§ 7.1, § 8.4) : un geste de regard, qui n'écrit rien. + // // L'en-tête trie : un clic sur une colonne la trie croissante, un second // décroissante ; le sens se lit en texte et dans aria-sort. Un collage sur // la grille, hors d'une cellule en édition, ouvre l'aperçu d'import (§ @@ -38,6 +41,11 @@ const t = getContext('t'); const seance = getContext('seance'); const ui = getContext('ui'); + + function voirSurLePlan(id) { + ui.mettreEnEvidence(id); + ui.allerA('plan'); + } const uid = $props.id(); const geste = creerGeste(seance); @@ -327,6 +335,9 @@ + {#if personne.exclu} ui.basculerPleinEcran()} >{t('plan.vue.pleinEcran')} + {#if evidence !== null} +

+ {t('plan.evidence.personne')}{#if evidence.nom !== null}{evidence.nom}{/if} + +

+ {/if}