// © 2026 TechnoLibre (http://www.technolibre.ca) // License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) // Épreuves du dessin du plan dans un vrai moteur de rendu (§ 7.1, § 7.3 à // § 7.7, § 13.3, § 13.5, § 17 point 6). PlanDessin se monte dans un d'épreuve, sans viewBox, dont le groupe [data-vue] porte // translate(tx,ty) scale(k) ; BandeauPlan et EchellePlan dans la page. Les // épreuves lisent le style calculé, la boîte et l'élément qu'atteint un // point, jamais la présence d'une classe, et atteignent les éléments par // leurs attributs data-…. Une démonstration livrée se lit, et ses variantes // se dérivent, en mémoire depuis texteLivre ; les noms d'épreuve sont // inventés. // // Le projet navigateur emploie expect, et test et describe de Vitest // (§ 14.8). import { afterEach, beforeAll, describe, expect, test } from 'vitest'; import { flushSync, mount, unmount } from 'svelte'; import '../jetons.css'; import { creerFormat } from '../../application/format.js'; import { TABLES_APPLICATION } from '../../application/libelles.js'; import { etatPlaces } from '../../application/places.js'; import { creerTraduction } from '../../application/traduction.js'; import { PLAN_PARFAIT_PETITE } from '../../demo/catalogue.js'; import { texteLivre } from '../../demo/livrees.js'; import { constante } from '../../geometrie/constantes.js'; import { designer } from '../../geometrie/designation.js'; import { echelleGraphique } from '../../geometrie/echelle.js'; import { GABARIT_HAUTEUR_LIGNE } from '../../geometrie/texte.js'; import { dessinDepuisEcran, ecranDepuisDessin, transformDe } from '../../geometrie/vue.js'; import { rechercher } from '../../moteur/recherche.js'; import { analyser, configurationDepuisCharge, creerCharge } from '../../stockage/document.js'; import { versFichier } from '../../stockage/placements.js'; import { fixerCadre } from '../../../test/navigateur/entrees.js'; import { composantes, couleursPeintes, sousTheme, visible } from '../../../test/navigateur/rendu.js'; import TEXTE_TOUTES_EN_CONFLIT from '../../../test/fixtures/plan/toutes_en_conflit.gtt.json?raw'; import { TABLES_INTERFACE } from '../libelles.js'; import BandeauPlan from './BandeauPlan.svelte'; import EchellePlan from './EchellePlan.svelte'; import { LARGEUR_ECHELLE_PX, modeleDessin } from './modele.js'; import PlanDessin from './PlanDessin.svelte'; const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]); const CONTEXTE = new Map([ ['t', t], ['format', creerFormat(t)], ]); const SVG = 'http://www.w3.org/2000/svg'; const K_LISTES = constante('K_LISTES'); const LARGEUR = constante('LARGEUR_BLOC_NOMS_CM'); const MARQUE = t('plan.liste.abreviation'); const MARQUES_DE_CHAISE = ['cadenasFerme', 'cadenasOuvert', 'anneau']; // Les montages de l'épreuve courante : composant et conteneur. const montes = []; beforeAll(async () => { await fixerCadre(1366, 700); }); // Démonte tout ce que l'épreuve courante a monté. function demonterTout() { for (const { composant, conteneur } of montes.splice(0)) { unmount(composant); conteneur.remove(); } } afterEach(demonterTout); // --- Montage --------------------------------------------------------------- /** Monte PlanDessin dans un d'épreuve de la taille de la * fenêtre, au début du corps, sous la vue donnée ; caché, le conteneur est * en display: none, et conteneur.style.display = '' le montre. */ function monterPlan(modele, vue = { k: 1, tx: 0, ty: 0 }, { cache = false } = {}) { const conteneur = document.createElement('div'); conteneur.dataset.surface = ''; if (cache) conteneur.style.display = 'none'; document.body.prepend(conteneur); const svg = document.createElementNS(SVG, 'svg'); svg.setAttribute('data-plan', ''); svg.setAttribute('width', String(innerWidth)); svg.setAttribute('height', String(innerHeight)); svg.style.display = 'block'; conteneur.append(svg); const groupe = document.createElementNS(SVG, 'g'); groupe.setAttribute('data-vue', ''); groupe.setAttribute('transform', transformDe(vue)); svg.append(groupe); const composant = mount(PlanDessin, { target: groupe, props: { modele }, context: CONTEXTE }); flushSync(); montes.push({ composant, conteneur }); const boite = svg.getBoundingClientRect(); const cadre = { gauche: boite.left, haut: boite.top, largeur: boite.width, hauteur: boite.height }; return { svg, groupe, conteneur, vue, cadre, ecran: (x, y) => ecranDepuisDessin(vue, cadre, x, y) }; } // Attend deux images : les observations de taille de la première sont // livrées, et les mises à jour qu'elles déclenchent appliquées. const deuxImages = () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))); /** Monte un composant HTML dans un conteneur neuf, au début du corps. */ function monterHtml(Composant, props) { const conteneur = document.createElement('div'); document.body.prepend(conteneur); const composant = mount(Composant, { target: conteneur, props, context: CONTEXTE }); flushSync(); montes.push({ composant, conteneur }); return conteneur; } // --- Configurations ---------------------------------------------------------- const NOMS = [ ['Iris', 'Ombrelle'], ['Théo', 'Pervenche'], ['Ondine', 'Lacasse'], [null, 'Grisaille'], ['Basile', 'Tourmaline'], ['Capucine', 'Arrosoir'], ['Aurèle', 'Cerfeuil'], ['Mélisande', 'Bruyère'], ['Firmin', 'Galet'], ['Clélia', 'Mistral'], ['Anatole', 'Houblon'], ['Solveig', 'Sarcelle'], ]; const personne = (id, nom, prenom, appartenance = null) => ({ id, nom, prenom, appartenance, courriel: null, titrePressenti: null, notes: null, exclu: false, }); const nomsDe = (charge) => { const noms = new Map(charge.participants.map(({ id, nom, prenom }) => [id, prenom === null ? nom : `${prenom} ${nom}`])); return (id) => noms.get(id); }; /** Le modèle d'un tour de la charge, sous la source demandée. */ function modeleDe(charge, { tour = 1, proposition = null, ...options } = {}) { return modeleDessin(etatPlaces(charge, { tour, proposition }), { k: 1, listes: true, miseEnEvidence: null, nomDe: nomsDe(charge), ...options, }); } /** Une charge de tables de capacités données — rondes, ou de la forme * donnée —, centres espacés de pas cm sur une ligne, et douze personnes * inventées. */ function chargeDeTables(capacites, { tours = 1, pas = 400, formes = [] } = {}) { const charge = creerCharge({ id: 'epreuve:dessin', nom: 'Dessin', siegesParDefaut: 8, tours }); charge.tables = capacites.map((sieges, i) => ({ id: i + 1, numero: i + 1, sieges, forme: formes[i] ?? 'ronde', position: { x: pas * i, y: 0 }, })); charge.participants = NOMS.map(([prenom, nom], i) => personne(i + 1, nom, prenom)); charge.prochainsIds = { participant: 13, table: capacites.length + 1, proposition: 1 }; return charge; } // Pose une proposition de rechercher, ou la proposition donnée. function avecProposition(charge, proposition = null) { const configuration = configurationDepuisCharge(charge); const fichier = proposition ?? versFichier(rechercher(configuration, { graine: 7, nombre: 1, arret: 1000 })[0], configuration, { produitVersion: 'epreuve', attribuerSieges: false, decalage: 0, }); charge.propositions = [fichier]; charge.prochainsIds.proposition = fichier.id + 1; charge.evenement.etat = 'propose'; return charge; } const toutesEnConflit = () => analyser(TEXTE_TOUTES_EN_CONFLIT).charge; // La petite démonstration, la seule règle « nouveaux voisins » active, et une // proposition dont le tour 2 réunit de nouveau la table 1 du tour 1 : au tour // 2, la table 1 seule est en rencontre répétée. function rencontreRepetee() { const charge = analyser(texteLivre('petite')).charge; Object.assign(charge.reglages, { separerAppartenances: false, nouvelleTable: false, varierAppartenances: false }); const tours = [ PLAN_PARFAIT_PETITE.tours[0], [ [1, 2, 3], [4, 8, 12], [5, 9, 10], [6, 7, 11], ], PLAN_PARFAIT_PETITE.tours[2], PLAN_PARFAIT_PETITE.tours[3], ]; return avecProposition(charge, { id: 1, graine: 1, arret: 1, historique: 1000, produitVersion: 'epreuve', siegesAttribues: false, tables: [1, 2, 3, 4], capacites: [3, 3, 3, 3], tours: 4, participants: charge.participants.map(({ id }) => id), placement: tours.map((listes) => ({ sieges: listes.map((liste) => [...liste].sort((a, b) => a - b)), reserve: [] })), }); } // --- Lectures -------------------------------------------------------------------- const tous = (racine, selecteur) => [...racine.querySelectorAll(selecteur)]; const plateauDe = (svg, id) => svg.querySelector(`[data-table="${id}"] :is([data-marque=contourTable], [data-marque=contourConflit])`); const centre = (element) => { const b = element.getBoundingClientRect(); return { x: b.left + b.width / 2, y: b.top + b.height / 2 }; }; // La valeur d'un jeton de couleur, en composantes [r, g, b, a]. function jeton(nom) { const sonde = document.createElement('div'); sonde.style.color = `var(${nom})`; document.body.append(sonde); const lu = composantes(getComputedStyle(sonde).color); sonde.remove(); return lu; } const memeCouleur = (a, b) => a.every((c, i) => Math.abs(c - b[i]) < 0.01); // Les lignes rendues d'une liste : chaque enfant du . const lignesDe = (svg, id) => tous(svg, `[data-liste="${id}"] > tspan`); // La hauteur d'encre du gabarit sur la police calculée d'un élément. function encreDuGabarit(element, gabarit = GABARIT_HAUTEUR_LIGNE) { const style = getComputedStyle(element); const contexte = new OffscreenCanvas(1, 1).getContext('2d'); contexte.font = `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`; const m = contexte.measureText(gabarit); return m.actualBoundingBoxAscent + m.actualBoundingBoxDescent; } // L'anneau se voit quand il est rendu et qu'un point de son trait l'atteint, // pointer-events ignoré : le centre de sa boîte, au milieu de la chaise, ne // le touche pas. function anneauVisible(anneau) { if (!anneau.checkVisibility({ opacityProperty: true, visibilityProperty: true })) return 'checkVisibility faux'; const style = getComputedStyle(anneau); if (style.stroke === 'none' || !(parseFloat(style.strokeWidth) > 0)) return `trait ${style.stroke} ${style.strokeWidth}`; const b = anneau.getBoundingClientRect(); const feuille = document.createElement('style'); feuille.textContent = '* { pointer-events: auto !important; }'; document.head.append(feuille); try { const atteints = document.elementsFromPoint(b.left + b.width / 2, b.top + 1); return atteints.includes(anneau) ? true : `le haut de son trait n'atteint pas l'anneau`; } finally { feuille.remove(); } } // ============================================================================= describe('le mobilier à l\u{2019}échelle (§ 7.1, § 7.7)', () => { test('à k = 2, le disque d\u{2019}une table de 8, de 3 et de 10 a la largeur de son diamètre, à moins d\u{2019}un pixel', () => { const charge = chargeDeTables([8, 3, 10]); const { svg } = monterPlan(modeleDe(charge, { k: 2 }), { k: 2, tx: 300, ty: 350 }); const diametres = [(8 * 60) / Math.PI, 70, (10 * 60) / Math.PI]; expect(diametres[0]).toBeCloseTo(2 * 76.39, 1); diametres.forEach((diametre, i) => { expect(Math.abs(plateauDe(svg, i + 1).getBoundingClientRect().width - diametre * 2)).toBeLessThan(1); }); }); }); describe('les marques des places (§ 7.6, § 19.3 08)', () => { // Siège 1 : réservé pour tous les tours et titré ; 2 : titre non pourvu ; // 3 : réservé au seul tour 1 ; les autres, nus. function marques() { const charge = chargeDeTables([8], { tours: 2 }); charge.reservations = [ { participant: 1, table: 1, siege: 1, portee: 'tous', tour: null }, { participant: 2, table: 1, siege: 3, portee: 'tour', tour: 1 }, ]; charge.titres = [ { table: 1, siege: 1, libelle: 'animateur' }, { table: 1, siege: 2, libelle: 'animateur' }, ]; return charge; } const ATTENDUES = new Map([ [1, ['cadenasFerme', 'anneau']], [2, ['cadenasOuvert']], [3, ['cadenasFerme']], ]); const REMPLISSAGES = new Map([ [1, 'remplissagePlein'], [2, 'hachure'], [3, 'remplissagePlein'], ]); test('chaque marque attendue se voit, aucune autre ; deux marques de chaise au plus ; aucun pictogramme', () => { const charge = marques(); const { svg } = monterPlan(modeleDe(charge, { k: 2.5 }), { k: 2.5, tx: 683, ty: 350 }); const sieges = tous(svg, '[data-siege]'); expect(sieges.length).toBe(8); for (const siege of sieges) { const numero = Number(siege.dataset.siege.split(':')[1]); const portees = MARQUES_DE_CHAISE.flatMap((nom) => tous(siege, `[data-marque=${nom}]`)); expect(portees.length).toBeLessThanOrEqual(2); expect(portees.map((m) => m.dataset.marque).sort()).toEqual([...(ATTENDUES.get(numero) ?? [])].sort()); for (const marque of portees) { expect(marque).toBeInstanceOf(SVGElement); expect(marque.dataset.marque === 'anneau' ? anneauVisible(marque) : visible(marque)).toBe(true); } } // Les nœuds de texte du plan : au moins un par ligne de liste, huit. const textes = tous(svg, '*').flatMap((e) => [...e.childNodes].filter((n) => n.nodeType === Node.TEXT_NODE)); expect(tous(svg, '[data-liste] > tspan').length).toBe(8); expect(textes.length).toBeGreaterThanOrEqual(8); for (const noeud of textes) { expect(/\p{Extended_Pictographic}/u.test(noeud.textContent)).toBe(false); } }); test('le remplissage se lit au style calculé : plein, hachuré, ou le fond de la chaise', () => { const charge = marques(); const { svg } = monterPlan(modeleDe(charge, { k: 2.5 }), { k: 2.5, tx: 683, ty: 350 }); const chaises = tous(svg, '[data-siege] > :is([data-marque=contourChaise], [data-marque=remplissagePlein], [data-marque=hachure])'); expect(chaises.length).toBe(8); for (const chaise of chaises) { const numero = Number(chaise.closest('[data-siege]').dataset.siege.split(':')[1]); const attendu = REMPLISSAGES.get(numero) ?? 'contourChaise'; expect(chaise.dataset.marque).toBe(attendu); const { fill } = couleursPeintes(chaise); if (attendu === 'remplissagePlein') expect(fill).toEqual([jeton('--couleur-chaise-pleine')]); if (attendu === 'contourChaise') expect(fill).toEqual([jeton('--couleur-chaise-fond')]); if (attendu === 'hachure') { expect(getComputedStyle(chaise).fill).toMatch(/^url\(/); expect(fill.some((c) => memeCouleur(c, jeton('--couleur-hachure')))).toBe(true); } // Un cadenas couvre le centre de sa chaise : seule une chaise nue se // voit en son centre ; toutes sont rendues. expect(chaise.checkVisibility({ opacityProperty: true, visibilityProperty: true })).toBe(true); if (!ATTENDUES.has(numero)) expect(visible(chaise)).toBe(true); } }); }); describe('la liste fait foi (§ 7.6)', () => { test('la ligne du siège 1 égale exactement sa forme, son titre en italique ; celle d\u{2019}un titre non pourvu l\u{2019}énonce', () => { const charge = chargeDeTables([8]); charge.reservations = [{ participant: 1, table: 1, siege: 1, portee: 'tous', tour: null }]; charge.titres = [ { table: 1, siege: 1, libelle: 'animateur' }, { table: 1, siege: 2, libelle: 'animateur' }, ]; // Au bloc du registre, LARGEUR_BLOC_NOMS_CM : la ligne entière y tient. const { svg } = monterPlan(modeleDe(charge), { k: 1, tx: 400, ty: 350 }); const lignes = lignesDe(svg, 1); expect(lignes.length).toBe(8); expect(lignes[0].textContent).toBe('1. (réservée, tous les tours) Iris Ombrelle \u{2014} animateur'); const titre = lignes[0].querySelector('[data-partie=titre]'); expect(titre.textContent).toBe('animateur'); expect(getComputedStyle(titre).fontStyle).toBe('italic'); expect(getComputedStyle(lignes[0].querySelector('[data-partie=nom]')).fontStyle).toBe('normal'); expect(lignes[1].textContent).toBe('2. (libre) \u{2014} titre animateur non pourvu'); }); test('grande démonstration livrée, tour 1, modèle tel quel : chaque ligne tient, chaque place occupée garde son nom, chaque titre son libellé', () => { const charge = analyser(texteLivre('grande')).charge; const modele = modeleDe(charge); const occupees = modele.tables.flatMap(({ lignes }) => lignes.filter(({ nom }) => nom !== null)); expect(occupees.length).toBe(33); expect(occupees.every(({ etat, titre }) => etat?.cle === 'plan.ligne.reserveeTous' && titre !== null)).toBe(true); const { svg } = monterPlan(modele, { k: 1, tx: 50, ty: 50 }); const lignes = tous(svg, '[data-liste] > tspan'); expect(lignes.length).toBe(260); for (const ligne of lignes) expect(ligne.getComputedTextLength()).toBeLessThanOrEqual(LARGEUR + 1e-3); const noms = tous(svg, '[data-liste] [data-partie=nom]'); expect(noms.length).toBe(33); for (const nom of noms) expect(nom.textContent.replace(MARQUE, '').length).toBeGreaterThan(0); const titres = tous(svg, '[data-liste] [data-partie=titre]'); expect(titres.length).toBe(33); for (const titre of titres) expect(titre.textContent).toBe('animation'); for (const nom of noms) expect(nom.previousElementSibling.textContent).toMatch(/^\d+\. \(réservée, tous les tours\) $/); }); }); describe('le texte des listes (§ 7.4)', () => { test('chaque ligne est un à x et y explicites, sans textLength ; deux lignes sont au moins à la hauteur d\u{2019}encre du gabarit', () => { const charge = chargeDeTables([8, 4]); charge.participants.forEach((p, i) => { p.nom = ['\u{D4}jgq', '\u{C6}jgq', '\u{D4}gq \u{C6}'][i % 3]; }); avecProposition(charge); const { svg } = monterPlan(modeleDe(charge, { proposition: 1 }), { k: 1, tx: 400, ty: 350 }); const lignes = lignesDe(svg, 1); expect(lignes.length).toBe(8); for (const ligne of lignes) { expect(ligne.hasAttribute('x')).toBe(true); expect(ligne.hasAttribute('y') || ligne.hasAttribute('dy')).toBe(true); } expect(svg.querySelectorAll('[textLength], [lengthAdjust]').length).toBe(0); const encre = encreDuGabarit(svg.querySelector('[data-liste="1"]')); expect(encre).toBeGreaterThan(18); expect(encre).toBeGreaterThan(encreDuGabarit(svg.querySelector('[data-liste="1"]'), 'abc')); for (let i = 1; i < lignes.length; i += 1) { const ecart = lignes[i].getStartPositionOfChar(0).y - lignes[i - 1].getStartPositionOfChar(0).y; expect(ecart).toBeGreaterThanOrEqual(encre - 1e-3); } }); test('troncature : sur les 33 tables sans exception, chaque ligne tient, chaque nom s\u{2019}abrège sur la marque', () => { const charge = analyser(texteLivre('grande-sans-exception')).charge; const LONGS = ['L'.repeat(200), 'W'.repeat(60), '\u{C6}'.repeat(60)]; charge.participants.forEach((p, i) => { p.prenom = null; p.nom = LONGS[i % 3]; }); expect(charge.titres.filter(({ siege, libelle }) => siege === 1 && libelle === 'animation').length).toBe(33); avecProposition(charge); const modele = modeleDe(charge, { proposition: 1, k: 0.5 }); const places = modele.tables.reduce((somme, { lignes }) => somme + lignes.length, 0); const occupees = modele.tables.reduce((somme, { lignes }) => somme + lignes.filter(({ nom }) => nom !== null).length, 0); expect(places).toBe(264); expect(occupees).toBe(260); // Au bloc du registre : chaque nom s'abrège, aucun ne tombe, et le // titre reste entier. const { svg } = monterPlan(modele, { k: 0.5, tx: 50, ty: 50 }); const lignes = tous(svg, '[data-liste] > tspan'); expect(lignes.length).toBe(places); for (const ligne of lignes) expect(ligne.getComputedTextLength()).toBeLessThanOrEqual(LARGEUR + 1e-3); const noms = tous(svg, '[data-liste] [data-partie=nom]'); expect(noms.length).toBe(occupees); for (const nom of noms) expect(nom.textContent.endsWith(MARQUE) && nom.textContent.length > 1).toBe(true); const titres = tous(svg, '[data-liste] [data-partie=titre]'); expect(titres.length).toBe(33); for (const titre of titres) expect(titre.textContent).toBe('animation'); }); test('un titre de deux cents caractères : la ligne tient encore, le titre abrégé', () => { const charge = chargeDeTables([4]); charge.reservations = [{ participant: 1, table: 1, siege: 1, portee: 'tous', tour: null }]; charge.titres = [{ table: 1, siege: 1, libelle: 't'.repeat(200) }]; const { svg } = monterPlan(modeleDe(charge), { k: 1, tx: 300, ty: 350 }); const [premiere] = lignesDe(svg, 1); expect(premiere.getComputedTextLength()).toBeLessThanOrEqual(LARGEUR + 1e-3); const titre = premiere.querySelector('[data-partie=titre]'); expect(titre.textContent.endsWith(MARQUE)).toBe(true); expect(titre.textContent.length).toBeLessThan(200); }); }); describe('le plan monté caché (point d\u{2019}attention 1)', () => { test('monté en display: none puis montré : chaque ligne tient, le nom long s\u{2019}abrège sur la marque', async () => { const charge = chargeDeTables([4, 8]); for (const p of charge.participants) { p.prenom = null; p.nom = 'W'.repeat(80); } avecProposition(charge); const { svg, conteneur } = monterPlan(modeleDe(charge, { proposition: 1 }), { k: 1, tx: 300, ty: 350 }, { cache: true }); conteneur.style.display = ''; await deuxImages(); flushSync(); const lignes = lignesDe(svg, 1); expect(lignes.length).toBe(4); for (const ligne of lignes) { expect(ligne.getComputedTextLength()).toBeLessThanOrEqual(LARGEUR + 1e-3); const nom = ligne.querySelector('[data-partie=nom]').textContent; expect(nom.startsWith('W') && nom.endsWith(MARQUE)).toBe(true); } }); }); describe('le signal de conflit (§ 7.5, § 13.3)', () => { for (const theme of ['clair', 'sombre']) { test(`thème ${theme} : contour épaissi, trame peinte, pastille chiffrée ; la rencontre répétée et le siège réservé peignent autrement ; le tour change le signal`, async () => { await sousTheme(theme, async () => { const charge = rencontreRepetee(); const tour2 = modeleDe(charge, { tour: 2, proposition: 1, k: 1.5 }); expect(tour2.tables.map(({ conflit }) => conflit)).toEqual([true, false, false, false]); const vue = { k: 1.5, tx: 150, ty: 250 }; const { svg } = monterPlan(tour2, vue); const enConflit = plateauDe(svg, 1); const saine = plateauDe(svg, 2); expect(enConflit.dataset.marque).toBe('contourConflit'); expect(parseFloat(getComputedStyle(enConflit).strokeWidth)).toBeGreaterThan(parseFloat(getComputedStyle(saine).strokeWidth)); expect(couleursPeintes(enConflit).stroke).not.toEqual(couleursPeintes(saine).stroke); const trames = tous(svg, '[data-marque=trameConflit]'); expect(trames.length).toBe(1); expect(trames[0].closest('[data-table]').dataset.table).toBe('1'); expect(getComputedStyle(trames[0]).fill).toMatch(/^url\("?#/); expect(couleursPeintes(trames[0]).fill.some((c) => memeCouleur(c, jeton('--couleur-conflit')))).toBe(true); const pastilles = tous(svg, '[data-pastille]'); expect(pastilles.length).toBe(1); expect(pastilles[0].dataset.pastille).toBe('1'); expect(pastilles[0].textContent).toBe(String(tour2.tables[0].pastille)); expect(visible(pastilles[0].querySelector('text'))).toBe(true); expect(couleursPeintes(pastilles[0].querySelector('[data-marque=pastille]')).fill).toEqual([jeton('--couleur-pastille-fond')]); expect(getComputedStyle(pastilles[0]).pointerEvents).toBe('none'); demonterTout(); const tour1 = monterPlan(modeleDe(charge, { tour: 1, proposition: 1, k: 1.5 }), vue).svg; expect(plateauDe(tour1, 1).dataset.marque).toBe('contourTable'); expect(tous(tour1, '[data-marque=trameConflit], [data-pastille]').length).toBe(0); expect(getComputedStyle(plateauDe(tour1, 1)).strokeWidth).toBe(getComputedStyle(plateauDe(tour1, 2)).strokeWidth); const reserve = chargeDeTables([8]); reserve.reservations = [{ participant: 1, table: 1, siege: 1, portee: 'tous', tour: null }]; const places = monterPlan(modeleDe(reserve, { k: 1.5 }), vue).svg; const [plein, nu] = ['1:1', '1:2'].map((s) => places.querySelector(`[data-siege="${s}"] > circle`)); expect(couleursPeintes(plein).fill).not.toEqual(couleursPeintes(nu).fill); }); }); } test('toutes en conflit, au rendu : ni contour ni trame, le style d\u{2019}une table saine ; chaque pastille garde son nombre ; le bandeau porte la phrase', () => { const charge = toutesEnConflit(); const modele = modeleDe(charge, { proposition: 1, k: 1 }); expect(modele.signalerConflits).toBe(false); const { svg } = monterPlan(modele, { k: 1, tx: 150, ty: 250 }); expect(tous(svg, '[data-marque=contourConflit], [data-marque=trameConflit]').length).toBe(0); const saine = monterPlan(modeleDe(chargeDeTables([3]), { k: 1 }), { k: 1, tx: 150, ty: 600 }).svg; const reference = getComputedStyle(plateauDe(saine, 1)); for (const id of [1, 2, 3, 4]) { const style = getComputedStyle(plateauDe(svg, id)); for (const propriete of ['stroke', 'strokeWidth', 'fill']) expect(style[propriete]).toBe(reference[propriete]); } const pastilles = tous(svg, '[data-pastille]'); expect(pastilles.length).toBe(4); for (const pastille of pastilles) { const table = modele.tables.find(({ id }) => String(id) === pastille.dataset.pastille); expect(table.pastille).toBeGreaterThan(0); expect(pastille.textContent).toBe(String(table.pastille)); } const bandeau = monterHtml(BandeauPlan, { bandeau: modele.bandeau }); const porteur = bandeau.querySelector('[data-bandeau-plan]'); expect(porteur.textContent).toBe(t('plan.bandeau.toutesEnConflit')); expect(visible(porteur.querySelector('li span'))).toBe(true); }); test('le bandeau pose l\u{2019}appartenance d\u{2019}un plancher dans son propre nœud', () => { const bandeau = [ { cle: 'plan.bandeau.toutesEnConflit', details: {} }, { cle: 'plan.bandeau.plancherGroupe', details: { groupe: 'Club des Merles', effectif: 6, plancherCumulees: 8 } }, ]; const conteneur = monterHtml(BandeauPlan, { bandeau }); const entrees = tous(conteneur, '[data-bandeau-plan] li'); expect(entrees.length).toBe(2); const noeuds = [...entrees[1].querySelectorAll('*')].map((e) => e.textContent); expect(noeuds).toContain('Club des Merles'); expect(noeuds).toContain(t('plan.bandeau.plancherGroupe', bandeau[1].details)); }); }); describe('l\u{2019}échelle graphique et la mention (§ 7.7, § 19.3 07)', () => { test('la mention se voit, portée par EchellePlan, tirée des libellés', () => { const conteneur = monterHtml(EchellePlan, { k: 1 }); const mention = [...conteneur.querySelectorAll('*')].find((e) => e.textContent === t('plan.echelle.mention')); expect(mention).toBeDefined(); expect(mention.textContent).toBe("disposition relative \u{2014} l'encombrement dans la salle n'est pas vérifié"); expect(visible(mention)).toBe(true); }); for (const k of [0.2, 1, 5]) { test(`k = ${k} : la largeur rendue de la barre, divisée par k, égale sa longueur libellée à 1 % près`, () => { const conteneur = monterHtml(EchellePlan, { k }); const barre = conteneur.querySelector('[data-marque=echelle]'); expect(visible(barre)).toBe(true); const { longueurCm } = echelleGraphique(k, LARGEUR_ECHELLE_PX); const libelle = barre.parentElement.textContent; const lu = /^([\d,]+)\s*(cm|m|km)$/.exec(libelle.trim()); expect(lu).not.toBeNull(); const facteur = { cm: 1, m: 100, km: 100000 }[lu[2]]; const longueurLue = Number(lu[1].replace(',', '.')) * facteur; expect(longueurLue).toBeCloseTo(longueurCm, 6); expect(Math.abs(barre.getBoundingClientRect().width / k - longueurLue) / longueurLue).toBeLessThan(0.01); expect(/\b1\s*:\s*\d/.test(conteneur.textContent)).toBe(false); }); } }); describe('les seuils de rendu (§ 7.3, § 17 point 6, § 19.10)', () => { test('à K_LISTES − 0,01, aucun tspan de liste ; à K_LISTES + 0,01, les listes sont là', () => { const charge = chargeDeTables([8, 8]); for (const [k, present] of [ [K_LISTES - 0.01, false], [K_LISTES + 0.01, true], ]) { const { svg } = monterPlan(modeleDe(charge, { k }), { k, tx: 200, ty: 200 }); expect(tous(svg, '[data-liste] tspan').length > 0).toBe(present); expect(tous(svg, '[data-table]').length).toBe(2); } }); test('grande démonstration : à 0,39, aucune chaise, une seule cible par table ; à 0,41, 260 sièges', () => { const charge = analyser(texteLivre('grande')).charge; const vue = { k: 0.39, tx: 80, ty: 80 }; const bas = monterPlan(modeleDe(charge, { k: 0.39 }), vue); expect(tous(bas.svg, '[data-siege]').length).toBe(0); const modele = modeleDe(charge, { k: 0.39 }); let chaises = 0; for (const table of modele.tables) { const { centre: c, rayonEmprise } = table.geometrie; const coin1 = bas.ecran(c.x - rayonEmprise, c.y - rayonEmprise); const coin2 = bas.ecran(c.x + rayonEmprise, c.y + rayonEmprise); if (coin1.x < 0 || coin1.y < 0 || coin2.x > innerWidth || coin2.y > innerHeight) continue; const atteints = table.geometrie.chaises.map(({ x, y }) => { const p = bas.ecran(x, y); return document.elementFromPoint(p.x, p.y); }); chaises += atteints.length; expect(new Set(atteints).size).toBe(1); expect(atteints[0].closest('[data-siege]')).toBeNull(); expect(atteints[0].closest('[data-table]').dataset.table).toBe(String(table.id)); } expect(chaises).toBeGreaterThanOrEqual(10); const haut = monterPlan(modeleDe(charge, { k: 0.41 }), { k: 0.41, tx: 80, ty: 80 }); const capacites = etatPlaces(charge, { tour: 1 }).tables.reduce((somme, { capacite }) => somme + capacite, 0); expect(capacites).toBe(260); expect(tous(haut.svg, '[data-siege]').length).toBe(260); }); }); describe('dessin et désignation (§ 7.3, § 13.5)', () => { test('le centre rendu de chaque siège est sa position ; aux points témoins, le DOM et designer désignent la même place ; une liste ne masque aucune chaise', () => { const charge = chargeDeTables([8, 8], { pas: 200 }); const vue = { k: 1.5, tx: 300, ty: 350 }; const modele = modeleDe(charge, { k: vue.k }); const { svg, cadre, ecran } = monterPlan(modele, vue); const geometries = modele.tables.map(({ geometrie }) => geometrie); const sieges = tous(svg, '[data-siege]'); expect(sieges.length).toBe(16); for (const element of sieges) { const [table, siege] = element.dataset.siege.split(':').map(Number); const attendu = geometries[table - 1].chaises[siege - 1]; const c = centre(element); const lu = dessinDepuisEcran(vue, cadre, c.x, c.y); expect(Math.hypot(lu.x - attendu.x, lu.y - attendu.y) * vue.k).toBeLessThan(0.5); } const temoins = []; for (const { centre: c, chaises } of geometries) { temoins.push(c); chaises.forEach((chaise, i) => { const suivante = chaises[(i + 1) % chaises.length]; temoins.push(chaise, { x: (chaise.x + suivante.x) / 2, y: (chaise.y + suivante.y) / 2 }); }); } temoins.push({ x: -300, y: 0 }, { x: 100, y: -200 }); expect(temoins.length).toBe(36); for (const point of temoins) { const p = ecran(point.x, point.y); const parDom = document.elementFromPoint(p.x, p.y)?.closest('[data-siege]')?.dataset.siege ?? null; const designe = designer(dessinDepuisEcran(vue, cadre, p.x, p.y), geometries, vue.k); const parGeometrie = designe !== null && designe.siege !== undefined ? `${designe.table}:${designe.siege}` : null; expect(parDom, `point (${point.x}, ${point.y})`).toBe(parGeometrie); } const boite = svg.querySelector('[data-liste="1"]').getBoundingClientRect(); const couvertes = geometries[1].chaises .map((chaise) => ({ chaise, p: ecran(chaise.x, chaise.y) })) .filter(({ p }) => p.x >= boite.left && p.x <= boite.right && p.y >= boite.top && p.y <= boite.bottom); expect(couvertes.length).toBeGreaterThanOrEqual(1); for (const { chaise, p } of couvertes) { expect(document.elementFromPoint(p.x, p.y).closest('[data-siege]').dataset.siege).toBe(`2:${chaise.siege}`); } }); }); describe('aucune rotation, aucun canvas (§ 7.1, § 13.3)', () => { test('chaque table, ronde ou carrée, porte une seule poignée, centrée sur GeometrieTable.poignee', () => { const charge = chargeDeTables([8, 12, 2], { formes: ['ronde', 'carree', 'carree'], pas: 450 }); for (const vue of [ { k: 1, tx: 200, ty: 300 }, { k: 0.3, tx: 200, ty: 300 }, ]) { const modele = modeleDe(charge, { k: vue.k }); const { svg, groupe, ecran } = monterPlan(modele, vue); expect(tous(svg, '[data-poignee]').length).toBe(3); for (const table of modele.tables) { const poignees = tous(svg, `[data-poignee="${table.id}"]`); expect(poignees.length).toBe(1); const attendu = ecran(table.geometrie.poignee.x, table.geometrie.poignee.y); const c = centre(poignees[0]); expect(Math.hypot(c.x - attendu.x, c.y - attendu.y)).toBeLessThan(1); expect(poignees[0].dataset.marque).toBe('poignee'); } expect(groupe.closest('[data-surface]').querySelectorAll('canvas').length).toBe(0); } }); }); describe('la mise en évidence', () => { test('la chaise de la personne, et elle seule, porte [data-marque=miseEnEvidence] au tour montré', () => { const charge = avecProposition(chargeDeTables([4, 4, 4], { tours: 2 })); const sieges = new Set(); for (const tour of [1, 2]) { const { svg } = monterPlan(modeleDe(charge, { tour, proposition: 1, k: 1, miseEnEvidence: 5 }), { k: 1, tx: 150, ty: 300 }); const marquees = tous(svg, '[data-marque=miseEnEvidence]'); expect(marquees.length).toBe(1); const siege = marquees[0].closest('[data-siege]').dataset.siege; const etat = etatPlaces(charge, { tour, proposition: 1 }); const [table, numero] = siege.split(':').map(Number); expect(etat.tables.find(({ id }) => id === table).chaises[numero - 1].occupant).toBe(5); expect(couleursPeintes(marquees[0]).stroke).toEqual([jeton('--couleur-mise-en-evidence')]); expect(anneauVisible(marquees[0])).toBe(true); sieges.add(siege); demonterTout(); } expect(sieges.size).toBe(2); }); }); describe('les bords (points d\u{2019}attention 3 et 4)', () => { test('une table de 2 : deux chaises, un disque de 70 cm', () => { const { svg } = monterPlan(modeleDe(chargeDeTables([2]), { k: 2 }), { k: 2, tx: 300, ty: 300 }); expect(tous(svg, '[data-siege]').length).toBe(2); expect(Math.abs(plateauDe(svg, 1).getBoundingClientRect().width / 2 - 70)).toBeLessThan(0.5); }); test('une table gelée : la marque gel se voit, « gelée » dans son aria-label et en tête de sa liste', () => { const charge = chargeDeTables([2]); charge.reservations = [ { participant: 1, table: 1, siege: 1, portee: 'tous', tour: null }, { participant: 2, table: 1, siege: 2, portee: 'tous', tour: null }, ]; const { svg } = monterPlan(modeleDe(charge, { k: 2 }), { k: 2, tx: 300, ty: 300 }); const gel = svg.querySelector('[data-table="1"] [data-marque=gel]'); expect(gel).not.toBeNull(); expect(visible(gel)).toBe(true); expect(svg.querySelector('[data-table="1"]').getAttribute('aria-label')).toMatch(/gelée/); expect(lignesDe(svg, 1)[0].textContent).toBe(t('plan.liste.gelee')); }); test('une seule table ; aucune table : aucun [data-table], un bandeau vide, un montage qui ne lève pas', () => { expect(tous(monterPlan(modeleDe(chargeDeTables([6]))).svg, '[data-table]').length).toBe(1); const vide = creerCharge({ id: 'epreuve:vide', nom: 'Vide', siegesParDefaut: 6, tours: 1 }); const modele = modeleDe(vide); const { svg } = monterPlan(modele); expect(tous(svg, '[data-table]').length).toBe(0); const conteneur = monterHtml(BandeauPlan, { bandeau: modele.bandeau }); expect(conteneur.querySelector('[data-bandeau-plan]')).toBeNull(); expect(conteneur.textContent.trim()).toBe(''); }); test('sièges non attribués, deux titres et une réservation « tous » sans siège : la réservée au siège 1, pourvue ; « 2. (libre) — titre … non pourvu »', () => { const charge = chargeDeTables([4]); charge.reservations = [{ participant: 1, table: 1, siege: null, portee: 'tous', tour: null }]; charge.titres = [ { table: 1, siege: 1, libelle: 'animateur' }, { table: 1, siege: 2, libelle: 'animateur' }, ]; const { svg } = monterPlan(modeleDe(charge), { k: 1, tx: 300, ty: 300 }); const lignes = lignesDe(svg, 1).map((ligne) => ligne.textContent); expect(lignes.slice(0, 2)).toEqual([ '1. (réservée, tous les tours) Iris Ombrelle \u{2014} animateur', '2. (libre) \u{2014} titre animateur non pourvu', ]); expect(svg.querySelector('[data-siege="1:1"] [data-marque=anneau]')).not.toBeNull(); }); });