[ADD] proposals: pages of 100 in the single ranking order
Past a few hundred proposals the table rebuilt every row on each visit: at 500, 15 000 nodes and about 290 ms on a second visit. The table now shows the global ranking 100 at a time, each row keeping its rank, with the retained row on top of every page. The page is navigation state, never written: it resets on each event opening and after a generation, is clamped when proposals go, and follows a proposal shown on the plan. At 500: second visit 288 -> 55 ms, first 686 -> 449 ms, 3 100 nodes. Checked: node 4039, navigateur 884, node-long 105, all green. --- FR --- [ADD] propositions : pages de 100 dans l'ordre du classement unique Au-delà de quelques centaines de propositions, le tableau refaisait toutes ses lignes à chaque visite : à 500, 15 000 nœuds et environ 290 ms au retour. Il montre désormais le classement global 100 par 100, chaque ligne gardant son rang, le retenu en tête de chaque page. La page est un état de navigation, jamais écrit : elle repart à chaque ouverture et après une génération, se borne quand des propositions partent, et suit la proposition montrée au plan. À 500 : retour 288 -> 55 ms, 1re 686 -> 449. Vérifié : node 4039, navigateur 884, node-long 105, toutes vertes. Assisted-by: Claude Opus 5.5
This commit is contained in:
parent
156851e043
commit
824f9214f6
10 changed files with 847 additions and 14 deletions
8
spec.md
8
spec.md
|
|
@ -635,6 +635,14 @@ 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.
|
||||
|
||||
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.
|
||||
|
||||
**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
|
||||
proposition conserve sa graine dérivée, ce compte et cette longueur (§ 8.9).
|
||||
|
|
|
|||
|
|
@ -3,10 +3,11 @@
|
|||
|
||||
// L'état de navigation de l'interface : la section ouverte, le panneau « À
|
||||
// propos », le tour montré, la personne mise en évidence, la proposition
|
||||
// demandée au plan, le texte d'un import en attente, les listes de noms, le
|
||||
// plein écran, la place sélectionnée, la fiche ouverte, le choix en attente
|
||||
// du plan, le refus posé sur une place, l'appartenance collante du
|
||||
// formulaire d'ajout et l'aperçu avant impression. Ce qu'il tient survit au
|
||||
// demandée au plan et le numéro de sa demande, le texte d'un import en
|
||||
// attente, les listes de noms, le plein écran, la place sélectionnée, la
|
||||
// fiche ouverte, le choix en attente du plan, le refus posé sur une place,
|
||||
// l'appartenance collante du formulaire d'ajout, l'aperçu avant impression
|
||||
// et la page du tableau des propositions. Ce qu'il tient survit au
|
||||
// démontage des sections.
|
||||
// Regarder n'écrit jamais (§ 8.4) : cet état ne reçoit ni la séance, ni la
|
||||
// façade, ni un système de fichiers, et rien de ce qu'il tient ne quitte la
|
||||
|
|
@ -49,6 +50,12 @@ import { SECTION_OUVERTURE, SECTIONS } from './evenement/sections.js';
|
|||
* tour du plan, 'tous' pour une vue de la soirée, jamais pour le plan ; les
|
||||
* listes de noms du plan ; sa découpe en pages ; la feuille par table, une
|
||||
* par tour ou la soirée en colonnes ; la section où l'aperçu se referme
|
||||
* @property {number} visee le numéro de la dernière demande d'une proposition
|
||||
* au plan : chaque montrerProposition d'une proposition l'augmente de 1, même
|
||||
* pour celle déjà demandée ; 0 au départ
|
||||
* @property {Object|null} pagePropositions le suivi de la page du tableau des propositions
|
||||
* (SuiviDePage, propositions/modele.js), figé, que la section des propositions
|
||||
* tient ; null au départ, et chaque événement qui s'ouvre repart de la première page
|
||||
*/
|
||||
|
||||
// L'état de navigation d'un événement qui s'ouvre, panneau « À propos »
|
||||
|
|
@ -67,6 +74,8 @@ const DEPART = Object.freeze({
|
|||
pose: null,
|
||||
appartenance: '',
|
||||
apercu: null,
|
||||
visee: 0,
|
||||
pagePropositions: null,
|
||||
});
|
||||
|
||||
/** Les vues que l'aperçu imprime (§ 11.7). */
|
||||
|
|
@ -144,7 +153,8 @@ function choixValide(choix) {
|
|||
* basculerListes: Function, basculerPleinEcran: Function,
|
||||
* selectionner: Function, ouvrirFiche: Function, choisir: Function,
|
||||
* poserSurPlace: Function, garderAppartenance: Function,
|
||||
* ouvrirApercu: Function, reglerApercu: Function, fermerApercu: Function}}
|
||||
* ouvrirApercu: Function, reglerApercu: Function, fermerApercu: Function,
|
||||
* retenirPagePropositions: Function}}
|
||||
*/
|
||||
export function creerEtatUi() {
|
||||
let etat = Object.freeze({ ...DEPART, apropos: false });
|
||||
|
|
@ -197,9 +207,21 @@ export function creerEtatUi() {
|
|||
changer({ miseEnEvidence: id });
|
||||
},
|
||||
/** Demande la proposition id au plan, ou aucune (null) ; la pose, lue
|
||||
* sur l'état des places d'avant, se retire. */
|
||||
* sur l'état des places d'avant, se retire. Chaque demande d'une
|
||||
* proposition reçoit le numéro de visée suivant, et publie donc un état
|
||||
* neuf même pour la proposition déjà demandée : la section des
|
||||
* propositions y reconnaît une visée nouvelle. */
|
||||
montrerProposition(id) {
|
||||
changer({ proposition: id, pose: null });
|
||||
changer(id === null ? { proposition: null, pose: null } : { proposition: id, pose: null, visee: etat.visee + 1 });
|
||||
},
|
||||
/** Retient le suivi de la page du tableau des propositions, un objet
|
||||
* figé, ou null ; RangeError sinon, sans rien changer. Le même suivi ne
|
||||
* publie rien. */
|
||||
retenirPagePropositions(suivi) {
|
||||
if (suivi !== null && (typeof suivi !== 'object' || !Object.isFrozen(suivi))) {
|
||||
throw new RangeError(`page des propositions : suivi figé ou null attendu, reçu ${JSON.stringify(suivi)}`);
|
||||
}
|
||||
changer({ pagePropositions: suivi });
|
||||
},
|
||||
/** Garde un texte à importer et ouvre la section de l'import. */
|
||||
ouvrirImport({ texte, origine }) {
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ const DEPART = {
|
|||
pose: null,
|
||||
appartenance: '',
|
||||
apercu: null,
|
||||
visee: 0,
|
||||
pagePropositions: null,
|
||||
};
|
||||
|
||||
// Un état et le relevé de ce que reçoit son abonné.
|
||||
|
|
@ -95,6 +97,7 @@ describe('état de navigation : chaque transition', () => {
|
|||
ui.ouvrirFiche(4);
|
||||
ui.choisir({ genre: 'personne', participant: 2, depuis: null });
|
||||
ui.poserSurPlace({ place: { table: 1, siege: 2 }, refus: { code: 'SANS_EFFET', details: {} }, avertissements: [] });
|
||||
ui.retenirPagePropositions(Object.freeze({ page: 2 }));
|
||||
ui.ouvrirApropos();
|
||||
ui.reinitialiser();
|
||||
assert.deepEqual(ui.etat(), { ...DEPART, apropos: true });
|
||||
|
|
@ -124,11 +127,45 @@ describe('état de navigation : chaque transition', () => {
|
|||
ui.mettreEnEvidence(12);
|
||||
ui.montrerProposition(3);
|
||||
ui.selectionner({ table: 2, siege: 1 });
|
||||
assert.deepEqual(ui.etat(), { ...DEPART, miseEnEvidence: 12, proposition: 3, selection: { table: 2, siege: 1 } });
|
||||
assert.deepEqual(ui.etat(), { ...DEPART, miseEnEvidence: 12, proposition: 3, visee: 1, selection: { table: 2, siege: 1 } });
|
||||
ui.mettreEnEvidence(null);
|
||||
ui.montrerProposition(null);
|
||||
ui.selectionner(null);
|
||||
assert.deepEqual(ui.etat(), DEPART);
|
||||
assert.deepEqual(ui.etat(), { ...DEPART, visee: 1 });
|
||||
});
|
||||
|
||||
test('montrerProposition numérote chaque visée : la même proposition demandée de nouveau publie un état neuf ; null retire la visée sans la numéroter', () => {
|
||||
const { ui, recus } = suivi();
|
||||
ui.montrerProposition(5);
|
||||
assert.equal(ui.etat().visee, 1);
|
||||
ui.montrerProposition(5);
|
||||
assert.deepEqual([ui.etat().proposition, ui.etat().visee, recus.length], [5, 2, 3]);
|
||||
ui.montrerProposition(null);
|
||||
ui.montrerProposition(null);
|
||||
assert.deepEqual([ui.etat().proposition, ui.etat().visee, recus.length], [null, 2, 4]);
|
||||
ui.montrerProposition(5);
|
||||
assert.equal(ui.etat().visee, 3);
|
||||
ui.reinitialiser();
|
||||
assert.equal(ui.etat().visee, 0);
|
||||
});
|
||||
|
||||
test('retenirPagePropositions retient le suivi de la page des propositions, figé, ou null ; un autre événement repart de null ; RangeError sinon, sans rien changer', () => {
|
||||
const { ui, recus } = suivi();
|
||||
const page = Object.freeze({ cles: Object.freeze(['1/1/40/1000']), visee: 0, page: 2, pages: 3 });
|
||||
ui.retenirPagePropositions(page);
|
||||
assert.equal(ui.etat().pagePropositions, page);
|
||||
ui.retenirPagePropositions(page);
|
||||
assert.equal(recus.length, 2);
|
||||
ui.allerA('plan');
|
||||
ui.allerA('propositions');
|
||||
assert.equal(ui.etat().pagePropositions, page);
|
||||
for (const faute of [undefined, 2, 'page', { page: 2 }]) assert.throws(() => ui.retenirPagePropositions(faute), RangeError, String(faute));
|
||||
assert.equal(ui.etat().pagePropositions, page);
|
||||
ui.reinitialiser();
|
||||
assert.equal(ui.etat().pagePropositions, null);
|
||||
ui.retenirPagePropositions(page);
|
||||
ui.retenirPagePropositions(null);
|
||||
assert.equal(ui.etat().pagePropositions, null);
|
||||
});
|
||||
|
||||
test('ouvrirImport garde le texte en attente et mène à la section import ; prendreImport le rend une fois', () => {
|
||||
|
|
|
|||
|
|
@ -41,6 +41,22 @@ export const TABLE = new Map([
|
|||
`Appliqué à sa place : ${critere(applique, texte)}.`,
|
||||
],
|
||||
|
||||
// --- Les pages du classement (§ 5.7) -------------------------------------------
|
||||
// Au-delà de cent propositions, le classement se lit page à page ; chaque
|
||||
// nav des pages se nomme, celle du dessus et celle du dessous. L'étendue
|
||||
// reçoit ses nombres déjà mis en forme (texteEtendue,
|
||||
// propositions/modele.js), et nombre, le compte de la page, qui accorde
|
||||
// son nom.
|
||||
['propositions.pages.haut', 'Pages du classement'],
|
||||
['propositions.pages.bas', 'Pages du classement, sous le tableau'],
|
||||
['propositions.pages.precedentes', 'Précédentes'],
|
||||
['propositions.pages.suivantes', 'Suivantes'],
|
||||
[
|
||||
'propositions.pages.etendue',
|
||||
({ premier, dernier, total, nombre }) =>
|
||||
nombre <= 1 ? `Proposition ${premier} sur ${total}` : `Propositions ${premier} à ${dernier} sur ${total}`,
|
||||
],
|
||||
|
||||
// --- Les colonnes, chacune avec son unité (§ 5.4, § 5.5) ----------------------
|
||||
['propositions.colonne.rang', 'Rang'],
|
||||
['propositions.colonne.proposition', 'Proposition'],
|
||||
|
|
|
|||
|
|
@ -1170,3 +1170,265 @@ describe('l’habillage du tableau des propositions', () => {
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
// --- Les pages du classement (§ 5.7, § 2.1 étapes 9 et 10, § 8.4) ---------------
|
||||
|
||||
// La grande démonstration et 237 propositions (graine 7, arrêt 50) : brute,
|
||||
// sans retenu ; retenue, la 150e du classement retenue ; encore, cent
|
||||
// propositions de plus sous la graine 8, que l'épreuve enregistre. ordre :
|
||||
// les identifiants dans l'ordre que rend le moteur, sollicité une seconde
|
||||
// fois (§ 19.1). Calculées une fois, pour toutes les épreuves des pages.
|
||||
let calculees = null;
|
||||
function pagesDEpreuve() {
|
||||
if (calculees !== null) return calculees;
|
||||
const brute = generee(chargeDemo('grande'), { graine: 7, nombre: 237, arret: 50, historique: 1000 });
|
||||
const configuration = configurationDepuisCharge(brute);
|
||||
const instance = normaliser(configuration);
|
||||
const aPriori = plafondsAPriori(instance);
|
||||
const { ordre } = classer(
|
||||
brute.propositions.map((p) => ({ id: p.id, mesures: mesurer(instance, indexerPlan(instance, planDepuisFichier(p))), plafondsAPriori: aPriori })),
|
||||
);
|
||||
const retenue = executer(brute, 'retenirProposition', { id: ordre[149] });
|
||||
const encore = rechercher(configuration, { graine: 8, nombre: 100, arret: 50, historique: 1000 });
|
||||
calculees = { brute, retenue, ordre, idRetenue: ordre[149], encore, configuration };
|
||||
return calculees;
|
||||
}
|
||||
|
||||
// La charge brute réduite à ses n premières propositions par identifiant.
|
||||
function premieres(n) {
|
||||
const reduite = structuredClone(pagesDEpreuve().brute);
|
||||
reduite.propositions = reduite.propositions.filter(({ id }) => id <= n);
|
||||
return reduite;
|
||||
}
|
||||
|
||||
const navDes = (m, ou) => section(m).querySelector(`nav[data-pages="${ou}"]`);
|
||||
const etendue = (m, ou = 'haut') => navDes(m, ou)?.querySelector('[data-etendue]') ?? null;
|
||||
const rangsLus = (m) => lignes(m).map((tr) => texteDe(tr.querySelector('td[data-colonne="rang"]')));
|
||||
const rangsDe = (premier, dernier) => Array.from({ length: dernier - premier + 1 }, (_, place) => format.nombre(premier + place));
|
||||
const texteEtendue = (premier, dernier, total) =>
|
||||
t('propositions.pages.etendue', { premier: format.nombre(premier), dernier: format.nombre(dernier), total: format.nombre(total), nombre: dernier - premier + 1 });
|
||||
|
||||
// La page montrée : les identifiants de ses lignes, et l'étendue que disent
|
||||
// les deux nav.
|
||||
async function exigerPage(m, ordre, premier, dernier, total) {
|
||||
await attendre(() => expect(ids(m)).toEqual(ordre.slice(premier - 1, dernier)));
|
||||
expect(rangsLus(m)).toEqual(rangsDe(premier, dernier));
|
||||
for (const ou of ['haut', 'bas']) expect(texteDe(etendue(m, ou)), ou).toBe(texteEtendue(premier, dernier, total));
|
||||
}
|
||||
|
||||
// Une commande de page d'une nav, null quand elle n'y est pas.
|
||||
const commandePage = (m, ou, nom) => commande(navDes(m, ou), nom);
|
||||
|
||||
describe('les pages du classement (§ 5.7)', () => {
|
||||
beforeEach(async () => {
|
||||
await pointer('bouger', 0, 0);
|
||||
});
|
||||
|
||||
test('237 propositions, la page 1 : les rangs 1 à 100 dans l’ordre du moteur, le retenu en tête ; une nav nommée au-dessus du tableau et une sous lui, « Suivantes » sans « Précédentes »', async () => {
|
||||
const { retenue, ordre, idRetenue } = pagesDEpreuve();
|
||||
const m = await ouvrir(retenue);
|
||||
await exigerPage(m, ordre, 1, 100, 237);
|
||||
const table = section(m).querySelector('table');
|
||||
const haut = navDes(m, 'haut');
|
||||
const bas = navDes(m, 'bas');
|
||||
expect(haut.getAttribute('aria-label')).toBe(t('propositions.pages.haut'));
|
||||
expect(bas.getAttribute('aria-label')).toBe(t('propositions.pages.bas'));
|
||||
expect(haut.compareDocumentPosition(table) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(bas.compareDocumentPosition(table) & Node.DOCUMENT_POSITION_PRECEDING).toBeTruthy();
|
||||
for (const ou of ['haut', 'bas']) {
|
||||
expect(commandePage(m, ou, 'precedentes'), ou).toBeNull();
|
||||
expect(texteDe(commandePage(m, ou, 'suivantes')), ou).toBe(t('propositions.pages.suivantes'));
|
||||
}
|
||||
expect(etendue(m, 'haut').getAttribute('role')).toBe('status');
|
||||
expect(etendue(m, 'bas').getAttribute('role')).toBeNull();
|
||||
expect(ligneRetenu(m)).not.toBeNull();
|
||||
expect(ligneRetenu(m).compareDocumentPosition(lignes(m)[0]) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(ligne(m, idRetenue)).toBeNull();
|
||||
});
|
||||
|
||||
test('« Suivantes » : la page 2 aux rangs globaux 101 à 200, le focus sur l’étendue annoncée ; puis la 3, 37 lignes, sans « Suivantes » ; « Précédentes » rend la 2 ; rien ne s’écrit', async () => {
|
||||
const { retenue, ordre, idRetenue } = pagesDEpreuve();
|
||||
const m = await ouvrir(retenue);
|
||||
const ecritures = m.trace.length;
|
||||
const revisionAvant = revision(m);
|
||||
commandePage(m, 'haut', 'suivantes').click();
|
||||
await exigerPage(m, ordre, 101, 200, 237);
|
||||
await attendre(() => expect(document.activeElement).toBe(etendue(m, 'haut')));
|
||||
expect(ligneRetenu(m)).not.toBeNull();
|
||||
expect(texteDe(ligne(m, idRetenue))).toContain(t('propositions.marque.retenue'));
|
||||
for (const ou of ['haut', 'bas']) {
|
||||
expect(commandePage(m, ou, 'precedentes'), ou).not.toBeNull();
|
||||
expect(commandePage(m, ou, 'suivantes'), ou).not.toBeNull();
|
||||
}
|
||||
commandePage(m, 'bas', 'suivantes').click();
|
||||
await exigerPage(m, ordre, 201, 237, 237);
|
||||
await attendre(() => expect(document.activeElement).toBe(etendue(m, 'haut')));
|
||||
for (const ou of ['haut', 'bas']) expect(commandePage(m, ou, 'suivantes'), ou).toBeNull();
|
||||
commandePage(m, 'bas', 'precedentes').click();
|
||||
await exigerPage(m, ordre, 101, 200, 237);
|
||||
await attendre(() => expect(document.activeElement).toBe(etendue(m, 'haut')));
|
||||
expect(m.trace.length).toBe(ecritures);
|
||||
expect(revision(m)).toBe(revisionAvant);
|
||||
});
|
||||
|
||||
test('en mode lecture, « Suivantes » montre la page 2 et rien ne s’écrit (§ 8.4)', async () => {
|
||||
const { retenue, ordre } = pagesDEpreuve();
|
||||
const m = await ouvrir(retenue, { mode: 'lecture' });
|
||||
await exigerPage(m, ordre, 1, 100, 237);
|
||||
const ecritures = m.trace.length;
|
||||
const revisionAvant = revision(m);
|
||||
commandePage(m, 'haut', 'suivantes').click();
|
||||
await exigerPage(m, ordre, 101, 200, 237);
|
||||
expect(m.trace.length).toBe(ecritures);
|
||||
expect(revision(m)).toBe(revisionAvant);
|
||||
});
|
||||
|
||||
test('la page survit au plan : « Voir sur le plan » depuis la page 2, retour sur la 2 ; changer de page garde la proposition demandée au plan, et la page choisie reste', async () => {
|
||||
const { retenue, ordre } = pagesDEpreuve();
|
||||
const m = await ouvrir(retenue);
|
||||
commandePage(m, 'haut', 'suivantes').click();
|
||||
await exigerPage(m, ordre, 101, 200, 237);
|
||||
const montree = ordre[120];
|
||||
commande(ligne(m, montree), 'voir').click();
|
||||
await attendre(() => expect(m.racine.querySelector('[data-section="plan"]')).not.toBeNull());
|
||||
expect(m.ui.etat().proposition).toBe(montree);
|
||||
m.ui.allerA('propositions');
|
||||
await exigerPage(m, ordre, 101, 200, 237);
|
||||
commandePage(m, 'haut', 'precedentes').click();
|
||||
await exigerPage(m, ordre, 1, 100, 237);
|
||||
expect(m.ui.etat().proposition).toBe(montree);
|
||||
m.ui.allerA('plan');
|
||||
flushSync();
|
||||
m.ui.allerA('propositions');
|
||||
await exigerPage(m, ordre, 1, 100, 237);
|
||||
});
|
||||
|
||||
test('une proposition visée depuis ailleurs amène la page qui la porte : au retour sur la section, et section ouverte', async () => {
|
||||
const { retenue, ordre } = pagesDEpreuve();
|
||||
const m = await ouvrir(retenue);
|
||||
await exigerPage(m, ordre, 1, 100, 237);
|
||||
m.ui.allerA('plan');
|
||||
flushSync();
|
||||
m.ui.montrerProposition(ordre[220]);
|
||||
m.ui.allerA('propositions');
|
||||
await exigerPage(m, ordre, 201, 237, 237);
|
||||
expect(ligne(m, ordre[220])).not.toBeNull();
|
||||
m.ui.montrerProposition(ordre[150]);
|
||||
await exigerPage(m, ordre, 101, 200, 237);
|
||||
});
|
||||
|
||||
test('la même proposition visée de nouveau depuis le plan, après un retour à aucune ou sans lui, amène encore sa page', async () => {
|
||||
const { retenue, ordre } = pagesDEpreuve();
|
||||
const m = await ouvrir(retenue);
|
||||
m.ui.allerA('plan');
|
||||
flushSync();
|
||||
m.ui.montrerProposition(ordre[220]);
|
||||
m.ui.allerA('propositions');
|
||||
await exigerPage(m, ordre, 201, 237, 237);
|
||||
commandePage(m, 'haut', 'precedentes').click();
|
||||
await exigerPage(m, ordre, 101, 200, 237);
|
||||
m.ui.allerA('plan');
|
||||
flushSync();
|
||||
m.ui.montrerProposition(null);
|
||||
flushSync();
|
||||
m.ui.montrerProposition(ordre[220]);
|
||||
m.ui.allerA('propositions');
|
||||
await exigerPage(m, ordre, 201, 237, 237);
|
||||
commandePage(m, 'haut', 'precedentes').click();
|
||||
await exigerPage(m, ordre, 101, 200, 237);
|
||||
m.ui.allerA('plan');
|
||||
flushSync();
|
||||
m.ui.montrerProposition(ordre[220]);
|
||||
m.ui.allerA('propositions');
|
||||
await exigerPage(m, ordre, 201, 237, 237);
|
||||
});
|
||||
|
||||
test('une génération ramène à la page 1 ; « Défaire », qui la retire, borne la page à la dernière qui reste', async () => {
|
||||
const { retenue, ordre, encore, configuration } = pagesDEpreuve();
|
||||
const m = await ouvrir(retenue);
|
||||
commandePage(m, 'haut', 'suivantes').click();
|
||||
commandePage(m, 'haut', 'suivantes').click();
|
||||
await exigerPage(m, ordre, 201, 237, 237);
|
||||
await m.seance.executer('enregistrerGeneration', { propositions: encore, configuration, produitVersion: V1 });
|
||||
await attendre(() => expect(texteDe(etendue(m))).toBe(texteEtendue(1, 100, 337)));
|
||||
expect(rangsLus(m)).toEqual(rangsDe(1, 100));
|
||||
for (let page = 2; page <= 4; page += 1) {
|
||||
commandePage(m, 'haut', 'suivantes').click();
|
||||
await attendre(() => expect(texteDe(etendue(m))).toBe(texteEtendue((page - 1) * 100 + 1, Math.min(page * 100, 337), 337)));
|
||||
}
|
||||
await m.seance.defaire();
|
||||
await exigerPage(m, ordre, 201, 237, 237);
|
||||
});
|
||||
|
||||
test('un autre événement s’ouvre sur la page 1', async () => {
|
||||
const { retenue, ordre } = pagesDEpreuve();
|
||||
const texte = serialiser(retenue, { revision: 1, produitVersion: V1 });
|
||||
const m = await monterApp({
|
||||
fichiers: [BASE, 'autre-soiree'].map((base) => ({ racine: 'documents', chemin: `${base}${SUFFIXES.etat}`, texte })),
|
||||
});
|
||||
montes.push(m);
|
||||
await m.seance.ouvrir(BASE);
|
||||
m.ui.allerA('propositions');
|
||||
flushSync();
|
||||
commandePage(m, 'haut', 'suivantes').click();
|
||||
await exigerPage(m, ordre, 101, 200, 237);
|
||||
await m.seance.ouvrir('autre-soiree');
|
||||
m.ui.allerA('propositions');
|
||||
await exigerPage(m, ordre, 1, 100, 237);
|
||||
});
|
||||
|
||||
test('chaque ouverture repart de la page 1 : le premier événement rouvert après un autre que la section n’a pas vu, puis rouvert après sa fermeture', async () => {
|
||||
const { retenue, ordre } = pagesDEpreuve();
|
||||
const texte = serialiser(retenue, { revision: 1, produitVersion: V1 });
|
||||
const m = await monterApp({
|
||||
fichiers: [BASE, 'autre-soiree'].map((base) => ({ racine: 'documents', chemin: `${base}${SUFFIXES.etat}`, texte })),
|
||||
});
|
||||
montes.push(m);
|
||||
await m.seance.ouvrir(BASE);
|
||||
m.ui.allerA('propositions');
|
||||
flushSync();
|
||||
commandePage(m, 'haut', 'suivantes').click();
|
||||
await exigerPage(m, ordre, 101, 200, 237);
|
||||
await m.seance.ouvrir('autre-soiree');
|
||||
flushSync();
|
||||
await m.seance.ouvrir(BASE);
|
||||
m.ui.allerA('propositions');
|
||||
await exigerPage(m, ordre, 1, 100, 237);
|
||||
commandePage(m, 'haut', 'suivantes').click();
|
||||
await exigerPage(m, ordre, 101, 200, 237);
|
||||
await m.seance.fermer();
|
||||
flushSync();
|
||||
await m.seance.ouvrir(BASE);
|
||||
m.ui.allerA('propositions');
|
||||
await exigerPage(m, ordre, 1, 100, 237);
|
||||
});
|
||||
|
||||
test('à 100 propositions, ni nav ni commande de page ; à 101, la page 2 ne porte que la 101e : « Proposition 101 sur 101 »', async () => {
|
||||
const cent = await ouvrir(premieres(100));
|
||||
await attendre(() => expect(lignes(cent).length).toBe(100));
|
||||
expect(section(cent).querySelector('nav')).toBeNull();
|
||||
expect(section(cent).querySelector('[data-etendue]')).toBeNull();
|
||||
for (const nom of ['precedentes', 'suivantes']) expect(commande(section(cent), nom), nom).toBeNull();
|
||||
const centUn = await ouvrir(premieres(101));
|
||||
await attendre(() => expect(texteDe(etendue(centUn))).toBe(texteEtendue(1, 100, 101)));
|
||||
expect(lignes(centUn).length).toBe(100);
|
||||
commandePage(centUn, 'bas', 'suivantes').click();
|
||||
await attendre(() => expect(lignes(centUn).length).toBe(1));
|
||||
expect(texteDe(etendue(centUn))).toBe('Proposition 101 sur 101');
|
||||
expect(rangsLus(centUn)).toEqual([format.nombre(101)]);
|
||||
});
|
||||
|
||||
test('la colonne que désigne un nom survolé reste surlignée sur la page suivante', async () => {
|
||||
const { retenue } = pagesDEpreuve();
|
||||
const m = await ouvrir(retenue);
|
||||
const nom = section(m).querySelector('[data-ordre] [data-critere="excedentCollisions"]');
|
||||
const { x, y } = await centre(nom);
|
||||
await pointer('bouger', x, y);
|
||||
await attendre(() => expect(surlignees(m)).toEqual(colonne(m, 'excedentCollisions')));
|
||||
commandePage(m, 'haut', 'suivantes').click();
|
||||
await attendre(() => expect(texteDe(etendue(m))).toBe(texteEtendue(101, 200, 237)));
|
||||
const attendues = colonne(m, 'excedentCollisions');
|
||||
expect(attendues.length).toBe(102);
|
||||
await attendre(() => expect(surlignees(m)).toEqual(attendues));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -39,6 +39,25 @@
|
|||
// explication (explicationDeColonne, modele.js). Le terme expliqué porte
|
||||
// aria-describedby vers elle, et lui seul.
|
||||
//
|
||||
// Au-delà de TAILLE_PAGE propositions (modele.js), le tableau se lit page
|
||||
// à page : pagine découpe le classement unique, sans le retrier, et la
|
||||
// ligne du retenu reste en tête de chaque page, hors du compte. Une nav
|
||||
// nommée au-dessus du tableau, répétée sous lui, porte « Précédentes »,
|
||||
// l'étendue de la page et « Suivantes » ; une commande sans objet n'y est
|
||||
// pas, comme « Refaire » sans retour à refaire. Changer de page n'écrit
|
||||
// rien (§ 8.4) : le focus va à l'étendue de la nav du dessus, une région
|
||||
// role="status" qui annonce la page ; chaque étendue se focalise par
|
||||
// programme (tabindex -1), jamais par Tab. La page est un état
|
||||
// d'interface que l'état de navigation retient (pagePropositions,
|
||||
// retenirPagePropositions) : elle survit au démontage de la section, et
|
||||
// repart de la première à chaque ouverture d'un événement, quand
|
||||
// l'état de navigation revient à son départ. suivrePage la recale à chaque
|
||||
// charge ou visée nouvelles — la première après une génération, bornée à
|
||||
// la dernière quand des propositions partent, celle de la proposition
|
||||
// visée à chaque nouvelle demande au plan (EtatUi.visee). Rien de ce
|
||||
// qu'une ligne tient — la colonne surlignée, la proposition demandée au
|
||||
// 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 et le champ de la cible, chacun dans sa carte.
|
||||
|
|
@ -47,7 +66,7 @@
|
|||
// l'événement (cibleRedondance), et exécute changerCibleRedondance à la
|
||||
// sortie du champ — une entrée ; la saisie se lit par lireEntier, et un
|
||||
// refus se pose contre le champ (Champ).
|
||||
import { getContext } from 'svelte';
|
||||
import { getContext, tick, untrack } from 'svelte';
|
||||
import { lireEntier } from '../../application/capacite.js';
|
||||
import { cibleRedondance } from '../../application/evaluation.js';
|
||||
import { REGLAGES_GENERATION, reglagesDeSection } from '../generation/reglages.js';
|
||||
|
|
@ -58,9 +77,22 @@
|
|||
import Infobulle from '../primitives/Infobulle.svelte';
|
||||
import Refus from '../primitives/Refus.svelte';
|
||||
import TableauPropositions from './TableauPropositions.svelte';
|
||||
import { demandeConfirmation, evaluation, explicationDeColonne, nomDe, retenuAjuste, surlignee as colonneSurlignee, tableau } from './modele.js';
|
||||
import {
|
||||
changerDePage,
|
||||
demandeConfirmation,
|
||||
evaluation,
|
||||
explicationDeColonne,
|
||||
nomDe,
|
||||
pagine,
|
||||
retenuAjuste,
|
||||
suivrePage,
|
||||
surlignee as colonneSurlignee,
|
||||
tableau,
|
||||
texteEtendue,
|
||||
} from './modele.js';
|
||||
|
||||
const t = getContext('t');
|
||||
const format = getContext('format');
|
||||
// Le champ de la cible lit l'entrée que lit aussi le tableau des réglages
|
||||
// de la documentation (§ 19.8).
|
||||
const [entreeCible] = reglagesDeSection(REGLAGES_GENERATION, 'propositions');
|
||||
|
|
@ -72,6 +104,29 @@
|
|||
const ajuste = $derived(charge !== null && retenuAjuste(charge));
|
||||
const vue = $derived(evaluee.table === null ? null : tableau(evaluee.table, { ajuste }));
|
||||
|
||||
// Le suivi de la page (SuiviDePage, modele.js), que l'état de navigation
|
||||
// retient, recalé avant le rendu à chaque vue ou visée nouvelles ; sans
|
||||
// tableau, il reste tel. Le suivi inchangé revient identique de
|
||||
// suivrePage, et l'état de navigation ne publie rien : le recalage
|
||||
// s'arrête là. La page montrée en découle.
|
||||
const suivi = $derived($ui.pagePropositions);
|
||||
$effect.pre(() => {
|
||||
const observee = { vue, propositions: charge?.propositions ?? [], vise: $ui.proposition, visee: $ui.visee };
|
||||
if (vue === null) return;
|
||||
untrack(() => ui.retenirPagePropositions(suivrePage(ui.etat().pagePropositions, observee)));
|
||||
});
|
||||
const page = $derived(vue === null ? null : pagine(vue, suivi?.page ?? 1));
|
||||
const vuePage = $derived(page === null ? null : { ...vue, lignes: page.lignes, horsClassement: page.horsClassement });
|
||||
|
||||
// L'élément de la section, où se trouve l'étendue de la nav du dessus.
|
||||
let racine = $state(null);
|
||||
|
||||
// Montre la page demandée, puis rend le focus à l'étendue qui l'annonce.
|
||||
function tourner(numero) {
|
||||
ui.retenirPagePropositions(changerDePage(ui.etat().pagePropositions, numero));
|
||||
tick().then(() => racine?.querySelector('[data-pages="haut"] [data-etendue]')?.focus());
|
||||
}
|
||||
|
||||
// La retenue en attente de confirmation, telle que demandeConfirmation la
|
||||
// rend : la proposition demandée, le titre et les éléments de la fenêtre ;
|
||||
// null sans fenêtre.
|
||||
|
|
@ -142,11 +197,33 @@
|
|||
const nommer = (id) => nomDe(charge, id);
|
||||
</script>
|
||||
|
||||
{#snippet pages(ou)}
|
||||
<nav class="pages" data-pages={ou} aria-label={t(`propositions.pages.${ou}`)}>
|
||||
<div class="precedentes">
|
||||
{#if page.page > 1}
|
||||
<Commande cle="propositions.pages.precedentes" executer={() => tourner(page.page - 1)} />
|
||||
{/if}
|
||||
</div>
|
||||
<p
|
||||
class="etendue"
|
||||
data-etendue
|
||||
role={ou === 'haut' ? 'status' : undefined}
|
||||
tabindex="-1">
|
||||
{texteEtendue(page, { t, format })}
|
||||
</p>
|
||||
<div class="suivantes">
|
||||
{#if page.page < page.pages}
|
||||
<Commande cle="propositions.pages.suivantes" executer={() => tourner(page.page + 1)} />
|
||||
{/if}
|
||||
</div>
|
||||
</nav>
|
||||
{/snippet}
|
||||
|
||||
{#snippet effacerListe()}
|
||||
<Commande cle="propositions.commande.effacer" executer={effacer} />
|
||||
{/snippet}
|
||||
|
||||
<section data-section="propositions">
|
||||
<section data-section="propositions" bind:this={racine}>
|
||||
<h2>{t('coquille.section.propositions')}</h2>
|
||||
|
||||
{#if charge !== null && charge.propositions.length === 0}
|
||||
|
|
@ -188,7 +265,9 @@
|
|||
</p>
|
||||
{/each}
|
||||
</div>
|
||||
<TableauPropositions {vue} {surlignee} {nommer} {voir} {retenir} {expliquer} {taire} {decrit} />
|
||||
{#if page.paginee}{@render pages('haut')}{/if}
|
||||
<TableauPropositions vue={vuePage} {surlignee} {nommer} {voir} {retenir} {expliquer} {taire} {decrit} />
|
||||
{#if page.paginee}{@render pages('bas')}{/if}
|
||||
</Carte>
|
||||
{:else if charge !== null && charge.propositions.length > 0}
|
||||
<div class="effacer">{@render effacerListe()}</div>
|
||||
|
|
@ -271,6 +350,34 @@
|
|||
display: flex;
|
||||
}
|
||||
|
||||
/* 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. */
|
||||
.pages {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
gap: var(--espace-3);
|
||||
padding: var(--espace-3) var(--espace-5);
|
||||
}
|
||||
|
||||
.precedentes {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.suivantes {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.etendue {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.etendue:focus-visible {
|
||||
outline: 2px solid var(--couleur-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.cible {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
|
|||
|
|
@ -11,7 +11,9 @@
|
|||
// Les violations d'une ligne comparable — retenu compris — se lisent sous
|
||||
// son identifiant.
|
||||
//
|
||||
// vue : ce que rend tableau(), libellés et étendues compris. nommer(id) :
|
||||
// vue : ce que rend tableau(), libellés et étendues compris, ses lignes
|
||||
// et ses lignes hors classement réduites à celles de la page montrée
|
||||
// (pagine, modele.js) ; chaque ligne garde son rang global. nommer(id) :
|
||||
// le nom affiché d'une personne, null pour id null ou pour une personne que
|
||||
// la charge n'a plus. voir(id) et
|
||||
// retenir(id) : les gestes d'une ligne, que la section câble ; voir(null)
|
||||
|
|
|
|||
|
|
@ -461,3 +461,156 @@ export function nomDe(charge, id) {
|
|||
const personne = charge.participants.find((candidate) => candidate.id === id);
|
||||
return personne === undefined ? null : nomAffiche(personne);
|
||||
}
|
||||
|
||||
/** Le nombre de propositions qu'une page du tableau montre (§ 5.7) : le
|
||||
* classement entier se découpe en pages de TAILLE_PAGE lignes, la ligne du
|
||||
* retenu en plus, hors du compte. */
|
||||
export const TAILLE_PAGE = 100;
|
||||
|
||||
/** Le nombre de pages d'un tableau de total propositions : une au moins,
|
||||
* la page d'un tableau vide comprise. */
|
||||
export function nombreDePages(total) {
|
||||
return Math.max(1, Math.ceil(total / TAILLE_PAGE));
|
||||
}
|
||||
|
||||
// La page bornée aux pages existantes : la première en deçà, la dernière
|
||||
// au-delà.
|
||||
const bornee = (page, pages) => Math.min(Math.max(1, page), pages);
|
||||
|
||||
/**
|
||||
* La page d'un tableau (tableau()) : le classement unique découpé, jamais
|
||||
* retrié. Les propositions se comptent dans l'ordre où le tableau les
|
||||
* montre — les classées dans l'ordre du classement, puis celles hors
|
||||
* classement —, et la page p porte celles de rang (p − 1) × TAILLE_PAGE + 1
|
||||
* à p × TAILLE_PAGE. Chaque ligne garde le rang global que tableau() lui a
|
||||
* donné. La page demandée se borne aux pages existantes.
|
||||
*
|
||||
* @param {{lignes: Object[], horsClassement: Object[]}} vue
|
||||
* @param {number} page entière, à partir de 1
|
||||
* @returns {{lignes: Object[], horsClassement: Object[], page: number,
|
||||
* pages: number, premier: number, dernier: number, total: number,
|
||||
* paginee: boolean}} lignes et horsClassement : celles de la page, dans
|
||||
* l'ordre de la vue ; page : la page bornée ; pages : leur nombre ;
|
||||
* premier et dernier : les places, à partir de 1, de la première et de la
|
||||
* dernière proposition de la page, 0 et 0 sans proposition ; total : le
|
||||
* nombre de propositions de la vue ; paginee : vrai au-delà d'une page,
|
||||
* seul cas où des commandes de page existent
|
||||
*/
|
||||
export function pagine(vue, page) {
|
||||
const classees = vue.lignes.length;
|
||||
const total = classees + vue.horsClassement.length;
|
||||
const pages = nombreDePages(total);
|
||||
const montree = bornee(page, pages);
|
||||
const debut = (montree - 1) * TAILLE_PAGE;
|
||||
const fin = Math.min(debut + TAILLE_PAGE, total);
|
||||
return {
|
||||
lignes: vue.lignes.slice(debut, fin),
|
||||
horsClassement: vue.horsClassement.slice(Math.max(0, debut - classees), Math.max(0, fin - classees)),
|
||||
page: montree,
|
||||
pages,
|
||||
premier: total === 0 ? 0 : debut + 1,
|
||||
dernier: fin,
|
||||
total,
|
||||
paginee: total > TAILLE_PAGE,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* La page du tableau qui porte la proposition id, classée ou hors
|
||||
* classement ; null quand la vue ne la porte pas.
|
||||
*
|
||||
* @param {{lignes: Object[], horsClassement: Object[]}} vue
|
||||
* @param {number|null} id
|
||||
* @returns {number|null}
|
||||
*/
|
||||
export function pageDe(vue, id) {
|
||||
const place = [...vue.lignes, ...vue.horsClassement].findIndex((ligne) => ligne.id === id);
|
||||
return place === -1 ? null : Math.floor(place / TAILLE_PAGE) + 1;
|
||||
}
|
||||
|
||||
// Ce qui distingue une proposition d'une autre : son identifiant, et ce qui
|
||||
// la régénère. Un retour arrière rend des identifiants que la génération
|
||||
// suivante réattribue, sous d'autres graines : l'identifiant seul ne dirait
|
||||
// pas qu'elle est nouvelle.
|
||||
const cleDe = ({ id, graine, arret, historique }) => `${id}/${graine}/${arret}/${historique}`;
|
||||
|
||||
/**
|
||||
* @typedef {Readonly<{cles: ReadonlyArray<string>, visee: number, page: number,
|
||||
* pages: number}>} SuiviDePage
|
||||
* la page du tableau que l'état de navigation retient pour l'événement
|
||||
* ouvert : les clés des propositions vues (cleDe), le numéro de la
|
||||
* dernière visée prise en compte (EtatUi.visee), la page, et le nombre de
|
||||
* pages
|
||||
*/
|
||||
|
||||
// Vrai quand deux listes de clés portent les mêmes clés, dans le même ordre.
|
||||
const memesCles = (a, b) => a.length === b.length && a.every((cle, place) => cle === b[place]);
|
||||
|
||||
/**
|
||||
* La page à montrer, du suivi que l'état de navigation retient et de ce que
|
||||
* l'écran montre maintenant (§ 8.4 : un état d'interface, que rien
|
||||
* n'écrit) :
|
||||
*
|
||||
* - la première, sans suivi retenu — l'état de navigation le remet à null à
|
||||
* chaque ouverture d'un événement —, ou quand une proposition est
|
||||
* nouvelle : une génération montre son classement depuis le meilleur ;
|
||||
* - sinon la page retenue, bornée à la dernière qui existe encore ;
|
||||
* - à chaque visée nouvelle — un numéro de visée autre que celui du suivi :
|
||||
* « Voir sur le plan », un remède qui montre une proposition, même celle
|
||||
* que le plan montrait déjà —, la page qui porte la proposition visée, si
|
||||
* la vue la porte. Une visée retirée (vise null) n'amène rien.
|
||||
*
|
||||
* Rien de changé, le suivi retenu revient lui-même : l'état de navigation,
|
||||
* qui ne publie qu'un état différent, ne republie pas.
|
||||
*
|
||||
* @param {SuiviDePage|null} retenue
|
||||
* @param {{vue: {lignes: Object[], horsClassement: Object[]},
|
||||
* propositions: Object[], vise: number|null, visee: number}} observee le
|
||||
* tableau, les propositions de la charge, la proposition que l'état de
|
||||
* navigation demande au plan, et le numéro de cette visée
|
||||
* @returns {SuiviDePage} le suivi à retenir, celui de la page montrée
|
||||
*/
|
||||
export function suivrePage(retenue, { vue, propositions, vise, visee }) {
|
||||
const cles = propositions.map(cleDe);
|
||||
const pages = nombreDePages(vue.lignes.length + vue.horsClassement.length);
|
||||
const connues = new Set(retenue?.cles ?? []);
|
||||
const neuve = retenue === null || cles.some((cle) => !connues.has(cle));
|
||||
let page = neuve ? 1 : retenue.page;
|
||||
const visant = vise !== null && (retenue === null || visee !== retenue.visee) ? pageDe(vue, vise) : null;
|
||||
if (visant !== null) page = visant;
|
||||
page = bornee(page, pages);
|
||||
if (retenue !== null && retenue.visee === visee && retenue.page === page && retenue.pages === pages && memesCles(retenue.cles, cles)) {
|
||||
return retenue;
|
||||
}
|
||||
return Object.freeze({ cles: Object.freeze(cles), visee, page, pages });
|
||||
}
|
||||
|
||||
/**
|
||||
* Le suivi à la page demandée, bornée aux pages existantes.
|
||||
*
|
||||
* @param {SuiviDePage} suivi
|
||||
* @param {number} page
|
||||
* @returns {SuiviDePage}
|
||||
*/
|
||||
export function changerDePage(suivi, page) {
|
||||
return Object.freeze({ ...suivi, page: bornee(page, suivi.pages) });
|
||||
}
|
||||
|
||||
/**
|
||||
* Le texte de l'étendue d'une page (pagine) : « Propositions 101 à 200 sur
|
||||
* 237 », au singulier pour une page d'une seule proposition. Ses nombres se
|
||||
* mettent en forme par format.nombre ; le compte de la page accorde le nom.
|
||||
*
|
||||
* @param {{premier: number, dernier: number, total: number}} page
|
||||
* @param {{t: Function, format: {nombre: Function}}} outils la traduction
|
||||
* et la mise en forme du contexte
|
||||
* @returns {string}
|
||||
*/
|
||||
export function texteEtendue({ premier, dernier, total }, { t, format }) {
|
||||
return t('propositions.pages.etendue', {
|
||||
premier: format.nombre(premier),
|
||||
dernier: format.nombre(dernier),
|
||||
total: format.nombre(total),
|
||||
nombre: dernier - premier + 1,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,15 +32,22 @@ import { TABLES_INTERFACE } from '../libelles.js';
|
|||
import {
|
||||
COLONNES,
|
||||
GROUPES_COLONNES,
|
||||
TAILLE_PAGE,
|
||||
changerDePage,
|
||||
colonneDuCritere,
|
||||
demandeConfirmation,
|
||||
evaluation,
|
||||
explicationDeColonne,
|
||||
nomDe,
|
||||
nombreDePages,
|
||||
pageDe,
|
||||
pagine,
|
||||
retenuAjuste,
|
||||
suivrePage,
|
||||
surlignee,
|
||||
tableau,
|
||||
texteCellule,
|
||||
texteEtendue,
|
||||
} from './modele.js';
|
||||
|
||||
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
|
||||
|
|
@ -891,3 +898,220 @@ describe('COLONNES : les colonnes de nombres', () => {
|
|||
for (const colonne of COLONNES) assert.equal(colonne.nombre, !textes.has(colonne.nom), colonne.nom);
|
||||
});
|
||||
});
|
||||
|
||||
// --- La pagination du classement (§ 5.7, § 2.1 étapes 9 et 10, § 8.4) ---------
|
||||
|
||||
// Une vue réduite à ce que la pagination lit : comparables lignes classées,
|
||||
// rangs 1 à comparables, identifiants 1000 + rang ; puis hors propositions
|
||||
// hors classement, identifiants 1 à hors.
|
||||
function vueDe(comparables, hors = 0) {
|
||||
return {
|
||||
lignes: Array.from({ length: comparables }, (_, place) => ({ id: 1000 + place + 1, rang: place + 1, comparable: true })),
|
||||
horsClassement: Array.from({ length: hors }, (_, place) => ({ id: place + 1, rang: null, comparable: false })),
|
||||
retenu: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Les propositions d'une charge, réduites à ce qui les distingue : chaque
|
||||
// identifiant reçoit sa graine, son arrêt et son historique.
|
||||
const propositionsDe = (ids, { graine = 0, arret = 40, historique = 1000 } = {}) =>
|
||||
ids.map((id) => ({ id, graine: graine + id, arret, historique }));
|
||||
|
||||
// Ce que la section observe d'une vue : la vue, les propositions de sa
|
||||
// charge — une par ligne —, la proposition visée et le numéro de sa visée.
|
||||
function observee(vue, { vise = null, visee = 0, graine = 0, arret = 40, historique = 1000 } = {}) {
|
||||
const ids = [...vue.lignes, ...vue.horsClassement].map(({ id }) => id);
|
||||
return { vue, propositions: propositionsDe(ids, { graine, arret, historique }), vise, visee };
|
||||
}
|
||||
|
||||
describe('pagination : le classement unique se découpe en pages de cent (§ 5.7, § 2.1 étape 9)', () => {
|
||||
test('TAILLE_PAGE vaut 100 ; nombreDePages compte une page au moins', () => {
|
||||
assert.equal(TAILLE_PAGE, 100);
|
||||
assert.deepEqual([0, 1, 100, 101, 200, 201, 237].map(nombreDePages), [1, 1, 1, 2, 2, 3, 3]);
|
||||
});
|
||||
|
||||
test('aux bornes 0, 1, 100, 101, 200, 201 : les pages mises bout à bout rendent le classement entier, dans son ordre, chaque rang une fois', () => {
|
||||
for (const n of [0, 1, 100, 101, 200, 201]) {
|
||||
const vue = vueDe(n);
|
||||
const pages = nombreDePages(n);
|
||||
const lues = [];
|
||||
for (let page = 1; page <= pages; page += 1) {
|
||||
const decoupe = pagine(vue, page);
|
||||
assert.equal(decoupe.page, page, `${n} : page ${page}`);
|
||||
assert.equal(decoupe.pages, pages, `${n}`);
|
||||
assert.equal(decoupe.total, n, `${n}`);
|
||||
assert.equal(decoupe.paginee, n > 100, `${n}`);
|
||||
assert.ok(decoupe.lignes.length <= TAILLE_PAGE, `${n} : page ${page}`);
|
||||
assert.equal(decoupe.premier, n === 0 ? 0 : (page - 1) * TAILLE_PAGE + 1, `${n} : page ${page}`);
|
||||
assert.equal(decoupe.dernier, Math.min(page * TAILLE_PAGE, n), `${n} : page ${page}`);
|
||||
assert.equal(decoupe.dernier - decoupe.premier + (n === 0 ? 0 : 1), decoupe.lignes.length, `${n} : page ${page}`);
|
||||
lues.push(...decoupe.lignes);
|
||||
}
|
||||
assert.deepEqual(lues, vue.lignes, `${n}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('à cent propositions ou moins, la page unique rend la vue telle quelle ; à 201, la page 3 ne porte que le rang 201', () => {
|
||||
const cent = vueDe(100, 0);
|
||||
assert.deepEqual(pagine(cent, 1).lignes, cent.lignes);
|
||||
const troisPages = pagine(vueDe(201), 3);
|
||||
assert.deepEqual(troisPages.lignes.map(({ rang }) => rang), [201]);
|
||||
assert.deepEqual([troisPages.premier, troisPages.dernier, troisPages.total], [201, 201, 201]);
|
||||
});
|
||||
|
||||
test('une ligne de la page 2 garde son rang global : 101 à 200, jamais 1', () => {
|
||||
const decoupe = pagine(vueDe(237), 2);
|
||||
assert.deepEqual(decoupe.lignes.map(({ rang }) => rang), Array.from({ length: 100 }, (_, place) => 101 + place));
|
||||
assert.deepEqual([decoupe.premier, decoupe.dernier, decoupe.total], [101, 200, 237]);
|
||||
});
|
||||
|
||||
test('une page hors des pages existantes se borne : 0 et moins à la première, au-delà à la dernière', () => {
|
||||
const vue = vueDe(237);
|
||||
assert.equal(pagine(vue, 0).page, 1);
|
||||
assert.equal(pagine(vue, -4).page, 1);
|
||||
assert.equal(pagine(vue, 4).page, 3);
|
||||
assert.equal(pagine(vue, 99).page, 3);
|
||||
assert.equal(pagine(vueDe(0), 2).page, 1);
|
||||
});
|
||||
|
||||
test('les propositions hors classement suivent les classées dans le compte : 150 classées et 60 hors, la page 2 porte 50 et 50, la page 3 les 10 dernières hors', () => {
|
||||
const vue = vueDe(150, 60);
|
||||
const deux = pagine(vue, 2);
|
||||
assert.deepEqual(deux.lignes.map(({ rang }) => rang), Array.from({ length: 50 }, (_, place) => 101 + place));
|
||||
assert.deepEqual(deux.horsClassement.map(({ id }) => id), Array.from({ length: 50 }, (_, place) => place + 1));
|
||||
assert.deepEqual([deux.premier, deux.dernier, deux.total, deux.pages], [101, 200, 210, 3]);
|
||||
const trois = pagine(vue, 3);
|
||||
assert.deepEqual(trois.lignes, []);
|
||||
assert.deepEqual(trois.horsClassement.map(({ id }) => id), [51, 52, 53, 54, 55, 56, 57, 58, 59, 60]);
|
||||
assert.deepEqual(pagine(vue, 1).horsClassement, []);
|
||||
});
|
||||
|
||||
test('la grande et 237 propositions : la page découpe le classement de evaluer, sans tri local — la page 2 commence au 101e de l’ordre du moteur', () => {
|
||||
const charge = generee(chargeDemo('grande'), { graine: 7, nombre: 237, arret: 50, historique: 1000 });
|
||||
const table = evaluer(charge);
|
||||
const vue = tableau(table);
|
||||
const ordre = parLeMoteur(charge).classement.ordre;
|
||||
assert.equal(ordre.length, 237);
|
||||
const pages = [1, 2, 3].map((page) => pagine(vue, page));
|
||||
assert.deepEqual(pages.flatMap(({ lignes }) => lignes.map(({ id }) => id)), ordre);
|
||||
assert.deepEqual(pages[1].lignes.map(({ id }) => id), ordre.slice(100, 200));
|
||||
assert.deepEqual(pages.map(({ lignes }) => lignes.length), [100, 100, 37]);
|
||||
assert.notDeepEqual(
|
||||
pages[1].lignes.map(({ id }) => id),
|
||||
[...pages[1].lignes.map(({ id }) => id)].sort((a, b) => a - b),
|
||||
'l’ordre du classement n’est pas celui des identifiants',
|
||||
);
|
||||
});
|
||||
|
||||
test('pageDe rend la page d’une proposition, classée ou hors classement ; null pour une proposition absente', () => {
|
||||
const vue = vueDe(150, 60);
|
||||
assert.equal(pageDe(vue, 1001), 1);
|
||||
assert.equal(pageDe(vue, 1100), 1);
|
||||
assert.equal(pageDe(vue, 1101), 2);
|
||||
assert.equal(pageDe(vue, 1150), 2);
|
||||
assert.equal(pageDe(vue, 50), 2);
|
||||
assert.equal(pageDe(vue, 51), 3);
|
||||
assert.equal(pageDe(vue, 999), null);
|
||||
assert.equal(pageDe(vue, null), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pagination : la page suit l’état de navigation, jamais le fichier (§ 8.4)', () => {
|
||||
test('sans page retenue, la première ; la page choisie se garde tant que rien ne change', () => {
|
||||
const vue = vueDe(237);
|
||||
const depart = suivrePage(null, observee(vue));
|
||||
assert.equal(depart.page, 1);
|
||||
const choisie = changerDePage(depart, 3);
|
||||
assert.equal(choisie.page, 3);
|
||||
assert.equal(suivrePage(choisie, observee(vue)).page, 3);
|
||||
});
|
||||
|
||||
test('changerDePage se borne aux pages existantes', () => {
|
||||
const suivi = suivrePage(null, observee(vueDe(237)));
|
||||
assert.equal(changerDePage(suivi, 0).page, 1);
|
||||
assert.equal(changerDePage(suivi, 7).page, 3);
|
||||
assert.equal(changerDePage(suivi, 2).page, 2);
|
||||
});
|
||||
|
||||
test('rien de changé — mêmes propositions, même visée — : le suivi retenu lui-même, que l’état de navigation ne republie pas', () => {
|
||||
const vue = vueDe(237);
|
||||
const choisie = changerDePage(suivrePage(null, observee(vue, { vise: 1150, visee: 4 })), 3);
|
||||
assert.equal(suivrePage(choisie, observee(vue, { vise: 1150, visee: 4 })), choisie);
|
||||
assert.ok(Object.isFrozen(choisie));
|
||||
assert.notEqual(suivrePage(choisie, observee(vueDe(150), { vise: 1150, visee: 4 })), choisie);
|
||||
});
|
||||
|
||||
test('moins de propositions, sans aucune nouvelle — un effacement, un retour : la page se borne à la dernière', () => {
|
||||
const choisie = changerDePage(suivrePage(null, observee(vueDe(237))), 3);
|
||||
const apres = suivrePage(choisie, observee(vueDe(150)));
|
||||
assert.equal(apres.page, 2);
|
||||
assert.equal(suivrePage(choisie, observee(vueDe(1))).page, 1);
|
||||
assert.equal(suivrePage(choisie, observee(vueDe(201))).page, 3);
|
||||
});
|
||||
|
||||
test('une proposition nouvelle — une génération — ramène à la première page, même quand le compte baisse', () => {
|
||||
const choisie = changerDePage(suivrePage(null, observee(vueDe(237))), 2);
|
||||
assert.equal(suivrePage(choisie, observee(vueDe(300))).page, 1);
|
||||
const moins = suivrePage(choisie, observee(vueDe(150)));
|
||||
assert.equal(moins.page, 2);
|
||||
// Un retour qui retire des propositions, puis une génération qui réattribue
|
||||
// leurs identifiants sous d'autres graines : des identifiants déjà vus,
|
||||
// des propositions nouvelles, donc la première.
|
||||
assert.equal(suivrePage(choisie, observee(vueDe(150), { graine: 5000 })).page, 1);
|
||||
assert.equal(suivrePage(choisie, observee(vueDe(150), { arret: 41 })).page, 1);
|
||||
assert.equal(suivrePage(choisie, observee(vueDe(150), { historique: 1001 })).page, 1);
|
||||
});
|
||||
|
||||
test('des clés qui changent sans changer le compte de pages sont retenues : le retour des propositions retirées ramène à la première page', () => {
|
||||
const choisie = changerDePage(suivrePage(null, observee(vueDe(250))), 2);
|
||||
const apres = suivrePage(choisie, observee(vueDe(220)));
|
||||
assert.deepEqual([apres.page, apres.cles.length], [2, 220]);
|
||||
assert.equal(suivrePage(apres, observee(vueDe(250))).page, 1);
|
||||
});
|
||||
|
||||
test('une proposition visée depuis ailleurs, sur une autre page, amène sa page ; même numéro de visée, la page choisie reste', () => {
|
||||
const vue = vueDe(237);
|
||||
const choisie = changerDePage(suivrePage(null, observee(vue)), 3);
|
||||
const visee = suivrePage(choisie, observee(vue, { vise: 1150, visee: 1 }));
|
||||
assert.equal(visee.page, 2);
|
||||
const ailleurs = changerDePage(visee, 3);
|
||||
assert.equal(suivrePage(ailleurs, observee(vue, { vise: 1150, visee: 1 })).page, 3);
|
||||
assert.equal(suivrePage(ailleurs, observee(vue, { vise: null, visee: 1 })).page, 3);
|
||||
assert.equal(suivrePage(ailleurs, observee(vue, { vise: 999, visee: 2 })).page, 3, 'une proposition absente n’amène rien');
|
||||
assert.equal(suivrePage(null, observee(vue, { vise: 1201, visee: 1 })).page, 3);
|
||||
});
|
||||
|
||||
test('la même proposition visée de nouveau — un autre numéro de visée — amène encore sa page ; une visée retirée n’amène rien', () => {
|
||||
const vue = vueDe(237);
|
||||
const ailleurs = changerDePage(suivrePage(null, observee(vue, { vise: 1150, visee: 1 })), 3);
|
||||
assert.equal(ailleurs.page, 3);
|
||||
assert.equal(suivrePage(ailleurs, observee(vue, { vise: 1150, visee: 2 })).page, 2);
|
||||
assert.equal(suivrePage(ailleurs, observee(vue, { vise: 1150, visee: 7 })).page, 2);
|
||||
assert.equal(suivrePage(ailleurs, observee(vue, { vise: null, visee: 2 })).page, 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pagination : le texte de l’étendue (libelles/propositions.js)', () => {
|
||||
test('« Propositions 101 à 200 sur 237 », au singulier pour une page d’une seule proposition ; des chiffres déjà mis en forme', () => {
|
||||
const etendue = (premier, dernier, total) =>
|
||||
t('propositions.pages.etendue', {
|
||||
premier: format.nombre(premier),
|
||||
dernier: format.nombre(dernier),
|
||||
total: format.nombre(total),
|
||||
nombre: dernier - premier + 1,
|
||||
});
|
||||
assert.equal(etendue(101, 200, 237), 'Propositions 101 à 200 sur 237');
|
||||
assert.equal(etendue(201, 201, 201), 'Proposition 201 sur 201');
|
||||
assert.equal(etendue(1, 100, 1500), `Propositions 1 à 100 sur ${format.nombre(1500)}`);
|
||||
assert.equal(t('propositions.pages.precedentes'), 'Précédentes');
|
||||
assert.equal(t('propositions.pages.suivantes'), 'Suivantes');
|
||||
assert.notEqual(t('propositions.pages.haut'), t('propositions.pages.bas'));
|
||||
});
|
||||
|
||||
test('texteEtendue met en forme les nombres de la page par format.nombre : la page 11 de 1 500, « Propositions 1 001 à 1 100 sur 1 500 »', () => {
|
||||
const fine = '\u{202F}';
|
||||
assert.equal(texteEtendue(pagine(vueDe(1500), 11), { t, format }), `Propositions 1${fine}001 à 1${fine}100 sur 1${fine}500`);
|
||||
assert.equal(texteEtendue(pagine(vueDe(1101), 12), { t, format }), `Proposition 1${fine}101 sur 1${fine}101`);
|
||||
assert.equal(texteEtendue(pagine(vueDe(237), 2), { t, format }), 'Propositions 101 à 200 sur 237');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ const ENTREES = [
|
|||
['data-estimation', 'génération : l’estimation de la durée', ['']],
|
||||
['data-etat', 'événement : un état du plan dans la tête', null],
|
||||
['data-etat-table', 'tables : l’état d’une table', ['']],
|
||||
['data-etendue', 'propositions : l’étendue d’une page du classement', ['']],
|
||||
['data-evenement', 'accueil : une ligne de la liste, par sa base', null],
|
||||
['data-evidence', 'plan : la personne mise en évidence', null],
|
||||
['data-fantome', 'plan : le fantôme d’une personne glissée', ['']],
|
||||
|
|
@ -122,6 +123,7 @@ const ENTREES = [
|
|||
['data-nombre', 'import : un nombre affiché', ['']],
|
||||
['data-ordre', 'propositions : la phrase de l’ordre', ['']],
|
||||
['data-orthographe', 'import : une orthographe d’un groupe', ['']],
|
||||
['data-pages', 'propositions : une nav des pages du classement, haut ou bas', null],
|
||||
['data-panneau', 'plan : un panneau', ['place', 'table']],
|
||||
['data-panneau-diagnostic', 'plan : le panneau du diagnostic', ['']],
|
||||
['data-papier', 'le papier de la machine : la question, ou le papier choisi', ['choisi', 'question']],
|
||||
|
|
|
|||
Loading…
Reference in a new issue