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}`}