diff --git a/src/application/conflits_placement.js b/src/application/conflits_placement.js new file mode 100644 index 0000000..4e4d04e --- /dev/null +++ b/src/application/conflits_placement.js @@ -0,0 +1,116 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Les conflits du placement montré (§ 7.5), conflit par conflit, pour une +// lecture hors du plan : la liste des participants les range sous ses +// groupes. Ils se lisent dans etatPlaces (places.js), tour par tour, avec la +// même demande que le plan — la proposition demandée, sinon le retenu — : +// même source, mêmes conflits de conflitsParTable, donc à chaque table +// autant de conflits que sa pastille en compte. Rien ne se recalcule ici +// que les rencontres antérieures d'un conflit, lues dans les occupants des +// tours précédents de la même source que son tour : une proposition plus +// courte que R cède ses derniers tours au retenu, et l'histoire d'un conflit +// lu dans le retenu se relit alors dans le retenu, non dans la proposition. + +import { etatPlaces } from './places.js'; + +/** + * @typedef {Object} ConflitPlacement + * @property {number} tour + * @property {number} table identifiant + * @property {number} numero numéro affiché de la table + * @property {'separerAppartenances'|'nouveauxVoisins'|'nouvelleTable'} regle + * @property {number[]} personnes la paire, croissante, ou la personne revenue + * @property {Array<{tour: number, numero: number}>} deja + * les tours antérieurs où la paire était réunie à une même table + * (nouveauxVoisins) ou où la personne était à cette table + * (nouvelleTable), dans l'ordre des tours ; vide pour + * separerAppartenances + */ + +/** + * Les conflits du placement que le plan montre : à chaque tour dont la + * source, celle d'etatPlaces(charge, {tour, proposition}), est la + * proposition demandée ou le retenu, et dont les conflits s'évaluent, une + * entrée par violation d'une règle active — par paire pour séparer les + * appartenances et nouveaux voisins, par personne pour nouvelle table —, + * dans l'ordre des tours, puis des tables de la charge, puis des règles et + * des paires de conflitsParTable. Un tour dont la source n'est que les + * réservations n'en rend aucun. + * + * Rend null quand aucun tour ne se lit dans une proposition ou un retenu + * évalué : ni proposition demandée ni retenu, ou une source en dérive, que + * le plan ne signale pas non plus. Ne modifie pas la charge. + * + * @param {import('../stockage/types.js').Charge} charge + * @param {{proposition?: number|null}} demande la proposition demandée au plan + * @returns {ConflitPlacement[]|null} + */ +export function conflitsDuPlacement(charge, { proposition = null } = {}) { + const conflits = []; + // Chaque tour déjà lu : sa source, sa proposition, et la table de chaque + // personne assise, {id, numero}. + const lus = []; + let lu = false; + for (let tour = 1; tour <= charge.evenement.tours; tour += 1) { + const etat = etatPlaces(charge, { tour, proposition }); + if (etat.source !== 'reservations' && etat.evalue) { + lu = true; + const tablesParTour = historique(charge, etat, lus); + for (const { id: table, numero, conflits: deTable } of etat.tables) { + for (const regle of deTable?.regles ?? []) { + for (const personnes of regle.paires ?? regle.personnes.map((p) => [p])) { + conflits.push({ + tour, + table, + numero, + regle: regle.regle, + personnes: [...personnes], + deja: rencontresAnterieures(regle.regle, personnes, table, tablesParTour), + }); + } + } + } + } + lus.push({ source: etat.source, proposition: etat.proposition, tableDe: tablesDe(etat) }); + } + return lu ? conflits : null; +} + +// La table de chaque personne assise d'un EtatPlaces, Map id → {id, numero}. +function tablesDe(etat) { + const tableDe = new Map(); + for (const { id, numero, chaises } of etat.tables) { + for (const { occupant } of chaises) if (occupant !== null) tableDe.set(occupant, { id, numero }); + } + return tableDe; +} + +// La table de chaque personne aux tours qui précèdent celui d'etat, lus dans +// la source d'etat : un tour lu dans une autre source se relit par +// etatPlaces avec la proposition d'etat, null pour le retenu. La source +// d'etat porte son tour, donc tous ceux qui le précèdent, et la relecture +// rend la même source. +function historique(charge, etat, lus) { + return lus.map(({ source, proposition, tableDe }, rang) => + source === etat.source && proposition === etat.proposition + ? tableDe + : tablesDe(etatPlaces(charge, { tour: rang + 1, proposition: etat.proposition })), + ); +} + +// Les tours antérieurs, dans leur ordre, où les deux personnes d'une paire +// revue étaient à une même table, ou où la personne revenue était à la +// table d'identifiant table, chacun avec le numéro affiché de cette table ; +// aucun pour une paire de même appartenance. +function rencontresAnterieures(regle, personnes, table, tablesParTour) { + const deja = []; + if (regle === 'separerAppartenances') return deja; + tablesParTour.forEach((tableDe, rang) => { + const premiere = tableDe.get(personnes[0]); + if (premiere === undefined) return; + const reunis = regle === 'nouvelleTable' ? premiere.id === table : premiere.id === tableDe.get(personnes[1])?.id; + if (reunis) deja.push({ tour: rang + 1, numero: premiere.numero }); + }); + return deja; +} diff --git a/src/application/conflits_placement.test.js b/src/application/conflits_placement.test.js new file mode 100644 index 0000000..2342cc8 --- /dev/null +++ b/src/application/conflits_placement.test.js @@ -0,0 +1,248 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Épreuves des conflits du placement montré (§ 7.5) : la proposition +// demandée, sinon le retenu, lus tour par tour comme le plan les lit, en +// conflits nommés — tour, table, règle, personnes et rencontres +// antérieures. Les noms d'épreuve sont inventés ; la petite démonstration +// au conflit livrée se lit telle quelle, et la version vient du module +// engendré. +import assert from 'node:assert/strict'; +import { describe, test } from '../../test/lanceur.js'; +import { texteLivre } from '../demo/livrees.js'; +import { rechercher } from '../moteur/recherche.js'; +import { analyser, configurationDepuisCharge, creerCharge } from '../stockage/document.js'; +import { VERSION } from '../version.genere.js'; +import { COMMANDES } from './commandes.js'; +import { conflitsDuPlacement } from './conflits_placement.js'; +import { etatPlaces } from './places.js'; + +const executer = (nom, charge, args) => COMMANDES.get(nom)(charge, args); + +const personne = (id, nom, prenom, appartenance) => ({ + id, + nom, + prenom, + appartenance, + courriel: null, + titrePressenti: null, + notes: null, + exclu: false, +}); + +// Deux tables de trois places dont les numéros affichés diffèrent des +// identifiants : la table 1 porte le numéro 4, la table 2 le numéro 1. +const TABLES = [ + { id: 1, numero: 4, sieges: null, forme: 'ronde', position: { x: 0, y: 0 } }, + { id: 2, numero: 1, sieges: null, forme: 'ronde', position: { x: 300, y: 0 } }, +]; + +// Six personnes : 1 et 2 du Club des Merles, 3 et 4 des Pinsons, 5 et 6 +// sans appartenance. +const PERSONNES = [ + personne(1, 'Ombrelle', 'Iris', 'Club des Merles'), + personne(2, 'Fontaine', 'Noé', 'Club des Merles'), + personne(3, 'Pervenche', 'Théo', 'Pinsons'), + personne(4, 'Lacasse', 'Ondine', 'Pinsons'), + personne(5, 'Grisaille', null, null), + personne(6, 'Bruyère', 'Anouk', null), +]; + +// Tour 1 : 1 et 2, collègues, à la table 1 (numéro 4). Tour 2 : 4 et 5 +// se revoient à la table 1, déjà réunis à la table 2 (numéro 1) ; 2 et 3 +// se revoient à la table 2, déjà réunis à la table 1 ; 1 revient à la table +// 1, 6 à la table 2. +const PLAN = [ + [[1, 2, 3], [4, 5, 6]], + [[1, 4, 5], [2, 3, 6]], +]; +// Le même plan, ses tables échangées au tour 2 : 2 et 3 se revoient à la +// table 1 et y reviennent, 4 et 5 de même à la table 2. +const AUTRE = [ + [[1, 2, 3], [4, 5, 6]], + [[2, 3, 6], [1, 4, 5]], +]; + +const ATTENDUS = [ + { tour: 1, table: 1, numero: 4, regle: 'separerAppartenances', personnes: [1, 2], deja: [] }, + { tour: 2, table: 1, numero: 4, regle: 'nouveauxVoisins', personnes: [4, 5], deja: [{ tour: 1, numero: 1 }] }, + { tour: 2, table: 1, numero: 4, regle: 'nouvelleTable', personnes: [1], deja: [{ tour: 1, numero: 4 }] }, + { tour: 2, table: 2, numero: 1, regle: 'nouveauxVoisins', personnes: [2, 3], deja: [{ tour: 1, numero: 4 }] }, + { tour: 2, table: 2, numero: 1, regle: 'nouvelleTable', personnes: [6], deja: [{ tour: 1, numero: 1 }] }, +]; + +const AUTRES_ATTENDUS = [ + ATTENDUS[0], + { tour: 2, table: 1, numero: 4, regle: 'nouveauxVoisins', personnes: [2, 3], deja: [{ tour: 1, numero: 4 }] }, + { tour: 2, table: 1, numero: 4, regle: 'nouvelleTable', personnes: [2], deja: [{ tour: 1, numero: 4 }] }, + { tour: 2, table: 1, numero: 4, regle: 'nouvelleTable', personnes: [3], deja: [{ tour: 1, numero: 4 }] }, + { tour: 2, table: 2, numero: 1, regle: 'nouveauxVoisins', personnes: [4, 5], deja: [{ tour: 1, numero: 1 }] }, + { tour: 2, table: 2, numero: 1, regle: 'nouvelleTable', personnes: [4], deja: [{ tour: 1, numero: 1 }] }, + { tour: 2, table: 2, numero: 1, regle: 'nouvelleTable', personnes: [5], deja: [{ tour: 1, numero: 1 }] }, +]; + +// Un plan de fichier sur les deux tables, sièges non attribués, chaque tour +// donné par ses listes. +function planFichier(tours, siegesAttribues = false) { + const participants = PERSONNES.map(({ id }) => id); + return { + siegesAttribues, + tables: TABLES.map(({ id }) => id), + capacites: [3, 3], + tours: tours.length, + participants, + placement: tours.map((listes) => ({ sieges: structuredClone(listes), reserve: [] })), + }; +} + +const propositionDe = (id, tours) => ({ + id, + graine: id, + arret: 40, + historique: 1000, + produitVersion: VERSION.affichee, + ...planFichier(tours), +}); + +// La charge des six personnes, R tours, ses propositions numérotées depuis +// 1 dans l'ordre des plans donnés, et le plan de rang retenu (compté depuis +// 0) retenu quand il est donné. +function charge({ plans = [PLAN], retenu = null, tours = 2 } = {}) { + const c = creerCharge({ id: 'evt-conflits', nom: 'Soirée des Merles', siegesParDefaut: 3, tours }); + c.participants = structuredClone(PERSONNES); + c.tables = structuredClone(TABLES); + c.prochainsIds = { participant: 7, table: 3, proposition: plans.length + 1 }; + c.propositions = plans.map((tours_, rang) => propositionDe(rang + 1, tours_)); + c.evenement.etat = retenu === null ? 'propose' : 'retenu'; + c.retenu = retenu === null ? null : { proposition: retenu + 1, ...planFichier(plans[retenu]) }; + return c; +} + +// La somme des pastilles du plan à chaque tour : le nombre de chaque table +// d'etatPlaces, 0 pour une table sans conflit évalué. +const pastilles = (c, proposition) => + Array.from({ length: c.evenement.tours }, (_, r) => + etatPlaces(c, { tour: r + 1, proposition }).tables.reduce((somme, { conflits }) => somme + (conflits?.nombre ?? 0), 0), + ); + +const parTour = (conflits, R) => Array.from({ length: R }, (_, r) => conflits.filter(({ tour }) => tour === r + 1).length); + +describe('conflitsDuPlacement : la proposition montrée, sinon le retenu (§ 7.5)', () => { + test('la proposition demandée, sans retenu : chaque violation nommée, tour, table et numéro affiché, règle, personnes et rencontres antérieures', () => { + const c = charge(); + assert.deepEqual(conflitsDuPlacement(c, { proposition: 1 }), ATTENDUS); + }); + + test('le retenu, sans proposition demandée : le même plan rend les mêmes conflits', () => { + const c = charge({ retenu: 0 }); + assert.deepEqual(conflitsDuPlacement(c, { proposition: null }), ATTENDUS); + }); + + test('une proposition demandée alors qu’un retenu existe : la proposition se lit, non le retenu', () => { + const c = charge({ plans: [AUTRE, PLAN], retenu: 0 }); + assert.deepEqual(conflitsDuPlacement(c, { proposition: 2 }), ATTENDUS); + assert.deepEqual(conflitsDuPlacement(c, { proposition: null }), AUTRES_ATTENDUS); + }); + + test('ni proposition demandée ni retenu : null, quelles que soient les propositions de la charge', () => { + assert.equal(conflitsDuPlacement(charge(), { proposition: null }), null); + assert.equal(conflitsDuPlacement(charge({ plans: [] }), { proposition: null }), null); + assert.equal(conflitsDuPlacement(charge(), {}), null); + }); + + test('une proposition demandée qui n’existe pas : le retenu, ou null sans lui', () => { + assert.equal(conflitsDuPlacement(charge(), { proposition: 9 }), null); + assert.deepEqual(conflitsDuPlacement(charge({ retenu: 0 }), { proposition: 9 }), ATTENDUS); + }); + + test('une règle inactive ne rend aucun conflit ; un placement sans violation rend une liste vide', () => { + const c = charge(); + c.reglages.nouvelleTable = false; + c.reglages.nouveauxVoisins = false; + assert.deepEqual(conflitsDuPlacement(c, { proposition: 1 }), [ATTENDUS[0]]); + c.reglages.separerAppartenances = false; + assert.deepEqual(conflitsDuPlacement(c, { proposition: 1 }), []); + }); + + test('sièges attribués : la même lecture', () => { + const c = charge(); + c.propositions[0] = { ...c.propositions[0], ...planFichier(PLAN, true) }; + assert.deepEqual(conflitsDuPlacement(c, { proposition: 1 }), ATTENDUS); + }); + + test('une paire revue deux fois avant : chaque rencontre antérieure, dans l’ordre des tours', () => { + // Tour 3 : 2 et 3 à la table 1, déjà réunis à la table 1 (numéro 4) au + // tour 1 et à la table 2 (numéro 1) au tour 2. + const plan = [...PLAN, [[2, 3, 4], [1, 5, 6]]]; + const c = charge({ plans: [plan], tours: 3 }); + const conflits = conflitsDuPlacement(c, { proposition: 1 }); + const paire = conflits.find(({ tour, regle, personnes }) => tour === 3 && regle === 'nouveauxVoisins' && personnes.join() === '2,3'); + assert.deepEqual(paire, { + tour: 3, + table: 1, + numero: 4, + regle: 'nouveauxVoisins', + personnes: [2, 3], + deja: [ + { tour: 1, numero: 4 }, + { tour: 2, numero: 1 }, + ], + }); + }); + + test('une proposition en dérive plus courte que R : ses tours se lisent en elle, les suivants dans le retenu, avec les rencontres antérieures du retenu', () => { + // La proposition 1 porte deux tours sur trois, la 2, retenue, les trois : + // le tour 3 se lit dans le retenu, et l'histoire de ses conflits aussi. + const c = charge({ plans: [AUTRE, [...PLAN, [[2, 3, 4], [1, 5, 6]]]], retenu: 1, tours: 3 }); + assert.equal(c.propositions[0].tours, 2); + assert.equal(etatPlaces(c, { tour: 3, proposition: 1 }).source, 'retenu'); + const auTour3 = (conflits) => conflits.filter(({ tour }) => tour === 3); + const duRetenu = auTour3(conflitsDuPlacement(c, { proposition: null })); + assert.ok(duRetenu.length > 0); + assert.deepEqual(auTour3(conflitsDuPlacement(c, { proposition: 1 })), duRetenu); + const paire = duRetenu.find(({ regle, personnes }) => regle === 'nouveauxVoisins' && personnes.join() === '2,3'); + assert.deepEqual(paire.deja, [ + { tour: 1, numero: 4 }, + { tour: 2, numero: 1 }, + ]); + }); + + test('un retenu en dérive — une personne ajoutée qu’il ne place pas — ne s’évalue pas : null, comme les pastilles absentes du plan', () => { + const c = executer('ajouterParticipant', charge({ retenu: 0 }), { champs: { nom: 'Sarcelle', prenom: 'Capucine' } }).charge; + assert.ok(etatPlaces(c, { tour: 1 }).evalue === false); + assert.equal(conflitsDuPlacement(c, { proposition: null }), null); + }); + + test('ne modifie pas la charge reçue', () => { + const c = charge({ plans: [AUTRE, PLAN], retenu: 0 }); + const avant = structuredClone(c); + conflitsDuPlacement(c, { proposition: 2 }); + conflitsDuPlacement(c, { proposition: null }); + assert.deepEqual(c, avant); + }); + + test('d’accord avec les pastilles du plan sur la petite démonstration au conflit : même somme à chaque tour, même nombre à chaque table', () => { + let c = analyser(texteLivre('petite-conflit')).charge; + const configuration = configurationDepuisCharge(c); + const propositions = rechercher(configuration, { graine: 2, nombre: 2, arret: 300, historique: 1000 }); + c = executer('enregistrerGeneration', c, { propositions, configuration, produitVersion: VERSION.affichee }).charge; + const R = c.evenement.tours; + assert.ok(R >= 2); + for (const { id } of c.propositions) { + const conflits = conflitsDuPlacement(c, { proposition: id }); + assert.ok(conflits.length > 0, `proposition ${id} : des conflits attendus`); + assert.deepEqual(parTour(conflits, R), pastilles(c, id)); + for (let tour = 1; tour <= R; tour += 1) { + for (const { id: table, numero, conflits: deTable } of etatPlaces(c, { tour, proposition: id }).tables) { + const ici = conflits.filter((conflit) => conflit.tour === tour && conflit.table === table); + assert.equal(ici.length, deTable?.nombre ?? 0, `tour ${tour}, table ${table}`); + for (const conflit of ici) assert.equal(conflit.numero, numero); + } + } + } + const retenue = executer('retenirProposition', c, { id: c.propositions[1].id }).charge; + const duRetenu = conflitsDuPlacement(retenue, { proposition: null }); + assert.deepEqual(parTour(duRetenu, R), pastilles(retenue, null)); + assert.deepEqual(duRetenu, conflitsDuPlacement(c, { proposition: c.propositions[1].id })); + }); +}); diff --git a/src/interface/libelles/participants.js b/src/interface/libelles/participants.js index a18f18f..98e0a09 100644 --- a/src/interface/libelles/participants.js +++ b/src/interface/libelles/participants.js @@ -9,7 +9,10 @@ // d'une valeur de la fiche ; participants.statut.* nomme chaque statut // calculé (§ 4.2), par cleDeStatut ; participants.groupe.* porte le // groupement de la liste : le sélecteur de chaque niveau, l'en-tête d'un -// groupe et ses commandes. +// groupe et ses commandes ; participants.conflit.* les conflits du +// placement montré dans la liste groupée : la marque d'un groupe et celle +// d'une personne, puis chaque ligne de conflit — son lieu, la liaison de +// deux noms, sa raison par règle. // Un nombre suivi de son nom, au singulier pour 0 et 1. const compte = (n, singulier, pluriel) => `${n} ${n <= 1 ? singulier : pluriel}`; @@ -17,6 +20,27 @@ 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}`); +// Les tours antérieurs d'une personne revenue, entre parenthèses : « (déjà +// au tour 1) », « (déjà aux tours 1 et 2) », « (déjà aux tours 1, 2 et +// 3) » ; rien sans tour. +function dejaAux(tours) { + if (tours.length === 0) return ''; + if (tours.length === 1) return ` (déjà au tour ${tours[0]})`; + return ` (déjà aux tours ${tours.slice(0, -1).join(', ')} et ${tours.at(-1)})`; +} + +// Les rencontres antérieures d'une paire revue, entre parenthèses : « (déjà +// à la table 1 au tour 1, à la table 2 au tour 3) » ; rien sans rencontre. +const dejaReunis = (deja) => + deja.length === 0 ? '' : ` (déjà ${deja.map(({ tour, numero }) => `à la table ${numero} au tour ${tour}`).join(', ')})`; + +// Le texte court de chaque règle dans la marque d'une personne en conflit. +const REGLES_COURTES = new Map([ + ['separerAppartenances', 'même appartenance'], + ['nouveauxVoisins', 'se revoient'], + ['nouvelleTable', 'revient à une table'], +]); + export const TABLE = new Map([ // --- Les colonnes, que la grille, le formulaire et la fiche partagent ------ ['participants.colonne.nom', 'Nom'], @@ -63,6 +87,21 @@ export const TABLE = new Map([ ['participants.groupe.toutReplier', 'Tout replier'], ['participants.groupe.toutDeplier', 'Tout déplier'], + // --- Les conflits du placement montré, dans la liste groupée (§ 7.5) ----- + ['participants.conflit.groupe', ({ nombre }) => `en conflit : ${compte(nombre, 'conflit', 'conflits')}`], + ['participants.conflit.personne', ({ regles }) => `en conflit : ${regles.map((regle) => REGLES_COURTES.get(regle)).join(', ')}`], + ['participants.conflit.lieu', ({ tour, numero }) => `Tour ${tour}, table ${numero} :`], + ['participants.conflit.et', 'et'], + ['participants.conflit.raison.separerAppartenances', '\u{2014} même appartenance'], + [ + 'participants.conflit.raison.nouveauxVoisins', + ({ deja }) => `\u{2014} se revoient${dejaReunis(deja)}`, + ], + [ + 'participants.conflit.raison.nouvelleTable', + ({ numero, deja }) => `\u{2014} revient à la table ${numero}${dejaAux(deja.map(({ tour }) => tour))}`, + ], + // --- Les commandes de la section ------------------------------------------ ['participants.commande.fiche', 'Fiche'], ['participants.commande.exclure', 'Exclure'], diff --git a/src/interface/participants/Grille.svelte b/src/interface/participants/Grille.svelte index 2cddb69..360f691 100644 --- a/src/interface/participants/Grille.svelte +++ b/src/interface/participants/Grille.svelte @@ -41,15 +41,36 @@ // jamais un en-tête ni une ligne repliée. Une saisie validée dont la // ligne quitte le rendu — tri ou groupe changé — rend le focus à la même // cellule de sa nouvelle ligne, et chaque groupe replié qui la reçoit, de - // tout niveau, se déplie. Replier et déplier n'écrivent rien. + // tout niveau, se déplie. Replier et déplier n'écrivent rien. Entrée sur + // la cellule d'exclusion suit sa ligne de même : la personne exclue ou + // réintégrée passe dans son nouveau groupe, et sa cellule garde le focus. + // + // Les conflits du placement montré (conflits, clé d'un groupe → ses + // lignes de conflit ; enConflit, identifiant → ses règles), que la section + // ne donne que groupée : l'en-tête d'un groupe en conflit, de tout niveau, + // porte une marque, un cadre plein qui dit en texte le nombre de ses + // conflits ; un groupe du dernier niveau, déplié, écrit au-dessus de ses + // lignes une ligne par conflit — lieu, noms, raison —, chaque nom dans son + // propre nœud ; la cellule du nom d'une personne en conflit nomme ses + // règles. Aucune couleur ne les distingue : la teinte de conflit reste au + // plan. import { getContext, onDestroy, tick } from 'svelte'; import { creerMachineChamp } from '../primitives/champ.js'; import Commande from '../primitives/Commande.svelte'; import { creerGeste } from '../primitives/geste.js'; import Refus from '../primitives/Refus.svelte'; + import { cleDeRaison } from './conflits.js'; import { COLONNES, basculerGroupe, celluleSuivante, cheminDe, cleDeColonne, entreeValide, lignesVisibles } from './grille.js'; - let { lignes, groupes = null, replies = $bindable(new Set()), tri = $bindable(null), supprimer } = $props(); + let { + lignes, + groupes = null, + replies = $bindable(new Set()), + tri = $bindable(null), + supprimer, + conflits = new Map(), + enConflit = new Map(), + } = $props(); const t = getContext('t'); const seance = getContext('seance'); @@ -257,11 +278,17 @@ return true; } + // Exclut ou réintègre la personne ; écrit, la ligne suit son nouveau + // groupe, comme après une saisie validée, quand sa cellule avait le focus. function basculerExclusion(personne) { const nom = personne.exclu ? 'reintegrerParticipant' : 'exclureParticipant'; refusCellule = null; + const garderFocus = document.activeElement === cellule(personne.id, 'exclu'); try { - Promise.resolve(seance.executer(nom, { id: personne.id })).catch((erreur) => refuser(personne.id, 'exclu', erreur)); + Promise.resolve(seance.executer(nom, { id: personne.id })).then( + () => suivreLigne(personne.id, 'exclu', garderFocus), + (erreur) => refuser(personne.id, 'exclu', erreur), + ); } catch (erreur) { refuser(personne.id, 'exclu', erreur); } @@ -337,6 +364,9 @@ {:else} {personne[colonne] ?? ''} + {#if colonne === 'nom' && enConflit.has(personne.id)} + {t('participants.conflit.personne', { regles: enConflit.get(personne.id) })} + {/if} {/if} {#if refus !== null} (refusCellule = null)} /> @@ -388,10 +418,29 @@ {groupe.libelle} {/if} {t('participants.groupe.compte', { compte: groupe.compte })} + {#if conflits.has(groupe.cle)} + {t('participants.conflit.groupe', { nombre: conflits.get(groupe.cle).length })} + {/if} {#if !replie} + {#if groupe.sousGroupes === null && conflits.has(groupe.cle)} + + + + + + {/if} {#if groupe.sousGroupes === null} {#each groupe.lignes as personne (personne.id)} {@render rangee(personne, rangs.get(personne.id), groupe.cle)} @@ -544,6 +593,23 @@ overflow-wrap: anywhere; } + /* La marque d'un conflit : un cadre plein, de l'encre du texte, autour + d'un texte qui nomme le conflit ; la forme ne dépend d'aucune couleur. */ + .marque-conflit { + display: inline-block; + padding: 0 var(--espace-1); + border: 2px solid var(--couleur-page-texte); + border-radius: var(--rayon-1); + background-color: var(--couleur-page-fond); + color: var(--couleur-page-texte); + font-weight: bold; + } + + .conflits ul { + margin: 0; + padding-left: var(--espace-5); + } + .commandes { display: flex; flex-wrap: wrap; diff --git a/src/interface/participants/Participants.navigateur.test.js b/src/interface/participants/Participants.navigateur.test.js index e3a83d3..96487e6 100644 --- a/src/interface/participants/Participants.navigateur.test.js +++ b/src/interface/participants/Participants.navigateur.test.js @@ -6,7 +6,8 @@ // 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, le collage, et la liste groupée par une -// colonne ou plusieurs, ses groupes imbriqués, repliés et dépliés. Chaque épreuve monte App par +// colonne ou plusieurs, ses groupes imbriqués, repliés et dépliés, et les +// conflits du placement montré sous ses groupes. 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 @@ -23,10 +24,12 @@ import { creerTraduction } from '../../application/traduction.js'; import { apercevoir, cleNormalisee, decoder, exporterParticipants } from '../../application/csv.js'; import { statuts } from '../../application/places.js'; import { STATUT, nombrePlacesManquantes, normaliser } from '../../moteur/configuration.js'; -import { configurationDepuisCharge } from '../../stockage/document.js'; +import { configurationDepuisCharge, creerCharge } from '../../stockage/document.js'; +import { serialiser } from '../../stockage/canonique.js'; import { lireJournal } from '../../stockage/journal.js'; import { SUFFIXES } from '../../stockage/noms.js'; import { formeNommee } from '../../stockage/placements.js'; +import { VERSION } from '../../version.genere.js'; import { monterApp } from '../../../test/navigateur/montage.js'; import { pointer, touche } from '../../../test/navigateur/entrees.js'; import { visible } from '../../../test/navigateur/rendu.js'; @@ -1354,4 +1357,247 @@ describe('la liste groupée par une colonne ou plusieurs', () => { expect(idsDuGroupe(m, destination)).toEqual([pervenche, lacasse]); expect(document.activeElement).toBe(cellule(m, lacasse, 'appartenance')); }); + + test('groupée par exclusion, Entrée sur l’exclusion : la ligne change de groupe, garde le focus, déplie son groupe', async () => { + const m = await evenementOuvert({ tables: 1, personnes: CINQ }); + await grouperPar(m, 'exclu'); + const lacasse = idDe(m, 'Lacasse'); + const avant = entrees(m).length; + + cellule(m, lacasse, 'exclu').focus(); + await touche('Enter'); + await attendreSeance(m); + expect(entrees(m)).toHaveLength(avant + 1); + expect(participants(m).find(({ id }) => id === lacasse).exclu).toBe(true); + expect(idsDuGroupe(m, 'exclu:oui')).toContain(lacasse); + expect(document.activeElement).toBe(cellule(m, lacasse, 'exclu')); + + // Vers le groupe des présentes, replié : il se déplie, la cellule garde + // le focus. + await cliquer(bascule(m, 'exclu:non')); + expect(bascule(m, 'exclu:non').getAttribute('aria-expanded')).toBe('false'); + cellule(m, lacasse, 'exclu').focus(); + await touche('Enter'); + await attendreSeance(m); + expect(entrees(m)).toHaveLength(avant + 2); + expect(participants(m).find(({ id }) => id === lacasse).exclu).toBe(false); + expect(bascule(m, 'exclu:non').getAttribute('aria-expanded')).toBe('true'); + expect(idsDuGroupe(m, 'exclu:non')).toContain(lacasse); + expect(document.activeElement).toBe(cellule(m, lacasse, 'exclu')); + }); + + describe('les conflits du placement montré (§ 7.5)', () => { + // Deux tables de trois places, numérotées 4 (identifiant 1) et 1 + // (identifiant 2), deux tours. 1 et 2 du Club des Merles, 3 et 4 du + // Cercle des Pinsons, 5 et 6 sans appartenance, 7 des Pinsons, exclue. + const SEPT = [ + ['Ombrelle', 'Iris', CLUB], + ['Fontaine', 'Noé', CLUB], + ['Pervenche', 'Théo', 'Cercle des Pinsons'], + ['Lacasse', 'Ondine', 'Cercle des Pinsons'], + ['Grisaille', null, null], + ['Bruyère', 'Anouk', null], + ['Sarcelle', 'Capucine', 'Cercle des Pinsons'], + ]; + // Proposition 1 : au tour 1, 1 et 2, collègues, à la table 4 ; au tour + // 2, 4 et 5 se revoient à la table 4, 2 et 3 à la table 1, 1 revient à + // la table 4 et 6 à la table 1. Proposition 2 : au tour 2, 2 et 3 se + // revoient à la table 4 et y reviennent, 4 et 5 de même à la table 1. + const PLANS = [ + [ + [[1, 2, 3], [4, 5, 6]], + [[1, 4, 5], [2, 3, 6]], + ], + [ + [[1, 2, 3], [4, 5, 6]], + [[2, 3, 6], [1, 4, 5]], + ], + ]; + const TIRET = '\u{2014}'; + + const planFichier = (tours) => ({ + siegesAttribues: false, + tables: [1, 2], + capacites: [3, 3], + tours: tours.length, + participants: [1, 2, 3, 4, 5, 6], + placement: tours.map((listes) => ({ sieges: structuredClone(listes), reserve: [] })), + }); + + function chargeSept() { + const charge = creerCharge({ id: 'evt-conflits', nom: EVENEMENT, siegesParDefaut: 3, tours: 2 }); + charge.participants = SEPT.map(([nom, prenom, appartenance], rang) => ({ + id: rang + 1, + nom, + prenom, + appartenance, + courriel: null, + titrePressenti: null, + notes: null, + exclu: rang === 6, + })); + charge.tables = [ + { id: 1, numero: 4, sieges: null, forme: 'ronde', position: { x: 0, y: 0 } }, + { id: 2, numero: 1, sieges: null, forme: 'ronde', position: { x: 300, y: 0 } }, + ]; + charge.propositions = PLANS.map((tours, rang) => ({ + id: rang + 1, + graine: rang + 1, + arret: 40, + historique: 1000, + produitVersion: VERSION.affichee, + ...planFichier(tours), + })); + charge.prochainsIds = { participant: 8, table: 3, proposition: 3 }; + charge.evenement.etat = 'propose'; + return charge; + } + + // L'événement de la charge, ouvert depuis le disque simulé, en écriture, + // la section des participants ouverte. + async function ouvrirSept() { + const texte = serialiser(chargeSept(), { revision: 1, produitVersion: VERSION.affichee }); + const m = await monterApp({ fichiers: [{ racine: 'documents', chemin: `soiree${SUFFIXES.etat}`, texte }] }); + montes.push(m); + await m.seance.ouvrir('soiree'); + await m.seance.passerEnEcriture(); + m.ui.allerA('participants'); + await rendu(); + return m; + } + + // Un geste de la ligne d'une proposition dans sa section, puis le retour + // aux participants par la navigation des sections. + async function gesteDeProposition(m, id, nom) { + m.ui.allerA('propositions'); + await rendu(); + await cliquer(m.racine.querySelector(`[data-section="propositions"] [data-proposition="${id}"] [data-commande="${nom}"]`)); + await attendreSeance(m); + await cliquer(m.racine.querySelector('[data-commande="section"][data-cible="participants"]')); + expect(section(m)).not.toBeNull(); + } + + const texteDe = (element) => element.textContent.replace(/\s+/g, ' ').trim(); + const marqueDeGroupe = (m, cle) => groupe(m, cle).querySelector('[data-groupe-conflits]'); + const conflitsDuGroupe = (m, cle) => [...grille(m).querySelectorAll(`[data-conflits="${cle}"] [data-conflit]`)].map(texteDe); + const marqueDePersonne = (m, id) => ligne(m, id).querySelector('[data-conflit-personne]'); + const toutesLesMarques = (m) => grille(m).querySelectorAll('[data-groupe-conflits], [data-conflits], [data-conflit], [data-conflit-personne]'); + + // Ce que montre la liste groupée par appartenance sous la proposition 1, + // ou sous un retenu du même plan. + const SOUS_LE_PLAN_1 = { + [MERLES]: [ + `Tour 1, table 4 : Iris Ombrelle et Noé Fontaine ${TIRET} même appartenance`, + `Tour 2, table 4 : Iris Ombrelle ${TIRET} revient à la table 4 (déjà au tour 1)`, + `Tour 2, table 1 : Noé Fontaine et Théo Pervenche ${TIRET} se revoient (déjà à la table 4 au tour 1)`, + ], + [PINSONS]: [ + `Tour 2, table 4 : Ondine Lacasse et Grisaille ${TIRET} se revoient (déjà à la table 1 au tour 1)`, + `Tour 2, table 1 : Noé Fontaine et Théo Pervenche ${TIRET} se revoient (déjà à la table 4 au tour 1)`, + ], + vide: [ + `Tour 2, table 4 : Ondine Lacasse et Grisaille ${TIRET} se revoient (déjà à la table 1 au tour 1)`, + `Tour 2, table 1 : Anouk Bruyère ${TIRET} revient à la table 1 (déjà au tour 1)`, + ], + }; + + async function attendreSousLePlan1(m) { + for (const [cle, attendues] of Object.entries(SOUS_LE_PLAN_1)) { + expect(texteDe(marqueDeGroupe(m, cle)), cle).toBe(t('participants.conflit.groupe', { nombre: attendues.length })); + expect(conflitsDuGroupe(m, cle), cle).toEqual(attendues); + } + expect(texteDe(marqueDePersonne(m, 1))).toBe('en conflit : même appartenance, revient à une table'); + expect(texteDe(marqueDePersonne(m, 3))).toBe('en conflit : se revoient'); + expect(marqueDePersonne(m, 7)).toBeNull(); + } + + test('voir la proposition 1 : marque et nombre par groupe, une ligne par conflit, dans chaque groupe touché ; rien ne s’écrit', async () => { + const m = await ouvrirSept(); + await gesteDeProposition(m, 1, 'voir'); + expect(m.ui.etat().proposition).toBe(1); + const avant = { journal: texteJournal(m), etat: texteEtat(m) }; + await grouperPar(m, 'appartenance'); + expect(groupes(m).map((g) => g.dataset.groupe)).toEqual([PINSONS, MERLES, 'vide']); + await attendreSousLePlan1(m); + + // La marque se lit en texte, dans une forme : un cadre de trait plein, + // non une seule couleur. + const marque = getComputedStyle(marqueDeGroupe(m, MERLES)); + expect(marque.borderTopStyle).toBe('solid'); + expect(Number.parseFloat(marque.borderTopWidth)).toBeGreaterThanOrEqual(2); + // Les lignes de conflit d'un groupe précèdent ses lignes de personnes. + const premiere = grille(m).querySelector(`[data-conflits="${MERLES}"]`); + expect(premiere.compareDocumentPosition(ligne(m, 1)) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(premiere.compareDocumentPosition(groupe(m, MERLES)) & Node.DOCUMENT_POSITION_PRECEDING).toBeTruthy(); + + // Replié, le groupe garde sa marque et retire ses lignes de conflit. + await cliquer(bascule(m, MERLES)); + expect(texteDe(marqueDeGroupe(m, MERLES))).toBe(t('participants.conflit.groupe', { nombre: 3 })); + expect(conflitsDuGroupe(m, MERLES)).toEqual([]); + expect({ journal: texteJournal(m), etat: texteEtat(m) }).toEqual(avant); + }); + + test('imbriqués : un en-tête compte son sous-arbre, les lignes vont au plus étroit groupe', async () => { + const m = await ouvrirSept(); + await gesteDeProposition(m, 1, 'voir'); + await grouperPar(m, 'appartenance'); + await grouperPar(m, 'prenom', 2); + expect(texteDe(marqueDeGroupe(m, MERLES))).toBe(t('participants.conflit.groupe', { nombre: 3 })); + expect(conflitsDuGroupe(m, MERLES)).toEqual([]); + const [iris, noe] = ['texte:iris', 'texte:noe'].map((segment) => `${MERLES}/${segment}`); + expect(texteDe(marqueDeGroupe(m, iris))).toBe(t('participants.conflit.groupe', { nombre: 2 })); + expect(conflitsDuGroupe(m, iris)).toEqual([SOUS_LE_PLAN_1[MERLES][0], SOUS_LE_PLAN_1[MERLES][1]]); + expect(conflitsDuGroupe(m, noe)).toEqual([SOUS_LE_PLAN_1[MERLES][0], SOUS_LE_PLAN_1[MERLES][2]]); + // Capucine Sarcelle, exclue, n'est dans aucun conflit : son groupe n'a + // pas de marque. + expect(marqueDeGroupe(m, `${PINSONS}/texte:capucine`)).toBeNull(); + }); + + test('retenir la 1 : mêmes marques, lues sur le retenu ; voir la 2 montre les siennes', async () => { + const m = await ouvrirSept(); + await gesteDeProposition(m, 1, 'retenir'); + expect(m.seance.etat().charge.retenu?.proposition).toBe(1); + expect(m.ui.etat().proposition).toBeNull(); + await grouperPar(m, 'appartenance'); + await attendreSousLePlan1(m); + + await gesteDeProposition(m, 2, 'voir'); + expect(m.ui.etat().proposition).toBe(2); + await grouperPar(m, 'appartenance'); + expect(texteDe(marqueDeGroupe(m, PINSONS))).toBe(t('participants.conflit.groupe', { nombre: 4 })); + expect(conflitsDuGroupe(m, PINSONS)).toEqual([ + `Tour 2, table 4 : Noé Fontaine et Théo Pervenche ${TIRET} se revoient (déjà à la table 4 au tour 1)`, + `Tour 2, table 4 : Théo Pervenche ${TIRET} revient à la table 4 (déjà au tour 1)`, + `Tour 2, table 1 : Ondine Lacasse et Grisaille ${TIRET} se revoient (déjà à la table 1 au tour 1)`, + `Tour 2, table 1 : Ondine Lacasse ${TIRET} revient à la table 1 (déjà au tour 1)`, + ]); + }); + + test('une règle désactivée retire ses conflits des groupes et des personnes', async () => { + const m = await ouvrirSept(); + await gesteDeProposition(m, 1, 'voir'); + await grouperPar(m, 'appartenance'); + expect(texteDe(marqueDeGroupe(m, MERLES))).toBe(t('participants.conflit.groupe', { nombre: 3 })); + await m.seance.executer('modifierReglage', { cle: 'separerAppartenances', valeur: false }); + await attendreSeance(m); + expect(texteDe(marqueDeGroupe(m, MERLES))).toBe(t('participants.conflit.groupe', { nombre: 2 })); + expect(conflitsDuGroupe(m, MERLES)).toEqual(SOUS_LE_PLAN_1[MERLES].slice(1)); + expect(texteDe(marqueDePersonne(m, 1))).toBe('en conflit : revient à une table'); + }); + + test('ni proposition montrée ni retenu : aucune marque ; sans groupement, rien ne change', async () => { + const m = await ouvrirSept(); + expect(m.ui.etat().proposition).toBeNull(); + await grouperPar(m, 'appartenance'); + expect(groupes(m)).toHaveLength(3); + expect(toutesLesMarques(m)).toHaveLength(0); + + await gesteDeProposition(m, 1, 'voir'); + expect(m.ui.etat().proposition).toBe(1); + expect(selecteur(m).value).toBe(''); + expect(lignes(m)).toHaveLength(7); + expect(toutesLesMarques(m)).toHaveLength(0); + expect(texteDe(cellule(m, 1, 'nom'))).toBe('Ombrelle'); + }); + }); }); diff --git a/src/interface/participants/SectionParticipants.svelte b/src/interface/participants/SectionParticipants.svelte index c0c09ed..69b65d4 100644 --- a/src/interface/participants/SectionParticipants.svelte +++ b/src/interface/participants/SectionParticipants.svelte @@ -22,6 +22,14 @@ // dans la section, le temps qu'elle est ouverte : changer de groupement // déplie tout, et rien ne s'écrit. // + // Groupée, la liste lit les conflits du placement que le plan montre — + // la proposition demandée, ui.proposition, sinon le retenu — par + // conflitsDuPlacement (src/application/conflits_placement.js), recalculés + // à chaque changement de la charge, du groupement ou de la proposition + // montrée, puis rangés sous les groupes par conflitsDesGroupes, que Grille + // écrit. Sans groupement, ou sans placement montré, aucun conflit ne se + // lit. + // // Supprimer une personne qui porte des réservations ouvre la fenêtre à // confirmer, qui les nomme ; sans réservation, ou quand la séance refuse // déjà tout geste modifiant, la commande part et la séance répond. L'export @@ -36,6 +44,8 @@ import Formulaire from './Formulaire.svelte'; import Grille from './Grille.svelte'; import { statuts } from '../../application/places.js'; + import { conflitsDuPlacement } from '../../application/conflits_placement.js'; + import { conflitsDesGroupes, lignesDeConflit, reglesParPersonne } from './conflits.js'; import { choisirGroupement, cleDeGroupement, @@ -68,6 +78,11 @@ const parStatut = $derived(groupement.includes('statut') && charge !== null ? statuts(charge).parId : null); const groupes = $derived(groupement.length === 0 ? null : groupesGrille(lignes, groupement, { tri, cleNormalisee, statuts: parStatut })); const niveaux = $derived(niveauxAffiches(groupement)); + const conflits = $derived( + groupes === null || charge === null ? null : lignesDeConflit(conflitsDuPlacement(charge, { proposition: $ui.proposition }), participants), + ); + const conflitsParGroupe = $derived(groupes === null ? new Map() : conflitsDesGroupes(groupes, conflits)); + const enConflit = $derived(reglesParPersonne(conflits)); const personneFiche = $derived($ui.fiche === null ? null : (participants.find(({ id }) => id === $ui.fiche) ?? null)); function choisirNiveau(rang, colonne) { @@ -138,7 +153,7 @@
- + {#if participants.length === 0}

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

{:else if lignes.length === 0} diff --git a/src/interface/participants/conflits.js b/src/interface/participants/conflits.js new file mode 100644 index 0000000..f37ed27 --- /dev/null +++ b/src/interface/participants/conflits.js @@ -0,0 +1,98 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Les conflits du placement montré dans la liste groupée (§ 7.5, § 10.2) : +// ceux de conflitsDuPlacement (src/application/conflits_placement.js), que +// SectionParticipants lit et que Grille range sous ses groupes. Un groupe, +// de tout niveau, porte chaque conflit qui touche l'une de ses lignes ; une +// personne, les règles de ses conflits. Aucune fonction ne modifie ce +// qu'elle reçoit. +import { nomAffiche } from '../../application/personnes.js'; + +/** Les règles qui se localisent à une table, dans l'ordre de conflitsParTable. */ +export const REGLES = Object.freeze(['separerAppartenances', 'nouveauxVoisins', 'nouvelleTable']); + +const CLES_RAISONS = new Map(REGLES.map((regle) => [regle, `participants.conflit.raison.${regle}`])); + +/** + * La clé du texte qui dit la raison d'un conflit de la règle donnée. + * + * @param {string} regle l'une de REGLES + * @returns {string} + * @throws {RangeError} une autre règle + */ +export function cleDeRaison(regle) { + const cle = CLES_RAISONS.get(regle); + if (cle === undefined) throw new RangeError(`règle inconnue : ${JSON.stringify(regle)}`); + return cle; +} + +/** + * Les conflits de chaque groupe de groupesGrille (grille.js), de tout + * niveau : ceux dont l'une des personnes est parmi les lignes du groupe — + * celles de tous ses sous-groupes —, dans l'ordre reçu. Un conflit dont les + * deux personnes sont dans deux groupes paraît dans chacun ; un parent le + * compte une fois. Un groupe sans conflit n'a pas d'entrée. + * + * @param {Array<{cle: string, lignes: Array<{id: number}>, sousGroupes: Array|null}>} groupes + * @param {Array<{personnes: number[]}>|null} conflits null : aucun placement montré + * @returns {Map>} clé du groupe → ses conflits + */ +export function conflitsDesGroupes(groupes, conflits) { + const parGroupe = new Map(); + if (conflits === null || conflits.length === 0) return parGroupe; + const visiter = (groupe) => { + const ids = new Set(groupe.lignes.map(({ id }) => id)); + const siens = conflits.filter(({ personnes }) => personnes.some((id) => ids.has(id))); + if (siens.length > 0) parGroupe.set(groupe.cle, siens); + for (const sousGroupe of groupe.sousGroupes ?? []) visiter(sousGroupe); + }; + for (const groupe of groupes) visiter(groupe); + return parGroupe; +} + +/** + * Les règles des conflits de chaque personne qui en a un, sans doublon, + * dans l'ordre de REGLES. + * + * @param {Array<{regle: string, personnes: number[]}>|null} conflits + * @returns {Map} + */ +export function reglesParPersonne(conflits) { + const vues = new Map(); + for (const { regle, personnes } of conflits ?? []) { + for (const id of personnes) { + if (!vues.has(id)) vues.set(id, new Set()); + vues.get(id).add(regle); + } + } + return new Map([...vues].map(([id, regles]) => [id, REGLES.filter((regle) => regles.has(regle))])); +} + +/** + * Une ligne par conflit, dans l'ordre reçu, que la grille écrit : sa clé, + * unique parmi les conflits d'un placement — tour, table, règle et + * personnes —, le tour, le numéro affiché de la table, la règle, les + * personnes du conflit, le nom affiché de chacune dans leur ordre, et ses + * rencontres antérieures. Une personne que participants n'a pas n'est pas + * nommée. Les lignes se rangent sous les groupes par conflitsDesGroupes, + * comme les conflits ; null, aucun placement montré, rend null. + * + * @param {Array|null} conflits ceux de conflitsDuPlacement + * @param {Array<{id: number, nom: string, prenom: string|null}>} participants + * @returns {Array<{cle: string, tour: number, numero: number, regle: string, personnes: number[], + * noms: string[], deja: Array<{tour: number, numero: number}>}>|null} + */ +export function lignesDeConflit(conflits, participants) { + if (conflits === null) return null; + const parId = new Map(participants.map((p) => [p.id, p])); + return conflits.map(({ tour, table, numero, regle, personnes, deja }) => ({ + cle: `${tour}:${table}:${regle}:${personnes.join('-')}`, + tour, + numero, + regle, + personnes, + noms: personnes.filter((id) => parId.has(id)).map((id) => nomAffiche(parId.get(id))), + deja, + })); +} diff --git a/src/interface/participants/conflits.test.js b/src/interface/participants/conflits.test.js new file mode 100644 index 0000000..88ea103 --- /dev/null +++ b/src/interface/participants/conflits.test.js @@ -0,0 +1,160 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +// Les conflits du placement montré dans la liste groupée (§ 7.5, § 10.2) : +// les conflits de chaque groupe, de tout niveau, les règles de chaque +// personne, les lignes de conflit nommées et leurs textes. Les noms +// d'épreuve sont inventés. +import assert from 'node:assert/strict'; +import { describe, test } from '../../../test/lanceur.js'; +import { cleNormalisee } from '../../application/csv.js'; +import { TABLES_APPLICATION } from '../../application/libelles.js'; +import { creerTraduction } from '../../application/traduction.js'; +import { TABLES_INTERFACE } from '../libelles.js'; +import { REGLES, cleDeRaison, conflitsDesGroupes, lignesDeConflit, reglesParPersonne } from './conflits.js'; +import { groupesGrille, lignesGrille } from './grille.js'; + +const { t } = creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]); + +const personne = (id, nom, prenom, appartenance, exclu = false) => ({ + id, + nom, + prenom, + appartenance, + courriel: null, + titrePressenti: null, + notes: null, + exclu, +}); + +// 1 et 2 du Club des Merles, 3 et 4 des Pinsons, 5 sans appartenance. +const PERSONNES = [ + personne(1, 'Ombrelle', 'Iris', 'Club des Merles'), + personne(2, 'Fontaine', 'Noé', 'Club des Merles'), + personne(3, 'Pervenche', 'Théo', 'Pinsons'), + personne(4, 'Lacasse', 'Ondine', 'Pinsons'), + personne(5, 'Grisaille', null, null), +]; + +const conflit = (tour, table, numero, regle, personnes, deja = []) => ({ tour, table, numero, regle, personnes, deja }); + +// Une paire de collègues des Merles, une paire revue qui traverse les +// Merles et les Pinsons, une personne revenue sans appartenance. +const C1 = conflit(1, 1, 4, 'separerAppartenances', [1, 2]); +const C2 = conflit(2, 2, 1, 'nouveauxVoisins', [2, 3], [{ tour: 1, numero: 4 }]); +const C3 = conflit(2, 1, 4, 'nouvelleTable', [5], [{ tour: 1, numero: 4 }]); +const CONFLITS = [C1, C2, C3]; + +const lignes = lignesGrille(PERSONNES, { tri: null, filtre: '' }, cleNormalisee); +const grouper = (colonnes, de = lignes) => groupesGrille(de, colonnes, { tri: null, cleNormalisee, statuts: null }); +const cleDe = (groupes, libelle) => groupes.find((g) => (g.libelle ?? g.cleLibelle) === libelle).cle; + +describe('conflitsDesGroupes : les conflits de chaque groupe, de tout niveau', () => { + test('par appartenance : chaque groupe a les conflits qui touchent l’une de ses lignes, dans l’ordre reçu ; un conflit entre deux groupes paraît dans chacun', () => { + const groupes = grouper(['appartenance']); + assert.equal(groupes.length, 3); + const parGroupe = conflitsDesGroupes(groupes, CONFLITS); + assert.deepEqual(parGroupe.get(cleDe(groupes, 'Club des Merles')), [C1, C2]); + assert.deepEqual(parGroupe.get(cleDe(groupes, 'Pinsons')), [C2]); + assert.deepEqual(parGroupe.get(cleDe(groupes, 'participants.groupe.vide')), [C3]); + assert.equal(parGroupe.size, 3); + }); + + test('imbriqués : un parent compte une fois chaque conflit de ses sous-groupes ; un groupe sans conflit n’a pas d’entrée', () => { + const groupes = grouper(['appartenance', 'prenom']); + const merles = groupes.find(({ libelle }) => libelle === 'Club des Merles'); + const parGroupe = conflitsDesGroupes(groupes, CONFLITS); + assert.deepEqual(parGroupe.get(merles.cle), [C1, C2]); + const [iris, noe] = ['Iris', 'Noé'].map((prenom) => merles.sousGroupes.find(({ libelle }) => libelle === prenom).cle); + assert.deepEqual(parGroupe.get(iris), [C1]); + assert.deepEqual(parGroupe.get(noe), [C1, C2]); + const pinsons = groupes.find(({ libelle }) => libelle === 'Pinsons'); + const ondine = pinsons.sousGroupes.find(({ libelle }) => libelle === 'Ondine').cle; + assert.equal(parGroupe.has(ondine), false); + assert.deepEqual(parGroupe.get(pinsons.cle), [C2]); + }); + + test('le filtre passe d’abord : une personne qui n’est plus listée n’attire pas son conflit dans un groupe', () => { + const filtrees = lignesGrille(PERSONNES, { tri: null, filtre: 'pinsons' }, cleNormalisee); + const groupes = grouper(['appartenance'], filtrees); + assert.equal(groupes.length, 1); + assert.deepEqual([...conflitsDesGroupes(groupes, CONFLITS)], [[groupes[0].cle, [C2]]]); + }); + + test('aucun conflit, ou null : aucune entrée', () => { + const groupes = grouper(['appartenance']); + assert.equal(conflitsDesGroupes(groupes, []).size, 0); + assert.equal(conflitsDesGroupes(groupes, null).size, 0); + }); +}); + +describe('reglesParPersonne et lignesDeConflit', () => { + test('les règles de chaque personne en conflit, sans doublon, dans l’ordre de REGLES', () => { + assert.deepEqual(REGLES, ['separerAppartenances', 'nouveauxVoisins', 'nouvelleTable']); + const regles = reglesParPersonne([C2, C1, conflit(2, 1, 4, 'nouveauxVoisins', [1, 2]), C3]); + assert.deepEqual( + [...regles].sort(([a], [b]) => a - b), + [ + [1, ['separerAppartenances', 'nouveauxVoisins']], + [2, ['separerAppartenances', 'nouveauxVoisins']], + [3, ['nouveauxVoisins']], + [5, ['nouvelleTable']], + ], + ); + assert.equal(reglesParPersonne(null).size, 0); + }); + + test('chaque conflit rend une ligne : clé unique, lieu, personnes et leurs noms affichés dans leur ordre, règle et rencontres antérieures ; null rend null', () => { + const lignesRendues = lignesDeConflit(CONFLITS, PERSONNES); + assert.deepEqual(lignesRendues, [ + { cle: '1:1:separerAppartenances:1-2', tour: 1, numero: 4, regle: 'separerAppartenances', personnes: [1, 2], noms: ['Iris Ombrelle', 'Noé Fontaine'], deja: [] }, + { + cle: '2:2:nouveauxVoisins:2-3', + tour: 2, + numero: 1, + regle: 'nouveauxVoisins', + personnes: [2, 3], + noms: ['Noé Fontaine', 'Théo Pervenche'], + deja: [{ tour: 1, numero: 4 }], + }, + { cle: '2:1:nouvelleTable:5', tour: 2, numero: 4, regle: 'nouvelleTable', personnes: [5], noms: ['Grisaille'], deja: [{ tour: 1, numero: 4 }] }, + ]); + assert.equal(lignesDeConflit(null, PERSONNES), null); + // Les lignes se rangent sous les groupes comme les conflits. + const groupes = grouper(['appartenance']); + assert.deepEqual(conflitsDesGroupes(groupes, lignesRendues).get(cleDe(groupes, 'Pinsons')), [lignesRendues[1]]); + }); + + test('la clé de la raison de chaque règle existe ; une autre règle lève', () => { + for (const regle of REGLES) assert.equal(typeof t(cleDeRaison(regle), { numero: 4, deja: [] }), 'string'); + assert.throws(() => cleDeRaison('varierAppartenances'), RangeError); + }); +}); + +describe('les textes des conflits', () => { + test('le lieu, la liaison, les raisons, la marque d’un groupe et celle d’une personne', () => { + assert.equal(t('participants.conflit.lieu', { tour: 2, numero: 4 }), 'Tour 2, table 4 :'); + assert.equal(t('participants.conflit.et'), 'et'); + assert.equal(t(cleDeRaison('separerAppartenances'), { numero: 4, deja: [] }), '\u{2014} même appartenance'); + assert.equal(t(cleDeRaison('nouveauxVoisins'), { numero: 4, deja: [{ tour: 1, numero: 1 }] }), '\u{2014} se revoient (déjà à la table 1 au tour 1)'); + assert.equal( + t(cleDeRaison('nouveauxVoisins'), { numero: 4, deja: [{ tour: 1, numero: 1 }, { tour: 3, numero: 2 }] }), + '\u{2014} se revoient (déjà à la table 1 au tour 1, à la table 2 au tour 3)', + ); + assert.equal(t(cleDeRaison('nouvelleTable'), { numero: 4, deja: [{ tour: 1, numero: 4 }] }), '\u{2014} revient à la table 4 (déjà au tour 1)'); + assert.equal( + t(cleDeRaison('nouvelleTable'), { numero: 4, deja: [{ tour: 1, numero: 4 }, { tour: 2, numero: 4 }, { tour: 3, numero: 4 }] }), + '\u{2014} revient à la table 4 (déjà aux tours 1, 2 et 3)', + ); + // Sans rencontre antérieure connue, la raison se tait sur elles. + assert.equal(t(cleDeRaison('nouveauxVoisins'), { numero: 4, deja: [] }), '\u{2014} se revoient'); + assert.equal(t(cleDeRaison('nouvelleTable'), { numero: 4, deja: [] }), '\u{2014} revient à la table 4'); + assert.equal(t('participants.conflit.groupe', { nombre: 1 }), 'en conflit : 1 conflit'); + assert.equal(t('participants.conflit.groupe', { nombre: 3 }), 'en conflit : 3 conflits'); + assert.equal(t('participants.conflit.personne', { regles: ['separerAppartenances'] }), 'en conflit : même appartenance'); + assert.equal( + t('participants.conflit.personne', { regles: ['separerAppartenances', 'nouveauxVoisins', 'nouvelleTable'] }), + 'en conflit : même appartenance, se revoient, revient à une table', + ); + }); +}); diff --git a/test/navigateur/ecrans.js b/test/navigateur/ecrans.js index 4c5a8b4..d5acc98 100644 --- a/test/navigateur/ecrans.js +++ b/test/navigateur/ecrans.js @@ -492,6 +492,26 @@ export const ECRANS = Object.freeze([ return m.racine; }, }, + { + nom: 'participants.conflits', + mode: 'ecriture', + async atteindre(contexte) { + // La liste groupée par appartenance, choisie au clavier, sous la + // proposition montrée au plan : un groupe en conflit porte sa marque, + // ses lignes de conflit et celles de ses personnes. + const m = await ouvrirEvenement(contexte, chargePrincipale(), { ecriture: true, section: 'participants' }); + const choix = await element(m, '[data-section="participants"] [data-champ="grouper"]'); + choix.focus(); + for (let essai = 0; essai < choix.options.length && choix.value !== 'appartenance'; essai += 1) { + await touche('ArrowDown'); + flushSync(); + } + await element(m, '[data-groupe] [data-groupe-conflits]'); + await element(m, '[data-conflits] [data-conflit]'); + await element(m, '[data-participant] [data-conflit-personne]'); + return m.racine; + }, + }, { nom: 'import.apercu', mode: 'ecriture',