[FIX] plan: named warning, one places state, highlight, table spacing

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
This commit is contained in:
Mathieu Benoit 2026-10-07 15:05:01 -04:00
parent 71b9e75363
commit a2812cddb9
25 changed files with 761 additions and 159 deletions

View file

@ -28,11 +28,13 @@
// plus grande à égalité ; une table d'une autre capacité porte la sienne // plus grande à égalité ; une table d'une autre capacité porte la sienne
// en surcharge (§ 6.1) ; // en surcharge (§ 6.1) ;
// - les tables rondes, rangées en grille dans l'ordre de la configuration, // - 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 // rangée par rangée ; la grille a la plus petite largeur dont le carré
// plus petite largeur dont le carré contient toutes les tables, et seule // contient toutes les tables, et seule sa dernière rangée est incomplète.
// sa dernière rangée est incomplète. Ses colonnes s'écartent assez pour // Son pas est ecartsGrille (src/geometrie/tables.js), celui des tables que
// que le bloc de noms d'une table, de toute sa largeur, tienne entre deux // l'opérateur crée : ses colonnes s'écartent assez pour que le bloc de
// colonnes : aucune liste ne passe sur une autre table (§ 7.4) ; // 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 // - les contraintes de la configuration, les sièges non attribués, la
// génération par défaut de la recherche ; // génération par défaut de la recherche ;
// - chaque réservation, celle d'un animateur, au siège 1 de sa table pour // - 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 { SEPARATEUR, ecrireCsv, exporterParticipants } from '../src/csv/export.js';
import { decouper } from '../src/csv/lecture.js'; import { decouper } from '../src/csv/lecture.js';
import { CATALOGUE } from '../src/demo/catalogue.js'; import { CATALOGUE } from '../src/demo/catalogue.js';
import { constante } from '../src/geometrie/constantes.js'; import { ecartsGrille } from '../src/geometrie/tables.js';
import { ECART_LISTE, geometrieTable } from '../src/geometrie/tables.js';
import { serialiser, serialiserCharge } from '../src/stockage/canonique.js'; import { serialiser, serialiserCharge } from '../src/stockage/canonique.js';
import { analyser, creerCharge } from '../src/stockage/document.js'; import { analyser, creerCharge } from '../src/stockage/document.js';
import { VERSION } from '../src/version.genere.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). // Séparateur de la variante windows-1252 (§ 10.3).
const VIRGULE = ','; 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. // Le siège de l'animateur, et le titre qu'il porte.
const SIEGE_ANIMATEUR = 1; const SIEGE_ANIMATEUR = 1;
const ANIMATION = 'animation'; const ANIMATION = 'animation';
@ -100,21 +98,6 @@ function colonnesDe(nombre) {
return colonnes; 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 // Plus grand identifiant d'une liste d'enregistrements ; 0 pour une liste
// vide. // vide.
const plusGrandId = (enregistrements) => enregistrements.reduce((plus, { id }) => Math.max(plus, id), 0); 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 animateurs = animateursDe(reservations, cle);
const reserves = new Set(animateurs.values()); const reserves = new Set(animateurs.values());
const colonnes = colonnesDe(tables.length); 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; const { separerAppartenances, nouveauxVoisins, nouvelleTable, varierAppartenances } = contraintes;
return { return {
...neuve, ...neuve,
@ -174,7 +157,7 @@ export function chargeDeDemonstration({ cle, nom, construire }) {
numero, numero,
sieges: capacite === siegesParDefaut ? null : capacite, sieges: capacite === siegesParDefaut ? null : capacite,
forme: 'ronde', 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]) => ({ reservations: [...animateurs].map(([table, participant]) => ({
participant, participant,

View file

@ -17,6 +17,7 @@ import { nombrePlacesManquantes, normaliser } from '../moteur/configuration.js';
import { verifierInvariants } from '../moteur/verification.js'; import { verifierInvariants } from '../moteur/verification.js';
import { configurationDepuisCharge } from '../stockage/document.js'; import { configurationDepuisCharge } from '../stockage/document.js';
import { examiner, planDepuisFichier } from '../stockage/placements.js'; import { examiner, planDepuisFichier } from '../stockage/placements.js';
import { nomAffiche } from './personnes.js';
const avertissement = (code, details) => ({ code, details }); 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) * raisons d'examiner(apres) — derives, retenu.raisons — qu'examiner(avant)
* ne portait pas, comparées champ pour champ ; les propositions par * ne portait pas, comparées champ pour champ ; les propositions par
* identifiant, puis le retenu, sous id 'retenu' ; * identifiant, puis le retenu, sous id 'retenu' ;
* RESERVATION_NON_HONOREE {participant, table, tour} chaque violation * RESERVATION_NON_HONOREE {participant, table, tour, personne, numero}
* RESERVATION de verifierInvariants(instance d'après, * chaque violation RESERVATION de verifierInvariants(instance d'après,
* planDepuisFichier(apres.retenu)) absente du même calcul sur avant, qui * 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 * 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} avant
* @param {import('../stockage/types.js').Charge} apres * @param {import('../stockage/types.js').Charge} apres
@ -151,7 +154,10 @@ export function avertissementsApres(avant, apres) {
const tenues = reservations(avant, examenAvant, instanceAvant); const tenues = reservations(avant, examenAvant, instanceAvant);
for (const { participant, table, tour } of reservations(apres, examenApres, instance)) { for (const { participant, table, tour } of reservations(apres, examenApres, instance)) {
const dejaLa = tenues.some((connue) => connue.participant === participant && connue.table === table && connue.tour === tour); 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; return rendus;
} }

View file

@ -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)", () => { 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 avant = petiteSoiree();
const apres = avec(avant, reserverGrisaille); const apres = avec(avant, reserverGrisaille);
assert.deepEqual(avertissementsApres(avant, apres), [ 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'))), []); 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", () => { 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 avant = avec(petiteSoiree({ retenu: false }), reserverGrisaille);
const apres = avec(avant, (charge) => { 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'; charge.evenement.etat = 'retenu';
}); });
assert.deepEqual(avertissementsApres(avant, apres), [ 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 avant = avec(avec(petiteSoiree(), reserverGrisaille), rendreFautif);
const apres = avec(avant, (charge) => (charge.retenu.placement[0].reserve = [])); const apres = avec(avant, (charge) => (charge.retenu.placement[0].reserve = []));
assert.deepEqual(avertissementsApres(avant, apres), [ 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 } },
]); ]);
}); });

View file

@ -91,8 +91,9 @@ function exigerCapacite(charge, id, sieges, origine) {
/** /**
* creerTable {forme} (§ 4, § 6.1) : une table qui suit le défaut, d'identifiant * 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 * 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 * case libre (caseLibre) de la grille des démonstrations, au pas que donnent
* capacité et une liste vide à chaque tour. * 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 } = {}) { function creerTable(charge, { forme } = {}) {
exigerModifiable(charge); exigerModifiable(charge);
@ -100,7 +101,8 @@ function creerTable(charge, { forme } = {}) {
const id = charge.prochainsIds.table; const id = charge.prochainsIds.table;
if (id >= IDENTIFIANT_MAX) throw refus('COMPTEUR_SATURE', { compteur: 'table' }); if (id >= IDENTIFIANT_MAX) throw refus('COMPTEUR_SATURE', { compteur: 'table' });
const numero = Math.max(0, ...charge.tables.map((table) => table.numero)) + 1; 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); const suivante = structuredClone(charge);
suivante.tables.push({ id, numero, sieges: null, forme, position }); suivante.tables.push({ id, numero, sieges: null, forme, position });
suivante.prochainsIds.table = id + 1; suivante.prochainsIds.table = id + 1;

View file

@ -11,7 +11,10 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { creerFichiersSimules } from '../../test/fichiers_simules.js'; import { creerFichiersSimules } from '../../test/fichiers_simules.js';
import { describe, test } from '../../test/lanceur.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 { STATUT, nombrePlacesManquantes, normaliser } from '../moteur/configuration.js';
import { ErreurConfiguration } from '../moteur/erreurs.js'; import { ErreurConfiguration } from '../moteur/erreurs.js';
import { serialiser } from '../stockage/canonique.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 // 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. // se relit, et se normalise quand la charge reçue se normalisait.
function accepter(nom, charge, args) { 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)', () => { 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", () => { 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(); let charge = chargeDe();
for (let i = 0; i < 3; i += 1) charge = accepter('creerTable', charge, { forme: 'ronde' }).charge; 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, numero: 3,
sieges: null, sieges: null,
forme: 'carree', 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); assert.equal(cree.charge.prochainsIds.table, 5);
const deplace = accepter('deplacerTable', cree.charge, { id: 4, position: { x: 612.4, y: -0.4 } }); 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); 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', () => { 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 charge = chargeDe({ tables: [{ id: 1 }, { id: 2 }], n: 6 });
const listes = [[[1, 2, 3], [4, 5, 6]], [[1, 4, 5], [2, 3, 6]]]; const listes = [[[1, 2, 3], [4, 5, 6]], [[1, 4, 5], [2, 3, 6]]];

View file

@ -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', () => { 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' }); 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/); assert.match(nombre, /la ligne 3 n'a pas le nombre de champs de l'en-tête/);

View file

@ -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', 'RESERVATION_NON_HONOREE',
({ participant, table, tour }) => ({ participant, table, tour, personne = null, numero = null }) =>
`Le placement retenu n'honore pas la réservation de la personne n° ${participant} ` + `Le placement retenu n'honore pas la réservation de ${personne ?? `la personne n° ${participant}`} ` +
`à la table d'identifiant ${table} au tour ${tour} : il la place ailleurs.`, `à la ${numero === null ? `table d'identifiant ${table}` : `table ${numero}`} au tour ${tour} : il la place ailleurs.`,
], ],
[ [
'INVARIANT_VIOLE', 'INVARIANT_VIOLE',

View file

@ -7,7 +7,9 @@
// dessin pose les chaises par geometrieTable et la désignation d'un siège // 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 // (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 // 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 * @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 * 26 cm d'elle, plus la moitié de son trait ; le bloc commence au-delà, et
* aucune marque ne couvre la liste. */ * aucune marque ne couvre la liste. */
export const ECART_LISTE = 36; export const ECART_LISTE = 36;
/** Distance entre deux cases de la grille des tables créées, en cm. */ /** Écart minimal entre les centres de deux rangées de la grille des tables,
export const PAS_GRILLE = 300; * en cm. */
export const ECART_RANGEES = 300;
// Accroissement du rayon d'une ronde pour une place de plus, au-dessus du // 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π. // 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 }; 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 * 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 * grille à ⌈√(T + 1)⌉ colonnes, T = tables.length, partant de (0, 0), au pas
* de (0, 0) ; une case est prise quand un centre est à PAS_GRILLE / 2 ou * d'ecartsGrille des tables posées et de la nouvelle — le pas des
* moins. centres : les positions de charge.tables, que l'appelant passe sans * démonstrations (scripts/engendrer_livrables.js). Une case est prise quand
* construire de géométrie. * 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 * La recherche s'arrête : un intervalle ouvert de largeur 2 × pas contient
* deux cases, le plus petit disque qui contient trois cases ayant un rayon * au plus deux multiples du pas, et une table prend donc au plus quatre
* d'au moins PAS_GRILLE / √2 ; l'une des 2T + 1 premières cases est donc * cases ; l'une des 4T + 1 premières cases est libre.
* 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}} * @returns {{x: number, y: number}}
*/ */
export function caseLibre(centres) { export function caseLibre(tables, nouvelle) {
const colonnes = Math.ceil(Math.sqrt(centres.length + 1)); const { colonnes: pasX, rangees: pasY } = ecartsGrille([...tables, nouvelle]);
const colonnes = Math.ceil(Math.sqrt(tables.length + 1));
for (let rang = 0; ; rang += 1) { for (let rang = 0; ; rang += 1) {
const candidate = { x: (rang % colonnes) * PAS_GRILLE, y: Math.floor(rang / colonnes) * PAS_GRILLE }; const candidate = { x: (rang % colonnes) * pasX, y: Math.floor(rang / colonnes) * pasY };
const prise = centres.some(({ x, y }) => Math.hypot(x - candidate.x, y - candidate.y) <= PAS_GRILLE / 2); const prise = tables.some(
({ position: { x, y } }) => Math.abs(x - candidate.x) < pasX && Math.abs(y - candidate.y) < pasY,
);
if (!prise) return candidate; if (!prise) return candidate;
} }
} }

View file

@ -8,18 +8,21 @@
// attendues se calculent à la main dans le commentaire qui les précède. // attendues se calculent à la main dans le commentaire qui les précède.
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js'; import { describe, test } from '../../test/lanceur.js';
import { constante } from './constantes.js';
import { surLeSol } from './designation.js'; import { surLeSol } from './designation.js';
import { TAILLE_TEXTE_LISTE_CM } from './texte.js';
import { import {
ECART_LISTE, ECART_LISTE,
ECART_POIGNEE, ECART_POIGNEE,
ECART_RANGEES,
LARGEUR_PAR_PLACE, LARGEUR_PAR_PLACE,
PAS_GRILLE,
PLANCHER_DIAMETRE, PLANCHER_DIAMETRE,
RAYON_CHAISE, RAYON_CHAISE,
RAYON_POIGNEE, RAYON_POIGNEE,
caseLibre, caseLibre,
cote, cote,
diametre, diametre,
ecartsGrille,
englobant, englobant,
geometrieTable, geometrieTable,
placesSousPoignee, 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. // 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 } }); 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', () => { describe('constantes', () => {
test('les valeurs du contrat', () => { test('les valeurs du contrat', () => {
assert.equal(PLANCHER_DIAMETRE, 70); assert.equal(PLANCHER_DIAMETRE, 70);
@ -50,7 +95,7 @@ describe('constantes', () => {
assert.equal(RAYON_POIGNEE, 15); assert.equal(RAYON_POIGNEE, 15);
assert.equal(ECART_POIGNEE, 8); assert.equal(ECART_POIGNEE, 8);
assert.equal(ECART_LISTE, 36); assert.equal(ECART_LISTE, 36);
assert.equal(PAS_GRILLE, 300); assert.equal(ECART_RANGEES, 300);
}); });
}); });
@ -289,27 +334,105 @@ describe('englobant', () => {
}); });
}); });
describe('caseLibre', () => { describe('ecartsGrille : le pas d\u{2019}une grille de tables (§ 7.4, § 7.7)', () => {
test('sans table, l’origine', () => { // colonnes = ⌈2 × emprise + 2 × ECART_LISTE + LARGEUR_BLOC_NOMS_CM⌉ ;
assert.deepEqual(caseLibre([]), { x: 0, y: 0 }); // 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)', () => { test('ronde de 3 : emprise 79, colonnes 158 + 72 + 500 = 730, rangées 300', () => {
assert.deepEqual(caseLibre([{ x: 0, y: 0 }, { x: 300, y: 0 }]), { x: 0, y: 300 }); assert.deepEqual(ecartsGrille([{ forme: 'ronde', places: 3 }]), { colonnes: 730, rangees: 300 });
}); });
test('un centre en (150, 0) prend les deux premières cases', () => { test('ronde de 8 : emprise 120,39, colonnes ⌈812,79⌉ = 813, rangées 300', () => {
assert.deepEqual(caseLibre([{ x: 150, y: 0 }]), { x: 0, y: 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', () => { test('carrée de 8 : côté 120, emprise 104, colonnes 780, rangées 300', () => {
assert.deepEqual(caseLibre([{ x: 150.001, y: 0 }]), { x: 0, y: 0 }); 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)', () => { test('la plus grande emprise décide : une ronde de 16 parmi des rondes de 8 → colonnes 966, rangées 430', () => {
const centres = [{ x: 0, y: 0 }, { x: 300, y: 0 }, { x: 0, y: 300 }, { x: 300, y: 300 }]; // diamètre 16 × 60 / π = 305,577 ; emprise 152,789 + 44 = 196,789 ;
assert.deepEqual(caseLibre(centres.slice(0, 3)), { x: 300, y: 300 }); // 393,578 + 572 = 965,578 → 966 ; 393,578 + 36 = 429,578 → 430.
assert.deepEqual(caseLibre(centres), { x: 600, y: 0 }); 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 });
}); });
}); });

View file

@ -156,8 +156,16 @@ export const TABLE = new Map([
['plan.menu.deplacerReservation', 'Déplacer sa réservation'], ['plan.menu.deplacerReservation', 'Déplacer sa réservation'],
['plan.menu.exclure', 'Exclure'], ['plan.menu.exclure', 'Exclure'],
['plan.menu.reintegrer', 'Réintégrer'], ['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'], ['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) --------------------------- // --- 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 // 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. // des données, chacun dans son nœud, entre ces textes.

View file

@ -21,6 +21,9 @@
// lieu, avec son remède : celui de la séance, lu dans etat().refus avant // 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. // 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 // 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 // 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 (§ // la grille, hors d'une cellule en édition, ouvre l'aperçu d'import (§
@ -38,6 +41,11 @@
const t = getContext('t'); const t = getContext('t');
const seance = getContext('seance'); const seance = getContext('seance');
const ui = getContext('ui'); const ui = getContext('ui');
function voirSurLePlan(id) {
ui.mettreEnEvidence(id);
ui.allerA('plan');
}
const uid = $props.id(); const uid = $props.id();
const geste = creerGeste(seance); const geste = creerGeste(seance);
@ -327,6 +335,9 @@
<button type="button" data-commande="fiche" onclick={() => ui.ouvrirFiche(personne.id)}> <button type="button" data-commande="fiche" onclick={() => ui.ouvrirFiche(personne.id)}>
{t('participants.commande.fiche')} {t('participants.commande.fiche')}
</button> </button>
<button type="button" data-commande="voirSurLePlan" onclick={() => voirSurLePlan(personne.id)}>
{t('plan.evidence.voir')}
</button>
{#if personne.exclu} {#if personne.exclu}
<Commande <Commande
cle="participants.commande.reintegrer" cle="participants.commande.reintegrer"

View file

@ -10,10 +10,15 @@
// les zooms et l'ajustement par Plan.svelte, qui tient la vue : // les zooms et l'ajustement par Plan.svelte, qui tient la vue :
// zoomer(sens), sens valant 1 ou -1, et ajuster(). Un bouton à bascule dit // zoomer(sens), sens valant 1 ou -1, et ajuster(). Un bouton à bascule dit
// son état par aria-pressed, que la forme du bouton redit. // son état par aria-pressed, que la forme du bouton redit.
//
// 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).
import { getContext } from 'svelte'; import { getContext } from 'svelte';
import { toursOfferts } from './vue_plan.js'; import { toursOfferts } from './vue_plan.js';
let { tour, tours, zoomer, ajuster } = $props(); let { tour, tours, zoomer, ajuster, evidence = null } = $props();
const t = getContext('t'); const t = getContext('t');
const ui = getContext('ui'); const ui = getContext('ui');
@ -39,6 +44,12 @@
<button type="button" data-commande="pleinEcran" aria-pressed={$ui.pleinEcran} onclick={() => ui.basculerPleinEcran()} <button type="button" data-commande="pleinEcran" aria-pressed={$ui.pleinEcran} onclick={() => ui.basculerPleinEcran()}
>{t('plan.vue.pleinEcran')}</button >{t('plan.vue.pleinEcran')}</button
> >
{#if evidence !== null}
<p class="evidence" data-evidence={evidence.id}>
<span>{t('plan.evidence.personne')}</span>{#if evidence.nom !== null}<span>{evidence.nom}</span>{/if}
<button type="button" data-commande="oterEvidence" onclick={() => ui.mettreEnEvidence(null)}>{t('plan.evidence.oter')}</button>
</p>
{/if}
</div> </div>
<style> <style>
@ -49,6 +60,13 @@
gap: var(--espace-2); gap: var(--espace-2);
} }
.evidence {
display: flex;
align-items: center;
gap: var(--espace-1);
margin: 0;
}
.tour { .tour {
display: flex; display: flex;
align-items: center; align-items: center;

View file

@ -15,6 +15,7 @@
// (§ 14.8). // (§ 14.8).
import { afterEach, beforeAll, describe, expect, test } from 'vitest'; import { afterEach, beforeAll, describe, expect, test } from 'vitest';
import { flushSync, mount, unmount } from 'svelte'; import { flushSync, mount, unmount } from 'svelte';
import { SvelteMap } from 'svelte/reactivity';
import '../jetons.css'; import '../jetons.css';
import { creerFormat } from '../../application/format.js'; import { creerFormat } from '../../application/format.js';
import { TABLES_APPLICATION } from '../../application/libelles.js'; import { TABLES_APPLICATION } from '../../application/libelles.js';
@ -36,6 +37,7 @@ import TEXTE_TOUTES_EN_CONFLIT from '../../../test/fixtures/plan/toutes_en_confl
import { TABLES_INTERFACE } from '../libelles.js'; import { TABLES_INTERFACE } from '../libelles.js';
import BandeauPlan from './BandeauPlan.svelte'; import BandeauPlan from './BandeauPlan.svelte';
import EchellePlan from './EchellePlan.svelte'; import EchellePlan from './EchellePlan.svelte';
import { creerMesureur } from './mesure_texte.js';
import { LARGEUR_ECHELLE_PX, modeleDessin } from './modele.js'; import { LARGEUR_ECHELLE_PX, modeleDessin } from './modele.js';
import PlanDessin from './PlanDessin.svelte'; import PlanDessin from './PlanDessin.svelte';
@ -71,8 +73,10 @@ afterEach(demonterTout);
/** Monte PlanDessin dans un <svg data-plan> d'épreuve de la taille de la /** Monte PlanDessin dans un <svg data-plan> d'épreuve de la taille de la
* fenêtre, au début du corps, sous la vue donnée ; caché, le conteneur est * fenêtre, au début du corps, sous la vue donnée ; caché, le conteneur est
* en display: none, et conteneur.style.display = '' le montre. */ * en display: none, et conteneur.style.display = '' le montre. props
function monterPlan(modele, vue = { k: 1, tx: 0, ty: 0 }, { cache = false } = {}) { * remplace { modele } : un accesseur qui lit un état réactif change le
* modèle du composant monté. */
function monterPlan(modele, vue = { k: 1, tx: 0, ty: 0 }, { cache = false, props = { modele } } = {}) {
const conteneur = document.createElement('div'); const conteneur = document.createElement('div');
conteneur.dataset.surface = ''; conteneur.dataset.surface = '';
if (cache) conteneur.style.display = 'none'; if (cache) conteneur.style.display = 'none';
@ -87,7 +91,7 @@ function monterPlan(modele, vue = { k: 1, tx: 0, ty: 0 }, { cache = false } = {}
groupe.setAttribute('data-vue', ''); groupe.setAttribute('data-vue', '');
groupe.setAttribute('transform', transformDe(vue)); groupe.setAttribute('transform', transformDe(vue));
svg.append(groupe); svg.append(groupe);
const composant = mount(PlanDessin, { target: groupe, props: { modele }, context: CONTEXTE }); const composant = mount(PlanDessin, { target: groupe, props, context: CONTEXTE });
flushSync(); flushSync();
montes.push({ composant, conteneur }); montes.push({ composant, conteneur });
const boite = svg.getBoundingClientRect(); const boite = svg.getBoundingClientRect();
@ -483,6 +487,113 @@ describe('le plan monté caché (point d\u{2019}attention 1)', () => {
expect(nom.startsWith('W') && nom.endsWith(MARQUE)).toBe(true); expect(nom.startsWith('W') && nom.endsWith(MARQUE)).toBe(true);
} }
}); });
test('montré, caché, son modèle changé pendant qu\u{2019}il est caché, puis remontré : chaque ligne tient, chaque nom long s\u{2019}abrège sur la marque', async () => {
const charge = chargeDeTables([4, 8]);
avecProposition(charge);
const reactif = new SvelteMap([['modele', modeleDe(charge, { proposition: 1 })]]);
const { svg, conteneur } = monterPlan(null, { k: 1, tx: 300, ty: 350 }, {
props: {
get modele() {
return reactif.get('modele');
},
},
});
await deuxImages();
flushSync();
expect(lignesDe(svg, 1).length).toBe(4);
conteneur.style.display = 'none';
await deuxImages();
flushSync();
// Un autre modèle, posé pendant que le plan est caché : quatre-vingts
// « W » pour chaque nom.
const longs = structuredClone(charge);
for (const p of longs.participants) {
p.prenom = null;
p.nom = 'W'.repeat(80);
}
reactif.set('modele', modeleDe(longs, { proposition: 1 }));
flushSync();
await deuxImages();
conteneur.style.display = '';
await deuxImages();
flushSync();
const lignes = tous(svg, '[data-liste] > tspan');
expect(lignes.length).toBe(12);
for (const ligne of lignes) {
expect(ligne.getComputedTextLength()).toBeLessThanOrEqual(LARGEUR + 1e-3);
const nom = ligne.querySelector('[data-partie=nom]').textContent;
expect(nom.startsWith('W') && nom.endsWith(MARQUE)).toBe(true);
}
});
test('creerMesureur : la largeur nulle d\u{2019}un texte non rendu ne se retient pas ; rendu, la chaîne se mesure', () => {
const conteneur = document.createElement('div');
conteneur.style.display = 'none';
document.body.prepend(conteneur);
try {
const svg = document.createElementNS(SVG, 'svg');
const texte = document.createElementNS(SVG, 'text');
texte.setAttribute('font-size', '18');
svg.append(texte);
conteneur.append(svg);
const mesurer = creerMesureur(texte);
expect(mesurer('Ombrelle')).toBe(0);
conteneur.style.display = '';
expect(mesurer('Ombrelle')).toBeGreaterThan(0);
expect(mesurer('')).toBe(0);
} finally {
conteneur.remove();
}
});
});
describe('un nom porteur de balisage, des homonymes (point d\u{2019}attention 3)', () => {
test('le balisage reste du texte, dans un seul nœud ; deux « Iris Ombrelle » ont chacune leur ligne et leur numéro', () => {
const BALISAGES = ['<tspan font-size="200">X</tspan>', '</text><circle r="999"/>'];
const charge = chargeDeTables([8, 4]);
charge.participants[1].prenom = 'Iris';
charge.participants[1].nom = 'Ombrelle';
charge.participants[2].prenom = null;
charge.participants[2].nom = BALISAGES[0];
charge.participants[3].prenom = null;
charge.participants[3].nom = BALISAGES[1];
// Une proposition qui assied les huit premiers à la table 1.
avecProposition(charge, {
id: 1,
graine: 1,
arret: 1,
historique: 1,
produitVersion: 'epreuve',
siegesAttribues: false,
tables: [1, 2],
capacites: [8, 4],
tours: 1,
participants: charge.participants.map(({ id }) => id),
placement: [{ sieges: [[1, 2, 3, 4, 5, 6, 7, 8], [9, 10, 11, 12]], reserve: [] }],
});
const { svg } = monterPlan(modeleDe(charge, { proposition: 1 }), { k: 1, tx: 300, ty: 350 });
const liste = svg.querySelector('[data-liste="1"]');
const lignes = lignesDe(svg, 1);
expect(lignes.length).toBe(8);
// Chaque ligne d'un occupant sans réservation ni titre : sa tête et son
// nom, deux tspan ; rien d'autre sous la liste.
expect(liste.querySelectorAll('*').length).toBe(8 * 3);
expect([...liste.querySelectorAll('*')].every((e) => e.localName === 'tspan')).toBe(true);
expect(svg.querySelectorAll('circle[r="999"], [font-size="200"]').length).toBe(0);
const noms = tous(liste, '[data-partie=nom]');
expect(noms.length).toBe(8);
for (const nom of noms) expect(nom.childElementCount).toBe(0);
for (const balisage of BALISAGES) {
const portes = noms.filter(({ textContent }) => textContent === balisage || (textContent.endsWith(MARQUE) && balisage.startsWith(textContent.slice(0, -MARQUE.length))));
expect(portes.length, balisage).toBe(1);
}
const homonymes = noms.filter(({ textContent }) => textContent === 'Iris Ombrelle');
expect(homonymes.length).toBe(2);
const numeros = homonymes.map((nom) => nom.previousElementSibling.textContent);
expect(numeros.every((tete) => /^\d+\. $/.test(tete))).toBe(true);
expect(new Set(numeros).size).toBe(2);
});
}); });
describe('le signal de conflit (§ 7.5, § 13.3)', () => { describe('le signal de conflit (§ 7.5, § 13.3)', () => {

View file

@ -19,14 +19,15 @@
// n'est pas assise (executerSurPlace), sinon à côté de la commande. // n'est pas assise (executerSurPlace), sinon à côté de la commande.
// //
// participant : l'identifiant de la personne ; le menu porte data-menu. // participant : l'identifiant de la personne ; le menu porte data-menu.
// etatTour : l'état des places du tour affiché, que SectionPlan calcule et
// que le panneau de la place passe, ou null sans événement.
import { getContext } from 'svelte'; import { getContext } from 'svelte';
import { etatPlaces } from '../../application/places.js';
import Commande from '../primitives/Commande.svelte'; import Commande from '../primitives/Commande.svelte';
import { commandeDeDepot } from './depot.js'; import { commandeDeDepot } from './depot.js';
import { actionsPersonne, executerSurPlace } from './modele.js'; import { actionsPersonne, executerSurPlace } from './modele.js';
import { nomDeCharge, tourAffiche } from './vue_plan.js'; import { nomDeCharge } from './vue_plan.js';
let { participant } = $props(); let { participant, etatTour } = $props();
const t = getContext('t'); const t = getContext('t');
const seance = getContext('seance'); const seance = getContext('seance');
@ -35,8 +36,8 @@
const charge = $derived($seance.charge); const charge = $derived($seance.charge);
const fiche = $derived(charge === null ? undefined : charge.participants.find(({ id }) => id === participant)); const fiche = $derived(charge === null ? undefined : charge.participants.find(({ id }) => id === participant));
const tour = $derived(charge === null ? 1 : tourAffiche($ui.tour, charge.evenement.tours)); const etat = $derived(fiche === undefined ? null : etatTour);
const etat = $derived(fiche === undefined ? null : etatPlaces(charge, { tour, proposition: $ui.proposition })); const tour = $derived(etatTour === null ? 1 : etatTour.tour);
// La place que la personne occupe au tour montré, et sa chaise, ou null. // La place que la personne occupe au tour montré, et sa chaise, ou null.
const assise = $derived.by(() => { const assise = $derived.by(() => {
if (etat === null) return null; if (etat === null) return null;
@ -51,7 +52,10 @@
? null ? null
: commandeDeDepot({ etatPlaces: etat, tour, personne: participant, depuis: assise.place, cible: null, surLeSol: true }), : commandeDeDepot({ etatPlaces: etat, tour, personne: participant, depuis: assise.place, cible: null, surLeSol: true }),
); );
const actions = $derived(fiche === undefined ? [] : actionsPersonne({ exclu: fiche.exclu, chaise: assise?.chaise ?? null, retrait })); const enEvidence = $derived($ui.miseEnEvidence === participant);
const actions = $derived(
fiche === undefined ? [] : actionsPersonne({ exclu: fiche.exclu, chaise: assise?.chaise ?? null, retrait, enEvidence }),
);
const dependances = { seance, ui }; const dependances = { seance, ui };
// Où poser un refus : sa place, sinon la place sélectionnée. // Où poser un refus : sa place, sinon la place sélectionnée.
@ -87,6 +91,8 @@
deplacerReservation, deplacerReservation,
exclure: () => executerSurPlace(dependances, lieu(), 'exclureParticipant', { id: participant }), exclure: () => executerSurPlace(dependances, lieu(), 'exclureParticipant', { id: participant }),
reintegrer: () => executerSurPlace(dependances, lieu(), 'reintegrerParticipant', { id: participant }), reintegrer: () => executerSurPlace(dependances, lieu(), 'reintegrerParticipant', { id: participant }),
mettreEnEvidence: () => ui.mettreEnEvidence(participant),
oterEvidence: () => ui.mettreEnEvidence(null),
ouvrirFiche: () => ui.ouvrirFiche(participant), ouvrirFiche: () => ui.ouvrirFiche(participant),
}; };
</script> </script>

View file

@ -21,15 +21,19 @@
// — une exclusion —, pour qu'elle se réintègre d'où elle a été exclue. // — une exclusion —, pour qu'elle se réintègre d'où elle a été exclue.
// //
// Échap referme le panneau et rend le focus à la racine du plan. Le // Échap referme le panneau et rend le focus à la racine du plan. Le
// panneau lit 'seance' et 'ui' au contexte, sans propriété. // panneau lit 'seance' et 'ui' au contexte ; etatTour, l'état des places
// du tour affiché, que SectionPlan calcule, ou null sans événement, et le
// passe au menu.
import { getContext, untrack } from 'svelte'; import { getContext, untrack } from 'svelte';
import { etatPlaces, libellePropose } from '../../application/places.js'; import { libellePropose } from '../../application/places.js';
import Commande from '../primitives/Commande.svelte'; import Commande from '../primitives/Commande.svelte';
import { commandeDeDepot } from './depot.js'; import { commandeDeDepot } from './depot.js';
import { libellePlace } from './gestes.js'; import { libellePlace } from './gestes.js';
import MenuPersonne from './MenuPersonne.svelte'; import MenuPersonne from './MenuPersonne.svelte';
import { actionsPlace, executerSurPlace, ligneListe, partiesLigne, textesLigne } from './modele.js'; import { actionsPlace, executerSurPlace, ligneListe, partiesLigne, textesLigne } from './modele.js';
import { nomDeCharge, tourAffiche } from './vue_plan.js'; import { nomDeCharge } from './vue_plan.js';
let { etatTour } = $props();
const t = getContext('t'); const t = getContext('t');
const seance = getContext('seance'); const seance = getContext('seance');
@ -39,8 +43,8 @@
const charge = $derived($seance.charge); const charge = $derived($seance.charge);
const selection = $derived($ui.selection); const selection = $derived($ui.selection);
const place = $derived(charge !== null && selection !== null && (selection.siege ?? null) !== null ? selection : null); const place = $derived(charge !== null && selection !== null && (selection.siege ?? null) !== null ? selection : null);
const tour = $derived(charge === null ? 1 : tourAffiche($ui.tour, charge.evenement.tours)); const etat = $derived(place === null ? null : etatTour);
const etat = $derived(place === null ? null : etatPlaces(charge, { tour, proposition: $ui.proposition })); const tour = $derived(etatTour === null ? 1 : etatTour.tour);
const etatTable = $derived(etat === null ? undefined : etat.tables.find(({ id }) => id === place.table)); const etatTable = $derived(etat === null ? undefined : etat.tables.find(({ id }) => id === place.table));
const chaise = $derived(etatTable === undefined ? null : (etatTable.chaises[place.siege - 1] ?? null)); const chaise = $derived(etatTable === undefined ? null : (etatTable.chaises[place.siege - 1] ?? null));
const nomDe = $derived(charge === null ? null : nomDeCharge(charge)); const nomDe = $derived(charge === null ? null : nomDeCharge(charge));
@ -199,7 +203,7 @@
{/if} {/if}
</div> </div>
{#if menuDe !== null} {#if menuDe !== null}
<MenuPersonne participant={menuDe} /> <MenuPersonne participant={menuDe} {etatTour} />
{/if} {/if}
</section> </section>
{/if} {/if}

View file

@ -22,14 +22,17 @@
// (executerSurPlace), LIBELLE_VIDE à côté du champ. // (executerSurPlace), LIBELLE_VIDE à côté du champ.
// //
// Échap referme le panneau et rend le focus à la racine du plan. Le // Échap referme le panneau et rend le focus à la racine du plan. Le
// panneau lit 'seance' et 'ui' au contexte, sans propriété. // panneau lit 'seance' et 'ui' au contexte ; etatTour, l'état des places
// du tour affiché, que SectionPlan calcule, ou null sans événement.
import { getContext, tick, untrack } from 'svelte'; import { getContext, tick, untrack } from 'svelte';
import { comptesTitres } from '../../application/listes_travail.js'; import { comptesTitres } from '../../application/listes_travail.js';
import { etatPlaces, libellePropose } from '../../application/places.js'; import { libellePropose } from '../../application/places.js';
import Commande from '../primitives/Commande.svelte'; import Commande from '../primitives/Commande.svelte';
import { libellePlace } from './gestes.js'; import { libellePlace } from './gestes.js';
import { executerSurPlace, ligneListe, partiesLigne, textesLigne } from './modele.js'; import { executerSurPlace, ligneListe, partiesLigne, textesLigne } from './modele.js';
import { nomDeCharge, tourAffiche } from './vue_plan.js'; import { nomDeCharge } from './vue_plan.js';
let { etatTour } = $props();
const t = getContext('t'); const t = getContext('t');
const seance = getContext('seance'); const seance = getContext('seance');
@ -39,8 +42,7 @@
const charge = $derived($seance.charge); const charge = $derived($seance.charge);
const selection = $derived($ui.selection); const selection = $derived($ui.selection);
const table = $derived(charge !== null && selection !== null && (selection.siege ?? null) === null ? selection.table : null); const table = $derived(charge !== null && selection !== null && (selection.siege ?? null) === null ? selection.table : null);
const tour = $derived(charge === null ? 1 : tourAffiche($ui.tour, charge.evenement.tours)); const etat = $derived(table === null ? null : etatTour);
const etat = $derived(table === null ? null : etatPlaces(charge, { tour, proposition: $ui.proposition }));
const etatTable = $derived(etat === null ? undefined : etat.tables.find(({ id }) => id === table)); const etatTable = $derived(etat === null ? undefined : etat.tables.find(({ id }) => id === table));
const nomDe = $derived(charge === null ? null : nomDeCharge(charge)); const nomDe = $derived(charge === null ? null : nomDeCharge(charge));
const comptes = $derived(etatTable === undefined ? null : comptesTitres(charge, table)); const comptes = $derived(etatTable === undefined ? null : comptesTitres(charge, table));

View file

@ -261,16 +261,24 @@ async function cliquerPoint({ x, y }) {
await images(2); await images(2);
} }
// Un clic réel au centre d'une chaise. Quand un avertissement paru au-dessus // Un clic réel au centre d'une chaise. Quand elle est hors du cadre du
// du plan l'a poussé sous le bas de la fenêtre, la page défile d'abord // plan — les annonces du cadre de mode, avertissements compris, prennent
// jusqu'à la chaise. // leur hauteur à la surface —, la vue se déplace d'abord, par le cadrage de
// la séance, pour poser la chaise au centre du cadre, au même k.
async function cliquerChaise(m, table, siege) { async function cliquerChaise(m, table, siege) {
const atteinte = () => { const atteinte = () => {
const { x, y } = pointChaise(m, table, siege); const { x, y } = pointChaise(m, table, siege);
return chaiseEl(m, table, siege)?.contains(document.elementFromPoint(x, y)) ?? false; return chaiseEl(m, table, siege)?.contains(document.elementFromPoint(x, y)) ?? false;
}; };
if (!atteinte()) { if (!atteinte()) {
chaiseEl(m, table, siege).scrollIntoView({ block: 'center', inline: 'center' }); const vue = vueLue(m);
const cadre = cadreDe(m);
const { x, y } = pointChaise(m, table, siege);
await avecVue(m, {
k: vue.k,
tx: vue.tx + cadre.gauche + cadre.largeur / 2 - x,
ty: vue.ty + cadre.haut + cadre.hauteur / 2 - y,
});
await attendre(() => expect(atteinte()).toBe(true)); await attendre(() => expect(atteinte()).toBe(true));
} }
await cliquerPoint(pointChaise(m, table, siege)); await cliquerPoint(pointChaise(m, table, siege));
@ -967,15 +975,16 @@ describe('réserver, titrer, désigner depuis le panneau (§ 4, § 2.1 étape 6)
expect(apres.titres).toHaveLength(origine.titres.length + 1); expect(apres.titres).toHaveLength(origine.titres.length + 1);
expect(apres.retenu).toEqual(origine.retenu); expect(apres.retenu).toEqual(origine.retenu);
expect(apres.propositions).toEqual(origine.propositions); expect(apres.propositions).toEqual(origine.propositions);
expect(m.seance.etat().avertissements).toContainEqual({ code: 'RESERVATION_NON_HONOREE', details: { participant: IRIS, table: 3, tour: 1 } }); expect(m.seance.etat().avertissements).toContainEqual({
code: 'RESERVATION_NON_HONOREE',
details: { participant: IRIS, table: 3, tour: 1, personne: 'Iris Ombrelle', numero: 3 },
});
await attendre(() => expect(dans(m, '[data-avertissement="RESERVATION_NON_HONOREE"]')).not.toBeNull()); await attendre(() => expect(dans(m, '[data-avertissement="RESERVATION_NON_HONOREE"]')).not.toBeNull());
}); });
// L'avertissement nomme Iris et la table 3 : son texte vient du libellé // L'avertissement nomme Iris et la table 3 : ses détails portent le nom
// d'A2 et du composant des avertissements d'I4, que le correctif // affiché de la personne et le numéro de la table (avertissementsApres).
// rapports/I11-avertissement-nomme.diff complète. L'épreuve échoue tant test('RESERVATION_NON_HONOREE nomme Iris Ombrelle et la table 3', async () => {
// qu'il n'est pas appliqué ; appliqué, test.fails la signale à retourner.
test.fails('RESERVATION_NON_HONOREE nomme Iris Ombrelle et la table 3 (attend le correctif d’A2 et d’I4)', async () => {
const base = chargeDe({ const base = chargeDe({
id: 'evt-apres-generation', id: 'evt-apres-generation',
places: [4, 4, 4], places: [4, 4, 4],
@ -1388,3 +1397,82 @@ describe('les listes de travail mènent à la place ou à la personne (§ 5.8)',
expect(m.ui.etat().choix).toBeNull(); expect(m.ui.etat().choix).toBeNull();
}); });
}); });
// --- La mise en évidence d'une personne (§ 7.1, § 8.2, § 8.4) ------------------------
describe('la mise en évidence d’une personne : posée et ôtée par un geste, en lecture, sans rien écrire (§ 7.1, § 8.4)', () => {
// La chaise qui porte la marque de mise en évidence, ou null ; la marque
// se voit au style calculé.
const marquees = (m) => tous(m, '[data-marque="miseEnEvidence"]');
const chaiseMarquee = (m) => {
const [marque, ...autres] = marquees(m);
expect(autres).toEqual([]);
if (marque === undefined) return null;
expect(marque.checkVisibility({ opacityProperty: true, visibilityProperty: true })).toBe(true);
return marque.closest('[data-siege]').dataset.siege;
};
// La place qu'occupe une personne au tour montré, « table:siège ».
const placeDe = (m, personne) => {
for (const table of etatMontre(m).tables) {
const chaise = table.chaises.find(({ occupant }) => occupant === personne);
if (chaise !== undefined) return `${table.id}:${chaise.siege}`;
}
return null;
};
test('par le menu de la personne : sa chaise marquée, et elle seule, à chaque tour montré ; ôtée par le menu, puis posée et ôtée par la barre de vue', async () => {
const m = await ouvrir(huitRetenue(), { mode: 'lecture' });
expect(m.seance.etat().mode).toBe('lecture');
const avant = fichiers(m);
await cliquerChaise(m, 2, 1);
const personne = etatMontre(m).tables[1].chaises[0].occupant;
expect(personne).not.toBeNull();
expect(chaiseMarquee(m)).toBeNull();
await cliquer(dans(m, `[data-menu="${personne}"] [data-commande="mettreEnEvidence"]`));
await attendre(() => expect(chaiseMarquee(m)).toBe('2:1'));
expect(dans(m, `[data-menu="${personne}"] [data-commande="mettreEnEvidence"]`)).toBeNull();
// La barre de vue nomme la personne mise en évidence.
const barre = dans(m, '[data-evidence]');
expect(barre).not.toBeNull();
const { nom, prenom } = charge(m).participants.find(({ id }) => id === personne);
expect(texteDe(barre)).toContain(prenom === null ? nom : `${prenom} ${nom}`);
expect(Number(barre.dataset.evidence)).toBe(personne);
for (const tour of [2, 3, 1]) {
m.ui.montrerTour(tour);
flushSync();
await images(2);
expect(chaiseMarquee(m), `tour ${tour}`).toBe(placeDe(m, personne));
}
await cliquerChaise(m, 2, 1);
await cliquer(dans(m, `[data-menu="${personne}"] [data-commande="oterEvidence"]`));
await attendre(() => expect(chaiseMarquee(m)).toBeNull());
expect(dans(m, '[data-evidence]')).toBeNull();
expect(m.ui.etat().miseEnEvidence).toBeNull();
await cliquer(dans(m, `[data-menu="${personne}"] [data-commande="mettreEnEvidence"]`));
await attendre(() => expect(chaiseMarquee(m)).toBe('2:1'));
await cliquer(dans(m, '[data-evidence] [data-commande="oterEvidence"]'));
await attendre(() => expect(chaiseMarquee(m)).toBeNull());
expect(fichiers(m)).toEqual(avant);
expect(m.seance.etat().mode).toBe('lecture');
});
test('depuis sa ligne de la grille des participants : « Voir sur le plan » ouvre le plan, sa chaise marquée ; rien d’écrit', async () => {
const m = await ouvrir(huitRetenue(), { mode: 'lecture' });
const avant = fichiers(m);
const personne = etatMontre(m).tables[2].chaises[1].occupant;
m.ui.allerA('participants');
flushSync();
const voir = await attendre(() => {
const bouton = dans(m, `[data-participant="${personne}"] [data-commande="voirSurLePlan"]`);
expect(bouton).not.toBeNull();
return bouton;
});
await cliquer(voir);
await attendre(() => expect(m.ui.etat().section).toBe('plan'));
await attendre(() => expect(tous(m, '[data-table]').length).toBe(8));
await attendre(() => expect(chaiseMarquee(m)).toBe('3:2'));
expect(m.ui.etat().miseEnEvidence).toBe(personne);
expect(fichiers(m)).toEqual(avant);
});
});

View file

@ -33,6 +33,8 @@
// //
// diagnostic : celui que rend diagnostiquer, que SectionPlan passe, ou // diagnostic : celui que rend diagnostiquer, que SectionPlan passe, ou
// null — le bandeau ne porte alors que la phrase des conflits (§ 7.5). // null — le bandeau ne porte alors que la phrase des conflits (§ 7.5).
// etatTour : l'état des places du tour affiché (EtatPlaces, A5), que
// SectionPlan calcule pour le plan et les panneaux.
// //
// Les gestes du pointeur (§ 7.1, § 7.3, § 8.4) passent par un seul // Les gestes du pointeur (§ 7.1, § 7.3, § 8.4) passent par un seul
// écouteur de chaque type, sur la racine : pointerdown identifie la source // écouteur de chaque type, sur la racine : pointerdown identifie la source
@ -86,7 +88,6 @@
// suivant. // suivant.
import { getContext, tick, untrack } from 'svelte'; import { getContext, tick, untrack } from 'svelte';
import { controlerCapacite } from '../../application/capacite.js'; import { controlerCapacite } from '../../application/capacite.js';
import { etatPlaces } from '../../application/places.js';
import { constante } from '../../geometrie/constantes.js'; import { constante } from '../../geometrie/constantes.js';
import { ajuster as vueAjustee, ecranDepuisDessin, transformDe } from '../../geometrie/vue.js'; import { ajuster as vueAjustee, ecranDepuisDessin, transformDe } from '../../geometrie/vue.js';
import BandeauPlan from './BandeauPlan.svelte'; import BandeauPlan from './BandeauPlan.svelte';
@ -104,14 +105,13 @@
cadreDepuisRect, cadreDepuisRect,
englobantDuTour, englobantDuTour,
nomDeCharge, nomDeCharge,
tourAffiche,
vueApresCadre, vueApresCadre,
vueInitiale, vueInitiale,
zoomerBouton, zoomerBouton,
zoomerMolette, zoomerMolette,
} from './vue_plan.js'; } from './vue_plan.js';
let { diagnostic = null } = $props(); let { diagnostic = null, etatTour } = $props();
const t = getContext('t'); const t = getContext('t');
const seance = getContext('seance'); const seance = getContext('seance');
@ -148,12 +148,15 @@
const charge = $derived($seance.charge); const charge = $derived($seance.charge);
const tours = $derived(charge.evenement.tours); const tours = $derived(charge.evenement.tours);
const tour = $derived(tourAffiche($ui.tour, tours)); const etat = $derived(etatTour);
const etat = $derived(etatPlaces(charge, { tour, proposition: $ui.proposition })); const tour = $derived(etat.tour);
const englobantPlan = $derived(englobantDuTour(etat)); const englobantPlan = $derived(englobantDuTour(etat));
const nomDe = $derived(nomDeCharge(charge)); const nomDe = $derived(nomDeCharge(charge));
const selection = $derived($ui.selection); const selection = $derived($ui.selection);
const pose = $derived($ui.pose); const pose = $derived($ui.pose);
// La personne mise en évidence, que la barre de vue nomme : {id, nom}, nom
// null pour une personne que la charge n'a plus ; null sans mise en évidence.
const evidence = $derived($ui.miseEnEvidence === null ? null : { id: $ui.miseEnEvidence, nom: nomDe($ui.miseEnEvidence) ?? null });
// L'élément actif que le dessin marque : sous un focus venu du clavier. // L'élément actif que le dessin marque : sous un focus venu du clavier.
const actifMontre = $derived(aLeFocus && auClavier ? actif : null); const actifMontre = $derived(aLeFocus && auClavier ? actif : null);
const modele = $derived( const modele = $derived(
@ -693,7 +696,7 @@
</script> </script>
<div class="plan"> <div class="plan">
<BarreVue {tour} {tours} {zoomer} {ajuster} /> <BarreVue {tour} {tours} {zoomer} {ajuster} {evidence} />
<div class="surface" data-surface bind:this={surface}> <div class="surface" data-surface bind:this={surface}>
<BandeauPlan bandeau={modele === null ? [] : modele.bandeau} /> <BandeauPlan bandeau={modele === null ? [] : modele.bandeau} />
<!-- Le plan est un composant interactif à deux dimensions, que le <!-- Le plan est un composant interactif à deux dimensions, que le

View file

@ -11,10 +11,12 @@
// d'une liste vers le plan : la place se choisit ensuite sur le plan. Un // d'une liste vers le plan : la place se choisit ensuite sur le plan. Un
// changement de tour change la liste et oublie le choix (etat_ui.js) ; // changement de tour change la liste et oublie le choix (etat_ui.js) ;
// Échap, sur la réserve ou l'un de ses boutons, l'oublie aussi. // Échap, sur la réserve ou l'un de ses boutons, l'oublie aussi.
// Le panneau lit 'seance' et 'ui' au contexte, sans propriété. // Le panneau lit 'seance' et 'ui' au contexte ; etatTour, l'état des
// places du tour affiché, que SectionPlan calcule, ou null sans événement.
import { getContext } from 'svelte'; import { getContext } from 'svelte';
import { etatPlaces } from '../../application/places.js'; import { nomDeCharge } from './vue_plan.js';
import { nomDeCharge, tourAffiche } from './vue_plan.js';
let { etatTour } = $props();
const t = getContext('t'); const t = getContext('t');
const seance = getContext('seance'); const seance = getContext('seance');
@ -22,8 +24,8 @@
const uid = $props.id(); const uid = $props.id();
const charge = $derived($seance.charge); const charge = $derived($seance.charge);
const tour = $derived(charge === null ? 1 : tourAffiche($ui.tour, charge.evenement.tours)); const tour = $derived(etatTour === null ? 1 : etatTour.tour);
const reserve = $derived(charge === null ? [] : etatPlaces(charge, { tour, proposition: $ui.proposition }).reserve); const reserve = $derived(etatTour === null ? [] : etatTour.reserve);
const nomDe = $derived(charge === null ? null : nomDeCharge(charge)); const nomDe = $derived(charge === null ? null : nomDeCharge(charge));
const choix = $derived($ui.choix); const choix = $derived($ui.choix);
const choisie = (id) => choix !== null && choix.genre === 'personne' && choix.participant === id; const choisie = (id) => choix !== null && choix.genre === 'personne' && choix.participant === id;

View file

@ -6,32 +6,44 @@
// sa vue (Plan.svelte), et les panneaux latéraux — le diagnostic compact, // sa vue (Plan.svelte), et les panneaux latéraux — le diagnostic compact,
// dans [data-panneau-diagnostic], visible dès l'ouverture, sans clic ; // dans [data-panneau-diagnostic], visible dès l'ouverture, sans clic ;
// les listes de travail, la réserve, le panneau d'une place et celui // les listes de travail, la réserve, le panneau d'une place et celui
// d'une table. Les panneaux lisent 'seance' et 'ui' au contexte : la // d'une table. Les panneaux lisent 'seance' et 'ui' au contexte.
// section ne leur passe rien. Le plan se remonte pour chaque événement : //
// un autre événement repart de sa propre vue, mémorisée ou ajustée. // L'état des places du tour affiché (etatPlaces, A5) se calcule ici, une
// fois par changement de la charge, du tour affiché ou de la proposition
// montrée — chacun un $derived, qu'un autre changement de l'état de
// navigation ne relance pas —, et se passe au plan, à la réserve et aux
// panneaux de la place et de la table (etatTour) : aucun ne le recalcule.
// Le plan se remonte pour chaque événement : un autre événement repart de
// sa propre vue, mémorisée ou ajustée.
// //
// En plein écran, le titre et les panneaux s'effacent : le plan prend la // En plein écran, le titre et les panneaux s'effacent : le plan prend la
// surface que le cadre de mode lui laisse sous le bandeau (§ 18.6), et le // surface que le cadre de mode lui laisse sous le bandeau (§ 18.6), et le
// cadre l'entoure toujours (§ 8.4). // cadre l'entoure toujours (§ 8.4).
import { getContext } from 'svelte'; import { getContext } from 'svelte';
import { diagnostiquerCharge } from '../../application/diagnostic.js'; import { diagnostiquerCharge } from '../../application/diagnostic.js';
import { etatPlaces } from '../../application/places.js';
import Diagnostic from '../diagnostic/Diagnostic.svelte'; import Diagnostic from '../diagnostic/Diagnostic.svelte';
import ListesTravail from './ListesTravail.svelte'; import ListesTravail from './ListesTravail.svelte';
import PanneauPlace from './PanneauPlace.svelte'; import PanneauPlace from './PanneauPlace.svelte';
import PanneauTable from './PanneauTable.svelte'; import PanneauTable from './PanneauTable.svelte';
import Plan from './Plan.svelte'; import Plan from './Plan.svelte';
import Reserve from './Reserve.svelte'; import Reserve from './Reserve.svelte';
import { tourAffiche } from './vue_plan.js';
const t = getContext('t'); const t = getContext('t');
const seance = getContext('seance'); const seance = getContext('seance');
const ui = getContext('ui'); const ui = getContext('ui');
const evenement = $derived($seance.charge === null ? null : $seance.charge.evenement.id); const charge = $derived($seance.charge);
const evenement = $derived(charge === null ? null : charge.evenement.id);
const tour = $derived(charge === null ? 1 : tourAffiche($ui.tour, charge.evenement.tours));
const proposition = $derived($ui.proposition);
const etatTour = $derived(charge === null ? null : etatPlaces(charge, { tour, proposition }));
// Le diagnostic de la charge, que Plan donne au modèle de dessin : quand // Le diagnostic de la charge, que Plan donne au modèle de dessin : quand
// toutes les tables sont en conflit, le bandeau du plan en énonce les // toutes les tables sont en conflit, le bandeau du plan en énonce les
// planchers (§ 7.5) ; null quand la configuration ne se normalise pas, et // planchers (§ 7.5) ; null quand la configuration ne se normalise pas, et
// le bandeau ne porte que la phrase. // le bandeau ne porte que la phrase.
const diagnostic = $derived($seance.charge === null ? null : diagnostiquerCharge($seance.charge).diagnostic); const diagnostic = $derived(charge === null ? null : diagnostiquerCharge(charge).diagnostic);
</script> </script>
<section data-section="plan" class="section-plan"> <section data-section="plan" class="section-plan">
@ -41,7 +53,7 @@
<div class="corps"> <div class="corps">
{#if evenement !== null} {#if evenement !== null}
{#key evenement} {#key evenement}
<Plan {diagnostic} /> <Plan {diagnostic} {etatTour} />
{/key} {/key}
{/if} {/if}
{#if !$ui.pleinEcran} {#if !$ui.pleinEcran}
@ -50,9 +62,9 @@
<Diagnostic compact /> <Diagnostic compact />
</div> </div>
<ListesTravail /> <ListesTravail />
<Reserve /> <Reserve {etatTour} />
<PanneauPlace /> <PanneauPlace {etatTour} />
<PanneauTable /> <PanneauTable {etatTour} />
</aside> </aside>
{/if} {/if}
</div> </div>

View file

@ -569,7 +569,7 @@ describe('monté caché, cadre trop petit (§ 7.2, point d’attention 1)', () =
}); });
describe('le cadrage (§ 8.5, § 13.3)', () => { describe('le cadrage (§ 8.5, § 13.3)', () => {
test('des zooms rapprochés n’écrivent ni l’état ni le journal ; une seule écriture des réglages après DELAI_REGLAGES_MS, qui porte la vue rendue ; en lecture, puis sur un plan bloqué', async () => { test('des zooms rapprochés n’écrivent ni l’état ni le journal ; une seule écriture des réglages après DELAI_REGLAGES_MS, qui porte la vue rendue ; en écriture, en lecture, puis sur un plan bloqué', async () => {
const m = await petite(); const m = await petite();
const verifier = async (gestes) => { const verifier = async (gestes) => {
const avant = fichiers(m); const avant = fichiers(m);
@ -590,15 +590,24 @@ describe('le cadrage (§ 8.5, § 13.3)', () => {
const lue = vueLue(m); const lue = vueLue(m);
expect({ k: ecrit.k, tx: ecrit.tx, ty: ecrit.ty }).toEqual(lue); expect({ k: ecrit.k, tx: ecrit.tx, ty: ecrit.ty }).toEqual(lue);
}; };
expect(m.seance.etat().mode).toBe('lecture'); const zooms = async () => {
await verifier(async () => {
await cran(m, -100); await cran(m, -100);
await cran(m, -100); await cran(m, -100);
cliquer(m, 'zoomer'); cliquer(m, 'zoomer');
cliquer(m, 'dezoomer'); cliquer(m, 'dezoomer');
cliquer(m, 'ajuster'); cliquer(m, 'ajuster');
await cran(m, 100); await cran(m, 100);
}); };
// Le cas de base : l'écriture, sur un événement que rien ne bloque.
await m.seance.passerEnEcriture();
flushSync();
expect(m.seance.etat().mode).toBe('ecriture');
expect(m.seance.etat().evenement.etat).not.toBe('bloque');
await verifier(zooms);
await m.seance.passerEnLecture();
flushSync();
expect(m.seance.etat().mode).toBe('lecture');
await verifier(zooms);
await m.seance.passerEnEcriture(); await m.seance.passerEnEcriture();
await m.seance.executer('changerEtat', { etat: 'bloque' }); await m.seance.executer('changerEtat', { etat: 'bloque' });
flushSync(); flushSync();

View file

@ -473,15 +473,16 @@ export function actionsPlace({ reservation, titre }, choix) {
* montré ; retirer du tour quand le dépôt sur le sol rend une commande ou un * montré ; retirer du tour quand le dépôt sur le sol rend une commande ou un
* refus (retrait non null) ; réserver ici quand elle est assise sur une * refus (retrait non null) ; réserver ici quand elle est assise sur une
* place qu'aucune réservation ne tient ; déplacer sa réservation quand la * place qu'aucune réservation ne tient ; déplacer sa réservation quand la
* sienne tient sa chaise ; exclure, ou réintégrer une exclue ; ouvrir la * sienne tient sa chaise ; exclure, ou réintégrer une exclue ; la mettre en
* fiche. * évidence, ou ôter sa mise en évidence quand elle l'est ; ouvrir la fiche.
* *
* @param {{exclu: boolean, chaise: Object|null, retrait: Object|null}} personne * @param {{exclu: boolean, chaise: Object|null, retrait: Object|null, enEvidence: boolean}} personne
* chaise, l'EtatChaise qu'elle occupe au tour montré, null hors du dessin * chaise, l'EtatChaise qu'elle occupe au tour montré, null hors du dessin ;
* enEvidence, vrai quand elle est la personne mise en évidence (ui.miseEnEvidence)
* @returns {string[]} parmi deplacer, echanger, retirerDuTour, reserverIci, * @returns {string[]} parmi deplacer, echanger, retirerDuTour, reserverIci,
* deplacerReservation, exclure, reintegrer, ouvrirFiche * deplacerReservation, exclure, reintegrer, mettreEnEvidence, oterEvidence, ouvrirFiche
*/ */
export function actionsPersonne({ exclu, chaise, retrait }) { export function actionsPersonne({ exclu, chaise, retrait, enEvidence }) {
const assise = chaise !== null; const assise = chaise !== null;
const tenue = assise && chaise.reservation !== null && chaise.reservation.participant === chaise.occupant; const tenue = assise && chaise.reservation !== null && chaise.reservation.participant === chaise.occupant;
return [ return [
@ -492,6 +493,8 @@ export function actionsPersonne({ exclu, chaise, retrait }) {
['deplacerReservation', tenue], ['deplacerReservation', tenue],
['exclure', !exclu], ['exclure', !exclu],
['reintegrer', exclu], ['reintegrer', exclu],
['mettreEnEvidence', !enEvidence],
['oterEvidence', enEvidence],
['ouvrirFiche', true], ['ouvrirFiche', true],
] ]
.filter(([, offerte]) => offerte) .filter(([, offerte]) => offerte)

View file

@ -965,25 +965,42 @@ describe('actionsPersonne : le menu d’une personne (§ 7.1)', () => {
const assise = { siege: 2, occupant: 4, reservation: null, titre: null }; const assise = { siege: 2, occupant: 4, reservation: null, titre: null };
const retrait = { commande: 'retirerDuTour', arguments: { tour: 1, participant: 4 } }; const retrait = { commande: 'retirerDuTour', arguments: { tour: 1, participant: 4 } };
test('assise, non réservée : déplacer, échanger, retirer du tour, réserver ici, exclure, ouvrir la fiche', () => { test('assise, non réservée : déplacer, échanger, retirer du tour, réserver ici, exclure, mettre en évidence, ouvrir la fiche', () => {
assert.deepEqual(actionsPersonne({ exclu: false, chaise: assise, retrait }), [ assert.deepEqual(actionsPersonne({ exclu: false, chaise: assise, retrait, enEvidence: false }), [
'deplacer', 'deplacer',
'echanger', 'echanger',
'retirerDuTour', 'retirerDuTour',
'reserverIci', 'reserverIci',
'exclure', 'exclure',
'mettreEnEvidence',
'ouvrirFiche', 'ouvrirFiche',
]); ]);
}); });
test('assise sur sa réservation : déplacer sa réservation, plus de « réserver ici » ; sans retrait possible, pas de « retirer du tour »', () => { test('assise sur sa réservation : déplacer sa réservation, plus de « réserver ici » ; sans retrait possible, pas de « retirer du tour »', () => {
const tenue = { ...assise, reservation: { participant: 4, portee: 'tous', tour: null } }; const tenue = { ...assise, reservation: { participant: 4, portee: 'tous', tour: null } };
assert.deepEqual(actionsPersonne({ exclu: false, chaise: tenue, retrait: null }), ['deplacer', 'echanger', 'deplacerReservation', 'exclure', 'ouvrirFiche']); assert.deepEqual(actionsPersonne({ exclu: false, chaise: tenue, retrait: null, enEvidence: false }), [
'deplacer',
'echanger',
'deplacerReservation',
'exclure',
'mettreEnEvidence',
'ouvrirFiche',
]);
}); });
test('hors du dessin — réserve, exclue — : déplacer, puis exclure ou réintégrer, et la fiche', () => { test('hors du dessin — réserve, exclue — : déplacer, puis exclure ou réintégrer, la mise en évidence et la fiche', () => {
assert.deepEqual(actionsPersonne({ exclu: false, chaise: null, retrait: null }), ['deplacer', 'exclure', 'ouvrirFiche']); assert.deepEqual(actionsPersonne({ exclu: false, chaise: null, retrait: null, enEvidence: false }), ['deplacer', 'exclure', 'mettreEnEvidence', 'ouvrirFiche']);
assert.deepEqual(actionsPersonne({ exclu: true, chaise: null, retrait: null }), ['deplacer', 'reintegrer', 'ouvrirFiche']); assert.deepEqual(actionsPersonne({ exclu: true, chaise: null, retrait: null, enEvidence: false }), ['deplacer', 'reintegrer', 'mettreEnEvidence', 'ouvrirFiche']);
});
test('mise en évidence (§ 8.4) : la personne mise en évidence offre de l\u{2019}ôter, à la même place, et rien d\u{2019}autre ne change', () => {
for (const [chaise, sonRetrait] of [[assise, retrait], [null, null]]) {
const sans = actionsPersonne({ exclu: false, chaise, retrait: sonRetrait, enEvidence: false });
const avec = actionsPersonne({ exclu: false, chaise, retrait: sonRetrait, enEvidence: true });
assert.equal(sans.filter((action) => action === 'mettreEnEvidence').length, 1);
assert.deepEqual(avec, sans.map((action) => (action === 'mettreEnEvidence' ? 'oterEvidence' : action)));
}
}); });
}); });
@ -1041,11 +1058,11 @@ describe('les clés que les panneaux composent existent (§ 14.6)', () => {
const reservation = { genre: 'reservation', participant: 1, depuis: null, de: { table: 1, portee: 'tous', tour: null } }; const reservation = { genre: 'reservation', participant: 1, depuis: null, de: { table: 1, portee: 'tous', tour: null } };
const actions = new Set([...actionsPlace(libre, reservation), ...actionsPlace(tenue, { genre: 'personne', participant: 2, depuis: null })]); const actions = new Set([...actionsPlace(libre, reservation), ...actionsPlace(tenue, { genre: 'personne', participant: 2, depuis: null })]);
const menu = new Set([ const menu = new Set([
...actionsPersonne({ exclu: false, chaise: { ...libre, occupant: 4 }, retrait: {} }), ...actionsPersonne({ exclu: false, chaise: { ...libre, occupant: 4 }, retrait: {}, enEvidence: false }),
...actionsPersonne({ exclu: true, chaise: tenue, retrait: {} }), ...actionsPersonne({ exclu: true, chaise: tenue, retrait: {}, enEvidence: true }),
]); ]);
assert.equal(actions.size, 7); assert.equal(actions.size, 7);
assert.equal(menu.size, 8); assert.equal(menu.size, 10);
for (const action of actions) assert.equal(typeof t(`plan.action.${action}`), 'string', action); for (const action of actions) assert.equal(typeof t(`plan.action.${action}`), 'string', action);
for (const action of menu) assert.equal(typeof t(`plan.menu.${action}`), 'string', action); for (const action of menu) assert.equal(typeof t(`plan.menu.${action}`), 'string', action);
for (const regle of ['separerAppartenances', 'nouveauxVoisins', 'nouvelleTable']) assert.equal(typeof t(`plan.infobulle.${regle}`), 'string'); for (const regle of ['separerAppartenances', 'nouveauxVoisins', 'nouvelleTable']) assert.equal(typeof t(`plan.infobulle.${regle}`), 'string');

View file

@ -548,10 +548,6 @@ export function releverMarques(racine) {
// --- Les listes de noms et les tables ----------------------------------------------- // --- Les listes de noms et les tables -----------------------------------------------
// Les marques de désignation passagère que le clavier et le pointeur posent
// autour d'une table ou d'une chaise : elles ne sont pas la table.
const DESIGNATIONS = new Set(['focus', 'selection', 'miseEnEvidence']);
// Côté de la grille de points qui couvre l'intersection de deux boîtes. // Côté de la grille de points qui couvre l'intersection de deux boîtes.
const COTE_GRILLE = 7; const COTE_GRILLE = 7;
@ -585,7 +581,8 @@ function couvreUnPoint(forme, { left, top, right, bottom }) {
* Les listes de noms posées sur une table (§ 7.4) : chaque partie rendue * Les listes de noms posées sur une table (§ 7.4) : chaque partie rendue
* d'une ligne de [data-liste], par sa boîte à l'écran entière, contre * d'une ligne de [data-liste], par sa boîte à l'écran entière, contre
* chaque forme marquée sous un [data-table] — emprise, plateau, trame, gel, * chaque forme marquée sous un [data-table] — emprise, plateau, trame, gel,
* chaise et ses marques —, hors désignations passagères. Une partie est * chaise et ses marques, focus, sélection et mise en évidence comprises :
* une désignation ne cache jamais un texte. Une partie est
* posée sur une forme quand un point de la grille qui pave l'intersection * posée sur une forme quand un point de la grille qui pave l'intersection
* de leurs boîtes tombe dans la forme : le coin d'une boîte qui frôle un * de leurs boîtes tombe dans la forme : le coin d'une boîte qui frôle un
* cercle ne compte pas, un texte qui le traverse loin de son centre compte. * cercle ne compte pas, un texte qui le traverse loin de son centre compte.
@ -593,7 +590,7 @@ function couvreUnPoint(forme, { left, top, right, bottom }) {
*/ */
export function releverListes(racine) { export function releverListes(racine) {
const formes = [...racine.querySelectorAll('[data-table] [data-marque]')] const formes = [...racine.querySelectorAll('[data-table] [data-marque]')]
.filter((f) => f instanceof SVGGeometryElement && !DESIGNATIONS.has(f.dataset.marque) && rendu(f)) .filter((f) => f instanceof SVGGeometryElement && rendu(f))
.map((forme) => ({ forme, boite: boiteAvecTrait(forme) })); .map((forme) => ({ forme, boite: boiteAvecTrait(forme) }));
const fautes = []; const fautes = [];
let lues = 0; let lues = 0;
@ -980,6 +977,29 @@ describe('les gardes relèvent la faute qu’un fragment porte (§ 14.2)', () =>
expect(fautes).toEqual(['tspan[data-partie="nom"] dans text[data-liste="1"] « Wxyz Quorvel Tamberlune » sur circle[data-marque="contourChaise"] dans g[data-siege="2:1"]']); expect(fautes).toEqual(['tspan[data-partie="nom"] dans text[data-liste="1"] « Wxyz Quorvel Tamberlune » sur circle[data-marque="contourChaise"] dans g[data-siege="2:1"]']);
}); });
test('listes : un carré de focus ou de sélection qui couvre les premiers centimètres d\u{2019}une partie est relevé — une désignation ne cache jamais un texte', () => {
for (const marque of ['focus', 'selection', 'miseEnEvidence']) {
const { fautes, lues } = avecFragment(
'<svg width="400" height="160" style="font: 12px sans-serif">' +
`<g data-table="2"><rect data-marque="${marque}" x="0" y="0" width="30" height="30" style="fill:none;stroke:#000;stroke-width:2" /></g>` +
'<text data-liste="1"><tspan x="10" y="54"><tspan data-partie="nom">Wxyz Quorvel Tamberlune</tspan></tspan></text></svg>',
(c) => {
// Le carré se pose sur le début de la partie, loin de son centre.
const p = c.querySelector('tspan[data-partie]').getBBox();
expect(p.width).toBeGreaterThan(80);
const carre = c.querySelector('rect');
carre.setAttribute('x', String(p.x - 20));
carre.setAttribute('y', String(p.y + p.height / 2 - 15));
return releverListes(c);
},
);
expect(lues, marque).toBe(1);
expect(fautes, marque).toEqual([
`tspan[data-partie="nom"] dans text[data-liste="1"] « Wxyz Quorvel Tamberlune » sur rect[data-marque="${marque}"] dans g[data-table="2"]`,
]);
}
});
test('jamais la seule couleur : deux formes qui ne diffèrent que par la couleur ont la même signature', () => { test('jamais la seule couleur : deux formes qui ne diffèrent que par la couleur ont la même signature', () => {
const fautes = avecFragment( const fautes = avecFragment(
'<svg width="60" height="20"><circle id="a" cx="10" cy="10" r="8" style="fill:#000;stroke:#333;stroke-width:2" />' + '<svg width="60" height="20"><circle id="a" cx="10" cy="10" r="8" style="fill:#000;stroke:#333;stroke-width:2" />' +
@ -1028,6 +1048,7 @@ describe('les gardes relèvent la faute qu’un fragment porte (§ 14.2)', () =>
describe('chaque écran de l’itération (§ 14.5, § 14.6, § 19.11)', () => { describe('chaque écran de l’itération (§ 14.5, § 14.6, § 19.11)', () => {
test('chaque écran s’atteint depuis le premier, et le collecteur ne reçoit rien', () => { test('chaque écran s’atteint depuis le premier, et le collecteur ne reçoit rien', () => {
expect(ECRANS.length, 'écrans parcourus').toBeGreaterThan(0);
expect(releves.length).toBe(ECRANS.length); expect(releves.length).toBe(ECRANS.length);
const fautes = [ const fautes = [
...releves.filter((r) => r.echec !== null).map((r) => `${r.nom} : ${r.echec}`), ...releves.filter((r) => r.echec !== null).map((r) => `${r.nom} : ${r.echec}`),
@ -1085,7 +1106,9 @@ describe('chaque écran de l’itération (§ 14.5, § 14.6, § 19.11)', () => {
}); });
test('marques : chaque sorte de MARQUES est rendue au moins une fois, et chacune tient 3:1 contre ce qui est peint dessous, dans les deux thèmes', () => { test('marques : chaque sorte de MARQUES est rendue au moins une fois, et chacune tient 3:1 contre ce qui est peint dessous, dans les deux thèmes', () => {
expect(atteints().reduce((n, r) => n + r.clair.marques.mesures, 0)).toBeGreaterThan(0); for (const theme of THEMES) {
expect(atteints().reduce((n, r) => n + r[theme].marques.mesures, 0), `marques mesurées [${theme}]`).toBeGreaterThan(0);
}
const vues = new Set(atteints().flatMap((r) => [...r.clair.marques.vues, ...r.sombre.marques.vues])); const vues = new Set(atteints().flatMap((r) => [...r.clair.marques.vues, ...r.sombre.marques.vues]));
const fautes = MARQUES.filter(({ nom }) => !vues.has(nom)).map(({ nom }) => `${nom} : rendue sur aucun écran`); const fautes = MARQUES.filter(({ nom }) => !vues.has(nom)).map(({ nom }) => `${nom} : rendue sur aucun écran`);
for (const r of atteints()) { for (const r of atteints()) {
@ -1235,6 +1258,7 @@ describe('transitions annulées (§ 19.2)', () => {
} finally { } finally {
feuille.remove(); feuille.remove();
} }
expect(atteintes, 'écrans atteints').toBeGreaterThan(0);
expect(atteintes + fautes.length).toBe(ECRANS.length); expect(atteintes + fautes.length).toBe(ECRANS.length);
aucuneFaute(fautes, 'transitions annulées'); aucuneFaute(fautes, 'transitions annulées');
}, 900_000); }, 900_000);

View file

@ -17,14 +17,11 @@
// données sont tirées des démonstrations livrées et de commandes de // données sont tirées des démonstrations livrées et de commandes de
// l'application, sans aléa : deux parcours rendent le même écran. // l'application, sans aléa : deux parcours rendent le même écran.
// //
// Deux états s'atteignent sans geste de l'opérateur : la mise en évidence // Un état s'atteint sans geste de l'opérateur : la question du dossier de
// d'une personne passe par l'état de navigation du montage
// (ui.mettreEnEvidence), qui n'écrit rien ; la question du dossier de
// travail, qui se pose au démarrage, par les options du système simulé. // travail, qui se pose au démarrage, par les options du système simulé.
import { flushSync } from 'svelte'; import { flushSync } from 'svelte';
import { appliquerCommande } from '../../src/application/commandes.js'; import { appliquerCommande } from '../../src/application/commandes.js';
import { ErreurAnnulee } from '../../src/application/generation.js'; import { ErreurAnnulee } from '../../src/application/generation.js';
import { etatPlaces } from '../../src/application/places.js';
import { texteLivre } from '../../src/demo/livrees.js'; import { texteLivre } from '../../src/demo/livrees.js';
import { rechercher } from '../../src/moteur/recherche.js'; import { rechercher } from '../../src/moteur/recherche.js';
import { serialiser } from '../../src/stockage/canonique.js'; import { serialiser } from '../../src/stockage/canonique.js';
@ -359,10 +356,9 @@ export const ECRANS = Object.freeze([
mode: 'ecriture', mode: 'ecriture',
async atteindre(contexte) { async atteindre(contexte) {
const m = await planEnEcriture(contexte); const m = await planEnEcriture(contexte);
const { tour, proposition } = m.ui.etat(); await cliquerPointeur(chaise(m, 4, 1));
const occupant = etatPlaces(m.seance.etat().charge, { tour, proposition }).tables[3].chaises[0].occupant; await cliquer(m, '[data-panneau="place"] [data-menu] [data-commande="mettreEnEvidence"]');
m.ui.mettreEnEvidence(occupant); await element(m, '[data-siege="4:1"] [data-marque="miseEnEvidence"]');
await element(m, '[data-marque="miseEnEvidence"]');
return m.racine; return m.racine;
}, },
}, },