[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:
parent
8fd3bccd86
commit
4f9c550e44
22 changed files with 980 additions and 34 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
56
spec.md
|
|
@ -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 —
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]) {
|
||||
|
|
|
|||
|
|
@ -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) ------------
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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."],
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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) ----------------------------------------
|
||||
[
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.'));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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', ['']],
|
||||
|
|
|
|||
Loading…
Reference in a new issue