[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
// en surcharge (§ 6.1) ;
// - les tables rondes, rangées en grille dans l'ordre de la configuration,
// rangée par rangée, leurs rangées espacées de 300 cm ; la grille a la
// plus petite largeur dont le carré contient toutes les tables, et seule
// sa dernière rangée est incomplète. Ses colonnes s'écartent assez pour
// que le bloc de noms d'une table, de toute sa largeur, tienne entre deux
// colonnes : aucune liste ne passe sur une autre table (§ 7.4) ;
// rangée par rangée ; la grille a la plus petite largeur dont le carré
// contient toutes les tables, et seule sa dernière rangée est incomplète.
// Son pas est ecartsGrille (src/geometrie/tables.js), celui des tables que
// l'opérateur crée : ses colonnes s'écartent assez pour que le bloc de
// noms d'une table, de toute sa largeur, tienne entre deux colonnes —
// aucune liste ne passe sur une autre table (§ 7.4) —, ses rangées de
// 300 cm au moins ;
// - les contraintes de la configuration, les sièges non attribués, la
// génération par défaut de la recherche ;
// - chaque réservation, celle d'un animateur, au siège 1 de sa table pour
@ -49,8 +51,7 @@ import { decoder } from '../src/csv/encodage.js';
import { SEPARATEUR, ecrireCsv, exporterParticipants } from '../src/csv/export.js';
import { decouper } from '../src/csv/lecture.js';
import { CATALOGUE } from '../src/demo/catalogue.js';
import { constante } from '../src/geometrie/constantes.js';
import { ECART_LISTE, geometrieTable } from '../src/geometrie/tables.js';
import { ecartsGrille } from '../src/geometrie/tables.js';
import { serialiser, serialiserCharge } from '../src/stockage/canonique.js';
import { analyser, creerCharge } from '../src/stockage/document.js';
import { VERSION } from '../src/version.genere.js';
@ -70,9 +71,6 @@ const DEMONSTRATIONS_EN_CSV = ['petite', 'grande'];
// Séparateur de la variante windows-1252 (§ 10.3).
const VIRGULE = ',';
// Écart entre les centres de deux rangées de la grille, en cm.
const ECART_RANGEES_CM = 300;
// Le siège de l'animateur, et le titre qu'il porte.
const SIEGE_ANIMATEUR = 1;
const ANIMATION = 'animation';
@ -100,21 +98,6 @@ function colonnesDe(nombre) {
return colonnes;
}
// Écart entre les centres de deux colonnes de la grille, en cm entiers : le
// bloc de noms d'une table commence à ECART_LISTE de son emprise et s'étend
// sur LARGEUR_BLOC_NOMS_CM, où toute ligne s'abrège ; il s'achève à
// ECART_LISTE de l'emprise de la colonne suivante, pour la plus grande
// emprise des capacités données. Aucune emprise, d'aucune rangée, ne
// rencontre ainsi la bande du bloc.
function ecartColonnes(capacites) {
const emprise = Math.max(
...capacites.map(
(places) => geometrieTable({ id: 0, numero: 0, forme: 'ronde', position: { x: 0, y: 0 } }, places).rayonEmprise,
),
);
return Math.ceil(2 * emprise + 2 * ECART_LISTE + constante('LARGEUR_BLOC_NOMS_CM'));
}
// Plus grand identifiant d'une liste d'enregistrements ; 0 pour une liste
// vide.
const plusGrandId = (enregistrements) => enregistrements.reduce((plus, { id }) => Math.max(plus, id), 0);
@ -153,7 +136,7 @@ export function chargeDeDemonstration({ cle, nom, construire }) {
const animateurs = animateursDe(reservations, cle);
const reserves = new Set(animateurs.values());
const colonnes = colonnesDe(tables.length);
const ecart = ecartColonnes(tables.map(({ capacite }) => capacite));
const pas = ecartsGrille(tables.map(({ capacite }) => ({ forme: 'ronde', places: capacite })));
const { separerAppartenances, nouveauxVoisins, nouvelleTable, varierAppartenances } = contraintes;
return {
...neuve,
@ -174,7 +157,7 @@ export function chargeDeDemonstration({ cle, nom, construire }) {
numero,
sieges: capacite === siegesParDefaut ? null : capacite,
forme: 'ronde',
position: { x: (rang % colonnes) * ecart, y: Math.floor(rang / colonnes) * ECART_RANGEES_CM },
position: { x: (rang % colonnes) * pas.colonnes, y: Math.floor(rang / colonnes) * pas.rangees },
})),
reservations: [...animateurs].map(([table, participant]) => ({
participant,

View file

@ -17,6 +17,7 @@ import { nombrePlacesManquantes, normaliser } from '../moteur/configuration.js';
import { verifierInvariants } from '../moteur/verification.js';
import { configurationDepuisCharge } from '../stockage/document.js';
import { examiner, planDepuisFichier } from '../stockage/placements.js';
import { nomAffiche } from './personnes.js';
const avertissement = (code, details) => ({ code, details });
@ -111,11 +112,13 @@ export function invariantViole(charge, examen = examiner(charge)) {
* raisons d'examiner(apres) — derives, retenu.raisons — qu'examiner(avant)
* ne portait pas, comparées champ pour champ ; les propositions par
* identifiant, puis le retenu, sous id 'retenu' ;
* RESERVATION_NON_HONOREE {participant, table, tour} chaque violation
* RESERVATION de verifierInvariants(instance d'après,
* RESERVATION_NON_HONOREE {participant, table, tour, personne, numero}
* chaque violation RESERVATION de verifierInvariants(instance d'après,
* planDepuisFichier(apres.retenu)) absente du même calcul sur avant, qui
* n'en compte aucune quand son retenu est nul ou fautif, ou que sa
* configuration ne se normalisait pas.
* configuration ne se normalisait pas ; personne est le nom affiché du
* participant, numero celui de la table, lus sur la charge d'après, qui
* porte l'une et l'autre puisque l'instance qui les viole en vient.
*
* @param {import('../stockage/types.js').Charge} avant
* @param {import('../stockage/types.js').Charge} apres
@ -151,7 +154,10 @@ export function avertissementsApres(avant, apres) {
const tenues = reservations(avant, examenAvant, instanceAvant);
for (const { participant, table, tour } of reservations(apres, examenApres, instance)) {
const dejaLa = tenues.some((connue) => connue.participant === participant && connue.table === table && connue.tour === tour);
if (!dejaLa) rendus.push(avertissement('RESERVATION_NON_HONOREE', { participant, table, tour }));
if (dejaLa) continue;
const personne = nomAffiche(apres.participants.find(({ id }) => id === participant));
const { numero } = apres.tables.find(({ id }) => id === table);
rendus.push(avertissement('RESERVATION_NON_HONOREE', { participant, table, tour, personne, numero }));
}
return rendus;
}

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)", () => {
test('une réservation posée après la retenue, que le retenu ne tient pas : RESERVATION_NON_HONOREE {participant, table, tour}', () => {
test('une réservation posée après la retenue, que le retenu ne tient pas : RESERVATION_NON_HONOREE {participant, table, tour, personne, numero}', () => {
const avant = petiteSoiree();
const apres = avec(avant, reserverGrisaille);
assert.deepEqual(avertissementsApres(avant, apres), [
{ code: 'RESERVATION_NON_HONOREE', details: { participant: 2, table: 2, tour: 1 } },
{ code: 'RESERVATION_NON_HONOREE', details: { participant: 2, table: 2, tour: 1, personne: 'Grisaille', numero: 2 } },
]);
assert.deepEqual(avertissementsApres(apres, avec(apres, (charge) => (charge.participants[0].nom = 'Parasol'))), []);
});
test('la personne se nomme par son nom affiché, et la table par son numéro, distinct de son identifiant', () => {
const avant = avec(petiteSoiree(), (charge) => {
charge.tables[1].numero = 7;
charge.participants[1].prenom = 'Aubépine';
});
const apres = avec(avant, reserverGrisaille);
assert.deepEqual(avertissementsApres(avant, apres), [
{ code: 'RESERVATION_NON_HONOREE', details: { participant: 2, table: 2, tour: 1, personne: 'Aubépine Grisaille', numero: 7 } },
]);
});
test("retenir une proposition qui ne tient pas une réservation : le retenu nul d'avant n'en comptait aucune", () => {
const avant = avec(petiteSoiree({ retenu: false }), reserverGrisaille);
const apres = avec(avant, (charge) => {
@ -242,7 +253,7 @@ describe("avertissements : la réservation que le retenu n'honore plus (§ 5.8,
charge.evenement.etat = 'retenu';
});
assert.deepEqual(avertissementsApres(avant, apres), [
{ code: 'RESERVATION_NON_HONOREE', details: { participant: 2, table: 2, tour: 1 } },
{ code: 'RESERVATION_NON_HONOREE', details: { participant: 2, table: 2, tour: 1, personne: 'Grisaille', numero: 2 } },
]);
});
@ -250,7 +261,7 @@ describe("avertissements : la réservation que le retenu n'honore plus (§ 5.8,
const avant = avec(avec(petiteSoiree(), reserverGrisaille), rendreFautif);
const apres = avec(avant, (charge) => (charge.retenu.placement[0].reserve = []));
assert.deepEqual(avertissementsApres(avant, apres), [
{ code: 'RESERVATION_NON_HONOREE', details: { participant: 2, table: 2, tour: 1 } },
{ code: 'RESERVATION_NON_HONOREE', details: { participant: 2, table: 2, tour: 1, personne: 'Grisaille', numero: 2 } },
]);
});

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
* prochainsIds.table, numérotée après la plus grande, posée sur la première
* case libre (caseLibre). Sous un retenu lisible, elle y entre avec sa
* capacité et une liste vide à chaque tour.
* case libre (caseLibre) de la grille des démonstrations, au pas que donnent
* les tables posées, chacune à sa capacité, et la nouvelle. Sous un retenu
* lisible, elle y entre avec sa capacité et une liste vide à chaque tour.
*/
function creerTable(charge, { forme } = {}) {
exigerModifiable(charge);
@ -100,7 +101,8 @@ function creerTable(charge, { forme } = {}) {
const id = charge.prochainsIds.table;
if (id >= IDENTIFIANT_MAX) throw refus('COMPTEUR_SATURE', { compteur: 'table' });
const numero = Math.max(0, ...charge.tables.map((table) => table.numero)) + 1;
const position = caseLibre(charge.tables.map((table) => table.position));
const posees = charge.tables.map((table) => ({ position: table.position, forme: table.forme, places: capacite(charge, table) }));
const position = caseLibre(posees, { forme, places: charge.evenement.siegesParDefaut });
const suivante = structuredClone(charge);
suivante.tables.push({ id, numero, sieges: null, forme, position });
suivante.prochainsIds.table = id + 1;

View file

@ -11,7 +11,10 @@
import assert from 'node:assert/strict';
import { creerFichiersSimules } from '../../test/fichiers_simules.js';
import { describe, test } from '../../test/lanceur.js';
import { caseLibre } from '../geometrie/tables.js';
import { texteLivre } from '../demo/livrees.js';
import { constante } from '../geometrie/constantes.js';
import { caseLibre, geometrieTable } from '../geometrie/tables.js';
import { TAILLE_TEXTE_LISTE_CM } from '../geometrie/texte.js';
import { STATUT, nombrePlacesManquantes, normaliser } from '../moteur/configuration.js';
import { ErreurConfiguration } from '../moteur/erreurs.js';
import { serialiser } from '../stockage/canonique.js';
@ -118,6 +121,45 @@ function seNormalise(charge) {
}
}
// Hauteur de ligne d'une liste que suppose l'épreuve des bandes : 1,25 corps,
// au-dessus de la hauteur d'encre du gabarit sur les polices courantes ; le
// dessin la mesure au navigateur.
const HAUTEUR_LIGNE_SUPPOSEE = 1.25 * TAILLE_TEXTE_LISTE_CM;
// Les fautes « une liste passe sur une autre table » d'une charge : pour
// chaque paire ordonnée de tables, la bande du bloc de noms de la première —
// LARGEUR_BLOC_NOMS_CM de large depuis origineListe, capacité + 1 lignes de
// haut, en-tête d'une table gelée compris — recoupe le carré d'emprise de la
// seconde, bords exclus. Avec avec, seules les paires où figure la table
// d'identifiant avec sont examinées. Rend les fautes et le nombre de paires
// examinées.
function fautesBandes(charge, { avec = null } = {}) {
const largeur = constante('LARGEUR_BLOC_NOMS_CM');
const geometries = charge.tables.map((table) => ({ g: geometrieTable(table, capacite(charge, table)), places: capacite(charge, table) }));
const fautes = [];
let paires = 0;
for (const { g, places } of geometries) {
const bande = {
gauche: g.origineListe.x,
droite: g.origineListe.x + largeur,
haut: g.origineListe.y,
bas: g.origineListe.y + (places + 1) * HAUTEUR_LIGNE_SUPPOSEE,
};
for (const { g: autre } of geometries) {
if (autre === g || (avec !== null && g.id !== avec && autre.id !== avec)) continue;
paires += 1;
const e = autre.rayonEmprise;
const recoupe =
autre.centre.x + e > bande.gauche &&
autre.centre.x - e < bande.droite &&
autre.centre.y + e > bande.haut &&
autre.centre.y - e < bande.bas;
if (recoupe) fautes.push(`bloc de la table ${g.id} (${g.centre.x}, ${g.centre.y}) sur la table ${autre.id} (${autre.centre.x}, ${autre.centre.y})`);
}
}
return { fautes, paires };
}
// Une commande acceptée, sur une copie gelée de la charge : la charge rendue
// se relit, et se normalise quand la charge reçue se normalisait.
function accepter(nom, charge, args) {
@ -222,6 +264,56 @@ describe('mobilier : le registre (§ 8.2)', () => {
});
describe('mobilier : créer, déplacer et supprimer une table (§ 4, § 6.1, § 9)', () => {
test('une table créée se pose sur la grille des démonstrations : le pas tient compte des emprises et du bloc de noms, aucune liste ne passe sur une autre table', () => {
let charge = chargeDe();
for (let i = 0; i < 5; i += 1) charge = accepter('creerTable', charge, { forme: 'ronde' }).charge;
// Rondes de 8 : colonnes à 813 cm, rangées à 300 (ecartsGrille).
assert.deepEqual(
charge.tables.map(({ position }) => [position.x, position.y]),
[[0, 0], [813, 0], [0, 300], [813, 300], [1626, 0]],
);
// La bande du bloc de noms de chaque table, de toute sa largeur, ne
// recoupe l'emprise d'aucune autre.
assert.deepEqual(fautesBandes(charge), { fautes: [], paires: 5 * 4 });
// Une table passée à 16 places : le pas devient 966 × 430 ; toute case
// à moins d'un pas d'une table posée, sur les deux axes, est prise, la
// suivante est (1932, 430) : la table créée ne porte sa liste sur aucune
// table, et aucune liste ne passe sur elle. La table 5 agrandie reçoit
// la liste de sa voisine, ce que la création ne change pas.
charge = accepter('changerPlacesTable', charge, { id: 5, sieges: 16, origine: 'champ' }).charge;
charge = accepter('creerTable', charge, { forme: 'carree' }).charge;
assert.deepEqual(charge.tables.at(-1).position, { x: 1932, y: 430 });
assert.deepEqual(fautesBandes(charge, { avec: charge.tables.at(-1).id }), { fautes: [], paires: 2 * 5 });
});
test('dans la grande démonstration, la table 1 passée à 16 places : la table créée au pas 966 × 430 évite toute liste, (2898, 1720)', () => {
// La case (4830, 0) est à 765 cm de la table (4065, 0), dont la liste,
// de 4221,4 à 4721,4, couvrirait la table nouvelle, de 4709,6 à 4950,4 :
// elle est prise, comme toute case à moins d'un pas d'une table posée.
// La rangée 1720 est à 220 cm de la dernière rangée, de trois tables en
// 0, 813 et 1626 : la case (2898, 1720) est la première libre. Seules
// les paires où figure la table créée sont examinées : la table 1
// agrandie porte sa liste sur sa voisine, ce que la création ne change
// pas.
let charge = analyser(texteLivre('grande')).charge;
const premiere = charge.tables.find(({ numero }) => numero === 1);
assert.deepEqual(premiere.position, { x: 0, y: 0 });
charge = accepter('changerPlacesTable', charge, { id: premiere.id, sieges: 16, origine: 'champ' }).charge;
charge = accepter('creerTable', charge, { forme: 'ronde' }).charge;
assert.deepEqual(charge.tables.at(-1).position, { x: 2898, y: 1720 });
const creee = charge.tables.at(-1).id;
assert.deepEqual(fautesBandes(charge, { avec: creee }), { fautes: [], paires: 2 * 33 });
});
test('créée dans la grande démonstration, une table se pose sur la case suivante de sa grille, (2439, 1500)', () => {
// 33 rondes de 8 sur six colonnes à 813 cm et des rangées à 300 cm ; la
// trente-quatrième case, rang 33, est la colonne 3 de la rangée 5.
const grande = analyser(texteLivre('grande')).charge;
assert.equal(grande.tables.length, 33);
const { charge } = accepter('creerTable', grande, { forme: 'ronde' });
assert.deepEqual(charge.tables.at(-1).position, { x: 2439, y: 1500 });
});
test("les identifiants ne se réutilisent jamais ; le numéro suit le plus grand, et le libellé nomme le numéro", () => {
let charge = chargeDe();
for (let i = 0; i < 3; i += 1) charge = accepter('creerTable', charge, { forme: 'ronde' }).charge;
@ -234,7 +326,10 @@ describe('mobilier : créer, déplacer et supprimer une table (§ 4, § 6.1, §
numero: 3,
sieges: null,
forme: 'carree',
position: caseLibre(charge.tables.map(({ position }) => position)),
position: caseLibre(
charge.tables.map((table) => ({ position: table.position, forme: table.forme, places: capacite(charge, table) })),
{ forme: 'carree', places: charge.evenement.siegesParDefaut },
),
});
assert.equal(cree.charge.prochainsIds.table, 5);
const deplace = accepter('deplacerTable', cree.charge, { id: 4, position: { x: 612.4, y: -0.4 } });
@ -272,6 +367,20 @@ describe('mobilier : créer, déplacer et supprimer une table (§ 4, § 6.1, §
assert.equal(capacite(dix, dix.tables[0]), 10);
});
test('une table créée se pose au pas de sa propre capacité, le défaut : sous un défaut de 16, à côté d’une ronde de 8, en (966, 0) et non en (813, 0)', () => {
// Une ronde de 8 posée en (0, 0) : la grille a deux colonnes, et la case
// (0, 0) est prise. Le pas se calcule sur la plus grande emprise, celle
// de la table nouvelle à 16 places : 966 cm entre colonnes, là où deux
// rondes de 8 n'en demandent que 813.
const charge = chargeDe({ tables: [{ id: 1, sieges: 8 }], defaut: 16 });
charge.tables[0].position = { x: 0, y: 0 };
const { charge: apres } = accepter('creerTable', charge, { forme: 'ronde' });
const creee = apres.tables.at(-1);
assert.equal(capacite(apres, creee), 16);
assert.deepEqual(creee.position, { x: 966, y: 0 });
assert.deepEqual(fautesBandes(apres), { fautes: [], paires: 2 });
});
test('une table créée après la retenue entre au retenu, vide à chaque tour ; les propositions dérivent TABLE_AJOUTEE', () => {
const charge = chargeDe({ tables: [{ id: 1 }, { id: 2 }], n: 6 });
const listes = [[[1, 2, 3], [4, 5, 6]], [[1, 4, 5], [2, 3, 6]]];

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', () => {
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/);

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

View file

@ -7,7 +7,9 @@
// dessin pose les chaises par geometrieTable et la désignation d'un siège
// (designation.js) lit les mêmes chaises : une seule implémentation, que le
// dessin et le dépôt partagent. Aucune table ne tourne : la géométrie ne
// porte aucun angle.
// porte aucun angle. La grille où se posent les tables des démonstrations et
// celles que l'opérateur crée a un seul pas, ecartsGrille.
import { constante } from './constantes.js';
/**
* @typedef {Object} GeometrieTable en cm
@ -40,8 +42,9 @@ export const ECART_POIGNEE = 8;
* 26 cm d'elle, plus la moitié de son trait ; le bloc commence au-delà, et
* aucune marque ne couvre la liste. */
export const ECART_LISTE = 36;
/** Distance entre deux cases de la grille des tables créées, en cm. */
export const PAS_GRILLE = 300;
/** Écart minimal entre les centres de deux rangées de la grille des tables,
* en cm. */
export const ECART_RANGEES = 300;
// Accroissement du rayon d'une ronde pour une place de plus, au-dessus du
// plancher : le périmètre croît de LARGEUR_PAR_PLACE, le rayon de 60 / 2π.
@ -204,26 +207,63 @@ export function englobant(geometries) {
return { minX, minY, maxX, maxY };
}
/**
* Le pas de la grille des tables, en cm entiers, pour la plus grande emprise
* E des tables données, chacune de sa forme et de son nombre de places :
* entre deux colonnes, ⌈2E + 2 × ECART_LISTE + LARGEUR_BLOC_NOMS_CM⌉ — le bloc
* de noms d'une table, à ECART_LISTE de son emprise et large de
* LARGEUR_BLOC_NOMS_CM, où toute ligne s'abrège, s'achève à ECART_LISTE de
* l'emprise de la colonne suivante : aucune liste ne passe sur une autre
* table de la grille, d'aucune rangée ; entre deux rangées,
* max(ECART_RANGEES, ⌈2E + ECART_LISTE⌉) — deux emprises voisines restent à
* ECART_LISTE l'une de l'autre, la marge du carré de focus.
*
* @param {Array<{forme: 'ronde'|'carree', places: number}>} tables au moins une
* @returns {{colonnes: number, rangees: number}}
*/
export function ecartsGrille(tables) {
const emprise = Math.max(
...tables.map(({ forme, places }) => geometrieTable({ id: 0, numero: 0, forme, position: { x: 0, y: 0 } }, places).rayonEmprise),
);
return {
colonnes: Math.ceil(2 * emprise + 2 * ECART_LISTE + constante('LARGEUR_BLOC_NOMS_CM')),
rangees: Math.max(ECART_RANGEES, Math.ceil(2 * emprise + ECART_LISTE)),
};
}
/**
* Position d'une table créée : la première case libre, ligne par ligne, d'une
* grille de PAS_GRILLE cm à ⌈√(T + 1)⌉ colonnes, T = centres.length, partant
* de (0, 0) ; une case est prise quand un centre est à PAS_GRILLE / 2 ou
* moins. centres : les positions de charge.tables, que l'appelant passe sans
* construire de géométrie.
* grille à ⌈√(T + 1)⌉ colonnes, T = tables.length, partant de (0, 0), au pas
* d'ecartsGrille des tables posées et de la nouvelle — le pas des
* démonstrations (scripts/engendrer_livrables.js). Une case est prise quand
* une table posée est à moins d'un pas de son centre sur chacun des deux
* axes, bornes exclues. Une table à un pas de colonne ou plus a son emprise
* hors de la bande du bloc de noms de la table nouvelle, et la sienne
* s'achève avant l'emprise nouvelle, à ECART_LISTE au moins, quelle que soit
* la rangée : c'est le calcul d'ecartsGrille, fait sur la plus grande
* emprise des deux. Une table à un pas de rangée ou plus a son emprise à
* ECART_LISTE au moins de l'emprise nouvelle. Ainsi une table déplacée hors
* de la grille, ou une grille dont le pas a crû depuis qu'elle a été posée,
* ne reçoit pas la liste de la table nouvelle et ne lui porte pas la sienne
* à travers une colonne.
*
* La recherche s'arrête : un centre est à PAS_GRILLE / 2 ou moins d'au plus
* deux cases, le plus petit disque qui contient trois cases ayant un rayon
* d'au moins PAS_GRILLE / √2 ; l'une des 2T + 1 premières cases est donc
* libre.
* La recherche s'arrête : un intervalle ouvert de largeur 2 × pas contient
* au plus deux multiples du pas, et une table prend donc au plus quatre
* cases ; l'une des 4T + 1 premières cases est libre.
*
* @param {Array<{x: number, y: number}>} centres
* @param {Array<{position: {x: number, y: number}, forme: 'ronde'|'carree', places: number}>} tables
* les tables posées, chacune à sa capacité courante
* @param {{forme: 'ronde'|'carree', places: number}} nouvelle la table à poser
* @returns {{x: number, y: number}}
*/
export function caseLibre(centres) {
const colonnes = Math.ceil(Math.sqrt(centres.length + 1));
export function caseLibre(tables, nouvelle) {
const { colonnes: pasX, rangees: pasY } = ecartsGrille([...tables, nouvelle]);
const colonnes = Math.ceil(Math.sqrt(tables.length + 1));
for (let rang = 0; ; rang += 1) {
const candidate = { x: (rang % colonnes) * PAS_GRILLE, y: Math.floor(rang / colonnes) * PAS_GRILLE };
const prise = centres.some(({ x, y }) => Math.hypot(x - candidate.x, y - candidate.y) <= PAS_GRILLE / 2);
const candidate = { x: (rang % colonnes) * pasX, y: Math.floor(rang / colonnes) * pasY };
const prise = tables.some(
({ position: { x, y } }) => Math.abs(x - candidate.x) < pasX && Math.abs(y - candidate.y) < pasY,
);
if (!prise) return candidate;
}
}

View file

@ -8,18 +8,21 @@
// attendues se calculent à la main dans le commentaire qui les précède.
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { constante } from './constantes.js';
import { surLeSol } from './designation.js';
import { TAILLE_TEXTE_LISTE_CM } from './texte.js';
import {
ECART_LISTE,
ECART_POIGNEE,
ECART_RANGEES,
LARGEUR_PAR_PLACE,
PAS_GRILLE,
PLANCHER_DIAMETRE,
RAYON_CHAISE,
RAYON_POIGNEE,
caseLibre,
cote,
diametre,
ecartsGrille,
englobant,
geometrieTable,
placesSousPoignee,
@ -42,6 +45,48 @@ const distance = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
// Une table du fichier d'état : identifiant, numéro, forme et centre.
const table = (id, forme, x, y) => ({ id, numero: id, sieges: null, forme, position: { x, y } });
// Hauteur de ligne d'une liste que suppose l'épreuve des bandes : 1,25 corps,
// au-dessus de la hauteur d'encre du gabarit sur les polices courantes. Le
// dessin la mesure au navigateur ; le module de géométrie ne la connaît pas.
const HAUTEUR_LIGNE_SUPPOSEE = 1.25 * TAILLE_TEXTE_LISTE_CM;
// Les fautes « une liste passe sur une autre table » d'un ensemble de tables
// posées {position, forme, places} : pour chaque paire ordonnée, la bande du
// bloc de noms de la première — LARGEUR_BLOC_NOMS_CM de large depuis
// origineListe, places + 1 lignes de haut, en-tête d'une table gelée
// compris — recoupe le carré d'emprise de la seconde, bords exclus. Avec
// avec, seules les paires où figure la table de ce rang, compté depuis 1,
// sont examinées. Rend les fautes et le nombre de paires examinées.
function fautesBandes(posees, { avec = null } = {}) {
const largeur = constante('LARGEUR_BLOC_NOMS_CM');
const geometries = posees.map(({ position, forme, places }, rang) => ({
g: geometrieTable(table(rang + 1, forme, position.x, position.y), places),
places,
}));
const fautes = [];
let paires = 0;
for (const { g, places } of geometries) {
const bande = {
gauche: g.origineListe.x,
droite: g.origineListe.x + largeur,
haut: g.origineListe.y,
bas: g.origineListe.y + (places + 1) * HAUTEUR_LIGNE_SUPPOSEE,
};
for (const { g: autre } of geometries) {
if (autre === g || (avec !== null && g.id !== avec && autre.id !== avec)) continue;
paires += 1;
const e = autre.rayonEmprise;
const recoupe =
autre.centre.x + e > bande.gauche &&
autre.centre.x - e < bande.droite &&
autre.centre.y + e > bande.haut &&
autre.centre.y - e < bande.bas;
if (recoupe) fautes.push(`bloc de la table ${g.id} (${g.centre.x}, ${g.centre.y}) sur la table ${autre.id} (${autre.centre.x}, ${autre.centre.y})`);
}
}
return { fautes, paires };
}
describe('constantes', () => {
test('les valeurs du contrat', () => {
assert.equal(PLANCHER_DIAMETRE, 70);
@ -50,7 +95,7 @@ describe('constantes', () => {
assert.equal(RAYON_POIGNEE, 15);
assert.equal(ECART_POIGNEE, 8);
assert.equal(ECART_LISTE, 36);
assert.equal(PAS_GRILLE, 300);
assert.equal(ECART_RANGEES, 300);
});
});
@ -289,27 +334,105 @@ describe('englobant', () => {
});
});
describe('caseLibre', () => {
test('sans table, l’origine', () => {
assert.deepEqual(caseLibre([]), { x: 0, y: 0 });
describe('ecartsGrille : le pas d\u{2019}une grille de tables (§ 7.4, § 7.7)', () => {
// colonnes = ⌈2 × emprise + 2 × ECART_LISTE + LARGEUR_BLOC_NOMS_CM⌉ ;
// rangées = max(ECART_RANGEES, ⌈2 × emprise + ECART_LISTE⌉), l'emprise la
// plus grande des tables données. LARGEUR_BLOC_NOMS_CM vaut 500.
test('la largeur du bloc de noms est celle du registre', () => {
assert.equal(constante('LARGEUR_BLOC_NOMS_CM'), 500);
});
test('deux centres en (0, 0) et (300, 0) : deux colonnes, la case suivante est (0, 300)', () => {
assert.deepEqual(caseLibre([{ x: 0, y: 0 }, { x: 300, y: 0 }]), { x: 0, y: 300 });
test('ronde de 3 : emprise 79, colonnes 158 + 72 + 500 = 730, rangées 300', () => {
assert.deepEqual(ecartsGrille([{ forme: 'ronde', places: 3 }]), { colonnes: 730, rangees: 300 });
});
test('un centre en (150, 0) prend les deux premières cases', () => {
assert.deepEqual(caseLibre([{ x: 150, y: 0 }]), { x: 0, y: 300 });
test('ronde de 8 : emprise 120,39, colonnes ⌈812,79⌉ = 813, rangées 300', () => {
// 2 × 120,3944 + 72 + 500 = 812,789 → 813 ; 2 × 120,3944 + 36 = 276,8 → 300.
assert.deepEqual(ecartsGrille([{ forme: 'ronde', places: 8 }]), { colonnes: 813, rangees: 300 });
});
test('un centre à plus de PAS_GRILLE / 2 laisse la case libre', () => {
assert.deepEqual(caseLibre([{ x: 150.001, y: 0 }]), { x: 0, y: 0 });
test('carrée de 8 : côté 120, emprise 104, colonnes 780, rangées 300', () => {
assert.deepEqual(ecartsGrille([{ forme: 'carree', places: 8 }]), { colonnes: 780, rangees: 300 });
});
test('quatre centres sur 2 × 2 : trois colonnes, la case suivante est (600, 0)', () => {
const centres = [{ x: 0, y: 0 }, { x: 300, y: 0 }, { x: 0, y: 300 }, { x: 300, y: 300 }];
assert.deepEqual(caseLibre(centres.slice(0, 3)), { x: 300, y: 300 });
assert.deepEqual(caseLibre(centres), { x: 600, y: 0 });
test('la plus grande emprise décide : une ronde de 16 parmi des rondes de 8 → colonnes 966, rangées 430', () => {
// diamètre 16 × 60 / π = 305,577 ; emprise 152,789 + 44 = 196,789 ;
// 393,578 + 572 = 965,578 → 966 ; 393,578 + 36 = 429,578 → 430.
const tables = [{ forme: 'ronde', places: 8 }, { forme: 'ronde', places: 16 }, { forme: 'carree', places: 8 }];
assert.deepEqual(ecartsGrille(tables), { colonnes: 966, rangees: 430 });
});
});
describe('caseLibre : la place d\u{2019}une table créée, sur la grille des démonstrations', () => {
const huit = { forme: 'ronde', places: 8 };
const posee = (x, y, autres = huit) => ({ position: { x, y }, ...autres });
test('sans table, l\u{2019}origine', () => {
assert.deepEqual(caseLibre([], huit), { x: 0, y: 0 });
});
test('deux rondes de 8 en (0, 0) et (813, 0) : deux colonnes, la case suivante est (0, 300)', () => {
assert.deepEqual(caseLibre([posee(0, 0), posee(813, 0)], huit), { x: 0, y: 300 });
});
test('un centre à moins d\u{2019}un pas de colonne, (406,6 ; 0) ou (812,9 ; 0), prend les deux premières cases ; à 813, la première est libre', () => {
// À 406,6 de (0, 0), le bloc de la table nouvelle, de 156,4 à 656,4,
// couvrirait l'emprise posée, de 286,2 à 527 : la case est refusée.
assert.deepEqual(caseLibre([posee(406.6, 0)], huit), { x: 0, y: 300 });
assert.deepEqual(caseLibre([posee(812.9, 0)], huit), { x: 0, y: 300 });
assert.deepEqual(caseLibre([posee(813, 0)], huit), { x: 0, y: 0 });
});
test('un centre à moins d\u{2019}un pas de rangée, (0 ; 150,1) ou (0 ; 299,9), prend la case (0, 0) ; à 300, elle est libre', () => {
assert.deepEqual(caseLibre([posee(0, 150.1)], huit), { x: 813, y: 0 });
assert.deepEqual(caseLibre([posee(0, 299.9)], huit), { x: 813, y: 0 });
assert.deepEqual(caseLibre([posee(0, 300)], huit), { x: 0, y: 0 });
});
test('une table déplacée en (330, 0) prend aussi (813, 0) : la case suivante est (0, 300), et aucun bloc ne passe sur l\u{2019}autre table', () => {
// En (813, 0), à 483 cm, la liste de la table déplacée, de 486,4 à
// 986,4, couvrirait la table nouvelle, de 692,6 à 933,4.
const posees = [posee(330, 0)];
const position = caseLibre(posees, huit);
assert.deepEqual(position, { x: 0, y: 300 });
assert.deepEqual(fautesBandes([...posees, posee(position.x, position.y)]), { fautes: [], paires: 2 });
});
test('l\u{2019}épreuve des bandes voit la faute : une ronde de 8 en (330, 0) et une autre en (813, 0)', () => {
assert.equal(fautesBandes([posee(330, 0), posee(813, 0)]).fautes.length, 1);
});
test('une ronde de 8 agrandie à 16 au milieu de rondes de 8 : la table créée au nouveau pas ne porte ni ne subit aucune liste', () => {
// Vingt rondes de 8 sur cinq colonnes au pas 813 × 300, la première
// passée à 16 places : le pas devient 966 × 430, sur cinq colonnes. La
// case (3864, 0) est à 612 cm de la table (3252, 0), dont la liste, de
// 3408,4 à 3908,4, couvrirait la table nouvelle, de 3743,6 à 3984,4 :
// elle est prise, comme toute case à moins d'un pas d'une table posée,
// jusqu'à la rangée 1720, à 820 cm de la dernière. Seules les paires où
// figure la table nouvelle sont examinées : la table agrandie porte sa
// liste sur ses voisines, ce que la création ne change pas.
const posees = Array.from({ length: 20 }, (_, rang) => posee((rang % 5) * 813, Math.floor(rang / 5) * 300));
posees[0] = posee(0, 0, { forme: 'ronde', places: 16 });
const position = caseLibre(posees, huit);
assert.deepEqual(position, { x: 0, y: 1720 });
assert.deepEqual(fautesBandes([...posees, posee(position.x, position.y)], { avec: 21 }), { fautes: [], paires: 2 * 20 });
});
test('le pas suit la table à créer et les tables posées : une ronde de 16 posée écarte la case suivante à 966', () => {
assert.deepEqual(caseLibre([posee(0, 0, { forme: 'ronde', places: 16 })], huit), { x: 966, y: 0 });
assert.deepEqual(caseLibre([posee(0, 0)], { forme: 'ronde', places: 16 }), { x: 966, y: 0 });
});
test('trente-trois rondes de 8 créées une à une : les cases de la grille des démonstrations, et aucun bloc de noms ne passe sur une autre table', () => {
const posees = [];
for (let n = 0; n < 33; n += 1) posees.push(posee(...Object.values(caseLibre(posees, huit))));
assert.equal(posees.length, 33);
assert.equal(new Set(posees.map(({ position: { x, y } }) => `${x},${y}`)).size, 33);
for (const { position } of posees) {
assert.equal(position.x % 813, 0, `x ${position.x}`);
assert.equal(position.y % 300, 0, `y ${position.y}`);
}
assert.deepEqual(fautesBandes(posees), { fautes: [], paires: 33 * 32 });
});
});

View file

@ -156,8 +156,16 @@ export const TABLE = new Map([
['plan.menu.deplacerReservation', 'Déplacer sa réservation'],
['plan.menu.exclure', 'Exclure'],
['plan.menu.reintegrer', 'Réintégrer'],
['plan.menu.mettreEnEvidence', 'Mettre en évidence'],
['plan.menu.oterEvidence', 'Ôter la mise en évidence'],
['plan.menu.ouvrirFiche', 'Ouvrir la fiche'],
// --- La mise en évidence d'une personne (§ 7.1, § 8.4) ----------------------
// Le nom de la personne mise en évidence est une donnée dans son nœud.
['plan.evidence.personne', 'Mise en évidence : '],
['plan.evidence.oter', 'Ôter la mise en évidence'],
['plan.evidence.voir', 'Voir sur le plan'],
// --- Les listes de travail (§ 5.8, § 4.1, § 4.2) ---------------------------
// Le libellé d'un titre, le titre pressenti et le nom d'une personne sont
// des données, chacun dans son nœud, entre ces textes.

View file

@ -21,6 +21,9 @@
// lieu, avec son remède : celui de la séance, lu dans etat().refus avant
// d'ouvrir l'édition, ou celui de la commande, la saisie gardée.
//
// « Voir sur le plan » met la personne en évidence et ouvre le plan, au
// tour montré (§ 7.1, § 8.4) : un geste de regard, qui n'écrit rien.
//
// L'en-tête trie : un clic sur une colonne la trie croissante, un second
// décroissante ; le sens se lit en texte et dans aria-sort. Un collage sur
// la grille, hors d'une cellule en édition, ouvre l'aperçu d'import (§
@ -38,6 +41,11 @@
const t = getContext('t');
const seance = getContext('seance');
const ui = getContext('ui');
function voirSurLePlan(id) {
ui.mettreEnEvidence(id);
ui.allerA('plan');
}
const uid = $props.id();
const geste = creerGeste(seance);
@ -327,6 +335,9 @@
<button type="button" data-commande="fiche" onclick={() => ui.ouvrirFiche(personne.id)}>
{t('participants.commande.fiche')}
</button>
<button type="button" data-commande="voirSurLePlan" onclick={() => voirSurLePlan(personne.id)}>
{t('plan.evidence.voir')}
</button>
{#if personne.exclu}
<Commande
cle="participants.commande.reintegrer"

View file

@ -10,10 +10,15 @@
// 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
// 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 { toursOfferts } from './vue_plan.js';
let { tour, tours, zoomer, ajuster } = $props();
let { tour, tours, zoomer, ajuster, evidence = null } = $props();
const t = getContext('t');
const ui = getContext('ui');
@ -39,6 +44,12 @@
<button type="button" data-commande="pleinEcran" aria-pressed={$ui.pleinEcran} onclick={() => ui.basculerPleinEcran()}
>{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>
<style>
@ -49,6 +60,13 @@
gap: var(--espace-2);
}
.evidence {
display: flex;
align-items: center;
gap: var(--espace-1);
margin: 0;
}
.tour {
display: flex;
align-items: center;

View file

@ -15,6 +15,7 @@
// (§ 14.8).
import { afterEach, beforeAll, describe, expect, test } from 'vitest';
import { flushSync, mount, unmount } from 'svelte';
import { SvelteMap } from 'svelte/reactivity';
import '../jetons.css';
import { creerFormat } from '../../application/format.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 BandeauPlan from './BandeauPlan.svelte';
import EchellePlan from './EchellePlan.svelte';
import { creerMesureur } from './mesure_texte.js';
import { LARGEUR_ECHELLE_PX, modeleDessin } from './modele.js';
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
* 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. */
function monterPlan(modele, vue = { k: 1, tx: 0, ty: 0 }, { cache = false } = {}) {
* en display: none, et conteneur.style.display = '' le montre. props
* 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');
conteneur.dataset.surface = '';
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('transform', transformDe(vue));
svg.append(groupe);
const composant = mount(PlanDessin, { target: groupe, props: { modele }, context: CONTEXTE });
const composant = mount(PlanDessin, { target: groupe, props, context: CONTEXTE });
flushSync();
montes.push({ composant, conteneur });
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);
}
});
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)', () => {

View file

@ -19,14 +19,15 @@
// n'est pas assise (executerSurPlace), sinon à côté de la commande.
//
// 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 { etatPlaces } from '../../application/places.js';
import Commande from '../primitives/Commande.svelte';
import { commandeDeDepot } from './depot.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 seance = getContext('seance');
@ -35,8 +36,8 @@
const charge = $derived($seance.charge);
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 : etatPlaces(charge, { tour, proposition: $ui.proposition }));
const etat = $derived(fiche === undefined ? null : etatTour);
const tour = $derived(etatTour === null ? 1 : etatTour.tour);
// La place que la personne occupe au tour montré, et sa chaise, ou null.
const assise = $derived.by(() => {
if (etat === null) return null;
@ -51,7 +52,10 @@
? null
: 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 };
// Où poser un refus : sa place, sinon la place sélectionnée.
@ -87,6 +91,8 @@
deplacerReservation,
exclure: () => executerSurPlace(dependances, lieu(), 'exclureParticipant', { id: participant }),
reintegrer: () => executerSurPlace(dependances, lieu(), 'reintegrerParticipant', { id: participant }),
mettreEnEvidence: () => ui.mettreEnEvidence(participant),
oterEvidence: () => ui.mettreEnEvidence(null),
ouvrirFiche: () => ui.ouvrirFiche(participant),
};
</script>

View file

@ -21,15 +21,19 @@
// — 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
// 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 { etatPlaces, libellePropose } from '../../application/places.js';
import { libellePropose } from '../../application/places.js';
import Commande from '../primitives/Commande.svelte';
import { commandeDeDepot } from './depot.js';
import { libellePlace } from './gestes.js';
import MenuPersonne from './MenuPersonne.svelte';
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 seance = getContext('seance');
@ -39,8 +43,8 @@
const charge = $derived($seance.charge);
const selection = $derived($ui.selection);
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 : etatPlaces(charge, { tour, proposition: $ui.proposition }));
const etat = $derived(place === null ? null : etatTour);
const tour = $derived(etatTour === null ? 1 : etatTour.tour);
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 nomDe = $derived(charge === null ? null : nomDeCharge(charge));
@ -199,7 +203,7 @@
{/if}
</div>
{#if menuDe !== null}
<MenuPersonne participant={menuDe} />
<MenuPersonne participant={menuDe} {etatTour} />
{/if}
</section>
{/if}

View file

@ -22,14 +22,17 @@
// (executerSurPlace), LIBELLE_VIDE à côté du champ.
//
// É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 { 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 { libellePlace } from './gestes.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 seance = getContext('seance');
@ -39,8 +42,7 @@
const charge = $derived($seance.charge);
const selection = $derived($ui.selection);
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 : etatPlaces(charge, { tour, proposition: $ui.proposition }));
const etat = $derived(table === null ? null : etatTour);
const etatTable = $derived(etat === null ? undefined : etat.tables.find(({ id }) => id === table));
const nomDe = $derived(charge === null ? null : nomDeCharge(charge));
const comptes = $derived(etatTable === undefined ? null : comptesTitres(charge, table));

View file

@ -261,16 +261,24 @@ async function cliquerPoint({ x, y }) {
await images(2);
}
// Un clic réel au centre d'une chaise. Quand un avertissement paru au-dessus
// du plan l'a poussé sous le bas de la fenêtre, la page défile d'abord
// jusqu'à la chaise.
// Un clic réel au centre d'une chaise. Quand elle est hors du cadre du
// plan — les annonces du cadre de mode, avertissements compris, prennent
// 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) {
const atteinte = () => {
const { x, y } = pointChaise(m, table, siege);
return chaiseEl(m, table, siege)?.contains(document.elementFromPoint(x, y)) ?? false;
};
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 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.retenu).toEqual(origine.retenu);
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());
});
// L'avertissement nomme Iris et la table 3 : son texte vient du libellé
// d'A2 et du composant des avertissements d'I4, que le correctif
// rapports/I11-avertissement-nomme.diff complète. L'épreuve échoue tant
// 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 () => {
// L'avertissement nomme Iris et la table 3 : ses détails portent le nom
// affiché de la personne et le numéro de la table (avertissementsApres).
test('RESERVATION_NON_HONOREE nomme Iris Ombrelle et la table 3', async () => {
const base = chargeDe({
id: 'evt-apres-generation',
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();
});
});
// --- 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
// 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
// écouteur de chaque type, sur la racine : pointerdown identifie la source
@ -86,7 +88,6 @@
// suivant.
import { getContext, tick, untrack } from 'svelte';
import { controlerCapacite } from '../../application/capacite.js';
import { etatPlaces } from '../../application/places.js';
import { constante } from '../../geometrie/constantes.js';
import { ajuster as vueAjustee, ecranDepuisDessin, transformDe } from '../../geometrie/vue.js';
import BandeauPlan from './BandeauPlan.svelte';
@ -104,14 +105,13 @@
cadreDepuisRect,
englobantDuTour,
nomDeCharge,
tourAffiche,
vueApresCadre,
vueInitiale,
zoomerBouton,
zoomerMolette,
} from './vue_plan.js';
let { diagnostic = null } = $props();
let { diagnostic = null, etatTour } = $props();
const t = getContext('t');
const seance = getContext('seance');
@ -148,12 +148,15 @@
const charge = $derived($seance.charge);
const tours = $derived(charge.evenement.tours);
const tour = $derived(tourAffiche($ui.tour, tours));
const etat = $derived(etatPlaces(charge, { tour, proposition: $ui.proposition }));
const etat = $derived(etatTour);
const tour = $derived(etat.tour);
const englobantPlan = $derived(englobantDuTour(etat));
const nomDe = $derived(nomDeCharge(charge));
const selection = $derived($ui.selection);
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.
const actifMontre = $derived(aLeFocus && auClavier ? actif : null);
const modele = $derived(
@ -693,7 +696,7 @@
</script>
<div class="plan">
<BarreVue {tour} {tours} {zoomer} {ajuster} />
<BarreVue {tour} {tours} {zoomer} {ajuster} {evidence} />
<div class="surface" data-surface bind:this={surface}>
<BandeauPlan bandeau={modele === null ? [] : modele.bandeau} />
<!-- Le plan est un composant interactif à deux dimensions, que le

View file

@ -11,10 +11,12 @@
// 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) ;
// É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 { etatPlaces } from '../../application/places.js';
import { nomDeCharge, tourAffiche } from './vue_plan.js';
import { nomDeCharge } from './vue_plan.js';
let { etatTour } = $props();
const t = getContext('t');
const seance = getContext('seance');
@ -22,8 +24,8 @@
const uid = $props.id();
const charge = $derived($seance.charge);
const tour = $derived(charge === null ? 1 : tourAffiche($ui.tour, charge.evenement.tours));
const reserve = $derived(charge === null ? [] : etatPlaces(charge, { tour, proposition: $ui.proposition }).reserve);
const tour = $derived(etatTour === null ? 1 : etatTour.tour);
const reserve = $derived(etatTour === null ? [] : etatTour.reserve);
const nomDe = $derived(charge === null ? null : nomDeCharge(charge));
const choix = $derived($ui.choix);
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,
// 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
// d'une table. Les panneaux lisent 'seance' et 'ui' au contexte : la
// 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.
// d'une table. Les panneaux lisent 'seance' et 'ui' au contexte.
//
// 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
// surface que le cadre de mode lui laisse sous le bandeau (§ 18.6), et le
// cadre l'entoure toujours (§ 8.4).
import { getContext } from 'svelte';
import { diagnostiquerCharge } from '../../application/diagnostic.js';
import { etatPlaces } from '../../application/places.js';
import Diagnostic from '../diagnostic/Diagnostic.svelte';
import ListesTravail from './ListesTravail.svelte';
import PanneauPlace from './PanneauPlace.svelte';
import PanneauTable from './PanneauTable.svelte';
import Plan from './Plan.svelte';
import Reserve from './Reserve.svelte';
import { tourAffiche } from './vue_plan.js';
const t = getContext('t');
const seance = getContext('seance');
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
// 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
// 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>
<section data-section="plan" class="section-plan">
@ -41,7 +53,7 @@
<div class="corps">
{#if evenement !== null}
{#key evenement}
<Plan {diagnostic} />
<Plan {diagnostic} {etatTour} />
{/key}
{/if}
{#if !$ui.pleinEcran}
@ -50,9 +62,9 @@
<Diagnostic compact />
</div>
<ListesTravail />
<Reserve />
<PanneauPlace />
<PanneauTable />
<Reserve {etatTour} />
<PanneauPlace {etatTour} />
<PanneauTable {etatTour} />
</aside>
{/if}
</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)', () => {
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 verifier = async (gestes) => {
const avant = fichiers(m);
@ -590,15 +590,24 @@ describe('le cadrage (§ 8.5, § 13.3)', () => {
const lue = vueLue(m);
expect({ k: ecrit.k, tx: ecrit.tx, ty: ecrit.ty }).toEqual(lue);
};
expect(m.seance.etat().mode).toBe('lecture');
await verifier(async () => {
const zooms = async () => {
await cran(m, -100);
await cran(m, -100);
cliquer(m, 'zoomer');
cliquer(m, 'dezoomer');
cliquer(m, 'ajuster');
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.executer('changerEtat', { etat: 'bloque' });
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
* 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
* sienne tient sa chaise ; exclure, ou réintégrer une exclue ; ouvrir la
* fiche.
* sienne tient sa chaise ; exclure, ou réintégrer une exclue ; la mettre en
* évidence, ou ôter sa mise en évidence quand elle l'est ; ouvrir la fiche.
*
* @param {{exclu: boolean, chaise: Object|null, retrait: Object|null}} personne
* chaise, l'EtatChaise qu'elle occupe au tour montré, null hors du dessin
* @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 ;
* enEvidence, vrai quand elle est la personne mise en évidence (ui.miseEnEvidence)
* @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 tenue = assise && chaise.reservation !== null && chaise.reservation.participant === chaise.occupant;
return [
@ -492,6 +493,8 @@ export function actionsPersonne({ exclu, chaise, retrait }) {
['deplacerReservation', tenue],
['exclure', !exclu],
['reintegrer', exclu],
['mettreEnEvidence', !enEvidence],
['oterEvidence', enEvidence],
['ouvrirFiche', true],
]
.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 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', () => {
assert.deepEqual(actionsPersonne({ exclu: false, chaise: assise, retrait }), [
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, enEvidence: false }), [
'deplacer',
'echanger',
'retirerDuTour',
'reserverIci',
'exclure',
'mettreEnEvidence',
'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 »', () => {
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', () => {
assert.deepEqual(actionsPersonne({ exclu: false, chaise: null, retrait: null }), ['deplacer', 'exclure', 'ouvrirFiche']);
assert.deepEqual(actionsPersonne({ exclu: true, chaise: null, retrait: null }), ['deplacer', 'reintegrer', 'ouvrirFiche']);
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, enEvidence: false }), ['deplacer', 'exclure', 'mettreEnEvidence', '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 actions = new Set([...actionsPlace(libre, reservation), ...actionsPlace(tenue, { genre: 'personne', participant: 2, depuis: null })]);
const menu = new Set([
...actionsPersonne({ exclu: false, chaise: { ...libre, occupant: 4 }, retrait: {} }),
...actionsPersonne({ exclu: true, chaise: tenue, retrait: {} }),
...actionsPersonne({ exclu: false, chaise: { ...libre, occupant: 4 }, retrait: {}, enEvidence: false }),
...actionsPersonne({ exclu: true, chaise: tenue, retrait: {}, enEvidence: true }),
]);
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 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');

View file

@ -548,10 +548,6 @@ export function releverMarques(racine) {
// --- 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.
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
* 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,
* 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
* 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.
@ -593,7 +590,7 @@ function couvreUnPoint(forme, { left, top, right, bottom }) {
*/
export function releverListes(racine) {
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) }));
const fautes = [];
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"]']);
});
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', () => {
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" />' +
@ -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)', () => {
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);
const fautes = [
...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', () => {
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 fautes = MARQUES.filter(({ nom }) => !vues.has(nom)).map(({ nom }) => `${nom} : rendue sur aucun écran`);
for (const r of atteints()) {
@ -1235,6 +1258,7 @@ describe('transitions annulées (§ 19.2)', () => {
} finally {
feuille.remove();
}
expect(atteintes, 'écrans atteints').toBeGreaterThan(0);
expect(atteintes + fautes.length).toBe(ECRANS.length);
aucuneFaute(fautes, 'transitions annulées');
}, 900_000);

View file

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