The working screens of an evening. The home screen lists the events of the working folder, asks its question, loads demos, copies, renames, imports and deletes, and shows what the trash holds. An open event frames its mode, offers Modifier, Débloquer and a confirmed lock takeover, and shows its warnings. Participants get a grid, a form and a file; an import goes through a preview and a report; tables, the default and tours have their screens; the plan draws tables, chairs, empty seats and names. Checked: 2342 node, 356 browser, 60 node-long; the Electron start check. --- FR --- [ADD] interface : liste, événement, participants, import, tables, plan Les écrans de travail d'une soirée. L'accueil liste les événements du dossier de travail, pose sa question, charge les démonstrations, copie, renomme, importe et supprime, et montre ce que tient la corbeille. Un événement ouvert encadre son mode, offre Modifier, Débloquer et une reprise de verrou confirmée, et montre ses avertissements. Les participants ont une grille, un formulaire et une fiche ; un import passe par un aperçu et un rapport ; tables, défaut et tours ont leurs écrans ; le plan dessine tables, chaises, sièges vides et noms. Vérifié : 2342 node, 356 navigateur, 60 node-long ; démarrage d'Electron. Assisted-by: Claude Opus 5.5
742 lines
34 KiB
JavaScript
742 lines
34 KiB
JavaScript
// © 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 : « <b>x</b> & =1+1 » s’affiche tel quel dans la grille et sur la fiche, sans élément <b>', async () => {
|
||
const m = await evenementOuvert();
|
||
const notes = '<b>x</b> & =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);
|
||
});
|
||
});
|