[IMP] event list: retained collisions replace the redundancy target gap

The event list summarised the retained placement by its distance to a
redundancy target, a figure the operator does not act on. It now shows the
retained placement's cumulated collisions (pair and round), the same number
as the proposals table. The target setting, which only fed that column, is
removed: a file or journal that still carries it opens and replays without
error and drops it at the next write. Undo and redo now step past an entry
that changes nothing, instead of refusing it, so old journals stay undoable.
Checked: node 5130, browser 1145, long 129; captures stable on two passes.

--- FR ---

[IMP] liste des événements : collisions du retenu, cible retirée

La liste résumait le retenu par son écart à une cible de redondance, un
chiffre sur lequel l'opérateur n'agit pas. Elle montre désormais les
collisions cumulées (paire et tour) du retenu, le nombre du tableau des
propositions. Le réglage de la cible, qui ne servait qu'à cette colonne, est
retiré : un fichier ou un journal qui le porte s'ouvre et se rejoue sans
erreur et le perd à la prochaine écriture. Défaire et refaire passent une
entrée sans effet au lieu de la refuser : un ancien journal reste défaisable.
Vérifié : node 5130, navigateur 1145, longues 129 ; captures stables, 2 passes.

Assisted-by: Claude Opus 5.5
This commit is contained in:
Mathieu Benoit 2026-10-13 04:52:17 -04:00
parent dbeb97f261
commit 8fd3bccd86
39 changed files with 585 additions and 607 deletions

View file

@ -166,6 +166,12 @@
page de qualité.
- Le guide d'usage montre la page de qualité, ses vues et ses cas limites,
captures à l'appui.
- Dans la liste des événements, la colonne du placement retenu montre ses
« Collisions cumulées (paire et tour) », le même nombre que la colonne du
tableau des propositions, au lieu de « Diff somme d'appartenance ». Le réglage
« Cible de la somme de redondance », qui ne servait qu'à cette colonne, quitte
la section des propositions : un fichier qui le porte s'ouvre sans message, et
le perd au premier enregistrement.
**Version de format des fichiers** : 2. Les étiquettes, les objectifs, leurs
règles, les cases nouvelles, le mode des objectifs et l'ordre des priorités

View file

@ -807,7 +807,6 @@ réglage du poste, pour la séance, automatique par défaut (étape 9).
| Attribuer les sièges | Génération | décochée |
| Objectifs des tables : chercher d'abord | Génération | le plus de places servies |
| Ordre des priorités | Génération | écart au plafond a priori, puis excédent de collisions, puis collisions cumulées, puis tours hors objectif, puis rencontres répétées et retours, puis animateurs non rencontrés, puis redondance d'appartenance |
| Cible de la somme de redondance | Propositions | 1 000 |
<!-- engendre:fin reglages -->
@ -877,7 +876,6 @@ l’historique ; les noms des exemples sont inventés.
| `changerDefaut` | Sièges par défaut : 10 ; 4 tables réalignées |
| `changerTours` | Nombre de tours : 3 ; 2 réservations retirées |
| `modifierReglage` | Séparer les appartenances : désactivé |
| `changerCibleRedondance` | Cible de la somme de redondance : 1200 |
| `poserObjectif` | Table 3 : objectif de la soirée « finance », règle par défaut |
| `retirerObjectif` | Table 3, tour 2 : objectif retiré |
| `changerRegleObjectifs` | Règle par défaut des objectifs : toutes les étiquettes |

File diff suppressed because one or more lines are too long

View file

@ -373,7 +373,6 @@ export const EXEMPLES_COMMANDES = Object.freeze({
changerDefaut: { sieges: 10, realignees: 4 },
changerTours: { tours: 3, retirees: 2 },
modifierReglage: { cle: 'separerAppartenances', valeur: false },
changerCibleRedondance: { cible: 1200 },
changerEtiquettes: { personne: 'Iris Ombrelle', ajoutees: ['finance'], retirees: [] },
poserObjectif: { numero: 3, tour: null, regle: null, etiquettes: ['finance'] },
retirerObjectif: { numero: 3, tour: 2 },

93
spec.md
View file

@ -199,8 +199,8 @@ table, les objectifs des tables tour par tour, les animateurs, et la performance
du calcul qui a produit la proposition. 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. Le
retenu se résume ensuite dans la liste des événements, par la différence absolue
de sa somme de redondance à la cible de l'événement (§ 8.1).
retenu se résume ensuite dans la liste des événements, par ses collisions
cumulées (§ 5.4, § 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
@ -1905,35 +1905,32 @@ en-têtes de colonnes se groupent en « Événement », « Placement retenu » e
« Meilleure proposition du classement », et le nom de chaque ligne en est
l'en-tête, la date et le chemin en petite ligne dessous. Sous le tableau, des
cartes offrent la création, le dossier de travail et les démonstrations. Une
colonne résume en plus le **placement retenu** : « 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. Elle se lit dans la même mesure que
le tableau, sans rien écrire ; un événement sans retenu, ou dont le retenu est
fautif ou en dérive (§ 8.9, § 9), y porte « — ». Chaque ligne porte aussi la
**« Meilleure proposition du classement »**, sous les libellés du bloc que la
section de la génération montre en tête : l'identifiant de la proposition au
rang 1 du classement (§ 5.7) — la même proposition que ce bloc et que la
première ligne du tableau des propositions, retenue ou non ; une proposition
fautive, que l'ouverture écarte (§ 8.9, point 3), n'y entre pas. Sans
proposition classée — aucune, ou aucune qui décrive encore la configuration —,
elle vaut « — ». La liste ne montre les collisions cumulées ni du retenu ni de
la meilleure proposition : le tableau des propositions et le bloc de la
génération les portent. Chaque intitulé s'explique dans une infobulle, posée
sur l'en-tête de sa colonne.
**La liste paraît avant ses colonnes calculées** : ses lignes s'affichent dès le
relevé du dossier, et les mesures du retenu et la meilleure proposition, qui
évaluent chaque événement, se calculent ensuite, un événement après l'autre, la
main rendue au navigateur entre deux. Tant que celles d'une ligne se calculent,
chacune de ses valeurs porte un signe de chargement qui tourne ; chaque ligne
montre les siennes dès qu'elles sont obtenues, et une liste relue abandonne les
calculs de la précédente.
**Un répertoire vide n'affiche pas une liste vide** : il présente la commande de
création et les démonstrations — ni tuiles, ni tableau, ni résumé —, car une
liste vide sans issue est le premier écran que voit un opérateur qui lance
l'exécutable. Un filtre qui ne retient aucune ligne le dit, sans laisser un
tableau vide.
colonne résume en plus le **placement retenu** : « Collisions cumulées (paire et
tour) », les collisions cumulées du retenu (§ 5.4) — le nombre que la colonne du
même intitulé du tableau des propositions porte sur la ligne du retenu. Elle se
lit dans la même mesure que le tableau, sans rien écrire ; un événement sans
retenu, ou dont le retenu est fautif ou en dérive (§ 8.9, § 9), y porte « — ».
Chaque ligne porte aussi la **« Meilleure proposition du classement »**, sous
les libellés du bloc que la section de la génération montre en tête :
l'identifiant de la proposition au rang 1 du classement (§ 5.7) — la même
proposition que ce bloc et que la première ligne du tableau des propositions,
retenue ou non ; une proposition fautive, que l'ouverture écarte (§ 8.9, point
3), n'y entre pas. Sans proposition classée — aucune, ou aucune qui décrive
encore la configuration —, elle vaut « — ». La liste ne montre pas les
collisions cumulées de la meilleure proposition : le tableau des propositions et
le bloc de la génération les portent. Chaque intitulé s'explique dans une
infobulle, posée sur l'en-tête de sa colonne. **La liste paraît avant ses
colonnes calculées** : ses lignes s'affichent dès le relevé du dossier, et les
mesures du retenu et la meilleure proposition, qui évaluent chaque événement, se
calculent ensuite, un événement après l'autre, la main rendue au navigateur
entre deux. Tant que celles d'une ligne se calculent, chacune de ses valeurs
porte un signe de chargement qui tourne ; chaque ligne montre les siennes dès
qu'elles sont obtenues, et une liste relue abandonne les calculs de la
précédente. **Un répertoire vide n'affiche pas une liste vide** : il présente la
commande de création et les démonstrations — ni tuiles, ni tableau, ni résumé —,
car une liste vide sans issue est le premier écran que voit un opérateur qui
lance l'exécutable. Un filtre qui ne retient aucune ligne le dit, sans laisser
un tableau vide.
Le logiciel n'ouvre **qu'un événement à la fois**, et ferme le précédent sans
question : il n'y a rien à enregistrer.
@ -2004,6 +2001,12 @@ retour est **l'entrée qu'elle vise**. *Défaire* vise le prédécesseur sur ce
remonter son fil et atterrit sur un fil mort obtient un état légitime, jamais
celui qu'il visait.
**Défaire et refaire avancent toujours d'un pas.** Quand l'instant visé a le
même contenu que l'instant courant, leur entrée s'écrit quand même : c'est elle
qui déplace le fil, et un refus laisserait chaque nouveau « Défaire » buter sur
le même instant. Revenir à un tel instant, choisi dans la liste, ne change rien
et ne s'écrit pas.
**Les jalons.** L'opérateur peut **nommer l'instant courant**, et filtrer
l'historique sur les instants nommés. Nommer ne modifie pas le contenu : c'est
une **ligne de marque**, pas une entrée, sinon l'équivalence du § 8.2 serait
@ -2514,19 +2517,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
#### Un champ ancien, oublié : 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.** Un fichier qui ne
le porte pas — chaque fichier écrit avant lui, chaque démonstration livrée — se
lit à la cible 1000, et s'écrit sans lui. 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.
Un fichier écrit par une construction antérieure peut porter, dans les
réglages de l'événement, un champ `cibleRedondance` : la cible à laquelle la
liste des événements comparait la somme de redondance Σ r(p) du retenu. La
liste montre les collisions cumulées du retenu (§ 8.1), et ce champ ne sert
plus. **Il est oublié** : un fichier qui le porte se lit sans faute de forme, sans
refus ni avertissement, quelle que soit sa valeur ; la charge lue ne le porte
pas, et le premier geste écrit le fichier sans lui. Aucune commande ne le pose.
Un correctif du journal (§ 8.6) qui le pose ou le retire, qu'un journal plus
ancien porte, s'applique sans rien changer : chaque instant se restitue sans
lui. L'instant qui le posait ou le retirait se restitue donc comme son
voisin : le défaire ou le refaire s'écrit quand même, d'une entrée qui ne
change rien, et le fil avance d'un pas (§ 8.3). Hors des réglages, la même clé
reste une clé inconnue, et une faute de forme.
#### Deux champs facultatifs : le placement daté
@ -2552,7 +2557,7 @@ là, et nulle part ailleurs : le pied des pages, la feuille de contrôle, le bad
Chacun est **absent tant qu'il vaut son défaut** et s'écrit après les clés du
format 1 de son objet, dans l'ordre du tableau qui suit — pour les réglages,
`cibleRedondance`, `regleObjectifs`, `respecterObjectifs`, `modeObjectifs`,
`regleObjectifs`, `respecterObjectifs`, `modeObjectifs`,
`rencontrerAnimateurs`, `priorites` : un événement qui n'emploie ni étiquette,
ni objectif, ni ordre réglé s'écrit comme un fichier du format 1, au numéro de
format près. Chacun se pose par une commande — une entrée d'historique, refusée

View file

@ -987,7 +987,6 @@ 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' }],
['changerEtiquettes', { id: 3, ajouter: 'finance' }],
['poserObjectif', { table: 2, tour: 1, etiquettes: 'finance', regle: 'et' }],
['retirerObjectif', { table: 2, tour: null }],

View file

@ -2,10 +2,10 @@
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le registre des commandes du mobilier — tables, sièges, tours, réglages,
// cible de la somme de redondance, objectifs des tables et leur règle par
// défaut, ordre des priorités et mode des objectifs (§ 4, § 4.5, § 5.3,
// § 5.7, § 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.
// objectifs des tables et leur règle par défaut, ordre des priorités et
// mode des objectifs (§ 4, § 4.5, § 5.3, § 5.7, § 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.
//
// Chaque commande suit le contrat de commandes.js : une fonction pure
// (charge, arguments) → { charge, libelle, avertissements }, la charge reçue
@ -27,11 +27,9 @@
import { caseLibre, geometrieTable, listeSurTable } from '../geometrie/tables.js';
import { CRITERES, MODES_OBJECTIFS, POIDS_MAX, seulsASonPalier, verifierPriorites } from '../moteur/criteres.js';
import {
CIBLE_REDONDANCE_PAR_DEFAUT,
SCHEMA,
TOURS_MAX,
capacite,
cibleRedondance,
critereCoche,
modeObjectifs,
priorites,
@ -377,27 +375,6 @@ 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é. La même valeur que
* la cible courante, lue par cibleRedondance, est SANS_EFFET, champ écrit ou
* non : une cible de 1000 écrite au fichier ne se retire pas par une saisie
* qui ne change rien.
*/
function changerCibleRedondance(charge, { cible: saisie } = {}) {
exigerModifiable(charge);
const cible = entierSaisi(saisie);
if (cible === cibleRedondance(charge)) throw refus('SANS_EFFET', { commande: 'changerCibleRedondance' });
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: [] };
}
// L'enregistrement de la table d'identifiant id qu'un objectif vise, ou
// TABLE_DISPARUE {table}, remède null : aucun geste ne rend une table
// supprimée.
@ -715,7 +692,6 @@ export const COMMANDES_MOBILIER = new Map([
['changerDefaut', changerDefaut],
['changerTours', changerTours],
['modifierReglage', modifierReglage],
['changerCibleRedondance', changerCibleRedondance],
['poserObjectif', poserObjectif],
['retirerObjectif', retirerObjectif],
['changerRegleObjectifs', changerRegleObjectifs],

View file

@ -24,7 +24,6 @@ import {
TOURS_MAX,
analyser,
capacite,
cibleRedondance,
configurationDepuisCharge,
creerCharge,
modeObjectifs,
@ -217,7 +216,7 @@ async function seanceSur(charge) {
// --- Les épreuves -----------------------------------------------------------
describe('mobilier : le registre (§ 8.2)', () => {
test('COMMANDES_MOBILIER porte les quatorze commandes du mobilier, réunies dans COMMANDES', () => {
test('COMMANDES_MOBILIER porte les treize commandes du mobilier, réunies dans COMMANDES', () => {
const noms = [
'creerTable',
'supprimerTable',
@ -227,7 +226,6 @@ describe('mobilier : le registre (§ 8.2)', () => {
'changerDefaut',
'changerTours',
'modifierReglage',
'changerCibleRedondance',
'poserObjectif',
'retirerObjectif',
'changerRegleObjectifs',
@ -250,7 +248,6 @@ describe('mobilier : le registre (§ 8.2)', () => {
['changerDefaut', { sieges: 6, realigner: false }],
['changerTours', { tours: 3 }],
['modifierReglage', { cle: 'nouveauxVoisins', valeur: false }],
['changerCibleRedondance', { cible: '1200' }],
['poserObjectif', { table: 1, tour: null, etiquettes: 'finance', regle: null }],
['retirerObjectif', { table: 1, tour: null }],
['changerRegleObjectifs', { regle: 'et' }],
@ -1151,57 +1148,23 @@ describe('mobilier : les réglages (§ 5.3, § 15.4)', () => {
});
});
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', () => {
describe('mobilier : la cible de la somme de redondance n’est plus un réglage (§ 8.9)', () => {
test('aucune commande ne la pose : ni changerCibleRedondance au registre, ni modifierReglage sous sa clé', () => {
assert.equal(COMMANDES.has('changerCibleRedondance'), false);
assert.equal(COMMANDES_MOBILIER.has('changerCibleRedondance'), false);
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 }));
assert.throws(() => COMMANDES.get('modifierReglage')(charge, { cle: 'cibleRedondance', valeur: 1200 }), TypeError);
});
test('une saisie négative, décimale ou vide est refusée NON_ENTIER {saisie}, remède null ; la même valeur, SANS_EFFET', () => {
test('une charge qui la porte encore se modifie par les autres commandes, et s’écrit sans elle', () => {
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);
});
test('une cible de 1000 écrite au fichier, saisie de nouveau à 1000 : SANS_EFFET, sans retirer le champ ni faire d’entrée', () => {
const explicite = chargeDe({ tables: [{ id: 1 }], n: 2 });
explicite.reglages.cibleRedondance = 1000;
analyser(serialiser(explicite, { revision: 1, produitVersion: V1 }));
refuse(() => appliquerCommande('changerCibleRedondance', explicite, { cible: '1000' }), 'SANS_EFFET', {
commande: 'changerCibleRedondance',
});
refuse(() => COMMANDES.get('changerCibleRedondance')(explicite, { cible: 1000 }), 'SANS_EFFET');
assert.equal(explicite.reglages.cibleRedondance, 1000);
const ancienne = structuredClone(charge);
ancienne.reglages.cibleRedondance = 1200;
const rendu = accepter('modifierReglage', ancienne, { cle: 'nouveauxVoisins', valeur: false });
const attendu = accepter('modifierReglage', charge, { cle: 'nouveauxVoisins', valeur: false });
const texte = serialiser(rendu.charge, { revision: 1, produitVersion: V1 });
assert.equal(texte.includes('cibleRedondance'), false);
assert.equal(texte, serialiser(attendu.charge, { revision: 1, produitVersion: V1 }));
});
});

View file

@ -25,8 +25,8 @@
// 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 la différence à la cible
// du retenu, lue dans la même mesure que sa ligne du tableau, sans le
// resumeDuRetenu donne à la liste des événements les collisions cumulées
// du retenu, lues 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. premiereDuClassement est la seule définition de la proposition
// au rang 1 : la section de la génération la lit dans sa table,
@ -63,13 +63,10 @@ import { borneDiversite, plafondsRealises } from '../moteur/plafond.js';
import { detailler, planchersParGroupe, profilRencontres } from '../moteur/qualite.js';
import { verifierIndicateurs, verifierInvariants } from '../moteur/verification.js';
import { ErreurConfiguration } from '../moteur/erreurs.js';
import { CIBLE_REDONDANCE_PAR_DEFAUT, cibleRedondance } from '../stockage/document.js';
import { derive, deriveSansRevalider, fautes, planDepuisFichier, planSansRevalider } from '../stockage/placements.js';
import { configurationDe } from './configuration.js';
import { nomAffiche } from './personnes.js';
export { CIBLE_REDONDANCE_PAR_DEFAUT, cibleRedondance };
// Les modes des objectifs, le défaut en tête, et les cases qui font compter
// chaque critère, tels que le registre les tient (contrainte 35) :
// l'interface, qui n'importe pas le moteur, les lit ici.
@ -220,16 +217,17 @@ export function evaluer(charge, { plafondsAPriori = 'calcul' } = {}) {
}
/**
* Le résumé du retenu que montre la liste des événements (§ 5.4, § 8.1,
* § 8.9) : diffRedondance, la valeur absolue de la cible de l'événement
* (cibleRedondance) moins la somme de redondance Σ r(p) du retenu, celle de
* mesurer, que la ligne du retenu d'evaluer porte aussi. 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.
* Le résumé du retenu que montre la liste des événements (§ 5.4, § 8.1) :
* collisionsCumulees, les occurrences (paire, tour) où deux personnes d'une
* même appartenance partagent une table, celles de mesurer — la valeur que
* la ligne du retenu d'evaluer porte, et que la colonne « Collisions
* cumulées (paire et tour) » du tableau des propositions écrit. 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 {{diffRedondance: number}|null}
* @returns {{collisionsCumulees: number}|null}
*/
export function resumeDuRetenu(charge) {
const { retenu } = charge;
@ -241,8 +239,8 @@ export function resumeDuRetenu(charge) {
if (erreur instanceof ErreurConfiguration) return null;
throw erreur;
}
const { totalRedondance } = mesurer(instance, indexerPlan(instance, planDepuisFichier(retenu)));
return { diffRedondance: Math.abs(cibleRedondance(charge) - totalRedondance) };
const { collisionsCumulees } = mesurer(instance, indexerPlan(instance, planDepuisFichier(retenu)));
return { collisionsCumulees };
}
/**

View file

@ -46,9 +46,8 @@ import { deriveSansRevalider, fautes, planDepuisFichier } from '../stockage/plac
import { VERSION } from '../version.genere.js';
import { appliquerCommande } from './commandes.js';
import { configurationDe } from './configuration.js';
import * as EVALUATION from './evaluation.js';
import {
CIBLE_REDONDANCE_PAR_DEFAUT,
cibleRedondance,
detaillerQualite,
evaluer,
premiereDuClassement,
@ -484,36 +483,32 @@ describe("évaluation : une configuration qui ne se tient pas", () => {
});
});
describe('resumeDuRetenu : la différence à la cible que lit la liste des événements (§ 5.4, § 8.1, § 8.9)', () => {
describe('resumeDuRetenu : les collisions cumulées du retenu que lit la liste des événements (§ 5.4, § 8.1)', () => {
// 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("la seule différence à la cible, 1000 par défaut, contre le Σ r(p) de la ligne du retenu d'evaluer", () => {
test("les seules collisions cumulées (paire et tour), celles de la ligne du retenu d'evaluer et de mesurer", () => {
const charge = figer(retenue());
const resume = resumeDuRetenu(charge);
const { mesures } = evaluer(charge).retenu;
assert.ok(mesures.totalRedondance !== 1000, 'la différence se distingue de zéro');
assert.deepEqual(resume, { diffRedondance: Math.abs(1000 - mesures.totalRedondance) });
assert.ok(mesures.collisionsCumulees > 0, 'le retenu porte des collisions');
assert.notEqual(mesures.collisionsCumulees, mesures.totalRedondance);
assert.deepEqual(resume, { collisionsCumulees: mesures.collisionsCumulees });
const instance = normaliser(configurationDe(charge));
const attendues = mesurer(instance, indexerPlan(instance, planDepuisFichier(charge.retenu)));
assert.equal(attendues.totalRedondance, mesures.totalRedondance);
assert.equal(CIBLE_REDONDANCE_PAR_DEFAUT, 1000);
assert.equal(cibleRedondance(charge), 1000);
assert.equal(attendues.collisionsCumulees, mesures.collisionsCumulees);
});
test("une cible propre à l'événement : la différence se lit contre elle, en valeur absolue, des deux côtés", () => {
test('aucune cible : le résumé ne dépend pas d’un champ cibleRedondance resté dans les réglages, et le module n’en exporte plus', () => {
const charge = retenue();
const total = evaluer(charge).retenu.mesures.totalRedondance;
for (const cible of [0, total, total + 7, 5_000]) {
const avecCible = executer(charge, 'changerCibleRedondance', { cible: String(cible) });
assert.equal(cibleRedondance(avecCible), cible);
assert.deepEqual(resumeDuRetenu(avecCible), { diffRedondance: Math.abs(cible - total) }, String(cible));
}
const dessous = executer(charge, 'changerCibleRedondance', { cible: '0' });
assert.equal(resumeDuRetenu(dessous).diffRedondance, total);
const avecChamp = structuredClone(charge);
avecChamp.reglages.cibleRedondance = 25;
assert.deepEqual(resumeDuRetenu(avecChamp), resumeDuRetenu(charge));
assert.equal(Object.hasOwn(EVALUATION, 'cibleRedondance'), false);
assert.equal(Object.hasOwn(EVALUATION, 'CIBLE_REDONDANCE_PAR_DEFAUT'), false);
});
test('sans retenu, null ; un retenu fautif ou en dérive, null, sans lever', () => {

View file

@ -639,7 +639,7 @@ describe('lister (§ 8.1, § 19.4)', () => {
return serialiser(modifier(charge), { revision: 3, produitVersion: V1 });
}
test('les mesures du retenu : la seule différence à la cible, celle de resumeDuRetenu ; null sans retenu ; rien d’écrit', async () => {
test('les mesures du retenu : ses seules collisions cumulées, 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);
@ -648,20 +648,21 @@ describe('lister (§ 8.1, § 19.4)', () => {
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, { diffRedondance: Math.abs(1000 - mesures.totalRedondance) });
assert.ok(mesures.collisionsCumulees > 0, 'le retenu porte des collisions');
assert.deepEqual(par.retenue.mesuresDuRetenu, { collisionsCumulees: mesures.collisionsCumulees });
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 () => {
test('un état écrit avec la cible de redondance d’un code antérieur se résume sans faute, comme sans 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 lu = JSON.parse(texteRetenu());
lu.charge.reglages.cibleRedondance = 25;
fs.deposer('portable', `retenue${SUFFIXES.etat}`, `${JSON.stringify(lu, null, 2)}\n`);
const ligne = (await listerResume(facade)).find(({ base }) => base === 'retenue');
const { totalRedondance } = evaluer(analyser(texte).charge).retenu.mesures;
assert.notEqual(Math.abs(25 - totalRedondance), Math.abs(1000 - totalRedondance));
assert.deepEqual(ligne.mesuresDuRetenu, { diffRedondance: Math.abs(25 - totalRedondance) });
assert.equal(ligne.illisible, null);
assert.deepEqual(ligne.mesuresDuRetenu, resumeDuRetenu(analyser(texteRetenu()).charge));
});
test('un retenu fautif, ou en dérive, et un état illisible : null, la liste entière rendue, rien d’écrit', async () => {
@ -894,15 +895,15 @@ describe('lister (§ 8.1, § 19.4)', () => {
const lignes = await facade.lister();
const recus = new Map();
const flux = facade.resumer(lignes, { ceder: pasDe(horloge), recevoir: (base, resume) => recus.set(base, resume) });
// La cible de l'événement passe à 25 après la liste : le résumé du pas
// la lit, et non celle que la liste a vue.
const plusTard = texteRetenu((charge) => appliquerCommande('changerCibleRedondance', charge, { cible: '25' }).charge);
// L'autre proposition devient le retenu après la liste : le résumé du
// pas lit ses collisions, et non celles du retenu que la liste a vu.
const plusTard = texteRetenu((charge) => appliquerCommande('retenirProposition', charge, { id: charge.propositions[0].id }).charge);
fs.deposer('portable', `retenue${SUFFIXES.etat}`, plusTard);
for (let pas = 0; pas < lignes.length; pas += 1) await unPas(horloge);
await flux.fin;
const { totalRedondance } = evaluer(analyser(plusTard).charge).retenu.mesures;
assert.notEqual(Math.abs(25 - totalRedondance), Math.abs(1000 - totalRedondance));
assert.equal(recus.get('retenue').mesuresDuRetenu.diffRedondance, Math.abs(25 - totalRedondance));
const { collisionsCumulees } = evaluer(analyser(plusTard).charge).retenu.mesures;
assert.notEqual(collisionsCumulees, evaluer(analyser(texteRetenu()).charge).retenu.mesures.collisionsCumulees);
assert.equal(recus.get('retenue').mesuresDuRetenu.collisionsCumulees, collisionsCumulees);
assert.deepEqual(recus.get('retenue'), resumesAttendus(plusTard));
});

View file

@ -578,7 +578,6 @@ 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 }],
// Objectifs des tables
['OBJECTIF_HORS_SOIREE', { tour: 5, remede: null }],
['OBJECTIF_SANS_ETIQUETTE', { table: 2, tour: 3, remede: { commande: 'retirerObjectif', arguments: { table: 2, tour: 3 } } }],

View file

@ -251,7 +251,6 @@ export const TABLE = new Map([
({ cle, valeur }) =>
`${nomDans(NOMS_DES_REGLAGES, cle, 'réglage')} : ${cle === 'modeObjectifs' ? nomMode(valeur) : valeurDeReglage(valeur)}`,
],
['ENTREE_CHANGER_CIBLE_REDONDANCE', ({ cible }) => `Cible de la somme de redondance : ${cible}`],
[
'ENTREE_POSER_OBJECTIF',
({ numero, tour, regle, etiquettes }) =>

View file

@ -400,14 +400,6 @@ 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' } },
},
],
[
'changerEtiquettes',
{

View file

@ -1049,6 +1049,12 @@ export function creerSeance({
// Un retour, sans son suivi : défaire vise le prédécesseur de l'instant
// courant sur le fil, refaire le sommet de sa pile, revenir l'instant
// donné ; l'entrée porte la charge restituée, son sens et sa cible.
// Revenir à un instant de même contenu que la charge courante est
// SANS_EFFET. Défaire et refaire s'écrivent même alors, d'un correctif
// vide : leur entrée seule fait avancer le fil. Un instant dont le geste ne
// changeait qu'un champ oublié à la lecture (§ 8.9) se restitue comme son
// voisin ; le refuser laisserait le fil sur place, et chaque nouveau
// défaire retomberait sur le même refus.
async function retour(sens, visee) {
const ouvert = exigerEcriture();
const { cibleDefaire, cibleRefaire, courant } = filDe(ouvert);
@ -1062,7 +1068,9 @@ export function creerSeance({
cible = cibleRefaire;
}
const charge = avecCompteursCourants(restituer(ouvert, cible), ouvert.charge);
if (serialiserCharge(charge) === serialiserCharge(ouvert.charge)) throw new ErreurSeance('SANS_EFFET', { sens });
if (sens === 'revenir' && serialiserCharge(charge) === serialiserCharge(ouvert.charge)) {
throw new ErreurSeance('SANS_EFFET', { sens });
}
const instant = sens === 'defaire' ? courant : libelleDeRevision(ouvert.journal, cible);
const entree = { charge, libelle: libelleDuRetour(sens, instant), avertissements: [] };
const texte = await ecrireGeste(ouvert, entree, { sens, retour: cible });

View file

@ -10,6 +10,7 @@
// se remplace par un autre dossier. Les noms d'épreuve sont inventés ; la
// version vient du module engendré.
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { PanneSimulee, creerFichiersSimules } from '../../test/fichiers_simules.js';
import { H_RETENU, personne as personneBadge, soiree, table as tableBadge } from '../../test/fixtures/badges/cas.js';
@ -18,7 +19,7 @@ import { lirePolices } from '../../test/polices.js';
import { apercevoir } from '../csv/apercu.js';
import { nombrePlacesManquantes, normaliser } from '../moteur/configuration.js';
import { rechercher } from '../moteur/recherche.js';
import { serialiser } from '../stockage/canonique.js';
import { serialiser, serialiserCharge } from '../stockage/canonique.js';
import { creer as creerDansLeDepot, ouvrir as ouvrirDansLeDepot } from '../stockage/depot.js';
import { FORMAT, analyser, configurationDepuisCharge, creerCharge, etatDeduit } from '../stockage/document.js';
import { ErreurStockage } from '../stockage/erreurs.js';
@ -1522,6 +1523,92 @@ describe('séance : revenir, défaire, refaire (§ 8.3)', () => {
});
});
// Un événement écrit par le code du format 1 (test/fixtures/stockage/format1) :
// son journal pose la cible de redondance à la révision 3 et la retire à la
// 5. Ce code oublie ce réglage (§ 8.9) : la révision 3 se restitue comme la
// 2, la 4 comme la 5. ATTENDUES porte, par révision, l'empreinte de la charge
// que ce code restitue ; empreintes.json, celle que restituait le format 1.
const FORMAT1 = new URL('../../test/fixtures/stockage/format1/', import.meta.url);
const FORMAT1_EMPREINTES = JSON.parse(readFileSync(new URL('empreintes.json', FORMAT1), 'utf8'));
const FORMAT1_ATTENDUES = {
...FORMAT1_EMPREINTES.revisions,
3: FORMAT1_EMPREINTES.revisions[2],
4: FORMAT1_EMPREINTES.revisions[5],
};
const empreinte = (charge) => createHash('sha256').update(serialiserCharge(charge), 'utf8').digest('hex');
describe('séance : défaire et refaire sur un journal du format 1 qui porte la cible de redondance (§ 8.3, § 8.9)', () => {
// Une séance en écriture sur une copie de la fixture déposée dans le
// système d'épreuve, et le journal de la fixture, lu tel quel.
async function surLaFixture() {
const fs = creerFichiersSimules();
fs.deposerDossier('documents');
const base = FORMAT1_EMPREINTES.base;
for (const suffixe of [SUFFIXES.journal, SUFFIXES.etat, SUFFIXES.precedent]) {
fs.deposer('documents', `${base}${suffixe}`, readFileSync(new URL(`${base}${suffixe}`, FORMAT1), 'utf8'));
}
const montage = monter({ fs });
await ouvrirSansEcrire(montage, base);
await montage.seance.passerEnEcriture();
const origine = lireJournal(readFileSync(new URL(`${base}${SUFFIXES.journal}`, FORMAT1), 'utf8'));
return { ...montage, base, origine };
}
// La charge courante a le contenu de l'instant revision : son empreinte, les
// compteurs d'identifiants ramenés à ceux de l'instant, est l'attendue — un
// retour garde les compteurs courants (§ 8.9).
function aLeContenuDe(seance, origine, revision) {
const instant = reconstruire(origine, revision);
assert.equal(empreinte({ ...seance.charge, prochainsIds: instant.prochainsIds }), FORMAT1_ATTENDUES[revision], `révision ${revision}`);
}
test("défaire remonte chaque instant jusqu'à la création, celui qui ne changeait que la cible compris ; refaire redescend de même jusqu'à la dernière", async () => {
const { fs, seance, base, origine } = await surLaFixture();
const derniereDe = () => journalDe(fs, 'documents', base).entrees.at(-1);
const defaits = [];
while (seance.retours.defaire) {
const { libelle: texte } = await seance.defaire();
const { sens, retour } = derniereDe();
assert.equal(sens, 'defaire');
aLeContenuDe(seance, origine, retour);
defaits.push([retour, texte]);
}
assert.deepEqual(defaits, [
[5, 'Défait : « Note »'],
[4, 'Défait : « Cible par défaut »'],
[3, 'Défait : « Réservation »'],
[2, 'Défait : « Cible de redondance »'],
[1, 'Défait : « Inscription de Brunelle »'],
]);
await refus(seance.defaire(), 'RIEN_A_DEFAIRE');
const refaits = [];
while (seance.retours.refaire) {
const { libelle: texte } = await seance.refaire();
const { sens, retour } = derniereDe();
assert.equal(sens, 'refaire');
aLeContenuDe(seance, origine, retour);
refaits.push([retour, texte]);
}
assert.deepEqual(refaits, [
[2, 'Refait : « Inscription de Brunelle »'],
[3, 'Refait : « Cible de redondance »'],
[4, 'Refait : « Réservation »'],
[5, 'Refait : « Cible par défaut »'],
[6, 'Refait : « Note »'],
]);
assert.equal(empreinte(seance.charge), FORMAT1_EMPREINTES.revisions[6]);
assert.equal(fs.contenu('documents', `${base}${SUFFIXES.etat}`).includes('cibleRedondance'), false);
});
test("revenir à un instant de même contenu que l'instant courant reste SANS_EFFET, sans écriture", async () => {
const { fs, seance } = await surLaFixture();
await seance.defaire();
const avant = fs.compteurs.ecritures;
await refus(seance.revenir(4), 'SANS_EFFET', { sens: 'revenir', remede: null });
assert.equal(fs.compteurs.ecritures, avant);
});
});
describe('séance : les jalons automatiques (§ 8.3)', () => {
test("la création, chaque changement d'état, et l'instant qui précède chaque génération sont nommés", async () => {
const fs = creerFichiersSimules();

View file

@ -29,7 +29,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { cdp } from 'vitest/browser';
import { flushSync } from 'svelte';
import { appliquerCommande } from '../application/commandes.js';
import { cibleRedondance, evaluer } from '../application/evaluation.js';
import { evaluer } from '../application/evaluation.js';
import { creerFormat } from '../application/format.js';
import { TABLES_APPLICATION } from '../application/libelles.js';
import { creerTraduction } from '../application/traduction.js';
@ -394,25 +394,18 @@ describe('la liste relue', () => {
describe('les résumés de la liste relue (§ 8.1)', () => {
// La petite démonstration au conflit inévitable, deux propositions du
// moteur, la seconde retenue, sous la cible de redondance donnée ; la
// cible par défaut n'a pas de réglage écrit.
function texteRetenu(cible) {
// moteur, celle du rang donné retenue.
function texteRetenu(rang) {
let charge = analyser(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;
charge = appliquerCommande('retenirProposition', charge, { id: charge.propositions[1].id }).charge;
if (cible !== cibleRedondance(charge)) {
charge = appliquerCommande('changerCibleRedondance', charge, { cible: String(cible) }).charge;
}
charge = appliquerCommande('retenirProposition', charge, { id: charge.propositions[rang].id }).charge;
return serialiser(charge, { revision: 1, produitVersion: VERSION.affichee });
}
// La différence à la cible que la ligne d'un texte d'état écrit.
const difference = (texte) => {
const charge = analyser(texte).charge;
return format.nombre(Math.abs(cibleRedondance(charge) - evaluer(charge).retenu.mesures.totalRedondance));
};
const cellule = (m) => dans(m, '[data-evenement="bal"] [data-colonne="diffRedondance"]');
// Les collisions cumulées du retenu que la ligne d'un texte d'état écrit.
const collisions = (texte) => format.nombre(evaluer(analyser(texte).charge).retenu.mesures.collisionsCumulees);
const cellule = (m) => dans(m, '[data-evenement="bal"] [data-colonne="collisionsCumulees"]');
const uneImage = () => new Promise((resoudre) => requestAnimationFrame(() => resoudre()));
// Retient la prochaine lecture de l'état de « bal » : elle lit le disque à
@ -436,9 +429,9 @@ describe('les résumés de la liste relue (§ 8.1)', () => {
}
test('une relecture pendant le chargement : la ligne ne montre jamais la valeur de l’ancienne lecture, même finie la dernière', async () => {
const ancien = texteRetenu(1000);
const nouveau = texteRetenu(25);
expect(difference(ancien)).not.toBe(difference(nouveau));
const ancien = texteRetenu(1);
const nouveau = texteRetenu(0);
expect(collisions(ancien)).not.toBe(collisions(nouveau));
const m = await monter({ fichiers: [{ racine: 'documents', chemin: 'bal.gtt.json', texte: ancien }] });
const retenue = retenirLecture(m);
// Le premier pas de la première lecture lit l'état, et reste suspendu.
@ -455,7 +448,7 @@ describe('les résumés de la liste relue (§ 8.1)', () => {
await uneImage();
m.horloge.avancer(0);
flushSync();
expect(cellule(m).textContent).toBe(difference(nouveau));
expect(cellule(m).textContent).toBe(collisions(nouveau));
});
// La lecture d'avant s'achève : son calcul ne se pose pas.
retenue.liberer();
@ -463,13 +456,13 @@ describe('les résumés de la liste relue (§ 8.1)', () => {
for (let image = 0; image < 3; image += 1) await uneImage();
m.horloge.avancer(0);
flushSync();
expect(cellule(m).textContent).toBe(difference(nouveau));
expect(cellule(m).textContent).toBe(collisions(nouveau));
});
test('un événement ouvert pendant le chargement l’abandonne : l’horloge avancée ne relit plus aucun état', async () => {
const m = await monter({
fichiers: [
{ racine: 'documents', chemin: 'bal.gtt.json', texte: texteRetenu(1000) },
{ racine: 'documents', chemin: 'bal.gtt.json', texte: texteRetenu(1) },
{ racine: 'documents', chemin: 'club.gtt.json', texte: evenement('Club des Merles', { texte: CONFLIT }) },
],
});
@ -492,7 +485,7 @@ describe('les résumés de la liste relue (§ 8.1)', () => {
test('une lecture de la liste qui s’achève sous un événement ouvert ne lance aucun chargement', async () => {
const m = await monter({
fichiers: [
{ racine: 'documents', chemin: 'bal.gtt.json', texte: texteRetenu(1000) },
{ racine: 'documents', chemin: 'bal.gtt.json', texte: texteRetenu(1) },
{ racine: 'documents', chemin: 'club.gtt.json', texte: evenement('Club des Merles', { texte: CONFLIT }) },
],
});

View file

@ -1231,16 +1231,18 @@ async function eprouverTermes(racine, cas) {
}
}
describe('les mesures du retenu dans la liste (§ 5.4, § 8.1, § 8.9)', () => {
describe('les mesures du retenu dans la liste (§ 5.4, § 8.1)', () => {
// 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)));
}
test('un événement retenu : |1000 \u{2212} Σ r(p)|, sans colonne des collisions cumulées ; sans retenu : « — » ; rien d’écrit', async () => {
test('un événement retenu : ses collisions cumulées (paire et tour), sous l’intitulé du tableau des propositions ; sans retenu : « — » ; rien d’écrit', async () => {
const charge = chargeRetenue();
const mesures = mesuresDe(charge);
expect(mesures.collisionsCumulees).toBeGreaterThan(0);
expect(mesures.collisionsCumulees).not.toBe(Math.abs(1000 - mesures.totalRedondance));
const m = await monter({
fichiers: [fichier(`retenue${SUFFIXES.etat}`, etat(charge)), fichier(`club${SUFFIXES.etat}`, texteRenomme('Club des Merles'))],
});
@ -1248,51 +1250,44 @@ describe('les mesures du retenu dans la liste (§ 5.4, § 8.1, § 8.9)', () => {
await chargerResumes(m);
const avant = ecritures(m);
const retenue = ligneDe(m, 'retenue');
expect(colonne(retenue, 'diffRedondance')).toBe(format.nombre(Math.abs(1000 - mesures.totalRedondance)));
expect(colonne(retenue, 'collisionsCumulees')).toBe(format.nombre(mesures.collisionsCumulees));
expect(colonne(retenue, 'collisionsCumulees')).toBe(format.nombre(evaluer(charge).retenu.mesures.collisionsCumulees));
const club = ligneDe(m, 'club');
expect(colonne(club, 'diffRedondance')).toBe(TIRET);
expect(colonne(club, 'collisionsCumulees')).toBe(TIRET);
// L'intitulé est un en-tête de colonne, une seule fois pour toute la
// liste ; aucune colonne ni aucun intitulé ne nomme les collisions
// cumulées.
expect(tous(m, '[data-terme="diffRedondance"]').length).toBe(1);
expect(terme(accueil(m), 'diffRedondance').closest('thead')).not.toBeNull();
expect(terme(accueil(m), 'diffRedondance').textContent).toBe(t('accueil.colonne.diffRedondance'));
expect(tous(m, '[data-colonne="collisionsCumulees"], [data-terme="collisionsCumulees"]')).toEqual([]);
expect(tous(m, '[data-ecran="accueil"] thead th').filter((th) => th.textContent.includes(t('propositions.colonne.collisionsCumulees')))).toEqual([]);
expect(visible(retenue.querySelector('[data-colonne="diffRedondance"]'))).toBe(true);
// liste, au texte de la colonne du tableau des propositions ; plus rien
// ne nomme la différence à la cible.
expect(tous(m, '[data-terme="collisionsCumulees"]').length).toBe(1);
expect(terme(accueil(m), 'collisionsCumulees').closest('thead')).not.toBeNull();
expect(terme(accueil(m), 'collisionsCumulees').textContent).toBe(t('propositions.colonne.collisionsCumulees'));
expect(tous(m, '[data-colonne="diffRedondance"], [data-terme="diffRedondance"]')).toEqual([]);
expect(accueil(m).textContent).not.toContain("Diff somme d'appartenance");
expect(visible(retenue.querySelector('[data-colonne="collisionsCumulees"]'))).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));
await eprouverTermes(accueil(m), [['diffRedondance', 'accueil.explication.diffRedondance']]);
await eprouverTermes(accueil(m), [['collisionsCumulees', 'accueil.explication.collisionsCumulees']]);
});
test('la cible changée dans les propositions, puis la liste : la différence se lit contre la nouvelle cible', async () => {
test('un état écrit avec la cible de redondance d’un code antérieur : la ligne se lit sans signalement, ses collisions comme sans elle ; l’ouvrir n’écrit rien', async () => {
const charge = chargeRetenue();
const { totalRedondance } = mesuresDe(charge);
const m = await monter({ fichiers: [fichier(`retenue${SUFFIXES.etat}`, etat(charge))] });
const lu = JSON.parse(etat(charge));
lu.charge.reglages.cibleRedondance = 25;
const m = await monter({ fichiers: [fichier(`retenue${SUFFIXES.etat}`, `${JSON.stringify(lu, null, 2)}\n`)] });
await vi.waitFor(() => expect(lignes(m).length).toBe(1));
await chargerResumes(m);
expect(colonne(ligneDe(m, 'retenue'), 'diffRedondance')).toBe(format.nombre(Math.abs(1000 - totalRedondance)));
const ligne = ligneDe(m, 'retenue');
expect(ligne.querySelector('[data-signalement]')).toBeNull();
expect(colonne(ligne, 'collisionsCumulees')).toBe(format.nombre(mesuresDe(charge).collisionsCumulees));
const avant = ecritures(m);
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));
// Le résumé que la liste relue reçoit, non celui de la lecture d'avant,
// que la ligne montre encore tant que la relecture ne s'est pas posée.
const resumeRecu = suivreResumes(m);
await revenirALaListe(m, 1);
await resumeRecu('retenue');
expect(colonne(ligneDe(m, 'retenue'), 'diffRedondance')).toBe(format.nombre(Math.abs(25 - totalRedondance)));
expect(Object.hasOwn(m.seance.etat().charge.reglages, 'cibleRedondance')).toBe(false);
expect(m.seance.etat().avertissements ?? []).toEqual([]);
expect(ecritures(m)).toBe(avant);
});
});
@ -1452,14 +1447,14 @@ describe('le chargement des résumés de la liste (§ 8.1, § 14.5, § 19.2)', (
}
// Les deux valeurs de résumé d'une ligne, dans l'ordre du rendu.
const CELLULES = ['[data-colonne="diffRedondance"]', '[data-meilleure-id]'];
const CELLULES = ['[data-colonne="collisionsCumulees"]', '[data-meilleure-id]'];
const cellules = (ligne) => CELLULES.map((selecteur) => ligne.querySelector(selecteur));
// Ce que la ligne d'une charge écrit, par la table d'évaluation sollicitée
// une seconde fois (§ 19.1).
function attendues(charge) {
const table = evaluer(charge);
const [premiere] = table.propositions;
return [Math.abs(1000 - table.retenu.mesures.totalRedondance), premiere.id].map((n) => format.nombre(n));
return [table.retenu.mesures.collisionsCumulees, premiere.id].map((n) => format.nombre(n));
}
// Chaque valeur de la ligne attend : la cellule se dit occupée, porte un
// seul signe, muet pour l'accessibilité (aria-hidden, sans rôle ni nom),
@ -1812,7 +1807,7 @@ describe('la liste en tableau : en-tête, tuiles, groupes et cartes (§ 8.1, §
};
const ligne = ligneDe(m, 'gala');
for (const nom of ['nom', 'etat', 'participants', 'modification']) expect(dans2(ligne.querySelector(`[data-colonne="${nom}"]`), entetes[0]), nom).toBe(true);
expect(dans2(ligne.querySelector('[data-colonne="diffRedondance"]'), entetes[1])).toBe(true);
expect(dans2(ligne.querySelector('[data-colonne="collisionsCumulees"]'), entetes[1])).toBe(true);
expect(dans2(ligne.querySelector('[data-meilleure-id]'), entetes[2])).toBe(true);
// Le nom est l'en-tête de sa ligne ; la date et le chemin le suivent,
// dessous, en plus petit.

View file

@ -14,7 +14,7 @@
//
// Les en-têtes de colonnes sont groupés : l'événement — nom, état,
// participants, dernière modification —, le placement retenu, à la teinte
// de Retenu — sa différence à la cible (COLONNES_DU_RETENU) —, la
// de Retenu — ses collisions cumulées (COLONNES_DU_RETENU) —, la
// meilleure proposition du classement, à la teinte de Proposé, sous le
// titre et le libellé du bloc de la génération (MEILLEURE) — son
// identifiant —, puis les actions. Chaque valeur est dans son propre

View file

@ -4,8 +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, evenements.js) devient ses six
// informations et ses signalements, mis en forme, puis, son résumé reçu, la
// différence à la cible de son retenu et l'identifiant de la meilleure
// informations et ses signalements, mis en forme, puis, son résumé reçu, les
// collisions cumulées de son retenu et l'identifiant de la meilleure
// proposition de son classement ; les lignes de la liste, les tuiles de ses
// filtres par état du plan, le résumé de son en-tête et les groupes de son
// tableau ; une question du dossier de travail, ses réponses ; un état
@ -25,17 +25,20 @@ const CLES_ETATS = new Map([
export const ETATS = Object.freeze([...CLES_ETATS.keys()]);
/**
* La mesure du placement retenu qu'une ligne montre (§ 5.4, § 8.9) : la
* différence à la cible, { colonne, cle, explication } — le nom de sa valeur
* La mesure du placement retenu qu'une ligne montre (§ 5.4, § 8.1) : ses
* collisions cumulées, { 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. Les collisions
* cumulées du retenu ne se montrent pas dans la liste.
* la colonne du tableau des propositions qui porte la même mesure, et celle
* de son explication, propre à la liste, que l'infobulle de l'intitulé
* montre.
*/
export const COLONNES_DU_RETENU = Object.freeze(
['diffRedondance'].map((colonne) =>
Object.freeze({ colonne, cle: `accueil.colonne.${colonne}`, explication: `accueil.explication.${colonne}` }),
),
);
export const COLONNES_DU_RETENU = Object.freeze([
Object.freeze({
colonne: 'collisionsCumulees',
cle: 'propositions.colonne.collisionsCumulees',
explication: 'accueil.explication.collisionsCumulees',
}),
]);
/**
* Les clés de la meilleure proposition du classement qu'une ligne montre
@ -127,7 +130,7 @@ export function signalementsDe(ligne) {
}
/**
* Les six informations d'une ligne (§ 8.1), la différence à la cible de son
* Les six informations d'une ligne (§ 8.1), les collisions cumulées de son
* retenu et l'identifiant de la meilleure proposition de son classement,
* mis 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,
@ -138,21 +141,20 @@ export function signalementsDe(ligne) {
* l'affiche, en entier.
*
* Les deux valeurs de résumé lisent resume, ce que facade.resumer a rendu
* pour la ligne : diffRedondance, celle de mesuresDuRetenu, par format, la
* valeur absente sans retenu mesurable — elle se lit telle quelle, jamais
* refaite ; meilleure.id, l'identifiant de meilleureDuClassement, par
* format, la valeur absente sans proposition classée. Sans resume, la
* ligne est enAttente : son résumé se calcule encore, et les deux valeurs
* valent la valeur absente, que le composant remplace par le signe du
* chargement.
* pour la ligne : collisionsCumulees, celles de mesuresDuRetenu, par format,
* la valeur absente sans retenu mesurable — elles se lisent telles quelles,
* jamais refaites ; meilleure.id, l'identifiant de meilleureDuClassement, par
* format, la valeur absente sans proposition classée. Sans resume, la ligne
* est enAttente : son résumé se calcule encore, et les deux valeurs valent la
* valeur absente, que le composant remplace par le signe du chargement.
*
* @param {Object} ligne LigneListe (evenements.js)
* @param {ReturnType<import('../../application/format.js').creerFormat>} format
* @param {{mesuresDuRetenu: {diffRedondance: number}|null,
* @param {{mesuresDuRetenu: {collisionsCumulees: number}|null,
* meilleureDuClassement: {id: number}|null}} [resume]
* @returns {{nom: string, date: string, etat: string, participants: string,
* modification: string, chemin: string, enAttente: boolean,
* diffRedondance: string, meilleure: {id: string},
* collisionsCumulees: string, meilleure: {id: string},
* signalements: Array<{cle: string, details: Object, noms: string[]}>}}
*/
export function ligneAffichee(ligne, format, resume) {
@ -164,7 +166,7 @@ export function ligneAffichee(ligne, format, resume) {
modification: ligne.derniereModification === null ? format.nombre(null) : format.horodatage(ligne.derniereModification),
chemin: ligne.chemin,
enAttente: resume === undefined,
diffRedondance: format.nombre(resume?.mesuresDuRetenu?.diffRedondance ?? null),
collisionsCumulees: format.nombre(resume?.mesuresDuRetenu?.collisionsCumulees ?? null),
meilleure: { id: format.nombre(resume?.meilleureDuClassement?.id ?? null) },
signalements: signalementsDe(ligne),
};

View file

@ -80,8 +80,8 @@ describe('ligneAffichee : les six informations (§ 8.1)', () => {
const affichee = ligneAffichee(ligne(), format);
assert.deepEqual(Object.keys(affichee).sort(), [
'chemin',
'collisionsCumulees',
'date',
'diffRedondance',
'enAttente',
'etat',
'meilleure',
@ -334,35 +334,39 @@ describe('saisieDe : la saisie d’un nom pour copier ou renommer (§ 8.7)', ()
});
});
describe('ligneAffichee : les mesures du retenu (§ 5.4, § 8.1, § 8.9)', () => {
test('la différence à la cible, celle de la façade, mise en forme, sans calcul ici', () => {
for (const diffRedondance of [500, 7, 0]) {
const affichee = ligneAffichee(ligne(), format, { ...AUCUN, mesuresDuRetenu: { diffRedondance } });
assert.equal(affichee.diffRedondance, format.nombre(diffRedondance), String(diffRedondance));
describe('ligneAffichee : les mesures du retenu (§ 5.4, § 8.1)', () => {
test('les collisions cumulées du retenu, celles de la façade, mises en forme, sans calcul ici', () => {
for (const collisionsCumulees of [500, 7, 0]) {
const affichee = ligneAffichee(ligne(), format, { ...AUCUN, mesuresDuRetenu: { collisionsCumulees } });
assert.equal(affichee.collisionsCumulees, format.nombre(collisionsCumulees), String(collisionsCumulees));
}
});
test('sans retenu mesurable, et pour une ligne illisible, la différence vaut « — »', () => {
test('sans retenu mesurable, et pour une ligne illisible, les collisions valent « — »', () => {
for (const champs of [{}, { nom: null, etat: null, participants: null, illisible: { raison: 'VIDE', chemin: null } }]) {
const affichee = ligneAffichee(ligne(champs), format, AUCUN);
assert.equal(affichee.diffRedondance, VIDE);
assert.equal(affichee.collisionsCumulees, VIDE);
}
});
test('COLONNES_DU_RETENU : la seule différence à la cible, avec son intitulé et son explication, qui existent ; aucune clé des collisions cumulées dans la liste', () => {
assert.deepEqual(
COLONNES_DU_RETENU.map(({ colonne }) => colonne),
['diffRedondance'],
);
test('COLONNES_DU_RETENU : les seules collisions cumulées, sous l’intitulé de la colonne du tableau des propositions, avec l’explication propre à la liste ; plus rien de la différence à la cible', () => {
assert.deepEqual(COLONNES_DU_RETENU, [
{
colonne: 'collisionsCumulees',
cle: 'propositions.colonne.collisionsCumulees',
explication: 'accueil.explication.collisionsCumulees',
},
]);
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.ok(Object.isFrozen(COLONNES_DU_RETENU[0]));
assert.equal(t('propositions.colonne.collisionsCumulees'), 'Collisions cumulées (paire et tour)');
const explication = t('accueil.explication.collisionsCumulees');
for (const fragment of ['paire', 'tour', 'Collisions cumulées (paire et tour)', '\u{2014}']) {
assert.ok(explication.includes(fragment), fragment);
}
for (const cle of ['accueil.colonne.diffRedondance', 'accueil.explication.diffRedondance', 'accueil.colonne.collisionsCumulees']) {
assert.throws(() => t(cle), RangeError, cle);
}
assert.equal(t('accueil.colonne.diffRedondance'), "Diff somme d'appartenance");
for (const cle of ['accueil.colonne.collisionsCumulees', 'accueil.explication.collisionsCumulees']) assert.throws(() => t(cle), RangeError, cle);
});
});
@ -370,20 +374,20 @@ describe('ligneAffichee : les résumés en attente (§ 8.1)', () => {
test('sans résumé reçu, la ligne est en attente et ses deux valeurs de résumé ne disent rien ; reçu, elle ne l’est plus', () => {
const attente = ligneAffichee(ligne(), format);
assert.equal(attente.enAttente, true);
assert.equal(attente.diffRedondance, VIDE);
assert.equal(attente.collisionsCumulees, VIDE);
assert.deepEqual(attente.meilleure, { id: VIDE });
assert.equal(ligneAffichee(ligne(), format, AUCUN).enAttente, false);
const recu = ligneAffichee(ligne(), format, {
mesuresDuRetenu: { diffRedondance: 10 },
mesuresDuRetenu: { collisionsCumulees: 10 },
meilleureDuClassement: { id: 3 },
});
assert.equal(recu.enAttente, false);
assert.equal(recu.diffRedondance, format.nombre(10));
assert.equal(recu.collisionsCumulees, format.nombre(10));
assert.deepEqual(recu.meilleure, { id: format.nombre(3) });
});
test('les six informations ne dépendent pas des résumés : en attente ou non, elles sont les mêmes', () => {
const sans = ({ enAttente, diffRedondance, meilleure, ...reste }) => reste;
const sans = ({ enAttente, collisionsCumulees, meilleure, ...reste }) => reste;
assert.deepEqual(sans(ligneAffichee(ligne(), format)), sans(ligneAffichee(ligne(), format, AUCUN)));
});
});

View file

@ -130,10 +130,10 @@ const entree = (cible, cle) => cible.querySelector(`input[data-champ="${cle}"]`)
const libelleDe = (cible, cle) => cible.querySelector(`label[for="${entree(cible, cle).id}"]`).textContent.trim();
describe('Reglages : l’écran montre la définition (§ 19.8)', () => {
test('la définition livrée : la graine, chaque entrée de la génération dans son ordre, sous son libellé, à la valeur de la charge, puis les cœurs ; la cible des propositions n’y est pas', async () => {
test('la définition livrée : la graine, chaque entrée de la génération dans son ordre, sous son libellé, à la valeur de la charge, puis les cœurs ; aucune cible de redondance', async () => {
const cible = await monter();
expect(DE_LA_GENERATION.length).toBeGreaterThan(0);
expect(DE_LA_GENERATION.length).toBeLessThan(REGLAGES_GENERATION.length);
expect(DE_LA_GENERATION.length).toBe(REGLAGES_GENERATION.length);
expect(designations(cible)).toEqual(['graine', ...designationsAttendues(REGLAGES_GENERATION), 'coeursAutomatiques']);
expect(cible.querySelector('[data-champ="cibleRedondance"]')).toBeNull();
for (const { cle, etiquette, genre, defaut, valeurs } of DE_LA_GENERATION) {

View file

@ -16,8 +16,7 @@
// generation.<nom> pour un champ de la génération, <nom> sinon ;
// etiquette la clé du libellé, qui porte l'unité pour un champ ;
// unite ce qu'un champ compte, 'propositions' ou 'mouvements' ; null
// pour une bascule, un choix, l'ordre, et pour la cible, une
// somme sans unité ;
// pour une bascule, un choix, l'ordre ;
// defaut la valeur d'une charge neuve, ou celle d'un réglage facultatif
// que la charge ne porte pas ;
// genre 'champ', une saisie entière ; 'bascule', une case à cocher ;
@ -25,9 +24,7 @@
// l'ordre des priorités, une liste de paliers que son éditeur
// règle (Priorites.svelte) ;
// section la section de l'écran qui le montre : 'generation', écrit par
// modifierReglage — l'ordre par changerPriorites —, ou
// 'propositions', la cible de la somme de redondance, écrite par
// changerCibleRedondance ;
// modifierReglage — l'ordre par changerPriorites — ;
// valeurs les valeurs d'un choix, dans l'ordre de ses boutons ; le
// libellé de chacune est <etiquette>.<valeur>, son explication
// <etiquette>.<valeur>.explication ;
@ -40,23 +37,22 @@
//
// Les défauts se lisent sur la charge d'un événement neuf, telle que la
// commande de création la pose (chargeNeuve) : creerCharge y copie
// GENERATION_PAR_DEFAUT et y écrit les bascules, et l'interface n'importe
// pas le stockage. La charge lue ici n'est écrite nulle part. Les réglages
// facultatifs n'y sont pas : la cible a pour défaut
// CIBLE_REDONDANCE_PAR_DEFAUT, l'ordre des priorités LISTE_PAR_DEFAUT, lue
// dans le registre par l'application (priorites.js). Le mode prend ses
// valeurs et son défaut, le premier, dans MODES_OBJECTIFS, et son critère
// dans CASES, celui que sa case fait compter : le registre, que
// GENERATION_PAR_DEFAUT et y écrit les bascules, et l'interface n'importe pas
// le stockage. La charge lue ici n'est écrite nulle part. Les réglages
// facultatifs n'y sont pas : l'ordre des priorités a pour défaut
// LISTE_PAR_DEFAUT, lue dans le registre par l'application (priorites.js). Le
// mode prend ses valeurs et son défaut, le premier, dans MODES_OBJECTIFS, et
// son critère dans CASES, celui que sa case fait compter : le registre, que
// l'application réexporte (contrainte 35). Les deux cases portent ici le
// défaut que le stockage leur lit (§ 8.9), et les épreuves de
// reglages.test.js les comparent à ses lecteurs.
import { chargeNeuve } from '../../application/commandes.js';
import { CASES, CIBLE_REDONDANCE_PAR_DEFAUT, MODES_OBJECTIFS } from '../../application/evaluation.js';
import { CASES, MODES_OBJECTIFS } from '../../application/evaluation.js';
import { SIEGES_PAR_DEFAUT, TOURS_PAR_DEFAUT } from '../../application/evenements.js';
import { LISTE_PAR_DEFAUT } from './priorites.js';
/** @typedef {'champ'|'bascule'|'choix'|'ordre'} GenreReglage */
/** @typedef {'generation'|'propositions'} SectionReglage */
/** @typedef {'generation'} SectionReglage */
/**
* @typedef {Readonly<{nom: string, cle: string, etiquette: string, unite: 'propositions'|'mouvements'|null,
* defaut: number|boolean|string|ReadonlyArray<ReadonlyArray<{critere: string, poids: number}>>,
@ -126,22 +122,11 @@ const PRIORITES = Object.freeze({
section: 'generation',
});
const CIBLE = Object.freeze({
nom: 'cibleRedondance',
cle: 'cibleRedondance',
etiquette: 'propositions.cible.etiquette',
unite: null,
defaut: CIBLE_REDONDANCE_PAR_DEFAUT,
genre: 'champ',
section: 'propositions',
});
/**
* Les réglages de la charge, dans l'ordre des écrans : les trois champs de
* la recherche (§ 5.7, § 5.10), puis les six contraintes et l'attribution
* des sièges, dans l'ordre du § 5.3 ; le mode des objectifs, que l'écran
* montre sous sa case ; l'ordre des priorités ; enfin la cible de la somme
* de redondance (§ 8.9), de la section des propositions.
* montre sous sa case ; enfin l'ordre des priorités.
*
* @type {ReadonlyArray<EntreeReglage>}
*/
@ -158,7 +143,6 @@ export const REGLAGES_GENERATION = Object.freeze([
bascule('attribuerSieges'),
MODE_OBJECTIFS,
PRIORITES,
CIBLE,
]);
/**

View file

@ -3,8 +3,8 @@
// Épreuves de la définition des réglages de la charge (§ 5.3, § 5.7,
// § 5.10, § 8.9, § 19.8) : sa forme, figée ; l'ordre des écrans ; les
// défauts, ceux d'une charge neuve, GENERATION_PAR_DEFAUT et
// CIBLE_REDONDANCE_PAR_DEFAUT compris, et ceux que les lecteurs du stockage
// défauts, ceux d'une charge neuve, GENERATION_PAR_DEFAUT compris, et ceux
// que les lecteurs du stockage
// donnent aux réglages facultatifs — les deux cases, le mode des objectifs,
// l'ordre des priorités ; chaque réglage du schéma du fichier, facultatif
// compris, et lui seul ; les clés que les commandes acceptent et les
@ -16,7 +16,6 @@ import { appliquerCommande } from '../../application/commandes.js';
import { creerTraduction } from '../../application/traduction.js';
import { MODES_OBJECTIFS } from '../../moteur/criteres.js';
import {
CIBLE_REDONDANCE_PAR_DEFAUT,
GENERATION_PAR_DEFAUT,
SCHEMA,
creerCharge,
@ -66,7 +65,6 @@ const ECRITURES = new Map([
(entree, valeur) =>
entree.genre === 'ordre' ? ['changerPriorites', { paliers: valeur }] : ['modifierReglage', { cle: entree.cle, valeur }],
],
['propositions', (entree, valeur) => ['changerCibleRedondance', { cible: String(valeur) }]],
]);
// Une valeur autre que le défaut de l'entrée : la case contraire, un mode
@ -96,7 +94,7 @@ describe('REGLAGES_GENERATION : la définition que lisent les écrans et la docu
assert.throws(() => REGLAGES_GENERATION.push({}), TypeError);
});
test('l’ordre des écrans : les trois champs de la recherche, les bascules du § 5.3, le mode des objectifs, l’ordre des priorités, puis la cible de la section des propositions', () => {
test('l’ordre des écrans : les trois champs de la recherche, les bascules du § 5.3, le mode des objectifs, l’ordre des priorités ; aucun réglage hors de la génération', () => {
assert.deepEqual(
REGLAGES_GENERATION.map(({ nom, genre, section }) => [section, genre, nom]),
[
@ -112,7 +110,6 @@ describe('REGLAGES_GENERATION : la définition que lisent les écrans et la docu
['generation', 'bascule', 'attribuerSieges'],
['generation', 'choix', 'modeObjectifs'],
['generation', 'ordre', 'priorites'],
['propositions', 'champ', 'cibleRedondance'],
],
);
});
@ -178,23 +175,13 @@ describe('REGLAGES_GENERATION : la définition que lisent les écrans et la docu
assert.ok(Object.isFrozen(ordre.defaut));
});
test('la cible de la somme de redondance : un champ de la section des propositions, à la racine des réglages, sous le libellé de son champ, sans unité', () => {
assert.deepEqual(reglagesDeSection(REGLAGES_GENERATION, 'propositions'), [
{
nom: 'cibleRedondance',
cle: 'cibleRedondance',
etiquette: 'propositions.cible.etiquette',
unite: null,
defaut: CIBLE_REDONDANCE_PAR_DEFAUT,
genre: 'champ',
section: 'propositions',
},
]);
test('la cible de la somme de redondance n’est plus un réglage : ni entrée, ni section des propositions', () => {
assert.equal(REGLAGES_GENERATION.some(({ nom, cle }) => nom === 'cibleRedondance' || cle === 'cibleRedondance'), false);
assert.deepEqual(reglagesDeSection(REGLAGES_GENERATION, 'propositions'), []);
});
test('chaque défaut est celui d’une charge neuve : GENERATION_PAR_DEFAUT pour les champs, les bascules de creerCharge, CIBLE_REDONDANCE_PAR_DEFAUT pour la cible absente', () => {
test('chaque défaut est celui d’une charge neuve : GENERATION_PAR_DEFAUT pour les champs, les bascules de creerCharge', () => {
const { reglages } = chargeNeuveEpreuve();
assert.equal(Object.hasOwn(reglages, 'cibleRedondance'), false);
for (const entree of REGLAGES_GENERATION) {
assert.equal(entree.defaut, valeurDuReglage(reglages, entree), entree.nom);
}
@ -218,7 +205,7 @@ describe('REGLAGES_GENERATION : la définition que lisent les écrans et la docu
test('chaque réglage du schéma du fichier, facultatif compris, a son entrée, et chaque entrée en désigne un : aucun n’échappe aux écrans ni au tableau, hors des réglages nommés sans entrée', () => {
const chemins = cheminsDuSchema();
assert.ok(chemins.includes('cibleRedondance') && chemins.includes('generation.nombre'), chemins.join(', '));
assert.ok(chemins.includes('generation.nombre') && !chemins.includes('cibleRedondance'), chemins.join(', '));
const cles = REGLAGES_GENERATION.map(({ cle }) => cle);
for (const cle of SANS_ENTREE.keys()) {
assert.ok(chemins.includes(cle), `${cle} : absent du schéma, à retirer de SANS_ENTREE`);
@ -242,19 +229,18 @@ describe('REGLAGES_GENERATION : la définition que lisent les écrans et la docu
describe('valeurDuReglage, reglagesDuGenre et reglagesDeSection', () => {
test('la valeur d’un réglage se lit au chemin de sa clé : sous generation pour un champ de la génération, à la racine sinon', () => {
const reglages = { nouvelleTable: false, cibleRedondance: 7, generation: { nombre: 9 } };
const reglages = { nouvelleTable: false, modeObjectifs: 'minimum', generation: { nombre: 9 } };
const [nombre] = REGLAGES_GENERATION;
const nouvelleTable = REGLAGES_GENERATION.find(({ nom }) => nom === 'nouvelleTable');
const cible = REGLAGES_GENERATION.find(({ nom }) => nom === 'cibleRedondance');
const mode = REGLAGES_GENERATION.find(({ nom }) => nom === 'modeObjectifs');
assert.equal(valeurDuReglage(reglages, nombre), 9);
assert.equal(valeurDuReglage(reglages, nouvelleTable), false);
assert.equal(valeurDuReglage(reglages, cible), 7);
assert.equal(valeurDuReglage(reglages, mode), 'minimum');
});
test('un réglage facultatif absent vaut son défaut', () => {
const cible = REGLAGES_GENERATION.find(({ nom }) => nom === 'cibleRedondance');
assert.equal(valeurDuReglage({ generation: {} }, cible), CIBLE_REDONDANCE_PAR_DEFAUT);
assert.equal(valeurDuReglage({ generation: {} }, { ...cible, defaut: 3 }), 3);
const mode = REGLAGES_GENERATION.find(({ nom }) => nom === 'modeObjectifs');
assert.equal(valeurDuReglage({ generation: {} }, { ...mode, defaut: 'chacun' }), 'chacun');
for (const entree of REGLAGES_GENERATION.filter(({ genre }) => genre !== 'champ')) {
assert.equal(valeurDuReglage({ generation: {} }, entree), entree.defaut, entree.cle);
}
@ -277,7 +263,7 @@ describe('valeurDuReglage, reglagesDuGenre et reglagesDeSection', () => {
const definition = [...REGLAGES_GENERATION].reverse();
assert.deepEqual(
reglagesDuGenre(definition, 'champ').map(({ nom }) => nom),
['cibleRedondance', 'historique', 'arret', 'nombre'],
['historique', 'arret', 'nombre'],
);
assert.deepEqual(
reglagesDeSection(definition, 'generation').slice(0, 2).map(({ nom }) => nom),

View file

@ -10,7 +10,6 @@
// 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';
import { creerFormat } from '../../application/format.js';
const compte = (n, singulier, pluriel) => `${n} ${n <= 1 ? singulier : pluriel}`;
@ -74,17 +73,17 @@ export const TABLE = new Map([
['accueil.colonne.participants', 'Participants'],
['accueil.colonne.modification', 'Dernière modification'],
['accueil.colonne.chemin', 'Fichier'],
// La mesure du placement retenu (§ 5.4, § 8.9), et l'explication que
// l'infobulle de son intitulé donne : ce que compte le nombre, d'où il
// vient, et quand il vaut « — ».
['accueil.colonne.diffRedondance', "Diff somme d'appartenance"],
// La mesure du placement retenu (§ 5.4, § 8.1) : son intitulé est celui de
// la colonne du tableau des propositions qui porte la même mesure ;
// l'explication que l'infobulle de l'intitulé donne dit ce que compte le
// nombre, d'où il vient, et quand il vaut « — ».
[
'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.",
'accueil.explication.collisionsCumulees',
'Les collisions 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 (paire et tour) » du tableau des propositions, sur la ligne du retenu. ' +
"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, ou se contredit.",
],
// La meilleure proposition du classement de chaque événement (§ 5.4,

View file

@ -85,7 +85,6 @@ 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.changerEtiquettes', 'Changer les étiquettes'],
['communs.remede.poserObjectif', "Poser l'objectif"],
['communs.remede.retirerObjectif', "Retirer l'objectif"],

View file

@ -340,14 +340,6 @@ 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

@ -1042,51 +1042,15 @@ describe('les infobulles d’explication : chaque nom de critère, chaque en-tê
});
});
// --- La cible de la somme de redondance (§ 5.4, § 8.1, § 8.9) ---------------------
// --- La cible de la somme de redondance n'est plus un réglage (§ 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);
describe('la cible de la somme de redondance : aucun champ dans la section', () => {
test('la section ne porte ni le champ de la cible ni son libellé, en écriture comme en lecture', async () => {
for (const options of [{}, { mode: 'lecture' }]) {
const m = await ouvrir(chargeDemo('petite'), options);
expect(section(m).querySelector('[data-champ="cibleRedondance"]')).toBeNull();
expect(texteDe(section(m))).not.toContain('Cible de la somme de redondance');
}
});
});

View file

@ -63,18 +63,9 @@
//
// Habillage : le tableau et la phrase de l'ordre vivent dans une carte
// titrée, « Effacer les propositions » à droite de son en-tête ; le message
// d'une liste vide et le champ de la cible, chacun dans sa carte.
//
// 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).
// d'une liste vide dans sa carte.
import { getContext, tick, untrack } from 'svelte';
import { lireEntier } from '../../application/capacite.js';
import { cibleRedondance } from '../../application/evaluation.js';
import { REGLAGES_GENERATION, reglagesDeSection } from '../generation/reglages.js';
import Carte from '../primitives/Carte.svelte';
import Champ from '../primitives/Champ.svelte';
import Commande from '../primitives/Commande.svelte';
import Confirmation from '../primitives/Confirmation.svelte';
import Infobulle from '../primitives/Infobulle.svelte';
@ -96,9 +87,6 @@
const t = getContext('t');
const format = getContext('format');
// Le champ de la cible lit l'entrée que lit aussi le tableau des réglages
// de la documentation (§ 19.8).
const [entreeCible] = reglagesDeSection(REGLAGES_GENERATION, 'propositions');
const seance = getContext('seance');
const ui = getContext('ui');
@ -196,7 +184,6 @@
}
const effacer = () => seance.executer('effacerPropositions', {});
const changerCible = (cible) => seance.executer('changerCibleRedondance', { cible });
const nommer = (id) => nomDe(charge, id);
</script>
@ -278,21 +265,6 @@
<Infobulle id={idExplication} ouverte={expliquee} fermer={fermerExplication} />
{#if charge !== null}
<Carte>
<div class="cible" data-cible-redondance>
<Champ
cle={entreeCible.cle}
etiquette={entreeCible.etiquette}
valeur={cibleRedondance(charge)}
lire={lireEntier}
executer={changerCible}
/>
<p class="aide">{t('propositions.cible.aide')}</p>
</div>
</Carte>
{/if}
{#if aConfirmer !== null}
<Confirmation
titre={aConfirmer.titre.cle}
@ -380,16 +352,4 @@
outline: 2px solid var(--couleur-focus);
outline-offset: 2px;
}
.cible {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--espace-1);
}
.aide {
color: var(--couleur-page-texte-secondaire);
font-size: var(--taille-texte-petit);
}
</style>

View file

@ -737,7 +737,6 @@ describe('les champs facultatifs du format 2 (§ 8.9, A16)', () => {
charge.reglages.modeObjectifs = 'chacun';
charge.reglages.respecterObjectifs = false;
charge.reglages.regleObjectifs = 'et';
charge.reglages.cibleRedondance = 800;
charge.participants[0].etiquettes = ['finance', 'bénévole', 'Montréal'];
charge.tables[1].objectifs = [
{ portee: 'tour', tour: 2, regle: 'ou', etiquettes: ['ressources humaines', 'finance'] },
@ -751,7 +750,7 @@ describe('les champs facultatifs du format 2 (§ 8.9, A16)', () => {
remplacer(
TEXTE_CONTRAT,
'"generation":{"nombre":5,"arret":200000,"historique":1000}}',
'"generation":{"nombre":5,"arret":200000,"historique":1000},"cibleRedondance":800,"regleObjectifs":"et","respecterObjectifs":false,"modeObjectifs":"chacun","rencontrerAnimateurs":true}',
'"generation":{"nombre":5,"arret":200000,"historique":1000},"regleObjectifs":"et","respecterObjectifs":false,"modeObjectifs":"chacun","rencontrerAnimateurs":true}',
),
'"notes":null,"exclu":false},\n {"id":2',
'"notes":null,"exclu":false,"etiquettes":["Montréal","bénévole","finance"]},\n {"id":2',
@ -769,7 +768,7 @@ describe('les champs facultatifs du format 2 (§ 8.9, A16)', () => {
test('absents, ils ne s’écrivent pas : la charge du format 1 rend le texte du contrat', () => {
const charge = chargeFormat2();
for (const cle of ['cibleRedondance', 'regleObjectifs', 'respecterObjectifs', 'modeObjectifs', 'rencontrerAnimateurs']) {
for (const cle of ['regleObjectifs', 'respecterObjectifs', 'modeObjectifs', 'rencontrerAnimateurs']) {
delete charge.reglages[cle];
}
delete charge.participants[0].etiquettes;
@ -777,6 +776,14 @@ describe('les champs facultatifs du format 2 (§ 8.9, A16)', () => {
assert.equal(serialiser(charge, ENTETE), TEXTE_CONTRAT);
});
test('la cible de redondance, un ancien réglage qu’une charge porte encore, ne se copie ni ne s’écrit', () => {
const charge = chargeFormat2();
charge.reglages = { cibleRedondance: 800, ...charge.reglages };
assert.equal(Object.hasOwn(canoniser(charge).reglages, 'cibleRedondance'), false);
assert.equal(serialiser(charge, ENTETE), TEXTE_FORMAT_2);
assert.equal(serialiserCharge(charge), serialiserCharge(chargeFormat2()));
});
test('l’ordre des étiquettes est celui des unités de code : une lettre hors du plan de base passe avant U+FF21, une majuscule avant une minuscule', () => {
const charge = chargeContrat();
charge.participants[1].etiquettes = ['\u{FF21}', 'été', 'Été', '\u{1D538}', 'zèbre'];

View file

@ -8,7 +8,9 @@
// 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 champ facultatif du schéma (optionnel) se
// pose aussi quand son objet ne le porte pas, et se retire par sa clé. Un
// pose aussi quand son objet ne le porte pas, et se retire par sa clé. Une
// clé oubliée (oubliees de sa règle) ne se pose ni ne se retire : son
// opération, qu'un journal antérieur porte, s'applique sans rien changer. 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 —
@ -371,7 +373,10 @@ function retirer(conteneur, etape, regle) {
// charge, ou que la valeur posée n'y suivrait pas sa règle (valeurAdmise).
// Un chemin vide n'a pas de dernière étape, et undefined n'en désigne
// aucune. Une opération poser porte une valeur : JSON n'en écrit pas
// d'undefined.
// d'undefined. Une pose ou un retrait à une clé oubliée de l'objet qui la
// porterait (oubliees de sa règle), qu'écrivait un journal antérieur, rend
// vrai sans rien changer, quelle que soit la valeur et que la clé soit là
// ou non : la charge ne la porte jamais.
function executer(charge, operation) {
if (!estObjet(operation) || !Array.isArray(operation.chemin)) return false;
const { op, chemin, valeur } = operation;
@ -383,6 +388,9 @@ function executer(charge, operation) {
if (conteneur === undefined) return false;
}
const derniere = chemin[chemin.length - 1];
if (lecture.regle === undefined && lecture.parent?.oubliees?.has(derniere) === true && estObjet(conteneur)) {
return op === 'poser' ? valeur !== undefined : op === 'retirer';
}
if (op === 'poser') {
if (valeur === undefined || !valeurAdmise(charge, chemin, lecture.regle, valeur)) return false;
return poser(conteneur, derniere, valeur, lecture.regle, lecture.parent);
@ -401,12 +409,13 @@ function executer(charge, operation) {
* garde l'ordre des identifiants.
*
* Lève ErreurStockage('CORRECTIF', { rang }) à la première opération qui ne
* s'applique pas — rang est sa place dans le correctif, à partir de 0 — :
* une opération mal formée ; une étape { id } hors des participants, des
* 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, hors d'un champ facultatif, ou au-delà de la fin d'une liste, ou sous
* s'applique pas — rang est sa place dans le correctif, à partir de 0 — : une
* opération mal formée ; une étape { id } hors des participants, des 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, hors d'un
* champ facultatif et d'une clé oubliée — dont la pose et le retrait
* s'appliquent sans rien changer —, ou au-delà de la fin d'une liste, ou sous
* une liste d'étiquettes, qui ne se pose qu'entière ; 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

View file

@ -1836,8 +1836,9 @@ 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.
describe('la cible de la somme de redondance : un ancien réglage, que le correctif oublie (§ 8.6, § 8.9)', () => {
// Une charge neuve, sans la cible, et la même portant encore la cible
// donnée en mémoire.
const sans = () => creerCharge({ id: 'evt-cible', nom: 'Veillée des Grives', siegesParDefaut: 6, tours: 2 });
const avec = (cible) => {
const charge = sans();
@ -1846,34 +1847,51 @@ describe('la cible de la somme de redondance : un champ facultatif dans un corre
};
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('difference ne la pose ni ne la retire jamais : deux charges qui ne diffèrent que par elle ont un correctif vide', () => {
for (const [a, b] of [
[sans(), avec(1200)],
[avec(1200), sans()],
[avec(1200), avec(0)],
]) {
assert.deepEqual(difference(a, b), []);
assert.equal(serialiser(a, 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', () => {
test('la pose et le retrait qu’écrivait un journal antérieur s’appliquent sans rien changer, de toute valeur, la clé présente ou non', () => {
const operations = [
{ op: 'poser', chemin: CHEMIN, valeur: 800 },
{ op: 'poser', chemin: CHEMIN, valeur: 0 },
{ op: 'poser', chemin: CHEMIN, valeur: -1 },
{ op: 'poser', chemin: CHEMIN, valeur: null },
{ op: 'poser', chemin: CHEMIN, valeur: '1000' },
{ op: 'retirer', chemin: CHEMIN },
];
for (const depart of [sans(), avec(800)]) {
for (const operation of operations) {
const rendue = appliquer(depart, [JSON.parse(JSON.stringify(operation))]);
assert.equal(Object.hasOwn(rendue.reglages, 'cibleRedondance'), false, JSON.stringify(operation));
assert.deepEqual(rendue, canoniser(sans()), JSON.stringify(operation));
}
}
const suite = appliquer(sans(), [
{ op: 'poser', chemin: CHEMIN, valeur: 800 },
{ op: 'poser', chemin: ['reglages', 'regleObjectifs'], valeur: 'et' },
{ op: 'retirer', chemin: CHEMIN },
]);
assert.equal(suite.reglages.regleObjectifs, 'et');
assert.equal(Object.hasOwn(suite.reglages, 'cibleRedondance'), false);
});
test('l’oubli ne vaut que pour elle, à sa place : un réglage obligatoire retiré, une autre clé inconnue, la cible hors des réglages ou sous elle 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 }],
['retrait d’une clé inconnue', sans(), { op: 'retirer', chemin: ['reglages', 'cibleInconnue'] }],
['pose de la cible hors des réglages', sans(), { op: 'poser', chemin: ['evenement', 'cibleRedondance'], valeur: 3 }],
['pose sous la cible', avec(10), { op: 'poser', chemin: [...CHEMIN, 'valeur'], valeur: 3 }],
['opération inconnue sur la cible', sans(), { op: 'changer', chemin: CHEMIN, valeur: 3 }],
];
assert.ok(cas.length > 0);
for (const [libelle, charge, operation] of cas) {
assert.deepEqual(refusDe(charge, [operation]), { rang: 0 }, libelle);
}
@ -2055,13 +2073,13 @@ describe('les étiquettes, les objectifs et les réglages du format 2 dans un co
c.participants[0].exclu = true;
});
const b = base((c) => {
c.reglages.cibleRedondance = 900;
c.reglages.regleObjectifs = 'et';
c.reglages.respecterObjectifs = false;
c.reglages.rencontrerAnimateurs = true;
c.participants[0].etiquettes = ['finance'];
});
const rendue = appliquer(a, difference(a, b));
assert.deepEqual(Object.keys(rendue.reglages).slice(-3), ['cibleRedondance', 'respecterObjectifs', 'rencontrerAnimateurs']);
assert.deepEqual(Object.keys(rendue.reglages).slice(-3), ['regleObjectifs', 'respecterObjectifs', 'rencontrerAnimateurs']);
assert.equal(JSON.stringify(rendue), JSON.stringify(canoniser(b)));
});

View file

@ -45,6 +45,13 @@
// fichier du format 1 ne les porte pas, et se lit sans conversion. Il ne se
// réécrit qu'au premier geste, au format courant.
//
// Une clé que les réglages ont portée et ne portent plus — la cible de la
// somme de redondance, cibleRedondance — est oubliée (oubliees de la règle
// des réglages) : un fichier qui la porte se lit sans faute, quelle que soit
// sa valeur, la charge rendue ne la porte pas, et le premier geste écrit le
// fichier sans elle. Un correctif du journal qui la pose ou la retire
// s'applique sans rien changer (correctifs.js).
//
// Les autres fonctions créent une charge ou dérivent d'elle la configuration
// du moteur, l'état qu'impose son contenu, la capacité d'une table et la
// valeur d'un réglage facultatif. Aucune ne modifie ce qu'elle reçoit.
@ -86,13 +93,6 @@ 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;
// AAAA-MM-JJTHH:MM:SS±HH:MM, la forme que rend l'horloge de l'application.
// Le calendrier ne se contrôle pas : un horodatage ne décide d'aucun ordre.
const HORODATAGE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$/;
@ -113,10 +113,12 @@ export const estHorodatage = (valeur) => typeof valeur === 'string' && HORODATAG
// 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, et peut borner
// sa longueur (min, max) et exiger des éléments distincts (distincts). 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.
// de leurs clés, et peut nommer des clés oubliées (oubliees), admises de
// toute valeur et jamais copiées ; une liste porte la règle de ses éléments,
// et peut borner sa longueur (min, max) et exiger des éléments distincts
// (distincts). 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, optionnel: false, ...proprietes });
}
@ -275,28 +277,32 @@ const PRIORITES = liste(
{ min: 1, optionnel: true },
);
const REGLAGES = objet([
['separerAppartenances', BOOLEEN],
['nouveauxVoisins', BOOLEEN],
['nouvelleTable', BOOLEEN],
['varierAppartenances', BOOLEEN],
['attribuerSieges', BOOLEEN],
['generation', objet([['nombre', entier(1)], ['arret', entier(1)], ['historique', entier(1)]])],
// Facultatifs, en dernier, dans l'ordre de A16 : un fichier qui n'en porte
// aucun s'écrit sans eux, au numéro de format près. La cible de la somme
// de redondance (cibleRedondance) ; la règle par défaut des objectifs,
// « et » seulement (regleObjectifs) ; la case des objectifs, décochée
// seulement (respecterObjectifs) ; le mode des objectifs hors du plus de
// places servies (modeObjectifs) ; la case des animateurs, cochée
// seulement (rencontrerAnimateurs) ; l'ordre des priorités hors de l'ordre
// par défaut (priorites).
['cibleRedondance', entier(0, Infinity, { optionnel: true })],
['regleObjectifs', horsDuDefaut('et')],
['respecterObjectifs', horsDuDefaut(false)],
['modeObjectifs', horsDuDefaut('chacun', 'minimum')],
['rencontrerAnimateurs', horsDuDefaut(true)],
['priorites', PRIORITES],
]);
// La cible de la somme de redondance (cibleRedondance), un réglage que les
// fichiers ont porté : oubliée, elle se lit de toute valeur et ne s'écrit
// plus.
const REGLAGES = objet(
[
['separerAppartenances', BOOLEEN],
['nouveauxVoisins', BOOLEEN],
['nouvelleTable', BOOLEEN],
['varierAppartenances', BOOLEEN],
['attribuerSieges', BOOLEEN],
['generation', objet([['nombre', entier(1)], ['arret', entier(1)], ['historique', entier(1)]])],
// Facultatifs, en dernier, dans l'ordre de A16 : un fichier qui n'en
// porte aucun s'écrit sans eux, au numéro de format près. La règle par
// défaut des objectifs, « et » seulement (regleObjectifs) ; la case des
// objectifs, décochée seulement (respecterObjectifs) ; le mode des
// objectifs hors du plus de places servies (modeObjectifs) ; la case des
// animateurs, cochée seulement (rencontrerAnimateurs) ; l'ordre des
// priorités hors de l'ordre par défaut (priorites).
['regleObjectifs', horsDuDefaut('et')],
['respecterObjectifs', horsDuDefaut(false)],
['modeObjectifs', horsDuDefaut('chacun', 'minimum')],
['rencontrerAnimateurs', horsDuDefaut(true)],
['priorites', PRIORITES],
],
{ oubliees: new Set(['cibleRedondance']) },
);
const PARTICIPANT = objet([
['id', IDENTIFIANT],
@ -577,9 +583,11 @@ export function clesRangees(valeur) {
return Object.keys(valeur).sort(comparerTextes);
}
// Plus petite des clés de valeur absentes de cles, en comparant les unités
// UTF-16, ou undefined.
const cleInconnue = (valeur, cles) => clesRangees(valeur).find((cle) => !cles.has(cle));
// Plus petite des clés de valeur que la règle objet ne connaît pas — ni
// l'une de ses clés, ni l'une de ses clés oubliées —, en comparant les
// unités UTF-16, ou undefined.
const cleInconnue = (valeur, { cles, oubliees }) =>
clesRangees(valeur).find((cle) => !cles.has(cle) && oubliees?.has(cle) !== true);
// Format du fichier lu, contrôlé avant toute autre règle : il dit comment
// lire le reste. Une racine qui n'est pas un objet, un en-tête ou un format
@ -627,7 +635,7 @@ function fauteDeForme(valeur, regleDeValeur, chemin, racine) {
const faute = fauteDeForme(valeur[cle], regleDuChamp, joindre(chemin, cle), false);
if (faute !== null) return faute;
}
const inconnue = cleInconnue(valeur, regleDeValeur.cles);
const inconnue = cleInconnue(valeur, regleDeValeur);
return inconnue === undefined ? null : joindre(chemin, inconnue);
}
if (regleDeValeur.genre === 'liste') {
@ -642,7 +650,8 @@ function fauteDeForme(valeur, regleDeValeur, chemin, racine) {
/**
* Chemin de la première valeur qui sort de sa règle, en profondeur, ou null
* 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
* l'ordre du schéma, puis la plus petite de ses clés inconnues — une clé
* oubliée de sa règle n'en est pas une, quelle que soit sa valeur ; 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'admet seule une
* règle facultative ; ailleurs, il se nomme par son chemin comme une valeur
@ -810,14 +819,21 @@ function examinerReferences(charge, plus) {
charge.titres.forEach((titre, rang) => examinerPlace(titre, `charge.titres[${rang}]`));
}
// Retire des réglages lus, en place, chaque clé oubliée de leur règle
// (oubliees) : la charge que rend la lecture ne la porte pas.
function oublierDesReglages(reglages) {
for (const cle of REGLAGES.oubliees) delete reglages[cle];
}
/**
* Lit le texte d'un fichier d'état, sans rien écrire (§ 8.4, § 8.8). Une
* marque d'ordre d'octets en tête est ignorée. Rend l'en-tête, la charge,
* formatPlusRecent, vrai quand le format dépasse FORMAT, et manquants, ce que
* les comptes des propositions et du retenu annoncent sans que la charge le
* porte, qui ne refuse jamais le fichier (§ 8.9, point 3). Au format courant,
* l'en-tête et la charge sont ceux que le texte porte, ni copiés ni triés :
* canoniser les met dans l'ordre canonique. Un fichier d'un format plus
* l'en-tête et la charge sont ceux que le texte porte, ni copiés ni triés,
* les clés oubliées des réglages retirées (oublierDesReglages) : canoniser
* les met dans l'ordre canonique. Un fichier d'un format plus
* récent s'ouvre en lecture seule et ne se réécrit jamais : l'en-tête et la
* charge rendus sont des copies réduites aux clés que le schéma connaît, et
* une faute de forme y porte la raison FORMAT_PLUS_RECENT ; les comptes et
@ -856,6 +872,7 @@ export function analyser(texte) {
const cheminFautif = premiereFaute(fichier, SCHEMA);
if (cheminFautif !== null) faute(cheminFautif);
const { entete, charge } = fichier;
oublierDesReglages(charge.reglages);
examinerIdentifiants(charge, faute);
examinerTours(charge, faute);
examinerObjectifs(charge, faute);
@ -947,18 +964,6 @@ 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;
}
// La règle par défaut des objectifs et le mode des objectifs quand les
// réglages ne les portent pas (§ 8.9).
const REGLE_OBJECTIFS_PAR_DEFAUT = 'ou';

View file

@ -20,8 +20,8 @@ import { HISTORIQUE_PAR_DEFAUT } from '../moteur/recherche.js';
import { texteLivre } from '../demo/livrees.js';
import { VERSION } from '../version.genere.js';
import { serialiser, serialiserCharge } from './canonique.js';
import * as DOCUMENT from './document.js';
import {
CIBLE_REDONDANCE_PAR_DEFAUT,
ENTREE_DE_COURSE,
FORMAT,
GENERATION_PAR_DEFAUT,
@ -30,7 +30,6 @@ import {
TOURS_MAX,
analyser,
capacite,
cibleRedondance,
configurationDepuisCharge,
creerCharge,
critereCoche,
@ -1139,65 +1138,62 @@ describe('le marqueur de chaise vide : la règle d\'une liste de table (§ 8.9)'
});
});
describe('la cible de la somme de redondance : un réglage facultatif (§ 8.9)', () => {
describe('la cible de la somme de redondance : un ancien réglage, oublié à la lecture (§ 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';
// Toute valeur qu'un fichier peut porter sous la clé : celles que le code
// antérieur écrivait, et celles qu'il refusait.
const VALEURS = [0, 1, 1000, 1234, Number.MAX_SAFE_INTEGER, -1, 2.5, 2 ** 53, '1000', true, null, [1000], { cible: 3 }];
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);
test('le schéma ne la décrit plus, et le module n’en exporte ni la valeur par défaut ni la lecture', () => {
assert.equal(REGLAGES.cles.has('cibleRedondance'), false);
assert.equal(REGLAGES.champs.some(([cle]) => cle === 'cibleRedondance'), false);
assert.equal(Object.hasOwn(DOCUMENT, 'cibleRedondance'), false);
assert.equal(Object.hasOwn(DOCUMENT, 'CIBLE_REDONDANCE_PAR_DEFAUT'), false);
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('un fichier qui la porte, de toute valeur, se lit sans faute, et la charge lue ne la porte plus : la même que sans elle', () => {
const sans = analyser(JSON.stringify(documentValide()));
for (const valeur of VALEURS) {
const texte = abime((d) => { d.charge.reglages.cibleRedondance = valeur; });
assert.equal(premiereFaute(JSON.parse(texte), SCHEMA), null, JSON.stringify(valeur));
const lu = analyser(texte);
assert.equal(Object.hasOwn(lu.charge.reglages, 'cibleRedondance'), false, JSON.stringify(valeur));
assert.deepEqual(lu, sans, JSON.stringify(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('l’oubli ne vaut que pour elle, et que dans les réglages : une autre clé inconnue reste une faute de forme', () => {
assert.deepEqual(ecartsDeRefus([
['une autre clé des réglages', abime((d) => { d.charge.reglages.cibleRedondances = 1000; }), 'FORME', 'charge.reglages.cibleRedondances'],
['la clé hors des réglages', abime((d) => { d.charge.evenement.cibleRedondance = 1000; }), 'FORME', 'charge.evenement.cibleRedondance'],
[
'une clé inconnue à côté d’elle',
abime((d) => {
d.charge.reglages.cibleRedondance = 1000;
d.charge.reglages.autreReglage = true;
}),
'FORME',
'charge.reglages.autreReglage',
],
]), []);
});
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", () => {
test('une charge qui la porte encore en mémoire s’écrit sans elle, comme la charge qui ne l’a jamais portée', () => {
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);
assert.equal(serialiser(posee, { revision: 12, produitVersion: VERSION.affichee }), sans);
assert.equal(serialiserCharge(posee), serialiserCharge(charge));
});
test("un fichier d'un format plus récent garde la cible qu'il porte, réduite aux clés connues", () => {
test("un fichier d'un format plus récent qui la porte se lit réduit aux clés connues, sans elle", () => {
const texte = abime((d) => {
d.entete.format = FORMAT + 1;
d.charge.reglages.cibleRedondance = 750;
@ -1205,7 +1201,7 @@ describe('la cible de la somme de redondance : un réglage facultatif (§ 8.9)',
});
const lecture = analyser(texte);
assert.equal(lecture.formatPlusRecent, true);
assert.equal(cibleRedondance(lecture.charge), 750);
assert.equal(Object.hasOwn(lecture.charge.reglages, 'cibleRedondance'), false);
assert.equal(Object.hasOwn(lecture.charge.reglages, 'autreReglage'), false);
});
});
@ -1418,10 +1414,9 @@ describe('les champs facultatifs du format 2 : forme et place (§ 8.9, A16)', ()
const PARTICIPANT = regleDuChamp(CHARGE_SCHEMA, 'participants').element;
const TABLE = regleDuChamp(CHARGE_SCHEMA, 'tables').element;
assert.deepEqual(
REGLAGES.champs.slice(-7).map(([cle, regle]) => [cle, regle.optionnel]),
REGLAGES.champs.slice(-6).map(([cle, regle]) => [cle, regle.optionnel]),
[
['generation', false],
['cibleRedondance', true],
['regleObjectifs', true],
['respecterObjectifs', true],
['modeObjectifs', true],
@ -1452,12 +1447,6 @@ describe('les champs facultatifs du format 2 : forme et place (§ 8.9, A16)', ()
const relue = analyser(texte);
assert.deepEqual(relue.charge, charge);
assert.equal(serialiser(relue.charge, relue.entete), texte);
const avecCible = structuredClone(charge);
avecCible.reglages.cibleRedondance = 900;
assert.ok(
serialiser(avecCible, relue.entete).includes('"historique":500},"cibleRedondance":900,"regleObjectifs":"et",'),
'la cible avant la règle',
);
});
test('des étiquettes hors de l’ordre des unités de code et des objectifs hors de l’ordre soirée puis tours se lisent, et s’écrivent rangés', () => {

View file

@ -7,14 +7,19 @@
// jalon, une inscription, une cible de redondance posée puis retirée, une
// réservation, un second jalon, une note. empreintes.json y porte, par
// révision, l'empreinte SHA-256 du texte de serialiserCharge que restituait
// le journal sous ce même code. Chaque épreuve travaille sur une copie de ces
// le journal sous ce même code. Les révisions 3 et 4 y portent la cible de
// redondance, un réglage que ce code oublie à la lecture (§ 8.9) : la
// révision 3 s'y restitue comme la 2, la 4 comme la 5 (ATTENDUES). Chaque
// épreuve travaille sur une copie de ces
// fichiers dans un dossier temporaire neuf, retiré à la fin, par le système
// de fichiers de la plateforme electron (fichiers_electron.js devant les
// primitives de la coquille, electron/fichiers.js) : l'ouverture n'écrit
// rien ; chaque révision se restitue comme le format 1 la restituait ; le
// premier geste réécrit le journal au format courant, chaque ligne
// d'historique gardée ; défaire, d'un geste à l'autre au-delà de la
// conversion, rend chaque charge d'avant. L'épreuve est de la série longue :
// conversion, rend chaque charge d'avant ; un état qui porte encore la cible
// s'ouvre sans avertissement et s'écrit sans elle au premier geste.
// L'épreuve est de la série longue :
// elle charge les primitives de la coquille, et le motif de surveillance
// d'electron/ (vitest.config.js) ne relance, dans la série node, que les
// épreuves qu'il nomme.
@ -39,6 +44,10 @@ const { base: BASE, revisions: EMPREINTES } = JSON.parse(readFileSync(join(FIXTU
const NOMS = [SUFFIXES.journal, SUFFIXES.etat, SUFFIXES.precedent].map((suffixe) => `${BASE}${suffixe}`);
const [JOURNAL, ETAT, PRECEDENT] = NOMS;
const DERNIERE = Object.keys(EMPREINTES).length;
// Les empreintes que restitue ce code : celles du format 1, la cible de
// redondance oubliée — la révision 3, qui la posait, égale la 2 ; la 4, qui
// la portait encore, égale la 5, qui la retirait.
const ATTENDUES = { ...EMPREINTES, 3: EMPREINTES[2], 4: EMPREINTES[5] };
const empreinte = (charge) => createHash('sha256').update(serialiserCharge(charge), 'utf8').digest('hex');
const lignesDe = (texte) => texte.replace(/\n$/, '').split('\n');
@ -126,11 +135,15 @@ describe('stockage : un événement écrit par le code du format 1 (§ 8.8, A15)
assert.equal(ouvert.revision, DERNIERE);
}));
test('chaque révision se restitue telle que le code du format 1 l’enregistrait ; l’état et le .precedent portent les deux dernières', () => {
test('chaque révision se restitue telle que le code du format 1 l’enregistrait, la cible de redondance oubliée ; l’état et le .precedent portent les deux dernières', () => {
const journal = lireJournal(fixture(JOURNAL));
assert.equal(journal.ecartees, 0);
assert.ok(fixture(JOURNAL).includes('"chemin":["reglages","cibleRedondance"],"valeur":800'));
const lues = Object.fromEntries(journal.entrees.map(({ revision }) => [revision, empreinte(reconstruire(journal, revision))]));
assert.deepEqual(lues, EMPREINTES);
assert.deepEqual(lues, ATTENDUES);
for (const { revision } of journal.entrees) {
assert.equal(Object.hasOwn(reconstruire(journal, revision).reglages, 'cibleRedondance'), false, `révision ${revision}`);
}
assert.equal(empreinte(analyser(fixture(ETAT)).charge), EMPREINTES[DERNIERE]);
assert.equal(empreinte(analyser(fixture(PRECEDENT)).charge), EMPREINTES[DERNIERE - 1]);
assert.deepEqual(
@ -155,7 +168,7 @@ describe('stockage : un événement écrit par le code du format 1 (§ 8.8, A15)
const nouvelles = lignesDe(texte);
assert.deepEqual(nouvelles.slice(1, anciennes.length), anciennes.slice(1));
assert.equal(nouvelles.length, anciennes.length + 1);
for (const [revision, attendue] of Object.entries(EMPREINTES)) {
for (const [revision, attendue] of Object.entries(ATTENDUES)) {
assert.equal(empreinte(reconstruire(journal, Number(revision))), attendue, `révision ${revision}`);
}
assert.equal(empreinte(reconstruire(journal, DERNIERE + 1)), empreinte(apres));
@ -175,7 +188,7 @@ describe('stockage : un événement écrit par le code du format 1 (§ 8.8, A15)
for (let n = 2; fil(ouvert.journal).cibleDefaire !== null; n += 1) {
const cible = fil(ouvert.journal).cibleDefaire;
ouvert = await enregistrer(ouvert, reconstruire(ouvert.journal, cible), geste(n, { sens: 'defaire', retour: cible }));
assert.equal(empreinte(ouvert.charge), EMPREINTES[cible], `retour à ${cible}`);
assert.equal(empreinte(ouvert.charge), ATTENDUES[cible], `retour à ${cible}`);
atteintes.push(cible);
}
assert.deepEqual(atteintes, [6, 5, 4, 3, 2, 1]);
@ -184,4 +197,21 @@ describe('stockage : un événement écrit par le code du format 1 (§ 8.8, A15)
assert.equal(rouvert.journal.format, FORMAT);
assert.equal(fil(rouvert.journal).cibleRefaire, 2);
}));
test('un état qui porte encore la cible de redondance s’ouvre sans avertissement, sans elle, et s’écrit sans elle au premier geste', () =>
surUneCopie(async ({ fs, racine, chemin }) => {
const lu = JSON.parse(fixture(ETAT));
lu.charge.reglages.cibleRedondance = 800;
writeFileSync(chemin(ETAT), `${JSON.stringify(lu, null, 2)}\n`);
const ouvert = await ouvrir(fs, racine, BASE);
assert.deepEqual(ouvert.avertissements, []);
assert.equal(Object.hasOwn(ouvert.charge.reglages, 'cibleRedondance'), false);
assert.equal(empreinte(ouvert.charge), EMPREINTES[DERNIERE]);
const apres = structuredClone(ouvert.charge);
apres.participants[1].notes = 'converti';
await enregistrer(ouvert, apres, geste(1));
const ecrit = readFileSync(chemin(ETAT), 'utf8');
assert.equal(ecrit.includes('cibleRedondance'), false);
assert.equal(empreinte(analyser(ecrit).charge), empreinte(apres));
}));
});

View file

@ -1156,6 +1156,23 @@ describe('le journal d’un format plus ancien se lit (§ 8.8, A15)', () => {
assert.deepEqual([lu.format, lu.entrees.length, lu.ecartees], [1, 36, 64]);
});
test('la cible de redondance qu’écrivait un code antérieur — dans un instantané, posée puis retirée par un correctif — se lit, et chaque instant se restitue sans elle', () => {
const courant = lireJournal(cent());
const lignes = lignesDe(auFormat(cent(), 1)).map((ligne) => JSON.parse(ligne));
const entree = (revision) => lignes.find((ligne) => ligne.type === 'entree' && ligne.revision === revision);
entree(1).instantane.reglages.cibleRedondance = 800;
entree(2).correctif.unshift({ op: 'poser', chemin: ['reglages', 'cibleRedondance'], valeur: 1200 });
entree(3).correctif.push({ op: 'retirer', chemin: ['reglages', 'cibleRedondance'] });
entree(4).correctif.push({ op: 'retirer', chemin: ['reglages', 'cibleRedondance'] });
const lu = lireJournal(texteDe(lignes.map((ligne) => JSON.stringify(ligne))));
assert.deepEqual([lu.format, lu.ecartees, lu.entrees.length], [1, 0, courant.entrees.length]);
for (const revision of [1, 2, 3, 4, 5, 51, 100]) {
const charge = reconstruire(lu, revision);
assert.equal(Object.hasOwn(charge.reglages, 'cibleRedondance'), false, String(revision));
assert.equal(texteCanonique(charge), texteCanonique(reconstruire(courant, revision)), String(revision));
}
});
test('élagué, un journal du format 1 se réécrit au format courant : seule son ouverture change', () => {
const etapes = suite(650);
const texte = journalDe(etapes);

View file

@ -103,10 +103,6 @@
* 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
* @property {'et'} [regleObjectifs] facultatif, présent seulement à
* « et » : la règle que suit un
* objectif de règle nulle ; absent,
@ -288,6 +284,10 @@
* @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
* @property {Set<string>} [oubliees] objet : des clés qu'il a portées
* et ne porte plus. La forme les admet, de toute valeur ; la
* lecture les retire, canoniser ne les copie pas, et un correctif
* qui les pose ou les retire s'applique sans rien changer
* @property {Regle} [element] liste : la règle de ses éléments
* @property {number} [min] entier : bornes incluses ;
* liste : bornes incluses de sa longueur

View file

@ -40,7 +40,6 @@ const ENTREES = [
['data-chemin-planche', 'badges : le chemin de la planche produite, ou son nom téléchargé', ['']],
['data-choix-siege', 'plan : un siège offert par le panneau d’une table, par son rang', null],
['data-cible', 'la cible d’une commande : section, groupe, colonne, vue imprimée ou papier', null],
['data-cible-redondance', 'propositions : le champ de la cible de redondance', ['']],
['data-classement', 'propositions : les lignes classées', ['']],
['data-coeurs', 'génération : le réglage des cœurs de calcul', ['']],
['data-coeurs-disponibles', 'génération : la valeur automatique des cœurs', ['']],

View file

@ -658,21 +658,22 @@ export const ECRANS = Object.freeze([
nom: 'accueil.mesuresRetenu',
mode: null,
async atteindre(contexte) {
// La principale, sa proposition retenue : la ligne montre la
// différence du retenu et la meilleure proposition du classement, et le
// pointeur réel sur l'intitulé de la différence — un en-tête de
// colonne du tableau, une fois pour la liste — ouvre son infobulle.
// La principale, sa proposition retenue : la ligne montre les
// collisions cumulées du retenu et la meilleure proposition du
// classement, et le pointeur réel sur l'intitulé des collisions — un
// en-tête de colonne du tableau, une fois pour la liste — 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 chargerResumes(m);
await attendre('la différence du retenu et la meilleure proposition', () =>
['[data-colonne="diffRedondance"]', '[data-meilleure-id]'].every((valeur) => /\d/.test(m.racine.querySelector(`${ligne} ${valeur}`)?.textContent ?? '')),
await attendre('les collisions du retenu et la meilleure proposition', () =>
['[data-colonne="collisionsCumulees"]', '[data-meilleure-id]'].every((valeur) => /\d/.test(m.racine.querySelector(`${ligne} ${valeur}`)?.textContent ?? '')),
);
const { x, y } = await centre(await element(m, '[data-ecran="accueil"] thead [data-terme="diffRedondance"]'));
const { x, y } = await centre(await element(m, '[data-ecran="accueil"] thead [data-terme="collisionsCumulees"]'));
await pointer('bouger', x, y);
await attendre("l'infobulle de la différence", () => {
await attendre("l'infobulle des collisions", () => {
const bulle = m.racine.querySelector('[data-ecran="accueil"] [role="tooltip"]');
return bulle !== null && !bulle.hidden && getComputedStyle(bulle).visibility === 'visible';
});