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