[ADD] participants: conflicts of the shown plan inside the list groups

When the list is grouped and a proposition is shown, or a plan retained,
each group whose people are in a conflict carries a mark and a count, and
lists every conflict: the tour, the table number, the people, and the
rule broken (same appartenance, met again, back at a table). The
conflicts are read from the engine's per-table conflicts, as the plan
reads them, from the same placement and the same earlier tours, so both
screens give the same numbers. Nothing is written to the event.
Checked: 2831 node, 571 browser tests, from the index alone.

--- FR ---

[ADD] participants : conflits du plan montré dans les groupes

Quand la liste est groupée et qu'une proposition est montrée, ou un plan
retenu, chaque groupe dont des personnes sont en conflit porte une marque
et un compte, et liste chaque conflit : le tour, le numéro de table, les
personnes et la règle enfreinte (même appartenance, se revoient, revient à
une table). Les conflits se lisent dans ceux du moteur par table, comme le
plan, sur le même placement et les mêmes tours antérieurs : les deux
écrans donnent les mêmes chiffres. Rien ne s'écrit dans l'événement.
Vérifié : 2831 node, 571 navigateur, depuis l'index seul.

Assisted-by: Claude Opus 5.5
This commit is contained in:
Mathieu Benoit 2026-10-08 04:32:30 -04:00
parent fbff3a6c5f
commit 5f62698c82
9 changed files with 1015 additions and 7 deletions

View file

@ -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;
}

View file

@ -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 }));
});
});

View file

@ -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'],

View file

@ -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 @@
</span>
{:else}
<span class="valeur">{personne[colonne] ?? ''}</span>
{#if colonne === 'nom' && enConflit.has(personne.id)}
<span class="marque-conflit" data-conflit-personne>{t('participants.conflit.personne', { regles: enConflit.get(personne.id) })}</span>
{/if}
{/if}
{#if refus !== null}
<Refus id={idRefus(personne.id, colonne)} {refus} lever={() => (refusCellule = null)} />
@ -388,10 +418,29 @@
<span class="libelle valeur" data-groupe-libelle>{groupe.libelle}</span>
{/if}
<span class="compte" data-groupe-compte>{t('participants.groupe.compte', { compte: groupe.compte })}</span>
{#if conflits.has(groupe.cle)}
<span class="marque-conflit" data-groupe-conflits>{t('participants.conflit.groupe', { nombre: conflits.get(groupe.cle).length })}</span>
{/if}
</button>
</th>
</tr>
{#if !replie}
{#if groupe.sousGroupes === null && conflits.has(groupe.cle)}
<tr class="conflits" data-conflits={groupe.cle}>
<td colspan={COLONNES.length + 1}>
<ul>
{#each conflits.get(groupe.cle) as conflit (conflit.cle)}
<li data-conflit>
<span>{t('participants.conflit.lieu', { tour: conflit.tour, numero: conflit.numero })}</span>
{#each conflit.noms as nom, rang (rang)}{' '}{#if rang > 0}<span>{t('participants.conflit.et')}</span>{' '}{/if}<span
class="valeur">{nom}</span
>{/each}{' '}<span>{t(cleDeRaison(conflit.regle), { numero: conflit.numero, deja: conflit.deja })}</span>
</li>
{/each}
</ul>
</td>
</tr>
{/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;

View file

@ -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');
});
});
});

View file

@ -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 @@
<div class="corps">
<div class="liste">
<Grille {lignes} {groupes} bind:replies bind:tri {supprimer} />
<Grille {lignes} {groupes} bind:replies bind:tri {supprimer} conflits={conflitsParGroupe} {enConflit} />
{#if participants.length === 0}
<p>{t('participants.grille.vide')}</p>
{:else if lignes.length === 0}

View file

@ -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<Object>|null}>} groupes
* @param {Array<{personnes: number[]}>|null} conflits null : aucun placement montré
* @returns {Map<string, Array<Object>>} 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<number, string[]>}
*/
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<Object>|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,
}));
}

View file

@ -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',
);
});
});

View file

@ -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',