// © 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(); }); });