diff --git a/spec.md b/spec.md index 9201bd5..9f16739 100644 --- a/spec.md +++ b/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). diff --git a/src/interface/etat_ui.js b/src/interface/etat_ui.js index d5d150b..c96d4cb 100644 --- a/src/interface/etat_ui.js +++ b/src/interface/etat_ui.js @@ -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 }) { diff --git a/src/interface/etat_ui.test.js b/src/interface/etat_ui.test.js index ef9f46c..114457d 100644 --- a/src/interface/etat_ui.test.js +++ b/src/interface/etat_ui.test.js @@ -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', () => { diff --git a/src/interface/libelles/propositions.js b/src/interface/libelles/propositions.js index 7f9a4ca..cf3f6ba 100644 --- a/src/interface/libelles/propositions.js +++ b/src/interface/libelles/propositions.js @@ -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'], diff --git a/src/interface/propositions/Propositions.navigateur.test.js b/src/interface/propositions/Propositions.navigateur.test.js index 731f4e7..504babc 100644 --- a/src/interface/propositions/Propositions.navigateur.test.js +++ b/src/interface/propositions/Propositions.navigateur.test.js @@ -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)); + }); +}); diff --git a/src/interface/propositions/SectionPropositions.svelte b/src/interface/propositions/SectionPropositions.svelte index 0b64c0c..4eb6704 100644 --- a/src/interface/propositions/SectionPropositions.svelte +++ b/src/interface/propositions/SectionPropositions.svelte @@ -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); +{#snippet pages(ou)} + +{/snippet} + {#snippet effacerListe()} {/snippet} -
+

{t('coquille.section.propositions')}

{#if charge !== null && charge.propositions.length === 0} @@ -188,7 +265,9 @@

{/each} - + {#if page.paginee}{@render pages('haut')}{/if} + + {#if page.paginee}{@render pages('bas')}{/if} {:else if charge !== null && charge.propositions.length > 0}
{@render effacerListe()}
@@ -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; diff --git a/src/interface/propositions/TableauPropositions.svelte b/src/interface/propositions/TableauPropositions.svelte index ce9a0c8..3f6183e 100644 --- a/src/interface/propositions/TableauPropositions.svelte +++ b/src/interface/propositions/TableauPropositions.svelte @@ -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) diff --git a/src/interface/propositions/modele.js b/src/interface/propositions/modele.js index df824e4..b063891 100644 --- a/src/interface/propositions/modele.js +++ b/src/interface/propositions/modele.js @@ -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, 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, + }); +} diff --git a/src/interface/propositions/modele.test.js b/src/interface/propositions/modele.test.js index f89aaeb..7155898 100644 --- a/src/interface/propositions/modele.test.js +++ b/src/interface/propositions/modele.test.js @@ -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'); + }); +}); diff --git a/test/navigateur/designations.js b/test/navigateur/designations.js index d143c22..f14214c 100644 --- a/test/navigateur/designations.js +++ b/test/navigateur/designations.js @@ -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']],