diff --git a/src/application/csv.js b/src/application/csv.js index 24a90a5..5571053 100644 --- a/src/application/csv.js +++ b/src/application/csv.js @@ -10,6 +10,7 @@ import { exporterRefus as exporterRefusCsv } from '../csv/import.js'; import { libelle } from './libelles.js'; export { apercevoir } from '../csv/apercu.js'; +export { CHAMPS } from '../csv/colonnes.js'; export { decoder } from '../csv/encodage.js'; export { exporterParticipants } from '../csv/export.js'; export { bilanRemplacement } from '../csv/import.js'; diff --git a/src/application/csv.test.js b/src/application/csv.test.js index a18f19e..4b37f23 100644 --- a/src/application/csv.test.js +++ b/src/application/csv.test.js @@ -8,6 +8,7 @@ import assert from 'node:assert/strict'; import { describe, test } from '../../test/lanceur.js'; import * as apercu from '../csv/apercu.js'; +import * as colonnes from '../csv/colonnes.js'; import * as encodage from '../csv/encodage.js'; import * as exportCsv from '../csv/export.js'; import * as importCsv from '../csv/import.js'; @@ -17,7 +18,7 @@ import * as relais from './csv.js'; import { libelle } from './libelles.js'; describe('csv : le relais de src/csv pour l’interface (§ 13.4)', () => { - test('decoder, apercevoir, exporterParticipants, bilanRemplacement et cleNormalisee sont ceux de src/csv', () => { + test('decoder, apercevoir, exporterParticipants, bilanRemplacement, cleNormalisee et CHAMPS sont ceux de src/csv', () => { const attendus = [ ['decoder', encodage.decoder], ['apercevoir', apercu.apercevoir], @@ -30,8 +31,9 @@ describe('csv : le relais de src/csv pour l’interface (§ 13.4)', () => { assert.equal(typeof fonction, 'function', nom); assert.equal(relais[nom], fonction, nom); } + assert.equal(relais.CHAMPS, colonnes.CHAMPS); assert.deepEqual(Object.keys(relais).sort(), [ - 'apercevoir', 'bilanRemplacement', 'cleNormalisee', 'decoder', 'exporterParticipants', 'exporterRefus', + 'CHAMPS', 'apercevoir', 'bilanRemplacement', 'cleNormalisee', 'decoder', 'exporterParticipants', 'exporterRefus', ]); }); diff --git a/src/application/libelles/seance.js b/src/application/libelles/seance.js index 0ffa19c..2d543ca 100644 --- a/src/application/libelles/seance.js +++ b/src/application/libelles/seance.js @@ -92,7 +92,7 @@ export const TABLE = new Map([ ], [ 'VERROU_REPRIS', - ({ depuis }, texte) => `Le verrou qu'une séance interrompue de ce poste avait laissé${depuisQuand(depuis, texte)} est repris.`, + ({ depuis }, texte) => `Le verrou qu'une autre séance tenait${depuisQuand(depuis, texte)} est repris.`, ], [ 'RETOUR_AUTOMATIQUE', diff --git a/src/application/seance.js b/src/application/seance.js index 256597b..7443138 100644 --- a/src/application/seance.js +++ b/src/application/seance.js @@ -88,6 +88,7 @@ import { import { FORMAT, analyser } from '../stockage/document.js'; import { ErreurStockage } from '../stockage/erreurs.js'; import { fil, reconstruire } from '../stockage/journal.js'; +import { SUFFIXES } from '../stockage/noms.js'; import { avecCadrage, cadrageDe, creerEcrivainDiffere, ecrireReglages, lireReglages } from '../stockage/reglages_locaux.js'; import { avertissementsApres, invariantViole } from './avertissements.js'; import { appliquerCommande, chargeNeuve, formeNommeeDe } from './commandes.js'; @@ -429,21 +430,35 @@ export function creerSeance({ // null) ou illisible ne se reprend pas : VERROU_PRIS {seance, depuis, // vivant}, remède null, que verrouAutre garde. Un système sans verrou le // rend toujours pris. Un support qui refuse d'écrire le verrou, ou - // d'effacer celui du mort, rompt le support sous l'échec + // d'effacer celui qu'il reprend, rompt le support sous l'échec // VERROU_NON_ECRIT, et son ECRITURE remonte (§ 8.6). Rend les // avertissements. - async function prendreVerrou(ouvert) { + // + // reprise : le verrou que l'opérateur a vu refusé et confirmé reprendre, + // {seance, depuis} de verrouAutre (reprendreVerrou), null sinon. Un verrou + // dont la vie ne se prouve pas (vivant null) s'efface alors, puis se prend, + // sous VERROU_REPRIS {seance, depuis} — mais seulement le verrou même que + // la reprise nomme, de même séance et de même instant : un verrou posé + // depuis, que l'opérateur n'a pas vu, se refuse VERROU_PRIS comme sans + // reprise. Un verrou vivant ne se reprend jamais. + async function prendreVerrou(ouvert, reprise = null) { let verrou; const avertissements = []; try { verrou = await verrouiller(ouvert, id); if (!verrou.pris && verrou.seance === id) verrou = { pris: true }; - if (!verrou.pris && verrou.vivant === false) { - const mort = verrou; - await deverrouiller(ouvert, mort.seance); + const confirme = + !verrou.pris && + reprise !== null && + verrou.vivant === null && + verrou.seance === reprise.seance && + verrou.depuis === reprise.depuis; + if (!verrou.pris && (verrou.vivant === false || confirme)) { + const ancien = verrou; + await effacerVerrou(ouvert, ancien.seance); verrou = await verrouiller(ouvert, id); if (verrou.pris) { - avertissements.push(avertissement('VERROU_REPRIS', { seance: mort.seance, depuis: mort.depuis })); + avertissements.push(avertissement('VERROU_REPRIS', { seance: ancien.seance, depuis: ancien.depuis })); } } } catch (erreur) { @@ -460,6 +475,18 @@ export function creerSeance({ return avertissements; } + // Efface le verrou d'une autre séance, que prendreVerrou reprend : celui + // de la séance nommée, par deverrouiller, qui n'efface que lui ; un verrou + // illisible (seance null) n'a pas de séance qui le rende, et son fichier + // s'efface. + async function effacerVerrou(ouvert, seance) { + if (seance !== null) { + await deverrouiller(ouvert, seance); + return; + } + await ouvert.fs.supprimer(ouvert.racine, ouvert.base + SUFFIXES.verrou); + } + // Rend le verrou que la séance tient sur l'événement de l'Ouvert. Un refus // du système de fichiers ne retient pas la séance : VERROU_NON_RENDU le // dit, et le verrou tombe avec ce processus. @@ -911,7 +938,33 @@ export function creerSeance({ if (estBloque(ouvert)) throw refusBloque(); if (etat.mode === 'ecriture') return { avertissements: [] }; exigerSupport(); - const avertissements = await prendreVerrou(ouvert); + return entrerSousVerrou(ouvert, null); + } + + // Reprendre le verrou (§ 8.8) : le passage en écriture qui reprend le + // verrou refusé que verrouAutre garde, une fois que l'opérateur l'a + // confirmé en voyant sa séance et depuis quand (prendreVerrou). Sans + // verrou refusé, c'est un passage en écriture. Le refus d'un débloquer + // admet la reprise : sur un plan bloqué, ou relu bloqué, le verrou repris + // se rend aussitôt, la séance reste en lecture, et VERROU_REPRIS se lit + // parmi les avertissements du dernier geste ; débloquer passe ensuite. + async function reprendreVerrou() { + const ouvert = exigerOuvert(); + if (ouvert.formatPlusRecent) throw refusFormat(); + if (etat.mode === 'ecriture') return { avertissements: [] }; + exigerSupport(); + const reprise = etat.verrouAutre; + return entrerSousVerrou(ouvert, reprise === null ? null : { seance: reprise.seance, depuis: reprise.depuis }, { + bloqueAdmis: true, + }); + } + + // Prend le verrou — en reprenant celui que reprise nomme —, relit + // l'événement, et passe en écriture. Relu d'un format plus récent, il rend + // le verrou et lève le refus ; relu bloqué, il rend le verrou, et lève le + // refus sauf bloqueAdmis, qui rend les avertissements. + async function entrerSousVerrou(ouvert, reprise, { bloqueAdmis = false } = {}) { + const avertissements = await prendreVerrou(ouvert, reprise); avertissements.push(...(await relireSousVerrou(ouvert))); const relu = etat.ouvert; let refus = null; @@ -919,6 +972,7 @@ export function creerSeance({ else if (estBloque(relu)) refus = refusBloque(); if (refus !== null) { etat.avertissementsGeste = [...avertissements, ...(await rendreVerrou(relu))]; + if (bloqueAdmis && !relu.formatPlusRecent) return { avertissements: [...etat.avertissementsGeste] }; throw refus; } entrerEnEcriture(); @@ -1170,6 +1224,14 @@ export function creerSeance({ return { avertissements }; }), passerEnEcriture: () => enFile(passerEnEcriture), + /** + * Reprend le verrou que verrouAutre garde, après la confirmation de + * l'opérateur qui a vu sa séance et depuis quand (§ 8.8) : l'efface, + * prend celui de la séance et passe en écriture, sous VERROU_REPRIS + * {seance, depuis}. Un verrou qui a changé depuis le refus se refuse + * VERROU_PRIS ; un verrou vivant ne se reprend jamais. + */ + reprendreVerrou: () => enFile(reprendreVerrou), passerEnLecture: () => enFile(passerEnLecture), executer: (nom, args = {}) => enFile(() => suivi(() => geste(nom, args))), debloquer: () => enFile(() => suivi(() => geste('debloquer', {}))), diff --git a/src/application/seance.test.js b/src/application/seance.test.js index 89661f9..f049488 100644 --- a/src/application/seance.test.js +++ b/src/application/seance.test.js @@ -420,6 +420,165 @@ describe("séance : le verrou, pris à l'entrée en écriture (§ 8.8)", () => { }); }); +describe('séance : reprendre le verrou que la séance ne reprend pas seule (§ 8.8)', () => { + // Un verrou posé sur le disque partagé par une séance d'un autre poste, à + // l'instant depuis. + const verrouLointain = (seance, depuis) => `${JSON.stringify({ seance, pid: 41, hote: 'poste-lointain', depuis })}\n`; + + // Une séance d'épreuve qui ouvre en lecture l'événement d'essai, créé par + // une première séance, déjà fermée, sur le même disque. + async function secondeSeance(disque, hote = 'autre-poste') { + const premier = monter({ fs: creerFichiersSimules({ disque }), prefixe: 'createur' }); + await premier.seance.creer({ nom: BASE, date: null, siegesParDefaut: 2, tours: 2 }); + await premier.seance.fermer(); + const second = monter({ fs: creerFichiersSimules({ disque, hote }), prefixe: 'reprise' }); + await ouvrirSansEcrire(second); + return second; + } + + test("le verrou d'un autre poste, refusé puis repris : il s'efface, la séance prend le sien et passe en écriture, VERROU_REPRIS le dit", async () => { + const disque = new Map(); + const premier = monter({ fs: creerFichiersSimules({ disque }), prefixe: 'premiere' }); + await enEcriture(premier); + const { depuis } = verrouDe(premier.fs); + const lointain = monter({ fs: creerFichiersSimules({ disque, hote: 'autre-poste' }), prefixe: 'lointaine' }); + await ouvrirSansEcrire(lointain); + await refus(lointain.seance.passerEnEcriture(), 'VERROU_PRIS', { seance: 'premiere-1', depuis, vivant: null, remede: null }); + assert.deepEqual(lointain.seance.etat().verrouAutre, { seance: 'premiere-1', depuis, vivant: null }); + const journal = lointain.fs.contenu('documents', JOURNAL); + const repris = [{ code: 'VERROU_REPRIS', details: { seance: 'premiere-1', depuis } }]; + assert.deepEqual(await lointain.seance.reprendreVerrou(), { avertissements: repris }); + assert.equal(lointain.seance.mode, 'ecriture'); + assert.equal(verrouDe(lointain.fs).seance, 'lointaine-1'); + assert.equal(lointain.fs.contenu('documents', JOURNAL), journal, 'reprendre n\u{2019}écrit aucune entrée'); + const etat = lointain.seance.etat(); + assert.equal(etat.verrouAutre, null); + assert.deepEqual(etat.avertissements, repris); + assert.equal(etat.refus, null); + await lointain.seance.executer('ajouterParticipant', { champs: { nom: 'Ombrelle', prenom: 'Iris' } }); + assert.deepEqual(noms(etatDe(lointain.fs).charge), ['Ombrelle']); + }); + + test('un verrou illisible se reprend de même, sous VERROU_REPRIS {seance: null, depuis: null}', async () => { + const disque = new Map(); + const second = await secondeSeance(disque, 'poste-epreuve'); + second.fs.deposer('documents', VERROU, 'pas un verrou\n'); + await refus(second.seance.passerEnEcriture(), 'VERROU_PRIS', { seance: null, depuis: null, vivant: null, remede: null }); + const repris = [{ code: 'VERROU_REPRIS', details: { seance: null, depuis: null } }]; + assert.deepEqual(await second.seance.reprendreVerrou(), { avertissements: repris }); + assert.equal(second.seance.mode, 'ecriture'); + assert.equal(verrouDe(second.fs).seance, 'reprise-1'); + }); + + test("un verrou qui a changé depuis le refus ne s'efface pas : VERROU_PRIS nomme le nouveau, que verrouAutre garde", async () => { + const disque = new Map(); + const second = await secondeSeance(disque); + const vu = '2031-05-17T09:00:00-04:00'; + second.fs.deposer('documents', VERROU, verrouLointain('lointaine-1', vu)); + await refus(second.seance.passerEnEcriture(), 'VERROU_PRIS', { seance: 'lointaine-1', depuis: vu, vivant: null, remede: null }); + const pose = '2031-05-17T09:30:00-04:00'; + const nouveau = verrouLointain('lointaine-2', pose); + second.fs.deposer('documents', VERROU, nouveau); + const details = { seance: 'lointaine-2', depuis: pose, vivant: null }; + await refus(second.seance.reprendreVerrou(), 'VERROU_PRIS', { ...details, remede: null }); + assert.equal(second.fs.contenu('documents', VERROU), nouveau); + assert.equal(second.seance.mode, 'lecture'); + assert.deepEqual(second.seance.etat().verrouAutre, details); + const repris = [{ code: 'VERROU_REPRIS', details: { seance: 'lointaine-2', depuis: pose } }]; + assert.deepEqual(await second.seance.reprendreVerrou(), { avertissements: repris }); + assert.equal(verrouDe(second.fs).seance, 'reprise-1'); + }); + + // Le verrou vu, puis remplacé par celui que décrit change : la reprise + // le refuse VERROU_PRIS avec les détails du nouveau, ne touche pas au + // fichier, et verrouAutre garde le nouveau. + async function repriseRefusee(seance, depuis) { + const second = await secondeSeance(new Map()); + const vu = '2031-05-17T09:00:00-04:00'; + second.fs.deposer('documents', VERROU, verrouLointain('lointaine-1', vu)); + await refus(second.seance.passerEnEcriture(), 'VERROU_PRIS', { seance: 'lointaine-1', depuis: vu, vivant: null, remede: null }); + const nouveau = verrouLointain(seance, depuis); + second.fs.deposer('documents', VERROU, nouveau); + const details = { seance, depuis, vivant: null }; + await refus(second.seance.reprendreVerrou(), 'VERROU_PRIS', { ...details, remede: null }); + assert.equal(second.fs.contenu('documents', VERROU), nouveau); + assert.equal(second.seance.mode, 'lecture'); + assert.deepEqual(second.seance.etat().verrouAutre, details); + } + + test("la même séance reprise à un instant plus tard : le verrou n'est plus celui que l'opérateur a vu, il ne s'efface pas", async () => { + await repriseRefusee('lointaine-1', '2031-05-17T09:45:00-04:00'); + }); + + test("une autre séance au même instant : le verrou n'est plus celui que l'opérateur a vu, il ne s'efface pas", async () => { + await repriseRefusee('lointaine-5', '2031-05-17T09:00:00-04:00'); + }); + + test("le verrou d'une séance vivante de ce poste ne se reprend jamais", async () => { + const disque = new Map(); + const premier = monter({ fs: creerFichiersSimules({ disque }), prefixe: 'premiere' }); + await enEcriture(premier); + const { depuis } = verrouDe(premier.fs); + const voisin = monter({ fs: creerFichiersSimules({ disque }), prefixe: 'voisine' }); + await ouvrirSansEcrire(voisin); + const details = { seance: 'premiere-1', depuis, vivant: true }; + await refus(voisin.seance.passerEnEcriture(), 'VERROU_PRIS', { ...details, remede: null }); + await refus(voisin.seance.reprendreVerrou(), 'VERROU_PRIS', { ...details, remede: null }); + assert.equal(verrouDe(voisin.fs).seance, 'premiere-1'); + assert.equal(voisin.seance.mode, 'lecture'); + }); + + test("sans verrou refusé, reprendre est passer en écriture : un verrou posé depuis n'est pas repris", async () => { + const second = await secondeSeance(new Map()); + const pose = '2031-05-17T10:00:00-04:00'; + second.fs.deposer('documents', VERROU, verrouLointain('lointaine-3', pose)); + await refus(second.seance.reprendreVerrou(), 'VERROU_PRIS', { seance: 'lointaine-3', depuis: pose, vivant: null, remede: null }); + assert.equal(verrouDe(second.fs).seance, 'lointaine-3'); + const libre = await secondeSeance(new Map()); + assert.deepEqual(await libre.seance.reprendreVerrou(), { avertissements: [] }); + assert.deepEqual([libre.seance.mode, verrouDe(libre.fs).seance], ['ecriture', 'reprise-1']); + }); + + test('sur un plan bloqué, la reprise rend aussitôt le verrou : la séance reste en lecture, et débloquer passe ensuite', async () => { + const montage = monter({ prefixe: 'bloqueuse' }); + const { fs, seance } = montage; + await enEcriture(montage); + await seance.executer('changerEtat', { etat: 'bloque' }); + const depuis = '2031-05-17T11:00:00-04:00'; + fs.deposer('documents', VERROU, verrouLointain('lointaine-4', depuis)); + await refus(seance.debloquer(), 'VERROU_PRIS', { seance: 'lointaine-4', depuis, vivant: null, remede: null }); + const journal = fs.contenu('documents', JOURNAL); + const repris = [{ code: 'VERROU_REPRIS', details: { seance: 'lointaine-4', depuis } }]; + assert.deepEqual(await seance.reprendreVerrou(), { avertissements: repris }); + assert.deepEqual([seance.mode, seance.charge.evenement.etat], ['lecture', 'bloque']); + assert.equal(verrouDe(fs), null); + assert.equal(fs.contenu('documents', JOURNAL), journal); + assert.deepEqual(seance.etat().avertissements, repris); + assert.deepEqual(seance.etat().refus, { code: 'LECTURE_SEULE', details: BLOQUE_SEUL }); + await seance.debloquer(); + assert.deepEqual([seance.mode, seance.charge.evenement.etat], ['ecriture', 'brouillon']); + }); + + test("en écriture, reprendre ne fait rien ; un format plus récent et un support rompu le refusent sans rien écrire", async () => { + const montage = monter(); + await enEcriture(montage); + const avant = montage.fs.compteurs.ecritures; + assert.deepEqual(await montage.seance.reprendreVerrou(), { avertissements: [] }); + assert.equal(montage.fs.compteurs.ecritures, avant); + montage.fs.pannes.ecriture('documents'); + await refus(montage.seance.executer('ajouterParticipant', { champs: { nom: 'Pervenche' } }), 'ECRITURE'); + await montage.seance.passerEnLecture(); + const rompu = montage.fs.compteurs.ecritures; + await refus(montage.seance.reprendreVerrou(), 'SUPPORT_ROMPU'); + assert.equal(montage.fs.compteurs.ecritures, rompu); + const fsRecent = creerFichiersSimules(); + deposerAuFormat(fsRecent, FORMAT + 1); + const recent = monter({ fs: fsRecent }); + await ouvrirSansEcrire(recent); + await refus(recent.seance.reprendreVerrou(), 'LECTURE_SEULE', FORMAT_SEUL); + }); +}); + describe("séance : l'inactivité (§ 8.4)", () => { test('au terme du délai, la séance revient seule en lecture et rend son verrou', async () => { const montage = monter(); @@ -2350,7 +2509,7 @@ describe("séance observable : le verrou d'une autre séance (§ 8.8, A2)", () = assert.deepEqual(second.seance.etat().avertissements, repris); }); - test("le verrou d'une instance d'un autre hôte (vivant null) n'est jamais repris : VERROU_PRIS, remède null, le fichier inchangé, et aucun geste de la séance ne le reprend", async () => { + test("le verrou d'une instance d'un autre hôte (vivant null) n'est jamais repris : VERROU_PRIS, remède null, le fichier inchangé, et aucun geste ne le reprend seul : seul reprendreVerrou, que l'opérateur confirme, le reprend", async () => { const disque = new Map(); const premier = monter({ fs: creerFichiersSimules({ disque }), prefixe: 'premiere' }); await enEcriture(premier); @@ -2365,7 +2524,6 @@ describe("séance observable : le verrou d'une autre séance (§ 8.8, A2)", () = assert.equal(premier.fs.contenu('documents', VERROU), texte); assert.deepEqual(seance.etat().verrouAutre, { seance: 'premiere-1', depuis, vivant: null }); assert.equal(seance.etat().mode, 'lecture'); - assert.equal(seance.reprendreVerrou, undefined); }); test("un verrou illisible n'est jamais repris : VERROU_PRIS, remède null, le fichier inchangé", async () => { @@ -2641,7 +2799,7 @@ describe("séance observable : les textes des codes qu'elle ajoute (§ 14.6, A2) for (const motif of [/identifiant 3\b/, /tour 2\b/, /\b9 personnes\b/, /\b8 places\b/]) assert.match(texte, motif); }); - test("la dérive nouvelle nomme le placement et sa raison ; l'invariant nomme la réservation ; la reprise d'un verrou le dit d'une séance interrompue", () => { + test("la dérive nouvelle nomme le placement et sa raison ; l'invariant nomme la réservation ; la reprise d'un verrou la dit d'une autre séance, d'un poste ou d'un autre", () => { const derive = libelle('DERIVE_NOUVELLE', EXEMPLES_A2[2][1]); assert.ok(derive.includes(libelle('PARTICIPANT_EXCLU', { participant: 3 })), derive); assert.match(derive, /n° 2/); @@ -2649,7 +2807,11 @@ describe("séance observable : les textes des codes qu'elle ajoute (§ 14.6, A2) const viole = libelle('INVARIANT_VIOLE', EXEMPLES_A2[1][1]); assert.ok(viole.includes(libelle('RESERVATION_NON_HONOREE', { participant: 2, table: 2, tour: 1 })), viole); assert.match(viole, /n° 3/); - assert.match(libelle('VERROU_REPRIS', { seance: 's', depuis: null }), /interrompue/); + for (const details of [{ seance: 's', depuis: null }, { seance: null, depuis: null }]) { + const repris = libelle('VERROU_REPRIS', details); + assert.match(repris, /une autre séance/); + assert.doesNotMatch(repris, /interrompue|ce poste/); + } assert.match(libelle('CONFIGURATION_INCOHERENTE', { code: 'CODE_INVENTE', details: {} }), /« CODE_INVENTE »/); }); }); diff --git a/src/geometrie/constantes.js b/src/geometrie/constantes.js index 7875d16..5577361 100644 --- a/src/geometrie/constantes.js +++ b/src/geometrie/constantes.js @@ -83,10 +83,12 @@ export const CONSTANTES = Object.freeze( }, { nom: 'LARGEUR_BLOC_NOMS_CM', - valeur: 180, + valeur: 500, unite: 'cm', mesure: "largeur, dans le dessin, du bloc de noms d'une table, où un nom trop long s'abrège", - raison: 'un nom tient sur une ligne du plan sans recouvrir la table voisine (§ 7.4)', + raison: + "la ligne entière d'une place réservée et titrée (numéro, état, nom, titre) tient sur une ligne " + + 'du plan au corps des listes, la liste faisant foi (§ 7.4, § 7.6)', configuration: 'grande démonstration au zoom par défaut', proprietaire: 'manuel', statut: 'defautDeTravail', diff --git a/src/geometrie/constantes.test.js b/src/geometrie/constantes.test.js index add9c51..243fd1f 100644 --- a/src/geometrie/constantes.test.js +++ b/src/geometrie/constantes.test.js @@ -14,7 +14,7 @@ const CONTRAT = [ ['DUREE_PRESSION_LONGUE_MS', 500, 'ms', 'manuel'], ['SEUIL_SIEGES_LISIBLES', 16, 'places', 'banc'], ['K_LISTES', 0.3, 'px/cm', 'banc'], - ['LARGEUR_BLOC_NOMS_CM', 180, 'cm', 'manuel'], + ['LARGEUR_BLOC_NOMS_CM', 500, 'cm', 'manuel'], ['HAUTEUR_LIGNE', null, 'cm', 'execution'], ]; @@ -42,7 +42,7 @@ describe('constantes : le registre (§ 14.1, § 19.10)', () => { test('constante rend la valeur par le nom ; un nom inconnu lève', () => { assert.equal(constante('SEUIL_GLISSER_PX'), 4); assert.equal(constante('K_LISTES'), 0.3); - assert.equal(constante('LARGEUR_BLOC_NOMS_CM'), 180); + assert.equal(constante('LARGEUR_BLOC_NOMS_CM'), 500); assert.equal(constante('HAUTEUR_LIGNE'), null); assert.throws(() => constante('INCONNUE'), RangeError); assert.throws(() => constante('nom'), RangeError); diff --git a/src/interface/Bandeau.svelte b/src/interface/Bandeau.svelte index 4d87e13..cd9b62f 100644 --- a/src/interface/Bandeau.svelte +++ b/src/interface/Bandeau.svelte @@ -15,7 +15,9 @@ // gestes à appeler ; mode.js choisit les clés du libellé de mode et de // l'annonce. Le refus de « Liste des événements » passe par la primitive // Refus, qui pose son texte et son remède en Commande — écrire ailleurs - // sous un support rompu. + // sous un support rompu. Ce refus se pose sous les commandes, hors du + // flux : le bandeau garde sa hauteur, et l'écran de l'événement, sa place + // fixe comprise, ne bouge pas (§ 8.4). import { getContext } from 'svelte'; import { cleAnnonceFormat, cleDeMode } from './mode.js'; import Refus from './primitives/Refus.svelte'; @@ -93,8 +95,18 @@ /* Le refus garde les couleurs de la page, sur lesquelles sa couleur et celles de sa commande tiennent leur contraste : le fond du bandeau ne - passe pas sous lui. */ + passe pas sous lui. Posé sous les commandes, aligné à leur droite, il + recouvre l'écran au lieu de le pousser. Sa largeur bornée le tient à + droite, loin de la place fixe, qui ouvre l'écran à gauche, et dans la + fenêtre. */ .refus-liste { + position: absolute; + top: 100%; + right: 0; + width: max-content; + max-width: min(32rem, calc(100vw - 2 * var(--espace-4))); + margin-top: var(--espace-1); + border: 1px solid var(--couleur-page-filet); border-radius: var(--rayon-1); background-color: var(--couleur-page-fond); color: var(--couleur-page-texte); @@ -109,6 +121,7 @@ } .commandes { + position: relative; margin-left: auto; } diff --git a/src/interface/accueil/Accueil.navigateur.test.js b/src/interface/accueil/Accueil.navigateur.test.js new file mode 100644 index 0000000..ffafc6e --- /dev/null +++ b/src/interface/accueil/Accueil.navigateur.test.js @@ -0,0 +1,1002 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// La liste des événements et le dossier de travail dans un vrai moteur de +// rendu (§ 8.1, § 8.4, § 8.5, § 8.6, § 8.7, § 8.8, § 15, § 18.6, § 19.4) : +// le premier écran, le répertoire vide, les trois issues, créer, un seul +// événement ouvert, ouvrir sans écrire, les signalements, la récupération, +// l'ouverture en lecture, les réglages illisibles, le dossier de travail, ses +// avis et ses questions, l'explorateur, l'autre emplacement, copier, +// renommer, importer, supprimer, les démonstrations, la racine neutre, et la +// liste qui défile sous le bandeau. Chaque épreuve monte App par monterApp +// (test/navigateur/montage.js), sur le système de fichiers simulé, et lit +// le relevé de ses écritures. +// +// Le test interroge le rendu et le disque, jamais une classe : le texte, la +// visibilité, l'élément qu'atteint le centre d'une boîte, les fichiers du +// disque simulé. Les éléments s'atteignent par leurs attributs data-…. +// Les noms d'épreuve sont inventés. +// +// Le projet navigateur emploie expect, et prend test et describe chez Vitest +// (voir src/interface/App.navigateur.test.js). +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { flushSync, tick } from 'svelte'; +import { creerFormat } from '../../application/format.js'; +import { creerHorlogePilotee } from '../../application/horloge.js'; +import { TABLES_APPLICATION, libelle } from '../../application/libelles.js'; +import { creerTraduction } from '../../application/traduction.js'; +import { CATALOGUE } from '../../demo/catalogue.js'; +import { LIVREES } from '../../demo/livrees.js'; +import { serialiser } from '../../stockage/canonique.js'; +import { creer as creerDansLeDepot, ouvrir as ouvrirDansLeDepot } from '../../stockage/depot.js'; +import { analyser, creerCharge } from '../../stockage/document.js'; +import { lireJournal } from '../../stockage/journal.js'; +import { SUFFIXES } from '../../stockage/noms.js'; +import { libelleDeLInstant } from '../../stockage/operations.js'; +import { VERSION } from '../../version.genere.js'; +import { creerFichiersSimules } from '../../../test/fichiers_simules.js'; +import { monterApp } from '../../../test/navigateur/montage.js'; +import { visible as visibleDansLaFenetre } from '../../../test/navigateur/rendu.js'; +import { TABLES_INTERFACE } from '../libelles.js'; + +const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]); +const format = creerFormat(t); + +const DOCUMENTS = 'C:\\Users\\Exemple\\Documents\\Gestion table tournante Libre'; +const PUBLIE = 'E:\\soirees'; +const DATA = 'E:\\soirees\\data'; +const SOUS_APPDATA = 'C:\\Users\\Exemple\\AppData\\Local\\Programmes\\Tablee'; +const HORODATAGE_DEPOT = '2031-05-17T08:05:00-04:00'; +// Le dossier daté de la corbeille au départ de l'horloge pilotée. +const CORBEILLE_DU_DEPART = 'corbeille/2026-10-06_19-00-00'; +// Un dossier daté que la corbeille porte avant le montage, antérieur au +// départ de l'horloge pilotée. +const ANCIEN_DOSSIER = 'corbeille/2026-01-15_12-00-00'; +const UTF8 = new TextEncoder(); + +// --- Les fichiers d'épreuve ------------------------------------------------------ + +// Le fichier d'état d'une démonstration livrée, renommé, au format courant. +const PETITE = LIVREES.find(({ cle }) => cle === 'petite').texte; +function texteRenomme(nom, { etat = null } = {}) { + const { charge } = analyser(PETITE); + charge.evenement.nom = nom; + if (etat !== null) charge.evenement.etat = etat; + return serialiser(charge, { revision: 1, produitVersion: VERSION.affichee }); +} + +// Le texte d'état d'un événement neuf. +const texteNeuf = ({ id, nom }) => + serialiser(creerCharge({ id, nom, siegesParDefaut: 8, tours: 2 }), { revision: 1, produitVersion: VERSION.affichee }); + +const fichier = (chemin, texte, racine = 'documents') => ({ racine, chemin, texte }); + +// Un disque garni par le dépôt avant le montage — un autre processus, celui +// d'une séance d'avant : chaque événement a son journal d'une entrée et son +// jalon. Rend le disque, l'instance qui l'a garni et ses racines. +async function disqueAvec(evenements, reglages = {}) { + const disque = new Map(); + const fs = creerFichiersSimules({ disque, ...reglages }); + const racines = await fs.racines(); + for (const { racine = 'documents', id, nom, etat = null, date = null } of evenements) { + fs.deposerDossier(racine); + const charge = creerCharge({ id, nom, date, siegesParDefaut: 8, tours: 2 }); + if (etat !== null) charge.evenement.etat = etat; + await creerDansLeDepot(fs, racines[racine], charge, { + libelle: libelle('ENTREE_CREATION', { nom }), + nomJalon: libelle('JALON_CREATION'), + horodatage: HORODATAGE_DEPOT, + produitVersion: VERSION.affichee, + }); + } + return { disque, fs, racines }; +} + +// --- Le montage et ses lectures --------------------------------------------------- + +let montes = []; +let erreursAdmises = false; +let erreursAvant = 0; +async function monter(options) { + const m = await monterApp(options); + montes.push(m); + return m; +} +beforeEach(() => { + erreursAvant = globalThis.__gttErreurs?.length ?? 0; + erreursAdmises = false; +}); +afterEach(() => { + for (const m of montes.reverse()) m.demonter(); + montes = []; + vi.restoreAllMocks(); + window.scrollTo(0, 0); + const recues = globalThis.__gttErreurs?.slice(erreursAvant) ?? []; + if (!erreursAdmises) expect(recues, 'erreurs reçues par le collecteur').toEqual([]); +}); + +const dans = (m, selecteur) => m.racine.querySelector(selecteur); +const tous = (m, selecteur) => [...m.racine.querySelectorAll(selecteur)]; +const accueil = (m) => dans(m, '[data-ecran="accueil"]'); +const lignes = (m) => tous(m, '[data-evenement]'); +const ligneDe = (m, base) => dans(m, `[data-evenement="${CSS.escape(base)}"]`); +const colonne = (ligne, nom) => ligne.querySelector(`[data-colonne="${nom}"]`)?.textContent; +const ecritures = (m) => m.trace.length; +const sondes = (m) => m.trace.filter(([primitive]) => primitive === 'sonder').length; + +// Visible une fois ramené dans la fenêtre : la liste défile sous le bandeau. +function visible(element) { + element.scrollIntoView({ block: 'center' }); + return visibleDansLaFenetre(element); +} + +// Le centre d'un élément, ramené dans la fenêtre, atteint l'élément. +function atteint(element) { + element.scrollIntoView({ block: 'center' }); + const boite = element.getBoundingClientRect(); + const touche = document.elementFromPoint(boite.left + boite.width / 2, boite.top + boite.height / 2); + return element === touche || element.contains(touche); +} + +// Saisit un texte dans un champ, comme une frappe : la valeur, puis input. +function saisir(entree, texte) { + entree.value = texte; + entree.dispatchEvent(new Event('input', { bubbles: true })); + flushSync(); +} + +async function cliquer(element) { + expect(element, 'élément à cliquer').not.toBeNull(); + element.click(); + await tick(); +} + +// L'écran de l'événement ouvert, une fois rendu. +async function attendreEvenement(m, quoi = () => true) { + await vi.waitFor(() => { + const evenement = m.seance.etat().evenement; + expect(evenement).not.toBeNull(); + expect(quoi(evenement)).toBe(true); + expect(dans(m, '[data-ecran="evenement"]')).not.toBeNull(); + }); + return m.seance.etat().evenement; +} + +// « Liste des événements » du bandeau, puis la liste relue de n lignes. +async function revenirALaListe(m, n = null) { + await cliquer(dans(m, '[data-bandeau] [data-commande="liste"]')); + await vi.waitFor(() => { + expect(m.seance.etat().evenement).toBeNull(); + expect(accueil(m)).not.toBeNull(); + if (n !== null) expect(lignes(m).length).toBe(n); + }); +} + +// Ouvre la ligne base par sa commande. +async function ouvrirLigne(m, base) { + await cliquer(ligneDe(m, base).querySelector('[data-commande="ouvrir"]')); +} + +// Crée par le formulaire de l'accueil. +async function creerParLeFormulaire(m, nom, date = '') { + saisir(dans(m, '[data-champ="nom"]'), nom); + saisir(dans(m, '[data-champ="date"]'), date); + await cliquer(dans(m, '[data-commande="creer"]')); +} + +// Aucune fenêtre ni boîte de dialogue dans le document. +function aucuneFenetre() { + expect(document.querySelectorAll('[role=dialog], [role=alertdialog], dialog[open]').length).toBe(0); +} + +// Les nœuds de texte non blancs d'un élément, dans l'ordre du document. +function noeudsDeTexte(element) { + const textes = []; + const parcours = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); + while (parcours.nextNode()) if (parcours.currentNode.textContent.trim() !== '') textes.push(parcours.currentNode.textContent); + return textes; +} + +// Les textes de chaque fichier d'une racine du disque, par chemin relatif. +async function photographier(m, racine, dossier = '') { + const racines = await m.fs.racines(); + const photo = {}; + for (const { nom, type } of await m.fs.lister(racines[racine], dossier)) { + const chemin = dossier === '' ? nom : `${dossier}/${nom}`; + if (type === 'fichier') photo[chemin] = m.fs.contenu(racine, chemin); + } + return photo; +} + +// --- Le premier écran (§ 8.1, § 18.6) --------------------------------------------- + +describe('le premier écran (§ 8.1, § 18.6)', () => { + test('le logiciel démarre sur la liste, sans événement ; « version » puis la version ; deux événements, six informations égales au fichier', async () => { + const { disque, fs, racines } = await disqueAvec([ + { id: 'evt-printemps', nom: 'Gala du printemps', date: '2031-05-17' }, + ]); + // Renommé à la main : ses deux fichiers sous une autre base. + for (const suffixe of [SUFFIXES.etat, SUFFIXES.journal]) { + fs.deposer('documents', `soiree-a${suffixe}`, fs.contenu('documents', `Gala du printemps${suffixe}`)); + await fs.supprimer(racines.documents, `Gala du printemps${suffixe}`); + } + // Sans journal. + const club = texteRenomme('Club des Merles'); + fs.deposer('documents', `club${SUFFIXES.etat}`, club); + const m = await monter({ systeme: { disque } }); + + expect(m.seance.etat().evenement).toBeNull(); + expect(accueil(m)).not.toBeNull(); + const version = dans(m, '[data-version]'); + expect(version.textContent.replace(/\s+/g, ' ').trim()).toBe(`${t('coquille.version.libelle')} ${VERSION.affichee}`); + expect(visible(version)).toBe(true); + + expect(lignes(m).length).toBe(2); + const gala = ligneDe(m, 'soiree-a'); + const lue = analyser(m.fs.contenu('documents', `soiree-a${SUFFIXES.etat}`)); + expect(colonne(gala, 'nom')).toBe(lue.charge.evenement.nom); + expect(colonne(gala, 'nom')).toBe('Gala du printemps'); + expect(colonne(gala, 'date')).toBe(format.date('2031-05-17')); + expect(colonne(gala, 'etat')).toBe(t('accueil.etat.brouillon')); + expect(colonne(gala, 'participants')).toBe(format.nombre(lue.entete.comptes.participants)); + expect(colonne(gala, 'modification')).toBe(format.horodatage(HORODATAGE_DEPOT)); + expect(colonne(gala, 'chemin')).toBe(`${DOCUMENTS}\\soiree-a${SUFFIXES.etat}`); + + const ligneClub = ligneDe(m, 'club'); + const lu = analyser(club); + expect(colonne(ligneClub, 'nom')).toBe('Club des Merles'); + expect(colonne(ligneClub, 'date')).toBe(lu.charge.evenement.date === null ? '\u{2014}' : format.date(lu.charge.evenement.date)); + expect(colonne(ligneClub, 'participants')).toBe(format.nombre(lu.entete.comptes.participants)); + expect(Number(lu.entete.comptes.participants)).toBeGreaterThan(0); + expect(colonne(ligneClub, 'modification')).toBe('\u{2014}'); + expect(colonne(ligneClub, 'chemin')).toBe(`${DOCUMENTS}\\club${SUFFIXES.etat}`); + for (const ligne of lignes(m)) expect(visible(ligne)).toBe(true); + }); + + test('répertoire vide : « version » et la version, la création, une entrée par démonstration du catalogue, atteinte ; aucun « aucun événement »', async () => { + const m = await monter(); + expect(lignes(m).length).toBe(0); + expect(dans(m, '[data-version]').textContent).toContain(VERSION.affichee); + const creer = dans(m, '[data-commande="creer"]'); + expect(visible(creer)).toBe(true); + expect(atteint(creer)).toBe(true); + const entrees = tous(m, '[data-demonstration]'); + expect(CATALOGUE.length).toBeGreaterThan(0); + expect(entrees.length).toBe(CATALOGUE.length); + entrees.forEach((entree, rang) => { + expect(entree.textContent.trim()).toBe(CATALOGUE[rang].nom); + expect(atteint(entree)).toBe(true); + }); + expect(accueil(m).textContent.toLowerCase()).not.toContain('aucun événement'); + }); + + test('répertoire vide, construction de documentation : la marque tient lieu de version au premier écran', async () => { + const m = await monter({ provenance: 'documentation' }); + const version = dans(m, '[data-version]').textContent; + expect(version).toContain(t('coquille.version.libelle')); + expect(version).toContain(t('coquille.version.documentation')); + expect(version).not.toContain(VERSION.affichee); + }); + + test('contre-épreuve : un catalogue d’une entrée de plus en montre une de plus', async () => { + const demonstrations = LIVREES.map(({ cle, texte }) => ({ cle, nom: CATALOGUE.find((e) => e.cle === cle).nom, texte })); + const plus = [...demonstrations, { cle: 'epreuve', nom: 'Démonstration des Pervenches', texte: PETITE }]; + const m = await monter({ demonstrations: plus }); + const entrees = tous(m, '[data-demonstration]'); + expect(entrees.length).toBe(CATALOGUE.length + 1); + expect(entrees.at(-1).textContent.trim()).toBe('Démonstration des Pervenches'); + }); + + test('trois issues toujours offertes : devant une liste non vide, la création et les démonstrations restent atteignables', async () => { + const m = await monter({ fichiers: [fichier(`bal${SUFFIXES.etat}`, texteRenomme('Bal des Grisailles'))] }); + expect(lignes(m).length).toBe(1); + expect(atteint(dans(m, '[data-commande="creer"]'))).toBe(true); + const entrees = tous(m, '[data-demonstration]'); + expect(entrees.length).toBe(CATALOGUE.length); + for (const entree of entrees) expect(atteint(entree)).toBe(true); + expect(atteint(ligneDe(m, 'bal').querySelector('[data-commande="ouvrir"]'))).toBe(true); + }); +}); + +// --- Créer, ouvrir (§ 2.1, § 8.1, § 8.2, § 8.4, § 8.8) ------------------------------------ + +describe('créer et ouvrir', () => { + test('créer « Soirée d’essai » : le journal avant l’état, une entrée et le jalon « Création » ; ouvert en lecture ; la liste compte une ligne de plus', async () => { + const m = await monter({ fichiers: [fichier(`bal${SUFFIXES.etat}`, texteRenomme('Bal des Grisailles'))] }); + const avant = ecritures(m); + await creerParLeFormulaire(m, 'Soirée d\u{2019}essai'); + const ouvert = await attendreEvenement(m); + expect(ouvert.nom).toBe('Soirée d\u{2019}essai'); + expect(m.seance.etat().mode).toBe('lecture'); + const ecrits = m.trace.slice(avant).map(([, , chemin]) => chemin); + const journal = ecrits.indexOf(`${ouvert.base}${SUFFIXES.journal}`); + const etat = ecrits.indexOf(`${ouvert.base}${SUFFIXES.etat}`); + expect(journal).toBeGreaterThanOrEqual(0); + expect(etat).toBeGreaterThan(journal); + const lu = lireJournal(m.fs.contenu('documents', `${ouvert.base}${SUFFIXES.journal}`)); + expect(lu.entrees.length).toBe(1); + expect(lu.jalons.map(({ nom }) => nom)).toEqual([libelle('JALON_CREATION')]); + await revenirALaListe(m, 2); + expect(colonne(ligneDe(m, ouvert.base), 'nom')).toBe('Soirée d\u{2019}essai'); + }); + + test('un nom vide : un message contre le champ, rien au relevé, aucun « Sans titre »', async () => { + const m = await monter(); + const avant = ecritures(m); + await creerParLeFormulaire(m, ' '); + const champ = dans(m, '[data-champ="nom"]'); + const message = await vi.waitFor(() => { + const element = dans(m, '[data-refus="NOM_REQUIS"]'); + expect(element).not.toBeNull(); + return element; + }); + expect(visible(message)).toBe(true); + expect(message.textContent).toContain(t('NOM_REQUIS')); + expect(champ.getAttribute('aria-invalid')).toBe('true'); + expect(message.id).not.toBe(''); + expect(champ.getAttribute('aria-describedby')).toBe(message.id); + expect(ecritures(m)).toBe(avant); + expect(m.seance.etat().evenement).toBeNull(); + expect([...m.disque.keys()].some((cle) => cle.toLowerCase().includes('sans titre'))).toBe(false); + aucuneFenetre(); + }); + + test('une date hors du calendrier : le message contre le champ de la date, rien au relevé', async () => { + const m = await monter(); + const avant = ecritures(m); + await creerParLeFormulaire(m, 'Bal des Grisailles', '2031-02-30'); + const message = await vi.waitFor(() => { + const element = dans(m, '[data-refus="DATE_INVALIDE"]'); + expect(element).not.toBeNull(); + return element; + }); + expect(message.textContent).toContain(t('DATE_INVALIDE', { date: '2031-02-30' })); + expect(dans(m, '[data-champ="date"]').getAttribute('aria-describedby')).toBe(message.id); + expect(dans(m, '[data-champ="nom"]').getAttribute('aria-invalid')).toBeNull(); + expect(ecritures(m)).toBe(avant); + }); + + test('un seul événement ouvert : A en écriture, « Liste des événements » le ferme sans question ni verrou ; B s’ouvre en lecture', async () => { + const confirmer = vi.spyOn(window, 'confirm'); + const m = await monter({ + fichiers: [ + fichier(`bal${SUFFIXES.etat}`, texteRenomme('Bal des Grisailles')), + fichier(`club${SUFFIXES.etat}`, texteNeuf({ id: 'evt-merles', nom: 'Club des Merles' })), + ], + }); + await ouvrirLigne(m, 'bal'); + await attendreEvenement(m, ({ base }) => base === 'bal'); + await m.seance.passerEnEcriture(); + expect(m.fs.contenu('documents', `bal${SUFFIXES.verrou}`)).not.toBeNull(); + await revenirALaListe(m, 2); + aucuneFenetre(); + expect(m.fs.contenu('documents', `bal${SUFFIXES.verrou}`)).toBeNull(); + await ouvrirLigne(m, 'club'); + await attendreEvenement(m, ({ base }) => base === 'club'); + expect(m.seance.etat().mode).toBe('lecture'); + expect(confirmer).not.toHaveBeenCalled(); + }); + + test('ouvrir et lister n’écrivent rien : le relevé n’a rien gagné depuis le montage', async () => { + const m = await monter({ fichiers: [fichier(`bal${SUFFIXES.etat}`, texteRenomme('Bal des Grisailles'))] }); + const avant = ecritures(m); + expect(lignes(m).length).toBe(1); + await ouvrirLigne(m, 'bal'); + await attendreEvenement(m); + expect(ecritures(m)).toBe(avant); + await revenirALaListe(m, 1); + expect(ecritures(m)).toBe(avant); + }); + + test('tout s’ouvre en lecture : l’événement créé, celui rouvert après l’écriture, une démonstration, une copie ; le fichier ne porte aucun mode', async () => { + const m = await monter(); + await creerParLeFormulaire(m, 'Bal des Grisailles'); + const cree = await attendreEvenement(m); + expect(m.seance.etat().mode).toBe('lecture'); + await m.seance.passerEnEcriture(); + expect(m.seance.etat().mode).toBe('ecriture'); + await revenirALaListe(m, 1); + await ouvrirLigne(m, cree.base); + await attendreEvenement(m); + expect(m.seance.etat().mode).toBe('lecture'); + await revenirALaListe(m, 1); + await cliquer(dans(m, '[data-demonstration="petite"]')); + const demonstration = await attendreEvenement(m, ({ base }) => base !== cree.base); + expect(m.seance.etat().mode).toBe('lecture'); + await revenirALaListe(m, 2); + const ligne = ligneDe(m, cree.base); + await cliquer(ligne.querySelector('[data-commande="copier"]')); + saisir(ligne.querySelector('[data-champ="nomCopie"]'), 'Bal des Grisailles, variante'); + await cliquer(ligne.querySelector('[data-commande="validerCopie"]')); + const copie = await attendreEvenement(m, ({ nom }) => nom === 'Bal des Grisailles, variante'); + expect(m.seance.etat().mode).toBe('lecture'); + for (const { base } of [cree, demonstration, copie]) { + const texte = m.fs.contenu('documents', `${base}${SUFFIXES.etat}`); + expect(texte, base).not.toBeNull(); + expect(texte).not.toMatch(/"mode"|lecture|ecriture/); + } + }); +}); + +// --- Les signalements et la récupération (§ 8.1, § 8.8) --------------------------------- + +describe('signalements et récupération (§ 8.1, § 8.8)', () => { + test('un état vide, un journal seul, deux états de même identifiant ; réglages, corbeille et PDF : quatre lignes signalées, de trois sortes', async () => { + const { disque, fs, racines } = await disqueAvec([{ id: 'evt-hiboux', nom: 'Soirée des Hiboux' }]); + await fs.supprimer(racines.documents, `Soirée des Hiboux${SUFFIXES.etat}`); + const merles = texteNeuf({ id: 'evt-merles', nom: 'Club des Merles' }); + const m = await monter({ + systeme: { disque }, + fichiers: [ + fichier(`Soirée vide${SUFFIXES.etat}`, ''), + fichier(`Merles A${SUFFIXES.etat}`, merles), + fichier(`Merles B${SUFFIXES.etat}`, merles), + fichier('reglages_locaux.json', '{}'), + fichier(`corbeille/2031-01-01_00-00-00/Ancienne${SUFFIXES.etat}`, merles), + fichier('planche.pdf', '%PDF'), + ], + }); + expect(lignes(m).map((l) => l.dataset.evenement).sort()).toEqual([ + 'Merles A', + 'Merles B', + 'Soirée des Hiboux', + 'Soirée vide', + ]); + const signalements = (base) => [...ligneDe(m, base).querySelectorAll('[data-signalement]')]; + for (const ligne of lignes(m)) expect(signalements(ligne.dataset.evenement).length).toBe(1); + const vide = signalements('Soirée vide')[0]; + expect(vide.dataset.signalement).toBe('ETAT_ILLISIBLE'); + expect(vide.textContent).toContain(t('ETAT_ILLISIBLE', { base: 'Soirée vide', raison: 'VIDE', chemin: null, secours: { precedent: null, journal: null } })); + const hiboux = signalements('Soirée des Hiboux')[0]; + expect(hiboux.dataset.signalement).toBe('ETAT_ILLISIBLE'); + expect(hiboux.textContent).toContain(t('ETAT_ILLISIBLE', { base: 'Soirée des Hiboux', raison: 'ABSENT', chemin: null, secours: { precedent: null, journal: 1 } })); + const a = signalements('Merles A')[0]; + const b = signalements('Merles B')[0]; + expect(a.dataset.signalement).toBe('accueil.signalement.doublon'); + // Chaque doublon nomme l'autre base dans son propre nœud de texte, à part + // de la phrase traduite. + for (const [element, autre] of [ + [a, 'Merles B'], + [b, 'Merles A'], + ]) { + expect(element.textContent).toContain(t('accueil.signalement.doublon', { autres: 1 })); + expect(element.querySelector(`[data-doublon="${CSS.escape(autre)}"]`)?.textContent).toBe(autre); + const noeuds = noeudsDeTexte(element).filter((texte) => texte.includes('Merles')); + expect(noeuds).toEqual([autre]); + } + const sortes = new Set([vide.textContent, hiboux.textContent, a.dataset.signalement]); + expect(sortes.size).toBe(3); + for (const element of [vide, hiboux, a, b]) expect(visible(element)).toBe(true); + }); + + test('récupération : un état tronqué, .precedent et journal lisibles → un panneau nomme le fichier et la raison, offre le .precedent puis le journal, sans écrire ; .precedent ouvre en lecture ; le premier geste réécrit l’état', async () => { + const { disque, fs } = await disqueAvec([{ id: 'evt-lucioles', nom: 'Nuit des Lucioles' }]); + const base = 'Nuit des Lucioles'; + const sain = fs.contenu('documents', `${base}${SUFFIXES.etat}`); + fs.deposer('documents', `${base}${SUFFIXES.precedent}`, sain); + fs.deposer('documents', `${base}${SUFFIXES.etat}`, sain.slice(0, Math.floor(sain.length / 2))); + const m = await monter({ systeme: { disque } }); + const avant = ecritures(m); + await ouvrirLigne(m, base); + const panneau = await vi.waitFor(() => { + const element = dans(m, '[data-ecran="recuperation"]'); + expect(element).not.toBeNull(); + return element; + }); + expect(visible(panneau)).toBe(true); + expect(panneau.textContent).toContain(`${DOCUMENTS}\\${base}${SUFFIXES.etat}`); + const raison = panneau.querySelector('[data-refus="ETAT_ILLISIBLE"]'); + expect(raison).not.toBeNull(); + expect(raison.textContent).toContain(base); + const offerts = [...panneau.querySelectorAll('[data-secours]')].map((e) => e.dataset.secours); + expect(offerts).toEqual(['precedent', 'journal']); + expect(panneau.querySelector('[data-secours="precedent"]').textContent).toContain(t('communs.commande.ouvrirPrecedent')); + expect(ecritures(m)).toBe(avant); + expect(m.seance.etat().evenement).toBeNull(); + + await cliquer(panneau.querySelector('[data-secours="precedent"] [data-commande="ouvrirPrecedent"]')); + await attendreEvenement(m, (evenement) => evenement.base === base); + expect(m.seance.etat().mode).toBe('lecture'); + expect(ecritures(m)).toBe(avant); + + await m.seance.passerEnEcriture(); + await m.seance.executer('ajouterParticipant', { champs: { nom: 'Pervenche' } }); + const reecrit = analyser(m.fs.contenu('documents', `${base}${SUFFIXES.etat}`)); + expect(reecrit.charge.participants.some(({ nom }) => nom === 'Pervenche')).toBe(true); + }); + + test('sans aucun secours lisible : un message, aucune commande d’ouverture, et aucun événement n’est créé', async () => { + const m = await monter({ fichiers: [fichier(`Soirée vide${SUFFIXES.etat}`, '')] }); + const cles = [...m.disque.keys()].sort(); + const avant = ecritures(m); + await ouvrirLigne(m, 'Soirée vide'); + const panneau = await vi.waitFor(() => { + const element = dans(m, '[data-ecran="recuperation"]'); + expect(element).not.toBeNull(); + return element; + }); + expect(panneau.textContent).toContain(t('accueil.recuperation.aucunSecours')); + expect(panneau.querySelectorAll('[data-secours]').length).toBe(0); + expect(ecritures(m)).toBe(avant); + expect([...m.disque.keys()].sort()).toEqual(cles); + expect(m.seance.etat().evenement).toBeNull(); + await cliquer(panneau.querySelector('[data-commande="retour"]')); + await vi.waitFor(() => expect(accueil(m)).not.toBeNull()); + expect(lignes(m).length).toBe(1); + }); +}); + +// --- Le dossier de travail (§ 8.5, § 8.6) ------------------------------------------------- + +describe('le dossier de travail (§ 8.5, § 8.6)', () => { + test('réglages locaux illisibles : l’avis REGLAGES_ILLISIBLES visible, la liste utilisable', async () => { + const m = await monter({ + fichiers: [fichier('reglages_locaux.json', 'pas du JSON'), fichier(`bal${SUFFIXES.etat}`, texteRenomme('Bal des Grisailles'))], + }); + const avis = dans(m, '[data-avertissement="REGLAGES_ILLISIBLES"]'); + expect(avis).not.toBeNull(); + expect(visible(avis)).toBe(true); + const { avis: attendus } = await m.facade.demarrer(); + const reglages = attendus.find(({ code }) => code === 'REGLAGES_ILLISIBLES'); + expect(avis.textContent).toBe(t(reglages.code, reglages.details)); + aucuneFenetre(); + expect(atteint(ligneDe(m, 'bal').querySelector('[data-commande="ouvrir"]'))).toBe(true); + await ouvrirLigne(m, 'bal'); + await attendreEvenement(m); + }); + + test('publié, sonde réussie : la liste affiche le dossier data en entier ; non publié : celui des Documents', async () => { + const portable = await monter({ systeme: { executable: PUBLIE } }); + expect(dans(portable, '[data-dossier-travail]').textContent).toBe(DATA); + expect(accueil(portable).textContent).not.toContain(DOCUMENTS); + const documents = await monter(); + expect(dans(documents, '[data-dossier-travail]').textContent).toBe(DOCUMENTS); + }); + + test('la règle ne s’applique qu’une fois : ouvrir, fermer, revenir à la liste n’ajoutent aucune sonde', async () => { + const m = await monter({ fichiers: [fichier(`bal${SUFFIXES.etat}`, texteRenomme('Bal des Grisailles'))] }); + const avant = sondes(m); + expect(avant).toBe(1); + await ouvrirLigne(m, 'bal'); + await attendreEvenement(m); + await revenirALaListe(m, 1); + await ouvrirLigne(m, 'bal'); + await attendreEvenement(m); + await revenirALaListe(m, 1); + expect(sondes(m)).toBe(avant); + }); + + const REPLIS = [ + ['NON_PUBLIE', {}, {}], + ['DONNEES_APPLICATIVES', { executable: SOUS_APPDATA }, {}], + ['SONDE_ECHOUEE', { executable: PUBLIE, sonde: { portable: 'EACCES' } }, { cause: 'EACCES' }], + ]; + for (const [code, systeme, details] of REPLIS) { + test(`repli en Documents, ${code} : un avis non modal, une fois, la liste utilisable dessous`, async () => { + const m = await monter({ systeme, fichiers: [fichier(`bal${SUFFIXES.etat}`, texteRenomme('Bal des Grisailles'))] }); + const avis = tous(m, `[data-avertissement="${code}"]`); + expect(avis.length).toBe(1); + expect(visible(avis[0])).toBe(true); + expect(avis[0].textContent).toBe(t(code, details)); + aucuneFenetre(); + expect(atteint(ligneDe(m, 'bal').querySelector('[data-commande="ouvrir"]'))).toBe(true); + expect(atteint(dans(m, '[data-commande="creer"]'))).toBe(true); + await ouvrirLigne(m, 'bal'); + await attendreEvenement(m); + await revenirALaListe(m, 1); + expect(tous(m, `[data-avertissement="${code}"]`).length).toBe(0); + }); + } + + test('plateforme web : l’avis PLATEFORME_WEB ; support amovible : l’avis SUPPORT_AMOVIBLE', async () => { + const web = await monter({ systeme: { nature: 'web' } }); + expect(visible(dans(web, '[data-avertissement="PLATEFORME_WEB"]'))).toBe(true); + const amovible = await monter({ systeme: { support: { documents: 'amovible' } } }); + const avis = dans(amovible, '[data-avertissement="SUPPORT_AMOVIBLE"]'); + expect(visible(avis)).toBe(true); + expect(avis.textContent).toBe(t('SUPPORT_AMOVIBLE', {})); + }); + + test('explorateur : la commande appelle ouvrirDansExplorateur avec la racine ; sous web, absente, et une phrase dit pourquoi', async () => { + const m = await monter(); + const espion = vi.fn(async () => undefined); + m.fs.ouvrirDansExplorateur = espion; + await cliquer(dans(m, '[data-commande="explorateur"]')); + await vi.waitFor(() => expect(espion).toHaveBeenCalledTimes(1)); + expect(espion.mock.calls[0][0]).toEqual(m.seance.etat().racine); + + const web = await monter({ systeme: { nature: 'web' } }); + expect(dans(web, '[data-commande="explorateur"]')).toBeNull(); + const phrase = dans(web, '[data-avertissement="EXPLORATEUR_ABSENT"]'); + expect(phrase?.textContent).toBe(t('EXPLORATEUR_ABSENT')); + expect(visible(phrase)).toBe(true); + expect(web.erreurs()).toEqual([]); + }); + + test('data/ porte deux événements et refuse l’écriture : la question nomme le dossier et la cause, dit « 2 », offre ses réponses ; aucune liste des Documents', async () => { + const m = await monter({ + systeme: { executable: PUBLIE, sonde: { portable: 'EACCES' } }, + fichiers: [ + fichier(`bal${SUFFIXES.etat}`, texteRenomme('Bal des Grisailles'), 'portable'), + fichier(`club${SUFFIXES.etat}`, texteNeuf({ id: 'evt-merles', nom: 'Club des Merles' }), 'portable'), + ], + }); + const question = dans(m, '[data-question="PORTABLE_NON_INSCRIPTIBLE"]'); + expect(question).not.toBeNull(); + expect(visible(question)).toBe(true); + expect(question.textContent).toContain(DATA); + expect(question.textContent).toContain('EACCES'); + expect(question.textContent).toContain('2'); + expect(visible(question.querySelector('[data-commande="reessayer"]'))).toBe(true); + expect(visible(question.querySelector('[data-commande="documents"]'))).toBe(true); + expect(lignes(m).length).toBe(0); + expect(accueil(m).textContent).not.toContain(DOCUMENTS); + aucuneFenetre(); + // Répondre « Documents » : les événements y sont copiés, la liste paraît. + await cliquer(question.querySelector('[data-commande="documents"]')); + await vi.waitFor(() => expect(lignes(m).length).toBe(2)); + expect(dans(m, '[data-question]')).toBeNull(); + expect(dans(m, '[data-dossier-travail]').textContent).toBe(DOCUMENTS); + }); + + test('non publié, Documents non inscriptibles : la question avant toute liste ; « Réessayer », la panne tenant, la repose', async () => { + const m = await monter({ + systeme: { sonde: { documents: 'EROFS' } }, + fichiers: [fichier(`bal${SUFFIXES.etat}`, texteRenomme('Bal des Grisailles'))], + }); + const question = dans(m, '[data-question="DOCUMENTS_NON_INSCRIPTIBLE"]'); + expect(visible(question)).toBe(true); + expect(question.textContent).toContain(DOCUMENTS); + expect(question.textContent).toContain('EROFS'); + expect(lignes(m).length).toBe(0); + const avant = sondes(m); + await cliquer(question.querySelector('[data-commande="reessayer"]')); + await vi.waitFor(() => expect(sondes(m)).toBe(avant + 1)); + // La question reposée offre de nouveau ses réponses. + await vi.waitFor(() => + expect(dans(m, '[data-question="DOCUMENTS_NON_INSCRIPTIBLE"] [data-commande="continuer"]')).not.toBeNull(), + ); + expect(lignes(m).length).toBe(0); + // « Continuer » : la liste s'ouvre telle qu'elle se lit. + await cliquer(dans(m, '[data-question] [data-commande="continuer"]')); + await vi.waitFor(() => expect(lignes(m).length).toBe(1)); + expect(dans(m, '[data-question]')).toBeNull(); + }); + + test('l’autre emplacement : la proposition en tête, son chemin, son compte, la phrase sur les originaux ; importer copie, originaux intacts', async () => { + const reglages = { executable: SOUS_APPDATA }; + const { disque } = await disqueAvec( + [ + { racine: 'portable', id: 'evt-lucioles', nom: 'Nuit des Lucioles' }, + { racine: 'portable', id: 'evt-cerfs', nom: 'Soirée des Cerfs' }, + ], + reglages, + ); + const m = await monter({ systeme: { disque, ...reglages } }); + const proposition = dans(m, '[data-autre]'); + expect(visible(proposition)).toBe(true); + const cheminAutre = `${SOUS_APPDATA}\\data`; + expect(proposition.textContent).toContain(t('accueil.autre.proposition', { evenements: 2 })); + // Le chemin est une donnée : son propre nœud de texte, à part de la phrase. + expect(dans(m, '[data-autre-chemin]')?.textContent).toBe(cheminAutre); + expect(noeudsDeTexte(proposition).filter((texte) => texte.includes('\\'))).toEqual([cheminAutre]); + expect(proposition.textContent).toContain(t('accueil.autre.originaux')); + // En tête : avant la création, les démonstrations et toute ligne. + for (const suivant of [dans(m, '[data-commande="creer"]'), dans(m, '[data-demonstration]')]) { + expect(proposition.compareDocumentPosition(suivant) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + } + const originaux = await photographier(m, 'portable'); + expect(Object.keys(originaux).length).toBe(4); + await cliquer(proposition.querySelector('[data-commande="importerAutre"]')); + await vi.waitFor(() => expect(lignes(m).length).toBe(2)); + expect(await photographier(m, 'portable')).toEqual(originaux); + expect(lignes(m).map((l) => colonne(l, 'nom')).sort()).toEqual(['Nuit des Lucioles', 'Soirée des Cerfs']); + // Des paires copiées : chaque état importé garde son journal, octet pour + // octet, et sa dernière modification est celle du journal d'origine. + const journaux = (photo) => + Object.entries(photo) + .filter(([chemin]) => chemin.endsWith(SUFFIXES.journal)) + .map(([, texte]) => texte) + .sort(); + expect(journaux(originaux).length).toBe(2); + expect(journaux(await photographier(m, 'documents'))).toEqual(journaux(originaux)); + for (const ligne of lignes(m)) expect(colonne(ligne, 'modification')).toBe(format.horodatage(HORODATAGE_DEPOT)); + }); +}); + +// --- Copier, renommer, importer, supprimer (§ 8.7) ---------------------------------------- + +describe('les opérations de fichiers (§ 8.7)', () => { + test('copier un plan bloqué : un champ de nom, rien avant validation ; une ligne de plus, une entrée au libellé exact, un état non bloqué, l’original intact', async () => { + const { disque } = await disqueAvec([{ id: 'evt-grisailles', nom: 'Bal des Grisailles', etat: 'bloque' }]); + const m = await monter({ systeme: { disque } }); + const original = await photographier(m, 'documents'); + const avant = ecritures(m); + const ligne = ligneDe(m, 'Bal des Grisailles'); + expect(colonne(ligne, 'etat')).toBe(t('accueil.etat.bloque')); + await cliquer(ligne.querySelector('[data-commande="copier"]')); + const champ = ligne.querySelector('[data-champ="nomCopie"]'); + expect(visible(champ)).toBe(true); + saisir(champ, 'Bal des Grisailles, variante'); + expect(ecritures(m)).toBe(avant); + await cliquer(ligne.querySelector('[data-commande="validerCopie"]')); + const copie = await attendreEvenement(m, ({ nom }) => nom === 'Bal des Grisailles, variante'); + await revenirALaListe(m, 2); + const racines = await m.fs.racines(); + const source = await ouvrirDansLeDepot(m.fs, racines.documents, 'Bal des Grisailles'); + const attendu = libelle('ENTREE_COPIE', { + source: 'Bal des Grisailles', + instant: libelleDeLInstant(source, libelle('ENTREE_REPRISE')), + }); + const journal = lireJournal(m.fs.contenu('documents', `${copie.base}${SUFFIXES.journal}`)); + expect(journal.entrees.map(({ libelle: texte }) => texte)).toEqual([attendu]); + expect(analyser(m.fs.contenu('documents', `${copie.base}${SUFFIXES.etat}`)).charge.evenement.etat).not.toBe('bloque'); + expect(colonne(ligneDe(m, copie.base), 'etat')).not.toBe(t('accueil.etat.bloque')); + const apres = await photographier(m, 'documents'); + for (const [chemin, texte] of Object.entries(original)) expect(apres[chemin]).toBe(texte); + }); + + test('copier sous un nom vide : refusé contre le champ, aucune copie, aucun « (copie) »', async () => { + const m = await monter({ fichiers: [fichier(`bal${SUFFIXES.etat}`, texteRenomme('Bal des Grisailles'))] }); + const avant = ecritures(m); + const ligne = ligneDe(m, 'bal'); + await cliquer(ligne.querySelector('[data-commande="copier"]')); + saisir(ligne.querySelector('[data-champ="nomCopie"]'), ' '); + await cliquer(ligne.querySelector('[data-commande="validerCopie"]')); + await vi.waitFor(() => expect(ligne.querySelector('[data-refus="NOM_REQUIS"]')).not.toBeNull()); + expect(ligne.querySelector('[data-champ="nomCopie"]').getAttribute('aria-invalid')).toBe('true'); + expect(ecritures(m)).toBe(avant); + expect([...m.disque.keys()].some((cle) => cle.includes('(COPIE)'))).toBe(false); + }); + + test('renommer : le nouveau nom et le nouveau chemin dans la liste', async () => { + const { disque } = await disqueAvec([{ id: 'evt-pervenches', nom: 'Gala des Pervenches' }]); + const m = await monter({ systeme: { disque } }); + const ligne = ligneDe(m, 'Gala des Pervenches'); + await cliquer(ligne.querySelector('[data-commande="renommer"]')); + const champ = ligne.querySelector('[data-champ="nouveauNom"]'); + expect(champ.value).toBe('Gala des Pervenches'); + saisir(champ, 'Gala des Ombrelles'); + await cliquer(ligne.querySelector('[data-commande="validerRenommage"]')); + await vi.waitFor(() => expect(ligneDe(m, 'Gala des Ombrelles')).not.toBeNull()); + expect(lignes(m).length).toBe(1); + const renomme = ligneDe(m, 'Gala des Ombrelles'); + expect(colonne(renomme, 'nom')).toBe('Gala des Ombrelles'); + expect(colonne(renomme, 'chemin')).toBe(`${DOCUMENTS}\\Gala des Ombrelles${SUFFIXES.etat}`); + expect(m.fs.contenu('documents', `Gala des Pervenches${SUFFIXES.verrou}`)).toBeNull(); + }); + + // Coupe le processus du montage juste après l'écriture réussie de chemin. + function couperApres(m, chemin) { + for (const primitive of ['ecrireAtomique', 'ajouterLigne']) { + const originale = m.fs[primitive]; + m.fs[primitive] = async (racine, cible, ...reste) => { + const rendu = await Reflect.apply(originale, m.fs, [racine, cible, ...reste]); + if (cible === chemin) m.fs.pannes.couperApres(0); + return rendu; + }; + } + } + + async function renommerCoupe(chemin) { + erreursAdmises = true; + const { disque } = await disqueAvec([{ id: 'evt-pervenches', nom: 'Gala des Pervenches' }]); + const m = await monter({ systeme: { disque } }); + couperApres(m, chemin); + const ligne = ligneDe(m, 'Gala des Pervenches'); + await cliquer(ligne.querySelector('[data-commande="renommer"]')); + saisir(ligne.querySelector('[data-champ="nouveauNom"]'), 'Gala des Ombrelles'); + await cliquer(ligne.querySelector('[data-commande="validerRenommage"]')); + await vi.waitFor(() => expect(m.erreurs().length).toBeGreaterThan(0)); + // Réouverture : un second processus sur le même disque. + return monter({ systeme: { disque } }); + } + + test('renommer coupé après le nouvel état : à la réouverture, deux lignes signalées doublons l’une de l’autre', async () => { + const rouvert = await renommerCoupe(`Gala des Ombrelles${SUFFIXES.etat}`); + expect(lignes(rouvert).map((l) => l.dataset.evenement).sort()).toEqual(['Gala des Ombrelles', 'Gala des Pervenches']); + const signalement = (base) => ligneDe(rouvert, base).querySelector('[data-signalement="accueil.signalement.doublon"]'); + for (const [base, autre] of [ + ['Gala des Ombrelles', 'Gala des Pervenches'], + ['Gala des Pervenches', 'Gala des Ombrelles'], + ]) { + expect(signalement(base).textContent).toContain(t('accueil.signalement.doublon', { autres: 1 })); + expect(noeudsDeTexte(signalement(base)).filter((texte) => texte.includes('Gala'))).toEqual([autre]); + } + }); + + test('renommer coupé après le seul nouveau journal : à la réouverture, une ligne, l’ancienne', async () => { + const rouvert = await renommerCoupe(`Gala des Ombrelles${SUFFIXES.journal}`); + expect(lignes(rouvert).map((l) => l.dataset.evenement)).toEqual(['Gala des Pervenches']); + expect(ligneDe(rouvert, 'Gala des Pervenches').querySelectorAll('[data-signalement]').length).toBe(0); + }); + + test('importer un événement : une ligne de plus, octets identiques ; une seconde fois, un refus qui nomme la base ; le libellé nomme l’événement, pas les participants', async () => { + const octets = UTF8.encode(texteRenomme('Bal des Grisailles')); + const m = await monter({ + aImporter: [ + { nom: `Bal des Grisailles${SUFFIXES.etat}`, octets }, + { nom: `Bal des Grisailles${SUFFIXES.etat}`, octets }, + ], + }); + const commande = dans(m, '[data-commande="importer"]'); + expect(commande.textContent).toContain(t('accueil.operation.importer')); + expect(commande.textContent.toLowerCase()).toContain('événement'); + expect(commande.textContent.toLowerCase()).not.toContain('participants'); + await cliquer(commande); + await vi.waitFor(() => expect(lignes(m).length).toBe(1)); + const base = lignes(m)[0].dataset.evenement; + expect([...m.fs.octets('documents', `${base}${SUFFIXES.etat}`)]).toEqual([...octets]); + await cliquer(dans(m, '[data-commande="importer"]')); + const refus = await vi.waitFor(() => { + const element = dans(m, '[data-refus="IDENTIFIANT_PRESENT"]'); + expect(element).not.toBeNull(); + return element; + }); + expect(refus.textContent).toContain(base); + expect(visible(refus)).toBe(true); + expect(lignes(m).length).toBe(1); + }); + + test('supprimer : à confirmer en place ; la ligne part, la corbeille montre son dossier daté et ses fichiers, à côté de ceux qu’elle portait', async () => { + const m = await monter({ + fichiers: [ + fichier(`bal${SUFFIXES.etat}`, texteRenomme('Bal des Grisailles')), + fichier(`${ANCIEN_DOSSIER}/Ancienne${SUFFIXES.etat}`, texteNeuf({ id: 'evt-cerfs', nom: 'Soirée des Cerfs' })), + fichier(`${ANCIEN_DOSSIER}/Ancienne${SUFFIXES.journal}`, ''), + ], + }); + await vi.waitFor(() => expect(dans(m, `[data-corbeille-dossier="${ANCIEN_DOSSIER}"]`)).not.toBeNull()); + const avant = ecritures(m); + const commande = () => ligneDe(m, 'bal')?.querySelector('[data-commande="supprimer"]') ?? null; + await cliquer(commande()); + expect(commande().textContent).toBe(t('accueil.operation.confirmerSuppression')); + expect(ecritures(m)).toBe(avant); + aucuneFenetre(); + await cliquer(commande()); + await vi.waitFor(() => expect(lignes(m).length).toBe(0)); + const corbeille = await vi.waitFor(() => { + const element = dans(m, `[data-corbeille-dossier="${CORBEILLE_DU_DEPART}"]`); + expect(element).not.toBeNull(); + return element; + }); + expect(visible(corbeille)).toBe(true); + expect(corbeille.textContent).toContain(`${DOCUMENTS}\\corbeille\\2026-10-06_19-00-00`); + expect(corbeille.textContent).toContain(`bal${SUFFIXES.etat}`); + expect(m.fs.contenu('documents', `${CORBEILLE_DU_DEPART}/bal${SUFFIXES.etat}`)).not.toBeNull(); + // Rien ne la vide : le dossier daté qu'elle portait reste montré, avec ses + // fichiers, et elle n'offre aucune commande. + const dossiers = tous(m, '[data-corbeille-dossier]'); + expect(dossiers.map((element) => element.dataset.corbeilleDossier).sort()).toEqual([ANCIEN_DOSSIER, CORBEILLE_DU_DEPART]); + const ancien = dans(m, `[data-corbeille-dossier="${ANCIEN_DOSSIER}"]`); + expect(visible(ancien)).toBe(true); + for (const nom of [`Ancienne${SUFFIXES.etat}`, `Ancienne${SUFFIXES.journal}`]) { + expect(noeudsDeTexte(ancien)).toContain(nom); + } + expect(noeudsDeTexte(corbeille)).toContain(`bal${SUFFIXES.etat}`); + expect(dans(m, '[data-corbeille]').querySelectorAll('button, [data-commande]').length).toBe(0); + expect(m.fs.contenu('documents', `${ANCIEN_DOSSIER}/Ancienne${SUFFIXES.etat}`)).not.toBeNull(); + }); + + test('une corbeille déjà présente au lancement se montre, et encore après un aller-retour vers un événement', async () => { + const m = await monter({ + fichiers: [ + fichier(`bal${SUFFIXES.etat}`, texteRenomme('Bal des Grisailles')), + fichier(`${ANCIEN_DOSSIER}/Ancienne${SUFFIXES.etat}`, texteNeuf({ id: 'evt-cerfs', nom: 'Soirée des Cerfs' })), + ], + }); + const montree = () => { + const element = dans(m, `[data-corbeille-dossier="${ANCIEN_DOSSIER}"]`); + expect(element).not.toBeNull(); + expect(visible(element)).toBe(true); + expect(noeudsDeTexte(element)).toContain(`Ancienne${SUFFIXES.etat}`); + }; + await vi.waitFor(montree); + await ouvrirLigne(m, 'bal'); + await attendreEvenement(m); + expect(dans(m, '[data-corbeille]')).toBeNull(); + await revenirALaListe(m, 1); + await vi.waitFor(montree); + }); + + test('supprimer un événement qu’une séance vivante tient en écriture : refusé, la séance nommée, fichiers en place', async () => { + const m = await monter({ fichiers: [fichier(`bal${SUFFIXES.etat}`, texteRenomme('Bal des Grisailles'))] }); + const second = await monter({ systeme: { disque: m.disque } }); + await second.seance.ouvrir('bal'); + await second.seance.passerEnEcriture(); + const commande = ligneDe(m, 'bal').querySelector('[data-commande="supprimer"]'); + await cliquer(commande); + await cliquer(ligneDe(m, 'bal').querySelector('[data-commande="supprimer"]')); + const refus = await vi.waitFor(() => { + const element = ligneDe(m, 'bal').querySelector('[data-refus="VERROU_PRIS"]'); + expect(element).not.toBeNull(); + return element; + }); + expect(visible(refus)).toBe(true); + // Le texte d'une séance vivante, sans son instant : celui-ci vient de + // l'horloge du disque. + const vivante = t('VERROU_PRIS', { seance: null, depuis: null, vivant: true }).split(' : ')[0]; + expect(refus.textContent).toContain(vivante); + expect(m.fs.contenu('documents', `bal${SUFFIXES.etat}`)).not.toBeNull(); + expect(lignes(m).length).toBe(1); + }); + + test('supprimer un événement passé en écriture, après « Liste des événements » : il part, sans VERROU_PRIS', async () => { + const m = await monter({ fichiers: [fichier(`bal${SUFFIXES.etat}`, texteRenomme('Bal des Grisailles'))] }); + await ouvrirLigne(m, 'bal'); + await attendreEvenement(m); + await m.seance.passerEnEcriture(); + await revenirALaListe(m, 1); + await cliquer(ligneDe(m, 'bal').querySelector('[data-commande="supprimer"]')); + await cliquer(ligneDe(m, 'bal').querySelector('[data-commande="supprimer"]')); + await vi.waitFor(() => expect(lignes(m).length).toBe(0)); + expect(dans(m, '[data-refus="VERROU_PRIS"]')).toBeNull(); + expect(m.fs.contenu('documents', `bal${SUFFIXES.etat}`)).toBeNull(); + }); +}); + +// --- Les démonstrations (§ 15) ------------------------------------------------------------- + +describe('les démonstrations (§ 15)', () => { + test('deux chargements : « Petite démonstration 1 » puis « … 2 », en lecture, la première intacte', async () => { + const m = await monter(); + const nom = CATALOGUE.find(({ cle }) => cle === 'petite').nom; + await cliquer(dans(m, '[data-demonstration="petite"]')); + const premiere = await attendreEvenement(m, (e) => e.nom === `${nom} 1`); + expect(m.seance.etat().mode).toBe('lecture'); + await revenirALaListe(m, 1); + const photo = await photographier(m, 'documents'); + const fichiersPremiere = Object.keys(photo).filter((chemin) => chemin.startsWith(premiere.base)); + expect(fichiersPremiere.length).toBeGreaterThan(0); + await cliquer(dans(m, '[data-demonstration="petite"]')); + await attendreEvenement(m, (e) => e.nom === `${nom} 2`); + expect(m.seance.etat().mode).toBe('lecture'); + const apres = await photographier(m, 'documents'); + for (const chemin of fichiersPremiere) expect(apres[chemin]).toBe(photo[chemin]); + }); +}); + +// --- La racine neutre et la liste qui défile (§ 18.6, § 19.4) ---------------------------------- + +describe('la racine neutre et la liste qui défile (§ 18.6, § 19.4)', () => { + const RACINE = 'C:\\Epreuve\\Tablees'; + + async function listeSousRacine() { + const m = await monter({ systeme: { documents: RACINE }, horloge: creerHorlogePilotee() }); + await creerParLeFormulaire(m, 'Bal des Grisailles'); + await attendreEvenement(m); + await revenirALaListe(m, 1); + return m; + } + + test('chaque chemin de la liste est sous la racine d’épreuve ; deux montages au même départ rendent le même texte', async () => { + const premier = await listeSousRacine(); + const textes = []; + const parcours = document.createTreeWalker(accueil(premier), NodeFilter.SHOW_TEXT); + while (parcours.nextNode()) textes.push(parcours.currentNode.textContent); + const chemins = textes.filter((texte) => texte.includes('\\')); + expect(chemins.length).toBeGreaterThan(0); + for (const chemin of chemins) expect(chemin.startsWith(RACINE), chemin).toBe(true); + const texte = accueil(premier).textContent; + premier.demonter(); + montes = montes.filter((m) => m !== premier); + const second = await listeSousRacine(); + expect(accueil(second).textContent).toBe(texte); + }); + + test('deux cents événements, la liste défilée jusqu’en bas : le bandeau reste dans la fenêtre, son centre l’atteint', async () => { + const fichiers = Array.from({ length: 200 }, (_, rang) => + fichier(`soiree-${String(rang).padStart(3, '0')}${SUFFIXES.etat}`, texteNeuf({ id: `evt-${rang}`, nom: `Soirée des Cerfs ${rang}` })), + ); + const m = await monter({ fichiers }); + expect(lignes(m).length).toBe(200); + window.scrollTo(0, document.documentElement.scrollHeight); + expect(window.scrollY).toBeGreaterThan(0); + const bandeau = dans(m, '[data-bandeau]'); + const boite = bandeau.getBoundingClientRect(); + expect(boite.top).toBeGreaterThanOrEqual(0); + expect(boite.bottom).toBeLessThanOrEqual(window.innerHeight); + const touche = document.elementFromPoint(boite.left + boite.width / 2, boite.top + boite.height / 2); + expect(bandeau.contains(touche)).toBe(true); + }); +}); diff --git a/src/interface/accueil/Accueil.svelte b/src/interface/accueil/Accueil.svelte index 422e41f..6dd7b80 100644 --- a/src/interface/accueil/Accueil.svelte +++ b/src/interface/accueil/Accueil.svelte @@ -2,79 +2,339 @@ License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) --> -
-

{t('coquille.liste')}

-

- {t('coquille.version.libelle')} - {versionVisible(provenance, version, t)} -

- {#if refus !== null} -

{t(refus.code, refus.details)}

- {/if} - -
+{#if recuperation !== null} + (recuperation = null)} /> +{:else} +
+

{t('coquille.liste')}

+

+ {t('coquille.version.libelle')} + {versionVisible(provenance, version, t)} +

+ {#if refusLecture !== null} +

{t(refusLecture.code, refusLecture.details)}

+ {/if} + + {#if issue !== null} + 0} + /> + {/if} + + + +
+

{t('accueil.creation.titre')}

+
+
+ + + {#if champRefuse === 'nom'} + + {/if} +
+
+ + + {#if champRefuse === 'date'} + + {/if} +
+
+ + {#if refusCreation !== null && champRefuse === null} + + {/if} +
+
+ +
+ + + + +
+{/if} diff --git a/src/interface/accueil/Corbeille.svelte b/src/interface/accueil/Corbeille.svelte new file mode 100644 index 0000000..fa25a7e --- /dev/null +++ b/src/interface/accueil/Corbeille.svelte @@ -0,0 +1,71 @@ + + + + +{#if contenu.length > 0} +
+

{t('accueil.corbeille.titre')}

+

{t('accueil.corbeille.explication')}

+ +
+{/if} + + diff --git a/src/interface/accueil/Demonstrations.svelte b/src/interface/accueil/Demonstrations.svelte new file mode 100644 index 0000000..8db1069 --- /dev/null +++ b/src/interface/accueil/Demonstrations.svelte @@ -0,0 +1,84 @@ + + + + +
+

{t('accueil.demonstrations.titre')}

+

{t('accueil.demonstrations.explication')}

+ +
+ + diff --git a/src/interface/accueil/DossierTravail.svelte b/src/interface/accueil/DossierTravail.svelte new file mode 100644 index 0000000..f3bf799 --- /dev/null +++ b/src/interface/accueil/DossierTravail.svelte @@ -0,0 +1,140 @@ + + + + +
+ {#if question !== null} +
+

{t(question.code, question)}

+ {#if !reponseEnCours} +
+ {#each reponses as { choix, cle } (choix)} + repondre(choix)} /> + {/each} +
+ {/if} +
+ {/if} + + {#if autre !== null && !lignesPresentes} +
+

{t('accueil.autre.proposition', { evenements: autre.evenements })}

+

{autre.racine.chemin}

+

{t('accueil.autre.originaux')}

+ +
+ {/if} + {#if importAutre !== null} +

{t('accueil.autre.resultat', importAutre)}

+ {/if} + + {#each avis as { code, details }, rang (rang)} +

{t(code, details)}

+ {/each} + + {#if question === null && racine !== null} +
+

{t('accueil.dossier.titre')}

+

{racine.chemin}

+ {#if explorateur.disponible} + surLaListe(() => facade.ouvrirExplorateur())} /> + {:else} +

{t(explorateur.raison)}

+ {/if} +
+ {/if} +
+ + diff --git a/src/interface/accueil/ListeEvenements.svelte b/src/interface/accueil/ListeEvenements.svelte new file mode 100644 index 0000000..c36a4e9 --- /dev/null +++ b/src/interface/accueil/ListeEvenements.svelte @@ -0,0 +1,158 @@ + + + + +{#if lignes.length > 0} + +{/if} + + diff --git a/src/interface/accueil/Operations.svelte b/src/interface/accueil/Operations.svelte new file mode 100644 index 0000000..15606d7 --- /dev/null +++ b/src/interface/accueil/Operations.svelte @@ -0,0 +1,152 @@ + + + + +
+ {#if saisie === null} + ouvrirSaisie('copier')} /> + ouvrirSaisie('renommer')} /> + + {:else} +
+
+ + + {#if surLeChamp} + + {/if} +
+
+ + + {#if refus !== null && !surLeChamp} + + {/if} +
+
+ {/if} +
+ + diff --git a/src/interface/accueil/Recuperation.svelte b/src/interface/accueil/Recuperation.svelte new file mode 100644 index 0000000..ad7d45c --- /dev/null +++ b/src/interface/accueil/Recuperation.svelte @@ -0,0 +1,81 @@ + + + + +
+

{t('accueil.recuperation.titre')}

+

+ {t('accueil.recuperation.fichier')} + {chemin} +

+ + {#if offerts.length > 0} +

{t('accueil.recuperation.offerte')}

+
    + {#each offerts as { source, cle } (source)} +
  • + surLaListe(() => seance.ouvrirSecours(base, source))} /> +
  • + {/each} +
+ {:else} +

{t('accueil.recuperation.aucunSecours')}

+ {/if} + +
+ + diff --git a/src/interface/accueil/modele.js b/src/interface/accueil/modele.js new file mode 100644 index 0000000..219e3e6 --- /dev/null +++ b/src/interface/accueil/modele.js @@ -0,0 +1,272 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Le modèle de l'accueil, pur (§ 8.1, § 8.6, § 8.8) : ce que les composants +// de la liste des événements affichent, sans arithmétique ni texte. Une +// ligne de la liste (LigneListe de la façade, A3) devient ses six +// informations mises en forme et ses signalements ; une question du dossier +// de travail, ses réponses ; un état illisible, ses secours ; un refus de la +// façade, le refus affiché sur la liste. Chaque clé rendue est écrite ici en +// entier : aucun composant ne compose une clé. + +// Les états du plan (§ 9) et la clé de leur nom, dans l'ordre du § 9. +const CLES_ETATS = new Map([ + ['brouillon', 'accueil.etat.brouillon'], + ['propose', 'accueil.etat.propose'], + ['retenu', 'accueil.etat.retenu'], + ['bloque', 'accueil.etat.bloque'], +]); + +/** Les états du plan, dans l'ordre du § 9. */ +export const ETATS = Object.freeze([...CLES_ETATS.keys()]); + +// La clé de la valeur absente, celle de la mise en forme. +const CLE_VIDE = 'format.vide'; + +// Les réponses de chaque question du dossier de travail, dans l'ordre où +// l'accueil les offre, et la clé de leur libellé : celles qu'admet +// facade.repondre (A3). +const REPONSES = new Map([ + [ + 'PORTABLE_NON_INSCRIPTIBLE', + [ + { choix: 'reessayer', cle: 'accueil.question.reessayer' }, + { choix: 'documents', cle: 'accueil.question.documents' }, + ], + ], + [ + 'DOCUMENTS_NON_INSCRIPTIBLE', + [ + { choix: 'reessayer', cle: 'accueil.question.reessayer' }, + { choix: 'continuer', cle: 'accueil.question.continuer' }, + ], + ], +]); + +// Les secours d'un état illisible, dans l'ordre du § 8.8 — la version +// précédente, puis le dernier instant du journal —, et la clé de la commande +// qui l'ouvre. +const SECOURS = [ + { source: 'precedent', cle: 'communs.commande.ouvrirPrecedent' }, + { source: 'journal', cle: 'communs.commande.ouvrirJournal' }, +]; + +/** + * La clé du nom d'un état du plan ; null, celui d'un état illisible, rend + * la clé de la valeur absente. + * + * @param {string|null} etat + * @returns {string} + * @throws {RangeError} un état hors de ETATS + */ +export function cleDEtat(etat) { + if (etat === null) return CLE_VIDE; + const cle = CLES_ETATS.get(etat); + if (cle === undefined) throw new RangeError(`état du plan inconnu : ${JSON.stringify(etat)}`); + return cle; +} + +/** + * Les signalements d'une ligne (§ 8.1, § 8.8), chacun { cle, details, noms } + * : la traduction compose cle et details, et noms sont les données qu'il + * nomme hors de son texte, que le composant rend chacune dans son propre + * nœud (§ 14.6). ETAT_ILLISIBLE pour un état qui ne s'ouvre pas — un journal + * sans état compris, de raison ABSENT —, avec la base, la raison et les + * secours lus par la façade, et aucun nom à part ; + * accueil.signalement.doublon pour un état dont d'autres bases portent + * l'identifiant, avec leur nombre, et ces bases pour noms, dans l'ordre de la + * façade. + * Un verrou présent ne se signale pas : seule la sonde d'une opération dit si + * sa séance vit. Les détails et les noms sont des copies. + * + * @param {Object} ligne LigneListe (A3) + * @returns {Array<{cle: string, details: Object, noms: string[]}>} + */ +export function signalementsDe(ligne) { + const signalements = []; + if (ligne.illisible !== null) { + signalements.push({ cle: 'ETAT_ILLISIBLE', details: { base: ligne.base, ...ligne.illisible }, noms: [] }); + } + if (ligne.doublonAvec.length > 0) { + const noms = [...ligne.doublonAvec]; + signalements.push({ cle: 'accueil.signalement.doublon', details: { autres: noms.length }, noms }); + } + return signalements; +} + +/** + * Les six informations d'une ligne (§ 8.1), mises en forme, et ses + * signalements. nom : celui inscrit dans le fichier, ou la base quand l'état + * ne se lit pas ; date, participants et dernière modification par format, + * la valeur absente quand ils manquent — la dernière modification est + * l'horodatage de la dernière entrée du journal, sur l'horloge de + * l'application, jamais la date du système de fichiers (§ 19.4) ; etat : la + * clé de son nom, que le composant traduit ; chemin : tel que la façade + * l'affiche, en entier. + * + * @param {Object} ligne LigneListe (A3) + * @param {ReturnType} format + * @returns {{nom: string, date: string, etat: string, participants: string, + * modification: string, chemin: string, + * signalements: Array<{cle: string, details: Object, noms: string[]}>}} + */ +export function ligneAffichee(ligne, format) { + return { + nom: ligne.nom ?? ligne.base, + date: ligne.date === null ? format.nombre(null) : format.date(ligne.date), + etat: cleDEtat(ligne.etat), + participants: format.nombre(ligne.participants), + modification: ligne.derniereModification === null ? format.nombre(null) : format.horodatage(ligne.derniereModification), + chemin: ligne.chemin, + signalements: signalementsDe(ligne), + }; +} + +/** + * Les réponses qu'offre une question du dossier de travail (§ 8.6), dans + * l'ordre : [{ choix, cle }], choix tel que facade.repondre le reçoit. + * + * @param {{code: string}} question + * @returns {Array<{choix: string, cle: string}>} + * @throws {RangeError} une question inconnue + */ +export function reponsesDe(question) { + const reponses = REPONSES.get(question.code); + if (reponses === undefined) throw new RangeError(`question inconnue : ${JSON.stringify(question.code)}`); + return reponses.map((reponse) => ({ ...reponse })); +} + +/** + * Les secours lisibles d'un état illisible (§ 8.8), dans l'ordre : la + * version précédente, puis le dernier instant du journal ; chacun { source, + * cle }, source telle que seance.ouvrirSecours la reçoit. Aucun secours, ou + * des secours non lus, rendent []. + * + * @param {{precedent: number|null, journal: number|null}|null|undefined} secours + * @returns {Array<{source: 'precedent'|'journal', cle: string}>} + */ +export function secoursOfferts(secours) { + if (secours === null || secours === undefined) return []; + return SECOURS.filter(({ source }) => secours[source] !== null && secours[source] !== undefined).map((offert) => ({ + ...offert, + })); +} + +/** + * Le refus d'un geste de la liste, tel que l'accueil l'affiche : le code et + * les détails de l'erreur, son remède retiré. Sur la liste, aucun événement + * n'est ouvert : un remède de la séance — débloquer, modifier, ouvrir un + * secours — n'y aurait rien sur quoi agir, et le texte du refus dit déjà ce + * qui le lève. Une erreur sans code n'est pas un refus : null. + * + * @param {unknown} erreur + * @returns {{code: string, details: Object}|null} + */ +export function refusDeLaListe(erreur) { + if (typeof erreur?.code !== 'string') return null; + return { code: erreur.code, details: { ...(erreur.details ?? {}), remede: null } }; +} + +/** + * Exécute une opération de la liste et rend ce qu'elle rend. Un refus + * remonte tel que la liste l'affiche (refusDeLaListe) : { code, details }, + * sans remède. Toute autre erreur est une faute ou une panne, et remonte + * telle quelle. + * + * @template T + * @param {() => T|Promise} operation + * @returns {Promise} + */ +export async function surLaListe(operation) { + try { + return await operation(); + } catch (erreur) { + const refus = refusDeLaListe(erreur); + if (refus === null) throw erreur; + throw refus; + } +} + +// Le champ que désigne chaque refus d'un nom ou d'une date saisis. +const CHAMPS_DES_REFUS = new Map([ + ['NOM_REQUIS', 'nom'], + ['DATE_INVALIDE', 'date'], +]); + +/** + * Le champ contre lequel un refus se pose : 'nom' pour NOM_REQUIS, 'date' + * pour DATE_INVALIDE ; null pour tout autre refus, qui se pose contre la + * commande, et sans refus. + * + * @param {{code: string}|null} refus + * @returns {'nom'|'date'|null} + */ +export function champDuRefus(refus) { + if (refus === null) return null; + return CHAMPS_DES_REFUS.get(refus.code) ?? null; +} + +// La saisie d'un nom de chaque opération qui en demande un (§ 8.7) : le +// data-champ de l'entrée, la clé de son étiquette, le data-commande et la +// clé du libellé de la commande qui valide. +const CLES_SAISIES = new Map([ + [ + 'copier', + { + champ: 'nomCopie', + etiquette: 'accueil.operation.nomCopie', + commande: 'validerCopie', + valider: 'accueil.operation.validerCopie', + }, + ], + [ + 'renommer', + { + champ: 'nouveauNom', + etiquette: 'accueil.operation.nouveauNom', + commande: 'validerRenommage', + valider: 'accueil.operation.validerRenommage', + }, + ], +]); + +/** Les opérations de la liste qui demandent un nom avant d'écrire. */ +export const SAISIES = Object.freeze([...CLES_SAISIES.keys()]); + +/** + * La saisie d'un nom pour une opération (§ 8.7) : son champ, ses clés, et le + * texte de départ — vide pour copier, qui demande un nom neuf plutôt que d'en + * empiler un « (copie) » ; le nom inscrit pour renommer, vide quand l'état + * ne se lit pas. + * + * @param {'copier'|'renommer'} operation + * @param {string|null} nom le nom inscrit dans le fichier + * @returns {{champ: string, etiquette: string, commande: string, valider: string, texte: string}} + * @throws {RangeError} une opération hors de SAISIES + */ +export function saisieDe(operation, nom) { + const cles = CLES_SAISIES.get(operation); + if (cles === undefined) throw new RangeError(`opération sans saisie : ${JSON.stringify(operation)}`); + return { ...cles, texte: operation === 'renommer' ? (nom ?? '') : '' }; +} + +// Les séances dont le dossier de travail a déjà été annoncé. +const ANNONCEES = new WeakSet(); + +/** + * Vrai la première fois qu'on le demande pour une séance, faux ensuite + * (§ 8.6) : l'accueil annonce le dossier de travail — les avis de la + * détermination — une fois par séance, et non à chaque retour à la liste. + * La séance est un objet qui vit autant qu'elle, la façade d'App ; rien ne + * survit à elle. + * + * @param {object} seance + * @returns {boolean} + * @throws {TypeError} une séance qui n'est pas un objet + */ +export function premiereAnnonce(seance) { + if (seance === null || typeof seance !== 'object') throw new TypeError('premiereAnnonce : la séance est un objet'); + if (ANNONCEES.has(seance)) return false; + ANNONCEES.add(seance); + return true; +} diff --git a/src/interface/accueil/modele.test.js b/src/interface/accueil/modele.test.js new file mode 100644 index 0000000..35eb2ed --- /dev/null +++ b/src/interface/accueil/modele.test.js @@ -0,0 +1,303 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Le modèle de l'accueil (§ 8.1, § 8.6, § 8.8) : les six informations d'une +// ligne de la liste, mises en forme, et ses signalements ; les réponses +// d'une question du dossier de travail ; les secours d'un état illisible ; +// le refus d'un geste de la liste, sans remède ; l'annonce du dossier de +// travail, une fois par séance. Chaque clé que le modèle rend existe dans la +// traduction. Les noms d'épreuve sont inventés. +import assert from 'node:assert/strict'; +import { describe, test } from '../../../test/lanceur.js'; +import { creerFormat } from '../../application/format.js'; +import { TABLES_APPLICATION } from '../../application/libelles.js'; +import { creerTraduction } from '../../application/traduction.js'; +import { TABLES_INTERFACE } from '../libelles.js'; +import { + ETATS, + SAISIES, + champDuRefus, + cleDEtat, + ligneAffichee, + premiereAnnonce, + refusDeLaListe, + reponsesDe, + saisieDe, + secoursOfferts, + signalementsDe, + surLaListe, +} from './modele.js'; + +const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]); +const format = creerFormat(t); +const VIDE = t('format.vide'); + +const RACINE = 'C:\\Epreuve\\Tablees'; + +// Une ligne de la liste telle que la façade la rend (LigneListe, A3), lisible. +function ligne(champs = {}) { + return { + base: 'Bal des Grisailles', + id: 'evt-grisailles', + nom: 'Bal des Grisailles', + date: '2031-05-17', + etat: 'propose', + participants: 1234, + derniereModification: '2031-05-17T08:05:00-04:00', + chemin: `${RACINE}\\Bal des Grisailles.gtt.json`, + illisible: null, + journalSeul: false, + doublonAvec: [], + verrou: null, + ...champs, + }; +} + +describe('ligneAffichee : les six informations (§ 8.1)', () => { + test('une ligne lisible : nom, date, état, participants, dernière modification et chemin, mis en forme', () => { + const affichee = ligneAffichee(ligne(), format); + assert.deepEqual(Object.keys(affichee).sort(), [ + 'chemin', + 'date', + 'etat', + 'modification', + 'nom', + 'participants', + 'signalements', + ]); + assert.equal(affichee.nom, 'Bal des Grisailles'); + assert.equal(affichee.date, format.date('2031-05-17')); + assert.equal(affichee.etat, 'accueil.etat.propose'); + assert.equal(affichee.participants, format.nombre(1234)); + assert.equal(affichee.modification, format.horodatage('2031-05-17T08:05:00-04:00')); + assert.equal(affichee.chemin, `${RACINE}\\Bal des Grisailles.gtt.json`); + assert.deepEqual(affichee.signalements, []); + }); + + test('sans journal, la dernière modification est la valeur absente ; sans date, de même — jamais une date du système', () => { + const affichee = ligneAffichee(ligne({ derniereModification: null, date: null }), format); + assert.equal(affichee.modification, VIDE); + assert.equal(affichee.date, VIDE); + }); + + test('une ligne illisible : son nom est sa base, ses informations absentes, son état la valeur absente', () => { + const affichee = ligneAffichee( + ligne({ + nom: null, + date: null, + etat: null, + participants: null, + derniereModification: null, + illisible: { base: 'Bal des Grisailles', raison: 'VIDE', chemin: null, secours: { precedent: null, journal: null } }, + }), + format, + ); + assert.equal(affichee.nom, 'Bal des Grisailles'); + assert.equal(affichee.date, VIDE); + assert.equal(affichee.etat, 'format.vide'); + assert.equal(affichee.participants, VIDE); + assert.equal(affichee.modification, VIDE); + }); + + test('chaque état du plan a sa clé, qui existe ; un état inconnu lève', () => { + assert.deepEqual([...ETATS], ['brouillon', 'propose', 'retenu', 'bloque']); + for (const etat of ETATS) { + const cle = cleDEtat(etat); + assert.equal(cle, `accueil.etat.${etat}`); + assert.equal(typeof t(cle), 'string'); + } + assert.equal(cleDEtat(null), 'format.vide'); + assert.throws(() => cleDEtat('archive'), RangeError); + }); + + test('ligneAffichee ne lit pas la ligne au-delà de ses champs : deux appels rendent la même chose', () => { + assert.deepEqual(ligneAffichee(ligne(), format), ligneAffichee(ligne(), format)); + }); +}); + +describe('signalementsDe (§ 8.1, § 8.8)', () => { + test('un état vide : ETAT_ILLISIBLE, sa base et sa raison', () => { + const illisible = { base: 'Soirée vide', raison: 'VIDE', chemin: null, secours: { precedent: null, journal: null } }; + const signalements = signalementsDe(ligne({ base: 'Soirée vide', nom: null, illisible })); + assert.deepEqual(signalements, [{ cle: 'ETAT_ILLISIBLE', details: illisible, noms: [] }]); + assert.match(t(signalements[0].cle, signalements[0].details), /vide/); + }); + + test('un journal sans état : ETAT_ILLISIBLE de raison ABSENT, qui nomme le secours du journal', () => { + const illisible = { base: 'Soirée des Hiboux', raison: 'ABSENT', chemin: null, secours: { precedent: null, journal: 1 } }; + const [signalement, ...reste] = signalementsDe(ligne({ base: 'Soirée des Hiboux', journalSeul: true, illisible })); + assert.deepEqual(reste, []); + assert.equal(signalement.cle, 'ETAT_ILLISIBLE'); + assert.equal(signalement.details.raison, 'ABSENT'); + assert.match(t(signalement.cle, signalement.details), /historique/); + }); + + test('un doublon nomme chaque autre base à part, hors de son texte, et sa clé existe', () => { + const signalements = signalementsDe(ligne({ base: 'Merles A', doublonAvec: ['Merles B'] })); + assert.deepEqual(signalements, [{ cle: 'accueil.signalement.doublon', details: { autres: 1 }, noms: ['Merles B'] }]); + // Une base est une donnée : elle occupe son propre nœud, jamais le texte. + assert.ok(!t(signalements[0].cle, signalements[0].details).includes('Merles')); + const deux = signalementsDe(ligne({ doublonAvec: ['Merles B', 'Merles C'] })); + assert.deepEqual(deux[0], { cle: 'accueil.signalement.doublon', details: { autres: 2 }, noms: ['Merles B', 'Merles C'] }); + assert.ok(!t(deux[0].cle, deux[0].details).includes('Merles')); + }); + + test('une ligne lisible sans doublon ne porte rien ; un verrou présent ne se signale pas', () => { + assert.deepEqual(signalementsDe(ligne()), []); + assert.deepEqual(signalementsDe(ligne({ verrou: { seance: 'seance-autre', depuis: null } })), []); + }); + + test('les détails rendus sont des copies : la ligne reçue ne change pas', () => { + const recue = ligne({ doublonAvec: ['Merles B'] }); + const [signalement] = signalementsDe(recue); + signalement.noms.push('Merles Z'); + assert.deepEqual(recue.doublonAvec, ['Merles B']); + }); +}); + +describe('reponsesDe : les réponses d’une question du dossier de travail (§ 8.6)', () => { + test('PORTABLE_NON_INSCRIPTIBLE : réessayer, puis travailler dans les Documents', () => { + const reponses = reponsesDe({ code: 'PORTABLE_NON_INSCRIPTIBLE' }); + assert.deepEqual(reponses, [ + { choix: 'reessayer', cle: 'accueil.question.reessayer' }, + { choix: 'documents', cle: 'accueil.question.documents' }, + ]); + }); + + test('DOCUMENTS_NON_INSCRIPTIBLE : réessayer, puis continuer', () => { + const reponses = reponsesDe({ code: 'DOCUMENTS_NON_INSCRIPTIBLE' }); + assert.deepEqual(reponses, [ + { choix: 'reessayer', cle: 'accueil.question.reessayer' }, + { choix: 'continuer', cle: 'accueil.question.continuer' }, + ]); + }); + + test('chaque clé de réponse existe ; une question inconnue lève', () => { + let comptees = 0; + for (const code of ['PORTABLE_NON_INSCRIPTIBLE', 'DOCUMENTS_NON_INSCRIPTIBLE']) { + for (const { cle } of reponsesDe({ code })) { + comptees += 1; + assert.equal(typeof t(cle), 'string'); + } + } + assert.equal(comptees, 4); + assert.throws(() => reponsesDe({ code: 'AUTRE' }), RangeError); + }); +}); + +describe('secoursOfferts : les secours d’un état illisible (§ 8.8)', () => { + test('la version précédente, puis le dernier instant du journal, dans cet ordre', () => { + assert.deepEqual(secoursOfferts({ precedent: 4, journal: 6 }), [ + { source: 'precedent', cle: 'communs.commande.ouvrirPrecedent' }, + { source: 'journal', cle: 'communs.commande.ouvrirJournal' }, + ]); + }); + + test('seuls les secours lisibles ; aucun, ou des secours inconnus, ne rendent rien', () => { + assert.deepEqual(secoursOfferts({ precedent: null, journal: 1 }), [ + { source: 'journal', cle: 'communs.commande.ouvrirJournal' }, + ]); + assert.deepEqual(secoursOfferts({ precedent: 2, journal: null }), [ + { source: 'precedent', cle: 'communs.commande.ouvrirPrecedent' }, + ]); + assert.deepEqual(secoursOfferts({ precedent: null, journal: null }), []); + assert.deepEqual(secoursOfferts(null), []); + assert.deepEqual(secoursOfferts(undefined), []); + }); +}); + +describe('refusDeLaListe : le refus d’un geste de la liste', () => { + test('code et détails gardés, le remède retiré : aucun événement n’est ouvert pour l’exécuter', () => { + const erreur = { code: 'LECTURE_SEULE', details: { raison: 'BLOQUE', remede: { geste: 'debloquer' } } }; + const refus = refusDeLaListe(erreur); + assert.deepEqual(refus, { code: 'LECTURE_SEULE', details: { raison: 'BLOQUE', remede: null } }); + assert.deepEqual(erreur.details.remede, { geste: 'debloquer' }); + assert.equal(t(refus.code, refus.details), t(erreur.code, erreur.details)); + }); + + test('un refus sans détails en reçoit de vides ; une erreur sans code n’est pas un refus', () => { + assert.deepEqual(refusDeLaListe({ code: 'NOM_REQUIS' }), { code: 'NOM_REQUIS', details: { remede: null } }); + assert.equal(refusDeLaListe(new TypeError('faute')), null); + assert.equal(refusDeLaListe(null), null); + }); +}); + +describe('premiereAnnonce : le dossier de travail s’annonce une fois par séance (§ 8.6)', () => { + test('vrai la première fois pour une séance, faux ensuite ; une autre séance a sa première fois', () => { + const seance = {}; + const autre = {}; + assert.equal(premiereAnnonce(seance), true); + assert.equal(premiereAnnonce(seance), false); + assert.equal(premiereAnnonce(seance), false); + assert.equal(premiereAnnonce(autre), true); + assert.equal(premiereAnnonce(autre), false); + }); + + test('une séance absente lève', () => { + assert.throws(() => premiereAnnonce(null), TypeError); + }); +}); + +describe('surLaListe : une opération de la liste et son refus', () => { + test('rend ce que rend l’opération', async () => { + assert.deepEqual(await surLaListe(async () => ({ base: 'Bal des Grisailles' })), { base: 'Bal des Grisailles' }); + assert.equal(await surLaListe(() => null), null); + }); + + test('un refus remonte sans remède ; toute autre erreur remonte telle quelle', async () => { + const refus = { code: 'LECTURE_SEULE', details: { raison: 'BLOQUE', remede: { geste: 'debloquer' } } }; + await assert.rejects( + surLaListe(async () => { + throw refus; + }), + (recu) => { + assert.deepEqual(recu, { code: 'LECTURE_SEULE', details: { raison: 'BLOQUE', remede: null } }); + return true; + }, + ); + const faute = new TypeError('faute'); + await assert.rejects( + surLaListe(() => { + throw faute; + }), + (recu) => recu === faute, + ); + }); +}); + +describe('champDuRefus : le champ contre lequel un refus se pose', () => { + test('le nom pour NOM_REQUIS, la date pour DATE_INVALIDE, aucun sinon', () => { + assert.equal(champDuRefus({ code: 'NOM_REQUIS', details: {} }), 'nom'); + assert.equal(champDuRefus({ code: 'DATE_INVALIDE', details: { date: '2031-02-30' } }), 'date'); + assert.equal(champDuRefus({ code: 'CHEMIN_TROP_LONG', details: {} }), null); + assert.equal(champDuRefus(null), null); + }); +}); + +describe('saisieDe : la saisie d’un nom pour copier ou renommer (§ 8.7)', () => { + test('copier part d’un champ vide, renommer du nom inscrit ; chaque clé existe', () => { + assert.deepEqual([...SAISIES], ['copier', 'renommer']); + const copier = saisieDe('copier', 'Bal des Grisailles'); + assert.deepEqual(copier, { + champ: 'nomCopie', + etiquette: 'accueil.operation.nomCopie', + commande: 'validerCopie', + valider: 'accueil.operation.validerCopie', + texte: '', + }); + const renommer = saisieDe('renommer', 'Bal des Grisailles'); + assert.deepEqual(renommer, { + champ: 'nouveauNom', + etiquette: 'accueil.operation.nouveauNom', + commande: 'validerRenommage', + valider: 'accueil.operation.validerRenommage', + texte: 'Bal des Grisailles', + }); + assert.equal(saisieDe('renommer', null).texte, ''); + for (const saisie of [copier, renommer]) { + assert.equal(typeof t(saisie.etiquette), 'string'); + assert.equal(typeof t(saisie.valider), 'string'); + } + assert.throws(() => saisieDe('supprimer', 'x'), RangeError); + }); +}); diff --git a/src/interface/etat_ui.js b/src/interface/etat_ui.js index d987675..fe942c7 100644 --- a/src/interface/etat_ui.js +++ b/src/interface/etat_ui.js @@ -4,7 +4,7 @@ // L'état de navigation de l'interface : la section ouverte, le panneau « À // propos », le tour montré, la personne mise en évidence, la proposition // demandée au plan, le texte d'un import en attente, les listes de noms, le -// plein écran, la place sélectionnée. Regarder n'écrit jamais (§ 8.4) : cet +// plein écran, la place sélectionnée, la fiche ouverte. Regarder n'écrit jamais (§ 8.4) : cet // état ne reçoit ni la séance, ni la façade, ni un système de fichiers, et // rien de ce qu'il tient ne quitte la mémoire. // @@ -25,6 +25,7 @@ import { SECTION_OUVERTURE, SECTIONS } from './evenement/sections.js'; * @property {boolean} listes les listes de noms sont montrées * @property {boolean} pleinEcran * @property {Object|null} selection la place sélectionnée + * @property {number|null} fiche l'identifiant de la personne dont la fiche est ouverte */ // L'état de navigation d'un événement qui s'ouvre, panneau « À propos » @@ -38,6 +39,7 @@ const DEPART = Object.freeze({ listes: true, pleinEcran: false, selection: null, + fiche: null, }); /** @@ -49,7 +51,7 @@ const DEPART = Object.freeze({ * montrerProposition: Function, ouvrirImport: Function, * prendreImport: () => null|{texte: string, origine: string}, * basculerListes: Function, basculerPleinEcran: Function, - * selectionner: Function}} + * selectionner: Function, ouvrirFiche: Function}} */ export function creerEtatUi() { let etat = Object.freeze({ ...DEPART, apropos: false }); @@ -118,5 +120,16 @@ export function creerEtatUi() { selectionner(place) { changer({ selection: place }); }, + /** Ouvre la fiche de la personne id, entier positif, dans la section des + * participants ; null referme la fiche, la section reste. RangeError + * sinon. */ + ouvrirFiche(id) { + if (id === null) { + changer({ fiche: null }); + return; + } + if (!Number.isInteger(id) || id < 1) throw new RangeError(`fiche : entier positif ou null attendu, reçu ${String(id)}`); + changer({ fiche: id, section: 'participants' }); + }, }; } diff --git a/src/interface/etat_ui.test.js b/src/interface/etat_ui.test.js index 869c043..9725f6e 100644 --- a/src/interface/etat_ui.test.js +++ b/src/interface/etat_ui.test.js @@ -20,6 +20,7 @@ const DEPART = { listes: true, pleinEcran: false, selection: null, + fiche: null, }; // Un état et le relevé de ce que reçoit son abonné. @@ -87,6 +88,7 @@ describe('état de navigation : chaque transition', () => { ui.basculerListes(); ui.basculerPleinEcran(); ui.selectionner({ table: 1, siege: 2 }); + ui.ouvrirFiche(4); ui.ouvrirApropos(); ui.reinitialiser(); assert.deepEqual(ui.etat(), { ...DEPART, apropos: true }); @@ -143,11 +145,32 @@ describe('état de navigation : chaque transition', () => { assert.deepEqual(ui.etat(), DEPART); }); + 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('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); }); }); diff --git a/src/interface/evenement/Avertissements.svelte b/src/interface/evenement/Avertissements.svelte new file mode 100644 index 0000000..fce3370 --- /dev/null +++ b/src/interface/evenement/Avertissements.svelte @@ -0,0 +1,108 @@ + + + + +{#if liste.length > 0} +
+ {#each liste as avertissement, rang (rang)} + {@const action = actionRemede(avertissement.remede, { seance, ui })} +
+

{t(avertissement.code, avertissement.details)}

+ {#if avertissement.fautives !== undefined && avertissement.fautives.length > 0} +
    + {#each avertissement.fautives as fautive, n (n)} +
  • + {#if fautive.id !== null} + {t('evenement.fautive.numero')} + {fautive.id} + {:else} + {t('evenement.fautive.rang')} + {fautive.rang} + {/if} + {#each fautive.fautes as faute, f (f)} + {t(faute.code, faute)} + {/each} +
  • + {/each} +
+ {/if} + {#if avertissement.derives !== undefined && avertissement.derives.length > 0} +
    + {#each avertissement.derives as derive (derive.id)} +
  • + {#if derive.id === 'retenu'} + {t('evenement.derive.retenu')} + {:else} + {t('evenement.derive.proposition')} + {derive.id} + {/if} + {#each derive.raisons as raison, r (r)} + {t(raison.code, raison)} + {/each} +
  • + {/each} +
+ {/if} + {#if action !== null} + + {/if} +
+ {/each} +
+{/if} + + diff --git a/src/interface/evenement/CadreMode.svelte b/src/interface/evenement/CadreMode.svelte new file mode 100644 index 0000000..35030cd --- /dev/null +++ b/src/interface/evenement/CadreMode.svelte @@ -0,0 +1,210 @@ + + + + +
+
+
+
+ {#if commande === 'format'} + + {:else if commande === 'modifier'} + seance.passerEnEcriture()} /> + {:else if commande === 'debloquer'} + seance.debloquer()} + /> + {:else if commande === 'lecture'} + seance.passerEnLecture()} /> + {/if} +
+ {#if cadre !== null} + {t(cadre.cle)} + {/if} + {#if commande !== 'format'} +
+ seance.defaire()} /> + {#if $seance.retours.refaire} + seance.refaire()} /> + {/if} +
+ {/if} +
+ {#if verrou !== null} +
+

{t('evenement.verrou.titre')}

+

+ {t('evenement.verrou.seance')} + {verrou.seance ?? t('evenement.verrou.inconnu')} + {t('evenement.verrou.depuis')} + {verrou.depuis ?? t('evenement.verrou.inconnu')} +

+ {#if reprise !== null} +

{t('evenement.verrou.avis')}

+ seance.reprendreVerrou()} + /> + {/if} +
+ {/if} +
+ {#if tete !== null} +
+ {@render tete()} +
+ {/if} +
+ {@render children()} +
+
+ + diff --git a/src/interface/evenement/EnteteEvenement.svelte b/src/interface/evenement/EnteteEvenement.svelte new file mode 100644 index 0000000..1a7f20b --- /dev/null +++ b/src/interface/evenement/EnteteEvenement.svelte @@ -0,0 +1,67 @@ + + + + +
+

{$seance.evenement?.nom}

+
+ {#each ETATS_PLAN as etat (etat)} + + seance.executer('changerEtat', { etat })} /> + + {/each} +
+
+ + diff --git a/src/interface/evenement/Evenement.navigateur.test.js b/src/interface/evenement/Evenement.navigateur.test.js new file mode 100644 index 0000000..3be5992 --- /dev/null +++ b/src/interface/evenement/Evenement.navigateur.test.js @@ -0,0 +1,869 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// L'événement ouvert dans un vrai moteur de rendu (§ 2.1, § 8.3 à § 8.9, +// § 9, § 14.5) : aucune section n'en exige une autre ; la place fixe de +// « Modifier », de « Débloquer » et de « Revenir en lecture », plein écran +// compris ; le cadre de mode et son libellé ; l'inactivité ; l'état bloqué +// et son déblocage confirmé en place ; les états du plan ; défaire et +// refaire ; les annonces d'ouverture, du journal et de la dérive ; le verrou +// d'une autre séance et sa reprise ; le support rompu ; le format plus +// récent. Chaque épreuve monte App par monterApp, sur le système de fichiers +// simulé, au cadre de 1 366 × 700, et lit le rendu : la boîte, l'élément +// qu'atteint un point, le style calculé, le texte — jamais une classe. +// +// Les éléments s'atteignent par leurs attributs data-… : data-place-fixe, la +// place fixe du cadre ; data-etat, un état du plan de l'en-tête ; +// data-verrou, le verrou d'une autre séance ; data-support-rompu, le message +// du support rompu. Le texte attendu vient de la même traduction que celle +// de l'application ; les noms d'épreuve sont inventés. +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import { flushSync } from 'svelte'; +import { TABLES_APPLICATION, libelle } from '../../application/libelles.js'; +import { INACTIVITE_MS } from '../../application/seance.js'; +import { creerTraduction } from '../../application/traduction.js'; +import { apercevoir } from '../../csv/apercu.js'; +import { FLUX, creerPcg32 } from '../../demo/prng.js'; +import { rechercher } from '../../moteur/recherche.js'; +import { serialiser } from '../../stockage/canonique.js'; +import { FORMAT, analyser, configurationDepuisCharge, etatDeduit } from '../../stockage/document.js'; +import { lireJournal } from '../../stockage/journal.js'; +import { SUFFIXES } from '../../stockage/noms.js'; +import { VERSION } from '../../version.genere.js'; +import { fixerCadre } from '../../../test/navigateur/entrees.js'; +import { monterApp } from '../../../test/navigateur/montage.js'; +import { visible } from '../../../test/navigateur/rendu.js'; +import { TABLES_INTERFACE } from '../libelles.js'; +import { CONFIRMATION_DEBLOCAGE, DELAI_CONFIRMATION_MS } from '../primitives/commande.js'; +import { SECTIONS } from './sections.js'; + +const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]); + +const NOMS = ['Ombrelle', 'Pervenche', 'Lacasse', 'Grisaille', 'Merlette', 'Fauvelle']; +const EVENEMENT = { nom: 'Club des Merles', date: null, siegesParDefaut: 3, tours: 2 }; +const DOCUMENTS = 'C:\\Users\\Exemple\\Documents\\Gestion table tournante Libre'; + +// Les montages de l'épreuve, démontés après elle ; le collecteur d'erreurs +// n'a rien reçu pendant l'épreuve. +let montes = []; +async function monter(options) { + const m = await monterApp(options); + montes.push(m); + return m; +} +beforeEach(async () => { + await fixerCadre(1366, 700); +}); +afterEach(() => { + const erreurs = montes.flatMap((m) => m.erreurs()); + for (const m of montes.reverse()) m.demonter(); + montes = []; + window.scrollTo(0, 0); + expect(erreurs, 'erreurs reçues par le collecteur').toEqual([]); +}); + +// --- Les lectures du rendu ---------------------------------------------------- + +const dans = (m, selecteur) => m.racine.querySelector(selecteur); +const tous = (m, selecteur) => [...m.racine.querySelectorAll(selecteur)]; +const cadre = (m) => dans(m, '[data-mode]'); +const fixe = (m, nom) => dans(m, `[data-place-fixe] [data-commande="${nom}"]`); +const historique = (m, nom) => dans(m, `[data-mode] [data-historique] [data-commande="${nom}"]`); +const boutonEtat = (m, etat) => dans(m, `[data-etat="${etat}"] [data-commande="etat"]`); +const libelleCadre = (m) => dans(m, '[data-mode-libelle="cadre"]'); +const libelleBandeau = (m) => dans(m, '[data-mode-libelle="bandeau"]'); +const avertissement = (m, code) => dans(m, `[data-avertissement="${code}"]`); +const ecran = (m) => dans(m, '[data-ecran]'); + +// Le coin haut gauche de la boîte d'un élément. +const coin = (element) => { + const { left, top } = element.getBoundingClientRect(); + return [left, top]; +}; + +// L'élément qu'atteint le centre de la boîte d'un élément. +const auCentre = (element) => { + const { left, top, width, height } = element.getBoundingClientRect(); + return document.elementFromPoint(left + width / 2, top + height / 2); +}; + +// Le refus posé à côté d'un bouton de Commande : dans son conteneur. +const refusPres = (bouton, code) => bouton.parentElement.querySelector(`[data-refus="${code}"]`); + +// Attend que l'assertion passe, le rendu de Svelte à jour. +async function attendre(assertion) { + for (let essai = 0; ; essai += 1) { + flushSync(); + try { + return assertion(); + } catch (erreur) { + if (essai >= 200) throw erreur; + await new Promise((resoudre) => requestAnimationFrame(resoudre)); + } + } +} + +// --- Les événements d'épreuve ----------------------------------------------------- + +const baseDe = (m) => m.seance.etat().evenement.base; +const fichier = (m, suffixe, racine = 'documents') => m.fs.contenu(racine, `${baseDe(m)}${suffixe}`); +const journalDe = (m, racine = 'documents') => lireJournal(fichier(m, SUFFIXES.journal, racine)); + +// Un montage où l'événement neuf « Club des Merles », zéro participant et +// zéro table, est ouvert en lecture. +async function neuf(options = {}) { + const m = await monter(options); + await m.seance.creer(EVENEMENT); + flushSync(); + return m; +} + +// L'événement ouvert en écriture, garni par la séance des six participants +// et de trois tables de trois places. +async function garnir(m) { + await m.seance.passerEnEcriture(); + for (const nom of NOMS) await m.seance.executer('ajouterParticipant', { champs: { nom } }); + for (let n = 0; n < 3; n += 1) await m.seance.executer('creerTable', { forme: 'ronde' }); + flushSync(); +} + +// Enregistre deux propositions du moteur, puis, quand retenir le veut, retient +// la première. +async function generer(m, { retenir = true } = {}) { + const charge = m.seance.etat().charge; + const configuration = configurationDepuisCharge(charge); + const propositions = rechercher(configuration, { graine: 7, arret: 40, nombre: 2 }); + await m.seance.executer('enregistrerGeneration', { propositions, configuration, produitVersion: VERSION.affichee }); + if (retenir) await m.seance.executer('retenirProposition', { id: m.seance.etat().charge.propositions[0].id }); + flushSync(); +} + +// Réécrit l'état d'un événement fermé : change(charge) le modifie en place, +// et l'état se sérialise à sa révision. +function reecrireEtat(m, base, change) { + const chemin = `${base}${SUFFIXES.etat}`; + const { entete, charge } = analyser(m.fs.contenu('documents', chemin)); + change(charge); + m.fs.deposer('documents', chemin, serialiser(charge, { revision: entete.revision, produitVersion: entete.produitVersion })); +} + +// --- Les épreuves ----------------------------------------------------------------- + +describe('aucun assistant (§ 2.1)', () => { + test('un événement neuf : chaque section s’ouvre depuis la navigation, dans un ordre tiré puis renversé, et rend son titre ; aucune n’est grisée, aucun « Suivant »', async () => { + const m = await neuf(); + const { charge } = m.seance.etat(); + expect([charge.participants.length, charge.tables.length]).toEqual([0, 0]); + const ordre = creerPcg32(20261006, FLUX.DEMO).melanger([...SECTIONS]); + expect(ordre.length).toBe(7); + for (const nom of [...ordre, ...[...ordre].reverse()]) { + const bouton = dans(m, `[data-commande="section"][data-cible="${nom}"]`); + expect(visible(bouton), nom).toBe(true); + bouton.click(); + flushSync(); + expect(m.ui.etat().section).toBe(nom); + const section = dans(m, `[data-section="${nom}"]`); + expect(section, nom).not.toBeNull(); + expect(section.textContent).toContain(t(`coquille.section.${nom}`)); + } + const boutons = tous(m, '[data-ecran="evenement"] button'); + expect(boutons.length).toBeGreaterThan(0); + for (const bouton of boutons) { + expect(bouton.disabled).toBe(false); + expect(bouton.getAttribute('aria-disabled')).toBeNull(); + } + expect(ecran(m).textContent).not.toMatch(/suivant/i); + }); +}); + +describe('la place fixe (§ 8.4, § 8.5)', () => { + test('« Modifier » aux mêmes coordonnées sur les sept sections ; plein écran, il reste visible et atteint ; « Revenir en lecture » puis « Débloquer » au même endroit', async () => { + const m = await neuf(); + const coins = []; + for (const nom of SECTIONS) { + m.ui.allerA(nom); + flushSync(); + expect(visible(fixe(m, 'modifier')), nom).toBe(true); + coins.push(coin(fixe(m, 'modifier'))); + } + expect(coins.length).toBe(7); + for (const lu of coins) expect(lu).toEqual(coins[0]); + // Un refus posé à côté d'un état, au-dessus de la surface, ne déplace + // pas la place fixe, et ne la couvre pas. + boutonEtat(m, 'bloque').click(); + await attendre(() => expect(dans(m, '[data-etat="bloque"] [data-refus="MODE_LECTURE"]')).not.toBeNull()); + expect(coin(fixe(m, 'modifier'))).toEqual(coins[0]); + expect(visible(fixe(m, 'modifier'))).toBe(true); + m.ui.allerA('plan'); + m.ui.basculerPleinEcran(); + flushSync(); + expect(visible(fixe(m, 'modifier'))).toBe(true); + expect(fixe(m, 'modifier').contains(auCentre(fixe(m, 'modifier')))).toBe(true); + expect(coin(fixe(m, 'modifier'))).toEqual(coins[0]); + expect(dans(m, '[data-commande="section"]')).toBeNull(); + m.ui.basculerPleinEcran(); + flushSync(); + await m.seance.passerEnEcriture(); + flushSync(); + expect(fixe(m, 'modifier')).toBeNull(); + expect(fixe(m, 'lecture').textContent).toBe(t('communs.commande.lecture')); + expect(coin(fixe(m, 'lecture'))).toEqual(coins[0]); + await m.seance.executer('changerEtat', { etat: 'bloque' }); + flushSync(); + expect(fixe(m, 'debloquer').textContent).toBe(t('communs.commande.debloquer')); + expect(coin(fixe(m, 'debloquer'))).toEqual(coins[0]); + expect(visible(fixe(m, 'debloquer'))).toBe(true); + }); +}); + +describe('un nom long ou de glyphes larges (point d’attention 3)', () => { + test('deux cents caractères sans espace : la page ne s’élargit pas, et « Modifier » garde sa place', async () => { + const m = await neuf(); + const place = coin(fixe(m, 'modifier')); + await m.seance.fermer(); + for (const motif of ['W', 'WW W', '\u{C6}\u{C6}\u{C6}', '\u{D4}jgq']) { + const nom = motif.repeat(Math.ceil(200 / motif.length)).slice(0, 200); + expect(nom.length).toBe(200); + await m.seance.creer({ ...EVENEMENT, nom }); + flushSync(); + const titre = dans(m, '[data-ecran="evenement"] h1'); + expect(titre.textContent).toBe(nom); + expect(document.documentElement.scrollWidth, motif).toBeLessThanOrEqual(window.innerWidth); + expect(titre.getBoundingClientRect().right, motif).toBeLessThanOrEqual(window.innerWidth); + expect(coin(fixe(m, 'modifier')), motif).toEqual(place); + expect(visible(fixe(m, 'modifier')), motif).toBe(true); + await m.seance.fermer(); + } + }); +}); + +describe('passer en écriture (§ 8.4, § 19.3 01b)', () => { + test('avant « Modifier », « Bloqué » est refusé à côté de l’état avec « Modifier », sans rien écrire ; après, il écrit une entrée, un jalon, et la séance revient en lecture', async () => { + const m = await neuf(); + const avant = m.trace.length; + boutonEtat(m, 'bloque').click(); + const refus = await attendre(() => { + const element = dans(m, '[data-etat="bloque"] [data-refus="MODE_LECTURE"]'); + expect(element).not.toBeNull(); + return element; + }); + expect(refus.textContent).toContain(t('MODE_LECTURE', {})); + expect(refus.querySelector('[data-commande="modifier"]').textContent).toBe(t('communs.remede.modifier')); + expect(visible(refus)).toBe(true); + expect(m.trace.length).toBe(avant); + fixe(m, 'modifier').click(); + await attendre(() => expect(m.seance.etat().mode).toBe('ecriture')); + expect(fichier(m, SUFFIXES.verrou)).not.toBeNull(); + // Sur un brouillon neuf, seul « Bloqué » pose un état : les autres sont + // refusés à côté d'eux, sans rien écrire. + const ecrites = m.trace.length; + for (const [etat, code] of [ + ['brouillon', 'SANS_EFFET'], + ['propose', 'AUCUNE_PROPOSITION'], + ['retenu', 'AUCUN_RETENU'], + ]) { + boutonEtat(m, etat).click(); + await attendre(() => expect(refusPres(boutonEtat(m, etat), code)).not.toBeNull()); + } + expect(m.trace.length).toBe(ecrites); + const { entrees, jalons } = journalDe(m); + boutonEtat(m, 'bloque').click(); + await attendre(() => expect(m.seance.etat().evenement.etat).toBe('bloque')); + await attendre(() => expect(m.seance.etat().mode).toBe('lecture')); + const apres = journalDe(m); + expect(apres.entrees.length).toBe(entrees.length + 1); + expect(apres.jalons.length).toBe(jalons.length + 1); + expect(fichier(m, SUFFIXES.verrou)).toBeNull(); + expect(fixe(m, 'debloquer')).not.toBeNull(); + }); +}); + +describe('le cadre de mode (§ 8.4, § 14.5)', () => { + test('il contient la surface, sa bordure n’est pas nulle, son libellé se voit ; texte et trait diffèrent en lecture, en écriture et bloqué ; même boîte après défilement', async () => { + const m = await neuf(); + const releves = []; + const relever = () => { + flushSync(); + const boite = cadre(m).getBoundingClientRect(); + const surface = dans(m, '[data-section]').getBoundingClientRect(); + expect(boite.left).toBeLessThanOrEqual(surface.left); + expect(boite.top).toBeLessThanOrEqual(surface.top); + expect(boite.right).toBeGreaterThanOrEqual(surface.right); + expect(boite.bottom).toBeGreaterThanOrEqual(surface.bottom); + const style = getComputedStyle(cadre(m)); + expect(Number.parseFloat(style.borderTopWidth)).toBeGreaterThan(0); + expect(style.borderTopStyle).not.toBe('none'); + const libelle = libelleCadre(m); + expect(libelle.checkVisibility()).toBe(true); + expect(visible(libelle)).toBe(true); + expect(libelle.contains(auCentre(libelle))).toBe(true); + expect(libelle.textContent).toBe(libelleBandeau(m).textContent); + releves.push({ texte: libelle.textContent, trait: style.borderTopStyle }); + }; + relever(); + await m.seance.passerEnEcriture(); + relever(); + await m.seance.executer('changerEtat', { etat: 'bloque' }); + relever(); + expect(releves.map(({ texte }) => texte)).toEqual([ + t('coquille.mode.lecture'), + t('coquille.mode.ecriture'), + t('coquille.mode.bloque'), + ]); + expect(new Set(releves.map(({ trait }) => trait)).size).toBe(3); + // Une surface plus haute que la fenêtre : la page et la surface + // défilées jusqu'en bas, le libellé garde sa boîte. + const avant = libelleCadre(m).getBoundingClientRect().toJSON(); + const haut = document.createElement('div'); + haut.style.height = '3000px'; + dans(m, '[data-section]').append(haut); + window.scrollTo(0, document.documentElement.scrollHeight); + for (let porteur = dans(m, '[data-section]'); porteur !== null; porteur = porteur.parentElement) { + porteur.scrollTop = porteur.scrollHeight; + } + expect(libelleCadre(m).getBoundingClientRect().toJSON()).toEqual(avant); + expect(visible(libelleCadre(m))).toBe(true); + expect(visible(fixe(m, 'debloquer'))).toBe(true); + haut.remove(); + }); +}); + +describe('l’inactivité (§ 8.4, § 19.4)', () => { + for (const [ouvrir, fermer] of [ + ['debuterGeste', 'finirGeste'], + ['debuterCalcul', 'finirCalcul'], + ]) { + test(`${ouvrir} ouvert, onze minutes ne ramènent pas en lecture ; ${fermer}, dix minutes y ramènent : verrou rendu, cadre, bandeau et avertissement le disent`, async () => { + const m = await neuf(); + fixe(m, 'modifier').click(); + await attendre(() => expect(m.seance.etat().mode).toBe('ecriture')); + m.seance[ouvrir](); + m.horloge.avancer(11 * 60 * 1000); + flushSync(); + expect(m.seance.etat().mode).toBe('ecriture'); + m.seance[fermer](); + m.horloge.avancer(INACTIVITE_MS); + await attendre(() => expect(m.seance.etat().mode).toBe('lecture')); + expect(fichier(m, SUFFIXES.verrou)).toBeNull(); + expect(libelleCadre(m).textContent).toBe(t('coquille.mode.lecture')); + expect(libelleBandeau(m).textContent).toBe(t('coquille.mode.lecture')); + const annonce = avertissement(m, 'RETOUR_AUTOMATIQUE'); + expect(annonce.textContent).toContain(t('RETOUR_AUTOMATIQUE', {})); + expect(visible(annonce)).toBe(true); + expect(fixe(m, 'modifier')).not.toBeNull(); + }); + } +}); + +describe('bloqué (§ 8.5, § 9)', () => { + test('« Bloquer » rend le verrou ; une autre séance le lit bloqué ; ni « Modifier », ni « Retenu », ni « Défaire » ne débloquent ; « Débloquer » se confirme en place, puis écrit', async () => { + const m = await neuf(); + await garnir(m); + boutonEtat(m, 'bloque').click(); + await attendre(() => expect(m.seance.etat().mode).toBe('lecture')); + expect(m.seance.etat().evenement.etat).toBe('bloque'); + expect(fichier(m, SUFFIXES.verrou)).toBeNull(); + expect(fixe(m, 'debloquer')).not.toBeNull(); + const base = baseDe(m); + const autre = await monter({ systeme: { disque: m.disque } }); + await autre.seance.ouvrir(base); + flushSync(); + expect(autre.seance.etat().evenement.etat).toBe('bloque'); + expect(fixe(autre, 'debloquer').textContent).toBe(t('communs.commande.debloquer')); + expect(fixe(autre, 'modifier')).toBeNull(); + + const avant = m.trace.length; + expect(fixe(m, 'modifier')).toBeNull(); + boutonEtat(m, 'retenu').click(); + await attendre(() => expect(refusPres(boutonEtat(m, 'retenu'), 'LECTURE_SEULE')).not.toBeNull()); + historique(m, 'defaire').click(); + const refusDefaire = await attendre(() => { + const element = refusPres(historique(m, 'defaire'), 'LECTURE_SEULE'); + expect(element).not.toBeNull(); + return element; + }); + expect(refusDefaire.querySelector('[data-commande="debloquer"]').textContent).toBe(t('communs.remede.debloquer')); + expect(m.seance.etat().evenement.etat).toBe('bloque'); + expect(m.trace.length).toBe(avant); + + // Un clic arme, sans rien écrire ; l'horloge le désarme au terme du délai. + fixe(m, 'debloquer').click(); + flushSync(); + expect(fixe(m, 'debloquer').textContent).toBe(t(CONFIRMATION_DEBLOCAGE)); + m.horloge.avancer(DELAI_CONFIRMATION_MS); + flushSync(); + expect(fixe(m, 'debloquer').textContent).toBe(t('communs.commande.debloquer')); + fixe(m, 'debloquer').click(); + flushSync(); + expect(m.trace.length).toBe(avant); + expect(m.seance.etat().evenement.etat).toBe('bloque'); + const { entrees, jalons } = journalDe(m); + fixe(m, 'debloquer').click(); + await attendre(() => expect(m.seance.etat().mode).toBe('ecriture')); + const { charge } = m.seance.etat(); + expect(charge.evenement.etat).toBe(etatDeduit(charge)); + expect(journalDe(m).entrees.length).toBe(entrees.length + 1); + expect(journalDe(m).jalons.length).toBe(jalons.length + 1); + expect(fixe(m, 'lecture')).not.toBeNull(); + }); +}); + +describe('les états du plan (§ 9)', () => { + test('avec des propositions et un retenu, « Brouillon » puis « Retenu » : rien d’autre ne change, deux entrées, deux jalons, libellés exacts', async () => { + const m = await neuf(); + await garnir(m); + await generer(m); + const avant = m.seance.etat().charge; + expect(avant.evenement.etat).toBe('retenu'); + const { entrees, jalons } = journalDe(m); + boutonEtat(m, 'brouillon').click(); + await attendre(() => expect(m.seance.etat().evenement.etat).toBe('brouillon')); + boutonEtat(m, 'retenu').click(); + await attendre(() => expect(m.seance.etat().evenement.etat).toBe('retenu')); + const apres = m.seance.etat().charge; + expect(apres).toEqual(avant); + const journal = journalDe(m); + expect(journal.entrees.slice(entrees.length).map((entree) => entree.libelle)).toEqual([ + libelle('ENTREE_CHANGER_ETAT', { etat: 'brouillon' }), + libelle('ENTREE_CHANGER_ETAT', { etat: 'retenu' }), + ]); + expect(journal.jalons.length).toBe(jalons.length + 2); + expect(journal.jalons.slice(-2).map(({ nom }) => nom)).toEqual([ + libelle('JALON_ETAT', { etat: 'brouillon' }), + libelle('JALON_ETAT', { etat: 'retenu' }), + ]); + }); + + test('« Retenu » sans retenu : refusé à côté de l’état, avec la section des propositions pour remède', async () => { + const m = await neuf(); + fixe(m, 'modifier').click(); + await attendre(() => expect(m.seance.etat().mode).toBe('ecriture')); + const avant = m.trace.length; + boutonEtat(m, 'retenu').click(); + const refus = await attendre(() => { + const element = refusPres(boutonEtat(m, 'retenu'), 'AUCUN_RETENU'); + expect(element).not.toBeNull(); + return element; + }); + expect(visible(refus)).toBe(true); + const remede = refus.querySelector('[data-commande="ouvrirSection"]'); + expect(remede.textContent).toBe(t('communs.remede.ouvrirSection.propositions')); + remede.click(); + await attendre(() => expect(m.ui.etat().section).toBe('propositions')); + expect(m.trace.length).toBe(avant); + }); +}); + +describe('défaire, refaire (§ 8.3)', () => { + const nombre = (m) => m.seance.etat().charge.participants.length; + + test('trois gestes, « Défaire » rend le deuxième et offre « Refaire » ; « Refaire » rend le troisième ; un geste après « Défaire » retire « Refaire »', async () => { + const m = await neuf(); + await m.seance.passerEnEcriture(); + for (const nom of NOMS.slice(0, 3)) await m.seance.executer('ajouterParticipant', { champs: { nom } }); + flushSync(); + expect(historique(m, 'refaire')).toBeNull(); + historique(m, 'defaire').click(); + await attendre(() => expect(nombre(m)).toBe(2)); + expect(historique(m, 'refaire').textContent).toBe(t('evenement.commande.refaire')); + historique(m, 'refaire').click(); + await attendre(() => expect(nombre(m)).toBe(3)); + historique(m, 'defaire').click(); + await attendre(() => expect(nombre(m)).toBe(2)); + expect(historique(m, 'refaire')).not.toBeNull(); + await m.seance.executer('ajouterParticipant', { champs: { nom: NOMS[3] } }); + await attendre(() => expect(historique(m, 'refaire')).toBeNull()); + }); + + test('un retour de 5 à 2, puis « Défaire » : la cible est le prédécesseur de 2 sur le fil, non la ligne précédente', async () => { + const m = await neuf(); + await m.seance.passerEnEcriture(); + for (const nom of NOMS.slice(0, 4)) await m.seance.executer('ajouterParticipant', { champs: { nom } }); + expect(m.seance.etat().evenement.revision).toBe(5); + await m.seance.revenir(2); + flushSync(); + expect(nombre(m)).toBe(1); + historique(m, 'defaire').click(); + await attendre(() => expect(nombre(m)).toBe(0)); + }); + + test('en lecture, « Défaire » est refusé à côté de lui avec « Modifier » ; bloqué, avec « Débloquer » ; rien ne s’écrit', async () => { + const m = await neuf(); + await m.seance.passerEnEcriture(); + await m.seance.executer('ajouterParticipant', { champs: { nom: NOMS[0] } }); + await m.seance.passerEnLecture(); + flushSync(); + const avant = m.trace.length; + historique(m, 'defaire').click(); + const enLecture = await attendre(() => { + const element = refusPres(historique(m, 'defaire'), 'MODE_LECTURE'); + expect(element).not.toBeNull(); + return element; + }); + expect(enLecture.querySelector('[data-commande="modifier"]').textContent).toBe(t('communs.remede.modifier')); + expect(m.trace.length).toBe(avant); + expect(nombre(m)).toBe(1); + await m.seance.passerEnEcriture(); + await m.seance.executer('changerEtat', { etat: 'bloque' }); + flushSync(); + const bloque = m.trace.length; + historique(m, 'defaire').click(); + const refus = await attendre(() => { + const element = refusPres(historique(m, 'defaire'), 'LECTURE_SEULE'); + expect(element).not.toBeNull(); + return element; + }); + expect(refus.querySelector('[data-commande="debloquer"]')).not.toBeNull(); + expect(m.trace.length).toBe(bloque); + }); +}); + +describe('les annonces d’ouverture (§ 8.9, § 9)', () => { + test('une proposition de longueur fausse et une périmée : l’écartée nomme son numéro et sa faute ; la dérive nomme la périmée et sa raison, et la charge la garde', async () => { + const m = await neuf(); + await garnir(m); + await generer(m, { retenir: false }); + const base = baseDe(m); + const [premiere, seconde] = m.seance.etat().charge.propositions.map(({ id }) => id); + await m.seance.fermer(); + reecrireEtat(m, base, (charge) => { + charge.propositions[0].placement.pop(); + charge.participants[0].exclu = true; + }); + await m.seance.ouvrir(base); + flushSync(); + const etat = m.seance.etat(); + expect(etat.fautives.length).toBe(1); + const [fautive] = etat.fautives; + expect(fautive.id).toBe(premiere); + const ecartee = avertissement(m, 'PROPOSITIONS_FAUTIVES'); + expect(visible(ecartee)).toBe(true); + expect(ecartee.textContent).toContain(t('PROPOSITIONS_FAUTIVES', { ids: [fautive.id] })); + expect(fautive.fautes.length).toBeGreaterThan(0); + for (const faute of fautive.fautes) expect(ecartee.textContent).toContain(t(faute.code, faute)); + expect(ecartee.querySelector('[data-fautive]').textContent).toContain(String(fautive.id)); + const derive = avertissement(m, 'DERIVE'); + expect(derive.textContent).toContain(t('DERIVE', { ids: [seconde], retenu: false })); + const [{ raisons }] = etat.examen.derives; + expect(raisons.length).toBeGreaterThan(0); + for (const raison of raisons) expect(derive.textContent).toContain(t(raison.code, raison)); + expect(etat.charge.propositions.map(({ id }) => id)).toEqual([seconde]); + derive.querySelector('[data-commande="ouvrirSection"]').click(); + await attendre(() => expect(m.ui.etat().section).toBe('propositions')); + }); + + test('un retenu fautif : son message vient en premier et offre « Ouvrir la version précédente », qui l’ouvre en lecture sans rien écrire', async () => { + const m = await neuf(); + await garnir(m); + await generer(m); + const base = baseDe(m); + await m.seance.fermer(); + reecrireEtat(m, base, (charge) => { + charge.retenu.placement.pop(); + }); + await m.seance.ouvrir(base); + flushSync(); + const premier = dans(m, '[data-avertissement]'); + expect(premier.dataset.avertissement).toBe('RETENU_FAUTIF'); + expect(premier.textContent).toContain(t('RETENU_FAUTIF', {})); + const remede = premier.querySelector('[data-commande="ouvrirSecours"]'); + expect(remede.textContent).toBe(t('communs.remede.ouvrirSecours.precedent')); + const avant = m.trace.length; + remede.click(); + await attendre(() => expect(m.seance.etat().examen.retenu.fautes).toEqual([])); + expect(m.seance.etat().mode).toBe('lecture'); + expect(m.seance.etat().charge.retenu).toBeNull(); + expect(avertissement(m, 'RETENU_FAUTIF')).toBeNull(); + expect(m.trace.length).toBe(avant); + }); +}); + +describe('le journal (§ 8.6, § 8.2)', () => { + test('cent entrées, la 37ᵉ abîmée : JOURNAL_ECARTEES {n: 64} s’affiche, son texte porte « 64 »', async () => { + const m = await neuf(); + await m.seance.passerEnEcriture(); + for (let n = 2; n <= 100; n += 1) await m.seance.executer('ajouterParticipant', { champs: { nom: `Ombrelle ${n}` } }); + const base = baseDe(m); + const journal = journalDe(m); + expect(journal.entrees.length).toBe(100); + expect(journal.jalons.length).toBe(1); + await m.seance.fermer(); + const chemin = `${base}${SUFFIXES.journal}`; + const lignes = m.fs.contenu('documents', chemin).split('\n'); + const rang = lignes.findIndex((ligne) => ligne.startsWith('{"type":"entree"') && JSON.parse(ligne).revision === 37); + expect(rang).toBeGreaterThan(0); + lignes[rang] = '{"type":"entree", abîmée'; + m.fs.deposer('documents', chemin, lignes.join('\n')); + const avant = m.trace.length; + await m.seance.ouvrir(base); + flushSync(); + const ecartees = avertissement(m, 'JOURNAL_ECARTEES'); + expect(ecartees.textContent).toContain(t('JOURNAL_ECARTEES', { n: 64 })); + expect(ecartees.textContent).toContain('64'); + expect(visible(ecartees)).toBe(true); + expect(m.trace.length).toBe(avant); + }); + + test('un journal en avance d’une entrée : la charge du geste est celle qui s’ouvre, annoncée, sans rien écrire', async () => { + const m = await neuf(); + await m.seance.passerEnEcriture(); + await m.seance.executer('ajouterParticipant', { champs: { nom: NOMS[0] } }); + await m.seance.executer('ajouterParticipant', { champs: { nom: NOMS[1] } }); + const base = baseDe(m); + await m.seance.fermer(); + m.fs.deposer('documents', `${base}${SUFFIXES.etat}`, m.fs.contenu('documents', `${base}${SUFFIXES.precedent}`)); + const avant = m.trace.length; + await m.seance.ouvrir(base); + flushSync(); + expect(m.seance.etat().charge.participants.map(({ nom }) => nom)).toEqual(NOMS.slice(0, 2)); + expect(m.seance.etat().evenement.revision).toBe(3); + expect(avertissement(m, 'JOURNAL_REPRIS').textContent).toContain(t('JOURNAL_REPRIS', {})); + expect(m.trace.length).toBe(avant); + }); +}); + +describe('la dérive (§ 9)', () => { + test('retenu, puis exclure une personne : l’avertissement nomme le retenu et la raison, et le geste suivant reste permis', async () => { + const m = await neuf(); + await garnir(m); + await generer(m); + const exclue = m.seance.etat().charge.participants[0].id; + await m.seance.executer('exclureParticipant', { id: exclue }); + flushSync(); + const nouvelle = m.seance.etat().avertissements.find(({ code }) => code === 'DERIVE_NOUVELLE'); + expect(nouvelle.details.touchees.map(({ id }) => id)).toContain('retenu'); + const element = avertissement(m, 'DERIVE_NOUVELLE'); + expect(element.textContent).toContain(t('DERIVE_NOUVELLE', nouvelle.details)); + expect(element.textContent).toContain(t('PARTICIPANT_EXCLU', { participant: exclue })); + await m.seance.executer('ajouterParticipant', { champs: { nom: 'Rouvraie' } }); + expect(m.seance.etat().charge.participants.length).toBe(NOMS.length + 1); + }); + + test('retenu : supprimer une table, réduire une capacité, ajouter une personne, chacun accepté, l’avertissement nommant les propositions touchées ; leur nombre inchangé ; un import, IMPORT_EN_RETENU', async () => { + const m = await neuf(); + await garnir(m); + await generer(m); + const propositions = m.seance.etat().charge.propositions.length; + const [premiere, seconde] = m.seance.etat().charge.tables; + const gestes = [ + ['supprimerTable', { id: premiere.id }], + ['changerPlacesTable', { id: seconde.id, sieges: 2, origine: 'champ' }], + ['ajouterParticipant', { champs: { nom: 'Escarbille' } }], + ]; + for (const [nom, args] of gestes) { + await m.seance.executer(nom, args); + flushSync(); + const nouvelle = m.seance.etat().avertissements.find(({ code }) => code === 'DERIVE_NOUVELLE'); + expect(nouvelle, nom).toBeDefined(); + expect(nouvelle.details.touchees.length, nom).toBeGreaterThan(0); + expect(avertissement(m, 'DERIVE_NOUVELLE').textContent).toContain(t('DERIVE_NOUVELLE', nouvelle.details)); + expect(m.seance.etat().charge.propositions.length).toBe(propositions); + } + const apercu = apercevoir('nom\nTilleraie\n', { participants: m.seance.etat().charge.participants }); + await m.seance.executer('importerParticipants', { apercu, mode: 'ajouter' }); + flushSync(); + expect(avertissement(m, 'IMPORT_EN_RETENU').textContent).toContain(t('IMPORT_EN_RETENU', {})); + }); +}); + +describe('le verrou d’une autre séance (§ 8.8)', () => { + test('le second montage ouvre en lecture ; son « Modifier » est refusé, la séance et l’instant affichés ; le premier coupé, il reprend le verrou du mort', async () => { + const premier = await neuf(); + await premier.seance.passerEnEcriture(); + const base = baseDe(premier); + const tenu = JSON.parse(fichier(premier, SUFFIXES.verrou)); + const second = await monter({ systeme: { disque: premier.disque } }); + // Les deux montages s'empilent dans la page : le premier se cache, et + // le second occupe la fenêtre. + premier.racine.hidden = true; + await second.seance.ouvrir(base); + flushSync(); + expect(second.seance.etat().mode).toBe('lecture'); + expect(dans(second, '[data-refus]')).toBeNull(); + fixe(second, 'modifier').click(); + const refus = await attendre(() => { + const element = refusPres(fixe(second, 'modifier'), 'VERROU_PRIS'); + expect(element).not.toBeNull(); + return element; + }); + const pris = second.seance.etat().verrouAutre; + expect(pris).toEqual({ seance: tenu.seance, depuis: tenu.depuis, vivant: true }); + expect(refus.textContent).toContain(t('VERROU_PRIS', { ...pris, remede: null })); + const verrou = dans(second, '[data-verrou]'); + expect(visible(verrou)).toBe(true); + expect(dans(second, '[data-verrou-seance]').textContent).toBe(tenu.seance); + expect(dans(second, '[data-verrou-depuis]').textContent).toBe(tenu.depuis); + expect(verrou.querySelector('[data-commande="reprendreVerrou"]')).toBeNull(); + premier.fs.pannes.couperApres(0); + fixe(second, 'modifier').click(); + await attendre(() => expect(second.seance.etat().mode).toBe('ecriture')); + const repris = avertissement(second, 'VERROU_REPRIS'); + expect(repris.textContent).toContain(t('VERROU_REPRIS', { seance: tenu.seance, depuis: tenu.depuis })); + expect(JSON.parse(fichier(second, SUFFIXES.verrou)).seance).toBe(second.seance.id); + expect(dans(second, '[data-verrou]')).toBeNull(); + }); + + test('le verrou d’un autre poste : la reprise s’offre à côté du refus, se confirme en place en nommant la séance et l’instant, puis le reprend', async () => { + const premier = await neuf(); + await premier.seance.passerEnEcriture(); + const base = baseDe(premier); + const tenu = JSON.parse(fichier(premier, SUFFIXES.verrou)); + const second = await monter({ systeme: { disque: premier.disque, hote: 'poste-voisin' } }); + premier.racine.hidden = true; + await second.seance.ouvrir(base); + flushSync(); + fixe(second, 'modifier').click(); + await attendre(() => expect(refusPres(fixe(second, 'modifier'), 'VERROU_PRIS')).not.toBeNull()); + expect(second.seance.etat().verrouAutre.vivant).toBeNull(); + const verrou = dans(second, '[data-verrou]'); + expect(dans(second, '[data-verrou-seance]').textContent).toBe(tenu.seance); + expect(dans(second, '[data-verrou-depuis]').textContent).toBe(tenu.depuis); + const reprendre = () => dans(second, '[data-verrou] [data-commande="reprendreVerrou"]'); + expect(reprendre().textContent).toBe(t('communs.remede.reprendreVerrou')); + expect(visible(reprendre())).toBe(true); + const avant = second.trace.length; + reprendre().click(); + flushSync(); + expect(reprendre().textContent).toBe(t('evenement.verrou.confirmerReprise')); + expect(verrou.contains(reprendre())).toBe(true); + expect(second.trace.length).toBe(avant); + expect(second.seance.etat().mode).toBe('lecture'); + reprendre().click(); + await attendre(() => expect(second.seance.etat().mode).toBe('ecriture')); + expect(JSON.parse(fichier(second, SUFFIXES.verrou)).seance).toBe(second.seance.id); + expect(avertissement(second, 'VERROU_REPRIS').textContent).toContain( + t('VERROU_REPRIS', { seance: tenu.seance, depuis: tenu.depuis }), + ); + expect(dans(second, '[data-verrou]')).toBeNull(); + }); +}); + +describe('le support rompu (§ 8.6, § 8.2)', () => { + const message = (m) => dans(m, '[data-support-rompu]'); + const commandeListe = (m) => dans(m, '[data-bandeau] [data-commande="liste"]'); + + // L'événement ouvert en écriture, puis le support de racine refuse + // l'écriture d'un geste. + async function rompre(m, racine) { + fixe(m, 'modifier').click(); + await attendre(() => expect(m.seance.etat().mode).toBe('ecriture')); + m.fs.pannes.ecriture(racine); + await expect(m.seance.executer('ajouterParticipant', { champs: { nom: NOMS[0] } })).rejects.toMatchObject({ code: 'ECRITURE' }); + flushSync(); + } + + test('publié : le message nomme le fichier, le dossier et la rupture ; « Liste » est refusée ; « Écrire dans les Documents » y écrit, et la séance s’y poursuit', async () => { + const m = await neuf({ systeme: { executable: 'E:\\soirees' } }); + expect(m.seance.etat().racine.id).toBe('portable'); + const base = baseDe(m); + await rompre(m, 'portable'); + const rompu = m.seance.etat().supportRompu; + expect(rompu.ailleurs).toEqual({ id: 'documents', chemin: DOCUMENTS }); + expect(visible(message(m))).toBe(true); + const { ailleurs, ...details } = rompu; + expect(message(m).textContent).toContain(t('SUPPORT_ROMPU', details)); + expect(message(m).textContent).toContain(rompu.chemin); + expect(message(m).textContent).toContain(rompu.dossier); + await expect(m.seance.executer('ajouterParticipant', { champs: { nom: NOMS[1] } })).rejects.toMatchObject({ + code: 'SUPPORT_ROMPU', + }); + // Le refus posé dans le bandeau ne déplace pas la place fixe, et ne la + // couvre pas. + const place = coin(fixe(m, 'lecture')); + commandeListe(m).click(); + await attendre(() => expect(dans(m, '[data-refus="liste"] [data-refus="SUPPORT_ROMPU_NON_SAUVE"]')).not.toBeNull()); + expect(coin(fixe(m, 'lecture'))).toEqual(place); + expect(visible(fixe(m, 'lecture'))).toBe(true); + expect(visible(dans(m, '[data-refus="liste"]'))).toBe(true); + expect(m.seance.etat().evenement.base).toBe(base); + expect(message(m).querySelector('[data-commande="choisirDossier"]')).not.toBeNull(); + message(m).querySelector('[data-commande="ecrireDocuments"]').click(); + await attendre(() => expect(m.seance.etat().supportRompu).toBeNull()); + const nouvelle = baseDe(m); + expect(m.fs.contenu('documents', `${nouvelle}${SUFFIXES.etat}`)).not.toBeNull(); + const entrees = journalDe(m, 'documents').entrees.length; + await m.seance.executer('ajouterParticipant', { champs: { nom: NOMS[2] } }); + expect(journalDe(m, 'documents').entrees.length).toBe(entrees + 1); + expect(message(m)).toBeNull(); + commandeListe(m).click(); + await attendre(() => expect(ecran(m).dataset.ecran).toBe('accueil')); + expect(m.seance.etat().racine.id).toBe('documents'); + await attendre(() => expect(dans(m, `[data-evenement="${nouvelle}"]`)).not.toBeNull()); + const dossier = dans(m, '[data-dossier-travail]'); + expect(dossier.textContent).toBe(DOCUMENTS); + expect(visible(dossier)).toBe(true); + }); + + test('non publié : le message n’offre que « Choisir un dossier… » ; le dialogue annulé ne change rien', async () => { + const m = await neuf(); + await rompre(m, 'documents'); + expect(m.seance.etat().supportRompu.ailleurs).toBeNull(); + expect(message(m).querySelector('[data-commande="ecrireDocuments"]')).toBeNull(); + const choisir = message(m).querySelector('[data-commande="choisirDossier"]'); + expect(choisir.textContent).toBe(t('communs.commande.choisirDossier')); + choisir.click(); + await new Promise((resoudre) => requestAnimationFrame(resoudre)); + await attendre(() => expect(m.seance.etat().supportRompu).not.toBeNull()); + expect(visible(message(m))).toBe(true); + }); + + test('non publié, « Fermer sans écrire ailleurs », confirmé en place : la liste revient, sans aucune écriture', async () => { + const m = await neuf(); + await rompre(m, 'documents'); + const avant = m.trace.length; + const abandonner = () => message(m).querySelector('[data-commande="abandonner"]'); + expect(abandonner().textContent).toBe(t('evenement.supportRompu.abandonner')); + abandonner().click(); + flushSync(); + expect(abandonner().textContent).toBe(t('evenement.supportRompu.confirmerAbandon')); + expect(m.seance.etat().evenement).not.toBeNull(); + abandonner().click(); + await attendre(() => expect(ecran(m).dataset.ecran).toBe('accueil')); + expect(m.seance.etat().evenement).toBeNull(); + expect(m.trace.slice(avant).filter(([primitive]) => primitive !== 'deverrouiller')).toEqual([]); + }); + + test('non publié, un dossier choisi : état et journal y sont écrits, le geste suivant aussi, et la liste le suit', async () => { + const m = await neuf({ systeme: { dossiersChoisis: ['D:\\Secours'] } }); + await rompre(m, 'documents'); + message(m).querySelector('[data-commande="choisirDossier"]').click(); + await attendre(() => expect(m.seance.etat().supportRompu).toBeNull()); + expect(m.seance.etat().racine).toEqual({ id: 'choisi-1', chemin: 'D:\\Secours' }); + const base = baseDe(m); + expect(m.fs.contenu('choisi-1', `${base}${SUFFIXES.etat}`)).not.toBeNull(); + const entrees = journalDe(m, 'choisi-1').entrees.length; + await m.seance.executer('ajouterParticipant', { champs: { nom: NOMS[3] } }); + expect(journalDe(m, 'choisi-1').entrees.length).toBe(entrees + 1); + commandeListe(m).click(); + await attendre(() => expect(ecran(m).dataset.ecran).toBe('accueil')); + await attendre(() => expect(dans(m, `[data-evenement="${base}"]`)).not.toBeNull()); + }); +}); + +describe('le format plus récent (§ 8.8)', () => { + test('« Modifier » est remplacé par l’explication, et aucune commande de l’événement ne passe en écriture', async () => { + const m = await neuf(); + const base = baseDe(m); + await m.seance.fermer(); + const chemin = `${base}${SUFFIXES.etat}`; + const texte = m.fs.contenu('documents', chemin); + m.fs.deposer('documents', chemin, texte.replace(`"format":${FORMAT}`, `"format":${FORMAT + 1}`)); + await m.seance.ouvrir(base); + flushSync(); + expect(fixe(m, 'modifier')).toBeNull(); + const explication = dans(m, '[data-place-fixe] [data-refus="LECTURE_SEULE"]'); + expect(explication.textContent).toContain(t('LECTURE_SEULE', { raison: 'FORMAT', remede: null })); + expect(visible(explication)).toBe(true); + const avant = m.trace.length; + const boutons = tous(m, '[data-ecran="evenement"] button').filter((bouton) => bouton.dataset.commande !== 'section'); + expect(boutons.length).toBeGreaterThan(0); + for (const bouton of boutons) { + if (!bouton.isConnected) continue; + bouton.click(); + bouton.click(); + await new Promise((resoudre) => requestAnimationFrame(resoudre)); + } + await attendre(() => expect(m.seance.etat().mode).toBe('lecture')); + expect(m.trace.length).toBe(avant); + }); +}); diff --git a/src/interface/evenement/Evenement.svelte b/src/interface/evenement/Evenement.svelte index 7082dec..e692746 100644 --- a/src/interface/evenement/Evenement.svelte +++ b/src/interface/evenement/Evenement.svelte @@ -2,9 +2,22 @@ License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) --> -
-

{$seance.evenement?.nom}

+{#snippet tete()} + -
+{/snippet} + +
+ + {#if $seance.supportRompu !== null} + + {/if} + +
+
diff --git a/src/interface/evenement/modele.js b/src/interface/evenement/modele.js new file mode 100644 index 0000000..44176d8 --- /dev/null +++ b/src/interface/evenement/modele.js @@ -0,0 +1,134 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Le modèle de l'événement ouvert, pur : ce que ses composants montrent de +// l'état observable de la séance (§ 8.4, § 8.5, § 8.8, § 8.9, § 9). Le cadre +// de mode et sa forme, la commande de la place fixe, les états du plan et +// leurs clés, la reprise de verrou que l'écran offre, et les avertissements +// dans l'ordre de la séance, chacun avec son remède. Rien n'y est décidé +// que la séance ne décide déjà : le refus courant, le mode et l'état du plan +// se lisent dans EtatSeance, jamais se recalculent. +import { cleDeMode } from '../mode.js'; + +// La forme du cadre pour chaque clé du libellé de mode : un trait différent +// par mode, pour qu'aucun ne se distingue par la seule couleur (§ 14.5). +const FORMES = new Map([ + ['coquille.mode.lecture', 'tirets'], + ['coquille.mode.ecriture', 'plein'], + ['coquille.mode.bloque', 'double'], +]); + +/** + * Libellé et forme du cadre de mode : cle, celle de cleDeMode (mode.js) — le + * libellé du bandeau — ; forme : lecture (tirets), écriture (trait plein), + * bloqué (trait double), quel que soit le mode d'un plan bloqué. null sans + * événement. + * + * @param {Object} etatSeance EtatSeance + * @returns {null|{cle: string, forme: 'tirets'|'plein'|'double'}} + */ +export function cadreDeMode(etatSeance) { + const cle = cleDeMode(etatSeance); + if (cle === null) return null; + return { cle, forme: FORMES.get(cle) }; +} + +/** + * La commande de la place fixe (§ 8.4, § 8.5, § 8.8) : 'format' quand le + * refus courant est celui d'un format plus récent — l'explication remplace + * alors toute commande, plan bloqué compris ; 'debloquer' sur un plan + * bloqué ; 'lecture' — « Revenir en lecture » — en écriture ; 'modifier' + * sinon. null sans événement. + * + * @param {Object} etatSeance EtatSeance + * @returns {null|'format'|'debloquer'|'lecture'|'modifier'} + */ +export function commandeFixe(etatSeance) { + if (etatSeance.evenement === null) return null; + const { refus } = etatSeance; + if (refus?.code === 'LECTURE_SEULE' && refus.details?.raison === 'FORMAT') return 'format'; + if (etatSeance.evenement.etat === 'bloque') return 'debloquer'; + return etatSeance.mode === 'ecriture' ? 'lecture' : 'modifier'; +} + +/** Les états d'un plan (§ 9), dans l'ordre du spec. */ +export const ETATS_PLAN = Object.freeze(['brouillon', 'propose', 'retenu', 'bloque']); + +/** + * La clé du libellé d'un état du plan, evenement.etat.. + * + * @param {string} etat + * @returns {string} + * @throws {RangeError} hors d'ETATS_PLAN + */ +export function cleEtat(etat) { + if (!ETATS_PLAN.includes(etat)) throw new RangeError(`état du plan inconnu : ${JSON.stringify(etat)}`); + return `evenement.etat.${etat}`; +} + +/** + * Le verrou que l'écran offre de reprendre (§ 8.8) : en lecture, celui qui a + * refusé le dernier passage en écriture quand sa vie ne se prouve pas + * (vivant null) — un autre poste, un verrou illisible. Un verrou vivant ne + * se reprend jamais, et celui d'une séance morte, la séance le reprend + * seule. null sinon. + * + * @param {Object} etatSeance EtatSeance + * @returns {null|{seance: string|null, depuis: string|null, vivant: null}} + */ +export function repriseOfferte(etatSeance) { + const { evenement, mode, verrouAutre } = etatSeance; + if (evenement === null || mode !== 'lecture' || verrouAutre === null || verrouAutre.vivant !== null) return null; + return verrouAutre; +} + +// Le remède que l'écran joint à un avertissement : rouvrir la version +// précédente d'un retenu fautif ; ouvrir les propositions devant une dérive, +// et les tables devant des places manquantes. Les autres n'en ont pas. +const VERS_PROPOSITIONS = Object.freeze({ geste: 'ouvrirSection', section: 'propositions' }); +const REMEDES = new Map([ + ['RETENU_FAUTIF', Object.freeze({ geste: 'ouvrirSecours', source: 'precedent' })], + ['DERIVE', VERS_PROPOSITIONS], + ['DERIVE_NOUVELLE', VERS_PROPOSITIONS], + ['PLACES_MANQUANTES', Object.freeze({ geste: 'ouvrirSection', section: 'tables' })], +]); + +// Ce que l'examen de la séance dit de chaque placement que nomme une dérive +// {ids, retenu} : le retenu d'abord, quand la dérive le nomme, puis chaque +// proposition de ids, dans l'ordre de ids, avec ses raisons. Un placement +// que l'examen ne porte pas n'apparaît pas. +function derivesNommees({ ids = [], retenu = false }, examen) { + if (examen === null || examen === undefined) return []; + const nommees = []; + if (retenu && examen.retenu.raisons.length > 0) nommees.push({ id: 'retenu', raisons: examen.retenu.raisons }); + for (const id of ids) { + const derive = examen.derives.find((candidate) => candidate.id === id); + if (derive !== undefined) nommees.push({ id, raisons: derive.raisons }); + } + return nommees; +} + +/** + * Les avertissements à montrer, dans l'ordre d'EtatSeance.avertissements — + * celui de la séance : ceux de l'Ouvert courant, INVARIANT_VIOLE, ceux du + * dernier geste, RETOUR_AUTOMATIQUE —, sans en retirer ni en ajouter. Chacun + * garde son code et ses détails, et reçoit son remède : RETENU_FAUTIF → + * { geste: 'ouvrirSecours', source: 'precedent' } ; DERIVE et + * DERIVE_NOUVELLE → { geste: 'ouvrirSection', section: 'propositions' } ; + * PLACES_MANQUANTES → { geste: 'ouvrirSection', section: 'tables' } ; null + * pour les autres. PROPOSITIONS_FAUTIVES porte en plus fautives, celles + * d'EtatSeance.fautives ; DERIVE, derives, ce que l'examen dit de chaque + * placement qu'elle nomme ({id: number|'retenu', raisons}). Le texte vient + * de la traduction, sur le code. + * + * @param {Object} etatSeance EtatSeance + * @returns {Array<{code: string, details: Object, remede: Object|null, fautives?: Object[], derives?: Object[]}>} + */ +export function avertissementsOrdonnes(etatSeance) { + return etatSeance.avertissements.map(({ code, details }) => { + const rendu = { code, details, remede: REMEDES.get(code) ?? null }; + if (code === 'PROPOSITIONS_FAUTIVES') rendu.fautives = etatSeance.fautives; + if (code === 'DERIVE') rendu.derives = derivesNommees(details, etatSeance.examen); + return rendu; + }); +} diff --git a/src/interface/evenement/modele.test.js b/src/interface/evenement/modele.test.js new file mode 100644 index 0000000..23618ea --- /dev/null +++ b/src/interface/evenement/modele.test.js @@ -0,0 +1,218 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Le modèle de l'événement ouvert (§ 8.4, § 8.5, § 8.8, § 8.9, § 9) : le cadre +// de mode et sa forme, la commande de la place fixe, les états du plan et +// leurs clés, la reprise de verrou offerte, et les avertissements ordonnés, +// chacun avec son remède. Chaque épreuve part d'un EtatSeance réduit à ce que +// la fonction lit ; les noms et identifiants sont inventés. +import assert from 'node:assert/strict'; +import { describe, test } from '../../../test/lanceur.js'; +import { TABLES_APPLICATION } from '../../application/libelles.js'; +import { creerTraduction } from '../../application/traduction.js'; +import { TABLES_INTERFACE } from '../libelles.js'; +import { ETATS_PLAN, avertissementsOrdonnes, cadreDeMode, cleEtat, commandeFixe, repriseOfferte } from './modele.js'; + +const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]); + +const FORMAT_REFUS = { code: 'LECTURE_SEULE', details: { raison: 'FORMAT', remede: null } }; +const BLOQUE_REFUS = { code: 'LECTURE_SEULE', details: { raison: 'BLOQUE', remede: { geste: 'debloquer' } } }; + +// Un EtatSeance réduit : mode, événement (null, ou son état du plan), refus, +// avertissements, fautives, examen, verrouAutre. +function etat({ + mode = 'lecture', + plan = 'brouillon', + refus = null, + avertissements = [], + fautives = [], + examen = null, + verrouAutre = null, +} = {}) { + return { + mode, + evenement: + plan === null ? null : { base: 'merles', id: 'evt-merles', nom: 'Club des Merles', etat: plan, revision: 3, annonceFormat: null }, + refus, + avertissements, + fautives, + examen, + verrouAutre, + }; +} + +describe('cadreDeMode (§ 8.4, § 14.5)', () => { + test('null sans événement', () => { + assert.equal(cadreDeMode(etat({ plan: null })), null); + }); + + test('lecture : tirets ; écriture : trait plein ; bloqué : trait double, quel que soit le mode', () => { + assert.deepEqual(cadreDeMode(etat({ mode: 'lecture' })), { cle: 'coquille.mode.lecture', forme: 'tirets' }); + assert.deepEqual(cadreDeMode(etat({ mode: 'ecriture' })), { cle: 'coquille.mode.ecriture', forme: 'plein' }); + assert.deepEqual(cadreDeMode(etat({ mode: 'lecture', plan: 'bloque' })), { cle: 'coquille.mode.bloque', forme: 'double' }); + assert.deepEqual(cadreDeMode(etat({ mode: 'ecriture', plan: 'bloque' })), { cle: 'coquille.mode.bloque', forme: 'double' }); + }); + + test('trois formes distinctes pour trois libellés distincts, chaque libellé traduit', () => { + const cadres = [etat(), etat({ mode: 'ecriture' }), etat({ plan: 'bloque' })].map(cadreDeMode); + assert.equal(new Set(cadres.map(({ forme }) => forme)).size, 3); + assert.equal(new Set(cadres.map(({ cle }) => t(cle))).size, 3); + }); +}); + +describe('commandeFixe (§ 8.4, § 8.5, § 8.8)', () => { + test('null sans événement ; modifier en lecture ; lecture en écriture ; debloquer sur un plan bloqué', () => { + assert.equal(commandeFixe(etat({ plan: null })), null); + assert.equal(commandeFixe(etat()), 'modifier'); + assert.equal(commandeFixe(etat({ mode: 'ecriture', plan: 'retenu' })), 'lecture'); + assert.equal(commandeFixe(etat({ plan: 'bloque', refus: BLOQUE_REFUS })), 'debloquer'); + }); + + test("un format plus récent remplace la commande par l'explication, avant le plan bloqué", () => { + assert.equal(commandeFixe(etat({ refus: FORMAT_REFUS })), 'format'); + assert.equal(commandeFixe(etat({ plan: 'bloque', refus: FORMAT_REFUS })), 'format'); + }); +}); + +describe('les états du plan (§ 9)', () => { + test("les quatre états, dans l'ordre du § 9, figés, chacun sous sa clé evenement.etat., traduite", () => { + assert.deepEqual(ETATS_PLAN, ['brouillon', 'propose', 'retenu', 'bloque']); + assert.ok(Object.isFrozen(ETATS_PLAN)); + assert.ok(ETATS_PLAN.length > 0); + for (const nom of ETATS_PLAN) { + assert.equal(cleEtat(nom), `evenement.etat.${nom}`); + assert.notEqual(t(cleEtat(nom)).trim(), ''); + } + assert.equal(new Set(ETATS_PLAN.map((nom) => t(cleEtat(nom)))).size, 4); + }); + + test('un état inconnu lève RangeError', () => { + assert.throws(() => cleEtat('archive'), RangeError); + assert.throws(() => cleEtat('toString'), RangeError); + }); +}); + +describe('repriseOfferte (§ 8.8)', () => { + const lointain = { seance: 'lointaine-1', depuis: '2031-05-17T09:00:00-04:00', vivant: null }; + + test('en lecture, un verrou refusé dont la vie ne se prouve pas : le verrou, à reprendre', () => { + assert.deepEqual(repriseOfferte(etat({ verrouAutre: lointain })), lointain); + const illisible = { seance: null, depuis: null, vivant: null }; + assert.deepEqual(repriseOfferte(etat({ verrouAutre: illisible })), illisible); + }); + + test('ni sans verrou refusé, ni en écriture, ni sans événement, ni pour un verrou vivant ou mort', () => { + assert.equal(repriseOfferte(etat()), null); + assert.equal(repriseOfferte(etat({ mode: 'ecriture', verrouAutre: lointain })), null); + assert.equal(repriseOfferte(etat({ plan: null, verrouAutre: lointain })), null); + assert.equal(repriseOfferte(etat({ verrouAutre: { ...lointain, vivant: true } })), null); + assert.equal(repriseOfferte(etat({ verrouAutre: { ...lointain, vivant: false } })), null); + }); +}); + +describe('avertissementsOrdonnes (§ 8.6, § 8.8, § 8.9, § 9)', () => { + // Un avertissement de chaque code du contrat, dans l'ordre où la séance + // les rend, avec des détails de forme quelconque : la fonction n'en lit + // aucun, hors de ceux qu'elle complète. + const CODES = [ + 'RETENU_FAUTIF', + 'JOURNAL_REPRIS', + 'JOURNAL_EN_AVANCE_DE', + 'JOURNAL_INCOMPLET', + 'JOURNAL_ECARTEES', + 'JOURNAL_RECREE', + 'JOURNAL_FORMAT_PLUS_RECENT', + 'JOURNAL_ILLISIBLE', + 'JOURNAL_ETRANGER', + 'PROPOSITIONS_FAUTIVES', + 'PROPOSITIONS_MANQUANTES', + 'DERIVE', + 'INVARIANT_VIOLE', + 'VERROU_REPRIS', + 'EVENEMENT_CHANGE', + 'VERROU_NON_RENDU', + 'JALON_NON_ECRIT', + 'SUPPORT_AMOVIBLE', + 'RESERVATIONS_SUSPENDUES', + 'IMPORT_EN_RETENU', + 'PLACES_MANQUANTES', + 'DERIVE_NOUVELLE', + 'RESERVATION_NON_HONOREE', + 'CONFIGURATION_INCOHERENTE', + 'RETOUR_AUTOMATIQUE', + ]; + const REMEDES = new Map([ + ['RETENU_FAUTIF', { geste: 'ouvrirSecours', source: 'precedent' }], + ['DERIVE', { geste: 'ouvrirSection', section: 'propositions' }], + ['DERIVE_NOUVELLE', { geste: 'ouvrirSection', section: 'propositions' }], + ['PLACES_MANQUANTES', { geste: 'ouvrirSection', section: 'tables' }], + ]); + const avertissement = (code, rang) => ({ code, details: { rang, ids: [], retenu: false } }); + + test('[] sans avertissement', () => { + assert.deepEqual(avertissementsOrdonnes(etat()), []); + assert.deepEqual(avertissementsOrdonnes(etat({ plan: null })), []); + }); + + test("l'ordre de la séance, codes et détails gardés, chacun avec son remède, null hors des quatre", () => { + const recus = CODES.map(avertissement); + const rendus = avertissementsOrdonnes(etat({ avertissements: recus })); + assert.equal(rendus.length, CODES.length); + assert.ok(rendus.length > 0); + rendus.forEach((rendu, rang) => { + assert.equal(rendu.code, CODES[rang]); + assert.deepEqual(rendu.details, recus[rang].details); + assert.deepEqual(rendu.remede, REMEDES.get(rendu.code) ?? null, rendu.code); + }); + }); + + test("l'ordre est celui reçu, jamais un ordre propre : la liste renversée revient renversée", () => { + const renverses = [...CODES].reverse().map(avertissement); + assert.deepEqual( + avertissementsOrdonnes(etat({ avertissements: renverses })).map(({ code }) => code), + [...CODES].reverse(), + ); + }); + + test('PROPOSITIONS_FAUTIVES porte les fautives de la séance, et lui seul', () => { + const fautives = [{ rang: 1, id: 4, fautes: [{ code: 'LONGUEUR', chemin: 'tours' }] }]; + const rendus = avertissementsOrdonnes( + etat({ fautives, avertissements: [{ code: 'PROPOSITIONS_FAUTIVES', details: { ids: [4] } }, avertissement('DERIVE_NOUVELLE', 0)] }), + ); + assert.deepEqual(rendus[0].fautives, fautives); + assert.equal(Object.hasOwn(rendus[1], 'fautives'), false); + }); + + test("DERIVE porte ce que l'examen dit de chaque placement nommé : le retenu d'abord, puis chaque proposition de ids", () => { + const raisonRetenu = { code: 'PARTICIPANT_EXCLU', participant: 2 }; + const raisonCinq = { code: 'TOURS_CHANGES', avant: 3, maintenant: 2 }; + const examen = { + derives: [ + { id: 5, raisons: [raisonCinq] }, + { id: 9, raisons: [raisonCinq] }, + ], + retenu: { fautes: [], raisons: [raisonRetenu] }, + }; + const [derive] = avertissementsOrdonnes( + etat({ examen, avertissements: [{ code: 'DERIVE', details: { ids: [5], retenu: true } }] }), + ); + assert.deepEqual(derive.derives, [ + { id: 'retenu', raisons: [raisonRetenu] }, + { id: 5, raisons: [raisonCinq] }, + ]); + const [seule] = avertissementsOrdonnes(etat({ examen, avertissements: [{ code: 'DERIVE', details: { ids: [9], retenu: false } }] })); + assert.deepEqual(seule.derives, [{ id: 9, raisons: [raisonCinq] }]); + const [sansExamen] = avertissementsOrdonnes(etat({ avertissements: [{ code: 'DERIVE', details: { ids: [9], retenu: false } }] })); + assert.deepEqual(sansExamen.derives, []); + }); + + test('chaque remède rendu se traduit : sa clé existe', () => { + const rendus = avertissementsOrdonnes(etat({ avertissements: CODES.map(avertissement) })); + const avecRemede = rendus.filter(({ remede }) => remede !== null); + assert.equal(avecRemede.length, 4); + for (const { remede } of avecRemede) { + const cle = remede.geste === 'ouvrirSecours' ? `communs.remede.ouvrirSecours.${remede.source}` : `communs.remede.ouvrirSection.${remede.section}`; + assert.notEqual(t(cle).trim(), ''); + } + }); +}); diff --git a/src/interface/import/Apercu.svelte b/src/interface/import/Apercu.svelte new file mode 100644 index 0000000..46d4220 --- /dev/null +++ b/src/interface/import/Apercu.svelte @@ -0,0 +1,322 @@ + + + + +
+
+ {#if source.origine === 'fichier'} +
{t('import.apercu.fichier')}
+
{source.nom}
+ {:else} +
{t('import.apercu.collage')}
+
+ {/if} + {#if modele.encodage !== null} +
{t('import.apercu.encodage')}
+
{t(modele.encodage)}
+ {/if} + {#if modele.separateur !== null} +
{t('import.apercu.separateur')}
+
{t(modele.separateur)}
+ {/if} +
+ + {#if global !== null} +
+

{t(global.cle, global.details)}

+ {#if !global.remede.dansLaCause} +

{t(global.remede.cle, global.remede.details)}

+ {/if} +
+ {/if} + + {#if modele.colonnes.length > 0} + + + + + + + + + + {#each modele.colonnes as colonne (colonne.rang)} + + + + + {/each} + +
{t('import.apercu.colonnes')}
{t('import.apercu.entete')}{t('import.apercu.devient')}
{colonne.entete}{t(colonne.cle)}
+ {/if} + + {#if choisir !== null} + {#each modele.ambigus as ambigu (ambigu.champ)} +
+ {t('import.ambiguite.question')} {t(ambigu.cle)} + {#each ambigu.rangs as { rang, entete } (rang)} + + {/each} +
+ {/each} + {/if} + + {#if global === null || modele.valides > 0} +

+ {format.nombre(modele.valides)} + {t('import.apercu.valides', { n: modele.valides })} +

+ {/if} + + {#if modele.lignes.length > 0} +
+ + + + + {#each COLONNES_DES_LIGNES as { champ, cle } (champ)} + + {/each} + + + + {#each modele.lignes as { ligne, cellules } (ligne)} + + + {#each cellules as { champ, texte, cle } (champ)} + + {/each} + + {/each} + +
{t('import.apercu.ligne')}{t(cle)}
{format.nombre(ligne)}{cle === null ? (texte ?? '') : t(cle)}
+
+ {/if} + + {#if refuseesMontrees.length > 0} + + + + + + + + + + {#each refuseesMontrees as { ligne, code, details } (ligne)} + + + + + {/each} + +
{t('import.apercu.refusees')}
{t('import.apercu.ligne')}{t('import.apercu.motif')}
{format.nombre(ligne)} + {motif(code, details)} + {#if details.participants !== undefined} + {t('import.rapport.designees')} + {#each nommer(participants, details.participants) as personne (personne.id)} + + {#if personne.prenom !== null}{personne.prenom}{/if} + {personne.nom} + + {/each} + {/if} +
+ {/if} + + {#if modele.fusions.length > 0} +
+

{t('import.apercu.fusions')}

+
    + {#each modele.fusions as fusion, rang (rang)} +
  • + {t('import.apercu.affichee')} + {fusion.affichee} +
      + {#each fusion.orthographes as orthographe (orthographe.texte)} +
    • + {orthographe.texte} + {format.nombre(orthographe.lignes)} + {t('import.apercu.orthographeLignes', { n: orthographe.lignes })} +
    • + {/each} +
    +
  • + {/each} +
+
+ {/if} + + {#if modele.doublons.length > 0} +
+

{t('import.apercu.doublons')}

+
    + {#each modele.doublons as doublon, rang (rang)} +
  • + {t('import.apercu.doublonLignes')} + {#each doublon.lignes as ligne (ligne)} + {format.nombre(ligne)} + {/each} + {#if doublon.existants.length > 0} + {t('import.apercu.doublonExistants')} + {#each nommer(participants, doublon.existants) as personne (personne.id)} + + {#if personne.prenom !== null}{personne.prenom}{/if} + {personne.nom} + + {/each} + {/if} +
  • + {/each} +
+
+ {/if} + + {#if modele.homonymes.length > 0} +
+

{t('import.apercu.homonymes')}

+
    + {#each modele.homonymes as homonyme (homonyme.ligne)} +
  • + {t('import.apercu.ligne')} + {format.nombre(homonyme.ligne)} + {#each nommer(participants, homonyme.participants) as personne (personne.id)} + + {#if personne.prenom !== null}{personne.prenom}{/if} + {personne.nom} + + {/each} +
  • + {/each} +
+
+ {/if} + + {#if modele.multilignes.length > 0} +
+

{t('import.apercu.multilignes')}

+
    + {#each modele.multilignes as multiligne (multiligne.ligne)} +
  • + {t('import.apercu.ligne')} + {format.nombre(multiligne.ligne)} +
  • + {/each} +
+
+ {/if} +
+ + diff --git a/src/interface/import/Import.navigateur.test.js b/src/interface/import/Import.navigateur.test.js new file mode 100644 index 0000000..227b41a --- /dev/null +++ b/src/interface/import/Import.navigateur.test.js @@ -0,0 +1,824 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// L'import des participants dans un vrai moteur de rendu (§ 10.1, § 10.2, +// § 10.3, § 2.1 étape 2, § 9) : l'aperçu obligatoire, l'encodage et le +// séparateur nommés, l'association par en-tête, l'ambiguïté, les refus +// d'une ligne et les refus globaux, le rapport, le CSV des refus, les trois +// modes, l'import selon l'état, le collage. Chaque épreuve monte App par +// monterApp, sur le système de fichiers simulé, ouvre un événement et sa +// section d'import ; le dialogue de choix sert, dans l'ordre, les fichiers +// que l'épreuve prépare, et le dialogue d'enregistrement relève ce qu'il +// reçoit (contrat de données, « Les dialogues de fichiers »). +// +// Les octets de chaque cas se construisent ici ; seuls les CSV livrés se +// lisent, par leur adresse (?url) et fetch : ?raw les lirait en UTF-8 et +// détruirait la variante windows-1252. Les valeurs attendues viennent des +// mêmes implémentations que l'écran sollicite — decoder, apercevoir, +// exporterRefus et libelle, bilanRemplacement —, appelées ici une seconde +// fois (§ 19.1). 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 { flushSync } from 'svelte'; +import { TABLES_APPLICATION, libelle } from '../../application/libelles.js'; +import { creerFormat } from '../../application/format.js'; +import { creerTraduction } from '../../application/traduction.js'; +import { apercevoir } from '../../csv/apercu.js'; +import { decoder } from '../../csv/encodage.js'; +import { bilanRemplacement, exporterRefus as exporterRefusCsv } from '../../csv/import.js'; +import { decouper } from '../../csv/lecture.js'; +import { serialiser } from '../../stockage/canonique.js'; +import { creerCharge } from '../../stockage/document.js'; +import { lireJournal } from '../../stockage/journal.js'; +import { SUFFIXES } from '../../stockage/noms.js'; +import { VERSION } from '../../version.genere.js'; +import { monterApp } from '../../../test/navigateur/montage.js'; +import { visible } from '../../../test/navigateur/rendu.js'; +import urlCasLimites from '../../../exemples/participants_cas_limites.csv?url'; +import urlCasLimites1252 from '../../../exemples/participants_cas_limites_cp1252.csv?url'; +import urlGrande from '../../../exemples/participants_demo_grande.csv?url'; +import { TABLES_INTERFACE } from '../libelles.js'; +import { ENCODAGES, SEPARATEURS } from './modele.js'; + +const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]); +const format = creerFormat(t); + +const BASE = 'soiree'; +const MARQUE_UTF8 = [0xef, 0xbb, 0xbf]; +const OUVERTURE_TEMOIN = '\u{2045}'; +const FERMETURE_TEMOIN = '\u{2046}'; + +// Les octets d'un CSV livré, tels que le fichier les porte. +const lireLivre = async (url) => new Uint8Array(await (await fetch(url)).arrayBuffer()); + +// Un texte en UTF-8 avec marque, en UTF-16 LE ou BE avec marque. +const enUtf8 = (texte) => Uint8Array.from([...MARQUE_UTF8, ...new TextEncoder().encode(texte)]); +function enUtf16(texte, ordre) { + const octets = [ordre === 'le' ? 0xff : 0xfe, ordre === 'le' ? 0xfe : 0xff]; + for (let i = 0; i < texte.length; i += 1) { + const unite = texte.charCodeAt(i); + const fort = unite >> 8; + const faible = unite & 0xff; + octets.push(...(ordre === 'le' ? [faible, fort] : [fort, faible])); + } + return Uint8Array.from(octets); +} + +// Un participant de la charge d'épreuve. +const personne = (id, nom, prenom = null, autres = {}) => ({ + id, nom, prenom, appartenance: null, courriel: null, titrePressenti: null, notes: null, exclu: false, ...autres, +}); + +// 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(); + expect(globalThis.__gttErreurs?.slice(erreursAvant) ?? [], 'erreurs reçues par le collecteur').toEqual([]); +}); + +/** + * Un montage dont l'événement « Soirée des Merles » est ouvert sur sa + * section d'import : les participants donnés, en écriture sauf demande, + * préparé par prepare(seance, generation) avant que l'espion se pose. Le + * dialogue de choix sert aServir dans l'ordre ; l'enregistreur relève + * enregistres et rend le nom proposé. + */ +async function ouvrir({ participants = [], ecriture = true, langue = 'fr', siegesParDefaut = 6, prepare = null } = {}) { + const charge = creerCharge({ id: 'epreuve-import', nom: 'Soirée des Merles', siegesParDefaut, tours: 2 }); + charge.participants = participants; + charge.prochainsIds.participant = participants.length + 1; + const texte = serialiser(charge, { revision: 1, produitVersion: VERSION.affichee }); + const m = await monterApp({ fichiers: [{ racine: 'documents', chemin: `${BASE}.gtt.json`, texte }], langue }); + montes.push(m); + await m.seance.ouvrir(BASE); + await m.seance.passerEnEcriture(); + if (prepare !== null) await prepare(m.seance, m.app.generation()); + if (!ecriture && m.seance.etat().mode === 'ecriture') await m.seance.passerEnLecture(); + m.ui.allerA('import'); + flushSync(); + const aServir = []; + const enregistres = []; + m.fs.choisirFichierAImporter = async () => aServir.shift() ?? null; + m.fs.enregistrerSous = async (nomPropose, octets) => { + enregistres.push({ nomPropose, octets }); + return nomPropose; + }; + const espion = vi.spyOn(m.seance, 'executer'); + return { m, aServir, enregistres, espion }; +} + +const section = ({ m }) => m.racine.querySelector('[data-section="import"]'); +const dans = (e, selecteur) => section(e).querySelector(selecteur); +const tous = (e, selecteur) => [...section(e).querySelectorAll(selecteur)]; +const commande = (e, nom) => dans(e, `[data-commande="${nom}"]`); +const texteDe = (element) => element.textContent.replace(/\s+/g, ' ').trim(); +const ecritures = ({ m }) => m.trace.length; +const imports = ({ espion }) => espion.mock.calls.filter(([nom]) => nom === 'importerParticipants'); + +// Sert un fichier au dialogue et le choisit : l'aperçu s'ouvre sur lui. +async function choisir(e, nom, octets) { + e.aServir.push({ nom, octets }); + commande(e, 'choisirFichier').click(); + await vi.waitFor(() => expect(dans(e, '[data-apercu] [data-fichier]')?.textContent).toBe(nom)); +} + +// Remet un texte comme le collage de la grille (I5) : ui.ouvrirImport, +// puis ui.allerA('import'). +async function coller(e, texte) { + e.m.ui.ouvrirImport({ texte, origine: 'collage' }); + e.m.ui.allerA('import'); + await vi.waitFor(() => expect(dans(e, '[data-apercu] [data-collage]')).not.toBeNull()); +} + +// Choisit un mode d'import. +function choisirMode(e, mode) { + dans(e, `[data-champ="mode"] input[value="${mode}"]`).click(); + flushSync(); +} + +// Clique « Importer » et attend la fin de l'appel à la séance qu'il fait : +// rend ce que rend seance.executer, ou le refus. +async function importer(e) { + const avant = imports(e).length; + commande(e, 'importer').click(); + await vi.waitFor(() => expect(imports(e).length).toBe(avant + 1)); + const resultat = e.espion.mock.results.filter((_, rang) => e.espion.mock.calls[rang][0] === 'importerParticipants').at(-1); + try { + const rendu = await resultat.value; + await vi.waitFor(() => expect(dans(e, '[data-rapport]')).not.toBeNull()); + return { rendu, refus: null }; + } catch (erreur) { + await vi.waitFor(() => expect(dans(e, `[data-refus="${erreur.code}"]`)).not.toBeNull()); + return { rendu: null, refus: erreur }; + } +} + +// Les lignes valides montrées sous un conteneur : le texte de chaque +// cellule, dans l'ordre. +const lignesMontrees = (conteneur) => + [...conteneur.querySelectorAll('[data-lignes] [data-ligne]')].map((ligne) => + [...ligne.querySelectorAll('[data-cellule]')].map((cellule) => cellule.textContent)); +const cellule = (conteneur, ligne, champ) => + conteneur.querySelector(`[data-lignes] [data-ligne="${ligne}"] [data-cellule="${champ}"]`).textContent; +const refusee = (conteneur, ligne) => conteneur.querySelector(`[data-refusees] [data-ligne="${ligne}"]`); +const association = (conteneur) => + [...conteneur.querySelectorAll('[data-colonne]')].map((colonne) => [ + colonne.querySelector('[data-entete]').textContent, + colonne.querySelector('[data-devient]').textContent, + ]); +const participantsDe = ({ m }) => m.seance.etat().charge.participants; + +// Les entrées du journal de l'événement, 0 avant le premier geste. +function entrees({ m }) { + const texte = m.fs.contenu('documents', `${BASE}${SUFFIXES.journal}`); + return texte === null ? 0 : lireJournal(texte).entrees.length; +} + +// Les comptes du rapport, chacun lu à son [data-compte] ; et ceux qu'il +// doit afficher : le résumé que rend seance.executer, par format.nombre. +const COMPTES_DU_RAPPORT = ['ajoutes', 'misAJour', 'inchanges', 'retires', 'reservationsRetirees']; +const comptesAffiches = (e) => + COMPTES_DU_RAPPORT.map((nom) => [nom, dans(e, `[data-rapport] [data-compte="${nom}"] [data-nombre]`).textContent]); +const comptesDu = (resume) => COMPTES_DU_RAPPORT.map((nom) => [nom, format.nombre(resume[nom])]); + +// Un glissement sur la fenêtre, portant un fichier ou un texte seul : rend +// l'événement distribué, dont defaultPrevented dit s'il a été accepté. +function glisser({ fichier = null, texte = null }) { + const transfert = new DataTransfer(); + if (fichier !== null) transfert.items.add(fichier); + if (texte !== null) transfert.setData('text/plain', texte); + const evenement = new DragEvent('dragover', { dataTransfer: transfert, bubbles: true, cancelable: true }); + document.body.dispatchEvent(evenement); + return evenement; +} + +// Amène un élément dans la fenêtre, puis dit s'il y est rendu visible +// (test/navigateur/rendu.js). +function vu(element) { + element.scrollIntoView({ block: 'center' }); + return visible(element); +} + +describe('l’aperçu est obligatoire (§ 10.1)', () => { + test('choisir les cas limites ouvre l’aperçu, sans rien écrire ; déposer un fichier sur la fenêtre ouvre son aperçu ; aucun contrôle hors de l’import n’importe', async () => { + const e = await ouvrir(); + const avant = ecritures(e); + await choisir(e, 'participants_cas_limites.csv', await lireLivre(urlCasLimites)); + expect(vu(dans(e, '[data-apercu] h3'))).toBe(true); + expect(ecritures(e)).toBe(avant); + + const octets = enUtf8('nom;prenom\r\nOmbrelle;Iris\r\n;Théo\r\n'); + const deposee = new File([octets], 'deposee.csv', { type: 'text/csv' }); + // Le survol qui porte un fichier est accepté, sans quoi le navigateur ne + // remet pas le dépôt et ouvre le fichier à la place de l'application ; + // un survol sans fichier ne l'est pas. + expect(glisser({ fichier: deposee }).defaultPrevented).toBe(true); + expect(glisser({ texte: 'Ombrelle' }).defaultPrevented).toBe(false); + const transfert = new DataTransfer(); + transfert.items.add(deposee); + document.body.dispatchEvent(new DragEvent('drop', { dataTransfer: transfert, bubbles: true, cancelable: true })); + await vi.waitFor(() => expect(dans(e, '[data-apercu] [data-fichier]')?.textContent).toBe('deposee.csv')); + + const controles = tous(e, '[data-apercu] button, [data-apercu] input').filter( + (element) => element.dataset.commande !== 'importer' && element.dataset.commande !== 'fermerApercu', + ); + expect(controles.length).toBeGreaterThan(0); + for (const controle of controles) { + controle.click(); + flushSync(); + } + await vi.waitFor(() => expect(e.enregistres.length).toBe(1)); + expect(imports(e)).toEqual([]); + expect(ecritures(e)).toBe(avant); + }); +}); + +describe('encodage et séparateur nommés (§ 10.1, § 19.3)', () => { + test('les cas limites, puis leur variante windows-1252 : encodage et séparateur de decoder et de l’aperçu, « Héloïse » intacte, mêmes lignes', async () => { + const e = await ouvrir(); + const vus = []; + for (const [nom, url] of [['participants_cas_limites.csv', urlCasLimites], ['participants_cas_limites_cp1252.csv', urlCasLimites1252]]) { + const octets = await lireLivre(url); + await choisir(e, nom, octets); + const { texte, encodage } = decoder(octets); + expect(dans(e, '[data-encodage]').textContent).toBe(t(ENCODAGES.get(encodage))); + expect(dans(e, '[data-separateur]').textContent).toBe(t(SEPARATEURS.get(apercevoir(texte).separateur))); + const apercu = dans(e, '[data-apercu]'); + expect(apercu.textContent).toContain('Héloïse'); + expect(apercu.textContent).not.toContain('Héloïse'); + vus.push({ encodage, lignes: lignesMontrees(apercu) }); + } + expect(vus.map(({ encodage }) => encodage)).toEqual(['utf-8-bom', 'windows-1252']); + expect(dans(e, '[data-separateur]').textContent).toBe(t('import.separateur.virgule')); + expect(vus[0].lignes.length).toBe(13); + expect(vus[1].lignes).toEqual(vus[0].lignes); + }); + + test('un même texte en UTF-16 LE puis BE, à marque : chacun nommé, mêmes lignes', async () => { + const e = await ouvrir(); + const texte = 'nom;prenom;appartenance\r\nOmbrelle;Iris;Club des Merles\r\nPervenche;Benoît;Grisaille\r\n'; + const vus = []; + for (const ordre of ['le', 'be']) { + await choisir(e, `liste-${ordre}.csv`, enUtf16(texte, ordre)); + expect(dans(e, '[data-encodage]').textContent).toBe(t(ENCODAGES.get(`utf-16${ordre}`))); + vus.push(lignesMontrees(dans(e, '[data-apercu]'))); + } + expect(vus[0].length).toBe(2); + expect(vus[1]).toEqual(vus[0]); + expect(vus[0][1].slice(0, 3)).toEqual(['Pervenche', 'Benoît', 'Grisaille']); + }); + + test('un fichier d’une seule colonne, en-tête « nom » : « une seule colonne », « Ombrelle, Iris » entière', async () => { + const e = await ouvrir(); + await choisir(e, 'noms.csv', enUtf8('nom\r\nPervenche\r\nOmbrelle, Iris\r\nLacasse\r\n')); + expect(dans(e, '[data-separateur]').textContent).toBe('une seule colonne'); + expect(cellule(dans(e, '[data-apercu]'), 3, 'nom')).toBe('Ombrelle, Iris'); + }); +}); + +describe('les colonnes s’associent par leur en-tête (§ 10.1)', () => { + test('deux fichiers aux colonnes permutées, « NOM », « Équipe », « Rôle » : même association, mêmes participants ; « Allergies » non reconnue', async () => { + const e = await ouvrir(); + const premier = 'NOM;Équipe;Rôle;Allergies\r\nOmbrelle;Club des Merles;animation;noix\r\nPervenche;Grisaille;;\r\n'; + const second = 'Allergies;Rôle;Équipe;NOM\r\nnoix;animation;Club des Merles;Ombrelle\r\n;;Grisaille;Pervenche\r\n'; + const vus = []; + for (const [nom, texte] of [['premier.csv', premier], ['second.csv', second]]) { + await choisir(e, nom, enUtf8(texte)); + const apercu = dans(e, '[data-apercu]'); + vus.push({ association: new Map(association(apercu)), lignes: lignesMontrees(apercu) }); + } + expect(vus[0].association.size).toBe(4); + expect(vus[1].association).toEqual(vus[0].association); + expect(vus[0].association.get('NOM')).toBe(t('import.champ.nom')); + expect(vus[0].association.get('Équipe')).toBe(t('import.champ.appartenance')); + expect(vus[0].association.get('Rôle')).toBe(t('import.champ.titrePressenti')); + expect(vus[0].association.get('Allergies')).toBe(t('import.colonne.nonReconnue')); + expect(vus[1].lignes).toEqual(vus[0].lignes); + expect(vus[0].lignes.length).toBe(2); + }); + + test('« organisation » et « entreprise » : l’aperçu demande laquelle vaut appartenance ; importer est refusé à côté de la commande tant qu’elle demeure ; le choix l’associe, et l’import passe', async () => { + const e = await ouvrir(); + await choisir(e, 'ambigu.csv', enUtf8('nom;organisation;entreprise\r\nOmbrelle;Club des Merles;Grisaille\r\n')); + const question = dans(e, '[data-champ="choix-appartenance"]'); + expect(vu(question)).toBe(true); + expect(texteDe(question)).toContain('organisation'); + expect(texteDe(question)).toContain('entreprise'); + const avant = ecritures(e); + const { refus } = await importer(e); + expect(refus.code).toBe('AMBIGUITE'); + const posee = dans(e, '[data-refus="AMBIGUITE"]'); + expect(commande(e, 'importer').parentElement.contains(posee)).toBe(true); + expect(ecritures(e)).toBe(avant); + + question.querySelector('input[value="2"]').click(); + flushSync(); + expect(new Map(association(dans(e, '[data-apercu]'))).get('entreprise')).toBe(t('import.champ.appartenance')); + expect(dans(e, '[data-champ="choix-appartenance"]')).toBeNull(); + const { rendu } = await importer(e); + expect(rendu.resume.ajoutes).toBe(1); + expect(participantsDe(e).map(({ nom, appartenance }) => [nom, appartenance])).toEqual([['Ombrelle', 'Grisaille']]); + }); + + test('sous « Remplacer », l’ambiguïté refuse l’import à côté de la commande, sans ouvrir la fenêtre ; le choix fait, la fenêtre s’ouvre', async () => { + const e = await ouvrir({ participants: QUATRE() }); + await choisir(e, 'ambigu.csv', enUtf8('nom;organisation;entreprise\r\nOmbrelle;Club des Merles;Grisaille\r\n')); + choisirMode(e, 'remplacer'); + const avant = ecritures(e); + commande(e, 'importer').click(); + await vi.waitFor(() => expect(dans(e, '[data-refus="AMBIGUITE"]')).not.toBeNull()); + expect(commande(e, 'importer').parentElement.contains(dans(e, '[data-refus="AMBIGUITE"]'))).toBe(true); + expect(dans(e, 'dialog')).toBeNull(); + expect(imports(e)).toEqual([]); + expect(ecritures(e)).toBe(avant); + + dans(e, '[data-champ="choix-appartenance"] input[value="1"]').click(); + flushSync(); + commande(e, 'importer').click(); + await vi.waitFor(() => expect(dans(e, 'dialog')?.open).toBe(true)); + dans(e, 'dialog [data-commande="renoncer"]').click(); + await vi.waitFor(() => expect(dans(e, 'dialog')).toBeNull()); + expect(imports(e)).toEqual([]); + expect(ecritures(e)).toBe(avant); + }); +}); + +describe('exclu : une liste fermée (§ 10.1)', () => { + test('« Oui », « X », « 0 » et vide acceptés ; « peut-être » refuse sa ligne, le motif citant la valeur ; les autres s’importent', async () => { + const e = await ouvrir(); + await choisir( + e, + 'exclus.csv', + enUtf8('nom;exclu\r\nOmbrelle;Oui\r\nPervenche;X\r\nLacasse;0\r\nFauvelle;\r\nMorillon;peut-être\r\n'), + ); + const apercu = dans(e, '[data-apercu]'); + expect(lignesMontrees(apercu).length).toBe(4); + expect(tous(e, '[data-apercu] [data-refusees] [data-ligne]').length).toBe(1); + expect(texteDe(refusee(apercu, 6))).toContain('« peut-être »'); + await importer(e); + expect(participantsDe(e).map(({ nom, exclu }) => [nom, exclu])).toEqual([ + ['Ombrelle', true], ['Pervenche', true], ['Lacasse', false], ['Fauvelle', false], + ]); + }); +}); + +// Un fichier par refus global, ses octets, et la clé du remède attendue. +const REFUS_GLOBAUX = [ + ['AUCUN_ENTETE', enUtf8('alpha;beta\r\nOmbrelle;Iris\r\n'), 'import.remede.aucunEntete'], + ['NOM_NON_ASSOCIE', enUtf8('prenom;notes\r\nIris;x\r\n'), 'import.remede.nomNonAssocie'], + ['AUCUNE_LIGNE_VALIDE', enUtf8('nom;prenom\r\n;Iris\r\n'), 'import.remede.aucuneLigneValide'], + ['UTF16_SANS_MARQUE', Uint8Array.from([0x6e, 0x00, 0x6f, 0x00, 0x6d, 0x00]), 'ENREGISTRER_EN_UTF8'], + ['UTF16_INVALIDE', Uint8Array.from([0xff, 0xfe, 0x6e, 0x00, 0x6f]), 'ENREGISTRER_EN_UTF8'], + ['UTF8_INVALIDE', Uint8Array.from([...MARQUE_UTF8, 0x6e, 0x6f, 0xe9, 0x6d]), 'ENREGISTRER_EN_UTF8'], + ['CARACTERE_NUL', enUtf8('nom\r\nOmbrelle\u{0}\r\n'), 'ENREGISTRER_EN_UTF8'], + ['GUILLEMET_OUVERT', enUtf8('nom;prenom\r\n"Ombrelle;Iris\r\n'), 'import.remede.guillemetOuvert'], + ['SEPARATEUR_INTROUVABLE', enUtf8('nom;prenom\r\nOmbrelle;Iris;x\r\nPervenche\r\n'), 'import.remede.separateurIntrouvable'], +]; + +describe('les refus globaux (§ 10.1)', () => { + test('un fichier par cas : la cause et le remède de la table affichés, aucune commande d’import, rien d’écrit', async () => { + const e = await ouvrir(); + expect(REFUS_GLOBAUX.length).toBe(9); + const avant = ecritures(e); + for (const [code, octets, remede] of REFUS_GLOBAUX) { + await choisir(e, `${code}.csv`, octets); + const pose = dans(e, `[data-apercu] [data-refus="${code}"]`); + expect(pose, code).not.toBeNull(); + expect(vu(pose), code).toBe(true); + const texte = pose.textContent; + expect(texte, code).toContain(t(remede, { ligne: 2 })); + expect(texte.length, code).toBeGreaterThan(t(remede, { ligne: 2 }).length); + expect(commande(e, 'importer'), code).toBeNull(); + } + expect(ecritures(e)).toBe(avant); + }); + + test('un collage qui porte le caractère nul : la cause et le remède du collage', async () => { + const e = await ouvrir(); + const avant = ecritures(e); + await coller(e, 'nom\nOmbrelle\u{0}\n'); + const pose = dans(e, '[data-apercu] [data-refus="CARACTERE_NUL"]'); + expect(pose.textContent).toContain(t('CARACTERE_NUL')); + expect(pose.textContent).toContain(t('import.remede.caractereNulCollage')); + expect(commande(e, 'importer')).toBeNull(); + expect(dans(e, '[data-encodage]')).toBeNull(); + expect(ecritures(e)).toBe(avant); + }); +}); + +describe('une ligne fausse n’arrête pas le reste (§ 10.1, § 2.1)', () => { + test('la grande démonstration, « peut-être » à l’enregistrement 213 : 259 lignes valides, la ligne 213 et sa valeur ; l’import en ajoute 259', async () => { + const e = await ouvrir(); + const { texte } = decoder(await lireLivre(urlGrande)); + const enregistrements = texte.split('\r\n'); + expect(enregistrements[212]).toMatch(/;non;$/); + enregistrements[212] = enregistrements[212].replace(/;non;$/, ';peut-être;'); + await choisir(e, 'grande.csv', enUtf8(enregistrements.join('\r\n'))); + const apercu = dans(e, '[data-apercu]'); + expect(dans(e, '[data-valides] [data-nombre]').textContent).toBe('259'); + expect(tous(e, '[data-apercu] [data-refusees] [data-ligne]').map((ligne) => ligne.dataset.ligne)).toEqual(['213']); + expect(texteDe(refusee(apercu, 213))).toContain('213'); + expect(texteDe(refusee(apercu, 213))).toContain(t('EXCLU_INCONNU', { valeur: 'peut-être' })); + const { rendu } = await importer(e); + expect(rendu.resume.ajoutes).toBe(259); + expect(participantsDe(e).length).toBe(259); + }); + + test('le rang : la ligne refusée des cas limites, sans nom, s’affiche 13, son enregistrement, et non 14, sa ligne de texte', async () => { + const e = await ouvrir(); + await choisir(e, 'participants_cas_limites.csv', await lireLivre(urlCasLimites)); + const apercu = dans(e, '[data-apercu]'); + expect(tous(e, '[data-apercu] [data-refusees] [data-ligne]').map((ligne) => ligne.dataset.ligne)).toEqual(['13']); + expect(texteDe(refusee(apercu, 13))).toContain('13'); + expect(texteDe(refusee(apercu, 13))).toContain(t('NOM_ABSENT')); + expect(refusee(apercu, 14)).toBeNull(); + }); +}); + +describe('le rapport (§ 10.1, § 19.3)', () => { + test('« Coopérative Bleue » existante, « cooperative bleue » et « COOPÉRATIVE BLEUE » lues : un groupe affiché, ses trois orthographes et leurs comptes, dans l’aperçu puis le rapport', async () => { + const e = await ouvrir({ participants: [personne(1, 'Ombrelle', 'Iris', { appartenance: 'Coopérative Bleue' })] }); + await choisir( + e, + 'bleue.csv', + enUtf8('nom;prenom;appartenance\r\nPervenche;Théo;cooperative bleue\r\nLacasse;Ondine;COOPÉRATIVE BLEUE\r\nFauvelle;;\r\n'), + ); + const attendu = [['Coopérative Bleue', '0'], ['cooperative bleue', '1'], ['COOPÉRATIVE BLEUE', '1']]; + const groupes = (conteneur) => + [...conteneur.querySelectorAll('[data-fusion]')].map((fusion) => ({ + affichee: fusion.querySelector('[data-affichee]').textContent, + orthographes: [...fusion.querySelectorAll('[data-orthographe]')].map((o) => [ + o.querySelector('[data-texte]').textContent, + o.querySelector('[data-nombre]').textContent, + ]), + })); + expect(groupes(dans(e, '[data-apercu]'))).toEqual([{ affichee: 'Coopérative Bleue', orthographes: attendu }]); + await importer(e); + const rapport = dans(e, '[data-rapport]'); + expect(vu(rapport.querySelector('h3'))).toBe(true); + expect(groupes(rapport)).toEqual([{ affichee: 'Coopérative Bleue', orthographes: attendu }]); + for (const texte of tous(e, '[data-orthographe] [data-texte]')) expect(texte.textContent.trim()).not.toBe(''); + }); + + test('les cas limites : au moins un groupe et une ligne refusée, dans l’aperçu puis le rapport ; aucune appartenance vide listée', async () => { + const e = await ouvrir(); + await choisir(e, 'participants_cas_limites.csv', await lireLivre(urlCasLimites)); + for (const conteneur of ['[data-apercu]', null]) { + if (conteneur === null) await importer(e); + const ici = dans(e, conteneur ?? '[data-rapport]'); + expect(ici.querySelectorAll('[data-fusion]').length).toBeGreaterThanOrEqual(1); + expect(ici.querySelectorAll('[data-refusees] [data-ligne]').length).toBeGreaterThanOrEqual(1); + const orthographes = [...ici.querySelectorAll('[data-orthographe] [data-texte]')]; + expect(orthographes.length).toBeGreaterThan(0); + for (const texte of orthographes) expect(texte.textContent.trim()).not.toBe(''); + } + }); +}); + +describe('le CSV des lignes refusées (§ 10.1)', () => { + test('« Enregistrer les lignes refusées » remet les octets d’exporterRefus de src/csv, motif de libelle ; corrigé et réimporté, la ligne entre, ligne et motif non reconnues', async () => { + const e = await ouvrir(); + const octets = await lireLivre(urlCasLimites); + await choisir(e, 'participants_cas_limites.csv', octets); + commande(e, 'enregistrerRefus').click(); + await vi.waitFor(() => expect(e.enregistres.length).toBe(1)); + const apercu = apercevoir(decoder(octets).texte, { participants: [], mode: 'ajouter' }); + expect(e.enregistres[0].nomPropose).toBe(`${BASE}-refus.csv`); + expect([...e.enregistres[0].octets]).toEqual([...exporterRefusCsv(apercu, libelle)]); + + const corrige = decoder(e.enregistres[0].octets).texte.replace('\r\n;Clotilde;', '\r\nRavenelle;Clotilde;'); + expect(corrige).toContain('Ravenelle;Clotilde;'); + await choisir(e, 'corrige.csv', enUtf8(corrige)); + const vu = new Map(association(dans(e, '[data-apercu]'))); + expect(vu.get('ligne')).toBe(t('import.colonne.nonReconnue')); + expect(vu.get('motif')).toBe(t('import.colonne.nonReconnue')); + await importer(e); + expect(participantsDe(e).map(({ nom, prenom }) => [nom, prenom])).toEqual([['Ravenelle', 'Clotilde']]); + }); + + test('après une mise à jour qui refuse des lignes, le rapport remet exporterRefus(apercu, libelle, resume.refusees), refus de la mise à jour compris', async () => { + const e = await ouvrir({ + participants: [personne(1, 'Lacasse', 'Ondine'), personne(2, 'Lacasse', 'Odile'), personne(3, 'Ombrelle', 'Iris')], + }); + await choisir(e, 'maj.csv', enUtf8('nom;prenom\r\nLacasse;\r\nOmbrelle;Irène\r\n;Théo\r\n')); + const { rendu } = await importer(e); + const { apercu } = imports(e).at(-1)[1]; + expect(rendu.resume.refusees.map(({ code }) => code)).toEqual(['PLUSIEURS_CORRESPONDENT', 'NOM_ABSENT']); + commande(e, 'enregistrerRefus').click(); + await vi.waitFor(() => expect(e.enregistres.length).toBe(1)); + expect([...e.enregistres[0].octets]).toEqual([...exporterRefusCsv(apercu, libelle, rendu.resume.refusees)]); + }); + + test('en langue témoin, le motif affiché porte les marques, la colonne motif du CSV aucune', async () => { + const e = await ouvrir({ langue: 'temoin' }); + await choisir(e, 'participants_cas_limites.csv', await lireLivre(urlCasLimites)); + const motif = refusee(dans(e, '[data-apercu]'), 13).querySelector('[data-motif]').textContent; + expect(motif.startsWith(OUVERTURE_TEMOIN) && motif.endsWith(FERMETURE_TEMOIN)).toBe(true); + commande(e, 'enregistrerRefus').click(); + await vi.waitFor(() => expect(e.enregistres.length).toBe(1)); + const { texte } = decoder(e.enregistres[0].octets); + const [entete, ...refus] = decouper(texte, ';').enregistrements; + const rang = entete.indexOf('motif'); + expect(rang).toBeGreaterThan(0); + expect(refus.length).toBe(1); + expect(refus[0][rang]).toBe(libelle('NOM_ABSENT', { ligne: 13, valeur: null })); + expect(texte).not.toContain(OUVERTURE_TEMOIN); + expect(texte).not.toContain(FERMETURE_TEMOIN); + }); +}); + +describe('une seule entrée, des doublons signalés, une mise à jour qui n’efface pas (§ 10.1)', () => { + test('les 260 lignes de la grande démonstration : une entrée du journal, un état écrit', async () => { + // Un premier geste pose le journal, et l'entrée de reprise qui l'ouvre. + const e = await ouvrir({ prepare: (seance) => seance.executer('creerTable', { forme: 'ronde' }) }); + expect(entrees(e)).toBeGreaterThan(0); + await choisir(e, 'participants_demo_grande.csv', await lireLivre(urlGrande)); + const avant = ecritures(e); + const entreesAvant = entrees(e); + const { rendu } = await importer(e); + expect(rendu.resume.ajoutes).toBe(260); + expect(entrees(e)).toBe(entreesAvant + 1); + const etats = e.m.trace.slice(avant).filter(([primitive, , chemin]) => primitive === 'ecrireAtomique' && chemin === `${BASE}${SUFFIXES.etat}`); + expect(etats.length).toBe(1); + }); + + test('deux lignes au même triplet et une égale à un existant : deux doublons ; en mode Ajouter, choisi, trois personnes de plus, d’identifiants distincts', async () => { + const e = await ouvrir({ participants: [personne(1, 'Ombrelle', 'Iris', { appartenance: 'Grisaille' })] }); + await choisir( + e, + 'doublons.csv', + enUtf8('nom;prenom;appartenance\r\nPervenche;Théo;Club des Merles\r\npervenche;THÉO;club des merles\r\nOmbrelle;Iris;Grisaille\r\n'), + ); + expect(dans(e, '[data-champ="mode"] input:checked').value).toBe('mettreAJour'); + choisirMode(e, 'ajouter'); + expect(tous(e, '[data-apercu] [data-doublon]').length).toBe(2); + const { rendu } = await importer(e); + expect(rendu.resume.ajoutes).toBe(3); + const ids = participantsDe(e).map(({ id }) => id); + expect(ids.length).toBe(4); + expect(new Set(ids).size).toBe(4); + }); + + test('mettre à jour : aucun courriel vidé ; une ligne qui désigne deux personnes, refusée, les deux nommées ; une seconde ligne pour la même personne, refusée, la première nommée', async () => { + const e = await ouvrir({ + participants: [ + personne(1, 'Ombrelle', 'Iris', { courriel: 'iris@exemple.test' }), + personne(2, 'Lacasse', 'Ondine', { courriel: 'ondine@exemple.test' }), + personne(3, 'Lacasse', 'Odile'), + personne(4, 'Pervenche', 'Théo', { courriel: 'theo@exemple.test' }), + ], + }); + await choisir( + e, + 'noms_prenoms.csv', + enUtf8('nom;prenom\r\nOmbrelle;Iris\r\nLacasse;\r\nPervenche;Théo\r\nPervenche;\r\nMorillon;Aurèle\r\n'), + ); + expect(dans(e, '[data-champ="mode"] input:checked').value).toBe('mettreAJour'); + const { rendu } = await importer(e); + expect(rendu.resume).toMatchObject({ ajoutes: 1, misAJour: 0, inchanges: 2, retires: 0, reservationsRetirees: 0 }); + expect(comptesAffiches(e)).toEqual(comptesDu(rendu.resume)); + const courriels = new Map(participantsDe(e).map(({ id, courriel }) => [id, courriel])); + expect([courriels.get(1), courriels.get(2), courriels.get(4)]).toEqual(['iris@exemple.test', 'ondine@exemple.test', 'theo@exemple.test']); + const rapport = dans(e, '[data-rapport]'); + const plusieurs = refusee(rapport, 3); + expect(texteDe(plusieurs)).toContain(t('PLUSIEURS_CORRESPONDENT', { participants: [2, 3] })); + expect([...plusieurs.querySelectorAll('[data-participant]')].map((p) => [p.dataset.participant, texteDe(p)])).toEqual([ + ['2', 'Ondine Lacasse'], + ['3', 'Odile Lacasse'], + ]); + const deja = refusee(rapport, 5); + expect(texteDe(deja)).toContain(t('DEJA_DESIGNE', { premiereLigne: 4 })); + }); +}); + +// Deux tables de trois places, puis une génération enregistrée jusqu'à +// compter nombre propositions. +async function avecPropositions(seance, generation, nombre) { + await seance.executer('creerTable', { forme: 'ronde' }); + await seance.executer('creerTable', { forme: 'ronde' }); + for (let essai = 0; essai < 5 && seance.etat().charge.propositions.length < nombre; essai += 1) { + await generation.generer({ ...generation.reglagesProposes(), nombre, arret: 500 }); + } + expect(seance.etat().charge.propositions.length).toBeGreaterThanOrEqual(nombre); +} + +const QUATRE = () => [ + personne(1, 'Ombrelle', 'Iris', { appartenance: 'Coopérative Bleue' }), + personne(2, 'Pervenche', 'Théo', { appartenance: 'Grisaille' }), + personne(3, 'Lacasse', 'Ondine', { appartenance: 'Club des Merles' }), + personne(4, 'Fauvelle', 'Aurèle'), +]; + +describe('remplacer annonce ce qu’il détruit (§ 10.1)', () => { + test('l’aperçu se recalcule sous remplacer, la fenêtre porte les quatre comptes ; Annuler n’écrit rien ; Confirmer, une entrée, des identifiants neufs', async () => { + const e = await ouvrir({ + participants: QUATRE(), + siegesParDefaut: 3, + prepare: async (seance, generation) => { + await avecPropositions(seance, generation, 2); + const [table] = seance.etat().charge.tables; + await seance.executer('reserver', { participant: 1, table: table.id, siege: null, portee: 'tous', tour: null }); + await seance.executer('designer', { participant: 2, table: table.id, siege: null, libelle: 'Animation' }); + }, + }); + await choisir(e, 'remplace.csv', enUtf8('nom;prenom;appartenance\r\nTisserin;Basilie;cooperative bleue\r\nMorillon;Aurèle;\r\n')); + expect(tous(e, '[data-apercu] [data-fusion]').length).toBe(1); + choisirMode(e, 'remplacer'); + expect(tous(e, '[data-apercu] [data-fusion]').length).toBe(0); + expect(tous(e, '[data-apercu] [data-doublon]').length).toBe(0); + expect(cellule(dans(e, '[data-apercu]'), 2, 'appartenance')).toBe('cooperative bleue'); + + const charge = e.m.seance.etat().charge; + const bilan = bilanRemplacement(charge); + expect(bilan.reservations).toBeGreaterThan(0); + expect(bilan.titresPourvus).toBeGreaterThan(0); + expect(bilan.propositions).toBeGreaterThanOrEqual(2); + const attendus = [ + t('import.remplacer.participants', { n: bilan.participants }), + t('import.remplacer.reservations', { n: bilan.reservations }), + t('import.remplacer.titres', { n: bilan.titresPourvus }), + t('import.remplacer.propositions', { n: bilan.propositions }), + ]; + const avant = ecritures(e); + commande(e, 'importer').click(); + await vi.waitFor(() => expect(dans(e, 'dialog')?.open).toBe(true)); + expect([...dans(e, 'dialog').querySelectorAll('li')].map((li) => li.textContent)).toEqual(attendus); + dans(e, 'dialog [data-commande="renoncer"]').click(); + await vi.waitFor(() => expect(dans(e, 'dialog')).toBeNull()); + expect(imports(e)).toEqual([]); + expect(ecritures(e)).toBe(avant); + + const prochain = charge.prochainsIds.participant; + const entreesAvant = entrees(e); + commande(e, 'importer').click(); + await vi.waitFor(() => expect(dans(e, 'dialog')?.open).toBe(true)); + dans(e, 'dialog [data-commande="confirmer"]').click(); + await vi.waitFor(() => expect(dans(e, '[data-rapport]')).not.toBeNull()); + expect(imports(e).length).toBe(1); + expect(imports(e)[0][1].mode).toBe('remplacer'); + expect(entrees(e)).toBe(entreesAvant + 1); + const { resume } = await e.espion.mock.results.at(-1).value; + expect(resume).toMatchObject({ ajoutes: 2, misAJour: 0, inchanges: 0, retires: 4, reservationsRetirees: 2 }); + expect(comptesAffiches(e)).toEqual(comptesDu(resume)); + const apres = participantsDe(e); + expect(apres.map(({ nom, appartenance }) => [nom, appartenance])).toEqual([['Tisserin', 'cooperative bleue'], ['Morillon', null]]); + for (const { id } of apres) expect(id).toBeGreaterThanOrEqual(prochain); + }); +}); + +describe('importer selon l’état (§ 10.1, § 9)', () => { + const FICHIER = 'nom;prenom\r\nMorillon;Aurèle\r\n'; + + test('brouillon et proposé sans avertissement ; retenu avec IMPORT_EN_RETENU dans le rendu de seance.executer', async () => { + const preparations = [ + ['brouillon', null], + ['propose', (seance, generation) => avecPropositions(seance, generation, 1)], + [ + 'retenu', + async (seance, generation) => { + await avecPropositions(seance, generation, 1); + await seance.executer('retenirProposition', { id: seance.etat().charge.propositions[0].id }); + }, + ], + ]; + for (const [etat, prepare] of preparations) { + const e = await ouvrir({ participants: QUATRE(), siegesParDefaut: 3, prepare }); + expect(e.m.seance.etat().charge.evenement.etat).toBe(etat); + await choisir(e, 'tardif.csv', enUtf8(FICHIER)); + const { rendu } = await importer(e); + const codes = rendu.avertissements.map(({ code }) => code); + if (etat === 'retenu') expect(codes, etat).toContain('IMPORT_EN_RETENU'); + else expect(codes, etat).not.toContain('IMPORT_EN_RETENU'); + expect(participantsDe(e).length, etat).toBe(5); + } + }); + + test('bloqué : l’aperçu s’ouvre, l’import est refusé à côté de la commande, le message portant « Débloquer », rien d’écrit', async () => { + const e = await ouvrir({ + participants: QUATRE(), + prepare: (seance) => seance.executer('changerEtat', { etat: 'bloque' }), + }); + const avant = ecritures(e); + await choisir(e, 'tardif.csv', enUtf8(FICHIER)); + expect(vu(dans(e, '[data-apercu] h3'))).toBe(true); + const { refus } = await importer(e); + expect(refus.code).toBe('LECTURE_SEULE'); + const pose = dans(e, '[data-refus="LECTURE_SEULE"]'); + expect(commande(e, 'importer').parentElement.contains(pose)).toBe(true); + expect(texteDe(pose)).toContain('Débloquer'); + expect(ecritures(e)).toBe(avant); + expect(participantsDe(e).length).toBe(4); + }); + + test('en lecture : l’aperçu s’ouvre, l’import est refusé avec « Modifier », rien d’écrit', async () => { + const e = await ouvrir({ ecriture: false }); + expect(e.m.seance.etat().mode).toBe('lecture'); + const avant = ecritures(e); + await choisir(e, 'tardif.csv', enUtf8(FICHIER)); + expect(vu(dans(e, '[data-apercu] h3'))).toBe(true); + const { refus } = await importer(e); + expect(refus.code).toBe('MODE_LECTURE'); + const pose = dans(e, '[data-refus="MODE_LECTURE"]'); + expect(pose.querySelector('[data-commande="modifier"]').textContent).toBe(t('communs.remede.modifier')); + expect(ecritures(e)).toBe(avant); + }); +}); + +describe('le collage en bloc passe par le même analyseur (§ 10.2, § 14.10)', () => { + const TEXTE = [ + ['nom', 'prenom', 'appartenance'], + ['Ombrelle', 'Iris', '"Club\tdes ""Merles"""'], + ['Pervenche', 'Benoît', 'Grisaille'], + ].map((champs) => champs.join('\t')).join('\n'); + + test('un texte à tabulations collé : un aperçu sans encodage ; l’import rend les mêmes participants, champ par champ, que le même texte servi en fichier', async () => { + const collage = await ouvrir(); + await coller(collage, TEXTE); + expect(dans(collage, '[data-encodage]')).toBeNull(); + expect(dans(collage, '[data-separateur]').textContent).toBe(t('import.separateur.tabulation')); + await importer(collage); + const fichier = await ouvrir(); + await choisir(fichier, 'colle.csv', enUtf8(TEXTE)); + await importer(fichier); + expect(participantsDe(collage).length).toBe(2); + expect(participantsDe(collage)[0].appartenance).toBe('Club\tdes "Merles"'); + expect(participantsDe(collage)[1].prenom).toBe('Benoît'); + expect(participantsDe(collage)).toEqual(participantsDe(fichier)); + }); + + test('sans sa ligne d’en-tête : AUCUN_ENTETE, puis « Lire les colonnes dans l’ordre de la grille » en fait nom, prénom et appartenance', async () => { + const e = await ouvrir(); + await coller(e, TEXTE.split('\n').slice(1).join('\n')); + const pose = dans(e, '[data-refus="AUCUN_ENTETE"]'); + expect(pose.textContent).toContain(t('import.remede.aucunEnteteCollage')); + commande(e, 'ordreDeLaGrille').click(); + await vi.waitFor(() => expect(dans(e, '[data-refus]')).toBeNull()); + expect(association(dans(e, '[data-apercu]')).map(([, devient]) => devient)).toEqual([ + t('import.champ.nom'), t('import.champ.prenom'), t('import.champ.appartenance'), + ]); + expect(tous(e, '[data-apercu] [data-lignes] [data-ligne]').map((ligne) => ligne.dataset.ligne)).toEqual(['2', '3']); + await importer(e); + expect(participantsDe(e).map(({ nom, prenom }) => [nom, prenom])).toEqual([['Ombrelle', 'Iris'], ['Pervenche', 'Benoît']]); + }); + + test('une liste de noms d’une colonne : SEPARATEUR_INTROUVABLE, la même offre, chaque ligne entière en nom', async () => { + const e = await ouvrir(); + await coller(e, 'Ombrelle\nLacasse, Ondine\nPervenche'); + const pose = dans(e, '[data-refus="SEPARATEUR_INTROUVABLE"]'); + expect(pose.textContent).toContain(t('import.remede.separateurIntrouvableCollage')); + commande(e, 'ordreDeLaGrille').click(); + await vi.waitFor(() => expect(dans(e, '[data-refus]')).toBeNull()); + expect(lignesMontrees(dans(e, '[data-apercu]')).map(([nom]) => nom)).toEqual(['Ombrelle', 'Lacasse, Ondine', 'Pervenche']); + }); +}); + +describe('les cas du § 10.3', () => { + // Chacun des neuf cas, lu dans un conteneur : l'aperçu, puis le rapport. + function neufCas(conteneur) { + const colonnes = new Map(association(conteneur)); + expect(colonnes.get('Téléphone'), 'colonne non reconnue').toBe(t('import.colonne.nonReconnue')); + expect(cellule(conteneur, 6, 'appartenance'), 'sans appartenance').toBe(''); + expect(cellule(conteneur, 7, 'exclu'), 'exclusion').toBe(t('import.exclu.oui')); + expect(cellule(conteneur, 8, 'notes'), 'champ cité avec le séparateur').toBe('végétarien; sans noix, ni arachides'); + expect(cellule(conteneur, 9, 'notes'), 'champ cité sur deux lignes').toMatch(/premier tour\r?\nrepart/); + const doublons = [...conteneur.querySelectorAll('[data-doublon]')].map((d) => + [...d.querySelectorAll('[data-nombre]')].map((n) => n.textContent)); + expect(doublons, 'doublon').toEqual([['2', '10']]); + expect(cellule(conteneur, 11, 'prenom'), 'sans prénom').toBe(''); + const fusions = [...conteneur.querySelectorAll('[data-fusion] [data-orthographe] [data-texte]')].map((o) => o.textContent); + expect(fusions, 'paire d’orthographes').toEqual(['Société des Hérons', 'Societe des herons']); + expect(refusee(conteneur, 13), 'ligne refusée').not.toBeNull(); + } + + test('l’aperçu et le rapport des cas limites montrent chacun des neuf cas', async () => { + const e = await ouvrir(); + await choisir(e, 'participants_cas_limites.csv', await lireLivre(urlCasLimites)); + neufCas(dans(e, '[data-apercu]')); + await importer(e); + neufCas(dans(e, '[data-rapport]')); + }); + + test('la variante windows-1252 nomme windows-1252 et la virgule, et rend les mêmes participants', async () => { + const parVariante = []; + for (const [nom, url] of [['participants_cas_limites.csv', urlCasLimites], ['participants_cas_limites_cp1252.csv', urlCasLimites1252]]) { + const e = await ouvrir(); + await choisir(e, nom, await lireLivre(url)); + parVariante.push({ encodage: dans(e, '[data-encodage]').textContent, separateur: dans(e, '[data-separateur]').textContent }); + await importer(e); + parVariante.at(-1).participants = participantsDe(e); + } + expect(parVariante[1].encodage).toBe(t('import.encodage.windows1252')); + expect(parVariante[1].separateur).toBe(t('import.separateur.virgule')); + expect(parVariante[0].participants.length).toBe(13); + expect(parVariante[1].participants).toEqual(parVariante[0].participants); + }); +}); diff --git a/src/interface/import/Rapport.svelte b/src/interface/import/Rapport.svelte new file mode 100644 index 0000000..9c16e88 --- /dev/null +++ b/src/interface/import/Rapport.svelte @@ -0,0 +1,73 @@ + + + + +
+

{t('import.rapport.titre')}

+ {#if rapport.libelle === null} +

{t('import.rapport.rienEcrit')}

+ {/if} +
    + {#each COMPTES as [nom, cle] (nom)} +
  • + {format.nombre(rapport.resume[nom])} + {t(cle, { n: rapport.resume[nom] })} +
  • + {/each} +
+ +
+ + diff --git a/src/interface/import/SectionImport.svelte b/src/interface/import/SectionImport.svelte index 8307797..6a2172f 100644 --- a/src/interface/import/SectionImport.svelte +++ b/src/interface/import/SectionImport.svelte @@ -2,12 +2,292 @@ License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) --> -
+ + +

{t('coquille.section.import')}

+

{t('import.consigne')}

+
+ +
+ + {#if source !== null} + {#key source} +
+

{t('import.apercu.titre')}

+ {#if modele.global === null} +
+ {t('import.mode.titre')} + {#each MODES_D_IMPORT as { mode: valeur, cle } (valeur)} + + {/each} +
+ {/if} + +
+ {#if grille !== null} + + {/if} + {#if modele.global === null} + + {/if} + {#if modele.refusees.length > 0} + enregistrerRefus(exporterRefus(apercu))} + /> + {/if} + +
+ {#if enregistre !== null} +

{t('import.refusEnregistres')} {enregistre}

+ {/if} +
+ {/key} + {/if} + + {#if rapport !== null} + +
+ {#if rapport.resume.refusees.length > 0} + enregistrerRefus(exporterRefus(rapport.apercu, rapport.resume.refusees))} + /> + {/if} + (rapport = null)} /> +
+ {#if enregistre !== null} +

{t('import.refusEnregistres')} {enregistre}

+ {/if} + {/if} + + {#if confirmation !== null} + (confirmation = null)} + /> + {/if}
+ + diff --git a/src/interface/import/modele.js b/src/interface/import/modele.js new file mode 100644 index 0000000..72c2c18 --- /dev/null +++ b/src/interface/import/modele.js @@ -0,0 +1,309 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Le modèle de l'aperçu d'import (§ 10.1, § 10.2) : ce que l'aperçu montre, +// en clés de libellés et en données, d'un fichier décodé, d'un fichier que +// decoder refuse, ou d'un collage. Le texte vient de la traduction ; ce +// module ne choisit que les clés, et ne recalcule rien de ce que l'aperçu de +// src/csv établit : association, lignes, fusions et doublons s'y lisent tels +// quels. +import { CHAMPS } from '../../application/csv.js'; + +/** La clé du nom de chaque encodage que rend decoder, dans l'ordre de ses + * quatre temps. */ +export const ENCODAGES = new Map([ + ['utf-8-bom', 'import.encodage.utf8Marque'], + ['utf-16le', 'import.encodage.utf16le'], + ['utf-16be', 'import.encodage.utf16be'], + ['utf-8', 'import.encodage.utf8'], + ['windows-1252', 'import.encodage.windows1252'], +]); + +/** La clé du nom de chaque séparateur de l'aperçu ; null : le fichier n'a + * qu'une colonne. */ +export const SEPARATEURS = new Map([ + [';', 'import.separateur.pointVirgule'], + [',', 'import.separateur.virgule'], + ['\t', 'import.separateur.tabulation'], + [null, 'import.separateur.uneColonne'], +]); + +// La clé du nom de chaque champ, dans l'ordre de CHAMPS. +const CLES_DES_CHAMPS = new Map([ + ['nom', 'import.champ.nom'], + ['prenom', 'import.champ.prenom'], + ['appartenance', 'import.champ.appartenance'], + ['courriel', 'import.champ.courriel'], + ['titre_pressenti', 'import.champ.titrePressenti'], + ['exclu', 'import.champ.exclu'], + ['notes', 'import.champ.notes'], +]); + +// La clé de la valeur lue d'« exclu » ; une cellule vide n'en a pas. +const CLES_D_EXCLU = new Map([ + [true, 'import.exclu.oui'], + [false, 'import.exclu.non'], +]); + +// Le nom de chaque champ dans ChampsLus, celui de l'aperçu de src/csv. +const CHAMPS_LUS = new Map([['titre_pressenti', 'titrePressenti']]); + +/** Les trois modes de l'import (§ 10.1), dans l'ordre où l'écran les + * offre, et la clé de leur nom. */ +export const MODES_D_IMPORT = Object.freeze( + [ + ['ajouter', 'import.mode.ajouter'], + ['mettreAJour', 'import.mode.mettreAJour'], + ['remplacer', 'import.mode.remplacer'], + ].map(([mode, cle]) => Object.freeze({ mode, cle })), +); + +/** Les colonnes du tableau des lignes : chaque champ, dans l'ordre de + * CHAMPS, et la clé de son nom. */ +export const COLONNES_DES_LIGNES = Object.freeze(CHAMPS.map((champ) => Object.freeze({ champ, cle: CLES_DES_CHAMPS.get(champ) }))); + +// Le remède d'un refus de decoder : le code que ses détails portent, que le +// texte du refus compose déjà (contrat « Refus et avertissements »). +const REFUS_DE_DECODER = new Set(['UTF16_SANS_MARQUE', 'UTF16_INVALIDE', 'UTF8_INVALIDE', 'CARACTERE_NUL']); + +// La clé du remède de chaque refus global de l'aperçu. Un caractère nul n'y +// arrive que d'un texte collé : celui d'un fichier, decoder le refuse. +const REMEDES_DE_L_APERCU = new Map([ + ['CARACTERE_NUL', 'import.remede.caractereNulCollage'], + ['AUCUN_ENTETE', 'import.remede.aucunEntete'], + ['NOM_NON_ASSOCIE', 'import.remede.nomNonAssocie'], + ['AUCUNE_LIGNE_VALIDE', 'import.remede.aucuneLigneValide'], + ['GUILLEMET_OUVERT', 'import.remede.guillemetOuvert'], + ['SEPARATEUR_INTROUVABLE', 'import.remede.separateurIntrouvable'], +]); + +// Le remède d'un collage auquel l'aperçu offre l'ordre de la grille. +const REMEDES_DE_LA_GRILLE = new Map([ + ['AUCUN_ENTETE', 'import.remede.aucunEnteteCollage'], + ['SEPARATEUR_INTROUVABLE', 'import.remede.separateurIntrouvableCollage'], +]); + +// La valeur d'une table de clés, ou RangeError qui nomme ce qui manque : +// jamais une clé devinée. +function dans(table, valeur, quoi) { + if (!table.has(valeur)) throw new RangeError(`import : ${quoi} inconnu ${JSON.stringify(valeur)}`); + return table.get(valeur); +} + +// Vrai quand l'aperçu offre la lecture dans l'ordre de la grille : un texte +// non blanc sans en-tête reconnu, ou une seule colonne sans séparateur. +function offreLaGrille(apercu) { + if (apercu.refusGlobal === 'AUCUN_ENTETE') return apercu.entetes.length > 0; + return apercu.refusGlobal === 'SEPARATEUR_INTROUVABLE' && apercu.refusGlobalLigne === null; +} + +// Le refus global d'un refus de decoder : sa cause, le code ; son remède, +// le code de details.remede, déjà dans la cause. +function globalDeDecoder({ code, details }) { + if (!REFUS_DE_DECODER.has(code)) throw new RangeError(`import : refus de decoder inconnu ${JSON.stringify(code)}`); + return { cle: code, details, remede: { cle: details.remede, details: {}, dansLaCause: true } }; +} + +// Le refus global d'un aperçu, null sans refus. Un collage auquel l'aperçu +// offre l'ordre de la grille prend le remède qui le nomme. +function globalDeLApercu(apercu, collage) { + const code = apercu.refusGlobal; + if (code === null) return null; + const remede = collage && offreLaGrille(apercu) ? REMEDES_DE_LA_GRILLE.get(code) : dans(REMEDES_DE_L_APERCU, code, 'refus global'); + const rang = apercu.refusGlobalLigne; + const details = rang === null ? {} : { ligne: rang }; + return { cle: code, details, remede: { cle: remede, details, dansLaCause: false } }; +} + +// Le nom du séparateur, quand la lecture l'a établi : rien sous un caractère +// nul, un séparateur introuvable ou un texte blanc, où l'aperçu porte null +// sans que le texte ait une seule colonne. +function separateurDe(apercu) { + const nonEtabli = + apercu.refusGlobal === 'CARACTERE_NUL' || + apercu.refusGlobal === 'SEPARATEUR_INTROUVABLE' || + apercu.entetes.length === 0; + return nonEtabli ? null : dans(SEPARATEURS, apercu.separateur, 'séparateur'); +} + +// Chaque colonne de l'en-tête : son rang, son en-tête tel qu'écrit, et la +// clé de ce qu'elle devient — un champ, une colonne à trancher, une colonne +// écartée par le choix d'une autre, une colonne non reconnue. +function colonnesDe({ entetes, association }) { + const champDuRang = new Map(CHAMPS.filter((champ) => Object.hasOwn(association.colonnes, champ)).map((champ) => [association.colonnes[champ], champ])); + const aTrancher = new Set(association.ambigus.flatMap(({ rangs }) => rangs)); + const nonReconnues = new Set(association.nonReconnues); + return entetes.map((entete, rang) => { + let cle = 'import.colonne.ecartee'; + if (champDuRang.has(rang)) cle = CLES_DES_CHAMPS.get(champDuRang.get(rang)); + else if (aTrancher.has(rang)) cle = 'import.colonne.aTrancher'; + else if (nonReconnues.has(rang)) cle = 'import.colonne.nonReconnue'; + return { rang, entete, cle }; + }); +} + +// Chaque ligne valide : son rang, puis une cellule par champ, dans l'ordre +// de CHAMPS : le texte lu, ou la clé de la valeur d'« exclu ». +function lignesDe(lignes) { + return lignes.map(({ ligne, champs }) => ({ + ligne, + cellules: CHAMPS.map((champ) => { + const valeur = champs[CHAMPS_LUS.get(champ) ?? champ]; + if (champ === 'exclu') return { champ, texte: null, cle: valeur === null ? null : CLES_D_EXCLU.get(valeur) }; + return { champ, texte: valeur, cle: null }; + }), + })); +} + +/** + * Les lignes refusées, par l'aperçu ou par la mise à jour, telles que + * l'aperçu et le rapport les montrent : le rang, le code, et les détails que + * reçoit le motif — la ligne refusée sans son code ni ses champs découpés, + * comme exporterRefus les passe au motif du CSV. + * + * @param {Array<{ligne: number, code: string, brut: string[]}>} refusees + * @returns {Array<{ligne: number, code: string, details: Object}>} + */ +export function refuseesDe(refusees) { + return refusees.map(({ code, brut, ...details }) => ({ ligne: details.ligne, code, details })); +} + +/** + * Les personnes que désigne une liste d'identifiants, dans son ordre ; un + * identifiant que la liste des participants ne porte plus est omis. + * + * @param {Array<{id: number, nom: string, prenom: string|null}>} participants + * @param {number[]} ids + * @returns {Array<{id: number, nom: string, prenom: string|null}>} + */ +export function nommer(participants, ids) { + const parId = new Map(participants.map((participant) => [participant.id, participant])); + return ids.filter((id) => parId.has(id)).map((id) => { + const { nom, prenom } = parId.get(id); + return { id, nom, prenom }; + }); +} + +/** + * Le mode d'import proposé (§ 10.1) : Ajouter pour un événement sans + * participant, Mettre à jour sinon. Remplacer ne se propose jamais : il se + * choisit. + * + * @param {Array} participants + * @returns {'ajouter'|'mettreAJour'} + */ +export function modePropose(participants) { + return participants.length === 0 ? 'ajouter' : 'mettreAJour'; +} + +// Un modèle vide : rien de lu. +const RIEN = Object.freeze({ + encodage: null, + separateur: null, + colonnes: [], + ambigus: [], + valides: 0, + lignes: [], + refusees: [], + fusions: [], + doublons: [], + homonymes: [], + multilignes: [], + global: null, +}); + +/** + * Ce que l'aperçu montre, d'un fichier ou d'un collage : encodage (null pour + * un collage) et séparateur nommés — « une seule colonne » pour un + * séparateur null —, association par colonne, ambiguïtés, lignes valides et + * refusées, fusions, doublons, et le refus global, de decoder (refusDecodage, + * apercu null) ou de l'aperçu (apercu.refusGlobal), avec la clé de sa cause + * et celle de son remède — en clés de libellés et en données. + * + * La cause est le code du refus, sa propre clé ; ses détails sont ceux de + * decoder, ou { ligne } quand l'aperçu nomme un rang. Le remède d'un refus + * de decoder est le code de details.remede, que le texte de la cause compose + * déjà : remede.dansLaCause le dit, et l'écran ne le répète pas. + * + * @param {{encodage?: string|null, apercu?: Object|null, refusDecodage?: {code: string, details: Object}|null}} entree + * encodage : celui que rend decoder, null pour un collage + * @returns {{ + * encodage: string|null, separateur: string|null, + * colonnes: Array<{rang: number, entete: string, cle: string}>, + * ambigus: Array<{champ: string, cle: string, rangs: Array<{rang: number, entete: string}>}>, + * valides: number, + * lignes: Array<{ligne: number, cellules: Array<{champ: string, texte: string|null, cle: string|null}>}>, + * refusees: Array<{ligne: number, code: string, details: Object}>, + * fusions: Array<{affichee: string, orthographes: Array<{texte: string, lignes: number}>}>, + * doublons: Array<{lignes: number[], existants: number[]}>, + * homonymes: Array<{ligne: number, participants: number[]}>, + * multilignes: Array<{ligne: number, rangs: number[]}>, + * global: null|{cle: string, details: Object, remede: {cle: string, details: Object, dansLaCause: boolean}}, + * }} + * encodage, separateur, colonnes[].cle et ambigus[].cle sont des clés de + * traduction, comme lignes[].cellules[].cle, celle de la valeur + * d'« exclu » ; refusees[].details, ceux que reçoit le motif d'une ligne + * (motifLigne) ; fusions[].orthographes[].lignes, le nombre de lignes de + * l'orthographe, 0 pour celle que seuls des participants existants portent + * @throws {RangeError} un encodage, un séparateur ou un refus sans clé + */ +export function modeleApercu({ encodage = null, apercu = null, refusDecodage = null }) { + if (refusDecodage !== null) return { ...RIEN, global: globalDeDecoder(refusDecodage) }; + if (apercu === null) return { ...RIEN }; + const { entetes, association } = apercu; + return { + encodage: encodage === null ? null : dans(ENCODAGES, encodage, 'encodage'), + separateur: separateurDe(apercu), + colonnes: colonnesDe(apercu), + ambigus: association.ambigus.map(({ champ, rangs }) => ({ + champ, + cle: CLES_DES_CHAMPS.get(champ), + rangs: rangs.map((rang) => ({ rang, entete: entetes[rang] })), + })), + valides: apercu.lignes.length, + lignes: lignesDe(apercu.lignes), + refusees: refuseesDe(apercu.refusees), + fusions: apercu.fusions.map(({ affichee, orthographes }) => ({ + affichee, + orthographes: orthographes.map(({ texte, lignes }) => ({ texte, lignes: lignes.length })), + })), + doublons: apercu.doublons.map(({ lignes, existants }) => ({ lignes, existants })), + homonymes: apercu.homonymes, + multilignes: apercu.multilignes, + global: globalDeLApercu(apercu, encodage === null), + }; +} + +/** + * Le texte relu dans l'ordre de la grille : précédé d'une ligne d'en-têtes — + * les largeur premiers de CHAMPS, puis des en-têtes vides au-delà de sept —, + * joints par le séparateur de l'aperçu. Sous AUCUN_ENTETE d'un texte non + * blanc, largeur vaut apercu.entetes.length ; sous SEPARATEUR_INTROUVABLE + * sans candidat (refusGlobalLigne null : une seule colonne), largeur vaut 1 + * et la ligne est « nom ». null sous tout autre aperçu. Offert pour un + * collage, jamais appliqué seul : le texte rendu repasse par apercevoir, et + * les rangs de ce second aperçu comptent la ligne ajoutée. + * + * @param {string} texte le texte de l'aperçu + * @param {Object} apercu l'aperçu de ce texte + * @returns {string|null} + */ +export function avecEntetesDeLaGrille(texte, apercu) { + if (!offreLaGrille(apercu)) return null; + const largeur = apercu.refusGlobal === 'AUCUN_ENTETE' ? apercu.entetes.length : 1; + const entetes = Array.from({ length: largeur }, (_, rang) => CHAMPS[rang] ?? ''); + return `${entetes.join(apercu.separateur ?? '')}\n${texte}`; +} + +/** + * Le motif d'une ligne refusée, tel que l'aperçu et le rapport l'affichent : + * (code, details) → t(code, details). Le CSV des refus ne le reçoit pas : + * son motif est celui de libelle, que lie exporterRefus de + * src/application/csv.js. + * + * @param {(cle: string, details?: Object) => string} t + * @returns {(code: string, details: Object) => string} + */ +export function motifLigne(t) { + return (code, details) => t(code, details); +} diff --git a/src/interface/import/modele.test.js b/src/interface/import/modele.test.js new file mode 100644 index 0000000..0e49482 --- /dev/null +++ b/src/interface/import/modele.test.js @@ -0,0 +1,408 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Le modèle de l'aperçu d'import (§ 10.1, § 10.2) : la lecture d'un collage +// sans en-tête dans l'ordre de la grille, la clé de la cause et celle du +// remède de chaque refus global, l'encodage et le séparateur nommés, et les +// titres que « Remplacer » annonce, comptés par la règle unique des titres +// pourvus. Les noms d'épreuve sont inventés. +import assert from 'node:assert/strict'; +import { describe, test } from '../../../test/lanceur.js'; +import { TABLES_APPLICATION } from '../../application/libelles.js'; +import { apercevoir, bilanRemplacement, CHAMPS, decoder } from '../../application/csv.js'; +import { comptesTitres, titresNonPourvus } from '../../application/listes_travail.js'; +import { creerTraduction } from '../../application/traduction.js'; +import { creerCharge } from '../../stockage/document.js'; +import { TABLES_INTERFACE } from '../libelles.js'; +import { + COLONNES_DES_LIGNES, + ENCODAGES, + MODES_D_IMPORT, + SEPARATEURS, + avecEntetesDeLaGrille, + modeleApercu, + modePropose, + motifLigne, + nommer, + refuseesDe, +} from './modele.js'; + +const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]); + +const TAB = '\t'; +const lignes = (...enregistrements) => `${enregistrements.join('\n')}\n`; + +// Les noms lus des lignes valides d'un aperçu, dans l'ordre du texte. +const nomsLus = (apercu) => apercu.lignes.map(({ champs }) => champs.nom); + +// Le refus de decoder sur ces octets, sous la forme { code, details }. +function refusDeDecoder(octets) { + try { + decoder(octets); + } catch (erreur) { + return { code: erreur.code, details: erreur.details }; + } + throw new Error('decoder a accepté les octets'); +} + +const octets = (...valeurs) => Uint8Array.from(valeurs); +const utf8 = (texte) => new TextEncoder().encode(texte); +const MARQUE_UTF8 = [0xef, 0xbb, 0xbf]; + +describe('avecEntetesDeLaGrille : un collage sans en-tête lu dans l’ordre de la grille (§ 10.2)', () => { + test('trois colonnes à tabulations, sans en-tête : AUCUN_ENTETE, puis nom, prénom et appartenance, les rangs comptant la ligne ajoutée', () => { + const texte = lignes( + ['Ombrelle', 'Iris', 'Club des Merles'].join(TAB), + ['Pervenche', 'Théo', 'Grisaille'].join(TAB), + ); + const sans = apercevoir(texte); + assert.equal(sans.refusGlobal, 'AUCUN_ENTETE'); + const avec = avecEntetesDeLaGrille(texte, sans); + assert.equal(avec, `${['nom', 'prenom', 'appartenance'].join(TAB)}\n${texte}`); + const lu = apercevoir(avec); + assert.equal(lu.refusGlobal, null); + assert.deepEqual(lu.association.colonnes, { nom: 0, prenom: 1, appartenance: 2 }); + assert.equal(lu.lignes.length, 2); + assert.deepEqual(lu.lignes.map(({ ligne }) => ligne), [2, 3]); + assert.deepEqual(lu.lignes[0].champs.appartenance, 'Club des Merles'); + }); + + test('deux colonnes : nom et prénom', () => { + const texte = lignes(['Ombrelle', 'Iris'].join(TAB), ['Pervenche', 'Théo'].join(TAB)); + const sans = apercevoir(texte); + assert.equal(sans.refusGlobal, 'AUCUN_ENTETE'); + const lu = apercevoir(avecEntetesDeLaGrille(texte, sans)); + assert.equal(lu.refusGlobal, null); + assert.deepEqual(lu.association.colonnes, { nom: 0, prenom: 1 }); + assert.deepEqual(nomsLus(lu), ['Ombrelle', 'Pervenche']); + }); + + test('neuf colonnes : les sept champs de la grille, puis deux en-têtes vides, listés non reconnus', () => { + const rangee = (nom) => [nom, 'Iris', 'Grisaille', 'iris@exemple.test', 'animation', 'non', 'note', 'x1', 'x2'].join(TAB); + const texte = lignes(rangee('Ombrelle'), rangee('Lacasse')); + const sans = apercevoir(texte); + assert.equal(sans.refusGlobal, 'AUCUN_ENTETE'); + assert.equal(sans.entetes.length, 9); + const avec = avecEntetesDeLaGrille(texte, sans); + assert.equal(avec.split('\n')[0], [...CHAMPS, '', ''].join(TAB)); + const lu = apercevoir(avec); + assert.equal(lu.refusGlobal, null); + assert.equal(CHAMPS.length, 7); + assert.deepEqual(lu.association.colonnes, Object.fromEntries(CHAMPS.map((champ, rang) => [champ, rang]))); + assert.deepEqual(lu.association.nonReconnues, [7, 8]); + assert.deepEqual(nomsLus(lu), ['Ombrelle', 'Lacasse']); + }); + + test('une liste de noms d’une colonne, « Lacasse, Ondine » compris : SEPARATEUR_INTROUVABLE sans rang, puis une colonne, la ligne entière en nom', () => { + const texte = lignes('Ombrelle', 'Lacasse, Ondine', 'Pervenche'); + const sans = apercevoir(texte); + assert.equal(sans.refusGlobal, 'SEPARATEUR_INTROUVABLE'); + assert.equal(sans.refusGlobalLigne, null); + const avec = avecEntetesDeLaGrille(texte, sans); + assert.equal(avec, `nom\n${texte}`); + const lu = apercevoir(avec); + assert.equal(lu.refusGlobal, null); + assert.equal(lu.separateur, null); + assert.deepEqual(nomsLus(lu), ['Ombrelle', 'Lacasse, Ondine', 'Pervenche']); + }); + + test('un texte blanc, un guillemet ouvert, un séparateur écarté à un rang nommé, un aperçu sans refus : null', () => { + const blanc = ' \n\t\n'; + assert.equal(apercevoir(blanc).refusGlobal, 'AUCUN_ENTETE'); + assert.equal(avecEntetesDeLaGrille(blanc, apercevoir(blanc)), null); + + const guillemet = lignes('nom;prenom', '"Ombrelle;Iris', 'Pervenche;Théo'); + assert.equal(apercevoir(guillemet).refusGlobal, 'GUILLEMET_OUVERT'); + assert.equal(avecEntetesDeLaGrille(guillemet, apercevoir(guillemet)), null); + + const ecarte = lignes('Lacasse, Ondine', 'Ombrelle', 'Pervenche'); + const refus = apercevoir(ecarte); + assert.equal(refus.refusGlobal, 'SEPARATEUR_INTROUVABLE'); + assert.equal(refus.refusGlobalLigne, 2); + assert.equal(avecEntetesDeLaGrille(ecarte, refus), null); + + const valide = lignes('nom;prenom', 'Ombrelle;Iris'); + assert.equal(avecEntetesDeLaGrille(valide, apercevoir(valide)), null); + }); +}); + +// Chaque refus global de la table des décisions d'I6, la façon de le +// produire, et la clé du remède attendue. Les quatre de decoder passent par +// refusDecodage ; les autres, par l'aperçu d'un fichier (encodage donné) ou +// d'un collage (encodage null). +const REFUS = [ + ['UTF16_SANS_MARQUE', { refusDecodage: refusDeDecoder(octets(0x6e, 0x00, 0x6f, 0x00)) }, 'ENREGISTRER_EN_UTF8'], + ['UTF16_INVALIDE', { refusDecodage: refusDeDecoder(octets(0xff, 0xfe, 0x6e, 0x00, 0x6f)) }, 'ENREGISTRER_EN_UTF8'], + ['UTF8_INVALIDE', { refusDecodage: refusDeDecoder(octets(...MARQUE_UTF8, 0x6e, 0xff, 0x6f)) }, 'ENREGISTRER_EN_UTF8'], + ['CARACTERE_NUL', { refusDecodage: refusDeDecoder(octets(...MARQUE_UTF8, ...utf8('nom\nOmbrelle\u{0}\n'))) }, 'ENREGISTRER_EN_UTF8'], + ['CARACTERE_NUL', { encodage: null, apercu: apercevoir('nom\nOmbrelle\u{0}\n') }, 'import.remede.caractereNulCollage'], + ['AUCUN_ENTETE', { encodage: 'utf-8', apercu: apercevoir(lignes('a;b', 'Ombrelle;Iris')) }, 'import.remede.aucunEntete'], + ['AUCUN_ENTETE', { encodage: null, apercu: apercevoir(lignes('Ombrelle;Iris', 'Pervenche;Théo')) }, 'import.remede.aucunEnteteCollage'], + ['AUCUN_ENTETE', { encodage: null, apercu: apercevoir(' \n') }, 'import.remede.aucunEntete'], + ['NOM_NON_ASSOCIE', { encodage: 'utf-8', apercu: apercevoir(lignes('prenom;notes', 'Iris;x')) }, 'import.remede.nomNonAssocie'], + ['AUCUNE_LIGNE_VALIDE', { encodage: 'utf-8', apercu: apercevoir(lignes('nom;prenom', ';Iris')) }, 'import.remede.aucuneLigneValide'], + ['GUILLEMET_OUVERT', { encodage: 'utf-8', apercu: apercevoir(lignes('nom;prenom', '"Ombrelle;Iris')) }, 'import.remede.guillemetOuvert'], + [ + 'SEPARATEUR_INTROUVABLE', + { encodage: 'utf-8', apercu: apercevoir(lignes('nom;prenom', 'Ombrelle;Iris;x', 'Pervenche')) }, + 'import.remede.separateurIntrouvable', + ], + [ + 'SEPARATEUR_INTROUVABLE', + { encodage: null, apercu: apercevoir(lignes('Ombrelle', 'Lacasse, Ondine')) }, + 'import.remede.separateurIntrouvableCollage', + ], +]; + +describe('modeleApercu : la cause et le remède de chaque refus global (§ 10.1)', () => { + test('chaque refus de la table des décisions rend la clé de sa cause, le code, et celle de son remède, toutes deux traduites', () => { + const codes = new Set(REFUS.map(([code]) => code)); + assert.equal(codes.size, 9); + assert.equal(REFUS.length, 13); + for (const [code, entree, remede] of REFUS) { + if (entree.apercu !== undefined) assert.equal(entree.apercu.refusGlobal, code, code); + if (entree.refusDecodage !== undefined) assert.equal(entree.refusDecodage.code, code, code); + const { global } = modeleApercu(entree); + assert.notEqual(global, null, code); + assert.equal(global.cle, code); + assert.equal(global.remede.cle, remede, code); + assert.equal(typeof t(global.cle, global.details), 'string'); + assert.ok(t(global.cle, global.details).length > 0, code); + assert.ok(t(global.remede.cle, global.remede.details).length > 0, code); + } + }); + + test('un refus de decoder : son texte compose déjà le remède, que le modèle dit et ne répète pas ; un refus de l’aperçu le porte à part', () => { + const [, entree] = REFUS[0]; + const { global } = modeleApercu(entree); + assert.equal(global.remede.dansLaCause, true); + assert.ok(t(global.cle, global.details).includes(t(global.remede.cle, global.remede.details))); + for (const [code, apercuDonne] of REFUS.slice(4)) { + assert.equal(modeleApercu(apercuDonne).global.remede.dansLaCause, false, code); + } + }); + + test('le rang d’un guillemet ouvert et d’un séparateur écarté passe à la cause et au remède', () => { + const guillemet = modeleApercu(REFUS[10][1]).global; + assert.deepEqual(guillemet.details, { ligne: 2 }); + assert.match(t(guillemet.cle, guillemet.details), /ligne 2/); + assert.match(t(guillemet.remede.cle, guillemet.remede.details), /2/); + const separateur = modeleApercu(REFUS[11][1]).global; + assert.deepEqual(separateur.details, { ligne: 2 }); + assert.match(t(separateur.cle, separateur.details), /ligne 2/); + }); + + test('un code de refus que la table ne connaît pas lève, jamais un remède muet', () => { + const apercu = { ...apercevoir(lignes('nom', 'Ombrelle')), refusGlobal: 'REFUS_INVENTE' }; + assert.throws(() => modeleApercu({ encodage: 'utf-8', apercu }), RangeError); + assert.throws( + () => modeleApercu({ refusDecodage: { code: 'REFUS_INVENTE', details: { remede: 'ENREGISTRER_EN_UTF8' } } }), + RangeError, + ); + }); + + test('sous un refus de decoder : ni encodage, ni séparateur, ni colonne, ni ligne', () => { + const modele = modeleApercu(REFUS[0][1]); + assert.equal(modele.encodage, null); + assert.equal(modele.separateur, null); + for (const liste of ['colonnes', 'ambigus', 'lignes', 'refusees', 'fusions', 'doublons', 'homonymes', 'multilignes']) { + assert.deepEqual(modele[liste], [], liste); + } + assert.equal(modele.valides, 0); + }); +}); + +describe('modeleApercu : ce que l’aperçu montre (§ 10.1)', () => { + test('chaque encodage de decoder et chaque séparateur ont leur clé, qui se traduit, et un collage n’a pas d’encodage', () => { + const encodages = ['utf-8-bom', 'utf-16le', 'utf-16be', 'utf-8', 'windows-1252']; + assert.deepEqual([...ENCODAGES.keys()], encodages); + for (const encodage of encodages) assert.ok(t(ENCODAGES.get(encodage)).length > 0, encodage); + assert.deepEqual([...SEPARATEURS.keys()], [';', ',', '\t', null]); + for (const cle of SEPARATEURS.values()) assert.ok(t(cle).length > 0, cle); + assert.equal(t(SEPARATEURS.get(null)), 'une seule colonne'); + + const apercu = apercevoir(lignes('nom,prenom', 'Ombrelle,Iris')); + const fichier = modeleApercu({ encodage: 'windows-1252', apercu }); + assert.equal(fichier.encodage, ENCODAGES.get('windows-1252')); + assert.equal(fichier.separateur, SEPARATEURS.get(',')); + const collage = modeleApercu({ apercu }); + assert.equal(collage.encodage, null); + assert.equal(collage.separateur, SEPARATEURS.get(',')); + const uneColonne = modeleApercu({ encodage: 'utf-8', apercu: apercevoir(lignes('nom', 'Lacasse, Ondine')) }); + assert.equal(uneColonne.separateur, SEPARATEURS.get(null)); + }); + + test('un séparateur que la lecture n’a pas établi n’est pas nommé « une seule colonne »', () => { + for (const [code, entree] of REFUS.slice(4)) { + const { separateur } = modeleApercu(entree); + const etabli = ['AUCUN_ENTETE', 'NOM_NON_ASSOCIE', 'AUCUNE_LIGNE_VALIDE', 'GUILLEMET_OUVERT'].includes(code) && + entree.apercu.entetes.length > 0; + if (etabli) assert.equal(separateur, SEPARATEURS.get(entree.apercu.separateur), code); + else assert.equal(separateur, null, code); + } + }); + + test('colonnes associées, non reconnues, à trancher puis écartée ; ambiguïtés nommées par leurs en-têtes', () => { + const texte = lignes('Nom;Organisation;Entreprise;Allergies', 'Ombrelle;Club des Merles;Grisaille;aucune'); + const ambigu = modeleApercu({ encodage: 'utf-8', apercu: apercevoir(texte) }); + assert.deepEqual( + ambigu.colonnes.map(({ rang, entete, cle }) => [rang, entete, cle]), + [ + [0, 'Nom', 'import.champ.nom'], + [1, 'Organisation', 'import.colonne.aTrancher'], + [2, 'Entreprise', 'import.colonne.aTrancher'], + [3, 'Allergies', 'import.colonne.nonReconnue'], + ], + ); + assert.deepEqual(ambigu.ambigus, [ + { champ: 'appartenance', cle: 'import.champ.appartenance', rangs: [{ rang: 1, entete: 'Organisation' }, { rang: 2, entete: 'Entreprise' }] }, + ]); + const tranche = modeleApercu({ encodage: 'utf-8', apercu: apercevoir(texte, { choix: { appartenance: 2 } }) }); + assert.deepEqual(tranche.colonnes.map(({ cle }) => cle), [ + 'import.champ.nom', 'import.colonne.ecartee', 'import.champ.appartenance', 'import.colonne.nonReconnue', + ]); + assert.deepEqual(tranche.ambigus, []); + for (const { cle } of [...ambigu.colonnes, ...tranche.colonnes]) assert.ok(t(cle).length > 0, cle); + }); + + test('chaque champ de CHAMPS a sa clé, qui se traduit', () => { + const texte = lignes(CHAMPS.join(';'), ['Ombrelle', 'Iris', 'Grisaille', 'i@exemple.test', 'x', 'non', 'n'].join(';')); + const { colonnes } = modeleApercu({ encodage: 'utf-8', apercu: apercevoir(texte) }); + assert.equal(colonnes.length, 7); + for (const { cle } of colonnes) { + assert.match(cle, /^import\.champ\./); + assert.ok(t(cle).length > 0, cle); + } + }); + + test('lignes refusées, fusions et doublons, en données ; le motif affiché est celui de t', () => { + const existants = [{ id: 4, nom: 'Ombrelle', prenom: 'Iris', appartenance: 'Coopérative Bleue' }]; + const texte = lignes( + 'nom;prenom;appartenance;exclu', + 'Ombrelle;Iris;cooperative bleue;', + ';Théo;Grisaille;', + 'Pervenche;Théo;COOPÉRATIVE BLEUE;peut-être', + 'Pervenche;Théo;COOPÉRATIVE BLEUE;x', + 'Lacasse;Ondine;;', + ); + const modele = modeleApercu({ encodage: 'utf-8', apercu: apercevoir(texte, { participants: existants }) }); + assert.equal(modele.valides, 3); + assert.deepEqual(modele.refusees, [ + { ligne: 3, code: 'NOM_ABSENT', details: { ligne: 3, valeur: null } }, + { ligne: 4, code: 'EXCLU_INCONNU', details: { ligne: 4, valeur: 'peut-être' } }, + ]); + assert.deepEqual(modele.fusions, [ + { + affichee: 'Coopérative Bleue', + orthographes: [ + { texte: 'Coopérative Bleue', lignes: 0 }, + { texte: 'cooperative bleue', lignes: 1 }, + { texte: 'COOPÉRATIVE BLEUE', lignes: 1 }, + ], + }, + ]); + assert.deepEqual(modele.doublons, [{ lignes: [2], existants: [4] }]); + const motif = motifLigne(t); + const [, refusee] = modele.refusees; + assert.equal(motif(refusee.code, refusee.details), t('EXCLU_INCONNU', { ligne: 4, valeur: 'peut-être' })); + assert.match(motif(refusee.code, refusee.details), /peut-être/); + }); +}); + +describe('les lignes, les refus de la mise à jour, les personnes désignées, le mode proposé (§ 10.1)', () => { + test('chaque ligne valide montre ses sept champs dans l’ordre de CHAMPS ; exclu par sa clé, vide sans valeur', () => { + const texte = lignes( + 'notes;exclu;nom;prenom', + 'arrive tard;Oui;Ombrelle;Iris', + ';;Pervenche;', + ';0;Lacasse;Ondine', + ); + const { lignes: lues } = modeleApercu({ encodage: 'utf-8', apercu: apercevoir(texte) }); + assert.equal(lues.length, 3); + assert.deepEqual(lues.map(({ ligne }) => ligne), [2, 3, 4]); + for (const { cellules } of lues) assert.deepEqual(cellules.map(({ champ }) => champ), [...CHAMPS]); + assert.deepEqual(COLONNES_DES_LIGNES.map(({ champ }) => champ), [...CHAMPS]); + for (const { cle } of COLONNES_DES_LIGNES) assert.ok(t(cle).length > 0, cle); + const [premiere, deuxieme, troisieme] = lues; + assert.deepEqual(premiere.cellules.map(({ texte: valeur }) => valeur), ['Ombrelle', 'Iris', null, null, null, null, 'arrive tard']); + assert.equal(premiere.cellules[5].cle, 'import.exclu.oui'); + assert.equal(deuxieme.cellules[5].cle, null); + assert.equal(troisieme.cellules[5].cle, 'import.exclu.non'); + for (const cle of ['import.exclu.oui', 'import.exclu.non']) assert.ok(t(cle).length > 0); + }); + + test('refuseesDe : les refus de la mise à jour gardent les personnes désignées et la première ligne, sans les champs découpés', () => { + const refusees = [ + { ligne: 3, code: 'PLUSIEURS_CORRESPONDENT', valeur: null, participants: [2, 5], brut: ['Lacasse', ''] }, + { ligne: 5, code: 'DEJA_DESIGNE', valeur: null, participants: [1], premiereLigne: 4, brut: ['Ombrelle', ''] }, + ]; + assert.deepEqual(refuseesDe(refusees), [ + { ligne: 3, code: 'PLUSIEURS_CORRESPONDENT', details: { ligne: 3, valeur: null, participants: [2, 5] } }, + { ligne: 5, code: 'DEJA_DESIGNE', details: { ligne: 5, valeur: null, participants: [1], premiereLigne: 4 } }, + ]); + const motif = motifLigne(t); + assert.match(motif('DEJA_DESIGNE', refuseesDe(refusees)[1].details), /ligne 4/); + }); + + test('nommer : les personnes d’une liste d’identifiants, dans son ordre ; une personne que la liste n’a plus est omise', () => { + const participants = [ + { id: 2, nom: 'Lacasse', prenom: 'Ondine' }, + { id: 5, nom: 'Lacasse', prenom: null }, + ]; + assert.deepEqual(nommer(participants, [5, 2, 9]), [ + { id: 5, nom: 'Lacasse', prenom: null }, + { id: 2, nom: 'Lacasse', prenom: 'Ondine' }, + ]); + }); + + test('les trois modes offerts, chacun nommé par sa clé, ceux de l’aperçu de src/csv', () => { + assert.deepEqual(MODES_D_IMPORT.map(({ mode }) => mode), ['ajouter', 'mettreAJour', 'remplacer']); + for (const { mode, cle } of MODES_D_IMPORT) { + assert.ok(t(cle).length > 0, cle); + assert.equal(apercevoir(lignes('nom', 'Ombrelle'), { mode }).mode, mode); + } + }); + + test('le mode proposé : Ajouter pour une liste vide, Mettre à jour sinon ; jamais Remplacer', () => { + assert.equal(modePropose([]), 'ajouter'); + assert.equal(modePropose([{ id: 1, nom: 'Ombrelle' }]), 'mettreAJour'); + }); +}); + +// Une charge de deux tours, une table de six places, trois titres à cette +// table, et trois personnes : la première réservée « tous » au siège du +// premier titre, la deuxième réservée au tour 1 seul au siège du deuxième, +// la troisième sans réservation. Sans attribution, les réservations ne +// portent pas de siège. +function chargeAuxTitres({ attribuerSieges }) { + const charge = creerCharge({ id: 'epreuve', nom: 'Soirée des Merles', siegesParDefaut: 6, tours: 2 }); + charge.reglages.attribuerSieges = attribuerSieges; + charge.participants = ['Ombrelle', 'Pervenche', 'Lacasse'].map((nom, rang) => ({ + id: rang + 1, nom, prenom: null, appartenance: null, courriel: null, titrePressenti: null, notes: null, exclu: false, + })); + charge.tables = [{ id: 1, numero: 1, sieges: null, forme: 'ronde', position: { x: 0, y: 0 } }]; + charge.titres = [1, 2, 3].map((siege) => ({ table: 1, siege, libelle: `Animation ${siege}` })); + charge.reservations = [ + { participant: 1, table: 1, siege: attribuerSieges ? 1 : null, portee: 'tous', tour: null }, + { participant: 2, table: 1, siege: attribuerSieges ? 2 : null, portee: 'tour', tour: 1 }, + ]; + charge.prochainsIds = { participant: 4, table: 2, proposition: 1 }; + return charge; +} + +describe('les titres que « Remplacer » annonce (§ 10.1, § 4.1)', () => { + for (const attribuerSieges of [true, false]) { + test(`sièges ${attribuerSieges ? 'attribués' : 'non attribués, réservations sans siège'} : deux titres perdent leur réservation, un « tous » et un « tour »`, () => { + const charge = chargeAuxTitres({ attribuerSieges }); + const bilan = bilanRemplacement(charge); + assert.equal(bilan.titresPourvus, 2); + assert.equal(charge.tables.length, 1); + const pourSoiree = charge.tables.reduce((somme, { id }) => somme + comptesTitres(charge, id).pourvus, 0); + assert.equal(pourSoiree, 1); + const aUnTour = titresNonPourvus(charge).filter(({ pourvuAuxTours }) => pourvuAuxTours.length > 0).length; + assert.equal(aUnTour, 1); + assert.equal(bilan.titresPourvus, pourSoiree + aUnTour); + assert.deepEqual(bilan, { participants: 3, reservations: 2, titresPourvus: 2, propositions: 0 }); + }); + } +}); diff --git a/src/interface/libelles/accueil.js b/src/interface/libelles/accueil.js index b773fad..739506d 100644 --- a/src/interface/libelles/accueil.js +++ b/src/interface/libelles/accueil.js @@ -3,5 +3,115 @@ // Les libellés de l'accueil : la liste des événements, les démonstrations, les // opérations de fichiers, la corbeille, le dossier de travail et la -// récupération (§ 14.6). Chaque clé commence par « accueil. ». -export const TABLE = new Map(); +// récupération (§ 14.6). Chaque clé commence par « accueil. ». Les codes que +// l'accueil affiche — avis et questions du dossier de travail, refus, état +// illisible — gardent leur texte dans les tables de l'application. + +// Une donnée — un chemin, une base — n'entre dans aucun libellé d'écran : +// le composant la rend dans son propre nœud, à côté du texte traduit +// (§ 14.6). Un compte s'accorde dans la phrase qui le porte. +const compte = (n, singulier, pluriel) => `${n} ${n <= 1 ? singulier : pluriel}`; + +export const TABLE = new Map([ + // --- La liste (§ 8.1) ---------------------------------------------------- + // Les intitulés des informations d'une ligne ; le nom est la commande qui + // ouvre l'événement. + ['accueil.colonne.date', 'Date'], + ['accueil.colonne.etat', 'État'], + ['accueil.colonne.participants', 'Participants'], + ['accueil.colonne.modification', 'Dernière modification'], + ['accueil.colonne.chemin', 'Fichier'], + + // Le nom d'un état du plan (§ 9), que rend cleDEtat (modele.js). + ['accueil.etat.brouillon', 'Brouillon'], + ['accueil.etat.propose', 'Proposé'], + ['accueil.etat.retenu', 'Retenu'], + ['accueil.etat.bloque', 'Bloqué'], + + // autres : le nombre des autres bases qui portent le même identifiant + // (§ 8.7, § 8.8) ; elles suivent le texte, chacune dans son propre nœud. + [ + 'accueil.signalement.doublon', + ({ autres }) => + autres <= 1 + ? 'Le même événement se trouve aussi dans le fichier nommé ci-dessous : deux fichiers portent son ' + + 'identifiant, comme après un renommage interrompu. Gardez-en un, et supprimez l\u{2019}autre.' + : `Le même événement se trouve aussi dans les ${autres} fichiers nommés ci-dessous : chacun porte son ` + + 'identifiant, comme après un renommage interrompu. Gardez-en un, et supprimez les autres.', + ], + + // --- La création (§ 2.1, étape 1) ---------------------------------------- + ['accueil.creation.titre', 'Créer un événement'], + ['accueil.creation.nom', 'Nom de l\u{2019}événement'], + ['accueil.creation.date', 'Date, facultative (AAAA-MM-JJ)'], + ['accueil.creation.creer', 'Créer l\u{2019}événement'], + + // --- Les opérations de fichiers (§ 8.7) ---------------------------------- + ['accueil.operation.importer', 'Importer un événement depuis un fichier\u{2026}'], + ['accueil.operation.copier', 'Copier'], + ['accueil.operation.renommer', 'Renommer'], + ['accueil.operation.supprimer', 'Supprimer'], + ['accueil.operation.confirmerSuppression', 'Confirmer : déplacer dans la corbeille'], + ['accueil.operation.nomCopie', 'Nom de la copie'], + ['accueil.operation.nouveauNom', 'Nouveau nom'], + ['accueil.operation.validerCopie', 'Créer la copie'], + ['accueil.operation.validerRenommage', 'Renommer l\u{2019}événement'], + ['accueil.operation.annuler', 'Annuler'], + + // --- Les démonstrations (§ 15) --------------------------------------------- + ['accueil.demonstrations.titre', 'Démonstrations'], + [ + 'accueil.demonstrations.explication', + 'Chacune se charge comme un nouvel événement, numéroté et ouvert en lecture ; ' + + 'aucun autre événement n\u{2019}est touché.', + ], + + // --- La corbeille (§ 8.7) -------------------------------------------------- + ['accueil.corbeille.titre', 'Corbeille'], + [ + 'accueil.corbeille.explication', + 'Un événement supprimé y est déplacé avec tous ses fichiers, dans un dossier daté. ' + + 'Le logiciel ne la vide jamais.', + ], + + // --- Le dossier de travail (§ 8.6) ----------------------------------------- + ['accueil.dossier.titre', 'Dossier de travail'], + ['accueil.dossier.explorateur', 'Ouvrir dans l\u{2019}explorateur'], + ['accueil.question.reessayer', 'Réessayer'], + ['accueil.question.documents', 'Travailler dans les Documents, en y copiant ces événements'], + ['accueil.question.continuer', 'Continuer : ouvrir la liste, aucun geste ne s\u{2019}y enregistrera'], + // evenements : ce que porte l'autre emplacement, dont le chemin suit le + // texte, dans son propre nœud. + [ + 'accueil.autre.proposition', + ({ evenements }) => + `Le dossier de travail est neuf, et cet autre dossier porte ${compte(evenements, 'événement', 'événements')} :`, + ], + [ + 'accueil.autre.originaux', + 'L\u{2019}import les copie : les originaux restent où ils sont, et chaque copie vit ensuite sa propre vie.', + ], + ['accueil.autre.importer', 'Importer ces événements'], + // importes, refuses : les comptes de l'import de l'autre emplacement. + [ + 'accueil.autre.resultat', + ({ importes, refuses }) => + refuses === 0 + ? `${compte(importes, 'événement importé', 'événements importés')}.` + : `${compte(importes, 'événement importé', 'événements importés')} ; ` + + `${compte(refuses, 'ne s\u{2019}est pas importé', 'ne se sont pas importés')}.`, + ], + + // --- La récupération (§ 8.8) ----------------------------------------------- + ['accueil.recuperation.titre', 'Récupération d\u{2019}un événement'], + ['accueil.recuperation.fichier', 'Fichier'], + [ + 'accueil.recuperation.offerte', + 'La version choisie s\u{2019}ouvre en lecture : rien ne s\u{2019}écrit avant un geste en écriture.', + ], + [ + 'accueil.recuperation.aucunSecours', + 'Aucune version de secours ne se lit : l\u{2019}événement ne s\u{2019}ouvre pas, et rien n\u{2019}est créé à sa place.', + ], + ['accueil.recuperation.retour', 'Revenir à la liste'], +]); diff --git a/src/interface/libelles/evenement.js b/src/interface/libelles/evenement.js index 65403ec..98ef262 100644 --- a/src/interface/libelles/evenement.js +++ b/src/interface/libelles/evenement.js @@ -1,7 +1,43 @@ // © 2026 TechnoLibre (http://www.technolibre.ca) // License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -// Les libellés de l'événement ouvert : son en-tête, son mode, ses -// avertissements, le support rompu (§ 14.6). Chaque clé commence par « -// evenement. ». -export const TABLE = new Map(); +// Les libellés de l'événement ouvert : son en-tête, ses états, la place fixe +// de son cadre de mode, la reprise d'un verrou, ses avertissements, le +// support rompu (§ 8.3 à § 8.9, § 9, § 14.6). Chaque clé commence par « +// evenement. ». Une donnée — un numéro, une séance, un instant — n'entre +// dans aucun de ces textes : l'écran la pose dans son propre nœud, à côté. +export const TABLE = new Map([ + // --- L'en-tête et les états du plan (§ 9) ----------------------------------- + ['evenement.etat.titre', 'État du plan'], + ['evenement.etat.brouillon', 'Brouillon'], + ['evenement.etat.propose', 'Proposé'], + ['evenement.etat.retenu', 'Retenu'], + ['evenement.etat.bloque', 'Bloqué'], + + // --- Le cadre de mode et sa place fixe (§ 8.3, § 8.4, § 8.5) ------------- + ['evenement.cadre.commandes', 'Mode et historique'], + ['evenement.commande.defaire', 'Défaire'], + ['evenement.commande.refaire', 'Refaire'], + + // --- La reprise d'un verrou dont la vie ne se prouve pas (§ 8.8) ---------- + ['evenement.verrou.titre', 'Le verrou qui tient cet événement'], + ['evenement.verrou.seance', 'Séance'], + ['evenement.verrou.depuis', 'Pris le'], + ['evenement.verrou.inconnu', 'illisible'], + [ + 'evenement.verrou.avis', + "Reprendre ce verrou n'est sûr que si cette séance est arrêtée : deux séances qui écrivent le même événement se contrediraient.", + ], + ['evenement.verrou.confirmerReprise', 'Confirmer la reprise de ce verrou'], + + // --- Les avertissements (§ 8.6, § 8.9, § 9) ------------------------------- + ['evenement.avertissements', 'Avertissements'], + ['evenement.fautive.numero', 'Proposition n°'], + ['evenement.fautive.rang', 'Proposition illisible, au rang'], + ['evenement.derive.retenu', 'Placement retenu'], + ['evenement.derive.proposition', 'Proposition n°'], + + // --- Le support rompu (§ 8.6) ---------------------------------------------- + ['evenement.supportRompu.abandonner', 'Fermer sans écrire ailleurs'], + ['evenement.supportRompu.confirmerAbandon', "Confirmer : fermer sans l'écrire ailleurs"], +]); diff --git a/src/interface/libelles/import.js b/src/interface/libelles/import.js index 7241b32..0335d2f 100644 --- a/src/interface/libelles/import.js +++ b/src/interface/libelles/import.js @@ -2,5 +2,133 @@ // License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) // Les libellés de l'import d'une liste : aperçu et rapport (§ 14.6). Chaque -// clé commence par « import. ». -export const TABLE = new Map(); +// clé commence par « import. ». Un nombre, un nom ou un rang s'affiche dans +// son propre nœud de texte : les libellés qui le suivent ne reçoivent que ce +// qui accorde le nom au compte. + +// Le singulier jusqu'à un, le pluriel au-delà, comme le veut le français. +const accorde = (n, singulier, pluriel) => (n <= 1 ? singulier : pluriel); + +export const TABLE = new Map([ + // --- La section et ses commandes (§ 10.1, § 10.2) ---------------------------- + [ + 'import.consigne', + 'Choisissez un fichier CSV ou déposez-le sur la fenêtre ; une plage collée dans la grille des participants arrive ici. ' + + "Rien ne s'importe avant l'aperçu.", + ], + ['import.commande.choisirFichier', 'Choisir un fichier CSV\u{2026}'], + ['import.commande.importer', 'Importer'], + ['import.commande.fermerApercu', "Fermer l'aperçu"], + ['import.commande.ordreDeLaGrille', "Lire les colonnes dans l'ordre de la grille"], + ['import.commande.enregistrerRefus', 'Enregistrer les lignes refusées'], + ['import.commande.fermerRapport', 'Fermer le rapport'], + ['import.refusEnregistres', 'Lignes refusées enregistrées :'], + + // --- Le mode de l'import (§ 10.1) ------------------------------------------- + ['import.mode.titre', "Mode de l'import"], + ['import.mode.ajouter', 'Ajouter'], + ['import.mode.mettreAJour', 'Mettre à jour'], + ['import.mode.remplacer', 'Remplacer'], + + // --- L'aperçu : source, encodage, séparateur --------------------------------- + ['import.apercu.titre', 'Aperçu'], + ['import.apercu.fichier', 'Fichier'], + ['import.apercu.collage', 'Texte collé'], + ['import.apercu.encodage', 'Encodage'], + ['import.apercu.separateur', 'Séparateur'], + ['import.encodage.utf8Marque', 'UTF-8 avec marque'], + ['import.encodage.utf16le', 'UTF-16 LE'], + ['import.encodage.utf16be', 'UTF-16 BE'], + ['import.encodage.utf8', 'UTF-8'], + ['import.encodage.windows1252', 'windows-1252'], + ['import.separateur.pointVirgule', 'point-virgule'], + ['import.separateur.virgule', 'virgule'], + ['import.separateur.tabulation', 'tabulation'], + ['import.separateur.uneColonne', 'une seule colonne'], + + // --- L'aperçu : les colonnes et leur association (§ 10.1) -------------------- + ['import.apercu.colonnes', 'Colonnes'], + ['import.apercu.entete', 'En-tête'], + ['import.apercu.devient', 'Devient'], + ['import.champ.nom', 'nom'], + ['import.champ.prenom', 'prénom'], + ['import.champ.appartenance', 'appartenance'], + ['import.champ.courriel', 'courriel'], + ['import.champ.titrePressenti', 'titre pressenti'], + ['import.champ.exclu', 'exclu'], + ['import.champ.notes', 'notes'], + ['import.exclu.oui', 'oui'], + ['import.exclu.non', 'non'], + ['import.colonne.nonReconnue', 'non reconnue : ignorée'], + ['import.colonne.aTrancher', 'à trancher'], + ['import.colonne.ecartee', 'écartée par le choix'], + ['import.ambiguite.question', 'Plusieurs colonnes répondent au même champ. Laquelle vaut :'], + + // --- L'aperçu : les lignes --------------------------------------------------- + ['import.apercu.valides', ({ n }) => accorde(n, 'ligne valide', 'lignes valides')], + ['import.apercu.refusees', 'Lignes refusées'], + ['import.apercu.ligne', 'Ligne'], + ['import.apercu.motif', 'Motif'], + ['import.apercu.fusions', 'Orthographes fondues en une appartenance'], + ['import.apercu.orthographeLignes', ({ n }) => accorde(n, 'ligne', 'lignes')], + ['import.apercu.affichee', 'affichée :'], + ['import.apercu.doublons', 'Doublons signalés, jamais fusionnés'], + ['import.apercu.doublonLignes', 'lignes'], + ['import.apercu.doublonExistants', 'déjà dans la liste :'], + ['import.apercu.homonymes', "Ajoutées à côté d'une personne de même nom"], + ['import.apercu.multilignes', 'Cellules sur plusieurs lignes qui portent le séparateur : à vérifier'], + + // --- Les refus globaux : le remède (§ 10.1) --------------------------------- + ['import.remede.caractereNulCollage', 'Retirez le caractère nul du texte collé, ou importez le fichier réenregistré en UTF-8.'], + ['import.remede.aucunEntete', "Ajoutez une ligne d'en-tête qui nomme les colonnes, « nom » au moins."], + [ + 'import.remede.aucunEnteteCollage', + "Ajoutez une ligne d'en-tête qui nomme les colonnes, ou lisez les colonnes dans l'ordre de la grille.", + ], + ['import.remede.nomNonAssocie', 'Nommez « nom » la colonne des noms.'], + ['import.remede.aucuneLigneValide', 'Corrigez les lignes refusées, listées avec leur motif.'], + [ + 'import.remede.guillemetOuvert', + ({ ligne }) => `Fermez le guillemet ouvert${Number.isInteger(ligne) ? ` à la ligne ${ligne}` : ''}.`, + ], + [ + 'import.remede.separateurIntrouvable', + 'Séparez les colonnes par « ; », « , » ou une tabulation, en même nombre à chaque ligne.', + ], + [ + 'import.remede.separateurIntrouvableCollage', + "Séparez les colonnes par « ; », « , » ou une tabulation, ou lisez la colonne dans l'ordre de la grille : " + + 'chaque ligne devient un nom.', + ], + + // --- La fenêtre de « Remplacer » (§ 10.1) ------------------------------------ + ['import.remplacer.titre', 'Remplacer la liste des participants'], + [ + 'import.remplacer.participants', + ({ n }) => `${n} ${accorde(n, 'personne sera supprimée', 'personnes seront supprimées')}`, + ], + [ + 'import.remplacer.reservations', + ({ n }) => `${n} ${accorde(n, 'réservation sera supprimée', 'réservations seront supprimées')}`, + ], + [ + 'import.remplacer.titres', + ({ n }) => + `${n} ${accorde(n, 'titre perdra la réservation qui le pourvoit', 'titres perdront la réservation qui les pourvoit')}, ` + + 'à un tour au moins', + ], + [ + 'import.remplacer.propositions', + ({ n }) => `${n} ${accorde(n, 'proposition deviendra périmée', 'propositions deviendront périmées')}`, + ], + + // --- Le rapport (§ 10.1) --------------------------------------------------- + ['import.rapport.titre', "Rapport d'import"], + ['import.rapport.ajoutes', ({ n }) => accorde(n, 'personne ajoutée', 'personnes ajoutées')], + ['import.rapport.misAJour', ({ n }) => accorde(n, 'personne mise à jour', 'personnes mises à jour')], + ['import.rapport.inchanges', ({ n }) => accorde(n, 'personne inchangée', 'personnes inchangées')], + ['import.rapport.retires', ({ n }) => accorde(n, 'personne retirée', 'personnes retirées')], + ['import.rapport.reservationsRetirees', ({ n }) => accorde(n, 'réservation retirée', 'réservations retirées')], + ['import.rapport.rienEcrit', "Rien n'a changé dans la liste : seules des lignes refusées restent à lire."], + ['import.rapport.designees', 'désignées :'], +]); diff --git a/src/interface/libelles/participants.js b/src/interface/libelles/participants.js index 8955750..da54374 100644 --- a/src/interface/libelles/participants.js +++ b/src/interface/libelles/participants.js @@ -3,4 +3,89 @@ // Les libellés de la liste des participants : grille, formulaire, fiche (§ // 14.6). Chaque clé commence par « participants. ». -export const TABLE = new Map(); +// +// participants.colonne.* nomme chaque colonne de COLONNES (grille.js), dans +// l'en-tête de la grille, l'étiquette d'un champ du formulaire et celle +// d'une valeur de la fiche ; participants.statut.* nomme chaque statut +// calculé (§ 4.2), par cleDeStatut. + +// Un nombre suivi de son nom, au singulier pour 0 et 1. +const compte = (n, singulier, pluriel) => `${n} ${n <= 1 ? singulier : pluriel}`; + +// La portée d'une réservation : tous les tours, ou le tour désigné. +const portee = ({ portee: genre, tour }) => (genre === 'tous' ? 'tous les tours' : `tour ${tour}`); + +export const TABLE = new Map([ + // --- Les colonnes, que la grille, le formulaire et la fiche partagent ------ + ['participants.colonne.nom', 'Nom'], + ['participants.colonne.prenom', 'Prénom'], + ['participants.colonne.appartenance', 'Appartenance'], + ['participants.colonne.courriel', 'Courriel'], + ['participants.colonne.titrePressenti', 'Titre pressenti'], + ['participants.colonne.exclu', 'Exclusion'], + ['participants.colonne.notes', 'Notes'], + + // --- Le formulaire d'ajout (§ 10.2) --------------------------------------- + ['participants.formulaire.titre', 'Ajouter une personne'], + ['participants.formulaire.ajouter', 'Ajouter'], + ['participants.formulaire.orthographe', 'Cette appartenance existe déjà sous l\u{2019}orthographe'], + ['participants.formulaire.reprendre', 'Reprendre cette orthographe'], + + // --- La grille (§ 10.2) --------------------------------------------------- + ['participants.grille.titre', 'Liste des participants'], + ['participants.grille.filtre', 'Filtrer'], + [ + 'participants.grille.compte', + ({ total, affiches }) => + affiches === total ? compte(total, 'participant', 'participants') : `${affiches} sur ${compte(total, 'participant', 'participants')}`, + ], + ['participants.grille.croissant', '(croissant)'], + ['participants.grille.decroissant', '(décroissant)'], + ['participants.grille.actions', 'Actions'], + ['participants.grille.exclue', 'exclue'], + ['participants.grille.presente', 'présente'], + ['participants.grille.vide', 'Aucun participant. Ajoutez une personne, ou collez une liste dans la grille.'], + ['participants.grille.aucunResultat', 'Aucune personne ne correspond au filtre.'], + + // --- Les commandes de la section ------------------------------------------ + ['participants.commande.fiche', 'Fiche'], + ['participants.commande.exclure', 'Exclure'], + ['participants.commande.reintegrer', 'Réintégrer'], + ['participants.commande.supprimer', 'Supprimer'], + ['participants.commande.exporter', 'Exporter la liste'], + ['participants.commande.fermerFiche', 'Fermer la fiche'], + + // --- La suppression d'une personne qui porte des réservations (§ 2.1) ----- + ['participants.suppression.titre', ({ personne }) => `Supprimer ${personne} et ses réservations ?`], + [ + 'participants.suppression.reservation', + (details) => + `Réservation à la table ${details.numero}${details.siege === null ? '' : `, siège ${details.siege}`}, ${portee(details)}`, + ], + + // --- La fiche (§ 4, § 4.2, § 7.4) ----------------------------------------- + ['participants.fiche.titre', 'Fiche de la personne'], + ['participants.fiche.vide', '\u{2014}'], + ['participants.fiche.statut', 'Statut'], + ['participants.fiche.statutExclue', 'exclue : ni placée, ni comptée'], + ['participants.fiche.statutInconnu', 'non calculé : la configuration est incohérente'], + ['participants.fiche.reservations', 'Réservations'], + ['participants.fiche.aucuneReservation', 'Aucune réservation.'], + ['participants.fiche.suspendues', 'Suspendues tant que la personne est exclue.'], + ['participants.fiche.table', 'Table'], + ['participants.fiche.siege', 'Siège'], + ['participants.fiche.portee', 'Portée'], + ['participants.fiche.sansSiege', 'sans siège'], + ['participants.fiche.tousLesTours', 'tous les tours'], + ['participants.fiche.auTour', 'tour'], + ['participants.fiche.itineraire', 'Placement retenu, tour par tour'], + ['participants.fiche.tour', 'Tour'], + ['participants.fiche.enReserve', 'en réserve'], + ['participants.fiche.siegeNonAttribue', 'non attribué'], + ['participants.fiche.aucunRetenu', 'Aucun placement retenu ne la nomme.'], + + // --- Les statuts calculés (§ 4.2) ----------------------------------------- + ['participants.statut.ancre', 'ancré'], + ['participants.statut.partiel', 'partiellement fixé'], + ['participants.statut.mobile', 'mobile'], +]); diff --git a/src/interface/libelles/plan.js b/src/interface/libelles/plan.js index 1a5b102..637c633 100644 --- a/src/interface/libelles/plan.js +++ b/src/interface/libelles/plan.js @@ -3,4 +3,73 @@ // Les libellés du plan de salle : vue, tables, places, panneaux et listes de // travail (§ 14.6). Chaque clé commence par « plan. ». -export const TABLE = new Map(); +// +// Une ligne de la liste de noms (§ 7.6) se compose de parties, chacune son +// tspan : la tête — numéro, puis état de la place —, le nom, puis le titre +// entre ce qui le précède et ce qui le suit. Le nom et le libellé du titre +// sont des données : aucun texte de cette table ne les porte. La tête finit +// par une espace quand un nom la suit (avecNom). +import { compte } from '../../application/libelles/aides.js'; + +export const TABLE = new Map([ + // --- Le dessin d'une table (§ 7.5, § 7.6) ---------------------------------- + // Le nom accessible d'une table : numéro, places, conflits du tour quand + // ils s'évaluent (conflits null sinon), puis gelée et surchargée. + [ + 'plan.table.nom', + ({ numero, places, conflits, gelee, surchargee }) => + [ + `Table ${numero}`, + compte(places, 'place', 'places'), + ...(conflits === null ? [] : [conflits === 0 ? 'aucun conflit' : compte(conflits, 'conflit', 'conflits')]), + ...(gelee ? ['gelée'] : []), + ...(surchargee ? ['surchargée'] : []), + ].join(', '), + ], + + // --- La liste de noms (§ 7.4, § 7.6) --------------------------------------- + ['plan.liste.gelee', 'table gelée'], + ['plan.liste.abreviation', '\u{2026}'], + [ + 'plan.ligne.tete', + ({ numero, etat, avecNom }, texte) => + `${numero}.${etat === null ? '' : ` ${texte(etat.cle, etat.details)}`}${avecNom ? ' ' : ''}`, + ], + ['plan.ligne.reserveeTous', '(réservée, tous les tours)'], + ['plan.ligne.reserveeTour', ({ tour }) => `(réservée, tour ${tour})`], + ['plan.ligne.libre', '(libre)'], + // Une place réservée à une autre personne que l'occupant : l'état + // s'écrit autour du nom de la titulaire, qui occupe son propre nœud, + // entre teteReserveeA et reserveeA. + ['plan.ligne.teteReserveeA', ({ numero }) => `${numero}. (réservée à `], + ['plan.ligne.reserveeA', ({ portee, tour }) => (portee === 'tous' ? ', tous les tours)' : `, tour ${tour})`)], + ['plan.ligne.avantTitrePourvu', ' \u{2014} '], + ['plan.ligne.avantTitreNonPourvu', ' \u{2014} titre '], + ['plan.ligne.apresTitreNonPourvu', ' non pourvu'], + + // --- Le bandeau du plan (§ 7.5) -------------------------------------------- + // L'appartenance d'un plancher de groupe est une donnée : l'écran la pose + // dans son propre nœud, avant ce texte. + ['plan.bandeau.toutesEnConflit', 'toutes les tables sont en conflit à ce tour'], + [ + 'plan.bandeau.collisionPartout', + 'tout placement porte une collision à chaque table : ajouter des tables ou désactiver la séparation', + ], + [ + 'plan.bandeau.uneSeuleAppartenance', + 'une seule appartenance sous séparation active : chaque table réunit des personnes de la même appartenance', + ], + [ + 'plan.bandeau.plancherGroupe', + ({ effectif, plancherCumulees }) => + `${compte(effectif, 'personne', 'personnes')}, au moins ${compte(plancherCumulees, 'collision', 'collisions')} sur la soirée`, + ], + ['plan.bandeau.sansPlancher', 'le diagnostic ne prouve aucun plancher de collisions'], + + // --- L'échelle graphique et la mention (§ 7.7) ----------------------------- + ['plan.echelle.mention', "disposition relative \u{2014} l'encombrement dans la salle n'est pas vérifié"], + ['plan.echelle.nom', 'Échelle graphique'], + ['plan.echelle.centimetres', 'cm'], + ['plan.echelle.metres', 'm'], + ['plan.echelle.kilometres', 'km'], +]); diff --git a/src/interface/libelles/tables.js b/src/interface/libelles/tables.js index 396ec20..152db9e 100644 --- a/src/interface/libelles/tables.js +++ b/src/interface/libelles/tables.js @@ -1,6 +1,131 @@ // © 2026 TechnoLibre (http://www.technolibre.ca) // License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -// Les libellés des tables et des tours (§ 14.6). Chaque clé commence par « -// tables. ». -export const TABLE = new Map(); +// Les libellés des tables et des tours (§ 4, § 6, § 2.1 étapes 4 et 5, +// § 14.6). Chaque clé commence par « tables. ». +// +// L'énoncé d'un changement de défaut se compose de phrases, que rend +// enonceDefaut (tables/modele.js) et que l'écran sépare d'une espace : la +// phrase d'application, puis celle du total — le § 6.1 les écrit ainsi. Une +// baisse refusée n'a qu'une phrase d'en-tête, puis le refus de chaque table, +// composé par texte depuis son code. Les noms de personnes et les numéros de +// table viennent des détails : une fenêtre à confirmer nomme chaque élément +// détruit dans son propre libellé. +import { cite, compte } from '../../application/libelles/aides.js'; + +// Un accord au nombre : le singulier jusqu'à un, comme compte. +const accord = (n, singulier, pluriel) => (n <= 1 ? singulier : pluriel); + +// La portée d'une réservation en mots : tous les tours, ou le tour désigné. +const portee = ({ portee: genre, tour }) => (genre === 'tous' ? 'pour tous les tours' : `au tour ${tour}`); + +export const TABLE = new Map([ + // --- Le défaut (§ 6.1) ------------------------------------------------------ + ['tables.defaut.etiquette', 'Places par défaut'], + [ + 'tables.defaut.application', + ({ suivent, avant, apres, surchargees }) => { + const suivi = + suivent === 0 + ? 'Aucune table ne suit le défaut' + : `${compte(suivent, 'table suit', 'tables suivent')} le défaut et ` + + `${accord(suivent, 'passe', 'passent')} de ${avant} à ${compte(apres, 'place', 'places')}`; + if (surchargees.length === 0) return `${suivi}.`; + return ( + `${suivi} ; ${compte(surchargees.length, 'table surchargée', 'tables surchargées')} ` + + `(${surchargees.join(', ')}) ${accord(surchargees.length, 'ne change', 'ne changent')} pas.` + ); + }, + ], + [ + 'tables.defaut.total', + ({ totalAvant, totalApres, participants }) => + `Total : ${totalAvant} \u{2192} ${compte(totalApres, 'siège', 'sièges')} ` + + `pour ${compte(participants, 'participant', 'participants')}.`, + ], + [ + 'tables.defaut.refusee', + ({ avant, apres }) => + `Le défaut ne passe pas de ${avant} à ${compte(apres, 'place', 'places')} : ` + + 'une table au moins refuse, et rien ne change.', + ], + [ + 'tables.defaut.realignementRefuse', + ({ avant, apres }) => + `Le réalignement sur ${compte(apres, 'place', 'places')} est refusé : une table au moins refuse, ` + + `et rien ne change ; le défaut peut passer de ${avant} à ${apres} sans réaligner.`, + ], + // Une table qui refuse le changement : son numéro, puis son refus ; numero + // null pour un refus de l'événement entier, qui ne nomme aucune table. + [ + 'tables.defaut.refusTable', + ({ numero, refus }, texte) => { + const motif = texte(refus.code, refus.details); + return numero === null ? motif : `Table ${numero} \u{2014} ${motif}`; + }, + ], + ['tables.defaut.appliquer', 'Appliquer'], + [ + 'tables.defaut.appliquerRealigner', + ({ n }) => `Appliquer et réaligner ${n <= 1 ? 'la surchargée' : `les ${n} surchargées`}`, + ], + ['tables.defaut.garder', 'Garder le défaut actuel'], + [ + 'tables.defaut.confirmationRealigner', + ({ n }) => + `Réaligner ${n <= 1 ? 'la table surchargée' : `les ${n} tables surchargées`} ? ` + + `${accord(n, 'Sa surcharge est perdue', 'Leurs surcharges sont perdues')} :`, + ], + ['tables.defaut.surchargePerdue', ({ numero, sieges }) => `Table ${numero} : ${compte(sieges, 'place', 'places')}`], + + // --- La liste des tables (§ 4, § 6.1, § 6.2) ---------------------------------- + ['tables.liste.titre', 'Tables'], + ['tables.liste.aucune', 'Aucune table : ajoutez-en une, ronde ou carrée.'], + ['tables.liste.table', 'Table'], + ['tables.liste.places', 'Places'], + ['tables.liste.realigner', 'Réaligner'], + ['tables.liste.supprimer', 'Supprimer'], + ['tables.forme.ronde', 'Ronde'], + ['tables.forme.carree', 'Carrée'], + ['tables.etat.suitDefaut', 'Suit le défaut'], + ['tables.etat.surchargee', 'Surchargée'], + ['tables.creer.ronde', 'Ajouter une table ronde'], + ['tables.creer.carree', 'Ajouter une table carrée'], + + // Ce qu'un refus SIEGES_RETIRES retire, siège par siège : le nom de la + // personne réservée suit, dans son propre nœud. + ['tables.siegeRetire.titre', ({ siege, libelle }) => `Siège ${siege} : titre ${cite(libelle)}`], + [ + 'tables.siegeRetire.reserve', + ({ siege, suspendue }) => `Siège ${siege}${suspendue ? ', réservation suspendue' : ''} :`, + ], + + // --- La suppression d'une table (§ 6.2) ----------------------------------- + [ + 'tables.suppression.confirmation', + ({ numero }) => `Supprimer la table ${numero} ? Ces saisies partent avec elle :`, + ], + [ + 'tables.suppression.reservation', + (reservation) => + `${reservation.nom} : réservation ${portee(reservation)}` + + `${reservation.siege === null ? '' : `, siège ${reservation.siege}`}`, + ], + ['tables.suppression.titre', ({ siege, libelle }) => `Titre ${cite(libelle)}, siège ${siege}`], + + // --- Les tours (§ 2.1 étape 5, § 4.3) ---------------------------------------- + ['tables.tours.etiquette', 'Nombre de tours'], + [ + 'tables.tours.confirmation', + ({ avant, apres }) => `Passer de ${avant} à ${compte(apres, 'tour', 'tours')} ? Ce changement emporte :`, + ], + [ + 'tables.tours.reservation', + ({ nom, numero, tour }) => `${nom} : réservation à la table ${numero} au tour ${tour}, détruite`, + ], + + // Un placement qu'un changement laisse en dérive (§ 9) : il reste, et ne + // correspond plus à la configuration. + ['tables.touchee.proposition', ({ id }) => `Proposition n° ${id}, qui ne correspondra plus à la configuration`], + ['tables.touchee.retenu', 'Placement retenu, qui ne correspondra plus à la configuration'], +]); diff --git a/src/interface/participants/Fiche.svelte b/src/interface/participants/Fiche.svelte new file mode 100644 index 0000000..b9b5304 --- /dev/null +++ b/src/interface/participants/Fiche.svelte @@ -0,0 +1,214 @@ + + + + + + + diff --git a/src/interface/participants/Formulaire.svelte b/src/interface/participants/Formulaire.svelte new file mode 100644 index 0000000..04b1b3e --- /dev/null +++ b/src/interface/participants/Formulaire.svelte @@ -0,0 +1,221 @@ + + + + +
+

{t('participants.formulaire.titre')}

+
+ {#each CHAMPS as cle (cle)} +
+ + {#if cle === 'nom'} + + {#if refusNom !== null} + (refusNom = null)} /> + {/if} + {:else if cle === 'appartenance'} + + {#if orthographe !== null} +
+

{t('participants.formulaire.orthographe')} {orthographe}

+ +
+ {/if} + {:else} + + {/if} +
+ {/each} +
+
+ + {#if refusAjout !== null} + (refusAjout = null)} /> + {/if} +
+
+ + diff --git a/src/interface/participants/Grille.svelte b/src/interface/participants/Grille.svelte new file mode 100644 index 0000000..4bdf346 --- /dev/null +++ b/src/interface/participants/Grille.svelte @@ -0,0 +1,443 @@ + + + + +
+ + + + {#each COLONNES as colonne (colonne)} + + {/each} + + + + + {#each lignes as personne, rang (personne.id)} + + {#each COLONNES as colonne, rangColonne (colonne)} + {@const refus = refusDe(personne.id, colonne)} + + {/each} + + + {/each} + +
+ + {t('participants.grille.actions')}
{ + if (evenement.target === evenement.currentTarget) actif = { id: personne.id, colonne: rangColonne }; + }} + onkeydown={(evenement) => surToucheCellule(evenement, personne, rang, rangColonne)} + > + {#if enEdition(personne.id, colonne) && colonne === 'notes'} + + {:else if enEdition(personne.id, colonne)} + surToucheEntree(evenement, rang)} + onblur={surSortie} + /> + {:else if colonne === 'exclu'} + + {t(personne.exclu ? 'participants.grille.exclue' : 'participants.grille.presente')} + + {:else} + {personne[colonne] ?? ''} + {/if} + {#if refus !== null} + (refusCellule = null)} /> + {/if} + +
+ + {#if personne.exclu} + seance.executer('reintegrerParticipant', { id: personne.id })} + /> + {:else} + seance.executer('exclureParticipant', { id: personne.id })} + /> + {/if} + supprimer(personne)} /> +
+
+
+ + diff --git a/src/interface/participants/Participants.navigateur.test.js b/src/interface/participants/Participants.navigateur.test.js new file mode 100644 index 0000000..f8ab24d --- /dev/null +++ b/src/interface/participants/Participants.navigateur.test.js @@ -0,0 +1,742 @@ +// © 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); + }); +}); diff --git a/src/interface/participants/SectionParticipants.svelte b/src/interface/participants/SectionParticipants.svelte index 8079fc6..c325107 100644 --- a/src/interface/participants/SectionParticipants.svelte +++ b/src/interface/participants/SectionParticipants.svelte @@ -2,12 +2,160 @@ License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->

{t('coquille.section.participants')}

+ + + +
+
+ + +
+ {t('participants.grille.compte', { total: participants.length, affiches: lignes.length })} + +
+ +
+
+ + {#if participants.length === 0} +

{t('participants.grille.vide')}

+ {:else if lignes.length === 0} +

{t('participants.grille.aucunResultat')}

+ {/if} +
+ {#if personneFiche !== null} + + {/if} +
+ + {#if aSupprimer !== null} + t('participants.suppression.reservation', reservation))} + surConfirmer={confirmerSuppression} + surAnnuler={() => (aSupprimer = null)} + /> + {/if}
+ + diff --git a/src/interface/participants/grille.js b/src/interface/participants/grille.js new file mode 100644 index 0000000..e930330 --- /dev/null +++ b/src/interface/participants/grille.js @@ -0,0 +1,234 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Les modules purs de la liste des participants (§ 4, § 7.4, § 10.2), que +// câblent SectionParticipants, Grille, Formulaire et Fiche : les colonnes de +// la grille, ses lignes triées et filtrées, la cellule qu'atteint une +// touche, l'orthographe d'appartenance à reprendre, l'itinéraire d'une +// personne dans le retenu et ses réservations. Aucune fonction ne modifie ce +// qu'elle reçoit ; la clé normalisée vient de l'appelant, qui la tient de +// src/application/csv.js. + +/** Les colonnes de la grille, propriétés d'un participant, dans l'ordre des + * colonnes de l'export : « l'ordre de la grille » que relit l'import. */ +export const COLONNES = Object.freeze(['nom', 'prenom', 'appartenance', 'courriel', 'titrePressenti', 'exclu', 'notes']); + +// Les colonnes de texte : toutes, exclu excepté. +const TEXTES = COLONNES.filter((colonne) => colonne !== 'exclu'); + +// La clé du libellé de chaque colonne, et celle de chaque statut calculé. +const CLES_COLONNES = new Map([ + ['nom', 'participants.colonne.nom'], + ['prenom', 'participants.colonne.prenom'], + ['appartenance', 'participants.colonne.appartenance'], + ['courriel', 'participants.colonne.courriel'], + ['titrePressenti', 'participants.colonne.titrePressenti'], + ['exclu', 'participants.colonne.exclu'], + ['notes', 'participants.colonne.notes'], +]); +const CLES_STATUTS = new Map([ + ['ancre', 'participants.statut.ancre'], + ['partiel', 'participants.statut.partiel'], + ['mobile', 'participants.statut.mobile'], +]); + +/** + * La clé du libellé d'une colonne de COLONNES. + * + * @param {string} colonne + * @returns {string} + * @throws {RangeError} hors de COLONNES + */ +export function cleDeColonne(colonne) { + const cle = CLES_COLONNES.get(colonne); + if (cle === undefined) throw new RangeError(`colonne inconnue : ${JSON.stringify(colonne)}`); + return cle; +} + +/** + * La clé du libellé d'un statut calculé (statuts, src/application/places.js) : + * 'ancre', 'partiel' ou 'mobile'. + * + * @param {string} statut + * @returns {string} + * @throws {RangeError} un autre statut + */ +export function cleDeStatut(statut) { + const cle = CLES_STATUTS.get(statut); + if (cle === undefined) throw new RangeError(`statut inconnu : ${JSON.stringify(statut)}`); + return cle; +} + +// Deux chaînes, comparées unité UTF-16 par unité. +const comparerTextes = (a, b) => (a < b ? -1 : a > b ? 1 : 0); + +const SENS = new Map([ + ['croissant', 1], + ['decroissant', -1], +]); + +/** + * Les lignes affichées : une ligne reste quand la clé normalisée d'un de ses + * champs texte non null contient celle du filtre — un champ null n'est + * jamais passé à cleNormalisee, qui lève sur lui —, puis tri par colonne, + * croissant ou décroissant, sur la clé normalisée (exclu : faux avant vrai), + * un champ null après toute valeur dans les deux sens, départagé par + * identifiant croissant. Sans tri, l'ordre est celui des identifiants + * croissants ; un filtre dont la clé est vide garde tout. + * + * @param {Array} participants + * @param {{tri: null|{colonne: string, sens: 'croissant'|'decroissant'}, filtre: string}} vue + * @param {(texte: string) => string} cleNormalisee + * @returns {Array} les participants reçus, dans l'ordre de la grille + * @throws {RangeError} une colonne hors de COLONNES, un autre sens + */ +export function lignesGrille(participants, { tri, filtre }, cleNormalisee) { + const signe = tri === null ? 1 : SENS.get(tri.sens); + if (tri !== null && (!COLONNES.includes(tri.colonne) || signe === undefined)) { + throw new RangeError(`tri inconnu : ${JSON.stringify(tri)}`); + } + const cherche = cleNormalisee(filtre ?? ''); + const retenues = + cherche === '' + ? [...participants] + : participants.filter((p) => TEXTES.some((colonne) => p[colonne] !== null && cleNormalisee(p[colonne]).includes(cherche))); + + // La valeur de tri de chaque ligne, calculée une fois : null reste null. + const colonne = tri?.colonne ?? null; + const valeurDe = (p) => { + if (colonne === null) return null; + if (colonne === 'exclu') return p.exclu ? 1 : 0; + return p[colonne] === null ? null : cleNormalisee(p[colonne]); + }; + const valeurs = new Map(retenues.map((p) => [p, valeurDe(p)])); + const comparer = (a, b) => { + const va = valeurs.get(a); + const vb = valeurs.get(b); + if (va !== vb) { + if (va === null) return 1; + if (vb === null) return -1; + const ecart = typeof va === 'number' ? va - vb : comparerTextes(va, vb); + if (ecart !== 0) return ecart * signe; + } + return a.id - b.id; + }; + return retenues.sort(comparer); +} + +/** + * La cellule qu'atteint une touche depuis la cellule position, dans une + * grille de lignes × colonnes. Les flèches bougent d'une cellule et restent + * sur place au bord ; Tab avance dans la ligne puis passe au début de la + * suivante, Maj+Tab recule de même. Tab après la dernière cellule, Maj+Tab + * avant la première, rendent null : le focus quitte la grille, et le + * navigateur le mène à l'élément suivant. Une autre touche ne bouge rien. + * Une grille sans ligne n'a pas de cellule : null. + * + * @param {{ligne: number, colonne: number}} position + * @param {string} touche 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', + * 'Tab' ou 'Maj+Tab' + * @param {{lignes: number, colonnes: number}} taille + * @returns {{ligne: number, colonne: number}|null} + */ +export function celluleSuivante({ ligne, colonne }, touche, { lignes, colonnes }) { + if (lignes === 0 || colonnes === 0) return null; + const borner = (valeur, max) => (valeur < 0 ? 0 : valeur > max ? max : valeur); + const rang = ligne * colonnes + colonne; + switch (touche) { + case 'ArrowUp': + return { ligne: borner(ligne - 1, lignes - 1), colonne }; + case 'ArrowDown': + return { ligne: borner(ligne + 1, lignes - 1), colonne }; + case 'ArrowLeft': + return { ligne, colonne: borner(colonne - 1, colonnes - 1) }; + case 'ArrowRight': + return { ligne, colonne: borner(colonne + 1, colonnes - 1) }; + case 'Tab': + return rang + 1 >= lignes * colonnes ? null : { ligne: Math.floor((rang + 1) / colonnes), colonne: (rang + 1) % colonnes }; + case 'Maj+Tab': + return rang === 0 ? null : { ligne: Math.floor((rang - 1) / colonnes), colonne: (rang - 1) % colonnes }; + default: + return { ligne, colonne }; + } +} + +/** + * L'orthographe déjà portée par un autre participant pour une appartenance + * saisie, à proposer sans fusionner : la première, dans l'ordre reçu, dont + * la clé normalisée égale celle de la saisie. null quand la saisie est + * nouvelle, vide, ou identique à une appartenance portée — lue comme la + * saisie l'enregistre, en NFC, sans ses blancs de bord. Une appartenance + * null ne se compare pas. + * + * @param {string|null} saisie + * @param {Array<{appartenance: string|null}>} participants + * @param {(texte: string) => string} cleNormalisee + * @returns {string|null} + */ +export function orthographeExistante(saisie, participants, cleNormalisee) { + if (saisie === null) return null; + const texte = saisie.normalize('NFC').trim(); + if (texte === '') return null; + const portees = participants.map(({ appartenance }) => appartenance).filter((a) => a !== null); + if (portees.includes(texte)) return null; + const cle = cleNormalisee(texte); + return portees.find((appartenance) => cleNormalisee(appartenance) === cle) ?? null; +} + +/** + * L'itinéraire d'une personne dans le retenu, pour la fiche : à chaque + * tour, de 1 à tours, la table (numéro affiché) et le siège — null quand le + * retenu n'attribue pas les sièges —, ou la réserve. Lu dans etatDuTour(r) + * = etatPlaces(charge, {tour: r}), jamais recalculé ; null quand la source + * n'y est pas 'retenu' à l'un des tours, ou que la personne n'y est ni + * assise ni en réserve — une exclue, absente de tout tour. + * + * @param {number} id + * @param {number} tours + * @param {(tour: number) => Object} etatDuTour l'EtatPlaces du tour + * @returns {Array<{tour: number, numero: number, siege: number|null}|{tour: number, reserve: true}>|null} + */ +export function itineraire(id, tours, etatDuTour) { + const etapes = []; + for (let tour = 1; tour <= tours; tour += 1) { + const etat = etatDuTour(tour); + if (etat.source !== 'retenu') return null; + let etape = null; + for (const table of etat.tables) { + const chaise = table.chaises.find(({ occupant }) => occupant === id); + if (chaise !== undefined) { + etape = { tour, numero: table.numero, siege: etat.siegesAttribues ? chaise.siege : null }; + break; + } + } + if (etape === null && etat.reserve.includes(id)) etape = { tour, reserve: true }; + if (etape === null) return null; + etapes.push(etape); + } + return etapes; +} + +/** + * Les réservations d'une personne, pour la fiche et la fenêtre de + * suppression : dans l'ordre de la charge, chacune avec le numéro affiché + * de sa table. + * + * @param {{tables: Array<{id: number, numero: number}>, reservations: Array}} charge + * @param {number} id + * @returns {Array<{numero: number, siege: number|null, portee: 'tous'|'tour', tour: number|null}>} + */ +export function reservationsDe(charge, id) { + const numeroDe = new Map(charge.tables.map((table) => [table.id, table.numero])); + return charge.reservations + .filter(({ participant }) => participant === id) + .map(({ table, siege, portee, tour }) => ({ numero: numeroDe.get(table), siege, portee, tour })); +} + +/** + * Le nom proposé à l'enregistrement de l'export de la liste (§ 10.2) : + * ‹base›-participants.csv, base étant celle de l'événement ouvert. + * + * @param {string} base + * @returns {string} + */ +export const nomExport = (base) => `${base}-participants.csv`; diff --git a/src/interface/participants/grille.test.js b/src/interface/participants/grille.test.js new file mode 100644 index 0000000..031bb71 --- /dev/null +++ b/src/interface/participants/grille.test.js @@ -0,0 +1,305 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Les modules purs de la liste des participants (§ 4, § 7.4, § 10.2) : les +// lignes de la grille, triées et filtrées, la navigation au clavier, +// l'orthographe d'appartenance à reprendre, l'itinéraire d'une personne dans +// le retenu, ses réservations, et l'ordre des colonnes, celui de l'export. +// Les noms d'épreuve sont inventés. +import assert from 'node:assert/strict'; +import { describe, test } from '../../../test/lanceur.js'; +import { cleNormalisee, decoder, exporterParticipants } from '../../application/csv.js'; +import { + COLONNES, + celluleSuivante, + cleDeColonne, + cleDeStatut, + itineraire, + lignesGrille, + nomExport, + orthographeExistante, + reservationsDe, +} from './grille.js'; + +// Un participant ; les champs absents valent null, exclu faux. +const personne = (id, nom, champs = {}) => ({ + id, + nom, + prenom: null, + appartenance: null, + courriel: null, + titrePressenti: null, + notes: null, + exclu: false, + ...champs, +}); + +const ids = (lignes) => lignes.map(({ id }) => id); + +describe('lignesGrille : tri et filtre (§ 10.2)', () => { + const participants = [ + personne(4, 'Pervenche', { prenom: 'Théo', appartenance: 'CLUB DES MERLES' }), + personne(1, 'Ombrelle', { prenom: 'Iris', appartenance: 'Club des Merles' }), + personne(3, 'Lacasse', { prenom: 'Ondine' }), + personne(2, 'Ombrelle', { prenom: 'Anselme' }), + personne(5, 'Grisaille', { exclu: true }), + ]; + + test('sans tri ni filtre, par identifiant croissant', () => { + assert.deepEqual(ids(lignesGrille(participants, { tri: null, filtre: '' }, cleNormalisee)), [1, 2, 3, 4, 5]); + }); + + test('par nom, croissant puis décroissant ; deux noms égaux se départagent par identifiant croissant', () => { + const croissant = lignesGrille(participants, { tri: { colonne: 'nom', sens: 'croissant' }, filtre: '' }, cleNormalisee); + assert.deepEqual(ids(croissant), [5, 3, 1, 2, 4]); + const decroissant = lignesGrille(participants, { tri: { colonne: 'nom', sens: 'decroissant' }, filtre: '' }, cleNormalisee); + assert.deepEqual(ids(decroissant), [4, 1, 2, 3, 5]); + }); + + test('la clé normalisée trie : casse et accents ignorés', () => { + const liste = [personne(1, 'Émeraude'), personne(2, 'elan'), personne(3, 'Ezra')]; + const lignes = lignesGrille(liste, { tri: { colonne: 'nom', sens: 'croissant' }, filtre: '' }, cleNormalisee); + assert.deepEqual(ids(lignes), [2, 1, 3]); + }); + + test('exclu : faux avant vrai en croissant, vrai avant faux en décroissant', () => { + const croissant = lignesGrille(participants, { tri: { colonne: 'exclu', sens: 'croissant' }, filtre: '' }, cleNormalisee); + assert.deepEqual(ids(croissant), [1, 2, 3, 4, 5]); + const decroissant = lignesGrille(participants, { tri: { colonne: 'exclu', sens: 'decroissant' }, filtre: '' }, cleNormalisee); + assert.deepEqual(ids(decroissant), [5, 1, 2, 3, 4]); + }); + + test('filtre « merles » : « Club des Merles » et « CLUB DES MERLES » restent, la clé normalisée du filtre comprise', () => { + for (const filtre of ['merles', 'MERLES', ' Mérles ']) { + assert.deepEqual(ids(lignesGrille(participants, { tri: null, filtre }, cleNormalisee)), [1, 4], filtre); + } + }); + + test('le filtre lit chaque champ texte : prénom, courriel, titre pressenti, notes', () => { + const liste = [ + personne(1, 'Ombrelle', { courriel: 'iris@exemple.test' }), + personne(2, 'Pervenche', { titrePressenti: 'hôtesse' }), + personne(3, 'Lacasse', { notes: 'arrive tard' }), + personne(4, 'Grisaille', { prenom: 'Hôtesse' }), + ]; + assert.deepEqual(ids(lignesGrille(liste, { tri: null, filtre: 'exemple' }, cleNormalisee)), [1]); + assert.deepEqual(ids(lignesGrille(liste, { tri: null, filtre: 'hotesse' }, cleNormalisee)), [2, 4]); + assert.deepEqual(ids(lignesGrille(liste, { tri: null, filtre: 'tard' }, cleNormalisee)), [3]); + assert.deepEqual(ids(lignesGrille(liste, { tri: null, filtre: 'absent' }, cleNormalisee)), []); + }); + + test('des champs null : le filtre ne lève pas, et le tri les range après toute valeur, croissant comme décroissant', () => { + const vides = ['prenom', 'appartenance', 'courriel', 'titrePressenti', 'notes']; + // 1 et 3 portent chaque champ, 2 et 4 aucun : les null viennent après, + // par identifiant croissant, dans les deux sens. + const liste = [ + personne(4, 'Grisaille'), + personne(3, 'Lacasse', Object.fromEntries(vides.map((champ) => [champ, 'zinnia']))), + personne(2, 'Pervenche'), + personne(1, 'Ombrelle', Object.fromEntries(vides.map((champ) => [champ, 'acacia']))), + ]; + assert.doesNotThrow(() => lignesGrille(liste, { tri: null, filtre: 'acacia' }, cleNormalisee)); + assert.deepEqual(ids(lignesGrille(liste, { tri: null, filtre: 'acacia' }, cleNormalisee)), [1]); + assert.ok(vides.length > 0); + for (const colonne of vides) { + const croissant = lignesGrille(liste, { tri: { colonne, sens: 'croissant' }, filtre: '' }, cleNormalisee); + assert.deepEqual(ids(croissant), [1, 3, 2, 4], `${colonne} croissant`); + const decroissant = lignesGrille(liste, { tri: { colonne, sens: 'decroissant' }, filtre: '' }, cleNormalisee); + assert.deepEqual(ids(decroissant), [3, 1, 2, 4], `${colonne} décroissant`); + } + }); + + test('cleNormalisee ne reçoit jamais null', () => { + const recus = []; + const espion = (texte) => { + recus.push(texte); + return cleNormalisee(texte); + }; + const liste = [personne(1, 'Ombrelle'), personne(2, 'Pervenche', { notes: 'x' })]; + lignesGrille(liste, { tri: { colonne: 'notes', sens: 'croissant' }, filtre: 'o' }, espion); + assert.ok(recus.length > 0); + assert.ok(recus.every((texte) => typeof texte === 'string')); + }); + + test('ne modifie pas la liste reçue', () => { + const copie = structuredClone(participants); + lignesGrille(participants, { tri: { colonne: 'nom', sens: 'decroissant' }, filtre: '' }, cleNormalisee); + assert.deepEqual(participants, copie); + }); + + test('une colonne de tri inconnue, un sens inconnu : RangeError', () => { + assert.throws(() => lignesGrille(participants, { tri: { colonne: 'id', sens: 'croissant' }, filtre: '' }, cleNormalisee), RangeError); + assert.throws(() => lignesGrille(participants, { tri: { colonne: 'nom', sens: 'haut' }, filtre: '' }, cleNormalisee), RangeError); + }); +}); + +describe('celluleSuivante : flèches, Tab, Maj+Tab (§ 10.2)', () => { + const taille = { lignes: 3, colonnes: 4 }; + + test('les flèches bougent d’une cellule et restent sur place au bord', () => { + assert.deepEqual(celluleSuivante({ ligne: 1, colonne: 1 }, 'ArrowDown', taille), { ligne: 2, colonne: 1 }); + assert.deepEqual(celluleSuivante({ ligne: 1, colonne: 1 }, 'ArrowUp', taille), { ligne: 0, colonne: 1 }); + assert.deepEqual(celluleSuivante({ ligne: 1, colonne: 1 }, 'ArrowRight', taille), { ligne: 1, colonne: 2 }); + assert.deepEqual(celluleSuivante({ ligne: 1, colonne: 1 }, 'ArrowLeft', taille), { ligne: 1, colonne: 0 }); + assert.deepEqual(celluleSuivante({ ligne: 0, colonne: 0 }, 'ArrowUp', taille), { ligne: 0, colonne: 0 }); + assert.deepEqual(celluleSuivante({ ligne: 0, colonne: 0 }, 'ArrowLeft', taille), { ligne: 0, colonne: 0 }); + assert.deepEqual(celluleSuivante({ ligne: 2, colonne: 3 }, 'ArrowDown', taille), { ligne: 2, colonne: 3 }); + assert.deepEqual(celluleSuivante({ ligne: 2, colonne: 3 }, 'ArrowRight', taille), { ligne: 2, colonne: 3 }); + }); + + test('Tab avance dans la ligne puis passe au début de la suivante ; Maj+Tab recule de même', () => { + assert.deepEqual(celluleSuivante({ ligne: 0, colonne: 2 }, 'Tab', taille), { ligne: 0, colonne: 3 }); + assert.deepEqual(celluleSuivante({ ligne: 0, colonne: 3 }, 'Tab', taille), { ligne: 1, colonne: 0 }); + assert.deepEqual(celluleSuivante({ ligne: 1, colonne: 0 }, 'Maj+Tab', taille), { ligne: 0, colonne: 3 }); + assert.deepEqual(celluleSuivante({ ligne: 1, colonne: 2 }, 'Maj+Tab', taille), { ligne: 1, colonne: 1 }); + }); + + test('Tab après la dernière cellule, Maj+Tab avant la première : null, le focus quitte la grille', () => { + assert.equal(celluleSuivante({ ligne: 2, colonne: 3 }, 'Tab', taille), null); + assert.equal(celluleSuivante({ ligne: 0, colonne: 0 }, 'Maj+Tab', taille), null); + }); + + test('une autre touche ne bouge rien ; une grille vide n’a pas de cellule', () => { + assert.deepEqual(celluleSuivante({ ligne: 1, colonne: 1 }, 'Enter', taille), { ligne: 1, colonne: 1 }); + assert.equal(celluleSuivante({ ligne: 0, colonne: 0 }, 'ArrowDown', { lignes: 0, colonnes: 4 }), null); + }); +}); + +describe('orthographeExistante (§ 10.1, § 10.2)', () => { + const participants = [ + personne(1, 'Ombrelle', { appartenance: 'Club des Merles' }), + personne(2, 'Pervenche'), + personne(3, 'Lacasse', { appartenance: 'Atelier Grisaille' }), + ]; + + test('« club des merles » → « Club des Merles », l’orthographe déjà portée', () => { + assert.equal(orthographeExistante('club des merles', participants, cleNormalisee), 'Club des Merles'); + assert.equal(orthographeExistante('CLUB DÉS MERLES', participants, cleNormalisee), 'Club des Merles'); + }); + + test('null pour une saisie identique, nouvelle, vide ou null ; une appartenance null ne se compare pas', () => { + assert.equal(orthographeExistante('Club des Merles', participants, cleNormalisee), null); + assert.equal(orthographeExistante(' Club des Merles ', participants, cleNormalisee), null); + assert.equal(orthographeExistante('Cercle des Saules', participants, cleNormalisee), null); + assert.equal(orthographeExistante('', participants, cleNormalisee), null); + assert.equal(orthographeExistante(' ', participants, cleNormalisee), null); + assert.equal(orthographeExistante(null, participants, cleNormalisee), null); + }); + + test('une saisie déjà portée telle quelle par une personne ne propose rien, même quand une autre l’écrit autrement', () => { + const deux = [...participants, personne(4, 'Grisaille', { appartenance: 'club des merles' })]; + assert.equal(orthographeExistante('club des merles', deux, cleNormalisee), null); + assert.equal(orthographeExistante('Club Des Merles', deux, cleNormalisee), 'Club des Merles'); + }); +}); + +describe("itineraire : la personne dans le retenu, tour par tour (§ 7.4)", () => { + // Un état de places écrit à la main, réduit à ce que lit itineraire. + const etat = (source, siegesAttribues, tables, reserve) => ({ source, siegesAttribues, tables, reserve }); + const tableDe = (numero, occupants) => ({ + id: numero * 10, + numero, + chaises: occupants.map((occupant, rang) => ({ siege: rang + 1, occupant })), + }); + + test('à la table 4, siège 2, au tour 1, en réserve au tour 2', () => { + const etats = new Map([ + [1, etat('retenu', true, [tableDe(3, [1, 2]), tableDe(4, [5, 7])], [])], + [2, etat('retenu', true, [tableDe(3, [1, 2]), tableDe(4, [5, null])], [7])], + ]); + assert.deepEqual(itineraire(7, 2, (r) => etats.get(r)), [ + { tour: 1, numero: 4, siege: 2 }, + { tour: 2, reserve: true }, + ]); + }); + + test('sièges non attribués : siège null', () => { + const etats = new Map([[1, etat('retenu', false, [tableDe(4, [5, 7])], [])]]); + assert.deepEqual(itineraire(7, 1, (r) => etats.get(r)), [{ tour: 1, numero: 4, siege: null }]); + }); + + test('source reservations ou proposition, à un tour : null', () => { + const retenu = etat('retenu', true, [tableDe(4, [7])], []); + for (const source of ['reservations', 'proposition']) { + const etats = new Map([ + [1, retenu], + [2, etat(source, true, [tableDe(4, [7])], [])], + ]); + assert.equal(itineraire(7, 2, (r) => etats.get(r)), null, source); + } + }); + + test("une personne qu'aucune chaise ne reçoit et qui n'attend pas — exclue — : null", () => { + const etats = new Map([[1, etat('retenu', true, [tableDe(4, [5])], [6])]]); + assert.equal(itineraire(7, 1, (r) => etats.get(r)), null); + }); + + test('lit chaque tour de 1 à tours, une fois', () => { + const lus = []; + itineraire(7, 3, (r) => { + lus.push(r); + return etat('retenu', true, [tableDe(1, [7])], []); + }); + assert.deepEqual(lus, [1, 2, 3]); + }); +}); + +describe('reservationsDe : les réservations d’une personne, pour la fiche (§ 4)', () => { + test('les siennes, dans l’ordre de la charge, la table par son numéro affiché', () => { + const charge = { + tables: [ + { id: 1, numero: 3 }, + { id: 2, numero: 1 }, + ], + reservations: [ + { participant: 7, table: 2, siege: null, portee: 'tous', tour: null }, + { participant: 8, table: 1, siege: 1, portee: 'tous', tour: null }, + { participant: 7, table: 1, siege: 4, portee: 'tour', tour: 2 }, + ], + }; + assert.deepEqual(reservationsDe(charge, 7), [ + { numero: 1, siege: null, portee: 'tous', tour: null }, + { numero: 3, siege: 4, portee: 'tour', tour: 2 }, + ]); + assert.deepEqual(reservationsDe(charge, 9), []); + }); +}); + +describe("l'ordre de la grille est celui de l'export (§ 10.2)", () => { + test('la cellule de rang i de la ligne exportée égale p[COLONNES[i]], exclu écrit « non »', () => { + const p = { + id: 1, + nom: 'Ombrelle', + prenom: 'Iris', + appartenance: 'Club des Merles', + courriel: 'iris@exemple.test', + titrePressenti: 'hôtesse', + notes: 'arrive à neuf heures', + exclu: false, + }; + const { texte } = decoder(exporterParticipants([p])); + const [, ligne] = texte.split('\r\n'); + const cellules = ligne.split(';'); + assert.equal(COLONNES.length, 7); + assert.equal(cellules.length, COLONNES.length); + COLONNES.forEach((colonne, i) => { + assert.equal(cellules[i], colonne === 'exclu' ? 'non' : p[colonne], colonne); + }); + }); + + test('le nom proposé à l’enregistrement : ‹base›-participants.csv', () => { + assert.equal(nomExport('soiree-des-merles'), 'soiree-des-merles-participants.csv'); + }); + + test('chaque colonne et chaque statut ont leur clé de libellé', () => { + assert.deepEqual( + COLONNES.map(cleDeColonne), + COLONNES.map((colonne) => `participants.colonne.${colonne}`), + ); + assert.equal(cleDeStatut('ancre'), 'participants.statut.ancre'); + assert.equal(cleDeStatut('partiel'), 'participants.statut.partiel'); + assert.equal(cleDeStatut('mobile'), 'participants.statut.mobile'); + assert.throws(() => cleDeColonne('id'), RangeError); + assert.throws(() => cleDeStatut('exclu'), RangeError); + }); +}); diff --git a/src/interface/plan/BandeauPlan.svelte b/src/interface/plan/BandeauPlan.svelte new file mode 100644 index 0000000..3fa6300 --- /dev/null +++ b/src/interface/plan/BandeauPlan.svelte @@ -0,0 +1,60 @@ + + + + +{#if bandeau.length > 0} +
+
    + {#each bandeau as entree, rang (rang)} +
  • + {#if entree.details.groupe !== undefined}{entree.details.groupe}{/if}{t(entree.cle, entree.details)} +
  • + {/each} +
+
+{/if} + + diff --git a/src/interface/plan/Dessin.navigateur.test.js b/src/interface/plan/Dessin.navigateur.test.js new file mode 100644 index 0000000..5753fee --- /dev/null +++ b/src/interface/plan/Dessin.navigateur.test.js @@ -0,0 +1,776 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Épreuves du dessin du plan dans un vrai moteur de rendu (§ 7.1, § 7.3 à +// § 7.7, § 13.3, § 13.5, § 17 point 6). PlanDessin se monte dans un d'épreuve, sans viewBox, dont le groupe [data-vue] porte +// translate(tx,ty) scale(k) ; BandeauPlan et EchellePlan dans la page. Les +// épreuves lisent le style calculé, la boîte et l'élément qu'atteint un +// point, jamais la présence d'une classe, et atteignent les éléments par +// leurs attributs data-…. Une démonstration livrée se lit, et ses variantes +// se dérivent, en mémoire depuis texteLivre ; les noms d'épreuve sont +// inventés. +// +// Le projet navigateur emploie expect, et test et describe de Vitest +// (§ 14.8). +import { afterEach, beforeAll, describe, expect, test } from 'vitest'; +import { flushSync, mount, unmount } from 'svelte'; +import '../jetons.css'; +import { creerFormat } from '../../application/format.js'; +import { TABLES_APPLICATION } from '../../application/libelles.js'; +import { etatPlaces } from '../../application/places.js'; +import { creerTraduction } from '../../application/traduction.js'; +import { PLAN_PARFAIT_PETITE } from '../../demo/catalogue.js'; +import { texteLivre } from '../../demo/livrees.js'; +import { constante } from '../../geometrie/constantes.js'; +import { designer } from '../../geometrie/designation.js'; +import { echelleGraphique } from '../../geometrie/echelle.js'; +import { GABARIT_HAUTEUR_LIGNE } from '../../geometrie/texte.js'; +import { dessinDepuisEcran, ecranDepuisDessin, transformDe } from '../../geometrie/vue.js'; +import { rechercher } from '../../moteur/recherche.js'; +import { analyser, configurationDepuisCharge, creerCharge } from '../../stockage/document.js'; +import { versFichier } from '../../stockage/placements.js'; +import { fixerCadre } from '../../../test/navigateur/entrees.js'; +import { composantes, couleursPeintes, sousTheme, visible } from '../../../test/navigateur/rendu.js'; +import TEXTE_TOUTES_EN_CONFLIT from '../../../test/fixtures/plan/toutes_en_conflit.gtt.json?raw'; +import { TABLES_INTERFACE } from '../libelles.js'; +import BandeauPlan from './BandeauPlan.svelte'; +import EchellePlan from './EchellePlan.svelte'; +import { LARGEUR_ECHELLE_PX, modeleDessin } from './modele.js'; +import PlanDessin from './PlanDessin.svelte'; + +const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]); +const CONTEXTE = new Map([ + ['t', t], + ['format', creerFormat(t)], +]); +const SVG = 'http://www.w3.org/2000/svg'; +const K_LISTES = constante('K_LISTES'); +const LARGEUR = constante('LARGEUR_BLOC_NOMS_CM'); +const MARQUE = t('plan.liste.abreviation'); +const MARQUES_DE_CHAISE = ['cadenasFerme', 'cadenasOuvert', 'anneau']; + +// Les montages de l'épreuve courante : composant et conteneur. +const montes = []; + +beforeAll(async () => { + await fixerCadre(1366, 700); +}); + +// Démonte tout ce que l'épreuve courante a monté. +function demonterTout() { + for (const { composant, conteneur } of montes.splice(0)) { + unmount(composant); + conteneur.remove(); + } +} + +afterEach(demonterTout); + +// --- Montage --------------------------------------------------------------- + +/** Monte PlanDessin dans un d'épreuve de la taille de la + * fenêtre, au début du corps, sous la vue donnée ; caché, le conteneur est + * en display: none, et conteneur.style.display = '' le montre. */ +function monterPlan(modele, vue = { k: 1, tx: 0, ty: 0 }, { cache = false } = {}) { + const conteneur = document.createElement('div'); + conteneur.dataset.surface = ''; + if (cache) conteneur.style.display = 'none'; + document.body.prepend(conteneur); + const svg = document.createElementNS(SVG, 'svg'); + svg.setAttribute('data-plan', ''); + svg.setAttribute('width', String(innerWidth)); + svg.setAttribute('height', String(innerHeight)); + svg.style.display = 'block'; + conteneur.append(svg); + const groupe = document.createElementNS(SVG, 'g'); + groupe.setAttribute('data-vue', ''); + groupe.setAttribute('transform', transformDe(vue)); + svg.append(groupe); + const composant = mount(PlanDessin, { target: groupe, props: { modele }, context: CONTEXTE }); + flushSync(); + montes.push({ composant, conteneur }); + const boite = svg.getBoundingClientRect(); + const cadre = { gauche: boite.left, haut: boite.top, largeur: boite.width, hauteur: boite.height }; + return { svg, groupe, conteneur, vue, cadre, ecran: (x, y) => ecranDepuisDessin(vue, cadre, x, y) }; +} + +// Attend deux images : les observations de taille de la première sont +// livrées, et les mises à jour qu'elles déclenchent appliquées. +const deuxImages = () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))); + +/** Monte un composant HTML dans un conteneur neuf, au début du corps. */ +function monterHtml(Composant, props) { + const conteneur = document.createElement('div'); + document.body.prepend(conteneur); + const composant = mount(Composant, { target: conteneur, props, context: CONTEXTE }); + flushSync(); + montes.push({ composant, conteneur }); + return conteneur; +} + +// --- Configurations ---------------------------------------------------------- + +const NOMS = [ + ['Iris', 'Ombrelle'], + ['Théo', 'Pervenche'], + ['Ondine', 'Lacasse'], + [null, 'Grisaille'], + ['Basile', 'Tourmaline'], + ['Capucine', 'Arrosoir'], + ['Aurèle', 'Cerfeuil'], + ['Mélisande', 'Bruyère'], + ['Firmin', 'Galet'], + ['Clélia', 'Mistral'], + ['Anatole', 'Houblon'], + ['Solveig', 'Sarcelle'], +]; + +const personne = (id, nom, prenom, appartenance = null) => ({ + id, + nom, + prenom, + appartenance, + courriel: null, + titrePressenti: null, + notes: null, + exclu: false, +}); + +const nomsDe = (charge) => { + const noms = new Map(charge.participants.map(({ id, nom, prenom }) => [id, prenom === null ? nom : `${prenom} ${nom}`])); + return (id) => noms.get(id); +}; + +/** Le modèle d'un tour de la charge, sous la source demandée. */ +function modeleDe(charge, { tour = 1, proposition = null, ...options } = {}) { + return modeleDessin(etatPlaces(charge, { tour, proposition }), { + k: 1, + listes: true, + miseEnEvidence: null, + nomDe: nomsDe(charge), + ...options, + }); +} + +/** Une charge de tables de capacités données — rondes, ou de la forme + * donnée —, centres espacés de pas cm sur une ligne, et douze personnes + * inventées. */ +function chargeDeTables(capacites, { tours = 1, pas = 400, formes = [] } = {}) { + const charge = creerCharge({ id: 'epreuve:dessin', nom: 'Dessin', siegesParDefaut: 8, tours }); + charge.tables = capacites.map((sieges, i) => ({ + id: i + 1, + numero: i + 1, + sieges, + forme: formes[i] ?? 'ronde', + position: { x: pas * i, y: 0 }, + })); + charge.participants = NOMS.map(([prenom, nom], i) => personne(i + 1, nom, prenom)); + charge.prochainsIds = { participant: 13, table: capacites.length + 1, proposition: 1 }; + return charge; +} + +// Pose une proposition de rechercher, ou la proposition donnée. +function avecProposition(charge, proposition = null) { + const configuration = configurationDepuisCharge(charge); + const fichier = + proposition ?? + versFichier(rechercher(configuration, { graine: 7, nombre: 1, arret: 1000 })[0], configuration, { + produitVersion: 'epreuve', + attribuerSieges: false, + decalage: 0, + }); + charge.propositions = [fichier]; + charge.prochainsIds.proposition = fichier.id + 1; + charge.evenement.etat = 'propose'; + return charge; +} + +const toutesEnConflit = () => analyser(TEXTE_TOUTES_EN_CONFLIT).charge; + +// La petite démonstration, la seule règle « nouveaux voisins » active, et une +// proposition dont le tour 2 réunit de nouveau la table 1 du tour 1 : au tour +// 2, la table 1 seule est en rencontre répétée. +function rencontreRepetee() { + const charge = analyser(texteLivre('petite')).charge; + Object.assign(charge.reglages, { separerAppartenances: false, nouvelleTable: false, varierAppartenances: false }); + const tours = [ + PLAN_PARFAIT_PETITE.tours[0], + [ + [1, 2, 3], + [4, 8, 12], + [5, 9, 10], + [6, 7, 11], + ], + PLAN_PARFAIT_PETITE.tours[2], + PLAN_PARFAIT_PETITE.tours[3], + ]; + return avecProposition(charge, { + id: 1, + graine: 1, + arret: 1, + historique: 1000, + produitVersion: 'epreuve', + siegesAttribues: false, + tables: [1, 2, 3, 4], + capacites: [3, 3, 3, 3], + tours: 4, + participants: charge.participants.map(({ id }) => id), + placement: tours.map((listes) => ({ sieges: listes.map((liste) => [...liste].sort((a, b) => a - b)), reserve: [] })), + }); +} + +// --- Lectures -------------------------------------------------------------------- + +const tous = (racine, selecteur) => [...racine.querySelectorAll(selecteur)]; +const plateauDe = (svg, id) => svg.querySelector(`[data-table="${id}"] :is([data-marque=contourTable], [data-marque=contourConflit])`); +const centre = (element) => { + const b = element.getBoundingClientRect(); + return { x: b.left + b.width / 2, y: b.top + b.height / 2 }; +}; +// La valeur d'un jeton de couleur, en composantes [r, g, b, a]. +function jeton(nom) { + const sonde = document.createElement('div'); + sonde.style.color = `var(${nom})`; + document.body.append(sonde); + const lu = composantes(getComputedStyle(sonde).color); + sonde.remove(); + return lu; +} +const memeCouleur = (a, b) => a.every((c, i) => Math.abs(c - b[i]) < 0.01); + +// Les lignes rendues d'une liste : chaque enfant du . +const lignesDe = (svg, id) => tous(svg, `[data-liste="${id}"] > tspan`); + +// La hauteur d'encre du gabarit sur la police calculée d'un élément. +function encreDuGabarit(element, gabarit = GABARIT_HAUTEUR_LIGNE) { + const style = getComputedStyle(element); + const contexte = new OffscreenCanvas(1, 1).getContext('2d'); + contexte.font = `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`; + const m = contexte.measureText(gabarit); + return m.actualBoundingBoxAscent + m.actualBoundingBoxDescent; +} + +// L'anneau se voit quand il est rendu et qu'un point de son trait l'atteint, +// pointer-events ignoré : le centre de sa boîte, au milieu de la chaise, ne +// le touche pas. +function anneauVisible(anneau) { + if (!anneau.checkVisibility({ opacityProperty: true, visibilityProperty: true })) return 'checkVisibility faux'; + const style = getComputedStyle(anneau); + if (style.stroke === 'none' || !(parseFloat(style.strokeWidth) > 0)) return `trait ${style.stroke} ${style.strokeWidth}`; + const b = anneau.getBoundingClientRect(); + const feuille = document.createElement('style'); + feuille.textContent = '* { pointer-events: auto !important; }'; + document.head.append(feuille); + try { + const atteints = document.elementsFromPoint(b.left + b.width / 2, b.top + 1); + return atteints.includes(anneau) ? true : `le haut de son trait n'atteint pas l'anneau`; + } finally { + feuille.remove(); + } +} + +// ============================================================================= + +describe('le mobilier à l\u{2019}échelle (§ 7.1, § 7.7)', () => { + test('à k = 2, le disque d\u{2019}une table de 8, de 3 et de 10 a la largeur de son diamètre, à moins d\u{2019}un pixel', () => { + const charge = chargeDeTables([8, 3, 10]); + const { svg } = monterPlan(modeleDe(charge, { k: 2 }), { k: 2, tx: 300, ty: 350 }); + const diametres = [(8 * 60) / Math.PI, 70, (10 * 60) / Math.PI]; + expect(diametres[0]).toBeCloseTo(2 * 76.39, 1); + diametres.forEach((diametre, i) => { + expect(Math.abs(plateauDe(svg, i + 1).getBoundingClientRect().width - diametre * 2)).toBeLessThan(1); + }); + }); +}); + +describe('les marques des places (§ 7.6, § 19.3 08)', () => { + // Siège 1 : réservé pour tous les tours et titré ; 2 : titre non pourvu ; + // 3 : réservé au seul tour 1 ; les autres, nus. + function marques() { + const charge = chargeDeTables([8], { tours: 2 }); + charge.reservations = [ + { participant: 1, table: 1, siege: 1, portee: 'tous', tour: null }, + { participant: 2, table: 1, siege: 3, portee: 'tour', tour: 1 }, + ]; + charge.titres = [ + { table: 1, siege: 1, libelle: 'animateur' }, + { table: 1, siege: 2, libelle: 'animateur' }, + ]; + return charge; + } + const ATTENDUES = new Map([ + [1, ['cadenasFerme', 'anneau']], + [2, ['cadenasOuvert']], + [3, ['cadenasFerme']], + ]); + const REMPLISSAGES = new Map([ + [1, 'remplissagePlein'], + [2, 'hachure'], + [3, 'remplissagePlein'], + ]); + + test('chaque marque attendue se voit, aucune autre ; deux marques de chaise au plus ; aucun pictogramme', () => { + const charge = marques(); + const { svg } = monterPlan(modeleDe(charge, { k: 2.5 }), { k: 2.5, tx: 683, ty: 350 }); + const sieges = tous(svg, '[data-siege]'); + expect(sieges.length).toBe(8); + for (const siege of sieges) { + const numero = Number(siege.dataset.siege.split(':')[1]); + const portees = MARQUES_DE_CHAISE.flatMap((nom) => tous(siege, `[data-marque=${nom}]`)); + expect(portees.length).toBeLessThanOrEqual(2); + expect(portees.map((m) => m.dataset.marque).sort()).toEqual([...(ATTENDUES.get(numero) ?? [])].sort()); + for (const marque of portees) { + expect(marque).toBeInstanceOf(SVGElement); + expect(marque.dataset.marque === 'anneau' ? anneauVisible(marque) : visible(marque)).toBe(true); + } + } + // Les nœuds de texte du plan : au moins un par ligne de liste, huit. + const textes = tous(svg, '*').flatMap((e) => [...e.childNodes].filter((n) => n.nodeType === Node.TEXT_NODE)); + expect(tous(svg, '[data-liste] > tspan').length).toBe(8); + expect(textes.length).toBeGreaterThanOrEqual(8); + for (const noeud of textes) { + expect(/\p{Extended_Pictographic}/u.test(noeud.textContent)).toBe(false); + } + }); + + test('le remplissage se lit au style calculé : plein, hachuré, ou le fond de la chaise', () => { + const charge = marques(); + const { svg } = monterPlan(modeleDe(charge, { k: 2.5 }), { k: 2.5, tx: 683, ty: 350 }); + const chaises = tous(svg, '[data-siege] > :is([data-marque=contourChaise], [data-marque=remplissagePlein], [data-marque=hachure])'); + expect(chaises.length).toBe(8); + for (const chaise of chaises) { + const numero = Number(chaise.closest('[data-siege]').dataset.siege.split(':')[1]); + const attendu = REMPLISSAGES.get(numero) ?? 'contourChaise'; + expect(chaise.dataset.marque).toBe(attendu); + const { fill } = couleursPeintes(chaise); + if (attendu === 'remplissagePlein') expect(fill).toEqual([jeton('--couleur-chaise-pleine')]); + if (attendu === 'contourChaise') expect(fill).toEqual([jeton('--couleur-chaise-fond')]); + if (attendu === 'hachure') { + expect(getComputedStyle(chaise).fill).toMatch(/^url\(/); + expect(fill.some((c) => memeCouleur(c, jeton('--couleur-hachure')))).toBe(true); + } + // Un cadenas couvre le centre de sa chaise : seule une chaise nue se + // voit en son centre ; toutes sont rendues. + expect(chaise.checkVisibility({ opacityProperty: true, visibilityProperty: true })).toBe(true); + if (!ATTENDUES.has(numero)) expect(visible(chaise)).toBe(true); + } + }); +}); + +describe('la liste fait foi (§ 7.6)', () => { + test('la ligne du siège 1 égale exactement sa forme, son titre en italique ; celle d\u{2019}un titre non pourvu l\u{2019}énonce', () => { + const charge = chargeDeTables([8]); + charge.reservations = [{ participant: 1, table: 1, siege: 1, portee: 'tous', tour: null }]; + charge.titres = [ + { table: 1, siege: 1, libelle: 'animateur' }, + { table: 1, siege: 2, libelle: 'animateur' }, + ]; + // Au bloc du registre, LARGEUR_BLOC_NOMS_CM : la ligne entière y tient. + const { svg } = monterPlan(modeleDe(charge), { k: 1, tx: 400, ty: 350 }); + const lignes = lignesDe(svg, 1); + expect(lignes.length).toBe(8); + expect(lignes[0].textContent).toBe('1. (réservée, tous les tours) Iris Ombrelle \u{2014} animateur'); + const titre = lignes[0].querySelector('[data-partie=titre]'); + expect(titre.textContent).toBe('animateur'); + expect(getComputedStyle(titre).fontStyle).toBe('italic'); + expect(getComputedStyle(lignes[0].querySelector('[data-partie=nom]')).fontStyle).toBe('normal'); + expect(lignes[1].textContent).toBe('2. (libre) \u{2014} titre animateur non pourvu'); + }); + + test('grande démonstration livrée, tour 1, modèle tel quel : chaque ligne tient, chaque place occupée garde son nom, chaque titre son libellé', () => { + const charge = analyser(texteLivre('grande')).charge; + const modele = modeleDe(charge); + const occupees = modele.tables.flatMap(({ lignes }) => lignes.filter(({ nom }) => nom !== null)); + expect(occupees.length).toBe(33); + expect(occupees.every(({ etat, titre }) => etat?.cle === 'plan.ligne.reserveeTous' && titre !== null)).toBe(true); + const { svg } = monterPlan(modele, { k: 1, tx: 50, ty: 50 }); + const lignes = tous(svg, '[data-liste] > tspan'); + expect(lignes.length).toBe(260); + for (const ligne of lignes) expect(ligne.getComputedTextLength()).toBeLessThanOrEqual(LARGEUR + 1e-3); + const noms = tous(svg, '[data-liste] [data-partie=nom]'); + expect(noms.length).toBe(33); + for (const nom of noms) expect(nom.textContent.replace(MARQUE, '').length).toBeGreaterThan(0); + const titres = tous(svg, '[data-liste] [data-partie=titre]'); + expect(titres.length).toBe(33); + for (const titre of titres) expect(titre.textContent).toBe('animation'); + for (const nom of noms) expect(nom.previousElementSibling.textContent).toMatch(/^\d+\. \(réservée, tous les tours\) $/); + }); +}); + +describe('le texte des listes (§ 7.4)', () => { + test('chaque ligne est un à x et y explicites, sans textLength ; deux lignes sont au moins à la hauteur d\u{2019}encre du gabarit', () => { + const charge = chargeDeTables([8, 4]); + charge.participants.forEach((p, i) => { + p.nom = ['\u{D4}jgq', '\u{C6}jgq', '\u{D4}gq \u{C6}'][i % 3]; + }); + avecProposition(charge); + const { svg } = monterPlan(modeleDe(charge, { proposition: 1 }), { k: 1, tx: 400, ty: 350 }); + const lignes = lignesDe(svg, 1); + expect(lignes.length).toBe(8); + for (const ligne of lignes) { + expect(ligne.hasAttribute('x')).toBe(true); + expect(ligne.hasAttribute('y') || ligne.hasAttribute('dy')).toBe(true); + } + expect(svg.querySelectorAll('[textLength], [lengthAdjust]').length).toBe(0); + const encre = encreDuGabarit(svg.querySelector('[data-liste="1"]')); + expect(encre).toBeGreaterThan(18); + expect(encre).toBeGreaterThan(encreDuGabarit(svg.querySelector('[data-liste="1"]'), 'abc')); + for (let i = 1; i < lignes.length; i += 1) { + const ecart = lignes[i].getStartPositionOfChar(0).y - lignes[i - 1].getStartPositionOfChar(0).y; + expect(ecart).toBeGreaterThanOrEqual(encre - 1e-3); + } + }); + + test('troncature : sur les 33 tables sans exception, chaque ligne tient, chaque nom s\u{2019}abrège sur la marque', () => { + const charge = analyser(texteLivre('grande-sans-exception')).charge; + const LONGS = ['L'.repeat(200), 'W'.repeat(60), '\u{C6}'.repeat(60)]; + charge.participants.forEach((p, i) => { + p.prenom = null; + p.nom = LONGS[i % 3]; + }); + expect(charge.titres.filter(({ siege, libelle }) => siege === 1 && libelle === 'animation').length).toBe(33); + avecProposition(charge); + const modele = modeleDe(charge, { proposition: 1, k: 0.5 }); + const places = modele.tables.reduce((somme, { lignes }) => somme + lignes.length, 0); + const occupees = modele.tables.reduce((somme, { lignes }) => somme + lignes.filter(({ nom }) => nom !== null).length, 0); + expect(places).toBe(264); + expect(occupees).toBe(260); + // Au bloc du registre : chaque nom s'abrège, aucun ne tombe, et le + // titre reste entier. + const { svg } = monterPlan(modele, { k: 0.5, tx: 50, ty: 50 }); + const lignes = tous(svg, '[data-liste] > tspan'); + expect(lignes.length).toBe(places); + for (const ligne of lignes) expect(ligne.getComputedTextLength()).toBeLessThanOrEqual(LARGEUR + 1e-3); + const noms = tous(svg, '[data-liste] [data-partie=nom]'); + expect(noms.length).toBe(occupees); + for (const nom of noms) expect(nom.textContent.endsWith(MARQUE) && nom.textContent.length > 1).toBe(true); + const titres = tous(svg, '[data-liste] [data-partie=titre]'); + expect(titres.length).toBe(33); + for (const titre of titres) expect(titre.textContent).toBe('animation'); + }); + + test('un titre de deux cents caractères : la ligne tient encore, le titre abrégé', () => { + const charge = chargeDeTables([4]); + charge.reservations = [{ participant: 1, table: 1, siege: 1, portee: 'tous', tour: null }]; + charge.titres = [{ table: 1, siege: 1, libelle: 't'.repeat(200) }]; + const { svg } = monterPlan(modeleDe(charge), { k: 1, tx: 300, ty: 350 }); + const [premiere] = lignesDe(svg, 1); + expect(premiere.getComputedTextLength()).toBeLessThanOrEqual(LARGEUR + 1e-3); + const titre = premiere.querySelector('[data-partie=titre]'); + expect(titre.textContent.endsWith(MARQUE)).toBe(true); + expect(titre.textContent.length).toBeLessThan(200); + }); +}); + +describe('le plan monté caché (point d\u{2019}attention 1)', () => { + test('monté en display: none puis montré : chaque ligne tient, le nom long s\u{2019}abrège sur la marque', async () => { + const charge = chargeDeTables([4, 8]); + for (const p of charge.participants) { + p.prenom = null; + p.nom = 'W'.repeat(80); + } + avecProposition(charge); + const { svg, conteneur } = monterPlan(modeleDe(charge, { proposition: 1 }), { k: 1, tx: 300, ty: 350 }, { cache: true }); + conteneur.style.display = ''; + await deuxImages(); + flushSync(); + const lignes = lignesDe(svg, 1); + expect(lignes.length).toBe(4); + for (const ligne of lignes) { + expect(ligne.getComputedTextLength()).toBeLessThanOrEqual(LARGEUR + 1e-3); + const nom = ligne.querySelector('[data-partie=nom]').textContent; + expect(nom.startsWith('W') && nom.endsWith(MARQUE)).toBe(true); + } + }); +}); + +describe('le signal de conflit (§ 7.5, § 13.3)', () => { + for (const theme of ['clair', 'sombre']) { + test(`thème ${theme} : contour épaissi, trame peinte, pastille chiffrée ; la rencontre répétée et le siège réservé peignent autrement ; le tour change le signal`, async () => { + await sousTheme(theme, async () => { + const charge = rencontreRepetee(); + const tour2 = modeleDe(charge, { tour: 2, proposition: 1, k: 1.5 }); + expect(tour2.tables.map(({ conflit }) => conflit)).toEqual([true, false, false, false]); + const vue = { k: 1.5, tx: 150, ty: 250 }; + const { svg } = monterPlan(tour2, vue); + const enConflit = plateauDe(svg, 1); + const saine = plateauDe(svg, 2); + expect(enConflit.dataset.marque).toBe('contourConflit'); + expect(parseFloat(getComputedStyle(enConflit).strokeWidth)).toBeGreaterThan(parseFloat(getComputedStyle(saine).strokeWidth)); + expect(couleursPeintes(enConflit).stroke).not.toEqual(couleursPeintes(saine).stroke); + const trames = tous(svg, '[data-marque=trameConflit]'); + expect(trames.length).toBe(1); + expect(trames[0].closest('[data-table]').dataset.table).toBe('1'); + expect(getComputedStyle(trames[0]).fill).toMatch(/^url\("?#/); + expect(couleursPeintes(trames[0]).fill.some((c) => memeCouleur(c, jeton('--couleur-conflit')))).toBe(true); + const pastilles = tous(svg, '[data-pastille]'); + expect(pastilles.length).toBe(1); + expect(pastilles[0].dataset.pastille).toBe('1'); + expect(pastilles[0].textContent).toBe(String(tour2.tables[0].pastille)); + expect(visible(pastilles[0].querySelector('text'))).toBe(true); + expect(couleursPeintes(pastilles[0].querySelector('[data-marque=pastille]')).fill).toEqual([jeton('--couleur-pastille-fond')]); + expect(getComputedStyle(pastilles[0]).pointerEvents).toBe('none'); + demonterTout(); + + const tour1 = monterPlan(modeleDe(charge, { tour: 1, proposition: 1, k: 1.5 }), vue).svg; + expect(plateauDe(tour1, 1).dataset.marque).toBe('contourTable'); + expect(tous(tour1, '[data-marque=trameConflit], [data-pastille]').length).toBe(0); + expect(getComputedStyle(plateauDe(tour1, 1)).strokeWidth).toBe(getComputedStyle(plateauDe(tour1, 2)).strokeWidth); + + const reserve = chargeDeTables([8]); + reserve.reservations = [{ participant: 1, table: 1, siege: 1, portee: 'tous', tour: null }]; + const places = monterPlan(modeleDe(reserve, { k: 1.5 }), vue).svg; + const [plein, nu] = ['1:1', '1:2'].map((s) => places.querySelector(`[data-siege="${s}"] > circle`)); + expect(couleursPeintes(plein).fill).not.toEqual(couleursPeintes(nu).fill); + }); + }); + } + + test('toutes en conflit, au rendu : ni contour ni trame, le style d\u{2019}une table saine ; chaque pastille garde son nombre ; le bandeau porte la phrase', () => { + const charge = toutesEnConflit(); + const modele = modeleDe(charge, { proposition: 1, k: 1 }); + expect(modele.signalerConflits).toBe(false); + const { svg } = monterPlan(modele, { k: 1, tx: 150, ty: 250 }); + expect(tous(svg, '[data-marque=contourConflit], [data-marque=trameConflit]').length).toBe(0); + const saine = monterPlan(modeleDe(chargeDeTables([3]), { k: 1 }), { k: 1, tx: 150, ty: 600 }).svg; + const reference = getComputedStyle(plateauDe(saine, 1)); + for (const id of [1, 2, 3, 4]) { + const style = getComputedStyle(plateauDe(svg, id)); + for (const propriete of ['stroke', 'strokeWidth', 'fill']) expect(style[propriete]).toBe(reference[propriete]); + } + const pastilles = tous(svg, '[data-pastille]'); + expect(pastilles.length).toBe(4); + for (const pastille of pastilles) { + const table = modele.tables.find(({ id }) => String(id) === pastille.dataset.pastille); + expect(table.pastille).toBeGreaterThan(0); + expect(pastille.textContent).toBe(String(table.pastille)); + } + const bandeau = monterHtml(BandeauPlan, { bandeau: modele.bandeau }); + const porteur = bandeau.querySelector('[data-bandeau-plan]'); + expect(porteur.textContent).toBe(t('plan.bandeau.toutesEnConflit')); + expect(visible(porteur.querySelector('li span'))).toBe(true); + }); + + test('le bandeau pose l\u{2019}appartenance d\u{2019}un plancher dans son propre nœud', () => { + const bandeau = [ + { cle: 'plan.bandeau.toutesEnConflit', details: {} }, + { cle: 'plan.bandeau.plancherGroupe', details: { groupe: 'Club des Merles', effectif: 6, plancherCumulees: 8 } }, + ]; + const conteneur = monterHtml(BandeauPlan, { bandeau }); + const entrees = tous(conteneur, '[data-bandeau-plan] li'); + expect(entrees.length).toBe(2); + const noeuds = [...entrees[1].querySelectorAll('*')].map((e) => e.textContent); + expect(noeuds).toContain('Club des Merles'); + expect(noeuds).toContain(t('plan.bandeau.plancherGroupe', bandeau[1].details)); + }); +}); + +describe('l\u{2019}échelle graphique et la mention (§ 7.7, § 19.3 07)', () => { + test('la mention se voit, portée par EchellePlan, tirée des libellés', () => { + const conteneur = monterHtml(EchellePlan, { k: 1 }); + const mention = [...conteneur.querySelectorAll('*')].find((e) => e.textContent === t('plan.echelle.mention')); + expect(mention).toBeDefined(); + expect(mention.textContent).toBe("disposition relative \u{2014} l'encombrement dans la salle n'est pas vérifié"); + expect(visible(mention)).toBe(true); + }); + + for (const k of [0.2, 1, 5]) { + test(`k = ${k} : la largeur rendue de la barre, divisée par k, égale sa longueur libellée à 1 % près`, () => { + const conteneur = monterHtml(EchellePlan, { k }); + const barre = conteneur.querySelector('[data-marque=echelle]'); + expect(visible(barre)).toBe(true); + const { longueurCm } = echelleGraphique(k, LARGEUR_ECHELLE_PX); + const libelle = barre.parentElement.textContent; + const lu = /^([\d,]+)\s*(cm|m|km)$/.exec(libelle.trim()); + expect(lu).not.toBeNull(); + const facteur = { cm: 1, m: 100, km: 100000 }[lu[2]]; + const longueurLue = Number(lu[1].replace(',', '.')) * facteur; + expect(longueurLue).toBeCloseTo(longueurCm, 6); + expect(Math.abs(barre.getBoundingClientRect().width / k - longueurLue) / longueurLue).toBeLessThan(0.01); + expect(/\b1\s*:\s*\d/.test(conteneur.textContent)).toBe(false); + }); + } +}); + +describe('les seuils de rendu (§ 7.3, § 17 point 6, § 19.10)', () => { + test('à K_LISTES − 0,01, aucun tspan de liste ; à K_LISTES + 0,01, les listes sont là', () => { + const charge = chargeDeTables([8, 8]); + for (const [k, present] of [ + [K_LISTES - 0.01, false], + [K_LISTES + 0.01, true], + ]) { + const { svg } = monterPlan(modeleDe(charge, { k }), { k, tx: 200, ty: 200 }); + expect(tous(svg, '[data-liste] tspan').length > 0).toBe(present); + expect(tous(svg, '[data-table]').length).toBe(2); + } + }); + + test('grande démonstration : à 0,39, aucune chaise, une seule cible par table ; à 0,41, 260 sièges', () => { + const charge = analyser(texteLivre('grande')).charge; + const vue = { k: 0.39, tx: 80, ty: 80 }; + const bas = monterPlan(modeleDe(charge, { k: 0.39 }), vue); + expect(tous(bas.svg, '[data-siege]').length).toBe(0); + const modele = modeleDe(charge, { k: 0.39 }); + let chaises = 0; + for (const table of modele.tables) { + const { centre: c, rayonEmprise } = table.geometrie; + const coin1 = bas.ecran(c.x - rayonEmprise, c.y - rayonEmprise); + const coin2 = bas.ecran(c.x + rayonEmprise, c.y + rayonEmprise); + if (coin1.x < 0 || coin1.y < 0 || coin2.x > innerWidth || coin2.y > innerHeight) continue; + const atteints = table.geometrie.chaises.map(({ x, y }) => { + const p = bas.ecran(x, y); + return document.elementFromPoint(p.x, p.y); + }); + chaises += atteints.length; + expect(new Set(atteints).size).toBe(1); + expect(atteints[0].closest('[data-siege]')).toBeNull(); + expect(atteints[0].closest('[data-table]').dataset.table).toBe(String(table.id)); + } + expect(chaises).toBeGreaterThanOrEqual(10); + + const haut = monterPlan(modeleDe(charge, { k: 0.41 }), { k: 0.41, tx: 80, ty: 80 }); + const capacites = etatPlaces(charge, { tour: 1 }).tables.reduce((somme, { capacite }) => somme + capacite, 0); + expect(capacites).toBe(260); + expect(tous(haut.svg, '[data-siege]').length).toBe(260); + }); +}); + +describe('dessin et désignation (§ 7.3, § 13.5)', () => { + test('le centre rendu de chaque siège est sa position ; aux points témoins, le DOM et designer désignent la même place ; une liste ne masque aucune chaise', () => { + const charge = chargeDeTables([8, 8], { pas: 200 }); + const vue = { k: 1.5, tx: 300, ty: 350 }; + const modele = modeleDe(charge, { k: vue.k }); + const { svg, cadre, ecran } = monterPlan(modele, vue); + const geometries = modele.tables.map(({ geometrie }) => geometrie); + const sieges = tous(svg, '[data-siege]'); + expect(sieges.length).toBe(16); + for (const element of sieges) { + const [table, siege] = element.dataset.siege.split(':').map(Number); + const attendu = geometries[table - 1].chaises[siege - 1]; + const c = centre(element); + const lu = dessinDepuisEcran(vue, cadre, c.x, c.y); + expect(Math.hypot(lu.x - attendu.x, lu.y - attendu.y) * vue.k).toBeLessThan(0.5); + } + + const temoins = []; + for (const { centre: c, chaises } of geometries) { + temoins.push(c); + chaises.forEach((chaise, i) => { + const suivante = chaises[(i + 1) % chaises.length]; + temoins.push(chaise, { x: (chaise.x + suivante.x) / 2, y: (chaise.y + suivante.y) / 2 }); + }); + } + temoins.push({ x: -300, y: 0 }, { x: 100, y: -200 }); + expect(temoins.length).toBe(36); + for (const point of temoins) { + const p = ecran(point.x, point.y); + const parDom = document.elementFromPoint(p.x, p.y)?.closest('[data-siege]')?.dataset.siege ?? null; + const designe = designer(dessinDepuisEcran(vue, cadre, p.x, p.y), geometries, vue.k); + const parGeometrie = designe !== null && designe.siege !== undefined ? `${designe.table}:${designe.siege}` : null; + expect(parDom, `point (${point.x}, ${point.y})`).toBe(parGeometrie); + } + + const boite = svg.querySelector('[data-liste="1"]').getBoundingClientRect(); + const couvertes = geometries[1].chaises + .map((chaise) => ({ chaise, p: ecran(chaise.x, chaise.y) })) + .filter(({ p }) => p.x >= boite.left && p.x <= boite.right && p.y >= boite.top && p.y <= boite.bottom); + expect(couvertes.length).toBeGreaterThanOrEqual(1); + for (const { chaise, p } of couvertes) { + expect(document.elementFromPoint(p.x, p.y).closest('[data-siege]').dataset.siege).toBe(`2:${chaise.siege}`); + } + }); +}); + +describe('aucune rotation, aucun canvas (§ 7.1, § 13.3)', () => { + test('chaque table, ronde ou carrée, porte une seule poignée, centrée sur GeometrieTable.poignee', () => { + const charge = chargeDeTables([8, 12, 2], { formes: ['ronde', 'carree', 'carree'], pas: 450 }); + for (const vue of [ + { k: 1, tx: 200, ty: 300 }, + { k: 0.3, tx: 200, ty: 300 }, + ]) { + const modele = modeleDe(charge, { k: vue.k }); + const { svg, groupe, ecran } = monterPlan(modele, vue); + expect(tous(svg, '[data-poignee]').length).toBe(3); + for (const table of modele.tables) { + const poignees = tous(svg, `[data-poignee="${table.id}"]`); + expect(poignees.length).toBe(1); + const attendu = ecran(table.geometrie.poignee.x, table.geometrie.poignee.y); + const c = centre(poignees[0]); + expect(Math.hypot(c.x - attendu.x, c.y - attendu.y)).toBeLessThan(1); + expect(poignees[0].dataset.marque).toBe('poignee'); + } + expect(groupe.closest('[data-surface]').querySelectorAll('canvas').length).toBe(0); + } + }); +}); + +describe('la mise en évidence', () => { + test('la chaise de la personne, et elle seule, porte [data-marque=miseEnEvidence] au tour montré', () => { + const charge = avecProposition(chargeDeTables([4, 4, 4], { tours: 2 })); + const sieges = new Set(); + for (const tour of [1, 2]) { + const { svg } = monterPlan(modeleDe(charge, { tour, proposition: 1, k: 1, miseEnEvidence: 5 }), { k: 1, tx: 150, ty: 300 }); + const marquees = tous(svg, '[data-marque=miseEnEvidence]'); + expect(marquees.length).toBe(1); + const siege = marquees[0].closest('[data-siege]').dataset.siege; + const etat = etatPlaces(charge, { tour, proposition: 1 }); + const [table, numero] = siege.split(':').map(Number); + expect(etat.tables.find(({ id }) => id === table).chaises[numero - 1].occupant).toBe(5); + expect(couleursPeintes(marquees[0]).stroke).toEqual([jeton('--couleur-mise-en-evidence')]); + expect(anneauVisible(marquees[0])).toBe(true); + sieges.add(siege); + demonterTout(); + } + expect(sieges.size).toBe(2); + }); +}); + +describe('les bords (points d\u{2019}attention 3 et 4)', () => { + test('une table de 2 : deux chaises, un disque de 70 cm', () => { + const { svg } = monterPlan(modeleDe(chargeDeTables([2]), { k: 2 }), { k: 2, tx: 300, ty: 300 }); + expect(tous(svg, '[data-siege]').length).toBe(2); + expect(Math.abs(plateauDe(svg, 1).getBoundingClientRect().width / 2 - 70)).toBeLessThan(0.5); + }); + + test('une table gelée : la marque gel se voit, « gelée » dans son aria-label et en tête de sa liste', () => { + const charge = chargeDeTables([2]); + charge.reservations = [ + { participant: 1, table: 1, siege: 1, portee: 'tous', tour: null }, + { participant: 2, table: 1, siege: 2, portee: 'tous', tour: null }, + ]; + const { svg } = monterPlan(modeleDe(charge, { k: 2 }), { k: 2, tx: 300, ty: 300 }); + const gel = svg.querySelector('[data-table="1"] [data-marque=gel]'); + expect(gel).not.toBeNull(); + expect(visible(gel)).toBe(true); + expect(svg.querySelector('[data-table="1"]').getAttribute('aria-label')).toMatch(/gelée/); + expect(lignesDe(svg, 1)[0].textContent).toBe(t('plan.liste.gelee')); + }); + + test('une seule table ; aucune table : aucun [data-table], un bandeau vide, un montage qui ne lève pas', () => { + expect(tous(monterPlan(modeleDe(chargeDeTables([6]))).svg, '[data-table]').length).toBe(1); + const vide = creerCharge({ id: 'epreuve:vide', nom: 'Vide', siegesParDefaut: 6, tours: 1 }); + const modele = modeleDe(vide); + const { svg } = monterPlan(modele); + expect(tous(svg, '[data-table]').length).toBe(0); + const conteneur = monterHtml(BandeauPlan, { bandeau: modele.bandeau }); + expect(conteneur.querySelector('[data-bandeau-plan]')).toBeNull(); + expect(conteneur.textContent.trim()).toBe(''); + }); + + test('sièges non attribués, deux titres et une réservation « tous » sans siège : la réservée au siège 1, pourvue ; « 2. (libre) — titre … non pourvu »', () => { + const charge = chargeDeTables([4]); + charge.reservations = [{ participant: 1, table: 1, siege: null, portee: 'tous', tour: null }]; + charge.titres = [ + { table: 1, siege: 1, libelle: 'animateur' }, + { table: 1, siege: 2, libelle: 'animateur' }, + ]; + const { svg } = monterPlan(modeleDe(charge), { k: 1, tx: 300, ty: 300 }); + const lignes = lignesDe(svg, 1).map((ligne) => ligne.textContent); + expect(lignes.slice(0, 2)).toEqual([ + '1. (réservée, tous les tours) Iris Ombrelle \u{2014} animateur', + '2. (libre) \u{2014} titre animateur non pourvu', + ]); + expect(svg.querySelector('[data-siege="1:1"] [data-marque=anneau]')).not.toBeNull(); + }); +}); diff --git a/src/interface/plan/EchellePlan.svelte b/src/interface/plan/EchellePlan.svelte new file mode 100644 index 0000000..4a20551 --- /dev/null +++ b/src/interface/plan/EchellePlan.svelte @@ -0,0 +1,62 @@ + + + + +
+ +

{t('plan.echelle.mention')}

+
+ + diff --git a/src/interface/plan/ListeNoms.svelte b/src/interface/plan/ListeNoms.svelte new file mode 100644 index 0000000..30b96a1 --- /dev/null +++ b/src/interface/plan/ListeNoms.svelte @@ -0,0 +1,62 @@ + + + + + + +{#each rangees as rangee (rangee.rang)}{#each rangee.parties as partie (partie.partie)}{partie.texte}{/each}{/each} + + diff --git a/src/interface/plan/Motifs.svelte b/src/interface/plan/Motifs.svelte new file mode 100644 index 0000000..2288fe5 --- /dev/null +++ b/src/interface/plan/Motifs.svelte @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/interface/plan/PlanDessin.svelte b/src/interface/plan/PlanDessin.svelte new file mode 100644 index 0000000..47bc14f --- /dev/null +++ b/src/interface/plan/PlanDessin.svelte @@ -0,0 +1,87 @@ + + + + + + + +{#each modele.tables as table (table.id)} + +{/each} + +{#if modele.listesRendues && mesures !== null} + {#each modele.tables as table (table.id)} + + {/each} +{/if} + + diff --git a/src/interface/plan/TablePlan.svelte b/src/interface/plan/TablePlan.svelte new file mode 100644 index 0000000..3bcd917 --- /dev/null +++ b/src/interface/plan/TablePlan.svelte @@ -0,0 +1,193 @@ + + + + + + + + {#if table.regime === 'table'} + {#if formes.ronde} + + {:else} + + {/if} + {/if} + {#if formes.ronde} + + {:else} + + {/if} + {#if signal} + {#if formes.ronde} + + {:else} + + {/if} + {/if} + {#if table.gelee} + + {/if} + {#each table.chaises as chaise (chaise.siege)} + + + {#each chaise.marques as marque (marque)} + {#if marque === 'anneau'} + + {:else} + + {/if} + {/each} + {#if chaise.miseEnEvidence} + + {/if} + + {/each} + + +{#if table.pastille > 0} + + + {table.pastille} + +{/if} + + diff --git a/src/interface/plan/mesure_texte.js b/src/interface/plan/mesure_texte.js new file mode 100644 index 0000000..913a467 --- /dev/null +++ b/src/interface/plan/mesure_texte.js @@ -0,0 +1,68 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Les mesures du texte du plan, au navigateur (§ 7.4, § 14.1) : la hauteur +// d'une ligne de liste et la largeur d'une chaîne, relevées sur la police +// rendue, jamais déduites de la taille de police. La hauteur se mesure dans +// un OffscreenCanvas, hors du document : le plan ne porte aucun . + +import { GABARIT_HAUTEUR_LIGNE } from '../../geometrie/texte.js'; + +const SVG = 'http://www.w3.org/2000/svg'; + +/** + * Hauteur d'encre du gabarit « Ôjgq Æ W » sur la police calculée de + * l'élément : actualBoundingBoxAscent + actualBoundingBoxDescent d'un + * OffscreenCanvas réglé sur elle — style, graisse, corps et famille. Le + * corps calculé d'un texte du dessin est en unités du dessin : la hauteur + * rendue est en cm, quel que soit le zoom. + * + * @param {Element} elementTexte + * @returns {number} + */ +export function hauteurLigne(elementTexte) { + const { fontStyle, fontWeight, fontSize, fontFamily } = getComputedStyle(elementTexte); + const contexte = new OffscreenCanvas(1, 1).getContext('2d'); + contexte.font = `${fontStyle} ${fontWeight} ${fontSize} ${fontFamily}`; + const mesure = contexte.measureText(GABARIT_HAUTEUR_LIGNE); + return mesure.actualBoundingBoxAscent + mesure.actualBoundingBoxDescent; +} + +/** + * La sonde est-elle rendue : un texte sous un ancêtre en display: none n'a + * pas de mise en page, et getComputedTextLength y rend 0 pour toute chaîne. + * La visibilité (visibility: hidden) n'y entre pas : la sonde du plan est + * cachée ainsi et se mesure. + * + * @param {Element} elementTexte + * @returns {boolean} + */ +export function mesurable(elementTexte) { + return elementTexte.isConnected && elementTexte.checkVisibility(); +} + +/** + * La mesure de largeur d'une chaîne au style de l'élément : un tspan sonde, + * ajouté à l'élément le temps de getComputedTextLength, puis retiré. Un + * élément en italique mesure en italique. Chaque chaîne se mesure une fois : + * la police calculée de l'élément est fixe pour la durée du mesureur. Une + * largeur nulle d'une chaîne non vide — l'élément n'est pas rendu — ne se + * retient pas : la chaîne se mesure de nouveau à la demande suivante. + * + * @param {Element} elementTexte un ou un du dessin + * @returns {(chaine: string) => number} largeur en unités du dessin + */ +export function creerMesureur(elementTexte) { + const deja = new Map(); + return (chaine) => { + const connue = deja.get(chaine); + if (connue !== undefined) return connue; + const sonde = elementTexte.ownerDocument.createElementNS(SVG, 'tspan'); + sonde.textContent = chaine; + elementTexte.append(sonde); + const largeur = sonde.getComputedTextLength(); + sonde.remove(); + if (largeur > 0 || chaine === '') deja.set(chaine, largeur); + return largeur; + }; +} diff --git a/src/interface/plan/modele.js b/src/interface/plan/modele.js new file mode 100644 index 0000000..06a696f --- /dev/null +++ b/src/interface/plan/modele.js @@ -0,0 +1,336 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Le modèle de dessin du plan (§ 7.3 à § 7.7), sans DOM : ce que PlanDessin +// peint, tiré de l'état des places d'un tour (EtatPlaces, A5) et de la +// géométrie des tables (P2). Le modèle lit les conflits de l'état des +// places, jamais ne les recalcule (§ 7.5) ; il ne rend que des codes, des +// nombres et des données — noms, libellés de titre —, que les composants +// traduisent. Les mesures de texte sont injectées : getComputedTextLength +// au navigateur, une fonction quelconque sous node. +import { constante } from '../../geometrie/constantes.js'; +import { regimeCibles } from '../../geometrie/designation.js'; +import { geometrieTable } from '../../geometrie/tables.js'; +import { decalagesLignes, tronquer } from '../../geometrie/texte.js'; + +/** Largeur maximale de la barre de l'échelle graphique, en px d'écran. */ +export const LARGEUR_ECHELLE_PX = 120; + +/** Rayon du disque de la pastille, en cm de dessin : sous la marque de gel + * d'une table de 2, la plus petite. */ +export const RAYON_PASTILLE = 15; +/** Corps du chiffre de la pastille, en cm de dessin. */ +export const TAILLE_TEXTE_PASTILLE_CM = 18; + +/** Écart entre le bord d'une chaise et son anneau, ou sa mise en évidence, en cm. */ +export const ECART_ANNEAU = 6; +export const ECART_MISE_EN_EVIDENCE = 12; + +// Les cadenas d'une chaise (§ 7.6), en cm, centrés sur l'origine : un corps +// plein et une anse, fermée ou ouverte, remplis d'un seul tenant. Le dessin +// les translate au centre de la chaise. Le centre de la boîte de chacun +// tombe dans son corps. +const CORPS = 'M-8,-6 H8 V9 H-8 Z'; + +/** Le tracé du cadenas fermé, centré sur l'origine, en cm. */ +export const CADENAS_FERME = `${CORPS} M-6,-6 V-10 A6,6 0 0 1 6,-10 V-6 H3.5 V-10 A3.5,3.5 0 0 0 -3.5,-10 V-6 Z`; +/** Le tracé du cadenas ouvert : la branche droite de l'anse ne rejoint pas le corps. */ +export const CADENAS_OUVERT = `${CORPS} M-6,-6 V-13 A6,6 0 0 1 6,-13 V-10 H3.5 V-13 A3.5,3.5 0 0 0 -3.5,-13 V-6 Z`; + +/** Distance entre le haut du plateau et le centre de la marque de gel, en cm. */ +export const RETRAIT_GEL = 13; + +/** Le tracé de la marque de gel, un hexagone plein centré sur l'origine, en cm. */ +export const TRACE_GEL = 'M0,-8 L7,-4 L7,4 L0,8 L-7,4 L-7,-4 Z'; + +/** + * Les formes d'une table en attributs SVG, en cm : plateau et emprise — + * disques d'une ronde ({cx, cy, r}), carrés aux côtés parallèles aux axes + * d'une carrée ({x, y, width, height}) —, le centre de la pastille, celui du + * plateau, et celui de la marque de gel, dans le plateau, RETRAIT_GEL sous + * son haut. + * + * @param {import('../../geometrie/tables.js').GeometrieTable} geometrie + */ +export function formesTable({ forme, centre, demiTaille, rayonEmprise }) { + const ronde = forme === 'ronde'; + const dessiner = (demi) => + ronde ? { cx: centre.x, cy: centre.y, r: demi } : { x: centre.x - demi, y: centre.y - demi, width: 2 * demi, height: 2 * demi }; + return { + ronde, + plateau: dessiner(demiTaille), + emprise: dessiner(rayonEmprise), + pastille: { x: centre.x, y: centre.y }, + gel: { x: centre.x, y: centre.y - demiTaille + RETRAIT_GEL }, + }; +} + +/** + * La ligne d'une chaise (§ 7.6) : son numéro, l'état de la place — réservée + * pour tous les tours, réservée au tour désigné, libre, ou rien pour un + * occupant sans réservation —, le nom de l'occupant, et le titre de la place. + * La réservation dit l'état de la place ; l'occupant, le nom : l'occupant + * d'une place titrée n'en devient pas titulaire (§ 4.1). + * + * Une réservation dont la titulaire n'occupe pas la chaise — une source + * qui assied une autre personne, ou personne, sur la place réservée — ne + * s'attribue pas à l'occupant : l'état devient plan.ligne.reserveeA, de + * mêmes portée et tour, et titulaire porte le nom de la personne réservée, + * que la ligne énonce dans sa propre partie. + * + * @param {Object} chaise EtatChaise + * @param {(id: number) => string} nomDe le nom affiché d'un participant + * @returns {{numero: number, etat: {cle: string, details: Object}|null, titulaire: string|null, + * nom: string|null, titre: {libelle: string, pourvu: boolean}|null}} + */ +export function ligneListe(chaise, nomDe) { + const { siege, occupant, reservation, titre } = chaise; + let etat = null; + let titulaire = null; + if (reservation !== null && reservation.participant !== occupant) { + etat = { cle: 'plan.ligne.reserveeA', details: { portee: reservation.portee, tour: reservation.tour } }; + titulaire = nomDe(reservation.participant); + } else if (reservation !== null) { + etat = + reservation.portee === 'tous' + ? { cle: 'plan.ligne.reserveeTous', details: {} } + : { cle: 'plan.ligne.reserveeTour', details: { tour: reservation.tour } }; + } else if (occupant === null) { + etat = { cle: 'plan.ligne.libre', details: {} }; + } + return { + numero: siege, + etat, + titulaire, + nom: occupant === null ? null : nomDe(occupant), + titre: titre === null ? null : { libelle: titre.libelle, pourvu: titre.pourvu }, + }; +} + +/** + * Les textes d'une ligne, partie par partie : tete — numéro et état, suivis + * d'une espace quand un nom suit —, teteSansNom — la même sans l'espace, + * quand le nom tombe —, titulaire et suite, nom, avantTitre, titre — le + * libellé seul —, apresTitre ; null pour une partie absente. Leur + * concaténation, dans cet ordre et sans les formes « SansNom », est la + * ligne entière. + * + * Quand la ligne a une titulaire, l'état s'écrit autour de son nom : tete + * porte le numéro et le début de l'état, titulaire le nom de la personne + * réservée, suite la portée — suivie d'une espace quand un nom suit —, et + * suiteSansNom la même sans l'espace ; tete et teteSansNom sont alors + * égales. Sans titulaire, titulaire, suite et suiteSansNom valent null. + * + * @param {ReturnType} ligne + * @param {(cle: string, details?: Object) => string} t + */ +export function textesLigne({ numero, etat, titulaire = null, nom, titre }, t) { + const tete = (avecNom) => t('plan.ligne.tete', { numero, etat, avecNom }); + const espace = nom === null ? '' : ' '; + const reservee = titulaire === null ? null : t(etat.cle, etat.details); + return { + tete: titulaire === null ? tete(nom !== null) : t('plan.ligne.teteReserveeA', { numero }), + teteSansNom: titulaire === null ? tete(false) : t('plan.ligne.teteReserveeA', { numero }), + titulaire, + suite: reservee === null ? null : `${reservee}${espace}`, + suiteSansNom: reservee, + nom, + avantTitre: titre === null ? null : t(titre.pourvu ? 'plan.ligne.avantTitrePourvu' : 'plan.ligne.avantTitreNonPourvu'), + titre: titre === null ? null : titre.libelle, + apresTitre: titre === null || titre.pourvu ? null : t('plan.ligne.apresTitreNonPourvu'), + }; +} + +/** + * La ligne qui tient dans largeur (§ 7.4) : le nom s'abrège, suivi de la + * marque, à la largeur que lui laissent les autres parties ; quand la + * marque n'y tient plus, le nom tombe — la tête, ou la suite, perd son + * espace — et le titre s'abrège à son tour ; quand il ne tient plus, il + * tombe avec ce qui l'entoure, puis le nom de la titulaire s'abrège dans sa + * partie ; enfin une tête encore trop large s'abrège seule. Le numéro, en + * tête, part le dernier. + * + * Les parties de même style se mesurent ensemble, comme le moteur de rendu + * les compose : tête, titulaire, suite, nom et avantTitre droits d'un + * tenant, le titre en italique, apresTitre droit ; un changement de style + * coupe la composition. + * + * @param {ReturnType} textes + * @param {{mesurer: (s: string) => number, mesurerItalique: (s: string) => number, + * largeur: number, marque: string}} mesures + * @returns {{tete: string, titulaire: string|null, suite: string|null, nom: string|null, + * avantTitre: string|null, titre: string|null, apresTitre: string|null}} + */ +export function ajusterLigne(textes, { mesurer, mesurerItalique, largeur, marque }) { + const { tete, teteSansNom, nom, avantTitre, titre, apresTitre } = textes; + const titulaire = textes.titulaire ?? null; + const suite = textes.suite ?? null; + const suiteSansNom = textes.suiteSansNom ?? null; + // Le début droit de la ligne, jusqu'au nom : tête, titulaire et suite. + const debutAvecNom = `${tete}${titulaire ?? ''}${suite ?? ''}`; + const debutSansNom = `${teteSansNom}${titulaire ?? ''}${suiteSansNom ?? ''}`; + // Largeur de ce qui suit le titre, puis du titre en italique. + const apres = apresTitre === null ? 0 : mesurer(apresTitre); + const italique = (texte) => (texte === null ? 0 : mesurerItalique(texte)); + const vide = { nom: null, avantTitre: null, titre: null, apresTitre: null }; + + if (nom !== null) { + const reste = italique(titre) + apres; + const nomAjuste = tronquer(nom, largeur, (candidat) => mesurer(`${debutAvecNom}${candidat}${avantTitre ?? ''}`) + reste, marque); + if (nomAjuste !== '') return { tete, titulaire, suite, nom: nomAjuste, avantTitre, titre, apresTitre }; + } + + if (titre !== null) { + const debut = mesurer(`${debutSansNom}${avantTitre}`); + const titreAjuste = tronquer(titre, largeur, (candidat) => debut + italique(candidat) + apres, marque); + if (titreAjuste !== '') return { tete: teteSansNom, titulaire, suite: suiteSansNom, ...vide, avantTitre, titre: titreAjuste, apresTitre }; + } + + if (titulaire !== null) { + const titulaireAjuste = tronquer(titulaire, largeur, (candidat) => mesurer(`${teteSansNom}${candidat}${suiteSansNom}`), marque); + if (titulaireAjuste !== '') return { tete: teteSansNom, titulaire: titulaireAjuste, suite: suiteSansNom, ...vide }; + } + + return { tete: tronquer(teteSansNom, largeur, mesurer, marque), titulaire: null, suite: null, ...vide }; +} + +// L'ordre des parties d'une ligne, celui de la lecture. +const PARTIES = ['tete', 'titulaire', 'suite', 'nom', 'avantTitre', 'titre', 'apresTitre']; + +/** + * Les parties présentes d'une ligne ajustée, dans l'ordre de la lecture : + * chacune son tspan, nommé par data-partie ; le titre seul en italique. + * @param {ReturnType} ligne + * @returns {Array<{partie: string, texte: string}>} + */ +export function partiesLigne(ligne) { + return PARTIES.filter((partie) => (ligne[partie] ?? null) !== null).map((partie) => ({ partie, texte: ligne[partie] })); +} + +/** + * Les lignes de base des n lignes d'un bloc dont le haut est à hautY : la + * première une hauteur de ligne sous le haut, chacune une hauteur sous la + * précédente. hauteur est la hauteur d'encre du gabarit (§ 7.4) : deux + * lignes consécutives ne se chevauchent pas. + * @param {number} hautY @param {number} n @param {number} hauteur + * @returns {number[]} + */ +export function ordonneesLignes(hautY, n, hauteur) { + return decalagesLignes(n, hauteur).map((decalage) => hautY + hauteur + decalage); +} + +/** + * La longueur d'une barre d'échelle en mots : sa valeur dans l'unité qui la + * rend lisible — cm sous le mètre, m sous le kilomètre, km au-delà —, le + * nombre de décimales qu'elle demande, et la clé de l'unité. + * @param {number} longueurCm 1, 2 ou 5 × 10ⁿ + * @returns {{valeur: number, decimales: number, cle: string}} + */ +export function libelleLongueur(longueurCm) { + let valeur = longueurCm; + let cle = 'plan.echelle.centimetres'; + if (longueurCm >= 100000) { + valeur = longueurCm / 100000; + cle = 'plan.echelle.kilometres'; + } else if (longueurCm >= 100) { + valeur = longueurCm / 100; + cle = 'plan.echelle.metres'; + } + // Sous l'unité, une mantisse 1, 2 ou 5 × 10⁻ⁿ demande n décimales ; la + // marge absorbe l'arrondi du logarithme d'une puissance de dix. + const decimales = valeur >= 1 ? 0 : Math.ceil(-Math.log10(valeur) - 1e-9); + return { valeur, decimales, cle }; +} + +// Le bandeau du plan (§ 7.5) : vide tant qu'une table au moins est saine ; +// sinon la phrase, puis collisionPartout quand le diagnostic le prouve, puis +// les planchers — une seule appartenance, ou chaque groupe de collisions, +// ou l'absence de plancher prouvé. Les chiffres sont ceux du moteur, tels +// quels, sans somme ; sans diagnostic, la phrase seule. +function bandeauDe(toutesEnConflit, diagnostic) { + if (!toutesEnConflit) return []; + const entrees = [{ cle: 'plan.bandeau.toutesEnConflit', details: {} }]; + if (diagnostic === null) return entrees; + if (diagnostic.collisionPartout) entrees.push({ cle: 'plan.bandeau.collisionPartout', details: {} }); + if (diagnostic.uneSeuleAppartenance) { + entrees.push({ cle: 'plan.bandeau.uneSeuleAppartenance', details: {} }); + } else if (diagnostic.collisions.length > 0) { + for (const { groupe, effectif, plancherCumulees } of diagnostic.collisions) { + entrees.push({ cle: 'plan.bandeau.plancherGroupe', details: { groupe, effectif, plancherCumulees } }); + } + } else { + entrees.push({ cle: 'plan.bandeau.sansPlancher', details: {} }); + } + return entrees; +} + +// Le dessin d'une table de l'état des places au zoom k. +function tableDessin(etatTable, { k, miseEnEvidence, nomDe }) { + const { id, numero, capacite, gelee, surchargee, conflits } = etatTable; + const geometrie = geometrieTable(etatTable, capacite); + const regime = regimeCibles(k, geometrie); + const pastille = conflits === null ? 0 : conflits.nombre; + const chaises = + regime === 'table' + ? [] + : etatTable.chaises.map((chaise, i) => { + const { x, y, rayon } = geometrie.chaises[i]; + return { + siege: chaise.siege, + x, + y, + rayon, + remplissage: chaise.remplissage, + marques: [...chaise.marques], + miseEnEvidence: miseEnEvidence !== null && chaise.occupant === miseEnEvidence, + }; + }); + return { + id, + geometrie, + regime, + conflit: pastille > 0, + pastille, + gelee, + surchargee, + nomAccessible: { + cle: 'plan.table.nom', + details: { numero, places: capacite, conflits: conflits === null ? null : conflits.nombre, gelee, surchargee }, + }, + chaises, + enTete: gelee ? { cle: 'plan.liste.gelee', details: {} } : null, + lignes: etatTable.chaises.map((chaise) => ligneListe(chaise, nomDe)), + }; +} + +/** + * Le modèle de dessin d'un tour (§ 7.3 à § 7.7). + * + * Chaque table, dans l'ordre de l'état des places, porte sa géométrie + * (geometrieTable, P2), son régime des cibles au zoom k (regimeCibles) — + * aucune chaise dessinée en régime table —, son conflit et sa pastille, lus + * dans conflits — null : ni conflit, ni pastille —, son nom accessible, et + * une ligne par siège, de 1 à la capacité, précédée de l'en-tête d'une + * table gelée. Quand toutes les tables sont en conflit, aucune n'est + * signalée — signalerConflits faux — et le bandeau énonce le diagnostic ; + * les pastilles gardent leur nombre. Les listes se rendent quand elles + * sont demandées et que k atteint K_LISTES (§ 17, point 6). La largeur du + * bloc de noms n'est pas dans le modèle : c'est LARGEUR_BLOC_NOMS_CM du + * registre, que le dessin lit. + * + * @param {Object} etatPlaces EtatPlaces (A5) + * @param {{k: number, listes: boolean, miseEnEvidence: number|null, + * nomDe: (id: number) => string, diagnostic?: Object|null}} options + * diagnostic : celui que rend diagnostiquer, compléments de M1 compris + * @returns {Object} ModeleDessin + */ +export function modeleDessin(etatPlaces, { k, listes, miseEnEvidence, nomDe, diagnostic = null }) { + const tables = etatPlaces.tables.map((etatTable) => tableDessin(etatTable, { k, miseEnEvidence, nomDe })); + const toutesEnConflit = tables.length > 0 && tables.every(({ conflit }) => conflit); + return { + tables, + signalerConflits: !toutesEnConflit, + bandeau: bandeauDe(toutesEnConflit, diagnostic), + listesRendues: listes && k >= constante('K_LISTES'), + }; +} diff --git a/src/interface/plan/modele.test.js b/src/interface/plan/modele.test.js new file mode 100644 index 0000000..e533073 --- /dev/null +++ b/src/interface/plan/modele.test.js @@ -0,0 +1,719 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Épreuves du modèle de dessin du plan (§ 7.3, § 7.4, § 7.5, § 7.6, § 7.7), +// sans DOM. Le plan lit les conflits de l'état des places, jamais ne les +// recalcule ; l'épreuve, elle, importe le moteur et le stockage pour +// construire ses données et obtenir la valeur attendue (§ 19.1). Les noms +// d'épreuve sont inventés ; la petite démonstration livrée se lit, et sa +// variante se dérive, en mémoire depuis texteLivre. +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { describe, test } from '../../../test/lanceur.js'; +import { TABLES_APPLICATION } from '../../application/libelles.js'; +import { etatPlaces, instanceDe } from '../../application/places.js'; +import { creerTraduction } from '../../application/traduction.js'; +import { texteLivre } from '../../demo/livrees.js'; +import { constante } from '../../geometrie/constantes.js'; +import { regimeCibles } from '../../geometrie/designation.js'; +import { geometrieTable } from '../../geometrie/tables.js'; +import { indexerPlan } from '../../moteur/configuration.js'; +import { conflitsParTable } from '../../moteur/conflits.js'; +import { diagnostiquer } from '../../moteur/diagnostic.js'; +import { rechercher } from '../../moteur/recherche.js'; +import { serialiser } from '../../stockage/canonique.js'; +import { analyser, configurationDepuisCharge, creerCharge } from '../../stockage/document.js'; +import { planDepuisFichier, versFichier } from '../../stockage/placements.js'; +import { TABLES_INTERFACE } from '../libelles.js'; +import { + LARGEUR_ECHELLE_PX, + ajusterLigne, + formesTable, + libelleLongueur, + ligneListe, + modeleDessin, + ordonneesLignes, + partiesLigne, + textesLigne, +} from './modele.js'; + +const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]); + +const K_LISTES = constante('K_LISTES'); + +// --- Les configurations d'épreuve ------------------------------------------- + +// Douze noms inventés : prénom (null pour un nom seul) et nom. +const NOMS = [ + ['Iris', 'Ombrelle'], + ['Théo', 'Pervenche'], + ['Ondine', 'Lacasse'], + [null, 'Grisaille'], + ['Basile', 'Tourmaline'], + ['Capucine', 'Arrosoir'], + ['Aurèle', 'Cerfeuil'], + ['Mélisande', 'Bruyère'], + ['Firmin', 'Galet'], + ['Clélia', 'Mistral'], + ['Anatole', 'Houblon'], + ['Solveig', 'Sarcelle'], +]; + +const personne = (id, nom, prenom, appartenance) => ({ + id, + nom, + prenom, + appartenance, + courriel: null, + titrePressenti: null, + notes: null, + exclu: false, +}); + +// Les propositions de rechercher(configuration, {graine, nombre, arret: +// 4000}), passées par versFichier et posées dans la charge ; le compteur +// passe au-delà du dernier identifiant, et le plan est proposé. +function avecPropositions(charge, { graine = 7, nombre = 1 } = {}) { + const configuration = configurationDepuisCharge(charge); + const propositions = rechercher(configuration, { graine, nombre, arret: 4000 }); + charge.propositions = propositions.map((p) => + versFichier(p, configuration, { produitVersion: 'epreuve', attribuerSieges: false, decalage: 0 }), + ); + charge.prochainsIds.proposition = nombre + 1; + charge.evenement.etat = 'propose'; + return charge; +} + +/** Douze personnes en deux appartenances de six — une seule avec uneSeule —, + * quatre tables rondes de 3, quatre tours, séparation active, et une + * proposition de rechercher(…, {graine: 7, nombre: 1, arret: 4000}). */ +function toutesEnConflit({ uneSeule = false } = {}) { + const charge = creerCharge({ id: 'epreuve:toutes-en-conflit', nom: 'Toutes en conflit', siegesParDefaut: 3, tours: 4 }); + charge.participants = NOMS.map(([prenom, nom], i) => + personne(i + 1, nom, prenom, uneSeule || i % 2 === 0 ? 'Club des Merles' : 'Cercle des Hérons'), + ); + charge.tables = [1, 2, 3, 4].map((id) => ({ id, numero: id, sieges: null, forme: 'ronde', position: { x: 300 * (id - 1), y: 0 } })); + charge.prochainsIds = { participant: 13, table: 5, proposition: 1 }; + return avecPropositions(charge); +} + +const FIXTURE = new URL('../../../test/fixtures/plan/toutes_en_conflit.gtt.json', import.meta.url); + +// La petite démonstration livrée, relue en mémoire. +const petite = (cle = 'petite') => analyser(texteLivre(cle)).charge; + +// nomDe sur les participants de la charge : prénom et nom, ou le nom seul. +const nomsDe = (charge) => { + const noms = new Map(charge.participants.map(({ id, nom, prenom }) => [id, prenom === null ? nom : `${prenom} ${nom}`])); + return (id) => noms.get(id); +}; + +const options = (charge, autres = {}) => ({ k: 1, listes: true, miseEnEvidence: null, nomDe: nomsDe(charge), ...autres }); + +// Une copie de l'état des places où la table d'identifiant donné est saine. +function avecTableSaine(etat, id) { + return { ...etat, tables: etat.tables.map((table) => (table.id === id ? { ...table, conflits: { nombre: 0, regles: [] } } : table)) }; +} + +// --- Les conflits se lisent --------------------------------------------------- + +describe('modeleDessin : les conflits se lisent, jamais ne se recalculent (§ 7.5)', () => { + test('petite démonstration, conflit inévitable : tables signalées et pastilles de chaque tour égalent conflitsParTable', () => { + const charge = avecPropositions(petite('petite-conflit')); + const instance = instanceDe(charge); + const attendus = conflitsParTable(instance, indexerPlan(instance, planDepuisFichier(charge.propositions[0]))); + let signalees = 0; + for (let tour = 1; tour <= charge.evenement.tours; tour += 1) { + const etat = etatPlaces(charge, { tour, proposition: 1 }); + const modele = modeleDessin(etat, options(charge)); + assert.equal(modele.tables.length, 4); + const toutes = attendus[tour - 1].every(({ nombre }) => nombre > 0); + assert.equal(modele.signalerConflits, !toutes, `tour ${tour}`); + const nombreDe = new Map(attendus[tour - 1].map(({ table, nombre }) => [table, nombre])); + for (const table of modele.tables) { + const lu = etat.tables.find(({ id }) => id === table.id).conflits; + assert.equal(table.pastille, lu.nombre); + assert.equal(table.pastille, nombreDe.get(table.id), `tour ${tour}, table ${table.id}`); + assert.equal(table.conflit, lu.nombre > 0); + if (table.conflit && modele.signalerConflits) signalees += 1; + } + } + assert.ok(signalees > 0, 'la variante conflit signale au moins une table, à un tour où une autre est saine'); + }); + + test('conflits null — non évalués — : ni conflit, ni pastille', () => { + const charge = petite(); + const etat = etatPlaces(charge, { tour: 1 }); + const sansEvaluation = { ...etat, evalue: false, tables: etat.tables.map((table) => ({ ...table, conflits: null })) }; + const modele = modeleDessin(sansEvaluation, options(charge)); + assert.equal(modele.tables.length, 4); + for (const table of modele.tables) { + assert.equal(table.conflit, false); + assert.equal(table.pastille, 0); + assert.deepEqual(table.nomAccessible.details.conflits, null); + } + assert.equal(modele.signalerConflits, true); + assert.deepEqual(modele.bandeau, []); + }); + + test('toutes en conflit : le fichier d\u{2019}épreuve se relit, et le plan cesse de signaler', () => { + const texte = readFileSync(FIXTURE, 'utf8'); + const attendu = toutesEnConflit(); + assert.equal(texte, serialiser(attendu, { revision: 1, produitVersion: 'epreuve' }), 'le fichier est la configuration construite'); + const { charge } = analyser(texte); + for (let tour = 1; tour <= charge.evenement.tours; tour += 1) { + const etat = etatPlaces(charge, { tour, proposition: 1 }); + const modele = modeleDessin(etat, options(charge)); + assert.equal(modele.tables.length, 4); + assert.equal(modele.signalerConflits, false); + for (const table of modele.tables) { + assert.ok(table.conflit); + assert.ok(table.pastille > 0); + assert.equal(table.pastille, etat.tables.find(({ id }) => id === table.id).conflits.nombre); + } + assert.deepEqual(modele.bandeau, [{ cle: 'plan.bandeau.toutesEnConflit', details: {} }]); + + const sain = modeleDessin(avecTableSaine(etat, 2), options(charge)); + assert.equal(sain.signalerConflits, true, 'une table saine rétablit le signalement'); + assert.deepEqual(sain.bandeau, []); + assert.deepEqual( + sain.tables.map(({ id, conflit }) => [id, conflit]), + [ + [1, true], + [2, false], + [3, true], + [4, true], + ], + ); + } + }); + + test('aucune table : aucun dessin, un bandeau vide', () => { + const charge = creerCharge({ id: 'epreuve:vide', nom: 'Vide', siegesParDefaut: 6, tours: 1 }); + const modele = modeleDessin(etatPlaces(charge, { tour: 1 }), options(charge)); + assert.deepEqual(modele.tables, []); + assert.deepEqual(modele.bandeau, []); + assert.equal(modele.signalerConflits, true); + }); +}); + +// --- Le bandeau énonce le diagnostic ------------------------------------------- + +describe('modeleDessin : le bandeau énonce le diagnostic (§ 7.5)', () => { + const lire = (charge) => etatPlaces(charge, { tour: 1, proposition: 1 }); + + test('deux appartenances de six : la phrase, collisionPartout, puis un plancher par groupe, chiffres du moteur', () => { + const charge = toutesEnConflit(); + const diagnostic = diagnostiquer(configurationDepuisCharge(charge)); + assert.equal(diagnostic.collisionPartout, true); + assert.equal(diagnostic.uneSeuleAppartenance, false); + assert.equal(diagnostic.collisions.length, 2); + const { bandeau } = modeleDessin(lire(charge), options(charge, { diagnostic })); + assert.deepEqual(bandeau, [ + { cle: 'plan.bandeau.toutesEnConflit', details: {} }, + { cle: 'plan.bandeau.collisionPartout', details: {} }, + ...diagnostic.collisions.map(({ groupe, effectif, plancherCumulees }) => ({ + cle: 'plan.bandeau.plancherGroupe', + details: { groupe, effectif, plancherCumulees }, + })), + ]); + assert.deepEqual( + bandeau.slice(2).map(({ details }) => details.effectif), + [6, 6], + ); + for (const { cle, details } of bandeau) assert.equal(typeof t(cle, details), 'string'); + }); + + test('une seule appartenance : l\u{2019}entrée uneSeuleAppartenance, sans plancher par groupe', () => { + const charge = toutesEnConflit({ uneSeule: true }); + const diagnostic = diagnostiquer(configurationDepuisCharge(charge)); + assert.equal(diagnostic.uneSeuleAppartenance, true); + const { bandeau, signalerConflits } = modeleDessin(lire(charge), options(charge, { diagnostic })); + assert.equal(signalerConflits, false); + assert.deepEqual( + bandeau.map(({ cle }) => cle), + [ + 'plan.bandeau.toutesEnConflit', + ...(diagnostic.collisionPartout ? ['plan.bandeau.collisionPartout'] : []), + 'plan.bandeau.uneSeuleAppartenance', + ], + ); + }); + + test('un diagnostic sans plancher : la phrase, puis l\u{2019}entrée qui le dit', () => { + const charge = toutesEnConflit(); + const diagnostic = { collisions: [], uneSeuleAppartenance: false, collisionPartout: false }; + const { bandeau } = modeleDessin(lire(charge), options(charge, { diagnostic })); + assert.deepEqual(bandeau, [ + { cle: 'plan.bandeau.toutesEnConflit', details: {} }, + { cle: 'plan.bandeau.sansPlancher', details: {} }, + ]); + }); + + test('diagnostic null : la phrase seule', () => { + const charge = toutesEnConflit(); + const { bandeau } = modeleDessin(lire(charge), options(charge, { diagnostic: null })); + assert.deepEqual(bandeau, [{ cle: 'plan.bandeau.toutesEnConflit', details: {} }]); + }); + + test('la petite démonstration : collisionPartout faux, son entrée absente', () => { + const diagnostic = diagnostiquer(configurationDepuisCharge(petite())); + assert.equal(diagnostic.collisionPartout, false); + const charge = toutesEnConflit(); + const { bandeau } = modeleDessin(lire(charge), options(charge, { diagnostic })); + assert.equal(bandeau[0].cle, 'plan.bandeau.toutesEnConflit'); + assert.ok(!bandeau.some(({ cle }) => cle === 'plan.bandeau.collisionPartout')); + }); + + test('une table saine : le bandeau reste vide, diagnostic donné ou non', () => { + const charge = toutesEnConflit(); + const diagnostic = diagnostiquer(configurationDepuisCharge(charge)); + const { bandeau } = modeleDessin(avecTableSaine(lire(charge), 3), options(charge, { diagnostic })); + assert.deepEqual(bandeau, []); + }); +}); + +// --- La source -------------------------------------------------------------------- + +describe('modeleDessin : les lignes nomment les occupants de la source (contrat)', () => { + test('proposition 2 demandée → ses occupants ; sans demande → ceux du retenu, tiré de la proposition 1', () => { + const charge = avecPropositions(petite(), { graine: 11, nombre: 2 }); + const [premiere] = charge.propositions; + const { siegesAttribues, tables, capacites, tours, participants, placement } = structuredClone(premiere); + charge.retenu = { proposition: 1, siegesAttribues, tables, capacites, tours, participants, placement }; + charge.evenement.etat = 'retenu'; + const nomDe = nomsDe(charge); + // Les noms des lignes de chaque table, dans l'ordre des tables. + const nomsDesLignes = (etat) => modeleDessin(etat, options(charge)).tables.map(({ lignes }) => lignes.map(({ nom }) => nom)); + // Les noms qu'une proposition assied au tour 1, table par table. + const nomsDeLaSource = (p) => charge.tables.map(({ id }) => p.placement[0].sieges[p.tables.indexOf(id)].map(nomDe).sort()); + + const montree = etatPlaces(charge, { tour: 1, proposition: 2 }); + assert.equal(montree.source, 'proposition'); + const retenu = etatPlaces(charge, { tour: 1 }); + assert.equal(retenu.source, 'retenu'); + const deLaDeux = nomsDesLignes(montree).map((noms) => [...noms].sort()); + const duRetenu = nomsDesLignes(retenu).map((noms) => [...noms].sort()); + assert.deepEqual(deLaDeux, nomsDeLaSource(charge.propositions[1])); + assert.deepEqual(duRetenu, nomsDeLaSource(charge.retenu)); + assert.notDeepEqual(deLaDeux, duRetenu, 'les deux sources assoient autrement au tour 1'); + }); +}); + +// --- Les lignes de la liste -------------------------------------------------------- + +// Une chaise de l'état des places, comme etatPlaces la rend. +const chaise = (siege, { occupant = null, reservation = null, titre = null } = {}) => ({ + siege, + occupant, + reservation, + titre, + remplissage: 'normal', + marques: [], +}); + +const NOMS_LIGNES = new Map([ + [1, 'Iris Ombrelle'], + [2, 'Théo Pervenche'], + [3, 'Ondine Lacasse'], + [4, 'Grisaille'], +]); +const nomDeLigne = (id) => NOMS_LIGNES.get(id); +const animateur = (pourvu) => ({ libelle: 'animateur', pourvu }); + +// Les six formes de la décision : chaise, ligne attendue, texte attendu. +const SIX_FORMES = [ + [ + chaise(1, { occupant: 1, reservation: { participant: 1, portee: 'tous', tour: null }, titre: animateur(true) }), + { numero: 1, etat: { cle: 'plan.ligne.reserveeTous', details: {} }, titulaire: null, nom: 'Iris Ombrelle', titre: animateur(true) }, + '1. (réservée, tous les tours) Iris Ombrelle \u{2014} animateur', + ], + [ + chaise(2, { occupant: 2, reservation: { participant: 2, portee: 'tour', tour: 2 } }), + { numero: 2, etat: { cle: 'plan.ligne.reserveeTour', details: { tour: 2 } }, titulaire: null, nom: 'Théo Pervenche', titre: null }, + '2. (réservée, tour 2) Théo Pervenche', + ], + [chaise(3, { occupant: 3 }), { numero: 3, etat: null, titulaire: null, nom: 'Ondine Lacasse', titre: null }, '3. Ondine Lacasse'], + [ + chaise(4, { occupant: 4, titre: animateur(false) }), + { numero: 4, etat: null, titulaire: null, nom: 'Grisaille', titre: animateur(false) }, + '4. Grisaille \u{2014} titre animateur non pourvu', + ], + [chaise(5), { numero: 5, etat: { cle: 'plan.ligne.libre', details: {} }, titulaire: null, nom: null, titre: null }, '5. (libre)'], + [ + chaise(6, { titre: animateur(false) }), + { numero: 6, etat: { cle: 'plan.ligne.libre', details: {} }, titulaire: null, nom: null, titre: animateur(false) }, + '6. (libre) \u{2014} titre animateur non pourvu', + ], +]; + +// Le texte entier d'une ligne : ses parties dans l'ordre, les absentes omises. +const joindre = ({ tete, titulaire, suite, nom, avantTitre, titre, apresTitre }) => + [tete, titulaire, suite, nom, avantTitre, titre, apresTitre].filter((partie) => partie !== null).join(''); + +describe('ligneListe et textesLigne : les six formes (§ 7.6)', () => { + test('chaque forme, exactement, titre valant {libelle, pourvu}', () => { + assert.equal(SIX_FORMES.length, 6); + for (const [entree, ligne, texte] of SIX_FORMES) { + assert.deepEqual(ligneListe(entree, nomDeLigne), ligne); + assert.equal(joindre(textesLigne(ligne, t)), texte); + } + }); + + test('le titre est sa propre partie, le libellé seul ; le nom aussi', () => { + const textes = textesLigne(SIX_FORMES[0][1], t); + assert.equal(textes.nom, 'Iris Ombrelle'); + assert.equal(textes.titre, 'animateur'); + assert.equal(textes.tete, '1. (réservée, tous les tours) '); + assert.equal(textes.teteSansNom, '1. (réservée, tous les tours)'); + assert.equal(textesLigne(SIX_FORMES[2][1], t).titre, null); + }); +}); + +// Mesures factices : une unité par unité de code, 1,5 en italique. +const droit = (chaine) => chaine.length; +const italique = (chaine) => 1.5 * chaine.length; +const MARQUE = '\u{2026}'; +const largeurDe = (ligne) => + droit(`${ligne.tete}${ligne.titulaire ?? ''}${ligne.suite ?? ''}${ligne.nom ?? ''}${ligne.avantTitre ?? ''}`) + + italique(ligne.titre ?? '') + + droit(ligne.apresTitre ?? ''); + +// --- Une place réservée qu'occupe une autre personne ----------------------------------- + +describe('ligneListe : la réservation d\u{2019}une autre personne que l\u{2019}occupant (§ 7.6)', () => { + test('petite démonstration, une réservation posée après la proposition : la ligne nomme sa titulaire, puis l\u{2019}occupant', () => { + const charge = petite(); + const configuration = configurationDepuisCharge(charge); + const [proposition] = rechercher(configuration, { graine: 7, nombre: 1, arret: 1000 }); + charge.propositions = [versFichier(proposition, configuration, { produitVersion: 'epreuve', attribuerSieges: false, decalage: 0 })]; + charge.prochainsIds.proposition = 2; + charge.evenement.etat = 'propose'; + charge.reservations.push({ participant: 1, table: 1, siege: 2, portee: 'tous', tour: null }); + const etat = etatPlaces(charge, { tour: 1, proposition: 1 }); + const chaise2 = etat.tables[0].chaises[1]; + assert.equal(chaise2.reservation.participant, 1); + assert.equal(etat.tables[0].chaises.some(({ occupant }) => occupant === 1), false, 'la proposition n\u{2019}assied pas la titulaire à la table 1'); + assert.notEqual(chaise2.occupant, null); + const nomDe = nomsDe(charge); + const titulaire = nomDe(1); + const occupant = nomDe(chaise2.occupant); + const ligne = ligneListe(chaise2, nomDe); + assert.deepEqual(ligne, { + numero: 2, + etat: { cle: 'plan.ligne.reserveeA', details: { portee: 'tous', tour: null } }, + titulaire, + nom: occupant, + titre: null, + }); + assert.deepEqual(modeleDessin(etat, options(charge)).tables[0].lignes[1], ligne); + const textes = textesLigne(ligne, t); + assert.equal(joindre(textes), `2. (réservée à ${titulaire}, tous les tours) ${occupant}`); + // La titulaire et l'occupant occupent chacun leur partie, donc leur tspan. + const parties = partiesLigne(ajusterLigne(textes, { mesurer: droit, mesurerItalique: italique, largeur: 1000, marque: MARQUE })); + assert.deepEqual( + parties.filter(({ partie }) => partie === 'titulaire' || partie === 'nom'), + [ + { partie: 'titulaire', texte: titulaire }, + { partie: 'nom', texte: occupant }, + ], + ); + }); + + test('réservée au seul tour 3 pour une autre personne, la place vide : « 2. (réservée à …, tour 3) »', () => { + const ligne = ligneListe(chaise(2, { reservation: { participant: 1, portee: 'tour', tour: 3 } }), nomDeLigne); + assert.deepEqual(ligne, { + numero: 2, + etat: { cle: 'plan.ligne.reserveeA', details: { portee: 'tour', tour: 3 } }, + titulaire: 'Iris Ombrelle', + nom: null, + titre: null, + }); + assert.equal(joindre(textesLigne(ligne, t)), '2. (réservée à Iris Ombrelle, tour 3)'); + }); + + test('trop étroite : le nom de l\u{2019}occupant s\u{2019}abrège, puis tombe ; la titulaire s\u{2019}abrège, dans sa partie', () => { + const entree = textesLigne( + ligneListe(chaise(2, { occupant: 2, reservation: { participant: 1, portee: 'tous', tour: null } }), nomDeLigne), + t, + ); + const pleine = joindre(entree).length; + const abregee = ajusterLigne(entree, { mesurer: droit, mesurerItalique: italique, largeur: pleine - 3, marque: MARQUE }); + assert.equal(abregee.titulaire, 'Iris Ombrelle'); + assert.ok(abregee.nom.endsWith(MARQUE)); + assert.ok(largeurDe(abregee) <= pleine - 3); + const sansNom = ajusterLigne(entree, { mesurer: droit, mesurerItalique: italique, largeur: 40, marque: MARQUE }); + assert.equal(sansNom.nom, null); + assert.ok(sansNom.titulaire.endsWith(MARQUE), sansNom.titulaire); + assert.equal(sansNom.suite, entree.suiteSansNom); + assert.ok(largeurDe(sansNom) <= 40, `${largeurDe(sansNom)}`); + }); +}); + +// --- La ligne entière tient dans la largeur du bloc ------------------------------------ + +describe('ajusterLigne : la ligne entière tient (§ 7.4)', () => { + const textes = (nom, titre = null, pourvu = true) => + textesLigne({ numero: 1, etat: null, nom, titre: titre === null ? null : { libelle: titre, pourvu } }, t); + + test('une ligne qui tient reste entière', () => { + const entree = textes('Iris Ombrelle', 'animateur'); + const ligne = ajusterLigne(entree, { mesurer: droit, mesurerItalique: italique, largeur: 100, marque: MARQUE }); + assert.deepEqual(ligne, { + tete: entree.tete, + titulaire: null, + suite: null, + nom: entree.nom, + avantTitre: entree.avantTitre, + titre: entree.titre, + apresTitre: null, + }); + }); + + test('le nom s\u{2019}abrège à la largeur que lui laissent les autres parties, mesurées chacune dans son style', () => { + const entree = textes('W'.repeat(200), 'animateur'); + // tête « 1. » + espace : 3 ; « — » entouré : 3 ; titre en italique : 13,5. + const ligne = ajusterLigne(entree, { mesurer: droit, mesurerItalique: italique, largeur: 40, marque: MARQUE }); + assert.equal(ligne.titre, 'animateur'); + assert.ok(ligne.nom.endsWith(MARQUE)); + assert.ok(largeurDe(ligne) <= 40); + assert.ok(largeurDe(ligne) > 39, `le nom prend toute la place restante : ${largeurDe(ligne)}`); + }); + + test('sans place pour la marque, le nom tombe et le titre s\u{2019}abrège', () => { + const entree = textes('Iris Ombrelle', 'a'.repeat(30), false); + const ligne = ajusterLigne(entree, { mesurer: droit, mesurerItalique: italique, largeur: 40, marque: MARQUE }); + assert.equal(ligne.nom, null); + assert.equal(ligne.tete, entree.teteSansNom); + assert.ok(ligne.titre.endsWith(MARQUE)); + assert.equal(ligne.apresTitre, entree.apresTitre); + assert.ok(largeurDe(ligne) <= 40); + }); + + test('sans place pour le titre, il tombe avec ce qui l\u{2019}entoure ; une tête trop large s\u{2019}abrège', () => { + const entree = textesLigne( + { numero: 1, etat: { cle: 'plan.ligne.reserveeTous', details: {} }, nom: 'Iris Ombrelle', titre: { libelle: 'animateur', pourvu: true } }, + t, + ); + const etroite = ajusterLigne(entree, { mesurer: droit, mesurerItalique: italique, largeur: 20, marque: MARQUE }); + assert.deepEqual([etroite.nom, etroite.avantTitre, etroite.titre, etroite.apresTitre], [null, null, null, null]); + assert.ok(etroite.tete.startsWith('1. (r')); + assert.ok(etroite.tete.endsWith(MARQUE)); + assert.ok(largeurDe(etroite) <= 20); + }); + + test('chaque ligne d\u{2019}un bloc ajusté tient, quelle que soit la largeur', () => { + const lignes = SIX_FORMES.map(([, ligne]) => textesLigne(ligne, t)); + assert.equal(lignes.length, 6); + for (const largeur of [5, 12, 20, 40, 80, 200]) { + for (const entree of lignes) { + const ligne = ajusterLigne(entree, { mesurer: droit, mesurerItalique: italique, largeur, marque: MARQUE }); + assert.ok(largeurDe(ligne) <= largeur, `${joindre(entree)} à ${largeur} : ${largeurDe(ligne)}`); + } + } + }); +}); + +describe('ordonneesLignes : une ligne sous la précédente', () => { + test('la première ligne sous le haut du bloc, d\u{2019}une hauteur de ligne ; puis une hauteur de plus chacune', () => { + assert.deepEqual(ordonneesLignes(-50, 3, 20), [-30, -10, 10]); + assert.deepEqual(ordonneesLignes(0, 0, 20), []); + }); +}); + +// --- Régime, listes, marques, bords ------------------------------------------------------ + +// Une charge de tables rondes de capacités données, des personnes inventées. +function chargeDeTables(capacites, { tours = 1 } = {}) { + const charge = creerCharge({ id: 'epreuve:tables', nom: 'Tables', siegesParDefaut: 8, tours }); + charge.tables = capacites.map((sieges, i) => ({ + id: i + 1, + numero: i + 1, + sieges, + forme: i % 2 === 0 ? 'ronde' : 'carree', + position: { x: 400 * i, y: 0 }, + })); + charge.participants = NOMS.map(([prenom, nom], i) => personne(i + 1, nom, prenom, null)); + charge.prochainsIds = { participant: 13, table: capacites.length + 1, proposition: 1 }; + return charge; +} + +describe('modeleDessin : le dessin d\u{2019}une table (§ 7.3, § 7.6, § 17 point 6)', () => { + test('géométrie de geometrieTable ; chaises dessinées en régime sièges, aucune en régime table', () => { + const charge = chargeDeTables([8, 12, 2]); + const etat = etatPlaces(charge, { tour: 1 }); + for (const k of [0.39, 0.41, 2]) { + const modele = modeleDessin(etat, options(charge, { k })); + assert.equal(modele.tables.length, 3); + for (const table of modele.tables) { + const enregistrement = charge.tables.find(({ id }) => id === table.id); + const geometrie = geometrieTable(enregistrement, etat.tables.find(({ id }) => id === table.id).capacite); + assert.deepEqual(table.geometrie, geometrie); + assert.equal(table.regime, regimeCibles(k, geometrie)); + if (table.regime === 'table') assert.deepEqual(table.chaises, []); + else { + assert.equal(table.chaises.length, geometrie.chaises.length); + table.chaises.forEach((dessin, i) => { + const { siege, x, y, rayon } = geometrie.chaises[i]; + assert.deepEqual({ siege: dessin.siege, x: dessin.x, y: dessin.y, rayon: dessin.rayon }, { siege, x, y, rayon }); + }); + } + } + } + const regimes = (k) => modeleDessin(etat, options(charge, { k })).tables.map(({ regime }) => regime); + assert.deepEqual(regimes(0.39), ['table', 'table', 'sieges']); + assert.deepEqual(regimes(0.41), ['sieges', 'sieges', 'sieges']); + }); + + test('listes rendues : demandées et k ≥ K_LISTES', () => { + const charge = chargeDeTables([8]); + const etat = etatPlaces(charge, { tour: 1 }); + assert.equal(modeleDessin(etat, options(charge, { k: K_LISTES - 0.01 })).listesRendues, false); + assert.equal(modeleDessin(etat, options(charge, { k: K_LISTES })).listesRendues, true); + assert.equal(modeleDessin(etat, options(charge, { k: K_LISTES + 0.01 })).listesRendues, true); + assert.equal(modeleDessin(etat, options(charge, { k: 5, listes: false })).listesRendues, false); + assert.equal('largeurListe' in modeleDessin(etat, options(charge)), false, 'la largeur du bloc est celle du registre'); + }); + + test('une ligne par siège, de 1 à la capacité, quel que soit le régime', () => { + const charge = chargeDeTables([8, 2]); + const etat = etatPlaces(charge, { tour: 1 }); + const modele = modeleDessin(etat, options(charge, { k: 0.1 })); + assert.deepEqual( + modele.tables.map(({ lignes }) => lignes.map(({ numero }) => numero)), + [ + [1, 2, 3, 4, 5, 6, 7, 8], + [1, 2], + ], + ); + }); + + test('remplissage, marques et mise en évidence reprennent l\u{2019}état des places, au tour montré', () => { + const charge = chargeDeTables([8], { tours: 2 }); + charge.reservations = [ + { participant: 1, table: 1, siege: 1, portee: 'tous', tour: null }, + { participant: 2, table: 1, siege: 3, portee: 'tour', tour: 1 }, + ]; + charge.titres = [ + { table: 1, siege: 1, libelle: 'animateur' }, + { table: 1, siege: 2, libelle: 'animateur' }, + ]; + for (const tour of [1, 2]) { + const etat = etatPlaces(charge, { tour }); + const modele = modeleDessin(etat, options(charge, { miseEnEvidence: 1 })); + const [table] = modele.tables; + assert.equal(table.chaises.length, 8); + table.chaises.forEach((dessin, i) => { + const lue = etat.tables[0].chaises[i]; + assert.equal(dessin.remplissage, lue.remplissage); + assert.deepEqual(dessin.marques, lue.marques); + assert.equal(dessin.miseEnEvidence, lue.occupant === 1); + }); + assert.equal(table.chaises.filter(({ miseEnEvidence }) => miseEnEvidence).length, 1); + } + const [tour1] = modeleDessin(etatPlaces(charge, { tour: 1 }), options(charge)).tables; + assert.deepEqual(tour1.chaises.slice(0, 3).map(({ marques }) => marques), [['cadenasFerme', 'anneau'], ['cadenasOuvert'], ['cadenasFerme']]); + assert.deepEqual(tour1.chaises.slice(0, 3).map(({ remplissage }) => remplissage), ['plein', 'hachure', 'plein']); + assert.ok(tour1.chaises.every(({ miseEnEvidence }) => miseEnEvidence === false), 'aucune mise en évidence demandée'); + }); + + test('une table gelée le dit dans son nom accessible et en tête de sa liste ; une surchargée, dans son nom', () => { + const charge = chargeDeTables([2, 3]); + charge.reservations = [ + { participant: 1, table: 1, siege: 1, portee: 'tous', tour: null }, + { participant: 2, table: 1, siege: 2, portee: 'tous', tour: null }, + ]; + charge.evenement.siegesParDefaut = 3; + const etat = etatPlaces(charge, { tour: 1 }); + assert.equal(etat.tables[0].gelee, true); + const [gelee, autre] = modeleDessin(etat, options(charge)).tables; + assert.equal(gelee.gelee, true); + assert.deepEqual(gelee.enTete, { cle: 'plan.liste.gelee', details: {} }); + assert.equal(gelee.nomAccessible.cle, 'plan.table.nom'); + assert.match(t(gelee.nomAccessible.cle, gelee.nomAccessible.details), /gelée/); + assert.match(t(gelee.nomAccessible.cle, gelee.nomAccessible.details), /surchargée/); + assert.equal(autre.gelee, false); + assert.equal(autre.enTete, null); + assert.doesNotMatch(t(autre.nomAccessible.cle, autre.nomAccessible.details), /gelée/); + assert.match(t(autre.nomAccessible.cle, autre.nomAccessible.details), /^Table 2, 3 places, .*surchargée$/); + }); + + test('sièges non attribués, deux titres et une réservation « tous » sans siège : la réservée au siège 1, pourvue ; « 2. (libre) — titre … non pourvu »', () => { + const charge = chargeDeTables([4]); + charge.participants = charge.participants.slice(0, 1); + charge.prochainsIds.participant = 2; + charge.reservations = [{ participant: 1, table: 1, siege: null, portee: 'tous', tour: null }]; + charge.titres = [ + { table: 1, siege: 1, libelle: 'animateur' }, + { table: 1, siege: 2, libelle: 'animateur' }, + ]; + const etat = etatPlaces(charge, { tour: 1 }); + const [table] = modeleDessin(etat, options(charge)).tables; + const textes = table.lignes.map((ligne) => joindre(textesLigne(ligne, t))); + assert.deepEqual(textes, [ + '1. (réservée, tous les tours) Iris Ombrelle \u{2014} animateur', + '2. (libre) \u{2014} titre animateur non pourvu', + '3. (libre)', + '4. (libre)', + ]); + }); +}); + +describe('libelleLongueur : la longueur de la barre en mots (§ 7.7)', () => { + test('cm sous le mètre, m sous le kilomètre, km au-delà ; décimales d\u{2019}une longueur sous l\u{2019}unité', () => { + assert.deepEqual(libelleLongueur(50), { valeur: 50, decimales: 0, cle: 'plan.echelle.centimetres' }); + assert.deepEqual(libelleLongueur(200), { valeur: 2, decimales: 0, cle: 'plan.echelle.metres' }); + assert.deepEqual(libelleLongueur(500000), { valeur: 5, decimales: 0, cle: 'plan.echelle.kilometres' }); + assert.deepEqual(libelleLongueur(0.5), { valeur: 0.5, decimales: 1, cle: 'plan.echelle.centimetres' }); + assert.deepEqual(libelleLongueur(0.02), { valeur: 0.02, decimales: 2, cle: 'plan.echelle.centimetres' }); + assert.deepEqual(libelleLongueur(0.1), { valeur: 0.1, decimales: 1, cle: 'plan.echelle.centimetres' }); + assert.deepEqual(libelleLongueur(0.01), { valeur: 0.01, decimales: 2, cle: 'plan.echelle.centimetres' }); + assert.deepEqual(libelleLongueur(1), { valeur: 1, decimales: 0, cle: 'plan.echelle.centimetres' }); + assert.ok(LARGEUR_ECHELLE_PX > 0); + }); +}); + +describe('formesTable : les formes d\u{2019}une table, en attributs SVG (§ 7.1, § 7.7)', () => { + test('ronde : disques du plateau et de l\u{2019}emprise ; carrée : carrés aux côtés parallèles aux axes', () => { + const ronde = geometrieTable({ id: 1, numero: 1, forme: 'ronde', position: { x: 100, y: -40 } }, 8); + const formes = formesTable(ronde); + assert.equal(formes.ronde, true); + assert.deepEqual(formes.plateau, { cx: 100, cy: -40, r: ronde.demiTaille }); + assert.deepEqual(formes.emprise, { cx: 100, cy: -40, r: ronde.rayonEmprise }); + const carree = geometrieTable({ id: 2, numero: 2, forme: 'carree', position: { x: 0, y: 0 } }, 12); + const deCarree = formesTable(carree); + assert.equal(deCarree.ronde, false); + assert.deepEqual(deCarree.plateau, { x: -90, y: -90, width: 180, height: 180 }); + assert.deepEqual(deCarree.emprise, { + x: -carree.rayonEmprise, + y: -carree.rayonEmprise, + width: 2 * carree.rayonEmprise, + height: 2 * carree.rayonEmprise, + }); + }); + + test('la pastille au centre du plateau ; la marque de gel dans le plateau, au-dessus de la pastille', () => { + for (const places of [2, 8, 16]) { + const geometrie = geometrieTable({ id: 1, numero: 1, forme: 'ronde', position: { x: 10, y: 20 } }, places); + const { pastille, gel } = formesTable(geometrie); + assert.deepEqual(pastille, { x: 10, y: 20 }); + assert.equal(gel.x, 10); + assert.ok(gel.y < 20 && gel.y > 20 - geometrie.demiTaille, `${places} places : ${gel.y}`); + } + }); +}); + +describe('partiesLigne : les tspans d\u{2019}une ligne, dans l\u{2019}ordre', () => { + test('chaque partie présente, nommée ; les absentes omises', () => { + const ligne = { tete: '1. ', titulaire: null, suite: null, nom: 'Iris Ombrelle', avantTitre: ' \u{2014} ', titre: 'animateur', apresTitre: null }; + assert.deepEqual(partiesLigne(ligne), [ + { partie: 'tete', texte: '1. ' }, + { partie: 'nom', texte: 'Iris Ombrelle' }, + { partie: 'avantTitre', texte: ' \u{2014} ' }, + { partie: 'titre', texte: 'animateur' }, + ]); + assert.deepEqual(partiesLigne({ tete: '5. (libre)', titulaire: null, suite: null, nom: null, avantTitre: null, titre: null, apresTitre: null }), [ + { partie: 'tete', texte: '5. (libre)' }, + ]); + }); +}); diff --git a/src/interface/tables/Defaut.svelte b/src/interface/tables/Defaut.svelte new file mode 100644 index 0000000..0eaeb95 --- /dev/null +++ b/src/interface/tables/Defaut.svelte @@ -0,0 +1,269 @@ + + + + + +{#snippet refusDeTable(entree)} + {@const action = actionRemede(entree.details.refus.details.remede ?? null, { seance, ui })} +
+

{t(entree.cle, entree.details)}

+ {#if action !== null} + + {/if} +
+{/snippet} + +
+ {#key rangChamp} + + {/key} + + {#if enAttente !== null} +
+ {#if refuse} + {#each entrees as entree, rang (rang)} + {#if rang === 0} +

{t(entree.cle, entree.details)}

+ {:else} + {@render refusDeTable(entree)} + {/if} + {/each} + {:else} +

+ {#each entrees as entree, rang (rang)}{#if rang > 0}{' '}{/if}{t(entree.cle, entree.details)}{/each} +

+ {/if} +
+ {#if !refuse} + appliquer(false)} /> + {#if bilan.surchargees.length > 0} + + {/if} + {/if} + +
+ {#if refusRealigne.length > 0} +
+ {#each refusRealigne as entree, rang (rang)} + {#if rang === 0} +

{t(entree.cle, entree.details)}

+ {:else} + {@render refusDeTable(entree)} + {/if} + {/each} +
+ {/if} +
+ {/if} + + {#each avertissements as avertissement, rang (rang)} +
+ {#if avertissement.numero !== null} +

{t('tables.liste.table')} {avertissement.numero}

+ {/if} +

{t(avertissement.code, avertissement.details)}

+ {#if avertissement.noms.length > 0} +
    + {#each avertissement.noms as nom, rangNom (rangNom)} +
  • {nom}
  • + {/each} +
+ {/if} +
+ {/each} + + {#if fenetreRealigner !== null} + (fenetreRealigner = null)} + /> + {/if} +
+ + diff --git a/src/interface/tables/ListeTables.svelte b/src/interface/tables/ListeTables.svelte new file mode 100644 index 0000000..89e949d --- /dev/null +++ b/src/interface/tables/ListeTables.svelte @@ -0,0 +1,266 @@ + + + + +
+

{t('tables.liste.titre')}

+
+ seance.executer('creerTable', { forme: 'ronde' })} /> + seance.executer('creerTable', { forme: 'carree' })} /> +
+ + {#if tables.length === 0} +

{t('tables.liste.aucune')}

+ {:else} +
    + {#each tables as table (table.id)} + {@const etat = etatTable(table, charge.evenement.siegesParDefaut)} + {@const complement = complementDe(table.id)} +
  • +
    + {t('tables.liste.table')} {table.numero} + {t(cleForme(table.forme))} + {t(etat.cle)} +
    +
    + executerSurTable(table.id, 'changerPlacesTable', { id: table.id, sieges, origine: 'champ' })} + /> +
    + {#if complement !== null && complement.retires.length > 0} +
      + {#each complement.retires as retire, rang (rang)} +
    • {t(retire.cle, retire.details)}{#if retire.nom !== null}{' '}{retire.nom}{/if}
    • + {/each} +
    + {/if} + {#if complement !== null} + {#each complement.avertissements as avertissement, rang (rang)} +
    +

    {t(avertissement.code, avertissement.details)}

    + {#if avertissement.noms.length > 0} +
      + {#each avertissement.noms as nom, rangNom (rangNom)} +
    • {nom}
    • + {/each} +
    + {/if} +
    + {/each} + {/if} +
    + {#if etat.realignable} + executerSurTable(table.id, 'realignerTable', { id: table.id })} + /> + {/if} + supprimer(table)} /> +
    +
  • + {/each} +
+ {/if} + + {#if suppression !== null} + (suppression = null)} + /> + {/if} +
+ + diff --git a/src/interface/tables/SectionTables.svelte b/src/interface/tables/SectionTables.svelte index 83a09a1..7021175 100644 --- a/src/interface/tables/SectionTables.svelte +++ b/src/interface/tables/SectionTables.svelte @@ -2,12 +2,47 @@ License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -->

{t('coquille.section.tables')}

+ {#if $seance.charge !== null} +
+ + +
+ + {/if}
+ + diff --git a/src/interface/tables/Tables.navigateur.test.js b/src/interface/tables/Tables.navigateur.test.js new file mode 100644 index 0000000..3daee75 --- /dev/null +++ b/src/interface/tables/Tables.navigateur.test.js @@ -0,0 +1,672 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Épreuves de la section des tables et des tours dans un vrai moteur de +// rendu (§ 4, § 4.3, § 6, § 2.1 étapes 4 et 5, § 19.3 captures 05 et 06). +// La section est montée par mount avec les contextes que pose App — +// 'seance', 't', 'horloge', 'ui' —, sur une séance qui ouvre un fichier +// d'état d'épreuve dans le système de fichiers simulé, à l'horloge pilotée : +// chaque écriture se compte, et chaque entrée se lit dans la révision. Les +// saisies et les clics passent par des entrées que Chromium traite comme +// réelles (test/navigateur/entrees.js). L'épreuve lit le texte rendu, les +// attributs data-… du contrat et la charge que la séance a écrite. Les noms +// d'épreuve sont inventés. +// +// Le projet navigateur emploie expect, et test et describe de Vitest +// (§ 14.8). +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { flushSync, mount, tick, unmount } from 'svelte'; +import '../jetons.css'; +import { controlerCapacite } from '../../application/capacite.js'; +import { creerHorlogePilotee } from '../../application/horloge.js'; +import { TABLES_APPLICATION } from '../../application/libelles.js'; +import { nomAffiche } from '../../application/personnes.js'; +import { creerSeance } from '../../application/seance.js'; +import { creerTraduction } from '../../application/traduction.js'; +import { constante } from '../../geometrie/constantes.js'; +import { nombrePlaces, normaliser } from '../../moteur/configuration.js'; +import { serialiser } from '../../stockage/canonique.js'; +import { capacite, configurationDepuisCharge, creerCharge } from '../../stockage/document.js'; +import { SUFFIXES } from '../../stockage/noms.js'; +import { VERSION } from '../../version.genere.js'; +import { creerFichiersSimules } from '../../../test/fichiers_simules.js'; +import { pointer, touche } from '../../../test/navigateur/entrees.js'; +import { visible } from '../../../test/navigateur/rendu.js'; +import { creerEtatUi } from '../etat_ui.js'; +import { TABLES_INTERFACE } from '../libelles.js'; +import SectionTables from './SectionTables.svelte'; + +const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]); + +const BASE = 'soiree'; + +const NOMS = [ + ['Ombrelle', 'Iris'], + ['Pervenche', 'Théo'], + ['Lacasse', 'Ondine'], + ['Grisaille', null], + ['Bruyère', 'Anouk'], + ['Sarcelle', 'Capucine'], +]; + +const personne = (id, exclu = false) => ({ + id, + nom: NOMS[(id - 1) % NOMS.length][0], + prenom: NOMS[(id - 1) % NOMS.length][1], + appartenance: null, + courriel: null, + titrePressenti: null, + notes: null, + exclu, +}); + +const resa = (participant, table, siege, tour = null) => ({ participant, table, siege, portee: tour === null ? 'tous' : 'tour', tour }); +const suite = (de, a) => Array.from({ length: a - de + 1 }, (_, rang) => de + rang); + +// Une charge : les tables données ({id, numero, sieges, forme}), n +// personnes dont les exclus, R tours, un défaut. +function chargeDe({ tables = [], n = 0, exclus = [], tours = 2, defaut = 8, attribuerSieges = false, reservations = [], titres = [] } = {}) { + const charge = creerCharge({ id: 'evt-tables', nom: 'Soirée des Merles', siegesParDefaut: defaut, tours }); + charge.reglages.attribuerSieges = attribuerSieges; + charge.participants = Array.from({ length: n }, (_, rang) => personne(rang + 1, exclus.includes(rang + 1))); + charge.tables = tables.map(({ id, numero = id, sieges = null, forme = 'ronde' }) => ({ id, numero, sieges, forme, position: { x: 300 * id, y: 0 } })); + charge.reservations = reservations; + charge.titres = titres; + charge.prochainsIds = { participant: n + 1, table: Math.max(0, ...tables.map(({ id }) => id)) + 1, proposition: 1 }; + return charge; +} + +// Ajoute la proposition id, ses listes dans l'ordre de charge.tables, et la +// retient quand retenu est vrai. +function placer(charge, listes, { id, retenu = false }) { + const participants = charge.participants.filter(({ exclu }) => !exclu).map(({ id: p }) => p); + const plan = { + siegesAttribues: charge.reglages.attribuerSieges, + tables: charge.tables.map(({ id: table }) => table), + capacites: charge.tables.map((table) => capacite(charge, table)), + tours: listes.length, + participants, + placement: listes.map((tour) => ({ sieges: tour, reserve: participants.filter((p) => !tour.flat().includes(p)) })), + }; + charge.propositions.push({ id, graine: id, arret: 40, historique: 1000, produitVersion: VERSION.affichee, ...plan }); + charge.prochainsIds.proposition = id + 1; + if (retenu) { + charge.retenu = { proposition: id, ...structuredClone(plan) }; + charge.evenement.etat = 'retenu'; + } else if (charge.evenement.etat === 'brouillon') { + charge.evenement.etat = 'propose'; + } + return charge; +} + +// Treize tables : neuf suivent 8, les tables 3, 6, 9 et 12 sont surchargées +// à 7, 7, 6 et 12 ; 120 personnes, dont 2 exclues. +function treizeTables() { + const surcharges = new Map([[3, 7], [6, 7], [9, 6], [12, 12]]); + const tables = Array.from({ length: 13 }, (_, rang) => ({ id: rang + 1, sieges: surcharges.get(rang + 1) ?? null })); + return chargeDe({ tables, n: 120, exclus: [40, 80] }); +} + +// Les composants montés par l'épreuve courante, et leurs conteneurs. +const montes = []; + +afterEach(() => { + for (const { composant, cible } of montes.splice(0)) { + unmount(composant); + cible.remove(); + } + vi.restoreAllMocks(); +}); + +// Le fichier d'état de la charge posé dans le système simulé, ouvert par une +// séance, en écriture sauf mode 'lecture' ; la section montée au début du +// corps, avec les contextes de l'application. +async function preparer(charge, { mode = 'ecriture' } = {}) { + const fs = creerFichiersSimules(); + fs.deposerDossier('documents'); + fs.deposer('documents', `${BASE}${SUFFIXES.etat}`, serialiser(charge, { revision: 1, produitVersion: VERSION.affichee })); + const { documents } = await fs.racines(); + const horloge = creerHorlogePilotee(); + let tirages = 0; + const seance = creerSeance({ + fs, + racine: documents, + horloge, + identifiant: () => { + tirages += 1; + return `epreuve-${tirages}`; + }, + produitVersion: VERSION.affichee, + planifier: horloge.planifier, + annuler: horloge.annuler, + }); + await seance.ouvrir(BASE); + if (mode === 'ecriture') await seance.passerEnEcriture(); + const ui = creerEtatUi(); + const contexte = new Map([ + ['seance', seance], + ['t', t], + ['horloge', horloge], + ['ui', ui], + ]); + const cible = document.createElement('div'); + document.body.prepend(cible); + const composant = mount(SectionTables, { target: cible, props: {}, context: contexte }); + flushSync(); + montes.push({ composant, cible }); + return { fs, seance, ui, cible }; +} + +const section = (m) => m.cible.querySelector('[data-section="tables"]'); +const charge = (m) => m.seance.etat().charge; +const revision = (m) => m.seance.etat().evenement.revision; +const ecritures = (m) => m.fs.compteurs.ecritures; +const ligne = (m, id) => section(m).querySelector(`[data-table="${id}"]`); +const champTable = (m, id) => ligne(m, id).querySelector(`input[data-champ="sieges-${id}"]`); +const champ = (m, cle) => section(m).querySelector(`input[data-champ="${cle}"]`); +const commande = (racine, nom) => racine.querySelector(`[data-commande="${nom}"]`); +const etatDe = (m, id) => ligne(m, id).querySelector('[data-etat-table]').textContent; +const enonce = (m) => section(m).querySelector('[data-enonce="defaut"]'); +const phrases = (m) => [...enonce(m).querySelectorAll('[data-phrase]')].map((p) => p.textContent).join(' '); +const fenetre = () => document.querySelector('dialog[open]'); +const elementsFenetre = () => [...fenetre().querySelectorAll('li')].map((li) => li.textContent); + +// Attend que la file de la séance ait passé tout ce qui s'y trouve, par une +// lecture admise dans tout mode qui n'écrit rien, puis le rendu. +async function attendre(m) { + await m.seance.formeNommee(null).catch(() => undefined); + await tick(); + flushSync(); +} + +// Un clic réel au centre de l'élément, puis la file de la séance passée. +async function cliquer(m, element) { + expect(element, 'élément à cliquer').not.toBeNull(); + element.scrollIntoView({ block: 'center' }); + const boite = element.getBoundingClientRect(); + const x = boite.left + boite.width / 2; + const y = boite.top + boite.height / 2; + await pointer('presser', x, y); + await pointer('relacher', x, y); + await attendre(m); +} + +// Saisit texte dans le champ, la valeur courante remplacée, puis valide par +// Entrée : le geste s'achève, et la file de la séance passe. +async function saisir(m, entree, texte) { + await cliquer(m, entree); + entree.select(); + if (texte === '') await touche('Backspace'); + for (const caractere of texte) await touche(caractere); + await touche('Enter'); + await attendre(m); +} + +describe('une table créée (§ 4, § 6.1)', () => { + test('défaut 8 : la table créée suit le défaut, affichée 8 ; le défaut à 10, elle affiche 10 ; changer de tour affiché ne change ni la valeur ni le contrôle', async () => { + const m = await preparer(chargeDe({ n: 2 })); + await cliquer(m, commande(section(m), 'creerTableRonde')); + expect(charge(m).tables).toHaveLength(1); + const { id, sieges } = charge(m).tables[0]; + expect(sieges).toBeNull(); + expect(champTable(m, id).value).toBe('8'); + expect(etatDe(m, id)).toBe(t('tables.etat.suitDefaut')); + + await saisir(m, champ(m, 'siegesParDefaut'), '10'); + await cliquer(m, commande(enonce(m), 'appliquer')); + expect(charge(m).evenement.siegesParDefaut).toBe(10); + expect(charge(m).tables[0].sieges).toBeNull(); + expect(champTable(m, id).value).toBe('10'); + + const avant = { valeur: champTable(m, id).value, etat: etatDe(m, id), champ: champTable(m, id) }; + m.ui.montrerTour(2); + await attendre(m); + expect(champTable(m, id)).toBe(avant.champ); + expect(champTable(m, id).value).toBe(avant.valeur); + expect(etatDe(m, id)).toBe(avant.etat); + }); + + test('la forme : la création offre ronde et carrée ; carrée, puis ronde, une entrée chacune', async () => { + const m = await preparer(chargeDe({ n: 2 })); + expect(commande(section(m), 'creerTableRonde').textContent).toBe(t('tables.creer.ronde')); + expect(commande(section(m), 'creerTableCarree').textContent).toBe(t('tables.creer.carree')); + const r0 = revision(m); + await cliquer(m, commande(section(m), 'creerTableCarree')); + expect(charge(m).tables.map(({ forme }) => forme)).toEqual(['carree']); + expect(revision(m)).toBe(r0 + 1); + await cliquer(m, commande(section(m), 'creerTableRonde')); + expect(charge(m).tables.map(({ forme }) => forme)).toEqual(['carree', 'ronde']); + expect(revision(m)).toBe(r0 + 2); + expect(ligne(m, charge(m).tables[0].id).textContent).toContain(t('tables.forme.carree')); + expect(ligne(m, charge(m).tables[1].id).textContent).toContain(t('tables.forme.ronde')); + }); +}); + +describe('l’état, pas seulement la valeur (§ 6.1, § 19.3 05)', () => { + test('deux tables à 8, l’une suit le défaut, l’autre surchargée à 8 : les états diffèrent, seule la seconde offre « Réaligner » ; réalignée, son état égale celui de la première', async () => { + const m = await preparer(chargeDe({ tables: [{ id: 1 }, { id: 2, sieges: 8 }], n: 4 })); + expect(champTable(m, 1).value).toBe('8'); + expect(champTable(m, 2).value).toBe('8'); + expect(etatDe(m, 1)).not.toBe(etatDe(m, 2)); + expect(visible(ligne(m, 1).querySelector('[data-etat-table]'))).toBe(true); + expect(commande(ligne(m, 1), 'realignerTable')).toBeNull(); + expect(commande(ligne(m, 2), 'realignerTable').textContent).toBe(t('tables.liste.realigner')); + + const r0 = revision(m); + await cliquer(m, commande(ligne(m, 2), 'realignerTable')); + expect(revision(m)).toBe(r0 + 1); + expect(charge(m).tables[1].sieges).toBeNull(); + expect(etatDe(m, 2)).toBe(etatDe(m, 1)); + expect(champTable(m, 2).value).toBe(champTable(m, 1).value); + expect(commande(ligne(m, 2), 'realignerTable')).toBeNull(); + }); + + test('surcharge et réalignement : 6 sur une table qui suit 8, surchargée ; le défaut à 10 la laisse à 6 ; « Réaligner » la rend au défaut, une entrée', async () => { + const m = await preparer(chargeDe({ tables: [{ id: 1 }], n: 4 })); + const r0 = revision(m); + await saisir(m, champTable(m, 1), '6'); + expect(charge(m).tables[0].sieges).toBe(6); + expect(etatDe(m, 1)).toBe(t('tables.etat.surchargee')); + expect(revision(m)).toBe(r0 + 1); + + await saisir(m, champ(m, 'siegesParDefaut'), '10'); + await cliquer(m, commande(enonce(m), 'appliquer')); + expect(charge(m).evenement.siegesParDefaut).toBe(10); + expect(charge(m).tables[0].sieges).toBe(6); + expect(champTable(m, 1).value).toBe('6'); + + const r1 = revision(m); + await cliquer(m, commande(ligne(m, 1), 'realignerTable')); + expect(charge(m).tables[0].sieges).toBeNull(); + expect(champTable(m, 1).value).toBe('10'); + expect(etatDe(m, 1)).toBe(t('tables.etat.suitDefaut')); + expect(revision(m)).toBe(r1 + 1); + }); +}); + +describe('changer le défaut (§ 6.1, § 6.2, § 19.3 06)', () => { + test('treize tables, 120 participants dont 2 exclus ; 10 saisi : l’énoncé exact, zéro écriture avant le clic ; « Appliquer » : le total annoncé est celui de l’instance écrite, et 118 son N', async () => { + const m = await preparer(treizeTables()); + const avant = ecritures(m); + const r0 = revision(m); + await saisir(m, champ(m, 'siegesParDefaut'), '10'); + expect(visible(enonce(m))).toBe(true); + expect(phrases(m)).toBe( + '9 tables suivent le défaut et passent de 8 à 10 places ; 4 tables surchargées (7, 7, 6, 12) ne changent pas. ' + + 'Total : 104 \u{2192} 122 sièges pour 118 participants.', + ); + expect(ecritures(m)).toBe(avant); + expect(commande(enonce(m), 'appliquer').textContent).toBe(t('tables.defaut.appliquer')); + expect(commande(enonce(m), 'appliquerRealigner').textContent).toBe('Appliquer et réaligner les 4 surchargées'); + + await cliquer(m, commande(enonce(m), 'appliquer')); + expect(revision(m)).toBe(r0 + 1); + const instance = normaliser(configurationDepuisCharge(charge(m))); + expect(nombrePlaces(instance)).toBe(122); + expect(instance.N).toBe(118); + expect(enonce(m)).toBeNull(); + }); + + test('appliquer et réaligner : la fenêtre nomme les surcharges perdues ; Annuler n’écrit rien ; Confirmer : les treize tables suivent 10, une entrée', async () => { + const m = await preparer(treizeTables()); + await saisir(m, champ(m, 'siegesParDefaut'), '10'); + const avant = ecritures(m); + const r0 = revision(m); + await cliquer(m, commande(enonce(m), 'appliquerRealigner')); + expect(fenetre()).not.toBeNull(); + expect(elementsFenetre()).toEqual(['Table 3 : 7 places', 'Table 6 : 7 places', 'Table 9 : 6 places', 'Table 12 : 12 places']); + await cliquer(m, commande(fenetre(), 'renoncer')); + expect(fenetre()).toBeNull(); + expect(ecritures(m)).toBe(avant); + + await cliquer(m, commande(enonce(m), 'appliquerRealigner')); + await cliquer(m, commande(fenetre(), 'confirmer')); + await attendre(m); + expect(fenetre()).toBeNull(); + expect(revision(m)).toBe(r0 + 1); + expect(charge(m).evenement.siegesParDefaut).toBe(10); + expect(charge(m).tables).toHaveLength(13); + expect(charge(m).tables.every(({ sieges }) => sieges === null)).toBe(true); + }); + + test('baisse refusée en bloc : la table 2 porte 7 réservations ; 6 saisi : l’énoncé nomme la table 2 et ses 7 réservations, aucune commande d’application, rien n’est écrit', async () => { + const reservations = suite(1, 7).map((p) => resa(p, 2, null)); + const m = await preparer(chargeDe({ tables: [{ id: 1 }, { id: 2 }, { id: 3 }], n: 10, reservations })); + const avant = ecritures(m); + await saisir(m, champ(m, 'siegesParDefaut'), '6'); + const texte = enonce(m).textContent; + expect(texte).toContain('Table 2'); + expect(texte).toContain('7 réservations'); + expect(commande(enonce(m), 'appliquer')).toBeNull(); + expect(commande(enonce(m), 'appliquerRealigner')).toBeNull(); + expect(charge(m).evenement.siegesParDefaut).toBe(8); + expect([1, 2, 3].map((id) => champTable(m, id).value)).toEqual(['8', '8', '8']); + expect(ecritures(m)).toBe(avant); + }); + + test('réaligner refusé : la table 2 surchargée à 12 porte 11 réservations ; 10 saisi, « Appliquer et réaligner » : aucune fenêtre, le refus nomme la table 2, rien n’est écrit', async () => { + const reservations = suite(1, 11).map((p) => resa(p, 2, null)); + const m = await preparer(chargeDe({ tables: [{ id: 1 }, { id: 2, sieges: 12 }], n: 12, reservations })); + await saisir(m, champ(m, 'siegesParDefaut'), '10'); + expect(enonce(m).textContent).not.toContain('Table 2'); + const avant = ecritures(m); + await cliquer(m, commande(enonce(m), 'appliquerRealigner')); + expect(fenetre()).toBeNull(); + const refus = enonce(m).querySelector('[data-refus-realigner]'); + expect(refus).not.toBeNull(); + expect(refus.textContent).toContain('Table 2'); + expect(refus.textContent).toContain('11 réservations'); + expect(ecritures(m)).toBe(avant); + expect(charge(m).evenement.siegesParDefaut).toBe(8); + expect(charge(m).tables.find(({ id }) => id === 2).sieges).toBe(12); + expect(commande(enonce(m), 'appliquer')).not.toBeNull(); + }); + + test('« Garder le défaut actuel » : l’énoncé se retire, le champ rend la valeur enregistrée, rien n’est écrit', async () => { + const m = await preparer(treizeTables()); + const avant = ecritures(m); + await saisir(m, champ(m, 'siegesParDefaut'), '10'); + await cliquer(m, commande(enonce(m), 'garder')); + expect(enonce(m)).toBeNull(); + expect(champ(m, 'siegesParDefaut').value).toBe('8'); + expect(ecritures(m)).toBe(avant); + }); + + test('moins de 2 places par défaut : refusé sur le champ, sans énoncé ni écriture', async () => { + const m = await preparer(chargeDe({ tables: [{ id: 1 }], n: 4 })); + const avant = ecritures(m); + await saisir(m, champ(m, 'siegesParDefaut'), '1'); + const bloc = champ(m, 'siegesParDefaut').closest('[data-defaut]'); + expect(bloc.querySelector('[data-refus="MOINS_DE_DEUX"]').textContent).toContain(t('MOINS_DE_DEUX')); + expect(enonce(m)).toBeNull(); + expect(ecritures(m)).toBe(avant); + }); +}); + +describe('la capacité d’une table, saisie au champ (§ 6.2)', () => { + test('1, 0, -3, 2.5, « 8abc », « huit » et vide : refusés sur le champ, leur cause, aucun remède, rien d’écrit ; 2 accepté', async () => { + const m = await preparer(chargeDe({ tables: [{ id: 1 }], n: 4 })); + const avant = ecritures(m); + const saisies = [ + ['1', 'MOINS_DE_DEUX'], + ['0', 'MOINS_DE_DEUX'], + ['-3', 'NON_ENTIER'], + ['2.5', 'NON_ENTIER'], + ['8abc', 'NON_ENTIER'], + ['huit', 'NON_ENTIER'], + ['', 'NON_ENTIER'], + ]; + expect(saisies).toHaveLength(7); + for (const [saisie, code] of saisies) { + await saisir(m, champTable(m, 1), saisie); + const refus = ligne(m, 1).querySelector(`[data-refus="${code}"]`); + expect(refus, `refus de « ${saisie} »`).not.toBeNull(); + const details = code === 'NON_ENTIER' ? { saisie } : {}; + expect(refus.querySelector('p').textContent).toBe(t(code, details)); + expect(refus.querySelector('[data-commande]')).toBeNull(); + expect(ecritures(m)).toBe(avant); + expect(charge(m).tables[0].sieges).toBeNull(); + } + await saisir(m, champTable(m, 1), '2'); + expect(charge(m).tables[0].sieges).toBe(2); + expect(ligne(m, 1).querySelector('[data-refus]')).toBeNull(); + }); + + test('sous les réservations : 5 réservations « tous » ; 4 refusé contre le champ, qui nomme 5, son remède offert ; 5 accepté, une entrée', async () => { + const reservations = suite(1, 5).map((p) => resa(p, 1, null)); + const m = await preparer(chargeDe({ tables: [{ id: 1 }], n: 6, reservations })); + const avant = ecritures(m); + await saisir(m, champTable(m, 1), '4'); + const refus = ligne(m, 1).querySelector('[data-refus="SOUS_RESERVATIONS"]'); + expect(refus.textContent).toContain('5 réservations'); + expect(commande(refus, 'afficherTour').textContent).toBe(t('communs.remede.afficherTour', { tour: 1 })); + expect(ecritures(m)).toBe(avant); + const r0 = revision(m); + await saisir(m, champTable(m, 1), '5'); + expect(charge(m).tables[0].sieges).toBe(5); + expect(revision(m)).toBe(r0 + 1); + }); + + test('sièges retirés : sièges attribués, Iris Ombrelle au siège 7, un titre « animateur » au siège 8 ; 6 liste le siège 8 puis le siège 7 et sa personne, rien d’écrit ; 7, le siège 8 seul', async () => { + const m = await preparer( + chargeDe({ + tables: [{ id: 1 }], + n: 4, + attribuerSieges: true, + reservations: [resa(1, 1, 7)], + titres: [{ table: 1, siege: 8, libelle: 'animateur' }], + }), + ); + const avant = ecritures(m); + await saisir(m, champTable(m, 1), '6'); + expect(ligne(m, 1).querySelector('[data-refus="SIEGES_RETIRES"]')).not.toBeNull(); + const retires = () => [...ligne(m, 1).querySelectorAll('[data-sieges-retires] li')].map((li) => li.textContent); + expect(retires()).toEqual(['Siège 8 : titre « animateur »', 'Siège 7 : Iris Ombrelle']); + const texte = ligne(m, 1).textContent; + expect(texte.indexOf('animateur')).toBeLessThan(texte.indexOf('Iris Ombrelle')); + expect(ecritures(m)).toBe(avant); + + await saisir(m, champTable(m, 1), '7'); + expect(retires()).toEqual(['Siège 8 : titre « animateur »']); + expect(ligne(m, 1).textContent).not.toContain('Iris Ombrelle'); + expect(ecritures(m)).toBe(avant); + }); + + test('surnuméraires et lisibilité : un retenu assied huit personnes à la table 3 au tour 2 ; 6 accepté, l’avertissement nomme les deux personnes versées en réserve ; S + 1 avertit de S ; S, non', async () => { + const donnees = chargeDe({ tables: [{ id: 1 }, { id: 2 }, { id: 3 }], n: 18 }); + placer( + donnees, + [ + [suite(1, 6), suite(7, 12), suite(13, 18)], + [suite(1, 5), suite(6, 10), suite(11, 18)], + ], + { id: 1, retenu: true }, + ); + const { avertissements } = controlerCapacite(donnees, 3, 6, { origine: 'champ' }); + expect(avertissements).toHaveLength(1); + const noms = avertissements[0].details.personnes.map((id) => nomAffiche(donnees.participants[id - 1])); + expect(noms).toHaveLength(2); + + const m = await preparer(donnees); + const r0 = revision(m); + await saisir(m, champTable(m, 3), '6'); + expect(charge(m).tables[2].sieges).toBe(6); + expect(revision(m)).toBe(r0 + 1); + const surnumeraires = ligne(m, 3).querySelector('[data-avertissement="SURNUMERAIRES"]'); + expect(surnumeraires.textContent).toContain(t('SURNUMERAIRES', avertissements[0].details)); + expect([...surnumeraires.querySelectorAll('li')].map((li) => li.textContent)).toEqual(noms); + + const seuil = constante('SEUIL_SIEGES_LISIBLES'); + await saisir(m, champTable(m, 1), String(seuil + 1)); + expect(charge(m).tables[0].sieges).toBe(seuil + 1); + const lisible = ligne(m, 1).querySelector('[data-avertissement="AU_DELA_LISIBLE"]'); + expect(lisible.textContent).toContain(String(seuil)); + expect(lisible.querySelector('p').textContent).toBe(t('AU_DELA_LISIBLE', { seuil })); + + await saisir(m, champTable(m, 1), String(seuil)); + expect(charge(m).tables[0].sieges).toBe(seuil); + expect(ligne(m, 1).querySelector('[data-avertissement="AU_DELA_LISIBLE"]')).toBeNull(); + }); +}); + +describe('les tours (§ 4, § 4.3, § 2.1 étape 5)', () => { + test('0, 2.5 et « x » refusés sur le champ, leur cause, sans fenêtre, rien d’écrit ; 1 accepté', async () => { + // La réservation du tour 2 : un changement qui la détruirait ouvre la + // fenêtre, que la saisie refusée n'ouvre pas. + const m = await preparer(chargeDe({ tables: [{ id: 1 }], n: 4, reservations: [resa(1, 1, null, 2)] })); + const avant = ecritures(m); + const saisies = [ + ['0', 'MOINS_D_UN_TOUR', { tours: 0 }], + ['2.5', 'NON_ENTIER', { saisie: '2.5' }], + ['x', 'NON_ENTIER', { saisie: 'x' }], + ]; + expect(saisies).toHaveLength(3); + for (const [saisie, code, details] of saisies) { + await saisir(m, champ(m, 'tours'), saisie); + const refus = champ(m, 'tours').closest('[data-tours]').querySelector(`[data-refus="${code}"]`); + expect(refus, `refus de « ${saisie} »`).not.toBeNull(); + expect(refus.querySelector('p').textContent).toBe(t(code, details)); + expect(refus.querySelector('[data-commande]')).toBeNull(); + expect(fenetre()).toBeNull(); + expect(ecritures(m)).toBe(avant); + } + const r0 = revision(m); + await saisir(m, champ(m, 'tours'), '1'); + expect(ecritures(m)).toBe(avant); + await cliquer(m, commande(fenetre(), 'confirmer')); + await attendre(m); + expect(charge(m).evenement.tours).toBe(1); + expect(charge(m).reservations).toEqual([]); + expect(revision(m)).toBe(r0 + 1); + }); + + test('aucune perte : le nombre de tours change sans fenêtre, une entrée', async () => { + const m = await preparer(chargeDe({ tables: [{ id: 1 }], n: 4, reservations: [resa(1, 1, null)] })); + const r0 = revision(m); + await saisir(m, champ(m, 'tours'), '5'); + expect(fenetre()).toBeNull(); + expect(charge(m).evenement.tours).toBe(5); + expect(revision(m)).toBe(r0 + 1); + }); + + test('R = 4, deux réservations au tour 4, une au tour 2, deux propositions ; 3 : la fenêtre les nomme, rien d’écrit avant ; Confirmer : les deux retirées, celle du tour 2 gardée, une entrée, le fichier se rouvre', async () => { + const donnees = chargeDe({ + tables: [{ id: 1 }, { id: 2 }], + n: 8, + tours: 4, + reservations: [resa(1, 2, null, 4), resa(2, 1, null, 4), resa(3, 1, null, 2)], + }); + // Chaque proposition honore les réservations : Iris Ombrelle à la table + // 2 et Théo Pervenche à la table 1 au tour 4, Ondine Lacasse à la table + // 1 au tour 2. + const listes = [[suite(1, 4), suite(5, 8)], [suite(1, 4), suite(5, 8)], [suite(1, 4), suite(5, 8)], [suite(2, 5), [1, 6, 7, 8]]]; + placer(donnees, listes, { id: 1 }); + placer(donnees, listes, { id: 2 }); + const m = await preparer(donnees); + const avant = ecritures(m); + const r0 = revision(m); + await saisir(m, champ(m, 'tours'), '3'); + expect(fenetre()).not.toBeNull(); + expect(ecritures(m)).toBe(avant); + const elements = elementsFenetre(); + expect(elements).toHaveLength(4); + expect(elements[0]).toContain('Iris Ombrelle'); + expect(elements[0]).toContain('table 2'); + expect(elements[1]).toContain('Théo Pervenche'); + expect(elements[1]).toContain('table 1'); + expect(elements.slice(2)).toEqual([t('tables.touchee.proposition', { id: 1 }), t('tables.touchee.proposition', { id: 2 })]); + + await cliquer(m, commande(fenetre(), 'confirmer')); + await attendre(m); + expect(fenetre()).toBeNull(); + expect(revision(m)).toBe(r0 + 1); + expect(charge(m).evenement.tours).toBe(3); + expect(charge(m).reservations).toEqual([resa(3, 1, null, 2)]); + + await m.seance.fermer(); + await m.seance.ouvrir(BASE); + expect(m.seance.etat().charge.evenement.tours).toBe(3); + expect(m.seance.etat().charge.reservations).toEqual([resa(3, 1, null, 2)]); + }); + + test('une hausse avec une proposition : sans fenêtre, une entrée, la dérive avertie par la séance', async () => { + const donnees = chargeDe({ tables: [{ id: 1 }], n: 4, tours: 2 }); + placer(donnees, [[suite(1, 4)], [suite(1, 4)]], { id: 1 }); + const m = await preparer(donnees); + const r0 = revision(m); + await saisir(m, champ(m, 'tours'), '3'); + expect(fenetre()).toBeNull(); + expect(revision(m)).toBe(r0 + 1); + expect(charge(m).evenement.tours).toBe(3); + expect(charge(m).propositions).toHaveLength(1); + expect(m.seance.etat().avertissements.map(({ code }) => code)).toContain('DERIVE_NOUVELLE'); + }); + + test('une baisse avec une proposition et sans réservation : la fenêtre nomme la proposition, rien d’écrit avant', async () => { + const donnees = chargeDe({ tables: [{ id: 1 }], n: 4, tours: 3 }); + placer(donnees, [[suite(1, 4)], [suite(1, 4)], [suite(1, 4)]], { id: 1 }); + const m = await preparer(donnees); + const avant = ecritures(m); + await saisir(m, champ(m, 'tours'), '2'); + expect(fenetre()).not.toBeNull(); + expect(ecritures(m)).toBe(avant); + expect(elementsFenetre()).toEqual([t('tables.touchee.proposition', { id: 1 })]); + }); + + test('Annuler la fenêtre des tours : rien n’est écrit, et le champ rend la valeur enregistrée', async () => { + const donnees = chargeDe({ tables: [{ id: 1 }], n: 4, tours: 4, reservations: [resa(1, 1, null, 4)] }); + const m = await preparer(donnees); + const avant = ecritures(m); + await saisir(m, champ(m, 'tours'), '3'); + await cliquer(m, commande(fenetre(), 'renoncer')); + expect(fenetre()).toBeNull(); + expect(ecritures(m)).toBe(avant); + expect(champ(m, 'tours').value).toBe('4'); + expect(charge(m).reservations).toHaveLength(1); + }); +}); + +describe('supprimer une table (§ 6.2)', () => { + test('une table qui porte une réservation « tous » d’Iris Ombrelle et un titre « animateur » : la fenêtre les nomme ; une table nue : aucune fenêtre, une entrée', async () => { + const m = await preparer( + chargeDe({ + tables: [{ id: 1 }, { id: 2 }], + n: 4, + reservations: [resa(1, 1, null)], + titres: [{ table: 1, siege: 1, libelle: 'animateur' }], + }), + ); + const avant = ecritures(m); + const r0 = revision(m); + await cliquer(m, commande(ligne(m, 1), 'supprimerTable')); + expect(fenetre()).not.toBeNull(); + expect(ecritures(m)).toBe(avant); + const elements = elementsFenetre(); + expect(elements).toHaveLength(2); + expect(elements[0]).toContain('Iris Ombrelle'); + expect(elements[1]).toContain('« animateur »'); + await cliquer(m, commande(fenetre(), 'confirmer')); + await attendre(m); + expect(fenetre()).toBeNull(); + expect(charge(m).tables.map(({ id }) => id)).toEqual([2]); + expect(charge(m).reservations).toEqual([]); + expect(charge(m).titres).toEqual([]); + expect(revision(m)).toBe(r0 + 1); + + await cliquer(m, commande(ligne(m, 2), 'supprimerTable')); + expect(fenetre()).toBeNull(); + expect(charge(m).tables).toEqual([]); + expect(revision(m)).toBe(r0 + 2); + expect(section(m).textContent).toContain(t('tables.liste.aucune')); + }); +}); + +describe('en lecture (§ 8.4)', () => { + test('créer une table, surcharger, changer le défaut ou les tours : refusés contre la commande ou le champ, le message porte « Modifier », rien d’écrit', async () => { + const m = await preparer(chargeDe({ tables: [{ id: 1 }], n: 4 }), { mode: 'lecture' }); + const avant = ecritures(m); + const modifier = t('communs.remede.modifier'); + + await cliquer(m, commande(section(m), 'creerTableRonde')); + const pres = commande(section(m), 'creerTableRonde').parentElement; + expect(pres.querySelector('[data-refus="MODE_LECTURE"]').textContent).toContain(modifier); + + const porteurs = [ + [champTable(m, 1), (e) => e.closest('[data-table]')], + [champ(m, 'siegesParDefaut'), (e) => e.closest('[data-defaut]')], + [champ(m, 'tours'), (e) => e.closest('[data-tours]')], + ]; + expect(porteurs).toHaveLength(3); + for (const [entree, porteur] of porteurs) { + await cliquer(m, entree); + await touche('5'); + await attendre(m); + const refus = porteur(entree).querySelector('[data-refus="MODE_LECTURE"]'); + expect(refus, entree.dataset.champ).not.toBeNull(); + expect(refus.textContent).toContain(modifier); + expect(commande(refus, 'modifier').textContent).toBe(modifier); + } + expect(charge(m).tables).toHaveLength(1); + expect(ecritures(m)).toBe(avant); + }); +}); diff --git a/src/interface/tables/Tours.svelte b/src/interface/tables/Tours.svelte new file mode 100644 index 0000000..b58d6c4 --- /dev/null +++ b/src/interface/tables/Tours.svelte @@ -0,0 +1,78 @@ + + + + +
+ {#key rangChamp} + + {/key} + {#if fenetre !== null} + + {/if} +
+ + diff --git a/src/interface/tables/modele.js b/src/interface/tables/modele.js new file mode 100644 index 0000000..580e481 --- /dev/null +++ b/src/interface/tables/modele.js @@ -0,0 +1,267 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Le modèle de la section des tables et des tours (§ 4, § 6, § 2.1 étapes 4 +// et 5), pur : les composants de tables/ le câblent. Il lit la charge et les +// bilans d'A4 — bilanChangementDefaut, bilanTours, bilanSuppressionTable — +// et rend des clés de libellé et leurs détails, jamais un texte : la +// traduction les compose. Il ne refait aucun contrôle : un refus vient d'un +// bilan ou de la commande elle-même, essayée sur la charge sans rien écrire. +// Aucune fonction ne modifie ce qu'elle reçoit. +import { appliquerCommande } from '../../application/commandes.js'; +import { nomAffiche } from '../../application/personnes.js'; + +const parNumero = (a, b) => a.numero - b.numero || a.id - b.id; + +// Le numéro affiché de la table d'identifiant id parmi tables ; une table +// inconnue est une faute de l'appelant. +function numeroDe(tables, id) { + const table = tables.find((candidate) => candidate.id === id); + if (table === undefined) throw new TypeError(`table inconnue : ${JSON.stringify(id)}`); + return table.numero; +} + +// Le nom affiché du participant d'identifiant id ; une personne inconnue +// est une faute de l'appelant. +function nomDe(charge, id) { + const personne = charge.participants.find((candidate) => candidate.id === id); + if (personne === undefined) throw new TypeError(`participant inconnu : ${JSON.stringify(id)}`); + return nomAffiche(personne); +} + +// La clé du nom de chaque forme de table (§ 4). +const FORMES = new Map([ + ['ronde', 'tables.forme.ronde'], + ['carree', 'tables.forme.carree'], +]); + +/** La clé du nom d'une forme de table ; RangeError pour une forme inconnue. */ +export function cleForme(forme) { + if (!FORMES.has(forme)) throw new RangeError(`forme inconnue : ${JSON.stringify(forme)}`); + return FORMES.get(forme); +} + +/** Les tables de la charge par numéro affiché croissant, à identifiant égal du plus petit. */ +export function tablesParNumero(charge) { + return [...charge.tables].sort(parNumero); +} + +/** + * L'état d'une table en texte : « suit le défaut » ou « surchargée », et sa + * capacité ; realignable, vrai pour une table surchargée, offre + * « Réaligner » (§ 6.1). Une table surchargée à la valeur du défaut a la + * même capacité qu'une table qui le suit, et un autre état. + * + * @param {{sieges: number|null}} table + * @param {number} siegesParDefaut + * @returns {{cle: string, capacite: number, realignable: boolean}} + */ +export function etatTable(table, siegesParDefaut) { + const surchargee = table.sieges !== null; + return { + cle: surchargee ? 'tables.etat.surchargee' : 'tables.etat.suitDefaut', + capacite: surchargee ? table.sieges : siegesParDefaut, + realignable: surchargee, + }; +} + +/** + * L'énoncé du § 6.1, en clés et détails, depuis bilanChangementDefaut ; ses + * phrases se lisent séparées d'une espace. Accepté : la phrase + * d'application — les tables qui suivent le défaut et ce qu'elles + * deviennent, puis les surchargées, qui ne changent pas — et celle du total. + * Refusé : une phrase d'en-tête, puis chaque refus de bilan.refus, sa table + * nommée par son numéro (numero null pour un refus de l'événement entier), + * et aucune forme d'application. Avec realigner, le bilan est celui de + * bilanChangementDefaut(…, {realigner: true}) : l'en-tête d'un refus dit + * que le réalignement est refusé, le changement seul restant possible. + * + * @param {ReturnType} bilan + * @param {Array<{id: number, numero: number}>} [tables] celles de la charge, + * qui donnent son numéro à une table qui refuse + * @param {{realigner?: boolean}} [options] + * @returns {Array<{cle: string, details: Object}>} + * @throws {TypeError} une table de bilan.refus absente de tables + */ +export function enonceDefaut(bilan, tables = [], { realigner = false } = {}) { + const { avant, apres } = bilan; + if (bilan.refus.length > 0) { + return [ + { cle: realigner ? 'tables.defaut.realignementRefuse' : 'tables.defaut.refusee', details: { avant, apres } }, + ...bilan.refus.map(({ table, code, details }) => ({ + cle: 'tables.defaut.refusTable', + details: { numero: table === null ? null : numeroDe(tables, table), refus: { code, details } }, + })), + ]; + } + return [ + { cle: 'tables.defaut.application', details: { suivent: bilan.suivent, avant, apres, surchargees: bilan.surchargees } }, + { + cle: 'tables.defaut.total', + details: { totalAvant: bilan.totalAvant, totalApres: bilan.totalApres, participants: bilan.participants }, + }, + ]; +} + +/** + * Le refus qu'un nouveau défaut reçoit sur son champ : celui du bilan qui + * ne nomme aucune table — moins de 2 places refusent pour l'événement + * entier —, ou null. Les refus d'une table s'énoncent (enonceDefaut). + * + * @param {{refus: Array<{table: number|null, code: string, details: Object}>}} bilan + * @returns {{code: string, details: Object}|null} + */ +export function refusDuChampDefaut(bilan) { + const refus = bilan.refus.find(({ table }) => table === null); + return refus === undefined ? null : { code: refus.code, details: refus.details }; +} + +/** + * Ce qu'un réalignement de toutes les tables efface : la surcharge de + * chaque table surchargée, par numéro de table (§ 6.1). + * + * @param {Object} charge + * @returns {Array<{cle: string, details: {numero: number, sieges: number}}>} + */ +export function surchargesPerdues(charge) { + return tablesParNumero(charge) + .filter(({ sieges }) => sieges !== null) + .map(({ numero, sieges }) => ({ cle: 'tables.defaut.surchargePerdue', details: { numero, sieges } })); +} + +/** + * Vrai quand le changement de avant à apres tours détruit de la saisie : + * une réservation « tour désigné » au-delà, ou, pour une baisse, les tours + * retirés d'un placement engendré (§ 2.1 étape 5). Une hausse ne détruit + * rien : les placements qu'elle laisse en dérive restent entiers, et la + * séance en avertit (DERIVE_NOUVELLE) sans fenêtre (§ 5.9). + * + * @param {ReturnType} bilan + * @param {number} avant le nombre de tours enregistré + * @param {number} apres le nombre de tours demandé + * @returns {boolean} + */ +export const annonceTours = (bilan, avant, apres) => + bilan.reservationsDetruites.length > 0 || + (apres < avant && (bilan.propositionsTouchees.length > 0 || bilan.retenuTouche)); + +// Les placements qu'un changement laisse en dérive : chaque proposition, +// puis le retenu. +const touches = (bilan) => [ + ...bilan.propositionsTouchees.map((id) => ({ cle: 'tables.touchee.proposition', details: { id } })), + ...(bilan.retenuTouche ? [{ cle: 'tables.touchee.retenu', details: {} }] : []), +]; + +/** + * Ce que nomme la fenêtre d'un changement de tours (§ 2.1 étape 5) : chaque + * réservation détruite, sa personne et le numéro de sa table, dans l'ordre + * du bilan, puis les placements touchés. + * + * @param {Object} charge + * @param {ReturnType} bilan + * @returns {Array<{cle: string, details: Object}>} + */ +export function elementsTours(charge, bilan) { + return [ + ...bilan.reservationsDetruites.map(({ participant, table, tour }) => ({ + cle: 'tables.tours.reservation', + details: { nom: nomDe(charge, participant), numero: numeroDe(charge.tables, table), tour }, + })), + ...touches(bilan), + ]; +} + +/** Vrai quand supprimer la table détruit une réservation ou un titre. */ +export const annonceSuppression = (bilan) => bilan.reservations.length > 0 || bilan.titres.length > 0; + +/** + * Ce que nomme la fenêtre de la suppression d'une table (§ 6.2) : ses + * réservations, leur personne, leur portée et leur siège, puis ses titres, + * dans l'ordre du bilan ; puis les placements touchés. Rien pour une table + * nue, que la suppression ne demande pas de confirmer. + * + * @param {Object} charge + * @param {ReturnType} bilan + * @returns {Array<{cle: string, details: Object}>} + */ +export function elementsSuppression(charge, bilan) { + if (!annonceSuppression(bilan)) return []; + return [ + ...bilan.reservations.map(({ participant, portee, tour, siege }) => ({ + cle: 'tables.suppression.reservation', + details: { nom: nomDe(charge, participant), portee, tour, siege }, + })), + ...bilan.titres.map(({ siege, libelle }) => ({ cle: 'tables.suppression.titre', details: { siege, libelle } })), + ...touches(bilan), + ]; +} + +/** + * Les sièges qu'un refus SIEGES_RETIRES retire, nommés : chaque siège dans + * l'ordre du refus, son titre, ou le nom de la personne qui le réserve — + * le texte du refus désigne les personnes par identifiant. Sous des sièges + * non attribués, le refus compte ses titres sans les lister : ils ne le + * sont pas ici non plus. Rien pour un autre refus. + * + * @param {Object} charge + * @param {{code: string, details: Object}} refus + * @returns {Array<{cle: string, details: Object, nom: string|null}>} + */ +export function siegesRetiresNommes(charge, refus) { + if (refus.code !== 'SIEGES_RETIRES') return []; + const comptes = refus.details.titres !== undefined; + return refus.details.sieges + .filter(({ cause }) => !(comptes && cause === 'SIEGE_TITRE')) + .map((retire) => + retire.cause === 'SIEGE_TITRE' + ? { cle: 'tables.siegeRetire.titre', details: { siege: retire.siege, libelle: retire.libelle }, nom: null } + : { + cle: 'tables.siegeRetire.reserve', + details: { siege: retire.siege, suspendue: retire.suspendue }, + nom: nomDe(charge, retire.participant), + }, + ); +} + +/** + * Les avertissements d'un geste, nommés : numero, celui de la table qu'ils + * désignent — details.table, ou table pour un avertissement d'un bilan —, + * null sinon ; noms, les personnes de details.personnes dans leur ordre. + * + * @param {Object} charge + * @param {Array<{code: string, details: Object, table?: number}>} avertissements + * @returns {Array<{code: string, details: Object, numero: number|null, noms: string[]}>} + */ +export function avertissementsNommes(charge, avertissements) { + return avertissements.map(({ code, details, table }) => { + const id = details.table ?? table ?? null; + return { + code, + details, + numero: id === null ? null : numeroDe(charge.tables, id), + noms: (details.personnes ?? []).map((personne) => nomDe(charge, personne)), + }; + }); +} + +/** + * Le refus que la commande nom lèverait sur la charge, essayée sans rien + * écrire, ou null quand elle passe. Les fenêtres l'appellent avant de + * s'ouvrir : une saisie que la commande refuse reçoit son refus sur le + * champ, et aucune fenêtre n'annonce ce qui ne s'appliquera pas. + * + * @param {Object} charge + * @param {string} nom + * @param {Object} args + * @returns {{code: string, details: Object}|null} + * @throws {TypeError} une commande inconnue, un argument de forme fausse + */ +export function refusDeCommande(charge, nom, args) { + try { + appliquerCommande(nom, charge, args); + return null; + } catch (erreur) { + if (typeof erreur?.code !== 'string') throw erreur; + return { code: erreur.code, details: erreur.details }; + } +} diff --git a/src/interface/tables/modele.test.js b/src/interface/tables/modele.test.js new file mode 100644 index 0000000..16440a4 --- /dev/null +++ b/src/interface/tables/modele.test.js @@ -0,0 +1,332 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Épreuves du modèle de la section des tables (§ 4, § 6.1, § 6.2, § 2.1 +// étape 5) : l'état d'une table, l'énoncé d'un changement de défaut, et ce +// que les fenêtres et les compléments d'un refus ou d'un avertissement +// nomment. Les bilans viennent des fonctions d'A4 sur des charges +// d'épreuve ; les textes, de la traduction réelle. Les noms d'épreuve sont +// inventés. +import assert from 'node:assert/strict'; +import { describe, test } from '../../../test/lanceur.js'; +import { bilanChangementDefaut, bilanSuppressionTable, bilanTours } from '../../application/bilans.js'; +import { controlerCapacite } from '../../application/capacite.js'; +import { TABLES_APPLICATION } from '../../application/libelles.js'; +import { creerTraduction } from '../../application/traduction.js'; +import { creerCharge } from '../../stockage/document.js'; +import { TABLES_INTERFACE } from '../libelles.js'; +import { + annonceSuppression, + annonceTours, + avertissementsNommes, + cleForme, + elementsSuppression, + elementsTours, + enonceDefaut, + etatTable, + refusDeCommande, + refusDuChampDefaut, + siegesRetiresNommes, + surchargesPerdues, + tablesParNumero, +} from './modele.js'; + +const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]); + +const NOMS = [ + ['Ombrelle', 'Iris'], + ['Pervenche', 'Théo'], + ['Lacasse', 'Ondine'], + ['Grisaille', null], +]; + +const personne = (id, exclu = false) => ({ + id, + nom: NOMS[(id - 1) % NOMS.length][0], + prenom: NOMS[(id - 1) % NOMS.length][1], + appartenance: null, + courriel: null, + titrePressenti: null, + notes: null, + exclu, +}); + +const resa = (participant, table, siege, tour = null) => ({ participant, table, siege, portee: tour === null ? 'tous' : 'tour', tour }); + +// Une charge : les tables données ({id, numero, sieges}), n personnes dont +// les exclus, R tours, un défaut. +function chargeDe({ tables = [], n = 0, exclus = [], tours = 2, defaut = 8, attribuerSieges = false, reservations = [], titres = [] } = {}) { + const charge = creerCharge({ id: 'evt-tables', nom: 'Soirée des Merles', siegesParDefaut: defaut, tours }); + charge.reglages.attribuerSieges = attribuerSieges; + charge.participants = Array.from({ length: n }, (_, rang) => personne(rang + 1, exclus.includes(rang + 1))); + charge.tables = tables.map(({ id, numero = id, sieges = null }) => ({ id, numero, sieges, forme: 'ronde', position: { x: 300 * id, y: 0 } })); + charge.reservations = reservations; + charge.titres = titres; + charge.prochainsIds = { participant: n + 1, table: Math.max(0, ...tables.map(({ id }) => id)) + 1, proposition: 1 }; + return charge; +} + +// Treize tables : neuf suivent 8, les tables 3, 6, 9 et 12 sont surchargées +// à 7, 7, 6 et 12 ; 120 personnes, dont 2 exclues. +function treizeTables() { + const surcharges = new Map([[3, 7], [6, 7], [9, 6], [12, 12]]); + const tables = Array.from({ length: 13 }, (_, rang) => ({ id: rang + 1, sieges: surcharges.get(rang + 1) ?? null })); + return chargeDe({ tables, n: 120, exclus: [40, 80] }); +} + +// L'énoncé traduit, ses phrases séparées d'une espace, comme l'écran les pose. +const traduire = (entrees) => entrees.map(({ cle, details }) => t(cle, details)).join(' '); + +describe('etatTable : l’état, pas seulement la valeur (§ 6.1)', () => { + test('une table qui suit le défaut : sa capacité est le défaut, et rien à réaligner', () => { + assert.deepEqual(etatTable({ id: 1, numero: 1, sieges: null }, 8), { cle: 'tables.etat.suitDefaut', capacite: 8, realignable: false }); + assert.equal(t('tables.etat.suitDefaut'), 'Suit le défaut'); + }); + + test('une table surchargée à la valeur du défaut : même capacité, autre état, réalignable', () => { + const suit = etatTable({ id: 1, numero: 1, sieges: null }, 8); + const surchargee = etatTable({ id: 2, numero: 2, sieges: 8 }, 8); + assert.equal(surchargee.capacite, suit.capacite); + assert.notEqual(t(surchargee.cle), t(suit.cle)); + assert.deepEqual(surchargee, { cle: 'tables.etat.surchargee', capacite: 8, realignable: true }); + }); + + test('une surcharge garde sa valeur quand le défaut change', () => { + assert.equal(etatTable({ id: 1, numero: 1, sieges: 6 }, 10).capacite, 6); + assert.equal(etatTable({ id: 1, numero: 1, sieges: null }, 10).capacite, 10); + }); + + test('cleForme : une clé par forme, traduite ; une forme inconnue lève', () => { + assert.deepEqual(['ronde', 'carree'].map((forme) => t(cleForme(forme))), ['Ronde', 'Carrée']); + assert.throws(() => cleForme('ovale'), RangeError); + }); + + test('tablesParNumero : par numéro croissant, sans toucher la charge', () => { + const charge = chargeDe({ tables: [{ id: 1, numero: 3 }, { id: 2, numero: 1 }, { id: 3, numero: 2 }] }); + const avant = structuredClone(charge.tables); + const ordre = tablesParNumero(charge).map(({ numero }) => numero); + assert.equal(ordre.length, 3); + assert.deepEqual(ordre, [1, 2, 3]); + assert.deepEqual(charge.tables, avant); + }); +}); + +describe('enonceDefaut : la conséquence chiffrée avant l’application (§ 6.1)', () => { + test('le bilan du § 6.1, traduit, est exactement l’énoncé du spec', () => { + const charge = treizeTables(); + const entrees = enonceDefaut(bilanChangementDefaut(charge, 10), charge.tables); + assert.equal(entrees.length, 2); + assert.equal( + traduire(entrees), + '9 tables suivent le défaut et passent de 8 à 10 places ; 4 tables surchargées (7, 7, 6, 12) ne changent pas. ' + + 'Total : 104 \u{2192} 122 sièges pour 118 participants.', + ); + }); + + test('une table seule, aucune surchargée : le singulier', () => { + const charge = chargeDe({ tables: [{ id: 1 }], n: 1 }); + assert.equal( + traduire(enonceDefaut(bilanChangementDefaut(charge, 10), charge.tables)), + '1 table suit le défaut et passe de 8 à 10 places. Total : 8 \u{2192} 10 sièges pour 1 participant.', + ); + }); + + test('une surchargée seule, aucune table qui suit : leur forme', () => { + const charge = chargeDe({ tables: [{ id: 1, sieges: 6 }], n: 4 }); + assert.equal( + traduire(enonceDefaut(bilanChangementDefaut(charge, 10), charge.tables)), + 'Aucune table ne suit le défaut ; 1 table surchargée (6) ne change pas. Total : 6 \u{2192} 6 sièges pour 4 participants.', + ); + }); + + test('une baisse de 8 à 6, sans réservation : sa forme', () => { + const charge = chargeDe({ tables: [{ id: 1 }, { id: 2 }, { id: 3 }], n: 10 }); + assert.equal( + traduire(enonceDefaut(bilanChangementDefaut(charge, 6), charge.tables)), + '3 tables suivent le défaut et passent de 8 à 6 places. Total : 24 \u{2192} 18 sièges pour 10 participants.', + ); + }); + + test('une baisse refusée : la table 2 nommée par son numéro, avec son refus, et aucune forme d’application', () => { + const reservations = [1, 2, 3, 4, 5, 6, 7].map((p) => resa(p, 2, null)); + const charge = chargeDe({ tables: [{ id: 1 }, { id: 2 }, { id: 3 }], n: 10, reservations }); + const bilan = bilanChangementDefaut(charge, 6); + assert.equal(bilan.refus.length, 1); + const entrees = enonceDefaut(bilan, charge.tables); + assert.equal(entrees.length, 2); + assert.deepEqual(entrees[1], { + cle: 'tables.defaut.refusTable', + details: { numero: 2, refus: { code: 'SOUS_RESERVATIONS', details: bilan.refus[0].details } }, + }); + assert.equal(bilan.refus[0].details.n, 7); + const texte = traduire(entrees); + assert.ok(texte.includes(`Table 2 \u{2014} ${t('SOUS_RESERVATIONS', bilan.refus[0].details)}`), texte); + assert.ok(texte.includes('7 réservations'), texte); + for (const cle of entrees.map((entree) => entree.cle)) { + assert.ok(!['tables.defaut.application', 'tables.defaut.total'].includes(cle), cle); + } + }); + + test('le numéro affiché nomme la table, non son identifiant ; une table inconnue lève', () => { + const reservations = [1, 2, 3, 4, 5, 6, 7].map((p) => resa(p, 2, null)); + const charge = chargeDe({ tables: [{ id: 1, numero: 4 }, { id: 2, numero: 9 }], n: 10, reservations }); + const bilan = bilanChangementDefaut(charge, 6); + assert.equal(enonceDefaut(bilan, charge.tables)[1].details.numero, 9); + assert.throws(() => enonceDefaut(bilan, []), TypeError); + }); + + test('moins de deux places : un refus de l’événement entier, que le champ porte', () => { + const charge = chargeDe({ tables: [{ id: 1 }], n: 2 }); + const bilan = bilanChangementDefaut(charge, 1); + assert.deepEqual(refusDuChampDefaut(bilan), { code: 'MOINS_DE_DEUX', details: { remede: null } }); + const entrees = enonceDefaut(bilan, charge.tables); + assert.equal(entrees[1].details.numero, null); + assert.equal(t(entrees[1].cle, entrees[1].details), t('MOINS_DE_DEUX', {})); + assert.equal(refusDuChampDefaut(bilanChangementDefaut(charge, 10)), null); + }); + + test('le réalignement refusé : son en-tête, la table 2 nommée avec son refus, et aucune forme d’application', () => { + const reservations = Array.from({ length: 11 }, (_, rang) => resa(rang + 1, 2, null)); + const charge = chargeDe({ tables: [{ id: 1 }, { id: 2, sieges: 12 }], n: 12, reservations }); + assert.equal(bilanChangementDefaut(charge, 10).refus.length, 0); + const bilan = bilanChangementDefaut(charge, 10, { realigner: true }); + assert.equal(bilan.refus.length, 1); + const entrees = enonceDefaut(bilan, charge.tables, { realigner: true }); + assert.equal(entrees.length, 2); + assert.deepEqual(entrees[0], { cle: 'tables.defaut.realignementRefuse', details: { avant: 8, apres: 10 } }); + assert.deepEqual(entrees[1], { + cle: 'tables.defaut.refusTable', + details: { numero: 2, refus: { code: 'SOUS_RESERVATIONS', details: bilan.refus[0].details } }, + }); + const texte = traduire(entrees); + assert.ok(texte.includes(`Table 2 \u{2014} ${t('SOUS_RESERVATIONS', bilan.refus[0].details)}`), texte); + assert.ok(!texte.includes('Le défaut ne passe pas'), texte); + assert.equal(enonceDefaut(bilan, charge.tables)[0].cle, 'tables.defaut.refusee'); + }); +}); + +describe('ce que nomment les fenêtres (§ 6.1, § 6.2, § 2.1 étape 5)', () => { + test('surchargesPerdues : chaque surcharge par numéro de table, celles qu’un réalignement efface', () => { + const elements = surchargesPerdues(treizeTables()); + assert.equal(elements.length, 4); + assert.deepEqual( + elements.map(({ cle, details }) => t(cle, details)), + ['Table 3 : 7 places', 'Table 6 : 7 places', 'Table 9 : 6 places', 'Table 12 : 12 places'], + ); + }); + + test('elementsTours : les réservations détruites, personnes et tables, puis les placements touchés', () => { + const charge = chargeDe({ + tables: [{ id: 1, numero: 1 }, { id: 2, numero: 5 }], + n: 4, + tours: 4, + reservations: [resa(1, 2, null, 4), resa(2, 1, null, 4), resa(3, 1, null, 2)], + }); + charge.propositions = [{ id: 1, tours: 4 }, { id: 2, tours: 4 }]; + charge.retenu = { proposition: 1, tours: 4 }; + const bilan = bilanTours(charge, 3); + assert.equal(annonceTours(bilan, 4, 3), true); + const textes = elementsTours(charge, bilan).map(({ cle, details }) => t(cle, details)); + assert.equal(textes.length, 5); + assert.deepEqual(textes, [ + 'Iris Ombrelle : réservation à la table 5 au tour 4, détruite', + 'Théo Pervenche : réservation à la table 1 au tour 4, détruite', + t('tables.touchee.proposition', { id: 1 }), + t('tables.touchee.proposition', { id: 2 }), + t('tables.touchee.retenu'), + ]); + }); + + test('annonceTours : rien à annoncer sans réservation détruite ni placement touché', () => { + const charge = chargeDe({ tables: [{ id: 1 }], n: 2, tours: 4, reservations: [resa(1, 1, null, 2)] }); + assert.equal(annonceTours(bilanTours(charge, 3), 4, 3), false); + assert.equal(annonceTours(bilanTours(charge, 4), 4, 4), false); + }); + + test('annonceTours : une hausse ne détruit rien et n’annonce rien ; une baisse annonce les placements qui perdent des tours', () => { + const charge = chargeDe({ tables: [{ id: 1 }], n: 2, tours: 2 }); + charge.propositions = [{ id: 1, tours: 2 }]; + charge.retenu = { proposition: 1, tours: 2 }; + const hausse = bilanTours(charge, 3); + assert.deepEqual(hausse.propositionsTouchees, [1]); + assert.equal(hausse.retenuTouche, true); + assert.equal(annonceTours(hausse, 2, 3), false); + assert.equal(annonceTours(bilanTours(charge, 1), 2, 1), true); + charge.retenu = null; + assert.equal(annonceTours(bilanTours(charge, 1), 2, 1), true); + assert.equal(annonceTours(bilanTours(charge, 5), 2, 5), false); + }); + + test('elementsSuppression : la réservation d’Iris Ombrelle et le titre « animateur » ; une table nue n’annonce rien', () => { + const charge = chargeDe({ + tables: [{ id: 1 }, { id: 2 }], + n: 2, + attribuerSieges: true, + reservations: [resa(1, 1, 3)], + titres: [{ table: 1, siege: 3, libelle: 'animateur' }], + }); + const bilan = bilanSuppressionTable(charge, 1); + assert.equal(annonceSuppression(bilan), true); + const textes = elementsSuppression(charge, bilan).map(({ cle, details }) => t(cle, details)); + assert.equal(textes.length, 2); + assert.deepEqual(textes, ['Iris Ombrelle : réservation pour tous les tours, siège 3', 'Titre « animateur », siège 3']); + assert.equal(annonceSuppression(bilanSuppressionTable(charge, 2)), false); + assert.deepEqual(elementsSuppression(charge, bilanSuppressionTable(charge, 2)), []); + }); +}); + +describe('les compléments d’un refus et d’un avertissement (§ 6.2)', () => { + test('siegesRetiresNommes : le siège 8 et son titre, puis le siège 7 et Iris Ombrelle, dans cet ordre', () => { + const charge = chargeDe({ + tables: [{ id: 1 }], + n: 2, + attribuerSieges: true, + reservations: [resa(1, 1, 7)], + titres: [{ table: 1, siege: 8, libelle: 'animateur' }], + }); + const { refus } = controlerCapacite(charge, 1, 6, { origine: 'champ' }); + assert.equal(refus.code, 'SIEGES_RETIRES'); + const lignes = siegesRetiresNommes(charge, refus); + assert.equal(lignes.length, 2); + assert.deepEqual( + lignes.map(({ cle, details, nom }) => [t(cle, details), nom]), + [ + ['Siège 8 : titre « animateur »', null], + ['Siège 7 :', 'Iris Ombrelle'], + ], + ); + const sept = siegesRetiresNommes(charge, controlerCapacite(charge, 1, 7, { origine: 'champ' }).refus); + assert.deepEqual(sept.map(({ details }) => details.siege), [8]); + }); + + test('siegesRetiresNommes : rien pour un autre refus', () => { + assert.deepEqual(siegesRetiresNommes(chargeDe(), { code: 'MOINS_DE_DEUX', details: { remede: null } }), []); + }); + + test('avertissementsNommes : SURNUMERAIRES nomme ses personnes, dans l’ordre reçu, et sa table par son numéro', () => { + const charge = chargeDe({ tables: [{ id: 3, numero: 7 }], n: 4 }); + const nommes = avertissementsNommes(charge, [ + { code: 'SURNUMERAIRES', details: { table: 3, tour: 2, personnes: [4, 2] } }, + { code: 'AU_DELA_LISIBLE', details: { seuil: 16 } }, + ]); + assert.equal(nommes.length, 2); + assert.deepEqual(nommes[0], { + code: 'SURNUMERAIRES', + details: { table: 3, tour: 2, personnes: [4, 2] }, + numero: 7, + noms: ['Grisaille', 'Théo Pervenche'], + }); + assert.deepEqual(nommes[1], { code: 'AU_DELA_LISIBLE', details: { seuil: 16 }, numero: null, noms: [] }); + }); +}); + +describe('refusDeCommande : la commande essayée sans rien écrire', () => { + test('zéro tour : le code que la commande donne à moins d’un tour ; trois tours : aucun refus', () => { + const charge = chargeDe({ tables: [{ id: 1 }], n: 2, tours: 4 }); + const zero = refusDeCommande(charge, 'changerTours', { tours: 0 }); + assert.equal(zero.code, 'MOINS_D_UN_TOUR'); + assert.equal(zero.details.remede, null); + assert.equal(refusDeCommande(charge, 'changerTours', { tours: 3 }), null); + assert.equal(charge.evenement.tours, 4); + }); +}); diff --git a/test/fichiers_simules.js b/test/fichiers_simules.js index ad9f37e..cead5cd 100644 --- a/test/fichiers_simules.js +++ b/test/fichiers_simules.js @@ -149,6 +149,12 @@ const estContenuVerrou = (contenu) => * ce que rend typeSupport, 'fixe' pour une racine absente de l'objet * @param {string} [reglages.hote] machine du processus ; un verrou d'un autre * hôte rend vivant à null + * @param {string[]} [reglages.dossiersChoisis] chemins absolus que rend + * choisirDossier, un par appel, dans l'ordre, puis null comme quand + * l'opérateur annule : chacun sous l'identifiant choisi-, n compté + * depuis 1, et inscrit parmi les racines du système, comme le fait la + * coquille (electron/fichiers.js). La racine choisie n'existe qu'une + * fois créée ; racines() ne la rend pas * @returns {import('../src/stockage/systeme_fichiers.js').SystemeFichiers & Object} * le système, et pour l'épreuve : * - pid, hote : ceux du processus, qu'écrit son verrou ; les pid d'un disque @@ -196,7 +202,11 @@ export function creerFichiersSimules({ sonde = {}, support = {}, hote = 'poste-epreuve', + dossiersChoisis = [], } = {}) { + if (!Array.isArray(dossiersChoisis) || dossiersChoisis.some((chemin) => typeof chemin !== 'string' || chemin === '')) { + throw new TypeError('dossiersChoisis : un tableau de chemins, chaînes non vides'); + } const etat = etatDuDisque(disque, insensibleCasse, separateur); const pid = etat.prochainPid; etat.prochainPid += 1; @@ -205,6 +215,10 @@ export function creerFichiersSimules({ const joindre = (dossier, nom) => (dossier.endsWith(separateur) ? dossier + nom : dossier + separateur + nom); const racines = new Map([['documents', documents]]); if (executable !== null) racines.set('portable', joindre(executable, 'data')); + // Les dossiers que le dialogue rendra encore, et le nombre de ceux qu'il a + // rendus. + const aChoisir = [...dossiersChoisis]; + let choisis = 0; // Cause qui refuse toute écriture d'une racine : celle de sa sonde déclarée, // puis celle de pannes.ecriture. Renommages refusés restants d'une racine, @@ -365,8 +379,17 @@ export function creerFichiersSimules({ documents: { id: 'documents', chemin: documents }, })), - // Les dialogues natifs rendent null, comme quand l'opérateur annule. - choisirDossier: primitive(null, () => null), + // Les dialogues natifs rendent null, comme quand l'opérateur annule ; + // choisirDossier rend d'abord les dossiersChoisis, chacun inscrit comme + // racine pour le reste du processus. + choisirDossier: primitive(null, () => { + if (aChoisir.length === 0) return null; + const chemin = aChoisir.shift(); + choisis += 1; + const id = `choisi-${choisis}`; + racines.set(id, chemin); + return { id, chemin }; + }), // Le témoin s'écrit par-dessus celui qu'une séance précédente a pu // laisser, puis s'efface ; sa relecture ne peut différer de ce qui vient diff --git a/test/fichiers_simules.test.js b/test/fichiers_simules.test.js index 462ce58..9ed8524 100644 --- a/test/fichiers_simules.test.js +++ b/test/fichiers_simules.test.js @@ -207,6 +207,41 @@ describe('fichiers simulés : racines et emplacements', () => { assert.equal(await fs.ouvrirDansExplorateur(DOC), undefined); await echoue(fs.ouvrirDansExplorateur(PORTABLE), 'CHEMIN_REFUSE', { chemin: '' }); }); + + test("dossiersChoisis : choisirDossier rend chacun dans l'ordre, choisi-, puis null ; la racine rendue s'inscrit, et n'existe qu'une fois créée", async () => { + const SECOURS = 'D:\\Secours'; + const fs = creerFichiersSimules({ dossiersChoisis: [SECOURS, 'F:\\Repli'] }); + const choisi = { id: 'choisi-1', chemin: SECOURS }; + // Avant le choix, la racine n'est pas connue du système. + await echoue(fs.lister(choisi, ''), 'CHEMIN_REFUSE', { chemin: '' }); + const avant = { ...fs.compteurs }; + assert.deepEqual(await fs.choisirDossier(), choisi); + assert.deepEqual(await fs.choisirDossier(), { id: 'choisi-2', chemin: 'F:\\Repli' }); + assert.equal(await fs.choisirDossier(), null); + assert.equal(await fs.choisirDossier(), null); + assert.deepEqual(fs.compteurs, avant, 'le dialogue ne compte pas'); + assert.deepEqual(await fs.racines(), { portable: null, documents: DOC }, 'les racines du système restent les deux'); + await echoue(fs.lister(choisi, ''), 'ABSENT', { chemin: '' }); + const absent = { chemin: 'soiree.gtt.json', dossier: SECOURS, cause: 'ENOENT' }; + await echoue(fs.ecrireAtomique(choisi, 'soiree.gtt.json', 'x'), 'ECRITURE', absent); + await fs.creerDossier(choisi, ''); + await fs.ecrireAtomique(choisi, 'soiree.gtt.json', 'état'); + assert.equal(fs.contenu('choisi-1', 'soiree.gtt.json'), 'état'); + assert.deepEqual(noms(await fs.lister(choisi, '')), ['soiree.gtt.json']); + fs.pannes.ecriture('choisi-2'); + await echoue(fs.creerDossier({ id: 'choisi-2', chemin: 'F:\\Repli' }, ''), 'ECRITURE', { + chemin: '', + dossier: 'F:\\Repli', + cause: 'EROFS', + }); + await echoue(fs.lister({ id: 'choisi-3', chemin: 'G:\\' }, ''), 'CHEMIN_REFUSE', { chemin: '' }); + }); + + test('dossiersChoisis : un chemin qui n\u{2019}est pas une chaîne non vide lève TypeError à la création', () => { + for (const fautif of [[''], [null], 'D:\\Secours']) { + assert.ok(erreurDe(() => creerFichiersSimules({ dossiersChoisis: fautif })) instanceof TypeError, JSON.stringify(fautif)); + } + }); }); describe('fichiers simulés : casse des noms', () => { diff --git a/test/fixtures/plan/toutes_en_conflit.gtt.json b/test/fixtures/plan/toutes_en_conflit.gtt.json new file mode 100644 index 0000000..2b701d2 --- /dev/null +++ b/test/fixtures/plan/toutes_en_conflit.gtt.json @@ -0,0 +1,39 @@ +{ + "entete": {"format":1,"produitVersion":"epreuve","revision":1,"comptes":{"participants":12,"tables":4,"reservations":0,"titres":0,"propositions":1,"retenu":0}}, + "charge": { + "evenement": {"id":"epreuve:toutes-en-conflit","nom":"Toutes en conflit","date":null,"siegesParDefaut":3,"tours":4,"unite":"cm","etat":"propose","filiation":null}, + "reglages": {"separerAppartenances":true,"nouveauxVoisins":true,"nouvelleTable":true,"varierAppartenances":true,"attribuerSieges":false,"generation":{"nombre":5,"arret":200000,"historique":1000}}, + "prochainsIds": {"participant":13,"table":5,"proposition":2}, + "participants": [ + {"id":1,"nom":"Ombrelle","prenom":"Iris","appartenance":"Club des Merles","courriel":null,"titrePressenti":null,"notes":null,"exclu":false}, + {"id":2,"nom":"Pervenche","prenom":"Théo","appartenance":"Cercle des Hérons","courriel":null,"titrePressenti":null,"notes":null,"exclu":false}, + {"id":3,"nom":"Lacasse","prenom":"Ondine","appartenance":"Club des Merles","courriel":null,"titrePressenti":null,"notes":null,"exclu":false}, + {"id":4,"nom":"Grisaille","prenom":null,"appartenance":"Cercle des Hérons","courriel":null,"titrePressenti":null,"notes":null,"exclu":false}, + {"id":5,"nom":"Tourmaline","prenom":"Basile","appartenance":"Club des Merles","courriel":null,"titrePressenti":null,"notes":null,"exclu":false}, + {"id":6,"nom":"Arrosoir","prenom":"Capucine","appartenance":"Cercle des Hérons","courriel":null,"titrePressenti":null,"notes":null,"exclu":false}, + {"id":7,"nom":"Cerfeuil","prenom":"Aurèle","appartenance":"Club des Merles","courriel":null,"titrePressenti":null,"notes":null,"exclu":false}, + {"id":8,"nom":"Bruyère","prenom":"Mélisande","appartenance":"Cercle des Hérons","courriel":null,"titrePressenti":null,"notes":null,"exclu":false}, + {"id":9,"nom":"Galet","prenom":"Firmin","appartenance":"Club des Merles","courriel":null,"titrePressenti":null,"notes":null,"exclu":false}, + {"id":10,"nom":"Mistral","prenom":"Clélia","appartenance":"Cercle des Hérons","courriel":null,"titrePressenti":null,"notes":null,"exclu":false}, + {"id":11,"nom":"Houblon","prenom":"Anatole","appartenance":"Club des Merles","courriel":null,"titrePressenti":null,"notes":null,"exclu":false}, + {"id":12,"nom":"Sarcelle","prenom":"Solveig","appartenance":"Cercle des Hérons","courriel":null,"titrePressenti":null,"notes":null,"exclu":false} + ], + "tables": [ + {"id":1,"numero":1,"sieges":null,"forme":"ronde","position":{"x":0,"y":0}}, + {"id":2,"numero":2,"sieges":null,"forme":"ronde","position":{"x":300,"y":0}}, + {"id":3,"numero":3,"sieges":null,"forme":"ronde","position":{"x":600,"y":0}}, + {"id":4,"numero":4,"sieges":null,"forme":"ronde","position":{"x":900,"y":0}} + ], + "reservations": [], + "titres": [], + "propositions": [ + {"id":1,"graine":2215483850,"arret":4000,"historique":1000,"produitVersion":"epreuve","siegesAttribues":false,"tables":[1,2,3,4],"capacites":[3,3,3,3],"tours":4,"participants":[1,2,3,4,5,6,7,8,9,10,11,12],"placement":[ + {"sieges":[[4,7,8],[5,6,10],[3,9,12],[1,2,11]],"reserve":[]}, + {"sieges":[[2,7,12],[3,5,11],[1,4,10],[6,8,9]],"reserve":[]}, + {"sieges":[[2,9,10],[7,8,11],[4,5,12],[1,3,6]],"reserve":[]}, + {"sieges":[[9,11,12],[3,4,6],[5,7,10],[1,2,8]],"reserve":[]} + ]} + ], + "retenu": null + } +}