gestion_table_tournante_libre/src/interface/qualite/graphiques/graphiques.navigateur.test.js

970 lines
46 KiB
JavaScript
Raw Normal View History

// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Épreuves des composants de graphique de la page de qualité dans un vrai
// moteur de rendu (§ 12.3, § 12.4, § 12.5, § 12.7, § 14.5, D30, D34, D44,
// D51) : escalier, points de relevé, barres, grille, jumeau et pourcentage,
// montés chacun par mount sur des données forgées, avec les contextes que
// pose App — 't' et 'format'. Deux graphiques empilés partagent leur rang
// pointé comme une vue le ferait par bind:pointe : un magasin lu par
// fromStore, que chaque graphique lit et écrit. Les gestes passent par des
// entrées que Chromium traite comme réelles (test/navigateur/entrees.js).
// Les noms d'épreuve sont inventés.
//
// Le projet navigateur emploie expect, et test et describe de Vitest
// (§ 14.8).
import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vitest';
import { flushSync, mount, tick, unmount } from 'svelte';
import { fromStore, writable } from 'svelte/store';
import '../../jetons.css';
import { TABLES_APPLICATION } from '../../../application/libelles.js';
import { creerFormat } from '../../../application/format.js';
import { creerTraduction } from '../../../application/traduction.js';
import { creerMesureur, mesuresDuTexte } from '../../plan/mesure_texte.js';
import { releverMarques } from '../../../../test/navigateur/captures.js';
import { fixerCadre, pointer, touche } from '../../../../test/navigateur/entrees.js';
import { attenuations, sousTheme } from '../../../../test/navigateur/rendu.js';
import { TABLES_INTERFACE } from '../../libelles.js';
import { creerDessinDiffere } from './dessin_differe.js';
import { LARGEUR_NOM_PX, margesCommunes } from './pointage.js';
import Barres from './Barres.svelte';
import Escalier from './Escalier.svelte';
import Grille from './Grille.svelte';
import Jumeau from './Jumeau.svelte';
import Pourcentage from './Pourcentage.svelte';
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
const CONTEXTE = new Map([
['t', t],
['format', creerFormat(t)],
]);
const NOM_LONG = 'Maëlle-Hortense Quenouille-Vasque de Belle-Rive';
const PRENOMS = ['Iris Ombrelle', 'Théo Pervenche', 'Ondine Lacasse', 'Basile Tourteau', NOM_LONG];
const nomDe = (i) => PRENOMS[i % PRENOMS.length];
const montes = [];
// Le cadre des écrans de l'application (§ 19.4) : un graphique de 1 000 px
// y tient en entier.
beforeAll(async () => {
await fixerCadre(1366, 700);
});
afterAll(async () => {
await fixerCadre(1366, 700);
});
afterEach(() => {
for (const { composant, cible } of montes.splice(0)) {
unmount(composant);
cible.remove();
}
});
// Monte un composant dans un conteneur neuf de la largeur donnée, au début
// du corps, puis laisse le graphique mesurer sa largeur et son texte.
async function monter(Composant, props, largeur = 800) {
const cible = document.createElement('div');
cible.style.width = `${largeur}px`;
document.body.prepend(cible);
const composant = mount(Composant, { target: cible, props, context: CONTEXTE });
flushSync();
montes.push({ composant, cible });
await tick();
flushSync();
await tick();
return cible;
}
// Un rang pointé partagé, comme bind:pointe le partage entre deux vues :
// avec(props) rend les propriétés reçues augmentées de pointe, un accesseur
// sur le magasin commun — une décomposition par « ... » copierait sa valeur
// et perdrait la liaison.
function rangPartage() {
const rang = fromStore(writable(null));
const accesseur = {
get() {
return rang.current;
},
set(valeur) {
rang.current = valeur;
},
enumerable: true,
};
return { rang, avec: (props) => Object.defineProperty({ ...props }, 'pointe', accesseur) };
}
// Des marges partagées par un groupe de graphiques, comme une vue les lie :
// chaque graphique écrit les siennes (marges) et reçoit les marges communes
// de tous (alignerSur, margesCommunes). Les accesseurs se posent sur l'objet
// reçu même, qui garde ainsi celui de pointe.
function margesPartagees(n) {
const marges = Array.from({ length: n }, () => fromStore(writable(null)));
return (props, i) =>
Object.defineProperties(
props,
{
marges: { get: () => marges[i].current, set: (v) => (marges[i].current = v), enumerable: true },
alignerSur: { get: () => margesCommunes(marges.map((m) => m.current)), enumerable: true },
},
);
}
// La couleur calculée d'un jeton, lue sur un élément témoin.
function couleurDuJeton(jeton) {
const temoin = document.createElement('span');
temoin.style.color = `var(${jeton})`;
document.body.append(temoin);
const couleur = getComputedStyle(temoin).color;
temoin.remove();
return couleur;
}
// Le motif calculé d'un trait, ses longueurs en px ; [] pour un trait plein.
const motifDe = (element) => (getComputedStyle(element).strokeDasharray.match(/[\d.]+/g) ?? []).map(Number);
// Le point (x, y) de l'élément SVG d'un graphique dans la fenêtre, pour un
// point local au SVG : sans viewBox, le pixel du dessin est celui de l'écran.
function versFenetre(svg, x, y) {
const cadre = svg.getBoundingClientRect();
return { x: cadre.left + x, y: cadre.top + y };
}
// La surface de pointage d'un graphique : le rect sans remplissage qui
// capte le pointeur.
const surfaceDe = (svg) => svg.querySelector(':scope > rect[pointer-events="all"]');
const infobulleDe = (cible) => cible.querySelector('[role="tooltip"]');
// Le profil forgé de n personnes : rencontres croissantes, appartenances
// rencontrées dessous, plafond réalisé en escalier, borne de diversité ;
// marques aux rangs 2 et 4.
function profil(n) {
const rencontres = Array.from({ length: n }, (_, i) => 10 + Math.floor((17 * i) / Math.max(1, n - 1)));
return {
rencontres,
appartenances: rencontres.map((r) => Math.floor(r / 3)),
plafond: rencontres.map((_, i) => (i < n / 2 ? 27 : 26)),
borne: rencontres.map(() => 6),
marques: rencontres.map((_, i) => (i === 1 ? 'ancre' : i === 3 ? 'reserve' : null)),
};
}
const COLONNES_PROFIL = [
{ cle: 'rang', libelle: 'Rang', genre: 'nombre' },
{ cle: 'nom', libelle: 'Personne', genre: 'texte' },
{ cle: 'rencontres', libelle: 'Rencontres', genre: 'nombre' },
{ cle: 'part', libelle: 'Part', genre: 'pourcentage' },
];
function infobulleProfil(p) {
return {
colonnes: COLONNES_PROFIL,
lignes: p.rencontres.map((r, i) => ({
rang: i + 1,
nom: nomDe(i),
rencontres: r,
part: { texte: `${Math.floor((100 * r) / p.plafond[i])} %`, compte: `${r} sur ${p.plafond[i]}` },
})),
};
}
function propsProfil(n, extra = {}) {
const p = profil(n);
return {
graphique: 'profil',
nom: 'Qui rencontre le moins de monde ?',
synthese: 'Minimum 10 rencontres, une personne.',
series: [
{ libelle: 'rencontres', valeurs: p.rencontres },
{ libelle: 'appartenances rencontrées', valeurs: p.appartenances },
],
reperes: [
{ genre: 'plafond', libelle: 'plafond réalisé', valeurs: p.plafond },
{ genre: 'borne', libelle: 'borne de diversité', valeurs: p.borne },
],
marques: p.marques,
infobulle: infobulleProfil(p),
...extra,
};
}
describe('graphiques : l’escalier du profil (§ 12.3, D30)', () => {
test('une image nommée et décrite, sans viewBox ; chaque trait peint son jeton, sans opacité ni filtre dans son ascendance', async () => {
const cible = await monter(Escalier, propsProfil(12));
const svg = cible.querySelector('svg[data-graphique="profil"]');
expect(svg.getAttribute('role')).toBe('img');
expect(svg.hasAttribute('viewBox')).toBe(false);
expect(document.getElementById(svg.getAttribute('aria-labelledby')).textContent).toBe('Qui rencontre le moins de monde ?');
expect(document.getElementById(svg.getAttribute('aria-describedby')).textContent).toBe('Minimum 10 rencontres, une personne.');
const attendus = [
['[data-serie="serie1"]', '--couleur-graphique-serie-1'],
['[data-serie="serie2"]', '--couleur-graphique-serie-2'],
['[data-repere="plafond"]', '--couleur-graphique-repere'],
['[data-repere="borne"]', '--couleur-graphique-borne'],
];
for (const [selecteur, jeton] of attendus) {
const trace = svg.querySelector(selecteur);
expect(trace, selecteur).not.toBeNull();
expect(getComputedStyle(trace).stroke, selecteur).toBe(couleurDuJeton(jeton));
expect(attenuations(trace), selecteur).toEqual([]);
}
for (const marque of svg.querySelectorAll('[data-marque]')) expect(attenuations(marque)).toEqual([]);
});
test('rouge : les quatre traits — plein, tirets, mixte, pointillé — ont des motifs calculés deux à deux distincts', async () => {
const cible = await monter(Escalier, propsProfil(12));
const motifs = ['[data-serie="serie1"]', '[data-serie="serie2"]', '[data-repere="plafond"]', '[data-repere="borne"]'].map(
(s) => getComputedStyle(cible.querySelector(s)).strokeDasharray,
);
expect(new Set(motifs).size, motifs.join(' | ')).toBe(4);
expect(motifs[0]).toBe('none');
});
test('rouge : le plafond en tiret-point, la borne en pointillés plus courts que les tirets de la seconde série', async () => {
const cible = await monter(Escalier, propsProfil(12));
const [long, vide1, court, vide2, ...reste] = motifDe(cible.querySelector('path[data-repere="plafond"]'));
expect([long, vide1, court, vide2].every(Number.isFinite) && reste.length === 0, 'plafond : tiret, vide, point, vide').toBe(true);
expect(long).toBeGreaterThan(court);
expect(court).toBeLessThanOrEqual(Math.min(vide1, vide2));
const borne = motifDe(cible.querySelector('path[data-repere="borne"]'));
expect(borne, 'borne : point, vide').toHaveLength(2);
expect(borne[0]).toBeLessThanOrEqual(borne[1]);
expect(borne[0]).toBeLessThan(motifDe(cible.querySelector('path[data-serie="serie2"]'))[0]);
});
test('rouge : plus étroit que ses marges, l’escalier se rend sans lever, zone nulle', async () => {
const cible = await monter(Escalier, propsProfil(12), 150);
const svg = cible.querySelector('svg[data-graphique="profil"]');
expect(svg).not.toBeNull();
expect(svg.querySelector('[data-serie="serie1"]')).not.toBeNull();
});
test('un seul axe vertical ; chaque trace porte son libellé sur le tracé, au bout de sa ligne', async () => {
const cible = await monter(Escalier, propsProfil(12));
const svg = cible.querySelector('svg');
const axes = [...svg.querySelectorAll('line[data-marque="graphiqueAxe"]')];
expect(axes.filter((a) => a.getAttribute('x1') === a.getAttribute('x2'))).toHaveLength(1);
const surface = surfaceDe(svg);
const fin = Number(surface.getAttribute('x')) + Number(surface.getAttribute('width'));
const textes = [...svg.querySelectorAll('text')];
for (const libelle of ['rencontres', 'appartenances rencontrées', 'plafond réalisé', 'borne de diversité']) {
const etiquette = textes.find((x) => x.textContent === libelle);
expect(etiquette, libelle).toBeDefined();
expect(Number(etiquette.getAttribute('x'))).toBeGreaterThan(fin);
const boite = etiquette.getBoundingClientRect();
expect(boite.right).toBeLessThanOrEqual(svg.getBoundingClientRect().right + 0.5);
}
});
test('un signe de forme aux seuls rangs marqués, de la marque du registre', async () => {
const cible = await monter(Escalier, propsProfil(12));
expect(cible.querySelectorAll('svg [data-marque="graphiqueMarque"]')).toHaveLength(2);
});
test('rouge : la surface de pointage est un rect sans remplissage, sœur des marques, jamais leur ancêtre', async () => {
const cible = await monter(Escalier, propsProfil(12));
const svg = cible.querySelector('svg');
const surface = surfaceDe(svg);
expect(surface).not.toBeNull();
expect(surface.getAttribute('fill')).toBe('none');
expect(surface.children).toHaveLength(0);
for (const marque of svg.querySelectorAll('[data-marque]')) {
expect(surface.contains(marque)).toBe(false);
// Aucun ancêtre d'une marque, dans le dessin, ne capte le pointeur à
// la place de la surface.
for (let n = marque.parentElement; n !== svg; n = n.parentElement) {
expect(getComputedStyle(n).pointerEvents, n.localName).not.toBe('all');
}
}
// La surface est la dernière : rien de peint ne la couvre.
expect(svg.lastElementChild).toBe(surface);
});
test('rouge : à 260 rangs dans 1 000 px, le pointage désigne le rang le plus proche, et l’infobulle porte sa ligne', async () => {
const cible = await monter(Escalier, propsProfil(260), 1000);
const svg = cible.querySelector('svg');
const surface = surfaceDe(svg);
const x0 = Number(surface.getAttribute('x'));
const pas = Number(surface.getAttribute('width')) / 260;
const y = Number(surface.getAttribute('y')) + 5;
for (let rang = 1; rang <= 260; rang += 1) {
const p = versFenetre(svg, x0 + (rang - 0.5) * pas, y);
await pointer('bouger', p.x, p.y);
flushSync();
expect(svg.querySelector('[data-rang]')?.getAttribute('data-rang'), `rang ${rang}`).toBe(String(rang));
}
const bulle = infobulleDe(cible);
expect(bulle.hidden).toBe(false);
expect(bulle.textContent).toContain(nomDe(259));
expect(bulle.querySelector('[data-pourcentage] + [data-compte-pire]').textContent).not.toBe('');
});
test('Échap ferme l’infobulle ; quitter le tracé aussi', async () => {
const cible = await monter(Escalier, propsProfil(5));
const svg = cible.querySelector('svg');
const surface = surfaceDe(svg);
const boite = surface.getBoundingClientRect();
// Le document a le focus, comme quand l'opérateur survole puis tape :
// une touche n'atteint que le document focalisé.
const focalisable = document.createElement('button');
cible.append(focalisable);
focalisable.focus();
await pointer('bouger', boite.left + 3, boite.top + 3);
flushSync();
expect(infobulleDe(cible).hidden).toBe(false);
await touche('Escape');
flushSync();
expect(infobulleDe(cible).hidden).toBe(true);
await pointer('bouger', boite.left + 4, boite.top + 3);
flushSync();
expect(infobulleDe(cible).hidden).toBe(false);
await pointer('bouger', boite.right + 200, boite.bottom + 200);
flushSync();
expect(infobulleDe(cible).hidden).toBe(true);
});
test('rouge : deux graphiques empilés — comptes, puis pourcentages — désignent le même rang pointé, l’infobulle dans le seul graphique survolé', async () => {
const p = profil(20);
const partage = rangPartage();
const aligner = margesPartagees(2);
const pourcents = p.rencontres.map((r, i) => (i === 6 ? null : Math.floor((100 * r) / p.plafond[i])));
const haut = await monter(Escalier, aligner(partage.avec(propsProfil(20)), 0), 900);
const bas = await monter(
Escalier,
aligner(partage.avec({
graphique: 'profilPourcent',
nom: 'Quelle part de son plafond chacun rencontre-t-il ?',
synthese: 'minimum 10 sur 27',
series: [{ libelle: 'part du plafond réalisé', valeurs: pourcents }],
reperes: [{ genre: 'cent', libelle: '100 %', valeurs: p.rencontres.map(() => 100) }],
graduer: (v) => `${v} %`,
infobulle: infobulleProfil(p),
}), 1),
900,
);
await tick();
flushSync();
const svgHaut = haut.querySelector('svg');
const surface = surfaceDe(svgHaut);
const pas = Number(surface.getAttribute('width')) / 20;
const point = versFenetre(svgHaut, Number(surface.getAttribute('x')) + 7.5 * pas, Number(surface.getAttribute('y')) + 4);
await pointer('bouger', point.x, point.y);
flushSync();
expect(haut.querySelector('[data-rang]').getAttribute('data-rang')).toBe('8');
expect(bas.querySelector('[data-rang]')?.getAttribute('data-rang')).toBe('8');
expect(infobulleDe(haut).hidden).toBe(false);
expect(infobulleDe(bas).hidden).toBe(true);
// La règle à 100 % a le motif du plafond, la série en pourcentage le plein.
const regle = bas.querySelector('path[data-repere="cent"]');
expect(bas.querySelector('text[data-repere="cent"]').textContent).toBe('100 %');
expect(getComputedStyle(regle).strokeDasharray).toBe(getComputedStyle(haut.querySelector('[data-repere="plafond"]')).strokeDasharray);
expect(getComputedStyle(regle).strokeDasharray).not.toBe(getComputedStyle(bas.querySelector('[data-serie="serie1"]')).strokeDasharray);
// Les deux abscisses s'alignent : même zone, rang contre rang ; le rang
// 7, sans valeur, interrompt le tracé du second.
const zones = [haut, bas].map((c) => ['x', 'width'].map((a) => surfaceDe(c.querySelector('svg')).getAttribute(a)));
expect(zones[1]).toEqual(zones[0]);
expect(bas.querySelector('[data-serie="serie1"]').getAttribute('d').match(/M/g)).toHaveLength(2);
});
test('rouge : un chemin ne relie jamais deux rangs par-dessus un rang sans valeur', async () => {
const cible = await monter(Escalier, {
graphique: 'trou',
nom: 'Trou',
synthese: 'Un rang sans valeur.',
series: [{ libelle: 'part', valeurs: [40, null, 40, 60] }],
});
const d = cible.querySelector('[data-serie="serie1"]').getAttribute('d');
expect(d.match(/M/g)).toHaveLength(2);
});
});
describe('graphiques : les points de relevé (§ 12.14, D49)', () => {
const ABSCISSES = [1, 10, 100, 1000, 10000, 20000];
const multiple = (graphique, valeurs) => ({
graphique,
nom: `Le ${graphique} au fil de la recherche`,
synthese: t('graphiques.synthese.pointsDeReleve'),
forme: 'points',
abscisses: ABSCISSES,
series: [{ libelle: graphique, valeurs }],
infobulle: {
colonnes: [
{ cle: 'mouvements', libelle: 'Mouvements', genre: 'nombre' },
{ cle: 'valeur', libelle: 'Valeur', genre: 'nombre' },
],
lignes: ABSCISSES.map((mouvements, i) => ({ mouvements, valeur: valeurs[i] })),
},
hauteur: 140,
});
test('rouge : un point aux seuls points de relevé ; le point pointé est désigné dans tous les petits multiples à la fois', async () => {
const partage = rangPartage();
const aligner = margesPartagees(2);
const a = await monter(Escalier, aligner(partage.avec(multiple('collisions', [9, 7, 4, 2, 1, 1])), 0), 700);
const b = await monter(Escalier, aligner(partage.avec(multiple('appartenances rencontrées', [3, 5, 8, 9, 10, 10])), 1), 700);
await tick();
flushSync();
for (const cible of [a, b]) expect(cible.querySelectorAll('[data-point-releve]')).toHaveLength(6);
// L'abscisse est commune : chaque point de relevé à la même abscisse.
const abscisses = [a, b].map((c) => [...c.querySelectorAll('[data-point-releve]')].map((p) => p.getAttribute('cx')));
expect(abscisses[1]).toEqual(abscisses[0]);
const point = a.querySelector('[data-point-releve="100"]');
const boite = point.getBoundingClientRect();
await pointer('bouger', boite.left + boite.width / 2 + 2, boite.top + boite.height / 2);
flushSync();
for (const cible of [a, b]) {
const designe = cible.querySelector('[data-rang]');
expect(designe?.getAttribute('data-rang')).toBe('3');
expect(designe.getAttribute('cx')).toBe(cible.querySelector('[data-point-releve="100"]').getAttribute('cx'));
}
expect(infobulleDe(a).hidden).toBe(false);
expect(infobulleDe(b).hidden).toBe(true);
});
test('rouge : l’infobulle écrit les nombres comme les axes, par format.nombre', async () => {
const cible = await monter(Escalier, multiple('collisions', [9, 7, 4, 2, 1, 1]), 700);
const boite = cible.querySelector('[data-point-releve="20000"]').getBoundingClientRect();
await pointer('bouger', boite.left + boite.width / 2, boite.top + boite.height / 2);
flushSync();
const bulle = infobulleDe(cible);
expect(bulle.hidden).toBe(false);
expect(bulle.textContent).toContain('20\u{202F}000');
expect(bulle.textContent).not.toContain('20000');
const graduations = [...cible.querySelectorAll('svg text')].map((x) => x.textContent);
expect(graduations).toContain('20\u{202F}000');
});
});
describe('graphiques : les barres (§ 12.4, § 12.13)', () => {
const LIGNES = [
{ id: 'aubier', nom: 'Coopérative Aubier', appartenance: 1, valeurs: [0, 3] },
{ id: 'long', nom: NOM_LONG, appartenance: null, valeurs: [2, 0] },
{ id: 'ressac', nom: 'Atelier Ressac', appartenance: 2, valeurs: [1, 1] },
];
const INFOBULLE = {
colonnes: [
{ cle: 'nom', libelle: 'Appartenance', genre: 'texte' },
{ cle: 'cumulees', libelle: 'Collisions cumulées', genre: 'nombre' },
],
lignes: LIGNES.map((l) => ({ nom: l.nom, cumulees: l.valeurs[0] + l.valeurs[1] })),
};
const props = (extra) => ({
graphique: 'collisions',
nom: 'Où les appartenances se retrouvent-elles ?',
synthese: 'Trois appartenances.',
lignes: LIGNES,
segments: [
{ genre: 'prouve', libelle: 'prouvé inévitable' },
{ genre: 'reste', libelle: 'reste' },
],
infobulle: INFOBULLE,
...extra,
});
test('rouge : un nom long se tronque sur le tracé, et reste entier dans l’infobulle et le jumeau', async () => {
const cible = await monter(Barres, props());
const ligne = cible.querySelector('[data-ligne-graphique="long"]');
const intitule = [...ligne.querySelectorAll('text')].find((x) => x.textContent.startsWith('Maëlle'));
expect(intitule.textContent).not.toBe(NOM_LONG);
expect(intitule.textContent.endsWith(t('graphiques.troncature'))).toBe(true);
expect(intitule.getComputedTextLength()).toBeLessThanOrEqual(LARGEUR_NOM_PX);
const boite = ligne.querySelector('[data-segment]').getBoundingClientRect();
await pointer('bouger', boite.left + 2, boite.top + boite.height / 2);
flushSync();
expect(infobulleDe(cible).hidden).toBe(false);
expect(infobulleDe(cible).textContent).toContain(NOM_LONG);
expect(cible.querySelector('[data-rang]').getAttribute('data-rang')).toBe('2');
const jumeau = await monter(Jumeau, { tableaux: [{ nom: 'Collisions', ...INFOBULLE }] });
jumeau.querySelector('button').click();
flushSync();
expect(jumeau.querySelector('[data-jumeau]').textContent).toContain(NOM_LONG);
});
test('rouge : le libellé de chaque segment se pose sur la première barre où il est non nul, et dans la légende', async () => {
const cible = await monter(Barres, props());
const svg = cible.querySelector('svg');
const textes = [...svg.querySelectorAll('text')];
for (const [libelle, id] of [['prouvé inévitable', 'long'], ['reste', 'aubier']]) {
const etiquette = textes.find((x) => x.textContent === libelle);
const barre = svg.querySelector(`[data-ligne-graphique="${id}"] [data-segment]`).getBoundingClientRect();
const boite = etiquette.getBoundingClientRect();
expect(boite.top, libelle).toBeGreaterThanOrEqual(barre.top - 1);
expect(boite.bottom, libelle).toBeLessThanOrEqual(barre.bottom + 1);
expect(cible.querySelector('ul').textContent).toContain(libelle);
}
for (const segment of svg.querySelectorAll('[data-segment]')) {
expect(Number(segment.getAttribute('width'))).toBeGreaterThan(0);
expect(attenuations(segment)).toEqual([]);
}
expect(svg.querySelectorAll('[data-segment="prouve"]')).toHaveLength(2);
});
test('rouge : chaque ligne porte ses autres chiffres en encre secondaire, après sa barre ; une ligne sans barre écrit « — »', async () => {
const vide = t('format.vide');
const cible = await monter(
Barres,
props({
lignes: [
{ ...LIGNES[0], secondaire: '7 personnes, 3 paires, excédent 2' },
{ ...LIGNES[1], secondaire: '4 personnes, 2 paires, excédent 0' },
{ id: 'sans', nom: 'Ondine Lacasse', valeurs: [0, 0], secondaire: vide },
],
infobulle: null,
}),
640,
);
const svg = cible.querySelector('svg');
const droite = svg.getBoundingClientRect().right;
const encre = couleurDuJeton('--couleur-page-texte-secondaire');
for (const [id, texte] of [['aubier', '7 personnes, 3 paires, excédent 2'], ['long', '4 personnes, 2 paires, excédent 0'], ['sans', vide]]) {
const ligne = svg.querySelector(`[data-ligne-graphique="${id}"]`);
const ecrit = [...ligne.querySelectorAll('text')].find((x) => x.textContent === texte);
expect(ecrit, id).toBeDefined();
expect(getComputedStyle(ecrit).fill, id).toBe(encre);
expect(attenuations(ecrit), id).toEqual([]);
const boite = ecrit.getBoundingClientRect();
expect(boite.right, id).toBeLessThanOrEqual(droite + 0.5);
for (const segment of ligne.querySelectorAll('[data-segment]')) {
expect(boite.left, id).toBeGreaterThanOrEqual(segment.getBoundingClientRect().right);
}
}
// Le libellé du segment « reste », posé sur la ligne d'Aubier, précède
// ses chiffres.
const reste = [...svg.querySelectorAll('text')].find((x) => x.textContent === 'reste').getBoundingClientRect();
const chiffres = [...svg.querySelectorAll('[data-ligne-graphique="aubier"] text')].find((x) => x.textContent.startsWith('7 personnes'));
expect(chiffres.getBoundingClientRect().left).toBeGreaterThanOrEqual(reste.right);
expect(svg.querySelectorAll('[data-ligne-graphique="sans"] [data-segment]')).toHaveLength(0);
});
test('deux colonnes juxtaposées, chacune son axe, les lignes dans le même ordre', async () => {
const cible = await monter(Barres, {
graphique: 'diversite',
nom: 'Quel animateur rencontre le moins de diversité ?',
synthese: 'Deux animateurs.',
lignes: [
{ id: 'iris', nom: 'Iris Ombrelle', valeurs: [2, 9] },
{ id: 'theo', nom: 'Théo Pervenche', valeurs: [4, 1] },
],
colonnes: [
{ genre: 'serie1', libelle: 'appartenances' },
{ genre: 'serie2', libelle: 'étiquettes' },
],
});
const svg = cible.querySelector('svg');
const lignes = [...svg.querySelectorAll('[data-ligne-graphique]')].map((g) => g.getAttribute('data-ligne-graphique'));
expect(lignes).toEqual(['iris', 'theo']);
const [premiere, seconde] = ['serie1', 'serie2'].map((g) => svg.querySelector(`[data-ligne-graphique="theo"] [data-segment="${g}"]`));
expect(premiere.getBoundingClientRect().right).toBeLessThan(seconde.getBoundingClientRect().left);
expect(svg.querySelectorAll('line[data-marque="graphiqueAxe"]')).toHaveLength(2);
});
});
describe('graphiques : la grille (§ 12.5, § 12.11, D33)', () => {
const PERSONNES = [{ libelle: 'Iris Ombrelle' }, { libelle: 'Théo Pervenche' }, { libelle: 'Basile Tourteau' }];
const VALEURS = [null, 1, 3, 1, null, 0, 3, 0, null];
const props = (extra) => ({
graphique: 'matrice',
nom: 'Qui a rencontré qui ?',
synthese: 'Une paire vue trois fois.',
lignes: PERSONNES,
colonnes: PERSONNES,
valeurs: VALEURS,
diagonale: true,
infobulle: {
colonnes: [{ cle: 'paire', libelle: 'Paire', genre: 'texte' }, { cle: 'fois', libelle: 'Fois', genre: 'nombre' }],
lignes: VALEURS.map((fois, i) => ({ paire: `${PERSONNES[Math.floor(i / 3)].libelle} et ${PERSONNES[i % 3].libelle}`, fois })),
},
...extra,
});
test('cases bordées, de leur niveau, le compte écrit de l’encre du niveau ; diagonale vide ; aucune case n’est une cible', async () => {
const cible = await monter(Grille, props());
const cases = cible.querySelectorAll('[data-case]');
expect(cases).toHaveLength(9);
const trois = cible.querySelector('[data-case="0:2"]');
expect(trois.querySelector('[data-marque="graphiqueNiveau3"]')).not.toBeNull();
// Les bordures : un seul <path>, un sous-chemin fermé par case,
// diagonale comprise ; le bord gauche de 0:2 est sur son trait, à
// RETRAIT_CASE_PX − 0,5 px à gauche de son remplissage.
const bordures = cible.querySelectorAll('[data-marque="graphiqueBordureCase"]');
expect(bordures).toHaveLength(1);
expect(bordures[0].getAttribute('d').match(/M/g)).toHaveLength(9);
const fond = trois.querySelector('[data-marque="graphiqueNiveau3"]');
const [x, y, cote] = ['x', 'y', 'height'].map((nom) => Number(fond.getAttribute(nom)));
const bord = new DOMPoint(x - 1.5, y + cote / 2);
expect(bordures[0].isPointInStroke(bord)).toBe(true);
expect(trois.querySelector('text').textContent).toBe('3');
expect(getComputedStyle(trois.querySelector('text')).fill).toBe(couleurDuJeton('--couleur-graphique-encre-claire'));
const diagonale = cible.querySelector('[data-case="1:1"]');
expect(diagonale.querySelector('text')?.textContent ?? '').toBe('');
expect(diagonale.querySelector('[data-marque^="graphiqueNiveau"]')).toBeNull();
for (const c of cases) {
expect(c.querySelector('[tabindex], [role="button"], a, button')).toBeNull();
expect(c.hasAttribute('tabindex')).toBe(false);
}
const zero = cible.querySelector('[data-case="1:2"]');
expect(zero.querySelector('[data-marque^="graphiqueNiveau"]')).toBeNull();
expect(zero.querySelector('text').textContent).toBe('0');
});
test('le pointage désigne la case ; la diagonale n’ouvre rien', async () => {
const cible = await monter(Grille, props());
const boite = cible.querySelector('[data-case="1:2"] rect').getBoundingClientRect();
await pointer('bouger', boite.left + boite.width / 2, boite.top + boite.height / 2);
flushSync();
expect(infobulleDe(cible).hidden).toBe(false);
expect(infobulleDe(cible).textContent).toContain('Théo Pervenche et Basile Tourteau');
// La case 1:1, vide : la colonne du compte de 0:1, la ligne de celui de
// 1:0.
const colonne = cible.querySelector('[data-case="0:1"] text').getBoundingClientRect();
const ligne = cible.querySelector('[data-case="1:0"] text').getBoundingClientRect();
await pointer('bouger', colonne.left + colonne.width / 2, ligne.top + ligne.height / 2);
flushSync();
expect(infobulleDe(cible).hidden).toBe(true);
});
test('une case sans compte écrit « — », sans couleur ; une grille trop étroite cède la place à sa phrase', async () => {
const cible = await monter(Grille, props({ diagonale: false }));
const vide = cible.querySelector('[data-case="0:0"]');
expect(vide.querySelector('text').textContent).toBe(t('format.vide'));
expect(vide.querySelector('[data-marque^="graphiqueNiveau"]')).toBeNull();
const etroite = await monter(Grille, props({ phrase: 'La grille ne tient pas dans la carte.' }), 60);
expect(etroite.querySelector('svg')).toBeNull();
expect(etroite.textContent).toContain('La grille ne tient pas dans la carte.');
});
});
describe('graphiques : le jumeau (§ 12.7, D44)', () => {
const TABLEAU = {
nom: 'Profil des rencontres',
colonnes: COLONNES_PROFIL,
lignes: [
{ rang: 1, nom: 'Iris Ombrelle', rencontres: 24, part: { texte: '88 %', compte: '24 sur 27' } },
{ rang: 2, nom: 'Théo Pervenche', rencontres: 0, part: { texte: null, compte: '0 sur 0' } },
],
};
test('rouge : ouvert au clavier, refermé au clavier, le focus revient à la commande, jamais sur body', async () => {
const cible = await monter(Jumeau, { tableaux: [TABLEAU] });
const avant = document.createElement('button');
avant.textContent = 'avant';
cible.prepend(avant);
avant.focus();
await touche('Tab');
const commande = document.activeElement;
expect(commande.getAttribute('data-commande')).toBe('ouvrirJumeau');
expect(commande.getAttribute('aria-expanded')).toBe('false');
expect(cible.querySelector('[data-jumeau]')).toBeNull();
await touche('Enter');
flushSync();
await tick();
expect(commande.getAttribute('aria-expanded')).toBe('true');
expect(commande.getAttribute('data-commande')).toBe('fermerJumeau');
expect(cible.querySelector('table[data-jumeau]')).not.toBeNull();
await touche('Enter');
flushSync();
await tick();
expect(cible.querySelector('[data-jumeau]')).toBeNull();
expect(document.activeElement).toBe(commande);
expect(document.activeElement).not.toBe(document.body);
expect(commande.getAttribute('aria-expanded')).toBe('false');
});
test('rouge : chaque cellule de pourcentage est suivie du compte de sa ligne ; « — » sans valeur', async () => {
const cible = await monter(Jumeau, { tableaux: [TABLEAU], ouvert: true });
const cellules = cible.querySelectorAll('td[data-colonne="part"]');
expect(cellules).toHaveLength(2);
const lus = [...cellules].map((c) => [c.querySelector('[data-pourcentage]').textContent, c.querySelector('[data-pourcentage] + [data-compte-pire]').textContent]);
expect(lus).toEqual([
['88 %', '24 sur 27'],
[t('format.vide'), '0 sur 0'],
]);
expect(cible.querySelector('th[scope="row"]').textContent).toBe('1');
});
test('rouge : une cellule de nombre s’écrit par format.nombre, aux décimales de sa colonne', async () => {
const tableau = {
nom: 'Course',
colonnes: [
{ cle: 'nom', libelle: 'Personne', genre: 'texte' },
{ cle: 'mouvements', libelle: 'Mouvements', genre: 'nombre' },
{ cle: 'taux', libelle: 'Taux de diversité', genre: 'nombre', decimales: 2 },
],
lignes: [{ nom: 'Basile Tourteau', mouvements: 20000, taux: 0.75 }],
};
const cible = await monter(Jumeau, { tableaux: [tableau], ouvert: true });
expect(cible.querySelector('td[data-colonne="mouvements"]').textContent.trim()).toBe('20\u{202F}000');
expect(cible.querySelector('td[data-colonne="taux"]').textContent.trim()).toBe('0,75');
});
});
describe('graphiques : le pourcentage et son compte (D34, contrainte 44)', () => {
test('rouge : le compte se rend juste sous le pourcentage ; « — » quand le pourcentage n’a pas de valeur', async () => {
const cible = await monter(Pourcentage, { texte: '88 %', compte: 'minimum 24 sur 27' });
const valeur = cible.querySelector('[data-pourcentage]');
const compte = cible.querySelector('[data-pourcentage] + [data-compte-pire]');
expect(valeur.textContent).toBe('88 %');
expect(compte.textContent).toBe('minimum 24 sur 27');
expect(compte.getBoundingClientRect().top).toBeGreaterThanOrEqual(valeur.getBoundingClientRect().bottom - 0.5);
const vide = await monter(Pourcentage, { texte: null, compte: '0 sur 0' });
expect(vide.querySelector('[data-pourcentage]').textContent).toBe(t('format.vide'));
expect(vide.querySelector('[data-compte-pire]').textContent).toBe('0 sur 0');
});
});
describe('graphiques : les marques mesurées dans les deux thèmes (§ 14.5)', () => {
test('chaque marque de graphique rendue est au registre, peinte de son jeton, à 3:1 de ce qui l’entoure', async () => {
const racine = document.createElement('div');
racine.style.background = 'var(--couleur-carte-fond)';
const montages = [
await monter(Escalier, propsProfil(12)),
await monter(Barres, {
graphique: 'collisions',
nom: 'Collisions',
synthese: 'Deux.',
lignes: [{ id: 'a', nom: 'Atelier Ressac', appartenance: 1, valeurs: [2, 1] }],
segments: [
{ genre: 'prouve', libelle: 'prouvé inévitable' },
{ genre: 'reste', libelle: 'reste' },
],
}),
await monter(Grille, {
graphique: 'grille',
nom: 'Grille',
synthese: 'Quatre niveaux.',
lignes: [{ libelle: 'Table 1' }, { libelle: 'Table 2' }],
colonnes: [{ libelle: 'Tour 1' }, { libelle: 'Tour 2' }],
valeurs: [0, 1, 2, 3],
}),
];
for (const m of montages) racine.append(m);
document.body.prepend(racine);
try {
for (const theme of ['clair', 'sombre']) {
const releve = await sousTheme(theme, () => releverMarques(racine));
console.log(`contrastes ${theme} : ${releve.ratios.map((r) => `${r.marque} ${r.ratio.toFixed(2)}`).join(', ')}`);
expect(releve.fautes, `${theme} :\n${releve.fautes.join('\n')}`).toEqual([]);
expect(releve.attenuations, theme).toEqual([]);
expect(releve.mesures, theme).toBeGreaterThan(0);
}
} finally {
racine.remove();
}
});
});
describe('graphiques : le dessin différé et les mesures de la séance (E2s)', () => {
// Une zone qui défile de 400 px de haut, posée en tête du document : un
// graphique, puis un intervalle de 2 000 px, puis un second, puis fin, un
// élément vide. Chacun est monté sous les contextes de App, et sous
// dessinDiffere quand differe est vrai, comme la page de qualité les
// monte ; registre est ce contexte, null sans lui.
async function monterDansUneZone(premier, second, { differe = true } = {}) {
const zone = document.createElement('div');
zone.style.cssText = 'width: 800px; height: 400px; overflow-y: auto;';
const intervalle = document.createElement('div');
intervalle.style.height = '2000px';
const cibles = [document.createElement('div'), document.createElement('div')];
const fin = document.createElement('div');
zone.append(cibles[0], intervalle, cibles[1], fin);
document.body.prepend(zone);
const registre = differe ? creerDessinDiffere() : null;
const context = new Map([...CONTEXTE, ...(differe ? [['dessinDiffere', registre]] : [])]);
for (const [i, [Composant, props]] of [premier, second].entries()) {
const composant = mount(Composant, { target: cibles[i], props, context });
montes.push({ composant, cible: i === 0 ? zone : cibles[i] });
}
flushSync();
await tick();
flushSync();
return { zone, fin, registre, svgs: cibles.map((cible) => cible.querySelector('svg[data-graphique]')) };
}
const image = () => new Promise((resoudre) => requestAnimationFrame(resoudre));
test('sous dessinDiffere : le graphique visible se dessine au montage, l’autre attend, nommé et décrit, et se dessine en approchant ; leurs marges sont communes dès le montage', async () => {
const partager = margesPartagees(2);
const props = [partager(propsProfil(12, { graphique: 'haut' }), 0), partager(propsProfil(12, { graphique: 'bas' }), 1)];
const { zone, svgs } = await monterDansUneZone([Escalier, props[0]], [Escalier, props[1]]);
const [haut, bas] = svgs;
expect(haut.hasAttribute('data-dessin-differe')).toBe(false);
expect(haut.querySelector('[data-marque]')).not.toBeNull();
expect(bas.hasAttribute('data-dessin-differe')).toBe(true);
expect(bas.querySelector('[data-marque]')).toBeNull();
expect(document.getElementById(bas.getAttribute('aria-labelledby')).textContent).toBe('Qui rencontre le moins de monde ?');
expect(document.getElementById(bas.getAttribute('aria-describedby')).textContent).toBe('Minimum 10 rencontres, une personne.');
// Les marges du second se mesurent sans son dessin : le premier est
// aligné sur elles avant que le second ne se dessine, et ne bouge pas
// quand il se dessine.
expect(props[1].marges).not.toBeNull();
const avant = surfaceDe(haut).getAttribute('x');
bas.scrollIntoView({ block: 'center' });
for (let n = 0; n < 60 && bas.hasAttribute('data-dessin-differe'); n += 1) await image();
expect(bas.hasAttribute('data-dessin-differe')).toBe(false);
expect(bas.querySelector('[data-marque]')).not.toBeNull();
expect(surfaceDe(haut).getAttribute('x')).toBe(avant);
expect(surfaceDe(bas).getAttribute('x')).toBe(avant);
// Remonté, le second reste dessiné.
zone.scrollTo(0, 0);
await image();
await image();
expect(bas.querySelector('[data-marque]')).not.toBeNull();
});
test('en attente, Barres et Grille réservent la hauteur de leur dessin, chaque graphique porte déjà sa légende ; le registre dessine ce qui précède une cible, rien de ce qui la suit', async () => {
const barres = {
graphique: 'collisions',
nom: 'Collisions',
synthese: 'Trois appartenances.',
lignes: [
{ id: 'aubier', nom: 'Coopérative Aubier', appartenance: 1, valeurs: [0, 3] },
{ id: 'long', nom: NOM_LONG, appartenance: null, valeurs: [2, 0] },
{ id: 'ressac', nom: 'Atelier Ressac', appartenance: 2, valeurs: [1, 1] },
],
segments: [
{ genre: 'prouve', libelle: 'prouvé inévitable' },
{ genre: 'reste', libelle: 'reste' },
],
};
const colonnes = {
...barres,
graphique: 'diversite',
segments: undefined,
colonnes: [
{ genre: 'serie1', libelle: 'Appartenances' },
{ genre: 'serie2', libelle: 'Personnes' },
],
};
const grille = (extra) => ({
graphique: 'tables',
nom: 'Tables',
synthese: 'Quatre niveaux.',
lignes: [{ libelle: 'Tour 1' }, { libelle: 'Tour 2' }],
colonnes: [{ libelle: 'Table 1' }, { libelle: 'Table 2' }, { libelle: 'Table 3' }],
valeurs: [0, 1, 2, 3, null, 1],
...extra,
});
const cas = [
['barres empilées', Barres, barres],
['barres juxtaposées', Barres, colonnes],
['grille bornée', Grille, grille({ coteMaxTextes: 3 })],
['grille, intitulés tournés', Grille, grille({ colonnes: [{ libelle: 'Une table au nom très long' }, { libelle: 'Deux' }, { libelle: 'Trois' }], coteMaxTextes: 2 })],
['grille sans borne', Grille, grille()],
['escalier', Escalier, propsProfil(12, { graphique: 'bas' })],
];
for (const [quoi, Composant, props] of cas) {
const { svgs, registre, fin } = await monterDansUneZone([Escalier, propsProfil(12, { graphique: 'haut' })], [Composant, props]);
const [haut, bas] = svgs;
const cadre = bas.parentElement;
const releve = () => ({
svg: bas.getAttribute('height'),
cadre: cadre.getBoundingClientRect().height,
legende: [...cadre.querySelectorAll('ul li')].map((li) => li.textContent),
});
expect(bas.hasAttribute('data-dessin-differe'), quoi).toBe(true);
expect(bas.querySelector('[data-marque]'), quoi).toBeNull();
const avant = releve();
expect(Number(avant.svg), quoi).toBeGreaterThan(0);
if (quoi === 'barres empilées' || quoi === 'escalier') expect(avant.legende.length, quoi).toBeGreaterThan(0);
// Le graphique en attente suit la cible haut : il reste en attente.
registre.dessinerAvant(haut);
flushSync();
expect(bas.hasAttribute('data-dessin-differe'), quoi).toBe(true);
registre.dessinerAvant(fin);
flushSync();
expect(bas.hasAttribute('data-dessin-differe'), quoi).toBe(false);
expect(bas.querySelector('[data-marque]'), quoi).not.toBeNull();
expect(releve(), quoi).toEqual(avant);
for (const { composant, cible } of montes.splice(0)) {
unmount(composant);
cible.remove();
}
}
});
test('hors de dessinDiffere, un graphique loin de la zone visible se dessine au montage', async () => {
const haut = [Escalier, propsProfil(12, { graphique: 'haut' })];
const bas = [Escalier, propsProfil(12, { graphique: 'bas' })];
const { svgs } = await monterDansUneZone(haut, bas, { differe: false });
for (const svg of svgs) {
expect(svg.hasAttribute('data-dessin-differe')).toBe(false);
expect(svg.querySelector('[data-marque]')).not.toBeNull();
}
});
// Un <svg> sans viewBox au style des textes de graphique, une sonde, et le
// compte des appels de getComputedTextLength et des relevés (after) que
// faire() provoque.
function sousSonde(faire) {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.style.fontSize = '13px';
const sonde = document.createElementNS('http://www.w3.org/2000/svg', 'text');
svg.append(sonde);
document.body.prepend(svg);
const longueur = SVGTextContentElement.prototype.getComputedTextLength;
const apres = Element.prototype.after;
const comptes = { longueurs: 0, releves: 0 };
SVGTextContentElement.prototype.getComputedTextLength = function compter() {
comptes.longueurs += 1;
return longueur.call(this);
};
Element.prototype.after = function compter(...noeuds) {
comptes.releves += 1;
return apres.apply(this, noeuds);
};
try {
return { rendu: faire(sonde), comptes };
} finally {
SVGTextContentElement.prototype.getComputedTextLength = longueur;
Element.prototype.after = apres;
svg.remove();
}
}
test('mesuresDuTexte : les largeurs de creerMesureur ; un relevé par lot, calcul relu jusqu’à ne rien demander d’inconnu ; gardées pour la séance, oubliées à la fin d’un chargement de police', () => {
document.fonts.dispatchEvent(new Event('loadingdone'));
const chaines = ['Iris Ombrelle', 'Théo Pervenche', '27', ''];
const temoins = sousSonde((sonde) => chaines.map(creerMesureur(sonde))).rendu;
// Le calcul demande d'abord trois chaînes, puis une quatrième qui
// dépend de la largeur de la première : deux lots, trois passages.
let passages = 0;
const calcul = (mesurer) => {
passages += 1;
const premieres = chaines.slice(0, 3).map(mesurer);
return premieres[0] > 0 ? [...premieres, mesurer(chaines[3])] : premieres;
};
const premier = sousSonde((sonde) => mesuresDuTexte(sonde).calculer(calcul));
expect(premier.rendu).toEqual(temoins);
expect(passages).toBe(3);
expect(premier.comptes).toEqual({ longueurs: 4, releves: 2 });
// Une autre sonde du même style : tout vient de la séance.
passages = 0;
const second = sousSonde((sonde) => mesuresDuTexte(sonde).calculer(calcul));
expect(second.rendu).toEqual(temoins);
expect(passages).toBe(1);
expect(second.comptes).toEqual({ longueurs: 0, releves: 0 });
// Une police chargée : les largeurs se relèvent.
document.fonts.dispatchEvent(new Event('loadingdone'));
const releve = sousSonde((sonde) => mesuresDuTexte(sonde).calculer(calcul));
expect(releve.rendu).toEqual(temoins);
expect(releve.comptes.longueurs).toBe(4);
});
test('mesuresDuTexte : sous un ancêtre caché, une largeur nulle vaut 0 pour le calcul, ne se garde pas, et le calcul finit', () => {
document.fonts.dispatchEvent(new Event('loadingdone'));
const { rendu, comptes } = sousSonde((sonde) => {
sonde.parentNode.style.display = 'none';
const mesures = mesuresDuTexte(sonde);
const cache = mesures.calculer((mesurer) => mesurer('Ondine Lacasse'));
sonde.parentNode.style.display = '';
return [cache, mesures.mesurer('Ondine Lacasse')];
});
expect(rendu[0]).toBe(0);
expect(rendu[1]).toBeGreaterThan(0);
expect(comptes.longueurs).toBe(2);
});
});