diff --git a/src/interface/libelles/plan.js b/src/interface/libelles/plan.js index 637c633..333eeed 100644 --- a/src/interface/libelles/plan.js +++ b/src/interface/libelles/plan.js @@ -72,4 +72,21 @@ export const TABLE = new Map([ ['plan.echelle.centimetres', 'cm'], ['plan.echelle.metres', 'm'], ['plan.echelle.kilometres', 'km'], + + // --- La barre de vue (§ 7.2, § 7.7, § 8.4) --------------------------------- + // Le numéro d'un tour, dans le sélecteur, est une donnée : son option ne + // porte que lui. + ['plan.vue.barre', 'Vue du plan'], + ['plan.vue.tour', 'Tour affiché'], + ['plan.vue.zoomer', 'Zoom +'], + ['plan.vue.dezoomer', 'Zoom \u{2212}'], + ['plan.vue.ajuster', 'Ajuster'], + ['plan.vue.listes', 'Listes de noms'], + ['plan.vue.pleinEcran', 'Plein écran'], + ['plan.vue.dessin', 'Plan de salle'], + + // --- Les panneaux latéraux (§ 2.1, étape 8) -------------------------------- + ['plan.panneaux', 'Panneaux du plan'], + ['plan.listesTravail.titre', 'Listes de travail'], + ['plan.reserve.titre', 'Réserve'], ]); diff --git a/src/interface/plan/BarreVue.svelte b/src/interface/plan/BarreVue.svelte new file mode 100644 index 0000000..2459c8e --- /dev/null +++ b/src/interface/plan/BarreVue.svelte @@ -0,0 +1,84 @@ + + + + + + + diff --git a/src/interface/plan/ListesTravail.svelte b/src/interface/plan/ListesTravail.svelte new file mode 100644 index 0000000..5678a32 --- /dev/null +++ b/src/interface/plan/ListesTravail.svelte @@ -0,0 +1,22 @@ + + + + +
+

{t('plan.listesTravail.titre')}

+
+ + diff --git a/src/interface/plan/PanneauPlace.svelte b/src/interface/plan/PanneauPlace.svelte new file mode 100644 index 0000000..5803d8e --- /dev/null +++ b/src/interface/plan/PanneauPlace.svelte @@ -0,0 +1,7 @@ + + + diff --git a/src/interface/plan/PanneauTable.svelte b/src/interface/plan/PanneauTable.svelte new file mode 100644 index 0000000..0c53c02 --- /dev/null +++ b/src/interface/plan/PanneauTable.svelte @@ -0,0 +1,7 @@ + + + diff --git a/src/interface/plan/Plan.svelte b/src/interface/plan/Plan.svelte new file mode 100644 index 0000000..006728b --- /dev/null +++ b/src/interface/plan/Plan.svelte @@ -0,0 +1,181 @@ + + + + +
+ +
+ + + + {#if modele !== null} + + {/if} + + + +
+ +
+
+
+ + diff --git a/src/interface/plan/Reserve.svelte b/src/interface/plan/Reserve.svelte new file mode 100644 index 0000000..5853c51 --- /dev/null +++ b/src/interface/plan/Reserve.svelte @@ -0,0 +1,22 @@ + + + + +
+

{t('plan.reserve.titre')}

+
+ + diff --git a/src/interface/plan/SectionPlan.svelte b/src/interface/plan/SectionPlan.svelte index 615fcd7..3d0a244 100644 --- a/src/interface/plan/SectionPlan.svelte +++ b/src/interface/plan/SectionPlan.svelte @@ -2,12 +2,86 @@ License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) --> -
-

{t('coquille.section.plan')}

+
+ {#if !$ui.pleinEcran} +

{t('coquille.section.plan')}

+ {/if} +
+ {#if evenement !== null} + {#key evenement} + + {/key} + {/if} + {#if !$ui.pleinEcran} + + {/if} +
+ + diff --git a/src/interface/plan/Vue.navigateur.test.js b/src/interface/plan/Vue.navigateur.test.js new file mode 100644 index 0000000..7a524f0 --- /dev/null +++ b/src/interface/plan/Vue.navigateur.test.js @@ -0,0 +1,839 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// La vue du plan dans un vrai moteur de rendu (§ 7.2, § 7.3, § 7.7, § 8.2, +// § 8.4, § 8.5, § 13.3, § 14.10) : l'oracle du moteur de rendu, +// getScreenCTM().inverse(), confronté à dessinDepuisEcran à tout zoom et +// sur une page défilée ; les trois invariants du — pas de +// viewBox, ni bordure ni remplissage, aucune transformation dans +// l'ascendance —, chacun avec son contrôle d'échec ; touch-action ; un zoom +// qui n'écrit qu'un attribut ; le zoom sous le curseur ; l'ajustement ; le +// plan monté caché ou trop petit ; le cadrage, écrit différé dans +// reglages_locaux.json et restitué ; les commandes de vue qui n'écrivent +// rien ; le plein écran ; le diagnostic du panneau latéral. +// +// Chaque épreuve monte App par monterApp, sur le système de fichiers simulé +// et l'horloge pilotée, au cadre de 1 366 × 700, et lit le rendu : la boîte, +// le style calculé, l'attribut transform relu par vueDepuisTransform — +// jamais une classe. Les valeurs attendues viennent des fonctions de +// src/geometrie/vue.js et de vue_plan.js, sollicitées une seconde fois ; les +// noms d'épreuve sont inventés. +// +// Le projet navigateur emploie expect, et test et describe de Vitest +// (§ 14.8). +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import { flushSync } from 'svelte'; +import { etatPlaces } from '../../application/places.js'; +import { DELAI_REGLAGES_MS } from '../../application/seance.js'; +import { FACTEUR_K_MIN, K_MAX, ajuster, bornesZoom, dessinDepuisEcran, kAjuste, transformDe, vueDepuisTransform } from '../../geometrie/vue.js'; +import { constante } from '../../geometrie/constantes.js'; +import { regimeCibles } from '../../geometrie/designation.js'; +import { geometrieTable } from '../../geometrie/tables.js'; +import { SUFFIXES } from '../../stockage/noms.js'; +import { fixerCadre, molette } from '../../../test/navigateur/entrees.js'; +import { monterApp } from '../../../test/navigateur/montage.js'; +import { sousTheme, visible } from '../../../test/navigateur/rendu.js'; +import { cadreDepuisRect, englobantDuTour, vueApresCadre, vueInitiale } from './vue_plan.js'; + +const EVENEMENT = { nom: 'Club des Merles', date: null, siegesParDefaut: 3, tours: 2 }; +const FICHIER_REGLAGES = 'reglages_locaux.json'; + +// L'écart admis entre dessinDepuisEcran et l'oracle, en px d'écran : écart +// en cm × k. Chromium lit les nombres de l'attribut transform en flottants +// de 32 bits — 0,6943231441048034 s'y lit 0,6943232417106628 — et compose +// la matrice d'écran à cette précision : à moins de 2 048 px du coin de la +// fenêtre, un ULP vaut au plus 1,2e-4 px, et l'écart relevé reste de cet +// ordre. Un défaut des invariants décale d'au moins un pixel. +const TOLERANCE_ORACLE_PX = 1e-3; + +// --- Montages, feuilles d'épreuve, nettoyage ----------------------------------- + +let montes = []; +let feuilles = []; +let ajouts = []; + +async function monter(options) { + const m = await monterApp(options); + montes.push(m); + return m; +} + +// Démonte un montage avant la fin de l'épreuve, et relève son collecteur. +let erreursDemontees = []; +function demonter(m) { + erreursDemontees.push(...m.erreurs()); + m.demonter(); + montes = montes.filter((autre) => autre !== m); +} + +/** Pose une feuille de style d'épreuve dans la page ; rend la fonction qui la retire. */ +function feuille(css) { + const element = document.createElement('style'); + element.textContent = css; + document.head.append(element); + feuilles.push(element); + return () => element.remove(); +} + +beforeEach(async () => { + await fixerCadre(1366, 700); +}); + +afterEach(async () => { + const erreurs = [...erreursDemontees, ...montes.flatMap((m) => m.erreurs())]; + for (const m of montes.reverse()) m.demonter(); + for (const element of [...feuilles, ...ajouts]) element.remove(); + montes = []; + feuilles = []; + ajouts = []; + erreursDemontees = []; + window.scrollTo(0, 0); + await fixerCadre(1366, 700); + expect(erreurs, 'erreurs reçues par le collecteur').toEqual([]); +}); + +// --- Lectures du rendu ----------------------------------------------------------- + +const dans = (m, selecteur) => m.racine.querySelector(selecteur); +const tous = (m, selecteur) => [...m.racine.querySelectorAll(selecteur)]; +const racinePlan = (m) => dans(m, '[data-plan]'); +const groupe = (m) => dans(m, '[data-vue]'); +const surface = (m) => dans(m, '[data-surface]'); +const cadreDe = (m) => cadreDepuisRect(racinePlan(m).getBoundingClientRect()); +const vueLue = (m) => vueDepuisTransform(groupe(m).getAttribute('transform')); +const englobantDe = (m) => englobantDuTour(etatPlaces(m.seance.etat().charge, { tour: 1 })); +const kAjusteDe = (m) => kAjuste(englobantDe(m), cadreDe(m)); +const boite = (element) => element.getBoundingClientRect().toJSON(); +const centre = (element) => { + const { left, top, width, height } = element.getBoundingClientRect(); + return { x: left + width / 2, y: top + height / 2 }; +}; + +// Attend que l'assertion passe, le rendu de Svelte à jour, une image après +// l'autre : les observations de taille se livrent avant la peinture. +async function attendre(assertion) { + for (let essai = 0; ; essai += 1) { + flushSync(); + try { + return assertion(); + } catch (erreur) { + if (essai >= 200) throw erreur; + await new Promise((resoudre) => requestAnimationFrame(resoudre)); + } + } +} + +const deuxImages = async () => { + for (let i = 0; i < 2; i += 1) await new Promise((resoudre) => requestAnimationFrame(resoudre)); + flushSync(); +}; + +/** Clique la commande de vue nom, puis met le rendu à jour. */ +function cliquer(m, nom) { + const bouton = dans(m, `[data-commande="${nom}"]`); + expect(bouton, nom).not.toBeNull(); + bouton.click(); + flushSync(); +} + +/** Choisit le tour r dans le sélecteur de la barre de vue. */ +function choisirTour(m, r) { + const select = dans(m, '[data-champ="tour"]'); + select.value = String(r); + select.dispatchEvent(new Event('change', { bubbles: true })); + flushSync(); +} + +/** Un cran de molette réel au centre du cadre, ou au point donné, et le rendu à jour. */ +async function cran(m, deltaY, point = null) { + const cadre = cadreDe(m); + const { x, y } = point ?? { x: cadre.gauche + cadre.largeur / 2, y: cadre.haut + cadre.hauteur / 2 }; + const avant = groupe(m).getAttribute('transform'); + await molette(x, y, deltaY); + await attendre(() => expect(groupe(m).getAttribute('transform')).not.toBe(avant)); +} + +/** Le texte de l'état et du journal de l'événement ouvert, et sa révision. */ +function fichiers(m) { + const { base, revision } = m.seance.etat().evenement; + return { + etat: m.fs.contenu('documents', `${base}${SUFFIXES.etat}`), + journal: m.fs.contenu('documents', `${base}${SUFFIXES.journal}`), + revision, + }; +} + +/** Le cadrage de l'événement ouvert lu dans reglages_locaux.json, ou undefined. */ +function cadrageEcrit(m) { + const texte = m.fs.contenu('documents', FICHIER_REGLAGES); + if (texte === null) return undefined; + const { id } = m.seance.etat().charge.evenement; + return JSON.parse(texte).cadrages.find(({ evenement }) => evenement === id); +} + +/** Un montage où la petite démonstration est chargée et ouverte en lecture, + * sur la section du plan, la vue posée. */ +async function petite(options) { + const m = await monter(options); + await m.facade.chargerDemonstration('petite'); + flushSync(); + expect(m.ui.etat().section).toBe('plan'); + await attendre(() => expect(groupe(m).getAttribute('transform')).not.toBeNull()); + return m; +} + +// Le plus grand écart entre dessinDepuisEcran sous la vue relue et l'oracle +// du moteur de rendu, getScreenCTM().inverse() du groupe, sur une grille de +// 5 × 5 points du cadre, bords et coins compris ; en px d'écran, l'écart en +// cm multiplié par k. cadre : celui que lit dessinDepuisEcran, par défaut +// relu maintenant. +function ecartOracle(m, cadre = cadreDe(m)) { + const lu = cadreDe(m); + const inverse = groupe(m).getScreenCTM().inverse(); + const vue = vueLue(m); + let ecart = 0; + let points = 0; + for (const fx of [0, 0.25, 0.5, 0.75, 1]) { + for (const fy of [0, 0.25, 0.5, 0.75, 1]) { + const x = lu.gauche + fx * lu.largeur; + const y = lu.haut + fy * lu.hauteur; + const oracle = new DOMPoint(x, y).matrixTransform(inverse); + const calcule = dessinDepuisEcran(vue, cadre, x, y); + ecart = Math.max(ecart, Math.abs(oracle.x - calcule.x), Math.abs(oracle.y - calcule.y)); + points += 1; + } + } + expect(points).toBe(25); + return ecart * vue.k; +} + +// Les fautes des trois invariants du § 7.2 sur la racine du plan, dans le +// thème courant : viewBox, largeurs de bordure et de remplissage non nulles, +// transformation sur la racine ou un ancêtre jusqu'à . +function fautesInvariants(m) { + const fautes = []; + const svg = racinePlan(m); + if (svg.hasAttribute('viewBox')) fautes.push('viewBox'); + const style = getComputedStyle(svg); + const largeurs = ['Top', 'Right', 'Bottom', 'Left'].flatMap((cote) => [`border${cote}Width`, `padding${cote}`]); + expect(largeurs.length).toBe(8); + for (const propriete of largeurs) if (style[propriete] !== '0px') fautes.push(`${propriete} ${style[propriete]}`); + let porteurs = 0; + for (let porteur = svg; porteur !== null; porteur = porteur.parentElement) { + const calcule = getComputedStyle(porteur); + for (const propriete of ['transform', 'translate', 'rotate', 'scale']) { + if (calcule[propriete] !== 'none') fautes.push(`${porteur.tagName} ${propriete} ${calcule[propriete]}`); + } + if (calcule.zoom !== '1') fautes.push(`${porteur.tagName} zoom ${calcule.zoom}`); + porteurs += 1; + } + expect(porteurs).toBeGreaterThan(3); + return fautes; +} + +// --- Les épreuves ----------------------------------------------------------------- + +describe('l’oracle du moteur de rendu (§ 7.2, § 14.10)', () => { + test('à k_min, k_ajusté, 1,25 × k_ajusté et 10, avant et après un défilement de la page, dessinDepuisEcran égale getScreenCTM().inverse() à 1e-3 px près ; un cadre mémorisé au montage tombe', async () => { + const m = await petite(); + // Une page plus haute que la fenêtre. + const haut = document.createElement('div'); + haut.style.height = '2000px'; + document.body.append(haut); + ajouts.push(haut); + expect(document.documentElement.scrollHeight).toBeGreaterThan(innerHeight + 300); + + const releves = []; + const relever = (visee) => { + expect(vueLue(m).k).toBe(visee); + window.scrollTo(0, 0); + const memorise = cadreDe(m); + expect(ecartOracle(m)).toBeLessThan(TOLERANCE_ORACLE_PX); + window.scrollTo(0, 300); + expect(window.scrollY).toBe(300); + expect(cadreDe(m).haut).toBe(memorise.haut - 300); + expect(ecartOracle(m)).toBeLessThan(TOLERANCE_ORACLE_PX); + // Le cadre lu avant le défilement répond faux après lui. + expect(ecartOracle(m, memorise)).toBeGreaterThan(299); + window.scrollTo(0, 0); + releves.push(visee); + }; + + cliquer(m, 'ajuster'); + const kAjusteVise = kAjusteDe(m); + relever(kAjusteVise); + cliquer(m, 'zoomer'); + relever(1.25 * kAjusteVise); + for (let precedent = null; precedent !== vueLue(m).k; ) { + precedent = vueLue(m).k; + cliquer(m, 'dezoomer'); + } + relever(FACTEUR_K_MIN * kAjusteVise); + for (let precedent = null; precedent !== vueLue(m).k; ) { + precedent = vueLue(m).k; + cliquer(m, 'zoomer'); + } + relever(K_MAX); + expect(releves.length).toBe(4); + }); +}); + +describe('les trois invariants du (§ 7.2)', () => { + test('pas de viewBox ; contrôle d’échec : un viewBox ajouté fait tomber l’épreuve et décale l’oracle', async () => { + const m = await petite(); + expect(racinePlan(m).hasAttribute('viewBox')).toBe(false); + expect(fautesInvariants(m)).toEqual([]); + expect(ecartOracle(m)).toBeLessThan(TOLERANCE_ORACLE_PX); + racinePlan(m).setAttribute('viewBox', '0 0 100 100'); + expect(fautesInvariants(m)).toContain('viewBox'); + expect(ecartOracle(m)).toBeGreaterThan(1); + racinePlan(m).removeAttribute('viewBox'); + }); + + test('ni bordure ni remplissage, dans les deux thèmes ; contrôle d’échec : « border: 1px solid; padding: 2px » fait tomber l’épreuve et décale l’oracle', async () => { + const m = await petite(); + for (const theme of ['clair', 'sombre']) { + await sousTheme(theme, async () => { + flushSync(); + expect(fautesInvariants(m), theme).toEqual([]); + expect(ecartOracle(m), theme).toBeLessThan(TOLERANCE_ORACLE_PX); + }); + } + const retirer = feuille('[data-plan] { border: 1px solid !important; padding: 2px !important; }'); + expect(fautesInvariants(m).length).toBe(8); + expect(ecartOracle(m)).toBeGreaterThan(1); + retirer(); + }); + + test('aucune transformation de la racine à ; contrôle d’échec : transform: translateZ(0) sur un conteneur', async () => { + const m = await petite(); + expect(fautesInvariants(m)).toEqual([]); + const retirer = feuille('[data-section="plan"] { transform: translateZ(0); }'); + expect(fautesInvariants(m).some((faute) => faute.startsWith('SECTION transform'))).toBe(true); + retirer(); + expect(fautesInvariants(m)).toEqual([]); + }); + + test('touch-action: none au style calculé de la racine et de la surface (§ 7.3)', async () => { + const m = await petite(); + expect(getComputedStyle(racinePlan(m)).touchAction).toBe('none'); + expect(getComputedStyle(surface(m)).touchAction).toBe('none'); + }); +}); + +// Zoome à la molette, par « Zoom + » et par « Zoom moins » en restant au-dessus +// des seuils de rendu, sous un MutationObserver posé sur la racine ; rend +// ses relevés et la largeur de la barre d'échelle avant et après. +async function zoomsObserves(m) { + const barre = () => dans(m, '[data-marque="echelle"]'); + const largeurAvant = barre().getBoundingClientRect().width; + const releves = []; + const observateur = new MutationObserver((liste) => releves.push(...liste)); + observateur.observe(racinePlan(m), { attributes: true, childList: true, subtree: true, characterData: true }); + await cran(m, -100); + await cran(m, -100); + cliquer(m, 'zoomer'); + cliquer(m, 'dezoomer'); + await cran(m, 100); + await deuxImages(); + releves.push(...observateur.takeRecords()); + observateur.disconnect(); + return { releves, largeurAvant, largeurApres: barre().getBoundingClientRect().width }; +} + +describe('zoomer écrit un attribut sur un élément (§ 7.2)', () => { + test('molette, « Zoom + » et « Zoom moins » : seul le transform du groupe change sous la racine, il décrit la vue tenue, et la barre d’échelle, hors du , change de longueur', async () => { + const m = await petite(); + cliquer(m, 'ajuster'); + await deuxImages(); + // Au-dessus des seuils de rendu : chaises dessinées, listes rendues. + const k = vueLue(m).k; + const etat = etatPlaces(m.seance.etat().charge, { tour: 1 }); + expect(etat.tables.length).toBe(4); + for (const table of etat.tables) expect(regimeCibles(k / 1.1, geometrieTable(table, table.capacite))).toBe('sieges'); + expect(k / 1.1).toBeGreaterThan(constante('K_LISTES')); + expect(tous(m, '[data-liste]').length).toBe(4); + expect(tous(m, '[data-siege]').length).toBe(12); + const { releves, largeurAvant, largeurApres } = await zoomsObserves(m); + expect(releves.length).toBeGreaterThan(0); + for (const releve of releves) { + expect([releve.type, releve.attributeName, releve.target]).toEqual(['attributes', 'transform', groupe(m)]); + } + expect(vueLue(m)).toEqual(m.seance.cadrage()); + expect(largeurApres).not.toBe(largeurAvant); + }); + + test('contrôle d’échec : la même échelle montée sous le fait tomber l’épreuve', async () => { + const m = await petite(); + cliquer(m, 'ajuster'); + await deuxImages(); + // La barre, sa graduation et la mention : le bloc entier d'EchellePlan. + const echelle = dans(m, '[data-marque="echelle"]').parentElement.parentElement; + expect(surface(m).contains(echelle)).toBe(true); + const etranger = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject'); + etranger.setAttribute('width', '300'); + etranger.setAttribute('height', '80'); + racinePlan(m).append(etranger); + etranger.append(echelle); + const { releves } = await zoomsObserves(m); + expect(releves.some((releve) => releve.target !== groupe(m) || releve.attributeName !== 'transform')).toBe(true); + }); +}); + +describe('la molette zoome sans faire défiler (§ 7.2, § 7.3)', () => { + test('page plus haute que la fenêtre, surface défilable : des crans réels sur [data-plan] changent le transform, et ni la page ni aucun ancêtre ne défile', async () => { + const m = await petite(); + const haut = document.createElement('div'); + haut.style.height = '2000px'; + document.body.append(haut); + ajouts.push(haut); + // La surface défile : une hauteur fixe, un plan de 400 px, et une cale + // de 2 000 px sous lui. Le garde ses invariants. + feuille( + '[data-surface] { flex: none !important; height: 600px !important; overflow-y: auto !important; }' + + ' [data-plan] { flex: none !important; height: 400px !important; }', + ); + const cale = document.createElement('div'); + cale.style.cssText = 'flex: none; height: 2000px;'; + surface(m).append(cale); + ajouts.push(cale); + await deuxImages(); + // Chaque défileur part du milieu : un cran vers le haut comme vers le + // bas trouverait de quoi défiler. + window.scrollTo(0, 100); + surface(m).scrollTop = 50; + await deuxImages(); + expect(window.scrollY).toBe(100); + expect(surface(m).scrollTop).toBe(50); + expect(surface(m).scrollHeight).toBeGreaterThan(surface(m).clientHeight + 100); + const ancetres = []; + for (let porteur = racinePlan(m); porteur !== null; porteur = porteur.parentElement) ancetres.push(porteur); + expect(ancetres.length).toBeGreaterThan(3); + const defilements = () => [window.scrollX, window.scrollY, ...ancetres.flatMap((porteur) => [porteur.scrollLeft, porteur.scrollTop])]; + const avant = defilements(); + const rect = racinePlan(m).getBoundingClientRect(); + const point = { x: rect.left + rect.width / 2, y: Math.max(rect.top, surface(m).getBoundingClientRect().top, 0) + 100 }; + expect(document.elementFromPoint(point.x, point.y)?.closest('[data-plan]')).toBe(racinePlan(m)); + const transform = groupe(m).getAttribute('transform'); + let crans = 0; + for (const deltaY of [100, -100, -100]) { + await cran(m, deltaY, point); + crans += 1; + } + expect(crans).toBe(3); + for (let i = 0; i < 10; i += 1) await deuxImages(); + expect(groupe(m).getAttribute('transform')).not.toBe(transform); + expect(defilements()).toEqual(avant); + }); +}); + +describe('le zoom sous le curseur (§ 7.2)', () => { + test('au centre de la boîte d’une table proche du bord droit, plusieurs crans dans les deux sens : ce centre reste sous le pointeur à moins d’un pixel', async () => { + const m = await petite(); + cliquer(m, 'ajuster'); + await deuxImages(); + const tables = tous(m, '[data-table]'); + expect(tables.length).toBe(4); + const droite = tables.reduce((a, b) => (b.getBoundingClientRect().right > a.getBoundingClientRect().right ? b : a)); + const pointeur = centre(droite); + const kAjusteVise = vueLue(m).k; + let crans = 0; + for (const deltaY of [-100, -100, -100, 100, 100, 100, 100, 100, -100]) { + await cran(m, deltaY, pointeur); + // Sans franchir de seuil de rendu : le régime des sièges tient. + expect(vueLue(m).k).toBeGreaterThan(kAjusteVise / 1.5); + expect(droite.querySelectorAll('[data-siege]').length).toBe(3); + const lu = centre(droite); + expect(Math.abs(lu.x - pointeur.x)).toBeLessThan(1); + expect(Math.abs(lu.y - pointeur.y)).toBeLessThan(1); + crans += 1; + } + expect(crans).toBe(9); + }); +}); + +describe('ajuster (§ 7.2, § 8.4)', () => { + test('après des zooms puis « Ajuster », la boîte rendue de chaque table tient dans le cadre du ', async () => { + const m = await petite(); + cliquer(m, 'zoomer'); + cliquer(m, 'zoomer'); + await cran(m, -300, { x: cadreDe(m).gauche + 10, y: cadreDe(m).haut + 10 }); + cliquer(m, 'ajuster'); + await deuxImages(); + const cadre = racinePlan(m).getBoundingClientRect(); + const tables = tous(m, '[data-table]'); + expect(tables.length).toBe(m.seance.etat().charge.tables.length); + expect(tables.length).toBeGreaterThan(0); + for (const table of tables) { + const b = table.getBoundingClientRect(); + expect(b.left).toBeGreaterThanOrEqual(cadre.left - 0.5); + expect(b.top).toBeGreaterThanOrEqual(cadre.top - 0.5); + expect(b.right).toBeLessThanOrEqual(cadre.right + 0.5); + expect(b.bottom).toBeLessThanOrEqual(cadre.bottom + 0.5); + } + expect(vueLue(m).k).toBe(kAjusteDe(m)); + }); + + test('un événement sans table : translate(L/2,H/2) scale(1), à l’ouverture et après « Ajuster »', async () => { + const m = await monter(); + await m.seance.creer(EVENEMENT); + flushSync(); + await attendre(() => expect(groupe(m).getAttribute('transform')).not.toBeNull()); + expect(tous(m, '[data-table]').length).toBe(0); + const { largeur, hauteur } = cadreDe(m); + expect(largeur).toBeGreaterThan(32); + expect(hauteur).toBeGreaterThan(32); + const attendu = transformDe({ k: 1, tx: largeur / 2, ty: hauteur / 2 }); + expect(groupe(m).getAttribute('transform')).toBe(attendu); + cliquer(m, 'zoomer'); + cliquer(m, 'ajuster'); + expect(groupe(m).getAttribute('transform')).toBe(attendu); + }); +}); + +// Vrai quand le groupe ne porte pas de transform, ou un transform aux +// nombres finis. +const transformFiniOuAbsent = (m) => { + const texte = groupe(m).getAttribute('transform'); + if (texte === null) return true; + const { k, tx, ty } = vueDepuisTransform(texte); + return [k, tx, ty].every(Number.isFinite); +}; + +describe('monté caché, cadre trop petit (§ 7.2, point d’attention 1)', () => { + test('surface en display: none → aucun transform non fini ; montrée → vueInitiale dans le cadre rendu ; 683 × 700 → vueApresCadre', async () => { + const montrer = feuille('[data-surface] { display: none !important; }'); + const m = await monter(); + await m.facade.chargerDemonstration('petite'); + flushSync(); + await deuxImages(); + expect(cadreDe(m).largeur).toBe(0); + expect(groupe(m).getAttribute('transform')).toBeNull(); + expect(transformFiniOuAbsent(m)).toBe(true); + montrer(); + await attendre(() => expect(groupe(m).getAttribute('transform')).not.toBeNull()); + expect(groupe(m).getAttribute('transform')).toBe(transformDe(vueInitiale(m.seance.cadrage(), englobantDe(m), cadreDe(m)))); + const avant = vueLue(m); + const largeurAvant = cadreDe(m).largeur; + await fixerCadre(683, 700); + await attendre(() => expect(cadreDe(m).largeur).not.toBe(largeurAvant)); + await deuxImages(); + expect(groupe(m).getAttribute('transform')).toBe(transformDe(vueApresCadre(avant, englobantDe(m), cadreDe(m)))); + }); + + test('dézoomé à k_min à 683 × 700, puis élargi à 1 366 × 700 → vueApresCadre relève k au k_min du nouveau cadre', async () => { + // Un cadre qui rétrécit abaisse k_min et laisse la vue telle quelle ; un + // cadre qui s'élargit relève k_min au-dessus d'une vue dézoomée à fond : + // seul ce sens distingue vueApresCadre d'une vue gardée inchangée. + await fixerCadre(683, 700); + const m = await petite(); + for (let precedent = null; precedent !== vueLue(m).k; ) { + precedent = vueLue(m).k; + cliquer(m, 'dezoomer'); + } + const auPlusBas = vueLue(m); + expect(auPlusBas.k).toBe(FACTEUR_K_MIN * kAjusteDe(m)); + const largeurPetite = cadreDe(m).largeur; + await fixerCadre(1366, 700); + await attendre(() => expect(cadreDe(m).largeur).toBeGreaterThan(largeurPetite)); + // La vue se recalcule à l'image qui suit l'observation de taille. + const attendu = () => transformDe(vueApresCadre(auPlusBas, englobantDe(m), cadreDe(m))); + await attendre(() => expect(groupe(m).getAttribute('transform')).toBe(attendu())); + expect(attendu()).not.toBe(transformDe(auPlusBas)); + expect(vueLue(m).k).toBe(FACTEUR_K_MIN * kAjusteDe(m)); + expect(vueLue(m).k).toBeGreaterThan(auPlusBas.k); + }); + + test('une surface de 20 × 20 px, au montage puis après un premier rendu : aucun transform non fini, rien au collecteur', async () => { + const css = '[data-surface] { flex: none !important; width: 20px !important; height: 20px !important; }'; + let retirer = feuille(css); + const m = await monter(); + await m.facade.chargerDemonstration('petite'); + flushSync(); + await deuxImages(); + expect(cadreDe(m).largeur).toBeLessThanOrEqual(20); + expect(groupe(m).getAttribute('transform')).toBeNull(); + retirer(); + await attendre(() => expect(groupe(m).getAttribute('transform')).not.toBeNull()); + const posee = groupe(m).getAttribute('transform'); + retirer = feuille(css); + await deuxImages(); + expect(cadreDe(m).largeur).toBeLessThanOrEqual(20); + expect(transformFiniOuAbsent(m)).toBe(true); + expect(groupe(m).getAttribute('transform')).toBe(posee); + retirer(); + await deuxImages(); + expect(transformFiniOuAbsent(m)).toBe(true); + expect(m.erreurs()).toEqual([]); + }); +}); + +describe('le cadrage (§ 8.5, § 13.3)', () => { + test('des zooms rapprochés n’écrivent ni l’état ni le journal ; une seule écriture des réglages après DELAI_REGLAGES_MS, qui porte la vue rendue ; en lecture, puis sur un plan bloqué', async () => { + const m = await petite(); + const verifier = async (gestes) => { + const avant = fichiers(m); + const ecritures = m.fs.compteurs.ecritures; + await gestes(); + await deuxImages(); + expect(fichiers(m)).toEqual(avant); + expect(m.fs.compteurs.ecritures).toBe(ecritures); + m.horloge.avancer(DELAI_REGLAGES_MS - 1); + await deuxImages(); + expect(m.fs.compteurs.ecritures).toBe(ecritures); + m.horloge.avancer(1); + await attendre(() => expect(m.fs.compteurs.ecritures).toBe(ecritures + 1)); + await deuxImages(); + expect(m.fs.compteurs.ecritures).toBe(ecritures + 1); + expect(fichiers(m)).toEqual(avant); + const ecrit = cadrageEcrit(m); + const lue = vueLue(m); + expect({ k: ecrit.k, tx: ecrit.tx, ty: ecrit.ty }).toEqual(lue); + }; + expect(m.seance.etat().mode).toBe('lecture'); + await verifier(async () => { + await cran(m, -100); + await cran(m, -100); + cliquer(m, 'zoomer'); + cliquer(m, 'dezoomer'); + cliquer(m, 'ajuster'); + await cran(m, 100); + }); + await m.seance.passerEnEcriture(); + await m.seance.executer('changerEtat', { etat: 'bloque' }); + flushSync(); + expect(m.seance.etat().evenement.etat).toBe('bloque'); + expect(m.seance.etat().mode).toBe('lecture'); + await verifier(async () => { + for (let i = 0; i < 20; i += 1) await cran(m, i % 3 === 0 ? 100 : -100); + cliquer(m, 'zoomer'); + cliquer(m, 'ajuster'); + cliquer(m, 'dezoomer'); + }); + // Une suite qui finit par « Ajuster » : la vue ajustée est celle qui + // s'écrit, et non celle du zoom qui la précède. + await verifier(async () => { + await cran(m, -100); + cliquer(m, 'zoomer'); + cliquer(m, 'ajuster'); + }); + expect(groupe(m).getAttribute('transform')).toBe(transformDe(ajuster(englobantDe(m), cadreDe(m)))); + }); + + test('démonté puis remonté sur le même disque : le même transform ; « Défaire » ne le change pas', async () => { + const premier = await petite(); + const base = premier.seance.etat().evenement.base; + await premier.seance.passerEnEcriture(); + await cran(premier, -100); + cliquer(premier, 'zoomer'); + // Un geste écrit, puis défait, ne touche pas à la vue. + const transform = groupe(premier).getAttribute('transform'); + await premier.seance.executer('creerTable', { forme: 'ronde' }); + flushSync(); + expect(groupe(premier).getAttribute('transform')).toBe(transform); + await premier.seance.defaire(); + flushSync(); + expect(premier.seance.etat().charge.tables.length).toBe(4); + expect(groupe(premier).getAttribute('transform')).toBe(transform); + premier.horloge.avancer(DELAI_REGLAGES_MS); + await attendre(() => expect(cadrageEcrit(premier)).toEqual({ evenement: premier.seance.etat().charge.evenement.id, ...vueLue(premier) })); + await premier.seance.passerEnLecture(); + demonter(premier); + + const second = await monter({ systeme: { disque: premier.disque }, horloge: premier.horloge }); + await second.seance.ouvrir(base); + flushSync(); + await attendre(() => expect(groupe(second).getAttribute('transform')).toBe(transform)); + await second.seance.passerEnEcriture(); + await second.seance.executer('creerTable', { forme: 'ronde' }); + await second.seance.defaire(); + flushSync(); + expect(groupe(second).getAttribute('transform')).toBe(transform); + }); + + test('un cadrage restitué dans un cadre plus petit reste dans ses bornes, tel que vueInitiale le borne ; dans un cadre plus grand, k_min le relève', async () => { + const premier = await petite(); + const base = premier.seance.etat().evenement.base; + for (let precedent = null; precedent !== vueLue(premier).k; ) { + precedent = vueLue(premier).k; + cliquer(premier, 'dezoomer'); + } + const memorise = vueLue(premier); + expect(memorise.k).toBe(FACTEUR_K_MIN * kAjusteDe(premier)); + premier.horloge.avancer(DELAI_REGLAGES_MS); + await attendre(() => expect(cadrageEcrit(premier)?.k).toBe(memorise.k)); + demonter(premier); + + await fixerCadre(683, 700); + const petitCadre = await monter({ systeme: { disque: premier.disque }, horloge: premier.horloge }); + await petitCadre.seance.ouvrir(base); + flushSync(); + await attendre(() => expect(groupe(petitCadre).getAttribute('transform')).not.toBeNull()); + const cadrePetit = cadreDe(petitCadre); + expect(groupe(petitCadre).getAttribute('transform')).toBe(transformDe(vueInitiale(memorise, englobantDe(petitCadre), cadrePetit))); + const { kMin, kMax } = bornesZoom(kAjuste(englobantDe(petitCadre), cadrePetit)); + expect(vueLue(petitCadre).k).toBeGreaterThanOrEqual(kMin); + expect(vueLue(petitCadre).k).toBeLessThanOrEqual(kMax); + // Dans ce cadre, k_min descend : le dézoom va plus loin, et se mémorise. + for (let precedent = null; precedent !== vueLue(petitCadre).k; ) { + precedent = vueLue(petitCadre).k; + cliquer(petitCadre, 'dezoomer'); + } + const auPlusBas = vueLue(petitCadre).k; + expect(auPlusBas).toBeLessThan(memorise.k); + petitCadre.horloge.avancer(DELAI_REGLAGES_MS); + await attendre(() => expect(cadrageEcrit(petitCadre)?.k).toBe(auPlusBas)); + demonter(petitCadre); + + await fixerCadre(1366, 700); + const grand = await monter({ systeme: { disque: premier.disque }, horloge: premier.horloge }); + await grand.seance.ouvrir(base); + flushSync(); + await attendre(() => expect(groupe(grand).getAttribute('transform')).not.toBeNull()); + expect(vueLue(grand).k).toBe(FACTEUR_K_MIN * kAjusteDe(grand)); + expect(vueLue(grand).k).toBeGreaterThan(auPlusBas); + }); + + test('ouvrir, attendre DELAI_REGLAGES_MS sans geste, changer de cadre, attendre encore : aucune écriture', async () => { + const m = await petite(); + const ecritures = m.fs.compteurs.ecritures; + m.horloge.avancer(DELAI_REGLAGES_MS); + await deuxImages(); + const largeur = cadreDe(m).largeur; + await fixerCadre(683, 700); + await attendre(() => expect(cadreDe(m).largeur).not.toBe(largeur)); + await deuxImages(); + m.horloge.avancer(DELAI_REGLAGES_MS); + await deuxImages(); + expect(m.fs.compteurs.ecritures).toBe(ecritures); + expect(m.fs.contenu('documents', FICHIER_REGLAGES) === null || cadrageEcrit(m) === undefined).toBe(true); + }); +}); + +// La signature de ce que le plan montre : le texte des listes, puis les +// marques de chaque chaise, puis les pastilles. +function montre(m) { + return { + listes: tous(m, '[data-liste]').map((liste) => liste.textContent), + marques: tous(m, '[data-siege]').map((chaise) => [ + chaise.getAttribute('data-siege'), + [...chaise.querySelectorAll('[data-marque]')].map((marque) => marque.getAttribute('data-marque')), + ]), + pastilles: tous(m, '[data-pastille]').map((pastille) => pastille.textContent), + }; +} + +describe('les commandes de vue n’écrivent rien et restent vivantes (§ 8.2, § 8.4)', () => { + test('écriture, lecture, plan bloqué : tour, zooms, ajuster, mise en évidence, listes, plein écran n’écrivent rien ; le tour change listes et marques', async () => { + const m = await petite(); + await m.seance.passerEnEcriture(); + const charge = m.seance.etat().charge; + const personne = charge.participants[0].id; + // Une réservation du seul tour 1 : une marque et une ligne qui ne + // paraissent qu'à ce tour. + await m.seance.executer('reserver', { participant: personne, table: 1, siege: 1, portee: 'tour', tour: 1 }); + flushSync(); + cliquer(m, 'ajuster'); + await deuxImages(); + + const parcourir = async (nom) => { + const avant = fichiers(m); + const transform = groupe(m).getAttribute('transform'); + choisirTour(m, 1); + await deuxImages(); + const tour1 = montre(m); + expect(tour1.listes.length, nom).toBe(4); + expect(tour1.marques.length, nom).toBe(12); + choisirTour(m, 2); + await deuxImages(); + expect(m.ui.etat().tour, nom).toBe(2); + const tour2 = montre(m); + expect(tour2.listes, nom).not.toEqual(tour1.listes); + expect(tour2.marques, nom).not.toEqual(tour1.marques); + choisirTour(m, 1); + cliquer(m, 'zoomer'); + expect(groupe(m).getAttribute('transform'), nom).not.toBe(transform); + cliquer(m, 'ajuster'); + m.ui.mettreEnEvidence(personne); + flushSync(); + await deuxImages(); + expect(tous(m, '[data-marque="miseEnEvidence"]').length, nom).toBe(1); + m.ui.mettreEnEvidence(null); + cliquer(m, 'listes'); + await deuxImages(); + expect(dans(m, '[data-commande="listes"]').getAttribute('aria-pressed'), nom).toBe('false'); + expect(tous(m, '[data-liste]').length, nom).toBe(0); + expect(tous(m, '[data-liste] tspan').length, nom).toBe(0); + cliquer(m, 'listes'); + await deuxImages(); + expect(tous(m, '[data-liste]').length, nom).toBe(4); + cliquer(m, 'pleinEcran'); + await deuxImages(); + expect(m.ui.etat().pleinEcran, nom).toBe(true); + cliquer(m, 'pleinEcran'); + await deuxImages(); + expect(fichiers(m), nom).toEqual(avant); + expect(m.seance.etat().refus === null, nom).toBe(nom === 'écriture'); + }; + + await parcourir('écriture'); + await m.seance.passerEnLecture(); + flushSync(); + await parcourir('lecture'); + await m.seance.passerEnEcriture(); + await m.seance.executer('changerEtat', { etat: 'bloque' }); + flushSync(); + expect(m.seance.etat().evenement.etat).toBe('bloque'); + await parcourir('bloqué'); + }); +}); + +describe('le plein écran (§ 8.4, § 18.6)', () => { + test('la surface s’agrandit ; le cadre de mode, son libellé et le bandeau restent visibles ; ressorti, la surface retrouve sa boîte', async () => { + const m = await petite(); + await deuxImages(); + const avant = boite(surface(m)); + cliquer(m, 'pleinEcran'); + await deuxImages(); + const plein = boite(surface(m)); + expect(plein.width * plein.height).toBeGreaterThan(avant.width * avant.height); + expect(plein.height).toBeGreaterThan(avant.height); + expect(plein.width).toBeGreaterThan(avant.width); + const temoins = [dans(m, '[data-mode]'), dans(m, '[data-mode-libelle="cadre"]'), dans(m, '[data-mode-libelle="bandeau"]'), dans(m, '[data-bandeau]')]; + for (const temoin of temoins) { + expect(temoin).not.toBeNull(); + expect(visible(temoin)).toBe(true); + } + const cadre = dans(m, '[data-mode]').getBoundingClientRect(); + expect(cadre.left).toBeLessThanOrEqual(plein.left); + expect(cadre.right).toBeGreaterThanOrEqual(plein.right); + expect(cadre.bottom).toBeGreaterThanOrEqual(plein.bottom); + expect(dans(m, '[data-commande="pleinEcran"]').getAttribute('aria-pressed')).toBe('true'); + cliquer(m, 'pleinEcran'); + await deuxImages(); + expect(boite(surface(m))).toEqual(avant); + }); + + test('le cadre de mode garde sa boîte après un zoom', async () => { + const m = await petite(); + await deuxImages(); + const avant = boite(dans(m, '[data-mode]')); + await cran(m, -100); + cliquer(m, 'zoomer'); + cliquer(m, 'ajuster'); + await deuxImages(); + expect(boite(dans(m, '[data-mode]'))).toEqual(avant); + }); +}); + +describe('le panneau du diagnostic (§ 2.1, étape 8)', () => { + test('un événement ouvert : [data-panneau-diagnostic] visible dans la section du plan, sans aucun clic', async () => { + const m = await petite(); + await deuxImages(); + const section = dans(m, '[data-section="plan"]'); + const panneau = dans(m, '[data-panneau-diagnostic]'); + expect(panneau).not.toBeNull(); + expect(section.contains(panneau)).toBe(true); + expect(visible(panneau)).toBe(true); + }); +}); diff --git a/src/interface/plan/vue_plan.js b/src/interface/plan/vue_plan.js new file mode 100644 index 0000000..f7f4232 --- /dev/null +++ b/src/interface/plan/vue_plan.js @@ -0,0 +1,129 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// La vue du plan côté interface (§ 7.2, § 8.5, § 13.3), sans DOM : ce que +// Plan.svelte décide de sa vue — à l'ouverture, après un changement de +// cadre, à la molette et aux boutons de zoom —, composé des seules +// fonctions de src/geometrie/vue.js. Le cadre est un paramètre, relu par +// l'appelant au moment du geste ; aucune fonction ne lit le DOM. +// +// Une vue ne se calcule que dans un cadre où kAjuste rend un nombre : un +// cadre dont la largeur ou la hauteur ne dépasse pas 2 × MARGE_AJUSTEMENT_PX, +// nul compris — un plan monté caché —, ne fixe aucune borne, et bornesZoom +// lèverait sur null. Chaque fonction fait ce contrôle avant de borner. +import { nomAffiche } from '../../application/personnes.js'; +import { englobant, geometrieTable } from '../../geometrie/tables.js'; +import { ajuster, borner, bornesZoom, facteurMolette, kAjuste, zoomerSous } from '../../geometrie/vue.js'; + +/** Facteur de zoom des boutons de zoom, appliqué autour du centre du cadre. */ +export const FACTEUR_BOUTON = 1.25; + +/** + * Le Cadre du contrat depuis un DOMRect : gauche ← left, haut ← top, + * largeur ← width, hauteur ← height. Plan.svelte, les gestes et les + * épreuves n'en lisent pas d'autre. + * @param {{left: number, top: number, width: number, height: number}} rect + * @returns {{gauche: number, haut: number, largeur: number, hauteur: number}} + */ +export function cadreDepuisRect(rect) { + return { gauche: rect.left, haut: rect.top, largeur: rect.width, hauteur: rect.height }; +} + +/** + * Vue à l'ouverture : le cadrage mémorisé, son k ramené dans les bornes du + * cadre (borner) ; sans cadrage, l'ajustement. Null dès que kAjuste vaut + * null, cadrage mémorisé ou non. Le cadrage reçu n'est pas modifié. + * @param {{k: number, tx: number, ty: number}|null} cadrage + * @param {{minX: number, minY: number, maxX: number, maxY: number}|null} englobantPlan + * @param {{gauche: number, haut: number, largeur: number, hauteur: number}} cadre + * @returns {{k: number, tx: number, ty: number}|null} + */ +export function vueInitiale(cadrage, englobantPlan, cadre) { + const k = kAjuste(englobantPlan, cadre); + if (k === null) return null; + if (cadrage === null) return ajuster(englobantPlan, cadre); + return borner(cadrage, cadre, bornesZoom(k)); +} + +/** + * Vue après un changement de cadre : k ajusté recalculé sur le nouveau + * cadre, k ramené dans ses bornes, le point de dessin au centre du cadre y + * restant. Quand kAjuste vaut null, la vue reçue elle-même, inchangée. + * @returns {{k: number, tx: number, ty: number}} + */ +export function vueApresCadre(vue, englobantPlan, cadre) { + const k = kAjuste(englobantPlan, cadre); + if (k === null) return vue; + return borner(vue, cadre, bornesZoom(k)); +} + +/** + * Vue après un cran de molette : zoom sous le pointeur (clientX, clientY), + * du facteur de facteurMolette(deltaY, deltaMode), aux bornes du cadre. + * Null quand il n'y a rien à zoomer : vue null, ou cadre où kAjuste vaut null. + * @param {{k: number, tx: number, ty: number}|null} vue + * @param {{clientX: number, clientY: number, deltaY: number, deltaMode: number}} evenement + * @returns {{k: number, tx: number, ty: number}|null} + */ +export function zoomerMolette(vue, englobantPlan, cadre, { clientX, clientY, deltaY, deltaMode }) { + const k = vue === null ? null : kAjuste(englobantPlan, cadre); + if (k === null) return null; + return zoomerSous(vue, cadre, clientX, clientY, facteurMolette(deltaY, deltaMode), bornesZoom(k)); +} + +/** + * Vue après « Zoom + » (sens 1) ou « Zoom moins » (sens -1) : zoom de FACTEUR_BOUTON, + * ou de son inverse, autour du centre du cadre, aux bornes du cadre. Null + * quand il n'y a rien à zoomer, comme zoomerMolette. RangeError pour un + * autre sens. + * @param {1|-1} sens + * @returns {{k: number, tx: number, ty: number}|null} + */ +export function zoomerBouton(vue, englobantPlan, cadre, sens) { + if (sens !== 1 && sens !== -1) throw new RangeError(`sens du zoom : 1 ou -1 attendu, reçu ${String(sens)}`); + const k = vue === null ? null : kAjuste(englobantPlan, cadre); + if (k === null) return null; + const facteur = sens === 1 ? FACTEUR_BOUTON : 1 / FACTEUR_BOUTON; + const x = cadre.gauche + cadre.largeur / 2; + const y = cadre.haut + cadre.hauteur / 2; + return zoomerSous(vue, cadre, x, y, facteur, bornesZoom(k)); +} + +/** + * L'englobant des emprises des tables d'un état des places (EtatPlaces, + * A5), chacune à sa capacité courante, comme le modèle de dessin les pose + * (geometrieTable) ; null sans table. Il ne dépend ni du tour ni de k. + * @returns {{minX: number, minY: number, maxX: number, maxY: number}|null} + */ +export function englobantDuTour(etat) { + return englobant(etat.tables.map((table) => geometrieTable(table, table.capacite))); +} + +/** + * Les tours que le sélecteur offre : 1 à tours, croissants. + * @param {number} tours entier ≥ 1 + * @returns {number[]} + */ +export function toursOfferts(tours) { + return Array.from({ length: tours }, (_, rang) => rang + 1); +} + +/** + * Le tour que montre le plan : le tour demandé, ramené au dernier quand le + * nombre de tours a baissé depuis la demande — etatPlaces lève au-delà. + * @param {number} tour entier ≥ 1 @param {number} tours entier ≥ 1 + * @returns {number} + */ +export function tourAffiche(tour, tours) { + return Math.min(tour, tours); +} + +/** + * La fonction nomDe du modèle de dessin : l'identifiant d'un participant de + * la charge vers son nom affiché (nomAffiche, F1). + * @returns {(id: number) => string} + */ +export function nomDeCharge(charge) { + const noms = new Map(charge.participants.map((participant) => [participant.id, nomAffiche(participant)])); + return (id) => noms.get(id); +} diff --git a/src/interface/plan/vue_plan.test.js b/src/interface/plan/vue_plan.test.js new file mode 100644 index 0000000..73d249f --- /dev/null +++ b/src/interface/plan/vue_plan.test.js @@ -0,0 +1,251 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// La vue du plan côté interface (§ 7.2, § 8.5, § 13.3) : le cadre lu d'un +// DOMRect, la vue d'ouverture — cadrage mémorisé borné au cadre, sinon +// l'ajustement —, la vue d'après un changement de cadre, les zooms de la +// molette et des boutons, l'englobant d'un tour, le tour montré et les noms. +// Les valeurs attendues viennent de src/geometrie/vue.js, la seule +// implémentation des conversions, sollicitée une seconde fois. +import assert from 'node:assert/strict'; +import { describe, test } from '../../../test/lanceur.js'; +import { etatPlaces } from '../../application/places.js'; +import { englobant, geometrieTable } from '../../geometrie/tables.js'; +import { + FACTEUR_K_MIN, + K_MAX, + MARGE_AJUSTEMENT_PX, + ajuster, + bornesZoom, + facteurMolette, + kAjuste, + zoomerSous, +} from '../../geometrie/vue.js'; +import { creerCharge } from '../../stockage/document.js'; +import { + FACTEUR_BOUTON, + cadreDepuisRect, + englobantDuTour, + nomDeCharge, + tourAffiche, + toursOfferts, + vueApresCadre, + vueInitiale, + zoomerBouton, + zoomerMolette, +} from './vue_plan.js'; + +// Quatre tables de 3 places, centres à 300 cm : l'emprise de chacune a un +// rayon de 79 cm. +const ENGLOBANT = { minX: -79, minY: -79, maxX: 379, maxY: 379 }; +const CADRE = { gauche: 37, haut: 211, largeur: 1366, hauteur: 700 }; +const PETIT = { gauche: 37, haut: 211, largeur: 683, hauteur: 700 }; + +const finie = (vue) => [vue.k, vue.tx, vue.ty].every(Number.isFinite) && vue.k > 0; + +describe('cadreDepuisRect', () => { + test('gauche ← left, haut ← top, largeur ← width, hauteur ← height', () => { + assert.deepEqual(cadreDepuisRect({ left: 37, top: 211, width: 800, height: 600 }), { + gauche: 37, + haut: 211, + largeur: 800, + hauteur: 600, + }); + }); + + test('ne lit que ces quatre propriétés d’un DOMRect, x et y ignorés', () => { + const rect = { x: 1, y: 2, left: 3, top: 4, right: 13, bottom: 24, width: 10, height: 20 }; + assert.deepEqual(cadreDepuisRect(rect), { gauche: 3, haut: 4, largeur: 10, hauteur: 20 }); + }); +}); + +describe('vueInitiale (§ 7.2, § 8.5)', () => { + test('sans cadrage : l’ajustement', () => { + assert.deepEqual(vueInitiale(null, ENGLOBANT, CADRE), ajuster(ENGLOBANT, CADRE)); + }); + + test('un cadrage dans les bornes du cadre est rendu tel quel', () => { + const cadrage = { k: 1.3, tx: -40, ty: 25 }; + assert.deepEqual(vueInitiale(cadrage, ENGLOBANT, CADRE), cadrage); + }); + + test('un cadrage hors des bornes est ramené : sous k_min, au-dessus de 10', () => { + const kMin = FACTEUR_K_MIN * kAjuste(ENGLOBANT, CADRE); + const bas = vueInitiale({ k: kMin / 4, tx: 10, ty: 20 }, ENGLOBANT, CADRE); + assert.equal(bas.k, kMin); + const haut = vueInitiale({ k: 40, tx: 10, ty: 20 }, ENGLOBANT, CADRE); + assert.equal(haut.k, K_MAX); + assert.ok(finie(bas) && finie(haut)); + }); + + test('ramené, le point de dessin au centre du cadre y reste', () => { + const cadrage = { k: 40, tx: 10, ty: 20 }; + const vue = vueInitiale(cadrage, ENGLOBANT, CADRE); + const centre = (v) => [(CADRE.largeur / 2 - v.tx) / v.k, (CADRE.hauteur / 2 - v.ty) / v.k]; + const [ax, ay] = centre(cadrage); + const [bx, by] = centre(vue); + assert.ok(Math.abs(ax - bx) < 1e-9 && Math.abs(ay - by) < 1e-9); + }); + + test('un cadrage restitué dans un cadre plus grand, où k_min monte, est relevé à k_min', () => { + const kMinPetit = FACTEUR_K_MIN * kAjuste(ENGLOBANT, PETIT); + const kMinGrand = FACTEUR_K_MIN * kAjuste(ENGLOBANT, CADRE); + assert.ok(kMinPetit < kMinGrand); + assert.equal(vueInitiale({ k: kMinPetit, tx: 0, ty: 0 }, ENGLOBANT, CADRE).k, kMinGrand); + }); + + test('cadre nul → null, avec ou sans cadrage', () => { + const nul = { gauche: 0, haut: 0, largeur: 0, hauteur: 0 }; + assert.equal(vueInitiale(null, ENGLOBANT, nul), null); + assert.equal(vueInitiale({ k: 1, tx: 0, ty: 0 }, ENGLOBANT, nul), null); + assert.equal(vueInitiale(null, null, nul), null); + }); + + test('cadre de 20 × 20 → null, avec et sans cadrage ; 32 px de large aussi, 33 non', () => { + const minuscule = { gauche: 5, haut: 5, largeur: 20, hauteur: 20 }; + assert.equal(vueInitiale(null, ENGLOBANT, minuscule), null); + assert.equal(vueInitiale({ k: 1, tx: 0, ty: 0 }, ENGLOBANT, minuscule), null); + const limite = 2 * MARGE_AJUSTEMENT_PX; + assert.equal(vueInitiale(null, ENGLOBANT, { gauche: 0, haut: 0, largeur: limite, hauteur: 600 }), null); + const etroit = vueInitiale(null, ENGLOBANT, { gauche: 0, haut: 0, largeur: limite + 1, hauteur: 600 }); + assert.ok(finie(etroit)); + }); + + test('cadre de 33 × 600 → une vue aux trois nombres finis, k > 0', () => { + const vue = vueInitiale(null, ENGLOBANT, { gauche: 0, haut: 0, largeur: 33, hauteur: 600 }); + assert.ok(finie(vue)); + const memorisee = vueInitiale({ k: 2, tx: 3, ty: 4 }, ENGLOBANT, { gauche: 0, haut: 0, largeur: 33, hauteur: 600 }); + assert.ok(finie(memorisee)); + }); + + test('plan vide : k = 1, l’origine au centre du cadre ; un cadrage mémorisé se borne aussi', () => { + assert.deepEqual(vueInitiale(null, null, CADRE), { k: 1, tx: CADRE.largeur / 2, ty: CADRE.hauteur / 2 }); + assert.equal(vueInitiale({ k: 0.1, tx: 0, ty: 0 }, null, CADRE).k, FACTEUR_K_MIN); + }); + + test('ne modifie pas le cadrage reçu', () => { + const cadrage = Object.freeze({ k: 40, tx: 10, ty: 20 }); + vueInitiale(cadrage, ENGLOBANT, CADRE); + assert.deepEqual(cadrage, { k: 40, tx: 10, ty: 20 }); + }); +}); + +describe('vueApresCadre (§ 7.2)', () => { + test('k gardé dans les bornes du nouveau cadre', () => { + const kMinGrand = FACTEUR_K_MIN * kAjuste(ENGLOBANT, CADRE); + const vue = { k: FACTEUR_K_MIN * kAjuste(ENGLOBANT, PETIT), tx: 5, ty: 6 }; + const apres = vueApresCadre(vue, ENGLOBANT, CADRE); + assert.equal(apres.k, kMinGrand); + const { kMin, kMax } = bornesZoom(kAjuste(ENGLOBANT, CADRE)); + assert.ok(apres.k >= kMin && apres.k <= kMax); + }); + + test('un k déjà dans les bornes : la même vue, tx et ty compris', () => { + const vue = { k: 1, tx: 5, ty: 6 }; + assert.deepEqual(vueApresCadre(vue, ENGLOBANT, PETIT), vue); + }); + + test('cadre nul → la vue reçue, la même, sans lever', () => { + const vue = { k: 1, tx: 5, ty: 6 }; + assert.equal(vueApresCadre(vue, ENGLOBANT, { gauche: 0, haut: 0, largeur: 0, hauteur: 0 }), vue); + }); + + test('cadre de 20 × 20 → la vue reçue, la même, sans lever', () => { + const vue = { k: 1, tx: 5, ty: 6 }; + assert.equal(vueApresCadre(vue, ENGLOBANT, { gauche: 0, haut: 0, largeur: 20, hauteur: 20 }), vue); + }); +}); + +describe('les zooms de l’opérateur (§ 7.2)', () => { + test('molette : zoomerSous au point du pointeur, du facteur de facteurMolette, aux bornes du cadre', () => { + const vue = ajuster(ENGLOBANT, CADRE); + const evenement = { clientX: 400, clientY: 300, deltaY: -100, deltaMode: 0 }; + const attendue = zoomerSous(vue, CADRE, 400, 300, facteurMolette(-100, 0), bornesZoom(kAjuste(ENGLOBANT, CADRE))); + assert.deepEqual(zoomerMolette(vue, ENGLOBANT, CADRE, evenement), attendue); + }); + + test('molette : vingt crans vers l’arrière s’arrêtent à k_min, vingt vers l’avant à 10', () => { + const depart = ajuster(ENGLOBANT, CADRE); + let vue = depart; + for (let i = 0; i < 20; i += 1) vue = zoomerMolette(vue, ENGLOBANT, CADRE, { clientX: 100, clientY: 100, deltaY: 100, deltaMode: 0 }); + assert.equal(vue.k, FACTEUR_K_MIN * kAjuste(ENGLOBANT, CADRE)); + for (let i = 0; i < 60; i += 1) vue = zoomerMolette(vue, ENGLOBANT, CADRE, { clientX: 100, clientY: 100, deltaY: -100, deltaMode: 0 }); + assert.equal(vue.k, K_MAX); + }); + + test('bouton : facteur 1,25 autour du centre du cadre, dans un sens et dans l’autre', () => { + assert.equal(FACTEUR_BOUTON, 1.25); + const vue = ajuster(ENGLOBANT, CADRE); + const bornes = bornesZoom(kAjuste(ENGLOBANT, CADRE)); + const cx = CADRE.gauche + CADRE.largeur / 2; + const cy = CADRE.haut + CADRE.hauteur / 2; + assert.deepEqual(zoomerBouton(vue, ENGLOBANT, CADRE, 1), zoomerSous(vue, CADRE, cx, cy, 1.25, bornes)); + assert.deepEqual(zoomerBouton(vue, ENGLOBANT, CADRE, -1), zoomerSous(vue, CADRE, cx, cy, 1 / 1.25, bornes)); + assert.ok(zoomerBouton(vue, ENGLOBANT, CADRE, 1).k > vue.k); + assert.ok(zoomerBouton(vue, ENGLOBANT, CADRE, -1).k < vue.k); + }); + + test('bouton : un sens qui n’est ni 1 ni -1 lève', () => { + const vue = ajuster(ENGLOBANT, CADRE); + assert.throws(() => zoomerBouton(vue, ENGLOBANT, CADRE, 0), RangeError); + assert.throws(() => zoomerBouton(vue, ENGLOBANT, CADRE, 2), RangeError); + }); + + test('vue null ou cadre trop petit : null, rien à zoomer, sans lever', () => { + const vue = { k: 1, tx: 0, ty: 0 }; + const minuscule = { gauche: 0, haut: 0, largeur: 20, hauteur: 20 }; + const molette = { clientX: 0, clientY: 0, deltaY: -100, deltaMode: 0 }; + assert.equal(zoomerMolette(null, ENGLOBANT, CADRE, molette), null); + assert.equal(zoomerMolette(vue, ENGLOBANT, minuscule, molette), null); + assert.equal(zoomerBouton(null, ENGLOBANT, CADRE, 1), null); + assert.equal(zoomerBouton(vue, ENGLOBANT, minuscule, 1), null); + }); +}); + +// Une charge de deux tables, ronde de 6 et carrée de 5, et trois tours. +function chargeDEpreuve() { + const charge = creerCharge({ id: 'epreuve-vue', nom: 'Club des Merles', siegesParDefaut: 6, tours: 3 }); + charge.tables = [ + { id: 1, numero: 1, sieges: null, forme: 'ronde', position: { x: 0, y: 0 } }, + { id: 2, numero: 2, sieges: 5, forme: 'carree', position: { x: 300, y: 120 } }, + ]; + charge.participants = [ + { id: 1, nom: 'Ombrelle', prenom: 'Iris', appartenance: null, courriel: null, titrePressenti: null, notes: null, exclu: false }, + { id: 2, nom: 'Grisaille', prenom: null, appartenance: null, courriel: null, titrePressenti: null, notes: null, exclu: false }, + ]; + return charge; +} + +describe('l’englobant d’un tour, le tour montré, les noms', () => { + test('englobantDuTour : celui des géométries des tables à leur capacité', () => { + const charge = chargeDEpreuve(); + const etat = etatPlaces(charge, { tour: 1 }); + assert.equal(etat.tables.length, 2); + const attendu = englobant([geometrieTable(charge.tables[0], 6), geometrieTable(charge.tables[1], 5)]); + assert.deepEqual(englobantDuTour(etat), attendu); + }); + + test('englobantDuTour : null sans table', () => { + const charge = chargeDEpreuve(); + charge.tables = []; + assert.equal(englobantDuTour(etatPlaces(charge, { tour: 1 })), null); + }); + + test('toursOfferts : 1 à R, croissants', () => { + assert.deepEqual(toursOfferts(1), [1]); + assert.deepEqual(toursOfferts(4), [1, 2, 3, 4]); + }); + + test('tourAffiche : le tour demandé, ramené au dernier quand le nombre de tours a baissé', () => { + assert.equal(tourAffiche(2, 3), 2); + assert.equal(tourAffiche(3, 3), 3); + assert.equal(tourAffiche(5, 3), 3); + assert.equal(tourAffiche(1, 1), 1); + }); + + test('nomDeCharge : le nom affiché de chaque participant, par identifiant', () => { + const nomDe = nomDeCharge(chargeDEpreuve()); + assert.equal(nomDe(1), 'Iris Ombrelle'); + assert.equal(nomDe(2), 'Grisaille'); + }); +});