diff --git a/spec.md b/spec.md index eb07b5f..750b454 100644 --- a/spec.md +++ b/spec.md @@ -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 diff --git a/src/application/commandes.test.js b/src/application/commandes.test.js index 7fc9ca5..57eabab 100644 --- a/src/application/commandes.test.js +++ b/src/application/commandes.test.js @@ -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' }], ]); } diff --git a/src/application/commandes_mobilier.js b/src/application/commandes_mobilier.js index e6f56c4..c9de1cb 100644 --- a/src/application/commandes_mobilier.js +++ b/src/application/commandes_mobilier.js @@ -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 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], ]); diff --git a/src/application/commandes_mobilier.test.js b/src/application/commandes_mobilier.test.js index f59e364..81b75a1 100644 --- a/src/application/commandes_mobilier.test.js +++ b/src/application/commandes_mobilier.test.js @@ -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); + }); +}); diff --git a/src/application/evaluation.js b/src/application/evaluation.js index 10312b0..23c4637 100644 --- a/src/application/evaluation.js +++ b/src/application/evaluation.js @@ -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) }; +} diff --git a/src/application/evaluation.test.js b/src/application/evaluation.test.js index a5d7b3f..c0d4ad3 100644 --- a/src/application/evaluation.test.js +++ b/src/application/evaluation.test.js @@ -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); + }); +}); diff --git a/src/application/evenements.js b/src/application/evenements.js index 460039f..2d160b6 100644 --- a/src/application/evenements.js +++ b/src/application/evenements.js @@ -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, diff --git a/src/application/evenements.test.js b/src/application/evenements.test.js index 17a0d40..1949d03 100644 --- a/src/application/evenements.test.js +++ b/src/application/evenements.test.js @@ -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) ---------------------------------------------------- diff --git a/src/application/libelles.test.js b/src/application/libelles.test.js index c91780b..0e4c408 100644 --- a/src/application/libelles.test.js +++ b/src/application/libelles.test.js @@ -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 diff --git a/src/application/libelles/mobilier.js b/src/application/libelles/mobilier.js index b3f1efc..65bbc72 100644 --- a/src/application/libelles/mobilier.js +++ b/src/application/libelles/mobilier.js @@ -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}`], ]); diff --git a/src/application/registre.test.js b/src/application/registre.test.js index 3bbda53..e077a80 100644 --- a/src/application/registre.test.js +++ b/src/application/registre.test.js @@ -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', { diff --git a/src/interface/accueil/Accueil.navigateur.test.js b/src/interface/accueil/Accueil.navigateur.test.js index 7658dee..4fe88cb 100644 --- a/src/interface/accueil/Accueil.navigateur.test.js +++ b/src/interface/accueil/Accueil.navigateur.test.js @@ -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)); + }); +}); diff --git a/src/interface/accueil/ListeEvenements.svelte b/src/interface/accueil/ListeEvenements.svelte index c36a4e9..246b10a 100644 --- a/src/interface/accueil/ListeEvenements.svelte +++ b/src/interface/accueil/ListeEvenements.svelte @@ -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 ':' 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); {#if lignes.length > 0} @@ -50,6 +74,28 @@
{t('accueil.colonne.modification')}
{affichee.modification}
+ {#each COLONNES_DU_RETENU as { colonne, cle, explication } (colonne)} + {@const terme = `${ligne.base}:${colonne}`} +
+
+ + + expliquer(evenement, terme, explication)} + onmouseleave={() => taire(terme)} + onfocus={(evenement) => expliquer(evenement, terme, explication)} + onblur={() => taire(terme)}>{t(cle)} +
+
{affichee[colonne]}
+
+ {/each}
{t('accueil.colonne.chemin')}
{affichee.chemin}
@@ -74,6 +120,7 @@ {/each} + {/if} diff --git a/src/stockage/canonique.js b/src/stockage/canonique.js index 23cac4f..1262c9c 100644 --- a/src/stockage/canonique.js +++ b/src/stockage/canonique.js @@ -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(',')}}`; diff --git a/src/stockage/correctifs.js b/src/stockage/correctifs.js index 862a06d..13d9e73 100644 --- a/src/stockage/correctifs.js +++ b/src/stockage/correctifs.js @@ -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. diff --git a/src/stockage/correctifs.test.js b/src/stockage/correctifs.test.js index 039a192..b69fe4a 100644 --- a/src/stockage/correctifs.test.js +++ b/src/stockage/correctifs.test.js @@ -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); + } + }); +}); diff --git a/src/stockage/document.js b/src/stockage/document.js index e3ee1b2..95d6864 100644 --- a/src/stockage/document.js +++ b/src/stockage/document.js @@ -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). diff --git a/src/stockage/document.test.js b/src/stockage/document.test.js index ad9a134..cd89a06 100644 --- a/src/stockage/document.test.js +++ b/src/stockage/document.test.js @@ -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); + }); +}); diff --git a/src/stockage/types.js b/src/stockage/types.js index 75e7ab6..eb9f8a6 100644 --- a/src/stockage/types.js +++ b/src/stockage/types.js @@ -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} [cles] objet : les clés de ses champs diff --git a/test/navigateur/ecrans.js b/test/navigateur/ecrans.js index 97316f0..a5b2210 100644 --- a/test/navigateur/ecrans.js +++ b/test/navigateur/ecrans.js @@ -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,