[IMP] home: list shown at once, computed columns loaded per event
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
This commit is contained in:
parent
9eb1275f53
commit
94ea937ae4
18 changed files with 1116 additions and 116 deletions
27
spec.md
27
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.**
|
||||
|
||||
|
|
|
|||
|
|
@ -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<unknown>, recevoir: (base: string, resume: Object) => void}} rappels
|
||||
* @returns {{abandonner: () => void, fin: Promise<void>}}
|
||||
*/
|
||||
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). */
|
||||
|
|
|
|||
|
|
@ -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) ----------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
<APropos {bloc} {pressePapiers} fermer={() => uiApp.fermerApropos()} />
|
||||
{:else if etatSeance.evenement === null}
|
||||
<Accueil {provenance} version={produitVersion} {demonstrations} {lignes} {refusLecture} />
|
||||
<Accueil {provenance} version={produitVersion} {demonstrations} {lignes} {resumes} {refusLecture} />
|
||||
{:else}
|
||||
<Evenement />
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
<ListeEvenements {lignes} {ouvrir} {refusOuverture} {apresOperation} />
|
||||
<ListeEvenements {lignes} {resumes} {ouvrir} {refusOuverture} {apresOperation} />
|
||||
|
||||
<section class="creation" aria-labelledby={`${uid}-creation`}>
|
||||
<h2 id={`${uid}-creation`}>{t('accueil.creation.titre')}</h2>
|
||||
|
|
|
|||
49
src/interface/accueil/Chargement.svelte
Normal file
49
src/interface/accueil/Chargement.svelte
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// Le signe d'une valeur de la liste qui se calcule encore (§ 8.1) : un arc
|
||||
// de cercle dessiné en SVG (§ 7.6), que porte une cellule qui se dit
|
||||
// occupée (aria-busy). Il est décoratif (aria-hidden) : la liste annonce
|
||||
// son chargement une seule fois, dans sa région d'état
|
||||
// (ListeEvenements.svelte), et non une fois par valeur. Il tourne d'un
|
||||
// tour par seconde : c'est la seule animation de l'interface, et aucun
|
||||
// état n'en dépend — rien n'attend sa fin, rien ne lit son rendu. Il reste
|
||||
// immobile sous prefers-reduced-motion: reduce, et la feuille qui annule
|
||||
// les animations des captures (§ 19.2) l'arrête comme toute autre. L'arc
|
||||
// est peint de l'encre secondaire de la page, sans opacité partielle
|
||||
// (§ 14.5) ; son trait est la marque « chargement » du registre
|
||||
// (marques.js).
|
||||
</script>
|
||||
|
||||
<svg class="rouet" data-chargement viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path data-marque="chargement" d="M 8 2 A 6 6 0 1 1 2 8" />
|
||||
</svg>
|
||||
|
||||
<style>
|
||||
.rouet {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
vertical-align: -0.125em;
|
||||
animation: rotation 1s linear infinite;
|
||||
}
|
||||
|
||||
path {
|
||||
fill: none;
|
||||
stroke: var(--couleur-page-texte-secondaire);
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
@keyframes rotation {
|
||||
to {
|
||||
transform: rotate(1turn);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.rouet {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -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 '<base>:<colonne>' et la clé de son explication — ; null
|
||||
// sans infobulle.
|
||||
|
|
@ -58,6 +69,12 @@
|
|||
<!-- Un terme expliqué : nom est son data-terme, cle son intitulé,
|
||||
explication la clé que l'infobulle montre ; id, celui qu'un groupe
|
||||
nomme par aria-labelledby, ou undefined. -->
|
||||
<!-- Une valeur de résumé : le signe du chargement tant que la ligne
|
||||
attend, sa valeur ensuite. -->
|
||||
{#snippet resume(affichee, valeur)}
|
||||
{#if affichee.enAttente}<Chargement />{:else}{valeur}{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet termeExplique(base, nom, cle, explication, id)}
|
||||
{@const terme = `${base}:${nom}`}
|
||||
<!-- L'intitulé est un terme, non une commande : le focus ne sert qu'à
|
||||
|
|
@ -79,7 +96,8 @@
|
|||
{#if lignes.length > 0}
|
||||
<ul class="liste">
|
||||
{#each lignes as ligne, position (ligne.base)}
|
||||
{@const affichee = ligneAffichee(ligne, format)}
|
||||
{@const affichee = ligneAffichee(ligne, format, resumes.get(ligne.base))}
|
||||
{@const occupee = affichee.enAttente ? 'true' : undefined}
|
||||
{@const idMeilleure = `${uid}-meilleure-${position}`}
|
||||
<li class="ligne" data-evenement={ligne.base}>
|
||||
<button type="button" class="ouvrir" data-commande="ouvrir" onclick={() => ouvrir(ligne)}
|
||||
|
|
@ -105,7 +123,7 @@
|
|||
{#each COLONNES_DU_RETENU as { colonne, cle, explication } (colonne)}
|
||||
<div>
|
||||
<dt>{@render termeExplique(ligne.base, colonne, cle, explication, undefined)}</dt>
|
||||
<dd data-colonne={colonne}>{affichee[colonne]}</dd>
|
||||
<dd data-colonne={colonne} aria-busy={occupee}>{@render resume(affichee, affichee[colonne])}</dd>
|
||||
</div>
|
||||
{/each}
|
||||
<div class="chemin">
|
||||
|
|
@ -120,7 +138,7 @@
|
|||
<dl>
|
||||
<div>
|
||||
<dt>{t(MEILLEURE.proposition)}</dt>
|
||||
<dd data-meilleure-id>{affichee.meilleure.id}</dd>
|
||||
<dd data-meilleure-id aria-busy={occupee}>{@render resume(affichee, affichee.meilleure.id)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>
|
||||
|
|
@ -132,7 +150,7 @@
|
|||
undefined,
|
||||
)}
|
||||
</dt>
|
||||
<dd data-meilleure-collisions>{affichee.meilleure.collisions}</dd>
|
||||
<dd data-meilleure-collisions aria-busy={occupee}>{@render resume(affichee, affichee.meilleure.collisions)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
|
@ -155,6 +173,7 @@
|
|||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<p class="chargement" role="status" data-chargement-liste>{#if enChargement}{t('accueil.chargement')}{/if}</p>
|
||||
<Infobulle id={idExplication} ouverte={expliquee} fermer={fermerExplication} />
|
||||
{/if}
|
||||
|
||||
|
|
@ -279,6 +298,11 @@
|
|||
margin: 0;
|
||||
}
|
||||
|
||||
.chargement {
|
||||
margin: 0;
|
||||
color: var(--couleur-page-texte-secondaire);
|
||||
}
|
||||
|
||||
.noms {
|
||||
margin: 0;
|
||||
padding-left: var(--espace-4);
|
||||
|
|
|
|||
34
src/interface/accueil/chargement.js
Normal file
34
src/interface/accueil/chargement.js
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Le pas du chargement des résumés de la liste (§ 8.1) : ce qu'attend
|
||||
// facade.resumer avant chaque événement, pour rendre la main au navigateur.
|
||||
// Le premier pas attend l'image suivante, puis une minuterie de l'horloge :
|
||||
// la minuterie armée au moment de l'image ne part qu'après la peinture de
|
||||
// cette image, si bien que la liste est peinte avant le premier calcul. Les
|
||||
// pas suivants n'attendent que la minuterie : entre deux événements, le
|
||||
// navigateur traite les entrées en attente et peint quand une image est due.
|
||||
// La minuterie vient de l'horloge de l'application, seule à en armer
|
||||
// (§ 19.4) : sous l'horloge pilotée, chaque pas attend l'épreuve.
|
||||
|
||||
/**
|
||||
* Un pas neuf, dont le premier appel attend une image.
|
||||
*
|
||||
* @param {{planifier: (fn: () => void, ms: number) => unknown}} horloge
|
||||
* @param {(rappel: () => void) => unknown} image demande le rappel à la
|
||||
* prochaine image : requestAnimationFrame dans le navigateur
|
||||
* @returns {() => Promise<void>}
|
||||
*/
|
||||
export function creerPas(horloge, image) {
|
||||
let premier = true;
|
||||
return () =>
|
||||
new Promise((reprendre) => {
|
||||
const minuterie = () => horloge.planifier(reprendre, 0);
|
||||
if (!premier) {
|
||||
minuterie();
|
||||
return;
|
||||
}
|
||||
premier = false;
|
||||
image(minuterie);
|
||||
});
|
||||
}
|
||||
87
src/interface/accueil/chargement.test.js
Normal file
87
src/interface/accueil/chargement.test.js
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Le pas du chargement des résumés de la liste (§ 8.1, § 19.4) : le premier
|
||||
// attend une image, puis la minuterie de l'horloge ; les suivants, la seule
|
||||
// minuterie. L'horloge pilotée et une demande d'image d'épreuve tiennent
|
||||
// chaque attente : rien n'avance seul.
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from '../../../test/lanceur.js';
|
||||
import { creerHorlogePilotee } from '../../application/horloge.js';
|
||||
import { creerPas } from './chargement.js';
|
||||
|
||||
// setImmediate rappelle une fois vidées les microtâches : une promesse
|
||||
// résolue a rappelé ses suites.
|
||||
const laisser = () => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
// Une demande d'image d'épreuve : chaque rappel attend peindre().
|
||||
function creerImages() {
|
||||
const enAttente = [];
|
||||
return {
|
||||
demander: (rappel) => {
|
||||
enAttente.push(rappel);
|
||||
},
|
||||
get demandes() {
|
||||
return enAttente.length;
|
||||
},
|
||||
peindre() {
|
||||
for (const rappel of enAttente.splice(0)) rappel();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Suit une promesse : vrai une fois résolue.
|
||||
function suivre(promesse) {
|
||||
const suivi = { resolue: false };
|
||||
promesse.then(() => {
|
||||
suivi.resolue = true;
|
||||
});
|
||||
return suivi;
|
||||
}
|
||||
|
||||
describe('creerPas : le pas du chargement des résumés (§ 8.1)', () => {
|
||||
test('le premier pas attend une image, puis la minuterie : ni l’une ni l’autre seule ne le libère', async () => {
|
||||
const horloge = creerHorlogePilotee();
|
||||
const images = creerImages();
|
||||
const pas = creerPas(horloge, images.demander);
|
||||
const premier = suivre(pas());
|
||||
assert.equal(images.demandes, 1);
|
||||
horloge.avancer(0);
|
||||
await laisser();
|
||||
assert.equal(premier.resolue, false, 'la minuterie ne part qu’après l’image');
|
||||
images.peindre();
|
||||
await laisser();
|
||||
assert.equal(premier.resolue, false, 'l’image seule ne libère pas le pas');
|
||||
horloge.avancer(0);
|
||||
await laisser();
|
||||
assert.equal(premier.resolue, true);
|
||||
});
|
||||
|
||||
test('les pas suivants n’attendent que la minuterie, sans demander d’image', async () => {
|
||||
const horloge = creerHorlogePilotee();
|
||||
const images = creerImages();
|
||||
const pas = creerPas(horloge, images.demander);
|
||||
const premier = suivre(pas());
|
||||
images.peindre();
|
||||
horloge.avancer(0);
|
||||
await laisser();
|
||||
assert.equal(premier.resolue, true);
|
||||
for (let rang = 2; rang <= 3; rang += 1) {
|
||||
const suivant = suivre(pas());
|
||||
await laisser();
|
||||
assert.equal(suivant.resolue, false, `pas ${rang} avant la minuterie`);
|
||||
horloge.avancer(0);
|
||||
await laisser();
|
||||
assert.equal(suivant.resolue, true, `pas ${rang}`);
|
||||
}
|
||||
assert.equal(images.demandes, 0);
|
||||
});
|
||||
|
||||
test('deux pas créés séparément ont chacun leur premier pas', () => {
|
||||
const horloge = creerHorlogePilotee();
|
||||
const images = creerImages();
|
||||
creerPas(horloge, images.demander)();
|
||||
creerPas(horloge, images.demander)();
|
||||
assert.equal(images.demandes, 2);
|
||||
});
|
||||
});
|
||||
|
|
@ -4,12 +4,12 @@
|
|||
// Le modèle de l'accueil, pur (§ 8.1, § 8.6, § 8.8) : ce que les composants
|
||||
// de la liste des événements affichent, sans arithmétique ni texte. Une
|
||||
// ligne de la liste (LigneListe de la façade, A3) devient ses six
|
||||
// informations, les deux mesures de son retenu et la meilleure proposition
|
||||
// de son classement, mises en forme, et ses signalements ; une question du
|
||||
// dossier
|
||||
// de travail, ses réponses ; un état illisible, ses secours ; un refus de la
|
||||
// façade, le refus affiché sur la liste. Chaque clé rendue est écrite ici en
|
||||
// entier : aucun composant ne compose une clé.
|
||||
// informations et ses signalements, mis en forme, puis, son résumé reçu, les
|
||||
// deux mesures de son retenu et la meilleure proposition de son
|
||||
// classement ; une question du dossier de travail, ses réponses ; un état
|
||||
// illisible, ses secours ; un refus de la façade, le refus affiché sur la
|
||||
// liste. Chaque clé rendue est écrite ici en entier : aucun composant ne
|
||||
// compose une clé.
|
||||
import { COLONNE_ANNONCEE } from '../generation/meilleure.js';
|
||||
import { COLONNES } from '../propositions/modele.js';
|
||||
|
||||
|
|
@ -138,21 +138,28 @@ export function signalementsDe(ligne) {
|
|||
* l'horodatage de la dernière entrée du journal, sur l'horloge de
|
||||
* l'application, jamais la date du système de fichiers (§ 19.4) ; etat : la
|
||||
* clé de son nom, que le composant traduit ; chemin : tel que la façade
|
||||
* l'affiche, en entier ; collisionsCumulees et diffRedondance : celles que
|
||||
* la façade rend dans mesuresDuRetenu, par format, la valeur absente sans
|
||||
* retenu mesurable — la différence se lit telle quelle, jamais refaite ;
|
||||
* meilleure : l'identifiant et les collisions cumulées que la façade rend
|
||||
* dans meilleureDuClassement, par format, la valeur absente l'un et l'autre
|
||||
* sans proposition classée.
|
||||
* l'affiche, en entier.
|
||||
*
|
||||
* Les quatre valeurs de résumé lisent resume, ce que facade.resumer a rendu
|
||||
* pour la ligne : collisionsCumulees et diffRedondance celles de
|
||||
* mesuresDuRetenu, par format, la valeur absente sans retenu mesurable — la
|
||||
* différence se lit telle quelle, jamais refaite ; meilleure, l'identifiant
|
||||
* et les collisions cumulées de meilleureDuClassement, par format, la valeur
|
||||
* absente l'un et l'autre sans proposition classée. Sans resume, la ligne
|
||||
* est enAttente : son résumé se calcule encore, et les quatre valeurs
|
||||
* valent la valeur absente, que le composant remplace par le signe du
|
||||
* chargement.
|
||||
*
|
||||
* @param {Object} ligne LigneListe (A3)
|
||||
* @param {ReturnType<import('../../application/format.js').creerFormat>} format
|
||||
* @param {{mesuresDuRetenu: Object|null, meilleureDuClassement: Object|null}} [resume]
|
||||
* @returns {{nom: string, date: string, etat: string, participants: string,
|
||||
* modification: string, chemin: string, collisionsCumulees: string,
|
||||
* diffRedondance: string, meilleure: {id: string, collisions: string},
|
||||
* modification: string, chemin: string, enAttente: boolean,
|
||||
* collisionsCumulees: string, diffRedondance: string,
|
||||
* meilleure: {id: string, collisions: string},
|
||||
* signalements: Array<{cle: string, details: Object, noms: string[]}>}}
|
||||
*/
|
||||
export function ligneAffichee(ligne, format) {
|
||||
export function ligneAffichee(ligne, format, resume) {
|
||||
return {
|
||||
nom: ligne.nom ?? ligne.base,
|
||||
date: ligne.date === null ? format.nombre(null) : format.date(ligne.date),
|
||||
|
|
@ -160,11 +167,12 @@ export function ligneAffichee(ligne, format) {
|
|||
participants: format.nombre(ligne.participants),
|
||||
modification: ligne.derniereModification === null ? format.nombre(null) : format.horodatage(ligne.derniereModification),
|
||||
chemin: ligne.chemin,
|
||||
collisionsCumulees: format.nombre(ligne.mesuresDuRetenu?.collisionsCumulees ?? null),
|
||||
diffRedondance: format.nombre(ligne.mesuresDuRetenu?.diffRedondance ?? null),
|
||||
enAttente: resume === undefined,
|
||||
collisionsCumulees: format.nombre(resume?.mesuresDuRetenu?.collisionsCumulees ?? null),
|
||||
diffRedondance: format.nombre(resume?.mesuresDuRetenu?.diffRedondance ?? null),
|
||||
meilleure: {
|
||||
id: format.nombre(ligne.meilleureDuClassement?.id ?? null),
|
||||
collisions: format.nombre(ligne.meilleureDuClassement?.collisionsCumulees ?? null),
|
||||
id: format.nombre(resume?.meilleureDuClassement?.id ?? null),
|
||||
collisions: format.nombre(resume?.meilleureDuClassement?.collisionsCumulees ?? null),
|
||||
},
|
||||
signalements: signalementsDe(ligne),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -44,6 +44,10 @@ const VIDE = t('format.vide');
|
|||
|
||||
const RACINE = 'C:\\Epreuve\\Tablees';
|
||||
|
||||
// Les résumés d'une ligne que rend facade.resumer, sans retenu mesurable ni
|
||||
// proposition classée.
|
||||
const AUCUN = Object.freeze({ mesuresDuRetenu: null, meilleureDuClassement: null });
|
||||
|
||||
// Une ligne de la liste telle que la façade la rend (LigneListe, A3), lisible.
|
||||
function ligne(champs = {}) {
|
||||
return {
|
||||
|
|
@ -59,8 +63,6 @@ function ligne(champs = {}) {
|
|||
journalSeul: false,
|
||||
doublonAvec: [],
|
||||
verrou: null,
|
||||
mesuresDuRetenu: null,
|
||||
meilleureDuClassement: null,
|
||||
...champs,
|
||||
};
|
||||
}
|
||||
|
|
@ -73,6 +75,7 @@ describe('ligneAffichee : les six informations (§ 8.1)', () => {
|
|||
'collisionsCumulees',
|
||||
'date',
|
||||
'diffRedondance',
|
||||
'enAttente',
|
||||
'etat',
|
||||
'meilleure',
|
||||
'modification',
|
||||
|
|
@ -320,18 +323,18 @@ describe('saisieDe : la saisie d’un nom pour copier ou renommer (§ 8.7)', ()
|
|||
describe('ligneAffichee : les mesures du retenu (§ 5.4, § 8.1, § 8.9)', () => {
|
||||
test('les collisions cumulées et la différence à la cible, celles de la façade, mises en forme ; aucune ne se calcule ici', () => {
|
||||
const mesuresDuRetenu = { collisionsCumulees: 1234, totalRedondance: 1500, cibleRedondance: 1000, diffRedondance: 500 };
|
||||
const affichee = ligneAffichee(ligne({ mesuresDuRetenu }), format);
|
||||
const affichee = ligneAffichee(ligne(), format, { ...AUCUN, mesuresDuRetenu });
|
||||
assert.equal(affichee.collisionsCumulees, format.nombre(1234));
|
||||
assert.equal(affichee.diffRedondance, format.nombre(500));
|
||||
// La différence se lit telle que la façade la rend, jamais refaite
|
||||
// depuis la cible et la somme.
|
||||
const telleQuelle = ligneAffichee(ligne({ mesuresDuRetenu: { ...mesuresDuRetenu, diffRedondance: 7 } }), format);
|
||||
const telleQuelle = ligneAffichee(ligne(), format, { ...AUCUN, mesuresDuRetenu: { ...mesuresDuRetenu, diffRedondance: 7 } });
|
||||
assert.equal(telleQuelle.diffRedondance, format.nombre(7));
|
||||
});
|
||||
|
||||
test('sans retenu mesurable, et pour une ligne illisible, les deux valent « — »', () => {
|
||||
for (const champs of [{}, { nom: null, etat: null, participants: null, illisible: { raison: 'VIDE', chemin: null } }]) {
|
||||
const affichee = ligneAffichee(ligne(champs), format);
|
||||
const affichee = ligneAffichee(ligne(champs), format, AUCUN);
|
||||
assert.equal(affichee.collisionsCumulees, VIDE);
|
||||
assert.equal(affichee.diffRedondance, VIDE);
|
||||
}
|
||||
|
|
@ -354,6 +357,29 @@ describe('ligneAffichee : les mesures du retenu (§ 5.4, § 8.1, § 8.9)', () =>
|
|||
});
|
||||
});
|
||||
|
||||
describe('ligneAffichee : les résumés en attente (§ 8.1)', () => {
|
||||
test('sans résumé reçu, la ligne est en attente et ses quatre valeurs de résumé ne disent rien ; reçu, elle ne l’est plus', () => {
|
||||
const attente = ligneAffichee(ligne(), format);
|
||||
assert.equal(attente.enAttente, true);
|
||||
assert.equal(attente.collisionsCumulees, VIDE);
|
||||
assert.equal(attente.diffRedondance, VIDE);
|
||||
assert.deepEqual(attente.meilleure, { id: VIDE, collisions: VIDE });
|
||||
assert.equal(ligneAffichee(ligne(), format, AUCUN).enAttente, false);
|
||||
const recu = ligneAffichee(ligne(), format, {
|
||||
mesuresDuRetenu: { collisionsCumulees: 12, totalRedondance: 990, cibleRedondance: 1000, diffRedondance: 10 },
|
||||
meilleureDuClassement: { id: 3, collisionsCumulees: 9 },
|
||||
});
|
||||
assert.equal(recu.enAttente, false);
|
||||
assert.equal(recu.collisionsCumulees, format.nombre(12));
|
||||
assert.deepEqual(recu.meilleure, { id: format.nombre(3), collisions: format.nombre(9) });
|
||||
});
|
||||
|
||||
test('les six informations ne dépendent pas des résumés : en attente ou non, elles sont les mêmes', () => {
|
||||
const sans = ({ enAttente, collisionsCumulees, diffRedondance, meilleure, ...reste }) => reste;
|
||||
assert.deepEqual(sans(ligneAffichee(ligne(), format)), sans(ligneAffichee(ligne(), format, AUCUN)));
|
||||
});
|
||||
});
|
||||
|
||||
describe('ligneAffichee : la meilleure proposition du classement (§ 5.4, § 5.7, § 8.1)', () => {
|
||||
const executer = (charge, nom, args) => appliquerCommande(nom, charge, args).charge;
|
||||
// La charge, une génération du moteur de plus, enregistrée par la commande.
|
||||
|
|
@ -364,13 +390,13 @@ describe('ligneAffichee : la meilleure proposition du classement (§ 5.4, § 5.7
|
|||
}
|
||||
|
||||
test("l'identifiant et les collisions cumulées que rend la façade, mis en forme ; aucun ne se calcule ici", () => {
|
||||
const affichee = ligneAffichee(ligne({ meilleureDuClassement: { id: 1234, collisionsCumulees: 5678 } }), format);
|
||||
const affichee = ligneAffichee(ligne(), format, { ...AUCUN, meilleureDuClassement: { id: 1234, collisionsCumulees: 5678 } });
|
||||
assert.deepEqual(affichee.meilleure, { id: format.nombre(1234), collisions: format.nombre(5678) });
|
||||
});
|
||||
|
||||
test('sans proposition classée, et pour une ligne illisible, les deux valent « — »', () => {
|
||||
for (const champs of [{}, { nom: null, etat: null, participants: null, illisible: { raison: 'VIDE', chemin: null } }]) {
|
||||
assert.deepEqual(ligneAffichee(ligne(champs), format).meilleure, { id: VIDE, collisions: VIDE });
|
||||
assert.deepEqual(ligneAffichee(ligne(champs), format, AUCUN).meilleure, { id: VIDE, collisions: VIDE });
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -396,7 +422,7 @@ describe('ligneAffichee : la meilleure proposition du classement (§ 5.4, § 5.7
|
|||
const charges = [generees, retenue, derivees, analyser(texteLivre('petite')).charge];
|
||||
let classees = 0;
|
||||
for (const charge of charges) {
|
||||
const affichee = ligneAffichee(ligne({ meilleureDuClassement: resumeDuMeilleur(charge) }), format);
|
||||
const affichee = ligneAffichee(ligne(), format, { ...AUCUN, meilleureDuClassement: resumeDuMeilleur(charge) });
|
||||
const bloc = meilleureProposition(charge);
|
||||
if (bloc === null) {
|
||||
assert.deepEqual(affichee.meilleure, { id: VIDE, collisions: VIDE });
|
||||
|
|
|
|||
|
|
@ -57,6 +57,10 @@ export const TABLE = new Map([
|
|||
'configuration.',
|
||||
],
|
||||
|
||||
// L'annonce de la région d'état de la liste tant que le résumé d'une de
|
||||
// ses lignes se calcule (§ 8.1) ; les signes de chaque valeur sont muets.
|
||||
['accueil.chargement', 'Calcul des colonnes de la liste\u{2026}'],
|
||||
|
||||
// Le nom d'un état du plan (§ 9), que rend cleDEtat (modele.js).
|
||||
['accueil.etat.brouillon', 'Brouillon'],
|
||||
['accueil.etat.propose', 'Proposé'],
|
||||
|
|
|
|||
|
|
@ -93,6 +93,9 @@ export const MARQUES = Object.freeze([
|
|||
// chaises.
|
||||
marque('echelle', 'echelle', ['sol', 'page-fond']),
|
||||
marque('fantome', 'fantome', ['sol', 'table-fond', ...REMPLISSAGES_CHAISE]),
|
||||
// Le signe du chargement d'une valeur de la liste des événements (§ 8.1) :
|
||||
// un arc tracé de l'encre secondaire, sur la page.
|
||||
marque('chargement', 'page-texte-secondaire', ['page-fond']),
|
||||
]);
|
||||
|
||||
/** Les textes et leurs fonds, même forme, seuil 4,5 : page, secondaire, bandeau, refus,
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ const NOMS_MARQUES = [
|
|||
'cadenasFerme', 'cadenasOuvert', 'anneau', 'hachure', 'remplissagePlein',
|
||||
'contourConflit', 'trameAppartenance', 'trameVoisins', 'trameTable', 'pastille', 'poignee', 'gel', 'miseEnEvidence',
|
||||
'selection', 'cadreLecture', 'cadreEcriture', 'cadreBloque', 'focus',
|
||||
'contourTable', 'contourChaise', 'echelle', 'fantome',
|
||||
'contourTable', 'contourChaise', 'echelle', 'fantome', 'chargement',
|
||||
];
|
||||
|
||||
// Les remplissages d'une chaise : fond, pleine, et les deux couleurs de la
|
||||
|
|
@ -39,7 +39,7 @@ const FONDS_REQUIS = {
|
|||
const parNom = (registre, nom) => registre.find((e) => e.nom === nom);
|
||||
|
||||
describe('registre des marques', () => {
|
||||
test('les vingt-deux marques du contrat, noms uniques, seuil 3, au moins un fond chacune', () => {
|
||||
test('les vingt-trois marques du contrat, noms uniques, seuil 3, au moins un fond chacune', () => {
|
||||
assert.equal(MARQUES.length, NOMS_MARQUES.length);
|
||||
assert.deepEqual(MARQUES.map((m) => m.nom), NOMS_MARQUES);
|
||||
assert.equal(new Set(MARQUES.map((m) => m.nom)).size, MARQUES.length);
|
||||
|
|
|
|||
|
|
@ -50,15 +50,21 @@ const PUBLIE = 'E:\\soirees-epreuve';
|
|||
// démonstration.
|
||||
const IMAGES_MAX = 900;
|
||||
|
||||
// Images attendues au plus pour qu'un résumé de la liste arrive : bien
|
||||
// au-delà d'une relecture du système simulé et d'un pas par événement, et
|
||||
// sous le délai d'une épreuve, que IMAGES_MAX atteint à 60 images par
|
||||
// seconde : un résumé qui ne vient pas se nomme au lieu d'épuiser ce délai.
|
||||
const IMAGES_RESUME = 300;
|
||||
|
||||
// --- L'attente et les gestes -------------------------------------------------
|
||||
|
||||
const uneImage = () => new Promise((resoudre) => requestAnimationFrame(resoudre));
|
||||
|
||||
/** Attend, image après image, que condition() rende une valeur vraie sans
|
||||
* lever, et la rend ; lève en nommant quoi après IMAGES_MAX images. */
|
||||
export async function attendre(quoi, condition) {
|
||||
* lever, et la rend ; lève en nommant quoi après images images. */
|
||||
export async function attendre(quoi, condition, images = IMAGES_MAX) {
|
||||
let derniere = null;
|
||||
for (let image = 0; image < IMAGES_MAX; image += 1) {
|
||||
for (let image = 0; image < images; image += 1) {
|
||||
flushSync();
|
||||
try {
|
||||
const valeur = condition();
|
||||
|
|
@ -84,6 +90,46 @@ export async function cliquer(m, selecteur) {
|
|||
return bouton;
|
||||
}
|
||||
|
||||
/** Mène le chargement des résumés de la liste à son terme (§ 8.1) : l'horloge
|
||||
* pilotée du montage tient chaque pas du chargement, et avance d'un pas à
|
||||
* chaque image jusqu'à ce qu'aucun signe du chargement ne reste. */
|
||||
export async function chargerResumes(m) {
|
||||
await attendre('les résumés de la liste', () => {
|
||||
if (m.racine.querySelector('[data-chargement]') === null) return true;
|
||||
m.horloge.avancer(0);
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
/** Suit les résumés que la liste du montage reçoit à partir de cet appel :
|
||||
* chaque flux que l'application lance par m.facade.resumer rapporte, en
|
||||
* plus, chaque base dont il pose le résumé. Rend resumeRecu(base), qui
|
||||
* attend, l'horloge pilotée avancée d'un pas à chaque image, que la liste
|
||||
* ait reçu un résumé de base depuis le suivi, le rendu à jour : un résumé
|
||||
* posé avant le suivi, d'une liste plus ancienne, n'y répond pas. */
|
||||
export function suivreResumes(m) {
|
||||
const recues = new Set();
|
||||
const resumer = m.facade.resumer;
|
||||
m.facade.resumer = (lues, options) =>
|
||||
resumer(lues, {
|
||||
...options,
|
||||
recevoir(base, resume) {
|
||||
options.recevoir(base, resume);
|
||||
recues.add(base);
|
||||
},
|
||||
});
|
||||
return (base) =>
|
||||
attendre(
|
||||
`le résumé de ${base} reçu par la liste`,
|
||||
() => {
|
||||
if (recues.has(base)) return true;
|
||||
m.horloge.avancer(0);
|
||||
return false;
|
||||
},
|
||||
IMAGES_RESUME,
|
||||
);
|
||||
}
|
||||
|
||||
/** Le centre de la boîte d'un élément, en coordonnées de la fenêtre, une
|
||||
* fois l'élément amené dans la fenêtre. */
|
||||
export async function centre(cible) {
|
||||
|
|
@ -294,6 +340,23 @@ export const ECRANS = Object.freeze([
|
|||
async atteindre(contexte) {
|
||||
const m = await contexte.monter({ fichiers: [fichierEtat(chargePrincipale()), fichierEtat(chargeGelee(), 'gelee')] });
|
||||
await element(m, `[data-evenement="${BASE}"]`);
|
||||
await chargerResumes(m);
|
||||
return m.racine;
|
||||
},
|
||||
},
|
||||
{
|
||||
nom: 'accueil.chargement',
|
||||
mode: null,
|
||||
async atteindre(contexte) {
|
||||
// La liste paraît avant ses résumés : tant que l'horloge pilotée
|
||||
// n'avance pas, chaque valeur de résumé porte le signe du chargement.
|
||||
// Rien ici ne l'avance : les signes restent pendant les relevés des
|
||||
// deux thèmes, où le trait de chacun se mesure comme la marque
|
||||
// « chargement ».
|
||||
const m = await contexte.monter({ fichiers: [fichierEtat(chargePrincipale()), fichierEtat(chargeGelee(), 'gelee')] });
|
||||
await attendre('le signe du chargement dans chaque valeur de résumé', () =>
|
||||
[BASE, 'gelee'].every((base) => m.racine.querySelectorAll(`[data-evenement="${base}"] [aria-busy="true"] [data-chargement]`).length === 4),
|
||||
);
|
||||
return m.racine;
|
||||
},
|
||||
},
|
||||
|
|
@ -308,6 +371,7 @@ export const ECRANS = Object.freeze([
|
|||
const charge = executer(principale, 'retenirProposition', { id: principale.propositions[0].id });
|
||||
const m = await contexte.monter({ fichiers: [fichierEtat(charge)] });
|
||||
const ligne = `[data-evenement="${BASE}"]`;
|
||||
await chargerResumes(m);
|
||||
await attendre('les mesures du retenu et la meilleure proposition', () =>
|
||||
[
|
||||
...['collisionsCumulees', 'diffRedondance'].map((colonne) => `[data-colonne="${colonne}"]`),
|
||||
|
|
|
|||
|
|
@ -730,18 +730,44 @@ function nomDeDirective(noeud) {
|
|||
// Déclarations d'animation admises : celles qui l'éteignent.
|
||||
const ANIMATION_ETEINTE = /^\s*(?:none|0m?s)\s*$/i;
|
||||
|
||||
// Les animations admises, chacune nommée par son fichier, le sélecteur de sa
|
||||
// règle et le nom de ses images clés, avec sa raison. Une exception admet,
|
||||
// dans son seul fichier, les déclarations animation… de sa règle et les
|
||||
// @keyframes de son nom ; toute autre animation du fichier, et toute
|
||||
// transition, restent relevées. La règle admise s'éteint sous
|
||||
// prefers-reduced-motion: reduce, sinon elle se relève ; la feuille qui
|
||||
// annule transitions et animations (§ 19.2) l'éteint comme toute autre. Une
|
||||
// exception que rien ne sert se relève « sans objet ».
|
||||
const ANIMATIONS_ADMISES = [
|
||||
{
|
||||
fichier: 'src/interface/accueil/Chargement.svelte',
|
||||
selecteur: '.rouet',
|
||||
images: 'rotation',
|
||||
raison:
|
||||
"le signe du chargement des résumés de la liste (§ 8.1) tourne pour dire qu'un calcul se poursuit : aucun état " +
|
||||
"n'en dépend ni n'attend sa fin, et rien ne lit son rendu",
|
||||
},
|
||||
];
|
||||
|
||||
// Vrai sous une règle @media qui demande le mouvement réduit.
|
||||
const sousMouvementReduit = (ancetres) =>
|
||||
ancetres.some((a) => a.type === 'Atrule' && a.name === 'media' && /prefers-reduced-motion\s*:\s*reduce/.test(a.prelude));
|
||||
|
||||
// Relevé de textLength et lengthAdjust sous src/interface et src/geometrie, de
|
||||
// transitionend et animationend sous src/interface, et de toute transition
|
||||
// ou animation de l'interface : déclaration transition…, animation… ou
|
||||
// @keyframes d'une feuille, directive transition:, in:, out: ou animate:
|
||||
// d'un composant. Un état qui attendrait la fin d'une transition ne
|
||||
// s'atteindrait plus sous la feuille qui les annule (§ 19.2).
|
||||
function releverTexteEtAnimation(racine) {
|
||||
// d'un composant —, hors des animations admises (exceptions), suivi d'une
|
||||
// ligne « exception sans objet » par exception que rien ne sert. Un état qui
|
||||
// attendrait la fin d'une transition ne s'atteindrait plus sous la feuille
|
||||
// qui les annule (§ 19.2).
|
||||
function releverTexteEtAnimation(racine, exceptions = ANIMATIONS_ADMISES) {
|
||||
const chemins = exigerNonVide(
|
||||
sources(racine, ['src/interface', 'src/geometrie'], ['.svelte', '.js', '.css']),
|
||||
'texte et animation',
|
||||
);
|
||||
const releves = [];
|
||||
const servies = new Set();
|
||||
for (const chemin of chemins) {
|
||||
const interface_ = chemin.startsWith('src/interface/');
|
||||
const motifs = interface_ ? ['textLength', 'lengthAdjust', 'transitionend', 'animationend'] : ['textLength', 'lengthAdjust'];
|
||||
|
|
@ -768,15 +794,38 @@ function releverTexteEtAnimation(racine) {
|
|||
const directive = nomDeDirective(noeud);
|
||||
if (directive !== null) relever(noeud.start, `directive ${directive}:${noeud.name}`);
|
||||
});
|
||||
parcourir(css, (noeud) => {
|
||||
// L'exception de ce fichier, la première déclaration qu'elle admet, et
|
||||
// vrai quand sa règle s'éteint sous le mouvement réduit.
|
||||
const admise = exceptions.find(({ fichier }) => fichier === chemin) ?? null;
|
||||
let premiereAdmise = null;
|
||||
let arretee = false;
|
||||
parcourir(css, (noeud, ancetres) => {
|
||||
const anime = noeud.type === 'Declaration' && /^(?:transition|animation)(?:-|$)/.test(noeud.property);
|
||||
const regle = ancetres.findLast((a) => a.type === 'Rule');
|
||||
const selecteur = regle === undefined ? '' : texte.slice(regle.prelude.start, regle.prelude.end).trim();
|
||||
const deLAdmise = anime && admise !== null && selecteur === admise.selecteur && noeud.property.startsWith('animation');
|
||||
if (anime && !ANIMATION_ETEINTE.test(noeud.value)) {
|
||||
relever(noeud.start, `${noeud.property}: ${noeud.value}`);
|
||||
if (deLAdmise) {
|
||||
servies.add(admise);
|
||||
premiereAdmise ??= noeud.start;
|
||||
} else {
|
||||
relever(noeud.start, `${noeud.property}: ${noeud.value}`);
|
||||
}
|
||||
}
|
||||
if (deLAdmise && ANIMATION_ETEINTE.test(noeud.value) && sousMouvementReduit(ancetres)) arretee = true;
|
||||
if (noeud.type === 'Atrule' && /keyframes$/i.test(noeud.name)) {
|
||||
if (admise !== null && noeud.prelude.trim() === admise.images) servies.add(admise);
|
||||
else relever(noeud.start, `@${noeud.name}`);
|
||||
}
|
||||
if (noeud.type === 'Atrule' && /keyframes$/i.test(noeud.name)) relever(noeud.start, `@${noeud.name}`);
|
||||
});
|
||||
if (premiereAdmise !== null && !arretee) {
|
||||
relever(premiereAdmise, `animation admise sans arrêt sous prefers-reduced-motion : ${admise.selecteur}`);
|
||||
}
|
||||
}
|
||||
return trier(releves);
|
||||
const sansObjet = exceptions
|
||||
.filter((exception) => !servies.has(exception))
|
||||
.map(({ fichier, selecteur }) => `exception sans objet : ${fichier} ${selecteur}`);
|
||||
return [...trier(releves), ...sansObjet];
|
||||
}
|
||||
|
||||
// --- Garde 7 : le câblage (§ 12.9, § 13.3) ------------------------------------
|
||||
|
|
@ -1289,7 +1338,7 @@ describe('sources de l’interface : texte comprimé et animations (§ 7.4, § 1
|
|||
'src/application/c.js': "export const f = 'textLength';\n",
|
||||
'src/interface/A.navigateur.test.js': "export const f = '[textLength], [lengthAdjust]';\n",
|
||||
};
|
||||
assert.deepEqual(avecArbre(fichiers, releverTexteEtAnimation), [
|
||||
assert.deepEqual(avecArbre(fichiers, (racine) => releverTexteEtAnimation(racine, [])), [
|
||||
'src/geometrie/texte.js:1 textLength',
|
||||
'src/geometrie/texte.js:4 textLength',
|
||||
'src/interface/b.js:1 animationend',
|
||||
|
|
@ -1307,6 +1356,45 @@ describe('sources de l’interface : texte comprimé et animations (§ 7.4, § 1
|
|||
test('un arbre sans source fait échouer la garde', () => {
|
||||
assert.throws(() => avecArbre({ 'src/application/a.js': '' }, releverTexteEtAnimation), /aucun fichier examiné/);
|
||||
});
|
||||
|
||||
test('l’animation admise : celle du signe du chargement, par son fichier et son sélecteur, arrêtée sous prefers-reduced-motion', () => {
|
||||
assert.equal(ANIMATIONS_ADMISES.length, 1);
|
||||
const [admise] = ANIMATIONS_ADMISES;
|
||||
assert.equal(admise.fichier, 'src/interface/accueil/Chargement.svelte');
|
||||
assert.ok(admise.raison.length > 0);
|
||||
const tourne = ` ${admise.selecteur} { animation: ${admise.images} 1s linear infinite; }`;
|
||||
const images = ` @keyframes ${admise.images} { to { transform: rotate(1turn); } }`;
|
||||
const arret = ` @media (prefers-reduced-motion: reduce) { ${admise.selecteur} { animation: none; } }`;
|
||||
const style = (...regles) => composant({ gabarit: '<svg></svg>', style: regles.join('\n') });
|
||||
const ailleurs = 'src/interface/accueil/Autre.svelte';
|
||||
// Le fichier admis, sa règle, ses images et son arrêt : rien à relever.
|
||||
assert.deepEqual(avecArbre({ [admise.fichier]: style(tourne, images, arret) }, releverTexteEtAnimation), []);
|
||||
// Une autre règle du même fichier, une transition de la règle admise, et
|
||||
// la même règle dans un autre fichier restent relevées.
|
||||
assert.deepEqual(
|
||||
avecArbre(
|
||||
{
|
||||
[admise.fichier]: style(tourne, images, arret, ' .autre { animation: clignote 1s; }', ` ${admise.selecteur} { transition: transform 1s; }`),
|
||||
[ailleurs]: style(tourne, images),
|
||||
},
|
||||
releverTexteEtAnimation,
|
||||
),
|
||||
[
|
||||
`${ailleurs}:3 animation: ${admise.images} 1s linear infinite`,
|
||||
`${ailleurs}:4 @keyframes`,
|
||||
`${admise.fichier}:6 animation: clignote 1s`,
|
||||
`${admise.fichier}:7 transition: transform 1s`,
|
||||
],
|
||||
);
|
||||
// Sans son arrêt sous prefers-reduced-motion, l'animation admise se relève.
|
||||
assert.deepEqual(avecArbre({ [admise.fichier]: style(tourne, images) }, releverTexteEtAnimation), [
|
||||
`${admise.fichier}:3 animation admise sans arrêt sous prefers-reduced-motion : ${admise.selecteur}`,
|
||||
]);
|
||||
// Un arbre qui ne la porte plus : l'exception est sans objet.
|
||||
assert.deepEqual(avecArbre({ [ailleurs]: style() }, releverTexteEtAnimation), [
|
||||
`exception sans objet : ${admise.fichier} ${admise.selecteur}`,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sources de l’interface : le câblage (§ 12.9, § 13.3)', () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue