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

744 lines
35 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 { 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 { 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();
expect(trois.querySelector('[data-marque="graphiqueBordureCase"]')).not.toBeNull();
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"] [data-marque="graphiqueBordureCase"]').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');
const diag = cible.querySelector('[data-case="1:1"] [data-marque="graphiqueBordureCase"]').getBoundingClientRect();
await pointer('bouger', diag.left + diag.width / 2, diag.top + diag.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();
}
});
});