[ADD] quality page base: Qualité tab, detail, charts, course companion
The quality page gets its section, its data and its building blocks.
The engine details a placement (percentages floored, worst case as a
count, three table-mix counts with their orders, per-round floors); a
Qualité tab, loaded as its own chunk, follows the proposition the plan
shows; charts and their twin tables share one component set. The search
course goes to a companion file written only when propositions change,
keyed by proposition identity, timestamp and placement fingerprint, so a
gesture costs what it did and a stale course never reaches the page.
Checked: node 4929, navigateur 1016, node-long 128; main chunk 689 ko.
--- FR ---
[ADD] base de la qualité : onglet, détail, graphiques, compagnon
La page de qualité reçoit sa section, ses données et ses briques. Le
moteur détaille un placement (pourcentages arrondis vers le bas, pire cas
en compte, trois comptes du mélange par table et leurs ordres, planchers
par tour) ; un onglet Qualité, chargé à part, suit la proposition que
montre le plan ; graphiques et tableaux jumeaux partagent leurs
composants. La course de la recherche va dans un fichier compagnon écrit
quand les propositions changent, clé : identité, horodatage, empreinte du
placement ; un geste coûte ce qu'il coûtait, aucune course périmée.
Vérifié : node 4929, navigateur 1016, longues 128 ; entrée à 689 ko.
Assisted-by: Claude Opus 5.5
2026-10-12 07:42:34 -04:00
// © 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' ;
[IMP] quality page: deferred charts, cached text, hash-free gestures
Opening the quality tab measured text and forced layout per chart, and every
gesture rehashed each placement because a cloned load lost its fingerprints.
A gesture now inherits the fingerprint of an unchanged placement; charts far
from view draw on approach, holding their drawn height and their legend, so
a link or a scroll lands where the fully drawn page puts it.
Checked, 200 propositions: tab opening 84.5 -> 27.1 ms (limit 50), 4x CPU
370.7 -> 113.9 ms, 1,973 -> 485 elements, a gesture back at its prior cost.
Every memePlacement mutant fails its own test; 44 captures byte-identical.
--- FR ---
[IMP] page qualité : graphiques différés, texte en cache, gestes légers
L'onglet Qualité mesurait le texte et forçait la mise en page à chaque
graphique, et chaque geste rehachait chaque placement, la charge clonée
perdant ses empreintes. Un geste hérite de l'empreinte d'un placement
inchangé ; un graphique loin de la vue se dessine à l'approche, sa hauteur
et sa légende déjà posées : un lien ou un défilement arrive où le met la
page entièrement dessinée.
Vérifié, 200 propositions : ouverture 84,5 -> 27,1 ms (limite 50), CPU 4x
370,7 -> 113,9 ms, 1 973 -> 485 éléments, un geste revenu à son coût.
Chaque mutant de memePlacement fait échouer son test ; 44 captures identiques.
Assisted-by: Claude Opus 5.5
2026-10-12 22:31:46 -04:00
import { creerMesureur , mesuresDuTexte } from '../../plan/mesure_texte.js' ;
[ADD] quality page base: Qualité tab, detail, charts, course companion
The quality page gets its section, its data and its building blocks.
The engine details a placement (percentages floored, worst case as a
count, three table-mix counts with their orders, per-round floors); a
Qualité tab, loaded as its own chunk, follows the proposition the plan
shows; charts and their twin tables share one component set. The search
course goes to a companion file written only when propositions change,
keyed by proposition identity, timestamp and placement fingerprint, so a
gesture costs what it did and a stale course never reaches the page.
Checked: node 4929, navigateur 1016, node-long 128; main chunk 689 ko.
--- FR ---
[ADD] base de la qualité : onglet, détail, graphiques, compagnon
La page de qualité reçoit sa section, ses données et ses briques. Le
moteur détaille un placement (pourcentages arrondis vers le bas, pire cas
en compte, trois comptes du mélange par table et leurs ordres, planchers
par tour) ; un onglet Qualité, chargé à part, suit la proposition que
montre le plan ; graphiques et tableaux jumeaux partagent leurs
composants. La course de la recherche va dans un fichier compagnon écrit
quand les propositions changent, clé : identité, horodatage, empreinte du
placement ; un geste coûte ce qu'il coûtait, aucune course périmée.
Vérifié : node 4929, navigateur 1016, longues 128 ; entrée à 689 ko.
Assisted-by: Claude Opus 5.5
2026-10-12 07:42:34 -04:00
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' ;
[IMP] quality page: deferred charts, cached text, hash-free gestures
Opening the quality tab measured text and forced layout per chart, and every
gesture rehashed each placement because a cloned load lost its fingerprints.
A gesture now inherits the fingerprint of an unchanged placement; charts far
from view draw on approach, holding their drawn height and their legend, so
a link or a scroll lands where the fully drawn page puts it.
Checked, 200 propositions: tab opening 84.5 -> 27.1 ms (limit 50), 4x CPU
370.7 -> 113.9 ms, 1,973 -> 485 elements, a gesture back at its prior cost.
Every memePlacement mutant fails its own test; 44 captures byte-identical.
--- FR ---
[IMP] page qualité : graphiques différés, texte en cache, gestes légers
L'onglet Qualité mesurait le texte et forçait la mise en page à chaque
graphique, et chaque geste rehachait chaque placement, la charge clonée
perdant ses empreintes. Un geste hérite de l'empreinte d'un placement
inchangé ; un graphique loin de la vue se dessine à l'approche, sa hauteur
et sa légende déjà posées : un lien ou un défilement arrive où le met la
page entièrement dessinée.
Vérifié, 200 propositions : ouverture 84,5 -> 27,1 ms (limite 50), CPU 4x
370,7 -> 113,9 ms, 1 973 -> 485 éléments, un geste revenu à son coût.
Chaque mutant de memePlacement fait échouer son test ; 44 captures identiques.
Assisted-by: Claude Opus 5.5
2026-10-12 22:31:46 -04:00
import { creerDessinDiffere } from './dessin_differe.js' ;
[ADD] quality page base: Qualité tab, detail, charts, course companion
The quality page gets its section, its data and its building blocks.
The engine details a placement (percentages floored, worst case as a
count, three table-mix counts with their orders, per-round floors); a
Qualité tab, loaded as its own chunk, follows the proposition the plan
shows; charts and their twin tables share one component set. The search
course goes to a companion file written only when propositions change,
keyed by proposition identity, timestamp and placement fingerprint, so a
gesture costs what it did and a stale course never reaches the page.
Checked: node 4929, navigateur 1016, node-long 128; main chunk 689 ko.
--- FR ---
[ADD] base de la qualité : onglet, détail, graphiques, compagnon
La page de qualité reçoit sa section, ses données et ses briques. Le
moteur détaille un placement (pourcentages arrondis vers le bas, pire cas
en compte, trois comptes du mélange par table et leurs ordres, planchers
par tour) ; un onglet Qualité, chargé à part, suit la proposition que
montre le plan ; graphiques et tableaux jumeaux partagent leurs
composants. La course de la recherche va dans un fichier compagnon écrit
quand les propositions changent, clé : identité, horodatage, empreinte du
placement ; un geste coûte ce qu'il coûtait, aucune course périmée.
Vérifié : node 4929, navigateur 1016, longues 128 ; entrée à 689 ko.
Assisted-by: Claude Opus 5.5
2026-10-12 07:42:34 -04:00
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 ( ) ;
[IMP] quality page: deferred charts, cached text, hash-free gestures
Opening the quality tab measured text and forced layout per chart, and every
gesture rehashed each placement because a cloned load lost its fingerprints.
A gesture now inherits the fingerprint of an unchanged placement; charts far
from view draw on approach, holding their drawn height and their legend, so
a link or a scroll lands where the fully drawn page puts it.
Checked, 200 propositions: tab opening 84.5 -> 27.1 ms (limit 50), 4x CPU
370.7 -> 113.9 ms, 1,973 -> 485 elements, a gesture back at its prior cost.
Every memePlacement mutant fails its own test; 44 captures byte-identical.
--- FR ---
[IMP] page qualité : graphiques différés, texte en cache, gestes légers
L'onglet Qualité mesurait le texte et forçait la mise en page à chaque
graphique, et chaque geste rehachait chaque placement, la charge clonée
perdant ses empreintes. Un geste hérite de l'empreinte d'un placement
inchangé ; un graphique loin de la vue se dessine à l'approche, sa hauteur
et sa légende déjà posées : un lien ou un défilement arrive où le met la
page entièrement dessinée.
Vérifié, 200 propositions : ouverture 84,5 -> 27,1 ms (limite 50), CPU 4x
370,7 -> 113,9 ms, 1 973 -> 485 éléments, un geste revenu à son coût.
Chaque mutant de memePlacement fait échouer son test ; 44 captures identiques.
Assisted-by: Claude Opus 5.5
2026-10-12 22:31:46 -04:00
// 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 ) ;
[ADD] quality page base: Qualité tab, detail, charts, course companion
The quality page gets its section, its data and its building blocks.
The engine details a placement (percentages floored, worst case as a
count, three table-mix counts with their orders, per-round floors); a
Qualité tab, loaded as its own chunk, follows the proposition the plan
shows; charts and their twin tables share one component set. The search
course goes to a companion file written only when propositions change,
keyed by proposition identity, timestamp and placement fingerprint, so a
gesture costs what it did and a stale course never reaches the page.
Checked: node 4929, navigateur 1016, node-long 128; main chunk 689 ko.
--- FR ---
[ADD] base de la qualité : onglet, détail, graphiques, compagnon
La page de qualité reçoit sa section, ses données et ses briques. Le
moteur détaille un placement (pourcentages arrondis vers le bas, pire cas
en compte, trois comptes du mélange par table et leurs ordres, planchers
par tour) ; un onglet Qualité, chargé à part, suit la proposition que
montre le plan ; graphiques et tableaux jumeaux partagent leurs
composants. La course de la recherche va dans un fichier compagnon écrit
quand les propositions changent, clé : identité, horodatage, empreinte du
placement ; un geste coûte ce qu'il coûtait, aucune course périmée.
Vérifié : node 4929, navigateur 1016, longues 128 ; entrée à 689 ko.
Assisted-by: Claude Opus 5.5
2026-10-12 07:42:34 -04:00
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 ( ) ) ;
[IMP] quality page: deferred charts, cached text, hash-free gestures
Opening the quality tab measured text and forced layout per chart, and every
gesture rehashed each placement because a cloned load lost its fingerprints.
A gesture now inherits the fingerprint of an unchanged placement; charts far
from view draw on approach, holding their drawn height and their legend, so
a link or a scroll lands where the fully drawn page puts it.
Checked, 200 propositions: tab opening 84.5 -> 27.1 ms (limit 50), 4x CPU
370.7 -> 113.9 ms, 1,973 -> 485 elements, a gesture back at its prior cost.
Every memePlacement mutant fails its own test; 44 captures byte-identical.
--- FR ---
[IMP] page qualité : graphiques différés, texte en cache, gestes légers
L'onglet Qualité mesurait le texte et forçait la mise en page à chaque
graphique, et chaque geste rehachait chaque placement, la charge clonée
perdant ses empreintes. Un geste hérite de l'empreinte d'un placement
inchangé ; un graphique loin de la vue se dessine à l'approche, sa hauteur
et sa légende déjà posées : un lien ou un défilement arrive où le met la
page entièrement dessinée.
Vérifié, 200 propositions : ouverture 84,5 -> 27,1 ms (limite 50), CPU 4x
370,7 -> 113,9 ms, 1 973 -> 485 éléments, un geste revenu à son coût.
Chaque mutant de memePlacement fait échouer son test ; 44 captures identiques.
Assisted-by: Claude Opus 5.5
2026-10-12 22:31:46 -04:00
const boite = cible . querySelector ( '[data-case="1:2"] rect' ) . getBoundingClientRect ( ) ;
[ADD] quality page base: Qualité tab, detail, charts, course companion
The quality page gets its section, its data and its building blocks.
The engine details a placement (percentages floored, worst case as a
count, three table-mix counts with their orders, per-round floors); a
Qualité tab, loaded as its own chunk, follows the proposition the plan
shows; charts and their twin tables share one component set. The search
course goes to a companion file written only when propositions change,
keyed by proposition identity, timestamp and placement fingerprint, so a
gesture costs what it did and a stale course never reaches the page.
Checked: node 4929, navigateur 1016, node-long 128; main chunk 689 ko.
--- FR ---
[ADD] base de la qualité : onglet, détail, graphiques, compagnon
La page de qualité reçoit sa section, ses données et ses briques. Le
moteur détaille un placement (pourcentages arrondis vers le bas, pire cas
en compte, trois comptes du mélange par table et leurs ordres, planchers
par tour) ; un onglet Qualité, chargé à part, suit la proposition que
montre le plan ; graphiques et tableaux jumeaux partagent leurs
composants. La course de la recherche va dans un fichier compagnon écrit
quand les propositions changent, clé : identité, horodatage, empreinte du
placement ; un geste coûte ce qu'il coûtait, aucune course périmée.
Vérifié : node 4929, navigateur 1016, longues 128 ; entrée à 689 ko.
Assisted-by: Claude Opus 5.5
2026-10-12 07:42:34 -04:00
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' ) ;
[IMP] quality page: deferred charts, cached text, hash-free gestures
Opening the quality tab measured text and forced layout per chart, and every
gesture rehashed each placement because a cloned load lost its fingerprints.
A gesture now inherits the fingerprint of an unchanged placement; charts far
from view draw on approach, holding their drawn height and their legend, so
a link or a scroll lands where the fully drawn page puts it.
Checked, 200 propositions: tab opening 84.5 -> 27.1 ms (limit 50), 4x CPU
370.7 -> 113.9 ms, 1,973 -> 485 elements, a gesture back at its prior cost.
Every memePlacement mutant fails its own test; 44 captures byte-identical.
--- FR ---
[IMP] page qualité : graphiques différés, texte en cache, gestes légers
L'onglet Qualité mesurait le texte et forçait la mise en page à chaque
graphique, et chaque geste rehachait chaque placement, la charge clonée
perdant ses empreintes. Un geste hérite de l'empreinte d'un placement
inchangé ; un graphique loin de la vue se dessine à l'approche, sa hauteur
et sa légende déjà posées : un lien ou un défilement arrive où le met la
page entièrement dessinée.
Vérifié, 200 propositions : ouverture 84,5 -> 27,1 ms (limite 50), CPU 4x
370,7 -> 113,9 ms, 1 973 -> 485 éléments, un geste revenu à son coût.
Chaque mutant de memePlacement fait échouer son test ; 44 captures identiques.
Assisted-by: Claude Opus 5.5
2026-10-12 22:31:46 -04:00
// 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 ) ;
[ADD] quality page base: Qualité tab, detail, charts, course companion
The quality page gets its section, its data and its building blocks.
The engine details a placement (percentages floored, worst case as a
count, three table-mix counts with their orders, per-round floors); a
Qualité tab, loaded as its own chunk, follows the proposition the plan
shows; charts and their twin tables share one component set. The search
course goes to a companion file written only when propositions change,
keyed by proposition identity, timestamp and placement fingerprint, so a
gesture costs what it did and a stale course never reaches the page.
Checked: node 4929, navigateur 1016, node-long 128; main chunk 689 ko.
--- FR ---
[ADD] base de la qualité : onglet, détail, graphiques, compagnon
La page de qualité reçoit sa section, ses données et ses briques. Le
moteur détaille un placement (pourcentages arrondis vers le bas, pire cas
en compte, trois comptes du mélange par table et leurs ordres, planchers
par tour) ; un onglet Qualité, chargé à part, suit la proposition que
montre le plan ; graphiques et tableaux jumeaux partagent leurs
composants. La course de la recherche va dans un fichier compagnon écrit
quand les propositions changent, clé : identité, horodatage, empreinte du
placement ; un geste coûte ce qu'il coûtait, aucune course périmée.
Vérifié : node 4929, navigateur 1016, longues 128 ; entrée à 689 ko.
Assisted-by: Claude Opus 5.5
2026-10-12 07:42:34 -04:00
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 ( ) ;
}
} ) ;
} ) ;
[IMP] quality page: deferred charts, cached text, hash-free gestures
Opening the quality tab measured text and forced layout per chart, and every
gesture rehashed each placement because a cloned load lost its fingerprints.
A gesture now inherits the fingerprint of an unchanged placement; charts far
from view draw on approach, holding their drawn height and their legend, so
a link or a scroll lands where the fully drawn page puts it.
Checked, 200 propositions: tab opening 84.5 -> 27.1 ms (limit 50), 4x CPU
370.7 -> 113.9 ms, 1,973 -> 485 elements, a gesture back at its prior cost.
Every memePlacement mutant fails its own test; 44 captures byte-identical.
--- FR ---
[IMP] page qualité : graphiques différés, texte en cache, gestes légers
L'onglet Qualité mesurait le texte et forçait la mise en page à chaque
graphique, et chaque geste rehachait chaque placement, la charge clonée
perdant ses empreintes. Un geste hérite de l'empreinte d'un placement
inchangé ; un graphique loin de la vue se dessine à l'approche, sa hauteur
et sa légende déjà posées : un lien ou un défilement arrive où le met la
page entièrement dessinée.
Vérifié, 200 propositions : ouverture 84,5 -> 27,1 ms (limite 50), CPU 4x
370,7 -> 113,9 ms, 1 973 -> 485 éléments, un geste revenu à son coût.
Chaque mutant de memePlacement fait échouer son test ; 44 captures identiques.
Assisted-by: Claude Opus 5.5
2026-10-12 22:31:46 -04:00
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 ) ;
} ) ;
} ) ;