gestion_table_tournante_libre/src/application/avertissements.js
Mathieu Benoit a2812cddb9 [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
2026-10-07 15:05:01 -04:00

163 lines
8.7 KiB
JavaScript

// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les avertissements qu'un geste fait naître (§ 5.9, § 9, § 14.12), calculés
// de la charge d'avant à celle d'après, et les violations du retenu que la
// séance montre tant que la charge les porte. Un avertissement n'empêche
// rien : il dit ce que le geste a changé, avec le nombre.
//
// Aucun calcul ne lève sur un placement. Les raisons de dérive se lisent sur
// examiner (S2), qui les rend pour tout placement cohérent, dérivé compris ;
// le retenu ne se juge contre l'instance par verifierInvariants que quand
// examiner ne lui trouve aucune faute, et que la configuration se normalise.
// Ni indexerPlan ni derive n'entrent ici : le premier lève sur un plan qui ne
// décrit plus la configuration, le second sur un placement fautif.
import { ErreurConfiguration } from '../moteur/erreurs.js';
import { nombrePlacesManquantes, normaliser } from '../moteur/configuration.js';
import { 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 });
// L'instance de la charge, ou l'ErreurConfiguration qui la refuse :
// { instance } ou { refus }. Une autre erreur remonte.
function instanceDe(charge) {
try {
return { instance: normaliser(configurationDepuisCharge(charge)), refus: null };
} catch (erreur) {
if (!(erreur instanceof ErreurConfiguration)) throw erreur;
return { instance: null, refus: erreur };
}
}
// Deux objets plats égaux champ pour champ : mêmes clés, mêmes valeurs.
function memesChamps(a, b) {
const cles = Object.keys(a);
return cles.length === Object.keys(b).length && cles.every((cle) => Object.hasOwn(b, cle) && Object.is(a[cle], b[cle]));
}
// Les raisons de dérive de chaque placement d'un examen : les propositions
// par identifiant croissant, puis le retenu sous la clé 'retenu'.
function raisonsParPlacement(examen) {
const raisons = new Map(examen.derives.map(({ id, raisons: liste }) => [id, liste]));
raisons.set('retenu', examen.retenu.raisons);
return raisons;
}
// Les violations du retenu cohérent d'une charge, contre son instance, la
// réserve admise : des surnuméraires versés en réserve ne violent rien.
// [] sans retenu, pour un retenu fautif, ou sans instance.
function violationsDuRetenu(charge, examen, instance) {
if (charge.retenu === null || examen.retenu.fautes.length > 0 || instance === null) return [];
return verifierInvariants(instance, planDepuisFichier(charge.retenu), { reserveAutorisee: true });
}
const memeRaison = (code, cle, valeur) => (raison) => raison.code === code && raison[cle] === valeur;
// La raison de dérive qui nomme déjà la violation, ou undefined : la
// personne exclue, supprimée ou non placée ; la table supprimée, ajoutée, ou
// dont la capacité a changé ; le nombre de tours changé.
function raisonQuiLaNomme(violation, raisons) {
const { code, participant, table, detail } = violation;
if (code === 'EXCLU_PLACE') return raisons.find(memeRaison('PARTICIPANT_EXCLU', 'participant', participant));
if (code === 'INCONNU') return raisons.find(memeRaison('PARTICIPANT_SUPPRIME', 'participant', participant));
if (code === 'NON_ASSIS') return raisons.find(memeRaison('PARTICIPANT_NON_PLACE', 'participant', participant));
if (code === 'CAPACITE') return raisons.find(memeRaison('CAPACITE_CHANGEE', 'table', table));
if (code === 'TOURS') return raisons.find((raison) => raison.code === 'TOURS_CHANGES');
if (code === 'TABLE_INCONNUE' && detail === 'inconnue') return raisons.find(memeRaison('TABLE_SUPPRIMEE', 'table', table));
if (code === 'TABLE_INCONNUE' && detail === 'absente') return raisons.find(memeRaison('TABLE_AJOUTEE', 'table', table));
return undefined;
}
/**
* Les violations du retenu de la charge que la dérive ne nomme pas déjà
* (§ 14.12, § 14.15) : INVARIANT_VIOLE {proposition, violations}, la
* proposition d'origine du retenu et les violations de verifierInvariants
* dans leur ordre, réserve admise ; null quand il n'en reste aucune, sans
* retenu, pour un retenu fautif, ou sur une configuration qui ne se
* normalise pas. Une réservation que le retenu ne tient pas en est le cas
* ordinaire : la dérive ne compare pas les réservations.
*
* @param {import('../stockage/types.js').Charge} charge
* @param {import('../stockage/placements.js').Examen} [examen] celui de la
* charge, examiner(charge) quand il manque
* @returns {{code: 'INVARIANT_VIOLE', details: {proposition: number, violations: object[]}}|null}
*/
export function invariantViole(charge, examen = examiner(charge)) {
const { instance } = instanceDe(charge);
const raisons = examen.retenu.raisons;
const violations = violationsDuRetenu(charge, examen, instance).filter(
(violation) => raisonQuiLaNomme(violation, raisons) === undefined,
);
if (violations.length === 0) return null;
return avertissement('INVARIANT_VIOLE', { proposition: charge.retenu.proposition, violations });
}
/**
* Avertissements d'un geste, de la charge d'avant à celle d'après (§ 5.9,
* § 9), dans cet ordre. Aucun ne lève sur un placement : les raisons se
* lisent sur examiner(avant) et examiner(apres) (S2), et planDepuisFichier
* ne reçoit le retenu que quand examiner(…).retenu.fautes est vide.
*
* CONFIGURATION_INCOHERENTE {code, details} normaliser(configurationDepuisCharge(apres))
* lève, et celle d'avant se normalisait ; PLACES_MANQUANTES et
* RESERVATION_NON_HONOREE, qui lisent l'instance d'après, ne se calculent
* alors pas ;
* PLACES_MANQUANTES {placesManquantes} nombrePlacesManquantes de l'instance
* d'après, non nul et plus grand que celui d'avant (0 quand avant ne se
* normalisait pas) ;
* DERIVE_NOUVELLE {touchees: [{id|'retenu', raisons}]} par placement, les
* raisons d'examiner(apres) — derives, retenu.raisons — qu'examiner(avant)
* ne portait pas, comparées champ pour champ ; les propositions par
* identifiant, puis le retenu, sous id 'retenu' ;
* RESERVATION_NON_HONOREE {participant, table, tour, personne, numero}
* chaque violation RESERVATION de verifierInvariants(instance d'après,
* planDepuisFichier(apres.retenu)) absente du même calcul sur avant, qui
* n'en compte aucune quand son retenu est nul ou fautif, ou que sa
* configuration ne se normalisait pas ; personne est le nom affiché du
* participant, numero celui de la table, lus sur la charge d'après, qui
* porte l'une et l'autre puisque l'instance qui les viole en vient.
*
* @param {import('../stockage/types.js').Charge} avant
* @param {import('../stockage/types.js').Charge} apres
* @returns {Array<{code: string, details: Object}>}
*/
export function avertissementsApres(avant, apres) {
const rendus = [];
const examenAvant = examiner(avant);
const examenApres = examiner(apres);
const instanceAvant = instanceDe(avant).instance;
const { instance, refus } = instanceDe(apres);
if (refus !== null && instanceAvant !== null) {
rendus.push(avertissement('CONFIGURATION_INCOHERENTE', { code: refus.code, details: refus.details }));
}
if (instance !== null) {
const manque = nombrePlacesManquantes(instance);
const manqueAvant = instanceAvant === null ? 0 : nombrePlacesManquantes(instanceAvant);
if (manque > 0 && manque > manqueAvant) rendus.push(avertissement('PLACES_MANQUANTES', { placesManquantes: manque }));
}
const anciennes = raisonsParPlacement(examenAvant);
const touchees = [];
for (const [id, raisons] of raisonsParPlacement(examenApres)) {
const connues = anciennes.get(id) ?? [];
const nouvelles = raisons.filter((raison) => !connues.some((connue) => memesChamps(connue, raison)));
if (nouvelles.length > 0) touchees.push({ id, raisons: nouvelles });
}
if (touchees.length > 0) rendus.push(avertissement('DERIVE_NOUVELLE', { touchees }));
const reservations = (charge, examen, inst) =>
violationsDuRetenu(charge, examen, inst).filter(({ code }) => code === 'RESERVATION');
const tenues = reservations(avant, examenAvant, instanceAvant);
for (const { participant, table, tour } of reservations(apres, examenApres, instance)) {
const dejaLa = tenues.some((connue) => connue.participant === participant && connue.table === table && connue.tour === tour);
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;
}