From 94ea937ae4c74afd9ed924cafe7ce0df5b38d106 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 8 Oct 2026 17:07:31 -0400 Subject: [PATCH] [IMP] home: list shown at once, computed columns loaded per event MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The event list waited until every event's ranking was evaluated, about 10 ms per event with propositions, before showing anything. It now shows as soon as the folder is read; each line's retained-plan columns and best proposition load one event at a time, yielding to the browser between events, behind a rotating SVG spinner, and show as each result arrives. Work for a list no longer shown is dropped. The spinner is the one named exception to the no-animation rule: still under reduced motion, cancelled for captures, announced once. Checked: 2935 node, 616 browser, 62 long. --- FR --- [IMP] accueil : liste aussitôt montrée, colonnes calculées par événement La liste des événements attendait l'évaluation du classement de chaque événement, environ 10 ms par événement qui a des propositions, avant de rien montrer. Elle paraît dès la lecture du dossier ; les colonnes du retenu et la meilleure proposition de chaque ligne se chargent un événement à la fois, le navigateur reprenant la main entre deux, derrière une icône SVG qui tourne. Le travail d'une liste qui n'est plus montrée est abandonné. L'icône est la seule exception nommée à l'interdit d'animation. Vérifié : 2935 node, 616 navigateur, 62 longues. Assisted-by: Claude Opus 5.5 --- spec.md | 27 ++- src/application/evenements.js | 118 +++++++--- src/application/evenements.test.js | 218 ++++++++++++++++-- src/interface/App.navigateur.test.js | 147 +++++++++++- src/interface/App.svelte | 37 ++- .../accueil/Accueil.navigateur.test.js | 195 +++++++++++++++- src/interface/accueil/Accueil.svelte | 9 +- src/interface/accueil/Chargement.svelte | 49 ++++ src/interface/accueil/ListeEvenements.svelte | 38 ++- src/interface/accueil/chargement.js | 34 +++ src/interface/accueil/chargement.test.js | 87 +++++++ src/interface/accueil/modele.js | 46 ++-- src/interface/accueil/modele.test.js | 42 +++- src/interface/libelles/accueil.js | 4 + src/interface/marques.js | 3 + src/interface/marques.test.js | 4 +- test/navigateur/ecrans.js | 70 +++++- test/sources_interface.test.js | 104 ++++++++- 18 files changed, 1116 insertions(+), 116 deletions(-) create mode 100644 src/interface/accueil/Chargement.svelte create mode 100644 src/interface/accueil/chargement.js create mode 100644 src/interface/accueil/chargement.test.js diff --git a/spec.md b/spec.md index bd08d2c..3fdcb4b 100644 --- a/spec.md +++ b/spec.md @@ -1209,16 +1209,23 @@ d'appartenance », |cible − Σ r(p)|, où Σ r(p) est la somme de redondance d retenu — le nombre de la colonne « Redondance d'appartenance, somme Σ r(p) » du tableau des propositions — et la cible celle de l'événement (§ 8.9), 1000 par défaut, réglable dans la section des propositions. Les deux se lisent dans la -même mesure que le tableau, au moment de lister, sans rien écrire ; un événement -sans retenu, ou dont le retenu est fautif ou en dérive (§ 8.9, § 9), y porte -« — ». Chaque ligne porte aussi la **« Meilleure proposition du classement »**, -sous les libellés du bloc que la section de la génération montre en tête : -l'identifiant de la proposition au rang 1 du classement (§ 5.7) et ses +même mesure que le tableau, sans rien écrire ; un événement sans retenu, ou dont +le retenu est fautif ou en dérive (§ 8.9, § 9), y porte « — ». Chaque ligne +porte aussi la **« Meilleure proposition du classement »**, sous les libellés +du bloc que la section de la génération montre en tête : l'identifiant de la +proposition au rang 1 du classement (§ 5.7) et ses collisions cumulées — la même proposition et le même nombre que ce bloc et que la première ligne du tableau des propositions, retenue ou non ; une proposition fautive, que l'ouverture écarte (§ 8.9, point 3), n'y entre pas. Sans proposition classée — aucune, ou aucune qui décrive encore la configuration —, l'un et l'autre valent « — ». Chaque intitulé s'explique dans une infobulle. +**La liste paraît avant ses colonnes calculées** : ses lignes s'affichent dès le +relevé du dossier, et les mesures du retenu et la meilleure proposition, qui +évaluent chaque événement, se calculent ensuite, un événement après l'autre, la +main rendue au navigateur entre deux. Tant que celles d'une ligne se calculent, +chacune de ses valeurs porte un signe de chargement qui tourne ; chaque ligne +montre les siennes dès qu'elles sont obtenues, et une liste relue abandonne les +calculs de la précédente. **Un répertoire vide n'affiche pas une liste vide** : il présente la commande de création et les démonstrations — une liste vide sans issue est le premier écran que voit un opérateur qui lance l'exécutable. @@ -4656,9 +4663,13 @@ ne les offre de façon portable : n'attrape pas : une promesse rejetée sans gestionnaire. - **L'émulation d'une préférence système.** Le logiciel règle son **propre** réglage de thème, qui est celui que l'opérateur manipule (§ 13.3), et injecte une - feuille de style qui annule transitions et animations. La préférence système - est consultée à chaque lancement, et le réglage de l'opérateur vaut pour la - séance : rien ne l'enregistre, ni fichier ni stockage du navigateur (§ 8.6). + feuille de style qui annule transitions et animations. L'interface n'en porte + qu'une : la rotation du signe de chargement de la liste des événements + (§ 8.1), qu'aucun état n'attend et dont rien ne lit le rendu ; elle s'arrête + sous la préférence de mouvement réduit, et cette feuille l'annule comme toute + autre. La préférence système est consultée à chaque lancement, et le réglage + de l'opérateur vaut pour la séance : rien ne l'enregistre, ni fichier ni + stockage du navigateur (§ 8.6). **Le scénario pose donc le thème par le réglage, à chaque exécution, et ne le déduit jamais de la machine.** diff --git a/src/application/evenements.js b/src/application/evenements.js index af53b50..25e6474 100644 --- a/src/application/evenements.js +++ b/src/application/evenements.js @@ -14,13 +14,17 @@ // système de fichiers se lit sur fs à l'appel, jamais à la création : une // épreuve la remplace après le montage. // -// Lister ne fait que lire. Chaque ligne lisible porte le résumé de son -// retenu (resumeDuRetenu, evaluation.js) et la meilleure proposition de son -// classement (resumeDuMeilleur), lus dans une seule lecture de l'état de -// l'événement au moment de lister, sur sa charge examinée comme l'ouverture -// l'examine. Une opération qui écrit dérive ses refus lisibles sans écrire -// — nom vide, plan bloqué, format plus récent — avant toute écriture, sonde -// de verrou comprise. Renommer et supprimer ferment d'abord +// Lister ne fait que lire, et n'évalue rien : la liste paraît sans attendre +// aucune évaluation. Les deux résumés d'une +// ligne — celui de son retenu (resumeDuRetenu, evaluation.js) et la +// meilleure proposition de son classement (resumeDuMeilleur) — se calculent +// ensuite, un événement après l'autre, par resumer : chacun relit l'état de +// son événement une fois, examine sa charge comme l'ouverture l'examine, et +// rend la main entre deux événements. +// +// Une opération qui écrit dérive ses refus lisibles sans écrire — nom vide, +// plan bloqué, format plus récent — avant toute écriture, sonde de verrou +// comprise. Renommer et supprimer ferment d'abord // l'événement visé quand il est ouvert : la séance rend son verrou, et la // façade sonde alors celui du disque sous son propre identifiant de séance, // tiré une fois à sa création ; une séance vivante, ou dont la vie ne se @@ -133,30 +137,28 @@ async function derniereModification(fs, racine, base) { return lireJournal(texte).entrees.at(-1)?.horodatage ?? null; } -// Les deux résumés de l'événement base, lus dans une seule lecture de son -// état, sans rien écrire : mesuresDuRetenu (resumeDuRetenu) et -// meilleureDuClassement (resumeDuMeilleur). Ils lisent la charge examinée -// (examiner, § 8.9, point 3), celle que l'ouverture de l'état garde : une -// proposition fautive, dont l'identifiant se répète ou atteint le compteur, -// n'entre pas au classement, qui lève sur un identifiant répété, et la -// liste nomme la proposition au rang 1 que le bloc de génération de -// l'événement ouvert nomme. Chacun vaut null pour un état -// illisible au moment de la liste — un résumé illisible ne se relit pas —, -// et pour un état qui a cessé de se lire depuis : la ligne en porte déjà la -// cause, ou la portera à la liste suivante. +// Les deux résumés d'un événement : mesuresDuRetenu (resumeDuRetenu) et +// meilleureDuClassement (resumeDuMeilleur). AUCUN_RESUME est celui d'un état +// qui ne se lit pas — illisible au relevé du dépôt, ou depuis : la ligne en +// porte déjà la cause, ou la portera à la liste suivante —, et celui que la +// liste montre pour un événement dont le calcul a fauté. const AUCUN_RESUME = Object.freeze({ mesuresDuRetenu: null, meilleureDuClassement: null }); -async function resumesDe(fs, racine, { base, illisible }) { - if (illisible !== null) return AUCUN_RESUME; - let charge; + +// La charge examinée de l'état base, lue sans rien écrire (examiner, § 8.9, +// point 3) : celle que l'ouverture garde, où une proposition fautive, dont +// l'identifiant se répète ou atteint le compteur, n'entre pas au classement — +// qui lève sur un identifiant répété —, si bien que la liste nomme la +// proposition au rang 1 que le bloc de génération de l'événement ouvert +// nomme. null pour un état absent ou qui ne se lit pas. +async function chargeExaminee(fs, racine, base) { try { const texte = await fs.lireTexte(racine, base + SUFFIXES.etat); - if (texte === null) return AUCUN_RESUME; - charge = examiner(analyser(texte).charge).charge; + if (texte === null) return null; + return examiner(analyser(texte).charge).charge; } catch (erreur) { - if (erreur instanceof ErreurStockage) return AUCUN_RESUME; + if (erreur instanceof ErreurStockage) return null; throw erreur; } - return { mesuresDuRetenu: resumeDuRetenu(charge), meilleureDuClassement: resumeDuMeilleur(charge) }; } // Pourquoi l'état d'un résumé illisible ne s'ouvre pas : les détails de @@ -316,12 +318,50 @@ export function creerFacade({ ? resumes.filter((autre) => autre !== resume && autre.doublon && autre.id === id).map((autre) => autre.base) : [], verrou: await verrouDe(fs, courante, base), - ...(await resumesDe(fs, courante, resume)), }); } return lignes; } + // Les résumés des lignes, dans le dossier de travail de l'appel : ceux des + // états illisibles aussitôt, puis un événement lisible par pas, dans + // l'ordre des lignes. Chaque pas attend ceder(), lit l'état, puis calcule ; + // abandonné, le flux ne lit ni ne calcule plus rien, et le calcul en cours + // ne se reçoit pas. Une faute d'un événement lui donne AUCUN_RESUME, le + // flux continue, et fin la rejette une fois le flux arrêté. + function resumer(lignes, { ceder, recevoir }) { + const courante = racine(); + if (courante === null) throw new RangeError("resumer : aucun dossier de travail, demarrer n'a pas abouti"); + let abandonne = false; + for (const { base, illisible } of lignes) if (illisible !== null) recevoir(base, AUCUN_RESUME); + const lisibles = lignes.filter(({ illisible }) => illisible === null).map(({ base }) => base); + async function parcourir() { + let faute = null; + for (const base of lisibles) { + await ceder(); + if (abandonne) break; + let resume = AUCUN_RESUME; + try { + const charge = await chargeExaminee(fs, courante, base); + if (!abandonne && charge !== null) { + resume = { mesuresDuRetenu: resumeDuRetenu(charge), meilleureDuClassement: resumeDuMeilleur(charge) }; + } + } catch (erreur) { + faute ??= erreur; + } + if (abandonne) break; + recevoir(base, resume); + } + if (faute !== null) throw faute; + } + return { + abandonner() { + abandonne = true; + }, + fin: parcourir(), + }; + } + // Le verrou de l'événement cible pris sous la séance de la façade. Le sien, // resté d'un rendu refusé, est déjà pris ; celui d'une séance morte // (vivant faux) s'efface en son nom, puis se prend ; tout autre lève @@ -496,13 +536,29 @@ export function creerFacade({ */ repondre, /** - * Les lignes de la liste (§ 8.1), dans l'ordre du dépôt ; n'écrit rien. - * mesuresDuRetenu porte resumeDuRetenu de l'état, null sans retenu - * mesurable ou pour un état illisible ; meilleureDuClassement, - * resumeDuMeilleur de l'état, null sans proposition classée ou pour un - * état illisible. + * Les lignes de la liste (§ 8.1), dans l'ordre du dépôt, telles que le + * relevé du dépôt les donne ; n'écrit rien, et n'évalue rien : leurs + * résumés viennent de resumer. */ lister, + /** + * Les résumés des lignes de lister (§ 8.1), un événement après l'autre, + * sans rien écrire : recevoir(base, { mesuresDuRetenu, + * meilleureDuClassement }) une fois par ligne — resumeDuRetenu et + * resumeDuMeilleur de sa charge examinée, chacun null sans retenu + * mesurable ou sans proposition classée, l'un et l'autre null pour un + * état illisible. Une ligne illisible reçoit le sien avant le retour ; + * chaque ligne lisible attend la promesse de ceder(), puis relit son + * état et se calcule. abandonner() arrête le flux : plus rien ne se lit, + * ne se calcule ni ne se reçoit. fin se résout quand le flux s'arrête, et + * rejette par la première faute d'un calcul, dont la ligne a reçu les + * deux résumés à null. RangeError sans dossier de travail. + * + * @param {Array<{base: string, illisible: object|null}>} lignes + * @param {{ceder: () => Promise, recevoir: (base: string, resume: Object) => void}} rappels + * @returns {{abandonner: () => void, fin: Promise}} + */ + resumer, /** Crée et ouvre en lecture l'événement { nom, date } (§ 2.1, étape 1). */ creer: creerEvenement, /** Charge la démonstration de clé donnée sous « ‹nom› N », ouverte en lecture (§ 15). */ diff --git a/src/application/evenements.test.js b/src/application/evenements.test.js index 94afa22..9a0ce42 100644 --- a/src/application/evenements.test.js +++ b/src/application/evenements.test.js @@ -398,6 +398,16 @@ describe('les questions du dossier de travail (§ 8.6)', () => { // --- La liste (§ 8.1, § 19.4) ----------------------------------------------- +// Les lignes de la liste, chacune complétée des deux résumés que resumer +// rend pour elle, le flux mené à son terme sans attendre d'horloge. +async function listerResume(facade) { + const lignes = await facade.lister(); + const resumes = new Map(); + await facade.resumer(lignes, { ceder: async () => undefined, recevoir: (base, resume) => resumes.set(base, resume) }).fin; + assert.equal(resumes.size, lignes.length); + return lignes.map((ligne) => ({ ...ligne, ...resumes.get(ligne.base) })); +} + describe('lister (§ 8.1, § 19.4)', () => { // Publié en E:\soirees, data/ inscriptible : la petite démonstration // déposée sans journal, et un événement renommé à la main. @@ -432,8 +442,6 @@ describe('lister (§ 8.1, § 19.4)', () => { journalSeul: false, doublonAvec: [], verrou: null, - mesuresDuRetenu: null, - meilleureDuClassement: null, }, { base: 'petite', @@ -448,8 +456,6 @@ describe('lister (§ 8.1, § 19.4)', () => { journalSeul: false, doublonAvec: [], verrou: null, - mesuresDuRetenu: null, - meilleureDuClassement: null, }, ]); assert.ok(petite.participants.length > 0); @@ -555,7 +561,7 @@ describe('lister (§ 8.1, § 19.4)', () => { const { fs, facade } = await deuxEvenements(); fs.deposer('portable', `retenue${SUFFIXES.etat}`, texteRetenu()); const avant = ecritures(fs); - const lignes = await facade.lister(); + const lignes = await listerResume(facade); assert.equal(ecritures(fs), avant); const par = Object.fromEntries(lignes.map((ligne) => [ligne.base, ligne])); const charge = analyser(texteRetenu()).charge; @@ -575,7 +581,7 @@ describe('lister (§ 8.1, § 19.4)', () => { const { fs, facade } = await deuxEvenements(); const texte = texteRetenu((charge) => appliquerCommande('changerCibleRedondance', charge, { cible: '25' }).charge); fs.deposer('portable', `retenue${SUFFIXES.etat}`, texte); - const ligne = (await facade.lister()).find(({ base }) => base === 'retenue'); + const ligne = (await listerResume(facade)).find(({ base }) => base === 'retenue'); const { totalRedondance } = evaluer(analyser(texte).charge).retenu.mesures; assert.equal(ligne.mesuresDuRetenu.cibleRedondance, 25); assert.equal(ligne.mesuresDuRetenu.diffRedondance, Math.abs(25 - totalRedondance)); @@ -594,7 +600,7 @@ describe('lister (§ 8.1, § 19.4)', () => { fs.deposer('portable', `derivee${SUFFIXES.etat}`, texteRetenu((charge) => appliquerCommande('changerTours', charge, { tours: '3' }).charge)); fs.deposer('portable', `tronquee${SUFFIXES.etat}`, texteRetenu().slice(0, 40)); const avant = ecritures(fs); - const lignes = await facade.lister(); + const lignes = await listerResume(facade); assert.equal(ecritures(fs), avant); assert.equal(lignes.length, 5); const par = Object.fromEntries(lignes.map((ligne) => [ligne.base, ligne])); @@ -617,7 +623,7 @@ describe('lister (§ 8.1, § 19.4)', () => { fs.deposer('portable', `retenue${SUFFIXES.etat}`, texteRetenu()); fs.deposer('portable', `proposee${SUFFIXES.etat}`, sansRetenu); const avant = ecritures(fs); - const lignes = await facade.lister(); + const lignes = await listerResume(facade); assert.equal(ecritures(fs), avant); assert.equal(lignes.length, 4); const par = Object.fromEntries(lignes.map((ligne) => [ligne.base, ligne])); @@ -636,7 +642,7 @@ describe('lister (§ 8.1, § 19.4)', () => { assert.equal(par.petite.meilleureDuClassement, null); }); - test('le résumé du retenu et la meilleure proposition se lisent dans une seule lecture de l’état, après celle du dépôt', async () => { + test('lister lit chaque état une fois, celle du dépôt ; resumer le relit une fois de plus pour ses deux résumés', async () => { const { fs, facade } = await deuxEvenements(); fs.deposer('portable', `retenue${SUFFIXES.etat}`, texteRetenu()); const lireTexte = fs.lireTexte; @@ -645,10 +651,14 @@ describe('lister (§ 8.1, § 19.4)', () => { lus.push(chemin); return lireTexte.call(fs, racine, chemin); }; - const ligne = (await facade.lister()).find(({ base }) => base === 'retenue'); + const lues = await facade.lister(); + assert.equal(lues.length, 3); + const etats = () => lus.filter((chemin) => chemin === `retenue${SUFFIXES.etat}`).length; + assert.equal(etats(), 1, lus.join()); + const ligne = (await listerResume(facade)).find(({ base }) => base === 'retenue'); assert.notEqual(ligne.mesuresDuRetenu, null); assert.notEqual(ligne.meilleureDuClassement, null); - assert.equal(lus.filter((chemin) => chemin === `retenue${SUFFIXES.etat}`).length, 2, lus.join()); + assert.equal(etats(), 3, lus.join()); }); // Un état que l'analyse admet et qu'examiner corrige (§ 8.9, point 3) : @@ -665,7 +675,7 @@ describe('lister (§ 8.1, § 19.4)', () => { }); assert.equal(examiner(analyser(texte).charge).fautives[0].fautes[0].code, 'IDENTIFIANT_REPETE'); fs.deposer('portable', `repetee${SUFFIXES.etat}`, texte); - const ligne = (await facade.lister()).find(({ base }) => base === 'repetee'); + const ligne = (await listerResume(facade)).find(({ base }) => base === 'repetee'); assert.notEqual(ligne, undefined); assert.notEqual(meilleureOuverte(texte), null); assert.deepEqual(ligne.meilleureDuClassement, meilleureOuverte(texte)); @@ -680,7 +690,7 @@ describe('lister (§ 8.1, § 19.4)', () => { assert.equal(examiner(analyser(texte).charge).fautives[0].fautes[0].code, 'IDENTIFIANT_HORS_COMPTEUR'); assert.notDeepEqual(meilleureOuverte(texte), resumeDuMeilleur(analyser(texte).charge)); fs.deposer('portable', `abaissee${SUFFIXES.etat}`, texte); - const ligne = (await facade.lister()).find(({ base }) => base === 'abaissee'); + const ligne = (await listerResume(facade)).find(({ base }) => base === 'abaissee'); assert.deepEqual(ligne.meilleureDuClassement, meilleureOuverte(texte)); }); @@ -690,13 +700,193 @@ describe('lister (§ 8.1, § 19.4)', () => { assert.ok(evaluer(analyser(derivee).charge).propositions.every(({ statut }) => statut === 'derive')); fs.deposer('portable', `derivee${SUFFIXES.etat}`, derivee); fs.deposer('portable', `tronquee${SUFFIXES.etat}`, texteRetenu().slice(0, 40)); - const lignes = await facade.lister(); + const lignes = await listerResume(facade); assert.equal(lignes.length, 4); const par = Object.fromEntries(lignes.map((ligne) => [ligne.base, ligne])); assert.equal(par.derivee.meilleureDuClassement, null); assert.notEqual(par.tronquee.illisible, null); assert.equal(par.tronquee.meilleureDuClassement, null); }); + + // --- resumer : les résumés, un événement après l'autre --------------------- + + // Un pas que tient l'horloge pilotée : sa promesse se résout à la prochaine + // avance de l'horloge, comme le pas de l'interface à sa minuterie. + const pasDe = (horloge) => () => + new Promise((reprendre) => { + horloge.planifier(reprendre, 0); + }); + // setImmediate rappelle une fois vidées les microtâches, où s'enchaînent + // les lectures du système simulé et le calcul qu'un pas libère. + const laisser = () => new Promise((resolve) => setImmediate(resolve)); + async function unPas(horloge) { + horloge.avancer(0); + await laisser(); + } + const AUCUN = { mesuresDuRetenu: null, meilleureDuClassement: null }; + // Les deux résumés d'un texte d'état, sur sa charge examinée, celle que + // l'ouverture lit. + function resumesAttendus(texte) { + const charge = examiner(analyser(texte).charge).charge; + return { mesuresDuRetenu: resumeDuRetenu(charge), meilleureDuClassement: resumeDuMeilleur(charge) }; + } + + // Le dossier de deuxEvenements, plus un retenu, des propositions sans + // retenu et un état tronqué. + async function dossierAResumer() { + const montage = await deuxEvenements(); + const sansRetenu = texteRetenu((charge) => ({ + ...charge, + retenu: null, + evenement: { ...charge.evenement, id: 'evt-proposee', etat: 'propose' }, + })); + montage.fs.deposer('portable', `retenue${SUFFIXES.etat}`, texteRetenu()); + montage.fs.deposer('portable', `proposee${SUFFIXES.etat}`, sansRetenu); + montage.fs.deposer('portable', `tronquee${SUFFIXES.etat}`, texteRetenu().slice(0, 40)); + return { ...montage, textes: { retenue: texteRetenu(), proposee: sansRetenu } }; + } + + test('lister rend ses lignes sans résumé ; resumer rend l’état illisible aussitôt, puis un événement par pas, dans l’ordre de la liste, rien d’écrit', async () => { + const { fs, horloge, facade, textes } = await dossierAResumer(); + const avant = ecritures(fs); + const lignes = await facade.lister(); + assert.equal(lignes.length, 5); + for (const ligne of lignes) { + assert.equal('mesuresDuRetenu' in ligne, false, ligne.base); + assert.equal('meilleureDuClassement' in ligne, false, ligne.base); + } + const recus = []; + const flux = facade.resumer(lignes, { ceder: pasDe(horloge), recevoir: (base, resume) => recus.push([base, resume]) }); + // Un état illisible n'a rien à résumer : son résumé vide vient avant + // tout pas. + assert.deepEqual(recus, [['tronquee', AUCUN]]); + await laisser(); + assert.equal(recus.length, 1); + const lisibles = lignes.filter(({ illisible }) => illisible === null).map(({ base }) => base); + assert.equal(lisibles.length, 4); + for (let pas = 1; pas <= lisibles.length; pas += 1) { + await unPas(horloge); + assert.deepEqual( + recus.slice(1).map(([base]) => base), + lisibles.slice(0, pas), + ); + } + await flux.fin; + const par = new Map(recus); + assert.equal(par.size, 5); + for (const base of ['retenue', 'proposee']) assert.deepEqual(par.get(base), resumesAttendus(textes[base]), base); + assert.notEqual(par.get('retenue').mesuresDuRetenu, null); + assert.notEqual(par.get('proposee').meilleureDuClassement, null); + assert.deepEqual(par.get('petite'), AUCUN); + assert.deepEqual(par.get('soiree-a'), AUCUN); + assert.equal(ecritures(fs), avant); + }); + + test('rien ne s’évalue avant le pas : un état réécrit entre la liste et son pas se résume tel que le pas le lit', async () => { + const { fs, horloge, facade } = await dossierAResumer(); + const lignes = await facade.lister(); + const recus = new Map(); + const flux = facade.resumer(lignes, { ceder: pasDe(horloge), recevoir: (base, resume) => recus.set(base, resume) }); + // La cible de l'événement passe à 25 après la liste : le résumé du pas + // la lit, et non celle que la liste a vue. + const plusTard = texteRetenu((charge) => appliquerCommande('changerCibleRedondance', charge, { cible: '25' }).charge); + fs.deposer('portable', `retenue${SUFFIXES.etat}`, plusTard); + for (let pas = 0; pas < lignes.length; pas += 1) await unPas(horloge); + await flux.fin; + assert.equal(recus.get('retenue').mesuresDuRetenu.cibleRedondance, 25); + assert.deepEqual(recus.get('retenue'), resumesAttendus(plusTard)); + }); + + test('abandonner : le calcul en cours ne se reçoit pas, et aucune lecture ne part ensuite', async () => { + const { fs, horloge, facade } = await dossierAResumer(); + const lignes = await facade.lister(); + const lisibles = lignes.filter(({ illisible }) => illisible === null).map(({ base }) => base); + // La lecture du deuxième état lisible reste suspendue jusqu'à liberer(). + const lireTexte = fs.lireTexte; + let liberer; + const barriere = new Promise((resoudre) => { + liberer = resoudre; + }); + let suspendue = null; + fs.lireTexte = (racine, chemin) => { + if (chemin === lisibles[1] + SUFFIXES.etat && suspendue === null) { + suspendue = barriere.then(() => lireTexte.call(fs, racine, chemin)); + return suspendue; + } + return lireTexte.call(fs, racine, chemin); + }; + const recus = []; + const flux = facade.resumer(lignes, { ceder: pasDe(horloge), recevoir: (base) => recus.push(base) }); + await unPas(horloge); + await unPas(horloge); + assert.notEqual(suspendue, null); + assert.deepEqual(recus, ['tronquee', lisibles[0]]); + flux.abandonner(); + // La lecture suspendue s'achève et compte ; aucune ne part après elle. + liberer(); + await laisser(); + const lectures = fs.compteurs.lectures; + for (let pas = 0; pas < lignes.length; pas += 1) await unPas(horloge); + await flux.fin; + assert.deepEqual(recus, ['tronquee', lisibles[0]]); + assert.equal(fs.compteurs.lectures, lectures); + }); + + test('sans dossier de travail, resumer lève RangeError sans rien recevoir', () => { + const { facade } = monter(); + const recus = []; + assert.throws( + () => facade.resumer([{ base: 'x', illisible: { raison: 'VIDE' } }], { ceder: async () => undefined, recevoir: (base) => recus.push(base) }), + RangeError, + ); + assert.deepEqual(recus, []); + }); + + test('abandonné avant son premier pas, le flux ne lit rien et ne rend que les résumés vides des états illisibles', async () => { + const { fs, horloge, facade } = await dossierAResumer(); + const lignes = await facade.lister(); + const lectures = fs.compteurs.lectures; + const recus = []; + const flux = facade.resumer(lignes, { ceder: pasDe(horloge), recevoir: (base) => recus.push(base) }); + flux.abandonner(); + for (let pas = 0; pas < lignes.length; pas += 1) await unPas(horloge); + await flux.fin; + assert.deepEqual(recus, ['tronquee']); + assert.equal(fs.compteurs.lectures, lectures); + }); + + test('un état qui ne se relit plus rend son résumé vide, les autres le leur ; une faute de même, puis fin la rejette', async () => { + const { fs, horloge, facade, textes } = await dossierAResumer(); + const lignes = await facade.lister(); + // Tronqué après la liste : la relecture du pas le trouve illisible. + fs.deposer('portable', `proposee${SUFFIXES.etat}`, textes.proposee.slice(0, 40)); + const recus = new Map(); + const flux = facade.resumer(lignes, { ceder: pasDe(horloge), recevoir: (base, resume) => recus.set(base, resume) }); + for (let pas = 0; pas < lignes.length; pas += 1) await unPas(horloge); + await flux.fin; + assert.deepEqual(recus.get('proposee'), AUCUN); + assert.deepEqual(recus.get('retenue'), resumesAttendus(textes.retenue)); + + // Une faute — une erreur qui n'est pas un refus du stockage — sur un + // seul état : son résumé vide, les suivants rendus, puis fin la rejette. + fs.deposer('portable', `proposee${SUFFIXES.etat}`, textes.proposee); + const faute = new TypeError('faute d’épreuve'); + const lireTexte = fs.lireTexte; + fs.lireTexte = (racine, chemin) => (chemin === `petite${SUFFIXES.etat}` ? Promise.reject(faute) : lireTexte.call(fs, racine, chemin)); + const apres = new Map(); + const suivant = facade.resumer(lignes, { ceder: pasDe(horloge), recevoir: (base, resume) => apres.set(base, resume) }); + const issue = suivant.fin.then( + () => null, + (erreur) => erreur, + ); + for (let pas = 0; pas < lignes.length; pas += 1) await unPas(horloge); + assert.equal(await issue, faute); + assert.equal(apres.size, 5); + assert.deepEqual(apres.get('petite'), AUCUN); + assert.deepEqual(apres.get('retenue'), resumesAttendus(textes.retenue)); + assert.deepEqual(apres.get('proposee'), resumesAttendus(textes.proposee)); + assert.notEqual(apres.get('proposee').meilleureDuClassement, null); + }); }); // --- Créer (§ 2.1, § 8.3) ---------------------------------------------------- diff --git a/src/interface/App.navigateur.test.js b/src/interface/App.navigateur.test.js index 67dd8bf..6e2aefb 100644 --- a/src/interface/App.navigateur.test.js +++ b/src/interface/App.navigateur.test.js @@ -4,7 +4,8 @@ // La coquille de l'application dans un vrai moteur de rendu : le titre de la // fenêtre et le bandeau (§ 8.4, § 18.6), « Liste des événements » (§ 8.1), // le panneau « À propos », le réglage de thème (§ 19.2), le collecteur -// d'erreurs, l'injection des plateformes (§ 13.4), l'ordre du montage, et la +// d'erreurs, l'injection des plateformes (§ 13.4), l'ordre du montage, la +// liste relue et l'abandon des résumés d'une liste dépassée (§ 8.1), et la // fermeture sans question (§ 8.4, § 8.8). Chaque épreuve monte App par // monterApp (test/navigateur/montage.js), sur le système de fichiers simulé. // @@ -27,10 +28,15 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { cdp } from 'vitest/browser'; import { flushSync } from 'svelte'; +import { appliquerCommande } from '../application/commandes.js'; +import { cibleRedondance, evaluer } from '../application/evaluation.js'; +import { creerFormat } from '../application/format.js'; import { TABLES_APPLICATION } from '../application/libelles.js'; import { creerTraduction } from '../application/traduction.js'; import { LIVREES } from '../demo/livrees.js'; -import { FORMAT } from '../stockage/document.js'; +import { rechercher } from '../moteur/recherche.js'; +import { serialiser } from '../stockage/canonique.js'; +import { FORMAT, analyser, configurationDepuisCharge } from '../stockage/document.js'; import { ErreurStockage } from '../stockage/erreurs.js'; import { VERSION } from '../version.genere.js'; import COLLECTEUR from '../../public/collecteur.js?raw'; @@ -52,6 +58,7 @@ import { SECTION_OUVERTURE } from './evenement/sections.js'; import { cleDeMode } from './mode.js'; const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]); +const format = creerFormat(t); const TRANSPARENT = 'rgba(0, 0, 0, 0)'; const THEMES = ['clair', 'sombre']; @@ -384,6 +391,142 @@ describe('la liste relue', () => { }); }); +describe('les résumés de la liste relue (§ 8.1)', () => { + // La petite démonstration au conflit inévitable, deux propositions du + // moteur, la seconde retenue, sous la cible de redondance donnée ; la + // cible par défaut n'a pas de réglage écrit. + function texteRetenu(cible) { + let charge = analyser(CONFLIT).charge; + const configuration = configurationDepuisCharge(charge); + const propositions = rechercher(configuration, { graine: 6, nombre: 2, arret: 300 }); + charge = appliquerCommande('enregistrerGeneration', charge, { propositions, configuration, produitVersion: VERSION.affichee }).charge; + charge = appliquerCommande('retenirProposition', charge, { id: charge.propositions[1].id }).charge; + if (cible !== cibleRedondance(charge)) { + charge = appliquerCommande('changerCibleRedondance', charge, { cible: String(cible) }).charge; + } + return serialiser(charge, { revision: 1, produitVersion: VERSION.affichee }); + } + // La différence à la cible que la ligne d'un texte d'état écrit. + const difference = (texte) => { + const charge = analyser(texte).charge; + return format.nombre(Math.abs(cibleRedondance(charge) - evaluer(charge).retenu.mesures.totalRedondance)); + }; + const cellule = (m) => dans(m, '[data-evenement="bal"] [data-colonne="diffRedondance"]'); + const uneImage = () => new Promise((resoudre) => requestAnimationFrame(() => resoudre())); + + // Retient la prochaine lecture de l'état de « bal » : elle lit le disque à + // son appel, et ne rend ce qu'elle a lu qu'à liberer(). + function retenirLecture(m) { + const lireTexte = m.fs.lireTexte; + let liberer; + const barriere = new Promise((resoudre) => { + liberer = resoudre; + }); + const retenue = { promesse: null, liberer: () => liberer() }; + m.fs.lireTexte = (racine, chemin) => { + if (chemin === 'bal.gtt.json' && retenue.promesse === null) { + const lue = lireTexte.call(m.fs, racine, chemin); + retenue.promesse = barriere.then(() => lue); + return retenue.promesse; + } + return lireTexte.call(m.fs, racine, chemin); + }; + return retenue; + } + + test('une relecture pendant le chargement : la ligne ne montre jamais la valeur de l’ancienne lecture, même finie la dernière', async () => { + const ancien = texteRetenu(1000); + const nouveau = texteRetenu(25); + expect(difference(ancien)).not.toBe(difference(nouveau)); + const m = await monter({ fichiers: [{ racine: 'documents', chemin: 'bal.gtt.json', texte: ancien }] }); + const retenue = retenirLecture(m); + // Le premier pas de la première lecture lit l'état, et reste suspendu. + await vi.waitFor(async () => { + await uneImage(); + m.horloge.avancer(0); + expect(retenue.promesse).not.toBeNull(); + }); + m.fs.deposer('documents', 'bal.gtt.json', nouveau); + await m.facade.demarrer(); + flushSync(); + expect(cellule(m).querySelector('[data-chargement]')).not.toBeNull(); + await vi.waitFor(async () => { + await uneImage(); + m.horloge.avancer(0); + flushSync(); + expect(cellule(m).textContent).toBe(difference(nouveau)); + }); + // La lecture d'avant s'achève : son calcul ne se pose pas. + retenue.liberer(); + await retenue.promesse; + for (let image = 0; image < 3; image += 1) await uneImage(); + m.horloge.avancer(0); + flushSync(); + expect(cellule(m).textContent).toBe(difference(nouveau)); + }); + + test('un événement ouvert pendant le chargement l’abandonne : l’horloge avancée ne relit plus aucun état', async () => { + const m = await monter({ + fichiers: [ + { racine: 'documents', chemin: 'bal.gtt.json', texte: texteRetenu(1000) }, + { racine: 'documents', chemin: 'club.gtt.json', texte: evenement('Club des Merles', { texte: CONFLIT }) }, + ], + }); + expect(m.racine.querySelectorAll('[data-chargement]').length).toBeGreaterThan(0); + await m.seance.ouvrir('bal'); + flushSync(); + const lireTexte = m.fs.lireTexte; + const etats = []; + m.fs.lireTexte = (racine, chemin) => { + if (chemin.endsWith('.gtt.json')) etats.push(chemin); + return lireTexte.call(m.fs, racine, chemin); + }; + for (let pas = 0; pas < 4; pas += 1) { + await uneImage(); + m.horloge.avancer(0); + } + expect(etats).toEqual([]); + }); + + test('une lecture de la liste qui s’achève sous un événement ouvert ne lance aucun chargement', async () => { + const m = await monter({ + fichiers: [ + { racine: 'documents', chemin: 'bal.gtt.json', texte: texteRetenu(1000) }, + { racine: 'documents', chemin: 'club.gtt.json', texte: evenement('Club des Merles', { texte: CONFLIT }) }, + ], + }); + await m.seance.ouvrir('bal'); + flushSync(); + // La lecture de la liste que lance la fermeture lit le dossier à son + // appel, et ne rend ce qu'elle a lu qu'à liberer(). + const lister = m.facade.lister; + let liberer; + const barriere = new Promise((resoudre) => { + liberer = resoudre; + }); + m.facade.lister = (...arguments_) => { + const lue = lister(...arguments_); + return barriere.then(() => lue); + }; + await m.seance.fermer(); + await m.seance.ouvrir('club'); + flushSync(); + const lireTexte = m.fs.lireTexte; + const etats = []; + m.fs.lireTexte = (racine, chemin) => { + if (chemin.endsWith('.gtt.json')) etats.push(chemin); + return lireTexte.call(m.fs, racine, chemin); + }; + liberer(); + for (let pas = 0; pas < 4; pas += 1) { + await uneImage(); + m.horloge.avancer(0); + } + expect(m.seance.etat().evenement?.base).toBe('club'); + expect(etats).toEqual([]); + }); +}); + describe('l’événement ouvert et sa navigation', () => { test('chaque ouverture remet la navigation à son départ : la section d’ouverture, montrée', async () => { const m = await avecEvenementOuvert(); diff --git a/src/interface/App.svelte b/src/interface/App.svelte index 2065a72..53dff19 100644 --- a/src/interface/App.svelte +++ b/src/interface/App.svelte @@ -21,7 +21,9 @@ // façade et la donne à l'accueil, une fois la détermination réglée — sans // question au démarrage, après la réponse sinon : pret se résout quand la // détermination est faite, la liste lue quand aucune question n'attend, et - // le premier écran — les lignes de la liste, ou la question — rendu. + // le premier écran — les lignes de la liste, ou la question — rendu. Les + // résumés des lignes n'en sont pas : ils se calculent ensuite, un + // événement après l'autre, et l'accueil montre chacun dès qu'il arrive. // // L'écran suit la séance : la liste sans événement ouvert, l'événement // sinon ; le panneau « À propos » les remplace tant qu'il est ouvert. Chaque @@ -36,6 +38,7 @@ // sans question ; aucun beforeunload ne retient la page (§ 8.4). import './jetons.css'; import { onDestroy, setContext, tick, untrack } from 'svelte'; + import { SvelteMap } from 'svelte/reactivity'; import { creerFacade } from '../application/evenements.js'; import { creerFormat } from '../application/format.js'; import { creerControleurGeneration } from '../application/generation.js'; @@ -44,6 +47,7 @@ import { creerTraduction } from '../application/traduction.js'; import APropos from './APropos.svelte'; import Accueil from './accueil/Accueil.svelte'; + import { creerPas } from './accueil/chargement.js'; import Bandeau from './Bandeau.svelte'; import Evenement from './evenement/Evenement.svelte'; import { blocAPropos, provenanceVisible, versionVisible } from './apropos.js'; @@ -148,14 +152,42 @@ let lecture = Promise.resolve(); let lectures = 0; + // Les résumés des lignes lues, par base, que facade.resumer rend un + // événement après l'autre, chaque pas attendant l'image puis l'horloge + // (creerPas) ; chargement est son flux en cours, ou null. Une lecture qui + // commence abandonne le flux de la précédente, comme l'ouverture d'un + // événement et le démontage : un résumé calculé pour une liste ne se pose + // jamais sur une plus récente. Une lecture qui s'achève sous un événement + // ouvert pose ses lignes sans lancer de flux : la fermeture relit la + // liste, et ses résumés avec elle. Le flux lit dans le dossier de travail de + // sa liste, et un changement de dossier relit la liste. Une faute d'un + // calcul rejette fin, que rien ne retient : la page la reçoit comme toute + // faute, et la ligne écrit « — ». + const resumes = new SvelteMap(); + let chargement = null; + function abandonnerResumes() { + chargement?.abandonner(); + chargement = null; + } + function chargerResumes(lues) { + resumes.clear(); + chargement = facadeContexte.resumer(lues, { + ceder: creerPas(horloge, (rappel) => requestAnimationFrame(rappel)), + recevoir: (base, resume) => resumes.set(base, resume), + }); + } + onDestroy(abandonnerResumes); + async function lireListe() { lectures += 1; const rang = lectures; + abandonnerResumes(); try { const lues = await facadeContexte.lister(); if (rang !== lectures) return; lignes = lues; refusLecture = null; + if (seanceApp.etat().evenement === null) chargerResumes(lues); } catch (erreur) { if (!estRefus(erreur)) throw erreur; if (rang !== lectures) return; @@ -184,6 +216,7 @@ let baseOuverte = null; const desabonner = seanceApp.subscribe((etat) => { const base = etat.evenement?.base ?? null; + if (base !== null) abandonnerResumes(); if (base !== null && base !== baseOuverte) uiApp.reinitialiser(); const fermeture = baseOuverte !== null && base === null; baseOuverte = base; @@ -274,7 +307,7 @@ {#if $uiApp.apropos} uiApp.fermerApropos()} /> {:else if etatSeance.evenement === null} - + {:else} {/if} diff --git a/src/interface/accueil/Accueil.navigateur.test.js b/src/interface/accueil/Accueil.navigateur.test.js index 32cba27..64b71d2 100644 --- a/src/interface/accueil/Accueil.navigateur.test.js +++ b/src/interface/accueil/Accueil.navigateur.test.js @@ -7,10 +7,11 @@ // événement ouvert, ouvrir sans écrire, les signalements, la récupération, // l'ouverture en lecture, les réglages illisibles, le dossier de travail, ses // avis et ses questions, l'explorateur, l'autre emplacement, copier, -// renommer, importer, supprimer, les démonstrations, la racine neutre, et la -// liste qui défile sous le bandeau. Chaque épreuve monte App par monterApp -// (test/navigateur/montage.js), sur le système de fichiers simulé, et lit -// le relevé de ses écritures. +// renommer, importer, supprimer, les démonstrations, la racine neutre, la +// liste qui défile sous le bandeau, et le chargement des résumés de la liste, +// dont l'horloge pilotée du montage tient chaque pas. Chaque épreuve monte +// App par monterApp (test/navigateur/montage.js), sur le système de fichiers +// simulé, et lit le relevé de ses écritures. // // Le test interroge le rendu et le disque, jamais une classe : le texte, la // visibilité, l'élément qu'atteint le centre d'une boîte, les fichiers du @@ -20,6 +21,7 @@ // Le projet navigateur emploie expect, et prend test et describe chez Vitest // (voir src/interface/App.navigateur.test.js). import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { cdp } from 'vitest/browser'; import { flushSync, tick } from 'svelte'; import { appliquerCommande } from '../../application/commandes.js'; import { evaluer } from '../../application/evaluation.js'; @@ -35,16 +37,24 @@ import { rechercher } from '../../moteur/recherche.js'; import { serialiser } from '../../stockage/canonique.js'; import { creer as creerDansLeDepot, ouvrir as ouvrirDansLeDepot } from '../../stockage/depot.js'; import { analyser, configurationDepuisCharge, creerCharge } from '../../stockage/document.js'; +import { ErreurStockage } from '../../stockage/erreurs.js'; import { lireJournal } from '../../stockage/journal.js'; import { SUFFIXES } from '../../stockage/noms.js'; import { libelleDeLInstant } from '../../stockage/operations.js'; import { planDepuisFichier } from '../../stockage/placements.js'; import { VERSION } from '../../version.genere.js'; import { creerFichiersSimules } from '../../../test/fichiers_simules.js'; -import { centre } from '../../../test/navigateur/ecrans.js'; +import { centre, chargerResumes, suivreResumes } from '../../../test/navigateur/ecrans.js'; import { fixerCadre, pointer, touche } from '../../../test/navigateur/entrees.js'; import { monterApp } from '../../../test/navigateur/montage.js'; -import { visible as visibleDansLaFenetre } from '../../../test/navigateur/rendu.js'; +import { + attenuations, + composantes, + contraste, + fondPeintSous, + sousTheme, + visible as visibleDansLaFenetre, +} from '../../../test/navigateur/rendu.js'; import { TABLES_INTERFACE } from '../libelles.js'; const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]); @@ -232,6 +242,10 @@ async function photographier(m, racine, dossier = '') { return photo; } +// Les signes du chargement des résumés d'un montage. +const signes = (m) => tous(m, '[data-chargement]'); +const uneImage = () => new Promise((resoudre) => requestAnimationFrame(() => resoudre())); + // --- Le premier écran (§ 8.1, § 18.6) --------------------------------------------- describe('le premier écran (§ 8.1, § 18.6)', () => { @@ -1158,6 +1172,7 @@ describe('les mesures du retenu dans la liste (§ 5.4, § 8.1, § 8.9)', () => { fichiers: [fichier(`retenue${SUFFIXES.etat}`, etat(charge)), fichier(`club${SUFFIXES.etat}`, texteRenomme('Club des Merles'))], }); await vi.waitFor(() => expect(lignes(m).length).toBe(2)); + await chargerResumes(m); const avant = ecritures(m); const retenue = ligneDe(m, 'retenue'); expect(colonne(retenue, 'collisionsCumulees')).toBe(format.nombre(mesures.collisionsCumulees)); @@ -1186,6 +1201,7 @@ describe('les mesures du retenu dans la liste (§ 5.4, § 8.1, § 8.9)', () => { const { totalRedondance } = mesuresDe(charge); const m = await monter({ fichiers: [fichier(`retenue${SUFFIXES.etat}`, etat(charge))] }); await vi.waitFor(() => expect(lignes(m).length).toBe(1)); + await chargerResumes(m); expect(colonne(ligneDe(m, 'retenue'), 'diffRedondance')).toBe(format.nombre(Math.abs(1000 - totalRedondance))); await ouvrirLigne(m, 'retenue'); await attendreEvenement(m); @@ -1198,8 +1214,12 @@ describe('les mesures du retenu dans la liste (§ 5.4, § 8.1, § 8.9)', () => { saisir(entree, '25'); entree.blur(); await vi.waitFor(() => expect(m.seance.etat().charge.reglages.cibleRedondance).toBe(25)); + // Le résumé que la liste relue reçoit, non celui de la lecture d'avant, + // que la ligne montre encore tant que la relecture ne s'est pas posée. + const resumeRecu = suivreResumes(m); await revenirALaListe(m, 1); - await vi.waitFor(() => expect(colonne(ligneDe(m, 'retenue'), 'diffRedondance')).toBe(format.nombre(Math.abs(25 - totalRedondance)))); + await resumeRecu('retenue'); + expect(colonne(ligneDe(m, 'retenue'), 'diffRedondance')).toBe(format.nombre(Math.abs(25 - totalRedondance))); expect(colonne(ligneDe(m, 'retenue'), 'collisionsCumulees')).toBe(format.nombre(mesuresDe(charge).collisionsCumulees)); }); }); @@ -1240,6 +1260,7 @@ describe('la meilleure proposition du classement dans la liste (§ 5.4, § 5.7, fichiers: [fichier(`classee${SUFFIXES.etat}`, etat(charge)), fichier(`club${SUFFIXES.etat}`, texteRenomme('Club des Merles'))], }); await vi.waitFor(() => expect(lignes(m).length).toBe(2)); + await chargerResumes(m); const avant = ecritures(m); const classee = ligneDe(m, 'classee'); const bloc = blocDe(classee); @@ -1270,6 +1291,7 @@ describe('la meilleure proposition du classement dans la liste (§ 5.4, § 5.7, test('la ligne écrit ce que le bloc de la génération écrit du même événement, sous les mêmes libellés, et de nouveau au retour', async () => { const m = await monter({ fichiers: [fichier(`classee${SUFFIXES.etat}`, etat(chargeClassee()))] }); await vi.waitFor(() => expect(lignes(m).length).toBe(1)); + await chargerResumes(m); const ligne = ligneDe(m, 'classee'); const deLaListe = lu(blocDe(ligne)); expect(deLaListe.id).toMatch(/\d/); @@ -1287,8 +1309,12 @@ describe('la meilleure proposition du classement dans la liste (§ 5.4, § 5.7, (element) => element.textContent, ), ).toEqual(libelles); + // Le même résumé, mais reçu de la liste relue au retour : celui d'avant + // l'ouverture écrirait les mêmes valeurs sans rien prouver. + const resumeRecu = suivreResumes(m); await revenirALaListe(m, 1); - await vi.waitFor(() => expect(lu(blocDe(ligneDe(m, 'classee')))).toEqual(deLaListe)); + await resumeRecu('classee'); + expect(lu(blocDe(ligneDe(m, 'classee')))).toEqual(deLaListe); }); test('des propositions toutes hors du classement : « — » à la liste, là où le bloc de la génération le dit en mots', async () => { @@ -1299,6 +1325,7 @@ describe('la meilleure proposition du classement dans la liste (§ 5.4, § 5.7, expect(statuts.every((statut) => statut !== 'comparable')).toBe(true); const m = await monter({ fichiers: [fichier(`horsclassement${SUFFIXES.etat}`, etat(charge))] }); await vi.waitFor(() => expect(lignes(m).length).toBe(1)); + await chargerResumes(m); expect(lu(blocDe(ligneDe(m, 'horsclassement')))).toEqual({ id: TIRET, collisions: TIRET }); await ouvrirLigne(m, 'horsclassement'); await attendreEvenement(m); @@ -1313,6 +1340,7 @@ describe('la meilleure proposition du classement dans la liste (§ 5.4, § 5.7, const bases = ['un', 'deux', 'trois', 'quatre', 'cinq', 'six']; const m = await monter({ fichiers: bases.map((base) => fichier(`${base}${SUFFIXES.etat}`, etat(chargeClassee({ id: `evt-${base}` })))) }); await vi.waitFor(() => expect(lignes(m).length).toBe(bases.length)); + await chargerResumes(m); const blocs = lignes(m).map(blocDe); expect(blocs.length).toBe(bases.length); expect(document.documentElement.scrollWidth).toBeLessThanOrEqual(innerWidth); @@ -1326,3 +1354,154 @@ describe('la meilleure proposition du classement dans la liste (§ 5.4, § 5.7, } }); }); + +// --- Le chargement des résumés (§ 8.1, § 14.5, § 19.2) ------------------------------ + +describe('le chargement des résumés de la liste (§ 8.1, § 14.5, § 19.2)', () => { + // La petite démonstration au conflit inévitable, sous l'identifiant id, + // trois propositions du moteur et la deuxième retenue. + function chargeResumee(id) { + let charge = analyser(texteLivre('petite-conflit')).charge; + charge.evenement.id = id; + const configuration = configurationDepuisCharge(charge); + const propositions = rechercher(configuration, { graine: 6, nombre: 3, arret: 300 }); + charge = appliquerCommande('enregistrerGeneration', charge, { propositions, configuration, produitVersion: VERSION.affichee }).charge; + return appliquerCommande('retenirProposition', charge, { id: charge.propositions[1].id }).charge; + } + + // Les quatre valeurs de résumé d'une ligne, dans l'ordre du rendu. + const CELLULES = ['[data-colonne="collisionsCumulees"]', '[data-colonne="diffRedondance"]', '[data-meilleure-id]', '[data-meilleure-collisions]']; + const cellules = (ligne) => CELLULES.map((selecteur) => ligne.querySelector(selecteur)); + // Ce que la ligne d'une charge écrit, par la table d'évaluation sollicitée + // une seconde fois (§ 19.1). + function attendues(charge) { + const table = evaluer(charge); + const [premiere] = table.propositions; + const { collisionsCumulees, totalRedondance } = table.retenu.mesures; + return [collisionsCumulees, Math.abs(1000 - totalRedondance), premiere.id, premiere.mesures.collisionsCumulees].map((n) => format.nombre(n)); + } + // Chaque valeur de la ligne attend : la cellule se dit occupée, porte un + // seul signe, muet pour l'accessibilité (aria-hidden, sans rôle ni nom), + // et aucun texte. + function enAttente(ligne) { + for (const cellule of cellules(ligne)) { + expect(cellule.getAttribute('aria-busy')).toBe('true'); + const signe = cellule.querySelectorAll('[data-chargement]'); + expect(signe.length).toBe(1); + expect(signe[0].getAttribute('aria-hidden')).toBe('true'); + expect(signe[0].hasAttribute('role')).toBe(false); + expect(signe[0].hasAttribute('aria-label')).toBe(false); + expect(signe[0] instanceof SVGSVGElement).toBe(true); + expect(cellule.textContent.trim()).toBe(''); + } + } + // La région d'état de la liste, la seule que la liste porte : l'annonce + // du chargement s'y écrit une fois pour toutes les lignes. + function regionEtat(m) { + const regions = tous(m, '[data-chargement-liste]'); + expect(regions.length).toBe(1); + expect(regions[0].getAttribute('role')).toBe('status'); + expect(tous(m, '.liste [role="status"], .liste [role="img"]')).toEqual([]); + return regions[0]; + } + const resolue = (ligne, valeurs) => { + expect(cellules(ligne).map((cellule) => cellule.textContent)).toEqual(valeurs); + for (const cellule of cellules(ligne)) { + expect(cellule.hasAttribute('aria-busy')).toBe(false); + expect(cellule.querySelector('[data-chargement]')).toBeNull(); + } + }; + + test('la liste paraît avant ses résumés, un signe muet par valeur, une seule annonce ; chaque pas de l’horloge résout une ligne, dans l’ordre, aux valeurs de l’évaluation ; rien d’écrit', async () => { + const charges = { alpha: chargeResumee('evt-alpha'), beta: chargeResumee('evt-beta') }; + const m = await monter({ fichiers: Object.entries(charges).map(([base, charge]) => fichier(`${base}${SUFFIXES.etat}`, etat(charge))) }); + const avant = ecritures(m); + // pret résolu : les lignes et leurs informations sont là, chaque valeur + // de résumé attend, et le reste tant que l'horloge n'avance pas. + expect(lignes(m).map((ligne) => ligne.dataset.evenement)).toEqual(['alpha', 'beta']); + for (const ligne of lignes(m)) { + expect(colonne(ligne, 'nom')).toBe(charges[ligne.dataset.evenement].evenement.nom); + expect(colonne(ligne, 'participants')).toBe(format.nombre(charges[ligne.dataset.evenement].participants.length)); + enAttente(ligne); + } + expect(signes(m).length).toBe(8); + const region = regionEtat(m); + expect(region.textContent).toBe(t('accueil.chargement')); + for (let image = 0; image < 3; image += 1) await uneImage(); + flushSync(); + expect(signes(m).length).toBe(8); + m.horloge.avancer(0); + await vi.waitFor(() => resolue(ligneDe(m, 'alpha'), attendues(charges.alpha))); + enAttente(ligneDe(m, 'beta')); + expect(regionEtat(m)).toBe(region); + expect(region.textContent).toBe(t('accueil.chargement')); + m.horloge.avancer(0); + await vi.waitFor(() => resolue(ligneDe(m, 'beta'), attendues(charges.beta))); + expect(signes(m).length).toBe(0); + // La fin du chargement vide la région, qui reste là pour le suivant. + expect(regionEtat(m)).toBe(region); + expect(region.textContent).toBe(''); + expect(ecritures(m)).toBe(avant); + }); + + test('un état qui ne se relit pas pendant le chargement : « — » dans ses quatre valeurs, et l’autre ligne résolue', async () => { + const charges = { alpha: chargeResumee('evt-alpha'), beta: chargeResumee('evt-beta') }; + const m = await monter({ fichiers: Object.entries(charges).map(([base, charge]) => fichier(`${base}${SUFFIXES.etat}`, etat(charge))) }); + const lireTexte = m.fs.lireTexte; + m.fs.lireTexte = (racine, chemin) => + chemin === `alpha${SUFFIXES.etat}` + ? Promise.reject(new ErreurStockage('LECTURE', { chemin, cause: 'EIO' })) + : lireTexte.call(m.fs, racine, chemin); + await chargerResumes(m); + resolue(ligneDe(m, 'alpha'), [TIRET, TIRET, TIRET, TIRET]); + resolue(ligneDe(m, 'beta'), attendues(charges.beta)); + }); + + test('le signe tourne par une animation CSS sans fin ; immobile sous prefers-reduced-motion: reduce, et sous la feuille qui annule les animations', async () => { + const m = await monter({ fichiers: [fichier(`alpha${SUFFIXES.etat}`, etat(chargeResumee('evt-alpha')))] }); + const [signe] = signes(m); + expect(signe).toBeDefined(); + const tourne = () => signe.getAnimations().filter((animation) => animation.playState === 'running'); + expect(tourne().length).toBe(1); + expect(tourne()[0].effect.getTiming().iterations).toBe(Infinity); + expect(getComputedStyle(signe).animationName).not.toBe('none'); + await cdp().send('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-reduced-motion', value: 'reduce' }] }); + try { + expect(matchMedia('(prefers-reduced-motion: reduce)').matches).toBe(true); + await vi.waitFor(() => expect(tourne().length).toBe(0)); + expect(getComputedStyle(signe).animationName).toBe('none'); + } finally { + await cdp().send('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-reduced-motion', value: '' }] }); + } + await vi.waitFor(() => expect(tourne().length).toBe(1)); + const feuille = document.createElement('style'); + feuille.textContent = '*,*::before,*::after{transition:none!important;animation:none!important}'; + document.head.append(feuille); + try { + await vi.waitFor(() => expect(tourne().length).toBe(0)); + } finally { + feuille.remove(); + } + }); + + test('le signe se voit : il tient 3:1 sur ce qui l’entoure dans les deux thèmes, sans atténuation', async () => { + const m = await monter({ fichiers: [fichier(`alpha${SUFFIXES.etat}`, etat(chargeResumee('evt-alpha')))] }); + const traces = tous(m, '[data-chargement] path'); + expect(traces.length).toBe(4); + for (const theme of ['clair', 'sombre']) { + await sousTheme(theme, async () => { + for (const trace of traces) { + const signe = trace.ownerSVGElement; + signe.scrollIntoView({ block: 'center' }); + expect(visibleDansLaFenetre(signe), theme).toBe(true); + expect(attenuations(trace), theme).toEqual([]); + expect(getComputedStyle(trace).fill, theme).toBe('none'); + const couleur = composantes(getComputedStyle(trace).stroke); + const autour = fondPeintSous(signe); + expect(autour.length, theme).toBeGreaterThan(0); + for (const fond of autour) expect(contraste(couleur, fond), `${theme} sur ${fond.join(', ')}`).toBeGreaterThanOrEqual(3); + } + }); + } + }); +}); diff --git a/src/interface/accueil/Accueil.svelte b/src/interface/accueil/Accueil.svelte index 9ce0165..409a3ce 100644 --- a/src/interface/accueil/Accueil.svelte +++ b/src/interface/accueil/Accueil.svelte @@ -10,8 +10,9 @@ // et il ne lit ni n'écrit rien lui-même. // // Les lignes viennent d'App, qui lit la liste par la façade une fois la - // détermination du dossier de travail réglée, et refusLecture est le refus - // qu'App a reçu en la lisant. L'accueil prend la détermination par + // détermination du dossier de travail réglée, puis leurs résumés, qu'App + // reçoit un événement après l'autre (resumes, par base) ; refusLecture est + // le refus qu'App a reçu en la lisant. L'accueil prend la détermination par // facade.demarrer — la même pour toute la séance, sans nouvelle sonde — et // en montre la question, puis l'autre emplacement, les avis et le dossier. // Les avis ne se disent qu'au premier accueil de la séance (premiereAnnonce) @@ -41,7 +42,7 @@ const seance = getContext('seance'); const relireListe = getContext('relireListe'); - let { provenance, version, demonstrations, lignes, refusLecture } = $props(); + let { provenance, version, demonstrations, lignes, resumes, refusLecture } = $props(); // Vrai au premier accueil de la séance : ses avis s'y disent. const annoncer = premiereAnnonce(facade); @@ -229,7 +230,7 @@ /> {/if} - +

{t('accueil.creation.titre')}

diff --git a/src/interface/accueil/Chargement.svelte b/src/interface/accueil/Chargement.svelte new file mode 100644 index 0000000..3377ce9 --- /dev/null +++ b/src/interface/accueil/Chargement.svelte @@ -0,0 +1,49 @@ + + + + + + + diff --git a/src/interface/accueil/ListeEvenements.svelte b/src/interface/accueil/ListeEvenements.svelte index 4aa0a9f..8b6f3f1 100644 --- a/src/interface/accueil/ListeEvenements.svelte +++ b/src/interface/accueil/ListeEvenements.svelte @@ -16,8 +16,15 @@ // le refus de son ouverture, et ses opérations. Un dossier sans événement n'affiche pas de liste vide : // la création et les démonstrations de l'accueil en tiennent lieu. // - // lignes : LigneListe[] de la façade ; ouvrir(ligne) : le geste de - // l'accueil ; refusOuverture : { base, refus } du dernier échec, ou null ; + // lignes : LigneListe[] de la façade ; resumes : les résumés reçus de + // facade.resumer, par base — une ligne sans le sien attend, et chacune de + // ses quatre valeurs de résumé porte le signe du chargement (Chargement), + // décoratif, dans une cellule qui se dit occupée (aria-busy), jusqu'à ce + // qu'il arrive. La région d'état qui suit la liste (data-chargement-liste) + // existe tant que la liste a des lignes, et écrit « accueil.chargement » + // tant qu'une ligne attend : le chargement s'annonce une fois pour la + // liste, et sa fin la vide. ouvrir(ligne) : le geste de l'accueil ; + // refusOuverture : { base, refus } du dernier échec, ou null ; // apresOperation() : à appeler quand une opération a changé le dossier. // // L'intitulé de chaque mesure du retenu, le titre de la meilleure @@ -30,14 +37,18 @@ import { getContext } from 'svelte'; import Infobulle from '../primitives/Infobulle.svelte'; import Refus from '../primitives/Refus.svelte'; + import Chargement from './Chargement.svelte'; import Operations from './Operations.svelte'; import { COLONNES_DU_RETENU, MEILLEURE, ligneAffichee } from './modele.js'; - let { lignes, ouvrir, refusOuverture, apresOperation } = $props(); + let { lignes, resumes, ouvrir, refusOuverture, apresOperation } = $props(); const t = getContext('t'); const format = getContext('format'); + // Une ligne au moins attend son résumé. + const enChargement = $derived(lignes.some((ligne) => ligneAffichee(ligne, format, resumes.get(ligne.base)).enAttente)); + // Le terme expliqué : {ancre, terme, cle, details} — son élément, sa // désignation ':' et la clé de son explication — ; null // sans infobulle. @@ -58,6 +69,12 @@ + +{#snippet resume(affichee, valeur)} + {#if affichee.enAttente}{:else}{valeur}{/if} +{/snippet} + {#snippet termeExplique(base, nom, cle, explication, id)} {@const terme = `${base}:${nom}`}