[IMP] quality page: tile floors, measured thresholds, spec integrated

A tile now writes the floor the diagnostic proves under its number, and says
when the measure reaches it, so an operator stops relaunching toward a value
no placement can beat; no floor is written outside the floors' domain. A
proved bar segment keeps its 6 px outline without pushing the bar past its
value. The matrix and label thresholds adopt their measured values (44
people, 168 px). The spec and the changelog describe the quality page as
the code builds it.
Checked: node 5130, browser 1146, long 128; captures stable on two passes.

--- FR ---

[IMP] page qualité : planchers des tuiles, seuils mesurés, spec intégrée

Une tuile écrit sous son chiffre le plancher que prouve le diagnostic, et dit
quand la mesure l'atteint : l'opérateur cesse de relancer vers une valeur
qu'aucun placement ne bat ; hors du domaine des planchers, rien ne s'écrit.
Un segment prouvé garde son contour de 6 px sans pousser la barre au-delà de
sa valeur. Les seuils de la matrice et des libellés prennent leurs valeurs
mesurées (44 personnes, 168 px). La spec et le journal des versions
décrivent la page de qualité telle que le code la construit.
Vérifié : node 5130, navigateur 1146, longues 128 ; captures stables, 2 passes.

Assisted-by: Claude Opus 5.5
This commit is contained in:
Mathieu Benoit 2026-10-13 01:48:10 -04:00
parent 2cb1e771a2
commit 18cd4c51be
30 changed files with 2205 additions and 351 deletions

View file

@ -90,11 +90,89 @@
environ 5 %.
- Le guide d'usage montre les étiquettes, les objectifs des tables, leur
import et l'ordre des priorités, captures à l'appui.
- Une neuvième section, « Qualité », suit « Propositions » dans la phase Placer.
Elle mesure le placement que le plan montre — le retenu ou la proposition
montrée — et répond à deux questions : ce placement est-il bon, et qui s'y
trouve mal servi ? Chaque ligne du tableau des propositions offre « Qualité »,
qui montre la proposition au plan et ouvre la page. La page ne modifie rien :
elle reste ouverte en lecture, sur un plan bloqué et pendant une génération.
- La page se lit vue par vue, une liste en tête menant à chacune : l'en-tête
chiffré, une tuile par colonne du tableau des propositions, chaque borne celle
d'une personne nommée ; le profil trié des rencontres ; les collisions
d'appartenance, « prouvé inévitable » et « reste » ; les appartenances à
chaque table ; les paires répétées ; les objectifs des tables ; les animateurs
rencontrés ; la diversité par animateur ; la performance du calcul. Un cas
sans objet — une seule table, un seul tour, aucune appartenance, aucune
collision… — s'écrit en une phrase, jamais par des axes vides.
- Une tuile écrit sous son chiffre le plancher que le diagnostic prouve pour sa
mesure, « plancher 4 », et « plancher 4 atteint : impossible de faire moins »
quand la mesure l'égale : collisions cumulées, paires distinctes, excédent,
écart d'itinéraire maximal et, chacun quand le critère des objectifs le
compare sous le mode réglé, personnes jamais servies, plus de tours hors
objectif d'une personne et tours hors objectif. Sur un placement qui assoit
chacun à chaque tour et tient chaque réservation, relancer ne fera pas passer
la mesure sous ce plancher ; hors de là, un placement ajusté qui laisse
quelqu'un en réserve par exemple, la page n'en écrit aucun. Sans plancher
prouvé, la tuile n'en écrit aucun, jamais un 0.
- Chaque graphique a son tableau jumeau, qui s'ouvre à la demande sous le
graphique et se parcourt au clavier et au lecteur d'écran ; l'infobulle d'un
point en porte la ligne. Les graphiques se lisent dans les deux thèmes et sans
leur couleur : chaque tracé a son motif.
- Les parts se lisent en pourcentages, sur la page de qualité seulement, et
chaque pourcentage est suivi, juste dessous, du pire cas en compte : « 88 % »,
puis « minimum 24 sur 27 » ; « 77 % des tours possibles servis », puis « 3
personnes jamais servies ». Sur une ligne de tableau ou d'infobulle, il est
suivi du compte de cette ligne, « 5 appartenances pour 8 sièges ». Le tableau
des propositions garde ses comptes.
- Le profil des rencontres se dessine en deux graphiques, au même ordre de
personnes, de la plus mal servie à la mieux servie : en comptes, avec le
plafond réalisé en trait mixte et la **borne de diversité** — une borne
arithmétique, qui ne prouve rien, des appartenances qu'une personne peut
rencontrer à ses tables — en pointillés ; puis en pourcentage du plafond
réalisé, jusqu'à la règle à 100 %.
- Les appartenances à chaque table se lisent sur une grille tables × tours, sous
trois positions d'un sélecteur : « appartenances par siège », par défaut,
« appartenances par personne assise » et « doublons parmi les affiliés ». La
pire table vient en tête ; la position choisie se garde jusqu'à la fermeture
de l'événement et n'écrit rien.
- La fiche d'une personne porte une rubrique « Rencontres » : les personnes
qu'elle a rencontrées, le nombre de fois et les tours, les plus revues
d'abord. C'est le chemin vers une personne quand la matrice « qui a rencontré
qui » ne se dessine pas, au-delà de 44 participants.
- Les objectifs des tables se lisent tour par tour : les personnes jamais
servies d'abord, la soirée, puis une barre par tour, avec ses tours hors
objectif, la part que la configuration rend inévitable, et les personnes
assises sans table possible.
- Les animateurs se lisent en deux vues : les animateurs que chacun a
rencontrés, avec la borne des animateurs en pointillés ; puis la diversité que
chaque animateur voit à sa table, appartenances et étiquettes, sur la soirée,
et tour par tour dans le tableau jumeau, avec le moins varié, le plus varié et
combien sont au minimum.
- La performance du calcul montre la course de la recherche qui a produit la
proposition : ses mouvements évalués, la valeur de chaque critère à 1, 10,
100… mouvements, toujours remesurée sous les réglages courants, et le dernier
point où une valeur a changé. La durée et la cadence de la génération
s'affichent pendant la séance qui l'a produite ; rouverte plus tard, la
proposition garde sa course et dit sa durée inconnue.
- La course de chaque proposition se garde dans un fichier à côté de celui de
l'événement, `<nom>.gtt-courses.json`, écrit seulement quand les propositions
changent — génération, effacement, retour dans l'historique. Le perdre ne perd
rien de votre travail : la page dit seulement que la course n'est pas gardée.
Renommer, copier et supprimer l'événement l'emportent avec lui ; un fichier
d'événement venu d'ailleurs arrive sans course connue.
- Rien de la page de qualité ne s'imprime, et aucun indicateur ne s'exporte en
CSV : elle se lit à l'écran, où l'on choisit sa proposition.
- À graine égale, les propositions et leur classement sont les mêmes qu'avant la
page de qualité.
- Le guide d'usage montre la page de qualité, ses vues et ses cas limites,
captures à l'appui.
**Version de format des fichiers** : 2. Les étiquettes, les objectifs, leurs
règles, les cases nouvelles, le mode des objectifs et l'ordre des priorités
s'enregistrent dans des champs facultatifs, absents à leur défaut. Un fichier
de format 1, écrit par une version de développement, s'ouvre tel quel et se
convertit, son historique avec lui, au premier geste en écriture.
La course de la recherche ne va ni dans le fichier de l'événement ni dans son
historique : elle a son propre fichier, `<nom>.gtt-courses.json`.
**En remplaçant** : rien à faire, c'est la première version.

View file

@ -154,8 +154,8 @@ l'exécution elle-même —, son statut et la façon de le relever. Une valeur a
| `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,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 |
| `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é | 44 participants | mesurée | 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 | 168 px | mesurée | 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.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 80 KiB

File diff suppressed because one or more lines are too long

1230
spec.md

File diff suppressed because it is too large Load diff

View file

@ -128,6 +128,18 @@ function exigerAPriori(fonction, plafondsAPriori) {
// jamais.
const seCompare = (placement, charge) => fautes(placement).length === 0 && derive(placement, charge).length === 0;
// La mesure d'un plan indexé que porte sa ligne d'évaluation : mesurer,
// les plafonds réalisés et les trois chiffres sous le plafond a priori
// aPriori, null quand il est inconnu. ligneDe et detaillerQualite la
// prennent ici l'une et l'autre : la tuile écrit la valeur de la ligne, et
// le détail juge un plancher atteint sur la même, sans la recompter
// autrement.
function mesureDuPlan(instance, tableDe, aPriori) {
const mesures = mesurer(instance, tableDe);
const realises = plafondsRealises(instance, tableDe);
return { mesures, realises, chiffres: troisChiffres(instance, mesures, aPriori, realises) };
}
// Une ligne qui ne se mesure pas.
const nonMesuree = (id, statut, raisons) => ({
id,
@ -171,9 +183,7 @@ export function evaluer(charge, { plafondsAPriori = 'calcul' } = {}) {
const raisons = deriveSansRevalider(placement, charge);
if (raisons.length > 0) return nonMesuree(id, 'derive', raisons);
const plan = planSansRevalider(placement);
const tableDe = indexerPlan(instance, plan);
const mesures = mesurer(instance, tableDe);
const realises = plafondsRealises(instance, tableDe);
const { mesures, realises, chiffres } = mesureDuPlan(instance, indexerPlan(instance, plan), aPriori);
return {
id,
statut: 'comparable',
@ -181,7 +191,7 @@ export function evaluer(charge, { plafondsAPriori = 'calcul' } = {}) {
violations: verifierInvariants(instance, plan, { reserveAutorisee: true }).filter(({ code }) => code === 'RESERVATION'),
mesures,
plafondsRealises: realises,
troisChiffres: troisChiffres(instance, mesures, aPriori, realises),
troisChiffres: chiffres,
ecartAuPlafondAPrioriMax: plusGrand(ecartsAuPlafondAPriori(mesures, aPriori)),
minimumAtteint: minimumAtteint(mesures, aPriori),
refusDessin: verifierIndicateurs(mesures, aPriori, realises).map((violation) => ({
@ -527,6 +537,7 @@ function enIdentifiants(instance, brut, critere) {
animateurs: avecId(bornes.animateurs),
objectifs: avecId(bornes.objectifs),
soiree: bornes.soiree,
planchers: bornes.planchers,
},
profil: brut.profil.map(avecId),
synthese: brut.synthese,
@ -594,7 +605,9 @@ function placementDe(charge, id) {
* La configuration figée et l'instance sont celles de la charge, comme dans
* evaluer ; le placement se valide une fois (fautes), puis se lit sans
* seconde validation. Fautif ou en dérive, il rend ses raisons et chaque
* champ de mesure à null. Sinon : le diagnostic une fois ; les planchers
* champ de mesure à null. Sinon : le diagnostic une fois ; le plan mesuré
* comme sa ligne d'évaluation le mesure (mesureDuPlan), dont detailler lit
* les trois chiffres pour juger un plancher atteint ; les planchers
* valent sur un plan qui assoit chacun à chaque tour et ne laisse aucune
* réservation non tenue (D31) ; la borne de diversité ; detailler, en une
* lecture du plan, la matrice seulement jusqu'à SEUIL_MATRICE_PARTICIPANTS
@ -647,8 +660,7 @@ export function detaillerQualite(charge, id, options = {}) {
const aPriori = plafondsAPriori === 'calcul' ? diagnostic.plafondAPriori.parPersonne : null;
const plan = planSansRevalider(placement);
const tableDe = indexerPlan(instance, plan);
const mesures = mesurer(instance, tableDe);
const realises = plafondsRealises(instance, tableDe);
const { mesures, realises, chiffres } = mesureDuPlan(instance, tableDe, aPriori);
const nonTenues = verifierInvariants(instance, plan, { reserveAutorisee: true }).filter(({ code }) => code === 'RESERVATION');
const planchersValides = nonTenues.length === 0 && mesures.toursAssis.every((assis) => assis === instance.R);
const { phiAffilies, aMax } = borner(instance, tableDe);
@ -661,6 +673,7 @@ export function detaillerQualite(charge, id, options = {}) {
mesures,
plafondsRealises: realises,
plafondsAPriori: aPriori,
troisChiffres: chiffres,
planchersValides,
cle: cleNormalisee,
ordreMatrice,

View file

@ -987,6 +987,53 @@ describe('detaillerQualite : une seule arithmétique (§ 12.9, § 13.2, contrain
assert.ok(detail.collisions.lignes.every((ligne) => !Object.hasOwn(ligne, 'g')));
});
test('les planchers des tuiles passent tels que le moteur les rend, sur des plans écrits à la main', () => {
// La petite démonstration au conflit : l'appartenance de cinq pour
// quatre tables prouve 4 collisions cumulées, une par tour, et 1 paire
// distincte ; l'excédent, 4 sous C(5, 2), ne s'écrit pas. « (4, 1) »
// atteint les deux, « (4, 2) » les cumulées seules, « (6, 6) » aucun.
const charge = structuredClone(chargeDemo('petite-conflit'));
charge.propositions = PLANS_CONFLIT.map(({ tours }, rang) => propositionDe(charge, rang + 1, tours));
charge.prochainsIds.proposition = 5;
charge.evenement.etat = 'propose';
figer(charge);
const collisionsDe = (id) => {
const { collisionsCumulees, pairesDistinctes, excedentCollisions } = detaillerQualite(charge, id).bornes.planchers;
return [collisionsCumulees, pairesDistinctes, excedentCollisions];
};
assert.deepEqual(collisionsDe(1), [{ plancher: 4, atteint: true }, { plancher: 1, atteint: true }, null]);
assert.deepEqual(collisionsDe(2), [{ plancher: 4, atteint: true }, { plancher: 1, atteint: false }, null]);
assert.deepEqual(collisionsDe(3), [{ plancher: 4, atteint: false }, { plancher: 1, atteint: false }, null]);
});
test('l’écart d’itinéraire porte son plancher sous le plafond a priori calculé, aucun sous un plafond a priori inconnu', () => {
// Cinq personnes, une table de trois et une de deux, deux tours : le
// diagnostic prouve un écart d'itinéraire maximal de 1 au moins. Le
// plan qui fait passer quatre personnes par la table de deux l'atteint,
// celui qui y garde les deux mêmes laisse 4 et 5 deux sous leur plafond
// a priori.
const charge = chargeDeN(5);
charge.tables = [3, 2].map((sieges, rang) => ({ id: rang + 1, numero: rang + 1, sieges, forme: 'ronde', position: { x: 300 * rang, y: 0 } }));
charge.prochainsIds.table = 3;
charge.propositions = [
propositionDe(charge, 1, [
[[1, 2, 3], [4, 5]],
[[1, 4, 5], [2, 3]],
]),
propositionDe(charge, 2, [
[[1, 2, 3], [4, 5]],
[[1, 2, 3], [4, 5]],
]),
];
charge.prochainsIds.proposition = 3;
charge.evenement.etat = 'propose';
figer(charge);
assert.deepEqual(detaillerQualite(charge, 1).bornes.planchers.ecartItineraireMax, { plancher: 1, atteint: true });
assert.deepEqual(detaillerQualite(charge, 2).bornes.planchers.ecartItineraireMax, { plancher: 1, atteint: false });
assert.equal(evaluer(charge).propositions.find(({ id }) => id === 2).troisChiffres.ecartItineraireMax.tous, 2);
for (const id of [1, 2]) assert.equal(detaillerQualite(charge, id, { plafondsAPriori: null }).bornes.planchers.ecartItineraireMax, null);
});
test("chaque personne en entier — appartenance ou null, statut ancré, partiel ou mobile — et le numéro de chaque table, distinct de son identifiant", () => {
const charge = structuredClone(chargeDemo('petite-conflit'));
const [premiere, seconde] = [...charge.tables].sort((a, b) => a.id - b.id);
@ -1101,6 +1148,9 @@ describe('detaillerQualite : la garde à quatre paramètres (§ 12.3, D31, D42)'
assert.equal(detail.statut, 'comparable', `${id}`);
assert.equal(detail.collisions.planchersValides, false, `${id}`);
assert.ok(detail.collisions.lignes.every(({ prouve, reste }) => prouve === null && reste === null), `${id}`);
// Aucune tuile ne porte de plancher, pas même des 1000 relevés.
const planchers = Object.entries(detail.bornes.planchers).filter(([, plancher]) => plancher !== null);
assert.deepEqual(planchers, [], `${id}`);
const codes = detail.refusDessin.map(({ code }) => code);
assert.equal(codes.includes('RESTE_NEGATIF'), false, `${id}`);
assert.equal(codes.includes('RESTE_OBJECTIFS_NEGATIF'), false, `${id}`);

View file

@ -293,7 +293,7 @@ export const CONSTANTES = Object.freeze(
},
{
nom: 'SEUIL_MATRICE_PARTICIPANTS',
valeur: 40,
valeur: 44,
unite: 'participants',
mesure:
"nombre de participants au-delà duquel une case de la matrice « qui a rencontré qui » tombe sous la " +
@ -302,9 +302,11 @@ export const CONSTANTES = Object.freeze(
'au-delà, la matrice coûte et ne se lit plus : la vue renvoie à la fiche de chaque personne, qui porte ' +
'ses rencontres (§ 12.5). La page éprouve en plus, par la géométrie, que les cases tiennent 24 px ' +
'dans la largeur réelle de la carte',
configuration: 'banc de mesure, cadre de 1 366 × 700 px, facteur 1, vue des paires de la page de qualité',
configuration:
'banc de mesure, 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, vue des paires de la page de qualité',
proprietaire: 'banc',
statut: 'defautDeTravail',
statut: 'mesuree',
protocole:
'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 ; ' +
@ -312,15 +314,17 @@ export const CONSTANTES = Object.freeze(
},
{
nom: 'LARGEUR_TRONCATURE_NOM_PX',
valeur: 180,
valeur: 168,
unite: 'px',
mesure: "largeur à laquelle un nom se tronque dans une étiquette de graphique",
raison:
"un nom plus long déborderait sur le tracé ou sur l'étiquette voisine ; le nom entier reste dans " +
"l'infobulle et dans le jumeau (§ 12.6)",
configuration: "banc de mesure, cadre de 1 366 × 700 px, facteur 1, police Noto Sans, corps des étiquettes de graphique",
configuration:
'banc de mesure, 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, corps des étiquettes de graphique',
proprietaire: 'banc',
statut: 'defautDeTravail',
statut: 'mesuree',
protocole:
`banc de mesure, police Noto Sans : la largeur rendue d'un nom de ${LONGUEUR_NOM_REFERENCE} caractères, ` +
"chacun le glyphe le plus large de l'alphabet visé, au corps des étiquettes de graphique, dans la marge " +

View file

@ -7,10 +7,11 @@
// l'accord du registre avec le relevé committé du banc : le banc écrit un
// relevé, jamais le registre, et une constante du banc déclarée mesurée
// porte la valeur que le relevé propose, sur la configuration qu'il décrit.
// Deux valeurs du relevé sont adoptées — le seuil de sièges lisibles et la
// tolérance de la chaîne témoin — ; le ralentissement à pleine charge garde
// son défaut de travail : la machine de référence est le poste de
// développement, ρ à son défaut (Q13 b).
// Quatre valeurs du relevé sont adoptées — le seuil de sièges lisibles, la
// tolérance de la chaîne témoin, et les deux seuils de la page de qualité,
// celui de la matrice et la largeur de troncature des noms — ; le
// ralentissement à pleine charge garde son défaut de travail : la machine de
// référence est le poste de développement, ρ à son défaut (Q13 b).
import assert from 'node:assert/strict';
import { describe, test } from '../../test/lanceur.js';
import { accordRegistre } from '../../scripts/banc/releve.js';
@ -38,12 +39,12 @@ const CONTRAT = [
['HAUTEUR_BANDE_IMPRESSION', null, 'mm', 'execution', 'mesuree'],
['TOLERANCE_CHAINE_TEMOIN', 0.011, 'rapport', 'banc', 'mesuree'],
['RALENTISSEMENT_PLEINE_CHARGE', 2, '×', 'banc', 'defautDeTravail'],
['SEUIL_MATRICE_PARTICIPANTS', 40, 'participants', 'banc', 'defautDeTravail'],
['LARGEUR_TRONCATURE_NOM_PX', 180, 'px', 'banc', 'defautDeTravail'],
['SEUIL_MATRICE_PARTICIPANTS', 44, 'participants', 'banc', 'mesuree'],
['LARGEUR_TRONCATURE_NOM_PX', 168, 'px', 'banc', 'mesuree'],
];
// Les constantes du banc dont le registre a adopté la valeur relevée.
const ADOPTEES = ['SEUIL_SIEGES_LISIBLES', 'TOLERANCE_CHAINE_TEMOIN'];
const ADOPTEES = ['SEUIL_SIEGES_LISIBLES', 'TOLERANCE_CHAINE_TEMOIN', 'SEUIL_MATRICE_PARTICIPANTS', 'LARGEUR_TRONCATURE_NOM_PX'];
// Une copie du registre, entrée par entrée, que l'épreuve peut altérer.
const copie = () => CONSTANTES.map((entree) => ({ ...entree }));
@ -76,7 +77,7 @@ describe('constantes : le registre (§ 14.1, § 19.10)', () => {
assert.match(entree('TOLERANCE_CHAINE_TEMOIN').protocole, /repli/);
});
test('les deux seuils de la page de qualité attendent le banc, protocole écrit (B29) ; la troncature se cale sur une longueur de nom écrite ici', () => {
test('les deux seuils de la page de qualité sont adoptés du banc, protocole écrit (B29) ; la troncature se cale sur une longueur de nom écrite ici', () => {
const entree = (nom) => CONSTANTES.find((c) => c.nom === nom);
const matrice = entree('SEUIL_MATRICE_PARTICIPANTS');
assert.match(matrice.mesure, /24 px/);
@ -88,11 +89,13 @@ describe('constantes : le registre (§ 14.1, § 19.10)', () => {
assert.match(troncature.protocole, /glyphe le plus large/);
// Jamais les noms d'une démonstration, que sa régénération change.
assert.match(troncature.protocole, /jamais un nom de démonstration/);
// Le banc les relève ; le registre garde leur défaut de travail jusqu'à
// l'adoption à la main d'une valeur relevée.
// Le banc les relève ; le registre les déclare mesurés, sur la
// configuration du relevé non ralenti, ce que l'accord avec le relevé
// committé éprouve à son tour.
for (const seuil of [matrice, troncature]) {
assert.equal(seuil.proprietaire, 'banc', seuil.nom);
assert.equal(seuil.statut, 'defautDeTravail', seuil.nom);
assert.equal(seuil.statut, 'mesuree', seuil.nom);
assert.match(seuil.configuration, /processeur non ralenti, police Noto Sans/, seuil.nom);
}
});

View file

@ -26,8 +26,11 @@ export const TABLE = new Map([
// --- La source et les cas sans source mesurable (§ 12, B9, § 12.6) ---------
// La ligne de source nomme le placement mesuré, après cet intitulé, par le
// libellé que la barre de vue du plan lui donne (plan.source.*) ; une
// proposition fautive, que le plan ne montre jamais, a le sien.
// proposition fautive, que le plan ne montre jamais, a le sien. Sans
// placement à mesurer, elle n'en nomme aucun : les réservations que le
// plan montre alors ne se mesurent pas.
['qualite.source.intitule', 'Placement mesuré :'],
['qualite.source.aucun', 'aucun, le plan ne montre que les réservations'],
['qualite.source.propositionFautive', ({ proposition }) => `Proposition n° ${proposition}, fautive : le plan ne la montre pas`],
['qualite.source.montrerPremiere', ({ proposition }) => `Montrer la proposition n° ${proposition}`],
[
@ -101,6 +104,7 @@ export const TABLE = new Map([
['qualite.tuile.borneDiversite', ({ borne }) => `borne de diversité ${borne}`],
['qualite.tuile.borneAnimateurs', ({ borne }) => `borne des animateurs ${borne}`],
['qualite.tuile.plancher', ({ plancher }) => `plancher ${plancher}`],
['qualite.tuile.plancherAtteint', ({ plancher }) => `plancher ${plancher} atteint : impossible de faire moins`],
['qualite.tuile.toursPossibles', ({ possibles }) => compte(possibles, 'tour possible', 'tours possibles')],
['qualite.tuile.imposees', ({ imposees }) => `${compte(imposees, 'imposée', 'imposées')}, à part`],
['qualite.tuile.imposes', ({ imposes }) => `${compte(imposes, 'imposé', 'imposés')}, à part`],

View file

@ -3321,7 +3321,9 @@ describe('la page de qualité (§ 12, § 19.3, B27)', { timeout: DUREE }, () =>
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();
// La ligne de source ne nomme aucun placement mesuré : les réservations
// que le plan montre ne se mesurent pas.
expect(texteDe(dans('[data-section="qualite"] [data-source-qualite]'))).toBe(plat(`${t('qualite.source.intitule')} ${t('qualite.source.aucun')}`));
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"]'],
@ -3625,7 +3627,26 @@ describe('la page de qualité (§ 12, § 19.3, B27)', { timeout: DUREE }, () =>
await uneEntree(r, 'débloquer');
await passerEnEcriture();
await generer();
// Le retenu que les familles laissent et les propositions d'avant elles
// ne décrivent plus la liste : leur annonce couvrirait l'image. La
// première du classement est retenue — la fenêtre confirmée quand le
// retenu qu'elle remplace est ajusté —, puis l'effacement garde d'elle
// seule.
await ouvrirSection('propositions');
const classee = await attendre('le tableau classé', () => dans('[data-section="propositions"] [data-classement] tr[data-proposition]'));
const retenue = Number(classee.dataset.proposition);
let entree = revision();
await cliquer(`[data-section="propositions"] tr[data-proposition="${retenue}"] [data-commande="retenir"]`);
await attendre('le retenu, ou sa fenêtre', () => revision() > entree || dans('dialog[open]'));
if (revision() === entree) await appuyer(dans('dialog[open] [data-commande="confirmer"]'), 'confirmer retenir');
await uneEntree(entree, 'retenir');
entree = revision();
await cliquer('[data-section="propositions"] [data-commande="effacer"]');
await uneEntree(entree, 'effacer les propositions');
expect(charge().propositions.map((proposition) => proposition.id)).toEqual([retenue]);
expect(charge().retenu.proposition).toBe(retenue);
const id = await qualiteDeLaPremiere();
expect(id).toBe(retenue);
const vue = vueQualite('objectifs');
expect(vue.querySelector('[data-jamais-servies]')).not.toBeNull();
const soiree = vue.querySelector('[data-soiree-objectifs]');
@ -3661,6 +3682,9 @@ describe('la page de qualité (§ 12, § 19.3, B27)', { timeout: DUREE }, () =>
const objectifs = vueQualite('objectifs');
await cadrerDansLaSurface([objectifs.querySelector('[data-jamais-servies]'), objectifs.querySelector('ol.tours')]);
// L'image montre les objectifs sans annonce : aucun placement de la
// charge ne contredit la liste.
expect(tous('[data-avertissement]').map((annonce) => annonce.dataset.avertissement)).toEqual([]);
await photographier(m, '15a-qualite-objectifs', {
condition: ['[data-vue-qualite="objectifs"] [data-soiree-objectifs]', '[data-vue-qualite="objectifs"] ol.tours'],
graine: GRAINE,

View file

@ -222,6 +222,22 @@ describe('l’en-tête chiffré (§ 12.2, B11, D48)', () => {
expect(copie.textContent).not.toContain('%');
});
test('rouge : un plancher prouvé s’écrit dans le texte de sa tuile, sous son chiffre, rendu et lu par l’assistance', async () => {
// Cinq de la Coopérative Aubier pour deux tables de trois : au moins
// quatre collisions par tour, douze sur trois tours, que tout plan
// atteint.
const charge = chargeSix();
for (const personne of charge.participants) personne.appartenance = personne.id <= 5 ? 'Coopérative Aubier' : null;
const cible = await monter(VueEntete, vueDe(generee(charge)));
const tuile = tuileDe(cible, 'collisionsCumulees');
const ligne = t('qualite.tuile.plancherAtteint', { plancher: 12 });
const porteuse = [...tuile.querySelectorAll('.borne')].find((element) => texteDe(element) === ligne);
expect(porteuse, texteDe(tuile)).toBeDefined();
expect(porteuse.compareDocumentPosition(tuile.querySelector('.valeur')) & Node.DOCUMENT_POSITION_PRECEDING).toBeTruthy();
expect(porteuse.closest('[aria-hidden="true"], [hidden]')).toBeNull();
expect(porteuse.checkVisibility({ visibilityProperty: true })).toBe(true);
});
test('le libellé d’une tuile s’explique au focus du clavier : l’infobulle porte l’explication de la colonne', async () => {
const cible = await monter(VueEntete, vueDe(generee(chargeSix())));
const terme = tuileDe(cible, 'manqueMax').querySelector('[data-terme]');

View file

@ -313,7 +313,7 @@ describe('les quatre cas sans source mesurable (§ 12, § 12.6, B9)', () => {
expect(texteDe(page(m).querySelector('[data-phrase-qualite="aucunPlacement"]'))).toBe(t('qualite.phrase.aucunPlacement'));
expect(texteDe(page(m))).toContain(t('coquille.section.diagnostic'));
expect(page(m).querySelector('[data-enonce]')).not.toBeNull();
expect(ligneDeSource(m)).toBe(attendueDeSource('plan.source.reservations'));
expect(ligneDeSource(m)).toBe(attendueDeSource('qualite.source.aucun'));
expect(vuesRendues(m)).toEqual([]);
expect(page(m).querySelector('svg, nav')).toBeNull();
});
@ -404,7 +404,7 @@ describe('une source qui disparaît sous la page ouverte (§ 12, B9)', () => {
await m.seance.executer('enregistrerGeneration', { propositions, configuration, produitVersion: V1 });
await attendre(() => expect(phrases(m)).toEqual(['aucuneMontree']));
expect(charge(m).propositions.some((proposition) => proposition.id === id)).toBe(false);
expect(ligneDeSource(m)).toBe(attendueDeSource('plan.source.reservations'));
expect(ligneDeSource(m)).toBe(attendueDeSource('qualite.source.aucun'));
expect(vuesRendues(m)).toEqual([]);
const revenir = commande(page(m), 'revenir');
expect(texteDe(revenir)).toBe(t('plan.source.revenirReservations'));

View file

@ -14,9 +14,11 @@
// Une tuile porte son libellé, sa valeur écrite comme au tableau, « ne
// classe pas » quand le tableau ne porte pas sa colonne, son pourcentage
// et le compte du pire (Pourcentage) sur trois tuiles seulement, puis les
// lignes de sa borne, chacune d'une personne nommée. Le libellé est un
// terme : le survol et le focus du clavier ouvrent l'infobulle de son
// explication, celle de la colonne au tableau (Infobulle, primitive).
// lignes de sa borne, celles d'une personne qu'elle nomme, et en dernier,
// quand le détail en porte un, son plancher, qui ne nomme personne : il
// borne la mesure de la tuile. Le libellé est un terme : le survol et le
// focus du clavier ouvrent l'infobulle de son explication, celle de la
// colonne au tableau (Infobulle, primitive).
//
// Désignations : data-vue-qualite sur le <div> qui enveloppe la carte,
// que la carte ne transmet pas ; data-tuile sur chaque tuile, par le nom

View file

@ -17,10 +17,13 @@
//
// Les bornes des tuiles viennent du détail du placement (detail.bornes),
// chacune celle d'une personne nommée, que la tuile nomme : rien ne s'y
// recompte, rien ne s'y moyenne. Les tours hors objectif portent le
// plancher de la soirée (detail.objectifs) quand le diagnostic en écrit
// un — non nul, sous des planchers valides — ; les rencontres répétées et
// les retours choisis énoncent à part les imposés, lus dans la ligne
// recompte, rien ne s'y moyenne. Le plancher, lui, ne nomme personne : il
// borne la mesure de la tuile. Chaque tuile dont le détail porte le plancher
// (detail.bornes.planchers, là où le diagnostic du § 5.6 l'écrit) l'écrit
// en dernière ligne, sous son chiffre, et dit « atteint : impossible de
// faire moins » quand le détail le dit atteint ; sans plancher, aucune
// ligne, jamais un 0. Les rencontres répétées
// et les retours choisis énoncent à part les imposés, lus dans la ligne
// d'évaluation et dans la table. Trois tuiles portent un pourcentage, un
// entier que le détail porte déjà, suivi du compte du pire : le minimum
// brut (la personne au rang 1 du profil), les tours hors objectif (la
@ -42,7 +45,8 @@ const EN_TETES = new Map(COLONNES.map(({ nom, cle }) => [nom, cle]));
* @property {string} texte la valeur, écrite comme au tableau
* @property {boolean} classe vrai quand le tableau porte la colonne
* @property {string|null} neClassePas la mention, null quand elle classe
* @property {string[]} bornes les lignes de la borne, traduites
* @property {string[]} bornes les lignes de la borne, traduites, la
* ligne du plancher en dernier quand il est prouvé
* @property {{texte: string|null, compte: string}|null} pourcentage
* le pourcentage écrit, null quand son dénominateur est nul, et
* le compte du pire ; null hors des trois tuiles
@ -85,9 +89,7 @@ function bornesDe(nom, { detail, ligne, table, t, nommer, lire }) {
case 'toursHorsObjectif': {
const { soiree } = bornes;
if (soiree === null) return rendu([]);
const plancher = detail.objectifs?.soiree.plancher ?? null;
const ecrit = detail.collisions.planchersValides && plancher !== null && plancher > 0;
return rendu(ecrit ? [t('qualite.tuile.plancher', { plancher })] : [], {
return rendu([], {
texte: soiree.pourcent === null ? null : t('qualite.pourcentage.servis', { pourcent: soiree.pourcent }),
compte: t('qualite.tuile.pireJamaisServies', { personnes: soiree.jamaisServies }),
});
@ -114,6 +116,24 @@ function bornesDe(nom, { detail, ligne, table, t, nommer, lire }) {
}
}
// La ligne du plancher de la tuile nom, lue dans le détail : {plancher,
// atteint} ou null, que le moteur ne rend que prouvé et non nul. Une liste
// d'une ligne, ou vide.
function plancherDe(nom, { detail, t }) {
const { planchers } = detail.bornes;
const lu = Object.hasOwn(planchers, nom) ? planchers[nom] : null;
if (lu === null) return [];
const { plancher, atteint } = lu;
return [t(atteint ? 'qualite.tuile.plancherAtteint' : 'qualite.tuile.plancher', { plancher })];
}
// Les lignes de la borne d'une tuile, son plancher en dernier, et son
// pourcentage.
function bornesEtPlancher(nom, contexte) {
const { bornes, pourcentage } = bornesDe(nom, contexte);
return { bornes: [...bornes, ...plancherDe(nom, contexte)], pourcentage };
}
/**
* L'en-tête chiffré d'une source mesurée, sous le contrat de propriétés des
* vues : null sans détail comparable, sans ligne ou sans table.
@ -156,7 +176,7 @@ export function enteteDe({ detail, ligne, table }, { t, format }) {
texte: texteCellule(cellule, { t, format }),
classe,
neClassePas: classe ? null : t('qualite.tuile.neClassePas'),
...bornesDe(nom, contexte),
...bornesEtPlancher(nom, contexte),
});
}
if (deGroupe.length > 0) groupes.push({ cle, libelle: t(cle), tuiles: deGroupe });

View file

@ -6,8 +6,9 @@
// propositions, dans son ordre et sous ses groupes, la cellule même du
// tableau quand la colonne y est ; une tuile sans colonne lit la même
// mesure et dit qu'elle ne classe pas ; un critère sans objet n'a pas de
// tuile ; chaque borne est celle d'une personne nommée, lue dans le détail ;
// trois tuiles seulement portent un pourcentage, suivi du compte du pire.
// tuile ; chaque borne est celle d'une personne nommée, lue dans le détail,
// et le plancher, là où le diagnostic l'écrit, ne nomme personne ; trois
// tuiles seulement portent un pourcentage, suivi du compte du pire.
// Les charges s'écrivent ici ou partent des démonstrations livrées ; leurs
// propositions s'enregistrent par la commande. Les noms d'épreuve sont
// inventés.
@ -88,6 +89,21 @@ const tuile = (entete, nom) => {
const nomDe = (detail, id) => detail.personnes.find((personne) => personne.id === id).nom;
const valeur = (pourcent) => t('qualite.pourcentage.valeur', { pourcent });
// Les tuiles qui portent un plancher quand le diagnostic en prouve un.
const TUILES_A_PLANCHER = [
'collisionsCumulees',
'pairesDistinctes',
'excedentCollisions',
'ecartItineraireMax',
'jamaisServies',
'horsObjectifMax',
'toursHorsObjectif',
];
// La ligne qu'une tuile écrit pour son plancher, et les lignes de plancher
// parmi les bornes d'une tuile.
const ligneDuPlancher = ({ plancher, atteint }) => t(atteint ? 'qualite.tuile.plancherAtteint' : 'qualite.tuile.plancher', { plancher });
const lignesDePlancher = (bornes) => bornes.filter((ligne) => ligne.startsWith('plancher'));
// Les tuiles des objectifs et des animateurs.
const SIXIEME = ['jamaisServies', 'horsObjectifMax', 'toursHorsObjectif', 'toursSelonObjectif', 'animateursRencontres', 'animateursNonRencontres'];
@ -264,23 +280,80 @@ describe('enteteDe : les bornes, chacune d’une personne nommée (§ 12.2, B11,
});
});
test('les tours hors objectif : le plancher de la soirée quand le diagnostic en écrit un, sous des planchers valides ; aucun sinon', () => {
test('rouge : chaque tuile au plancher prouvé l’écrit sous son chiffre, « atteint » quand il l’est ; aucune autre', () => {
const vue = vueDe(generee(chargeSix({ objectifs: true })));
assert.deepEqual(Object.keys(vue.detail.bornes.planchers).sort(), [...TUILES_A_PLANCHER].sort());
for (const nom of TUILES_A_PLANCHER) {
for (const atteint of [false, true]) {
const detail = structuredClone(vue.detail);
detail.bornes.planchers = Object.fromEntries(TUILES_A_PLANCHER.map((autre) => [autre, null]));
detail.bornes.planchers[nom] = { plancher: 3, atteint };
const entete = enteteDe({ ...vue, detail }, outils);
const attendue = t(atteint ? 'qualite.tuile.plancherAtteint' : 'qualite.tuile.plancher', { plancher: 3 });
assert.equal(tuile(entete, nom).bornes.at(-1), attendue, nom);
assert.deepEqual(lignesDePlancher(tuile(entete, nom).bornes), [attendue], nom);
for (const autre of tuiles(entete)) if (autre.nom !== nom) assert.deepEqual(lignesDePlancher(autre.bornes), [], `${nom} : ${autre.nom}`);
}
}
// Sans plancher prouvé, aucune ligne, jamais « plancher 0 ».
const detail = structuredClone(vue.detail);
detail.bornes.planchers = Object.fromEntries(TUILES_A_PLANCHER.map((nom) => [nom, null]));
for (const autre of tuiles(enteteDe({ ...vue, detail }, outils))) assert.deepEqual(lignesDePlancher(autre.bornes), [], autre.nom);
});
test('rouge : cinq membres d’une appartenance pour deux tables de trois : 12 collisions cumulées au moins, atteintes ; chaque plancher du détail écrit', () => {
const charge = chargeSix();
for (const personne of charge.participants) personne.appartenance = personne.id <= 5 ? 'Coopérative Aubier' : null;
const vue = vueDe(generee(charge));
assert.equal(vue.ligne.mesures.collisionsCumulees, 12);
assert.deepEqual(vue.detail.bornes.planchers.collisionsCumulees, { plancher: 12, atteint: true });
const entete = enteteDe(vue, outils);
assert.deepEqual(tuile(entete, 'collisionsCumulees').bornes, [t('qualite.tuile.plancherAtteint', { plancher: 12 })]);
for (const nom of TUILES_A_PLANCHER) {
const plancher = vue.detail.bornes.planchers[nom];
const ecrites = tuiles(entete).some((candidate) => candidate.nom === nom) ? lignesDePlancher(tuile(entete, nom).bornes) : [];
assert.deepEqual(ecrites, plancher === null ? [] : [ligneDuPlancher(plancher)], nom);
}
});
test('les tours hors objectif : le plancher de la soirée, sous son pourcentage et le compte du pire', () => {
// Quatre personnes attendues à une table de trois places, deux autres
// réservées à l'autre table : au moins trois tours hors objectif.
const vue = vueDe(generee(chargeSix({ objectifs: true, finance: 4, reservees: true })));
const { soiree } = vue.detail.objectifs;
assert.equal(vue.detail.collisions.planchersValides, true);
assert.ok(soiree.plancher > 0, `plancher ${soiree.plancher}`);
const ligneDuPlancher = t('qualite.tuile.plancher', { plancher: soiree.plancher });
assert.ok(tuile(enteteDe(vue, outils), 'toursHorsObjectif').bornes.includes(ligneDuPlancher));
// Hors du domaine des planchers, le même plancher ne s'écrit pas.
const detail = structuredClone(vue.detail);
detail.collisions.planchersValides = false;
assert.ok(!tuile(enteteDe({ ...vue, detail }, outils), 'toursHorsObjectif').bornes.some((ligne) => ligne.startsWith('plancher')));
// Un plancher nul : le diagnostic se tait, aucune ligne.
detail.collisions.planchersValides = true;
detail.objectifs.soiree = { ...detail.objectifs.soiree, plancher: 0 };
assert.ok(!tuile(enteteDe({ ...vue, detail }, outils), 'toursHorsObjectif').bornes.some((ligne) => ligne.startsWith('plancher')));
const plancher = vue.detail.bornes.planchers.toursHorsObjectif;
assert.ok(plancher !== null && plancher.plancher === vue.detail.objectifs.soiree.plancher, JSON.stringify(plancher));
const horsObjectif = tuile(enteteDe(vue, outils), 'toursHorsObjectif');
assert.notEqual(horsObjectif.pourcentage, null);
assert.deepEqual(horsObjectif.bornes, [ligneDuPlancher(plancher)]);
});
test('rouge : les deux lignes du plancher, mot pour mot, ne disent rien que de la mesure de leur tuile', () => {
assert.equal(t('qualite.tuile.plancher', { plancher: 4 }), 'plancher 4');
assert.equal(t('qualite.tuile.plancherAtteint', { plancher: 4 }), 'plancher 4 atteint : impossible de faire moins');
});
test('rouge : une tuile des objectifs qui ne classe pas n’écrit aucun plancher, même prouvé, comme le diagnostic (§ 5.6)', () => {
// Quatre personnes attendues à une table de trois places : 3 tours hors
// objectif au moins, et 1 au moins pour une personne.
const sous = (reglages) => {
const vue = vueDe(generee(chargeSix({ objectifs: true, finance: 4, reservees: true, reglages })));
return { vue, entete: enteteDe(vue, outils) };
};
const places = sous({});
assert.equal(tuile(places.entete, 'horsObjectifMax').classe, false);
assert.deepEqual(lignesDePlancher(tuile(places.entete, 'horsObjectifMax').bornes), []);
assert.equal(lignesDePlancher(tuile(places.entete, 'toursHorsObjectif').bornes).length, 1);
const minimum = sous({ modeObjectifs: 'minimum' });
const auPlus = minimum.vue.detail.bornes.planchers.horsObjectifMax;
assert.equal(auPlus.plancher, 1);
assert.equal(tuile(minimum.entete, 'horsObjectifMax').classe, true);
assert.deepEqual(lignesDePlancher(tuile(minimum.entete, 'horsObjectifMax').bornes), [ligneDuPlancher(auPlus)]);
// Le critère décoché ne compare rien : ses tuiles restent, sans plancher.
const decoche = sous({ respecterObjectifs: false });
for (const nom of ['jamaisServies', 'horsObjectifMax', 'toursHorsObjectif']) {
assert.equal(tuile(decoche.entete, nom).classe, false, nom);
assert.deepEqual(lignesDePlancher(tuile(decoche.entete, nom).bornes), [], nom);
}
});
test('les rencontres répétées et les retours : les choisis en valeur, les imposés énoncés à part', () => {

View file

@ -619,21 +619,42 @@ export function dispositionBarres({ largeur, lignes, segments = null, colonnes =
? 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. 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;
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] };
});
// segment en contour garde au moins LARGEUR_MIN_CONTOUR_PX, et la
// barre finit au plus loin à sa limite : la fin de sa valeur, ou, pour
// une ligne plus étroite que le contour minimal de son premier segment à
// sa valeur, son premier segment plus ce minimum. Ce que le contour
// gagne au-delà de sa fin se prend sur les segments qui le suivent, dans
// l'ordre, chacun jusqu'à zéro ; ce qui dépasse encore la limite, sur
// ceux qui le précèdent, du plus proche au premier, chacun jusqu'à zéro
// — un segment ramené à zéro cède aussi l'écart qui le suivait —, et le
// contour recule d'autant. Seul le prouvé se trace en contour, et deux
// segments n'ont jamais le même genre : ce qui précède le contour est en
// aplat. Juxtaposé, chaque segment est seul sur son axe, et sa limite est
// la sienne.
const visibles = traces.filter((s) => l.valeurs[s.colonne] > 0);
const limiteDe = (premier, dernier) => Math.max(dernier.x + dernier.largeur, premier.x + LARGEUR_MIN_CONTOUR_PX);
const limiteEmpilee = visibles.length === 0 ? 0 : limiteDe(visibles[0], visibles.at(-1));
let finPrecedente = null;
const tracees = visibles.map((s) => {
const ecart = !juxtaposees && s.colonne > 0 && l.valeurs[s.colonne - 1] > 0 ? ECART_SEGMENTS_PX : 0;
const limite = juxtaposees ? limiteDe(s, s) : limiteEmpilee;
const depart = juxtaposees || finPrecedente === null ? s.x : Math.max(s.x, finPrecedente);
const x = Math.min(limite, depart + ecart);
const propre = Math.max(0, s.x + s.largeur - x);
const largeurTracee = REMPLISSAGES[s.genre].contour ? Math.max(propre, LARGEUR_MIN_CONTOUR_PX) : propre;
finPrecedente = x + largeurTracee;
return { ...s, x, largeur: largeurTracee, y, hauteur: hauteurLigne, ...REMPLISSAGES[s.genre] };
});
let depasse = juxtaposees || finPrecedente === null ? 0 : finPrecedente - limiteEmpilee;
for (let i = tracees.length - 2; depasse > 0 && i >= 0; i -= 1) {
const s = tracees[i];
const ecartSuivant = tracees[i + 1].x - (s.x + s.largeur);
const pris = Math.min(depasse, s.largeur);
s.largeur -= pris;
const recul = pris + (s.largeur === 0 ? Math.min(ecartSuivant, depasse - pris) : 0);
for (let j = i + 1; j < tracees.length; j += 1) tracees[j].x -= recul;
depasse -= recul;
}
const intitule = intitules[indice];
const centre = y + hauteurLigne / 2;
const pastille =

View file

@ -365,6 +365,105 @@ describe('pointage : les barres (§ 12.4)', () => {
assert.equal(large.largeur, ordinaire.ex.versPx(1) - ordinaire.ex.versPx(0), 'un prouvé assez large garde sa largeur');
});
test('rouge : une barre ne finit jamais au-delà de sa valeur ; seule une ligne plus étroite que le contour minimal s’allonge, jusqu’à lui', () => {
// Une première ligne de 600 fixe l'axe : une unité y fait moins de
// 2 px, si bien qu'un prouvé de 1 et un reste de 2 tiennent dans moins
// que LARGEUR_MIN_CONTOUR_PX.
const axe = { id: 'a', nom: 'Coopérative Aubier', appartenance: 1, valeurs: [0, 600] };
const unite = (() => {
const d = disposer({ lignes: [axe] });
return d.ex.versPx(1) - d.ex.versPx(0);
})();
assert.ok(3 * unite < LARGEUR_MIN_CONTOUR_PX, `une unité : ${unite} px`);
// Le plus petit reste qui fait la ligne au moins aussi large que le
// contour minimal, sans lui laisser l'écart d'un second segment.
const reste = Math.ceil(LARGEUR_MIN_CONTOUR_PX / unite) - 1;
assert.ok((1 + reste) * unite < LARGEUR_MIN_CONTOUR_PX + ECART_SEGMENTS_PX, `un plus ${reste} : ${(1 + reste) * unite} px`);
const d = disposer({
lignes: [
axe,
{ id: 'b', nom: 'Atelier Ressac', appartenance: 2, valeurs: [1, 2] },
{ id: 'c', nom: 'Iris Ombrelle', appartenance: null, valeurs: [1, reste] },
],
});
const zero = d.ex.versPx(0);
const finDe = (segment) => segment.x + segment.largeur;
// 1 et 2, plus étroite que le contour minimal : la barre s'allonge
// jusqu'à lui, et pas d'un pixel de plus.
const etroite = d.lignes[1];
assert.equal(etroite.segments[0].largeur, LARGEUR_MIN_CONTOUR_PX);
assert.ok(Math.abs(etroite.fin - (zero + LARGEUR_MIN_CONTOUR_PX)) < 1e-9, `fin ${etroite.fin - zero} px`);
assert.ok(etroite.segments.every((s) => finDe(s) <= etroite.fin + 1e-9 && s.largeur >= 0));
// 1 et le reste, au moins aussi large : le prouvé garde son contour, le
// reste cède ce qu'il faut, jusqu'à zéro, et la barre finit à sa valeur.
const juste = d.lignes[2];
const valeur = d.ex.versPx(1 + reste);
assert.equal(juste.segments[0].largeur, LARGEUR_MIN_CONTOUR_PX);
assert.ok(Math.abs(juste.fin - valeur) < 1e-9, `fin ${juste.fin - zero} px, valeur ${valeur - zero} px`);
for (const s of juste.segments) assert.ok(finDe(s) <= valeur + 1e-9 && s.x <= valeur + 1e-9 && s.largeur >= 0, `${s.genre} : ${s.x - zero} + ${s.largeur}`);
// La hauteur réservée reste celle du dessin.
assert.equal(d.hauteurTotale, hauteurBarres({ n: 3, juxtaposees: false, hauteurTexte: HAUTEUR_TEXTE }));
// Un prouvé en dernier n'a aucun segment après lui où prendre : il ne
// passe pas la valeur de sa ligne, plus large que le contour minimal.
const inverse = disposer({
segments: [...segments].reverse(),
lignes: [
{ ...axe, valeurs: [600, 0] },
{ id: 'd', nom: 'Théo Pervenche', appartenance: null, valeurs: [reste + 1, 1] },
],
});
const derniere = inverse.lignes[1];
const fin = inverse.ex.versPx(reste + 2);
assert.equal(derniere.segments.at(-1).genre, 'prouve');
assert.ok(Math.abs(derniere.fin - fin) < 1e-9, `fin ${derniere.fin - inverse.ex.versPx(0)} px, valeur ${fin - inverse.ex.versPx(0)} px`);
for (const s of derniere.segments) assert.ok(finDe(s) <= fin + 1e-9 && s.largeur >= 0, `${s.genre} : ${s.x} + ${s.largeur}`);
});
test('rouge : un prouvé en dernier garde aussi le contour minimal, pris sur le segment qui le précède ; la barre ne passe sa valeur que pour une ligne plus étroite que lui', () => {
const d = disposer({
segments: [...segments].reverse(),
lignes: [
{ id: 'a', nom: 'Coopérative Aubier', appartenance: 1, valeurs: [300, 0] },
{ id: 'b', nom: 'Atelier Ressac', appartenance: 2, valeurs: [9, 1] },
{ id: 'c', nom: 'Iris Ombrelle', appartenance: null, valeurs: [1, 1] },
{ id: 'd', nom: 'Théo Pervenche', appartenance: null, valeurs: [0, 1] },
],
});
const zero = d.ex.versPx(0);
const unite = d.ex.versPx(1) - zero;
assert.ok(2 * unite < LARGEUR_MIN_CONTOUR_PX && 10 * unite > LARGEUR_MIN_CONTOUR_PX + ECART_SEGMENTS_PX, `une unité : ${unite} px`);
const proche = (a, b) => Math.abs(a - b) < 1e-9;
// 9 et 1 : le prouvé prend son contour sur le reste, qui le précède ;
// la barre finit à sa valeur.
const [reste, prouve] = d.lignes[1].segments;
assert.deepEqual([reste.genre, prouve.genre], ['reste', 'prouve']);
assert.equal(prouve.largeur, LARGEUR_MIN_CONTOUR_PX);
assert.ok(proche(prouve.x + prouve.largeur, d.ex.versPx(10)), `fin ${prouve.x + prouve.largeur - zero} px`);
assert.ok(proche(d.lignes[1].fin, d.ex.versPx(10)));
assert.equal(reste.x, zero);
assert.ok(proche(reste.x + reste.largeur + ECART_SEGMENTS_PX, prouve.x), 'le reste cède, l’écart reste');
// 1 et 1, plus étroite que le contour minimal : le reste cède tout,
// l'écart avec lui, et la barre s'allonge jusqu'au contour minimal.
const [vide, seul] = d.lignes[2].segments;
assert.equal(vide.largeur, 0);
assert.equal(seul.x, zero);
assert.equal(seul.largeur, LARGEUR_MIN_CONTOUR_PX);
assert.ok(proche(d.lignes[2].fin, zero + LARGEUR_MIN_CONTOUR_PX));
// Un prouvé seul : le contour minimal, depuis zéro.
assert.deepEqual(
d.lignes[3].segments.map(({ genre, x, largeur }) => [genre, x, largeur]),
[['prouve', zero, LARGEUR_MIN_CONTOUR_PX]],
);
// Aucun segment prouvé plus étroit que le contour minimal, aucun segment
// de largeur négative ; la hauteur réservée reste celle du dessin.
const traces = d.lignes.flatMap((ligne) => ligne.segments);
assert.ok(traces.every((s) => s.largeur >= 0));
const prouves = traces.filter((s) => s.contour);
assert.equal(prouves.length, 3);
assert.ok(prouves.every((s) => s.largeur >= LARGEUR_MIN_CONTOUR_PX), prouves.map((s) => s.largeur).join(', '));
assert.equal(d.hauteurTotale, hauteurBarres({ n: 4, juxtaposees: false, hauteurTexte: HAUTEUR_TEXTE }));
});
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

@ -15,15 +15,17 @@
// La ligne de source reprend le libellé que la barre de vue du plan donne à
// ce placement (sourceMontree, plan/vue_plan.js), sa commande de retour
// comprise, configuration normalisée ou non ; une proposition fautive, que la
// barre ne nomme jamais, a le sien, qui dit que le plan ne la montre pas. La
// ligne d'évaluation est celle de la table d'évaluation de la charge
// (evaluation, propositions/modele.js), la même que le tableau des
// propositions lit : rien ne se mesure ici. Sans source mesurable, quatre cas
// se partagent la page sans se recouvrir, éprouvés dans l'ordre de
// CAS_QUALITE : la configuration qui ne se normalise pas l'emporte ; puis ni
// proposition ni retenu ; puis des propositions sans source, avec la première
// comparable du classement (premiereDuClassement) ; puis une source en dérive
// ou fautive, avec ses raisons.
// barre ne nomme jamais, a le sien, qui dit que le plan ne la montre pas ;
// sans placement à mesurer, elle n'en nomme aucun, et dit que le plan ne
// montre que les réservations, qui ne se mesurent pas. La ligne
// d'évaluation est celle de la table d'évaluation de la charge (evaluation,
// propositions/modele.js), la même que le tableau des propositions lit :
// rien ne se mesure ici. Sans source mesurable, quatre cas se partagent la
// page sans se recouvrir, éprouvés dans l'ordre de CAS_QUALITE : la
// configuration qui ne se normalise pas l'emporte ; puis ni proposition ni
// retenu ; puis des propositions sans source, avec la première comparable
// du classement (premiereDuClassement) ; puis une source en dérive ou
// fautive, avec ses raisons.
import { formeNommeeDe } from '../../application/commandes.js';
import { diagnostiquerCharge } from '../../application/diagnostic.js';
import { premiereDuClassement } from '../../application/evaluation.js';
@ -45,9 +47,10 @@ const raisonDe = (raison) => ({ cle: raison.code, details: raison, participant:
// le placement id ('retenu', un identifiant de proposition, ou null) : ceux
// que la barre de vue du plan donne à ce placement, l'état des places
// qu'elle lit étant reconstitué sans tour ; une proposition fautive a le
// sien. Fautif se lit aux fautes du placement (formeNommeeDe), comme la
// barre le lit, et non à la table d'évaluation, nulle quand la
// configuration ne se normalise pas.
// sien, et l'absence de placement (id null) aussi, la commande de retour
// restant celle de la barre. Fautif se lit aux fautes du placement
// (formeNommeeDe), comme la barre le lit, et non à la table d'évaluation,
// nulle quand la configuration ne se normalise pas.
function ligneDeSource(charge, demandee, id) {
const retenu = charge.retenu !== null;
const cible = id === 'retenu' ? { retenu: true } : { proposition: id };
@ -61,7 +64,8 @@ function ligneDeSource(charge, demandee, id) {
etat = { source: 'reservations', refus: null };
}
const montree = sourceMontree(etat, demandee, retenu);
if (id === null || id === 'retenu' || !fautif) return montree;
if (id === null) return { libelle: { cle: 'qualite.source.aucun', details: {} }, revenir: montree.revenir };
if (id === 'retenu' || !fautif) return montree;
return { libelle: { cle: 'qualite.source.propositionFautive', details: { proposition: id } }, revenir: montree.revenir };
}

View file

@ -97,12 +97,12 @@ describe('la source de la page de qualité (§ 12, B9, D25)', () => {
assert.ok(Object.isFrozen(CAS_QUALITE));
});
test('ni proposition ni retenu : aucun placement, la barre dit les réservations, rien à mesurer', () => {
test('ni proposition ni retenu : aucun placement, la barre dit les réservations, la ligne de source « aucun », rien à mesurer', () => {
const charge = chargeSix();
const source = sourceQualite(charge);
assert.deepEqual(sourceDuPlan(charge, null), { source: 'reservations', proposition: null, refus: null });
assert.equal(source.cas, 'aucunPlacement');
assert.deepEqual(source.libelle, { cle: 'plan.source.reservations', details: {} });
assert.deepEqual(source.libelle, { cle: 'qualite.source.aucun', details: {} });
assert.equal(source.revenir, null);
assert.equal(source.id, null);
assert.equal(source.ligne, null);
@ -118,7 +118,7 @@ describe('la source de la page de qualité (§ 12, B9, D25)', () => {
const source = sourceQualite(charge);
assert.equal(source.cas, 'aucuneMontree');
assert.equal(source.premiere, premiere.id);
assert.deepEqual(source.libelle, { cle: 'plan.source.reservations', details: {} });
assert.deepEqual(source.libelle, { cle: 'qualite.source.aucun', details: {} });
assert.equal(source.id, null);
assert.equal(source.ligne, null);
});
@ -182,6 +182,7 @@ describe('la source de la page de qualité (§ 12, B9, D25)', () => {
assert.deepEqual(sourceDuPlan(sansElle, disparue), { source: 'reservations', proposition: null, refus: null });
const aucune = sourceQualite(sansElle, { demandee: disparue });
assert.equal(aucune.cas, 'aucuneMontree');
assert.deepEqual(aucune.libelle, { cle: 'qualite.source.aucun', details: {} });
assert.equal(aucune.revenir, 'plan.source.revenirReservations');
// Une génération qui remplace la liste sous un retenu : le retenu revient.
const retenue = executer(generees, 'retenirProposition', { id: generees.propositions[1].id });
@ -249,6 +250,15 @@ describe('la source de la page de qualité (§ 12, B9, D25)', () => {
assert.equal(source.revenir, 'plan.source.revenirRetenu');
});
test('une configuration qui ne se normalise pas, sans proposition ni retenu : la ligne de source n’en nomme aucun', () => {
const charge = avec(chargeSix(), enConflit);
assert.equal(charge.retenu, null);
assert.equal(charge.propositions.length, 0);
const source = sourceQualite(charge);
assert.equal(source.cas, 'configuration');
assert.deepEqual(source.libelle, { cle: 'qualite.source.aucun', details: {} });
});
test('une configuration qui ne se normalise pas l’emporte : sans placement, avec des propositions, avec un retenu, le refus de la génération', () => {
const generees = generee(chargeSix());
const retenue = executer(generees, 'retenirProposition', { id: generees.propositions[0].id });

View file

@ -6,9 +6,10 @@
// 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 ; 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
// page de qualité — chaque pourcentage suivi de son compte, celui de sa
// ligne sur une ligne, 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.
//
@ -38,6 +39,7 @@ import { analyser } from '../stockage/document.js';
import { lireEtiquettes } from '../stockage/etiquettes.js';
import { lireJournal } from '../stockage/journal.js';
import { SUFFIXES } from '../stockage/noms.js';
import { pourcentage } from '../moteur/qualite.js';
import { VERSION } from '../version.genere.js';
import INDEX from '../../index.html?raw';
import {
@ -592,15 +594,62 @@ function pourcentAdmis(element) {
return texte.getAttribute('text-anchor') === 'end' && !texte.hasAttribute('data-serie') && !texte.hasAttribute('data-repere');
}
// Les entiers qu'écrit un texte, dans son ordre.
const entiersDe = (texte) => (texte.match(/\d+/g) ?? []).map(Number);
// Le compte qui suit un pourcentage est-il le sien (contrainte 44, D34) ?
// Sur une ligne — d'un jumeau ou d'une infobulle —, le compte est celui de
// la ligne : son numérateur puis son dénominateur, ses deux seuls entiers ;
// seule une ligne sans rien à compter, sa valeur « — », écrit à la place
// une phrase sans entier (« aucun affilié à ce tour »). Partout, un compte
// de deux entiers redonne, par pourcentage du moteur, la valeur écrite —
// son premier entier, ou « — » quand le dénominateur est nul : le compte
// d'une autre ligne ou d'une autre population se trahit ainsi dès que leurs
// rapports diffèrent. Le pire cas d'une population écrit d'un seul entier
// (« 3 personnes jamais servies ») ne se recoupe pas. → null, ou la faute.
function fauteDuCompte(valeur, compte) {
const texte = compte.textContent.trim();
const entiers = entiersDe(texte);
const ecrit = entiersDe(valeur.textContent)[0] ?? null;
if (entiers.length !== 2) {
if (valeur.closest('[data-jumeau], [role="tooltip"]') === null) return null;
if (ecrit === null && entiers.length === 0) return null;
return `« ${extrait(texte)} » n’est pas le compte de sa ligne, numérateur sur dénominateur`;
}
const [n, d] = entiers;
const attendu = pourcentage(n, d);
if (ecrit === attendu) return null;
return `son compte ${extrait(texte)} donne ${attendu === null ? '\u{2014}' : `${attendu}\u{202F}%`}`;
}
// Vrai quand une ligne de jumeau porte deux comptes de même numérateur, de
// dénominateurs qui donnent deux pourcentages distincts — les sièges et les
// assis d'une table incomplète : un compte permuté avec l'autre y redonne
// un pourcentage qui n'est pas le sien, et fauteDuCompte le relève.
function ligneQuiDistingue(rangee) {
const parNumerateur = new Map();
for (const compte of rangee.querySelectorAll('[data-compte-pire]')) {
const entiers = entiersDe(compte.textContent);
if (entiers.length !== 2) continue;
const [n, d] = entiers;
const autres = parNumerateur.get(n) ?? [];
if (autres.some((autre) => pourcentage(n, autre) !== pourcentage(n, d))) return true;
parNumerateur.set(n, [...autres, d]);
}
return false;
}
/**
* 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 « % ».
* le composant Pourcentage, d'un [data-compte-pire] rendu, non vide et
* qui est le sien (fauteDuCompte) ; et chaque texte rendu — nœud de texte
* ou attribut annoncé — qui porte « % » hors des places que pourcentAdmis
* admet. → { lus, dansInfobulle, dansJumeaux, lignesQuiDistinguent, fautes,
* signes: { lus, fautes } }, lus le nombre de pourcentages rendus,
* dansInfobulle et dansJumeaux ceux d'une infobulle et d'un jumeau ;
* lignesQuiDistinguent, les lignes de jumeau rendues où ligneQuiDistingue
* est vrai ; signes.lus le nombre de textes rendus qui portent « % ».
*/
export function releverPourcentages(racine) {
const fautes = [];
@ -616,7 +665,12 @@ export function releverPourcentages(racine) {
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`);
else {
const faute = fauteDuCompte(valeur, compte);
if (faute !== null) fautes.push(`${nomme} : ${faute}`);
}
}
const lignesQuiDistinguent = [...racine.querySelectorAll('[data-jumeau] tbody tr')].filter((rangee) => rendu(rangee) && ligneQuiDistingue(rangee)).length;
const signes = { lus: 0, fautes: [] };
const marcheur = document.createTreeWalker(racine, NodeFilter.SHOW_TEXT);
for (let noeud = marcheur.nextNode(); noeud !== null; noeud = marcheur.nextNode()) {
@ -635,7 +689,7 @@ export function releverPourcentages(racine) {
signes.fautes.push(`${situer(element)}[${nom}] : « ${extrait(valeur)} »`);
}
}
return { lus, dansInfobulle, dansJumeaux, fautes, signes };
return { lus, dansInfobulle, dansJumeaux, lignesQuiDistinguent, fautes, signes };
}
/** Les niveaux de l'échelle que chaque grille rend sous racine (§ 12.7,
@ -1161,6 +1215,36 @@ describe('les gardes relèvent la faute qu’un fragment porte (§ 14.2)', () =>
expect(signes.fautes.join('\n')).toContain('[aria-label] : « 9 % »');
});
test('qualité, pourcentages : le compte d’une ligne est le sien, numérateur sur dénominateur ; deux entiers rendent la valeur écrite', () => {
const paire = (valeur, compte) => `<span data-pourcentage>${valeur}</span><span data-compte-pire>${compte}</span>`;
const ligne = (valeur, compte) => `<tr><td>${paire(valeur, compte)}</td></tr>`;
const { lus, fautes } = avecFragment(
`<p>${paire('88\u{202F}%', 'minimum 24 sur 27')}</p><p>${paire('77\u{202F}% des tours possibles servis', '3 personnes jamais servies')}</p>` +
'<table data-jumeau="0"><tbody>' +
ligne('62\u{202F}%', '5 appartenances pour 8 sièges') +
ligne('80\u{202F}%', '24 servis sur 30 possibles') +
ligne('1\u{202F}%', '1 sur 250') +
ligne('\u{2014}', '0 sur 0') +
ligne('\u{2014}', 'aucun affilié à ce tour') +
ligne('12\u{202F}%', 'aucun affilié à ce tour') +
ligne('9\u{202F}%', '1 sur 14') +
ligne('\u{2014}', '2 sur 5') +
ligne('40\u{202F}%', '2 sur 0') +
'</tbody></table>' +
`<div role="tooltip">${paire('50\u{202F}%', '3 personnes jamais servies')}</div>` +
`<p>${paire('70\u{202F}%', 'minimum 24 sur 27')}</p>`,
(c) => releverPourcentages(c),
);
expect(lus).toBe(13);
expect(fautes).toHaveLength(6);
expect(fautes[0]).toContain('« aucun affilié à ce tour » n’est pas le compte de sa ligne');
expect(fautes[1]).toMatch(/« 9\s% » : son compte 1 sur 14 donne 7\u{202F}%$/u);
expect(fautes[2]).toContain('2 sur 5 donne 40\u{202F}%');
expect(fautes[3]).toContain('2 sur 0 donne \u{2014}');
expect(fautes[4]).toContain('« 3 personnes jamais servies » n’est pas le compte de sa ligne');
expect(fautes[5]).toContain('minimum 24 sur 27 donne 88\u{202F}%');
});
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>`)
@ -1570,9 +1654,12 @@ describe('la page de qualité, sur chacun de ses écrans (§ 12.7, § 14.5, D30,
}
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']) {
for (const nom of ['qualite.jumeaux', 'qualite.objectifs', 'qualite.performance', 'qualite.tables.assis']) {
if (q(nom) !== null && q(nom).pourcentages.dansJumeaux === 0) fautes.push(`${nom} : aucun pourcentage dans un jumeau`);
}
if (q('qualite.tables.assis') !== null && q('qualite.tables.assis').pourcentages.lignesQuiDistinguent === 0) {
fautes.push('qualite.tables.assis : aucune ligne du jumeau où sièges et assis donnent deux pourcentages, une permutation passerait');
}
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)) {
@ -1603,7 +1690,7 @@ describe('la page de qualité, sur chacun de ses écrans (§ 12.7, § 14.5, D30,
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', () => {
test('chaque pourcentage rendu est suivi, dans son composant, de son compte du pire — celui de sa ligne sur une ligne —, 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));

View file

@ -12,7 +12,8 @@
// Le détail ne recompte rien (§ 13.2) : les comptes par paire, par table et
// par tour sortent de parcourirTours (indicateurs.js), l'occupation de
// occupations (plafond.js), les planchers du diagnostic (diagnostic.js), les
// valeurs par personne des mesures et des plafonds que l'appelant passe.
// valeurs par personne des mesures et des plafonds que l'appelant passe,
// comme les trois chiffres de la ligne d'évaluation du plan (manque.js).
// Chaque pourcentage sort de pourcentage, la seule division d'un
// pourcentage (D52), et se rend à côté de ses deux comptes.
//
@ -216,7 +217,7 @@ function premier(N, estCandidat, avant) {
/**
* Les bornes des tuiles (§ 12.2, D29, D34, D52, B11), chacune celle d'une
* personne nommée :
* personne nommée, et les planchers, qui ne nomment personne :
* - rang1 : la personne au rang 1 du profil, ses rencontres, son plafond
* réalisé, son plafond a priori et leur pourcentage ; null sans personne ;
* - manque : la tête du profil de manque (profilManque, § 12.10.6) et son
@ -231,18 +232,27 @@ function premier(N, estCandidat, avant) {
* possibles et leur pourcentage ; null sans personne concernée ;
* - soiree : Σ S_r, Σ Π_r et leur pourcentage, lus dans les objectifs par
* tour, et les personnes jamais servies de la mesure ; null sans personne
* concernée.
* concernée ;
* - planchers : le plancher de chaque tuile qui en porte un, le même
* pour toute personne (planchersDe).
*
* @param {import('./types.js').Instance} instance N, ids, indexDe et
* groupes sont lus
* @param {import('./types.js').Instance} instance N, ids, indexDe, statut,
* groupes et etatCriteres sont lus
* @param {{mesures: import('./indicateurs.js').Mesures,
* plafondsRealises: ArrayLike<number>, aMax: ArrayLike<number>,
* profil: LigneProfil[], animateurs: Array<Object>|null,
* objectifs: Object|null}} lus
* objectifs: Object|null,
* diagnostic: import('./diagnostic.js').Diagnostic,
* planchersValides: boolean,
* troisChiffres: import('./manque.js').TroisChiffres}} lus
* profil, profilRencontres ; animateurs, profilAnimateurs ;
* objectifs, objectifsParTour
* objectifs, objectifsParTour ; diagnostic, celui de la
* configuration ; planchersValides, vrai sur un plan du domaine des
* planchers ; troisChiffres, ceux que la ligne d'évaluation du plan
* porte, lus sans être recomptés
*/
export function bornesDesTuiles(instance, { mesures, plafondsRealises, aMax, profil, animateurs, objectifs }) {
export function bornesDesTuiles(instance, lus) {
const { mesures, plafondsRealises, aMax, profil, animateurs, objectifs } = lus;
const { N, indexDe, groupes } = instance;
let rang1 = null;
let manque = null;
@ -278,9 +288,75 @@ export function bornesDesTuiles(instance, { mesures, plafondsRealises, aMax, pro
animateurs: animateurs === null ? null : { p: animateurs[0].p, borne: animateurs[0].borne },
objectifs: parObjectif,
soiree,
planchers: planchersDe(instance, lus),
};
}
// Un plancher strictement positif et la valeur mesurée qu'il borne :
// {plancher, atteint}, atteint quand la valeur l'égale ; null pour un
// plancher nul, que le diagnostic ne prouve pas.
const plancherDe = (plancher, valeur) => (plancher > 0 ? { plancher, atteint: valeur === plancher } : null);
// La somme d'un champ des planchers de collisions du diagnostic, un par
// appartenance en collision ; un champ null compte pour 0.
const sommeDe = (collisions, champ) => collisions.reduce((total, ligne) => total + (ligne[champ] ?? 0), 0);
// Le nom du critère des objectifs dans instance.etatCriteres, et, pour
// chaque valeur qu'il peut comparer, sous le nom de sa colonne, le champ
// qui la porte dans objectifsInevitables comme dans mesures.objectifs.
const CRITERE_DES_OBJECTIFS = 'toursHorsObjectif';
const CHAMPS_DES_OBJECTIFS = [
['jamaisServies', 'jamaisServies'],
['horsObjectifMax', 'horsObjectifMax'],
['toursHorsObjectif', 'horsObjectif'],
];
// Le plancher de chaque tuile qui en porte un (§ 5.6, § 12.2, B11), sous le
// nom de sa colonne (§ 5.7), {plancher, atteint} ou null, jamais 0 — là où
// le diagnostic écrit le sien, et là seulement :
// - collisionsCumulees, pairesDistinctes, excedentCollisions : la somme des
// planchers de chaque appartenance en collision ; chaque mesure somme
// celles des appartenances, et chaque plancher minore la sienne ;
// - ecartItineraireMax : le plancher de l'écart d'itinéraire maximal, null
// quand la ligne n'a pas d'écart d'itinéraire — plafond a priori inconnu,
// la tuile écrit « inconnu » ;
// - jamaisServies, horsObjectifMax, toursHorsObjectif : le plancher de
// chaque valeur que le critère des objectifs compare sous le mode réglé,
// null pour une autre, et tous null quand le critère est décoché ou sans
// objet, qui ne compare rien.
// atteint compare au plancher la valeur même que la tuile écrit, lue sans
// recompte : les mesures du plan, et l'écart d'itinéraire maximal des trois
// chiffres de sa ligne d'évaluation. Hors du domaine des planchers — un plan
// qui laisse quelqu'un en réserve ou ne tient pas une réservation —, aucun
// ne tient : tous null.
function planchersDe({ etatCriteres }, { mesures, troisChiffres, diagnostic, planchersValides }) {
const planchers = {
collisionsCumulees: null,
pairesDistinctes: null,
excedentCollisions: null,
ecartItineraireMax: null,
jamaisServies: null,
horsObjectifMax: null,
toursHorsObjectif: null,
};
if (!planchersValides) return planchers;
const { collisions, ecartItineraire, objectifsInevitables } = diagnostic;
planchers.collisionsCumulees = plancherDe(sommeDe(collisions, 'plancherCumulees'), mesures.collisionsCumulees);
planchers.pairesDistinctes = plancherDe(sommeDe(collisions, 'plancherPairesDistinctes'), mesures.pairesDistinctes);
planchers.excedentCollisions = plancherDe(sommeDe(collisions, 'plancherExcedent'), mesures.excedentCollisions);
const { ecartItineraireMax } = troisChiffres;
if (ecartItineraire !== null && ecartItineraireMax !== null) {
planchers.ecartItineraireMax = plancherDe(ecartItineraire.plancher, ecartItineraireMax.tous);
}
const critere = etatCriteres.find(({ nom }) => nom === CRITERE_DES_OBJECTIFS);
if (critere.coche && !critere.sansObjet) {
for (const [colonne, champ] of CHAMPS_DES_OBJECTIFS) {
if (critere.valeurs.includes(colonne)) planchers[colonne] = plancherDe(objectifsInevitables[champ], mesures.objectifs[champ]);
}
}
return planchers;
}
/**
* Le plancher des collisions cumulées de chaque appartenance (§ 5.6), dans
* l'ordre de instance.groupes, lu dans le diagnostic : 0 pour une
@ -545,7 +621,9 @@ export function profilAnimateurs({ N, statut }, mesures) {
* occupations se lisent une fois chacun, et chaque partie en dérive.
*
* - profil, synthese, repartitionRencontres, bornes : profilRencontres et ce
* qu'on en tire ;
* qu'on en tire, les planchers des tuiles lus dans le diagnostic sous
* planchersValides, jugés atteints sur les mesures et les trois chiffres
* reçus ;
* - collisions : {planchersValides, lignes}, les barres, les planchers lus
* dans diagnostic.collisions ;
* - tables : mixTables ; paires : pairesRepetees ;
@ -568,13 +646,14 @@ export function profilAnimateurs({ N, statut }, mesures) {
* mesures: import('./indicateurs.js').Mesures,
* plafondsRealises: ArrayLike<number>,
* plafondsAPriori: ArrayLike<number>|null,
* troisChiffres: import('./manque.js').TroisChiffres,
* planchersValides: boolean,
* cle: (libelle: string) => string,
* ordreMatrice?: number[]|null,
* aMax?: ArrayLike<number>}} options
*/
export function detailler(instance, tableDe, options) {
const { diagnostic, mesures, plafondsRealises, plafondsAPriori, planchersValides, cle, ordreMatrice = null } = options;
const { diagnostic, mesures, plafondsRealises, plafondsAPriori, troisChiffres, planchersValides, cle, ordreMatrice = null } = options;
const aMax = options.aMax ?? borneDiversite(instance, tableDe).aMax;
const parcours = parcourirTours(instance, tableDe);
const occupation = occupations(instance, tableDe, { affilies: true });
@ -592,10 +671,13 @@ export function detailler(instance, tableDe, options) {
bornes: bornesDesTuiles(instance, {
mesures,
plafondsRealises,
troisChiffres,
aMax,
profil,
animateurs: profilDesAnimateurs,
objectifs,
diagnostic,
planchersValides,
}),
collisions: { planchersValides, lignes: barresCollisions(instance, mesures, planchers, planchersValides, cle) },
tables: mixTables(instance, tableDe, occupation, parcours),

View file

@ -18,6 +18,7 @@ import { LIBRE, indexerPlan, normaliser } from './configuration.js';
import { diagnostiquer } from './diagnostic.js';
import { diversiteAnimateurs } from './diversite.js';
import { mesurer, parcourirTours } from './indicateurs.js';
import { troisChiffres } from './manque.js';
import { matriceRencontres, pairesRepetees } from './paires.js';
import { borneDiversite, occupations, plafondsAPriori, plafondsRealises } from './plafond.js';
import {
@ -64,8 +65,8 @@ const participant = ([id, appartenance = null, etiquettes, anime]) => ({
// Configuration, instance et plan indexé. tables : [id, capacité], dans
// l'ordre de la configuration ; plan[r] associe à un identifiant de table
// les identifiants qui s'y assoient au tour r + 1 ; un présent absent d'un
// tour est en réserve.
function preparer({ personnes, tables, plan, reservations = [], objectifs = [], contraintes = SANS_CONTRAINTE }) {
// tour est en réserve ; modeObjectifs, facultatif, le mode des objectifs.
function preparer({ personnes, tables, plan, reservations = [], objectifs = [], contraintes = SANS_CONTRAINTE, modeObjectifs }) {
const configuration = {
participants: personnes.map(participant),
tables: tables.map(([id, capacite], i) => ({ id, numero: i + 1, capacite })),
@ -73,6 +74,7 @@ function preparer({ personnes, tables, plan, reservations = [], objectifs = [],
reservations,
contraintes,
...(objectifs.length === 0 ? {} : { objectifs }),
...(modeObjectifs === undefined ? {} : { modeObjectifs }),
};
const instance = normaliser(configuration);
const ids = configuration.tables.map(({ id }) => id);
@ -96,6 +98,22 @@ function mesurerTout(prepare) {
};
}
// Les options de detailler sur un plan préparé et ses mesures : le
// diagnostic de sa configuration, les trois chiffres de sa ligne
// d'évaluation, aucune matrice.
function optionsDe({ configuration, instance }, { mesures, realises, aPriori }, planchersValides = true) {
return {
diagnostic: diagnostiquer(configuration),
mesures,
plafondsRealises: realises,
plafondsAPriori: aPriori,
troisChiffres: troisChiffres(instance, mesures, aPriori, realises),
planchersValides,
cle,
ordreMatrice: null,
};
}
const index = (instance, id) => instance.indexDe.get(id);
const idsDe = (instance, lignes) => lignes.map(({ p }) => instance.ids[p]);
const somme = (valeurs) => valeurs.reduce((total, valeur) => total + valeur, 0);
@ -659,13 +677,42 @@ describe('profilAnimateurs (§ 12.13, D37)', () => {
});
});
// Ce que bornesDesTuiles lit pour les planchers des tuiles : le diagnostic
// de la configuration préparée, sur un plan du domaine des planchers sauf
// demande contraire, et les trois chiffres que la ligne d'évaluation du
// plan porte sous le plafond a priori reçu.
const duDiagnostic = ({ configuration, instance, tableDe }, plafondsAPriori, planchersValides = true) => ({
diagnostic: diagnostiquer(configuration),
planchersValides,
troisChiffres: troisChiffres(instance, mesurer(instance, tableDe), plafondsAPriori, plafondsRealises(instance, tableDe)),
});
// Les sept tuiles qui peuvent porter un plancher, chacune sans plancher.
const SANS_PLANCHER = {
collisionsCumulees: null,
pairesDistinctes: null,
excedentCollisions: null,
ecartItineraireMax: null,
jamaisServies: null,
horsObjectifMax: null,
toursHorsObjectif: null,
};
describe('bornesDesTuiles (§ 12.2, D29, D34, D52, B11)', () => {
test('le rang 1 du profil, la tête du profil de manque, le minimum de A(p) au même tri : chacun une personne nommée', () => {
const prepare = preparer(PROFIL);
const { instance } = prepare;
const { mesures, realises, aPriori, aMax } = mesurerTout(prepare);
const profil = profilRencontres(instance, mesures, realises, aPriori, aMax);
const bornes = bornesDesTuiles(instance, { mesures, plafondsRealises: realises, aMax, profil, animateurs: null, objectifs: null });
const bornes = bornesDesTuiles(instance, {
mesures,
plafondsRealises: realises,
aMax,
profil,
animateurs: null,
objectifs: null,
...duDiagnostic(prepare, aPriori),
});
const un = index(instance, 1);
assert.deepEqual(bornes.rang1, { p: un, rencontres: 2, plafondRealise: 2, plafondAPriori: aPriori[un], pourcent: 100 });
// Manque 1 pour 4 et 5, au même plafond 4 : l'identifiant le plus petit.
@ -683,7 +730,15 @@ describe('bornesDesTuiles (§ 12.2, D29, D34, D52, B11)', () => {
const { mesures, realises, aPriori, aMax } = mesurerTout(avec);
const profil = profilRencontres(instance, mesures, realises, aPriori, aMax);
const { objectifs } = objectifsDe(avec);
const bornes = bornesDesTuiles(instance, { mesures, plafondsRealises: realises, aMax, profil, animateurs: null, objectifs });
const bornes = bornesDesTuiles(instance, {
mesures,
plafondsRealises: realises,
aMax,
profil,
animateurs: null,
objectifs,
...duDiagnostic(avec, aPriori),
});
// s(p) = 0 pour 4 et 5 : 5, aux tours possibles les plus nombreux.
assert.deepEqual(bornes.objectifs, { p: index(instance, 5), servis: 0, possibles: 2, pourcent: 0 });
assert.deepEqual(bornes.soiree, { servis: 5, possibles: 11, pourcent: 45, jamaisServies: 2 });
@ -711,6 +766,7 @@ describe('bornesDesTuiles (§ 12.2, D29, D34, D52, B11)', () => {
profil: profilRencontres(servis.instance, lu.mesures, lu.realises, lu.aPriori, lu.aMax),
animateurs: null,
objectifs: objectifsDe(servis).objectifs,
...duDiagnostic(servis, lu.aPriori),
});
assert.deepEqual(lu.mesures.objectifs.tours, [1, 1, 1, 1, 1, 2, 0, 0, 0]);
// s(p) = 1 pour 1, 2, 3, 5 à P(p) = 2 et pour 4 à P(p) = 1 : 1, le
@ -728,22 +784,304 @@ describe('bornesDesTuiles (§ 12.2, D29, D34, D52, B11)', () => {
profil: profilA,
animateurs: profilAnimateurs(animes.instance, m),
objectifs: null,
...duDiagnostic(animes, tout.aPriori),
});
assert.deepEqual(deAnimateurs.animateurs, { p: index(animes.instance, 5), borne: 2 });
});
test('sans personne présente : ni rang 1, ni tête de manque', () => {
const vide = normaliser({
const configuration = {
participants: [{ id: 1, nom: 'P1', appartenance: AUBIER, exclu: true }],
tables: [{ id: 1, numero: 1, capacite: 2 }],
tours: 1,
reservations: [],
contraintes: SANS_CONTRAINTE,
});
};
const vide = normaliser(configuration);
const plan = new Int32Array(0);
const mesures = mesurer(vide, plan);
const bornes = bornesDesTuiles(vide, { mesures, plafondsRealises: [], aMax: [], profil: [], animateurs: null, objectifs: null });
assert.deepEqual(bornes, { rang1: null, manque: null, diversite: null, animateurs: null, objectifs: null, soiree: null });
const bornes = bornesDesTuiles(vide, {
mesures,
plafondsRealises: [],
aMax: [],
profil: [],
animateurs: null,
objectifs: null,
...duDiagnostic({ configuration, instance: vide, tableDe: plan }, []),
});
assert.deepEqual(bornes, {
rang1: null,
manque: null,
diversite: null,
animateurs: null,
objectifs: null,
soiree: null,
planchers: SANS_PLANCHER,
});
});
});
// Les bornes des tuiles d'un plan préparé, sous le diagnostic de sa
// configuration ; aPrioriConnu faux passe un plafond a priori inconnu.
function bornesDuPlan(prepare, { planchersValides = true, aPrioriConnu = true } = {}) {
const { instance } = prepare;
const { mesures, realises, aPriori, aMax } = mesurerTout(prepare);
const plafonds = aPrioriConnu ? aPriori : null;
return bornesDesTuiles(instance, {
mesures,
plafondsRealises: realises,
aMax,
profil: profilRencontres(instance, mesures, realises, plafonds, aMax),
animateurs: null,
objectifs: objectifsDe(prepare).objectifs,
...duDiagnostic(prepare, plafonds, planchersValides),
});
}
// Cinq de la Coopérative Aubier et une personne sans appartenance, deux
// tables de trois, trois tours : chaque tour assied trois membres à une
// table et deux à l'autre, quatre collisions. Le diagnostic prouve 12
// collisions cumulées, 4 paires distinctes — le plus grand minimum d'un
// tour — et un excédent de 12 − C(5, 2) = 2.
const CINQ_MEMBRES = {
personnes: [[1, AUBIER], [2, AUBIER], [3, AUBIER], [4, AUBIER], [5, AUBIER], [6]],
tables: [[1, 3], [2, 3]],
};
// Les mêmes tables à chaque tour : 4 paires distinctes, revues trois fois,
// excédent 8.
const MEMES_TABLES = [
{ 1: [1, 2, 3], 2: [4, 5, 6] },
{ 1: [1, 2, 3], 2: [4, 5, 6] },
{ 1: [1, 2, 3], 2: [4, 5, 6] },
];
// Chaque tour ne revoit que 1 et 2 : les dix paires de l'appartenance,
// excédent 2.
const TABLES_VARIEES = [
{ 1: [1, 2, 3], 2: [4, 5, 6] },
{ 1: [1, 2, 4], 2: [3, 5, 6] },
{ 1: [1, 2, 5], 2: [3, 4, 6] },
];
// Cinq personnes sans appartenance, une table de trois et une de deux, deux
// tours : le diagnostic prouve un écart d'itinéraire maximal de 1 au moins
// (diagnostic.test.js, § 12.10.4). Le plan qui fait passer quatre personnes
// distinctes par la table de deux l'atteint ; celui qui y garde les deux
// mêmes laisse 4 et 5 à deux sous leur plafond a priori de 4.
const ITINERAIRE = { personnes: [[1], [2], [3], [4], [5]], tables: [[10, 3], [20, 2]] };
const PAR_LA_TABLE_DE_DEUX = [
{ 10: [1, 2, 3], 20: [4, 5] },
{ 10: [1, 4, 5], 20: [2, 3] },
];
const MEMES_A_LA_TABLE_DE_DEUX = [
{ 10: [1, 2, 3], 20: [4, 5] },
{ 10: [1, 2, 3], 20: [4, 5] },
];
// Cinq « finance » et une autre personne, une table de deux qui attend
// « finance » et une de quatre, deux tours : cinq couples possibles pour
// deux sièges à chaque tour. Le diagnostic prouve 3 + 3 = 6 tours hors
// objectif, 5 − (2 + 2) = 1 personne jamais servie et ⌈6 / 5⌉ = 2 tours
// hors objectif au moins pour une personne.
const DEUX_SIEGES = {
personnes: [[1, null, ['finance']], [2, null, ['finance']], [3, null, ['finance']], [4, null, ['finance']], [5, null, ['finance']], [6]],
tables: [[1, 2], [2, 4]],
objectifs: [{ table: 1, tour: null, regle: 'ou', etiquettes: ['finance'] }],
};
// Quatre « finance » servies une fois chacune, 5 jamais : chaque plancher
// atteint.
const QUATRE_SERVIES = [
{ 1: [1, 2], 2: [3, 4, 5, 6] },
{ 1: [3, 4], 2: [1, 2, 5, 6] },
];
describe('bornesDesTuiles : les planchers des tuiles (§ 12.2, § 5.6, B11)', () => {
test('rouge : les collisions cumulées, les paires distinctes et l’excédent portent la somme des planchers du diagnostic ; atteint quand la mesure les égale', () => {
const memes = preparer({ ...CINQ_MEMBRES, plan: MEMES_TABLES });
const lu = mesurer(memes.instance, memes.tableDe);
assert.deepEqual([lu.collisionsCumulees, lu.pairesDistinctes, lu.excedentCollisions], [12, 4, 8]);
assert.deepEqual(
diagnostiquer(memes.configuration).collisions.map(({ plancherCumulees, plancherPairesDistinctes, plancherExcedent }) => [
plancherCumulees,
plancherPairesDistinctes,
plancherExcedent,
]),
[[12, 4, 2]],
);
assert.deepEqual(bornesDuPlan(memes).planchers, {
...SANS_PLANCHER,
collisionsCumulees: { plancher: 12, atteint: true },
pairesDistinctes: { plancher: 4, atteint: true },
excedentCollisions: { plancher: 2, atteint: false },
});
const variees = preparer({ ...CINQ_MEMBRES, plan: TABLES_VARIEES });
assert.equal(mesurer(variees.instance, variees.tableDe).pairesDistinctes, 10);
assert.deepEqual(bornesDuPlan(variees).planchers, {
...SANS_PLANCHER,
collisionsCumulees: { plancher: 12, atteint: true },
pairesDistinctes: { plancher: 4, atteint: false },
excedentCollisions: { plancher: 2, atteint: true },
});
});
test('rouge : deux appartenances en collision : chaque plancher somme ceux des deux, l’excédent ceux qui en écrivent un', () => {
// Trois de la Coopérative Aubier et trois de l'Atelier Ressac, deux
// tables de trois, trois tours : un tour réunit au moins deux membres
// de chacune, une collision ; 3 cumulées, 1 distincte, excédent 3 − 3 =
// 0, que le diagnostic n'écrit pas, pour chacune.
const deux = preparer({
personnes: [[1, AUBIER], [2, AUBIER], [3, AUBIER], [4, RESSAC], [5, RESSAC], [6, RESSAC]],
tables: [[1, 3], [2, 3]],
plan: [
{ 1: [1, 2, 4], 2: [3, 5, 6] },
{ 1: [1, 3, 5], 2: [2, 4, 6] },
{ 1: [2, 3, 6], 2: [1, 4, 5] },
],
});
assert.deepEqual(
diagnostiquer(deux.configuration).collisions.map(({ plancherCumulees, plancherPairesDistinctes, plancherExcedent }) => [
plancherCumulees,
plancherPairesDistinctes,
plancherExcedent,
]),
[[3, 1, null], [3, 1, null]],
);
const lu = mesurer(deux.instance, deux.tableDe);
assert.deepEqual([lu.collisionsCumulees, lu.pairesDistinctes], [6, 6]);
assert.deepEqual(bornesDuPlan(deux).planchers, {
...SANS_PLANCHER,
collisionsCumulees: { plancher: 6, atteint: true },
pairesDistinctes: { plancher: 2, atteint: false },
});
});
test('rouge : l’écart d’itinéraire maximal porte le plancher du diagnostic, atteint ou non ; aucun sous un plafond a priori inconnu', () => {
const distincts = preparer({ ...ITINERAIRE, plan: PAR_LA_TABLE_DE_DEUX });
assert.deepEqual(diagnostiquer(distincts.configuration).ecartItineraire, { plancher: 1, mobilesAuMoins: 2 });
assert.deepEqual(bornesDuPlan(distincts).planchers, { ...SANS_PLANCHER, ecartItineraireMax: { plancher: 1, atteint: true } });
const memes = preparer({ ...ITINERAIRE, plan: MEMES_A_LA_TABLE_DE_DEUX });
assert.deepEqual(bornesDuPlan(memes).planchers, { ...SANS_PLANCHER, ecartItineraireMax: { plancher: 1, atteint: false } });
assert.deepEqual(bornesDuPlan(distincts, { aPrioriConnu: false }).planchers, SANS_PLANCHER);
});
test('rouge : l’écart d’itinéraire se dit atteint sur la valeur que la ligne d’évaluation porte, reçue, jamais recomptée', () => {
const memes = preparer({ ...ITINERAIRE, plan: MEMES_A_LA_TABLE_DE_DEUX });
const { instance } = memes;
const { mesures, realises, aPriori, aMax } = mesurerTout(memes);
const ligne = troisChiffres(instance, mesures, aPriori, realises);
assert.equal(ligne.ecartItineraireMax.tous, 2);
const lus = {
mesures,
plafondsRealises: realises,
aMax,
profil: profilRencontres(instance, mesures, realises, aPriori, aMax),
animateurs: null,
objectifs: null,
diagnostic: diagnostiquer(memes.configuration),
planchersValides: true,
};
// Une ligne qui porterait 1 : le détail la lit telle quelle, sans
// remesurer le plan, qui en donnerait 2.
const recue = { ...ligne, ecartItineraireMax: { ...ligne.ecartItineraireMax, tous: 1 } };
assert.deepEqual(bornesDesTuiles(instance, { ...lus, troisChiffres: recue }).planchers.ecartItineraireMax, {
plancher: 1,
atteint: true,
});
// Une ligne sans écart d'itinéraire, sous un plafond a priori inconnu :
// la tuile écrit « inconnu », et aucun plancher.
const sansEcart = { ...ligne, ecartItineraireMax: null };
assert.equal(bornesDesTuiles(instance, { ...lus, troisChiffres: sansEcart }).planchers.ecartItineraireMax, null);
});
test('rouge : les personnes jamais servies, le plus de tours hors objectif d’une personne et les tours hors objectif portent ceux du diagnostic, chacun sous le mode qui le compare', () => {
const servies = preparer({ ...DEUX_SIEGES, plan: QUATRE_SERVIES });
assert.deepEqual(diagnostiquer(servies.configuration).objectifsInevitables, {
horsObjectif: 6,
jamaisServies: 1,
horsObjectifMax: 2,
parTour: [3, 3],
});
const objectifsDuPlan = (prepare) => {
const { jamaisServies, horsObjectifMax, toursHorsObjectif } = bornesDuPlan(prepare).planchers;
return [jamaisServies, horsObjectifMax, toursHorsObjectif];
};
assert.deepEqual(objectifsDuPlan(preparer({ ...DEUX_SIEGES, plan: QUATRE_SERVIES, modeObjectifs: 'chacun' })), [
{ plancher: 1, atteint: true },
null,
{ plancher: 6, atteint: true },
]);
assert.deepEqual(objectifsDuPlan(preparer({ ...DEUX_SIEGES, plan: QUATRE_SERVIES, modeObjectifs: 'minimum' })), [
null,
{ plancher: 2, atteint: true },
{ plancher: 6, atteint: true },
]);
// 1 et 2 servies aux deux tours : autant de tours hors objectif, mais 3,
// 4 et 5 jamais servies.
const memes = [
{ 1: [1, 2], 2: [3, 4, 5, 6] },
{ 1: [1, 2], 2: [3, 4, 5, 6] },
];
const chacun = preparer({ ...DEUX_SIEGES, plan: memes, modeObjectifs: 'chacun' });
assert.equal(mesurer(chacun.instance, chacun.tableDe).objectifs.jamaisServies, 3);
assert.deepEqual(objectifsDuPlan(chacun), [{ plancher: 1, atteint: false }, null, { plancher: 6, atteint: true }]);
assert.deepEqual(objectifsDuPlan(preparer({ ...DEUX_SIEGES, plan: memes, modeObjectifs: 'minimum' })), [
null,
{ plancher: 2, atteint: true },
{ plancher: 6, atteint: true },
]);
// Le siège d'une « finance » donné à 6, qui n'attend rien : un tour
// hors objectif de plus.
const cede = bornesDuPlan(preparer({ ...DEUX_SIEGES, plan: [QUATRE_SERVIES[0], { 1: [3, 6], 2: [1, 2, 4, 5] }] })).planchers;
assert.deepEqual(cede.toursHorsObjectif, { plancher: 6, atteint: false });
});
test('rouge : un plancher des objectifs ne s’écrit que pour une valeur que le mode réglé compare, critère coché, comme au diagnostic (§ 5.6)', () => {
// Sous le mode par défaut, seuls les tours hors objectif se comparent :
// le diagnostic prouve 1 personne jamais servie et 2 tours hors
// objectif pour une personne, et aucune tuile ne les écrit.
const desObjectifs = ({ jamaisServies, horsObjectifMax, toursHorsObjectif }) => [jamaisServies, horsObjectifMax, toursHorsObjectif];
const parDefaut = bornesDuPlan(preparer({ ...DEUX_SIEGES, plan: QUATRE_SERVIES })).planchers;
assert.deepEqual(desObjectifs(parDefaut), [null, null, { plancher: 6, atteint: true }]);
// Le critère décoché ne compare rien, sous aucun mode, et les mesures
// des objectifs restent : aucun plancher.
for (const modeObjectifs of ['places', 'chacun', 'minimum']) {
const decoche = preparer({
...DEUX_SIEGES,
plan: QUATRE_SERVIES,
modeObjectifs,
contraintes: { ...SANS_CONTRAINTE, respecterObjectifs: false },
});
assert.notEqual(mesurer(decoche.instance, decoche.tableDe).objectifs, null, modeObjectifs);
assert.deepEqual(desObjectifs(bornesDuPlan(decoche).planchers), [null, null, null], modeObjectifs);
}
});
test('rouge : hors du domaine des planchers, aucun ; un plancher nul ne s’écrit pas, jamais 0', () => {
assert.deepEqual(bornesDuPlan(preparer({ ...CINQ_MEMBRES, plan: MEMES_TABLES }), { planchersValides: false }).planchers, SANS_PLANCHER);
assert.deepEqual(bornesDuPlan(preparer({ ...DEUX_SIEGES, plan: QUATRE_SERVIES }), { planchersValides: false }).planchers, SANS_PLANCHER);
// Deux membres par appartenance pour deux tables, aucune table
// inférieure, aucun objectif : le diagnostic ne prouve rien.
const rien = preparer(PROFIL);
const diagnostic = diagnostiquer(rien.configuration);
assert.deepEqual([diagnostic.collisions, diagnostic.ecartItineraire], [[], null]);
assert.deepEqual(bornesDuPlan(rien).planchers, SANS_PLANCHER);
// Cinq sièges pour cinq « finance » : des planchers des objectifs nuls,
// que la tuile tait.
const large = preparer({
...DEUX_SIEGES,
tables: [[1, 5], [2, 2]],
plan: [
{ 1: [1, 2, 3, 4, 5], 2: [6] },
{ 1: [1, 2, 3, 4, 5], 2: [6] },
],
});
assert.deepEqual(diagnostiquer(large.configuration).objectifsInevitables, {
horsObjectif: 0,
jamaisServies: 0,
horsObjectifMax: 0,
parTour: [0, 0],
});
const planchers = bornesDuPlan(large).planchers;
assert.deepEqual([planchers.jamaisServies, planchers.horsObjectifMax, planchers.toursHorsObjectif], [null, null, null]);
});
});
@ -848,17 +1186,9 @@ describe('detailler (§ 12.9, D27)', () => {
personnes: OBJECTIFS.personnes.map(([id, , etiquettes], i) => [id, [AUBIER, RESSAC, null][i % 3], etiquettes, id === 7 ? [2] : undefined]),
reservations: [...OBJECTIFS.reservations, auTour(7, 1, 2)],
});
const { configuration, instance, tableDe } = prepare;
const { instance, tableDe } = prepare;
const { mesures, realises, aPriori, aMax } = mesurerTout(prepare);
const options = {
diagnostic: diagnostiquer(configuration),
mesures,
plafondsRealises: realises,
plafondsAPriori: aPriori,
planchersValides: true,
cle,
ordreMatrice: null,
};
const options = optionsDe(prepare, { mesures, realises, aPriori });
return { prepare, instance, tableDe, mesures, realises, aPriori, aMax, options };
}
@ -887,7 +1217,20 @@ describe('detailler (§ 12.9, D27)', () => {
repartition: repartition(animateurs.map(({ rencontres }) => rencontres), null),
diversite: diversiteAnimateurs(instance, tableDe),
});
assert.deepEqual(detail.bornes, bornesDesTuiles(instance, { mesures, plafondsRealises: realises, aMax, profil, animateurs, objectifs }));
assert.deepEqual(
detail.bornes,
bornesDesTuiles(instance, {
mesures,
plafondsRealises: realises,
aMax,
profil,
animateurs,
objectifs,
diagnostic: options.diagnostic,
planchersValides: true,
troisChiffres: options.troisChiffres,
}),
);
assert.equal(detail.matrice, null);
});
@ -904,23 +1247,51 @@ describe('detailler (§ 12.9, D27)', () => {
test('sans animateur ni personne concernée, les animateurs valent null ; hors du domaine, les barres n’ont qu’un segment', () => {
const prepare = preparer(PROFIL);
const { configuration, instance, tableDe } = prepare;
const { mesures, realises, aPriori } = mesurerTout(prepare);
const detail = detailler(instance, tableDe, {
diagnostic: diagnostiquer(configuration),
mesures,
plafondsRealises: realises,
plafondsAPriori: aPriori,
planchersValides: false,
cle,
ordreMatrice: null,
});
const { instance, tableDe } = prepare;
const detail = detailler(instance, tableDe, optionsDe(prepare, mesurerTout(prepare), false));
assert.equal(detail.animateurs, null);
assert.equal(detail.objectifs, null);
assert.ok(detail.collisions.lignes.every(({ prouve, reste }) => prouve === null && reste === null));
assert.equal(detail.collisions.planchersValides, false);
});
test('hors du domaine des planchers, aucune tuile ne porte de plancher, même prouvé par le diagnostic', () => {
// Cinq membres d'une appartenance, 5 en réserve à chaque tour : deux
// collisions par tour, 6 en tout, sous les 12 que le diagnostic prouve
// pour un plan qui assoit chacun. Ce plancher ne tient pas ici.
const prepare = preparer({ ...CINQ_MEMBRES, plan: [0, 1, 2].map(() => ({ 1: [1, 2, 6], 2: [3, 4] })) });
const { instance, tableDe } = prepare;
const lu = mesurerTout(prepare);
const options = optionsDe(prepare, lu, false);
assert.deepEqual(options.diagnostic.collisions.map(({ plancherCumulees }) => plancherCumulees), [12]);
assert.equal(lu.mesures.collisionsCumulees, 6);
const detail = detailler(instance, tableDe, options);
assert.deepEqual(detail.bornes.planchers, SANS_PLANCHER);
assert.equal(detail.collisions.planchersValides, false);
});
test('rouge : les trois chiffres reçus passent aux tuiles : le plancher de l’écart d’itinéraire, atteint ou non, aucun sous un plafond a priori inconnu', () => {
for (const [plan, atteint] of [
[PAR_LA_TABLE_DE_DEUX, true],
[MEMES_A_LA_TABLE_DE_DEUX, false],
]) {
const prepare = preparer({ ...ITINERAIRE, plan });
const lu = mesurerTout(prepare);
const detail = detailler(prepare.instance, prepare.tableDe, optionsDe(prepare, lu));
assert.deepEqual(detail.bornes.planchers, { ...SANS_PLANCHER, ecartItineraireMax: { plancher: 1, atteint } }, `${atteint}`);
const inconnu = detailler(prepare.instance, prepare.tableDe, optionsDe(prepare, { ...lu, aPriori: null }));
assert.deepEqual(inconnu.bornes.planchers, SANS_PLANCHER, `${atteint}`);
}
// Les chiffres reçus font foi, même quand le plan en donnerait
// d'autres : 1 reçu pour les mêmes à la table de deux, qui mesurent 2.
const memes = preparer({ ...ITINERAIRE, plan: MEMES_A_LA_TABLE_DE_DEUX });
const options = optionsDe(memes, mesurerTout(memes));
assert.equal(options.troisChiffres.ecartItineraireMax.tous, 2);
const recus = { ...options.troisChiffres, ecartItineraireMax: { ...options.troisChiffres.ecartItineraireMax, tous: 1 } };
const detail = detailler(memes.instance, memes.tableDe, { ...options, troisChiffres: recus });
assert.deepEqual(detail.bornes.planchers.ecartItineraireMax, { plancher: 1, atteint: true });
});
test('des animateurs sans personne concernée : le profil et sa répartition valent null, la diversité reste', () => {
// 1 anime, seul présent : il ne se rencontre pas lui-même, et personne
// d'autre ne peut le rejoindre.
@ -930,18 +1301,10 @@ describe('detailler (§ 12.9, D27)', () => {
reservations: [auTour(1, 1, 1)],
plan: [{ 1: [1] }],
});
const { configuration, instance, tableDe } = prepare;
const { mesures, realises, aPriori } = mesurerTout(prepare);
assert.equal(mesures.animateurs, null);
const detail = detailler(instance, tableDe, {
diagnostic: diagnostiquer(configuration),
mesures,
plafondsRealises: realises,
plafondsAPriori: aPriori,
planchersValides: true,
cle,
ordreMatrice: null,
});
const { instance, tableDe } = prepare;
const lu = mesurerTout(prepare);
assert.equal(lu.mesures.animateurs, null);
const detail = detailler(instance, tableDe, optionsDe(prepare, lu));
assert.equal(detail.animateurs.profil, null);
assert.equal(detail.animateurs.repartition, null);
assert.equal(detail.animateurs.diversite.lignes.length, 1);
@ -951,6 +1314,12 @@ describe('detailler (§ 12.9, D27)', () => {
describe('qualite : la source du module', () => {
const source = readFileSync(new URL('./qualite.js', import.meta.url), 'utf8');
test('rouge : aucune mesure de la ligne d’évaluation recomptée : les trois chiffres se reçoivent', () => {
assert.doesNotMatch(source, /\btroisChiffres\(/);
// Ni importés sous un autre nom : aucun import n'en nomme.
assert.doesNotMatch(source, /^import[^;]*\btroisChiffres\b/m);
});
test('aucun couple possible ni correspondance relus : les comptes par tour viennent de parcourirTours', () => {
assert.doesNotMatch(source, /\bpossible\[/);
assert.doesNotMatch(source, /\bcorrespond\[/);

View file

@ -2380,6 +2380,26 @@ describe('dépôt : le compagnon des courses (§ 8.6, § 8.9, B31)', () => {
assert.equal(fs.contenu('documents', COURSES), valide);
});
test("une lecture du compagnon que le système refuse n'empêche pas l'ouverture : ses courses sont inconnues ; une erreur qui ne vient pas du stockage remonte telle quelle", async () => {
const charge = chargeEssai();
const [p] = charge.propositions;
const { disque } = disqueAvec([[ETAT, etatTexte(charge)], [COURSES, texteDesCourses('evt-lucioles', [entreeDe(p)])]]);
// Un lecteur du disque dont la lecture du compagnon rejette l'erreur
// que rend erreurDe(chemin), les autres fichiers lus comme d'ordinaire.
const refusant = (erreurDe) => {
const fs = creerFichiersSimules({ disque });
const lire = fs.lireTexte;
fs.lireTexte = (racine, chemin) => (chemin === COURSES ? Promise.reject(erreurDe(chemin)) : lire(racine, chemin));
return fs;
};
assert.equal(courseDe(await rouvrir(disque, BASE), 1).evalues, 100);
const refuse = await ouvrir(refusant((chemin) => new ErreurStockage('LECTURE', { chemin, dossier: DOCUMENTS, cause: 'EACCES' })), DOC, BASE);
assert.deepEqual(refuse.avertissements, []);
assert.equal(courseDe(refuse, 1), null);
const panne = new TypeError('lecteur en panne');
await assert.rejects(ouvrir(refusant(() => panne), DOC, BASE), (erreur) => erreur === panne);
});
test("un refus d'écrire le compagnon n'empêche pas le geste : journal et état écrits, la course connue de l'Ouvert pour la séance ; le geste qui change ensuite les propositions le réécrit", async () => {
const { fs } = nouveauDisque();
const ouvert = await creer(fs, DOC, chargeEssai(), CREATION);

View file

@ -271,11 +271,13 @@ const NIVEAUX = Object.freeze([
[[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). */
/** Seize personnes inventées, trois tables de quatre et une de cinq, 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).
* La table 4 garde un siège vide à chaque tour : ses sièges et ses assis
* diffèrent, et leurs deux pourcentages aussi aux tours 1 et 2. */
export function chargeNiveaux() {
const appartenances = [
...Array(4).fill(AUBIER),
@ -286,6 +288,7 @@ export function chargeNiveaux() {
...Array(5).fill(null),
];
let charge = chargeInventee({ appartenances, tables: 4, sieges: 4, tours: 3 });
charge.tables[3].sieges = 5;
for (const participant of [1, 2, 3, 4]) {
charge = executer(charge, 'reserver', { participant, table: 1, siege: participant, portee: 'tous', tour: null });
}
@ -575,12 +578,14 @@ async function jumeauSansEcriture(contexte, charge) {
}
// La page de qualité de chargeNiveaux, son sélecteur du mélange des tables
// posé sur la position donnée par un clic.
// posé sur la position donnée par un clic, et son jumeau ouvert : chaque
// ligne y écrit les trois pourcentages, chacun suivi du compte de sa ligne.
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);
await ouvrirJumeaux(m, '[data-vue-qualite="tables"]');
return m.racine;
}