[ADD] quality page: walkthrough captures, edge cases, contrast guards

The quality page is now photographed and guarded like the other
screens. The walkthrough captures each view and the seven edge cases
where a sentence replaces a chart; the transversal guards measure the
page's marks, keyboard access and tooltips, a dotted reference line
against the chart background and a run point against what its trace
covers. Proved and remaining shares differ by outline, not colour
alone; tooltips keep clear of the sticky banner; rank axes print whole
numbers. The two bench thresholds of the page are measured.
Checked: node 5096, navigateur 1137, node-long 128; captures published.

--- FR ---

[ADD] page de qualité : parcours, cas limites, gardes du contraste

La page de qualité est désormais photographiée et gardée comme les autres
écrans. Le parcours photographie chaque vue et les sept cas limites où une
phrase remplace un graphique ; les gardes transversales mesurent ses
marques, son clavier et ses infobulles, un repère pointillé contre le fond
du graphique, un point de relevé contre ce que son tracé recouvre. Prouvé
et reste se distinguent par le contour, pas par la seule couleur ; les
infobulles évitent le bandeau collant ; les rangs s'écrivent en entiers.
Les deux seuils de la page sont mesurés au banc.
Vérifié : node 5096, navigateur 1137, longues 128 ; captures publiées.

Assisted-by: Claude Opus 5.5
This commit is contained in:
Mathieu Benoit 2026-10-12 16:38:04 -04:00
parent 01c9296c73
commit 91a0cea4cb
42 changed files with 4271 additions and 1191 deletions

View file

@ -384,6 +384,178 @@ redondance d’appartenance à la cible de la soirée.
Retenir n’est pas obligatoire, mais tout ce qui s’imprime sans placement retenu
porte la mention BROUILLON (étape 12).
### La page « Qualité »
Avant de retenir, la section « Qualité » dit si un placement est bon, et qui
y est mal servi. La commande « Qualité » de chaque ligne du tableau classé
montre la proposition sur le plan et ouvre la page ; sans proposition
montrée, la page mesure le placement retenu. Sa première ligne dit toujours
quel placement elle mesure. Regarder la page n’écrit rien : elle se lit en
lecture comme en écriture.
Chaque graphique a son tableau jumeau, que « Voir le tableau » ouvre : les
mêmes nombres, ligne par ligne, au clavier et au lecteur d’écran. Un
pourcentage est toujours suivi du compte qu’il résume : le pire cas d’une
population, ou le compte de sa ligne.
Les écrans de la grande démonstration viennent d’une génération faite avec
la graine 4242 et un compte d’arrêt de 2 000 ; ceux de la petite
démonstration, avec la graine 4242 et le compte d’arrêt par défaut ; les
autres, avec la graine 4242 et un compte d’arrêt de 300.
![Le profil trié des rencontres de la grande démonstration, en comptes puis en pourcentages](doc/captures/12-qualite-profil.png)
*Le profil trié de la première proposition de la grande démonstration. En
haut, en comptes de personnes : les rencontres de chacun en trait plein, de
la personne la moins servie, à gauche, à la mieux servie ; les appartenances
rencontrées en tirets ; le plafond réalisé en trait mixte et la borne de
diversité en pointillés, chacun nommé au bout de son tracé. Les triangles
marquent les personnes ancrées par une réservation. Dessous, au même ordre
de rangs, les rencontres de chacun en pourcentage de son plafond réalisé,
sous la règle à 100 % ; le bas de ce second graphique sort de l’image.
Le pire cas est au premier rang des deux graphiques.*
![Les collisions d’appartenance de la variante « conflit inévitable », en deux segments](doc/captures/13-qualite-collisions.png)
*Les collisions d’appartenance de la variante « conflit inévitable » : une
barre par appartenance, le plus grand excédent d’abord. Pour
l’appartenance de cinq personnes, à quatre tables, le segment gris « prouvé
inévitable » vaut 4 collisions cumulées, le plancher que le diagnostic
annonce ; le segment bleu « reste » est ce que la recherche aurait pu
éviter. L’effectif, les paires distinctes et l’excédent suivent en encre
secondaire.*
![Les appartenances à chaque table, sous « appartenances par siège »](doc/captures/13a-qualite-tables.png)
*Les appartenances à chaque table, même proposition. Le sélecteur est sur
« appartenances par siège » : une ligne par table, une colonne par tour, et
chaque case écrit le nombre de sièges qui n’apportent aucune appartenance
nouvelle à leur table ; sa couleur fonce avec lui. La pire table vient en
tête, une table qui n’en a aucun à aucun tour, en dernier. Les deux autres
positions comptent les personnes assises, puis les doublons parmi les
personnes d’une appartenance ; le choix tient le temps de la séance, et
n’écrit rien.*
![Le tableau des paires répétées d’une soirée où se revoir est forcé](doc/captures/14-qualite-paires.png)
*Les paires répétées d’une soirée construite de 12 personnes, 4 tables de
3 places et 6 tours, où se revoir est forcé : les paires les plus revues
d’abord. « Voir au tour … » montre le plan au premier tour où la paire se
revoit ; chaque tour et sa table sont aussi une commande vers le plan.*
![La fiche d’une personne et sa rubrique « Rencontres »](doc/captures/14a-fiche-rencontres.png)
*La fiche d’une personne de la même soirée, sa rubrique « Rencontres » : qui
elle a rencontré, combien de fois, à quels tours et à quelles tables, les
plus revues d’abord. Ses rencontres de plus d’une fois sont ses lignes du
tableau des paires.*
![La matrice « qui a rencontré qui », remplacée par une phrase sur la grande démonstration](doc/captures/15-pas-de-matrice.png)
*Sur la grande démonstration, la matrice « qui a rencontré qui » n’est pas
dessinée : 260 personnes dépassent son seuil, et la phrase renvoie à la
fiche de chaque personne. Au-dessus, la fin du tableau des paires
répétées.*
![Les objectifs des tables, tour par tour](doc/captures/15a-qualite-objectifs.png)
*Les objectifs des tables de la « Soirée d’essai », changée depuis
l’étape 9 et générée de nouveau : d’abord les personnes jamais servies —
aucune ici —, puis la soirée en texte, sa part des tours possibles servis
et, dessous, le compte des jamais servies ; puis une barre par tour, le tour
au plus grand reste en tête, chacune avec ses assis sans table possible en
encre secondaire ; enfin chaque tour, son pourcentage et ses deux comptes.
En haut à droite, un avertissement rappelle que le placement retenu et les
propositions d’avant le changement ne décrivent plus la soirée : ils sont
gardés.*
![Les animateurs rencontrés sous la borne des animateurs, puis la diversité par animateur](doc/captures/15b-qualite-animateurs.png)
*Les animateurs rencontrés sur la grande démonstration : chaque personne
concernée, de la moins servie à la mieux servie, sous la borne des
animateurs en pointillés — ce que la soirée permet au mieux, sans garantie.
Dessous commence la diversité par animateur : aucune personne présente ne
porte d’étiquette, et seule la colonne des appartenances se dessine.*
![La performance du calcul : la course de la recherche aux points de relevé](doc/captures/15c-qualite-performance.png)
*La performance du calcul de la proposition montrée, dans la séance qui l’a
générée : les mouvements évalués sur le compte d’arrêt, puis la durée
observée, les cœurs, la cadence et le nombre de propositions de la
génération entière. Chaque petit multiple porte une valeur aux seuls points
de relevé — après 1, 10, 100 et 1 000 mouvements, puis au dernier, la
proposition elle-même —, sur une abscisse logarithmique ; entre deux
points, rien n’est mesuré. Ici, plusieurs valeurs changent encore au
dernier point ; la phrase qui nomme le dernier changement suit les petits
multiples, plus bas, hors de l’image. Rouvert plus tard, l’événement garde
la course, et la durée devient « inconnue » : elle ne valait que pour la
séance.*
![« Minimum atteint » sur la petite démonstration](doc/captures/16-minimum-atteint.png)
*Le bas de l’en-tête chiffré, sur la petite démonstration générée au compte
d’arrêt par défaut : la diversité d’appartenance, aucun manque, et « minimum
atteint ». Plus haut, hors de l’image, les collisions cumulées et les
rencontres répétées valent 0, et la ligne des ancrés écrit « — » : la
démonstration n’a aucune réservation, et une population vide n’a pas de
minimum.*
#### Quand il n’y a rien à dessiner
La page ne dessine jamais d’axes vides : un cas qui n’a rien à montrer le
dit par une phrase. Chacun des sept écrans suivants vient de sa propre
configuration : la variante « conflit inévitable », ou une courte soirée
construite à la main.
![La page sans placement : sa phrase et le diagnostic](doc/captures/12a-qualite-sans-placement.png)
*Aucun placement : la variante « conflit inévitable », avant toute
génération. La page dit qu’il n’y a rien à mesurer, et montre le diagnostic
préalable : au moins 4 collisions cumulées pour l’appartenance de cinq
personnes, à quatre tables.*
![Une seule table : une phrase à la place de chaque vue](doc/captures/12b-qualite-une-table.png)
*Une seule table de six places, six personnes, trois tours : chacun y
rencontre tous les autres. Le profil, les collisions, les appartenances à
chaque table et les paires cèdent chacun la place à sa phrase.*
![Un seul tour : la grille d’une colonne, la phrase des paires](doc/captures/12c-qualite-un-tour.png)
*Un seul tour, douze personnes à trois tables de quatre : le profil et les
collisions se dessinent plus haut, hors de l’image ; la vue des tables, son
sélecteur sur « appartenances par siège », n’a qu’une colonne, et aucune
paire ne peut se revoir.*
![Une seule appartenance : le profil à une seule série](doc/captures/12d-qualite-une-appartenance.png)
*Douze personnes d’une même appartenance : le profil n’a qu’une série, les
rencontres, sans borne de diversité, et sa phrase le dit. Les collisions et
la grille des tables restent, plus bas.*
![Aucune collision : la phrase des collisions, la grille à zéro](doc/captures/12e-qualite-sans-collision.png)
*Neuf personnes de neuf appartenances, trois tables de trois, deux tours :
aucune collision, et la phrase le dit. La grille, sous « appartenances par
siège », écrit 0 dans chaque case.*
![Une personne en réserve : sa marque dans le profil](doc/captures/12f-qualite-reserve.png)
*La variante, sa première proposition retenue, puis une personne retirée du
tour 2 sur le plan : le carré la marque, dans les deux graphiques du
profil, en réserve à un tour au moins. Plus bas, hors de l’image, les
collisions n’ont plus qu’un segment : le plancher du diagnostic ne vaut que
pour un plan qui assoit chacun à chaque tour, et une phrase le dit.*
![Un plafond réalisé nul au premier rang du profil, son infobulle ouverte](doc/captures/12g-qualite-plafond-nul.png)
*Cinq personnes, une table de quatre et une de deux, deux tours ; quatre
sont réservées à la table de quatre. La cinquième, assise seule à la table
de deux, ne peut rencontrer personne : son plafond réalisé est nul. Au
premier rang du profil, son infobulle écrit le plafond réalisé 0 et le
pourcentage « — » ; au second graphique, plus bas, ce rang n’a pas de
point.*
## Étape 11 — Ajuster à la main
Sur le placement retenu, le geste de l’étape 7 déplace maintenant une personne
@ -496,7 +668,10 @@ même plan diffèrent, et pourquoi relancer aide parfois, et parfois jamais.
Asseoir chacun, à chaque tour, de sorte que la personne la plus mal servie
rencontre le plus de monde possible. C’est l’objectif de l’ordre par défaut ;
vous pouvez en ranger d’autres devant, dans l’ordre des priorités de la
section « Génération ».
section « Génération ». Le profil trié de la page « Qualité » range les
personnes de la moins servie à la mieux servie
([le profil trié](doc/captures/12-qualite-profil.png)) : la plus mal servie
est au premier rang.
### Pourquoi il n’a pas de réponse évidente
@ -522,6 +697,16 @@ n’est « le bon ». La même graine, sur la même configuration et les mêmes
réglages, redonne exactement les mêmes propositions ; une autre graine en donne
d’autres. Cette phrase est vérifiée par une épreuve du logiciel.
La page « Qualité » montre la course d’une recherche
([la performance du calcul](doc/captures/15c-qualite-performance.png)) :
chaque valeur de la proposition, mesurée sur le meilleur placement trouvé
après un, dix, cent, mille mouvements, et ainsi de suite, puis au dernier.
Quand plus rien ne change pendant les derniers relevés, la recherche est sur
un plateau, et relancer plus longtemps la même graine a peu de chances
d’aider ; quand une valeur change encore au dernier point, comme sur cet
écran, un compte d’arrêt plus grand peut faire mieux. La page ne dit rien
des mouvements que la recherche n’a pas essayés.
### Ce que font les contraintes
« Séparer les appartenances », « Nouveaux voisins à chaque tour », « Nouvelle
@ -553,6 +738,10 @@ jamais : son diagnostic prouve, avant toute recherche, que des personnes
finiront sous leur plafond a priori — c’est le plancher de l’écart
d’itinéraire.
La page « Qualité » l’écrit sous son en-tête chiffré
([« minimum atteint » sur la petite démonstration](doc/captures/16-minimum-atteint.png)),
avec les compteurs mesurés de la proposition.
Son absence ne dit pas qu’un meilleur placement existe. « Atteignabilité
inconnue » dit que le logiciel ne sait pas si mieux est possible.
@ -593,7 +782,8 @@ atteignent le même minimum, c’est le plus souvent qu’une borne le tient, et
relancer n’y changera rien. C’est **un indice, pas une preuve**. Le plafond a
priori, que le diagnostic affiche, le dit d’un coup d’œil : quand le minimum
d’une proposition l’égale, c’est la borne qui le tient, et aucune relance ne
fera mieux.
fera mieux. Sur la page « Qualité », la tuile du minimum porte ce plafond à
côté du minimum, pour la personne au premier rang du profil.
## Annexe A — Les réglages et leurs valeurs par défaut

View file

@ -81,10 +81,11 @@ sur les captures.
| Jeton | Rôle | Seuil | Thème clair | Capture | Thème sombre | Capture |
|---|---|---|---|---|---|---|
| `--couleur-action-encre` | texte | 4,5 | 8,29 | 14-qualite-paires | 9,62 | 14-qualite-paires |
| `--couleur-action-texte` | texte | 4,5 | — | — | 8,45 | 01-premier-ecran |
| `--couleur-avis` | texte | 4,5 | 6,58 | 03-import-rapport | 8,93 | 03-import-rapport |
| `--couleur-bandeau-fond` | texte | 4,5 | 15,94 | 01-premier-ecran | 12,84 | 01-premier-ecran |
| `--couleur-bandeau-texte` | texte | 4,5 | 6,09 | 01-premier-ecran | 12,84 | 01-premier-ecran |
| `--couleur-bandeau-texte` | texte | 4,5 | 5,85 | 13a-qualite-tables | 12,84 | 01-premier-ecran |
| `--couleur-bandeau-texte-secondaire` | texte | 4,5 | 9,05 | 01-premier-ecran | 7,60 | 01-premier-ecran |
| `--couleur-chaise-pleine` | marque | 3 | 15,09 | 07a-cibles-sieges | 14,94 | 07a-cibles-sieges |
| `--couleur-chaise-trait` | marque | 3 | 7,44 | 07-plan-tables | 8,13 | 22-plan-brouillon |
@ -93,12 +94,25 @@ sur les captures.
| `--couleur-echelle` | marque | 3 | 14,82 | 07-plan-tables | 14,62 | 07-plan-tables |
| `--couleur-fantome` | marque | 3 | 4,34 | 19-ajustement | 4,10 | 19-ajustement |
| `--couleur-filigrane` | marque | 3 | 5,10 | 22-plan-brouillon | 5,10 | 22-plan-brouillon |
| `--couleur-graphique-axe` | marque | 3 | 5,64 | 12-qualite-profil | 5,38 | 12-qualite-profil |
| `--couleur-graphique-bordure-case` | marque | 3 | 4,78 | 12c-qualite-un-tour | 5,38 | 12c-qualite-un-tour |
| `--couleur-graphique-borne` | marque | 3 | 3,93 | 12g-qualite-plafond-nul | 4,96 | 12g-qualite-plafond-nul |
| `--couleur-graphique-encre-claire` | texte | 4,5 | — | — | 6,03 | 13a-qualite-tables |
| `--couleur-graphique-encre-foncee` | texte | 4,5 | 5,43 | 12c-qualite-un-tour | — | — |
| `--couleur-graphique-marque` | marque | 3 | 9,67 | 12-qualite-profil | 10,64 | 12-qualite-profil |
| `--couleur-graphique-niveau-1` | marque | 3 | 3,46 | 12c-qualite-un-tour | 3,31 | 12c-qualite-un-tour |
| `--couleur-graphique-niveau-2` | marque | 3 | 5,85 | 13a-qualite-tables | 5,35 | 13a-qualite-tables |
| `--couleur-graphique-prouve` | marque | 3 | 4,23 | 13-qualite-collisions | 5,12 | 13-qualite-collisions |
| `--couleur-graphique-repere` | marque | 3 | 6,15 | 12-qualite-profil | 7,01 | 12-qualite-profil |
| `--couleur-graphique-reste` | marque | 3 | 5,10 | 13-qualite-collisions | 6,89 | 13-qualite-collisions |
| `--couleur-graphique-serie-1` | marque | 3 | 5,10 | 12-qualite-profil | 6,89 | 12-qualite-profil |
| `--couleur-graphique-serie-2` | marque | 3 | 4,53 | 12g-qualite-plafond-nul | 6,29 | 12g-qualite-plafond-nul |
| `--couleur-hachure` | marque | 3 | 16,48 | 08-animateurs-designes | 15,21 | 08-animateurs-designes |
| `--couleur-liste-texte` | texte | 4,5 | 14,82 | 07-plan-tables | — | — |
| `--couleur-marque` | marque | 3 | 3,63 | 07a-cibles-sieges | 3,50 | 07a-cibles-sieges |
| `--couleur-page-texte` | texte | 4,5 | 13,78 | 01b-passer-en-ecriture | 11,02 | 11-propositions |
| `--couleur-page-texte-secondaire` | texte | 4,5 | 5,57 | 01-premier-ecran | 6,72 | 01b-passer-en-ecriture |
| `--couleur-papier` | texte | 4,5 | — | — | 13,44 | 22-plan-brouillon |
| `--couleur-papier` | texte | 4,5 | — | — | 5,00 | 12c-qualite-un-tour |
| `--couleur-pastille-fond` | marque | 3 | 13,44 | 19-ajustement | 11,62 | 19-ajustement |
| `--couleur-pastille-texte` | texte | 4,5 | — | — | 14,51 | 19-ajustement |
| `--couleur-poignee` | marque | 3 | 7,44 | 07-plan-tables | 11,32 | 07-plan-tables |
@ -139,9 +153,9 @@ l'exécution elle-même —, son statut et la façon de le relever. Une valeur a
| `MARGE_PAGE_MM` | marge de chaque bord d'une page imprimée par l'aperçu | 10 mm | défaut de travail | manuel | marge non imprimable mesurée sur chaque imprimante visée (une page de filets à 1 mm d'écart, lue à la règle) ; la plus grande, écrite dans ce registre | — | imprimantes visées, chaque papier des sorties |
| `HAUTEUR_BANDE_IMPRESSION` | hauteur de ce que porte une feuille imprimée hors de sa zone de contenu : pied, mentions, barre, cartouche | relevée à l’exécution | mesurée | exécution | mesurée à l'ouverture de l'aperçu : tout ce qu'une feuille porte hors de [data-contenu] ; aucune valeur n'est écrite dans ce registre | — | rendu de l'aperçu, relevé à chaque ouverture |
| `TOLERANCE_CHAINE_TEMOIN` | écart relatif admis entre la longueur rendue de la chaîne témoin et celle que donnent les avances de la police | 0,011 rapport | mesurée | banc | banc de mesure : le quart de l'écart relatif entre la police embarquée et une famille de repli forcée, et au moins trois fois la gigue de dix mesures, chaîne témoin à 16 px, crénage et ligatures coupés ; arrondi par excès à deux chiffres significatifs, écrit dans ce registre | 0,011 rapport | cadre de 1 366 × 700 px, facteur 1, Chromium 153.0.8010.12, Node v26.10.0, 16 processeurs logiques, processeur non ralenti, police Noto Sans |
| `RALENTISSEMENT_PLEINE_CHARGE` | ralentissement d'une génération quand tous les processeurs logiques travaillent, t(L) / t(1) | 2 × | défaut de travail | banc | banc de mesure : durée d'une même génération sur un travailleur, puis sur L travailleurs ; le rapport ρ des médianes, écrit dans ce registre | 2,79 × | cadre de 1 366 × 700 px, facteur 1, Chromium 153.0.8010.12, Node v26.10.0, 16 processeurs logiques, processeur non ralenti, police Noto Sans |
| `SEUIL_MATRICE_PARTICIPANTS` | nombre de participants au-delà duquel une case de la matrice « qui a rencontré qui » tombe sous la cible de 24 px dans la largeur de la carte, au cadre fixé | 40 participants | défaut de travail | banc | banc de mesure, cadre de 1 366 × 700 px : le plus grand nombre de participants dont la case de la matrice tient 24 px dans la largeur de la carte de la vue, noms en tête compris, relevé sur le rendu ; la valeur proposée, adoptée à la main dans ce registre | non relevée | cadre de 1 366 × 700 px, facteur 1, Chromium 153.0.8010.12, Node v26.10.0, 16 processeurs logiques, processeur ralenti ×4, police Noto Sans |
| `LARGEUR_TRONCATURE_NOM_PX` | largeur à laquelle un nom se tronque dans une étiquette de graphique | 180 px | défaut de travail | banc | banc de mesure, police Noto Sans : la largeur rendue d'un nom de 14 caractères, chacun le glyphe le plus large de l'alphabet visé, au corps des étiquettes de graphique, dans la marge gauche d'une vue à barres — jamais un nom de démonstration ; arrondie au pixel supérieur, adoptée à la main dans ce registre | non relevée | cadre de 1 366 × 700 px, facteur 1, Chromium 153.0.8010.12, Node v26.10.0, 16 processeurs logiques, processeur ralenti ×4, police Noto Sans |
| `RALENTISSEMENT_PLEINE_CHARGE` | ralentissement d'une génération quand tous les processeurs logiques travaillent, t(L) / t(1) | 2 × | défaut de travail | banc | banc de mesure : durée d'une même génération sur un travailleur, puis sur L travailleurs ; le rapport ρ des médianes, écrit dans ce registre | 2,98 × | cadre de 1 366 × 700 px, facteur 1, Chromium 153.0.8010.12, Node v26.10.0, 16 processeurs logiques, processeur non ralenti, police Noto Sans |
| `SEUIL_MATRICE_PARTICIPANTS` | nombre de participants au-delà duquel une case de la matrice « qui a rencontré qui » tombe sous la cible de 24 px dans la largeur de la carte, au cadre fixé | 40 participants | défaut de travail | banc | banc de mesure, cadre de 1 366 × 700 px : le plus grand nombre de participants dont la case de la matrice tient 24 px dans la largeur de la carte de la vue, noms en tête compris, relevé sur le rendu ; la valeur proposée, adoptée à la main dans ce registre | 44 participants | cadre de 1 366 × 700 px, facteur 1, Chromium 153.0.8010.12, Node v26.10.0, 16 processeurs logiques, processeur non ralenti, police Noto Sans |
| `LARGEUR_TRONCATURE_NOM_PX` | largeur à laquelle un nom se tronque dans une étiquette de graphique | 180 px | défaut de travail | banc | banc de mesure, police Noto Sans : la largeur rendue d'un nom de 14 caractères, chacun le glyphe le plus large de l'alphabet visé, au corps des étiquettes de graphique, dans la marge gauche d'une vue à barres — jamais un nom de démonstration ; arrondie au pixel supérieur, adoptée à la main dans ce registre | 168 px | cadre de 1 366 × 700 px, facteur 1, Chromium 153.0.8010.12, Node v26.10.0, 16 processeurs logiques, processeur non ralenti, police Noto Sans |
<!-- engendre:fin constantes -->

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

File diff suppressed because one or more lines are too long

View file

@ -65,8 +65,15 @@ export const ATTENDUES = Object.freeze([...CONSTANTES.filter((c) => c.proprietai
/** La police embarquée, sous laquelle se prend toute mesure de texte. */
export const FAMILLE_EMBARQUEE = 'Noto Sans';
// Les mesures qui lisent le rendu du texte, et nomment donc leur police.
const MESURES_DE_TEXTE = Object.freeze(['SEUIL_SIEGES_LISIBLES', 'TOLERANCE_CHAINE_TEMOIN']);
// Les mesures qui lisent le rendu du texte, et nomment donc leur police :
// le seuil de la matrice en est, sa colonne de noms prenant la largeur
// rendue du nom de référence.
const MESURES_DE_TEXTE = Object.freeze([
'SEUIL_SIEGES_LISIBLES',
'TOLERANCE_CHAINE_TEMOIN',
'SEUIL_MATRICE_PARTICIPANTS',
'LARGEUR_TRONCATURE_NOM_PX',
]);
// La machine de référence du banc : le poste, son processeur ralenti ×4 par
// le navigateur. C'est le ralentissement de la configuration du relevé ;

View file

@ -95,7 +95,7 @@ const sorties = () =>
format: 1,
mesures: [
{ nom: 'LARGEUR_TRONCATURE_NOM_PX', unite: 'px', valeurProposee: 190, critere: 'nom de référence', police: 'Noto Sans', ralentissementCpu: 1 },
{ nom: 'SEUIL_MATRICE_PARTICIPANTS', unite: 'participants', valeurProposee: 38, critere: 'case de 24 px', ralentissementCpu: 1 },
{ nom: 'SEUIL_MATRICE_PARTICIPANTS', unite: 'participants', valeurProposee: 38, critere: 'case de 24 px', police: 'Noto Sans', ralentissementCpu: 1 },
],
},
],
@ -252,6 +252,16 @@ describe('lancer : le jugement qui rend la sortie non nulle', () => {
]);
});
test('les deux seuils de la page de qualité sont des mesures de texte : sans police nommée ou sous une autre, motif bloquant', () => {
const r = releve();
delete r.mesures.find(({ nom }) => nom === 'SEUIL_MATRICE_PARTICIPANTS').police;
r.mesures.find(({ nom }) => nom === 'LARGEUR_TRONCATURE_NOM_PX').police = 'DejaVu Sans';
assert.deepEqual(jugement(r).bloquants, [
'SEUIL_MATRICE_PARTICIPANTS mesurée sous la police « inconnue », et non sous Noto Sans',
'LARGEUR_TRONCATURE_NOM_PX mesurée sous la police « DejaVu Sans », et non sous Noto Sans',
]);
});
test('un verdict non tenu : motif non bloquant, qui nomme la mesure et son critère', () => {
const r = releve();
r.mesures.find(({ nom }) => nom === 'TEMPS_IMAGE_LISTES').verdict = 'nonTenu';

View file

@ -0,0 +1,330 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les deux seuils de la page de qualité (§ 12.5, § 12.6, § 14.1, § 19.10),
// relevés par le banc au cadre de 1 366 × 700, sur le rendu.
//
// LARGEUR_TRONCATURE_NOM_PX : la largeur rendue du nom de référence, fait de
// LONGUEUR_NOM_REFERENCE fois le glyphe le plus large de l'alphabet visé,
// au corps des étiquettes de graphique. App s'ouvre sur la page de qualité
// d'un événement de deux tables dont une appartenance porte ce nom ; la vue
// des collisions l'écrit en intitulé d'une ligne de barres. Les glyphes se
// mesurent un à un par le mesureur des graphiques (creerMesureur) sur
// l'intitulé rendu d'une première ouverture, et nomDeReference (seuils.js)
// compose le nom ; une seconde ouverture le rend entier, à la largeur
// mesurée, dans la marge gauche, avant l'axe. La valeur proposée est cette
// largeur arrondie au pixel supérieur. L'alphabet visé : les lettres
// latines de base, celles du supplément Latin-1 — « × » et « ÷ » exclus —,
// et Œ, œ, Ÿ ; jamais un nom de démonstration, que sa régénération change.
//
// SEUIL_MATRICE_PARTICIPANTS : le plus grand nombre n de participants dont
// la matrice « qui a rencontré qui » se dessine, chaque case d'au moins
// CIBLE_MIN_PX de côté, la dernière colonne dans la largeur de la carte de
// la vue des paires, noms en tête compris. L'événement de n personnes a
// ⌈n / 4⌉ tables de quatre places et trois tours ; une génération courte le
// place, puis App s'ouvre sur la page de qualité, sa proposition montrée.
// Une personne porte le nom de référence, les autres un nom court : la
// colonne des intitulés de ligne a la largeur que le registre suppose au
// plus long. Le côté d'une case se lit sur le rendu, de la gauche d'une
// case à celle de sa voisine. plusGrandTenant (seuils.js) tire le seuil par
// dichotomie sur [MIN, MAX], monotonie relue aux voisins ; deux contrôles
// disent que la mesure bascule : MIN tient, MAX non.
//
// Le registre est remplacé pour ce fichier (vi.mock), toute autre constante
// inchangée : SEUIL_MATRICE_PARTICIPANTS à MAX, pour que le détail porte la
// matrice au-delà du seuil de travail, et LARGEUR_TRONCATURE_NOM_PX à une
// largeur qu'aucun nom n'atteint, pour que l'intitulé ne se tronque pas
// avant d'être mesuré.
//
// Chaque texte mesuré doit être rendu en Noto Sans, sa face chargée : sinon
// la mesure se refuse, valeur proposée null et refus nommé, et l'épreuve
// échoue. Les deux mesures sont des géométries : elles se prennent sans
// ralentissement du processeur.
import { flushSync } from 'svelte';
import { beforeAll, describe, expect, test, vi } from 'vitest';
import { commands } from 'vitest/browser';
import { appliquerCommande } from '../../src/application/commandes.js';
import { CONSTANTES, LONGUEUR_NOM_REFERENCE, constante } from '../../src/geometrie/constantes.js';
import { CIBLE_MIN_PX } from '../../src/geometrie/designation.js';
import { creerMesureur } from '../../src/interface/plan/mesure_texte.js';
import { rechercher } from '../../src/moteur/recherche.js';
import { configurationDepuisCharge, creerCharge } from '../../src/stockage/document.js';
import { VERSION } from '../../src/version.genere.js';
import { attendre, creerContexte, ouvrirEvenement } from '../../test/navigateur/ecrans.js';
import { controlerCadre, ecrireSortie } from './navigateur.js';
import { verifierReleve } from './releve.js';
import { nomDeReference, plusGrandTenant } from './seuils.js';
// Les bornes de la dichotomie du seuil de la matrice : deux tables au
// moins, puis bien au-delà de ce qu'une carte de 1 366 px tient.
const MIN = 8;
const MAX = 120;
// Une largeur d'intitulé qu'aucun nom n'atteint, en px.
const SANS_TRONCATURE_PX = 100_000;
vi.mock('../../src/geometrie/constantes.js', async (importOriginal) => {
const original = await importOriginal();
const remplacees = new Map([
['SEUIL_MATRICE_PARTICIPANTS', 120],
['LARGEUR_TRONCATURE_NOM_PX', 100_000],
]);
return { ...original, constante: (nom) => (remplacees.has(nom) ? remplacees.get(nom) : original.constante(nom)) };
});
const FAMILLE = 'Noto Sans';
// Les points de code de de à a, en texte.
const lettres = (de, a) => String.fromCodePoint(...Array.from({ length: a - de + 1 }, (_, i) => de + i));
/** L'alphabet visé : A–Z, a–z, les lettres du supplément Latin-1 hors
* U+00D7 et U+00F7, puis Œ, œ, Ÿ. */
const ALPHABET =
lettres(0x41, 0x5a) + lettres(0x61, 0x7a) + lettres(0xc0, 0xd6) + lettres(0xd8, 0xf6) + lettres(0xf8, 0xff) + '\u{152}\u{153}\u{178}';
// Les noms courts des autres personnes de la matrice, inventés ; un numéro
// les rend distincts, sans homonyme.
const PRENOMS = Object.freeze(['Iris', 'Théo', 'Ondine', 'Basile', 'Maëlle']);
const APPARTENANCES = Object.freeze(['Coopérative Aubier', 'Atelier Ressac']);
const sansGuillemets = (texte) => texte.trim().replace(/^(["'])(.*)\1$/, '$2');
/**
* Un événement de n personnes, ⌈n / 4⌉ tables de quatre places, tours
* tours, placé par une génération courte enregistrée par la commande.
* nomDe(i) donne le nom de la personne d'indice i, sans prénom ;
* appartenanceDe(i), son appartenance.
*/
function evenementPlace({ id, n, tours, nomDe, appartenanceDe }) {
const charge = creerCharge({ id, nom: 'Club des Merles', siegesParDefaut: 4, tours });
charge.participants = Array.from({ length: n }, (_, i) => ({
id: i + 1,
nom: nomDe(i),
prenom: null,
appartenance: appartenanceDe(i),
courriel: null,
titrePressenti: null,
notes: null,
exclu: false,
}));
const tables = Math.ceil(n / 4);
charge.tables = Array.from({ length: tables }, (_, i) => ({
id: i + 1,
numero: i + 1,
sieges: null,
forme: 'ronde',
position: { x: 300 * i, y: 0 },
}));
charge.prochainsIds = { participant: n + 1, table: tables + 1, proposition: 1 };
const configuration = configurationDepuisCharge(charge);
const propositions = rechercher(configuration, { graine: 3, arret: 20, nombre: 1 });
return appliquerCommande('enregistrerGeneration', charge, { propositions, configuration, produitVersion: VERSION.affichee }).charge;
}
// Huit personnes, deux tables, deux tours : six de l'appartenance nommée,
// qui se rencontrent forcément, et deux d'une autre.
const evenementCollisions = (appartenance) =>
evenementPlace({
id: 'banc-troncature',
n: 8,
tours: 2,
nomDe: (i) => `${PRENOMS[i % PRENOMS.length]} ${i + 1}`,
appartenanceDe: (i) => (i < 6 ? appartenance : APPARTENANCES[0]),
});
// n personnes, la première au nom de référence.
const evenementMatrice = (n, nomReference) =>
evenementPlace({
id: `banc-matrice-${n}`,
n,
tours: 3,
nomDe: (i) => (i === 0 ? nomReference : `${PRENOMS[i % PRENOMS.length]} ${i + 1}`),
appartenanceDe: (i) => APPARTENANCES[i % APPARTENANCES.length],
});
// La police d'un texte rendu : la première famille calculée, et la face
// 400 de Noto Sans chargée dans le document.
function policeDe(texte) {
const premiere = sansGuillemets(getComputedStyle(texte).fontFamily.split(',')[0]);
const chargee = [...document.fonts].some((face) => sansGuillemets(face.family) === FAMILLE && face.weight === '400' && face.status === 'loaded');
return { premiere, chargee };
}
// L'intitulé rendu de la ligne de barres de l'appartenance donnée, dans la
// vue des collisions ; le graphique et son axe avec lui.
const intituleDe = (m, appartenance) =>
attendre(`l’intitulé de « ${appartenance} »`, () => {
const graphique = m.racine.querySelector('[data-vue-qualite="collisions"] [data-graphique="collisions"]');
const ligne = [...(graphique?.querySelectorAll('[data-ligne-graphique]') ?? [])].find((l) => l.dataset.ligneGraphique === appartenance);
const texte = ligne?.querySelector(':scope > text') ?? null;
const axe = graphique?.querySelector('[data-marque="graphiqueAxe"]') ?? null;
return texte !== null && texte.textContent !== '' && axe !== null ? { texte, axe } : null;
});
// Les polices de chaque texte mesuré, dans l'ordre des mesures.
const polices = [];
// Les relevés de chaque appel du prédicat de la matrice, dans l'ordre.
const sondes = [];
let reference = null;
let troncature = null;
let seuilMatrice = null;
/**
* Le prédicat de la matrice : pour n personnes, la grille se dessine-t-elle,
* chaque case d'au moins CIBLE_MIN_PX, la dernière colonne et l'intitulé du
* nom de référence dans la carte de la vue ? Monte, mesure, défait. Lève si
* la vue montre la phrase du seuil : le remplacement du registre n'aurait
* pas pris.
*/
async function tient(n) {
const contexte = creerContexte();
try {
const m = await ouvrirEvenement(contexte, evenementMatrice(n, reference.nom), { section: 'qualite' });
const rendu = await attendre(`la matrice de ${n} personnes ou sa phrase`, () => {
const vue = m.racine.querySelector('[data-vue-qualite="paires"]');
if (vue === null) return null;
if (vue.querySelector('[data-phrase-qualite="matriceSeuil"]') !== null) throw new Error('phrase du seuil : le registre de ce fichier n’a pas pris');
if (vue.querySelector('[data-phrase-qualite="matriceEtroite"]') !== null) return { vue, grille: null };
const grille = vue.querySelector('[data-graphique="matrice"]');
return grille !== null && grille.querySelectorAll('[data-case]').length === n * n ? { vue, grille } : null;
});
flushSync();
const carte = rendu.vue.getBoundingClientRect();
if (rendu.grille === null) {
sondes.push({ participants: n, dessinee: false, cotePx: null, largeurCartePx: carte.width, tient: false });
return false;
}
// La bordure d'une case de la première ligne : un trait d'un pixel posé
// au même retrait dans chaque case, si bien que deux bordures voisines
// sont distantes d'un côté de case.
const bordure = (colonne) =>
rendu.grille.querySelector(`[data-case="0:${colonne}"] [data-marque="graphiqueBordureCase"]`).getBoundingClientRect();
const cote = bordure(1).left - bordure(0).left;
const droite = bordure(n - 1).right;
const intitule = [...rendu.grille.querySelectorAll('text')].find((texte) => texte.textContent.endsWith(`. ${reference.nom}`));
if (intitule === undefined) throw new Error(`matrice de ${n} personnes : l’intitulé du nom de référence n’est pas rendu`);
polices.push(policeDe(intitule));
const gauche = intitule.getBoundingClientRect().left;
const resultat = cote >= CIBLE_MIN_PX && droite <= carte.right && gauche >= carte.left;
sondes.push({ participants: n, dessinee: true, cotePx: cote, largeurCartePx: carte.width, tient: resultat });
return resultat;
} finally {
await contexte.defaire();
}
}
beforeAll(async () => {
await controlerCadre();
});
describe('les deux seuils de la page de qualité (§ 12.5, § 12.6, § 14.1)', () => {
test('le registre de ce fichier porte les deux seuils remplacés, les autres constantes inchangées', () => {
expect(constante('SEUIL_MATRICE_PARTICIPANTS')).toBe(MAX);
expect(constante('LARGEUR_TRONCATURE_NOM_PX')).toBe(SANS_TRONCATURE_PX);
expect(constante('SEUIL_SIEGES_LISIBLES')).toBe(CONSTANTES.find(({ nom }) => nom === 'SEUIL_SIEGES_LISIBLES').valeur);
});
test(`le nom de référence : ${LONGUEUR_NOM_REFERENCE} fois le glyphe le plus large de l’alphabet visé, au corps des étiquettes de graphique`, async () => {
const contexte = creerContexte();
try {
const m = await ouvrirEvenement(contexte, evenementCollisions(APPARTENANCES[1]), { section: 'qualite' });
const { texte } = await intituleDe(m, APPARTENANCES[1]);
polices.push(policeDe(texte));
reference = nomDeReference(ALPHABET, LONGUEUR_NOM_REFERENCE, creerMesureur(texte));
} finally {
await contexte.defaire();
}
expect([...reference.nom]).toHaveLength(LONGUEUR_NOM_REFERENCE);
expect(ALPHABET).toContain(reference.glyphe);
});
test('le nom de référence s’écrit entier dans la marge gauche d’une vue à barres, à la largeur mesurée', async () => {
expect(reference).not.toBeNull();
const contexte = creerContexte();
try {
const m = await ouvrirEvenement(contexte, evenementCollisions(reference.nom), { section: 'qualite' });
const { texte, axe } = await intituleDe(m, reference.nom);
polices.push(policeDe(texte));
const boite = texte.getBBox();
const marge = Number(axe.getAttribute('x1'));
const rendue = texte.getComputedTextLength();
troncature = { rendue, droite: boite.x + boite.width, marge };
expect(texte.textContent).toBe(reference.nom);
expect(Math.abs(rendue - reference.largeur)).toBeLessThan(0.5);
expect(troncature.droite).toBeLessThanOrEqual(marge);
} finally {
await contexte.defaire();
}
});
test(`la matrice : ${MIN} personnes tiennent, ${MAX} non — la mesure bascule`, async () => {
expect(reference).not.toBeNull();
expect(await tient(MIN)).toBe(true);
expect(await tient(MAX)).toBe(false);
});
test(`la matrice : le plus grand nombre de personnes dont les cases tiennent ${CIBLE_MIN_PX} px dans la carte, par dichotomie sur [${MIN}, ${MAX}]`, async () => {
seuilMatrice = await plusGrandTenant(tient, MIN, MAX);
expect(seuilMatrice).toBeGreaterThanOrEqual(MIN);
expect(seuilMatrice).toBeLessThan(MAX);
});
test('les mesures s’écrivent dans scripts/banc/sorties/qualite.json, relues telles quelles, et se rangent dans un relevé', async () => {
expect(polices.length).toBeGreaterThan(0);
const fautive = polices.find(({ premiere, chargee }) => premiere !== FAMILLE || !chargee);
const police = fautive === undefined ? FAMILLE : null;
const refus = police === null ? `police des étiquettes ${fautive.premiere}${fautive.chargee ? '' : ', non chargée'} : la mesure se prend sur ${FAMILLE}` : null;
const matrice = {
nom: 'SEUIL_MATRICE_PARTICIPANTS',
unite: 'participants',
valeurProposee: refus === null && Number.isInteger(seuilMatrice) ? seuilMatrice : null,
critere:
`le plus grand nombre de participants dont la matrice se dessine, chaque case d’au moins ${CIBLE_MIN_PX} px, la dernière ` +
`colonne et les intitulés dans la carte de la vue des paires, un nom de référence en tête ; ` +
`⌈n / 4⌉ tables de quatre places, trois tours ; dichotomie sur [${MIN}, ${MAX}]`,
police,
ralentissementCpu: 1,
sondes: sondes.map((sonde) => ({ ...sonde })),
refus,
};
const largeur = {
nom: 'LARGEUR_TRONCATURE_NOM_PX',
unite: 'px',
valeurProposee: refus === null && reference !== null && troncature !== null ? reference.valeur : null,
critere:
`la largeur rendue de ${LONGUEUR_NOM_REFERENCE} fois le glyphe le plus large de l’alphabet visé — lettres latines de base, ` +
'supplément Latin-1, Œ, œ, Ÿ —, intitulé entier d’une ligne de la vue des collisions, arrondie au pixel supérieur',
police,
ralentissementCpu: 1,
glyphe: reference?.glyphe ?? null,
largeurGlyphePx: reference?.largeurGlyphe ?? null,
nomReference: reference?.nom ?? null,
largeurNomPx: reference?.largeur ?? null,
largeurRenduePx: troncature?.rendue ?? null,
droiteIntitulePx: troncature?.droite ?? null,
axePx: troncature?.marge ?? null,
refus,
};
const objet = { format: 1, mesures: [matrice, largeur] };
const releve = {
format: 1,
configuration: {
cadre: { largeur: innerWidth, hauteur: innerHeight },
facteurEchelle: devicePixelRatio,
navigateur: null,
node: null,
processeursLogiques: null,
ralentissementCpu: 1,
police,
},
mesures: objet.mesures,
};
expect(verifierReleve(releve, CONSTANTES)).toEqual([]);
await ecrireSortie('qualite', objet);
expect(JSON.parse(await commands.readFile('scripts/banc/sorties/qualite.json'))).toEqual(objet);
expect(refus).toBeNull();
expect(matrice.valeurProposee).not.toBeNull();
expect(largeur.valeurProposee).not.toBeNull();
});
});

View file

@ -0,0 +1,163 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves du banc des deux seuils de la page de qualité (§ 14.1, § 19.10),
// sous node : le nom de référence que le banc compose pour la largeur de
// troncature (nomDeReference, seuils.js), sur des largeurs écrites, dont le
// glyphe le plus large se lit à la main ; puis la sortie que le banc écrit
// (qualite.banc.js), rangée dans un relevé, que la garde du relevé accepte
// telle quelle et refuse dès qu'une valeur y nomme la machine. Le nom d'hôte
// et le chemin de compte de ces épreuves sont inventés.
import assert from 'node:assert/strict';
import { CONSTANTES } from '../../src/geometrie/constantes.js';
import { describe, test } from '../../test/lanceur.js';
import { verifierReleve } from './releve.js';
import { nomDeReference } from './seuils.js';
// Des largeurs écrites, en px, par glyphe ; un texte de plusieurs glyphes
// mesure la somme des siens. Chaque appel est noté.
const LARGEURS = new Map([
['a', 7],
['W', 12.5],
['M', 11],
['\u{152}', 12.5],
['i', 3],
['Q', 12.1],
]);
function mesureur() {
const appels = [];
const mesurer = (texte) => {
appels.push(texte);
return [...texte].reduce((somme, glyphe) => somme + LARGEURS.get(glyphe), 0);
};
return { mesurer, appels };
}
describe('nomDeReference : le glyphe le plus large, répété (§ 14.1)', () => {
test('le glyphe le plus large de l’alphabet, répété longueur fois ; sa largeur mesurée, arrondie au pixel supérieur', () => {
const { mesurer, appels } = mesureur();
assert.deepEqual(nomDeReference('aMWi', 3, mesurer), {
glyphe: 'W',
largeurGlyphe: 12.5,
nom: 'WWW',
largeur: 37.5,
valeur: 38,
});
// Chaque glyphe se mesure une fois, puis le nom composé, entier.
assert.deepEqual(appels, ['a', 'M', 'W', 'i', 'WWW']);
});
test('une partie fractionnaire sous le demi-pixel s’arrondit aussi au pixel supérieur', () => {
const { mesurer } = mesureur();
assert.deepEqual(nomDeReference('aQ', 2, mesurer), { glyphe: 'Q', largeurGlyphe: 12.1, nom: 'QQ', largeur: 24.2, valeur: 25 });
});
test('à égalité, le premier glyphe de l’alphabet ; une largeur entière reste entière', () => {
const { mesurer } = mesureur();
assert.equal(nomDeReference('ia\u{152}W', 2, mesurer).glyphe, '\u{152}');
assert.equal(nomDeReference('iaW\u{152}', 2, mesurer).glyphe, 'W');
assert.deepEqual(nomDeReference('aM', 4, mesurer), { glyphe: 'M', largeurGlyphe: 11, nom: 'MMMM', largeur: 44, valeur: 44 });
});
test('l’alphabet se parcourt par points de code : un glyphe hors du plan de base compte pour un', () => {
const largeurs = new Map([
['a', 7],
['\u{1D400}', 15],
]);
const mesurer = (texte) => [...texte].reduce((somme, glyphe) => somme + largeurs.get(glyphe), 0);
assert.deepEqual(nomDeReference('a\u{1D400}', 2, mesurer), {
glyphe: '\u{1D400}',
largeurGlyphe: 15,
nom: '\u{1D400}\u{1D400}',
largeur: 30,
valeur: 30,
});
});
test('refuse un alphabet vide, une longueur qui n’est pas un entier ≥ 1, un mesureur absent', () => {
const { mesurer } = mesureur();
assert.throws(() => nomDeReference('', 3, mesurer), RangeError);
assert.throws(() => nomDeReference(['a'], 3, mesurer), TypeError);
for (const longueur of [0, -1, 2.5, Number.NaN, '3']) {
assert.throws(() => nomDeReference('aW', longueur, mesurer), RangeError, String(longueur));
}
assert.throws(() => nomDeReference('aW', 3, null), TypeError);
});
test('refuse une largeur nulle ou non finie — un texte qui n’est pas rendu ne mesure rien — en nommant le texte', () => {
assert.throws(() => nomDeReference('aW', 2, (texte) => (texte === 'W' ? 0 : 7)), /« W »/);
assert.throws(() => nomDeReference('aW', 2, (texte) => (texte === 'W' ? Number.NaN : 7)), /« W »/);
assert.throws(() => nomDeReference('aW', 2, (texte) => (texte.length > 1 ? 0 : 7)), /« aa »/);
});
});
// La sortie du banc de la page de qualité, de valeurs inventées, à la forme
// que qualite.banc.js écrit : les deux mesures, puis leurs sondes.
const sortie = () => ({
format: 1,
mesures: [
{
nom: 'SEUIL_MATRICE_PARTICIPANTS',
unite: 'participants',
valeurProposee: 37,
critere: 'case de 24 px dans la largeur de la carte, noms en tête compris',
police: 'Noto Sans',
ralentissementCpu: 1,
sondes: [{ participants: 37, dessinee: true, cotePx: 24, largeurCartePx: 1100, tient: true }],
refus: null,
},
{
nom: 'LARGEUR_TRONCATURE_NOM_PX',
unite: 'px',
valeurProposee: 190,
critere: 'nom de référence de 14 glyphes',
police: 'Noto Sans',
ralentissementCpu: 1,
glyphe: '\u{152}',
largeurGlyphePx: 13.5,
nomReference: '\u{152}'.repeat(14),
largeurNomPx: 189.1,
refus: null,
},
],
});
// La sortie rangée dans un relevé, comme le banc le vérifie avant de
// l'écrire.
const releveDe = (s) => ({
format: 1,
configuration: {
cadre: { largeur: 1366, hauteur: 700 },
facteurEchelle: 1,
navigateur: null,
node: null,
processeursLogiques: null,
ralentissementCpu: 1,
police: 'Noto Sans',
},
mesures: s.mesures,
});
describe('la sortie du banc de la page de qualité, rangée dans un relevé (§ 19.10)', () => {
test('conforme : les deux constantes du banc, à l’unité du registre', () => {
assert.deepEqual(verifierReleve(releveDe(sortie()), CONSTANTES), []);
});
test('un nom d’hôte inventé dans une sonde est refusé, par son chemin', () => {
const s = sortie();
s.mesures[0].sondes[0].hote = 'poste-grebiche-07';
assert.deepEqual(verifierReleve(releveDe(s), CONSTANTES), [
{ chemin: 'mesures[0].sondes[0].hote', faute: 'un relevé ne nomme pas sa machine : ni hôte, ni utilisateur, ni date' },
]);
});
test('un nom de machine inventé au sommet d’une mesure, et un chemin de compte inventé dans son critère, sont refusés', () => {
const s = sortie();
s.mesures[1].nomMachine = 'poste-grebiche-07';
s.mesures[1].critere = 'mesuré depuis /home/grebiche/banc';
assert.deepEqual(
verifierReleve(releveDe(s), CONSTANTES).map(({ chemin }) => chemin),
['mesures[1].critere', 'mesures[1].nomMachine'],
);
});
});

File diff suppressed because it is too large Load diff

View file

@ -3,10 +3,12 @@
// Les seuils du banc de mesure (§ 19.10, § 19.4, § 6.2), tirés de mesures
// déjà prises : le plus grand entier qui tient un prédicat, par dichotomie ;
// le plus petit zoom au-delà duquel une courbe de temps par image tient son
// critère ; la tolérance de la chaîne témoin. Module pur, sans import ni
// horloge : il se charge sous node, où ses épreuves le jouent, comme dans la
// page du banc, où les *.banc.js lui passent leurs mesures.
// le nom de référence d'une largeur de troncature, fait du glyphe le plus
// large (§ 14.1) ; le plus petit zoom au-delà duquel une courbe de temps par
// image tient son critère ; la tolérance de la chaîne témoin. Module pur,
// sans import ni horloge : il se charge sous node, où ses épreuves le
// jouent, comme dans la page du banc, où les *.banc.js lui passent leurs
// mesures.
/** Le temps par image qu'un glissement de la grande démonstration doit tenir,
* au p95, sur la machine de référence (Q13 b) : le choix de l'utilisateur.
@ -85,6 +87,54 @@ export async function plusGrandTenant(predicat, min, max) {
const estPositifFini = (valeur) => typeof valeur === 'number' && Number.isFinite(valeur) && valeur > 0;
const estPositifOuNul = (valeur) => typeof valeur === 'number' && Number.isFinite(valeur) && valeur >= 0;
// La largeur d'un texte par le mesureur reçu, qui doit être finie > 0 : un
// texte qui n'est pas rendu mesure 0, et ne mesure alors rien.
function largeurDe(mesurer, texte) {
const largeur = mesurer(texte);
if (!estPositifFini(largeur)) throw new RangeError(`nomDeReference : largeur finie > 0 attendue pour « ${texte} », reçu ${String(largeur)}`);
return largeur;
}
/**
* Le nom de référence d'une largeur de troncature (§ 14.1) : le glyphe le
* plus large de l'alphabet, répété longueur fois. Chaque glyphe se mesure
* une fois, dans l'ordre de l'alphabet, parcouru par points de code ; à
* égalité, le premier l'emporte. Le nom composé se mesure ensuite entier,
* et non comme longueur fois son glyphe : la mesure est celle du texte
* rendu. valeur est sa largeur arrondie au pixel supérieur, celle que le
* banc propose au registre.
*
* @param {string} alphabet au moins un glyphe
* @param {number} longueur entier ≥ 1, le nombre de glyphes du nom
* @param {(texte: string) => number} mesurer largeur rendue d'un texte, px
* @returns {{glyphe: string, largeurGlyphe: number, nom: string, largeur: number, valeur: number}}
* @throws {TypeError} un alphabet qui n'est pas un texte, un mesureur qui
* n'est pas une fonction
* @throws {RangeError} un alphabet vide, une longueur hors contrat, avant
* toute mesure ; une largeur mesurée qui n'est pas finie > 0, en nommant
* son texte
*/
export function nomDeReference(alphabet, longueur, mesurer) {
if (typeof alphabet !== 'string') throw new TypeError('nomDeReference : un alphabet en texte est attendu');
if (typeof mesurer !== 'function') throw new TypeError('nomDeReference : un mesureur est attendu');
if (alphabet.length === 0) throw new RangeError('nomDeReference : alphabet vide');
if (!Number.isSafeInteger(longueur) || longueur < 1) {
throw new RangeError(`nomDeReference : longueur entière ≥ 1 attendue, reçu ${String(longueur)}`);
}
let glyphe = null;
let largeurGlyphe = 0;
for (const candidat of alphabet) {
const largeur = largeurDe(mesurer, candidat);
if (largeur > largeurGlyphe) {
glyphe = candidat;
largeurGlyphe = largeur;
}
}
const nom = glyphe.repeat(longueur);
const largeur = largeurDe(mesurer, nom);
return { glyphe, largeurGlyphe, nom, largeur, valeur: Math.ceil(largeur) };
}
/**
* Le plus petit k de la courbe au-delà duquel tout k de la courbe tient le
* critère : son p95 vaut au plus critereMs. Les points se rangent par k

View file

@ -38,12 +38,21 @@ const capture = (fichier, etape, configuration, source = 'navigateur') => Object
// des étiquettes, des objectifs et de l'ordre des priorités : 04a, 05a, 05b,
// 10a (A22). « configuration » nomme ce que la capture exige : le répertoire
// neuf, le fichier d'import d'exemples/, l'identifiant de démonstration du
// catalogue, ou l'événement que le parcours construit puis retient. L'étape
// place une capture dans le guide, la configuration dit sur quoi elle se
// prend : les deux 07a, à l'étape 4, se photographient sur la grande
// démonstration que le parcours charge pour 10 ; 05b, sur l'exemple des
// objectifs importé dans l'événement construit ; 10a, à l'étape 9 où l'ordre
// des priorités se règle à côté de la génération (§ 2.1).
// catalogue, l'événement que le parcours construit puis retient, ou, préfixée
// de « construite- », une configuration qu'un scénario court du parcours
// construit. L'étape place une capture dans le guide, la configuration dit
// sur quoi elle se prend : les deux 07a, à l'étape 4, se photographient sur
// la grande démonstration que le parcours charge pour 10 ; 05b, sur
// l'exemple des objectifs importé dans l'événement construit ; 10a, à
// l'étape 9 où l'ordre des priorités se règle à côté de la génération
// (§ 2.1).
//
// Les captures de la page de qualité, 12 à 16 et leurs voisines en « a » à
// « g » (B27), illustrent l'étape 10 et se prennent à la fin du parcours,
// chacune sur sa charge : la grande démonstration, la petite, sa variante
// « conflit inévitable », l'événement construit, ou l'une des six
// configurations construites — les cinq cas limites 12b à 12e et 12g, et
// les paires de 14 et 14a.
export const CAPTURES = Object.freeze([
capture('01-premier-ecran', '1', 'repertoire-neuf'),
capture('01b-passer-en-ecriture', '1b', 'repertoire-neuf'),
@ -65,6 +74,23 @@ export const CAPTURES = Object.freeze([
capture('10-diagnostic', '8', 'grande'),
capture('10a-ordre-priorites', '9', 'evenement-construit'),
capture('11-propositions', '9', 'evenement-construit'),
capture('12-qualite-profil', '10', 'grande'),
capture('12a-qualite-sans-placement', '10', 'petite-conflit'),
capture('12b-qualite-une-table', '10', 'construite-une-table'),
capture('12c-qualite-un-tour', '10', 'construite-un-tour'),
capture('12d-qualite-une-appartenance', '10', 'construite-une-appartenance'),
capture('12e-qualite-sans-collision', '10', 'construite-sans-collision'),
capture('12f-qualite-reserve', '10', 'petite-conflit'),
capture('12g-qualite-plafond-nul', '10', 'construite-plafond-nul'),
capture('13-qualite-collisions', '10', 'petite-conflit'),
capture('13a-qualite-tables', '10', 'petite-conflit'),
capture('14-qualite-paires', '10', 'construite-paires'),
capture('14a-fiche-rencontres', '10', 'construite-paires'),
capture('15-pas-de-matrice', '10', 'grande'),
capture('15a-qualite-objectifs', '10', 'evenement-construit'),
capture('15b-qualite-animateurs', '10', 'grande'),
capture('15c-qualite-performance', '10', 'grande'),
capture('16-minimum-atteint', '10', 'petite'),
capture('19-ajustement', '11', 'evenement-retenu'),
capture('20-mode-lecture', '11', 'evenement-retenu'),
capture('22-plan-brouillon', '12', 'evenement-retenu'),
@ -75,14 +101,10 @@ export const CAPTURES = Object.freeze([
const reportee = (fichier, etape) => Object.freeze({ fichier, etape, raison: 'iteration5' });
// Les captures des écrans que l'itération 5 livre (Q8 a) : déclarées à leur
// Les captures des écrans que le logiciel n'offre pas encore — la
// comparaison de deux propositions et la vue d'historique : déclarées à leur
// étape, sans image ; le contrôle d'ordre les compte comme présentes.
export const REPORTEES = Object.freeze([
reportee('12-qualite-profil', '10'),
reportee('13-qualite-collisions', '10'),
reportee('14-qualite-paires', '10'),
reportee('15-pas-de-matrice', '10'),
reportee('16-minimum-atteint', '10'),
reportee('17-comparaison-manque', '10'),
reportee('18-comparaison-escalier', '10'),
reportee('21-historique', '11'),

View file

@ -79,7 +79,7 @@ describe('manifeste : la liste déclarée (§ 19.3, Q8 a)', () => {
assert.ok(Object.isFrozen(ETAPES));
});
test('CAPTURES égale la table du contrat : vingt-six entrées, leur étape et leur source', () => {
test('CAPTURES égale la table du contrat : quarante-trois entrées, leur étape et leur source', () => {
const table = CAPTURES.map((c) => [c.fichier, c.etape, c.source]);
assert.deepEqual(table, [
['01-premier-ecran', '1', 'navigateur'],
@ -102,6 +102,23 @@ describe('manifeste : la liste déclarée (§ 19.3, Q8 a)', () => {
['10-diagnostic', '8', 'navigateur'],
['10a-ordre-priorites', '9', 'navigateur'],
['11-propositions', '9', 'navigateur'],
['12-qualite-profil', '10', 'navigateur'],
['12a-qualite-sans-placement', '10', 'navigateur'],
['12b-qualite-une-table', '10', 'navigateur'],
['12c-qualite-un-tour', '10', 'navigateur'],
['12d-qualite-une-appartenance', '10', 'navigateur'],
['12e-qualite-sans-collision', '10', 'navigateur'],
['12f-qualite-reserve', '10', 'navigateur'],
['12g-qualite-plafond-nul', '10', 'navigateur'],
['13-qualite-collisions', '10', 'navigateur'],
['13a-qualite-tables', '10', 'navigateur'],
['14-qualite-paires', '10', 'navigateur'],
['14a-fiche-rencontres', '10', 'navigateur'],
['15-pas-de-matrice', '10', 'navigateur'],
['15a-qualite-objectifs', '10', 'navigateur'],
['15b-qualite-animateurs', '10', 'navigateur'],
['15c-qualite-performance', '10', 'navigateur'],
['16-minimum-atteint', '10', 'navigateur'],
['19-ajustement', '11', 'navigateur'],
['20-mode-lecture', '11', 'navigateur'],
['22-plan-brouillon', '12', 'navigateur'],
@ -134,24 +151,44 @@ describe('manifeste : la liste déclarée (§ 19.3, Q8 a)', () => {
// le parcours charge pour 10 (§ 19.3).
assert.equal(configuration.get('07a-cibles-sieges'), 'grande');
assert.equal(configuration.get('07a-cibles-tables'), 'grande');
// La page de qualité (B27) : chaque capture sur sa charge, la
// démonstration du catalogue qu'elle charge, l'événement que le parcours
// construit, ou une configuration qu'un scénario court construit.
const qualite = [
['12-qualite-profil', 'grande'],
['12a-qualite-sans-placement', 'petite-conflit'],
['12b-qualite-une-table', 'construite-une-table'],
['12c-qualite-un-tour', 'construite-un-tour'],
['12d-qualite-une-appartenance', 'construite-une-appartenance'],
['12e-qualite-sans-collision', 'construite-sans-collision'],
['12f-qualite-reserve', 'petite-conflit'],
['12g-qualite-plafond-nul', 'construite-plafond-nul'],
['13-qualite-collisions', 'petite-conflit'],
['13a-qualite-tables', 'petite-conflit'],
['14-qualite-paires', 'construite-paires'],
['14a-fiche-rencontres', 'construite-paires'],
['15-pas-de-matrice', 'grande'],
['15a-qualite-objectifs', 'evenement-construit'],
['15b-qualite-animateurs', 'grande'],
['15c-qualite-performance', 'grande'],
['16-minimum-atteint', 'petite'],
];
assert.deepEqual(qualite.map(([fichier]) => [fichier, configuration.get(fichier)]), qualite);
});
test('REPORTEES : 12 à 18 à l’étape 10, 21 à l’étape 11, raison « iteration5 », rien d’autre', () => {
test('REPORTEES : 17 et 18 à l’étape 10, 21 à l’étape 11, raison « iteration5 », rien d’autre', () => {
assert.deepEqual(
REPORTEES.map((r) => [r.fichier, r.etape, r.raison]),
[
['12-qualite-profil', '10', 'iteration5'],
['13-qualite-collisions', '10', 'iteration5'],
['14-qualite-paires', '10', 'iteration5'],
['15-pas-de-matrice', '10', 'iteration5'],
['16-minimum-atteint', '10', 'iteration5'],
['17-comparaison-manque', '10', 'iteration5'],
['18-comparaison-escalier', '10', 'iteration5'],
['21-historique', '11', 'iteration5'],
],
);
const rangs = REPORTEES.map((r) => Number(r.fichier.slice(0, 2)));
for (const rang of rangs) assert.ok((rang >= 12 && rang <= 18) || rang === 21, String(rang));
// La page de qualité se photographie : aucune de ses captures n'est
// reportée, et aucune reportée n'est déclarée photographiée.
const declarees = new Set(CAPTURES.map((c) => c.fichier));
for (const reportee of REPORTEES) assert.ok(!declarees.has(reportee.fichier), reportee.fichier);
assert.ok(Object.isFrozen(REPORTEES));
for (const reportee of REPORTEES) assert.ok(Object.isFrozen(reportee), reportee.fichier);
});
@ -351,8 +388,11 @@ describe('manifeste : l’ordre contre les étapes du § 2.1 (§ 19.3)', () => {
]);
});
test('les reportées couvrent l’étape 10 ; sans elles, elle manque', () => {
assert.deepEqual(verifierOrdre(manifesteDeclare({ reportees: [] })), [
test('les captures de la page de qualité couvrent l’étape 10 sans les reportées ; sans les unes ni les autres, elle manque', () => {
const etape10 = CAPTURES.filter((c) => c.etape === '10').map((c) => c.fichier);
assert.equal(etape10.length, 17);
assert.deepEqual(verifierOrdre(manifesteDeclare({ reportees: [] })), []);
assert.deepEqual(verifierOrdre(manifesteDeclare({ reportees: [], sans: etape10 })), [
{ chemin: CHEMIN_MANIFESTE, faute: 'étape 10 : aucune capture ni reportée' },
]);
});

View file

@ -19,11 +19,12 @@
// fonds. Un signe ne porte jamais seul une information : un libellé
// l'accompagne toujours.
//
// La famille « graphique » (§ 12.7, § 13.3) a ses listes à part :
// MARQUES_GRAPHIQUE, TEXTES_GRAPHIQUE et FONDS_GRAPHIQUE, de même forme,
// validées par calcul comme les autres. Elles restent hors de MARQUES et de
// TEXTES tant qu'aucun écran ne les rend : la garde transversale exige que
// chaque entrée de MARQUES soit rendue sur un écran.
// La famille « graphique » (§ 12.7, § 13.3) a ses listes :
// MARQUES_GRAPHIQUE et TEXTES_GRAPHIQUE, de même forme, versées à la fin de
// MARQUES et de TEXTES — les écrans de la page de qualité les rendent, et la
// garde transversale exige que chaque entrée de MARQUES soit rendue sur un
// écran ; FONDS_GRAPHIQUE, la grille et le niveau 0, qui ne portent aucune
// information et restent hors de toute mesure.
const c = (nom) => `--couleur-${nom}`;
@ -122,93 +123,6 @@ export const SIGNES = Object.freeze([
marque('actionPrincipale', 'action-fond', SURFACES),
]);
/** Les marques porteuses d'information (§ 14.5) : { nom, jeton, fonds, seuil: 3 } — nom, la
* désignation data-marque ; jeton, celui qui la peint ; fonds, chaque jeton sur lequel elle
* se peint, chaque remplissage d'une chaise pour une marque qui passe sur une chaise. */
export const MARQUES = Object.freeze([
// Sur la chaise (§ 7.6) : cadenas posés sur ses remplissages ; l'anneau
// l'entoure, sur le sol et le bord du plateau qu'elle touche.
marque('cadenasFerme', 'marque', REMPLISSAGES_CHAISE),
marque('cadenasOuvert', 'marque', REMPLISSAGES_CHAISE),
marque('anneau', 'marque', ['sol', 'table-fond', ...REMPLISSAGES_CHAISE]),
marque('hachure', 'hachure', ['chaise-fond']),
marque('remplissagePlein', 'chaise-pleine', ['sol', 'table-fond']),
// Le conflit (§ 7.5) : contour épaissi sur le sol, commun aux trois
// règles et peint de l'encre forte du plan ; une trame par règle violée
// sur le plateau, chacune de sa teinte, ses traits détachés par un halo
// du fond du plateau ; pastille chiffrée près de la table.
marque('contourConflit', 'contour-fort', ['sol', 'table-fond']),
...SORTES_CONFLIT.map(([, trame, jeton]) => marque(trame, jeton, ['table-fond'])),
marque('pastille', 'pastille-fond', ['sol', 'table-fond']),
marque('poignee', 'poignee', ['sol']),
marque('gel', 'gel', ['sol', 'table-fond']),
// La désignation se pose sur la page, le sol, le plateau et une chaise
// vide, pourvue ou à pourvoir : chacun de ses remplissages est un fond.
marque('miseEnEvidence', 'mise-en-evidence', ['page-fond', 'sol', 'table-fond', ...REMPLISSAGES_CHAISE]),
// La sélection marque aussi une colonne du tableau des propositions, sur la
// carte, son fond sourd et la ligne du retenu.
marque('selection', 'selection', ['page-fond', 'carte-fond', 'carte-fond-sourd', 'etat-retenu-fond', 'sol', 'table-fond', ...REMPLISSAGES_CHAISE]),
marque('focus', 'focus', ['page-fond', 'bandeau-fond', 'carte-fond', 'carte-fond-sourd', 'etat-retenu-fond', 'action-teinte', 'sol', 'table-fond', ...REMPLISSAGES_CHAISE]),
// Le mobilier sur le sol : le trait de la table, celui de la chaise, qui
// touche aussi le plateau.
marque('contourTable', 'table-trait', ['sol']),
marque('contourChaise', 'chaise-trait', ['sol', 'table-fond']),
// L'échelle graphique (§ 7.7) vit dans la surface du plan, sur le sol ou la
// page ; le fantôme d'un glissement passe sur le sol, les tables et les
// 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, dans une cellule du tableau, sur la
// carte.
marque('chargement', 'page-texte-secondaire', ['carte-fond']),
// Le filigrane BROUILLON (§ 11.7) : un texte au trait, par-dessus le
// contenu d'une feuille imprimée, mesuré sur le papier, clair dans les
// deux thèmes.
marque('filigrane', 'filigrane', ['papier']),
// La croix cerclée d'un retrait, seul contenu de son bouton : sur la
// carte, et sur son fond sourd, celui du survol.
marque('retrait', 'retrait', ['carte-fond', 'carte-fond-sourd']),
]);
// Les fonds teintés d'une pastille : ceux de ses teintes, puis ceux des
// messages, qu'un statut reprend, et la teinte de l'action.
const FONDS_ETIQUETTE = [
...TEINTES_DE_PASTILLE.map(([, teinte]) => `${teinte}-fond`),
'avis-fond',
'refus-fond',
'information-fond',
'action-teinte',
];
/** Les textes et leurs fonds, même forme, seuil 4,5 : page, secondaire,
* bandeau, refus, avis, listes du plan sur le sol, pastille du plan ; puis
* l'encre de la page sur la carte, l'encre secondaire sur la carte, le
* texte d'une étiquette — l'encre de la page sur chaque fond teinté de
* pastille (§ 12.7) —, celui de la bande de mode, la version et la
* provenance du bandeau, le thème retenu du bandeau, aux couleurs du
* bandeau inversées, le texte de l'action principale, un lien ou un texte
* mis en valeur de l'encre de l'action, et l'information. Le refus, l'avis
* et l'information gardent l'encre de leur message, sur son fond. */
export const TEXTES = Object.freeze([
texte('page', 'page-texte', ['page-fond']),
texte('secondaire', 'page-texte-secondaire', ['page-fond']),
texte('bandeau', 'bandeau-texte', ['bandeau-fond']),
texte('refus', 'refus', ['page-fond', 'refus-fond', 'carte-fond']),
texte('avis', 'avis', ['page-fond', 'avis-fond', 'carte-fond']),
texte('listes', 'liste-texte', ['sol']),
texte('pastille', 'pastille-texte', ['pastille-fond']),
texte('carte', 'page-texte', ['carte-fond', 'carte-fond-sourd']),
texte('secondaireCarte', 'page-texte-secondaire', ['carte-fond', 'carte-fond-sourd']),
texte('etiquette', 'page-texte', FONDS_ETIQUETTE),
texte('bandeMode', 'page-texte', ['mode-ecriture-fond', 'mode-lecture-fond', 'mode-bloque-fond']),
texte('bandeauSecondaire', 'bandeau-texte-secondaire', ['bandeau-fond']),
texte('bandeauChoisi', 'bandeau-fond', ['bandeau-texte']),
texte('action', 'action-texte', ['action-fond']),
texte('lien', 'action-encre', ['page-fond', 'carte-fond', 'carte-fond-sourd', 'action-teinte']),
texte('information', 'information', ['information-fond', 'page-fond', 'carte-fond']),
]);
// --- La famille « graphique » (§ 12.7, § 13.3, D30, D43) ---------------------
const graphique = (nom) => `graphique-${nom}`;
@ -288,3 +202,96 @@ export const FONDS_GRAPHIQUE = Object.freeze(
['graphiqueNiveau0', 'niveau-0'],
].map(([nom, jeton]) => Object.freeze({ nom, jeton: c(graphique(jeton)) })),
);
/** Les marques porteuses d'information (§ 14.5) : { nom, jeton, fonds, seuil: 3 } — nom, la
* désignation data-marque ; jeton, celui qui la peint ; fonds, chaque jeton sur lequel elle
* se peint, chaque remplissage d'une chaise pour une marque qui passe sur une chaise. Celles
* du plan et des écrans, puis celles des graphiques (MARQUES_GRAPHIQUE), les mêmes entrées. */
export const MARQUES = Object.freeze([
// Sur la chaise (§ 7.6) : cadenas posés sur ses remplissages ; l'anneau
// l'entoure, sur le sol et le bord du plateau qu'elle touche.
marque('cadenasFerme', 'marque', REMPLISSAGES_CHAISE),
marque('cadenasOuvert', 'marque', REMPLISSAGES_CHAISE),
marque('anneau', 'marque', ['sol', 'table-fond', ...REMPLISSAGES_CHAISE]),
marque('hachure', 'hachure', ['chaise-fond']),
marque('remplissagePlein', 'chaise-pleine', ['sol', 'table-fond']),
// Le conflit (§ 7.5) : contour épaissi sur le sol, commun aux trois
// règles et peint de l'encre forte du plan ; une trame par règle violée
// sur le plateau, chacune de sa teinte, ses traits détachés par un halo
// du fond du plateau ; pastille chiffrée près de la table.
marque('contourConflit', 'contour-fort', ['sol', 'table-fond']),
...SORTES_CONFLIT.map(([, trame, jeton]) => marque(trame, jeton, ['table-fond'])),
marque('pastille', 'pastille-fond', ['sol', 'table-fond']),
marque('poignee', 'poignee', ['sol']),
marque('gel', 'gel', ['sol', 'table-fond']),
// La désignation se pose sur la page, le sol, le plateau et une chaise
// vide, pourvue ou à pourvoir : chacun de ses remplissages est un fond.
marque('miseEnEvidence', 'mise-en-evidence', ['page-fond', 'sol', 'table-fond', ...REMPLISSAGES_CHAISE]),
// La sélection marque aussi une colonne du tableau des propositions, sur la
// carte, son fond sourd et la ligne du retenu.
marque('selection', 'selection', ['page-fond', 'carte-fond', 'carte-fond-sourd', 'etat-retenu-fond', 'sol', 'table-fond', ...REMPLISSAGES_CHAISE]),
marque('focus', 'focus', ['page-fond', 'bandeau-fond', 'carte-fond', 'carte-fond-sourd', 'etat-retenu-fond', 'action-teinte', 'sol', 'table-fond', ...REMPLISSAGES_CHAISE]),
// Le mobilier sur le sol : le trait de la table, celui de la chaise, qui
// touche aussi le plateau.
marque('contourTable', 'table-trait', ['sol']),
marque('contourChaise', 'chaise-trait', ['sol', 'table-fond']),
// L'échelle graphique (§ 7.7) vit dans la surface du plan, sur le sol ou la
// page ; le fantôme d'un glissement passe sur le sol, les tables et les
// 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, dans une cellule du tableau, sur la
// carte.
marque('chargement', 'page-texte-secondaire', ['carte-fond']),
// Le filigrane BROUILLON (§ 11.7) : un texte au trait, par-dessus le
// contenu d'une feuille imprimée, mesuré sur le papier, clair dans les
// deux thèmes.
marque('filigrane', 'filigrane', ['papier']),
// La croix cerclée d'un retrait, seul contenu de son bouton : sur la
// carte, et sur son fond sourd, celui du survol.
marque('retrait', 'retrait', ['carte-fond', 'carte-fond-sourd']),
// Les graphiques de la page de qualité (§ 12.7).
...MARQUES_GRAPHIQUE,
]);
// Les fonds teintés d'une pastille : ceux de ses teintes, puis ceux des
// messages, qu'un statut reprend, et la teinte de l'action.
const FONDS_ETIQUETTE = [
...TEINTES_DE_PASTILLE.map(([, teinte]) => `${teinte}-fond`),
'avis-fond',
'refus-fond',
'information-fond',
'action-teinte',
];
/** Les textes et leurs fonds, même forme, seuil 4,5 : page, secondaire,
* bandeau, refus, avis, listes du plan sur le sol, pastille du plan ; puis
* l'encre de la page sur la carte, l'encre secondaire sur la carte, le
* texte d'une étiquette — l'encre de la page sur chaque fond teinté de
* pastille (§ 12.7) —, celui de la bande de mode, la version et la
* provenance du bandeau, le thème retenu du bandeau, aux couleurs du
* bandeau inversées, le texte de l'action principale, un lien ou un texte
* mis en valeur de l'encre de l'action, et l'information. Le refus, l'avis
* et l'information gardent l'encre de leur message, sur son fond. Puis les
* deux encres des comptes d'une case de graphique (TEXTES_GRAPHIQUE), les
* mêmes entrées. */
export const TEXTES = Object.freeze([
texte('page', 'page-texte', ['page-fond']),
texte('secondaire', 'page-texte-secondaire', ['page-fond']),
texte('bandeau', 'bandeau-texte', ['bandeau-fond']),
texte('refus', 'refus', ['page-fond', 'refus-fond', 'carte-fond']),
texte('avis', 'avis', ['page-fond', 'avis-fond', 'carte-fond']),
texte('listes', 'liste-texte', ['sol']),
texte('pastille', 'pastille-texte', ['pastille-fond']),
texte('carte', 'page-texte', ['carte-fond', 'carte-fond-sourd']),
texte('secondaireCarte', 'page-texte-secondaire', ['carte-fond', 'carte-fond-sourd']),
texte('etiquette', 'page-texte', FONDS_ETIQUETTE),
texte('bandeMode', 'page-texte', ['mode-ecriture-fond', 'mode-lecture-fond', 'mode-bloque-fond']),
texte('bandeauSecondaire', 'bandeau-texte-secondaire', ['bandeau-fond']),
texte('bandeauChoisi', 'bandeau-fond', ['bandeau-texte']),
texte('action', 'action-texte', ['action-fond']),
texte('lien', 'action-encre', ['page-fond', 'carte-fond', 'carte-fond-sourd', 'action-teinte']),
texte('information', 'information', ['information-fond', 'page-fond', 'carte-fond']),
...TEXTES_GRAPHIQUE,
]);

View file

@ -5,8 +5,9 @@
// § 7.5, § 7.6, § 7.7), et la palette de jetons.css validée par calcul sur
// lui (§ 12.7) : chaque entrée tient son seuil sur chacun de ses fonds, dans
// les deux thèmes, en couleurs d'origine puis sous les trois dichromaties.
// La famille « graphique » (§ 12.7, § 13.3) a ses listes à part, hors de
// MARQUES et de TEXTES, éprouvées de même.
// La famille « graphique » (§ 12.7, § 13.3) a ses listes, versées à la fin
// de MARQUES et de TEXTES, que les écrans de la qualité rendent ; ses fonds
// restent hors de toute mesure.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { describe, test } from '../../test/lanceur.js';
@ -64,9 +65,10 @@ 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', () => {
assert.equal(MARQUES.length, NOMS_MARQUES.length);
assert.deepEqual(MARQUES.map((m) => m.nom), NOMS_MARQUES);
test('les trente-quatre marques du contrat — les vingt-deux du plan et des écrans, puis les douze de la famille « graphique » —, noms uniques, seuil 3, au moins un fond chacune', () => {
const noms = [...NOMS_MARQUES, ...NOMS_MARQUES_GRAPHIQUE];
assert.equal(MARQUES.length, noms.length);
assert.deepEqual(MARQUES.map((m) => m.nom), noms);
assert.equal(new Set(MARQUES.map((m) => m.nom)).size, MARQUES.length);
for (const marque of MARQUES) {
assert.deepEqual(Object.keys(marque).sort(), ['fonds', 'jeton', 'nom', 'seuil'], marque.nom);
@ -176,10 +178,11 @@ describe('les trois sortes de conflit (§ 7.5, § 12.7)', () => {
});
describe('registre des textes', () => {
test('page, secondaire, bandeau, refus, avis, listes du plan, pastille, puis les textes des cartes, des étiquettes, de la bande de mode, du bandeau, de l’action et des messages ; seuil 4,5', () => {
test('page, secondaire, bandeau, refus, avis, listes du plan, pastille, puis les textes des cartes, des étiquettes, de la bande de mode, du bandeau, de l’action et des messages, puis les deux encres des cases de graphique ; seuil 4,5', () => {
assert.deepEqual(TEXTES.map((t) => t.nom), [
'page', 'secondaire', 'bandeau', 'refus', 'avis', 'listes', 'pastille',
'carte', 'secondaireCarte', 'etiquette', 'bandeMode', 'bandeauSecondaire', 'bandeauChoisi', 'action', 'lien', 'information',
'graphiqueEncreFoncee', 'graphiqueEncreClaire',
]);
assert.ok(TEXTES.every((t) => t.seuil === 4.5 && t.fonds.length > 0));
assert.equal(new Set(TEXTES.map((t) => t.nom)).size, TEXTES.length);
@ -345,7 +348,7 @@ const CARTE = '--couleur-carte-fond';
const PAGE = '--couleur-page-fond';
const SIMULATIONS_ET_ORIGINE = ['origine', 'protanopie', 'deuteranopie', 'tritanopie'];
describe('la famille « graphique » : ses listes, hors de MARQUES et de TEXTES', () => {
describe('la famille « graphique » : ses listes, versées dans MARQUES et TEXTES, ses fonds hors de toute mesure', () => {
test('les seize jetons du contrat existent dans les deux thèmes, et aucun autre jeton ne porte le préfixe de la famille', () => {
for (const theme of THEMES) {
const famille = [...lireJetons(JETONS, theme).keys()].filter((n) => n.startsWith('--couleur-graphique-'));
@ -369,13 +372,24 @@ describe('la famille « graphique » : ses listes, hors de MARQUES et de TEXTES'
}
});
test('les listes de la famille restent hors de MARQUES et de TEXTES : aucun nom ni aucun jeton partagé, MARQUES garde ses vingt-deux entrées', () => {
assert.equal(MARQUES.length, NOMS_MARQUES.length);
test('MARQUES_GRAPHIQUE et TEXTES_GRAPHIQUE sont versées à la fin de MARQUES et de TEXTES, les mêmes entrées dans le même ordre ; aucune autre entrée des registres ne porte un jeton de la famille', () => {
assert.deepEqual(MARQUES.slice(NOMS_MARQUES.length), [...MARQUES_GRAPHIQUE]);
assert.ok(MARQUES.slice(NOMS_MARQUES.length).every((e, i) => e === MARQUES_GRAPHIQUE[i]));
assert.deepEqual(TEXTES.slice(-TEXTES_GRAPHIQUE.length), [...TEXTES_GRAPHIQUE]);
assert.ok(TEXTES.slice(-TEXTES_GRAPHIQUE.length).every((e, i) => e === TEXTES_GRAPHIQUE[i]));
const nomsGraphique = [...MARQUES_GRAPHIQUE, ...TEXTES_GRAPHIQUE, ...FONDS_GRAPHIQUE].map((e) => e.nom);
assert.equal(new Set(nomsGraphique).size, nomsGraphique.length);
const famille = new Set([...MARQUES_GRAPHIQUE, ...TEXTES_GRAPHIQUE]);
assert.deepEqual([...MARQUES, ...TEXTES, ...SIGNES].filter((e) => e.jeton.startsWith('--couleur-graphique-') && !famille.has(e)).map((e) => e.nom), []);
assert.deepEqual(SIGNES.filter((e) => nomsGraphique.includes(e.nom)).map((e) => e.nom), []);
});
test('les fonds de la famille — la grille et le niveau 0 — restent hors de MARQUES, de TEXTES et de SIGNES : ni leur nom, ni leur jeton comme jeton d’une entrée', () => {
const registres = [...MARQUES, ...TEXTES, ...SIGNES];
assert.deepEqual(registres.filter((e) => nomsGraphique.includes(e.nom)).map((e) => e.nom), []);
assert.deepEqual(registres.filter((e) => e.jeton.startsWith('--couleur-graphique-')).map((e) => e.nom), []);
for (const { nom, jeton } of FONDS_GRAPHIQUE) {
assert.deepEqual(registres.filter((e) => e.nom === nom).map((e) => e.nom), [], nom);
assert.deepEqual(registres.filter((e) => e.jeton === jeton).map((e) => e.nom), [], jeton);
}
});
test('l’échelle : quatre niveaux, 0 à 3, chacun son jeton et l’encre de son compte ; deux encres au plus ; figée', () => {

View file

@ -24,11 +24,20 @@
// et reglages_locaux.json (§ 11.6), puis l'ordre de lecture du § 2.1 sur
// l'événement retenu, les fenêtres — aucune pendant un geste non
// destructeur, une qui nomme ce qui part pour chaque geste qui détruit —,
// enfin chaque famille de geste modifiant en lecture puis sur un plan
// bloqué. Les valeurs attendues viennent du moteur, du CSV et du stockage,
// sollicités une seconde fois (§ 19.1), et le texte de la même traduction
// que celle de l'application. Aucun délai fixe : chaque étape attend un
// état du document (§ 19.3).
// puis chaque famille de geste modifiant en lecture puis sur un plan
// bloqué, enfin la page de qualité de l'étape 10 (B27). Ses captures se
// prennent toutes après les familles, chacune sur sa charge, pour ne rien
// changer de ce que 10a, 11, 19, 20 et les familles reçoivent : 12, 15, 15b
// et 15c sur la grande démonstration rouverte et générée sous la graine
// écrite, au compte réduit de la grande, la durée de 15c pilotée par le
// relais de l'exécuteur ; 12a, 13, 13a puis 12f sur la variante « conflit
// inévitable » ; 12b à 12e et 12g, chacune sur une configuration qu'un
// scénario court construit et génère ; 14 et 14a sur celle des paires ;
// 15a sur l'événement construit, rouvert, débloqué et généré de nouveau ;
// 16 sur la petite démonstration. Les valeurs attendues viennent du
// moteur, du CSV et du stockage, sollicités une seconde fois (§ 19.1), et
// le texte de la même traduction que celle de l'application. Aucun délai
// fixe : chaque étape attend un état du document (§ 19.3).
//
// Une épreuve qui porte une capture finit, ses conditions affirmées, par
// photographier (test/navigateur/captures.js), ou, pour une page du PDF de
@ -60,8 +69,10 @@ import { apercevoir } from '../csv/apercu.js';
import { decoder } from '../csv/encodage.js';
import { decouper } from '../csv/lecture.js';
import { CATALOGUE } from '../demo/catalogue.js';
import { constante } from '../geometrie/constantes.js';
import { regimeCibles } from '../geometrie/designation.js';
import { echelleGraphique } from '../geometrie/echelle.js';
import { niveau } from '../geometrie/graphique.js';
import { diametre, geometrieTable } from '../geometrie/tables.js';
import { vueDepuisTransform } from '../geometrie/vue.js';
import { classer } from '../moteur/classement.js';
@ -98,6 +109,7 @@ import { visible } from '../../test/navigateur/rendu.js';
import { ENCODAGES, SEPARATEURS } from './import/modele.js';
import { TABLES_INTERFACE } from './libelles.js';
import { LARGEUR_ECHELLE_PX } from './plan/modele.js';
import { colonneDeValeur } from './propositions/modele.js';
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
@ -234,6 +246,36 @@ const epreuvesEnEchec = [];
// proposition retenue, la charge d'avant l'ajustement de 19.
const memoire = {};
// Le pas dont le relais avance l'horloge pilotée à chaque avancement du
// moteur, pendant la génération de la grande démonstration de la page de
// qualité : la durée que 15c affiche vaut le nombre d'avancements multiplié
// par ce pas, la même à chaque exécution (§ 19.3, § 19.4).
const PAS_HORLOGE_GENERATION_MS = 250;
// Le relais d'épreuve : armé, il compte chaque avancement que le moteur
// publie et avance d'autant l'horloge pilotée du montage, dont le
// contrôleur de génération lit la durée du calcul ; désarmé, il est inerte.
const relais = { arme: false, avancements: 0 };
// L'exécuteur enveloppé du relais : les mêmes cœurs annoncés, le même
// calcul ; l'abonnement aux avancements se pose avant que le moteur ne
// tourne, dans la tâche que lancer rend, et seulement sous le relais armé.
function relayer(executeur) {
return {
coeursDisponibles: executeur.coeursDisponibles,
lancer(configuration, reglages, options) {
const execution = executeur.lancer(configuration, reglages, options);
if (relais.arme) {
execution.surAvancement(() => {
relais.avancements += 1;
m.horloge.avancer(PAS_HORLOGE_GENERATION_MS);
});
}
return execution;
},
};
}
// En écriture, le dossier d'attente part vide : le dépôt d'un passage
// interrompu ne se publie jamais avec celui-ci.
beforeAll(async () => {
@ -241,7 +283,7 @@ beforeAll(async () => {
retirerFeuille = poserFeuille();
await commands.requetesHorsBoucle();
if (modeFourni() === 'ecrire') await commands.viderAttente();
m = await monterApp({ provenance: 'documentation', executeur: creerExecuteurDirect({ coeursDisponibles: 4 }) });
m = await monterApp({ provenance: 'documentation', executeur: relayer(creerExecuteurDirect({ coeursDisponibles: 4 })) });
});
// La capture d'un échec prend pour nom de fichier le titre de son groupe et
@ -461,6 +503,24 @@ async function cadrerDansLaSurface(elements) {
await images(2);
}
// Fait défiler le premier ancêtre défilant de l'élément jusqu'à poser son
// haut ('haut') ou son bas ('bas') à 8 px du bord correspondant de la
// hauteur visible de cette surface : l'image montre l'élément à ce bord, et
// ce qui le suit ou le précède tant que la surface le laisse voir.
async function cadrerAuBord(element, bord) {
let porteur = element.parentElement;
while (porteur !== null && !(porteur.scrollHeight > porteur.clientHeight && /auto|scroll/.test(getComputedStyle(porteur).overflowY))) {
porteur = porteur.parentElement;
}
expect(porteur, 'une surface qui défile').not.toBeNull();
const cadre = porteur.getBoundingClientRect();
const visibleHaut = Math.max(cadre.top + porteur.clientTop, 0);
const visibleBas = Math.min(cadre.top + porteur.clientTop + porteur.clientHeight, innerHeight);
const boite = element.getBoundingClientRect();
porteur.scrollTop += Math.round(bord === 'haut' ? boite.top - visibleHaut - 8 : boite.bottom - visibleBas + 8);
await images(2);
}
// Fait défiler en largeur le premier ancêtre des éléments qui défile ainsi,
// jusqu'à centrer la réunion de leurs boîtes, qui doit tenir dans sa largeur
// visible : l'image les montre ensemble. Un tel ancêtre est exigé.
@ -1602,6 +1662,8 @@ describe('le parcours par gestes, depuis un répertoire neuf', { timeout: DUREE
await attendre('le premier écran', () => dans('[data-ecran="accueil"] [data-demonstration="grande"]'));
await cliquer('[data-demonstration="grande"]');
await attendre('la grande démonstration ouverte', () => dans('[data-ecran="evenement"]') && etat().evenement !== null && etat().evenement.base !== memoire.base);
// La page de qualité la rouvre à la fin du parcours (12, 15, 15b, 15c).
memoire.baseGrande = base();
await ouvrirSection('diagnostic');
const diagnostic = diagnostiquer(configurationDepuisCharge(charge()));
expect(diagnostic.retoursImposes).toBe(99);
@ -2763,6 +2825,876 @@ describe('lecture et bloqué : tout geste modifiant refusé, rien d’écrit', {
});
});
// --- La page de qualité (§ 12, § 19.3, B27) ---------------------------------------------
// Le compte d'arrêt de la génération de la grande démonstration : quatre
// points de relevé avant le dernier, et dix avancements du moteur, que le
// relais compte.
const ARRET_GRANDE = 2_000;
// La cadence des avancements que le moteur publie, en mouvements évalués :
// le relais en compte un à chaque multiple, proposition par proposition.
const CADENCE_AVANCEMENTS = 1_000;
// Des personnes et des appartenances inventées (contrainte 48), pour les
// configurations que la page de qualité photographie et qu'aucun catalogue
// ne porte : chaque configuration en prend les premières.
const PERSONNES_INVENTEES = Object.freeze([
['Ombrelle', 'Iris'],
['Pervenche', 'Théo'],
['Lacasse', 'Ondine'],
['Tourteau', 'Basile'],
['Quenouille', 'Maëlle'],
['Varech', 'Sidonie'],
['Aubépin', 'Lucien'],
['Bruyère', 'Aglaé'],
['Cormier', 'Félix'],
['Dorine', 'Ysé'],
['Escourgeon', 'Paulin'],
['Fenouil', 'Rosalie'],
]);
const APPARTENANCES_INVENTEES = Object.freeze([
'Coopérative Aubier',
'Atelier Ressac',
'Chantier Grève',
'Fanfare Ormeau',
'Rucher Salicorne',
'Cercle Varangue',
'Jardin Pétrel',
'Forge Brisant',
'Collectif Ajonc',
]);
// Les configurations construites (§ 19.3, B27) : le nom de l'événement,
// le nombre de personnes, l'appartenance de la personne de rang donné, les
// places par défaut, le nombre de tables, les places surchargées par rang
// de table, le nombre de tours, et le nombre de personnes réservées pour la
// soirée, dans l'ordre de leurs rangs, aux premiers sièges de la table 1.
const CONSTRUITES = Object.freeze({
uneTable: { nom: 'Une seule table', personnes: 6, appartenance: (rang) => APPARTENANCES_INVENTEES[rang % 3], sieges: 6, tables: 1, tours: 3 },
unTour: { nom: 'Un seul tour', personnes: 12, appartenance: (rang) => APPARTENANCES_INVENTEES[rang % 3], sieges: 4, tables: 3, tours: 1 },
uneAppartenance: { nom: 'Une seule appartenance', personnes: 12, appartenance: () => APPARTENANCES_INVENTEES[0], sieges: 4, tables: 3, tours: 3 },
sansCollision: { nom: 'Sans collision', personnes: 9, appartenance: (rang) => APPARTENANCES_INVENTEES[rang], sieges: 3, tables: 3, tours: 2 },
plafondNul: {
nom: 'Plafond nul',
personnes: 5,
appartenance: (rang) => APPARTENANCES_INVENTEES[rang % 2],
sieges: 4,
tables: 2,
surcharges: [[1, 2]],
tours: 2,
reservees: 4,
},
paires: { nom: 'Paires répétées', personnes: 12, appartenance: (rang) => APPARTENANCES_INVENTEES[rang % 3], sieges: 3, tables: 4, tours: 6 },
});
// Le CSV des participants d'une configuration construite, en UTF-8.
function csvConstruite({ personnes, appartenance }) {
const lignes = PERSONNES_INVENTEES.slice(0, personnes).map(([nom, prenom], rang) => `${nom};${prenom};${appartenance(rang)}`);
return new TextEncoder().encode(`nom;prenom;appartenance\r\n${lignes.join('\r\n')}\r\n`);
}
// Revient à la liste des événements, quand un événement est ouvert.
async function versLaListe() {
if (dans('[data-ecran="accueil"]') === null) await cliquer('[data-bandeau] [data-commande="liste"]');
await attendre('le premier écran', () => dans('[data-ecran="accueil"] [data-dossier-travail]'));
}
// Rouvre l'événement de base donnée depuis la liste.
async function rouvrir(baseVisee) {
await versLaListe();
await cliquer(`[data-evenement="${baseVisee}"] [data-commande="ouvrir"]`);
await attendre('l’événement rouvert', () => dans('[data-ecran="evenement"]') && etat().evenement?.base === baseVisee);
}
// Charge la démonstration du catalogue de clé donnée, depuis la liste : un
// événement neuf, sous une base qu'aucun événement de la liste ne porte.
async function chargerDemonstration(cle) {
await versLaListe();
const connues = new Set(tous('[data-evenement]').map((element) => element.dataset.evenement));
await cliquer(`[data-demonstration="${cle}"]`);
await attendre(`la démonstration ${cle}`, () => dans('[data-ecran="evenement"]') && etat().evenement !== null && !connues.has(etat().evenement.base));
}
async function passerEnEcriture() {
if (etat().mode === 'ecriture') return;
await cliquer('[data-place-fixe] [data-commande="modifier"]');
await attendre('l’écriture', () => etat().mode === 'ecriture');
}
// Génère sous la graine écrite, au compte d'arrêt donné, ou au compte de
// l'événement quand arret est null : une entrée, la question d'une
// génération longue confirmée si elle vient.
async function generer(arret = null) {
await ouvrirSection('generation');
await taper(dans('[data-section="generation"] input[data-champ="graine"]'), String(GRAINE));
if (arret !== null && charge().reglages.generation.arret !== arret) {
const r = revision();
await saisir(dans('[data-section="generation"] input[data-champ="generation.arret"]'), String(arret));
await uneEntree(r, 'le compte d’arrêt');
}
const r = revision();
await cliquer('[data-section="generation"] [data-commande="generer"]');
await attendre('la génération, ou sa question', () => revision() > r || dans('[data-question="longue"]'));
if (revision() === r) await cliquer('[data-commande="confirmerGeneration"]');
await uneEntree(r, 'générer');
expect(charge().propositions.length).toBeGreaterThan(0);
}
// La page de qualité de la première proposition du classement, ouverte par
// la commande « Qualité » de sa ligne ; rend son identifiant.
async function qualiteDeLaPremiere() {
await ouvrirSection('propositions');
const premiere = await attendre('le tableau classé', () => dans('[data-section="propositions"] [data-classement] tr[data-proposition]'));
const id = Number(premiere.dataset.proposition);
await appuyer(premiere.querySelector('[data-commande="qualite"]'), 'la commande « Qualité »');
await attendre('la page de qualité de la proposition', () => dans('[data-section="qualite"] [data-vue-qualite="performance"]') && m.ui.etat().proposition === id);
await images(3);
return id;
}
// Construit la configuration par les gestes de l'opérateur — créer
// l'événement, l'écriture, importer ses participants, ses places par
// défaut, ses tables, ses surcharges, ses tours, ses réservations pour la
// soirée —, génère au compte réduit, puis ouvre la page de qualité de la
// première proposition du classement ; rend son identifiant.
async function construire(configuration) {
const { nom, personnes, sieges, tables, surcharges = [], tours, reservees = 0 } = configuration;
await versLaListe();
await taper(dans('[data-ecran="accueil"] input[data-champ="nom"]'), nom);
await cliquer('[data-ecran="accueil"] [data-commande="creer"]');
await attendre(`l’événement « ${nom} »`, () => dans('[data-ecran="evenement"]') && etat().evenement?.nom === nom);
await passerEnEcriture();
await ouvrirSection('import');
await ouvrirApercu('participants_qualite.csv', csvConstruite(configuration));
let r = revision();
await cliquer('[data-apercu] [data-commande="importer"]');
await attendre('le rapport', () => dans('[data-rapport]'));
await uneEntree(r, 'importer');
expect(charge().participants).toHaveLength(personnes);
await ouvrirSection('tables');
if (charge().evenement.siegesParDefaut !== sieges) {
r = revision();
await saisir(dans('[data-section="tables"] input[data-champ="siegesParDefaut"]'), String(sieges));
await cliquer('[data-enonce="defaut"] [data-commande="appliquer"]');
await uneEntree(r, 'les places par défaut');
}
for (let rang = 0; rang < tables; rang += 1) {
r = revision();
await cliquer('[data-section="tables"] [data-commande="creerTableRonde"]');
await uneEntree(r, 'créer une table');
}
for (const [rang, places] of surcharges) {
const { id } = charge().tables[rang];
r = revision();
await saisir(dans(`[data-section="tables"] [data-table="${id}"] input[data-champ="sieges-${id}"]`), String(places));
await uneEntree(r, 'surcharger');
}
if (charge().evenement.tours !== tours) {
r = revision();
await saisir(dans('[data-section="tables"] input[data-champ="tours"]'), String(tours));
await uneEntree(r, 'les tours');
}
expect(charge().tables.map(({ sieges: places }) => places ?? sieges)).toEqual(
Array.from({ length: tables }, (_, rang) => surcharges.find(([rangSurcharge]) => rangSurcharge === rang)?.[1] ?? sieges),
);
expect(charge().evenement.tours).toBe(tours);
if (reservees > 0) {
await ouvrirPlan();
await regimeSieges();
const [t1] = charge().tables.map(({ id }) => id);
for (let rang = 0; rang < reservees; rang += 1) {
const participant = charge().participants[rang].id;
r = revision();
await choisir('[data-reserve]', participant);
await cliquerPointeur(chaise(t1, rang + 1));
const portee = await attendre('le choix de la portée', () => dans('[data-panneau="place"] select[data-champ="portee"]'));
portee.value = 'tous';
portee.dispatchEvent(new Event('change', { bubbles: true }));
flushSync();
await appuyer(actionPlace('reserver'), 'réserver pour la soirée');
await uneEntree(r, 'réserver pour la soirée');
expect(charge().reservations.at(-1)).toMatchObject({ participant, table: t1, siege: rang + 1, portee: 'tous' });
}
await oublierChoix();
}
await generer(ARRET);
return qualiteDeLaPremiere();
}
// Les lectures de la page de qualité.
const vueQualite = (nom) => dans(`[data-section="qualite"] [data-vue-qualite="${nom}"]`);
const graphique = (nom) => dans(`[data-section="qualite"] svg[data-graphique="${nom}"]`);
const phrasesQualite = () => tous('[data-section="qualite"] [data-phrase-qualite]').map((phrase) => phrase.dataset.phraseQualite);
const motif = (trace) => getComputedStyle(trace).strokeDasharray;
// Les axes verticaux d'un graphique : ses traits d'axe d'abscisse constante.
const axesVerticaux = (svg) =>
[...svg.querySelectorAll('line[data-marque="graphiqueAxe"]')].filter((axe) => axe.getAttribute('x1') === axe.getAttribute('x2')).length;
// Les graphiques de la page qui ne portent aucune marque de donnée — ni
// série, ni segment, ni case, ni point de relevé — : des axes vides, que
// la page ne dessine jamais (§ 12.6).
const axesVides = () =>
tous('[data-section="qualite"] svg[data-graphique]')
.filter((svg) => svg.querySelector('path[data-serie], rect[data-segment], [data-case], circle[data-point-releve]') === null)
.map((svg) => svg.dataset.graphique);
// Chaque [data-pourcentage] du porteur est suivi, son voisin immédiat, d'un
// [data-compte-pire] non vide (contrainte 44) ; rend leur nombre.
function exigerComptes(porteur) {
const pourcentages = [...porteur.querySelectorAll('[data-pourcentage]')];
for (const valeur of pourcentages) {
const compte = valeur.nextElementSibling;
expect(compte?.hasAttribute('data-compte-pire'), texteDe(valeur)).toBe(true);
expect(texteDe(compte)).not.toBe('');
}
return pourcentages.length;
}
// Le jumeau d'une vue, ouvert par sa commande, lu, puis refermé, sa
// commande quittée par le focus — l'image ne porte pas son contour : par
// tableau, chaque ligne, une Map colonne → texte de sa cellule.
async function lireJumeau(vue) {
await appuyer(vue.querySelector('[data-commande="ouvrirJumeau"]'), 'ouvrir le jumeau');
await attendre('le jumeau', () => vue.querySelector('[data-jumeau="0"]'));
const tableaux = [...vue.querySelectorAll('[data-jumeau]')].map((table) =>
[...table.querySelectorAll('tbody tr')].map((tr) => new Map([...tr.querySelectorAll('[data-colonne]')].map((cellule) => [cellule.dataset.colonne, texteDe(cellule)]))),
);
await appuyer(vue.querySelector('[data-commande="fermerJumeau"]'), 'fermer le jumeau');
await attendre('le jumeau fermé', () => vue.querySelector('[data-jumeau]') === null);
document.activeElement?.blur();
await images(2);
return tableaux;
}
// Pointe le rang 1 d'un graphique en escalier, au bord gauche de sa surface
// de pointage, puis rend l'infobulle ouverte : une Map libellé → texte.
async function pointerLeRang1(svg) {
const surface = svg.querySelector(':scope > rect[pointer-events="all"]');
const boite = surface.getBoundingClientRect();
await pointer('bouger', boite.left + 2, boite.top + boite.height / 2);
flushSync();
await images(2);
const bulle = tous('[data-section="qualite"] [role="tooltip"]').find((element) => !element.hidden);
expect(bulle, 'l’infobulle du rang pointé').toBeDefined();
const cellules = new Map([...bulle.querySelectorAll('.cellule')].map((cellule) => [texteDe(cellule.querySelector('dt')), cellule.querySelector('dd')]));
return { bulle, cellule: (cle) => texteDe(cellules.get(plat(t(cle)))) };
}
// Écarte le pointeur des graphiques : en haut à gauche de la fenêtre.
async function ecarterLePointeur() {
await pointer('bouger', 1, 1);
flushSync();
await images(2);
}
// Le compte et le plafond réalisé minimaux de la proposition id, mesurés par
// le moteur sur son plan, sous la configuration de l'événement ouvert.
function minimumDe(id) {
const instance = normaliser(configurationDepuisCharge(charge()));
const proposition = charge().propositions.find((p) => p.id === id);
return mesurer(instance, indexerPlan(instance, planDepuisFichier(proposition))).aggRencontres.tous.min;
}
// La ligne de la proposition id au tableau des propositions : une Map
// colonne → texte de sa cellule.
async function ligneAuTableau(id) {
await ouvrirSection('propositions');
const ligne = await attendre('la ligne de la proposition', () => dans(`[data-section="propositions"] tr[data-proposition="${id}"]`));
return new Map([...ligne.querySelectorAll('td[data-colonne]')].map((cellule) => [cellule.dataset.colonne, texteDe(cellule)]));
}
// La page de qualité d'une proposition déjà au plan, rouverte par la
// navigation.
async function revenirALaQualite(id) {
await ouvrirSection('qualite');
await attendre('la page de qualité', () => vueQualite('performance') && m.ui.etat().proposition === id);
await images(3);
}
// La page mesure la proposition id que la séance montre, avant toute
// retenue : la ligne de source porte le libellé que la barre de vue du plan
// donne à une proposition montrée sans placement retenu, et non celui du
// retenu.
function exigerLaPropositionMontree(id) {
expect(m.ui.etat().proposition, 'la proposition montrée').toBe(id);
expect(charge().retenu).toBeNull();
const placement = texteDe(dans('[data-section="qualite"] [data-source-qualite] .placement'));
expect(placement, 'la ligne de source').toBe(plat(t('plan.source.propositionReservations', { proposition: id })));
}
// La configuration construite des paires est celle de l'événement ouvert :
// 12 personnes, 4 tables de 3 places, 6 tours.
function exigerLaConstruiteDesPaires() {
const { nom, personnes, tables, sieges, tours } = CONSTRUITES.paires;
expect(charge().evenement.nom).toBe(nom);
expect(charge().participants).toHaveLength(personnes);
expect(charge().tables.map(({ sieges: places }) => places ?? charge().evenement.siegesParDefaut)).toEqual(Array.from({ length: tables }, () => sieges));
expect(charge().evenement.tours).toBe(tours);
}
describe('la page de qualité (§ 12, § 19.3, B27)', { timeout: DUREE }, () => {
// Le plan bloqué que les familles laissent n'est plus touché : chaque
// capture se prend sur sa charge, celle que le parcours charge ou
// construit ici, et rien ne revient aux captures précédentes.
test('12 la grande générée, durée pilotée : le profil en deux graphiques, un axe vertical chacun, le même rang 1', async () => {
await rouvrir(memoire.baseGrande);
await passerEnEcriture();
// Le relais est resté inerte pour chaque génération qui précède.
expect(relais.avancements, 'aucun avancement compté avant la grande').toBe(0);
relais.arme = true;
const debut = m.horloge.instant();
try {
await generer(ARRET_GRANDE);
} finally {
relais.arme = false;
}
memoire.grande = { avancements: relais.avancements, dureeMs: m.horloge.instant() - debut };
expect(relais.avancements).toBeGreaterThan(0);
const id = await qualiteDeLaPremiere();
memoire.grande.id = id;
// La page a dessiné plutôt que d'opposer son refus (§ 12.3).
const profil = await attendre('les deux graphiques du profil', () => graphique('profilPourcentages') && vueQualite('profil'));
expect(phrasesQualite()).not.toContain('refusDessin');
const comptes = graphique('profilComptes');
const pourcentages = graphique('profilPourcentages');
for (const svg of [comptes, pourcentages]) expect(axesVerticaux(svg), svg.dataset.graphique).toBe(1);
// Deux séries en comptes, une en pourcentages ; le plafond, la borne de
// diversité et la règle à 100 % en repères, aucun du motif d'une série.
expect(comptes.querySelectorAll('path[data-serie]')).toHaveLength(2);
expect(pourcentages.querySelectorAll('path[data-serie]')).toHaveLength(1);
for (const [svg, repere] of [[comptes, 'plafond'], [comptes, 'borne'], [pourcentages, 'cent']]) {
expect(svg.querySelector(`path[data-repere="${repere}"]`), repere).not.toBeNull();
}
const motifsDe = (attribut) => [comptes, pourcentages].flatMap((svg) => [...svg.querySelectorAll(`path[${attribut}]`)].map(motif));
const series = motifsDe('data-serie');
for (const repere of motifsDe('data-repere')) expect(series).not.toContain(repere);
expect(texteDe(comptes.querySelector('text[data-repere="borne"]'))).toBe(plat(t('qualite.profil.repere.borne')));
expect(exigerComptes(profil)).toBeGreaterThan(0);
// Le même rang 1 dans les deux graphiques : le pire cas, le minimum que
// le moteur mesure sur le plan de la proposition. Les graphiques se
// cadrent d'abord : le pointeur atteint ce que la fenêtre montre. Les
// deux ensemble sont plus hauts que la surface : le premier se pose en
// haut, et le second se montre jusqu'à sa règle à 100 %, ses
// pourcentages au-dessous de la règle, son axe des rangs hors de l'image.
await cadrerAuBord(comptes, 'haut');
const { cellule } = await pointerLeRang1(comptes);
expect(comptes.querySelector('[data-rang]')?.getAttribute('data-rang')).toBe('1');
expect(pourcentages.querySelector('[data-rang]')?.getAttribute('data-rang')).toBe('1');
expect(cellule('qualite.profil.colonne.rencontres')).toBe(String(minimumDe(id)));
await ecarterLePointeur();
expect(tous('[data-section="qualite"] [role="tooltip"]').every((bulle) => bulle.hidden)).toBe(true);
await photographier(m, '12-qualite-profil', {
condition: [
'[data-vue-qualite="profil"] svg[data-graphique="profilComptes"]',
'[data-vue-qualite="profil"] svg[data-graphique="profilPourcentages"] path[data-repere="cent"]',
'[data-vue-qualite="profil"] svg[data-graphique="profilPourcentages"] text[data-repere="cent"]',
],
graine: GRAINE,
});
});
test('15 la grande, 260 participants : la phrase du seuil au lieu de la matrice', async () => {
const vue = vueQualite('paires');
const seuil = constante('SEUIL_MATRICE_PARTICIPANTS');
const presents = charge().participants.filter(({ exclu }) => !exclu).length;
expect(presents).toBe(260);
expect(presents).toBeGreaterThan(seuil);
expect(vue.querySelector('svg[data-graphique="matrice"]')).toBeNull();
const phrase = vue.querySelector('[data-phrase-qualite="matriceSeuil"]');
expect(texteDe(phrase)).toBe(plat(t('qualite.paires.matrice.seuil', { participants: format.nombre(presents), seuil: format.nombre(seuil) })));
await cadrerDansLaSurface([vue.querySelector('h4') ?? phrase, phrase]);
await photographier(m, '15-pas-de-matrice', { condition: ['[data-vue-qualite="paires"] [data-phrase-qualite="matriceSeuil"]'], graine: GRAINE });
});
test('15b la grande : les animateurs sous leur borne pointillée, sans « plafond » ; la diversité, le moins varié en tête', async () => {
const animateurs = vueQualite('animateurs');
const svg = animateurs.querySelector('svg[data-graphique="animateurs"]');
expect(svg).not.toBeNull();
expect(axesVerticaux(svg)).toBe(1);
const borne = svg.querySelector('path[data-repere="borne"]');
expect(borne).not.toBeNull();
expect(motif(borne)).not.toBe(motif(svg.querySelector('path[data-serie]')));
expect(texteDe(animateurs)).not.toMatch(/plafond/i);
// La première barre de la soirée porte le minimum annoncé : le moins
// varié de chaque colonne dessinée.
const diversite = vueQualite('diversite');
const colonnes = [...diversite.querySelectorAll('[data-equite]')];
expect(colonnes.length).toBeGreaterThan(0);
const [soirees] = await lireJumeau(diversite);
for (const colonne of colonnes) {
const moins = texteDe(colonne.querySelector('dd'));
expect(soirees[0].get(colonne.dataset.equite), colonne.dataset.equite).toBe(moins);
}
// Le profil des animateurs en haut de la surface, puis le haut de la
// diversité : son graphique, plus bas, ne tient pas avec lui.
await cadrerAuBord(svg, 'haut');
await photographier(m, '15b-qualite-animateurs', {
condition: ['[data-vue-qualite="animateurs"] svg[data-graphique="animateurs"]'],
graine: GRAINE,
});
});
test('15c la grande : la course, la durée pilotée ; le dernier point est la ligne de la proposition', async () => {
const { id, avancements, dureeMs } = memoire.grande;
// Le moteur sollicité une seconde fois : les mouvements évalués de
// chaque proposition, dont le relais a compté les avancements.
const { nombre, historique } = charge().reglages.generation;
const propositions = rechercher(configurationDepuisCharge(charge()), { graine: GRAINE, nombre, arret: ARRET_GRANDE, historique });
const evalues = propositions.reduce((somme, p) => somme + p.evalues, 0);
expect(avancements).toBe(propositions.reduce((somme, p) => somme + Math.floor(p.evalues / CADENCE_AVANCEMENTS), 0));
expect(dureeMs).toBe(avancements * PAS_HORLOGE_GENERATION_MS);
const montree = propositions.find(({ graine }) => graine === charge().propositions.find((p) => p.id === id).graine);
const vue = vueQualite('performance');
const tete = [...vue.querySelectorAll('[data-course] li')].map(texteDe);
expect(tete).toEqual([
plat(t('qualite.performance.mouvements', { evalues: format.nombre(montree.evalues), arret: format.nombre(ARRET_GRANDE) })),
plat(t('qualite.performance.duree', { duree: format.duree(dureeMs) })),
plat(t('qualite.performance.coeurs', { coeurs: 1 })),
plat(t('qualite.performance.cadence', { cadence: format.nombre(Math.round((evalues * 1000) / dureeMs)) })),
plat(t('qualite.performance.propositions', { nombre })),
]);
expect(vue.querySelectorAll('svg[data-graphique]').length).toBeGreaterThan(0);
expect(vue.querySelector('[data-dernier-changement]')).not.toBeNull();
// Le dernier point de relevé est le plan de la proposition : ses
// mouvements, ses valeurs égales à sa ligne au tableau des propositions.
const [points] = await lireJumeau(vue);
memoire.grande.points = points;
const dernier = points.at(-1);
expect(dernier.get('mouvements')).toBe(plat(format.nombre(montree.evalues)));
const ligne = await ligneAuTableau(id);
const colonneAuTableau = (nom) => (nom === 'minimumBrut' ? 'rencontresTous' : colonneDeValeur(nom));
for (const [nom, valeur] of dernier) if (nom !== 'mouvements') expect(valeur, nom).toBe(ligne.get(colonneAuTableau(nom)));
await revenirALaQualite(id);
// La ligne de tête au haut de la surface, et sous elle les deux premiers
// rangs des petits multiples : la vue entière, la phrase du dernier
// changement comprise, est plus haute que la surface. Le bord haut tombe
// entre la question de la vue et la ligne de tête, le bas entre deux rangs.
const performance = vueQualite('performance');
const multiples = [...performance.querySelectorAll('svg[data-graphique]')];
const dernierMontre = multiples[Math.min(3, multiples.length - 1)];
await cadrerAuBord(performance.querySelector('[data-course]'), 'haut');
await photographier(m, '15c-qualite-performance', {
condition: ['[data-vue-qualite="performance"] [data-course]', `[data-vue-qualite="performance"] svg[data-graphique="${dernierMontre.dataset.graphique}"]`],
graine: GRAINE,
});
});
test('15c rouverte, sans capture : la même course, la durée « inconnue »', async () => {
const { id, points } = memoire.grande;
await rouvrir(memoire.baseGrande);
await ouvrirSection('propositions');
await cliquer(`[data-section="propositions"] tr[data-proposition="${id}"] [data-commande="qualite"]`);
await revenirALaQualite(id);
const vue = vueQualite('performance');
const tete = [...vue.querySelectorAll('[data-course] li')].map(texteDe);
expect(tete).toContain(plat(t('qualite.performance.dureeInconnue')));
// Le début du gabarit de la cadence, coupé à la place de sa valeur :
// aucun morceau de la ligne de tête ne l'écrit.
const [avantCadence] = plat(t('qualite.performance.cadence', { cadence: '\u{0}' })).split('\u{0}');
expect(avantCadence).not.toBe('');
expect(tete.filter((morceau) => morceau.startsWith(avantCadence))).toEqual([]);
const [relus] = await lireJumeau(vue);
expect(relus.map((ligne) => [...ligne])).toEqual(points.map((ligne) => [...ligne]));
});
test('12a la variante « conflit », sans placement : la phrase et le diagnostic, aucun graphique', async () => {
await chargerDemonstration('petite-conflit');
memoire.baseConflit = base();
await ouvrirSection('qualite');
const phrase = await attendre('la phrase d’aucun placement', () => dans('[data-section="qualite"] [data-phrase-qualite="aucunPlacement"]'));
expect(texteDe(phrase)).toBe(plat(t('qualite.phrase.aucunPlacement')));
expect(phrasesQualite()).toEqual(['aucunPlacement']);
expect(tous('[data-section="qualite"] svg[data-graphique]')).toEqual([]);
expect(dans('[data-section="qualite"] [data-source-qualite]')).not.toBeNull();
expect(tous('[data-section="qualite"] [data-phrase]').length, 'le diagnostic préalable').toBeGreaterThan(0);
await photographier(m, '12a-qualite-sans-placement', {
condition: ['[data-section="qualite"] [data-source-qualite]', '[data-section="qualite"] [data-phrase-qualite="aucunPlacement"]'],
graine: GRAINE,
});
});
test('13 la variante générée : deux segments, le prouvé égal au plancher, 4, triées sur l’excédent', async () => {
await passerEnEcriture();
await generer(ARRET);
memoire.conflit = { id: await qualiteDeLaPremiere() };
exigerLaPropositionMontree(memoire.conflit.id);
const vue = vueQualite('collisions');
const svg = vue.querySelector('svg[data-graphique="collisions"]');
expect(svg).not.toBeNull();
expect(svg.querySelectorAll('rect[data-segment="prouve"]').length).toBeGreaterThan(0);
const diagnostic = diagnostiquer(configurationDepuisCharge(charge()));
const plancherDe = new Map(diagnostic.collisions.map(({ groupe, plancherCumulees }) => [groupe, plancherCumulees]));
expect([...plancherDe.values()]).toContain(4);
const [lignes] = await lireJumeau(vue);
expect(lignes.length).toBeGreaterThan(0);
for (const ligne of lignes) {
expect(ligne.get('prouve'), ligne.get('appartenance')).toBe(String(plancherDe.get(ligne.get('appartenance')) ?? 0));
}
expect(lignes.map((ligne) => ligne.get('prouve'))).toContain('4');
const excedents = lignes.map((ligne) => Number(ligne.get('excedent')));
expect(excedents).toEqual([...excedents].sort((a, b) => b - a));
await cadrerDansLaSurface([svg]);
await photographier(m, '13-qualite-collisions', { condition: ['[data-vue-qualite="collisions"] svg[data-graphique="collisions"]'], graine: GRAINE });
});
test('13a les tables sous « sièges » : la pire en tête, chaque case écrit le compte que son niveau colore', async () => {
// La proposition générée, et non le retenu que 12f ajuste ensuite.
exigerLaPropositionMontree(memoire.conflit.id);
const vue = vueQualite('tables');
expect(vue.querySelector('input[data-position-tables="sieges"]').checked).toBe(true);
const svg = vue.querySelector('svg[data-graphique="tables"]');
expect(svg).not.toBeNull();
const cases = [...svg.querySelectorAll('[data-case]')];
expect(cases.length).toBe(charge().tables.length * charge().evenement.tours);
const comptes = new Map();
for (const c of cases) {
const [ligne] = c.dataset.case.split(':').map(Number);
const texte = texteDe(c.querySelector('text'));
if (texte === t('format.vide')) {
expect(c.querySelector('rect[data-marque^="graphiqueNiveau"]'), c.dataset.case).toBeNull();
continue;
}
const compte = Number(texte);
expect(Number.isInteger(compte), c.dataset.case).toBe(true);
const n = niveau(compte);
const fond = c.querySelector('rect:not(.bordure)');
expect(fond?.getAttribute('data-marque') ?? null, c.dataset.case).toBe(n > 0 ? `graphiqueNiveau${n}` : null);
expect(fond?.style.fill ?? '', c.dataset.case).toContain(`--couleur-graphique-niveau-${n}`);
comptes.set(ligne, Math.max(comptes.get(ligne) ?? 0, compte));
}
expect(comptes.get(0)).toBe(Math.max(...comptes.values()));
await cadrerDansLaSurface([vue.querySelector('fieldset'), svg]);
await photographier(m, '13a-qualite-tables', {
condition: ['[data-vue-qualite="tables"] input[data-position-tables="sieges"]', '[data-vue-qualite="tables"] svg[data-graphique="tables"]'],
graine: GRAINE,
});
});
test('12f une personne mobile en réserve au tour 2 : sa marque, les collisions d’un seul segment', async () => {
const { id } = memoire.conflit;
await ouvrirSection('propositions');
let r = revision();
await cliquer(`[data-section="propositions"] tr[data-proposition="${id}"] [data-commande="retenir"]`);
await uneEntree(r, 'retenir');
expect(charge().retenu.proposition).toBe(id);
await ouvrirPlan();
if (m.ui.etat().proposition !== null) {
await cliquer('[data-commande="quitterProposition"]');
await attendre('le retenu montré', () => m.ui.etat().proposition === null);
}
const selecteurTour = dans('[data-champ="tour"]');
selecteurTour.value = '2';
selecteurTour.dispatchEvent(new Event('change', { bubbles: true }));
await attendre('le tour 2 montré', () => m.ui.etat().tour === 2);
await images(2);
await regimeSieges();
// Une personne mobile : aucune réservation ne la tient, à aucun tour.
const tenues = new Set(charge().reservations.map(({ participant }) => participant));
const assise = chaiseVue((c) => c.occupant !== null && !tenues.has(c.occupant));
const mobile = assise.occupant;
r = revision();
await cliquerPointeur(chaise(assise.table, assise.siege));
await cliquer(`[data-panneau="place"] [data-menu="${mobile}"] [data-commande="retirerDuTour"]`);
await uneEntree(r, 'retirer du tour 2');
selecteurTour.value = '1';
selecteurTour.dispatchEvent(new Event('change', { bubbles: true }));
await attendre('le tour 1 montré', () => m.ui.etat().tour === 1);
await ouvrirSection('qualite');
await attendre('la page du retenu', () => vueQualite('profil')?.querySelector('svg[data-graphique="profilComptes"]'));
await images(3);
const nom = nomAffiche(charge().participants.find((p) => p.id === mobile));
const profil = vueQualite('profil');
expect(profil.querySelectorAll('svg[data-graphique="profilComptes"] [data-marque="graphiqueMarque"]').length).toBeGreaterThan(0);
const [rangs] = await lireJumeau(profil);
const sienne = rangs.find((ligne) => ligne.get('nom') === plat(nom));
expect(sienne?.get('marque'), nom).toBe(plat(t('qualite.profil.marque.reserve')));
// Hors du domaine des planchers, une barre d'un seul segment, et la
// phrase qui le dit (D31).
const collisions = vueQualite('collisions');
expect(collisions.querySelector('svg[data-graphique="collisions"]')).not.toBeNull();
expect(collisions.querySelector('rect[data-segment="prouve"]')).toBeNull();
const segments = [...collisions.querySelectorAll('svg[data-graphique="collisions"] rect[data-segment]')];
expect(segments.length).toBeGreaterThan(0);
expect([...new Set(segments.map((segment) => segment.dataset.segment))]).toEqual(['serie1']);
expect(axesVides()).toEqual([]);
const phrase = collisions.querySelector('[data-phrase-qualite="collisionsHorsDomaine"]');
expect(texteDe(phrase)).toBe(plat(t('qualite.collisions.horsDomaine')));
// Le premier graphique du profil en haut de la surface, son signe de la
// réserve : les barres des collisions, plus bas, ne tiennent pas avec
// lui, et s'affirment sans l'image.
await cadrerAuBord(profil.querySelector('svg[data-graphique="profilComptes"]'), 'haut');
await photographier(m, '12f-qualite-reserve', {
condition: [
'[data-vue-qualite="profil"] svg[data-graphique="profilComptes"]',
'[data-vue-qualite="profil"] svg[data-graphique="profilComptes"] [data-marque="graphiqueMarque"]',
],
graine: GRAINE,
});
});
test('12b une seule table : sa phrase, ni profil, ni barre, ni grille, ni paires', async () => {
await construire(CONSTRUITES.uneTable);
for (const code of ['uneTable', 'collisionsUneTable', 'tablesUneTable', 'pairesUneTable']) expect(phrasesQualite(), code).toContain(code);
for (const nom of ['profilComptes', 'profilPourcentages', 'collisions', 'tables', 'matrice']) expect(graphique(nom), nom).toBeNull();
expect(dans('[data-section="qualite"] [data-paire]')).toBeNull();
expect(axesVides()).toEqual([]);
const phrase = vueQualite('profil').querySelector('[data-phrase-qualite="uneTable"]');
expect(texteDe(phrase)).toBe(plat(t('qualite.profil.uneTable')));
await cadrerDansLaSurface([phrase, vueQualite('paires').querySelector('[data-phrase-qualite="pairesUneTable"]')]);
await photographier(m, '12b-qualite-une-table', {
condition: ['[data-vue-qualite="profil"] [data-phrase-qualite="uneTable"]', '[data-vue-qualite="paires"] [data-phrase-qualite="pairesUneTable"]'],
graine: GRAINE,
});
});
test('12c un seul tour : le profil et les collisions dessinés, la phrase des paires', async () => {
await construire(CONSTRUITES.unTour);
expect(graphique('profilComptes')).not.toBeNull();
expect(graphique('collisions')).not.toBeNull();
expect(phrasesQualite()).toContain('pairesUnTour');
expect(dans('[data-section="qualite"] [data-vue-qualite="paires"] table')).toBeNull();
expect(axesVides()).toEqual([]);
const phrase = vueQualite('paires').querySelector('[data-phrase-qualite="pairesUnTour"]');
expect(texteDe(phrase)).toBe(plat(t('qualite.paires.unTour')));
// La vue des tables entière, sa grille d'un seul tour, et la phrase des
// paires : le profil et les collisions, plus haut, ne tiennent pas avec
// elles dans la surface, et s'affirment sans l'image.
await cadrerDansLaSurface([vueQualite('tables'), phrase]);
await photographier(m, '12c-qualite-un-tour', {
condition: [
'[data-vue-qualite="tables"]',
'[data-vue-qualite="tables"] svg[data-graphique="tables"]',
'[data-vue-qualite="paires"] [data-phrase-qualite="pairesUnTour"]',
],
graine: GRAINE,
});
});
test('12d une seule appartenance : une série, sans borne, sa phrase ; les collisions et la grille restent', async () => {
await construire(CONSTRUITES.uneAppartenance);
const comptes = graphique('profilComptes');
expect(comptes.querySelectorAll('path[data-serie]')).toHaveLength(1);
expect(comptes.querySelector('[data-repere="borne"]')).toBeNull();
const phrase = vueQualite('profil').querySelector('[data-phrase-qualite="uneAppartenance"]');
expect(texteDe(phrase)).toBe(plat(t('qualite.profil.uneAppartenance')));
expect(graphique('collisions')?.querySelectorAll('rect[data-segment]').length).toBeGreaterThan(0);
expect(graphique('tables')).not.toBeNull();
expect(axesVides()).toEqual([]);
await cadrerDansLaSurface([phrase, comptes]);
await photographier(m, '12d-qualite-une-appartenance', {
condition: ['[data-vue-qualite="profil"] [data-phrase-qualite="uneAppartenance"]', '[data-vue-qualite="profil"] svg[data-graphique="profilComptes"]'],
graine: GRAINE,
});
});
test('12e aucune collision : la phrase ; la grille sous « sièges », chaque case 0', async () => {
await construire(CONSTRUITES.sansCollision);
expect(graphique('collisions')).toBeNull();
const phrase = vueQualite('collisions').querySelector('[data-phrase-qualite="collisionsAucune"]');
expect(texteDe(phrase)).toBe(plat(t('qualite.collisions.aucune')));
const tables = vueQualite('tables');
expect(tables.querySelector('input[data-position-tables="sieges"]').checked).toBe(true);
const cases = [...graphique('tables').querySelectorAll('[data-case]')];
expect(cases).toHaveLength(3 * 2);
for (const c of cases) expect(texteDe(c.querySelector('text')), c.dataset.case).toBe('0');
expect(axesVides()).toEqual([]);
await cadrerDansLaSurface([phrase, graphique('tables')]);
await photographier(m, '12e-qualite-sans-collision', {
condition: ['[data-vue-qualite="collisions"] [data-phrase-qualite="collisionsAucune"]', '[data-vue-qualite="tables"] svg[data-graphique="tables"]'],
graine: GRAINE,
});
});
test('12g la cinquième seule à la table de 2 : plafond réalisé nul au rang 1, son infobulle', async () => {
await construire(CONSTRUITES.plafondNul);
const seule = charge().participants[4];
const comptes = graphique('profilComptes');
const pourcentages = graphique('profilPourcentages');
// Le graphique au bas de la surface : l'infobulle du rang 1, plus haute
// que l'espace sous son point, se pose au-dessus de lui, dans l'image.
await cadrerAuBord(comptes, 'bas');
const { bulle, cellule } = await pointerLeRang1(comptes);
expect(cellule('qualite.profil.colonne.nom')).toBe(plat(nomAffiche(seule)));
expect(cellule('qualite.profil.colonne.plafondRealise')).toBe('0');
expect(cellule('qualite.profil.colonne.marque')).toBe(plat(t('qualite.profil.marque.plafondNul')));
expect(texteDe(bulle.querySelector('[data-pourcentage]'))).toBe(t('format.vide'));
// Au second graphique, aucun point au rang 1 : la série commence après
// son signe, posé au pied de l'axe.
const signes = [...pourcentages.querySelectorAll('[data-marque="graphiqueMarque"]')].map((signe) => signe.getBoundingClientRect());
const premierSigne = signes.reduce((a, b) => (b.left < a.left ? b : a));
const debutSerie = Number(/^M\s*([-\d.]+)/.exec(pourcentages.querySelector('path[data-serie]').getAttribute('d'))[1]);
const gaucheSvg = pourcentages.getBoundingClientRect().left;
expect(debutSerie + gaucheSvg).toBeGreaterThan(premierSigne.right);
expect(axesVides()).toEqual([]);
await photographier(m, '12g-qualite-plafond-nul', {
condition: ['[data-vue-qualite="profil"] svg[data-graphique="profilComptes"]', '[data-section="qualite"] [role="tooltip"]:not([hidden])'],
graine: GRAINE,
});
});
test('14 les paires répétées : le tableau, la première commande au premier tour revu', async () => {
await ecarterLePointeur();
memoire.paires = { id: await construire(CONSTRUITES.paires) };
exigerLaConstruiteDesPaires();
const vue = vueQualite('paires');
const lignes = [...vue.querySelectorAll('tr[data-paire]')];
expect(lignes.length).toBeGreaterThan(0);
const fois = lignes.map((ligne) => Number(/\d+/.exec(texteDe(ligne.querySelector('td.nombre')))[0]));
for (const ligne of lignes) {
const [premiere, ...tours] = [...ligne.querySelectorAll('[data-commande="allerAuTour"]')];
expect(tours.length).toBeGreaterThanOrEqual(2);
const numeros = tours.map((bouton) => Number(/\d+/.exec(texteDe(bouton))[0]));
expect(texteDe(premiere)).toBe(plat(t('qualite.paires.premierTour', { tour: numeros[1] })));
}
expect(fois[0]).toBe(Math.max(...fois));
memoire.paires.premiere = { cle: lignes[0].dataset.paire, tour: Number(/\d+/.exec(texteDe(lignes[0].querySelector('.premiere')))[0]) };
await cadrerDansLaSurface([vue.querySelector('thead'), lignes[Math.min(lignes.length, 5) - 1]]);
await photographier(m, '14-qualite-paires', { condition: ['[data-vue-qualite="paires"] tr[data-paire]'], graine: GRAINE });
});
test('14a la commande mène au tour revu ; la fiche, ses « Rencontres », égales au tableau des paires', async () => {
const { cle, tour } = memoire.paires.premiere;
exigerLaConstruiteDesPaires();
const [a] = cle.split(':').map(Number);
// Les paires de la personne au tableau : l'autre, ses fois, ses tours.
const deLaPersonne = tous('[data-section="qualite"] tr[data-paire]')
.filter((ligne) => ligne.dataset.paire.split(':').map(Number).includes(a))
.map((ligne) => {
const [x, y] = ligne.dataset.paire.split(':').map(Number);
const autre = x === a ? y : x;
const tours = [...ligne.querySelectorAll('[data-commande="allerAuTour"]:not(.premiere)')].map((bouton) => texteDe(bouton).match(/\d+/g).map(Number));
return [plat(nomAffiche(charge().participants.find((p) => p.id === autre))), Number(/\d+/.exec(texteDe(ligne.querySelector('td.nombre')))[0]), tours];
});
expect(deLaPersonne.length).toBeGreaterThan(0);
await cliquer(`[data-section="qualite"] tr[data-paire="${cle}"] .premiere`);
await attendre('le plan au premier tour revu', () => dans('[data-section="plan"]') && m.ui.etat().tour === tour);
await ouvrirSection('participants');
await cliquer(`[data-section="participants"] [data-participant="${a}"] [data-commande="fiche"]`);
const rubrique = await attendre('la rubrique « Rencontres »', () => dans(`[data-fiche="${a}"] [data-rubrique-rencontres]`));
const lignes = [...rubrique.querySelectorAll('tbody tr')].map((ligne) => [
texteDe(ligne.querySelector('.nom')),
Number(texteDe(ligne.querySelector('td.fois'))),
texteDe(ligne.querySelectorAll('td')[1]).split(' ; ').map((etape) => etape.match(/\d+/g).map(Number)),
]);
// Les plus revues d'abord ; celles revues plus d'une fois sont les
// lignes de la personne au tableau des paires, une pour une.
const fois = lignes.map(([, f]) => f);
expect(fois).toEqual([...fois].sort((x, y) => y - x));
expect(lignes.filter(([, f]) => f > 1)).toEqual(deLaPersonne);
// La rubrique jusqu'à ses quatre premières rencontres, les plus revues
// d'abord : le haut de la fiche, son nom compris, ne tient pas avec
// elles dans la surface du panneau.
const rangees = [...rubrique.querySelectorAll('tbody tr')];
await cadrerDansLaSurface([rubrique.querySelector('h4'), rangees[Math.min(rangees.length, 4) - 1]]);
await photographier(m, '14a-fiche-rencontres', {
condition: [`[data-fiche="${a}"] [data-rubrique-rencontres] h4`, `[data-fiche="${a}"] [data-rubrique-rencontres] tbody tr`],
graine: GRAINE,
});
});
test('15a l’événement construit généré de nouveau : jamais servies, la soirée, chaque tour et ses assis sans table', async () => {
if (dans('[data-fiche]') !== null) {
await cliquer('[data-fiche] [data-commande="fermerFiche"]');
await attendre('la fiche fermée', () => dans('[data-fiche]') === null);
}
// Le plan bloqué que les familles laissent : « Débloquer », à place
// fixe, se confirme sur place, puis l'écriture.
await rouvrir(memoire.base);
expect(charge().evenement.etat).toBe('bloque');
const r = revision();
await cliquer('[data-place-fixe] [data-commande="debloquer"]');
await cliquer('[data-place-fixe] [data-commande="debloquer"]');
await uneEntree(r, 'débloquer');
await passerEnEcriture();
await generer();
const id = await qualiteDeLaPremiere();
const vue = vueQualite('objectifs');
expect(vue.querySelector('[data-jamais-servies]')).not.toBeNull();
const soiree = vue.querySelector('[data-soiree-objectifs]');
expect(soiree).not.toBeNull();
expect(soiree.querySelector('[data-pourcentage] + [data-compte-pire]')).not.toBeNull();
const tours = [...vue.querySelectorAll('ol.tours > li')];
expect(tours).toHaveLength(charge().evenement.tours);
for (const tour of tours) expect(tour.querySelector('[data-pourcentage] + [data-compte-pire]'), texteDe(tour)).not.toBeNull();
exigerComptes(vue);
const [parTour] = await lireJumeau(vue);
const lignesTours = parTour.slice(0, -1);
const laSoiree = parTour.at(-1);
expect(laSoiree.get('tour')).toBe(plat(t('qualite.objectifs.soiree')));
// Chaque barre de tour écrit ses assis sans table possible.
const barres = [...vue.querySelectorAll('svg[data-graphique="objectifs"] [data-ligne-graphique]')];
expect(barres).toHaveLength(lignesTours.length);
barres.forEach((barre, rang) => {
expect(texteDe(barre.querySelector('text.secondaire')), lignesTours[rang].get('tour')).toBe(
plat(t('qualite.objectifs.assisSansTable', { assis: Number(lignesTours[rang].get('assis')) })),
);
});
// La somme des planchers des tours égale le plancher du diagnostic ;
// possibles − servis de la soirée égale la colonne du tableau.
const diagnostic = diagnostiquer(configurationDepuisCharge(charge()));
const somme = lignesTours.reduce((total, ligne) => total + Number(ligne.get('plancher')), 0);
expect(somme).toBe(diagnostic.objectifsInevitables.horsObjectif);
expect(Number(laSoiree.get('plancher'))).toBe(somme);
const hors = Number(laSoiree.get('possibles')) - Number(laSoiree.get('servis'));
const ligne = await ligneAuTableau(id);
expect(ligne.get('toursHorsObjectif')).toBe(plat(format.nombre(hors)));
await revenirALaQualite(id);
const objectifs = vueQualite('objectifs');
await cadrerDansLaSurface([objectifs.querySelector('[data-jamais-servies]'), objectifs.querySelector('ol.tours')]);
await photographier(m, '15a-qualite-objectifs', {
condition: ['[data-vue-qualite="objectifs"] [data-soiree-objectifs]', '[data-vue-qualite="objectifs"] ol.tours'],
graine: GRAINE,
});
});
test('16 la petite : « minimum atteint », collisions et répétitions à zéro, les ancrés à « — »', async () => {
await chargerDemonstration('petite');
await passerEnEcriture();
await generer();
const id = await qualiteDeLaPremiere();
const instance = normaliser(configurationDepuisCharge(charge()));
const mesures = mesurer(instance, indexerPlan(instance, planDepuisFichier(charge().propositions.find((p) => p.id === id))));
expect(mesures.collisionsCumulees).toBe(0);
expect(mesures.rencontresRepetees).toEqual({ choisies: 0, imposees: 0 });
const entete = vueQualite('entete');
expect(texteDe(entete.querySelector('.certificat'))).toBe(plat(t('propositions.certificat.atteint')));
const valeur = (nom) => texteDe(entete.querySelector(`[data-tuile="${nom}"] .valeur`));
for (const nom of ['collisionsCumulees', 'rencontresRepetees']) expect(valeur(nom), nom).toBe('0');
expect(valeur('rencontresAncres')).toBe(t('format.vide'));
// Le relais est resté inerte pour chaque génération qui suit la grande.
expect(relais.avancements, 'aucun avancement compté après la grande').toBe(memoire.grande.avancements);
// Le bas de l'en-tête, du groupe de la diversité à la mention : le bord
// haut tombe entre deux groupes de tuiles, aucune n'y est coupée. Du
// groupe des collisions à la mention, l'en-tête est plus haut que la
// surface : les rencontres et leur ligne des ancrés, les collisions et
// les répétitions s'affirment plus haut, sans l'image.
await cadrerAuBord(entete.querySelector('section.groupe:has([data-tuile="diversite"])'), 'haut');
await photographier(m, '16-minimum-atteint', {
condition: [
'[data-vue-qualite="entete"] section.groupe:has([data-tuile="diversite"]) h4',
'[data-vue-qualite="entete"] [data-tuile="manqueMax"]',
'[data-vue-qualite="entete"] .certificat',
],
graine: GRAINE,
});
});
});
// --- Les captures d'écran du parcours (§ 19.5, § 19.7) ----------------------------------------
// La dernière épreuve du fichier. Une épreuve du fichier en échec, même

View file

@ -12,7 +12,11 @@
// prend le plus grand et reste dans le cadre, quitte à couvrir le terme.
// Horizontalement, elle s'aligne sur le bord gauche du terme, puis recule
// pour garder MARGE_INFOBULLE_PX entre elle et chaque bord du cadre ; plus
// large que le cadre, elle se colle à la marge gauche.
// large que le cadre, elle se colle à la marge gauche. Verticalement, le
// cadre peut se borner à la zone visible où défile le terme — haut et bas,
// ramenés dans la fenêtre — : l'infobulle n'en sort pas, et ne couvre pas
// ce qui reste fixe au-dessus ou au-dessous de cette zone, comme un bandeau
// collant.
/** Distance entre le terme et l'infobulle, en px. */
export const ECART_INFOBULLE_PX = 6;
@ -26,8 +30,10 @@ export const MARGE_INFOBULLE_PX = 8;
* @param {{gauche: number, haut: number, droite: number, bas: number}} ancre
* le rectangle du terme expliqué
* @param {{largeur: number, hauteur: number}} taille celle de l'infobulle
* @param {{largeur: number, hauteur: number}} cadre la partie visible de la
* fenêtre, barres de défilement exclues
* @param {{largeur: number, hauteur: number, haut?: number, bas?: number}} cadre
* la partie visible de la fenêtre, barres de défilement exclues ; haut et
* bas, facultatifs, bornent la zone visible où défile le terme, 0 et
* hauteur par défaut
* @returns {{gauche: number, haut: number, cote: 'dessous'|'dessus'}} le coin
* haut gauche de l'infobulle, et le côté du terme où elle se pose
*/
@ -37,15 +43,16 @@ export function placerInfobulle(ancre, { largeur, hauteur }, cadre) {
const dessous = ancre.bas + ECART_INFOBULLE_PX;
const dessus = ancre.haut - ECART_INFOBULLE_PX - hauteur;
const basMax = cadre.hauteur - MARGE_INFOBULLE_PX;
const hautMin = Math.max(0, cadre.haut ?? 0) + MARGE_INFOBULLE_PX;
const basMax = Math.min(cadre.hauteur, cadre.bas ?? cadre.hauteur) - MARGE_INFOBULLE_PX;
if (dessous + hauteur <= basMax) return { gauche, haut: dessous, cote: 'dessous' };
if (dessus >= MARGE_INFOBULLE_PX) return { gauche, haut: dessus, cote: 'dessus' };
if (dessus >= hautMin) return { gauche, haut: dessus, cote: 'dessus' };
// Aucun côté ne la reçoit : le plus grand, la boîte ramenée dans le cadre.
const placeDessous = basMax - dessous;
const placeDessus = ancre.haut - ECART_INFOBULLE_PX - MARGE_INFOBULLE_PX;
const placeDessus = ancre.haut - ECART_INFOBULLE_PX - hautMin;
const cote = placeDessous >= placeDessus ? 'dessous' : 'dessus';
const voulu = cote === 'dessous' ? dessous : dessus;
const haut = Math.max(MARGE_INFOBULLE_PX, Math.min(voulu, basMax - hauteur));
const haut = Math.max(hautMin, Math.min(voulu, basMax - hauteur));
return { gauche, haut, cote };
}

View file

@ -65,6 +65,21 @@ describe('placerInfobulle : sous le terme, au-dessus, dans le cadre', () => {
assert.ok(dansLeCadre(boite(haut, haute), petit), JSON.stringify(haut));
});
test('rouge : un cadre borné en haut et en bas — la zone visible où défile le terme, sous un bandeau collant — : l’infobulle n’en sort jamais, quitte à couvrir le terme ; sans bornes, la fenêtre', () => {
// Sous la zone, elle monte ; au-dessus de la zone, elle descend.
assert.deepEqual(placerInfobulle(terme(200, 500), TAILLE, { largeur: 1366, hauteur: 700, bas: 600 }), { gauche: 200, haut: 404, cote: 'dessus' });
assert.deepEqual(placerInfobulle(terme(200, 600), TAILLE, { largeur: 1366, hauteur: 700, haut: 560, bas: 690 }), { gauche: 200, haut: 592, cote: 'dessous' });
const zone = { largeur: 1366, hauteur: 700, haut: 260, bas: 690 };
for (let haut = zone.haut; haut <= zone.bas - 20; haut += 11) {
const b = boite(placerInfobulle(terme(200, haut), TAILLE, zone), TAILLE);
assert.ok(b.haut >= zone.haut + MARGE_INFOBULLE_PX && b.bas <= zone.bas - MARGE_INFOBULLE_PX, `${haut} : ${JSON.stringify(b)}`);
}
const ancre = terme(200, 300);
assert.deepEqual(placerInfobulle(ancre, TAILLE, { ...CADRE, haut: 0, bas: 700 }), placerInfobulle(ancre, TAILLE, CADRE));
const debordante = { ...CADRE, haut: -40, bas: 900 };
assert.deepEqual(placerInfobulle(terme(200, 640), TAILLE, debordante), placerInfobulle(terme(200, 640), TAILLE, CADRE), 'la zone se borne à la fenêtre');
});
test('sur une grille de termes qui couvre le cadre : dès qu’un côté la reçoit, l’infobulle reste dans le cadre et ne couvre jamais le terme', () => {
let essais = 0;
for (let gauche = -100; gauche <= CADRE.largeur; gauche += 37) {

View file

@ -23,7 +23,12 @@
// titre, jamais empilées ni sur un axe commun, valeurs[k] celle de la
// colonne k, les lignes dans le même ordre.
// genre : serie1, serie2, prouve ou reste, chacun son jeton (REMPLISSAGES,
// pointage.js). Un segment nul ne se trace pas.
// pointage.js). Un segment au remplissage en contour — « prouvé
// inévitable » — se trace d'un trait de TRAIT_CONTOUR_PX de son jeton,
// tenu dans la boîte du segment, que la disposition garde au moins large
// de LARGEUR_MIN_CONTOUR_PX ; les autres en aplat : prouvé et reste se
// distinguent par la forme comme par la couleur, à toute largeur (§ 12.4,
// B13). Un segment nul ne se trace pas.
//
// Le pointage désigne la ligne sous le pointeur ; pointe, liable, en porte
// l'indice ; infobulle, {colonnes, lignes}, une ligne du jumeau par ligne
@ -36,7 +41,7 @@
// pointée, par son rang à partir de 1. Chaque marque porte data-marque.
import { getContext } from 'svelte';
import Graphique from './Graphique.svelte';
import { ESPACE_PX, cibleLigne, dispositionBarres } from './pointage.js';
import { ESPACE_PX, TRAIT_CONTOUR_PX, cibleLigne, dispositionBarres } from './pointage.js';
let {
graphique,
@ -127,15 +132,29 @@
<text class="secondaire" x={ligne.secondaire.x} y={ligne.secondaire.y} dominant-baseline="central">{ligne.secondaire.texte}</text>
{/if}
{#each ligne.segments as segment (segment.colonne)}
<rect
data-segment={segment.genre}
data-marque={segment.marque}
x={segment.x}
y={segment.y}
width={segment.largeur}
height={segment.hauteur}
style:fill={`var(${segment.jeton})`}
/>
{#if segment.contour}
<rect
class="contour"
data-segment={segment.genre}
data-marque={segment.marque}
x={segment.x + TRAIT_CONTOUR_PX / 2}
y={segment.y + TRAIT_CONTOUR_PX / 2}
width={segment.largeur - TRAIT_CONTOUR_PX}
height={segment.hauteur - TRAIT_CONTOUR_PX}
style:stroke={`var(${segment.jeton})`}
style:stroke-width={`${TRAIT_CONTOUR_PX}px`}
/>
{:else}
<rect
data-segment={segment.genre}
data-marque={segment.marque}
x={segment.x}
y={segment.y}
width={segment.largeur}
height={segment.hauteur}
style:fill={`var(${segment.jeton})`}
/>
{/if}
{/each}
</g>
{/each}
@ -146,6 +165,10 @@
</Graphique>
<style>
.contour {
fill: none;
}
.axe {
stroke: var(--couleur-graphique-axe);
stroke-width: 1;

View file

@ -21,9 +21,10 @@
// genre plafond, borne ou cent ; libellés déjà traduits, valeurs une par
// rang ou par point, dans l'ordre du détail : rien ne se trie ni ne se
// recompte ici. Chaque trace porte son libellé au bout de son tracé, et la
// légende le reprend. graduer(v) et graduerX(v) écrivent une graduation —
// par défaut le nombre mis en forme ; titreX nomme l'abscisse, null pour
// aucun titre, par défaut celui de la forme.
// légende le reprend. graduer(v) écrit une graduation de l'axe vertical,
// graduerX(v) une graduation de l'abscisse — un rang ou des mouvements
// évalués —, chacun par défaut le nombre mis en forme ; titreX nomme
// l'abscisse, null pour aucun titre, par défaut celui de la forme.
//
// marges, liable en sortie : les marges que le graphique demande pour son
// texte (margesVerticales, pointage.js) ; alignerSur : null, ou les marges
@ -114,7 +115,7 @@
};
return enPoints
? dispositionPoints({ ...communes, abscisses, graduerX: graduerX ?? nombre })
: dispositionEscalier({ ...communes, reperes, marques });
: dispositionEscalier({ ...communes, reperes, marques, graduerX: graduerX ?? nombre });
});
const cibler = (x) => (enPoints ? ciblePoint(x, disposition) : cibleRang(x, disposition));

View file

@ -36,8 +36,10 @@
// Elle ne s'ouvre que dans le graphique survolé, sur le modèle de
// l'infobulle primitive : un seul élément [role=tooltip], caché tant
// qu'aucune cible n'est pointée, placé par placerInfobulle contre le point
// d'ancrage de la cible (ancrer(indice) → {x, y} local), replacé au
// défilement et au redimensionnement, invisible tant qu'il n'est pas
// d'ancrage de la cible (ancrer(indice) → {x, y} local), dans la zone
// visible où défile le graphique (zoneVisible) — elle ne couvre ni le
// bandeau collant ni ce qui reste fixe au-dessus de cette zone —, replacé
// au défilement et au redimensionnement, invisible tant qu'il n'est pas
// placé ; Échap la ferme. Elle ne capte aucun pointeur.
//
// legende : [{genre, libelle, signe}] — un trait, un remplissage ou un
@ -52,7 +54,7 @@
import { placerInfobulle } from '../../primitives/infobulle.js';
import { effetDe } from '../../raccourcis.js';
import Pourcentage from './Pourcentage.svelte';
import { REMPLISSAGES, TRAITS, cheminSigne, ligneInfobulle } from './pointage.js';
import { REMPLISSAGES, TRAITS, TRAIT_CONTOUR_PX, cheminSigne, ligneInfobulle } from './pointage.js';
let {
graphique,
@ -110,6 +112,20 @@
pointe = null;
}
// La zone visible où défile le graphique : le haut et le bas, dans le
// repère de la fenêtre, du plus proche ancêtre qui fait défiler son
// contenu à la verticale ; {} sans un tel ancêtre.
function zoneVisible(element) {
for (let ancetre = element.parentElement; ancetre !== null; ancetre = ancetre.parentElement) {
const { overflowY } = getComputedStyle(ancetre);
if (overflowY === 'auto' || overflowY === 'scroll') {
const boite = ancetre.getBoundingClientRect();
return { haut: boite.top, bas: boite.bottom };
}
}
return {};
}
function placer() {
if (ligne === null || bulle === null || svg === null) return;
const point = ancrer(pointe);
@ -121,7 +137,7 @@
place = placerInfobulle(
{ gauche: x, haut: y, droite: x, bas: y },
{ largeur: boite.width, hauteur: boite.height },
{ largeur: fenetre.clientWidth, hauteur: fenetre.clientHeight },
{ largeur: fenetre.clientWidth, hauteur: fenetre.clientHeight, ...zoneVisible(svg) },
);
}
@ -159,6 +175,16 @@
stroke-dasharray={TRAITS[entree.genre].motif}
style:stroke={`var(${TRAITS[entree.genre].jeton})`}
/>
{:else if REMPLISSAGES[entree.genre].contour}
<rect
x="2"
y="3"
width="24"
height="6"
class="contour"
style:stroke={`var(${REMPLISSAGES[entree.genre].jeton})`}
style:stroke-width={`${TRAIT_CONTOUR_PX}px`}
/>
{:else}
<rect x="1" y="2" width="26" height="8" style:fill={`var(${REMPLISSAGES[entree.genre].jeton})`} />
{/if}
@ -295,6 +321,11 @@
stroke-width: 1.5;
}
/* L'échantillon d'un segment tracé en contour : « prouvé inévitable ». */
.contour {
fill: none;
}
.signe.plein {
fill: var(--couleur-graphique-marque);
}

View file

@ -77,9 +77,16 @@ export const ECART_SEGMENTS_PX = 1;
* se lit sur le fond de la carte, et le niveau aussi. */
export const RETRAIT_CASE_PX = 2;
/** L'épaisseur du trait d'un segment tracé en contour, en px. */
export const TRAIT_CONTOUR_PX = 2;
/** La largeur minimale d'un segment tracé en contour, en px : ses deux
* traits et un vide de 2 px entre eux, qui le distingue d'un aplat. */
export const LARGEUR_MIN_CONTOUR_PX = 2 * TRAIT_CONTOUR_PX + 2;
const jeton = (nom) => `--couleur-graphique-${nom}`;
const trait = (marque, nom, motif) => Object.freeze({ marque, jeton: jeton(nom), motif });
const remplissage = (marque, nom) => Object.freeze({ marque, jeton: jeton(nom) });
const remplissage = (marque, nom, contour = false) => Object.freeze({ marque, jeton: jeton(nom), contour });
/** Les traits d'un graphique, par genre (D30, § 12.7) : { marque, jeton,
* motif } — la désignation data-marque du registre (marques.js), le jeton
@ -95,11 +102,14 @@ export const TRAITS = Object.freeze({
borne: trait('graphiqueBorne', 'borne', '2 3'),
});
/** Les remplissages d'un segment de barre, par genre : { marque, jeton }. */
/** Les remplissages d'un segment de barre, par genre : { marque, jeton,
* contour }. Le segment « prouvé inévitable » se trace en contour, un
* trait de TRAIT_CONTOUR_PX de son jeton, les autres en aplat : prouvé et
* reste se distinguent par la forme comme par la couleur (§ 12.4, B13). */
export const REMPLISSAGES = Object.freeze({
serie1: remplissage('graphiqueSerie1', 'serie-1'),
serie2: remplissage('graphiqueSerie2', 'serie-2'),
prouve: remplissage('graphiqueProuve', 'prouve'),
prouve: remplissage('graphiqueProuve', 'prouve', true),
reste: remplissage('graphiqueReste', 'reste'),
});
@ -306,8 +316,11 @@ function legendeDe(traces, signes = []) {
* linéaire des rangs. marques : null, ou une entrée par rang, null ou un
* genre de SIGNES — le signe se pose au centre du rang, à la valeur de la
* première série, au bas de la zone quand elle est nulle. graduer(v) écrit
* une graduation de l'axe vertical ; titreX, déjà traduit, nomme
* l'abscisse, ou null. Les libellés des traces se posent au bout de leur
* une graduation de l'axe vertical, graduerX(rang) une graduation de
* l'abscisse — un rang, jamais une valeur de l'axe vertical : un graphique
* en pourcentages ne gradue pas ses rangs en pourcentages ; sans graduerX,
* graduer écrit les deux. titreX, déjà traduit, nomme l'abscisse, ou
* null. Les libellés des traces se posent au bout de leur
* tracé, dans la marge droite mesurée. aligner : null, ou les marges
* communes d'un groupe de graphiques (margesVerticales, margesCommunes).
* Chaque graduation de l'abscisse, {valeur, texte, x}, porte son rang :
@ -325,6 +338,7 @@ export function dispositionEscalier({
mesurer,
hauteurTexte,
graduer,
graduerX = graduer,
titreX = null,
aligner = null,
}) {
@ -353,7 +367,7 @@ export function dispositionEscalier({
const graduationsX =
n === 0
? []
: graduations(1, n, GRADUATIONS_RANGS, { entieres: true }).map((rang) => ({ valeur: rang, texte: graduer(rang), x: ex.versPx(rang - 0.5) }));
: graduations(1, n, GRADUATIONS_RANGS, { entieres: true }).map((rang) => ({ valeur: rang, texte: graduerX(rang), x: ex.versPx(rang - 0.5) }));
return { ...cadre, n, ex, traces: tracees, signes, graduationsX, legende: legendeDe(tracees, signes) };
}
@ -571,12 +585,21 @@ export function dispositionBarres({ largeur, lignes, segments = null, colonnes =
const traces = juxtaposees
? geometries.map((g, k) => ({ ...g.lignes[indice].segments[0], genre: parts[k].genre, colonne: k }))
: geometries[0].lignes[indice].segments.map((s, k) => ({ ...s, genre: parts[k].genre, colonne: k }));
// Empilé, un segment qui suit un segment non nul s'en écarte.
// Empilé, un segment qui suit un segment non nul s'en écarte. Un
// segment en contour garde LARGEUR_MIN_CONTOUR_PX ; empilé, ce qu'il
// gagne au-delà de sa fin se retranche du segment qui le suit, sans le
// rendre négatif : la barre finit où sa valeur la fait finir, sauf quand
// le segment en contour est seul.
let gagne = 0;
const tracees = traces
.filter((s) => l.valeurs[s.colonne] > 0)
.map((s) => {
const ecart = !juxtaposees && s.colonne > 0 && l.valeurs[s.colonne - 1] > 0 ? ECART_SEGMENTS_PX : 0;
return { ...s, x: s.x + ecart, largeur: Math.max(0, s.largeur - ecart), y, hauteur: hauteurLigne, ...REMPLISSAGES[s.genre] };
const x = s.x + ecart + gagne;
const propre = Math.max(0, s.x + s.largeur - x);
const largeurTracee = REMPLISSAGES[s.genre].contour ? Math.max(propre, LARGEUR_MIN_CONTOUR_PX) : propre;
gagne = juxtaposees ? 0 : Math.max(0, x + largeurTracee - (s.x + s.largeur));
return { ...s, x, largeur: largeurTracee, y, hauteur: hauteurLigne, ...REMPLISSAGES[s.genre] };
});
const intitule = intitules[indice];
const centre = y + hauteurLigne / 2;
@ -599,7 +622,7 @@ export function dispositionBarres({ largeur, lignes, segments = null, colonnes =
indice,
y,
hauteur: hauteurLigne,
fin: derniere.x + derniere.largeur,
fin: Math.max(derniere.x + derniere.largeur, ...tracees.map((s) => s.x + s.largeur)),
intitule: { ...intitule, x: retraitNom, y: centre },
pastille,
segments: tracees,

View file

@ -16,10 +16,12 @@ import {
ECART_AXE_PX,
ECART_SEGMENTS_PX,
ESPACE_PX,
LARGEUR_MIN_CONTOUR_PX,
LARGEUR_NOM_PX,
RETRAIT_CASE_PX,
REMPLISSAGES,
SIGNES,
TRAIT_CONTOUR_PX,
TRAITS,
ancrePoint,
ancreRang,
@ -329,6 +331,36 @@ describe('pointage : les barres (§ 12.4)', () => {
assert.equal(d.ex.domaine[1], 3);
});
test('rouge : « prouvé inévitable » seul se trace en contour ; étroit, il garde LARGEUR_MIN_CONTOUR_PX, pris sur le segment qui le suit, la fin de la barre inchangée ; seul, sa barre s’allonge d’autant', () => {
assert.deepEqual(Object.entries(REMPLISSAGES).filter(([, r]) => r.contour).map(([genre]) => genre), ['prouve']);
assert.equal(TRAIT_CONTOUR_PX, 2);
assert.equal(LARGEUR_MIN_CONTOUR_PX, 2 * TRAIT_CONTOUR_PX + 2);
const d = disposer({
lignes: [
{ id: 'a', nom: 'Coopérative Aubier', appartenance: 1, valeurs: [0, 300] },
{ id: 'b', nom: 'Atelier Ressac', appartenance: 2, valeurs: [1, 9] },
{ id: 'c', nom: 'Iris Ombrelle', appartenance: null, valeurs: [1, 0] },
],
});
const naturelle = d.ex.versPx(1) - d.ex.versPx(0);
assert.ok(naturelle < LARGEUR_MIN_CONTOUR_PX, `un sur trois cents : ${naturelle} px`);
const [prouve, reste] = d.lignes[1].segments;
assert.equal(prouve.contour, true);
assert.equal(reste.contour, false);
assert.equal(prouve.x, d.ex.versPx(0));
assert.equal(prouve.largeur, LARGEUR_MIN_CONTOUR_PX);
assert.equal(reste.x, prouve.x + prouve.largeur + ECART_SEGMENTS_PX);
assert.ok(Math.abs(reste.x + reste.largeur - d.ex.versPx(10)) < 1e-9, 'le reste finit où finit la barre');
assert.ok(Math.abs(d.lignes[1].fin - d.ex.versPx(10)) < 1e-9);
const seul = d.lignes[2].segments[0];
assert.equal(seul.largeur, LARGEUR_MIN_CONTOUR_PX);
assert.equal(d.lignes[2].fin, seul.x + LARGEUR_MIN_CONTOUR_PX);
const ordinaire = disposer();
const large = ordinaire.lignes[2].segments[0];
assert.ok(large.largeur > LARGEUR_MIN_CONTOUR_PX);
assert.equal(large.largeur, ordinaire.ex.versPx(1) - ordinaire.ex.versPx(0), 'un prouvé assez large garde sa largeur');
});
test('rouge : le libellé de chaque segment se pose sur la première barre où il est non nul', () => {
const d = disposer();
assert.deepEqual(d.etiquettesSegments.map((e) => [e.genre, e.indice]), [['prouve', 1], ['reste', 0]]);

View file

@ -5,8 +5,11 @@
// § 19.11, § 12.7, § 7.6, § 13.1, § 2, § 19.2, § 8.4, § 18.6) : langue
// témoin, erreurs par leur libellé, contrastes et marques dans les deux
// thèmes, aucune atténuation, jamais la seule couleur, teinte réservée, aucun
// emoji, balisage littéral, bandeau, formulaires, transitions annulées ;
// puis le mécanisme des captures d'écran (§ 19.4, § 19.5, § 19.7, § 19.11),
// emoji, balisage littéral, bandeau, formulaires, transitions annulées ; la
// page de qualité — chaque pourcentage suivi de son compte, le signe « % »
// à ses seules places, chaque niveau de l'échelle rendu, les motifs des
// tracés, ses graphiques nommés et décrits, ses jumeaux au clavier ; puis
// le mécanisme des captures d'écran (§ 19.4, § 19.5, § 19.7, § 19.11),
// chaque contrôle montré en train d'échouer sur un dossier d'épreuve.
//
// Un parcours unique, en langue témoin, au cadre de 1 366 × 700, atteint
@ -42,13 +45,17 @@ import {
NOM_CAS_LIMITES,
attendre,
centre,
chargeObjectifs,
chargePaires,
chargePrincipale,
cliquer,
creerContexte,
fichierCourses,
generationPrincipale,
octetsCasLimites,
ouvrirEvenement,
} from '../../test/navigateur/ecrans.js';
import { fixerCadre, pointer } from '../../test/navigateur/entrees.js';
import { fixerCadre, pointer, touche } from '../../test/navigateur/entrees.js';
import { monterApp } from '../../test/navigateur/montage.js';
import {
CADRE,
@ -72,7 +79,9 @@ import { poserFeuille } from '../../test/navigateur/feuilles.js';
import { composantes, couleursPeintes, designation, sousTheme, visible } from '../../test/navigateur/rendu.js';
import { provenanceVisible } from './apropos.js';
import { TABLES_INTERFACE } from './libelles.js';
import { CONFLITS, FONDS_GRAPHIQUE, MARQUES } from './marques.js';
import SOURCE_CAPTURES from '../../test/navigateur/captures.js?raw';
import { CONFLITS, FONDS_GRAPHIQUE, JETON_RETRAIT, MARQUES, MARQUES_GRAPHIQUE, NIVEAUX_GRAPHIQUE, SEPARATIONS, TEINTES_APPARTENANCE } from './marques.js';
import { SIMULATIONS, ecartPercu, simuler } from './palette.js';
const { t: tTemoin } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE], { langue: 'temoin' });
const { ouverture: OUVERTURE, fermeture: FERMETURE } = MARQUE_TEMOIN;
@ -559,6 +568,299 @@ export function fautesFormulaires(racine) {
return { fautes, formulaires: formulaires.length };
}
// --- La page de qualité --------------------------------------------------------------------
// Le graphique en pourcentages : les graduations de son axe vertical et le
// libellé de son repère « cent » écrivent le signe « % » sans être le
// pourcentage d'une mesure (contrainte 44, D34), et n'ont pas de compte.
const GRAPHIQUE_EN_POURCENTAGES = 'profilPourcentages';
// Le signe « % » est-il admis dans l'élément qui porte le texte ? Dans un
// [data-pourcentage] ; dans le libellé du repère « cent », sur le tracé ou
// repris par la légende de son graphique — une entrée de liste sœur du
// <svg> qui porte ce libellé, au même texte ; dans une graduation de l'axe
// vertical du graphique en pourcentages — un <text> ancré à sa fin, qui
// n'est le libellé ni d'une série ni d'un repère.
function pourcentAdmis(element) {
if (element.closest('[data-pourcentage], [data-repere="cent"]') !== null) return true;
const liste = element.closest('li')?.parentElement ?? null;
const voisin = liste?.parentElement?.querySelector(`:scope > svg[data-graphique="${GRAPHIQUE_EN_POURCENTAGES}"]`) ?? null;
const cent = voisin?.querySelector('text[data-repere="cent"]') ?? null;
if (cent !== null && cent.textContent.trim() === element.textContent.trim()) return true;
const texte = element.closest('text');
if (texte === null || texte.closest('svg[data-graphique]')?.dataset.graphique !== GRAPHIQUE_EN_POURCENTAGES) return false;
return texte.getAttribute('text-anchor') === 'end' && !texte.hasAttribute('data-serie') && !texte.hasAttribute('data-repere');
}
/**
* Les pourcentages et le signe « % » rendus sous racine (contrainte 44,
* D34) : chaque [data-pourcentage] rendu suivi, son voisin immédiat dans
* le composant Pourcentage, d'un [data-compte-pire] rendu et non vide ; et
* chaque texte rendu — nœud de texte ou attribut annoncé — qui porte « % »
* hors des places que pourcentAdmis admet. → { lus, dansInfobulle,
* dansJumeaux, fautes, signes: { lus, fautes } }, lus le nombre de
* pourcentages rendus, dansInfobulle et dansJumeaux ceux d'une infobulle
* et d'un jumeau ; signes.lus le nombre de textes rendus qui portent « % ».
*/
export function releverPourcentages(racine) {
const fautes = [];
let lus = 0;
let dansInfobulle = 0;
let dansJumeaux = 0;
for (const valeur of racine.querySelectorAll('[data-pourcentage]')) {
if (!rendu(valeur)) continue;
lus += 1;
if (valeur.closest('[role="tooltip"]') !== null) dansInfobulle += 1;
if (valeur.closest('[data-jumeau]') !== null) dansJumeaux += 1;
const compte = valeur.nextElementSibling;
const nomme = `${situer(valeur)} « ${extrait(valeur.textContent)} »`;
if (compte === null || !compte.matches('[data-compte-pire]')) fautes.push(`${nomme} : aucun [data-compte-pire] ne le suit`);
else if (!rendu(compte) || compte.textContent.trim() === '') fautes.push(`${nomme} : son compte du pire est vide`);
}
const signes = { lus: 0, fautes: [] };
const marcheur = document.createTreeWalker(racine, NodeFilter.SHOW_TEXT);
for (let noeud = marcheur.nextNode(); noeud !== null; noeud = marcheur.nextNode()) {
const parent = noeud.parentElement;
if (parent === null || !noeud.textContent.includes('%') || TEXTES_NON_PEINTS.has(parent.localName) || !rendu(parent)) continue;
if (parent.closest('svg title, svg desc') !== null) continue;
signes.lus += 1;
if (!pourcentAdmis(parent)) signes.fautes.push(`${situer(parent)} : « ${extrait(noeud.textContent)} »`);
}
for (const element of [racine, ...racine.querySelectorAll('*')]) {
if (!rendu(element)) continue;
for (const nom of ATTRIBUTS_ANNONCES) {
const valeur = element.getAttribute(nom);
if (valeur === null || !valeur.includes('%')) continue;
signes.lus += 1;
signes.fautes.push(`${situer(element)}[${nom}] : « ${extrait(valeur)} »`);
}
}
return { lus, dansInfobulle, dansJumeaux, fautes, signes };
}
/** Les niveaux de l'échelle que chaque grille rend sous racine (§ 12.7,
* D33, D35) : [{ graphique, niveaux }], niveaux croissants, chacun lu sur
* le remplissage calculé d'une case, comparé à la couleur que le thème
* courant donne au jeton de chaque niveau (NIVEAUX_GRAPHIQUE). */
export function releverNiveaux(racine) {
const couleurs = NIVEAUX_GRAPHIQUE.map(({ niveau, jeton }) => ({ niveau, couleur: couleurDeJeton(jeton) }));
const grilles = [];
for (const svg of racine.querySelectorAll('svg[data-graphique]')) {
const cases = [...svg.querySelectorAll('[data-case] > rect')].filter(rendu);
if (cases.length === 0) continue;
const niveaux = new Set();
for (const forme of cases) {
for (const peinte of couleursPeintes(forme).fill) {
for (const { niveau, couleur } of couleurs) if (egales(peinte, couleur)) niveaux.add(niveau);
}
}
grilles.push({ graphique: svg.dataset.graphique, niveaux: [...niveaux].sort((a, b) => a - b) });
}
return grilles;
}
// Les jetons des teintes d'appartenance, la marque et le fond de chacune.
const JETONS_APPARTENANCE = TEINTES_APPARTENANCE.flatMap(({ marque, fond }) => [marque, fond]);
/**
* Ce que les tracés de la page portent hors de leur couleur (§ 12.7, D30,
* B13, B16) : traces, par genre — serie1, serie2, plafond, borne, cent —,
* chaque tracé rendu [{ ou, sig, motif }], sig sa signature non chromatique
* (signature), motif le stroke-dasharray calculé ; segments, prouve et
* reste, chaque segment de barre rendu [{ ou, sig }].
*/
export function releverTraces(racine) {
const traces = { serie1: [], serie2: [], plafond: [], borne: [], cent: [] };
for (const trace of racine.querySelectorAll('path[data-serie], path[data-repere]')) {
if (!rendu(trace)) continue;
const genre = trace.dataset.serie ?? trace.dataset.repere;
traces[genre] ??= [];
traces[genre].push({ ou: situer(trace), sig: signature([trace]), motif: getComputedStyle(trace).strokeDasharray });
}
const segments = { prouve: [], reste: [] };
for (const segment of racine.querySelectorAll('[data-segment="prouve"], [data-segment="reste"]')) {
if (rendu(segment)) segments[segment.dataset.segment].push({ ou: situer(segment), sig: signature([segment]) });
}
return { traces, segments };
}
/**
* Fautes des tracés réunis de plusieurs écrans (releverTraces) — jamais la
* seule couleur (§ 12.7, D30, B13, B16) : la série 1 et la série 2, prouvé
* inévitable et reste ont des signatures distinctes (fautesPaire) ; les motifs
* des quatre traits — plein, tirets, trait mixte, pointillé — sont deux à
* deux distincts : chaque série, le plafond, la borne ; la règle « cent »
* a le motif du plafond et aucun autre. Une paire absente est une faute.
*/
export function fautesTraces({ traces, segments }) {
const de = (genre) => traces[genre] ?? [];
const motifs = (genre) => de(genre).map(({ ou, motif }) => ({ ou, sig: motif }));
const fautes = [
...fautesPaire('série 1 / série 2', de('serie1'), de('serie2')),
...fautesPaire('prouvé inévitable / reste', segments.prouve, segments.reste),
];
const genres = ['serie1', 'serie2', 'plafond', 'borne'];
for (let i = 0; i < genres.length; i += 1) {
for (let j = i + 1; j < genres.length; j += 1) {
fautes.push(...fautesPaire(`motif ${genres[i]} / ${genres[j]}`, motifs(genres[i]), motifs(genres[j])));
}
}
const cent = motifs('cent');
if (cent.length === 0) fautes.push('motif cent : absent');
for (const regle of cent) {
for (const plafond of motifs('plafond')) if (regle.sig !== plafond.sig) fautes.push(`motif cent : ${regle.ou} (${regle.sig}) n’a pas celui du plafond ${plafond.ou} (${plafond.sig})`);
}
return fautes;
}
// Les vues sous lesquelles une série se mesure à une teinte d'appartenance :
// les couleurs d'origine, puis chaque dichromatie (§ 12.7 : simuler d'abord,
// puis mesurer l'écart).
const VUES_SEPARATION = ['origine', ...Object.keys(SIMULATIONS)];
/**
* Les couleurs que les séries et les teintes d'appartenance peignent
* réellement sous racine, dans le thème courant (§ 12.7, D43) : series,
* chaque tracé de série rendu [{ ou, couleur }], couleur son trait calculé ;
* teintes, chaque élément rendu, hors des conteneurs de référence et des
* tracés de série, qui peint la marque d'une teinte d'appartenance — le
* point d'une pastille — [{ ou, couleur }]. Une couleur déjà relevée ne se relève pas deux fois.
*/
export function releverSeparation(racine) {
const series = [];
const teintes = [];
const ajouter = (liste, ou, couleur) => {
if (!liste.some((x) => egales(x.couleur, couleur))) liste.push({ ou, couleur });
};
for (const trace of racine.querySelectorAll('path[data-serie]')) {
if (rendu(trace)) for (const couleur of couleursPeintes(trace).stroke) ajouter(series, situer(trace), couleur);
}
const marques = TEINTES_APPARTENANCE.map(({ marque }) => couleurDeJeton(marque));
for (const element of racine.querySelectorAll('*')) {
if (element.closest(CONTENEURS_DE_REFERENCE) !== null || element.matches('[data-serie]') || !rendu(element)) continue;
for (const { couleurs } of peinturesDe(element)) {
for (const couleur of couleurs) if (marques.some((m) => egales(m, couleur))) ajouter(teintes, situer(element), couleur);
}
}
return { series, teintes };
}
/** Fautes de séparation (releverSeparation, réunis de plusieurs écrans) :
* chaque série rendue et chaque teinte d'appartenance rendue s'écartent
* d'au moins SEPARATIONS.autresMarques (ecartPercu), sur les couleurs
* d'origine puis sous chaque dichromatie (§ 12.7, D43). Aucune série ou
* aucune teinte relevée est une faute : la mesure n'a pas eu lieu. */
export function fautesSeparation({ series, teintes }) {
if (series.length === 0 || teintes.length === 0) return [`séries / teintes d’appartenance : paire absente (${series.length} et ${teintes.length})`];
const fautes = [];
for (const serie of series) {
for (const teinte of teintes) {
for (const vue of VUES_SEPARATION) {
const vu = (c) => (vue === 'origine' ? c : simuler(c, vue));
const ecart = ecartPercu(vu(serie.couleur), vu(teinte.couleur));
if (ecart < SEPARATIONS.autresMarques) {
fautes.push(`${serie.ou} ${rgb(serie.couleur)} et ${teinte.ou} ${rgb(teinte.couleur)} : écart ${ecart.toFixed(1)} (${vue}), ${SEPARATIONS.autresMarques} au moins`);
}
}
}
}
return fautes;
}
/** Fautes des marques de graphique sous racine (D43, § 12.7) : une marque
* d'un <svg data-graphique> peinte d'une teinte d'appartenance — sa marque
* ou son fond — ou du rouge du retrait, dans le thème courant. → { fautes,
* lus }, lus le nombre de marques de graphique lues. */
export function fautesTeintesGraphique(racine) {
const reservees = [...JETONS_APPARTENANCE, JETON_RETRAIT].map((jeton) => ({ jeton, couleur: couleurDeJeton(jeton) }));
const fautes = [];
let lus = 0;
for (const marque of racine.querySelectorAll('svg[data-graphique] [data-marque]')) {
if (!rendu(marque)) continue;
lus += 1;
const { fill, stroke } = couleursPeintes(marque);
for (const { jeton, couleur } of reservees) {
if ([...fill, ...stroke].some((c) => egales(c, couleur))) fautes.push(`${situer(marque)} : ${rgb(couleur)} (${jeton})`);
}
}
return { fautes, lus };
}
// Le texte des éléments que nomment des identifiants séparés d'espaces.
const texteDesIdentifiants = (ids) =>
(ids ?? '')
.split(/\s+/)
.filter((id) => id !== '')
.map((id) => document.getElementById(id)?.textContent ?? '')
.join(' ')
.replace(/\s+/g, ' ')
.trim();
/** Les graphiques rendus sous racine (§ 12.7, D44) : [{ ou, vue, role,
* nom, description }], vue la vue de qualité qui le porte
* (data-vue-qualite), nom son nom accessible — aria-label, sinon le texte
* de aria-labelledby —, description le texte de aria-describedby. */
export function releverGraphiques(racine) {
return [...racine.querySelectorAll('svg[data-graphique]')].filter(rendu).map((svg) => ({
ou: situer(svg),
vue: svg.closest('[data-vue-qualite]')?.dataset.vueQualite ?? null,
role: svg.getAttribute('role'),
nom: (svg.getAttribute('aria-label') ?? '').trim() || texteDesIdentifiants(svg.getAttribute('aria-labelledby')),
description: texteDesIdentifiants(svg.getAttribute('aria-describedby')),
}));
}
/** Fautes des graphiques d'un écran (releverGraphiques) : chacun une image
* (role img), nommée et décrite, et deux graphiques de vues différentes
* jamais du même nom. */
export function fautesGraphiques(graphiques) {
const fautes = [];
for (const g of graphiques) {
if (g.role !== 'img') fautes.push(`${g.ou} : rôle ${g.role ?? 'absent'}, img attendu`);
if (g.nom === '') fautes.push(`${g.ou} : sans nom accessible`);
if (g.description === '') fautes.push(`${g.ou} : sans description`);
}
for (let i = 0; i < graphiques.length; i += 1) {
for (let j = i + 1; j < graphiques.length; j += 1) {
const [a, b] = [graphiques[i], graphiques[j]];
if (a.nom !== '' && a.nom === b.nom && a.vue !== b.vue) fautes.push(`${a.ou} (${a.vue}) et ${b.ou} (${b.vue}) : même nom « ${extrait(a.nom)} »`);
}
}
return fautes;
}
/** Ce qu'un écran de la page de qualité rend, hors des thèmes : null hors
* de la page. Sinon les pourcentages, les niveaux, les tracés et les
* graphiques, les phrases de cas (data-phrase-qualite), le nombre de
* jumeaux ouverts et les vues qui les portent (vuesJumeau), le nombre de
* points de relevé, la ligne de tête de la course, et la largeur rendue,
* en px, de chaque segment « prouvé inévitable » des collisions
* (prouvesCollisions). */
function releverQualite(racine) {
const section = racine.querySelector('[data-section="qualite"]');
if (section === null) return null;
return {
pourcentages: releverPourcentages(racine),
niveaux: releverNiveaux(section),
traces: releverTraces(section),
graphiques: releverGraphiques(section),
phrases: [...section.querySelectorAll('[data-phrase-qualite]')].filter(rendu).map((p) => p.dataset.phraseQualite),
jumeaux: [...section.querySelectorAll('[data-jumeau]')].filter(rendu).length,
vuesJumeau: [
...new Set(
[...section.querySelectorAll('[data-jumeau]')]
.filter(rendu)
.map((jumeau) => jumeau.closest('[data-vue-qualite]')?.dataset.vueQualite ?? null),
),
],
prouvesCollisions: [...section.querySelectorAll('[data-graphique="collisions"] [data-segment="prouve"]')]
.filter(rendu)
.map((segment) => segment.getBoundingClientRect().width),
pointsReleve: [...section.querySelectorAll('[data-point-releve]')].filter(rendu).length,
course: section.querySelector('[data-course]') !== null,
};
}
// --- Le parcours et ses relevés ----------------------------------------------------------
// Les messages de refus d'un écran, et les motifs des lignes refusées d'un
@ -582,7 +884,14 @@ function releverTheme(racine) {
try {
const textes = releverTextes(racine);
const marques = releverMarques(racine);
return { textes, marques, teinte: fautesTeinte(racine, teintesDeConflit()) };
const section = racine.querySelector('[data-section="qualite"]');
return {
textes,
marques,
teinte: fautesTeinte(racine, teintesDeConflit()),
teintesGraphique: fautesTeintesGraphique(racine),
separation: section === null ? null : releverSeparation(section),
};
} finally {
window.scrollTo(0, 0);
}
@ -610,6 +919,7 @@ beforeAll(async () => {
releve.etats = releverEtats(m);
releve.listes = releverListes(racine);
releve.signatureMode = signatureMode(racine);
releve.qualite = releverQualite(racine);
releve.clair = releverTheme(racine);
await cliquer(m, '[data-bandeau] [data-commande="theme"][data-theme-choix="sombre"]');
await attendre('le thème sombre', () => document.documentElement.getAttribute('data-theme') === 'sombre');
@ -750,7 +1060,15 @@ describe('les gardes relèvent la faute qu’un fragment porte (§ 14.2)', () =>
}
});
test('marques de graphique : le relevé d’un écran connaît MARQUES_GRAPHIQUE et les mesure à 3:1 ; une marque inconnue des deux listes et un fond de graphique restent hors du registre', () => {
test('le pont retiré : chaque marque de graphique est versée dans MARQUES, le seul registre que releverMarques lit', () => {
const noms = MARQUES.map(({ nom }) => nom);
expect(MARQUES_GRAPHIQUE.length).toBeGreaterThan(0);
for (const { nom } of MARQUES_GRAPHIQUE) expect(noms, nom).toContain(nom);
expect(SOURCE_CAPTURES).not.toMatch(/MARQUES_GRAPHIQUE|REGISTRE_MARQUES/);
expect(SOURCE_CAPTURES).toMatch(/const entree = MARQUES\.find\(/);
});
test('marques de graphique : le relevé d’un écran les connaît par MARQUES et les mesure à 3:1 ; une marque inconnue du registre et un fond de graphique restent hors du registre', () => {
const { fautes, vues, mesures } = avecFragment(
'<svg width="200" height="40" style="background:var(--couleur-carte-fond)">' +
'<rect data-marque="graphiqueSerie1" x="4" y="4" width="24" height="24" style="fill:var(--couleur-graphique-serie-1)" />' +
@ -815,6 +1133,115 @@ describe('les gardes relèvent la faute qu’un fragment porte (§ 14.2)', () =>
expect(vues).toEqual(['selection']);
});
test('qualité, pourcentages : un pourcentage sans compte, ou d’un compte vide, est relevé ; un « % » hors d’un pourcentage, d’une graduation de l’axe vertical du graphique en pourcentages et du repère « cent », sur le tracé ou dans sa légende, est relevé — une graduation de l’abscisse, un attribut annoncé compris', () => {
const { lus, dansInfobulle, dansJumeaux, fautes, signes } = avecFragment(
'<p><span data-pourcentage>88 %</span><span data-compte-pire>minimum 24 sur 27</span></p>' +
'<p><span data-pourcentage>50 %</span></p><p><span data-pourcentage>40 %</span><span data-compte-pire> </span></p>' +
'<div role="tooltip"><span data-pourcentage>7 %</span><span data-compte-pire>1 sur 14</span></div>' +
'<table data-jumeau="0"><tbody><tr><td><span data-pourcentage>9 %</span><span data-compte-pire>1 sur 11</span></td></tr></tbody></table>' +
'<p>12 %</p><button aria-label="9 %">x</button>' +
'<div><svg data-graphique="profilPourcentages" width="200" height="60">' +
'<text x="40" y="10" text-anchor="end">50 %</text><text data-repere="cent" x="60" y="30">100 %</text>' +
'<text data-serie="serie1" x="60" y="50">3 %</text><text x="80" y="58" text-anchor="middle">4 %</text></svg>' +
'<ul><li><span>100 %</span></li><li><span>7 %</span></li></ul></div>' +
'<svg data-graphique="profilComptes" width="200" height="30"><text x="40" y="10" text-anchor="end">20 %</text></svg>',
(c) => releverPourcentages(c),
);
expect([lus, dansInfobulle, dansJumeaux]).toEqual([5, 1, 1]);
expect(fautes).toHaveLength(2);
expect(fautes[0]).toContain('aucun [data-compte-pire]');
expect(fautes[1]).toContain('compte du pire est vide');
expect(signes.lus).toBe(14);
expect(signes.fautes).toHaveLength(6);
expect(signes.fautes.join('\n')).toContain('« 4 % »');
expect(signes.fautes.join('\n')).toContain('« 7 % »');
expect(signes.fautes.join('\n')).toMatch(/^p : « 12 % »/m);
expect(signes.fautes.join('\n')).toContain('[data-serie="serie1"]');
expect(signes.fautes.join('\n')).toContain('« 20 % »');
expect(signes.fautes.join('\n')).toContain('[aria-label] : « 9 % »');
});
test('qualité, niveaux : une grille rend les niveaux que portent ses cases, lus sur leur remplissage calculé ; une forme hors d’une case ne compte pas', () => {
const cases = [0, 2, 2]
.map((k, i) => `<g data-case="0:${i}"><rect x="${30 * i}" width="20" height="20" style="fill:var(--couleur-graphique-niveau-${k})" /></g>`)
.join('');
const grilles = avecFragment(
`<svg data-graphique="matrice" width="120" height="30">${cases}<rect x="100" width="10" height="10" style="fill:var(--couleur-graphique-niveau-3)" /></svg>`,
(c) => releverNiveaux(c),
);
expect(grilles).toEqual([{ graphique: 'matrice', niveaux: [0, 2] }]);
});
test('qualité, tracés : deux séries de même trait, une borne du motif d’une série, une règle « cent » d’un autre motif que le plafond, deux segments que seule la couleur sépare sont relevés', () => {
const trace = (attribut, jeton, motif) =>
`<path ${attribut} d="M10 10 H190" style="fill:none;stroke:var(${jeton});stroke-width:2px"${motif === null ? '' : ` stroke-dasharray="${motif}"`} />`;
const fragment = (motifs) =>
'<svg width="200" height="60">' +
trace('data-serie="serie1"', '--couleur-graphique-serie-1', motifs.serie1) +
trace('data-serie="serie2"', '--couleur-graphique-serie-2', motifs.serie2) +
trace('data-repere="plafond"', '--couleur-graphique-repere', motifs.plafond) +
trace('data-repere="borne"', '--couleur-graphique-borne', motifs.borne) +
trace('data-repere="cent"', '--couleur-graphique-repere', motifs.cent) +
'<rect data-segment="prouve" x="10" y="30" width="40" height="10" style="fill:var(--couleur-graphique-prouve)" />' +
`<rect data-segment="reste" x="50" y="30" width="40" height="10" style="fill:var(--couleur-graphique-reste)"${motifs.reste} /></svg>`;
const justes = { serie1: null, serie2: '6 4', plafond: '8 3 2 3', borne: '2 3', cent: '8 3 2 3', reste: ' stroke="#000" stroke-width="1"' };
const juste = avecFragment(fragment(justes), (c) => releverTraces(c));
expect(fautesTraces(juste)).toEqual([]);
const fautive = avecFragment(fragment({ ...justes, serie2: null, borne: null, cent: '2 3', reste: '' }), (c) => releverTraces(c));
const fautes = fautesTraces(fautive);
expect(fautes.some((f) => f.startsWith('série 1 / série 2'))).toBe(true);
expect(fautes.some((f) => f.startsWith('prouvé inévitable / reste'))).toBe(true);
expect(fautes.some((f) => f.startsWith('motif serie1 / borne'))).toBe(true);
expect(fautes.some((f) => f.startsWith('motif cent'))).toBe(true);
expect(fautesTraces({ traces: {}, segments: { prouve: [], reste: [] } }).some((f) => f.includes('paire absente'))).toBe(true);
});
test('qualité, séparation : une série peinte d’une couleur que la marque d’une teinte d’appartenance rendue ne quitte pas d’assez loin est relevée, sous chaque vue ; ses jetons passent ; sans série ou sans teinte, la paire absente', () => {
const fragment = (jetonSerie) =>
'<svg width="200" height="30">' +
`<path data-serie="serie1" d="M10 10 H190" style="fill:none;stroke:var(${jetonSerie});stroke-width:2px" /></svg>` +
'<span style="display:inline-block;width:12px;height:12px;background-color:var(--couleur-appartenance-2-marque)"></span>';
const juste = avecFragment(fragment('--couleur-graphique-serie-1'), (c) => releverSeparation(c));
expect(juste.series).toHaveLength(1);
expect(juste.teintes).toHaveLength(1);
expect(fautesSeparation(juste)).toEqual([]);
const fautes = fautesSeparation(avecFragment(fragment('--couleur-appartenance-2-marque'), (c) => releverSeparation(c)));
expect(fautes).toHaveLength(4);
expect(fautes[0]).toMatch(/^path\b.*span\b.*écart 0\.0 \(origine\)/);
expect(fautes.map((f) => /\(([a-z]+)\)/.exec(f)[1])).toEqual(['origine', ...Object.keys(SIMULATIONS)]);
expect(fautesSeparation({ series: juste.series, teintes: [] })).toEqual(['séries / teintes d’appartenance : paire absente (1 et 0)']);
});
test('qualité, teintes : une marque de graphique peinte d’une teinte d’appartenance ou du rouge du retrait est relevée ; une série de son jeton passe', () => {
const { fautes, lus } = avecFragment(
'<svg data-graphique="collisions" width="200" height="30">' +
'<rect data-marque="graphiqueSerie1" width="20" height="20" style="fill:var(--couleur-graphique-serie-1)" />' +
'<rect data-marque="graphiqueReste" x="40" width="20" height="20" style="fill:var(--couleur-appartenance-2-marque)" />' +
'<path data-marque="graphiqueRepere" d="M80 10 H150" style="fill:none;stroke:var(--couleur-retrait);stroke-width:2px" /></svg>',
(c) => fautesTeintesGraphique(c),
);
expect(lus).toBe(3);
expect(fautes).toHaveLength(2);
expect(fautes[0]).toContain('--couleur-appartenance-2-marque');
expect(fautes[1]).toContain('--couleur-retrait');
});
test('qualité, graphiques : un graphique sans rôle img, sans nom ou sans description est relevé, et deux graphiques de vues différentes du même nom', () => {
const graphique = (vue, nom, decrit) =>
`<div data-vue-qualite="${vue}"><span id="n-${vue}" hidden>${nom}</span><p id="d-${vue}">synthèse</p>` +
`<svg data-graphique="${vue}" role="img" width="40" height="20" aria-labelledby="n-${vue}"${decrit ? ` aria-describedby="d-${vue}"` : ''}></svg></div>`;
const fautes = avecFragment(
graphique('profil', 'Qui est mal servi ?', true) + graphique('paires', 'Qui est mal servi ?', true) + graphique('tables', '', false) +
'<svg data-graphique="nu" width="40" height="20"></svg>',
(c) => fautesGraphiques(releverGraphiques(c)),
);
expect(fautes).toHaveLength(6);
expect(fautes.filter((f) => f.includes('sans nom accessible'))).toHaveLength(2);
expect(fautes.filter((f) => f.includes('sans description'))).toHaveLength(2);
expect(fautes.filter((f) => f.includes('rôle absent'))).toHaveLength(1);
expect(fautes.filter((f) => f.includes('même nom'))).toHaveLength(1);
});
test('teintes réservées : un texte peint d’une teinte de conflit, et la trame d’une règle peinte de la teinte d’une autre, sont relevés ; une trame de sa propre teinte passe', () => {
const { fautes, lus } = avecFragment(
'<p style="color:var(--couleur-conflit-voisins)">alerte</p>' +
@ -1097,6 +1524,227 @@ describe('chaque écran de l’itération (§ 14.5, § 14.6, § 19.11)', () => {
});
});
// --- La page de qualité, sur ses écrans --------------------------------------------------
// Les écrans nommés de la page de qualité (§ 12, § 12.7) : chacun atteint,
// et chacun montre ce que son nom annonce.
const ECRANS_QUALITE = [
'qualite.infobulle',
'qualite.jumeaux',
'qualite.jumeaux.lecture',
'qualite.jumeaux.bloque',
'qualite.paires',
'qualite.objectifs',
'qualite.performance',
'qualite.collisions.etroit',
'qualite.niveaux',
'qualite.tables.assis',
'qualite.tables.doublons',
'qualite.sansCourse',
'qualite.refus',
];
// Les vues de la page qui portent un jumeau : chaque source de vue qui rend
// <Jumeau>, nommée par son data-vue-qualite, en ordre alphabétique.
const SOURCES_VUES = import.meta.glob('./qualite/Vue*.svelte', { query: '?raw', import: 'default', eager: true });
const VUES_A_JUMEAU = Object.values(SOURCES_VUES)
.filter((source) => /<Jumeau\b/.test(source))
.map((source) => /data-vue-qualite="([^"]+)"/.exec(source)?.[1] ?? source.slice(0, 60))
.sort();
// Un segment « prouvé inévitable » étroit : 8 px de large au plus, où un
// contour de 2 px de chaque côté ne laisse plus de vide.
const PROUVE_ETROIT_PX = 8;
// Les relevés des écrans atteints qui montrent la page de qualité.
const deQualite = () => atteints().filter((r) => r.qualite !== null);
const releveDe = (nom) => atteints().find((r) => r.nom === nom);
describe('la page de qualité, sur chacun de ses écrans (§ 12.7, § 14.5, D30, D34, D43, D44)', () => {
test('chaque écran nommé de la page est atteint et la montre ; chacun rend ce que son nom annonce', () => {
const fautes = [];
for (const nom of ECRANS_QUALITE) {
const r = releveDe(nom);
if (r === undefined) fautes.push(`${nom} : non atteint`);
else if (r.qualite === null) fautes.push(`${nom} : la page de qualité absente`);
}
const q = (nom) => releveDe(nom)?.qualite ?? null;
if (q('qualite.infobulle') !== null && q('qualite.infobulle').pourcentages.dansInfobulle === 0) fautes.push('qualite.infobulle : aucun pourcentage dans l’infobulle');
for (const nom of ['qualite.jumeaux', 'qualite.objectifs', 'qualite.performance']) {
if (q(nom) !== null && q(nom).pourcentages.dansJumeaux === 0) fautes.push(`${nom} : aucun pourcentage dans un jumeau`);
}
if (q('qualite.objectifs') !== null && !q('qualite.objectifs').vuesJumeau.includes('objectifs')) fautes.push('qualite.objectifs : le jumeau des objectifs fermé');
if (q('qualite.performance') !== null && !q('qualite.performance').vuesJumeau.includes('performance')) fautes.push('qualite.performance : le jumeau de la performance fermé');
if (q('qualite.collisions.etroit') !== null && !q('qualite.collisions.etroit').prouvesCollisions.some((largeur) => largeur <= PROUVE_ETROIT_PX)) {
fautes.push(`qualite.collisions.etroit : aucun segment prouvé de ${PROUVE_ETROIT_PX} px au plus — largeurs [${q('qualite.collisions.etroit').prouvesCollisions.map((l) => l.toFixed(1))}]`);
}
if (q('qualite.performance') !== null && !(q('qualite.performance').course && q('qualite.performance').pointsReleve > 0)) fautes.push('qualite.performance : ni course ni point de relevé');
if (q('qualite.sansCourse') !== null && !q('qualite.sansCourse').phrases.includes('sansCourse')) fautes.push('qualite.sansCourse : la phrase « sansCourse » absente');
if (q('qualite.refus') !== null) {
if (!q('qualite.refus').phrases.includes('refusDessin')) fautes.push('qualite.refus : la phrase « refusDessin » absente');
if (q('qualite.refus').graphiques.length > 0) fautes.push('qualite.refus : un graphique dessiné malgré le refus');
}
if (q('qualite.paires') !== null && !q('qualite.paires').niveaux.some(({ graphique }) => graphique === 'matrice')) fautes.push('qualite.paires : la matrice non dessinée');
if (q('qualite.objectifs') !== null && !q('qualite.objectifs').graphiques.some(({ ou }) => ou.includes('objectifs'))) fautes.push('qualite.objectifs : le graphique des objectifs absent');
aucuneFaute(fautes, 'écrans de la qualité');
});
test('un jumeau ouvert en lecture et sur un plan bloqué : aucun refus rendu (l’écran lève à la moindre écriture)', () => {
const fautes = [];
for (const nom of ['qualite.jumeaux.lecture', 'qualite.jumeaux.bloque']) {
const r = releveDe(nom);
if (r === undefined) {
fautes.push(`${nom} : non atteint`);
continue;
}
if (r.qualite.jumeaux === 0) fautes.push(`${nom} : aucun jumeau ouvert`);
for (const refus of r.refus) fautes.push(`${nom} : refus ${refus.code}`);
}
aucuneFaute(fautes, 'jumeau en lecture et bloqué');
});
test('chaque pourcentage rendu est suivi, dans son composant, de son compte du pire, sur chaque écran de la page, infobulle et jumeaux compris — le jumeau de chaque vue qui en porte un ouvert sur un écran au moins', () => {
expect(deQualite().reduce((n, r) => n + r.qualite.pourcentages.lus, 0), 'pourcentages lus').toBeGreaterThan(0);
expect(VUES_A_JUMEAU.length, 'vues à jumeau').toBeGreaterThan(0);
const ouvertes = new Set(deQualite().flatMap((r) => r.qualite.vuesJumeau));
const fautes = VUES_A_JUMEAU.filter((vue) => !ouvertes.has(vue)).map((vue) => `${vue} : son jumeau ouvert sur aucun écran`);
fautes.push(...deQualite().flatMap((r) => r.qualite.pourcentages.fautes.map((f) => `${r.nom} — ${f}`)));
aucuneFaute(fautes, 'pourcentage et son compte');
});
test('le signe « % » ne s’écrit que dans un pourcentage, une graduation du graphique en pourcentages ou le libellé du repère « cent »', () => {
expect(deQualite().reduce((n, r) => n + r.qualite.pourcentages.signes.lus, 0), 'textes à « % » lus').toBeGreaterThan(0);
aucuneFaute(deQualite().flatMap((r) => r.qualite.pourcentages.signes.fautes.map((f) => `${r.nom} — ${f}`)), 'signe « % »');
});
test('chaque niveau de l’échelle est rendu : sur qualite.niveaux, la grille des tables et la matrice portent chacune les quatre ; sous les deux autres positions, la grille aussi', () => {
const fautes = [];
const tous = NIVEAUX_GRAPHIQUE.map(({ niveau }) => niveau);
for (const [nom, graphiques] of [
['qualite.niveaux', ['tables', 'matrice']],
['qualite.tables.assis', ['tables']],
['qualite.tables.doublons', ['tables']],
]) {
const grilles = releveDe(nom)?.qualite?.niveaux ?? [];
for (const graphique of graphiques) {
const niveaux = grilles.find((g) => g.graphique === graphique)?.niveaux ?? [];
if (niveaux.join() !== tous.join()) fautes.push(`${nom} : ${graphique} rend les niveaux [${niveaux}], [${tous}] attendus`);
}
}
const rendus = new Set(deQualite().flatMap((r) => r.qualite.niveaux.flatMap(({ niveaux }) => niveaux)));
for (const niveau of tous) if (!rendus.has(niveau)) fautes.push(`niveau ${niveau} : rendu sur aucun écran`);
aucuneFaute(fautes, 'niveaux de l’échelle');
});
test('jamais la seule couleur : série 1 / série 2, prouvé inévitable / reste — un segment prouvé étroit compris ; le plafond, la borne et chaque série, quatre motifs deux à deux distincts ; la règle « cent » au motif du plafond', () => {
// Chaque liste réunie ne garde que la première occurrence de chaque
// signature et de chaque motif : une faute se nomme une fois, sur le
// premier écran qui la montre.
const reunis = { traces: {}, segments: { prouve: [], reste: [] } };
const ajouter = (cible, liste, nom) => {
for (const x of liste) {
if (!cible.some((y) => y.sig === x.sig && y.motif === x.motif)) cible.push({ ...x, ou: `${nom} ${x.ou}` });
}
};
for (const r of deQualite()) {
const { traces, segments } = r.qualite.traces;
for (const [genre, liste] of Object.entries(traces)) ajouter((reunis.traces[genre] ??= []), liste, r.nom);
for (const genre of ['prouve', 'reste']) ajouter(reunis.segments[genre], segments[genre], r.nom);
}
aucuneFaute([...new Set(fautesTraces(reunis))], 'jamais la seule couleur (graphiques)');
});
test('chaque série rendue se sépare de chaque teinte d’appartenance rendue, sur les couleurs d’origine et sous les trois dichromaties, dans les deux thèmes', () => {
const fautes = [];
for (const theme of THEMES) {
// Les couleurs réunies de tous les écrans, chacune nommée sur le
// premier qui la peint.
const reunis = { series: [], teintes: [] };
for (const r of deQualite()) {
for (const genre of ['series', 'teintes']) {
for (const x of r[theme].separation[genre]) {
if (!reunis[genre].some((y) => egales(y.couleur, x.couleur))) reunis[genre].push({ ...x, ou: `${r.nom} ${x.ou}` });
}
}
}
fautes.push(...fautesSeparation(reunis).map((f) => `[${theme}] ${f}`));
}
aucuneFaute(fautes, 'séries et teintes d’appartenance');
});
test('aucune teinte d’appartenance ni le rouge du retrait sur une marque de graphique, dans les deux thèmes', () => {
for (const theme of THEMES) {
expect(deQualite().reduce((n, r) => n + r[theme].teintesGraphique.lus, 0), `marques de graphique lues [${theme}]`).toBeGreaterThan(0);
}
const fautes = deQualite().flatMap((r) => THEMES.flatMap((theme) => r[theme].teintesGraphique.fautes.map((f) => `${r.nom} [${theme}] — ${f}`)));
aucuneFaute([...new Set(fautes)], 'teintes des graphiques');
});
test('chaque graphique est une image nommée par sa question et décrite par sa synthèse ; deux vues ne nomment jamais deux graphiques du même nom', () => {
expect(deQualite().reduce((n, r) => n + r.qualite.graphiques.length, 0), 'graphiques lus').toBeGreaterThan(0);
aucuneFaute([...new Set(deQualite().flatMap((r) => fautesGraphiques(r.qualite.graphiques).map((f) => `${r.nom} — ${f}`)))], 'graphiques nommés et décrits');
});
// Les charges que l'épreuve du clavier ouvre sur la page : chacune rend
// { charge, fichiers }, fichiers posés dans les Documents. Ensemble, elles
// montrent chaque vue qui porte un jumeau.
const CHARGES_CLAVIER = [
['principale', () => ({ charge: chargePrincipale(), fichiers: [] })],
['paires', () => ({ charge: chargePaires(), fichiers: [] })],
['objectifs', () => ({ charge: chargeObjectifs(), fichiers: [] })],
[
'performance',
() => {
const { charge, courses } = generationPrincipale();
return { charge, fichiers: [fichierCourses(charge, courses)] };
},
],
];
test('au clavier : chaque jumeau de la page s’ouvre et se ferme par Entrée, le focus jamais sur body, celui de chaque vue qui en porte un ; Échap ferme l’infobulle', async () => {
const vues = new Set();
for (const [nom, preparer] of CHARGES_CLAVIER) {
const contexte = creerContexte({ langue: 'temoin', theme: 'clair' });
try {
const { charge, fichiers } = preparer();
const m = await ouvrirEvenement(contexte, charge, { section: 'qualite', options: { fichiers } });
await attendre(`la page de qualité dessinée (${nom})`, () => m.racine.querySelector('[data-section="qualite"] svg[data-graphique] [data-marque]'));
const commandes = [...m.racine.querySelectorAll('[data-section="qualite"] [data-commande="ouvrirJumeau"]')];
expect(commandes.length, `commandes de jumeau (${nom})`).toBeGreaterThan(0);
for (const commande of commandes) {
const vue = commande.closest('[data-vue-qualite]')?.dataset.vueQualite ?? null;
commande.focus();
await touche('Enter');
await attendre(`le jumeau de ${vue} ouvert au clavier (${nom})`, () => {
const tableaux = document.getElementById(commande.getAttribute('aria-controls') ?? '');
return commande.getAttribute('aria-expanded') === 'true' && commande.dataset.commande === 'fermerJumeau' && tableaux?.querySelector('[data-jumeau]');
});
expect(document.activeElement, `focus après l’ouverture (${nom}, ${vue})`).toBe(commande);
await touche('Enter');
await attendre(`le jumeau de ${vue} refermé au clavier (${nom})`, () => commande.getAttribute('aria-expanded') === 'false' && commande.dataset.commande === 'ouvrirJumeau');
expect(document.activeElement, `focus après la fermeture (${nom}, ${vue})`).toBe(commande);
expect(document.activeElement).not.toBe(document.body);
vues.add(vue);
}
if (nom === 'principale') {
const { x, y } = await centre(m.racine.querySelector('[data-graphique="profilComptes"] > rect[pointer-events="all"]'));
await pointer('bouger', x, y);
const bulle = await attendre("l'infobulle du profil", () => {
const trouvee = m.racine.querySelector('[data-vue-qualite="profil"] [role="tooltip"]');
return trouvee !== null && !trouvee.hidden ? trouvee : null;
});
await touche('Escape');
await attendre("l'infobulle fermée par Échap", () => bulle.hidden);
}
expect(contexte.erreurs(), `erreurs (${nom})`).toEqual([]);
} finally {
await contexte.defaire();
}
}
expect([...vues].filter((vue) => vue !== null).sort(), 'vues dont le jumeau s’ouvre au clavier').toEqual(expect.arrayContaining(VUES_A_JUMEAU));
}, 240_000);
});
// --- Le balisage littéral -------------------------------------------------------------------
describe('balisage littéral (§ 13.1)', () => {

View file

@ -26,7 +26,7 @@ import { MARQUE_TEMOIN, creerTraduction } from '../../src/application/traduction
import { constante } from '../../src/geometrie/constantes.js';
import JETONS_CSS from '../../src/interface/jetons.css?raw';
import { TABLES_INTERFACE } from '../../src/interface/libelles.js';
import { MARQUES, MARQUES_GRAPHIQUE, TEXTES } from '../../src/interface/marques.js';
import { MARQUES, TEXTES, TEXTES_GRAPHIQUE } from '../../src/interface/marques.js';
import { serialiserCharge } from '../../src/stockage/canonique.js';
import { analyser } from '../../src/stockage/document.js';
import { SUFFIXES } from '../../src/stockage/noms.js';
@ -35,6 +35,7 @@ import { MODE_AFFICHAGE } from '../chromium.js';
import { CHAINE_TEMOIN, longueurTemoin } from '../temoin.js';
import { feuillePosee } from './feuilles.js';
import {
atteignable,
attenuations,
composantes,
contraste,
@ -114,25 +115,15 @@ function svgExterieur(element) {
return svg;
}
// Feuille qui rend chaque élément atteignable le temps d'une lecture, comme
// celle de rendu.js : elementsFromPoint suit l'atteinte du pointeur.
const ATTEIGNABLE = '*, *::before, *::after { pointer-events: auto !important; }';
// Le point (x, y) de la fenêtre montre-t-il le dessin qui porte l'élément ?
// Dans la fenêtre, et le <svg> extérieur, ou l'un de ses descendants, parmi
// les éléments présents au point : un ancêtre qui défile rogne ce qui
// déborde de sa boîte, et le point rogné n'atteint plus le dessin.
// les éléments présents au point, chacun atteignable (atteignable,
// rendu.js) : un ancêtre qui défile rogne ce qui déborde de sa boîte, et le
// point rogné n'atteint plus le dessin.
function montreLeDessin(element, x, y) {
if (!(x >= 0 && y >= 0 && x < innerWidth && y < innerHeight)) return false;
const svg = svgExterieur(element);
const feuille = document.createElement('style');
feuille.textContent = ATTEIGNABLE;
document.head.append(feuille);
try {
return document.elementsFromPoint(x, y).some((n) => svg.contains(n));
} finally {
feuille.remove();
}
return atteignable(document, () => document.elementsFromPoint(x, y).some((n) => svg.contains(n)));
}
// Le centre de la boîte d'un élément SVG montre-t-il son dessin ?
@ -164,7 +155,12 @@ export const CHAMPS = 'input:not([type="radio"]):not([type="checkbox"]):not([typ
* dont la boîte coupe la fenêtre se mesurent, sans rien amener — ce que
* montre une capture d'écran.
*/
export function releverTextes(racine, { fenetre = false } = {}) {
export function releverTextes(racine, options = {}) {
return atteignable(document, () => lireTextes(racine, options));
}
// Le corps de releverTextes, sous la feuille ATTEIGNABLE qu'il pose une fois.
function lireTextes(racine, { fenetre = false } = {}) {
const fautes = [];
const fautesAttenuation = [];
const couleurs = new Map();
@ -297,9 +293,21 @@ function dessousLePlusFrequent(forme, quoi) {
// Les vides de son motif laissent alors voir la série : le repère se mesure
// contre ce qui est peint sous la première série tracée avant lui, le fond du
// graphique et sa grille. Un repère plein, sans vide, se mesure contre la
// série qu'il recouvre.
// série qu'il recouvre. Un point de relevé — data-point-releve —, posé sur
// le tracé de sa série, de la même marque, se mesure contre ce qui est peint
// sous ce tracé, le premier frère qui le précède sous la même marque : le
// point et son tracé sont une seule marque. Dans un graphique de la page de
// qualité, une autre forme se mesure de même contre ce qui est peint sous la
// première forme de sa marque qui la précède chez son parent : les signes de
// rangs voisins se chevauchent quand les rangs sont serrés, et ce qu'une forme
// de sa propre encre recouvre n'est pas son fond.
function sondeAvant(forme) {
if (!forme.matches('[data-repere]') || getComputedStyle(forme).strokeDasharray === 'none') return forme;
if (forme.matches('[data-point-releve]')) {
let trace = forme.previousElementSibling;
while (trace !== null && !(trace.localName === 'path' && trace.dataset.marque === forme.dataset.marque)) trace = trace.previousElementSibling;
return trace ?? forme;
}
if (!forme.matches('[data-repere]') || getComputedStyle(forme).strokeDasharray === 'none') return memeMarqueAvant(forme);
for (const frere of forme.parentNode.children) {
if (frere === forme) break;
if (frere.matches('[data-serie]')) return frere;
@ -307,6 +315,19 @@ function sondeAvant(forme) {
return forme;
}
// La première forme qui précède la forme chez son parent et porte la même
// data-marque, dans un graphique (svg[data-graphique]) ; la forme elle-même
// sinon, et hors d'un graphique.
function memeMarqueAvant(forme) {
const marque = forme.getAttribute('data-marque');
if (marque === null || forme.closest('svg[data-graphique]') === null) return forme;
for (const frere of forme.parentNode.children) {
if (frere === forme) break;
if (frere.getAttribute('data-marque') === marque) return frere;
}
return forme;
}
// Les couleurs peintes sous une forme SVG au point donné de son repère : une
// sonde sans peinture, posée chez son parent juste avant elle (sondeAvant),
// se mesure par fondPeintSous, qui lit les formes peintes avant elle à ce
@ -379,17 +400,10 @@ function voisinage(element, jeton) {
return { peinture: null, voisins: [] };
}
// Le registre que releverMarques connaît : MARQUES, et les marques de
// graphique (MARQUES_GRAPHIQUE), tenues hors de MARQUES tant qu'aucun
// écran ne les rend toutes — la couverture des écrans ne lit que MARQUES.
// Le relevé d'un écran comme celui du parcours photographié les mesure
// ainsi à 3:1. Les fonds de graphique (FONDS_GRAPHIQUE) ne sont pas des
// marques et n'y entrent pas.
const REGISTRE_MARQUES = Object.freeze([...MARQUES, ...MARQUES_GRAPHIQUE]);
/**
* Les marques rendues sous racine (§ 14.5, § 19.5) : chacune nommée au
* registre, peinte de son jeton, à 3:1 de ce qui l'entoure, sans
* registre (MARQUES, marques.js, celles des graphiques comprises), peinte de
* son jeton, à 3:1 de ce qui l'entoure, sans
* atténuation. Sous le document imprimé de l'aperçu, le jeton se résout
* comme le papier le résout : au clair, dans les deux thèmes.
* → { vues: string[], fautes, attenuations, mesures, ratios },
@ -401,7 +415,12 @@ const REGISTRE_MARQUES = Object.freeze([...MARQUES, ...MARQUES_GRAPHIQUE]);
* le bord du plan ne garde parfois, de son trait, que le point où il touche
* le trait du plateau.
*/
export function releverMarques(racine, { fenetre = false } = {}) {
export function releverMarques(racine, options = {}) {
return atteignable(document, () => lireMarques(racine, options));
}
// Le corps de releverMarques, sous la feuille ATTEIGNABLE qu'il pose une fois.
function lireMarques(racine, { fenetre = false } = {}) {
const vues = new Set();
const ratios = [];
const fautes = [];
@ -411,7 +430,7 @@ export function releverMarques(racine, { fenetre = false } = {}) {
if (fenetre && !entiereDansLaFenetre(element)) continue;
if (!rendu(element)) continue;
const nom = element.dataset.marque;
const entree = REGISTRE_MARQUES.find((m) => m.nom === nom);
const entree = MARQUES.find((m) => m.nom === nom);
if (entree === undefined) {
fautes.push(`${situer(element)} : marque hors du registre`);
continue;
@ -476,11 +495,16 @@ const NOMMANTS = ['aria-label', 'aria-labelledby', 'title'];
const CLES = new Set(creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]).cles());
// Les jetons de couleur que la feuille des jetons déclare, dans l'ordre de
// leur première déclaration ; les jetons des textes du registre (TEXTES)
// d'abord, qui nomment une couleur partagée par plusieurs jetons.
// leur première déclaration ; d'abord les jetons des textes du registre
// (TEXTES) hors de la famille « graphique » (TEXTES_GRAPHIQUE), qui nomment
// une couleur partagée par plusieurs jetons. Les encres des cases de
// graphique ne passent pas devant : une couleur qu'elles partagent avec un
// jeton déclaré avant elles, comme le blanc du papier dans le thème sombre,
// garde le nom de ce jeton, que les encres soient versées dans TEXTES ou non.
const JETONS = (() => {
const declares = [...new Set([...JETONS_CSS.matchAll(/(--couleur-[a-z0-9-]+)\s*:/g)].map(([, nom]) => nom))];
const deTexte = TEXTES.map(({ jeton }) => jeton);
const deGraphique = new Set(TEXTES_GRAPHIQUE.map(({ jeton }) => jeton));
const deTexte = TEXTES.map(({ jeton }) => jeton).filter((jeton) => !deGraphique.has(jeton));
return Object.freeze([...deTexte.filter((jeton) => declares.includes(jeton)), ...declares.filter((jeton) => !deTexte.includes(jeton))]);
})();

View file

@ -14,8 +14,9 @@
//
// Aucun délai fixe (§ 19.3) : chaque étape attend une condition du document,
// image après image, et lève en la nommant quand elle ne vient pas. Les
// données sont tirées des démonstrations livrées et de commandes de
// l'application, sans aléa : deux parcours rendent le même écran.
// données sont tirées des démonstrations livrées, d'événements d'épreuve aux
// noms inventés et de commandes de l'application, sans aléa : deux parcours
// rendent le même écran.
//
// Un état s'atteint sans geste de l'opérateur : la question du dossier de
// travail, qui se pose au démarrage, par les options du système simulé.
@ -27,7 +28,8 @@ import { SECTIONS } from '../../src/interface/evenement/sections.js';
import { LISTE_PAR_DEFAUT, listeApres } from '../../src/interface/generation/priorites.js';
import { rechercher } from '../../src/moteur/recherche.js';
import { serialiser } from '../../src/stockage/canonique.js';
import { analyser, capacite, configurationDepuisCharge } from '../../src/stockage/document.js';
import { texteDesCourses } from '../../src/stockage/courses.js';
import { analyser, capacite, configurationDepuisCharge, creerCharge } from '../../src/stockage/document.js';
import { SUFFIXES } from '../../src/stockage/noms.js';
import { FICHIER_REGLAGES, serialiserReglages } from '../../src/stockage/reglages_locaux.js';
import { VERSION } from '../../src/version.genere.js';
@ -178,15 +180,163 @@ const demonstration = (cle) => analyser(texteLivre(cle)).charge;
* : au tour 1, les tables 1 et 2 sont en conflit, les tables 3 et 4 saines.
*/
export function chargePrincipale() {
return generationPrincipale().charge;
}
/** La principale et les entrées du compagnon des courses que sa génération
* rend : { charge, courses }. */
export function generationPrincipale() {
let charge = demonstration('petite-conflit');
charge = executer(charge, 'designer', { participant: 1, table: 1, siege: 1, libelle: 'hôte' });
charge = executer(charge, 'reserver', { participant: 2, table: 2, siege: 2, portee: 'tour', tour: 1 });
charge = executer(charge, 'poserTitre', { table: 3, siege: 3, libelle: 'animateur' });
const configuration = configurationDepuisCharge(charge);
const propositions = rechercher(configuration, { graine: 2, nombre: 1, arret: 300, historique: 1000 });
return appliquerCommande('enregistrerGeneration', charge, { propositions, configuration, produitVersion: VERSION.affichee }).charge;
return generee(charge, { graine: 2, nombre: 1, arret: 300 });
}
// La charge, une génération de plus sous les réglages donnés, historique
// 1 000, enregistrée par la commande : { charge, courses }, courses les
// entrées du compagnon que la commande rend, une par proposition.
function generee(charge, reglages) {
const configuration = configurationDepuisCharge(charge);
const propositions = rechercher(configuration, { historique: 1000, ...reglages });
const rendu = appliquerCommande('enregistrerGeneration', charge, { propositions, configuration, produitVersion: VERSION.affichee });
return { charge: rendu.charge, courses: rendu.courses };
}
// Des personnes inventées : le prénom et le nom se combinent, chacun
// désigné par son rang, sans homonyme jusqu'à soixante-quatre.
const PRENOMS = ['Iris', 'Théo', 'Ondine', 'Basile', 'Maëlle', 'Sidonie', 'Aurèle', 'Clovis'];
const NOMS = ['Ombrelle', 'Pervenche', 'Lacasse', 'Tourteau', 'Quenouille', 'Cormoran', 'Belette', 'Garance'];
// Un événement d'épreuve inventé, sans proposition ni retenu : une
// personne par entrée d'appartenances — son appartenance, null sans —,
// tables tables rondes de sieges places numérotées de 1 à tables, tours
// tours.
function chargeInventee({ appartenances, tables, sieges, tours }) {
const charge = creerCharge({ id: 'evt-qualite', nom: 'Soirée Pervenche', siegesParDefaut: sieges, tours });
charge.participants = appartenances.map((appartenance, rang) => ({
id: rang + 1,
nom: NOMS[Math.floor(rang / PRENOMS.length) % NOMS.length],
prenom: PRENOMS[rang % PRENOMS.length],
appartenance,
courriel: null,
titrePressenti: null,
notes: null,
exclu: false,
}));
charge.tables = Array.from({ length: tables }, (_, rang) => ({
id: rang + 1,
numero: rang + 1,
sieges: null,
forme: 'ronde',
position: { x: 300 * (rang + 1), y: 0 },
}));
charge.prochainsIds = { participant: appartenances.length + 1, table: tables + 1, proposition: 1 };
return charge;
}
const AUBIER = 'Coopérative Aubier';
const RESSAC = 'Atelier Ressac';
/** Douze personnes, quatre tables de trois, six tours (§ 12.5) : chacun
* revoit quelqu'un ; les deux premières, réservées ensemble à la table 1
* pour tous les tours, forment une paire imposée. Une génération de deux
* propositions. */
export function chargePaires() {
const charge = chargeInventee({ appartenances: Array.from({ length: 12 }, (_, rang) => [AUBIER, RESSAC, null][rang % 3]), tables: 4, sieges: 3, tours: 6 });
charge.reservations = [1, 2].map((participant) => ({ participant, table: 1, siege: null, portee: 'tous', tour: null }));
return generee(charge, { graine: 5, nombre: 2, arret: 60 }).charge;
}
/** Douze personnes, trois tables de quatre, trois tours ; les six premières
* portent l'étiquette « finance », que la table 1 a pour objectif à tous
* les tours. Une génération d'une proposition, sous cet objectif. */
export function chargeObjectifs() {
const charge = chargeInventee({ appartenances: Array.from({ length: 12 }, (_, rang) => [AUBIER, RESSAC, null][rang % 3]), tables: 3, sieges: 4, tours: 3 });
for (const personne of charge.participants) if (personne.id <= 6) personne.etiquettes = ['finance'];
charge.tables[0].objectifs = [{ portee: 'tous', tour: null, regle: null, etiquettes: ['finance'] }];
return generee(charge, { graine: 3, nombre: 1, arret: 300 }).charge;
}
// Le placement de chargeNiveaux, tours par tours, sièges non attribués.
// Les doublons parmi les affiliés (α − δ) valent 3 à la table 1 à chaque
// tour, 2 et 1 aux tables 2 et 3 du tour 1, 0 ailleurs ; les sièges sans
// appartenance nouvelle (c − δ) valent 0 à la table 2 du tour 2, 1 aux
// tables 2 et 3 du tour 3, 2 et 3 ailleurs ; la table 4 du tour 3 n'a aucun
// affilié. Les quatre de la table 1 se voient trois fois, 5 et 6 deux
// fois, 5 et 7 une fois, 1 et 5 jamais.
const NIVEAUX = Object.freeze([
[[1, 2, 3, 4], [5, 6, 7, 12], [8, 9, 13, 14], [10, 11, 15, 16]],
[[1, 2, 3, 4], [5, 8, 10, 11], [6, 9, 12, 13], [7, 14, 15, 16]],
[[1, 2, 3, 4], [5, 6, 9, 10], [7, 8, 11, 12], [13, 14, 15, 16]],
]);
/** Seize personnes inventées, quatre tables de quatre, trois tours : les
* quatre de la première appartenance réservées ensemble à la table 1 pour
* tous les tours, et la proposition NIVEAUX, écrite à la main, où la
* grille des tables, sous chaque position, et la matrice des rencontres
* portent les quatre niveaux de l'échelle (§ 12.7, D33, D35). */
export function chargeNiveaux() {
const appartenances = [
...Array(4).fill(AUBIER),
...Array(3).fill(RESSAC),
...Array(2).fill('Collectif Brisant'),
'Cercle Ormeau',
'Brigade Sauge',
...Array(5).fill(null),
];
let charge = chargeInventee({ appartenances, tables: 4, sieges: 4, tours: 3 });
for (const participant of [1, 2, 3, 4]) {
charge = executer(charge, 'reserver', { participant, table: 1, siege: participant, portee: 'tous', tour: null });
}
return avecTours(charge, NIVEAUX);
}
// Le nombre de tours de chargeCollisionsEtroites.
const TOURS_ETROITS = 10;
/** Soixante-quatre personnes inventées, huit tables de huit, TOURS_ETROITS
* tours, et une proposition écrite à la main, la même à chaque tour : les
* huit de la première appartenance réunies à la table 1, vingt-huit
* collisions par tour, aucune prouvée ; les huit de la seconde, une par
* table sauf deux à la table 2, une collision par tour, dont une seule
* prouvée — les deux réservées ensemble à la table 2 au tour 1. Le segment
* « prouvé inévitable » de la seconde vaut un sur un axe de 280 : il est
* étroit (§ 12.4, B13). Au-delà de SEUIL_MATRICE_PARTICIPANTS, la matrice
* ne se dessine pas. */
export function chargeCollisionsEtroites() {
const appartenances = [...Array(8).fill(AUBIER), ...Array(8).fill(RESSAC), ...Array(48).fill(null)];
const charge = chargeInventee({ appartenances, tables: 8, sieges: 8, tours: TOURS_ETROITS });
charge.reservations = [9, 10].map((participant) => ({ participant, table: 2, siege: null, portee: 'tour', tour: 1 }));
// Les tables 2 à 8 : la seconde appartenance, 9 et 10 à la table 2, puis
// une de 11 à 16 par table, complétées des personnes sans appartenance.
const sans = Array.from({ length: 48 }, (_, rang) => 17 + rang);
const autres = [
[9, 10, ...sans.slice(0, 6)],
...[11, 12, 13, 14, 15, 16].map((personne, rang) => [personne, ...sans.slice(6 + 7 * rang, 13 + 7 * rang)]),
];
const tour = [[1, 2, 3, 4, 5, 6, 7, 8], ...autres];
return avecTours(charge, Array.from({ length: TOURS_ETROITS }, () => tour));
}
/** La petite démonstration dont la table 4 est réservée en entier pour
* tous les tours (chargeGelee), sous la proposition TROIS_CONFLITS, écrite
* à la main, qui assoit ailleurs les trois personnes réservées : les
* rencontres que le plan mesure dépassent les plafonds que les
* réservations bornent, et la garde du détail (refusDessin, § 12.3) retire
* le dessin de la page de qualité. */
export function chargeRefusDessin() {
return avecTours(chargeGelee(), TROIS_CONFLITS);
}
/** Le compagnon des courses d'une charge, ses entrées données, posé dans
* les Documents sous BASE. */
export const fichierCourses = (charge, entrees, base = BASE) => ({
racine: 'documents',
chemin: `${base}${SUFFIXES.courses}`,
texte: texteDesCourses(charge.evenement.id, entrees),
});
// Un placement de la petite démonstration en conflit, quatre tours de
// quatre tables de trois, sièges non attribués : au tour 3, la table 1 réunit
// une même appartenance, des voisins revus et un retour à sa table, la
@ -365,6 +515,69 @@ function ecranDeSection(section, mode) {
// La principale en écriture, sur le plan.
const planEnEcriture = (contexte, options) => ouvrirEvenement(contexte, chargePrincipale(), { ecriture: true, section: 'plan', options });
// La page de qualité du montage dessinée : une vue posée au moins, chaque
// graphique de la section mesuré et porteur d'une marque au moins.
async function qualiteDessinee(m) {
await attendre('la page de qualité dessinée', () => {
const section = m.racine.querySelector('[data-section="qualite"]');
if (section === null || section.querySelector('[data-vue-qualite]') === null) return false;
return [...section.querySelectorAll('svg[data-graphique]')].every(
(svg) => Number(svg.getAttribute('width')) > 0 && svg.querySelector('[data-marque]') !== null,
);
});
}
// Ouvre l'événement de la charge sur la page de qualité (ouvrirEvenement),
// puis attend son dessin. Rend le montage.
async function ouvrirQualite(contexte, charge, { ecriture = false, options = {} } = {}) {
const m = await ouvrirEvenement(contexte, charge, { ecriture, section: 'qualite', options });
await qualiteDessinee(m);
return m;
}
// Les commandes qui ouvrent un jumeau, sous le sélecteur donné.
const commandesJumeau = (m, sous) => m.racine.querySelectorAll(`${sous} [data-commande="ouvrirJumeau"]`);
// Ouvre, par sa commande, chaque jumeau sous le sélecteur donné, l'un après
// l'autre — la commande d'un jumeau ouvert devient celle qui le ferme —,
// puis attend que chacun soit rendu. Rend le nombre de jumeaux ouverts.
async function ouvrirJumeaux(m, sous) {
const total = commandesJumeau(m, sous).length;
for (let ouverts = 0; ouverts < total; ouverts += 1) {
const commande = commandesJumeau(m, sous)[0];
if (commande === undefined) throw new Error(`écran : ${total - ouverts} jumeau(x) de ${sous} sans commande d'ouverture`);
commande.click();
flushSync();
await uneImage();
}
await attendre(`les ${total} jumeaux de ${sous} ouverts`, () => commandesJumeau(m, sous).length === 0 && m.racine.querySelectorAll(`${sous} [data-jumeau]`).length >= total);
return total;
}
// Un jumeau du profil ouvert sur la page de qualité de la charge, au mode
// donné : regarder ne refuse rien et n'écrit rien (§ 8.4) — un refus rendu
// ou une primitive d'écriture appelée depuis l'ouverture de la page fait
// lever l'écran.
async function jumeauSansEcriture(contexte, charge) {
const m = await ouvrirQualite(contexte, charge);
const ecritures = m.trace.length;
await ouvrirJumeaux(m, '[data-vue-qualite="profil"]');
if (m.racine.querySelector('[data-refus]') !== null) throw new Error('écran : un refus rendu à l’ouverture d’un jumeau');
if (m.trace.length !== ecritures) throw new Error(`écran : l’ouverture d’un jumeau a écrit ${JSON.stringify(m.trace.slice(ecritures))}`);
return m.racine;
}
// La page de qualité de chargeNiveaux, son sélecteur du mélange des tables
// posé sur la position donnée par un clic.
async function tablesSurPosition(contexte, position) {
const m = await ouvrirQualite(contexte, chargeNiveaux());
await cliquer(m, `[data-vue-qualite="tables"] [data-position-tables="${position}"]`);
await attendre(`la position « ${position} » choisie`, () => m.racine.querySelector(`[data-position-tables="${position}"]`)?.checked === true);
await qualiteDessinee(m);
return m.racine;
}
/**
* Le contexte d'un parcours : il monte App dans la langue et le thème
* donnés, garde chaque montage et chaque nettoyage, et défait tout par
@ -872,6 +1085,148 @@ export const ECRANS = Object.freeze([
return m.racine;
},
},
{
nom: 'qualite.infobulle',
mode: 'lecture',
async atteindre(contexte) {
// Le pointeur réel au centre de la surface du profil en comptes,
// amenée au milieu de la fenêtre comme centre() amène toute cible : le
// rang pointé se désigne, et l'infobulle porte sa ligne du jumeau, son
// pourcentage suivi du compte de la ligne. Rien ne la place à l'écart
// du bandeau collant : l'infobulle elle-même reste dans la zone où
// défile le graphique.
const m = await ouvrirQualite(contexte, chargePrincipale());
const { x, y } = await centre(await element(m, '[data-graphique="profilComptes"] > rect[pointer-events="all"]'));
await pointer('bouger', x, y);
await attendre("l'infobulle du profil", () => {
const bulle = m.racine.querySelector('[data-vue-qualite="profil"] [role="tooltip"]');
return (
bulle !== null &&
!bulle.hidden &&
getComputedStyle(bulle).visibility === 'visible' &&
bulle.querySelector('[data-pourcentage]') !== null &&
m.racine.querySelector('[data-graphique="profilComptes"] [data-rang]') !== null
);
});
return m.racine;
},
},
{
nom: 'qualite.jumeaux',
mode: 'lecture',
async atteindre(contexte) {
// La principale : chaque jumeau de la page ouvert par sa commande. Les
// écrans des objectifs et de la performance ouvrent les leurs.
const m = await ouvrirQualite(contexte, chargePrincipale());
await ouvrirJumeaux(m, '[data-section="qualite"]');
return m.racine;
},
},
{
nom: 'qualite.jumeaux.lecture',
mode: 'lecture',
atteindre: (contexte) => jumeauSansEcriture(contexte, chargePrincipale()),
},
{
nom: 'qualite.jumeaux.bloque',
mode: 'bloque',
atteindre: (contexte) => jumeauSansEcriture(contexte, chargeBloquee()),
},
{
nom: 'qualite.paires',
mode: 'lecture',
async atteindre(contexte) {
// 12 / 4 × 3 / 6 : les paires choisies et imposées, et la matrice
// dessinée sous le seuil.
const m = await ouvrirQualite(contexte, chargePaires());
await element(m, '[data-vue-qualite="paires"] [data-paire]');
await element(m, '[data-graphique="matrice"] [data-case]');
return m.racine;
},
},
{
nom: 'qualite.objectifs',
mode: 'lecture',
async atteindre(contexte) {
// La charge à objectifs, chaque jumeau de la page ouvert : celui des
// objectifs porte la ligne de la soirée et celle de chaque table.
const m = await ouvrirQualite(contexte, chargeObjectifs());
await element(m, '[data-graphique="objectifs"]');
await ouvrirJumeaux(m, '[data-section="qualite"]');
return m.racine;
},
},
{
nom: 'qualite.performance',
mode: 'lecture',
async atteindre(contexte) {
// La principale et le compagnon des courses que sa génération rend :
// la ligne de tête de la course et ses points de relevé, chaque
// jumeau de la page ouvert, celui de la performance compris.
const { charge, courses } = generationPrincipale();
const m = await ouvrirQualite(contexte, charge, { options: { fichiers: [fichierCourses(charge, courses)] } });
await element(m, '[data-vue-qualite="performance"] [data-course]');
await element(m, '[data-vue-qualite="performance"] [data-point-releve]');
await ouvrirJumeaux(m, '[data-section="qualite"]');
return m.racine;
},
},
{
nom: 'qualite.collisions.etroit',
mode: 'lecture',
async atteindre(contexte) {
// chargeCollisionsEtroites : la barre de la seconde appartenance
// porte un segment « prouvé inévitable » étroit, à côté de son reste.
const m = await ouvrirQualite(contexte, chargeCollisionsEtroites());
await element(m, '[data-graphique="collisions"] [data-segment="prouve"]');
await element(m, '[data-graphique="collisions"] [data-segment="reste"]');
return m.racine;
},
},
{
nom: 'qualite.niveaux',
mode: 'lecture',
async atteindre(contexte) {
// La grille des tables, position « sièges », et la matrice portent
// chacune les quatre niveaux de l'échelle.
const m = await ouvrirQualite(contexte, chargeNiveaux());
await element(m, '[data-graphique="tables"] [data-case]');
await element(m, '[data-graphique="matrice"] [data-case]');
return m.racine;
},
},
{
nom: 'qualite.tables.assis',
mode: 'lecture',
atteindre: (contexte) => tablesSurPosition(contexte, 'assis'),
},
{
nom: 'qualite.tables.doublons',
mode: 'lecture',
atteindre: (contexte) => tablesSurPosition(contexte, 'doublons'),
},
{
nom: 'qualite.sansCourse',
mode: 'lecture',
async atteindre(contexte) {
// La principale sans compagnon des courses : la phrase de la
// performance.
const m = await ouvrirQualite(contexte, chargePrincipale());
await element(m, '[data-vue-qualite="performance"] [data-phrase-qualite="sansCourse"]');
return m.racine;
},
},
{
nom: 'qualite.refus',
mode: 'lecture',
async atteindre(contexte) {
// chargeRefusDessin : la garde du détail retire le dessin, la phrase
// du refus et chaque contradiction nommée.
const m = await ouvrirEvenement(contexte, chargeRefusDessin(), { section: 'qualite' });
await element(m, '[data-section="qualite"] [data-phrase-qualite="refusDessin"]');
return m.racine;
},
},
{
nom: 'apercu',
mode: 'lecture',

View file

@ -368,6 +368,39 @@ function centreDe(element) {
// pointer-events: none n'en est pas moins peint.
const ATTEIGNABLE = '*, *::before, *::after { pointer-events: auto !important; }';
// Les documents où la feuille ATTEIGNABLE est posée par une lecture en
// cours.
const ATTEIGNABLES = new WeakSet();
/**
* Rend lire(), la feuille ATTEIGNABLE posée dans doc le temps de l'appel.
* Une lecture imbriquée dans une autre lit sous la feuille de la première,
* qui seule la pose et la retire : un relevé qui atteint des milliers de
* points, enveloppé d'un seul appel, ne change les feuilles du document
* qu'une fois. Chaque pose et chaque retrait recalculent le style du
* document entier, et la page épuise sa mémoire après quelque cent mille
* recalculs. lire est synchrone : rien d'autre ne s'exécute sous la
* feuille.
*
* @template T
* @param {Document} doc
* @param {() => T} lire
* @returns {T}
*/
export function atteignable(doc, lire) {
if (ATTEIGNABLES.has(doc)) return lire();
const feuille = doc.createElement('style');
feuille.textContent = ATTEIGNABLE;
doc.head.append(feuille);
ATTEIGNABLES.add(doc);
try {
return lire();
} finally {
ATTEIGNABLES.delete(doc);
feuille.remove();
}
}
// Éléments présents au point centre, en coordonnées de la fenêtre, du plus
// haut au plus bas dans l'ordre de peinture — cascade, positionnement et
// dessins SVG compris —, pointer-events ignoré. Un point hors de la fenêtre
@ -377,14 +410,7 @@ function elementsAuPoint(doc, centre, texte) {
if (centre.x < 0 || centre.y < 0 || centre.x >= fenetre.innerWidth || centre.y >= fenetre.innerHeight) {
throw new Error(`fond sous ${designation(texte)} : centre (${centre.x}, ${centre.y}) hors de la fenêtre`);
}
const feuille = doc.createElement('style');
feuille.textContent = ATTEIGNABLE;
doc.head.append(feuille);
try {
return doc.elementsFromPoint(centre.x, centre.y);
} finally {
feuille.remove();
}
return atteignable(doc, () => doc.elementsFromPoint(centre.x, centre.y));
}
// Premier fond opaque peint au point centre sous racine. Parmi les éléments