[FIX] plan preview: drag ghost effect, page fallback, sheet border

The drag ghost now states what releasing does where the default said
otherwise: a person with no reservation changes nothing, and the floor
under the retained plan removes the person from the tour. A long event
name no longer throws in the plan preview: the cut falls back to the
orientation that still has room, and throws only when neither has. The
on-screen sheet draws its edge as an inset outline, so the content zone
is as wide as on paper. The unused page cut of the preview model is gone.
Checked: node 3899, browser 769; captures 22 and 23 republished.

--- FR ---

[FIX] aperçu du plan : effet du fantôme, repli de page, bord de feuille

Le fantôme du glisser dit ce que fait le relâcher là où le défaut disait
autre chose : une personne sans réservation ne change rien, et le sol
sous le retenu la retire du tour. Un long nom d'événement ne fait plus
lever l'aperçu du plan : la découpe se replie sur l'orientation qui a la
place, et ne lève que si aucune ne l'a. La feuille à l'écran trace son
bord en contour intérieur : la zone de contenu a la largeur du papier.
La découpe inutilisée du modèle de l'aperçu disparaît.
Vérifié : node 3899, navigateur 769 ; captures 22 et 23 republiées.

Assisted-by: Claude Opus 5.5
This commit is contained in:
Mathieu Benoit 2026-10-10 20:14:18 -04:00
parent 7c9b708cc9
commit 9a2c8c20f3
13 changed files with 276 additions and 65 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 86 KiB

File diff suppressed because one or more lines are too long

View file

@ -98,7 +98,9 @@ function bords(n, utileMm, echelle, centre, etendue) {
* le plan se lit sur une page : elle est alors d'une page.
*
* L'orientation est celle qui donne le moins de pages ; à pages égales, la
* plus grande échelle ; à échelle égale aussi, le paysage.
* plus grande échelle ; à échelle égale aussi, le paysage. Une orientation
* sans surface utile ne concourt pas : une bande plus haute que la page
* couchée laisse le portrait seul.
*
* Les tuiles, toutes de même taille, se rangent par rangées de haut en bas,
* de gauche à droite dans chaque rangée ; rang est l'indice dans pages,
@ -130,8 +132,9 @@ function bords(n, utileMm, echelle, centre, etendue) {
* voisins: {haut: number|null, droite: number|null,
* bas: number|null, gauche: number|null}},
* tables: number[]}>}}
* @throws {RangeError} papier ou mode inconnu ; marge, bande ou corps hors de leur domaine ; page
* sans surface utile ; englobant plat, inversé ou non fini ; table sans hauteur de liste
* @throws {RangeError} papier ou mode inconnu ; marge, bande ou corps hors de leur domaine ; aucune
* orientation avec une surface utile ; englobant plat, inversé ou non fini ; table sans
* hauteur de liste
*/
export function decouperPlan(
englobant,
@ -164,17 +167,23 @@ export function decouperPlan(
const seuil = corpsMinMm / corpsDessinCm;
const { largeur: petit, hauteur: grand } = PAPIERS[papier];
// Paysage d'abord : il garde la place à toute égalité.
const candidats = [
// Paysage d'abord : il garde la place à toute égalité. Une orientation
// sans surface utile — une bande plus haute que la page couchée — n'est
// pas candidate ; le portrait, plus haut, garde alors la sienne.
const orientations = [
{ orientation: 'paysage', feuille: { largeur: grand, hauteur: petit } },
{ orientation: 'portrait', feuille: { largeur: petit, hauteur: grand } },
].map(({ orientation, feuille }) => {
const utile = { largeur: feuille.largeur - 2 * margeMm, hauteur: feuille.hauteur - 2 * margeMm - bandeMm };
if (!(utile.largeur > 0 && utile.hauteur > 0)) {
throw new RangeError(`page ${orientation} sans surface utile : ${utile.largeur} × ${utile.hauteur} mm`);
}
return { orientation, utile, ...disposer(utile, plan, seuil, mode) };
});
].map(({ orientation, feuille }) => ({
orientation,
utile: { largeur: feuille.largeur - 2 * margeMm, hauteur: feuille.hauteur - 2 * margeMm - bandeMm },
}));
const candidats = orientations
.filter(({ utile }) => utile.largeur > 0 && utile.hauteur > 0)
.map(({ orientation, utile }) => ({ orientation, utile, ...disposer(utile, plan, seuil, mode) }));
if (candidats.length === 0) {
const surfaces = orientations.map(({ orientation, utile }) => `${orientation} ${utile.largeur} × ${utile.hauteur} mm`);
throw new RangeError(`aucune orientation n'a de surface utile : ${surfaces.join(', ')}`);
}
const retenu = candidats.reduce((meilleur, c) => {
const pages = c.colonnes * c.rangees;
const pagesMeilleur = meilleur.colonnes * meilleur.rangees;

View file

@ -358,8 +358,34 @@ describe('decouperPlan : les refus', () => {
assert.throws(() => decouperPlan(ok, { ...OPTIONS, corpsDessinCm: Number.NaN }), RangeError);
assert.throws(() => decouperPlan(ok, { ...OPTIONS, margeMm: -1 }), RangeError);
// A4 : 210 − 2 × 105 = 0 mm, en largeur du portrait et en hauteur du paysage.
assert.throws(() => decouperPlan(ok, { ...OPTIONS, margeMm: 105 }), RangeError);
assert.throws(() => decouperPlan(ok, { ...OPTIONS, bandeMm: 300 }), RangeError);
assert.throws(() => decouperPlan(ok, { ...OPTIONS, margeMm: 105 }), { name: 'RangeError', message: /aucune orientation/ });
// 297 − 20 − 300 < 0 : ni le portrait ni le paysage n'ont de hauteur.
assert.throws(() => decouperPlan(ok, { ...OPTIONS, bandeMm: 300 }), { name: 'RangeError', message: /aucune orientation/ });
// 297 − 20 − 277 = 0 : le portrait n'a plus de hauteur non plus.
assert.throws(() => decouperPlan(ok, { ...OPTIONS, bandeMm: 277 }), { name: 'RangeError', message: /aucune orientation/ });
});
test('une bande plus haute que le paysage : la découpe se replie sur le portrait, qui garde de la place', () => {
// Bande de 195 mm, plus de la moitié d'une page couchée : le paysage a
// 210 − 20 − 195 = −5 mm de hauteur, le portrait 297 − 20 − 195 = 82 mm.
// Le plan large de 100 × 50 cm, qui préférerait le paysage, s'imprime en
// portrait : e₁ = min(190/100, 82/50) = 1,64 < 2, sa grille au seuil est
// ⌈200/190⌉ × ⌈100/82⌉ = 2 × 2, à min(380/100, 164/50) = 3,28.
for (const mode of ['auto', 'unePage', 'decoupe']) {
const plan = decouperPlan(boite(0, 0, 100, 50), { ...OPTIONS, bandeMm: 195, mode });
assert.equal(plan.orientation, 'portrait', mode);
for (const page of plan.pages) {
proche(page.cadrage.hauteur * plan.echelle, 82, `${mode} : hauteur utile`, 1e-4);
proche(page.cadrage.largeur * plan.echelle, 190, `${mode} : largeur utile`, 1e-4);
}
}
assert.equal(decouperPlan(boite(0, 0, 100, 50), { ...OPTIONS, bandeMm: 195 }).pages.length, 4);
// Lettre : 215,9 − 20 − 196 < 0 en paysage, 279,4 − 20 − 196 = 63,4 en portrait.
const lettre = decouperPlan(boite(0, 0, 100, 50), { ...OPTIONS, papier: 'Lettre', bandeMm: 196, mode: 'unePage' });
assert.equal(lettre.orientation, 'portrait');
proche(lettre.pages[0].cadrage.hauteur * lettre.echelle, PAPIERS.Lettre.hauteur - 20 - 196, 'hauteur utile Lettre', 1e-4);
// Le plan vide aussi : une page portrait.
assert.equal(decouperPlan(null, { ...OPTIONS, bandeMm: 195 }).orientation, 'portrait');
});
test('un englobant plat, inversé, non fini, ou hors des cotes représentables', () => {

View file

@ -65,7 +65,7 @@
[data-impression] [data-feuille] {
margin: 0 !important;
border: 0 !important;
outline: none !important;
box-shadow: none !important;
break-after: page;
}

View file

@ -0,0 +1,107 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// La feuille de l'aperçu est la page imprimée (§ 11.7 : « ce n'est pas une
// seconde mise en page : c'est la même ») : sa zone de contenu
// [data-contenu] a la même largeur à l'écran et sous les médias
// d'impression émulés, celle de la page moins ses deux marges. Le bord que
// l'écran dessine autour de la feuille ne prend aucune place : la tuile du
// plan, à la largeur utile, y tient sans être rognée, et les listes se
// mesurent et se paginent à l'écran à la largeur du papier.
//
// App est montée par test/navigateur/ecrans.js sur le système de fichiers
// simulé, au cadre de 1 366 × 700. Le projet navigateur emploie expect, et
// test et describe de Vitest (§ 14.8).
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
import { cdp } from 'vitest/browser';
import { texteLivre } from '../../demo/livrees.js';
import { constante } from '../../geometrie/constantes.js';
import { analyser } from '../../stockage/document.js';
import { fixerCadre } from '../../../test/navigateur/entrees.js';
import { attendre, chargePrincipale, cliquer, creerContexte, ouvrirApercu } from '../../../test/navigateur/ecrans.js';
import { MM_PAR_PX } from './modele.js';
// Images attendues au plus par étape : bien au-delà d'un rendu, et sous le
// délai d'une épreuve, pour qu'une étape qui ne vient pas se nomme.
const IMAGES = 300;
// Une feuille se mesure au dixième de millimètre (§ 11.7).
const TOLERANCE_MM = 0.1;
let contexte = null;
beforeEach(async () => {
await fixerCadre(1366, 700);
contexte = creerContexte();
});
afterEach(async () => {
const erreurs = contexte.erreurs();
await contexte.defaire();
expect(erreurs, 'erreurs reçues par le collecteur').toEqual([]);
});
const feuilles = (m) => [...m.racine.querySelectorAll('[data-impression] [data-feuille]')];
// Chaque feuille du document : sa largeur et celle de sa zone de contenu,
// en px CSS, ce que le contenu porte au-delà de sa largeur visible, et le
// bord tracé autour de la feuille — le style de son contour, s'il tient
// dans le rectangle de la feuille (décalage + épaisseur ≤ 0), son ombre.
const mesures = (m) =>
feuilles(m).map((feuille) => {
const contenu = feuille.querySelector('[data-contenu]');
const style = getComputedStyle(feuille);
return {
rang: feuille.dataset.feuille,
feuille: feuille.getBoundingClientRect().width,
contenu: contenu.getBoundingClientRect().width,
deborde: contenu.scrollWidth - contenu.clientWidth,
bord: style.outlineStyle,
bordDedans: parseFloat(style.outlineOffset) + parseFloat(style.outlineWidth) <= 0,
ombre: style.boxShadow,
};
});
// Les mesures sous les médias d'impression émulés, puis l'écran rétabli.
async function mesuresImprimees(m) {
await cdp().send('Emulation.setEmulatedMedia', { media: 'print' });
try {
return mesures(m);
} finally {
await cdp().send('Emulation.setEmulatedMedia', { media: '' });
}
}
// La zone de contenu de chaque feuille, à l'écran et à l'impression : la
// même largeur, celle de la feuille moins ses deux marges, et rien qui
// déborde. L'écran trace le bord de la feuille ; le papier n'en porte ni
// bord ni ombre.
async function verifierLargeurs(m, cas) {
const marge = constante('MARGE_PAGE_MM');
const ecran = mesures(m);
const papier = await mesuresImprimees(m);
expect(ecran.length, `${cas} : des feuilles`).toBeGreaterThan(0);
expect(papier.map(({ rang }) => rang), `${cas} : les mêmes feuilles`).toEqual(ecran.map(({ rang }) => rang));
for (const [i, sur] of ecran.entries()) {
const imprime = papier[i];
const libelle = `${cas}, feuille ${sur.rang} : écran ${JSON.stringify(sur)}, impression ${JSON.stringify(imprime)}`;
expect(Math.abs(sur.contenu - imprime.contenu) * MM_PAR_PX, libelle).toBeLessThanOrEqual(TOLERANCE_MM);
expect(Math.abs((sur.feuille - sur.contenu) * MM_PAR_PX - 2 * marge), libelle).toBeLessThanOrEqual(TOLERANCE_MM);
expect(sur.deborde, libelle).toBeLessThanOrEqual(0);
expect([sur.bord, sur.bordDedans], libelle).toEqual(['solid', true]);
expect([imprime.bord, imprime.ombre], libelle).toEqual(['none', 'none']);
}
}
describe('la feuille à l’écran est la feuille imprimée (§ 11.7)', () => {
test('le plan de la principale : la zone de contenu a la même largeur à l’écran et à l’impression, et la tuile y tient', async () => {
const m = await ouvrirApercu(contexte, chargePrincipale());
await verifierLargeurs(m, 'plan, A4');
});
test('la grande démonstration sur papier Lettre, plan puis liste d’accueil : la même largeur sur chaque feuille', async () => {
const m = await ouvrirApercu(contexte, analyser(texteLivre('grande')).charge, { papier: 'Lettre' });
await verifierLargeurs(m, 'plan, Lettre');
await cliquer(m, '[data-ecran="apercu"] [data-commande="vue"][data-cible="accueil"]');
await attendre('la liste d’accueil', () => m.racine.querySelector('[data-impression][data-vue-impression="accueil"] [data-feuille]'), IMAGES);
await verifierLargeurs(m, 'accueil, Lettre');
});
});

View file

@ -13,6 +13,11 @@
// [data-impression], qui porte les jetons du clair, et peint son fond du
// papier et son texte de l'encre de la page.
//
// À l'écran, le bord de la feuille est un contour tracé sur son propre
// rectangle, qui ne prend aucune place : la zone de contenu a la largeur
// de la page moins ses marges, à l'écran comme sur le papier, et la tuile
// du plan, à la largeur utile, y tient sans être rognée.
//
// La zone de contenu mesure la hauteur utile que la découpe reçoit : la
// page, moins les marges, moins bandeMm, la bande mesurée sur la feuille
// gabarit. Sans bandeMm, la feuille est le gabarit : sa hauteur suit ce
@ -61,7 +66,8 @@
flex-direction: column;
margin: 0 auto var(--espace-5);
overflow: hidden;
border: 1px solid var(--couleur-page-filet);
outline: 1px solid var(--couleur-page-filet);
outline-offset: -1px;
background-color: var(--couleur-papier);
color: var(--couleur-page-texte);
box-shadow: var(--ombre-2);

View file

@ -5,14 +5,12 @@
// ce qu'une vue imprime — papier, orientation, tour —, les dimensions d'une
// page et de sa zone de contenu, la bande mesurée, la page nommée que la
// feuille d'impression déclare pour chaque papier et chaque orientation, le
// pied de cinq éléments et le filigrane d'une feuille, la source que
// l'aperçu imprime et la découpe du plan. Le modèle ne rend que des
// nombres, des codes, des clés et des données ; les composants les
// traduisent et les posent.
// pied de cinq éléments et le filigrane d'une feuille, et la source que
// l'aperçu imprime. La découpe du plan en pages n'est pas ici : pagesDuPlan
// (plan.js) l'appelle seule. Le modèle ne rend que des nombres, des codes,
// des clés et des données ; les composants les traduisent et les posent.
import { etatPlaces } from '../../application/places.js';
import { decouperPlan } from '../../geometrie/pages.js';
import { PAPIERS } from '../../geometrie/papiers.js';
import { englobantImpression, geometrieTable } from '../../geometrie/tables.js';
import { DECOUPES, VUES_IMPRESSION } from '../etat_ui.js';
/** Millimètres par pixel CSS : 96 px au pouce, 25,4 mm au pouce. */
@ -243,16 +241,3 @@ export function sortieApercu(charge, { tour }, proposition) {
tour: tour === 'tous' ? 'tous' : tourEtat,
};
}
/**
* La découpe du plan d'un tour en pages (decouperPlan, § 11.7) : les
* géométries des tables de l'état des places, à leur capacité, leur
* englobant, et la bande mesurée sur la feuille gabarit.
*
* @param {Object} etat EtatPlaces
* @param {{papier: string, bandeMm: number, decoupe: string}} options
*/
export function decoupeDuPlan(etat, { papier, bandeMm, decoupe }) {
const geometries = etat.tables.map((table) => geometrieTable(table, table.capacite));
return decouperPlan(englobantImpression(geometries), { papier, bandeMm, mode: decoupe, geometries });
}

View file

@ -12,9 +12,7 @@ import { TABLES_APPLICATION } from '../../application/libelles.js';
import { etatPlaces } from '../../application/places.js';
import { MARQUE_TEMOIN, creerTraduction } from '../../application/traduction.js';
import { constante } from '../../geometrie/constantes.js';
import { decouperPlan } from '../../geometrie/pages.js';
import { PAPIERS } from '../../geometrie/papiers.js';
import { englobantImpression, geometrieTable } from '../../geometrie/tables.js';
import { texteLivre } from '../../demo/livrees.js';
import { analyser } from '../../stockage/document.js';
import { TABLES_INTERFACE } from '../libelles.js';
@ -25,7 +23,6 @@ import {
clesPapier,
cleDecoupe,
cleVue,
decoupeDuPlan,
dimensionsPage,
feuilleDe,
filigraneDe,
@ -220,7 +217,7 @@ describe('le filigrane BROUILLON (§ 11.7, § 14.5)', () => {
});
});
describe('la sortie de l’aperçu et la découpe du plan', () => {
describe('la sortie de l’aperçu', () => {
test('sans placement, la source des réservations, sans proposition ; le tour du plan borné aux tours de la soirée', () => {
const charge = petite();
const sortie = sortieApercu(charge, { tour: 9 }, null);
@ -235,17 +232,16 @@ describe('la sortie de l’aperçu et la découpe du plan', () => {
const sortie = sortieApercu(avecProposition(), { tour: 1 }, 1);
assert.deepEqual([sortie.source, sortie.proposition, sortie.tour], ['proposition', 1, 1]);
});
});
test('la découpe du plan reçoit la bande mesurée : une bande plus haute réduit la surface utile', () => {
const etat = etatPlaces(petite(), { tour: 1 });
const geometries = etat.tables.map((table) => geometrieTable(table, table.capacite));
for (const bandeMm of [0, 12.5]) {
const attendue = decouperPlan(englobantImpression(geometries), { papier: 'A4', bandeMm, mode: 'unePage', geometries });
assert.deepEqual(decoupeDuPlan(etat, { papier: 'A4', bandeMm, decoupe: 'unePage' }), attendue, String(bandeMm));
}
const basse = decoupeDuPlan(etat, { papier: 'A4', bandeMm: 0, decoupe: 'unePage' });
const haute = decoupeDuPlan(etat, { papier: 'A4', bandeMm: 150, decoupe: 'unePage' });
assert.ok(haute.echelle < basse.echelle, `${haute.echelle} < ${basse.echelle}`);
describe('la découpe du plan : une seule implémentation (§ 13.2)', () => {
test('le modèle de l’aperçu ne découpe pas le plan : la vue du plan passe par pagesDuPlan (plan.js) seule', () => {
// Un second appelant de decouperPlan découperait le même plan sur
// d'autres géométries ou sans les listes, et deux aperçus d'un même
// état ne compteraient plus les mêmes pages. Le texte du module, sans
// ses commentaires, n'importe ni la découpe ni l'englobant d'impression.
const source = readFileSync(new URL('./modele.js', import.meta.url), 'utf8').replace(/\/\*[\s\S]*?\*\/|\/\/.*$/gm, '');
assert.doesNotMatch(source, /geometrie\/pages\.js|\bdecouperPlan\b|\benglobantImpression\b/);
});
});

View file

@ -99,13 +99,17 @@ export const TABLE = new Map([
// courant modifie : l'état des données, non le geste (§ 5.8) ; une
// personne réservée déplace sa réservation, dont il dit la portée, et
// au-dessus du sol la retire (« ce tour ») ou se la voit refuser (« tous
// les tours »). Le nom de la personne est une donnée, dans son propre nœud.
// les tours ») ; une personne que rien ne tient n'y change rien ; sous le
// retenu, le sol retire du tour. Le nom de la personne est une donnée,
// dans son propre nœud.
['plan.fantome.reservations', 'pose une réservation'],
['plan.fantome.reservationTour', 'déplace sa réservation de ce tour'],
['plan.fantome.reservationTous', 'déplace sa réservation, à tous les tours'],
['plan.fantome.retireReservationTour', 'retire sa réservation de ce tour'],
['plan.fantome.refuseTous', 'refusé : sa réservation vaut pour tous les tours'],
['plan.fantome.rienNeChange', 'aucune réservation à retirer : rien ne change'],
['plan.fantome.retenu', 'déplace dans le placement retenu'],
['plan.fantome.retireDuTour', 'retire du tour : attend dans la réserve'],
['plan.fantome.vueSeule', 'proposition en vue seule : rien ne change'],
// La place où un refus ou un avertissement du plan se pose.
['plan.refus.table', ({ numero }) => `Table ${numero}`],

View file

@ -231,13 +231,24 @@ export function memoriser(controler) {
}
// Les clés du libellé du fantôme, par cible de l'état des places : ce que
// le dépôt modifie, ou rien sous une cible null.
// le dépôt sur une chaise modifie, ou rien sous une cible null.
const CLES_FANTOME = new Map([
['reservations', 'plan.fantome.reservations'],
['retenu', 'plan.fantome.retenu'],
[null, 'plan.fantome.vueSeule'],
]);
// Les mêmes, au-dessus du sol (commandeDeDepot, depot.js) : sous le retenu,
// le dépôt retire la personne du tour (retirerDuTour) ; sous les
// réservations, une personne qu'aucune réservation ne tient n'a rien à
// retirer, et le dépôt ne rend rien ; sous une cible null, le refus de la
// vue seule, comme sur une chaise.
const CLES_FANTOME_SUR_LE_SOL = new Map([
['reservations', 'plan.fantome.rienNeChange'],
['retenu', 'plan.fantome.retireDuTour'],
[null, 'plan.fantome.vueSeule'],
]);
// Sous la cible 'reservations', la clé d'une personne que sa réservation
// tient à sa chaise, par portée : son dépôt sur une chaise est
// deplacerReservation, qui garde la portée.
@ -257,13 +268,18 @@ const CLES_RESERVATION_SUR_LE_SOL = new Map([
/**
* La clé du libellé que le fantôme porte pendant le glissement d'une
* personne (§ 5.8, § 7.1), qui dit ce que le relâcher au point courant
* modifie : sous la cible 'reservations', « pose une réservation » pour une
* personne que rien ne tient, sinon le déplacement de sa réservation et sa
* portée — « tous les tours » s'y dit — ; au-dessus du sol, la même
* réservation se dit retirée (« ce tour ») ou refusée (« tous les tours ») ;
* « déplace dans le placement retenu » sous 'retenu' ; la vue seule sous une
* cible null. Une cible, une portée ou un sol hors du contrat lève : il ne
* prend aucun libellé par défaut.
* modifie, celui que rend commandeDeDepot (depot.js) :
* - sous la cible 'reservations', sur une chaise, « pose une réservation »
* pour une personne que rien ne tient, sinon le déplacement de sa
* réservation et sa portée — « tous les tours » s'y dit ; au-dessus du
* sol, la même réservation se dit retirée (« ce tour ») ou refusée
* (« tous les tours »), et « rien ne change » pour une personne que rien
* ne tient ;
* - sous 'retenu', « déplace dans le placement retenu » sur une chaise,
* « retire du tour » au-dessus du sol ;
* - la vue seule sous une cible null, partout.
* Une cible, une portée ou un sol hors du contrat lève : il ne prend aucun
* libellé par défaut.
*
* @param {'reservations'|'retenu'|null} cible EtatPlaces.cible
* @param {'tous'|'tour'|null} [portee] la portée de la réservation qui tient
@ -277,7 +293,7 @@ export function cleFantome(cible, portee = null, surLeSol = false) {
throw new TypeError(`portée du fantôme hors du contrat : ${JSON.stringify(portee)}`);
}
if (typeof surLeSol !== 'boolean') throw new TypeError(`sol du fantôme hors du contrat : ${JSON.stringify(surLeSol)}`);
if (cible !== 'reservations' || portee === null) return CLES_FANTOME.get(cible);
if (cible !== 'reservations' || portee === null) return (surLeSol ? CLES_FANTOME_SUR_LE_SOL : CLES_FANTOME).get(cible);
return (surLeSol ? CLES_RESERVATION_SUR_LE_SOL : CLES_RESERVATION_TENUE).get(portee);
}

View file

@ -15,6 +15,7 @@ import { constante } from '../../geometrie/constantes.js';
import { placesSousPoignee } from '../../geometrie/tables.js';
import { deplacer, deplacerVue, dessinDepuisEcran, transformDe } from '../../geometrie/vue.js';
import { TABLE as LIBELLES } from '../libelles/plan.js';
import { commandeDeDepot, reservationTenue } from './depot.js';
import { REPOS, cleFantome, geste, libellePlace, memoriser } from './gestes.js';
const SEUIL = constante('SEUIL_GLISSER_PX');
@ -371,14 +372,11 @@ describe('les libellés d’un geste : le fantôme et la place d’une pose', ()
test('cleFantome : au-dessus du sol, une réservation « ce tour » se dit retirée, une « tous les tours » refusée (§ 5.8)', () => {
// Le dépôt sur le sol retire la première et refuse la seconde : le
// fantôme dit ce qui arrivera au relâcher, non ce qui arrive sur une
// chaise. Une personne que rien ne tient, et les autres cibles, gardent
// leur libellé.
// chaise. La vue seule garde son libellé.
assert.equal(cleFantome('reservations', 'tour', true), 'plan.fantome.retireReservationTour');
assert.equal(cleFantome('reservations', 'tous', true), 'plan.fantome.refuseTous');
assert.equal(cleFantome('reservations', 'tour', false), 'plan.fantome.reservationTour');
assert.equal(cleFantome('reservations', 'tous', false), 'plan.fantome.reservationTous');
assert.equal(cleFantome('reservations', null, true), 'plan.fantome.reservations');
assert.equal(cleFantome('retenu', null, true), 'plan.fantome.retenu');
assert.equal(cleFantome(null, 'tous', true), 'plan.fantome.vueSeule');
assert.match(LIBELLES.get('plan.fantome.retireReservationTour'), /retire/);
assert.match(LIBELLES.get('plan.fantome.refuseTous'), /refusé/);
@ -388,6 +386,70 @@ describe('les libellés d’un geste : le fantôme et la place d’une pose', ()
}
});
test('cleFantome : au-dessus du sol, le retenu se dit retiré du tour ; une personne que rien ne tient, rien ne change', () => {
// Sous 'retenu', le dépôt sur le sol est retirerDuTour : la personne
// attend dans la réserve (§ 7.1). Sous 'reservations', une personne
// qu'aucune réservation ne tient n'a rien à retirer : le dépôt ne rend
// rien, et le fantôme ne promet pas une réservation.
for (const portee of [null, 'tour', 'tous']) {
assert.equal(cleFantome('retenu', portee, true), 'plan.fantome.retireDuTour');
assert.equal(cleFantome('retenu', portee, false), 'plan.fantome.retenu');
}
assert.equal(cleFantome('reservations', null, true), 'plan.fantome.rienNeChange');
assert.equal(cleFantome('reservations', null, false), 'plan.fantome.reservations');
assert.match(LIBELLES.get('plan.fantome.retireDuTour'), /retire du tour/);
assert.match(LIBELLES.get('plan.fantome.retireDuTour'), /réserve/);
assert.match(LIBELLES.get('plan.fantome.rienNeChange'), /rien ne change/);
});
test('cleFantome dit l’effet du relâcher : chaque clé, sur chaque cible, portée et point, suit la commande de commandeDeDepot', () => {
// Le fantôme et le dépôt lisent la même réservation (reservationTenue),
// comme Plan.svelte : la personne 11, assise à la table 1, siège 1, sans
// réservation, ou tenue « ce tour » ou « tous les tours ». Elle se
// dépose sur une chaise libre, sur une chaise occupée, ou sur le sol.
// L'effet est la commande exécutée, le code du refus rendu, ou « rien ».
const EFFETS = new Map([
['plan.fantome.reservations', ['reserver']],
['plan.fantome.reservationTour', ['deplacerReservation']],
['plan.fantome.reservationTous', ['deplacerReservation']],
['plan.fantome.retireReservationTour', ['retirerReservation']],
['plan.fantome.refuseTous', ['RESERVATION_TOUS_LES_TOURS']],
['plan.fantome.retenu', ['deplacerDansRetenu', 'echangerDansRetenu']],
['plan.fantome.retireDuTour', ['retirerDuTour']],
['plan.fantome.rienNeChange', ['rien']],
['plan.fantome.vueSeule', ['VUE_SEULE']],
]);
const RESERVATIONS = [null, { participant: 11, portee: 'tour', tour: 1 }, { participant: 11, portee: 'tous', tour: null }];
const POINTS = [
{ cible: { table: 2, siege: 1 }, surLeSol: false },
{ cible: { table: 2, siege: 2 }, surLeSol: false },
{ cible: null, surLeSol: true },
];
const depuis = { table: 1, siege: 1 };
const vus = new Set();
for (const cible of ['reservations', 'retenu', null]) {
for (const reservation of RESERVATIONS) {
const etat = {
cible,
refus: cible === null ? { code: 'VUE_SEULE', details: {} } : null,
tables: [
{ id: 1, chaises: [{ siege: 1, occupant: 11, reservation }, { siege: 2, occupant: 12, reservation: null }] },
{ id: 2, chaises: [{ siege: 1, occupant: null, reservation: null }, { siege: 2, occupant: 13, reservation: null }] },
],
};
for (const point of POINTS) {
const cle = cleFantome(cible, reservationTenue(etat, 11, depuis)?.portee ?? null, point.surLeSol);
const resultat = commandeDeDepot({ etatPlaces: etat, tour: 1, personne: 11, depuis, ...point });
const effet = resultat === null ? 'rien' : (resultat.refus?.code ?? resultat.commande);
const cas = JSON.stringify({ cible, reservation: reservation?.portee ?? null, point });
assert.ok(EFFETS.get(cle).includes(effet), `${cas} : le fantôme dit ${cle}, le relâcher fait ${effet}`);
vus.add(cle);
}
}
}
assert.deepEqual([...vus].sort(), [...EFFETS.keys()].sort());
});
test('cleFantome : une cible hors du contrat lève, au lieu de se dire « vue seule »', () => {
for (const cible of ['reservation', 'Retenu', undefined, '', 0]) {
assert.throws(() => cleFantome(cible), { name: 'TypeError', message: /^cible du fantôme hors du contrat/ });