gestion_table_tournante_libre/src/interface/etat_ui.test.js
Mathieu Benoit ddd587da87 [FIX] iteration 3 review: seat targets, gestures, warnings, settings
The wheel no longer zooms during a gesture, so a drop lands under the
ghost. A seat is a target the size of its pitch. A floor drop refuses to
remove an every-tour reservation. A seat reservation the retained plan
does not hold is flagged at the seat, with its swap as remedy. DERIVE names
the person; capacities have an upper bound; Generate waits for a pending
setting; a worker that fails to load shows a refusal; § 5.3 constraints
get their switches. Commands are not re-entrant and a double click does not
confirm. Checked: 2755 node, 550 browser tests, from the index alone.

--- FR ---

[FIX] revue de l'itération 3 : cibles, gestes, avertissements, réglages

La molette ne zoome plus pendant un geste : le dépôt tombe sous le fantôme.
Un siège est une cible de la taille de son pas. Un dépôt sur le sol refuse
de retirer une réservation de tous les tours. Une réservation à un siège
que le retenu ne tient pas est relevée au siège, l'échange en remède. La
dérive nomme la personne ; les capacités ont une borne ; Générer attend un
réglage en cours ; un travailleur qui ne se charge pas donne un refus ; le
§ 5.3 a ses bascules. Les commandes ne sont pas ré-entrantes et un double
clic ne confirme pas. Vérifié : 2755 node, 550 navigateur, index seul.

Assisted-by: Claude Opus 5.5
2026-10-07 17:50:35 -04:00

310 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// L'état de navigation de l'interface : chaque transition, le contrat des
// magasins de Svelte, et l'absence de toute écriture — l'état ne connaît pas
// la séance, et regarder n'écrit jamais (§ 8.2, § 8.4).
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { describe, test } from '../../test/lanceur.js';
import { creerEtatUi } from './etat_ui.js';
import { SECTION_OUVERTURE, SECTIONS } from './evenement/sections.js';
const DEPART = {
section: SECTION_OUVERTURE,
apropos: false,
tour: 1,
miseEnEvidence: null,
proposition: null,
importEnAttente: null,
listes: true,
pleinEcran: false,
selection: null,
fiche: null,
choix: null,
pose: null,
appartenance: '',
};
// Un état et le relevé de ce que reçoit son abonné.
function suivi() {
const ui = creerEtatUi();
const recus = [];
const desabonner = ui.subscribe((etat) => recus.push(etat));
return { ui, recus, desabonner };
}
describe('état de navigation : départ et magasin', () => {
test('le départ : la section d’ouverture, le tour 1, rien de choisi, les listes montrées', () => {
assert.deepEqual(creerEtatUi().etat(), DEPART);
});
test('subscribe rend l’état tout de suite, puis chaque nouvel état, figé ; désabonné, plus rien', () => {
const { ui, recus, desabonner } = suivi();
assert.deepEqual(recus, [DEPART]);
ui.allerA('tables');
assert.equal(recus.length, 2);
assert.equal(recus[1], ui.etat());
assert.ok(Object.isFrozen(recus[1]));
assert.throws(() => {
recus[1].section = 'plan';
}, TypeError);
desabonner();
ui.allerA('plan');
assert.equal(recus.length, 2);
});
test('l’état ne reçoit rien à sa création : il ne connaît ni la séance, ni la façade, ni un système de fichiers', () => {
assert.equal(creerEtatUi.length, 0);
const source = readFileSync(new URL('./etat_ui.js', import.meta.url), 'utf8');
// Le dernier mot de chaque ligne d'import : le module qu'elle charge.
const imports = source
.split('\n')
.filter((ligne) => ligne.startsWith('import '))
.map((ligne) => ligne.slice(ligne.lastIndexOf(' ') + 1));
assert.deepEqual(imports, ["'./evenement/sections.js';"]);
});
});
describe('état de navigation : chaque transition', () => {
test('allerA mène à chaque section ; hors de SECTIONS, RangeError sans rien changer', () => {
const ui = creerEtatUi();
assert.ok(SECTIONS.length > 0);
for (const section of SECTIONS) {
ui.allerA(section);
assert.deepEqual(ui.etat(), { ...DEPART, section });
}
const avant = ui.etat();
for (const faute of ['accueil', 'apropos', '', undefined, 'Plan', 'constructor']) {
assert.throws(() => ui.allerA(faute), RangeError, String(faute));
}
assert.equal(ui.etat(), avant);
});
test('reinitialiser rend l’état de départ, le panneau « À propos » excepté', () => {
const ui = creerEtatUi();
ui.allerA('generation');
ui.montrerTour(3);
ui.mettreEnEvidence(7);
ui.montrerProposition(2);
ui.ouvrirImport({ texte: 'nom\nOmbrelle', origine: 'liste.csv' });
ui.basculerListes();
ui.basculerPleinEcran();
ui.selectionner({ table: 1, siege: 2 });
ui.ouvrirFiche(4);
ui.choisir({ genre: 'personne', participant: 2, depuis: null });
ui.poserSurPlace({ place: { table: 1, siege: 2 }, refus: { code: 'SANS_EFFET', details: {} }, avertissements: [] });
ui.ouvrirApropos();
ui.reinitialiser();
assert.deepEqual(ui.etat(), { ...DEPART, apropos: true });
assert.equal(ui.etat().section, SECTION_OUVERTURE);
});
test('ouvrirApropos et fermerApropos', () => {
const ui = creerEtatUi();
ui.ouvrirApropos();
assert.deepEqual(ui.etat(), { ...DEPART, apropos: true });
ui.fermerApropos();
assert.deepEqual(ui.etat(), DEPART);
});
test('montrerTour prend un entier positif ; sinon RangeError', () => {
const ui = creerEtatUi();
ui.montrerTour(4);
assert.equal(ui.etat().tour, 4);
for (const faute of [0, -1, 1.5, Number.NaN, '2', null]) {
assert.throws(() => ui.montrerTour(faute), RangeError, String(faute));
}
assert.equal(ui.etat().tour, 4);
});
test('mettreEnEvidence, montrerProposition et selectionner posent puis retirent leur choix', () => {
const ui = creerEtatUi();
ui.mettreEnEvidence(12);
ui.montrerProposition(3);
ui.selectionner({ table: 2, siege: 1 });
assert.deepEqual(ui.etat(), { ...DEPART, miseEnEvidence: 12, proposition: 3, selection: { table: 2, siege: 1 } });
ui.mettreEnEvidence(null);
ui.montrerProposition(null);
ui.selectionner(null);
assert.deepEqual(ui.etat(), DEPART);
});
test('ouvrirImport garde le texte en attente et mène à la section import ; prendreImport le rend une fois', () => {
const ui = creerEtatUi();
const enAttente = { texte: 'nom;prénom\nPervenche;Théo', origine: 'invites.csv' };
ui.ouvrirImport(enAttente);
assert.deepEqual(ui.etat(), { ...DEPART, section: 'import', importEnAttente: enAttente });
assert.deepEqual(ui.prendreImport(), enAttente);
assert.equal(ui.etat().importEnAttente, null);
assert.equal(ui.prendreImport(), null);
});
test('basculerListes et basculerPleinEcran alternent', () => {
const ui = creerEtatUi();
ui.basculerListes();
ui.basculerPleinEcran();
assert.deepEqual(ui.etat(), { ...DEPART, listes: false, pleinEcran: true });
ui.basculerListes();
ui.basculerPleinEcran();
assert.deepEqual(ui.etat(), DEPART);
});
test('le plein écran ne vaut que pour le plan : quitter le plan le retire, par allerA, ouvrirFiche ou ouvrirImport ; hors du plan, il ne se pose pas', () => {
const quitters = [
...SECTIONS.filter((section) => section !== 'plan').map((section) => [`allerA ${section}`, (ui) => ui.allerA(section)]),
['ouvrirFiche', (ui) => ui.ouvrirFiche(4)],
['ouvrirImport', (ui) => ui.ouvrirImport({ texte: 'nom\nOmbrelle', origine: 'collage' })],
];
assert.ok(SECTIONS.includes('plan'));
assert.equal(quitters.length, SECTIONS.length - 1 + 2);
for (const [nom, quitter] of quitters) {
const ui = creerEtatUi();
ui.allerA('plan');
ui.basculerPleinEcran();
assert.equal(ui.etat().pleinEcran, true, nom);
quitter(ui);
assert.notEqual(ui.etat().section, 'plan', nom);
assert.equal(ui.etat().pleinEcran, false, nom);
ui.basculerPleinEcran();
assert.equal(ui.etat().pleinEcran, false, `${nom} : hors du plan, la bascule ne pose rien`);
ui.allerA('plan');
assert.equal(ui.etat().pleinEcran, false, `${nom} : revenu au plan, la tête est là`);
}
});
test('ouvrirFiche(id) mène à la section participants et ouvre la fiche ; ouvrirFiche(null) la referme, la section reste', () => {
const ui = creerEtatUi();
ui.allerA('plan');
ui.ouvrirFiche(7);
assert.deepEqual(ui.etat(), { ...DEPART, section: 'participants', fiche: 7 });
ui.allerA('tables');
ui.ouvrirFiche(null);
assert.deepEqual(ui.etat(), { ...DEPART, section: 'tables', fiche: null });
});
test('ouvrirFiche prend un identifiant entier positif ou null ; sinon RangeError sans rien changer', () => {
const ui = creerEtatUi();
ui.ouvrirFiche(3);
const avant = ui.etat();
for (const faute of [0, -2, 1.5, Number.NaN, '3', undefined]) {
assert.throws(() => ui.ouvrirFiche(faute), RangeError, String(faute));
}
assert.equal(ui.etat(), avant);
});
test('garderAppartenance tient l’appartenance collante du formulaire d’ajout (§ 10.2) : elle survit au changement de section, et un autre événement repart vide', () => {
const ui = creerEtatUi();
ui.allerA('participants');
ui.garderAppartenance('Club des Merles');
assert.equal(ui.etat().appartenance, 'Club des Merles');
ui.allerA('plan');
ui.allerA('participants');
ui.ouvrirApropos();
ui.fermerApropos();
assert.equal(ui.etat().appartenance, 'Club des Merles');
ui.reinitialiser();
assert.equal(ui.etat().appartenance, '');
for (const faute of [null, undefined, 3, {}]) assert.throws(() => ui.garderAppartenance(faute), RangeError, String(faute));
assert.equal(ui.etat().appartenance, '');
});
test('une transition qui ne change rien ne publie pas d’état neuf', () => {
const { ui, recus } = suivi();
ui.allerA(SECTION_OUVERTURE);
ui.fermerApropos();
ui.mettreEnEvidence(null);
ui.ouvrirFiche(null);
assert.equal(recus.length, 1);
});
});
describe('état de navigation : le choix en attente (§ 5.8, § 7.1)', () => {
const PERSONNE = { genre: 'personne', participant: 2, depuis: { table: 4, siege: 1 } };
const DEPUIS_LA_RESERVE = { genre: 'personne', participant: 3, depuis: null };
const RESERVATION = {
genre: 'reservation',
participant: 5,
depuis: { table: 3, siege: 2 },
de: { table: 3, portee: 'tous', tour: null },
};
test('choisir pose un choix de chaque genre, figé, puis null l’oublie', () => {
const ui = creerEtatUi();
for (const choix of [PERSONNE, DEPUIS_LA_RESERVE, RESERVATION]) {
ui.choisir(choix);
assert.deepEqual(ui.etat(), { ...DEPART, choix });
assert.ok(Object.isFrozen(ui.etat().choix));
ui.choisir(null);
assert.deepEqual(ui.etat(), DEPART);
}
});
test('un choix hors du contrat lève RangeError sans rien changer', () => {
const ui = creerEtatUi();
ui.choisir(PERSONNE);
const avant = ui.etat();
const fautes = [
{ genre: 'table', participant: 2, depuis: null },
{ genre: 'personne', participant: 0, depuis: null },
{ genre: 'personne', participant: '2', depuis: null },
{ genre: 'personne', participant: 2 },
{ genre: 'personne', participant: 2, depuis: { siege: 1 } },
{ genre: 'personne', participant: 2, depuis: { table: 1, siege: 0 } },
{ genre: 'reservation', participant: 5, depuis: { table: 3, siege: 2 } },
{ genre: 'reservation', participant: 5, depuis: null, de: { table: 3, portee: 'parfois', tour: null } },
undefined,
];
for (const faute of fautes) assert.throws(() => ui.choisir(faute), RangeError, JSON.stringify(faute));
assert.equal(ui.etat(), avant);
});
test('montrerTour et selectionner(null) oublient le choix ; selectionner une place le garde', () => {
const ui = creerEtatUi();
ui.choisir(PERSONNE);
ui.selectionner({ table: 2, siege: 3 });
assert.deepEqual(ui.etat().choix, PERSONNE);
ui.selectionner(null);
assert.equal(ui.etat().choix, null);
ui.choisir(RESERVATION);
ui.montrerTour(2);
assert.equal(ui.etat().choix, null);
ui.choisir(PERSONNE);
ui.montrerTour(2);
assert.equal(ui.etat().choix, null, 'le même tour remontré oublie aussi');
});
test('poserSurPlace pose un refus ou des avertissements sur une place, puis null les retire ; aucune transition ne reçoit la séance', () => {
const ui = creerEtatUi();
const pose = {
place: { table: 3, siege: null },
refus: { code: 'PERSONNE_EXCLUE', details: { participant: 1 } },
avertissements: [{ code: 'TABLE_GELEE', details: { table: 3 } }],
};
ui.poserSurPlace(pose);
assert.deepEqual(ui.etat(), { ...DEPART, pose });
assert.ok(Object.isFrozen(ui.etat().pose));
ui.poserSurPlace(null);
assert.deepEqual(ui.etat(), DEPART);
assert.throws(() => ui.poserSurPlace({ place: null, refus: null, avertissements: [] }), RangeError);
assert.equal(ui.choisir.length, 1);
assert.equal(ui.poserSurPlace.length, 1);
});
test('montrerTour, montrerProposition et un changement de section retirent la pose ; la section remontrée la garde', () => {
const ui = creerEtatUi();
const pose = { place: { table: 7, siege: 1 }, refus: { code: 'PLACE_RESERVEE', details: { participant: 4 } }, avertissements: [] };
ui.poserSurPlace(pose);
ui.montrerTour(2);
assert.equal(ui.etat().pose, null, 'montrerTour');
ui.poserSurPlace(pose);
ui.montrerProposition(1);
assert.equal(ui.etat().pose, null, 'montrerProposition');
ui.allerA('plan');
ui.poserSurPlace(pose);
ui.allerA('plan');
assert.deepEqual(ui.etat().pose, pose, 'la même section');
ui.allerA('tables');
assert.equal(ui.etat().pose, null, 'une autre section');
});
});