[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é. page de qualité.
- Le guide d'usage montre la page de qualité, ses vues et ses cas limites, - Le guide d'usage montre la page de qualité, ses vues et ses cas limites,
captures à l'appui. 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 **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 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 | | Attribuer les sièges | Génération | décochée |
| Objectifs des tables : chercher d'abord | Génération | le plus de places servies | | 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 | | 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 --> <!-- 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 | | `changerDefaut` | Sièges par défaut : 10 ; 4 tables réalignées |
| `changerTours` | Nombre de tours : 3 ; 2 réservations retirées | | `changerTours` | Nombre de tours : 3 ; 2 réservations retirées |
| `modifierReglage` | Séparer les appartenances : désactivé | | `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 | | `poserObjectif` | Table 3 : objectif de la soirée « finance », règle par défaut |
| `retirerObjectif` | Table 3, tour 2 : objectif retiré | | `retirerObjectif` | Table 3, tour 2 : objectif retiré |
| `changerRegleObjectifs` | Règle par défaut des objectifs : toutes les étiquettes | | `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 }, changerDefaut: { sieges: 10, realignees: 4 },
changerTours: { tours: 3, retirees: 2 }, changerTours: { tours: 3, retirees: 2 },
modifierReglage: { cle: 'separerAppartenances', valeur: false }, modifierReglage: { cle: 'separerAppartenances', valeur: false },
changerCibleRedondance: { cible: 1200 },
changerEtiquettes: { personne: 'Iris Ombrelle', ajoutees: ['finance'], retirees: [] }, changerEtiquettes: { personne: 'Iris Ombrelle', ajoutees: ['finance'], retirees: [] },
poserObjectif: { numero: 3, tour: null, regle: null, etiquettes: ['finance'] }, poserObjectif: { numero: 3, tour: null, regle: null, etiquettes: ['finance'] },
retirerObjectif: { numero: 3, tour: 2 }, 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 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 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 à 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 retenu se résume ensuite dans la liste des événements, par ses collisions
de sa somme de redondance à la cible de l'événement (§ 8.1). cumulées (§ 5.4, § 8.1).
**11. Ajuster à la main.** *Facultative, reprenable.* Le même geste qu'à **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 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 « 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 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 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 », colonne résume en plus le **placement retenu** : « Collisions cumulées (paire et
|cible − Σ r(p)|, où Σ r(p) est la somme de redondance du retenu — le nombre de tour) », les collisions cumulées du retenu (§ 5.4) — le nombre que la colonne du
la colonne « Redondance d'appartenance, somme Σ r(p) » du tableau des même intitulé du tableau des propositions porte sur la ligne du retenu. Elle se
propositions — et la cible celle de l'événement (§ 8.9), 1000 par défaut, lit dans la même mesure que le tableau, sans rien écrire ; un événement sans
réglable dans la section des propositions. Elle se lit dans la même mesure que retenu, ou dont le retenu est fautif ou en dérive (§ 8.9, § 9), y porte « — ».
le tableau, sans rien écrire ; un événement sans retenu, ou dont le retenu est Chaque ligne porte aussi la **« Meilleure proposition du classement »**, sous
fautif ou en dérive (§ 8.9, § 9), y porte « — ». Chaque ligne porte aussi la les libellés du bloc que la section de la génération montre en tête :
**« Meilleure proposition du classement »**, sous les libellés du bloc que la l'identifiant de la proposition au rang 1 du classement (§ 5.7) — la même
section de la génération montre en tête : l'identifiant de la proposition au proposition que ce bloc et que la première ligne du tableau des propositions,
rang 1 du classement (§ 5.7) — la même proposition que ce bloc et que la retenue ou non ; une proposition fautive, que l'ouverture écarte (§ 8.9, point
première ligne du tableau des propositions, retenue ou non ; une proposition 3), n'y entre pas. Sans proposition classée — aucune, ou aucune qui décrive
fautive, que l'ouverture écarte (§ 8.9, point 3), n'y entre pas. Sans encore la configuration —, elle vaut « — ». La liste ne montre pas les
proposition classée — aucune, ou aucune qui décrive encore la configuration —, collisions cumulées de la meilleure proposition : le tableau des propositions et
elle vaut « — ». La liste ne montre les collisions cumulées ni du retenu ni de le bloc de la génération les portent. Chaque intitulé s'explique dans une
la meilleure proposition : le tableau des propositions et le bloc de la infobulle, posée sur l'en-tête de sa colonne. **La liste paraît avant ses
génération les portent. Chaque intitulé s'explique dans une infobulle, posée colonnes calculées** : ses lignes s'affichent dès le relevé du dossier, et les
sur l'en-tête de sa colonne. mesures du retenu et la meilleure proposition, qui évaluent chaque événement, se
**La liste paraît avant ses colonnes calculées** : ses lignes s'affichent dès le calculent ensuite, un événement après l'autre, la main rendue au navigateur
relevé du dossier, et les mesures du retenu et la meilleure proposition, qui entre deux. Tant que celles d'une ligne se calculent, chacune de ses valeurs
évaluent chaque événement, se calculent ensuite, un événement après l'autre, la porte un signe de chargement qui tourne ; chaque ligne montre les siennes dès
main rendue au navigateur entre deux. Tant que celles d'une ligne se calculent, qu'elles sont obtenues, et une liste relue abandonne les calculs de la
chacune de ses valeurs porte un signe de chargement qui tourne ; chaque ligne précédente. **Un répertoire vide n'affiche pas une liste vide** : il présente la
montre les siennes dès qu'elles sont obtenues, et une liste relue abandonne les commande de création et les démonstrations — ni tuiles, ni tableau, ni résumé —,
calculs de la précédente. car une liste vide sans issue est le premier écran que voit un opérateur qui
**Un répertoire vide n'affiche pas une liste vide** : il présente la commande de lance l'exécutable. Un filtre qui ne retient aucune ligne le dit, sans laisser
création et les démonstrations — ni tuiles, ni tableau, ni résumé —, car une un tableau vide.
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 Le logiciel n'ouvre **qu'un événement à la fois**, et ferme le précédent sans
question : il n'y a rien à enregistrer. 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 remonter son fil et atterrit sur un fil mort obtient un état légitime, jamais
celui qu'il visait. 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 **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 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 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 | | 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 | | 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 fichier écrit par une construction antérieure peut porter, dans les
un entier ≥ 0 : la cible à laquelle la liste des événements compare la somme de réglages de l'événement, un champ `cibleRedondance` : la cible à laquelle la
redondance Σ r(p) du retenu (§ 8.1). **Absent, il vaut 1000.** Un fichier qui ne liste des événements comparait la somme de redondance Σ r(p) du retenu. La
le porte pas — chaque fichier écrit avant lui, chaque démonstration livrée — se liste montre les collisions cumulées du retenu (§ 8.1), et ce champ ne sert
lit à la cible 1000, et s'écrit sans lui. Une valeur négative, non entière, hors plus. **Il est oublié** : un fichier qui le porte se lit sans faute de forme, sans
des entiers exacts ou d'une autre sorte est une faute de forme du fichier refus ni avertissement, quelle que soit sa valeur ; la charge lue ne le porte
(§ 8.8). La commande « Cible de la somme de redondance », un champ de la section pas, et le premier geste écrit le fichier sans lui. Aucune commande ne le pose.
des propositions, le pose — une entrée d'historique, refusée en lecture et sur Un correctif du journal (§ 8.6) qui le pose ou le retire, qu'un journal plus
un plan bloqué — et le retire quand la cible revient à 1000 : deux fichiers de ancien porte, s'applique sans rien changer : chaque instant se restitue sans
même cible s'écrivent pareil. Un correctif du journal (§ 8.6) le pose quand il lui. L'instant qui le posait ou le retirait se restitue donc comme son
apparaît et le retire quand il disparaît. 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é #### 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 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, 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, `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 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 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 }], ['changerDefaut', { sieges: 3, realigner: false }],
['changerTours', { tours: 3 }], ['changerTours', { tours: 3 }],
['modifierReglage', { cle: 'nouveauxVoisins', valeur: false }], ['modifierReglage', { cle: 'nouveauxVoisins', valeur: false }],
['changerCibleRedondance', { cible: '1200' }],
['changerEtiquettes', { id: 3, ajouter: 'finance' }], ['changerEtiquettes', { id: 3, ajouter: 'finance' }],
['poserObjectif', { table: 2, tour: 1, etiquettes: 'finance', regle: 'et' }], ['poserObjectif', { table: 2, tour: 1, etiquettes: 'finance', regle: 'et' }],
['retirerObjectif', { table: 2, tour: null }], ['retirerObjectif', { table: 2, tour: null }],

View file

@ -2,10 +2,10 @@
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) // License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le registre des commandes du mobilier — tables, sièges, tours, réglages, // 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 // objectifs des tables et leur règle par défaut, ordre des priorités et
// défaut, ordre des priorités et mode des objectifs (§ 4, § 4.5, § 5.3, // mode des objectifs (§ 4, § 4.5, § 5.3, § 5.7, § 6, § 7.1, § 8.2, § 8.9,
// § 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 // § 9), que commandes.js réunit aux autres. Ses imports n'atteignent jamais
// réuni dépend de lui, non l'inverse. // commandes.js : le registre réuni dépend de lui, non l'inverse.
// //
// Chaque commande suit le contrat de commandes.js : une fonction pure // Chaque commande suit le contrat de commandes.js : une fonction pure
// (charge, arguments) → { charge, libelle, avertissements }, la charge reçue // (charge, arguments) → { charge, libelle, avertissements }, la charge reçue
@ -27,11 +27,9 @@
import { caseLibre, geometrieTable, listeSurTable } from '../geometrie/tables.js'; import { caseLibre, geometrieTable, listeSurTable } from '../geometrie/tables.js';
import { CRITERES, MODES_OBJECTIFS, POIDS_MAX, seulsASonPalier, verifierPriorites } from '../moteur/criteres.js'; import { CRITERES, MODES_OBJECTIFS, POIDS_MAX, seulsASonPalier, verifierPriorites } from '../moteur/criteres.js';
import { import {
CIBLE_REDONDANCE_PAR_DEFAUT,
SCHEMA, SCHEMA,
TOURS_MAX, TOURS_MAX,
capacite, capacite,
cibleRedondance,
critereCoche, critereCoche,
modeObjectifs, modeObjectifs,
priorites, priorites,
@ -377,27 +375,6 @@ function modifierReglage(charge, { cle, valeur } = {}) {
return { charge: suivante, libelle: libelle('ENTREE_MODIFIER_REGLAGE', { cle, valeur }), avertissements: [] }; 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 // 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 // TABLE_DISPARUE {table}, remède null : aucun geste ne rend une table
// supprimée. // supprimée.
@ -715,7 +692,6 @@ export const COMMANDES_MOBILIER = new Map([
['changerDefaut', changerDefaut], ['changerDefaut', changerDefaut],
['changerTours', changerTours], ['changerTours', changerTours],
['modifierReglage', modifierReglage], ['modifierReglage', modifierReglage],
['changerCibleRedondance', changerCibleRedondance],
['poserObjectif', poserObjectif], ['poserObjectif', poserObjectif],
['retirerObjectif', retirerObjectif], ['retirerObjectif', retirerObjectif],
['changerRegleObjectifs', changerRegleObjectifs], ['changerRegleObjectifs', changerRegleObjectifs],

View file

@ -24,7 +24,6 @@ import {
TOURS_MAX, TOURS_MAX,
analyser, analyser,
capacite, capacite,
cibleRedondance,
configurationDepuisCharge, configurationDepuisCharge,
creerCharge, creerCharge,
modeObjectifs, modeObjectifs,
@ -217,7 +216,7 @@ async function seanceSur(charge) {
// --- Les épreuves ----------------------------------------------------------- // --- Les épreuves -----------------------------------------------------------
describe('mobilier : le registre (§ 8.2)', () => { 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 = [ const noms = [
'creerTable', 'creerTable',
'supprimerTable', 'supprimerTable',
@ -227,7 +226,6 @@ describe('mobilier : le registre (§ 8.2)', () => {
'changerDefaut', 'changerDefaut',
'changerTours', 'changerTours',
'modifierReglage', 'modifierReglage',
'changerCibleRedondance',
'poserObjectif', 'poserObjectif',
'retirerObjectif', 'retirerObjectif',
'changerRegleObjectifs', 'changerRegleObjectifs',
@ -250,7 +248,6 @@ describe('mobilier : le registre (§ 8.2)', () => {
['changerDefaut', { sieges: 6, realigner: false }], ['changerDefaut', { sieges: 6, realigner: false }],
['changerTours', { tours: 3 }], ['changerTours', { tours: 3 }],
['modifierReglage', { cle: 'nouveauxVoisins', valeur: false }], ['modifierReglage', { cle: 'nouveauxVoisins', valeur: false }],
['changerCibleRedondance', { cible: '1200' }],
['poserObjectif', { table: 1, tour: null, etiquettes: 'finance', regle: null }], ['poserObjectif', { table: 1, tour: null, etiquettes: 'finance', regle: null }],
['retirerObjectif', { table: 1, tour: null }], ['retirerObjectif', { table: 1, tour: null }],
['changerRegleObjectifs', { regle: 'et' }], ['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)', () => { describe('mobilier : la cible de la somme de redondance n’est plus un réglage (§ 8.9)', () => {
test('« Cible de la somme de redondance : 1200 », une entrée ; défaire rend la charge sans le champ', async () => { test('aucune commande ne la pose : ni changerCibleRedondance au registre, ni modifierReglage sous sa clé', () => {
const { seance, entrees } = await seanceSur(chargeDe({ tables: [{ id: 1 }], n: 2 })); assert.equal(COMMANDES.has('changerCibleRedondance'), false);
const avant = entrees(); assert.equal(COMMANDES_MOBILIER.has('changerCibleRedondance'), false);
assert.equal(Object.hasOwn(seance.charge.reglages, 'cibleRedondance'), false);
const rendu = await seance.executer('changerCibleRedondance', { cible: '1200' });
assert.equal(rendu.libelle, 'Cible de la somme de redondance : 1200');
assert.equal(entrees() - avant, 1);
assert.equal(seance.charge.reglages.cibleRedondance, 1200);
await seance.defaire();
assert.equal(Object.hasOwn(seance.charge.reglages, 'cibleRedondance'), false);
assert.equal(cibleRedondance(seance.charge), 1000);
});
test('la saisie se lit par lireEntier, 0 compris ; revenir à 1000 retire le champ, et la charge s’écrit comme sans lui', () => {
const charge = chargeDe({ tables: [{ id: 1 }], n: 2 }); const charge = chargeDe({ tables: [{ id: 1 }], n: 2 });
const zero = accepter('changerCibleRedondance', charge, { cible: ' 0 ' }); assert.throws(() => COMMANDES.get('modifierReglage')(charge, { cle: 'cibleRedondance', valeur: 1200 }), TypeError);
assert.equal(zero.charge.reglages.cibleRedondance, 0);
assert.equal(zero.libelle, 'Cible de la somme de redondance : 0');
const nombre = accepter('changerCibleRedondance', charge, { cible: 750 });
assert.equal(nombre.charge.reglages.cibleRedondance, 750);
const retour = accepter('changerCibleRedondance', nombre.charge, { cible: '1000' });
assert.equal(Object.hasOwn(retour.charge.reglages, 'cibleRedondance'), false);
assert.equal(retour.libelle, 'Cible de la somme de redondance : 1000');
assert.equal(serialiser(retour.charge, { revision: 1, produitVersion: V1 }), serialiser(charge, { revision: 1, produitVersion: V1 }));
}); });
test('une saisie négative, décimale ou vide est refusée NON_ENTIER {saisie}, remède null ; la même valeur, SANS_EFFET', () => { 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 charge = chargeDe({ tables: [{ id: 1 }], n: 2 });
const saisies = ['-5', '2,5', '', 'mille']; const ancienne = structuredClone(charge);
assert.ok(saisies.length > 0); ancienne.reglages.cibleRedondance = 1200;
for (const saisie of saisies) { const rendu = accepter('modifierReglage', ancienne, { cle: 'nouveauxVoisins', valeur: false });
refuse(() => COMMANDES.get('changerCibleRedondance')(charge, { cible: saisie }), 'NON_ENTIER', { saisie, remede: null }); const attendu = accepter('modifierReglage', charge, { cle: 'nouveauxVoisins', valeur: false });
} const texte = serialiser(rendu.charge, { revision: 1, produitVersion: V1 });
refuse(() => appliquerCommande('changerCibleRedondance', charge, { cible: '1000' }), 'SANS_EFFET', { assert.equal(texte.includes('cibleRedondance'), false);
commande: 'changerCibleRedondance', assert.equal(texte, serialiser(attendu.charge, { revision: 1, produitVersion: V1 }));
});
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);
}); });
}); });

View file

@ -25,8 +25,8 @@
// rencontres répétées imposées dépendent du plan, et chaque ligne les lit // rencontres répétées imposées dépendent du plan, et chaque ligne les lit
// dans ses mesures, à part des choisies. // dans ses mesures, à part des choisies.
// //
// resumeDuRetenu donne à la liste des événements la différence à la cible // resumeDuRetenu donne à la liste des événements les collisions cumulées
// du retenu, lue dans la même mesure que sa ligne du tableau, sans le // 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 // diagnostic ni les plafonds : la liste en résume chaque événement du
// dossier. premiereDuClassement est la seule définition de la proposition // 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, // 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 { detailler, planchersParGroupe, profilRencontres } from '../moteur/qualite.js';
import { verifierIndicateurs, verifierInvariants } from '../moteur/verification.js'; import { verifierIndicateurs, verifierInvariants } from '../moteur/verification.js';
import { ErreurConfiguration } from '../moteur/erreurs.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 { derive, deriveSansRevalider, fautes, planDepuisFichier, planSansRevalider } from '../stockage/placements.js';
import { configurationDe } from './configuration.js'; import { configurationDe } from './configuration.js';
import { nomAffiche } from './personnes.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 // 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) : // chaque critère, tels que le registre les tient (contrainte 35) :
// l'interface, qui n'importe pas le moteur, les lit ici. // 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, * 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 * collisionsCumulees, les occurrences (paire, tour) où deux personnes d'une
* (cibleRedondance) moins la somme de redondance Σ r(p) du retenu, celle de * même appartenance partagent une table, celles de mesurer — la valeur que
* mesurer, que la ligne du retenu d'evaluer porte aussi. null sans retenu, * la ligne du retenu d'evaluer porte, et que la colonne « Collisions
* pour un retenu fautif ou en dérive, et quand la configuration ne se * cumulées (paire et tour) » du tableau des propositions écrit. null sans
* normalise pas : rien ne s'y mesure, et la liste écrit « — ». Ne modifie * retenu, pour un retenu fautif ou en dérive, et quand la configuration ne
* pas la charge. * se normalise pas : rien ne s'y mesure, et la liste écrit « — ». Ne
* modifie pas la charge.
* *
* @param {import('../stockage/types.js').Charge} charge * @param {import('../stockage/types.js').Charge} charge
* @returns {{diffRedondance: number}|null} * @returns {{collisionsCumulees: number}|null}
*/ */
export function resumeDuRetenu(charge) { export function resumeDuRetenu(charge) {
const { retenu } = charge; const { retenu } = charge;
@ -241,8 +239,8 @@ export function resumeDuRetenu(charge) {
if (erreur instanceof ErreurConfiguration) return null; if (erreur instanceof ErreurConfiguration) return null;
throw erreur; throw erreur;
} }
const { totalRedondance } = mesurer(instance, indexerPlan(instance, planDepuisFichier(retenu))); const { collisionsCumulees } = mesurer(instance, indexerPlan(instance, planDepuisFichier(retenu)));
return { diffRedondance: Math.abs(cibleRedondance(charge) - totalRedondance) }; return { collisionsCumulees };
} }
/** /**

View file

@ -46,9 +46,8 @@ import { deriveSansRevalider, fautes, planDepuisFichier } from '../stockage/plac
import { VERSION } from '../version.genere.js'; import { VERSION } from '../version.genere.js';
import { appliquerCommande } from './commandes.js'; import { appliquerCommande } from './commandes.js';
import { configurationDe } from './configuration.js'; import { configurationDe } from './configuration.js';
import * as EVALUATION from './evaluation.js';
import { import {
CIBLE_REDONDANCE_PAR_DEFAUT,
cibleRedondance,
detaillerQualite, detaillerQualite,
evaluer, evaluer,
premiereDuClassement, 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. // La petite démonstration, trois propositions générées, la deuxième retenue.
function retenue() { function retenue() {
const generees = generee(chargeDemo('petite-conflit'), { graine: 3, nombre: 3, arret: 300 }); const generees = generee(chargeDemo('petite-conflit'), { graine: 3, nombre: 3, arret: 300 });
return executer(generees, 'retenirProposition', { id: generees.propositions[1].id }); 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 charge = figer(retenue());
const resume = resumeDuRetenu(charge); const resume = resumeDuRetenu(charge);
const { mesures } = evaluer(charge).retenu; const { mesures } = evaluer(charge).retenu;
assert.ok(mesures.totalRedondance !== 1000, 'la différence se distingue de zéro'); assert.ok(mesures.collisionsCumulees > 0, 'le retenu porte des collisions');
assert.deepEqual(resume, { diffRedondance: Math.abs(1000 - mesures.totalRedondance) }); assert.notEqual(mesures.collisionsCumulees, mesures.totalRedondance);
assert.deepEqual(resume, { collisionsCumulees: mesures.collisionsCumulees });
const instance = normaliser(configurationDe(charge)); const instance = normaliser(configurationDe(charge));
const attendues = mesurer(instance, indexerPlan(instance, planDepuisFichier(charge.retenu))); const attendues = mesurer(instance, indexerPlan(instance, planDepuisFichier(charge.retenu)));
assert.equal(attendues.totalRedondance, mesures.totalRedondance); assert.equal(attendues.collisionsCumulees, mesures.collisionsCumulees);
assert.equal(CIBLE_REDONDANCE_PAR_DEFAUT, 1000);
assert.equal(cibleRedondance(charge), 1000);
}); });
test("une cible propre à l'événement : la différence se lit contre elle, en valeur absolue, des deux côtés", () => { 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 charge = retenue();
const total = evaluer(charge).retenu.mesures.totalRedondance; const avecChamp = structuredClone(charge);
for (const cible of [0, total, total + 7, 5_000]) { avecChamp.reglages.cibleRedondance = 25;
const avecCible = executer(charge, 'changerCibleRedondance', { cible: String(cible) }); assert.deepEqual(resumeDuRetenu(avecChamp), resumeDuRetenu(charge));
assert.equal(cibleRedondance(avecCible), cible); assert.equal(Object.hasOwn(EVALUATION, 'cibleRedondance'), false);
assert.deepEqual(resumeDuRetenu(avecCible), { diffRedondance: Math.abs(cible - total) }, String(cible)); assert.equal(Object.hasOwn(EVALUATION, 'CIBLE_REDONDANCE_PAR_DEFAUT'), false);
}
const dessous = executer(charge, 'changerCibleRedondance', { cible: '0' });
assert.equal(resumeDuRetenu(dessous).diffRedondance, total);
}); });
test('sans retenu, null ; un retenu fautif ou en dérive, null, sans lever', () => { 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 }); 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(); const { fs, facade } = await deuxEvenements();
fs.deposer('portable', `retenue${SUFFIXES.etat}`, texteRetenu()); fs.deposer('portable', `retenue${SUFFIXES.etat}`, texteRetenu());
const avant = ecritures(fs); 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 par = Object.fromEntries(lignes.map((ligne) => [ligne.base, ligne]));
const charge = analyser(texteRetenu()).charge; const charge = analyser(texteRetenu()).charge;
const { mesures } = evaluer(charge).retenu; 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.deepEqual(par.retenue.mesuresDuRetenu, resumeDuRetenu(charge));
assert.equal(par['soiree-a'].mesuresDuRetenu, null); assert.equal(par['soiree-a'].mesuresDuRetenu, null);
assert.equal(par.petite.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 { fs, facade } = await deuxEvenements();
const texte = texteRetenu((charge) => appliquerCommande('changerCibleRedondance', charge, { cible: '25' }).charge); const lu = JSON.parse(texteRetenu());
fs.deposer('portable', `retenue${SUFFIXES.etat}`, texte); 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 ligne = (await listerResume(facade)).find(({ base }) => base === 'retenue');
const { totalRedondance } = evaluer(analyser(texte).charge).retenu.mesures; assert.equal(ligne.illisible, null);
assert.notEqual(Math.abs(25 - totalRedondance), Math.abs(1000 - totalRedondance)); assert.deepEqual(ligne.mesuresDuRetenu, resumeDuRetenu(analyser(texteRetenu()).charge));
assert.deepEqual(ligne.mesuresDuRetenu, { diffRedondance: Math.abs(25 - totalRedondance) });
}); });
test('un retenu fautif, ou en dérive, et un état illisible : null, la liste entière rendue, rien d’écrit', async () => { 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 lignes = await facade.lister();
const recus = new Map(); const recus = new Map();
const flux = facade.resumer(lignes, { ceder: pasDe(horloge), recevoir: (base, resume) => recus.set(base, resume) }); 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 // L'autre proposition devient le retenu après la liste : le résumé du
// la lit, et non celle que la liste a vue. // pas lit ses collisions, et non celles du retenu que la liste a vu.
const plusTard = texteRetenu((charge) => appliquerCommande('changerCibleRedondance', charge, { cible: '25' }).charge); const plusTard = texteRetenu((charge) => appliquerCommande('retenirProposition', charge, { id: charge.propositions[0].id }).charge);
fs.deposer('portable', `retenue${SUFFIXES.etat}`, plusTard); fs.deposer('portable', `retenue${SUFFIXES.etat}`, plusTard);
for (let pas = 0; pas < lignes.length; pas += 1) await unPas(horloge); for (let pas = 0; pas < lignes.length; pas += 1) await unPas(horloge);
await flux.fin; await flux.fin;
const { totalRedondance } = evaluer(analyser(plusTard).charge).retenu.mesures; const { collisionsCumulees } = evaluer(analyser(plusTard).charge).retenu.mesures;
assert.notEqual(Math.abs(25 - totalRedondance), Math.abs(1000 - totalRedondance)); assert.notEqual(collisionsCumulees, evaluer(analyser(texteRetenu()).charge).retenu.mesures.collisionsCumulees);
assert.equal(recus.get('retenue').mesuresDuRetenu.diffRedondance, Math.abs(25 - totalRedondance)); assert.equal(recus.get('retenue').mesuresDuRetenu.collisionsCumulees, collisionsCumulees);
assert.deepEqual(recus.get('retenue'), resumesAttendus(plusTard)); 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_DEFAUT', { sieges: 10, realignees: 4 }],
['ENTREE_CHANGER_TOURS', { tours: 3, retirees: 2 }], ['ENTREE_CHANGER_TOURS', { tours: 3, retirees: 2 }],
['ENTREE_MODIFIER_REGLAGE', { cle: 'separerAppartenances', valeur: false }], ['ENTREE_MODIFIER_REGLAGE', { cle: 'separerAppartenances', valeur: false }],
['ENTREE_CHANGER_CIBLE_REDONDANCE', { cible: 1200 }],
// Objectifs des tables // Objectifs des tables
['OBJECTIF_HORS_SOIREE', { tour: 5, remede: null }], ['OBJECTIF_HORS_SOIREE', { tour: 5, remede: null }],
['OBJECTIF_SANS_ETIQUETTE', { table: 2, tour: 3, remede: { commande: 'retirerObjectif', arguments: { table: 2, tour: 3 } } }], ['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 }) => ({ cle, valeur }) =>
`${nomDans(NOMS_DES_REGLAGES, cle, 'réglage')} : ${cle === 'modeObjectifs' ? nomMode(valeur) : valeurDeReglage(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', 'ENTREE_POSER_OBJECTIF',
({ numero, tour, regle, etiquettes }) => ({ numero, tour, regle, etiquettes }) =>

View file

@ -400,14 +400,6 @@ const EXEMPLES = new Map([
sansEffet: { args: { cle: 'attribuerSieges', valeur: false } }, 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', '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 // 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 // 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. // 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) { async function retour(sens, visee) {
const ouvert = exigerEcriture(); const ouvert = exigerEcriture();
const { cibleDefaire, cibleRefaire, courant } = filDe(ouvert); const { cibleDefaire, cibleRefaire, courant } = filDe(ouvert);
@ -1062,7 +1068,9 @@ export function creerSeance({
cible = cibleRefaire; cible = cibleRefaire;
} }
const charge = avecCompteursCourants(restituer(ouvert, cible), ouvert.charge); 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 instant = sens === 'defaire' ? courant : libelleDeRevision(ouvert.journal, cible);
const entree = { charge, libelle: libelleDuRetour(sens, instant), avertissements: [] }; const entree = { charge, libelle: libelleDuRetour(sens, instant), avertissements: [] };
const texte = await ecrireGeste(ouvert, entree, { sens, retour: cible }); 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 // se remplace par un autre dossier. Les noms d'épreuve sont inventés ; la
// version vient du module engendré. // version vient du module engendré.
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { PanneSimulee, creerFichiersSimules } from '../../test/fichiers_simules.js'; import { PanneSimulee, creerFichiersSimules } from '../../test/fichiers_simules.js';
import { H_RETENU, personne as personneBadge, soiree, table as tableBadge } from '../../test/fixtures/badges/cas.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 { apercevoir } from '../csv/apercu.js';
import { nombrePlacesManquantes, normaliser } from '../moteur/configuration.js'; import { nombrePlacesManquantes, normaliser } from '../moteur/configuration.js';
import { rechercher } from '../moteur/recherche.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 { creer as creerDansLeDepot, ouvrir as ouvrirDansLeDepot } from '../stockage/depot.js';
import { FORMAT, analyser, configurationDepuisCharge, creerCharge, etatDeduit } from '../stockage/document.js'; import { FORMAT, analyser, configurationDepuisCharge, creerCharge, etatDeduit } from '../stockage/document.js';
import { ErreurStockage } from '../stockage/erreurs.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)', () => { 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 () => { 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(); const fs = creerFichiersSimules();

View file

@ -29,7 +29,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { cdp } from 'vitest/browser'; import { cdp } from 'vitest/browser';
import { flushSync } from 'svelte'; import { flushSync } from 'svelte';
import { appliquerCommande } from '../application/commandes.js'; 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 { creerFormat } from '../application/format.js';
import { TABLES_APPLICATION } from '../application/libelles.js'; import { TABLES_APPLICATION } from '../application/libelles.js';
import { creerTraduction } from '../application/traduction.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)', () => { describe('les résumés de la liste relue (§ 8.1)', () => {
// La petite démonstration au conflit inévitable, deux propositions du // La petite démonstration au conflit inévitable, deux propositions du
// moteur, la seconde retenue, sous la cible de redondance donnée ; la // moteur, celle du rang donné retenue.
// cible par défaut n'a pas de réglage écrit. function texteRetenu(rang) {
function texteRetenu(cible) {
let charge = analyser(CONFLIT).charge; let charge = analyser(CONFLIT).charge;
const configuration = configurationDepuisCharge(charge); const configuration = configurationDepuisCharge(charge);
const propositions = rechercher(configuration, { graine: 6, nombre: 2, arret: 300 }); const propositions = rechercher(configuration, { graine: 6, nombre: 2, arret: 300 });
charge = appliquerCommande('enregistrerGeneration', charge, { propositions, configuration, produitVersion: VERSION.affichee }).charge; charge = appliquerCommande('enregistrerGeneration', charge, { propositions, configuration, produitVersion: VERSION.affichee }).charge;
charge = appliquerCommande('retenirProposition', charge, { id: charge.propositions[1].id }).charge; charge = appliquerCommande('retenirProposition', charge, { id: charge.propositions[rang].id }).charge;
if (cible !== cibleRedondance(charge)) {
charge = appliquerCommande('changerCibleRedondance', charge, { cible: String(cible) }).charge;
}
return serialiser(charge, { revision: 1, produitVersion: VERSION.affichee }); return serialiser(charge, { revision: 1, produitVersion: VERSION.affichee });
} }
// La différence à la cible que la ligne d'un texte d'état écrit. // Les collisions cumulées du retenu que la ligne d'un texte d'état écrit.
const difference = (texte) => { const collisions = (texte) => format.nombre(evaluer(analyser(texte).charge).retenu.mesures.collisionsCumulees);
const charge = analyser(texte).charge; const cellule = (m) => dans(m, '[data-evenement="bal"] [data-colonne="collisionsCumulees"]');
return format.nombre(Math.abs(cibleRedondance(charge) - evaluer(charge).retenu.mesures.totalRedondance));
};
const cellule = (m) => dans(m, '[data-evenement="bal"] [data-colonne="diffRedondance"]');
const uneImage = () => new Promise((resoudre) => requestAnimationFrame(() => resoudre())); const uneImage = () => new Promise((resoudre) => requestAnimationFrame(() => resoudre()));
// Retient la prochaine lecture de l'état de « bal » : elle lit le disque à // 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 () => { 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 ancien = texteRetenu(1);
const nouveau = texteRetenu(25); const nouveau = texteRetenu(0);
expect(difference(ancien)).not.toBe(difference(nouveau)); expect(collisions(ancien)).not.toBe(collisions(nouveau));
const m = await monter({ fichiers: [{ racine: 'documents', chemin: 'bal.gtt.json', texte: ancien }] }); const m = await monter({ fichiers: [{ racine: 'documents', chemin: 'bal.gtt.json', texte: ancien }] });
const retenue = retenirLecture(m); const retenue = retenirLecture(m);
// Le premier pas de la première lecture lit l'état, et reste suspendu. // 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(); await uneImage();
m.horloge.avancer(0); m.horloge.avancer(0);
flushSync(); 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. // La lecture d'avant s'achève : son calcul ne se pose pas.
retenue.liberer(); 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(); for (let image = 0; image < 3; image += 1) await uneImage();
m.horloge.avancer(0); m.horloge.avancer(0);
flushSync(); 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 () => { test('un événement ouvert pendant le chargement l’abandonne : l’horloge avancée ne relit plus aucun état', async () => {
const m = await monter({ const m = await monter({
fichiers: [ 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 }) }, { 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 () => { test('une lecture de la liste qui s’achève sous un événement ouvert ne lance aucun chargement', async () => {
const m = await monter({ const m = await monter({
fichiers: [ 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 }) }, { 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). // Les mesures du retenu, par le moteur sollicité une seconde fois (§ 19.1).
function mesuresDe(charge) { function mesuresDe(charge) {
const instance = normaliser(configurationDepuisCharge(charge)); const instance = normaliser(configurationDepuisCharge(charge));
return mesurer(instance, indexerPlan(instance, planDepuisFichier(charge.retenu))); 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 charge = chargeRetenue();
const mesures = mesuresDe(charge); const mesures = mesuresDe(charge);
expect(mesures.collisionsCumulees).toBeGreaterThan(0);
expect(mesures.collisionsCumulees).not.toBe(Math.abs(1000 - mesures.totalRedondance));
const m = await monter({ const m = await monter({
fichiers: [fichier(`retenue${SUFFIXES.etat}`, etat(charge)), fichier(`club${SUFFIXES.etat}`, texteRenomme('Club des Merles'))], 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); await chargerResumes(m);
const avant = ecritures(m); const avant = ecritures(m);
const retenue = ligneDe(m, 'retenue'); 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'); 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 // 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 // liste, au texte de la colonne du tableau des propositions ; plus rien
// cumulées. // ne nomme la différence à la cible.
expect(tous(m, '[data-terme="diffRedondance"]').length).toBe(1); expect(tous(m, '[data-terme="collisionsCumulees"]').length).toBe(1);
expect(terme(accueil(m), 'diffRedondance').closest('thead')).not.toBeNull(); expect(terme(accueil(m), 'collisionsCumulees').closest('thead')).not.toBeNull();
expect(terme(accueil(m), 'diffRedondance').textContent).toBe(t('accueil.colonne.diffRedondance')); expect(terme(accueil(m), 'collisionsCumulees').textContent).toBe(t('propositions.colonne.collisionsCumulees'));
expect(tous(m, '[data-colonne="collisionsCumulees"], [data-terme="collisionsCumulees"]')).toEqual([]); expect(tous(m, '[data-colonne="diffRedondance"], [data-terme="diffRedondance"]')).toEqual([]);
expect(tous(m, '[data-ecran="accueil"] thead th').filter((th) => th.textContent.includes(t('propositions.colonne.collisionsCumulees')))).toEqual([]); expect(accueil(m).textContent).not.toContain("Diff somme d'appartenance");
expect(visible(retenue.querySelector('[data-colonne="diffRedondance"]'))).toBe(true); expect(visible(retenue.querySelector('[data-colonne="collisionsCumulees"]'))).toBe(true);
expect(ecritures(m)).toBe(avant); 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 () => { 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()))] }); const m = await monter({ fichiers: [fichier(`retenue${SUFFIXES.etat}`, etat(chargeRetenue()))] });
await vi.waitFor(() => expect(lignes(m).length).toBe(1)); 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 charge = chargeRetenue();
const { totalRedondance } = mesuresDe(charge); const lu = JSON.parse(etat(charge));
const m = await monter({ fichiers: [fichier(`retenue${SUFFIXES.etat}`, 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 vi.waitFor(() => expect(lignes(m).length).toBe(1));
await chargerResumes(m); 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 ouvrirLigne(m, 'retenue');
await attendreEvenement(m); await attendreEvenement(m);
await m.seance.passerEnEcriture(); expect(Object.hasOwn(m.seance.etat().charge.reglages, 'cibleRedondance')).toBe(false);
m.ui.allerA('propositions'); expect(m.seance.etat().avertissements ?? []).toEqual([]);
await vi.waitFor(() => expect(dans(m, '[data-section="propositions"] [data-champ="cibleRedondance"]')).not.toBeNull()); expect(ecritures(m)).toBe(avant);
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)));
}); });
}); });
@ -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. // 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)); const cellules = (ligne) => CELLULES.map((selecteur) => ligne.querySelector(selecteur));
// Ce que la ligne d'une charge écrit, par la table d'évaluation sollicitée // Ce que la ligne d'une charge écrit, par la table d'évaluation sollicitée
// une seconde fois (§ 19.1). // une seconde fois (§ 19.1).
function attendues(charge) { function attendues(charge) {
const table = evaluer(charge); const table = evaluer(charge);
const [premiere] = table.propositions; 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 // 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), // 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'); 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); 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); 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, // Le nom est l'en-tête de sa ligne ; la date et le chemin le suivent,
// dessous, en plus petit. // dessous, en plus petit.

View file

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

View file

@ -80,8 +80,8 @@ describe('ligneAffichee : les six informations (§ 8.1)', () => {
const affichee = ligneAffichee(ligne(), format); const affichee = ligneAffichee(ligne(), format);
assert.deepEqual(Object.keys(affichee).sort(), [ assert.deepEqual(Object.keys(affichee).sort(), [
'chemin', 'chemin',
'collisionsCumulees',
'date', 'date',
'diffRedondance',
'enAttente', 'enAttente',
'etat', 'etat',
'meilleure', '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)', () => { describe('ligneAffichee : les mesures du retenu (§ 5.4, § 8.1)', () => {
test('la différence à la cible, celle de la façade, mise en forme, sans calcul ici', () => { test('les collisions cumulées du retenu, celles de la façade, mises en forme, sans calcul ici', () => {
for (const diffRedondance of [500, 7, 0]) { for (const collisionsCumulees of [500, 7, 0]) {
const affichee = ligneAffichee(ligne(), format, { ...AUCUN, mesuresDuRetenu: { diffRedondance } }); const affichee = ligneAffichee(ligne(), format, { ...AUCUN, mesuresDuRetenu: { collisionsCumulees } });
assert.equal(affichee.diffRedondance, format.nombre(diffRedondance), String(diffRedondance)); 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 } }]) { for (const champs of [{}, { nom: null, etat: null, participants: null, illisible: { raison: 'VIDE', chemin: null } }]) {
const affichee = ligneAffichee(ligne(champs), format, AUCUN); 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', () => { 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( assert.deepEqual(COLONNES_DU_RETENU, [
COLONNES_DU_RETENU.map(({ colonne }) => colonne), {
['diffRedondance'], colonne: 'collisionsCumulees',
); cle: 'propositions.colonne.collisionsCumulees',
explication: 'accueil.explication.collisionsCumulees',
},
]);
assert.ok(Object.isFrozen(COLONNES_DU_RETENU)); assert.ok(Object.isFrozen(COLONNES_DU_RETENU));
for (const { colonne, cle, explication } of COLONNES_DU_RETENU) { assert.ok(Object.isFrozen(COLONNES_DU_RETENU[0]));
assert.equal(cle, `accueil.colonne.${colonne}`); assert.equal(t('propositions.colonne.collisionsCumulees'), 'Collisions cumulées (paire et tour)');
assert.equal(explication, `accueil.explication.${colonne}`); const explication = t('accueil.explication.collisionsCumulees');
assert.ok(t(cle).length > 0); for (const fragment of ['paire', 'tour', 'Collisions cumulées (paire et tour)', '\u{2014}']) {
assert.ok(t(explication).length > 0); 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', () => { 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); const attente = ligneAffichee(ligne(), format);
assert.equal(attente.enAttente, true); assert.equal(attente.enAttente, true);
assert.equal(attente.diffRedondance, VIDE); assert.equal(attente.collisionsCumulees, VIDE);
assert.deepEqual(attente.meilleure, { id: VIDE }); assert.deepEqual(attente.meilleure, { id: VIDE });
assert.equal(ligneAffichee(ligne(), format, AUCUN).enAttente, false); assert.equal(ligneAffichee(ligne(), format, AUCUN).enAttente, false);
const recu = ligneAffichee(ligne(), format, { const recu = ligneAffichee(ligne(), format, {
mesuresDuRetenu: { diffRedondance: 10 }, mesuresDuRetenu: { collisionsCumulees: 10 },
meilleureDuClassement: { id: 3 }, meilleureDuClassement: { id: 3 },
}); });
assert.equal(recu.enAttente, false); 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) }); 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', () => { 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))); 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(); const libelleDe = (cible, cle) => cible.querySelector(`label[for="${entree(cible, cle).id}"]`).textContent.trim();
describe('Reglages : l’écran montre la définition (§ 19.8)', () => { 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(); const cible = await monter();
expect(DE_LA_GENERATION.length).toBeGreaterThan(0); 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(designations(cible)).toEqual(['graine', ...designationsAttendues(REGLAGES_GENERATION), 'coeursAutomatiques']);
expect(cible.querySelector('[data-champ="cibleRedondance"]')).toBeNull(); expect(cible.querySelector('[data-champ="cibleRedondance"]')).toBeNull();
for (const { cle, etiquette, genre, defaut, valeurs } of DE_LA_GENERATION) { 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 ; // generation.<nom> pour un champ de la génération, <nom> sinon ;
// etiquette la clé du libellé, qui porte l'unité pour un champ ; // etiquette la clé du libellé, qui porte l'unité pour un champ ;
// unite ce qu'un champ compte, 'propositions' ou 'mouvements' ; null // unite ce qu'un champ compte, 'propositions' ou 'mouvements' ; null
// pour une bascule, un choix, l'ordre, et pour la cible, une // pour une bascule, un choix, l'ordre ;
// somme sans unité ;
// defaut la valeur d'une charge neuve, ou celle d'un réglage facultatif // defaut la valeur d'une charge neuve, ou celle d'un réglage facultatif
// que la charge ne porte pas ; // que la charge ne porte pas ;
// genre 'champ', une saisie entière ; 'bascule', une case à cocher ; // 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 // l'ordre des priorités, une liste de paliers que son éditeur
// règle (Priorites.svelte) ; // règle (Priorites.svelte) ;
// section la section de l'écran qui le montre : 'generation', écrit par // section la section de l'écran qui le montre : 'generation', écrit par
// modifierReglage — l'ordre par changerPriorites —, ou // modifierReglage — l'ordre par changerPriorites — ;
// 'propositions', la cible de la somme de redondance, écrite par
// changerCibleRedondance ;
// valeurs les valeurs d'un choix, dans l'ordre de ses boutons ; le // valeurs les valeurs d'un choix, dans l'ordre de ses boutons ; le
// libellé de chacune est <etiquette>.<valeur>, son explication // libellé de chacune est <etiquette>.<valeur>, son explication
// <etiquette>.<valeur>.explication ; // <etiquette>.<valeur>.explication ;
@ -40,23 +37,22 @@
// //
// Les défauts se lisent sur la charge d'un événement neuf, telle que la // 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 // commande de création la pose (chargeNeuve) : creerCharge y copie
// GENERATION_PAR_DEFAUT et y écrit les bascules, et l'interface n'importe // GENERATION_PAR_DEFAUT et y écrit les bascules, et l'interface n'importe pas
// pas le stockage. La charge lue ici n'est écrite nulle part. Les réglages // 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 // facultatifs n'y sont pas : l'ordre des priorités a pour défaut
// CIBLE_REDONDANCE_PAR_DEFAUT, l'ordre des priorités LISTE_PAR_DEFAUT, lue // LISTE_PAR_DEFAUT, lue dans le registre par l'application (priorites.js). Le
// dans le registre par l'application (priorites.js). Le mode prend ses // mode prend ses valeurs et son défaut, le premier, dans MODES_OBJECTIFS, et
// valeurs et son défaut, le premier, dans MODES_OBJECTIFS, et son critère // son critère dans CASES, celui que sa case fait compter : le registre, que
// dans CASES, celui que sa case fait compter : le registre, que
// l'application réexporte (contrainte 35). Les deux cases portent ici le // 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 // défaut que le stockage leur lit (§ 8.9), et les épreuves de
// reglages.test.js les comparent à ses lecteurs. // reglages.test.js les comparent à ses lecteurs.
import { chargeNeuve } from '../../application/commandes.js'; 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 { SIEGES_PAR_DEFAUT, TOURS_PAR_DEFAUT } from '../../application/evenements.js';
import { LISTE_PAR_DEFAUT } from './priorites.js'; import { LISTE_PAR_DEFAUT } from './priorites.js';
/** @typedef {'champ'|'bascule'|'choix'|'ordre'} GenreReglage */ /** @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, * @typedef {Readonly<{nom: string, cle: string, etiquette: string, unite: 'propositions'|'mouvements'|null,
* defaut: number|boolean|string|ReadonlyArray<ReadonlyArray<{critere: string, poids: number}>>, * defaut: number|boolean|string|ReadonlyArray<ReadonlyArray<{critere: string, poids: number}>>,
@ -126,22 +122,11 @@ const PRIORITES = Object.freeze({
section: 'generation', 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 * 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 * 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 * 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 * montre sous sa case ; enfin l'ordre des priorités.
* de redondance (§ 8.9), de la section des propositions.
* *
* @type {ReadonlyArray<EntreeReglage>} * @type {ReadonlyArray<EntreeReglage>}
*/ */
@ -158,7 +143,6 @@ export const REGLAGES_GENERATION = Object.freeze([
bascule('attribuerSieges'), bascule('attribuerSieges'),
MODE_OBJECTIFS, MODE_OBJECTIFS,
PRIORITES, PRIORITES,
CIBLE,
]); ]);
/** /**

View file

@ -3,8 +3,8 @@
// Épreuves de la définition des réglages de la charge (§ 5.3, § 5.7, // É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 // § 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 // défauts, ceux d'une charge neuve, GENERATION_PAR_DEFAUT compris, et ceux
// CIBLE_REDONDANCE_PAR_DEFAUT compris, et ceux que les lecteurs du stockage // que les lecteurs du stockage
// donnent aux réglages facultatifs — les deux cases, le mode des objectifs, // 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 // 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 // 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 { creerTraduction } from '../../application/traduction.js';
import { MODES_OBJECTIFS } from '../../moteur/criteres.js'; import { MODES_OBJECTIFS } from '../../moteur/criteres.js';
import { import {
CIBLE_REDONDANCE_PAR_DEFAUT,
GENERATION_PAR_DEFAUT, GENERATION_PAR_DEFAUT,
SCHEMA, SCHEMA,
creerCharge, creerCharge,
@ -66,7 +65,6 @@ const ECRITURES = new Map([
(entree, valeur) => (entree, valeur) =>
entree.genre === 'ordre' ? ['changerPriorites', { paliers: valeur }] : ['modifierReglage', { cle: entree.cle, 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 // 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); 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( assert.deepEqual(
REGLAGES_GENERATION.map(({ nom, genre, section }) => [section, genre, nom]), 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', 'bascule', 'attribuerSieges'],
['generation', 'choix', 'modeObjectifs'], ['generation', 'choix', 'modeObjectifs'],
['generation', 'ordre', 'priorites'], ['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)); 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é', () => { test('la cible de la somme de redondance n’est plus un réglage : ni entrée, ni section des propositions', () => {
assert.deepEqual(reglagesDeSection(REGLAGES_GENERATION, 'propositions'), [ assert.equal(REGLAGES_GENERATION.some(({ nom, cle }) => nom === 'cibleRedondance' || cle === 'cibleRedondance'), false);
{ 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('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(); const { reglages } = chargeNeuveEpreuve();
assert.equal(Object.hasOwn(reglages, 'cibleRedondance'), false);
for (const entree of REGLAGES_GENERATION) { for (const entree of REGLAGES_GENERATION) {
assert.equal(entree.defaut, valeurDuReglage(reglages, entree), entree.nom); 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', () => { 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(); 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); const cles = REGLAGES_GENERATION.map(({ cle }) => cle);
for (const cle of SANS_ENTREE.keys()) { for (const cle of SANS_ENTREE.keys()) {
assert.ok(chemins.includes(cle), `${cle} : absent du schéma, à retirer de SANS_ENTREE`); 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', () => { 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', () => { 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 [nombre] = REGLAGES_GENERATION;
const nouvelleTable = REGLAGES_GENERATION.find(({ nom }) => nom === 'nouvelleTable'); 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, nombre), 9);
assert.equal(valeurDuReglage(reglages, nouvelleTable), false); 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', () => { test('un réglage facultatif absent vaut son défaut', () => {
const cible = REGLAGES_GENERATION.find(({ nom }) => nom === 'cibleRedondance'); const mode = REGLAGES_GENERATION.find(({ nom }) => nom === 'modeObjectifs');
assert.equal(valeurDuReglage({ generation: {} }, cible), CIBLE_REDONDANCE_PAR_DEFAUT); assert.equal(valeurDuReglage({ generation: {} }, { ...mode, defaut: 'chacun' }), 'chacun');
assert.equal(valeurDuReglage({ generation: {} }, { ...cible, defaut: 3 }), 3);
for (const entree of REGLAGES_GENERATION.filter(({ genre }) => genre !== 'champ')) { for (const entree of REGLAGES_GENERATION.filter(({ genre }) => genre !== 'champ')) {
assert.equal(valeurDuReglage({ generation: {} }, entree), entree.defaut, entree.cle); assert.equal(valeurDuReglage({ generation: {} }, entree), entree.defaut, entree.cle);
} }
@ -277,7 +263,7 @@ describe('valeurDuReglage, reglagesDuGenre et reglagesDeSection', () => {
const definition = [...REGLAGES_GENERATION].reverse(); const definition = [...REGLAGES_GENERATION].reverse();
assert.deepEqual( assert.deepEqual(
reglagesDuGenre(definition, 'champ').map(({ nom }) => nom), reglagesDuGenre(definition, 'champ').map(({ nom }) => nom),
['cibleRedondance', 'historique', 'arret', 'nombre'], ['historique', 'arret', 'nombre'],
); );
assert.deepEqual( assert.deepEqual(
reglagesDeSection(definition, 'generation').slice(0, 2).map(({ nom }) => nom), 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 : // 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 // 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. // (§ 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'; import { creerFormat } from '../../application/format.js';
const compte = (n, singulier, pluriel) => `${n} ${n <= 1 ? singulier : pluriel}`; 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.participants', 'Participants'],
['accueil.colonne.modification', 'Dernière modification'], ['accueil.colonne.modification', 'Dernière modification'],
['accueil.colonne.chemin', 'Fichier'], ['accueil.colonne.chemin', 'Fichier'],
// La mesure du placement retenu (§ 5.4, § 8.9), et l'explication que // La mesure du placement retenu (§ 5.4, § 8.1) : son intitulé est celui de
// l'infobulle de son intitulé donne : ce que compte le nombre, d'où il // la colonne du tableau des propositions qui porte la même mesure ;
// vient, et quand il vaut « — ». // l'explication que l'infobulle de l'intitulé donne dit ce que compte le
['accueil.colonne.diffRedondance', "Diff somme d'appartenance"], // nombre, d'où il vient, et quand il vaut « — ».
[ [
'accueil.explication.diffRedondance', 'accueil.explication.collisionsCumulees',
'|cible \u{2212} Σ r(p)| : la différence, sans signe, entre la cible de la soirée et la somme Σ r(p) du placement ' + 'Les collisions du placement retenu. Chaque fois que deux personnes de la même appartenance partagent une ' +
"retenu, celle de la colonne « Redondance d'appartenance, somme Σ r(p) » du tableau des propositions. r(p) compte " + 'table, compté une fois par paire et par tour : deux collègues ensemble aux quatre tours font 4. Le même nombre ' +
"les personnes que p rencontre et qui ont une appartenance, moins le nombre d'appartenances différentes qu'elles " + 'que la colonne « Collisions cumulées (paire et tour) » du tableau des propositions, sur la ligne du retenu. ' +
`portent. La cible vaut ${CIBLE_REDONDANCE_PAR_DEFAUT} tant qu'on ne la change pas, dans la section des ` + "Plus il est bas, mieux c'est. « \u{2014} » quand aucun placement n'est retenu, ou que le retenu ne décrit " +
"propositions. « \u{2014} » quand aucun placement n'est retenu, ou que le retenu ne décrit plus la configuration.", "plus la configuration, ou se contredit.",
], ],
// La meilleure proposition du classement de chaque événement (§ 5.4, // 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.changerDefaut', 'Changer le nombre de places par défaut'],
['communs.remede.changerTours', 'Changer le nombre de tours'], ['communs.remede.changerTours', 'Changer le nombre de tours'],
['communs.remede.modifierReglage', 'Modifier le réglage'], ['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.changerEtiquettes', 'Changer les étiquettes'],
['communs.remede.poserObjectif', "Poser l'objectif"], ['communs.remede.poserObjectif', "Poser l'objectif"],
['communs.remede.retirerObjectif', "Retirer 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.derive', 'Non comparable : la proposition ne décrit plus la configuration courante.'],
['propositions.statut.fautif', 'Non comparable : le placement se contredit.'], ['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) ------------------------------------ // --- Les commandes (§ 2.1 étape 10, § 5.7) ------------------------------------
['propositions.commande.voir', 'Voir sur le plan'], ['propositions.commande.voir', 'Voir sur le plan'],
['propositions.commande.retenir', 'Retenir'], ['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', () => { describe('la cible de la somme de redondance : aucun champ dans la section', () => {
const champCible = (m) => section(m).querySelector('[data-champ="cibleRedondance"]'); 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' }]) {
// Saisit un texte dans le champ, comme une frappe, puis quitte le champ : const m = await ouvrir(chargeDemo('petite'), options);
// la sortie valide la saisie (Champ). expect(section(m).querySelector('[data-champ="cibleRedondance"]')).toBeNull();
async function saisirCible(m, texte) { expect(texteDe(section(m))).not.toContain('Cible de la somme de redondance');
const entree = champCible(m); }
entree.focus();
entree.value = texte;
entree.dispatchEvent(new Event('input', { bubbles: true }));
flushSync();
entree.blur();
flushSync();
}
test('le champ montre 1000 sans réglage ; 25 saisi écrit une entrée, la cible 25 ; défaire rend 1000', async () => {
const m = await ouvrir(chargeDemo('petite'));
const entree = champCible(m);
expect(entree).not.toBeNull();
expect(entree.value).toBe('1000');
expect(texteDe(entree.closest('label') ?? section(m))).toContain(t('propositions.cible.etiquette'));
const avant = revision(m);
await saisirCible(m, '25');
await attendre(() => expect(charge(m).reglages.cibleRedondance).toBe(25));
expect(revision(m)).toBe(avant + 1);
await m.seance.defaire();
await attendre(() => expect(champCible(m).value).toBe('1000'));
expect(Object.hasOwn(charge(m).reglages, 'cibleRedondance')).toBe(false);
});
test('en lecture, la saisie est refusée MODE_LECTURE contre le champ, rien n’écrit ; une saisie négative, NON_ENTIER', async () => {
const lecture = await ouvrir(chargeDemo('petite'), { mode: 'lecture' });
const avant = revision(lecture);
await saisirCible(lecture, '25');
await attendre(() => expect(section(lecture).querySelector('[data-refus="MODE_LECTURE"]')).not.toBeNull());
expect(revision(lecture)).toBe(avant);
expect(Object.hasOwn(charge(lecture).reglages, 'cibleRedondance')).toBe(false);
const ecriture = await ouvrir(chargeDemo('petite'));
const revisionEcriture = revision(ecriture);
await saisirCible(ecriture, '-3');
await attendre(() => expect(section(ecriture).querySelector('[data-refus="NON_ENTIER"]')).not.toBeNull());
expect(revision(ecriture)).toBe(revisionEcriture);
}); });
}); });

View file

@ -63,18 +63,9 @@
// //
// Habillage : le tableau et la phrase de l'ordre vivent dans une carte // 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 // 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. // d'une liste vide 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).
import { getContext, tick, untrack } from 'svelte'; 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 Carte from '../primitives/Carte.svelte';
import Champ from '../primitives/Champ.svelte';
import Commande from '../primitives/Commande.svelte'; import Commande from '../primitives/Commande.svelte';
import Confirmation from '../primitives/Confirmation.svelte'; import Confirmation from '../primitives/Confirmation.svelte';
import Infobulle from '../primitives/Infobulle.svelte'; import Infobulle from '../primitives/Infobulle.svelte';
@ -96,9 +87,6 @@
const t = getContext('t'); const t = getContext('t');
const format = getContext('format'); 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 seance = getContext('seance');
const ui = getContext('ui'); const ui = getContext('ui');
@ -196,7 +184,6 @@
} }
const effacer = () => seance.executer('effacerPropositions', {}); const effacer = () => seance.executer('effacerPropositions', {});
const changerCible = (cible) => seance.executer('changerCibleRedondance', { cible });
const nommer = (id) => nomDe(charge, id); const nommer = (id) => nomDe(charge, id);
</script> </script>
@ -278,21 +265,6 @@
<Infobulle id={idExplication} ouverte={expliquee} fermer={fermerExplication} /> <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} {#if aConfirmer !== null}
<Confirmation <Confirmation
titre={aConfirmer.titre.cle} titre={aConfirmer.titre.cle}
@ -380,16 +352,4 @@
outline: 2px solid var(--couleur-focus); outline: 2px solid var(--couleur-focus);
outline-offset: 2px; 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> </style>

View file

@ -737,7 +737,6 @@ describe('les champs facultatifs du format 2 (§ 8.9, A16)', () => {
charge.reglages.modeObjectifs = 'chacun'; charge.reglages.modeObjectifs = 'chacun';
charge.reglages.respecterObjectifs = false; charge.reglages.respecterObjectifs = false;
charge.reglages.regleObjectifs = 'et'; charge.reglages.regleObjectifs = 'et';
charge.reglages.cibleRedondance = 800;
charge.participants[0].etiquettes = ['finance', 'bénévole', 'Montréal']; charge.participants[0].etiquettes = ['finance', 'bénévole', 'Montréal'];
charge.tables[1].objectifs = [ charge.tables[1].objectifs = [
{ portee: 'tour', tour: 2, regle: 'ou', etiquettes: ['ressources humaines', 'finance'] }, { 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( remplacer(
TEXTE_CONTRAT, TEXTE_CONTRAT,
'"generation":{"nombre":5,"arret":200000,"historique":1000}}', '"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},\n {"id":2',
'"notes":null,"exclu":false,"etiquettes":["Montréal","bénévole","finance"]},\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', () => { test('absents, ils ne s’écrivent pas : la charge du format 1 rend le texte du contrat', () => {
const charge = chargeFormat2(); 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.reglages[cle];
} }
delete charge.participants[0].etiquettes; 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); 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', () => { 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(); const charge = chargeContrat();
charge.participants[1].etiquettes = ['\u{FF21}', 'été', 'Été', '\u{1D538}', 'zèbre']; 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 // Une opération pose une valeur au bout d'un chemin, ou retire
// l'enregistrement qu'il désigne : { op: 'poser', chemin, valeur } ou // l'enregistrement qu'il désigne : { op: 'poser', chemin, valeur } ou
// { op: 'retirer', chemin }. Un champ facultatif du schéma (optionnel) se // { 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 ; // chemin est une liste d'étapes depuis la charge : une clé d'objet, chaîne ;
// un rang de liste, entier ≥ 0 ; ou // un rang de liste, entier ≥ 0 ; ou
// { id }, qui désigne dans une liste d'enregistrements à identifiant — // { 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). // 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 // 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 // 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) { function executer(charge, operation) {
if (!estObjet(operation) || !Array.isArray(operation.chemin)) return false; if (!estObjet(operation) || !Array.isArray(operation.chemin)) return false;
const { op, chemin, valeur } = operation; const { op, chemin, valeur } = operation;
@ -383,6 +388,9 @@ function executer(charge, operation) {
if (conteneur === undefined) return false; if (conteneur === undefined) return false;
} }
const derniere = chemin[chemin.length - 1]; 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 (op === 'poser') {
if (valeur === undefined || !valeurAdmise(charge, chemin, lecture.regle, valeur)) return false; if (valeur === undefined || !valeurAdmise(charge, chemin, lecture.regle, valeur)) return false;
return poser(conteneur, derniere, valeur, lecture.regle, lecture.parent); return poser(conteneur, derniere, valeur, lecture.regle, lecture.parent);
@ -401,12 +409,13 @@ function executer(charge, operation) {
* garde l'ordre des identifiants. * garde l'ordre des identifiants.
* *
* Lève ErreurStockage('CORRECTIF', { rang }) à la première opération qui ne * 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 — : * s'applique pas — rang est sa place dans le correctif, à partir de 0 — : une
* une opération mal formée ; une étape { id } hors des participants, des * opération mal formée ; une étape { id } hors des participants, des tables
* tables et des propositions, même dans une liste vide ; une étape qui ne * et des propositions, même dans une liste vide ; une étape qui ne trouve pas
* trouve pas sa clé, que l'objet porte en propre et n'hérite pas, son rang * sa clé, que l'objet porte en propre et n'hérite pas, son rang ou exactement
* ou exactement un enregistrement ; poser à une clé que l'objet ne porte * un enregistrement ; poser à une clé que l'objet ne porte pas, hors d'un
* pas, hors d'un champ facultatif, ou au-delà de la fin d'une liste, ou sous * 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 * 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 * 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 * 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)', () => { 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 la cible donnée. // 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 sans = () => creerCharge({ id: 'evt-cible', nom: 'Veillée des Grives', siegesParDefaut: 6, tours: 2 });
const avec = (cible) => { const avec = (cible) => {
const charge = sans(); 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']; 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', () => { test('difference ne la pose ni ne la retire jamais : deux charges qui ne diffèrent que par elle ont un correctif vide', () => {
const cas = [ for (const [a, b] of [
[sans(), avec(1200), [{ op: 'poser', chemin: CHEMIN, valeur: 1200 }]], [sans(), avec(1200)],
[avec(1200), sans(), [{ op: 'retirer', chemin: CHEMIN }]], [avec(1200), sans()],
[avec(1200), avec(0), [{ op: 'poser', chemin: CHEMIN, valeur: 0 }]], [avec(1200), avec(0)],
[sans(), sans(), []], ]) {
]; assert.deepEqual(difference(a, b), []);
assert.ok(cas.length > 0); assert.equal(serialiser(a, ENTETE), serialiser(b, ENTETE));
for (const [a, b, attendu] of cas) {
const correctif = difference(a, b);
assert.deepEqual(correctif, attendu);
const rendue = appliquer(a, JSON.parse(JSON.stringify(correctif)));
assert.deepEqual(rendue, canoniser(b));
assert.equal(serialiser(rendue, ENTETE), serialiser(b, ENTETE));
} }
}); });
test('une cible hors de sa règle, un retrait de cible absente ou de tout autre champ, une pose sous une clé inconnue lèvent CORRECTIF', () => { 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 = [ 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'] }], ["retrait d'un réglage obligatoire", avec(10), { op: 'retirer', chemin: ['reglages', 'attribuerSieges'] }],
['pose sous une clé inconnue', sans(), { op: 'poser', chemin: ['reglages', 'cibleInconnue'], valeur: 3 }], ['pose 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 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) { for (const [libelle, charge, operation] of cas) {
assert.deepEqual(refusDe(charge, [operation]), { rang: 0 }, libelle); 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; c.participants[0].exclu = true;
}); });
const b = base((c) => { const b = base((c) => {
c.reglages.cibleRedondance = 900; c.reglages.regleObjectifs = 'et';
c.reglages.respecterObjectifs = false; c.reglages.respecterObjectifs = false;
c.reglages.rencontrerAnimateurs = true; c.reglages.rencontrerAnimateurs = true;
c.participants[0].etiquettes = ['finance']; c.participants[0].etiquettes = ['finance'];
}); });
const rendue = appliquer(a, difference(a, b)); 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))); 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 // 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. // 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 // 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 // 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. // 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 }); 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. // 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. // 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}$/; 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 // 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 // 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 // de leurs clés, et peut nommer des clés oubliées (oubliees), admises de
// sa longueur (min, max) et exiger des éléments distincts (distincts). Un // toute valeur et jamais copiées ; une liste porte la règle de ses éléments,
// champ facultatif (optionnel) peut manquer à son objet : absent, il ne // et peut borner sa longueur (min, max) et exiger des éléments distincts
// s'écrit pas, et sa valeur par défaut se lit par la fonction qui le nomme. // (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) { function regle(genre, proprietes) {
return Object.freeze({ genre, nul: false, optionnel: false, ...proprietes }); return Object.freeze({ genre, nul: false, optionnel: false, ...proprietes });
} }
@ -275,28 +277,32 @@ const PRIORITES = liste(
{ min: 1, optionnel: true }, { min: 1, optionnel: true },
); );
const REGLAGES = objet([ // La cible de la somme de redondance (cibleRedondance), un réglage que les
['separerAppartenances', BOOLEEN], // fichiers ont porté : oubliée, elle se lit de toute valeur et ne s'écrit
['nouveauxVoisins', BOOLEEN], // plus.
['nouvelleTable', BOOLEEN], const REGLAGES = objet(
['varierAppartenances', BOOLEEN], [
['attribuerSieges', BOOLEEN], ['separerAppartenances', BOOLEEN],
['generation', objet([['nombre', entier(1)], ['arret', entier(1)], ['historique', entier(1)]])], ['nouveauxVoisins', BOOLEEN],
// Facultatifs, en dernier, dans l'ordre de A16 : un fichier qui n'en porte ['nouvelleTable', BOOLEEN],
// aucun s'écrit sans eux, au numéro de format près. La cible de la somme ['varierAppartenances', BOOLEEN],
// de redondance (cibleRedondance) ; la règle par défaut des objectifs, ['attribuerSieges', BOOLEEN],
// « et » seulement (regleObjectifs) ; la case des objectifs, décochée ['generation', objet([['nombre', entier(1)], ['arret', entier(1)], ['historique', entier(1)]])],
// seulement (respecterObjectifs) ; le mode des objectifs hors du plus de // Facultatifs, en dernier, dans l'ordre de A16 : un fichier qui n'en
// places servies (modeObjectifs) ; la case des animateurs, cochée // porte aucun s'écrit sans eux, au numéro de format près. La règle par
// seulement (rencontrerAnimateurs) ; l'ordre des priorités hors de l'ordre // défaut des objectifs, « et » seulement (regleObjectifs) ; la case des
// par défaut (priorites). // objectifs, décochée seulement (respecterObjectifs) ; le mode des
['cibleRedondance', entier(0, Infinity, { optionnel: true })], // objectifs hors du plus de places servies (modeObjectifs) ; la case des
['regleObjectifs', horsDuDefaut('et')], // animateurs, cochée seulement (rencontrerAnimateurs) ; l'ordre des
['respecterObjectifs', horsDuDefaut(false)], // priorités hors de l'ordre par défaut (priorites).
['modeObjectifs', horsDuDefaut('chacun', 'minimum')], ['regleObjectifs', horsDuDefaut('et')],
['rencontrerAnimateurs', horsDuDefaut(true)], ['respecterObjectifs', horsDuDefaut(false)],
['priorites', PRIORITES], ['modeObjectifs', horsDuDefaut('chacun', 'minimum')],
]); ['rencontrerAnimateurs', horsDuDefaut(true)],
['priorites', PRIORITES],
],
{ oubliees: new Set(['cibleRedondance']) },
);
const PARTICIPANT = objet([ const PARTICIPANT = objet([
['id', IDENTIFIANT], ['id', IDENTIFIANT],
@ -577,9 +583,11 @@ export function clesRangees(valeur) {
return Object.keys(valeur).sort(comparerTextes); return Object.keys(valeur).sort(comparerTextes);
} }
// Plus petite des clés de valeur absentes de cles, en comparant les unités // Plus petite des clés de valeur que la règle objet ne connaît pas — ni
// UTF-16, ou undefined. // l'une de ses clés, ni l'une de ses clés oubliées —, en comparant les
const cleInconnue = (valeur, cles) => clesRangees(valeur).find((cle) => !cles.has(cle)); // 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 // 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 // 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); const faute = fauteDeForme(valeur[cle], regleDuChamp, joindre(chemin, cle), false);
if (faute !== null) return faute; if (faute !== null) return faute;
} }
const inconnue = cleInconnue(valeur, regleDeValeur.cles); const inconnue = cleInconnue(valeur, regleDeValeur);
return inconnue === undefined ? null : joindre(chemin, inconnue); return inconnue === undefined ? null : joindre(chemin, inconnue);
} }
if (regleDeValeur.genre === 'liste') { 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 * 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 * 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 * 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 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 * 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}]`)); 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 * 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, * 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 * 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 * 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, * 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 : * 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 * 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 * 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 * 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 * 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); const cheminFautif = premiereFaute(fichier, SCHEMA);
if (cheminFautif !== null) faute(cheminFautif); if (cheminFautif !== null) faute(cheminFautif);
const { entete, charge } = fichier; const { entete, charge } = fichier;
oublierDesReglages(charge.reglages);
examinerIdentifiants(charge, faute); examinerIdentifiants(charge, faute);
examinerTours(charge, faute); examinerTours(charge, faute);
examinerObjectifs(charge, faute); examinerObjectifs(charge, faute);
@ -947,18 +964,6 @@ export function etatDeduit({ retenu, propositions }) {
return propositions.length > 0 ? 'propose' : 'brouillon'; 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 // La règle par défaut des objectifs et le mode des objectifs quand les
// réglages ne les portent pas (§ 8.9). // réglages ne les portent pas (§ 8.9).
const REGLE_OBJECTIFS_PAR_DEFAUT = 'ou'; 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 { texteLivre } from '../demo/livrees.js';
import { VERSION } from '../version.genere.js'; import { VERSION } from '../version.genere.js';
import { serialiser, serialiserCharge } from './canonique.js'; import { serialiser, serialiserCharge } from './canonique.js';
import * as DOCUMENT from './document.js';
import { import {
CIBLE_REDONDANCE_PAR_DEFAUT,
ENTREE_DE_COURSE, ENTREE_DE_COURSE,
FORMAT, FORMAT,
GENERATION_PAR_DEFAUT, GENERATION_PAR_DEFAUT,
@ -30,7 +30,6 @@ import {
TOURS_MAX, TOURS_MAX,
analyser, analyser,
capacite, capacite,
cibleRedondance,
configurationDepuisCharge, configurationDepuisCharge,
creerCharge, creerCharge,
critereCoche, 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 REGLAGES = (() => {
const CHARGE = SCHEMA.champs.find(([nom]) => nom === 'charge')[1]; const CHARGE = SCHEMA.champs.find(([nom]) => nom === 'charge')[1];
return CHARGE.champs.find(([nom]) => nom === 'reglages')[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', () => { 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(CIBLE_REDONDANCE_PAR_DEFAUT, 1000); assert.equal(REGLAGES.cles.has('cibleRedondance'), false);
const { charge } = analyser(JSON.stringify(documentValide())); assert.equal(REGLAGES.champs.some(([cle]) => cle === 'cibleRedondance'), false);
assert.equal(Object.hasOwn(charge.reglages, 'cibleRedondance'), false); assert.equal(Object.hasOwn(DOCUMENT, 'cibleRedondance'), false);
assert.equal(cibleRedondance(charge), 1000); assert.equal(Object.hasOwn(DOCUMENT, 'CIBLE_REDONDANCE_PAR_DEFAUT'), false);
assert.equal(premiereFaute(charge.reglages, REGLAGES), null);
const neuve = creerCharge({ id: 'evt-neuf', nom: 'Atelier', siegesParDefaut: 8, tours: 3 }); const neuve = creerCharge({ id: 'evt-neuf', nom: 'Atelier', siegesParDefaut: 8, tours: 3 });
assert.equal(Object.hasOwn(neuve.reglages, 'cibleRedondance'), false); 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', () => { 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 valeurs = [0, 1, 1000, 1234, Number.MAX_SAFE_INTEGER]; const sans = analyser(JSON.stringify(documentValide()));
assert.ok(valeurs.length > 0); for (const valeur of VALEURS) {
for (const valeur of valeurs) { const texte = abime((d) => { d.charge.reglages.cibleRedondance = valeur; });
const { charge } = analyser(abime((d) => { d.charge.reglages.cibleRedondance = valeur; })); assert.equal(premiereFaute(JSON.parse(texte), SCHEMA), null, JSON.stringify(valeur));
assert.equal(charge.reglages.cibleRedondance, valeur); const lu = analyser(texte);
assert.equal(cibleRedondance(charge), valeur); 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', () => { test('l’oubli ne vaut que pour elle, et que dans les réglages : une autre clé inconnue reste une faute de forme', () => {
const cas = [ assert.deepEqual(ecartsDeRefus([
['négative', -1], ['une autre clé des réglages', abime((d) => { d.charge.reglages.cibleRedondances = 1000; }), 'FORME', 'charge.reglages.cibleRedondances'],
['non entière', 2.5], ['la clé hors des réglages', abime((d) => { d.charge.evenement.cibleRedondance = 1000; }), 'FORME', 'charge.evenement.cibleRedondance'],
['au-delà des entiers sûrs', 2 ** 53], [
['en texte', '1000'], 'une clé inconnue à côté d’elle',
['booléenne', true], abime((d) => {
['nulle', null], d.charge.reglages.cibleRedondance = 1000;
['une liste', [1000]], d.charge.reglages.autreReglage = true;
]; }),
assert.deepEqual(ecartsDeRefus(cas.map(([libelle, valeur]) => [ 'FORME',
libelle, 'charge.reglages.autreReglage',
abime((d) => { d.charge.reglages.cibleRedondance = valeur; }), ],
'FORME', ]), []);
CHEMIN,
])), []);
}); });
test("absente, la charge s'écrit sans le champ ; posée, il s'écrit après la génération, et la relecture rend la même charge", () => { 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 { charge } = analyser(JSON.stringify(documentValide()));
const sans = serialiser(charge, { revision: 12, produitVersion: VERSION.affichee }); const sans = serialiser(charge, { revision: 12, produitVersion: VERSION.affichee });
assert.equal(sans.includes('cibleRedondance'), false); assert.equal(sans.includes('cibleRedondance'), false);
const posee = structuredClone(charge); const posee = structuredClone(charge);
posee.reglages = { cibleRedondance: 1200, ...posee.reglages }; posee.reglages = { cibleRedondance: 1200, ...posee.reglages };
const avec = serialiser(posee, { revision: 12, produitVersion: VERSION.affichee }); assert.equal(serialiser(posee, { revision: 12, produitVersion: VERSION.affichee }), sans);
assert.ok(avec.includes('"generation":{"nombre":3,"arret":50000,"historique":500},"cibleRedondance":1200}'), avec); assert.equal(serialiserCharge(posee), serialiserCharge(charge));
assert.deepEqual(analyser(avec).charge, analyser(serialiser(analyser(avec).charge, { revision: 12, produitVersion: VERSION.affichee })).charge);
assert.equal(cibleRedondance(analyser(avec).charge), 1200);
}); });
test("un fichier d'un format plus récent garde la cible qu'il porte, réduite aux clés connues", () => { 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) => { const texte = abime((d) => {
d.entete.format = FORMAT + 1; d.entete.format = FORMAT + 1;
d.charge.reglages.cibleRedondance = 750; 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); const lecture = analyser(texte);
assert.equal(lecture.formatPlusRecent, true); 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); 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 PARTICIPANT = regleDuChamp(CHARGE_SCHEMA, 'participants').element;
const TABLE = regleDuChamp(CHARGE_SCHEMA, 'tables').element; const TABLE = regleDuChamp(CHARGE_SCHEMA, 'tables').element;
assert.deepEqual( assert.deepEqual(
REGLAGES.champs.slice(-7).map(([cle, regle]) => [cle, regle.optionnel]), REGLAGES.champs.slice(-6).map(([cle, regle]) => [cle, regle.optionnel]),
[ [
['generation', false], ['generation', false],
['cibleRedondance', true],
['regleObjectifs', true], ['regleObjectifs', true],
['respecterObjectifs', true], ['respecterObjectifs', true],
['modeObjectifs', true], ['modeObjectifs', true],
@ -1452,12 +1447,6 @@ describe('les champs facultatifs du format 2 : forme et place (§ 8.9, A16)', ()
const relue = analyser(texte); const relue = analyser(texte);
assert.deepEqual(relue.charge, charge); assert.deepEqual(relue.charge, charge);
assert.equal(serialiser(relue.charge, relue.entete), texte); 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', () => { 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 // 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éservation, un second jalon, une note. empreintes.json y porte, par
// révision, l'empreinte SHA-256 du texte de serialiserCharge que restituait // 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 // fichiers dans un dossier temporaire neuf, retiré à la fin, par le système
// de fichiers de la plateforme electron (fichiers_electron.js devant les // de fichiers de la plateforme electron (fichiers_electron.js devant les
// primitives de la coquille, electron/fichiers.js) : l'ouverture n'écrit // primitives de la coquille, electron/fichiers.js) : l'ouverture n'écrit
// rien ; chaque révision se restitue comme le format 1 la restituait ; le // rien ; chaque révision se restitue comme le format 1 la restituait ; le
// premier geste réécrit le journal au format courant, chaque ligne // 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 // 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 // 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 // d'electron/ (vitest.config.js) ne relance, dans la série node, que les
// épreuves qu'il nomme. // é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 NOMS = [SUFFIXES.journal, SUFFIXES.etat, SUFFIXES.precedent].map((suffixe) => `${BASE}${suffixe}`);
const [JOURNAL, ETAT, PRECEDENT] = NOMS; const [JOURNAL, ETAT, PRECEDENT] = NOMS;
const DERNIERE = Object.keys(EMPREINTES).length; 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 empreinte = (charge) => createHash('sha256').update(serialiserCharge(charge), 'utf8').digest('hex');
const lignesDe = (texte) => texte.replace(/\n$/, '').split('\n'); 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); 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)); const journal = lireJournal(fixture(JOURNAL));
assert.equal(journal.ecartees, 0); 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))])); 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(ETAT)).charge), EMPREINTES[DERNIERE]);
assert.equal(empreinte(analyser(fixture(PRECEDENT)).charge), EMPREINTES[DERNIERE - 1]); assert.equal(empreinte(analyser(fixture(PRECEDENT)).charge), EMPREINTES[DERNIERE - 1]);
assert.deepEqual( 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); const nouvelles = lignesDe(texte);
assert.deepEqual(nouvelles.slice(1, anciennes.length), anciennes.slice(1)); assert.deepEqual(nouvelles.slice(1, anciennes.length), anciennes.slice(1));
assert.equal(nouvelles.length, anciennes.length + 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, Number(revision))), attendue, `révision ${revision}`);
} }
assert.equal(empreinte(reconstruire(journal, DERNIERE + 1)), empreinte(apres)); 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) { for (let n = 2; fil(ouvert.journal).cibleDefaire !== null; n += 1) {
const cible = fil(ouvert.journal).cibleDefaire; const cible = fil(ouvert.journal).cibleDefaire;
ouvert = await enregistrer(ouvert, reconstruire(ouvert.journal, cible), geste(n, { sens: 'defaire', retour: cible })); 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); atteintes.push(cible);
} }
assert.deepEqual(atteintes, [6, 5, 4, 3, 2, 1]); 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(rouvert.journal.format, FORMAT);
assert.equal(fil(rouvert.journal).cibleRefaire, 2); 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]); 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', () => { test('élagué, un journal du format 1 se réécrit au format courant : seule son ouverture change', () => {
const etapes = suite(650); const etapes = suite(650);
const texte = journalDe(etapes); const texte = journalDe(etapes);

View file

@ -103,10 +103,6 @@
* propre siegesAttribues * propre siegesAttribues
* @property {{nombre: number, arret: number, historique: number}} generation * @property {{nombre: number, arret: number, historique: number}} generation
* entiers ≥ 1 * 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 à * @property {'et'} [regleObjectifs] facultatif, présent seulement à
* « et » : la règle que suit un * « et » : la règle que suit un
* objectif de règle nulle ; absent, * objectif de règle nulle ; absent,
@ -288,6 +284,10 @@
* @property {Array<[string, Regle]>} [champs] objet : ses champs, dans * @property {Array<[string, Regle]>} [champs] objet : ses champs, dans
* l'ordre des clés du texte canonique * l'ordre des clés du texte canonique
* @property {Set<string>} [cles] objet : les clés de ses champs * @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 {Regle} [element] liste : la règle de ses éléments
* @property {number} [min] entier : bornes incluses ; * @property {number} [min] entier : bornes incluses ;
* liste : bornes incluses de sa longueur * 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-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-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', '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-classement', 'propositions : les lignes classées', ['']],
['data-coeurs', 'génération : le réglage des cœurs de calcul', ['']], ['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', ['']], ['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', nom: 'accueil.mesuresRetenu',
mode: null, mode: null,
async atteindre(contexte) { async atteindre(contexte) {
// La principale, sa proposition retenue : la ligne montre la // La principale, sa proposition retenue : la ligne montre les
// différence du retenu et la meilleure proposition du classement, et le // collisions cumulées du retenu et la meilleure proposition du
// pointeur réel sur l'intitulé de la différence — un en-tête de // classement, et le pointeur réel sur l'intitulé des collisions — un
// colonne du tableau, une fois pour la liste — ouvre son infobulle. // en-tête de colonne du tableau, une fois pour la liste — ouvre son
// infobulle.
const principale = chargePrincipale(); const principale = chargePrincipale();
const charge = executer(principale, 'retenirProposition', { id: principale.propositions[0].id }); const charge = executer(principale, 'retenirProposition', { id: principale.propositions[0].id });
const m = await contexte.monter({ fichiers: [fichierEtat(charge)] }); const m = await contexte.monter({ fichiers: [fichierEtat(charge)] });
const ligne = `[data-evenement="${BASE}"]`; const ligne = `[data-evenement="${BASE}"]`;
await chargerResumes(m); await chargerResumes(m);
await attendre('la différence du retenu et la meilleure proposition', () => await attendre('les collisions du retenu et la meilleure proposition', () =>
['[data-colonne="diffRedondance"]', '[data-meilleure-id]'].every((valeur) => /\d/.test(m.racine.querySelector(`${ligne} ${valeur}`)?.textContent ?? '')), ['[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 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"]'); const bulle = m.racine.querySelector('[data-ecran="accueil"] [role="tooltip"]');
return bulle !== null && !bulle.hidden && getComputedStyle(bulle).visibility === 'visible'; return bulle !== null && !bulle.hidden && getComputedStyle(bulle).visibility === 'visible';
}); });