[ADD] home: retained plan's collisions and redundancy gap per event

The event list showed no outcome, so comparing events meant opening each.
Each line now shows, for the retained plan only, the cumulated collisions
(pair and tour) and the redundancy gap |target - sum of r(p)|, with an
explanation tooltip; an event without a measurable retained plan shows a
dash. The target is set per event in Propositions, 1000 by default: an
optional settings field, absent in every existing file and read as 1000,
written through one undoable command refused in reading mode. Listing
stays read-only. Checked: 2909 node, 604 browser, 62 long tests.

--- FR ---

[ADD] accueil : collisions et Diff de redondance du retenu par événement

La liste des événements ne montrait aucun résultat : comparer obligeait à
ouvrir chacun. Chaque ligne montre, pour le retenu seul, les collisions
cumulées (paire et tour) et la Diff somme d'appartenance, |cible - somme
de r(p)|, avec une infobulle ; sans retenu mesurable, un tiret. La cible
se règle par événement dans Propositions, 1000 par défaut : champ
facultatif des réglages, absent de tout fichier existant et lu 1000, écrit
par une commande qui se défait et se refuse en lecture. La liste reste en
lecture seule. Vérifié : 2909 node, 604 navigateur, 62 longues.

Assisted-by: Claude Opus 5.5
This commit is contained in:
Mathieu Benoit 2026-10-08 05:35:48 -04:00
parent 654cde4f10
commit 5fc5ac9205
27 changed files with 922 additions and 59 deletions

28
spec.md
View file

@ -173,7 +173,9 @@ quoi que ce soit.* L'opérateur lit la page de qualité (§ 12), compare deux
propositions sur le même axe — celui du **manque** (§ 12.10) —, retient celle
qu'il préfère. Le logiciel sépare ce que le moteur a décidé de ce que les
réservations imposent (§ 5.4). Retenir fait passer le plan à l'état *Retenu*
(§ 9) ; les propositions non retenues restent disponibles.
(§ 9) ; les propositions non retenues restent disponibles. Le retenu se résume
ensuite dans la liste des événements, par ses collisions cumulées et la
différence absolue de sa somme de redondance à la cible de l'événement (§ 8.1).
**11. Ajuster à la main.** *Facultative, reprenable.* Le même geste qu'à
l'étape 7 déplace maintenant une personne réelle dans le placement retenu. **Le
@ -1201,6 +1203,15 @@ qui est dedans, sans son complément, laisse l'implémenteur ajouter.
Le logiciel ouvre sur une **liste des événements**, avec pour chacun le nom, la
date, l'état, le nombre de participants, la dernière modification et le chemin.
Deux colonnes résument en plus le **placement retenu** : « Collisions cumulées
(paire et tour) », les collisions cumulées du retenu (§ 5.4), et « Diff somme
d'appartenance », |cible − Σ r(p)|, où Σ r(p) est la somme de redondance du
retenu — le nombre de la colonne « Redondance d'appartenance, somme Σ r(p) » du
tableau des propositions — et la cible celle de l'événement (§ 8.9), 1000 par
défaut, réglable dans la section des propositions. Les deux se lisent dans la
même mesure que le tableau, au moment de lister, sans rien écrire ; un événement
sans retenu, ou dont le retenu est fautif ou en dérive (§ 8.9, § 9), y porte
« — ». Chaque intitulé s'explique dans une infobulle.
**Un répertoire vide n'affiche pas une liste vide** : il présente la commande de
création et les démonstrations — une liste vide sans issue est le premier écran
que voit un opérateur qui lance l'exécutable.
@ -1640,6 +1651,21 @@ retrouve-t-il ?*
| participants, tables, tours, réservations, titres de place, filiation | **champs nommés**, un objet par chose | c'est de la saisie d'opérateur : perdue, elle est perdue, et elle doit se lire à l'œil quand un fichier s'abîme. Elle est petite — de l'ordre de 40 ko pour 260 personnes avec courriels |
| placements engendrés | **forme positionnelle** | c'est la partie volumineuse, et la seule qui se **recalcule** : une proposition perdue coûte une relance |
#### Un réglage facultatif : la cible de la somme de redondance
Les réglages de l'événement portent un champ **facultatif**,
`cibleRedondance`, un entier ≥ 0 : la cible à laquelle la liste des événements
compare la somme de redondance Σ r(p) du retenu (§ 8.1). **Absent, il vaut
1000.** Le format reste le format 1 : un fichier qui ne le porte pas — chaque
fichier écrit avant lui, chaque démonstration livrée — se lit tel quel, à la
cible 1000, et s'écrit octet pour octet comme avant. Une valeur négative, non
entière, hors des entiers exacts ou d'une autre sorte est une faute de forme du
fichier (§ 8.8). La commande « Cible de la somme de redondance », un champ de la
section des propositions, le pose — une entrée d'historique, refusée en lecture
et sur un plan bloqué — et le retire quand la cible revient à 1000 : deux
fichiers de même cible s'écrivent pareil. Un correctif du journal (§ 8.6) le
pose quand il apparaît et le retire quand il disparaît.
#### La forme positionnelle
Une proposition porte un **identifiant entier séquentiel**, et les propositions

View file

@ -642,6 +642,7 @@ describe('commandes : la charge reçue ne change jamais (§ 13.2)', () => {
['changerDefaut', { sieges: 3, realigner: false }],
['changerTours', { tours: 3 }],
['modifierReglage', { cle: 'nouveauxVoisins', valeur: false }],
['changerCibleRedondance', { cible: '1200' }],
]);
}

View file

@ -1,8 +1,9 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le registre des commandes du mobilier — tables, sièges, tours, réglages
// (§ 4, § 6, § 7.1, § 8.2, § 9), que commandes.js réunit aux autres. Ses
// Le registre des commandes du mobilier — tables, sièges, tours, réglages,
// cible de la somme de redondance (§ 4, § 6, § 7.1, § 8.2, § 8.9, § 9), que
// commandes.js réunit aux autres. Ses
// imports n'atteignent jamais commandes.js : le registre réuni dépend de lui,
// non l'inverse.
//
@ -22,7 +23,7 @@
// proposition ne se réécrit jamais : elle dérive (§ 8.9, § 9). Supprimer une
// table et changer les tours laissent propositions et retenu en dérive.
import { caseLibre } from '../geometrie/tables.js';
import { SCHEMA, TOURS_MAX, capacite } from '../stockage/document.js';
import { CIBLE_REDONDANCE_PAR_DEFAUT, SCHEMA, TOURS_MAX, capacite } from '../stockage/document.js';
import { fautes } from '../stockage/placements.js';
import { bilanChangementDefaut } from './bilans.js';
import { controlerCapacite, lireEntier, retenuAuxCapacites } from './capacite.js';
@ -257,6 +258,24 @@ function modifierReglage(charge, { cle, valeur } = {}) {
return { charge: suivante, libelle: libelle('ENTREE_MODIFIER_REGLAGE', { cle, valeur }), avertissements: [] };
}
/**
* changerCibleRedondance {cible} (§ 5.4, § 8.1, § 8.9) : la cible de la
* somme de redondance de l'événement, que la liste des événements compare à
* Σ r(p) du retenu ; une saisie lue par lireEntier, un entier ≥ 0, NON_ENTIER
* sinon — un signe moins compris —, remède null. La valeur par défaut,
* CIBLE_REDONDANCE_PAR_DEFAUT, retire le champ des réglages : la charge
* s'écrit alors comme une charge qui ne l'a jamais porté, et la même valeur
* que la cible courante est sans effet.
*/
function changerCibleRedondance(charge, { cible: saisie } = {}) {
exigerModifiable(charge);
const cible = entierSaisi(saisie);
const suivante = structuredClone(charge);
if (cible === CIBLE_REDONDANCE_PAR_DEFAUT) delete suivante.reglages.cibleRedondance;
else suivante.reglages.cibleRedondance = cible;
return { charge: suivante, libelle: libelle('ENTREE_CHANGER_CIBLE_REDONDANCE', { cible }), avertissements: [] };
}
/** @type {Map<string, (charge: Object, args: Object) => Object>} */
export const COMMANDES_MOBILIER = new Map([
['creerTable', creerTable],
@ -267,4 +286,5 @@ export const COMMANDES_MOBILIER = new Map([
['changerDefaut', changerDefaut],
['changerTours', changerTours],
['modifierReglage', modifierReglage],
['changerCibleRedondance', changerCibleRedondance],
]);

View file

@ -19,7 +19,15 @@ import { STATUT, nombrePlacesManquantes, normaliser } from '../moteur/configurat
import { ErreurConfiguration } from '../moteur/erreurs.js';
import { serialiser } from '../stockage/canonique.js';
import { creer as creerDansLeDepot } from '../stockage/depot.js';
import { SIEGES_MAX, TOURS_MAX, analyser, capacite, configurationDepuisCharge, creerCharge } from '../stockage/document.js';
import {
SIEGES_MAX,
TOURS_MAX,
analyser,
capacite,
cibleRedondance,
configurationDepuisCharge,
creerCharge,
} from '../stockage/document.js';
import { lireJournal } from '../stockage/journal.js';
import { SUFFIXES } from '../stockage/noms.js';
import { derive, examiner, fautes } from '../stockage/placements.js';
@ -237,8 +245,18 @@ async function seanceSur(charge) {
// --- Les épreuves -----------------------------------------------------------
describe('mobilier : le registre (§ 8.2)', () => {
test('COMMANDES_MOBILIER porte les huit commandes du mobilier, réunies dans COMMANDES', () => {
const noms = ['creerTable', 'supprimerTable', 'deplacerTable', 'changerPlacesTable', 'realignerTable', 'changerDefaut', 'changerTours', 'modifierReglage'];
test('COMMANDES_MOBILIER porte les neuf commandes du mobilier, réunies dans COMMANDES', () => {
const noms = [
'creerTable',
'supprimerTable',
'deplacerTable',
'changerPlacesTable',
'realignerTable',
'changerDefaut',
'changerTours',
'modifierReglage',
'changerCibleRedondance',
];
assert.deepEqual([...COMMANDES_MOBILIER.keys()], noms);
for (const nom of noms) assert.equal(COMMANDES.get(nom), COMMANDES_MOBILIER.get(nom), nom);
});
@ -255,6 +273,7 @@ describe('mobilier : le registre (§ 8.2)', () => {
['changerDefaut', { sieges: 6, realigner: false }],
['changerTours', { tours: 3 }],
['modifierReglage', { cle: 'nouveauxVoisins', valeur: false }],
['changerCibleRedondance', { cible: '1200' }],
];
assert.equal(gestes.length, COMMANDES_MOBILIER.size);
for (const [nom, args] of gestes) {
@ -1004,3 +1023,46 @@ describe('mobilier : les réglages (§ 5.3, § 15.4)', () => {
refuse(() => appliquerCommande('modifierReglage', charge, { cle: 'nouveauxVoisins', valeur: true }), 'SANS_EFFET');
});
});
describe('mobilier : la cible de la somme de redondance (§ 5.4, § 8.1, § 8.9)', () => {
test('« Cible de la somme de redondance : 1200 », une entrée ; défaire rend la charge sans le champ', async () => {
const { seance, entrees } = await seanceSur(chargeDe({ tables: [{ id: 1 }], n: 2 }));
const avant = entrees();
assert.equal(Object.hasOwn(seance.charge.reglages, 'cibleRedondance'), false);
const rendu = await seance.executer('changerCibleRedondance', { cible: '1200' });
assert.equal(rendu.libelle, 'Cible de la somme de redondance : 1200');
assert.equal(entrees() - avant, 1);
assert.equal(seance.charge.reglages.cibleRedondance, 1200);
await seance.defaire();
assert.equal(Object.hasOwn(seance.charge.reglages, 'cibleRedondance'), false);
assert.equal(cibleRedondance(seance.charge), 1000);
});
test('la saisie se lit par lireEntier, 0 compris ; revenir à 1000 retire le champ, et la charge s’écrit comme sans lui', () => {
const charge = chargeDe({ tables: [{ id: 1 }], n: 2 });
const zero = accepter('changerCibleRedondance', charge, { cible: ' 0 ' });
assert.equal(zero.charge.reglages.cibleRedondance, 0);
assert.equal(zero.libelle, 'Cible de la somme de redondance : 0');
const nombre = accepter('changerCibleRedondance', charge, { cible: 750 });
assert.equal(nombre.charge.reglages.cibleRedondance, 750);
const retour = accepter('changerCibleRedondance', nombre.charge, { cible: '1000' });
assert.equal(Object.hasOwn(retour.charge.reglages, 'cibleRedondance'), false);
assert.equal(retour.libelle, 'Cible de la somme de redondance : 1000');
assert.equal(serialiser(retour.charge, { revision: 1, produitVersion: V1 }), serialiser(charge, { revision: 1, produitVersion: V1 }));
});
test('une saisie négative, décimale ou vide est refusée NON_ENTIER {saisie}, remède null ; la même valeur, SANS_EFFET', () => {
const charge = chargeDe({ tables: [{ id: 1 }], n: 2 });
const saisies = ['-5', '2,5', '', 'mille'];
assert.ok(saisies.length > 0);
for (const saisie of saisies) {
refuse(() => COMMANDES.get('changerCibleRedondance')(charge, { cible: saisie }), 'NON_ENTIER', { saisie, remede: null });
}
refuse(() => appliquerCommande('changerCibleRedondance', charge, { cible: '1000' }), 'SANS_EFFET', {
commande: 'changerCibleRedondance',
});
const posee = accepter('changerCibleRedondance', charge, { cible: '640' }).charge;
refuse(() => appliquerCommande('changerCibleRedondance', posee, { cible: '640' }), 'SANS_EFFET');
assert.throws(() => COMMANDES.get('changerCibleRedondance')(charge, { cible: null }), TypeError);
});
});

View file

@ -17,6 +17,10 @@
// Les imposés viennent du diagnostic, les mêmes pour toute ligne ; les
// rencontres répétées imposées dépendent du plan, et chaque ligne les lit
// dans ses mesures, à part des choisies.
//
// resumeDuRetenu donne à la liste des événements deux nombres du retenu,
// lus dans la même mesure que sa ligne du tableau, sans le diagnostic ni
// les plafonds : la liste en résume chaque événement du dossier.
import { classer } from '../moteur/classement.js';
import { indexerPlan, normaliser } from '../moteur/configuration.js';
import { diagnostiquer } from '../moteur/diagnostic.js';
@ -24,9 +28,12 @@ import { mesurer } from '../moteur/indicateurs.js';
import { ecartsAuPlafondAPriori, minimumAtteint, troisChiffres } from '../moteur/manque.js';
import { plafondsRealises } from '../moteur/plafond.js';
import { verifierIndicateurs, verifierInvariants } from '../moteur/verification.js';
import { configurationDepuisCharge } from '../stockage/document.js';
import { ErreurConfiguration } from '../moteur/erreurs.js';
import { CIBLE_REDONDANCE_PAR_DEFAUT, cibleRedondance, configurationDepuisCharge } from '../stockage/document.js';
import { derive, fautes, planDepuisFichier } from '../stockage/placements.js';
export { CIBLE_REDONDANCE_PAR_DEFAUT, cibleRedondance };
/**
* @typedef {Object} LigneEvaluation
* @property {number} id celui de la proposition ; retenu.proposition pour le retenu
@ -62,6 +69,11 @@ function plusGrand(valeurs) {
return plus;
}
// Vrai quand le placement se compare à la configuration courante : sans
// faute, puis sans dérive — derive lève sur une faute, et ne la reçoit
// jamais.
const seCompare = (placement, charge) => fautes(placement).length === 0 && derive(placement, charge).length === 0;
// Une ligne qui ne se mesure pas.
const nonMesuree = (id, statut, raisons) => ({
id,
@ -138,3 +150,31 @@ export function evaluer(charge, { plafondsAPriori = 'calcul' } = {}) {
imposes: { retours: diagnostic.retoursImposes, ancrages: diagnostic.ancragesImposantRetours },
};
}
/**
* Le résumé du retenu que montre la liste des événements (§ 5.4, § 8.1,
* § 8.9) : ses collisions cumulées et sa somme de redondance Σ r(p), celles
* de mesurer, que la ligne du retenu d'evaluer porte aussi ; la cible de
* l'événement (cibleRedondance) ; et diffRedondance, la valeur absolue de la
* cible moins Σ r(p). null sans retenu, pour un retenu fautif ou en dérive,
* et quand la configuration ne se normalise pas : rien ne s'y mesure, et
* la liste écrit « — ». Ne modifie pas la charge.
*
* @param {import('../stockage/types.js').Charge} charge
* @returns {{collisionsCumulees: number, totalRedondance: number,
* cibleRedondance: number, diffRedondance: number}|null}
*/
export function resumeDuRetenu(charge) {
const { retenu } = charge;
if (retenu === null || !seCompare(retenu, charge)) return null;
let instance;
try {
instance = normaliser(configurationDepuisCharge(charge));
} catch (erreur) {
if (erreur instanceof ErreurConfiguration) return null;
throw erreur;
}
const { collisionsCumulees, totalRedondance } = mesurer(instance, indexerPlan(instance, planDepuisFichier(retenu)));
const cible = cibleRedondance(charge);
return { collisionsCumulees, totalRedondance, cibleRedondance: cible, diffRedondance: Math.abs(cible - totalRedondance) };
}

View file

@ -23,7 +23,7 @@ import { analyser, capacite, configurationDepuisCharge } from '../stockage/docum
import { planDepuisFichier } from '../stockage/placements.js';
import { VERSION } from '../version.genere.js';
import { appliquerCommande } from './commandes.js';
import { evaluer } from './evaluation.js';
import { CIBLE_REDONDANCE_PAR_DEFAUT, cibleRedondance, evaluer, resumeDuRetenu } from './evaluation.js';
const V1 = VERSION.affichee;
@ -359,3 +359,68 @@ describe("évaluation : une configuration qui ne se tient pas", () => {
assert.throws(() => evaluer(figer(charge)), (erreur) => erreur instanceof ErreurConfiguration && erreur.code === 'SURRESERVATION');
});
});
describe('resumeDuRetenu : les mesures du retenu que lit la liste des événements (§ 5.4, § 8.1, § 8.9)', () => {
// La petite démonstration, trois propositions générées, la deuxième retenue.
function retenue() {
const generees = generee(chargeDemo('petite-conflit'), { graine: 3, nombre: 3, arret: 300 });
return executer(generees, 'retenirProposition', { id: generees.propositions[1].id });
}
test("les collisions cumulées et Σ r(p) du retenu, celles de la ligne du retenu d'evaluer, et la cible, 1000 par défaut", () => {
const charge = figer(retenue());
const resume = resumeDuRetenu(charge);
const { mesures } = evaluer(charge).retenu;
assert.ok(mesures.collisionsCumulees > 0, 'la petite démonstration en conflit compte des collisions');
assert.deepEqual(resume, {
collisionsCumulees: mesures.collisionsCumulees,
totalRedondance: mesures.totalRedondance,
cibleRedondance: 1000,
diffRedondance: Math.abs(1000 - mesures.totalRedondance),
});
const instance = normaliser(configurationDepuisCharge(charge));
const attendues = mesurer(instance, indexerPlan(instance, planDepuisFichier(charge.retenu)));
assert.equal(resume.totalRedondance, attendues.totalRedondance);
assert.equal(resume.collisionsCumulees, attendues.collisionsCumulees);
assert.equal(CIBLE_REDONDANCE_PAR_DEFAUT, 1000);
assert.equal(cibleRedondance(charge), 1000);
});
test("une cible propre à l'événement : la différence se lit contre elle, en valeur absolue, des deux côtés", () => {
const charge = retenue();
const total = resumeDuRetenu(charge).totalRedondance;
for (const cible of [0, total, total + 7, 5_000]) {
const avecCible = executer(charge, 'changerCibleRedondance', { cible: String(cible) });
assert.equal(cibleRedondance(avecCible), cible);
const resume = resumeDuRetenu(avecCible);
assert.equal(resume.cibleRedondance, cible);
assert.equal(resume.diffRedondance, Math.abs(cible - total), String(cible));
}
const dessous = executer(charge, 'changerCibleRedondance', { cible: '0' });
assert.equal(resumeDuRetenu(dessous).diffRedondance, total);
});
test('sans retenu, null ; un retenu fautif ou en dérive, null, sans lever', () => {
const sansRetenu = generee(chargeDemo('petite-conflit'), { graine: 3, nombre: 1, arret: 200 });
assert.equal(sansRetenu.retenu, null);
assert.equal(resumeDuRetenu(sansRetenu), null);
assert.equal(resumeDuRetenu(chargeDemo('petite')), null);
const fautif = structuredClone(retenue());
fautif.retenu.placement[0].reserve.push(fautif.retenu.participants[0]);
assert.equal(evaluer(fautif).retenu.statut, 'fautif');
assert.equal(resumeDuRetenu(fautif), null);
const enDerive = executer(retenue(), 'changerTours', { tours: '3' });
assert.equal(evaluer(enDerive).retenu.statut, 'derive');
assert.equal(resumeDuRetenu(enDerive), null);
});
test('une configuration qui ne se normalise pas rend null là où evaluer lève', () => {
const charge = structuredClone(retenue());
const table = charge.tables[0].id;
charge.reservations = [1, 2, 3, 4].map((participant) => ({ participant, table, siege: null, portee: 'tous', tour: null }));
assert.throws(() => evaluer(charge), (erreur) => erreur instanceof ErreurConfiguration && erreur.code === 'SURRESERVATION');
assert.equal(resumeDuRetenu(charge), null);
});
});

View file

@ -14,7 +14,9 @@
// système de fichiers se lit sur fs à l'appel, jamais à la création : une
// épreuve la remplace après le montage.
//
// Lister ne fait que lire. Une opération qui écrit dérive ses refus lisibles
// Lister ne fait que lire. Chaque ligne lisible porte le résumé de son
// retenu (resumeDuRetenu, evaluation.js), lu dans l'état de l'événement au
// moment de lister. Une opération qui écrit dérive ses refus lisibles
// sans écrire — nom vide, plan bloqué, format plus récent — avant toute
// écriture, sonde de verrou comprise. Renommer et supprimer ferment d'abord
// l'événement visé quand il est ouvert : la séance rend son verrou, et la
@ -28,6 +30,7 @@
// libelle, en français ; aucune autre chaîne visible n'en sort : refus, avis
// et questions sont des codes et leurs détails.
import { deverrouiller, lister as listerDepot, ouvrir as ouvrirDepot, verrouiller } from '../stockage/depot.js';
import { analyser } from '../stockage/document.js';
import { determinerDossierTravail } from '../stockage/dossier_travail.js';
import { ErreurStockage } from '../stockage/erreurs.js';
import { decoderJournal, lireJournal } from '../stockage/journal.js';
@ -44,6 +47,7 @@ import {
} from '../stockage/operations.js';
import { lireReglages } from '../stockage/reglages_locaux.js';
import { ErreurCommande, refus } from './erreurs.js';
import { resumeDuRetenu } from './evaluation.js';
import { libelle } from './libelles.js';
/** Sièges par défaut et tours d'un événement neuf (§ 2.1, étapes 4 et 5). */
@ -126,6 +130,22 @@ async function derniereModification(fs, racine, base) {
return lireJournal(texte).entrees.at(-1)?.horodatage ?? null;
}
// Le résumé du retenu de l'événement base (resumeDuRetenu), lu dans son
// état, sans rien écrire ; null pour un état illisible au moment de la
// liste — un résumé illisible ne se relit pas —, et pour un état qui a
// cessé de se lire depuis : la ligne en porte déjà la cause, ou la portera
// à la liste suivante.
async function mesuresDuRetenuDe(fs, racine, { base, illisible }) {
if (illisible !== null) return null;
try {
const texte = await fs.lireTexte(racine, base + SUFFIXES.etat);
return texte === null ? null : resumeDuRetenu(analyser(texte).charge);
} catch (erreur) {
if (erreur instanceof ErreurStockage) return null;
throw erreur;
}
}
// Pourquoi l'état d'un résumé illisible ne s'ouvre pas : les détails de
// l'ETAT_ILLISIBLE qu'ouvrir lève — raison, chemin, format quand il en nomme
// un, et les secours lisibles —, sans rien écrire. Un état redevenu lisible,
@ -283,6 +303,7 @@ export function creerFacade({
? resumes.filter((autre) => autre !== resume && autre.doublon && autre.id === id).map((autre) => autre.base)
: [],
verrou: await verrouDe(fs, courante, base),
mesuresDuRetenu: await mesuresDuRetenuDe(fs, courante, resume),
});
}
return lignes;
@ -461,7 +482,11 @@ export function creerFacade({
* @param {'reessayer'|'documents'|'continuer'} choix
*/
repondre,
/** Les lignes de la liste (§ 8.1), dans l'ordre du dépôt ; n'écrit rien. */
/**
* Les lignes de la liste (§ 8.1), dans l'ordre du dépôt ; n'écrit rien.
* mesuresDuRetenu porte resumeDuRetenu de l'état, null sans retenu
* mesurable ou pour un état illisible.
*/
lister,
/** Crée et ouvre en lecture l'événement { nom, date } (§ 2.1, étape 1). */
creer: creerEvenement,

View file

@ -13,13 +13,16 @@ import { creerFichiersSimules } from '../../test/fichiers_simules.js';
import { describe, test } from '../../test/lanceur.js';
import { CATALOGUE } from '../demo/catalogue.js';
import { LIVREES, texteLivre } from '../demo/livrees.js';
import { rechercher } from '../moteur/recherche.js';
import { serialiser } from '../stockage/canonique.js';
import { creer as creerDansLeDepot } from '../stockage/depot.js';
import { analyser, creerCharge } from '../stockage/document.js';
import { analyser, configurationDepuisCharge, creerCharge } from '../stockage/document.js';
import { ErreurStockage } from '../stockage/erreurs.js';
import { lireJournal } from '../stockage/journal.js';
import { SUFFIXES } from '../stockage/noms.js';
import { VERSION } from '../version.genere.js';
import { appliquerCommande } from './commandes.js';
import { evaluer, resumeDuRetenu } from './evaluation.js';
import { creerFacade } from './evenements.js';
import { creerHorlogePilotee } from './horloge.js';
import { libelle } from './libelles.js';
@ -428,6 +431,7 @@ describe('lister (§ 8.1, § 19.4)', () => {
journalSeul: false,
doublonAvec: [],
verrou: null,
mesuresDuRetenu: null,
},
{
base: 'petite',
@ -442,6 +446,7 @@ describe('lister (§ 8.1, § 19.4)', () => {
journalSeul: false,
doublonAvec: [],
verrou: null,
mesuresDuRetenu: null,
},
]);
assert.ok(petite.participants.length > 0);
@ -530,6 +535,72 @@ describe('lister (§ 8.1, § 19.4)', () => {
assert.equal(par['Merles A'].illisible, null);
assert.equal(par['Soirée vide'].doublonAvec.length, 0);
});
// La petite démonstration au conflit inévitable, deux propositions
// générées par le moteur et la seconde retenue, par les commandes ;
// modifier(charge) l'ajuste avant l'écriture.
function texteRetenu(modifier = (charge) => charge) {
let charge = analyser(texteLivre('petite-conflit')).charge;
const configuration = configurationDepuisCharge(charge);
const propositions = rechercher(configuration, { graine: 4, nombre: 2, arret: 300 });
charge = appliquerCommande('enregistrerGeneration', charge, { propositions, configuration, produitVersion: V1 }).charge;
charge = appliquerCommande('retenirProposition', charge, { id: charge.propositions[1].id }).charge;
return serialiser(modifier(charge), { revision: 3, produitVersion: V1 });
}
test('les mesures du retenu : collisions cumulées, Σ r(p), la cible et la différence, celles de resumeDuRetenu ; null sans retenu ; rien d’écrit', async () => {
const { fs, facade } = await deuxEvenements();
fs.deposer('portable', `retenue${SUFFIXES.etat}`, texteRetenu());
const avant = ecritures(fs);
const lignes = await facade.lister();
assert.equal(ecritures(fs), avant);
const par = Object.fromEntries(lignes.map((ligne) => [ligne.base, ligne]));
const charge = analyser(texteRetenu()).charge;
const { mesures } = evaluer(charge).retenu;
assert.deepEqual(par.retenue.mesuresDuRetenu, {
collisionsCumulees: mesures.collisionsCumulees,
totalRedondance: mesures.totalRedondance,
cibleRedondance: 1000,
diffRedondance: Math.abs(1000 - mesures.totalRedondance),
});
assert.deepEqual(par.retenue.mesuresDuRetenu, resumeDuRetenu(charge));
assert.equal(par['soiree-a'].mesuresDuRetenu, null);
assert.equal(par.petite.mesuresDuRetenu, null);
});
test("une cible propre à l'événement : la différence se lit contre elle", async () => {
const { fs, facade } = await deuxEvenements();
const texte = texteRetenu((charge) => appliquerCommande('changerCibleRedondance', charge, { cible: '25' }).charge);
fs.deposer('portable', `retenue${SUFFIXES.etat}`, texte);
const ligne = (await facade.lister()).find(({ base }) => base === 'retenue');
const { totalRedondance } = evaluer(analyser(texte).charge).retenu.mesures;
assert.equal(ligne.mesuresDuRetenu.cibleRedondance, 25);
assert.equal(ligne.mesuresDuRetenu.diffRedondance, Math.abs(25 - totalRedondance));
});
test('un retenu fautif, ou en dérive, et un état illisible : null, la liste entière rendue, rien d’écrit', async () => {
const { fs, facade } = await deuxEvenements();
fs.deposer(
'portable',
`fautive${SUFFIXES.etat}`,
texteRetenu((charge) => {
charge.retenu.placement[0].reserve.push(charge.retenu.participants[0]);
return charge;
}),
);
fs.deposer('portable', `derivee${SUFFIXES.etat}`, texteRetenu((charge) => appliquerCommande('changerTours', charge, { tours: '3' }).charge));
fs.deposer('portable', `tronquee${SUFFIXES.etat}`, texteRetenu().slice(0, 40));
const avant = ecritures(fs);
const lignes = await facade.lister();
assert.equal(ecritures(fs), avant);
assert.equal(lignes.length, 5);
const par = Object.fromEntries(lignes.map((ligne) => [ligne.base, ligne]));
assert.equal(par.fautive.illisible, null);
assert.equal(par.fautive.mesuresDuRetenu, null);
assert.equal(par.derivee.mesuresDuRetenu, null);
assert.notEqual(par.tronquee.illisible, null);
assert.equal(par.tronquee.mesuresDuRetenu, null);
});
});
// --- Créer (§ 2.1, § 8.3) ----------------------------------------------------

View file

@ -538,6 +538,7 @@ const EXEMPLES = new Map([
['ENTREE_CHANGER_DEFAUT', { sieges: 10, realignees: 4 }],
['ENTREE_CHANGER_TOURS', { tours: 3, retirees: 2 }],
['ENTREE_MODIFIER_REGLAGE', { cle: 'separerAppartenances', valeur: false }],
['ENTREE_CHANGER_CIBLE_REDONDANCE', { cible: 1200 }],
]);
// Un texte composé ne laisse paraître ni valeur absente, ni booléen, ni

View file

@ -113,4 +113,5 @@ export const TABLE = new Map([
'ENTREE_MODIFIER_REGLAGE',
({ cle, valeur }) => `${nomDans(NOMS_DES_REGLAGES, cle, 'réglage')} : ${valeurDeReglage(valeur)}`,
],
['ENTREE_CHANGER_CIBLE_REDONDANCE', ({ cible }) => `Cible de la somme de redondance : ${cible}`],
]);

View file

@ -336,6 +336,14 @@ const EXEMPLES = new Map([
sansEffet: { args: { cle: 'attribuerSieges', valeur: false } },
},
],
[
'changerCibleRedondance',
{
accepte: { args: { cible: '1200' } },
refuse: { args: { cible: '-1' }, code: 'NON_ENTIER', remede: null },
sansEffet: { args: { cible: '1000' } },
},
],
[
'reserver',
{

View file

@ -21,20 +21,27 @@
// (voir src/interface/App.navigateur.test.js).
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { flushSync, tick } from 'svelte';
import { appliquerCommande } from '../../application/commandes.js';
import { creerFormat } from '../../application/format.js';
import { creerHorlogePilotee } from '../../application/horloge.js';
import { TABLES_APPLICATION, libelle } from '../../application/libelles.js';
import { creerTraduction } from '../../application/traduction.js';
import { CATALOGUE } from '../../demo/catalogue.js';
import { LIVREES } from '../../demo/livrees.js';
import { LIVREES, texteLivre } from '../../demo/livrees.js';
import { indexerPlan, normaliser } from '../../moteur/configuration.js';
import { mesurer } from '../../moteur/indicateurs.js';
import { rechercher } from '../../moteur/recherche.js';
import { serialiser } from '../../stockage/canonique.js';
import { creer as creerDansLeDepot, ouvrir as ouvrirDansLeDepot } from '../../stockage/depot.js';
import { analyser, creerCharge } from '../../stockage/document.js';
import { analyser, configurationDepuisCharge, creerCharge } from '../../stockage/document.js';
import { lireJournal } from '../../stockage/journal.js';
import { SUFFIXES } from '../../stockage/noms.js';
import { libelleDeLInstant } from '../../stockage/operations.js';
import { planDepuisFichier } from '../../stockage/placements.js';
import { VERSION } from '../../version.genere.js';
import { creerFichiersSimules } from '../../../test/fichiers_simules.js';
import { centre } from '../../../test/navigateur/ecrans.js';
import { pointer, touche } from '../../../test/navigateur/entrees.js';
import { monterApp } from '../../../test/navigateur/montage.js';
import { visible as visibleDansLaFenetre } from '../../../test/navigateur/rendu.js';
import { TABLES_INTERFACE } from '../libelles.js';
@ -1077,3 +1084,116 @@ describe('la racine neutre et la liste qui défile (§ 18.6, § 19.4)', () => {
expect(bandeau.contains(touche)).toBe(true);
});
});
// --- Les mesures du retenu (§ 5.4, § 8.1, § 8.9) ------------------------------------
describe('les mesures du retenu dans la liste (§ 5.4, § 8.1, § 8.9)', () => {
// La petite démonstration au conflit inévitable, deux propositions du
// moteur, la seconde retenue par la commande.
function chargeRetenue() {
let charge = analyser(texteLivre('petite-conflit')).charge;
const configuration = configurationDepuisCharge(charge);
const propositions = rechercher(configuration, { graine: 6, nombre: 2, arret: 300 });
charge = appliquerCommande('enregistrerGeneration', charge, { propositions, configuration, produitVersion: VERSION.affichee }).charge;
return appliquerCommande('retenirProposition', charge, { id: charge.propositions[1].id }).charge;
}
// Les mesures du retenu, par le moteur sollicité une seconde fois (§ 19.1).
function mesuresDe(charge) {
const instance = normaliser(configurationDepuisCharge(charge));
return mesurer(instance, indexerPlan(instance, planDepuisFichier(charge.retenu)));
}
const etat = (charge) => serialiser(charge, { revision: 1, produitVersion: VERSION.affichee });
const TIRET = '\u{2014}';
const terme = (ligne, colonne) => ligne.querySelector(`[data-terme="${colonne}"]`);
const infobulles = () => [...document.querySelectorAll('[role="tooltip"]')];
// L'infobulle unique, ouverte sur le terme : visible, au texte de la clé,
// désignée par l'aria-describedby du terme et de nul autre.
function exigerInfobulle(ancre, cle) {
const bulles = infobulles();
expect(bulles.length).toBe(1);
const [bulle] = bulles;
expect(bulle.hidden, cle).toBe(false);
expect(getComputedStyle(bulle).visibility, cle).toBe('visible');
expect(bulle.textContent.trim(), cle).toBe(t(cle));
expect(ancre.getAttribute('aria-describedby'), cle).toBe(bulle.id);
expect(document.querySelectorAll(`[aria-describedby~="${bulle.id}"]`).length, cle).toBe(1);
const b = bulle.getBoundingClientRect();
const a = ancre.getBoundingClientRect();
expect(b.width).toBeGreaterThan(0);
expect(b.left < a.right && a.left < b.right && b.top < a.bottom && a.top < b.bottom, `${cle} sur son terme`).toBe(false);
}
test('un événement retenu : ses collisions cumulées et |1000 \u{2212} Σ r(p)| ; sans retenu : « — » ; rien d’écrit', async () => {
const charge = chargeRetenue();
const mesures = mesuresDe(charge);
expect(mesures.collisionsCumulees).toBeGreaterThan(0);
const m = await monter({
fichiers: [fichier(`retenue${SUFFIXES.etat}`, etat(charge)), fichier(`club${SUFFIXES.etat}`, texteRenomme('Club des Merles'))],
});
await vi.waitFor(() => expect(lignes(m).length).toBe(2));
const avant = ecritures(m);
const retenue = ligneDe(m, 'retenue');
expect(colonne(retenue, 'collisionsCumulees')).toBe(format.nombre(mesures.collisionsCumulees));
expect(colonne(retenue, 'diffRedondance')).toBe(format.nombre(Math.abs(1000 - mesures.totalRedondance)));
const club = ligneDe(m, 'club');
expect(colonne(club, 'collisionsCumulees')).toBe(TIRET);
expect(colonne(club, 'diffRedondance')).toBe(TIRET);
expect(terme(retenue, 'collisionsCumulees').textContent).toBe(t('accueil.colonne.collisionsCumulees'));
expect(terme(retenue, 'diffRedondance').textContent).toBe(t('accueil.colonne.diffRedondance'));
expect(visible(retenue.querySelector('[data-colonne="diffRedondance"]'))).toBe(true);
expect(ecritures(m)).toBe(avant);
});
test('chaque intitulé s’explique : le survol puis le focus ouvrent son infobulle, Échap et la sortie la ferment', async () => {
const m = await monter({ fichiers: [fichier(`retenue${SUFFIXES.etat}`, etat(chargeRetenue()))] });
await vi.waitFor(() => expect(lignes(m).length).toBe(1));
const retenue = ligneDe(m, 'retenue');
const cas = [
['collisionsCumulees', 'accueil.explication.collisionsCumulees'],
['diffRedondance', 'accueil.explication.diffRedondance'],
];
expect(cas.length).toBeGreaterThan(0);
await pointer('bouger', 0, 0);
for (const [nom, cle] of cas) {
const ancre = terme(retenue, nom);
ancre.scrollIntoView({ block: 'center' });
const { x, y } = await centre(ancre);
await pointer('bouger', x, y);
await vi.waitFor(() => exigerInfobulle(ancre, cle));
await pointer('bouger', 0, 0);
await vi.waitFor(() => expect(infobulles()[0].hidden).toBe(true));
expect(ancre.tabIndex).toBe(0);
ancre.focus();
await vi.waitFor(() => exigerInfobulle(ancre, cle));
await touche('Escape');
await vi.waitFor(() => expect(infobulles()[0].hidden).toBe(true));
expect(document.activeElement).toBe(ancre);
ancre.blur();
}
});
test('la cible changée dans les propositions, puis la liste : la différence se lit contre la nouvelle cible', async () => {
const charge = chargeRetenue();
const { totalRedondance } = mesuresDe(charge);
const m = await monter({ fichiers: [fichier(`retenue${SUFFIXES.etat}`, etat(charge))] });
await vi.waitFor(() => expect(lignes(m).length).toBe(1));
expect(colonne(ligneDe(m, 'retenue'), 'diffRedondance')).toBe(format.nombre(Math.abs(1000 - totalRedondance)));
await ouvrirLigne(m, 'retenue');
await attendreEvenement(m);
await m.seance.passerEnEcriture();
m.ui.allerA('propositions');
await vi.waitFor(() => expect(dans(m, '[data-section="propositions"] [data-champ="cibleRedondance"]')).not.toBeNull());
const entree = dans(m, '[data-champ="cibleRedondance"]');
expect(entree.value).toBe('1000');
entree.focus();
saisir(entree, '25');
entree.blur();
await vi.waitFor(() => expect(m.seance.etat().charge.reglages.cibleRedondance).toBe(25));
await revenirALaListe(m, 1);
await vi.waitFor(() => expect(colonne(ligneDe(m, 'retenue'), 'diffRedondance')).toBe(format.nombre(Math.abs(25 - totalRedondance))));
expect(colonne(ligneDe(m, 'retenue'), 'collisionsCumulees')).toBe(format.nombre(mesuresDe(charge).collisionsCumulees));
});
});

View file

@ -5,8 +5,9 @@
// La liste des événements (§ 8.1) : une ligne par événement du dossier de
// travail, dans l'ordre de la façade, que désigne data-evenement. Chaque
// ligne porte la commande qui l'ouvre en lecture — son nom —, ses cinq
// autres informations sous leur intitulé, chacune dans son propre nœud
// (data-colonne), ses signalements traduits — les bases qu'un doublon
// autres informations et les deux mesures de son retenu (COLONNES_DU_RETENU)
// sous leur intitulé, chacune dans son propre nœud (data-colonne), ses
// signalements traduits — les bases qu'un doublon
// nomme suivent son texte, chacune dans son propre nœud (data-doublon) —,
// le refus de son ouverture, et ses opérations. Un dossier sans événement n'affiche pas de liste vide :
// la création et les démonstrations de l'accueil en tiennent lieu.
@ -14,15 +15,38 @@
// lignes : LigneListe[] de la façade ; ouvrir(ligne) : le geste de
// l'accueil ; refusOuverture : { base, refus } du dernier échec, ou null ;
// apresOperation() : à appeler quand une opération a changé le dossier.
//
// L'intitulé de chaque mesure du retenu s'explique dans l'infobulle de la
// liste, une seule (Infobulle, primitive) : son terme (data-terme) se
// focalise au clavier, son survol ou son focus ouvre l'explication, le
// quitter la referme quand elle est encore la sienne, Échap aussi. Le
// terme expliqué porte aria-describedby vers elle, et lui seul.
import { getContext } from 'svelte';
import Infobulle from '../primitives/Infobulle.svelte';
import Refus from '../primitives/Refus.svelte';
import Operations from './Operations.svelte';
import { ligneAffichee } from './modele.js';
import { COLONNES_DU_RETENU, ligneAffichee } from './modele.js';
let { lignes, ouvrir, refusOuverture, apresOperation } = $props();
const t = getContext('t');
const format = getContext('format');
// Le terme expliqué : {ancre, terme, cle, details} — son élément, sa
// désignation '<base>:<colonne>' et la clé de son explication — ; null
// sans infobulle.
const uid = $props.id();
const idExplication = `${uid}-explication`;
let expliquee = $state(null);
function expliquer(evenement, terme, cle) {
expliquee = { ancre: evenement.currentTarget, terme, cle, details: {} };
}
function taire(terme) {
if (expliquee?.terme === terme) expliquee = null;
}
const fermerExplication = () => (expliquee = null);
// L'aria-describedby d'un terme : l'infobulle quand elle l'explique.
const decrit = (terme) => (expliquee?.terme === terme ? idExplication : undefined);
</script>
{#if lignes.length > 0}
@ -50,6 +74,28 @@
<dt>{t('accueil.colonne.modification')}</dt>
<dd data-colonne="modification">{affichee.modification}</dd>
</div>
{#each COLONNES_DU_RETENU as { colonne, cle, explication } (colonne)}
{@const terme = `${ligne.base}:${colonne}`}
<div>
<dt>
<!-- L'intitulé est un terme, non une commande : le focus ne
sert qu'à montrer au clavier l'explication que le
pointeur montre au survol. -->
<!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_static_element_interactions -->
<span
class="terme"
data-terme={colonne}
tabindex="0"
aria-describedby={decrit(terme)}
onmouseenter={(evenement) => expliquer(evenement, terme, explication)}
onmouseleave={() => taire(terme)}
onfocus={(evenement) => expliquer(evenement, terme, explication)}
onblur={() => taire(terme)}>{t(cle)}</span
>
</dt>
<dd data-colonne={colonne}>{affichee[colonne]}</dd>
</div>
{/each}
<div class="chemin">
<dt>{t('accueil.colonne.chemin')}</dt>
<dd data-colonne="chemin">{affichee.chemin}</dd>
@ -74,6 +120,7 @@
</li>
{/each}
</ul>
<Infobulle id={idExplication} ouverte={expliquee} fermer={fermerExplication} />
{/if}
<style>
@ -138,6 +185,18 @@
overflow-wrap: anywhere;
}
/* Un intitulé qui s'explique se signale par un soulignement pointillé ;
focalisé, par le contour du focus. */
.terme {
text-decoration: underline dotted;
text-underline-offset: 0.2em;
}
.terme:focus-visible {
outline: 2px solid var(--couleur-focus);
outline-offset: 2px;
}
.signalement {
display: flex;
flex-direction: column;

View file

@ -4,7 +4,8 @@
// Le modèle de l'accueil, pur (§ 8.1, § 8.6, § 8.8) : ce que les composants
// de la liste des événements affichent, sans arithmétique ni texte. Une
// ligne de la liste (LigneListe de la façade, A3) devient ses six
// informations mises en forme et ses signalements ; une question du dossier
// informations et les deux mesures de son retenu, mises en forme, et ses
// signalements ; une question du dossier
// de travail, ses réponses ; un état illisible, ses secours ; un refus de la
// façade, le refus affiché sur la liste. Chaque clé rendue est écrite ici en
// entier : aucun composant ne compose une clé.
@ -20,6 +21,18 @@ const CLES_ETATS = new Map([
/** Les états du plan, dans l'ordre du § 9. */
export const ETATS = Object.freeze([...CLES_ETATS.keys()]);
/**
* Les deux mesures du placement retenu qu'une ligne montre (§ 5.4, § 8.9),
* dans l'ordre d'affichage : chacune { colonne, cle, explication } — le nom
* de sa valeur dans ligneAffichee et son data-colonne, la clé de son
* intitulé, celle de son explication, que l'infobulle de l'intitulé montre.
*/
export const COLONNES_DU_RETENU = Object.freeze(
['collisionsCumulees', 'diffRedondance'].map((colonne) =>
Object.freeze({ colonne, cle: `accueil.colonne.${colonne}`, explication: `accueil.explication.${colonne}` }),
),
);
// La clé de la valeur absente, celle de la mise en forme.
const CLE_VIDE = 'format.vide';
@ -95,20 +108,22 @@ export function signalementsDe(ligne) {
}
/**
* Les six informations d'une ligne (§ 8.1), mises en forme, et ses
* signalements. nom : celui inscrit dans le fichier, ou la base quand l'état
* Les six informations d'une ligne (§ 8.1), et les deux mesures de son
* retenu, mises en forme, et ses signalements. nom : celui inscrit dans le fichier, ou la base quand l'état
* ne se lit pas ; date, participants et dernière modification par format,
* la valeur absente quand ils manquent — la dernière modification est
* l'horodatage de la dernière entrée du journal, sur l'horloge de
* l'application, jamais la date du système de fichiers (§ 19.4) ; etat : la
* clé de son nom, que le composant traduit ; chemin : tel que la façade
* l'affiche, en entier.
* l'affiche, en entier ; collisionsCumulees et diffRedondance : celles que
* la façade rend dans mesuresDuRetenu, par format, la valeur absente sans
* retenu mesurable — la différence se lit telle quelle, jamais refaite.
*
* @param {Object} ligne LigneListe (A3)
* @param {ReturnType<import('../../application/format.js').creerFormat>} format
* @returns {{nom: string, date: string, etat: string, participants: string,
* modification: string, chemin: string,
* signalements: Array<{cle: string, details: Object, noms: string[]}>}}
* modification: string, chemin: string, collisionsCumulees: string,
* diffRedondance: string, signalements: Array<{cle: string, details: Object, noms: string[]}>}}
*/
export function ligneAffichee(ligne, format) {
return {
@ -118,6 +133,8 @@ export function ligneAffichee(ligne, format) {
participants: format.nombre(ligne.participants),
modification: ligne.derniereModification === null ? format.nombre(null) : format.horodatage(ligne.derniereModification),
chemin: ligne.chemin,
collisionsCumulees: format.nombre(ligne.mesuresDuRetenu?.collisionsCumulees ?? null),
diffRedondance: format.nombre(ligne.mesuresDuRetenu?.diffRedondance ?? null),
signalements: signalementsDe(ligne),
};
}

View file

@ -14,6 +14,7 @@ import { TABLES_APPLICATION } from '../../application/libelles.js';
import { creerTraduction } from '../../application/traduction.js';
import { TABLES_INTERFACE } from '../libelles.js';
import {
COLONNES_DU_RETENU,
ETATS,
SAISIES,
champDuRefus,
@ -49,6 +50,7 @@ function ligne(champs = {}) {
journalSeul: false,
doublonAvec: [],
verrou: null,
mesuresDuRetenu: null,
...champs,
};
}
@ -58,7 +60,9 @@ describe('ligneAffichee : les six informations (§ 8.1)', () => {
const affichee = ligneAffichee(ligne(), format);
assert.deepEqual(Object.keys(affichee).sort(), [
'chemin',
'collisionsCumulees',
'date',
'diffRedondance',
'etat',
'modification',
'nom',
@ -301,3 +305,40 @@ describe('saisieDe : la saisie d’un nom pour copier ou renommer (§ 8.7)', ()
assert.throws(() => saisieDe('supprimer', 'x'), RangeError);
});
});
describe('ligneAffichee : les mesures du retenu (§ 5.4, § 8.1, § 8.9)', () => {
test('les collisions cumulées et la différence à la cible, celles de la façade, mises en forme ; aucune ne se calcule ici', () => {
const mesuresDuRetenu = { collisionsCumulees: 1234, totalRedondance: 1500, cibleRedondance: 1000, diffRedondance: 500 };
const affichee = ligneAffichee(ligne({ mesuresDuRetenu }), format);
assert.equal(affichee.collisionsCumulees, format.nombre(1234));
assert.equal(affichee.diffRedondance, format.nombre(500));
// La différence se lit telle que la façade la rend, jamais refaite
// depuis la cible et la somme.
const telleQuelle = ligneAffichee(ligne({ mesuresDuRetenu: { ...mesuresDuRetenu, diffRedondance: 7 } }), format);
assert.equal(telleQuelle.diffRedondance, format.nombre(7));
});
test('sans retenu mesurable, et pour une ligne illisible, les deux valent « — »', () => {
for (const champs of [{}, { nom: null, etat: null, participants: null, illisible: { raison: 'VIDE', chemin: null } }]) {
const affichee = ligneAffichee(ligne(champs), format);
assert.equal(affichee.collisionsCumulees, VIDE);
assert.equal(affichee.diffRedondance, VIDE);
}
});
test('COLONNES_DU_RETENU : les deux colonnes, dans leur ordre, chacune avec son intitulé et son explication, qui existent', () => {
assert.deepEqual(
COLONNES_DU_RETENU.map(({ colonne }) => colonne),
['collisionsCumulees', 'diffRedondance'],
);
assert.ok(Object.isFrozen(COLONNES_DU_RETENU));
for (const { colonne, cle, explication } of COLONNES_DU_RETENU) {
assert.equal(cle, `accueil.colonne.${colonne}`);
assert.equal(explication, `accueil.explication.${colonne}`);
assert.ok(t(cle).length > 0);
assert.ok(t(explication).length > 0);
}
assert.equal(t('accueil.colonne.collisionsCumulees'), 'Collisions cumulées (paire et tour)');
assert.equal(t('accueil.colonne.diffRedondance'), "Diff somme d'appartenance");
});
});

View file

@ -10,6 +10,8 @@
// Une donnée — un chemin, une base — n'entre dans aucun libellé d'écran :
// le composant la rend dans son propre nœud, à côté du texte traduit
// (§ 14.6). Un compte s'accorde dans la phrase qui le porte.
import { CIBLE_REDONDANCE_PAR_DEFAUT } from '../../application/evaluation.js';
const compte = (n, singulier, pluriel) => `${n} ${n <= 1 ? singulier : pluriel}`;
export const TABLE = new Map([
@ -21,6 +23,26 @@ export const TABLE = new Map([
['accueil.colonne.participants', 'Participants'],
['accueil.colonne.modification', 'Dernière modification'],
['accueil.colonne.chemin', 'Fichier'],
// Les deux mesures du placement retenu (§ 5.4, § 8.9), et l'explication
// que l'infobulle de leur intitulé donne : ce que compte le nombre, d'où
// il vient, et quand il vaut « — ».
['accueil.colonne.collisionsCumulees', 'Collisions cumulées (paire et tour)'],
['accueil.colonne.diffRedondance', "Diff somme d'appartenance"],
[
'accueil.explication.collisionsCumulees',
'Celles du placement retenu : chaque fois que deux personnes de la même appartenance partagent une table, ' +
'compté une fois par paire et par tour ; deux collègues ensemble aux quatre tours font 4. Le même nombre que la ' +
"colonne « Collisions cumulées » du tableau des propositions. Plus il est bas, mieux c'est. " +
"« \u{2014} » quand aucun placement n'est retenu, ou que le retenu ne décrit plus la configuration.",
],
[
'accueil.explication.diffRedondance',
'|cible \u{2212} Σ r(p)| : la différence, sans signe, entre la cible de la soirée et la somme Σ r(p) du placement ' +
"retenu, celle de la colonne « Redondance d'appartenance, somme Σ r(p) » du tableau des propositions. r(p) compte " +
"les personnes que p rencontre et qui ont une appartenance, moins le nombre d'appartenances différentes qu'elles " +
`portent. La cible vaut ${CIBLE_REDONDANCE_PAR_DEFAUT} tant qu'on ne la change pas, dans la section des ` +
"propositions. « \u{2014} » quand aucun placement n'est retenu, ou que le retenu ne décrit plus la configuration.",
],
// Le nom d'un état du plan (§ 9), que rend cleDEtat (modele.js).
['accueil.etat.brouillon', 'Brouillon'],

View file

@ -74,6 +74,7 @@ export const TABLE = new Map([
['communs.remede.changerDefaut', 'Changer le nombre de places par défaut'],
['communs.remede.changerTours', 'Changer le nombre de tours'],
['communs.remede.modifierReglage', 'Modifier le réglage'],
['communs.remede.changerCibleRedondance', 'Changer la cible de la somme de redondance'],
['communs.remede.reserver', 'Réserver la place'],
['communs.remede.retirerReservation', 'Retirer la réservation'],
['communs.remede.deplacerReservation', 'Déplacer la réservation'],

View file

@ -224,6 +224,14 @@ export const TABLE = new Map([
['propositions.statut.derive', 'Non comparable : la proposition ne décrit plus la configuration courante.'],
['propositions.statut.fautif', 'Non comparable : le placement se contredit.'],
// --- La cible de la somme de redondance (§ 5.4, § 8.1, § 8.9) -------------------
['propositions.cible.etiquette', 'Cible de la somme de redondance'],
[
'propositions.cible.aide',
"La liste des événements montre, pour le placement retenu, la différence sans signe entre cette cible et la " +
"somme Σ r(p) de la colonne « Redondance d'appartenance ». Un nombre entier, 0 ou plus.",
],
// --- Les commandes (§ 2.1 étape 10, § 5.7) ------------------------------------
['propositions.commande.voir', 'Voir sur le plan'],
['propositions.commande.retenir', 'Retenir'],

View file

@ -878,3 +878,51 @@ describe('les infobulles d’explication : chaque nom de critère, chaque en-tê
await attendre(() => exigerInfobulle(second, explicationDeColonne('pairesDistinctes')));
});
});
// --- La cible de la somme de redondance (§ 5.4, § 8.1, § 8.9) ---------------------
describe('la cible de la somme de redondance : un champ de la section', () => {
const champCible = (m) => section(m).querySelector('[data-champ="cibleRedondance"]');
// Saisit un texte dans le champ, comme une frappe, puis quitte le champ :
// la sortie valide la saisie (Champ).
async function saisirCible(m, texte) {
const entree = champCible(m);
entree.focus();
entree.value = texte;
entree.dispatchEvent(new Event('input', { bubbles: true }));
flushSync();
entree.blur();
flushSync();
}
test('le champ montre 1000 sans réglage ; 25 saisi écrit une entrée, la cible 25 ; défaire rend 1000', async () => {
const m = await ouvrir(chargeDemo('petite'));
const entree = champCible(m);
expect(entree).not.toBeNull();
expect(entree.value).toBe('1000');
expect(texteDe(entree.closest('label') ?? section(m))).toContain(t('propositions.cible.etiquette'));
const avant = revision(m);
await saisirCible(m, '25');
await attendre(() => expect(charge(m).reglages.cibleRedondance).toBe(25));
expect(revision(m)).toBe(avant + 1);
await m.seance.defaire();
await attendre(() => expect(champCible(m).value).toBe('1000'));
expect(Object.hasOwn(charge(m).reglages, 'cibleRedondance')).toBe(false);
});
test('en lecture, la saisie est refusée MODE_LECTURE contre le champ, rien n’écrit ; une saisie négative, NON_ENTIER', async () => {
const lecture = await ouvrir(chargeDemo('petite'), { mode: 'lecture' });
const avant = revision(lecture);
await saisirCible(lecture, '25');
await attendre(() => expect(section(lecture).querySelector('[data-refus="MODE_LECTURE"]')).not.toBeNull());
expect(revision(lecture)).toBe(avant);
expect(Object.hasOwn(charge(lecture).reglages, 'cibleRedondance')).toBe(false);
const ecriture = await ouvrir(chargeDemo('petite'));
const revisionEcriture = revision(ecriture);
await saisirCible(ecriture, '-3');
await attendre(() => expect(section(ecriture).querySelector('[data-refus="NON_ENTIER"]')).not.toBeNull());
expect(revision(ecriture)).toBe(revisionEcriture);
});
});

View file

@ -36,7 +36,15 @@
// encore la sienne, Échap aussi. Un critère et sa colonne partagent leur
// explication (explicationDeColonne, modele.js). Le terme expliqué porte
// aria-describedby vers elle, et lui seul.
//
// Le champ de la cible de la somme de redondance montre la cible de
// l'événement (cibleRedondance), et exécute changerCibleRedondance à la
// sortie du champ — une entrée ; la saisie se lit par lireEntier, et un
// refus se pose contre le champ (Champ).
import { getContext } from 'svelte';
import { lireEntier } from '../../application/capacite.js';
import { cibleRedondance } from '../../application/evaluation.js';
import Champ from '../primitives/Champ.svelte';
import Commande from '../primitives/Commande.svelte';
import Confirmation from '../primitives/Confirmation.svelte';
import Infobulle from '../primitives/Infobulle.svelte';
@ -118,6 +126,7 @@
}
const effacer = () => seance.executer('effacerPropositions', {});
const changerCible = (cible) => seance.executer('changerCibleRedondance', { cible });
const nommer = (id) => nomDe(charge, id);
</script>
@ -165,6 +174,19 @@
<Infobulle id={idExplication} ouverte={expliquee} fermer={fermerExplication} />
{#if charge !== null}
<div class="cible" data-cible-redondance>
<Champ
cle="cibleRedondance"
etiquette="propositions.cible.etiquette"
valeur={cibleRedondance(charge)}
lire={lireEntier}
executer={changerCible}
/>
<p>{t('propositions.cible.aide')}</p>
</div>
{/if}
{#if charge !== null && charge.propositions.length > 0}
<div class="effacer">
<Commande cle="propositions.commande.effacer" executer={effacer} />
@ -227,4 +249,11 @@
.effacer {
display: flex;
}
.cible {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--espace-1);
}
</style>

View file

@ -70,9 +70,9 @@ function copierHorsDuSchema(valeur) {
// à ce qu'il contient : ses listes de table ne se trient que quand il vaut
// faux. Le réglage attribuerSieges de la charge n'y entre pas. Les clés hors
// du schéma ne sont pas copiées. Un retenu que sa règle refuse se copie hors
// du schéma, entier. Ailleurs, une clé du schéma absente lève TypeError en
// nommant son chemin : JSON.stringify la tairait, et le texte ne se relirait
// pas.
// du schéma, entier. Un champ facultatif absent reste absent de la copie ;
// ailleurs, une clé du schéma absente lève TypeError en nommant son chemin :
// JSON.stringify la tairait, et le texte ne se relirait pas.
function copier(valeur, regle, chemin, siegesAttribues) {
if (valeur === null) return null;
if (retenuHorsDeSaRegle(valeur, regle)) return copierHorsDuSchema(valeur);
@ -81,6 +81,7 @@ function copier(valeur, regle, chemin, siegesAttribues) {
const copie = {};
for (const [cle, regleDeCle] of regle.champs) {
const cheminDeCle = `${chemin}.${cle}`;
if (valeur[cle] === undefined && regleDeCle.optionnel) continue;
if (valeur[cle] === undefined) throw new TypeError(`${cheminDeCle} absente`);
copie[cle] = copier(valeur[cle], regleDeCle, cheminDeCle, drapeau);
}
@ -109,15 +110,17 @@ function rendre(valeur, regle, retrait) {
const lignes = valeur.map((element) => dedans + rendre(element, regle.element, retrait + 2));
return `[\n${lignes.join(',\n')}\n${fin}]`;
}
// Un champ facultatif absent ne s'écrit pas.
const presents = regle.champs.filter(([cle, regleDeCle]) => !(regleDeCle.optionnel && valeur[cle] === undefined));
if (regle.mise === 'lignes') {
const lignes = regle.champs.map(
const lignes = presents.map(
([cle, regleDeCle]) => `${dedans}${JSON.stringify(cle)}: ${rendre(valeur[cle], regleDeCle, retrait + 2)}`,
);
return `{\n${lignes.join(',\n')}\n${fin}}`;
}
// Mise « ouverte » : la syntaxe compacte, chaque champ selon sa règle au
// retrait de l'objet.
const champs = regle.champs.map(
const champs = presents.map(
([cle, regleDeCle]) => `${JSON.stringify(cle)}:${rendre(valeur[cle], regleDeCle, retrait)}`,
);
return `{${champs.join(',')}}`;

View file

@ -7,7 +7,8 @@
//
// Une opération pose une valeur au bout d'un chemin, ou retire
// l'enregistrement qu'il désigne : { op: 'poser', chemin, valeur } ou
// { op: 'retirer', chemin }. Un chemin est une liste d'étapes depuis la
// { op: 'retirer', chemin }. Un champ facultatif du schéma (optionnel) se
// pose aussi quand son objet ne le porte pas, et se retire par sa clé. Un chemin est une liste d'étapes depuis la
// charge : une clé d'objet, chaîne ; un rang de liste, entier ≥ 0 ; ou
// { id }, qui désigne dans une liste d'enregistrements à identifiant —
// participants, tables, propositions — l'enregistrement de cet identifiant,
@ -61,7 +62,8 @@ import { ErreurStockage } from './erreurs.js';
*
* @typedef {Object} OperationRetirer
* @property {'retirer'} op
* @property {Etape[]} chemin non vide, terminé par { id }
* @property {Etape[]} chemin non vide, terminé par { id }, ou par la clé
* d'un champ facultatif
*
* @typedef {OperationPoser|OperationRetirer} Operation
*/
@ -110,14 +112,24 @@ const poids = (operations) => JSON.stringify(operations).length;
// deux autres listes, de longueurs différentes, posent b entière, et de même
// longueur se comparent rang par rang — deux listes d'identifiants ou
// d'occupants exceptées, qui posent b entière quand leurs poses rang par
// rang pèsent plus que la sienne.
// rang pèsent plus que la sienne. Un champ facultatif absent d'un côté se
// pose quand b le porte, et se retire quand b ne le porte pas.
function comparer(a, b, regle, chemin, operations) {
if (a === null || b === null || (regle.genre !== 'objet' && regle.genre !== 'liste')) {
if (a !== b) operations.push({ op: 'poser', chemin, valeur: b });
} else if (retenuHorsDeSaRegle(a, regle) || retenuHorsDeSaRegle(b, regle)) {
if (JSON.stringify(a) !== JSON.stringify(b)) operations.push({ op: 'poser', chemin, valeur: b });
} else if (regle.genre === 'objet') {
for (const [cle, regleDeCle] of regle.champs) comparer(a[cle], b[cle], regleDeCle, [...chemin, cle], operations);
for (const [cle, regleDeCle] of regle.champs) {
const place = [...chemin, cle];
if (regleDeCle.optionnel && b[cle] === undefined) {
if (a[cle] !== undefined) operations.push({ op: 'retirer', chemin: place });
} else if (regleDeCle.optionnel && a[cle] === undefined) {
operations.push({ op: 'poser', chemin: place, valeur: b[cle] });
} else {
comparer(a[cle], b[cle], regleDeCle, place, operations);
}
}
} else if (identifiantsDistincts(a) && identifiantsDistincts(b)) {
comparerParIdentifiant(a, b, regle.element, chemin, operations);
} else if (a.length !== b.length) {
@ -286,13 +298,16 @@ function descendre(conteneur, etape) {
}
// Pose une copie de valeur à la place que désigne etape dans conteneur :
// une clé qu'il porte déjà, un rang qu'il contient, ou { id } absent de la
// liste, quand valeur, un enregistrement que valeurAdmise a contrôlé, porte
// cet id ; l'enregistrement s'insère alors avant le premier d'id plus
// grand, ce qui garde une liste rangée par identifiant croissant. Rend faux
// quand etape ne désigne aucune de ces places.
function poser(conteneur, etape, valeur) {
if (estCleDe(conteneur, etape) || estRangDe(conteneur, etape)) {
// une clé qu'il porte déjà, ou la clé d'un champ facultatif — regle, celle
// de la place, le dit — qu'un objet ne porte pas ; un rang qu'il contient ;
// ou { id } absent de la liste, quand valeur, un enregistrement que
// valeurAdmise a contrôlé, porte cet id ; l'enregistrement s'insère alors
// avant le premier d'id plus grand, ce qui garde une liste rangée par
// identifiant croissant. Rend faux quand etape ne désigne aucune de ces
// places.
function poser(conteneur, etape, valeur, regle) {
const facultatifAbsent = regle.optionnel && typeof etape === 'string' && estObjet(conteneur);
if (estCleDe(conteneur, etape) || estRangDe(conteneur, etape) || facultatifAbsent) {
conteneur[etape] = structuredClone(valeur);
return true;
}
@ -302,11 +317,17 @@ function poser(conteneur, etape, valeur) {
return true;
}
// Retire de conteneur l'enregistrement que désigne l'étape { id } ; rend
// faux quand elle n'en désigne pas exactement un. Une clé ou un rang ne se
// retirent pas : les objets de la charge ont les clés du schéma, et une
// liste sans identifiants se pose entière quand sa longueur change.
function retirer(conteneur, etape) {
// Retire de conteneur l'enregistrement que désigne l'étape { id }, ou le
// champ facultatif de la clé etape qu'il porte — regle, celle de la place,
// le dit ; rend faux quand elle n'en désigne pas exactement un. Une autre
// clé ou un rang ne se retirent pas : les objets de la charge ont les clés
// obligatoires du schéma, et une liste sans identifiants se pose entière
// quand sa longueur change.
function retirer(conteneur, etape, regle) {
if (regle?.optionnel && estCleDe(conteneur, etape)) {
delete conteneur[etape];
return true;
}
const rangs = rangsDe(conteneur, etape);
if (rangs?.length !== 1) return false;
conteneur.splice(rangs[0], 1);
@ -333,9 +354,9 @@ function executer(charge, operation) {
const derniere = chemin[chemin.length - 1];
if (op === 'poser') {
if (valeur === undefined || !valeurAdmise(charge, chemin, lecture.regle, valeur)) return false;
return poser(conteneur, derniere, valeur);
return poser(conteneur, derniere, valeur, lecture.regle);
}
if (op === 'retirer') return retirer(conteneur, derniere);
if (op === 'retirer') return retirer(conteneur, derniere, lecture.regle);
return false;
}
@ -354,15 +375,15 @@ function executer(charge, operation) {
* tables et des propositions, même dans une liste vide ; une étape qui ne
* trouve pas sa clé, que l'objet porte en propre et n'hérite pas, son rang
* ou exactement un enregistrement ; poser à une clé que l'objet ne porte
* pas ou au-delà de la fin d'une liste ; poser par { id } un identifiant
* déjà présent, ou une valeur qui n'est pas l'enregistrement de cet
* identifiant ; poser une valeur qui sort de la règle de sa place, en
* genre, en domaine, null compris, ou en clés, jusque dans les propositions
* et le retenu ; à la place du retenu, autre chose qu'un objet ou null ;
* sous un retenu que sa règle refuse, quoi que ce soit ; retirer autre
* chose qu'un enregistrement par { id }. Une charge rendue garde ainsi
* chaque proposition dans sa règle, et canoniser, difference et serialiser
* la lisent sans lever. Le contrôle porte sur la forme : une opération ne
* pas, hors d'un champ facultatif, ou au-delà de la fin d'une liste ; poser
* par { id } un identifiant déjà présent, ou une valeur qui n'est pas
* l'enregistrement de cet identifiant ; poser une valeur qui sort de la
* règle de sa place, en genre, en domaine, null compris, ou en clés, jusque
* dans les propositions et le retenu ; à la place du retenu, autre chose
* qu'un objet ou null ; sous un retenu que sa règle refuse, quoi que ce
* soit ; retirer autre chose qu'un enregistrement par { id } ou un champ
* facultatif présent. Une charge rendue garde ainsi chaque proposition dans
* sa règle, et canoniser, difference et serialiser la lisent sans lever. Le contrôle porte sur la forme : une opération ne
* porte pas la valeur qu'elle remplace, et un correctif dont chaque chemin
* existe aussi dans une autre charge s'y applique sans lever. Un correctif
* qui n'est pas une liste lève TypeError.

View file

@ -1758,3 +1758,47 @@ describe('le marqueur de chaise vide : correctifs et forme canonique (§ 8.9)',
}
});
});
describe('la cible de la somme de redondance : un champ facultatif dans un correctif (§ 8.6, § 8.9)', () => {
// Une charge neuve, sans la cible, et la même portant la cible donnée.
const sans = () => creerCharge({ id: 'evt-cible', nom: 'Veillée des Grives', siegesParDefaut: 6, tours: 2 });
const avec = (cible) => {
const charge = sans();
charge.reglages.cibleRedondance = cible;
return charge;
};
const CHEMIN = ['reglages', 'cibleRedondance'];
test('absente puis posée : une pose ; posée puis retirée : un retrait ; changée : une pose ; absente des deux côtés : rien', () => {
const cas = [
[sans(), avec(1200), [{ op: 'poser', chemin: CHEMIN, valeur: 1200 }]],
[avec(1200), sans(), [{ op: 'retirer', chemin: CHEMIN }]],
[avec(1200), avec(0), [{ op: 'poser', chemin: CHEMIN, valeur: 0 }]],
[sans(), sans(), []],
];
assert.ok(cas.length > 0);
for (const [a, b, attendu] of cas) {
const correctif = difference(a, b);
assert.deepEqual(correctif, attendu);
const rendue = appliquer(a, JSON.parse(JSON.stringify(correctif)));
assert.deepEqual(rendue, canoniser(b));
assert.equal(serialiser(rendue, ENTETE), serialiser(b, ENTETE));
}
});
test('une cible hors de sa règle, un retrait de cible absente ou de tout autre champ, une pose sous une clé inconnue lèvent CORRECTIF', () => {
const cas = [
['cible négative', sans(), { op: 'poser', chemin: CHEMIN, valeur: -1 }],
['cible non entière', avec(10), { op: 'poser', chemin: CHEMIN, valeur: 2.5 }],
['cible nulle', sans(), { op: 'poser', chemin: CHEMIN, valeur: null }],
['retrait de la cible absente', sans(), { op: 'retirer', chemin: CHEMIN }],
["retrait d'un réglage obligatoire", avec(10), { op: 'retirer', chemin: ['reglages', 'attribuerSieges'] }],
['pose sous une clé inconnue', sans(), { op: 'poser', chemin: ['reglages', 'cibleInconnue'], valeur: 3 }],
['pose de la cible hors des réglages', sans(), { op: 'poser', chemin: ['evenement', 'cibleRedondance'], valeur: 3 }],
];
assert.ok(cas.length > 0);
for (const [libelle, charge, operation] of cas) {
assert.deepEqual(refusDe(charge, [operation]), { rang: 0 }, libelle);
}
});
});

View file

@ -73,11 +73,20 @@ export const TOURS_MAX = 100;
*/
export const GENERATION_PAR_DEFAUT = Object.freeze({ nombre: 5, arret: 200_000, historique: HISTORIQUE_PAR_DEFAUT });
/**
* La cible de la somme de redondance d'un événement quand ses réglages n'en
* portent pas (§ 8.9) : la liste des événements la compare à Σ r(p) du
* placement retenu. Un fichier sans le champ se lit à cette valeur.
*/
export const CIBLE_REDONDANCE_PAR_DEFAUT = 1000;
// Une règle du schéma ; types.js en décrit les propriétés (Regle). Un objet
// porte ses champs, paires [clé, règle] dans l'ordre des clés, et l'ensemble
// de leurs clés ; une liste porte la règle de ses éléments.
// de leurs clés ; une liste porte la règle de ses éléments. Un champ
// facultatif (optionnel) peut manquer à son objet : absent, il ne s'écrit
// pas, et sa valeur par défaut se lit par la fonction qui le nomme.
function regle(genre, proprietes) {
return Object.freeze({ genre, nul: false, ...proprietes });
return Object.freeze({ genre, nul: false, optionnel: false, ...proprietes });
}
const chaine = (options) => regle('chaine', options);
const entier = (min, max = Infinity, options = {}) => regle('entier', { min, max, ...options });
@ -186,6 +195,9 @@ const REGLAGES = objet([
['varierAppartenances', BOOLEEN],
['attribuerSieges', BOOLEEN],
['generation', objet([['nombre', entier(1)], ['arret', entier(1)], ['historique', entier(1)]])],
// Facultatif, en dernier : un fichier qui ne le porte pas s'écrit sans
// lui, octet pour octet comme avant qu'il existe (cibleRedondance).
['cibleRedondance', entier(0, Infinity, { optionnel: true })],
]);
const PARTICIPANT = objet([
@ -454,6 +466,7 @@ function reduire(valeur, regleDeValeur) {
// vrai fait examiner tout le contenu de la règle donnée, même aPart, et
// faux la lit à sa place sous une autre, comme l'analyse.
function fauteDeForme(valeur, regleDeValeur, chemin, racine) {
if (valeur === undefined && regleDeValeur.optionnel) return null;
if (valeur === null && regleDeValeur.nul) return null;
if (!conforme(valeur, regleDeValeur)) return chemin;
if (regleDeValeur.aPart && !racine) return null;
@ -479,8 +492,9 @@ function fauteDeForme(valeur, regleDeValeur, chemin, racine) {
* quand valeur est conforme ; ne lève jamais. D'un objet, chaque champ dans
* l'ordre du schéma, puis la plus petite de ses clés inconnues ; d'une
* liste, chaque élément dans l'ordre des rangs. null est conforme à une
* règle qui l'admet. Un champ absent se lit undefined, qu'aucune règle
* n'admet, et se nomme par son chemin comme une valeur fautive. Le chemin
* règle qui l'admet. Un champ absent se lit undefined, qu'admet seule une
* règle facultative ; ailleurs, il se nomme par son chemin comme une valeur
* fautive. Le chemin
* suit la convention d'analyser, à partir de chemin, '' par défaut.
*
* Une règle aPart rencontrée sous une autre n'examine que son conteneur :
@ -699,6 +713,18 @@ export function etatDeduit({ retenu, propositions }) {
return propositions.length > 0 ? 'propose' : 'brouillon';
}
/**
* La cible de la somme de redondance de l'événement (§ 8.9) : le réglage
* cibleRedondance quand la charge le porte, CIBLE_REDONDANCE_PAR_DEFAUT
* sinon. C'est la seule lecture de ce réglage.
*
* @param {import('./types.js').Charge} charge
* @returns {number}
*/
export function cibleRedondance(charge) {
return charge.reglages.cibleRedondance ?? CIBLE_REDONDANCE_PAR_DEFAUT;
}
/**
* Capacité courante d'une table (§ 6.1) : ses sièges quand elle est
* surchargée, le défaut de l'événement quand elle le suit (sieges null).

View file

@ -18,6 +18,7 @@ import { texteLivre } from '../demo/livrees.js';
import { VERSION } from '../version.genere.js';
import { serialiser } from './canonique.js';
import {
CIBLE_REDONDANCE_PAR_DEFAUT,
FORMAT,
GENERATION_PAR_DEFAUT,
SCHEMA,
@ -25,6 +26,7 @@ import {
TOURS_MAX,
analyser,
capacite,
cibleRedondance,
configurationDepuisCharge,
creerCharge,
etatDeduit,
@ -1110,3 +1112,74 @@ describe('le marqueur de chaise vide : la règle d\'une liste de table (§ 8.9)'
assert.deepStrictEqual(relue.propositions[0], propositionAChaiseVide());
});
});
describe('la cible de la somme de redondance : un réglage facultatif (§ 8.9)', () => {
const REGLAGES = (() => {
const CHARGE = SCHEMA.champs.find(([nom]) => nom === 'charge')[1];
return CHARGE.champs.find(([nom]) => nom === 'reglages')[1];
})();
const CHEMIN = 'charge.reglages.cibleRedondance';
test('sans le champ, le fichier se lit, la charge ne le porte pas, et la cible vaut 1000', () => {
assert.equal(CIBLE_REDONDANCE_PAR_DEFAUT, 1000);
const { charge } = analyser(JSON.stringify(documentValide()));
assert.equal(Object.hasOwn(charge.reglages, 'cibleRedondance'), false);
assert.equal(cibleRedondance(charge), 1000);
assert.equal(premiereFaute(charge.reglages, REGLAGES), null);
const neuve = creerCharge({ id: 'evt-neuf', nom: 'Atelier', siegesParDefaut: 8, tours: 3 });
assert.equal(Object.hasOwn(neuve.reglages, 'cibleRedondance'), false);
assert.equal(cibleRedondance(neuve), 1000);
});
test('un entier ≥ 0, sûr, se lit tel quel et fait la cible, 1000 écrit compris', () => {
const valeurs = [0, 1, 1000, 1234, Number.MAX_SAFE_INTEGER];
assert.ok(valeurs.length > 0);
for (const valeur of valeurs) {
const { charge } = analyser(abime((d) => { d.charge.reglages.cibleRedondance = valeur; }));
assert.equal(charge.reglages.cibleRedondance, valeur);
assert.equal(cibleRedondance(charge), valeur);
}
});
test('une valeur négative, non entière, hors des entiers sûrs ou d\'une autre sorte est refusée FORME, par son chemin', () => {
const cas = [
['négative', -1],
['non entière', 2.5],
['au-delà des entiers sûrs', 2 ** 53],
['en texte', '1000'],
['booléenne', true],
['nulle', null],
['une liste', [1000]],
];
assert.deepEqual(ecartsDeRefus(cas.map(([libelle, valeur]) => [
libelle,
abime((d) => { d.charge.reglages.cibleRedondance = valeur; }),
'FORME',
CHEMIN,
])), []);
});
test("absente, la charge s'écrit sans le champ ; posée, il s'écrit après la génération, et la relecture rend la même charge", () => {
const { charge } = analyser(JSON.stringify(documentValide()));
const sans = serialiser(charge, { revision: 12, produitVersion: VERSION.affichee });
assert.equal(sans.includes('cibleRedondance'), false);
const posee = structuredClone(charge);
posee.reglages = { cibleRedondance: 1200, ...posee.reglages };
const avec = serialiser(posee, { revision: 12, produitVersion: VERSION.affichee });
assert.ok(avec.includes('"generation":{"nombre":3,"arret":50000,"historique":500},"cibleRedondance":1200}'), avec);
assert.deepEqual(analyser(avec).charge, analyser(serialiser(analyser(avec).charge, { revision: 12, produitVersion: VERSION.affichee })).charge);
assert.equal(cibleRedondance(analyser(avec).charge), 1200);
});
test("un fichier d'un format plus récent garde la cible qu'il porte, réduite aux clés connues", () => {
const texte = abime((d) => {
d.entete.format = FORMAT + 1;
d.charge.reglages.cibleRedondance = 750;
d.charge.reglages.autreReglage = true;
});
const lecture = analyser(texte);
assert.equal(lecture.formatPlusRecent, true);
assert.equal(cibleRedondance(lecture.charge), 750);
assert.equal(Object.hasOwn(lecture.charge.reglages, 'autreReglage'), false);
});
});

View file

@ -96,6 +96,10 @@
* propre siegesAttribues
* @property {{nombre: number, arret: number, historique: number}} generation
* entiers ≥ 1
* @property {number} [cibleRedondance] entier ≥ 0, facultatif : la cible
* de la somme de redondance ; absent,
* elle vaut CIBLE_REDONDANCE_PAR_DEFAUT,
* que rend la fonction cibleRedondance
*
* @typedef {Object} ProchainsIds entiers de 1 à 2^31 − 1, au-delà de tout
* identifiant attribué ; ils ne reculent
@ -229,6 +233,8 @@
* chaine : non vide ; nombre : fini ; parmi : l'une de valeurs ;
* date : AAAA-MM-JJ d'un jour du calendrier
* @property {boolean} nul null est admis
* @property {boolean} optionnel le champ peut manquer à son
* objet : absent, il ne s'écrit pas et ne se compare pas
* @property {Array<[string, Regle]>} [champs] objet : ses champs, dans
* l'ordre des clés du texte canonique
* @property {Set<string>} [cles] objet : les clés de ses champs

View file

@ -297,6 +297,31 @@ export const ECRANS = Object.freeze([
return m.racine;
},
},
{
nom: 'accueil.mesuresRetenu',
mode: null,
async atteindre(contexte) {
// La principale, sa proposition retenue : la ligne montre les deux
// mesures du retenu, et le pointeur réel sur l'intitulé de la
// différence ouvre son infobulle.
const principale = chargePrincipale();
const charge = executer(principale, 'retenirProposition', { id: principale.propositions[0].id });
const m = await contexte.monter({ fichiers: [fichierEtat(charge)] });
const ligne = `[data-evenement="${BASE}"]`;
await attendre('les mesures du retenu', () =>
['collisionsCumulees', 'diffRedondance'].every((colonne) =>
/\d/.test(m.racine.querySelector(`${ligne} [data-colonne="${colonne}"]`)?.textContent ?? ''),
),
);
const { x, y } = await centre(await element(m, `${ligne} [data-terme="diffRedondance"]`));
await pointer('bouger', x, y);
await attendre("l'infobulle de la différence", () => {
const bulle = m.racine.querySelector('[data-ecran="accueil"] [role="tooltip"]');
return bulle !== null && !bulle.hidden && getComputedStyle(bulle).visibility === 'visible';
});
return m.racine;
},
},
{
nom: 'accueil.vide',
mode: null,