// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// La liste des participants dans un vrai moteur de rendu (§ 4, § 4.2, § 4.4,
// § 5.9, § 7.4, § 10.2) : la saisie par le seul nom, l'appartenance
// collante, la grille au clavier, le tri, le filtre et le compte,
// l'exclusion, le manque de places à l'ajout et à la réintégration, la fiche
// et l'itinéraire, l'export et le collage. Chaque épreuve monte App par
// monterApp, sur le système de fichiers simulé et l'horloge pilotée ; chaque
// écriture se relève (trace) et chaque entrée se compte dans le journal. Les
// gestes passent par des entrées que Chromium traite comme réelles
// (test/navigateur/entrees.js). 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, vi } from 'vitest';
import { cdp } from 'vitest/browser';
import { flushSync, tick } from 'svelte';
import { bilanChangementDefaut } from '../../application/bilans.js';
import { TABLES_APPLICATION } from '../../application/libelles.js';
import { creerTraduction } from '../../application/traduction.js';
import { apercevoir, decoder, exporterParticipants } from '../../application/csv.js';
import { STATUT, nombrePlacesManquantes, normaliser } from '../../moteur/configuration.js';
import { configurationDepuisCharge } from '../../stockage/document.js';
import { lireJournal } from '../../stockage/journal.js';
import { SUFFIXES } from '../../stockage/noms.js';
import { formeNommee } from '../../stockage/placements.js';
import { monterApp } from '../../../test/navigateur/montage.js';
import { pointer, touche } from '../../../test/navigateur/entrees.js';
import { visible } from '../../../test/navigateur/rendu.js';
import CAS_LIMITES from '../../../exemples/participants_cas_limites.csv?url';
import { TABLES_INTERFACE } from '../libelles.js';
import { COLONNES, cleDeStatut } from './grille.js';
const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]);
const EVENEMENT = 'Soirée des Merles';
const CLUB = 'Club des Merles';
// Des noms inventés, hors du réservoir du générateur.
const NOMS = [
'Quenouillet',
'Brindelle',
'Calvaille',
'Doucemer',
'Esquirol',
'Faverolle',
'Glanduc',
'Houblonnet',
'Isardin',
'Joncaille',
'Kerbouet',
'Lisonnet',
'Moucheron',
'Nivelette',
'Orbelin',
'Pistachon',
'Rouvrelle',
];
// Les montages de l'épreuve en cours, démontés après elle ; le collecteur
// n'a rien reçu pendant l'épreuve.
let montes = [];
let erreursAvant = 0;
beforeEach(() => {
erreursAvant = globalThis.__gttErreurs?.length ?? 0;
});
afterEach(() => {
for (const m of montes.reverse()) m.demonter();
montes = [];
vi.restoreAllMocks();
const recues = globalThis.__gttErreurs?.slice(erreursAvant) ?? [];
expect(recues, 'erreurs reçues par le collecteur').toEqual([]);
});
async function rendu() {
flushSync();
await tick();
}
// Attend que la file de la séance ait passé ce que le geste y a posé, par
// une lecture admise dans tout mode qui n'écrit rien, puis le rendu.
async function attendreSeance(m) {
await m.seance.formeNommee(null).catch(() => undefined);
await rendu();
await rendu();
}
// Un CSV d'épreuve, séparé par « ; » : nom, prénom, appartenance, exclu,
// titre pressenti, notes.
const csv = (personnes) =>
[
'nom;prenom;appartenance;exclu;titre_pressenti;notes',
...personnes.map((p) => [p.nom, p.prenom ?? '', p.appartenance ?? '', p.exclu ? 'oui' : '', p.titre ?? '', p.notes ?? ''].join(';')),
].join('\n');
// Importe un texte par la commande, comme le ferait l'aperçu (I6).
const importer = (m, texte) => m.seance.executer('importerParticipants', { apercu: apercevoir(texte), mode: 'ajouter' });
// Les octets d'un CSV livré, lus par son adresse.
async function octetsLivres(url) {
const reponse = await fetch(url);
return new Uint8Array(await reponse.arrayBuffer());
}
/**
* Un montage, un événement créé — sieges places par défaut, tours tours,
* tables tables rondes, personnes importées —, en écriture, la section des
* participants ouverte.
*/
async function evenementOuvert({ sieges = 8, tours = 2, tables = 0, personnes = [] } = {}) {
const m = await monterApp();
montes.push(m);
await m.seance.creer({ nom: EVENEMENT, date: null, siegesParDefaut: sieges, tours });
await m.seance.passerEnEcriture();
for (let i = 0; i < tables; i += 1) await m.seance.executer('creerTable', { forme: 'ronde' });
if (personnes.length > 0) await importer(m, csv(personnes));
m.ui.allerA('participants');
await rendu();
return m;
}
const base = (m) => m.seance.etat().evenement.base;
const texteJournal = (m) => m.fs.contenu('documents', `${base(m)}${SUFFIXES.journal}`);
const entrees = (m) => lireJournal(texteJournal(m)).entrees;
const texteEtat = (m) => m.fs.contenu('documents', `${base(m)}${SUFFIXES.etat}`);
const participants = (m) => m.seance.etat().charge.participants;
const idDe = (m, nom) => participants(m).find((p) => p.nom === nom).id;
const section = (m) => m.racine.querySelector('[data-section="participants"]');
const formulaire = (m) => section(m).querySelector('form');
const champ = (m, cle) => formulaire(m).querySelector(`[data-champ="${cle}"]`);
const grille = (m) => section(m).querySelector('[role="grid"]');
const lignes = (m) => [...grille(m).querySelectorAll('[data-participant]')];
const idsAffiches = (m) => lignes(m).map((ligne) => Number(ligne.dataset.participant));
const ligne = (m, id) => grille(m).querySelector(`[data-participant="${id}"]`);
const cellule = (m, id, colonne) => ligne(m, id).querySelector(`[data-champ="${colonne}"]`);
const commandeDeLigne = (m, id, nom) => ligne(m, id).querySelector(`[data-commande="${nom}"]`);
const fiche = (m) => section(m).querySelector('[data-fiche]');
const centre = (element) => {
const b = element.getBoundingClientRect();
return { x: b.left + b.width / 2, y: b.top + b.height / 2 };
};
// Un clic réel au centre de l'élément, amené d'abord dans la fenêtre.
async function cliquer(element) {
element.scrollIntoView({ block: 'center' });
const { x, y } = centre(element);
await pointer('presser', x, y);
await pointer('relacher', x, y);
await rendu();
}
// Remplace le texte d'un champ par une saisie réelle : le champ prend le
// focus, son texte est sélectionné, puis remplacé par l'insertion du texte,
// ou effacé.
async function saisir(entree, texte) {
entree.focus();
entree.select();
if (texte === '') await touche('Backspace');
else await cdp().send('Input.insertText', { text: texte });
await rendu();
}
// Ajoute une personne par le formulaire : chaque champ donné saisi, puis
// Entrée dans le champ du nom.
async function ajouter(m, champs) {
for (const [cle, valeur] of Object.entries(champs)) await saisir(champ(m, cle), valeur);
champ(m, 'nom').focus();
await touche('Enter');
await attendreSeance(m);
}
// Maj+Tab réel : touche() ne porte pas de modificateur ; 8 est le masque de
// Maj dans Input.dispatchKeyEvent.
async function majTab() {
const commun = { key: 'Tab', code: 'Tab', windowsVirtualKeyCode: 9, nativeVirtualKeyCode: 9, modifiers: 8 };
await cdp().send('Input.dispatchKeyEvent', { type: 'rawKeyDown', ...commun });
await cdp().send('Input.dispatchKeyEvent', { type: 'keyUp', ...commun });
}
// La valeur de la fiche que nomme l'étiquette de clé donnée.
function valeurDeFiche(m, cle) {
const etiquette = [...fiche(m).querySelectorAll('dt')].find((dt) => dt.textContent === t(cle));
expect(etiquette, cle).toBeDefined();
return etiquette.nextElementSibling;
}
// Le tableau de la fiche que titre la légende de clé donnée.
const tableauDeFiche = (m, cle) => [...fiche(m).querySelectorAll('table')].find((tableau) => tableau.caption?.textContent === t(cle));
// L'écart entre deux boîtes : 0 quand elles se touchent ou se chevauchent.
function ecart(a, b) {
const ba = a.getBoundingClientRect();
const bb = b.getBoundingClientRect();
const dx = Math.max(0, ba.left - bb.right, bb.left - ba.right);
const dy = Math.max(0, ba.top - bb.bottom, bb.top - ba.bottom);
return Math.max(dx, dy);
}
const aucuneFenetre = () => expect(document.querySelectorAll('dialog[open], [role=dialog], [role=alertdialog]').length).toBe(0);
describe('la saisie (§ 4, § 10.2)', () => {
test('seul le nom : « Pervenche » enregistre une personne aux autres champs null, en une entrée ; un nom tapé en NFD est enregistré en NFC', async () => {
const m = await evenementOuvert();
const avant = entrees(m).length;
await ajouter(m, { nom: 'Pervenche' });
expect(entrees(m)).toHaveLength(avant + 1);
const [{ id, ...champs }] = participants(m);
expect(Number.isInteger(id)).toBe(true);
expect(champs).toEqual({
nom: 'Pervenche',
prenom: null,
appartenance: null,
courriel: null,
titrePressenti: null,
notes: null,
exclu: false,
});
expect(champ(m, 'nom').value).toBe('');
const decompose = 'Ombre\u{0301}lle';
expect(decompose.length).toBe(9);
await ajouter(m, { nom: decompose });
expect(entrees(m)).toHaveLength(avant + 2);
expect(participants(m).at(-1).nom).toBe('Ombr\u{00E9}lle');
});
test('nom vide : « ␠␠ » pose sur le champ aria-invalid et un message visible qui le jouxte ; aucune fenêtre, zéro écriture', async () => {
const m = await evenementOuvert();
const trace = m.trace.length;
const etat = texteEtat(m);
await ajouter(m, { nom: ' ' });
const entree = champ(m, 'nom');
expect(entree.getAttribute('aria-invalid')).toBe('true');
const message = document.getElementById(entree.getAttribute('aria-describedby'));
expect(message).not.toBeNull();
expect(message.textContent).toContain(t('NOM_REQUIS', {}));
expect(visible(message)).toBe(true);
expect(ecart(entree, message)).toBeLessThanOrEqual(16);
aucuneFenetre();
expect(m.trace.length).toBe(trace);
expect(texteEtat(m)).toBe(etat);
expect(participants(m)).toHaveLength(0);
});
test('appartenance collante : trois personnes portent « Club des Merles » ; le champ vidé, la quatrième n’en porte aucune', async () => {
const m = await evenementOuvert();
await ajouter(m, { nom: 'Ombrelle', prenom: 'Iris', appartenance: CLUB });
expect(champ(m, 'appartenance').value).toBe(CLUB);
expect(champ(m, 'prenom').value).toBe('');
await ajouter(m, { nom: 'Pervenche' });
await ajouter(m, { nom: 'Lacasse' });
expect(participants(m).map((p) => [p.nom, p.prenom, p.appartenance])).toEqual([
['Ombrelle', 'Iris', CLUB],
['Pervenche', null, CLUB],
['Lacasse', null, CLUB],
]);
await saisir(champ(m, 'appartenance'), '');
await ajouter(m, { nom: 'Grisaille' });
expect(participants(m).at(-1).appartenance).toBeNull();
});
test('une appartenance qui ne diffère d’une existante que par la casse et les blancs est signalée sur le champ, l’orthographe existante à reprendre ; rien ne fusionne seul', async () => {
const m = await evenementOuvert({ personnes: [{ nom: 'Ombrelle', prenom: 'Iris', appartenance: CLUB }] });
const trace = m.trace.length;
await saisir(champ(m, 'appartenance'), 'club des merles');
const signal = formulaire(m).querySelector('[data-avertissement]');
expect(signal).not.toBeNull();
expect(visible(signal)).toBe(true);
expect(signal.textContent).toContain(CLUB);
expect(champ(m, 'appartenance').getAttribute('aria-describedby')).toContain(signal.id);
expect(champ(m, 'appartenance').value).toBe('club des merles');
await cliquer(signal.querySelector('[data-commande="reprendreOrthographe"]'));
expect(champ(m, 'appartenance').value).toBe(CLUB);
expect(formulaire(m).querySelector('[data-avertissement]')).toBeNull();
expect(m.trace.length).toBe(trace);
});
});
describe('la grille (§ 10.2, § 19.3)', () => {
const TROIS = [
{ nom: 'Ombrelle', prenom: 'Iris', appartenance: CLUB },
{ nom: 'Pervenche', prenom: 'Théo', appartenance: 'CLUB DES MERLES' },
{ nom: 'Lacasse', prenom: 'Ondine' },
{ nom: 'Grisaille' },
];
test('au clavier seul : le courriel d’Ondine Lacasse changé en une entrée ; Échap rend la valeur, valider sans changement n’écrit rien', async () => {
const m = await evenementOuvert({ personnes: TROIS });
const ondine = idDe(m, 'Lacasse');
const rang = idsAffiches(m).indexOf(ondine);
const colonne = COLONNES.indexOf('courriel');
expect(rang).toBeGreaterThan(0);
expect(colonne).toBeGreaterThan(0);
const premiere = grille(m).querySelector('[tabindex="0"]');
expect(premiere).toBe(cellule(m, idsAffiches(m)[0], COLONNES[0]));
premiere.focus();
for (let i = 0; i < rang; i += 1) await touche('ArrowDown');
for (let i = 0; i < colonne; i += 1) await touche('ArrowRight');
await rendu();
const cible = cellule(m, ondine, 'courriel');
expect(document.activeElement).toBe(cible);
const avant = entrees(m).length;
await touche('Enter');
await rendu();
for (const caractere of 'ondine@exemple.test') await touche(caractere);
await touche('Enter');
await attendreSeance(m);
expect(entrees(m)).toHaveLength(avant + 1);
expect(entrees(m).at(-1).libelle).toBe('Modifié Ondine Lacasse : courriel');
expect(participants(m).find((p) => p.id === ondine).courriel).toBe('ondine@exemple.test');
expect(cellule(m, ondine, 'courriel').textContent.trim()).toBe('ondine@exemple.test');
// Échap en cours d'édition : rien ne s'écrit, la valeur d'origine revient.
const trace = m.trace.length;
cellule(m, ondine, 'courriel').focus();
await touche('Enter');
await rendu();
for (const caractere of 'zz') await touche(caractere);
await touche('Escape');
await attendreSeance(m);
expect(entrees(m)).toHaveLength(avant + 1);
expect(cellule(m, ondine, 'courriel').querySelector('input')).toBeNull();
expect(cellule(m, ondine, 'courriel').textContent.trim()).toBe('ondine@exemple.test');
// Valider sans changement : aucune commande ne part, donc aucun refus
// SANS_EFFET ne se pose sur la cellule, et rien ne s'écrit.
const executer = vi.spyOn(m.seance, 'executer');
cellule(m, ondine, 'courriel').focus();
await touche('Enter');
await rendu();
await touche('Enter');
await attendreSeance(m);
expect(executer).not.toHaveBeenCalled();
expect(cellule(m, ondine, 'courriel').querySelector('[data-refus]')).toBeNull();
expect(cellule(m, ondine, 'courriel').querySelector('[aria-invalid]')).toBeNull();
expect(cellule(m, ondine, 'courriel').querySelector('input')).toBeNull();
expect(entrees(m)).toHaveLength(avant + 1);
expect(m.trace.length).toBe(trace);
});
test('Tab après une saisie qui déplace la ligne au tri mène à la cellule suivante de la même personne', async () => {
const m = await evenementOuvert({ personnes: TROIS });
await cliquer(section(m).querySelector('[data-commande="trier"][data-cible="nom"]'));
const premier = idsAffiches(m)[0];
expect(participants(m).find((p) => p.id === premier).nom).toBe('Grisaille');
cellule(m, premier, 'nom').focus();
await touche('Enter');
await rendu();
await saisir(cellule(m, premier, 'nom').querySelector('input'), 'Zed');
await touche('Tab');
await attendreSeance(m);
expect(participants(m).find((p) => p.id === premier).nom).toBe('Zed');
expect(idsAffiches(m).at(-1)).toBe(premier);
expect(document.activeElement).toBe(cellule(m, premier, COLONNES[1]));
});
test('Tab après une saisie qui retire la ligne du filtre mène à la première cellule de la personne qui la suivait', async () => {
const m = await evenementOuvert({ personnes: TROIS });
await cliquer(section(m).querySelector('[data-commande="trier"][data-cible="nom"]'));
await saisir(section(m).querySelector('[data-champ="filtre"]'), 'merles');
const [premier, second] = idsAffiches(m);
expect(idsAffiches(m)).toHaveLength(2);
cellule(m, premier, 'appartenance').focus();
await touche('Enter');
await rendu();
await saisir(cellule(m, premier, 'appartenance').querySelector('input'), 'Cercle des Pinsons');
await touche('Tab');
await attendreSeance(m);
expect(idsAffiches(m)).toEqual([second]);
expect(document.activeElement).toBe(cellule(m, second, COLONNES[0]));
});
test('Maj+Tab après une saisie qui retire la ligne du filtre mène à la dernière cellule de la personne qui la précédait', async () => {
const m = await evenementOuvert({ personnes: TROIS });
await cliquer(section(m).querySelector('[data-commande="trier"][data-cible="nom"]'));
await saisir(section(m).querySelector('[data-champ="filtre"]'), 'merles');
const [premier, second] = idsAffiches(m);
expect(idsAffiches(m)).toHaveLength(2);
cellule(m, second, 'appartenance').focus();
await touche('Enter');
await rendu();
await saisir(cellule(m, second, 'appartenance').querySelector('input'), 'Cercle des Pinsons');
await majTab();
await attendreSeance(m);
expect(idsAffiches(m)).toEqual([premier]);
expect(document.activeElement).toBe(cellule(m, premier, COLONNES.at(-1)));
});
test('une note de deux lignes, retouchée dans la grille, garde son saut de ligne', async () => {
const m = await evenementOuvert();
await m.seance.executer('ajouterParticipant', { champs: { nom: 'Lacasse', prenom: 'Ondine', notes: 'arrive tard\nrepart tôt' } });
await rendu();
const id = idDe(m, 'Lacasse');
const avant = entrees(m).length;
cellule(m, id, 'notes').focus();
await touche('Enter');
await rendu();
for (const caractere of ' x') await touche(caractere);
await touche('Enter');
await attendreSeance(m);
expect(entrees(m)).toHaveLength(avant + 1);
expect(participants(m).find((p) => p.id === id).notes).toBe('arrive tard\nrepart tôt x');
});
test('trier par nom, croissant puis décroissant, filtrer « merles » : l’ordre et les lignes suivent ; rien ne s’écrit, en écriture comme en lecture', async () => {
const m = await evenementOuvert({ personnes: TROIS });
const parNom = (sens) =>
[...participants(m)]
.sort((a, b) => (a.nom < b.nom ? -1 : a.nom > b.nom ? 1 : a.id - b.id) * (sens === 'croissant' ? 1 : -1))
.map((p) => p.id);
const merles = participants(m)
.filter((p) => p.appartenance !== null)
.map((p) => p.id);
expect(merles).toHaveLength(2);
for (const mode of ['ecriture', 'lecture']) {
if (mode === 'lecture') {
await m.seance.passerEnLecture();
await rendu();
}
const journal = texteJournal(m);
const etat = texteEtat(m);
const trace = m.trace.length;
const trier = section(m).querySelector('[data-commande="trier"][data-cible="nom"]');
await cliquer(trier);
expect(idsAffiches(m), mode).toEqual(parNom('croissant'));
await cliquer(section(m).querySelector('[data-commande="trier"][data-cible="nom"]'));
expect(idsAffiches(m), mode).toEqual(parNom('decroissant'));
await saisir(section(m).querySelector('[data-champ="filtre"]'), 'merles');
expect([...idsAffiches(m)].sort((a, b) => a - b), mode).toEqual(merles);
expect(section(m).querySelector('output').textContent).toBe(t('participants.grille.compte', { total: 4, affiches: 2 }));
await saisir(section(m).querySelector('[data-champ="filtre"]'), '');
expect(idsAffiches(m)).toHaveLength(4);
await attendreSeance(m);
expect(texteJournal(m), mode).toBe(journal);
expect(texteEtat(m), mode).toBe(etat);
expect(m.trace.length, mode).toBe(trace);
}
});
test('après l’import des cas limites, le compte affiché égale le nombre de lignes importées ; une personne exclue reste visible', async () => {
const m = await evenementOuvert();
const octets = await octetsLivres(CAS_LIMITES);
const { resume } = await m.seance.executer('importerParticipants', {
apercu: apercevoir(decoder(octets).texte),
mode: 'ajouter',
});
await rendu();
expect(resume.ajoutes).toBeGreaterThan(0);
expect(participants(m)).toHaveLength(resume.ajoutes);
expect(lignes(m)).toHaveLength(resume.ajoutes);
expect(section(m).querySelector('output').textContent).toBe(
t('participants.grille.compte', { total: resume.ajoutes, affiches: resume.ajoutes }),
);
const exclue = participants(m).find((p) => p.exclu);
expect(exclue).toBeDefined();
expect(cellule(m, exclue.id, 'exclu').textContent.trim()).toBe(t('participants.grille.exclue'));
});
test('exclusion : douze personnes, en exclure une → douze lignes, la sienne porte « exclue » en texte ; le bilan compte onze participants', async () => {
const m = await evenementOuvert({ personnes: NOMS.slice(0, 12).map((nom) => ({ nom })) });
const id = idDe(m, NOMS[3]);
const avant = entrees(m).length;
await cliquer(commandeDeLigne(m, id, 'exclureParticipant'));
await attendreSeance(m);
expect(entrees(m)).toHaveLength(avant + 1);
expect(lignes(m)).toHaveLength(12);
expect(cellule(m, id, 'exclu').textContent.trim()).toBe(t('participants.grille.exclue'));
const autres = participants(m).filter((p) => p.id !== id);
expect(autres).toHaveLength(11);
// La marque est un texte, distinct de celui d'une personne présente.
for (const p of autres) expect(cellule(m, p.id, 'exclu').textContent.trim()).toBe(t('participants.grille.presente'));
expect(t('participants.grille.presente')).not.toBe(t('participants.grille.exclue'));
expect(bilanChangementDefaut(m.seance.etat().charge, 8).participants).toBe(11);
});
test('notes jamais interprétées : « x & =1+1 » s’affiche tel quel dans la grille et sur la fiche, sans élément ', async () => {
const m = await evenementOuvert();
const notes = 'x & =1+1';
await m.seance.executer('ajouterParticipant', { champs: { nom: 'Ombrelle', notes } });
await rendu();
const id = idDe(m, 'Ombrelle');
expect(cellule(m, id, 'notes').textContent.trim()).toBe(notes);
m.ui.ouvrirFiche(id);
await rendu();
expect(valeurDeFiche(m, 'participants.colonne.notes').textContent).toBe(notes);
expect(section(m).querySelectorAll('b')).toHaveLength(0);
});
test('titre pressenti : visible dans la grille et sur la fiche', async () => {
const m = await evenementOuvert({ personnes: [{ nom: 'Pervenche', prenom: 'Théo', titre: 'hôtesse' }] });
const id = idDe(m, 'Pervenche');
expect(cellule(m, id, 'titrePressenti').textContent.trim()).toBe('hôtesse');
cellule(m, id, 'titrePressenti').scrollIntoView({ block: 'center', inline: 'center' });
expect(visible(cellule(m, id, 'titrePressenti'))).toBe(true);
m.ui.ouvrirFiche(id);
await rendu();
const valeur = valeurDeFiche(m, 'participants.colonne.titrePressenti');
expect(valeur.textContent).toBe('hôtesse');
expect(visible(valeur)).toBe(true);
});
test('supprimer sans réservation : aucune fenêtre, une entrée ; avec réservations, la fenêtre les nomme, et Annuler ne détruit rien', async () => {
const m = await evenementOuvert({ tables: 1, personnes: [{ nom: 'Ombrelle' }, { nom: 'Pervenche' }] });
const iris = idDe(m, 'Ombrelle');
const theo = idDe(m, 'Pervenche');
const table = m.seance.etat().charge.tables[0];
await m.seance.executer('reserver', { participant: theo, table: table.id, siege: 2, portee: 'tous', tour: null });
await rendu();
const avant = entrees(m).length;
await cliquer(commandeDeLigne(m, iris, 'supprimerParticipant'));
await attendreSeance(m);
aucuneFenetre();
expect(entrees(m)).toHaveLength(avant + 1);
expect(participants(m).map((p) => p.id)).toEqual([theo]);
await cliquer(commandeDeLigne(m, theo, 'supprimerParticipant'));
const fenetre = document.querySelector('dialog[open]');
expect(fenetre).not.toBeNull();
const elements = [...fenetre.querySelectorAll('li')].map((li) => li.textContent);
expect(elements).toEqual([
t('participants.suppression.reservation', { numero: table.numero, siege: 2, portee: 'tous', tour: null }),
]);
await cliquer(fenetre.querySelector('[data-commande="renoncer"]'));
await attendreSeance(m);
expect(entrees(m)).toHaveLength(avant + 1);
expect(participants(m).map((p) => p.id)).toEqual([theo]);
await cliquer(commandeDeLigne(m, theo, 'supprimerParticipant'));
await cliquer(document.querySelector('dialog[open] [data-commande="confirmer"]'));
await attendreSeance(m);
expect(entrees(m)).toHaveLength(avant + 2);
expect(participants(m)).toEqual([]);
});
});
describe('le manque de places (§ 5.9, § 4.4)', () => {
test('deux tables de 8 et seize personnes : la dix-septième est acceptée, une entrée, et avertit PLACES_MANQUANTES de 1', async () => {
const m = await evenementOuvert({ tables: 2, personnes: NOMS.slice(0, 16).map((nom) => ({ nom })) });
const executer = vi.spyOn(m.seance, 'executer');
const avant = entrees(m).length;
await ajouter(m, { nom: NOMS[16] });
expect(entrees(m)).toHaveLength(avant + 1);
expect(participants(m)).toHaveLength(17);
expect(executer).toHaveBeenCalledTimes(1);
expect(executer.mock.calls[0][0]).toBe('ajouterParticipant');
const rendus = await executer.mock.results[0].value;
const attendu = nombrePlacesManquantes(normaliser(configurationDepuisCharge(m.seance.etat().charge)));
expect(attendu).toBe(1);
const manque = rendus.avertissements.find(({ code }) => code === 'PLACES_MANQUANTES');
expect(manque?.details.placesManquantes).toBe(attendu);
const etat = m.seance.etat().avertissements.find(({ code }) => code === 'PLACES_MANQUANTES');
expect(etat?.details.placesManquantes).toBe(attendu);
});
test('deux tables de 8, dix-sept personnes dont une exclue : la réintégrer est accepté, une entrée, et avertit PLACES_MANQUANTES de 1', async () => {
const personnes = NOMS.slice(0, 17).map((nom, rang) => ({ nom, exclu: rang === 5 }));
const m = await evenementOuvert({ tables: 2, personnes });
const id = idDe(m, NOMS[5]);
const executer = vi.spyOn(m.seance, 'executer');
const avant = entrees(m).length;
await cliquer(commandeDeLigne(m, id, 'reintegrerParticipant'));
await attendreSeance(m);
expect(entrees(m)).toHaveLength(avant + 1);
expect(participants(m).find((p) => p.id === id).exclu).toBe(false);
expect(executer).toHaveBeenCalledTimes(1);
expect(executer.mock.calls[0]).toEqual(['reintegrerParticipant', { id }]);
const rendus = await executer.mock.results[0].value;
const attendu = nombrePlacesManquantes(normaliser(configurationDepuisCharge(m.seance.etat().charge)));
expect(attendu).toBe(1);
expect(rendus.avertissements.find(({ code }) => code === 'PLACES_MANQUANTES')?.details.placesManquantes).toBe(attendu);
expect(m.seance.etat().avertissements.find(({ code }) => code === 'PLACES_MANQUANTES')?.details.placesManquantes).toBe(
attendu,
);
});
});
describe('la fiche (§ 4.2, § 7.4)', () => {
test('statuts : A, B, C, D → ancré, ancré, partiellement fixé, mobile, égaux au statut du moteur ; aucun contrôle de saisie de statut', async () => {
const m = await evenementOuvert({
sieges: 4,
tours: 3,
tables: 2,
personnes: [
{ nom: 'Ombrelle', prenom: 'Iris' },
{ nom: 'Pervenche', prenom: 'Théo' },
{ nom: 'Lacasse', prenom: 'Ondine' },
{ nom: 'Grisaille' },
],
});
const [t1, t2] = m.seance.etat().charge.tables.map(({ id }) => id);
const [a, b, c, d] = ['Ombrelle', 'Pervenche', 'Lacasse', 'Grisaille'].map((nom) => idDe(m, nom));
const reserver = (participant, table, portee, tour = null) =>
m.seance.executer('reserver', { participant, table, siege: null, portee, tour });
await reserver(a, t1, 'tous');
for (const tour of [1, 2, 3]) await reserver(b, t1, 'tour', tour);
await reserver(c, t1, 'tour', 1);
await reserver(c, t2, 'tour', 2);
await rendu();
const instance = normaliser(configurationDepuisCharge(m.seance.etat().charge));
const NOMS_STATUTS = new Map([
[STATUT.ANCRE, 'ancre'],
[STATUT.PARTIELLEMENT_FIXE, 'partiel'],
[STATUT.MOBILE, 'mobile'],
]);
const attendus = [
[a, 'ancre'],
[b, 'ancre'],
[c, 'partiel'],
[d, 'mobile'],
];
for (const [id, statut] of attendus) {
expect(NOMS_STATUTS.get(instance.statut[instance.indexDe.get(id)])).toBe(statut);
m.ui.ouvrirFiche(id);
await rendu();
expect(fiche(m).dataset.fiche).toBe(String(id));
expect(valeurDeFiche(m, 'participants.fiche.statut').textContent).toBe(t(cleDeStatut(statut)));
expect(fiche(m).querySelectorAll('input, select, textarea')).toHaveLength(0);
}
});
test('ouverte depuis sa ligne, puis par ui.ouvrirFiche : visible ; sur un retenu, son itinéraire égale, tour par tour, la forme nommée', async () => {
const m = await evenementOuvert({ sieges: 4, tours: 3, tables: 2, personnes: NOMS.slice(0, 7).map((nom) => ({ nom })) });
await m.seance.executer('modifierReglage', { cle: 'attribuerSieges', valeur: true });
await m.app.generation().generer({ graine: 1, nombre: 1, arret: 50, historique: 1000 });
const [proposition] = m.seance.etat().charge.propositions;
await m.seance.executer('retenirProposition', { id: proposition.id });
// Une personne quitte sa table au tour 2 : la réserve figure à l'itinéraire.
await m.seance.executer('retirerDuTour', { tour: 2, participant: idDe(m, NOMS[2]) });
await rendu();
const charge = m.seance.etat().charge;
const forme = formeNommee(charge.retenu, charge);
expect(forme.some((ligneForme) => ligneForme.reserve)).toBe(true);
const id = idDe(m, NOMS[2]);
await cliquer(commandeDeLigne(m, id, 'fiche'));
expect(m.ui.etat().fiche).toBe(id);
expect(fiche(m).dataset.fiche).toBe(String(id));
expect(visible(fiche(m))).toBe(true);
await cliquer(fiche(m).querySelector('[data-commande="fermerFiche"]'));
expect(fiche(m)).toBeNull();
expect(m.ui.etat().section).toBe('participants');
// Chaque personne : l'itinéraire de la fiche, ligne par ligne.
expect(charge.participants).toHaveLength(7);
for (const { id: personne } of charge.participants) {
m.ui.ouvrirFiche(personne);
await rendu();
expect(visible(fiche(m))).toBe(true);
const tableau = tableauDeFiche(m, 'participants.fiche.itineraire');
expect(tableau).toBeDefined();
const lus = [...tableau.tBodies[0].rows].map((rangee) => [...rangee.cells].map((c) => c.textContent.trim()));
const attendus = forme
.filter((l) => l.participant === personne)
.map((l) => (l.reserve ? [String(l.tour), t('participants.fiche.enReserve')] : [String(l.tour), String(l.numero), String(l.siege)]));
expect(attendus).toHaveLength(3);
expect(lus).toEqual(attendus);
}
});
test('un nom de deux cents caractères se lit entier sur la fiche, replié, sans marque ni débordement', async () => {
const long = 'Ombrelle'.repeat(25);
expect(long).toHaveLength(200);
const m = await evenementOuvert();
await m.seance.executer('ajouterParticipant', { champs: { nom: long } });
const id = idDe(m, long);
m.ui.ouvrirFiche(id);
await rendu();
const porteur = fiche(m);
const valeur = valeurDeFiche(m, 'participants.colonne.nom');
expect(valeur.textContent).toBe(long);
expect(porteur.textContent).not.toContain('\u{2026}');
expect(porteur.scrollWidth).toBeLessThanOrEqual(porteur.clientWidth);
expect(valeur.scrollWidth).toBeLessThanOrEqual(valeur.clientWidth);
const interligne = Number.parseFloat(getComputedStyle(valeur).lineHeight);
expect(interligne).toBeGreaterThan(0);
expect(valeur.getBoundingClientRect().height).toBeGreaterThanOrEqual(2 * interligne);
});
});
describe('export et collage (§ 10.2)', () => {
test('« Exporter la liste » remet les octets d’exporterParticipants ; réimportés dans un événement neuf, ils rendent les mêmes participants, exclu compris', async () => {
const m = await evenementOuvert();
const octets = await octetsLivres(CAS_LIMITES);
await m.seance.executer('importerParticipants', { apercu: apercevoir(decoder(octets).texte), mode: 'ajouter' });
await rendu();
const releves = [];
m.fs.enregistrerSous = async (nomPropose, contenu) => {
releves.push({ nomPropose, octets: contenu });
return nomPropose;
};
const avant = participants(m);
expect(avant.some((p) => p.exclu)).toBe(true);
const trace = m.trace.length;
await cliquer(section(m).querySelector('[data-commande="exporter"]'));
await attendreSeance(m);
expect(releves).toHaveLength(1);
expect(releves[0].nomPropose).toBe(`${base(m)}-participants.csv`);
expect([...releves[0].octets]).toEqual([...exporterParticipants(avant)]);
expect(m.trace.length).toBe(trace);
await m.seance.fermer();
await m.seance.creer({ nom: 'Soirée des Grisailles', date: null, siegesParDefaut: 8, tours: 2 });
await m.seance.passerEnEcriture();
await m.seance.executer('importerParticipants', { apercu: apercevoir(decoder(releves[0].octets).texte), mode: 'ajouter' });
const sansId = (liste) => liste.map(({ id, ...champs }) => champs);
expect(avant.length).toBeGreaterThan(0);
expect(sansId(participants(m))).toEqual(sansId(avant));
});
test('un collage sur la grille ouvre l’aperçu d’import : importEnAttente porte le texte intact, origine collage, et la section import ; zéro écriture', async () => {
const m = await evenementOuvert({ personnes: [{ nom: 'Ombrelle' }] });
const etats = [];
const desabonner = m.ui.subscribe((etat) => etats.push(etat));
const trace = m.trace.length;
const texte = 'nom;prénom\r\nPervenche;Théo\n"Lacasse; Ondine";\u{00A0}x\t';
grille(m).querySelector('[tabindex="0"]').focus();
const presse = new DataTransfer();
presse.setData('text/plain', texte);
grille(m).querySelector('[tabindex="0"]').dispatchEvent(
new ClipboardEvent('paste', { clipboardData: presse, bubbles: true, cancelable: true }),
);
await rendu();
desabonner();
expect(etats.some((etat) => etat.importEnAttente?.texte === texte && etat.importEnAttente.origine === 'collage' && etat.section === 'import')).toBe(
true,
);
expect(m.ui.etat().section).toBe('import');
await attendreSeance(m);
expect(m.trace.length).toBe(trace);
});
});