[ADD] interface: plan view — zoom, framing, fit, scale, side panels
The drawing becomes a plan the operator can look around. One transform on the drawing group holds the view, so the screen and the drawing share the formula of src/geometrie; the wheel zooms about the pointer without scrolling the page, « + », « − » and « Ajuster » bound the scale, and a resize keeps the view inside its limits. Only a deliberate gesture saves the framing, deferred, into reglages_locaux.json — in read mode and on a blocked plan too. The side panels take their first form. Checked: 2368 node and 378 browser tests. --- FR --- [ADD] interface : vue du plan — zoom, cadrage, ajuster, échelle Le dessin devient un plan que l'opérateur parcourt. Une seule transformation du groupe de dessin tient la vue : l'écran et le dessin partagent la formule de src/geometrie ; la molette zoome autour du pointeur sans faire défiler la page, « + », « − » et « Ajuster » bornent l'échelle, et un redimensionnement garde la vue dans ses limites. Seul un geste voulu enregistre le cadrage, en différé, dans reglages_locaux.json — en lecture et sur un plan bloqué aussi. Les panneaux latéraux prennent leur première forme. Vérifié : 2368 node et 378 navigateur. Assisted-by: Claude Opus 5.5
This commit is contained in:
parent
134e7850b7
commit
682e69876b
11 changed files with 1636 additions and 3 deletions
|
|
@ -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'],
|
||||
]);
|
||||
|
|
|
|||
84
src/interface/plan/BarreVue.svelte
Normal file
84
src/interface/plan/BarreVue.svelte
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// La barre de vue du plan (§ 7.2, § 7.7, § 8.4) : le sélecteur de tour,
|
||||
// « Zoom + », « Zoom moins », « Ajuster », l'affichage des listes et le plein
|
||||
// écran. Des contrôles HTML, hors du <svg data-plan>, tous vivants en
|
||||
// lecture comme sur un plan bloqué : aucun n'écrit l'événement. Le tour,
|
||||
// les listes et le plein écran passent par l'état de navigation ('ui') ;
|
||||
// les zooms et l'ajustement par Plan.svelte, qui tient la vue :
|
||||
// zoomer(sens), sens valant 1 ou -1, et ajuster(). Un bouton à bascule dit
|
||||
// son état par aria-pressed, que la forme du bouton redit.
|
||||
import { getContext } from 'svelte';
|
||||
import { toursOfferts } from './vue_plan.js';
|
||||
|
||||
let { tour, tours, zoomer, ajuster } = $props();
|
||||
|
||||
const t = getContext('t');
|
||||
const ui = getContext('ui');
|
||||
|
||||
const offerts = $derived(toursOfferts(tours));
|
||||
</script>
|
||||
|
||||
<div class="barre-vue" role="toolbar" aria-label={t('plan.vue.barre')}>
|
||||
<label class="tour">
|
||||
<span>{t('plan.vue.tour')}</span>
|
||||
<select data-champ="tour" value={tour} onchange={(evenement) => ui.montrerTour(Number(evenement.currentTarget.value))}>
|
||||
{#each offerts as numero (numero)}
|
||||
<option value={numero}>{numero}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" data-commande="zoomer" onclick={() => zoomer(1)}>{t('plan.vue.zoomer')}</button>
|
||||
<button type="button" data-commande="dezoomer" onclick={() => zoomer(-1)}>{t('plan.vue.dezoomer')}</button>
|
||||
<button type="button" data-commande="ajuster" onclick={() => ajuster()}>{t('plan.vue.ajuster')}</button>
|
||||
<button type="button" data-commande="listes" aria-pressed={$ui.listes} onclick={() => ui.basculerListes()}
|
||||
>{t('plan.vue.listes')}</button
|
||||
>
|
||||
<button type="button" data-commande="pleinEcran" aria-pressed={$ui.pleinEcran} onclick={() => ui.basculerPleinEcran()}
|
||||
>{t('plan.vue.pleinEcran')}</button
|
||||
>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.barre-vue {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--espace-2);
|
||||
}
|
||||
|
||||
.tour {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--espace-1);
|
||||
}
|
||||
|
||||
button,
|
||||
select {
|
||||
padding: var(--espace-1) var(--espace-3);
|
||||
border: 1px solid var(--couleur-page-filet);
|
||||
border-radius: var(--rayon-1);
|
||||
background-color: var(--couleur-page-fond);
|
||||
color: var(--couleur-page-texte);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Une bascule active se distingue par sa forme : trait intérieur épais,
|
||||
texte souligné ; sa boîte ne change pas, et la barre ne bouge pas. */
|
||||
button[aria-pressed='true'] {
|
||||
box-shadow: inset 0 0 0 2px var(--couleur-page-texte);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
select:focus-visible {
|
||||
outline: 2px solid var(--couleur-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
</style>
|
||||
22
src/interface/plan/ListesTravail.svelte
Normal file
22
src/interface/plan/ListesTravail.svelte
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// Les listes de travail du plan sous leur forme minimale : leur titre,
|
||||
// tiré des libellés. Le panneau lit 'seance' et 'ui' au contexte, sans
|
||||
// propriété de SectionPlan.
|
||||
import { getContext } from 'svelte';
|
||||
|
||||
const t = getContext('t');
|
||||
</script>
|
||||
|
||||
<section>
|
||||
<h3>{t('plan.listesTravail.titre')}</h3>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: var(--taille-texte);
|
||||
}
|
||||
</style>
|
||||
7
src/interface/plan/PanneauPlace.svelte
Normal file
7
src/interface/plan/PanneauPlace.svelte
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// Le panneau d'une place sous sa forme minimale : il ne rend rien.
|
||||
// SectionPlan le monte sans propriété ; ce qu'il montre se lit au contexte.
|
||||
</script>
|
||||
7
src/interface/plan/PanneauTable.svelte
Normal file
7
src/interface/plan/PanneauTable.svelte
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// Le panneau d'une table sous sa forme minimale : il ne rend rien.
|
||||
// SectionPlan le monte sans propriété ; ce qu'il montre se lit au contexte.
|
||||
</script>
|
||||
181
src/interface/plan/Plan.svelte
Normal file
181
src/interface/plan/Plan.svelte
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// Le plan de salle et sa vue (§ 7.2, § 7.3, § 7.7, § 8.4, § 8.5) : la
|
||||
// barre de vue, puis la surface [data-surface] qui porte le bandeau du
|
||||
// plan, le <svg data-plan> et l'échelle graphique. Le <svg> racine n'a ni
|
||||
// viewBox, ni bordure, ni remplissage : son unité est le pixel CSS, et le
|
||||
// dessin vit dans un seul groupe <g data-vue>, dont l'attribut transform
|
||||
// est la vue, {k, tx, ty}. Un zoom n'écrit que cet attribut sous la
|
||||
// racine ; l'échelle et le bandeau, hors du <svg>, gardent leur taille à
|
||||
// l'écran.
|
||||
//
|
||||
// Le composant est le seul à calculer le modèle de dessin (modele.js), de
|
||||
// l'état des places du tour montré, au k de sa vue. La vue est null tant
|
||||
// qu'aucun cadre ne la permet — plan monté caché, cadre où kAjuste vaut
|
||||
// null — : rien ne se dessine alors, et le groupe ne porte pas de
|
||||
// transform. Un ResizeObserver sur la racine relit le cadre à chaque
|
||||
// changement de taille : tant que la vue est null, il la calcule par
|
||||
// vueInitiale, du cadrage que la séance a mémorisé ; ensuite par
|
||||
// vueApresCadre. Ni l'une ni l'autre ne s'écrit : seul un geste de
|
||||
// l'opérateur — molette, « Zoom + », « Zoom moins », « Ajuster » — confie sa
|
||||
// vue à seance.demanderCadrage, qui l'écrit, différée, dans
|
||||
// reglages_locaux.json, jamais dans l'événement.
|
||||
//
|
||||
// Le cadre se relit au moment du geste, par getBoundingClientRect de la
|
||||
// racine : un défilement de la page le déplace sans changer sa taille.
|
||||
// Les écouteurs se posent sur la racine par addEventListener — Svelte 5
|
||||
// délègue au conteneur de montage un gestionnaire déclaré dans le
|
||||
// gabarit —, celui de la molette non passif, pour que la page ne défile
|
||||
// pas sous un zoom. touch-action: none sur la racine et la surface : le
|
||||
// navigateur ne prend aucun glissement du doigt pour un défilement.
|
||||
//
|
||||
// diagnostic : celui que rend diagnostiquer, que SectionPlan passe, ou
|
||||
// null — le bandeau ne porte alors que la phrase des conflits (§ 7.5).
|
||||
import { getContext } from 'svelte';
|
||||
import { etatPlaces } from '../../application/places.js';
|
||||
import { ajuster as vueAjustee, transformDe } from '../../geometrie/vue.js';
|
||||
import BandeauPlan from './BandeauPlan.svelte';
|
||||
import BarreVue from './BarreVue.svelte';
|
||||
import EchellePlan from './EchellePlan.svelte';
|
||||
import { modeleDessin } from './modele.js';
|
||||
import PlanDessin from './PlanDessin.svelte';
|
||||
import {
|
||||
cadreDepuisRect,
|
||||
englobantDuTour,
|
||||
nomDeCharge,
|
||||
tourAffiche,
|
||||
vueApresCadre,
|
||||
vueInitiale,
|
||||
zoomerBouton,
|
||||
zoomerMolette,
|
||||
} from './vue_plan.js';
|
||||
|
||||
let { diagnostic = null } = $props();
|
||||
|
||||
const t = getContext('t');
|
||||
const seance = getContext('seance');
|
||||
const ui = getContext('ui');
|
||||
|
||||
let racine = $state(null);
|
||||
let vue = $state.raw(null);
|
||||
|
||||
const charge = $derived($seance.charge);
|
||||
const tours = $derived(charge.evenement.tours);
|
||||
const tour = $derived(tourAffiche($ui.tour, tours));
|
||||
const etat = $derived(etatPlaces(charge, { tour, proposition: $ui.proposition }));
|
||||
const englobantPlan = $derived(englobantDuTour(etat));
|
||||
const nomDe = $derived(nomDeCharge(charge));
|
||||
const modele = $derived(
|
||||
vue === null
|
||||
? null
|
||||
: modeleDessin(etat, { k: vue.k, listes: $ui.listes, miseEnEvidence: $ui.miseEnEvidence, nomDe, diagnostic }),
|
||||
);
|
||||
|
||||
// Le cadre de la racine, relu maintenant.
|
||||
const cadreCourant = () => cadreDepuisRect(racine.getBoundingClientRect());
|
||||
|
||||
// Pose la vue d'un geste de l'opérateur et la confie à la séance ; une
|
||||
// vue null — rien à zoomer dans ce cadre — ne change rien.
|
||||
function poserVue(suivante) {
|
||||
if (suivante === null) return;
|
||||
vue = suivante;
|
||||
seance.demanderCadrage(suivante);
|
||||
}
|
||||
|
||||
function zoomer(sens) {
|
||||
poserVue(zoomerBouton(vue, englobantPlan, cadreCourant(), sens));
|
||||
}
|
||||
|
||||
function ajuster() {
|
||||
poserVue(vueAjustee(englobantPlan, cadreCourant()));
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const element = racine;
|
||||
// La vue d'un changement de cadre : la vue initiale tant qu'il n'y en a
|
||||
// pas, sinon la vue gardée dans les bornes du nouveau cadre. Elle se
|
||||
// calcule à l'image suivante, sur le cadre relu alors : ce qu'elle fait
|
||||
// paraître dans la surface — l'échelle, le bandeau — ne change pas le
|
||||
// cadre pendant la livraison des observations de taille, et plusieurs
|
||||
// changements d'une même image n'en font qu'un.
|
||||
let image = null;
|
||||
const recadrer = () => {
|
||||
image = null;
|
||||
const cadre = cadreCourant();
|
||||
vue = vue === null ? vueInitiale(seance.cadrage(), englobantPlan, cadre) : vueApresCadre(vue, englobantPlan, cadre);
|
||||
};
|
||||
const observateur = new ResizeObserver(() => {
|
||||
if (image === null) image = requestAnimationFrame(recadrer);
|
||||
});
|
||||
const surMolette = (evenement) => {
|
||||
evenement.preventDefault();
|
||||
poserVue(zoomerMolette(vue, englobantPlan, cadreCourant(), evenement));
|
||||
};
|
||||
observateur.observe(element);
|
||||
element.addEventListener('wheel', surMolette, { passive: false });
|
||||
return () => {
|
||||
observateur.disconnect();
|
||||
if (image !== null) cancelAnimationFrame(image);
|
||||
element.removeEventListener('wheel', surMolette);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="plan">
|
||||
<BarreVue {tour} {tours} {zoomer} {ajuster} />
|
||||
<div class="surface" data-surface>
|
||||
<BandeauPlan bandeau={modele === null ? [] : modele.bandeau} />
|
||||
<svg class="dessin" data-plan aria-label={t('plan.vue.dessin')} bind:this={racine}>
|
||||
<g data-vue transform={vue === null ? undefined : transformDe(vue)}>
|
||||
{#if modele !== null}
|
||||
<PlanDessin {modele} />
|
||||
{/if}
|
||||
</g>
|
||||
</svg>
|
||||
<!-- L'échelle occupe sa place avant que la vue n'existe, cachée : sa
|
||||
venue ne change pas le cadre du dessin. -->
|
||||
<div class="pied" class:cache={vue === null}>
|
||||
<EchellePlan k={vue === null ? 1 : vue.k} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.plan {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-2);
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.surface {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-2);
|
||||
min-height: 0;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.pied.cache {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
/* Ni bordure, ni remplissage, ni viewBox : un pixel CSS du <svg> est un
|
||||
pixel de l'écran, et le cadre est sa boîte (§ 7.2). */
|
||||
.dessin {
|
||||
display: block;
|
||||
flex: 1 1 0;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
touch-action: none;
|
||||
}
|
||||
</style>
|
||||
22
src/interface/plan/Reserve.svelte
Normal file
22
src/interface/plan/Reserve.svelte
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<!-- © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// La réserve du tour affiché sous sa forme minimale : son titre, tiré des
|
||||
// libellés. Le panneau lit 'seance' et 'ui' au contexte, sans propriété de
|
||||
// SectionPlan.
|
||||
import { getContext } from 'svelte';
|
||||
|
||||
const t = getContext('t');
|
||||
</script>
|
||||
|
||||
<section>
|
||||
<h3>{t('plan.reserve.titre')}</h3>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: var(--taille-texte);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -2,12 +2,86 @@
|
|||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->
|
||||
|
||||
<script>
|
||||
// La section du plan sous sa forme minimale : son titre, tiré des libellés de la coquille.
|
||||
// La section du plan (§ 2.1, étapes 7 et 8 ; § 7) : son titre, le plan et
|
||||
// sa vue (Plan.svelte), et les panneaux latéraux — le diagnostic compact,
|
||||
// dans [data-panneau-diagnostic], visible dès l'ouverture, sans clic ;
|
||||
// les listes de travail, la réserve, le panneau d'une place et celui
|
||||
// d'une table. Les panneaux lisent 'seance' et 'ui' au contexte : la
|
||||
// section ne leur passe rien. Le plan se remonte pour chaque événement :
|
||||
// un autre événement repart de sa propre vue, mémorisée ou ajustée.
|
||||
//
|
||||
// En plein écran, le titre et les panneaux s'effacent : le plan prend la
|
||||
// surface que le cadre de mode lui laisse sous le bandeau (§ 18.6), et le
|
||||
// cadre l'entoure toujours (§ 8.4).
|
||||
import { getContext } from 'svelte';
|
||||
import Diagnostic from '../diagnostic/Diagnostic.svelte';
|
||||
import ListesTravail from './ListesTravail.svelte';
|
||||
import PanneauPlace from './PanneauPlace.svelte';
|
||||
import PanneauTable from './PanneauTable.svelte';
|
||||
import Plan from './Plan.svelte';
|
||||
import Reserve from './Reserve.svelte';
|
||||
|
||||
const t = getContext('t');
|
||||
const seance = getContext('seance');
|
||||
const ui = getContext('ui');
|
||||
|
||||
const evenement = $derived($seance.charge === null ? null : $seance.charge.evenement.id);
|
||||
</script>
|
||||
|
||||
<section data-section="plan">
|
||||
<section data-section="plan" class="section-plan">
|
||||
{#if !$ui.pleinEcran}
|
||||
<h2>{t('coquille.section.plan')}</h2>
|
||||
{/if}
|
||||
<div class="corps">
|
||||
{#if evenement !== null}
|
||||
{#key evenement}
|
||||
<Plan diagnostic={null} />
|
||||
{/key}
|
||||
{/if}
|
||||
{#if !$ui.pleinEcran}
|
||||
<aside class="panneaux" aria-label={t('plan.panneaux')}>
|
||||
<div data-panneau-diagnostic>
|
||||
<Diagnostic compact />
|
||||
</div>
|
||||
<ListesTravail />
|
||||
<Reserve />
|
||||
<PanneauPlace />
|
||||
<PanneauTable />
|
||||
</aside>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
/* La section prend la hauteur de la surface du cadre de mode : le plan
|
||||
s'y étend, et c'est la surface du cadre qui défile, jamais la page. */
|
||||
.section-plan {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-2);
|
||||
box-sizing: border-box;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: var(--taille-texte);
|
||||
}
|
||||
|
||||
.corps {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
gap: var(--espace-3);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.panneaux {
|
||||
display: flex;
|
||||
flex: 0 0 18rem;
|
||||
flex-direction: column;
|
||||
gap: var(--espace-3);
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
839
src/interface/plan/Vue.navigateur.test.js
Normal file
839
src/interface/plan/Vue.navigateur.test.js
Normal file
|
|
@ -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 <svg data-plan> — 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'à <html>.
|
||||
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 <svg data-plan> (§ 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 à <html> ; 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 <svg>, 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 <svg data-plan> 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 <svg> 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 <svg>', 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);
|
||||
});
|
||||
});
|
||||
129
src/interface/plan/vue_plan.js
Normal file
129
src/interface/plan/vue_plan.js
Normal file
|
|
@ -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);
|
||||
}
|
||||
251
src/interface/plan/vue_plan.test.js
Normal file
251
src/interface/plan/vue_plan.test.js
Normal file
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue