[ADD] proposals: keep the N best, erase the rest, after confirmation

Hundreds of proposals slow every table, chart and save. A new command keeps
the first N of the single ranking, the retained placement's origin besides,
and erases the rest in one undoable journal entry. N sits in a field beside
the button, 10 by default, and is never written to the file. A window states
how many go and recounts on each write that lands while it is open; the
announcement gives the count the command returns.
Checked: node 5150, browser 1154, long 129; one capture changes, stable on
two passes.

--- FR ---

[ADD] propositions : garder les N meilleures, effacer le reste, confirmé

Des centaines de propositions ralentissent chaque tableau, graphique et
enregistrement. Une commande garde les N premières du classement unique,
l'origine du retenu en plus, et efface le reste en une entrée défaisable. N
se saisit à côté du bouton, 10 par défaut, et ne s'écrit jamais au fichier.
Une fenêtre dit combien partent et se recompte à chaque écriture pendant
qu'elle attend ; l'annonce donne le compte que rend la commande.
Vérifié : node 5150, navigateur 1154, longues 129 ; une capture change,
stable sur deux passes.

Assisted-by: Claude Opus 5.5
This commit is contained in:
Mathieu Benoit 2026-10-13 06:17:04 -04:00
parent 8fd3bccd86
commit 4f9c550e44
22 changed files with 980 additions and 34 deletions

View file

@ -172,6 +172,12 @@
« 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.
- La section « Propositions » offre « Garder les 10 meilleures » : les dix
premières du classement restent, et celle d'où vient le placement retenu
aussi, les autres s'effacent. Le nombre se règle dans le champ voisin. Une
fenêtre dit d'abord combien de propositions partent, et « Défaire » les rend.
Une longue liste allégée garde le logiciel rapide ; le nombre choisi ne
s'enregistre pas dans l'événement.
**Version de format des fichiers** : 2. Les étiquettes, les objectifs, leurs
règles, les cases nouvelles, le mode des objectifs et l'ordre des priorités

View file

@ -868,6 +868,7 @@ l’historique ; les noms des exemples sont inventés.
| `enregistrerGeneration` | Généré 5 propositions (n° 3 à 7) |
| `retenirProposition` | Retenu la proposition n° 3 |
| `effacerPropositions` | Effacé 4 propositions, gardé la retenue (n° 2) |
| `garderMeilleures` | Gardé les 10 meilleures propositions, effacé 37 propositions, gardé aussi la retenue (n° 42) |
| `creerTable` | Ajouté la table 3 |
| `supprimerTable` | Supprimé la table 2 ; retirés : 2 réservations et 1 titre |
| `deplacerTable` | Déplacé la table 3 |

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 87 KiB

File diff suppressed because one or more lines are too long

View file

@ -365,6 +365,7 @@ export const EXEMPLES_COMMANDES = Object.freeze({
enregistrerGeneration: { n: 5, premier: 3, dernier: 7 },
retenirProposition: { id: 3 },
effacerPropositions: { n: 4, gardee: 2 },
garderMeilleures: { n: 10, effacees: 37, gardee: 42 },
creerTable: { numero: 3 },
supprimerTable: { numero: 2, reservations: 2, titres: 1 },
deplacerTable: { numero: 3 },

56
spec.md
View file

@ -69,7 +69,8 @@ Aucun des deux ne dépend de l'étape atteinte.
geste (§ 5.9), **là où le geste a eu lieu** : sur la place pour un geste du
plan, dans l'aperçu d'import pour une ligne de CSV (§ 10.1), sur le champ
pour une saisie. Seul un geste qui détruit de la saisie ouvre une fenêtre à
confirmer.
confirmer, et « Garder les N meilleures », qui efface d'un coup des
propositions que l'écran ne montre pas toutes (§ 5.7).
Les étapes 8 et 10 — lire le diagnostic, comparer — ne modifient rien et restent
vivantes en lecture (§ 8.4).
@ -1029,13 +1030,57 @@ réglages ajoute des candidats plutôt que d'effacer les précédents, et un
classement unique les ordonne tous. Une commande efface la liste en épargnant
celui qui est retenu.
**« Garder les N meilleures » allège la liste sans la vider.** La commande
`garderMeilleures {n}` garde les n premières propositions du classement unique
— les n premières lignes que le tableau montre, dans son ordre : les
comparables dans l'ordre du classement, puis celles hors classement par
identifiant —, et la proposition d'où vient le placement retenu quand elle
vient après elles : n + 1 au plus. Les autres s'effacent, en une seule entrée
d'historique que « Défaire » reprend (§ 8.3), libellée « Gardé les 10
meilleures propositions, effacé 37 propositions », et « gardé aussi la retenue
(n° 42) » quand la proposition du retenu reste en plus. Le partage se lit sur
la mesure qui classe le tableau, jamais sur un ordre que l'écran referait. Le
compteur ne recule pas (§ 8.9), et l'état du plan ne change pas : une
proposition au moins reste. Rien à effacer — n au moins le nombre de
propositions, ou la seule au-delà est celle du retenu — : le geste est sans
effet, refusé comme tel (§ 8.2). En lecture et sur un plan bloqué, la séance le
refuse comme tout geste qui modifie (§ 8.4, § 9) ; une configuration qui ne se
normalise pas ne se classe pas, et son refus nomme sa cause, sans remède. Les
courses des propositions effacées quittent le compagnon des courses comme à
l'effacement (§ 8.6) ; rien de nouveau ne s'écrit dans le fichier (§ 8.9).
**N est une saisie de la section, jamais écrite.** Il se saisit dans un champ
étroit à côté du bouton, dix au départ, un entier d'au moins 1, et le bouton le
nomme : « Garder les 10 meilleures », « Garder la meilleure ». Comme le filtre
d'une liste, ni le fichier ni le journal ne le portent : il se règle en
lecture, et revient à dix quand la section se rouvre. Une saisie qui n'est pas
un entier d'au moins 1 se refuse contre le champ, et le bouton garde le dernier
nombre accepté.
**Une fenêtre à confirmer précède le nettoyage**, parce qu'il efface d'un coup
des lignes que l'opérateur ne voit pas toutes : « Effacer 37 propositions et
garder les 10 meilleures ? ». Elle dit que les premières du classement restent,
que la proposition du retenu reste aussi quand elle vient après elles, et que
« Défaire » rend les effacées. Ouverte, elle se recompte sur chaque écriture
qui arrive pendant qu'elle attend — une génération qui s'achève en
arrière-plan ajoute des propositions à effacer —, et le compte qu'elle montre
est celui que « Confirmer » applique, sauf pour une écriture déjà en file au
moment du clic, que la commande voit avant elle : l'annonce donne alors le
compte réel, et « Défaire » rend tout. « Annuler » et Échap la referment sans
rien écrire, le focus revenu au bouton. Un geste que la séance refuserait, ou
qui n'effacerait rien, part sans fenêtre, et son refus se pose à côté du
bouton. Abouti, une annonce dit le nombre de propositions effacées, celui que
la commande rend dans son résumé (`{effacees}`), et la page montrée qui
n'existe plus ramène à la première.
Au-delà de cent propositions, le tableau se lit par pages de cent : la page
découpe ce classement unique sans le retrier, chaque ligne y garde son rang, et
la ligne du retenu reste en tête de chaque page, hors du compte. La page est une
posture de la navigation, jamais écrite (§ 8.4) : elle repart de la première à
chaque ouverture d'un événement et après une génération, se borne à la dernière
quand des propositions partent, et amène la proposition visée à chaque demande
au plan, même répétée.
quand des propositions partent — à la première quand « Garder les N meilleures »
fait disparaître la page montrée —, et amène la proposition visée à chaque
demande au plan, même répétée.
**La commande prend une graine entière et un compte d'arrêt**, et une longueur
d'historique d'acceptation dont l'algorithme fixe la valeur par défaut. Chaque
@ -1310,8 +1355,9 @@ dans l'outil.
Un refus **nomme sa cause et propose le geste qui le lève**, et se manifeste
**sur la place concernée**, pas dans une boîte de dialogue. Seul un geste qui
détruit de la saisie ouvre une fenêtre à confirmer : une suite de modales
apprend à cliquer sans lire, et le premier refus qui comptait passe inaperçu.
détruit de la saisie ouvre une fenêtre à confirmer — et « Garder les N
meilleures », qui efface en bloc (§ 5.7) — : une suite de modales apprend à
cliquer sans lire, et le premier refus qui comptait passe inaperçu.
**Quand aucun geste ne lève le refus, il nomme sa cause seule.** Ainsi d'une
valeur qui se corrige dans le champ même, dont le texte dit la valeur attendue —

View file

@ -24,10 +24,12 @@
// retenu quand un placement est retenu ; bloqué se pose de chacun d'eux, et
// seul debloquer le lève, vers l'état que le contenu impose (etatDeduit). Une
// génération fait passer un brouillon à proposé ; retenir pose retenu ;
// effacer les propositions ramène un plan proposé sans retenu à brouillon.
// effacer les propositions ramène un plan proposé sans retenu à brouillon ;
// garder les meilleures en laisse une au moins, et ne change pas l'état.
import { ErreurCsv } from '../csv/erreurs.js';
import { appliquerImport, bilanRemplacement } from '../csv/import.js';
import { appliquerImportObjectifs } from '../csv/objectifs.js';
import { ErreurConfiguration } from '../moteur/erreurs.js';
import { serialiserCharge } from '../stockage/canonique.js';
import { entreeDeCourse } from '../stockage/courses.js';
import { SCHEMA, clesRangees, creerCharge, etatDeduit, premiereFaute } from '../stockage/document.js';
@ -38,6 +40,7 @@ import { courseVersFichier, fautes, formeNommee, versFichier } from '../stockage
import { COMMANDES_MOBILIER } from './commandes_mobilier.js';
import { COMMANDES_PLACES } from './commandes_places.js';
import { ErreurCommande, refus } from './erreurs.js';
import { evaluer, nettoyage } from './evaluation.js';
import { libelle } from './libelles.js';
import { avisObjectifs, etiquettesDeLEvenement, etiquettesRangees, recevoirEtiquettes } from './objectifs.js';
import { nomAffiche } from './personnes.js';
@ -582,6 +585,43 @@ function effacerPropositions(charge) {
};
}
/**
* garderMeilleures {n} (§ 5.7) : les n premières propositions du classement
* unique, celles que le tableau montre en tête, restent, et celle d'où vient
* le retenu avec elles ; les autres s'effacent. Le partage est celui de
* nettoyage, sur la table d'evaluer de la charge reçue : la mesure qui
* classe le tableau, refaite ici, puisque la commande ne lit que sa charge.
* Les gardées restent dans l'ordre du fichier ; le compteur ne recule pas,
* et l'état ne change pas, une proposition au moins restant. Rien à
* effacer : SANS_EFFET. Une configuration qui ne se normalise pas ne se
* classe pas : son refus, du code de normaliser, remède nul. n ne s'écrit
* pas dans la charge : le libellé seul le nomme. Le résumé rendu,
* {effacees}, compte les propositions que cette exécution efface : l'annonce
* de l'écran le lit, plutôt que l'écart de deux lectures de la séance entre
* lesquelles une autre écriture de la file peut passer.
*/
function garderMeilleures(charge, { n }) {
exigerModifiable(charge);
let table;
try {
table = evaluer(charge);
} catch (erreur) {
if (erreur instanceof ErreurConfiguration) throw refus(erreur.code, erreur.details);
throw erreur;
}
const { gardees, effacees, origineEnPlus } = nettoyage(table, n);
if (effacees.length === 0) throw refus('SANS_EFFET', { commande: 'garderMeilleures' });
const restent = new Set(gardees);
const suivante = structuredClone(charge);
suivante.propositions = suivante.propositions.filter(({ id }) => restent.has(id));
return {
charge: suivante,
libelle: libelle('ENTREE_GARDER_MEILLEURES', { n, effacees: effacees.length, gardee: origineEnPlus }),
avertissements: [],
resume: { effacees: effacees.length },
};
}
/**
* Un registre réuni de plusieurs : chaque nom une fois, dans l'ordre des
* registres reçus, puis de chacun.
@ -627,6 +667,7 @@ export const COMMANDES = reunirCommandes(
['enregistrerGeneration', enregistrerGeneration],
['retenirProposition', retenirProposition],
['effacerPropositions', effacerPropositions],
['garderMeilleures', garderMeilleures],
]),
COMMANDES_MOBILIER,
COMMANDES_PLACES,

View file

@ -20,6 +20,7 @@ import { VERSION } from '../version.genere.js';
import { readFileSync } from 'node:fs';
import { chargements } from '../../test/chargements.js';
import { COMMANDES, ErreurCommande, appliquerCommande, chargeNeuve, formeNommeeDe, reunirCommandes } from './commandes.js';
import { evaluer, nettoyage } from './evaluation.js';
import { COMMANDES_MOBILIER } from './commandes_mobilier.js';
import { COMMANDES_PLACES } from './commandes_places.js';
import { ErreurCommande as ErreurDesErreurs, refus } from './erreurs.js';
@ -99,7 +100,7 @@ const executer = (nom, charge, args) => COMMANDES.get(nom)(charge, args);
const CSV_DEUX = 'nom;prenom;appartenance\nAncolie;Bastien;Club des Merles\nBruyère;Capucine;\n';
describe('commandes : le registre (§ 8.2)', () => {
test("le registre porte les onze commandes d'A1, changerEtiquettes, les dix des places et celles du mobilier, chacune une fonction", () => {
test("le registre porte les onze commandes d'A1, changerEtiquettes, garderMeilleures, les dix des places et celles du mobilier, chacune une fonction", () => {
const attendues = [
'ajouterParticipant',
'changerEtat',
@ -113,6 +114,7 @@ describe('commandes : le registre (§ 8.2)', () => {
'effacerPropositions',
'enregistrerGeneration',
'exclureParticipant',
'garderMeilleures',
'importerObjectifs',
'importerParticipants',
'modifierParticipant',
@ -125,7 +127,7 @@ describe('commandes : le registre (§ 8.2)', () => {
'retirerTitre',
'supprimerParticipant',
];
assert.equal(attendues.length, 23);
assert.equal(attendues.length, 24);
assert.deepEqual([...COMMANDES.keys()].sort(), [...attendues, ...COMMANDES_MOBILIER.keys()].sort());
for (const commande of COMMANDES.values()) assert.equal(typeof commande, 'function');
});
@ -771,6 +773,85 @@ describe('commandes : générer, retenir, effacer (§ 5.7, § 8.9)', () => {
});
});
describe('commandes : garder les meilleures propositions (§ 5.7)', () => {
// La charge d'essai, deux générations : cinq propositions, 1 à 5.
function chargeCinq() {
const premiere = executer('enregistrerGeneration', chargeEssai(), generer(chargeEssai(), 3)).charge;
return executer('enregistrerGeneration', premiere, generer(premiere, 2, 11)).charge;
}
// L'ordre du tableau des propositions : celui de la table d'évaluation.
const ordreDe = (charge) => evaluer(charge).propositions.map(({ id }) => id);
test("garde les n premières du classement, dans l'ordre du fichier ; le compteur et l'état ne bougent pas ; une entrée qui le dit", () => {
const avant = figer(chargeCinq());
const ordre = ordreDe(avant);
assert.equal(ordre.length, 5);
const { charge, libelle, avertissements, resume } = executer('garderMeilleures', avant, { n: 2 });
const gardees = new Set(ordre.slice(0, 2));
// Le compte des effacées, rendu par la commande même : l'annonce le lit.
assert.deepEqual(resume, { effacees: 3 });
assert.deepEqual(charge.propositions, avant.propositions.filter(({ id }) => gardees.has(id)));
assert.equal(charge.prochainsIds.proposition, avant.prochainsIds.proposition);
assert.equal(charge.evenement.etat, 'propose');
assert.equal(charge.retenu, null);
assert.equal(libelle, 'Gardé les 2 meilleures propositions, effacé 3 propositions');
assert.deepEqual(avertissements, []);
const une = executer('garderMeilleures', avant, { n: 4 });
assert.deepEqual(une.charge.propositions.map(({ id }) => id).sort(), ordre.slice(0, 4).sort());
assert.equal(une.libelle, 'Gardé les 4 meilleures propositions, effacé 1 proposition');
const seule = executer('garderMeilleures', avant, { n: 1 });
assert.deepEqual(seule.charge.propositions.map(({ id }) => id), [ordre[0]]);
assert.equal(seule.libelle, 'Gardé la meilleure proposition, effacé 4 propositions');
});
test('la proposition du retenu, hors des n premières, reste en plus, et le libellé la nomme ; le plan reste retenu', () => {
const generee = chargeCinq();
const derniere = ordreDe(generee).at(-1);
const avant = figer(executer('retenirProposition', generee, { id: derniere }).charge);
const ordre = ordreDe(avant);
assert.equal(ordre.at(-1), derniere);
const { charge, libelle, resume } = executer('garderMeilleures', avant, { n: 2 });
assert.deepEqual(charge.propositions.map(({ id }) => id).sort(), [...ordre.slice(0, 2), derniere].sort());
assert.deepEqual(resume, { effacees: 2 });
assert.deepEqual(charge.retenu, avant.retenu);
assert.equal(charge.evenement.etat, 'retenu');
assert.equal(libelle, `Gardé les 2 meilleures propositions, effacé 2 propositions, gardé aussi la retenue (n° ${derniere})`);
assert.deepEqual(nettoyage(evaluer(avant), 2).effacees.sort(), ordre.slice(2, 4).sort());
});
test("rien à effacer — n au moins le compte, ou la seule au-delà est la retenue : SANS_EFFET", () => {
const avant = figer(chargeCinq());
refuse(() => executer('garderMeilleures', avant, { n: 5 }), 'SANS_EFFET', { commande: 'garderMeilleures', remede: null });
refuse(() => executer('garderMeilleures', avant, { n: 50 }), 'SANS_EFFET');
const derniere = ordreDe(avant).at(-1);
const retenue = figer(executer('retenirProposition', avant, { id: derniere }).charge);
refuse(() => executer('garderMeilleures', retenue, { n: 4 }), 'SANS_EFFET');
});
test("une configuration qui ne se normalise pas ne se classe pas : son refus, remède nul ; un plan bloqué refuse d'abord", () => {
const charge = structuredClone(chargeCinq());
charge.reservations = [1, 2, 3].map((participant) => ({ participant, table: 1, siege: null, portee: 'tous', tour: null }));
refuse(() => executer('garderMeilleures', figer(charge), { n: 1 }), 'SURRESERVATION');
assert.throws(() => executer('garderMeilleures', charge, { n: 1 }), (erreur) => erreur.details.remede === null);
const bloquee = figer({ ...charge, evenement: { ...charge.evenement, etat: 'bloque' } });
refuse(() => executer('garderMeilleures', bloquee, { n: 1 }), 'PLAN_BLOQUE', { remede: { geste: 'debloquer' } });
});
test("n hors des entiers ≥ 1 est une faute du code ; la commande ne rend aucune course", () => {
const avant = figer(chargeCinq());
for (const n of [0, -2, 1.5, '1', null]) assert.throws(() => executer('garderMeilleures', avant, { n }), TypeError, String(n));
assert.throws(() => executer('garderMeilleures', avant, {}), TypeError);
assert.equal(Object.hasOwn(executer('garderMeilleures', avant, { n: 1 }), 'courses'), false);
});
test("après le nettoyage, aucun identifiant ne revient : la génération suivante continue le compteur", () => {
const gardee = executer('garderMeilleures', chargeCinq(), { n: 1 }).charge;
const suite = executer('enregistrerGeneration', gardee, generer(gardee, 1, 13)).charge;
assert.equal(suite.propositions.at(-1).id, 6);
});
});
describe('commandes : la course de la recherche, rendue à côté de la charge (§ 8.9, B31, D53)', () => {
// La charge d'essai sur trois tours, sièges attribués, deux tables de
// trois places pour quatre personnes : Iris Ombrelle réservée au siège 3
@ -934,9 +1015,11 @@ describe('commandes : la charge reçue ne change jamais (§ 13.2)', () => {
];
// La charge que reçoit chaque commande : la charge riche, sauf debloquer,
// qui la reçoit bloquée ; convertirEnTous, qui y trouve Théo réservé à la
// table 2 à chaque tour, en portée « tour » ; deplacerDansRetenu, qui y
// trouve l'autre personne du tour 1 retirée dans la réserve.
// qui la reçoit bloquée ; garderMeilleures, qui y trouve deux propositions
// de plus, de quoi effacer au-delà de la première et de la retenue ;
// convertirEnTous, qui y trouve Théo réservé à la table 2 à chaque tour, en
// portée « tour » ; deplacerDansRetenu, qui y trouve l'autre personne du
// tour 1 retirée dans la réserve.
function recues(charge) {
const { autre } = auTour1(charge);
const reservee = structuredClone(charge);
@ -950,6 +1033,7 @@ describe('commandes : la charge reçue ne change jamais (§ 13.2)', () => {
['deplacerDansRetenu', executer('retirerDuTour', charge, { tour: 1, participant: autre }).charge],
['retirerObjectif', executer('poserObjectif', charge, { table: 2, tour: null, etiquettes: 'finance', regle: null }).charge],
['retablirPriorites', executer('changerPriorites', charge, { paliers: PRIORITES_REGLEES }).charge],
['garderMeilleures', executer('enregistrerGeneration', charge, generer(charge, 2, 5)).charge],
]);
}
@ -969,6 +1053,7 @@ describe('commandes : la charge reçue ne change jamais (§ 13.2)', () => {
['enregistrerGeneration', generer(charge, 1, 5)],
['retenirProposition', { id: 2 }],
['effacerPropositions', {}],
['garderMeilleures', { n: 1 }],
['reserver', { participant: 4, table: 2, siege: null, portee: 'tour', tour: 1 }],
['retirerReservation', { participant: 1, table: 1, portee: 'tous', tour: null }],
['deplacerReservation', { participant: 1, de: { table: 1, portee: 'tous', tour: null }, vers: { table: 2, siege: null } }],
@ -1040,8 +1125,8 @@ describe('commandes : les registres réunis et les erreurs partagées', () => {
);
});
test("COMMANDES réunit les onze commandes d'A1, changerEtiquettes, importerObjectifs et les deux registres de F1", () => {
assert.equal(COMMANDES.size, 13 + COMMANDES_MOBILIER.size + COMMANDES_PLACES.size);
test("COMMANDES réunit les onze commandes d'A1, changerEtiquettes, importerObjectifs, garderMeilleures et les deux registres de F1", () => {
assert.equal(COMMANDES.size, 14 + COMMANDES_MOBILIER.size + COMMANDES_PLACES.size);
assert.ok(COMMANDES_MOBILIER instanceof Map);
assert.ok(COMMANDES_PLACES instanceof Map);
for (const registre of [COMMANDES_MOBILIER, COMMANDES_PLACES]) {

View file

@ -281,6 +281,36 @@ export function resumeDuMeilleur(charge) {
return premiere === null ? null : { id: premiere.id };
}
/**
* Le partage d'un nettoyage des propositions (§ 5.7) : les n premières de
* la table, dans l'ordre où le tableau des propositions les montre — les
* comparables dans l'ordre du classement, puis les autres par identifiant
* —, se gardent, et la proposition d'où vient le retenu avec elles quand
* elle vient après ; les autres s'effacent. La commande garderMeilleures et
* la fenêtre qui la confirme lisent ce partage, chacune sur la table de la
* charge qu'elle tient : aucun ordre ne se refait ailleurs.
*
* @param {TableEvaluation} table
* @param {number} n entier ≥ 1
* @returns {{gardees: number[], effacees: number[], origineEnPlus: number|null}}
* gardees et effacees : des identifiants, dans l'ordre de la table ;
* origineEnPlus : la proposition du retenu gardée au-delà des n premières,
* null quand elle en est, sans retenu, et quand la liste ne la porte plus
* @throws {TypeError} n qui n'est pas un entier ≥ 1
*/
export function nettoyage(table, n) {
if (!Number.isSafeInteger(n) || n < 1) throw new TypeError(`nettoyage : entier ≥ 1 attendu, reçu ${JSON.stringify(n)}`);
const ids = table.propositions.map(({ id }) => id);
const origine = table.retenu?.id ?? null;
const origineEnPlus = origine !== null && ids.indexOf(origine) >= n ? origine : null;
const premieres = ids.slice(0, n);
return {
gardees: origineEnPlus === null ? premieres : [...premieres, origineEnPlus],
effacees: ids.slice(n).filter((id) => id !== origineEnPlus),
origineEnPlus,
};
}
// --- Le détail d'un placement (§ 12, § 12.9, D27, D29, D40, D42) ------------
/**

View file

@ -50,6 +50,7 @@ import * as EVALUATION from './evaluation.js';
import {
detaillerQualite,
evaluer,
nettoyage,
premiereDuClassement,
rencontresDePersonne,
resumeDuMeilleur,
@ -602,6 +603,61 @@ describe('resumeDuMeilleur : la proposition au rang 1 que lit la liste des évé
});
});
describe('nettoyage : les n premières du tableau, et la proposition du retenu (§ 5.7)', () => {
// Les plans écrits à la main, que le classement range 3, 2, 1, 4.
function chargeConflit() {
const charge = structuredClone(chargeDemo('petite-conflit'));
charge.propositions = PLANS_CONFLIT.map(({ tours }, rang) => propositionDe(charge, rang + 1, tours));
charge.prochainsIds.proposition = 5;
charge.evenement.etat = 'propose';
return charge;
}
test("garde les n premières dans l'ordre de la table, efface les suivantes ; sans retenu, rien de plus", () => {
const table = evaluer(figer(chargeConflit()));
assert.deepEqual(table.propositions.map(({ id }) => id), [3, 2, 1, 4]);
assert.deepEqual(nettoyage(table, 2), { gardees: [3, 2], effacees: [1, 4], origineEnPlus: null });
assert.deepEqual(nettoyage(table, 1), { gardees: [3], effacees: [2, 1, 4], origineEnPlus: null });
assert.deepEqual(nettoyage(table, 4), { gardees: [3, 2, 1, 4], effacees: [], origineEnPlus: null });
assert.deepEqual(nettoyage(table, 10), { gardees: [3, 2, 1, 4], effacees: [], origineEnPlus: null });
});
test("la proposition du retenu, après les n premières, est gardée en plus ; parmi elles, rien de plus", () => {
const charge = executer(chargeConflit(), 'retenirProposition', { id: 4 });
const table = evaluer(figer(charge));
assert.equal(table.retenu.id, 4);
assert.deepEqual(nettoyage(table, 2), { gardees: [3, 2, 4], effacees: [1], origineEnPlus: 4 });
assert.deepEqual(nettoyage(table, 3), { gardees: [3, 2, 1, 4], effacees: [], origineEnPlus: 4 });
const parmi = evaluer(figer(executer(chargeConflit(), 'retenirProposition', { id: 2 })));
assert.deepEqual(nettoyage(parmi, 2), { gardees: [3, 2], effacees: [1, 4], origineEnPlus: null });
});
test("un retenu dont la proposition n'est plus dans la liste ne garde rien de plus", () => {
const charge = executer(chargeConflit(), 'retenirProposition', { id: 1 });
charge.propositions = charge.propositions.filter(({ id }) => id !== 1);
const table = evaluer(figer(charge));
assert.equal(table.retenu.id, 1);
assert.deepEqual(nettoyage(table, 1), { gardees: [3], effacees: [2, 4], origineEnPlus: null });
});
test('les hors classement viennent après les classées, par identifiant, comme au tableau', () => {
let charge = generee(chargeDemo('petite'), { graine: 1, nombre: 2, arret: 200 });
charge = executer(charge, 'exclureParticipant', { id: 4 });
charge = generee(charge, { graine: 2, nombre: 2, arret: 200 });
const table = evaluer(figer(charge));
const ordre = table.classement.ordre;
assert.deepEqual(nettoyage(table, 2), { gardees: ordre, effacees: [1, 2], origineEnPlus: null });
assert.deepEqual(nettoyage(table, 3), { gardees: [...ordre, 1], effacees: [2], origineEnPlus: null });
});
test('n hors des entiers ≥ 1 est une faute du code', () => {
const table = evaluer(figer(chargeConflit()));
for (const n of [0, -1, 1.5, '2', null, undefined, Number.NaN, 2 ** 53]) {
assert.throws(() => nettoyage(table, n), TypeError, String(n));
}
});
});
// --- L'ordre des priorités, le mode et les cases (A11, A21) ---------------------
exigerVerification(process.env);

View file

@ -519,6 +519,7 @@ const EXEMPLES = new Map([
['ENTREE_ENREGISTRER_GENERATION', { n: 5, premier: 3, dernier: 7 }],
['ENTREE_RETENIR_PROPOSITION', { id: 3 }],
['ENTREE_EFFACER_PROPOSITIONS', { n: 4, gardee: 2 }],
['ENTREE_GARDER_MEILLEURES', { n: 10, effacees: 37, gardee: 42 }],
// Places : refus d'une pose, titres, réservations, retenu
['SIEGE_INEXISTANT', { table: 2, siege: 9, capacite: 8, remede: null }],
['DEUX_PLACES_MEME_TOUR', { participant: 1, tour: 2, autre: { table: 1, siege: null, portee: 'tous', tour: null }, remede: null }],
@ -940,7 +941,7 @@ describe('libellés : les tables par domaine (§ 14.6)', () => {
['seance', ['MODE_LECTURE', 'SUPPORT_ROMPU', 'SANS_EFFET', 'ENTREE_DEFAIRE', 'JALON_CREATION']],
['participants', ['NOM_REQUIS', 'PARTICIPANT_INCONNU', 'RESERVATIONS_SUSPENDUES', 'IMPORT_EN_RETENU', 'ENTREE_AJOUTER_PARTICIPANT']],
['evenements', ['ENTREE_CREATION', 'DATE_INVALIDE', 'AUCUN_RETENU', 'PLAN_NON_BLOQUE', 'ENTREE_DEBLOQUER']],
['generation', ['PROPOSITION_INCONNUE', 'ENTREE_ENREGISTRER_GENERATION', 'ENTREE_EFFACER_PROPOSITIONS']],
['generation', ['PROPOSITION_INCONNUE', 'ENTREE_ENREGISTRER_GENERATION', 'ENTREE_EFFACER_PROPOSITIONS', 'ENTREE_GARDER_MEILLEURES']],
['format', ['format.vide', 'format.duree.vingtMinutes']],
];
assert.equal(ATTENDUS.length, 7);

View file

@ -62,6 +62,15 @@ export const TABLE = new Map([
`Effacé ${compte(n, 'proposition', 'propositions')}` +
`${gardee === null || gardee === undefined ? '' : `, gardé la retenue (n° ${gardee})`}`,
],
// n : les premières du classement gardées ; effacees : le nombre
// d'effacées ; gardee : la proposition du retenu, gardée en plus, ou null.
[
'ENTREE_GARDER_MEILLEURES',
({ n, effacees, gardee }) =>
`Gardé ${n <= 1 ? 'la meilleure proposition' : `les ${n} meilleures propositions`}, ` +
`effacé ${compte(effacees, 'proposition', 'propositions')}` +
`${gardee === null || gardee === undefined ? '' : `, gardé aussi la retenue (n° ${gardee})`}`,
],
// --- Le contrôleur de génération (§ 5.9, § 5.10, § 15.4) -------------------
['GENERATION_EN_COURS', () => "Une génération est déjà en cours : l'annuler, ou attendre qu'elle s'achève."],

View file

@ -171,6 +171,19 @@ const RETIRER_UN_LIBRE_DE_LA_TABLE = (idTable) => [
(charge) => ({ tour: 1, participant: libresA(charge, 1, idTable)[0] }),
];
// Deux propositions de plus sur la charge où le geste s'exécute, sous une
// graine qui n'est pas celle de la charge témoin : de quoi effacer au-delà
// de la première du classement et de la retenue.
const GENERER_DEUX_DE_PLUS = [
'enregistrerGeneration',
(charge) => {
const configuration = configurationDepuisCharge(charge);
const { historique } = charge.reglages.generation;
const propositions = rechercher(configuration, { graine: 23, nombre: 2, arret: 200, historique });
return { propositions, configuration, produitVersion: V };
},
];
/**
* Par commande : accepte, refuse, sansEffet, chacun { args, avant?, sur? }
* — args, un objet ou une fonction de la charge où l'exemple s'exécute ;
@ -323,6 +336,16 @@ const EXEMPLES = new Map([
sansEffet: { avant: [['effacerPropositions', {}]], args: {} },
},
],
[
'garderMeilleures',
{
accepte: { avant: [GENERER_DEUX_DE_PLUS], args: { n: 1 } },
refuse:
"garder ne refuse du domaine qu'une configuration qui ne se normalise pas, que la charge témoin ne porte " +
"pas : ses réservations tiennent ; commandes.test.js l'éprouve",
sansEffet: { args: (charge) => ({ n: charge.propositions.length }) },
},
],
[
'creerTable',
{

View file

@ -3645,6 +3645,37 @@ describe('séance : la course de la recherche dans son compagnon (§ 5.10, § 8.
assert.equal(fs.contenu('documents', COURSES), texte);
});
test("garder les meilleures retire du compagnon les courses des effacées ; une entrée, que défaire rend, compagnon compris ; en lecture, refusé sans rien écrire", async () => {
const fs = creerFichiersSimules();
await deposerEssai(fs);
const montage = monter({ fs });
await ouvrirSansEcrire(montage);
const { seance } = montage;
await seance.passerEnEcriture();
await seance.executer('enregistrerGeneration', generer(seance.charge, 3));
assert.deepEqual(dansLeCompagnon(fs), [1, 2, 3]);
const entrees = journalDe(fs).entrees.length;
const rendu = await seance.executer('garderMeilleures', { n: 1 });
assert.deepEqual(rendu.resume, { effacees: 2 });
const gardees = seance.charge.propositions.map(({ id }) => id);
assert.equal(gardees.length, 1);
assert.deepEqual(dansLeCompagnon(fs), gardees);
assert.equal(seance.charge.prochainsIds.proposition, 4);
assert.equal(journalDe(fs).entrees.length, entrees + 1);
assert.equal(libellesDe(journalDe(fs)).at(-1), 'Gardé la meilleure proposition, effacé 2 propositions');
await seance.defaire();
assert.deepEqual(seance.charge.propositions.map(({ id }) => id), [1, 2, 3]);
assert.deepEqual(dansLeCompagnon(fs), [1, 2, 3]);
await seance.passerEnLecture();
const etat = fs.contenu('documents', `${BASE}${SUFFIXES.etat}`);
await refus(seance.executer('garderMeilleures', { n: 1 }), 'MODE_LECTURE', { remede: { geste: 'modifier' } });
assert.equal(fs.contenu('documents', `${BASE}${SUFFIXES.etat}`), etat);
assert.deepEqual(dansLeCompagnon(fs), [1, 2, 3]);
});
// Le compteur des propositions ne recule jamais sous défaire ni revenir ;
// il recule quand l'opérateur reprend un état d'avant la génération, ici
// son .precedent. La génération suivante, sous la même graine, porte

View file

@ -72,6 +72,7 @@ export const TABLE = new Map([
['communs.remede.enregistrerGeneration', 'Enregistrer la génération'],
['communs.remede.retenirProposition', 'Retenir la proposition'],
['communs.remede.effacerPropositions', 'Effacer les propositions'],
['communs.remede.garderMeilleures', 'Garder les meilleures propositions'],
['communs.remede.creerTable', 'Créer une table'],
['communs.remede.supprimerTable', 'Supprimer la table'],
['communs.remede.deplacerTable', 'Déplacer la table'],

View file

@ -11,8 +11,15 @@
// chaque valeur qu'un palier applique par sa clé moteur.valeur.<valeur>,
// puis l'identifiant croissant, qui départage deux propositions égales sur
// tous (§ 5.7, A11).
import { creerFormat } from '../../application/format.js';
import { compte } from '../../application/libelles/aides.js';
// Un nombre de propositions, mis en forme, et son nom accordé.
const propositions = (n, texte) => `${creerFormat(texte).nombre(n)} ${n <= 1 ? 'proposition' : 'propositions'}`;
// « la meilleure » pour une, « les N meilleures » au-delà, N mis en forme.
const meilleures = (n, texte) => (n <= 1 ? 'la meilleure' : `les ${creerFormat(texte).nombre(n)} meilleures`);
// Le nom d'un critère du classement, celui de la table du moteur.
const critere = (nom, texte) => texte(`moteur.critere.${nom}`);
@ -347,6 +354,36 @@ export const TABLE = new Map([
// de qualité, qui la mesure (§ 12).
['propositions.commande.qualite', 'Qualité'],
['propositions.commande.effacer', 'Effacer les propositions'],
['propositions.commande.garder', ({ n }, texte) => `Garder ${meilleures(n, texte)}`],
// --- Garder les meilleures (§ 5.7) ---------------------------------------------
// n : le nombre à garder ; effacees : celles qui s'effacent, comptées par
// nettoyage (evaluation.js) ; proposition : celle du retenu, gardée en plus.
['propositions.nettoyage.champ', 'Propositions à garder'],
[
'propositions.nettoyage.titre',
({ n, effacees }, texte) => `Effacer ${propositions(effacees, texte)} et garder ${meilleures(n, texte)} ?`,
],
[
'propositions.nettoyage.gardees',
({ n }, texte) =>
n <= 1
? 'La première proposition du classement reste.'
: `Les ${creerFormat(texte).nombre(n)} premières propositions du classement restent.`,
],
[
'propositions.nettoyage.origine',
({ proposition }) => `La proposition n° ${proposition} reste aussi : le placement retenu en vient.`,
],
[
'propositions.nettoyage.defaire',
({ effacees }, texte) =>
effacees <= 1 ? '« Défaire » rend la proposition effacée.' : `« Défaire » rend les ${propositions(effacees, texte)} effacées.`,
],
[
'propositions.nettoyage.annonce',
({ effacees }, texte) => `${propositions(effacees, texte)} ${effacees <= 1 ? 'effacée' : 'effacées'}.`,
],
// --- Retenir sur un retenu ajusté (§ 2.1) ----------------------------------------
[

View file

@ -2,16 +2,16 @@
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
<script>
// La fenêtre à confirmer (§ 2.1) : la seule fenêtre du logiciel, réservée
// à la destruction de saisie. Un <dialog> modal, ouvert au montage, qui
// porte le titre — la clé titre traduite — et nomme chaque élément détruit,
// chacun dans son propre nœud de texte. Annuler a le focus par défaut ;
// Échap annule. Annuler ferme la fenêtre puis appelle surAnnuler.
// Confirmer appelle surConfirmer et attend sa promesse : aboutie, la
// fenêtre se ferme ; refusée — une erreur qui porte un code —, elle reste
// ouverte et le refus, avec son remède, s'y pose, là où le geste a eu
// lieu ; une erreur sans code ferme la fenêtre et remonte. Le parent la
// démonte.
// La fenêtre à confirmer (§ 2.1) : la seule fenêtre du logiciel, réservée à
// la destruction de saisie et au nettoyage des propositions (§ 5.7). Un
// <dialog> modal, ouvert au montage, qui porte le titre — la clé titre
// traduite — et nomme chaque élément détruit, chacun dans son propre nœud
// de texte. Annuler a le focus par défaut ; Échap annule. Annuler ferme la
// fenêtre puis appelle surAnnuler. Confirmer appelle surConfirmer et attend
// sa promesse : aboutie, la fenêtre se ferme ; refusée — une erreur qui
// porte un code —, elle reste ouverte et le refus, avec son remède, s'y
// pose, là où le geste a eu lieu ; une erreur sans code ferme la fenêtre et
// remonte. Le parent la démonte.
//
// Pendant l'attente, la fenêtre est occupée : la destruction est déjà
// demandée, et rien ne la dit annulée pendant qu'elle s'achève. Annuler,

View file

@ -582,6 +582,204 @@ describe('effacer', () => {
});
});
// --- Garder les meilleures (§ 5.7) ---------------------------------------------------
// Le champ du nombre à garder, le bouton qui garde, et l'annonce de leur
// geste.
const champGardees = (m) => section(m).querySelector('[data-champ="gardees"]');
const garder = (m) => commande(section(m), 'garder');
const annonce = (m) => section(m).querySelector('[data-annonce-nettoyage]');
// Saisit texte dans le champ du nombre à garder, puis le valide par Entrée.
async function saisirGardees(m, texte) {
const entree = champGardees(m);
entree.focus();
entree.value = texte;
entree.dispatchEvent(new Event('input', { bubbles: true }));
await touche('Enter');
flushSync();
}
// Active le bouton au clavier, qui prend d'abord le focus, puis rend la
// fenêtre ouverte.
async function ouvrirFenetre(m) {
garder(m).focus();
await touche('Enter');
return attendre(() => {
const ouverte = fenetreOuverte(m);
expect(ouverte).not.toBeNull();
return ouverte;
});
}
describe('garder les meilleures', () => {
test('dix au départ, à côté d’« Effacer » ; rien à effacer : le geste part sans fenêtre et « sans effet » se pose à côté, rien d’écrit', async () => {
const m = await ouvrir(avecPlans(chargeDemo('petite-conflit'), PLANS_CONFLIT));
expect(champGardees(m).value).toBe('10');
expect(texteDe(garder(m))).toBe(t('propositions.commande.garder', { n: 10 }));
expect(texteDe(garder(m))).toBe('Garder les 10 meilleures');
const etiquette = section(m).querySelector(`label[for="${champGardees(m).id}"]`);
expect(texteDe(etiquette)).toBe(t('propositions.nettoyage.champ'));
expect(commande(section(m), 'effacer')).not.toBeNull();
expect(annonce(m).getAttribute('role')).toBe('status');
expect(texteDe(annonce(m))).toBe('');
const ecritures = m.trace.length;
garder(m).click();
await attendre(() => expect(refusPres(garder(m), 'SANS_EFFET')).not.toBeNull());
expect(fenetreOuverte(m)).toBeNull();
expect(m.trace.length).toBe(ecritures);
expect(ids(m)).toEqual([3, 2, 1]);
});
test('N à 1 : le bouton le dit ; la fenêtre compte les effacées ; Annuler et Échap n’écrivent rien et rendent le focus au bouton ; Confirmer garde la première, une entrée, l’annonce le dit, « Défaire » les rend et la tait', async () => {
const m = await ouvrir(avecPlans(chargeDemo('petite-conflit'), PLANS_CONFLIT));
await saisirGardees(m, '1');
await attendre(() => expect(texteDe(garder(m))).toBe('Garder la meilleure'));
const ecritures = m.trace.length;
const fenetre = await ouvrirFenetre(m);
expect(texteDe(fenetre.querySelector('h2'))).toBe('Effacer 2 propositions et garder la meilleure ?');
expect(texteDe(fenetre)).toContain(t('propositions.nettoyage.gardees', { n: 1 }));
expect(texteDe(fenetre)).not.toContain(t('propositions.nettoyage.origine', { proposition: 1 }));
expect(document.activeElement).toBe(commande(fenetre, 'renoncer'));
commande(fenetre, 'renoncer').click();
await attendre(() => expect(fenetreOuverte(m)).toBeNull());
await attendre(() => expect(document.activeElement).toBe(garder(m)));
await ouvrirFenetre(m);
await touche('Escape');
await attendre(() => expect(fenetreOuverte(m)).toBeNull());
await attendre(() => expect(document.activeElement).toBe(garder(m)));
expect(m.trace.length).toBe(ecritures);
expect(ids(m)).toEqual([3, 2, 1]);
const avant = revision(m);
const seconde = await ouvrirFenetre(m);
commande(seconde, 'confirmer').click();
await attendre(() => expect(ids(m)).toEqual([3]));
await attendre(() => expect(fenetreOuverte(m)).toBeNull());
expect(charge(m).propositions.map(({ id }) => id)).toEqual([3]);
expect(charge(m).prochainsIds.proposition).toBe(4);
expect(charge(m).evenement.etat).toBe('propose');
expect(revision(m)).toBe(avant + 1);
await attendre(() => expect(texteDe(annonce(m))).toBe('2 propositions effacées.'));
await attendre(() => expect(document.activeElement).toBe(garder(m)));
await m.seance.defaire();
await attendre(() => expect(ids(m)).toEqual([3, 2, 1]));
// L'annonce ne vaut que pour la charge qu'elle décrit : défaite, elle se tait.
await attendre(() => expect(texteDe(annonce(m))).toBe(''));
});
test('la proposition du retenu, au-delà de N, reste aussi : la fenêtre le dit, le retenu ne bouge pas', async () => {
const m = await ouvrir(executer(avecPlans(chargeDemo('petite-conflit'), PLANS_CONFLIT), 'retenirProposition', { id: 1 }));
const retenu = structuredClone(charge(m).retenu);
await saisirGardees(m, '1');
const fenetre = await ouvrirFenetre(m);
expect(texteDe(fenetre.querySelector('h2'))).toBe('Effacer 1 proposition et garder la meilleure ?');
expect(texteDe(fenetre)).toContain('La proposition n° 1 reste aussi : le placement retenu en vient.');
commande(fenetre, 'confirmer').click();
await attendre(() => expect(ids(m)).toEqual([3, 1]));
expect(charge(m).propositions.map(({ id }) => id)).toEqual([1, 3]);
expect(charge(m).retenu).toEqual(retenu);
expect(charge(m).evenement.etat).toBe('retenu');
await attendre(() => expect(texteDe(annonce(m))).toBe('1 proposition effacée.'));
});
test('une saisie hors des entiers ≥ 1 se refuse contre le champ, et le bouton garde le dernier nombre accepté ; Échap le rend', async () => {
const m = await ouvrir(avecPlans(chargeDemo('petite-conflit'), PLANS_CONFLIT));
await saisirGardees(m, '2');
await attendre(() => expect(texteDe(garder(m))).toBe('Garder les 2 meilleures'));
await saisirGardees(m, '0');
await attendre(() => expect(champGardees(m).getAttribute('aria-invalid')).toBe('true'));
const refus = section(m).querySelector(`#${champGardees(m).getAttribute('aria-describedby')}`);
expect(texteDe(refus)).toContain("La valeur doit être d'au moins 1.");
expect(texteDe(garder(m))).toBe('Garder les 2 meilleures');
await saisirGardees(m, 'dix');
await attendre(() => expect(champGardees(m).getAttribute('aria-describedby')).not.toBeNull());
expect(texteDe(garder(m))).toBe('Garder les 2 meilleures');
champGardees(m).focus();
await touche('Escape');
await attendre(() => expect(champGardees(m).value).toBe('2'));
expect(champGardees(m).getAttribute('aria-invalid')).toBeNull();
});
// Deux propositions du moteur, que la séance enregistre comme le fait une
// génération qui s'achève en arrière-plan ; rend la promesse de l'écriture.
function genererPendant(m) {
const configuration = configurationDepuisCharge(charge(m));
const propositions = rechercher(configuration, { graine: 2, nombre: 2, arret: 300 });
return m.seance.executer('enregistrerGeneration', { propositions, configuration, produitVersion: V1 });
}
test('une génération enregistrée pendant que la fenêtre est ouverte : la fenêtre recompte les effacées sans perdre le focus, et Confirmer efface ce qu’elle dit', async () => {
const m = await ouvrir(avecPlans(chargeDemo('petite-conflit'), PLANS_CONFLIT));
await saisirGardees(m, '1');
const fenetre = await ouvrirFenetre(m);
expect(texteDe(fenetre.querySelector('h2'))).toBe('Effacer 2 propositions et garder la meilleure ?');
await genererPendant(m);
expect(charge(m).propositions.length).toBe(5);
await attendre(() => expect(texteDe(fenetre.querySelector('h2'))).toBe('Effacer 4 propositions et garder la meilleure ?'));
expect(texteDe(fenetre)).toContain('« Défaire » rend les 4 propositions effacées.');
expect(fenetreOuverte(m)).toBe(fenetre);
expect(document.activeElement).toBe(commande(fenetre, 'renoncer'));
const premiere = evaluation(charge(m)).table.propositions[0].id;
const avant = revision(m);
// Chaque titre que la fenêtre montre entre Confirmer et sa fermeture :
// l'écriture de la commande ne le recompte pas.
const titres = [];
const observateur = new MutationObserver(() => titres.push(texteDe(fenetre.querySelector('h2'))));
observateur.observe(fenetre.querySelector('h2'), { childList: true, characterData: true, subtree: true });
commande(fenetre, 'confirmer').click();
await attendre(() => expect(ids(m)).toEqual([premiere]));
await attendre(() => expect(fenetreOuverte(m)).toBeNull());
observateur.disconnect();
expect(titres.filter((titre) => titre !== 'Effacer 4 propositions et garder la meilleure ?')).toEqual([]);
expect(revision(m)).toBe(avant + 1);
await attendre(() => expect(texteDe(annonce(m))).toBe('4 propositions effacées.'));
});
test('une écriture déjà dans la file quand Confirmer part : l’annonce dit le compte que la commande a effacé, non l’écart de deux lectures', async () => {
const m = await ouvrir(avecPlans(chargeDemo('petite-conflit'), PLANS_CONFLIT));
await saisirGardees(m, '1');
const fenetre = await ouvrirFenetre(m);
const enregistree = genererPendant(m);
commande(fenetre, 'confirmer').click();
await enregistree;
await attendre(() => expect(ids(m).length).toBe(1));
await attendre(() => expect(texteDe(annonce(m))).toBe('4 propositions effacées.'));
});
test('en lecture, le nombre se règle ; le geste part sans fenêtre et « Modifier » se pose à côté, rien d’écrit ; le fichier ne garde jamais N', async () => {
const m = await ouvrir(avecPlans(chargeDemo('petite-conflit'), PLANS_CONFLIT), { mode: 'lecture' });
const ecritures = m.trace.length;
await saisirGardees(m, '1');
await attendre(() => expect(texteDe(garder(m))).toBe('Garder la meilleure'));
garder(m).click();
const refus = await attendre(() => {
const pose = refusPres(garder(m), 'MODE_LECTURE');
expect(pose).not.toBeNull();
return pose;
});
expect(commande(refus, 'modifier')).not.toBeNull();
expect(fenetreOuverte(m)).toBeNull();
expect(m.trace.length).toBe(ecritures);
expect(ids(m)).toEqual([3, 2, 1]);
});
test('sur un plan bloqué, refusé avec « Débloquer », sans fenêtre', async () => {
const bloquee = executer(avecPlans(chargeDemo('petite-conflit'), PLANS_CONFLIT), 'changerEtat', { etat: 'bloque' });
const m = await ouvrir(bloquee, { mode: 'lecture' });
await saisirGardees(m, '1');
garder(m).click();
const refus = await attendre(() => {
const pose = refusPres(garder(m), 'LECTURE_SEULE');
expect(pose).not.toBeNull();
return pose;
});
expect(commande(refus, 'debloquer')).not.toBeNull();
expect(fenetreOuverte(m)).toBeNull();
expect(ids(m)).toEqual([3, 2, 1]);
});
});
// --- Les populations différentes (§ 9, § 12.6) -------------------------------------
describe('les populations différentes', () => {
@ -1609,3 +1807,21 @@ describe('les pages du classement (§ 5.7)', () => {
await attendre(() => expect(surlignees(m)).toEqual(attendues));
});
});
describe('garder les meilleures, sur les pages du classement', () => {
test('237 propositions, la page 3 montrée, garder 150 : la page 3 n’existe plus, la 1 revient ; les 150 premières du classement restent', async () => {
const { brute, ordre } = pagesDEpreuve();
const m = await ouvrir(brute);
commandePage(m, 'haut', 'suivantes').click();
await exigerPage(m, ordre, 101, 200, 237);
commandePage(m, 'haut', 'suivantes').click();
await exigerPage(m, ordre, 201, 237, 237);
await saisirGardees(m, '150');
const fenetre = await ouvrirFenetre(m);
expect(texteDe(fenetre.querySelector('h2'))).toBe('Effacer 87 propositions et garder les 150 meilleures ?');
commande(fenetre, 'confirmer').click();
await exigerPage(m, ordre, 1, 100, 150);
expect(charge(m).propositions.map(({ id }) => id)).toEqual(ordre.slice(0, 150).sort((a, b) => a - b));
await attendre(() => expect(texteDe(annonce(m))).toBe('87 propositions effacées.'));
});
});

View file

@ -3,10 +3,11 @@
<script>
// La section des propositions (§ 2.1 étape 10, § 5.7, § 9) : la phrase de
// l'ordre du classement, le tableau, et la commande qui efface la liste en
// épargnant le retenu. Regarder n'écrit jamais (§ 8.4) : le tableau se lit
// en lecture et sur un plan bloqué, où la séance refuse elle-même
// « Retenir » et « Effacer », le refus posé à côté du bouton.
// l'ordre du classement, le tableau, la commande qui garde les meilleures
// et celle qui efface la liste en épargnant le retenu. Regarder n'écrit
// jamais (§ 8.4) : le tableau se lit en lecture et sur un plan bloqué, où
// la séance refuse elle-même « Retenir », « Garder » et « Effacer », le
// refus posé à côté du bouton.
//
// La table d'évaluation se lit dans la charge de l'état observable, par
// evaluation (modele.js) : une seule mesure, celle d'evaluer
@ -24,6 +25,26 @@
// aucune saisie — chaque proposition se régénère de sa graine, et le geste
// se défait — : il s'exécute sans fenêtre.
//
// « Garder les N meilleures » garde les N premières lignes du tableau, et
// la proposition du retenu quand elle vient après elles, par la commande
// garderMeilleures {n}. N se saisit dans le champ étroit qui précède le
// bouton, dix au départ : une saisie de la section, que ni le fichier ni le
// journal ne portent, vivante en lecture, et qui repart de dix quand la
// section se remonte. La saisie se valide à la sortie du champ ou par
// Entrée, lue par lireGardees ; refusée, son refus se pose contre le champ,
// la saisie gardée, et le bouton garde le dernier nombre accepté ; Échap
// rend celui-ci. Le geste efface d'un coup des lignes que l'opérateur ne
// voit pas toutes : la fenêtre à confirmer dit combien s'effacent et ce
// qui reste (demandeNettoyage), sur la table même que le tableau montre.
// Ouverte, elle se recalcule sur chaque état publié : une génération
// enregistrée en arrière-plan pendant qu'elle attend change son compte,
// celui que Confirmer applique. Un geste que la séance refuserait, ou qui
// n'effacerait rien, part sans fenêtre, et son refus se pose à côté du
// bouton. Abouti, une région role="status" annonce le nombre d'effacées
// que la commande rend dans son résumé, tant que l'événement reste à la
// révision du nettoyage, et la page montrée qui n'existe plus ramène à la
// première (pageApresNettoyage).
//
// La phrase de l'ordre se met bout à bout, morceau par morceau (modele.js) :
// les paliers appliqués, chaque nom celui de la valeur que son palier
// applique — deux noms pour un critère qui en classe deux. Chaque nom, et
@ -62,8 +83,9 @@
// plan, le retenu — ne dépend de la page.
//
// Habillage : le tableau et la phrase de l'ordre vivent dans une carte
// titrée, « Effacer les propositions » à droite de son en-tête ; le message
// d'une liste vide dans sa carte.
// titrée, le nombre à garder, « Garder les N meilleures » et « Effacer les
// propositions » à droite de son en-tête ; le message d'une liste vide
// dans sa carte.
import { getContext, tick, untrack } from 'svelte';
import Carte from '../primitives/Carte.svelte';
import Commande from '../primitives/Commande.svelte';
@ -71,12 +93,18 @@
import Infobulle from '../primitives/Infobulle.svelte';
import Refus from '../primitives/Refus.svelte';
import TableauPropositions from './TableauPropositions.svelte';
import { effetDe } from '../raccourcis.js';
import {
GARDEES_PAR_DEFAUT,
annonceNettoyage,
changerDePage,
demandeConfirmation,
demandeNettoyage,
evaluation,
explicationDeColonne,
lireGardees,
nomDe,
pageApresNettoyage,
pagine,
retenuAjuste,
suivrePage,
@ -184,6 +212,85 @@
}
const effacer = () => seance.executer('effacerPropositions', {});
// Le nombre à garder : le dernier accepté, le texte du champ, et le refus
// de la saisie, null sans lui.
const idGardees = `${uid}-gardees`;
const idRefusGardees = `${uid}-gardees-refus`;
let gardees = $state(GARDEES_PAR_DEFAUT);
let saisieGardees = $state(String(GARDEES_PAR_DEFAUT));
let refusGardees = $state(null);
// Rend au champ le dernier nombre accepté, sans refus.
function rendreGardees() {
saisieGardees = String(gardees);
refusGardees = null;
}
function validerGardees() {
const lu = lireGardees(saisieGardees);
if (lu.refus !== undefined) {
refusGardees = lu.refus;
return;
}
gardees = lu.valeur;
rendreGardees();
}
function toucheGardees(evenement) {
const effet = effetDe('champ', evenement.key);
if (effet === 'rendre') rendreGardees();
else if (effet === 'valider') {
evenement.preventDefault();
validerGardees();
}
}
// Le nettoyage en attente de confirmation, tel que demandeNettoyage le
// rend à l'ouverture de la fenêtre ; null sans fenêtre. La fenêtre montre
// nettoyageMontre : la demande refaite sur l'état publié, dont le compte
// suit chaque écriture qui arrive pendant qu'elle attend. Quand l'état
// publié ne donnerait plus de fenêtre — la séance refuserait, ou rien ne
// s'effacerait —, elle reprend aNettoyer : Confirmer n'efface alors rien
// et reçoit le refus de la commande, posé dans la fenêtre. Confirmer
// retient dans aNettoyer la demande montrée : l'écriture de la commande,
// qui ne laisse rien à effacer, ne ramène pas la fenêtre au compte de son
// ouverture avant qu'elle se ferme. L'annonce du dernier nettoyage abouti,
// {cle, details, revision}, ou null : elle ne se montre que tant que
// l'événement ouvert est à la révision que le nettoyage a écrite — un
// geste suivant, « Défaire » compris, la tait.
let aNettoyer = $state(null);
const nettoyageMontre = $derived(aNettoyer === null ? null : (demandeNettoyage($seance, aNettoyer.n) ?? aNettoyer));
let annonce = $state(null);
const annonceMontree = $derived(annonce !== null && annonce.revision === $seance.evenement?.revision ? annonce : null);
// Garde les n meilleures, puis annonce les effacées que la commande
// compte ; la page montrée avant le geste, qui n'existe plus, ramène à la
// première.
async function nettoyerSansQuestion(n) {
const pageAvant = page?.page ?? 1;
annonce = null;
const { resume } = await seance.executer('garderMeilleures', { n });
await tick();
ui.retenirPagePropositions(pageApresNettoyage(ui.etat().pagePropositions, pageAvant));
annonce = { ...annonceNettoyage(resume.effacees), revision: seance.etat().evenement.revision };
}
function garder() {
const demande = demandeNettoyage(seance.etat(), gardees);
if (demande === null) return nettoyerSansQuestion(gardees);
aNettoyer = demande;
return undefined;
}
// Confirmé, le nettoyage abouti démonte la fenêtre, qui ne rend pas le
// focus d'elle-même une fois retirée : le bouton « Garder » le reprend.
async function confirmerNettoyage() {
aNettoyer = nettoyageMontre;
await nettoyerSansQuestion(aNettoyer.n);
aNettoyer = null;
tick().then(() => racine?.querySelector('[data-commande="garder"]')?.focus());
}
const nommer = (id) => nomDe(charge, id);
</script>
@ -213,6 +320,29 @@
<Commande cle="propositions.commande.effacer" executer={effacer} />
{/snippet}
{#snippet commandesListe()}
<div class="garder">
<label for={idGardees}>{t('propositions.nettoyage.champ')}</label>
<input
id={idGardees}
type="text"
inputmode="numeric"
data-champ="gardees"
bind:value={saisieGardees}
aria-invalid={refusGardees === null ? undefined : 'true'}
aria-describedby={refusGardees === null ? undefined : idRefusGardees}
onkeydown={toucheGardees}
onblur={validerGardees}
/>
{#if refusGardees !== null}
<Refus id={idRefusGardees} refus={refusGardees} />
{/if}
<Commande cle="propositions.commande.garder" details={{ n: gardees }} executer={garder} />
<p class="annonce" role="status" data-annonce-nettoyage>{annonceMontree === null ? '' : t(annonceMontree.cle, annonceMontree.details)}</p>
</div>
{@render effacerListe()}
{/snippet}
<section data-section="propositions" bind:this={racine}>
<h2>{t('coquille.section.propositions')}</h2>
@ -227,7 +357,7 @@
{/if}
{#if vue !== null}
<Carte titre={t('propositions.tableau.titre')} niveau={3} plein actions={charge.propositions.length > 0 ? effacerListe : null}>
<Carte titre={t('propositions.tableau.titre')} niveau={3} plein actions={charge.propositions.length > 0 ? commandesListe : null}>
<div class="ordre" data-ordre>
{#each vue.ordre as morceaux, rang (rang)}
<p>
@ -274,6 +404,16 @@
surAnnuler={() => (aConfirmer = null)}
/>
{/if}
{#if nettoyageMontre !== null}
<Confirmation
titre={nettoyageMontre.titre.cle}
details={nettoyageMontre.titre.details}
elements={nettoyageMontre.elements.map((element) => t(element.cle, element.details))}
surConfirmer={confirmerNettoyage}
surAnnuler={() => (aNettoyer = null)}
/>
{/if}
</section>
<style>
@ -325,6 +465,36 @@
display: flex;
}
/* Le nombre à garder, son bouton et l'annonce, sur une ligne qui se replie
quand l'en-tête manque de place. */
.garder {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--espace-2);
}
.garder input {
width: 6ch;
padding: var(--espace-1) var(--espace-2);
border: 1px solid var(--couleur-page-filet);
border-radius: var(--rayon-2);
background-color: var(--couleur-carte-fond);
color: var(--couleur-page-texte);
font: inherit;
font-size: var(--taille-texte);
font-variant-numeric: tabular-nums;
}
.garder input:focus-visible {
outline: 2px solid var(--couleur-focus);
outline-offset: 1px;
}
.garder input[aria-invalid='true'] {
border-color: var(--couleur-refus);
}
/* Une nav des pages : l'étendue au centre, « Précédentes » contre elle à
gauche, « Suivantes » à droite ; une commande absente laisse sa place
vide, et l'étendue ne bouge pas d'une page à l'autre. */

View file

@ -31,7 +31,8 @@
// virgule ; sinon le texte est t(cle) — « — » pour une population vide,
// « inconnu » pour une grandeur que l'a priori inconnu ne permet pas, le
// certificat en mots.
import { evaluer, retenuAjuste } from '../../application/evaluation.js';
import { lireEntier } from '../../application/capacite.js';
import { evaluer, nettoyage, retenuAjuste } from '../../application/evaluation.js';
import { nomAffiche } from '../../application/personnes.js';
const VIDE = 'format.vide';
@ -711,3 +712,85 @@ export function texteEtendue({ premier, dernier, total }, { t, format }) {
nombre: dernier - premier + 1,
});
}
/** Le nombre de propositions que « Garder les N meilleures » garde au
* départ (§ 5.7). Le nombre réglé est une saisie de la section, que ni le
* fichier ni le journal ne portent. */
export const GARDEES_PAR_DEFAUT = 10;
/**
* Lit la saisie du nombre de propositions à garder : un entier
* (lireEntier, NON_ENTIER {saisie}), puis au moins 1 (HORS_DOMAINE {min: 1,
* max: null}) ; chaque refus porte un remède nul, la valeur se corrigeant
* dans le champ même.
*
* @param {string} saisie
* @returns {{valeur: number}|{refus: {code: string, details: Object}}}
*/
export function lireGardees(saisie) {
const lu = lireEntier(saisie);
if (lu.refus !== undefined) return { refus: { code: lu.refus.code, details: lu.refus.details } };
if (lu.valeur < 1) return { refus: { code: 'HORS_DOMAINE', details: { min: 1, max: null, remede: null } } };
return { valeur: lu.valeur };
}
/**
* La fenêtre à confirmer avant de garder les n meilleures propositions
* (§ 5.7) : elle dit combien s'effacent, combien restent, et que la
* proposition du retenu reste aussi quand elle vient après les n premières.
* Le partage est celui de nettoyage (evaluation.js), sur la table que le
* tableau montre (evaluation) : la fenêtre ne range rien elle-même. Sans
* fenêtre — null —, le geste part tel quel et son refus s'affiche : quand la
* séance le refuserait (etat.refus), quand rien ne se classe, faute de
* charge ou de table, et quand rien ne s'effacerait.
*
* @param {{refus: Object|null, charge: Object|null}} etat l'état observable de la séance
* @param {number} n entier ≥ 1, le nombre à garder
* @returns {null|{n: number, effacees: number, titre: {cle: string, details: Object},
* elements: Array<{cle: string, details: Object}>}} null sans fenêtre ;
* sinon n, le nombre d'effacées, le titre, puis les éléments : les
* premières qui restent, la proposition du retenu quand elle reste en
* plus, et le retour que « Défaire » offre
*/
export function demandeNettoyage({ refus, charge }, n) {
if (refus !== null || charge === null) return null;
const { table } = evaluation(charge);
if (table === null) return null;
const { effacees, origineEnPlus } = nettoyage(table, n);
if (effacees.length === 0) return null;
const nombre = effacees.length;
return {
n,
effacees: nombre,
titre: { cle: 'propositions.nettoyage.titre', details: { n, effacees: nombre } },
elements: [
{ cle: 'propositions.nettoyage.gardees', details: { n } },
...(origineEnPlus === null ? [] : [{ cle: 'propositions.nettoyage.origine', details: { proposition: origineEnPlus } }]),
{ cle: 'propositions.nettoyage.defaire', details: { effacees: nombre } },
],
};
}
/**
* L'annonce qui suit le nettoyage : le nombre de propositions effacées, tel
* que la commande garderMeilleures le rend dans son résumé.
*
* @param {number} effacees resume.effacees du geste abouti
* @returns {{cle: string, details: {effacees: number}}}
*/
export function annonceNettoyage(effacees) {
return { cle: 'propositions.nettoyage.annonce', details: { effacees } };
}
/**
* La page après un nettoyage : la première quand la page montrée avant,
* pageAvant, n'existe plus — le suivi recalé par suivrePage la bornerait à
* la dernière —, le suivi tel quel sinon, null compris.
*
* @param {SuiviDePage|null} suivi le suivi recalé sur le tableau d'après
* @param {number} pageAvant
* @returns {SuiviDePage|null}
*/
export function pageApresNettoyage(suivi, pageAvant) {
return suivi !== null && pageAvant > suivi.pages ? changerDePage(suivi, 1) : suivi;
}

View file

@ -12,7 +12,7 @@
import assert from 'node:assert/strict';
import { describe, test } from '../../../test/lanceur.js';
import { appliquerCommande } from '../../application/commandes.js';
import { detaillerQualite, evaluer, retenuAjuste as retenuAjusteDeLEvaluation } from '../../application/evaluation.js';
import { detaillerQualite, evaluer, nettoyage, retenuAjuste as retenuAjusteDeLEvaluation } from '../../application/evaluation.js';
import { creerFormat } from '../../application/format.js';
import { TABLES_APPLICATION } from '../../application/libelles.js';
import { nomAffiche } from '../../application/personnes.js';
@ -33,16 +33,21 @@ import { TABLES_INTERFACE } from '../libelles.js';
import * as MODELE from './modele.js';
import {
COLONNES,
GARDEES_PAR_DEFAUT,
GROUPES_COLONNES,
TAILLE_PAGE,
annonceNettoyage,
changerDePage,
colonneDeValeur,
demandeConfirmation,
demandeNettoyage,
evaluation,
explicationDeColonne,
lireColonne,
nomDe,
nombreDePages,
lireGardees,
pageApresNettoyage,
pageDe,
pagine,
retenuAjuste,
@ -1085,6 +1090,109 @@ describe('demandeConfirmation (§ 2.1)', () => {
});
});
describe('garder les meilleures (§ 5.7) : la saisie, la fenêtre, l’annonce, la page', () => {
// Les trois plans de la petite démonstration au conflit inévitable, que
// le classement range 3, 2, 1 ; la charge figée, comme celle de l'état
// observable de la séance.
const conflit = () => avecPlans(chargeDemo('petite-conflit'), PLANS_CONFLIT);
const figee = (charge) => Object.freeze(structuredClone(charge));
test('dix au départ ; la saisie se lit en entier ≥ 1, blancs de bord admis ; sinon son refus, remède nul', () => {
assert.equal(GARDEES_PAR_DEFAUT, 10);
assert.deepEqual(lireGardees('10'), { valeur: 10 });
assert.deepEqual(lireGardees(' 3 '), { valeur: 3 });
assert.deepEqual(lireGardees('0'), { refus: { code: 'HORS_DOMAINE', details: { min: 1, max: null, remede: null } } });
for (const saisie of ['', 'dix', '2,5', '-1', '1e3']) {
assert.deepEqual(lireGardees(saisie), { refus: { code: 'NON_ENTIER', details: { saisie, remede: null } } }, saisie);
}
assert.equal(t('HORS_DOMAINE', { min: 1, max: null }), "La valeur doit être d'au moins 1.");
});
test('la fenêtre dit combien s’effacent et combien restent, sur la table que le tableau montre ; aucun ordre refait', () => {
const charge = figee(conflit());
const table = evaluation(charge).table;
assert.deepEqual(table.propositions.map(({ id }) => id), [3, 2, 1]);
const demande = demandeNettoyage({ refus: null, charge }, 1);
assert.deepEqual(demande, {
n: 1,
effacees: 2,
titre: { cle: 'propositions.nettoyage.titre', details: { n: 1, effacees: 2 } },
elements: [
{ cle: 'propositions.nettoyage.gardees', details: { n: 1 } },
{ cle: 'propositions.nettoyage.defaire', details: { effacees: 2 } },
],
});
assert.equal(demande.effacees, nettoyage(table, 1).effacees.length);
assert.equal(t(demande.titre.cle, demande.titre.details), 'Effacer 2 propositions et garder la meilleure ?');
assert.deepEqual(
demande.elements.map(({ cle, details }) => t(cle, details)),
['La première proposition du classement reste.', '« Défaire » rend les 2 propositions effacées.'],
);
});
test('la proposition du retenu, au-delà des n premières, reste aussi, et la fenêtre le dit', () => {
const charge = figee(executer(conflit(), 'retenirProposition', { id: 1 }));
const demande = demandeNettoyage({ refus: null, charge }, 1);
assert.equal(demande.effacees, 1);
assert.deepEqual(demande.elements, [
{ cle: 'propositions.nettoyage.gardees', details: { n: 1 } },
{ cle: 'propositions.nettoyage.origine', details: { proposition: 1 } },
{ cle: 'propositions.nettoyage.defaire', details: { effacees: 1 } },
]);
assert.equal(t(demande.titre.cle, demande.titre.details), 'Effacer 1 proposition et garder la meilleure ?');
assert.deepEqual(
demande.elements.map(({ cle, details }) => t(cle, details)),
[
'La première proposition du classement reste.',
'La proposition n° 1 reste aussi : le placement retenu en vient.',
'« Défaire » rend la proposition effacée.',
],
);
});
test('sans fenêtre : la séance refuserait, rien à effacer, ou rien à classer — le geste part, et son refus se pose', () => {
const charge = figee(conflit());
const refus = { code: 'MODE_LECTURE', details: { remede: { geste: 'modifier' } } };
assert.equal(demandeNettoyage({ refus, charge }, 1), null);
assert.equal(demandeNettoyage({ refus: null, charge }, 3), null);
assert.equal(demandeNettoyage({ refus: null, charge }, 12), null);
assert.equal(demandeNettoyage({ refus: null, charge: null }, 1), null);
const retenue = figee(executer(conflit(), 'retenirProposition', { id: 1 }));
assert.equal(demandeNettoyage({ refus: null, charge: retenue }, 2), null);
const surreservee = structuredClone(conflit());
surreservee.reservations = [1, 2, 3, 4].map((participant) => ({ participant, table: 1, siege: null, portee: 'tous', tour: null }));
assert.equal(evaluation(figee(surreservee)).table, null);
assert.equal(demandeNettoyage({ refus: null, charge: figee(surreservee) }, 1), null);
});
test('les libellés : le bouton nomme N, la fenêtre et l’annonce accordent leurs nombres, mis en forme', () => {
const fine = '\u{202F}';
assert.equal(t('propositions.commande.garder', { n: 10 }), 'Garder les 10 meilleures');
assert.equal(t('propositions.commande.garder', { n: 1 }), 'Garder la meilleure');
assert.equal(t('propositions.commande.garder', { n: 1500 }), `Garder les 1${fine}500 meilleures`);
assert.equal(t('propositions.nettoyage.titre', { n: 10, effacees: 37 }), 'Effacer 37 propositions et garder les 10 meilleures ?');
assert.equal(t('propositions.nettoyage.gardees', { n: 10 }), 'Les 10 premières propositions du classement restent.');
assert.equal(t('propositions.nettoyage.champ'), 'Propositions à garder');
assert.deepEqual(annonceNettoyage(37), { cle: 'propositions.nettoyage.annonce', details: { effacees: 37 } });
assert.equal(t('propositions.nettoyage.annonce', { effacees: 37 }), '37 propositions effacées.');
assert.equal(t('propositions.nettoyage.annonce', { effacees: 1 }), '1 proposition effacée.');
});
test('après le nettoyage, la page qui n’existe plus ramène à la première ; une page qui existe encore reste', () => {
const avant = changerDePage(suivrePage(null, observee(vueDe(450))), 4);
assert.equal(avant.page, 4);
const apres = suivrePage(avant, observee(vueDe(250)));
assert.equal(apres.page, 3);
const premiere = pageApresNettoyage(apres, 4);
assert.deepEqual([premiere.page, premiere.pages, premiere.cles], [1, 3, apres.cles]);
assert.ok(Object.isFrozen(premiere));
const deuxieme = changerDePage(suivrePage(null, observee(vueDe(450))), 2);
const garde = suivrePage(deuxieme, observee(vueDe(250)));
assert.equal(pageApresNettoyage(garde, 2), garde);
assert.equal(pageApresNettoyage(null, 2), null);
});
});
describe('evaluation : la table d’une charge, ou ce qui l’empêche', () => {
test('sans proposition ni retenu, rien à évaluer ; une configuration qui ne se normalise pas rend son refus', () => {
assert.deepEqual(evaluation(chargeDemo('petite')), { table: null, refus: null });

View file

@ -19,6 +19,7 @@
// [attribut, porteur, valeurs], dans l'ordre des attributs.
const ENTREES = [
['data-affichee', 'import : la valeur affichée d’une cellule de l’aperçu', ['']],
['data-annonce-nettoyage', 'propositions : l’annonce des propositions effacées par « Garder les N meilleures »', ['']],
['data-annonce-planche', 'badges : l’annonce de la planche, avant de produire', ['']],
['data-annonces', 'événement : la zone des annonces à côté de la tête', ['']],
['data-apercu', 'import : l’aperçu d’un fichier ou d’un collage', ['']],